Skip to content

fix(chat): thread the abort signal into local turns so Ctrl+C works [SC-A1.1] - #77

Merged
AetherAI3 merged 3 commits into
mainfrom
supercluster/a1-tool-runtime
Aug 19, 2026
Merged

fix(chat): thread the abort signal into local turns so Ctrl+C works [SC-A1.1]#77
AetherAI3 merged 3 commits into
mainfrom
supercluster/a1-tool-runtime

Conversation

@AetherAI3

Copy link
Copy Markdown
Owner

Problem

Ctrl+C did nothing to a local turn.

The REPL builds an AbortController per turn and aborts it on Ctrl+C. runTurn accepted that signal — and then dropped it:

const backend = await resolveBackend(ctx);
if (backend === "local") {
  await runLocalTurn(ctx, prompt);   // ← signal not passed
  return;
}
await runCloudTurn(ctx, prompt, signal, onFrame, onPulsePaint);   // cloud gets it

The abort fired and nothing was listening. The turn ran to completion regardless. Only the cloud path was ever cancellable.

Contract

runLocalTurn 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 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 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 is 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.

test proves
aborting closes the brain the signal reaches something that can act on it
an already-aborted signal stops the turn no work starts that was cancelled first
an aborted turn does not reject a user-requested stop is not an error
no signal still completes normally the happy path is unchanged

Mutation-checked — and the failure mode is the point. Restoring the old behaviour does not fail the tests, it hangs them:

result
signal dropped (old behaviour) killed by timeout, exit 124, zero TAP summary lines — the turn never settles
signal wired (this PR) exit 0, 4/4 pass

That hang is precisely what a user experienced when they pressed Ctrl+C.

Gates at 8ca821d:

command result
npm run typecheck exit 0
npm test 926 pass / 0 fail

Baseline 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:88 still uses blocking spawnSync with no AbortSignal, so:

  • Ctrl+C during a long run_tests is still not observed until that command returns.
  • A timed-out command still orphans its children — spawnSync signals only the direct child (cmd.exe / sh), so the actual npm test / pytest keeps running. Near-certain on Windows.
  • The whole event loop is blocked for the duration, so heartbeats and animations freeze too.

Why that is a separate slice, not grafted on here: fixing it needs spawn() with process-group cleanup → ToolExecutor.run must become async → execute() can no longer serve run_shell/run_tests synchronously → finalVerify must become async. That is the synchronous ground-truth gate (verify_gate.ts:71, called from code.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

  • No change to what is executed, or to permission evaluation — this only adds a cancellation path.
  • No new process spawning, no new filesystem or network access.
  • The abort listener is removed in finally, so a long REPL session cannot accumulate listeners on reused signals.

Known limits

  • The blocking-spawnSync items above.
  • chat.ts still re-implements the host loop rather than sharing code.ts's, and the two have already drifted: different permission-prompt fields (chat.ts reads url/query, code.ts does not), different truncation widths, different denial strings, and no finalVerify on the REPL path at all. Loop convergence is a later slice of this lane.
  • hostLoop still has no turn or wall-clock budget for LocalBrain/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 only brain_ollama.ts, so there is no overlap; either order works.

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.
@AetherAI3
AetherAI3 marked this pull request as ready for review August 19, 2026 13:00
@AetherAI3
AetherAI3 merged commit 430178a into main Aug 19, 2026
5 checks passed
@AetherAI3
AetherAI3 deleted the supercluster/a1-tool-runtime branch August 19, 2026 13:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant