Fix #5532: /api/session/clear sets truncation watermark (P0 data loss) - #5553
nesquena-hermes wants to merge 4 commits into
Conversation
…loss)
The /api/session/clear handler wiped s.messages and s.tool_calls but never
set truncation_watermark. The append-only state.db merge
(merge_session_messages_append_only via reconciled_state_db_messages_for_session)
treats an unset/None watermark as "keep everything and dedup", so the next
/api/session read resurrected every cleared turn from state.db:
* history reappeared after clear + refresh, and
* because context_messages also survived the clear, a continued turn still
carried the full pre-clear context into the model.
The sibling /api/session/truncate handler is not affected: it calls
truncate_session_at_keep(session, keep), which sets
truncation_watermark = truncation_boundary = _truncation_watermark_for(kept).
Fix: route /clear through the SAME helper with keep=0. A full clear is a
truncate that keeps zero messages, so the watermark becomes
_truncation_watermark_for([]) == 0.0 — the #2914 "truncate-to-empty" sentinel
that blocks ALL state.db replay — and context_messages is emptied in lockstep.
The merge contract is not forked; /clear and /truncate now set the marker
identically. The read/merge path is untouched.
Tests: tests/test_issue5532_clear_truncation_watermark.py drives the real
POST /api/session/clear route and asserts the watermark/boundary are set to
0.0, context_messages is emptied, and a subsequent state.db merge returns
EMPTY (no resurrection). All four tests fail on origin/master (watermark stays
None) and pass with this fix.
Codex gate on the initial fix found a real reachable resurrection edge: a compressed-continuation child persists its archived transcript in a parent sidecar marked pre_compression_snapshot, and _webui_sidecar_lineage_messages_for_display() stitches that parent back for display, merging the child with truncation_watermark=None (api/routes.py:8061-8064). So the 0.0 truncate-to-empty sentinel we set on the CHILD does NOT stop the PARENT snapshot from resurrecting the pre-clear transcript on refresh — /clear on a compressed continuation still leaked history. Fix: on /clear, detach the compression lineage (parent_session_id + compression_anchor_visible_idx/message_key/summary) so the cleared child no longer resolves a pre_compression_snapshot parent to stitch. Test: test_clear_detaches_compression_snapshot_parent asserts the display-lineage path returns [] after clearing a child whose parent is pre_compression_snapshot (precondition proves the stitch surfaces it before clear). Fail-without-fix verified. All 5 tests green.
…resurrect it (Codex gate r2) Codex re-gate found a third resurrection path: s.save() on /clear writes a pre-clear .json.bak (messages shrank to []), and recover_all_sessions_on_startup restores any session whose .bak has MORE messages than the live file (session_recovery.py:234, bak_count > live_count) WITHOUT consulting the live truncation_watermark==0.0 — so after a WebUI restart the cleared transcript was restored from the backup (with a None watermark). Codex verified end to end: live messages 0 -> recovery recommend:restore -> live 1, watermark None. Fix: after the /clear save, unlink the pre-clear .json.bak with the SAME guarded pattern manual-compress (routes.py:22325) and delete already use. An intentional full clear must not be undoable by startup recovery. Test: test_clear_survives_startup_recovery drives the real /clear route then recover_all_sessions_on_startup() and asserts messages stay [], context stays [], watermark stays 0.0, and no .bak survives. Fail-without-fix verified. All 6 tests green.
…chors Two CI failures on the prior head, both real: 1. Lint (ruff forward-gate): F841 unused local 'models' in test_clear_endpoint_sets_truncation_watermark — dropped the assignment (the other test that DOES use models.SESSION_DIR is unchanged). 2. test_issue4812_session_sse_contract_rfc shard-0: this PR's +46 lines in api/routes.py shifted the symbol definitions the SSE-contract RFC pins by absolute line number (_handle_session_events_stream 16177->16223, the run-journal parse/replay/runner_event_id anchors +46 each). Realigned every affected api/routes.py:NNNN anchor in docs/rfcs/session-sse-contract-v1.md to current lines (the route + heartbeat anchors below the insertion point are unchanged). This is the known #5542 line-anchor brittleness; de-brittling the test itself stays scoped to #5542. Verified: ruff clean on changed files; test_issue4812 + test_issue5532 = 39 passed.
|
Cross-ref for merge ordering: #5569 (de-brittle #5542) converts the SSE-contract RFC to cite This PR (#5553) currently includes a small RFC hunk that realigns those line numbers (+46, to survive this PR's routes.py insertion) so the pre-#5569 line-anchor test stays green. That realignment becomes obsolete once #5569 lands.
No code conflict either way — both only touch the RFC's citation style. The P0 code fix in this PR is independent of #5569. |
|
Shipped in v0.51.860 🎉 as part of the combined #5532 fix (primary PR #5556 by @rodboev). Your improvements were folded in and credited ( |
…uilt on de-brittled base) Rebuilt the combined nesquena#5556+nesquena#5553 fix on v0.51.859 (now has the flake fix + the nesquena#5542 RFC de-brittle, so no anchor-test collateral). Nathan's call: rodboev's nesquena#5556 primary + fold self-built nesquena#5553 improvements, credit both. Clear handler now: - routes through shared truncate_session_at_keep(s,0) (single source of truth, sets watermark=_truncation_watermark_for([])==0.0, the nesquena#2914 sentinel that blocks state.db append-merge replay) - detaches compression lineage ONLY when the parent is a pre_compression_snapshot (Codex-caught: preserve genuine fork parent links for nesting + "Forked from") - rodboev's persisted-clear read-back verification + stale .bak removal RFC conflict resolved in favor of the de-brittled (symbol-anchor) master version. Ships both PRs' test files (state_db_replay + clear_truncation_watermark incl the fork-preservation regression). Opus (on the amended tree): both resurrection paths closed, detach lineage-safe, verification sound — SHIP. 3 non-blocking follow-ups filed (nesquena#5570 .bak crash-window, nesquena#5571 fork-stitch corner, nesquena#5572 messaging clear). Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Summary
Closes #5532 (P0 data loss).
POST /api/session/clearcleared the sidecar transcript but never recorded atruncation watermark, so
state.dbmessages resurrected on the next/api/sessionread: history reappeared after clear + refresh, and a continuedconversation still carried the full pre-clear context into the model.
Root cause
The
/api/session/clearhandler (api/routes.py) did:It never set
s.truncation_watermark. The append-only state.db merge(
merge_session_messages_append_only, reached viareconciled_state_db_messages_for_session) treats an unset /Nonewatermarkas "keep everything and just dedup". Because
state.dbis append-only, thecleared transcript was still there, so the merge re-added all of it on the next
read — the exact data-loss symptom in the issue.
The sibling destructive op
/api/session/truncatedoes not have this bug:it calls
truncate_session_at_keep(session, keep)inapi/session_ops.py,which sets
truncation_watermark = truncation_boundary = _truncation_watermark_for(kept_messages).Fix
A full clear is just a truncate that keeps zero messages. Route
/clearthrough the same helper the truncate handler uses:
_truncation_watermark_for([]) == 0.0, which is the #2914 "truncate-to-empty"sentinel that blocks all state.db replay. The merge/read path is
untouched —
/clearand/truncatenow set the marker identically, so themerge contract is not forked.
Second half of the issue (continued-context) — verified
The issue notes that a continued conversation retained pre-clear context. That
is fixed by the same change:
truncate_session_at_keep(s, 0)emptiescontext_messagesin lockstep withmessages(viatruncate_context_for_display_keep(..., keep=0) -> []), so the model-facingcontext no longer carries the cleared turns. A regression test asserts
context_messages == []after clear.Tests
tests/test_issue5532_clear_truncation_watermark.py(drives the realPOST /api/session/clearroute viahandle_post, mirroring the #2914integration harness):
test_clear_endpoint_sets_truncation_watermark— watermark & boundary set to0.0test_clear_empties_context_messages— second-half fix:context_messages == []test_clear_then_read_does_not_resurrect_state_db_messages— subsequentstate.db merge returns EMPTY (no resurrection)
test_clear_matches_truncate_to_empty_marker—/clearand/truncate(keep=0)produce an identical marker (no forked merge logic)Fail-without-fix confirmed: all four fail on
origin/master(
truncation_watermarkstaysNone, resurrection occurs) and pass with thischange.
Related watermark suite stays green: