Commit a4b248939f37cfdbfdce2354f531b6de6d7dbc9e

Authored by yibo
1 parent ddda1ac7

fix(graph-engine): 44 号写共享区产出闸门永假 + 41 号 infra_flake 忽视 target_node(两处真 bug)

44 号:writesArtifacts 节点产出闸门永远打不过
- goal-benchmark 按设计把产物写进 root run 共享 artifacts/ 目录(三个节点合成同一份
  acceptance.json,天然不可能落在某节点私有目录),但 unsatisfiedRequiredProduces 的
  R-19 逃生口 readDeclaredProduces 的 allowedRoots 只认节点私有目录 + 项目根,够不到
  共享区——这条契约在当前实现下无论 agent 表现多好都无法通过。
- 修法(44 号文档方案 1,改动面最小):allowedRoots 加入 runRootDir(run)/artifacts/;
  goal-benchmark 的 agent.md/tools.md 补上"写完共享产物后在自己节点私有目录声明一份
  contract.json"这一步。
- 回归:graph-files-index.test.js 新增用例 ⑭。

41 号:routeFailure 的 infra_flake 分支完全无视 target_node
- 30 号文档记录过一次真实死循环:节点自称 infra_flake 并点名 target_node,引擎原来
  一律原地重跑该节点自己,从不看 target_node。
- 修法(最保守方案,用户确认):新增 infraFlakeTargetEdge——target_node 只有在图上
  真有一条从本节点出发的 backward fail 边指向那个节点时才生效,命中就改走正常的
  backward 边路由(计入 backward_count/双层熔断),不再享受"原地重跑、只受
  infra_flake_count 约束"的免账本待遇;没命中维持原有行为。
- 重要澄清:这条修复堵不住 30 号文档那次真实事故——事故里点名的目标是下游节点,
  而 org-graph-service.js 规则 4(backward-labeled)从图结构上就不允许 backward 边
  指向非祖先,那次事故本来就不是"有边不认",是"诊断内容在打图结构不允许表达的
  主意",已经靠 INFRA_FLAKE_HARD_CAP 熔断收敛,不在这条修复范围内。
- 回归:graph-engine.test.js 的 infra_flake describe 块新增两条用例。

44 号文档移入 done/,41 号清单项相应更新,43 号文档的交叉引用一并修正。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CwGcvMfUcgCcweN5vjhTkn
docs/README.md
@@ -37,6 +37,7 @@ @@ -37,6 +37,7 @@
37 | [system-design/40-agent-chat-streaming-interrupt.md](./done/system-design/40-agent-chat-streaming-interrupt.md) | ✅ **已实现**(commit `4d23f50`,2026-08-30)。回应"前端刷新不及时/没有打字机效果,要不要把 SSE 换成 WebSocket"——排查后结论是传输层不是瓶颈(对照 hermes-webui/hermes-agent/deepseek-harness 三个参考实现,全部走 SSE,不用 WebSocket)。三个独立缺口全部补上:① 打字机不流畅→后端按字符数切片 + 前端 `createTypewriterQueue` 匀速吐出;② 执行中无法打断并接上新消息→`stop` 端点改轮询确认锁真正释放,前端据此直接发送/强制发送兜底,顺带修了服务端对同 convId 并发缺闸门这个真实 bug;③ 串台加固→`handleEvent` 抽成纯函数并补齐 `9b1c3f1` 那条不变式的回归测试。已用真实 Claude CLI 会话在浏览器验证 | 37 | [system-design/40-agent-chat-streaming-interrupt.md](./done/system-design/40-agent-chat-streaming-interrupt.md) | ✅ **已实现**(commit `4d23f50`,2026-08-30)。回应"前端刷新不及时/没有打字机效果,要不要把 SSE 换成 WebSocket"——排查后结论是传输层不是瓶颈(对照 hermes-webui/hermes-agent/deepseek-harness 三个参考实现,全部走 SSE,不用 WebSocket)。三个独立缺口全部补上:① 打字机不流畅→后端按字符数切片 + 前端 `createTypewriterQueue` 匀速吐出;② 执行中无法打断并接上新消息→`stop` 端点改轮询确认锁真正释放,前端据此直接发送/强制发送兜底,顺带修了服务端对同 convId 并发缺闸门这个真实 bug;③ 串台加固→`handleEvent` 抽成纯函数并补齐 `9b1c3f1` 那条不变式的回归测试。已用真实 Claude CLI 会话在浏览器验证 |
38 | [system-design/39-boss-agent-task-supervision.md](./done/system-design/39-boss-agent-task-supervision.md) | ✅ **P1-P5 全部已实现**(2026-08-30,`a00590a`/`5108d67`/`d967ac0`/`14cbaf5`)。boss agent(复用 `ceo`)监督 goal/task 看板闭环:draft task 回写、事件驱动+低频兜底唤醒、独立任务看板页、公司级 `target_dir`。**真实端到端测试**(建公司→下 goal→跑出一个 Playwright 5/5 验证通过的贪吃蛇游戏)撞出并记录了三个单测没覆盖的真问题,见文内「七、真跑复盘」;沉淀的待办已在 [42-org-work-graph-reliability-followups.md](./done/system-design/42-org-work-graph-reliability-followups.md) 全部收口 | 38 | [system-design/39-boss-agent-task-supervision.md](./done/system-design/39-boss-agent-task-supervision.md) | ✅ **P1-P5 全部已实现**(2026-08-30,`a00590a`/`5108d67`/`d967ac0`/`14cbaf5`)。boss agent(复用 `ceo`)监督 goal/task 看板闭环:draft task 回写、事件驱动+低频兜底唤醒、独立任务看板页、公司级 `target_dir`。**真实端到端测试**(建公司→下 goal→跑出一个 Playwright 5/5 验证通过的贪吃蛇游戏)撞出并记录了三个单测没覆盖的真问题,见文内「七、真跑复盘」;沉淀的待办已在 [42-org-work-graph-reliability-followups.md](./done/system-design/42-org-work-graph-reliability-followups.md) 全部收口 |
39 | [system-design/42-org-work-graph-reliability-followups.md](./done/system-design/42-org-work-graph-reliability-followups.md) | ✅ **Phase 1-4 全部已实现**(2026-08-30,`6ae1f76`/`2a60fe1`/`ae7650e`/`2a0ffb8`)。39 号文档真跑复盘沉淀的四 phase 可靠性收尾:① 卡住恢复闭环(服务器重启僵尸节点检测 + 强制判失败端点)② `ceo` 自建 task 的闸门穿透性回归测试 ③ `target_dir` 的 API/UI 配置入口(Phase 3 前端缺一次真实浏览器点击验证,见该节说明,是唯一已知缺口)④ `timeline.ndjson` 承接 planning/reflection 轨迹——`--artifact` 路径本身编码 `rootRunId`/`nodeRunId`,不用改任何 agent.md 或塞新环境变量 | 39 | [system-design/42-org-work-graph-reliability-followups.md](./done/system-design/42-org-work-graph-reliability-followups.md) | ✅ **Phase 1-4 全部已实现**(2026-08-30,`6ae1f76`/`2a60fe1`/`ae7650e`/`2a0ffb8`)。39 号文档真跑复盘沉淀的四 phase 可靠性收尾:① 卡住恢复闭环(服务器重启僵尸节点检测 + 强制判失败端点)② `ceo` 自建 task 的闸门穿透性回归测试 ③ `target_dir` 的 API/UI 配置入口(Phase 3 前端缺一次真实浏览器点击验证,见该节说明,是唯一已知缺口)④ `timeline.ndjson` 承接 planning/reflection 轨迹——`--artifact` 路径本身编码 `rootRunId`/`nodeRunId`,不用改任何 agent.md 或塞新环境变量 |
  40 +| [system-design/44-writes-artifacts-produces-gate-mismatch.md](./done/system-design/44-writes-artifacts-produces-gate-mismatch.md) | ✅ **已修复**(2026-08-31)。43 号文档真实回归中发现:`writesArtifacts:true` 节点(`goal-benchmark`)写进共享 `artifacts/` 目录的产物,产出闸门只查节点私有目录,永远判「没产出」。修法:`readDeclaredProduces` 的 `allowedRoots` 纳入共享区 + `goal-benchmark` 补一份 `contract.json` 显式声明 |
