Commit 947ed31d84d257054d70cf914ebce74d21f2e51a

Authored by bboonnee
1 parent d10a05b0

fix: make migrate_drop_work_graph_run_org_node handle real duplicate-active-run data

Real self-hosting DB (2026-08-13) has 10 task_ids with multiple non-terminal
work_graph_run rows each (legacy per-org_node-row leftovers from before the
merged-graph architecture) -- the migration's final CREATE UNIQUE INDEX
ux_work_graph_run_active ON work_graph_run(task_id) crashed against this real
data (IntegrityError), and because executescript() commits each DDL statement
individually, the crash left the live DB in a partially-migrated state:
org_node column dropped, but the index never created, with the old
idempotency check (`if "org_node" not in cols: return`) then permanently
skipping any further attempt to create it.

Adds _reconcile_duplicate_active_work_graph_runs(), which marks all but the
most-recently-created non-terminal run per task_id as 'failed' (not 'blocked'
-- the index's WHERE clause treats 'blocked' as still-active, which was the
first fix attempt and still failed) before the index is created. Recency is
ranked by rowid, not run_id: real data has started_at=NULL on every legacy
row, so a run_id tiebreak would pick among duplicates by UUID string value,
unrelated to actual insertion order.

Also splits the idempotency check into column-drop and index-creation halves
so a database already in the partially-migrated state (like the live one)
gets the missing index created on the next run instead of being skipped.

Dry-run verified against a copy of the live 36-row production DB before
applying to the real ~/.graphlemon/graphlemon.db (backend_server was down for
this, no concurrent writers).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
src/schema.py
... ... @@ -847,15 +847,39 @@ def migrate_drop_work_graph_run_org_node(conn):
847 847 (each embeds the full column/constraint set valid as of ITS place in the
848 848 sequence, not retroactively edited by later migrations).
849 849  
850   - Idempotent: skipped if work_graph_run no longer has an org_node column,
851   - so a fresh create_schema()'d database (already on this final shape) is a
852   - no-op, same convention as every other migrate_* function here."""
  850 + Idempotent, and re-entrant against a database left PARTWAY through a
  851 + previous, interrupted attempt (real 2026-08-13 self-hosting incident:
  852 + executescript() does not wrap the whole script in one transaction, so a
  853 + prior run of this function got as far as dropping the org_node column
  854 + and renaming the table in, then died on the final CREATE UNIQUE INDEX
  855 + step -- see the _reconcile_duplicate_active_work_graph_runs() docstring
  856 + for why that step can fail against real data). Column-drop and index-
  857 + creation are therefore checked and completed independently rather than
  858 + as one all-or-nothing gate, so re-running this function against that
  859 + exact half-migrated state finishes the job instead of silently
  860 + no-op'ing (the OLD idempotency check here was just `if "org_node" not
  861 + in cols: return`, which would have skipped forever on that database,
  862 + permanently leaving ux_work_graph_run_active missing)."""
853 863 migrate_relax_org_node_constraints(conn)
854 864 conn.commit()
855 865  
  866 + _reconcile_duplicate_active_work_graph_runs(conn)
  867 + conn.commit()
  868 +
856 869 cols = {row[1] for row in conn.execute("PRAGMA table_info(work_graph_run)").fetchall()}
  870 + has_index = conn.execute(
  871 + "SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'ux_work_graph_run_active'"
  872 + ).fetchone() is not None
  873 +
857 874 if "org_node" not in cols:
  875 + if not has_index:
  876 + conn.execute(
  877 + "CREATE UNIQUE INDEX ux_work_graph_run_active ON work_graph_run(task_id) "
  878 + "WHERE status NOT IN ('done', 'failed')"
  879 + )
  880 + conn.commit()
858 881 return
  882 +
