{"repo":"NousResearch/hermes-agent","public_id":"30445","display_label":"Kanban SQLite DB corruption under concurrent access","summary":"This cluster centers on SQLite database corruption in the kanban module occurring under multi-process, multi-gateway, or high-concurrency conditions, particularly in WAL mode. Members include bug reports, hardening fixes, serialization approaches, and some adjacent concerns about storage backend flexibility and journal mode fallbacks.","subsystem":"cron","counts":{"members":19,"open_issues":4,"open_issues_distinct":4,"open_prs":2,"closed_issues":9,"merged_prs":1,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":33482},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-27T21:31:56+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.0814035087719298},"fix_edges":[{"issue_number":26479,"pr_number":33482,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":30576,"pr_number":33482,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":31158,"pr_number":33482,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":31502,"pr_number":33482,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":30445,"title":"Kanban DB corruption risk from multi-gateway concurrent SQLite access","url":null,"state":"closed"},{"kind":"issue","number":31502,"title":"Kanban SQLite database corruption under rapid task creation","url":null,"state":"closed"},{"kind":"issue","number":32424,"title":"[Bug]: kanban.db corruption when multiple profile gateways share the same board DB","url":null,"state":"closed"},{"kind":"issue","number":32532,"title":"feat(kanban): add busy_timeout PRAGMA to prevent WAL corruption under concurrent writers","url":null,"state":"closed"},{"kind":"issue","number":32749,"title":"[Bug] kanban.db corrupted on WSL2 with multiple gateway processes","url":null,"state":"closed"},{"kind":"issue","number":33169,"title":"[Bug] Kanban DB corruption when gateway and dashboard open the same board DB concurrently (WAL mode)","url":null,"state":"closed"},{"kind":"issue","number":33267,"title":"DB-ADAPTER-006: Kanban storage backend needs concurrency-safe adapter boundary","url":null,"state":"closed"},{"kind":"issue","number":33334,"title":"[Bug]: Database corruption problem with Kanban causing system crash.","url":null,"state":"closed"},{"kind":"issue","number":34385,"title":"[Bug] Kanban DB (kanban.db) index corruption under concurrent multi-process access in WAL mode","url":null,"state":"open"},{"kind":"issue","number":38185,"title":"[Feature]: The kanban module (hermes_cli.kanban_db) is hardcoded to SQLite only !","url":null,"state":"open"},{"kind":"issue","number":51606,"title":"Kanban dispatcher: SQLite DB integrity corruption (idx_events_task) on parallel claim contention","url":null,"state":"closed"},{"kind":"issue","number":53819,"title":"bug: kanban DB corruption under high concurrent-worker load -- workers need per-write serialization","url":null,"state":"open"},{"kind":"issue","number":60510,"title":"bug(kanban): WAL-mode page-count invariant reports false torn-extend errors after successful concurrent writes","url":null,"state":"open"},{"kind":"pr","number":33325,"title":"fix: harden kanban sqlite writes","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":33366,"title":"fix(gateway): dispatch kanban through configured store","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"1c68f6f81f6ff5f94ceb1b6933f2524e59e5f9c8","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":33482,"title":"fix(kanban): batch-salvage 7 SQLite corruption hardening fixes from #32857","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","hermes_cli/kanban_db.py","hermes_state.py","scripts/release.py","tests/hermes_cli/test_kanban_core_functionality.py","tests/hermes_cli/test_kanban_db.py","tests/test_hermes_state.py","tests/test_hermes_state_wal_fallback.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -5691,6 +5691,19 @@ def _auto_decompose_tick() -> int:\n             \"kanban dispatcher: embedded in gateway (interval=%.1fs)\", interval\n         )\n         while self._running:\n+            try:\n+                # Reap zombie children before per-board work so a board DB\n+                # failure cannot block cleanup of unrelated workers.\n+                pids = await asyncio.to_thread(_kb.reap_worker_zombies)\n+                if pids:\n+                    logger.info(\n+                        \"kanban dispatcher: reaped %d zombie worker(s), pids=%s\",\n+                        len(pids),\n+                        pids,\n+                    )\n+            except Exception:\n+                logger.exception(\"kanban dispatcher: zombie reaper failed\")\n+\n             try:\n                 if auto_decompose_enabled:\n                     await asyncio.to_thread(_auto_decompose_tick)"]},{"file":"hermes_cli/kanban_db.py","hunks":["@@ -134,6 +134,34 @@ def _resolve_claim_ttl_seconds(ttl_seconds: Optional[int] = None) -> int:\n     return DEFAULT_CLAIM_TTL_SECONDS\n \n \n+# Grace period after a task transitions to ``running`` during which\n+# ``detect_crashed_workers`` skips the ``_pid_alive`` check. Covers the\n+# fork() → /proc-visibility window where liveness can transiently report\n+# False for a freshly-spawned worker. The 15-minute claim TTL still\n+# catches genuinely-crashed workers; this only suppresses false positives\n+# during the launch window.\n+DEFAULT_CRASH_GRACE_SECONDS = 30\n+\n+\n+def _resolve_crash_grace_seconds() -> int:\n+    \"\"\"Return the crash-detection grace period in seconds.\n+\n+    Reads ``HERMES_KANBAN_CRASH_GRACE_SECONDS`` from the environment;\n+    falls back to ``DEFAULT_CRASH_GRACE_SECONDS`` when absent, empty,\n+    non-integer, or negative. A value of 0 restores immediate-reclaim\n+    behaviour (useful for tests).\n+    \"\"\"\n+    raw = os.environ.get(\"HERMES_KANBAN_CRASH_GRACE_SECONDS\", \"\").strip()\n+    if raw:\n+        try:\n+            parsed = int(raw)\n+        except ValueError:\n+            parsed = -1\n+        if parsed >= 0:\n+            return parsed\n+    return DEFAULT_CRASH_GRACE_SECONDS\n+\n+\n # Worker-context caps so build_worker_context() stays bounded on\n # pathological boards (retry-heavy tasks, comment storms, giant\n # summaries). Values chosen to fit a typical 100k-char LLM prompt with","@@ -1181,8 +1209,17 @@ def connect(\n             # See hermes_state._WAL_INCOMPAT_MARKERS for detection logic.\n             from hermes_state import apply_wal_with_fallback\n             apply_wal_with_fallback(conn, db_label=f\"kanban.db ({path.name})\")\n-            conn.execute(\"PRAGMA synchronous=NORMAL\")\n+            # FULL (was NORMAL): fsync before each checkpoint to narrow the\n+            # crash window that can leave a b-tree page header torn.\n+            conn.execute(\"PRAGMA synchronous=FULL\")\n+            conn.execute(\"PRAGMA wal_autocheckpoint=100\")\n             conn.execute(\"PRAGMA foreign_keys=ON\")\n+            # Zero freed pages so a later torn write cannot expose stale\n+            # cell content; persisted in the DB header for new DBs.\n+            conn.execute(\"PRAGMA secure_delete=ON\")\n+            # Surface corrupt cells as read errors instead of silent\n+            # wrong-data returns.\n+            conn.execute(\"PRAGMA cell_size_check=ON\")\n             needs_init = resolved not in _INITIALIZED_PATHS\n             if needs_init:\n                 # Idempotent: runs CREATE TABLE IF NOT EXISTS + the additive","@@ -1466,6 +1503,45 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:\n         )\n \n \n+def _check_file_length_invariant(conn: sqlite3.Connection) -> None:\n+    \"\"\"Read the SQLite header page_count and compare against actual file size.\n+\n+    Raises sqlite3.DatabaseError if the file is shorter than the header claims\n+    (torn-extend corruption).\n+    \"\"\"\n+    try:\n+        row = conn.execute(\"PRAGMA database_list\").fetchone()\n+        if row is None:\n+            return\n+        path_str = row[2]  # column 2 is the file path; empty for in-memory DBs\n+        if not path_str:\n+            return  # in-memory or unnamed DB; skip\n+        path = path_str\n+        page_size = conn.execute(\"PRAGMA page_size\").fetchone()[0]\n+        file_size = os.path.getsize(path)\n+        with open(path, \"rb\") as f:\n+            f.seek(28)\n+            header_bytes = f.read(4)\n+        if len(header_bytes) < 4:\n+            return  # can't read header; skip\n+        header_page_count = int.from_bytes(header_bytes, \"big\")\n+        if header_page_count == 0:\n+            return  # new/empty DB; skip\n+        actual_pages = file_size // page_size\n+        if actual_pages < header_page_count:\n+            raise sqlite3.DatabaseError(\n+                f\"torn-extend detected: page count mismatch on {path}: \"\n+                f\"header claims {header_page_count} pages, \"\n+                f\"file has {actual_pages} pages \"\n+                f\"(missing {header_page_count - actual_pages} pages, \"\n+                f\"file_size={file_size}, page_size={page_size})\"\n+            )\n+    except sqlite3.DatabaseError:\n+        raise\n+    except Exception:\n+        pass  # I/O errors during check are non-fatal; let normal ops continue\n+\n+\n @contextlib.contextmanager\n def write_txn(conn: sqlite3.Connection):\n     \"\"\"Context manager for an IMMEDIATE write transaction.","@@ -1473,15 +1549,28 @@ def write_txn(conn: sqlite3.Connection):\n     Use for any multi-statement write (creating a task + link, claiming a\n     task + recording an event, etc.).  A claim CAS inside this context is\n     atomic -- at most one concurrent writer can succeed.\n+\n+    The explicit ROLLBACK on exception is wrapped in try/except so that\n+    a SQLite auto-rollback (which leaves no active transaction) does not\n+    shadow the original exception with a spurious rollback error.\n     \"\"\"\n     conn.execute(\"BEGIN IMMEDIATE\")\n     try:\n         yield conn\n     except Exception:\n-        conn.execute(\"ROLLBACK\")\n+        try:\n+            conn.execute(\"ROLLBACK\")\n+        except sqlite3.OperationalError:\n+            # SQLite has already auto-rolled-back the transaction (typical\n+            # under EIO, lock contention, or corruption). Nothing to undo;\n+            # do not let this secondary failure shadow the real one.\n+            pass\n         raise\n     else:\n         conn.execute(\"COMMIT\")\n+        # Post-commit file-length check: header page_count must match actual file pages.\n+        # A discrepancy means a torn-extend — raise now rather than silently corrupt.\n+        _check_file_length_invariant(conn)\n \n \n # ---------------------------------------------------------------------------","@@ -4169,6 +4258,30 @@ def _classify_worker_exit(pid: int) -> \"tuple[str, Optional[int]]\":\n     return (\"unknown\", None)\n \n \n+def reap_worker_zombies() -> \"list[int]\":\n+    \"\"\"Reap all zombie children of this process without blocking.\n+\n+    Returns the list of reaped PIDs. Safe to call when there are no\n+    children (returns []). No-op on Windows.\n+    \"\"\"\n+    if os.name == \"nt\":\n+        return []\n+    reaped: \"list[int]\" = []\n+    try:\n+        while True:\n+            try:\n+                pid, status = os.waitpid(-1, os.WNOHANG)\n+            except ChildProcessError:\n+                break\n+            if pid == 0:\n+                break\n+            _record_worker_exit(pid, status)\n+            reaped.append(pid)\n+    except Exception:\n+        pass\n+    return reaped\n+\n+\n def _pid_alive(pid: Optional[int]) -> bool:\n     \"\"\"Return True if ``pid`` is still running on this host.\n ","@@ -4635,7 +4748,7 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]:\n     # (task_id, pid, claimer, protocol_violation, error_text)\n     with write_txn(conn):\n         rows = conn.execute(\n-            \"SELECT id, worker_pid, claim_lock FROM tasks \"\n+            \"SELECT id, worker_pid, claim_lock, started_at FROM tasks \"\n             \"WHERE status = 'running' AND worker_pid IS NOT NULL\"\n         ).fetchall()\n         host_prefix = f\"{_claimer_id().split(':', 1)[0]}:\"","@@ -4644,6 +4757,14 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]:\n             lock = row[\"claim_lock\"] or \"\"\n             if not lock.startswith(host_prefix):\n                 continue\n+            # Skip liveness check inside the launch-window grace period\n+            # so a freshly-spawned worker isn't reclaimed before its PID\n+            # is visible on /proc.\n+            started_at = row[\"started_at\"] if \"started_at\" in row.keys() else None\n+            if started_at is not None:\n+                grace = _resolve_crash_grace_seconds()\n+                if time.time() - started_at < grace:\n+                    continue\n             if _pid_alive(row[\"worker_pid\"]):\n                 continue\n ","@@ -5125,38 +5246,9 @@ def dispatch_once(\n     ``board`` pins workspace/log/db resolution for this tick to a specific\n     board. When omitted, the current-board resolution chain is used.\n     \"\"\"\n-    # Reap zombie children from previously spawned workers.\n-    # The gateway-embedded dispatcher is the parent of every worker spawned\n-    # via _default_spawn (start_new_session=True only detaches the\n-    # controlling tty, not the parent). Without an explicit waitpid, each\n-    # completed worker becomes a <defunct> entry that lingers until gateway\n-    # exit. WNOHANG keeps this non-blocking; ChildProcessError means no\n-    # children to reap. Bounded: at most one tick's worth of completions\n-    # can be in <defunct> at once.\n-    #\n-    # We also record the exit status keyed by pid, so\n-    # ``detect_crashed_workers`` can distinguish a worker that exited\n-    # cleanly without calling ``kanban_complete`` / ``kanban_block``\n-    # (protocol violation — auto-block) from a real crash (OOM killer,\n-    # SIGKILL, non-zero exit — existing counter behavior).\n-    #\n-    # Windows has no zombies / no os.WNOHANG — subprocess.Popen handles\n-    # are freed when the Python object is garbage-collected or .wait() is\n-    # called explicitly.  The kanban dispatcher discards the Popen handle\n-    # after spawn (``_default_spawn`` → abandon), so on Windows there's\n-    # nothing to reap here — skip the whole block.\n-    if os.name != \"nt\":\n-        try:\n-            while True:\n-                try:\n-                    _pid, _status = os.waitpid(-1, os.WNOHANG)\n-                except ChildProcessError:\n-                    break\n-                if _pid == 0:\n-                    break\n-                _record_worker_exit(_pid, _status)\n-        except Exception:\n-            pass\n+    # Reap zombie children from previously spawned workers. See\n+    # reap_worker_zombies() for the full rationale.\n+    reap_worker_zombies()\n \n     result = DispatchResult()\n     result.reclaimed = release_stale_claims(conn)"]},{"file":"hermes_state.py","hunks":["@@ -54,7 +54,6 @@\n _WAL_INCOMPAT_MARKERS = (\n     \"locking protocol\",       # SQLITE_PROTOCOL on NFS/SMB\n     \"not authorized\",         # Some FUSE mounts block WAL pragma outright\n-    \"disk i/o error\",         # Flaky network FS during WAL setup\n )\n \n # Last SessionDB() init error, per-process.  Surfaced in /resume and","@@ -125,6 +124,27 @@ def format_session_db_unavailable(prefix: str = \"Session database not available\"\n     return f\"{prefix}: {cause}{hint}.\"\n \n \n+def _on_disk_journal_mode(conn: sqlite3.Connection) -> Optional[str]:\n+    \"\"\"Read the journal mode from the SQLite DB header on disk.\n+\n+    Returns the mode string (e.g. ``\"wal\"``, ``\"delete\"``), or ``None``\n+    if the value cannot be determined (new DB, or PRAGMA read failed).\n+    \"\"\"\n+    try:\n+        row = conn.execute(\"PRAGMA journal_mode\").fetchone()\n+    except sqlite3.OperationalError:\n+        return None\n+    if row is None:\n+        return None\n+    mode = row[0]\n+    if isinstance(mode, bytes):  # defensive: sqlite3 occasionally returns bytes\n+        try:\n+            mode = mode.decode(\"ascii\")\n+        except UnicodeDecodeError:\n+            return None\n+    return str(mode).strip().lower() if mode is not None else None\n+\n+\n def apply_wal_with_fallback(\n     conn: sqlite3.Connection,\n     *,","@@ -147,7 +167,18 @@ def apply_wal_with_fallback(\n \n     Shared by :class:`SessionDB` and ``hermes_cli.kanban_db.connect`` so\n     both databases get identical fallback behavior.\n+\n+    Never downgrades to DELETE if the on-disk DB header reports WAL — see _on_disk_journal_mode.\n     \"\"\"\n+    # Read-only probe — no flock, no checkpoint, no WAL/SHM unlink.\n+    # Skipping the set-pragma prevents WAL-init from unlinking files other connections hold open.\n+    try:\n+        current_mode = conn.execute(\"PRAGMA journal_mode\").fetchone()\n+        if current_mode and current_mode[0] == \"wal\":\n+            return \"wal\"\n+    except sqlite3.OperationalError:\n+        pass\n+\n     try:\n         conn.execute(\"PRAGMA journal_mode=WAL\")\n         return \"wal\"","@@ -156,6 +187,10 @@ def apply_wal_with_fallback(\n         if not any(marker in msg for marker in _WAL_INCOMPAT_MARKERS):\n             # Unrelated OperationalError — don't silently swallow.\n             raise\n+        # Don't downgrade if another process already set WAL on disk.\n+        existing = _on_disk_journal_mode(conn)\n+        if existing == \"wal\":\n+            raise\n         _log_wal_fallback_once(db_label, exc)\n         conn.execute(\"PRAGMA journal_mode=DELETE\")\n         return \"delete\""]},{"file":"scripts/release.py","hunks":["@@ -71,6 +71,8 @@\n     \"[redacted:email]\": \"Strontvod\",\n     \"[redacted:email]\": \"someaka\",\n     \"[redacted:email]\": \"Jaggia\",\n+    \"[redacted:email]\": \"steveonjava\",\n+    \"[redacted:email]\": \"steveonjava\",\n     \"[redacted:email]\": \"simpolism\",\n     \"[redacted:email]\": \"simpolism\",\n     \"[redacted:email]\": \"simpolism\","]},{"file":"tests/hermes_cli/test_kanban_core_functionality.py","hunks":["@@ -35,7 +35,19 @@ def kanban_home(tmp_path, monkeypatch):\n     home = tmp_path / \".hermes\"\n     home.mkdir()\n     monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+    # Existing crash-detection tests pre-date the grace window; pin to 0\n+    # so they keep their immediate-reclaim semantics.\n+    monkeypatch.setenv(\"HERMES_KANBAN_CRASH_GRACE_SECONDS\", \"0\")\n     monkeypatch.setattr(Path, \"home\", lambda: tmp_path)\n+    # Disable the detect_crashed_workers grace period for legacy tests in\n+    # this file that claim a task and immediately expect\n+    # ``detect_crashed_workers`` to act on it. The grace period (30s by\n+    # default, see ``DEFAULT_CRASH_GRACE_SECONDS``) prevents the\n+    # multi-dispatcher reap race in production; setting it to 0 here\n+    # restores the pre-fix instant-reclaim semantics these tests were\n+    # written against. The grace-period itself is covered by dedicated\n+    # tests in tests/hermes_cli/test_kanban_db.py.\n+    monkeypatch.setenv(\"HERMES_KANBAN_CRASH_GRACE_SECONDS\", \"0\")\n     kb.init_db()\n     return home\n ","@@ -3655,9 +3667,16 @@ def _connect(*args, **kwargs):\n         raise sqlite3.DatabaseError(\"file is not a database\")\n \n     async def _to_thread(fn, *args, **kwargs):\n+        # PR salvage (#32857 commit 7): the dispatcher now reaps zombies at\n+        # the top of each tick via ``asyncio.to_thread(_kb.reap_worker_zombies)``\n+        # BEFORE the per-board tick work. Each tick now issues 3 ``to_thread``\n+        # calls (reaper + ``_tick_once`` + ``_ready_nonempty``) instead of 2,\n+        # so this counter must reach 6 to allow the same 2 dispatch ticks the\n+        # pre-reaper test expected at 4. Connect counts in the assertion below\n+        # are unchanged.\n         calls[\"to_thread\"] += 1\n         result = fn(*args, **kwargs)\n-        if calls[\"to_thread\"] >= 4:\n+        if calls[\"to_thread\"] >= 6:\n             runner._running = False\n         return result\n "]},{"file":"tests/hermes_cli/test_kanban_db.py","hunks":["@@ -6,6 +6,7 @@\n import os\n import sqlite3\n import time\n+import unittest.mock\n from pathlib import Path\n \n import pytest","@@ -564,6 +565,80 @@ def test_detect_crashed_workers_isolated_failure_normal_retry(\n             )\n \n \n+def test_detect_crashed_workers_skips_freshly_claimed_tasks(\n+    kanban_home, monkeypatch,\n+):\n+    \"\"\"Grace period prevents reclaim of freshly-started tasks.\"\"\"\n+    import hermes_cli.kanban_db as _kb\n+\n+    monkeypatch.setattr(_kb, \"_pid_alive\", lambda _pid: False)\n+    monkeypatch.delenv(\"HERMES_KANBAN_CRASH_GRACE_SECONDS\", raising=False)\n+\n+    now = 1_000_000.0\n+    monkeypatch.setattr(_kb.time, \"time\", lambda: now)\n+\n+    with kb.connect() as conn:\n+        host = _kb._claimer_id().split(\":\", 1)[0]\n+        tid = kb.create_task(conn, title=\"grace test\", assignee=\"a\")\n+        conn.execute(\n+            \"UPDATE tasks SET status='running', worker_pid=?, \"\n+            \"claim_lock=?, started_at=? WHERE id=?\",\n+            (99999, f\"{host}:w\", int(now), tid),\n+        )\n+        conn.commit()\n+\n+        # With time = now (just claimed), grace period should suppress reclaim.\n+        crashed = kb.detect_crashed_workers(conn)\n+        assert tid not in crashed, \"should not reclaim freshly-started task\"\n+\n+        # With time = now + 60 (past default 30s grace), should reclaim.\n+        monkeypatch.setattr(_kb.time, \"time\", lambda: now + 60)\n+        crashed = kb.detect_crashed_workers(conn)\n+        assert tid in crashed, \"should reclaim task past grace period\"\n+\n+\n+def test_detect_crashed_workers_grace_period_env_override(\n+    kanban_home, monkeypatch,\n+):\n+    \"\"\"HERMES_KANBAN_CRASH_GRACE_SECONDS env var adjusts the window.\"\"\"\n+    import hermes_cli.kanban_db as _kb\n+\n+    monkeypatch.setattr(_kb, \"_pid_alive\", lambda _pid: False)\n+    monkeypatch.setenv(\"HERMES_KANBAN_CRASH_GRACE_SECONDS\", \"5\")\n+\n+    now = 2_000_000.0\n+\n+    with kb.connect() as conn:\n+        host = _kb._claimer_id().split(\":\", 1)[0]\n+        tid = kb.create_task(conn, title=\"env override test\", assignee=\"a\")\n+        conn.execute(\n+            \"UPDATE tasks SET status='running', worker_pid=?, \"\n+            \"claim_lock=?, started_at=? WHERE id=?\",\n+            (99999, f\"{host}:w\", int(now), tid),\n+        )\n+        conn.commit()\n+\n+        # 3s after claim: within 5s grace → no reclaim.\n+        monkeypatch.setattr(_kb.time, \"time\", lambda: now + 3)\n+        assert tid not in kb.detect_crashed_workers(conn)\n+\n+        # 6s after claim: past 5s grace → reclaim.\n+        monkeypatch.setattr(_kb.time, \"time\", lambda: now + 6)\n+        assert tid in kb.detect_crashed_workers(conn)\n+\n+\n+def test_resolve_crash_grace_seconds_handles_bad_env(monkeypatch):\n+    \"\"\"Bad env values fall back to DEFAULT_CRASH_GRACE_SECONDS.\"\"\"\n+    import hermes_cli.kanban_db as _kb\n+\n+    for bad_val in (\"notanumber\", \"-5\", \"\"):\n+        monkeypatch.setenv(\"HERMES_KANBAN_CRASH_GRACE_SECONDS\", bad_val)\n+        result = _kb._resolve_crash_grace_seconds()\n+        assert result == _kb.DEFAULT_CRASH_GRACE_SECONDS, (\n+            f\"expected default for {bad_val!r}, got {result}\"\n+        )\n+\n+\n def test_max_runtime_uses_current_run_start_after_retry(kanban_home, monkeypatch):\n     \"\"\"A retry should get a fresh max-runtime window.\n ","@@ -2097,17 +2172,31 @@ def test_latest_summaries_batch_omits_tasks_without_summary(kanban_home):\n # NFS / network-filesystem fallback (see hermes_state.apply_wal_with_fallback)\n # ---------------------------------------------------------------------------\n \n-def test_connect_falls_back_to_delete_on_locking_protocol(kanban_home, caplog):\n+def test_connect_falls_back_to_delete_on_locking_protocol(tmp_path, monkeypatch, caplog):\n     \"\"\"kanban_db.connect() must handle ``locking protocol`` on NFS/SMB.\n \n     Without this fallback, the gateway's kanban dispatcher crashes every\n     60s and the kanban migration (``consecutive_failures`` ADD COLUMN) is\n     retried forever — which is what the real-world user report shows\n     (see hermes-agent issue #22032).\n+\n+    NOTE: We do NOT use the ``kanban_home`` fixture here because that\n+    fixture pre-initializes the DB via ``kb.init_db()`` — putting the\n+    file in WAL on disk. The Bug D safety guard now refuses to downgrade\n+    to DELETE when the on-disk header is already WAL, so testing the\n+    NFS-fallback path requires a truly-fresh DB file (NFS scenario in\n+    production: first connection of the first process ever to touch the\n+    file, where downgrading is safe because nobody else has WAL state\n+    yet).\n     \"\"\"\n     import sqlite3 as _sqlite3\n     from unittest.mock import patch as _patch\n \n+    home = tmp_path / \".hermes\"\n+    home.mkdir()\n+    monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+    monkeypatch.setattr(Path, \"home\", lambda: tmp_path)\n+\n     # Clear module cache so a fresh connect() is attempted\n     kb._INITIALIZED_PATHS.clear()\n ","@@ -3339,3 +3428,380 @@ def test_maybe_emit_scratch_tip_skips_non_scratch_workspaces(kanban_home, caplog\n             ).fetchall()\n             assert \"tip_scratch_workspace\" not in [e[\"kind\"] for e in events]\n \n+\n+# ---------------------------------------------------------------------------\n+# Connection pragmas (secure_delete, cell_size_check, synchronous=FULL)\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_connect_sets_secure_delete_on(tmp_path):\n+    \"\"\"secure_delete=ON must be active on every new connection.\"\"\"\n+    db_path = tmp_path / \"kanban.db\"\n+    kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))\n+    with kb.connect(db_path=db_path) as conn:\n+        row = conn.execute(\"PRAGMA secure_delete\").fetchone()\n+    assert row[0] == 1, f\"expected secure_delete=1, got {row[0]}\"\n+\n+\n+def test_connect_sets_cell_size_check_on(tmp_path):\n+    \"\"\"cell_size_check=ON must be active on every new connection.\"\"\"\n+    db_path = tmp_path / \"kanban.db\"\n+    kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))\n+    with kb.connect(db_path=db_path) as conn:\n+        row = conn.execute(\"PRAGMA cell_size_check\").fetchone()\n+    assert row[0] == 1, f\"expected cell_size_check=1, got {row[0]}\"\n+\n+\n+def test_connect_sets_synchronous_full(tmp_path):\n+    \"\"\"synchronous must be FULL (=2), not NORMAL (=1).\"\"\"\n+    db_path = tmp_path / \"kanban.db\"\n+    kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))\n+    with kb.connect(db_path=db_path) as conn:\n+        row = conn.execute(\"PRAGMA synchronous\").fetchone()\n+    assert row[0] == 2, f\"expected synchronous=2 (FULL), got {row[0]}\"\n+\n+\n+def test_connect_pragmas_applied_on_reconnect(tmp_path):\n+    \"\"\"All three pragmas must be re-applied on every connect(), not just the first.\"\"\"\n+    db_path = tmp_path / \"kanban.db\"\n+    kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))\n+    # First connection: write a task and close.\n+    with kb.connect(db_path=db_path) as conn:\n+        kb.create_task(conn, title=\"reconnect-check\")\n+    # Force re-init path by discarding path cache.\n+    kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))\n+    # Second connection: pragmas must still be applied.\n+    with kb.connect(db_path=db_path) as conn:\n+        assert conn.execute(\"PRAGMA secure_delete\").fetchone()[0] == 1\n+        assert conn.execute(\"PRAGMA cell_size_check\").fetchone()[0] == 1\n+        assert conn.execute(\"PRAGMA synchronous\").fetchone()[0] == 2\n+\n+\n+\n+def test_pragmas_not_accidentally_disabled_by_migrate_path(tmp_path):\n+    \"\"\"Migration path must not reset connection pragmas.\"\"\"\n+    db_path = tmp_path / \"legacy.db\"\n+    kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))\n+    # Initialise with a fresh connect so schema + init run.\n+    with kb.connect(db_path=db_path) as conn:\n+        kb.create_task(conn, title=\"pre-migration-task\")\n+    # Simulate a re-entry through the init/migration path by discarding path cache.\n+    kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))\n+    with kb.connect(db_path=db_path) as conn:\n+        assert conn.execute(\"PRAGMA secure_delete\").fetchone()[0] == 1\n+        assert conn.execute(\"PRAGMA cell_size_check\").fetchone()[0] == 1\n+        assert conn.execute(\"PRAGMA synchronous\").fetchone()[0] == 2\n+\n+# write_txn — rollback handler must not mask the original exception\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_write_txn_preserves_original_exception_when_rollback_fails(kanban_home):\n+    \"\"\"When a write inside write_txn raises an OperationalError that SQLite\n+    has already auto-rolled-back (e.g. ``disk I/O error``,\n+    ``database is locked``, ``database disk image is malformed``), the\n+    explicit ROLLBACK in ``write_txn.__exit__`` itself raises\n+    ``cannot rollback - no transaction is active``. The original cause\n+    must NOT be masked by the secondary rollback failure — operators rely\n+    on the original cause to diagnose the underlying issue.\n+    \"\"\"\n+\n+    class FailingConnWrapper:\n+        \"\"\"Delegate to a real connection, simulating an EIO during an INSERT\n+        that SQLite has already auto-rolled-back.\"\"\"\n+\n+        def __init__(self, real):\n+            self._real = real\n+            self._fail_armed = True\n+\n+        def execute(self, sql, *args, **kwargs):\n+            if (\n+                self._fail_armed\n+                and sql.lstrip().upper().startswith(\"INSERT\")\n+                and \"task_events\" in sql.lower()\n+            ):\n+                self._fail_armed = False  # one-shot\n+                # Simulate SQLite auto-rolling back the transaction by\n+                # issuing a real ROLLBACK now. After this, BEGIN IMMEDIATE\n+                # is no longer active and an explicit ROLLBACK would error.\n+                try:\n+                    self._real.execute(\"ROLLBACK\")\n+                except sqlite3.OperationalError:\n+                    pass\n+                raise sqlite3.OperationalError(\"disk I/O error\")\n+            return self._real.execute(sql, *args, **kwargs)\n+\n+        def __getattr__(self, name):\n+            return getattr(self._real, name)\n+\n+    with kb.connect() as conn:\n+        wrapper = FailingConnWrapper(conn)\n+        with pytest.raises(sqlite3.OperationalError) as excinfo:\n+            with kb.write_txn(wrapper):\n+                kb._append_event(wrapper, \"t_bogus\", \"promoted\", None)\n+\n+    msg = str(excinfo.value)\n+    assert \"disk I/O error\" in msg, (\n+        f\"write_txn masked the original exception with rollback failure; \"\n+        f\"got {msg!r} (expected to contain 'disk I/O error')\"\n+    )\n+    assert \"cannot rollback\" not in msg, (\n+        f\"write_txn surfaced the rollback failure instead of the original \"\n+        f\"OperationalError; got {msg!r}\"\n+    )\n+def test_write_txn_healthy_commit_no_exception(tmp_path):\n+    \"\"\"Normal commit does not trigger the torn-extend check.\"\"\"\n+    from hermes_cli.kanban_db import connect, write_txn, create_task\n+    db = tmp_path / \"test.db\"\n+    conn = connect(db_path=db)\n+    # Should not raise\n+    with write_txn(conn) as c:\n+        c.execute(\n+            \"INSERT INTO tasks (id, title, assignee, status, priority, created_at) \"\n+            \"VALUES ('t_test01', 'test task', 'tester', 'todo', 0, 1234567890)\"\n+        )\n+    row = conn.execute(\"SELECT title FROM tasks WHERE id='t_test01'\").fetchone()\n+    assert row[\"title\"] == \"test task\"\n+    conn.close()\n+\n+\n+def test_write_txn_raises_on_truncated_file(tmp_path):\n+    \"\"\"A mocked smaller file size triggers the torn-extend check.\"\"\"\n+    from hermes_cli.kanban_db import connect, write_txn\n+    import hermes_cli.kanban_db as kanban_db_module\n+    db = tmp_path / \"test.db\"\n+    conn = connect(db_path=db)\n+    # Get actual page size so we can fake a smaller file\n+    page_size = conn.execute(\"PRAGMA page_size\").fetchone()[0]\n+    original_getsize = os.path.getsize\n+\n+    def fake_getsize(path):\n+        # Return a size that implies at least 1 fewer page than header claims\n+        real_size = original_getsize(path)\n+        return max(0, real_size - page_size)\n+\n+    with pytest.raises(sqlite3.DatabaseError, match=\"torn-extend|page count mismatch\"):\n+        with unittest.mock.patch(\"hermes_cli.kanban_db.os.path.getsize\", side_effect=fake_getsize):\n+            with write_txn(conn) as c:\n+                c.execute(\n+                    \"INSERT INTO tasks (id, title, assignee, status, priority, created_at) \"\n+                    \"VALUES ('t_test02', 'test task 2', 'tester', 'todo', 0, 1234567890)\"\n+                )\n+    conn.close()\n+\n+\n+def test_write_txn_post_commit_check_fires_every_call(tmp_path):\n+    \"\"\"The invariant check runs on every write_txn call.\"\"\"\n+    from hermes_cli.kanban_db import connect, write_txn\n+    import hermes_cli.kanban_db as kanban_db_module\n+    db = tmp_path / \"test.db\"\n+    conn = connect(db_path=db)\n+    call_count = 0\n+    real_check = kanban_db_module._check_file_length_invariant\n+\n+    def counting_check(c):\n+        nonlocal call_count\n+        call_count += 1\n+        real_check(c)\n+\n+    with unittest.mock.patch.object(kanban_db_module, \"_check_file_length_invariant\", counting_check):\n+        for i in range(3):\n+            with write_txn(conn) as c:\n+                c.execute(\n+                    f\"INSERT INTO tasks (id, title, assignee, status, priority, created_at) \"\n+                    f\"VALUES ('t_fire{i:02d}', 'task {i}', 'tester', 'todo', 0, 1234567890)\"\n+                )\n+    assert call_count == 3\n+    conn.close()\n+\n+\n+def test_connect_sets_wal_autocheckpoint_100(tmp_path):\n+    \"\"\"connect() sets wal_autocheckpoint to 100.\"\"\"\n+    from hermes_cli.kanban_db import connect\n+    db = tmp_path / \"test.db\"\n+    conn = connect(db_path=db)\n+    val = conn.execute(\"PRAGMA wal_autocheckpoint\").fetchone()[0]\n+    assert val == 100\n+    conn.close()\n+\n+\n+def test_write_txn_check_reads_correct_header_fields(tmp_path):\n+    \"\"\"Synthetic DB file with mismatched header page_count triggers the check.\"\"\"\n+    import struct\n+    from hermes_cli.kanban_db import connect, write_txn, _check_file_length_invariant\n+    db = tmp_path / \"synthetic.db\"\n+    conn = connect(db_path=db)\n+    page_size = conn.execute(\"PRAGMA page_size\").fetchone()[0]\n+    conn.close()\n+    # Now corrupt the file: claim N pages but truncate to N-1 pages\n+    with open(db, \"rb\") as f:\n+        data = bytearray(f.read())\n+    # Read current page_count from header bytes 28-31\n+    real_page_count = struct.unpack(\">I\", data[28:32])[0]\n+    if real_page_count < 2:\n+        # Need at least 2 pages to fake a truncation\n+        pytest.skip(\"DB too small for synthetic truncation test\")\n+    # Truncate to N-1 pages\n+    truncated = bytes(data[: (real_page_count - 1) * page_size])\n+    with open(db, \"wb\") as f:\n+        f.write(truncated)\n+    # Now open and check — should raise\n+    # We can't use connect() because _validate_sqlite_header may block; use a raw connection\n+    raw_conn = sqlite3.connect(str(db), isolation_level=None)\n+    with pytest.raises(sqlite3.DatabaseError, match=\"torn-extend|page count mismatch\"):\n+        _check_file_length_invariant(raw_conn)\n+    raw_conn.close()\n+\n+\n+# ---------------------------------------------------------------------------\n+# reap_worker_zombies() tests\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_reap_worker_zombies_returns_count():\n+    \"\"\"reap_worker_zombies() returns the list of reaped PIDs.\"\"\"\n+    from unittest.mock import patch\n+\n+    fake_pids = [12345, 67890, 11111]\n+    call_count = [0]\n+\n+    def fake_waitpid(pid, flags):\n+        if call_count[0] < len(fake_pids):\n+            p = fake_pids[call_count[0]]\n+            call_count[0] += 1\n+            return p, 0\n+        return 0, 0\n+\n+    with patch(\"hermes_cli.kanban_db.os.waitpid\", side_effect=fake_waitpid):\n+        with patch(\"hermes_cli.kanban_db._record_worker_exit\"):\n+            pids = kb.reap_worker_zombies()\n+    assert pids == [12345, 67890, 11111]\n+\n+\n+def test_reap_worker_zombies_noop_on_windows(monkeypatch):\n+    \"\"\"reap_worker_zombies() returns 0 and never calls os.waitpid on Windows.\"\"\"\n+    from unittest.mock import patch\n+\n+    monkeypatch.setattr(\"hermes_cli.kanban_db.os.name\", \"nt\")\n+    with patch(\"hermes_cli.kanban_db.os.waitpid\") as mock_waitpid:\n+        result = kb.reap_worker_zombies()\n+    mock_waitpid.assert_not_called()\n+    assert result == []\n+\n+\n+def test_reap_worker_zombies_noop_no_children():\n+    \"\"\"reap_worker_zombies() returns 0 without error when there are no children.\"\"\"\n+    from unittest.mock import patch\n+\n+    with patch(\"hermes_cli.kanban_db.os.waitpid\", side_effect=ChildProcessError):\n+        result = kb.reap_worker_zombies()\n+    assert result == []\n+\n+\n+def test_reap_worker_zombies_records_exit_status():\n+    \"\"\"reap_worker_zombies() calls _record_worker_exit for each reaped pid.\"\"\"\n+    from unittest.mock import patch\n+\n+    calls = []\n+    call_count = [0]\n+\n+    def fake_waitpid(pid, flags):\n+        call_count[0] += 1\n+        if call_count[0] == 1:\n+            return 12345, 0\n+        return 0, 0\n+\n+    with patch(\"hermes_cli.kanban_db.os.waitpid\", side_effect=fake_waitpid):\n+        with patch(\n+            \"hermes_cli.kanban_db._record_worker_exit\",\n+            side_effect=lambda p, s: calls.append((p, s)),\n+        ):\n+            kb.reap_worker_zombies()\n+\n+    assert calls == [(12345, 0)]\n+\n+\n+def test_reap_worker_zombies_handles_waitpid_os_error():\n+    \"\"\"reap_worker_zombies() does not propagate generic OSError from os.waitpid.\"\"\"\n+    from unittest.mock import patch\n+\n+    with patch(\"hermes_cli.kanban_db.os.waitpid\", side_effect=OSError(\"test error\")):\n+        result = kb.reap_worker_zombies()\n+    assert result == []\n+\n+\n+def test_zombie_reaper_runs_despite_board_connect_failure():\n+    \"\"\"reap_worker_zombies runs even when a board tick raises an error.\"\"\"\n+    from unittest.mock import patch\n+\n+    call_count = [0]\n+\n+    def fake_waitpid(pid, flags):\n+        call_count[0] += 1\n+        if call_count[0] <= 2:\n+            return [12345, 67890][call_count[0] - 1], 0\n+        return 0, 0\n+\n+    with patch(\"hermes_cli.kanban_db.os.waitpid\", side_effect=fake_waitpid):\n+        with patch(\"hermes_cli.kanban_db._record_worker_exit\"):\n+            # Simulate a board tick failure before reaping\n+            try:\n+                raise sqlite3.OperationalError(\"disk I/O error\")\n+            except sqlite3.OperationalError:\n+                pass\n+\n+            # Reaper still runs independently\n+            pids = kb.reap_worker_zombies()\n+\n+    assert pids == [12345, 67890]\n+\n+\n+def test_zombie_reaper_survives_all_boards_failing():\n+    \"\"\"reap_worker_zombies runs each tick regardless of board tick failures.\"\"\"\n+    from unittest.mock import patch\n+\n+    total_reaped = 0\n+\n+    def make_fake_waitpid(zombie_pids):\n+        call_count = [0]\n+\n+        def fake_waitpid(pid, flags):\n+            if call_count[0] < len(zombie_pids):\n+                p = zombie_pids[call_count[0]]\n+                call_count[0] += 1\n+                return p, 0\n+            return 0, 0\n+\n+        return fake_waitpid\n+\n+    # 5 ticks, 2 zombies per tick = 10 total\n+    for tick in range(5):\n+        pids = [tick * 100 + 1, tick * 100 + 2]\n+        with patch(\n+            \"hermes_cli.kanban_db.os.waitpid\", side_effect=make_fake_waitpid(pids)\n+        ):\n+            with patch(\"hermes_cli.kanban_db._record_worker_exit\"):\n+                pids = kb.reap_worker_zombies()\n+        total_reaped += len(pids)\n+\n+    assert total_reaped == 10\n+\n+\n+def test_dispatch_once_still_reaps_via_extracted_fn(kanban_home):\n+    \"\"\"The reaper inside dispatch_once still works after refactor to reap_worker_zombies().\"\"\"\n+    from unittest.mock import patch\n+\n+    call_count = [0]\n+\n+    def fake_waitpid(pid, flags):\n+        call_count[0] += 1\n+        if call_count[0] == 1:\n+            return 99999, 0\n+        return 0, 0\n+\n+    with patch(\"hermes_cli.kanban_db.os.waitpid\", side_effect=fake_waitpid):\n+        with patch(\"hermes_cli.kanban_db._record_worker_exit\"):\n+            with patch(\"hermes_cli.kanban_db.os.name\", \"posix\"):\n+                pids = kb.reap_worker_zombies()\n+\n+    assert pids == [99999]"]},{"file":"tests/test_hermes_state.py","hunks":["@@ -3021,3 +3021,223 @@ def test_v10_to_v11_upgrade_backfills_tool_fields(self, tmp_path):\n         finally:\n             session_db.close()\n \n+\n+# ---------------------------------------------------------------------------\n+# apply_wal_with_fallback — read-only probe tests\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestApplyWalProbe:\n+    \"\"\"Unit tests for the journal_mode probe in apply_wal_with_fallback.\"\"\"\n+\n+    def test_skips_set_pragma_when_already_wal(self, tmp_path):\n+        \"\"\"Already-WAL connection must not trigger the set-pragma.\"\"\"\n+        import sqlite3\n+        from hermes_state import apply_wal_with_fallback\n+\n+        class _TracingConn(sqlite3.Connection):\n+            def __init__(self, *a, **kw):\n+                super().__init__(*a, **kw)\n+                self.executed = []\n+\n+            def execute(self, sql, params=()):\n+                self.executed.append(sql)\n+                return super().execute(sql, params)\n+\n+        db_path = tmp_path / \"wal.db\"\n+        # Prime the file into WAL mode first.\n+        with sqlite3.connect(str(db_path)) as seed:\n+            seed.execute(\"PRAGMA journal_mode=WAL\")\n+\n+        conn = _TracingConn(str(db_path))\n+        try:\n+            result = apply_wal_with_fallback(conn)\n+        finally:\n+            conn.close()\n+\n+        assert result == \"wal\"\n+        # Only the probe should have fired; the set-pragma must NOT appear.\n+        assert any(\"PRAGMA journal_mode\" == sql.strip() for sql in conn.executed), (\n+            \"probe PRAGMA should have run\"\n+        )\n+        assert not any(\"journal_mode=WAL\" in sql for sql in conn.executed), (\n+            \"set-pragma must not run when already in WAL mode\"\n+        )\n+\n+    def test_sets_wal_on_fresh_connection(self, tmp_path):\n+        \"\"\"Probe sees 'delete', then set-pragma runs and returns 'wal'.\"\"\"\n+        import sqlite3\n+        from hermes_state import apply_wal_with_fallback\n+\n+        class _TracingConn(sqlite3.Connection):\n+            def __init__(self, *a, **kw):\n+                super().__init__(*a, **kw)\n+                self.executed = []\n+\n+            def execute(self, sql, params=()):\n+                self.executed.append(sql)\n+                return super().execute(sql, params)\n+\n+        db_path = tmp_path / \"fresh.db\"\n+        conn = _TracingConn(str(db_path))\n+        try:\n+            result = apply_wal_with_fallback(conn)\n+        finally:\n+            conn.close()\n+\n+        assert result == \"wal\"\n+        assert any(\"journal_mode=WAL\" in sql for sql in conn.executed), (\n+            \"set-pragma must fire on a fresh (non-WAL) connection\"\n+        )\n+\n+    def test_apply_wal_concurrent_connects_no_eio(self, tmp_path):\n+        \"\"\"20 threads calling connect() on the same DB must not see disk I/O error.\"\"\"\n+        import sys\n+        import threading\n+        import sqlite3\n+        from hermes_state import apply_wal_with_fallback\n+\n+        db_path = tmp_path / \"concurrent.db\"\n+        errors = []\n+\n+        def _connect_cycle():\n+            for _ in range(5):\n+                try:\n+                    conn = sqlite3.connect(str(db_path))\n+                    apply_wal_with_fallback(conn)\n+                    conn.close()\n+                except sqlite3.OperationalError as exc:\n+                    if \"disk i/o error\" in str(exc).lower():\n+                        errors.append(exc)\n+\n+        threads = [threading.Thread(target=_connect_cycle) for _ in range(20)]\n+        for t in threads:\n+            t.start()\n+        for t in threads:\n+            t.join()\n+\n+        assert not errors, f\"disk I/O errors from concurrent connects: {errors}\"\n+\n+        # Linux-only: no (deleted) WAL/SHM FDs should accumulate.\n+        if sys.platform == \"linux\":\n+            import os\n+\n+            fd_dir = f\"/proc/{os.getpid()}/fd\"\n+            deleted_fds = []\n+            for fd_name in os.listdir(fd_dir):\n+                try:\n+                    target = os.readlink(os.path.join(fd_dir, fd_name))\n+                    if \"(deleted)\" in target and (\n+                        \"wal\" in target.lower() or \"shm\" in target.lower()\n+                    ):\n+                        deleted_fds.append(target)\n+                except OSError:\n+                    pass\n+            assert not deleted_fds, f\"stale deleted WAL/SHM FDs: {deleted_fds}\"\n+\n+    def test_fallback_to_delete_still_works(self, tmp_path):\n+        \"\"\"When set-pragma raises a WAL-incompat error, falls back to DELETE.\"\"\"\n+        import sqlite3\n+        from hermes_state import apply_wal_with_fallback\n+\n+        class _IncompatConn(sqlite3.Connection):\n+            def __init__(self, *a, **kw):\n+                super().__init__(*a, **kw)\n+                self._call_count = 0\n+\n+            def execute(self, sql, params=()):\n+                self._call_count += 1\n+                # First call is the read probe; let it return \"delete\".\n+                # Second call is the set-pragma; raise a WAL-incompat error.\n+                if \"journal_mode=WAL\" in sql:\n+                    raise sqlite3.OperationalError(\"locking protocol\")\n+                return super().execute(sql, params)\n+\n+        db_path = tmp_path / \"incompat.db\"\n+        conn = _IncompatConn(str(db_path))\n+        try:\n+            result = apply_wal_with_fallback(conn, db_label=\"test.db\")\n+        finally:\n+            conn.close()\n+\n+        assert result == \"delete\"\n+\n+    def test_probe_failure_falls_through_to_set_pragma(self, tmp_path):\n+        \"\"\"When the read probe raises OperationalError, fall through to set-pragma.\"\"\"\n+        import sqlite3\n+        from hermes_state import apply_wal_with_fallback\n+\n+        class _ProbeFails(sqlite3.Connection):\n+            def __init__(self, *a, **kw):\n+                super().__init__(*a, **kw)\n+                self._first = True\n+\n+            def execute(self, sql, params=()):\n+                if self._first and \"journal_mode\" in sql and \"WAL\" not in sql:\n+                    self._first = False\n+                    raise sqlite3.OperationalError(\"simulated probe failure\")\n+                return super().execute(sql, params)\n+\n+        db_path = tmp_path / \"probe_fail.db\"\n+        conn = _ProbeFails(str(db_path))\n+        try:\n+            result = apply_wal_with_fallback(conn)\n+        finally:\n+            conn.close()\n+\n+        # Despite probe failure, set-pragma must still run and succeed.\n+        assert result == \"wal\"\n+\n+    def test_no_downgrade_from_wal_to_delete_on_eio(self, tmp_path):\n+        \"\"\"OperationalError NOT in _WAL_INCOMPAT_MARKERS must propagate, not downgrade.\"\"\"\n+        import sqlite3\n+        import pytest\n+        from hermes_state import apply_wal_with_fallback\n+\n+        class _EIOConn(sqlite3.Connection):\n+            def __init__(self, *a, **kw):\n+                super().__init__(*a, **kw)\n+                self._first = True\n+\n+            def execute(self, sql, params=()):\n+                # Let the probe succeed (returns \"delete\" for fresh DB).\n+                if \"journal_mode=WAL\" in sql:\n+                    raise sqlite3.OperationalError(\"some unexpected hardware failure\")\n+                return super().execute(sql, params)\n+\n+        db_path = tmp_path / \"eio.db\"\n+        conn = _EIOConn(str(db_path))\n+        try:\n+            with pytest.raises(\n+                sqlite3.OperationalError, match=\"some unexpected hardware failure\"\n+            ):\n+                apply_wal_with_fallback(conn)\n+        finally:\n+            conn.close()\n+\n+    def test_returns_wal_not_delete_from_probe(self, tmp_path):\n+        \"\"\"Early-return only on 'wal'; 'delete' or 'memory' must fall through to set-pragma.\"\"\"\n+        import sqlite3\n+        from hermes_state import apply_wal_with_fallback\n+\n+        class _TracingConn(sqlite3.Connection):\n+            def __init__(self, *a, **kw):\n+                super().__init__(*a, **kw)\n+                self.executed = []\n+\n+            def execute(self, sql, params=()):\n+                self.executed.append(sql)\n+                return super().execute(sql, params)\n+\n+        # Fresh DB is in \"delete\" mode — probe returns \"delete\", must NOT early-return.\n+        db_path = tmp_path / \"delete_mode.db\"\n+        conn = _TracingConn(str(db_path))\n+        try:\n+            result = apply_wal_with_fallback(conn)\n+        finally:\n+            conn.close()\n+\n+        assert result == \"wal\"\n+        assert any(\"journal_mode=WAL\" in sql for sql in conn.executed), (\n+            \"set-pragma must fire when probe returns 'delete'\"\n+        )"]},{"file":"tests/test_hermes_state_wal_fallback.py","hunks":["@@ -110,15 +110,79 @@ def test_falls_back_on_not_authorized(self, tmp_path):\n         assert mode == \"delete\"\n         conn.close()\n \n-    def test_falls_back_on_disk_io_error(self, tmp_path):\n-        \"\"\"Flaky network FS → disk I/O error → still fall back.\"\"\"\n+    def test_reraises_on_disk_io_error(self, tmp_path):\n+        \"\"\"Transient EIO from ``PRAGMA journal_mode=WAL`` must NOT silently\n+        downgrade to DELETE.\n+\n+        Regression for \"Bug D\": treating transient EIO as a permanent\n+        WAL-incompat marker produced the mixed-journal-mode-across-processes\n+        corruption pattern (process A downgrades to DELETE, sibling\n+        processes successfully set WAL, SQLite corrupts the file because\n+        the two locking protocols are documented as incompatible). EIO is\n+        usually transient (page-cache pressure, lock contention, brief\n+        storage hiccups); the right behavior is to re-raise so the caller\n+        can retry, not to walk the DB into a permanently downgraded state.\n+        \"\"\"\n         conn, _ = _open_blocking(\n             tmp_path / \"flaky.db\", reason=\"disk I/O error\", isolation_level=None\n         )\n-        mode = apply_wal_with_fallback(conn)\n-        assert mode == \"delete\"\n+        with pytest.raises(sqlite3.OperationalError, match=\"disk I/O error\"):\n+            apply_wal_with_fallback(conn)\n         conn.close()\n \n+    def test_does_not_downgrade_when_disk_says_wal(self, tmp_path):\n+        \"\"\"Refuse to downgrade an already-WAL DB even if the set-pragma path\n+        would have raised a downgrade-eligible marker.\n+\n+        With the WAL-skip patch, the read-only probe short-circuits before\n+        ``PRAGMA journal_mode=WAL`` ever runs on an already-WAL connection,\n+        so the set-pragma path is unreachable here and ``attempts`` stays 0.\n+        Either outcome (skip-via-probe OR re-raise-on-disk-check) preserves\n+        the property this test guards: we never silently DELETE-downgrade\n+        a WAL-mode file. The on-disk guard remains in place as\n+        belt-and-suspenders for any future code path that bypasses the\n+        probe.\n+        \"\"\"\n+        # Prime the file in WAL mode using a normal connection\n+        primer = sqlite3.connect(\n+            str(tmp_path / \"already-wal.db\"), isolation_level=None\n+        )\n+        try:\n+            primer.execute(\"PRAGMA journal_mode=WAL\")\n+            primer.execute(\"CREATE TABLE t (x INTEGER)\")\n+            primer.execute(\"INSERT INTO t VALUES (1)\")\n+            assert (\n+                primer.execute(\"PRAGMA journal_mode\").fetchone()[0].lower() == \"wal\"\n+            )\n+        finally:\n+            primer.close()\n+\n+        # New connection whose set-WAL pragma would raise \"locking protocol\"\n+        # if it were ever called. With the WAL-skip patch the probe sees\n+        # journal_mode=wal and returns early, so set-WAL is never attempted.\n+        conn, attempts = _open_blocking(\n+            tmp_path / \"already-wal.db\",\n+            reason=\"locking protocol\",\n+            isolation_level=None,\n+        )\n+        result = apply_wal_with_fallback(conn)\n+        assert result == \"wal\", (\n+            \"must report wal mode (either skipped via probe or refused downgrade)\"\n+        )\n+        assert attempts[0] == 0, (\n+            \"set-WAL pragma must not run when the on-disk header already says wal\"\n+        )\n+        conn.close()\n+\n+        # And the file is STILL WAL on disk — nothing got rewritten\n+        check = sqlite3.connect(str(tmp_path / \"already-wal.db\"))\n+        try:\n+            assert (\n+                check.execute(\"PRAGMA journal_mode\").fetchone()[0].lower() == \"wal\"\n+            )\n+        finally:\n+            check.close()\n+\n     def test_reraises_unrelated_operational_error(self, tmp_path):\n         \"\"\"Non-WAL-compat errors must NOT be silently swallowed by the fallback.\"\"\"\n         conn, _ = _open_blocking(\n"]}]},{"kind":"pr","number":45525,"title":"fix(kanban): avoid WAL false corruption under worker bursts","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#45383","number":45383}],"category":"wontfix_bydesign","implication":"wontfix"},"merged":false},{"kind":"pr","number":60653,"title":"fix(kanban): probe integrity_check in read-only immutable mode to avoid WAL checkpoint race","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#29507","number":29507}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":67477,"title":"fix(kanban): serialize all SQLite writers across processes","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#31740","number":31740},{"kind":"pr","value":"#45525","number":45525},{"kind":"pr","value":"#60653","number":60653}],"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}