Commit ba481dde6e5da3d5bd6c8953dcfffbca4eed4782

Authored by bboonnee
1 parent 59749fc5

U-017: full Org-level Loop closed-loop verification (PH2-S1 complete)

run_fix_loop() chains U-015 (3-perspective root-cause judgment) + U-016
(routing) + real Coder/Planner/Validator calls into architecture doc §11's
4th MVP acceptance criterion: judge -> create bug_record -> route
(fast_lane for category=coder, full_replan for category=planning -- a
fixed mapping this Unit defines, since bug_record.suggested_route is
technically an independent Validator judgment but REQ-05/architecture §11's
scenarios always correlate the two, so no extra Agent call was added just
to re-decide it) -> trigger the fixer -> re-trigger Validator -> close_task_
done on confirmed PASS.

3 new mocked stdlib unittest cases (route branching, verification-fails
leaves the task out of 'done'). 160 total mocked tests across U-001-017,
all passing.

Real coverage reuses U-015's wall-collision scenario but this time drives
an actual fix: a real Coder call edits collision.py in the repo, a real
Validator call reads the fixed file and confirms it, running as a detached
background process (survives this session's own restarts, unlike two
earlier real runs this session that got silently orphaned by a session
teardown with no completion record -- lesson learned, not repeated).

This closes PH2-S1 (U-015/016/017): REQ-04/05/09 and architecture doc §11
criterion 4 are now real, verified code, not just planned intent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/development-plan.md
... ... @@ -171,6 +171,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。
171 171 - **U-017 [Risk Gate]** 人为植入 bug 完整闭环验证(架构文档 §11 验收标准第 4 条)
172 172 - 依赖:U-016
173 173 - Done:引入一个人为植入的 bug,系统自主定位根因并驱动 Planner/Coder 完成修复闭环
  174 + - **实现补充(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 读文件复核,不是只判定不动手
174 175  
175 176 ### PH2-S2 Skill 自主迭代
176 177  
... ...
docs/sdd/unit_matrix.json
... ... @@ -30,7 +30,7 @@
30 30  
31 31 {"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 通过"},
32 32 {"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 打通)"},
33   - {"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 条达成"},
  33 + {"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 复核"},
34 34 {"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 全部通过"},
35 35 {"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 通过"},
36 36 {"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 全部通过(含超时兜底)"},
... ...
src/org_level_loop.py 0 → 100644
  1 +"""U-017: Org-level Loop closed loop (architecture doc §11 MVP acceptance
  2 +criterion 4) -- ties U-015 (root-cause judgment) + U-016 (routing) + real
  3 +Coder/Validator calls into one function: given a task sitting at
  4 +'validating' with a known bug, autonomously locate root cause, route to
  5 +the right Org Agent, drive the fix, and re-validate back to 'done'.
  6 +"""
  7 +
  8 +from src import bug_record, org_loop_route, org_node_trigger, root_cause_trace
  9 +
  10 +_ROUTE_FOR_CATEGORY = {"coder": "fast_lane", "planning": "full_replan"}
  11 +
  12 +
  13 +def run_fix_loop(conn, task_id, target_folder_path, bug_description, context, fix_prompt, verify_prompt, timeout=300):
  14 + """Returns {"root_cause_category", "route", "verified", "final_status",
  15 + "verify_response"}. Raises root_cause_trace.InconclusiveEscalateError if
  16 + the 3 perspectives don't reach 2/3 agreement (same as U-015 -- this
  17 + function doesn't add its own fallback for that, matching 04-P's own
  18 + "升级人工" answer to disagreement)."""
  19 + perspective_fns = [
  20 + root_cause_trace.real_validator_perspective(
  21 + name, bug_description, context, target_folder_path,
  22 + trigger_fn=org_node_trigger.trigger_org_node, timeout=timeout,
  23 + )
  24 + for name in ("correctness", "requirement_completeness", "skill_defect")
  25 + ]
  26 + judgment = root_cause_trace.judge_root_cause(perspective_fns)
  27 + category = judgment["root_cause_category"]
  28 + route = _ROUTE_FOR_CATEGORY.get(category, "full_replan")
  29 +
  30 + bug_record.create_bug_record(
  31 + conn, task_id=task_id, description=bug_description,
  32 + root_cause_category=category, root_cause_detail=judgment["root_cause_detail"],
  33 + suggested_route=route,
  34 + )
  35 + org_loop_route.route_org_loop(conn, task_id, route)
  36 +
  37 + fixer_node = "coder" if route == "fast_lane" else "planner"
  38 + org_node_trigger.trigger_org_node(fixer_node, fix_prompt, target_folder_path, timeout=timeout)
  39 +
  40 + verify_response = org_node_trigger.trigger_org_node("validator", verify_prompt, target_folder_path, timeout=timeout)
  41 + verified = "PASS" in verify_response.upper()
  42 +
  43 + if verified:
  44 + # A real Work Graph would move coding/planning_brd_prd_sdd back to
  45 + # 'validating' once the fixer's own Work Graph completes (U-011/012/013,
  46 + # same mechanism U-014 uses) -- no separate controller exists for that
  47 + # hop, so this mirrors it directly rather than inventing one just for
  48 + # this Unit.
  49 + conn.execute("UPDATE task SET status = 'validating' WHERE task_id = ?", (task_id,))
  50 + conn.commit()
  51 + org_loop_route.close_task_done(conn, task_id)
  52 + final_status = "done"
  53 + else:
  54 + final_status = conn.execute("SELECT status FROM task WHERE task_id = ?", (task_id,)).fetchone()[0]
  55 +
  56 + return {
  57 + "root_cause_category": category,
  58 + "route": route,
  59 + "verified": verified,
  60 + "final_status": final_status,
  61 + "verify_response": verify_response,
  62 + }
... ...
src/test_org_level_loop.py 0 → 100644
  1 +"""U-017 tests: the full Org-level Loop wiring (judge -> record -> route ->
  2 +fix -> re-verify -> done), mocked at the subprocess boundary (CLAUDE.md
  3 +Testing constraint). Real coverage is in src/test_org_level_loop_e2e.py
  4 +(RUN_REAL_AGENT_E2E=1).
  5 +
  6 +Run: python3 -m unittest src.test_org_level_loop -v
  7 +"""
  8 +
  9 +import unittest
  10 +from unittest.mock import patch
  11 +
  12 +from src import org_level_loop, schema
  13 +
  14 +
  15 +def _make_trigger(perspective_category, fix_ok=True):
  16 + """Fakes org_node_trigger.trigger_org_node for the whole loop: 3
  17 + perspective calls (all agreeing on perspective_category), one fixer
  18 + call, one verify call."""
  19 +
  20 + def fake(org_node, prompt, target_folder_path, timeout=300):
  21 + if "CATEGORY:" in prompt:
  22 + return "CATEGORY: {}\nDETAIL: because reasons".format(perspective_category)
  23 + if org_node in ("coder", "planner"):
  24 + return "Fixed it."
  25 + if org_node == "validator":
  26 + return "VERDICT: PASS\nlooks fixed" if fix_ok else "VERDICT: FAIL\nstill broken"
  27 + raise AssertionError("unexpected call: " + org_node)
  28 +
  29 + return fake
  30 +
  31 +
  32 +class RunFixLoopTestBase(unittest.TestCase):
  33 + def setUp(self):
  34 + self.conn = schema.connect(":memory:")
  35 + schema.create_schema(self.conn)
  36 + self.conn.execute(
  37 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  38 + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')"
  39 + )
  40 + self.conn.execute(
  41 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, loop_round, assigned_org_node) "
  42 + "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'validating', 0, 'validator')"
  43 + )
  44 + self.conn.commit()
  45 +
  46 + def tearDown(self):
  47 + self.conn.close()
  48 +
  49 +
  50 +class CoderCategoryFastLaneTest(RunFixLoopTestBase):
  51 + def test_coder_category_routes_fast_lane_and_reaches_done(self):
  52 + with patch("src.org_node_trigger.trigger_org_node", side_effect=_make_trigger("coder", fix_ok=True)):
  53 + result = org_level_loop.run_fix_loop(
  54 + self.conn, "task-1", "/tmp/proj-1", "bug desc", "context", "fix it", "verify it"
  55 + )
  56 + self.assertEqual(result["root_cause_category"], "coder")
  57 + self.assertEqual(result["route"], "fast_lane")
  58 + self.assertTrue(result["verified"])
  59 + self.assertEqual(result["final_status"], "done")
  60 +
  61 + row = self.conn.execute("SELECT status, loop_round FROM task WHERE task_id = 'task-1'").fetchone()
  62 + self.assertEqual(row, ("done", 1))
  63 +
  64 + bug_row = self.conn.execute(
  65 + "SELECT root_cause_category, suggested_route FROM bug_record WHERE task_id = 'task-1'"
  66 + ).fetchone()
  67 + self.assertEqual(bug_row, ("coder", "fast_lane"))
  68 +
  69 +
  70 +class PlanningCategoryFullReplanTest(RunFixLoopTestBase):
  71 + def test_planning_category_routes_full_replan(self):
  72 + with patch("src.org_node_trigger.trigger_org_node", side_effect=_make_trigger("planning", fix_ok=True)):
  73 + result = org_level_loop.run_fix_loop(
  74 + self.conn, "task-1", "/tmp/proj-1", "bug desc", "context", "fix it", "verify it"
  75 + )
  76 + self.assertEqual(result["route"], "full_replan")
  77 + self.assertEqual(result["final_status"], "done")
  78 + assigned = self.conn.execute("SELECT assigned_org_node FROM task WHERE task_id = 'task-1'").fetchone()[0]
  79 + # after close_task_done the task is 'done'; assigned_org_node was set
  80 + # to 'planner' by route_org_loop and close_task_done doesn't touch it
  81 + self.assertEqual(assigned, "planner")
  82 +
  83 +
  84 +class VerificationFailsTest(RunFixLoopTestBase):
  85 + def test_fix_not_verified_leaves_task_out_of_done(self):
  86 + with patch("src.org_node_trigger.trigger_org_node", side_effect=_make_trigger("coder", fix_ok=False)):
  87 + result = org_level_loop.run_fix_loop(
  88 + self.conn, "task-1", "/tmp/proj-1", "bug desc", "context", "fix it", "verify it"
  89 + )
  90 + self.assertFalse(result["verified"])
  91 + self.assertNotEqual(result["final_status"], "done")
  92 + status = self.conn.execute("SELECT status FROM task WHERE task_id = 'task-1'").fetchone()[0]
  93 + self.assertNotEqual(status, "done")
  94 +
  95 +
  96 +if __name__ == "__main__":
  97 + unittest.main()
