Skip to content

fix(execution): give a cancelled async run its terminal metadata - #6693

Merged
waleedlatif1 merged 2 commits into
stagingfrom
fix/async-cancel-terminal-metadata
Aug 14, 2026
Merged

fix(execution): give a cancelled async run its terminal metadata#6693
waleedlatif1 merged 2 commits into
stagingfrom
fix/async-cancel-terminal-metadata

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Fixes the firing staging alarm sim-staging-us-east-1-integ-failureinteg-cancel-async-api-key/async-execution-becomes-cancelled returns status=cancelled with a null endedAt and null totalDurationMs, failing the assertion and skipping the dependent worker-stop check. Recurring across deployments since at least 2026-08-13 21:56 UTC.

Not the same bug as #6686

#6686 merged ~3h into the incident window and only touched the four workflow_execution_logs terminal writes. I verified it live at 01:58 UTC on a table workflow-group run (totalDurationMs: 16358) — that path has a log row. This is the async path, which does not.

Mechanism

execution-status.ts consults the queue job only when no log row exists (...(!logRow ? [jobId] : [])). A cancel that lands before the worker writes that row — LoggingSession.start writes it after preprocessing — takes the cancel route's if (!execution) branch, cancels the queue jobs, returns reason: 'queue_cancelled', and writes no log row. The next status poll therefore projects the queue job:

status  = job.status                      // 'cancelled'
endedAt = job.completedAt ?? null          // null
totalDurationMs = endedAt ?  : null       // null

It is a race, not an absence. Trigger.dev flips a run to CANCELED the moment it accepts the cancellation and stamps finishedAt only when the worker drains. Verified against live staging runs (project proj_kufttkwzywcydwtccqhx, env stg) bearing the failing test's own execution IDs — they ran ~12s before the cancel drained. The run does write a correct terminal log row on drain, which is why the endpoint self-heals and the alarm fires intermittently rather than always.

Backend confirmed empirically rather than from config: getAsyncBackendType selects trigger-dev only when TRIGGER_DEV_ENABLED plus project/secret env are set, which is not in the repo — so this was established from the live runs. The database backend is unaffected: both cancelJob and cancelByExecution set completedAt: now.

Fix — at the mapping, not the projection

The backend was discarding the one timestamp always present: RetrieveRunResponse carries a required, non-optional updatedAt beside the optional finishedAt.

if (finishedAt) return new Date(finishedAt)
if (!TERMINAL_JOB_STATUSES.includes(status)) return undefined
return updatedAt ? new Date(updatedAt) : undefined
  • finishedAt still wins wherever present, so nothing already reporting correctly changes.
  • The fallback is gated on terminal status — an active run's updatedAt means progress, and reading it as an end would retire a run still in flight.
  • updatedAt is the server's record of the run's last transition, not a read-time clock, so it is stable across polls. (new Date() was explicitly disqualified: it would report a duration that grows after the run is over.)

Rejected alternatives. Withholding the terminal status until metadata is final would report running for a run the user already cancelled — a worse lie — and depends on the log row always landing, which the never-dequeued case does not guarantee. Writing a log row on async cancel would add a fifth log-writer to a route that deliberately returns queue_cancelled without touching the log; the projection is already correct once the job carries a timestamp.

Plus a fifth cancellation write

claimExecutionLogCancellation in the internal cancel route wrote endedAt with no totalDurationMs — same shape as the four #6686 covered. It hid because that sweep searched lib/ and this lives under app/. The sweep now spans both; all five carry the duration.

Two pre-existing tests asserting the exact .set() payload failed when the field was added, which is its own confirmation the path is live.

Verification

  • Red-then-green: reverting the mapping produced exactly the two cancellation failures, while the completion and still-running guards stayed green — confirming they guard rather than duplicate.
  • Full suite 1,864 files / 24,957 tests passed, 0 failed
  • type-check, biome, check:audits 26/26 — all clean

Adjacent, not fixed

Whether the queued-but-never-dequeued case can still produce a terminal job with no timestamp on the database backend — its cancel writes are guarded by WHERE status IN (PENDING, PROCESSING), so a job in another state would not update. Not observed, and out of scope for this incident.

@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview Aug 14, 2026 3:07am

Request Review

@cursor

cursor Bot commented Aug 14, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches execution status projection and Trigger.dev job mapping on a timing-sensitive cancel path; behavior change is scoped to terminal metadata but affects polling and integration tests.

