Commit efb1ee4226a5c326f8d5df19869fb6013cecf4b2
1 parent
a8cab45d
U-010: c_org_node_trigger full implementation (3 forward transitions)
trigger_org_node_full(): guards (TASK_NOT_READY -- wrong task.status or
required artifacts not validation_status=passed; CONCURRENCY_LIMIT_REACHED
-- only on the planner/queued transition, per 04-P's guard column;
USAGE_LIMIT_REACHED -- detected from the underlying claude error text,
best-effort against REQ-14's cited "hit your session limit" phrasing) then
calls U-005's trigger_org_node for the real subprocess call, then advances
task.status: queued->planning_brd_prd_sdd (planner), planning_brd_prd_sdd
->coding (coder, gated on brd/prd/sdd/benchmark all passed),
coding->validating (validator, gated on code_diff/test_report both passed).
Verified for real: all three Org Agents actually triggered in sequence
(chained claude --agent calls, ~15s/~$0.15 total) with task.status
advancing correctly at each step -- not just planner like U-004/U-005's
tests, all three personas confirmed invokable.
Also corrects two more development-plan.md issues found while scoping
this Unit, same class as U-002/U-009/U-036:
- U-010's Done line cited '架构文档 §11"手工调用验证"里程碑' -- that phrase
doesn't exist in architecture doc §11 at all. The real source is 05
§5.5's rollout step 2 ("手工调用验证...三个 Org Agent 分别可被触发"),
whose "分别" (separately/respectively) is the load-bearing word: verify
each CAN be triggered, not that a full artifact-validated chain runs
end to end. That distinction is why this Unit's scope is exactly what
got built -- not chasing planning_failed/coding_failed retries or
validator PASS/FAIL routing, which need real Work Graph output (U-011+)
or Org-level Loop routing (U-015/016, PH2) that don't exist yet.
- Added an explicit scope note spelling out exactly which 3 transitions
this Unit owns vs. which parts of p_org_task_lifecycle it deliberately
doesn't touch, so nobody has to re-derive this from the code later.
11 new mocked stdlib unittest cases + 1 gated real chained-trigger test.
87 total mocked tests across U-001-010, all passing; the real test
independently re-run and confirmed passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
5 changed files
with
359 additions
and
2 deletions
docs/development-plan.md
| @@ -110,7 +110,8 @@ | @@ -110,7 +110,8 @@ | ||
| 110 | 110 | ||
| 111 | - **U-010 [Value Path]** `c_org_node_trigger` 完整实现(含 `TASK_NOT_READY`/`CONCURRENCY_LIMIT_REACHED`/`USAGE_LIMIT_REACHED` 错误处理) | 111 | - **U-010 [Value Path]** `c_org_node_trigger` 完整实现(含 `TASK_NOT_READY`/`CONCURRENCY_LIMIT_REACHED`/`USAGE_LIMIT_REACHED` 错误处理) |
| 112 | - 依赖:U-005, U-009 | 112 | - 依赖:U-005, U-009 |
| 113 | - - Done:手工依次触发 planner/coder/validator 三个 Org Agent,`task.status` 按状态机正确流转(对应架构文档 §11"手工调用验证"里程碑) | 113 | + - Done:手工依次触发 planner/coder/validator 三个 Org Agent,`task.status` 按状态机正确流转(**2026-07-31 订正引用**:原来写"对应架构文档 §11'手工调用验证'里程碑",但架构文档 §11 里根本没有这个短语;真正出处是 05 节 §5.5"上线顺序与兼容性"第 2 步:"再落地 Org Graph 层接口...此时可先用手工调用验证 Planner/Coder/Validator 三个 Org Agent **分别**可被触发"——注意是"分别",即逐个验证能被触发,不是要求一次串联跑通带真实 Artifact 校验的完整链路,那是 05 节 §5.5 第 3 步"Work Graph 层集成"之后的事,此处不做) |
| 114 | + - **范围说明(2026-07-31,实现后补充)**:本 Unit 只落地 `p_org_task_lifecycle`(04-P)里由单次 `c_org_node_trigger` 调用直接触发的三段前向状态迁移——`queued→planning_brd_prd_sdd`(触发 planner)、`planning_brd_prd_sdd→coding`(触发 coder,需要 BRD/PRD/SDD/Benchmark 四类 Artifact 均 `validation_status=passed`)、`coding→validating`(触发 validator,需要 `code_diff`/`test_report` 均 `validation_status=passed`)。`planning_failed`/`coding_failed` 重试、Validator PASS/FAIL 判定与 `bug_record`/`c_org_loop_route` 路由、`blocked_pending_human` 升级,都需要真实 Work Graph 产出(U-011 起)或 Org-level Loop(U-015/U-016,PH2)才能实现,本 Unit 不做,也做不到——由于没有真实 Work Graph 产出 Artifact,测试里的 Artifact 校验前置条件是直接 seed 数据进去的,不是跑出来的,这是意料之中的(上游契约还没实现,消费方先按契约测) | ||
| 114 | - 覆盖:03-C `c_org_node_trigger` | 115 | - 覆盖:03-C `c_org_node_trigger` |
| 115 | 116 | ||
| 116 | ### PH1-S2 Work Graph 层集成(本项目风险最高的 Sub-phase) | 117 | ### PH1-S2 Work Graph 层集成(本项目风险最高的 Sub-phase) |
docs/sdd/unit_matrix.json
| @@ -22,7 +22,7 @@ | @@ -22,7 +22,7 @@ | ||
| 22 | {"id": "U-008", "phase": "PH0", "sub_phase": "PH0-S3", "name": "前端骨架", "classification": "Enabler", "depends_on": ["U-007"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "本地 web 应用路由跳转正常"}, | 22 | {"id": "U-008", "phase": "PH0", "sub_phase": "PH0-S3", "name": "前端骨架", "classification": "Enabler", "depends_on": ["U-007"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "本地 web 应用路由跳转正常"}, |
| 23 | 23 | ||
| 24 | {"id": "U-009", "phase": "PH1", "sub_phase": "PH1-S1", "name": "c_task_create + task 状态机(c_project_create 已在 U-002 完成)", "classification": "Value Path", "depends_on": ["U-001", "U-002"], "coverage": {"model": ["task"], "view": [], "controller": ["c_task_create"], "process": ["p_org_task_lifecycle"], "requirement": ["REQ-01"]}, "done": "REQ-01 boundary/failure 通过;happy 用例需要 c_org_node_trigger 完整实现,随 U-010 累计满足(2026-07-31 订正)"}, | 24 | {"id": "U-009", "phase": "PH1", "sub_phase": "PH1-S1", "name": "c_task_create + task 状态机(c_project_create 已在 U-002 完成)", "classification": "Value Path", "depends_on": ["U-001", "U-002"], "coverage": {"model": ["task"], "view": [], "controller": ["c_task_create"], "process": ["p_org_task_lifecycle"], "requirement": ["REQ-01"]}, "done": "REQ-01 boundary/failure 通过;happy 用例需要 c_org_node_trigger 完整实现,随 U-010 累计满足(2026-07-31 订正)"}, |
| 25 | - {"id": "U-010", "phase": "PH1", "sub_phase": "PH1-S1", "name": "c_org_node_trigger 完整实现", "classification": "Value Path", "depends_on": ["U-005", "U-009"], "coverage": {"model": [], "view": [], "controller": ["c_org_node_trigger"], "process": [], "requirement": []}, "done": "三个 Org Agent 依次可被触发,task.status 按状态机正确流转"}, | 25 | + {"id": "U-010", "phase": "PH1", "sub_phase": "PH1-S1", "name": "c_org_node_trigger 完整实现(三段前向状态迁移,见 development-plan.md 2026-07-31 范围说明)", "classification": "Value Path", "depends_on": ["U-005", "U-009"], "coverage": {"model": [], "view": [], "controller": ["c_org_node_trigger"], "process": [], "requirement": []}, "done": "三个 Org Agent 分别可被触发(真实 claude --agent 调用),task.status 按 queued→planning_brd_prd_sdd→coding→validating 正确流转;重试/路由/blocked_pending_human 不在本 Unit 范围"}, |
| 26 | {"id": "U-011", "phase": "PH1", "sub_phase": "PH1-S2", "name": "c_work_graph_plan 实现", "classification": "Value Path", "depends_on": ["U-004", "U-010"], "coverage": {"model": ["artifact", "work_graph_run"], "view": [], "controller": ["c_work_graph_plan"], "process": [], "requirement": ["REQ-02"]}, "done": "REQ-02 happy 用例通过"}, | 26 | {"id": "U-011", "phase": "PH1", "sub_phase": "PH1-S2", "name": "c_work_graph_plan 实现", "classification": "Value Path", "depends_on": ["U-004", "U-010"], "coverage": {"model": ["artifact", "work_graph_run"], "view": [], "controller": ["c_work_graph_plan"], "process": [], "requirement": ["REQ-02"]}, "done": "REQ-02 happy 用例通过"}, |
| 27 | {"id": "U-012", "phase": "PH1", "sub_phase": "PH1-S2", "name": "c_work_graph_execute 完整实现", "classification": "Value Path", "depends_on": ["U-006", "U-011"], "coverage": {"model": [], "view": [], "controller": ["c_work_graph_execute"], "process": ["p_work_graph_execution", "p_usage_limit_wait"], "requirement": ["REQ-07", "REQ-14"]}, "done": "REQ-07 全部通过;REQ-14 happy 用例通过"}, | 27 | {"id": "U-012", "phase": "PH1", "sub_phase": "PH1-S2", "name": "c_work_graph_execute 完整实现", "classification": "Value Path", "depends_on": ["U-006", "U-011"], "coverage": {"model": [], "view": [], "controller": ["c_work_graph_execute"], "process": ["p_work_graph_execution", "p_usage_limit_wait"], "requirement": ["REQ-07", "REQ-14"]}, "done": "REQ-07 全部通过;REQ-14 happy 用例通过"}, |
| 28 | {"id": "U-013", "phase": "PH1", "sub_phase": "PH1-S2", "name": "c_work_node_run + git worktree 隔离", "classification": "Value Path", "depends_on": ["U-012"], "coverage": {"model": ["work_node_execution"], "view": [], "controller": ["c_work_node_run"], "process": [], "requirement": ["REQ-03"]}, "done": "REQ-03 happy/boundary 通过"}, | 28 | {"id": "U-013", "phase": "PH1", "sub_phase": "PH1-S2", "name": "c_work_node_run + git worktree 隔离", "classification": "Value Path", "depends_on": ["U-012"], "coverage": {"model": ["work_node_execution"], "view": [], "controller": ["c_work_node_run"], "process": [], "requirement": ["REQ-03"]}, "done": "REQ-03 happy/boundary 通过"}, |
src/org_node_lifecycle.py
0 → 100644
| 1 | +"""c_org_node_trigger (03-C §3.3): full implementation. | ||
| 2 | + | ||
| 3 | +U-010 scope: per 05 §5.1 4.4's staged rollout (05-实施约束与依赖.md §5.5: | ||
| 4 | +"手工调用验证 Planner/Coder/Validator 三个 Org Agent 分别可被触发" -- NOT | ||
| 5 | +architecture doc §11, which doesn't contain that phrase; development-plan.md's | ||
| 6 | +citation was wrong and is corrected in this session's notes), this drives the | ||
| 7 | +three DIRECT forward transitions in `p_org_task_lifecycle` (04-P) that a single | ||
| 8 | +`c_org_node_trigger` call causes: | ||
| 9 | + | ||
| 10 | + queued -> planning_brd_prd_sdd (trigger planner) | ||
| 11 | + planning_brd_prd_sdd -> coding (trigger coder) | ||
| 12 | + coding -> validating (trigger validator) | ||
| 13 | + | ||
| 14 | +with TASK_NOT_READY / CONCURRENCY_LIMIT_REACHED / USAGE_LIMIT_REACHED guards. | ||
| 15 | + | ||
| 16 | +Everything past that -- planning_failed/coding_failed retries, the | ||
| 17 | +validator_pass/validator_fail decision and bug_record-driven routing, | ||
| 18 | +blocked_pending_human -- needs real Work Graph artifact production (U-011+) | ||
| 19 | +or Org-level Loop routing (U-015/U-016, PH2) that doesn't exist yet, and is | ||
| 20 | +not attempted here. The artifact-validation guards below are real, testable | ||
| 21 | +logic even though nothing produces passing artifacts for real yet -- tests | ||
| 22 | +seed the precondition rows directly, same as any consumer of an | ||
| 23 | +upstream contract that hasn't been built. | ||
| 24 | +""" | ||
| 25 | + | ||
| 26 | +import json | ||
| 27 | +import uuid | ||
| 28 | + | ||
| 29 | +from src.org_node_trigger import OrgNodeTriggerError, trigger_org_node | ||
| 30 | + | ||
| 31 | +_PRE_STATE = {"planner": "queued", "coder": "planning_brd_prd_sdd", "validator": "coding"} | ||
| 32 | +_POST_STATE = {"planner": "planning_brd_prd_sdd", "coder": "coding", "validator": "validating"} | ||
| 33 | +_REQUIRED_ARTIFACTS = { | ||
| 34 | + "coder": ("brd", "prd", "sdd", "benchmark"), | ||
| 35 | + "validator": ("code_diff", "test_report"), | ||
| 36 | +} | ||
| 37 | +# REQ-14 happy case cites "You've hit your session limit" as the real signal | ||
| 38 | +# text; weekly/generic phrasing added defensively. Best-effort until we've | ||
| 39 | +# observed the real error text from an actual exhausted-quota call (U-020). | ||
| 40 | +_USAGE_LIMIT_PHRASES = ("hit your session limit", "hit your weekly limit", "usage limit") | ||
| 41 | + | ||
| 42 | + | ||
| 43 | +class TaskNotReadyError(Exception): | ||
| 44 | + """TASK_NOT_READY -- 03-C §3.3.""" | ||
| 45 | + | ||
| 46 | + | ||
| 47 | +class ConcurrencyLimitReachedError(Exception): | ||
| 48 | + """CONCURRENCY_LIMIT_REACHED -- 03-C §3.3.""" | ||
| 49 | + | ||
| 50 | + | ||
| 51 | +class UsageLimitReachedError(Exception): | ||
| 52 | + """USAGE_LIMIT_REACHED -- 03-C §3.3 / 04-P p_usage_limit_wait. Detection | ||
| 53 | + only here; the wait-and-retry loop itself is U-012/U-020's job.""" | ||
| 54 | + | ||
| 55 | + | ||
| 56 | +def _get_task(conn, task_id): | ||
| 57 | + row = conn.execute("SELECT status, project_id FROM task WHERE task_id = ?", (task_id,)).fetchone() | ||
| 58 | + if row is None: | ||
| 59 | + raise TaskNotReadyError("no such task: " + task_id) | ||
| 60 | + return {"status": row[0], "project_id": row[1]} | ||
| 61 | + | ||
| 62 | + | ||
| 63 | +def _artifacts_ready(conn, task_id, artifact_types): | ||
| 64 | + for artifact_type in artifact_types: | ||
| 65 | + row = conn.execute( | ||
| 66 | + "SELECT 1 FROM artifact WHERE task_id = ? AND artifact_type = ? AND validation_status = 'passed'", | ||
| 67 | + (task_id, artifact_type), | ||
| 68 | + ).fetchone() | ||
| 69 | + if row is None: | ||
| 70 | + return False | ||
| 71 | + return True | ||
| 72 | + | ||
| 73 | + | ||
| 74 | +def _check_concurrency(conn): | ||
| 75 | + row = conn.execute( | ||
| 76 | + "SELECT max_concurrent_goals FROM org_concurrency_config WHERE config_id = 'default'" | ||
| 77 | + ).fetchone() | ||
| 78 | + limit = row[0] if row else 1 # 01-M 1.11 default | ||
| 79 | + active = conn.execute( | ||
| 80 | + "SELECT COUNT(*) FROM task WHERE status NOT IN ('queued', 'done', 'blocked_pending_human')" | ||
| 81 | + ).fetchone()[0] | ||
| 82 | + if active >= limit: | ||
| 83 | + raise ConcurrencyLimitReachedError( | ||
| 84 | + "max_concurrent_goals={} reached ({} active)".format(limit, active) | ||
| 85 | + ) | ||
| 86 | + | ||
| 87 | + | ||
| 88 | +def _looks_like_usage_limit(message): | ||
| 89 | + lowered = message.lower() | ||
| 90 | + return any(phrase in lowered for phrase in _USAGE_LIMIT_PHRASES) | ||
| 91 | + | ||
| 92 | + | ||
| 93 | +def trigger_org_node_full(conn, org_node, task_id, target_folder_path, prompt, timeout=300): | ||
| 94 | + """Full c_org_node_trigger: guards + real subprocess call (src.org_node_trigger) | ||
| 95 | + + task.status transition, all in one call. Returns the updated | ||
| 96 | + {"task_id", "status", "assigned_org_node"}. | ||
| 97 | + """ | ||
| 98 | + if org_node not in _PRE_STATE: | ||
| 99 | + raise ValueError(org_node) | ||
| 100 | + | ||
| 101 | + task = _get_task(conn, task_id) | ||
| 102 | + if task["status"] != _PRE_STATE[org_node]: | ||
| 103 | + raise TaskNotReadyError( | ||
| 104 | + "task {} is in status={}, not ready for {} (needs {})".format( | ||
| 105 | + task_id, task["status"], org_node, _PRE_STATE[org_node] | ||
| 106 | + ) | ||
| 107 | + ) | ||
| 108 | + if org_node in _REQUIRED_ARTIFACTS and not _artifacts_ready(conn, task_id, _REQUIRED_ARTIFACTS[org_node]): | ||
| 109 | + raise TaskNotReadyError("required artifacts not validation_status=passed yet for task " + task_id) | ||
| 110 | + if org_node == "planner": | ||
| 111 | + _check_concurrency(conn) | ||
| 112 | + | ||
| 113 | + try: | ||
| 114 | + trigger_org_node(org_node, prompt, target_folder_path, timeout=timeout) | ||
| 115 | + except OrgNodeTriggerError as e: | ||
| 116 | + if _looks_like_usage_limit(str(e)): | ||
| 117 | + raise UsageLimitReachedError(str(e)) from e | ||
| 118 | + raise | ||
| 119 | + | ||
| 120 | + old_status = task["status"] | ||
| 121 | + new_status = _POST_STATE[org_node] | ||
| 122 | + conn.execute( | ||
| 123 | + "UPDATE task SET status = ?, assigned_org_node = ?, updated_at = CURRENT_TIMESTAMP WHERE task_id = ?", | ||
| 124 | + (new_status, org_node, task_id), | ||
| 125 | + ) | ||
| 126 | + conn.execute( | ||
| 127 | + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " | ||
| 128 | + "VALUES (?, ?, ?, 'task_status_changed', ?)", | ||
| 129 | + ( | ||
| 130 | + str(uuid.uuid4()), | ||
| 131 | + task["project_id"], | ||
| 132 | + task_id, | ||
| 133 | + json.dumps({"from_status": old_status, "to_status": new_status}), | ||
| 134 | + ), | ||
| 135 | + ) | ||
| 136 | + conn.commit() | ||
| 137 | + return {"task_id": task_id, "status": new_status, "assigned_org_node": org_node} |
src/test_org_node_lifecycle.py
0 → 100644
| 1 | +"""U-010 tests: c_org_node_trigger's full guard + transition logic, mocked at | ||
| 2 | +the subprocess boundary (src.org_node_trigger.trigger_org_node) per CLAUDE.md's | ||
| 3 | +Testing constraint. Real chained-trigger coverage (all three Org Agents, | ||
| 4 | +actually invoked) is in src/test_org_node_lifecycle_e2e.py (RUN_REAL_AGENT_E2E=1). | ||
| 5 | + | ||
| 6 | +Run: python3 -m unittest src.test_org_node_lifecycle -v | ||
| 7 | +""" | ||
| 8 | + | ||
| 9 | +import unittest | ||
| 10 | +from unittest.mock import patch | ||
| 11 | + | ||
| 12 | +from src import org_node_lifecycle, schema, task_create | ||
| 13 | + | ||
| 14 | + | ||
| 15 | +class LifecycleTestBase(unittest.TestCase): | ||
| 16 | + def setUp(self): | ||
| 17 | + self.conn = schema.connect(":memory:") | ||
| 18 | + schema.create_schema(self.conn) | ||
| 19 | + self.conn.execute( | ||
| 20 | + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " | ||
| 21 | + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" | ||
| 22 | + ) | ||
| 23 | + self.conn.commit() | ||
| 24 | + self.task_id = task_create.create_task(self.conn, "proj-1", "实现贪吃蛇游戏")["task_id"] | ||
| 25 | + | ||
| 26 | + def tearDown(self): | ||
| 27 | + self.conn.close() | ||
| 28 | + | ||
| 29 | + def _pass_artifact(self, artifact_type, run_id="run-1"): | ||
| 30 | + self.conn.execute( | ||
| 31 | + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) " | ||
| 32 | + "VALUES (?, ?, 'planner', '{}', 'done') ON CONFLICT(run_id) DO NOTHING", | ||
| 33 | + (run_id, self.task_id), | ||
| 34 | + ) | ||
| 35 | + self.conn.execute( | ||
| 36 | + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, " | ||
| 37 | + "file_path, git_commit_sha, validation_status) VALUES (?, ?, ?, ?, 'f.md', 'sha', 'passed')", | ||
| 38 | + (artifact_type + "-artifact", artifact_type, self.task_id, run_id), | ||
| 39 | + ) | ||
| 40 | + self.conn.commit() | ||
| 41 | + | ||
| 42 | + | ||
| 43 | +class PlannerTriggerTest(LifecycleTestBase): | ||
| 44 | + def test_success_transitions_queued_to_planning(self): | ||
| 45 | + with patch("src.org_node_lifecycle.trigger_org_node", return_value="ok"): | ||
| 46 | + result = org_node_lifecycle.trigger_org_node_full( | ||
| 47 | + self.conn, "planner", self.task_id, "/tmp/proj-1", "go" | ||
| 48 | + ) | ||
| 49 | + self.assertEqual(result["status"], "planning_brd_prd_sdd") | ||
| 50 | + self.assertEqual(result["assigned_org_node"], "planner") | ||
| 51 | + row = self.conn.execute("SELECT status FROM task WHERE task_id = ?", (self.task_id,)).fetchone() | ||
| 52 | + self.assertEqual(row[0], "planning_brd_prd_sdd") | ||
| 53 | + | ||
| 54 | + def test_writes_task_status_changed_event(self): | ||
| 55 | + with patch("src.org_node_lifecycle.trigger_org_node", return_value="ok"): | ||
| 56 | + org_node_lifecycle.trigger_org_node_full(self.conn, "planner", self.task_id, "/tmp/proj-1", "go") | ||
| 57 | + event = self.conn.execute( | ||
| 58 | + "SELECT event_type FROM execution_event WHERE task_id = ? ORDER BY created_at DESC LIMIT 1", | ||
| 59 | + (self.task_id,), | ||
| 60 | + ).fetchone() | ||
| 61 | + self.assertEqual(event[0], "task_status_changed") | ||
| 62 | + | ||
| 63 | + def test_wrong_status_raises_task_not_ready(self): | ||
| 64 | + self.conn.execute("UPDATE task SET status = 'coding' WHERE task_id = ?", (self.task_id,)) | ||
| 65 | + self.conn.commit() | ||
| 66 | + with self.assertRaises(org_node_lifecycle.TaskNotReadyError): | ||
| 67 | + org_node_lifecycle.trigger_org_node_full(self.conn, "planner", self.task_id, "/tmp/proj-1", "go") | ||
| 68 | + | ||
| 69 | + def test_concurrency_limit_reached_blocks_trigger(self): | ||
| 70 | + self.conn.execute( | ||
| 71 | + "INSERT INTO org_concurrency_config (config_id, max_concurrent_goals, updated_by) " | ||
| 72 | + "VALUES ('default', 1, 'test')" | ||
| 73 | + ) | ||
| 74 | + other_task = task_create.create_task(self.conn, "proj-1", "other goal")["task_id"] | ||
| 75 | + self.conn.execute("UPDATE task SET status = 'coding' WHERE task_id = ?", (other_task,)) | ||
| 76 | + self.conn.commit() | ||
| 77 | + | ||
| 78 | + with self.assertRaises(org_node_lifecycle.ConcurrencyLimitReachedError): | ||
| 79 | + org_node_lifecycle.trigger_org_node_full(self.conn, "planner", self.task_id, "/tmp/proj-1", "go") | ||
| 80 | + | ||
| 81 | + def test_unknown_org_node_raises_value_error(self): | ||
| 82 | + with self.assertRaises(ValueError): | ||
| 83 | + org_node_lifecycle.trigger_org_node_full(self.conn, "not-a-node", self.task_id, "/tmp/proj-1", "go") | ||
| 84 | + | ||
| 85 | + def test_usage_limit_phrase_maps_to_usage_limit_reached(self): | ||
| 86 | + from src.org_node_trigger import OrgNodeTriggerError | ||
| 87 | + | ||
| 88 | + with patch( | ||
| 89 | + "src.org_node_lifecycle.trigger_org_node", | ||
| 90 | + side_effect=OrgNodeTriggerError("claude --agent planner: You've hit your session limit"), | ||
| 91 | + ): | ||
| 92 | + with self.assertRaises(org_node_lifecycle.UsageLimitReachedError): | ||
| 93 | + org_node_lifecycle.trigger_org_node_full(self.conn, "planner", self.task_id, "/tmp/proj-1", "go") | ||
| 94 | + | ||
| 95 | + def test_other_trigger_errors_are_not_reclassified(self): | ||
| 96 | + from src.org_node_trigger import OrgNodeTriggerError | ||
| 97 | + | ||
| 98 | + with patch( | ||
| 99 | + "src.org_node_lifecycle.trigger_org_node", side_effect=OrgNodeTriggerError("some other failure") | ||
| 100 | + ): | ||
| 101 | + with self.assertRaises(OrgNodeTriggerError): | ||
| 102 | + org_node_lifecycle.trigger_org_node_full(self.conn, "planner", self.task_id, "/tmp/proj-1", "go") | ||
| 103 | + | ||
| 104 | + | ||
| 105 | +class CoderTriggerTest(LifecycleTestBase): | ||
| 106 | + def setUp(self): | ||
| 107 | + super().setUp() | ||
| 108 | + self.conn.execute("UPDATE task SET status = 'planning_brd_prd_sdd' WHERE task_id = ?", (self.task_id,)) | ||
| 109 | + self.conn.commit() | ||
| 110 | + | ||
| 111 | + def test_blocked_until_all_four_artifacts_pass(self): | ||
| 112 | + with self.assertRaises(org_node_lifecycle.TaskNotReadyError): | ||
| 113 | + org_node_lifecycle.trigger_org_node_full(self.conn, "coder", self.task_id, "/tmp/proj-1", "go") | ||
| 114 | + | ||
| 115 | + for artifact_type in ("brd", "prd", "sdd"): # missing benchmark on purpose | ||
| 116 | + self._pass_artifact(artifact_type) | ||
| 117 | + with self.assertRaises(org_node_lifecycle.TaskNotReadyError): | ||
| 118 | + org_node_lifecycle.trigger_org_node_full(self.conn, "coder", self.task_id, "/tmp/proj-1", "go") | ||
| 119 | + | ||
| 120 | + def test_succeeds_once_all_four_artifacts_pass(self): | ||
| 121 | + for artifact_type in ("brd", "prd", "sdd", "benchmark"): | ||
| 122 | + self._pass_artifact(artifact_type) | ||
| 123 | + with patch("src.org_node_lifecycle.trigger_org_node", return_value="ok"): | ||
| 124 | + result = org_node_lifecycle.trigger_org_node_full( | ||
| 125 | + self.conn, "coder", self.task_id, "/tmp/proj-1", "go" | ||
| 126 | + ) | ||
| 127 | + self.assertEqual(result["status"], "coding") | ||
| 128 | + self.assertEqual(result["assigned_org_node"], "coder") | ||
| 129 | + | ||
| 130 | + | ||
| 131 | +class ValidatorTriggerTest(LifecycleTestBase): | ||
| 132 | + def setUp(self): | ||
| 133 | + super().setUp() | ||
| 134 | + self.conn.execute("UPDATE task SET status = 'coding' WHERE task_id = ?", (self.task_id,)) | ||
| 135 | + self.conn.commit() | ||
| 136 | + | ||
| 137 | + def test_blocked_until_code_diff_and_test_report_pass(self): | ||
| 138 | + with self.assertRaises(org_node_lifecycle.TaskNotReadyError): | ||
| 139 | + org_node_lifecycle.trigger_org_node_full(self.conn, "validator", self.task_id, "/tmp/proj-1", "go") | ||
| 140 | + | ||
| 141 | + def test_succeeds_once_both_artifacts_pass(self): | ||
| 142 | + self._pass_artifact("code_diff") | ||
| 143 | + self._pass_artifact("test_report") | ||
| 144 | + with patch("src.org_node_lifecycle.trigger_org_node", return_value="ok"): | ||
| 145 | + result = org_node_lifecycle.trigger_org_node_full( | ||
| 146 | + self.conn, "validator", self.task_id, "/tmp/proj-1", "go" | ||
| 147 | + ) | ||
| 148 | + self.assertEqual(result["status"], "validating") | ||
| 149 | + self.assertEqual(result["assigned_org_node"], "validator") | ||
| 150 | + | ||
| 151 | + | ||
| 152 | +if __name__ == "__main__": | ||
| 153 | + unittest.main() |
src/test_org_node_lifecycle_e2e.py
0 → 100644
| 1 | +"""U-010's real-call coverage: "手工依次触发 planner/coder/validator 三个 Org | ||
| 2 | +Agent" (05 §5.5) with actual `claude --agent <node> -p` calls for all three, | ||
| 3 | +not just planner (which U-004 already validated). Artifact-validation | ||
| 4 | +preconditions are seeded directly -- no real Work Graph exists yet to | ||
| 5 | +produce them (U-011+), same as the mocked tests. Gated behind | ||
| 6 | +RUN_REAL_AGENT_E2E=1 (CLAUDE.md Testing constraint). | ||
| 7 | + | ||
| 8 | +Run: RUN_REAL_AGENT_E2E=1 python3 -m unittest src.test_org_node_lifecycle_e2e -v | ||
| 9 | +""" | ||
| 10 | + | ||
| 11 | +import subprocess | ||
| 12 | +import tempfile | ||
| 13 | +import unittest | ||
| 14 | +import uuid | ||
| 15 | + | ||
| 16 | +from src import agent_profile, org_node_lifecycle, project_scaffold, schema, task_create | ||
| 17 | +from src.test_support import skip_unless_env | ||
| 18 | + | ||
| 19 | +_PROMPT = "In one short sentence, confirm you're ready and state your role. Take no other action." | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +@skip_unless_env("RUN_REAL_AGENT_E2E") | ||
| 23 | +class RealChainedTriggerTest(unittest.TestCase): | ||
| 24 | + def test_all_three_org_agents_trigger_for_real_and_status_advances_each_time(self): | ||
| 25 | + with tempfile.TemporaryDirectory() as tmp: | ||
| 26 | + subprocess.run(["git", "init"], cwd=tmp, check=True, capture_output=True) | ||
| 27 | + conn = schema.connect(":memory:") | ||
| 28 | + schema.create_schema(conn) | ||
| 29 | + project_scaffold.create_project(conn, tmp, init_git_if_missing=False) | ||
| 30 | + agent_profile.deploy_agent_definitions(tmp) | ||
| 31 | + task_id = task_create.create_task(conn, self._project_id(conn), "实现贪吃蛇游戏")["task_id"] | ||
| 32 | + | ||
| 33 | + planner_result = org_node_lifecycle.trigger_org_node_full(conn, "planner", task_id, tmp, _PROMPT) | ||
| 34 | + self.assertEqual(planner_result["status"], "planning_brd_prd_sdd") | ||
| 35 | + | ||
| 36 | + self._seed_passing_artifacts(conn, task_id, ("brd", "prd", "sdd", "benchmark")) | ||
| 37 | + coder_result = org_node_lifecycle.trigger_org_node_full(conn, "coder", task_id, tmp, _PROMPT) | ||
| 38 | + self.assertEqual(coder_result["status"], "coding") | ||
| 39 | + | ||
| 40 | + self._seed_passing_artifacts(conn, task_id, ("code_diff", "test_report")) | ||
| 41 | + validator_result = org_node_lifecycle.trigger_org_node_full(conn, "validator", task_id, tmp, _PROMPT) | ||
| 42 | + self.assertEqual(validator_result["status"], "validating") | ||
| 43 | + | ||
| 44 | + @staticmethod | ||
| 45 | + def _project_id(conn): | ||
| 46 | + return conn.execute("SELECT project_id FROM project").fetchone()[0] | ||
| 47 | + | ||
| 48 | + @staticmethod | ||
| 49 | + def _seed_passing_artifacts(conn, task_id, artifact_types): | ||
| 50 | + run_id = str(uuid.uuid4()) | ||
| 51 | + conn.execute( | ||
| 52 | + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) " | ||
| 53 | + "VALUES (?, ?, 'planner', '{}', 'done')", | ||
| 54 | + (run_id, task_id), | ||
| 55 | + ) | ||
| 56 | + for artifact_type in artifact_types: | ||
| 57 | + conn.execute( | ||
| 58 | + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, " | ||
| 59 | + "file_path, git_commit_sha, validation_status) VALUES (?, ?, ?, ?, 'f.md', ?, 'passed')", | ||
| 60 | + (str(uuid.uuid4()), artifact_type, task_id, run_id, artifact_type), | ||
| 61 | + ) | ||
| 62 | + conn.commit() | ||
| 63 | + | ||
| 64 | + | ||
| 65 | +if __name__ == "__main__": | ||
| 66 | + unittest.main() |