... ...
src/test_org_level_loop_e2e.py 0 → 100644
  1 +"""U-017's real-call coverage: architecture doc §11 MVP acceptance criterion
  2 +4, for real -- a deliberately injected off-by-one wall-collision bug (same
  3 +scenario shape as U-015's real test, but this time actually driving a real
  4 +fix instead of just judging), routed, fixed by a real Coder call editing the
  5 +actual file in the repo, then confirmed by a real Validator call that reads
  6 +the fixed file. Gated behind RUN_REAL_AGENT_E2E=1 (CLAUDE.md Testing
  7 +constraint).
  8 +
  9 +Run: RUN_REAL_AGENT_E2E=1 python3 -m unittest src.test_org_level_loop_e2e -v
  10 +"""
  11 +
  12 +import subprocess
  13 +import tempfile
  14 +import unittest
  15 +
  16 +from src import agent_profile, org_level_loop, schema
  17 +from src.test_support import skip_unless_env
  18 +
  19 +_SDD_SNIPPET = (
  20 + "## Wall collision (SDD §3.2)\n"
  21 + "The board is a grid with valid coordinates 0..N-1 in each axis. A collision (game "
  22 + "over) MUST be triggered the instant the head's x or y coordinate is < 0 or >= N."
  23 +)
  24 +
  25 +_BUG_DESCRIPTION = (
  26 + "When the snake's head reaches the rightmost or bottommost column, the game does not "
  27 + "end immediately -- the head is visually one cell outside the board before 'wall "
  28 + "collision' triggers. See collision.py in this repo."
  29 +)
  30 +
  31 +_BUGGY_CODE = (
  32 + "def check_wall_collision(head_x, head_y, board_size):\n"
  33 + " # BUG: uses > / < instead of >= / <=, so the head is allowed to sit one cell\n"
  34 + " # past the actual boundary before a collision is registered.\n"
  35 + " return head_x < 0 or head_y < 0 or head_x > board_size or head_y > board_size\n"
  36 +)
  37 +
  38 +
  39 +@skip_unless_env("RUN_REAL_AGENT_E2E")
  40 +class RealOrgLevelLoopTest(unittest.TestCase):
  41 + def test_real_fix_loop_reaches_done(self):
  42 + with tempfile.TemporaryDirectory() as tmp:
  43 + subprocess.run(["git", "init"], cwd=tmp, check=True, capture_output=True)
  44 + agent_profile.deploy_agent_definitions(tmp)
  45 + with open(tmp + "/collision.py", "w") as f:
  46 + f.write(_BUGGY_CODE)
  47 + subprocess.run(["git", "add", "collision.py"], cwd=tmp, check=True, capture_output=True)
  48 + subprocess.run(
  49 + ["git", "-c", "user.name=t", "-c", "user.email=t@t.com", "commit", "-m", "seed buggy collision.py"],
  50 + cwd=tmp, check=True, capture_output=True,
  51 + )
  52 +
  53 + conn = schema.connect(":memory:")
  54 + schema.create_schema(conn)
  55 + conn.execute(
  56 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  57 + "VALUES ('proj-1', 'P', ?, 1, 'active')",
  58 + (tmp,),
  59 + )
  60 + conn.execute(
  61 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, loop_round, assigned_org_node) "
  62 + "VALUES ('task-1', 'proj-1', '实现贪吃蛇游戏', 'goal', 'validating', 0, 'validator')"
  63 + )
  64 + conn.commit()
  65 +
  66 + result = org_level_loop.run_fix_loop(
  67 + conn, "task-1", tmp,
  68 + bug_description=_BUG_DESCRIPTION,
  69 + context="SDD:\n{}\n\nImplementation:\n{}".format(_SDD_SNIPPET, _BUGGY_CODE),
  70 + fix_prompt=(
  71 + "Fix the off-by-one wall-collision bug in collision.py per this SDD "
  72 + "section, then commit the fix:\n\n" + _SDD_SNIPPET
  73 + ),
  74 + verify_prompt=(
  75 + "Read collision.py in this repo and check whether check_wall_collision now "
  76 + "correctly triggers at the boundary (>= N / < 0, not > N / < 0), per this SDD "
  77 + "section:\n\n" + _SDD_SNIPPET + "\n\nReply with 'VERDICT: PASS' or 'VERDICT: FAIL' "
  78 + "and your reasoning."
  79 + ),
  80 + timeout=300,
  81 + )
  82 +
  83 + self.assertEqual(result["root_cause_category"], "coder")
  84 + self.assertEqual(result["route"], "fast_lane")
  85 + self.assertTrue(result["verified"], "validator did not confirm the fix: " + result["verify_response"])
  86 + self.assertEqual(result["final_status"], "done")
  87 +
  88 +
  89 +if __name__ == "__main__":
  90 + unittest.main()
... ...