From 7c622b6c749387297b2c41cf37692d2688927a41 Mon Sep 17 00:00:00 2001 From: Michael Nguyen Date: Tue, 19 May 2026 20:43:59 +0700 Subject: [PATCH] fix(kanban): migrate task session index after columns --- hermes_cli/kanban_db.py | 20 ++++++------ tests/hermes_cli/test_kanban_db.py | 49 +++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index edeae51707b..83feab95a08 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -865,8 +865,6 @@ CREATE TABLE IF NOT EXISTS tasks ( 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 +935,6 @@ CREATE TABLE IF NOT EXISTS kanban_notify_subs ( 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); 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); @@ -1170,14 +1166,20 @@ 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)" - ) + + # Indexes over additive task columns must be created after the columns + # exist. Keeping them in SCHEMA_SQL breaks legacy boards because + # CREATE TABLE IF NOT EXISTS does not add new columns to existing tables. + 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)" + ) + 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). diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index a33becb4e0f..9d2b551d0b8 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -48,6 +48,51 @@ def test_init_creates_expected_tables(kanban_home): assert {"tasks", "task_links", "task_comments", "task_events"} <= names +def test_connect_migrates_legacy_db_before_optional_column_indexes(tmp_path): + db_path = tmp_path / "legacy-kanban.db" + conn = sqlite3.connect(str(db_path)) + conn.execute(""" + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + body TEXT, + assignee TEXT, + status TEXT NOT NULL, + priority INTEGER NOT NULL 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 + ) + """) + conn.execute( + "INSERT INTO tasks (id, title, status, created_at) " + "VALUES ('legacy', 'old board task', 'ready', 1)" + ) + conn.commit() + conn.close() + + with kb.connect(db_path) as migrated: + task_columns = { + row["name"] for row in migrated.execute("PRAGMA table_info(tasks)") + } + indexes = { + row["name"] + for row in migrated.execute( + "SELECT name FROM sqlite_master WHERE type = 'index'" + ) + } + + assert "session_id" in task_columns + assert "idx_tasks_session_id" in indexes + assert "idx_tasks_tenant" in indexes + assert "idx_tasks_idempotency" in indexes + + # --------------------------------------------------------------------------- # Task creation + status inference # --------------------------------------------------------------------------- @@ -462,7 +507,7 @@ def test_detect_crashed_workers_isolated_failure_normal_retry( ) -def test_max_runtime_uses_current_run_start_after_retry(kanban_home): +def test_max_runtime_uses_current_run_start_after_retry(kanban_home, monkeypatch): """A retry should get a fresh max-runtime window. ``tasks.started_at`` intentionally records the first time the task ever @@ -470,6 +515,8 @@ def test_max_runtime_uses_current_run_start_after_retry(kanban_home): ``task_runs.started_at`` row; otherwise every retry of an old task is immediately timed out again. """ + monkeypatch.setattr(kb, "_pid_alive", lambda _pid: False) + with kb.connect() as conn: host = kb._claimer_id().split(":", 1)[0] t = kb.create_task(