Skip to content

Commit 0cb2042

Browse files
committed
fix(mothership): end a running job record whose run was removed
1 parent cb11851 commit 0cb2042

3 files changed

Lines changed: 34 additions & 7 deletions

File tree

‎apps/sim/lib/mothership/jobs/finalize.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ async function lastAssistantText(tx: DbOrTx, chatId: string, streamId: string):
7979
* under a row lock, so the turn's own end and a reconciler pass settle it exactly once: one
8080
* outcome, one change to the job's failure streak. An outcome the agent reported is kept; a
8181
* turn that ended well without one is `not_met`, summarized by its last reply, and is not a
82-
* failure. Returns false while the turn is still live.
82+
* failure. A run whose row is gone (its chat was deleted) ends `interrupted`. Returns false while
83+
* the turn is still live.
8384
*/
8485
export async function finalizeJobRun(executionId: string): Promise<boolean> {
8586
let disabledScheduleId: string | null = null
@@ -108,13 +109,17 @@ export async function finalizeJobRun(executionId: string): Promise<boolean> {
108109
.from(copilotRuns)
109110
.where(eq(copilotRuns.streamId, executionId))
110111
.limit(1)
111-
if (!run || !TERMINAL_RUN_STATUSES.includes(run.status)) return false
112+
if (run && !TERMINAL_RUN_STATUSES.includes(run.status)) return false
112113

113114
const reported = REPORTED_JOB_OUTCOMES.find((outcome) => outcome === record.outcome)
114115
const deadlineStopped = toRecord(record.executionData).deadlineStopRequestedAt !== undefined
115116
let outcome: JobRunOutcome
116117
let summary: string
117-
if (run.status === 'complete' && reported) {
118+
if (!run) {
119+
/** A started run's row is gone (its chat was deleted): nothing will ever end it otherwise. */
120+
outcome = 'interrupted'
121+
summary = 'The run was removed before it finished.'
122+
} else if (run.status === 'complete' && reported) {
118123
outcome = reported
119124
summary = record.summary ?? ''
120125
} else if (run.status === 'complete') {
@@ -127,7 +132,7 @@ export async function finalizeJobRun(executionId: string): Promise<boolean> {
127132
outcome = 'failed'
128133
summary = run.error ?? 'The run failed.'
129134
}
130-
const endedAt = run.completedAt ?? new Date()
135+
const endedAt = run?.completedAt ?? new Date()
131136
await tx
132137
.update(jobExecutionLogs)
133138
.set({

‎apps/sim/lib/mothership/jobs/lane.integration.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,24 @@ describe.runIf(Boolean(redisUrl))('the scheduled job lane', () => {
648648
await reconcileUntilSettled(occurrenceId)
649649
})
650650

651+
it('ends a running occurrence whose run was removed, instead of re-reading it forever', async () => {
652+
const { chatId, scheduleId, occurrenceId } = await seedJob()
653+
worker.legs.set(occurrenceId, [{ text: 'Working.', mode: 'hang' }])
654+
await runJobLane(new Date())
655+
await waitFor(async () => worker.open.has(occurrenceId), 'the leg')
656+
controllers.get(occurrenceId)?.abort(new StreamControllerSupersededError())
657+
worker.open.get(occurrenceId)?.response.socket?.destroy()
658+
worker.open.delete(occurrenceId)
659+
await waitFor(async () => !(await redis().get(chatStreamLockKey(chatId))), 'the lock to lapse')
660+
await db.delete(copilotRuns).where(eq(copilotRuns.streamId, occurrenceId))
661+
662+
await reconcileJobRuns(new Date(Date.now() + 6 * 60 * 60_000 + 60_000))
663+
664+
expect(await runsOf(scheduleId)).toEqual([
665+
expect.objectContaining({ status: 'complete', outcome: 'interrupted' }),
666+
])
667+
}, 60_000)
668+
651669
it('ends a queued occurrence whose job was deleted, so the lane goes idle', async () => {
652670
const { scheduleId, occurrenceId } = await seedJob()
653671
await claimDueJobOccurrences(new Date(), 10)

‎apps/sim/lib/mothership/jobs/reconcile.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,16 @@ const admittedBillingRequestId = sql<
4646

4747
/**
4848
* Open records that need this pass, oldest first. Every queued record does; a running one only
49-
* once its turn ended, its time limit passed without a Stop, or its spend-cap link is missing,
50-
* so a backlog of healthy long runs never hides the ones that finished.
49+
* once its turn ended or its run row is gone, its time limit passed without a Stop, or its
50+
* spend-cap link is missing, so a backlog of healthy long runs never hides the ones that finished.
5151
*/
5252
function openJobRuns(status: OpenJobRunStatus, now: Date) {
5353
const needsAction =
5454
status === 'queued'
5555
? undefined
5656
: or(
5757
inArray(copilotRuns.status, TERMINAL_RUN_STATUSES),
58+
isNull(copilotRuns.id),
5859
and(
5960
lte(jobExecutionLogs.startedAt, new Date(now.getTime() - JOB_RUN_TIME_LIMIT_MS)),
6061
sql`${jobExecutionLogs.executionData}->>'deadlineStopRequestedAt' IS NULL`
@@ -101,7 +102,10 @@ export async function reconcileJobRuns(now: Date): Promise<void> {
101102
for (const row of [...queued, ...running]) {
102103
try {
103104
if (!row.runStatus) {
104-
if (row.status !== 'queued') continue
105+
if (row.status === 'running') {
106+
await finalizeJobRun(row.executionId)
107+
continue
108+
}
105109
if (!row.scheduleId) {
106110
await endUnstartedJobRun(
107111
row.executionId,

0 commit comments

Comments
 (0)