Flow: Abort: clear the queue, hand it back, let the settle end the run
A stop is one POST /api/threads/:id/abort with no body: the thread id is the whole request (#116). The registry clears the runtime’s queue BEFORE telling it to abort and answers with what was waiting (clear-and-restore, #75), for the composer of the tab that stopped. The route releases nothing: the run ends on the follow by its own settled, as any run does, and the runtime is told once; a repeat while the abort is pending gets the same answer with nothing restored. 200 with the receipt; 409 nothing in flight; 404 not live.
The sequence
Section titled “The sequence”The server
Section titled “The server”Recorded by follow.test.ts: ⚠ POST …/abort tells pi once and answers with the restored queue; the run still ends on the follow by its settle, not by the route (#116, #75). Arrows are what the test drove and what its doubles received; notes are what the code logged at debug and above, in the order it logged them.
sequenceDiagram
participant B as browser (the test client)
participant R as abort route (server/index.ts, follow.ts)
participant T as thread registry
participant P as runtime (fake host)
participant F as feed (observation log)
B->>R: POST /api/threads/never/abort
Note over R: abort refused (404): no live session for thread never
R-->>B: 404
B->>R: POST /api/threads/t1/prompt {message: "hello"}
Note over T: status to followers: live true, busy false, run 0
Note over T: thread built: session s1 in /s
Note over T: status to followers: live true, busy true, run 1
Note over T: run 1 acquired
R->>P: prompt "hello" { onAccepted, onRejected }
Note over R: prompt [id] opened on run 1
B->>P: the test queues two prompts behind the run
B->>R: POST /api/threads/t1/abort
T->>P: clearQueue()
Note over F: queue
T->>P: abort()
Note over T: run 1 told to abort · 2 queued prompt(s) handed back
Note over R: abort: queue of 2 restored to the caller
R-->>B: 200 {threadId: "t1", restored}
P->>F: settled
Note over F: settled
Note over T: status to followers: live true, busy false, run 1
Note over T: run 1 released · idle timer [ms] ms
B->>R: POST /api/threads/t1/abort
Note over R: abort refused (409): nothing is running on thread t1
R-->>B: 409
The browser
Section titled “The browser”Recorded by follow-connection.test.ts: ⚠ abort() hands back what pi had queued; a later rejection of a queued prompt is said, not matched to a run. Arrows are what the test drove and what its doubles received; notes are what the code logged at debug and above, in the order it logged them.
sequenceDiagram
participant C as composer (the test)
participant U as TanStack (useChat)
participant A as adapter (follow-connection.ts)
participant S as server (scripted)
S->>A: snapshot
A->>U: CUSTOM snapshot
A->>U: RUN_STARTED external-1
C->>A: prompt("and then this"), during the run
A->>S: POST /api/threads/t1/prompt {message: "and then this", admission: "enqueue"}
A->>U: TEXT_MESSAGE_START
S-->>A: 202 {threadId: "t1", messageId: "pm1", generation: 2, queued: true}
A-->>C: queued
C->>A: abort()
A->>S: POST /api/threads/t1/abort
S-->>A: 200 {threadId: "t1", restored}
A-->>C: aborted, restored 2
S->>A: observation 1: prompt-rejected
Note over A: queued prompt refused: aborted
The invariants it shows
Section titled “The invariants it shows”The run ends on the follow by its settle, not by the route.
Section titled “The run ends on the follow by its settle, not by the route.”The runtime’s abort() resolves when the agent is idle again, and the run still settles (pi emits agent_settled from a finally); the prompt route’s settle observer (promptThread) releases the lease, so every follower sees the same settled + status it would for a natural end. The registry keeps the abort in flight and the next acquire awaits it, so a prompt straight after a stop starts against an idle session (the module header’s abort-then-prompt race). The browser’s abort() closes no run either; idle, gone, unreachable are outcomes, not throws.
Stated at abortThread in server/follow.ts.
Pinned by:
follow-connection.test.ts: abort is one POST to …/abort and names what the server did; it closes no run itself — the feed does (#116)
follow.test.ts: ⚠ POST …/abort tells pi once and answers with the restored queue; the run still ends on the follow by its settle, not by the route (#116, #75)
thread-registry.test.ts: abort stops the run and never disposes; a repeat while it is pending is the same answer, not a second call; the next acquire waits for it (#116)
One abort per run, by thread; the route releases nothing.
Section titled “One abort per run, by thread; the route releases nothing.”The stop is addressed by thread id because the lease that took the run lives in the prompt route’s closure and a later request cannot reach it. busy stays true until the run’s settle lands, so a second click (PR #120 review) would reach pi again. pi’s abort() is re-entrant (agent.abort() + waitForIdle()), but the second call is the same answer, so it is not made: a repeat while the abort is pending is answered aborted with an empty restored, since the first stop already took the queue. No live thread is unknown (a 404), nothing in flight idle (a 409); neither reaches the runtime.
Stated at abort in server/thread-registry.ts.
Pinned by:
follow.test.ts: ⚠ POST …/abort tells pi once and answers with the restored queue; the run still ends on the follow by its settle, not by the route (#116, #75)
thread-registry.test.ts: abort stops the run and never disposes; a repeat while it is pending is the same answer, not a second call; the next acquire waits for it (#116)
thread-registry.test.ts: abort on an idle thread or an unknown one touches nothing, and says which (#116)
thread-registry.test.ts: ⚠ abort hands back the queued prompts in order and clears pi’s queue; a repeat restores nothing
The queue is cleared before the runtime is told, and comes back steering first.
Section titled “The queue is cleared before the runtime is told, and comes back steering first.”pi’s loop leaves on aborted before its follow-up drain, so anything waiting would otherwise ride into the next prompt unasked (#75). So clearQueue() first, then abort(), and the answer is the steering texts followed by the follow-ups. clearQueue() also emits the emptied queue observation, so every follower’s list clears; the texts go only to the caller. The stop is an info record carrying the COUNT handed back, never the texts.
Stated at abort in server/thread-registry.ts.
Pinned by:
follow.test.ts: ⚠ POST …/abort tells pi once and answers with the restored queue; the run still ends on the follow by its settle, not by the route (#116, #75)
thread-registry.test.ts: ⚠ abort hands back the queued prompts in order and clears pi’s queue; a repeat restores nothing
A stop must stop.
Section titled “A stop must stop.”If clearing the queue throws, the loss is an error record, restored is empty, and the runtime is still told to abort (PR #129 review); never the other way round.
Stated at abort in server/thread-registry.ts.
Pinned by:
thread-registry.test.ts: ⚠ a stop must stop: if clearing the queue throws, pi is still told to abort and the loss is said (PR #129 review)
The stopping tab’s composer gets the texts back; the bubbles that were never asked leave.
Section titled “The stopping tab’s composer gets the texts back; the bubbles that were never asked leave.”A stop’s restored is appended to the draft after anything already typed, and the user bubbles this composer put in the transcript for prompts the runtime still held are removed; a claimed prompt’s bubble stays, and deltas that streamed meanwhile are kept (the filter reads the latest committed transcript, not this render’s). Only texts come back: images attached to a queued prompt are not restored, a limit the composer states, not a gap.
Stated at takeBack in chat-screen.tsx.
Pinned by:
thread-pane.test.tsx: Enter or the button: one POST …/prompt each and the draft clears; DURING a run the prompt goes to pi now, listed above the composer, and a stop hands it back (#75)
A later rejection of a queued prompt is said, not matched to a run.
Section titled “A later rejection of a queued prompt is said, not matched to a run.”A prompt-rejected naming one of these is reported where the human looks and opens no run error. settled forgets the ids: nothing behind a settled run can be refused any more; the runtime claimed it, or a stop cleared it.
Stated at queuedPrompts in follow-connection.ts.
Pinned by:
follow-connection.test.ts: ⚠ abort() hands back what pi had queued; a later rejection of a queued prompt is said, not matched to a run
The contract
Section titled “The contract”/** * What `POST /api/threads/:id/abort` answers with when pi was told (#116, #75): the prompts * that were WAITING behind the stopped run, in order, the interrupting ones first — cleared from the * queue and handed back so the stopping tab can put them in its composer (pi's TUI does the * same). * * Never delivered silently on the next turn, never dropped. Empty when nothing waited. The * run's own end still arrives on the follow. */export const EnsoAbortReceipt = Type.Object( { threadId: Type.String(), restored: Type.Array(Type.String()), }, { additionalProperties: false },)Where it is stated
Section titled “Where it is stated”abortThreadinserver/follow.ts: the flow, settle-ends-runabortinserver/thread-registry.ts: one-abort-by-thread, queue-cleared-first, stop-must-stoptakeBackinchat-screen.tsx: composer-takes-backqueuedPromptsinfollow-connection.ts: queued-rejection-saidabortandclearQueue, the runtime’s contract- a stop restores through the receipt, not the
queueframe - decided constraints: abort is by thread, not by lease (#116); a stop hands the queue back (#75); control-plane invariant 2
- the words: abort, stop (Control)
