fix(sessions): don't let the empty-session sweep delete an archived transcript - #96092
Closed
JoaoMarcos44 wants to merge 2 commits into
Closed
JoaoMarcos44 wants to merge 2 commits into
JoaoMarcos44 wants to merge 2 commits into
Conversation
…ranscript
`count_empty_sessions` / `delete_empty_sessions` — the dashboard's
"Delete empty (N)" affordance — defined "empty" as `sessions.message_count
= 0`. That column is a denormalized counter over the LIVE (`active = 1`)
rows only, and two production transcript-rewrite paths reset it on purpose
while keeping every dropped turn on disk as `active = 0`:
* `replace_messages(..., archive_dropped=True)` — the rewind / edit /
regenerate mode added in NousResearch#82756 so a taken-back turn stays recoverable.
* `archive_and_compact` — in-place compaction, which archives the
pre-compaction transcript under the same session id (NousResearch#38763).
A chat rewound to its first turn, or compacted with an empty live set,
therefore reports `message_count = 0` while still holding its entire
history — and those soft-archived rows are the only copy. A gateway reload
is what makes the row eligible: it stamps `ended_at` on every detached
session (`end_reason='ws_orphan_reap'`), satisfying the sweep's
`ended_at IS NOT NULL` gate. The next sweep then hard-deleted the session
row AND `DELETE FROM messages`, destroying the transcript silently.
Every other emptiness test in `hermes_state` already defends the counter
with a real `EXISTS (SELECT 1 FROM messages ...)` probe
(`delete_session_if_empty`, `prune_empty_ghost_sessions`,
`list_never_active_keyed_sessions`, `find_recoverable_session`). This
sweep was the only destructive path that trusted the counter alone. It now
uses the same probe, via one `_EMPTY_SESSION_WHERE` selector shared by the
count and the delete so the button's N and the sweep it triggers can never
disagree again. The counter stays as a cheap prefilter; `EXISTS` is the
authority.
Genuinely message-less rows are still swept — the feature is unchanged for
the case it was built for.
Fixes NousResearch#95868
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zTDHnWcBvQsJSX1fBLhuv
…esearch#95868 guards Precision pass on the comments added by the previous commit: prompt.submit reaches replace_messages(archive_dropped=True) with an empty prefix on a confirmed ordinal-0 rewind, which is the production shape that lands a populated session on message_count = 0. archive_and_compact normally publishes at least a summary row, so it is pinned as defense in depth rather than claimed as an equally reachable trigger. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011zTDHnWcBvQsJSX1fBLhuv
|
Merged via #96401. Your commits were cherry-picked with authorship preserved (rebase-merge). The fix is correct and well-tested — the Thanks for the thorough root-cause analysis in the PR body — it made the review straightforward. |
2 of 5 tasks
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.
Summary
Investigating #95868 I could not reproduce a hard delete on the reload path itself — and I want to state that plainly up front, because it changes where the fix belongs.
Driving the real code against a real
state.db, the gateway-reload sequence the reporter describes (ws closed … code=1012 … detached_sessions=7→ plugin remount) preserves every row._close_sessions_for_transportonly re-points detached sessions at the drop sentinel;_teardown_popped_session→_finalize_sessiononly callsend_session(). There is noDELETE FROM sessionsanywhere on that path. What the reload does do is stampended_at+end_reason='ws_orphan_reap'on every detached session — and that stamp is the trigger for the actual defect.Root cause: the empty-session sweep decides "empty" from a counter that two production paths deliberately zero on populated sessions.
SessionDB.count_empty_sessions()/delete_empty_sessions()— the dashboard's "Delete empty (N)" affordance,GET|DELETE /api/sessions/empty— selected onsessions.message_count = 0. That column is a denormalized counter over the live (active = 1) row set only. Two transcript-rewrite paths reset it on purpose while keeping every dropped turn on disk asactive = 0:message_count = 0replace_messages(..., archive_dropped=True)DELETEalso evicts the rows from FTS, leaving nothing to recover fromprompt.submitcomputes the surviving prefix withhistory_before_user_originated_turn; a confirmed ordinal-0 rewind (regenerating the very first user turn, gated behindconfirm_empty_truncate) yieldstruncated == []archive_and_compactSo a chat whose first user turn was regenerated reports
message_count = 0while still holding its entire recoverable history — and those soft-archived rows are the only copy that exists. Once the gateway reload stampsended_at, the row satisfies the sweep'sended_at IS NOT NULLgate, gets counted in the "Delete empty (N)" badge, and is hard-deleted together withDELETE FROM messages. Silently, and with no log line — which is exactly the "rows gone, nodelete_sessionin the logs" signature reported.What makes this sting is whose guarantee it breaks. The
archive_dropped=Truecall site says so in its own comment: "a rewind overwrites turns the user may not have meant to drop, and this write is the last step before they are gone — three reported incidents ended here with nothing to restore from (#70516, #80763, #82756). Soft-archiving keeps them on disk (active=0) and in the FTS index, so a mis-aimed cut is recoverable instead of terminal." The empty-session sweep silently deletes exactly the rows those three fixes were written to preserve.The asymmetry that makes this a clear bug rather than a design call: every other emptiness test in
hermes_statealready refuses to trust the counter and pairs it with a realEXISTS (SELECT 1 FROM messages …)probe —delete_session_if_empty,prune_empty_ghost_sessions,list_never_active_keyed_sessions,find_recoverable_session. This sweep was the single destructive path still trusting it alone. Its own inline comment even anticipated the failure ("if a bookkeeping bug ever lets the counter drift below the real row count") without guarding against it.The fix
One selector,
SessionDB._EMPTY_SESSION_WHERE, shared by the count and the delete so the badge'sNand the sweep it triggers can never disagree about what "empty" means:The counter stays as a cheap prefilter; the
EXISTSprobe is the authority. This is the same shape the four sibling guards already use, so it is an in-style narrowing rather than new machinery. Genuinely message-less rows are still swept — the feature is unchanged for the case it was built for.Flow
%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#8b0000', 'mainBkg': '#0a0204', 'primaryTextColor': '#ffccd5', 'primaryBorderColor': '#ff0038', 'lineColor': '#ff0038'}}}%% graph TD A[🩸 Populated Desktop Chat] -->|"confirmed ordinal-0 rewind"| B["🔥 replace_messages(archive_dropped=True)"] A -->|"in-place compaction"| C[🔥 archive_and_compact] B --> D["⚰️ Rows kept as active = 0<br/>message_count reset to 0"] C --> D D -->|"gateway reload<br/>detached_sessions > 0"| E["🕯️ ended_at stamped<br/>end_reason = ws_orphan_reap"] E --> F{"⚔️ Empty-Session Sweep"} F -->|"BEFORE: message_count = 0 only"| G["💀 Hard Delete<br/>sessions + messages<br/>no log, unrecoverable"] F -->|"AFTER: + NOT EXISTS messages"| H["🛡️ Transcript Preserved<br/>row survives, archived rows intact"] style G fill:#3b0008,stroke:#ff0038,color:#ffccd5 style H fill:#062a12,stroke:#28d17c,color:#d6ffe6Relationship to #95961
Deliberately disjoint — no overlapping files, no overlapping mechanism. #95961 addresses the soft half of the report (populated rows should not be stamped
tui_shutdown/ended by a reload, plus delete-path logging) intui_gateway/*. This PR addresses the hard half: the sweep that turns anended_atstamp into an actualDELETE. Both can land independently; together they close the loop, since #95961 removes the stamp that makes rows eligible and this PR removes the sweep's ability to act on a populated row even when a stamp is legitimate.Verification
Empirical, not inferred. Two harnesses were run against real
SessionDBinstances:source='desktop'sessions bound to one transport, then_close_sessions_for_transport(reaped=0 detached=8, matching the report's shape) → forcedws_orphan_reapteardown →_shutdown_sessions→ next-startup_run_state_db_auto_maintenance. Result: 8/8 rows survived, stampedended_at+ws_orphan_reap. No hard-delete path exists there.replace_messages(sid, [], archive_dropped=True)andarchive_and_compact(sid, [])both leave(message_count=0, rows_on_disk=4, rows_active=0). On pre-fix codecount_empty_sessions()reported2anddelete_empty_sessions()destroyed both session rows and all 8 message rows.The regression tests encode exactly that, and were confirmed red before / green after (3 of the 5 fail on unpatched
hermes_state.py).Test plan
pytest tests/hermes_state/test_empty_sweep_archived_transcript_95868.py— 5 passed (3 fail without the fix)pytest tests/test_empty_session_hygiene.py tests/test_session_system_prompt_dedup.py tests/hermes_state— 202 passedpytest tests/hermes_cli/test_web_server.py::TestDeleteEmptySessionsEndpoint— 3 passedpytest tests/test_hermes_state.py tests/test_web_server_sessiondb_eventloop.py— 246 passed, 2 skippedruff checkon all changed files — cleanFixes #95868