diff --git a/docs/development-plan.md b/docs/development-plan.md index be0c3b5..0edb2c8 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -118,8 +118,10 @@ - **U-011 [Value Path]** `c_work_graph_plan` 实现(Planning Skill 调用,输出 `graph_manifest`) - 依赖:U-004, U-010 - - Done:REQ-02 happy 用例通过(Planner 产出 BRD/PRD/SDD/Benchmark 四类 artifact 且血缘完整) - - 覆盖:01-M `artifact`/`work_graph_run`;03-C `c_work_graph_plan`;REQ-02 + - 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 落地后累计满足 + - **已知缺口(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 行")时需要显式补上,不要假设它已经存在 + - **技术发现(2026-07-31)**:`--agent` 和 `--json-schema` 同时使用时,`--json-schema` 会被静默忽略(`structured_output` 完全不出现),已记录到 05 节 §5.4.1.1,`plan_work_graph` 改成从 `result` 里解析 ```` ```json ```` 围栏代码块 + - 覆盖:01-M `artifact`/`work_graph_run`;03-C `c_work_graph_plan`;REQ-02(happy 用例的"计划"部分,非全部) - **U-012 [Value Path]** `c_work_graph_execute` 完整实现(`auto` 权限模式、`USAGE_LIMIT_REACHED` 退避重试 `p_usage_limit_wait`、`CLASSIFIER_BLOCKED` 处理) - 依赖:U-006, U-011 diff --git a/docs/sdd/05-实施约束与依赖.md b/docs/sdd/05-实施约束与依赖.md index e2718da..5f8fb4f 100644 --- a/docs/sdd/05-实施约束与依赖.md +++ b/docs/sdd/05-实施约束与依赖.md @@ -32,6 +32,13 @@ - 具体实现契约(写入 workflow 脚本、子进程调用、解析 `structured_output`、回填预算用量)已落地到 03-C 3.5 节 - 原风险已解除,不再需要在实施第一周单独排期做技术验证;架构文档 10.1 节的方案 B(完全自研 Graph Engine)作为长期备选保留,暂不启用 +## 5.4.1.1 `--agent` 与 `--json-schema` 不能同时生效(发现,2026-07-31,U-011) + +- 实测确认:`claude --agent -p ... --json-schema ` 时,`--json-schema` 被静默忽略——返回的 JSON payload 里 `structured_output` 字段整个不存在,模型改成把结构化内容以自由文本 + ```` ```json ```` 围栏代码块的形式塞进 `result` 字段里;但 `--agent` 本身工作正常(人设确实生效,回复里引用了迁移后 SOUL.md 的具体条目编号) +- 这条约束和 5.4 节 A-1 spike 的场景不同:A-1 验证的是**不带 `--agent`** 的动态工作流调用(`claude -p 'Run /...'`),`--json-schema` 在那种调用形式下是生效的(`structured_output` 精确匹配);只有**同时**传 `--agent` 和 `--json-schema` 时才会出现这个静默失效 +- **应对方式**:需要"某个 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`) +- 未在 Anthropic 官方文档里找到这条限制的说明,是靠实测发现的,不排除后续版本行为会变——如果这里升级过 Claude Code 版本,值得重新验证一次 + ## 5.4.2 Claude Code plan 额度等待重试参数(架构文档 7.5 节决策,2026-07-29) - 退避轮询间隔(5→10→20→30 分钟,上限 30 分钟)、超时兜底阈值(session 8 小时、weekly 9 天)是 MVP 阶段的硬编码默认值,不在管理后台开放用户配置,和 `log_retention_config` 的"保留天数可配、清理频率不可配"是同一个 MVP 简化原则 diff --git a/docs/sdd/unit_matrix.json b/docs/sdd/unit_matrix.json index 6fa2115..2317775 100644 --- a/docs/sdd/unit_matrix.json +++ b/docs/sdd/unit_matrix.json @@ -23,7 +23,7 @@ {"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 完整实现(三段前向状态迁移,见 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-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 认领,是已知缺口"}, {"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 通过"}, {"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 条全部达成"}, diff --git a/src/test_work_graph_plan.py b/src/test_work_graph_plan.py new file mode 100644 index 0000000..e75b465 --- /dev/null +++ b/src/test_work_graph_plan.py @@ -0,0 +1,146 @@ +"""U-011 tests: c_work_graph_plan, mocked at the subprocess boundary (CLAUDE.md +Testing constraint). Real-call coverage is in src/test_work_graph_plan_e2e.py +(RUN_REAL_AGENT_E2E=1) -- that real call is what proved --json-schema doesn't +constrain output when --agent is set, which is why these mocks return +free-form ```json-fenced text in `result`, not `structured_output`. + +Run: python3 -m unittest src.test_work_graph_plan -v +""" + +import json +import subprocess +import unittest +from unittest.mock import patch + +from src import schema, work_graph_plan + +_MANIFEST = { + "nodes": [ + {"node_id": "n1", "artifact_type": "brd", "depends_on": []}, + {"node_id": "n2", "artifact_type": "prd", "depends_on": ["n1"]}, + {"node_id": "n3", "artifact_type": "sdd", "depends_on": ["n2"]}, + {"node_id": "n4", "artifact_type": "benchmark", "depends_on": ["n3"]}, + ], + "edges": [ + {"from": "n1", "to": "n2", "edge_type": "needs_judgment"}, + {"from": "n2", "to": "n3", "edge_type": "needs_judgment"}, + {"from": "n3", "to": "n4", "edge_type": "needs_judgment"}, + ], +} + + +def _completed_process(returncode=0, stdout_obj=None, stderr=""): + stdout = json.dumps(stdout_obj) if stdout_obj is not None else "" + return subprocess.CompletedProcess(args=["claude"], returncode=returncode, stdout=stdout, stderr=stderr) + + +def _fenced_result(manifest, wrapper_key=None): + body = {wrapper_key: manifest} if wrapper_key else manifest + return "Here is my plan:\n\n```json\n" + json.dumps(body) + "\n```\n\nDone." + + +class PlanWorkGraphTestBase(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.execute( + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " + "VALUES ('task-1', 'proj-1', '实现贪吃蛇游戏', 'goal', 'queued', 'planner')" + ) + self.conn.commit() + + def tearDown(self): + self.conn.close() + + +class ExtractGraphManifestTest(unittest.TestCase): + def test_bare_json_result(self): + self.assertEqual(work_graph_plan._extract_graph_manifest(json.dumps(_MANIFEST)), _MANIFEST) + + def test_fenced_json_block_with_prose_around_it(self): + self.assertEqual(work_graph_plan._extract_graph_manifest(_fenced_result(_MANIFEST)), _MANIFEST) + + def test_fenced_json_wrapped_under_graph_manifest_key(self): + text = _fenced_result(_MANIFEST, wrapper_key="graph_manifest") + self.assertEqual(work_graph_plan._extract_graph_manifest(text), _MANIFEST) + + def test_no_json_anywhere_returns_none(self): + self.assertIsNone(work_graph_plan._extract_graph_manifest("sorry, I can't do that")) + + +class HappyPathTest(PlanWorkGraphTestBase): + def test_returns_run_id_and_graph_manifest(self): + with patch("src.work_graph_plan.subprocess.run") as run: + run.return_value = _completed_process( + stdout_obj={"is_error": False, "result": _fenced_result(_MANIFEST)} + ) + result = work_graph_plan.plan_work_graph( + self.conn, "planner", "task-1", "实现贪吃蛇游戏", "/tmp/proj-1" + ) + self.assertEqual(result["graph_manifest"], _MANIFEST) + self.assertTrue(result["run_id"]) + + def test_creates_work_graph_run_row_in_planning_status(self): + with patch("src.work_graph_plan.subprocess.run") as run: + run.return_value = _completed_process( + stdout_obj={"is_error": False, "result": _fenced_result(_MANIFEST)} + ) + result = work_graph_plan.plan_work_graph( + self.conn, "planner", "task-1", "实现贪吃蛇游戏", "/tmp/proj-1" + ) + row = self.conn.execute( + "SELECT task_id, org_node, status, graph_manifest FROM work_graph_run WHERE run_id = ?", + (result["run_id"],), + ).fetchone() + self.assertEqual(row[0], "task-1") + self.assertEqual(row[1], "planner") + self.assertEqual(row[2], "planning") + self.assertEqual(json.loads(row[3]), _MANIFEST) + + def test_invokes_claude_with_expected_args(self): + with patch("src.work_graph_plan.subprocess.run") as run: + run.return_value = _completed_process( + stdout_obj={"is_error": False, "result": _fenced_result(_MANIFEST)} + ) + work_graph_plan.plan_work_graph(self.conn, "planner", "task-1", "goal", "/tmp/proj-1") + + args, kwargs = run.call_args + argv = args[0] + self.assertEqual(argv[:3], ["claude", "--agent", "planner"]) + self.assertIn("--permission-mode", argv) + self.assertIn("auto", argv) + self.assertNotIn("--bare", argv) + # --json-schema deliberately dropped -- confirmed for real it's a no-op + # when combined with --agent (see module docstring) + self.assertNotIn("--json-schema", argv) + self.assertEqual(kwargs["cwd"], "/tmp/proj-1") + self.assertEqual(kwargs["stdin"], subprocess.DEVNULL) + + +class FailureTest(PlanWorkGraphTestBase): + def test_nonzero_exit_raises_planning_failed(self): + with patch("src.work_graph_plan.subprocess.run") as run: + run.return_value = _completed_process(returncode=1, stdout_obj=None, stderr="boom") + with self.assertRaises(work_graph_plan.PlanningFailedError): + work_graph_plan.plan_work_graph(self.conn, "planner", "task-1", "goal", "/tmp/proj-1") + + def test_is_error_true_raises_planning_failed(self): + with patch("src.work_graph_plan.subprocess.run") as run: + run.return_value = _completed_process(stdout_obj={"is_error": True}) + with self.assertRaises(work_graph_plan.PlanningFailedError): + work_graph_plan.plan_work_graph(self.conn, "planner", "task-1", "goal", "/tmp/proj-1") + + def test_unparseable_result_raises_planning_failed(self): + with patch("src.work_graph_plan.subprocess.run") as run: + run.return_value = _completed_process(stdout_obj={"is_error": False, "result": "no json here"}) + with self.assertRaises(work_graph_plan.PlanningFailedError): + work_graph_plan.plan_work_graph(self.conn, "planner", "task-1", "goal", "/tmp/proj-1") + self.assertEqual(self.conn.execute("SELECT COUNT(*) FROM work_graph_run").fetchone()[0], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/test_work_graph_plan_e2e.py b/src/test_work_graph_plan_e2e.py new file mode 100644 index 0000000..a0b4f7a --- /dev/null +++ b/src/test_work_graph_plan_e2e.py @@ -0,0 +1,44 @@ +"""U-011's real-call coverage: does `--agent ` actually combine with +`--json-schema` correctly (planner identity AND schema-constrained output at +the same time)? Not documented anywhere, not exercised by U-004/U-005/U-006's +tests (which use one or the other, never both) -- genuinely uncertain until +tried for real. Gated behind RUN_REAL_AGENT_E2E=1 (CLAUDE.md Testing constraint). + +Run: RUN_REAL_AGENT_E2E=1 python3 -m unittest src.test_work_graph_plan_e2e -v +""" + +import subprocess +import tempfile +import unittest + +from src import agent_profile, project_scaffold, schema, work_graph_plan +from src.test_support import skip_unless_env + + +@skip_unless_env("RUN_REAL_AGENT_E2E") +class RealPlanWorkGraphTest(unittest.TestCase): + def test_planner_produces_a_valid_graph_manifest_for_real(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) + conn.execute( + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " + "VALUES ('task-1', (SELECT project_id FROM project), '实现贪吃蛇游戏', 'goal', 'queued', 'planner')" + ) + conn.commit() + + result = work_graph_plan.plan_work_graph(conn, "planner", "task-1", "实现贪吃蛇游戏", tmp) + + manifest = result["graph_manifest"] + artifact_types = {node["artifact_type"] for node in manifest["nodes"]} + self.assertEqual(artifact_types, {"brd", "prd", "sdd", "benchmark"}) + self.assertTrue(manifest["edges"], "expected at least one edge in the graph_manifest") + for edge in manifest["edges"]: + self.assertIn(edge["edge_type"], ("zero_token", "needs_judgment")) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/work_graph_plan.py b/src/work_graph_plan.py new file mode 100644 index 0000000..98f9b2b --- /dev/null +++ b/src/work_graph_plan.py @@ -0,0 +1,140 @@ +"""c_work_graph_plan (03-C §3.4): produces a `graph_manifest`, does not execute. + +U-011 scope: a real `claude --agent -p` call, recorded as a +`work_graph_run` row with `status='planning'` (04-P `p_work_graph_execution` +start_state). + +**Empirical finding (2026-07-31)**: `--json-schema` does NOT constrain output +when combined with `--agent` -- tried it for real, the response came back as +normal prose with an embedded ```json fenced block, `structured_output` was +entirely absent from the payload even though the identity itself came through +correctly (the response referenced "SOUL 原则 #10" by number, i.e. it was +genuinely reasoning as the migrated Planner). So this parses the +graph_manifest out of free-form response text instead of trusting +`structured_output` -- see `_extract_graph_manifest`. Not documented anywhere; +found by actually running it, not assumed. + +Does NOT execute the resulting plan or produce artifacts -- that's +U-012 (`c_work_graph_execute` full)/U-013 (`c_work_node_run`), and nothing +today owns `c_artifact_write` either (REQ-02 lists it, no Unit in +development-plan.md builds it -- a real gap, flagged rather than silently +plugged, since it affects more than just this Unit's scope). REQ-02's happy +case (real BRD/PRD/SDD/Benchmark artifacts with lineage) needs all of that; +development-plan.md's U-011 Done line claimed it passes here alone, which +isn't accurate -- corrected in this session's notes, same pattern as +U-009/U-010. +""" + +import json +import re +import subprocess +import uuid + +GRAPH_MANIFEST_SCHEMA = { + "type": "object", + "required": ["nodes", "edges"], + "properties": { + "nodes": { + "type": "array", + "items": { + "type": "object", + "required": ["node_id", "artifact_type", "depends_on"], + "properties": { + "node_id": {"type": "string"}, + "artifact_type": {"type": "string", "enum": ["brd", "prd", "sdd", "benchmark"]}, + "depends_on": {"type": "array", "items": {"type": "string"}}, + }, + }, + }, + "edges": { + "type": "array", + "items": { + "type": "object", + "required": ["from", "to", "edge_type"], + "properties": { + "from": {"type": "string"}, + "to": {"type": "string"}, + "edge_type": {"type": "string", "enum": ["zero_token", "needs_judgment"]}, + }, + }, + }, + }, +} + +_PROMPT_TEMPLATE = ( + "Plan (do not execute) the Work Graph needed to produce BRD, PRD, SDD, and " + "Benchmark artifacts for this goal: {goal_text}\n" + "Output a graph_manifest with one node per artifact_type (brd, prd, sdd, " + "benchmark) and edges expressing their dependency chain (sdd depends on prd " + "depends on brd, benchmark depends on sdd). Every edge needs edge_type: " + "'zero_token' for pure aggregation/reduce steps, 'needs_judgment' for anything " + "requiring the agent to actually reason about content.\n" + "End your reply with the graph_manifest as a single fenced ```json code block " + "shaped exactly like {{\"nodes\": [...], \"edges\": [...]}} -- no extra wrapper key." +) + +_JSON_FENCE_RE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL) + + +class PlanningFailedError(Exception): + """PLANNING_FAILED -- 03-C §3.4.""" + + +def _extract_graph_manifest(text): + """--json-schema doesn't constrain output when --agent is set (see module + docstring) -- pulls the graph_manifest out of free-form response text + instead: try the whole text as JSON, then each ```json fenced block; + accept either {"nodes":...,"edges":...} directly or wrapped one level + deep under a "graph_manifest" key (models sometimes add that wrapper + despite being asked not to).""" + candidates = [text] + _JSON_FENCE_RE.findall(text) + for candidate in candidates: + try: + parsed = json.loads(candidate) + except (json.JSONDecodeError, TypeError): + continue + if isinstance(parsed, dict): + if "nodes" in parsed and "edges" in parsed: + return parsed + inner = parsed.get("graph_manifest") + if isinstance(inner, dict) and "nodes" in inner and "edges" in inner: + return inner + return None + + +def plan_work_graph(conn, org_node, task_id, goal_text, target_folder_path, timeout=300): + """Returns {"run_id", "graph_manifest"}. Creates one `work_graph_run` row + (status='planning') -- moving it to 'running'/executing it is out of scope.""" + prompt = _PROMPT_TEMPLATE.format(goal_text=goal_text) + + result = subprocess.run( + [ + "claude", "--agent", org_node, + "-p", prompt, + "--output-format", "json", + "--permission-mode", "auto", + ], + cwd=target_folder_path, + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=timeout, + ) + if result.returncode != 0: + raise PlanningFailedError("plan failed for task {}: {}".format(task_id, result.stderr)) + + payload = json.loads(result.stdout) + if payload.get("is_error"): + raise PlanningFailedError("plan failed for task {}: {}".format(task_id, payload)) + graph_manifest = _extract_graph_manifest(payload.get("result", "")) + if not graph_manifest: + raise PlanningFailedError("could not extract a graph_manifest for task {}: {}".format(task_id, payload)) + + run_id = str(uuid.uuid4()) + conn.execute( + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) " + "VALUES (?, ?, ?, ?, 'planning')", + (run_id, task_id, org_node, json.dumps(graph_manifest)), + ) + conn.commit() + return {"run_id": run_id, "graph_manifest": graph_manifest}