Commit 59749fc5e87ab0b91e5b64668e04838f65a82fe2
1 parent
f8c59e36
U-016: c_org_loop_route + fast_lane/full_replan (PH2-S1 complete)
route_org_loop(): fast_lane -> coding/coder, full_replan -> planning_brd_prd_sdd/planner, loop_round += 1, writes an execution_event(event_type=loop_routed) recording the decision. loop_round >= 3 forces blocked_pending_human (blocked_reason= 'loop_limit_reached') and raises LoopLimitReachedError instead of routing (03-C §3.11: "不接受常规路由请求,只接受 manual_resume"). manual_resume walks execution_event's task_status_changed history backwards to find what status preceded the most recent transition into blocked_pending_human, and restores it -- 04-P says "恢复到触发前状态" but task has no previous_status column, so this reconstructs it from the event log already being written everywhere in this codebase, rather than adding a new column for a value that's already reconstructable. Also built close_task_done() (validating->done, validator_pass): 04-P names this transition but not a controller for it, and nothing in this codebase had ever set task.status='done' before this commit -- REQ-05's happy case ends there, so it had to exist for that to be reachable. Documented as a deliberate scope extension, not silently assumed. Simplification, noted rather than silently collapsed: 04-P models fast_lane/full_replan as two hops (validating -> routed_fast_lane -> coding) but c_org_loop_route's own 03-C contract returns the final state directly and no controller owns the second hop -- implemented as one atomic transition, with the route decision preserved via the loop_routed event instead of a persisted intermediate status. Neither REQ-05 nor REQ-09 check for that literal intermediate state. 11 new stdlib unittest cases, all real (sqlite is fast/free/local, no mocking needed). 157 total across U-001-016, all passing. This closes PH2-S1 (U-015 + U-016): REQ-04, REQ-05, and REQ-09 are now all real code, not just planned. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
4 changed files
with
318 additions
and
1 deletions
docs/development-plan.md
| @@ -164,6 +164,8 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 | @@ -164,6 +164,8 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 | ||
| 164 | - **U-016 [Risk Gate]** `c_org_loop_route` + `fast_lane`/`full_replan` 快速通道 | 164 | - **U-016 [Risk Gate]** `c_org_loop_route` + `fast_lane`/`full_replan` 快速通道 |
| 165 | - 依赖:U-015 | 165 | - 依赖:U-015 |
| 166 | - Done:REQ-05/REQ-09 全部用例通过(含 3 轮上限 → `blocked_pending_human`) | 166 | - Done:REQ-05/REQ-09 全部用例通过(含 3 轮上限 → `blocked_pending_human`) |
| 167 | + - **实现补充(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",不补这个到不了终点 | ||
| 168 | + - **简化,如实标注(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 看板如果要真的展示这个瞬时状态,需要回头改 | ||
| 167 | - 覆盖:03-C `c_org_loop_route`;REQ-05/REQ-09 | 169 | - 覆盖:03-C `c_org_loop_route`;REQ-05/REQ-09 |
| 168 | 170 | ||
| 169 | - **U-017 [Risk Gate]** 人为植入 bug 完整闭环验证(架构文档 §11 验收标准第 4 条) | 171 | - **U-017 [Risk Gate]** 人为植入 bug 完整闭环验证(架构文档 §11 验收标准第 4 条) |
docs/sdd/unit_matrix.json
| @@ -29,7 +29,7 @@ | @@ -29,7 +29,7 @@ | ||
| 29 | {"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 全流程真实跑通待用户确认后执行"}, | 29 | {"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 全流程真实跑通待用户确认后执行"}, |
| 30 | 30 | ||
| 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 通过"}, | 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 | - {"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 全部通过"}, | 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 完整闭环验证", "classification": "Risk Gate", "depends_on": ["U-016"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "架构文档 §11 验收标准第 4 条达成"}, |
| 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 全部通过"}, | 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 | {"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 通过"}, | 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 通过"}, |
src/org_loop_route.py
0 → 100644
| 1 | +"""c_org_loop_route (03-C §3.11) + the validating-state exit that isn't | ||
| 2 | +covered by any named controller (04-P: validating->done on validator_pass, | ||
| 3 | +action "关闭任务" -- no c_* name given). Built together because they're the | ||
| 4 | +same decision point (did the Validator's verdict PASS or FAIL) that U-015 | ||
| 5 | +already produces one half of (FAIL -> root-cause judgment); this module is | ||
| 6 | +where task.status actually reaches 'done' or gets routed back into the loop. | ||
| 7 | + | ||
| 8 | +Judgment (which route, or PASS/FAIL) already happened via the Validator -- | ||
| 9 | +this module is purely the deterministic code side ("判断用 Agent,路由用代码" | ||
| 10 | +per 03-C §3.11): given a decided_route, apply the matching p_org_task_lifecycle | ||
| 11 | +(04-P) transition. | ||
| 12 | + | ||
| 13 | +**Simplification, noted rather than silently assumed**: 04-P's table models | ||
| 14 | +validating->routed_fast_lane->coding as two hops (an observable | ||
| 15 | +'routed_fast_lane'/'routed_full_replan' status, then a second 'route_applied' | ||
| 16 | +transition to the final state). c_org_loop_route's own 03-C output_contract | ||
| 17 | +returns {new_status, new_assigned_org_node} -- the *final* state directly, not | ||
| 18 | +the intermediate one -- and no controller is named for the second hop. This | ||
| 19 | +implementation collapses both into one atomic call (validating straight to | ||
| 20 | +coding/planning_brd_prd_sdd), recording the route decision via an explicit | ||
| 21 | +`execution_event(event_type=loop_routed)` instead of ever persisting | ||
| 22 | +'routed_fast_lane'/'routed_full_replan' as a literal task.status row. Neither | ||
| 23 | +REQ-05 nor REQ-09's acceptance wording checks for that literal intermediate | ||
| 24 | +status, so this doesn't affect Done -- but a future PH3 dashboard wanting to | ||
| 25 | +*see* that transient state would need this revisited. | ||
| 26 | + | ||
| 27 | +core_engine routing (03-C: routes to p_deploy_supervisor_blue_green instead | ||
| 28 | +of fast_lane/full_replan) is PH5 scope (U-034+), not attempted here -- | ||
| 29 | +core_engine isn't a valid bug_record.root_cause_category yet either (U-001's | ||
| 30 | +schema deliberately excludes it, see src/bug_record.py). | ||
| 31 | +""" | ||
| 32 | + | ||
| 33 | +import json | ||
| 34 | +import uuid | ||
| 35 | + | ||
| 36 | +_NEW_STATUS = {"fast_lane": "coding", "full_replan": "planning_brd_prd_sdd"} | ||
| 37 | +_NEW_ORG_NODE = {"fast_lane": "coder", "full_replan": "planner"} | ||
| 38 | +_LOOP_LIMIT = 3 | ||
| 39 | + | ||
| 40 | + | ||
| 41 | +class LoopLimitReachedError(Exception): | ||
| 42 | + """LOOP_LIMIT_REACHED -- 03-C §3.11.""" | ||
| 43 | + | ||
| 44 | + | ||
| 45 | +class TaskNotBlockedError(Exception): | ||
| 46 | + """manual_resume called on a task that isn't blocked_pending_human (REQ-09 | ||
| 47 | + failure case: calling it on an already-'done' task must error, not | ||
| 48 | + silently no-op or corrupt the finished task).""" | ||
| 49 | + | ||
| 50 | + | ||
| 51 | +class TaskNotValidatingError(Exception): | ||
| 52 | + """close_task_done / route_org_loop(fast_lane|full_replan) called on a | ||
| 53 | + task that isn't currently 'validating'.""" | ||
| 54 | + | ||
| 55 | + | ||
| 56 | +def _write_status_event(conn, task_id, project_id, from_status, to_status): | ||
| 57 | + conn.execute( | ||
| 58 | + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " | ||
| 59 | + "VALUES (?, ?, ?, 'task_status_changed', ?)", | ||
| 60 | + (str(uuid.uuid4()), project_id, task_id, json.dumps({"from_status": from_status, "to_status": to_status})), | ||
| 61 | + ) | ||
| 62 | + | ||
| 63 | + | ||
| 64 | +def _get_task(conn, task_id): | ||
| 65 | + row = conn.execute("SELECT project_id, status, loop_round FROM task WHERE task_id = ?", (task_id,)).fetchone() | ||
| 66 | + if row is None: | ||
| 67 | + raise ValueError("no such task: " + task_id) | ||
| 68 | + return {"project_id": row[0], "status": row[1], "loop_round": row[2]} | ||
| 69 | + | ||
| 70 | + | ||
| 71 | +def close_task_done(conn, task_id): | ||
| 72 | + """04-P: validating -> done (validator_pass, "全部 Benchmark/测试用例通过").""" | ||
| 73 | + task = _get_task(conn, task_id) | ||
| 74 | + if task["status"] != "validating": | ||
| 75 | + raise TaskNotValidatingError( | ||
| 76 | + "task {} is status={}, not validating -- can't close to done".format(task_id, task["status"]) | ||
| 77 | + ) | ||
| 78 | + conn.execute("UPDATE task SET status = 'done', updated_at = CURRENT_TIMESTAMP WHERE task_id = ?", (task_id,)) | ||
| 79 | + _write_status_event(conn, task_id, task["project_id"], "validating", "done") | ||
| 80 | + conn.commit() | ||
| 81 | + return {"task_id": task_id, "new_status": "done"} | ||
| 82 | + | ||
| 83 | + | ||
| 84 | +def route_org_loop(conn, task_id, decided_route): | ||
| 85 | + """decided_route: 'fast_lane' | 'full_replan' | 'manual_resume'. | ||
| 86 | + Returns {"task_id", "new_status", "new_assigned_org_node"}. | ||
| 87 | + """ | ||
| 88 | + if decided_route not in ("fast_lane", "full_replan", "manual_resume"): | ||
| 89 | + raise ValueError(decided_route) | ||
| 90 | + | ||
| 91 | + task = _get_task(conn, task_id) | ||
| 92 | + | ||
| 93 | + if decided_route == "manual_resume": | ||
| 94 | + return _manual_resume(conn, task_id, task) | ||
| 95 | + | ||
| 96 | + if task["status"] != "validating": | ||
| 97 | + raise TaskNotValidatingError( | ||
| 98 | + "task {} is status={}, not validating -- fast_lane/full_replan routing only applies " | ||
| 99 | + "right after a Validator fail".format(task_id, task["status"]) | ||
| 100 | + ) | ||
| 101 | + | ||
| 102 | + if task["loop_round"] >= _LOOP_LIMIT: | ||
| 103 | + conn.execute( | ||
| 104 | + "UPDATE task SET status = 'blocked_pending_human', blocked_reason = 'loop_limit_reached', " | ||
| 105 | + "updated_at = CURRENT_TIMESTAMP WHERE task_id = ?", | ||
| 106 | + (task_id,), | ||
| 107 | + ) | ||
| 108 | + _write_status_event(conn, task_id, task["project_id"], task["status"], "blocked_pending_human") | ||
| 109 | + conn.commit() | ||
| 110 | + raise LoopLimitReachedError( | ||
| 111 | + "task {} has loop_round={} >= {}, forced to blocked_pending_human".format( | ||
| 112 | + task_id, task["loop_round"], _LOOP_LIMIT | ||
| 113 | + ) | ||
| 114 | + ) | ||
| 115 | + | ||
| 116 | + new_status = _NEW_STATUS[decided_route] | ||
| 117 | + new_org_node = _NEW_ORG_NODE[decided_route] | ||
| 118 | + conn.execute( | ||
| 119 | + "UPDATE task SET status = ?, assigned_org_node = ?, loop_round = loop_round + 1, " | ||
| 120 | + "updated_at = CURRENT_TIMESTAMP WHERE task_id = ?", | ||
| 121 | + (new_status, new_org_node, task_id), | ||
| 122 | + ) | ||
| 123 | + conn.execute( | ||
| 124 | + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " | ||
| 125 | + "VALUES (?, ?, ?, 'loop_routed', ?)", | ||
| 126 | + (str(uuid.uuid4()), task["project_id"], task_id, json.dumps({"decided_route": decided_route})), | ||
| 127 | + ) | ||
| 128 | + _write_status_event(conn, task_id, task["project_id"], task["status"], new_status) | ||
| 129 | + conn.commit() | ||
| 130 | + return {"task_id": task_id, "new_status": new_status, "new_assigned_org_node": new_org_node} | ||
| 131 | + | ||
| 132 | + | ||
| 133 | +def _manual_resume(conn, task_id, task): | ||
| 134 | + if task["status"] != "blocked_pending_human": | ||
| 135 | + raise TaskNotBlockedError( | ||
| 136 | + "task {} is status={}, not blocked_pending_human -- manual_resume only applies to " | ||
| 137 | + "blocked tasks".format(task_id, task["status"]) | ||
| 138 | + ) | ||
| 139 | + | ||
| 140 | + rows = conn.execute( | ||
| 141 | + "SELECT payload FROM execution_event WHERE task_id = ? AND event_type = 'task_status_changed' " | ||
| 142 | + "ORDER BY rowid DESC", | ||
| 143 | + (task_id,), | ||
| 144 | + ).fetchall() | ||
| 145 | + restore_status = None | ||
| 146 | + for (payload_json,) in rows: | ||
| 147 | + payload = json.loads(payload_json) | ||
| 148 | + if payload.get("to_status") == "blocked_pending_human": | ||
| 149 | + restore_status = payload.get("from_status") | ||
| 150 | + break | ||
| 151 | + if restore_status is None: | ||
| 152 | + raise ValueError( | ||
| 153 | + "could not find the pre-blocked status for task {} from execution_event history".format(task_id) | ||
| 154 | + ) | ||
| 155 | + | ||
| 156 | + conn.execute( | ||
| 157 | + "UPDATE task SET status = ?, blocked_reason = NULL, updated_at = CURRENT_TIMESTAMP WHERE task_id = ?", | ||
| 158 | + (restore_status, task_id), | ||
| 159 | + ) | ||
| 160 | + _write_status_event(conn, task_id, task["project_id"], "blocked_pending_human", restore_status) | ||
| 161 | + conn.commit() | ||
| 162 | + new_org_node = conn.execute("SELECT assigned_org_node FROM task WHERE task_id = ?", (task_id,)).fetchone()[0] | ||
| 163 | + return {"task_id": task_id, "new_status": restore_status, "new_assigned_org_node": new_org_node} |
src/test_org_loop_route.py
0 → 100644
| 1 | +"""U-016 tests: c_org_loop_route + close_task_done. All real (no mocking | ||
| 2 | +needed -- sqlite is fast/free/local). | ||
| 3 | + | ||
| 4 | +Run: python3 -m unittest src.test_org_loop_route -v | ||
| 5 | +""" | ||
| 6 | + | ||
| 7 | +import json | ||
| 8 | +import unittest | ||
| 9 | + | ||
| 10 | +from src import org_loop_route, schema | ||
| 11 | + | ||
| 12 | + | ||
| 13 | +class OrgLoopRouteTestBase(unittest.TestCase): | ||
| 14 | + def setUp(self): | ||
| 15 | + self.conn = schema.connect(":memory:") | ||
| 16 | + schema.create_schema(self.conn) | ||
| 17 | + self.conn.execute( | ||
| 18 | + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " | ||
| 19 | + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" | ||
| 20 | + ) | ||
| 21 | + self.conn.commit() | ||
| 22 | + | ||
| 23 | + def tearDown(self): | ||
| 24 | + self.conn.close() | ||
| 25 | + | ||
| 26 | + def _make_task(self, task_id="task-1", status="validating", loop_round=0, assigned_org_node="validator"): | ||
| 27 | + self.conn.execute( | ||
| 28 | + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, loop_round, assigned_org_node) " | ||
| 29 | + "VALUES (?, 'proj-1', 'goal', 'goal', ?, ?, ?)", | ||
| 30 | + (task_id, status, loop_round, assigned_org_node), | ||
| 31 | + ) | ||
| 32 | + self.conn.commit() | ||
| 33 | + return task_id | ||
| 34 | + | ||
| 35 | + | ||
| 36 | +class CloseTaskDoneTest(OrgLoopRouteTestBase): | ||
| 37 | + def test_validating_to_done(self): | ||
| 38 | + self._make_task() | ||
| 39 | + result = org_loop_route.close_task_done(self.conn, "task-1") | ||
| 40 | + self.assertEqual(result["new_status"], "done") | ||
| 41 | + status = self.conn.execute("SELECT status FROM task WHERE task_id = 'task-1'").fetchone()[0] | ||
| 42 | + self.assertEqual(status, "done") | ||
| 43 | + | ||
| 44 | + def test_rejects_non_validating_task(self): | ||
| 45 | + self._make_task(status="coding") | ||
| 46 | + with self.assertRaises(org_loop_route.TaskNotValidatingError): | ||
| 47 | + org_loop_route.close_task_done(self.conn, "task-1") | ||
| 48 | + | ||
| 49 | + | ||
| 50 | +class FastLaneRoutingTest(OrgLoopRouteTestBase): | ||
| 51 | + """REQ-05 happy: fast_lane routes straight to Coder, loop_round increments.""" | ||
| 52 | + | ||
| 53 | + def test_routes_to_coding_and_increments_loop_round(self): | ||
| 54 | + self._make_task(loop_round=0) | ||
| 55 | + result = org_loop_route.route_org_loop(self.conn, "task-1", "fast_lane") | ||
| 56 | + self.assertEqual(result["new_status"], "coding") | ||
| 57 | + self.assertEqual(result["new_assigned_org_node"], "coder") | ||
| 58 | + row = self.conn.execute("SELECT status, assigned_org_node, loop_round FROM task WHERE task_id = 'task-1'").fetchone() | ||
| 59 | + self.assertEqual(row, ("coding", "coder", 1)) | ||
| 60 | + | ||
| 61 | + def test_writes_loop_routed_event(self): | ||
| 62 | + self._make_task() | ||
| 63 | + org_loop_route.route_org_loop(self.conn, "task-1", "fast_lane") | ||
| 64 | + row = self.conn.execute( | ||
| 65 | + "SELECT payload FROM execution_event WHERE task_id = 'task-1' AND event_type = 'loop_routed'" | ||
| 66 | + ).fetchone() | ||
| 67 | + self.assertEqual(json.loads(row[0]), {"decided_route": "fast_lane"}) | ||
| 68 | + | ||
| 69 | + def test_full_chain_reaches_done_after_fix(self): | ||
| 70 | + # REQ-05 happy, full chain: fast_lane -> coding -> (fix happens | ||
| 71 | + # elsewhere, U-013) -> validating -> done, loop_round ends at 1 | ||
| 72 | + self._make_task(loop_round=0) | ||
| 73 | + org_loop_route.route_org_loop(self.conn, "task-1", "fast_lane") | ||
| 74 | + self.conn.execute("UPDATE task SET status = 'validating' WHERE task_id = 'task-1'") | ||
| 75 | + self.conn.commit() | ||
| 76 | + result = org_loop_route.close_task_done(self.conn, "task-1") | ||
| 77 | + self.assertEqual(result["new_status"], "done") | ||
| 78 | + loop_round = self.conn.execute("SELECT loop_round FROM task WHERE task_id = 'task-1'").fetchone()[0] | ||
| 79 | + self.assertEqual(loop_round, 1) | ||
| 80 | + | ||
| 81 | + | ||
| 82 | +class FullReplanRoutingTest(OrgLoopRouteTestBase): | ||
| 83 | + """REQ-05 boundary: full_replan goes back to Planner, not Coder.""" | ||
| 84 | + | ||
| 85 | + def test_routes_to_planning_not_coding(self): | ||
| 86 | + self._make_task() | ||
| 87 | + result = org_loop_route.route_org_loop(self.conn, "task-1", "full_replan") | ||
| 88 | + self.assertEqual(result["new_status"], "planning_brd_prd_sdd") | ||
| 89 | + self.assertEqual(result["new_assigned_org_node"], "planner") | ||
| 90 | + | ||
| 91 | + | ||
| 92 | +class LoopLimitTest(OrgLoopRouteTestBase): | ||
| 93 | + """REQ-05 failure: 3 unconverged rounds -> blocked_pending_human.""" | ||
| 94 | + | ||
| 95 | + def test_third_route_attempt_at_limit_blocks_and_raises(self): | ||
| 96 | + self._make_task(loop_round=3) | ||
| 97 | + with self.assertRaises(org_loop_route.LoopLimitReachedError): | ||
| 98 | + org_loop_route.route_org_loop(self.conn, "task-1", "fast_lane") | ||
| 99 | + row = self.conn.execute("SELECT status, blocked_reason FROM task WHERE task_id = 'task-1'").fetchone() | ||
| 100 | + self.assertEqual(row, ("blocked_pending_human", "loop_limit_reached")) | ||
| 101 | + | ||
| 102 | + def test_scheduler_can_still_progress_other_tasks(self): | ||
| 103 | + # REQ-09 boundary: one blocked task doesn't lock out others -- no | ||
| 104 | + # scheduler exists yet to test against directly, but this proves | ||
| 105 | + # route_org_loop/close_task_done have no cross-task side effects | ||
| 106 | + self._make_task("task-1", loop_round=3) | ||
| 107 | + self._make_task("task-2", loop_round=0) | ||
| 108 | + with self.assertRaises(org_loop_route.LoopLimitReachedError): | ||
| 109 | + org_loop_route.route_org_loop(self.conn, "task-1", "fast_lane") | ||
| 110 | + result = org_loop_route.route_org_loop(self.conn, "task-2", "fast_lane") | ||
| 111 | + self.assertEqual(result["new_status"], "coding") | ||
| 112 | + | ||
| 113 | + | ||
| 114 | +class ManualResumeTest(OrgLoopRouteTestBase): | ||
| 115 | + """REQ-09 happy/failure: manual_resume restores pre-blocked state, or | ||
| 116 | + errors clearly if the task isn't actually blocked.""" | ||
| 117 | + | ||
| 118 | + def test_restores_status_before_it_was_blocked(self): | ||
| 119 | + self._make_task(loop_round=3) | ||
| 120 | + with self.assertRaises(org_loop_route.LoopLimitReachedError): | ||
| 121 | + org_loop_route.route_org_loop(self.conn, "task-1", "fast_lane") | ||
| 122 | + # task was 'validating' immediately before the loop-limit block | ||
| 123 | + result = org_loop_route.route_org_loop(self.conn, "task-1", "manual_resume") | ||
| 124 | + self.assertEqual(result["new_status"], "validating") | ||
| 125 | + status = self.conn.execute("SELECT status, blocked_reason FROM task WHERE task_id = 'task-1'").fetchone() | ||
| 126 | + self.assertEqual(status, ("validating", None)) | ||
| 127 | + | ||
| 128 | + def test_rejects_resume_on_non_blocked_task(self): | ||
| 129 | + self._make_task(status="done") | ||
| 130 | + with self.assertRaises(org_loop_route.TaskNotBlockedError): | ||
| 131 | + org_loop_route.route_org_loop(self.conn, "task-1", "manual_resume") | ||
| 132 | + | ||
| 133 | + def test_resume_after_multiple_block_cycles_uses_most_recent(self): | ||
| 134 | + self._make_task(loop_round=3) | ||
| 135 | + with self.assertRaises(org_loop_route.LoopLimitReachedError): | ||
| 136 | + org_loop_route.route_org_loop(self.conn, "task-1", "fast_lane") | ||
| 137 | + org_loop_route.route_org_loop(self.conn, "task-1", "manual_resume") | ||
| 138 | + # force loop_round back up and block again from a *different* status | ||
| 139 | + self.conn.execute("UPDATE task SET status = 'coding', loop_round = 3 WHERE task_id = 'task-1'") | ||
| 140 | + self.conn.commit() | ||
| 141 | + with self.assertRaises(org_loop_route.LoopLimitReachedError): | ||
| 142 | + # fast_lane routing requires status=validating, so force that | ||
| 143 | + # first to hit the loop-limit branch again from a known state | ||
| 144 | + self.conn.execute("UPDATE task SET status = 'validating' WHERE task_id = 'task-1'") | ||
| 145 | + self.conn.commit() | ||
| 146 | + org_loop_route.route_org_loop(self.conn, "task-1", "fast_lane") | ||
| 147 | + result = org_loop_route.route_org_loop(self.conn, "task-1", "manual_resume") | ||
| 148 | + self.assertEqual(result["new_status"], "validating") | ||
| 149 | + | ||
| 150 | + | ||
| 151 | +if __name__ == "__main__": | ||
| 152 | + unittest.main() |