diff --git a/docs/development-plan.md b/docs/development-plan.md index fdc28da..be0c3b5 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -110,7 +110,8 @@ - **U-010 [Value Path]** `c_org_node_trigger` 完整实现(含 `TASK_NOT_READY`/`CONCURRENCY_LIMIT_REACHED`/`USAGE_LIMIT_REACHED` 错误处理) - 依赖:U-005, U-009 - - Done:手工依次触发 planner/coder/validator 三个 Org Agent,`task.status` 按状态机正确流转(对应架构文档 §11"手工调用验证"里程碑) + - 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 层集成"之后的事,此处不做) + - **范围说明(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 数据进去的,不是跑出来的,这是意料之中的(上游契约还没实现,消费方先按契约测) - 覆盖:03-C `c_org_node_trigger` ### PH1-S2 Work Graph 层集成(本项目风险最高的 Sub-phase) diff --git a/docs/sdd/unit_matrix.json b/docs/sdd/unit_matrix.json index e7d7a95..6fa2115 100644 --- a/docs/sdd/unit_matrix.json +++ b/docs/sdd/unit_matrix.json @@ -22,7 +22,7 @@ {"id": "U-008", "phase": "PH0", "sub_phase": "PH0-S3", "name": "前端骨架", "classification": "Enabler", "depends_on": ["U-007"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "本地 web 应用路由跳转正常"}, {"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 订正)"}, - {"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 按状态机正确流转"}, + {"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 范围"}, {"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 用例通过"}, {"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 用例通过"}, {"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 通过"}, diff --git a/src/org_node_lifecycle.py b/src/org_node_lifecycle.py new file mode 100644 index 0000000..92c10e5 --- /dev/null +++ b/src/org_node_lifecycle.py @@ -0,0 +1,137 @@ +"""c_org_node_trigger (03-C §3.3): full implementation. + +U-010 scope: per 05 §5.1 4.4's staged rollout (05-实施约束与依赖.md §5.5: +"手工调用验证 Planner/Coder/Validator 三个 Org Agent 分别可被触发" -- NOT +architecture doc §11, which doesn't contain that phrase; development-plan.md's +citation was wrong and is corrected in this session's notes), this drives the +three DIRECT forward transitions in `p_org_task_lifecycle` (04-P) that a single +`c_org_node_trigger` call causes: + + queued -> planning_brd_prd_sdd (trigger planner) + planning_brd_prd_sdd -> coding (trigger coder) + coding -> validating (trigger validator) + +with TASK_NOT_READY / CONCURRENCY_LIMIT_REACHED / USAGE_LIMIT_REACHED guards. + +Everything past that -- planning_failed/coding_failed retries, the +validator_pass/validator_fail decision and bug_record-driven routing, +blocked_pending_human -- needs real Work Graph artifact production (U-011+) +or Org-level Loop routing (U-015/U-016, PH2) that doesn't exist yet, and is +not attempted here. The artifact-validation guards below are real, testable +logic even though nothing produces passing artifacts for real yet -- tests +seed the precondition rows directly, same as any consumer of an +upstream contract that hasn't been built. +""" + +import json +import uuid + +from src.org_node_trigger import OrgNodeTriggerError, trigger_org_node + +_PRE_STATE = {"planner": "queued", "coder": "planning_brd_prd_sdd", "validator": "coding"} +_POST_STATE = {"planner": "planning_brd_prd_sdd", "coder": "coding", "validator": "validating"} +_REQUIRED_ARTIFACTS = { + "coder": ("brd", "prd", "sdd", "benchmark"), + "validator": ("code_diff", "test_report"), +} +# REQ-14 happy case cites "You've hit your session limit" as the real signal +# text; weekly/generic phrasing added defensively. Best-effort until we've +# observed the real error text from an actual exhausted-quota call (U-020). +_USAGE_LIMIT_PHRASES = ("hit your session limit", "hit your weekly limit", "usage limit") + + +class TaskNotReadyError(Exception): + """TASK_NOT_READY -- 03-C §3.3.""" + + +class ConcurrencyLimitReachedError(Exception): + """CONCURRENCY_LIMIT_REACHED -- 03-C §3.3.""" + + +class UsageLimitReachedError(Exception): + """USAGE_LIMIT_REACHED -- 03-C §3.3 / 04-P p_usage_limit_wait. Detection + only here; the wait-and-retry loop itself is U-012/U-020's job.""" + + +def _get_task(conn, task_id): + row = conn.execute("SELECT status, project_id FROM task WHERE task_id = ?", (task_id,)).fetchone() + if row is None: + raise TaskNotReadyError("no such task: " + task_id) + return {"status": row[0], "project_id": row[1]} + + +def _artifacts_ready(conn, task_id, artifact_types): + for artifact_type in artifact_types: + row = conn.execute( + "SELECT 1 FROM artifact WHERE task_id = ? AND artifact_type = ? AND validation_status = 'passed'", + (task_id, artifact_type), + ).fetchone() + if row is None: + return False + return True + + +def _check_concurrency(conn): + row = conn.execute( + "SELECT max_concurrent_goals FROM org_concurrency_config WHERE config_id = 'default'" + ).fetchone() + limit = row[0] if row else 1 # 01-M 1.11 default + active = conn.execute( + "SELECT COUNT(*) FROM task WHERE status NOT IN ('queued', 'done', 'blocked_pending_human')" + ).fetchone()[0] + if active >= limit: + raise ConcurrencyLimitReachedError( + "max_concurrent_goals={} reached ({} active)".format(limit, active) + ) + + +def _looks_like_usage_limit(message): + lowered = message.lower() + return any(phrase in lowered for phrase in _USAGE_LIMIT_PHRASES) + + +def trigger_org_node_full(conn, org_node, task_id, target_folder_path, prompt, timeout=300): + """Full c_org_node_trigger: guards + real subprocess call (src.org_node_trigger) + + task.status transition, all in one call. Returns the updated + {"task_id", "status", "assigned_org_node"}. + """ + if org_node not in _PRE_STATE: + raise ValueError(org_node) + + task = _get_task(conn, task_id) + if task["status"] != _PRE_STATE[org_node]: + raise TaskNotReadyError( + "task {} is in status={}, not ready for {} (needs {})".format( + task_id, task["status"], org_node, _PRE_STATE[org_node] + ) + ) + if org_node in _REQUIRED_ARTIFACTS and not _artifacts_ready(conn, task_id, _REQUIRED_ARTIFACTS[org_node]): + raise TaskNotReadyError("required artifacts not validation_status=passed yet for task " + task_id) + if org_node == "planner": + _check_concurrency(conn) + + try: + trigger_org_node(org_node, prompt, target_folder_path, timeout=timeout) + except OrgNodeTriggerError as e: + if _looks_like_usage_limit(str(e)): + raise UsageLimitReachedError(str(e)) from e + raise + + old_status = task["status"] + new_status = _POST_STATE[org_node] + conn.execute( + "UPDATE task SET status = ?, assigned_org_node = ?, updated_at = CURRENT_TIMESTAMP WHERE task_id = ?", + (new_status, org_node, task_id), + ) + conn.execute( + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " + "VALUES (?, ?, ?, 'task_status_changed', ?)", + ( + str(uuid.uuid4()), + task["project_id"], + task_id, + json.dumps({"from_status": old_status, "to_status": new_status}), + ), + ) + conn.commit() + return {"task_id": task_id, "status": new_status, "assigned_org_node": org_node} diff --git a/src/test_org_node_lifecycle.py b/src/test_org_node_lifecycle.py new file mode 100644 index 0000000..3ec05a6 --- /dev/null +++ b/src/test_org_node_lifecycle.py @@ -0,0 +1,153 @@ +"""U-010 tests: c_org_node_trigger's full guard + transition logic, mocked at +the subprocess boundary (src.org_node_trigger.trigger_org_node) per CLAUDE.md's +Testing constraint. Real chained-trigger coverage (all three Org Agents, +actually invoked) is in src/test_org_node_lifecycle_e2e.py (RUN_REAL_AGENT_E2E=1). + +Run: python3 -m unittest src.test_org_node_lifecycle -v +""" + +import unittest +from unittest.mock import patch + +from src import org_node_lifecycle, schema, task_create + + +class LifecycleTestBase(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() + self.task_id = task_create.create_task(self.conn, "proj-1", "实现贪吃蛇游戏")["task_id"] + + def tearDown(self): + self.conn.close() + + def _pass_artifact(self, artifact_type, run_id="run-1"): + self.conn.execute( + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) " + "VALUES (?, ?, 'planner', '{}', 'done') ON CONFLICT(run_id) DO NOTHING", + (run_id, self.task_id), + ) + self.conn.execute( + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, " + "file_path, git_commit_sha, validation_status) VALUES (?, ?, ?, ?, 'f.md', 'sha', 'passed')", + (artifact_type + "-artifact", artifact_type, self.task_id, run_id), + ) + self.conn.commit() + + +class PlannerTriggerTest(LifecycleTestBase): + def test_success_transitions_queued_to_planning(self): + with patch("src.org_node_lifecycle.trigger_org_node", return_value="ok"): + result = org_node_lifecycle.trigger_org_node_full( + self.conn, "planner", self.task_id, "/tmp/proj-1", "go" + ) + self.assertEqual(result["status"], "planning_brd_prd_sdd") + self.assertEqual(result["assigned_org_node"], "planner") + row = self.conn.execute("SELECT status FROM task WHERE task_id = ?", (self.task_id,)).fetchone() + self.assertEqual(row[0], "planning_brd_prd_sdd") + + def test_writes_task_status_changed_event(self): + with patch("src.org_node_lifecycle.trigger_org_node", return_value="ok"): + org_node_lifecycle.trigger_org_node_full(self.conn, "planner", self.task_id, "/tmp/proj-1", "go") + event = self.conn.execute( + "SELECT event_type FROM execution_event WHERE task_id = ? ORDER BY created_at DESC LIMIT 1", + (self.task_id,), + ).fetchone() + self.assertEqual(event[0], "task_status_changed") + + def test_wrong_status_raises_task_not_ready(self): + self.conn.execute("UPDATE task SET status = 'coding' WHERE task_id = ?", (self.task_id,)) + self.conn.commit() + with self.assertRaises(org_node_lifecycle.TaskNotReadyError): + org_node_lifecycle.trigger_org_node_full(self.conn, "planner", self.task_id, "/tmp/proj-1", "go") + + def test_concurrency_limit_reached_blocks_trigger(self): + self.conn.execute( + "INSERT INTO org_concurrency_config (config_id, max_concurrent_goals, updated_by) " + "VALUES ('default', 1, 'test')" + ) + other_task = task_create.create_task(self.conn, "proj-1", "other goal")["task_id"] + self.conn.execute("UPDATE task SET status = 'coding' WHERE task_id = ?", (other_task,)) + self.conn.commit() + + with self.assertRaises(org_node_lifecycle.ConcurrencyLimitReachedError): + org_node_lifecycle.trigger_org_node_full(self.conn, "planner", self.task_id, "/tmp/proj-1", "go") + + def test_unknown_org_node_raises_value_error(self): + with self.assertRaises(ValueError): + org_node_lifecycle.trigger_org_node_full(self.conn, "not-a-node", self.task_id, "/tmp/proj-1", "go") + + def test_usage_limit_phrase_maps_to_usage_limit_reached(self): + from src.org_node_trigger import OrgNodeTriggerError + + with patch( + "src.org_node_lifecycle.trigger_org_node", + side_effect=OrgNodeTriggerError("claude --agent planner: You've hit your session limit"), + ): + with self.assertRaises(org_node_lifecycle.UsageLimitReachedError): + org_node_lifecycle.trigger_org_node_full(self.conn, "planner", self.task_id, "/tmp/proj-1", "go") + + def test_other_trigger_errors_are_not_reclassified(self): + from src.org_node_trigger import OrgNodeTriggerError + + with patch( + "src.org_node_lifecycle.trigger_org_node", side_effect=OrgNodeTriggerError("some other failure") + ): + with self.assertRaises(OrgNodeTriggerError): + org_node_lifecycle.trigger_org_node_full(self.conn, "planner", self.task_id, "/tmp/proj-1", "go") + + +class CoderTriggerTest(LifecycleTestBase): + def setUp(self): + super().setUp() + self.conn.execute("UPDATE task SET status = 'planning_brd_prd_sdd' WHERE task_id = ?", (self.task_id,)) + self.conn.commit() + + def test_blocked_until_all_four_artifacts_pass(self): + with self.assertRaises(org_node_lifecycle.TaskNotReadyError): + org_node_lifecycle.trigger_org_node_full(self.conn, "coder", self.task_id, "/tmp/proj-1", "go") + + for artifact_type in ("brd", "prd", "sdd"): # missing benchmark on purpose + self._pass_artifact(artifact_type) + with self.assertRaises(org_node_lifecycle.TaskNotReadyError): + org_node_lifecycle.trigger_org_node_full(self.conn, "coder", self.task_id, "/tmp/proj-1", "go") + + def test_succeeds_once_all_four_artifacts_pass(self): + for artifact_type in ("brd", "prd", "sdd", "benchmark"): + self._pass_artifact(artifact_type) + with patch("src.org_node_lifecycle.trigger_org_node", return_value="ok"): + result = org_node_lifecycle.trigger_org_node_full( + self.conn, "coder", self.task_id, "/tmp/proj-1", "go" + ) + self.assertEqual(result["status"], "coding") + self.assertEqual(result["assigned_org_node"], "coder") + + +class ValidatorTriggerTest(LifecycleTestBase): + def setUp(self): + super().setUp() + self.conn.execute("UPDATE task SET status = 'coding' WHERE task_id = ?", (self.task_id,)) + self.conn.commit() + + def test_blocked_until_code_diff_and_test_report_pass(self): + with self.assertRaises(org_node_lifecycle.TaskNotReadyError): + org_node_lifecycle.trigger_org_node_full(self.conn, "validator", self.task_id, "/tmp/proj-1", "go") + + def test_succeeds_once_both_artifacts_pass(self): + self._pass_artifact("code_diff") + self._pass_artifact("test_report") + with patch("src.org_node_lifecycle.trigger_org_node", return_value="ok"): + result = org_node_lifecycle.trigger_org_node_full( + self.conn, "validator", self.task_id, "/tmp/proj-1", "go" + ) + self.assertEqual(result["status"], "validating") + self.assertEqual(result["assigned_org_node"], "validator") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/test_org_node_lifecycle_e2e.py b/src/test_org_node_lifecycle_e2e.py new file mode 100644 index 0000000..d323a4c --- /dev/null +++ b/src/test_org_node_lifecycle_e2e.py @@ -0,0 +1,66 @@ +"""U-010's real-call coverage: "手工依次触发 planner/coder/validator 三个 Org +Agent" (05 §5.5) with actual `claude --agent -p` calls for all three, +not just planner (which U-004 already validated). Artifact-validation +preconditions are seeded directly -- no real Work Graph exists yet to +produce them (U-011+), same as the mocked tests. Gated behind +RUN_REAL_AGENT_E2E=1 (CLAUDE.md Testing constraint). + +Run: RUN_REAL_AGENT_E2E=1 python3 -m unittest src.test_org_node_lifecycle_e2e -v +""" + +import subprocess +import tempfile +import unittest +import uuid + +from src import agent_profile, org_node_lifecycle, project_scaffold, schema, task_create +from src.test_support import skip_unless_env + +_PROMPT = "In one short sentence, confirm you're ready and state your role. Take no other action." + + +@skip_unless_env("RUN_REAL_AGENT_E2E") +class RealChainedTriggerTest(unittest.TestCase): + def test_all_three_org_agents_trigger_for_real_and_status_advances_each_time(self): + with tempfile.TemporaryDirectory() as tmp: + subprocess.run(["git", "init"], cwd=tmp, check=True, capture_output=True) + conn = schema.connect(":memory:") + schema.create_schema(conn) + project_scaffold.create_project(conn, tmp, init_git_if_missing=False) + agent_profile.deploy_agent_definitions(tmp) + task_id = task_create.create_task(conn, self._project_id(conn), "实现贪吃蛇游戏")["task_id"] + + planner_result = org_node_lifecycle.trigger_org_node_full(conn, "planner", task_id, tmp, _PROMPT) + self.assertEqual(planner_result["status"], "planning_brd_prd_sdd") + + self._seed_passing_artifacts(conn, task_id, ("brd", "prd", "sdd", "benchmark")) + coder_result = org_node_lifecycle.trigger_org_node_full(conn, "coder", task_id, tmp, _PROMPT) + self.assertEqual(coder_result["status"], "coding") + + self._seed_passing_artifacts(conn, task_id, ("code_diff", "test_report")) + validator_result = org_node_lifecycle.trigger_org_node_full(conn, "validator", task_id, tmp, _PROMPT) + self.assertEqual(validator_result["status"], "validating") + + @staticmethod + def _project_id(conn): + return conn.execute("SELECT project_id FROM project").fetchone()[0] + + @staticmethod + def _seed_passing_artifacts(conn, task_id, artifact_types): + run_id = str(uuid.uuid4()) + conn.execute( + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) " + "VALUES (?, ?, 'planner', '{}', 'done')", + (run_id, task_id), + ) + for artifact_type in artifact_types: + conn.execute( + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, " + "file_path, git_commit_sha, validation_status) VALUES (?, ?, ?, ?, 'f.md', ?, 'passed')", + (str(uuid.uuid4()), artifact_type, task_id, run_id, artifact_type), + ) + conn.commit() + + +if __name__ == "__main__": + unittest.main()