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
This commit is contained in:
teknium1
2026-09-18 00:49:38 -07:00
committed by Teknium
parent 9ec089bf34
commit a834988165
3 changed files with 39 additions and 2 deletions

View File

@@ -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",
)

View File

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

View File

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