Skip to content

fix(session): settle approvals an interrupt skips or a reactor never ran - #1577

Open
bwachman wants to merge 4 commits into
mainfrom
cl-11433-record-skipped-and-unrun-approval-decisions
Open

bwachman wants to merge 4 commits into
mainfrom
cl-11433-record-skipped-and-unrun-approval-decisions

Conversation

@bwachman

@bwachman bwachman commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • A decision that an interrupt skips while it waits on the session queue counts as a dropped approval, not as handed over, so the rebuilt agent settles it.
  • A decision handed to a reactor that closes before running the call is tracked the same way until a rebuilt store shows the call closed.
  • A decision whose delivery fails is tracked the same way, including one the deliverer handed over but never saw accepted.
  • createQueuedApprovalDeliver is the deliver seam the TUI and the tests share.
  • Builds on fix(session): settle a dropped approval on the rebuilt agent without a new turn #1576, which settles dropped approvals on the rebuilt agent.

Verification

  • bun run check passes on each commit (9527 tests at the tip)
  • e2e, through the real store: an approval skipped behind a busy session queue, an approval the reactor accepted behind a busy loop before closing, and an approval whose acceptance the deliverer never observed each leave the call parked at the rebuild, never run it, and settle once with no model turn
  • Each new e2e test fails with its commit's fix removed

Fixes CL-11433

The parked cancel is unregistered before an operator's decision is
delivered, so an interrupt that bumped the generation while the
decision waited on the session queue skipped it silently. Nothing
reached either agent, the call was marked handed over so a later
recovery ignored it, and the rebuilt agent answered it with "approval
timed out" at the old deadline.
A decision handed to a reactor that closes before running the call,
such as an interrupt landing while the loop is busy, left the call
parked on disk with nothing to settle it: rehydration announces no
gate, recovery ignores a correlation it handed over, and no drop was
recorded. The rebuilt agent answered it with "approval timed out" at
the old deadline. A handed-over decision is tracked like a drop until
a rebuilt store shows its call closed.

@TheGreatAxios TheGreatAxios left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review · Request changes

This records an approval that an interrupt skipped, or that was handed to an agent which closed before the tool ran, and settles it on the rebuilt session so it does not later time out as if nobody decided.

The case where delivery hands the decision over and then times out while watching for acceptance is handled on this branch: that call is kept and settled after the rebuild.

bun test --config ./bunfig.toml src/session/approval-resume-interrupt.test.ts src/session/approval-resume.test.ts src/tui/approval-delivery.test.ts src/tui/runner/exit.test.ts src/tui/runner/queued-approval-delivery.test.ts — 174 pass, 0 fail. Those tests cover the skipped, unrun, and failed deliveries this branch settles. They do not cover an approved tool that has already started.

What is still wrong: an approval you already gave can be recorded as denied if the session is rebuilt while that tool is still running.

Every decision handed to the agent is marked unfinished (src/session/approval-resume.ts:613), approval or rejection. On the next rebuild, a call that is still saved with no result is rejected as dropped (src/session/approval-resume.ts:771). The tool's result is not saved until the tool finishes. Approve a call, let the tool start, then interrupt before that result is written, and the rebuilt agent is told the call was denied. The tool may already have done its work. The transcript says it was dropped, so the model can try the same call again, and a grant saved before the interrupt can allow that retry without asking again.

A call whose result is already saved is left alone. The hole is the stretch after the tool starts and before its result is saved.

A deliverer gives up waiting to observe a decision's acceptance only
after it has handed the decision to the agent, so the reactor may still
hold it, but a delivery that threw was never tracked for the rebuild
settle. An interrupt then rebuilt with the call still parked and nothing
to settle it, and the rebuilt agent answered it with "approval timed
out" at the old deadline. A decision whose delivery throws is tracked
like a handed-over one, whether it failed before or after the hand-over,
since either way the call stays parked.
@bwachman

Copy link
Copy Markdown
Contributor Author

