fix(session-storage): catch a mid-staging resume that only reads the transcript - #7633
Conversation
…transcript move_to_trash's revival guard compared each source file's mtime against the instant the reclaim began. That catches a resume that WRITES. A resume that only reads the old transcript to rebuild history, recording the turn that follows under a newly mapped sid, leaves every file's mtime days old, passes the check, and the live slot's durable history is staged out from under it. The index is where that resume is visible, so refresh is now called before every session the move loop reaches rather than once before the loop. A session the re-read reports as mapped is left in place with nothing staged and named in TrashBatch.revived, the same way a write-shaped revival already was. Rebuilding the index per session cannot be paid for directly: _build_index reads and parses the whole session map (~0.26 ms even for a 100-entry map) and the selection is capped at 200,000, so a naive per-session rebuild is ~56 s at that floor and hours against a realistic map. So refresh may return the SAME index object to say nothing has moved, which the loop recognises by identity, and the dashboard's refresher rebuilds only when session_map.json's (st_ino, st_mtime_ns, st_size) moves. Every mapping write lands via os.replace, so the inode changes on every write. Per-session cost at the cap: one stat. A re-read that fails mid-loop is logged once and the last view is kept: every re-read only widens the live sets, so losing one costs the extra protection it would have added, not the protection already read. A refresh broken from the start still fails closed before anything moves. Closes #7118
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS A measured, disclosed-residual fix: read-only resumes get the same per-unit revival treatment writes already had, with cost bounded by a file-identity gate. The one race left open (the ~50 ms [DESIGN-REVIEWED] 16382ec |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All claims verified: First-Principles-Verdict: PASS Closes a named defect (#7118) at the deepest reachable level, with the out-of-scope cause (no shared lock) documented rather than hidden. What this change shipsIntent: stop a reclaim from trashing the history of a session that was resumed by reading, not writing — a FIX.
Every item is declared in the description, including both residual windows and the two rejected shapes. The reuse of the existing [FIRST-PRINCIPLES-REVIEWED] 16382ec |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsBased on my review, the candidate list contained no candidates from the discovery pass. I independently examined the core logic:
No behavioral defect or AUTOSDE rule violation grounded in the changed lines survived falsification, and I found no new grounded finding at the 80+ bar. No findings. [OPUS-REVIEWED] 16382ec Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (5 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: fix with a clear root cause — catches a mid-staging resume that only reads an old transcript (#7118) so its history is not reclaimed out from under a live slot, via a map-file-identity-gated per-session refresh. Spec files changed as a ride-along (a minority of the diff on both file count and changed lines), not reviewed as a design decision: docs/system-specs/modules/session-storage.md.
bolichen97
left a comment
There was a problem hiding this comment.
Reviewed via parallel subagent audit: diff matches description, CI fully green, no blocking findings, no unresolved threads.
Problem / Motivation
move_to_trash's revival guard (added in #7081) decides whether a session wasresumed mid-staging by comparing each source file's mtime against the instant the
reclaim began. That catches a resume that WRITES. It does not catch a resume that
only READS.
The uncovered sequence:
reads, untouched for
MIN_RECLAIM_AGE_DAYS.that old transcript.
all of them pass
mtime > validated_at, and the whole session is staged.The live slot then keeps running with its durable history in the trash.
Why it matters
A user resumes a chat and finds its history gone, with nothing in the transcript
explaining why. #7081 narrowed the window this happens in; the remaining case is
the one where the resume is a pure read, and its window is the whole move loop
rather than microseconds.
It is a continuity failure rather than data loss: restore puts the history back,
and emptying the trash needs a separate deliberate action. Same severity class as
the defect #7081 fixed.
What changed (motivation -> approach -> change)
The index is the only place a read-only resume is visible, because such a
resume is mapped even though it wrote nothing. So
refreshis now called beforeEVERY session the move loop reaches, not once before the loop. A session the
re-read reports as mapped is left in place and named in
TrashBatch.revived, thesame way a write-shaped revival already was. Nothing of it has moved when the
check fires, so there is no rollback. The mtime check stays: it costs no syscall
(the stat is already taken for the manifest) and it catches the write-shaped
resume inside a session's own file walk, which the re-read cannot.
Making that cadence affordable is the whole design problem, and it is measured.
_build_indexreads and parses the whole session map, so there is a per-call floorof about 0.26 ms however small the map, and the selection is capped at
_MAX_SELECTION = 200_000:_build_index()os.stat()on the map file, for comparison: 2.0 us. It is not the six-figurestore that makes a naive per-session rebuild expensive, it is the six-figure
selection: 56 s even against a 100-entry map.
So the cheapness is where the knowledge is.
refreshmay return the SAMESessionIndexobject it returned last time to say "nothing has moved", which theloop recognises by identity and does not re-derive sets for. The dashboard's
refresher (
_MapBackedRefresh) rebuilds only whensession_map.json's(st_ino, st_mtime_ns, st_size)moves. Every mapping write lands throughmkstempplusos.replace, so the inode changes on every write and an unmovedtoken cannot hide one. Per-session cost at the cap: one stat, 0.4 s total. The
token is read BEFORE the rebuild and that one is stored, so a write landing during
a rebuild is seen by the next call rather than stamped as already-included. An
unreadable token is
None, which never compares equal, so it costs a real re-readrather than a skipped one. No signature change, and a caller that returns a fresh
index every call (every existing test) still gets a real re-read per session.
A re-read that fails mid-loop degrades rather than aborting. It is logged once
per batch and the last view read is kept. Every re-read only ever widens the live
sets, so losing one costs the extra protection it would have added, not the
protection already read; abandoning a batch that has already moved files would be
the worse trade. A refresh broken from the start still fails closed, before
anything moves, through the existing pre-loop raise.
Two candidate shapes the issue named were rejected, and why. Shape 1 (naive
per-unit rebuild) is the table above. Shape 2 (one refresh after the loop, then
roll back what is now live) costs one extra map read, but the unwind is the
problem: manifest entries are written as each session lands specifically so an
interruption leaves a manifest describing exactly what moved, so removing an entry
means either rewriting the manifest, which gives that invariant up, or a tombstone
every one of
_read_manifest,_summarize_manifest,_restore_locked,_manifest_rels,_listed_bytes,staged_targetsand_empty_trash_lockedhasto honour. The shape shipped here keeps the append-as-it-lands invariant untouched.
docs/system-specs/modules/session-storage.mdmoves with the code: the section isretitled, the two-signal loop is described, the corrected Known Limitations entry
that #7282 added for this gap is replaced by the bounded residual that survives it,
and the handler section stops saying a read-only resume is staged instead of
refused.
Tests
Targeted files only (
test_session_storage.py,test_session_storage_api.py):284 passed.
test_session_map_locking.py(the locking ratchet over this tree): 16passed. black, ruff and mypy clean on the four changed Python files.
Red on base, with the exact assertions:
test_a_resume_that_only_reads_the_transcript_is_left_in_place-assert () == ('bbbb2222',). The resume writes nothing at all: no append, noutime, no recreated origin. Also asserts every half is still in place and the
batch holds no file of it.
test_the_index_is_consulted_before_every_session-assert 1 == 4. Pins thecadence, which is what a future change could quietly drop.
test_a_re_read_that_starts_failing_keeps_the_view_it_last_read-assert 3 == 2. The last good view still protects the session it named, the restof the batch still moves, and the warning is logged once rather than per session.
test_an_unchanged_index_is_not_re_derivedpasses on base too, because base callsrefreshonce - it is a cost ratchet, not a repro. Mutation-verified it has teeth:replacing the identity check with an unconditional re-derivation turns it red
(
assert ['active_stems', ...] == ['active_stems']).Four tests cover the refresher itself: same object while the map has not moved
(one build for three calls), a rebuild after a mapping write, a rebuild when the
map is unreadable, and a write landing during a rebuild not being missed.
Manual verification
Not applicable - no user-visible surface changes. The behaviour is a refusal path
inside a reclaim, exercised by the tests above.
Related Issues
Closes #7118
Refs #7081, #7282
Pattern harvest
Rule candidate: review-prompt
Pattern: a guard infers "was this resumed" from a file's mtime, so a resume that
only READS the file is invisible to it.
The mtime proxy is the whole defect. It answers "was this written since we
certified it", which is a strictly narrower question than "is this live", and the
gap is exactly the read-only user. A review prompt can ask it directly: when a
staleness or liveness check reads an mtime, what does a reader of that file look
like to the check? Not a semgrep candidate -
mtime > stampis a correct andcommon comparison, and only the surrounding claim makes it wrong here.
Three further observations from the fix, none of them rule-shaped on their own:
naive form. Measure the floor, not the ceiling: the first row of the table (a
100-entry map) settles the design, because n is what multiplies it.
liveness from its caller by design, so a caller-memoized refresh is idiomatic
here where a module that secretly stats the caller's backing file would not be.
failure. That is what makes "log once and keep the last view" defensible instead
of a swallowed error.