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
|
2026-05-03 19:55:14 +00:00
|
|
|
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:
|
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
|
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). "
|
2026-05-03 19:55:14 +00:00
|
|
|
"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'):
|
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
|
2026-05-03 20:54:02 +00:00
|
|
|
# - any future non-session JSON marked with the ``_`` convention is
|
|
|
|
|
# skipped automatically (project convention for system files in
|
|
|
|
|
# directories that otherwise hold user data)
|
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
|
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 "
|
2026-05-03 19:55:14 +00:00
|
|
|
"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}
|