fix(agent): stop load-time repairs from masquerading as snapshot conflicts#6095
Merged
SivanCola merged 4 commits intoJul 6, 2026
Merged
Conversation
A mid-turn autosave can legitimately cut an assistant tool call from its still-running result, leaving a dangling call at the tail of the saved transcript. checkSnapshotWrite compared the next snapshot against the LoadSession view of disk, where normalization has already fabricated a placeholder answer for that call. The live session's real tool result collides with the placeholder, so a pure append misread as diverged and forked a "snapshot conflict" recovery branch 30 seconds after the first mid-turn save of any tool-heavy turn. Keep the pre-repair transcript on loaded sessions and fall back to it in checkSnapshotWrite when the normalized prefix checks fail: append shape, the owned-rewrite digest, and conflict-kind classification are now judged against the bytes actually on disk. Appending the real results also lands the transcript well-formed, so no repair is left pending in that path.
…k recovery need Two more sites judged the normalized load as if it were the disk truth: - checkSnapshotWrite let a session resumed from a repaired transcript take the append-only shortcut with appendFrom measured against the normalized view. The event log replays the raw transcript, so the append event's index broke the replay chain and the whole appended turn silently vanished from disk on the next load (until a later save healed the log). A pending repair now falls through to the full rewrite, which persists the repair and the new turn together. - SaveRecoveryBranch decided "recovery not needed" only against the normalized view, so a mid-history repair (e.g. an empty tool-call name backfilled on load) made a fully-covered snapshot look uncovered and forked a pointless recovery branch. Coverage now falls back to the raw transcript, mirroring checkSnapshotWrite.
A snapshot-conflict recovery fork switches the controller onto the new branch mid-turn, but the in-flight turn marker written at turn start stayed on the original path — turn end then cleared the (absent) marker on the recovery path, leaving the original's marker behind forever. The next open of the original branch fired recoverInterruptedTurn against a turn that in fact kept running on the recovery branch, stripping every message after the turn boundary and force-saving the truncation. Transplant the marker when the fork switches paths: mark the recovery branch (so a crash before turn end still strips correctly there) and clear the forked-from branch.
The marker transplant stops new recovery forks from leaving in-flight turn markers behind, but every fork made by an older runtime already left one on its forked-from branch. Opening such a branch fired recoverInterruptedTurn against a turn that completed on the recovery child, stripping the transcript back to the turn boundary and force-saving the truncation. recoverInterruptedTurn now recognizes the leftover: a recovery branch whose parent is this session and whose creation postdates the marker means the marked turn moved there rather than dying with a runtime. Clear the stale marker and keep the messages; a marker with no such child still strips the partial tail as before.
This was referenced Jul 6, 2026
Closed
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Field incident
A session running on latest
main-v2(dce3b3a) forked a "conflict copy" 30 seconds after its first mid-turn autosave, with no second runtime involved (samewriter_id/pid throughout):divergedand forked…-recovery-748ca7f3….Root cause:
checkSnapshotWritecompares the pending snapshot againstLoadSession's view of disk, and load-time normalization had already fabricated a placeholder result for the dangling call (sessionToolResults). The live session's real tool result collides positionally with the placeholder, both prefix checks fail, and a pure append misreads as divergence. Any tool-heavy turn whose tool outlives the 30s autosave interval reproduces this deterministically.Fixes (one per commit, same root cause family)
checkSnapshotWritefalls back to the raw transcript (09ce7df).LoadSessionnow preserves the pre-repair messages when normalization changed them; append shape, owned-rewrite ownership, and conflict-kind classification retry against the bytes actually on disk before declaring conflict. The mid-turn compaction rewrite variant (ownership revoked by the repaired digest) is covered by the same fallback.appendFromwas measured against the normalized view while the event log replays the raw transcript; the chain-broken append event was silently discarded on the next load, dropping the whole post-resume turn from disk despite a successful save. A pending repair now falls through to the full rewrite, which persists the repair and the new turn together. Same commit:SaveRecoveryBranch's "recovery not needed" coverage check gets the same raw fallback.recoverInterruptedTurnagainst a turn that had completed on the recovery branch, stripping the transcript back to the turn boundary and force-saving the truncation.parent_idis this session and whosecreated_atpostdates the marker means the marked turn moved rather than died: clear the marker, keep the messages. A genuine crash (no such child) strips exactly as before. This is the migration path for existing users — no manual cleanup needed.Tests
10 regression tests across
internal/agentandinternal/control; each was verified to FAIL with its fix stashed and PASS with it applied. Fullgo test ./...,go vet,gofmtclean.Related
#6074, #6080, #6082 fixed the revision-CAS/ledger-drift/adoption conflict sources; this covers the normalization-induced ones they don't reach.