Commit 59749fc5e87ab0b91e5b64668e04838f65a82fe2

Authored by bboonnee
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>
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()