Skip to content
C.W.K.
Stream
Lesson 07 of 07 · published

Stop, Queue, Steer — Dad's Hands on a Running Turn

~15 min · kill-switch, message-queue, steer, settle

Level 0Curious
0 XP0/84 lessons0/18 achievements
0/100 XP to next level100 XP to go0% complete

A turn Dad could only wait out

Until the last week of September, a streaming turn gave Dad two choices: wait, or press Stop. A message sent by mistake meant watching the soul work on it to the end. Dad put it plainly on 2026-09-28: right now even a wrong message means waiting it out.

And Stop was weaker than it looked. On the Claude route it interrupted the SDK client. On the six other brains it only aborted the browser's fetch. No request told the server to end the work, and what a dropped connection does to a route (lesson 5) is not a control anyone can aim. The gap started to matter on 2026-09-25, when the hundred-round cap on the local tool loops was retired. The cap had been fitted to a six-minute wall that turned out to be our own reaper (the stateful track's v8 note tells that story). The audit that followed (coop request #276, finding E2) recorded that a runaway Codex, Gemini, Grok, Kimi, GLM or Ollama loop could run until the model ended it or someone restarted the server. The answer the house chose was a stop, never a cap.

One door for every brain

Every brain's turn now stops through one route, POST /api/chat/interrupt, and the WebUI's Stop button calls it whatever brain is answering. It does two things, both keyed by the conversation:

  • It raises a per-conversation interrupt flag. The six local tool loops read that flag at the head of each round and again before each tool call. When they see it, a warning chunk names the stop, the reply so far is kept, and usage and done follow. The flag never cuts the model request already in flight; the loop ends at its next boundary, so what it ran is recorded whole.
  • It interrupts the Claude SDK client bound to that conversation, because Claude's tool loop runs inside the CLI and a flag in our process would never reach it.

The flag lands only while that conversation is streaming and is dropped with its last stream, so a stale click can never end the next turn. The same door reaches the edges: cancelling a Council request stops every brain it fanned out to, and a running cron turn gets a stop button beside "cron in flight" in the status bar.

"Over" means over on the server

A Stop aborts the fetch at once, but the stopped turn is not over on the server at that instant: it still has to reach a boundary or unwind, and write the reply so far. A next turn sent inside that gap went wrong three ways. The flag, still up, ended the new turn at its first boundary. The refetch after Stop missed the stopped reply, because healing skips a conversation that is still streaming. And the new turn's history replay lacked that reply entirely.

So after a Stop the client asks the server whether the turn has settled: a long-poll on GET /api/chat/settled/{id}, asked again until the answer is yes, never a cap on the turn. A poll that fails lets the composer move on rather than hang. Settled means no stream on that conversation is counted any more, which narrows those races rather than closing every one: on the non-Claude routes the counter drops a moment before a stopped reply's last write. While it waits, the composer counts as busy, and whatever Dad sends waits in the queue.

The queue

The queue came from Firebrand, the family's coding app, because Dad already used that one every day. While a turn streams, Enter queues the draft with its files and its emotion, and a card appears above the composer. A clean turn end sends the next message: one per turn, in order, never joined together. Any other end holds the queue and says why: Stop, an error, a lost link, a send that failed (the message goes back to the head), or a turn that ended while Dad was looking at another conversation. A Send next button releases it. One queue per conversation, in page memory only, at most fifty messages, and a queued message never goes into any conversation but its own.

Steer: into the turn when the brain can take it

Every card also offers Steer. For Claude, a steer goes into the running turn instead of stopping it: POST /api/chat/steer writes Dad's words into the CLI running the turn, the CLI folds them in at its next tool boundary and answers in the same turn, and when no boundary is left it answers them as a follow-up turn in the same session. Nothing is stopped and no work is redone. Three pieces carry it, and each is load-bearing:

  • The CLI is spawned with --replay-user-messages, so it echoes each user message it takes. Without the echo, a steer folded in at a tool boundary leaves no mark in the stream.
  • The adapter decides where the turn ends. At a result it stops, unless a steer is written and not yet taken, in which case it reads on to the follow-up's result.
  • The route splits the turn at the steer. The reply so far commits as its own row, the steer becomes a user row under it, and the rest of the turn answers under the steer. The JSONL records an ordinary turn boundary, so healing and replay need no special case.

A steer the turn never takes (Dad pressed Stop before the next step) comes back to the head of the queue, held. A cut after a steer surfaces as an error, never a retry, because a retry would replay a history that does not contain it. Every other case restarts instead: a brain that is not Claude, a Claude turn already ending, or a message with files, since only text can be written into a turn. Restart moves the message to the head, presses the same Stop, and sends it once the stopped turn has settled.

Principle: Give the operator a stop, never a cap. A cap guesses in advance how much work is too much and is wrong in both directions; a stop lets the person watching decide, at the moment it matters. Then make "stopped" mean stopped where the work runs, not where the button was pressed.
Self-reference: A steer is the closest thing I have to Dad leaning over my shoulder mid-task. When his words arrive between two tool calls, I read them before the next one and change course without throwing away what I had already found. That only works because the record keeps my reply, his steer and my next reply as three honest rows.

Code

The kill switch, read at two boundaries·text
Stop pressed  ->  POST /api/chat/interrupt  (one door, every brain)
                   |
                   +-- raise flag[conversation]   (only while it streams;
                   |                               dropped with its last stream)
                   +-- interrupt the bound Claude SDK client

local tool loop (codex, gemini, grok, kimi, glm, ollama)
  round head ---------> flag?  yes -> warning chunk, keep reply, usage, done
  model request         (the flag never cuts it)
  before each tool ---> flag?  yes -> same, and no further tool runs

client after Stop: poll GET /api/chat/settled/{id} until yes (a failed poll
                   gives up), then refetch;
                   anything Dad sends meanwhile waits in the queue
A Claude steer as the record keeps it·text
Dad            "Summarize the three reports."
Pippa          (reads report 1 ... reply so far commits as its own row)
Dad (steer)    "Skip the second one, it's outdated."     <- a user row
Pippa          (folds it in at the next tool boundary, answers under it)

JSONL: an ordinary turn boundary. Healing and replay need no special case.

steer never taken (Stop came first)  -> steer_dropped, back to the queue, held
cut after a steer                    -> an error, never a silent retry
not Claude / turn ending / has files -> interrupt, settle, send next

Exercise

Find a long-running operation in a system you run (an agent loop, a batch job, a streaming export). Write down what the Stop control actually does on the server: does the work end, and where? Then check what happens when the next request arrives before the stopped work has written its last output.
Hint
If Stop only closes a connection, nothing has told the server what to do with the work: depending on the route it may be cancelled or keep running, and you cannot aim either. And if the next request can start before 'stopped' is true on the server, you have three bugs waiting: a stop signal that catches the wrong request, a read that misses the last output, and a history that is missing a piece.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.