Run Control
A run in flight can be paused, resumed, or cancelled by its run_id, from the process
running it or from another one entirely — see Pause, Resume, Cancel
for how to reach one from outside the process. In your own code it looks like this:
await deck.pause(run_id, reason="operator stepped away")
events = await deck.resume(run_id)
await deck.cancel(run_id, reason="user closed the tab")Asking is not stopping
pause and cancel record a request and return. They do not wait for the run to
stop, because at the moment you ask, nobody can know when it will: the run may be halfway
through a tool call that has to finish first.
That is why a controlled run writes three things to its log rather than one:
| Event | Means |
|---|---|
control.requested | the signal is recorded — {verb, reason?} |
control.observed | the run reached a safe point and is acting on it — {verb, safe_point} |
run.paused / run.cancelled / run.resumed | the effect |
A request is not a status change. A run stays running after control.requested; only the
effect moves it to paused or cancelled. So “did it stop?” is answered by watching the run’s
events for the effect — not by the response to your request.
A signal that arrives after the run already ended does nothing, and says nothing: a terminal event is a run’s last event, so there is nowhere for a rejection to land. Sending the same pause twice records one request. Neither is an error.
Safe points
A signal is acted on at the next safe point, never mid-token and never by killing anything:
stream_item— between two items of the model’s stream. The chunk in flight is always delivered whole.tool_dispatch— before a tool call is dispatched.node_boundary— between two graph nodes.
Agent runs today honor stream_item. A workflow (LangGraph) run has no safe point yet, so
pause and cancel do not reach one — see issue #128 .
A tool call that is already running is never interrupted. If you pause while a tool is
executing, the call runs to completion and the run stops before the step that would have used
its result. The safe_point on control.observed is what tells “cancel took eight seconds”
from “cancel took eight seconds because a tool call did”.
How long a pause or cancel takes to land
Two things add up, and both are bounded:
- Up to 200ms before the run notices the request. The run re-reads pending control at most once per 200ms, so a long answer costs a handful of reads instead of one per token — a 400-chunk answer streaming for 12 seconds costs 58 reads, not 400. This is a latency bound, not a correctness one: nothing is ever missed, only noticed a moment later.
- However long the current step takes to reach a safe point — the rest of a streamed chunk, or all of a tool call that is already in flight. This one is your run’s shape, not ours.
For a streaming answer that is typically well under half a second. For a run inside a 30-second tool call it is 30 seconds, and no setting can change that without killing the call.
Both of those are about a run that is live. Cancelling a run that is already paused is the
one case with no bound on it, because a paused run has no loop reaching safe points: the request
is recorded, and the next resume honours it — ending the run cancelled instead of playing
it on. So the cancel is never lost and can never be overridden by a resume, but it becomes an
effect only when somebody next touches the run. A paused run that nobody ever picks up stays
paused, holding its session until AGENTDECK_RUNTIME_STALE_RUN_AFTER_SECONDS takes it over.
What resume replays
A paused run is suspended in the log, not parked in memory: the process is free to exit, and
any worker holding the same event store can lift the pause. There is no stack to return to, so
resuming re-enters the engine with the run’s original input and the log as history — the run
keeps its run_id, and seq carries on from where it stopped.
The consequence to design for: work the paused turn had already done can happen again. The
model is asked again, and a tool it had already called may be called a second time. RunContext
has no idempotency-key field to lean on for this — only run_id, which does stay the same
across a resume (see above) if your own side effects want a key to dedupe against. Keep tools
idempotent, and treat a resumed turn the way you treat a resumed LangGraph interrupt node.
resume returns the events the continuation produced — an empty list means there was
nothing to resume (the run finished, was cancelled, is still running, or another worker got
there first). Exactly one caller can resume a paused run: the transition is a conditional append
to the log, so two racing resumes cannot both play the turn.
Cancel is terminal. A cancelled run cannot be resumed, and a cancel cannot un-do what a tool
already did — an email that was sent stays sent. That holds for a run cancelled while paused
too: resume finds the pending cancel and ends the run rather than continuing it, so asking
to resume does not quietly override whoever cancelled.
paused is also not waiting_human: a run stopped on an interrupt() is waiting for an answer
and resumes with a value; a paused run is waiting for an operator and resumes with nothing.