859 883 conn.execute("PRAGMA foreign_keys = OFF")
860 884 try:
861 885 conn.executescript(
... ... @@ -897,3 +921,61 @@ def migrate_drop_work_graph_run_org_node(conn):
897 921 )
898 922 finally:
899 923 conn.execute("PRAGMA foreign_keys = ON")
  924 +
  925 +
  926 +def _reconcile_duplicate_active_work_graph_runs(conn):
  927 + """Real production data (2026-08-13 incident) can have MULTIPLE
  928 + non-terminal work_graph_run rows for the same task_id -- a legitimate
  929 + leftover of the pre-Phase-2c architecture where each org_node got its
  930 + own row instead of sharing one merged run. ux_work_graph_run_active's
  931 + UNIQUE(task_id) WHERE status NOT IN ('done', 'failed') can't be created
  932 + while such duplicates exist (10 affected task_ids found against the
  933 + live self-hosting database, e.g. one task had 8 concurrently-'open'
  934 + rows from the old per-org_node model).
  935 +
  936 + Keeps the most-recently-started non-terminal row per task_id as the
  937 + active one and marks every other non-terminal row for that task_id as
  938 + 'failed' -- 'blocked' was considered but rejected: ux_work_graph_run_active's
  939 + WHERE clause is `status NOT IN ('done', 'failed')`, so a 'blocked' row
  940 + still counts as active and would keep tripping the same UNIQUE
  941 + violation this function exists to prevent (confirmed by re-running this
  942 + against real duplicate data -- the index creation still failed with
  943 + 'blocked'). 'failed' is the only terminal-compatible status that
  944 + doesn't misrepresent these rows as successfully completed ('done'
  945 + would); it's an honest description too, since these are abandoned runs
  946 + from before the merged-graph architecture -- nothing will ever resume
  947 + against them directly again (Coder/Validator now only ever append into
  948 + the newest run for a task). Plain Python grouping rather than a SQL
  949 + window function, matching this file's existing style (no window
  950 + functions used elsewhere in this module) and avoiding any SQLite-
  951 + version assumption.
  952 +
  953 + Safe to call unconditionally and repeatedly: a database with no
  954 + duplicates (the common case) does zero UPDATEs.
  955 +
  956 + Ranks candidates by (started_at, rowid), NOT (started_at, run_id):
  957 + real data confirmed started_at is NULL on every one of these legacy
  958 + rows (never backfilled -- they predate this column being populated),
  959 + which would make a run_id tiebreak pick among them by UUID string
  960 + value, unrelated to actual chronology. rowid reflects true insertion
  961 + order (SQLite assigns it monotonically increasing unless the table is
  962 + declared WITHOUT ROWID, which this one isn't), so it's the only
  963 + honest proxy available for "most recently created" when started_at
  964 + can't answer that."""
  965 + rows = conn.execute(
  966 + "SELECT rowid, run_id, task_id, started_at FROM work_graph_run "
  967 + "WHERE status NOT IN ('done', 'failed')"
  968 + ).fetchall()
  969 + by_task = {}
  970 + for rowid, run_id, task_id, started_at in rows:
  971 + by_task.setdefault(task_id, []).append((started_at or "", rowid, run_id))
  972 +
  973 + to_block = []
  974 + for task_id, entries in by_task.items():
  975 + if len(entries) <= 1:
  976 + continue
  977 + entries.sort()
  978 + to_block.extend(run_id for _, _, run_id in entries[:-1])
  979 +
  980 + for run_id in to_block:
  981 + conn.execute("UPDATE work_graph_run SET status = 'failed' WHERE run_id = ?", (run_id,))
