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,commitc3abf4a)。 命名: 遵循 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 收编)不冲突,是它们之前的一步。
一、目标与非目标
目标
- fan-out 并行执行 —— 解除 P3「一次只推进一个节点」的限制,让同一波次的多个 ready 节点真正并发跑。
- 同一 system agent 的多实例隔离 —— 这是并行的前置条件,卡在两个既有不变量上(§三.2)。
- 解除「一家公司同时只有一个 active root run」约束 —— P3 v1 用一个唯一索引强制它(§三.3),并行能力具备后应放开。
- 产物统一目录 +
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 产物统一目录 + 产物索引表(R-19 定案后重写)
v0.2 重写:本节原设计是「公司根放一个
files.json」。27-REQ 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 §四.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 §八。brd.md/prd.md/sdd.md这几格仍在节点私有目录,不在本条范围内。
不走 workspace/(R-19.3 定案):workspace/ 的现有语义是
06-沙盒自治 定的
「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 说「我拿不到输入」 (第四次真跑正是这么发现的)。
表结构
-- 产物实例:运行时由**引擎**追加。不靠 agent 自觉写,否则索引必然与实际文件漂移
-- (本轮已被验证过多次:凡「靠 agent 自觉」的环节几乎全部落空)。
CREATE TABLE IF NOT EXISTS graph_files (
id TEXT PRIMARY KEY, -- gf_<nanoid>
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 行 → 提示词里的绝对路径 |
同轮暴露的两条不对称(都不是本轮改动引入的):
- 落库通道走的是降级路径,不是 REST 端点。
POST /api/company/:id/work-graphs实际不存在(真跑实测 404,全仓库无该路由)。规划 agent 按agent.md的降级约定把图 JSON 写到workspace/workgraph-<taskId>.json、由引擎接手落库 —— 契约在这条路上没丢 (normalizeContracts在work-graph-db.normalizeEdges里,与入口无关)。 但模板里那段 curl 是必然 404 的死路径,每次真跑都要浪费 agent 一轮试探。 - 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/<agent-cwd>/*.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 条。)
同轮暴露的四条不对称(都不是本轮改动引入的,且都不在本轮可改文件内):
- 引擎提示词与新模板在
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 永远不可能「自行落库成功」,那个分支对它来说只是陷阱。 - 同一处还留着「端点尚未上线」的措辞(
graph-engine.js:829→ 提示词第 770 行), 暗示将来会上线;新模板的口径是NO-SUCH-ENDPOINT — 从来没有实现过。两边措辞不一致。 顺带一提,graph-engine.js:832那个内联 JSON 示例的边是{"from":"...","to":"...","condition":"success"}—— 一个produces都没有, 与模板「forward 边基本都要写produces」正相反。三处(829/832/835)都在同一段字符串数组里, 一次改完最省事。 - 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 入边上确实有声明 + 索引里确实有实例」这个具体组合的现场,不是代码路径。 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 §四.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、全量与 ?=可选进 <title>);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 列),落库后两者不可区分 |
❌ 未落库:所以「这条边声明的 kind 有没有实例」只能看索引本身,而不能按 role 判定谁该产它 |
五、任务划分
| # | 任务 | 文件 | 依赖 |
|---|---|---|---|
| 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 项)。
六、验收标准
- 并发隔离:同一 system agent 被两个并行节点同时调用,两次对话的上下文互不污染(各自有独立 session)。
- 批量拾取幂等:并发调用两次
advanceBatch,拾取到的节点集合无交集,且没有节点被漏掉。 - fan-out 真并行:一张有 3 个并行分支的图,自动模式下三个节点同时处于
running(而不是依次),且总墙钟明显短于串行。 - 部分失败停等:3 个并行分支中 1 个失败时,fan-in 节点保持
pending不被点亮;控制台能显示「在等哪几个节点」。 - 额度生效:
max_concurrency=2的 run 上有 5 个 ready 节点时,同时 running 的不超过 2 个。 - 单公司多 run:同一公司可同时有 3 个 active root run,第 4 个被拒绝且错误信息明确。
- 手动模式不变:
maxConcurrency=1,「下一步」行为与 P3 完全一致(P3 已有测试全绿)。 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
- 并行让预算烧得更快,但预算语义不变。任务级总预算是「只增不减的共池」(P3 已实现),并行不改变总量,只改变消耗速率。控制台的成本提示需要相应更新——用户点「自动」时看到的预估时间会大幅缩短,但花费不变。
- 改 DevLoop 规格的连带风险(T2)。§12 第一条不变量是 DevLoop 的,放宽它理论上不影响 DevLoop 自身(它的
runProject波内本来就是串行的),但必须跑一遍 DevLoop 全部测试确认。 maxSessions上限触顶时的行为未定。额度为 0 时是「等待下一轮」还是「拒绝并报错」?本文档 §4.4 选了前者(不报错、等下一轮),但如果一个 run 长期抢不到额度会静默卡住——需要一个「等待超时」告警,否则表现为「不动了」。留待实现时定阈值。files.json的并发写入。并行节点会同时追加索引,需要串行化(文件锁或走 SQLite)。本文档设计的是 JSON 文件形态(符合用户要求「一个 files.json 文件」),但并发追加是真实风险——可能需要退化为「引擎内单写者队列」。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 自己也能并行。