Skip to content

fix(execution): stop a cancelled run reporting success when its wait swallows the cancellation - #6775

Merged
waleedlatif1 merged 2 commits into
stagingfrom
investigate/staging-cancel-canary
Aug 17, 2026
Merged

fix(execution): stop a cancelled run reporting success when its wait swallows the cancellation#6775
waleedlatif1 merged 2 commits into
stagingfrom
investigate/staging-cancel-canary

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • Cancellation reaches a running execution over Redis pub/sub, which is at-most-once. The engine turns that into status: 'cancelled'. But the wait handler also polled the durable Redis cancellation key itself, and on a hit returned an ordinary successful block output — leaving the engine's cancelledFlag false, so a cancelled run finished as success: true.
  • Whichever detector fired first won. The engine's pub/sub path normally wins by about one round trip; when the wait's own 500 ms poll landed inside that window, the cancellation was lost. Intermittent, roughly daily.
  • With a block after the wait it's worse than a mislabelled result: the engine's next loop iteration reads cancelledFlag === false and keeps executing, so a cancel silently doesn't cancel.
  • Consolidated detection into ExecutionEngine, the only component that can project run status: the once-at-start durable backstop is now a poll that runs for the life of the run and routes through signalCancelled. The wait handler and loop orchestrator observe only ctx.abortSignal, which the engine aborts — no leaf can observe a cancellation the engine hasn't seen.
  • Fixes a second latent bug: the loop orchestrator ignored abortSignal.aborted whenever Redis was enabled, so a mid-loop timeout or client disconnect was invisible to it. It also awaited a Redis round trip on every loop iteration, now a local boolean read.

Why this shape

Redis pub/sub has no persistence, acks, or replay for a disconnected subscriber, so a correctness-critical signal carried over it needs a durable fallback read. The codebase already encodes that: markExecutionCancelled writes the durable key before publishing, and the engine already had a backstop — it just only ran once, at engine start. This completes that design rather than adding a new mechanism.

status: 'cancelled' is not a new state. Every consumer across the sync route, async job, webhook, schedule, resume, column-execution, and child-workflow paths already branches on it — it's what the pub/sub path produces in the common case. This makes the lost-delivery case converge on the already-handled state.

Handlers that abort their own I/O off ctx.abortSignal (Mothership, Pi babysit) are unaffected: that surfaces as a throw, which the cancelled branch of run already classifies. They were never part of this defect class.

Type of Change

  • Bug fix

Testing

Tested manually against the failing scenario, plus:

  • New regression test: a durable cancellation with no pub/sub delivery now ends the run success: false, status: 'cancelled'. Verified it goes red with the poll removed.
  • New timer-leak test asserting no interval survives the run. Verified it goes red with the cleanup removed.
  • executor/ suite: 1984 passed. Cancellation-adjacent suites (child-execution, executor lib, run-control, schedule/resume/column execution, cancel-workflow-execution, cancellation, cancel route): 314 passed. Execute route: 119 passed.
  • bun run type-check clean, bun run lint clean, bun run check:audits 29/29, docs:check in sync.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

…swallows the cancellation

Cancellation reaches a running execution over Redis pub/sub, which is
at-most-once. The engine turns that into `status: 'cancelled'` via
`signalCancelled`. But the wait handler also polled the durable Redis
cancellation key itself, and on a hit it broke out of its sleep and returned an
ordinary successful block output. The engine's `cancelledFlag` stayed false, so
a cancelled run finished as `success: true` — and with a block after the wait,
kept executing.

Whichever detector fired first won. The engine's pub/sub path normally wins by
about one round trip; when the wait's own 500ms poll landed inside that window
the cancellation was lost.

Consolidate detection in the engine, which is the only component that can
project run status: extend the once-at-start durable backstop into a poll that
runs for the life of the run and routes through `signalCancelled`. The wait
handler and loop orchestrator now observe only `ctx.abortSignal`, which the
engine aborts, so no leaf can observe a cancellation the engine has not seen.

The loop orchestrator additionally used to ignore `abortSignal.aborted`
whenever Redis was enabled, so a mid-loop timeout or client disconnect was
invisible to it, and it awaited a Redis round trip on every iteration.

