test(chat): ratchet the bounded slot-detail disk-vs-window disagreement - #8650
Conversation
Pins current behaviour of the bounded branch of `GET /api/chat/slots/{slot}`
so a future change cannot move it silently. No production code: the four
disagreement tests are `xfail(strict=True)`, so the day the contract is
settled they announce themselves as an `xpass` and force the marker's removal.
The bounded branch is disk-authoritative for the live window's region, while
the unbounded branch returns `older + list(slot.messages)` and lets the window
decide. Two user-visible consequences, both reproduced here:
* a variant switch rewrites the window row IN PLACE at the same `meta.mid`
and `api_chat_slot_switch_variant` swallows a failed save WITHOUT setting
`_pending_rewrite`, so the next flush takes the append path and disk keeps
the previous variant indefinitely -- a bounded read repaints it;
* `chat_regenerate` truncates the window and only then saves, so a failed
save leaves the deleted turns on disk for a bounded read to resurrect.
The tests preserved in c9979c4 no longer reproduce either one. They stub
`read_messages_chained`, and the bounded branch now reads
`read_messages_chained_full`, so the disk side is never consulted and they
pass vacuously against an unpatched handler. These stub BOTH readers with one
corpus, so a pass cannot come from an empty tmp history.
`TestTheFrozenPrefixSurvives` is not xfail: it passes today and guards the
opposite error, a fix that swallows the older-session prefix the bound exists
to page into.
Refs #7526
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: |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS A real, user-visible corruption pinned with strict xfails that cannot rot silently — deliberately deferring the cross-surface contract ruling is the right scope call. The four [DESIGN-REVIEWED] aff869e |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsNo candidates were proposed, and this is a test-only diff. The added tests stub both disk readers on No findings. [OPUS-REVIEWED] aff869e Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
dwu96
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: test (1 file). 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: adds one new test-only file (test/test_slot_detail_disk_window_authority_7526.py) as a strict-xfail positive-control ratchet for the bounded slot-detail disk-vs-window disagreement; no production code touched, no runtime impact.
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: test (1 file). 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: test-only ratchet -- adds test/test_slot_detail_disk_window_authority_7526.py pinning the bounded vs unbounded slot-detail branches' disagreement over which store decides a row inside the live window; no production file touched, no behaviour change.
What is the problem?
The bounded branch of
GET /api/chat/slots/{slot}and the unbounded branch do not agree on which store decides a row's content inside the live window's region. The unbounded branch returnsolder + list(slot.messages), so the window decides. The bounded branch reads the chained disk corpus and merges only the rows disk is missing, so disk decides -- and a row that was rewritten in place is not missing, it is present at the samemeta.midholding stale text.Two triggers reach it, both ordinary user actions:
api_chat_slot_switch_variantassignstarget_dict["content"]andtarget_dict["ts"]on the existing window dict and never touchesmeta.mid. Its inline save is wrapped in atry/exceptthat swallows the failure without setting_pending_rewrite, so the next periodic flush takes the append path rather than the rewrite path and disk keeps the previous variant indefinitely -- not for one flush interval.chat_regeneratedoesdel slot.messages[u_idx + 1:]and only then saves. A failed save leaves the deleted turns on disk.Refs #7526-- this PR does not resolve that issue. It carries the reproduction only.Why this issue matters to the user
Both are user-visible transcript corruption. Selecting a variant paints the one you switched away from straight back over it, because
chat_variant_switchis broadcast and the client's handler dispatchesrefreshSlot-- which is bounded as of #6947. Regenerating can bring deleted turns back. Neither is hypothetical and neither needs a race to observe.How our fix solves it
It does not fix it, deliberately. It ratchets it.
The contract decision -- which store is authoritative for the bounded branch's window region -- is not a local change, and that is the finding that kept production code out of this PR. It spans three surfaces:
chat_handlers.py), including thetotalandnext_beforeit reports.api_chat_fork. It resolves a client-suppliedat_message_indexagainst exactly this corpus and mirrors it on purpose: "Mirror that corpus here, or every index sent after the reader paged past a rotation boundary resolves short by the archived visible-row count, silently forking the WRONG message." Change the bounded read's corpus without changing fork in lockstep and a fork lands on a different message.pagingCursorAfterKeptHeadtreatsnext_beforeas a row offset in the server's corpus index space and shifts it by the kept head's ownserverRowCount.retainServerTotalkeepstotalas a warm-read baseline, and its own comment records the cost of a wrong one: a bad baseline "makes the next warm read that ordinary collapse as a truncation and suppress the rescue, dropping a live row."So the ratchet is the part that is safe to land now: four
xfail(strict=True)tests that pin today's answer.strictis the point -- the day the contract is settled they turn into anxpassand force whoever fixed it to delete the marker, so the fix cannot land while leaving prose behind claiming the old behaviour.It also defuses a live trap. The eleven tests preserved in
c9979c43no longer reproduce anything: they stubread_messages_chained, and the bounded branch now readsread_messages_chained_full, so the disk side is never consulted and eight of them pass vacuously against an unpatched handler. Anyone who picks the issue up and runs them concludes the bug is gone. These stub both readers with one corpus, so a pass cannot come from an empty tmp history.What tests we did
test/test_slot_detail_disk_window_authority_7526.py, +150/-0, no other file touched.Red-before was established against the unpatched handler without the markers, and each failed for the right reason rather than by construction:
test_bounded_read_returns_the_selected_variantAssertionError: assert 'm11' == 'SELECTED VARIANT'test_both_branches_agree_on_content'm11'vs unbounded'SELECTED VARIANT'test_bounded_read_does_not_resurrect_the_deleted_tail'm10'present after truncationtest_both_branches_agree_on_lengthTestTheFrozenPrefixSurvivesis not marked: it passes today and guards the opposite error -- a future fix that swallows the older-session prefix the bound exists to page into. A ratchet that only pins the broken direction lets an over-correction through.With the markers:
1 passed, 4 xfailed.flake8,isortandmypyclean on the file. Single-file runs only (-n0); the suite was not run, by fleet instruction -- another track on the host was measured at 26G on full-suite runs tonight.Any other suggestions on the work
Two things found while establishing the premise that are worth the record but are not this PR's to change:
_disk_older_count"describes the current session's file while the corpus is a chained read". It does not -- both load sites compute it aslen(read_messages_chained(key)) - len(window)(chat_persistence.py:1110,:1586), already in chained-corpus units. The boundary is exact for any session with no rotated archive, so "cannot reconcile" is stronger than the code supports.read_messages_chained_full, which prepends each chain key's archive block and appliesdrop_persisted_tail_prefix, which drops live rows and reports how many to nobody. Rotation needs_SESSION_MAX_BYTES = 10 * 1024 * 1024, so that shape needs a real 10MBrotatesegment in the chain and a later member carrying an archive segment. Real, but not the common path -- and I could not measure its production frequency, which is why the fail-closed fallback's blast radius stays unquantified.Both are recorded in full on the issue.
Pattern harvest
Rule candidate: a preserved test kept as documentation of intended behaviour rots into a false all-clear the moment the code under it changes which collaborator it calls -- these stubbed
read_messages_chainedand kept passing after the handler moved toread_messages_chained_full, reporting green for a bug that was never fixed. A reproduction must stub every reader the path can reach, or assert the stub was actually consulted, so that "passes" cannot mean "never ran".Refs #7526