... ...
src/test_schema.py
... ... @@ -915,6 +915,90 @@ class MigrateDropWorkGraphRunOrgNodeTest(unittest.TestCase):
915 915 finally:
916 916 fresh.close()
917 917  
  918 + def test_duplicate_non_terminal_rows_per_task_reconciled_before_index(self):
  919 + # Real 2026-08-13 incident: pre-Phase-2c databases can have several
  920 + # non-terminal work_graph_run rows for the same task_id (one per
  921 + # org_node, before runs were merged). The narrowed unique index
  922 + # can't be created until only one non-terminal row per task_id
  923 + # remains -- this must not raise.
  924 + self.conn.execute(
  925 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status, started_at) "
  926 + "VALUES ('run-2', 'task-1', 'coder', '{}', 'running', '2026-08-01T00:00:00')"
  927 + )
  928 + self.conn.execute(
  929 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status, started_at) "
  930 + "VALUES ('run-3', 'task-1', 'validator', '{}', 'planning', '2026-08-02T00:00:00')"
  931 + )
  932 + self.conn.commit()
  933 +
  934 + schema.migrate_drop_work_graph_run_org_node(self.conn)
  935 +
  936 + rows = self.conn.execute(
  937 + "SELECT run_id, status FROM work_graph_run WHERE task_id = 'task-1' AND run_id != 'run-1'"
  938 + ).fetchall()
  939 + statuses = dict(rows)
  940 + # run-3 started latest among the non-terminal duplicates -> stays active.
  941 + self.assertEqual(statuses["run-3"], "planning")
  942 + self.assertEqual(statuses["run-2"], "failed")
  943 + # the narrowed index must have actually been created and be enforced.
  944 + with self.assertRaises(sqlite3.IntegrityError):
  945 + self.conn.execute(
  946 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  947 + "VALUES ('run-4', 'task-1', '{}', 'running')"
  948 + )
  949 +
  950 + def test_duplicate_reconciliation_uses_insertion_order_not_uuid_when_started_at_null(self):
  951 + # Real 2026-08-13 incident: every legacy duplicate row had
  952 + # started_at = NULL. A run_id-string tiebreak would pick among
  953 + # them by UUID value, unrelated to which one was actually created
  954 + # last -- e.g. 'aaaa...' sorts after 'zzzz...' alphabetically but
  955 + # was inserted first. rowid (SQLite's true insertion-order
  956 + # counter) must be used instead, so the row inserted LAST (run-2,
  957 + # despite its run_id sorting before run-3's alphabetically) is the
  958 + # one kept active.
  959 + self.conn.execute(
  960 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  961 + "VALUES ('aaa-run-3', 'task-1', 'validator', '{}', 'planning')"
  962 + )
  963 + self.conn.execute(
  964 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  965 + "VALUES ('zzz-run-2', 'task-1', 'coder', '{}', 'running')"
  966 + )
  967 + self.conn.commit()
  968 +
  969 + schema.migrate_drop_work_graph_run_org_node(self.conn)
  970 +
  971 + statuses = dict(
  972 + self.conn.execute(
  973 + "SELECT run_id, status FROM work_graph_run WHERE run_id IN ('aaa-run-3', 'zzz-run-2')"
  974 + ).fetchall()
  975 + )
  976 + self.assertEqual(statuses["aaa-run-3"], "failed")
  977 + self.assertEqual(statuses["zzz-run-2"], "running")
  978 +
  979 + def test_reentrant_after_partial_migration_missing_index_only(self):
  980 + # Real 2026-08-13 incident: executescript() commits each DDL
  981 + # statement individually, so a process crash between ALTER TABLE
  982 + # ... RENAME and CREATE UNIQUE INDEX leaves a database with the
  983 + # org_node column already gone but ux_work_graph_run_active never
  984 + # created. Re-running the migration against that exact state must
  985 + # finish the job, not silently no-op because "org_node" is already
  986 + # absent.
  987 + schema.migrate_drop_work_graph_run_org_node(self.conn)
  988 + self.conn.execute("DROP INDEX ux_work_graph_run_active")
  989 + self.conn.commit()
  990 + has_index = self.conn.execute(
  991 + "SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'ux_work_graph_run_active'"
  992 + ).fetchone()
  993 + self.assertIsNone(has_index) # sanity: simulated partial state matches the real incident
  994 +
  995 + schema.migrate_drop_work_graph_run_org_node(self.conn)
  996 +
  997 + has_index = self.conn.execute(
  998 + "SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'ux_work_graph_run_active'"
  999 + ).fetchone()
  1000 + self.assertIsNotNone(has_index)
  1001 +
918 1002  
919 1003 class MigrateAddWorkGraphLoopLimitConfigTest(unittest.TestCase):
920 1004 """2026-08-08: real upgrade path for an already-populated database that
... ...