From c3d7e23ff08b68149866702d957200da11a658b1 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Tue, 19 May 2026 19:45:10 +0700 Subject: [PATCH 1/3] fix(kanban): worker-initiated block must not be auto-promoted (#28712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a worker calls ``kanban_block(reason="review-required: ...")`` to hand a task off for human review, the dispatcher's ``recompute_ready`` was treating the resulting ``blocked`` status as eligible for auto-promotion — exactly the same as a circuit-breaker block. On the next tick the task flipped back to ``ready``, a fresh worker spawned, found nothing to do (work already applied, review-required comment already posted), exited cleanly, got recorded as ``protocol_violation`` → ``gave_up`` → ``blocked``, and the dispatcher promoted again. Infinite loop until manual ``hermes kanban reclaim`` + ``kanban block``. Add ``_has_sticky_block`` which distinguishes the two block sources using the cheapest available signal: the most recent ``"blocked"``/``"unblocked"`` event in ``task_events``. * Worker / operator ``kanban_block`` emits ``"blocked"`` → ``_has_sticky_block`` returns True → ``recompute_ready`` skips the task entirely. ``unblock_task`` emits ``"unblocked"`` which flips the predicate back, so the only legitimate exit is the documented human-in-the-loop path. * Circuit-breaker ``_record_task_failure`` emits ``"gave_up"`` (not ``"blocked"``) → predicate stays False → original parent-completion-recovery semantics from #40c1decb3 are preserved. * Tasks blocked purely by direct DB manipulation also recover, since they have no ``"blocked"`` event row at all — matches the existing ``test_recompute_ready_promotes_blocked_with_done_parents`` fixture behaviour. --- hermes_cli/kanban_db.py | 56 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index edeae51707b0..7bd0ab8297de 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -1994,11 +1994,58 @@ def _synthesize_ended_run( # Dependency resolution (todo -> ready) # --------------------------------------------------------------------------- +def _has_sticky_block(conn: sqlite3.Connection, task_id: str) -> bool: + """Return True when ``task_id`` is sticky-blocked by an explicit + worker/operator ``kanban_block`` call (#28712). + + A ``blocked`` status can come from two very different sources: + + * **Worker- or operator-initiated** — a worker called + ``kanban_block(reason="review-required: ...")`` (or somebody ran + ``hermes kanban block ``). This is a deliberate handoff that + should stay blocked until an operator unblocks it. The block tool + emits a ``"blocked"`` event row in ``task_events``. + + * **Circuit-breaker** — ``_record_task_failure`` tripped after + repeated crashes / spawn failures / timeouts. This emits + ``"gave_up"``, *not* ``"blocked"``, and is meant to recover + automatically once the underlying conditions change (e.g. parents + finish, transient infra error clears). + + The cheapest signal that distinguishes the two is the most recent + ``"blocked"`` / ``"unblocked"`` event for the task. If the most + recent one is ``"blocked"`` (or there is a ``"blocked"`` event and + no ``"unblocked"`` event has fired since), the task is sticky and + ``recompute_ready`` must *not* auto-promote it. + + Returns ``False`` when there is no such event at all (e.g. the task + was set to ``status='blocked'`` by the circuit breaker or by direct + DB manipulation) — preserves the pre-#28712 auto-recover semantics + for that path. + """ + row = conn.execute( + "SELECT kind FROM task_events " + "WHERE task_id = ? AND kind IN ('blocked', 'unblocked') " + "ORDER BY id DESC LIMIT 1", + (task_id,), + ).fetchone() + return bool(row) and row["kind"] == "blocked" + + def recompute_ready(conn: sqlite3.Connection) -> int: """Promote ``todo`` tasks to ``ready`` when all parents are ``done`` or ``archived``. Returns the number of tasks promoted. Safe to call inside or outside an existing transaction; it opens its own IMMEDIATE txn. + + ``blocked`` tasks are also considered for promotion (so a task + blocked purely by a parent dependency unblocks itself when the + parent completes), *except* when the most recent block event was a + worker-initiated ``kanban_block`` — those stay blocked until an + explicit ``kanban_unblock`` (#28712). Without that guard, a + ``review-required`` handoff would auto-respawn, the fresh worker + would find nothing to do, exit cleanly, get recorded as a protocol + violation, and the cycle would repeat indefinitely. """ promoted = 0 with write_txn(conn): @@ -2008,6 +2055,12 @@ def recompute_ready(conn: sqlite3.Connection) -> int: for row in todo_rows: task_id = row["id"] cur_status = row["status"] + if cur_status == "blocked" and _has_sticky_block(conn, task_id): + # Worker / operator asked for human review — do not + # silently auto-recover. ``unblock_task`` is the only + # legitimate exit (it emits ``"unblocked"`` which flips + # this predicate back). + continue parents = conn.execute( "SELECT t.status FROM tasks t " "JOIN task_links l ON l.parent_id = t.id " @@ -2016,7 +2069,8 @@ def recompute_ready(conn: sqlite3.Connection) -> int: ).fetchall() if all(p["status"] in ("done", "archived") for p in parents): # Blocked tasks also get their failure counters reset — - # this is effectively an auto-unblock. + # this is effectively an auto-unblock (circuit-breaker + # recovery; worker-initiated blocks are skipped above). if cur_status == "blocked": conn.execute( "UPDATE tasks SET status = 'ready', " From aa791be9c427302e458cca13ec30392529a0c514 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Tue, 19 May 2026 19:45:58 +0700 Subject: [PATCH 2/3] fix(kanban): defer late-added column indexes to the migration block (#28712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``init_db`` on a kanban.db that pre-dates the ``session_id``, ``tenant`` or ``idempotency_key`` migrations crashed with ``no such column: `` because ``SCHEMA_SQL`` asserted the index on those columns before ``_migrate_add_optional_columns`` had a chance to ADD the columns. On legacy DBs the ``CREATE TABLE IF NOT EXISTS`` at the top of the script was a no-op, so the table never grew the new columns, and the next ``CREATE INDEX`` blew up the entire init sequence — including the migration that would have fixed it. Reporter had to manually ``ALTER TABLE`` + ``CREATE INDEX`` to unstick their install. Move ``idx_tasks_tenant``, ``idx_tasks_idempotency`` and ``idx_tasks_session_id`` out of ``SCHEMA_SQL`` and into ``_migrate_add_optional_columns``, unconditionally and with ``IF NOT EXISTS`` so they're a cheap no-op on fresh DBs where the columns and indexes were created together. The additive ``ALTER TABLE`` for each column happens first; the index assertion runs after, by which point the column is guaranteed to exist on both new and legacy schemas. --- hermes_cli/kanban_db.py | 51 ++++++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 7bd0ab8297de..5c2328a03ef3 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -861,12 +861,13 @@ class Event: -- inside an agent loop that propagated ``HERMES_SESSION_ID``. NULL -- for tasks created from the CLI, dashboard, or any path that doesn't -- set the env var. Indexed so per-session list queries stay cheap on - -- larger boards. + -- larger boards. The accompanying index is created later in + -- ``_migrate_add_optional_columns`` so legacy DBs (where + -- ``CREATE TABLE IF NOT EXISTS`` is a no-op) still get the column + -- added *before* the index is asserted — see #28712. session_id TEXT ); -CREATE INDEX IF NOT EXISTS idx_tasks_session_id ON tasks(session_id); - CREATE TABLE IF NOT EXISTS task_links ( parent_id TEXT NOT NULL, child_id TEXT NOT NULL, @@ -937,8 +938,15 @@ class Event: CREATE INDEX IF NOT EXISTS idx_tasks_assignee_status ON tasks(assignee, status); CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); -CREATE INDEX IF NOT EXISTS idx_tasks_tenant ON tasks(tenant); -CREATE INDEX IF NOT EXISTS idx_tasks_idempotency ON tasks(idempotency_key); +-- ``idx_tasks_tenant``, ``idx_tasks_idempotency`` and +-- ``idx_tasks_session_id`` are created in +-- ``_migrate_add_optional_columns`` instead of here. Those columns +-- were added after v1, so on a legacy DB ``CREATE TABLE IF NOT EXISTS`` +-- above is a no-op and the columns don't exist yet — running the +-- index DDL before the additive migration crashed ``init_db`` with +-- ``no such column`` on real-world upgrades (#28712). Deferring the +-- indexes lets the migration ALTER the columns in first, then assert +-- the indexes idempotently. CREATE INDEX IF NOT EXISTS idx_links_child ON task_links(child_id); CREATE INDEX IF NOT EXISTS idx_links_parent ON task_links(parent_id); CREATE INDEX IF NOT EXISTS idx_comments_task ON task_comments(task_id, created_at); @@ -1083,10 +1091,18 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None: _add_column_if_missing( conn, "tasks", "idempotency_key", "idempotency_key TEXT" ) - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_tasks_idempotency " - "ON tasks(idempotency_key)" - ) + # Indexes for late-added columns live here (not in SCHEMA_SQL) so + # legacy DBs don't crash on the ``CREATE INDEX`` before the + # additive migrations had a chance to run — see #28712. Both + # statements use ``IF NOT EXISTS`` so they're cheap no-ops on + # fresh DBs where the columns and indexes were created together. + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_tasks_tenant ON tasks(tenant)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_tasks_idempotency " + "ON tasks(idempotency_key)" + ) # Refresh after early additive migrations above. Some existing DBs were # partially migrated in older releases and can already contain the later @@ -1170,14 +1186,21 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None: # created from within an agent loop that propagated # ``HERMES_SESSION_ID`` (e.g. ACP). NULL on legacy rows and on any # creation path that doesn't set the env var (CLI, dashboard). - # Index keeps per-session list queries cheap. _add_column_if_missing( conn, "tasks", "session_id", "session_id TEXT" ) - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_tasks_session_id " - "ON tasks(session_id)" - ) + # Index is always (re-)asserted here rather than in SCHEMA_SQL so + # legacy DBs — where ``CREATE TABLE IF NOT EXISTS`` is a no-op and + # the ``session_id`` column does not yet exist — get the migration + # ALTER first, then the index. Otherwise ``init_db`` blew up with + # ``no such column: session_id`` mid-script and never reached this + # migration at all (#28712). ``IF NOT EXISTS`` makes the call a + # cheap no-op on fresh DBs where the column was already in the + # ``CREATE TABLE``. + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_tasks_session_id " + "ON tasks(session_id)" + ) # task_events gained a run_id column; back-fill it as NULL for # historical events (they predate runs and can't be attributed). From da1bf44538f2489e1e1c7a046928c47c1d3e58f2 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Tue, 19 May 2026 19:46:13 +0700 Subject: [PATCH 3/3] test(kanban): cover sticky blocks + legacy-DB init recovery (#28712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven regression tests pinning the contract that was broken in #28712: Dispatcher (``recompute_ready``): * ``test_worker_block_is_not_auto_promoted_by_recompute_ready`` — ``kanban_block`` survives five back-to-back ticks (compressed dispatcher loop). * ``test_worker_block_on_child_with_done_parents_is_still_sticky`` — the parent-completion code path was the worst false-positive; even when every parent is ``done``, an explicit worker block stays blocked. * ``test_circuit_breaker_block_still_auto_promotes`` — preserves the pre-#28712 recovery semantics for circuit-breaker blocks (direct ``UPDATE`` + no ``"blocked"`` event). * ``test_gave_up_event_alone_does_not_make_block_sticky`` — explicit guard so the ``gave_up`` event is never accidentally treated as sticky; covers the second leg of the protocol_violation loop. * ``test_unblock_clears_sticky_state_and_lets_block_recover`` — only ``unblock_task`` resolves the sticky state; subsequent circuit-breaker blocks recover normally. * ``test_protocol_violation_loop_is_broken`` — full bug-shaped reproduction: block → tick → (would-be) crash + gave_up → next tick still blocked. Without the fix this would loop indefinitely. Schema init: * ``test_init_db_recovers_from_legacy_tasks_table_without_session_id`` — hand-crafts a pre-``tenant`` / pre-``idempotency_key`` / pre- ``session_id`` ``tasks`` table, calls ``init_db``, and asserts all three columns + indexes end up present and legacy rows survive. --- .../hermes_cli/test_kanban_blocked_sticky.py | 344 ++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 tests/hermes_cli/test_kanban_blocked_sticky.py diff --git a/tests/hermes_cli/test_kanban_blocked_sticky.py b/tests/hermes_cli/test_kanban_blocked_sticky.py new file mode 100644 index 000000000000..de7df20e1991 --- /dev/null +++ b/tests/hermes_cli/test_kanban_blocked_sticky.py @@ -0,0 +1,344 @@ +"""Regression tests for #28712 — kanban dispatcher must not auto-promote +worker-initiated ``kanban_block`` (sticky blocks), but must keep +auto-recovering circuit-breaker blocks. + +The bug: when a worker called ``kanban_block(reason="review-required: +...")`` to hand off to a human, the dispatcher's ``recompute_ready`` +would promote the task back to ``ready`` on the next tick. The fresh +worker found nothing to do (work already applied), exited cleanly, and +got recorded as a ``protocol_violation`` → ``gave_up`` → promote → loop +until manual intervention. + +These tests pin down: + +* Worker / operator-initiated blocks are sticky and survive + ``recompute_ready``. +* Circuit-breaker blocks (``gave_up`` event, status flipped via + ``_record_task_failure``) still auto-recover — the original intent + of #40c1decb3 is preserved. +* An explicit ``kanban_unblock`` clears the sticky state. +* The full block → promote → crash → ``gave_up`` loop is broken after + this fix: subsequent ticks leave the task blocked. +* The schema-init ordering bug also reported in #28712 is fixed — + ``init_db`` no longer crashes on legacy DBs that pre-date the + ``session_id`` migration. +""" + +from __future__ import annotations + +import sqlite3 +import time +from pathlib import Path + +import pytest + +from hermes_cli import kanban_db as kb + + +@pytest.fixture +def kanban_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Isolated HERMES_HOME with an empty kanban DB.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + kb.init_db() + return home + + +# --------------------------------------------------------------------------- +# Worker-initiated kanban_block must be sticky +# --------------------------------------------------------------------------- + + +def test_worker_block_is_not_auto_promoted_by_recompute_ready(kanban_home: Path) -> None: + """A standalone task that a worker explicitly blocks for review + must stay blocked across an arbitrary number of dispatcher ticks. + Before #28712's fix, ``recompute_ready`` would silently flip it + back to ``ready`` on the very next tick.""" + with kb.connect() as conn: + tid = kb.create_task(conn, title="needs human review") + kb.claim_task(conn, tid) + assert kb.block_task( + conn, tid, + reason="review-required: please verify ACL change", + expected_run_id=kb.get_task(conn, tid).current_run_id, + ) + assert kb.get_task(conn, tid).status == "blocked" + + # Hammer the promotion code — exactly the dispatcher loop's + # behaviour, just compressed in time. + for _ in range(5): + promoted = kb.recompute_ready(conn) + assert promoted == 0, "worker-blocked task must not auto-promote" + assert kb.get_task(conn, tid).status == "blocked" + + +def test_worker_block_on_child_with_done_parents_is_still_sticky(kanban_home: Path) -> None: + """The parent-completion path is the one ``recompute_ready`` was + designed for, so it's the most dangerous false-positive: even when + every parent is done, a worker-initiated block on the child must + stay blocked.""" + with kb.connect() as conn: + parent = kb.create_task(conn, title="parent") + child = kb.create_task(conn, title="child", parents=[parent]) + kb.complete_task(conn, parent, result="parent ok") + + kb.claim_task(conn, child) + kb.block_task( + conn, child, + reason="review-required: child needs sign-off", + expected_run_id=kb.get_task(conn, child).current_run_id, + ) + assert kb.get_task(conn, child).status == "blocked" + + promoted = kb.recompute_ready(conn) + assert promoted == 0 + assert kb.get_task(conn, child).status == "blocked" + + +# --------------------------------------------------------------------------- +# Circuit-breaker blocks still auto-recover (preserve #40c1decb3 intent) +# --------------------------------------------------------------------------- + + +def test_circuit_breaker_block_still_auto_promotes(kanban_home: Path) -> None: + """A child that was put into ``blocked`` *without* a worker-issued + ``kanban_block`` (e.g. circuit-breaker after repeated spawn + failures, manual DB triage) must still get auto-promoted when its + parents complete — preserves the pre-#28712 recovery semantics.""" + with kb.connect() as conn: + parent = kb.create_task(conn, title="parent") + child = kb.create_task(conn, title="child", parents=[parent]) + kb.complete_task(conn, parent, result="ok") + + # Simulate a circuit-breaker / direct triage that flips status + # without emitting a ``blocked`` event — exactly what + # ``_record_task_failure`` does after a ``gave_up``. + conn.execute( + "UPDATE tasks SET status='blocked', consecutive_failures=5, " + "last_failure_error='persistent error' WHERE id=?", + (child,), + ) + conn.commit() + + promoted = kb.recompute_ready(conn) + assert promoted == 1 + task = kb.get_task(conn, child) + assert task.status == "ready" + assert task.consecutive_failures == 0 + assert task.last_failure_error is None + + +def test_gave_up_event_alone_does_not_make_block_sticky(kanban_home: Path) -> None: + """The circuit-breaker emits ``gave_up`` (not ``blocked``). Make + sure ``_has_sticky_block`` doesn't accidentally treat ``gave_up`` + as sticky — otherwise we'd regress the safety net for genuinely + transient crashes.""" + with kb.connect() as conn: + parent = kb.create_task(conn, title="parent") + child = kb.create_task(conn, title="child", parents=[parent]) + kb.complete_task(conn, parent, result="ok") + + # Status + event match what _record_task_failure writes when + # the breaker trips. + conn.execute( + "UPDATE tasks SET status='blocked' WHERE id=?", (child,), + ) + conn.execute( + "INSERT INTO task_events (task_id, kind, payload, created_at) " + "VALUES (?, 'gave_up', NULL, ?)", + (child, int(time.time())), + ) + conn.commit() + + promoted = kb.recompute_ready(conn) + assert promoted == 1 + assert kb.get_task(conn, child).status == "ready" + + +# --------------------------------------------------------------------------- +# unblock_task clears the sticky state +# --------------------------------------------------------------------------- + + +def test_unblock_clears_sticky_state_and_lets_block_recover(kanban_home: Path) -> None: + """``hermes kanban unblock`` (or the ``kanban_unblock`` tool) is + the only legitimate way out of a worker-initiated block. After + unblock, a *subsequent* circuit-breaker block on the same task + must again be eligible for auto-recovery.""" + with kb.connect() as conn: + tid = kb.create_task(conn, title="t") + kb.claim_task(conn, tid) + kb.block_task( + conn, tid, + reason="review-required: ...", + expected_run_id=kb.get_task(conn, tid).current_run_id, + ) + assert kb.unblock_task(conn, tid) + # After unblock the task is no longer blocked at all. + assert kb.get_task(conn, tid).status == "ready" + + # Now simulate a *later* circuit-breaker block (no new + # ``blocked`` event, just status flip). The most recent + # block/unblock event is ``unblocked`` → guard does not fire + # → recompute can recover. + conn.execute( + "UPDATE tasks SET status='blocked' WHERE id=?", (tid,), + ) + conn.commit() + + promoted = kb.recompute_ready(conn) + assert promoted == 1 + assert kb.get_task(conn, tid).status == "ready" + + +# --------------------------------------------------------------------------- +# Full bug-shaped loop: block → promote → crash → gave_up → next tick +# --------------------------------------------------------------------------- + + +def test_protocol_violation_loop_is_broken(kanban_home: Path) -> None: + """Reproduces the exact #28712 loop and asserts the dispatcher + leaves the task blocked instead of cycling. + + Loop shape from the issue: + + 1. Worker calls ``kanban_block`` → status='blocked', + ``task_runs.outcome='blocked'``, ``blocked`` event. + 2. (Bug) Dispatcher promotes back to ``ready``. + 3. Fresh worker exits cleanly without terminal tool call → + ``protocol_violation`` event. + 4. ``_record_task_failure(failure_limit=1)`` → ``gave_up`` event, + status='blocked' again. + 5. (Bug) Dispatcher promotes again → infinite loop. + + With the fix in place, step 2 never happens — the test simulates + one would-be loop cycle by faking the crash-then-gave_up entries + that *would* have been written and asserts the *next* tick still + leaves the task blocked. + """ + with kb.connect() as conn: + tid = kb.create_task(conn, title="loop reproducer") + kb.claim_task(conn, tid) + kb.block_task( + conn, tid, + reason="review-required: human eyes please", + expected_run_id=kb.get_task(conn, tid).current_run_id, + ) + assert kb.get_task(conn, tid).status == "blocked" + + # First dispatcher tick — must NOT promote. + assert kb.recompute_ready(conn) == 0 + assert kb.get_task(conn, tid).status == "blocked" + + # Simulate the (hypothetical) protocol_violation + gave_up + # entries that the dispatcher would have written if the bug + # were still present. Even with those event rows in place, + # the worker-initiated ``blocked`` event is the most recent + # of the ``{blocked, unblocked}`` pair, so the sticky guard + # still fires. + now = int(time.time()) + conn.execute( + "INSERT INTO task_events (task_id, kind, payload, created_at) " + "VALUES (?, 'protocol_violation', NULL, ?)", + (tid, now), + ) + conn.execute( + "INSERT INTO task_events (task_id, kind, payload, created_at) " + "VALUES (?, 'gave_up', NULL, ?)", + (tid, now + 1), + ) + conn.commit() + + # Subsequent ticks must still leave it blocked. + for _ in range(3): + promoted = kb.recompute_ready(conn) + assert promoted == 0 + assert kb.get_task(conn, tid).status == "blocked" + + +# --------------------------------------------------------------------------- +# Schema-init recovery on legacy DBs (the tangential #28712 finding) +# --------------------------------------------------------------------------- + + +def test_init_db_recovers_from_legacy_tasks_table_without_session_id( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A kanban.db that pre-dates the ``session_id`` migration must + upgrade cleanly: ``init_db`` previously crashed with + ``no such column: session_id`` because ``SCHEMA_SQL`` tried to + create the index *before* the additive-columns migration had a + chance to add the column. Reported alongside the dispatcher loop + in #28712. + """ + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + # Hand-craft a legacy ``tasks`` table — column list pulled from a + # pre-session_id release. The DB has the minimum schema needed for + # ``_migrate_add_optional_columns`` to walk through every additive + # column (including ``session_id``) and emerge with a valid layout. + db_path = home / "kanban.db" + raw = sqlite3.connect(db_path) + raw.executescript( + """ + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + body TEXT, + assignee TEXT, + status TEXT NOT NULL, + priority INTEGER DEFAULT 0, + created_by TEXT, + created_at INTEGER NOT NULL, + started_at INTEGER, + completed_at INTEGER, + workspace_kind TEXT NOT NULL DEFAULT 'scratch', + workspace_path TEXT, + claim_lock TEXT, + claim_expires INTEGER + ); + INSERT INTO tasks (id, title, status, workspace_kind, created_at) + VALUES ('legacy-1', 'pre-migration task', 'ready', 'scratch', 1700000000); + """ + ) + raw.commit() + raw.close() + + # Reset the per-process init cache so init_db actually runs the + # script — without this, an earlier connect() in the same test run + # would have short-circuited initialisation. + kb._INITIALIZED_PATHS.clear() + + # Before the fix this raised ``OperationalError: no such column: + # session_id``. After the fix it must run to completion. + kb.init_db() + + with kb.connect() as conn: + cols = {row["name"] for row in conn.execute("PRAGMA table_info(tasks)")} + # All three late-added columns must end up present. + for late_col in ("tenant", "idempotency_key", "session_id"): + assert late_col in cols, f"migration must add the {late_col} column" + + # All three late-added indexes must end up present so that + # subsequent ``CREATE INDEX IF NOT EXISTS`` calls and + # query planner lookups remain consistent with a freshly + # created DB. + indexes = { + row["name"] + for row in conn.execute("PRAGMA index_list(tasks)") + } + for late_idx in ( + "idx_tasks_tenant", + "idx_tasks_idempotency", + "idx_tasks_session_id", + ): + assert late_idx in indexes, f"{late_idx} must be created after migration" + + # Legacy data must survive the upgrade. + legacy = conn.execute("SELECT title FROM tasks WHERE id='legacy-1'").fetchone() + assert legacy["title"] == "pre-migration task"