Commit 0d602ed40302e68f946305c03946bf8949b198c5

Authored by bboonnee
1 parent 2aa68435

U-011 fix: deterministically enforce the canonical BRD->PRD->SDD->Benchmark chain

The 6th real self-hosting attempt (first to reach the full 4-node doc-writing
graph after the previous five bugs) hit a genuinely new failure: this real
graph_manifest had no edges at all, not just wrong field names -- worse than
the case _normalize_graph_manifest already handled. With zero dependency
info to normalize from, topological_order() scheduled 'benchmark' before
'sdd' (alphabetically first among zero-in-degree nodes), and build_prompt()
crashed with KeyError: 'sdd_content' trying to format a template needing
sdd's not-yet-produced output.

_PROMPT_TEMPLATE always asks for exactly the same fixed 4-node linear chain
-- that's a constant architectural fact, not something that should be
re-inferred from the LLM's output every call. _normalize_graph_manifest now
deterministically enforces brd->prd->sdd->benchmark whenever all four
canonical artifact_types are present, regardless of what depends_on/edges
the LLM did or didn't provide. Same "机制用代码" principle as
ensure_worktree_committed and the earlier normalize logic.

Three new regression tests: zero dependency info still gets the chain
enforced, node_id != artifact_type still resolves to the real node_id (not
a guessed string), and a partial set (missing one of the four types) is
correctly left alone rather than guessed at.

560 tests green (440 src + 120 supervisor).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/development-plan.md
@@ -121,6 +121,7 @@ @@ -121,6 +121,7 @@
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 落地后累计满足 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 行")时需要显式补上,不要假设它已经存在 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 ```` 围栏代码块 123 - **技术发现(2026-07-31)**:`--agent` 和 `--json-schema` 同时使用时,`--json-schema` 会被静默忽略(`structured_output` 完全不出现),已记录到 05 节 §5.4.1.1,`plan_work_graph` 改成从 `result` 里解析 ```` ```json ```` 围栏代码块
  124 + - **真实 bug(2026-08-03,U-042 第六次真实自举压测发现)**:前五次真实压测各自卡在别的更早环节,这是第一次真正走到"用真实 `graph_manifest` 跑完整个 4 节点文档链"这一步就出问题。这次真实调用返回的 `graph_manifest` 里,四个节点连 `edges` 都没给(比 2026-08-01 那次"字段名不对但至少有 edges"更彻底),`_normalize_graph_manifest` 原来的"从 edges 反推 depends_on"逻辑无从反推——`topological_order()` 在完全没有依赖信息时按 node_id 字母序处理零入度节点,"benchmark"排在"sdd"前面,于是 `e2e_pipeline.build_prompt()` 试图给 benchmark 的 prompt 模板填 `{sdd_content}` 时,`sdd` 节点根本还没跑,直接 `KeyError`。**这次没有再指望 LLM 每次都能把这个结构给对**:`_PROMPT_TEMPLATE` 本来就是让 Planner 每次都产出完全固定的 brd→prd→sdd→benchmark 线性四节点,这是个不会变的架构事实,不是每次都要重新推断的东西——`_normalize_graph_manifest` 现在多一步:只要四个规范 `artifact_type`(brd/prd/sdd/benchmark)齐全,不管 LLM 给没给 `depends_on`/`edges`,直接按已知的固定线性顺序强制补上依赖链,和 `ensure_worktree_committed` 一样是"机制用代码"而不是祈祷 Agent 每次都按契约给结构。补了三个回归测试:完全没有依赖信息时依然能正确补链、`node_id` 和 `artifact_type` 不同名时按真实 `node_id` 补(不是瞎猜字符串)、四个规范类型不全时不强行拼凑。