40 41
41 ## todo/(当前主线) 42 ## todo/(当前主线)
42 43
@@ -56,11 +57,10 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d @@ -56,11 +57,10 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d
56 | [28-followups.md](./todo/system-design/28-followups.md) | 28 号(Agent 注册表统一)的遗留清单 — 主文档已在 `done/`,这份装没做完的:dream 机制(设计见 29 号,已实现,见下)、`source_kind`(✅已删)/`legacy_path`(不能删)删列、三项「已实施未验证」。任务态清理(✅已实现,见下)。§五 是**已立起来的守卫清单**(别重复造,也别删) | 57 | [28-followups.md](./todo/system-design/28-followups.md) | 28 号(Agent 注册表统一)的遗留清单 — 主文档已在 `done/`,这份装没做完的:dream 机制(设计见 29 号,已实现,见下)、`source_kind`(✅已删)/`legacy_path`(不能删)删列、三项「已实施未验证」。任务态清理(✅已实现,见下)。§五 是**已立起来的守卫清单**(别重复造,也别删) |
57 | [30-task-delegation-reliability-gaps.md](./todo/system-design/30-task-delegation-reliability-gaps.md) | **任务委派可靠性现状**— 📝 调研记录,非设计方案。DevLoop `runUnit`、Company 招聘装配、Graph 执行三条链路核对结论一致:都是「失败预算耗尽 → 停等人工」,没有「卡住了主动通知人」这一层;DevLoop 当前还默认走 mock,真实 CLI 成功率无实证数据。2026-08-23 追加真实实例并已修:`infra_flake` 原地重跑没有熔断线,真撞上过一次确定性死循环(节点把诊断结论误判成自己的执行结果,反复重跑却从不路由到诊断建议的目标节点);已加 `infra_flake_count` 列 + 硬顶 3 次熔断,连续判 infra_flake 超过硬顶就改判 blocked 交给人工 | 58 | [30-task-delegation-reliability-gaps.md](./todo/system-design/30-task-delegation-reliability-gaps.md) | **任务委派可靠性现状**— 📝 调研记录,非设计方案。DevLoop `runUnit`、Company 招聘装配、Graph 执行三条链路核对结论一致:都是「失败预算耗尽 → 停等人工」,没有「卡住了主动通知人」这一层;DevLoop 当前还默认走 mock,真实 CLI 成功率无实证数据。2026-08-23 追加真实实例并已修:`infra_flake` 原地重跑没有熔断线,真撞上过一次确定性死循环(节点把诊断结论误判成自己的执行结果,反复重跑却从不路由到诊断建议的目标节点);已加 `infra_flake_count` 列 + 硬顶 3 次熔断,连续判 infra_flake 超过硬顶就改判 blocked 交给人工 |
58 | [31-work-graph-fail-fallback-coverage.md](./todo/system-design/31-work-graph-fail-fallback-coverage.md) | work graph 兜底边覆盖不全 — ✅ 已完成(除入口节点外强制兜底边,`planner.json`/`executor-fanout.json` 补 6 条边),978/978 单测通过;2026-08-21 又用真实 LLM 起了一条新任务做端到端回归验证,`org-planner-sdd` 自判失败后自动打回 `org-planner-prd`(3 次,零人工介入),修复确认生效 | 59 | [31-work-graph-fail-fallback-coverage.md](./todo/system-design/31-work-graph-fail-fallback-coverage.md) | work graph 兜底边覆盖不全 — ✅ 已完成(除入口节点外强制兜底边,`planner.json`/`executor-fanout.json` 补 6 条边),978/978 单测通过;2026-08-21 又用真实 LLM 起了一条新任务做端到端回归验证,`org-planner-sdd` 自判失败后自动打回 `org-planner-prd`(3 次,零人工介入),修复确认生效 |
59 -| [41-graph-vs-fde-reliability-discussion.md](./todo/system-design/41-graph-vs-fde-reliability-discussion.md) | 📝 **讨论记录 + 落地实测,非设计方案**。org/work graph 与 master-Innovator-FDE 两套工作流的稳定性对比:根因是"拓扑运行时生成 vs 写死"。问题 7 是真实执行记录——新建 `org-hunt/digest/evolve`(graph 原生协议,方法论抄 innovator-hunt/digest/evolver)跑 Hunt→Digest→Evolve,11 轮真跑中挖出并修复 5 个独立缺陷(建公司自动规划竞态、work-graph 模板库缺内容型交付物骨架、验收节点任务描述越权、两个规划 agent 的路径口径写错、R-19 产出索引 `indexNodeOutputs()` 不覆盖项目根——最后一条改了 `graph-engine.js` 核心逻辑,加 `contract.json` 的 `produces` 显式声明机制,全局生效,补了 2 条守卫测试)。最终产出与原生 FDE(`prd-methodology-trial`)对比:内容骨架可迁移、质量不打折,但过程严谨度不如原生 FDE,可靠性差距悬殊(原生一次跑通,graph 版 11 轮才稳定)。`routeFailure` 的 `infra_flake` 分支绕过跨层寻址(`target_node`)仍是唯一确认还没补的口子 | 60 +| [41-graph-vs-fde-reliability-discussion.md](./todo/system-design/41-graph-vs-fde-reliability-discussion.md) | 📝 **讨论记录 + 落地实测,非设计方案**。org/work graph 与 master-Innovator-FDE 两套工作流的稳定性对比:根因是"拓扑运行时生成 vs 写死"。问题 7 是真实执行记录——新建 `org-hunt/digest/evolve`(graph 原生协议,方法论抄 innovator-hunt/digest/evolver)跑 Hunt→Digest→Evolve,11 轮真跑中挖出并修复 5 个独立缺陷(建公司自动规划竞态、work-graph 模板库缺内容型交付物骨架、验收节点任务描述越权、两个规划 agent 的路径口径写错、R-19 产出索引 `indexNodeOutputs()` 不覆盖项目根——最后一条改了 `graph-engine.js` 核心逻辑,加 `contract.json` 的 `produces` 显式声明机制,全局生效,补了 2 条守卫测试)。最终产出与原生 FDE(`prd-methodology-trial`)对比:内容骨架可迁移、质量不打折,但过程严谨度不如原生 FDE,可靠性差距悬殊(原生一次跑通,graph 版 11 轮才稳定)。`routeFailure` 的 `infra_flake` 分支绕过跨层寻址(`target_node`)这个口子已按最保守方案补上(2026-08-31,见文内清单)——但堵不住 30 号文档记录的那次真实事故,那次事故点名的目标是下游节点,图模型本身就不允许 backward 边指向非祖先,属于修复范围之外 |
60 | [37-org-graph-department-scoping.md](./todo/system-design/37-org-graph-department-scoping.md) | 📝 **设计讨论,调用点扫描已完成,尚未立项实现**。现状 org graph 按 `company_id` 单例(`27-P2` 标题原文即"公司级"),塞不下 HR/财务/市场这类跟"交付流水线"无关的公司常设职能——`system/agents/hr/`("招·留·管·退")本体已存在却从没被装进任何图,全靠 fire-and-forget chat 招聘、无图执行审计轨迹。提议把 org graph 作用域从 `company_id` 升级为 `(company_id, department_id)`,一家公司可装配多张部门图,每张仍保持人工固定/慢变/可审计。全仓库调用点扫描结论:改造面是最初估算的 4 倍——真正的单点真相在 `work-graph-db.js:readDefGraph` 的裸 SQL 不变式(不在服务层),还牵涉两个易混淆服务文件间必须字节级一致的重复唯一索引、独立的 `graph-seed-service.js` 对账子系统、`company-hire-org-layer.test.js` 专属测试、`orggraph-planning`/`task-planning` 两份 agent 人设文档的自我认知改写 | 61 | [37-org-graph-department-scoping.md](./todo/system-design/37-org-graph-department-scoping.md) | 📝 **设计讨论,调用点扫描已完成,尚未立项实现**。现状 org graph 按 `company_id` 单例(`27-P2` 标题原文即"公司级"),塞不下 HR/财务/市场这类跟"交付流水线"无关的公司常设职能——`system/agents/hr/`("招·留·管·退")本体已存在却从没被装进任何图,全靠 fire-and-forget chat 招聘、无图执行审计轨迹。提议把 org graph 作用域从 `company_id` 升级为 `(company_id, department_id)`,一家公司可装配多张部门图,每张仍保持人工固定/慢变/可审计。全仓库调用点扫描结论:改造面是最初估算的 4 倍——真正的单点真相在 `work-graph-db.js:readDefGraph` 的裸 SQL 不变式(不在服务层),还牵涉两个易混淆服务文件间必须字节级一致的重复唯一索引、独立的 `graph-seed-service.js` 对账子系统、`company-hire-org-layer.test.js` 专属测试、`orggraph-planning`/`task-planning` 两份 agent 人设文档的自我认知改写 |
61 | [36-prd-agent-methodology-training-feasibility.md](./todo/system-design/36-prd-agent-methodology-training-feasibility.md) | 📝 **调研 + 阶段一/二均已完成**。回应"落地页太单薄是不是该用 Innovator 先训练专业 agent、再用 Dream 持续迭代"——认同方向。**阶段一**:`graph-engine.js` 结算节点后把 `success`/`fail:<reason>` 写进本体 `memory.md`(此前只有人类对话路径会写,Dream 天天空转)。**阶段二**:新建独立试验节点 `prd-methodology-trial` 走真实 Hunt→Digest→Evolve,2 轮收敛(96/97 分,真实基线 50 分),三个真实历史项目反推验证方法论有效;过程中连带修复同一个 2026-08-16 改名批次遗留的 5 处 `innovator`→`org-innovator` 死链(后端两处硬编码、前端"重新训练"按钮、`/api/agent/list` 展示 id 来源的四个 `config.json`),以及 `AgentScaffold` 默认模板一处裸相对路径缺陷。结论:FDE 对方法论类角色确实管用,但收敛速度/验收严谨度依赖任务书显式写明的自纠正要求,非免人工黑盒。环节 3(训练产物 → 雇佣本体转换)尚未开工 | 62 | [36-prd-agent-methodology-training-feasibility.md](./todo/system-design/36-prd-agent-methodology-training-feasibility.md) | 📝 **调研 + 阶段一/二均已完成**。回应"落地页太单薄是不是该用 Innovator 先训练专业 agent、再用 Dream 持续迭代"——认同方向。**阶段一**:`graph-engine.js` 结算节点后把 `success`/`fail:<reason>` 写进本体 `memory.md`(此前只有人类对话路径会写,Dream 天天空转)。**阶段二**:新建独立试验节点 `prd-methodology-trial` 走真实 Hunt→Digest→Evolve,2 轮收敛(96/97 分,真实基线 50 分),三个真实历史项目反推验证方法论有效;过程中连带修复同一个 2026-08-16 改名批次遗留的 5 处 `innovator`→`org-innovator` 死链(后端两处硬编码、前端"重新训练"按钮、`/api/agent/list` 展示 id 来源的四个 `config.json`),以及 `AgentScaffold` 默认模板一处裸相对路径缺陷。结论:FDE 对方法论类角色确实管用,但收敛速度/验收严谨度依赖任务书显式写明的自纠正要求,非免人工黑盒。环节 3(训练产物 → 雇佣本体转换)尚未开工 |
62 | [34-validator-scored-without-real-deliverable.md](./todo/system-design/34-validator-scored-without-real-deliverable.md) | 📝 **调研记录,checklist 已查完**。最初以为 `org-coder-impl` 从未产出任何 HTML/CSS,后来发现真实网站其实存在于 `graph_files` 索引不覆盖的「项目根」目录(已顺手修:控制台「产物」面板现在会展示项目根)。追加查清 validator 有没有真的做浏览器级检查——中途自我订正一次:不是"没工具做不到"(`bash` 能走到仓库自带的 Playwright,另一条对照任务证明可行),是**没人教它这么做**;抽查另外两个项目根后发现**验证严谨度完全不稳定**(同类任务有的做了真实 Playwright 级验证、有的只做静态正则、有的没验证就被取消),根因很可能是新招聘的 `unit-tester` 本体撞上了 [32 号文档](./todo/system-design/32-hire-body-fill-reliability.md) 那类"装配没填充完"的真实样本,不是平台工具限制 | 63 | [34-validator-scored-without-real-deliverable.md](./todo/system-design/34-validator-scored-without-real-deliverable.md) | 📝 **调研记录,checklist 已查完**。最初以为 `org-coder-impl` 从未产出任何 HTML/CSS,后来发现真实网站其实存在于 `graph_files` 索引不覆盖的「项目根」目录(已顺手修:控制台「产物」面板现在会展示项目根)。追加查清 validator 有没有真的做浏览器级检查——中途自我订正一次:不是"没工具做不到"(`bash` 能走到仓库自带的 Playwright,另一条对照任务证明可行),是**没人教它这么做**;抽查另外两个项目根后发现**验证严谨度完全不稳定**(同类任务有的做了真实 Playwright 级验证、有的只做静态正则、有的没验证就被取消),根因很可能是新招聘的 `unit-tester` 本体撞上了 [32 号文档](./todo/system-design/32-hire-body-fill-reliability.md) 那类"装配没填充完"的真实样本,不是平台工具限制 |
63 -| [44-writes-artifacts-produces-gate-mismatch.md](./todo/system-design/44-writes-artifacts-produces-gate-mismatch.md) | 📝 **真实回归中发现的缺陷记录,未修复**。43 号文档 Phase 2 真实验证时撞见:`goal-benchmark`(`planner.json` 里标 `writesArtifacts:true`)按文档正确把产物写进 root run 共享 `artifacts/` 目录,但产出闸门 `unsatisfiedRequiredProduces`(`graph-engine.js:4430`)只查节点私有目录(`nodeArtifactDir`),`writesArtifacts` 这个字段全仓库零代码读取——这条契约在当前实现下**永远无法通过**,不是偶发。真实证据:`artifacts/goal-benchmark.json` 磁盘上确认存在,闸门仍判「没找到」,强制 fail 沿兜底边回退。很可能正是 snakegame-co 参考任务里 `goal-benchmark`/`planner-validator` 显示 `skipped` 的真实原因。已有的 R-19 `contract.json` 显式声明逃生口(41 号文档修的那个)`allowedRoots` 也够不到共享 `artifacts/` 目录,一并够不到。留了两条候选修法,未实现 |  
64 | [43-task-decomposition-granularity.md](./todo/system-design/43-task-decomposition-granularity.md) | ✅ **Phase 0-4 全部已实现**(2026-08-31)。回应"org-planner 出 brd/prd/sdd 后,任务看板能不能展示成 task/sub-task 树,coder-validator 按清单逐条开发验收"。`tasks` 加 `acceptance_mode`/`parent_task_id`(后者复用既有列);`task-planning` 判断要不要拆(硬规则 5 开例外让 LLM 写这一个字段);org-planner 收尾把 `acceptance.md` 物化成子任务;`coder-validator` 新增 `task subtask-result` 工具逐条上报,`settleNode()` 新增闸门强制核对子任务全 `completed` 才认 success;看板加子任务展示。**落地时订正了一处原计划想岔的架构假设**:org-planner 在 org 层永远是 `subgraph` 节点(不是 agent_call),真跑数据显示它当前展开成的 `planner.json` 5 节点子图里,`goal-benchmark`/`planner-validator` 两节点常被 skip,`acceptance.md` 未必真的产出——钩子已改成扫子 run 全节点、找不到就优雅降级为 holistic,不阻塞主产出,但"子任务一定会长出来"这件事目前是尽力而为不是保证,见文档"实现记录"一节的遗留风险说明。全量单测 1120/1123 通过(3 个失败与本次改动无关,本次改动前即存在) | 64 | [43-task-decomposition-granularity.md](./todo/system-design/43-task-decomposition-granularity.md) | ✅ **Phase 0-4 全部已实现**(2026-08-31)。回应"org-planner 出 brd/prd/sdd 后,任务看板能不能展示成 task/sub-task 树,coder-validator 按清单逐条开发验收"。`tasks` 加 `acceptance_mode`/`parent_task_id`(后者复用既有列);`task-planning` 判断要不要拆(硬规则 5 开例外让 LLM 写这一个字段);org-planner 收尾把 `acceptance.md` 物化成子任务;`coder-validator` 新增 `task subtask-result` 工具逐条上报,`settleNode()` 新增闸门强制核对子任务全 `completed` 才认 success;看板加子任务展示。**落地时订正了一处原计划想岔的架构假设**:org-planner 在 org 层永远是 `subgraph` 节点(不是 agent_call),真跑数据显示它当前展开成的 `planner.json` 5 节点子图里,`goal-benchmark`/`planner-validator` 两节点常被 skip,`acceptance.md` 未必真的产出——钩子已改成扫子 run 全节点、找不到就优雅降级为 holistic,不阻塞主产出,但"子任务一定会长出来"这件事目前是尽力而为不是保证,见文档"实现记录"一节的遗留风险说明。全量单测 1120/1123 通过(3 个失败与本次改动无关,本次改动前即存在) |
65 65
66 ## todo/future/(暂缓,非当前重心) 66 ## todo/future/(暂缓,非当前重心)
docs/todo/system-design/44-writes-artifacts-produces-gate-mismatch.md renamed to docs/done/system-design/44-writes-artifacts-produces-gate-mismatch.md
1 # 44 · `writesArtifacts` 节点的产出闸门永远打不过:写到共享区,闸门查的是私有目录 1 # 44 · `writesArtifacts` 节点的产出闸门永远打不过:写到共享区,闸门查的是私有目录
2 2
3 -> **状态**: 📝 真实回归中发现的缺陷记录,已诊断到根因,**未修复**(本文档只记录,不含实现) 3 +> **状态**: ✅ **已修复**(2026-08-31)。按下面「四、建议修法」的方案 1 实现:`readDeclaredProduces`
  4 +> 的 `allowedRoots` 扩到 `runRootDir(run)/artifacts/` 共享区(`graph-engine.js`),`goal-benchmark`
  5 +> 的 `agent.md`/`tools.md` 补上"写完共享产物后也在自己节点私有目录声明一份 `contract.json`"
  6 +> 这一步。回归测试见 `graph-files-index.test.js` 的用例 ⑭
