SherlockLiu Logo SherlockLiu
Back to all posts
Engineering

The Agent Loop: Turns, Steps, and a Real Cancellation Bug (Part 9)

SL
Aug 14, 2026 8 min read
The Agent Loop: Turns, Steps, and a Real Cancellation Bug (Part 9)

Series: Inside the DeepSeek Harness — Part 9 of 16


Everything in this series so far has described policy — what’s allowed to happen. This post is about the thing that actually decides what happens next: ReactLoopAgent, the default driver, and specifically its cancellation machinery. I’m leading with the bug on purpose. A repo can document invariants all day; what actually tells you whether the architecture holds up under pressure is what happens when a real concurrency bug gets found, and how narrow the fix turns out to be.


Three states, one field that almost wasn’t there

The driver’s Phase type (packages/core/agent-loop/src/agent.ts:38-46) is a real, three-variant discriminated union:

type Phase =
  | { kind: 'idle'; lastTurn: number }
  | { kind: 'maintenance'; abort: AbortController; lastTurn: number; wakeRequested: boolean }
  | { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean }

The public status getter deliberately collapses this down further: only 'running' reports as 'running'; both 'idle' and 'maintenance' report as 'idle' to anything watching from outside. Maintenance work — the kind of background housekeeping the loop sometimes needs to do — is invisible to external observers by design; AgentStatus itself is just two values, not three, with disposal explicitly documented as not being a third observable status at all.

idle running has wakeRequested latch maintenance also has the latch Public status: only "running" ≠ idle. Maintenance work never shows externally.

Figure: two of three phases carry a `wakeRequested` latch — a detail that becomes the entire subject of this post's bug fix.

The turn, as a real function

turn() (agent.ts:246-330) opens with session.append('turn/start', {turn}), then loops: claim admitted input, run agent/pre-step (reject closes the turn with no step spent), append messages, call step(). One genuinely sharp edge case, called out directly in a source comment: max-tokens is sticky. Once any step in a turn hits the token ceiling, later steps that complete normally must not downgrade the turn’s recorded outcome back to a clean completion — the turn’s final status has to reflect that a limit was hit somewhere along the way, even if the very last step in the sequence looked fine on its own. And whatever happens, a finally block always appends turn/end — even on the error path. That’s the same atomicity pattern Part 2 found in boot()’s failure handling: the thing that opens a durable transaction is also the thing responsible for guaranteeing it always closes, no matter which path gets taken to get there.


The inbox: three intents, and one of them deliberately doesn’t wake anything

// packages/core/agent-loop/src/agent.ts:113-132 (abridged, real)
send(message, target, wakeup) {
  const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted
  const resolvedTarget = wakingAfterAbort ? 'next-turn' : target
  this.inbox.splice(resolvedTarget, Infinity, 0, [message])
  if (wakeup) this.wakeDriver(wakingAfterAbort)
}
followup(input) { this.send(input, 'next-turn', true) }   // wakes
steer(input)    { this.send(input, 'next-step', true) }   // wakes
inject(input)   { this.send(input, 'next-step', false) }  // does NOT wake
followup() → next-turn wakes the driver steer() → next-step wakes the driver inject() → next-step does NOT wake inject() is for ambient context (time, tmux state) — it rides along on whatever step happens next, never starts one on its own.

Figure: the same durable inbox, three intents — the difference between them is entirely in whether they call `wakeDriver`.

inject() not waking the driver is deliberate: it exists for background plugins (a time-context provider, a tmux-state provider) that want their content to land in the next pre-step, whenever that happens, without themselves triggering a new turn. If the driver is idle, an injected message just sits in the inbox until a followup() or steer() eventually wakes it for an unrelated reason — the docs even carry an explicit caveat that it “may miss a request whose pre-step already claimed its batch” if the timing is unlucky. That’s an accepted tradeoff for a mechanism whose whole point is to be passive.


Cancellation is cooperative, on purpose — and the docs say why

Cancellation uses a closed, TypeScript-enforced union for its cause (user | parent | hook | disposed) — first cause wins, no runtime string parsing. The design rationale, from the actual internal note, is worth quoting directly because it’s a real, considered rejection of a tempting shortcut:

“Cancellation remains cooperative. The loop checks interruption before and after awaited boundaries but does not use Promise.race to abandon an in-process listener, adapter, or tool Promise.”

They considered the alternative — abandon uncooperative work after a grace period — and rejected it explicitly:

“Returning idle while same-process work still runs breaks teardown and resource-ownership guarantees. Hard termination requires a worker or process isolation boundary and is outside this control boundary.”

In other words: you can’t safely pretend a same-process Promise is dead just because you’ve stopped waiting for it. It’s still holding whatever resources it’s holding. A tool call canceled before it ever dispatches gets a real tool/call event paired with a synthetic tool/result carrying TOOL_ABORTED_BEFORE_DISPATCH (tool-calls.ts:248-259, appendSkippedToolCall()) — this is the exact mechanism Part 4 promised: the log never ends up with an orphaned call and no matching result, even for work that never actually ran.


The bug: a wake message that could vanish in an async gap

