From a8349881652eda647b3f7d1709a09f55f19151f8 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 18 Sep 2026 00:49:38 -0700 Subject: [PATCH] docs(state): name recovered placeholders in the stale-open sweep; exercise the real recovery path sessions.md lists the state-owned sources the auto-prune sweep closes; add `recovered`. The second test drives `_reconstruct_missing_sessions` itself so the placeholder shape the sweep must age out is the one recovery writes, and keeps a fresh placeholder open as the control. Fixes #114730 --- hermes_state.py | 3 +- .../test_sweep_orphaned_sessions.py | 34 +++++++++++++++++++ website/docs/user-guide/sessions.md | 4 ++- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 8e65eae035..73b3dd8e54 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -448,7 +448,8 @@ class SessionDB( # Only these state-owned producers join automatic stale-open reconciliation; messaging/UI # sources have their own lifecycle owners; unknown sources fail closed. - # See #60609. + # See #60609. `recovered` = placeholders `hermes sessions recover` synthesizes for + # orphaned messages (no live owner, never stamped ended_at); without it they are immortal. _AUTO_PRUNE_STALE_OPEN_SOURCES: Tuple[str, ...] = ( "cli", "cron", "kanban", "acp", "api_server", "subagent", "tool", "recovered", ) diff --git a/tests/hermes_state/test_sweep_orphaned_sessions.py b/tests/hermes_state/test_sweep_orphaned_sessions.py index 992af73aa8..a82c266d72 100644 --- a/tests/hermes_state/test_sweep_orphaned_sessions.py +++ b/tests/hermes_state/test_sweep_orphaned_sessions.py @@ -601,6 +601,40 @@ class TestSweepOrphanedSessions: assert second["pruned"] == 1 assert db.get_session("recovered-0") is None + def test_real_recovery_placeholders_age_out_but_fresh_one_stays_open(self, db, tmp_path): + """Through the real `_reconstruct_missing_sessions` path: a stale placeholder is closed + then deleted; a fresh one is left open (source survives as 'recovered').""" + import sqlite3 + + from hermes_cli.session_recovery import _reconstruct_missing_sessions + + stale = time.time() - 200 * 86400 + for sid in ("lost-old", "lost-fresh"): + db.create_session(sid, source="cli") + db.append_message(sid, role="user", content="salvaged") + db.close() + raw = sqlite3.connect(tmp_path / "state.db") + raw.execute("PRAGMA foreign_keys=OFF") + raw.execute("DELETE FROM sessions WHERE id IN ('lost-old', 'lost-fresh')") + assert _reconstruct_missing_sessions(raw)["sessions_reconstructed"] == 2 + raw.execute("UPDATE sessions SET started_at = ?, last_activity_at = ? WHERE id = 'lost-old'", (stale, stale)) + raw.execute("UPDATE messages SET timestamp = ? WHERE session_id = 'lost-old'", (stale,)) + raw.commit() + raw.close() + + db = SessionDB(tmp_path / "state.db") + first = db.maybe_auto_prune_and_vacuum(retention_days=90, min_interval_hours=0, vacuum=False) + assert first["closed"] == 1 + assert db.get_session("lost-old")["end_reason"] == "startup_orphan_reap" + assert db.get_session("lost-fresh")["ended_at"] is None + + db._conn.execute("UPDATE sessions SET ended_at = ended_at - 91 * 86400 WHERE id = 'lost-old'") + db._conn.commit() + second = db.maybe_auto_prune_and_vacuum(retention_days=90, min_interval_hours=0, vacuum=False) + assert second["pruned"] == 1 + assert db.get_session("lost-old") is None + assert db.get_session("lost-fresh")["source"] == "recovered" + def test_zero_ttl_is_noop(self, db): stale = time.time() - 8 * 3600 _make_session(db, "stale-tui", source="tui", started_at=stale, message_at=stale) diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index f64e552966..879c1bb87e 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -950,7 +950,9 @@ workers, subagents, one-shot CLI runs — can die without ever marking their session ended, and pruning only deletes *ended* rows. To keep those from accumulating forever, each auto-prune pass also *closes* open sessions from those state-owned sources (`cli`, `cron`, `kanban`, `acp`, `api_server`, -`subagent`, `tool`) whose last activity is older than `retention_days` +`subagent`, `tool`, plus the `recovered` placeholders that +`hermes sessions recover` synthesizes for orphaned messages) whose last +activity is older than `retention_days` (`end_reason: startup_orphan_reap`). Closing is non-destructive — the session stays resumable — and the row is aged from its close, so it is only deleted by a *later* pass after a further full retention window. Messaging