Commit 83afecbf82a85477a84da476beef2eb9cb38aa0f

Authored by yibo
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>
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 "chunks": [ 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 "files": [ 8 "files": [
9 { 9 {
10 "path": "attempt-{attempt}.json", 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,6 +1103,9 @@ function createWorkGraphSafely(ctx, draft) {
1103 okrNodeId: ctx.run.okr_node_id, 1103 okrNodeId: ctx.run.okr_node_id,
1104 nodes: draft.nodes, 1104 nodes: draft.nodes,
1105 edges: draft.edges || [], 1105 edges: draft.edges || [],
  1106 + // 31-work-graph-fail-fallback-coverage 决策 A:这是 workgraph-planning 真实提交的图
  1107 + // 落库前的唯一校验点,也是它 Step 7 那张 errors 表实际读到的来源——只在这里收紧。
  1108 + strictFailFallback: true,
1106 }); 1109 });
1107 return created.ok ? created : { ok: false, errors: created.errors }; 1110 return created.ok ? created : { ok: false, errors: created.errors };
1108 } catch (e) { 1111 } catch (e) {
server/services/work-graph-db.js
@@ -795,20 +795,46 @@ const canTransitionRun = (from, to) =&gt; canTransition(RUN_TRANSITIONS, from, to); @@ -795,20 +795,46 @@ const canTransitionRun = (from, to) =&gt; 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 * @returns {Array<{rule: string, detail: string}>} 835 * @returns {Array<{rule: string, detail: string}>}
810 */ 836 */
811 -function validateFailFallback(edges) { 837 +function validateFailFallback(nodes, edges, { strict = false } = {}) {
812 const errors = []; 838 const errors = [];
813 const byFrom = new Map(); // fromKey -> { hasFail: bool, hasFallback: bool } 839 const byFrom = new Map(); // fromKey -> { hasFail: bool, hasFallback: bool }
814 for (const e of edges || []) { 840 for (const e of edges || []) {
@@ -819,11 +845,47 @@ function validateFailFallback(edges) { @@ -819,11 +845,47 @@ function validateFailFallback(edges) {
819 if (reason === null || reason === undefined || reason === '') slot.hasFallback = true; 845 if (reason === null || reason === undefined || reason === '') slot.hasFallback = true;
820 byFrom.set(e.from, slot); 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 errors.push({ 886 errors.push({
825 rule: 'fail-fallback-edge', 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,9 +898,10 @@ function validateFailFallback(edges) {
836 * 898 *
837 * @param {Array} nodes [{nodeName, systemAgentId?, nodeKind?}] 或 ['nodeName', ...] 899 * @param {Array} nodes [{nodeName, systemAgentId?, nodeKind?}] 或 ['nodeName', ...]
838 * @param {Array} edges [{from, to, condition?, failReason?, edgeKind?}],from/to 是 nodeName 900 * @param {Array} edges [{from, to, condition?, failReason?, edgeKind?}],from/to 是 nodeName
  901 + * @param {{strictFailFallback?: boolean}} [opts] 见 `validateFailFallback` 的 `strict` 参数说明
839 * @returns {{ok: boolean, errors: Array<{rule: string, detail: string}>}} 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 const ns = normalizeNodes(nodes); 905 const ns = normalizeNodes(nodes);
843 const es = normalizeEdges(edges); 906 const es = normalizeEdges(edges);
844 907
@@ -848,7 +911,7 @@ function validateWorkGraph(nodes, edges) { @@ -848,7 +911,7 @@ function validateWorkGraph(nodes, edges) {
848 es 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 return { ok: errors.length === 0, errors }; 915 return { ok: errors.length === 0, errors };
853 } 916 }
854 917
@@ -903,11 +966,12 @@ function createWorkGraph({ @@ -903,11 +966,12 @@ function createWorkGraph({
903 okrNodeId = null, 966 okrNodeId = null,
904 nodes = [], 967 nodes = [],
905 edges = [], 968 edges = [],
  969 + strictFailFallback = false,
906 } = {}) { 970 } = {}) {
907 const ns = normalizeNodes(nodes); 971 const ns = normalizeNodes(nodes);
908 const es = normalizeEdges(edges); 972 const es = normalizeEdges(edges);
909 973
910 - const verdict = validateWorkGraph(ns, es); 974 + const verdict = validateWorkGraph(ns, es, { strictFailFallback });
911 if (!verdict.ok) return { ok: false, errors: verdict.errors }; 975 if (!verdict.ok) return { ok: false, errors: verdict.errors };
912 976
913 const bad = ns.find((n) => !NODE_KINDS.includes(n.nodeKind)); 977 const bad = ns.find((n) => !NODE_KINDS.includes(n.nodeKind));
server/tests/unit/graph-engine.test.js
@@ -876,6 +876,76 @@ describe(&#39;graph-engine&#39;, () =&gt; { @@ -876,6 +876,76 @@ describe(&#39;graph-engine&#39;, () =&gt; {
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 // §六 验收 10:预算扣完 → 整棵 run 树挂起 949 // §六 验收 10:预算扣完 → 整棵 run 树挂起
880 // ────────────────────────────────────────────────────────────── 950 // ──────────────────────────────────────────────────────────────
881 describe('任务级总预算(§四.A.3 / §六 验收 10)', () => { 951 describe('任务级总预算(§四.A.3 / §六 验收 10)', () => {
server/tests/unit/graph-real-chain-smoke.test.js
@@ -255,6 +255,85 @@ describe(&#39;銵頝 smoke嚗瘜典嚗 CLI 摮 @@ -255,6 +255,85 @@ describe(&#39;銵頝 smoke嚗瘜典嚗 CLI 摮
255 fs.rmSync(path.join(__dirname, '../../../system/agents', VALIDATOR, CO), { recursive: true, force: true }); 255 fs.rmSync(path.join(__dirname, '../../../system/agents', VALIDATOR, CO), { recursive: true, force: true });
256 }, 60_000); 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 it(' 颲寧餈頝臬**撘**釣 executor 摰蛹閬', () => { 337 it(' 颲寧餈頝臬**撘**釣 executor 摰蛹閬', () => {
259 // 撘撌梁嚗 timeline 鈭辣嚗 338 // 撘撌梁嚗 timeline 鈭辣嚗
260 // 摰摰蹂蜓隞僕撠曹漣辣嚗瓷辣瓷撘 339 // 摰摰蹂蜓隞僕撠曹漣辣嚗瓷辣瓷撘
server/tests/unit/work-graph-skeletons.test.js
@@ -93,9 +93,10 @@ describe(&#39;work graph 骨架的数据契约&#39;, () =&gt; { @@ -93,9 +93,10 @@ describe(&#39;work graph 骨架的数据契约&#39;, () =&gt; {
93 ]); 93 ]);
94 }); 94 });
95 95
96 - it('每张骨架都过引擎真正用的那套校验(含规则 6:声明要吃的 kind 必须有产出方)', () => { 96 + it('每张骨架都过引擎真正用的那套校验(含规则 6:声明要吃的 kind 必须有产出方;'
  97 + + '规则 5 用 strict 模式——骨架代表 workgraph-planning 真实会提交的图,31 号文档决策 A)', () => {
97 const verdicts = skeletons.map((s) => { 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 return `${s.file}: ${r.ok ? 'ok' : JSON.stringify(r.errors)}`; 100 return `${s.file}: ${r.ok ? 'ok' : JSON.stringify(r.errors)}`;
100 }); 101 });
101 expect(verdicts.filter((v) => !v.endsWith(': ok'))).toEqual([]); 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/&lt;公司ID&gt;/agents @@ -197,7 +197,7 @@ curl -s http://localhost:3010/api/company/&lt;公司ID&gt;/agents
197 | 2 | 单一入口 | 入度为 0 的只有 a | 197 | 2 | 单一入口 | 入度为 0 的只有 a |
198 | 3 | 全可达 | 从 a 出发 BFS 覆盖全部 3 个节点 | 198 | 3 | 全可达 | 从 a 出发 BFS 覆盖全部 3 个节点 |
199 | 4 | 回退边显式标注 | c → b 已标 edgeKind: backward | 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 | 6 | 数据契约(约束九) | a→b 声明 produces: [brd],b→c 声明 produces: [prd] + needs: [brd];needs 的产出方都在图内;backward 边上没有声明 | 201 | 6 | 数据契约(约束九) | a→b 声明 produces: [brd],b→c 声明 produces: [prd] + needs: [brd];needs 的产出方都在图内;backward 边上没有声明 |
202 202
203 ## 7. 招聘结果(Step 6 之后回填) 203 ## 7. 招聘结果(Step 6 之后回填)
@@ -366,7 +366,7 @@ ls data/companies/&lt;公司ID&gt;/agents/ @@ -366,7 +366,7 @@ ls data/companies/&lt;公司ID&gt;/agents/
366 | `single-entry` | 入度为 0 的 forward 节点不是恰好一个 | **0 个**→ 你把入口也画成了别人的下游,去掉那条指回入口的 forward 边;**多个**→ 选定一个真正的入口,其余孤立起点接到它下游(fan-out 要从**一个**拆分节点发散,不是多个入口) | 366 | `single-entry` | 入度为 0 的 forward 节点不是恰好一个 | **0 个**→ 你把入口也画成了别人的下游,去掉那条指回入口的 forward 边;**多个**→ 选定一个真正的入口,其余孤立起点接到它下游(fan-out 要从**一个**拆分节点发散,不是多个入口) |
367 | `reachable` | 有节点从入口沿 forward 边走不到 | 补一条上游 forward 边,或者它本来就不该存在,删掉 | 367 | `reachable` | 有节点从入口沿 forward 边走不到 | 补一条上游 forward 边,或者它本来就不该存在,删掉 |
368 | `backward-labeled` | 指向拓扑序更早节点的边没标 `backward`(或反过来,没构成回路的边标了 `backward`) | 按 `detail` 加上/去掉 `"edgeKind": "backward"` | 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 | **`data-contract`** | **某条边声明下游要吃 kind=X(`needs`),而图上没有任何节点的出边声明产出 X** | 二选一:给真正的产出方那条出边补 `"produces":["X"]`;产出方确实不在本图(`acceptance` 这类由上一层给)就改成 `"needs":[{"kind":"X","required":false}]`。见约束九 | 370 | **`data-contract`** | **某条边声明下游要吃 kind=X(`needs`),而图上没有任何节点的出边声明产出 X** | 二选一:给真正的产出方那条出边补 `"produces":["X"]`;产出方确实不在本图(`acceptance` 这类由上一层给)就改成 `"needs":[{"kind":"X","required":false}]`。见约束九 |
371 | `schema` | 字段缺失 / 取值非法 / `nodeName` 重复 | 按 `detail` 逐条改 | 371 | `schema` | 字段缺失 / 取值非法 / `nodeName` 重复 | 按 `detail` 逐条改 |
372 | `reference` | 边引用了 `nodes` 里不存在的节点名,**或某个 `systemAgentId` 在 `system_agents` 表里没有对应行** | 拼写错了,或忘了把这个节点加进 `nodes`;**也可能是占位符没替换干净**;**还可能是 Step 6 没把这个本体雇上**——回 Step 6 重雇一次,雇不上就按那里的分级表换掉该本体。**重雇只拿到 `already_exists` 却还是这条 rule** → 见 Step 6「幂等与重入」最后一条,重雇修不好,直接换掉 | 372 | `reference` | 边引用了 `nodes` 里不存在的节点名,**或某个 `systemAgentId` 在 `system_agents` 表里没有对应行** | 拼写错了,或忘了把这个节点加进 `nodes`;**也可能是占位符没替换干净**;**还可能是 Step 6 没把这个本体雇上**——回 Step 6 重雇一次,雇不上就按那里的分级表换掉该本体。**重雇只拿到 `already_exists` 却还是这条 rule** → 见 Step 6「幂等与重入」最后一条,重雇修不好,直接换掉 |
@@ -409,7 +409,8 @@ ls data/companies/&lt;公司ID&gt;/agents/ @@ -409,7 +409,8 @@ ls data/companies/&lt;公司ID&gt;/agents/
409 > 3. **可达性** —— 每个节点都必须从入口可达,孤立节点直接拒绝。 409 > 3. **可达性** —— 每个节点都必须从入口可达,孤立节点直接拒绝。
410 > 4. **回退边必须显式标注** —— 任何指向"拓扑序更早节点"的边,`edgeKind` 必须是 `backward`; 410 > 4. **回退边必须显式标注** —— 任何指向"拓扑序更早节点"的边,`edgeKind` 必须是 `backward`;
411 > 引擎独立复核,不信任标注(**反向也查**:没构成回路的边不许标 `backward`)。 411 > 引擎独立复核,不信任标注(**反向也查**:没构成回路的边不许标 `backward`)。
412 -> 5. **fail 兜底边** —— 任何有 `fail` 出边的节点,必须至少留一条 `failReason` 为空的兜底边。 412 +> 5. **fail 兜底边** —— **除图的唯一入口节点外**,每个节点都必须至少留一条 `failReason`
  413 +> 为空的兜底边(2026-08-21 收紧,见约束三:不再要求"先有别的 fail 边"这个前提)。
413 414
414 画完图先自己走一遍(这一遍就是 Step 5 的第 6 节): 415 画完图先自己走一遍(这一遍就是 Step 5 的第 6 节):
415 416
@@ -419,7 +420,7 @@ ls data/companies/&lt;公司ID&gt;/agents/ @@ -419,7 +420,7 @@ ls data/companies/&lt;公司ID&gt;/agents/
419 | 2 | 只数 `forward` 边的入度,入度为 0 的节点必须**恰好一个** | 420 | 2 | 只数 `forward` 边的入度,入度为 0 的节点必须**恰好一个** |
420 | 3 | 从入口沿 forward 边 BFS,必须覆盖 `nodes` 里每一个节点 | 421 | 3 | 从入口沿 forward 边 BFS,必须覆盖 `nodes` 里每一个节点 |
421 | 4 | 任何 `to` 在拓扑序上早于 `from` 的边,必须带 `"edgeKind": "backward"` | 422 | 4 | 任何 `to` 在拓扑序上早于 `from` 的边,必须带 `"edgeKind": "backward"` |
422 -| 5 | 把 `condition:"fail"` 的边按 `from` 分组,每组里**必须有**一条没写 `failReason` 的 | 423 +| 5 | 除了第 2 条找出的那个唯一入口,**逐个点名图里其余每一个节点**,确认每个都至少有一条 `condition:"fail"` 且没写 `failReason` 的边——不能只挑"已经有 fail 边"的节点抽查 |
423 424
424 第 4 条最容易漏:**验收节点打回执行节点的那条 `fail` 边,永远是 `backward`。** 425 第 4 条最容易漏:**验收节点打回执行节点的那条 `fail` 边,永远是 `backward`。**
425 426
@@ -440,9 +441,12 @@ ls data/companies/&lt;公司ID&gt;/agents/ @@ -440,9 +441,12 @@ ls data/companies/&lt;公司ID&gt;/agents/
440 > 不要自作主张改成 `org-validator-triager`。 441 > 不要自作主张改成 `org-validator-triager`。
441 > (职能词本身已定案:27-REQ **R-17.2** 定名 `triager`,`validator-reflection` 是历史名,不许再用。) 442 > (职能词本身已定案:27-REQ **R-17.2** 定名 `triager`,`validator-reflection` 是历史名,不许再用。)
442 443
443 -### 约束三:每个有 fail 出边的节点必须留一条兜底边 🚨 444 +### 约束三:除图的唯一入口节点外,每个节点都必须留一条兜底边 🚨
444 445
445 **这是最容易被 API 打回来的一条,也是唯一一条 org 层设计里没有、work 层新加的校验。** 446 **这是最容易被 API 打回来的一条,也是唯一一条 org 层设计里没有、work 层新加的校验。**
  447 +2026-08-21 收紧过一次(31-work-graph-fail-fallback-coverage)——判据不再是「先看这个节点
  448 +有没有 fail 边」,而是**除了图的唯一入口节点,其余节点必须无条件留一条兜底边**,
  449 +哪怕你没打算给它画任何 fail 边。
446 450
447 引擎选边的规则是死的(27-P3/P4 §四.D.2.2): 451 引擎选边的规则是死的(27-P3/P4 §四.D.2.2):
448 452
@@ -454,18 +458,26 @@ ls data/companies/&lt;公司ID&gt;/agents/ @@ -454,18 +458,26 @@ ls data/companies/&lt;公司ID&gt;/agents/
454 ``` 458 ```
455 459
456 `fail_reason` 的**取值域不由引擎约束**——它只做字符串匹配,取值由你和对应 validator 自行约定。 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 | ❌ 会被 400 拒绝 | ✅ 正确 | 467 | ❌ 会被 400 拒绝 | ✅ 正确 |
460 |---|---| 468 |---|---|
461 | `X →fail(impl_bug)→ A`、`X →fail(input_underspecified)→ B`,没了 | 上面两条 + `X →fail→ B`(无 `failReason`) | 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 ### 约束四:不要在节点内部再套四段式流转 🚨(27-P3/P4 §九bis) 482 ### 约束四:不要在节点内部再套四段式流转 🚨(27-P3/P4 §九bis)
471 483
@@ -625,7 +637,8 @@ digester-youtube-crawler evolver-youtube-crawler @@ -625,7 +637,8 @@ digester-youtube-crawler evolver-youtube-crawler
625 {{orgNode}} / {{implAgent}} 这类占位符,我**全部**替换干净了吗? 637 {{orgNode}} / {{implAgent}} 这类占位符,我**全部**替换干净了吗?
626 4. 节点名带宿主 org node 前缀了吗?本体名是**不带**前缀的吗?我有没有把两者搞反? 638 4. 节点名带宿主 org node 前缀了吗?本体名是**不带**前缀的吗?我有没有把两者搞反?
627 5. 五项校验过了吗?——环?入口恰好一个?每个节点都可达?回退边标 backward 了吗? 639 5. 五项校验过了吗?——环?入口恰好一个?每个节点都可达?回退边标 backward 了吗?
628 - **每个有 fail 出边的节点,都有一条不带 failReason 的兜底边吗?**(最容易漏的一条) 640 + **除了那一个入口节点,图里其余每一个节点,都有一条不带 failReason 的兜底边吗?**
  641 + (最容易漏的一条——一条 fail 边都没画的节点现在也要查,不能只挑已经有 fail 边的节点看)
629 5bis. **每条 forward 边上都写了数据契约吗?**(约束九)——不写的话下游节点的提示词里 642 5bis. **每条 forward 边上都写了数据契约吗?**(约束九)——不写的话下游节点的提示词里
630 没有上游产物路径,它必然说「我拿不到输入」。kind 是取值域里的那七个吗? 643 没有上游产物路径,它必然说「我拿不到输入」。kind 是取值域里的那七个吗?
631 我有没有把契约写到 backward 边上(等于没写)? 644 我有没有把契约写到 backward 边上(等于没写)?
system/work-graph-templates/executor-fanout.json
@@ -26,6 +26,9 @@ @@ -26,6 +26,9 @@
26 { "from": "{{orgNode}}-test-a", "to": "{{orgNode}}-impl-a", "condition": "fail", "edgeKind": "backward" }, 26 { "from": "{{orgNode}}-test-a", "to": "{{orgNode}}-impl-a", "condition": "fail", "edgeKind": "backward" },
27 { "from": "{{orgNode}}-test-b", "to": "{{orgNode}}-impl-b", "condition": "fail", "edgeKind": "backward" }, 27 { "from": "{{orgNode}}-test-b", "to": "{{orgNode}}-impl-b", "condition": "fail", "edgeKind": "backward" },
28 { "from": "{{orgNode}}-integration", "to": "{{orgNode}}-merge", "condition": "fail", "edgeKind": "backward" }, 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,6 +17,9 @@
17 { "from": "org-planner-sdd", "to": "org-planner-benchmark", "condition": "success", "produces": ["sdd"], "needs": ["prd"] }, 17 { "from": "org-planner-sdd", "to": "org-planner-benchmark", "condition": "success", "produces": ["sdd"], "needs": ["prd"] },
18 { "from": "org-planner-benchmark", "to": "org-planner-validator", "condition": "success", "produces": ["acceptance"], "needs": ["brd", "prd", "sdd"] }, 18 { "from": "org-planner-benchmark", "to": "org-planner-validator", "condition": "success", "produces": ["acceptance"], "needs": ["brd", "prd", "sdd"] },
19 { "from": "org-planner-validator", "to": "org-planner-prd", "condition": "fail", "failReason": "input_underspecified", "edgeKind": "backward" }, 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 }