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
3 changes: 3 additions & 0 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,7 @@ def __init__(self, session_id: str=None, title: str='Untitled',
last_prompt_tokens=None,
truncation_watermark=None,
truncation_boundary=None,
clear_generation=None,
gateway_routing=None, gateway_routing_history=None,
llm_title_generated: bool=False,
manual_title: bool=False,
Expand Down Expand Up @@ -1104,6 +1105,7 @@ def __init__(self, session_id: str=None, title: str='Untitled',
self.last_prompt_tokens = last_prompt_tokens
self.truncation_watermark = truncation_watermark
self.truncation_boundary = truncation_boundary
self.clear_generation = clear_generation
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 @@ -1174,6 +1176,7 @@ def save(self, touch_updated_at: bool = True, skip_index: bool = False) -> None:
'context_length', 'threshold_tokens', 'last_prompt_tokens',
'truncation_watermark',
'truncation_boundary',
'clear_generation',
'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
2 changes: 2 additions & 0 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -13764,6 +13764,7 @@ def _commit_prev_session_memory(_sid=prev_session_id):
s.pending_attachments = []
s.pending_started_at = None
s.pending_user_source = None
s.clear_generation = uuid.uuid4().hex if had_sidecar_messages else None
# Reset the title via the rename helper so clearing a manually-named
# session also clears manual_title/llm_title_generated — otherwise the
# reused session keeps its manual-title protection and never auto-names
Expand All @@ -13784,6 +13785,7 @@ def _commit_prev_session_memory(_sid=prev_session_id):
and persisted.get("pending_attachments") == []
and persisted.get("pending_started_at") is None
and persisted.get("pending_user_source") is None
and persisted.get("clear_generation") == s.clear_generation
)
except (OSError, json.JSONDecodeError, ValueError):
logger.warning("session clear could not verify persisted empty state for %s", sid, exc_info=True)
Expand Down
44 changes: 44 additions & 0 deletions api/session_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,42 @@ def _backup_predates_intentional_shrink(session_path: Path, bak_path: Path) -> b
return bak_ctx_len > live_ctx_len


def _session_records_clear_sentinel(session_path: Path, bak_path: Path) -> bool:
"""Return True when the live sidecar records a provenanced clear sentinel.

The live sidecar must carry the explicit /api/session/clear marker, and
the backup must not carry the same marker. Same-generation backups stay
recoverable; unreadable or partial matches fail open.
"""
try:
data = json.loads(session_path.read_text(encoding='utf-8'))
bak = json.loads(bak_path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError, ValueError):
return False
if not isinstance(data, dict) or not isinstance(bak, dict):
return False
clear_generation = data.get('clear_generation')
if not isinstance(clear_generation, str) or not clear_generation:
return False
if bak.get('clear_generation') == clear_generation:
return False
expected = {
'messages': [],
'context_messages': [],
'truncation_watermark': 0.0,
'truncation_boundary': 0.0,
'active_stream_id': None,
'pending_user_message': None,
'pending_attachments': [],
'pending_started_at': None,
'pending_user_source': None,
}
for key, value in expected.items():
if key not in data or data.get(key) != value:
return False
return True


def inspect_session_recovery_status(session_path: Path) -> dict:
"""Return a status dict describing whether recovery is recommended.

Expand All @@ -232,6 +268,14 @@ def inspect_session_recovery_status(session_path: Path) -> dict:
}
bak_count = _msg_count(bak_path)
if bak_count > live_count:
if _session_records_clear_sentinel(session_path, bak_path):
return {
"session_id": session_path.stem,
"live_messages": live_count,
"bak_messages": bak_count,
"recommend": "no_action",
"intentional_clear_truncate": True,
}
if (
_session_records_intentional_compress_shrink(session_path)
and _backup_predates_intentional_shrink(session_path, bak_path)
Expand Down
9 changes: 9 additions & 0 deletions tests/test_issue5532_session_clear_state_db_replay.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ def test_session_clear_persists_empty_context_and_blocks_state_db_replay(monkeyp
assert loaded.pending_attachments == []
assert loaded.pending_started_at is None
assert loaded.pending_user_source is None
assert loaded.clear_generation
assert loaded.title == "Untitled"

persisted = json.loads(loaded.path.read_text(encoding="utf-8"))
Expand All @@ -135,6 +136,7 @@ def test_session_clear_persists_empty_context_and_blocks_state_db_replay(monkeyp
assert persisted["pending_attachments"] == []
assert persisted["pending_started_at"] is None
assert persisted["pending_user_source"] is None
assert persisted["clear_generation"] == loaded.clear_generation

state_db_messages = [
_msg("user", "state prompt", 100.0, "s-u1"),
Expand Down Expand Up @@ -192,6 +194,12 @@ def test_clear_sentinel_does_not_suppress_later_backup_recovery(tmp_path):
"context_messages": [],
"truncation_watermark": 0.0,
"truncation_boundary": 0.0,
"active_stream_id": None,
"pending_user_message": None,
"pending_attachments": [],
"pending_started_at": None,
"pending_user_source": None,
"clear_generation": "clear-post",
}
backup = {
**live,
Expand Down Expand Up @@ -241,6 +249,7 @@ def test_clearing_empty_live_session_preserves_existing_recoverable_backup(monke

assert captured["status"] == 200
assert session.path.with_suffix(".json.bak").exists()
assert Session.load(sid).clear_generation is None
assert inspect_session_recovery_status(session.path)["recommend"] == "restore"
recovered = recover_session(session.path)
assert recovered["restored"] is True
Expand Down
Loading
Loading