fix(sessions): transfer export round-trips compaction-archived turns

The console `sessions export` backup went through export_session /
export_all, whose batched read filters `AND active = 1`, so an
export-then-import restore silently dropped every compaction-archived
turn (display 9 -> 3 in the C13 probe). export_all now takes
include_inactive (storage-order rows with their active/compacted flags,
which import_sessions restores as archived), the console export passes
it for both single-session and all-session exports, and
assert_export_safe sizes the same projection it guards.

Ported from #123267's transfer/backup hunks; its import-normalisation
hunks are superseded by #122680's checkpoint-correct import.

Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
This commit is contained in:
joaomarcos
2026-09-26 18:23:52 +05:30
committed by kshitij
parent 0325349f96
commit 85deb4553a
5 changed files with 81 additions and 14 deletions

View File

@@ -657,11 +657,11 @@ def _guard_exports(db, session_ids: list[str]) -> None:
return
try:
for session_id in session_ids:
db.assert_export_safe(session_id, max_messages=limit)
db.assert_export_safe(session_id, max_messages=limit, include_inactive=True)
except SessionExportTooLargeError as exc:
raise ConsoleCommandError(
f"Session '{exc.session_id}' has more than {limit:,} active "
"messages; in-memory export is capped per session. "
f"Session '{exc.session_id}' has more than {limit:,} "
"exportable messages; in-memory export is capped per session. "
"Use the Sessions page's streaming Export action, or set "
"sessions.max_export_messages: 0 in config.yaml to disable "
"the guard.") from exc
@@ -676,13 +676,15 @@ def _sessions_export(_engine: HermesConsoleEngine, args: list[str]) -> None:
if not resolved_session_id:
raise ConsoleCommandError(f"Session '{ns.session_id}' not found.")
_guard_exports(db, [resolved_session_id])
rows = [db.export_session(resolved_session_id)]
# Transfer projection: every row with its active/compacted flags, so an import of this
# JSONL restores a compacted session's whole history instead of only its live rows.
rows = [db.export_session(resolved_session_id, include_inactive=True)]
if not rows[0]:
raise ConsoleCommandError(f"Session '{ns.session_id}' not found.")
else:
found = db.search_sessions(source=ns.source, limit=100000)
_guard_exports(db, [session["id"] for session in found])
rows = db.export_all(source=ns.source)
rows = db.export_all(source=ns.source, include_inactive=True)
text = "\n".join(json.dumps(row, ensure_ascii=False) for row in rows)
if text:
text += "\n"

View File

@@ -309,20 +309,24 @@ class SessionPortabilityMixin:
"messages": messages, "timings": _export_timings(messages, session_id),
}
def export_all(self, source: str = None, include_compacted: bool = False) -> List[Dict[str, Any]]:
"""Export all sessions (with messages) as dicts, e.g. for JSONL backup (``include_compacted`` as in
:meth:`export_session`; that display read dedupes per session, so it skips the batched read)."""
def export_all(self, source: str = None, include_compacted: bool = False,
include_inactive: bool = False) -> List[Dict[str, Any]]:
"""Export all sessions (with messages) as dicts, e.g. for JSONL backup (flags as in
:meth:`export_session`; that display read dedupes per session, so it skips the batched read).
Backups that go back through :meth:`import_sessions` pass ``include_inactive`` so
compaction-archived turns survive the round trip as archived rows."""
sessions = self.search_sessions(source=source, limit=100000)
if include_compacted:
return [self._with_messages(session, True) for session in sessions]
return [self._with_messages(session, True, include_inactive) for session in sessions]
messages_by_session = {session["id"]: [] for session in sessions}
session_ids = list(messages_by_session)
active_clause = "" if include_inactive else " AND active = 1"
# Stay below SQLite's legacy 999-variable limit while replacing the per-session N+1 reads.
for start in range(0, len(session_ids), 900):
chunk = session_ids[start:start + 900]
rows = self._read_all(
f"SELECT * FROM messages WHERE session_id IN ({','.join('?' for _ in chunk)}) "
"AND active = 1 ORDER BY session_id, id",
f"{active_clause} ORDER BY session_id, id",
chunk,
)
for row in rows:

