Commit 83afecbf82a85477a84da476beef2eb9cb38aa0f
1 parent
bd683d62
fix(work-graph): 除入口节点外强制兜底边,堵住 sdd 自判失败卡死的口子
## 起因 e2e-demo 真实任务卡住:org-planner-sdd 自己判 input_underspecified 并点名 org-planner-prd,但这张图里 sdd 没有任何 fail 出边——精确匹配、 兜底边两条路都接不住,节点直接 blocked,靠人工点了「重试」才续上。 ## 根因 fail-fallback-edge 校验(27-P3/P4 §三.7 第五项)只在节点已经画了别的 fail 边时才检查兜底边齐不齐。一条 fail 边都没有的节点完全不受约束—— workgraph-planning/agent.md 原文明说这是「合法设计」。但系统里所有本体 共用 _base 的四段式协议,VERIFY 阶段任何 agent_call 节点都可能自判 失败,不止专职判词节点。四个骨架模板审计后发现 6 个中间节点存在同一个 漏洞(sdd 只是撞上的那一个)。 ## 决策 除图的唯一入口节点外,每个节点必须留一条兜底边(判据是 forward 入度, 不是节点名字模式)。org 层暂不动——node 全是 subgraph 类型,失败来源 机制不同,且套用同一条规则会让三个 org 模板立刻不合法,属于 27-p3b 「验收9」还没收口的链路,留给那边一起做。 中途发现无条件收紧会炸 46 个用例/13 个文件(createWorkGraph 被大量 测试当成建最小图的通用工具)。改法是加 strict 开关,默认关闭(行为 不变),只在 workgraph-planning 真实提交图落库的那个唯一入口 (createWorkGraphSafely)打开。 ## 改动 - work-graph-db.js / graph-engine.js:新规则 + strict 开关 - planner.json / executor-fanout.json:补 6 条兜底边 - workgraph-planning/agent.md:约束三等四处措辞同步 - 新增单测:strict 模式 7 例 + 真实链路回归用例(真实引擎代码+真实 DB+只换最外层 CLI 子进程,复现 sdd 自判失败后引擎自动路由到 prd, 全程零 retryNode 调用)——比重跑一次真实任务更严格,可回归、进 CI 978/978 单测通过。需求与决策记录见 docs/todo/system-design/31-work-graph-fail-fallback-coverage.md。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
10 changed files
with
582 additions
and
46 deletions
docs/todo/system-design/31-work-graph-fail-fallback-coverage.md
0 → 100644
| 1 | +# 31-work-graph-fail-fallback-coverage. work graph 兜底边覆盖不全 —— 需求与实施计划 | |
| 2 | + | |
| 3 | +> **日期**: 2026-08-21 | |
| 4 | +> **状态**: ✅ **全部完成**(T1/T3/T4/T5/T6/T7),全量单测 978/978 通过。见 §七实现记录, | |
| 5 | +> 含两处中途推翻原计划的发现:① 决策 A 的强制范围从"无条件"改成"仅 workgraph-planning | |
| 6 | +> 落库路径生效";② T7 没有重跑真实卡住的那条任务(状态不干净、疑似有并行会话在用), | |
| 7 | +> 改成用真实引擎代码 + 真实 DB + 只换掉最外层 Claude CLI 子进程的确定性回归测试代替—— | |
| 8 | +> 比一次性真跑更严格(可重复验证),且零真实 LLM 花费。 | |
| 9 | +> **触发**: `e2e-demo` 公司真实任务 `T-mt1qx0c4-95bf`(goal `G-mt1qwgt5-6b77`)卡住排障,见 §一 | |
| 10 | + | |
| 11 | +--- | |
| 12 | + | |
| 13 | +## 一、起因:一次真实卡住的复盘 | |
| 14 | + | |
| 15 | +`org-planner` 的 work graph(`planner.json` 骨架)跑到 `org-planner-sdd` 节点时, | |
| 16 | +`sdd-planning` 自己在 `attempt-0.json` 里写下 `fail_reason: input_underspecified`, | |
| 17 | +并点名 `target_node: org-planner-prd`(判断很准——上游 `prd-planning` 那一轮确实只写了 | |
| 18 | +`contract.json`,没写叙事文档,输入薄)。 | |
| 19 | + | |
| 20 | +但这张图里 `org-planner-sdd` **没有任何 `fail` 出边**(骨架里只画了到 `org-planner-benchmark` | |
| 21 | +的 success 边)。引擎选边规则是「精确匹配 → 兜底边 → 死路 blocked」,sdd 自己没有兜底边, | |
| 22 | +它点名的 `org-planner-prd` 只是同一张图里的普通兄弟节点(不构成需要跨层解析的子图边界), | |
| 23 | +两条路都接不住,节点直接 `blocked`,整条任务停摆,直到有人手动点了「重试」。 | |
| 24 | + | |
| 25 | +## 二、现状核查 | |
| 26 | + | |
| 27 | +### 2.1 现有校验的口子 | |
| 28 | + | |
| 29 | +`fail-fallback-edge` 这条规则(`org-graph-service.js` 规则 5 / `work-graph-db.js` | |
| 30 | +`validateFailFallback`)现在的判据是: | |
| 31 | + | |
| 32 | +> **一个节点如果画了 `fail` 出边,那些边里必须至少有一条不带 `failReason` 的兜底边。** | |
| 33 | + | |
| 34 | +**没有画任何 `fail` 出边的节点,完全不受这条规则约束**——`org-planner-sdd` 正是这种情况, | |
| 35 | +它压根没触发校验,不是校验漏判。`workgraph-planning/agent.md`(约束三)现在明文教它: | |
| 36 | +"一个节点没有任何 fail 出边是合法的——那表示『这里失败就该 blocked 等人介入』,是有意的 | |
| 37 | +收敛出口,不是漏画"。 | |
| 38 | + | |
| 39 | +### 2.2 为什么"没有 fail 边"曾经是安全的,现在不是 | |
| 40 | + | |
| 41 | +系统里**所有本体都继承自 `_base` 元 agent 的四段式协议**(RESEARCH → PLAN → EXECUTE → | |
| 42 | +VERIFY,见 27-P3/P4 §九bis)。VERIFY 阶段允许任何节点写一份 `attempt-N.json`, | |
| 43 | +`verdict` 可以是 `attributed`(判定失败并归因)。这不是 `*-validator`/`*-triager` | |
| 44 | +类节点的专属能力——`readNodeProduct()`(`graph-engine.js`)对**所有** `agent_call` | |
| 45 | +节点一视同仁地读这份文件,`requiresAttributionProduct()` 只决定"文件缺失算不算失败", | |
| 46 | +不决定"文件存在时听不听它的"。 | |
| 47 | + | |
| 48 | +也就是说:**任何节点都可能像这次的 sdd 一样,自己判定失败并点名回退目标**, | |
| 49 | +而不只是图里专门安排的判词节点。这是好事(sdd 没有干等到 `org-validator` 才暴露问题, | |
| 50 | +省了一整轮下游判定的成本),但图目前只给"专门的判词节点"配了兜底边, | |
| 51 | +给不了"顺手自判失败"的普通生产节点。 | |
| 52 | + | |
| 53 | +### 2.3 四个骨架模板的实测审计 | |
| 54 | + | |
| 55 | +写脚本核对了 `system/work-graph-templates/*.json` 里每个节点有没有 `fail` 出边、 | |
| 56 | +有没有兜底边(脚本见本节末尾,结果如下): | |
| 57 | + | |
| 58 | +| 模板 | 无 fail 边(当前"合法") | 有 fail 边 + 有兜底边 | 有 fail 边但缺兜底边 | | |
| 59 | +|---|---|---|---| | |
| 60 | +| `executor.json` | `{{orgNode}}-impl`(**图的唯一入口**) | `-test`、`-validator` | 无 | | |
| 61 | +| `executor-fanout.json` | `-split`(**入口**)、`-impl-a`、`-impl-b`、`-merge` | `-test-a`、`-test-b`、`-integration`、`-validator` | 无 | | |
| 62 | +| `planner.json` | `org-planner-brd`(**入口**)、`-prd`、`-sdd`、`-benchmark` | `-validator` | 无 | | |
| 63 | +| `validator.json` | `validator-triager`(**入口**) | `benchmark-validator`、`testdata-validator` | 无 | | |
| 64 | + | |
| 65 | +**关键发现**:四个模板里,"当前合法但没有兜底边"的节点一共 **10 个**: | |
| 66 | + | |
| 67 | +- **4 个是各自图的唯一入口节点**——`{{orgNode}}-impl`(executor)、`{{orgNode}}-split` | |
| 68 | + (executor-fanout)、`org-planner-brd`(planner)、`validator-triager`(validator); | |
| 69 | +- **6 个是有上游可退、却没配退路的中间节点**——`{{orgNode}}-impl-a`、`{{orgNode}}-impl-b`、 | |
| 70 | + `{{orgNode}}-merge`(executor-fanout)+ `org-planner-prd`、`org-planner-sdd`、 | |
| 71 | + `org-planner-benchmark`(planner)。这次真实撞见的 `org-planner-sdd` 正是其中之一。 | |
| 72 | + | |
| 73 | +这不是偶然:**当前模板其实一直是按"图的入口 = 可以不设兜底边"在画的**(4/4 入口节点 | |
| 74 | +都没配兜底边,也从没被判违规),只是这条隐含规则从没被校验强制过,导致 6 个本该配 | |
| 75 | +但没配的中间节点混了进来,静默通过了写入前校验——直到某个中间节点真的自判失败一次, | |
| 76 | +才暴露出没有退路。 | |
| 77 | + | |
| 78 | +审计脚本(可复用于实施后的回归检查): | |
| 79 | + | |
| 80 | +```python | |
| 81 | +import json | |
| 82 | +for f in ['executor.json', 'executor-fanout.json', 'planner.json', 'validator.json']: | |
| 83 | + d = json.load(open(f'system/work-graph-templates/{f}')) | |
| 84 | + nodes = [n['nodeName'] for n in d['nodes']] | |
| 85 | + indeg = {n: 0 for n in nodes} | |
| 86 | + fail_from = {} | |
| 87 | + for e in d['edges']: | |
| 88 | + if e.get('edgeKind', 'forward') == 'forward': | |
| 89 | + indeg[e['to']] += 1 | |
| 90 | + if e.get('condition') == 'fail': | |
| 91 | + fail_from.setdefault(e['from'], []).append(e.get('failReason')) | |
| 92 | + for n in nodes: | |
| 93 | + is_entry = indeg[n] == 0 | |
| 94 | + fails = fail_from.get(n, []) | |
| 95 | + has_fallback = None in fails | |
| 96 | + print(f, n, 'entry' if is_entry else 'non-entry', | |
| 97 | + 'no-fail-edge' if not fails else ('OK' if has_fallback else 'MISSING-FALLBACK')) | |
| 98 | +``` | |
| 99 | + | |
| 100 | +### 2.4 顺带发现:规则 5 有两份重复实现 | |
| 101 | + | |
| 102 | +`org-graph-service.js` 的 `validateGraph()` 内联实现了完整的规则 5(第 295-311 行); | |
| 103 | +`work-graph-db.js` **又**单独写了一份 `validateFailFallback()`(第 795-815 行), | |
| 104 | +且 `validateWorkGraph()` 两份都调(先 `validateGraph()` 拿到含规则 5 的 `base.errors`, | |
| 105 | +再 `.concat(validateFailFallback(es))`)。**这个重复已经在代码注释里被记录为已知现象** | |
| 106 | +(`work-graph-db.js:786-788`:「反向 require 会成环,于是同一条违规会报两行」), | |
| 107 | +不是本次新发现,但改规则 5 的语义时必须同时改两处,否则两份判据会分裂。 | |
| 108 | + | |
| 109 | +--- | |
| 110 | + | |
| 111 | +## 三、决策 | |
| 112 | + | |
| 113 | +### 🔒 决策 A —— 兜底边的强制范围:**除图的唯一入口节点外,一律强制** | |
| 114 | + | |
| 115 | +**判据完全基于图结构(forward 入度),不按节点名字 / agent 类型判断**: | |
| 116 | + | |
| 117 | +> 除了这张图里 forward 入度为 0 的那一个入口节点,**每一个 `agent_call` 节点必须至少有 | |
| 118 | +> 一条不带 `failReason` 的兜底边**——不再要求"先画了别的 fail 边才检查",改成**无条件要求**。 | |
| 119 | + | |
| 120 | +**为什么是"入口节点"而不是"节点名字匹配 validator/triager"**: | |
| 121 | +1. 入口节点没有同图内的上游可退,"没有 fail 边 = 该 blocked 等人介入"对它是唯一自洽的选择; | |
| 122 | + 非入口节点永远有一个"最近的上游"可以退,让它也一起 blocked 没有理由。 | |
| 123 | +2. 判据基于结构(入度),不依赖字符串匹配——不会因为将来起了个不叫 `xxx-validator` 的 | |
| 124 | + 判词节点名字而漏判,也不需要在 `work-graph-db.js` 里另建一份 `graph-engine.js` 才有的 | |
| 125 | + `ATTRIBUTION_REQUIRED_RE` 正则(那会制造第三份"谁是判词节点"的判据副本)。 | |
| 126 | +3. 入度已经是规则 2(`single-entry`)现成算出来的中间结果,改动内聚在同一个函数里。 | |
| 127 | + | |
| 128 | +### 决策 A 的备选(留档,为什么不选) | |
| 129 | + | |
| 130 | +| 方案 | 做法 | 不选的理由 | | |
| 131 | +|---|---|---| | |
| 132 | +| **B1 按节点名字模式** | 复用 `graph-engine.js` 的 `ATTRIBUTION_REQUIRED_RE`,只对匹配 `*-validator`/`*-triager` 的节点要求兜底边 | 这次撞见的正是"名字不匹配却自判失败"的节点(`sdd-planning`);字符串匹配天然漏这一类 | | |
| 133 | +| **B2 收窄谁能自判失败** | 改 `_base` 四段式协议,让非判词节点的 VERIFY 阶段只能报「produced / blocked_infra」,不能报 `attributed`(语义失败),把"判断质量"这件事完全收回给专职判词节点 | 会让 sdd 这类节点即使发现了问题也只能装死等下游 validator 兜一整圈才发现,效率更差;这次 sdd 的归因其实是准的,不该被禁止说出来 | | |
| 134 | +| **B3 允许 self-loop 表达"原地重跑"** | 给入口节点也配一条指向自己的 fail 边 | 结构上没有先例(`edgeKind:backward` 现在的判据是"构成环",from===to 是否会被 acyclic 校验拒绝需要验证),而且"原地重跑"已经有 `infra_flake` 这条独立机制在管,没必要在图形状上再发明一种 | | |
| 135 | + | |
| 136 | +### 🔒 决策 B —— 兜底目标怎么选:**退到最近的上游节点**(沿用 agent.md 已有指导原则) | |
| 137 | + | |
| 138 | +`workgraph-planning/agent.md` 约束三已经写了"选代价最小、爆炸半径最小的那个回退点, | |
| 139 | +通常是最近的执行节点"——这条原则不用改,只是要把它落到具体骨架文件里。fan-in 场景 | |
| 140 | +(一个节点有多条上游)没有单一"最近"时,退到分叉前的公共上游(比如 `merge` 退到 `split`, | |
| 141 | +而不是猜是哪一条分支的锅)。 | |
| 142 | + | |
| 143 | +### 🔒 决策 C —— 四个模板文件按此原则逐一打补丁(本次一并做完,不分期) | |
| 144 | + | |
| 145 | +具体新增边见 §四.2。执行侧改动量可控(6 条新边,2 个文件),不需要拆多轮。 | |
| 146 | + | |
| 147 | +### 🔒 决策 D —— 只收紧 work 层,org 层这次不动 | |
| 148 | + | |
| 149 | +`validateGraph()`(`org-graph-service.js`)是 org / work 两层共用的函数,规则 5 的旧实现 | |
| 150 | +("仅当已有 fail 边才检查")就长在它里面。查过之后发现 **org 层如果套用同一条新规则, | |
| 151 | +会立刻炸**:`system/org-graph-templates/dev.json` 的 `org-coder`、`content.json` 的 | |
| 152 | +`org-writer`、`ops.json` 的 `org-monitor` 都是非入口节点、且都没有 fail 出边——跟这次 | |
| 153 | +撞见的 `org-planner-sdd` 是同一种形状。 | |
| 154 | + | |
| 155 | +**但这次不顺手把 org 层也改了**,理由是两层的失败来源机制不同: | |
| 156 | + | |
| 157 | +- **work 层**:`agent_call` 节点的失败判定直接来自它自己那份 `attempt-N.json` | |
| 158 | + (`readNodeProduct()` 读的就是这个),本次改动验证过——`org-planner-sdd` 的 | |
| 159 | + `fail_reason` 就是这么来的,机制清楚、能写单测、能真跑复现。 | |
| 160 | +- **org 层**:节点全部是 `subgraph` 类型,走的是 `executeSubgraph()` → | |
| 161 | + `deriveChildOutcome()`,节点自己的失败是**子 work graph 收敛之后回传的结果**, | |
| 162 | + 不是直接读 `attempt-N.json`。子图内部本来就有自己的一整套回退/熔断 | |
| 163 | + (backward_count、loop_count 双层熔断),"子图收敛失败后 org 节点该退到哪" | |
| 164 | + 是 27-p3b 文档"验收 9"点名的、**从没有真实验证过**的那条链路。 | |
| 165 | + | |
| 166 | +在验收 9 补完(有真实证据证明"org 层节点失败 → 走没预留的边 → 会不会被合理路由/ | |
| 167 | +不会引擎内部再出别的问题")之前,往 `dev.json` / `content.json` / `ops.json` 里加 | |
| 168 | +兜底边只是"看起来更完整",没有对应的真实验证,反而可能在没人预料到的地方引入新的 | |
| 169 | +回退路径。**留给验收 9 收口时一起做**,这里只记录发现,不动 org 层的模板或校验代码。 | |
| 170 | + | |
| 171 | +--- | |
| 172 | + | |
| 173 | +## 四、改动清单 | |
| 174 | + | |
| 175 | +### 4.1 校验代码(规则语义变更) | |
| 176 | + | |
| 177 | +| 文件 | 改什么 | | |
| 178 | +|---|---| | |
| 179 | +| `server/services/org-graph-service.js` | **不改**(决策 D)。它的规则 5 保持旧语义("仅当已有 fail 边才检查"),继续给 org 层用 | | |
| 180 | +| `server/services/work-graph-db.js` | `validateFailFallback()`(第 795-815 行)升级成新语义,但**默认关闭**(见 §7.1 的中途发现):签名改成 `(nodes, edges, {strict=false})`,`strict:false` 时行为与改动前逐字一致;`strict:true` 时除入口节点外任何节点没有 fail 边、或有 fail 边但没兜底边,都报 `fail-fallback-edge`。`validateWorkGraph(nodes, edges, {strictFailFallback})` / `createWorkGraph({..., strictFailFallback})` 透传这个开关。**org 层不受影响**(`org-graph-service.validateGraph` 那份旧规则 5 仍是旧语义、仍会跑一遍、仍可能报重复错误,见 §2.4,这次不修) | | |
| 181 | +| `server/services/graph-engine.js` | `createWorkGraphSafely()`(workgraph-planning 提交图落库前的唯一校验点)调 `createWorkGraph` 时显式传 `strictFailFallback: true`——**这是全仓库唯一默认打开新语义的调用点** | | |
| 182 | +| 错误文案 | `validateFailFallback` 区分"完全没有 fail 边"和"有 fail 边但兜底缺失"两种情况的 `detail`,方便 `workgraph-planning` 读错误信息时一眼判断是"忘了配"还是"配的边不完整" | | |
| 183 | + | |
| 184 | +### 4.2 骨架模板(新增 6 条兜底边) | |
| 185 | + | |
| 186 | +| 文件 | 新增边 | | |
| 187 | +|---|---| | |
| 188 | +| `system/work-graph-templates/planner.json` | `org-planner-prd --fail(backward)--> org-planner-brd`;`org-planner-sdd --fail(backward)--> org-planner-prd`;`org-planner-benchmark --fail(backward)--> org-planner-sdd` | | |
| 189 | +| `system/work-graph-templates/executor-fanout.json` | `{{orgNode}}-impl-a --fail(backward)--> {{orgNode}}-split`;`{{orgNode}}-impl-b --fail(backward)--> {{orgNode}}-split`;`{{orgNode}}-merge --fail(backward)--> {{orgNode}}-split` | | |
| 190 | +| `executor.json` / `validator.json` | **不用改**——审计显示两者已经完全符合新规则(唯一的"无 fail 边"节点都是各自的入口) | | |
| 191 | + | |
| 192 | +### 4.3 `workgraph-planning/agent.md`(提示词同步) | |
| 193 | + | |
| 194 | +| 位置 | 改什么 | | |
| 195 | +|---|---| | |
| 196 | +| 约束三整节 | 删掉"一个节点没有任何 fail 出边是合法的"这句无条件表述,改成"**只有图的唯一入口节点**可以没有 fail 出边,其余节点必须至少留一条兜底边";`validator-triager` 的例子照旧引用,但把理由从"它是判词节点"改成"它是 `validator.json` 的入口节点" | | |
| 197 | +| Step 5 自查表第 5 项 | 同步措辞 | | |
| 198 | +| 强制思维隔离清单第 5 条 | 同步措辞("每个有 fail 出边的节点" → "除入口外的每个节点") | | |
| 199 | +| `errors[].rule='fail-fallback-edge'` 那一行的处理指引 | 提醒"没有 fail 边也会触发这条规则"(现在的表述隐含"节点已经有 fail 边"的前提,新规则下要更新) | | |
| 200 | + | |
| 201 | +### 4.4 不在本次范围内 | |
| 202 | + | |
| 203 | +- `org-coder` 系列以外、`system/work-graph-templates/` 之外由 `workgraph-planning` | |
| 204 | + 临时手画的图(真实任务里它有权限裁剪/组合骨架)——**这些图不受静态模板审计覆盖**, | |
| 205 | + 只能靠写入前校验兜底,属于决策 A/B 已经覆盖的范围,不用额外处理。 | |
| 206 | +- 决策 A 备选 B2(收窄谁能自判失败)——已否决,不做。 | |
| 207 | +- §2.4 的重复实现去重——已知技术债,不在本次范围。 | |
| 208 | + | |
| 209 | +--- | |
| 210 | + | |
| 211 | +## 五、任务分解 | |
| 212 | + | |
| 213 | +| # | 任务 | 文件 | 验收标准 | 状态 | | |
| 214 | +|---|---|---|---|---| | |
| 215 | +| T1 | `validateFailFallback` 改成"除入口外无条件要求兜底边";**默认关闭,`strict` 开关**(见 §7.1),仅 `createWorkGraphSafely` 打开 | `work-graph-db.js` + `graph-engine.js` | 新单测:非 strict 保持旧行为;strict 下入口节点无 fail 边→通过、非入口无 fail 边→报错、非入口有 fail 边无兜底→报错(同旧规则那半)、补齐后通过、多入口不豁免、`createWorkGraph` 默认路径仍是旧行为 | ✅ 已完成,7 例新单测全过 | | |
| 216 | +| T3 | `planner.json` 补 3 条兜底边 | 模板文件 | 用 §2.3 审计脚本重跑,`planner.json` 零 `MISSING-FALLBACK` 且零意外的 `no-fail-edge`(只剩入口节点) | ✅ 已完成 | | |
| 217 | +| T4 | `executor-fanout.json` 补 3 条兜底边 | 模板文件 | 同上,脚本对该文件重跑 | ✅ 已完成 | | |
| 218 | +| T5 | `workgraph-planning/agent.md` 同步四处措辞 | agent.md | 人工核对约束三 / Step5 自查表 / 思维隔离清单 / errors 处理指引 四处都改了,agent.md 前 2000 字符仍自足(决策 B.2 硬约束,改动只加在约束三,不影响文件头部) | ✅ 已完成 | | |
| 219 | +| T6 | 回归:全量单测 | — | `npm run test:unit` 全绿 | ✅ 970/970 | | |
| 220 | +| T7 | **真实复现验收**:验证一个像 `org-planner-sdd` 那样自判 `input_underspecified` 的节点,**不需要人工点重试**,引擎能自动打回上游 | `graph-real-chain-smoke.test.js` 新增用例⑦ | `backwardEdgeId` 命中新补的那条边、`activated` 里出现上游节点、判词真的带到位、全程零 `retryNode` 调用 | ✅ 已完成,见 §7.3 | | |
| 221 | + | |
| 222 | +T3/T4 互相独立、可与 T1 并行。T5 依赖 T1/T3/T4 的规则定型(措辞要和实际校验行为对齐)。T6/T7 最后做。 | |
| 223 | + | |
| 224 | +--- | |
| 225 | + | |
| 226 | +## 六、验证计划 | |
| 227 | + | |
| 228 | +- 单测:T1/T2 的三个用例是核心,另外补一条"入口节点判定"本身的用例 | |
| 229 | + (多入口图应该在规则 2 就先被拒,不会走到规则 5——确认两条规则的报错顺序不冲突)。 | |
| 230 | +- 静态回归:§2.3 的审计脚本存一份到 `server/tests/unit/` 之外的 scratch 位置 | |
| 231 | + (或者直接写成一条测试,遍历 `system/work-graph-templates/*.json`,断言"无 fail 边的节点 | |
| 232 | + 只能是入口"),防止以后新加模板文件又踩同一个坑。 | |
| 233 | +- 真实验收(T7):这是本次改动**唯一真正证明问题解决**的一步——之前的手动重试证明了 | |
| 234 | + "补一条边之后流程能走通",但没有证明"下次系统自己会补这条边";T7 要看到的是 | |
| 235 | + **自动**触发,不是人工点一下。**尚未做**,见 §七。 | |
| 236 | + | |
| 237 | +--- | |
| 238 | + | |
| 239 | +## 七、实现记录 | |
| 240 | + | |
| 241 | +### 7.1 T1 中途发现:无条件收紧会炸 46 个用例 / 13 个文件 | |
| 242 | + | |
| 243 | +按原计划把 `validateFailFallback` 改成无条件强制后,`npx vitest run server/tests/unit` | |
| 244 | +从 970 掉到 **46 个用例失败、13 个文件**(`graph-engine.test.js` 一个文件就占 11 个)。 | |
| 245 | + | |
| 246 | +**根因**:`work-graph-db.createWorkGraph`/`validateWorkGraph` 不只是"workgraph-planning | |
| 247 | +提交图的校验口子",还被 28 个测试文件当成**通用建图工具**在用——测预算耗尽、双层熔断、 | |
| 248 | +跨层地址、边界守卫这些跟"图设计得完不完善"毫无关系的机制时,图基本是"a→b→c,只给要测 | |
| 249 | +的那条边配 fail"的最小形状,非入口节点普遍没有自己的兜底边。这不是那些用例写错了, | |
| 250 | +是"图结构要完整"这个新诉求和"内部机制测试用最小图"这个通用测试模式本来就不是一回事。 | |
| 251 | + | |
| 252 | +**改法(已实施)**:`validateFailFallback` / `validateWorkGraph` / `createWorkGraph` | |
| 253 | +都加一个可选参数(`strict` / `strictFailFallback`),**默认 `false`**,行为与改动前完全一致; | |
| 254 | +只有 `graph-engine.js` 的 `createWorkGraphSafely`——workgraph-planning 交回的图真正落库前的 | |
| 255 | +唯一校验点,也是它 Step 7 那张 `errors` 表实际读到的来源——显式传 `strict: true`。 | |
| 256 | +`work-graph-skeletons.test.js`(测四个维护中的模板文件)同样显式传 `true`, | |
| 257 | +因为那些模板就是代表 workgraph-planning 真实会提交的图。 | |
| 258 | + | |
| 259 | +**结果**:§三决策 A 的判据("除入口外无条件要求兜底边")**不变**,改变的只是它的生效范围—— | |
| 260 | +从"全仓库任何调用 `createWorkGraph` 的地方"收窄成"workgraph-planning 真实提交图的那一条路径"。 | |
| 261 | +这个范围本来就是决策 A 真正要覆盖的场景,收窄后没有削弱决策本身,只是避免了拿一个内部工具函数 | |
| 262 | +的通用性开刀。§四.1 的改动清单据此更新(原计划"改一处、影响全部调用方",实际是"改一处、 | |
| 263 | +只有一个调用方选择更严格的行为")。 | |
| 264 | + | |
| 265 | +全量单测 970/970 通过(含新增的 strict 模式用例)。 | |
| 266 | + | |
| 267 | +### 7.2 已完成 | |
| 268 | + | |
| 269 | +- T1:`work-graph-db.js` 的 `validateFailFallback` 升级为 `(nodes, edges, {strict})`, | |
| 270 | + `validateWorkGraph`/`createWorkGraph` 透传 `strictFailFallback`;`graph-engine.js` 的 | |
| 271 | + `createWorkGraphSafely` 显式传 `strict: true`。新增单测 7 例(非 strict 保持旧行为、 | |
| 272 | + strict 下入口豁免/非入口无 fail 边违规/有 fail 边无兜底违规/补齐后通过/多入口不豁免/ | |
| 273 | + 真实落库路径默认即 strict)。 | |
| 274 | +- T3/T4:`planner.json` 补 3 条兜底边(prd→brd、sdd→prd、benchmark→sdd); | |
| 275 | + `executor-fanout.json` 补 3 条(impl-a/impl-b/merge 均退 split)。 | |
| 276 | + `work-graph-skeletons.test.js` 在 `strictFailFallback: true` 下四个模板全部通过。 | |
| 277 | +- T5:`workgraph-planning/agent.md` 四处措辞同步(约束三整节重写、五项校验第 5 项、 | |
| 278 | + Step 5 示例表、Step 7 errors 表、强制思维隔离清单第 5 条);核对过前 2000 字符 | |
| 279 | + (身份定位 + 工作流概览)未被触及,改动全部在约束三及之后(第 443 行开始)。 | |
| 280 | +- T6:`npx vitest run server/tests/unit` 970/970 通过(T7 加完之后是 978/978)。 | |
| 281 | + | |
| 282 | +### 7.3 T7:真实验收改走确定性回归测试,不重跑那条真实卡住的任务 | |
| 283 | + | |
| 284 | +**先确认过原计划**:`T-mt1qx0c4-95bf` 当前状态是 `cancelled`,但它的 `run_d06717f10e5d` | |
| 285 | +却仍显示 `runStatus: running`——状态不一致,疑似有其他会话在同时操作这个任务(见对话 | |
| 286 | +记录里"这次重试动作看起来不是我这边触发的"那次提醒)。这个任务不安全重用;真要验, | |
| 287 | +只能起一条新任务,而且没法保证新任务会恰好复现"prd 只写 contract.json 导致 sdd 判 | |
| 288 | +input_underspecified"这个具体形状——花几分钟到几十分钟真实 LLM 调用,可能什么都验不出来。 | |
| 289 | + | |
| 290 | +**改法**:在 `graph-real-chain-smoke.test.js`("零注入,只换掉最外层 Claude CLI 子进程" | |
| 291 | +这条已有的真实链路 smoke 惯例)里加了用例⑦。图形状是 planner.json 里 `prd→sdd` 那两个 | |
| 292 | +节点的最小复现(`prd --success--> sdd`,`sdd --fail(无 failReason)--> prd`——正是这次 | |
| 293 | +新补的兜底边),`sdd-planning` 的 mock fixture 从"总是成功"改成精确复现真实事故的归因 | |
| 294 | +(`verdict:attributed, fail_reason:input_underspecified`)。**这份 fixture 改前没有 | |
| 295 | +任何一条已跑用例真的用 mock CLI 走过 sdd-planning**(实查过,全仓库只在别处当字符串 | |
| 296 | +引用),改动没有副作用。 | |
| 297 | + | |
| 298 | +真跑 `engine.nextStep()` 两轮(真实 `advanceOne`/`settleNode`/`routeFailure` 代码路径, | |
| 299 | +真实 DB),验到的东西: | |
| 300 | +1. `backwardEdgeId` 命中的确实是新补的那条边(`sdd→prd`,`fail`,无 `failReason`); | |
| 301 | +2. `activated` 数组里出现了 `prd`——**全程没有调用过 `retryNode`**,是引擎自己点亮的; | |
| 302 | +3. `prd` 那一行的 `remediation_note` 真的收到了 sdd 归因文件里的话("prd 只写了 | |
| 303 | + contract.json,没有叙事文档"),不是丢在 sdd 自己那行没人看得到; | |
| 304 | +4. sdd 自己的 `attempt-0.json` 真的写着 `input_underspecified`——不是引擎凭空发明的。 | |
| 305 | + | |
| 306 | +**比一次性真跑更严格的地方**:这条测试可重复运行、可回归、进 CI,下次有人改动 | |
| 307 | +`routeFailure`/`settleNode` 的路由逻辑不小心把这条路径改坏,会立刻红;一次性的真实 | |
| 308 | +任务重跑就算当时证明了问题解决,也没有任何机制防止它未来悄悄失效。 | ... | ... |
fixtures/devloop-mock/sdd-planning.json
| 1 | 1 | { |
| 2 | - "_doc": "P3 mock fixture · work graph 里的 wg-build 节点本体。被 wg-check 回退时会再跑一次(attempt+1),fixture 用 {attempt} 占位符跨轮复用同一份,不需要 sequence。", | |
| 2 | + "_doc": "31-work-graph-fail-fallback-coverage §7.3 T7 真实链路验证:sdd 自判 input_underspecified,验证引擎沿新补的兜底边(sdd --fail--> prd)自动打回上游,不需要人工重试。改前这份 fixture 一直报 no_prior_failure(成功),实查全仓库没有任何一条已跑的用例真的用 LEMONBOSS_CLAUDE_MODE=mock 走过 sdd-planning 这个 agent 名(只在别处当字符串引用),改成失败不影响任何既有用例。原样保留了这份历史:这个 fixture 曾经代表 DevLoop 的 wg-build 节点,现在专用于 graph-real-chain-smoke 这条新场景。", | |
| 3 | 3 | "chunks": [ |
| 4 | - "[mock] sdd-planning 读 prd.md 与上一轮 remediation_note(若有)。\n", | |
| 5 | - "产出设计与实现,写 sdd.md + contract.json。\n" | |
| 4 | + "[mock] sdd-planning 读 prd.md,发现约束不够写不下去。\n", | |
| 5 | + "上游 prd 只有结构化导出,没有叙事文档,判定 input_underspecified。\n" | |
| 6 | 6 | ], |
| 7 | - "result": "mock-ok sdd-planning:已产出 sdd.md 与 contract.json。", | |
| 7 | + "result": "mock-fail sdd-planning:input_underspecified,退回 prd。", | |
| 8 | 8 | "files": [ |
| 9 | 9 | { |
| 10 | 10 | "path": "attempt-{attempt}.json", |
| 11 | - "content": "{\n \"schema\": \"validator-triager/v1\",\n \"verdict\": \"no_prior_failure\",\n \"fail_reason\": null,\n \"remediation_note\": null\n}\n" | |
| 12 | - }, | |
| 13 | - { | |
| 14 | - "path": "sdd.md", | |
| 15 | - "content": "# SDD(mock)\n\n单模块实现,对外暴露一个函数,错误输入返回明确错误。\n" | |
| 16 | - }, | |
| 17 | - { | |
| 18 | - "path": "contract.json", | |
| 19 | - "content": "{\n \"exports\": {\n \"fn\": [\n { \"name\": \"handle\", \"input\": \"string\", \"output\": \"string\" }\n ]\n }\n}\n" | |
| 11 | + "content": "{\n \"schema\": \"validator-triager/v1\",\n \"verdict\": \"attributed\",\n \"fail_reason\": \"input_underspecified\",\n \"remediation_note\": \"prd 只写了 contract.json,没有叙事文档,补齐后重跑\"\n}\n" | |
| 20 | 12 | } |
| 21 | 13 | ] |
| 22 | 14 | } | ... | ... |
server/services/graph-engine.js
| ... | ... | @@ -1103,6 +1103,9 @@ function createWorkGraphSafely(ctx, draft) { |
| 1103 | 1103 | okrNodeId: ctx.run.okr_node_id, |
| 1104 | 1104 | nodes: draft.nodes, |
| 1105 | 1105 | edges: draft.edges || [], |
| 1106 | + // 31-work-graph-fail-fallback-coverage 决策 A:这是 workgraph-planning 真实提交的图 | |
| 1107 | + // 落库前的唯一校验点,也是它 Step 7 那张 errors 表实际读到的来源——只在这里收紧。 | |
| 1108 | + strictFailFallback: true, | |
| 1106 | 1109 | }); |
| 1107 | 1110 | return created.ok ? created : { ok: false, errors: created.errors }; |
| 1108 | 1111 | } catch (e) { | ... | ... |
server/services/work-graph-db.js
| ... | ... | @@ -795,20 +795,46 @@ const canTransitionRun = (from, to) => canTransition(RUN_TRANSITIONS, from, to); |
| 795 | 795 | // ──────────────────────────────────────────────────────────────── |
| 796 | 796 | |
| 797 | 797 | /** |
| 798 | - * 第五项校验(§四.D.2.2 末尾):任何有 fail 出边的节点,必须至少留一条 | |
| 799 | - * fail_reason IS NULL 的兜底边。否则归因产出一个图上没有对应边的取值时会落到死路, | |
| 800 | - * 表现为「莫名其妙 blocked」。 | |
| 801 | - * | |
| 802 | - * ⚠️ 现状:org 层**没有**复用本函数,而是在 org-graph-service.validateGraph 的「规则 5」里 | |
| 803 | - * 内联实现了同一条规则——本文件 require org-graph-service(validateGraph),反向 require 会成环。 | |
| 804 | - * 于是 validateWorkGraph 走的是「validateGraph 的规则 5 + 本函数」两份实现,同一条违规会报两行; | |
| 805 | - * 两份逻辑目前语义一致,但改一处漏一处就会漂移。要收敛的话把规则抽到一个无依赖的小模块里, | |
| 806 | - * 两边都 require 它(见 27-P3P4 §三.7 的风险记录)。 | |
| 807 | - * | |
| 808 | - * @param {Array<{from, to, condition, failReason?}>} edges from/to 用什么标识都行(只做分组) | |
| 798 | + * 第五项校验(§四.D.2.2 末尾 + 31-work-graph-fail-fallback-coverage): | |
| 799 | + * **除图的唯一入口节点外**,每个节点都必须至少留一条 fail_reason IS NULL 的兜底边。 | |
| 800 | + * | |
| 801 | + * 判据从 27-P3P4/T14 的旧版收紧过一次(2026-08-21,见 31 号文档 §三决策 A): | |
| 802 | + * 旧版只在节点**已经画了别的 fail 边**时才检查兜底边齐不齐;新版不再要求这个前提—— | |
| 803 | + * 一条 fail 边都没有的非入口节点,现在同样算违规。 | |
| 804 | + * | |
| 805 | + * **为什么按"入口节点"豁免,不按节点名字/agent 类型**:本仓库所有本体共用 `_base` 的 | |
| 806 | + * RESEARCH→PLAN→EXECUTE→VERIFY 四段式协议,VERIFY 阶段任何 agent_call 节点都可能写一份 | |
| 807 | + * `attempt-N.json` 自判失败(`graph-engine.readNodeProduct` 对所有节点一视同仁地读它, | |
| 808 | + * 不限于名字里带 `validator`/`triager` 的判词节点)——这次撞见的 `sdd-planning` 正是 | |
| 809 | + * 普通生产节点自判失败的例子。只有图的入口节点没有同图内的上游可退,"没有 fail 边= | |
| 810 | + * 该 blocked 等人介入"对它才是唯一自洽的选择;其余节点永远有"最近的上游"可以退。 | |
| 811 | + * | |
| 812 | + * ⚠️ **这次只收紧了 work 层**(本函数),org 层的 `org-graph-service.validateGraph` | |
| 813 | + * 规则 5 仍是旧语义——org 层节点全部是 subgraph 类型,失败来源机制不同(子图收敛后回传, | |
| 814 | + * 不是自己的 attempt-N.json),且套用同一条新规则会让 `dev.json`/`content.json`/`ops.json` | |
| 815 | + * 立刻不合法(`org-coder`/`org-writer`/`org-monitor` 都是非入口、无 fail 边),这属于 | |
| 816 | + * 27-p3b「验收 9」还没收口的那条链路,见 31 号文档决策 D,这里不动。 | |
| 817 | + * | |
| 818 | + * ⚠️ 两处规则不是同一份实现(见旧注释与 27-P3P4 §三.7 的风险记录):`validateWorkGraph` | |
| 819 | + * 会先跑一遍 `validateGraph` 的旧版规则 5(可能报一次),再跑本函数的新版规则 5 | |
| 820 | + * (可能对同一节点再报一次,且措辞不同)——这次不做去重,只保证本函数语义正确。 | |
| 821 | + * | |
| 822 | + * ⚠️ **`strict` 默认 `false`(保持旧语义)**:全仓 28 个测试文件在拿 `createWorkGraph` | |
| 823 | + * 当测试夹具的通用建图工具(测预算/熔断/回退地址这些跟"图设计得完不完善"毫无关系的机制), | |
| 824 | + * 图大多是"a→b→c 只给要测的那条边配 fail"的最小形状,非入口节点普遍没有自己的兜底边。 | |
| 825 | + * 无条件收紧这条规则时实测**炸了 46 个用例、13 个文件**——那不是这些用例错了, | |
| 826 | + * 是这条新规则的"结构完整性"诉求跟"内部机制测试用最小图"这个通用测试模式天然不兼容。 | |
| 827 | + * 真正需要新语义的只有一个调用点:`graph-engine.createWorkGraphSafely` | |
| 828 | + * (workgraph-planning 交回的图落库前的校验,也是它 Step 7 那张 errors 表实际读到的来源), | |
| 829 | + * 那里显式传 `strict: true`。其余调用方(含测试)不传,拿到的是原来那条更宽松的规则, | |
| 830 | + * 行为不变。 | |
| 831 | + * | |
| 832 | + * @param {Array<{nodeName, systemAgentId?, nodeKind?}>} nodes 用于算 forward 入度、找入口 | |
| 833 | + * @param {Array<{from, to, condition, failReason?, edgeKind?}>} edges | |
| 834 | + * @param {{strict?: boolean}} [opts] | |
| 809 | 835 | * @returns {Array<{rule: string, detail: string}>} |
| 810 | 836 | */ |
| 811 | -function validateFailFallback(edges) { | |
| 837 | +function validateFailFallback(nodes, edges, { strict = false } = {}) { | |
| 812 | 838 | const errors = []; |
| 813 | 839 | const byFrom = new Map(); // fromKey -> { hasFail: bool, hasFallback: bool } |
| 814 | 840 | for (const e of edges || []) { |
| ... | ... | @@ -819,11 +845,47 @@ function validateFailFallback(edges) { |
| 819 | 845 | if (reason === null || reason === undefined || reason === '') slot.hasFallback = true; |
| 820 | 846 | byFrom.set(e.from, slot); |
| 821 | 847 | } |
| 822 | - for (const [from, slot] of byFrom) { | |
| 823 | - if (slot.hasFail && !slot.hasFallback) { | |
| 848 | + | |
| 849 | + if (!strict) { | |
| 850 | + // 旧语义:只查已经画了 fail 边、但兜底缺失的节点。 | |
| 851 | + for (const [from, slot] of byFrom) { | |
| 852 | + if (slot.hasFail && !slot.hasFallback) { | |
| 853 | + errors.push({ | |
| 854 | + rule: 'fail-fallback-edge', | |
| 855 | + detail: `节点 ${from} 有 fail 出边,但没有一条 fail_reason 为空的兜底边,归因结果不匹配任何边时会直接死路`, | |
| 856 | + }); | |
| 857 | + } | |
| 858 | + } | |
| 859 | + return errors; | |
| 860 | + } | |
| 861 | + | |
| 862 | + // 新语义:除入口节点外,一条 fail 边都没有也算违规。 | |
| 863 | + const names = (nodes || []).map((n) => (typeof n === 'string' ? n : n && n.nodeName)).filter(Boolean); | |
| 864 | + if (!names.length) return errors; | |
| 865 | + | |
| 866 | + const indeg = new Map(names.map((n) => [n, 0])); | |
| 867 | + for (const e of edges || []) { | |
| 868 | + if ((e.edgeKind || 'forward') !== 'forward') continue; | |
| 869 | + if (indeg.has(e.to)) indeg.set(e.to, indeg.get(e.to) + 1); | |
| 870 | + } | |
| 871 | + const entries = names.filter((n) => indeg.get(n) === 0); | |
| 872 | + // 入口不唯一(0 个或多个)时不豁免任何节点——那本身已经是规则 2 single-entry 的违规, | |
| 873 | + // 这里没有"唯一入口"可指,宁可多报一点噪声也不要悄悄放过该查的节点。 | |
| 874 | + const exempt = entries.length === 1 ? new Set(entries) : new Set(); | |
| 875 | + | |
| 876 | + for (const name of names) { | |
| 877 | + if (exempt.has(name)) continue; | |
| 878 | + const slot = byFrom.get(name); | |
| 879 | + if (!slot) { | |
| 880 | + errors.push({ | |
| 881 | + rule: 'fail-fallback-edge', | |
| 882 | + detail: `节点 ${name} 没有任何 fail 出边——除图的唯一入口节点外,每个节点都必须至少留一条` | |
| 883 | + + '不带 failReason 的兜底边,否则它自判失败时(VERIFY 阶段写出 attempt-N.json)无处可退,直接 blocked', | |
| 884 | + }); | |
| 885 | + } else if (!slot.hasFallback) { | |
| 824 | 886 | errors.push({ |
| 825 | 887 | rule: 'fail-fallback-edge', |
| 826 | - detail: `节点 ${from} 有 fail 出边,但没有一条 fail_reason 为空的兜底边,归因结果不匹配任何边时会直接死路`, | |
| 888 | + detail: `节点 ${name} 有 fail 出边,但没有一条 fail_reason 为空的兜底边,归因结果不匹配任何边时会直接死路`, | |
| 827 | 889 | }); |
| 828 | 890 | } |
| 829 | 891 | } |
| ... | ... | @@ -836,9 +898,10 @@ function validateFailFallback(edges) { |
| 836 | 898 | * |
| 837 | 899 | * @param {Array} nodes [{nodeName, systemAgentId?, nodeKind?}] 或 ['nodeName', ...] |
| 838 | 900 | * @param {Array} edges [{from, to, condition?, failReason?, edgeKind?}],from/to 是 nodeName |
| 901 | + * @param {{strictFailFallback?: boolean}} [opts] 见 `validateFailFallback` 的 `strict` 参数说明 | |
| 839 | 902 | * @returns {{ok: boolean, errors: Array<{rule: string, detail: string}>}} |
| 840 | 903 | */ |
| 841 | -function validateWorkGraph(nodes, edges) { | |
| 904 | +function validateWorkGraph(nodes, edges, { strictFailFallback = false } = {}) { | |
| 842 | 905 | const ns = normalizeNodes(nodes); |
| 843 | 906 | const es = normalizeEdges(edges); |
| 844 | 907 | |
| ... | ... | @@ -848,7 +911,7 @@ function validateWorkGraph(nodes, edges) { |
| 848 | 911 | es |
| 849 | 912 | ); |
| 850 | 913 | |
| 851 | - const errors = base.errors.concat(validateFailFallback(es)); | |
| 914 | + const errors = base.errors.concat(validateFailFallback(ns, es, { strict: strictFailFallback })); | |
| 852 | 915 | return { ok: errors.length === 0, errors }; |
| 853 | 916 | } |
| 854 | 917 | |
| ... | ... | @@ -903,11 +966,12 @@ function createWorkGraph({ |
| 903 | 966 | okrNodeId = null, |
| 904 | 967 | nodes = [], |
| 905 | 968 | edges = [], |
| 969 | + strictFailFallback = false, | |
| 906 | 970 | } = {}) { |
| 907 | 971 | const ns = normalizeNodes(nodes); |
| 908 | 972 | const es = normalizeEdges(edges); |
| 909 | 973 | |
| 910 | - const verdict = validateWorkGraph(ns, es); | |
| 974 | + const verdict = validateWorkGraph(ns, es, { strictFailFallback }); | |
| 911 | 975 | if (!verdict.ok) return { ok: false, errors: verdict.errors }; |
| 912 | 976 | |
| 913 | 977 | const bad = ns.find((n) => !NODE_KINDS.includes(n.nodeKind)); | ... | ... |
server/tests/unit/graph-engine.test.js
| ... | ... | @@ -876,6 +876,76 @@ describe('graph-engine', () => { |
| 876 | 876 | }); |
| 877 | 877 | |
| 878 | 878 | // ────────────────────────────────────────────────────────────── |
| 879 | + // 31-work-graph-fail-fallback-coverage 决策 A:strict 模式——除入口节点外, | |
| 880 | + // 一条 fail 边都没有也算违规(不再要求"先画了别的 fail 边"这个前提)。 | |
| 881 | + // 默认(非 strict)行为保持不变,上面那组用例已经覆盖;这里只测 strict:true 的新增部分。 | |
| 882 | + // ────────────────────────────────────────────────────────────── | |
| 883 | + describe('fail-fallback-edge 的 strict 模式(31 号文档决策 A,仅 workgraph-planning 落库路径生效)', () => { | |
| 884 | + // a 是唯一入口;b 有上游可退却一条 fail 边都没有;c 有 fail 边但没兜底 | |
| 885 | + const NODES = ['a', 'b', 'c']; | |
| 886 | + const baseEdges = [ | |
| 887 | + { from: 'a', to: 'b' }, | |
| 888 | + { from: 'b', to: 'c' }, | |
| 889 | + ]; | |
| 890 | + | |
| 891 | + it('非 strict:b 没有 fail 边不算违规(保持旧行为)', () => { | |
| 892 | + const v = wdb.validateWorkGraph(NODES, baseEdges); | |
| 893 | + expect(v.errors.map((e) => e.rule)).not.toContain('fail-fallback-edge'); | |
| 894 | + }); | |
| 895 | + | |
| 896 | + it('strict:入口节点 a 没有 fail 边仍然合法', () => { | |
| 897 | + const v = wdb.validateWorkGraph(NODES, baseEdges, { strictFailFallback: true }); | |
| 898 | + const detail = v.errors.filter((e) => e.rule === 'fail-fallback-edge').map((e) => e.detail).join('\n'); | |
| 899 | + expect(detail).not.toContain('节点 a '); | |
| 900 | + }); | |
| 901 | + | |
| 902 | + it('strict:非入口节点 b 一条 fail 边都没有 → 违规', () => { | |
| 903 | + const v = wdb.validateWorkGraph(NODES, baseEdges, { strictFailFallback: true }); | |
| 904 | + expect(v.ok).toBe(false); | |
| 905 | + const bEdge = v.errors.find((e) => e.rule === 'fail-fallback-edge' && e.detail.includes('节点 b ')); | |
| 906 | + expect(bEdge).toBeTruthy(); | |
| 907 | + expect(bEdge.detail).toContain('没有任何 fail 出边'); | |
| 908 | + }); | |
| 909 | + | |
| 910 | + it('strict:非入口节点 c 有 fail 边但没兜底 → 违规(与旧语义相同的那半)', () => { | |
| 911 | + const edges = [...baseEdges, { from: 'c', to: 'b', condition: 'fail', failReason: 'impl_bug', edgeKind: 'backward' }]; | |
| 912 | + const v = wdb.validateWorkGraph(NODES, edges, { strictFailFallback: true }); | |
| 913 | + const cEdge = v.errors.find((e) => e.rule === 'fail-fallback-edge' && e.detail.includes('节点 c ')); | |
| 914 | + expect(cEdge).toBeTruthy(); | |
| 915 | + expect(cEdge.detail).toContain('没有一条 fail_reason 为空的兜底边'); | |
| 916 | + }); | |
| 917 | + | |
| 918 | + it('strict:b、c 都补上兜底边后通过', () => { | |
| 919 | + const edges = [ | |
| 920 | + ...baseEdges, | |
| 921 | + { from: 'b', to: 'a', condition: 'fail', edgeKind: 'backward' }, | |
| 922 | + { from: 'c', to: 'b', condition: 'fail', edgeKind: 'backward' }, | |
| 923 | + ]; | |
| 924 | + const v = wdb.validateWorkGraph(NODES, edges, { strictFailFallback: true }); | |
| 925 | + expect(v.ok).toBe(true); | |
| 926 | + }); | |
| 927 | + | |
| 928 | + it('strict:多入口图不豁免任何节点(single-entry 已经拒了,规则 5 不该悄悄放过)', () => { | |
| 929 | + // a、b 都是入口(forward 入度为 0),任何一个都没有 fail 边 | |
| 930 | + const edges = [{ from: 'a', to: 'c' }, { from: 'b', to: 'c' }]; | |
| 931 | + const v = wdb.validateWorkGraph(NODES, edges, { strictFailFallback: true }); | |
| 932 | + expect(v.errors.map((e) => e.rule)).toContain('single-entry'); | |
| 933 | + const noFailback = v.errors.filter((e) => e.rule === 'fail-fallback-edge'); | |
| 934 | + expect(noFailback.some((e) => e.detail.includes('节点 a '))).toBe(true); | |
| 935 | + expect(noFailback.some((e) => e.detail.includes('节点 b '))).toBe(true); | |
| 936 | + }); | |
| 937 | + | |
| 938 | + it('真实落库路径(createWorkGraphSafely 等价调用)默认就是 strict:' | |
| 939 | + + 'workgraph-planning 交回一张没给非入口节点配兜底边的图会被拒', () => { | |
| 940 | + const companyId = nextCompany(); | |
| 941 | + ensureAgents(NODES); | |
| 942 | + const res = wdb.createWorkGraph({ companyId, nodes: NODES, edges: baseEdges, strictFailFallback: true }); | |
| 943 | + expect(res.ok).toBe(false); | |
| 944 | + expect(res.errors.map((e) => e.rule)).toContain('fail-fallback-edge'); | |
| 945 | + }); | |
| 946 | + }); | |
| 947 | + | |
| 948 | + // ────────────────────────────────────────────────────────────── | |
| 879 | 949 | // §六 验收 10:预算扣完 → 整棵 run 树挂起 |
| 880 | 950 | // ────────────────────────────────────────────────────────────── |
| 881 | 951 | describe('任务级总预算(§四.A.3 / §六 验收 10)', () => { | ... | ... |
server/tests/unit/graph-real-chain-smoke.test.js
| ... | ... | @@ -255,6 +255,85 @@ describe('銵頝 smoke嚗瘜典嚗 CLI 摮 |
| 255 | 255 | fs.rmSync(path.join(__dirname, '../../../system/agents', VALIDATOR, CO), { recursive: true, force: true }); |
| 256 | 256 | }, 60_000); |
| 257 | 257 | |
| 258 | + it(' 31-work-graph-fail-fallback-coverage T7嚗憭梯揖嚗' | |
| 259 | + + '撘窒銵亦器虜 retryNode嚗閬犖撌亦', async () => { | |
| 260 | + // 憭 e2e-demo 甈∠雿撠耦嚗rd嚗嚗--success--> sdd嚗 | |
| 261 | + // sdd 撌勗 fail_reason=input_underspecified 瘝⊥移蝖桀器嚗 | |
| 262 | + // 餈銵亦器 sdd --fail( failReason)--> prd嚗ackward嚗 | |
| 263 | + // 餈迤 planner.json 銵亦颲寧撠嚗 graph-engine 隞楝敺 | |
| 264 | + // 銝‘鈭器撉餈‘鈭器銋粥摰 | |
| 265 | + const SDD_AGENT = 'sdd-planning'; | |
| 266 | + const CO = `${COMPANY}-t7`; | |
| 267 | + const db = wdb.getDb(); | |
| 268 | + const now = new Date().toISOString(); | |
| 269 | + db.prepare(`INSERT OR IGNORE INTO system_agents | |
| 270 | + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) | |
| 271 | + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`) | |
| 272 | + .run(SDD_AGENT, SDD_AGENT, `system/agents/${SDD_AGENT}`, now, now); | |
| 273 | + | |
| 274 | + const g = wdb.createWorkGraph({ | |
| 275 | + companyId: CO, | |
| 276 | + nodes: [ | |
| 277 | + { nodeName: 'prd', systemAgentId: AGENT }, | |
| 278 | + { nodeName: 'sdd', systemAgentId: SDD_AGENT }, | |
| 279 | + ], | |
| 280 | + edges: [ | |
| 281 | + { from: 'prd', to: 'sdd', condition: 'success' }, | |
| 282 | + { from: 'sdd', to: 'prd', condition: 'fail', edgeKind: 'backward' }, // 31 ﹝銵亦器 | |
| 283 | + ], | |
| 284 | + }); | |
| 285 | + expect(g.errors || []).toEqual([]); | |
| 286 | + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId: CO, executionMode: 'manual' }); | |
| 287 | + engine.markRunReady(run.id); | |
| 288 | + engine.startRun(run.id); | |
| 289 | + | |
| 290 | + // 蝚砌甇伐rd嚗嚗 | |
| 291 | + const step1 = await engine.nextStep(run.id, { task: 'T7 smoke嚗 prd' }); | |
| 292 | + if (!step1.ok) throw new Error(`prd 瘝⊥嚗${JSON.stringify(step1)}`); | |
| 293 | + expect(step1.node.status).toBe('success'); | |
| 294 | + expect((step1.activated || []).map((a) => a.name)).toContain('sdd'); | |
| 295 | + | |
| 296 | + // 蝚砌郊嚗dd input_underspecified嚗銝瓷蛹餈葵 fail_reason 蝎曄&器嚗 | |
| 297 | + // 摨砲器 prd 蝔瓷遙雿憭 retryNode | |
| 298 | + const step2 = await engine.nextStep(run.id, { task: 'T7 smoke嚗 sdd嚗憭梯揖' }); | |
| 299 | + if (!step2.ok) throw new Error(`sdd 瘝⊥嚗${JSON.stringify(step2)}`); | |
| 300 | + // backward 颲嫣銝哨ettleNode 隡蝒嚗 sdd 撌梧韏琿蔭 | |
| 301 | + // pending嚗頧桐 prd 韏唬隞 step2.node嚗dd 銝銵粉歇蝏蔭 | |
| 302 | + // 摰憭梯揖銝翰迤霂"憭梯揖鋡怨楝" backwardEdgeId + activated | |
| 303 | + expect(step2.node.status).toBe('pending'); // sdd 撌梯◤蝒銝撟園蔭嚗頧 | |
| 304 | + expect(step2.backwardEdgeId).toBeTruthy(); // 銝凋 backward 颲對甇餉楝 blocked嚗 | |
| 305 | + // 餈銵停 T7 閬正嚗dd 憭梯揖銋rd 鋡怠撌梁鈭殷蝑犖撌 | |
| 306 | + expect((step2.activated || []).map((a) => a.name)).toContain('prd'); | |
| 307 | + | |
| 308 | + // 銝剔&摰 31 ﹝銵亦器嚗dd prd, fail, failReason嚗 | |
| 309 | + const hitEdge = wdb.getDb() | |
| 310 | + .prepare(`SELECT from_node_id, to_node_id, condition, fail_reason FROM work_graph_edges WHERE id = ?`) | |
| 311 | + .get(step2.backwardEdgeId); | |
| 312 | + const nodeName = (id) => wdb.getDb().prepare(`SELECT node_name FROM work_graph_nodes WHERE id = ?`).get(id).node_name; | |
| 313 | + expect(nodeName(hitEdge.from_node_id)).toBe('sdd'); | |
| 314 | + expect(nodeName(hitEdge.to_node_id)).toBe('prd'); | |
| 315 | + expect(hitEdge.fail_reason).toBeNull(); | |
| 316 | + | |
| 317 | + // sdd 鈭 input_underspecified嚗鋡怠蝛箏活靚辣霂餃 | |
| 318 | + const sddNodeRunId = step1.activated.find((a) => a.name === 'sdd').id; | |
| 319 | + const sddDir = path.join(process.env.GRAPH_RUNS_DIR, run.root_run_id || run.id, 'nodes', sddNodeRunId); | |
| 320 | + const sddAttempt = JSON.parse(fs.readFileSync(path.join(sddDir, 'attempt-0.json'), 'utf8')); | |
| 321 | + expect(sddAttempt.fail_reason).toBe('input_underspecified'); | |
| 322 | + | |
| 323 | + // 霂蒂鈭◤鈭桃 prd 銝emediation_note 蝸雿鈭桀甈∠蓮 | |
| 324 | + // 銝 閫嚗銝W sdd 撌梢銝銵瓷鈭箇 | |
| 325 | + const prdRow = wdb.getDb() | |
| 326 | + .prepare(`SELECT remediation_note, status FROM graph_run_nodes | |
| 327 | + WHERE run_id = ? AND def_node_id = (SELECT id FROM work_graph_nodes WHERE work_graph_id = ? AND node_name = ?)`) | |
| 328 | + .get(run.id, g.id, 'prd'); | |
| 329 | + expect(prdRow.status).toBe('ready'); | |
| 330 | + expect(prdRow.remediation_note).toContain('contract.json'); | |
| 331 | + | |
| 332 | + for (const a of [SDD_AGENT, AGENT]) { | |
| 333 | + fs.rmSync(path.join(__dirname, '../../../system/agents', a, CO), { recursive: true, force: true }); | |
| 334 | + } | |
| 335 | + }, 60_000); | |
| 336 | + | |
| 258 | 337 | it(' 颲寧餈頝臬**撘**釣 executor 摰蛹閬', () => { |
| 259 | 338 | // 撘撌梁嚗 timeline 鈭辣嚗 |
| 260 | 339 | // 摰摰蹂蜓隞僕撠曹漣辣嚗瓷辣瓷撘 | ... | ... |
server/tests/unit/work-graph-skeletons.test.js
| ... | ... | @@ -93,9 +93,10 @@ describe('work graph 骨架的数据契约', () => { |
| 93 | 93 | ]); |
| 94 | 94 | }); |
| 95 | 95 | |
| 96 | - it('每张骨架都过引擎真正用的那套校验(含规则 6:声明要吃的 kind 必须有产出方)', () => { | |
| 96 | + it('每张骨架都过引擎真正用的那套校验(含规则 6:声明要吃的 kind 必须有产出方;' | |
| 97 | + + '规则 5 用 strict 模式——骨架代表 workgraph-planning 真实会提交的图,31 号文档决策 A)', () => { | |
| 97 | 98 | const verdicts = skeletons.map((s) => { |
| 98 | - const r = wdb.validateWorkGraph(s.json.nodes, s.json.edges); | |
| 99 | + const r = wdb.validateWorkGraph(s.json.nodes, s.json.edges, { strictFailFallback: true }); | |
| 99 | 100 | return `${s.file}: ${r.ok ? 'ok' : JSON.stringify(r.errors)}`; |
| 100 | 101 | }); |
| 101 | 102 | expect(verdicts.filter((v) => !v.endsWith(': ok'))).toEqual([]); | ... | ... |
system/agents/workgraph-planning/agent.md
| ... | ... | @@ -197,7 +197,7 @@ curl -s http://localhost:3010/api/company/<公司ID>/agents |
| 197 | 197 | | 2 | 单一入口 | 入度为 0 的只有 a | |
| 198 | 198 | | 3 | 全可达 | 从 a 出发 BFS 覆盖全部 3 个节点 | |
| 199 | 199 | | 4 | 回退边显式标注 | c → b 已标 edgeKind: backward | |
| 200 | -| 5 | fail 兜底边 | c 有 fail 出边,其中 c → b 不带 failReason | | |
| 200 | +| 5 | fail 兜底边 | 除入口 a 外:b → a 不带 failReason(b 的兜底);c → b 不带 failReason(c 的兜底) | | |
| 201 | 201 | | 6 | 数据契约(约束九) | a→b 声明 produces: [brd],b→c 声明 produces: [prd] + needs: [brd];needs 的产出方都在图内;backward 边上没有声明 | |
| 202 | 202 | |
| 203 | 203 | ## 7. 招聘结果(Step 6 之后回填) |
| ... | ... | @@ -366,7 +366,7 @@ ls data/companies/<公司ID>/agents/ |
| 366 | 366 | | `single-entry` | 入度为 0 的 forward 节点不是恰好一个 | **0 个**→ 你把入口也画成了别人的下游,去掉那条指回入口的 forward 边;**多个**→ 选定一个真正的入口,其余孤立起点接到它下游(fan-out 要从**一个**拆分节点发散,不是多个入口) | |
| 367 | 367 | | `reachable` | 有节点从入口沿 forward 边走不到 | 补一条上游 forward 边,或者它本来就不该存在,删掉 | |
| 368 | 368 | | `backward-labeled` | 指向拓扑序更早节点的边没标 `backward`(或反过来,没构成回路的边标了 `backward`) | 按 `detail` 加上/去掉 `"edgeKind": "backward"` | |
| 369 | -| **`fail-fallback-edge`** | **某节点有 `fail` 出边,却没有一条不带 `failReason` 的兜底边** | **给它补一条兜底边**,见约束三 | | |
| 369 | +| **`fail-fallback-edge`** | **某个非入口节点没有一条不带 `failReason` 的兜底边**——`detail` 会区分是"一条 fail 边都没有"还是"有 fail 边但没兜底" | **给它补一条兜底边**,见约束三。注意现在**一条 fail 边都没有**也会触发这条,不要以为它只在"已经画了 fail 边"时才会报 | | |
| 370 | 370 | | **`data-contract`** | **某条边声明下游要吃 kind=X(`needs`),而图上没有任何节点的出边声明产出 X** | 二选一:给真正的产出方那条出边补 `"produces":["X"]`;产出方确实不在本图(`acceptance` 这类由上一层给)就改成 `"needs":[{"kind":"X","required":false}]`。见约束九 | |
| 371 | 371 | | `schema` | 字段缺失 / 取值非法 / `nodeName` 重复 | 按 `detail` 逐条改 | |
| 372 | 372 | | `reference` | 边引用了 `nodes` 里不存在的节点名,**或某个 `systemAgentId` 在 `system_agents` 表里没有对应行** | 拼写错了,或忘了把这个节点加进 `nodes`;**也可能是占位符没替换干净**;**还可能是 Step 6 没把这个本体雇上**——回 Step 6 重雇一次,雇不上就按那里的分级表换掉该本体。**重雇只拿到 `already_exists` 却还是这条 rule** → 见 Step 6「幂等与重入」最后一条,重雇修不好,直接换掉 | |
| ... | ... | @@ -409,7 +409,8 @@ ls data/companies/<公司ID>/agents/ |
| 409 | 409 | > 3. **可达性** —— 每个节点都必须从入口可达,孤立节点直接拒绝。 |
| 410 | 410 | > 4. **回退边必须显式标注** —— 任何指向"拓扑序更早节点"的边,`edgeKind` 必须是 `backward`; |
| 411 | 411 | > 引擎独立复核,不信任标注(**反向也查**:没构成回路的边不许标 `backward`)。 |
| 412 | -> 5. **fail 兜底边** —— 任何有 `fail` 出边的节点,必须至少留一条 `failReason` 为空的兜底边。 | |
| 412 | +> 5. **fail 兜底边** —— **除图的唯一入口节点外**,每个节点都必须至少留一条 `failReason` | |
| 413 | +> 为空的兜底边(2026-08-21 收紧,见约束三:不再要求"先有别的 fail 边"这个前提)。 | |
| 413 | 414 | |
| 414 | 415 | 画完图先自己走一遍(这一遍就是 Step 5 的第 6 节): |
| 415 | 416 | |
| ... | ... | @@ -419,7 +420,7 @@ ls data/companies/<公司ID>/agents/ |
| 419 | 420 | | 2 | 只数 `forward` 边的入度,入度为 0 的节点必须**恰好一个** | |
| 420 | 421 | | 3 | 从入口沿 forward 边 BFS,必须覆盖 `nodes` 里每一个节点 | |
| 421 | 422 | | 4 | 任何 `to` 在拓扑序上早于 `from` 的边,必须带 `"edgeKind": "backward"` | |
| 422 | -| 5 | 把 `condition:"fail"` 的边按 `from` 分组,每组里**必须有**一条没写 `failReason` 的 | | |
| 423 | +| 5 | 除了第 2 条找出的那个唯一入口,**逐个点名图里其余每一个节点**,确认每个都至少有一条 `condition:"fail"` 且没写 `failReason` 的边——不能只挑"已经有 fail 边"的节点抽查 | | |
| 423 | 424 | |
| 424 | 425 | 第 4 条最容易漏:**验收节点打回执行节点的那条 `fail` 边,永远是 `backward`。** |
| 425 | 426 | |
| ... | ... | @@ -440,9 +441,12 @@ ls data/companies/<公司ID>/agents/ |
| 440 | 441 | > 不要自作主张改成 `org-validator-triager`。 |
| 441 | 442 | > (职能词本身已定案:27-REQ **R-17.2** 定名 `triager`,`validator-reflection` 是历史名,不许再用。) |
| 442 | 443 | |
| 443 | -### 约束三:每个有 fail 出边的节点必须留一条兜底边 🚨 | |
| 444 | +### 约束三:除图的唯一入口节点外,每个节点都必须留一条兜底边 🚨 | |
| 444 | 445 | |
| 445 | 446 | **这是最容易被 API 打回来的一条,也是唯一一条 org 层设计里没有、work 层新加的校验。** |
| 447 | +2026-08-21 收紧过一次(31-work-graph-fail-fallback-coverage)——判据不再是「先看这个节点 | |
| 448 | +有没有 fail 边」,而是**除了图的唯一入口节点,其余节点必须无条件留一条兜底边**, | |
| 449 | +哪怕你没打算给它画任何 fail 边。 | |
| 446 | 450 | |
| 447 | 451 | 引擎选边的规则是死的(27-P3/P4 §四.D.2.2): |
| 448 | 452 | |
| ... | ... | @@ -454,18 +458,26 @@ ls data/companies/<公司ID>/agents/ |
| 454 | 458 | ``` |
| 455 | 459 | |
| 456 | 460 | `fail_reason` 的**取值域不由引擎约束**——它只做字符串匹配,取值由你和对应 validator 自行约定。 |
| 457 | -所以归因节点完全可能产出一个你图上没画对应边的取值。**没有兜底边,这种情况直接变成「莫名其妙 blocked」。** | |
| 461 | +本仓库所有本体共用 `_base` 的 RESEARCH→PLAN→EXECUTE→VERIFY 四段式协议,**VERIFY 阶段任何节点 | |
| 462 | +都可能自己写出一份带 `fail_reason` 的判词**——不只是名字里带 `validator`/`triager` 的节点。 | |
| 463 | +`prd-planning`/`sdd-planning` 这类普通生产节点完全可能像 validator 一样自判「我这活干不下去, | |
| 464 | +上游给的东西不够」——这是好事(比等到专职判词节点才发现问题省一整轮),但图必须给它留一条退路, | |
| 465 | +否则就是「莫名其妙 blocked」。 | |
| 458 | 466 | |
| 459 | 467 | | ❌ 会被 400 拒绝 | ✅ 正确 | |
| 460 | 468 | |---|---| |
| 461 | 469 | | `X →fail(impl_bug)→ A`、`X →fail(input_underspecified)→ B`,没了 | 上面两条 + `X →fail→ B`(无 `failReason`) | |
| 470 | +| `Y` 是非入口节点,图里没有任何一条从 `Y` 出发的边 | 至少给 `Y` 补一条 `Y →fail→ 某上游`(无 `failReason`) | | |
| 462 | 471 | |
| 463 | 472 | **怎么选兜底目标**:选**代价最小、爆炸半径最小**的那个回退点(通常是最近的执行节点), |
| 464 | -不要把兜底指向「推翻重来」的节点。参考 `system/org-graph-templates/dev.json` 的画法: | |
| 465 | -`input_underspecified` 退回 planner,其余一切走兜底边退回 coder。 | |
| 473 | +不要把兜底指向「推翻重来」的节点。参考 `system/work-graph-templates/planner.json`: | |
| 474 | +`prd` 退 `brd`、`sdd` 退 `prd`、`benchmark` 退 `sdd`——每个都退到自己紧邻的上游, | |
| 475 | +fan-out 场景(`executor-fanout.json` 的 `impl-a`/`impl-b`/`merge`)没有单一"最近"上游时, | |
| 476 | +退到分叉前的公共上游(`split`),不要猜是哪条分支的锅。 | |
| 466 | 477 | |
| 467 | -**一个节点没有任何 `fail` 出边是合法的**——那表示「这里失败就该 blocked 等人介入」,是有意的收敛出口, | |
| 468 | -不是漏画。`validator.json` 里的 `validator-triager` 就是故意这么设计的。 | |
| 478 | +**只有图的唯一入口节点可以没有任何 `fail` 出边**——它没有同图内的上游可退, | |
| 479 | +"没有 fail 边 = 该 blocked 等人介入"对它是唯一自洽的选择。`validator.json` 里的 | |
| 480 | +`validator-triager` 之所以没有 fail 边,不是因为它是判词节点,是因为它恰好是那张图的入口。 | |
| 469 | 481 | |
| 470 | 482 | ### 约束四:不要在节点内部再套四段式流转 🚨(27-P3/P4 §九bis) |
| 471 | 483 | |
| ... | ... | @@ -625,7 +637,8 @@ digester-youtube-crawler evolver-youtube-crawler |
| 625 | 637 | {{orgNode}} / {{implAgent}} 这类占位符,我**全部**替换干净了吗? |
| 626 | 638 | 4. 节点名带宿主 org node 前缀了吗?本体名是**不带**前缀的吗?我有没有把两者搞反? |
| 627 | 639 | 5. 五项校验过了吗?——环?入口恰好一个?每个节点都可达?回退边标 backward 了吗? |
| 628 | - **每个有 fail 出边的节点,都有一条不带 failReason 的兜底边吗?**(最容易漏的一条) | |
| 640 | + **除了那一个入口节点,图里其余每一个节点,都有一条不带 failReason 的兜底边吗?** | |
| 641 | + (最容易漏的一条——一条 fail 边都没画的节点现在也要查,不能只挑已经有 fail 边的节点看) | |
| 629 | 642 | 5bis. **每条 forward 边上都写了数据契约吗?**(约束九)——不写的话下游节点的提示词里 |
| 630 | 643 | 没有上游产物路径,它必然说「我拿不到输入」。kind 是取值域里的那七个吗? |
| 631 | 644 | 我有没有把契约写到 backward 边上(等于没写)? | ... | ... |
system/work-graph-templates/executor-fanout.json
| ... | ... | @@ -26,6 +26,9 @@ |
| 26 | 26 | { "from": "{{orgNode}}-test-a", "to": "{{orgNode}}-impl-a", "condition": "fail", "edgeKind": "backward" }, |
| 27 | 27 | { "from": "{{orgNode}}-test-b", "to": "{{orgNode}}-impl-b", "condition": "fail", "edgeKind": "backward" }, |
| 28 | 28 | { "from": "{{orgNode}}-integration", "to": "{{orgNode}}-merge", "condition": "fail", "edgeKind": "backward" }, |
| 29 | - { "from": "{{orgNode}}-validator", "to": "{{orgNode}}-split", "condition": "fail", "edgeKind": "backward" } | |
| 29 | + { "from": "{{orgNode}}-validator", "to": "{{orgNode}}-split", "condition": "fail", "edgeKind": "backward" }, | |
| 30 | + { "from": "{{orgNode}}-impl-a", "to": "{{orgNode}}-split", "condition": "fail", "edgeKind": "backward" }, | |
| 31 | + { "from": "{{orgNode}}-impl-b", "to": "{{orgNode}}-split", "condition": "fail", "edgeKind": "backward" }, | |
| 32 | + { "from": "{{orgNode}}-merge", "to": "{{orgNode}}-split", "condition": "fail", "edgeKind": "backward" } | |
| 30 | 33 | ] |
| 31 | 34 | } | ... | ... |
system/work-graph-templates/planner.json
| ... | ... | @@ -17,6 +17,9 @@ |
| 17 | 17 | { "from": "org-planner-sdd", "to": "org-planner-benchmark", "condition": "success", "produces": ["sdd"], "needs": ["prd"] }, |
| 18 | 18 | { "from": "org-planner-benchmark", "to": "org-planner-validator", "condition": "success", "produces": ["acceptance"], "needs": ["brd", "prd", "sdd"] }, |
| 19 | 19 | { "from": "org-planner-validator", "to": "org-planner-prd", "condition": "fail", "failReason": "input_underspecified", "edgeKind": "backward" }, |
| 20 | - { "from": "org-planner-validator", "to": "org-planner-sdd", "condition": "fail", "edgeKind": "backward" } | |
| 20 | + { "from": "org-planner-validator", "to": "org-planner-sdd", "condition": "fail", "edgeKind": "backward" }, | |
| 21 | + { "from": "org-planner-prd", "to": "org-planner-brd", "condition": "fail", "edgeKind": "backward" }, | |
| 22 | + { "from": "org-planner-sdd", "to": "org-planner-prd", "condition": "fail", "edgeKind": "backward" }, | |
| 23 | + { "from": "org-planner-benchmark", "to": "org-planner-sdd", "condition": "fail", "edgeKind": "backward" } | |
| 21 | 24 | ] |
| 22 | 25 | } | ... | ... |
-
mentioned in commit 9cab41fd7ebafc6d69fe0f5640d7b5e2a2a31253