Here’s where the design gets tested against reality. A real, dated fix — .agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md, tied to a real issue (#1838) — describes exactly this failure:

cancel(cause, { keepInbox: true }) returns immediately. But the actual teardown — stopping the LLM stream, canceling in-flight tools, appending turn/end — unwinds asynchronously, after that return. If a wake (a new followup() or steer()) arrived during that gap, it had nowhere safe to land: the phase wasn’t idle yet, so the wake wasn’t processed as a fresh start, but the phase was also mid-teardown, not actively watching for new wakes. The message “stayed parked until another waking send arrived” — meaning a session could go quiet and simply never resume, silently, unless something else happened to wake it later. This broke both session.cancel and subagent.interrupt.

Before the fix cancel() returns async teardown gap wake arrives HERE — dropped idle — parked forever After the fix cancel() returns async teardown gap wake sets wakeRequested latch finally: replay latch → resumes correctly

Figure: same async gap, two outcomes — the fix is a latch that survives the gap and gets replayed when teardown finishes.

The fix: add a wakeRequested latch to the running phase, mirroring the field that already existed on maintenance (that’s the field you saw in the Phase type at the top of this post). The exiting activity replays that latch from its own finally block once teardown actually completes.

What makes this genuinely good engineering writing, and worth quoting directly, is what they tried first and rejected. Four alternatives, each with a one-line reason:

“Have cancel() set the phase to idle immediately. Rejected: the driver is still unwinding… 14 of 83 tests failed, several deadlocked.”

They didn’t just reason their way to the fix — they tried the obvious shortcut, watched it break 14 tests and deadlock several more, and wrote that down as part of the record. That’s a genuinely rare thing to see documented this explicitly, and it’s a good reminder that “elegant architecture” doesn’t mean “bug-free architecture” — it means bugs get found, understood, and fixed with a change this narrow (one boolean field, replayed in one finally block) instead of a structural rewrite.


Compare: Claude Code

Claude Code’s dialog loop is async function* queryLoop(params) — a specific, hardened function with a five-phase turn lifecycle and 10 distinct termination reasons (completed, aborted_streaming, aborted_tools, max_turns, blocking_limit, prompt_too_long, model_error, stop_hook_prevented, hook_stopped, image_error). It’s the one piece of Claude Code’s architecture the April series was explicit about not being user-replaceable — you can hook into it, but you cannot swap it for a different loop implementation.

dsh’s loop is ReactLoopAgent — the same phase/turn/step vocabulary this post walks through, but registered as a plugin like any other, mountable, and in principle replaceable from the same YAML config that swaps a sandbox provider. Neither harness treats this as a small decision: Claude Code’s fixed loop is a deliberate reliability bet (one hardened path, exhaustively enumerated failure modes) and dsh’s pluggable loop is a deliberate extensibility bet (the loop is not exempt from the plugin discipline everything else follows). This post’s cancellation bug is a small piece of evidence for the reliability side of that trade — a genuine race condition surfaced even in a system built around “the loop is just a plugin,” which is exactly the kind of subtle failure a fixed, heavily-scrutinized implementation is designed to catch fewer of over time, not more.


Coming up in this series

(All 16 parts are live today — no daily drip.)

Part Title What it covers
1 DeepSeek Harness: Inside the Open-Source Claude Code Rival The launch, the comparison, no privileged core, the four Cordis primitives
2 Composing an App From YAML, Not Code Profiles, bundles, five patch layers, boot, live HMR, --dump-config
3 Scope: Why a Live Agent Is the Key of Its Own Registration Shadowing, restriction, lineage vs. scope
4 Tool Execution in DeepSeek Harness: Guards and Approval Pre-execute, monotonic guards, post-execute, approval
16 DeepSeek Harness: Agent Presets as Data, Not Code Agent presets, PTC/Code Mode, the plugin ecosystem, compared to Claude Code’s agent types
5 Capability Seams: Making Bash Swappable for Sandboxed Bash The 3-role pattern end-to-end, across ~85 real seams
6 The Session Log: DeepSeek Harness’s Enforced Invariant The real invariant-checking code, surface projection
7 Persistence and Compaction: Crash-Safe by Construction JSONL/SQLite, torn-tail repair, the compaction lock bracket
8 Waterfalls: The One Event Pattern That Runs Everything Five dispatch modes, durable vs. live events, retry
9 The Agent Loop: Turns, Steps, and a Real Cancellation Bug (this post) The phase state machine, the inbox, a dated bug fix
10 The LLM Layer: One Message Format, Every Surface Message vocabulary, streaming, retry-via-log-replay
11 Subagents and Workflows: Composing Agents From Agents Provider kinds, continuable children, Ralph rounds
12 Defense in Depth: Sandboxing and Four Real Incidents bwrap/Landlock/Seatbelt/ACL, real production postmortems
13 Three Surfaces, One Spine: Web, Typert RPC, and SDK/ACP Typert codegen, the four-quadrant RPC envelope
14 Engineering Rigor: DeepSeek Harness’s Verification Gate The 100% coverage gate, real engineering-culture quotes
15 What a Second Production Harness Teaches dsh vs. the Agent Harness series, side by side

Part 16 shipped after this series’ initial 15 posts, once Agent Presets and Code Mode landed — it reads best right after Part 4, which is why its row sits there instead of at the end.

Next: Part 10 follows a request one layer further out — the LLM adapter seam, and a retry mechanism clever enough to derive its own count by replaying the durable log instead of keeping in-memory state.


References

Comments