4 > **日期**: 2026-08-31 7 > **日期**: 2026-08-31
5 -> **发现场景**:[43 号文档](./43-task-decomposition-granularity.md) Phase 2 的真实端到端验证——新建 `task-decomp-verify-co` 公司,走一条真实 `itemized` 任务,org-planner 展开成 `system/work-graph-templates/planner.json` 那张 5 节点子图(真实 Claude CLI,非 mock) 8 +> **发现场景**:[43 号文档](../../todo/system-design/43-task-decomposition-granularity.md) Phase 2 的真实端到端验证——新建 `task-decomp-verify-co` 公司,走一条真实 `itemized` 任务,org-planner 展开成 `system/work-graph-templates/planner.json` 那张 5 节点子图(真实 Claude CLI,非 mock)
6 > **与 43 号文档的关系**:**完全独立的缺陷**,跟 goal 分解粒度那件事没有任何关系,只是恰好在验证 43 号文档时撞见。43 号文档"实现记录"一节提到的"acceptance.md 未必可靠",这份文档是那句话背后更具体、更确定的根因之一 9 > **与 43 号文档的关系**:**完全独立的缺陷**,跟 goal 分解粒度那件事没有任何关系,只是恰好在验证 43 号文档时撞见。43 号文档"实现记录"一节提到的"acceptance.md 未必可靠",这份文档是那句话背后更具体、更确定的根因之一
7 10
8 --- 11 ---
@@ -81,7 +84,7 @@ system/work-graph-templates/planner.json:11: { ..., &quot;writesArtifacts&quot;: true } @@ -81,7 +84,7 @@ system/work-graph-templates/planner.json:11: { ..., &quot;writesArtifacts&quot;: true }
81 84
82 ### 2.4 已有的逃生口(R-19 产出索引的显式声明机制)够不到共享区 85 ### 2.4 已有的逃生口(R-19 产出索引的显式声明机制)够不到共享区
83 86
84 -`graph-engine.js` 已经有一条为**同一类**问题准备的机制——`readDeclaredProduces`(`graph-engine.js:4377-4406`):节点可以在自己私有目录写一份 `contract.json`,里面显式声明 `produces: {kind: ["<绝对路径>"]}`,闸门会认这份声明而不是死磕目录扫描。这正是 [41 号文档](./41-graph-vs-fde-reliability-discussion.md) 提到的"R-19 产出索引不覆盖项目根,加 `contract.json` 的 `produces` 显式声明机制"那次修复的产物。 87 +`graph-engine.js` 已经有一条为**同一类**问题准备的机制——`readDeclaredProduces`(`graph-engine.js:4377-4406`):节点可以在自己私有目录写一份 `contract.json`,里面显式声明 `produces: {kind: ["<绝对路径>"]}`,闸门会认这份声明而不是死磕目录扫描。这正是 [41 号文档](../../todo/system-design/41-graph-vs-fde-reliability-discussion.md) 提到的"R-19 产出索引不覆盖项目根,加 `contract.json` 的 `produces` 显式声明机制"那次修复的产物。
85 88
86 但这条逃生口本身也够不到共享 `artifacts/` 目录——`inBounds()` 校验的 `allowedRoots` 只有 `[节点私有目录, 项目根]`(`graph-engine.js:4384`),不包含 `runRootDir(run)/artifacts/`。即使 `goal-benchmark` 主动在自己目录写一份 `contract.json` 声明 `produces.acceptance = ["/abs/path/to/artifacts/goal-benchmark.json"]`,这条声明也会因为路径不在 `allowedRoots` 里被判 `bad=true`,等于没声明。 89 但这条逃生口本身也够不到共享 `artifacts/` 目录——`inBounds()` 校验的 `allowedRoots` 只有 `[节点私有目录, 项目根]`(`graph-engine.js:4384`),不包含 `runRootDir(run)/artifacts/`。即使 `goal-benchmark` 主动在自己目录写一份 `contract.json` 声明 `produces.acceptance = ["/abs/path/to/artifacts/goal-benchmark.json"]`,这条声明也会因为路径不在 `allowedRoots` 里被判 `bad=true`,等于没声明。
87 90
@@ -91,7 +94,7 @@ system/work-graph-templates/planner.json:11: { ..., &quot;writesArtifacts&quot;: true } @@ -91,7 +94,7 @@ system/work-graph-templates/planner.json:11: { ..., &quot;writesArtifacts&quot;: true }
91 94
92 **任何一家公司只要 org-planner 走 `planner.json` 骨架(当前的常态,见 43 号文档 Phase 2 的真实数据核实),`org-planner-benchmark` 这一步几乎注定会撞这堵墙。** 每次撞墙都沿兜底回退边退回 `org-planner-sdd` 重来一轮(烧一次 sdd-planning 的真实调用 + 一次 goal-benchmark 的真实调用),直到 `loop_count` 撞到 `planner.json` 兜底边的硬顶(`LOOP_HARD_CAP_RUN` 相关,全树 `backward_count` 硬顶 5),最终大概率被强制判 `blocked` 或者 `planner-validator` 跟着一起被跳过。 95 **任何一家公司只要 org-planner 走 `planner.json` 骨架(当前的常态,见 43 号文档 Phase 2 的真实数据核实),`org-planner-benchmark` 这一步几乎注定会撞这堵墙。** 每次撞墙都沿兜底回退边退回 `org-planner-sdd` 重来一轮(烧一次 sdd-planning 的真实调用 + 一次 goal-benchmark 的真实调用),直到 `loop_count` 撞到 `planner.json` 兜底边的硬顶(`LOOP_HARD_CAP_RUN` 相关,全树 `backward_count` 硬顶 5),最终大概率被强制判 `blocked` 或者 `planner-validator` 跟着一起被跳过。
93 96
94 -**这很可能正是本 session 全程调试的参考任务(snakegame-co,[43 号文档](./43-task-decomposition-granularity.md) 的触发场景)里 `goal-benchmark`/`planner-validator` 两个节点显示 `skipped` 的真实原因**——不是没跑到,是跑了、正确地写了产物,却被这道闸门反复判定「没写」,最终在某一轮被放弃跳过。 97 +**这很可能正是本 session 全程调试的参考任务(snakegame-co,[43 号文档](../../todo/system-design/43-task-decomposition-granularity.md) 的触发场景)里 `goal-benchmark`/`planner-validator` 两个节点显示 `skipped` 的真实原因**——不是没跑到,是跑了、正确地写了产物,却被这道闸门反复判定「没写」,最终在某一轮被放弃跳过。
95 98
96 连带后果: 99 连带后果:
97 100
docs/todo/system-design/41-graph-vs-fde-reliability-discussion.md
@@ -351,7 +351,18 @@ FDE 无关,纯粹为了让 org-hunt 做真调研)反复 `rerun` 同一个 ta @@ -351,7 +351,18 @@ FDE 无关,纯粹为了让 org-hunt 做真调研)反复 `rerun` 同一个 ta
351 **已被另一并行会话实现**,见 [39-boss-agent-task-supervision.md](./39-boss-agent-task-supervision.md) 351 **已被另一并行会话实现**,见 [39-boss-agent-task-supervision.md](./39-boss-agent-task-supervision.md)
352 (P1-P5,commit `a00590a`/`5108d67`/`d967ac0`),本文档这条 TODO 作废,后续跟进请去那份 352 (P1-P5,commit `a00590a`/`5108d67`/`d967ac0`),本文档这条 TODO 作废,后续跟进请去那份
353 文档的「六、已知遗留」一节。 353 文档的「六、已知遗留」一节。
354 -- [ ] `routeFailure` 的 `infra_flake` 分支补上 `target_node` 路由(30 号文档遗留的"刻意没做②")。 354 +- [x] ~~`routeFailure` 的 `infra_flake` 分支补上 `target_node` 路由(30 号文档遗留的"刻意没做②")~~——
  355 + **已按最保守的方案实现**(2026-08-31,`graph-engine.js` 的 `infraFlakeTargetEdge`):
  356 + `target_node` 只有在图上真有一条从本节点出发的 **backward** fail 边指向那个节点时才
  357 + 生效,命中就改走正常的 backward 边路由(计入 backward_count/双层熔断),不再享受
  358 + "原地重跑、只受 infra_flake_count 约束"的免账本待遇;没命中(含没声明)维持原有的
  359 + 原地重跑行为不变。**这条修复堵不住 30 号文档记录的那次真实事故**——事故里
  360 + `org-coder-test` 点名的 `org-coder-validator` 是它的**下游**,而 `org-graph-service.js`
  361 + 规则 4(`backward-labeled`)从图结构上就不允许 backward 边指向非祖先节点,那次事故
  362 + 本来就不是"图上有边但引擎不认",是"诊断内容在打一个图结构不允许表达的主意"——
  363 + 已经靠 `INFRA_FLAKE_HARD_CAP` 熔断收敛,属于本条修复范围之外。这条修复堵的是更窄的
  364 + 另一种情形:诊断点名的目标恰好是本节点自己真实存在的祖先时,不该绕开正常的回退
  365 + 记账。回归测试见 `graph-engine.test.js` 的 `infra_flake` describe 块新增两条用例。
