44-writes-artifacts-produces-gate-mismatch.md 9.58 KB

44 · writesArtifacts 节点的产出闸门永远打不过:写到共享区,闸门查的是私有目录

状态: ✅ 已修复(2026-08-31)。按下面「四、建议修法」的方案 1 实现:readDeclaredProduces 的 allowedRoots 扩到 runRootDir(run)/artifacts/ 共享区(graph-engine.js),goal-benchmark 的 agent.md/tools.md 补上"写完共享产物后也在自己节点私有目录声明一份 contract.json" 这一步。回归测试见 graph-files-index.test.js 的用例 ⑭ 日期: 2026-08-31 发现场景:43 号文档 Phase 2 的真实端到端验证——新建 task-decomp-verify-co 公司,走一条真实 itemized 任务,org-planner 展开成 system/work-graph-templates/planner.json 那张 5 节点子图(真实 Claude CLI,非 mock) 与 43 号文档的关系:完全独立的缺陷,跟 goal 分解粒度那件事没有任何关系,只是恰好在验证 43 号文档时撞见。43 号文档"实现记录"一节提到的"acceptance.md 未必可靠",这份文档是那句话背后更具体、更确定的根因之一


一、现象

真实回归任务里,org-planner 展开的 planner.json 子图跑到 org-planner-benchmark(本体 goal-benchmark,模板里标了 "writesArtifacts": true)这一步:

  • agent 自己的判决(attempt-0.json)写的是 "verdict": "produced",output.artifact 指向 artifacts/goal-benchmark.json;
  • 落到 graph-engine.js 的产出闸门(unsatisfiedRequiredProduces,server/services/graph-engine.js:4430-4467)却判定「节点声明会产出 acceptance,但产物目录里没找到对应文件」,把这次 success 强制改判 fail(fail_reason: produces_missing);
  • 沿 planner.json 的兜底回退边退回 org-planner-sdd 重跑,loop_count 1/3。

时间线原文(data/companies/task-decomp-verify-co/runs/run_edaf3ea3ed69/timeline.ndjson):

18:24:17.177  node.output   org-planner-benchmark → success
18:24:17.178  node.output   节点声明会产出 acceptance,但产物目录里没找到对应文件,按 fail 结算
              payload: {"missing_kinds":["acceptance"],"degraded":"produces_missing"}
18:24:17.178  node.status   org-planner-benchmark: running → fail (failReason: produces_missing)
18:24:17.179  loop.registered  回退备案 loop_count=1/3 · backward_count=1/5
18:24:17.179  node.status   org-planner-benchmark: fail → pending(上游回退,本轮产出作废)

关键点:agent 没有做错任何事——它确实把文件写出来了,就在它自己判决里写的那个路径:

$ find data/companies/task-decomp-verify-co/runs/run_edaf3ea3ed69/artifacts -type f
data/companies/task-decomp-verify-co/runs/run_edaf3ea3ed69/artifacts/goal-benchmark.json   # 真实存在,内容正常

而闸门检查的是这个:

$ ls data/companies/task-decomp-verify-co/runs/run_edaf3ea3ed69/nodes/rn_d5fb26f5eede/
attempt-0.json   # 只有这一份,闸门要找的 acceptance 相关文件不在这

两个不是同一个目录,而且永远不会是同一个目录——下面「根因」一节说明为什么。


二、根因:闸门只认节点私有目录,writesArtifacts:true 是纯文档、代码里没人读

2.1 goal-benchmark 被明确指示写共享区,这是设计意图不是失误

system/agents/goal-benchmark/agent.md:13,43 白纸黑字写着:

产物写进 root run 的 artifacts/ 共享区…… runs/{rootRunId}/artifacts/:共享产物区。你对它有写权(writesArtifacts: true),但只写自己那一段

goal-benchmark/tools.md:25:用文件写入工具写 $R/artifacts/goal-benchmark.json。

这是因为 acceptance.json(R-19-B 契约)是三个 org node 各写一段合成的(benchmark/test_data/test_script),天然是跨节点共享状态,只能落在 root run 级别的共享区,不可能落在某一个节点自己的私有目录里——落在私有目录里,别的节点根本读不到它那一段。

2.2 但闸门只查节点私有目录

unsatisfiedRequiredProduces(server/services/graph-engine.js:4430-4467):

