fix(#5570): also suppress restore when post-clear messages exist (gate fix)
Codex gate CORE: the exact-empty sentinel only suppressed restore for a bit-empty cleared sidecar. If the user cleared, then sent ONE post-clear message, and the stale pre-clear .json.bak still existed (unlink failed), recovery saw bak_count>live_count and RESTORED the pre-clear transcript on top of the new message. Added _live_supersedes_backup_by_clear_generation: suppress restore when the live sidecar carries a clear_generation the backup lacks AND has a non-empty transcript AND still shows the clear boundary reset (watermark==boundary==0.0). Scoped to non-empty live so empty clear-shaped sidecars keep their existing recovery semantics (active/pending/partial variants still restore). Fails open on unreadable/partial reads. +2 regression tests (post-clear-message not restored; post-clear-after-real-compaction still restores).
This commit is contained in:
1 parent
8a6937978c
commit
bb818a56ba
2 files changed
+93
-1
No files matched your search
+40
-1
@@ -247,6 +247,42 @@ def _session_records_clear_sentinel(session_path: Path, bak_path: Path) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _live_supersedes_backup_by_clear_generation(session_path: Path, bak_path: Path) -> bool:
|
||||||
|
"""Return True when the live sidecar provably post-dates the backup via a
|
||||||
|
clear sentinel even though the user has since sent NEW messages.
|
||||||
|
|
||||||
|
Scope: this handles ONLY the post-clear-message case that the exact-empty
|
||||||
|
sentinel (_session_records_clear_sentinel) can't. After /api/session/clear
|
||||||
|
stamps a unique ``clear_generation`` and resets the truncation boundary to
|
||||||
|
0.0, a pre-clear ``.json.bak`` is stale — restoring it would resurrect
|
||||||
|
cleared history on top of the post-clear message. We require: live carries a
|
||||||
|
``clear_generation`` the backup lacks, live has a NON-EMPTY transcript, and
|
||||||
|
the live boundary still shows the clear reset (watermark == boundary == 0.0).
|
||||||
|
Empty clear-shaped sidecars stay governed by the exact-empty sentinel and its
|
||||||
|
existing recovery semantics. Same-generation backups stay recoverable;
|
||||||
|
unreadable/partial reads fail open (return False -> normal recovery), so a
|
||||||
|
genuine crash-loss is never suppressed.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
live_messages = data.get('messages')
|
||||||
|
if not isinstance(live_messages, list) or len(live_messages) == 0:
|
||||||
|
return False
|
||||||
|
if data.get('truncation_watermark') != 0.0 or data.get('truncation_boundary') != 0.0:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def inspect_session_recovery_status(session_path: Path) -> dict:
|
def inspect_session_recovery_status(session_path: Path) -> dict:
|
||||||
"""Return a status dict describing whether recovery is recommended.
|
"""Return a status dict describing whether recovery is recommended.
|
||||||
|
|
||||||
@@ -268,7 +304,10 @@ def inspect_session_recovery_status(session_path: Path) -> dict:
|
|||||||
}
|
}
|
||||||
bak_count = _msg_count(bak_path)
|
bak_count = _msg_count(bak_path)
|
||||||
if bak_count > live_count:
|
if bak_count > live_count:
|
||||||
if _session_records_clear_sentinel(session_path, bak_path):
|
if (
|
||||||
|
_session_records_clear_sentinel(session_path, bak_path)
|
||||||
|
or _live_supersedes_backup_by_clear_generation(session_path, bak_path)
|
||||||
|
):
|
||||||
return {
|
return {
|
||||||
"session_id": session_path.stem,
|
"session_id": session_path.stem,
|
||||||
"live_messages": live_count,
|
"live_messages": live_count,
|
||||||
|
|||||||
@@ -363,3 +363,56 @@ def test_manual_compression_recovery_behavior_is_preserved(tmp_path):
|
|||||||
|
|
||||||
assert post_status["recommend"] == "restore"
|
assert post_status["recommend"] == "restore"
|
||||||
assert post_result["restored"] is True
|
assert post_result["restored"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_clear_message_with_stale_pre_clear_backup_is_not_restored(tmp_path):
|
||||||
|
"""#5584 gate (Codex CORE): after a clear, the user sends one post-clear
|
||||||
|
message and the stale pre-clear .json.bak still exists (unlink failed). The
|
||||||
|
backup has MORE messages than the live sidecar, but the live sidecar carries
|
||||||
|
a clear_generation the backup lacks + the clear boundary reset (watermarks
|
||||||
|
0.0) — so recovery must NOT resurrect the pre-clear transcript on top of the
|
||||||
|
new message."""
|
||||||
|
sid = "s-postclear"
|
||||||
|
live_path = tmp_path / f"{sid}.json"
|
||||||
|
bak_path = tmp_path / f"{sid}.json.bak"
|
||||||
|
# Live: cleared sentinel shape + one post-clear message (boundary still 0.0,
|
||||||
|
# clear_generation intact — the clear handler's reset).
|
||||||
|
live = _clear_sentinel(sid)
|
||||||
|
live["messages"] = [_msg("user", "brand new post-clear question", 20.0, "u20")]
|
||||||
|
live["context_messages"] = [_msg("user", "brand new post-clear question", 20.0, "cu20")]
|
||||||
|
_write_json(live_path, live)
|
||||||
|
# Stale pre-clear backup: 2 messages, no clear_generation (predates the clear).
|
||||||
|
_write_json(bak_path, _stale_pre_clear_backup(sid))
|
||||||
|
|
||||||
|
status = inspect_session_recovery_status(live_path)
|
||||||
|
result = recover_session(live_path)
|
||||||
|
|
||||||
|
assert status["bak_messages"] > status["live_messages"] # backup is "larger"
|
||||||
|
assert status["recommend"] == "no_action"
|
||||||
|
assert status.get("intentional_clear_truncate") is True
|
||||||
|
assert result["restored"] is False
|
||||||
|
# Live sidecar must still be the post-clear content, not the resurrected pre-clear.
|
||||||
|
reread = json.loads(live_path.read_text(encoding="utf-8"))
|
||||||
|
assert [m["content"] for m in reread["messages"]] == ["brand new post-clear question"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_clear_message_after_real_compaction_still_recovers(tmp_path):
|
||||||
|
"""Guard the guard: if the live sidecar's boundary has moved OFF the clear
|
||||||
|
reset (a real compaction happened after the clear), the clear-generation
|
||||||
|
supersede check must decline so a genuine larger backup can still restore."""
|
||||||
|
sid = "s-postclear-compact"
|
||||||
|
live_path = tmp_path / f"{sid}.json"
|
||||||
|
bak_path = tmp_path / f"{sid}.json.bak"
|
||||||
|
live = _clear_sentinel(sid)
|
||||||
|
live["messages"] = [_msg("user", "q", 20.0, "u20")]
|
||||||
|
live["context_messages"] = [_msg("user", "q", 20.0, "cu20")]
|
||||||
|
# A later compaction moved the boundary off 0.0 — no longer the clear reset.
|
||||||
|
live["truncation_watermark"] = 30.0
|
||||||
|
live["truncation_boundary"] = 30.0
|
||||||
|
_write_json(live_path, live)
|
||||||
|
_write_json(bak_path, _stale_pre_clear_backup(sid))
|
||||||
|
|
||||||
|
status = inspect_session_recovery_status(live_path)
|
||||||
|
# Boundary moved off the clear reset -> supersede check declines -> normal
|
||||||
|
# recovery decides (larger backup, no compress-shrink provenance -> restore).
|
||||||
|
assert status["recommend"] == "restore"
|
||||||
Reference in new issue
Block a user