From f8d35574ab435b992a2cf231a52055380cd7327e Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 24 Sep 2026 23:31:38 -0500 Subject: [PATCH] fix(recovery): repair out-of-window timestamp cells in the recovered database --- hermes_cli/session_recovery.py | 44 ++++++++++++++- .../test_session_recovery_timestamps.py | 55 +++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 tests/hermes_cli/test_session_recovery_timestamps.py diff --git a/hermes_cli/session_recovery.py b/hermes_cli/session_recovery.py index 27c5173f6d..f6a8264579 100644 --- a/hermes_cli/session_recovery.py +++ b/hermes_cli/session_recovery.py @@ -16,6 +16,7 @@ from contextlib import contextmanager from pathlib import Path from typing import Any, Callable, Iterator, Optional +from hermes_cli.timefmt import EPOCH_MAX, EPOCH_MIN from hermes_state import SessionDB from hermes_state_common import FTS_STORAGE_VERSION, SCHEMA_VERSION from hermes_state_repair import _db_opens_cleanly @@ -941,9 +942,47 @@ def _sanitize_session_model_config(destination: sqlite3.Connection) -> int: ) +def _repair_out_of_window_timestamps(destination: sqlite3.Connection) -> int: + """Rewrite ``messages``/``sessions`` timestamp cells outside the ``coerce_epoch`` window; returns the count. + + A damaged cell can decode as a valid-looking garbage double (``5.49e+246``) that the salvage copies + verbatim, and one such row pins the session's recency and breaks every renderer that turns it into + a date (#91536). A message takes its nearest valid neighbour's time in the same session (keeps the + order), then its session's start, then now; ``started_at`` takes the earliest valid message; the + nullable ``ended_at``/``last_activity_at`` become NULL. + """ + message_columns, session_columns = _table_columns(destination, "messages"), _table_columns(destination, "sessions") + ok = f"BETWEEN {EPOCH_MIN!r} AND {EPOCH_MAX!r}" + now = "CAST(strftime('%s', 'now') AS REAL)" + repaired = 0 + with _immediate_transaction(destination): + if "timestamp" in message_columns: + repaired += _reconcile( + destination, "messages", f"NOT (timestamp {ok})", + "UPDATE messages SET timestamp = COALESCE(" + f"(SELECT p.timestamp FROM messages p WHERE p.session_id = messages.session_id AND p.id < messages.id" + f" AND p.timestamp {ok} ORDER BY p.id DESC LIMIT 1)," + f"(SELECT n.timestamp FROM messages n WHERE n.session_id = messages.session_id AND n.id > messages.id" + f" AND n.timestamp {ok} ORDER BY n.id LIMIT 1)," + f"(SELECT s.started_at FROM sessions s WHERE s.id = messages.session_id AND s.started_at {ok}), {now})", + ) + if "started_at" in session_columns: + repaired += _reconcile( + destination, "sessions", f"NOT (started_at {ok})", + "UPDATE sessions SET started_at = COALESCE((SELECT MIN(m.timestamp) FROM messages m" + f" WHERE m.session_id = sessions.id AND m.timestamp {ok}), {now})", + ) + for column in ("ended_at", "last_activity_at"): + if column in session_columns: + repaired += _reconcile( + destination, "sessions", f"NOT ({column} {ok})", f"UPDATE sessions SET {column} = NULL") + return repaired + + def _finalize_derived_metadata(destination: sqlite3.Connection) -> dict[str, Any]: - """Sanitize copied JSON columns and stamp metadata the new destination actually owns.""" + """Sanitize copied JSON columns and timestamps, and stamp metadata the new destination actually owns.""" model_config_reset = _sanitize_session_model_config(destination) + timestamps_repaired = _repair_out_of_window_timestamps(destination) fts_tables = { str(row[0]) for row in destination.execute( @@ -951,7 +990,8 @@ def _finalize_derived_metadata(destination: sqlite3.Connection) -> dict[str, Any ).fetchall() } result: dict[str, Any] = { - "fts_tables": sorted(fts_tables), "finalized": False, "model_config_reset": model_config_reset} + "fts_tables": sorted(fts_tables), "finalized": False, "model_config_reset": model_config_reset, + "timestamps_repaired": timestamps_repaired} if fts_tables != {"messages_fts", "messages_fts_trigram"}: result["error"] = "fresh destination is missing required FTS tables" return result diff --git a/tests/hermes_cli/test_session_recovery_timestamps.py b/tests/hermes_cli/test_session_recovery_timestamps.py new file mode 100644 index 0000000000..5303713d89 --- /dev/null +++ b/tests/hermes_cli/test_session_recovery_timestamps.py @@ -0,0 +1,55 @@ +"""Invariant: session recovery never ships an out-of-window timestamp cell (#91536). + +A damaged page can decode as a valid-looking garbage double (``5.49e+246``, ``1e-310`` magnitudes +are uninitialised-memory patterns) that the copy writes verbatim. Both recovery lanes pass through +``_finalize_derived_metadata``, so the repair lives there. +""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from hermes_cli.session_recovery import recover_session_database +from hermes_cli.timefmt import coerce_epoch +from hermes_state import SessionDB + + +def _damaged_source(path: Path) -> list[float]: + db = SessionDB(db_path=path) + try: + db.create_session("s", "cli") + for text in ("one", "two", "three"): + db.append_message("s", "user", text) + stamps = [row["timestamp"] for row in db.get_messages("s")] + finally: + db.close() + raw = sqlite3.connect(str(path)) + try: + middle = raw.execute("SELECT id FROM messages WHERE session_id = 's' ORDER BY id LIMIT 1 OFFSET 1").fetchone()[0] + raw.execute("UPDATE messages SET timestamp = 5.4905047707024164e+246 WHERE id = ?", (middle,)) + raw.execute("UPDATE sessions SET started_at = 'garbage', ended_at = 1e300, last_activity_at = -5 WHERE id = 's'") + raw.commit() + finally: + raw.close() + return stamps + + +def test_recovery_repairs_out_of_window_timestamps(tmp_path): + source, output = tmp_path / "state.db", tmp_path / "recovered.db" + stamps = _damaged_source(source) + + report = recover_session_database(source, output) + + assert report["verification"]["healthy"], report["verification"]["errors"] + conn = sqlite3.connect(str(output)) + try: + messages = [r[0] for r in conn.execute("SELECT timestamp FROM messages WHERE session_id = 's' ORDER BY id")] + started, ended, active = conn.execute( + "SELECT started_at, ended_at, last_activity_at FROM sessions WHERE id = 's'").fetchone() + finally: + conn.close() + # Every cell is a trusted epoch again, message order is preserved, and the good cells are untouched. + assert all(coerce_epoch(ts) is not None for ts in messages) + assert messages[0] == stamps[0] and messages[2] == stamps[2] and messages == sorted(messages) + assert started == min(messages) and ended is None and active is None