From 2a92d6be4d359f1f0be5b4988e0bc5992d0b215c Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:01:30 -0700 Subject: [PATCH] fix: session-export timings tolerate corrupt timestamp cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_export_timings` parsed message timestamps with a bare `float()`, so a pre-existing out-of-range cell (e.g. 8.4e252) reached `datetime.fromtimestamp` and raised OverflowError, killing the whole `export_session`/`export_all` call — a regression caught by tests/hermes_state/test_corrupt_row_robustness.py. Route the cell through `coerce_epoch`, the reader every other timestamp surface already uses, so a bad row degrades to a `missing` count (with the usual warning naming the session) and the export still lands. --- hermes_state_portability.py | 16 +++++++--------- .../hermes_state/test_session_export_timings.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/hermes_state_portability.py b/hermes_state_portability.py index 0042f4316d..9948f5f444 100644 --- a/hermes_state_portability.py +++ b/hermes_state_portability.py @@ -81,7 +81,7 @@ def _rich_select(select_cols: str, where: str, tail: str = "", prompt_select: Op _PROMPT_RESOLVED_SQL = "COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved" -def _export_timings(messages: List[Dict[str, Any]]) -> Dict[str, Any]: +def _export_timings(messages: List[Dict[str, Any]], session_id: Optional[str] = None) -> Dict[str, Any]: """Text-free timing evidence for a session export (port of nearai/ironclaw#7735). Exports get attached to bug reports; a reader should not have to infer from raw @@ -89,13 +89,11 @@ def _export_timings(messages: List[Dict[str, Any]]) -> Dict[str, Any]: intervals. Hermes persists no model/tool stopwatch samples, so message timestamps are the durable floor (``complete`` is therefore always False). Ids, roles, counts and durations only — never prompt text, arguments or results. + Corrupt timestamp cells go through ``coerce_epoch`` like every other reader: they + count as ``missing`` and never abort the export. """ - timestamped = [] - for msg in messages: - try: - timestamped.append((msg, float(msg.get("timestamp")))) - except (TypeError, ValueError): - continue + timestamped = [(msg, ts) for msg in messages + if (ts := coerce_epoch(msg.get("timestamp"), session_id=session_id)) is not None] role_counts = Counter(str(msg.get("role") or "unknown") for msg in messages) tool_calls_emitted = sum( len(tc) if isinstance(tc, list) else 1 for tc in (msg.get("tool_calls") for msg in messages) if tc) @@ -280,7 +278,7 @@ class SessionPortabilityMixin: def _with_messages(self, session: Dict[str, Any]) -> Dict[str, Any]: messages = self.get_messages(session["id"]) - return {**session, "messages": messages, "timings": _export_timings(messages)} + return {**session, "messages": messages, "timings": _export_timings(messages, session["id"])} def export_session(self, session_id: str) -> Optional[Dict[str, Any]]: """Export a single session with all its messages as a dict.""" @@ -320,7 +318,7 @@ class SessionPortabilityMixin: self._row_to_message_dict(row, warn_context="get_messages", summary_flag=True) ) return [{**session, "messages": messages_by_session[session["id"]], - "timings": _export_timings(messages_by_session[session["id"]])} for session in sessions] + "timings": _export_timings(messages_by_session[session["id"]], session["id"])} for session in sessions] def adopt_session_lineage_from(self, donor_db: Any, session_id: str, *, retire_donor: bool = True) -> Dict[str, Any]: """Adopt *session_id*'s full compression lineage from *donor_db* (stranded-bot-session diff --git a/tests/hermes_state/test_session_export_timings.py b/tests/hermes_state/test_session_export_timings.py index c641effda6..1b19b26a0d 100644 --- a/tests/hermes_state/test_session_export_timings.py +++ b/tests/hermes_state/test_session_export_timings.py @@ -79,3 +79,20 @@ def test_export_all_includes_timing_evidence(tmp_path): assert exported[0]["timings"]["wall_clock_ms"] == 1000 assert exported[0]["timings"]["intervals"][0]["gap_ms"] == 1000 + + +def test_corrupt_timestamp_rows_count_as_missing_instead_of_aborting_export(tmp_path): + db = SessionDB(db_path=tmp_path / "state.db") + try: + db.create_session(session_id="s1", source="cli", model="test-model") + db.append_message("s1", "user", "hello", timestamp=10.0) + db.append_message("s1", "assistant", "hi", timestamp=11.0) + # Writers refuse bad stamps; emulate a pre-existing corrupt row directly. + db._conn.execute("UPDATE messages SET timestamp = 8.4e252 WHERE id = 2") + db._conn.commit() + timings = db.export_session("s1")["timings"] + finally: + db.close() + + assert timings["message_timestamps"] == {"available": 1, "missing": 1} + assert timings["wall_clock_ms"] == 0 and timings["intervals"] == []