Skip to content

fix(approval): present parked approvals and settle the turns they end - #1361

Merged
TheGreatAxios merged 1 commit into
mainfrom
cl-9713-add-grant-preserving-watchdog-recovery-for-stuck-agent-calls
Oct 8, 2026
Merged

TheGreatAxios merged 1 commit into
mainfrom
cl-9713-add-grant-preserving-watchdog-recovery-for-stuck-agent-calls

Conversation

@bwachman

@bwachman bwachman commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • An approval that parks in a turn started by deliver() (a worker wake, a background shell exit, a queued prompt on an idle agent) reaches the operator. Only send() turns a park into a suspended result, so these approvals used to sit unseen until they timed out. The stream sink now routes approval reactor.gate.blocked events through the same correlated approval path a send uses. Whichever of the two arrives first presents the approval and the other joins it, so the operator is asked once.
  • A parked call that ends without running (rejected, timed out, or its approval dropped) no longer leaves the turn at "working". The reactor answers such a call with a synthetic error result and emits no tool.done, so the TUI kept it active and never settled the turn. The bridge records which active calls had not started executing when an approval gate blocked. The reactor cannot re-infer while a call is parked, so an inference.start that finds one still unstarted closes it as "not run" through the same path a tool.done takes. Approved re-dispatches, siblings already executing, and non-approval gates are left alone.
  • When the stall watchdog fires on a parked approval, it re-presents that one operation only if it is still persisted, its call and snapshot match what was captured, it has not timed out, the session generation is unchanged, and no settlement exists. Anything else takes the existing interrupt with manual-retry guidance.
  • The bridge runs at most one resume attempt at a time, abandons it after one more stall budget, counts it as resumed only when a decision is delivered, and records a content-free outcome code as a turn marker. No grant is minted, widened or copied by any of these paths, and the vendored reactor is unchanged.
  • Ctrl+C stops the run while an approval or operator prompt is open. It declines the open prompt the way Esc does, then interrupts the turn, which drops queued prompts. It interrupts whether or not the shell reads as busy, since a turn started by a delivered message can park without a composer send marking it. Esc still declines only the open prompt.

Why the Ctrl+C change is here

This is an existing gap that the first two fixes expose, not a regression. A permission or operator prompt has always held every key until it was answered (src/tui/shell/keys.ts returns from the overlay branch before it reaches the Ctrl+C handler), and that is the same on main. The only change to key handling in this PR is the last commit.

What this PR changes is how often it matters. A turn that continues after an approval is decided is not a send, so on main its further approvals park silently instead of prompting. With every parked approval now presented, a run that keeps issuing tool calls after a rejection can raise prompts faster than they can be answered: each rejection re-infers into more calls, often several at once, and every one needs approval under --no-auto. In a live run on this branch before the Ctrl+C change that was 21 prompts in about 100 seconds, with Ctrl+C, typing exit and /model all swallowed until the session was killed. That sequence was not run on main. Shipping the first two fixes without the Ctrl+C change would leave it reachable, so it is included here. An explicit "Reject and stop" choice in the prompt is a separate, lower-priority request.

Verification

  • bun run check passes (lint, typecheck, dead exports, build, guarded test suite: 8380 pass, 0 fail)
  • bun run typecheck passes at each of the six commits
  • e2e/reactor-approval-bare-deliver.test.ts drives the real reactor with a bare deliver() and pins the silent park, the stream route, and the watchdog re-presenting the persisted approval
  • e2e/reactor-approval-parked-call-settles.test.ts captures the real reactor's stream for a rejected and an approved parked call, confirms the rejected one emits no tool.done, and replays both into the bridge: the rejected call closes as not run and the turn settles, and the approved one reports its own result. The rejected case fails without the bridge change.
  • Bridge tests press real keys into wired gates: Ctrl+C on a permission prompt declines it and interrupts (with the shell busy and idle), queued prompts drop on the identity abort, Ctrl+C on an operator question cancels it and interrupts, and Esc declines without interrupting. The Ctrl+C cases fail without the change.
  • Live TUI (xai · grok-4.7-build-fast, --no-auto): a Reject in a normal turn and a Reject in a turn started by a background shell exit both closed the call as not run and settled the turn without an interrupt. An approval in a turn started by a background shell exit was raised as soon as the turn parked, and the accepted call ran. On main, the same Reject leaves the turn at "working".
  • Live TUI check of the Ctrl+C change on a prompt flood (steps 5 below). Not run yet.
  • Not exercised: a real approval timeout. It ends the call through the same reactor path as a rejection (synthetic error result, no tool.done), which the tests above cover.

Manual test

Auto mode is on by default and auto-approves non-destructive shell commands, so pass --no-auto or nothing parks. Use a model with well-formed tool calls (codex/plus · gpt-5.6-terra intermittently sent garbled working-directory arguments, which the gate hard-denies before any approval).

  1. bun install && bun run build:bin, then ./dist/corbits --no-auto. Confirm the header line shows no yolo.
  2. "Run touch cl9713-smoke-send as its own command in the current directory." Reject it. The row reads not run: no approval was granted and the footer returns to idle.
  3. "Use run_shell with background: true to run sleep 15. Run nothing else now and end your turn. When you are notified the background command finished, run touch cl9713-smoke-deliver as its own command in the current directory." Accept the sleep once. About 15 seconds later the touch prompt appears on its own. Accept once; the call runs and the turn settles.
  4. Repeat step 3 with a different file name and Reject the touch. The turn settles as in step 2.
  5. "Review the uncommitted changes in this repo." Reject the first prompt or two, then press Ctrl+C while a prompt is open. The prompt closes, no further prompts appear, the turn stops, and the footer returns to idle.
  6. ls cl9713-smoke-* shows only cl9713-smoke-deliver. Remove it.

Fixes CL-9713

🤖 Generated with Claude Code

@bwachman
bwachman requested a review from TheGreatAxios October 8, 2026 18:13
@linear-code

linear-code Bot commented Oct 8, 2026

Copy link
Copy Markdown

CL-9713

@bwachman bwachman changed the title fix(approval): present parked approvals and recover them on stall fix(approval): present parked approvals and settle the turns they end Oct 8, 2026
@TheGreatAxios
TheGreatAxios force-pushed the cl-9713-add-grant-preserving-watchdog-recovery-for-stuck-agent-calls branch from 5514c68 to 2417791 Compare October 8, 2026 23:20
@TheGreatAxios
TheGreatAxios marked this pull request as ready for review October 8, 2026 23:20
@TheGreatAxios
TheGreatAxios merged commit cd69a29 into main Oct 8, 2026
13 checks passed
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