"""A failed state.db schema repair must preserve the recovery image. For rollback-journal databases the failed-repair invariant is byte identity: the main file and its sidecars must be unchanged. WAL mode has an important qualification: SQLite may checkpoint committed frames when a connection is opened or closed, so the main file's bytes are not themselves the recovery image. WAL tests therefore assert committed-row and recovery semantics, while the byte-identity assertions remain on the DELETE/rollback-journal path. Reported incident: the automatic repair path deleted a user's transcripts and then reported that it had failed. ``_repair_state_db_schema_locked`` ran its strategies on the live file, and Strategy 2 ends in ``VACUUM``:: PRAGMA writable_schema=ON DELETE FROM sqlite_master WHERE name LIKE 'messages_fts%' PRAGMA writable_schema=OFF VACUUM VACUUM does not preserve what it cannot parse — it rebuilds the file from the schema SQLite can still read. When the damage IS in the schema b-tree (page 1's child pointers aimed at data pages, which is exactly the ``malformed database schema ()`` class this function exists to handle), the rebuild drops every table hanging off the unreadable part. Measured on the reporting install: ``state.db`` went from 3048 pages / 29 sessions / 2537 messages to 113 pages, in place. The probe that follows then correctly reported the file was *still* malformed, so the function returned ``repaired=False`` with "manual restore from backup may be required" — after the only live copy had already been gutted. Destroying the data and reporting the repair failed are not mutually exclusive outcomes, and nothing in the code treated them as a contradiction. The pre-repair backup (#69603) is a forensic artefact, not a recovery path: nothing reads it back. So the invariant under test is the stronger one — a repair that does not succeed must not change the file at all — plus the structural assertion that makes it hold: the strategies never receive the live database. Fix under test: every strategy runs on a ``.repair-scratch`` snapshot and is copied back through SQLite's transactional backup API only once the result is proven to open cleanly. Mutation-checked: pointing ``_run_repair_strategies`` back at ``db_path`` instead of the scratch copy fails ``test_failed_repair_leaves_the_original_byte_identical`` and ``test_strategies_never_receive_the_live_database``. """ from __future__ import annotations import contextlib import hashlib import multiprocessing import os import shutil import sqlite3 import struct import threading import time from pathlib import Path from types import SimpleNamespace import pytest import hermes_state from hermes_state import repair_state_db_schema PAGE_SIZE = 4096 def _writer_after_stage( db_path: str, ready: "multiprocessing.synchronize.Event", start: "multiprocessing.synchronize.Event", result: "multiprocessing.queues.Queue", ) -> None: """Try one real cross-process write after staging has begun. The repair process owns the SQLite exclusion for the complete stage/strategy/promotion interval. A writer is allowed to fail or to wait until that interval ends and commit afterwards; what is forbidden is a successful commit that promotion silently overwrites. """ conn = sqlite3.connect(db_path, timeout=0.75, isolation_level=None) try: ready.set() if not start.wait(20): result.put(("not-started", "repair did not reach staging")) return try: conn.execute( "INSERT INTO messages (body) VALUES ('committed-after-stage')" ) result.put(("committed", None)) except sqlite3.Error as exc: result.put(("failed", str(exc))) finally: conn.close() def _make_repair_test_db(path: Path, *, journal_mode: str = "delete") -> None: conn = sqlite3.connect(str(path), isolation_level=None) try: conn.execute("CREATE TABLE sessions (id INTEGER PRIMARY KEY, name TEXT)") conn.execute("CREATE TABLE messages (id INTEGER PRIMARY KEY, body TEXT)") conn.execute("INSERT INTO sessions (name) VALUES ('seed')") conn.execute("INSERT INTO messages (body) VALUES ('seed')") actual = conn.execute(f"PRAGMA journal_mode={journal_mode}").fetchone()[0] if journal_mode == "wal" and actual != "wal": pytest.skip("SQLite build/filesystem does not support WAL") finally: conn.close() def _leave_hot_wal_row(db_path: str) -> None: """Commit a WAL frame, then die without letting sqlite3 close the DB.""" conn = sqlite3.connect(db_path, isolation_level=None) conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA wal_autocheckpoint=0") conn.execute("INSERT INTO messages (body) VALUES ('committed-wal-row')") conn.commit() # Deliberately bypass Connection.close(): the next process must recover # the committed row from the hot WAL image, not from a pre-checkpoint main. os._exit(0) def _probe_repair_lock_from_child(db_path: str, result) -> None: """Attempt the repair lock with a short timeout from another process.""" hermes_state._REPAIR_LOCK_TIMEOUT_SECONDS = 0.5 with hermes_state._cross_process_repair_lock(Path(db_path)) as holding: result.put(holding) def _write_populated_db(path: Path, *, sessions: int = 3, messages: int = 25) -> None: conn = sqlite3.connect(str(path)) conn.execute(f"PRAGMA page_size={PAGE_SIZE}") conn.execute("CREATE TABLE sessions (id INTEGER PRIMARY KEY, name TEXT)") conn.execute("CREATE TABLE messages (id INTEGER PRIMARY KEY, body TEXT)") conn.executemany( "INSERT INTO sessions (name) VALUES (?)", [(f"session-{i}",) for i in range(sessions)], ) conn.executemany( "INSERT INTO messages (body) VALUES (?)", [(f"message body {i}" * 20,) for i in range(messages)], ) conn.commit() conn.close() def _break_the_schema_btree(path: Path) -> None: """Aim page 1's rightmost child at a data page. This is the shape of the reported corruption: ``sqlite_master``'s b-tree resolves to pages holding table content, so SQLite reports "malformed database schema ()" — the parentheses empty because the bogus row's name is not text. """ data = bytearray(path.read_bytes()) page_count = struct.unpack(">I", data[28:32])[0] assert page_count >= 3, "fixture needs a multi-page database" # Byte 100 is page 1's b-tree header; offset 108 is the rightmost pointer # on an interior page. Force page 1 to be interior and point it at the # last page, which holds table data rather than schema records. data[100] = 0x05 struct.pack_into(">H", data, 103, 1) # one cell struct.pack_into(">I", data, 108, page_count) # rightmost -> data page struct.pack_into(">H", data, 112, PAGE_SIZE - 6) # cell pointer struct.pack_into(">I", data, PAGE_SIZE - 6, page_count) path.write_bytes(bytes(data)) @pytest.fixture def corrupt_db(tmp_path: Path) -> Path: path = tmp_path / "state.db" _write_populated_db(path) _break_the_schema_btree(path) with pytest.raises(sqlite3.DatabaseError): conn = sqlite3.connect(str(path)) try: conn.execute("SELECT * FROM sessions").fetchall() finally: conn.close() return path # --------------------------------------------------------------------------- # The structural guarantee # --------------------------------------------------------------------------- def test_strategies_never_receive_the_live_database(corrupt_db, monkeypatch): """Every strategy mutates its argument in place, so the property that makes them safe is simply that the argument is never the real file.""" seen: list[Path] = [] real = hermes_state._run_repair_strategies def spy(path, report): seen.append(path) return real(path, report) monkeypatch.setattr(hermes_state, "_run_repair_strategies", spy) repair_state_db_schema(corrupt_db) assert seen, "the repair path did not run at all" for path in seen: assert path != corrupt_db assert path.name.endswith(".repair-scratch") # --------------------------------------------------------------------------- # The regression # --------------------------------------------------------------------------- def test_failed_repair_leaves_the_original_byte_identical(corrupt_db): before = hashlib.sha256(corrupt_db.read_bytes()).hexdigest() report = repair_state_db_schema(corrupt_db) after = hashlib.sha256(corrupt_db.read_bytes()).hexdigest() assert not report.get("repaired"), ( "fixture precondition: this corruption is not automatically repairable" ) assert before == after, ( "a repair that FAILED rewrote the database anyway — this is the " "reported data loss: 29 sessions / 2537 messages became 113 pages " "while the function reported 'manual restore may be required'" ) def test_failed_repair_leaves_no_scratch_file_behind(corrupt_db): """A half-repaired file beside the DB is a trap for the next probe.""" repair_state_db_schema(corrupt_db) leftovers = sorted(p.name for p in corrupt_db.parent.glob("*repair-scratch*")) assert leftovers == [] def test_failed_repair_still_takes_the_forensic_backup(corrupt_db): """Non-destructive repair does not make the #69603 backup redundant.""" report = repair_state_db_schema(corrupt_db) assert report["backup_path"], "the pre-repair forensic copy is still required" assert Path(report["backup_path"]).exists() # --------------------------------------------------------------------------- # ...and a repair that DOES succeed must still land on the original path # --------------------------------------------------------------------------- def test_successful_repair_is_promoted_over_the_original(tmp_path, monkeypatch): """The scratch copy is a staging area, not a detour: a strategy that heals the copy must leave the healed bytes at ``db_path``.""" db = tmp_path / "state.db" _write_populated_db(db) # Force the "already healthy" short-circuit off so the staging path runs, # and have the strategy pass mark a repair after writing a marker row. monkeypatch.setattr( hermes_state, "_db_opens_cleanly", lambda path: "forced-unhealthy" ) def fake_strategies(scratch_path, report): conn = sqlite3.connect(str(scratch_path)) conn.execute("INSERT INTO sessions (name) VALUES ('healed-on-scratch')") conn.commit() conn.close() report["repaired"] = True report["strategy"] = "test_strategy" return report monkeypatch.setattr(hermes_state, "_run_repair_strategies", fake_strategies) report = repair_state_db_schema(db) assert report["repaired"] is True conn = sqlite3.connect(str(db)) try: names = [r[0] for r in conn.execute("SELECT name FROM sessions")] finally: conn.close() assert "healed-on-scratch" in names, ( "the repaired copy was never promoted over the original" ) assert not list(db.parent.glob("*repair-scratch*")) @pytest.mark.parametrize("journal_mode", ("delete", "wal")) def test_committed_writer_after_staging_is_never_lost( tmp_path, monkeypatch, journal_mode ): """A writer racing the repair lifecycle cannot be overwritten. This uses the real orchestration and both SQLite online-backup calls. The strategy hook is only a deterministic latch: it writes the repaired marker to the real scratch database, then gives a separate process a chance to attempt its commit before promotion. A successful writer must still be present after repair; a locked/timeout writer is also valid. """ db = tmp_path / "state.db" _make_repair_test_db(db, journal_mode=journal_mode) monkeypatch.setattr( hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy" ) ready = multiprocessing.get_context("spawn").Event() start = multiprocessing.get_context("spawn").Event() result = multiprocessing.get_context("spawn").Queue() writer = multiprocessing.get_context("spawn").Process( target=_writer_after_stage, args=(str(db), ready, start, result), ) writer.start() assert ready.wait(20), "writer process did not initialize" def staged_strategy(scratch_path, report): with sqlite3.connect(str(scratch_path)) as conn: conn.execute( "INSERT INTO sessions (name) VALUES ('repaired-before-race')" ) conn.commit() start.set() # Make the race deterministic: an unguarded implementation lets the # child commit here, after which promotion silently erases its row. time.sleep(1.0) report["repaired"] = True report["strategy"] = "race_test" return report monkeypatch.setattr(hermes_state, "_run_repair_strategies", staged_strategy) report = repair_state_db_schema(db, backup=False) writer.join(20) if writer.is_alive(): writer.terminate() writer.join(5) pytest.fail("writer process did not finish") outcome, detail = result.get(timeout=5) assert outcome in {"failed", "committed"}, (outcome, detail) assert report["repaired"] is True with sqlite3.connect(str(db)) as conn: rows = { body for (body,) in conn.execute("SELECT body FROM messages") } names = { name for (name,) in conn.execute("SELECT name FROM sessions") } assert "repaired-before-race" in names if outcome == "committed": assert "committed-after-stage" in rows, ( "a writer reported a successful commit, but transactional repair " "promotion silently overwrote it" ) def test_environmental_aborts_do_not_burn_repair_ledger(tmp_path, monkeypatch): """Three disk/staging aborts leave the actual strategy budget untouched.""" db = tmp_path / "state.db" _make_repair_test_db(db) monkeypatch.setattr( hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy" ) monkeypatch.setattr( hermes_state, "_repair_scratch_space_error", lambda _path: "temporary disk pressure", ) for _ in range(3): report = repair_state_db_schema(db, backup=False) assert report["repaired"] is False assert report["error"] == "temporary disk pressure" ledger_path = hermes_state._repair_ledger_path(db) assert not ledger_path.exists(), "environmental aborts must not consume attempts" monkeypatch.setattr(hermes_state, "_repair_scratch_space_error", lambda _path: None) def successful_strategy(scratch_path, report): with sqlite3.connect(str(scratch_path)) as conn: conn.execute("INSERT INTO sessions (name) VALUES ('after-aborts')") conn.commit() report["repaired"] = True report["strategy"] = "after_environmental_aborts" return report monkeypatch.setattr(hermes_state, "_run_repair_strategies", successful_strategy) report = repair_state_db_schema(db, backup=False) assert report["repaired"] is True assert report["strategy"] == "after_environmental_aborts" def test_actual_strategy_failure_still_consumes_one_attempt(tmp_path, monkeypatch): db = tmp_path / "state.db" _make_repair_test_db(db) monkeypatch.setattr( hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy" ) def failed_strategy(_scratch_path, report): report["repaired"] = False report["strategy"] = None return report monkeypatch.setattr(hermes_state, "_run_repair_strategies", failed_strategy) report = repair_state_db_schema(db, backup=False) assert report["repaired"] is False ledger = hermes_state._read_repair_ledger(db) assert ledger["failed_attempts"] == 1 def test_repair_outcome_is_recorded_while_cross_process_lock_is_held( tmp_path, monkeypatch ): """Ledger publication must remain inside the repairer's critical section.""" db = tmp_path / "state.db" _make_repair_test_db(db) monkeypatch.setattr( hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy" ) def failed_strategy(_scratch_path, report): report["repaired"] = False report["strategy"] = None return report monkeypatch.setattr(hermes_state, "_run_repair_strategies", failed_strategy) observed = [] lock_released = threading.Event() real_repair_lock = hermes_state._cross_process_repair_lock @contextlib.contextmanager def tracking_repair_lock(path): with real_repair_lock(path) as holding: yield holding lock_released.set() monkeypatch.setattr( hermes_state, "_cross_process_repair_lock", tracking_repair_lock ) def record_outcome(_db_path, *, repaired, fingerprint=None): assert not lock_released.is_set(), ( "repair outcome was recorded after the cross-process lock released" ) context = multiprocessing.get_context("spawn") result = context.Queue() probe = context.Process( target=_probe_repair_lock_from_child, args=(str(db), result), ) probe.start() try: observed.append(result.get(timeout=5)) finally: probe.join(5) if probe.is_alive(): probe.terminate() probe.join(5) assert repaired is False monkeypatch.setattr(hermes_state, "_record_repair_outcome", record_outcome) report = repair_state_db_schema(db, backup=False) assert report["repaired"] is False assert observed == [False], ( "repair outcome was recorded after releasing the cross-process lock" ) def test_exhaustion_is_rechecked_after_acquiring_repair_lock(tmp_path, monkeypatch): """A queued repairer must not start surgery on a newly exhausted ledger.""" db = tmp_path / "state.db" _make_repair_test_db(db) exhaustion_checks = [] def exhaustion_probe(_db_path): exhaustion_checks.append(True) # The first caller's pre-lock view is stale; the queued view is # terminal because another repairer just recorded the final failure. return len(exhaustion_checks) >= 2 monkeypatch.setattr( hermes_state, "_persistent_repair_attempts_exhausted", exhaustion_probe ) monkeypatch.setattr(hermes_state, "_live_writer_holds_db", lambda _path: False) surgery_calls = [] def unexpected_surgery(_db_path, *, backup, report): surgery_calls.append(True) return report monkeypatch.setattr( hermes_state, "_repair_state_db_schema_locked", unexpected_surgery ) report = repair_state_db_schema(db, backup=False) assert report["repaired"] is False assert "automatic repair has already failed" in report["error"] assert len(exhaustion_checks) == 2 assert surgery_calls == [], "surgery ran despite the under-lock exhaustion recheck" def test_scratch_budget_counts_sidecars_in_vacuum_multiplier(tmp_path, monkeypatch): """A WAL-heavy snapshot must reserve 3x snapshot bytes, not 2x main.""" db = tmp_path / "state.db" main_bytes = 100 wal_bytes = 900 db.write_bytes(b"m" * main_bytes) db.with_name(db.name + "-wal").write_bytes(b"w" * wal_bytes) total = 10_000_000_000 headroom = hermes_state._repair_backup_headroom_bytes(total) # This exactly satisfies the obsolete ``snapshot + 2*main + headroom`` # calculation, but is below the corrected ``3*snapshot + headroom``. old_required = main_bytes + wal_bytes + (2 * main_bytes) + headroom monkeypatch.setattr( shutil, "disk_usage", lambda _path: SimpleNamespace(total=total, free=old_required), ) error = hermes_state._repair_scratch_space_error(db) assert error is not None assert "VACUUM may need another" in error def test_environmental_promotion_failures_do_not_burn_ledger( tmp_path, monkeypatch ): """Transient promotion disk errors remain retriable across three passes.""" db = tmp_path / "state.db" _make_repair_test_db(db) monkeypatch.setattr( hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy" ) def successful_strategy(_scratch_path, report): report["repaired"] = True report["strategy"] = "promotion_environment_test" return report monkeypatch.setattr(hermes_state, "_run_repair_strategies", successful_strategy) real_copy = hermes_state._copy_database_snapshot copy_calls = 0 def fail_promotion_three_times(source, destination, **kwargs): nonlocal copy_calls copy_calls += 1 if copy_calls in (2, 4, 6): raise sqlite3.OperationalError("database or disk is full") return real_copy(source, destination, **kwargs) monkeypatch.setattr( hermes_state, "_copy_database_snapshot", fail_promotion_three_times ) for _ in range(3): report = repair_state_db_schema(db, backup=False) assert report["repaired"] is False assert "could not be promoted" in report["error"] ledger = hermes_state._read_repair_ledger(db) assert ledger.get("failed_attempts", 0) == 0 monkeypatch.setattr(hermes_state, "_copy_database_snapshot", real_copy) report = repair_state_db_schema(db, backup=False) assert report["repaired"] is True assert report["strategy"] == "promotion_environment_test" def test_corrupt_promotion_failure_consumes_one_attempt(tmp_path, monkeypatch): """A deterministic malformed-image promotion failure is budgeted.""" db = tmp_path / "state.db" _make_repair_test_db(db) monkeypatch.setattr( hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy" ) def successful_strategy(_scratch_path, report): report["repaired"] = True report["strategy"] = "corrupt-promotion-test" return report monkeypatch.setattr(hermes_state, "_run_repair_strategies", successful_strategy) real_copy = hermes_state._copy_database_snapshot calls = 0 def fail_promotion_with_corruption(source, destination, **kwargs): nonlocal calls calls += 1 if calls == 2: raise sqlite3.DatabaseError("database disk image is malformed") return real_copy(source, destination, **kwargs) monkeypatch.setattr( hermes_state, "_copy_database_snapshot", fail_promotion_with_corruption ) report = repair_state_db_schema(db, backup=False) assert report["repaired"] is False assert hermes_state._read_repair_ledger(db)["failed_attempts"] == 1 def test_snapshot_includes_committed_wal_frames(tmp_path): """The recovery image includes commits not checkpointed to the WAL main.""" db = tmp_path / "state.db" conn = sqlite3.connect(str(db), isolation_level=None) try: conn.execute("CREATE TABLE messages (body TEXT)") assert conn.execute("PRAGMA journal_mode=WAL").fetchone()[0] == "wal" conn.execute("PRAGMA wal_autocheckpoint=0") conn.execute("INSERT INTO messages VALUES ('committed-only-in-wal')") assert db.with_name(db.name + "-wal").exists() scratch = tmp_path / "state.db.repair-scratch" hermes_state._copy_database_snapshot(db, scratch) with sqlite3.connect(str(scratch)) as check: assert check.execute("SELECT body FROM messages").fetchall() == [ ("committed-only-in-wal",) ] finally: conn.close() @pytest.mark.requires_wal def test_failed_wal_repair_preserves_committed_rows_semantically( tmp_path, monkeypatch ): """WAL correctness is about committed recovery state, not main-file bytes.""" db = tmp_path / "state.db" _make_repair_test_db(db) context = multiprocessing.get_context("spawn") writer = context.Process(target=_leave_hot_wal_row, args=(str(db),)) writer.start() writer.join(20) if writer.is_alive(): writer.terminate() writer.join(5) pytest.fail("hot-WAL fixture process did not finish") assert writer.exitcode == 0, "hot-WAL fixture process failed" if not db.with_name(db.name + "-wal").exists(): pytest.skip("SQLite/filesystem did not retain a hot WAL sidecar") monkeypatch.setattr( hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy" ) strategy_calls = [] def failed_strategy(_scratch_path, report): strategy_calls.append(True) report["repaired"] = False report["strategy"] = None return report monkeypatch.setattr(hermes_state, "_run_repair_strategies", failed_strategy) report = repair_state_db_schema(db, backup=False) assert report["repaired"] is False assert strategy_calls == [True], "the test must exercise strategy failure" with sqlite3.connect(str(db)) as conn: bodies = { body for (body,) in conn.execute("SELECT body FROM messages") } assert {"seed", "committed-wal-row"} <= bodies def test_snapshot_deadline_has_a_floor_and_scales_with_source_size( tmp_path, monkeypatch ): """Large snapshots get more than the lock floor without huge fixtures.""" deadline = getattr(hermes_state, "_repair_snapshot_timeout_seconds", None) assert callable(deadline), "repair snapshots need a size-scaled deadline" # Lower the throughput only for this arithmetic test so a 72 MiB fixture # crosses the floor without allocating a multi-GB file. monkeypatch.setattr( hermes_state, "_REPAIR_SNAPSHOT_MIN_THROUGHPUT_BYTES_PER_SECOND", 256 * 1024 ) db = tmp_path / "state.db" db.write_bytes(b"x" * PAGE_SIZE) small = deadline(db) # A modest sparse-ish fixture is enough to cross the 120-second floor on # the production throughput constant; no multi-GB allocation is needed. with db.open("ab") as fh: fh.truncate(64 * 1024 * 1024) db.with_name(db.name + "-wal").write_bytes(b"w" * (8 * 1024 * 1024)) large = deadline(db) assert small >= hermes_state._REPAIR_LOCK_TIMEOUT_SECONDS assert large > small def test_transactional_promotion_preserves_a_live_wal_reader(tmp_path): """Promotion must not replace the inode behind an existing reader.""" live = tmp_path / "state.db" scratch = tmp_path / "scratch.db" for path, value in ((live, "old"), (scratch, "repaired")): with sqlite3.connect(str(path)) as conn: conn.execute("CREATE TABLE messages (body TEXT)") conn.execute("INSERT INTO messages VALUES (?)", (value,)) conn.commit() if path == live: assert conn.execute("PRAGMA journal_mode=WAL").fetchone()[0] == "wal" reader = sqlite3.connect(str(live), isolation_level=None) try: reader.execute("BEGIN") assert reader.execute("SELECT body FROM messages").fetchall() == [("old",)] hermes_state._copy_database_snapshot(scratch, live) assert reader.execute("SELECT body FROM messages").fetchall() == [("old",)] with sqlite3.connect(str(live)) as fresh: assert fresh.execute("SELECT body FROM messages").fetchall() == [ ("repaired",) ] finally: reader.execute("ROLLBACK") reader.close() def test_interrupted_snapshot_rolls_back_destination(tmp_path, monkeypatch): source = tmp_path / "source.db" destination = tmp_path / "destination.db" with sqlite3.connect(str(source)) as conn: conn.execute("CREATE TABLE payloads (body BLOB)") conn.executemany( "INSERT INTO payloads VALUES (?)", [(b"x" * PAGE_SIZE,) for _ in range(400)], ) conn.commit() with sqlite3.connect(str(destination)) as conn: conn.execute("CREATE TABLE marker (value TEXT)") conn.execute("INSERT INTO marker VALUES ('original')") conn.commit() ticks = iter((0.0, hermes_state._REPAIR_LOCK_TIMEOUT_SECONDS + 1.0)) monkeypatch.setattr(hermes_state.time, "monotonic", lambda: next(ticks)) with pytest.raises(TimeoutError): hermes_state._copy_database_snapshot(source, destination) with sqlite3.connect(str(destination)) as conn: assert conn.execute("SELECT value FROM marker").fetchall() == [("original",)] def test_failed_promotion_returns_failure_and_preserves_original(tmp_path, monkeypatch): db = tmp_path / "state.db" _write_populated_db(db) before = hashlib.sha256(db.read_bytes()).hexdigest() real_copy = hermes_state._copy_database_snapshot calls = 0 def fail_second_copy(source, destination, **kwargs): nonlocal calls calls += 1 if calls == 1: return real_copy(source, destination, **kwargs) raise sqlite3.OperationalError("destination is busy") def fake_strategies(_scratch, report): report["repaired"] = True report["strategy"] = "test_strategy" return report monkeypatch.setattr( hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy" ) monkeypatch.setattr(hermes_state, "_copy_database_snapshot", fail_second_copy) monkeypatch.setattr(hermes_state, "_run_repair_strategies", fake_strategies) report = repair_state_db_schema(db, backup=False) assert report["repaired"] is False assert report["strategy"] is None assert "could not be promoted" in report["error"] assert hashlib.sha256(db.read_bytes()).hexdigest() == before assert not list(tmp_path.glob("*repair-scratch*")) def test_scratch_space_guard_accounts_for_snapshot_and_vacuum(tmp_path, monkeypatch): db = tmp_path / "state.db" db.write_bytes(b"x" * 4096) headroom = hermes_state._repair_backup_headroom_bytes(10_000_000_000) free = (3 * db.stat().st_size) + headroom - 1 usage = SimpleNamespace(total=10_000_000_000, free=free) monkeypatch.setattr(shutil, "disk_usage", lambda _path: usage) error = hermes_state._repair_scratch_space_error(db) assert error is not None assert "VACUUM may need" in error def test_stale_scratch_is_removed_before_health_check(tmp_path, monkeypatch): db = tmp_path / "state.db" _write_populated_db(db) scratch = tmp_path / "state.db.repair-scratch" scratch.write_bytes(b"crash debris" * 1000) checks: list[str] = [] def fake_health(_path): checks.append("health") assert not scratch.exists() return None monkeypatch.setattr(hermes_state, "_db_opens_cleanly", fake_health) report = repair_state_db_schema(db, backup=False) assert report["repaired"] is True assert report["strategy"] == "already_healthy" assert checks == ["health"] assert not scratch.exists() def test_stale_scratch_is_removed_before_space_check(tmp_path, monkeypatch): db = tmp_path / "state.db" _write_populated_db(db) scratch = tmp_path / "state.db.repair-scratch" scratch.write_bytes(b"crash debris" * 1000) checked_space = False monkeypatch.setattr( hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy" ) def fake_space_check(_path): nonlocal checked_space checked_space = True assert not scratch.exists() return "forced low space" monkeypatch.setattr( hermes_state, "_repair_scratch_space_error", fake_space_check ) report = repair_state_db_schema(db, backup=False) assert report["repaired"] is False assert report["error"] == "forced low space" assert checked_space is True assert not scratch.exists() def test_stale_scratch_cleanup_failure_aborts_before_probe(tmp_path, monkeypatch): db = tmp_path / "state.db" _write_populated_db(db) probed = False def fake_health(_path): nonlocal probed probed = True return "forced-unhealthy" monkeypatch.setattr(hermes_state, "_db_opens_cleanly", fake_health) monkeypatch.setattr( hermes_state, "_unlink_db_triple", lambda _path: "scratch is locked" ) report = { "repaired": False, "strategy": None, "backup_path": None, "error": None, } result = hermes_state._repair_state_db_schema_locked( db, backup=False, report=report ) assert result["repaired"] is False assert "stale repair snapshot" in result["error"] assert probed is False