fix(chat): thread the abort signal into local turns so Ctrl+C works [SC-A1.1] - #77
Merged
Conversation
Lane SC-A1, slice 1 of the cancellable tool runtime.
The REPL builds an AbortController per turn and aborts it on Ctrl+C. runTurn
accepted that signal and then dropped it on the local branch:
const backend = await resolveBackend(ctx);
if (backend === "local") {
await runLocalTurn(ctx, prompt); // signal not passed
return;
}
So Ctrl+C did nothing to a local turn. The abort fired and nothing was
listening; the turn ran to completion regardless. Only the cloud path was ever
cancellable.
runLocalTurn now takes the signal and closes the brain on abort. close() is
what unblocks a loop parked on a tool result, so an abort arriving mid-turn is
observed rather than waiting the turn out. The listener is registered before
the loop starts, so a signal that is already aborted is honoured instead of
starting work that was cancelled before it began.
An aborted turn returns rather than throwing. The user asked for the stop; it
is not a failed turn and must not be reported as one.
Adds a LocalTurnDeps seam ({ brain, exec }) mirroring the one smoke.ts already
uses, so the abort path is testable without an Ollama server, a child process
or real tool execution. runLocalTurn becomes exported for the same reason.
Tests: 4 added, driving a brain that emits one tool_call and then parks exactly
as the real one does while awaiting sendToolResult — abort reaches the brain, an
already-aborted signal stops the turn, an aborted turn does not reject, and a
turn with no signal still completes.
Mutation-checked, and the failure mode is the interesting part: restoring the
old behaviour does not fail the tests, it HANGS them. The runner is killed by
timeout with exit 124, having produced no TAP summary at all, because the turn
never settles. Restored, the same run exits 0. That hang is precisely what a
user experienced when they pressed Ctrl+C.
Gates at this commit:
npm run typecheck exit 0
npm test 926 pass / 0 fail (922 on clean 41a7e26)
Scope note. This makes a local turn cancellable BETWEEN steps — during a model
request, or while parked awaiting a tool result. It does not interrupt a tool
already executing: tool_executor.ts still uses blocking spawnSync with no
AbortSignal, so Ctrl+C during a long `run_tests` is still not observed until
that command returns, and the command's children are still orphaned on timeout.
Fixing that requires spawn() with process-group cleanup, which requires
ToolExecutor.run to become async, which requires finalVerify to become async —
it is the synchronous ground-truth gate (verify_gate.ts:71, called from
code.ts:371, with 13 tests pinning its behaviour). That is a deliberate,
separate slice rather than something to graft onto this one.
This was referenced Aug 19, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Ctrl+C did nothing to a local turn.
The REPL builds an
AbortControllerper turn and aborts it on Ctrl+C.runTurnaccepted that signal — and then dropped it:The abort fired and nothing was listening. The turn ran to completion regardless. Only the cloud path was ever cancellable.
Contract
runLocalTurntakes the signal and closes the brain on abort.close()is what unblocks a loop parked on a tool result, so an abort arriving mid-turn is observed rather than waiting the turn out.The listener is registered before the loop starts, so a signal that is already aborted is honoured rather than starting work that was cancelled before it began.
An aborted turn returns rather than throwing. The user asked for the stop; it is not a failed turn and must not be reported as one.
Implementation
Adds a
LocalTurnDepsseam ({ brain, exec }) mirroring the onesmoke.tsalready uses, so the abort path is testable without an Ollama server, a child process, or real tool execution.runLocalTurnis exported for the same reason.Tests
4 added, driving a brain that emits one
tool_calland then parks — exactly as the real one does while awaitingsendToolResult.Mutation-checked — and the failure mode is the point. Restoring the old behaviour does not fail the tests, it hangs them:
That hang is precisely what a user experienced when they pressed Ctrl+C.
Gates at
8ca821d:npm run typechecknpm testBaseline on clean
41a7e261, measured in the same session: 922 / 0.Scope — what this does NOT fix
This makes a local turn cancellable between steps: during a model request, or while parked awaiting a tool result.
It does not interrupt a tool that is already executing.
tool_executor.ts:88still uses blockingspawnSyncwith noAbortSignal, so:run_testsis still not observed until that command returns.spawnSyncsignals only the direct child (cmd.exe/sh), so the actualnpm test/pytestkeeps running. Near-certain on Windows.Why that is a separate slice, not grafted on here: fixing it needs
spawn()with process-group cleanup →ToolExecutor.runmust become async →execute()can no longer serverun_shell/run_testssynchronously →finalVerifymust become async. That is the synchronous ground-truth gate (verify_gate.ts:71, called fromcode.ts:371, with 13 tests pinning its behaviour, including the "a brain can never upgrade a red run" contract).Turning the verification gate async deserves its own reviewable change rather than riding along behind a three-line signal fix.
Security notes
finally, so a long REPL session cannot accumulate listeners on reused signals.Known limits
spawnSyncitems above.chat.tsstill re-implements the host loop rather than sharingcode.ts's, and the two have already drifted: different permission-prompt fields (chat.tsreadsurl/query,code.tsdoes not), different truncation widths, different denial strings, and nofinalVerifyon the REPL path at all. Loop convergence is a later slice of this lane.hostLoopstill has no turn or wall-clock budget forLocalBrain/CloudBrain.Dependency and merge order
Independent of #72, #73, #74, #75 — branched from
origin/main.Touches
src/commands/chat.ts, which SC-A4 (#75) deliberately avoided for this reason. #75 touches onlybrain_ollama.ts, so there is no overlap; either order works.