{"repo":"NousResearch/hermes-agent","public_id":"20842","display_label":"Kanban DB migration race conditions and column errors","summary":"These issues and fixes center on fragile SQLite schema migrations in the kanban module, including race conditions on concurrent startup, duplicate column errors on legacy databases, and indexes being created before their columns exist.","subsystem":"hermes_cli","counts":{"members":28,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":12,"merged_prs":5,"attempt_prs":11},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":20855},"resolution":{"state":"partially_resolved","by":"pr-20855,pr-22800,pr-22994,pr-28781"},"freshness":{"state":"verified","last_verified_at":"2026-05-19T15:09:11+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.08730158730158727},"fix_edges":[{"issue_number":20842,"pr_number":20855,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":21374,"pr_number":22994,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":21378,"pr_number":22994,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":21503,"pr_number":22994,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":21708,"pr_number":22669,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":21708,"pr_number":22800,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":22459,"pr_number":22669,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":28464,"pr_number":28781,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":28554,"pr_number":28781,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":28617,"pr_number":28781,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":28654,"pr_number":28781,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":28698,"pr_number":28781,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":28712,"pr_number":28781,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":20842,"title":"Kanban migration fails after update: no such column spawn_failures","url":null,"state":"closed"},{"kind":"issue","number":21374,"title":"Race condition in kanban _migrate_add_optional_columns on gateway startup","url":null,"state":"closed"},{"kind":"issue","number":21378,"title":"sqlite3.OperationalError: database is locked at first kanban dispatcher tick after every gateway startup","url":null,"state":"closed"},{"kind":"issue","number":21503,"title":"Kanban migration crashes on duplicate column: ALTER TABLE ADD COLUMN not idempotent","url":null,"state":"closed"},{"kind":"issue","number":21708,"title":"kanban dispatcher: 'duplicate column name: consecutive_failures' on first tick after gateway restart","url":null,"state":"closed"},{"kind":"issue","number":26572,"title":"[Bug] kanban dispatcher tick fails with \"duplicate column name: max_retries\" on legacy DB","url":null,"state":"closed"},{"kind":"issue","number":28464,"title":"Kanban legacy DB migration can fail creating indexes before additive columns exist","url":null,"state":"closed"},{"kind":"issue","number":28554,"title":"fix(kanban): SCHEMA_SQL CREATE INDEX on session_id fails when upgrading from older kanban.db","url":null,"state":"closed"},{"kind":"issue","number":28617,"title":"kanban: SCHEMA_SQL crashes on existing databases when new column is added — migration code never reached","url":null,"state":"closed"},{"kind":"issue","number":28654,"title":"[Bug] Kanban: sqlite3.OperationalError: no such column: session_id after update","url":null,"state":"closed"},{"kind":"issue","number":28698,"title":"Kanban legacy DB migration fails creating session_id index before column exists","url":null,"state":"closed"},{"kind":"issue","number":28844,"title":"kanban: SCHEMA_SQL creates idx_tasks_session_id before _migrate_add_optional_columns adds the column","url":null,"state":"closed"},{"kind":"pr","number":20848,"title":"fix(kanban): avoid fragile failure-column renames","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#20855","number":20855},{"kind":"pr","value":"#20842","number":20842}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":20855,"title":"fix(kanban): avoid fragile failure-column renames (salvage #20848)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/kanban_db.py","tests/hermes_cli/test_kanban_core_functionality.py"],"fix_hunks":[{"file":"hermes_cli/kanban_db.py","hunks":["@@ -628,11 +628,16 @@ def from_row(cls, row: sqlite3.Row) -> \"Task\":\n             idempotency_key=row[\"idempotency_key\"] if \"idempotency_key\" in keys else None,\n             consecutive_failures=(\n                 row[\"consecutive_failures\"] if \"consecutive_failures\" in keys\n+                # Pre-migration fallback: ``_migrate_add_optional_columns`` always\n+                # adds ``consecutive_failures`` now, so this branch is only reachable\n+                # on a DB that was never opened since pre-#20410 code ran. Keep for\n+                # belt-and-suspenders safety; in practice it is dead code post-migration.\n                 else (row[\"spawn_failures\"] if \"spawn_failures\" in keys else 0)\n             ),\n             worker_pid=row[\"worker_pid\"] if \"worker_pid\" in keys else None,\n             last_failure_error=(\n                 row[\"last_failure_error\"] if \"last_failure_error\" in keys\n+                # Same belt-and-suspenders fallback as consecutive_failures above.\n                 else (row[\"last_spawn_error\"] if \"last_spawn_error\" in keys else None)\n             ),\n             max_runtime_seconds=(","@@ -953,31 +958,40 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:\n             \"CREATE INDEX IF NOT EXISTS idx_tasks_idempotency \"\n             \"ON tasks(idempotency_key)\"\n         )\n-    # Legacy column rename: ``spawn_failures`` → ``consecutive_failures``\n-    # and ``last_spawn_error`` → ``last_failure_error``. The counter was\n-    # originally spawn-only; it's now unified across spawn/timeout/\n-    # crash outcomes. Rename when only the legacy columns exist to\n-    # preserve historical counter values across upgrades. Add fresh\n-    # otherwise.\n+    # Legacy column migration: ``spawn_failures`` → ``consecutive_failures``\n+    # and ``last_spawn_error`` → ``last_failure_error``.\n+    #\n+    # Avoid ``ALTER TABLE ... RENAME COLUMN`` for two reasons:\n+    #   1. Primary: very old DBs may never have had ``spawn_failures`` at\n+    #      all, so RENAME raises OperationalError: no such column (the crash\n+    #      reported in issue #20842 after the #20410 update).\n+    #   2. Secondary: SQLite reparses the whole schema on any RENAME, which\n+    #      fails if related objects (views, triggers) reference the old name.\n+    #\n+    # ADD-first-then-copy is tolerant of both shapes and preserves\n+    # historical counter values when the legacy columns do exist.\n+    #\n+    # NOTE: ``cols`` reflects the schema at entry to this function and is\n+    # not refreshed between ALTER TABLE calls.  Every guard below checks\n+    # the *original* snapshot; this is intentional and safe as long as\n+    # no step depends on a column added by a previous step in the same call.\n     if \"consecutive_failures\" not in cols:\n+        conn.execute(\n+            \"ALTER TABLE tasks ADD COLUMN consecutive_failures \"\n+            \"INTEGER NOT NULL DEFAULT 0\"\n+        )\n         if \"spawn_failures\" in cols:\n             conn.execute(\n-                \"ALTER TABLE tasks RENAME COLUMN spawn_failures TO consecutive_failures\"\n-            )\n-        else:\n-            conn.execute(\n-                \"ALTER TABLE tasks ADD COLUMN consecutive_failures \"\n-                \"INTEGER NOT NULL DEFAULT 0\"\n+                \"UPDATE tasks SET consecutive_failures = COALESCE(spawn_failures, 0)\"\n             )\n     if \"worker_pid\" not in cols:\n         conn.execute(\"ALTER TABLE tasks ADD COLUMN worker_pid INTEGER\")\n     if \"last_failure_error\" not in cols:\n+        conn.execute(\"ALTER TABLE tasks ADD COLUMN last_failure_error TEXT\")\n         if \"last_spawn_error\" in cols:\n             conn.execute(\n-                \"ALTER TABLE tasks RENAME COLUMN last_spawn_error TO last_failure_error\"\n+                \"UPDATE tasks SET last_failure_error = last_spawn_error\"\n             )\n-        else:\n-            conn.execute(\"ALTER TABLE tasks ADD COLUMN last_failure_error TEXT\")\n     if \"max_runtime_seconds\" not in cols:\n         conn.execute(\"ALTER TABLE tasks ADD COLUMN max_runtime_seconds INTEGER\")\n     if \"last_heartbeat_at\" not in cols:"]},{"file":"tests/hermes_cli/test_kanban_core_functionality.py","hunks":["@@ -2648,6 +2648,203 @@ def test_legacy_db_without_skills_column_migrates(tmp_path):\n     conn.close()\n \n \n+def test_legacy_spawn_failure_columns_are_copied_not_renamed(tmp_path):\n+    \"\"\"Legacy failure counters survive migration without fragile column renames.\"\"\"\n+    import sqlite3\n+    db_path = tmp_path / \"legacy-failures.db\"\n+    conn = sqlite3.connect(str(db_path))\n+    conn.row_factory = sqlite3.Row\n+    conn.execute(\"\"\"\n+        CREATE TABLE tasks (\n+            id TEXT PRIMARY KEY,\n+            title TEXT NOT NULL,\n+            body TEXT,\n+            assignee TEXT,\n+            status TEXT NOT NULL,\n+            priority INTEGER DEFAULT 0,\n+            created_by TEXT,\n+            created_at INTEGER NOT NULL,\n+            started_at INTEGER,\n+            completed_at INTEGER,\n+            workspace_kind TEXT NOT NULL DEFAULT 'scratch',\n+            workspace_path TEXT,\n+            claim_lock TEXT,\n+            claim_expires INTEGER,\n+            tenant TEXT,\n+            result TEXT,\n+            idempotency_key TEXT,\n+            spawn_failures INTEGER NOT NULL DEFAULT 0,\n+            worker_pid INTEGER,\n+            last_spawn_error TEXT\n+        )\n+    \"\"\")\n+    conn.execute(\"\"\"\n+        CREATE TABLE task_events (\n+            id INTEGER PRIMARY KEY AUTOINCREMENT,\n+            task_id TEXT NOT NULL,\n+            kind TEXT NOT NULL,\n+            payload TEXT,\n+            created_at INTEGER NOT NULL\n+        )\n+    \"\"\")\n+    # task_events is required: _migrate_add_optional_columns also runs a\n+    # PRAGMA on it to back-fill the run_id column and raises\n+    # OperationalError if the table is absent.\n+    conn.execute(\n+        \"INSERT INTO tasks \"\n+        \"(id, title, body, assignee, status, priority, created_by, created_at, \"\n+        \"started_at, completed_at, workspace_kind, workspace_path, claim_lock, \"\n+        \"claim_expires, tenant, result, idempotency_key, spawn_failures, \"\n+        \"worker_pid, last_spawn_error) \"\n+        \"VALUES ('legacy', 'old task', NULL, 'default', 'ready', 0, NULL, 1, \"\n+        \"NULL, NULL, 'scratch', NULL, NULL, NULL, NULL, NULL, NULL, 4, NULL, \"\n+        \"'missing profile')\"\n+    )\n+    conn.commit()\n+\n+    kb._migrate_add_optional_columns(conn)\n+    cols = {r[1] for r in conn.execute(\"PRAGMA table_info(tasks)\")}\n+    assert \"spawn_failures\" in cols\n+    assert \"consecutive_failures\" in cols\n+    assert \"last_spawn_error\" in cols\n+    assert \"last_failure_error\" in cols\n+\n+    row = conn.execute(\"SELECT * FROM tasks WHERE id = 'legacy'\").fetchone()\n+    assert row[\"consecutive_failures\"] == 4\n+    assert row[\"last_failure_error\"] == \"missing profile\"\n+    task = kb.Task.from_row(row)\n+    assert task.consecutive_failures == 4\n+    assert task.last_failure_error == \"missing profile\"\n+\n+    kb._migrate_add_optional_columns(conn)\n+    row_again = conn.execute(\"SELECT * FROM tasks WHERE id = 'legacy'\").fetchone()\n+    assert row_again[\"consecutive_failures\"] == 4\n+    assert row_again[\"last_failure_error\"] == \"missing profile\"\n+    conn.close()\n+\n+\n+def test_legacy_migration_no_legacy_columns_at_all(tmp_path):\n+    \"\"\"Scenario A: DB has neither spawn_failures nor consecutive_failures.\n+\n+    This is the exact crash scenario from issue #20842 — a very old DB that\n+    predates the spawn_failures column entirely.  The old RENAME COLUMN path\n+    raised ``sqlite3.OperationalError: no such column: spawn_failures``.\n+    The ADD-first approach adds consecutive_failures with default 0.\n+    \"\"\"\n+    import sqlite3\n+\n+    db_path = tmp_path / \"ancient.db\"\n+    conn = sqlite3.connect(str(db_path))\n+    conn.row_factory = sqlite3.Row\n+    conn.execute(\"\"\"\n+        CREATE TABLE tasks (\n+            id TEXT PRIMARY KEY,\n+            title TEXT NOT NULL,\n+            status TEXT NOT NULL,\n+            created_at INTEGER NOT NULL\n+        )\n+    \"\"\")\n+    # task_events is required: _migrate_add_optional_columns also runs a\n+    # PRAGMA on it to back-fill the run_id column and raises\n+    # OperationalError if the table is absent.\n+    conn.execute(\"\"\"\n+        CREATE TABLE task_events (\n+            id INTEGER PRIMARY KEY AUTOINCREMENT,\n+            task_id TEXT NOT NULL,\n+            kind TEXT NOT NULL,\n+            payload TEXT,\n+            created_at INTEGER NOT NULL\n+        )\n+    \"\"\")\n+    conn.execute(\n+        \"INSERT INTO tasks (id, title, status, created_at) \"\n+        \"VALUES ('t1', 'ancient task', 'ready', 1)\"\n+    )\n+    conn.commit()\n+\n+    # Must not raise (this was the crash before this fix).\n+    kb._migrate_add_optional_columns(conn)\n+\n+    cols = {r[1] for r in conn.execute(\"PRAGMA table_info(tasks)\")}\n+    assert \"consecutive_failures\" in cols, \"migration must add consecutive_failures\"\n+    assert \"last_failure_error\" in cols, \"migration must add last_failure_error\"\n+    assert \"spawn_failures\" not in cols, \"no legacy column should be synthesised\"\n+\n+    row = conn.execute(\"SELECT * FROM tasks WHERE id = 't1'\").fetchone()\n+    assert row[\"consecutive_failures\"] == 0\n+    assert row[\"last_failure_error\"] is None\n+\n+    # Idempotent second run must not raise either.\n+    kb._migrate_add_optional_columns(conn)\n+    row_again = conn.execute(\"SELECT * FROM tasks WHERE id = 't1'\").fetchone()\n+    assert row_again[\"consecutive_failures\"] == 0\n+    assert row_again[\"last_failure_error\"] is None\n+    conn.close()\n+\n+\n+def test_legacy_migration_both_columns_already_present(tmp_path):\n+    \"\"\"Scenario D: DB already has both spawn_failures AND consecutive_failures.\n+\n+    Represents a partially-migrated DB (e.g. user recovered manually after the\n+    #20842 crash).  The migration must be a complete no-op and must not\n+    zero-out the existing counter.\n+    \"\"\"\n+    import sqlite3\n+\n+    db_path = tmp_path / \"partial.db\"\n+    conn = sqlite3.connect(str(db_path))\n+    conn.row_factory = sqlite3.Row\n+    conn.execute(\"\"\"\n+        CREATE TABLE tasks (\n+            id TEXT PRIMARY KEY,\n+            title TEXT NOT NULL,\n+            status TEXT NOT NULL,\n+            created_at INTEGER NOT NULL,\n+            spawn_failures INTEGER NOT NULL DEFAULT 0,\n+            consecutive_failures INTEGER NOT NULL DEFAULT 0,\n+            last_spawn_error TEXT,\n+            last_failure_error TEXT\n+        )\n+    \"\"\")\n+    # task_events required for the run_id back-fill PRAGMA inside the migrator.\n+    conn.execute(\"\"\"\n+        CREATE TABLE task_events (\n+            id INTEGER PRIMARY KEY AUTOINCREMENT,\n+            task_id TEXT NOT NULL,\n+            kind TEXT NOT NULL,\n+            payload TEXT,\n+            created_at INTEGER NOT NULL\n+        )\n+    \"\"\")\n+    conn.execute(\n+        \"INSERT INTO tasks (id, title, status, created_at, spawn_failures, \"\n+        \"consecutive_failures, last_spawn_error, last_failure_error) \"\n+        \"VALUES ('t2', 'partial task', 'ready', 1, 2, 3, 'old error', 'new error')\"\n+    )\n+    conn.commit()\n+\n+    kb._migrate_add_optional_columns(conn)\n+\n+    row = conn.execute(\"SELECT * FROM tasks WHERE id = 't2'\").fetchone()\n+    # consecutive_failures must not be reset by the migration.\n+    assert row[\"consecutive_failures\"] == 3, \"migration must not overwrite existing counter\"\n+    assert row[\"last_failure_error\"] == \"new error\", \"migration must not overwrite existing error\"\n+    # Legacy column is preserved harmlessly.\n+    assert row[\"spawn_failures\"] == 2\n+\n+    # Schema must be unchanged — no spurious ADD or DROP.\n+    cols_after = {r[1] for r in conn.execute(\"PRAGMA table_info(tasks)\")}\n+    assert \"consecutive_failures\" in cols_after\n+    assert \"last_failure_error\" in cols_after\n+    assert \"spawn_failures\" in cols_after  # legacy preserved\n+\n+    # Idempotent second run must not modify values or raise.\n+    kb._migrate_add_optional_columns(conn)\n+    row_again = conn.execute(\"SELECT * FROM tasks WHERE id = 't2'\").fetchone()\n+    assert row_again[\"consecutive_failures\"] == 3\n+    assert row_again[\"last_failure_error\"] == \"new error\"\n+    conn.close()\n+\n \n # ---------------------------------------------------------------------------\n # Gateway-embedded dispatcher: config, CLI warnings, daemon deprecation stub\n"]}]},{"kind":"pr","number":21395,"title":"fix(kanban): serialize first-use schema initialization","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"78698381a","number":0},{"kind":"pr","value":"#22627","number":22627},{"kind":"pr","value":"#22994","number":22994},{"kind":"pr","value":"#21378","number":21378}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":21529,"title":"fix(kanban): make column migrations idempotent under concurrent access","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22627","number":22627},{"kind":"pr","value":"#22994","number":22994}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":21552,"title":"fix: harden kanban optional-column migrations","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#21021","number":21021},{"kind":"pr","value":"#21529","number":21529},{"kind":"pr","value":"#21503","number":21503},{"kind":"pr","value":"#21374","number":21374}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":21628,"title":"fix(kanban): wrap ALTER TABLE ADD COLUMN with race-safe helper","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"78698381a","number":0},{"kind":"pr","value":"#22627","number":22627},{"kind":"pr","value":"#22994","number":22994},{"kind":"pr","value":"#22627","number":22627},{"kind":"pr","value":"#22994","number":22994},{"kind":"pr","value":"#21378","number":21378}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":22627,"title":"fix(kanban): make _migrate_add_optional_columns idempotent on concurrent open","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22800","number":22800},{"kind":"pr","value":"#22669","number":22669}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":22669,"title":"fix(kanban): call recompute_ready after unlink_tasks (salvage #22646)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/kanban_db.py","scripts/release.py","tests/hermes_cli/test_kanban_db.py"],"fix_hunks":[{"file":"hermes_cli/kanban_db.py","hunks":["@@ -1504,7 +1504,14 @@ def unlink_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> boo\n                 conn, child_id, \"unlinked\",\n                 {\"parent\": parent_id, \"child\": child_id},\n             )\n-        return cur.rowcount > 0\n+        removed = cur.rowcount > 0\n+    if removed:\n+        # Dependency edge removed — re-evaluate promotion eligibility for the\n+        # child immediately.  Matches the contract of complete_task and\n+        # unblock_task; without this the child stays stuck in todo until the\n+        # next dispatcher tick or a manual `hermes kanban recompute` (issue #22459).\n+        recompute_ready(conn)\n+    return removed\n \n \n def parent_ids(conn: sqlite3.Connection, task_id: str) -> list[str]:"]},{"file":"scripts/release.py","hunks":["@@ -64,6 +64,7 @@\n     \"[redacted:email]\": \"liquidchen\",\n     \"[redacted:email]\": \"subtract0\",\n     \"[redacted:email]\": \"qWaitCrypto\",\n+    \"[redacted:email]\": \"wesleysimplicio\",\n     \"[redacted:email]\": \"uzunkuyruk\",\n     \"[redacted:email]\": \"Zhekinmaksim\",\n     \"[redacted:email]\": \"KvnGz\","]},{"file":"tests/hermes_cli/test_kanban_db.py","hunks":["@@ -966,3 +966,37 @@ def wal_blocking_connect(*args, **kwargs):\n     tasks = kb.list_tasks(conn)\n     assert any(row.id == t for row in tasks)\n     conn.close()\n+\n+\n+def test_unlink_tasks_triggers_recompute_ready(kanban_home):\n+    \"\"\"Regression test for issue #22459.\n+\n+    Removing a dependency via unlink_tasks must immediately promote the child\n+    to ready when all remaining parents are done — same contract as\n+    complete_task and unblock_task.\n+\n+    Before the fix, child stayed 'todo' indefinitely after unlink; only the\n+    next dispatcher tick or a manual 'hermes kanban recompute' would promote it.\n+    \"\"\"\n+    with kb.connect() as conn:\n+        # A is done.\n+        a = kb.create_task(conn, title=\"parent-done\")\n+        kb.complete_task(conn, a)\n+\n+        # C is running (not done) — blocks child B.\n+        c = kb.create_task(conn, title=\"parent-running\")\n+        kb.claim_task(conn, c, claimer=\"worker:1\")\n+\n+        # B depends on both A (done) and C (running) → stays todo.\n+        b = kb.create_task(conn, title=\"child\", parents=[a, c])\n+        assert kb.get_task(conn, b).status == \"todo\"\n+\n+        # Remove the blocking dependency C → B.\n+        removed = kb.unlink_tasks(conn, c, b)\n+        assert removed is True\n+\n+        # B's only remaining parent is A (done) → must be ready immediately.\n+        assert kb.get_task(conn, b).status == \"ready\", (\n+            \"child should promote to ready immediately after unlink_tasks \"\n+            \"removes its last blocking dependency\"\n+        )\n"]}]},{"kind":"pr","number":22800,"title":"fix(kanban): make _migrate_add_optional_columns idempotent on concurrent open (salvage #22627)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/kanban_db.py","tests/hermes_cli/test_kanban_db.py"],"fix_hunks":[{"file":"hermes_cli/kanban_db.py","hunks":["@@ -963,6 +963,25 @@ def init_db(\n     return path\n \n \n+def _add_column_if_missing(\n+    conn: sqlite3.Connection, table: str, column: str, ddl: str\n+) -> bool:\n+    \"\"\"Run ``ALTER TABLE <table> ADD COLUMN <ddl>``, idempotent across races.\n+\n+    Returns ``True`` when the column was actually added by this call.\n+    Swallows ``duplicate column name`` errors so a concurrent connection\n+    that ran the same migration first does not crash the dispatcher tick\n+    (issue #21708).\n+    \"\"\"\n+    try:\n+        conn.execute(f\"ALTER TABLE {table} ADD COLUMN {ddl}\")\n+        return True\n+    except sqlite3.OperationalError as exc:\n+        if \"duplicate column name\" in str(exc).lower():\n+            return False\n+        raise\n+\n+\n def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:\n     \"\"\"Add columns that were introduced after v1 release to legacy DBs.\n ","@@ -970,11 +989,13 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:\n     \"\"\"\n     cols = {row[\"name\"] for row in conn.execute(\"PRAGMA table_info(tasks)\")}\n     if \"tenant\" not in cols:\n-        conn.execute(\"ALTER TABLE tasks ADD COLUMN tenant TEXT\")\n+        _add_column_if_missing(conn, \"tasks\", \"tenant\", \"tenant TEXT\")\n     if \"result\" not in cols:\n-        conn.execute(\"ALTER TABLE tasks ADD COLUMN result TEXT\")\n+        _add_column_if_missing(conn, \"tasks\", \"result\", \"result TEXT\")\n     if \"idempotency_key\" not in cols:\n-        conn.execute(\"ALTER TABLE tasks ADD COLUMN idempotency_key TEXT\")\n+        _add_column_if_missing(\n+            conn, \"tasks\", \"idempotency_key\", \"idempotency_key TEXT\"\n+        )\n         conn.execute(\n             \"CREATE INDEX IF NOT EXISTS idx_tasks_idempotency \"\n             \"ON tasks(idempotency_key)\"","@@ -997,37 +1018,51 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:\n     # the *original* snapshot; this is intentional and safe as long as\n     # no step depends on a column added by a previous step in the same call.\n     if \"consecutive_failures\" not in cols:\n-        conn.execute(\n-            \"ALTER TABLE tasks ADD COLUMN consecutive_failures \"\n-            \"INTEGER NOT NULL DEFAULT 0\"\n+        added = _add_column_if_missing(\n+            conn,\n+            \"tasks\",\n+            \"consecutive_failures\",\n+            \"consecutive_failures INTEGER NOT NULL DEFAULT 0\",\n         )\n-        if \"spawn_failures\" in cols:\n+        if added and \"spawn_failures\" in cols:\n             conn.execute(\n                 \"UPDATE tasks SET consecutive_failures = COALESCE(spawn_failures, 0)\"\n             )\n     if \"worker_pid\" not in cols:\n-        conn.execute(\"ALTER TABLE tasks ADD COLUMN worker_pid INTEGER\")\n+        _add_column_if_missing(conn, \"tasks\", \"worker_pid\", \"worker_pid INTEGER\")\n     if \"last_failure_error\" not in cols:\n-        conn.execute(\"ALTER TABLE tasks ADD COLUMN last_failure_error TEXT\")\n-        if \"last_spawn_error\" in cols:\n+        added = _add_column_if_missing(\n+            conn, \"tasks\", \"last_failure_error\", \"last_failure_error TEXT\"\n+        )\n+        if added and \"last_spawn_error\" in cols:\n             conn.execute(\n                 \"UPDATE tasks SET last_failure_error = last_spawn_error\"\n             )\n     if \"max_runtime_seconds\" not in cols:\n-        conn.execute(\"ALTER TABLE tasks ADD COLUMN max_runtime_seconds INTEGER\")\n+        _add_column_if_missing(\n+            conn, \"tasks\", \"max_runtime_seconds\", \"max_runtime_seconds INTEGER\"\n+        )\n     if \"last_heartbeat_at\" not in cols:\n-        conn.execute(\"ALTER TABLE tasks ADD COLUMN last_heartbeat_at INTEGER\")\n+        _add_column_if_missing(\n+            conn, \"tasks\", \"last_heartbeat_at\", \"last_heartbeat_at INTEGER\"\n+        )\n     if \"current_run_id\" not in cols:\n-        conn.execute(\"ALTER TABLE tasks ADD COLUMN current_run_id INTEGER\")\n+        _add_column_if_missing(\n+            conn, \"tasks\", \"current_run_id\", \"current_run_id INTEGER\"\n+        )\n     if \"workflow_template_id\" not in cols:\n-        conn.execute(\"ALTER TABLE tasks ADD COLUMN workflow_template_id TEXT\")\n+        _add_column_if_missing(\n+            conn, \"tasks\", \"workflow_template_id\", \"workflow_template_id TEXT\"\n+        )\n     if \"current_step_key\" not in cols:\n-        conn.execute(\"ALTER TABLE tasks ADD COLUMN current_step_key TEXT\")\n+        _add_column_if_missing(\n+            conn, \"tasks\", \"current_step_key\", \"current_step_key TEXT\"\n+        )\n     if \"skills\" not in cols:\n         # JSON array of skill names the dispatcher force-loads into the\n         # worker (additive to the built-in `kanban-worker`). NULL is fine\n         # for existing rows.\n-        conn.execute(\"ALTER TABLE tasks ADD COLUMN skills TEXT\")\n+        _add_column_if_missing(conn, \"tasks\", \"skills\", \"skills TEXT\")\n \n     if \"max_retries\" not in cols:\n         # Per-task override for the consecutive-failure circuit breaker.","@@ -1035,13 +1070,13 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:\n         # config, then ``DEFAULT_FAILURE_LIMIT``. Existing rows get NULL,\n         # which is the correct default (they keep the global behaviour\n         # they were getting before the column existed).\n-        conn.execute(\"ALTER TABLE tasks ADD COLUMN max_retries INTEGER\")\n+        _add_column_if_missing(conn, \"tasks\", \"max_retries\", \"max_retries INTEGER\")\n \n     # task_events gained a run_id column; back-fill it as NULL for\n     # historical events (they predate runs and can't be attributed).\n     ev_cols = {row[\"name\"] for row in conn.execute(\"PRAGMA table_info(task_events)\")}\n     if \"run_id\" not in ev_cols:\n-        conn.execute(\"ALTER TABLE task_events ADD COLUMN run_id INTEGER\")\n+        _add_column_if_missing(conn, \"task_events\", \"run_id\", \"run_id INTEGER\")\n         conn.execute(\n             \"CREATE INDEX IF NOT EXISTS idx_events_run \"\n             \"ON task_events(run_id, id)\""]},{"file":"tests/hermes_cli/test_kanban_db.py","hunks":["@@ -1116,3 +1116,86 @@ def test_unlink_tasks_triggers_recompute_ready(kanban_home):\n             \"child should promote to ready immediately after unlink_tasks \"\n             \"removes its last blocking dependency\"\n         )\n+# ---------------------------------------------------------------------------\n+# _add_column_if_missing / _migrate_add_optional_columns idempotency (#21708)\n+# ---------------------------------------------------------------------------\n+\n+def test_add_column_if_missing_is_idempotent_on_race(kanban_home):\n+    \"\"\"``_add_column_if_missing`` must swallow 'duplicate column name' errors.\n+\n+    Regression for #21708: the kanban dispatcher opens the DB twice per tick\n+    (once via _tick_once_for_board, once via init_db's discard-and-reconnect\n+    path).  A second concurrent connection runs _migrate_add_optional_columns\n+    before the first one commits, so ALTER TABLE raises OperationalError with\n+    'duplicate column name: consecutive_failures'.  Without the idempotency\n+    guard that crashes the dispatcher on the first tick after every restart.\n+    \"\"\"\n+    import sqlite3\n+\n+    conn = sqlite3.connect(\":memory:\")\n+    conn.row_factory = sqlite3.Row\n+    conn.execute(\n+        \"CREATE TABLE tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL)\"\n+    )\n+\n+    # First call adds the column — returns True.\n+    added = kb._add_column_if_missing(conn, \"tasks\", \"extra_col\", \"extra_col TEXT\")\n+    assert added is True\n+    cols = {row[\"name\"] for row in conn.execute(\"PRAGMA table_info(tasks)\")}\n+    assert \"extra_col\" in cols\n+\n+    # Second call on same connection — column already exists — must return\n+    # False without raising, simulating the race the dispatcher hits.\n+    added_again = kb._add_column_if_missing(\n+        conn, \"tasks\", \"extra_col\", \"extra_col TEXT\"\n+    )\n+    assert added_again is False\n+\n+    conn.close()\n+\n+\n+def test_migrate_add_optional_columns_tolerates_concurrent_migration(kanban_home):\n+    \"\"\"Full _migrate_add_optional_columns must not raise when columns already\n+    exist (issue #21708 race window — two connections migrate concurrently).\"\"\"\n+    import sqlite3\n+\n+    # Schema already in fully-migrated state (all optional columns present).\n+    conn = sqlite3.connect(\":memory:\")\n+    conn.row_factory = sqlite3.Row\n+    conn.execute(\n+        \"\"\"\n+        CREATE TABLE tasks (\n+            id INTEGER PRIMARY KEY,\n+            title TEXT NOT NULL,\n+            tenant TEXT,\n+            result TEXT,\n+            idempotency_key TEXT,\n+            consecutive_failures INTEGER NOT NULL DEFAULT 0,\n+            worker_pid INTEGER,\n+            last_failure_error TEXT,\n+            max_runtime_seconds INTEGER,\n+            last_heartbeat_at INTEGER,\n+            current_run_id INTEGER,\n+            workflow_template_id TEXT,\n+            current_step_key TEXT,\n+            skills TEXT,\n+            max_retries INTEGER\n+        )\n+        \"\"\"\n+    )\n+    conn.execute(\n+        \"\"\"\n+        CREATE TABLE task_events (\n+            id         INTEGER PRIMARY KEY AUTOINCREMENT,\n+            task_id    TEXT NOT NULL DEFAULT '',\n+            run_id     INTEGER,\n+            kind       TEXT NOT NULL DEFAULT '',\n+            payload    TEXT,\n+            created_at INTEGER NOT NULL DEFAULT 0\n+        )\n+        \"\"\"\n+    )\n+\n+    # Running migration on an already-migrated schema must not raise.\n+    kb._migrate_add_optional_columns(conn)\n+    conn.close()\n"]}]},{"kind":"pr","number":22994,"title":"fix(kanban): drop redundant init_db() in gateway watchers (salvage #21874)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","scripts/release.py","tests/hermes_cli/test_kanban_notify.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -3887,10 +3887,18 @@ def _collect():\n                         except Exception:\n                             continue\n                         try:\n-                            try:\n-                                _kb.init_db(board=slug)  # idempotent; handles first-run\n-                            except Exception:\n-                                pass\n+                            # `connect()` runs the schema + idempotent migration\n+                            # on first open per process, so an explicit\n+                            # `init_db()` here would be redundant. Worse:\n+                            # `init_db()` deliberately busts the per-process\n+                            # cache and re-runs the migration on a *second*\n+                            # connection, which races the first and used to\n+                            # log a benign but noisy `duplicate column name`\n+                            # traceback (and intermittent \"database is locked\"\n+                            # — issue #21378) on every gateway start against\n+                            # a legacy DB. `_add_column_if_missing` now\n+                            # tolerates that race, but we still skip the\n+                            # redundant call to avoid the wasted work.\n                             subs = _kb.list_notify_subs(conn)\n                             for sub in subs:\n                                 cursor, events = _kb.unseen_events_for_sub(","@@ -4183,10 +4191,12 @@ def _tick_once_for_board(slug: str) -> \"Optional[object]\":\n             conn = None\n             try:\n                 conn = _kb.connect(board=slug)\n-                try:\n-                    _kb.init_db(board=slug)  # idempotent, handles first-run\n-                except Exception:\n-                    pass\n+                # `connect()` runs the schema + idempotent migration on\n+                # first open per process; the previous explicit\n+                # `init_db()` call here busted the per-process cache and\n+                # re-ran the migration on a second connection, racing\n+                # the first. See the matching comment in\n+                # `_kanban_notifier_watcher` and issue #21378.\n                 return _kb.dispatch_once(\n                     conn,\n                     board=slug,"]},{"file":"scripts/release.py","hunks":["@@ -698,6 +698,7 @@\n     \"[redacted:email]\": \"ms-alan\",\n     \"[redacted:email]\": \"WuTianyi123\",\n     \"[redacted:email]\": \"li0near\",\n+    \"[redacted:email]\": \"li0near\",\n     \"[redacted:email]\": \"sicnuyudidi\",\n     \"[redacted:email]\": \"haimu0x\",\n     \"[redacted:email]\": \"NIDNASSER-Abdelmajid\","]},{"file":"tests/hermes_cli/test_kanban_notify.py","hunks":["@@ -197,3 +197,107 @@ async def _fast_sleep(_):\n         f\"Should receive 2 blocked notification, but only get {len(blocked_deliveries)} count\\n\"\n         f\"Message {delivered_msgs}\"\n     )\n+\n+\n+# ---------------------------------------------------------------------------\n+# Regression: gateway watchers must not double-init the kanban DB.\n+#\n+# Both the notifier watcher (`_kanban_notifier_watcher`) and the dispatcher\n+# tick (`_tick_once_for_board`) used to call `_kb.connect(board=slug)`\n+# immediately followed by `_kb.init_db(board=slug)`. Since `connect()`\n+# already runs the schema + idempotent migration on first open per process,\n+# the explicit `init_db()` was redundant — and worse, `init_db()`\n+# deliberately busts the per-process cache and re-runs the migration on a\n+# *second* connection, which races the first.  On legacy DBs this surfaced\n+# as `duplicate column name: <col>` (now tolerated by\n+# `_add_column_if_missing`) and intermittent `database is locked` errors\n+# (issue #21378).\n+#\n+# The fix removes the `init_db()` calls in both watchers; this regression\n+# test pins that behaviour so we don't reintroduce them.\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]\n+async def test_notifier_does_not_call_init_db(kanban_home):\n+    \"\"\"Notifier watcher path must not invoke `_kb.init_db` (issue #21378).\"\"\"\n+    import hermes_cli.kanban_db as kb\n+    from gateway.run import GatewayRunner\n+    from gateway.config import Platform\n+\n+    runner = object.__new__(GatewayRunner)\n+    runner._running = True\n+    runner._kanban_sub_fail_counts = {}\n+\n+    fake_adapter = MagicMock()\n+    fake_adapter.send = AsyncMock()\n+    runner.adapters = {Platform.TELEGRAM: fake_adapter}\n+\n+    _orig_sleep = asyncio.sleep\n+    tick_count = 0\n+\n+    async def _fast_sleep(_):\n+        nonlocal tick_count\n+        await _orig_sleep(0)\n+        tick_count += 1\n+        if tick_count >= 3:\n+            runner._running = False\n+\n+    init_db_calls: list[object] = []\n+    real_init_db = kb.init_db\n+\n+    def _spy_init_db(*args, **kwargs):\n+        init_db_calls.append((args, kwargs))\n+        return real_init_db(*args, **kwargs)\n+\n+    with patch(\"gateway.run.asyncio.sleep\", side_effect=_fast_sleep), \\\n+         patch(\"hermes_cli.kanban_db.init_db\", side_effect=_spy_init_db):\n+        await asyncio.wait_for(\n+            runner._kanban_notifier_watcher(interval=1),\n+            timeout=10.0,\n+        )\n+\n+    assert init_db_calls == [], (\n+        \"_kanban_notifier_watcher must not call init_db on every tick — \"\n+        \"connect() handles first-run schema init. \"\n+        \"Reintroducing init_db revives issue #21378. \"\n+        f\"Got {len(init_db_calls)} call(s): {init_db_calls}\"\n+    )\n+\n+\n+def test_dispatcher_tick_does_not_call_init_db(kanban_home, monkeypatch):\n+    \"\"\"`_tick_once_for_board` must not invoke `_kb.init_db` (issue #21378).\n+\n+    `connect()` already runs the schema + idempotent migration on first open\n+    per process. The explicit `init_db()` call was redundant and triggered a\n+    second migration on a second connection that raced the first.\n+    \"\"\"\n+    import hermes_cli.kanban_db as kb\n+    from gateway.run import GatewayRunner\n+    from unittest.mock import patch\n+\n+    runner = object.__new__(GatewayRunner)\n+\n+    init_db_calls: list[object] = []\n+    real_init_db = kb.init_db\n+\n+    def _spy_init_db(*args, **kwargs):\n+        init_db_calls.append((args, kwargs))\n+        return real_init_db(*args, **kwargs)\n+\n+    # The dispatcher watcher's tick lives as a local closure inside\n+    # `_kanban_dispatcher_watcher`. Read the source and assert the\n+    # specific patterns that would reintroduce the bug are absent.\n+    import inspect\n+    src = inspect.getsource(GatewayRunner._kanban_dispatcher_watcher)\n+    assert \"_kb.init_db(board=slug)\" not in src, (\n+        \"_kanban_dispatcher_watcher must not call _kb.init_db(board=slug) — \"\n+        \"see issue #21378. Use connect() alone; it runs migrations on first \"\n+        \"open per process.\"\n+    )\n+\n+    notifier_src = inspect.getsource(GatewayRunner._kanban_notifier_watcher)\n+    assert \"_kb.init_db(board=slug)\" not in notifier_src, (\n+        \"_kanban_notifier_watcher must not call _kb.init_db(board=slug) — \"\n+        \"see issue #21378.\"\n+    )\n"]}]},{"kind":"pr","number":28461,"title":"fix(kanban): create additive-column indexes after migration","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#28781","number":28781},{"kind":"pr","value":"#16081","number":16081},{"kind":"pr","value":"#17805","number":17805}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":28562,"title":"fix(kanban): move session_id index creation from SCHEMA_SQL to migration","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#28781","number":28781},{"kind":"pr","value":"#16081","number":16081},{"kind":"pr","value":"#17805","number":17805}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":28597,"title":"fix(kanban): migrate session_id before indexing","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":28693,"title":"fix(kanban): move session_id index creation from SCHEMA_SQL to migration","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#28461","number":28461},{"kind":"pr","value":"#28461","number":28461},{"kind":"pr","value":"#28562","number":28562},{"kind":"pr","value":"#28597","number":28597},{"kind":"pr","value":"#28620","number":28620}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":28705,"title":"fix(achievements): persist lifetime counters to prevent regression after prune","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#28464","number":28464},{"kind":"pr","value":"#28461","number":28461},{"kind":"pr","value":"#28602","number":28602}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":28781,"title":"fix(kanban): hoist all additive-column indexes out of SCHEMA_SQL (#28461)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/kanban_db.py","tests/hermes_cli/test_kanban_db.py"],"fix_hunks":[{"file":"hermes_cli/kanban_db.py","hunks":["@@ -865,8 +865,6 @@ class Event:\n     session_id           TEXT\n );\n \n-CREATE INDEX IF NOT EXISTS idx_tasks_session_id ON tasks(session_id);\n-\n CREATE TABLE IF NOT EXISTS task_links (\n     parent_id  TEXT NOT NULL,\n     child_id   TEXT NOT NULL,","@@ -937,13 +935,10 @@ class Event:\n \n CREATE INDEX IF NOT EXISTS idx_tasks_assignee_status ON tasks(assignee, status);\n CREATE INDEX IF NOT EXISTS idx_tasks_status          ON tasks(status);\n-CREATE INDEX IF NOT EXISTS idx_tasks_tenant          ON tasks(tenant);\n-CREATE INDEX IF NOT EXISTS idx_tasks_idempotency     ON tasks(idempotency_key);\n CREATE INDEX IF NOT EXISTS idx_links_child           ON task_links(child_id);\n CREATE INDEX IF NOT EXISTS idx_links_parent          ON task_links(parent_id);\n CREATE INDEX IF NOT EXISTS idx_comments_task         ON task_comments(task_id, created_at);\n CREATE INDEX IF NOT EXISTS idx_events_task           ON task_events(task_id, created_at);\n-CREATE INDEX IF NOT EXISTS idx_events_run            ON task_events(run_id, id);\n CREATE INDEX IF NOT EXISTS idx_runs_task             ON task_runs(task_id, started_at);\n CREATE INDEX IF NOT EXISTS idx_runs_status           ON task_runs(status);\n CREATE INDEX IF NOT EXISTS idx_notify_task           ON kanban_notify_subs(task_id);","@@ -1083,10 +1078,9 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:\n         _add_column_if_missing(\n             conn, \"tasks\", \"idempotency_key\", \"idempotency_key TEXT\"\n         )\n-        conn.execute(\n-            \"CREATE INDEX IF NOT EXISTS idx_tasks_idempotency \"\n-            \"ON tasks(idempotency_key)\"\n-        )\n+    # ``idx_tasks_idempotency`` is created unconditionally below alongside\n+    # the other additive-column indexes — see the block after the\n+    # legacy-column migration. Creating it here too would be redundant.\n \n     # Refresh after early additive migrations above. Some existing DBs were\n     # partially migrated in older releases and can already contain the later","@@ -1170,24 +1164,38 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:\n         # created from within an agent loop that propagated\n         # ``HERMES_SESSION_ID`` (e.g. ACP). NULL on legacy rows and on any\n         # creation path that doesn't set the env var (CLI, dashboard).\n-        # Index keeps per-session list queries cheap.\n         _add_column_if_missing(\n             conn, \"tasks\", \"session_id\", \"session_id TEXT\"\n         )\n-        conn.execute(\n-            \"CREATE INDEX IF NOT EXISTS idx_tasks_session_id \"\n-            \"ON tasks(session_id)\"\n-        )\n+\n+    # Indexes over additive ``tasks`` columns must be created after the\n+    # columns exist. Keeping them in SCHEMA_SQL breaks legacy boards: SQLite\n+    # parses each statement in ``executescript`` against the live schema, so a\n+    # ``CREATE INDEX`` over a missing column aborts initialization before the\n+    # additive ``ALTER TABLE`` migrations below can run. Re-running them here\n+    # is cheap thanks to ``IF NOT EXISTS`` and stays correct on fresh DBs\n+    # (where the columns already exist from SCHEMA_SQL).\n+    conn.execute(\"CREATE INDEX IF NOT EXISTS idx_tasks_tenant ON tasks(tenant)\")\n+    conn.execute(\n+        \"CREATE INDEX IF NOT EXISTS idx_tasks_idempotency ON tasks(idempotency_key)\"\n+    )\n+    conn.execute(\n+        \"CREATE INDEX IF NOT EXISTS idx_tasks_session_id ON tasks(session_id)\"\n+    )\n \n     # task_events gained a run_id column; back-fill it as NULL for\n     # historical events (they predate runs and can't be attributed).\n     ev_cols = {row[\"name\"] for row in conn.execute(\"PRAGMA table_info(task_events)\")}\n     if \"run_id\" not in ev_cols:\n         _add_column_if_missing(conn, \"task_events\", \"run_id\", \"run_id INTEGER\")\n-        conn.execute(\n-            \"CREATE INDEX IF NOT EXISTS idx_events_run \"\n-            \"ON task_events(run_id, id)\"\n-        )\n+\n+    # Same ordering rule as the additive ``tasks`` indexes above: create the\n+    # index after the additive column migration so legacy ``task_events``\n+    # tables don't fail during SCHEMA_SQL execution before ``run_id`` exists.\n+    conn.execute(\n+        \"CREATE INDEX IF NOT EXISTS idx_events_run \"\n+        \"ON task_events(run_id, id)\"\n+    )\n \n     notify_table_exists = conn.execute(\n         \"SELECT name FROM sqlite_master WHERE type='table' AND name='kanban_notify_subs'\""]},{"file":"tests/hermes_cli/test_kanban_db.py","hunks":["@@ -48,6 +48,87 @@ def test_init_creates_expected_tables(kanban_home):\n     assert {\"tasks\", \"task_links\", \"task_comments\", \"task_events\"} <= names\n \n \n+def test_connect_migrates_legacy_db_before_optional_column_indexes(tmp_path):\n+    \"\"\"Legacy DBs missing additive indexed columns must migrate cleanly.\n+\n+    SCHEMA_SQL runs in ``connect()`` before ``_migrate_add_optional_columns``.\n+    Indexes over additive columns therefore must be created after the\n+    migration adds those columns, or boards predating the column fail to\n+    open before migration can run.\n+\n+    Covers all four indexes that sit on additive columns:\n+    - ``tasks.session_id``       -> ``idx_tasks_session_id``    (#28447)\n+    - ``tasks.tenant``           -> ``idx_tasks_tenant``        (#16081)\n+    - ``tasks.idempotency_key``  -> ``idx_tasks_idempotency``   (#17805)\n+    - ``task_events.run_id``     -> ``idx_events_run``          (#17805)\n+    \"\"\"\n+    db_path = tmp_path / \"legacy-kanban.db\"\n+    conn = sqlite3.connect(str(db_path))\n+    # Pre-#16081 ``tasks`` shape: missing tenant, idempotency_key, session_id.\n+    conn.execute(\"\"\"\n+        CREATE TABLE tasks (\n+            id TEXT PRIMARY KEY,\n+            title TEXT NOT NULL,\n+            body TEXT,\n+            assignee TEXT,\n+            status TEXT NOT NULL,\n+            priority INTEGER NOT NULL DEFAULT 0,\n+            created_by TEXT,\n+            created_at INTEGER NOT NULL,\n+            started_at INTEGER,\n+            completed_at INTEGER,\n+            workspace_kind TEXT NOT NULL DEFAULT 'scratch',\n+            workspace_path TEXT,\n+            claim_lock TEXT,\n+            claim_expires INTEGER\n+        )\n+    \"\"\")\n+    # Pre-#17805 ``task_events`` shape: missing run_id. Required because\n+    # ``_migrate_add_optional_columns`` unconditionally runs PRAGMA on\n+    # ``task_events`` for run_id back-fill.\n+    conn.execute(\"\"\"\n+        CREATE TABLE task_events (\n+            id INTEGER PRIMARY KEY AUTOINCREMENT,\n+            task_id TEXT NOT NULL,\n+            kind TEXT NOT NULL,\n+            payload TEXT,\n+            created_at INTEGER NOT NULL\n+        )\n+    \"\"\")\n+    conn.execute(\n+        \"INSERT INTO tasks (id, title, status, created_at) \"\n+        \"VALUES ('legacy', 'old board task', 'ready', 1)\"\n+    )\n+    conn.commit()\n+    conn.close()\n+\n+    with kb.connect(db_path) as migrated:\n+        task_columns = {\n+            row[\"name\"] for row in migrated.execute(\"PRAGMA table_info(tasks)\")\n+        }\n+        event_columns = {\n+            row[\"name\"]\n+            for row in migrated.execute(\"PRAGMA table_info(task_events)\")\n+        }\n+        indexes = {\n+            row[\"name\"]\n+            for row in migrated.execute(\n+                \"SELECT name FROM sqlite_master WHERE type = 'index'\"\n+            )\n+        }\n+\n+    # Additive columns added by migration:\n+    assert \"session_id\" in task_columns\n+    assert \"tenant\" in task_columns\n+    assert \"idempotency_key\" in task_columns\n+    assert \"run_id\" in event_columns\n+    # And their indexes — the regression scope of this test:\n+    assert \"idx_tasks_session_id\" in indexes\n+    assert \"idx_tasks_tenant\" in indexes\n+    assert \"idx_tasks_idempotency\" in indexes\n+    assert \"idx_events_run\" in indexes\n+\n+\n # ---------------------------------------------------------------------------\n # Task creation + status inference\n # ---------------------------------------------------------------------------","@@ -462,7 +543,7 @@ def test_detect_crashed_workers_isolated_failure_normal_retry(\n             )\n \n \n-def test_max_runtime_uses_current_run_start_after_retry(kanban_home):\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 \n     ``tasks.started_at`` intentionally records the first time the task ever","@@ -470,6 +551,8 @@ def test_max_runtime_uses_current_run_start_after_retry(kanban_home):\n     ``task_runs.started_at`` row; otherwise every retry of an old task is\n     immediately timed out again.\n     \"\"\"\n+    monkeypatch.setattr(kb, \"_pid_alive\", lambda _pid: False)\n+\n     with kb.connect() as conn:\n         host = kb._claimer_id().split(\":\", 1)[0]\n         t = kb.create_task(\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}