M2.3
Turn boundaries and cancellation
1,230 words · about 8 min
Concept
Cancellation is the hardest correctness problem in a harness, and the reason is that you cannot cancel a side effect that already happened. Ctrl-C during a model stream is easy — stop reading, discard the tail. Ctrl-C while a shell tool is three seconds into npm install is not. The process exists. The node_modules directory is half-written. The model does not know.
Every harness therefore implements cooperative cancellation: you signal intent, and the work is responsible for reaching a consistent stopping point. Preemptive cancellation of arbitrary code is not available in any managed runtime worth using.
Mechanism
The primitives are AbortController and AbortSignal — the platform-standard cooperative cancellation pair, which is exactly why dsh uses them rather than a bespoke token type: every Node and web API that accepts cancellation already accepts an AbortSignal.
dsh owns the controller in its phase state machine (packages/core/agent-loop/src/agent.ts):
type Phase =
| { kind: 'idle'; lastTurn: number }
| { kind: 'maintenance'; abort: AbortController; lastTurn: number; wakeRequested: boolean }
| { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean }Every non-idle phase owns exactly one controller. cancel(cause, options) clears the inbox (unless keepInbox is set) and calls this.phase.abort.abort(cause). The cause is typed, not a string:
// packages/core/session/src/types.ts
type AgentCancelCause =
| { readonly kind: 'user' } // a human pressed the interrupt key
| { readonly kind: 'parent' } // a parent agent cancelled a subagent
| { readonly kind: 'hook'; readonly reason: string }
| { readonly kind: 'disposed' } // the agent is being torn downThe cause is durable: turn/end records { kind: 'aborted', reason: TurnEndCancelCause }, so a transcript reader six months later can distinguish "the user changed their mind" from "a policy hook stopped it" from "the process shut down." A boolean cancelled flag cannot express any of that. Cancellation, per the Agent interface docs, is also first-cause-wins for a given activity, and is a no-op with no active activity — it does not arm future work.
Worked example — the checkpoint ladder
Worked exampleCooperative cancellation means the loop must check. dsh places signal.throwIfAborted() at every boundary where throwing is safe:
- In
preStep(), after the system prompt assembles — because assembly can be slow (it reads files, renders context sections) and the answer may already be unwanted. - In
turn(), at each step boundary. - In
step(), immediately before iterating the stream, and inside thefor awaiton every chunk.
The per-chunk check is what makes an interrupt feel instant. Without it, cancellation would only take effect at the end of a stream, and a model writing a long file would ignore your Ctrl-C for thirty seconds.
Now the interesting part: what happens to the half-finished completion? The step's catch block, when signal.aborted, salvages a prefix:
// packages/core/agent-loop/src/agent.ts (step, on abort)
// appends 'assistant/message' with the salvaged prefix and interrupted: true
assembler.interruptedBlocks()interruptedBlocks() returns the prefix an interrupted stream can safely finalize: closed and open text and reasoning blocks with non-whitespace content. Tool calls are omitted, because interruption precedes dispatch, and retaining one would require a fabricated result.
Read that reason again, because it is the whole discipline of cancellation in one sentence. A tool call in the model-visible history without a matching result is a corrupt transcript — most providers reject it outright, and a harness that "fixes" it by inventing a result has taught the model that a tool it never ran returned something. Dropping the call is the only honest option.
The event carries interrupted: true so consumers can distinguish a deliberately short answer from a truncated one without re-deriving interruption from turn boundaries. And per docs/subsystems/session.md, an aborted turn with no assistant/message at all means the stream delivered no visible content — absence is itself a meaningful signal.
Worked example — teardown mid-tool
Worked exampleTools are where the half-finished side effect lives. packages/core/agent-loop/src/tool-calls.ts handles the two cases separately, and the split is the design.
Calls that never started. When an abort arrives while a batch is scheduled, replenishment stops and the undispatched calls get a synthetic result rather than nothing:
// packages/core/agent-loop/src/tool-calls.ts (appendSkippedToolCall)
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: { info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }This upholds the invariant that every logged tool/call has a matching tool/result in the same step. The transcript stays well-formed, the model (on resume) sees an honest "this did not run", and replay does not break. A harness that simply drops undispatched calls produces a log that cannot be replayed into a valid request.
Calls already in flight. These are drained, never abandoned. The module documentation for tool-calls.ts states it: abort or an internal scheduler failure stops replenishment and drains started calls. On a scheduler failure the code does await Promise.allSettled(inFlight.values()) and only then rethrows.
Why drain rather than abandon? Because abandoning a promise does not stop the work behind it. The subprocess still runs, the file handle stays open, the socket stays connected — you have merely stopped listening. Awaiting settlement means the tool has actually reached quiescence before the loop declares the turn over, so no orphaned subprocess writes into the workspace after the agent has apparently stopped. This is the difference between "the UI says stopped" and "stopped."
The corollary is a contract every tool author must honor: a tool receives exec.signal and promises to observe it. A tool that ignores its signal cannot be cancelled, and the drain will wait for it. Cooperative cancellation is only as good as its least cooperative participant. dsh makes this contract explicit rather than pretending it is automatic — the timeout guard's module doc in packages/guard/timeout-policy/src/index.ts opens with exactly that promise.
Tradeoffs
Draining is correct and slow. A tool that ignores its signal makes Ctrl-C take as long as that tool takes, and users interpret that as a hang. The alternative — abandon after a grace period — gives snappy interrupts and orphaned processes. dsh chose correctness; a harness that shells out to long-running builds might reasonably choose a hybrid (drain with a hard kill after N seconds), and dsh has the seam for that in tools/execute rather than in the loop.
Salvaging an interrupted prefix is also a judgment call. Keeping it preserves useful partial work and matches user expectation; the cost is that history now contains an assistant message the model never finished, which can nudge the next completion toward continuing rather than restarting. The interrupted: true marker at least makes the fact inspectable by a plugin that wants to handle it differently.
How others differ
Claude Code's Esc-to-interrupt has similar semantics — the visible partial output is kept and the loop stops at the next safe point. The area where harnesses genuinely diverge is what happens to the inbox. dsh makes it explicit policy via CancelOptions.keepInbox: by default a cancel discards queued and steering work and logs a canceled inbox splice; with keepInbox, the active turn is still aborted but un-started and pending work survives for a later turn and no canceled splice is logged. Many harnesses have no queued-input concept at all, so the question never arises — and then cannot express "stop this, but keep what I typed while it was running." This is unsettled design space; there is no consensus on the right default.
Check yourself
2 questions on M2.3