fix(doctor): name the processes holding a retired WAL generation instead of a green state.db

After the deleted-WAL guard fires, every SessionDB open raises
DeletedWalGenerationError, yet `hermes doctor` printed "✓ state.db exists"
and "0 process(es) holding the DB open" with rc 0 (#110054). It only counted
holders of the CURRENT state.db inode; the processes wedging the store are
the ones holding the RETIRED -wal/-shm inodes, and nothing in-product named
them — while the chat explainer sends users to exactly this doctor.

Ask iter_deleted_sqlite_sidecar_holders (the helper the guard itself uses)
first. When it names holders: warn with their PIDs, record a stop-them issue
line pinned to the active profile, and return before the health/stats/WAL
probes — a read-only connect on the new generation is another opener, and
--fix must not checkpoint under them. No kill path, no new module.
This commit is contained in:
kshitijk4poor
2026-09-19 23:37:27 +05:30
committed by kshitij
parent f0d8efe4e7
commit 3d67226bea
2 changed files with 47 additions and 0 deletions

View File

@@ -344,11 +344,32 @@ def _state_db_wal(f: Finding, should_fix: bool, state_db_path: Path) -> None:
check_info(f"WAL file is {size // (1024*1024)} MB (normal for active sessions)")
def _retired_wal_holders(f: Finding, state_db_path: Path, _DHH: str) -> bool:
"""Name the processes holding a retired -wal/-shm generation (#110054). Every SessionDB open is
refused while they live, and the current inode has no holders, so the plain holder count says
"0 holding the DB open" beside a green state.db line — the opposite of the truth."""
from hermes_constants import profile_cli_selector
from hermes_state_dbfile import iter_deleted_sqlite_sidecar_holders
pids = list(dict.fromkeys(pid for pid, _ in iter_deleted_sqlite_sidecar_holders(state_db_path)))
if not pids:
return False
rendered = ", ".join(f"pid {pid}" for pid in pids)
check_warn(f"{_DHH}/state.db: {len(pids)} process(es) still hold a retired WAL generation ({rendered})",
"(every new session refuses to open until they exit; health/stats probes skipped)")
f.issues.append(f"state.db retired WAL generation held by {rendered} — stop them: "
f"'hermes {profile_cli_selector()}gateway stop', quit the Desktop app / dashboard, "
"then rerun 'hermes doctor'")
return True
@doctor_check()
def _check_state_db(should_fix: bool, f: Finding) -> None:
"""state.db session count, FTS write health, schema repair, stats snapshot, WAL size."""
from hermes_cli.doctor import HERMES_HOME, _DHH
state_db_path = HERMES_HOME / "state.db"
# A read-only connect on the new generation is itself another opener, so nothing below may run.
if _retired_wal_holders(f, state_db_path, _DHH):
return
if state_db_path.exists():
_state_db_health(f, should_fix, state_db_path, _DHH)
_state_db_stats(f.issues, state_db_path)

View File

@@ -49,3 +49,29 @@ def test_wal_checkpoint_skipped_while_live_writer_holds_db(tmp_path):
assert finding.fixed == 0
assert any("gateway" in issue for issue in finding.issues)
def test_doctor_names_retired_wal_holders_instead_of_healthy_state_db(tmp_path, monkeypatch, capsys):
"""After the deleted-WAL guard fires (#110054), doctor must name the PIDs holding the retired
generation, must not print a healthy state.db line, and must not open the store itself (the
health probe is another opener) nor checkpoint under --fix."""
import hermes_cli.doctor as doctor
import hermes_cli.doctor_state as doctor_state
import hermes_state_dbfile
db = tmp_path / "state.db"
db.write_bytes(b"")
monkeypatch.setattr(doctor, "HERMES_HOME", tmp_path)
monkeypatch.setattr(hermes_state_dbfile, "iter_deleted_sqlite_sidecar_holders",
lambda path: [(4242, f"{path}-wal"), (4242, f"{path}-shm")])
probed = []
monkeypatch.setattr(doctor_state, "_state_db_health", lambda *a, **k: probed.append(a))
monkeypatch.setattr(doctor_state, "_state_db_wal", lambda *a, **k: probed.append(a))
finding = doctor_state._check_state_db(True)
out = capsys.readouterr().out
assert "4242" in out and "retired WAL" in out
assert "✓" not in out
assert probed == [] and finding.fixed == 0
assert any("4242" in issue and "gateway stop" in issue for issue in finding.issues)