Fixed in c0eccec. deliverDecision now records the decision for the rebuild settle whenever deliver throws, then rethrows, so an acceptance-observation timeout after the hand-over is settled on the rebuilt agent.

I didn't split pre-handoff failures from post-handoff ones. A pre-handoff failure, like a failed agent build, leaves the call just as parked, and without tracking the rebuilt agent answers it with "approval timed out" at the old deadline. The silent settle is the right outcome there too, and tracking every throw keeps the session layer from having to recognize ApprovalDeliveryTimeoutError. Handed-over status is unchanged: a thrown delivery still isn't marked handed over, so recovery retries behave as before.

Tests:

  • e2e an approval whose acceptance was never observed settles with no model turn: the real createApprovalDeliverer with an acceptance that is never observed hands an approval to a busy loop, the wait times out, then interrupt, then still parked in the real store at the rebuild, then one silent settle and no model turn. It fails without the fix with timed out: dropped approval settled.
  • unit an approval whose delivery failed before the hand-over is rejected on the rebuilt agent with no turn covers the pre-handoff side.

bun run check passes: 9527 tests, 0 failures.

@bwachman
bwachman changed the base branch from cl-11433-settle-dropped-approvals-silently to main October 29, 2026 18:17
TheGreatAxios

This comment was marked as duplicate.

A decision handed to the agent was tracked for the rebuild settle whether
or not its tool had started. The store shows a started call parked until
its result is saved, the same as one the closing reactor never ran, so an
interrupt that rebuilt in that window rejected the approved call as
dropped. The tool may already have run, the transcript said it was
denied, and the model could retry it.

The TUI now reports each tool.start to the approval resume, which watches
the calls it offered a decision for. A call whose tool started is
answered with a result that says the operator approved it and that it
may have completed, so the model checks before retrying. A call whose
tool never started is still rejected as dropped.
@bwachman

bwachman commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor Author

Fixed in 6ad0349. Confirmed first: with an MCP save_issue that stays in flight when the session closes, the call ran, tool.start fired, and the rebuilt agent's history held denied by approver: Approval dropped because the session changed. (A sleep tool does not show it: the close aborts it and the abort result lands before the settle reads the store.)

The store shows a started call parked until its result is saved, the same as an accepted call the closing reactor never ran, so the resume layer cannot tell them apart. The only signal is tool.start, so the TUI's stream sink now reports each one to the approval resume (toolStarted), which watches the calls it offered a decision for.

  • Tool never started: unchanged, rejected as dropped. It never ran.
  • Tool started and no result saved: the rebuilt agent answers it with APPROVAL_INTERRUPTED_NOTICE: "Not a denial: the operator approved this call, but the session changed while the tool was running. It may have completed; check before retrying." It is still committed silently with no new model turn. The wording opens with "Not a denial" because the reactor prefixes every rejection message with "denied by approver: ".

Tests:

  • e2e an approved call whose tool is still running is not rejected as dropped: the stuck MCP save above, with the helper reporting tool.start as the stream sink does. Fails on c0eccec, passes now.
  • unit: start announced during delivery (the real order, since the acceptance wait settles on tool.start), start announced after the hand-over, start for an unwatched call ignored, started call closed on disk forgotten.
  • exit: the stream sink forwards tool.start to toolStarted.

bun run check passes: 9533 tests, 0 failures.

Not covered: if a tool finishes after the close and its result is never saved, the model reads the interrupted notice. That is the most we can say truthfully, so I left it.

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

An interrupt that skips an approval, a reactor that closes before the call runs, and a delivery that throws are settled on the rebuilt agent instead of waiting out the old deadline. A call whose tool already started is answered as approved and possibly finished. A call that never started is still rejected as dropped.

  • should-fix — The pull request title is 89 characters. It has to stay within 72, same as a commit subject: fix(session): settle approval decisions an interrupt skips or a closing reactor never ran

@bwachman bwachman changed the title fix(session): settle approval decisions an interrupt skips or a closing reactor never ran fix(session): settle approvals an interrupt skips or a reactor never ran Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants