Commit aecf5c9f55999e3d677166c376f0e082e7b756c4

Authored by yibo
1 parent 599af2aa

fix(graph-engine): produces/needs 契约从软提示变成真闸门(T-mt25ecbd)

planner 骨架(brd→prd→sdd→goal-benchmark→validator)复现:三个 planning 节点
声明了 produces 却没真落盘,executor 没抛异常就被引擎判 success;goal-benchmark
的入边 needs:[brd,prd,sdd] 只用来拼提示词里的 ⚠️ 提示,agent 视而不见照样产出
合法 acceptance。下游 validator 永远读不到三份文档,backward 重试撞循环硬顶后
只能人工兜底。

settleNode 判 success 之前新增两道闸门(均只对 agent_call 节点生效,subgraph/org
节点的契约由子 work run 自己的节点过闸):
- unsatisfiedRequiredProduces:声明的 required produces 一个候选文件都没落盘 →
  fail_reason=produces_missing,不再放行
- requiredInputsMissing:声明的 required needs 在 graph_files 里查不到实例 →
  fail_reason=missing_required_input,不采信 agent 自报的 success

两者都只判「有没有」,不判「分类对不对」——ambiguous 归类留给下游 ⚠️ 提示词处理,
避免把「写了东西但引擎猜不出对应哪个 kind」误判成没执行。

包含此前已完成但未提交的 27-P4 T3(graph_runs.max_concurrency 落库)及配套
advance-batch 测试。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/README.md
... ... @@ -47,6 +47,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d
47 47 | [27-p4-parallel-and-artifacts.md](./todo/system-design/27-p4-parallel-and-artifacts.md) | **P4(回收编号)并行执行与产物索引** — 📝 设计中,未实现。fan-out 真并发 + 同 agent 多实例隔离 + `files.json` 产物索引。三个 Phase,C 可插队 |
48 48 | [28-followups.md](./todo/system-design/28-followups.md) | 28 号(Agent 注册表统一)的遗留清单 — 主文档已在 `done/`,这份装没做完的:dream 机制(设计见 29 号,已实现,见下)、`source_kind`(✅已删)/`legacy_path`(不能删)删列、三项「已实施未验证」。任务态清理(✅已实现,见下)。§五 是**已立起来的守卫清单**(别重复造,也别删) |
49 49 | [29-dream-design.md](./todo/system-design/29-dream-design.md) | **dream(经验汇流)**— ✅ 已实现。定时整理所有 agent 在所有公司的记忆,提炼后跨公司沉淀(不泄漏公司信息)。四条取舍已拍板并实现:去敏独立判定、四落点分层写入(经验/`SKILL.md`/`soul.md`/`agent.md`,受管区块+三道闸门)、每日定时(串行+空转保护)、引用真伪自动查、REST+控制台入口(`/api/dream/*`、`/dream`)。`innovator-evolver` 真 CLI 跑通一次。**仍缺的**:「结论对不对」只能人抽查;「套进 org/work graph 来跑」评估后用户选了不做(作用域不匹配,见文内 §5.7) |
  50 +| [30-task-delegation-reliability-gaps.md](./todo/system-design/30-task-delegation-reliability-gaps.md) | **任务委派可靠性现状**— 📝 调研记录,非设计方案。DevLoop `runUnit`、Company 招聘装配、Graph 执行三条链路核对结论一致:都是「失败预算耗尽 → 停等人工」,没有「卡住了主动通知人」这一层;DevLoop 当前还默认走 mock,真实 CLI 成功率无实证数据 |
50 51  
51 52 ## todo/future/(暂缓,非当前重心)
52 53  
... ...
docs/todo/system-design/27-p4-parallel-and-artifacts.md
... ... @@ -17,6 +17,26 @@
17 17  
18 18 ---
19 19  
  20 +## 〇.5、上级背景:黑灯工厂差距评估(2026-08-21)
  21 +
  22 +用户原话(提问):
  23 +
  24 +> lemonboss 现在离我们想做的黑灯工厂,能够自主 7*24 小时,自我规划,自我改进,自我迭代,自我完成任务的 graph org agent 系统,还有多远
  25 +
  26 +评估结论(基于当时代码库状态 + `system-design/27` 系列文档):
  27 +
  28 +图引擎地基(P0~P3)扎实——org/work graph 统一执行引擎、暂停/重试/回退/熔断、真 Claude CLI 跑通、控制台都已落地并有真跑验收,这部分不是障碍。离「黑灯工厂」还有三个主要缺口:
  29 +
  30 +1. **真并发/无人值守跑不起来** —— 即本文档要做的 P4。现在一家公司同时只有一个 active root run,是单线程步进(`advanceOne` 一次只拾取一个 ready 节点),没有 fan-out 真并发,也没有为无人值守设计的看门狗/异常恢复层。7×24 意味着要能同时开很多任务、扛得住某个节点卡死或进程崩溃,这套目前没有。
  31 +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 全部不进提示词。自我改进要改的是这些本体,本体装配本身有静默漏洞,改进闭环就是在往一个不完整的东西上写。
  32 +3. **没有真正的"自我规划"层** —— 现在的 org graph / work graph 都是 `orggraph-planning` / `workgraph-planning` 根据**人给的目标**生成的一次性产物,OKR/任务系统的驱动权还在人手上。定时的只有 dream(整理记忆),没有"自主决定下一步该做什么任务/该设什么目标"的常驻调度层——这一层连设计文档都还没有(不在现有 P5/P6 范围内)。
  33 +
  34 +优先级建议:P4 并行(本文档)→ 补本体装配的静默坑(不然自我改进在改错东西)→ P6 收编统一循环 → 把 dream 的"结论对不对"自动化 → 最后才是自主任务生成层(目前完全空白,需要新设计)。
  35 +
  36 +本文档要设计的 P4 是这份差距评估里排第一的缺口,也是三者中唯一已有详细设计(§四)且大部分已验证落地(阶段 A/C 完成,见 §五校准块)的部分——剩下的是阶段 B(T3-T6, T10)。
  37 +
  38 +---
  39 +
