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:
@@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from agent.context_compressor import _DB_PERSISTED_MARKER as _DB_PERSISTED_MARKER_KEY, split_user_originated_turn
|
||||
from agent.memory_manager import sanitize_context
|
||||
from agent.message_sanitization import _sanitize_surrogates
|
||||
from hermes_cli.timefmt import coerce_epoch
|
||||
from hermes_state_common import (
|
||||
_COMPRESSION_LOCK_ROW_SQL, _ENDED_ROW_SQL, _RESET_END_REASONS, _RESET_END_REASONS_SQL, _ended_by_compression,
|
||||
_legacy_reset_child_sql, _placeholders, _sql_json_extract)
|
||||
@@ -54,18 +55,15 @@ def _json_or(raw: Any, fallback: Any, warning: str) -> Any:
|
||||
|
||||
|
||||
def _coerce_timestamp(value: Any, default: float) -> float:
|
||||
"""Explicit message timestamp (datetime or number) or *default* when invalid."""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
result = float(value.timestamp()) if hasattr(value, "timestamp") else float(value)
|
||||
# SQLite reads both signed zero spellings back as 0.0. Hash the value
|
||||
# in that stored form so -0.0 and 0.0 retain the historical SQL/Python
|
||||
# identity equality.
|
||||
return 0.0 if result == 0.0 else result
|
||||
except (TypeError, ValueError):
|
||||
logger.debug("Ignoring invalid explicit message timestamp: %r", value)
|
||||
"""Explicit message timestamp (datetime or number) or *default* when invalid or outside the sane
|
||||
epoch window — the write-side twin of the readers' ``coerce_epoch``: a bad row is never persisted."""
|
||||
result = coerce_epoch(value, field="message timestamp")
|
||||
if result is None:
|
||||
return default
|
||||
# SQLite reads both signed zero spellings back as 0.0. Hash the value
|
||||
# in that stored form so -0.0 and 0.0 retain the historical SQL/Python
|
||||
# identity equality.
|
||||
return 0.0 if result == 0.0 else result
|
||||
|
||||
|
||||
def _parse_tool_calls(tool_calls: Any) -> Any:
|
||||
|
||||
Reference in New Issue
Block a user