Skip to content

fix(acp): settle prompts that end without a normalized terminal - #3997

Merged
probepark merged 1 commit into
Yeachan-Heo:devfrom
probepark:fix/acp-prompt-settlement-wedge
Aug 7, 2026
Merged

fix(acp): settle prompts that end without a normalized terminal#3997
probepark merged 1 commit into
Yeachan-Heo:devfrom
probepark:fix/acp-prompt-settlement-wedge

Conversation

@probepark

@probepark probepark commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

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:

10:22:24.528Z  agent-manager  agentId=c3fc36dc… turnId=4b65b513…
               "cancelAgentRun: acknowledged turn still active after timeout, force-canceling"
10:22:24.534Z  lifecycle="running"  activeForegroundTurnId=null  error="A foreground turn is already active"
10:22:30.135Z  lifecycle="error"    activeForegroundTurnId=null  error="A foreground turn is already active"

A sibling session on the same host recorded the SDK-side cause verbatim:

Internal error: ACP prompt terminal was invalid:
Prompt resources did not settle before the terminalization grace expired.

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 normalized agent_end / agent_failed path (acp-agent.ts:1903-1906). An owned terminal carrying no normalized outcome takes the #rejectPrompt branch and returns before it, so the ACP request rejects while the client is never told the turn stopped.

2. session/cancel has no bounded settlement. cancel() forwards turn.abort, verifies aborted: true, and returns. Settling the pending session/prompt is left entirely to a terminal frame that, in this failure mode, never arrives. record.activePrompt stays set, so prompt() answers conflict — "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 the cancelled stop reason instead.

Change

  • #rejectPrompt publishes the idle session_info_update before rejecting. Only the phase transition — no context.get / session.metadata queries, because an invalid terminal carries no trustworthy usage or title to report.
  • An acknowledged session/cancel arms 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.
  • The grace (CANCEL_SETTLEMENT_GRACE_MS, 5s) is a constant with a test seam, following the SDK bus's PROMPT_TERMINALIZATION_GRACE_MS precedent. Not a user setting.

Scope is deliberately limited to the two paths with direct evidence. #failSession and involuntary #teardownSession share the shape but did not fire here, so they are untouched.

Tests

test/sdk-acp-prompt-terminal.test.ts:

  • The two invalid-terminal cases previously asserted no idle update. That assertion locked in the bug for owned-but-invalid terminals, where the turn genuinely is over; they now assert exactly one idle update and still zero new query calls.
  • New: after a settlement-grace rejection, the last update reports gjcRunning: false and the session accepts a further prompt instead of answering conflict.
  • New: a cancel whose aborted turn never publishes a terminal settles as cancelled, publishes idle, and leaves the session promptable.
  • New: a terminal arriving inside the grace still wins (refusal, not cancelled), 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 on dev.
  • Focused ACP suites — 36/36 across sdk-acp-prompt-terminal, acp/acp-fallback-cancel-completion, sdk-acp-production-path, run-acp-conformance.
  • Control run. With src/modes/acp/acp-agent.ts reverted to dev and 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.
  • Local ACP loop per docs/acp-local-development.md: build:nativeinstall:dev:binrestart: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 runpaseo stoppaseo lspaseo send), the fixed build cancels mid-turn, lands on idle with no lastError, and accepts the follow-up message.

But the same flow also passes on an unfixed build, including a variant where three task subagents were still running at cancel time. I could not reproduce the wedge on demand: it needs the SDK's abortPromptAndWait settlement 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:

  • The wedged session's agent record ends at lastError: "A foreground turn is already active", preceded in the daemon log by cancelAgentRun: acknowledged turn still active after timeout, force-canceling.
  • A sibling session on the same host recorded the SDK cause verbatim: ACP prompt terminal was invalid: Prompt resources did not settle before the terminalization grace expired.
  • The wedged session's transcript ends with irc:incoming records from two subagents arriving after the final assistant message — agent-owned async work outliving the turn, which is exactly what abortPromptAndWait cannot prove settled.
  • By inspection both paths are unconditional: #rejectPrompt returns before the only gjcPhase: "idle" publication, and cancel() 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 on dev as well and imports nothing from the ACP layer.

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
@probepark
probepark force-pushed the fix/acp-prompt-settlement-wedge branch from e5b232b to 7025907 Compare August 7, 2026 11:48

@Yeachan-Heo Yeachan-Heo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@probepark
probepark merged commit 1aac1f1 into Yeachan-Heo:dev Aug 7, 2026
21 checks passed
@probepark
probepark deleted the fix/acp-prompt-settlement-wedge branch August 7, 2026 13:58
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.

2 participants