Skip to content

fix(reactor): persist resolved approval removal with its result turn - #1485

Closed
bwachman wants to merge 5 commits into
mainfrom
cl-10764-persist-pending-operation-removal-when-an-approved-call-is
Closed

bwachman wants to merge 5 commits into
mainfrom
cl-10764-persist-pending-operation-removal-when-an-approved-call-is

Conversation

@bwachman

Copy link
Copy Markdown
Contributor

Summary

  • An approved or rejected call's pending operation now leaves metadata.json together with the turn that answers it, so a reactor rebuilt from the session store never re-arms the answered call's gate.
  • Tool cycles that leave pending operations untouched still skip the metadata write, so the turn-persistence coalescing savings stay.
  • An e2e regression test approves an ask, rebuilds the agent from the same store, and advances past the original gate deadline.

In the interactive TUI without yolo, a reload right after an approval rebuilt the agent while metadata.json still listed the pending operation. One hour later the stale gate injected approval timed out for a call that already had a result, and the session died with Malformed tool sequence: duplicate tool_result.

Verification

  • bun run typecheck, bun run build, and bun run test pass (9203 tests)
  • The new reactor tests and the new e2e test fail on main with the duplicate tool_result error and pass on this branch
  • With the reload deferral from the companion TUI change applied as well, the e2e test passes on 3 of 3 runs

Fixes CL-10764
Fixes CL-10767
Part of CL-10763

Tool cycles write turns only and defer metadata to the next terminal
commit. An approved or rejected call drops its pending operation from
memory, but a teardown before that terminal left it in metadata.json; a
reactor rebuilt from the store re-armed the answered call's gate and, at
the original deadline, appended a second tool_result for the same call
id, which the prompt tripwire rejects.

The turns-only write now also writes metadata when a resolved approval
changed the pending operations, so the removal lands with the turn that
answers the call. Writing metadata in tryCorrelate instead would drop
the op before the re-dispatched result is on disk, leaving a dangling
tool_use with no gate to answer it. Cycles that leave pending operations
untouched still skip the metadata write.
@linear-code

linear-code Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

CL-10767

CL-10764

CL-10763

The pending-operation removal reaches metadata in a second await after
the result turn, so a crash between the two leaves a stale op for a
call history already answers. A reactor rebuilt from that store
re-armed its gate and, at the original deadline, appended a second
tool_result for the same call id, which the prompt tripwire rejects.

Startup now drops and persists the removal of any loaded op whose
parked call already has a result, and the approval timeout injects no
synthetic result for such a call.

@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

A single approved or rejected call persists its pending-operation removal with the result turn. A second approval that resolves during that metadata write is left on disk.

Findings

  • vendor/intx-inference/src/reactor.ts:1392 — writeMetadata snapshots getPendingOperations() before the await, then sets pendingOperationsChanged = false at :1397. The flag is raised when an approval, rejection, or timeout drops an op (:789, :814, :1463). If another parked call is approved during that write, its removal is cleared off the flag after a payload that still contains it. writeCycleTurns then writes the second tool_result and skips metadata. Tear down before the next terminal commit and a rebuild re-arms that call. Two ask calls showed both results on disk while call-b was still in pendingOperations. Clear the flag only when the live set still matches the bytes just written. Cover that next to the tests at reactor.test.ts:5470 and :5529.

Notes

  • One approval or rejection still removes the op with the answering turn (reactor.ts:1231, :1667). Tool cycles that do not touch pending ops still skip metadata (reactor.test.ts:2522).
  • This diff edits vendor/intx-inference, which triage forbids. That is separate from the race above.

@bwachman

Copy link
Copy Markdown
Contributor Author

Superseded by CL-10781, which stores a pending approval in turns.jsonl next to the call it parks, so the call's result closes it. That needs no change under vendor/, and it removes the metadata write ordering this PR patches, including the in-flight write race from review. We don't intend to merge or keep working on this PR. It stays open, as a draft, until CL-10781 is proven, then it gets closed. Its reload regression e2e test moves to the CL-10781 PR.

Crash recovery could reload a pending operation for a call the turns already answer, re-arming its gate and injecting a second tool result at the deadline. load() now drops any pending operation whose suspended call id already has a tool result, warning per drop; marker operations without a suspended call pass through. The removal converges to disk on the next metadata write.
…out-result-for-a-parked-call-that-already

fix(reactor): never re-answer a parked call that already has a result
@TheGreatAxios

Copy link
Copy Markdown
Collaborator

Superseded by #1495, which has merged. A parked approval lives in turns.jsonl beside its call, so this metadata-removal patch is not needed.

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