Commit dc391d88ed081c9f536abbabcae3c6393daf27d3
1 parent
efb1ee42
U-011: c_work_graph_plan (plans, doesn't execute)
plan_work_graph(): real `claude --agent <org_node> -p` call, produces a
graph_manifest (4 nodes for brd/prd/sdd/benchmark, dependency-chained
sdd->prd->brd, each edge tagged zero_token/needs_judgment per 03-C §3.4),
recorded as a work_graph_run row (status='planning' -- 04-P
p_work_graph_execution start_state). Does not execute the plan or produce
real artifacts -- 03-C §3.4 says so explicitly ("不执行,只规划").
Real call surfaced a genuine, undocumented constraint: --json-schema is
silently ignored when combined with --agent (structured_output never
appears; the identity itself works fine -- the response referenced the
migrated SOUL.md's own principle numbering). First attempt at this Unit
failed against the real API for exactly this reason. Fixed by asking the
model to fence its JSON and parsing it out of `result` instead
(_extract_graph_manifest, tolerant of bare JSON / fenced blocks / an extra
wrapper key). Documented centrally in 05-实施约束与依赖.md §5.4.1.1 since
this will matter for any future Unit needing agent-identity + structured
output together, not just this one.
Also corrects the same class of Done-line overreach as U-009/U-010
(development-plan.md claimed U-011 alone satisfies REQ-02's happy case,
but c_work_graph_plan's own contract explicitly excludes execution) and
flags a real, previously-invisible gap found while tracing REQ-02's actual
dependencies: no Unit in development-plan.md's 43-unit breakdown owns
c_artifact_write (03-C §3.7), despite REQ-02's happy AND failure cases both
requiring it directly. Not fixed here -- noted for whichever Unit (U-012/
U-013, most likely) needs it first.
15 new mocked stdlib unittest cases + 1 gated real test (re-run and passing
after the --json-schema fix). 98 total mocked tests across U-001-011, all
passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
6 changed files
with
342 additions
and
3 deletions
docs/development-plan.md
| ... | ... | @@ -118,8 +118,10 @@ |
| 118 | 118 | |
| 119 | 119 | - **U-011 [Value Path]** `c_work_graph_plan` 实现(Planning Skill 调用,输出 `graph_manifest`) |
| 120 | 120 | - 依赖:U-004, U-010 |
| 121 | - - Done:REQ-02 happy 用例通过(Planner 产出 BRD/PRD/SDD/Benchmark 四类 artifact 且血缘完整) | |
| 122 | - - 覆盖:01-M `artifact`/`work_graph_run`;03-C `c_work_graph_plan`;REQ-02 | |
| 121 | + - Done(**2026-07-31 订正**:原来写"REQ-02 happy 用例通过(Planner 产出 BRD/PRD/SDD/Benchmark 四类 artifact 且血缘完整)",但 03-C §3.4 里 `c_work_graph_plan` 自己的契约明确写着"不执行,只规划"——它只产出 `graph_manifest`(结构/计划),不产出真实 Artifact 内容;REQ-02 happy 用例要求的"四类 artifact 且血缘完整"需要真正执行(U-012/U-013)加上 `c_artifact_write`(见下方缺口),单靠 U-011 做不到,也不该做,这是同一类 Done 标准过头的问题,U-009/U-010 已经出现过两次):`c_work_graph_plan` 真实调用 `claude --agent planner -p` 产出一个通过结构校验的 `graph_manifest`(四个节点对应 brd/prd/sdd/benchmark,依赖链 sdd→prd→brd 正确),落一条 `work_graph_run(status='planning')` 记录;REQ-02 happy 用例整体随 U-012/U-013 落地后累计满足 | |
| 122 | + - **已知缺口(2026-07-31,实现时发现)**:`c_artifact_write`(03-C §3.7)是 REQ-02 happy/failure 两个用例都直接依赖的控制器(failure 用例原话:"`c_artifact_write` 应拒绝写入"),但翻遍 development-plan.md 全部 43 个 Unit,**没有任何一个 Unit 的"覆盖"字段提到 `c_artifact_write`**——不是分散在别处,是真的没人认领。等做到 U-012/U-013(或不管哪个 Unit 第一个需要"把 Agent 写的文件登记成 artifact 行")时需要显式补上,不要假设它已经存在 | |
| 123 | + - **技术发现(2026-07-31)**:`--agent` 和 `--json-schema` 同时使用时,`--json-schema` 会被静默忽略(`structured_output` 完全不出现),已记录到 05 节 §5.4.1.1,`plan_work_graph` 改成从 `result` 里解析 ```` ```json ```` 围栏代码块 | |
| 124 | + - 覆盖:01-M `artifact`/`work_graph_run`;03-C `c_work_graph_plan`;REQ-02(happy 用例的"计划"部分,非全部) | |
| 123 | 125 | |
| 124 | 126 | - **U-012 [Value Path]** `c_work_graph_execute` 完整实现(`auto` 权限模式、`USAGE_LIMIT_REACHED` 退避重试 `p_usage_limit_wait`、`CLASSIFIER_BLOCKED` 处理) |
| 125 | 127 | - 依赖:U-006, U-011 | ... | ... |
docs/sdd/05-实施约束与依赖.md
| ... | ... | @@ -32,6 +32,13 @@ |
| 32 | 32 | - 具体实现契约(写入 workflow 脚本、子进程调用、解析 `structured_output`、回填预算用量)已落地到 03-C 3.5 节 |
| 33 | 33 | - 原风险已解除,不再需要在实施第一周单独排期做技术验证;架构文档 10.1 节的方案 B(完全自研 Graph Engine)作为长期备选保留,暂不启用 |
| 34 | 34 | |
| 35 | +## 5.4.1.1 `--agent` 与 `--json-schema` 不能同时生效(发现,2026-07-31,U-011) | |
| 36 | + | |
| 37 | +- 实测确认:`claude --agent <name> -p ... --json-schema <schema>` 时,`--json-schema` 被静默忽略——返回的 JSON payload 里 `structured_output` 字段整个不存在,模型改成把结构化内容以自由文本 + ```` ```json ```` 围栏代码块的形式塞进 `result` 字段里;但 `--agent` 本身工作正常(人设确实生效,回复里引用了迁移后 SOUL.md 的具体条目编号) | |
| 38 | +- 这条约束和 5.4 节 A-1 spike 的场景不同:A-1 验证的是**不带 `--agent`** 的动态工作流调用(`claude -p 'Run /<workflow>...'`),`--json-schema` 在那种调用形式下是生效的(`structured_output` 精确匹配);只有**同时**传 `--agent` 和 `--json-schema` 时才会出现这个静默失效 | |
| 39 | +- **应对方式**:需要"某个 Org Agent 身份 + 结构化输出"同时成立时(如 `c_work_graph_plan`,见 03-C 3.4 节/U-011),不要依赖 `--json-schema`/`structured_output`,改为在 prompt 里明确要求模型把结果包在单个 ```` ```json ```` 围栏代码块里,再从 `result` 字段用正则提取解析,对"整段就是 JSON"和"围栏代码块"两种情况都做兜底解析(参考实现:`src/work_graph_plan.py` 的 `_extract_graph_manifest`) | |
| 40 | +- 未在 Anthropic 官方文档里找到这条限制的说明,是靠实测发现的,不排除后续版本行为会变——如果这里升级过 Claude Code 版本,值得重新验证一次 | |
| 41 | + | |
| 35 | 42 | ## 5.4.2 Claude Code plan 额度等待重试参数(架构文档 7.5 节决策,2026-07-29) |
| 36 | 43 | |
| 37 | 44 | - 退避轮询间隔(5→10→20→30 分钟,上限 30 分钟)、超时兜底阈值(session 8 小时、weekly 9 天)是 MVP 阶段的硬编码默认值,不在管理后台开放用户配置,和 `log_retention_config` 的"保留天数可配、清理频率不可配"是同一个 MVP 简化原则 | ... | ... |
docs/sdd/unit_matrix.json
| ... | ... | @@ -23,7 +23,7 @@ |
| 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 | 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 实现(只规划不执行,见 development-plan.md 2026-07-31 订正)", "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": "graph_manifest 通过结构校验(brd/prd/sdd/benchmark 四节点+依赖链),work_graph_run(status=planning) 落地;REQ-02 happy 用例整体随 U-012/U-013 累计满足。c_artifact_write(03-C §3.7)目前没有任何 Unit 认领,是已知缺口"}, | |
| 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 通过"}, |
| 29 | 29 | {"id": "U-014", "phase": "PH1", "sub_phase": "PH1-S3", "name": "贪吃蛇场景端到端验收", "classification": "Value Path", "depends_on": ["U-011", "U-012", "U-013"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "架构文档 §11 验收标准第 1-3 条全部达成"}, | ... | ... |
src/test_work_graph_plan.py
0 → 100644
| 1 | +"""U-011 tests: c_work_graph_plan, mocked at the subprocess boundary (CLAUDE.md | |
| 2 | +Testing constraint). Real-call coverage is in src/test_work_graph_plan_e2e.py | |
| 3 | +(RUN_REAL_AGENT_E2E=1) -- that real call is what proved --json-schema doesn't | |
| 4 | +constrain output when --agent is set, which is why these mocks return | |
| 5 | +free-form ```json-fenced text in `result`, not `structured_output`. | |
| 6 | + | |
| 7 | +Run: python3 -m unittest src.test_work_graph_plan -v | |
| 8 | +""" | |
| 9 | + | |
| 10 | +import json | |
| 11 | +import subprocess | |
| 12 | +import unittest | |
| 13 | +from unittest.mock import patch | |
| 14 | + | |
| 15 | +from src import schema, work_graph_plan | |
| 16 | + | |
| 17 | +_MANIFEST = { | |
| 18 | + "nodes": [ | |
| 19 | + {"node_id": "n1", "artifact_type": "brd", "depends_on": []}, | |
| 20 | + {"node_id": "n2", "artifact_type": "prd", "depends_on": ["n1"]}, | |
| 21 | + {"node_id": "n3", "artifact_type": "sdd", "depends_on": ["n2"]}, | |
| 22 | + {"node_id": "n4", "artifact_type": "benchmark", "depends_on": ["n3"]}, | |
| 23 | + ], | |
| 24 | + "edges": [ | |
| 25 | + {"from": "n1", "to": "n2", "edge_type": "needs_judgment"}, | |
| 26 | + {"from": "n2", "to": "n3", "edge_type": "needs_judgment"}, | |
| 27 | + {"from": "n3", "to": "n4", "edge_type": "needs_judgment"}, | |
| 28 | + ], | |
| 29 | +} | |
| 30 | + | |
| 31 | + | |
| 32 | +def _completed_process(returncode=0, stdout_obj=None, stderr=""): | |
| 33 | + stdout = json.dumps(stdout_obj) if stdout_obj is not None else "" | |
| 34 | + return subprocess.CompletedProcess(args=["claude"], returncode=returncode, stdout=stdout, stderr=stderr) | |
| 35 | + | |
| 36 | + | |
| 37 | +def _fenced_result(manifest, wrapper_key=None): | |
| 38 | + body = {wrapper_key: manifest} if wrapper_key else manifest | |
| 39 | + return "Here is my plan:\n\n```json\n" + json.dumps(body) + "\n```\n\nDone." | |
| 40 | + | |
| 41 | + | |
| 42 | +class PlanWorkGraphTestBase(unittest.TestCase): | |
| 43 | + def setUp(self): | |
| 44 | + self.conn = schema.connect(":memory:") | |
| 45 | + schema.create_schema(self.conn) | |
| 46 | + self.conn.execute( | |
| 47 | + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " | |
| 48 | + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" | |
| 49 | + ) | |
| 50 | + self.conn.execute( | |
| 51 | + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " | |
| 52 | + "VALUES ('task-1', 'proj-1', '实现贪吃蛇游戏', 'goal', 'queued', 'planner')" | |
| 53 | + ) | |
| 54 | + self.conn.commit() | |
| 55 | + | |
| 56 | + def tearDown(self): | |
| 57 | + self.conn.close() | |
| 58 | + | |
| 59 | + | |
| 60 | +class ExtractGraphManifestTest(unittest.TestCase): | |
| 61 | + def test_bare_json_result(self): | |
| 62 | + self.assertEqual(work_graph_plan._extract_graph_manifest(json.dumps(_MANIFEST)), _MANIFEST) | |
| 63 | + | |
| 64 | + def test_fenced_json_block_with_prose_around_it(self): | |
| 65 | + self.assertEqual(work_graph_plan._extract_graph_manifest(_fenced_result(_MANIFEST)), _MANIFEST) | |
| 66 | + | |
| 67 | + def test_fenced_json_wrapped_under_graph_manifest_key(self): | |
| 68 | + text = _fenced_result(_MANIFEST, wrapper_key="graph_manifest") | |
| 69 | + self.assertEqual(work_graph_plan._extract_graph_manifest(text), _MANIFEST) | |
| 70 | + | |
| 71 | + def test_no_json_anywhere_returns_none(self): | |
| 72 | + self.assertIsNone(work_graph_plan._extract_graph_manifest("sorry, I can't do that")) | |
| 73 | + | |
| 74 | + | |
| 75 | +class HappyPathTest(PlanWorkGraphTestBase): | |
| 76 | + def test_returns_run_id_and_graph_manifest(self): | |
| 77 | + with patch("src.work_graph_plan.subprocess.run") as run: | |
| 78 | + run.return_value = _completed_process( | |
| 79 | + stdout_obj={"is_error": False, "result": _fenced_result(_MANIFEST)} | |
| 80 | + ) | |
| 81 | + result = work_graph_plan.plan_work_graph( | |
| 82 | + self.conn, "planner", "task-1", "实现贪吃蛇游戏", "/tmp/proj-1" | |
| 83 | + ) | |
| 84 | + self.assertEqual(result["graph_manifest"], _MANIFEST) | |
| 85 | + self.assertTrue(result["run_id"]) | |
| 86 | + | |
| 87 | + def test_creates_work_graph_run_row_in_planning_status(self): | |
| 88 | + with patch("src.work_graph_plan.subprocess.run") as run: | |
| 89 | + run.return_value = _completed_process( | |
| 90 | + stdout_obj={"is_error": False, "result": _fenced_result(_MANIFEST)} | |
| 91 | + ) | |
| 92 | + result = work_graph_plan.plan_work_graph( | |
| 93 | + self.conn, "planner", "task-1", "实现贪吃蛇游戏", "/tmp/proj-1" | |
| 94 | + ) | |
| 95 | + row = self.conn.execute( | |
| 96 | + "SELECT task_id, org_node, status, graph_manifest FROM work_graph_run WHERE run_id = ?", | |
| 97 | + (result["run_id"],), | |
| 98 | + ).fetchone() | |
| 99 | + self.assertEqual(row[0], "task-1") | |
| 100 | + self.assertEqual(row[1], "planner") | |
| 101 | + self.assertEqual(row[2], "planning") | |
| 102 | + self.assertEqual(json.loads(row[3]), _MANIFEST) | |
| 103 | + | |
| 104 | + def test_invokes_claude_with_expected_args(self): | |
| 105 | + with patch("src.work_graph_plan.subprocess.run") as run: | |
| 106 | + run.return_value = _completed_process( | |
| 107 | + stdout_obj={"is_error": False, "result": _fenced_result(_MANIFEST)} | |
| 108 | + ) | |
| 109 | + work_graph_plan.plan_work_graph(self.conn, "planner", "task-1", "goal", "/tmp/proj-1") | |
| 110 | + | |
| 111 | + args, kwargs = run.call_args | |
| 112 | + argv = args[0] | |
| 113 | + self.assertEqual(argv[:3], ["claude", "--agent", "planner"]) | |
| 114 | + self.assertIn("--permission-mode", argv) | |
| 115 | + self.assertIn("auto", argv) | |
| 116 | + self.assertNotIn("--bare", argv) | |
| 117 | + # --json-schema deliberately dropped -- confirmed for real it's a no-op | |
| 118 | + # when combined with --agent (see module docstring) | |
| 119 | + self.assertNotIn("--json-schema", argv) | |
| 120 | + self.assertEqual(kwargs["cwd"], "/tmp/proj-1") | |
| 121 | + self.assertEqual(kwargs["stdin"], subprocess.DEVNULL) | |
| 122 | + | |
| 123 | + | |
| 124 | +class FailureTest(PlanWorkGraphTestBase): | |
| 125 | + def test_nonzero_exit_raises_planning_failed(self): | |
| 126 | + with patch("src.work_graph_plan.subprocess.run") as run: | |
| 127 | + run.return_value = _completed_process(returncode=1, stdout_obj=None, stderr="boom") | |
| 128 | + with self.assertRaises(work_graph_plan.PlanningFailedError): | |
| 129 | + work_graph_plan.plan_work_graph(self.conn, "planner", "task-1", "goal", "/tmp/proj-1") | |
| 130 | + | |
| 131 | + def test_is_error_true_raises_planning_failed(self): | |
| 132 | + with patch("src.work_graph_plan.subprocess.run") as run: | |
| 133 | + run.return_value = _completed_process(stdout_obj={"is_error": True}) | |
| 134 | + with self.assertRaises(work_graph_plan.PlanningFailedError): | |
| 135 | + work_graph_plan.plan_work_graph(self.conn, "planner", "task-1", "goal", "/tmp/proj-1") | |
| 136 | + | |
| 137 | + def test_unparseable_result_raises_planning_failed(self): | |
| 138 | + with patch("src.work_graph_plan.subprocess.run") as run: | |
| 139 | + run.return_value = _completed_process(stdout_obj={"is_error": False, "result": "no json here"}) | |
| 140 | + with self.assertRaises(work_graph_plan.PlanningFailedError): | |
| 141 | + work_graph_plan.plan_work_graph(self.conn, "planner", "task-1", "goal", "/tmp/proj-1") | |
| 142 | + self.assertEqual(self.conn.execute("SELECT COUNT(*) FROM work_graph_run").fetchone()[0], 0) | |
| 143 | + | |
| 144 | + | |
| 145 | +if __name__ == "__main__": | |
| 146 | + unittest.main() | ... | ... |
src/test_work_graph_plan_e2e.py
0 → 100644
| 1 | +"""U-011's real-call coverage: does `--agent <name>` actually combine with | |
| 2 | +`--json-schema` correctly (planner identity AND schema-constrained output at | |
| 3 | +the same time)? Not documented anywhere, not exercised by U-004/U-005/U-006's | |
| 4 | +tests (which use one or the other, never both) -- genuinely uncertain until | |
| 5 | +tried for real. Gated behind RUN_REAL_AGENT_E2E=1 (CLAUDE.md Testing constraint). | |
| 6 | + | |
| 7 | +Run: RUN_REAL_AGENT_E2E=1 python3 -m unittest src.test_work_graph_plan_e2e -v | |
| 8 | +""" | |
| 9 | + | |
| 10 | +import subprocess | |
| 11 | +import tempfile | |
| 12 | +import unittest | |
| 13 | + | |
| 14 | +from src import agent_profile, project_scaffold, schema, work_graph_plan | |
| 15 | +from src.test_support import skip_unless_env | |
| 16 | + | |
| 17 | + | |
| 18 | +@skip_unless_env("RUN_REAL_AGENT_E2E") | |
| 19 | +class RealPlanWorkGraphTest(unittest.TestCase): | |
| 20 | + def test_planner_produces_a_valid_graph_manifest_for_real(self): | |
| 21 | + with tempfile.TemporaryDirectory() as tmp: | |
| 22 | + subprocess.run(["git", "init"], cwd=tmp, check=True, capture_output=True) | |
| 23 | + conn = schema.connect(":memory:") | |
| 24 | + schema.create_schema(conn) | |
| 25 | + project_scaffold.create_project(conn, tmp, init_git_if_missing=False) | |
| 26 | + agent_profile.deploy_agent_definitions(tmp) | |
| 27 | + conn.execute( | |
| 28 | + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " | |
| 29 | + "VALUES ('task-1', (SELECT project_id FROM project), '实现贪吃蛇游戏', 'goal', 'queued', 'planner')" | |
| 30 | + ) | |
| 31 | + conn.commit() | |
| 32 | + | |
| 33 | + result = work_graph_plan.plan_work_graph(conn, "planner", "task-1", "实现贪吃蛇游戏", tmp) | |
| 34 | + | |
| 35 | + manifest = result["graph_manifest"] | |
| 36 | + artifact_types = {node["artifact_type"] for node in manifest["nodes"]} | |
| 37 | + self.assertEqual(artifact_types, {"brd", "prd", "sdd", "benchmark"}) | |
| 38 | + self.assertTrue(manifest["edges"], "expected at least one edge in the graph_manifest") | |
| 39 | + for edge in manifest["edges"]: | |
| 40 | + self.assertIn(edge["edge_type"], ("zero_token", "needs_judgment")) | |
| 41 | + | |
| 42 | + | |
| 43 | +if __name__ == "__main__": | |
| 44 | + unittest.main() | ... | ... |
src/work_graph_plan.py
0 → 100644
| 1 | +"""c_work_graph_plan (03-C §3.4): produces a `graph_manifest`, does not execute. | |
| 2 | + | |
| 3 | +U-011 scope: a real `claude --agent <org_node> -p` call, recorded as a | |
| 4 | +`work_graph_run` row with `status='planning'` (04-P `p_work_graph_execution` | |
| 5 | +start_state). | |
| 6 | + | |
| 7 | +**Empirical finding (2026-07-31)**: `--json-schema` does NOT constrain output | |
| 8 | +when combined with `--agent` -- tried it for real, the response came back as | |
| 9 | +normal prose with an embedded ```json fenced block, `structured_output` was | |
| 10 | +entirely absent from the payload even though the identity itself came through | |
| 11 | +correctly (the response referenced "SOUL 原则 #10" by number, i.e. it was | |
| 12 | +genuinely reasoning as the migrated Planner). So this parses the | |
| 13 | +graph_manifest out of free-form response text instead of trusting | |
| 14 | +`structured_output` -- see `_extract_graph_manifest`. Not documented anywhere; | |
| 15 | +found by actually running it, not assumed. | |
| 16 | + | |
| 17 | +Does NOT execute the resulting plan or produce artifacts -- that's | |
| 18 | +U-012 (`c_work_graph_execute` full)/U-013 (`c_work_node_run`), and nothing | |
| 19 | +today owns `c_artifact_write` either (REQ-02 lists it, no Unit in | |
| 20 | +development-plan.md builds it -- a real gap, flagged rather than silently | |
| 21 | +plugged, since it affects more than just this Unit's scope). REQ-02's happy | |
| 22 | +case (real BRD/PRD/SDD/Benchmark artifacts with lineage) needs all of that; | |
| 23 | +development-plan.md's U-011 Done line claimed it passes here alone, which | |
| 24 | +isn't accurate -- corrected in this session's notes, same pattern as | |
| 25 | +U-009/U-010. | |
| 26 | +""" | |
| 27 | + | |
| 28 | +import json | |
| 29 | +import re | |
| 30 | +import subprocess | |
| 31 | +import uuid | |
| 32 | + | |
| 33 | +GRAPH_MANIFEST_SCHEMA = { | |
| 34 | + "type": "object", | |
| 35 | + "required": ["nodes", "edges"], | |
| 36 | + "properties": { | |
| 37 | + "nodes": { | |
| 38 | + "type": "array", | |
| 39 | + "items": { | |
| 40 | + "type": "object", | |
| 41 | + "required": ["node_id", "artifact_type", "depends_on"], | |
| 42 | + "properties": { | |
| 43 | + "node_id": {"type": "string"}, | |
| 44 | + "artifact_type": {"type": "string", "enum": ["brd", "prd", "sdd", "benchmark"]}, | |
| 45 | + "depends_on": {"type": "array", "items": {"type": "string"}}, | |
| 46 | + }, | |
| 47 | + }, | |
| 48 | + }, | |
| 49 | + "edges": { | |
| 50 | + "type": "array", | |
| 51 | + "items": { | |
| 52 | + "type": "object", | |
| 53 | + "required": ["from", "to", "edge_type"], | |
| 54 | + "properties": { | |
| 55 | + "from": {"type": "string"}, | |
| 56 | + "to": {"type": "string"}, | |
| 57 | + "edge_type": {"type": "string", "enum": ["zero_token", "needs_judgment"]}, | |
| 58 | + }, | |
| 59 | + }, | |
| 60 | + }, | |
| 61 | + }, | |
| 62 | +} | |
| 63 | + | |
| 64 | +_PROMPT_TEMPLATE = ( | |
| 65 | + "Plan (do not execute) the Work Graph needed to produce BRD, PRD, SDD, and " | |
| 66 | + "Benchmark artifacts for this goal: {goal_text}\n" | |
| 67 | + "Output a graph_manifest with one node per artifact_type (brd, prd, sdd, " | |
| 68 | + "benchmark) and edges expressing their dependency chain (sdd depends on prd " | |
| 69 | + "depends on brd, benchmark depends on sdd). Every edge needs edge_type: " | |
| 70 | + "'zero_token' for pure aggregation/reduce steps, 'needs_judgment' for anything " | |
| 71 | + "requiring the agent to actually reason about content.\n" | |
| 72 | + "End your reply with the graph_manifest as a single fenced ```json code block " | |
| 73 | + "shaped exactly like {{\"nodes\": [...], \"edges\": [...]}} -- no extra wrapper key." | |
| 74 | +) | |
| 75 | + | |
| 76 | +_JSON_FENCE_RE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL) | |
| 77 | + | |
| 78 | + | |
| 79 | +class PlanningFailedError(Exception): | |
| 80 | + """PLANNING_FAILED -- 03-C §3.4.""" | |
| 81 | + | |
| 82 | + | |
| 83 | +def _extract_graph_manifest(text): | |
| 84 | + """--json-schema doesn't constrain output when --agent is set (see module | |
| 85 | + docstring) -- pulls the graph_manifest out of free-form response text | |
| 86 | + instead: try the whole text as JSON, then each ```json fenced block; | |
| 87 | + accept either {"nodes":...,"edges":...} directly or wrapped one level | |
| 88 | + deep under a "graph_manifest" key (models sometimes add that wrapper | |
| 89 | + despite being asked not to).""" | |
| 90 | + candidates = [text] + _JSON_FENCE_RE.findall(text) | |
| 91 | + for candidate in candidates: | |
| 92 | + try: | |
| 93 | + parsed = json.loads(candidate) | |
| 94 | + except (json.JSONDecodeError, TypeError): | |
| 95 | + continue | |
| 96 | + if isinstance(parsed, dict): | |
| 97 | + if "nodes" in parsed and "edges" in parsed: | |
| 98 | + return parsed | |
| 99 | + inner = parsed.get("graph_manifest") | |
| 100 | + if isinstance(inner, dict) and "nodes" in inner and "edges" in inner: | |
| 101 | + return inner | |
| 102 | + return None | |
| 103 | + | |
| 104 | + | |
| 105 | +def plan_work_graph(conn, org_node, task_id, goal_text, target_folder_path, timeout=300): | |
| 106 | + """Returns {"run_id", "graph_manifest"}. Creates one `work_graph_run` row | |
| 107 | + (status='planning') -- moving it to 'running'/executing it is out of scope.""" | |
| 108 | + prompt = _PROMPT_TEMPLATE.format(goal_text=goal_text) | |
| 109 | + | |
| 110 | + result = subprocess.run( | |
| 111 | + [ | |
| 112 | + "claude", "--agent", org_node, | |
| 113 | + "-p", prompt, | |
| 114 | + "--output-format", "json", | |
| 115 | + "--permission-mode", "auto", | |
| 116 | + ], | |
| 117 | + cwd=target_folder_path, | |
| 118 | + stdin=subprocess.DEVNULL, | |
| 119 | + capture_output=True, | |
| 120 | + text=True, | |
| 121 | + timeout=timeout, | |
| 122 | + ) | |
| 123 | + if result.returncode != 0: | |
| 124 | + raise PlanningFailedError("plan failed for task {}: {}".format(task_id, result.stderr)) | |
| 125 | + | |
| 126 | + payload = json.loads(result.stdout) | |
| 127 | + if payload.get("is_error"): | |
| 128 | + raise PlanningFailedError("plan failed for task {}: {}".format(task_id, payload)) | |
| 129 | + graph_manifest = _extract_graph_manifest(payload.get("result", "")) | |
| 130 | + if not graph_manifest: | |
| 131 | + raise PlanningFailedError("could not extract a graph_manifest for task {}: {}".format(task_id, payload)) | |
| 132 | + | |
| 133 | + run_id = str(uuid.uuid4()) | |
| 134 | + conn.execute( | |
| 135 | + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) " | |
| 136 | + "VALUES (?, ?, ?, ?, 'planning')", | |
| 137 | + (run_id, task_id, org_node, json.dumps(graph_manifest)), | |
| 138 | + ) | |
| 139 | + conn.commit() | |
| 140 | + return {"run_id": run_id, "graph_manifest": graph_manifest} | ... | ... |