Commit bd1404db482ea5b4303ff192816289d1871abd29
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>
Showing
3 changed files
with
161 additions
and
32 deletions
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 |
-
mentioned in commit 89482e7c2435ecd5eeb7bcec1c48fb4b1ed313fe