Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 127 additions & 41 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,12 +428,15 @@ def _append_recovered_pending_turn(session, *, timestamp: int | None = None) ->
recovered['attachments'] = list(session.pending_attachments)
session.messages.append(recovered)
_append_recovered_turn_to_context(session, recovered)
# The new user turn is now committed to messages (#3831): retire a positive
# truncation watermark from a prior retry/undo/edit so it can't freeze at the
# old edit boundary and drop these post-edit turns on a later empty-sidecar
# reconcile. None, never 0.0 (the truncate-to-empty sentinel, #2914).
# The new user turn is now committed to messages (#3831): advance the
# truncation watermark to the new message's timestamp so that
# merge_session_messages_append_only() still filters out replaced
# pre-edit rows from state.db whose timestamps fall below the boundary.
# The merge's sidecar_advanced_past_watermark guard allows state.db rows
# newer than the watermark, so post-edit turns are not dropped.
# Never 0.0 (the truncate-to-empty sentinel, #2914).
if getattr(session, 'truncation_watermark', None):
session.truncation_watermark = None
session.truncation_watermark = recovered_ts
return recovered


Expand Down Expand Up @@ -643,6 +646,7 @@ def __init__(self, session_id: str=None, title: str='Untitled',
context_length=None, threshold_tokens=None,
last_prompt_tokens=None,
truncation_watermark=None,
truncation_boundary=None,
gateway_routing=None, gateway_routing_history=None,
llm_title_generated: bool=False,
manual_title: bool=False,
Expand Down Expand Up @@ -693,6 +697,7 @@ def __init__(self, session_id: str=None, title: str='Untitled',
self.threshold_tokens = threshold_tokens
self.last_prompt_tokens = last_prompt_tokens
self.truncation_watermark = truncation_watermark
self.truncation_boundary = truncation_boundary
self.gateway_routing = gateway_routing if isinstance(gateway_routing, dict) else None
self.gateway_routing_history = gateway_routing_history if isinstance(gateway_routing_history, list) else []
self.llm_title_generated = bool(llm_title_generated)
Expand Down Expand Up @@ -762,6 +767,7 @@ def save(self, touch_updated_at: bool = True, skip_index: bool = False) -> None:
'compression_anchor_details', 'context_engine_state',
'context_length', 'threshold_tokens', 'last_prompt_tokens',
'truncation_watermark',
'truncation_boundary',
'gateway_routing', 'gateway_routing_history', 'llm_title_generated', 'manual_title',
'parent_session_id',
'worktree_path', 'worktree_branch', 'worktree_repo_root', 'worktree_created_at',
Expand Down Expand Up @@ -5074,31 +5080,71 @@ def merge_session_messages_append_only(
state_messages: list,
*,
truncation_watermark=None,
truncation_boundary=None,
) -> list:
"""Merge sidecar/context and state.db messages without deleting local rows."""
"""Merge sidecar/context and state.db messages without deleting local rows.

``truncation_boundary``: the original truncate cutoff — the
timestamp of the last message kept by the truncate operation. When the
watermark is later advanced (new turn committed), this boundary is preserved
so the empty-sidecar recovery can distinguish a legitimate prefix from a
deleted suffix instead of guessing by dropping one turn pair.
"""
sidecar_messages = list(sidecar_messages or [])
state_messages = list(state_messages or [])
watermark_timestamp = _message_timestamp_as_float({"timestamp": truncation_watermark})
if not state_messages:
return sidecar_messages
if not sidecar_messages:
if watermark_timestamp is not None:
filtered = [
msg for msg in state_messages
if (
(timestamp := _message_timestamp_as_float(msg)) is not None
and timestamp <= watermark_timestamp
)
]
else:
if watermark_timestamp is None:
# No watermark — keep everything, just dedup.
filtered = state_messages
elif watermark_timestamp == 0:
# Truncate-to-empty sentinel (#2914) — block all replay.
return []
else:
# Positive watermark advanced after edit/retry/undo (#4767).
# Without a sidecar there's no seen_content_keys to check against,
# so we reconstruct the correct transcript from state.db alone.
#
# Use truncation_boundary (the original truncate cutoff) to
# distinguish legitimate prefix from deleted suffix. Without it,
# fall back to the backward-scan heuristic (drops last user+assistant
# pair) which works for the common single-turn case.
boundary_ts = _message_timestamp_as_float({"timestamp": truncation_boundary})
at_or_after = []
pre_watermark = []
for msg in state_messages:
ts = _message_timestamp_as_float(msg)
if ts is not None and ts >= watermark_timestamp:
at_or_after.append(msg)
else:
pre_watermark.append(msg)
if boundary_ts is not None:
# Use the persisted boundary: keep only messages at or before it.
pre_legitimate = [
m for m in pre_watermark
if (ts := _message_timestamp_as_float(m)) is not None
and ts <= boundary_ts
]
filtered = pre_legitimate + at_or_after
else:
# Fallback: backward scan (works when exactly one turn was
# deleted — the common edit/retry/undo case).
i = len(pre_watermark) - 1
while i >= 0:
role = str(pre_watermark[i].get("role", "")).lower()
if role == "assistant":
i -= 1
elif role == "user":
i -= 1 # skip this user message too (the replaced prompt)
break
else:
i -= 1 # tool messages etc. — skip
filtered = pre_watermark[:i + 1] + at_or_after

# Deduplicate true duplicates (same role, content, exact timestamp)
# without collapsing legitimately-repeated identical turns (#3346).
# Note: rows whose timestamps were mutated by compaction/recovery to
# microsecond-different values will not be folded — only byte-identical
# timestamps are treated as the same message. This is intentional;
# collapsing same-second distinct turns would be worse than retaining
# a compaction-restamped duplicate.
seen = set()
deduped = []
for msg in filtered:
Expand Down Expand Up @@ -5171,8 +5217,14 @@ def merge_session_messages_append_only(
# advanced), allow state rows newer than the sidecar tail to merge.
sidecar_advanced_past_watermark = (
watermark_timestamp is not None
and max_sidecar_timestamp is not None
and max_sidecar_timestamp > watermark_timestamp
and (
(max_sidecar_timestamp is not None
and max_sidecar_timestamp > watermark_timestamp)
# If the sidecar is empty but watermark > 0, the session has
# advanced (a new user turn was committed). Treat this as
# advanced so post-edit state.db rows are not dropped.
or (not sidecar_messages and watermark_timestamp > 0)
)
)
if (
watermark_timestamp is not None
Expand All @@ -5199,6 +5251,25 @@ def merge_session_messages_append_only(
and _session_message_content_key(msg) not in seen_content_keys
):
continue
# Same-second edit: if timestamp equals the watermark and the message
# content is not in the sidecar, it's a replaced message edited at the
# same second — skip it. The edited version (same timestamp, different
# content) is in the sidecar and survives this check.
#
# Only apply the same-second guard to user messages. An assistant reply
# (or tool message) at the same second as the watermark is a legitimate
# post-edit recovery row — the sidecar holds only the edited user
# checkpoint, so the assistant reply's content won't be in it and would
# be silently dropped without this role guard.
if (
watermark_timestamp is not None
and timestamp is not None
and timestamp == watermark_timestamp
and key not in seen_message_keys
and _session_message_content_key(msg) not in seen_content_keys
and str(msg.get("role", "")).lower() == "user"
):
continue
# Check for true duplicates using full-precision timestamp (#3346).
# Must run before the merge-key guards so that legitimately distinct
# sub-second messages with the same second-level merge key are not
Expand Down Expand Up @@ -5251,27 +5322,41 @@ def merge_session_messages_append_only(
and timestamp is not None
and timestamp <= max_sidecar_timestamp
):
# Legacy key within sidecar timestamp range. Normally skip — the
# sidecar already has this message. Exception: if the state.db
# message has tool_calls that DIFFER from the sidecar version
# (same content_key but different dedup_key because tool_calls
# differ), preserve it — distinct tool_calls must not be collapsed.
_tc = msg.get("tool_calls")
if _tc:
_ck = _session_message_content_key(msg)
if _ck in seen_content_keys and dedup_key not in seen_dedup_keys:
pass # different tool_calls from sidecar — preserve
else:
continue
# When a truncation watermark is active and the sidecar holds only
# the edited user checkpoint, state.db may contain an assistant/tool
# reply at the same timestamp that is NOT in the sidecar. This
# block would normally skip it ("sidecar already has this message"),
# but the sidecar doesn't — it's a genuine state-only recovery row.
# Let it through.
if (
watermark_timestamp is not None
and timestamp == watermark_timestamp
and str(msg.get("role", "")).lower() != "user"
and _session_message_content_key(msg) not in seen_content_keys
):
pass # fall through to append below
else:
if msg.get("role") == "user" and _session_message_content_key(msg) not in seen_content_keys:
if _insert_state_message_chronologically(merged_messages, msg):
seen_message_keys.add(key)
seen_dedup_keys.add(dedup_key)
seen_content_keys.add(_session_message_content_key(msg))
seen_visible_keys.add(visible_key)
# Legacy key within sidecar timestamp range. Normally skip — the
# sidecar already has this message. Exception: if the state.db
# message has tool_calls that DIFFER from the sidecar version
# (same content_key but different dedup_key because tool_calls
# differ), preserve it — distinct tool_calls must not be collapsed.
_tc = msg.get("tool_calls")
if _tc:
_ck = _session_message_content_key(msg)
if _ck in seen_content_keys and dedup_key not in seen_dedup_keys:
pass # different tool_calls from sidecar — preserve
else:
continue
else:
if msg.get("role") == "user" and _session_message_content_key(msg) not in seen_content_keys:
if _insert_state_message_chronologically(merged_messages, msg):
seen_message_keys.add(key)
seen_dedup_keys.add(dedup_key)
seen_content_keys.add(_session_message_content_key(msg))
seen_visible_keys.add(visible_key)
continue
continue
continue
seen_message_keys.add(key)
seen_dedup_keys.add(dedup_key)
seen_content_keys.add(_session_message_content_key(msg))
Expand Down Expand Up @@ -5321,6 +5406,7 @@ def reconciled_state_db_messages_for_session(
local_messages,
state_messages,
truncation_watermark=getattr(session, "truncation_watermark", None),
truncation_boundary=getattr(session, "truncation_boundary", None),
)


Expand Down
22 changes: 14 additions & 8 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5998,6 +5998,7 @@ def _limited_webui_messages_for_display(session, state_db_messages) -> list:
sidecar_messages,
state_db_messages,
truncation_watermark=getattr(session, "truncation_watermark", None),
truncation_boundary=getattr(session, "truncation_boundary", None),
)


Expand Down Expand Up @@ -6056,6 +6057,7 @@ def _webui_sidecar_lineage_messages_for_display(session, *, max_hops: int = 20)
merged,
getattr(segment, "messages", []) or [],
truncation_watermark=getattr(segment, "truncation_watermark", None),
truncation_boundary=getattr(segment, "truncation_boundary", None),
)
return merge_session_messages_append_only(
merged,
Expand Down Expand Up @@ -10803,6 +10805,7 @@ def handle_post(handler, parsed) -> bool:
context_length=getattr(session, "context_length", None),
threshold_tokens=getattr(session, "threshold_tokens", None),
truncation_watermark=getattr(session, "truncation_watermark", None),
truncation_boundary=getattr(session, "truncation_boundary", None),
# context_messages is the authoritative model-facing prefix — must be
# deepcopied so the duplicate has its own independent context that won't
# be mutated when the original session's context changes (#2914).
Expand Down Expand Up @@ -11368,8 +11371,11 @@ def handle_post(handler, parsed) -> bool:
try:
from api.session_ops import _truncation_watermark_for
s.truncation_watermark = _truncation_watermark_for(s.messages)
# Persist the original truncate cutoff.
s.truncation_boundary = s.truncation_watermark
except Exception:
s.truncation_watermark = 0.0
s.truncation_boundary = 0.0
s.save()
logger.info(
"truncate %s: messages %d→%d, context_messages %d→%d, watermark=%.2f",
Expand Down Expand Up @@ -16146,15 +16152,15 @@ def _checkpoint_user_message_for_eager_session_save(s, msg: str, attachments, st
if attachments:
user_msg["attachments"] = list(attachments)
s.messages.append(user_msg)
# The new user turn is now committed to messages (#3831): a positive
# truncation watermark from a prior retry/undo/edit has been superseded and
# must retire, else it freezes at the old edit boundary and later drops these
# post-edit turns on an empty-sidecar reconcile. Safe here (not at chat-start)
# because the row is durably in messages, so the merge's max-sidecar guard now
# suppresses the replaced tail without the watermark. Cleared to None — never
# 0.0, which is the truncate-to-empty sentinel (#2914).
# The new user turn is now committed to messages (#3831): advance the
# truncation watermark to the new message's timestamp so that
# merge_session_messages_append_only() still filters out replaced
# pre-edit rows from state.db whose timestamps fall below the boundary.
# The merge's sidecar_advanced_past_watermark guard (models.py:5172)
# allows state.db rows newer than the watermark, so post-edit turns
# are not dropped. Never 0.0 (the truncate-to-empty sentinel, #2914).
if getattr(s, "truncation_watermark", None):
s.truncation_watermark = None
s.truncation_watermark = user_msg.get("timestamp") or time.time()


def _is_default_or_empty_session_title(title) -> bool:
Expand Down
5 changes: 5 additions & 0 deletions api/session_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ def retry_last(session_id: str) -> dict[str, Any]:
removed_count = len(history) - last_user_idx
s.messages = history[:last_user_idx]
s.truncation_watermark = _truncation_watermark_for(s.messages)
# Persist the original truncate cutoff so empty-sidecar recovery
# can distinguish legitimate prefix from deleted suffix.
s.truncation_boundary = s.truncation_watermark
if isinstance(getattr(s, 'context_messages', None), list) and s.context_messages:
truncated_context = _truncate_at_last_user(s.context_messages)
if truncated_context is not None:
Expand Down Expand Up @@ -156,6 +159,8 @@ def undo_last(session_id: str) -> dict[str, Any]:
removed_count = len(history) - last_user_idx
s.messages = history[:last_user_idx]
s.truncation_watermark = _truncation_watermark_for(s.messages)
# Persist the original truncate cutoff.
s.truncation_boundary = s.truncation_watermark
if isinstance(getattr(s, 'context_messages', None), list) and s.context_messages:
truncated_context = _truncate_at_last_user(s.context_messages)
if truncated_context is not None:
Expand Down
Loading