fix(kanban): re-create the schema when a cached DB path loses it (#83445)

`connect()` caches every path it has initialized in the process-local
`_INITIALIZED_PATHS` set and then skips all first-open work for it —
header validation, integrity probe, `SCHEMA_SQL`, additive migrations.
That cache is keyed on a path, but the schema it stands for lives in a
file, and the two can drift apart: delete or replace `kanban.db` under a
live gateway/dispatcher/dashboard process and the next `connect()` takes
the fast path, lets SQLite create a fresh empty database, and hands back
a connection with no tables in it.

Nothing notices. Every query then fails with `no such table: tasks`,
`plugin_api._conn()` logs its init warning and carries on, and the board
renders empty. Because the cache entry survives, the process re-creates
the same schema-less ~4 KB file on every restart of the desktop app in
front of it — only killing the backing process clears it.

Verify the sentinel table on the fast path and self-heal when it is gone:
drop the stale cache entry and fall through to the existing init path,
which re-runs the probes and the schema script under the cross-process
init lock. The check is one `sqlite_master` lookup on the already-resident
page 1, so the steady-state path stays lock-free (#36644) and does no
schema work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
joaomarcos
2026-08-10 23:14:41 -03:00
committed by Teknium
parent 4b0c1031db
commit fa72a1edf8
2 changed files with 139 additions and 1 deletions
+38 -1
View File
@@ -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) —
+101
View File
@@ -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 ``<root>/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