How Suspension Works
Suspension begins when an executor returnsExecCodeSuspended (integer value 1) from its Execute method. The engine intercepts this exit code and transitions the task to PhaseSuspended rather than any terminal phase:
PhaseSuspended, the engine parks it. The parent DAG scope does not advance — downstream tasks that depend on the suspended task remain in PhaseCreated. The workflow stays alive but makes no progress on that branch until a Resume() call arrives.
Partial Outputs Are Accumulated
Each time the executor returnsExecCodeSuspended, any output parameters it includes are merged into the task’s accumulated outputs using last-writer-wins semantics. New keys are added; existing keys are overwritten by the latest value. This allows the executor to incrementally build up state across multiple suspend-resume cycles.
Triggering Suspension from the Executor
The playgroundecho executor suspends when the input parameter suspend is true. In a real executor you return ExecCodeSuspended directly:
await-approval suspends, finalize stays in PhaseCreated. The workflow remains alive — prepare has already succeeded, but finalize cannot run until await-approval is resolved.
Resuming a Suspended Task
CallEngine.Resume() from your application to re-dispatch a suspended task with new payload data:
Resume Signature
Payload Merging
Resume() merges the payload map into the task’s accumulated Inputs using last-writer-wins: keys present in payload overwrite the same keys in the stored inputs; keys absent from payload are left unchanged. This means:
- The executor always receives the full merged history of all resume payloads on top of the original resolved inputs.
- Multiple resume rounds can build up complex state incrementally.
- A resume payload with only a subset of keys does not erase previously accumulated keys.
What the Executor Sees on Re-dispatch
AfterResume() is called, the engine re-dispatches the task to the broker. The executor receives a TaskAssignment with:
- Inputs: the fully merged set (original inputs + all resume payloads)
- The same deadline: the original task deadline is not reset by
Resume()
- Return
ExecCodeSucceededto finalize the task and unblock downstream nodes. - Return
ExecCodeSuspendedagain to wait for another round of resume data. - Return
ExecCodeFailedorExecCodeErrorto signal that the task should not proceed.
Hook Integration
Suspension and resumption each fire task-level hooks if configured on the DAG task node:1
Task dispatched
onStart fires.2
Executor returns ExecCodeSuspended
Task transitions to
PhaseSuspended. onSuspend fires.3
Engine.Resume() called
Task is re-dispatched.
onResume fires.4
Executor returns ExecCodeSucceeded
Task transitions to
PhaseSucceeded. onSuccess fires, then onExit fires.Idempotency and Race Conditions
Resume() is safe to call on a task that is no longer suspended. If the task has already reached a terminal state (completed normally, timed out, or been cancelled), Resume() returns nil without taking any action. This makes it safe for callers to send resume signals without strict coordination — a race between a timeout and a concurrent Resume() is resolved by the store’s token-based optimistic lock: only one writer succeeds.
Design Patterns
Human approval gates
Suspend a task after sending a notification (email, Slack, etc.). Resume it when the approver clicks “Approve” in your UI. The workflow holds until the gate is cleared.
External callback integration
Suspend after making an async API call. Register the task run ID as the callback token. Resume from the callback handler when the external system responds.
Multi-round interactive tasks
Keep returning
ExecCodeSuspended with partial outputs to accumulate incremental state. Resume multiple times before committing. The task receives the full accumulated history each time.Timeout-bounded approval
Combine
suspend with timeout and continueOn.timeout to auto-approve (or auto-reject) if no human response arrives within a deadline. The downstream task runs either way.