# 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 号文档](./43-task-decomposition-granularity.md) 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 没有做错任何事——它确实把文件写出来了,就在它自己判决里写的那个路径: ```bash $ 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 # 真实存在,内容正常 ``` 而闸门检查的是这个: ```bash $ 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`): ```js 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` 这个字段,全仓库零代码读取 ```bash $ 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 号文档](../../todo/system-design/41-graph-vs-fde-reliability-discussion.md) 提到的"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 号文档](./43-task-decomposition-granularity.md) 的触发场景)里 `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` 目前是否保留模板里的自定义字段),再动手;本文档只记录诊断结果,不展开设计。