124 - 覆盖:01-M `artifact`/`work_graph_run`;03-C `c_work_graph_plan`;REQ-02(happy 用例的"计划"部分,非全部) 125 - 覆盖:01-M `artifact`/`work_graph_run`;03-C `c_work_graph_plan`;REQ-02(happy 用例的"计划"部分,非全部)
125 126
126 - **U-012 [Value Path]** `c_work_graph_execute` 完整实现(`auto` 权限模式、`USAGE_LIMIT_REACHED` 退避重试 `p_usage_limit_wait`、`CLASSIFIER_BLOCKED` 处理) 127 - **U-012 [Value Path]** `c_work_graph_execute` 完整实现(`auto` 权限模式、`USAGE_LIMIT_REACHED` 退避重试 `p_usage_limit_wait`、`CLASSIFIER_BLOCKED` 处理)
docs/sdd/unit_matrix.json
@@ -23,7 +23,7 @@ @@ -23,7 +23,7 @@
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 完整实现(三段前向状态迁移,见 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 范围"}, 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 实现(只规划不执行,见 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 认领,是已知缺口"}, 26 + {"id": "U-011", "phase": "PH1", "sub_phase": "PH1-S2", "name": "c_work_graph_plan 实现(只规划不执行,见 development-plan.md 2026-07-31/2026-08-03 订正)", "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 认领,是已知缺口。2026-08-03:U-042 第六次真实自举压测发现真实 LLM 输出可能完全不给 depends_on/edges(比此前已知的\"字段名不对\"更彻底),_normalize_graph_manifest 现在对 brd/prd/sdd/benchmark 四个规范 artifact_type 齐全时强制按已知的固定线性链补 depends_on,不再完全依赖 LLM 自己给对"},
27 {"id": "U-012", "phase": "PH1", "sub_phase": "PH1-S2", "name": "c_work_graph_execute 完整实现(见 development-plan.md 2026-07-31 范围说明)", "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 boundary/failure 通过(happy 需 PH2 REQ-04/05/06 就绪后累计满足);REQ-14 happy 通过。退避重试是整图级别,非逐节点;CLASSIFIER_BLOCKED 用单次调用 permission_denials≥3 近似判定"}, 27 {"id": "U-012", "phase": "PH1", "sub_phase": "PH1-S2", "name": "c_work_graph_execute 完整实现(见 development-plan.md 2026-07-31 范围说明)", "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 boundary/failure 通过(happy 需 PH2 REQ-04/05/06 就绪后累计满足);REQ-14 happy 通过。退避重试是整图级别,非逐节点;CLASSIFIER_BLOCKED 用单次调用 permission_denials≥3 近似判定"},
28 {"id": "U-013", "phase": "PH1", "sub_phase": "PH1-S2", "name": "c_work_node_run + git worktree 隔离(见 development-plan.md 2026-07-31 订正、2026-08-02/03 真实 bug 修复)", "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 boundary 通过(真实 git worktree 隔离+merge,无冲突);happy 需 c_artifact_write 落地后累计满足。2026-08-02/03:U-042 第一次真实自举压测发现 Coder 编辑 worktree 后从不 commit、merge_worktree 空操作、真实改动被 remove_worktree 静默吞掉的真实 bug,新增 ensure_worktree_committed() 兜底自动提交,已修复并补测试"}, 28 {"id": "U-013", "phase": "PH1", "sub_phase": "PH1-S2", "name": "c_work_node_run + git worktree 隔离(见 development-plan.md 2026-07-31 订正、2026-08-02/03 真实 bug 修复)", "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 boundary 通过(真实 git worktree 隔离+merge,无冲突);happy 需 c_artifact_write 落地后累计满足。2026-08-02/03:U-042 第一次真实自举压测发现 Coder 编辑 worktree 后从不 commit、merge_worktree 空操作、真实改动被 remove_worktree 静默吞掉的真实 bug,新增 ensure_worktree_committed() 兜底自动提交,已修复并补测试"},
29 {"id": "U-014", "phase": "PH1", "sub_phase": "PH1-S3", "name": "贪吃蛇场景端到端验收(含补建 c_artifact_write,见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-011", "U-012", "U-013"], "coverage": {"model": ["artifact"], "view": [], "controller": ["c_artifact_write"], "process": [], "requirement": []}, "done": "架构文档 §11 验收标准第 1-3 条全部达成;plan_documents(Planner 四件套+血缘)真实验证过,Coder/Validator 全流程真实跑通待用户确认后执行"}, 29 {"id": "U-014", "phase": "PH1", "sub_phase": "PH1-S3", "name": "贪吃蛇场景端到端验收(含补建 c_artifact_write,见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-011", "U-012", "U-013"], "coverage": {"model": ["artifact"], "view": [], "controller": ["c_artifact_write"], "process": [], "requirement": []}, "done": "架构文档 §11 验收标准第 1-3 条全部达成;plan_documents(Planner 四件套+血缘)真实验证过,Coder/Validator 全流程真实跑通待用户确认后执行"},
src/test_work_graph_plan.py
@@ -128,6 +128,64 @@ class NormalizeGraphManifestTest(unittest.TestCase): @@ -128,6 +128,64 @@ class NormalizeGraphManifestTest(unittest.TestCase):
128 result = work_graph_plan._normalize_graph_manifest(manifest) 128 result = work_graph_plan._normalize_graph_manifest(manifest)
129 self.assertEqual(result["nodes"][0]["depends_on"], []) 129 self.assertEqual(result["nodes"][0]["depends_on"], [])
130 130
  131 + def test_canonical_doc_chain_enforced_even_with_zero_dependency_info(self):
  132 + # 2026-08-03 regression: a real call omitted depends_on AND edges
  133 + # entirely -- worse than test_depends_on_derived_from_edges_when_absent,
  134 + # which at least had edges to derive from. With nothing to
  135 + # normalize, topological_order() scheduled 'benchmark' before 'sdd'
  136 + # (alphabetically first among zero-in-degree nodes), and
  137 + # e2e_pipeline's build_prompt() crashed formatting a template that
  138 + # needed sdd's not-yet-produced output.
  139 + manifest = {
  140 + "nodes": [
  141 + {"node_id": "brd", "artifact_type": "brd"},
  142 + {"node_id": "prd", "artifact_type": "prd"},
  143 + {"node_id": "sdd", "artifact_type": "sdd"},
  144 + {"node_id": "benchmark", "artifact_type": "benchmark"},
  145 + ],
  146 + "edges": [],
  147 + }
  148 + result = work_graph_plan._normalize_graph_manifest(manifest)
  149 + by_type = {n["artifact_type"]: n for n in result["nodes"]}
  150 + self.assertEqual(by_type["brd"]["depends_on"], [])
  151 + self.assertEqual(by_type["prd"]["depends_on"], ["brd"])
  152 + self.assertEqual(by_type["sdd"]["depends_on"], ["prd"])
  153 + self.assertEqual(by_type["benchmark"]["depends_on"], ["sdd"])
  154 +
  155 + def test_canonical_chain_enforcement_uses_real_node_ids_not_artifact_types(self):
  156 + # node_id and artifact_type can differ (e.g. real LLM output naming
  157 + # nodes "n1"/"n2"/... while still setting artifact_type correctly) --
  158 + # the enforced dependency must reference the real node_id.
  159 + manifest = {
  160 + "nodes": [
  161 + {"node_id": "step-1", "artifact_type": "brd"},
  162 + {"node_id": "step-2", "artifact_type": "prd"},
  163 + {"node_id": "step-3", "artifact_type": "sdd"},
  164 + {"node_id": "step-4", "artifact_type": "benchmark"},
  165 + ],
  166 + "edges": [],
  167 + }
  168 + result = work_graph_plan._normalize_graph_manifest(manifest)
  169 + by_type = {n["artifact_type"]: n for n in result["nodes"]}
  170 + self.assertEqual(by_type["prd"]["depends_on"], ["step-1"])
  171 + self.assertEqual(by_type["sdd"]["depends_on"], ["step-2"])
  172 + self.assertEqual(by_type["benchmark"]["depends_on"], ["step-3"])
  173 +
  174 + def test_partial_canonical_set_does_not_trigger_enforcement(self):
  175 + # only brd/prd/sdd present, no benchmark -- not a recognized
  176 + # canonical 4-node doc chain, must not guess at a shape.
  177 + manifest = {
  178 + "nodes": [
  179 + {"node_id": "brd", "artifact_type": "brd"},
  180 + {"node_id": "prd", "artifact_type": "prd"},
  181 + {"node_id": "sdd", "artifact_type": "sdd"},
  182 + ],
  183 + "edges": [],
  184 + }
  185 + result = work_graph_plan._normalize_graph_manifest(manifest)
  186 + for node in result["nodes"]:
  187 + self.assertEqual(node["depends_on"], [])
  188 +