Overview
Fixes async cancellation status polls that could show cancelled with null endedAt and totalDurationMs when Trigger.dev marks a run terminal before finishedAt is set (common during the cancel-to-drain window and when no log row exists yet).

The Trigger.dev job adapter now sets completedAt via resolveRunCompletedAt: prefer finishedAt, otherwise for terminal statuses only fall back to updatedAt so queue projection can derive end time and duration. Still-running jobs keep completedAt unset.

The workflow cancel API’s claimExecutionLogCancellation path also writes totalDurationMs using elapsedDurationMsSql, aligning with other terminal log updates.

Reviewed by Cursor Bugbot for commit 9dab895. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR fills terminal metadata for asynchronously cancelled Trigger.dev runs and standardizes cancellation-log duration writes.

  • Falls back to Trigger.dev’s terminal updatedAt when finishedAt is temporarily absent.
  • Centralizes cancellation log fields across direct, grouped, paused, and API cancellation paths.
  • Reuses a shared, bounded SQL duration expression for cancellation and stale-execution cleanup.
  • Adds focused tests for queue projection, SQL generation, and cancellation payload consistency.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains.

No blocking failure remains.

Important Files Changed

Filename Overview
apps/sim/lib/core/async-jobs/backends/trigger-dev.ts Maps terminal runs without finishedAt to a stable completion timestamp using updatedAt, while preserving finishedAt precedence and excluding active statuses.
apps/sim/lib/core/async-jobs/types.ts Defines the shared terminal-status set and documents the completion timestamp contract.
apps/sim/lib/logs/execution/cancellation.ts Centralizes the terminal cancellation payload, including deadline clearing and duration derivation.
apps/sim/lib/logs/execution/duration.ts Produces bounded, timezone-stable elapsed durations while preserving paused-run checkpoints.
apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts Applies the shared terminal cancellation fields to the internal execution-log claim.
apps/sim/app/api/cron/cleanup-stale-executions/route.ts Replaces duplicated duration SQL with the shared elapsed-duration helper.
packages/db/schema.ts Documents the existing totalDurationMs semantics without changing the persisted schema.

Sequence Diagram

sequenceDiagram
  participant Client
  participant CancelAPI as Cancellation path
  participant Queue as Trigger.dev
  participant StatusAPI as Execution status
  participant Logs as workflow_execution_logs

  Client->>CancelAPI: Cancel async execution
  CancelAPI->>Queue: Cancel job
  Queue-->>CancelAPI: CANCELED with updatedAt
  CancelAPI->>Logs: Write terminal fields when log exists
  Client->>StatusAPI: Poll execution status
  StatusAPI->>Logs: Read execution log
  alt Log row exists
    Logs-->>StatusAPI: cancelled + endedAt + totalDurationMs
  else Log row not written yet
    StatusAPI->>Queue: Retrieve run
    Queue-->>StatusAPI: CANCELED + updatedAt
    StatusAPI-->>Client: cancelled + projected endedAt/duration
  end
Loading

Reviews (2): Last reviewed commit: "refactor(execution): derive a cancelled ..." | Re-trigger Greptile

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.
…place

The five cancellation paths each hand-assembled the same four-key payload for
the workflow-execution log, and one of them had already drifted: the direct
cancel never cleared `execution_deadline_at`, leaving a cancelled row carrying
the deadline of an attempt that had stopped running. Extract the payload so the
key set cannot vary between them, and leave the paths themselves alone — they
differ in handle, claim predicate, whether they read the row back, and what they
do when the claim is lost, so they stay separate statements.

Bind the end instant through the `started_at` column encoder rather than a
pre-stringified ISO literal. `check:sql-date-binding` exists to enforce exactly
that binding; the literal passed only because it was already a string.

Collapse the duration expression to one `COALESCE` over a valueless-`ELSE`
`CASE`, which builds the elapsed fragment once instead of in both branches, and
reuse it for the stale-execution sweeper, which carried its own copy along with
a second int4 ceiling constant.

Document the invariants the fix depends on where a reader meets them: that
`total_duration_ms` means wall clock for a terminal row and active time for a
paused one, and that a terminal job must carry its transition instant.
@waleedlatif1
waleedlatif1 force-pushed the fix/async-cancel-terminal-metadata branch from 9dab895 to 34ed113 Compare August 14, 2026 03:01
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@waleedlatif1
waleedlatif1 merged commit b9a70e4 into staging Aug 14, 2026
21 of 23 checks passed
@waleedlatif1
waleedlatif1 deleted the fix/async-cancel-terminal-metadata branch August 14, 2026 03:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant