fix(acp): settle prompts that end without a normalized terminal - #3997
Conversation
A Paseo review session ended, then refused every following message with "A foreground turn is already active" while its composer kept spinning. Two open ends in the ACP prompt lifecycle produced it: an owned terminal with no normalized outcome rejected the prompt without publishing the matching idle session_info_update, and an acknowledged session/cancel relied on a terminal frame that never arrives when agent-owned async work outlives the turn, so the waiter stayed pending and every later prompt hit the conflict guard. Lore-id: 7c41a9e2 Constraint: an invalid terminal must still not query context.get/session.metadata -- it carries no trustworthy usage or title Constraint: a real terminal must keep winning inside the cancel grace -- activity frames stay advisory Rejected: settle immediately on aborted:true | discards the authoritative stop reason for a cancel that races a natural end Rejected: publish idle from #emitEndOfTurnUpdates on every path | reintroduces the two SDK queries an invalid terminal cannot answer Confidence: high Scope-risk: narrow Reversibility: easy Tested: cancel with no terminal settles as cancelled and the session accepts the next prompt (unit + live ACP smoke on the compiled binary) Not-tested: cancel racing an in-flight reconnect of the prompt-owner connection
e5b232b to
7025907
Compare
Yeachan-Heo
left a comment
There was a problem hiding this comment.
Exact-head adversarial review — commit 7025907
PR #3997 fix(acp): settle prompts that end without a normalized terminal (probepark, 1 commit, 3 files, +174/−8).
Identity: head 7025907ea82cecfe21c144754ca22696e43b0383 verified as the GitHub PR head (fetched from the head fork; tree diff byte-identical to the GitHub-returned diff). Base fa32aec00e634cff1731c16e633e09671f45e6ea == current dev; PR is not behind and has no conflict.
Exact CI (authoritative via gh): mergeStateStatus: CLEAN, mergeable: MERGEABLE. 21/21 check runs COMPLETED on the head sha — 15 SUCCESS, 6 SKIPPED (platform-gated Windows/darwin-arm64/Python matrix/Live-release), 0 failures, 0 pending. Includes the changed test file (sdk-acp-prompt-terminal.test.ts), ts-build, check:@gajae-code/coding-agent, cli-smoke, native-build, gjc-state-gates, Telegram daemon generation guard, Local public surfaces, evidence producer, plan.
Tests at the exact head (local runs): sdk-acp-prompt-terminal.test.ts 19/19 pass; acp-fallback-cancel-completion 5/5, sdk-acp-production-path 2/2, sdk-acp-adapter 10/10, sdk-acp-provider-reconnect 2/2. Only local failures were natives sentinel mismatches from a symlinked node_modules of a different release (environment artifact; CI native-build green on this head).
Prior feedback: none exists (0 reviews / 0 review comments / 0 issue comments).
Adversarial findings: both wedge paths (invalid terminal never releasing the running phase; acknowledged cancel never settling the pending prompt) are fixed with a bounded 5s grace, a test seam (not a user setting), record-identity revalidation on timer fire, and settled-correlation closure of late terminals. Tests lock exactly-one-idle-update and zero-query behavior without tautologies. Non-blocking only: the reconnect-loss and #failSession/#teardownSession rejection paths share the same pre-existing shape and are explicitly out of scope with justification; idle publish awaits before reject (same shape as existing #emitEndOfTurnUpdates); publish failures swallowed deliberately and documented. Conforms to AGENTS.md (no any, ES #private, no inline imports, changelog under ## [Unreleased]).
VERDICT: MERGE_READY
Problem
A Paseo review session against #3988 ended, then became unusable: the composer kept showing the turn as in progress, and every new message was refused with
A foreground turn is already active.The turn's last agent output was written at
10:10:58Z. Twelve minutes later, the client was still holding it open:A sibling session on the same host recorded the SDK-side cause verbatim:
That closure is what the SDK bus publishes when agent-owned async work outlives the turn (
sdk/bus/index.ts:4260-4271) — in this session, subagents that were still delivering IRC messages after the final assistant message.The ACP layer has two open ends that turn this into a permanently wedged session.
1. A rejected prompt never releases the client's running phase. The only
gjcPhase: "idle"publication lives in#emitEndOfTurnUpdates, reachable exclusively from the normalizedagent_end/agent_failedpath (acp-agent.ts:1903-1906). An owned terminal carrying no normalized outcome takes the#rejectPromptbranch and returns before it, so the ACP request rejects while the client is never told the turn stopped.2.
session/cancelhas no bounded settlement.cancel()forwardsturn.abort, verifiesaborted: true, and returns. Settling the pendingsession/promptis left entirely to a terminal frame that, in this failure mode, never arrives.record.activePromptstays set, soprompt()answersconflict— "ACP session already has an active prompt" — for the rest of the session's life. That is the exact error the client surfaced, and ACP is explicit that the agent must return thecancelledstop reason instead.Change
#rejectPromptpublishes the idlesession_info_updatebefore rejecting. Only the phase transition — nocontext.get/session.metadataqueries, because an invalid terminal carries no trustworthy usage or title to report.session/cancelarms a bounded settlement. A real terminal still wins inside the grace; past it the prompt settles as ACP's mandated{ stopReason: "cancelled" }, publishes idle, and records the correlation so a late terminal stays closed.CANCEL_SETTLEMENT_GRACE_MS, 5s) is a constant with a test seam, following the SDK bus'sPROMPT_TERMINALIZATION_GRACE_MSprecedent. Not a user setting.Scope is deliberately limited to the two paths with direct evidence.
#failSessionand involuntary#teardownSessionshare the shape but did not fire here, so they are untouched.Tests
test/sdk-acp-prompt-terminal.test.ts:gjcRunning: falseand the session accepts a further prompt instead of answeringconflict.cancelled, publishes idle, and leaves the session promptable.refusal, notcancelled), so the existing "activity is advisory, the normalized terminal is authoritative" contract is unchanged.Verification
Rebased onto
dev@fa32aec00.bun --cwd=packages/coding-agent run check— clean. The one biome warning (test/smithery-env-trust.test.ts) is untouched by this PR and present ondev.sdk-acp-prompt-terminal,acp/acp-fallback-cancel-completion,sdk-acp-production-path,run-acp-conformance.src/modes/acp/acp-agent.tsreverted todevand the new tests kept, 4 fail: the two updated invalid-terminal cases, the running-phase/next-prompt regression (Expected "session_info_update", received "user_message_chunk"— no idle update is published at all), and the cancel-without-terminal case, which burns 2012ms because the prompt simply never settles. Restoring the fix returns 19/19.docs/acp-local-development.md:build:native→install:dev:bin→restart:sdk-broker -- --close-session-hosts(50704 -> 59369, 6 stale hosts closed), confirming the broker entrypoint before measuring anything.What the live testing does and does not show
Driven through real Paseo 0.2.5 (
paseo run→paseo stop→paseo ls→paseo send), the fixed build cancels mid-turn, lands onidlewith nolastError, and accepts the follow-up message.But the same flow also passes on an unfixed build, including a variant where three
tasksubagents were still running at cancel time. I could not reproduce the wedge on demand: it needs the SDK'sabortPromptAndWaitsettlement proof to actually miss its 10s grace, and that race did not trigger in any of my attempts. So the Paseo runs are a no-regression check, not proof that this fixes the observed failure.The evidence that it does is forensic plus structural:
lastError: "A foreground turn is already active", preceded in the daemon log bycancelAgentRun: acknowledged turn still active after timeout, force-canceling.ACP prompt terminal was invalid: Prompt resources did not settle before the terminalization grace expired.irc:incomingrecords from two subagents arriving after the final assistant message — agent-owned async work outliving the turn, which is exactly whatabortPromptAndWaitcannot prove settled.#rejectPromptreturns before the onlygjcPhase: "idle"publication, andcancel()had no settlement path at all once the terminal never arrives.A reviewer who wants a live repro will need to fault-inject the settlement proof rather than rely on a natural race.
Unrelated pre-existing failures observed while surveying:
sdk-credential-disabled-bridge.test.ts(8 tests, ~30s of timeout waits) fails ondevas well and imports nothing from the ACP layer.