fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
"""Regression tests for #3831 — stale truncation_watermark data loss.
|
|
|
|
|
|
|
|
|
|
Root cause: retry_last / undo_last / the Edit-truncate handler set
|
|
|
|
|
``truncation_watermark`` to suppress the *replaced* tail from the append-only
|
|
|
|
|
state.db merge. ``Session.save()`` deliberately does not auto-clear it (#2914),
|
|
|
|
|
but nothing cleared it when the user then sent a genuinely new turn either — so
|
|
|
|
|
it froze at the old edit boundary. A frozen watermark then dropped post-watermark
|
|
|
|
|
state.db rows whenever the sidecar was later reconstructed empty
|
|
|
|
|
(recovery/reconcile), permanently losing the turns sent after the edit.
|
|
|
|
|
|
2026-06-23 09:55:29 +00:00
|
|
|
Fix: advance a *positive* watermark to the newest user message timestamp once
|
|
|
|
|
the new user turn is COMMITTED to ``session.messages`` (the success-merge,
|
|
|
|
|
eager-checkpoint, and recovery/cold-load commit points) — NOT at chat-start.
|
|
|
|
|
Clearing at chat-start was unsafe: in deferred mode (the default) the new user
|
|
|
|
|
row isn't in messages yet, so a merge in that window would resurrect the replaced
|
|
|
|
|
tail (the max-sidecar guard hasn't risen past the old boundary). Once the row is
|
|
|
|
|
committed, ``max_sidecar_timestamp`` rises past the replaced tail and the merge
|
|
|
|
|
suppresses it without the watermark, so advancing it is both safe and necessary.
|
|
|
|
|
|
|
|
|
|
Advancing to the new message timestamp (instead of clearing to None) keeps the
|
|
|
|
|
merge's watermark filter active for replaced pre-edit rows from state.db whose
|
|
|
|
|
timestamps fall below the boundary, while the sidecar_advanced_past_watermark
|
|
|
|
|
guard allows newer state.db rows to merge in.
|
|
|
|
|
|
|
|
|
|
``0.0`` is the truncate-to-empty sentinel (#2914) that must keep blocking all
|
|
|
|
|
state replay, so the advance is falsy-gated and never touches ``0.0``.
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
"""
|
|
|
|
|
import api.models as models
|
|
|
|
|
import api.streaming as streaming
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _rows(*specs):
|
|
|
|
|
return [
|
|
|
|
|
{"role": role, "content": content, "timestamp": ts}
|
|
|
|
|
for (role, content, ts) in specs
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakeSession:
|
|
|
|
|
def __init__(self, watermark):
|
|
|
|
|
self.truncation_watermark = watermark
|
|
|
|
|
self.messages = []
|
|
|
|
|
self.context_messages = []
|
|
|
|
|
self.pending_user_message = None
|
|
|
|
|
self.pending_attachments = None
|
|
|
|
|
self.pending_started_at = None
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 09:55:29 +00:00
|
|
|
# --- The fix: a committed new turn advances a stale positive watermark --------
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
|
2026-06-23 09:55:29 +00:00
|
|
|
def test_advance_helper_advances_positive_watermark():
|
|
|
|
|
"""After a new user turn is committed, the watermark advances to the
|
|
|
|
|
newest user message timestamp — NOT cleared to None."""
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
s = _FakeSession(100.0) # stale, from a prior retry/undo/edit
|
|
|
|
|
s.messages = _rows(("user", "new turn", 200))
|
2026-06-23 09:55:29 +00:00
|
|
|
streaming._advance_truncation_watermark_after_commit(s)
|
|
|
|
|
assert s.truncation_watermark == 200.0
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
|
|
|
|
|
|
2026-06-23 09:55:29 +00:00
|
|
|
def test_advance_helper_leaves_zero_watermark_untouched():
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
"""0.0 is the truncate-to-empty sentinel (#2914), not a stale boundary — the
|
|
|
|
|
falsy guard must leave it alone so #2914 replay-blocking is preserved."""
|
|
|
|
|
s = _FakeSession(0.0)
|
|
|
|
|
s.messages = _rows(("user", "new turn", 200))
|
2026-06-23 09:55:29 +00:00
|
|
|
streaming._advance_truncation_watermark_after_commit(s)
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
assert s.truncation_watermark == 0.0
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 09:55:29 +00:00
|
|
|
def test_advance_helper_noop_when_unset():
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
s = _FakeSession(None)
|
2026-06-23 09:55:29 +00:00
|
|
|
streaming._advance_truncation_watermark_after_commit(s)
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
assert s.truncation_watermark is None
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 09:55:29 +00:00
|
|
|
def test_advance_helper_uses_current_time_when_no_timestamp():
|
|
|
|
|
"""When the newest user message has no timestamp, the helper falls back to
|
|
|
|
|
time.time()."""
|
|
|
|
|
s = _FakeSession(100.0)
|
|
|
|
|
s.messages = _rows(("user", "new turn", None))
|
|
|
|
|
streaming._advance_truncation_watermark_after_commit(s)
|
|
|
|
|
assert s.truncation_watermark is not None
|
|
|
|
|
assert s.truncation_watermark > 100.0 # advanced past the old watermark
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_advance_helper_picks_newest_user_timestamp():
|
|
|
|
|
"""When there are multiple user messages, the helper picks the newest one."""
|
|
|
|
|
s = _FakeSession(50.0)
|
|
|
|
|
s.messages = _rows(
|
|
|
|
|
("user", "first", 100),
|
|
|
|
|
("assistant", "reply", 101),
|
|
|
|
|
("user", "second", 200),
|
|
|
|
|
("assistant", "reply2", 201),
|
|
|
|
|
)
|
|
|
|
|
streaming._advance_truncation_watermark_after_commit(s)
|
|
|
|
|
assert s.truncation_watermark == 200.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_recovery_commit_advances_watermark():
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
"""The cold-load / recovery commit path (_append_recovered_pending_turn)
|
2026-06-23 09:55:29 +00:00
|
|
|
advances a stale positive watermark when it materializes the pending turn."""
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
s = _FakeSession(100.0)
|
|
|
|
|
s.pending_user_message = "recovered new turn"
|
|
|
|
|
s.pending_attachments = None
|
|
|
|
|
models._append_recovered_pending_turn(s, timestamp=200)
|
2026-06-23 09:55:29 +00:00
|
|
|
assert s.truncation_watermark == 200
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
# The pending turn was committed to messages.
|
|
|
|
|
assert any(m.get("content") == "recovered new turn" for m in s.messages)
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 09:55:29 +00:00
|
|
|
# --- The effect: advanced watermark still merges post-edit turns --------------
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
|
2026-06-23 09:55:29 +00:00
|
|
|
def test_advanced_watermark_keeps_post_edit_state_rows():
|
|
|
|
|
"""With a sidecar whose max timestamp exceeds the watermark (the real
|
|
|
|
|
post-edit state after the assistant reply was appended), post-edit state.db
|
|
|
|
|
rows are merged in because sidecar_advanced_past_watermark is True."""
|
|
|
|
|
sidecar = _rows(
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
("user", "q1", 50),
|
|
|
|
|
("assistant", "a1", 100), # old edit boundary
|
2026-06-23 09:55:29 +00:00
|
|
|
("user", "q2-new", 200), # new message after edit (watermark=200)
|
|
|
|
|
("assistant", "a2-new", 250), # assistant reply — pushes sidecar past watermark
|
|
|
|
|
)
|
|
|
|
|
state = _rows(
|
|
|
|
|
("user", "q1", 50),
|
|
|
|
|
("assistant", "a1", 100),
|
|
|
|
|
("user", "q2-new", 200),
|
|
|
|
|
("assistant", "a2-new", 250),
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
)
|
|
|
|
|
merged = models.merge_session_messages_append_only(
|
2026-06-23 09:55:29 +00:00
|
|
|
sidecar, state, truncation_watermark=200.0
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
)
|
2026-06-23 09:55:29 +00:00
|
|
|
# max_sidecar_timestamp=250 > watermark=200 → sidecar_advanced_past_watermark=True
|
|
|
|
|
# → watermark filter does not block a2-new
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
assert [m["content"] for m in merged] == ["q1", "a1", "q2-new", "a2-new"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- The guardrails: the fix must NOT regress #2914/#3102 ---------------------
|
|
|
|
|
|
|
|
|
|
def test_active_watermark_still_filters_replaced_tail_empty_sidecar():
|
2026-06-24 07:13:35 +00:00
|
|
|
"""A positive watermark (advanced to the new user-turn timestamp) with an
|
|
|
|
|
empty sidecar must suppress the replaced pre-edit tail while keeping
|
2026-06-24 18:08:42 +00:00
|
|
|
post-edit rows (#4767 / CORE finding #2).
|
|
|
|
|
|
|
|
|
|
A genuinely-advanced session persists the original cutoff as
|
|
|
|
|
``truncation_boundary`` (@100, the last kept reply before the replaced tail)
|
|
|
|
|
alongside the advanced watermark (@200, the new committed turn). The
|
|
|
|
|
reconstruction keeps ts <= boundary plus ts >= watermark and drops the
|
|
|
|
|
replaced (boundary, watermark) suffix."""
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
state = _rows(
|
|
|
|
|
("user", "q1", 50),
|
|
|
|
|
("assistant", "a1", 100),
|
2026-06-24 07:13:35 +00:00
|
|
|
("user", "replaced-q2", 150), # replaced by the edit -> filtered
|
|
|
|
|
("assistant", "replaced-a2", 160), # replaced by the edit -> filtered
|
|
|
|
|
("user", "edited-q2", 200), # new user turn (watermark advanced here)
|
|
|
|
|
("assistant", "a2-new", 300), # post-edit reply -> kept
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
)
|
|
|
|
|
merged = models.merge_session_messages_append_only(
|
2026-06-24 18:08:42 +00:00
|
|
|
[], state, truncation_watermark=200.0, truncation_boundary=100.0
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
)
|
2026-06-24 07:13:35 +00:00
|
|
|
assert [m["content"] for m in merged] == ["q1", "a1", "edited-q2", "a2-new"]
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_zero_watermark_still_blocks_all_replay_empty_sidecar():
|
|
|
|
|
"""A 0.0 watermark on a truncate-to-empty session must STILL block all state
|
|
|
|
|
replay (#2914) — unchanged by this fix."""
|
|
|
|
|
state = _rows(
|
|
|
|
|
("user", "only prompt", 1.0),
|
|
|
|
|
("assistant", "only reply", 2.0),
|
|
|
|
|
)
|
|
|
|
|
merged = models.merge_session_messages_append_only(
|
|
|
|
|
[], state, truncation_watermark=0.0
|
|
|
|
|
)
|
|
|
|
|
assert merged == []
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 09:55:29 +00:00
|
|
|
# --- Inline commit-path coverage: the two sites that inline the advance --------
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
|
2026-06-23 09:55:29 +00:00
|
|
|
def test_error_path_materialize_advances_positive_watermark():
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
"""The error/cancel materialization path (_materialize_pending_user_turn_before_error)
|
2026-06-23 09:55:29 +00:00
|
|
|
inlines the watermark advance. A pending user turn committed on the error
|
|
|
|
|
path advances a stale positive watermark to the recovered timestamp."""
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
s = _FakeSession(100.0) # stale, from a prior retry/undo/edit
|
|
|
|
|
s.pending_user_message = "new turn after edit"
|
|
|
|
|
appended = streaming._materialize_pending_user_turn_before_error(s)
|
|
|
|
|
assert appended is True
|
2026-06-23 09:55:29 +00:00
|
|
|
assert s.truncation_watermark is not None
|
|
|
|
|
assert s.truncation_watermark >= 100.0 # advanced past old watermark
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
assert any(m.get("content") == "new turn after edit" for m in s.messages)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_error_path_materialize_preserves_zero_sentinel():
|
|
|
|
|
"""The error-path inline must use the same falsy guard as the helper: the 0.0
|
|
|
|
|
truncate-to-empty sentinel (#2914) is preserved, not cleared."""
|
|
|
|
|
s = _FakeSession(0.0)
|
|
|
|
|
s.pending_user_message = "new turn"
|
|
|
|
|
streaming._materialize_pending_user_turn_before_error(s)
|
|
|
|
|
assert s.truncation_watermark == 0.0
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 09:55:29 +00:00
|
|
|
def test_eager_checkpoint_advances_positive_watermark():
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
"""The eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save
|
2026-06-23 09:55:29 +00:00
|
|
|
in routes.py) inlines the same advance. A committed user turn advances a
|
|
|
|
|
stale positive watermark to the new message timestamp; the 0.0 sentinel is
|
|
|
|
|
preserved."""
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
import api.routes as routes
|
|
|
|
|
|
|
|
|
|
s = _FakeSession(100.0)
|
|
|
|
|
routes._checkpoint_user_message_for_eager_session_save(
|
|
|
|
|
s, "eager new turn", None, started_at=200.0
|
|
|
|
|
)
|
2026-06-23 09:55:29 +00:00
|
|
|
assert s.truncation_watermark == 200.0
|
fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes #3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:39:32 +00:00
|
|
|
assert any(m.get("content") == "eager new turn" for m in s.messages)
|
|
|
|
|
|
|
|
|
|
s0 = _FakeSession(0.0)
|
|
|
|
|
routes._checkpoint_user_message_for_eager_session_save(
|
|
|
|
|
s0, "eager new turn", None, started_at=200.0
|
|
|
|
|
)
|
|
|
|
|
assert s0.truncation_watermark == 0.0
|
|
|
|
|
|
2026-06-23 09:55:29 +00:00
|
|
|
|
|
|
|
|
# --- Advanced watermark filters pre-edit state.db rows -------------------------
|
|
|
|
|
|
|
|
|
|
def test_advanced_watermark_filters_pre_edit_state_db_rows():
|
|
|
|
|
"""With the watermark advanced to the new message timestamp, pre-edit state.db
|
|
|
|
|
rows whose timestamps are below the watermark AND whose content is not in the
|
|
|
|
|
sidecar are still filtered out."""
|
|
|
|
|
sidecar = _rows(
|
|
|
|
|
("user", "square", 200),
|
|
|
|
|
("assistant", "360", 201),
|
|
|
|
|
)
|
|
|
|
|
state_db = _rows(
|
|
|
|
|
("user", "triangle", 100), # pre-edit, below watermark, not in sidecar
|
|
|
|
|
("assistant", "180", 101), # pre-edit assistant, below watermark
|
|
|
|
|
("user", "square", 200), # in sidecar
|
|
|
|
|
("assistant", "360", 201), # in sidecar
|
|
|
|
|
)
|
|
|
|
|
merged = models.merge_session_messages_append_only(
|
|
|
|
|
sidecar, state_db, truncation_watermark=200.0
|
|
|
|
|
)
|
|
|
|
|
contents = [m["content"] for m in merged]
|
|
|
|
|
assert "triangle" not in contents
|
|
|
|
|
assert "180" not in contents
|
|
|
|
|
assert "square" in contents
|
|
|
|
|
assert "360" in contents
|