fix(#4767): advance truncation_watermark instead of clearing to None after edit/retry/undo - #4772
AlexeyDsov wants to merge 5 commits into
Conversation
|
| Filename | Overview |
|---|---|
| api/models.py | Core logic change: merge_session_messages_append_only gains truncation_boundary parameter and a new empty-sidecar reconstruction path using boundary or backward-scan fallback; sidecar_advanced_past_watermark gets a new dead-code branch; Session model and _SAVED_FIELDS updated with truncation_boundary. |
| api/streaming.py | Renamed _retire_truncation_watermark_after_commit → _advance_truncation_watermark_after_commit; now walks messages backwards to find the newest user timestamp rather than clearing to None. _materialize_pending_user_turn_before_error similarly advances to recovered_ts. |
| api/routes.py | Truncate path now sets truncation_boundary = truncation_watermark immediately after computing the watermark; eager-checkpoint path advances watermark to user_msg.get('timestamp') or time.time(); duplicate-session constructor propagates truncation_boundary. |
| api/session_ops.py | Both retry_last and undo_last now persist truncation_boundary = truncation_watermark immediately after truncation so empty-sidecar recovery can distinguish legitimate prefix from deleted suffix. |
| api/webui_session_db.py | Adds truncation_boundary to _METADATA_FIELDS so the new field is persisted/restored by the session DB layer. |
| tests/test_core_data_loss_cases.py | New test file covering: empty-sidecar multi-turn resurrection, same-second assistant-reply guard, save/load round-trip for truncation_boundary, and reconciled_state_db_messages_for_session boundary propagation. |
| tests/test_watermark_advance_after_edit.py | New end-to-end regression tests for edit/retry/undo → new turn → reload scenarios, verifying watermark advances (not clears) and pre-edit state.db rows remain filtered. |
| tests/test_issue3831_watermark_clear.py | Existing tests updated to match the advance (not clear) semantics; new tests added for multi-message selection, no-timestamp fallback, and filtering of pre-edit state.db rows with advanced watermark. |
| tests/test_session_duplicate_fields.py | Two new tests assert that truncation_boundary is copied in duplicate sessions but omitted in branch sessions, consistent with the truncation_watermark policy. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["edit / retry / undo"] --> B["set truncation_watermark = last-kept-ts\nset truncation_boundary = last-kept-ts"]
B --> C["user sends new turn"]
C --> D{eager\ncheckpoint?}
D -- yes --> E["_checkpoint_user_message_for_eager_session_save\nadvance watermark → new msg timestamp"]
D -- no --> F["_advance_truncation_watermark_after_commit\n(after assistant reply committed)\nadvance watermark → newest user ts"]
E --> G["session saved to disk"]
F --> G
G --> H["session switch / reload"]
H --> I["merge_session_messages_append_only\n(sidecar + state.db)"]
I --> J{sidecar\nempty?}
J -- yes --> K{truncation_boundary\nset?}
K -- yes --> L["keep msgs ≤ boundary_ts\n+ keep msgs ≥ watermark_ts"]
K -- no --> M["backward-scan fallback:\ndrop last user+assistant pair"]
J -- no --> N{sidecar_advanced\npast_watermark?}
N -- yes --> O["allow state.db rows\nbeyond sidecar tail"]
N -- no --> P["filter state.db rows\nabove watermark"]
L --> Q["✅ pre-edit rows filtered\npost-edit rows kept"]
M --> Q
O --> Q
P --> Q
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A["edit / retry / undo"] --> B["set truncation_watermark = last-kept-ts\nset truncation_boundary = last-kept-ts"]
B --> C["user sends new turn"]
C --> D{eager\ncheckpoint?}
D -- yes --> E["_checkpoint_user_message_for_eager_session_save\nadvance watermark → new msg timestamp"]
D -- no --> F["_advance_truncation_watermark_after_commit\n(after assistant reply committed)\nadvance watermark → newest user ts"]
E --> G["session saved to disk"]
F --> G
G --> H["session switch / reload"]
H --> I["merge_session_messages_append_only\n(sidecar + state.db)"]
I --> J{sidecar\nempty?}
J -- yes --> K{truncation_boundary\nset?}
K -- yes --> L["keep msgs ≤ boundary_ts\n+ keep msgs ≥ watermark_ts"]
K -- no --> M["backward-scan fallback:\ndrop last user+assistant pair"]
J -- no --> N{sidecar_advanced\npast_watermark?}
N -- yes --> O["allow state.db rows\nbeyond sidecar tail"]
N -- no --> P["filter state.db rows\nabove watermark"]
L --> Q["✅ pre-edit rows filtered\npost-edit rows kept"]
M --> Q
O --> Q
P --> Q
Reviews (4): Last reviewed commit: "fix(#4767): review fixes - more edge cas..." | Re-trigger Greptile
|
Thanks @AlexeyDsov — this is a real bug (ghost pre-edit rows resurrecting + polluting agent context) and "advance the watermark instead of clearing it" is the right direction. I warm-gated it (Codex + full suite). The suite is green (10207), but Codex reproduced two CORE edge cases the suite doesn't cover — both on the core state.db merge path, so they need fixing before this can ship: 1. (CORE) Same-second edit/retry can still resurrect the pre-edit rows —
|
nesquena-hermes
left a comment
There was a problem hiding this comment.
Warm-gated: suite green but Codex reproduced 2 CORE edge cases the suite doesn't cover — (1) same-second edit/retry still resurrects the ghost (timestamp tie vs the < boundary), (2) empty-sidecar recovery drops legitimate post-edit rows (reintroduces #3831 data loss). Both on the state.db merge path. Details + exact fixes in the comment.
|
Thanks for the quick turnaround @AlexeyDsov — but the re-push (the "Update api/streaming.py" commit) doesn't reach either CORE finding. Both live in
So both fixes belong in |
658ebe5 to
890e4b2
Compare
|
Thanks for the fast turnaround @AlexeyDsov — the 14:19 re-push did converge CORE finding #1 (same-second edit/retry timestamp-tie): I re-gated it (Codex, GPT-5.5, high reasoning, with direct reproduction harnesses) and the same-second non-empty-sidecar filtering verifies clean now. Good fix. But CORE finding #2 is still live — and the gate reproduced it directly on the current head, so this can't ship yet. CORE — empty-sidecar recovery still corrupts the transcript —
|
…to None after edit/retry/undo (nesquena#2914, nesquena#3831)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
890e4b2 to
297c88f
Compare
|
Thanks for the continued iteration @AlexeyDsov — CORE#1 (same-second tie) and my earlier CORE#2 (the basic empty-sidecar post-edit-reply case) are both resolved now; I verified the simple cases pass. But a deep P0 re-gate (Codex GPT-5.5, direct reproduction) found two more reproduced data-loss cases in the empty-sidecar recovery path, and they point at a design limitation rather than a one-liner — so I can't ship this yet. Both were reproduced against the current head ( CORE-A — empty-sidecar recovery resurrects deleted pre-edit turns when editing an older message with more than one later turn (
|
|
Shipped in v0.51.628 (Release WI) — thanks @AlexeyDsov! 🎉 Closes #4767. Your branch landed the core design — advance
Gate (all three, clean): Codex SAFE TO SHIP (8 data-loss reproductions all converged), Opus must-fixes applied, full suite 10392 passed. Deployed to prod, verified Really solid root-cause direction on a gnarly append-only-merge bug — appreciate the iteration. The new regression tests pin every edge so this class can't silently come back. |
…esquena#4986) Manual /compress shrinks the model-facing context_messages but keeps the visible messages[] transcript. The old context returned via two paths (nesquena#4836): (1) append-only state.db reconciliation re-appending pre-compression rows, and (2) startup .bak recovery treating the intentional shrink as data loss. Half A (routes.py _handle_session_compress): persist truncation_watermark + truncation_boundary (= watermark of the compressed context), set compression_anchor_mode="manual", refresh last_prompt_tokens, stamp missing timestamps on the compressed context, and delete the now-stale .bak. The boundary==watermark stamp drives the nesquena#4772 reconciliation logic onto its conservative path (block replay of pre-compression rows) while post-compression turns still merge once sidecar timestamps advance past the watermark. Half B (session_recovery.py): the .bak recovery guard. MAINTAINER FIX over the original PR — the contributor suppressed recovery whenever compression_anchor_mode=="manual", a flag set once at compress and never cleared, which PERMANENTLY disabled nesquena#1558 crash-recovery for any compressed session (real data loss). Recovery is now suppressed only when the session was intentionally compressed AND the .bak is genuinely the pre-compression backup, discriminated by the compaction marker (the same _context_messages_include_ compression_marker signal reconciliation uses): a marked .bak post-dates the compression -> recover; an unmarked .bak with a larger context is the shrink-undoing pre-compression one -> suppress. Fail-open on any error. Resolves an Opus-gate edge in the first cut (length-only heuristic wrongly suppressed a loss that shrank BOTH messages and context). Two new non-vacuous regression tests: post-compression real loss recovers, and the both-shrunk marked-backup case recovers. Co-authored-by: hyl-ailab <hyl-ailab@users.noreply.github.com>
Closes #4767
Problem
After editing a message (or using
/retryor/undo), switching to another session and switching back caused the original pre-edit message and its assistant reply to reappear. These ghost messages were also fed into the agent's context window, polluting the conversation.Root cause
truncation_watermarkwas cleared toNonewhen a new user turn was committed after edit/retry/undo. Without the watermark,merge_session_messages_append_only()lost the boundary needed to filter out replaced pre-edit rows from the append-only state.db, so they leaked back into the merged transcript on reload.Fix
Advance
truncation_watermarkto the newest user message timestamp instead of clearing it. The existingsidecar_advanced_past_watermarkguard in the merge logic already allows post-edit state.db rows to merge in, while the advanced watermark keeps filtering replaced pre-edit rows whose timestamps fall below the boundary.Changes
api/routes.py:_checkpoint_user_message_for_eager_session_save— advance watermark to new message timestampapi/models.py:_append_recovered_pending_turn— advance watermark to recovered timestampapi/streaming.py:_retire_truncation_watermark_after_commit→_advance_truncation_watermark_after_commit— find newest user message timestamp and advance watermark;_materialize_pending_user_turn_before_error— advance to recovered timestamptests/test_issue3831_watermark_clear.py: updated to reflect advance semanticstests/test_watermark_advance_after_edit.py: new end-to-end tests for edit/retry/undo → new turn → reload scenariosRelated
/undoand message edit appear to succeed but have no visual effect #2914 (state.db tail replay after undo/retry/edit)AI Assistance
This fix was developed with assistance from Qwen3.6-27B. The AI helped analyze the reconciliation logic, identify root cause, fix code and test scenarios.