From ec2a0f8c1ef534171f878d302a8a796d22cba7e5 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:52:06 -0700 Subject: [PATCH] fix(sessions): point failed in-place repair at offline recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed `hermes sessions repair` printed "keep state.db and the backup" and stopped, so a user whose sessions had vanished had no way to discover that a non-destructive recovery path exists — the reported dead end. The failure branch now names the next command, read-only step first, seeded with the backup path it just preserved. Covered by a CLI-surface regression test that drives the real subprocess against an unrepairable database. --- hermes_cli/main.py | 13 ++++++++ tests/hermes_cli/test_session_recovery.py | 36 +++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a1144314a56c..1db8d66546e4 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -16133,6 +16133,19 @@ def main(): if report.get("backup_path"): print(f" A backup is preserved at: {report['backup_path']}") print(" Keep state.db and the backup; do not delete them.") + # Without this pointer the user is at a dead end: in-place + # repair has failed and nothing tells them the non-destructive + # offline recovery path exists. Lead with --inspect-only so + # they confirm the data is readable before writing anything. + print("") + print(" Next step — offline recovery (never modifies the source):") + source_hint = report.get("backup_path") or db_path + print(f" hermes sessions recover --source {source_hint} \\") + print(" --inspect-only") + print(" If that reports the data is recoverable, rebuild it into") + print(" a NEW database (the active one is left untouched):") + print(f" hermes sessions recover --source {source_hint} \\") + print(" --output recovered-state.db") return if action == "recover": diff --git a/tests/hermes_cli/test_session_recovery.py b/tests/hermes_cli/test_session_recovery.py index eded2b8d5259..39ef1430a0d0 100644 --- a/tests/hermes_cli/test_session_recovery.py +++ b/tests/hermes_cli/test_session_recovery.py @@ -323,3 +323,39 @@ def test_cli_recover_writes_verified_report_without_touching_source( assert report["complete"] is True assert report["verification"]["table_counts"]["sessions"] == expected["sessions"] assert report["verification"]["table_counts"]["messages"] == expected["messages"] + + +def test_failed_repair_points_the_user_at_offline_recovery(tmp_path: Path) -> None: + """A failed in-place repair must name the offline recovery command. + + This is the reported dead end: `hermes sessions repair` exhausts its + in-place ladder, prints "keep the files", and stops -- leaving the user + with no idea that a non-destructive recovery path exists. The failure + branch has to hand them the next command, inspection first, seeded with + the preserved backup it just made. + """ + hermes_home = tmp_path / "isolated-hermes-home" + hermes_home.mkdir() + # Not a database at all -> every in-place repair strategy fails. + (hermes_home / "state.db").write_bytes(b"SQLite format 3\x00" + b"\x7f" * 2048) + env = os.environ.copy() + env["HERMES_HOME"] = str(hermes_home) + + result = subprocess.run( + [sys.executable, "-m", "hermes_cli.main", "sessions", "repair"], + cwd=Path(__file__).resolve().parents[2], + env=env, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + + combined = result.stdout + result.stderr + assert "Repair failed" in combined, combined + # The pointer, and specifically the read-only step first. + assert "sessions recover" in combined, combined + assert "--inspect-only" in combined, combined + # It must offer the source it actually preserved, not a placeholder. + assert "--source" in combined, combined + assert "malformed-backup" in combined, combined