fix(state): preserve soft-archived rows in transcript rewrites - #57803
fix(state): preserve soft-archived rows in transcript rewrites#57803Rival wants to merge 1 commit into
Conversation
replace_messages() deletes every row for the session by default, including the active=0 soft-archived turns that archive_and_compact() deliberately keeps on disk for NousResearch#38763 durability. The ACP adapter already guards this (has_archived_messages -> active_only=True), but the gateway rewrite path (/retry, /undo, /compress) and the tui_gateway prompt.submit truncation don't — a transcript rewrite on an in-place-compacted session silently destroys the archived pre-compaction history. Make the default safe instead of patching call sites one by one: active_only becomes Optional[bool]=None, where None means 'preserve soft-archived rows when the session has any'. The probe runs inside the same write transaction as the delete, so the decision cannot race a concurrent archive. Explicit active_only=False keeps the old full-wipe semantics; explicit True is unchanged. Sessions without archived rows (incl. the api_server fork path) behave byte-identically to before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Related: same soft-archived-transcript-preservation family as open #50306 (which added |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for tracing the surviving rewrite paths; the current TUI truncation path still calls the destructive default at tui_gateway/server.py:8509, and gateway retry reaches it through gateway/session.py:2470.
Problems
hermes_state.py:3288changes every implicit rewrite, including Yuanbao recall redaction. That path loads only active rows and rewrites them atgateway/platforms/yuanbao.py:1420-1427and:1459-1487; preserving inactive rows would leave recalled content in compaction-archived history.- The added unit tests do not exercise those call paths or distinguish compaction archives from recall-redaction semantics.
Suggested changes
- Preserve the destructive default and pass an explicit active-only choice only from transcript rewrite paths that are meant to retain archived history.
- Add real-SessionDB regressions for the protected rewrite path and the Yuanbao recall path.
This is an automated hermes-sweeper review.
| session_id: str, | ||
| messages: List[Dict[str, Any]], | ||
| active_only: bool = False, | ||
| active_only: Optional[bool] = None, |
There was a problem hiding this comment.
Changing this default also affects Yuanbao recall redaction through SessionStore.rewrite_transcript(): it loads only active rows, edits the recalled message, then rewrites. If inactive rows are retained automatically, a recalled message already in a compaction archive remains stored unchanged. Keep the destructive default and make archive preservation explicit at the safe call sites.
|
Closing this — the review was right, and in the meantime most of what it was for has been fixed upstream by other means. Writing up what I found so it is not lost, since a real gap does remain. All line references are against The review's objection holds, and the approach here is wrongFlipping Yuanbao recall redaction ( What upstream has since closed
ACP persist — closed, using precisely the pattern the review asked for. has_archived = db.has_archived_messages(state.session_id)
db.replace_messages(state.session_id, state.history, active_only=has_archived)with
What still looks openTwo callers still reach the destructive default, so a session that was compacted in place can still lose its pre-compaction archive:
If that is worth fixing, the shape the review asked for is now mechanical: thread an explicit choice through Why closing rather than reworkingOnce the default flip goes — and it should — nothing in this diff survives: both tests here exercise that hunk specifically. The remainder does not overlap this branch at all, and the title no longer describes the fix, which would only mislead a reviewer. Cleaner as a fresh narrow PR if and when someone wants it. One caveat I want to be explicit about: the two remaining gaps are from reading the code, not from a reproduction. I did not confirm that Thanks for the review — the yuanbao point was the one I had missed, and it is the reason the whole approach was wrong rather than just incomplete. |
|
Closing per the write-up above: the default flip is the wrong approach (yuanbao recall needs the destructive semantics), |
/retry truncates the live transcript to before the last user message and persists it via SessionStore.rewrite_transcript, which calls replace_messages() with the default active_only=False. That DELETEs every row for the session, including the soft-archived active=0/compacted=1 rows that in-place compaction keeps on disk (#38763), so any /retry after a compaction permanently wiped the archived history. #57803 named this call site as a residual gap after its global-default approach was rejected; the TUI sibling was fixed in #80195. The handler now probes has_archived_messages() (new SessionStore wrapper, auto-exposed through AsyncSessionStore) and passes active_only=True when archives exist, so only the live rows are replaced. rewrite_transcript gains an active_only parameter that defaults to False, keeping the destructive semantics yuanbao recall redaction depends on. Also corrects the rewrite_transcript docstring, which still listed /undo as a caller even though /undo soft-archives via rewind_session. The regression test drives _handle_retry_command against a real SessionStore and SessionDB seeded with archived compaction rows and asserts the archives survive.
/retry truncates the live transcript to before the last user message and persists it via SessionStore.rewrite_transcript, which calls replace_messages() with the default active_only=False. That DELETEs every row for the session, including the soft-archived active=0/compacted=1 rows that in-place compaction keeps on disk (NousResearch#38763), so any /retry after a compaction permanently wiped the archived history. NousResearch#57803 named this call site as a residual gap after its global-default approach was rejected; the TUI sibling was fixed in NousResearch#80195. The handler now probes has_archived_messages() (new SessionStore wrapper, auto-exposed through AsyncSessionStore) and passes active_only=True when archives exist, so only the live rows are replaced. rewrite_transcript gains an active_only parameter that defaults to False, keeping the destructive semantics yuanbao recall redaction depends on. Also corrects the rewrite_transcript docstring, which still listed /undo as a caller even though /undo soft-archives via rewind_session. The regression test drives _handle_retry_command against a real SessionStore and SessionDB seeded with archived compaction rows and asserts the archives survive.
/retry truncates the live transcript to before the last user message and persists it via SessionStore.rewrite_transcript, which calls replace_messages() with the default active_only=False. That DELETEs every row for the session, including the soft-archived active=0/compacted=1 rows that in-place compaction keeps on disk (NousResearch#38763), so any /retry after a compaction permanently wiped the archived history. NousResearch#57803 named this call site as a residual gap after its global-default approach was rejected; the TUI sibling was fixed in NousResearch#80195. The handler now probes has_archived_messages() (new SessionStore wrapper, auto-exposed through AsyncSessionStore) and passes active_only=True when archives exist, so only the live rows are replaced. rewrite_transcript gains an active_only parameter that defaults to False, keeping the destructive semantics yuanbao recall redaction depends on. Also corrects the rewrite_transcript docstring, which still listed /undo as a caller even though /undo soft-archives via rewind_session. The regression test drives _handle_retry_command against a real SessionStore and SessionDB seeded with archived compaction rows and asserts the archives survive.
What does this PR do?
replace_messages()deletes every row for the session by default — including theactive = 0soft-archived turns thatarchive_and_compact()deliberately keeps on disk for durability (#38763). The ACP adapter already guards against this (acp_adapter/session.py: probehas_archived_messages(), then passactive_only=True), but two other transcript-rewrite call sites don't:gateway/session.py— the /retry, /undo and /compress rewrite path;tui_gateway/server.py— history truncation inprompt.submit.Any transcript rewrite on a session that has been in-place-compacted silently destroys the archived pre-compaction history. We hit this in production: an archived transcript segment vanished after a bulk rewrite, with nothing in the logs.
This PR makes the default safe instead of patching call sites one by one:
active_onlybecomesOptional[bool] = None, whereNonemeans "preserve soft-archived rows when the session has any" (anactive = 0existence probe). The probe runs inside the same write transaction as the delete, so the decision cannot race a concurrent archive. Passingactive_only=Falseexplicitly keeps the old full-wipe semantics for callers that really mean it; explicitTrueis unchanged.Effects on existing callers:
acp_adapter/session.py— its manual probe becomes redundant (kept as-is here to minimise the diff; can be simplified in a follow-up).gateway/session.py,tui_gateway/server.py— fixed by the new default.gateway/platforms/api_server.py(session fork) — unaffected: a freshly created fork session has no archived rows, so the probe short-circuits and behaviour is byte-identical.If changing the default is undesirable, the fallback is to replicate the ACP guard at the two unprotected call sites — happy to rework the PR that way, but the auto-default also covers future callers.
Related Issue
Same durability concern as #38763 (which introduced the soft-archive).
Type of Change
Changes Made
hermes_state.py—replace_messages(active_only: Optional[bool] = None);Noneresolves via anactive = 0existence probe inside the write transaction; docstring rewritten to document all three modes.tests/test_hermes_state.py— three new tests: rewrite with archived rows present preserves them (new default); explicitactive_only=Falsestill wipes; rewrite on a session without archived rows matches old behaviour (message_count included).How to Test
306 passed locally (303 pre-existing + 3 new). Also ran every other test file referencing
replace_messages(tests/test_tui_gateway_server.py,tests/run_agent/test_in_place_compaction.py,tests/gateway/test_session_api.py,tests/gateway/test_session.py,tests/hermes_cli/test_web_server.py,tests/acp/test_session.py) before and after the change — pass/fail/skip counts are identical (the failures in my environment are missing optional deps, present on both runs).Manual: create a session, force in-place compaction (soft-archived rows appear), run /undo or /retry, verify the archived rows survive (
SELECT count(*) FROM messages WHERE session_id=? AND active=0).Checklist
Code
fix(state):, one squashed commit)tests/test_hermes_state.py); fullpytest tests/not re-run end-to-end (optional-dep env)Documentation & Housekeeping
cli-config.yaml.exampleif I added/changed config keys — N/A: no config keys🤖 Generated with Claude Code