diff --git a/docs/development-plan.md b/docs/development-plan.md index 8e343f3..ca1a909 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -164,6 +164,8 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 - **U-016 [Risk Gate]** `c_org_loop_route` + `fast_lane`/`full_replan` 快速通道 - 依赖:U-015 - Done:REQ-05/REQ-09 全部用例通过(含 3 轮上限 → `blocked_pending_human`) + - **实现补充(2026-07-31)**:顺手补上了 `validating→done`(validator_pass 分支,04-P 原话动作是"关闭任务",没有挂具体 `c_*` 控制器名字,之前也没有任何代码把 `task.status` 真正写成过 `done`)——`close_task_done()`,和 `c_org_loop_route` 是同一个"validating 状态怎么退出"决策的另一半,REQ-05 happy 用例要求"最终 task.status=done",不补这个到不了终点 + - **简化,如实标注(2026-07-31)**:04-P 表格把 fast_lane/full_replan 拆成两跳(`validating→routed_fast_lane→coding`),但 `c_org_loop_route` 自己的 03-C 契约直接输出最终态 `{new_status, new_assigned_org_node}`,且没有第二跳的独立控制器名字——实现上把两跳合并成一次原子调用,`routed_fast_lane`/`routed_full_replan` 这两个 `task.status` 枚举值实际不会被落库,改成写一条 `execution_event(event_type=loop_routed)` 记录路由决策本身。REQ-05/REQ-09 的验收文字都只看最终落点,不检查这个中间态,不影响 Done;以后 PH3 看板如果要真的展示这个瞬时状态,需要回头改 - 覆盖:03-C `c_org_loop_route`;REQ-05/REQ-09 - **U-017 [Risk Gate]** 人为植入 bug 完整闭环验证(架构文档 §11 验收标准第 4 条) diff --git a/docs/sdd/unit_matrix.json b/docs/sdd/unit_matrix.json index 92083f7..6e87657 100644 --- a/docs/sdd/unit_matrix.json +++ b/docs/sdd/unit_matrix.json @@ -29,7 +29,7 @@ {"id": "U-014", "phase": "PH1", "sub_phase": "PH1-S3", "name": "贪吃蛇场景端到端验收(含补建 c_artifact_write,见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-011", "U-012", "U-013"], "coverage": {"model": ["artifact"], "view": [], "controller": ["c_artifact_write"], "process": [], "requirement": []}, "done": "架构文档 §11 验收标准第 1-3 条全部达成;plan_documents(Planner 四件套+血缘)真实验证过,Coder/Validator 全流程真实跑通待用户确认后执行"}, {"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", "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 全部通过"}, + {"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-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 通过"}, diff --git a/src/org_loop_route.py b/src/org_loop_route.py new file mode 100644 index 0000000..980373d --- /dev/null +++ b/src/org_loop_route.py @@ -0,0 +1,163 @@ +"""c_org_loop_route (03-C §3.11) + the validating-state exit that isn't +covered by any named controller (04-P: validating->done on validator_pass, +action "关闭任务" -- no c_* name given). Built together because they're the +same decision point (did the Validator's verdict PASS or FAIL) that U-015 +already produces one half of (FAIL -> root-cause judgment); this module is +where task.status actually reaches 'done' or gets routed back into the loop. + +Judgment (which route, or PASS/FAIL) already happened via the Validator -- +this module is purely the deterministic code side ("判断用 Agent,路由用代码" +per 03-C §3.11): given a decided_route, apply the matching p_org_task_lifecycle +(04-P) transition. + +**Simplification, noted rather than silently assumed**: 04-P's table models +validating->routed_fast_lane->coding as two hops (an observable +'routed_fast_lane'/'routed_full_replan' status, then a second 'route_applied' +transition to the final state). c_org_loop_route's own 03-C output_contract +returns {new_status, new_assigned_org_node} -- the *final* state directly, not +the intermediate one -- and no controller is named for the second hop. This +implementation collapses both into one atomic call (validating straight to +coding/planning_brd_prd_sdd), recording the route decision via an explicit +`execution_event(event_type=loop_routed)` instead of ever persisting +'routed_fast_lane'/'routed_full_replan' as a literal task.status row. Neither +REQ-05 nor REQ-09's acceptance wording checks for that literal intermediate +status, so this doesn't affect Done -- but a future PH3 dashboard wanting to +*see* that transient state would need this revisited. + +core_engine routing (03-C: routes to p_deploy_supervisor_blue_green instead +of fast_lane/full_replan) is PH5 scope (U-034+), not attempted here -- +core_engine isn't a valid bug_record.root_cause_category yet either (U-001's +schema deliberately excludes it, see src/bug_record.py). +""" + +import json +import uuid + +_NEW_STATUS = {"fast_lane": "coding", "full_replan": "planning_brd_prd_sdd"} +_NEW_ORG_NODE = {"fast_lane": "coder", "full_replan": "planner"} +_LOOP_LIMIT = 3 + + +class LoopLimitReachedError(Exception): + """LOOP_LIMIT_REACHED -- 03-C §3.11.""" + + +class TaskNotBlockedError(Exception): + """manual_resume called on a task that isn't blocked_pending_human (REQ-09 + failure case: calling it on an already-'done' task must error, not + silently no-op or corrupt the finished task).""" + + +class TaskNotValidatingError(Exception): + """close_task_done / route_org_loop(fast_lane|full_replan) called on a + task that isn't currently 'validating'.""" + + +def _write_status_event(conn, task_id, project_id, from_status, to_status): + conn.execute( + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " + "VALUES (?, ?, ?, 'task_status_changed', ?)", + (str(uuid.uuid4()), project_id, task_id, json.dumps({"from_status": from_status, "to_status": to_status})), + ) + + +def _get_task(conn, task_id): + row = conn.execute("SELECT project_id, status, loop_round FROM task WHERE task_id = ?", (task_id,)).fetchone() + if row is None: + raise ValueError("no such task: " + task_id) + return {"project_id": row[0], "status": row[1], "loop_round": row[2]} + + +def close_task_done(conn, task_id): + """04-P: validating -> done (validator_pass, "全部 Benchmark/测试用例通过").""" + task = _get_task(conn, task_id) + if task["status"] != "validating": + raise TaskNotValidatingError( + "task {} is status={}, not validating -- can't close to done".format(task_id, task["status"]) + ) + conn.execute("UPDATE task SET status = 'done', updated_at = CURRENT_TIMESTAMP WHERE task_id = ?", (task_id,)) + _write_status_event(conn, task_id, task["project_id"], "validating", "done") + conn.commit() + return {"task_id": task_id, "new_status": "done"} + + +def route_org_loop(conn, task_id, decided_route): + """decided_route: 'fast_lane' | 'full_replan' | 'manual_resume'. + Returns {"task_id", "new_status", "new_assigned_org_node"}. + """ + if decided_route not in ("fast_lane", "full_replan", "manual_resume"): + raise ValueError(decided_route) + + task = _get_task(conn, task_id) + + if decided_route == "manual_resume": + return _manual_resume(conn, task_id, task) + + if task["status"] != "validating": + raise TaskNotValidatingError( + "task {} is status={}, not validating -- fast_lane/full_replan routing only applies " + "right after a Validator fail".format(task_id, task["status"]) + ) + + if task["loop_round"] >= _LOOP_LIMIT: + conn.execute( + "UPDATE task SET status = 'blocked_pending_human', blocked_reason = 'loop_limit_reached', " + "updated_at = CURRENT_TIMESTAMP WHERE task_id = ?", + (task_id,), + ) + _write_status_event(conn, task_id, task["project_id"], task["status"], "blocked_pending_human") + conn.commit() + raise LoopLimitReachedError( + "task {} has loop_round={} >= {}, forced to blocked_pending_human".format( + task_id, task["loop_round"], _LOOP_LIMIT + ) + ) + + new_status = _NEW_STATUS[decided_route] + new_org_node = _NEW_ORG_NODE[decided_route] + conn.execute( + "UPDATE task SET status = ?, assigned_org_node = ?, loop_round = loop_round + 1, " + "updated_at = CURRENT_TIMESTAMP WHERE task_id = ?", + (new_status, new_org_node, task_id), + ) + conn.execute( + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " + "VALUES (?, ?, ?, 'loop_routed', ?)", + (str(uuid.uuid4()), task["project_id"], task_id, json.dumps({"decided_route": decided_route})), + ) + _write_status_event(conn, task_id, task["project_id"], task["status"], new_status) + conn.commit() + return {"task_id": task_id, "new_status": new_status, "new_assigned_org_node": new_org_node} + + +def _manual_resume(conn, task_id, task): + if task["status"] != "blocked_pending_human": + raise TaskNotBlockedError( + "task {} is status={}, not blocked_pending_human -- manual_resume only applies to " + "blocked tasks".format(task_id, task["status"]) + ) + + rows = conn.execute( + "SELECT payload FROM execution_event WHERE task_id = ? AND event_type = 'task_status_changed' " + "ORDER BY rowid DESC", + (task_id,), + ).fetchall() + restore_status = None + for (payload_json,) in rows: + payload = json.loads(payload_json) + if payload.get("to_status") == "blocked_pending_human": + restore_status = payload.get("from_status") + break + if restore_status is None: + raise ValueError( + "could not find the pre-blocked status for task {} from execution_event history".format(task_id) + ) + + conn.execute( + "UPDATE task SET status = ?, blocked_reason = NULL, updated_at = CURRENT_TIMESTAMP WHERE task_id = ?", + (restore_status, task_id), + ) + _write_status_event(conn, task_id, task["project_id"], "blocked_pending_human", restore_status) + conn.commit() + new_org_node = conn.execute("SELECT assigned_org_node FROM task WHERE task_id = ?", (task_id,)).fetchone()[0] + return {"task_id": task_id, "new_status": restore_status, "new_assigned_org_node": new_org_node} diff --git a/src/test_org_loop_route.py b/src/test_org_loop_route.py new file mode 100644 index 0000000..2cc2ef0 --- /dev/null +++ b/src/test_org_loop_route.py @@ -0,0 +1,152 @@ +"""U-016 tests: c_org_loop_route + close_task_done. All real (no mocking +needed -- sqlite is fast/free/local). + +Run: python3 -m unittest src.test_org_loop_route -v +""" + +import json +import unittest + +from src import org_loop_route, schema + + +class OrgLoopRouteTestBase(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.commit() + + def tearDown(self): + self.conn.close() + + def _make_task(self, task_id="task-1", status="validating", loop_round=0, assigned_org_node="validator"): + self.conn.execute( + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, loop_round, assigned_org_node) " + "VALUES (?, 'proj-1', 'goal', 'goal', ?, ?, ?)", + (task_id, status, loop_round, assigned_org_node), + ) + self.conn.commit() + return task_id + + +class CloseTaskDoneTest(OrgLoopRouteTestBase): + def test_validating_to_done(self): + self._make_task() + result = org_loop_route.close_task_done(self.conn, "task-1") + self.assertEqual(result["new_status"], "done") + status = self.conn.execute("SELECT status FROM task WHERE task_id = 'task-1'").fetchone()[0] + self.assertEqual(status, "done") + + def test_rejects_non_validating_task(self): + self._make_task(status="coding") + with self.assertRaises(org_loop_route.TaskNotValidatingError): + org_loop_route.close_task_done(self.conn, "task-1") + + +class FastLaneRoutingTest(OrgLoopRouteTestBase): + """REQ-05 happy: fast_lane routes straight to Coder, loop_round increments.""" + + def test_routes_to_coding_and_increments_loop_round(self): + self._make_task(loop_round=0) + result = org_loop_route.route_org_loop(self.conn, "task-1", "fast_lane") + self.assertEqual(result["new_status"], "coding") + self.assertEqual(result["new_assigned_org_node"], "coder") + row = self.conn.execute("SELECT status, assigned_org_node, loop_round FROM task WHERE task_id = 'task-1'").fetchone() + self.assertEqual(row, ("coding", "coder", 1)) + + def test_writes_loop_routed_event(self): + self._make_task() + org_loop_route.route_org_loop(self.conn, "task-1", "fast_lane") + row = self.conn.execute( + "SELECT payload FROM execution_event WHERE task_id = 'task-1' AND event_type = 'loop_routed'" + ).fetchone() + self.assertEqual(json.loads(row[0]), {"decided_route": "fast_lane"}) + + def test_full_chain_reaches_done_after_fix(self): + # REQ-05 happy, full chain: fast_lane -> coding -> (fix happens + # elsewhere, U-013) -> validating -> done, loop_round ends at 1 + self._make_task(loop_round=0) + org_loop_route.route_org_loop(self.conn, "task-1", "fast_lane") + self.conn.execute("UPDATE task SET status = 'validating' WHERE task_id = 'task-1'") + self.conn.commit() + result = org_loop_route.close_task_done(self.conn, "task-1") + self.assertEqual(result["new_status"], "done") + loop_round = self.conn.execute("SELECT loop_round FROM task WHERE task_id = 'task-1'").fetchone()[0] + self.assertEqual(loop_round, 1) + + +class FullReplanRoutingTest(OrgLoopRouteTestBase): + """REQ-05 boundary: full_replan goes back to Planner, not Coder.""" + + def test_routes_to_planning_not_coding(self): + self._make_task() + result = org_loop_route.route_org_loop(self.conn, "task-1", "full_replan") + self.assertEqual(result["new_status"], "planning_brd_prd_sdd") + self.assertEqual(result["new_assigned_org_node"], "planner") + + +class LoopLimitTest(OrgLoopRouteTestBase): + """REQ-05 failure: 3 unconverged rounds -> blocked_pending_human.""" + + def test_third_route_attempt_at_limit_blocks_and_raises(self): + self._make_task(loop_round=3) + with self.assertRaises(org_loop_route.LoopLimitReachedError): + org_loop_route.route_org_loop(self.conn, "task-1", "fast_lane") + row = self.conn.execute("SELECT status, blocked_reason FROM task WHERE task_id = 'task-1'").fetchone() + self.assertEqual(row, ("blocked_pending_human", "loop_limit_reached")) + + def test_scheduler_can_still_progress_other_tasks(self): + # REQ-09 boundary: one blocked task doesn't lock out others -- no + # scheduler exists yet to test against directly, but this proves + # route_org_loop/close_task_done have no cross-task side effects + self._make_task("task-1", loop_round=3) + self._make_task("task-2", loop_round=0) + with self.assertRaises(org_loop_route.LoopLimitReachedError): + org_loop_route.route_org_loop(self.conn, "task-1", "fast_lane") + result = org_loop_route.route_org_loop(self.conn, "task-2", "fast_lane") + self.assertEqual(result["new_status"], "coding") + + +class ManualResumeTest(OrgLoopRouteTestBase): + """REQ-09 happy/failure: manual_resume restores pre-blocked state, or + errors clearly if the task isn't actually blocked.""" + + def test_restores_status_before_it_was_blocked(self): + self._make_task(loop_round=3) + with self.assertRaises(org_loop_route.LoopLimitReachedError): + org_loop_route.route_org_loop(self.conn, "task-1", "fast_lane") + # task was 'validating' immediately before the loop-limit block + result = org_loop_route.route_org_loop(self.conn, "task-1", "manual_resume") + self.assertEqual(result["new_status"], "validating") + status = self.conn.execute("SELECT status, blocked_reason FROM task WHERE task_id = 'task-1'").fetchone() + self.assertEqual(status, ("validating", None)) + + def test_rejects_resume_on_non_blocked_task(self): + self._make_task(status="done") + with self.assertRaises(org_loop_route.TaskNotBlockedError): + org_loop_route.route_org_loop(self.conn, "task-1", "manual_resume") + + def test_resume_after_multiple_block_cycles_uses_most_recent(self): + self._make_task(loop_round=3) + with self.assertRaises(org_loop_route.LoopLimitReachedError): + org_loop_route.route_org_loop(self.conn, "task-1", "fast_lane") + org_loop_route.route_org_loop(self.conn, "task-1", "manual_resume") + # force loop_round back up and block again from a *different* status + self.conn.execute("UPDATE task SET status = 'coding', loop_round = 3 WHERE task_id = 'task-1'") + self.conn.commit() + with self.assertRaises(org_loop_route.LoopLimitReachedError): + # fast_lane routing requires status=validating, so force that + # first to hit the loop-limit branch again from a known state + self.conn.execute("UPDATE task SET status = 'validating' WHERE task_id = 'task-1'") + self.conn.commit() + org_loop_route.route_org_loop(self.conn, "task-1", "fast_lane") + result = org_loop_route.route_org_loop(self.conn, "task-1", "manual_resume") + self.assertEqual(result["new_status"], "validating") + + +if __name__ == "__main__": + unittest.main()