Skip to content

fix(session): keep a parked approval in the turn log beside its call - #1495

Merged
TheGreatAxios merged 6 commits into
mainfrom
cl-10781-store-a-pending-approval-in-the-turn-log-next-to-the-call-it
Oct 17, 2026
Merged

TheGreatAxios merged 6 commits into
mainfrom
cl-10781-store-a-pending-approval-in-the-turn-log-next-to-the-call-it

Conversation

@bwachman

Copy link
Copy Markdown
Contributor

Summary

  • When a call parks on an approval, the context store writes the approval into turns.jsonl as a record line ({"pendingApproval": ...}) right after the turn that holds the call, in the same commit. The call's tool_result line closes it. "Waiting" and "answered" live in one file, so an agent rebuilt from the store can no longer re-arm an approval whose call already has a result.
  • load() keeps record lines out of the turns. They never reach the provider, prompt-cache hashing, compaction, history or transcripts. It returns as pending only approvals whose call is present with no result. readAt, loadRecentTurns and the subagent trace reader skip record lines.
  • Records follow their call through appends, rewrites, staged compaction rewrites and torn tails, and are dropped with their call. A failed staged commit restores the record set.
  • Sessions with pendingOperations in metadata.json still load through the same open/closed filter. An open one moves into the log on the next commit, and metadata.json is removed once nothing is left in it.
  • No changes under vendor/.

Verification

  • bun run check and bun run test pass (9152 tests)
  • The reload regression e2e test (rebuild after an approved call, then advance past the original gate deadline) fails on main with Malformed tool sequence: duplicate tool_result and passes here
  • The new store tests fail on main and pass here. They cover: the record written with the parked call, closure by the result, a parallel batch with one answer, migration from metadata.json, the snapshot scrub, compaction while parked, a failed staged commit, and a torn tail

Fixes CL-10781
Fixes CL-10767
Part of CL-10763

A parked call's approval is moving into the turn log as a role-less
line next to the call. Teach every reader of the log to pass over that
line first, so history, readAt, the recent window, and the subagent
trace never see it as a turn or count it as malformed. readAt stops
going through the base store's strict parser, which knows turns only.
An approved call's pending operation was removed only in memory: the
tool cycle writes turns, not metadata, so an agent rebuilt from the
store before the next commit re-armed the gate and later answered the
call a second time with "approval timed out". Store the approval as a
record line right after the turn holding the call, written in the same
commit, so the call's tool_result closes it with no second write. load()
returns only approvals whose call is in history and unanswered, from
the log and from a legacy metadata.json, and metadata.json now holds
only what the log does not, removed from the commit once empty.
@linear-code

linear-code Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

CL-10767

CL-10781

CL-10763

@bwachman

Copy link
Copy Markdown
Contributor Author

Review points:

  1. Signing test helper. A checkpoint with no pending approvals now writes no metadata.json, so the old empty checkpoint in optimized-context-store.test.ts committed nothing. The signing test failed, and "does not spawn system git" passed without committing anything. The shared helper (now commitTurnCheckpoint) writes one turn so a real commit happens. Please confirm that keeps both tests' intent.
  2. Older binaries on a session written by this one.
    • Builds before 7cedbfd4 warn "base context store load failed" and then skip the record line.
    • 7cedbfd4 skips it with one "unexpected structure" warning per record line.
    • In both, parked approvals are not visible (they read pending operations from metadata.json only), readAt throws on commits whose segments contain records, and their first full rewrite drops the records. Not addressed here.
  3. Known non-issues:
    • An approval parked while a rewrite is staged is briefly held in both metadata.json and the log after that commit; load() dedupes it.
    • Answered approvals keep their record line, filtered on load: one extra line per approval.
    • If sibling results reach the log before the suspend metadata write, inserting the record rewrites from the call turn onward instead of appending.

@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 parked approval is written as a role-less line in the turn log beside its call, and load() re-arms only calls that are still in history and unanswered.

Findings

  • src/session/optimized-context-store.ts:1313 — writeTurnLog adds every included correlation id to loggedApprovalIds after a segmented write, including the versioned base divertToVersionedBases switches to (:1375). load() only reads turns.jsonl. The next writeMetadataLive then drops that op from metadata.json (:1344) because the id looks logged. A second owner that diverts, writes the approval, commits, then writes metadata again leaves the unanswered call in canonical history with pendingOperations: [], so the rebuild this change is for times the gate out again. Same-owner appends are fine. Record the id only when the line landed on the log load() reads, or keep the metadata copy until it does.

  • src/session/optimized-context-store.ts:1150 — openPendingOperations inserts correlationId into seen before it decides the record is open. A log line that does not re-arm (call missing or already answered) still tombstones that id, and load() lists log records before metadata.pendingOperations (:1713). A legacy metadata approval for the same id is then dropped even though the log copy never became the live op. Skip seen.add until the op is actually pushed, so a closed record does not erase a later open one.

  • src/session/optimized-context-store.ts:1152 — openness is call-id membership only. A schema-valid record whose suspendedCall.id matches an unanswered tool_call is returned even when name or arguments differ, and resume compares that record to its own snapshot rather than to the transcript call. Reject a record whose suspendedCall is not the unanswered tool_call with that id, or the parked body can replace the call that was actually approved.

Commit subjects and bodies match CONTRIBUTING.md (git log origin/main..HEAD). No Bin entries in git diff origin/main...HEAD --stat. The divert case was reproduced with a temporary test that failed and was removed; the other two are from reading the new load path. I did not re-run the suite.

A diverted turn write marked the approval logged even though load()
only reads turns.jsonl, so the next metadata write deleted it. A closed
log record also tombstoned a later open op with the same correlation id,
and a record was accepted when only the call id matched.

Record an id as logged only on the canonical turn log. Accept a
correlation id only once the op is open, and only when the suspended
call matches the unanswered tool call's name and arguments.
@TheGreatAxios

Copy link
Copy Markdown
Collaborator

Pushed 9556d30f for the three review findings.

  • A diverted turn write no longer counts as logged, so the approval stays in metadata.json for load().
  • A closed log record no longer tombstones a later open op with the same correlation id.
  • A parked approval is returned only when suspendedCall matches the unanswered tool call on id, name, and arguments.

bun run check exited 0 before the commit (9157 pass). Not merging until triage can actually run: @corbits/system-one is not installed here and TYPESAFE_API_KEY / SYSTEM_ONE_API_KEY are unset, so the gate fails closed.

writeTurns stages an append so writeMetadata can attach the approval
and flush the segment a single time. commit only git-adds that flush.
A parked approval is never written to metadata.json. A versioned
divert is not the log load() reads, and is not copied into metadata
to compensate. Legacy open approvals move into the log on the next
commit and are not written back. Async markers and connectorState
stay on metadata.json.
writeTurns of an append hits turns.jsonl before it returns, so a new
store can load those turns with no metadata write and no commit.
writeMetadata then appends only the parked approval's record line.
That append also lands on the published log while a rewrite is still
staged, and a failed commit restores it with the published generation.
@TheGreatAxios
TheGreatAxios merged commit e0c1ec0 into main Oct 17, 2026
13 checks passed
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