mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 03:00:23 +00:00
The PR title and body correctly say 'Closes #1558' but every code comment, the test file name, error-message strings, docstrings, and the original commit body referenced #1557 instead. Independent reviewer flagged this: > The 17 wrong references won't auto-close issue #1558 from the commit > message — and the test file name will be misleading for future archeology. > Worth a one-pass s/#1557/#1558/g (and rename test file → > test_metadata_save_wipe_1558.py) before merge so the artifacts agree > with reality. This commit: - Renames tests/test_metadata_save_wipe_1557.py → test_metadata_save_wipe_1558.py - Replaces 17 #1557 references with #1558 across: - tests/test_metadata_save_wipe_1558.py (7 refs) - api/models.py (5 refs in Session.save guard + backup safeguard comments) - api/routes.py (2 refs in _clear_stale_stream_state docstring + log) - api/session_recovery.py (3 refs) - server.py (3 refs in startup self-heal block) Verified: 6/6 tests in tests/test_metadata_save_wipe_1558.py pass with the renamed file + updated references.
This commit is contained in:
+5
-5
@@ -366,7 +366,7 @@ class Session:
|
||||
return SESSION_DIR / f'{self.session_id}.json'
|
||||
|
||||
def save(self, touch_updated_at: bool = True, skip_index: bool = False) -> None:
|
||||
# ── #1557 P0 guard ──────────────────────────────────────────────
|
||||
# ── #1558 P0 guard ──────────────────────────────────────────────
|
||||
# Refuse to save a session that was loaded with metadata_only=True.
|
||||
# Such sessions have messages=[] (it's the whole point of the partial
|
||||
# load), and save() unconditionally writes self.messages to disk via
|
||||
@@ -381,7 +381,7 @@ class Session:
|
||||
f"Refusing to save metadata-only session {self.session_id!r}: "
|
||||
f"would atomically overwrite on-disk messages with []. "
|
||||
f"Reload with metadata_only=False before mutating state. "
|
||||
f"See #1557."
|
||||
f"See #1558."
|
||||
)
|
||||
if touch_updated_at:
|
||||
self.updated_at = time.time()
|
||||
@@ -409,14 +409,14 @@ class Session:
|
||||
and not k.startswith('_')}
|
||||
payload = json.dumps({**meta, **extra}, ensure_ascii=False, indent=2)
|
||||
|
||||
# ── #1557 backup safeguard ──────────────────────────────────────
|
||||
# ── #1558 backup safeguard ──────────────────────────────────────
|
||||
# Before overwriting the session file, copy the previous version to
|
||||
# ``<sid>.json.bak`` IFF the previous file has more messages than the
|
||||
# incoming payload. The asymmetric guard means:
|
||||
# * Normal grow-the-conversation saves never produce a backup
|
||||
# (incoming messages >= existing) — keeps disk overhead near zero.
|
||||
# * Any save that would shrink the messages array (the failure mode
|
||||
# of #1557, plus anything similar in the future) leaves a recoverable
|
||||
# of #1558, plus anything similar in the future) leaves a recoverable
|
||||
# snapshot of the pre-shrink state on disk.
|
||||
# The recovery path is api/session_recovery.py — at server startup and
|
||||
# via /api/session/recover, sessions whose JSON has fewer messages than
|
||||
@@ -497,7 +497,7 @@ class Session:
|
||||
# on-disk JSON with messages=[], wiping the conversation. Any
|
||||
# caller that needs to mutate persisted state on a metadata-only
|
||||
# session must reload it with metadata_only=False first.
|
||||
# See #1557 — v0.50.279 _clear_stale_stream_state() data-loss bug.
|
||||
# See #1558 — v0.50.279 _clear_stale_stream_state() data-loss bug.
|
||||
session._loaded_metadata_only = True
|
||||
return session
|
||||
except Exception:
|
||||
|
||||
+2
-2
@@ -328,7 +328,7 @@ def _clear_stale_stream_state(session) -> bool:
|
||||
session JSON while STREAMS is empty. The frontend then keeps reconnecting to
|
||||
a dead stream and shows a permanent running/thinking state.
|
||||
|
||||
SAFETY (#1557): If ``session`` was loaded with ``metadata_only=True``, its
|
||||
SAFETY (#1558): If ``session`` was loaded with ``metadata_only=True``, its
|
||||
``messages`` array is empty by design and calling ``save()`` would
|
||||
atomically overwrite the on-disk JSON, wiping the conversation. In that
|
||||
case we re-load the full session before mutating, so the persisted
|
||||
@@ -356,7 +356,7 @@ def _clear_stale_stream_state(session) -> bool:
|
||||
logger.warning(
|
||||
"_clear_stale_stream_state: refused to clear stale stream %s "
|
||||
"for session %s — full reload failed and we will not save a "
|
||||
"metadata-only stub. See #1557.",
|
||||
"metadata-only stub. See #1558.",
|
||||
stream_id, getattr(session, "session_id", "?"),
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Session recovery from .bak snapshots — last line of defense against
|
||||
data-loss bugs like #1557.
|
||||
data-loss bugs like #1558.
|
||||
|
||||
``Session.save()`` writes a ``<sid>.json.bak`` snapshot of the previous
|
||||
state whenever an incoming save would shrink the messages array. This
|
||||
@@ -100,7 +100,7 @@ def recover_session(session_path: Path) -> dict:
|
||||
return {**status, "restored": False, "error": str(exc)}
|
||||
logger.warning(
|
||||
"recover_session: restored %s from .bak (live=%d → bak=%d messages). "
|
||||
"See #1557 for the data-loss class this guards against.",
|
||||
"See #1558 for the data-loss class this guards against.",
|
||||
session_path.name, status["live_messages"], status["bak_messages"],
|
||||
)
|
||||
return {**status, "restored": True}
|
||||
@@ -126,6 +126,6 @@ def recover_all_sessions_on_startup(session_dir: Path) -> dict:
|
||||
logger.warning(
|
||||
"recover_all_sessions_on_startup: restored %d/%d sessions from .bak. "
|
||||
"If you weren't expecting this, check the session list for missing "
|
||||
"messages — see #1557.", restored, scanned,
|
||||
"messages — see #1558.", restored, scanned,
|
||||
)
|
||||
return {"scanned": scanned, "restored": restored, "details": details}
|
||||
|
||||
@@ -110,15 +110,15 @@ def main() -> None:
|
||||
# Fix sensitive file permissions before doing anything else
|
||||
fix_credential_permissions()
|
||||
|
||||
# ── #1557 startup self-heal ─────────────────────────────────────────
|
||||
# ── #1558 startup self-heal ─────────────────────────────────────────
|
||||
# If a previous process wrote a session JSON with fewer messages than
|
||||
# its .bak (the data-loss shape #1557 produced), restore from the .bak.
|
||||
# its .bak (the data-loss shape #1558 produced), restore from the .bak.
|
||||
# Safe to run unconditionally — a clean install is a no-op.
|
||||
try:
|
||||
from api.session_recovery import recover_all_sessions_on_startup
|
||||
result = recover_all_sessions_on_startup(SESSION_DIR)
|
||||
if result.get("restored"):
|
||||
print(f"[recovery] Restored {result['restored']}/{result['scanned']} sessions from .bak (see #1557).", flush=True)
|
||||
print(f"[recovery] Restored {result['restored']}/{result['scanned']} sessions from .bak (see #1558).", flush=True)
|
||||
except Exception as exc:
|
||||
# Recovery is best-effort; never block server startup.
|
||||
print(f"[recovery] startup recovery failed: {exc}", flush=True)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
P0 regression test for the metadata-only save-wipe (#1557).
|
||||
P0 regression test for the metadata-only save-wipe (#1558).
|
||||
|
||||
Before this fix, `_clear_stale_stream_state()` could be called on a session
|
||||
loaded with `metadata_only=True` (which means messages=[]). That handler called
|
||||
@@ -63,7 +63,7 @@ def _make_session_on_disk(session_dir, sid="s_test_1557", n_msgs=1000, with_acti
|
||||
|
||||
|
||||
def test_metadata_only_save_raises_to_prevent_wipe(temp_session_dir):
|
||||
"""Direct test of the #1557 guard: save() must refuse to wipe on-disk messages."""
|
||||
"""Direct test of the #1558 guard: save() must refuse to wipe on-disk messages."""
|
||||
from api.models import get_session
|
||||
sid = _make_session_on_disk(temp_session_dir, n_msgs=1000)
|
||||
|
||||
@@ -76,7 +76,7 @@ def test_metadata_only_save_raises_to_prevent_wipe(temp_session_dir):
|
||||
assert len(s.messages) == 0, "metadata-only load synthesizes empty messages — that's its job"
|
||||
assert getattr(s, "_loaded_metadata_only", False) is True, (
|
||||
"load_metadata_only() must set the _loaded_metadata_only flag so save() "
|
||||
"knows to refuse this save and prevent #1557 data-loss."
|
||||
"knows to refuse this save and prevent #1558 data-loss."
|
||||
)
|
||||
|
||||
# Mutate as the buggy code path did, then attempt to save.
|
||||
@@ -94,7 +94,7 @@ def test_metadata_only_save_raises_to_prevent_wipe(temp_session_dir):
|
||||
|
||||
|
||||
def test_clear_stale_stream_state_preserves_messages(temp_session_dir):
|
||||
"""High-level: the production trigger from #1557 must NOT wipe messages."""
|
||||
"""High-level: the production trigger from #1558 must NOT wipe messages."""
|
||||
from api.models import get_session
|
||||
sid = _make_session_on_disk(temp_session_dir, n_msgs=1000, with_active_stream=True)
|
||||
|
||||
@@ -120,7 +120,7 @@ def test_clear_stale_stream_state_preserves_messages(temp_session_dir):
|
||||
raw = json.loads((temp_session_dir / f"{sid}.json").read_text(encoding="utf-8"))
|
||||
assert len(raw["messages"]) >= 1000, (
|
||||
f"_clear_stale_stream_state() shrank messages to {len(raw['messages'])} — "
|
||||
"see #1557. It must clear the stream flags WITHOUT losing existing messages."
|
||||
"see #1558. It must clear the stream flags WITHOUT losing existing messages."
|
||||
)
|
||||
# And the stream flag must actually be cleared (whether by _repair_stale_pending
|
||||
# during the reload or by the explicit clear afterwards).
|
||||
@@ -136,7 +136,7 @@ def test_save_writes_bak_when_messages_shrink(temp_session_dir):
|
||||
sid = _make_session_on_disk(temp_session_dir, n_msgs=1000, with_active_stream=False)
|
||||
|
||||
# Build a fresh in-memory Session with a smaller messages array, then save —
|
||||
# this models the precise failure shape of #1557 (a caller mutates messages
|
||||
# this models the precise failure shape of #1558 (a caller mutates messages
|
||||
# downward and saves). We construct the Session directly rather than going
|
||||
# through get_session() so we don't trigger _repair_stale_pending side-effects.
|
||||
s = Session(
|
||||
@@ -150,7 +150,7 @@ def test_save_writes_bak_when_messages_shrink(temp_session_dir):
|
||||
|
||||
bak_path = temp_session_dir / f"{sid}.json.bak"
|
||||
assert bak_path.exists(), (
|
||||
"save() that shrinks messages must leave a .bak — #1557 backup safeguard."
|
||||
"save() that shrinks messages must leave a .bak — #1558 backup safeguard."
|
||||
)
|
||||
bak_data = json.loads(bak_path.read_text(encoding="utf-8"))
|
||||
assert len(bak_data["messages"]) == 1000, (
|
||||
Reference in New Issue
Block a user