fix(ledger): report the history a bounded write discards - #7631
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Silent, unrecoverable write-side eviction now reports at the layer that does the evicting, with the return-value/disk aliasing pinned as contract — sound and proportionate. [DESIGN-REVIEWED] 83dd9f5 |
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 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of Both data files read, the changed module read in full, and consumer counts run. Composing the review now. First-Principles-Verdict: PASS One reported, unrecoverable discard site made loud at the exact function that discards; every rider is a pin against a filed issue's inverted fix. What this change shipsIntent: let an operator find out when the ledger's write budget permanently throws away session history — a FIX for the silence, explicitly not for issue #6290's (inverted) claim.
Grepped Subtractions
[FIRST-PRINCIPLES-REVIEWED] 83dd9f5 |
_serialize_bounded evicts the oldest events, then the oldest tried entries, then unknown forward-compat fields, to keep the state document under the ceiling its own reader enforces. None of that was reported. Unlike a read-side clamp, the evicted entries never reach disk, so the stored file holds no copy to recover them from -- the loss is permanent the moment the write lands, and _read_state_unlocked already WARNs when the same ceiling makes it discard a whole file. Log one line per write naming the ledger and what went, including on the refusal path (which raises with the in-memory record already stripped). A document that fits logs nothing. Also pin the contract issue #6290 proposed to change: record() returns the dict _serialize_bounded evicted from, so the caller's post-write view equals the bytes on disk. Serializing a copy would return the pre-eviction lists while disk held the evicted ones. Closes #6290
|
Pushed GPT advisory ( Rather than just reword, I pinned it: PR Hygiene (red): fixed. The body was missing the Design Review, Opus and First Principles were PASS with no findings on the previous head; all three re-roll on this push. |
377758d to
83dd9f5
Compare
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (3 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 — reports (WARN) the history a bounded ledger serialization discards, and returns the evicted dict so the caller's post-write view matches disk (#6290). 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/features/session-work-ledger.md.
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (3 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: bounded ledger serialization discarded oldest events/tried entries with no log line, so an unrecoverable partial write was silent; the fix adds one WARNING per over-budget serialization naming the ledger and the evicted counts, and pins that record() returns the same dict the eviction mutated so the caller's post-write view equals disk. 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/features/session-work-ledger.md.
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (3 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: the ledger's serialization budget silently evicted the oldest events[]/tried[] entries with no operator signal — unlike the read side there is no original file left to recover them from, so this adds one warning per over-budget serialization naming what went and how much. 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/features/session-work-ledger.md.
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
What is the problem?
session_ledger._serialize_bounded()keeps the state document under the ceiling its own reader enforces by evicting the oldestevents, then the oldesttriedentries, then unknown forward-compat fields wholesale. It does all of that silently - there is no log line anywhere on the write path.The issue that prompted this reports a different defect: that
record()returns a dict the serializer mutated, so the caller sees fewer entries than are on disk. That claim is inverted and I did not implement its proposed fix. The eviction loop re-serializes after every eviction and returns the blob built from the fully-evicted dict, so the returned dict already equals the bytes written. Applying the proposed_serialize_bounded(dict(state))would create the divergence it claims to remove, returning the pre-eviction lists while disk held the evicted ones. Mutation-verified below.The genuine residual is the silence, and the missing test coverage underneath it. Two coverage facts I measured rather than assumed:
test_writer_guarantees_the_read_ceiling_for_legitimate_recordsnever reaches the eviction branch. A full astral-script event tail is ~806 KB, under the 995,904-byte budget on its own; crossing it needseventsandtriedboth at maxima (~1.87 MB). Running that test with the new warning in place emits zero lines, confirming it.maintheevents/triedeviction branch has no coverage at all. Only the extras-drop branch does, viatest_oversized_unknown_fields_are_dropped_not_self_corrupting.Why this issue matters to the user
A read-side clamp is recoverable: the original file is still on disk until something writes back. A write-side eviction is not. The dropped entries never reach disk, so once the write lands the loss is permanent, and nothing tells the operator it happened. A long-horizon session whose
tried/eventshistory is being quietly truncated by the write budget looks identical to one that simply has less history, and the ledger exists precisely so that history survives compaction.This is also an asymmetry on
maintoday, independent of any other PR:_read_state_unlocked()already logsWARNING "ledger state file over size ceiling; treating as absent"when that same ceiling makes it discard a whole file. The writer discarding history to stay under it says nothing.How our fix solves it
Symptom: a session's ledger history shrinks with no explanation. Cause:
_serialize_bounded()evicts to satisfy_MAX_STATE_BYTESand reports nothing. Fix: tally what each eviction drops and emit one line per over-budget serialization._serialize_bounded()counts evictedevents, evictedtried, and dropped unknown keys, then logs a singleWARNINGnaming the budget, the ledger directory, and each loss with its count. A document that fits logs nothing, so this is not per-write noise.finally, so theValueError("record too large to store")refusal path reports too. That path raises with the in-memory record already stripped of its history, which is the moment the operator most needs to know what the call threw away.atomic_writeruns after the serializer and can fail (ENOSPC), leaving the previous state file whole; the refusal path never writes at all. Claiming a write discarded data would be false in both cases. This wording was tightened in response to GPT's advisory finding on the first revision, and is now pinned by a test.record()passessource=dir_path.nameso the line names which ledger lost data, mirroring the read-side_coerce_state(raw, source=...)shape in open PR fix(ledger): report the data a state read discards instead of losing it silently #6017.record()'sreturnstates why a copy must not be serialized there, and a test enforces the equality.What tests we did
test/test_session_ledger.py, targeted file only - 52 passed.black,isort,flake8,mypyclean on both changed files.New:
TestBoundedWriteIsLoudAboutLoss(eviction counted and ledger named; unknown fields named; refusal path still reports; a failed write still reports while leaving the stored file intact; a record that fits logs nothing; plus the budget arithmetic documenting why the fast tests squeeze the ceiling instead of using production bounds) andtest_record_returns_exactly_what_landed_on_disk.Verified red, not just green. Three mutations, each isolating one property:
origin/main, tests keptlogger.warningsuppressed, signature kept_serialize_bounded(dict(state))test_record_returns_exactly_what_landed_on_diskfailstest_a_failed_write_still_reports_but_leaves_the_stored_file_intactfailsMutation A matters because the base-revert reds fail on the added
sourceparameter, which would prove nothing about the warning; suppressing only the log call reddens the same tests, so the loud assertions are load-bearing. Mutation B is the evidence for rejecting the issue's proposed fix. Mutation C proves the ENOSPC test pins the wording rather than merely passing alongside it.The eviction tests squeeze
_MAX_STATE_BYTESviamonkeypatchrather than building a 1.87 MB document.test_record_returns_exactly_what_landed_on_diskand the ENOSPC test both derive their squeezed ceiling from the real file size, so a timestamp-width change cannot silently turn either into a no-op.Any other suggestions on the work?
_MAX_EVENTS+_MAX_TRIED), each a fulljson.dumpsof an up-to-1.8 MB dict, each dropping exactly one entry. With_LOCK_TIMEOUT_SECS = 5.0, a slow eviction can push a concurrent writer past its deadline into a refused write (OSError, surfaced as 503 by the dashboard handler). Deliberately out of scope here; a size-guided bulk trim would collapse it to a couple of passes. Happy to file it separately._serialize_boundedvs_coerce_state). If fix(ledger): report the data a state read discards instead of losing it silently #6017 lands first, the rebase should fold this PR's inline summary rendering into its_lost_summary()helper - I kept the rendering local rather than pre-adopting a helper that does not exist onmain._serialize_bounded()evaluates_empty_state()once per key inside the extras comprehension. Untouched to keep this diff observability-only.Pattern harvest
Rule candidate: review-prompt
Pattern: a bound-enforcing path discards caller data with no diagnostic - and on a write path that discard is unrecoverable, because the dropped bytes never reach disk for anything to read back.
This is the second instance in this one module: open PR #6017 fixes the read-side clamps and this fixes the write-side evictions, in a file whose own reader already warned about the whole-file case. The generalizable review question is not "is the bound correct" but "when this bound sheds data, can an operator find out - and is there still a copy anywhere". A
semgreprule is not the right vehicle: the shape (a re-serialize-and-trim loop with no logging in the enclosing function) is too fuzzy to match without heavy false positives, whereas a reviewer prompted to ask it will catch the whole class.Closes #6290