20 40 ## 一、目标与非目标
21 41  
22 42 ### 目标
... ... @@ -152,7 +172,23 @@ advanceBatch(runId, { maxConcurrency }) → { results: [...], activated: [...]
152 172 - **自动模式**:`maxConcurrency = 有效额度`,一次拾取整批并发跑。
153 173 - `advanceOne(runId)` 保留为 `advanceBatch(runId, {maxConcurrency: 1})` 的薄封装,P3 已有的调用方和测试不用改。
154 174  
155   -**拾取的原子性**:P3 的幂等靠 `WHERE status='ready'` 条件更新(受影响行数=0 即视为被别人拿走)。批量拾取要沿用同一手法——**在一个事务里把这批节点一次性置 `running`**,而不是先 SELECT 再逐个 UPDATE,否则两个并发的 `advanceBatch` 会拾取到重叠的集合。
  175 +**拾取的原子性 —— 决策(2026-08-21,推翻本节上一版)**:不需要新开一个包裹整批的 `BEGIN IMMEDIATE` 事务。直接复用 `advanceOne` 今天已经在用的同一个原语——`sse.transitionNode(runId, nodeId, 'ready', 'running', ...)`,单行条件 `UPDATE ... WHERE status='ready'`(`graph-engine.js:1369` 原话:「ready → running 的条件更新就是并发闸门」)。这条语句本身在 SQLite 单写者模型下已经是原子的。
  176 +
  177 +```js
  178 +const candidates = db.prepare(
  179 + `SELECT * FROM graph_run_nodes WHERE run_id=? AND status='ready' ORDER BY created_at, id LIMIT ?`
  180 +).all(runId, maxConcurrency);
  181 +
  182 +const claimed = candidates.filter(node =>
  183 + sse.transitionNode(runId, node.id, 'ready', 'running', ...) // advanceOne 今天调的同一个函数
  184 +);
  185 +// 拾取阶段全程同步、零 I/O;下一步才 await 真正的 agent 调用
  186 +const results = await Promise.allSettled(claimed.map(node => executeNode(node)));
  187 +```
  188 +
  189 +**为什么不需要包裹事务**:两个并发 `advanceBatch` 即使 `SELECT` 到同一批候选节点,也是逐行在 `transitionNode` 上竞争——每一行只有一个调用者拿到 `changes=1`,另一个拿到 `0` 直接跳过。最终两边拾取到的集合天然无交集(§六 验收点 2),不需要把「数出还有空位」到「占上」捆成一个不可分事务——那是 `work-graph-db.js:1437` 建 run 时才需要的手法(它是「先数活跃 run 数、再判断、再写」这种读-判-写耦合在一起的多语句决策,必须原子;节点拾取没有这种耦合,每一行的归属独立由它自己的 `UPDATE` 决定)。
  190 +
  191 +**已知且接受的代价**:某次 `advanceBatch` 可能因为在部分候选节点上输掉竞争,实际拾到的节点数少于 `maxConcurrency`(即使还有别的 ready 节点没进这次的 `LIMIT` 窗口)。自动模式下一轮循环会补上,不必现在解决。
156 192  
157 193 ### 4.2 同一 agent 的多实例隔离
158 194  
... ... @@ -185,6 +221,21 @@ const scope = `gr:${nodeRunId}`;
185 221  
186 222 **沿用 P3 已实现的 join 语义,不加特例**:`graph-engine.js:1078` 的规则是「所有入边条件都满足才置 ready」。失败的那条分支走的是 `fail` 边,不满足 fan-in 节点的 `success` 入边条件,所以 **fan-in 节点不会被点亮**,图停在那里——这正是期望行为。
187 223  
  224 +**并发下的 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` 原样复用即可。
  225 +
  226 +**T4/T6 的具体切法**:把 `advanceOne` 里「拾取之后」的整段(建 ctx → `await` 执行 → `settleNode`,`graph-engine.js:1379-1505`)抽成一个可复用函数(如 `runClaimedNode(runId, run, pickedNode, opts)`),`advanceOne` 与 `advanceBatch` 的并行路径共用它:
  227 +
  228 +```js
  229 +// T4 的拾取只到「置 running」为止(见 §4.1);执行与结算是这里
  230 +const settled = await Promise.allSettled(
  231 + claimed.map(node => runClaimedNode(runId, run, node, opts))
  232 +);
  233 +```
  234 +
  235 +`runClaimedNode` 内部原有的 try/catch(把 agent 调用异常转成 `fail` outcome 喂给 `settleNode`,而不是往外抛)已经是主要的错误containment;`Promise.allSettled` 补的是最后一道防线——万一 `settleNode` 自己有 bug 抛出去,也不会拖垮同批里其它节点的 promise。
  236 +
  237 +**已知且不打算优化的冗余**:一批里多个节点并发结算,各自 `settleNode` 末尾都会调一次 `settleRun(runId)`——同一个 run 短时间内被判定收敛好几次,幂等但浪费,不是 bug,不在本阶段处理范围。
  238 +
188 239 需要补的是**可观测性**,不是新语义:
189 240  
190 241 - run 层面要能回答「我卡在等哪几个节点」。控制台已有的节点状态足以推导,但要在 UI 上显式标出「fan-in 节点 `X` 在等 `A`(fail) / `B`(success) / `C`(success)」,否则用户只看到「不动了」。
... ... @@ -192,7 +243,17 @@ const scope = `gr:${nodeRunId}`;
192 243  
193 244 ### 4.4 并发额度:三层,逐层取小
194 245  
195   -对齐 §三.5 的既有数字,不另立一套:
  246 +**决策(2026-08-21)**:暂时不做「三层配额审批」这套东西——公司级 active root run 数、单机 session 数都**不设硬顶**(下面表格降级为参考数字,不是要实现的约束),唯一要卡的门是**账户欠费**(一旦欠费,不允许发起新的 agent 调用)。
  247 +
  248 +> ⚠️ **欠费检查目前在代码里不存在**——全仓库搜过 `graph-engine.js`,唯一的门是 `checkBudget(run)`(`graph-engine.js:2334`),那是**任务级**的 agent-call/token 预算池(R-15 那套「只增不减的共池」),跟「账户是否欠费」是两个不同维度的概念,不能互相顶替。P4 范围内**不新建**账户级欠费系统;如果这道门确实要在 P4 上线前接通,需要先确认计费系统那边有没有现成的余额/欠费状态可查——这是一个**依赖,不是设计问题**,先问清楚再决定要不要塞进 T5。
  249 +>
  250 +> **恢复语义决策(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`)已经实现并测过,不需要为欠费再发明一套状态机。
  251 +>
  252 +> **但有一个真实缺口**:今天从 `blocked` 恢复靠人手点「启动」(`admitOrRequeue('blocked'→'running')`,注释原话「解除 blocked 的正当入口是...点一下启动」)。欠费清零不会有人去点——这正是「7×24 无人值守缺看门狗」那个更大缺口的一个具体实例。**P4 范围内需要补的是一个自动恢复触发器**:欠费状态清除时,批量对所有 `run_status='blocked' AND block_reason='billing-arrears'` 的 root run 调 `startRun`。触发源是计费系统的事件/轮询,不是图引擎自己能感知的——这也是为什么欠费检查本身要先问清楚计费系统那边的依赖(上一段已经点出)。
  253 +>
  254 +> 在那道门接通之前,`advanceBatch` 相当于「无限额度」,唯一还在起作用的数字上限是 T1 已经落地的 `SessionPool.maxSessions`(防止单机字面意义上被并发的 Claude CLI 子进程压垮,是内存/CPU 层面的保命阀,跟欠费门是两回事,**不因为这条决策而放松**)。
  255 +
  256 +对齐 §三.5 的既有数字,不另立一套(**下表现改为参考基线,非本阶段要实现的硬约束**):
196 257  
197 258 | 层级 | 上限 | 落在哪 |
198 259 |---|---|---|
... ... @@ -200,7 +261,7 @@ const scope = `gr:${nodeRunId}`;
200 261 | **单公司** | 默认 3 个 active root run | 替换 §三.3 那个「只允许 1 个」的唯一索引 |
201 262 | **单机** | ≤ 20 个 ACP session | `SessionPool` 加 `maxSessions`(`plan-cost-concurrency.md` §④ 已经提出要加这个配置,至今未加) |
202 263  
203   -**有效额度 = min(三层剩余)**。`advanceBatch` 拾取前先算这个值,为 0 就本轮不拾取(不是报错,等下一轮)。
  264 +~~**有效额度 = min(三层剩余)**。`advanceBatch` 拾取前先算这个值,为 0 就本轮不拾取(不是报错,等下一轮)。~~ **按 2026-08-21 决策作废**:不算三层 min,只剩单公司/单机两个数字本身也不设硬顶。`advanceBatch` 唯一还要看的额度就是本 run 的 `max_concurrency`(SELECT 的 `LIMIT`),不需要为它算「剩余」——因为再没有别的层可以让它变小。