dir = nodeArtifactDir(run, nodeRun.id);          // 4452 行:只算节点私有目录
if (!fs.existsSync(dir) || ...) return declared;
files = fs.readdirSync(dir)...

nodeArtifactDir(run, nodeRunId) = runRootDir(run)/nodes/{nodeRunId}(graph-engine.js:3680),不包含 runRootDir(run)/artifacts/。所以只要 goal-benchmark 正确地按文档写到共享区,它自己节点私有目录下除了 attempt-N.json(会被过滤掉,见 4458-4459 行)什么都不会有,hasCandidates 恒为 false,acceptance 这个 kind 恒被判定「缺」。

这条契约在当前实现下,无论 agent 表现多好都无法通过。

2.3 writesArtifacts: true 这个字段,全仓库零代码读取

$ grep -rn "writesArtifacts" server/ system/ --include="*.js" --include="*.json"
system/work-graph-templates/planner.json:11:    { ..., "writesArtifacts": true }

唯一一处命中就是模板里声明它的那一行。它是纯文档性质的元数据,从没被 unsatisfiedRequiredProduces、indexNodeOutputs(graph-engine.js:4469 起)或任何别的地方读取过。 模板作者(planner.json 的注释)显然知道 goal-benchmark 的产出方式跟别的节点不一样,也确实标了这个字段——但从来没人把它接进产出闸门的判断逻辑里。

2.4 已有的逃生口(R-19 产出索引的显式声明机制)够不到共享区

graph-engine.js 已经有一条为同一类问题准备的机制——readDeclaredProduces(graph-engine.js:4377-4406):节点可以在自己私有目录写一份 contract.json,里面显式声明 produces: {kind: ["<绝对路径>"]},闸门会认这份声明而不是死磕目录扫描。这正是 41 号文档 提到的"R-19 产出索引不覆盖项目根,加 contract.json 的 produces 显式声明机制"那次修复的产物。

但这条逃生口本身也够不到共享 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,等于没声明。


三、影响面

任何一家公司只要 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 跟着一起被跳过。

这很可能正是本 session 全程调试的参考任务(snakegame-co,43 号文档 的触发场景)里 goal-benchmark/planner-validator 两个节点显示 skipped 的真实原因——不是没跑到,是跑了、正确地写了产物,却被这道闸门反复判定「没写」,最终在某一轮被放弃跳过。

连带后果:

  • artifacts/acceptance.json(R-19-B 契约,benchmark/test_data/test_script 三段合成)的 benchmark 段大概率永远拿不到真实内容——不是 goal-benchmark 没算出来,是它算出来的东西没能真正"通过"这一节点的成功判定,图会不断回退重算,直到耗尽回退预算放弃。
  • 连带 43 号文档 Phase 2:acceptance.md(不同于此处的 acceptance.json,来源不同)不受这条闸门直接影响,但同一个 planner.json 子图反复在这里回退熔断,会拖慢/阻塞整条 org-planner 子图的收敛,间接影响 Phase 2 的"org-planner 真正判定 success"这一时机能不能正常到达。

四、建议修法(未实现,留给下一次专门处理)

两个方向,第一个更小、更贴合现有 R-19 设计:

  1. 扩大 readDeclaredProduces 的 allowedRoots(graph-engine.js:4384),把 runRootDir(run)/artifacts/ 也纳入合法路径;同时让 goal-benchmark(以及未来任何 writesArtifacts:true 的节点)在写完共享产物后,也在自己节点私有目录写一份 contract.json 显式声明 produces.acceptance 指向那个共享区绝对路径。这样闸门检查的还是"显式声明 + 文件真实存在",只是放宽了声明能指向的位置——跟 R-19 现有的设计哲学一致,改动面最小。
  2. 让 writesArtifacts: true 这个字段真正被消费:unsatisfiedRequiredProduces 读节点定义时,如果这个节点标了 writesArtifacts: true,改成同时扫 runRootDir(run)/artifacts/ 目录(不要求 agent 额外写 contract.json)。改动面稍大(要把这个字段从模板一路传到 def.nodes 里,目前 readDefGraph 会不会保留这个字段未核实),但对 agent 侧零改动。

两条路都没有实现,需要先确认哪条更契合现有 def.nodes 的数据流(readDefGraph 目前是否保留模板里的自定义字段),再动手;本文档只记录诊断结果,不展开设计。