# 27-P4. 并行执行与产物索引 > **所属**: [27 Graph Agent 架构](./27-graph-agent-architecture.md) 的 Phase P4 > **版本**: v0.4(阶段 A/B/C 全部收口,见 §五 2026-08-21 + 2026-09-08 记录;已归档到 `done/`) > **T5 收尾说明(2026-09-08,用户拍板)**:欠费门本身已实现并上线(可插拔桩子,`setBillingGate` 未注入时零行为影响,见下方 T5 落地记录)——**缺的只是接一个真实计费接口**,这一步被用户明确要求暂缓、挪到 `docs/todo/future/`,不算这份文档的收口条件 > **日期**: 2026-08-16 > **前置**: P0/P1/P2/P3 均已实现。本文档建立在 P3 已落地的执行引擎之上(`server/services/graph-engine.js` / `work-graph-db.js` / `graph-run-sse.js` / `goal-contract.js`,commit `c3abf4a`)。 > **命名**: 遵循 [27-NAMING](./27-naming-conventions.md),节点名按层级轴(`org-planner` / `org-coder-validator`),本体名不带前缀。 > **配套**: [27-E2E](./27-e2e-task-execution-flow.md) §7.4(fan-out 的两个阻塞点)、[27-EXAMPLE](./27-example-dev-company-walkthrough.md) 阶段 6(fan-out/fan-in 的具体场景) --- ## 〇、关于编号:这个 P4 不是原来那个 P4 27 号总纲 §六 里原本的「**P4 回退 + Loop-Validator**」已经**并入 P3 实现完毕**(总纲该行已标注「已并入上一行,不再单列」)——双层熔断、五项结构校验含 `fail-fallback-edge`、三层暂停/停止级联、按 `contract_hash` 判据的重试,全部在 `c3abf4a` 落地。 本文档**回收这个空出来的编号**,装的是 P3 明确排除、且现在成为最大能力缺口的内容。与 P5(自我进化)/ P6(DevLoop 收编)不冲突,是它们之前的一步。 --- ## 〇.5、上级背景:黑灯工厂差距评估(2026-08-21) 用户原话(提问): > lemonboss 现在离我们想做的黑灯工厂,能够自主 7*24 小时,自我规划,自我改进,自我迭代,自我完成任务的 graph org agent 系统,还有多远 评估结论(基于当时代码库状态 + `system-design/27` 系列文档): 图引擎地基(P0~P3)扎实——org/work graph 统一执行引擎、暂停/重试/回退/熔断、真 Claude CLI 跑通、控制台都已落地并有真跑验收,这部分不是障碍。离「黑灯工厂」还有三个主要缺口: 1. **真并发/无人值守跑不起来** —— 即本文档要做的 P4。现在一家公司同时只有一个 active root run,是单线程步进(`advanceOne` 一次只拾取一个 ready 节点),没有 fan-out 真并发,也没有为无人值守设计的看门狗/异常恢复层。7×24 意味着要能同时开很多任务、扛得住某个节点卡死或进程崩溃,这套目前没有。 2. **自我改进闭环是半成品** —— P5(自我进化)对应的 dream 机制(29 号文档)刚实现:能定时把 agent 经验蒸馏回 `memory.md`/`SKILL.md`/`soul.md`/`agent.md`,但"蒸馏出来的结论对不对"只能人工抽查,没有自动校验,也没有 REST/控制台入口去监督它。更麻烦的是几条已知的本体装配漏洞——org-coder 没有共享本体会静默退化成通用 Base Agent;devloop 的 impl/test 节点根本没有 `agent.md`;work 节点执行时 org node 的 `agent.md` 硬截断前 2000 字、且只加载 `agent.md`,soul/tools/identity/user 全部不进提示词。自我改进要改的是这些本体,本体装配本身有静默漏洞,改进闭环就是在往一个不完整的东西上写。 3. **没有真正的"自我规划"层** —— 现在的 org graph / work graph 都是 `orggraph-planning` / `workgraph-planning` 根据**人给的目标**生成的一次性产物,OKR/任务系统的驱动权还在人手上。定时的只有 dream(整理记忆),没有"自主决定下一步该做什么任务/该设什么目标"的常驻调度层——这一层连设计文档都还没有(不在现有 P5/P6 范围内)。 优先级建议:P4 并行(本文档)→ 补本体装配的静默坑(不然自我改进在改错东西)→ P6 收编统一循环 → 把 dream 的"结论对不对"自动化 → 最后才是自主任务生成层(目前完全空白,需要新设计)。 本文档要设计的 P4 是这份差距评估里排第一的缺口,也是三者中唯一已有详细设计(§四)且**已全部落地**(阶段 A/B/C,见 §五 2026-08-21 记录)的部分——T5 是可插拔桩子(真实计费接口待确认后再接),T11(专门的测试任务)没有单独立项,测试随每个任务同批写掉了。 --- ## 一、目标与非目标 ### 目标 1. **fan-out 并行执行** —— 解除 P3「一次只推进一个节点」的限制,让同一波次的多个 ready 节点真正并发跑。 2. **同一 system agent 的多实例隔离** —— 这是并行的**前置条件**,卡在两个既有不变量上(§三.2)。 3. **解除「一家公司同时只有一个 active root run」约束** —— P3 v1 用一个唯一索引强制它(§三.3),并行能力具备后应放开。 4. **产物统一目录 + `files.json` 索引** —— 所有 org/work graph 节点 agent 的产出物落在 `data/companies/{companyId}/` 下按 task 分目录,根目录 `files.json` 作为输入输出的统一检索入口,供所有 agent 引用。 ### 非目标 - **不做 L4**(system agent 自我迭代)—— 属 P5,见 [27-E2E](./27-e2e-task-execution-flow.md) §八。 - **不做 DevLoop 收编** —— 属 P6。 - **不做**跨公司的并行调度(多公司同时跑的全局资源仲裁)—— 本阶段并发上限按「单机总量」控制即可,见 §四.4。 - **不做**节点执行中途的断点续传 —— 沿用 P3 的重试语义(整个节点重跑)。 - **不做**把 `conversations/` 与 `system/tasklog/` 下的历史记录纳入 `files.json` —— 那是运行态日志,不是产物。 --- ## 二、背景:为什么并行是现在最大的缺口 [27-EXAMPLE](./27-example-dev-company-walkthrough.md) 阶段 6 描述的典型场景是:`org-coder` 把工作拆成三个可并行的 unit,各自跑完再 fan-in 到整合节点。这是 R-6 原话里明确要求的(「可以 fan out 并行多个 phase 的 code subagent」)。 **P3 把图结构和 join 语义都做好了,唯独没做并行本身**: - ✅ `graph-engine.js:1078` —— 出边评估时「目标节点若**所有**入边条件都满足则置 ready」,fan-in 的 join 语义已经正确。 - ✅ 图上可以有多个节点同时处于 `ready`。 - ❌ `graph-engine.js:908` —— 拾取 ready 节点的 SQL 是 `... WHERE status = 'ready' ORDER BY created_at, id **LIMIT 1**`。所以三个并行分支只会被一个一个串行跑掉。 也就是说,**图已经是 DAG,波次也分得出来,只是每次只走一个节点**。这不是设计缺陷,是 P3 刻意的范围切分——因为并行会立刻撞上 §三.2 的两个不变量。 --- ## 三、现状代码走查(决定设计边界的关键证据) ### 3.1 引擎侧:单节点拾取 `server/services/graph-engine.js:908`: ```sql SELECT * FROM graph_run_nodes WHERE run_id = ? AND status = 'ready' ORDER BY created_at, id LIMIT 1 ``` `advanceOne(runId)` 的语义是「拾取**一个** ready 节点 → running → 执行 → 评估出边 → 把满足条件的下游置 ready → 返回,**不循环往下跑**」(文件头注释 16-17 行)。手动模式一次点击一步、自动模式靠外层反复调用,两者共用同一原语——这个设计在 P4 要保留,只是原语从「一个」变成「一批」。 ### 3.2 两个阻塞不变量(并行的真正门槛) **① `CONVENTIONS.md:155`**: > 任意 `(pid, agent)` 同时只允许 1 个 status=running 如果并行跑三个 `org-coder` 的子任务,它们是**同一个 system agent 的三次调用**,直接违反这条。 **② `session-pool.js:29-32`** 的会话 key: ```js getKey(agentName, agentConfig, workDir, scope) { const base = `acp:${agentName}:${agentConfig.client}:${agentConfig.deploy}:${workDir}`; return scope ? `${base}:${scope}` : base; } ``` 同 agent 同 workDir 会**复用同一个 session**。并发调用会撞在一起——两个并行分支的对话会互相污染上下文。 💡 **关键发现**:`getKey` 的签名**已经预留了 `scope` 参数**,但第 45 行实际调用时是 `this.getKey(agentName, agentConfig, workDir)`——**没传**。也就是说隔离机制的挂载点已经在,只是从未被使用。这让 P4 的改动比预想小得多。 ### 3.3 一家公司只允许一个 active root run `work-graph-db.js:108-110`: ```sql CREATE UNIQUE INDEX IF NOT EXISTS idx_graph_runs_one_active_root ON graph_runs(company_id) WHERE parent_run_id IS NULL AND run_status IN ('ready','running','paused'); ``` `company.js:1188` 的注释写明这是「v1 约束(27-P3P4 §一非目标)」。注意**这条约束在 org 层就存在**,不是等到 work 层 fan-out 才需要——因为 org graph 本身也是被这套引擎跑的。 ### 3.4 现成的 fan-out 实现(可借鉴,但不在图引擎里) `orchestrator.js:265` 的 `executeWithDependencies()`:`Scheduler.getExecutionGroups()` 按 `dependsOn` 分组 → `Promise.allSettled` 组内并行、组间串行(天然 fan-in 屏障)→ 单点失败降级成 `{status:'ERROR', retryable}` 而不终结整组。 这套东西**图引擎没有用**(消费方只有 orchestrator 和 result-tracker)。P4 不必直接复用它的代码,但**它的错误降级策略值得照搬**:单个分支失败不应该让整批一起崩。 ### 3.5 并发上限的既有约定 `docs/prd/newfunction/_spec/plan-cost-concurrency.md` §④ 已经给出三层上限的数字,P4 应当对齐而不是另立一套: | 层级 | 上限 | 原因 | |---|---|---| | 项目内 L1 并行 | ≤ 5 | 每个 Claude CLI ≈ 100MB RAM + 1 CPU core | | 单机系统并发 | ≤ 20 个 ACP session | 合理 RAM 占用 ≈ 2GB | | 同 company 项目并发 | ≤ 3 | 避免单用户挤占 | | Bash 子进程 | ≤ 50 | 防 fork bomb | ### 3.6 产物目录现状 > ⚠️ **本节写于 2026-08-16,下面两个 ❌ 已经不成立了**(2026-08-20 校准)。 > 留着原文是因为它记录了当时的判断依据;**当前状态看这一段的校准块**。 - ✅ `goal-contract.js` 已实现验收契约的跨节点传递(R-15.6 验收先行)。 - ✅ `graph-engine.js` 有节点级产物路径。 - ~~❌ 没有 `files.json`~~ → **✅ 阶段 C 已落地,但形态换了**:产物索引做成了 `graph_files` **表**(`recordFile` / `findFiles` / `rebuildFilesFromNodes`), 不是 `files.json` 文件。表比文件多拿到两样东西:部分唯一索引保证「同一路径只有一个当前版本」, 以及 `superseded_by` 让历史版本可共存。守卫 `graph-files-index.test.js` 七条, 含「删掉索引后能从节点行重建」和「两个 task 的同名产物不互相覆盖」。 - ~~❌ 产物按 agent 分目录~~ → **✅ 已按 run/node 分目录**: `data/companies/{cid}/runs/{rootRunId}/nodes/{nodeRunId}/`, 跨 org node 的共享产物在 `runs/{rootRunId}/artifacts/`。 同一 agent 在不同任务下的产出因此天然不混。 > **2026-08-20 顺带修掉一个存量库缺陷**:生产库里还留着产物索引加版本之前的 > **无条件**唯一索引 `idx_graph_files_path`,导致重试时同路径第二次写必撞、 > 被降级成一条 timeline —— **产物索引在任何有重试的 run 上都不全**,而且不报错。 > 详见 [27-P3B §六ter](./27-p3b-real-run-runway.md)。 --- ## 四、设计方案 ### 4.1 `advanceOne` → `advanceBatch`(保持原语单一) **不新增第二个推进原语**。P3 「手动与自动共用同一个 advanceOne」的设计要保留,只把「拾取一个」放宽为「拾取一批」: ```js // 拾取至多 N 个 ready 节点(N = 本 run 的有效并发额度,见 4.4) advanceBatch(runId, { maxConcurrency }) → { results: [...], activated: [...] } ``` - **手动模式**:`maxConcurrency = 1`,行为与今天的 `advanceOne` 完全一致——「下一步」仍然只推进一个节点,用户能看清每一步。 - **自动模式**:`maxConcurrency = 有效额度`,一次拾取整批并发跑。 - `advanceOne(runId)` 保留为 `advanceBatch(runId, {maxConcurrency: 1})` 的薄封装,P3 已有的调用方和测试不用改。 **拾取的原子性 —— 决策(2026-08-21,推翻本节上一版)**:不需要新开一个包裹整批的 `BEGIN IMMEDIATE` 事务。直接复用 `advanceOne` 今天已经在用的同一个原语——`sse.transitionNode(runId, nodeId, 'ready', 'running', ...)`,单行条件 `UPDATE ... WHERE status='ready'`(`graph-engine.js:1369` 原话:「ready → running 的条件更新就是并发闸门」)。这条语句本身在 SQLite 单写者模型下已经是原子的。 ```js const candidates = db.prepare( `SELECT * FROM graph_run_nodes WHERE run_id=? AND status='ready' ORDER BY created_at, id LIMIT ?` ).all(runId, maxConcurrency); const claimed = candidates.filter(node => sse.transitionNode(runId, node.id, 'ready', 'running', ...) // advanceOne 今天调的同一个函数 ); // 拾取阶段全程同步、零 I/O;下一步才 await 真正的 agent 调用 const results = await Promise.allSettled(claimed.map(node => executeNode(node))); ``` **为什么不需要包裹事务**:两个并发 `advanceBatch` 即使 `SELECT` 到同一批候选节点,也是逐行在 `transitionNode` 上竞争——每一行只有一个调用者拿到 `changes=1`,另一个拿到 `0` 直接跳过。最终两边拾取到的集合天然无交集(§六 验收点 2),不需要把「数出还有空位」到「占上」捆成一个不可分事务——那是 `work-graph-db.js:1437` 建 run 时才需要的手法(它是「先数活跃 run 数、再判断、再写」这种读-判-写耦合在一起的多语句决策,必须原子;节点拾取没有这种耦合,每一行的归属独立由它自己的 `UPDATE` 决定)。 **已知且接受的代价**:某次 `advanceBatch` 可能因为在部分候选节点上输掉竞争,实际拾到的节点数少于 `maxConcurrency`(即使还有别的 ready 节点没进这次的 `LIMIT` 窗口)。自动模式下一轮循环会补上,不必现在解决。 ### 4.2 同一 agent 的多实例隔离 > ✅ **2026-09-08 已实现**(此前文档写着设计、T1 的✅曾误导人以为已完成——实测 `AgentManager._runViaProtocol` 两处 `getOrCreate` 调用当时确实固定只传 5 个参数,`options.sessionScope` 从未被读取,`callAgent` 解构出来只是摆设。核实动机:`executor-fanout.json` 这类骨架的 `{{orgNode}}-impl-a`/`-impl-b` 两个并行节点引用同一个 `systemAgentId`,`advanceBatch` 真并发拾取时 `taskId`(=`ctx.runId`)相同、不传 scope 就会撞会话键——这不是假设性风险,是 27-P4 阶段 B 已经上线的并发路径里一个真实存在的洞)。落地:`graph-engine.js` 两处 `callAgent` 调用都传 `sessionScope: \`gr:${ctx.nodeRunId}\`;`agent-manager.js` 的 `_runViaProtocol` 读 `options.sessionScope` 并透传给 `SessionPool.getOrCreate`(含失败重试那次)。守卫 `agent-manager-session-scope.test.js`(3 例,含反向验证:去掉 sessionScope 会退回撞车)。 利用 §三.2 发现的现成挂载点——`SessionPool.getKey()` 的 `scope` 参数: ```js // 图引擎调用 AgentManager 时带上 scope,取值用节点运行实例 id(graph_run_nodes.id) // 同一 system agent 的两次并发调用因此落在两个不同的 session 上,互不污染 const scope = `gr:${nodeRunId}`; ``` **为什么用 `graph_run_nodes.id` 而不是 `def_node_id`**:同一个定义节点在重试时会有多次 attempt,但同一时刻只有一个在跑;而并行的是**不同定义节点**引用同一本体。用运行实例 id 天然满足「并发的两个调用一定拿到不同 scope」。 **`CONVENTIONS.md` §12 第一条不变量要相应放宽**: ``` 旧:任意 (pid, agent) 同时只允许 1 个 status=running 新:任意 (pid, agent, scope) 同时只允许 1 个 status=running ``` ⚠️ 这条不变量属于 DevLoop 的规格文件。改它意味着**修改 `docs/prd/newfunction/*.html` 再跑 `npm run spec:extract`**(`_spec/` 是生成物,不能手改,pre-commit hook 会拦)。这是 P4 里唯一一处要动 DevLoop 规格的地方,且只是放宽维度、不改语义。 **workDir 保持不变**:仍然是 agent 的私有目录。P1 §3.3 已论证过 `workDir` 必须指向私有目录(CLI 子进程以它为 cwd 读 `soul.md` 等相对路径文件),并行不改变这一点——隔离靠 session scope,不靠换目录。 ### 4.3 部分失败的 fan-in 语义 三个并行分支,2 成功 1 失败,fan-in 节点怎么办? **沿用 P3 已实现的 join 语义,不加特例**:`graph-engine.js:1078` 的规则是「所有入边条件都满足才置 ready」。失败的那条分支走的是 `fail` 边,不满足 fan-in 节点的 `success` 入边条件,所以 **fan-in 节点不会被点亮**,图停在那里——这正是期望行为。 **并发下的 join 正确性证明(2026-08-21,落实 T6 前先确认过再写代码)**:A、B 两个并行分支都喂给 fan-in 节点 C,会不会两边都在对方提交前读到「还没齐」,谁都不去点亮 C?不会——`settleNode`(`graph-engine.js:1514`)是**纯同步函数**,内部(写自己状态 → `activateSuccessTargets` 读兄弟节点状态 → 可能 `markReady`)全程没有一个 `await`,全是同步的 `better-sqlite3` 调用。Node 事件循环单线程,`await agentExecutor(ctx)` 一旦 resolve,从那之后到 `settleNode` 跑完是**不可打断的一整个 tick**——A、B 谁的 agent 调用先 resolve 不重要,**谁的 `settleNode` 后跑**,谁就会看到两边状态都已提交,从而正确点亮 C。真正并发的只有 `await` 着的 agent 调用本身(正是 T6 要并行的那部分)。**结论:join 不需要额外事务或锁**,`activateSuccessTargets`/`markReady` 原样复用即可。 **T4/T6 的具体切法**:把 `advanceOne` 里「拾取之后」的整段(建 ctx → `await` 执行 → `settleNode`,`graph-engine.js:1379-1505`)抽成一个可复用函数(如 `runClaimedNode(runId, run, pickedNode, opts)`),`advanceOne` 与 `advanceBatch` 的并行路径共用它: ```js // T4 的拾取只到「置 running」为止(见 §4.1);执行与结算是这里 const settled = await Promise.allSettled( claimed.map(node => runClaimedNode(runId, run, node, opts)) ); ``` `runClaimedNode` 内部原有的 try/catch(把 agent 调用异常转成 `fail` outcome 喂给 `settleNode`,而不是往外抛)已经是主要的错误containment;`Promise.allSettled` 补的是最后一道防线——万一 `settleNode` 自己有 bug 抛出去,也不会拖垮同批里其它节点的 promise。 **已知且不打算优化的冗余**:一批里多个节点并发结算,各自 `settleNode` 末尾都会调一次 `settleRun(runId)`——同一个 run 短时间内被判定收敛好几次,幂等但浪费,不是 bug,不在本阶段处理范围。 需要补的是**可观测性**,不是新语义: - run 层面要能回答「我卡在等哪几个节点」。控制台已有的节点状态足以推导,但要在 UI 上显式标出「fan-in 节点 `X` 在等 `A`(fail) / `B`(success) / `C`(success)」,否则用户只看到「不动了」。 - 失败分支按 P3 既有路径处理(`fail` 出边 → 归因 → 回退或 blocked),P4 不改。 ### 4.4 并发额度:三层,逐层取小 **决策(2026-08-21)**:暂时不做「三层配额审批」这套东西——公司级 active root run 数、单机 session 数都**不设硬顶**(下面表格降级为参考数字,不是要实现的约束),唯一要卡的门是**账户欠费**(一旦欠费,不允许发起新的 agent 调用)。 > ⚠️ **欠费检查目前在代码里不存在**——全仓库搜过 `graph-engine.js`,唯一的门是 `checkBudget(run)`(`graph-engine.js:2334`),那是**任务级**的 agent-call/token 预算池(R-15 那套「只增不减的共池」),跟「账户是否欠费」是两个不同维度的概念,不能互相顶替。P4 范围内**不新建**账户级欠费系统;如果这道门确实要在 P4 上线前接通,需要先确认计费系统那边有没有现成的余额/欠费状态可查——这是一个**依赖,不是设计问题**,先问清楚再决定要不要塞进 T5。 > > **恢复语义决策(2026-08-21)**:欠费挂起直接复用 `suspendRunTree`(`graph-engine.js:2383`)——跟预算耗尽走同一条路:root run → `blocked`,全树非终态 run 置 `pause_requested=1`,只是 `reason` 从 `'budget-exhausted'` 换成 `'billing-arrears'`。这套三层级联暂停/恢复(`pauseRun` / `startRunSync` / `resumeDescendants`,`graph-engine.js:2506-2578`)已经实现并测过,不需要为欠费再发明一套状态机。 > > **但有一个真实缺口**:今天从 `blocked` 恢复靠人手点「启动」(`admitOrRequeue('blocked'→'running')`,注释原话「解除 blocked 的正当入口是...点一下启动」)。欠费清零不会有人去点——这正是「7×24 无人值守缺看门狗」那个更大缺口的一个具体实例。**P4 范围内需要补的是一个自动恢复触发器**:欠费状态清除时,批量对所有 `run_status='blocked' AND block_reason='billing-arrears'` 的 root run 调 `startRun`。触发源是计费系统的事件/轮询,不是图引擎自己能感知的——这也是为什么欠费检查本身要先问清楚计费系统那边的依赖(上一段已经点出)。 > > 在那道门接通之前,`advanceBatch` 相当于「无限额度」,唯一还在起作用的数字上限是 T1 已经落地的 `SessionPool.maxSessions`(防止单机字面意义上被并发的 Claude CLI 子进程压垮,是内存/CPU 层面的保命阀,跟欠费门是两回事,**不因为这条决策而放松**)。 对齐 §三.5 的既有数字,不另立一套(**下表现改为参考基线,非本阶段要实现的硬约束**): | 层级 | 上限 | 落在哪 | |---|---|---| | **单个 run** | 默认 5 | `graph_runs` 新增 `max_concurrency` 列,建 run 时写入,可按任务覆盖 | | **单公司** | 默认 3 个 active root run | 替换 §三.3 那个「只允许 1 个」的唯一索引 | | **单机** | ≤ 20 个 ACP session | `SessionPool` 加 `maxSessions`(`plan-cost-concurrency.md` §④ 已经提出要加这个配置,至今未加) | ~~**有效额度 = min(三层剩余)**。`advanceBatch` 拾取前先算这个值,为 0 就本轮不拾取(不是报错,等下一轮)。~~ **按 2026-08-21 决策作废**:不算三层 min,只剩单公司/单机两个数字本身也不设硬顶。`advanceBatch` 唯一还要看的额度就是本 run 的 `max_concurrency`(SELECT 的 `LIMIT`),不需要为它算「剩余」——因为再没有别的层可以让它变小。 **任务级总预算不变**:P3 已实现「按 root_run_id 共池的 backward_count + 任务级预算挂 root run」,并行不改变预算语义——**并行只是让预算烧得更快**,这一点要在控制台的成本提示里体现。 ### 4.5 解除单 root run 约束 ```sql -- 替换 work-graph-db.js:108 的 idx_graph_runs_one_active_root DROP INDEX IF EXISTS idx_graph_runs_one_active_root; -- 不再用唯一索引硬限,改为建 run 时按 4.4 的「单公司额度」做计数检查 ``` **为什么从「索引强制」改为「计数检查」**:唯一索引只能表达「≤1」,表达不了「≤3」。计数检查要放在**同一个事务**里(先 `SELECT COUNT(*)` 再 `INSERT`),否则并发建 run 会超额。 ### 4.6 产物统一目录 + 产物索引表(R-19 定案后重写) > **v0.2 重写**:本节原设计是「公司根放一个 `files.json`」。[27-REQ](./27-requirement.md) R-19 > 改为**建 sqlite 表**,并把「边即输入输出」这层显式化。原设计的目录约定保留。 #### 为什么这一节从「优化项」变成「阻塞项」 第四次真 CLI 冒烟(自动模式)跑出的断点:`org-planner-prd` 失败,它自己的归因说得很准 —— **「不是上游没产出,是它的产出结构上到不了我手上」**。核实:上游 `brd` 的 `brd.md` / `contract.json` 落在**它自己的节点私有目录**,共享区 `artifacts/` 只有 `project-root.json` 与 `task-goal.json`,而 `brd --success--> prd` 这条边 **不携带任何文件引用**。 同一张 work graph 内部,上游到下游**没有任何数据通路**。这不是引擎 bug —— 边一直只承载控制流(`condition` / `edge_kind` / `fail_reason`), 而 [27-P3/P4](./27-p3-p4-work-graph-console.md) §四.B.3 把 R-4 要的「边的输入输出」 落成了**从 `output_ref` 推断**的 UI 展示,没有实体。推断的那一端是私有目录,下游读不到。 **结论:不通这条路,任何多节点的图都只能跑完第一个节点。** 它不依赖并行,应从 P4 里 单独拆出来先做。 #### 目录约定(沿用原设计) ``` data/companies/{companyId}/ tasks/{taskId}/ brd.md · prd.md · sdd.md acceptance.json 验收契约(goal-contract.js 的路径归并到这里)✅ R19-B 已完成 {nodeName}/ 节点私有的中间产物(attempt-{n}.json 等) ``` **按 task 分目录而不是平铺**:同一家公司会跑很多 task,平铺的话第二个 task 的 `prd.md` 会覆盖第一个。按 task 分既避免覆盖,也保留历史。 > **R19-B 真实状态(2026-08-17)**:`acceptance.json` 这一格**已落地** —— > `goal-contract.js` 写在 `{companyRoot}/tasks/{taskId}/acceptance.json`(`taskId` 从 > `runs/{rootRunId}/artifacts/task-goal.json` 推),落盘同一次调用里进 `graph_files` > (`kind='acceptance'`,`superseded_by` 版本链),下游 validator 靠 `describeInputs` 查 > `TASK_SHARED_KINDS` 拿到绝对路径。旧落点 `runs/{r}/artifacts/acceptance.json` 保留**只读兜底**。 > **还没做的三件**(别当已解决):① `verdicts/` 故意留在 run 级、没搬; > ② 三个产出模板的 tools.md 仍教 agent 把 `goal-benchmark.json` / `goal-test-script/*.js` > 写进 `$R/artifacts/`,所以契约内相对路径要在两个根里找;③ `companyRootDir` / `taskDirRel` > 目前在 `graph-engine.js` 与 `goal-contract.js` 里各一份镜像,待提取到共享位置。 > 三件的判据与删除条件见 [27-goal-contract-format](./27-goal-contract-format.md) §八。 > `brd.md` / `prd.md` / `sdd.md` 这几格仍在节点私有目录,不在本条范围内。 **不走 `workspace/`**(R-19.3 定案):`workspace/` 的现有语义是 [06-沙盒自治](../../done/system-design/06-sandbox-autonomy.md) 定的 「**Agent 自治领地,框架不干预**」。产物索引要的是「**框架管着的、可检索的**交付物区」, 两种所有权混在一个目录里,索引就无法保证不漂移。 **根路径用代码推导,不入库**(R-19.3 定案):库里只存**相对公司根**的路径。 理由是库里存绝对路径会烂 —— W13 清掉的那 10 条 `legacy_path` 悬空行就是这个病 (指向早已删除的目录)。推导方式与现有 `runRootDir` / `nodeArtifactDir` 一致, 需要覆盖时给环境变量(测试隔离已在用 `GRAPH_RUNS_DIR` 这招)。 另:**库里没有 `companies` 表**,公司是 `company.json` + 目录扫描,所以也没有 「company 表的定义」这个落点。 #### 两类数据流,都要(R-19.3 定案①) 纯「文件挂在边上」有硬伤:边是 `(from, to)` 二元关系,只能表达**相邻**节点间的传递。 而 R-9.3 定的「验收产物由 planner 生成、由 validator 执行」是**非相邻**依赖 —— org graph 是 `org-planner → org-coder → org-validator`,`acceptance.json` 从 planner 流到 validator,**跳过 coder,这两个节点之间没有边**。 | | 沿边传递 | 任务级共享 | |---|---|---| | 例 | `brd.md` → 紧邻的 prd 节点 | `acceptance.json` → 任意下游 validator | | 绑定方式 | 边上声明契约(`kind`) | 挂在 task 上,按 `kind` 检索 | | 受拓扑相邻性限制 | 是 | **否** | #### 契约与实例分两阶段(R-19.4) `workgraph-planning` 画图时**不可能知道文件名**(节点还没跑),但它能声明 「这条边传一份 `brd`」。所以: - **规划时**:边上声明**契约**(`kind`),落 `graph_edge_contracts` - **运行时**:节点产出落盘后,引擎记**实例**(`rel_path`),落 `graph_files` 由此得到一个原设计没有的收益:**引擎能在节点跑之前校验「你声明要吃 brd,但上游没有任何 节点产 brd」**。这类图结构错误现在完全查不出来,只能等跑到那一步 agent 说「我拿不到输入」 (第四次真跑正是这么发现的)。 #### 表结构 ```sql -- 产物实例:运行时由**引擎**追加。不靠 agent 自觉写,否则索引必然与实际文件漂移 -- (本轮已被验证过多次:凡「靠 agent 自觉」的环节几乎全部落空)。 CREATE TABLE IF NOT EXISTS graph_files ( id TEXT PRIMARY KEY, -- gf_ company_id TEXT NOT NULL, task_id TEXT, -- 任务级共享产物按它检索 rel_path TEXT NOT NULL, -- **相对公司根**,根由代码推导 kind TEXT NOT NULL, -- brd|prd|sdd|acceptance|contract|artifact|log produced_by_run_id TEXT, produced_by_node_run_id TEXT, -- 与 graph_run_nodes.output_ref 同源 created_at TEXT NOT NULL, superseded_by TEXT -- 同名产物的版本链,可空 ); -- 边上声明的数据契约:规划时写,只有 kind 没有实例。 -- edge_layer 区分两层边表 —— org_graph_edges.id 是 INTEGER、work_graph_edges.id 是 TEXT, -- 与 graph_run_nodes.def_node_id 同一处类型不整齐(见 27-P3/P4 §四.A.2 的注)。 CREATE TABLE IF NOT EXISTS graph_edge_contracts ( id TEXT PRIMARY KEY, edge_layer TEXT NOT NULL CHECK (edge_layer IN ('org','work')), edge_id TEXT NOT NULL, kind TEXT NOT NULL, required INTEGER NOT NULL DEFAULT 1, -- 0 = 可选输入,缺了不算错 created_at TEXT NOT NULL ); ``` #### 引擎怎么用 **产出侧**:节点结算时,除了写 `output_ref`(节点级指针,不变), 再按落盘的文件往 `graph_files` 追加实例。两者关系与原设计一致 —— `output_ref` 回答「我这次跑出了什么」,`graph_files` 回答「这家公司有哪些产物、谁能用」, 后者由前者派生,所以要有 **rebuild** 能力(索引损坏时从节点行重建,类比 `_rebuildIndex()`)。 **输入侧**(这是补上断点的关键):节点执行前,引擎按 「该节点**入边**声明的 `kind`」+「本 task 的共享产物」去 `graph_files` 查实例, 把**真实路径**拼进提示词。 > 这比原设计的「注入一句『索引在 files.json,按 kind/taskId 自己检索』」更可靠: > 后者仍然依赖 agent 自觉去查、自觉拼对路径,而第四次真跑证明 agent 拼不出来 —— > **引擎直接把输入递到它手上**,不给它猜的机会。 #### 落地状态(R-19.A:声明侧接通) | 环 | 谁做 | 状态 | |---|---|---| | 产生(规划时声明契约) | `workgraph-planning` 模板约束九:forward 边写 `produces` / `needs`(kind 取值域 = `ARTIFACT_KINDS`,backward 边禁止声明) | ✅ 已落地 | | 传递(落库) | work 层 `work-graph-db.createWorkGraph` 插边后调 `declareEdgeContract`;org 层 `org-graph-service.writeGraph` / `patchGraph` 同(`insertEdge` 改为回传行 id,重复边被去重键吞掉时挂到同一条边上) | ✅ 已落地,`declareEdgeContract` 不再是零调用方 | | 落库(运行时实例) | `graph-engine.indexNodeOutputs`,kind 优先取出边声明 | ✅ 925d9db | | 读回(拼进提示词) | `graph-engine.describeInputs`,`inboundContracts` 现在真的非空 | ✅ 已实测(`graph-edge-contracts.test.js` 走 `advanceOne` 真入口验四环闭合) | | **R-19.4 结构校验** | `validateGraph` 新增**规则 6 `data-contract`**:`needs` 的 kind 在图上没有任何出边声明产出 → 拒,错误形态与既有五项一致(`{rule, detail}`)。work 层通过 `validateWorkGraph` 复用同一份实现,两层同时生效 | ✅ 已落地 | **两个键而不是一个**(`produces` 走产出侧、`needs` 走消费侧)是 R-19.4 能成立的前提: 合成一个键的话,同一行契约既是「上游产出」又是「下游要吃」, 「声明要吃 X 但没人产 X」永远自洽 —— 那条校验会退化成一条永不报错的空跑规则。 `required:false` 是显式逃生口:产出方不在本图(`acceptance` 由上一层 org graph / `artifacts/` 给)时用它。 **~~尚未接通的一环~~(已接通,见下面第六次真跑)**:`system/work-graph-templates/*.json` 四张骨架的边 原先**没有** `produces` 声明,所以「照抄骨架」得到的图不传数据,全靠规划 agent 自己按约束九补。 四张骨架现已补齐 `produces`/`needs`(backward 边一条都没动),第六次真跑实测规划 agent 会把骨架上的声明连同结构一起改编下来 —— 见「第六次真 CLI 冒烟」验收点⑤。 #### 第五次真 CLI 冒烟:第四次那个断点复验通过(2026-08-17,未改一行代码) **跑法**:主仓库 vite(5173)+ worktree `wf_55ba803e-5ac-6` 的真 CLI 服务(3010,`data/` 独立, `LEMONBOSS_DISABLE_WATCHDOG=1`)。公司 `r19smoke`,手动模式,预算 `total_agent_calls` 实跑 **3/15**。 **被测版本 = `925d9db` + 当时工作树里未提交的 R-19.A 改动**(这批改动随后进了 `1ea9efa`); `1ea9efa` 里新加的 role 列与控制台三字段**不在**本轮被测范围内。 | 验收点 | 结果 | |---|---| | 建公司 → `orggraph-planning` 真跑落图自雇 | ✅ 未被打破。`org-analyst` / `org-designer` / `org-validator` 三节点 + 正向边 + 两条 backward fail 边;规划本体没混进 org 节点 | | **R19-A:真实规划 agent 会不会在边上声明契约** | ✅ **成立**。约束九全文(`### 约束九` / kind 取值域 / backward 禁令 / 自查 5bis)确实进了它的提示词(28597 字,未被 2000 字截断)。它自己写出 `{"from":"org-analyst-brd","to":"org-analyst-prd","condition":"success","produces":["brd"]}` 与 `{...,"to":"org-analyst-validator","produces":["prd"],"needs":["brd"]}`,两条 backward 边干净。落库 `graph_edge_contracts` **3 行**(`edge_layer='work'`)—— `declareEdgeContract` 第一次有真实调用方写出真实行 | | **落库侧:节点产出进不进索引** | ✅ 成立。`org-analyst-brd` success 后 `graph_files` 两行:`brd.md`→`kind=brd`(**按出边声明定的**)、`contract.json`→`kind=contract`(按文件名兜底) | | **读回侧:下游提示词里有没有上游产物的真实绝对路径**(本轮核心) | ✅ **成立,断点关闭**。`org-analyst-prd`(`prd-planning`)的真实提示词里出现:
`[上游输入 —— 引擎已按图上声明的数据契约解析好,直接读这些路径]`
`- brd:…/runs/run_fc7e9b8a29d5/nodes/rn_2e8dbb1c6ed7/brd.md`
`- contract(任务级共享):…/rn_2e8dbb1c6ed7/contract.json`
对照第四次真跑同一个本体的提示词:`[上游输入` **一次都没出现过** | | **下游还会不会说「拿不到输入」** | ✅ 不会了。prd 节点第一个动作就是 `Read` 那条绝对路径,随后自评「BRD 完整(5 条 in_scope / 7 条 out_of_scope / 5 条可判定度量),无需打回」,`attempt-0.json` 记 `verdict:"produced"` + `brd_in_scope_covered:"5/5"`。节点 **success**,`fail_reason=null` | | 四环(产生→传递→落库→读回) | ✅ 真 CLI 下逐环有物证:agent 写 JSON → 引擎 `createWorkGraph` → `graph_edge_contracts` 3 行 + `graph_files` 4 行 → 提示词里的绝对路径 | **同轮暴露的两条不对称**(都不是本轮改动引入的): 1. **落库通道走的是降级路径,不是 REST 端点**。`POST /api/company/:id/work-graphs` **实际不存在**(真跑实测 404,全仓库无该路由)。规划 agent 按 `agent.md` 的降级约定把图 JSON 写到 `workspace/workgraph-.json`、由引擎接手落库 —— 契约在这条路上**没丢** (`normalizeContracts` 在 `work-graph-db.normalizeEdges` 里,与入口无关)。 但模板里那段 curl 是**必然 404** 的死路径,每次真跑都要浪费 agent 一轮试探。 2. **org 层的声明侧实际零产出**。`writeGraph → declareContracts` 代码在,但 `orggraph-planning/agent.md` **一个字都没提**数据契约(`grep -c 'produces\|数据契约'` = 0), 所以真跑后 `graph_edge_contracts` 里 `edge_layer='org'` **0 行**。 org 层这一半仍是「实现在、无真实调用方」。 #### 第六次真 CLI 冒烟:org 层四环闭合(2026-08-17) **跑法**:worktree `wf_9fbefcf9-79e-5`,真 CLI 服务 3010(`data/` 是 worktree 自己的 —— `company-service.js:18` 与 `graph-db.js:34` 都从 `__dirname/../../data` 派生,所以生产 `data/graph.sqlite` 结构上碰不到),`LEMONBOSS_DISABLE_WATCHDOG=1`(启动日志有 `[Watchdog] ⏸️ 已由 LEMONBOSS_DISABLE_WATCHDOG=1 关闭`)。公司 `smoke6`,org run `run_5274dad6b10c` 手动模式,子 run `run_0691b85eb7fc`。 **被测版本 = `2b77f8c` + 工作树里未提交的四份改动**(org 层读回方 `describeOrgContracts`、 `orggraph-planning` 约束五、`workgraph-planning` 去死端点、四张骨架补 `produces`/`needs`)。 **证据一律取 Claude CLI 自己的 transcript 原文**(`~/.claude/projects//*.jsonl` 里第一条 user 消息 = 引擎真的发出去的那段字),不用 timeline 推断。 **单测基线**:`npm run test:unit` 在 worktree 里 **837 passed / 6 skipped,83 个文件中 82 通过**。 唯一失败的 `server/tests/integration/graph-agent-p1-acceptance.test.js` 是**环境性失败、不是回归**: 它在 `:75` 断言 `data/companies/idearun/agents/evolver-youtube-crawler/agent.md` 存在, 而 `data/companies/` 被 `.gitignore` 忽略,**全新 worktree 里结构上不可能有这份 fixture** (该断言是作者刻意加的「别让测试空跑通过」保险)。对账:主树基线 829 passed / 80 文件, 本轮新增三个测试文件共 14 条,失败套件里 6 条没跑 → `829 + 14 − 6 = 837`,与实测**逐数吻合**, 即既有用例零回归。 | 验收点 | 结果 | |---|---| | **① org 层声明真的落库** | ✅ `graph_edge_contracts` 里 `edge_layer='org'` **4 行**(第五次真跑是 **0 行**)。边 1 `org-planner→org-coder`:`produces[prd]` + `produces[acceptance]`;边 2 `org-coder→org-validator`:`produces[artifact]` + **`needs[acceptance]`**。两条 backward 边(3/4)**零契约行** | | **② org 契约进没进规划 agent 的提示词**(本轮核心) | ✅ **成立**。发给 `workgraph-planning` 的提示词(52 677 字节)第 746 行逐字出现 `## ③bis org-planner 在上一层图上的数据契约(引擎已解析,不要自己猜路径)`,其下「你这一层必须产出的东西(硬约束)」列出 `acceptance / prd` —— 正是 org 边 1 上声明的两个 kind。四环里的「读回」这一环闭合 | | **③ 声明侧的语义质量** | ✅ 超出预期。`needs` 被用在**真正的隔节点中继**上:`org-validator` 要吃的 `acceptance` 产出方是 `org-planner`(非紧邻上游 `org-coder`),所以单独写 `needs`。规划记录 `workspace/orggraph-plan.md` 第 6 节「数据契约自查」表**填满了**,含 `data-contract` 规则预检、kind 取值域预检、以及「粒度:org 层比 work 层粗一档」自查 —— 约束五被完整吸收,不是照抄 | | **④ 死端点** | ✅ agent **不再** curl `POST /api/company/:id/work-graphs`。全 transcript 里该字符串只出现在**提示词**里(两处,见下面的不对称②),agent 侧零调用。它按新 Step 7 把图写成项目根下的绝对路径 `projects/p-xdc09a/workgraph-T-msx3w8xm.json`(**没有**写 `workspace/` 那条与写入边界冲突的旧路径),并**自己 require 引擎的 `validateWorkGraph` 预检了一遍**才交回。落库一轮通过,`plan` 无 `rejected` | | **⑤ 骨架 json 的声明起作用了吗** | ✅ 照抄并正确改编。宿主是 `org-planner` → 选 `planner.json`;任务是单文件 CLI 于是砍掉 `sdd` 节点,产出的四条 forward 边契约与骨架**同形**:`brd→prd produces[brd]`、`prd→benchmark produces[prd]`、`benchmark→validator produces[acceptance] + needs[brd,prd]`(骨架原文是 `needs[brd,prd,sdd]`,它跟着删掉了 `sdd` —— 说明是理解后改编,不是字面复制),三条 backward 边零声明。落库 `edge_layer='work'` **5 行** | | **⑥ 回归:第五次那条已成立的链路** | ✅ **没被 `resolveInboundLines` 抽函数搞坏**。`org-planner-prd`(`prd-planning`)的真实提示词逐字复现第五次的形状:
`[上游输入 —— 引擎已按图上声明的数据契约解析好,直接读这些路径]`
`- brd:…/runs/run_5274dad6b10c/nodes/rn_9836aeb60827/brd.md`
`- contract(任务级共享):…/rn_9836aeb60827/contract.json`
**一个 ⚠️ 都没有**。行为侧也一致:prd agent 的头两个动作就是 `Read` 这两条绝对路径,随后判「BRD 完整且五问齐备,无需打回」,节点 **attempt 0 一次 success** | | 产出侧 kind 认领 | ✅ `brd.md`→`kind=brd`、`prd.md`→`kind=prd`,两者都是**按出边 `produces` 声明**定的(不是文件名兜底);`contract.json`→`kind=contract` 按文件名 | **归因上的诚实交代**:⑤ 这一条无法把功劳单独归给 ③bis 还是骨架 —— `planner.json` 本身就在 `benchmark→validator` 上写了 `produces[acceptance]`、在 `prd→sdd` 上写了 `produces[prd]`, 所以「acceptance 与 prd 都有节点产出」这个结果两条路径都能推出来,本轮是**同向叠加**, 不构成对 ③bis 的独立验证。③bis 的独立证据只有验收点②(那段文字确实进了提示词)。 要把两者分开,得构造一个骨架覆盖不到的 kind(例如 org 边上声明 `produces[log]`)再看它补不补。 **跑法上踩到的一个坑(我自己造成的,写下来免得下一轮再踩)**: 第一次跑 `org-planner-brd` 时节点被判 `fail / fail_reason=boundary_escape` 而进 `blocked`, **而 agent 本身一个越界写入都没有**。`node.boundary` 事件的 `entries` 只有一条: `M docs/todo/system-design/27-p4-parallel-and-artifacts.md` —— 那是**我在节点跑的同时编辑本文件**。 `applyBoundaryGuard`(`graph-engine.js:2829`)拍调用前后两次**全仓库** `git status`, 差集里的每一条都算当前节点的越界;`splitUnattributable`(:2807)当时的豁免名单只有 `system/agents/*/{todo.md,plan.md,memory.md,_lessons.md,.claude/scheduled_tasks.json}` (旧名 `AGENT_RUNTIME_STATE_RE`),文档、源码、任何别的并发写入都不在豁免内。 > **28 号改造后这条豁免已放宽成整棵注册表树**:`AGENT_REGISTRY_TREE_RE = /^system\/agents\//`。 > 原因是新本体的落点搬进了被 git 跟踪的 `system/agents/{id}/`,运行期招聘会以 > `?? system/agents/{newId}/` 一整条目录项进 `git status`,旧的五文件名名单接不住、 > 会把成功的招聘节点判成 `boundary_escape`(gitignore 挡不住这一类:新本体本来就要进版本库)。 > **但本节这个坑一个字都没变**:`docs/` 不在 `system/agents/` 下,真跑期间人工编辑本文件 > 照样会冤枉正在跑的节点 —— 判罚范围收窄的只有注册表树。详见 28 号设计 §5.5b。 **结论:真跑期间不要在同一个工作树里改任何被 git 跟踪的文件** —— 这条约束此前只写成 「关掉 Master 巡检」,但巡检只是并发写入的一种,人工编辑同样会冤枉节点。 (重试后 `attempt 1` 一次 success,所以这只是浪费一次调用,不影响上表任何结论; 但重试本身留下了一个副作用文件 `contract.prev.json`,它落成了 `kind=artifact` —— 见下面第 4 条。) **同轮暴露的四条不对称**(都不是本轮改动引入的,且都不在本轮可改文件内): 1. **引擎提示词与新模板在 `wg_` 上直接对立,而且同处一条提示词里**。 `workgraph-planning/agent.md` 第 329/361 行(进了提示词)说「别自己编 `wg_` id」 「**不许出现 `wg_` + 12 位十六进制**,引擎优先匹配这个形状并当成已落库的图 id,查不到就整轮作废」; 而 `graph-engine.js:832` 拼出的第 775 行说「已经自行落库成功时,改为回一行 `workGraphId: wg_xxx`」。 本轮 agent 选择了听模板(没写 `wg_`),但这是运气而非保证。修法:删掉 `graph-engine.js` 那一行 —— agent 永远不可能「自行落库成功」,那个分支对它来说只是陷阱。 2. **同一处还留着「端点尚未上线」的措辞**(`graph-engine.js:829` → 提示词第 770 行), 暗示将来会上线;新模板的口径是 `NO-SUCH-ENDPOINT — 从来没有实现过`。两边措辞不一致。 顺带一提,`graph-engine.js:832` 那个内联 JSON 示例的边是 `{"from":"...","to":"...","condition":"success"}` —— **一个 `produces` 都没有**, 与模板「forward 边基本都要写 `produces`」正相反。三处(829/832/835)都在同一段字符串数组里, 一次改完最省事。 3. **org 层 `produces` 在产出侧没有认领方**(已知上限,非回归):org 节点是 subgraph, `deriveChildOutcome` 不回 `outputRef` → `output_ref` 恒 `null` → `indexNodeOutputs` 第一行就 return。 org 边上的 `produces` 能不能真的查到实例,完全取决于**子 run 里的 work 节点**把文件登记成对的 kind (`graph_files` 按 `(company_id, task_id)` 检索,子 run 继承同一个 `task_id`)。 本轮 org run 只推进到第一个 org node,**没有**验到第二个 org node 的入边把路径查出来 —— ③bis 的「上游节点交给你的输入」那一半(`resolveInboundLines` 拼绝对路径)在真跑里**仍未被覆盖**, 下一次真跑应当专门推到第二个 org node。 *间接旁证*:`resolveInboundLines` 是 `describeInputs` 与 `describeOrgContracts` **共用的同一个函数** (`graph-engine.js:3226`),验收点⑥已经证明它在真跑里能拼出正确绝对路径; org 层缺的只是「这条 org 入边上确实有声明 + 索引里确实有实例」这个具体组合的现场,不是代码路径。 4. **`contract.prev.json` 会被当成产物索引进去**(`kind=artifact`)。它是 `retryNode` 为算契约 hash 留的快照(`retry` 响应里的 `contractSnapshot.file`),不是节点交付物。产出侧没把它排除, 于是每次人工重试都会给 `graph_files` 添一行噪声。`validator.json` 那条「刻意零 produces」的 注释里已经写过同类道理(`attempt-{n}.json` 被排除在索引外),`contract.prev.json` 漏了。 修法:`indexNodeOutputs` 的排除名单加上它。 #### 控制台读回端(R19-C:R-4「边的输入输出」的字面落地) 此前 [27-P3/P4](./27-p3-p4-work-graph-console.md) §四.B.3 第 2 个 tab 把 R-4 落成了 「渲染 `output_ref` 指向的文件」+「原始产出 vs 被消费的形态」,**数据流是推断的** —— 所以那个 tab 只看得见自己的产出,看不见「我的输入从哪来」「我的产出给了谁」。 R-19 之后这两件事有实体(`graph_edge_contracts` × `graph_files`),控制台按实体重写: | 展示块 | 数据与规则 | 状态 | |---|---|---| | **输入** · 入边声明的契约 × 索引里的实例 | 逐 kind 一行:沿边传递 / 任务级共享、解析到的**绝对路径** / ⚠️ 查不到实例、`required`、以及**这一行 agent 到底看不看得到**(引擎对「可选 + 无实例」整行省略)。join 规则与 `describeInputs` 同源(同 kind 后来者胜、backward 入边不是数据来源) | ✅ `web/src/components/graph-console/io-model.js` + `NodeDetailPanel.vue` 的 io tab | | **输出** · 本节点产出并登记进索引的实例 | `taskFiles` 里 `produced_by_node_run_id = 本节点` 的行,各自 kind / 时间 / 是否被新版本取代;节点级指针 `output_ref` 与公司级索引的分工写在块首 | ✅ 同上 | | **出边契约** · 我的产出给了谁 | 每条 forward 出边一组 kind 药丸,三态:我产的 ✓ / 索引里有但不是我产的(标「由 X 产出」,合法,引擎按 task+kind 检索)/ 整个 task 都没有实例 ⚠️(= R-19.4 那类跑之前就能看出来的图结构错误) | ✅ 同上 | | **画布上的边** | 只有**声明过契约**的边多画一枚小药丸(必需的那个 kind 占位、其余折 `+N`、全量与 `?`=可选进 ``);`GAP_X` 只有 52px,第二个标签就会压线,所以克制到一枚 | ✅ `WorkGraphCanvas.vue` | | **tab 徽标** | 必需输入解析不到的条数标成「输入输出 ⚠️N」,不用点进去才发现 | ✅ | | **前置:路由字段** | `GET /api/graph-runs/:id` 要补三个字段:`companyRoot`(= `companyRootDir(run.company_id)`,库里只存相对路径)、`taskFiles`(= `findFiles({companyId, taskId})`)、`edges[].contracts`(= `edgeContracts(layer, e.id)`,`readDefGraph` 已回 `edge.id`)。三者都是现成导出函数 | ✅ **已接**(`1ea9efa`)。缺字段时的降级行为保留:三块**整块降级**并点名缺哪一个,不做任何推断(推断出来的数据流正是第四次真跑栽的坑)。⚠️ **降级分支已被真跑实测过、接通后的正常分支还没有**:第五次真跑用的是 `1ea9efa` 之前的构建,那时节点详情「输入输出」tab 只显示降级横幅「产物索引未随响应下发…`GET /api/graph-runs/:id` 还没返回 `companyRoot` / `taskFiles` / `edges[].contracts`」,画布 forward 边上也没有契约药丸——即当时 R-19 的数据通路**引擎侧已通、控制台侧看不见**。接上之后界面能否真的显示解析出的绝对路径,**留待下一次真跑复验** | | `graph_edge_contracts.role` | `produces` / `needs` 的非对称在 `declareEdgeContract` 处被丢掉(表里没有 role 列),落库后两者不可区分 | ✅ **文档落后于代码**:`role` 列(`work-graph-db.js:465` 建表块 + `:698` 存量库迁移回填)与 `normalizeContracts()`(`org-graph-service.js:49`,按来自 `edge.produces` 还是 `edge.needs` 分别打 `role`)2026-08-20 前后就已落地,守卫 `graph-edge-contract-role.test.js` 早于本文档最初写作。2026-09-08 核实时才发现这行判断是错的,予以订正 | --- ## 五、任务划分 | # | 任务 | 文件 | 依赖 | |---|---|---|---| | ~~**T1**~~ ✅ | `SessionPool` 加 `maxSessions` 上限 + 调用方传 `scope` —— **已完成**(27-P3C §15.5-2,守卫 `session-pool-max.test.js` / `session-pool-scope-key.test.js`) | `server/src/core/session-pool.js` | 无 | | ~~**T2**~~ ✅ | DevLoop 规格:§12 第一条不变量放宽为 `(pid, agent, scope)`。**2026-09-08 已完成,且发现原计划有误**——`CONVENTIONS.md` 不是 `spec:extract` 的生成物:全仓库 `docs/prd/newfunction/*.html` 里没有一份 `conventions.html`,`npm run spec:check` 只处理 52 个 `.html`→`.md` 的 1:1 映射,`_spec/` 目录里第 53 份的 `CONVENTIONS.md` 是手写文件,改它不需要也不会触发 `spec:extract`。直接改的是 `docs/prd/newfunction/_spec/CONVENTIONS.md` 本体 | `docs/prd/newfunction/_spec/CONVENTIONS.md` | 无 | | ~~**T3**~~ ✅ | `graph_runs` 加 `max_concurrency` 列(可空,NULL 用新导出常量 `DEFAULT_MAX_CONCURRENCY=5`,对齐 `plan-cost-concurrency.md` §④);`createRun` 新增 `maxConcurrency` 参数并持久化 —— **2026-08-21 已完成**(迁移块 + `createRun` INSERT 都改了,`hasColumn` 判断 + `ALTER TABLE ADD COLUMN` 照抄既有存量库迁移写法)。~~DROP `idx_graph_runs_one_active_root`~~ 那半本来就已完成(27-P3C 批 4),不用动 | `server/services/work-graph-db.js` | 无 | | ~~**T4**~~ ✅ | `advanceBatch(runId, {maxConcurrency})`:`maxConcurrency<=1` 直接委托 `advanceOne`(零行为差异,不是反过来让 advanceOne 委托 advanceBatch——见函数注释里的理由);`>1` 时 `SELECT ... LIMIT` 取候选,循环调 `sse.transitionNode` 逐行拾取(**不包裹事务**,§4.1 决策的完整论证)。**2026-08-21 已实现并测过** | `server/services/graph-engine.js` | T3 ✅ | | ~~**T5**~~ ✅ | ~~并发额度计算(三层取小)+ 额度为 0 时的等待语义~~ **2026-08-21 已实现,按可插拔桩子的方式落地**(用户明确选择:真实计费接口未定前先搭桩子)。`setBillingGate(fn)` 与 `setAgentExecutor` 同款注入模式,未注入时 `checkBilling` 恒「未欠费」,对现有行为零影响;欠费时复用 `suspendRunTree`(新增 `reason`/`summary` 参数,`reason='billing-arrears'`),写进新增的 `graph_runs.block_reason` 列;`startRunSync`「启动」清空该列;`resumeBillingClearedRuns()` 是自动恢复触发器(扫 `block_reason='billing-arrears'` 的 blocked root run,欠费真清了就代其 `startRun`),调度源(webhook/轮询)留给计费接口接通之后再定。**顺带修了一个真 bug**:`advanceBatch` 原来 budget 门查在候选查询之前,导致「已收敛只是恰好预算也耗尽」的 run 被误判成挂起而不是 completed——已改成候选先查、budget/billing 门后查,跟 `advanceOne` 的既有顺序对齐。新测试 `graph-engine-billing-gate.test.js`(7 条),临时 worktree 隔离验证(见下方实现记录) | `server/services/graph-engine.js` | T1, T3, T4 | | ~~**T6**~~ ✅ | 把 `advanceOne` 拾取之后的整段(建 ctx → 执行 → `settleNode`)抽成 `runClaimedNode(...)`,`advanceBatch` 用 `Promise.allSettled` 并发跑 claimed 节点。fan-in join 复用现有 `activateSuccessTargets`/`markReady`,**不需要新事务/锁**(并发安全性证明见 §4.3)。**2026-08-21 已实现并测过** | `server/services/graph-engine.js` | T4 ✅ | | ~~**T7**~~ ✅ | 产物索引:追加、查询、rebuild —— **已完成,形态换成 `graph_files` 表**(见 §3.6 校准块),不是 `files.json` | `server/services/work-graph-db.js` | — | | ~~**T8**~~ ✅ | 产物按 run/node 分目录 + 节点产出后自动进索引 —— **已完成**(`runs/{rootRunId}/nodes/{nodeRunId}/`) | `server/services/graph-engine.js` | — | | **T9** | ~~node agent 的 prompt 注入 `files.json` 检索说明~~ **很可能已经被 R-19 做掉、不用再建**:R-19 的设计哲学(§4.6「读回侧」)明确拒绝了「注入一句『自己去索引里查』」这条路——`describeInputs`/`resolveInboundLines` 已经把声明过契约的输入**直接解析成绝对路径**塞进提示词,不依赖 agent 自己检索。T9 原始设想的场景(agent 主动查询未在边上声明的共享产物)目前没有对应的可调用工具——Claude CLI 只读被塞进提示词或提示它去 `Read` 的路径,`graph_files` 表对它是不可见的。**先确认这个场景是否真的需要,需要则要设计一个新工具而不是「prompt 里加一句话」,不需要则直接划掉** | `system/templates/base/agent.md` 等 | T7 | | ~~**T10**~~ ✅ | 控制台:fan-in 等待态可视化(「在等哪几个节点」)+ 并行运行中的多节点高亮 —— **2026-08-21 已实现**。新增 `join-status.js`(`resolveJoinWait`/`joinWaitLabel`,判据与引擎 `activateSuccessTargets` 同源),`WorkGraphCanvas.vue` 给 pending 的真 fan-in 节点画「⏳done/total」徽标 + 悬停明细;多节点高亮不需要新代码(`.dot.running` 已经按每节点各自状态渲染,T4/T6 落地时就生效了);顺手把从未被任何调用方传值的 `nextNodeId` 改成 `nextNodeIds` 数组,接上 `run.max_concurrency` | `web/src/components/WorkGraphCanvas.vue`、`web/src/components/graph-console/{join-status.js,GraphRunPane.vue}` | T6 | | ~~**T11**~~ ✅ | ~~测试:批量拾取的并发幂等...~~ **没有单独立项**:随 T4/T6/T5 各自的实现同批写了(`graph-engine-advance-batch.test.js` 12 条、`graph-engine-billing-gate.test.js` 7 条、`join-status.check.js` 6 条断言),覆盖面与本行原计划一致(并发幂等/部分失败停等/额度生效都在),`files.json` rebuild 那半属于阶段 C(早就测过,见 T7/T8 状态) | `server/tests/unit/` | T4-T8 | ### 三个阶段 | 阶段 | 含 | 交付 | 可否独立上线 | |---|---|---|---| | ~~**A · 隔离**~~ ✅ | ~~T1~~ ✅ ~~T2~~ ✅ | 同一 agent 可安全并发调用 —— **2026-09-08 全部完成**:T1 当初的✅只做完了 `maxSessions` 上限,`scope` 参数的生产接线(`graph-engine.js` → `agent-manager.js` → `SessionPool`)当时其实没做,本轮一并补上;T2 随之完成 | ✅ | | ~~**B · 并行**~~ ✅ | ~~T3~~ ✅ ~~T4~~ ✅ ~~T6~~ ✅ ~~T5~~ ✅(桩子)~~T10~~ ✅ | fan-out 真正并发;解除单 root run 约束 | ✅ 依赖 A,**2026-08-21 全部完成** | | **C · 产物索引** | T7-T9 | `files.json` + 按 task 分目录 | ✅ **完全独立于 A/B**,可最先做 | **阶段 C 与 A/B 无依赖**,如果想先看到效果,可以先做 C(它是用户明确要求过的第 3 项)。 > **2026-09-08 补一条 B 阶段里没有单独立项、但确实属于「真并行」的缺口**:`advanceTree`(`graph-engine.js`,`runAuto` 的驱动原语)一直标着 `ponytail:` 只推「一棵子树里最深的一个 active run」,同一 tick 里的**兄弟**子 run(两个并行 `subgraph` 节点各自的子 run)靠外层循环轮流服务,不是真并发——`advanceBatch` 解决的是「同一个 run 内部」的并发,跨 run 这一层当时留了后手。已按代码注释里写好的升级路径改成 `Promise.allSettled` 并发推进树里所有 active run(安全性论证同 §4.3:不同 runId 的行互不覆盖,`settleRun` 被多次幂等调用不是新风险)。守卫 `graph-engine-advance-batch.test.js` 新增一例,反向验证过(撤回改动后 `maxActive` 卡在 1)。 > **2026-08-20 校准**:C 已经完成,A 的 T1 也完成了(都是在 P3C 那几批里顺手落的, > 当时没回来更新这份文档)。**真正剩下的是 B**:`max_concurrency` 列、`advanceBatch`、 > 三层额度取小、并行执行与部分失败降级,以及 T10 的控制台可视化。 > 这一段是「文档落后于代码」的又一例 —— 判断 P4 还剩多少工作量时, > **先照着任务表逐条 grep 一遍代码**,别照文档估。 > **2026-08-21 实现记录(T3/T4/T6)**: > - `server/services/work-graph-db.js`:`graph_runs` 加 `max_concurrency`(可空列 + 存量库迁移), > 新导出常量 `DEFAULT_MAX_CONCURRENCY=5`,`createRun` 新增 `maxConcurrency` 参数并校验/持久化。 > - `server/services/graph-engine.js`:把 `advanceOne` 拾取节点之后的整段(建 ctx → 执行 → `settleNode`) > 原样抽成 `runClaimedNode(runId, run, def, defNode, nodeName, picked, opts)`,`advanceOne` 改成调它 > (逐字同行为,靠现有 40 个用例背书,跑绿)。新增 `advanceBatch(runId, opts)`:`maxConcurrency<=1` > 委托 `advanceOne`;`>1` 时按 §4.1 决策逐行 `sse.transitionNode` 拾取(不包裹事务)、 > `Promise.allSettled` 并发跑 `runClaimedNode`。两者都从 `module.exports` 导出。 > - 新测试 `server/tests/unit/graph-engine-advance-batch.test.js`(5 条,diamond 图 entry→{a,b,c}→join): > fan-out 真并行(不借助 setImmediate/sleep,靠「`advanceBatch` 在自己第一次 await 之前全程同步」 > 这条性质直接同步断言)、批量拾取跳过已被抢走的候选、部分失败时 join 保持 pending 且 run 收敛到 > `blocked`、`maxConcurrency<=1` 委托行为、`run.max_concurrency` 持久化生效。 > - 全量 `npx vitest run`:147 文件 / 1347 用例全绿,零回归。 > - **没做的**:`advanceBatch` 还没接进 `runAuto`/路由——自动模式今天仍然只调 `advanceOne`, > 实际生产流量拿不到并行,这是接下来的线(大概率是 T10 之前、或与它同批)。T5(欠费门 + 自动恢复 > 触发器)仍卡在计费系统接口确认,T10(控制台可视化)未动。 > > **2026-08-21 追加:`advanceBatch` 接进 `advanceTree`/`runAuto`**(同一天,紧接上面那批): > - `advanceTree`(`runAuto` 每一步都调它)里 `advanceOne(r.id, opts)` 换成 `advanceBatch(r.id, opts)`—— > 「推最深的一个 active run」这个 tree-walk 逻辑不变,变的是「推」这一步本身现在能一次 claim 一批。 > `.driver` 的调用方(生产路由、测试)一律只读 `.ok`,不依赖 `.node`/`.results` 的具体形状—— > 核实过全仓库唯一消费方是 `advanceTree` 自己的循环判定 + 两个 blind-spot 测试文件,两者都只用 > `.ok`,这次改动不需要做返回形状的兼容层。 > - **审这段时抓到一个真 bug 并顺手修了**:`advanceBatch` 的 `no-ready-node` / `taken-by-other` > 分支原来写的是 `ok:true`,跟 `advanceOne` 用 `result()` helper 的惯例(这两种情形一律 `ok:false`) > 不一致。不修的话接进 `runAuto` 后,一个已经收敛的 run 会被自动循环一路空转到 `maxSteps`(1000) > 才停——不是崩溃,但是真实的效率/语义回归,而且正好是旧测试从没试过「对已收敛的 run 连续调 > `advanceBatch`」才漏掉的那种 bug。已修好并补了回归用例。 > - 新增 2 条测试(同一文件):`ok:false` 语义与 `advanceOne` 对齐(直接绕开引擎构造 > 「`run_status` 还是 running 但没有 ready/running 节点」这个状态,同款手法 `graph-engine.test.js` > 已经在用);`runAuto`/`advanceTree` 端到端确实吃到并行——用真实 `setTimeout` 延迟(不是同步 > `deferred`)证明 a/b/c 三个 executor 调用有时间窗口重叠,不是「反正都同步排到一起」的假象。 > - 全量 `npx vitest run`:147 文件 / 1350 用例全绿,零回归。 > - **仍然没做的**:`advanceTree` 一次只推「最深的一个」active run,**兄弟子 run**(比如两个并行 > subgraph 节点各自起的子 run)不会同时被推——它们靠 `runAuto` 外层循环一轮一轮轮流服务, > 不是真并发(已在 `advanceTree` 加了 ponytail 注释记这笔)。真并发目前只发生在**同一个 run > 内部**。这个跨 run 兄弟并发是否值得做,留给 T10 之后再看有没有真实场景撞上。 > > **2026-08-21 追加:T5 落地**(同一天,紧接上面那批): > - 用户明确决策:欠费判据「先搭可插拔的桩子」——真实计费接口依赖计费系统那边确认,不等它, > 先把挂载点和恢复机制落地。`setBillingGate(fn)` 与 `setAgentExecutor`/`setPlanWorkGraph` > 同款注入模式;`checkBilling(run)` 未注入时恒 `{ok:true, inArrears:false}`。两处调用点 > (`advanceOne`、`advanceBatch`)都紧跟在 `checkBudget` 之后——欠费是账户级门,预算是 > 任务级门,两个维度不合并。 > - `suspendRunTree(run, opts)` 加了 `reason`/`summary` 参数(默认值保持旧调用方——`checkBudget` > 那条路径——行为不变),欠费走同一个函数只是 `reason='billing-arrears'`。`graph_runs` 新增 > `block_reason` 列(`suspendRunTree` 写、`startRunSync`「启动」时无条件清空)+ 复合索引 > `(run_status, block_reason) WHERE parent_run_id IS NULL`,配 `resumeBillingClearedRuns()` > 的扫描查询:找出所有欠费挂起的 root run,对每条重新查一次欠费状态,清了就代其 `startRun`。 > 谁来调用这个扫描函数、多久扫一次,留给计费系统事件/轮询接通之后再定。 > - **审这段时抓到第二个真 bug**:`advanceBatch` 原来是「先查 budget、再查候选」,跟 > `advanceOne` 的既有顺序(先确认真有 ready 候选,才查 budget——`advanceOne` 注释原话 > 「拾到了 ready 节点(=真要花钱了)才查,避免空转时把已跑完的 run 误挂起」)反着来。 > 后果:一个已经收敛(零 ready 节点)只是恰好预算也耗尽的 run,会被误判成 > `budget-exhausted` 而不是正确收敛到 `completed`。接欠费门之前先把这个顺序修对, > 两道新旧门用同一个正确的位置。 > - 新增测试 `graph-engine-billing-gate.test.js`(7 条):零影响、`advanceOne`/`advanceBatch` > 欠费挂起、`startRunSync` 清空 `block_reason`、`resumeBillingClearedRuns` 正确恢复/放过、 > 按 `companyId` 限定扫描、上面那条排序 bug 的回归用例。 > - **踩到并处理了一次真实的并发编辑事故**:写 T5 期间发现 `work-graph-db.js` 被另一个会话 > 同时编辑(重写 `validateFailFallback`,31 号文档那条线,当时未完成、会让 51 个测试失败), > 与我的 `block_reason` 改动混在同一份工作区文件里。没有跟着 `git add -A`,改用 `git diff` > 自动生成的 patch 只保留自己的 3 处 hunk(`git apply --recount --cached`), > 再用一次性的 `git worktree add --detach` 临时工作树(跑完 `git worktree remove` 清掉) > 把这批 staged 内容单独跑一遍全量测试,确认自己的改动跟对方未完成的那部分互不依赖, > 最后才 commit。过程中还手滑把上一条 commit 的说明文字贴成了另一条无关 commit 的原文, > 用 `git commit --amend` 当场改正(那次 amend 合法——是刚提交、还没推送、也没有任何东西 > 叠在它上面的自己的commit,不是在改别人的历史)。这条经验值得记:共享工作目录下, > commit 前先 `git diff` 走查一遍两个待提交文件的内容,不要假设「我改的文件只有我的改动」。 --- ## 六、验收标准 1. **并发隔离**:同一 system agent 被两个并行节点同时调用,两次对话的上下文互不污染(各自有独立 session)。 2. **批量拾取幂等**:并发调用两次 `advanceBatch`,拾取到的节点集合无交集,且没有节点被漏掉。 3. **fan-out 真并行**:一张有 3 个并行分支的图,自动模式下三个节点同时处于 `running`(而不是依次),且总墙钟明显短于串行。 4. **部分失败停等**:3 个并行分支中 1 个失败时,fan-in 节点保持 `pending` 不被点亮;控制台能显示「在等哪几个节点」。 5. **额度生效**:`max_concurrency=2` 的 run 上有 5 个 ready 节点时,同时 running 的不超过 2 个。 6. **单公司多 run**:同一公司可同时有 3 个 active root run,第 4 个被拒绝且错误信息明确。 7. **手动模式不变**:`maxConcurrency=1`,「下一步」行为与 P3 完全一致(P3 已有测试全绿)。 8. **`files.json`**:节点产出后索引自动追加;删掉 `files.json` 后 rebuild 能从节点行完整重建;两个 task 的同名产物(都叫 `prd.md`)不互相覆盖。 --- ## 七、影响面 | 文件 | 改动 | |---|---| | `server/src/core/session-pool.js` | `maxSessions` 上限;`getKey` 的 `scope` 参数实际启用 | | `server/src/core/agent-manager.js` | 调用 `getKey` 时传 `scope` | | `server/services/work-graph-db.js` | `graph_runs.max_concurrency` 列;DROP 单 root run 唯一索引 | | `server/services/graph-engine.js` | `advanceBatch`;额度计算;并行执行与错误降级;产物路径改 `tasks/{taskId}/` | | `server/services/company-files-index.js` | 新增 | | `server/services/goal-contract.js` | 契约文件路径归并到 `tasks/{taskId}/` | | `web/src/views/GraphConsole.vue` | fan-in 等待态、多节点并行高亮 | | `docs/prd/newfunction/_spec/CONVENTIONS.md` | §12 不变量放宽(✅ 2026-09-08 已改;核实后发现它是手写文件、不是 `spec:extract` 的生成物,不需要跑 extract) | | `system/templates/base/agent.md` | `files.json` 检索说明 | **明确不改**:P3 的 join 语义(`graph-engine.js:1078` 已正确)、熔断与预算语义、手动/自动共用同一原语的设计、`workDir` 指向私有目录的约定(P1 §3.3 已论证)。 --- ## 八、风险 / Open Questions 1. **并行让预算烧得更快,但预算语义不变**。任务级总预算是「只增不减的共池」(P3 已实现),并行不改变总量,只改变消耗速率。**控制台的成本提示需要相应更新**——用户点「自动」时看到的预估时间会大幅缩短,但花费不变。 2. ~~**改 DevLoop 规格的连带风险**(T2)。§12 第一条不变量是 DevLoop 的,放宽它理论上不影响 DevLoop 自身(它的 `runProject` 波内本来就是串行的),但**必须跑一遍 DevLoop 全部测试**确认。~~ **2026-09-08 已解决,且风险本身不成立**:`CONVENTIONS.md` 不是 DevLoop 代码读取的配置,只是给人/agent 看的文字说明,`(pid, agent)` 这条不变量从没有被任何 DevLoop 代码断言过——改文字不触发任何测试变化,`npm run test:unit` 全量跑过确认无回归。 3. ~~**`maxSessions` 上限触顶时的行为未定**。额度为 0 时是「等待下一轮」还是「拒绝并报错」?本文档 §4.4 选了前者(不报错、等下一轮),但如果一个 run 长期抢不到额度会静默卡住——需要一个「等待超时」告警,否则表现为「不动了」。**留待实现时定阈值。**~~ **按 2026-08-21 决策已解决**:公司级/机器级不再设硬顶,「长期抢不到额度」这个场景基本不会出现了。账户欠费门定了——复用 `suspendRunTree` 的挂起语义(`reason='billing-arrears'`),跟预算耗尽是同一条路径,不发明第二种失败形态。**新缺口**:欠费清零后的恢复今天要人手点「启动」,无人值守场景下没人点——需要一个自动恢复触发器(欠费状态清除时批量对 blocked 的 root run 调 `startRun`),细节见 §4.4 决策块。欠费检查本身怎么接、恢复触发器订阅什么事件源,都要等计费系统那边的接口确认。 4. ~~**`files.json` 的并发写入**。并行节点会同时追加索引,需要串行化(文件锁或走 SQLite)。本文档设计的是 JSON 文件形态(符合用户要求「一个 files.json 文件」),但**并发追加是真实风险**——可能需要退化为「引擎内单写者队列」。~~ **已随 §4.6 的 v0.2 重写作废**:产物索引早就从「一个 files.json 文件」改成了 `graph_files` SQLite 表(阶段 C 已完成)。并发追加不需要文件锁——`indexNodeOutputs` 是 `settleNode` 内部的同步调用,跟 §4.3 证明 join 不需要额外锁是同一个道理(Node 单线程事件循环 + `better-sqlite3` 单写者,写表本身已经串行)。这条风险不成立,留着只是防止以后有人照旧设计去实现一个不存在的 JSON 文件锁。 5. **产物索引的 `consumedBy`/契约声明的准确性**。`graph_edge_contracts` 的 `needs`/`produces` 是**声明式**的(规划时写「这条边应该传什么」),不是实测的。如果规划 agent 声明错了,`validateGraph` 的 `data-contract` 规则能拦住「声明要吃的 kind 全图没人产」这类静态错误,但拦不住「声明的 kind 与实际语义对不上」这种语义错误。缓解方式不变:索引/契约是给 agent 的强提示(引擎直接把解析出的路径递到提示词里),不是不可绕过的约束,agent 仍可读同一 task 下任何已登记的产物。 --- ## 九、与其他阶段的关系 - **P3**:本阶段完全建立在 P3 的引擎之上,不推翻任何 P3 决策。P3 的「手动/自动共用同一原语」「join 语义」「熔断与预算」「三层暂停级联」全部保留。 - **P5(自我进化 / L4)**:无依赖,可并行推进。但 L4 的「agent dream 离线批处理」会受益于 `files.json`——跨公司汇总当日执行经验时,产物索引是天然的输入源。 - **P6(DevLoop 收编)**:T2 放宽的不变量是 P6 的前置——DevLoop 的 `runProject` 波内串行(`loop-controller.js:609` 注释 `// 串行跑`)正是被同一条不变量挡住的,P4 做完后 DevLoop 自己也能并行。