HERMES-GIELIE/api/session_recovery.py

159 lines
5.8 KiB
Python
Raw Normal View History

fix(P0 #1557): metadata-only Session.save() was wiping conversation history v0.50.279 introduced api.routes._clear_stale_stream_state() (#1525) which calls session.save() to clear stale active_stream_id/pending_* fields. The helper is called from /api/session and /api/session/status — both of which load the session with metadata_only=True. Session.load_metadata_only() synthesizes a stub with messages=[] (its whole purpose: fast metadata read without parsing the 400KB+ messages array). Session.save() unconditionally writes self.messages to disk via os.replace(), so saving a metadata-only stub atomically overwrites the on-disk JSON with messages=[], wiping the entire conversation. Production trigger: every SSE reconnect cycle after a server restart polls /api/session/status, which fans out to _clear_stale_stream_state, which saves the metadata-only stub. The user reported losing 1000+ message conversations and seeing 'Reconnecting…' loops on every prompt — the reconnect loop kept the cycle running until the conversation was empty. Fix: three layers, defense in depth. (1) api/models.py: load_metadata_only() now sets _loaded_metadata_only=True on the returned stub. Session.save() raises RuntimeError if that flag is set — a hard guard so any future caller making the same mistake cannot wipe data, only crash visibly. (2) api/routes.py: _clear_stale_stream_state() now detects the metadata-only flag and re-loads the full session with metadata_only=False before mutating persisted state. The full-load path also runs _repair_stale_pending() which independently clears the stream flags, so the explicit clear becomes a no-op in most cases — but messages stay intact. (3) api/models.py + api/session_recovery.py: every save() that would SHRINK the messages array (the precise failure shape of #1557) first snapshots the previous file to <sid>.json.bak. Server.py runs recover_all_sessions_on_startup() at boot — any session whose live JSON has fewer messages than its .bak is restored automatically. Idempotent on clean state. Backup overhead is zero on the normal grow-the-conversation path. Reproducer (master): test_metadata_only_save_does_not_wipe_messages goes from 1000 messages to 0 in a single save() call. After the fix, 1000 messages survive. Tests: 6 new regression tests in tests/test_metadata_save_wipe_1557.py covering all three layers. Full pytest: 4019 → 4025 (+6, all green). Live verified on port 8789: write 1000-msg session with stale active_stream_id, hit /api/session/status, /api/session — file ends with 1002 messages (_repair_stale_pending injects an error-marker pair on full reload, harmless existing behavior), active_stream_id cleared, pending cleared, no Reconnecting loop. Closes #1557. Reported by AvidFuturist via user feedback on v0.50.282.
2026-05-03 19:45:10 +00:00
"""
Session recovery from .bak snapshots — last line of defense against
data-loss bugs like #1558.
fix(P0 #1557): metadata-only Session.save() was wiping conversation history v0.50.279 introduced api.routes._clear_stale_stream_state() (#1525) which calls session.save() to clear stale active_stream_id/pending_* fields. The helper is called from /api/session and /api/session/status — both of which load the session with metadata_only=True. Session.load_metadata_only() synthesizes a stub with messages=[] (its whole purpose: fast metadata read without parsing the 400KB+ messages array). Session.save() unconditionally writes self.messages to disk via os.replace(), so saving a metadata-only stub atomically overwrites the on-disk JSON with messages=[], wiping the entire conversation. Production trigger: every SSE reconnect cycle after a server restart polls /api/session/status, which fans out to _clear_stale_stream_state, which saves the metadata-only stub. The user reported losing 1000+ message conversations and seeing 'Reconnecting…' loops on every prompt — the reconnect loop kept the cycle running until the conversation was empty. Fix: three layers, defense in depth. (1) api/models.py: load_metadata_only() now sets _loaded_metadata_only=True on the returned stub. Session.save() raises RuntimeError if that flag is set — a hard guard so any future caller making the same mistake cannot wipe data, only crash visibly. (2) api/routes.py: _clear_stale_stream_state() now detects the metadata-only flag and re-loads the full session with metadata_only=False before mutating persisted state. The full-load path also runs _repair_stale_pending() which independently clears the stream flags, so the explicit clear becomes a no-op in most cases — but messages stay intact. (3) api/models.py + api/session_recovery.py: every save() that would SHRINK the messages array (the precise failure shape of #1557) first snapshots the previous file to <sid>.json.bak. Server.py runs recover_all_sessions_on_startup() at boot — any session whose live JSON has fewer messages than its .bak is restored automatically. Idempotent on clean state. Backup overhead is zero on the normal grow-the-conversation path. Reproducer (master): test_metadata_only_save_does_not_wipe_messages goes from 1000 messages to 0 in a single save() call. After the fix, 1000 messages survive. Tests: 6 new regression tests in tests/test_metadata_save_wipe_1557.py covering all three layers. Full pytest: 4019 → 4025 (+6, all green). Live verified on port 8789: write 1000-msg session with stale active_stream_id, hit /api/session/status, /api/session — file ends with 1002 messages (_repair_stale_pending injects an error-marker pair on full reload, harmless existing behavior), active_stream_id cleared, pending cleared, no Reconnecting loop. Closes #1557. Reported by AvidFuturist via user feedback on v0.50.282.
2026-05-03 19:45:10 +00:00
``Session.save()`` writes a ``<sid>.json.bak`` snapshot of the previous
state whenever an incoming save would shrink the messages array. This
module reads those snapshots back and restores any session whose live
file has fewer messages than its backup.
Three integration points:
1. ``recover_all_sessions_on_startup()`` — called from server.py at boot,
scans the session dir, restores any session whose JSON has fewer
messages than its .bak. Idempotent: a clean run is a no-op.
2. ``recover_session(sid)`` — single-session helper backing the
``POST /api/session/recover`` endpoint, so users can re-run recovery
manually if their session was open through a server restart.
3. ``inspect_session_recovery_status(sid)`` — read-only audit returning
message counts for the live JSON, the .bak, and a recommendation.
"""
from __future__ import annotations
import json
import logging
import shutil
from pathlib import Path
logger = logging.getLogger(__name__)
def _msg_count(p: Path) -> int:
fix(session-recovery): skip _index.json + harden _msg_count against non-dict JSON (v0.50.284 follow-up) v0.50.284 shipped startup self-heal in api/session_recovery.py that crashed on the very first JSON file it scanned in the production session directory. Verified live on the prod server immediately after the v0.50.284 deploy: [recovery] startup recovery failed: 'list' object has no attribute 'get' Root cause: the production session dir contains _index.json — a top-level LIST of session metadata dicts (not a dict). _msg_count() did data.get('messages') which raises AttributeError on a list. The broad except Exception in server.py's startup hook swallowed the error and the recovery silently no-op'd for every user — defeating the entire purpose of the v0.50.284 release. Fix is three small defensive changes: 1. _msg_count() — added isinstance(data, dict) guard. Non-dict-shaped JSON files now return -1 (the harmless 'unknown count' sentinel) instead of raising AttributeError. 2. recover_all_sessions_on_startup() — skips any file whose name starts with '_' (the existing project convention for non-session metadata files like _index.json). These are convention-marked as system files, not session payloads. 3. recover_all_sessions_on_startup() — wraps recover_session(path) in try/except Exception so a single malformed file can't break recovery for the rest. Logs and continues. 2 new regression tests: - test_recover_all_sessions_on_startup_skips_non_session_index_json - test_msg_count_returns_neg1_for_non_dict_top_level 4026 → 4028 tests passing (+2). Net effect: any user wiped between v0.50.279 and v0.50.284 deploys whose session has a .bak shadow will now get auto-recovered on first launch of v0.50.285, as v0.50.284's release notes promised. Closes #1558 (follow-up — the original P0 was closed by v0.50.284 but the recovery half didn't actually run in production).
2026-05-03 20:50:06 +00:00
"""Return the number of messages in a session JSON file, or -1 on read/parse error.
Returns -1 for any non-session-shape file:
- File can't be read (OSError)
- Top-level isn't valid JSON or is invalid (JSONDecodeError, ValueError)
- Top-level isn't a dict (AttributeError on .get) — e.g. ``_index.json``
which is a top-level list of session metadata, not a session itself.
The startup recovery scanner globs ``*.json`` and would otherwise
crash on the first non-dict file it encounters.
"""
fix(P0 #1557): metadata-only Session.save() was wiping conversation history v0.50.279 introduced api.routes._clear_stale_stream_state() (#1525) which calls session.save() to clear stale active_stream_id/pending_* fields. The helper is called from /api/session and /api/session/status — both of which load the session with metadata_only=True. Session.load_metadata_only() synthesizes a stub with messages=[] (its whole purpose: fast metadata read without parsing the 400KB+ messages array). Session.save() unconditionally writes self.messages to disk via os.replace(), so saving a metadata-only stub atomically overwrites the on-disk JSON with messages=[], wiping the entire conversation. Production trigger: every SSE reconnect cycle after a server restart polls /api/session/status, which fans out to _clear_stale_stream_state, which saves the metadata-only stub. The user reported losing 1000+ message conversations and seeing 'Reconnecting…' loops on every prompt — the reconnect loop kept the cycle running until the conversation was empty. Fix: three layers, defense in depth. (1) api/models.py: load_metadata_only() now sets _loaded_metadata_only=True on the returned stub. Session.save() raises RuntimeError if that flag is set — a hard guard so any future caller making the same mistake cannot wipe data, only crash visibly. (2) api/routes.py: _clear_stale_stream_state() now detects the metadata-only flag and re-loads the full session with metadata_only=False before mutating persisted state. The full-load path also runs _repair_stale_pending() which independently clears the stream flags, so the explicit clear becomes a no-op in most cases — but messages stay intact. (3) api/models.py + api/session_recovery.py: every save() that would SHRINK the messages array (the precise failure shape of #1557) first snapshots the previous file to <sid>.json.bak. Server.py runs recover_all_sessions_on_startup() at boot — any session whose live JSON has fewer messages than its .bak is restored automatically. Idempotent on clean state. Backup overhead is zero on the normal grow-the-conversation path. Reproducer (master): test_metadata_only_save_does_not_wipe_messages goes from 1000 messages to 0 in a single save() call. After the fix, 1000 messages survive. Tests: 6 new regression tests in tests/test_metadata_save_wipe_1557.py covering all three layers. Full pytest: 4019 → 4025 (+6, all green). Live verified on port 8789: write 1000-msg session with stale active_stream_id, hit /api/session/status, /api/session — file ends with 1002 messages (_repair_stale_pending injects an error-marker pair on full reload, harmless existing behavior), active_stream_id cleared, pending cleared, no Reconnecting loop. Closes #1557. Reported by AvidFuturist via user feedback on v0.50.282.
2026-05-03 19:45:10 +00:00
try:
data = json.loads(p.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError, ValueError):
return -1
fix(session-recovery): skip _index.json + harden _msg_count against non-dict JSON (v0.50.284 follow-up) v0.50.284 shipped startup self-heal in api/session_recovery.py that crashed on the very first JSON file it scanned in the production session directory. Verified live on the prod server immediately after the v0.50.284 deploy: [recovery] startup recovery failed: 'list' object has no attribute 'get' Root cause: the production session dir contains _index.json — a top-level LIST of session metadata dicts (not a dict). _msg_count() did data.get('messages') which raises AttributeError on a list. The broad except Exception in server.py's startup hook swallowed the error and the recovery silently no-op'd for every user — defeating the entire purpose of the v0.50.284 release. Fix is three small defensive changes: 1. _msg_count() — added isinstance(data, dict) guard. Non-dict-shaped JSON files now return -1 (the harmless 'unknown count' sentinel) instead of raising AttributeError. 2. recover_all_sessions_on_startup() — skips any file whose name starts with '_' (the existing project convention for non-session metadata files like _index.json). These are convention-marked as system files, not session payloads. 3. recover_all_sessions_on_startup() — wraps recover_session(path) in try/except Exception so a single malformed file can't break recovery for the rest. Logs and continues. 2 new regression tests: - test_recover_all_sessions_on_startup_skips_non_session_index_json - test_msg_count_returns_neg1_for_non_dict_top_level 4026 → 4028 tests passing (+2). Net effect: any user wiped between v0.50.279 and v0.50.284 deploys whose session has a .bak shadow will now get auto-recovered on first launch of v0.50.285, as v0.50.284's release notes promised. Closes #1558 (follow-up — the original P0 was closed by v0.50.284 but the recovery half didn't actually run in production).
2026-05-03 20:50:06 +00:00
if not isinstance(data, dict):
return -1
fix(P0 #1557): metadata-only Session.save() was wiping conversation history v0.50.279 introduced api.routes._clear_stale_stream_state() (#1525) which calls session.save() to clear stale active_stream_id/pending_* fields. The helper is called from /api/session and /api/session/status — both of which load the session with metadata_only=True. Session.load_metadata_only() synthesizes a stub with messages=[] (its whole purpose: fast metadata read without parsing the 400KB+ messages array). Session.save() unconditionally writes self.messages to disk via os.replace(), so saving a metadata-only stub atomically overwrites the on-disk JSON with messages=[], wiping the entire conversation. Production trigger: every SSE reconnect cycle after a server restart polls /api/session/status, which fans out to _clear_stale_stream_state, which saves the metadata-only stub. The user reported losing 1000+ message conversations and seeing 'Reconnecting…' loops on every prompt — the reconnect loop kept the cycle running until the conversation was empty. Fix: three layers, defense in depth. (1) api/models.py: load_metadata_only() now sets _loaded_metadata_only=True on the returned stub. Session.save() raises RuntimeError if that flag is set — a hard guard so any future caller making the same mistake cannot wipe data, only crash visibly. (2) api/routes.py: _clear_stale_stream_state() now detects the metadata-only flag and re-loads the full session with metadata_only=False before mutating persisted state. The full-load path also runs _repair_stale_pending() which independently clears the stream flags, so the explicit clear becomes a no-op in most cases — but messages stay intact. (3) api/models.py + api/session_recovery.py: every save() that would SHRINK the messages array (the precise failure shape of #1557) first snapshots the previous file to <sid>.json.bak. Server.py runs recover_all_sessions_on_startup() at boot — any session whose live JSON has fewer messages than its .bak is restored automatically. Idempotent on clean state. Backup overhead is zero on the normal grow-the-conversation path. Reproducer (master): test_metadata_only_save_does_not_wipe_messages goes from 1000 messages to 0 in a single save() call. After the fix, 1000 messages survive. Tests: 6 new regression tests in tests/test_metadata_save_wipe_1557.py covering all three layers. Full pytest: 4019 → 4025 (+6, all green). Live verified on port 8789: write 1000-msg session with stale active_stream_id, hit /api/session/status, /api/session — file ends with 1002 messages (_repair_stale_pending injects an error-marker pair on full reload, harmless existing behavior), active_stream_id cleared, pending cleared, no Reconnecting loop. Closes #1557. Reported by AvidFuturist via user feedback on v0.50.282.
2026-05-03 19:45:10 +00:00
msgs = data.get('messages')
return len(msgs) if isinstance(msgs, list) else -1
def inspect_session_recovery_status(session_path: Path) -> dict:
"""Return a status dict describing whether recovery is recommended.
{
"session_id": "...",
"live_messages": int, # -1 if live file unreadable
"bak_messages": int, # -1 if no .bak or unreadable
"recommend": "restore" | "no_action" | "no_backup",
}
"""
bak_path = session_path.with_suffix('.json.bak')
live_count = _msg_count(session_path)
if not bak_path.exists():
return {
"session_id": session_path.stem,
"live_messages": live_count,
"bak_messages": -1,
"recommend": "no_backup",
}
bak_count = _msg_count(bak_path)
if bak_count > live_count:
return {
"session_id": session_path.stem,
"live_messages": live_count,
"bak_messages": bak_count,
"recommend": "restore",
}
return {
"session_id": session_path.stem,
"live_messages": live_count,
"bak_messages": bak_count,
"recommend": "no_action",
}
def recover_session(session_path: Path) -> dict:
"""Restore session_path from its .bak when the bak has more messages.
Returns a status dict identical to ``inspect_session_recovery_status``
plus a "restored" boolean.
"""
status = inspect_session_recovery_status(session_path)
if status["recommend"] != "restore":
return {**status, "restored": False}
bak_path = session_path.with_suffix('.json.bak')
# Stage the recovery via a tmp copy + atomic replace so a crash mid-restore
# cannot leave a half-written session.json.
tmp_path = session_path.with_suffix('.json.recover.tmp')
try:
shutil.copyfile(bak_path, tmp_path)
tmp_path.replace(session_path)
except OSError as exc:
logger.warning("recover_session: copy failed for %s: %s", session_path, exc)
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
return {**status, "restored": False, "error": str(exc)}
logger.warning(
"recover_session: restored %s from .bak (live=%d → bak=%d messages). "
"See #1558 for the data-loss class this guards against.",
fix(P0 #1557): metadata-only Session.save() was wiping conversation history v0.50.279 introduced api.routes._clear_stale_stream_state() (#1525) which calls session.save() to clear stale active_stream_id/pending_* fields. The helper is called from /api/session and /api/session/status — both of which load the session with metadata_only=True. Session.load_metadata_only() synthesizes a stub with messages=[] (its whole purpose: fast metadata read without parsing the 400KB+ messages array). Session.save() unconditionally writes self.messages to disk via os.replace(), so saving a metadata-only stub atomically overwrites the on-disk JSON with messages=[], wiping the entire conversation. Production trigger: every SSE reconnect cycle after a server restart polls /api/session/status, which fans out to _clear_stale_stream_state, which saves the metadata-only stub. The user reported losing 1000+ message conversations and seeing 'Reconnecting…' loops on every prompt — the reconnect loop kept the cycle running until the conversation was empty. Fix: three layers, defense in depth. (1) api/models.py: load_metadata_only() now sets _loaded_metadata_only=True on the returned stub. Session.save() raises RuntimeError if that flag is set — a hard guard so any future caller making the same mistake cannot wipe data, only crash visibly. (2) api/routes.py: _clear_stale_stream_state() now detects the metadata-only flag and re-loads the full session with metadata_only=False before mutating persisted state. The full-load path also runs _repair_stale_pending() which independently clears the stream flags, so the explicit clear becomes a no-op in most cases — but messages stay intact. (3) api/models.py + api/session_recovery.py: every save() that would SHRINK the messages array (the precise failure shape of #1557) first snapshots the previous file to <sid>.json.bak. Server.py runs recover_all_sessions_on_startup() at boot — any session whose live JSON has fewer messages than its .bak is restored automatically. Idempotent on clean state. Backup overhead is zero on the normal grow-the-conversation path. Reproducer (master): test_metadata_only_save_does_not_wipe_messages goes from 1000 messages to 0 in a single save() call. After the fix, 1000 messages survive. Tests: 6 new regression tests in tests/test_metadata_save_wipe_1557.py covering all three layers. Full pytest: 4019 → 4025 (+6, all green). Live verified on port 8789: write 1000-msg session with stale active_stream_id, hit /api/session/status, /api/session — file ends with 1002 messages (_repair_stale_pending injects an error-marker pair on full reload, harmless existing behavior), active_stream_id cleared, pending cleared, no Reconnecting loop. Closes #1557. Reported by AvidFuturist via user feedback on v0.50.282.
2026-05-03 19:45:10 +00:00
session_path.name, status["live_messages"], status["bak_messages"],
)
return {**status, "restored": True}
def recover_all_sessions_on_startup(session_dir: Path) -> dict:
"""Scan session_dir for shrunken sessions, restore each from its .bak.
Returns {"scanned": N, "restored": M, "details": [...]}.
"""
if not session_dir.exists():
return {"scanned": 0, "restored": 0, "details": []}
scanned = 0
restored = 0
details: list[dict] = []
for path in session_dir.glob('*.json'):
fix(session-recovery): skip _index.json + harden _msg_count against non-dict JSON (v0.50.284 follow-up) v0.50.284 shipped startup self-heal in api/session_recovery.py that crashed on the very first JSON file it scanned in the production session directory. Verified live on the prod server immediately after the v0.50.284 deploy: [recovery] startup recovery failed: 'list' object has no attribute 'get' Root cause: the production session dir contains _index.json — a top-level LIST of session metadata dicts (not a dict). _msg_count() did data.get('messages') which raises AttributeError on a list. The broad except Exception in server.py's startup hook swallowed the error and the recovery silently no-op'd for every user — defeating the entire purpose of the v0.50.284 release. Fix is three small defensive changes: 1. _msg_count() — added isinstance(data, dict) guard. Non-dict-shaped JSON files now return -1 (the harmless 'unknown count' sentinel) instead of raising AttributeError. 2. recover_all_sessions_on_startup() — skips any file whose name starts with '_' (the existing project convention for non-session metadata files like _index.json). These are convention-marked as system files, not session payloads. 3. recover_all_sessions_on_startup() — wraps recover_session(path) in try/except Exception so a single malformed file can't break recovery for the rest. Logs and continues. 2 new regression tests: - test_recover_all_sessions_on_startup_skips_non_session_index_json - test_msg_count_returns_neg1_for_non_dict_top_level 4026 → 4028 tests passing (+2). Net effect: any user wiped between v0.50.279 and v0.50.284 deploys whose session has a .bak shadow will now get auto-recovered on first launch of v0.50.285, as v0.50.284's release notes promised. Closes #1558 (follow-up — the original P0 was closed by v0.50.284 but the recovery half didn't actually run in production).
2026-05-03 20:50:06 +00:00
# Skip non-session JSON files in the same dir:
# - ``_index.json`` is a top-level list of session metadata
# - any future non-session JSON marked with the ``_`` convention is
# skipped automatically (project convention for system files in
# directories that otherwise hold user data)
fix(session-recovery): skip _index.json + harden _msg_count against non-dict JSON (v0.50.284 follow-up) v0.50.284 shipped startup self-heal in api/session_recovery.py that crashed on the very first JSON file it scanned in the production session directory. Verified live on the prod server immediately after the v0.50.284 deploy: [recovery] startup recovery failed: 'list' object has no attribute 'get' Root cause: the production session dir contains _index.json — a top-level LIST of session metadata dicts (not a dict). _msg_count() did data.get('messages') which raises AttributeError on a list. The broad except Exception in server.py's startup hook swallowed the error and the recovery silently no-op'd for every user — defeating the entire purpose of the v0.50.284 release. Fix is three small defensive changes: 1. _msg_count() — added isinstance(data, dict) guard. Non-dict-shaped JSON files now return -1 (the harmless 'unknown count' sentinel) instead of raising AttributeError. 2. recover_all_sessions_on_startup() — skips any file whose name starts with '_' (the existing project convention for non-session metadata files like _index.json). These are convention-marked as system files, not session payloads. 3. recover_all_sessions_on_startup() — wraps recover_session(path) in try/except Exception so a single malformed file can't break recovery for the rest. Logs and continues. 2 new regression tests: - test_recover_all_sessions_on_startup_skips_non_session_index_json - test_msg_count_returns_neg1_for_non_dict_top_level 4026 → 4028 tests passing (+2). Net effect: any user wiped between v0.50.279 and v0.50.284 deploys whose session has a .bak shadow will now get auto-recovered on first launch of v0.50.285, as v0.50.284's release notes promised. Closes #1558 (follow-up — the original P0 was closed by v0.50.284 but the recovery half didn't actually run in production).
2026-05-03 20:50:06 +00:00
if path.name.startswith('_'):
continue
fix(P0 #1557): metadata-only Session.save() was wiping conversation history v0.50.279 introduced api.routes._clear_stale_stream_state() (#1525) which calls session.save() to clear stale active_stream_id/pending_* fields. The helper is called from /api/session and /api/session/status — both of which load the session with metadata_only=True. Session.load_metadata_only() synthesizes a stub with messages=[] (its whole purpose: fast metadata read without parsing the 400KB+ messages array). Session.save() unconditionally writes self.messages to disk via os.replace(), so saving a metadata-only stub atomically overwrites the on-disk JSON with messages=[], wiping the entire conversation. Production trigger: every SSE reconnect cycle after a server restart polls /api/session/status, which fans out to _clear_stale_stream_state, which saves the metadata-only stub. The user reported losing 1000+ message conversations and seeing 'Reconnecting…' loops on every prompt — the reconnect loop kept the cycle running until the conversation was empty. Fix: three layers, defense in depth. (1) api/models.py: load_metadata_only() now sets _loaded_metadata_only=True on the returned stub. Session.save() raises RuntimeError if that flag is set — a hard guard so any future caller making the same mistake cannot wipe data, only crash visibly. (2) api/routes.py: _clear_stale_stream_state() now detects the metadata-only flag and re-loads the full session with metadata_only=False before mutating persisted state. The full-load path also runs _repair_stale_pending() which independently clears the stream flags, so the explicit clear becomes a no-op in most cases — but messages stay intact. (3) api/models.py + api/session_recovery.py: every save() that would SHRINK the messages array (the precise failure shape of #1557) first snapshots the previous file to <sid>.json.bak. Server.py runs recover_all_sessions_on_startup() at boot — any session whose live JSON has fewer messages than its .bak is restored automatically. Idempotent on clean state. Backup overhead is zero on the normal grow-the-conversation path. Reproducer (master): test_metadata_only_save_does_not_wipe_messages goes from 1000 messages to 0 in a single save() call. After the fix, 1000 messages survive. Tests: 6 new regression tests in tests/test_metadata_save_wipe_1557.py covering all three layers. Full pytest: 4019 → 4025 (+6, all green). Live verified on port 8789: write 1000-msg session with stale active_stream_id, hit /api/session/status, /api/session — file ends with 1002 messages (_repair_stale_pending injects an error-marker pair on full reload, harmless existing behavior), active_stream_id cleared, pending cleared, no Reconnecting loop. Closes #1557. Reported by AvidFuturist via user feedback on v0.50.282.
2026-05-03 19:45:10 +00:00
scanned += 1
fix(session-recovery): skip _index.json + harden _msg_count against non-dict JSON (v0.50.284 follow-up) v0.50.284 shipped startup self-heal in api/session_recovery.py that crashed on the very first JSON file it scanned in the production session directory. Verified live on the prod server immediately after the v0.50.284 deploy: [recovery] startup recovery failed: 'list' object has no attribute 'get' Root cause: the production session dir contains _index.json — a top-level LIST of session metadata dicts (not a dict). _msg_count() did data.get('messages') which raises AttributeError on a list. The broad except Exception in server.py's startup hook swallowed the error and the recovery silently no-op'd for every user — defeating the entire purpose of the v0.50.284 release. Fix is three small defensive changes: 1. _msg_count() — added isinstance(data, dict) guard. Non-dict-shaped JSON files now return -1 (the harmless 'unknown count' sentinel) instead of raising AttributeError. 2. recover_all_sessions_on_startup() — skips any file whose name starts with '_' (the existing project convention for non-session metadata files like _index.json). These are convention-marked as system files, not session payloads. 3. recover_all_sessions_on_startup() — wraps recover_session(path) in try/except Exception so a single malformed file can't break recovery for the rest. Logs and continues. 2 new regression tests: - test_recover_all_sessions_on_startup_skips_non_session_index_json - test_msg_count_returns_neg1_for_non_dict_top_level 4026 → 4028 tests passing (+2). Net effect: any user wiped between v0.50.279 and v0.50.284 deploys whose session has a .bak shadow will now get auto-recovered on first launch of v0.50.285, as v0.50.284's release notes promised. Closes #1558 (follow-up — the original P0 was closed by v0.50.284 but the recovery half didn't actually run in production).
2026-05-03 20:50:06 +00:00
try:
result = recover_session(path)
except Exception as exc:
# Defensive: a malformed session file shouldn't break recovery
# for the rest. Log and continue.
logger.warning(
"recover_all_sessions_on_startup: skipped %s due to %s: %s",
path.name, type(exc).__name__, exc,
)
continue
fix(P0 #1557): metadata-only Session.save() was wiping conversation history v0.50.279 introduced api.routes._clear_stale_stream_state() (#1525) which calls session.save() to clear stale active_stream_id/pending_* fields. The helper is called from /api/session and /api/session/status — both of which load the session with metadata_only=True. Session.load_metadata_only() synthesizes a stub with messages=[] (its whole purpose: fast metadata read without parsing the 400KB+ messages array). Session.save() unconditionally writes self.messages to disk via os.replace(), so saving a metadata-only stub atomically overwrites the on-disk JSON with messages=[], wiping the entire conversation. Production trigger: every SSE reconnect cycle after a server restart polls /api/session/status, which fans out to _clear_stale_stream_state, which saves the metadata-only stub. The user reported losing 1000+ message conversations and seeing 'Reconnecting…' loops on every prompt — the reconnect loop kept the cycle running until the conversation was empty. Fix: three layers, defense in depth. (1) api/models.py: load_metadata_only() now sets _loaded_metadata_only=True on the returned stub. Session.save() raises RuntimeError if that flag is set — a hard guard so any future caller making the same mistake cannot wipe data, only crash visibly. (2) api/routes.py: _clear_stale_stream_state() now detects the metadata-only flag and re-loads the full session with metadata_only=False before mutating persisted state. The full-load path also runs _repair_stale_pending() which independently clears the stream flags, so the explicit clear becomes a no-op in most cases — but messages stay intact. (3) api/models.py + api/session_recovery.py: every save() that would SHRINK the messages array (the precise failure shape of #1557) first snapshots the previous file to <sid>.json.bak. Server.py runs recover_all_sessions_on_startup() at boot — any session whose live JSON has fewer messages than its .bak is restored automatically. Idempotent on clean state. Backup overhead is zero on the normal grow-the-conversation path. Reproducer (master): test_metadata_only_save_does_not_wipe_messages goes from 1000 messages to 0 in a single save() call. After the fix, 1000 messages survive. Tests: 6 new regression tests in tests/test_metadata_save_wipe_1557.py covering all three layers. Full pytest: 4019 → 4025 (+6, all green). Live verified on port 8789: write 1000-msg session with stale active_stream_id, hit /api/session/status, /api/session — file ends with 1002 messages (_repair_stale_pending injects an error-marker pair on full reload, harmless existing behavior), active_stream_id cleared, pending cleared, no Reconnecting loop. Closes #1557. Reported by AvidFuturist via user feedback on v0.50.282.
2026-05-03 19:45:10 +00:00
if result.get("restored"):
restored += 1
details.append(result)
if restored:
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 #1558.", restored, scanned,
fix(P0 #1557): metadata-only Session.save() was wiping conversation history v0.50.279 introduced api.routes._clear_stale_stream_state() (#1525) which calls session.save() to clear stale active_stream_id/pending_* fields. The helper is called from /api/session and /api/session/status — both of which load the session with metadata_only=True. Session.load_metadata_only() synthesizes a stub with messages=[] (its whole purpose: fast metadata read without parsing the 400KB+ messages array). Session.save() unconditionally writes self.messages to disk via os.replace(), so saving a metadata-only stub atomically overwrites the on-disk JSON with messages=[], wiping the entire conversation. Production trigger: every SSE reconnect cycle after a server restart polls /api/session/status, which fans out to _clear_stale_stream_state, which saves the metadata-only stub. The user reported losing 1000+ message conversations and seeing 'Reconnecting…' loops on every prompt — the reconnect loop kept the cycle running until the conversation was empty. Fix: three layers, defense in depth. (1) api/models.py: load_metadata_only() now sets _loaded_metadata_only=True on the returned stub. Session.save() raises RuntimeError if that flag is set — a hard guard so any future caller making the same mistake cannot wipe data, only crash visibly. (2) api/routes.py: _clear_stale_stream_state() now detects the metadata-only flag and re-loads the full session with metadata_only=False before mutating persisted state. The full-load path also runs _repair_stale_pending() which independently clears the stream flags, so the explicit clear becomes a no-op in most cases — but messages stay intact. (3) api/models.py + api/session_recovery.py: every save() that would SHRINK the messages array (the precise failure shape of #1557) first snapshots the previous file to <sid>.json.bak. Server.py runs recover_all_sessions_on_startup() at boot — any session whose live JSON has fewer messages than its .bak is restored automatically. Idempotent on clean state. Backup overhead is zero on the normal grow-the-conversation path. Reproducer (master): test_metadata_only_save_does_not_wipe_messages goes from 1000 messages to 0 in a single save() call. After the fix, 1000 messages survive. Tests: 6 new regression tests in tests/test_metadata_save_wipe_1557.py covering all three layers. Full pytest: 4019 → 4025 (+6, all green). Live verified on port 8789: write 1000-msg session with stale active_stream_id, hit /api/session/status, /api/session — file ends with 1002 messages (_repair_stale_pending injects an error-marker pair on full reload, harmless existing behavior), active_stream_id cleared, pending cleared, no Reconnecting loop. Closes #1557. Reported by AvidFuturist via user feedback on v0.50.282.
2026-05-03 19:45:10 +00:00
)
return {"scanned": scanned, "restored": restored, "details": details}