diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 05a1873c11..ce29dbe7e2 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -2304,6 +2304,26 @@ def repair_db( ) +def _schema_is_present(conn: sqlite3.Connection) -> bool: + """Whether an open connection actually sees the kanban schema. + + ``tasks`` is the sentinel: :data:`SCHEMA_SQL` always creates it, and + SQLite loses tables all-or-nothing (a file is either the one we + initialized or a fresh one created by this very open), so one + ``sqlite_master`` lookup on the already-resident page 1 is enough. Cheap + by design — it runs on every steady-state :func:`connect`. + """ + try: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='tasks' LIMIT 1" + ).fetchone() + except sqlite3.DatabaseError: + # Unreadable schema table is not this guard's call — let the full init + # path's header/integrity probes classify and quarantine it. + return False + return row is not None + + def connect( db_path: Optional[Path] = None, *, @@ -2361,10 +2381,27 @@ def connect( conn.execute("PRAGMA foreign_keys=ON") conn.execute("PRAGMA secure_delete=ON") conn.execute("PRAGMA cell_size_check=ON") + schema_present = _schema_is_present(conn) except Exception: conn.close() raise - return conn + if schema_present: + return conn + # The cache says "initialized", the file says otherwise: it was deleted + # or replaced under a live process, and the open above silently + # recreated an empty DB. Left alone, every query on this path fails + # with "no such table: tasks" for the rest of the process's life and + # the board just renders empty (#83445). Drop the stale cache entry and + # fall through to the full init path, which re-runs the header and + # integrity probes and the schema script under the cross-process lock. + conn.close() + with _INIT_LOCK: + _INITIALIZED_PATHS.discard(resolved) + _log.warning( + "kanban DB %s lost its schema after this process initialized it " + "(deleted or replaced externally); re-initializing.", + path, + ) with _cross_process_init_lock(path): # Read-only file/sidecar preflight (port of kilocode#12508) — diff --git a/tests/hermes_cli/test_kanban_db_init.py b/tests/hermes_cli/test_kanban_db_init.py index 91c94c6f47..d5ef1a3f9e 100644 --- a/tests/hermes_cli/test_kanban_db_init.py +++ b/tests/hermes_cli/test_kanban_db_init.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import sqlite3 import threading from pathlib import Path @@ -134,3 +135,103 @@ def test_unseen_events_for_sub_survives_migrated_db(tmp_path, monkeypatch): ) assert isinstance(cursor, int) assert isinstance(events, list) + + +def _default_board_db(tmp_path, monkeypatch) -> Path: + """Point the kanban root at a temp home and return the default board's DB + (the back-compat top-level ``/kanban.db`` #83445 reports on).""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + db_path = kb.kanban_db_path(board="default") + db_path.parent.mkdir(parents=True, exist_ok=True) + kb._INITIALIZED_PATHS.discard(str(db_path.resolve())) + return db_path + + +def _tables(path: Path) -> set[str]: + conn = sqlite3.connect(str(path)) + try: + return {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} + finally: + conn.close() + + +def test_connect_reinitializes_schema_when_db_file_vanished(tmp_path, monkeypatch): + """#83445: the schema cache is process-local, but the schema is on disk. + + A long-lived process (gateway, dispatcher, dashboard API) that already + initialized a path keeps taking the ``_INITIALIZED_PATHS`` fast path after + the file is deleted underneath it. SQLite recreates an empty DB on the next + open, so every query then fails with ``no such table: tasks`` and the board + renders empty until that process itself is restarted. + """ + db_path = _default_board_db(tmp_path, monkeypatch) + + with kb.connect_closing(db_path) as conn: + conn.execute( + "INSERT INTO tasks (id, title, status, created_at) VALUES ('t-1', 'T', 'ready', 1000)" + ) + conn.commit() + assert str(db_path.resolve()) in kb._INITIALIZED_PATHS + + # External deletion (manual cleanup, restore, sync tool) while the process + # that cached this path is still alive. + for suffix in ("", "-wal", "-shm"): + db_path.with_name(db_path.name + suffix).unlink(missing_ok=True) + + with kb.connect_closing(db_path) as conn: + assert conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 0 + assert "tasks" in _tables(db_path) + + +def test_connect_reinitializes_schema_when_db_replaced_by_empty_file(tmp_path, monkeypatch): + """Same defect, restore shape: the file still exists and passes both the + header and the integrity probes, but carries no schema at all.""" + db_path = _default_board_db(tmp_path, monkeypatch) + + with kb.connect_closing(db_path): + pass + + for suffix in ("", "-wal", "-shm"): + db_path.with_name(db_path.name + suffix).unlink(missing_ok=True) + sqlite3.connect(str(db_path)).close() + assert "tasks" not in _tables(db_path) + + with kb.connect_closing(db_path) as conn: + conn.execute( + "INSERT INTO tasks (id, title, status, created_at) VALUES ('t-2', 'T', 'ready', 1000)" + ) + conn.commit() + assert "tasks" in _tables(db_path) + + +def test_healthy_fast_path_stays_lock_free(tmp_path, monkeypatch): + """The self-heal must cost nothing in steady state: an intact cached path + still skips the cross-process init lock (#36644), and only pays for it when + the schema is actually gone.""" + db_path = _default_board_db(tmp_path, monkeypatch) + + with kb.connect_closing(db_path): + pass + + locks: list[Path] = [] + real_lock = kb._cross_process_init_lock + + @contextlib.contextmanager + def recording_lock(path): + locks.append(path) + with real_lock(path): + yield + + monkeypatch.setattr(kb, "_cross_process_init_lock", recording_lock) + + with kb.connect_closing(db_path): + pass + assert locks == [] + + db_path.unlink() + with kb.connect_closing(db_path): + pass + assert len(locks) == 1