# 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`)卡住排障,见 §一 > > **2026-08-21 追加:真实 E2E 回归验证补上了**。§六「尚未做」的那一步(真跑证明「下次系统自己会 > 补这条边」)当时因为原任务状态不干净作罢,改用确定性测试代替(§7.3)。同一天晚些时候重启服务 > 加载了本次修复后,起了一条新任务(goal `G-mt2r62ia-73af` / task `T-mt2r6fz0-125b` / root run > `run_0e9074815130`)走真实 LLM:`org-planner-sdd` 真实自判失败,沿新补的兜底边**自动**回退 3 次 > (零 `retryNode` 调用),最终 planner 子图收敛到 `completed`。**修复确认生效,有真实证据**。 > 同一次验证顺带在 `org-coder` 子图撞见一个**独立的新 bug**(`inboundContracts` 未按 `role` > 过滤,把 `produces` 声明误当 `needs`,导致 `missing_required_input` 假阳性),与本文档无关, > 已记录在 [33-needs-gate-role-filter-bug.md](./33-needs-gate-role-filter-bug.md)。 --- ## 一、起因:一次真实卡住的复盘 `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` 的路由逻辑不小心把这条路径改坏,会立刻红;一次性的真实 任务重跑就算当时证明了问题解决,也没有任何机制防止它未来悄悄失效。