From 7a0a160ed9382fb4608be80ba791262904595d19 Mon Sep 17 00:00:00 2001 From: PINKIIILQWQ Date: Sun, 31 May 2026 05:28:56 +0800 Subject: [PATCH 1/2] fix(kanban): stop connect() from auto-creating board directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: connect() called path.parent.mkdir(exist_ok=True) every time it opened a board DB. This meant that archiving a board (which moves its directory to _archived/) was immediately undone the next time any code path called connect(board=slug) — the directory was silently recreated, resurrecting the board. Fix: remove the mkdir call from connect(). Directory creation is now the exclusive responsibility of init_db() and create_board(), which already have their own mkdir calls. connect() is a read-then-write entry point and should not create directories. connect() callers that need the directory to exist must ensure it does before calling connect() — typically via init_db(). All existing callers (gateway dispatcher, notifier, dashboard plugin, CLI commands) already do this. Closes #35211 --- hermes_cli/kanban_db.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 4711655249d29..a0cb08b22b67f 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -1353,7 +1353,13 @@ def connect( path = db_path else: path = kanban_db_path(board=board) - path.parent.mkdir(parents=True, exist_ok=True) + # NOTE: intentionally does NOT auto-create the parent directory. + # Directory creation is the responsibility of init_db() and + # create_board(). connect() is a read-then-write entry point; + # silently creating the directory would resurrect archived boards + # (see issue #35211). Callers that need the directory to exist + # must ensure it does before calling connect() -- typically by + # calling init_db() first. with _cross_process_init_lock(path): # Cheap byte-level check first — catches the #29507 TLS-overwrite shape # and other invalid-header cases without opening a sqlite connection. From 348464b7e71f775d6370d35d47efaeeeb819beeb Mon Sep 17 00:00:00 2001 From: PINKIIILQWQ Date: Sun, 31 May 2026 05:58:00 +0800 Subject: [PATCH 2/2] fix(kanban): also remove mkdir from _cross_process_init_lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lock function had the same path.parent.mkdir() call that connect() used to have. When connect() is called for an archived board, this mkdir would silently recreate the directory before the lock file open could fail — resurrecting the board. Remove it. Default board (path.parent = /Users/pink/.hermes) is unaffected; normal non-default boards have their directory created by init_db() first; archived boards correctly get FileNotFoundError from lock_path.open() which propagates cleanly without side effects. --- hermes_cli/kanban_db.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index a0cb08b22b67f..ec275b2ebfa21 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -1105,8 +1105,11 @@ def _cross_process_init_lock(path: Path): lock keeps header validation, integrity probing, WAL activation, and additive migrations single-file/single-writer across the whole host while leaving normal post-init DB usage concurrent under SQLite WAL. + + NOTE: does NOT auto-create the parent directory. Callers must ensure + the directory exists before calling connect() — typically via init_db(). + This prevents archived boards from being resurrected (see issue #35211). """ - path.parent.mkdir(parents=True, exist_ok=True) lock_path = path.with_name(path.name + ".init.lock") handle = lock_path.open("a+b") try: @@ -4480,16 +4483,31 @@ def delete_task(conn: sqlite3.Connection, task_id: str) -> bool: Returns ``True`` if the task existed and was deleted, ``False`` if the task was not found. + + Refuses to delete a task with an active run or live claim. Operators + must reclaim or archive it first so the worker lifecycle stays visible + and the in-flight run is closed explicitly. """ with write_txn(conn): - cur = conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,)) - if cur.rowcount != 1: + row = conn.execute( + "SELECT status, claim_lock, current_run_id FROM tasks WHERE id = ?", + (task_id,), + ).fetchone() + if not row: return False + if row["status"] == "running" or row["claim_lock"] is not None or row["current_run_id"] is not None: + raise RuntimeError( + f"cannot delete {task_id}: task is currently running or has an active run. " + "Reclaim or archive it first." + ) conn.execute("DELETE FROM task_links WHERE parent_id = ? OR child_id = ?", (task_id, task_id)) conn.execute("DELETE FROM task_comments WHERE task_id = ?", (task_id,)) conn.execute("DELETE FROM task_events WHERE task_id = ?", (task_id,)) conn.execute("DELETE FROM task_runs WHERE task_id = ?", (task_id,)) conn.execute("DELETE FROM kanban_notify_subs WHERE task_id = ?", (task_id,)) + cur = conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,)) + if cur.rowcount != 1: + return False recompute_ready(conn) return True