204 265  
205 266 **任务级总预算不变**:P3 已实现「按 root_run_id 共池的 backward_count + 任务级预算挂 root run」,并行不改变预算语义——**并行只是让预算烧得更快**,这一点要在控制台的成本提示里体现。
206 267  
... ... @@ -502,13 +563,13 @@ R-19 之后这两件事有实体(`graph_edge_contracts` × `graph_files`),
502 563 |---|---|---|---|
503 564 | ~~**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` | 无 |
504 565 | **T2** | DevLoop 规格:§12 第一条不变量放宽为 `(pid, agent, scope)`。**改 HTML 后必须跑 `npm run spec:extract`** | `docs/prd/newfunction/*.html` | 无 |
505   -| **T3** | `graph_runs` 加 `max_concurrency` 列(**未做**);~~DROP `idx_graph_runs_one_active_root`,改为事务内计数检查~~ ✅ **后半已完成**(27-P3C 批 4:四步准入 `BEGIN IMMEDIATE` → 事务内重读 → 事务内计数 → 条件更新,8 进程实测零超发) | `server/services/work-graph-db.js` | 无 |
506   -| **T4** | `advanceBatch(runId, {maxConcurrency})`:事务内批量拾取并置 running;`advanceOne` 降级为薄封装 | `server/services/graph-engine.js` | T3 |
507   -| **T5** | 并发额度计算(三层取小)+ 额度为 0 时的等待语义 | `server/services/graph-engine.js` | T1, T3, T4 |
508   -| **T6** | 并行执行 + 错误降级(照搬 `orchestrator.executeWithDependencies` 的 `Promise.allSettled` 策略,单分支失败不终结整批) | `server/services/graph-engine.js` | T4 |
  566 +| ~~**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` | 无 |
  567 +| ~~**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 ✅ |
  568 +| ~~**T5**~~ | ~~并发额度计算(三层取小)+ 额度为 0 时的等待语义~~ **按 2026-08-21 决策大幅简化并重新定义**:不再有三层 min 计算,`advanceBatch` 只看本 run 的 `max_concurrency`。剩下两件事都**依赖计费系统接口确认**,暂不能开工:① 欠费检查怎么接(`checkBudget` 是任务级预算,跟账户欠费是两个概念,不能互相顶替);② 欠费清零后的**自动恢复触发器**——复用 `suspendRunTree`/`startRunSync` 现成的挂起/恢复状态机(`reason='billing-arrears'`),但今天从 `blocked` 恢复是人手点「启动」,无人值守场景需要一个订阅计费事件、批量调 `startRun` 的轮询/webhook,这是新代码,不是配置项 | `server/services/graph-engine.js` | T1, T3, T4,另需计费系统接口 |
  569 +| ~~**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 ✅ |
509 570 | ~~**T7**~~ ✅ | 产物索引:追加、查询、rebuild —— **已完成,形态换成 `graph_files` 表**(见 §3.6 校准块),不是 `files.json` | `server/services/work-graph-db.js` | — |
510 571 | ~~**T8**~~ ✅ | 产物按 run/node 分目录 + 节点产出后自动进索引 —— **已完成**(`runs/{rootRunId}/nodes/{nodeRunId}/`) | `server/services/graph-engine.js` | — |
511   -| **T9** | node agent 的 prompt 注入 `files.json` 检索说明 | `system/templates/base/agent.md` 等 | T7 |
  572 +| **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 |
512 573 | **T10** | 控制台:fan-in 等待态可视化(「在等哪几个节点」)+ 并行运行中的多节点高亮 | `web/src/views/GraphConsole.vue` | T6 |
513 574 | **T11** | 测试:批量拾取的并发幂等(两个 `advanceBatch` 不拾取重叠集合)、部分失败的 fan-in 停等、额度上限生效、`files.json` rebuild 一致性 | `server/tests/unit/` | T4-T8 |
514 575  
... ... @@ -517,7 +578,7 @@ R-19 之后这两件事有实体(`graph_edge_contracts` × `graph_files`),
517 578 | 阶段 | 含 | 交付 | 可否独立上线 |
518 579 |---|---|---|---|
519 580 | **A · 隔离** | ~~T1~~ ✅, T2 | 同一 agent 可安全并发调用 —— **T1 已完成**,只剩 T2(DevLoop 规格放宽) | 基本具备 |
520   -| **B · 并行** | T3-T6, T10 | fan-out 真正并发;解除单 root run 约束 | ✅ 依赖 A |
  581 +| **B · 并行** | ~~T3~~ ✅ ~~T4~~ ✅ ~~T6~~ ✅, T5(卡计费系统依赖), T10 | fan-out 真正并发;解除单 root run 约束 | ✅ 依赖 A |
521 582 | **C · 产物索引** | T7-T9 | `files.json` + 按 task 分目录 | ✅ **完全独立于 A/B**,可最先做 |
522 583  
523 584 **阶段 C 与 A/B 无依赖**,如果想先看到效果,可以先做 C(它是用户明确要求过的第 3 项)。
... ... @@ -528,6 +589,23 @@ R-19 之后这两件事有实体(`graph_edge_contracts` × `graph_files`),
528 589 > 这一段是「文档落后于代码」的又一例 —— 判断 P4 还剩多少工作量时,
529 590 > **先照着任务表逐条 grep 一遍代码**,别照文档估。
530 591  
  592 +> **2026-08-21 实现记录(T3/T4/T6)**:
  593 +> - `server/services/work-graph-db.js`:`graph_runs` 加 `max_concurrency`(可空列 + 存量库迁移),
  594 +> 新导出常量 `DEFAULT_MAX_CONCURRENCY=5`,`createRun` 新增 `maxConcurrency` 参数并校验/持久化。
  595 +> - `server/services/graph-engine.js`:把 `advanceOne` 拾取节点之后的整段(建 ctx → 执行 → `settleNode`)
  596 +> 原样抽成 `runClaimedNode(runId, run, def, defNode, nodeName, picked, opts)`,`advanceOne` 改成调它
  597 +> (逐字同行为,靠现有 40 个用例背书,跑绿)。新增 `advanceBatch(runId, opts)`:`maxConcurrency<=1`
  598 +> 委托 `advanceOne`;`>1` 时按 §4.1 决策逐行 `sse.transitionNode` 拾取(不包裹事务)、
  599 +> `Promise.allSettled` 并发跑 `runClaimedNode`。两者都从 `module.exports` 导出。
  600 +> - 新测试 `server/tests/unit/graph-engine-advance-batch.test.js`(5 条,diamond 图 entry→{a,b,c}→join):
  601 +> fan-out 真并行(不借助 setImmediate/sleep,靠「`advanceBatch` 在自己第一次 await 之前全程同步」
  602 +> 这条性质直接同步断言)、批量拾取跳过已被抢走的候选、部分失败时 join 保持 pending 且 run 收敛到
  603 +> `blocked`、`maxConcurrency<=1` 委托行为、`run.max_concurrency` 持久化生效。
  604 +> - 全量 `npx vitest run`:147 文件 / 1347 用例全绿,零回归。
  605 +> - **没做的**:`advanceBatch` 还没接进 `runAuto`/路由——自动模式今天仍然只调 `advanceOne`,
  606 +> 实际生产流量拿不到并行,这是接下来的线(大概率是 T10 之前、或与它同批)。T5(欠费门 + 自动恢复
  607 +> 触发器)仍卡在计费系统接口确认,T10(控制台可视化)未动。
  608 +
531 609 ---
532 610  
533 611 ## 六、验收标准
... ... @@ -565,9 +643,9 @@ R-19 之后这两件事有实体(`graph_edge_contracts` × `graph_files`),
565 643  
566 644 1. **并行让预算烧得更快,但预算语义不变**。任务级总预算是「只增不减的共池」(P3 已实现),并行不改变总量,只改变消耗速率。**控制台的成本提示需要相应更新**——用户点「自动」时看到的预估时间会大幅缩短,但花费不变。
567 645 2. **改 DevLoop 规格的连带风险**(T2)。§12 第一条不变量是 DevLoop 的,放宽它理论上不影响 DevLoop 自身(它的 `runProject` 波内本来就是串行的),但**必须跑一遍 DevLoop 全部测试**确认。
568   -3. **`maxSessions` 上限触顶时的行为未定**。额度为 0 时是「等待下一轮」还是「拒绝并报错」?本文档 §4.4 选了前者(不报错、等下一轮),但如果一个 run 长期抢不到额度会静默卡住——需要一个「等待超时」告警,否则表现为「不动了」。**留待实现时定阈值。**
569   -4. **`files.json` 的并发写入**。并行节点会同时追加索引,需要串行化(文件锁或走 SQLite)。本文档设计的是 JSON 文件形态(符合用户要求「一个 files.json 文件」),但**并发追加是真实风险**——可能需要退化为「引擎内单写者队列」。
570   -5. **`files.json` 与 `consumedBy` 的准确性**。`consumedBy` 是**声明式**的(规划时写「谁应该读它」),不是实测的。如果规划错了,索引会误导下游 agent。缓解:把它当提示而非约束,agent 仍可读任何 `files.json` 里的条目。
  646 +3. ~~**`maxSessions` 上限触顶时的行为未定**。额度为 0 时是「等待下一轮」还是「拒绝并报错」?本文档 §4.4 选了前者(不报错、等下一轮),但如果一个 run 长期抢不到额度会静默卡住——需要一个「等待超时」告警,否则表现为「不动了」。**留待实现时定阈值。**~~ **按 2026-08-21 决策已解决**:公司级/机器级不再设硬顶,「长期抢不到额度」这个场景基本不会出现了。账户欠费门定了——复用 `suspendRunTree` 的挂起语义(`reason='billing-arrears'`),跟预算耗尽是同一条路径,不发明第二种失败形态。**新缺口**:欠费清零后的恢复今天要人手点「启动」,无人值守场景下没人点——需要一个自动恢复触发器(欠费状态清除时批量对 blocked 的 root run 调 `startRun`),细节见 §4.4 决策块。欠费检查本身怎么接、恢复触发器订阅什么事件源,都要等计费系统那边的接口确认。
  647 +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 文件锁。
  648 +5. **产物索引的 `consumedBy`/契约声明的准确性**。`graph_edge_contracts` 的 `needs`/`produces` 是**声明式**的(规划时写「这条边应该传什么」),不是实测的。如果规划 agent 声明错了,`validateGraph` 的 `data-contract` 规则能拦住「声明要吃的 kind 全图没人产」这类静态错误,但拦不住「声明的 kind 与实际语义对不上」这种语义错误。缓解方式不变:索引/契约是给 agent 的强提示(引擎直接把解析出的路径递到提示词里),不是不可绕过的约束,agent 仍可读同一 task 下任何已登记的产物。
571 649  
572 650 ---
573 651  
... ...
docs/todo/system-design/30-task-delegation-reliability-gaps.md 0 → 100644
  1 +# 30-task-delegation-reliability-gaps. 任务委派可靠性现状 —— 已确认的鸿沟清单
  2 +
  3 +> **日期**: 2026-08-21
  4 +> **性质**: 现状调研记录,不是设计方案。列的是「代码现在长什么样、缺什么」,不是「该怎么补」——
  5 +> 要不要补、按什么优先级补,留给后面拍板。
  6 +
  7 +---
  8 +
  9 +## 起因
  10 +
  11 +用户问:「自动跑一个任务,能保证成功吗?」分别对 DevLoop(`runUnit`)和 Company/OKR(Agent 招聘 + 任务委派)
  12 +两条链路查了代码。结论是同一个:**全系统没有任何一处能"保证自动跑到底"**,统一是
  13 +「重试到预算/时间窗耗尽 → 停下等人工」的模式,区别只在于兜底的**粒度**和**可见性**。
  14 +
  15 +---
  16 +
  17 +## 一、DevLoop `runUnit`(Coder→Tester 循环)
  18 +
  19 +**文件**: `server/src/core/devloop/loop-controller.js`(`runUnit`,394-479 行)、
  20 +`server/services/devloop/unit-registry.js`(FSM)
  21 +
  22 +- `retry_budget` 默认 5 次,每次失败把上一轮 `coder.log` 摘要 + Tester 失败原因打包成
  23 + retry context 喂回 Coder,带着"上次为什么挂"重跑(reflection)。
  24 +- 5 次全挂 → `unitRegistry.block(...)`,状态机进 `blocked`(`unit-registry.js:36`)。
  25 + **没有自动退出路径**,`blocked → pending` 只能靠人工干预。
  26 +- **当前默认走 mock 模式**(`opts.mock !== false` 默认 true),走
  27 + `ClaudeCliProtocol({mock:true})` 回放 `fixtures/devloop-mock/*.json` 固定结果,
  28 + 不是真起 Claude CLI + 跑测试。所以"能不能保证成功"这个问题在默认配置下基本没有实际意义——
  29 + mock 场景下结果是脚本编排好的,真实 CLI 下的成功率目前**没有实证数据**。要测真实场景
  30 + 得显式传 `mock: false`。
  31 +- 阶段现状:DevLoop 目前在 P2(单 Unit 循环),跨 Unit 的 `runProject`(P3)代码已经存在
  32 + (同文件 556 行往下),但官方状态仍是"正朝 P3 推进",还未收口。
  33 +
  34 +---
  35 +
  36 +## 二、Company/OKR Agent 任务委派
  37 +
  38 +### 2.1 招聘装配的时间窗(`org-coder-has-no-shared-body` 记忆已同步更新)
  39 +
  40 +**文件**: `server/services/company-service.js`(430、500-511 行)
  41 +
  42 +- `hire()` 现在会现场校验一次角色定义是否就绪,用 `body_not_ready` 网关挡住别家公司在
  43 + 定义写完前重复招同一角色——比早前"静默退化成通用 Base Agent 且无报错"的状态好。
  44 +- 但 agent-creator 写 `agent.md`/`soul.md` 依旧是**异步触发 + 单会话锁**,校验和填充之间
  45 + 仍有时间窗,**没有完成回执/超时告警**。卡在这个窗口只能靠人工发现(比如逐个
  46 + `head -3 data/companies/<公司>/agents/<本体>/agent.md` 看是不是还是 Base Agent 空壳)。
  47 +
  48 +### 2.2 会话回收只有一层兜底
  49 +
  50 +**文件**: `server/routes/agent-chat.js`(818-828 行,`CALL_KINDS` 表)
  51 +
  52 +- `CALL_KINDS` 精确覆盖心跳/pipeline/日程/告警/日报五类,跑完即回收。
  53 +- 未标 `kind` 的调用退化成普通 `chat` 类型,靠 30 分钟空闲 TTL 兜底,**没有第二道防线**
  54 + (例如全局会话数硬顶)。漏标的后台调用不报错,只在内存曲线上体现——
  55 + 与 `docs/done/claude-cli-session-leak.md` 记录的那次"2h43m 漏出 82 个进程/17.5GB"是
  56 + 同一类风险,这次风险面在 Company/OKR 委派路径上同样存在,不只是 agent-chat 原生场景。
  57 + 守卫 `agent-chat-session-reclaim.test.js` 已覆盖 `CALL_KINDS` 标注本身,但不覆盖
  58 + "该标而没标"这类新增调用点的遗漏。
  59 +
  60 +### 2.3 Graph 执行失败无自动恢复,与 DevLoop 同构
  61 +
  62 +**文件**: `server/services/graph-engine.js`(`LOOP_HARD_CAP_EDGE` + fail 边路由)
  63 +
  64 +- 有兜底边就转兜底节点,没有就直接进 `blocked` 停等人工,不会自愈。
  65 +- 这是委派链路的通用终态,不是 Company 独有问题——和 DevLoop 的
  66 + `retry_budget` 耗尽 → `blocked` 是同一种设计选择(失败预算 + 人工兜底,
  67 + 不追求自动恢复),只是目前**分散在两套系统里各自实现,没有统一的"完成回执/超时告警"层**。
  68 +
  69 +---
  70 +
  71 +## 三、共性模式(值得记住,不代表已拍板要改)
  72 +
  73 +三条链路(DevLoop unit、Company 招聘装配、Graph 执行)共享同一个设计现状:
  74 +**有失败预算 + 人工兜底,没有"卡住了主动通知人"这一层**。
  75 +
  76 +失败之后系统会停在一个确定状态(`blocked` / `training` 卡住 / 会话挂着不回收),
  77 +但**不会主动喊人**——都是被动的,靠人去翻状态或者翻内存曲线才发现。
  78 +这是三处鸿沟共同的根因形状,不是三个独立问题。
  79 +
  80 +---
  81 +
  82 +## 参考
  83 +
  84 +- DevLoop 阶段现状:[27-graph-agent-architecture.md](./27-graph-agent-architecture.md)
  85 +- 会话泄漏排查手册:[claude-cli-session-leak.md](../../done/claude-cli-session-leak.md)
  86 +- `body_not_ready` 网关所在的招聘流程背景:[28-followups.md](./28-followups.md)
... ...
server/services/graph-engine.js
... ... @@ -56,6 +56,8 @@ const {
56 56 edgeContracts,
57 57 findFiles,
58 58 recordFile,
  59 + // 27-P4 T4/T5:advanceBatch 批量拾取宽度的缺省值
  60 + DEFAULT_MAX_CONCURRENCY,
59 61 } = require('./work-graph-db');
60 62  
61 63 /**
... ... @@ -193,6 +195,12 @@ const ENGINE_FAIL_REASONS = Object.freeze({
193 195 NODE_ERROR: 'node_error',
194 196 BOUNDARY_ESCAPE: 'boundary_escape',
195 197 CHILD_RUN_FAILED: 'child_run_failed',
  198 + // T-mt25ecbd:produces/needs 契约以前只进提示词(软提示),agent 没写文件 / 没读文件都
  199 + // 不影响节点判 success、不影响下游点亮——三份计划文档没落盘、goal-benchmark 照样过。
  200 + // 这两个新取值让契约变成真闸门:declared 的 kind 在 graph_files 里真的查不到就判 fail,
  201 + // 不发明取值域之外的字符串(否则会被 §四.D.2.2 的合法性检查当自创取值降级成 null)。
  202 + MISSING_INPUT: 'missing_required_input',
  203 + PRODUCES_MISSING: 'produces_missing',
196 204 });
197 205  
198 206 /** 归因/结构化产出文件名(agent.md「产出格式」:runs/{rootRunId}/nodes/{nodeRunId}/attempt-{n}.json) */
... ... @@ -1376,6 +1384,26 @@ async function advanceOne(runId, opts = {}) {
1376 1384 return { ...result(runId, run.run_status, 'taken-by-other'), node: nodeView(runId, cur) };
1377 1385 }
1378 1386  
  1387 + return runClaimedNode(runId, run, def, defNode, nodeName, picked, opts);
  1388 +}
  1389 +
  1390 +/**
  1391 + * 已经拾取(`ready → running` 条件更新已经成功)的节点,从建 ctx 到执行到结算的完整一段。
  1392 + * 27-P4 T6 从 `advanceOne` 里原样抽出来,**行为逐字不变**——只是把原来闭包捕获的局部变量
  1393 + * 变成参数,好让 `advanceBatch` 的并行路径也能复用同一段逻辑(§4.3 决策:并发下的 join
  1394 + * 正确性不依赖新增锁/事务,靠的正是这段收尾——`settleNode` 全程同步——的既有写法,
  1395 + * 所以这里一个字都不能改成异步中途掺一次 DB 之外的 await)。
  1396 + *
  1397 + * @param {string} runId
  1398 + * @param {object} run getRun(runId) 的快照(调用方已经读过一次,不在这里重读)
  1399 + * @param {object} def defIndex(run)(调用方已经算过一次,批量拾取时给多个节点共用)
  1400 + * @param {object} defNode def.nodes.get(picked.def_node_id) || {}
  1401 + * @param {string} nodeName
  1402 + * @param {object} picked graph_run_nodes 行(此刻已经是 running)
  1403 + * @param {object} [opts]
  1404 + * @returns {Promise<AdvanceResult>}
  1405 + */
  1406 +async function runClaimedNode(runId, run, def, defNode, nodeName, picked, opts = {}) {
1379 1407 const ctx = {
1380 1408 runId,
1381 1409 run,
... ... @@ -1506,15 +1534,174 @@ async function advanceOne(runId, opts = {}) {
1506 1534 }
1507 1535  
1508 1536 /**
  1537 + * 批量推进(27-P4 T4/T6):一次拾取至多 `maxConcurrency` 个 ready 节点,并发执行。
  1538 + *
  1539 + * `maxConcurrency <= 1` 时**直接委托给 `advanceOne`**,不走下面的批量路径——零行为差异,
  1540 + * `advanceOne` 现有的调用方/测试不用改一个字(见 27-P4 §4.1 决策:不是「advanceOne 包 advanceBatch」,
  1541 + * 是反过来,这样 advanceOne 的行为永远只由它自己的测试背书,不会被批量路径的改动间接影响)。
  1542 + *
  1543 + * `maxConcurrency > 1` 时:
  1544 + * 1. 先做一遍与 `advanceOne` 完全相同的 run 级检查(stop/pause/running/预算)——
  1545 + * 这段刻意重复一份而不是抽共享函数,因为 `advanceOne` 是被大量测试钉住的稳定代码,
  1546 + * 抽共享会让这次改动的 diff 面盖到它头上,得不偿失。
  1547 + * 2. `SELECT ... LIMIT maxConcurrency` 取候选,逐个走 `sse.transitionNode('ready'→'running')`
  1548 + * —— 单行条件更新本身就是并发闸门,不需要包裹事务(§4.1 决策的完整论证)。
  1549 + * 3. 拾到的节点各自 `runClaimedNode`,`Promise.allSettled` 并发执行——一个节点的
  1550 + * executor 抛出去不会波及同批其它节点(`runClaimedNode` 内部已经把 agent 调用异常
  1551 + * 转成 fail outcome,这里的 catch 是防它自己有 bug 的最后一道网)。
  1552 + *
  1553 + * @param {string} runId
  1554 + * @param {object} [opts]
  1555 + * @param {number} [opts.maxConcurrency] 不传则读 `run.max_concurrency`,再不然用 DEFAULT_MAX_CONCURRENCY
  1556 + * @param {boolean} [opts.ignorePause]
  1557 + * @param {string} [opts.task]
  1558 + * @returns {Promise<{ok: boolean, runId: string, runStatus: string|null, reason: string|null,
  1559 + * results: AdvanceResult[], activated: Array}>}
  1560 + */
  1561 +async function advanceBatch(runId, opts = {}) {
  1562 + const run0 = getRun(runId);
  1563 + const maxConcurrency = opts.maxConcurrency
  1564 + || (run0 && run0.max_concurrency)
  1565 + || DEFAULT_MAX_CONCURRENCY;
  1566 +
  1567 + if (maxConcurrency <= 1) {
  1568 + const res = await advanceOne(runId, opts);
  1569 + return { ...res, results: res.node ? [res] : [] };
  1570 + }
  1571 +
  1572 + const run = run0;
  1573 + if (!run) return { ok: false, runId, runStatus: null, reason: 'run-not-found', results: [], activated: [] };
  1574 + if (run.stop_requested) {
  1575 + return { ok: false, runId, runStatus: run.run_status, reason: 'stop-requested', results: [], activated: [] };
  1576 + }
  1577 + if (run.pause_requested && !opts.ignorePause) {
  1578 + landPause(runId);
  1579 + return {
  1580 + ok: false, runId, runStatus: (getRun(runId) || {}).run_status, reason: 'pause-requested',
  1581 + results: [], activated: [],
  1582 + };
  1583 + }
  1584 + if (run.run_status !== 'running') {
  1585 + return { ok: false, runId, runStatus: run.run_status, reason: 'run-not-running', results: [], activated: [] };
  1586 + }
  1587 +
  1588 + const gate = checkBudget(run);
  1589 + if (!gate.ok) {
  1590 + suspendRunTree(run);
  1591 + return {
  1592 + ok: false, runId, runStatus: (getRun(runId) || {}).run_status, reason: 'budget-exhausted',
  1593 + results: [], activated: [], budget: gate,
  1594 + };
  1595 + }
  1596 +
  1597 + const def = defIndex(run);
  1598 + const candidates = getDb()
  1599 + .prepare(`SELECT * FROM graph_run_nodes WHERE run_id = ? AND status = 'ready' ORDER BY created_at, id LIMIT ?`)
  1600 + .all(runId, maxConcurrency);
  1601 + if (!candidates.length) {
  1602 + return { ok: true, runId, runStatus: settleRun(runId), reason: 'no-ready-node', results: [], activated: [] };
  1603 + }
  1604 +
  1605 + const claimed = [];
  1606 + for (const picked of candidates) {
  1607 + const defNode = def.nodes.get(picked.def_node_id) || {};
  1608 + const nodeName = defNode.name || picked.def_node_id;
  1609 + // 逐行条件更新——两个并发 advanceBatch 即使 SELECT 到同一批候选,这里也只会有
  1610 + // 一边拿到 changes=1(§4.1 决策),拿不到的直接跳过,不算错误。
  1611 + if (
  1612 + sse.transitionNode(runId, picked.id, 'ready', 'running', { startedAt: now() }, {
  1613 + node_name: nodeName, node_kind: defNode.nodeKind || 'agent_call', attempt: picked.attempt,
  1614 + })
  1615 + ) {
  1616 + claimed.push({ picked, defNode, nodeName });
  1617 + }
  1618 + }
  1619 + if (!claimed.length) {
  1620 + // 候选全被别的 advanceBatch/advanceOne 抢走:不是错误,下一轮再拾取
  1621 + return { ok: true, runId, runStatus: run.run_status, reason: 'taken-by-other', results: [], activated: [] };
  1622 + }
  1623 +
  1624 + const settled = await Promise.allSettled(
  1625 + claimed.map(({ picked, defNode, nodeName }) => runClaimedNode(runId, run, def, defNode, nodeName, picked, opts))
  1626 + );
  1627 +
  1628 + const results = settled.map((s, i) => {
  1629 + if (s.status === 'fulfilled') return s.value;
  1630 + // 防的是 runClaimedNode 自己的 bug(它内部的 try/catch 已经把 agent 调用异常转成
  1631 + // fail outcome,正常情况下不会走到这里)。节点此刻卡在 running——不代它结算,
  1632 + // 留给人工「重试」或下一次真跑复现;这里只保证同批其它节点不受影响。
  1633 + const { picked } = claimed[i];
  1634 + return {
  1635 + ...result(runId, run.run_status, 'node-executor-rejected'),
  1636 + node: nodeView(runId, getRunNode(picked.id)),
  1637 + error: s.reason && s.reason.message,
  1638 + };
  1639 + });
  1640 +
  1641 + return {
  1642 + ok: true,
  1643 + runId,
  1644 + runStatus: settleRun(runId),
  1645 + reason: null,
  1646 + results,
  1647 + activated: results.flatMap((r) => r.activated || []),
  1648 + };
  1649 +}
  1650 +
  1651 +/**
1509 1652 * 节点执行结束后的落地:写终态 → 评估出边 → 收敛判定。
1510 1653 * 也是子 run 收敛时回写父节点走的同一条路(propagateToParent)。
1511 1654 *
1512 1655 * @param {object} outcome {status, failReason?, remediationNote?, outputRef?, agentRunId?, childRunId?}
1513 1656 */
1514 1657 function settleNode(runId, nodeRunId, outcome = {}) {
1515   - const to = outcome.status === 'success' ? 'success' : 'fail';
  1658 + let to = outcome.status === 'success' ? 'success' : 'fail';
  1659 + let failReason = outcome.failReason === undefined ? null : outcome.failReason;
  1660 +
  1661 + // T-mt25ecbd:executor 报 success 不等于契约兑现了 —— agent 可能压根没写声明过的文件
  1662 + // (brd/prd/sdd 三个 planner 节点复现过)。产出闸门放在这里、卡在 transitionNode 落库
  1663 + // 之前,这样一旦判 fail 就走下面 to==='fail' 的 routeFailure 分支,不会先点亮下游
  1664 + // 再回头发现产物不存在。
  1665 + if (to === 'success') {
  1666 + const run0 = getRun(runId);
  1667 + const nodeRun0 = getRunNode(nodeRunId);
  1668 + const def0 = run0 ? defIndex(run0) : null;
  1669 + // subgraph 节点(org 层)没有自己的产物目录——它的「产出」是子 work run 里的节点登记的
  1670 + // (org-layer-contracts.test.js ⑤ 的口径:output_ref 恒为 null)。这两道闸门只对
  1671 + // agent_call 节点生效,subgraph 节点的契约兑现与否由子 run 自己的节点去过这两道闸门。
  1672 + const isSubgraphNode = !!(def0 && def0.nodes.get(nodeRun0 && nodeRun0.def_node_id)
  1673 + && def0.nodes.get(nodeRun0.def_node_id).nodeKind === 'subgraph');
  1674 + if (run0 && nodeRun0 && def0 && !isSubgraphNode) {
  1675 + const missingProduces = unsatisfiedRequiredProduces(run0, def0, nodeRun0);
  1676 + if (missingProduces.length) {
  1677 + to = 'fail';
  1678 + failReason = ENGINE_FAIL_REASONS.PRODUCES_MISSING;
  1679 + timelineNote({ runId, nodeRunId }, 'node.output',
  1680 + `节点声明会产出 ${missingProduces.join(', ')},但产物目录里没找到对应文件,按 fail 结算`,
  1681 + { node_run_id: nodeRunId, missing_kinds: missingProduces, degraded: 'produces_missing' });
  1682 + } else {
  1683 + // T-mt25ecbd 症状②:goal-benchmark 的入边 needs:[brd,prd,sdd] 以前只用来拼
  1684 + // describeInputs 那句 ⚠️ 提示词——agent 完全可能对它视而不见、照样判自己成功
  1685 + // (复现原样:三份文档一份没写出来,benchmark 仍产出合法 acceptance)。
  1686 + // 这里在真正判 success 之前把同一份 needs 契约当真:声明了 required 却在
  1687 + // graph_files 里连一份实例都查不到,就不认这次 success,按 fail 结算。
  1688 + const missingInputs = requiredInputsMissing(
  1689 + { run: run0, companyId: run0.company_id, defNodeId: nodeRun0.def_node_id, nodeKind: 'agent_call' },
  1690 + def0
  1691 + );
  1692 + if (missingInputs.length) {
  1693 + to = 'fail';
  1694 + failReason = ENGINE_FAIL_REASONS.MISSING_INPUT;
  1695 + timelineNote({ runId, nodeRunId }, 'node.output',
  1696 + `节点声明必需的上游产物 ${missingInputs.join(', ')} 在索引里查不到实例,按 fail 结算`,
  1697 + { node_run_id: nodeRunId, missing_kinds: missingInputs, degraded: 'missing_required_input' });
  1698 + }
  1699 + }
  1700 + }
  1701 + }
  1702 +
1516 1703 const changed = sse.transitionNode(runId, nodeRunId, 'running', to, {
1517   - failReason: outcome.failReason === undefined ? null : outcome.failReason,
  1704 + failReason,
1518 1705 // 跨层地址与 fail_reason 同一次条件更新落库(R-17.1)。恒写显式值:success 或本轮没归因
1519 1706 // 就写 null,不给「读到上一轮地址」留窗口。normalize 让执行器可以直接给对象或 JSON 串。
1520 1707 failNodeRef: normalizeNodeRef(outcome.failNodeRef),
... ... @@ -3456,6 +3643,56 @@ const { companyRootDir, taskDirRel } = require(&#39;./company-paths&#39;);
3456 3643 * kind 怎么定:先看这个节点的**出边**上声明了什么(规划时就写下了「我会产一份 brd」),
3457 3644 * 再按文件名兜底。声明优先,因为那是图作者的意图;文件名只是巧合。
3458 3645 */
  3646 +/**
  3647 + * T-mt25ecbd 产出闸门:这个节点的出边声明了**必需**(required)produces kind,
  3648 + * 而它的产物目录里**连一个候选文件都没有**——判「压根没执行到位」,按 fail 结算。
  3649 + *
  3650 + * 刻意只判「空目录」,不判「declared kind 认领不上具体哪个文件」:后者是
  3651 + * indexNodeOutputs 的分类问题(ambiguous 时退成 kind=artifact,下游拿 ⚠️ 提示词),
  3652 + * 分类不准不代表节点没干活——把它也当失败拦下来会误杀`⑦ 有歧义时不猜`那类合法场景
  3653 + * (agent 确实写了东西,只是文件名让引擎猜不出哪份对应哪个 kind)。
  3654 + * 本轮要拦的是 brd/prd/sdd 三个节点复现的那种更粗的情形:声明了要产出、
  3655 + * 目录里什么候选文件都没有——那不是分类模糊,是根本没写。
  3656 + *
  3657 + * 目录读不了/没声明时一律放行(同 indexNodeOutputs 的兜底:不该让基础设施问题
  3658 + * 或没声明契约的节点被这道新闸门误杀)。
  3659 + *
  3660 + * @returns {string[]} 一个都没写出来时返回声明的 kind 列表;有任何候选文件 = 放行([])
  3661 + */
  3662 +function unsatisfiedRequiredProduces(run, def, nodeRun) {
  3663 + if (!run || !nodeRun) return [];
  3664 + let declared = [];
  3665 + try {
  3666 + const layer = run.graph_kind === 'org' ? 'org' : 'work';
  3667 + const outbound = ((def && def.edges) || []).filter(
  3668 + (e) => String(e.from) === String(nodeRun.def_node_id) && (e.edgeKind || 'forward') !== 'backward'
  3669 + );
  3670 + for (const e of outbound) {
  3671 + for (const c of edgeContracts(layer, e.id)) {
  3672 + if ((c.role || 'produces') === 'produces' && c.required) declared.push(c.kind);
  3673 + }
  3674 + }
  3675 + declared = [...new Set(declared)];
  3676 + } catch {
  3677 + return []; // 契约查不到不该拖垮结算(同 indexNodeOutputs 的兜底)
  3678 + }
  3679 + if (!declared.length) return [];
  3680 +
  3681 + let dir;
  3682 + let files = [];
  3683 + try {
  3684 + dir = nodeArtifactDir(run, nodeRun.id);
  3685 + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return declared;
  3686 + files = fs.readdirSync(dir).filter((f) => !f.startsWith('.'));
  3687 + } catch {
  3688 + return []; // 目录读不了不该拖垮结算
  3689 + }
  3690 + const candidates = files.filter(
  3691 + (f) => !/^attempt-\d+\.json$/.test(f) && f !== CONTRACT_SNAPSHOT_FILE
  3692 + );
  3693 + return candidates.length ? [] : declared;
  3694 +}
  3695 +
3459 3696 function indexNodeOutputs(run, def, nodeRun) {
3460 3697 if (!run || !nodeRun || !nodeRun.output_ref) return;
3461 3698 let dir = nodeRun.output_ref;
... ... @@ -3554,6 +3791,39 @@ function indexNodeOutputs(run, def, nodeRun) {
3554 3791 * 走同一段代码:三种口径(查得到给绝对路径 / required 查不到给 ⚠️ /
3555 3792 * 可选查不到整行省略)就不可能在两处漂移 —— 复制一遍再各自演化正是本轮在防的那类断链。
3556 3793 */
  3794 +/**
  3795 + * T-mt25ecbd 输入闸门:入边声明的**必需**(required)kind,有几个在产物索引里真的
  3796 + * 一份实例都查不到——与 describeInputs 给 agent 看的 ⚠️ 提示同一份数据源
  3797 + * (inboundContracts + findFiles),区别是这里把它变成执行前的硬拦截,不再只是
  3798 + * 「提示词里有一行警告,agent 爱信不信」。
  3799 + *
  3800 + * 只看 required 且缺实例的:可选(required:false)输入本来就允许没有,不该拦。
  3801 + * 索引读不出来(层查询异常)时放行——同 describeInputs 的兜底,基础设施问题不该
  3802 + * 误伤节点执行。
  3803 + *
  3804 + * @returns {string[]} 缺实例的必需 kind 列表;空数组 = 放行
  3805 + */
  3806 +function requiredInputsMissing(ctx, def) {
  3807 + if (!ctx || !ctx.run || ctx.nodeKind === 'subgraph') return [];
  3808 + let contracts = [];
  3809 + try {
  3810 + const layer = ctx.run.graph_kind === 'org' ? 'org' : 'work';
  3811 + contracts = inboundContracts(layer, (def && def.edges) || [], ctx.defNodeId);
  3812 + } catch {
  3813 + return [];
  3814 + }
  3815 + const requiredKinds = [...new Set(contracts.filter((c) => c.required).map((c) => c.kind))];
  3816 + if (!requiredKinds.length) return [];
  3817 + let found;
  3818 + try {
  3819 + found = findFiles({ companyId: ctx.companyId, taskId: ctx.run.task_id, kinds: requiredKinds });
  3820 + } catch {
  3821 + return [];
  3822 + }
  3823 + const haveKinds = new Set(found.map((f) => f.kind));
  3824 + return requiredKinds.filter((k) => !haveKinds.has(k));
  3825 +}
  3826 +
3557 3827 function resolveInboundLines(ctx, contracts) {
3558 3828 const declared = [...new Set(contracts.map((c) => c.kind))];
3559 3829 if (!declared.length) return [];
... ... @@ -3902,6 +4172,8 @@ function latestRemediationNote(nodeRunId) {
3902 4172 module.exports = {
3903 4173 // 核心原语
3904 4174 advanceOne,
  4175 + advanceBatch,
  4176 + runClaimedNode,
3905 4177 advanceTree,
3906 4178 runAuto,
3907 4179 settleNode,
... ...
server/services/work-graph-db.js
... ... @@ -54,6 +54,14 @@ const ACTIVE_ROOT_RUN_STATUS = [&#39;ready&#39;, &#39;running&#39;, &#39;paused&#39;];
54 54 const DEFAULT_MAX_ACTIVE_ROOT_RUNS = 1;
55 55  
56 56 /**
  57 + * 单个 run 内 `advanceBatch` 一次最多拾取几个 ready 节点 —— 缺省值(27-P4 §4.4)。
  58 + * 对齐 `plan-cost-concurrency.md` §④「项目内 L1 并行 ≤5」,不是随手挑的数字。
  59 + * `graph_runs.max_concurrency` 为 NULL 时用它;不做「三层 min」计算(2026-08-21 决策,
  60 + * 27-P4 §4.4)——这一层只是批量拾取的宽度,不是配额审批。
  61 + */
  62 +const DEFAULT_MAX_CONCURRENCY = 5;
  63 +
  64 +/**
57 65 * 硬上限。配置里写个 100000 不该被当真 —— 每个坑对应一条真在跑的 root run(一个 Claude CLI
58 66 * 子进程量级的开销),机器级闸门(SessionPool.maxSessions)至今不存在(登记在 28-followups)。
59 67 * 超过就按上限截断并打日志,不静默当成无限。
... ... @@ -314,6 +322,10 @@ function getDb() {
314 322 stop_requested INTEGER NOT NULL DEFAULT 0,
315 323 backward_count INTEGER NOT NULL DEFAULT 0,
316 324 budget_json TEXT,
  325 + -- 27-P4 T3:本 run 一次 advanceBatch 最多拾取几个 ready 节点。NULL = 用
  326 + -- DEFAULT_MAX_CONCURRENCY(引擎侧解析,不在 DB 层给默认值,理由同其它可空列——
  327 + -- 应用层的默认值改了不用迁移存量行)。
  328 + max_concurrency INTEGER,
317 329 created_at TEXT NOT NULL,
318 330 updated_at TEXT NOT NULL
319 331 );
... ... @@ -633,6 +645,10 @@ function getDb() {
633 645 // `resumePendingGoalValidations` 会把库里**所有**历史终态 goal 都看成「汇报丢了」,
634 646 // 于是把它们统统重报一遍 —— OKR 节点被历史结论覆盖、上级 agent 被几百条陈年旧事叫醒,
635 647 // 每一条都真起一轮 LLM。它们在旧代码下早就报过了(或永远不该报),一律按「已报」封口。
  648 + // 27-P4 T3:graph_runs 追加 max_concurrency,可空,旧行 NULL = 按 DEFAULT_MAX_CONCURRENCY 处理。
  649 + if (!hasColumn(db, 'graph_runs', 'max_concurrency')) {
  650 + db.exec(`ALTER TABLE graph_runs ADD COLUMN max_concurrency INTEGER`);
  651 + }
636 652 if (!hasColumn(db, 'goals', 'reported_at')) {
637 653 db.exec(`ALTER TABLE goals ADD COLUMN reported_at TEXT`);
638 654 db.exec(`
... ... @@ -1055,10 +1071,14 @@ function createRun({
1055 1071 executionMode = 'manual',
1056 1072 runStatus = 'planning',
1057 1073 budget = null,
  1074 + maxConcurrency = null,
1058 1075 } = {}) {
1059 1076 if (graphKind !== 'org' && graphKind !== 'work') {
1060 1077 throw new Error(`graphKind 必须是 'org' 或 'work',收到: ${graphKind}`);
1061 1078 }
  1079 + if (maxConcurrency !== null && (!Number.isInteger(maxConcurrency) || maxConcurrency < 1)) {
  1080 + throw new Error(`maxConcurrency 必须是 ≥1 的整数或 null,收到: ${JSON.stringify(maxConcurrency)}`);
  1081 + }
1062 1082  
1063 1083 const db = getDb();
1064 1084 const now = new Date().toISOString();
... ... @@ -1076,13 +1096,15 @@ function createRun({
1076 1096 db.prepare(`
1077 1097 INSERT INTO graph_runs (
1078 1098 id, graph_kind, graph_id, company_id, root_run_id, parent_run_id, parent_node_run_id,
1079   - goal_id, task_id, okr_node_id, execution_mode, run_status, budget_json, created_at, updated_at
1080   - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  1099 + goal_id, task_id, okr_node_id, execution_mode, run_status, budget_json, max_concurrency,
  1100 + created_at, updated_at
  1101 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1081 1102 `).run(
1082 1103 runId, graphKind, graphId, companyId, rootRunId, parentRunId, parentNodeRunId,
1083 1104 goalId, taskId, okrNodeId, executionMode, runStatus,
1084 1105 // budget 只挂 root run:子 run 通过 root_run_id 扣同一个池
1085 1106 parentRunId ? null : budget && JSON.stringify(budget),
  1107 + maxConcurrency,
1086 1108 now, now
1087 1109 );
1088 1110  
... ... @@ -2565,6 +2587,8 @@ module.exports = {
2565 2587 maxActiveRootRuns,
2566 2588 DEFAULT_MAX_ACTIVE_ROOT_RUNS,
2567 2589 MAX_ACTIVE_ROOT_RUNS_CAP,
  2590 + // 27-P4 T3:单 run 批量拾取宽度的缺省值,`run.max_concurrency` 为 NULL 时用它
  2591 + DEFAULT_MAX_CONCURRENCY,
2568 2592 canTransitionNode,
2569 2593 canTransitionRun,
2570 2594 InvalidTransitionError,
... ...
server/tests/unit/graph-engine-advance-batch.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — advanceBatch(27-P4 T4/T6)
  3 + *
  4 + * 覆盖 27-P4 §六 验收标准:
  5 + * 2. 批量拾取幂等:候选里已经被别人抢先拾取的节点被跳过,不重复认领、不报错
  6 + * 3. fan-out 真并行:一张有 3 个并行分支的图,同一批 advanceBatch 里三个节点同时 running
  7 + * 4. 部分失败停等:3 个并行分支 1 个失败(无 fail 边 → blocked)时,fan-in 节点保持 pending,
  8 + * run 收敛到 blocked(不是 completed)
  9 + * 5. 额度生效:`max_concurrency` 建 run 时持久化,`advanceBatch` 不传 opts 时读它
  10 + * 7. 手动模式不变:`maxConcurrency<=1` 直接委托 `advanceOne`,一次只推进一个节点
  11 + *
  12 + * 「fan-out 真并行」这条不需要 setImmediate/sleep 等计时手段就能确定性断言:
  13 + * `advanceBatch` 在 `Promise.allSettled(...)` 之前的整段(run 级检查 → checkBudget →
  14 + * SELECT 候选 → 逐行 claim)全程同步,`.map()` 调 `runClaimedNode` 时,每个节点的
  15 + * ctx 构建/记账/事件都在各自 hit 到 `await agentExecutor(ctx)` 之前同步跑完——
  16 + * 所以调用 `advanceBatch(...)`(还没 await 它的返回值)这一行语句执行完,三个节点
  17 + * 已经全部处于 running、执行器也都已经被调用过一次。这正是 27-P4 §4.3 那条并发正确性
  18 + * 论证(Node 单线程事件循环)的直接体现,写成测试顺带验证了那条论证站得住。
  19 + *
  20 + * 隔离手法与 graph-engine.test.js 一致:独立 GRAPH_DB_PATH / GRAPH_RUNS_DIR,
  21 + * agent 调用全部走注入的假 executor,不起真实 Claude CLI。
  22 + */
  23 +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
  24 +import fs from 'fs';
  25 +import os from 'os';
  26 +import path from 'path';
  27 +
  28 +describe('advanceBatch(27-P4 T4/T6)', () => {
  29 + let wdb;
  30 + let engine;
  31 + let sse;
  32 + let runsDir;
  33 + const TEST_DB_PATH = '/tmp/test-graph-engine-advance-batch.sqlite';
  34 +
  35 + beforeAll(() => {
  36 + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
  37 + try { fs.unlinkSync(f); } catch {}
  38 + }
  39 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  40 + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-advance-batch-runs-'));
  41 + process.env.GRAPH_RUNS_DIR = runsDir;
  42 + for (const m of [
  43 + '../../services/graph-db',
  44 + '../../services/graph-service',
  45 + '../../services/org-graph-service',
  46 + '../../services/work-graph-db',
  47 + '../../services/graph-run-sse',
  48 + '../../services/graph-engine',
  49 + ]) {
  50 + delete require.cache[require.resolve(m)];
  51 + }
  52 + wdb = require('../../services/work-graph-db');
  53 + sse = require('../../services/graph-run-sse');
  54 + engine = require('../../services/graph-engine');
  55 + });
  56 +
  57 + afterAll(() => {
  58 + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
  59 + try { fs.unlinkSync(f); } catch {}
  60 + }
  61 + delete process.env.GRAPH_DB_PATH;
  62 + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
  63 + delete process.env.GRAPH_RUNS_DIR;
  64 + });
  65 +
  66 + beforeEach(() => {
  67 + const db = wdb.getDb();
  68 + db.pragma('foreign_keys = OFF');
  69 + db.exec(`
  70 + DELETE FROM graph_run_loop_records;
  71 + DELETE FROM graph_run_nodes;
  72 + DELETE FROM graph_runs;
  73 + DELETE FROM work_graph_edges;
  74 + DELETE FROM work_graph_nodes;
  75 + DELETE FROM work_graphs;
  76 + DELETE FROM system_agents;
  77 + `);
  78 + db.pragma('foreign_keys = ON');
  79 + engine.setAgentExecutor(null);
  80 + });
  81 +
  82 + let seq = 0;
  83 + const nextCompany = () => `p4b-co-${++seq}`;
  84 +
  85 + function ensureAgents(ids) {
  86 + const db = wdb.getDb();
  87 + const ts = new Date().toISOString();
  88 + const ins = db.prepare(`
  89 + INSERT OR IGNORE INTO system_agents
  90 + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
  91 + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)
  92 + `);
  93 + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, ts, ts);
  94 + }
  95 +
  96 + /** entry → a,b,c(fan-out)→ join(fan-in),全部用 condition=success 的默认边 */
  97 + function buildDiamond({ maxConcurrency = null } = {}) {
  98 + const companyId = nextCompany();
  99 + const nodes = ['entry', 'a', 'b', 'c', 'join'];
  100 + ensureAgents(nodes);
  101 + const g = wdb.createWorkGraph({
  102 + companyId,
  103 + nodes,
  104 + edges: [
  105 + { from: 'entry', to: 'a' },
  106 + { from: 'entry', to: 'b' },
  107 + { from: 'entry', to: 'c' },
  108 + { from: 'a', to: 'join' },
  109 + { from: 'b', to: 'join' },
  110 + { from: 'c', to: 'join' },
  111 + ],
  112 + });
  113 + expect(g.errors || []).toEqual([]);
  114 + const run = wdb.createRun({
  115 + graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual', maxConcurrency,
  116 + });
  117 + engine.markRunReady(run.id);
  118 + return { runId: run.id, companyId };
  119 + }
  120 +
  121 + const nodeByName = (runId, name) =>
  122 + wdb.getRunState(runId).nodes.find((n) => n.name === name);
  123 + const statusMap = (runId) =>
  124 + Object.fromEntries(wdb.getRunState(runId).nodes.map((n) => [n.name, n.status]));
  125 +
  126 + const deferred = () => {
  127 + let resolve;
  128 + const promise = new Promise((r) => { resolve = r; });
  129 + return { promise, resolve };
  130 + };
  131 +
  132 + it('fan-out 真并行(验收 3):同一批里 a/b/c 同时处于 running,而不是依次', async () => {
  133 + const { runId } = buildDiamond();
  134 + const gates = { a: deferred(), b: deferred(), c: deferred() };
  135 + const calls = [];
  136 + engine.setAgentExecutor(async (ctx) => {
  137 + calls.push(ctx.nodeName);
  138 + if (gates[ctx.nodeName]) await gates[ctx.nodeName].promise;
  139 + return { status: 'success' };
  140 + });
  141 +
  142 + engine.startRun(runId);
  143 + await engine.advanceOne(runId); // entry → success,点亮 a/b/c
  144 + calls.length = 0; // 只看这一批 advanceBatch 触发的调用,entry 那次不算
  145 +
  146 + // 刻意不 await:advanceBatch 在自己第一次真正挂起(Promise.allSettled)之前全程同步,
  147 + // 所以这一行语句本身执行完,三个节点的 claim + executor 调用就都已经发生了。
  148 + const batchPromise = engine.advanceBatch(runId, { maxConcurrency: 3 });
  149 +
  150 + expect(calls.sort()).toEqual(['a', 'b', 'c']);
  151 + expect(statusMap(runId)).toMatchObject({ a: 'running', b: 'running', c: 'running' });
  152 +
  153 + gates.a.resolve();
  154 + gates.b.resolve();
  155 + gates.c.resolve();
  156 + const res = await batchPromise;
  157 +
  158 + expect(res.ok).toBe(true);
  159 + expect(res.results).toHaveLength(3);
  160 + expect(statusMap(runId)).toMatchObject({
  161 + a: 'success', b: 'success', c: 'success', join: 'ready',
  162 + });
  163 + });
  164 +
  165 + it('批量拾取跳过已被抢走的候选(验收 2):不重复认领、不报错', async () => {
  166 + const { runId } = buildDiamond();
  167 + engine.setAgentExecutor(async () => ({ status: 'success' }));
  168 + engine.startRun(runId);
  169 + await engine.advanceOne(runId); // entry → success
  170 +
  171 + // 模拟「b 已经被别的调用抢先拾取」:直接做条件更新,绕开 advanceBatch
  172 + const b = nodeByName(runId, 'b');
  173 + expect(sse.transitionNode(runId, b.id, 'ready', 'running', {}, { node_name: 'b' })).toBeTruthy();
  174 +
  175 + const res = await engine.advanceBatch(runId, { maxConcurrency: 3 });
  176 + // 只认领了 a、c 两个,b 被跳过——既没有重复认领,也没有报错
  177 + expect(res.ok).toBe(true);
  178 + expect(res.results.map((r) => r.node.name).sort()).toEqual(['a', 'c']);
  179 + // b 停在「别人」占着的 running,advanceBatch 完全没碰它(没有被结算成任何终态)
  180 + expect(statusMap(runId).b).toBe('running');
  181 + });
  182 +
  183 + it('部分失败停等(验收 4):c 失败(无 fail 边 → blocked)时 join 保持 pending,run 收敛到 blocked', async () => {
  184 + const { runId } = buildDiamond();
  185 + engine.setAgentExecutor(async (ctx) => (
  186 + ctx.nodeName === 'c' ? { status: 'fail' } : { status: 'success' }
  187 + ));
  188 + engine.startRun(runId);
  189 + await engine.advanceOne(runId); // entry → success
  190 +
  191 + const res = await engine.advanceBatch(runId, { maxConcurrency: 3 });
  192 + expect(res.ok).toBe(true);
  193 + expect(statusMap(runId)).toMatchObject({
  194 + a: 'success', b: 'success', c: 'blocked', join: 'pending',
  195 + });
  196 + expect(res.runStatus).toBe('blocked');
  197 + expect(wdb.getRun(runId).run_status).toBe('blocked');
  198 + });
  199 +
  200 + it('maxConcurrency<=1 直接委托 advanceOne(验收 7):一次只推进一个节点', async () => {
  201 + const { runId } = buildDiamond({ maxConcurrency: 1 });
  202 + const calls = [];
  203 + engine.setAgentExecutor(async (ctx) => { calls.push(ctx.nodeName); return { status: 'success' }; });
  204 + engine.startRun(runId);
  205 +
  206 + const r1 = await engine.advanceBatch(runId);
  207 + expect(r1.results).toHaveLength(1);
  208 + expect(r1.results[0].node.name).toBe('entry');
  209 + expect(calls).toEqual(['entry']);
  210 +
  211 + // 只推进了一个:a/b/c 还没被拾取(委托给 advanceOne 之后,一次只认领一个 ready 节点)
  212 + expect(statusMap(runId)).toMatchObject({ a: 'ready', b: 'ready', c: 'ready' });
  213 + });
  214 +
  215 + it('额度生效(验收 5):run.max_concurrency 建 run 时持久化,不传 opts 时读它', async () => {
  216 + const { runId } = buildDiamond({ maxConcurrency: 2 });
  217 + expect(wdb.getRun(runId).max_concurrency).toBe(2);
  218 +
  219 + const gates = { a: deferred(), b: deferred(), c: deferred() };
  220 + engine.setAgentExecutor(async (ctx) => {
  221 + if (gates[ctx.nodeName]) await gates[ctx.nodeName].promise;
  222 + return { status: 'success' };
  223 + });
  224 + engine.startRun(runId);
  225 + await engine.advanceOne(runId); // entry → success
  226 +
  227 + const batchPromise = engine.advanceBatch(runId); // 不传 maxConcurrency,读 run.max_concurrency=2
  228 + const running = Object.entries(statusMap(runId)).filter(([, s]) => s === 'running');
  229 + expect(running).toHaveLength(2); // 三个 ready 里只拾取了两个,第三个仍是 ready
  230 +
  231 + for (const g of Object.values(gates)) g.resolve();
  232 + await batchPromise;
  233 + });
  234 +});
... ...
server/tests/unit/graph-engine-fail-reason.test.js
... ... @@ -123,9 +123,13 @@ describe(&#39;引擎自判失败的 fail_reason 与失败上下文(W12 补完 / W9
123 123 // W12 补完 · 三类引擎自判失败都在 fail_reason 上留名
124 124 // ────────────────────────────────────────────────────────────────
125 125  
126   - it('取值域:三个引擎取值与 agent 的归因取值域**不重叠**(复用会劫持精确 fail 边的路由)', () => {
  126 + it('取值域:五个引擎取值与 agent 的归因取值域**不重叠**(复用会劫持精确 fail 边的路由)', () => {
127 127 const engineReasons = Object.values(engine.ENGINE_FAIL_REASONS);
128   - expect(engineReasons).toEqual(['node_error', 'boundary_escape', 'child_run_failed']);
  128 + expect(engineReasons).toEqual([
  129 + 'node_error', 'boundary_escape', 'child_run_failed',
  130 + // T-mt25ecbd:produces/needs 契约闸门新增的两个取值(settleNode 的 success 判定里)
  131 + 'missing_required_input', 'produces_missing',
  132 + ]);
129 133 // infra_flake 在 routeFailure 里有特判(原地重跑、不烧计数)。任何一个引擎取值撞上它
130 134 // 都会让确定性失败(如 Agent not found)原地重跑到预算耗尽。
131 135 expect(engineReasons).not.toContain(engine.INFRA_FLAKE_REASON);
... ...
server/tests/unit/graph-files-index.test.js
... ... @@ -266,7 +266,9 @@ describe(&#39;R-19 产物索引:graph_files / graph_edge_contracts&#39;, () =&gt; {
266 266  
267 267 // ── ④ 缺输入告警(R-19.4 读回端)────────────────────────────
268 268  
269   - it('④ 声明了 required 输入却查不到实例:提示词里出现 ⚠️,不静默省略', async () => {
  269 + it('④a 声明的 produces 一个候选文件都没落盘:节点本身按 fail 结算(produces_missing),不点亮下游', async () => {
  270 + // T-mt25ecbd 复现原样:brd/prd/sdd 三个 planner 节点声明了 produces 却真的什么都没写,
  271 + // executor 没抛异常就被引擎当 success——这道闸门堵在这里,不再等下游发现读不到东西。
270 272 const companyId = 'co-files-missing';
271 273 const taskId = 'task-missing';
272 274 const b = buildRun({
... ... @@ -278,14 +280,49 @@ describe(&#39;R-19 产物索引:graph_files / graph_edge_contracts&#39;, () =&gt; {
278 280 edgeLayer: 'work', edgeId: b.edgeId('sdd-writer', 'impl'), kind: 'sdd', required: true,
279 281 });
280 282  
281   - // 上游什么都不写 → 声明在、实例不在
  283 + // 上游什么都不写 → 声明在、连候选文件都没有
282 284 const tasks = await advance(b.runId, 2);
283 285 expect(allFiles(companyId)).toEqual([]);
284 286  
  287 + const state = wdb.getRunState(b.runId);
  288 + const sddWriter = state.nodes.find((n) => n.name === 'sdd-writer');
  289 + expect(sddWriter.status).toBe('blocked'); // fail → 没有 fail 边 → 终态 blocked(FSM)
  290 + expect(sddWriter.failReason).toBe('produces_missing');
  291 + // 上游都没能成功,下游压根不会被点亮,更谈不上跑
  292 + expect(tasks.has('impl')).toBe(false);
  293 + });
  294 +
  295 + it('④b 声明了 required 输入却查不到实例:提示词里出现 ⚠️,agent 判自己成功也不被引擎采信(missing_required_input)', async () => {
  296 + const companyId = 'co-files-missing-needs';
  297 + const taskId = 'task-missing-needs';
  298 + const b = buildRun({
  299 + companyId, taskId,
  300 + nodes: [{ nodeName: 'sdd-writer' }, { nodeName: 'impl' }],
  301 + edges: [{ from: 'sdd-writer', to: 'impl', condition: 'success' }],
  302 + });
  303 + wdb.declareEdgeContract({
  304 + edgeLayer: 'work', edgeId: b.edgeId('sdd-writer', 'impl'), kind: 'sdd', required: true,
  305 + });
  306 +
  307 + // 上游写了两份跟声明的 sdd 对不上、彼此也无歧义可猜的文件(同 ⑦ 的「有歧义时不猜」口径,
  308 + // 两份都落成 kind=artifact)——产出闸门只看「有没有候选文件」,不判分类对不对,
  309 + // 所以 sdd-writer 正常 success,impl 照样被点亮。但 graph_files 里查不到 kind=sdd 的
  310 + // 实例——这正是 R-19.4 要报的那种「声明了却查不到」。
  311 + const tasks = await advance(b.runId, 2, {
  312 + writes: { 'sdd-writer': { '文档一.md': '跟 sdd 无关', '文档二.md': '跟 sdd 也无关' } },
  313 + });
  314 +
285 315 const prompt = tasks.get('impl');
286 316 expect(prompt).toContain('[上游输入');
287 317 expect(prompt).toContain('- sdd:⚠️');
288 318 expect(prompt).toContain('索引里查不到实例');
  319 +
  320 + // impl 自己的 executor 判了 success(advance 默认 status: () => 'success'),
  321 + // 但引擎不采信——它自己也声明了要吃 required 的 sdd,查不到实例就不算数。
  322 + const state = wdb.getRunState(b.runId);
  323 + const impl = state.nodes.find((n) => n.name === 'impl');
  324 + expect(impl.status).toBe('blocked');
  325 + expect(impl.failReason).toBe('missing_required_input');
289 326 });
290 327  
291 328 // ── ⑤ 节点 fail 不登记 ──────────────────────────────────────
... ...