Repository navigation
agent-console 1.3.4: fix interrupt races (ACP/Devin Method-not-found, Claude/Codex stale results) - #768
Conversation
Devin CLI registers session/cancel as CancelNotification only. Sending it as a JSON-RPC request returned -32601 Method not found; the manager's 1s fallback marked the page idle while AcpSession.running stayed true, so the next send failed with 当前轮次尚未结束. - ACP (Cursor/Copilot/OpenCode/Pi/Devin/dsh): notify cancel + immediate local endTurn(interrupted); discard late stopReasons - Claude/Codex: end local turn on interrupt; discard late/stale provider results - Manager: one retry if send still hits the in-flight-turn guard - Tests: acp-interrupt, codex-interrupt, interrupt-audit, Claude late-result case Bump to 1.3.4; rebuild daemon/dist.
|
Thanks — the ACP part is the right fix (session/cancel as a notification), and the three acp-interrupt tests are red on main, which is great. We ran the whole repo at 5b2c7ba on our box; a few things before this can merge:
|
1. Bump packages/agent-console/package-lock.json to 1.3.4 (lockfile in step). 2. Claude: keep real error_during_execution; drop late results by FIFO of results owed (not sawInterrupt/status); serialize fixture turns; add race test. 3. ACP: replace pendingCancelStops with per-prompt() gen discard so a -32603 on the cancelled prompt cannot swallow the next turn's stop; regression test. 4. Codex: take turn id from turn/start response (remove staleNextCodexTurn); Stop-before-started + immediate resend must still send turn/interrupt. 5. Remove untested manager retry keyed on 当前轮次尚未结束 (providers clear in-flight turns on interrupt after 2–4). Rebuild daemon/dist. No npm publish / tags / own-main.
yetone
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround. We re-ran everything at 45a67b2 on our macOS box (sandbox HOME): root HOME=$(mktemp -d) bun test → 6171 pass / 0 fail; bun scripts/check.mjs → ok; in packages/agent-console: npm ci --workspaces=false, npm run check (tsc) → ok, npm run build → daemon/dist/main.js byte-identical to the committed one, npx vitest run → 68/68 files pass.
Per point from the last review:
- Lockfile — fixed; the root suite is green.
- ACP — fixed. Ending each turn from its own
prompt()promise with a per-gen discard is the right shape. All four acp-interrupt tests are red on main (2b7eb23 / origin/main 39d4456 with only your test files copied in) and green here. We also checked that 40 chunks sent right before the prompt response all land beforeturn_ended(they do). Still open: have you tried Stop → resend with the real Devin CLI? Please say so either way in the PR. - Manager retry — removed, good.
Two things still block the merge:
2. Claude: the FIFO outlives the CLI it counts. awaitingResult / skipResultGens are per session, but results come per CLI process. When cli.interrupt() fails, the catch closes that CLI and sets needsRestart, and endLocal() adds the interrupted gen to skipResultGens. That gen's result will never arrive. The restarted CLI's first result (the next turn's) shifts the stale gen, finds it in the skip set and is dropped, so the next turn never ends. On main the same sequence ends normally. Probe: fake-claude.mjs with one added line, answering interrupt with an error:
if (process.env.PROBE_INTERRUPT_FAIL) return out({ type: "control_response", response: { subtype: "error", request_id: m.request_id, error: "busy" } });send SLOW → wait for a delta → interrupt() → wait for turn_ended → send second → wait 4s:
head 45a67b2: [["turn_started"],["turn_ended","interrupted"],["turn_started"]] ← second turn never ends
main: [["turn_started"],["turn_ended","interrupted"],["turn_started"],["turn_ended","completed"]]
The same misalignment occurs after a crash: a turn whose CLI died leaves its gen in awaitingResult, so every later result is matched to the wrong gen. Reset both structures whenever a CLI is replaced or exits, so they belong to one CLI (in ensureCli() when a new ClaudeCli is made, or keep them on the CLI object).
Also, neither new claude-cli test is red on main: both pass on 39d4456 with your fixture. On main interrupt() doesn't end the turn locally, so await until(ended) simply waits for the late result, and second is sent after it. So the tests never send before the late result on the code they are meant to guard. Please make the test check the order, for example that the second user line is in FAKE_CLAUDE_LOG before the interrupted turn's result, or that the local turn_ended arrives well inside FAKE_CLAUDE_INTERRUPT_RESULT_MS. Then it is red on main.
4. Codex: Stop while turn/start is still in flight, then an immediate new message. Using the response's turn id fixes the window between the response and turn/started. The window before the response is still the old bug, and the head now makes it easier to hit, because Stop ends the turn locally at once. Sequence: interrupt() sees no Codex id → interruptWanted = true and the turn ends locally. The user sends again → send() sets interruptWanted = false, and ourTurnId is null, so nothing is marked stale. Turn 1's turn/start response arrives → st.currentTurnId = codex-turn-1 (on top of turn 2's) with interruptWanted false → no turn/interrupt. Turn 1 runs to the end, and its turn/completed ends turn 2. Turn 2's own completion then comes out as an extra turn_ended under the raw Codex id. Probe: fake-codex-app-server.mjs delaying only the first turn/start response (PROBE_START_DELAY_MS=400, FAKE_CODEX_SLOW_MS=1500); send(first) not awaited → wait turn_started → interrupt() → wait turn_ended → send(second):
head 45a67b2: turn/interrupt sent: []
turn_started u_…8drmh4o1 · turn_ended u_…8drmh4o1 interrupted · turn_started u_…vg2vit1y
turn_ended u_…vg2vit1y completed ← turn 1's completion (codex-turn-1 ran to the end)
turn_ended codex-turn-2 completed ← stray end
main: turn/interrupt sent: ["codex-turn-1"], one interrupted + one completed
The pending interrupt should belong to the turn it was asked for, not be one flag that the next send() clears. For example, send() keeps its own turnId, and when its turn/start response arrives after that turn was interrupted locally (this.ourTurnId !== turnId), it adds the Codex id to staleCodexTurns, sends turn/interrupt for it, and leaves st.currentTurnId alone.
Also, Stop before turn/started still sends turn/interrupt from turn/start response id says it "is red without the response-id fix", but it passes on main (1277 ms): on main the late turn/started triggers the deferred interrupt, and the test's until(turn_ended interrupted) waits for it. Of the four codex tests, only after interrupt, send() works again… is red on main. Please add one that is red on main for the "Stop, then resend at once" case, for example the probe above, asserting turn/interrupt for codex-turn-1 and exactly one completed end.
What we ran (probe files are local only, not pushed): mote-run copies of 45a67b2 and of origin/main 39d4456 with your test files and our probe files copied in, then npx vitest run test/v1/{acp-interrupt,claude-cli,codex-interrupt,interrupt-audit,probe-review}.test.ts.
…pt; ACP cancel settle - Claude: resetResultFifo on CLI create/replace/exit/failed interrupt so skip/awaiting do not outlive the CLI - Codex: cancelledLocalTurns + turn/start response ownership so interrupt during turn/start still interrupts the right Codex turn - ACP: pendingCancelSettle waits for session/cancel + previous prompt settle (+1 macrotask) before activating the next session/prompt; per-turn ownership after buildAcpPrompt; ignore trailing updates while activeTurnId is null (Devin 0ms resend race) - Tests: Claude/Codex interrupt races; ACP FAKE_ACP_CANCEL_SETTLE_MS 0ms-resend (RED on 1b88ac8, GREEN with fix)
|
Pushed
|
yetone
left a comment
There was a problem hiding this comment.
Thanks. The interrupt work reads well, and the new fakes (late result, failed interrupt, crash, delayed cancel settle) cover the cases from the last review. I ran it at 8fd77eb on macOS (Node 22, HOME=$(mktemp -d)):
npm ci --workspaces=false,npm run checkandnpm run build: all ok.daemon/distcame out byte-identical to the committed one.npx vitest run: 68 files, 505 passed, 17 skipped.
Then I broke each fix by hand and reran claude-cli, codex-interrupt, acp-interrupt, interrupt-audit and manager (43 tests). Three went red:
- Claude, interrupt no longer adds the gen to
skipResultGens: 2 fail. - Claude, the crash path no longer sets
needsRestart: 2 fail. - ACP,
runTurnno longer waits forpendingCancelSettle: 1 fail.
These five still pass with the fix taken out:
- Codex,
interrupt()no longer adds the turn tocancelledLocalTurnswhile turn/start is in flight. The test "Stop while turn/start is in flight, then immediate resend interrupts codex-turn-1" says it is red without this, but it stays green. - Codex, the turn/start response handler drops
|| this.ourTurnId !== turnIdand checks onlycancelledLocalTurns. Each half passes alone, so either one of them is redundant (please remove it), or a test should show the case only that half covers. - ACP,
interrupt()no longer addsactivePromptGentocancelledPromptGens. That includes theFAKE_ACP_CANCEL_AS_ERRORcase it was written for. - Claude,
pump()no longer ignores a replaced CLI's stdout (if (this.cli !== cli) continue;). - Claude, the crash path no longer calls
resetResultFifo().
Please add a test for each that goes red when its line is removed, or remove the line if nothing needs it.
Two more:
- Timing bars measured from before the first send. codex-interrupt (
toBeLessThan(250)andtoBeLessThan(350)) and claude-cli (toBeLessThan(delay - 50)andtoBeLessThan(delay)) taket0beforesend(first). That includes spawning the fake and initialize, so on a loaded machine they fail with nothing wrong. Please test the order instead of the wall clock. For example, let the fake hold the late result, the turn/start response or turn/started until the test releases it (a file or a stdin line). Then assert that the localinterruptedand the second send both happen before the release. A test like that can't flake and still fails on main. - The Claude crash path keeps the event queue open (
this.q.close()removed). The manager then no longer marks the thread closed after a crash, and the next send restarts with--resume. That is fine if intended. Please say so in the PR body, and add a manager-level test that a crashed Claude thread resumes on the next send.
Address review on 8fd77eb: 1-2. Codex: drop cancelledLocalTurns; ourTurnId !== turnId alone covers Stop-while-turn/start-in-flight (with or without immediate resend). Simplify empty if (!codexTurn) else to if (codexTurn). 3. ACP: drop cancelledPromptGens / promptGen / activePromptGen; simplify inFlightPrompt to Promise<void> | null; late settle guarded only by activeTurnId === turnId (running half was redundant). 4. Claude: keep if (this.cli !== cli) continue; add linger-poison test. 5. Claude: move turnGen / awaitingResult / skipResultGens onto ClaudeCli; endLocal marks awaitingResult.at(-1); no session-level currentGen. 6. Replace wall-clock toBeLessThan bars with hold-file release gates (FAKE_*_HOLD_FILE) in codex-interrupt and claude-cli. 7. Document intentional open event queue on Claude crash; add manager-level test that next send resumes with --resume. Mutation audit (vitest red when each surviving guard is removed) and red-on-main (2b7eb23 sources + new tests) both checked. No push.
|
Pushed 1–2. Codex ( Verified every surviving guard goes red when removed; |
Follow-up after #760. Community
mainalready has 1.3.3, so this bumpspackages/agent-consoleto 1.3.4.Bug
Stopping a turn with Devin (and the same ACP path used by Cursor / Copilot / OpenCode / Pi / dsh) could leave the console stuck:
session/cancelas a notification (CancelNotification) only.-32601 Method not found.AcpSession.runningstayedtrue.Claude Code and Codex never threw that exact error (custom transports), but the same class of race existed: UI could go idle after the manager fallback while a late provider end rebound onto the next send.
What it does
session/cancelas a notification; end the local turn immediately withinterruptedsorunning/activeTurnIdclear; wait briefly forsession/cancel+ previous prompt settle before sending an overlappingsession/prompton immediate resend (verified live againstdevin 3000.11.3).turnGen/awaitingResult/skipResultGensonto eachClaudeCliprocess so late results from interrupted turns are dropped in FIFO order without outliving a crashed or restarted CLI, and ignore leftover stdout from a replaced CLI. Crash mid-turn: we intentionally keep the session event queue open (this.q.close()is not called on crash), so the manager leaves the threadidleafterturn_ended(failed)instead of permanentlyclosed, and the nextsend()restarts the CLI with--resume <claudeSessionId>.turn/startresponse’s turn id and checkourTurnId !== turnIdwhen an in-flightturn/startresolves after Stop socodex-turn-1still receivesturn/interruptwithout overwriting a newer turn.Security model unchanged (page token, Host check, WS hello auth).
Tests
npm run check: OKnpm test: 68 files, 507 passed / 17 skipped (hold-file order gates instead of wall-clock timing bars; mutation-checked so every surviving guard goes red when removed)npm run build+verify:build: OK;daemon/distrebuilt and committed (app/unchanged — no UI source edits)Notes
@yetone could you take a look when you have time? Thanks!