355 - [ ] `27-p4-parallel-and-artifacts.md` 并发 fan-out 设计推进。 366 - [ ] `27-p4-parallel-and-artifacts.md` 并发 fan-out 设计推进。
356 - [ ] 若要落地部门级 org graph(37 号文档方向),仍是"设计讨论,尚未立项"状态。 367 - [ ] 若要落地部门级 org graph(37 号文档方向),仍是"设计讨论,尚未立项"状态。
357 - [ ] **可选文档补丁**:`org-hunt/digest/evolve` 是"元 agent 不能直接入图,要建原生协议独立副本" 368 - [ ] **可选文档补丁**:`org-hunt/digest/evolve` 是"元 agent 不能直接入图,要建原生协议独立副本"
docs/todo/system-design/43-task-decomposition-granularity.md
@@ -162,4 +162,4 @@ Phase 2 排在 Phase 3 之前,且内部先做&quot;acceptance.md 是否真的可靠 @@ -162,4 +162,4 @@ Phase 2 排在 Phase 3 之前,且内部先做&quot;acceptance.md 是否真的可靠
162 162
163 **遗留风险**(订正后依然成立,写实话):`acceptance.md` 会不会被稳定产出,取决于 `workgraph-planning` 会不会给 org-planner 挑一张真正跑完 `goal-benchmark`/`planner-validator` 的图、以及那张图会不会真的产出这份文件——这两件事都不在本次改动范围内,`maybeMaterializeItemizedSubtasks()` 目前是**优雅降级**(找不到就跳过,不报错),不是**保证生效**。换句话说:`acceptance_mode='itemized'` 现在是"如果规划链路配合,就能自动长出子任务",还没做到"一定会长出子任务"。 163 **遗留风险**(订正后依然成立,写实话):`acceptance.md` 会不会被稳定产出,取决于 `workgraph-planning` 会不会给 org-planner 挑一张真正跑完 `goal-benchmark`/`planner-validator` 的图、以及那张图会不会真的产出这份文件——这两件事都不在本次改动范围内,`maybeMaterializeItemizedSubtasks()` 目前是**优雅降级**(找不到就跳过,不报错),不是**保证生效**。换句话说:`acceptance_mode='itemized'` 现在是"如果规划链路配合,就能自动长出子任务",还没做到"一定会长出子任务"。
164 164
165 -**2026-08-31 真实回归验证坐实了这条风险**,而且找到了比"未必可靠"更具体的根因:见 [44 号文档](./44-writes-artifacts-produces-gate-mismatch.md)——`goal-benchmark` 按设计把产物写进 root run 共享 `artifacts/` 目录,但产出闸门只查节点私有目录,`writesArtifacts:true` 这个字段全仓库零代码读取,这条契约在当前实现下**结构性地**打不过,不是运气不好。这很可能正是本文档触发场景(snakegame-co)里 `goal-benchmark`/`planner-validator` 显示 `skipped` 的真实原因。44 号文档只记录诊断,未修复;修复它会提高 Phase 2 的实际命中率,但不在 43 号文档范围内,不在这里展开。 165 +**2026-08-31 真实回归验证坐实了这条风险**,而且找到了比"未必可靠"更具体的根因:见 [44 号文档](../../done/system-design/44-writes-artifacts-produces-gate-mismatch.md)——`goal-benchmark` 按设计把产物写进 root run 共享 `artifacts/` 目录,但产出闸门只查节点私有目录,`writesArtifacts:true` 这个字段全仓库零代码读取,这条契约在当前实现下**结构性地**打不过,不是运气不好。这很可能正是本文档触发场景(snakegame-co)里 `goal-benchmark`/`planner-validator` 显示 `skipped` 的真实原因。44 号文档已修复(同日),会提高 Phase 2 的实际命中率,但验证不在 43 号文档范围内,不在这里展开。
server/services/graph-engine.js
@@ -2016,11 +2016,55 @@ function selectFailEdge(def, defNodeId, failReason) { @@ -2016,11 +2016,55 @@ function selectFailEdge(def, defNodeId, failReason) {
2016 return candidates.find((e) => e.failReason === null || e.failReason === undefined || e.failReason === '') || null; 2016 return candidates.find((e) => e.failReason === null || e.failReason === undefined || e.failReason === '') || null;
2017 } 2017 }
2018 2018
  2019 +/**
  2020 + * 41 号文档修复:infra_flake 诊断里带的 `target_node`(`fail_node_ref` 解出的 `workNode`),
  2021 + * 只有在图上真有一条从本节点出发的 **backward** fail 边指向那个节点时才算数——边赢,
  2022 + * 声明只是主张(同 `resolveCrossLayerTarget` 的设计哲学:地址与路由打架时以图上真实
  2023 + * 存在的边为准)。
  2024 + *
  2025 + * 只认 `backward` 边有两层原因:
  2026 + * 1. 只有 backward 边享有"计入 backward_count、受双层硬顶约束"这份安全网——forward
  2027 + * 的 fail 边(比如"失败转人工审核")走的是 `markReady` 直接点亮,没有熔断线;
  2028 + * 认 forward 边等于又开一条不受任何计数约束的重定向通道,跟这条修复本来要堵的
  2029 + * 口子是同一类风险。
  2030 + * 2. `org-graph-service.js` 规则 4(`backward-labeled`)从图结构上禁止声明"目标不是
  2031 + * 源节点祖先"的 backward 边(校验判据是"去掉这条边后目标是否仍能沿 forward 边
  2032 + * 回到源节点")——也就是说,backward 边的目标**必然是**当前节点的祖先,因此
  2033 + * `handleBackwardEdge` 第③步"重置目标 + 它的 forward 下游"必然会把当前这个
  2034 + * 诊断节点自己也一并重置回 pending(它就在自己声称的祖先的下游),不会有节点
  2035 + * 卡死在 fail 里出不来。
  2036 + *
  2037 + * **这条修复堵不住 30 号文档那次真实事故**:`org-coder-test` 当时点名的
  2038 + * `org-coder-validator` 是它的**下游**(`executor.json` 里 test→validator 是 forward
  2039 + * 边),根据上面第 2 点,图模型里根本不可能存在一条合法的 backward 边指向下游节点——
  2040 + * 这次事故本来就不属于"图上有边但引擎不认",而是"诊断内容本身在打一个图结构不允许
  2041 + * 表达的主意"。那次事故已经靠 `INFRA_FLAKE_HARD_CAP` 熔断收敛(见上面两条 §六 9c
  2042 + * 用例),这条修复堵的是另一种更窄的情形:诊断点名的目标恰好是本节点自己真实存在
  2043 + * 的祖先时,不再享受"原地重跑、只受 infra_flake_count 约束"的免账本待遇,改走跟别的
  2044 + * 失败一样受 backward_count/双层硬顶约束的正常路由。
  2045 + *
  2046 + * @returns {object|null} 命中的边,或 null(没声明 / 声明的节点没有对应边)
  2047 + */
  2048 +function infraFlakeTargetEdge(def, nodeRun) {
  2049 + const ref = parseNodeRef(nodeRun.fail_node_ref);
  2050 + if (!ref || !ref.workNode) return null;
  2051 + return def.edges.find((e) => {
  2052 + if (e.from !== nodeRun.def_node_id || e.condition !== 'fail' || e.edgeKind !== 'backward') return false;
  2053 + const target = def.nodes.get(e.to);
  2054 + return !!target && target.name === ref.workNode;
  2055 + }) || null;
  2056 +}
  2057 +
