Commit bd1404db482ea5b4303ff192816289d1871abd29

Authored by bboonnee
1 parent 86fc0bbc

Fix Phase 1 migration: task table rebuild trips FK enforcement on populated DBs

migrate_add_reviewing_planner_output_status() rebuilds task (create-copy-
drop-rename, SQLite's only way to change a CHECK constraint), but task is a
parent table referenced by work_graph_run/artifact/execution_event/
bug_record. DROP TABLE task under PRAGMA foreign_keys=ON (schema.connect()'s
default) trips FK enforcement the instant any child row exists -- unit tests
never seeded a referencing child row, so this never surfaced until a real
restart against the live production database (months of real history).

Found and fixed live: no data loss (INSERT INTO task_new already succeeded
before the DROP failed; verified the two tables held byte-identical rows,
cleaned up the stray task_new, backed up the db, re-applied the fixed
migration, verified via PRAGMA foreign_key_check, then restarted the real
process against production and confirmed a real end-to-end task run through
the new Planner -> planner_reviewer -> Coder -> Validator flow).

Fix follows the migrate_relax_org_node_constraints() precedent already in
this file: PRAGMA foreign_keys=OFF around the rebuild, PRAGMA
foreign_key_check after, ON in a finally block. New regression test seeds
one referencing row per child table so this class of bug can't pass silently
again.

Also adds real (non-mocked) coverage for the planner_reviewer role in
test_e2e_pipeline_e2e.py: one real call confirming it flags real narrative-
text repro fixtures, one confirming it passes real structured content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
src/schema.py
@@ -702,41 +702,69 @@ def migrate_add_reviewing_planner_output_status(conn): @@ -702,41 +702,69 @@ def migrate_add_reviewing_planner_output_status(conn):
702 ALREADY-POPULATED database -- same create-copy-drop-rename dance as 702 ALREADY-POPULATED database -- same create-copy-drop-rename dance as
703 migrate_expand_artifact_types/migrate_add_loop_back_event_type. Idempotent 703 migrate_expand_artifact_types/migrate_add_loop_back_event_type. Idempotent
704 and safe to call unconditionally, including against a freshly 704 and safe to call unconditionally, including against a freshly
705 - create_schema()'d database (which already has the expanded constraint).""" 705 + create_schema()'d database (which already has the expanded constraint).
  706 +
  707 + **2026-08-12 real-restart incident fix**: `task` is a PARENT table --
  708 + work_graph_run/artifact/execution_event/bug_record all REFERENCES
  709 + task(task_id) -- unlike execution_event (a leaf table nothing points
  710 + at), which is why migrate_add_planner_review_verdict_event_type doesn't
  711 + need this. Rebuilding a referenced parent table with
  712 + `PRAGMA foreign_keys=ON` (schema.connect()'s default -- see its own
  713 + docstring) trips FK enforcement on the DROP TABLE step the instant any
  714 + child row exists, even though the surviving task_id column's type/PK
  715 + are unchanged. Caught for real restarting the live production
  716 + backend_server against ~/.graphlemon/graphlemon.db (months of real
  717 + work_graph_run/artifact/execution_event history) -- every unit test for
  718 + this migration had passed because none of them seeded a single
  719 + FK-referencing child row, so DROP TABLE never actually exercised FK
  720 + enforcement in-test. Now follows the exact
  721 + migrate_relax_org_node_constraints() precedent (that function's own
  722 + docstring explains the same PRAGMA foreign_keys=OFF/rebuild/
  723 + foreign_key_check/ON dance in more depth) instead of the leaf-table
  724 + executescript() shortcut this originally (wrongly) copied."""
706 row = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='task'").fetchone() 725 row = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='task'").fetchone()
707 if row is None or "'reviewing_planner_output'" in row[0]: 726 if row is None or "'reviewing_planner_output'" in row[0]:
708 return 727 return
709 - conn.executescript(  
710 - """  
711 - CREATE TABLE task_new (  
712 - task_id TEXT PRIMARY KEY,  
713 - project_id TEXT NOT NULL REFERENCES project(project_id),  
714 - goal_text TEXT NOT NULL,  
715 - task_type TEXT NOT NULL CHECK (task_type IN ('goal', 'bug_fix')),  
716 - origin_ref TEXT,  
717 - created_via TEXT NOT NULL DEFAULT 'api' CHECK (created_via IN ('api', 'human_console')),  
718 - status TEXT NOT NULL CHECK (status IN (  
719 - 'queued', 'planning_brd_prd_sdd', 'reviewing_planner_output', 'planning_failed',  
720 - 'coding', 'coding_failed', 'validating',  
721 - 'routed_fast_lane', 'routed_full_replan',  
722 - 'blocked_pending_human', 'done'  
723 - )),  
724 - route TEXT CHECK (route IN ('fast_lane', 'full_replan')),  
725 - loop_round INTEGER NOT NULL DEFAULT 0,  
726 - blocked_reason TEXT,  
727 - assigned_org_node TEXT NOT NULL CHECK (assigned_org_node != ''),  
728 - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,  
729 - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP  
730 - );  
731 - INSERT INTO task_new SELECT * FROM task;  
732 - DROP TABLE task;  
733 - ALTER TABLE task_new RENAME TO task;  
734 - CREATE INDEX idx_task_status ON task(status);  
735 - CREATE INDEX idx_task_assigned_org_node ON task(assigned_org_node);  
736 - CREATE INDEX idx_task_project_id ON task(project_id);  
737 - """  
738 - )  
739 - conn.commit() 728 + conn.execute("PRAGMA foreign_keys = OFF")
  729 + try:
  730 + conn.executescript(
  731 + """
  732 + CREATE TABLE task_new (
  733 + task_id TEXT PRIMARY KEY,
  734 + project_id TEXT NOT NULL REFERENCES project(project_id),
  735 + goal_text TEXT NOT NULL,
  736 + task_type TEXT NOT NULL CHECK (task_type IN ('goal', 'bug_fix')),
  737 + origin_ref TEXT,
  738 + created_via TEXT NOT NULL DEFAULT 'api' CHECK (created_via IN ('api', 'human_console')),
  739 + status TEXT NOT NULL CHECK (status IN (
  740 + 'queued', 'planning_brd_prd_sdd', 'reviewing_planner_output', 'planning_failed',
  741 + 'coding', 'coding_failed', 'validating',
  742 + 'routed_fast_lane', 'routed_full_replan',
  743 + 'blocked_pending_human', 'done'
  744 + )),
  745 + route TEXT CHECK (route IN ('fast_lane', 'full_replan')),
  746 + loop_round INTEGER NOT NULL DEFAULT 0,
  747 + blocked_reason TEXT,
  748 + assigned_org_node TEXT NOT NULL CHECK (assigned_org_node != ''),
  749 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  750 + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
  751 + );
  752 + INSERT INTO task_new SELECT * FROM task;
  753 + DROP TABLE task;
  754 + ALTER TABLE task_new RENAME TO task;
  755 + CREATE INDEX idx_task_status ON task(status);
  756 + CREATE INDEX idx_task_assigned_org_node ON task(assigned_org_node);
  757 + CREATE INDEX idx_task_project_id ON task(project_id);
  758 + """
  759 + )
  760 + conn.commit()
  761 + violations = conn.execute("PRAGMA foreign_key_check").fetchall()
  762 + if violations:
  763 + raise RuntimeError(
  764 + "migrate_add_reviewing_planner_output_status left dangling foreign keys: {}".format(violations)
  765 + )
  766 + finally:
  767 + conn.execute("PRAGMA foreign_keys = ON")
740 768
741 769
742 def migrate_add_planner_review_verdict_event_type(conn): 770 def migrate_add_planner_review_verdict_event_type(conn):
src/test_e2e_pipeline_e2e.py
@@ -93,5 +93,63 @@ class RealPrdConsistencyCheckTest(unittest.TestCase): @@ -93,5 +93,63 @@ class RealPrdConsistencyCheckTest(unittest.TestCase):
93 self.assertTrue(ok, "false positive on a consistent PRD: {}".format(issues)) 93 self.assertTrue(ok, "false positive on a consistent PRD: {}".format(issues))
94 94
95 95
  96 +@skip_unless_env("RUN_REAL_AGENT_E2E")
  97 +class RealPlannerReviewTest(unittest.TestCase):
  98 + """Org Graph unification Phase 1, real coverage (2026-08-08): the new
  99 + planner_reviewer role is a genuinely new LLM-judgment capability (not a
  100 + wiring change) -- validated for real once each way (real narrative-text
  101 + repro / real structured content), same discipline as
  102 + RealPrdConsistencyCheckTest above. Also the cheapest real check that the
  103 + new role actually deploys correctly (.claude/agents/planner_reviewer.md
  104 + is discoverable and loadable by a real `claude --agent planner_reviewer`
  105 + call) -- if role.json/SOUL.md/AGENTS.md have a real wiring mistake, this
  106 + is where it surfaces, not deep inside a full run_end_to_end() call."""
  107 +
  108 + def test_detects_real_narrative_text_instead_of_structured_content(self):
  109 + with tempfile.TemporaryDirectory() as tmp:
  110 + subprocess.run(["git", "init"], cwd=tmp, check=True, capture_output=True)
  111 + agent_profile.deploy_agent_definitions(tmp)
  112 + conn = schema.connect(":memory:")
  113 + schema.create_schema(conn)
  114 + project_scaffold.create_project(conn, tmp, init_git_if_missing=False)
  115 + project_id = conn.execute("SELECT project_id FROM project").fetchone()[0]
  116 + task_id = task_create.create_task(conn, project_id, "实现贪吃蛇游戏")["task_id"]
  117 +
  118 + # real repro text from docs/todo/validator-test-script-narrative-
  119 + # output-issue.md, fed in as the SDD content.
  120 + narrative_sdd = (
  121 + "Launched the 'graphlemon-node-f9cefb87af4c492aac738b9736d41e4e' workflow "
  122 + "(Work Node validator) in the background, run ID wf_93345ca8-8c6. It's running "
  123 + "asynchronously — I'll report results once it completes; no further action is "
  124 + "needed right now."
  125 + )
  126 + result = e2e_pipeline.review_planner_output(
  127 + conn, task_id, "实现贪吃蛇游戏", tmp, sdd_content=narrative_sdd,
  128 + )
  129 + self.assertIn(result["verdict"], ("FAIL", "INCONCLUSIVE"))
  130 +
  131 + def test_passes_real_structured_content(self):
  132 + with tempfile.TemporaryDirectory() as tmp:
  133 + subprocess.run(["git", "init"], cwd=tmp, check=True, capture_output=True)
  134 + agent_profile.deploy_agent_definitions(tmp)
  135 + conn = schema.connect(":memory:")
  136 + schema.create_schema(conn)
  137 + project_scaffold.create_project(conn, tmp, init_git_if_missing=False)
  138 + project_id = conn.execute("SELECT project_id FROM project").fetchone()[0]
  139 + task_id = task_create.create_task(conn, project_id, "实现一个返回两数之和的函数")["task_id"]
  140 +
  141 + real_sdd = (
  142 + "# SDD: add(a, b)\n\n"
  143 + "## Module\n\n`add(a: int, b: int) -> int` in `math_utils.py`.\n\n"
  144 + "## Behavior\n\nReturns the sum of the two arguments. No validation needed -- "
  145 + "both arguments are guaranteed to be integers by the caller.\n\n"
  146 + "## Tests\n\n`test_add_positive`, `test_add_negative`, `test_add_zero`.\n"
  147 + )
  148 + result = e2e_pipeline.review_planner_output(
  149 + conn, task_id, "实现一个返回两数之和的函数", tmp, sdd_content=real_sdd,
  150 + )
  151 + self.assertEqual(result["verdict"], "PASS", result["raw_response"])
  152 +
  153 +
96 if __name__ == "__main__": 154 if __name__ == "__main__":
97 unittest.main() 155 unittest.main()
src/test_schema.py
@@ -871,6 +871,31 @@ class MigrateAddReviewingPlannerOutputStatusTest(unittest.TestCase): @@ -871,6 +871,31 @@ class MigrateAddReviewingPlannerOutputStatusTest(unittest.TestCase):
871 "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " 871 "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
872 "VALUES ('task-1', 'proj-1', 'implement snake game', 'goal', 'queued', 'planner')" 872 "VALUES ('task-1', 'proj-1', 'implement snake game', 'goal', 'queued', 'planner')"
873 ) 873 )
  874 + # 2026-08-12 real-restart incident regression: task is a PARENT table --
  875 + # every real production database has months of child rows across these
  876 + # four tables REFERENCES task(task_id). Seeding at least one of each here
  877 + # is the actual fix for why the original migration's bug (DROP TABLE task
  878 + # under PRAGMA foreign_keys=ON trips FK enforcement the instant a
  879 + # referencing child row exists) went undetected by this test class --
  880 + # every test below previously ran against a task row with ZERO
  881 + # referencing children, so FK enforcement was never truly exercised.
  882 + self.conn.execute(
  883 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  884 + "VALUES ('run-1', 'task-1', 'planner', '{}', 'done')"
  885 + )
  886 + self.conn.execute(
  887 + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, "
  888 + "file_path, git_commit_sha, validation_status) "
  889 + "VALUES ('art-1', 'sdd', 'task-1', 'run-1', 'f.md', 'sha', 'passed')"
  890 + )
  891 + self.conn.execute(
  892 + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) "
  893 + "VALUES ('evt-1', 'proj-1', 'task-1', 'task_status_changed', '{}')"
  894 + )
  895 + self.conn.execute(
  896 + "INSERT INTO bug_record (bug_id, task_id, source, description, status) "
  897 + "VALUES ('bug-1', 'task-1', 'validator', 'real bug', 'open')"
  898 + )
874 self.conn.commit() 899 self.conn.commit()
875 900
876 def tearDown(self): 901 def tearDown(self):
@@ -882,6 +907,24 @@ class MigrateAddReviewingPlannerOutputStatusTest(unittest.TestCase): @@ -882,6 +907,24 @@ class MigrateAddReviewingPlannerOutputStatusTest(unittest.TestCase):
882 "UPDATE task SET status = 'reviewing_planner_output' WHERE task_id = 'task-1'" 907 "UPDATE task SET status = 'reviewing_planner_output' WHERE task_id = 'task-1'"
883 ) 908 )
884 909
  910 + def test_migration_succeeds_with_real_foreign_key_referencing_child_rows(self):
  911 + # the actual regression: must not raise sqlite3.IntegrityError
  912 + # (FOREIGN KEY constraint failed) -- this is exactly what happened
  913 + # restarting the live production backend_server for real.
  914 + schema.migrate_add_reviewing_planner_output_status(self.conn)
  915 + for table, id_col, id_val in (
  916 + ("work_graph_run", "run_id", "run-1"),
  917 + ("artifact", "artifact_id", "art-1"),
  918 + ("execution_event", "event_id", "evt-1"),
  919 + ("bug_record", "bug_id", "bug-1"),
  920 + ):
  921 + row = self.conn.execute(
  922 + "SELECT task_id FROM {} WHERE {} = ?".format(table, id_col), (id_val,)
  923 + ).fetchone()
  924 + self.assertEqual(row[0], "task-1", "{} lost its task_id linkage after migration".format(table))
  925 + violations = self.conn.execute("PRAGMA foreign_key_check").fetchall()
  926 + self.assertEqual(violations, [])
  927 +
885 def test_migration_preserves_existing_rows_and_accepts_new_status(self): 928 def test_migration_preserves_existing_rows_and_accepts_new_status(self):
886 schema.migrate_add_reviewing_planner_output_status(self.conn) 929 schema.migrate_add_reviewing_planner_output_status(self.conn)
887 930