View File

@@ -1401,8 +1401,10 @@ class SessionSessionsMixin:
)
return statuses
def assert_export_safe(self, session_id: str, max_messages: Optional[int] = None) -> int:
"""Active row count of this segment, or raise SessionExportTooLargeError (the LIMITed subquery
def assert_export_safe(self, session_id: str, max_messages: Optional[int] = None,
include_inactive: bool = False) -> int:
"""Row count of this segment — live rows, or every row with ``include_inactive``, matching what
the export materializes — or raise SessionExportTooLargeError (the LIMITed subquery
stops once the bound is exceeded). ``None`` resolves ``sessions.max_export_messages``; 0 disables
the guard."""
from hermes_state import SessionExportTooLargeError, resolved_max_export_messages
@@ -1412,8 +1414,9 @@ class SessionSessionsMixin:
raise ValueError("max_messages must be non-negative")
if max_messages == 0:
return 0
active_clause = "" if include_inactive else " AND active = 1"
row = self._read_one(
"SELECT COUNT(*) FROM (SELECT 1 FROM messages WHERE session_id = ? AND active = 1 LIMIT ?)",
f"SELECT COUNT(*) FROM (SELECT 1 FROM messages WHERE session_id = ?{active_clause} LIMIT ?)",
(session_id, max_messages + 1),
)
message_count = int(row[0] if row else 0)

View File

@@ -169,7 +169,7 @@ def test_sessions_export_all_rejects_single_oversized_session(
assert result.status == "error"
assert "runaway" in result.output
assert "more than 3 active" in result.output
assert "more than 3 exportable" in result.output
assert "streaming Export" in result.output
assert "max_export_messages" in result.output
assert export_all_calls == []

View File

@@ -0,0 +1,58 @@
"""Round-trip export/import must restore archived message state (#122679).
The JSONL transfer projection feeds ``import_sessions``: rows in-place compaction
archived must come back archived — visible in the display history, never as live
model context — or a restored compacted session silently loses every turn the
summary replaced. ``active``/``compacted`` are the row state; absent flags (older
exports) keep meaning live rows.
"""
from __future__ import annotations
import json
from hermes_state import SessionDB
STRANDED_ID = "20260823_043331_c93770"
def _seed_and_compact(db, turns=4):
db.create_session(STRANDED_ID, source="tui")
db.set_session_title(STRANDED_ID, "Bot Chat")
for i in range(1, turns + 1):
db.append_message(STRANDED_ID, "user", f"question {i}")
db.append_message(STRANDED_ID, "assistant", f"answer {i}")
tail = db.get_messages(STRANDED_ID)[-2:]
db.archive_and_compact(
STRANDED_ID, [{"role": "user", "content": "[summary of turns 1-3]"}, *tail], tail_count=2)
def _shape(db, **flags):
return [(m["role"], m["content"], m.get("active", 1), m.get("compacted", 0))
for m in db.get_messages(STRANDED_ID, **flags)]
def test_export_all_round_trips_compacted_history(tmp_path):
src = SessionDB(db_path=tmp_path / "src.db")
dst = SessionDB(db_path=tmp_path / "dst.db")
try:
_seed_and_compact(src)
shown_before = _shape(src, include_compacted=True)
live_before = _shape(src)
all_before = _shape(src, include_inactive=True)
assert len(shown_before) > len(live_before), "compacted turns must be archived, not deleted"
# The transfer projection through real JSONL (what `sessions export` writes).
payload = json.loads(json.dumps(src.export_all(include_inactive=True)))
result = dst.import_sessions(payload)
assert result["ok"] and result["imported"] == 1, result
assert _shape(dst, include_inactive=True) == all_before, "every row must survive with its state"
assert _shape(dst, include_compacted=True) == shown_before, "display history must be unchanged"
assert _shape(dst) == live_before, "archived turns must not re-enter live model context"
# Session counters count live rows only.
assert dst.get_session(STRANDED_ID)["message_count"] == len(live_before)
finally:
src.close()
dst.close()