From d62964cdfaa2de446372a920ac7a1d4e269beb42 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Mon, 18 May 2026 20:13:11 -0700 Subject: [PATCH] fix(kanban): clear _INITIALIZED_PATHS in remove_board so recycled DBs re-init schema Archiving or deleting a board via remove_board() leaves the path's "schema already initialized" entry in the module-level cache. A concurrent connect(board=) call (e.g. the dashboard event-stream poll loop) then: 1. resolves the same kanban.db path, 2. recreates the directory + an empty sqlite file because connect() does mkdir(parents=True, exist_ok=True), 3. skips the CREATE TABLE pass because the cache entry says the schema is already in place, 4. errors on the next read with `no such table: task_events`. Drop the cache entry before mutating the filesystem so the fresh file gets a proper schema init on next connect(). Applies to both archive=True (rename) and archive=False (rmtree) branches. Fixes #23833. Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes_cli/kanban_db.py | 5 +++++ tests/hermes_cli/test_kanban_boards.py | 31 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index f622aa20e8..0cf7ba12a2 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -534,6 +534,11 @@ def remove_board(slug: str, *, archive: bool = True) -> dict: if get_current_board() == normed: clear_current_board() + # A concurrent connect(board=normed) after the rename/delete recreates + # an empty sqlite file via mkdir(exist_ok=True); the cache entry must be + # dropped first so the schema init pass re-runs on that fresh file. + _INITIALIZED_PATHS.discard(str((d / "kanban.db").resolve())) + if archive: archive_root = boards_root() / "_archived" archive_root.mkdir(parents=True, exist_ok=True) diff --git a/tests/hermes_cli/test_kanban_boards.py b/tests/hermes_cli/test_kanban_boards.py index 28b3fd3f8d..cb110bda78 100644 --- a/tests/hermes_cli/test_kanban_boards.py +++ b/tests/hermes_cli/test_kanban_boards.py @@ -258,6 +258,37 @@ class TestBoardCRUD: kb.remove_board("pinned") assert kb.get_current_board() == "default" + @pytest.mark.parametrize("archive", [True, False]) + def test_remove_clears_init_cache_for_recreated_db(self, fresh_home, archive): + # Regression for #23833: poll loops that call connect(board=slug) right + # after remove_board() recreate an empty kanban.db at the same path + # (connect() does mkdir(exist_ok=True)). If _INITIALIZED_PATHS still + # contains the resolved path, the CREATE TABLE pass is skipped and + # downstream readers hit `no such table: task_events`. + kb.create_board("recycle") + # First connect populates _INITIALIZED_PATHS for this DB. + with kb.connect(board="recycle") as conn: + kb.create_task(conn, title="t1", assignee="dev") + db_path = kb.board_dir("recycle") / "kanban.db" + assert str(db_path.resolve()) in kb._INITIALIZED_PATHS + + kb.remove_board("recycle", archive=archive) + # remove_board must drop the cache entry so a re-create through + # connect() gets a fresh schema-init pass. + assert str(db_path.resolve()) not in kb._INITIALIZED_PATHS + + # Simulate the event-stream poll: re-open the same slug. connect() + # recreates the directory + empty .db; the schema must be re-applied. + with kb.connect(board="recycle") as conn: + tables = { + row[0] + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ) + } + assert "task_events" in tables + assert "tasks" in tables + def test_rename_updates_metadata(self, fresh_home): kb.create_board("slug-immutable") kb.write_board_metadata("slug-immutable", name="New Display Name")