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:
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user