fix(state): one corrupt timestamp row no longer kills sessions list, export or insights

SQLite dynamic typing lets a TEXT cell ('not-a-timestamp'), inf/nan or a
garbage double (8.4e252 salvaged from a damaged page) sit in a REAL
timestamp column. Every reader called datetime.fromtimestamp()/float
arithmetic on the raw cell, so ONE bad row raised TypeError/OverflowError
out of the row loop and took down the whole `hermes sessions list`/browse
table (#102399), all three exporters — JSONL/MD, QMD, HTML (#102352) —
and `hermes insights` (#99959).

Fix the class with ONE helper, hermes_cli.timefmt.coerce_epoch(): a
stored cell becomes float epoch seconds inside a sane 1970..2103 window
or None after a WARNING that names the session id. Every reader routes
through it — relative_time (list/browse/resume picker), format_epoch
(prune/candidates tables), the three exporters' timestamp formatters,
insights' _get_sessions/_day/period range — so a bad row renders as
'?'/'N/A'/raw text for that one cell and the command completes.

Write side: hermes_state_messages._coerce_timestamp (append_message,
append_messages_batch, import) and the import path's started_at now use
the same window, so a new out-of-range timestamp falls back to now()
instead of being persisted — new bad rows cannot be written by Hermes.

Reported-by: #102399, #102352, #99959 reporters; kokhlo's insights
analysis pointed at every reporting site, not just line 860.
This commit is contained in:
teknium1
2026-09-11 02:21:44 -07:00
committed by Teknium
parent df1388e77b
commit 496eb13bd7
11 changed files with 88 additions and 41 deletions
+2 -2
View File
@@ -58,7 +58,7 @@ def _format_row(s: dict, max_x: int) -> str:
name = ((s.get("title") or "").strip() or (s.get("preview") or "").strip())[:name_width] or sid
return (
f"{name:<{name_width}} {_session_status_tag(s.get('_status')):<5} "
f"{_msgs_str(s):>5} {_relative_time(s.get('last_active')):<10} "
f"{_msgs_str(s):>5} {_relative_time(s.get('last_active'), session_id=s['id']):<10} "
f"{s.get('source', '')[:6]:<5} {sid}"
)
@@ -218,7 +218,7 @@ def _fallback_picker(sessions: list) -> Optional[str]:
for i, s in enumerate(sessions):
print(
f" {i + 1:>3}. {_clip(_label(s), 50):<50} {_session_status_tag(s.get('_status')):<5} "
f"{_msgs_str(s):>5} {_relative_time(s.get('last_active')):<10} {s.get('source', '')[:6]}"
f"{_msgs_str(s):>5} {_relative_time(s.get('last_active'), session_id=s['id']):<10} {s.get('source', '')[:6]}"
)
while True:
try: