44 · writesArtifacts 节点的产出闸门永远打不过:写到共享区,闸门查的是私有目录
状态: 📝 真实回归中发现的缺陷记录,已诊断到根因,未修复(本文档只记录,不含实现) 日期: 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 设计:
- 扩大
readDeclaredProduces的allowedRoots(graph-engine.js:4384),把runRootDir(run)/artifacts/也纳入合法路径;同时让goal-benchmark(以及未来任何writesArtifacts:true的节点)在写完共享产物后,也在自己节点私有目录写一份contract.json显式声明produces.acceptance指向那个共享区绝对路径。这样闸门检查的还是"显式声明 + 文件真实存在",只是放宽了声明能指向的位置——跟 R-19 现有的设计哲学一致,改动面最小。 - 让
writesArtifacts: true这个字段真正被消费:unsatisfiedRequiredProduces读节点定义时,如果这个节点标了writesArtifacts: true,改成同时扫runRootDir(run)/artifacts/目录(不要求 agent 额外写contract.json)。改动面稍大(要把这个字段从模板一路传到def.nodes里,目前readDefGraph会不会保留这个字段未核实),但对 agent 侧零改动。
两条路都没有实现,需要先确认哪条更契合现有 def.nodes 的数据流(readDefGraph 目前是否保留模板里的自定义字段),再动手;本文档只记录诊断结果,不展开设计。