27-p4-parallel-and-artifacts.md 21 KB

27-P4. 并行执行与产物索引

所属: 27 Graph Agent 架构 的 Phase P4 版本: v0.1(设计阶段,未实现) 日期: 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,节点名按层级轴(org-planner / org-coder-validator),本体名不带前缀。 配套: 27-E2E §7.4(fan-out 的两个阻塞点)、27-EXAMPLE 阶段 6(fan-out/fan-in 的具体场景)


〇、关于编号:这个 P4 不是原来那个 P4

27 号总纲 §六 里原本的「P4 回退 + Loop-Validator」已经并入 P3 实现完毕(总纲该行已标注「已并入上一行,不再单列」)——双层熔断、五项结构校验含 fail-fallback-edge、三层暂停/停止级联、按 contract_hash 判据的重试,全部在 c3abf4a 落地。

本文档回收这个空出来的编号,装的是 P3 明确排除、且现在成为最大能力缺口的内容。与 P5(自我进化)/ P6(DevLoop 收编)不冲突,是它们之前的一步。


一、目标与非目标

目标

  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 §八。
  • 不做 DevLoop 收编 —— 属 P6。
  • 不做跨公司的并行调度(多公司同时跑的全局资源仲裁)—— 本阶段并发上限按「单机总量」控制即可,见 §四.4。
  • 不做节点执行中途的断点续传 —— 沿用 P3 的重试语义(整个节点重跑)。
  • 不做把 conversations/ 与 system/tasklog/ 下的历史记录纳入 files.json —— 那是运行态日志,不是产物。

二、背景:为什么并行是现在最大的缺口

27-EXAMPLE 阶段 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:

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:

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:

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 产物目录现状

  • ✅ goal-contract.js 已实现验收契约的跨节点传递(R-15.6 验收先行)。
  • ✅ graph-engine.js:283/407/444 有节点级产物路径:data/companies/{cid}/agents/{systemAgentId}/.../attempt-{n}.json。
  • ❌ 没有 files.json(全仓库无此文件、代码无引用)。
  • ❌ 产物按 agent 分目录,不是按 task 分目录——同一 agent 在不同任务下的产出混在一起。

四、设计方案

4.1 advanceOne → advanceBatch(保持原语单一)

不新增第二个推进原语。P3 「手动与自动共用同一个 advanceOne」的设计要保留,只把「拾取一个」放宽为「拾取一批」:

// 拾取至多 N 个 ready 节点(N = 本 run 的有效并发额度,见 4.4)
advanceBatch(runId, { maxConcurrency })  →  { results: [...], activated: [...] }
  • 手动模式:maxConcurrency = 1,行为与今天的 advanceOne 完全一致——「下一步」仍然只推进一个节点,用户能看清每一步。
  • 自动模式:maxConcurrency = 有效额度,一次拾取整批并发跑。
  • advanceOne(runId) 保留为 advanceBatch(runId, {maxConcurrency: 1}) 的薄封装,P3 已有的调用方和测试不用改。

拾取的原子性:P3 的幂等靠 WHERE status='ready' 条件更新(受影响行数=0 即视为被别人拿走)。批量拾取要沿用同一手法——在一个事务里把这批节点一次性置 running,而不是先 SELECT 再逐个 UPDATE,否则两个并发的 advanceBatch 会拾取到重叠的集合。

4.2 同一 agent 的多实例隔离

这是并行的前置条件,必须先做。

利用 §三.2 发现的现成挂载点——SessionPool.getKey() 的 scope 参数:

// 图引擎调用 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 节点不会被点亮,图停在那里——这正是期望行为。

需要补的是可观测性,不是新语义:

  • run 层面要能回答「我卡在等哪几个节点」。控制台已有的节点状态足以推导,但要在 UI 上显式标出「fan-in 节点 X 在等 A(fail) / B(success) / C(success)」,否则用户只看到「不动了」。
  • 失败分支按 P3 既有路径处理(fail 出边 → 归因 → 回退或 blocked),P4 不改。

4.4 并发额度:三层,逐层取小

对齐 §三.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 就本轮不拾取(不是报错,等下一轮)。

任务级总预算不变:P3 已实现「按 root_run_id 共池的 backward_count + 任务级预算挂 root run」,并行不改变预算语义——并行只是让预算烧得更快,这一点要在控制台的成本提示里体现。

4.5 解除单 root run 约束

-- 替换 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 产物统一目录 + files.json

目录约定(用户要求的第 3 项,P3 未实现):

data/companies/{companyId}/
  files.json                        ← 统一检索索引(本节重点)
  tasks/{taskId}/
    brd.md            org-planner 的 work graph 里 brd-planning 节点的产出
    prd.md
    sdd.md
    acceptance.json   验收契约(goal-contract.js 已有,路径归并到这里)
    {nodeName}/       节点私有的中间产物(attempt-{n}.json 等)

为什么按 task 分目录而不是平铺在公司根目录:同一家公司会跑很多个 task,平铺的话第二个 task 的 prd.md 会覆盖第一个。按 task 分目录既避免覆盖,也保留历史。

files.json 的形态:

{
  "version": "1",
  "companyId": "auto-illust",
  "files": [
    {
      "path": "tasks/T-abc/prd.md",        // 相对 data/companies/{cid}/
      "kind": "prd",                        // brd | prd | sdd | acceptance | artifact | log
      "taskId": "T-abc",
      "producedBy": {                       // 谁产出的
        "runId": "gr_xxx", "nodeName": "org-planner-prd", "systemAgentId": "prd-planning"
      },
      "consumedBy": ["org-coder", "org-validator"],   // 声明式:谁应该读它
      "createdAt": "2026-08-16T...",
      "supersedes": "tasks/T-abc/prd.md@rev1"          // 同名产物的版本链,可空
    }
  ]
}

谁写:引擎在节点产出落盘后自动追加一条——不能靠 agent 自觉写,否则索引必然和实际文件漂移。这与 [27-P3/P4] 里 output_ref 的写入时机相同,是同一处扩展。

谁读:所有 node agent。在 prompt 里注入一句「本公司的产物索引在 files.json,按 kind/taskId 检索你需要的输入」,取代现在靠约定路径硬拼的方式。

与 output_ref 的关系:output_ref 是节点级的产物指针("我这次跑出了什么"),files.json 是公司级的检索索引("这家公司有哪些产物、谁能用")。两者不重复:前者一对一挂在 graph_run_nodes 行上,后者是聚合视图。files.json 由 output_ref 派生,所以需要一个 rebuild 能力(索引损坏时可从节点行重建),类比 _index.json 的 _rebuildIndex()。


五、任务划分

# 任务 文件 依赖
T1 SessionPool 加 maxSessions 上限 + 调用方传 scope(gr:{nodeRunId}) server/src/core/session-pool.js、server/src/core/agent-manager.js 无
T2 DevLoop 规格:§12 第一条不变量放宽为 (pid, agent, scope)。改 HTML 后必须跑 npm run spec:extract docs/prd/newfunction/*.html 无
T3 graph_runs 加 max_concurrency 列;DROP idx_graph_runs_one_active_root,改为事务内计数检查 server/services/work-graph-db.js 无
T4 advanceBatch(runId, {maxConcurrency}):事务内批量拾取并置 running;advanceOne 降级为薄封装 server/services/graph-engine.js T3
T5 并发额度计算(三层取小)+ 额度为 0 时的等待语义 server/services/graph-engine.js T1, T3, T4
T6 并行执行 + 错误降级(照搬 orchestrator.executeWithDependencies 的 Promise.allSettled 策略,单分支失败不终结整批) server/services/graph-engine.js T4
T7 files.json 服务:追加、查询、rebuild server/services/company-files-index.js(新增) 无,可并行
T8 产物落盘路径改为 tasks/{taskId}/;节点产出后自动写 files.json server/services/graph-engine.js、goal-contract.js T7
T9 node agent 的 prompt 注入 files.json 检索说明 system/templates/base/agent.md 等 T7
T10 控制台:fan-in 等待态可视化(「在等哪几个节点」)+ 并行运行中的多节点高亮 web/src/views/GraphConsole.vue T6
T11 测试:批量拾取的并发幂等(两个 advanceBatch 不拾取重叠集合)、部分失败的 fan-in 停等、额度上限生效、files.json rebuild 一致性 server/tests/unit/ T4-T8

三个阶段

阶段 含 交付 可否独立上线
A · 隔离 T1, T2 同一 agent 可安全并发调用 ✅ 可以,此时引擎仍串行,但隔离能力已具备
B · 并行 T3-T6, T10 fan-out 真正并发;解除单 root run 约束 ✅ 依赖 A
C · 产物索引 T7-T9 files.json + 按 task 分目录 ✅ 完全独立于 A/B,可最先做

阶段 C 与 A/B 无依赖,如果想先看到效果,可以先做 C(它是用户明确要求过的第 3 项)。


六、验收标准

  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/*.html §12 不变量放宽(改完必须跑 npm run spec: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 全部测试确认。
  3. maxSessions 上限触顶时的行为未定。额度为 0 时是「等待下一轮」还是「拒绝并报错」?本文档 §4.4 选了前者(不报错、等下一轮),但如果一个 run 长期抢不到额度会静默卡住——需要一个「等待超时」告警,否则表现为「不动了」。留待实现时定阈值。
  4. files.json 的并发写入。并行节点会同时追加索引,需要串行化(文件锁或走 SQLite)。本文档设计的是 JSON 文件形态(符合用户要求「一个 files.json 文件」),但并发追加是真实风险——可能需要退化为「引擎内单写者队列」。
  5. files.json 与 consumedBy 的准确性。consumedBy 是声明式的(规划时写「谁应该读它」),不是实测的。如果规划错了,索引会误导下游 agent。缓解:把它当提示而非约束,agent 仍可读任何 files.json 里的条目。

九、与其他阶段的关系

  • P3:本阶段完全建立在 P3 的引擎之上,不推翻任何 P3 决策。P3 的「手动/自动共用同一原语」「join 语义」「熔断与预算」「三层暂停级联」全部保留。
  • P5(自我进化 / L4):无依赖,可并行推进。但 L4 的「agent dream 离线批处理」会受益于 files.json——跨公司汇总当日执行经验时,产物索引是天然的输入源。
  • P6(DevLoop 收编):T2 放宽的不变量是 P6 的前置——DevLoop 的 runProject 波内串行(loop-controller.js:609 注释 // 串行跑)正是被同一条不变量挡住的,P4 做完后 DevLoop 自己也能并行。