Commit efb1ee4226a5c326f8d5df19869fb6013cecf4b2

Authored by bboonnee
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>
docs/development-plan.md
... ... @@ -110,7 +110,8 @@
110 110  
111 111 - **U-010 [Value Path]** `c_org_node_trigger` 完整实现(含 `TASK_NOT_READY`/`CONCURRENCY_LIMIT_REACHED`/`USAGE_LIMIT_REACHED` 错误处理)
112 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 115 - 覆盖:03-C `c_org_node_trigger`
115 116  
116 117 ### PH1-S2 Work Graph 层集成(本项目风险最高的 Sub-phase)
... ...
docs/sdd/unit_matrix.json
... ... @@ -22,7 +22,7 @@
22 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 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 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 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 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()
... ...