Skip to content

Commit bef36e1

Browse files
committed
fix(execution): give a cancelled async run its terminal metadata
The staging integration suite has been failing integ-cancel-async-api-key/async-execution-becomes-cancelled: the run reports status cancelled with a null endedAt and a null duration, so the assertion fails and the dependent worker-stop check never runs. The run resource falls back to the queue job whenever no execution-log row exists yet, and a cancel that lands before the worker has written that row leaves exactly that state. Trigger.dev marks a run canceled the moment it accepts the cancellation but only stamps its finish time when the worker drains, so for the seconds in between the job is terminal with no timestamp, and the projection faithfully reports a terminal status with nothing to date it. The run writes a correct log row when it finally drains, which is why the endpoint heals itself and the alarm fires intermittently rather than always. The backend was discarding the one timestamp that is always present: the retrieve response carries a required updatedAt beside the optional finishedAt. A finish time still wins wherever it exists, so nothing that already reports correctly changes, and the fallback is taken only once the mapped status is terminal — an active run's updatedAt marks progress, and reading it as an end would retire a run that is still going. It records the server's last transition for the run rather than the reader's clock, so it stays put across polls instead of growing. Also carries the duration on a fifth cancellation write, in the internal cancel route, that the earlier pass missed because its sweep covered lib and not app.
1 parent 9aa36a4 commit bef36e1

5 files changed

Lines changed: 120 additions & 3 deletions

File tree

apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1320,6 +1320,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
13201320
expect(mockSet).toHaveBeenCalledWith({
13211321
status: 'cancelled',
13221322
endedAt: expect.any(Date),
1323+
totalDurationMs: expect.anything(),
13231324
executionDeadlineAt: null,
13241325
})
13251326
})
@@ -1340,6 +1341,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
13401341
expect(mockSet).toHaveBeenCalledWith({
13411342
status: 'cancelled',
13421343
endedAt: expect.any(Date),
1344+
totalDurationMs: expect.anything(),
13431345
executionDeadlineAt: null,
13441346
})
13451347
})

apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from '@/lib/execution/cancellation'
2525
import { createExecutionEventWriter, readExecutionMetaState } from '@/lib/execution/event-buffer'
2626
import { abortManualExecution } from '@/lib/execution/manual-cancellation'
27+
import { elapsedDurationMsSql } from '@/lib/logs/execution/duration'
2728
import { workflowExecutionOriginSql } from '@/lib/logs/execution-origin'
2829
import { captureServerEvent } from '@/lib/posthog/server'
2930
import {
@@ -215,7 +216,12 @@ async function claimExecutionLogCancellation(args: {
215216
const now = new Date()
216217
const [cancelledExecution] = await db
217218
.update(workflowExecutionLogs)
218-
.set({ status: 'cancelled', endedAt: now, executionDeadlineAt: null })
219+
.set({
220+
status: 'cancelled',
221+
endedAt: now,
222+
totalDurationMs: elapsedDurationMsSql(now),
223+
executionDeadlineAt: null,
224+
})
219225
.where(
220226
and(
221227
eq(workflowExecutionLogs.executionId, args.executionId),

apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,80 @@ describe('TriggerDevJobQueue status mapping', () => {
331331

332332
await expect(queue.getJob('run-1')).resolves.toMatchObject({ status: jobStatus })
333333
})
334+
335+
it('dates a run cancelled before it was dequeued by its last transition', async () => {
336+
mockRetrieve.mockResolvedValueOnce({
337+
id: 'run-1',
338+
payload: {},
339+
status: 'CANCELED',
340+
taskIdentifier: 'workflow-execution',
341+
createdAt: new Date('2026-08-05T12:00:00.000Z'),
342+
updatedAt: new Date('2026-08-05T12:00:02.000Z'),
343+
})
344+
const queue = new TriggerDevJobQueue()
345+
346+
await expect(queue.getJob('run-1')).resolves.toMatchObject({
347+
status: 'cancelled',
348+
startedAt: undefined,
349+
completedAt: new Date('2026-08-05T12:00:02.000Z'),
350+
})
351+
})
352+
353+
it('dates a run cancelled mid-flight by its last transition until it drains', async () => {
354+
mockRetrieve.mockResolvedValueOnce({
355+
id: 'run-1',
356+
payload: {},
357+
status: 'CANCELED',
358+
taskIdentifier: 'workflow-execution',
359+
createdAt: new Date('2026-08-05T12:00:00.000Z'),
360+
startedAt: new Date('2026-08-05T12:00:01.000Z'),
361+
updatedAt: new Date('2026-08-05T12:00:03.000Z'),
362+
})
363+
const queue = new TriggerDevJobQueue()
364+
365+
await expect(queue.getJob('run-1')).resolves.toMatchObject({
366+
status: 'cancelled',
367+
startedAt: new Date('2026-08-05T12:00:01.000Z'),
368+
completedAt: new Date('2026-08-05T12:00:03.000Z'),
369+
})
370+
})
371+
372+
it('prefers the reported finish over the last transition once the run has drained', async () => {
373+
mockRetrieve.mockResolvedValueOnce({
374+
id: 'run-1',
375+
payload: {},
376+
status: 'COMPLETED',
377+
taskIdentifier: 'workflow-execution',
378+
createdAt: new Date('2026-08-05T12:00:00.000Z'),
379+
startedAt: new Date('2026-08-05T12:00:01.000Z'),
380+
finishedAt: new Date('2026-08-05T12:00:04.000Z'),
381+
updatedAt: new Date('2026-08-05T12:00:09.000Z'),
382+
})
383+
const queue = new TriggerDevJobQueue()
384+
385+
await expect(queue.getJob('run-1')).resolves.toMatchObject({
386+
status: 'completed',
387+
completedAt: new Date('2026-08-05T12:00:04.000Z'),
388+
})
389+
})
390+
391+
it('leaves a still-running job with no completion instant', async () => {
392+
mockRetrieve.mockResolvedValueOnce({
393+
id: 'run-1',
394+
payload: {},
395+
status: 'EXECUTING',
396+
taskIdentifier: 'workflow-execution',
397+
createdAt: new Date('2026-08-05T12:00:00.000Z'),
398+
startedAt: new Date('2026-08-05T12:00:01.000Z'),
399+
updatedAt: new Date('2026-08-05T12:00:03.000Z'),
400+
})
401+
const queue = new TriggerDevJobQueue()
402+
403+
await expect(queue.getJob('run-1')).resolves.toMatchObject({
404+
status: 'processing',
405+
completedAt: undefined,
406+
})
407+
})
334408
})
335409

336410
describe('TriggerDevJobQueue cancellation', () => {

apps/sim/lib/core/async-jobs/backends/trigger-dev.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,38 @@ function mapTriggerDevStatus(status: string): JobStatus {
213213
}
214214
}
215215

216+
const TERMINAL_JOB_STATUSES: readonly JobStatus[] = [
217+
JOB_STATUS.COMPLETED,
218+
JOB_STATUS.FAILED,
219+
JOB_STATUS.CANCELLED,
220+
]
221+
222+
/**
223+
* Dates the end of a run that trigger.dev already reports as terminal.
224+
*
225+
* A cancellation flips the run to `CANCELED` the moment it is accepted, but
226+
* `finishedAt` is only stamped once the worker actually drains — seconds later,
227+
* or never for a run that was cancelled before it was ever dequeued. A reader
228+
* polling inside that window sees a terminal job carrying no completion
229+
* instant, and every consumer that derives an end timestamp or an elapsed
230+
* duration from it reports null.
231+
*
232+
* `updatedAt` is trigger.dev's own record of when the run last changed, so for
233+
* a terminal run it dates that final transition rather than the read. It is
234+
* consulted only once the status is terminal: an active run's `updatedAt`
235+
* describes progress, not an ending, and reporting it would end a run that is
236+
* still going.
237+
*/
238+
function resolveRunCompletedAt(
239+
finishedAt: Date | string | undefined,
240+
updatedAt: Date | string | undefined,
241+
status: JobStatus
242+
): Date | undefined {
243+
if (finishedAt) return new Date(finishedAt)
244+
if (!TERMINAL_JOB_STATUSES.includes(status)) return undefined
245+
return updatedAt ? new Date(updatedAt) : undefined
246+
}
247+
216248
/**
217249
* Adapter that wraps the trigger.dev SDK to conform to JobQueueBackend interface.
218250
*/
@@ -379,14 +411,16 @@ export class TriggerDevJobQueue implements JobQueueBackend {
379411
: undefined,
380412
}
381413

414+
const status = mapTriggerDevStatus(run.status)
415+
382416
return {
383417
id: run.id,
384418
type: run.taskIdentifier as JobType,
385419
payload: run.payload,
386-
status: mapTriggerDevStatus(run.status),
420+
status,
387421
createdAt: run.createdAt ? new Date(run.createdAt) : new Date(),
388422
startedAt: run.startedAt ? new Date(run.startedAt) : undefined,
389-
completedAt: run.finishedAt ? new Date(run.finishedAt) : undefined,
423+
completedAt: resolveRunCompletedAt(run.finishedAt, run.updatedAt, status),
390424
attempts: run.attemptCount ?? 1,
391425
maxAttempts: 3,
392426
error: run.error?.message,

apps/sim/lib/workflows/executor/execution-status.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ describe('getWorkflowExecutionStatus queue projection', () => {
128128
status: 'cancelled',
129129
level: 'info',
130130
endedAt: '2026-08-05T12:00:01.000Z',
131+
totalDurationMs: 1000,
131132
error: null,
132133
})
133134
})

0 commit comments

Comments
 (0)