2019 function routeFailure(runId, def, nodeRun, out, remediationNote = null) { 2058 function routeFailure(runId, def, nodeRun, out, remediationNote = null) {
  2059 + const infraFlakeEdge = nodeRun.fail_reason === INFRA_FLAKE_REASON
  2060 + ? infraFlakeTargetEdge(def, nodeRun) : null;
  2061 +
2020 // infra_flake 特例(§四.D.2.1 末段):基础设施抖动直接**原地重跑**,不选边、不写 2062 // infra_flake 特例(§四.D.2.1 末段):基础设施抖动直接**原地重跑**,不选边、不写
2021 // loop record、不加 backward_count、不扣该节点的 retry 预算(attempt 也不加一)。 2063 // loop record、不加 backward_count、不扣该节点的 retry 预算(attempt 也不加一)。
2022 // 终止性由 §四.A.3 的任务级总预算兜底——每次重跑仍算一次 agent 调用,池子空了整棵树挂起。 2064 // 终止性由 §四.A.3 的任务级总预算兜底——每次重跑仍算一次 agent 调用,池子空了整棵树挂起。
2023 - if (nodeRun.fail_reason === INFRA_FLAKE_REASON) { 2065 + // 41 号文档修复:这条快捷路径只在「没有可信的 target_node」时才走——诊断点名的目标
  2066 + // 若真有对应的 fail 边(infraFlakeEdge 非空),改走下面的正常路由,不再原地重跑自己。
  2067 + if (nodeRun.fail_reason === INFRA_FLAKE_REASON && !infraFlakeEdge) {
2024 const d = def.nodes.get(nodeRun.def_node_id) || {}; 2068 const d = def.nodes.get(nodeRun.def_node_id) || {};
2025 const name = d.name || nodeRun.def_node_id; 2069 const name = d.name || nodeRun.def_node_id;
2026 2070
@@ -2058,7 +2102,20 @@ function routeFailure(runId, def, nodeRun, out, remediationNote = null) { @@ -2058,7 +2102,20 @@ function routeFailure(runId, def, nodeRun, out, remediationNote = null) {
2058 return; 2102 return;
2059 } 2103 }
2060 2104
2061 - const edge = selectFailEdge(def, nodeRun.def_node_id, nodeRun.fail_reason); 2105 + if (infraFlakeEdge) {
  2106 + const d = def.nodes.get(nodeRun.def_node_id) || {};
  2107 + const targetName = (def.nodes.get(infraFlakeEdge.to) || {}).name || infraFlakeEdge.to;
  2108 + sse.emitEvent(runId, 'node.triage',
  2109 + `infra_flake 诊断指名的目标 ${targetName} 在图上有对应 fail 边,按正常回退路由处理(计入 backward_count/双层熔断),不再原地重跑 ${d.name || nodeRun.def_node_id} 自己`,
  2110 + { node_run_id: nodeRun.id, node_name: d.name || nodeRun.def_node_id,
  2111 + target_node_ref: nodeRun.fail_node_ref, edge_id: infraFlakeEdge.id },
  2112 + { ref: nodeRun.id });
  2113 + }
  2114 +
  2115 + // infraFlakeEdge 命中时直接用它,不再交给 selectFailEdge 按 failReason 重新挑一遍——
  2116 + // 已经验证过这条边的目标就是诊断点名的那个节点,不该再被"有没有精确匹配 infra_flake
  2117 + // 这个 failReason 字符串"的兜底规则挑到另一条边去。
  2118 + const edge = infraFlakeEdge || selectFailEdge(def, nodeRun.def_node_id, nodeRun.fail_reason);
2062 2119
2063 // 死路:没有可走的 fail 边 → 节点 blocked,等人工介入(区别于人工主动「停止」)。 2120 // 死路:没有可走的 fail 边 → 节点 blocked,等人工介入(区别于人工主动「停止」)。
2064 // 判词的收件人是**它自己**:人工「重试」把它打回 ready 之后(retryNode 不动这一列), 2121 // 判词的收件人是**它自己**:人工「重试」把它打回 ready 之后(retryNode 不动这一列),
@@ -4367,10 +4424,17 @@ const { companyRootDir, taskDirRel } = require(&#39;./company-paths&#39;); @@ -4367,10 +4424,17 @@ const { companyRootDir, taskDirRel } = require(&#39;./company-paths&#39;);
4367 * 采信一份可能指错地方/指向不存在文件的声明(39 号文档确认的设计取向:静默放过比 4424 * 采信一份可能指错地方/指向不存在文件的声明(39 号文档确认的设计取向:静默放过比
4368 * 显式报错更难排查)。 4425 * 显式报错更难排查)。
4369 * 1. 路径必须是绝对路径 4426 * 1. 路径必须是绝对路径
4370 - * 2. 路径必须落在「项目根」或「本节点私有目录」之内——不能是引擎仓库根,也不能是  
4371 - * 别的节点私有目录(那是真正的越界读写,不该被一份自称的声明放行) 4427 + * 2. 路径必须落在「项目根」「本节点私有目录」或「root run 共享 artifacts/ 目录」之内
  4428 + * ——不能是引擎仓库根,也不能是别的节点私有目录(那是真正的越界读写,不该被
  4429 + * 一份自称的声明放行)
4372 * 3. 文件必须真实存在且是一个普通文件 4430 * 3. 文件必须真实存在且是一个普通文件
4373 * 4431 *
  4432 + * 44 号文档修复:`writesArtifacts: true` 的节点(如 goal-benchmark)按设计把产物写进
  4433 + * `runRootDir(run)/artifacts/` 共享区(多个节点合成同一份 acceptance.json 的地方,天然
  4434 + * 不可能落在某个节点自己的私有目录),此前这里的 allowedRoots 够不到那个目录,导致这类
  4435 + * 节点即使自己在私有目录写了合法的 contract.json 声明,也会因为声明的路径「越界」被判
  4436 + * 「没产出」——闸门在当前实现下永远打不过,不是 agent 表现不好。
  4437 + *
4374 * @returns {Map<string, string[]|null>} kind -> 校验通过的绝对路径数组; 4438 * @returns {Map<string, string[]|null>} kind -> 校验通过的绝对路径数组;
4375 * 校验不通过的 kind 映到 `null` 4439 * 校验不通过的 kind 映到 `null`
4376 */ 4440 */
@@ -4381,7 +4445,7 @@ function readDeclaredProduces(run, dir) { @@ -4381,7 +4445,7 @@ function readDeclaredProduces(run, dir) {
4381 if (!produces || typeof produces !== 'object') return out; 4445 if (!produces || typeof produces !== 'object') return out;
4382 4446
4383 const projectRoot = readProjectRootRecord(run); 4447 const projectRoot = readProjectRootRecord(run);
4384 - const allowedRoots = [dir, projectRoot && projectRoot.root_path] 4448 + const allowedRoots = [dir, projectRoot && projectRoot.root_path, path.join(runRootDir(run), 'artifacts')]
4385 .filter(Boolean) 4449 .filter(Boolean)
4386 .map((p) => path.resolve(p)); 4450 .map((p) => path.resolve(p));
4387 const inBounds = (abs) => allowedRoots.some((root) => abs === root || abs.startsWith(root + path.sep)); 4451 const inBounds = (abs) => allowedRoots.some((root) => abs === root || abs.startsWith(root + path.sep));
server/tests/unit/graph-engine.test.js
@@ -943,6 +943,85 @@ describe(&#39;graph-engine&#39;, () =&gt; { @@ -943,6 +943,85 @@ describe(&#39;graph-engine&#39;, () =&gt; {
943 expect(wdb.getRun(runId).backward_count).toBe(0); 943 expect(wdb.getRun(runId).backward_count).toBe(0);
944 expect(wdb.getRun(runId).run_status).toBe('blocked'); 944 expect(wdb.getRun(runId).run_status).toBe('blocked');
945 }); 945 });
  946 +
  947 + // 41 号文档修复:target_node 只有在图上真有一条从本节点出发的 backward fail 边指向
  948 + // 那个节点时才生效——边赢,声明只是主张。
  949 + //
  950 + // ⚠️ 图校验的"backward-labeled"规则(org-graph-service.js 规则 4)要求:backward 边
  951 + // 必须真的构成回路,也就是**目标必须是源节点的祖先**(去掉这条边后,目标仍能沿 forward
  952 + // 边回到源节点)。这意味着 30 号文档那次真实事故——org-coder-test 点名 target_node=
  953 + // org-coder-validator(validator 是 test 的**下游**,不是祖先)——在这套图模型里**永远
  954 + // 不可能**存在一条合法的 backward 边指向它,不管这条修复怎么写都堵不住/也不需要堵,
  955 + // 因为图本身就不允许声明这种边(那次事故按原有行为处理,即上面两条 §六 9c 用例:
  956 + // 靠 infra_flake_count 硬顶收敛,不是本条修复要覆盖的场景)。
  957 + //
  958 + // 本条修复真正堵的是**另一种**情形:target_node 点名的节点恰好是本节点自己真实存在的
  959 + // 祖先(比如 test 对着它本来就有的 test→impl 退回边,点名 target_node=impl)——命中时
  960 + // 不再走"原地重跑诊断节点自己、只受 infra_flake_count 约束"这条免账本快捷路径,改走
  961 + // 正常的 backward 边路由(计入 backward_count、受双层硬顶约束),跟别的失败一视同仁。
  962 + it('target_node 点名的是自己真实存在的祖先(不是描述里那种指向下游的情形):'
  963 + + '命中对应 backward 边,不再原地重跑诊断节点自己,改走正常回退路由(计入 backward_count)', async () => {
  964 + const companyId = nextCompany();
  965 + const { runId } = buildWorkRun({
  966 + companyId,
  967 + nodes: ['impl', 'test', 'validator'],
  968 + edges: [
  969 + { from: 'impl', to: 'test' },
  970 + { from: 'test', to: 'validator' },
  971 + { from: 'test', to: 'impl', condition: 'fail', edgeKind: 'backward' }, // 真实的 executor.json 拓扑
  972 + ],
  973 + mode: 'auto',
  974 + });
  975 + const calls = fakeExecutor({
  976 + impl: (nth) => (nth === 1 ? { status: 'success' } : { status: 'success' }),
  977 + // 诊断点名的正是 test 自己那条已有 backward 边的目标:impl
  978 + test: (nth) => (nth === 1
  979 + ? { status: 'fail', failReason: 'infra_flake', failNodeRef: { workNode: 'impl' } }
  980 + : { status: 'success' }),
  981 + validator: { status: 'success' },
  982 + });
  983 +
  984 + await engine.startRun(runId, { mode: 'auto' }).driver;
  985 +
  986 + // 不是原地重跑 test 自己——test 的 forward 下游(含它自己)被 impl 那条回退重置,
  987 + // impl 重新跑一遍带出新一轮 test/validator,test 总共被真实调用两次(不是"原地重跑")
  988 + expect(calls.filter((c) => c.node === 'impl')).toHaveLength(2);
  989 + expect(nodeByName(runId, 'validator').status).toBe('success');
  990 + // 走的是正常回退路由,不是 infra_flake 的免账本快捷路径
  991 + expect(wdb.listLoopRecords(runId)).toHaveLength(1);
  992 + expect(wdb.getRun(runId).backward_count).toBe(1);
  993 + });
  994 +
  995 + it('target_node 没命中任何 backward fail 边(比如指向的节点是自己的下游而非祖先——'
  996 + + '图模型本身就不允许这种边存在):维持原地重跑诊断节点自己,不静默改道', async () => {
  997 + const companyId = nextCompany();
  998 + const { runId } = buildWorkRun({
  999 + companyId,
  1000 + nodes: ['impl', 'test', 'validator'],
  1001 + edges: [
  1002 + { from: 'impl', to: 'test' },
  1003 + { from: 'test', to: 'validator' },
  1004 + { from: 'test', to: 'impl', condition: 'fail', edgeKind: 'backward' }, // 真实的 executor.json 拓扑
  1005 + ],
  1006 + mode: 'auto',
  1007 + });
  1008 + const calls = fakeExecutor({
  1009 + impl: { status: 'success' },
  1010 + // 诊断点名 validator,但图上 test 的 fail 边只通向 impl——地址够不到(也永远不可能够到)
  1011 + test: (nth) => (nth === 1
  1012 + ? { status: 'fail', failReason: 'infra_flake', failNodeRef: { workNode: 'validator' } }
  1013 + : { status: 'success' }),
  1014 + validator: { status: 'success' },
  1015 + });
  1016 +
  1017 + await engine.startRun(runId, { mode: 'auto' }).driver;
  1018 +
  1019 + // 原地重跑了 test 自己(今天已有的行为,target_node 够不到就当没声明)
  1020 + expect(calls.filter((c) => c.node === 'test')).toHaveLength(2);
  1021 + expect(calls.filter((c) => c.node === 'validator')).toHaveLength(1);
  1022 + expect(wdb.listLoopRecords(runId)).toHaveLength(0);
  1023 + expect(wdb.getRun(runId).backward_count).toBe(0);
  1024 + });
946 }); 1025 });
947 1026
948 // ────────────────────────────────────────────────────────────── 1027 // ──────────────────────────────────────────────────────────────
server/tests/unit/graph-files-index.test.js
@@ -678,4 +678,47 @@ describe(&#39;R-19 产物索引:graph_files / graph_edge_contracts&#39;, () =&gt; { @@ -678,4 +678,47 @@ describe(&#39;R-19 产物索引:graph_files / graph_edge_contracts&#39;, () =&gt; {
678 678
679 try { fs.rmSync(outsideDir, { recursive: true, force: true }); } catch {} 679 try { fs.rmSync(outsideDir, { recursive: true, force: true }); } catch {}
680 }); 680 });
  681 +
  682 + it('⑭ 44 号文档修复:声明的 produces 指向 root run 共享 artifacts/ 目录(不是节点私有目录/项目根)'
  683 + + '——不再判越界,节点按 success 结算', async () => {
  684 + const companyId = 'co-files-shared-artifacts';
  685 + const taskId = 'task-shared-artifacts';
  686 + const b = buildRun({
  687 + companyId, taskId,
  688 + nodes: [{ nodeName: 'w-benchmark' }, { nodeName: 'w-validator' }],
  689 + edges: [{ from: 'w-benchmark', to: 'w-validator', condition: 'success' }],
  690 + });
  691 + wdb.declareEdgeContract({
  692 + edgeLayer: 'work', edgeId: b.edgeId('w-benchmark', 'w-validator'), kind: 'acceptance', required: true,
  693 + });
  694 +
  695 + engine.setAgentExecutor(async (ctx) => {
  696 + if (ctx.nodeName !== 'w-benchmark') return { status: 'success' };
  697 + // 产物写进共享区,不是节点私有目录——goal-benchmark 真实的写法
  698 + const sharedDir = path.join(runsDir, ctx.run.root_run_id, 'artifacts');
  699 + fs.mkdirSync(sharedDir, { recursive: true });
  700 + const sharedPath = path.join(sharedDir, 'goal-benchmark.json');
  701 + fs.writeFileSync(sharedPath, JSON.stringify({ dimensions: [] }));
  702 +
  703 + const dir = nodeDir(ctx.run.root_run_id, ctx.nodeRunId);
  704 + fs.mkdirSync(dir, { recursive: true });
  705 + // 声明指向共享区绝对路径——44 号文档修复前,这条声明会被判「越界」
  706 + fs.writeFileSync(path.join(dir, 'contract.json'), JSON.stringify({
  707 + produces: { acceptance: sharedPath },
  708 + }));
  709 + return { status: 'success', outputRef: dir };
  710 + });
  711 + try {
  712 + await engine.advanceOne(b.runId);
  713 + await engine.advanceOne(b.runId);
  714 + } finally {
  715 + engine.setAgentExecutor(null);
  716 + }
  717 +
  718 + const state = wdb.getRunState(b.runId);
  719 + const benchmark = state.nodes.find((n) => n.name === 'w-benchmark');
  720 + expect(benchmark.failReason).toBeFalsy();
  721 + expect(benchmark.status).toBe('success');
  722 + expect(state.nodes.find((n) => n.name === 'w-validator').status).toBe('success');
  723 + });
