diff --git a/docs/development-plan.md b/docs/development-plan.md index ca1a909..2762229 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -171,6 +171,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 - **U-017 [Risk Gate]** 人为植入 bug 完整闭环验证(架构文档 §11 验收标准第 4 条) - 依赖:U-016 - Done:引入一个人为植入的 bug,系统自主定位根因并驱动 Planner/Coder 完成修复闭环 + - **实现补充(2026-07-31)**:`src/org_level_loop.py` 的 `run_fix_loop` 把 U-015(三视角判定)+ U-016(路由)串成一个闭环:判定→建 `bug_record`→路由→真实触发 Coder/Planner 修复→真实触发 Validator 复核→复核通过则 `close_task_done`。`category=coder→fast_lane`、`category=planning→full_replan` 是这里定的固定映射规则(`bug_record.suggested_route` 本来是 Validator 独立判定的字段,但 REQ-05/架构文档 §11 的验收场景里两者天然对应,没有再额外调一次 Agent 单独决定 route)。真实验证复用了 U-015 那个撞墙误判场景,但这次真的让 Coder 编辑仓库里的 `collision.py` 文件、真的让 Validator 读文件复核,不是只判定不动手 ### PH2-S2 Skill 自主迭代 diff --git a/docs/sdd/unit_matrix.json b/docs/sdd/unit_matrix.json index 6e87657..207c518 100644 --- a/docs/sdd/unit_matrix.json +++ b/docs/sdd/unit_matrix.json @@ -30,7 +30,7 @@ {"id": "U-015", "phase": "PH2", "sub_phase": "PH2-S1", "name": "p_validator_root_cause_trace 实现", "classification": "Risk Gate", "depends_on": ["U-013"], "coverage": {"model": ["bug_record"], "view": [], "controller": ["c_bug_record_create"], "process": ["p_validator_root_cause_trace"], "requirement": ["REQ-04"]}, "done": "REQ-04 happy/boundary/failure 通过"}, {"id": "U-016", "phase": "PH2", "sub_phase": "PH2-S1", "name": "c_org_loop_route + fast_lane/full_replan(含 close_task_done,见 development-plan.md 2026-07-31 实现补充)", "classification": "Risk Gate", "depends_on": ["U-015"], "coverage": {"model": [], "view": [], "controller": ["c_org_loop_route"], "process": [], "requirement": ["REQ-05", "REQ-09"]}, "done": "REQ-05/REQ-09 全部用例通过(含 3 轮上限 → blocked_pending_human;validating→done 通过新增 close_task_done 打通)"}, - {"id": "U-017", "phase": "PH2", "sub_phase": "PH2-S1", "name": "人为植入 bug 完整闭环验证", "classification": "Risk Gate", "depends_on": ["U-016"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "架构文档 §11 验收标准第 4 条达成"}, + {"id": "U-017", "phase": "PH2", "sub_phase": "PH2-S1", "name": "人为植入 bug 完整闭环验证(见 development-plan.md 2026-07-31 实现补充)", "classification": "Risk Gate", "depends_on": ["U-016"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "架构文档 §11 验收标准第 4 条达成:run_fix_loop 串联 U-015 判定 + U-016 路由 + 真实 Coder 修复 + 真实 Validator 复核"}, {"id": "U-018", "phase": "PH2", "sub_phase": "PH2-S2", "name": "c_skill_update + c_skill_rollback", "classification": "Risk Gate", "depends_on": ["U-015"], "coverage": {"model": ["skill", "skill_iteration"], "view": [], "controller": ["c_skill_update", "c_skill_rollback"], "process": [], "requirement": ["REQ-08"]}, "done": "REQ-08 全部通过"}, {"id": "U-019", "phase": "PH2", "sub_phase": "PH2-S3", "name": "budget_guardrail_config + c_budget_check", "classification": "Risk Gate", "depends_on": ["U-012"], "coverage": {"model": ["budget_guardrail_config"], "view": [], "controller": ["c_budget_check"], "process": ["p_budget_guardrail"], "requirement": ["REQ-10"]}, "done": "REQ-10 happy/boundary 通过"}, {"id": "U-020", "phase": "PH2", "sub_phase": "PH2-S3", "name": "p_usage_limit_wait 完整验收", "classification": "Risk Gate", "depends_on": ["U-012"], "coverage": {"model": [], "view": [], "controller": [], "process": ["p_usage_limit_wait"], "requirement": ["REQ-14"]}, "done": "REQ-14 全部通过(含超时兜底)"}, diff --git a/src/org_level_loop.py b/src/org_level_loop.py new file mode 100644 index 0000000..f6367a3 --- /dev/null +++ b/src/org_level_loop.py @@ -0,0 +1,62 @@ +"""U-017: Org-level Loop closed loop (architecture doc §11 MVP acceptance +criterion 4) -- ties U-015 (root-cause judgment) + U-016 (routing) + real +Coder/Validator calls into one function: given a task sitting at +'validating' with a known bug, autonomously locate root cause, route to +the right Org Agent, drive the fix, and re-validate back to 'done'. +""" + +from src import bug_record, org_loop_route, org_node_trigger, root_cause_trace + +_ROUTE_FOR_CATEGORY = {"coder": "fast_lane", "planning": "full_replan"} + + +def run_fix_loop(conn, task_id, target_folder_path, bug_description, context, fix_prompt, verify_prompt, timeout=300): + """Returns {"root_cause_category", "route", "verified", "final_status", + "verify_response"}. Raises root_cause_trace.InconclusiveEscalateError if + the 3 perspectives don't reach 2/3 agreement (same as U-015 -- this + function doesn't add its own fallback for that, matching 04-P's own + "升级人工" answer to disagreement).""" + perspective_fns = [ + root_cause_trace.real_validator_perspective( + name, bug_description, context, target_folder_path, + trigger_fn=org_node_trigger.trigger_org_node, timeout=timeout, + ) + for name in ("correctness", "requirement_completeness", "skill_defect") + ] + judgment = root_cause_trace.judge_root_cause(perspective_fns) + category = judgment["root_cause_category"] + route = _ROUTE_FOR_CATEGORY.get(category, "full_replan") + + bug_record.create_bug_record( + conn, task_id=task_id, description=bug_description, + root_cause_category=category, root_cause_detail=judgment["root_cause_detail"], + suggested_route=route, + ) + org_loop_route.route_org_loop(conn, task_id, route) + + fixer_node = "coder" if route == "fast_lane" else "planner" + org_node_trigger.trigger_org_node(fixer_node, fix_prompt, target_folder_path, timeout=timeout) + + verify_response = org_node_trigger.trigger_org_node("validator", verify_prompt, target_folder_path, timeout=timeout) + verified = "PASS" in verify_response.upper() + + if verified: + # A real Work Graph would move coding/planning_brd_prd_sdd back to + # 'validating' once the fixer's own Work Graph completes (U-011/012/013, + # same mechanism U-014 uses) -- no separate controller exists for that + # hop, so this mirrors it directly rather than inventing one just for + # this Unit. + conn.execute("UPDATE task SET status = 'validating' WHERE task_id = ?", (task_id,)) + conn.commit() + org_loop_route.close_task_done(conn, task_id) + final_status = "done" + else: + final_status = conn.execute("SELECT status FROM task WHERE task_id = ?", (task_id,)).fetchone()[0] + + return { + "root_cause_category": category, + "route": route, + "verified": verified, + "final_status": final_status, + "verify_response": verify_response, + } diff --git a/src/test_org_level_loop.py b/src/test_org_level_loop.py new file mode 100644 index 0000000..9b2c4aa --- /dev/null +++ b/src/test_org_level_loop.py @@ -0,0 +1,97 @@ +"""U-017 tests: the full Org-level Loop wiring (judge -> record -> route -> +fix -> re-verify -> done), mocked at the subprocess boundary (CLAUDE.md +Testing constraint). Real coverage is in src/test_org_level_loop_e2e.py +(RUN_REAL_AGENT_E2E=1). + +Run: python3 -m unittest src.test_org_level_loop -v +""" + +import unittest +from unittest.mock import patch + +from src import org_level_loop, schema + + +def _make_trigger(perspective_category, fix_ok=True): + """Fakes org_node_trigger.trigger_org_node for the whole loop: 3 + perspective calls (all agreeing on perspective_category), one fixer + call, one verify call.""" + + def fake(org_node, prompt, target_folder_path, timeout=300): + if "CATEGORY:" in prompt: + return "CATEGORY: {}\nDETAIL: because reasons".format(perspective_category) + if org_node in ("coder", "planner"): + return "Fixed it." + if org_node == "validator": + return "VERDICT: PASS\nlooks fixed" if fix_ok else "VERDICT: FAIL\nstill broken" + raise AssertionError("unexpected call: " + org_node) + + return fake + + +class RunFixLoopTestBase(unittest.TestCase): + def setUp(self): + self.conn = schema.connect(":memory:") + schema.create_schema(self.conn) + self.conn.execute( + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" + ) + self.conn.execute( + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, loop_round, assigned_org_node) " + "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'validating', 0, 'validator')" + ) + self.conn.commit() + + def tearDown(self): + self.conn.close() + + +class CoderCategoryFastLaneTest(RunFixLoopTestBase): + def test_coder_category_routes_fast_lane_and_reaches_done(self): + with patch("src.org_node_trigger.trigger_org_node", side_effect=_make_trigger("coder", fix_ok=True)): + result = org_level_loop.run_fix_loop( + self.conn, "task-1", "/tmp/proj-1", "bug desc", "context", "fix it", "verify it" + ) + self.assertEqual(result["root_cause_category"], "coder") + self.assertEqual(result["route"], "fast_lane") + self.assertTrue(result["verified"]) + self.assertEqual(result["final_status"], "done") + + row = self.conn.execute("SELECT status, loop_round FROM task WHERE task_id = 'task-1'").fetchone() + self.assertEqual(row, ("done", 1)) + + bug_row = self.conn.execute( + "SELECT root_cause_category, suggested_route FROM bug_record WHERE task_id = 'task-1'" + ).fetchone() + self.assertEqual(bug_row, ("coder", "fast_lane")) + + +class PlanningCategoryFullReplanTest(RunFixLoopTestBase): + def test_planning_category_routes_full_replan(self): + with patch("src.org_node_trigger.trigger_org_node", side_effect=_make_trigger("planning", fix_ok=True)): + result = org_level_loop.run_fix_loop( + self.conn, "task-1", "/tmp/proj-1", "bug desc", "context", "fix it", "verify it" + ) + self.assertEqual(result["route"], "full_replan") + self.assertEqual(result["final_status"], "done") + assigned = self.conn.execute("SELECT assigned_org_node FROM task WHERE task_id = 'task-1'").fetchone()[0] + # after close_task_done the task is 'done'; assigned_org_node was set + # to 'planner' by route_org_loop and close_task_done doesn't touch it + self.assertEqual(assigned, "planner") + + +class VerificationFailsTest(RunFixLoopTestBase): + def test_fix_not_verified_leaves_task_out_of_done(self): + with patch("src.org_node_trigger.trigger_org_node", side_effect=_make_trigger("coder", fix_ok=False)): + result = org_level_loop.run_fix_loop( + self.conn, "task-1", "/tmp/proj-1", "bug desc", "context", "fix it", "verify it" + ) + self.assertFalse(result["verified"]) + self.assertNotEqual(result["final_status"], "done") + status = self.conn.execute("SELECT status FROM task WHERE task_id = 'task-1'").fetchone()[0] + self.assertNotEqual(status, "done") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/test_org_level_loop_e2e.py b/src/test_org_level_loop_e2e.py new file mode 100644 index 0000000..bcc64e0 --- /dev/null +++ b/src/test_org_level_loop_e2e.py @@ -0,0 +1,90 @@ +"""U-017's real-call coverage: architecture doc §11 MVP acceptance criterion +4, for real -- a deliberately injected off-by-one wall-collision bug (same +scenario shape as U-015's real test, but this time actually driving a real +fix instead of just judging), routed, fixed by a real Coder call editing the +actual file in the repo, then confirmed by a real Validator call that reads +the fixed file. Gated behind RUN_REAL_AGENT_E2E=1 (CLAUDE.md Testing +constraint). + +Run: RUN_REAL_AGENT_E2E=1 python3 -m unittest src.test_org_level_loop_e2e -v +""" + +import subprocess +import tempfile +import unittest + +from src import agent_profile, org_level_loop, schema +from src.test_support import skip_unless_env + +_SDD_SNIPPET = ( + "## Wall collision (SDD §3.2)\n" + "The board is a grid with valid coordinates 0..N-1 in each axis. A collision (game " + "over) MUST be triggered the instant the head's x or y coordinate is < 0 or >= N." +) + +_BUG_DESCRIPTION = ( + "When the snake's head reaches the rightmost or bottommost column, the game does not " + "end immediately -- the head is visually one cell outside the board before 'wall " + "collision' triggers. See collision.py in this repo." +) + +_BUGGY_CODE = ( + "def check_wall_collision(head_x, head_y, board_size):\n" + " # BUG: uses > / < instead of >= / <=, so the head is allowed to sit one cell\n" + " # past the actual boundary before a collision is registered.\n" + " return head_x < 0 or head_y < 0 or head_x > board_size or head_y > board_size\n" +) + + +@skip_unless_env("RUN_REAL_AGENT_E2E") +class RealOrgLevelLoopTest(unittest.TestCase): + def test_real_fix_loop_reaches_done(self): + with tempfile.TemporaryDirectory() as tmp: + subprocess.run(["git", "init"], cwd=tmp, check=True, capture_output=True) + agent_profile.deploy_agent_definitions(tmp) + with open(tmp + "/collision.py", "w") as f: + f.write(_BUGGY_CODE) + subprocess.run(["git", "add", "collision.py"], cwd=tmp, check=True, capture_output=True) + subprocess.run( + ["git", "-c", "user.name=t", "-c", "user.email=t@t.com", "commit", "-m", "seed buggy collision.py"], + cwd=tmp, check=True, capture_output=True, + ) + + conn = schema.connect(":memory:") + schema.create_schema(conn) + conn.execute( + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " + "VALUES ('proj-1', 'P', ?, 1, 'active')", + (tmp,), + ) + conn.execute( + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, loop_round, assigned_org_node) " + "VALUES ('task-1', 'proj-1', '实现贪吃蛇游戏', 'goal', 'validating', 0, 'validator')" + ) + conn.commit() + + result = org_level_loop.run_fix_loop( + conn, "task-1", tmp, + bug_description=_BUG_DESCRIPTION, + context="SDD:\n{}\n\nImplementation:\n{}".format(_SDD_SNIPPET, _BUGGY_CODE), + fix_prompt=( + "Fix the off-by-one wall-collision bug in collision.py per this SDD " + "section, then commit the fix:\n\n" + _SDD_SNIPPET + ), + verify_prompt=( + "Read collision.py in this repo and check whether check_wall_collision now " + "correctly triggers at the boundary (>= N / < 0, not > N / < 0), per this SDD " + "section:\n\n" + _SDD_SNIPPET + "\n\nReply with 'VERDICT: PASS' or 'VERDICT: FAIL' " + "and your reasoning." + ), + timeout=300, + ) + + self.assertEqual(result["root_cause_category"], "coder") + self.assertEqual(result["route"], "fast_lane") + self.assertTrue(result["verified"], "validator did not confirm the fix: " + result["verify_response"]) + self.assertEqual(result["final_status"], "done") + + +if __name__ == "__main__": + unittest.main()