## What does this PR do? Makes observational CLI commands open `state.db` in read-only mode, so they can inspect a live Hermes installation without participating in writable WAL lifecycle handling. ### Symptom Running `hermes status`, `hermes doctor` without `--fix`, `hermes sessions list`, `hermes sessions stats`, or `hermes insights` while a gateway owns the store could open another writable session handle. The live turn could then lose its WAL generation and stop. ### Impact Users inspecting status or session history during an active turn could lose that in-flight turn and leave the gateway halted until recovery. ### Bug Cause **Trigger:** observational CLI helpers constructed `SessionDB()` with its writable default. **Causal chain:** 1. A live gateway holds the `state.db` WAL generation. 2. A nested observational CLI command opens a second writable handle. 3. Writable-handle close behavior can participate in WAL lifecycle work and retire the generation used by the live writer. **Why it is wrong:** these commands only query state and should not have writer privileges. **Working sibling / contrast:** repair and mutating session commands still use writable access intentionally. **Ruled out:** no state schema, migration, or WAL checkpoint implementation changes are included. ### Fix Routes status, non-fixing doctor state inspection, sessions list/stats, and both insights entrypoints through `SessionDB(read_only=True)`. Repair and mutating paths remain writable, and regression tests cover WAL preservation with a live writer. ## Related Issue Fixes #110173 ## Type of Change - ✅ Bug fix (non-breaking change that fixes an issue) ## Changes Made - `hermes_cli/status.py`, `hermes_cli/doctor_state.py`, and insights helpers — open observational state readers read-only. - `hermes_cli/sessions_cmd.py` — make only `list` and `stats` read-only; retain writable access for mutations. - `tests/hermes_cli/test_observational_sessiondb_modes.py` — verify access modes and a live writer's WAL remains usable. ## How to Test - ✅ `scripts/run_tests.sh tests/hermes_cli/test_observational_sessiondb_modes.py tests/hermes_cli/test_cli_insights_command.py` — 9 passed. - ✅ `scripts/run_tests.sh tests/hermes_cli/test_doctor.py tests/hermes_cli/test_doctor_structural_corruption.py tests/hermes_cli/test_sessions_error_exit_codes.py` — 75 passed; two sandbox-only failures came from blocked host process/symlink operations. - ✅ A live `SessionDB` writer remains able to create and retrieve a session after `sessions stats` reads the store. ## Checklist ### Code - ✅ I've read the Contributing Guide - ✅ My commit messages follow Conventional Commits - ✅ I searched for existing PRs to make sure this isn't a duplicate - ✅ My PR contains only changes related to this fix - ✅ I've run relevant tests locally (see How to Test) - ✅ I've added tests for my changes - ✅ I've tested on my platform: macOS ### Documentation & Housekeeping - ✅ Documentation update: N/A - ✅ `cli-config.yaml.example`: N/A - ✅ `CONTRIBUTING.md` or `AGENTS.md`: N/A - ✅ Cross-platform impact considered - ✅ Tool descriptions/schemas: N/A
136 lines
4.1 KiB
Python
136 lines
4.1 KiB
Python
import sys
|
|
|
|
import pytest
|
|
|
|
|
|
def test_sessions_delete_accepts_unique_id_prefix(monkeypatch, capsys):
|
|
import hermes_cli.main as main_mod
|
|
import hermes_state
|
|
|
|
captured = {}
|
|
|
|
class FakeDB:
|
|
def resolve_session_id(self, session_id):
|
|
captured["resolved_from"] = session_id
|
|
return "20260315_092437_c9a6ff"
|
|
|
|
def get_session(self, session_id):
|
|
return {"id": session_id, "pinned": 0}
|
|
|
|
def delete_session(self, session_id, **kwargs):
|
|
captured["deleted"] = session_id
|
|
return True
|
|
|
|
def close(self):
|
|
captured["closed"] = True
|
|
|
|
monkeypatch.setattr(hermes_state, "SessionDB", lambda *args, **kwargs: FakeDB())
|
|
monkeypatch.setattr(
|
|
sys,
|
|
"argv",
|
|
["hermes", "sessions", "delete", "20260315_092437_c9a6", "--yes"],
|
|
)
|
|
|
|
main_mod.main()
|
|
|
|
output = capsys.readouterr().out
|
|
assert captured == {
|
|
"resolved_from": "20260315_092437_c9a6",
|
|
"deleted": "20260315_092437_c9a6ff",
|
|
"closed": True,
|
|
}
|
|
assert "Deleted session '20260315_092437_c9a6ff'." in output
|
|
|
|
|
|
def _run_prune(monkeypatch, capsys, argv_tail, candidates=None, skipped_open=0):
|
|
"""Run `hermes sessions prune <argv_tail>` against a FakeDB, capturing
|
|
the filter kwargs passed to list_prune_candidates. Auto-confirms."""
|
|
import hermes_cli.main as main_mod
|
|
import hermes_state
|
|
|
|
seen = {}
|
|
rows = candidates if candidates is not None else [
|
|
{
|
|
"id": "20260101_000000_aaaaaa",
|
|
"source": "cron",
|
|
"title": "oldest run",
|
|
"started_at": 1_600_000_000.0,
|
|
"last_active": 1_600_000_050.0,
|
|
"ended_at": 1_600_000_100.0,
|
|
"message_count": 2,
|
|
"archived": 0,
|
|
},
|
|
{
|
|
"id": "20260601_000000_bbbbbb",
|
|
"source": "cron",
|
|
"title": "newest run",
|
|
"started_at": 1_700_000_000.0,
|
|
"last_active": 1_700_000_050.0,
|
|
"ended_at": 1_700_000_100.0,
|
|
"message_count": 4,
|
|
"archived": 0,
|
|
},
|
|
]
|
|
|
|
class FakeDB:
|
|
def list_prune_candidates(self, **kwargs):
|
|
seen.update(kwargs)
|
|
return rows
|
|
|
|
def count_open_prune_matches(self, **kwargs):
|
|
assert kwargs == seen
|
|
return skipped_open
|
|
|
|
def count_prune_matches(self, **kwargs):
|
|
return len(rows)
|
|
|
|
def prune_sessions(self, **kwargs):
|
|
return len(rows)
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
monkeypatch.setattr(hermes_state, "SessionDB", lambda *args, **kwargs: FakeDB())
|
|
monkeypatch.setattr(
|
|
sys, "argv", ["hermes", "sessions", "prune", *argv_tail]
|
|
)
|
|
monkeypatch.setattr("builtins.input", lambda _prompt="": "y")
|
|
main_mod.main()
|
|
return seen, capsys.readouterr().out
|
|
|
|
|
|
def test_sessions_prune_bare_keeps_90_day_default(monkeypatch, capsys):
|
|
"""A truly bare `hermes sessions prune` keeps the implicit 90-day cutoff."""
|
|
import time as _time
|
|
|
|
filters, _out = _run_prune(monkeypatch, capsys, [])
|
|
assert filters["last_active_before"] is not None
|
|
assert filters["last_active_before"] == pytest.approx(
|
|
_time.time() - 90 * 86400, abs=60
|
|
)
|
|
|
|
|
|
def test_sessions_prune_preview_shows_oldest_newest(monkeypatch, capsys):
|
|
"""Confirmation preview surfaces count + oldest/newest session times."""
|
|
from hermes_cli.session_filters import format_epoch
|
|
|
|
_filters, out = _run_prune(monkeypatch, capsys, ["--source", "cron"])
|
|
assert "2 session(s) match" in out
|
|
assert f"oldest activity {format_epoch(1_600_000_050.0)}" in out
|
|
assert f"newest activity {format_epoch(1_700_000_050.0)}" in out
|
|
|
|
|
|
def test_sessions_prune_surfaces_matching_open_sessions(monkeypatch, capsys):
|
|
_filters, out = _run_prune(
|
|
monkeypatch,
|
|
capsys,
|
|
["--source", "cron"],
|
|
candidates=[],
|
|
skipped_open=2,
|
|
)
|
|
|
|
assert "2 open sessions also match these filters" in out
|
|
assert "prune only deletes ended sessions" in out
|
|
assert "hermes sessions delete <id>" in out
|
|
assert "No sessions match" in out
|