681 }); 724 });
system/agents/goal-benchmark/agent.md
@@ -156,6 +156,23 @@ node server/services/goal-contract.js validate --run-dir $R # 必须退出 0 @@ -156,6 +156,23 @@ node server/services/goal-contract.js validate --run-dir $R # 必须退出 0
156 `--ref` 指的是 `artifacts/` 下的相对路径(大块内容外置,契约里存 `{"$ref": "goal-benchmark.json"}`)。 156 `--ref` 指的是 `artifacts/` 下的相对路径(大块内容外置,契约里存 `{"$ref": "goal-benchmark.json"}`)。
157 `validate` 退出码:`0` 合法 / `1` 契约非法(照它打印的 `[rule] detail` 改)/ `2` 压根没契约或参数错。 157 `validate` 退出码:`0` 合法 / `1` 契约非法(照它打印的 `[rule] detail` 改)/ `2` 压根没契约或参数错。
158 158
  159 +### 2.5) 图执行侧的产出闸门:自己节点目录也要写一份 `contract.json`(44 号文档修复)
  160 +
  161 +上面第 2 步挂进的是**验收契约**(`acceptance.json`,给 `benchmark-validator` 判卷用);这一步
  162 +是**引擎的产出闸门**(`graph-engine.js` 的 `unsatisfiedRequiredProduces`),两回事,缺一不可。
  163 +
  164 +你写进共享区的 `artifacts/goal-benchmark.json` 天生不在你自己的节点私有目录里(三个节点合成
  165 +同一份 `acceptance.json`,只能落共享区)——引擎的产出闸门默认只扫节点私有目录,扫不到就会把
  166 +这次明明成功的执行强制判 `fail_reason: produces_missing`,沿兜底边打回 `org-planner-sdd` 重来。
  167 +唯一的解法是显式声明:在 `runs/{rootRunId}/nodes/{你自己的 nodeRunId}/contract.json` 写
  168 +
  169 +```json
  170 +{ "produces": { "acceptance": "<runs/{rootRunId}/artifacts/goal-benchmark.json 的绝对路径>" } }
  171 +```
  172 +
  173 +必须是绝对路径,且必须在第 1 步真写完那份文件**之后**再写这份声明(引擎会去校验文件是否
  174 +真实存在)。漏了这一步,哪怕维度表内容完全正确,这个节点也永远过不了闸门。
  175 +