131 189
132 class HappyPathTest(PlanWorkGraphTestBase): 190 class HappyPathTest(PlanWorkGraphTestBase):
133 def test_returns_run_id_and_graph_manifest(self): 191 def test_returns_run_id_and_graph_manifest(self):
src/work_graph_plan.py
@@ -102,6 +102,9 @@ def _extract_graph_manifest(text): @@ -102,6 +102,9 @@ def _extract_graph_manifest(text):
102 return None 102 return None
103 103
104 104
  105 +_CANONICAL_DOC_CHAIN = ("brd", "prd", "sdd", "benchmark")
  106 +
  107 +
105 def _normalize_graph_manifest(manifest): 108 def _normalize_graph_manifest(manifest):
106 """Real LLM output has been observed (2026-08-01, U-031 integration 109 """Real LLM output has been observed (2026-08-01, U-031 integration
107 validation) to use `id` instead of the documented `node_id` key, and to 110 validation) to use `id` instead of the documented `node_id` key, and to
@@ -114,6 +117,24 @@ def _normalize_graph_manifest(manifest): @@ -114,6 +117,24 @@ def _normalize_graph_manifest(manifest):
114 instead of each one needing its own defensive "id or node_id, 117 instead of each one needing its own defensive "id or node_id,
115 depends_on or edges" fallback logic repeated independently. Mutates and 118 depends_on or edges" fallback logic repeated independently. Mutates and
116 returns `manifest`. 119 returns `manifest`.
  120 +
  121 + **Canonical doc-chain enforcement (2026-08-03, real self-hosting run)**:
  122 + a real call was observed to omit the brd->prd->sdd->benchmark
  123 + dependency entirely -- no `depends_on`, no `edges` connecting them at
  124 + all, not just the wrong key names the edge-derived backfill above can
  125 + already recover from. With no dependency info to normalize from,
  126 + `topological_order()` scheduled `benchmark` before `sdd` even ran
  127 + (alphabetically first among nodes with an empty in-degree), and
  128 + `e2e_pipeline.build_prompt()` crashed with `KeyError: 'sdd_content'`
  129 + trying to format a template that needed sdd's not-yet-produced output.
  130 + `_PROMPT_TEMPLATE` always asks for exactly this fixed 4-node linear
  131 + chain -- it's a constant architectural fact about what the Planner's
  132 + doc-writing graph looks like, not something that genuinely varies call
  133 + to call, so don't leave it to chance: whenever all four canonical
  134 + artifact_types are present, deterministically enforce the known linear
  135 + order regardless of what edges/depends_on the LLM did or didn't
  136 + provide. Same "机制用代码" principle already applied elsewhere
  137 + (`work_node_run.ensure_worktree_committed`, etc.).
117 """ 138 """
118 nodes = manifest.get("nodes", []) 139 nodes = manifest.get("nodes", [])
119 for node in nodes: 140 for node in nodes:
@@ -128,6 +149,15 @@ def _normalize_graph_manifest(manifest): @@ -128,6 +149,15 @@ def _normalize_graph_manifest(manifest):
128 for node in nodes: 149 for node in nodes:
129 if node.get("node_id") == to_id and from_id not in node["depends_on"]: 150 if node.get("node_id") == to_id and from_id not in node["depends_on"]:
130 node["depends_on"].append(from_id) 151 node["depends_on"].append(from_id)
  152 +
  153 + by_artifact_type = {n.get("artifact_type"): n for n in nodes}
  154 + if all(t in by_artifact_type for t in _CANONICAL_DOC_CHAIN):
  155 + for prev_type, cur_type in zip(_CANONICAL_DOC_CHAIN, _CANONICAL_DOC_CHAIN[1:]):
  156 + prev_id = by_artifact_type[prev_type]["node_id"]
  157 + cur_node = by_artifact_type[cur_type]
  158 + if prev_id not in cur_node["depends_on"]:
  159 + cur_node["depends_on"].append(prev_id)
  160 +
131 return manifest 161 return manifest
132 162
133 163