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:
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
58
tests/hermes_state/test_session_archived_round_trip.py
Normal file
58
tests/hermes_state/test_session_archived_round_trip.py
Normal 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()
|
||||
Reference in New Issue
Block a user