159 ### 3) 自己的节点目录:`attempt-{n}.json`(引擎读它判本节点成功/失败) 176 ### 3) 自己的节点目录:`attempt-{n}.json`(引擎读它判本节点成功/失败)
160 177
161 ```jsonc 178 ```jsonc
system/agents/goal-benchmark/tools.md
@@ -25,6 +25,16 @@ ls $R/artifacts/ # 别人已ç»å†™äº†å“ªå‡ @@ -25,6 +25,16 @@ ls $R/artifacts/ # 别人已ç»å†™äº†å“ªå‡
25 用文件写入工具写 `$R/artifacts/goal-benchmark.json`ï¼ˆå½¢çŠ¶è§ `agent.md`)。 25 用文件写入工具写 `$R/artifacts/goal-benchmark.json`ï¼ˆå½¢çŠ¶è§ `agent.md`)。
26 **这是你唯一有æƒå†™è¿›å…±äº«åŒºçš„æ–‡ä»¶**(`writesArtifacts: true` åªç»™äº†ä½ è¿™ä¸€æ ¼ç‰¹æƒï¼‰ã€‚ 26 **这是你唯一有æƒå†™è¿›å…±äº«åŒºçš„æ–‡ä»¶**(`writesArtifacts: true` åªç»™äº†ä½ è¿™ä¸€æ ¼ç‰¹æƒï¼‰ã€‚
27 27
  28 +写完**立刻**åœ¨ä½ è‡ªå·±çš„èŠ‚ç‚¹ç§æœ‰ç›®å½•å†å†™ä¸€ä»½ `contract.json`(44 å·æ–‡æ¡£ä¿®å¤çš„一步,别æ¼ï¼‰ï¼š
  29 +
  30 +```json
  31 +{ "produces": { "acceptance": "<$R/artifacts/goal-benchmark.json çš„ç»å¯¹è·¯å¾„>" } }
  32 +```
  33 +
  34 +写到 `$R/nodes/<ä½ çš„ nodeRunId>/contract.json`。**必须是ç»å¯¹è·¯å¾„**——引擎按这份声明去共享区
  35 +找文件,ä¸å£°æ˜Žæˆ–声明æˆç›¸å¯¹è·¯å¾„,你这一步会被判「没产出ã€å¼ºåˆ¶å›žé€€é‡æ¥ï¼ˆå“ªæ€•维度表已ç»
  36 +正确写进了共享区)。
  37 +
28 ## 3. 挂进契约 + 校验(`server/services/goal-contract.js` 的 CLI) 38 ## 3. 挂进契约 + 校验(`server/services/goal-contract.js` 的 CLI)
29 39
30 ```bash 40 ```bash