Handlers that abort their own I/O off `ctx.abortSignal` are unaffected: that
surfaces as a throw, which the cancelled branch of `run` already classifies.
The Wait page claimed a 10-minute cap for a synchronous wait in three places.
`MAX_INPROCESS_WAIT_MS`, the block description, the sub-block hint, and the
validation error all say 5 minutes.
@vercel

vercel Bot commented Aug 17, 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 17, 2026 8:20am

Request Review

@cursor

cursor Bot commented Aug 17, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Changes core execution cancellation and run status reporting; incorrect behavior could let cancelled workflows complete or misreport success, though scope is focused and well-tested.

Overview
Fixes a bug where a cancelled run could still finish as success: true when the Wait block polled the durable Redis cancellation key itself and returned a normal completed output, leaving the engine’s cancelledFlag false so downstream blocks could keep running.

ExecutionEngine now owns all durable cancellation detection: the one-shot startup Redis backstop becomes a 500ms poll for the entire run, routed through signalCancelled and ctx.abortSignal. Polling is stopped on cancel and in cleanup.

The Wait handler drops its Redis polling and only sleeps on ctx.abortSignal** (sleepUntilAborted). The **loop orchestrator** checks **abortSignal` instead of awaiting Redis on every iteration when Redis is enabled.

Docs for the Wait block update the in-line cap from 10 minutes to 5 minutes to match MAX_INPROCESS_WAIT_MS. New engine tests cover durable-only cancellation (no pub/sub) and no leaked poll timers after the run ends.

Reviewed by Cursor Bugbot for commit c3819ce. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR centralizes durable cancellation detection in ExecutionEngine, ensuring missed Redis pub/sub events still produce a cancelled execution result while handlers and loop orchestration rely on the engine-owned abort signal.

  • Adds a lifecycle-scoped Redis cancellation poll with overlap protection and cleanup.
  • Removes independent durable cancellation reads from synchronous waits and loop continuation.
  • Adds regression coverage for missed pub/sub cancellation and timer cleanup.
  • Updates Wait documentation to reflect the five-minute synchronous limit.

Confidence Score: 5/5

The PR appears safe to merge, with cancellation ownership consistently centralized in the execution engine and no actionable regression identified.

The durable fallback remains active for the run lifecycle, routes cancellation through the engine’s status flag and abort signal, prevents overlapping polls, ignores reads completing after cleanup, and is covered by cancellation and timer-cleanup regressions.

Important Files Changed

Filename Overview
apps/sim/executor/execution/engine.ts Adds engine-owned durable cancellation polling, routes detections through signalCancelled, and reliably stops polling during cancellation or cleanup.
apps/sim/executor/execution/engine.test.ts Adds focused regression tests for durable-only cancellation and interval cleanup after execution settles.
apps/sim/executor/handlers/wait/wait-handler.ts Removes leaf-level Redis polling so synchronous waits observe only the engine-owned abort signal.
apps/sim/executor/orchestrators/loop.ts Replaces per-iteration Redis reads with the shared abort signal, fixing non-Redis abort handling and avoiding cancellation classification outside the engine.
apps/sim/lib/workflows/custom-blocks/child-execution.ts Documents how parent-engine polling and parent-signal propagation provide durable cancellation coverage to child executions.
apps/docs/content/docs/en/workflows/blocks/wait.mdx Corrects public documentation to state the synchronous Wait block’s five-minute limit.

Sequence Diagram

sequenceDiagram
  participant User as Cancellation request
  participant Redis as Redis durable key
  participant PubSub as Redis pub/sub
  participant Engine as ExecutionEngine
  participant Handler as Wait/Loop/Child handler

  User->>Redis: Write cancellation key
  User->>PubSub: Publish cancellation event
  alt Event delivered
    PubSub->>Engine: Cancellation event
  else Event missed
    loop Every 500 ms while run is active
      Engine->>Redis: Read durable cancellation key
      Redis-->>Engine: Cancelled
    end
  end
  Engine->>Engine: signalCancelled()
  Engine->>Handler: Abort ctx.abortSignal
  Engine-->>User: success: false, status: cancelled
Loading

Reviews (1): Last reviewed commit: "docs(wait): correct the in-line wait cei..." | Re-trigger Greptile

@waleedlatif1
waleedlatif1 merged commit 75718ab into staging Aug 17, 2026
30 checks passed
@waleedlatif1
waleedlatif1 deleted the investigate/staging-cancel-canary branch August 17, 2026 08:55
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