From 83afecbf82a85477a84da476beef2eb9cb38aa0f Mon Sep 17 00:00:00 2001 From: yibo Date: Fri, 21 Aug 2026 15:39:45 +0800 Subject: [PATCH] fix(work-graph): 除入口节点外强制兜底边,堵住 sdd 自判失败卡死的口子 --- docs/todo/system-design/31-work-graph-fail-fallback-coverage.md | 308 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ fixtures/devloop-mock/sdd-planning.json | 18 +++++------------- server/services/graph-engine.js | 3 +++ server/services/work-graph-db.js | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------ server/tests/unit/graph-engine.test.js | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/tests/unit/graph-real-chain-smoke.test.js | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/tests/unit/work-graph-skeletons.test.js | 5 +++-- system/agents/workgraph-planning/agent.md | 35 ++++++++++++++++++++++++----------- system/work-graph-templates/executor-fanout.json | 5 ++++- system/work-graph-templates/planner.json | 5 ++++- 10 files changed, 582 insertions(+), 46 deletions(-) create mode 100644 docs/todo/system-design/31-work-graph-fail-fallback-coverage.md diff --git a/docs/todo/system-design/31-work-graph-fail-fallback-coverage.md b/docs/todo/system-design/31-work-graph-fail-fallback-coverage.md new file mode 100644 index 0000000..94769cb --- /dev/null +++ b/docs/todo/system-design/31-work-graph-fail-fallback-coverage.md @@ -0,0 +1,308 @@ +# 31-work-graph-fail-fallback-coverage. work graph 兜底边覆盖不全 —— 需求与实施计划 + +> **日期**: 2026-08-21 +> **状态**: ✅ **全部完成**(T1/T3/T4/T5/T6/T7),全量单测 978/978 通过。见 §七实现记录, +> 含两处中途推翻原计划的发现:① 决策 A 的强制范围从"无条件"改成"仅 workgraph-planning +> 落库路径生效";② T7 没有重跑真实卡住的那条任务(状态不干净、疑似有并行会话在用), +> 改成用真实引擎代码 + 真实 DB + 只换掉最外层 Claude CLI 子进程的确定性回归测试代替—— +> 比一次性真跑更严格(可重复验证),且零真实 LLM 花费。 +> **触发**: `e2e-demo` 公司真实任务 `T-mt1qx0c4-95bf`(goal `G-mt1qwgt5-6b77`)卡住排障,见 §一 + +--- + +## 一、起因:一次真实卡住的复盘 + +`org-planner` 的 work graph(`planner.json` 骨架)跑到 `org-planner-sdd` 节点时, +`sdd-planning` 自己在 `attempt-0.json` 里写下 `fail_reason: input_underspecified`, +并点名 `target_node: org-planner-prd`(判断很准——上游 `prd-planning` 那一轮确实只写了 +`contract.json`,没写叙事文档,输入薄)。 + +但这张图里 `org-planner-sdd` **没有任何 `fail` 出边**(骨架里只画了到 `org-planner-benchmark` +的 success 边)。引擎选边规则是「精确匹配 → 兜底边 → 死路 blocked」,sdd 自己没有兜底边, +它点名的 `org-planner-prd` 只是同一张图里的普通兄弟节点(不构成需要跨层解析的子图边界), +两条路都接不住,节点直接 `blocked`,整条任务停摆,直到有人手动点了「重试」。 + +## 二、现状核查 + +### 2.1 现有校验的口子 + +`fail-fallback-edge` 这条规则(`org-graph-service.js` 规则 5 / `work-graph-db.js` +`validateFailFallback`)现在的判据是: + +> **一个节点如果画了 `fail` 出边,那些边里必须至少有一条不带 `failReason` 的兜底边。** + +**没有画任何 `fail` 出边的节点,完全不受这条规则约束**——`org-planner-sdd` 正是这种情况, +它压根没触发校验,不是校验漏判。`workgraph-planning/agent.md`(约束三)现在明文教它: +"一个节点没有任何 fail 出边是合法的——那表示『这里失败就该 blocked 等人介入』,是有意的 +收敛出口,不是漏画"。 + +### 2.2 为什么"没有 fail 边"曾经是安全的,现在不是 + +系统里**所有本体都继承自 `_base` 元 agent 的四段式协议**(RESEARCH → PLAN → EXECUTE → +VERIFY,见 27-P3/P4 §九bis)。VERIFY 阶段允许任何节点写一份 `attempt-N.json`, +`verdict` 可以是 `attributed`(判定失败并归因)。这不是 `*-validator`/`*-triager` +类节点的专属能力——`readNodeProduct()`(`graph-engine.js`)对**所有** `agent_call` +节点一视同仁地读这份文件,`requiresAttributionProduct()` 只决定"文件缺失算不算失败", +不决定"文件存在时听不听它的"。 + +也就是说:**任何节点都可能像这次的 sdd 一样,自己判定失败并点名回退目标**, +而不只是图里专门安排的判词节点。这是好事(sdd 没有干等到 `org-validator` 才暴露问题, +省了一整轮下游判定的成本),但图目前只给"专门的判词节点"配了兜底边, +给不了"顺手自判失败"的普通生产节点。 + +### 2.3 四个骨架模板的实测审计 + +写脚本核对了 `system/work-graph-templates/*.json` 里每个节点有没有 `fail` 出边、 +有没有兜底边(脚本见本节末尾,结果如下): + +| 模板 | 无 fail 边(当前"合法") | 有 fail 边 + 有兜底边 | 有 fail 边但缺兜底边 | +|---|---|---|---| +| `executor.json` | `{{orgNode}}-impl`(**图的唯一入口**) | `-test`、`-validator` | 无 | +| `executor-fanout.json` | `-split`(**入口**)、`-impl-a`、`-impl-b`、`-merge` | `-test-a`、`-test-b`、`-integration`、`-validator` | 无 | +| `planner.json` | `org-planner-brd`(**入口**)、`-prd`、`-sdd`、`-benchmark` | `-validator` | 无 | +| `validator.json` | `validator-triager`(**入口**) | `benchmark-validator`、`testdata-validator` | 无 | + +**关键发现**:四个模板里,"当前合法但没有兜底边"的节点一共 **10 个**: + +- **4 个是各自图的唯一入口节点**——`{{orgNode}}-impl`(executor)、`{{orgNode}}-split` + (executor-fanout)、`org-planner-brd`(planner)、`validator-triager`(validator); +- **6 个是有上游可退、却没配退路的中间节点**——`{{orgNode}}-impl-a`、`{{orgNode}}-impl-b`、 + `{{orgNode}}-merge`(executor-fanout)+ `org-planner-prd`、`org-planner-sdd`、 + `org-planner-benchmark`(planner)。这次真实撞见的 `org-planner-sdd` 正是其中之一。 + +这不是偶然:**当前模板其实一直是按"图的入口 = 可以不设兜底边"在画的**(4/4 入口节点 +都没配兜底边,也从没被判违规),只是这条隐含规则从没被校验强制过,导致 6 个本该配 +但没配的中间节点混了进来,静默通过了写入前校验——直到某个中间节点真的自判失败一次, +才暴露出没有退路。 + +审计脚本(可复用于实施后的回归检查): + +```python +import json +for f in ['executor.json', 'executor-fanout.json', 'planner.json', 'validator.json']: + d = json.load(open(f'system/work-graph-templates/{f}')) + nodes = [n['nodeName'] for n in d['nodes']] + indeg = {n: 0 for n in nodes} + fail_from = {} + for e in d['edges']: + if e.get('edgeKind', 'forward') == 'forward': + indeg[e['to']] += 1 + if e.get('condition') == 'fail': + fail_from.setdefault(e['from'], []).append(e.get('failReason')) + for n in nodes: + is_entry = indeg[n] == 0 + fails = fail_from.get(n, []) + has_fallback = None in fails + print(f, n, 'entry' if is_entry else 'non-entry', + 'no-fail-edge' if not fails else ('OK' if has_fallback else 'MISSING-FALLBACK')) +``` + +### 2.4 顺带发现:规则 5 有两份重复实现 + +`org-graph-service.js` 的 `validateGraph()` 内联实现了完整的规则 5(第 295-311 行); +`work-graph-db.js` **又**单独写了一份 `validateFailFallback()`(第 795-815 行), +且 `validateWorkGraph()` 两份都调(先 `validateGraph()` 拿到含规则 5 的 `base.errors`, +再 `.concat(validateFailFallback(es))`)。**这个重复已经在代码注释里被记录为已知现象** +(`work-graph-db.js:786-788`:「反向 require 会成环,于是同一条违规会报两行」), +不是本次新发现,但改规则 5 的语义时必须同时改两处,否则两份判据会分裂。 + +--- + +## 三、决策 + +### 🔒 决策 A —— 兜底边的强制范围:**除图的唯一入口节点外,一律强制** + +**判据完全基于图结构(forward 入度),不按节点名字 / agent 类型判断**: + +> 除了这张图里 forward 入度为 0 的那一个入口节点,**每一个 `agent_call` 节点必须至少有 +> 一条不带 `failReason` 的兜底边**——不再要求"先画了别的 fail 边才检查",改成**无条件要求**。 + +**为什么是"入口节点"而不是"节点名字匹配 validator/triager"**: +1. 入口节点没有同图内的上游可退,"没有 fail 边 = 该 blocked 等人介入"对它是唯一自洽的选择; + 非入口节点永远有一个"最近的上游"可以退,让它也一起 blocked 没有理由。 +2. 判据基于结构(入度),不依赖字符串匹配——不会因为将来起了个不叫 `xxx-validator` 的 + 判词节点名字而漏判,也不需要在 `work-graph-db.js` 里另建一份 `graph-engine.js` 才有的 + `ATTRIBUTION_REQUIRED_RE` 正则(那会制造第三份"谁是判词节点"的判据副本)。 +3. 入度已经是规则 2(`single-entry`)现成算出来的中间结果,改动内聚在同一个函数里。 + +### 决策 A 的备选(留档,为什么不选) + +| 方案 | 做法 | 不选的理由 | +|---|---|---| +| **B1 按节点名字模式** | 复用 `graph-engine.js` 的 `ATTRIBUTION_REQUIRED_RE`,只对匹配 `*-validator`/`*-triager` 的节点要求兜底边 | 这次撞见的正是"名字不匹配却自判失败"的节点(`sdd-planning`);字符串匹配天然漏这一类 | +| **B2 收窄谁能自判失败** | 改 `_base` 四段式协议,让非判词节点的 VERIFY 阶段只能报「produced / blocked_infra」,不能报 `attributed`(语义失败),把"判断质量"这件事完全收回给专职判词节点 | 会让 sdd 这类节点即使发现了问题也只能装死等下游 validator 兜一整圈才发现,效率更差;这次 sdd 的归因其实是准的,不该被禁止说出来 | +| **B3 允许 self-loop 表达"原地重跑"** | 给入口节点也配一条指向自己的 fail 边 | 结构上没有先例(`edgeKind:backward` 现在的判据是"构成环",from===to 是否会被 acyclic 校验拒绝需要验证),而且"原地重跑"已经有 `infra_flake` 这条独立机制在管,没必要在图形状上再发明一种 | + +### 🔒 决策 B —— 兜底目标怎么选:**退到最近的上游节点**(沿用 agent.md 已有指导原则) + +`workgraph-planning/agent.md` 约束三已经写了"选代价最小、爆炸半径最小的那个回退点, +通常是最近的执行节点"——这条原则不用改,只是要把它落到具体骨架文件里。fan-in 场景 +(一个节点有多条上游)没有单一"最近"时,退到分叉前的公共上游(比如 `merge` 退到 `split`, +而不是猜是哪一条分支的锅)。 + +### 🔒 决策 C —— 四个模板文件按此原则逐一打补丁(本次一并做完,不分期) + +具体新增边见 §四.2。执行侧改动量可控(6 条新边,2 个文件),不需要拆多轮。 + +### 🔒 决策 D —— 只收紧 work 层,org 层这次不动 + +`validateGraph()`(`org-graph-service.js`)是 org / work 两层共用的函数,规则 5 的旧实现 +("仅当已有 fail 边才检查")就长在它里面。查过之后发现 **org 层如果套用同一条新规则, +会立刻炸**:`system/org-graph-templates/dev.json` 的 `org-coder`、`content.json` 的 +`org-writer`、`ops.json` 的 `org-monitor` 都是非入口节点、且都没有 fail 出边——跟这次 +撞见的 `org-planner-sdd` 是同一种形状。 + +**但这次不顺手把 org 层也改了**,理由是两层的失败来源机制不同: + +- **work 层**:`agent_call` 节点的失败判定直接来自它自己那份 `attempt-N.json` + (`readNodeProduct()` 读的就是这个),本次改动验证过——`org-planner-sdd` 的 + `fail_reason` 就是这么来的,机制清楚、能写单测、能真跑复现。 +- **org 层**:节点全部是 `subgraph` 类型,走的是 `executeSubgraph()` → + `deriveChildOutcome()`,节点自己的失败是**子 work graph 收敛之后回传的结果**, + 不是直接读 `attempt-N.json`。子图内部本来就有自己的一整套回退/熔断 + (backward_count、loop_count 双层熔断),"子图收敛失败后 org 节点该退到哪" + 是 27-p3b 文档"验收 9"点名的、**从没有真实验证过**的那条链路。 + +在验收 9 补完(有真实证据证明"org 层节点失败 → 走没预留的边 → 会不会被合理路由/ +不会引擎内部再出别的问题")之前,往 `dev.json` / `content.json` / `ops.json` 里加 +兜底边只是"看起来更完整",没有对应的真实验证,反而可能在没人预料到的地方引入新的 +回退路径。**留给验收 9 收口时一起做**,这里只记录发现,不动 org 层的模板或校验代码。 + +--- + +## 四、改动清单 + +### 4.1 校验代码(规则语义变更) + +| 文件 | 改什么 | +|---|---| +| `server/services/org-graph-service.js` | **不改**(决策 D)。它的规则 5 保持旧语义("仅当已有 fail 边才检查"),继续给 org 层用 | +| `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,这次不修) | +| `server/services/graph-engine.js` | `createWorkGraphSafely()`(workgraph-planning 提交图落库前的唯一校验点)调 `createWorkGraph` 时显式传 `strictFailFallback: true`——**这是全仓库唯一默认打开新语义的调用点** | +| 错误文案 | `validateFailFallback` 区分"完全没有 fail 边"和"有 fail 边但兜底缺失"两种情况的 `detail`,方便 `workgraph-planning` 读错误信息时一眼判断是"忘了配"还是"配的边不完整" | + +### 4.2 骨架模板(新增 6 条兜底边) + +| 文件 | 新增边 | +|---|---| +| `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` | +| `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` | +| `executor.json` / `validator.json` | **不用改**——审计显示两者已经完全符合新规则(唯一的"无 fail 边"节点都是各自的入口) | + +### 4.3 `workgraph-planning/agent.md`(提示词同步) + +| 位置 | 改什么 | +|---|---| +| 约束三整节 | 删掉"一个节点没有任何 fail 出边是合法的"这句无条件表述,改成"**只有图的唯一入口节点**可以没有 fail 出边,其余节点必须至少留一条兜底边";`validator-triager` 的例子照旧引用,但把理由从"它是判词节点"改成"它是 `validator.json` 的入口节点" | +| Step 5 自查表第 5 项 | 同步措辞 | +| 强制思维隔离清单第 5 条 | 同步措辞("每个有 fail 出边的节点" → "除入口外的每个节点") | +| `errors[].rule='fail-fallback-edge'` 那一行的处理指引 | 提醒"没有 fail 边也会触发这条规则"(现在的表述隐含"节点已经有 fail 边"的前提,新规则下要更新) | + +### 4.4 不在本次范围内 + +- `org-coder` 系列以外、`system/work-graph-templates/` 之外由 `workgraph-planning` + 临时手画的图(真实任务里它有权限裁剪/组合骨架)——**这些图不受静态模板审计覆盖**, + 只能靠写入前校验兜底,属于决策 A/B 已经覆盖的范围,不用额外处理。 +- 决策 A 备选 B2(收窄谁能自判失败)——已否决,不做。 +- §2.4 的重复实现去重——已知技术债,不在本次范围。 + +--- + +## 五、任务分解 + +| # | 任务 | 文件 | 验收标准 | 状态 | +|---|---|---|---|---| +| T1 | `validateFailFallback` 改成"除入口外无条件要求兜底边";**默认关闭,`strict` 开关**(见 §7.1),仅 `createWorkGraphSafely` 打开 | `work-graph-db.js` + `graph-engine.js` | 新单测:非 strict 保持旧行为;strict 下入口节点无 fail 边→通过、非入口无 fail 边→报错、非入口有 fail 边无兜底→报错(同旧规则那半)、补齐后通过、多入口不豁免、`createWorkGraph` 默认路径仍是旧行为 | ✅ 已完成,7 例新单测全过 | +| T3 | `planner.json` 补 3 条兜底边 | 模板文件 | 用 §2.3 审计脚本重跑,`planner.json` 零 `MISSING-FALLBACK` 且零意外的 `no-fail-edge`(只剩入口节点) | ✅ 已完成 | +| T4 | `executor-fanout.json` 补 3 条兜底边 | 模板文件 | 同上,脚本对该文件重跑 | ✅ 已完成 | +| T5 | `workgraph-planning/agent.md` 同步四处措辞 | agent.md | 人工核对约束三 / Step5 自查表 / 思维隔离清单 / errors 处理指引 四处都改了,agent.md 前 2000 字符仍自足(决策 B.2 硬约束,改动只加在约束三,不影响文件头部) | ✅ 已完成 | +| T6 | 回归:全量单测 | — | `npm run test:unit` 全绿 | ✅ 970/970 | +| T7 | **真实复现验收**:验证一个像 `org-planner-sdd` 那样自判 `input_underspecified` 的节点,**不需要人工点重试**,引擎能自动打回上游 | `graph-real-chain-smoke.test.js` 新增用例⑦ | `backwardEdgeId` 命中新补的那条边、`activated` 里出现上游节点、判词真的带到位、全程零 `retryNode` 调用 | ✅ 已完成,见 §7.3 | + +T3/T4 互相独立、可与 T1 并行。T5 依赖 T1/T3/T4 的规则定型(措辞要和实际校验行为对齐)。T6/T7 最后做。 + +--- + +## 六、验证计划 + +- 单测:T1/T2 的三个用例是核心,另外补一条"入口节点判定"本身的用例 + (多入口图应该在规则 2 就先被拒,不会走到规则 5——确认两条规则的报错顺序不冲突)。 +- 静态回归:§2.3 的审计脚本存一份到 `server/tests/unit/` 之外的 scratch 位置 + (或者直接写成一条测试,遍历 `system/work-graph-templates/*.json`,断言"无 fail 边的节点 + 只能是入口"),防止以后新加模板文件又踩同一个坑。 +- 真实验收(T7):这是本次改动**唯一真正证明问题解决**的一步——之前的手动重试证明了 + "补一条边之后流程能走通",但没有证明"下次系统自己会补这条边";T7 要看到的是 + **自动**触发,不是人工点一下。**尚未做**,见 §七。 + +--- + +## 七、实现记录 + +### 7.1 T1 中途发现:无条件收紧会炸 46 个用例 / 13 个文件 + +按原计划把 `validateFailFallback` 改成无条件强制后,`npx vitest run server/tests/unit` +从 970 掉到 **46 个用例失败、13 个文件**(`graph-engine.test.js` 一个文件就占 11 个)。 + +**根因**:`work-graph-db.createWorkGraph`/`validateWorkGraph` 不只是"workgraph-planning +提交图的校验口子",还被 28 个测试文件当成**通用建图工具**在用——测预算耗尽、双层熔断、 +跨层地址、边界守卫这些跟"图设计得完不完善"毫无关系的机制时,图基本是"a→b→c,只给要测 +的那条边配 fail"的最小形状,非入口节点普遍没有自己的兜底边。这不是那些用例写错了, +是"图结构要完整"这个新诉求和"内部机制测试用最小图"这个通用测试模式本来就不是一回事。 + +**改法(已实施)**:`validateFailFallback` / `validateWorkGraph` / `createWorkGraph` +都加一个可选参数(`strict` / `strictFailFallback`),**默认 `false`**,行为与改动前完全一致; +只有 `graph-engine.js` 的 `createWorkGraphSafely`——workgraph-planning 交回的图真正落库前的 +唯一校验点,也是它 Step 7 那张 `errors` 表实际读到的来源——显式传 `strict: true`。 +`work-graph-skeletons.test.js`(测四个维护中的模板文件)同样显式传 `true`, +因为那些模板就是代表 workgraph-planning 真实会提交的图。 + +**结果**:§三决策 A 的判据("除入口外无条件要求兜底边")**不变**,改变的只是它的生效范围—— +从"全仓库任何调用 `createWorkGraph` 的地方"收窄成"workgraph-planning 真实提交图的那一条路径"。 +这个范围本来就是决策 A 真正要覆盖的场景,收窄后没有削弱决策本身,只是避免了拿一个内部工具函数 +的通用性开刀。§四.1 的改动清单据此更新(原计划"改一处、影响全部调用方",实际是"改一处、 +只有一个调用方选择更严格的行为")。 + +全量单测 970/970 通过(含新增的 strict 模式用例)。 + +### 7.2 已完成 + +- T1:`work-graph-db.js` 的 `validateFailFallback` 升级为 `(nodes, edges, {strict})`, + `validateWorkGraph`/`createWorkGraph` 透传 `strictFailFallback`;`graph-engine.js` 的 + `createWorkGraphSafely` 显式传 `strict: true`。新增单测 7 例(非 strict 保持旧行为、 + strict 下入口豁免/非入口无 fail 边违规/有 fail 边无兜底违规/补齐后通过/多入口不豁免/ + 真实落库路径默认即 strict)。 +- T3/T4:`planner.json` 补 3 条兜底边(prd→brd、sdd→prd、benchmark→sdd); + `executor-fanout.json` 补 3 条(impl-a/impl-b/merge 均退 split)。 + `work-graph-skeletons.test.js` 在 `strictFailFallback: true` 下四个模板全部通过。 +- T5:`workgraph-planning/agent.md` 四处措辞同步(约束三整节重写、五项校验第 5 项、 + Step 5 示例表、Step 7 errors 表、强制思维隔离清单第 5 条);核对过前 2000 字符 + (身份定位 + 工作流概览)未被触及,改动全部在约束三及之后(第 443 行开始)。 +- T6:`npx vitest run server/tests/unit` 970/970 通过(T7 加完之后是 978/978)。 + +### 7.3 T7:真实验收改走确定性回归测试,不重跑那条真实卡住的任务 + +**先确认过原计划**:`T-mt1qx0c4-95bf` 当前状态是 `cancelled`,但它的 `run_d06717f10e5d` +却仍显示 `runStatus: running`——状态不一致,疑似有其他会话在同时操作这个任务(见对话 +记录里"这次重试动作看起来不是我这边触发的"那次提醒)。这个任务不安全重用;真要验, +只能起一条新任务,而且没法保证新任务会恰好复现"prd 只写 contract.json 导致 sdd 判 +input_underspecified"这个具体形状——花几分钟到几十分钟真实 LLM 调用,可能什么都验不出来。 + +**改法**:在 `graph-real-chain-smoke.test.js`("零注入,只换掉最外层 Claude CLI 子进程" +这条已有的真实链路 smoke 惯例)里加了用例⑦。图形状是 planner.json 里 `prd→sdd` 那两个 +节点的最小复现(`prd --success--> sdd`,`sdd --fail(无 failReason)--> prd`——正是这次 +新补的兜底边),`sdd-planning` 的 mock fixture 从"总是成功"改成精确复现真实事故的归因 +(`verdict:attributed, fail_reason:input_underspecified`)。**这份 fixture 改前没有 +任何一条已跑用例真的用 mock CLI 走过 sdd-planning**(实查过,全仓库只在别处当字符串 +引用),改动没有副作用。 + +真跑 `engine.nextStep()` 两轮(真实 `advanceOne`/`settleNode`/`routeFailure` 代码路径, +真实 DB),验到的东西: +1. `backwardEdgeId` 命中的确实是新补的那条边(`sdd→prd`,`fail`,无 `failReason`); +2. `activated` 数组里出现了 `prd`——**全程没有调用过 `retryNode`**,是引擎自己点亮的; +3. `prd` 那一行的 `remediation_note` 真的收到了 sdd 归因文件里的话("prd 只写了 + contract.json,没有叙事文档"),不是丢在 sdd 自己那行没人看得到; +4. sdd 自己的 `attempt-0.json` 真的写着 `input_underspecified`——不是引擎凭空发明的。 + +**比一次性真跑更严格的地方**:这条测试可重复运行、可回归、进 CI,下次有人改动 +`routeFailure`/`settleNode` 的路由逻辑不小心把这条路径改坏,会立刻红;一次性的真实 +任务重跑就算当时证明了问题解决,也没有任何机制防止它未来悄悄失效。 diff --git a/fixtures/devloop-mock/sdd-planning.json b/fixtures/devloop-mock/sdd-planning.json index 4823928..8058db9 100644 --- a/fixtures/devloop-mock/sdd-planning.json +++ b/fixtures/devloop-mock/sdd-planning.json @@ -1,22 +1,14 @@ { - "_doc": "P3 mock fixture · work graph 里的 wg-build 节点本体。被 wg-check 回退时会再跑一次(attempt+1),fixture 用 {attempt} 占位符跨轮复用同一份,不需要 sequence。", + "_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 这条新场景。", "chunks": [ - "[mock] sdd-planning 读 prd.md 与上一轮 remediation_note(若有)。\n", - "产出设计与实现,写 sdd.md + contract.json。\n" + "[mock] sdd-planning 读 prd.md,发现约束不够写不下去。\n", + "上游 prd 只有结构化导出,没有叙事文档,判定 input_underspecified。\n" ], - "result": "mock-ok sdd-planning:已产出 sdd.md 与 contract.json。", + "result": "mock-fail sdd-planning:input_underspecified,退回 prd。", "files": [ { "path": "attempt-{attempt}.json", - "content": "{\n \"schema\": \"validator-triager/v1\",\n \"verdict\": \"no_prior_failure\",\n \"fail_reason\": null,\n \"remediation_note\": null\n}\n" - }, - { - "path": "sdd.md", - "content": "# SDD(mock)\n\n单模块实现,对外暴露一个函数,错误输入返回明确错误。\n" - }, - { - "path": "contract.json", - "content": "{\n \"exports\": {\n \"fn\": [\n { \"name\": \"handle\", \"input\": \"string\", \"output\": \"string\" }\n ]\n }\n}\n" + "content": "{\n \"schema\": \"validator-triager/v1\",\n \"verdict\": \"attributed\",\n \"fail_reason\": \"input_underspecified\",\n \"remediation_note\": \"prd 只写了 contract.json,没有叙事文档,补齐后重跑\"\n}\n" } ] } diff --git a/server/services/graph-engine.js b/server/services/graph-engine.js index 3a2b4d9..591368b 100644 --- a/server/services/graph-engine.js +++ b/server/services/graph-engine.js @@ -1103,6 +1103,9 @@ function createWorkGraphSafely(ctx, draft) { okrNodeId: ctx.run.okr_node_id, nodes: draft.nodes, edges: draft.edges || [], + // 31-work-graph-fail-fallback-coverage 决策 A:这是 workgraph-planning 真实提交的图 + // 落库前的唯一校验点,也是它 Step 7 那张 errors 表实际读到的来源——只在这里收紧。 + strictFailFallback: true, }); return created.ok ? created : { ok: false, errors: created.errors }; } catch (e) { diff --git a/server/services/work-graph-db.js b/server/services/work-graph-db.js index e79b6b4..c6870d1 100644 --- a/server/services/work-graph-db.js +++ b/server/services/work-graph-db.js @@ -795,20 +795,46 @@ const canTransitionRun = (from, to) => canTransition(RUN_TRANSITIONS, from, to); // ──────────────────────────────────────────────────────────────── /** - * 第五项校验(§四.D.2.2 末尾):任何有 fail 出边的节点,必须至少留一条 - * fail_reason IS NULL 的兜底边。否则归因产出一个图上没有对应边的取值时会落到死路, - * 表现为「莫名其妙 blocked」。 - * - * ⚠️ 现状:org 层**没有**复用本函数,而是在 org-graph-service.validateGraph 的「规则 5」里 - * 内联实现了同一条规则——本文件 require org-graph-service(validateGraph),反向 require 会成环。 - * 于是 validateWorkGraph 走的是「validateGraph 的规则 5 + 本函数」两份实现,同一条违规会报两行; - * 两份逻辑目前语义一致,但改一处漏一处就会漂移。要收敛的话把规则抽到一个无依赖的小模块里, - * 两边都 require 它(见 27-P3P4 §三.7 的风险记录)。 - * - * @param {Array<{from, to, condition, failReason?}>} edges from/to 用什么标识都行(只做分组) + * 第五项校验(§四.D.2.2 末尾 + 31-work-graph-fail-fallback-coverage): + * **除图的唯一入口节点外**,每个节点都必须至少留一条 fail_reason IS NULL 的兜底边。 + * + * 判据从 27-P3P4/T14 的旧版收紧过一次(2026-08-21,见 31 号文档 §三决策 A): + * 旧版只在节点**已经画了别的 fail 边**时才检查兜底边齐不齐;新版不再要求这个前提—— + * 一条 fail 边都没有的非入口节点,现在同样算违规。 + * + * **为什么按"入口节点"豁免,不按节点名字/agent 类型**:本仓库所有本体共用 `_base` 的 + * RESEARCH→PLAN→EXECUTE→VERIFY 四段式协议,VERIFY 阶段任何 agent_call 节点都可能写一份 + * `attempt-N.json` 自判失败(`graph-engine.readNodeProduct` 对所有节点一视同仁地读它, + * 不限于名字里带 `validator`/`triager` 的判词节点)——这次撞见的 `sdd-planning` 正是 + * 普通生产节点自判失败的例子。只有图的入口节点没有同图内的上游可退,"没有 fail 边= + * 该 blocked 等人介入"对它才是唯一自洽的选择;其余节点永远有"最近的上游"可以退。 + * + * ⚠️ **这次只收紧了 work 层**(本函数),org 层的 `org-graph-service.validateGraph` + * 规则 5 仍是旧语义——org 层节点全部是 subgraph 类型,失败来源机制不同(子图收敛后回传, + * 不是自己的 attempt-N.json),且套用同一条新规则会让 `dev.json`/`content.json`/`ops.json` + * 立刻不合法(`org-coder`/`org-writer`/`org-monitor` 都是非入口、无 fail 边),这属于 + * 27-p3b「验收 9」还没收口的那条链路,见 31 号文档决策 D,这里不动。 + * + * ⚠️ 两处规则不是同一份实现(见旧注释与 27-P3P4 §三.7 的风险记录):`validateWorkGraph` + * 会先跑一遍 `validateGraph` 的旧版规则 5(可能报一次),再跑本函数的新版规则 5 + * (可能对同一节点再报一次,且措辞不同)——这次不做去重,只保证本函数语义正确。 + * + * ⚠️ **`strict` 默认 `false`(保持旧语义)**:全仓 28 个测试文件在拿 `createWorkGraph` + * 当测试夹具的通用建图工具(测预算/熔断/回退地址这些跟"图设计得完不完善"毫无关系的机制), + * 图大多是"a→b→c 只给要测的那条边配 fail"的最小形状,非入口节点普遍没有自己的兜底边。 + * 无条件收紧这条规则时实测**炸了 46 个用例、13 个文件**——那不是这些用例错了, + * 是这条新规则的"结构完整性"诉求跟"内部机制测试用最小图"这个通用测试模式天然不兼容。 + * 真正需要新语义的只有一个调用点:`graph-engine.createWorkGraphSafely` + * (workgraph-planning 交回的图落库前的校验,也是它 Step 7 那张 errors 表实际读到的来源), + * 那里显式传 `strict: true`。其余调用方(含测试)不传,拿到的是原来那条更宽松的规则, + * 行为不变。 + * + * @param {Array<{nodeName, systemAgentId?, nodeKind?}>} nodes 用于算 forward 入度、找入口 + * @param {Array<{from, to, condition, failReason?, edgeKind?}>} edges + * @param {{strict?: boolean}} [opts] * @returns {Array<{rule: string, detail: string}>} */ -function validateFailFallback(edges) { +function validateFailFallback(nodes, edges, { strict = false } = {}) { const errors = []; const byFrom = new Map(); // fromKey -> { hasFail: bool, hasFallback: bool } for (const e of edges || []) { @@ -819,11 +845,47 @@ function validateFailFallback(edges) { if (reason === null || reason === undefined || reason === '') slot.hasFallback = true; byFrom.set(e.from, slot); } - for (const [from, slot] of byFrom) { - if (slot.hasFail && !slot.hasFallback) { + + if (!strict) { + // 旧语义:只查已经画了 fail 边、但兜底缺失的节点。 + for (const [from, slot] of byFrom) { + if (slot.hasFail && !slot.hasFallback) { + errors.push({ + rule: 'fail-fallback-edge', + detail: `节点 ${from} 有 fail 出边,但没有一条 fail_reason 为空的兜底边,归因结果不匹配任何边时会直接死路`, + }); + } + } + return errors; + } + + // 新语义:除入口节点外,一条 fail 边都没有也算违规。 + const names = (nodes || []).map((n) => (typeof n === 'string' ? n : n && n.nodeName)).filter(Boolean); + if (!names.length) return errors; + + const indeg = new Map(names.map((n) => [n, 0])); + for (const e of edges || []) { + if ((e.edgeKind || 'forward') !== 'forward') continue; + if (indeg.has(e.to)) indeg.set(e.to, indeg.get(e.to) + 1); + } + const entries = names.filter((n) => indeg.get(n) === 0); + // 入口不唯一(0 个或多个)时不豁免任何节点——那本身已经是规则 2 single-entry 的违规, + // 这里没有"唯一入口"可指,宁可多报一点噪声也不要悄悄放过该查的节点。 + const exempt = entries.length === 1 ? new Set(entries) : new Set(); + + for (const name of names) { + if (exempt.has(name)) continue; + const slot = byFrom.get(name); + if (!slot) { + errors.push({ + rule: 'fail-fallback-edge', + detail: `节点 ${name} 没有任何 fail 出边——除图的唯一入口节点外,每个节点都必须至少留一条` + + '不带 failReason 的兜底边,否则它自判失败时(VERIFY 阶段写出 attempt-N.json)无处可退,直接 blocked', + }); + } else if (!slot.hasFallback) { errors.push({ rule: 'fail-fallback-edge', - detail: `节点 ${from} 有 fail 出边,但没有一条 fail_reason 为空的兜底边,归因结果不匹配任何边时会直接死路`, + detail: `节点 ${name} 有 fail 出边,但没有一条 fail_reason 为空的兜底边,归因结果不匹配任何边时会直接死路`, }); } } @@ -836,9 +898,10 @@ function validateFailFallback(edges) { * * @param {Array} nodes [{nodeName, systemAgentId?, nodeKind?}] 或 ['nodeName', ...] * @param {Array} edges [{from, to, condition?, failReason?, edgeKind?}],from/to 是 nodeName + * @param {{strictFailFallback?: boolean}} [opts] 见 `validateFailFallback` 的 `strict` 参数说明 * @returns {{ok: boolean, errors: Array<{rule: string, detail: string}>}} */ -function validateWorkGraph(nodes, edges) { +function validateWorkGraph(nodes, edges, { strictFailFallback = false } = {}) { const ns = normalizeNodes(nodes); const es = normalizeEdges(edges); @@ -848,7 +911,7 @@ function validateWorkGraph(nodes, edges) { es ); - const errors = base.errors.concat(validateFailFallback(es)); + const errors = base.errors.concat(validateFailFallback(ns, es, { strict: strictFailFallback })); return { ok: errors.length === 0, errors }; } @@ -903,11 +966,12 @@ function createWorkGraph({ okrNodeId = null, nodes = [], edges = [], + strictFailFallback = false, } = {}) { const ns = normalizeNodes(nodes); const es = normalizeEdges(edges); - const verdict = validateWorkGraph(ns, es); + const verdict = validateWorkGraph(ns, es, { strictFailFallback }); if (!verdict.ok) return { ok: false, errors: verdict.errors }; const bad = ns.find((n) => !NODE_KINDS.includes(n.nodeKind)); diff --git a/server/tests/unit/graph-engine.test.js b/server/tests/unit/graph-engine.test.js index 77467f7..7949033 100644 --- a/server/tests/unit/graph-engine.test.js +++ b/server/tests/unit/graph-engine.test.js @@ -876,6 +876,76 @@ describe('graph-engine', () => { }); // ────────────────────────────────────────────────────────────── + // 31-work-graph-fail-fallback-coverage 决策 A:strict 模式——除入口节点外, + // 一条 fail 边都没有也算违规(不再要求"先画了别的 fail 边"这个前提)。 + // 默认(非 strict)行为保持不变,上面那组用例已经覆盖;这里只测 strict:true 的新增部分。 + // ────────────────────────────────────────────────────────────── + describe('fail-fallback-edge 的 strict 模式(31 号文档决策 A,仅 workgraph-planning 落库路径生效)', () => { + // a 是唯一入口;b 有上游可退却一条 fail 边都没有;c 有 fail 边但没兜底 + const NODES = ['a', 'b', 'c']; + const baseEdges = [ + { from: 'a', to: 'b' }, + { from: 'b', to: 'c' }, + ]; + + it('非 strict:b 没有 fail 边不算违规(保持旧行为)', () => { + const v = wdb.validateWorkGraph(NODES, baseEdges); + expect(v.errors.map((e) => e.rule)).not.toContain('fail-fallback-edge'); + }); + + it('strict:入口节点 a 没有 fail 边仍然合法', () => { + const v = wdb.validateWorkGraph(NODES, baseEdges, { strictFailFallback: true }); + const detail = v.errors.filter((e) => e.rule === 'fail-fallback-edge').map((e) => e.detail).join('\n'); + expect(detail).not.toContain('节点 a '); + }); + + it('strict:非入口节点 b 一条 fail 边都没有 → 违规', () => { + const v = wdb.validateWorkGraph(NODES, baseEdges, { strictFailFallback: true }); + expect(v.ok).toBe(false); + const bEdge = v.errors.find((e) => e.rule === 'fail-fallback-edge' && e.detail.includes('节点 b ')); + expect(bEdge).toBeTruthy(); + expect(bEdge.detail).toContain('没有任何 fail 出边'); + }); + + it('strict:非入口节点 c 有 fail 边但没兜底 → 违规(与旧语义相同的那半)', () => { + const edges = [...baseEdges, { from: 'c', to: 'b', condition: 'fail', failReason: 'impl_bug', edgeKind: 'backward' }]; + const v = wdb.validateWorkGraph(NODES, edges, { strictFailFallback: true }); + const cEdge = v.errors.find((e) => e.rule === 'fail-fallback-edge' && e.detail.includes('节点 c ')); + expect(cEdge).toBeTruthy(); + expect(cEdge.detail).toContain('没有一条 fail_reason 为空的兜底边'); + }); + + it('strict:b、c 都补上兜底边后通过', () => { + const edges = [ + ...baseEdges, + { from: 'b', to: 'a', condition: 'fail', edgeKind: 'backward' }, + { from: 'c', to: 'b', condition: 'fail', edgeKind: 'backward' }, + ]; + const v = wdb.validateWorkGraph(NODES, edges, { strictFailFallback: true }); + expect(v.ok).toBe(true); + }); + + it('strict:多入口图不豁免任何节点(single-entry 已经拒了,规则 5 不该悄悄放过)', () => { + // a、b 都是入口(forward 入度为 0),任何一个都没有 fail 边 + const edges = [{ from: 'a', to: 'c' }, { from: 'b', to: 'c' }]; + const v = wdb.validateWorkGraph(NODES, edges, { strictFailFallback: true }); + expect(v.errors.map((e) => e.rule)).toContain('single-entry'); + const noFailback = v.errors.filter((e) => e.rule === 'fail-fallback-edge'); + expect(noFailback.some((e) => e.detail.includes('节点 a '))).toBe(true); + expect(noFailback.some((e) => e.detail.includes('节点 b '))).toBe(true); + }); + + it('真实落库路径(createWorkGraphSafely 等价调用)默认就是 strict:' + + 'workgraph-planning 交回一张没给非入口节点配兜底边的图会被拒', () => { + const companyId = nextCompany(); + ensureAgents(NODES); + const res = wdb.createWorkGraph({ companyId, nodes: NODES, edges: baseEdges, strictFailFallback: true }); + expect(res.ok).toBe(false); + expect(res.errors.map((e) => e.rule)).toContain('fail-fallback-edge'); + }); + }); + + // ────────────────────────────────────────────────────────────── // §六 验收 10:预算扣完 → 整棵 run 树挂起 // ────────────────────────────────────────────────────────────── describe('任务级总预算(§四.A.3 / §六 验收 10)', () => { diff --git a/server/tests/unit/graph-real-chain-smoke.test.js b/server/tests/unit/graph-real-chain-smoke.test.js index 02bd886..eb93d96 100644 --- a/server/tests/unit/graph-real-chain-smoke.test.js +++ b/server/tests/unit/graph-real-chain-smoke.test.js @@ -255,6 +255,85 @@ describe('图执行真实链路 smoke(零注入,只换掉真 CLI 子进程ï¼ fs.rmSync(path.join(__dirname, '../../../system/agents', VALIDATOR, CO), { recursive: true, force: true }); }, 60_000); + it('⑦ 31-work-graph-fail-fallback-coverage T7:非入口节点自判失败时,' + + '引擎沿新补的兜底边自动打回上游——不调 retryNode,不需要人工点重试', async () => { + // 复现 e2e-demo 那次真实卡住的最小形状:prd(入口)--success--> sdd, + // sdd 自己写出 fail_reason=input_underspecified 却没有精确匹配边时, + // 退到这条新补的兜底边 sdd --fail(无 failReason)--> prd(backward)。 + // 这正是 planner.json 补的那条边的最小复现,用真实 graph-engine 代码路径验证—— + // 不是「补了边、结构校验能过」,是「补了边之后引擎运行时真的会走它」。 + const SDD_AGENT = 'sdd-planning'; + const CO = `${COMPANY}-t7`; + const db = wdb.getDb(); + const now = new Date().toISOString(); + db.prepare(`INSERT OR IGNORE INTO system_agents + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`) + .run(SDD_AGENT, SDD_AGENT, `system/agents/${SDD_AGENT}`, now, now); + + const g = wdb.createWorkGraph({ + companyId: CO, + nodes: [ + { nodeName: 'prd', systemAgentId: AGENT }, + { nodeName: 'sdd', systemAgentId: SDD_AGENT }, + ], + edges: [ + { from: 'prd', to: 'sdd', condition: 'success' }, + { from: 'sdd', to: 'prd', condition: 'fail', edgeKind: 'backward' }, // 31 号文档新补的那条兜底边 + ], + }); + expect(g.errors || []).toEqual([]); + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId: CO, executionMode: 'manual' }); + engine.markRunReady(run.id); + engine.startRun(run.id); + + // 第一步:prd(入口)真的跑成功 + const step1 = await engine.nextStep(run.id, { task: 'T7 smoke:先跑 prd' }); + if (!step1.ok) throw new Error(`prd 没推动:${JSON.stringify(step1)}`); + expect(step1.node.status).toBe('success'); + expect((step1.activated || []).map((a) => a.name)).toContain('sdd'); + + // 第二步:sdd 自判 input_underspecified,图上没有为这个 fail_reason 精确匹配的边, + // 应该落到兜底边、自动打回 prd —— 全程没有任何一处调用 retryNode。 + const step2 = await engine.nextStep(run.id, { task: 'T7 smoke:跑 sdd,会自判失败' }); + if (!step2.ok) throw new Error(`sdd 没推动:${JSON.stringify(step2)}`); + // backward 边一旦命中,settleNode 会把「回退窗口内」的节点(含 sdd 自己)连同它一起重置回 + // pending,等下一轮从 prd 重新走一遍——所以 step2.node(sdd 那一行)读到的已经是重置后的 + // 状态,不是它刚判失败那一瞬间的快照。真正证明"失败被路由"的是 backwardEdgeId + activated。 + expect(step2.node.status).toBe('pending'); // sdd 自己被回退窗口一并重置,等下一轮 + expect(step2.backwardEdgeId).toBeTruthy(); // 命中了一条真实的 backward 边(不是死路 blocked) + // 这一行就是 T7 要证的东西:sdd 判失败之后,prd 被引擎自己点亮,不是停在原地等人工 + expect((step2.activated || []).map((a) => a.name)).toContain('prd'); + + // 命中的确实是 31 号文档新补的那条兜底边(sdd → prd, fail, 无 failReason) + const hitEdge = wdb.getDb() + .prepare(`SELECT from_node_id, to_node_id, condition, fail_reason FROM work_graph_edges WHERE id = ?`) + .get(step2.backwardEdgeId); + const nodeName = (id) => wdb.getDb().prepare(`SELECT node_name FROM work_graph_nodes WHERE id = ?`).get(id).node_name; + expect(nodeName(hitEdge.from_node_id)).toBe('sdd'); + expect(nodeName(hitEdge.to_node_id)).toBe('prd'); + expect(hitEdge.fail_reason).toBeNull(); + + // sdd 真的自判了 input_underspecified(不是被引擎凭空发明的)——从它这次调用的归因文件读回 + const sddNodeRunId = step1.activated.find((a) => a.name === 'sdd').id; + const sddDir = path.join(process.env.GRAPH_RUNS_DIR, run.root_run_id || run.id, 'nodes', sddNodeRunId); + const sddAttempt = JSON.parse(fs.readFileSync(path.join(sddDir, 'attempt-0.json'), 'utf8')); + expect(sddAttempt.fail_reason).toBe('input_underspecified'); + + // 判词也真的带到了被点亮的 prd 节点上(remediation_note 的载体是「点亮它的那次状态转换」, + // 与 ⑥ 同一条规矩),不是丢在 sdd 自己那一行没人看得到 + const prdRow = wdb.getDb() + .prepare(`SELECT remediation_note, status FROM graph_run_nodes + WHERE run_id = ? AND def_node_id = (SELECT id FROM work_graph_nodes WHERE work_graph_id = ? AND node_name = ?)`) + .get(run.id, g.id, 'prd'); + expect(prdRow.status).toBe('ready'); + expect(prdRow.remediation_note).toContain('contract.json'); + + for (const a of [SDD_AGENT, AGENT]) { + fs.rmSync(path.join(__dirname, '../../../system/agents', a, CO), { recursive: true, force: true }); + } + }, 60_000); + it('③ 边界守卫在这条路径上是**开着**的(注入假 executor 时它恒为假,那条分支零覆盖)', () => { // 判据取自引擎自己的开关函数,而不是去数 timeline 事件: // 守卫在宿主仓库干净时本来就不产生事件,拿「没有事件」当「没开」会反过来判错。 diff --git a/server/tests/unit/work-graph-skeletons.test.js b/server/tests/unit/work-graph-skeletons.test.js index 97f74bb..0ad03a6 100644 --- a/server/tests/unit/work-graph-skeletons.test.js +++ b/server/tests/unit/work-graph-skeletons.test.js @@ -93,9 +93,10 @@ describe('work graph 骨架的数据契约', () => { ]); }); - it('每张骨架都过引擎真正用的那套校验(含规则 6:声明要吃的 kind 必须有产出方)', () => { + it('每张骨架都过引擎真正用的那套校验(含规则 6:声明要吃的 kind 必须有产出方;' + + '规则 5 用 strict 模式——骨架代表 workgraph-planning 真实会提交的图,31 号文档决策 A)', () => { const verdicts = skeletons.map((s) => { - const r = wdb.validateWorkGraph(s.json.nodes, s.json.edges); + const r = wdb.validateWorkGraph(s.json.nodes, s.json.edges, { strictFailFallback: true }); return `${s.file}: ${r.ok ? 'ok' : JSON.stringify(r.errors)}`; }); expect(verdicts.filter((v) => !v.endsWith(': ok'))).toEqual([]); diff --git a/system/agents/workgraph-planning/agent.md b/system/agents/workgraph-planning/agent.md index 0150e51..b1c086d 100644 --- a/system/agents/workgraph-planning/agent.md +++ b/system/agents/workgraph-planning/agent.md @@ -197,7 +197,7 @@ curl -s http://localhost:3010/api/company/<公司ID>/agents | 2 | 单一入口 | 入度为 0 的只有 a | | 3 | 全可达 | 从 a 出发 BFS 覆盖全部 3 个节点 | | 4 | 回退边显式标注 | c → b 已标 edgeKind: backward | -| 5 | fail 兜底边 | c 有 fail 出边,其中 c → b 不带 failReason | +| 5 | fail 兜底边 | 除入口 a 外:b → a 不带 failReason(b 的兜底);c → b 不带 failReason(c 的兜底) | | 6 | 数据契约(约束九) | a→b 声明 produces: [brd],b→c 声明 produces: [prd] + needs: [brd];needs 的产出方都在图内;backward 边上没有声明 | ## 7. 招聘结果(Step 6 之后回填) @@ -366,7 +366,7 @@ ls data/companies/<公司ID>/agents/ | `single-entry` | 入度为 0 的 forward 节点不是恰好一个 | **0 个**→ 你把入口也画成了别人的下游,去掉那条指回入口的 forward 边;**多个**→ 选定一个真正的入口,其余孤立起点接到它下游(fan-out 要从**一个**拆分节点发散,不是多个入口) | | `reachable` | 有节点从入口沿 forward 边走不到 | 补一条上游 forward 边,或者它本来就不该存在,删掉 | | `backward-labeled` | 指向拓扑序更早节点的边没标 `backward`(或反过来,没构成回路的边标了 `backward`) | 按 `detail` 加上/去掉 `"edgeKind": "backward"` | -| **`fail-fallback-edge`** | **某节点有 `fail` 出边,却没有一条不带 `failReason` 的兜底边** | **给它补一条兜底边**,见约束三 | +| **`fail-fallback-edge`** | **某个非入口节点没有一条不带 `failReason` 的兜底边**——`detail` 会区分是"一条 fail 边都没有"还是"有 fail 边但没兜底" | **给它补一条兜底边**,见约束三。注意现在**一条 fail 边都没有**也会触发这条,不要以为它只在"已经画了 fail 边"时才会报 | | **`data-contract`** | **某条边声明下游要吃 kind=X(`needs`),而图上没有任何节点的出边声明产出 X** | 二选一:给真正的产出方那条出边补 `"produces":["X"]`;产出方确实不在本图(`acceptance` 这类由上一层给)就改成 `"needs":[{"kind":"X","required":false}]`。见约束九 | | `schema` | 字段缺失 / 取值非法 / `nodeName` 重复 | 按 `detail` 逐条改 | | `reference` | 边引用了 `nodes` 里不存在的节点名,**或某个 `systemAgentId` 在 `system_agents` 表里没有对应行** | 拼写错了,或忘了把这个节点加进 `nodes`;**也可能是占位符没替换干净**;**还可能是 Step 6 没把这个本体雇上**——回 Step 6 重雇一次,雇不上就按那里的分级表换掉该本体。**重雇只拿到 `already_exists` 却还是这条 rule** → 见 Step 6「幂等与重入」最后一条,重雇修不好,直接换掉 | @@ -409,7 +409,8 @@ ls data/companies/<公司ID>/agents/ > 3. **可达性** —— 每个节点都必须从入口可达,孤立节点直接拒绝。 > 4. **回退边必须显式标注** —— 任何指向"拓扑序更早节点"的边,`edgeKind` 必须是 `backward`; > 引擎独立复核,不信任标注(**反向也查**:没构成回路的边不许标 `backward`)。 -> 5. **fail 兜底边** —— 任何有 `fail` 出边的节点,必须至少留一条 `failReason` 为空的兜底边。 +> 5. **fail 兜底边** —— **除图的唯一入口节点外**,每个节点都必须至少留一条 `failReason` +> 为空的兜底边(2026-08-21 收紧,见约束三:不再要求"先有别的 fail 边"这个前提)。 画完图先自己走一遍(这一遍就是 Step 5 的第 6 节): @@ -419,7 +420,7 @@ ls data/companies/<公司ID>/agents/ | 2 | 只数 `forward` 边的入度,入度为 0 的节点必须**恰好一个** | | 3 | 从入口沿 forward 边 BFS,必须覆盖 `nodes` 里每一个节点 | | 4 | 任何 `to` 在拓扑序上早于 `from` 的边,必须带 `"edgeKind": "backward"` | -| 5 | 把 `condition:"fail"` 的边按 `from` 分组,每组里**必须有**一条没写 `failReason` 的 | +| 5 | 除了第 2 条找出的那个唯一入口,**逐个点名图里其余每一个节点**,确认每个都至少有一条 `condition:"fail"` 且没写 `failReason` 的边——不能只挑"已经有 fail 边"的节点抽查 | 第 4 条最容易漏:**验收节点打回执行节点的那条 `fail` 边,永远是 `backward`。** @@ -440,9 +441,12 @@ ls data/companies/<公司ID>/agents/ > 不要自作主张改成 `org-validator-triager`。 > (职能词本身已定案:27-REQ **R-17.2** 定名 `triager`,`validator-reflection` 是历史名,不许再用。) -### 约束三:每个有 fail 出边的节点必须留一条兜底边 🚨 +### 约束三:除图的唯一入口节点外,每个节点都必须留一条兜底边 🚨 **这是最容易被 API 打回来的一条,也是唯一一条 org 层设计里没有、work 层新加的校验。** +2026-08-21 收紧过一次(31-work-graph-fail-fallback-coverage)——判据不再是「先看这个节点 +有没有 fail 边」,而是**除了图的唯一入口节点,其余节点必须无条件留一条兜底边**, +哪怕你没打算给它画任何 fail 边。 引擎选边的规则是死的(27-P3/P4 §四.D.2.2): @@ -454,18 +458,26 @@ ls data/companies/<公司ID>/agents/ ``` `fail_reason` 的**取值域不由引擎约束**——它只做字符串匹配,取值由你和对应 validator 自行约定。 -所以归因节点完全可能产出一个你图上没画对应边的取值。**没有兜底边,这种情况直接变成「莫名其妙 blocked」。** +本仓库所有本体共用 `_base` 的 RESEARCH→PLAN→EXECUTE→VERIFY 四段式协议,**VERIFY 阶段任何节点 +都可能自己写出一份带 `fail_reason` 的判词**——不只是名字里带 `validator`/`triager` 的节点。 +`prd-planning`/`sdd-planning` 这类普通生产节点完全可能像 validator 一样自判「我这活干不下去, +上游给的东西不够」——这是好事(比等到专职判词节点才发现问题省一整轮),但图必须给它留一条退路, +否则就是「莫名其妙 blocked」。 | ❌ 会被 400 拒绝 | ✅ 正确 | |---|---| | `X →fail(impl_bug)→ A`、`X →fail(input_underspecified)→ B`,没了 | 上面两条 + `X →fail→ B`(无 `failReason`) | +| `Y` 是非入口节点,图里没有任何一条从 `Y` 出发的边 | 至少给 `Y` 补一条 `Y →fail→ 某上游`(无 `failReason`) | **怎么选兜底目标**:选**代价最小、爆炸半径最小**的那个回退点(通常是最近的执行节点), -不要把兜底指向「推翻重来」的节点。参考 `system/org-graph-templates/dev.json` 的画法: -`input_underspecified` 退回 planner,其余一切走兜底边退回 coder。 +不要把兜底指向「推翻重来」的节点。参考 `system/work-graph-templates/planner.json`: +`prd` 退 `brd`、`sdd` 退 `prd`、`benchmark` 退 `sdd`——每个都退到自己紧邻的上游, +fan-out 场景(`executor-fanout.json` 的 `impl-a`/`impl-b`/`merge`)没有单一"最近"上游时, +退到分叉前的公共上游(`split`),不要猜是哪条分支的锅。 -**一个节点没有任何 `fail` 出边是合法的**——那表示「这里失败就该 blocked 等人介入」,是有意的收敛出口, -不是漏画。`validator.json` 里的 `validator-triager` 就是故意这么设计的。 +**只有图的唯一入口节点可以没有任何 `fail` 出边**——它没有同图内的上游可退, +"没有 fail 边 = 该 blocked 等人介入"对它是唯一自洽的选择。`validator.json` 里的 +`validator-triager` 之所以没有 fail 边,不是因为它是判词节点,是因为它恰好是那张图的入口。 ### 约束四:不要在节点内部再套四段式流转 🚨(27-P3/P4 §九bis) @@ -625,7 +637,8 @@ digester-youtube-crawler evolver-youtube-crawler {{orgNode}} / {{implAgent}} 这类占位符,我**全部**替换干净了吗? 4. 节点名带宿主 org node 前缀了吗?本体名是**不带**前缀的吗?我有没有把两者搞反? 5. 五项校验过了吗?——环?入口恰好一个?每个节点都可达?回退边标 backward 了吗? - **每个有 fail 出边的节点,都有一条不带 failReason 的兜底边吗?**(最容易漏的一条) + **除了那一个入口节点,图里其余每一个节点,都有一条不带 failReason 的兜底边吗?** + (最容易漏的一条——一条 fail 边都没画的节点现在也要查,不能只挑已经有 fail 边的节点看) 5bis. **每条 forward 边上都写了数据契约吗?**(约束九)——不写的话下游节点的提示词里 没有上游产物路径,它必然说「我拿不到输入」。kind 是取值域里的那七个吗? 我有没有把契约写到 backward 边上(等于没写)? diff --git a/system/work-graph-templates/executor-fanout.json b/system/work-graph-templates/executor-fanout.json index 98ad23c..d7631bf 100644 --- a/system/work-graph-templates/executor-fanout.json +++ b/system/work-graph-templates/executor-fanout.json @@ -26,6 +26,9 @@ { "from": "{{orgNode}}-test-a", "to": "{{orgNode}}-impl-a", "condition": "fail", "edgeKind": "backward" }, { "from": "{{orgNode}}-test-b", "to": "{{orgNode}}-impl-b", "condition": "fail", "edgeKind": "backward" }, { "from": "{{orgNode}}-integration", "to": "{{orgNode}}-merge", "condition": "fail", "edgeKind": "backward" }, - { "from": "{{orgNode}}-validator", "to": "{{orgNode}}-split", "condition": "fail", "edgeKind": "backward" } + { "from": "{{orgNode}}-validator", "to": "{{orgNode}}-split", "condition": "fail", "edgeKind": "backward" }, + { "from": "{{orgNode}}-impl-a", "to": "{{orgNode}}-split", "condition": "fail", "edgeKind": "backward" }, + { "from": "{{orgNode}}-impl-b", "to": "{{orgNode}}-split", "condition": "fail", "edgeKind": "backward" }, + { "from": "{{orgNode}}-merge", "to": "{{orgNode}}-split", "condition": "fail", "edgeKind": "backward" } ] } diff --git a/system/work-graph-templates/planner.json b/system/work-graph-templates/planner.json index 9787dd8..09bede4 100644 --- a/system/work-graph-templates/planner.json +++ b/system/work-graph-templates/planner.json @@ -17,6 +17,9 @@ { "from": "org-planner-sdd", "to": "org-planner-benchmark", "condition": "success", "produces": ["sdd"], "needs": ["prd"] }, { "from": "org-planner-benchmark", "to": "org-planner-validator", "condition": "success", "produces": ["acceptance"], "needs": ["brd", "prd", "sdd"] }, { "from": "org-planner-validator", "to": "org-planner-prd", "condition": "fail", "failReason": "input_underspecified", "edgeKind": "backward" }, - { "from": "org-planner-validator", "to": "org-planner-sdd", "condition": "fail", "edgeKind": "backward" } + { "from": "org-planner-validator", "to": "org-planner-sdd", "condition": "fail", "edgeKind": "backward" }, + { "from": "org-planner-prd", "to": "org-planner-brd", "condition": "fail", "edgeKind": "backward" }, + { "from": "org-planner-sdd", "to": "org-planner-prd", "condition": "fail", "edgeKind": "backward" }, + { "from": "org-planner-benchmark", "to": "org-planner-sdd", "condition": "fail", "edgeKind": "backward" } ] } -- libgit2 0.21.4