fix(recovery): repair out-of-window timestamp cells in the recovered database

This commit is contained in:
Hermes Agent
2026-09-24 23:31:38 -05:00
committed by brooklyn!
parent f27bde1fdd
commit f8d35574ab
2 changed files with 97 additions and 2 deletions

View File

@@ -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

View File

@@ -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