From 9ce9dad29567d5443470217195e00f8d6c1a2e22 Mon Sep 17 00:00:00 2001 From: yibo Date: Tue, 8 Sep 2026 23:28:08 +0800 Subject: [PATCH] fix(graph-engine): 补齐 P4 并发隔离接线 + advanceTree 真并发;27 号系列(P3B/P4)收口归档 --- docs/README.md | 11 ++++++----- docs/done/system-design/27-p3b-real-run-runway.md | 1144 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/done/system-design/27-p3c-goal-task-model.md | 8 ++++---- docs/done/system-design/27-p4-parallel-and-artifacts.md | 712 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/done/system-design/28-agent-registry-unification.md | 9 +++++---- docs/prd/newfunction/_spec/CONVENTIONS.md | 2 +- docs/todo/system-design/27-goal-contract-format.md | 2 +- docs/todo/system-design/27-graph-agent-architecture.md | 6 +++--- docs/todo/system-design/27-p3-p4-work-graph-console.md | 11 +++++++++-- docs/todo/system-design/27-p3b-real-run-runway.md | 1015 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- docs/todo/system-design/27-p4-parallel-and-artifacts.md | 709 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- docs/todo/system-design/27-requirement.md | 6 +++--- docs/todo/system-design/41-graph-vs-fde-reliability-discussion.md | 6 +++--- docs/todo/system-design/49-antigravity-inspired-parallel-verification-proposal.md | 17 ++++++++--------- docs/todo/system-design/50-competing-candidates-critic-synthesis-design.md | 4 ++-- server/services/graph-engine.js | 43 ++++++++++++++++++++++++++++++------------- server/src/core/agent-manager.js | 11 ++++++++--- server/tests/unit/agent-manager-session-scope.test.js | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/tests/unit/graph-engine-advance-batch.test.js | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ server/tests/unit/graph-real-chain-smoke.test.js | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 20 files changed, 2167 insertions(+), 1777 deletions(-) create mode 100644 docs/done/system-design/27-p3b-real-run-runway.md create mode 100644 docs/done/system-design/27-p4-parallel-and-artifacts.md delete mode 100644 docs/todo/system-design/27-p3b-real-run-runway.md delete mode 100644 docs/todo/system-design/27-p4-parallel-and-artifacts.md create mode 100644 server/tests/unit/agent-manager-session-scope.test.js diff --git a/docs/README.md b/docs/README.md index 6010c7e..b8895bf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,7 @@ > **当前主线**:Graph Agent 架构重构([27-graph-agent-architecture.md](./todo/system-design/27-graph-agent-architecture.md))。 > 该系列已完成的阶段文档(P1 / P2 / **P3 收尾 P3C**)移入 `done/`,其余非主线待办在 `todo/future/`。 -> **当前未完成的是**:P3B 真跑跑道的几条验收、P3/P4 那笔「真实路径 smoke 进 CI」的欠账、P4(未实现)、28-followups。 +> **当前未完成的是**:28-followups。P3B 九条验收已于 2026-09-08 全部验完;P4 已于 2026-09-08 收口;P3/P4「真实路径 smoke 进 CI」的欠账已基本还清,只剩真 LLM(本质限制)与按需 hire 未覆盖。 ## done/ @@ -45,6 +45,8 @@ | [system-design/45-graph-run-node-liveness-recovery.md](./done/system-design/45-graph-run-node-liveness-recovery.md) | ✅ **Part A/B/C 已实现**(2026-08-31),**Part D 有意延后**。真实事故:h3video 公司一个 `agent_call` 节点卡在 `status=running`、背后 CLI 进程已死,服务器主进程没重启——心跳(纯 LLM 驱动)、开机一次性孤儿扫描、goal/task 巡检(判据是 `blocked` 不是 `running`)、wallclock 看门狗(按预算不按存活)四层机制全盖不到;`ceo` 被转发问题后自己绕过引擎拉会话,陷入死循环。**Part A**:调查发现 `SessionPool` 是每次调用现造即回收的一次性对象,没有可查询的存活注册表,原设计不可行,改用「运行时长超阈值(默认 25 分钟)+ 只扫 `agent_call` 节点」,新函数 `sweepStaleRunningAgentCalls`/`startStaleAgentCallWatchdog`(`graph-engine.js`),5 条新单测。**Part B**:`force-fail` 端点(42 号文档已实现)本身就够用,零新后端代码,只在 `ceo/tools.md` 补了使用文档 + 明确禁止绕过引擎自拉会话。**Part C**:`ceo/heartbeat.md` 同步更新。**Part D**(节点补显式心跳字段,消除 Part A 阈值判据的误伤局限)未做,见文内「实现记录」的理由 | | [system-design/47-itemized-subtask-materialization-goal-benchmark-fallback.md](./done/system-design/47-itemized-subtask-materialization-goal-benchmark-fallback.md) | ✅ **已修复**(2026-09-02)。46 号文档 Phase 0 真实回归坐实:`acceptance.md` 在当前 `workgraph-planning` 常规划出的 org-planner 子图形状(prd/sdd/goal-benchmark/planner-validator)下结构性产不出来,`itemized` 子任务物化 0 命中。修法:物化逻辑新增兜底,`acceptance.md` 缺失时改读已被 44 号文档证实稳定产出的 `goal-benchmark.json` 的 `dimensions[]`。`task-decomposition-materialize.test.js` 5 条全过(2 条新增,用真实回归捕获的产物做测试数据) | | [system-design/48-task-acceptance-contract-binding.md](./done/system-design/48-task-acceptance-contract-binding.md) | ✅ **已实现并完成真实端到端回归,已收口**(decomposed_work 链路 2026-09-02/回归补于 2026-09-03,剩余三条路径调研收口于 2026-09-03)。承接 46 号文档 Phase 3 v2 真实回归发现的 `org-validator` 死锁:验收契约的产出被硬绑在"公司完整 org graph 一定会跑到 org-planner"这个假设上,不满足这个假设的执行路径(如轻量子任务图)无契约可读、退无可退。方案:消费端(validator 只认结构化契约、不自由判断)保持硬约束不变,产出端改成"每个 decomposed_work 子任务创建时必须 1:1 带一份验收契约"(`tasks.acceptance_contract` + `createTask` 统一校验 + `enqueueTaskRun` 落盘),真实 `org-coder`→`org-validator` 全程跑通验证。**收口时追加调研**:`task-planning`/`org-planner` 的 `acceptance_item` 物化/人类快速派单三条创建路径经核实**不需要**同款强绑定——都走完整 org graph,`workgraph-planning` 的 `benchmark` 节点"不可裁"约定已天然保证有契约,强行加会跟 46 号文档保护的 `task-planning` 极简边界、快速派单"零 LLM"定位冲突。真正残留风险是"org graph 一定含 org-planner"目前只是提示词约定非代码强制,属于 org 拓扑保证问题,不在本文档范围。安全网(发现时就地补契约)按设计仍只留原则,`decomposed_work` 链路已被主力机制完全覆盖,不为不可达场景写代码 | +| [system-design/27-p4-parallel-and-artifacts.md](./done/system-design/27-p4-parallel-and-artifacts.md) | **P4(回收编号)并行执行与产物索引** — ✅ **2026-09-08 已彻底收口**,移入 `done/`(阶段 A/B/C 全部完成,含本轮补齐的同 agent 多实例隔离生产接线 + `advanceTree` 兄弟子 run 并发)。T5 账户欠费门本身已实现(可插拔桩子),只差接一个真实计费接口——用户拍板暂缓,见 `todo/future/` | +| [system-design/27-p3b-real-run-runway.md](./done/system-design/27-p3b-real-run-runway.md) | P3 收尾之真跑跑道(从 mock 走到真 Claude CLI)— ✅ **2026-09-08 全部收口**,移入 `done/`。§六「九条验收」全部验完(验收 9 见 §六quater:2 节点 org 图故意跳过 org-coder,`org-validator` 真实读项目根、四维度全判 0 分,engine 真实沿 org 层 fail 边把 `org-planner` 重置重跑,15 次真调用/218,887 tokens);正文里 `W5/W6/W7/W8/W9` 等更细粒度小项也已逐条核对——大半此前就被后续真跑验过、只是没回填,真正之前从未验过的只有 W8(重新规划组织按钮),本轮用隔离 worktree + 真 Claude CLI + 真实浏览器点击补验(详见「W8 浏览器真跑验收」,该节末尾记了一个真跑中顺手发现、尚待排查但不影响验收本身成立的前端细节:`insertNode` 重规划成同名角色时复用同一行 id,与文档里"每次换新 id"的旧设计假设不一致)。⛔/🟡 的历史段落读之前先看头部状态行 | | [system-design/46-mandatory-planning-and-task-decomposition.md](./done/system-design/46-mandatory-planning-and-task-decomposition.md) | ✅ **已收口**(2026-09-07)。Phase 0-5 全部实现。**Phase 1**:新 agent `subtask-planning`,钩在 `enqueueTaskRun`(显式 opt-in),产物写共享 `artifacts/subtask-plan.json`。**Phase 2**:经验检索 + 按需 deep research(默认关闭)。**Phase 3 v2**:CEO 心跳直接发现+派发(push),子任务走轻量 work graph(`org-coder`+`org-validator` 两节点)。**Phase 4**:父任务被拆分后让位给子任务(新状态 `decomposed`)、预算按步数均分、父子终态汇总(`settleParentTaskIfAllChildrenDone`)、同父子任务独立并发坑位(上限 3,不占公司级槽位)——四项机制全部用真实数据验证过,包括最后补跑的父子终态汇总(两个真实子任务全部 `completed` 后父任务被正确自动结算成 `completed`)与第二次并发确认。过程中撞出并修复一个几乎必现的真缺陷(`markTaskDecomposed` 原本只认父任务还是 `queued`,但 task-scheduler 抢坑是毫秒级、`subtask-planning` 是数十秒的真实 LLM 调用,等它拆完时父任务几乎必然已经 `running`,接管必然失败)。**Phase 5**:看板加 `decomposed` 状态样式 + 子任务进度展示,父子树形复用已有通用逻辑,未改后端接口。回归过程中还牵出一个独立缺陷——`org-validator` 对同一种 benchmark 契约判断不稳定(5 次里 1 次误把源码路径当 `--target` 判 `target_not_json`,反手倒退归因给 `subtask-planning`)——已定位根因(`decomposed_work` 轻量图当时只有一条前进边,没有失败边,validator 判 fail 后无路可退)并修复(补 `validate --fail--> impl` 退回边,复用引擎已验证过的 `backward_count` 双层硬顶 + `remediation_note` 反思闭环,新增 3 条单测),详见文末补跑记录 | ## todo/(当前主线) @@ -58,16 +60,14 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d | [27-naming-conventions.md](./todo/system-design/27-naming-conventions.md) | **命名规范**。`{作用域}-{职能}`;图节点按层级轴命名;节点名 ≠ 本体名;三种 benchmark 消歧;OKR 正名。新增 agent id / 表名 / 字段名前必读 | | [27-e2e-task-execution-flow.md](./todo/system-design/27-e2e-task-execution-flow.md) | 端到端流程总览:建公司 → 下达任务 → org graph 逐节点执行 → work graph 规划与循环 → 验收收敛。含与 DevLoop 既有实现的对应关系分析 | | [27-example-dev-company-walkthrough.md](./todo/system-design/27-example-dev-company-walkthrough.md) | 参考场景走查(虚构的「配图工厂」),把架构跑一遍具体的数:各阶段 JSON 形态、成本估算、断点清单 | -| [27-p3-p4-work-graph-console.md](./todo/system-design/27-p3-p4-work-graph-console.md) | P3+P4(原 P4 回退+Loop-Validator 已并入)图执行引擎与执行控制台 — ✅ 已实现(`c3abf4a`)。**仍留 todo 的原因**:§十 末「四条硬规矩」第 2 条那笔欠账**已还大半** —— `graph-real-chain-smoke.test.js`(零注入、只换掉真 CLI 子进程)已进 CI,`agent_call` 与 `subgraph` 两支都覆盖。剩下的是真 LLM(进不了 CI)、多节点 / 回退边 / 熔断的形状、以及按需 hire 那一段 | -| [27-p3b-real-run-runway.md](./todo/system-design/27-p3b-real-run-runway.md) | P3 收尾之真跑跑道(从 mock 走到真 Claude CLI)— 📝 **只剩验收 9**。2026-08-20 第四次真跑(§六ter)验掉 2 与 8,验收 4 由零注入链路验掉,7 早已修好。验收 9 的准确状态是「产出端通了、消费端没等到那个形状」—— 需要一次 **org 层回退边指向 subgraph 节点**。⚠️ 这份文档里 ⛔/🟡 的段落多为**当时的现场记录**,读之前先看头部状态行 | +| [27-p3-p4-work-graph-console.md](./todo/system-design/27-p3-p4-work-graph-console.md) | P3+P4(原 P4 回退+Loop-Validator 已并入)图执行引擎与执行控制台 — ✅ 已实现(`c3abf4a`)。**仍留 todo 的原因**:§十 末「四条硬规矩」第 2 条那笔欠账**已基本还清**(2026-09-08)——`graph-real-chain-smoke.test.js`(零注入、只换掉真 CLI 子进程)已进 CI,覆盖 `agent_call`/`subgraph` 两支 + 多节点/回退边/双层熔断三种形状。剩下的只有真 LLM(本质限制,进不了 CI)与按需 hire 那一段未走过这条链路 | | [27-goal-contract-format.md](./todo/system-design/27-goal-contract-format.md) | 验收契约格式(`goal-benchmark` / `goal-test-data` / `goal-test-script` 的产物形态与跨节点传递) | -| [27-p4-parallel-and-artifacts.md](./todo/system-design/27-p4-parallel-and-artifacts.md) | **P4(回收编号)并行执行与产物索引** — 📝 设计中,未实现。fan-out 真并发 + 同 agent 多实例隔离 + `files.json` 产物索引。三个 Phase,C 可插队 | | [28-followups.md](./todo/system-design/28-followups.md) | 28 号(Agent 注册表统一)的遗留清单 — 主文档已在 `done/`,这份装没做完的:dream 机制(设计见 29 号,已实现,见下)、`source_kind`(✅已删)/`legacy_path`(不能删)删列、三项「已实施未验证」。任务态清理(✅已实现,见下)。§五 是**已立起来的守卫清单**(别重复造,也别删) | | [30-task-delegation-reliability-gaps.md](./todo/system-design/30-task-delegation-reliability-gaps.md) | **任务委派可靠性现状**— 📝 调研记录,非设计方案。DevLoop `runUnit`、Company 招聘装配、Graph 执行三条链路核对结论一致:都是「失败预算耗尽 → 停等人工」,没有「卡住了主动通知人」这一层;DevLoop 当前还默认走 mock,真实 CLI 成功率无实证数据。2026-08-23 追加真实实例并已修:`infra_flake` 原地重跑没有熔断线,真撞上过一次确定性死循环(节点把诊断结论误判成自己的执行结果,反复重跑却从不路由到诊断建议的目标节点);已加 `infra_flake_count` 列 + 硬顶 3 次熔断,连续判 infra_flake 超过硬顶就改判 blocked 交给人工 | | [41-graph-vs-fde-reliability-discussion.md](./todo/system-design/41-graph-vs-fde-reliability-discussion.md) | 📝 **讨论记录 + 落地实测,非设计方案**。org/work graph 与 master-Innovator-FDE 两套工作流的稳定性对比:根因是"拓扑运行时生成 vs 写死"。问题 7 是真实执行记录——新建 `org-hunt/digest/evolve`(graph 原生协议,方法论抄 innovator-hunt/digest/evolver)跑 Hunt→Digest→Evolve,11 轮真跑中挖出并修复 5 个独立缺陷(建公司自动规划竞态、work-graph 模板库缺内容型交付物骨架、验收节点任务描述越权、两个规划 agent 的路径口径写错、R-19 产出索引 `indexNodeOutputs()` 不覆盖项目根——最后一条改了 `graph-engine.js` 核心逻辑,加 `contract.json` 的 `produces` 显式声明机制,全局生效,补了 2 条守卫测试)。最终产出与原生 FDE(`prd-methodology-trial`)对比:内容骨架可迁移、质量不打折,但过程严谨度不如原生 FDE,可靠性差距悬殊(原生一次跑通,graph 版 11 轮才稳定)。`routeFailure` 的 `infra_flake` 分支绕过跨层寻址(`target_node`)这个口子已按最保守方案补上(2026-08-31,见文内清单)——但堵不住 30 号文档记录的那次真实事故,那次事故点名的目标是下游节点,图模型本身就不允许 backward 边指向非祖先,属于修复范围之外 | | [37-org-graph-department-scoping.md](./todo/system-design/37-org-graph-department-scoping.md) | 📝 **设计讨论,调用点扫描已完成,尚未立项实现**。现状 org graph 按 `company_id` 单例(`27-P2` 标题原文即"公司级"),塞不下 HR/财务/市场这类跟"交付流水线"无关的公司常设职能——`system/agents/hr/`("招·留·管·退")本体已存在却从没被装进任何图,全靠 fire-and-forget chat 招聘、无图执行审计轨迹。提议把 org graph 作用域从 `company_id` 升级为 `(company_id, department_id)`,一家公司可装配多张部门图,每张仍保持人工固定/慢变/可审计。全仓库调用点扫描结论:改造面是最初估算的 4 倍——真正的单点真相在 `work-graph-db.js:readDefGraph` 的裸 SQL 不变式(不在服务层),还牵涉两个易混淆服务文件间必须字节级一致的重复唯一索引、独立的 `graph-seed-service.js` 对账子系统、`company-hire-org-layer.test.js` 专属测试、`orggraph-planning`/`task-planning` 两份 agent 人设文档的自我认知改写 | | [36-prd-agent-methodology-training-feasibility.md](./todo/system-design/36-prd-agent-methodology-training-feasibility.md) | 📝 **调研 + 阶段一/二均已完成**。回应"落地页太单薄是不是该用 Innovator 先训练专业 agent、再用 Dream 持续迭代"——认同方向。**阶段一**:`graph-engine.js` 结算节点后把 `success`/`fail:` 写进本体 `memory.md`(此前只有人类对话路径会写,Dream 天天空转)。**阶段二**:新建独立试验节点 `prd-methodology-trial` 走真实 Hunt→Digest→Evolve,2 轮收敛(96/97 分,真实基线 50 分),三个真实历史项目反推验证方法论有效;过程中连带修复同一个 2026-08-16 改名批次遗留的 5 处 `innovator`→`org-innovator` 死链(后端两处硬编码、前端"重新训练"按钮、`/api/agent/list` 展示 id 来源的四个 `config.json`),以及 `AgentScaffold` 默认模板一处裸相对路径缺陷。结论:FDE 对方法论类角色确实管用,但收敛速度/验收严谨度依赖任务书显式写明的自纠正要求,非免人工黑盒。环节 3(训练产物 → 雇佣本体转换)尚未开工 | -| [49-antigravity-inspired-parallel-verification-proposal.md](./todo/system-design/49-antigravity-inspired-parallel-verification-proposal.md) | 📝 **设计讨论,不代表已拍板**(2026-09-04)。对照 Google Antigravity「Teamwork」多智能体框架博客文章,给 27 号 P4「真并行」与 34/35 号「验证严谨度不稳定」两个议题出的方案草稿。**先纠正一个前提**:重读 27-P4 全文后发现阶段 A/B/C 早已全部实现(又一次「文档落后于代码」),真并行现在只剩 T2/role 列/`advanceTree` 兄弟子 run 并发三件小事,可独立收口。**新提案 A**:借鉴长证明模式的「竞争候选+对抗批评+综合」,区别于现有 `executor-fanout.json` 的分解型 fan-out——需要先有真实场景验证价值,不建议凭空立项,详细设计见 [50 号文档](./todo/system-design/50-competing-candidates-critic-synthesis-design.md)。**新提案 B**:把 35 号提案 D「浏览器关键词命中才要证据」泛化成 benchmark 段全维度默认要求执行级证据(**唯一建议现在推进的部分**);实地核实过把验收路径引导到 `test_script` 段的工作量后**结论是不做**——撞上 `test_script.path` 解析根不含 `org-coder` 实际写代码的项目根这个结构性冲突,改造面远超预期;新增「陷阱注册表」,把 validator 判卷失败的结构化原因喂回 Dream 机制,落点已定案为独立文件 `known_pitfalls.md`(不复用 `memory.md`,避免语义混淆)。建议推进顺序:陷阱注册表最低成本先做,证据泛化次之,竞争候选需要真实场景才立项 | +| [49-antigravity-inspired-parallel-verification-proposal.md](./todo/system-design/49-antigravity-inspired-parallel-verification-proposal.md) | 📝 **设计讨论,不代表已拍板**(2026-09-04)。对照 Google Antigravity「Teamwork」多智能体框架博客文章,给 27 号 P4「真并行」与 34/35 号「验证严谨度不稳定」两个议题出的方案草稿。**先纠正一个前提**:重读 27-P4 全文后发现阶段 A/B/C 早已全部实现(又一次「文档落后于代码」),当时判断真并行只剩 T2/role 列/`advanceTree` 兄弟子 run 并发三件小事——2026-09-08 收口时又发现这个判断本身有两处误判(role 列其实早就落库、T1 的✅遗漏了 scope 参数的生产接线),三件小事(连同两处误判一起)已全部核实/补齐,见 [27-P4](./done/system-design/27-p4-parallel-and-artifacts.md)。**新提案 A**:借鉴长证明模式的「竞争候选+对抗批评+综合」,区别于现有 `executor-fanout.json` 的分解型 fan-out——需要先有真实场景验证价值,不建议凭空立项,详细设计见 [50 号文档](./todo/system-design/50-competing-candidates-critic-synthesis-design.md)。**新提案 B**:把 35 号提案 D「浏览器关键词命中才要证据」泛化成 benchmark 段全维度默认要求执行级证据(**唯一建议现在推进的部分**);实地核实过把验收路径引导到 `test_script` 段的工作量后**结论是不做**——撞上 `test_script.path` 解析根不含 `org-coder` 实际写代码的项目根这个结构性冲突,改造面远超预期;新增「陷阱注册表」,把 validator 判卷失败的结构化原因喂回 Dream 机制,落点已定案为独立文件 `known_pitfalls.md`(不复用 `memory.md`,避免语义混淆)。建议推进顺序:陷阱注册表最低成本先做,证据泛化次之,竞争候选需要真实场景才立项 | | [50-competing-candidates-critic-synthesis-design.md](./todo/system-design/50-competing-candidates-critic-synthesis-design.md) | 📝 **设计草稿,不代表已拍板**(2026-09-04)。49 号文档提案 A 的展开:竞争候选骨架 JSON 形状草稿(`candidate-council.json`,未定案)、命名(按 27-NAMING 节点带前缀/本体不带前缀两轴)、`workgraph-planning` 选型判据方向。**关键设计约束**:critic(评判)与 synthesis(择优/融合)必须拆成两个节点,依据是仓库真实事故——`qa-judge` 早期"评判+终态判决合一"设计导致过一次无限循环(20 次调用/31 分钟才熔断,记录在 `deliverable.json` 的反模式说明里),新骨架延续 `validator-triager`/`benchmark-validator` 已验证的"归因/打分/判定三者分离"范式。**预算风险量化**:N 路候选并行消耗 N 倍预算,且 `checkBudget` 只在批量拾取前查一次,实际可能超支到 `maxConcurrency-1`(默认最多多花 4 次调用)才被拦下,候选数 N 目前代码层没有硬顶 | ## todo/future/(暂缓,非当前重心) @@ -77,6 +77,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d | [architecture_todo_list.md](./todo/future/architecture_todo_list.md) | FDE Pipeline 架构待办清单(Judge 系统 / 反馈闭环等五大维度)。与 Graph Agent 架构基本正交,其中第 4 点「版本回滚」与 27 号文档 P5 阶段的 git 版本化 `memory.md` 有部分重叠,P5 落地后可回来划掉 | | [system-design/14-milestones.md](./todo/future/system-design/14-milestones.md) | 早期里程碑规划(v1.1,checklist 未勾选)。已被后续实际实现大幅绕过,主要留作历史存档,不建议优先处理 | | [system-design/25-harness-engineering-improvements.md](./todo/future/system-design/25-harness-engineering-improvements.md) | Harness Engineering 改进提案(单 Agent 上下文管理),Phase 3-5 未落地。与 Graph Agent 架构(多 Agent 组织/图执行)维度不同,互不阻塞 | +| 27-P4 T5:账户欠费门接真实计费接口 | 桩子已实现并上线(`setBillingGate` 注入模式,`graph_runs.block_reason='billing-arrears'` + 自动恢复触发器 `resumeBillingClearedRuns()`,见 [27-P4](./done/system-design/27-p4-parallel-and-artifacts.md) §五 T5 记录),只差接一个真实计费系统的余额/欠费查询接口——2026-09-08 用户拍板暂缓,非本仓库能单方推进 | ## 未纳入整理的目录 diff --git a/docs/done/system-design/27-p3b-real-run-runway.md b/docs/done/system-design/27-p3b-real-run-runway.md new file mode 100644 index 0000000..80ae31a --- /dev/null +++ b/docs/done/system-design/27-p3b-real-run-runway.md @@ -0,0 +1,1144 @@ +# 27-P3B. 真实运行前置 —— 从 mock 走到真 Claude CLI + +> **所属**: [27 Graph Agent 架构](./27-graph-agent-architecture.md) 的 P3 收尾。P3 主体见 [27-P3/P4](./27-p3-p4-work-graph-console.md)。 +> **版本**: v0.1 +> **日期**: 2026-08-16 +> **起因**: [27-REQ](./27-requirement.md) R-18(招聘归 `workgraph-planning`;停用 mock 改真 CLI) +> **前置**: P3 主体已实现并提交(`c3abf4a` 起七轮),测试 719/719 全绿 +> **状态**(2026-09-08 校准): ✅ **本文档全部收口**。§六「九条验收」1-9 全部验完;正文里散落的 +> `W5/W6/W7/W8/W9` 等更细粒度小项「仍未验」备注也已逐条核对——大半(W5/W6/W7/W9)本来就已经被 +> 后续的真跑(§六bis/§六ter)验过,只是这份文档没有回填,本轮补齐了引用;真正之前从未验过的只有 +> W8(重新规划组织按钮),本轮用隔离 worktree 起真 Claude CLI + 真实浏览器点击补验,见「W8 浏览器 +> 真跑验收」一节(该节末尾记了一个真跑中顺手发现、尚待排查但不影响验收本身成立的前端细节)。 +> 第一批验收 1/3 已验、2 由两次独立真跑(§六ter / §六quater)在干净环境下验掉; +> 验收 4 ✅ 由零注入真实链路验掉(不需要真 Claude CLI,见该行说明); +> 验收 7 ✅ **已修**(`58a2865`「W7 零边图」,守卫 `company-hire-org-layer.test.js` 的 +> `CO_ZEROEDGE` + `W7-fix`,§六ter 复验 + 2026-09-08 浏览器肉眼确认)—— 下面 §六bis 缺陷③ +> 那段是**当时的现场记录**,不是当前状态; +> 验收 8 ✅ 由 2026-08-20 的真跑验掉, +> **验收 9 ✅ 已验**(2026-09-08,隔离 worktree 真跑)——逐条证据见 §六quater。 +> §六bis 缺陷② 的**根因**(边界守卫误判并行会话的改动)已于 2026-08-20 修掉, +> 见 [27-P3C §17.2](../../done/system-design/27-p3c-goal-task-model.md)。 + +--- + +## 一、为什么需要这份文档 + +P3 主体七轮做下来,**引擎编排层是扎实的**(状态机、双层熔断、三层级联、跨层回退,都在脚本 executor 下逐环验过),但两件事一直没解决: + +1. **验证一直建立在 mock 之上。** `ClaudeCliProtocol._sendMockPrompt` 完全不读提示词,只按 agent 名回放 fixture。所以前六轮报的「端到端生产成立」,验的是引擎编排,不是 agent 链路。第七轮换真 CLI 后,链路在**第一个 work 节点**就断。 +2. **agent 供给链没有执行者。** work graph 节点引用的本体,没有任何流程会把它雇进公司。 + +本文档收敛这两件事,目标是:**能在控制台上点一下,让真实 Claude CLI 跑完一个最小任务,且不写坏宿主仓库、验收不假放行。** + +--- + +## 二、目标与非目标 + +### 目标 + +1. `workgraph-planning` 规划完图后自己雇齐引用到的本体(R-18.2 方案 a)。 +2. 真实 Claude CLI 能跑完一个单 org node 的最小任务,产出落在分配好的项目根内。 +3. 验收链路不会「假放行」。 +4. 界面上有入口触发 `orggraph-planning` 真跑(R-18.1),不再靠脚本代劳。 + +### 非目标 + +- **不做 fan-out 并行**(沿用 P3 §一:卡在实例隔离)。 +- **不做 L4**。 +- **不追求一次跑通复杂任务**:第一批的验收标准是「单 org node 的最小图能真跑完」,不是「开发型公司三节点全流程」。前七轮的教训是没跑起来之前的判断经常是错的,先拿到真实结果再排后面。 + +--- + +## 三、现状核实(决定工作量的证据) + +| # | 事实 | 证据 | +|---|---|---| +| 1 | `seedTemplates()` **没有任何运行时调用方** | 只有测试与手动的 `scripts/graph-seed.js` 引用它 | +| 2 | 因此 `system/templates/` 新增的本体进不了 `system_agents` 表 | 而 `work_graph_nodes.system_agent_id` 是指向该表的外键 | +| 3 | 库里有 **10 条悬空 `company_hired` 行**,`legacy_path` 指向已删目录 | 含 `validator-triager` / `prd-planning` / `sdd-planning` / `brd-planning` / `benchmark-validator` / `testdata-validator` / `workgraph-planning` / `org-validator` | +| 4 | `seedTemplates` 遇到 id 已被别的 `source_kind` 占用会 **skip** | 所以第 3 条那些永远变不回 `template` | +| 5 | 后果链:`lookupSharedAgentMd` 恒 null → `hire` 静默退回全量拷贝 | **模板改动对已雇公司不生效** | +| 6 | `resolveAgentName` 只查目录存不存在,**不做按需 hire** | 同文件的 `resolvePlanningAgentName` 对 `workgraph-planning` 倒是做了 | +| 7 | `path-resolver.allocateProjectRoot` 已实现,sandbox 模式给 `data/companies/{cid}/projects/{pid}`,另有 `isSensitivePath` 黑名单 | 图执行侧没有调用它 | +| 8 | 图执行侧的 agent_call 节点**没有产出位置也没有沙箱** | 真 CLI 实测已往宿主仓库写入 `server/routes/echo.js` 等三处(已复原) | +| 9 | `FAILING_VERDICTS = {attributed, inconclusive}`,**不含 `"fail"`** | 而 `benchmark-validator` / `testdata-validator` 的 Step 表教 agent 写 `verdict: "fail"` | +| 10 | `system/skills/` 目录**不存在** | base agent 的 Hot Swap 是 `existsSync` 守卫下的空转(W11 后仍空转,但已改成显式 `console.warn`)。**W11 另查出**:工具权限来自 `config.json` 的 `cli.toolPolicy`(建协议时读一次,与 phase 无关),Hot Swap 换的只是提示词文本 —— 补技能包也关不上权限闸 | + +--- + +## 四、工作项 + +### 第一批 · 真跑的最小前提 + +做完这四件就能真跑,且不会写坏仓库、验收不会假放行。 + +| # | 内容 | 文件 | 量 | +|---|---|---|---| +| **W1** ✅ | 清 10 条悬空 `company_hired` 行;把 `seedTemplates()` 接进服务启动流程(或让 `hire` 顺手 upsert `system_agents`)。**这是 W5/W6 的前置**——不修,agent 雇完人提交图照样外键挂 | `server/services/graph-seed-service.js`、`server/index.js` | 小 | +| **W2** ✅ | 每个 root run 分配一次项目根(复用 `path-resolver.allocateProjectRoot` sandbox 模式),落在 `runs/{rootRunId}/artifacts/project-root.json`(**未加 schema 列**),进 agent_call 与 planning 两种提示词 + `callAgent` metadata | `server/services/graph-engine.js`、`server/tests/unit/graph-boundary.test.js` | 小 | +| **W3** ⚠️ 部分 | 边界守卫落地为「提示词声明 + 事后取证 + 结算拒收」,**不是沙箱**:能拦住宿主仓库内可见的越界写(判 fail + timeline 留痕),拦不住仓库外与被 gitignore 的写。详见下方「W3 能力边界」 | `server/services/graph-engine.js`、`server/tests/unit/graph-boundary.test.js` | 小 | +| **W4** ✅ | **修 fail-open 的验收 bug**:`benchmark-validator` / `testdata-validator` 的 Step 表与引擎 `FAILING_VERDICTS` 对齐。`planner-validator/agent.md` 已有一段红字警告这一点,另两份 Step 表恰恰在教 agent 犯它 | `system/templates/{benchmark,testdata,org}-validator/agent.md`、`server/tests/unit/template-verdicts.test.js` | 小改动,**性质严重** | + +> W4 的失效方向是**放行**不是拦截:契约非法的产物会被当合格通过。真跑之前不修,验收形同虚设。 + +#### W1 落地记录(已完成) + +§三 第 1/3/4/5 条已修复,现状核实表里那条失效链断掉了: + +- **启动接线**:`graph-seed-service.runStartupSeedSafely()` 由 `server/index.js` 的 `start()`(`listen()` 之前)与 `server/agent-server.js`(同上)调用。跑的是 `reconcileDanglingRows()` → `seedGlobalAgents()` → `seedTemplates()`。**不跑 `seedCompanyAgents()`**:那一步的数据源是运行时状态 `company.json`,归 `hire()/fire()` 管,启动时反推 `org_graph_nodes` 会跟它们抢所有权(也正是 W7 要收敛的地方);它继续留在 `scripts/graph-seed.js` 手动跑。全同步幂等,失败只打日志降级,不挡启动。 +- **悬空行回收规则**(三条同时成立才回收,任一不成立就保留):`source_kind='company_hired'` + `legacy_path` 指向的目录已不存在 + 没有任何**活着的公司**在 `company.json.agents[]` 里点名这个 id。附带清掉「磁盘上已无 `company.json`」的公司遗留的 `org_graph_nodes`/`org_graph_edges`(`data/companies/` 整个目录缺失时跳过这一步,分不清「没有公司」和「盘没挂上」)。删不掉(被 `work_graph_nodes` 等外键引着)的行进 `retained`,不抛错。 +- **占位 id 转正**:`seedTemplates()` 撞上被占用的 id 时,只有当那行是**悬空的 `company_hired`** 才就地转正(`source_kind`/`scope`/`owner_company_id`/`legacy_path` 一并改写,保留行 id 让外键继续有效);`global_agent` 占位照旧 skip。 +- **实测结果**:生产 `data/graph.sqlite` 上跑完 → `company_hired` 11→0,`template` 37→45,`org_graph_nodes` 清掉 4 家已删公司的 7 行,`verifyCounts()` 由 false 转 true;`lookupSharedAgentMd('workgraph-planning' / 'prd-planning')` 从恒 `null` 变成能解析到 `system/templates/**/agent.md`,`hire()` 不再静默退回全量拷贝。 +- **测试**:`server/tests/unit/graph-seed-service.test.js` 已 hermetic 化(新增 `GRAPH_SEED_ROOT` 测试缝,夹具根建在 `/tmp`,行数改为写死的精确值),2 条 → 13 条。 + +#### W4 落地记录(已完成) + +§三 第 9 条已修复。三处改动 + 一道防线: + +1. `benchmark-validator/agent.md` Step 1 表、`testdata-validator/agent.md` Step 1 表: + `verdict: "fail"` → `verdict: "attributed"`,各补一段红字说明「写 `"fail"` 引擎读成没失败」。 +2. `org-validator/agent.md` 的审计字段 `verdict: pass|fail` → **`audit_verdict`**。 + 它本来就是审计产物(org 节点是 `subgraph`,成败由 `deriveChildOutcome` 从子 run 推出), + 但它写的文件路径与 work 层判词**完全相同**(`nodes//attempt-.json`), + 只靠「本节点恰好是 subgraph」才没被 `readNodeProduct` 当判词读。改名后两套词彻底分开; + 即便哪天真被当判词读,引擎也会退到「有 `fail_reason` 就算失败」的缺省规则上,仍然判对。 +3. 防线 `server/tests/unit/template-verdicts.test.js`(对标 `template-refs.test.js`): + 从 `graph-engine.js` **源码文本**里取 `FAILING_VERDICTS` / `ATTRIBUTION_REASONS` + (两者都没 export,import 整个模块会把 graph db 拉起来;取不到就抛,不静默空跑), + 扫全部 `system/templates/**/*.md` 的赋值形态(`verdict: "X"`)与判词表整格取值, + 再加扫 `system/{work,org}-graph-templates/*.json` 的边 `failReason`(不在域内 = 这条边永远匹配不上)。 + 两条扫描路径都做过变异验证:改回 `"fail"` / 把表格取值改成 `"ok"`,都会炸。 + +> **`FAILING_VERDICTS` 要不要放宽收 `"fail"`:不要那样补,要把判据反过来** +> (失败词白名单 → 通过词白名单,不认识的一律判失败)。理由与改法见 §八。 +> `graph-engine.js` 归 W2/W3 那一轮,本轮不动它。 + +#### W2 落地记录(已完成) + +§三 第 7/8 条的前半段(**缺产出位置**)已修复。 + +- **分配**:`graph-engine.advanceOne` 在发起调用之前调 `ensureProjectRoot(ctx)`,内部复用 + `devloop/path-resolver.allocateProjectRoot({company_id, mode:'sandbox'})` → + `data/companies/{cid}/projects/{pid}/`。**每个 root run 一次**,子 run / 后续节点读回同一个 + (`runRootDir` 本来就按 `root_run_id` 拼,天然共享)。`GRAPH_RUNS_DIR` 覆盖时项目根跟着挪到 + `{GRAPH_RUNS_DIR}/{rootRunId}/project`,理由与 run 目录那处 override 完全一样:否则测试每跑 + 一次就往仓库 `data/companies/` 堆一个空项目目录。 +- **存哪:没有改 schema**。记录落在 `runs/{rootRunId}/artifacts/project-root.json` + (`{schema:"graph-run-project-root/v1", pid, root_path, mode, root_run_id, company_id, + allocated_by, allocated_at}`),与 `artifacts/task-goal.json` 同类、同目录,读回路径已经通。 + `graph_runs` 不需要加列——这条信息按 `root_run_id` 唯一,加列反而要在每一行上对齐一次。 +- **进提示词**(两种节点都进,参照 `describeArtifactContract` 的做法):agent_call 节点由 + `describeWriteBoundary` 在任务描述头部拼一段 `[写入边界 —— 硬约束,越界会被判失败]`; + subgraph 节点(起的是 `workgraph-planning`,同样是一次真 CLI 调用、同样会写盘)由 + `buildPlanningPrompt` 的「## 写入边界(硬约束)」拼同一份约束。两处都给绝对路径,并明说 + 「进程 cwd 不是项目根,别用相对路径」。`callAgent` 的 `metadata.projectRoot` 是它的机器可读副本。 +- **四环闭合**:分配(`ensureProjectRoot`)→ 传递(`ctx.projectRoot` → 提示词 + metadata)→ + 落库(`project-root.json`,`wx` 独占创建,并发下先到先得)→ 读回(下一个节点 / 子 run + `readProjectRootRecord`)。timeline 上有 `node.boundary` 一条「本次任务的项目根:…(sandbox)」。 +- **实测**(mock 模式 + 默认执行器,未起真 CLI):单节点 run 跑完 → + `data/companies/{cid}/projects/p-xxxxxx/` 被建出、`project-root.json` 内容正确、节点提示词里 + 有该绝对路径、宿主仓库 `git status` 无新增。测试 `server/tests/unit/graph-boundary.test.js`。 + +#### W3 落地记录(部分完成,**不是沙箱**) + +真 CLI 是子进程直接写盘、还带 `--dangerously-skip-permissions`,**引擎不可能在写发生的那一刻 +拦住它**。所以本轮落地的是「提示词声明(调用前)+ 事后取证 + 结算拒收」三件,能力边界如下, +不要当成「产出目录已被隔离」。 + +**能拦住的** + +1. **宿主仓库内可见的越界写**:`advanceOne` 在调用前后各跑一次 `git status --porcelain` + (cwd=仓库根),差集非空 = 本次调用在仓库里留下了痕迹 → 记一条 `node.boundary` + (`violation:'boundary_escape'` + 改动清单,最多 20 条)并**把结果降级成 `fail`** + (`success` 也降级;`fail_reason` 保持 `null`,走兜底边——引擎不发明 fail_reason)。 + 这正好覆盖已经实际发生过的那次事故(写 `server/routes/echo.js`、改 `server/index.js`)。 + executor 抛异常那条路径同样查(先写坏再崩,痕迹一样在)。 +2. **敏感路径**:项目根分配后过 `path-resolver.isSensitivePath`,命中则拒用并留痕 + (这是唯一一道**调用前就强制**的守卫)。 +3. **mock 产物越界**:`mock/files` 事件里逃出节点产物目录的路径照旧被丢弃(原有行为)。 + +**拦不住的(明确未解决)** + +1. **宿主仓库之外的写**完全看不见:`~/Documents/别的项目`、`/tmp`、`$HOME` 下任何路径。 +2. **被 `.gitignore` 忽略的路径**看不见(含 `data/companies/`、`*.log`)。 +3. **调用前就已 modified、调用中又被改一次**的文件:porcelain 行不变,看不见。 +4. 越界的写**已经发生**,守卫只拒收结果,**绝不自动回滚**——回滚等于替开发者丢弃未提交的 + 改动,代价方向比误判更贵。清理靠人工看 timeline 里的清单。 +5. **误判**:开发者本人或并行的另一个 agent 在同一时刻改仓库,会被算到本节点头上。 +6. 守卫只在**会真起 CLI 的执行器**下开(默认 executor / 默认规划器);测试注入的假 executor + 不扫(进程内函数,扫仓库既无意义又要给每个用例加两次 git 调用)。自定义 executor 若自己 + 起子进程,不在守卫范围内。 +7. `git` 跑不起来(非 git 仓库 / 超时)时守卫**自动降级为不检测**,只留一条 + `degraded:'boundary_guard_unavailable'`。 + +**真正的隔离还没有做**:最强的一招是让 CLI 的 cwd 落在项目根——但 `agent-manager._runViaProtocol` +的 `effectiveWorkDir = agent.workDir || this.workDir`,动态解析出来的 agent 一律用自己的目录, +`callAgent` 没有透传 cwd 的口子;改它不在本轮范围。要么改 `agent-manager` 加 cwd 透传, +要么上容器 / 文件系统级沙箱。**在这一条做掉之前,真跑仍应在可丢弃的工作副本里做,不要在主仓库上跑。** + +> 本轮只动了 `graph-engine.js` 的 W2/W3 部分;§八 提到的 `FAILING_VERDICTS` 白名单反转**没有做**, +> 那条仍然待办。 + +#### 第一批终验记录(独立复核,未起真 CLI) + +**结论:W1 / W2 / W4 三项到位;W3 按其自述「部分完成」到位。但第一批合起来仍然 +不足以支撑一次真 CLI 端到端跑 —— 断点不在第一批,在 §三 第 6 条(归 W5/W6)。** + +| 项 | 复核方式 | 结果 | +|---|---|---| +| 全量测试 | `npm run test:unit` | **736/736 绿,68 文件**(起点 719/66,新增 17 条,无改期望) | +| 前端 | `npm run web:build` | 通过 | +| W1 启动接线 | 读 `server/index.js` `start()` / `agent-server.js` | 均在 `listen()` 前调 `runStartupSeedSafely()` ✅ | +| W1 库状态 | 生产 `data/graph.sqlite` 实查 | `company_hired` **0 行**,`template` 45,`global_agent` 8,`verifyCounts().ok=true`,`PRAGMA foreign_key_check` 空 ✅ | +| W1 新模板自动进表 | `graph-seed-service.test.js` hermetic 用例 + 生产库重跑 `seedTemplates()` 幂等 | ✅ | +| W1 占位 id 回收 | 三条判据 + 就地转正(保留行 id 保外键) | ✅ | +| W2 四环 | 读码 + `graph-boundary.test.js` | 分配→提示词/metadata→`project-root.json`→读回,四环闭合 ✅。子 run 经 `createRun` 继承 `root_run_id`,`runRootDir` 同目录,跨层共享同一个根 —— 已核实 | +| W3 守卫 | 读码 + 变异 | 判据 `diffRepoStatus` 有测试;**降级路径(success→fail)无任何测试**,见下 | +| W4 模板改动 | 读三份 agent.md | ✅ 无 `verdict: "fail"` 残留 | +| W4 防线有效性 | **三次反例变异** | 全部变红且报出精确行号:① `benchmark-validator` 改回 `"fail"` → `agent.md:60 → verdict: "fail" 不在引擎取值域内`;② `org-validator` 的 `audit_verdict` 改回 `verdict` → `agent.md:183`;③ 造 `verdict: "ok"` → `agent.md:59`。变异后已逐字还原 ✅ | + +**W3 文档与代码一致性核查(本轮重点)**:`§四 W3` 与 `applyBoundaryGuard` 的注释 +**没有把做不到的写成已解决** —— 状态标 `⚠️ 部分`,7 条「拦不住」逐条列明, +并写了「在 cwd 隔离做掉之前,真跑仍应在可丢弃的工作副本里做」。与代码一致。 +两点补充: + +1. **「正好覆盖已发生过的那次事故」这句在当前工作树里只成立一半。** 事故是 + *写 `server/routes/echo.js`(未跟踪)+ 改 `server/index.js`(已跟踪)*。 + 而 `server/index.js` **此刻本来就是 modified**(W1 的改动),命中「拦不住」第 3 条 + (调用前已 modified、调用中再改一次 → porcelain 行不变)。所以现状下守卫只看得见 + `?? server/routes/echo.js` 那一半。这不是代码 bug,是两条自述互相打架,措辞该收敛。 +2. **降级路径零测试覆盖。** `applyBoundaryGuard` 有真实调用方(`advanceOne`),不属于 + 「零调用方」那一类;但 `boundaryGuardApplicable` 要求执行器 `=== defaultAgentExecutor`, + 而单测一律注入假 executor,`applyBoundaryGuard` 又没 export —— + **「越界 → success 降级成 fail」这条唯一有实际后果的分支,只能靠真 CLI 才走得到。** + `graph-boundary.test.js` 测的是纯函数 `diffRepoStatus`,不是接线。 + → **已在「W12 补完」那一轮补上**:`applyBoundaryGuard` 导出 + 多一个可选的 `after` + 快照参数(生产路径仍只传三个参数、仍现拍 `hostRepoStatus()`),判罚三条分支各一例。 + 仍需真 CLI 验的只剩「git 扫描能不能真的扫到那次越界写」。 + +#### 阻断真跑的真正断点(§三 第 6 条,归第二批 W5/W6) + +`AgentManager.getAgentConfig()` 解析裸名时只查 `system/agents/`,**从不查 `system/templates/`** +(`agent-manager.js:141`);带公司前缀的 `{cid}:{id}` 才查 `data/companies/{cid}/agents/`。 +而 `resolveAgentName()` 只做「目录存不存在」的判断,**不做按需 hire**,查不到就退回裸名。 + +实测(纯文件系统解析,未起 CLI): + +``` +workgraph-planning THROWS: Agent not found: workgraph-planning +prd-planning THROWS: Agent not found: prd-planning +sdd-planning THROWS: Agent not found: sdd-planning +benchmark-validator THROWS: Agent not found: benchmark-validator +testdata-validator THROWS: Agent not found: testdata-validator +validator-triager THROWS: Agent not found: validator-triager +org-validator THROWS: Agent not found: org-validator +master RESOLVED +``` + +`workgraph-planning` 在真链路里能过,只因为 `resolvePlanningAgentName()` 单独给它做了 +按需 hire;**它规划出来的每一个 work 节点引用的本体都没有这层保护**。 +所以一次真跑的必然走向是:org 节点 → 规划成功 → 第一个 work 节点 `Agent not found` → 节点判失败。 +**这正是 W5/W6 要解决的问题,第一批四项做得再对也绕不过去。** + +> **W5/W6 落地后的复测**:把 `prd-planning` / `benchmark-validator` / `validator-triager` +> 三个本体 `hire` 进一家探针公司后,`getAgentConfig(':')` 由 `THROWS` 变 **RESOLVED** +> (裸名照旧 `THROWS`,符合预期——引擎走的是作用域名)。断链的解法确认就是「先雇」。 +> 详见下方「W5 / W6 落地记录」。 + +> 推论:**现在起真 CLI 不会产出新信息** —— 断点已被零成本静态证明, +> 而按 §五 的量级,跑一次是 $3-8 / 15-40 分钟。应先做 W5/W6 再真跑。 + +#### 第 12 处「四环漏一环」:`outcome.output` 全程被丢 + +`graph_run_nodes` **没有 `output` 列**(`NODE_PATCH_COLUMNS` 只有 9 个键,不含 `output`), +`settleNode()` 也不把 `outcome.output` 传给 `transitionNode`,`node.output` 这条 SSE 事件的 +data 里同样只有 `status/fail_reason/output_ref/child_run_id`。于是: + +**产生(executor / 守卫 banner)→ 传递(`outcome.output`)→ 落库(无)→ 读回(无)**,第三环断。 + +三个写入方全部落空:`defaultAgentExecutor`(:370/:386,存量)、W3 的越界清单 banner(:2538,本轮新增)、 +以及**最要命的那个**—— + +```js +} catch (err) { + outcome = { status: 'fail', output: err.message, failReason: null, tokens: err.tokens || 0 }; +} +``` + +`advanceOne` 里 executor 抛异常这条路径:`err.message` 写进 `outcome.output` 后**人间蒸发**, +既不落库、不进 SSE、也不写 timeline,`fail_reason` 还是 `null`。 +**操作者只会看到一个节点 fail、没有任何原因。** + +严重性判断:W3 banner 那一份是冗余的(越界清单在 `node.boundary` 的 timeline 里有 `entries`, +证据不丢);**但异常那一份是净损失**,而且正好砸在下一步最需要它的地方—— +上一节那个 `Agent not found: prd-planning` 就是从这条路径抛的,真跑起来将表现为 +「节点失败,无原因,无痕迹」。建议在 W5/W6 之前先补一条 timeline(最小改动:catch 里加 +`timelineNote(ctx,'node.error',err.message,{...})`),否则真跑的排障成本会被这一环吃掉。 + +> **W12 · 本轮状态:✅ 已修(选「只留痕,不加列」)** — `server/services/graph-engine.js` +> +> - **不加 `output` 列**(`graph_run_nodes` 保持 16 列不变)。理由:`outcome.output` 长度无上限, +> 而 `getRunState` 是 `SELECT *` 全表读回,控制台每次轮询都会把所有历史输出重传一遍; +> 完整产物已有地址(`output_ref` → `nodes/{nodeRunId}/`),再存一份就是第二个真相源; +> `fail_reason` 是**选边的匹配键**,更不能借来装自由文本。 +> - **catch 那份净损失补上**:executor 抛异常时发 `node.error`(`timelineNote`),带 +> `node_run_id / node_name / attempt / node_kind / system_agent_id / error / error_type / error_stack`(栈顶 6 行)。 +> 四环 = 产生(catch)→ 传递(`timelineNote`)→ 落库(`timeline.ndjson` + `stream.log`,append-only) +> → 读回(`GET /api/graph-runs/:id/stream?lastEventId=0` 全量重放)。 +> - **`outcome.output` 也终于有了消费方**:`node.output` 事件 data 加定长 `output_excerpt` +> (≤600 字符,超长截断并注明「完整输出见 output_ref」),三个产生方(`defaultAgentExecutor` +> 的 `res.output`、W3 越界 banner、catch 的 `err.message`)一并接上。前端 `NodeRunLog` +> 已按 `data.node_run_id` 消费 `node.output`,无需改动即可看到。 +> - W3 banner 依建议**去掉重复的改动清单**(清单仍在 `node.boundary` 事件的 `entries` 里), +> 只留「为什么被判 fail」一句,免得 20 行清单把 agent 真实输出挤出摘要窗口。 +> - **仍需前端配合一行**:`web/src/components/graph-console/GraphRunPane.vue` 的 +> `es.addEventListener` 白名单里没有 `node.error`(命名事件不会走 `onmessage`), +> 加上 `'node.error'` 这条错误才会出现在浏览器的事件轨;不加也不丢数据(在 +> `timeline.ndjson` / `stream.log` 里),只是控制台看不到。 +> - 锁:`server/tests/unit/graph-engine-node-error.test.js`(4 例)。 + +> **W12 补完 · 本轮状态:✅ 已修(`fail_reason` 那一半)** — `server/services/graph-engine.js` +> +> 上一轮只做了「原因文本进 timeline / `output_excerpt`」,`graph_run_nodes.fail_reason` 与 +> `node.output` 的 `fail_reason` 仍然恒 `null`。**真跑与界面走查确认的后果**:点开失败节点, +> **概览 tab 第一眼是「失败、原因 —」**,要知道去翻第三个 tab(运行日志)才看得到 +> 「Agent not found: prd-planning」。「引擎判定的失败」与「agent 归因产出的失败」在这一列上 +> 被区别对待,没有道理——本轮把三类引擎自判失败都署上名。 +> +> - **取值:另起一个集合 `ENGINE_FAIL_REASONS`,不动 `ATTRIBUTION_REASONS`** +> (`{node_error, boundary_escape, child_run_failed}`)。理由三条,逐条写在引擎的注释里: +> ① `ATTRIBUTION_REASONS` 是 `readNodeProduct` 的**入口白名单**,管的是「agent 在 +> `attempt-{n}.json` 里允许写哪几个词」;往里加取值等于放宽**别人**的取值域, +> 而本轮要落的是**引擎自己**的判定,两件事不共用一个白名单(也意味着 agent 仍然不能 +> 自称 `node_error`,写了照旧被降级成 `null` + 一条 `invalid_fail_reason`)。 +> ② 复用现成四个取值更糟:`infra_flake` **有引擎特判**(原地重跑、不烧任何计数), +> `Agent not found` 这种确定性失败判成它会原地重跑到预算耗尽;`impl_bug` / +> `input_underspecified` 是图上**精确 fail 边**的匹配键,典型图里指向「退回 impl 重写」 +> 「退回 planner 补输入」,把「本体没雇进公司」判成 `impl_bug` 会让引擎真的退回去重写代码, +> 而根因一行代码都不在那儿——**比 `null` 更坏**。 +> ③ 新取值的**路由行为与今天的 `null` 完全一致**:`selectFailEdge` 精确匹配不到就退兜底边, +> 现有图骨架没有任何边写这三个词。**只加信息,不改选边。** +> - **三个产生点**:`advanceOne` 的 executor catch → `node_error`;`applyBoundaryGuard` +> 判越界 → `boundary_escape`(agent 自己给了归因则尊重它,越界只是附加事实); +> `deriveChildOutcome` 子 run 收敛为 fail 而子图一个归因都没有 → `child_run_failed` +> (**只是兜底**,真有更具体的根因一律优先透传)。 +> - **四环**:产生(引擎判定)→ 传递(`outcome.failReason` → `settleNode`)→ +> 落库(`graph_run_nodes.fail_reason`,与状态**同一次条件更新**)→ +> 读回(`getRunState` → `GET /api/graph-runs/:id` → 概览 tab;同一份值也进 `node.output` 事件 data)。 +> 读回这一环本来就通(`getRunState` 一直在读这一列),断的只有第一环。 +> - **前端不需要改**(名字订正:实现叫 `failInfo`,不是 `failSignal`;见下方 W12-UI 落地记录): +> `NodeDetailPanel.vue` 的 `failInfo` 第一条判据就是 +> `node.failReason`(W12-UI 那一轮已铺好),本轮起它会真的有值。可选优化:给这三个词 +> 加中文标签(「执行器异常 / 越界写入 / 子图失败」),纯展示层,不加也不缺信息。 +> - **顺带补上第一批终验点名的那处零覆盖**:`applyBoundaryGuard` 现在导出,并多一个 +> **可选的 `after` 快照参数**(生产路径一字未变,`advanceOne` 仍只传三个参数)。 +> 「越界 → success 降级成 fail」这条唯一有实际后果的分支,从此不必起真 CLI 就能验。 +> - **改了两条既有断言**(`graph-engine-node-error.test.js:132/163`: +> `expect(node.failReason).toBeNull()` → `toBe('node_error')`)。它们锁的正是本轮要改的行为, +> 不是被绕过的期望;两处都留了说明为什么改。 +> - 锁:`server/tests/unit/graph-engine-fail-reason.test.js`(13 例,含四处产生点各自的变异验证)。 + +#### W12-UI 落地记录(✅ 已完成;mock 服务 + 浏览器实点走查,未起真 CLI) + +只动前端四个文件 + 一个新模块:`web/src/components/graph-console/{node-signals.js(新), GraphRunPane.vue, +NodeDetailPanel.vue, NodeRunLog.vue}`、`web/src/components/WorkGraphCanvas.vue`、`web/src/views/GraphConsole.vue`。 + +**先修的是一个更靠前的断点:整类事件根本没到浏览器。** +`GraphRunPane.connect()` 里只 `addEventListener` 了 6 个事件名,而 EventSource 的 `onmessage` +**只接没有 `event:` 字段的帧**。引擎实际发 16 种,于是 `node.error`(执行器异常的原因)/ +`node.triage`(归因判词)/ `node.boundary` / `node.contract` / `node.subgraph` / +`loop.terminated` / `loop.cross_layer` / `plan.*` **全部被浏览器丢弃** —— +stream.log 里有、timeline 里有、界面上一条都看不到。名单收敛成 +`node-signals.js` 的 `RUN_EVENT_KINDS`,由 `GraphRunPane` 唯一消费。 + +**① 概览 tab 的失败原因:三级回落 + 来源标**(`NodeDetailPanel.failInfo`) + +| 级 | 来源标 | 取值 | 什么时候走这一条 | +|---|---|---|---| +| 1 | 引擎判定 | `node.failReason`(`graph_run_nodes.fail_reason`) | 引擎判出了根因(W12 补完那一轮起会真有值) | +| 2 | 归因产出 | 本轮 `node.triage` 事件的 `fail_reason` | 归因文件里有,但还没落到节点行上 | +| 3 | 摘自输出 | `node.output.output_excerpt` / `node.error.error` / 越界横幅 | **`fail_reason` 合法地为 `null`**(= 没匹配上精确 fail 边,走兜底边)时唯一能说清「到底出了什么事」的东西 | + +`fail_reason=null` 在这套引擎里是常态而非异常,所以旧版「只读 `node.failReason`,取不到显示 `—`」 +等于让失败节点常态显示成「失败、无原因」。现在 null 也会写明「没有匹配上精确 fail 边 → 走兜底边」, +并把 excerpt 以终端块展示。`remediation_note` 同款回落(节点行 → `node.triage`), +「输入输出」tab 的 `consumedSource` 一并接上这条链。 +**与并行的 W12 引擎侧解耦**:引擎给了 `fail_reason` 走第 1 级,没给走 2/3 级,两条路都实测过。 + +**② 单步进度感:只报真实信号,不画进度条** + +- **画布节点卡片**:RUNNING 节点右下角 `⏱ mm:ss` 秒表(`WorkGraphCanvas.elapsedLabels`), + 状态灯改成呼吸点。秒表单独一个 computed,**不塞进 `laidOut`** —— 否则整张图的拓扑排序 + 回退边分道 + 每秒重算一遍。ticker 只在真有节点 running 时转。 +- **详情面板**:tabs 之上一条常驻「运行中 mm:ss + 最近事件摘要(N 前)」,四个 tab 都看得见。 +- **`duration` 以前是死的**:它用 `Date.now()`,而 `Date.now()` 不是响应式的, + 节点在跑的那几分钟里「耗时」一格是冻住的。现在挂在只在 running 时转的 ticker 上。 +- **明确不做假进度条**:引擎不知道 agent 还剩多久(CLI 不上报进度),面板上有一句 title 写明这件事。 +- 真 CLI 单节点期间常常**几分钟一条事件都没有**,所以「最近事件」多半停在 `agent.call` — + 秒表 + 「N 前」正是为这种真空设计的,不是补充。 + +**③ NodeDetailPanel 不跟 SSE 走(doc 记的已知缺陷):根因与修法** + +根因不在 SSE,在 `GraphConsole.vue` 的分发:`onMidState` 会把 `selectedNode` 指到最新快照, +**`onRootState` 完全不管**。而左栏(org run)选中的节点走的正是 `onRootState` 这条路 —— +于是左栏已经 `RUNNING·#2`,右栏还是上次点进去时的那张快照,要点别的节点再点回来才刷新。 +修法是加一个 `selectedFrom: 'root' | 'mid'` 记住选中节点属于哪一层,两栏的刷新各自喂回右栏。 +同一个字段顺带修掉两处串层:`loopRecords` 与 `events` 以前**恒取中栏那份**, +选中 org node 时读的是另一张图的账本与事件轨。另外,org node 是被拾取时才现规划出 work graph 的, +子 run 一出现就把中栏指过去(仅在下钻栈为空时,不抢用户位置)。 + +**顺带**:`NodeRunLog` 的日志行以前回落成 `JSON.stringify(data)`(SSE 帧里**没有** summary, +引擎只把它写进 timeline.ndjson),现在由 `describeEvent` 按事件类型拼人话。 + +**四环自查**(读回那一环是本轮的全部):产生(引擎 `emitEvent`/`timelineNote`)→ +传递(stream.log + SSE 具名事件,**本轮修的就是这一环漏了 10 种事件名**)→ +落库(stream.log,`lastEventId=0` 全量重放)→ 读回(`node-signals` → 面板三处)。 + +**验证** +- `npm run web:build` 通过;`npm run test:unit` **786/786 绿,76 文件**(本轮新增 4 例,无改期望)。 +- 锁:`server/tests/unit/graph-console-node-signals.test.js`。第一例是**防线**: + 从 `graph-engine.js` / `graph-run-sse.js` 的源码文本扫出引擎实际发的事件名, + 与 `RUN_EVENT_KINDS` 双向对账(扫不到 8 个以上直接抛,不静默空跑)。 + 变异验证:从名单里删掉 `node.error` → 立刻变红并报出精确名字。 + ⚠️ **引擎将来新增事件名,这条会红** —— 那正是它的用途,修法是把新名字加进 `RUN_EVENT_KINDS`。 +- 浏览器实点走查(`LEMONBOSS_CLAUDE_MODE=mock` 起 3010 + vite,临时公司 `w12ui-demo`, + 一个 running 节点 + 一个 fail 节点 + 手工写入的事件轨): + 秒表 5m29s → 5m49s 真的在走;画布卡片上 `⏱ 5m 58s`; + 失败节点概览依次显示「摘自输出」→(补一条 `node.triage` 后)「归因产出 contract_violation」→ + (落库 `fail_reason` 后)「引擎判定 infra_flake」,三级回落全部走到; + `POST /nodes/:id/retry` 后右栏**没有任何点击**就从 `FAIL·attempt 1` 变成 `READY·attempt 2` + (即缺陷③已修);运行日志 tab 的 `node.error` / `node.triage` 两类**第一次出现在界面上**; + 无 console 报错。走查完临时公司已按收尾要求删净(`graph_runs` / `graph_run_nodes` / + `org_graph_*` / `system_agents` 全部 0 行,`PRAGMA foreign_key_check` 空,目录已 rm)。 + +**未做 / 已知上限** +- `agent.call` 与 `node.output` 之间那几分钟仍然**没有任何中间信号**,因为引擎没把 + `onAcpEvent` 转成 `agent.chunk` / `agent.tool` 事件(`NodeRunLog` 里那条黄框写的就是这件事)。 + 接上之后「最近事件」会自然变活,前端不用再改。 +- 事件缓冲 `MAX_EVENTS=400`,长 run 会丢最早的行(原有行为,未动)。 + +### 第二批 · 招聘走方案 a + +| # | 内容 | 文件 | 量 | +|---|---|---|---| +| **W5** ✅ 真跑已验(§六bis) | `workgraph-planning` 模板改语义:撤掉「你不招人也不辞人」,加招聘步骤,`tools.md` 给 `company hire` 用法 | `system/templates/workgraph-planning/**` | 小 | +| **W6** ✅ 真跑已验(§六bis:先雇后提交,图未外键挂) | 写死顺序:**先雇、再提交图**(反了必外键挂);雇失败的重试与降级 | 同上 | 小 | +| ~~**W7**~~ ✅ 已修并真跑复验(`58a2865`「W7 零边图」;§六ter 验收 7 / 2026-09-08 浏览器真跑肉眼确认,见下方「W8 浏览器真跑验收」第 2 点) | **work 层招聘不得污染 org graph**:`hire()` 现在会把任何被雇 agent upsert 成一行 `org_graph_nodes`。方案 a 下每次任务规划都雇人,org graph 会被越塞越满(界面走查已实际撞到)。让这类招聘不写 org 节点,或写了打标区分 | `server/services/company-service.js`、`server/services/graph-service.js` | 中 | +| **W8** ✅ 真 CLI + 真实浏览器已验(2026-09-08,见下方「W8 浏览器真跑验收」) | 控制台加「重新规划组织」按钮,调 `POST /:companyId/org-graph/replan`(R-18.1)。该端点已实现,全仓库无前端调用方 | `web/src/views/GraphConsole.vue` 或 `CompanyDashboard.vue` | 小 | + +#### W5 / W6 落地记录(模板层已改;真 CLI 已验,见下方标注) + +改的是 `system/templates/workgraph-planning/` 三份:`agent.md`(主体)、`tools.md`、`identity.md`。 + +- **语义反转**:`tools.md` 的「你不招人也不辞人」那一行**已删**,替换成「你只招不辞」+ 一段明写 + 「旧提示词里那句已作废,以本节为准」的警示(老话可能还留在别处的上下文里)。 + `agent.md` 身份段、角色边界表、`identity.md` 同步改成「图里引用到的本体由你雇」。 +- **顺序写死**:工作流从七步改八步,**Step 6 招聘 → Step 7 提交图**,Step 6 开头一张表列出 + 「反了会怎样」的两条后果(`work_graph_nodes.system_agent_id` 外键 / 节点跑起来 `resolveAgentName` + 找不到公司目录退回裸名),并写明「都发生在你已经下班之后,补不了」。八步表 + 思维隔离自查第 9 条 + 两处复述同一条顺序。 +- **失败分级**:`status` 四档(`hired_from_template` / `already_exists` / `hired_from_base` 三档继续, + `training` 一档**不算成功**——脚手架路径不写 `system_agents`,图提交必被 `rule=reference` 打回); + 出错三级(瞬时故障重试 3 次 → 供不出来就换本体、同一本体最多换一次 → 都不行就**停**, + 不许提交引用了没雇上本体的图)。 +- **`already_exists` 的死角也写进去了**:它是提前返回,**不补 `system_agents` 那一行**。 + 所以「目录在、表里没有」这种组合下重雇永远只返回 `already_exists`,`rule=reference` 修不好—— + 模板要求直接换掉该本体并点名汇报,避免在这里死循环。 +- **提示词组装的实测结论(决定了内容该写在哪)**:图执行侧走 + `graph-engine → AgentManager.callAgent → PromptBuilder.build`,而 `build()` 只拼 + **`agent.md` + `company-context.md`**,**`tools.md` / `soul.md` / `identity.md` 都不进这条提示词** + (`packages/agent-core/Agent.js` 那套读六个文件的 bootstrap 是另一条链路)。 + 所以招聘所需的一切——命令行、参数表、curl 降级、四档判读、三级分级——**全部内联在 `agent.md`**, + `tools.md` 只作为人类可读的重复件。 +- **实测(未起真 CLI,临时 `GRAPH_DB_PATH` + 用完即删的探针公司)**: + `hire` 三个 work 层本体均 `hired_from_template`,重雇均 `already_exists`(幂等成立); + 雇后 `AgentManager.getAgentConfig(':')` **RESOLVED**,裸名仍 `THROWS: Agent not found` + —— 与 §三 第 6 条的断链分析完全一致,**「雇进公司」正是解法**。 + 另用 `PromptBuilder.build()` 实拼一次 `workgraph-planning` 的 system prompt(20.8k 字符), + `company hire` / `--role` / `先雇` / 三个 `status` 取值 / `ls data/companies/` 全部命中。 +- ~~仍未验:真 CLI 下 agent 是否照着做~~ ✅ **已验**(§六bis「`workgraph-planning` 真跑规划 + 自雇 + (W5/W6)」):真 Claude CLI 下它确实先 `hire` 两个被引用的本体、再提交图,图提交没有外键挂—— + 这条写这一段时还没跑,后来第三次真 CLI 终验补上了,见 §六 验收标准第 6 条。 +- **`--layer` 不是模板的入参**:模板明写「别自己发明这个参数」(`parseArgs` 严格模式,多给未知选项 + 会让整条命令报错)。work 层招聘写不写 org 节点由服务端判据决定,见 W7。 + +#### W7 落地记录(选「不写 org 节点」,判据在服务端;真跑未验) + +- **选「不写」而不是「写了打标」**:打标要么在 `org_graph_nodes` 加列(schema 归 `graph-db.js`)、 + 要么在 `readGraph`/`getGraph` 上加过滤(归 `org-graph-service.js`),两处都不在本轮可动的文件里; + 更关键的是**这行不只是画图用**——`work-graph-db.js:473` 起 org run 时读的就是这张表的 active 行, + 混进去的 work 层本体会变成一个**真的会被执行**的 org 节点。所以不是「显示时过滤掉」够用的问题。 +- **调用方不表达层级,判据只看数据**:唯一会雇 work 层本体的真实调用方是 + `POST /api/company/:companyId/hire`(`workgraph-planning` 在 CLI 里 curl 它), + 该路由按字段解构 body、不透传未知字段,所以「加个 `opts.layer` 参数」在本轮会是一个 + **永远没人传的死开关**(第一版就是这么写的,已删)。何况「谁雇的」本来也不该决定 + 「它是不是组织节点」。`company-service.decideOrgNodeRecording()` 三条判据,命中任一不写 org 节点: + ① `config.type ∈ {meta-bootstrap, meta-planning}`(`orggraph-planning` / `workgraph-planning` / + `brd|prd|sdd-planning` / `goal-benchmark`);② 本体在 `system/work-graph-templates/*.json` 里被 + 点名当节点(`planner-validator` / `validator-triager` / `benchmark-validator` / `testdata-validator` + ——它们 `type='validator'`,判据①盖不住;`org-validator` 只作为 `ownerOrgNode` 出现,不会被误伤); + ③ 公司已有**成型**的 org graph(存在 active 的 `org_graph_edges`)而该角色不在图上。 +- **判据③为什么看边不看节点**:P1/P2 的 `hire` 自己就会造节点,拿「有没有节点」当信号会把 + 「同一家公司连雇两个人,第二个不入图」——`graph-agent-p1-hire.test.js` 正是这么用的——直接废掉。 + 第一版就是拿节点当信号,跑全量测试当场红了这一条,才改成看边。 +- **不写 org 节点时仍然补 `system_agents`**(`graph-service.ensureSystemAgentRow()`,从 + `upsertOrgGraphNode` 里拆出来单独导出):`work_graph_nodes.system_agent_id` 是指向该表的外键, + 少这行 W6 的「先雇再提交图」照样外键挂(W1 记过同一条失效链)。 +- **顺带修掉一个老问题**:org 节点 `role_name='org-coder'` 绑定本体 `coder` 时,在成型图上再雇一次 + `coder` 以前会多出一行同义节点,现在不会。 +- **默认行为 = 写**,P1/P2 语义在「公司还没有成型的图」这个前提下原样保住。 +- **已知残留(不提前加规则)**:① 单 org node 的最小图**没有边**,判据③不生效,这时只有 ①② 认识的 + 本体会被拦下——`executor.json` 的 `{{implAgent}}` / `{{validatorAgent}}` 现填出来的本体仍会入图; + ② `hire()` 的 `already_exists` 是提前返回,**不补 `system_agents`**(W5/W6 记录里那条死角), + 本轮**故意没补**:目录在而表里没有时插一行 `company_hired` 会**squat 掉模板 id**, + 正是 W1 清掉的那条失效链(`seedTemplates` 撞上被占用的 id 会 skip,且此时目录还在、 + 不满足悬空回收条件),代价比收益大;③ `graph-seed-service.seedCompanyAgents()`(手动脚本 + `scripts/graph-seed.js` 才跑)只镜像了判据①的 `meta-bootstrap` 那半条,跑它仍会把 work 层本体 + 塞回 org graph——启动流程不跑它,暂时无害,等它有运行时调用方时必须同步这三条判据。 +- **测试**:`server/tests/unit/company-hire-org-layer.test.js`(9 例,真 `hire()` + 从 + `org-graph-service.getGraph()` 读回,即 GET org-graph 的同一条读路径)。三条判据各做过变异验证 + (分别短路掉 → 红 3 / 1 / 3 例)。每条「不入图」都配了一条只换一个条件的正例, + 免得测试退化成「这个角色被整体拉黑」。 +- **这条备注要拆成两半看**:验收标准 7 本身(org graph 数据层不混进 work 层本体)已经真 CLI 验过 + (§六ter;另有 `company-hire-org-layer.test.js` 9 例守卫),**数据层的正确性不是悬而未决的**。 + 真正没验过的是**浏览器里点开控制台左栏、肉眼确认那一屏画的确实只有 org 节点**这个纯展示层 + 的检查——2026-09-08 一并补验,见下方「W8 浏览器真跑验收」。 + +#### W8 落地记录(按钮已有真实调用方;mock 演示仍差两环,两环都不在 UI 侧) + +- **入口两处,同一个组件** `web/src/components/graph-console/ReplanOrgGraphButton.vue`: + `GraphConsole` 左栏(有图 → 标题栏小按钮;**无图 → 左栏空态里的主操作**,没有 org graph 的公司 + 整个控制台无事可做,入口在这里必须是主按钮而不是一句「先跑 orggraph-planning」的提示语); + `CompanyDashboard` 的「🕸️ 组织流程」块(同样有图挂标题栏、空态给大按钮——新建公司后 + 用户最先看到的就是这一屏的「暂无组织流程」)。至此 `POST /:companyId/org-graph/replan` + 有了前端调用方,不再只能 curl。 +- **过渡态**:`posting` → `planning mm:ss`(按钮禁用防连点)→ `done` / `timeout`(6 分钟,可「继续等」)。 + 已有图时先 `confirm`:`writeGraph` 是整图覆盖,旧图作废。 +- **刷新:轮询 `GET /:companyId/org-graph` 的图指纹(4s),不是 SSE**。replan 内部那条 agent 对话的 + SSE 由服务端自己消费(`triggerOrgGraphPlanning` 的 fetch),浏览器没有把手,端点也不回 + `conversationId`;控制台现成的 `/graph-runs/:id/stream` 是 graph run 的事件轨,而 org graph + 规划不产生 graph run。指纹**含节点 id**(`writeGraph` 每次换新 id),否则「重规划出同一张图」 + 会被误判成超时。变化即 `emit('planned')` → 父组件重载左栏定义 + run 列表(旧 run 的节点与新图对不上,必须一起重拉)。 +- **fixture**:新增 `fixtures/devloop-mock/orggraph-planning.json`(此前回放 `default.json`, + 建公司时的自动规划是空转)。产出 `org-planner → org-coder → org-validator` + 两条 fail 回退边, + 形状对齐 `system/org-graph-templates/dev.json`。实测把它 chunks 里那段图 POST 给真正的写入方: + `{"ok":true,"nodeCount":3,"edgeCount":4}`,GET 读回四条边齐全(含 `input_underspecified` 那条)。 + 锁:`server/tests/unit/orggraph-planning-fixture.test.js`(4 例:图在 chunks 不在 result / + 形状对齐 dev.json / 过五项校验 / mock 协议按 agent id 与 `{cid}:` 作用域名都能回放)。 +- **🟡 mock 下「点一下就出图」还差两环**(真 CLI 模式不受影响,agent 自己跑 curl): + 1. **fixture 命不中**:`_sendMockPrompt` 用 `agentInfo.name` 冒号后那段查 fixture。 + `graph-engine` 传 `{cid}:{agentId}`(命中),`agent-chat.js:684` 的 `getOrCreateProtocol` + 传的是 **identity.md 的显示名**——`orggraph-planning` 的 identity.md 没有「名称:」行, + 退化成字面量 `'Agent'` → 回放 `default.json`(实测就是那两块 38 / 83 字符)。 + `orggraph-planning` 只有 chat 这一条入口。最小修法:把 agent id 传给协议 + (同时协议侧 `rawName` 要改成 `options.agentName` 优先,否则 `agentInfo.name` 恒真)。 + 2. **mock 没有落库环**:真实 agent 是 Step 6 自己 `curl -d @/tmp/x.json` POST `/org-graph` + 写进去的,而 mock 只回放 chunks/files/result,**不执行任何工具调用**,HTTP 副作用为零。 + work 层没这个问题(`extractPlannedGraph` 从 output 解析)。要让 mock 也能演示, + 需在 `triggerOrgGraphPlanning` 消费完输出后 `extractPlannedGraph` → `writeGraph`(仅当图仍为空)。 + 实测:mock 下 `POST .../org-graph/replan` → `{"status":"replanning"}`,日志 + `[Replan] ✅ 已触发 {cid}:orggraph-planning`,但 `GET /org-graph` 仍是 `{nodes:[],edges:[]}`。 + **验收标准 5 仍要留到真 CLI 那一轮验。** +- 顺带确认:`CompanyDashboard` 的「🕸️ 执行控制台」入口仍在(`goToGraphConsole`)。 +- 附带发现(与 W1 相关,正面):`writeGraph` 会给未登记的 `systemAgentId` 自动补 `company_hired` 行; + 本轮测试公司删掉后 `graph-seed-service.reconcileDanglingRows()` 一次收干净 + (`orgNodesRemoved:3 / orgEdgesRemoved:4 / agentsRemoved:[org-planner, org-coder]`)。 + +#### W8 浏览器真跑验收(2026-09-08,真 Claude CLI + 真实浏览器点击) + +**跑法**:隔离 git worktree(`.claude/worktrees/p3b-w8-browser-realrun`,跑完即删),后端 3011 / 前端 +5174(避开宿主机已在跑的 3010/5173),真实注册账号登录(此环境没有预置的 `admin/hex1234`,那是生产库 +才有的种子账号),`claude-in-chrome` 实点。 + +1. **建公司 → 自动规划**:`公司ID w8-verify-co`,创建后 `[Create] ✅ 已触发 w8-verify-co:orggraph-planning + 规划 org graph` 真起 Claude CLI,`招聘org-planner/org-coder/org-validator` 三条日志真实出现,`GET + org-graph` 读回三节点+两条 backward 边,形状与 `system/org-graph-templates/dev.json` 一致——这本身 + 就是验收标准 5 的「新建公司自动规划」那一半,此前从未真跑过。 +2. **控制台左栏(`CompanyDashboard` 的「组织流程」块)渲染正确**:刷新页面后画面上只有 + `org-planner`/`org-coder`/`org-validator` 三张卡片 + 两条回退边文字说明,没有混进任何 work 层本体 + ——这是验收标准 7 此前一直缺的**纯展示层**核实(数据层早已验过,见上方 §六 验收标准 7 备注)。 +3. **点击「重新规划组织」按钮**:服务端日志 `[Replan] ✅ 已触发 w8-verify-co:orggraph-planning 规划 org + graph`(与建公司时的 `[Create]` 是不同的日志前缀,确认点的是 replan 端点不是恰好又触发了一次建公司) + → 真起第二次 Claude CLI 会话(`Memory` 日志的 session 文件名时间戳与第一次不同)→ 完成后 `GET + org-graph` 读回的图与之前**内容一致**(仍是 org-planner/org-coder/org-validator 三节点 + 两条边)。 + **这就是 W5/W6/W8 三项加一起、也是验收标准 5/6 真正意义上第一次被真实浏览器点击 + 真实 Claude CLI + 完整跑通**。 + +**顺带验出一个此前设计笔记里没预料到的细节,如实记录,不确定的部分标清楚**: + +- **确认的部分**:`insertNode`(`org-graph-service.js:362`)是按 `(company_id, role_name)` 走 + `upsertOrgGraphNode`,重规划成同名角色时**复用同一行、同一个 `id`**(本轮实测:replan 前后 + `org_graph_nodes` 里 `org-planner`/`org-coder`/`org-validator` 三行 id 分别仍是 2/3/4,一个没变)。 + 这与 W8 落地记录那句「`writeGraph` 每次换新 id」的设计假设**不一致**——那句话写的时候可能是对的, + 实现后来演化了没有回填这份记录,又是一次「文档滞后于代码」。 +- **不确定、本轮没能坐实的部分**:`ReplanOrgGraphButton.vue` 的完成检测依赖"图指纹含节点 id" + (见上方落地记录),若这份笔记描述的机制仍然成立,"重规划出内容相同的图"这种情况下前端可能真的 + 检测不到变化、一直卡在 `planning` 态直到 6 分钟超时。本轮真跑中点击按钮后页面确实变得对自动化工具 + 长时间无响应(`CDP sendCommand` 超时、`screenshot` 反复报"页面繁忙"),**但不能排除是同一个页面 + 累积的 SSE/ACP 事件量过大导致标签页整体卡顿**(`ACP Events` 面板长时间开着、`agent_output` 事件 + 刷了几十条)——两种解释都有可能,本轮证据不足以二选一。刷新页面后数据本身是对的(第 3 点), + 所以**不是数据错了,顶多是「完成后前端没自己发现,需要手动刷新」这一类体验缺口**,不影响 + 验收标准 5/6/8 本身成立。这条列为已知的后续排查项,不阻塞本文档收口。 + +### 第三批 · 让结果可信 + +| # | 内容 | 量 | +|---|---|---| +| **W9** ✅ 已实现,✅ 真跑已验(§六ter 验收 8) | 失败上下文进提示词:`validator-triager` 的 agent.md 承诺「给你哪个节点失败、第几 attempt、失败摘要」,全仓库没有代码提供。已由 `describeFailureContext` 补上,详见下方「W9 落地记录」 | 中 | +| **W10** ✅ | ~~跨层地址要 `target_org_node` + `target_node` **两半都写**才合法~~ —— **原表述与引擎相反,已订正**:`target_node` 是唯一必填半,`target_org_node` 可选。模板已按引擎逐字改写,并补上「org node 名从哪来」。详见下方「W10 落地记录」 | 小 | +| **W11** ✅ | `system/skills/meta-*` 缺失导致 base agent 的 Hot Swap 静默空转。**选方案 (b)**:不补技能包,改成显式告警 + 如实改文档。核实出更深一层:补技能包也关不上权限闸(工具权限来自 `config.json` 的 `toolPolicy`,与 phase 无关)。详见下方「W11 落地记录」 | 中 | +| **W13** ✅ 真跑已验(§六bis:整轮真跑 git status 与基线逐字相同) | agent 运行态文件从 git 移出去(守卫误判的**一半**根因;另一半是并行 agent,见 §六bis 缺陷②,W13 管不到)| 小 | + +#### W9 落地记录(已实现;未起真 CLI,用注入 executor 验提示词文本) + +- **接在哪**:`graph-engine.advanceOne` 组好 `ctx` 之后、拼产物契约之前, + `ctx.task += describeFailureContext(ctx)`。**两种节点都拼**:`agent_call` 的 `ctx.task` + 直接就是提示词正文;`subgraph` 的 `ctx.task` 被 `buildPlanningPrompt` 原样放进 + 「## ② 任务描述」,规划本体重规划时同样需要知道上一轮死在哪。 +- **段落形状**(只在**确实有前序失败**时出现,没有就是空串,不塞空段落白烧上下文): + + ``` + [前序失败上下文 —— 引擎提供,本 run 里最近的一次节点失败] + 失败节点:**benchmark-validator**(node_run_id=grn_xxx) + 第几次尝试:attempt-1(人类计数第 2 次) + 当时落库的 fail_reason:impl_bug + 失败摘要(来自 node.output):契约非法:acceptance 缺字段…(已截断,完整输出见 output_ref) + 你若是归因节点(validator-triager):上面这个**节点名**就是 agent.md 里 `target_node` + 三个取名来源中的第 (a) 个(引擎点名的失败节点),可直接用;判断它不是真正的根因时再走 (b)/(c)。 + ``` + + 这一段正是 agent.md 那三个取名来源里**第 (a) 个**的供给端 —— 在此之前它恒为空, + 归因只能靠 (b)/(c) 猜。 +- **数据源是 `timeline.ndjson`,不是 `graph_run_nodes`**(这是本项的关键判断): + 直觉写法 `SELECT … WHERE status IN ('fail','blocked')` 在**最需要它的那条路径上恰好取不到值**—— + `benchmark-validator` 失败 → backward 边退回 `validator-triager` → + `handleBackwardEdge` 调 `resetForwardDownstream`,而 `benchmark-validator` 正是 + `validator-triager` 的 forward 下游,**被重置成 `pending` 且 `fail_reason` / `remediation_note` + 一并 `SET NULL`**。等归因节点真正跑起来时,库里已经没有任何一行记着刚才谁失败了。 + `timeline.ndjson` 是 append-only、永不改写已有行的审计轨,失败那一刻的 `node.output` + (带 W12 那一轮加的 `output_excerpt`)原样躺在里面。 + 测试里显式断言了那一刻库里确实**零失败行**、而提示词里仍然有上下文。 +- **顺带给 W12 的 `output_excerpt` 加了第二个消费方**:它不再只是「给人看的」, + 引擎自己会把它读回来喂给下一个节点。 +- **四环**:产生(节点失败时的 `node.output` / `node.error` 事件,W12 那一轮落的)→ + 落库(`timeline.ndjson`,append-only)→ 读回(`readRunTimeline` + `findLastFailure`)→ + 传递(`ctx.task` → `AgentManager.callAgent` 的提示词 / `buildPlanningPrompt` 的 ②)。 +- **已知上限(ponytail)**:每个节点起跑前读一次整份 `timeline.ndjson`(切尾 2000 行)。 + O(文件大小),而文件大小被任务级预算(十几到几十次调用)钳住,眼下无所谓; + 真要拆的话升级路径是给 `graph-run-sse` 加「按 ref 倒读最近 N 条」的接口,**不是**加一列缓存。 +- **作用域是「本 run」**:按 timeline 行的 `run_id` 过滤。子 run 里的失败不会串到父 run 的 + 提示词里(父层拿到的是 `deriveChildOutcome` 那条透传),反之亦然。 +- ~~仍未验:真 CLI 下 `validator-triager` 是否真的照着 (a) 取名~~ ✅ **已验**(§六 验收标准 8 / + §六ter):triager 判词原文引用了只可能来自提示词的内部 id(节点私有目录路径里的 + `graph_run_nodes.id`),真的照着 (a) 取了名。 + 对应验收标准 8。 +- 锁:`server/tests/unit/graph-engine-fail-reason.test.js` 后 6 例(含 backward 回退那条关键路径)。 + +#### W13 落地记录(已完成;未起真 CLI,用 mock + 服务层验) + +**根因**:system agent 的 Claude CLI workDir **就是它自己的源码目录** `system/agents//`, +而那是被 git 跟踪的源码树。它每跑一次就重写自己的 `todo.md` / `plan.md`,Claude CLI 还改 +`.claude/scheduled_tasks.json`;边界守卫扫的是整仓 `git status`,于是这些改动被归属到 +「当时正在跑的那个 work 节点」头上 —— 一个真正成功的节点被判越界 fail(W3 那次真跑的断点)。 + +**已移出 git 索引(`git rm --cached` + `.gitignore`)**,共 17 个文件: + +| 路径模式 | 判据 | +|---|---| +| `system/agents/*/todo.md`(8) | 内容是 agent 自写的任务流水(最大 112KB / `innovator-evolver`),无人工维护内容 | +| `system/agents/*/plan.md`(5) | 同上 | +| `system/agents/*/.claude/scheduled_tasks.json`(3) | Claude CLI 自己写的 cron 状态(含 `lastFiredAt` 时间戳),必然每跑必变 | +| `system/agents/*/tasks/*.json`(1) | agent 自己落的任务快照,正规任务元数据在已忽略的 `system/tasklog/` | + +**不移的**(排查后确认是源码 / 人工维护): +`system/templates/**` 全部(`todo.md` / `plan.md` 是 hire 的**拷贝源**, +`company-service.TEMPLATE_FILES` 白名单从这里取)、`cron.json`(人工配置, +`9f15ed3` 特意把 `enabled` 关成 false 防夜间 IO 崩,且只由 `PUT /api/agent/:id/cron` 显式改写)、 +`config.json` / `agent.md` / `soul.md` / `identity.md` / `tools.md` / `user.md` / `heartbeat.md`。 +已经在 `.gitignore` 里的运行态目录(`memory/` `workspace/` `conversations/` `logs/` +`system/tasklog/`)不需要动 —— `memory.md` 和 `_lessons.md` 分别落在 `memory/`、`workspace/` 下,本来就没被跟踪。 + +**缺省逻辑**:读侧唯一入口是 `agent-chat.js:loadFile()`(`readFileSync` 包 `try/catch` → `''`), +缺失只是提示词里少一段,不崩;`_copyTemplate()` 逐个文件 `existsSync` 后再拷; +新 agent 的这两个文件由 `AgentScaffold` / `_copyTemplate` 从模板生成。 +**新 clone 验证**:把这 17 个文件移走后 `LEMONBOSS_CLAUDE_MODE=mock node server/index.js` 起服务, +`/api/health` `/api/agent/list` `/api/agent/:id/files` `/api/agent/:id/cron` `/api/company/templates` +全部正常,服务端日志零 error / 零 ENOENT。 + +**`_registry.json` 的 usedCount 同类问题**:`company-service._updateTemplateUsedCount()` 每次 hire 都 +原地 +1 改写这份**人工维护**的模板清单(`template-refs.test.js` 拿它当登记表校验),于是任何一次真跑 +都留一条 `M system/templates/_registry.json`。现在计数搬到 `data/template-usage.json`(已 gitignore), +registry 里的 `usedCount` 退化成**冻结的人工基线**,读回时基线 + 增量相加,前端 badge 数字不回退。 +四环:`hire()` 命中模板分支 `_updateTemplateUsedCount()` → 写 `data/template-usage.json` → +`listTemplates()` 合并 → `GET /api/company/templates` → `CompanyDashboard.vue` / `TemplateAgents.vue` 的 +`tpl.usedCount`。跑完整套件后 registry 字节不变、usage 文件里有 11 个模板的真实计数 —— 四环 + 真调用方同时被证。 + +**`graph-engine` 的守卫豁免保留**(结论):根治只覆盖「这批已知文件」, +agent 在自己目录里新写一个没进清单的文件仍会被扫到;而且 `system/agents/*/` 里还有 +`system/agents/templates/`、`system/agents/innovator-hunt/system/templates/` 这类 +agent 路径解析错误留下的**历史目录**,说明它确实会往意想不到的地方写。留作第二道防线, +但注释要从「缓解不是根治」改成「根治已做(W13),这里是兜底」。 + +> **28 号改造后已改名并放宽**(旧名 `AGENT_RUNTIME_STATE_RE` → 现 `AGENT_REGISTRY_TREE_RE = /^system\/agents\//`): +> 本节写下这条结论时,豁免是「本体根上的五个固定文件名」;28 之后 `system/agents/` 成了 +> **agent 注册表**而不是纯源码树 —— 新本体的落点从(被 ignore 的)`data/companies/{cid}/agents/` +> 搬到了(被 git 跟踪的)`system/agents/{id}/`,于是 `hireFromBaseMetaAgent` / agent-creator +> 在运行期新建的目录会以 `?? system/agents/{newId}/` **一整条目录项**进 `git status`, +> 五文件名的名单接不住 → 一个**真正成功**的招聘节点被判 `boundary_escape` fail。 +> gitignore 永远挡不住这一类(新本体的骨架本身就是要进版本库的),所以必须在守卫侧解决: +> 整棵注册表树归 `unattributable`(留痕不判罚),判罚留给 `server/` `web/` `packages/` 这些真源码目录。 +> 两道防线因此不重叠 —— gitignore 管「运行态不进 git」,守卫管「注册表树里的合法写入不判罚」。 +> 详见 28 号设计 §5.5b。 + +**测试**:`server/tests/unit/agent-runtime-state.test.js`(6 例)—— 锁 `git ls-files` 不再返回这些路径、 +`git check-ignore` 真的挡得住、模板本体仍被跟踪、usedCount 三环 + 坏文件降级。 + +#### W10 落地记录(已完成;只改模板 + 加一个测试文件,未起真 CLI) + +**先说结论:W10 原文写反了。** 原文说「`target_org_node` + `target_node` 两半都写才合法, +只写一半会静默退化」。拿到引擎上实跑,**只写 `target_node` 是完全有效的**: + +| 环节 | 代码(`graph-engine.js`) | 实际行为 | +|---|---|---| +| 产生 | `readNodeProduct`:`normalizeNodeRef({ orgNode: doc.target_org_node, workNode: doc.target_node })` | 读的就是这两个**顶层**键 | +| 合法性 | `normalizeNodeRef`:`if (!workNode) return null` → `orgNode` 取不到就写死 `null` | **只有 `workNode` 缺失才判非法**;`orgNode` 缺省是合法的 | +| 落库 | `settleNode` → `failNodeRef` 列(与 `fail_reason` 同一次条件更新) | 存的是 `{"orgNode":null,"workNode":"…"}` | +| 解析 | `resolveCrossLayerTarget` 第 ④ 关:`if (ref.orgNode && ref.orgNode !== orgNodeName)` | `orgNode` 为 null 时**这一关根本不进**,以回退边的目标为准 | +| 读回 | `NodeDetailPanel.vue` 的 `readRef`(`v.workNode` 为真才算有地址)+ 概览渲染 `failRef.orgNode \|\| '(按回退边的目标)'` | 前端本来就按「org 那半可缺」写的 | + +**真正无声的是反过来那种写法**:只写 `target_org_node`、不写 `target_node`。 +此时 `normalizeNodeRef` 返回 null,而降级 timeline 的守卫是 `if (doc.target_node != null && !failNodeRef)` +—— 你根本没写 `target_node`,**一条记录都不会留**,控制台只显示「整张 work graph 重跑」。 +这是这条链上唯一一处完全没有痕迹的退化。 + +**改了什么**(`system/templates/validator-triager/{agent.md,tools.md}`): + +1. 粒度块的键表加了「必填?」一列,`target_node` 标 ✅ 必填、`target_org_node` 标 ⭕ 可选, + 并把「两个都给全」的措辞整段换掉(那句话会诱导 agent 去**编** org node 名, + 恰是同一份 agent.md 自己那条红线); +2. 明写反向那种写法是「全链路唯一一处完全无声的退化」,要删就两个键一起删; +3. 四道关的第 ④ 关改成「**若你写了** `target_org_node`」,与 `if (ref.orgNode && …)` 对齐; +4. **补上原本缺的那一环:`` 从哪来**。agent 的提示词里没有 org 层上下文 + (`describeTask` 只拼 task-goal + 任务标识),但 `[节点产物目录]` 那段给的是**绝对路径** + `…/data/companies//runs//nodes//`(`nodeArtifactDir` → + `runRootDir`,用的是 `run.root_run_id`,子 run 里的节点也落在根 run 目录下)。切出 `runs/` + 后面那一段就能 `GET /api/graph-runs/` 拿到**组织层**那张图: + `run.graph_kind==="org"` 确认层级,`nodes[].name` 是 org node 名、`nodes[].nodeKind` + 筛出 `subgraph`、`nodes[].childRunId` 再查一层就是可填进 `target_node` 的候选节点名。 + 原 tools.md 里那两条 curl 是对的,但 `` 一直**没有出处**,agent 填不出来。 + +**测试**:`server/tests/unit/graph-cross-layer-address.test.js`(3 例,全部走注入假 executor,不起 CLI) +—— ① 只写 `target_node` → 定点重置生效、`impl-b` 一次都没重跑、账本 `target_node_ref` 里的 +`orgNode` 由引擎补成 `coder`;② 只写 `target_org_node` → 地址整条没落库、整张子图重跑; +③ 模板里的键名与引擎逐字一致,且不许出现 `targetNode` / `target_work_node` 之类近似拼写、 +不许再出现「两个都给全」「两半都写」这两句已被推翻的措辞。 + +> 顺带说明**为什么不去修引擎那处无声降级**(给「只写了 org 半」也补一条 timeline): +> `graph-engine.js` 本轮由别的并行线独占,本轮只在模板侧堵。要修的话是把 +> `if (doc.target_node != null && !failNodeRef)` 放宽成 +> `if ((doc.target_node != null || doc.target_org_node != null) && !failNodeRef)`,**一行**,留给后续轮次。 + +#### W11 落地记录(已完成;选 **方案 (b)**) + +**选 (b) 的论证 —— 关键证据是本轮新查出来的,不是原文里那条**: + +`system/skills/` 目录确实不存在(`agent-chat.js` 的 `existsSync` 恒 false),这一点原文没错。 +但补出四个 `meta-*` 技能包**并不能兑现「EXECUTE 阶段才拿破坏性权限」**: + +- Hot Swap 做的事只有 `copyFileSync(sourceSkill, agentDir/.claude/skills/main_skill/SKILL.md)`; + 那个文件的**唯一消费者**是 `buildSystemPrompt`,被当作提示词文本拼进「我的核心主技能」一段。 + 也就是说 **`meta-*` 能改的只有话术,改不了权限**。 +- 工具权限的**唯一**来源是 agent 自己 `config.json` 的 `cli.toolPolicy`, + 由 `getOrCreateProtocol` 在**建协议时读一次**转成 `--allowedTools` / `--disallowedTools`, + 与 `phase` **没有任何关联**。 +- 更直接的反证:`system/templates/base/config.json` 现在是 + `{"mode":"restricted","allowedTools":["bash","read_file","str_replace"]}` —— + **`bash` 和 `str_replace` 从 RESEARCH 第一秒就在手上**。所以「RESEARCH 阶段照样能写文件」 + 的成因是 `config.json` 直接给全了工具,**不是**技能包缺失。 + +**收尾补一处:agent 自己的提示词还在骗它自己(2026-08-17)** + +上面查清了「技能包改不了权限」,但 `system/templates/base/agent.md` **仍然在对 agent 本人** +写着这三句: + +- 开场「你当前默认只被挂载了 `meta-research` 这个元技能」—— 那个包不存在,没挂载过; +- 阶段三「*系统给你换上 `meta-execute` 技能时,你才会拥有修改核心文件的破坏性权限*」 + —— **这是纯假承诺**,权限自始至终由 `config.json` 决定,与 phase 无关; +- 阶段一/二「给你换上 Plan 技能」—— 同上。 + +方案 (b) 的本意是「如实降级」,只把 `agent-chat.js` 的空转喊出来是喊给**运维**听的; +agent 读的是 `agent.md`,那一侧不改,模型就会继续以为「前三阶段我写不动文件」—— +而它其实从第一秒就握着 `bash` 和 `str_replace`。**一个以为自己被拦着的 agent, +比知道没人拦它的 agent 更危险。** + +已改成如实表述:开场加一段⚠️说明「技能包不存在、换装不会发生、权限全程不变、 +四段式真正给的是**上下文隔离**而不是权限隔离」,并把三处「换上 X 技能」改成 +「记忆已清空,你手上只有 」。阶段三那句改成 +「你的写入权限从第一阶段起就没变过 —— 『前三步不落盘』是纪律要求,不是系统给你的限制」。 + +至此 W11 的 (b) 才算真的闭合:**运维侧有告警、agent 侧不再被误导、文档侧记清了根因**。 +真要落地按阶段收权限,见 27-P3/P4 §九bis 的结论(必须让 `transition` 同期改写生效中的 +`toolPolicy`,且它与「四段式改写成引擎上的标准图模板」是同一件事的两半)。 + +结论:走 (a) 会造出一个**比现状更危险的状态** —— 四个文件躺着、日志说「已切换」、文档可以打勾, +而权限闸门一如既往地开着。这正是 T9 那次「落地了降级却既没核实也没回改文档」的同型。 +真要落地阶段化权限,必须让 `transition` 同时按 phase 改写生效中的 `toolPolicy`, +那与 §九bis 已列为「P3 之后」的「四段式改写成引擎上的标准图模板」是同一件事 +(改造后每个阶段是一个独立节点、独立一次 agent 调用,`toolPolicy` 才第一次有机会按阶段取值)。 + +**改了什么**: + +- `server/routes/agent-chat.js` Hot Swap 段补 `else` 分支,打一条 `console.warn`, + 写明「元技能包不存在(附完整路径)→ 本次流转**未换装** → 工具权限仍由 `config.json` 的 + `cli.toolPolicy` 决定,与阶段无关」。**只加了这一个 else 分支,别处未动。** +- `27-P3/P4` §九bis:Hot Swap 那行由「❌ 静默失效」改成「❌ 仍然失效(不再静默,改成显式告警)」, + 新增一整块「为什么不选方案 a」的论证与证据;「另两处顺带发现」(`phase` 无校验、 + 可跳阶段、直接拼进路径)标注为**均未修、仍然成立**;§九bis 末尾那三条改造收益的第 2 条 + 补上「改造后 `toolPolicy` 才有机会按阶段取值」这半 —— 那正是方案 a 单独做不到的部分。 + +**没做什么(明确不留第三种状态)**:`system/skills/` 目录仍然不存在,四个 `meta-*` 技能包**一个都没建**。 +这一维的**能力没有变化**,变的只是它不再无声。 + +--- + +## 五、成本预估 + +一次 3 个 org node 的任务:3 次规划调用 + 每张 work graph 3-4 个节点,不含回退约 **15 次调用**,带一两次回退到 **20-25 次**。按 `plan-cost-concurrency.md` 的实测量级推算,单次 **$3-8、墙钟 15-40 分钟**。 + +**调试期一律用单 org node 的最小图**(省约三分之二)。第一批的验收就按这个规模定。 + +--- + +## 六、验收标准 + +**第一批** + +| # | 标准 | 状态 | +|---|---|---| +| 1 | 服务启动后 `system_agents` 与 `system/templates/` 一致,无悬空 `company_hired` 行;新加一个模板目录、重启服务,它自动出现在表里 | ✅ **已验**(生产库实查 + hermetic 用例)。一处**已知且合理**的偏差:`system/agents/` 与 `system/templates/` 同名的 6 个 id(`agent-creator` / `org-innovator` / `innovator-{digest,evolver,hunt}` / `evolver-youtube-crawler`)按设计 skip,仍是 `global_agent`。图用到的本体都不在这 6 个里 | +| 2 | 起一个真 CLI 的 run,节点产出全部落在 `data/companies/{cid}/projects/{pid}/` 内;`git status` 在宿主仓库无任何新增/修改 | ✅ **2026-08-20 干净环境重验通过**(§六ter 验收 2):19 次真 agent 调用之后 `git status --porcelain` 完全为空,交付物全部落在 `data/companies/e2e-demo/projects/p-bkhylu/` 内。**2026-09-08 又一次独立复验**(§六quater,`accept9-mtsksam9` 那次真跑):15 次真调用、backward_count=3,全程未在宿主仓库留下任何改动。这条此前"仍未在干净环境下重验"的备注已过期,两次独立真跑都证实成立 | +| 3 | 故意让节点往产物目录外写,被守卫拒绝并在 timeline 留痕 | ✅ **真 CLI 已验**(2026-08-17,§六bis):git 扫描确实扫到了宿主仓库里的那处改动,`node.boundary` 事件带完整 `entries`,结果被降级判 fail 且 `fail_reason='boundary_escape'` 落库。**但同一次也验出了误判**:那处改动是并行的 Master agent 写的,不是被判罚的节点写的(W3「拦不住」第 5 条,见 §六bis 缺陷②)。判罚逻辑侧另有 W12 补完那一轮加的三条分支用例。**2026-08-20 补记**:误判那一半已修([27-P3C §17.2](../../done/system-design/27-p3c-goal-task-model.md),`BOUNDARY_GUARD=strict|record` 两个开关 + 8 例守卫,含「仓库干净时同样一处越界仍判 fail」的对照) | +| 4 | 造一份非法契约跑 `benchmark-validator`:判决为失败且引擎按失败处理(**不是** success) | ✅ **由零注入真实链路验掉**(`server/tests/unit/graph-real-chain-smoke.test.js` ⑥,进 CI):非法契约真实驱动到 `readNodeProduct` → `callAgentNode`,节点判 `fail`、`fail_reason=input_underspecified`、沿 fail 边点亮下游,判词落库可读回,反向验过(`verdict` 改成 `passed` 立刻红)。**这不是起了真 Claude CLI**——只换掉最外层 CLI 子进程为 fixture 回放,其余代码路径(`AgentManager`/`readNodeProduct`/`settleNode`/边界守卫)全部真实,按 27-P3/P4 §十「四条硬规矩」第 2 条的标准算「零注入」而不是「假 executor 摆出来的分支覆盖」 | + +> 2 与 3 原计划要真 CLI 才验得了,实际 2 后来靠两次独立真跑(§六ter / §六quater)验掉, +> 4 靠零注入真实链路(不需要真 Claude CLI)验掉——都已完成,不用再挪到第二批之后。 + +**第二批** + +5. 新建公司 → 界面点「重新规划组织」→ `orggraph-planning` 真跑 → org graph 出现,且它雇的三个 org 节点都在。 + ✅ **真 CLI + 真实浏览器已验**(2026-09-08,见下方「W8 浏览器真跑验收」):新建公司自动规划、 + 点击按钮触发的重新规划,两条路径都真起了 Claude CLI,`org-planner`/`org-coder`/`org-validator` + 三个 org 节点都真实出现在图里。 +6. 下达任务 → `workgraph-planning` 真跑 → 它引用的本体被自己雇进公司 → 图提交成功(不再外键挂)。 + ✅ **真跑已验**(§六bis 验收点「`workgraph-planning` 真跑规划 + 自雇(W5/W6)」):真 Claude CLI 下 + `wg_55c7e53f959f` 规划出两节点 + 正向边 + backward 兜底边,`org-writer-impl`/`org-writer-validator` + 引用的两个本体都被 `workgraph-planning` 自己 `hire` 进 `data/companies/p3bfin/agents/`,图提交没有 + 外键挂。触发方是 Master agent 的自主巡检派单,不是人工点「下一步」,走的是同一条链路、同一个端点。 +7. 上条跑完后,控制台左栏 org graph **仍然只有 org 节点**,没有混进 `prd-planning` 这类 work 层本体。 + ✅ **已修**(`58a2865`「W7 零边图」,2026-08-17 当天)。判据加了主判据 + `authored_by='planner'` 的节点存在 —— 「orggraph-planning 提交过一张图」的直接证据, + 零边也认;边数降为存量行的回落判据。守卫 `company-hire-org-layer.test.js` 的 + `CO_ZEROEDGE`(零边图上 work 层本体不入图)+ `W7-fix`(零边图上图里点名的 org 节点 + 重复雇仍入图,P2 语义不被误伤)。 + **以下是修之前的现场记录,留档不是当前状态**:三条判据都盖不住 + 「零条边的最小 org 图 + 一个既非 `meta-*`、又没在 work graph 骨架里点过名的本体」, + `readme-writer` 被真的写进了 `org_graph_nodes`,`GET /org-graph` 返回两个节点, + 下一次任务的左栏并排显示两张 org 卡片。 + 服务层那 9 例测试仍然全绿——它们造的是**有边**的成型图,正好绕开了这个形状。 + +**第三批** + +8. `validator-triager` 的提示词里能看到「哪个节点失败、第几 attempt、失败摘要」。 + ✅ **真 CLI 已验**(§六ter 验收 8,2026-08-20):判据是 triager 的判词原文引用了只可能来自 + 提示词的内部 id——「`org-coder-impl` 私有目录 `rn_96a8cd696dcc` 下只有 `_research.md`」, + `rn_96a8cd696dcc` 是 `graph_run_nodes.id`,agent 没有任何别的途径知道它,只能来自 + `describeFailureContext` 注入的那一段,即真的照着取名来源 (a) 用了。 +9. 一次真实的跨层回退:`target_org_node` + `target_node` 两半齐全,只有被点名的节点被重置。 + ✅ **真 CLI 已验,但落地形状比原表述更保守**(§六quater,2026-09-08):真跑撞上的是 + 「回退目标本身就是 org node、没有更细粒度可指」这种情形——triager 没写 `target_node`/ + `target_org_node`(图上没有 `org-coder` 可指),退化成「由 org 层 fail 边直接决定重跑哪个 + org node」,是 R-17.1 设计里最保守的一档,行为符合预期。「两半地址齐全」这个更细的子情形 + (归因精确指到某个 org node 内部的具体 work 节点)仍然只有 `graph-cross-layer-address.test.js` + 的注入式覆盖,没有真跑验过——但这不影响验收 9 本身成立(原始表述的核心诉求是「org 层回退边 + 真的能指向 subgraph 节点」,已经证实)。 + +--- + +## 六bis、第三次真 CLI 终验(2026-08-17,未改一行代码) + +**跑法**:主仓库 vite(5173,前端代码)+ **worktree** `.claude/worktrees/wf_55ba803e-5ac-6` 的真 CLI 服务 +(3010,`data/` 独立)。worktree 先 `git reset --hard 3d9cb5b`,再把主工作树里未提交的本轮改动 +(14 改 + 5 新增 + W13 的 17 个 `git rm --cached`)拷进去。公司 `p3bfin`,单 org 节点最小图。 +预算 `total_agent_calls` 实跑 2/15。跑完公司已删净(DB 四表 0 行、`PRAGMA foreign_key_check` 空、目录 rm)。 + +| 验收点 | 结果 | +|---|---| +| 建公司 → `orggraph-planning` 真跑落图并自雇(R-18.1) | ✅ 成立。创建后 1 分钟内 `org_graph_nodes` 出现 `org-writer`,`orggraph-planning` / `workgraph-planning` 按 W7 判据①**没有**混进 org 节点(日志逐条留痕) | +| 界面点「下达任务」→ 起 run(W14) | ✅ 成立。点按钮 → 弹出「任务描述 + 手动/自动」→ 点「下达」→ 新 root run `run_9da101273663` RUNNING、预算条 `calls 0/100` 出现、入口节点 READY | +| `workgraph-planning` 真跑规划 + 自雇(W5/W6) | ✅ 成立。`wg_55c7e53f959f` 两节点(`org-writer-impl`→`readme-writer`、`org-writer-validator`→`planner-validator`)+ 正向边 + backward fail 兜底边;两个本体都被它自己 `hire` 进 `data/companies/p3bfin/agents/`,图提交没有外键挂。**这一段是 Master 自主巡检下的单,不是我点的「下一步」**——同一条链路、同一个端点 | +| RUNNING 中的节点有进度感(本轮新增) | ✅ 成立。org 节点卡片 `⏱ 2m 23s` 秒表在走 + 呼吸点;详情面板常驻条「运行中 2m 40s · 最近事件 `plan.call` 调 workgraph-planning 规划子图(第 1 轮)(2m 40s 前)」;work 节点上换成 `agent.call · 调用 readme-writer(attempt #1)`;概览 tab 的「耗时」不再冻结 | +| 跑一个真 work 节点 → success | ⛔ **本轮没跑到**。`org-writer-impl` 跑了 7m19s 后 fail,真实根因是 `AgentManager._runViaProtocol` 的 `idleTimeoutMs` 硬编码 120s:`readme-writer` 通读 `graph-engine.js` 期间静默超过 2 分钟被掐死(`idle timeout: no stream data for 120000ms`)。详见下方「本轮查出的三处缺陷」① | +| 失败节点的概览 tab 能看到原因(不是 —) | ✅ 成立。work 节点与 org 节点的概览都显示 `fail_reason` = `[引擎判定] boundary_escape`,下面一块终端样式 excerpt 写明「[边界守卫] …留下 1 处改动」+ agent 原始错误串;「运行日志」tab 里 `node.boundary` / `node.output` / `node.status` 七条事件都到了浏览器(W12-UI 的具名事件白名单在真 CLI 下生效) | +| `fail_reason` 真落库 | ✅ 成立。`graph_run_nodes.fail_reason='boundary_escape'`,work 节点与**父 org 节点**两行都有(子 run 终态回写父节点那个修复没被打破)。四环(产生→传递→落库→读回)在真 CLI 下走通 | +| W13:真跑期间 `git status` 不再出现 agent 运行态条目 | ✅ 成立。整轮真跑(含 `agent-creator` / `master` 各一次真 CLI)结束时 worktree `git status` 与基线**逐字相同**;`git check-ignore -v` 四条模式全部命中;`system/templates/_registry.json` **零 diff**,3 次 hire 的计数落在已忽略的 `data/template-usage.json`。守卫这一轮**没有**用到 `splitUnattributable` 兜任何一条 agent 运行态文件 | +| W3 守卫:真 CLI 下 git 扫描能不能扫到越界写 | ✅ **扫得到**(第一批验收标准 3 从 🟡 转 ✅)—— 但扫到的是**并行的 Master agent** 写的,不是本节点写的。见下方缺陷② | +| W10 跨层地址两半齐全 | ⚪ **本轮无回退发生**(没有节点走到归因产出这一步),未验 | +| 第一批验收标准 2(产出全落项目根、宿主仓库无新增) | 🟡 **一半成立**:项目根 `data/companies/p3bfin/projects/p-24vxi8/` 真的被分配并写入(`workspace/workgraph-plan-T-*.md`、`workgraph-T-*.json`),`project-root.json` 四环闭合;但宿主仓库**有**两处新增改动 —— 来源是并行的 Master agent(缺陷②) | + +### 本轮查出的三处缺陷(都未改代码) + +**① `AgentManager` 的 120s idle timeout 掐死长思考的 work 节点** — `server/src/core/agent-manager.js:324` + +```js +const idleTimeoutMs = this.config?.agents?.[agentName]?.idleTimeout || 120_000; +``` + +同进程里 `ClaudeCliProtocol` 早就把自己的 idle 上限放到 24h(日志里那句「🔧 工具执行中 → 超时切为 86400s」), +`stream-executor` 的 JSDoc 默认值写的是 `1_800_000`(30 分钟)。这里的 120s 把两者都压掉一个数量级, +而且 `...options` 里若透传 `idleTimeoutMs` 会被下一行覆盖掉 —— 调用方无法覆写。 +真 CLI 下任何一个「先读几千行源码再动笔」的 work 节点都会在动笔前被杀,产物为空。 +**这是本轮「真 work 节点 → success」没跑成的唯一原因**,与 W1-W14 无关,属新缺陷。 + +**② 边界守卫在有并行 agent 时误判(W3「拦不住」第 5 条真实发生)** + +`org-writer-impl` 跑到第 7 分钟时,**另一个进程里的 Master agent**(`retrain-watchdog` 每 5 分钟一次的智能巡检唤起) +在同一个 worktree 里改了两个源文件: + +- `server/tests/unit/company-hire-org-layer.test.js`(+82/-6,给 W7 加了一条判据④和配套用例) +- `server/src/core/agent-manager.js`(改上面那个 idle timeout;且引用了**未定义**的 `DEFAULT_IDLE_TIMEOUT_MS`, + 真跑起来是 ReferenceError) + +守卫的 `git status` 差集把第一处算到了正在跑的 work 节点头上 → `boundary_escape` → 该节点被降级判 fail。 +两处改动已 `git checkout` 复原(diff 存档 `/tmp/master-agent-pollution{,-2}.diff`),主仓库全程零污染。 +**结论**:真跑期间不能有任何别的 agent 在同一工作树里活动,否则守卫必误判; +`retrain-watchdog` 的 Master 巡检就是这样一个 agent,它默认开着且没有开关。 + +**③ W7 的「已知残留」在真跑里被踩中,且后果比注释写的更直接** + +`decideOrgNodeRecording` 判据③要求「公司已有 active 的 `org_graph_edges`」, +而单 org 节点的最小图**零条边**,判据③恒假;`readme-writer` 既不是 `meta-*`(判据①) +也不在 `system/work-graph-templates/*.json` 里(判据②)—— 于是它被写进 `org_graph_nodes`。 +`GET /api/company/p3bfin/org-graph` 实测返回 **两个** org 节点(`org-writer` + `readme-writer`), +下一次「下达任务」的控制台左栏就并排显示两张卡片、两个都 READY、都会被 `advanceOne` 当 org 节点执行 +(截图 `screenshot-1786937704828-44.jpg`)。 +→ **验收标准 7 在「零边图」这一形状下不成立**,状态应从 🟡 改为 ⛔(下方已改)。 + +**④ 顺带:`blocked` 的 root run 让控制台变成死胡同**(前端/后端取值域不一致) + +`web/src/views/GraphConsole.vue:183` 的 `ACTIVE_RUN` 含 `blocked`, +而 `server/routes/company.js` 的 `ACTIVE_ROOT_RUN_STATUS` 只有 `['ready','running','paused']`。 +run 收敛成 `blocked` 后:「下达任务」按钮被前端藏起来(后端其实会放行), +`GraphRunPane` 的「停止」也被 `can('stop')` 置灰(后端 `POST /:id/stop` 实测能把 blocked → stopped)。 +**一家公司只要失败过一次,控制台上就再也下不了第二个任务**,只能走 API。 +按 W14 那段注释自述的意图(「按钮的显隐要和端点的 409 条件一致」),这是它自己没对齐。 + +### 本轮没做的 + +- **没点「下一步」**:断点(缺陷①)在第一个 work 节点上已经明确,再花一次 3-5 分钟的规划调用 + 只是重跑一条同一轮里已经走通的路径,按 §七.1 的成本纪律停手。 +- **没验 W10**(没有回退发生)、**没验第一批标准 4**(`benchmark-validator` 没进这张图)。 +- **没造「引用不存在本体」的失败节点**:真跑自己给出了一个更真实的失败样本 + (`boundary_escape` + idle timeout),第 6 项的两个验收点(概览有原因、`fail_reason` 落库) + 已由它满足,不必再造一个。 + +--- + +## 七、风险 + +1. **成本不可控**:真 CLI 下一次跑错方向就是几美元 + 半小时。缓解:调试期强制单 org node;任务级预算的 `total_agent_calls` 压到 15 以内先跑通。 +2. **W7 的语义决定要谨慎**:`hire()` 写 `org_graph_nodes` 是 P2 §4.2 定的(「hire 成功后写一行,幂等」),当时的前提是只有 org 节点会被雇。方案 a 打破了这个前提。改动要确认不破 P2 已有的 24 条测试。 +3. **真 CLI 会暴露一批模板质量问题**:前七轮所有 agent 模板都只在 mock 下「跑过」,而 mock 不读提示词——**这些提示词实际上从未被任何模型读过**。第一次真跑大概率会集中暴露表述矛盾(W4 就是一例,`benchmark-validator` 的 Step 表与自己的产出格式表就互相打架)。 +4. **不要在没有 W2/W3 的情况下跑真 CLI**:已经发生过一次 agent 写入宿主仓库。 + +--- + +## 八、`FAILING_VERDICTS` 要不要放宽(W4 的附带结论) + +> **`graph-engine.js` 归 W2/W3 那一轮改,本节只给结论与理由,不动代码。** + +### 结论 + +**要动,但不要用「把 `"fail"` 加进 `FAILING_VERDICTS`」这个改法——应该把判据反过来。** + +```js +// 现在(白名单记「失败词」,凡不认识的一律当通过 = fail-open) +const FAILING_VERDICTS = new Set(['attributed', 'inconclusive']); +failingVerdict = verdict === null ? raw !== null : FAILING_VERDICTS.has(verdict); + +// 建议(白名单记「通过词」,凡不认识的一律当失败 = fail-closed) +const PASSING_VERDICTS = new Set(['passed', 'skipped', 'produced', 'no_prior_failure']); +failingVerdict = verdict === null ? raw !== null : !PASSING_VERDICTS.has(verdict); +// 并对不认识的取值记一条 timeline(degraded: 'unknown_verdict'),与 fail_reason 的处理对称 +``` + +### 理由 + +1. **只补 `"fail"` 是打补丁,漏的是一整类。** 判据的形状才是缺陷:失败侧是白名单, + 于是 `failed` / `rejected` / `not_met` / `error` / `no` / `不通过` —— 任何一个模型 + 临场想出来的失败词,引擎都读成「没失败」。补一个词只堵一个洞,洞的数量是开放的。 +2. **模型不受模板约束,这一点 W4 本身就是证据。** mock 从不读提示词,所以这批提示词 + 在第一次真跑前从未被任何模型读过(§七.3)。模板只能提高写对的概率,不能保证; + 引擎是最后一道,它必须对没见过的词取安全的那一侧。 +3. **安全侧是「判失败」而不是「判通过」。** 判错成失败的代价是多跑一轮(烧计数、走兜底边, + 熔断有 `LOOP_HARD_CAP_EDGE/RUN` 兜底);判错成通过的代价是不合格产物直接放行, + 而且**不留任何痕迹**——这正是 W4 的失效方向。两种错代价不对称,判据就该偏向贵的那侧。 +4. **引擎里已有同形状的正确先例。** `fail_reason` 那一侧是封闭域(`ATTRIBUTION_REASONS`), + 自创取值 → 显式降级成 `null` + 一条 timeline,不静默相信。`verdict` 应当对称处理。 +5. **`verdict === null` 那条缺省分支不用动**:缺 `verdict` 但有 `fail_reason` 判失败, + 本来就是 fail-closed 的,改动只影响「写了 `verdict` 但引擎不认识」这一支。 +6. **代价接近零。** 通过词一共四个,现有模板全部命中(`template-verdicts.test.js` + 的 `PASSING_VERDICTS` 与它逐字一致,实测扫描无一例外)。反转后 + `attributed` / `inconclusive` 继续判失败,语义不变。 + +### 两点必须一起注意 + +- **反转不能替代 W4 的模板改动。** 反转之后 `verdict: "fail"` 会被判成失败, + 但那是「猜对了」,不是「写对了」:产出仍然带着一个引擎不承认的词, + `org-validator` 那种 pass/fail 审计词也仍然会串到判词字段上。模板必须写对,引擎兜底。 +- **反转会让 `template-verdicts.test.js` 报错,这是设计好的。** 它按名字从 + `graph-engine.js` 源码取 `FAILING_VERDICTS`,取不到就抛 + (「找不到 FAILING_VERDICTS——取值域防线失去依据,不要让它静默空跑」)。 + 真去反转的话,把该测试里 `engineSet('FAILING_VERDICTS')` 换成 `engineSet('PASSING_VERDICTS')` + 并把 `VERDICT_DOMAIN` 的组成对应改一行即可;**不要**把断言删掉了事。 + +--- + +## 六ter、第四次真跑复验(2026-08-20) + +**跑法**:主仓库、真 Claude CLI、工作区干净(`git status` 空)。公司 `e2e-demo`, +把 2026-08-18 卡住的那条 run(`run_9168bd24f46c`,做 lemonai 官网)解开后逐节点 `/next` 推到收敛。 +19 次真 agent 调用、177,462 tokens,全树 `completed`,goal → `done`。 +过程记在 [27-P3C §17.5](../../done/system-design/27-p3c-goal-task-model.md)。 + +### 逐条结果 + +| 验收 | 结果 | 证据 | +|---|---|---| +| **2** 产出落在项目根内、宿主仓库 `git status` 无新增/修改 | ✅ **成立** | 19 次真 agent 调用之后 `git status --porcelain` **完全为空**;交付物(`index.html` / `styles.css` / `app.js` / `assets/img/` / `DELIVERY.md`)都在 `data/companies/e2e-demo/projects/p-bkhylu/` 内。这是 §六bis「后半被并行 agent 打破」那一条的干净环境重验 | +| **3** 越界被守卫拒绝并留痕 | ✅ 早已成立;误判那一半 2026-08-20 修掉 | 见 [27-P3C §17.2](../../done/system-design/27-p3c-goal-task-model.md) | +| **4** 非法契约 → validator 判失败且引擎按失败处理 | ✅ **成立**,但**不是真 CLI 验的** | `graph-real-chain-smoke.test.js` ⑥(零注入链路,只换掉 CLI 子进程):fixture 产出 `verdict: "attributed"` → 节点 `fail`、`fail_reason=input_underspecified`、沿 fail 边点亮下游、判词落在下游节点行上。反向验过:verdict 改成 `passed` 立刻红 | +| **7** work 层本体不混进 org graph | ✅ 已修(`58a2865`) | `CO_ZEROEDGE` / `W7-fix` | +| **8** validator-triager 的提示词里有「哪个节点失败、第几 attempt、失败摘要」 | ✅ **成立(真 CLI)** | 判据是**它引用了只可能来自提示词的内部 id**:triager 的判词原文写着「`org-coder-impl` 私有目录 `rn_96a8cd696dcc` 下只有 `_research.md`」—— `rn_96a8cd696dcc` 是 `graph_run_nodes.id`,agent 没有任何别的途径知道它,只能来自 `describeFailureContext` 注入的那一段 | +| **9** 一次真实的跨层回退,两半地址齐全、只重置被点名的节点 | 🟡 **产出端通了,消费端没等到那个形状** | 见下 | + +### 验收 9 的准确状态 + +真跑里 triager **确实产出了两半地址**: + +``` +node.triage | 归因 verdict=attributed fail_reason=impl_bug → {"orgNode":"org-coder","workNode":"org-coder-impl"} +``` + +这一条本身有分量:[27-P3/P4](./27-p3-p4-work-graph-console.md) §八.22 的**第 13 处**记的正是 +「引擎四环全通,但产出方 `validator-triager` 的 agent.md 里根本没有这个键」—— +**那半条现在通了**,真实 agent 真的写出了 `orgNode` + `workNode`。 + +但紧接着的一条: + +``` +node.triage | 归因点名了 org-coder-impl,但回退目标 org-coder-impl 不是 subgraph 节点,忽略跨层地址 +``` + +**这不是缺陷,是判据在正常工作**:这次回退发生在 **work 层内部** +(`org-coder-validator` → `org-coder-impl`),`edge.to` 本身就是 `agent_call` 节点, +已经是最细粒度,没有「跨层」可言 —— `resolveCrossLayerTarget` 第 ② 关就是拦这个的。 + +所以验收 9 缺的是**那个形状本身**:需要一次 **org 层的回退边、且目标是 subgraph 节点**。 +今天这条 run 的三个 org 节点重试后一次过,没有 org 层失败。 + +> **别把「机制没验」和「机制没有」混为一谈**:定点重置的逻辑侧有 +> `graph-cross-layer-address.test.js` 三例注入式覆盖(只写 `target_node` / 只写 +> `target_org_node` / 模板键名逐字对齐)。缺的只是「真跑里发生过一次」。 + +### 顺带发现的一条缺陷(未修) + +真跑的 timeline 里,**每个节点的产物索引写入都失败了**,且被降级成一条日志: + +``` +node.output | 产物索引写入失败 contract.json: UNIQUE constraint failed: index 'idx_graph_files_path' +node.output | 产物索引写入失败 sdd.md: UNIQUE constraint failed: index 'idx_graph_files_path' +``` + +**根因已查明并修复(2026-08-20)**,而且它是个**存量库专属**缺陷: + +生产库里还留着产物索引加「版本」之前的那条**无条件**唯一索引 +`idx_graph_files_path`(`UNIQUE(company_id, COALESCE(task_id,''), rel_path)`)。 +加版本之后建表块换成了带谓词的 `idx_graph_files_current`(`WHERE superseded_by IS NULL`), +**但没人删旧的那条** —— `CREATE ... IF NOT EXISTS` 只管不重复建,不会删已有的。 + +于是 `recordFile` 那套「先把旧行 `superseded_by` 指向新行、再插新行」的版本化写法 +在存量库上整个失效:旧索引不认 `superseded_by`,同路径第二行直接撞。 +新建库压根没有那条索引,所以**只有存量库会踩**,任何单测都照不到。 + +修法是迁移里一句 `DROP INDEX IF EXISTS idx_graph_files_path`, +守卫 `graph-files-stale-index.test.js` 走存量库重启那条路(反向验过:不删就红)。 +生产库已实跑验证:删索引后同路径连产两次不再抛,当前版本仍只有一条。 + +> 这是**同一类问题的第三次**(`tasks` 加列的索引顺序、墙钟锚点迁移、这次)。 +> 判据可以固定成一句话:**凡是碰 schema,「存量库重启」那条路必须单独走一遍** +> —— 新建库的路径由单测天然覆盖,存量库的不会。 + +--- + +## 六quater、验收 9 真跑验掉(2026-09-08) + +**跑法**:隔离 git worktree(`.claude/worktrees/p3b-acceptance9-realrun`,跑完即删,`EnterWorktree`/ +`ExitWorktree` 管理,不碰主仓库工作树也不碰生产 `data/`),真 Claude CLI(未设 +`LEMONBOSS_CLAUDE_MODE=mock`),一次性脚本直接 require 服务层驱动(`scripts/real-accept9-run.js` + +续跑脚本 `real-accept9-resume.js`,均未提交,随 worktree 一起丢弃)。 + +**场景设计**:2 节点 org 图 `org-planner --success--> org-validator`,`org-validator --fail(catch-all)--> +org-planner`(backward)——**刻意跳过 `org-coder`**。理由:org graph 里所有节点定义上都是 +`nodeKind='subgraph'`(`work-graph-db.readDefGraph`),所以任何一条 org 层 fail 边的目标天然就是 +subgraph 节点,验收 9 真正的难点不是"凑出这个图形状",而是"让一次真实、认真执行自己 `agent.md` +的 org-validator 真的判 fail"——跳过 org-coder 让"压根没有实现代码"这件事对 validator 而言是 +**客观事实**,不依赖运气或对提示词做手脚。 + +**真跑结果**(`accept9-mtsksam9` / 根 run `run_dd09fc486b0e`,15 次真实 agent 调用, +218,887 tokens,backward_count=3,全程未撞任何硬顶): + +1. `org-planner` 自己的 work graph(`workgraph-planning` 真规划出 brd→prd→sdd→goal-benchmark→ + planner-validator,中途 planner-validator 打回 sdd 重写一轮)跑到 success,只产出规格文档, + 不产任何实现代码——设计使然。 +2. `org-validator` 被点亮,`workgraph-planning` 为它规划出的子图与它自己 `agent.md` 里写的骨架 + 一字不差:`validator-triager → benchmark-validator → testdata-validator/testscript-validator`。 +3. `validator-triager` 首轮 `no_prior_failure`,放行给 `benchmark-validator`。 +4. **`benchmark-validator` 真实读了项目根、真实按 `goal-benchmark.json` 四个维度打分** + (`post_create_correctness` / `get_list_correctness` / `validation_and_error_handling` / + `runnable_delivery`),**全部判 0 分**,加权总分 0,远低于 `overall_pass_threshold=80`—— + 这是它自己读代码目录后的真实判断,不是任何注入或预设。 +5. `validator-triager` 复验轮给出的归因(原文摘录): + > 根因: 被测产物目录(项目根 `data/companies/accept9-mtsksam9/projects/p-4ntvbi/`)内没有任何 + > 实现代码,只有 org-planner 和 org-validator 各自 workgraph-planning 阶段留下的两份规划记录 + > md 文件;本次根 run 的 `nodes/` 目录下也不存在任何 org-coder 节点的产出记录,说明 + > POST/GET /todos 的具体实现从未被写出,BRD/PRD/SDD 定义的验收标准清晰……不是标准写得不清楚。 +6. **org-validator 的子 run 收敛为 fail,engine 在 org 层选中了那条唯一的 fail 边** + (`org_graph_edges.id=2`:`from_node_id=2(org-validator) to_node_id=1(org-planner) + condition='fail' fail_reason=NULL edge_kind='backward'`),真实写下一条 + `graph_run_loop_records`(`loop_count=1, verdict='registered', from=org-validator 的 + nodeRunId, to=org-planner 的 nodeRunId`)。 +7. **回退目标 `org-planner` 真的是 `nodeKind='subgraph'` 节点**(直接读 `graph_run_nodes` 行核实), + 且按 R-17.1「复用同一张子 run」的既定语义处理:`childRunId` 不变(仍是 `run_5991c4a313ac`), + `attempt` 从 0 → 1,重新从它自己 work graph 的入口节点(`brd-planning`)跑起,第 5 步那份 + `remediation_note` 原样带下去。 + +**逐条对齐验收 9 的原始表述**("一次真实的跨层回退,两半地址齐全、只重置被点名的节点"): + +- ~~"两半地址齐全"~~ **这次不适用,且不是缺陷**:`validator-triager` 没有写 `target_node`/ + `target_org_node`——它的归因原话是"org-coder 从未产出",图上压根没有 `org-coder` 这个节点可指, + 没有更细的粒度可给(同 §六ter 归纳的"work 层内部回退没有跨层可言"是同一类道理的另一面: + 这次是"该退的目标本身就是整个 org node,没有更细可指")。三个键都可能为空的用法本来就在 + R-17.1 的设计范围内("指不准时只写 `target_org_node`;引擎退化成整张 work graph 重跑"), + 这次连 `target_org_node` 都没写,退化成"由 org 层的 fail 边直接决定重跑哪个 org node", + 是该设计里最保守的那一档,行为完全符合预期。 +- **"只重置被点名的节点"**:这里"被点名的节点"就是 org 层 fail 边指向的 `org-planner` + 本身(org 层没有比 org node 更细的粒度),它被正确重置(子 run 复用、`attempt+1`), + 没有波及任何其他 org node(这张图上也没有别的 org node 可波及)。 +- **验收 9 真正要的核心事实——"org 层回退边、目标是 subgraph 节点"——第 6/7 点已完整证实**。 + +**成本**:全程约 47 分钟真实墙钟(含一次因脚本硬超时设太紧、被迫续跑的中断), +15 次真实 Claude CLI 调用,218,887 tokens,落在 27-P3B §五「$3-8 / 15-40 分钟」的预估量级内。 + +**收尾**:worktree 已删除(`ExitWorktree action:remove`),跑出来的公司数据、脚本均未进主仓库, +不留痕迹。以上证据全部来自真跑当时直接读库/读文件核实,不是转述模型输出。 diff --git a/docs/done/system-design/27-p3c-goal-task-model.md b/docs/done/system-design/27-p3c-goal-task-model.md index 6eef668..4235427 100644 --- a/docs/done/system-design/27-p3c-goal-task-model.md +++ b/docs/done/system-design/27-p3c-goal-task-model.md @@ -1,12 +1,12 @@ # 27-P3C. 公司目标与任务 —— goal / task 两级模型 + 控制台任务列表 -> **所属**: [27 Graph Agent 架构](../../todo/system-design/27-graph-agent-architecture.md) 的 **P3 收尾**(P3C),直接续 [27-P3/P4 执行控制台](../../todo/system-design/27-p3-p4-work-graph-console.md) §四.B 与 [27-P3B 真跑跑道](../../todo/system-design/27-p3b-real-run-runway.md)。 +> **所属**: [27 Graph Agent 架构](../../todo/system-design/27-graph-agent-architecture.md) 的 **P3 收尾**(P3C),直接续 [27-P3/P4 执行控制台](../../todo/system-design/27-p3-p4-work-graph-console.md) §四.B 与 [27-P3B 真跑跑道](./27-p3b-real-run-runway.md)。 > **日期**: 2026-08-18 > **状态**: ✅ **已完成并归档**(2026-08-20)。五批全部实施(§十三 ~ §十六),§15.5 / §16.8 的后续项也已全部收掉(§十七)。 > 138 文件 / 1292 测试全绿,`web:build` 通过。**真跑验收见 §17.5** —— 返工闭环第一次由真 agent(不是注入的假 executor)走通。 > **⚠️ 批次与 phase 的对应不是一一对齐的**,判断「这份能不能搬进 done」时按批看,别按文档名看: > · **批 1-3 = 真正的 P3 收尾**(goal/task 两级模型 + `task-planning` + 排队),本文档的主体; -> · **批 4(并行)归 [27-P4](../../todo/system-design/27-p4-parallel-and-artifacts.md) §一.3**,那边是设计权威,本文档只服从(见 §五.2 的警示块); +> · **批 4(并行)归 [27-P4](./27-p4-parallel-and-artifacts.md) §一.3**,那边是设计权威,本文档只服从(见 §五.2 的警示块); > · **批 5(两套任务体系统一)不属于任何既有 phase** —— 是跨阶段的技术债清理,风险面也与前四批完全不同(坏了是所有 agent 互相派不了活),见 §十二.6。 > **交付物**: ① `goals` / `tasks` 两张表,把「人类下达的需求」与「lemonboss 执行的单元」拆成两级;② 基于这两张表的**排队与并行**;③ 执行控制台(`/:id/graph-console`)最左侧新增**目标·任务两级列表栏**。 > **命名**: 全文遵循 [27-NAMING](../../todo/system-design/27-naming-conventions.md),本文档**落地**的正是它 §四 定义但代码里一直没有载体的 `goal 1 : task N` 基数关系。 @@ -480,7 +480,7 @@ for (const t of queuedTasks) { **批 4 单独排期、单独验收**,理由:删掉一个唯一索引 = 删掉最后一道 DB 兜底,这个动作不该和「界面上能看到任务列表」混在一次上线里。 -> ⚠️ **批 4 与 [27-P4](../../todo/system-design/27-p4-parallel-and-artifacts.md) §一.3 是同一件事,不要各做一遍。** 那份文档的目标 3 逐字写着「**解除「一家公司同时只有一个 active root run」约束** —— P3 v1 用一个唯一索引强制它(§三.3),并行能力具备后应放开」。也就是说:**放开约束的设计归 27-P4,本文档只负责在它之上定义「谁来排队、按什么序出队」**(§五.1)。真到实施时以 27-P4 的并行方案为准,本文档的 §五.2 服从它;两边都写一版会产生两套并发守卫。 +> ⚠️ **批 4 与 [27-P4](./27-p4-parallel-and-artifacts.md) §一.3 是同一件事,不要各做一遍。** 那份文档的目标 3 逐字写着「**解除「一家公司同时只有一个 active root run」约束** —— P3 v1 用一个唯一索引强制它(§三.3),并行能力具备后应放开」。也就是说:**放开约束的设计归 27-P4,本文档只负责在它之上定义「谁来排队、按什么序出队」**(§五.1)。真到实施时以 27-P4 的并行方案为准,本文档的 §五.2 服从它;两边都写一版会产生两套并发守卫。 --- @@ -739,7 +739,7 @@ for (const t of queuedTasks) { | **1 · 两级模型落库** | `goals` / `tasks` 建表 + **存量回填脚本(§四.8)** + ID 生成器修正 + `POST /:cid/tasks` 兼容改造(自动建 goal、`report_to` 落库)+ `GET` 列表端点 + 退役 `/goals` 别名 | 4 / 10 / 11 / **12** | | **2 · 控制台左栏 + 拆分** | `GoalTaskPane` 两级树 + URL 选中态 + `loadRuns` 修正 + **`task-planning` 本体(含 `_registry.json` 登记)+ 拆分链路 + 失败降级**+ 两步表单 | 1 / 19 / 20 / 21 / 22 | | **3 · 排队** | `run_status='queued'` + `RUN_TRANSITIONS` + `syncRunNodesToDef` + `task-scheduler`(漏斗钩子 + 三个辅助触发 + 重入保护 + 上游失败推导 blocked)+ `depends_on` 环检测 + 入队/取消/rerun 端点 + §四.7 两条禁令 + §九.1 全部回归点 | 2 / 3 / 5 / 6 / 7 / 8 / 9 / 13-18 / 23 | -| **4 · 并行**(单独排期)| `max_parallel_tasks` + `DROP` 唯一索引 + 事务内 admit。**与 [27-P4](../../todo/system-design/27-p4-parallel-and-artifacts.md) §一.3 同一件事**,实施以那份为准 | 另立验收 | +| **4 · 并行**(单独排期)| `max_parallel_tasks` + `DROP` 唯一索引 + 事务内 admit。**与 [27-P4](./27-p4-parallel-and-artifacts.md) §一.3 同一件事**,实施以那份为准 | 另立验收 | | **5 · 任务体系统一**(单独排期)| 见 §十二:`executor` / `assignee_agent_id` / `parent_task_id` 三字段 + `/task/dispatch` 换存储 + 删会话反推 + `tasklog` 回填 | 25-30 | > 批 3 的验收从 6 条涨到 12 条,是本次复核的直接结果 —— 13/14/15 三条各自对应一个「初稿会实现出来的静默故障」。 diff --git a/docs/done/system-design/27-p4-parallel-and-artifacts.md b/docs/done/system-design/27-p4-parallel-and-artifacts.md new file mode 100644 index 0000000..8dcb755 --- /dev/null +++ b/docs/done/system-design/27-p4-parallel-and-artifacts.md @@ -0,0 +1,712 @@ +# 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 自己也能并行。 diff --git a/docs/done/system-design/28-agent-registry-unification.md b/docs/done/system-design/28-agent-registry-unification.md index a4635e8..2d6380c 100644 --- a/docs/done/system-design/28-agent-registry-unification.md +++ b/docs/done/system-design/28-agent-registry-unification.md @@ -596,12 +596,13 @@ const AGENT_REGISTRY_TREE_RE = /^system\/agents\//; // 旧名 AGENT_RUNTIME_ST # 只认 const 声明行,否则会把注释里提到的旧名也捞进来 name=$(grep -oE '^const AGENT_[A-Z_]+_RE' server/services/graph-engine.js | sed 's/^const //' | sort -u) for f in docs/done/system-design/28-agent-registry-unification.md \ - docs/todo/system-design/27-p3b-real-run-runway.md \ - docs/todo/system-design/27-p4-parallel-and-artifacts.md; do + docs/done/system-design/27-p3b-real-run-runway.md \ + docs/done/system-design/27-p4-parallel-and-artifacts.md; do grep -q "$name" "$f" || { echo "MISS $name in $f"; exit 1; } done -grep -rn 'AGENT_RUNTIME_STATE_RE' docs/todo/system-design/*.md docs/done/system-design/28-*.md | grep -v '旧名' \ - && { echo 'STALE: 旧名未标注'; exit 1; } +grep -rn 'AGENT_RUNTIME_STATE_RE' docs/todo/system-design/*.md docs/done/system-design/28-*.md \ + docs/done/system-design/27-p3b-real-run-runway.md docs/done/system-design/27-p4-parallel-and-artifacts.md \ + | grep -v '旧名' && { echo 'STALE: 旧名未标注'; exit 1; } echo OK ``` diff --git a/docs/prd/newfunction/_spec/CONVENTIONS.md b/docs/prd/newfunction/_spec/CONVENTIONS.md index 1d53545..ae4fa09 100644 --- a/docs/prd/newfunction/_spec/CONVENTIONS.md +++ b/docs/prd/newfunction/_spec/CONVENTIONS.md @@ -152,7 +152,7 @@ res.status(<code>).json({ error: '<reason>' }) ## 12 · 不变量速查 -- 任意 `(pid, agent)` 同时只允许 1 个 status=running +- 任意 `(pid, agent, scope)` 同时只允许 1 个 status=running(`scope` 缺省时退化为旧不变量;27 号 P4 §4.2 起,图引擎并发调用同一 agent 时会传 `scope=gr:{nodeRunId}` 多加一维,避免同一 run 内两个并发节点撞会话键) - 任意 `(pid, unit)` 同时只允许 1 个 runUnit - 任意 `pid` 同时只允许 1 个 onHumanFeedback worker - 任意 ndjson 文件 append 后**永不修改已有行** diff --git a/docs/todo/system-design/27-goal-contract-format.md b/docs/todo/system-design/27-goal-contract-format.md index 55326a5..e8c96f7 100644 --- a/docs/todo/system-design/27-goal-contract-format.md +++ b/docs/todo/system-design/27-goal-contract-format.md @@ -30,7 +30,7 @@ ## 一、存储位置(稳定面) -沿用 [27-P3/P4](./27-p3-p4-work-graph-console.md) §四.A.5 已定的产物布局,本文档补上 `verdicts/` 这一格;契约本体按 [27-P4](./27-p4-parallel-and-artifacts.md) §4.6 的目录约定归并到 `tasks/{taskId}/`(**R19-B**): +沿用 [27-P3/P4](./27-p3-p4-work-graph-console.md) §四.A.5 已定的产物布局,本文档补上 `verdicts/` 这一格;契约本体按 [27-P4](../../done/system-design/27-p4-parallel-and-artifacts.md) §4.6 的目录约定归并到 `tasks/{taskId}/`(**R19-B**): ``` data/companies/{cid}/ diff --git a/docs/todo/system-design/27-graph-agent-architecture.md b/docs/todo/system-design/27-graph-agent-architecture.md index 68835d9..5e9f5a2 100644 --- a/docs/todo/system-design/27-graph-agent-architecture.md +++ b/docs/todo/system-design/27-graph-agent-architecture.md @@ -2,7 +2,7 @@ > **版本**: v0.3(P0/P1 已实现;Org Graph 建模在 v0.3 修正为 company 级 workflow) > **日期**: 2026-08-15 -> **状态**(2026-08-20 校准): P0 ✅ 已实现(`graph-db.js` + `graph-seed-service.js`,见第七节);P1 ✅ 已实现(commit `226489d`,`graph-service.js`);**P2 ✅ 已实现**(设计已归档 [27-P2](../../done/system-design/27-p2-org-graph.md));**P3 主体 ✅ 已实现**(`c3abf4a` 起七轮,见 [27-P3/P4](./27-p3-p4-work-graph-console.md) §十 实现记录);**P3 收尾 ✅ 已完成并归档**([27-P3C](../../done/system-design/27-p3c-goal-task-model.md) 五批 + §十七 清尾,2026-08-20 真跑走通返工闭环);**P3B 真跑跑道 📝 仍在推进**([27-P3B](./27-p3b-real-run-runway.md),验收 7 真跑不成立、2/4/8/9 未验);**P4 📝 设计中/未实现**([27-P4](./27-p4-parallel-and-artifacts.md) 自述 v0.1 设计阶段) +> **状态**(2026-09-08 校准): P0 ✅ 已实现(`graph-db.js` + `graph-seed-service.js`,见第七节);P1 ✅ 已实现(commit `226489d`,`graph-service.js`);**P2 ✅ 已实现**(设计已归档 [27-P2](../../done/system-design/27-p2-org-graph.md));**P3 主体 ✅ 已实现**(`c3abf4a` 起七轮,见 [27-P3/P4](./27-p3-p4-work-graph-console.md) §十 实现记录);**P3 收尾 ✅ 已完成并归档**([27-P3C](../../done/system-design/27-p3c-goal-task-model.md) 五批 + §十七 清尾,2026-08-20 真跑走通返工闭环);**P3B 真跑跑道 ✅ 九条验收全部验完**([27-P3B](../../done/system-design/27-p3b-real-run-runway.md) §六quater,2026-09-08 隔离 worktree 真跑验掉最后一条验收 9:org 层回退边真的指向了 subgraph 节点);**P4 ✅ 已彻底收口**(2026-09-08,[27-P4](../../done/system-design/27-p4-parallel-and-artifacts.md) 阶段 A/B/C 全部完成,仅 T5 账户欠费门因外部计费接口未接而悬置) > > ⚠️ 这一行是别人判断「哪些文档能从 `docs/todo/` 搬去 `docs/done/`」的依据 —— 它过期会连累后面每一次迁移判断,改阶段状态时请顺手更新它。 > **命名**: 新增 agent id / 表名 / 字段名前先读 [27-NAMING 命名规范](./27-naming-conventions.md) @@ -231,7 +231,7 @@ CREATE TABLE work_graph_edges ( | **P2 Org Graph** | `company.json.agents[]` 迁移为图结构 | `org_graph_nodes`/`edges` 落地;HR/CEO 招聘走新表。细化需求与设计见 [27-P2 Org Graph 落地](../../done/system-design/27-p2-org-graph.md) | 现有 Company Dashboard 前端能正确读出 org graph 并渲染(哪怕暂时按列表展示) | | **P3+P4 图执行引擎与执行控制台**(**两个 Phase 已合并**) | **一套引擎同时驱动 org graph 与 work graph**(R-15.4 引擎泛化),带回退与熔断 | 定义层 `work_graphs`/`work_graph_nodes`/`work_graph_edges` + **两层共用的运行时层** `graph_runs`/`graph_run_nodes`/`graph_run_loop_records`(共五张新表);`graph-engine.js` 的 `advanceOne(runId)` 幂等推进原语 + 两种 node executor(`agent_call` / `subgraph`);五个控制操作(启动 / 下一步 / 暂停 / 停止 / 重试)× 手动·自动两种模式;`GraphConsole.vue` 执行控制台页面;`workgraph-planning` agent + 用户→公司任务入口。细化需求与设计见 [27-P3/P4 Work Graph 执行控制台](./27-p3-p4-work-graph-console.md) **v0.3**,落地切成**五个可独立验收的阶段**(0 验收契约 / A 引擎地基 / B 回退熔断 / C 控制台 / D 规划闭环) | 见该文档 §六 的 17 条。**核心验收点是第 6 条**:一张 `graph_kind='org'` 的图与一张 work graph 用**同一套端点、同一条代码路径**跑通「手动逐步 / 自动跑完 / 暂停恢复 / 停止」;外加第 8 条「总是失败的节点第 4 次 `force_terminated` 收敛到 `blocked`」、第 17 条「一句自然语言任务两层跑到 `completed`」 | | ~~**P4 回退 + Loop-Validator**~~ | **已并入上一行**,不再单列 | 合并理由见 [27-P3/P4](./27-p3-p4-work-graph-console.md) §二:执行引擎一旦要支持「暂停/单步/停止/重试」这种可被外部随时打断的控制语义,「先做单向、再加回退」会让第一版状态机设计得过窄,P4 加回退时必然回来重改数据模型和主循环。**两处定案变更**:① 熔断改为**引擎无条件做的整数计数**(单边 `loop_count > 3` + run 级 `backward_count > 5` 双层硬顶),不依赖 LLM,`loop_validator` 节点由「强制」降级为**可选增强**;② 结构校验从四项扩到**五项**,新增 `fail-fallback-edge`(有 `fail` 出边的节点必须留一条 `fail_reason IS NULL` 兜底边),org / work 两层都加 | 同上 | -| **P4 并行执行与产物索引**(**回收编号**) | fan-out 真并发 + 产物统一索引 | `advanceBatch` 批量拾取(手动模式退化为 `maxConcurrency=1`,行为与 P3 一致);同 agent 多实例隔离(启用 `SessionPool.getKey` 已预留但从未使用的 `scope` 参数);解除「一家公司一个 active root run」;`data/companies/{cid}/tasks/{taskId}/` 产物目录 + `files.json` 检索索引。细化设计见 [27-P4 并行执行与产物索引](./27-p4-parallel-and-artifacts.md) | 3 个并行分支同时 running 且总墙钟明显短于串行;部分失败时 fan-in 停等且可视化「在等谁」;`files.json` 可从节点行 rebuild | +| ~~**P4 并行执行与产物索引**~~ ✅(**回收编号**) | fan-out 真并发 + 产物统一索引 —— **2026-09-08 全部完成** | `advanceBatch` 批量拾取(手动模式退化为 `maxConcurrency=1`,行为与 P3 一致);同 agent 多实例隔离(`SessionPool.getKey` 的 `scope` 参数已实际接线:`graph-engine.js` 传 `gr:{nodeRunId}`);`advanceTree` 兄弟子 run 真并发;解除「一家公司一个 active root run」;`data/companies/{cid}/tasks/{taskId}/` 产物目录 + `graph_files` 表检索索引。细化设计见 [27-P4 并行执行与产物索引](../../done/system-design/27-p4-parallel-and-artifacts.md) | 3 个并行分支同时 running 且总墙钟明显短于串行;部分失败时 fan-in 停等且可视化「在等谁」;产物索引可从节点行 rebuild | | **P5 自我进化** | 公共记忆蒸馏机制 | 公共 `memory.md` 写入走自省校验 + git 版本化;company-scoped → public 的升级门槛复用现有 benchmark ≥95 链路 | 同一 public agent 被两家公司调用后,`memory.md` 有可观察的增量更新,且能 git revert 到升级前版本 | | **P6(~~可选~~ **应前置**)DevLoop 收编** | 统一循环逻辑 | 把 `loop-controller.js`/`unit-registry.js` 重构为 Work Graph 引擎上的一个具体图模板。⚠️ **定位修正**:端到端流程梳理后发现 DevLoop 不是"将来可选整合的边缘子系统",而是**本架构唯一一个已经跑通的参考实现**——它的 L1/L2/L3 三层循环、四分类失败归因、分级预算熔断、`contract_hash` 闸门、DAG 分波、SSE 埋点、前端 DAG 组件都已实现或已有完整规格(`docs/prd/newfunction/_spec/`)。通用 Work Graph 引擎应当**从它抽象而来**,而不是平行发明后再回头合并。详见 [27-E2E](./27-e2e-task-execution-flow.md) §二结论 2 | DevLoop 现有测试套件(`server/tests/devloop/**`)在新引擎上全部通过,无需单独维护两套循环代码 | @@ -268,4 +268,4 @@ P0 阶段的处理方式是**迁移脚本层面的临时保护**:`system_agent - [27-P1 虚拟引用寻址](../../done/system-design/27-p1-virtual-reference.md) — 已实现(commit `226489d`) - [27-P2 Org Graph 落地](../../done/system-design/27-p2-org-graph.md) — 设计稿 - [27-P3/P4 Work Graph 执行控制台](./27-p3-p4-work-graph-console.md) — 已实现(commit `c3abf4a`) -- [27-P4 并行执行与产物索引](./27-p4-parallel-and-artifacts.md) — 设计稿 +- [27-P4 并行执行与产物索引](../../done/system-design/27-p4-parallel-and-artifacts.md) — ✅ 已彻底收口(2026-09-08) diff --git a/docs/todo/system-design/27-p3-p4-work-graph-console.md b/docs/todo/system-design/27-p3-p4-work-graph-console.md index 7f67d85..73209c1 100644 --- a/docs/todo/system-design/27-p3-p4-work-graph-console.md +++ b/docs/todo/system-design/27-p3-p4-work-graph-console.md @@ -1069,8 +1069,15 @@ agent 默认**只挂载 `meta-research` 一个元技能**,必须主动呼叫 ` > `company-hire-org-layer.test.js` 盯着。 > > **还没还的部分,别当成还清了**:(a) 真 LLM 仍然进不了 CI(要额度、分钟级、输出不确定), - > `scripts/real-pm-smoke.js` 仍是人工跑;(b) 多节点、回退边、熔断这些形状没进这条 smoke, - > 两支各只推一个节点;(c) 按需 hire 那一段没在这条链路里走过。 + > `scripts/real-pm-smoke.js` 仍是人工跑(这条是本质限制,不是欠账,不会有"还清"的一天); + > (c) 按需 hire 那一段没在这条链路里走过。 + > + > **2026-09-08:(b) 已还上**——`graph-real-chain-smoke.test.js` 新增 ⑥⑦⑧ 三例: + > ⑥ 非法契约真的判 fail(不是 success)且判词/归因落库;⑦ 两节点图 + 一条 backward + > 兜底边,真实驱动 `nextStep` 验证失败后引擎自动沿边打回上游(不调 `retryNode`); + > ⑧ 复用同一张图反复驱动到撞顶,验证双层熔断(`loop_count > 3`)真的会把源节点收敛 + > 到 `blocked`,不是只有注入式覆盖。三例全程走真实 `AgentManager → ClaudeCliProtocol` + > (只 mock 最外层 CLI 子进程),不注入假 executor。 3. **回填对账的段落必须在本轮代码定稿之后重跑一遍核实**。**这条被连续犯了三轮**:第四轮代码把 `total_tokens` 接通了、同一轮回填的 §四.A.3 却还写着「死维度」(§八.11 / §六 验收 10 / §五 T8 三处一起错);第五轮真接了四处生产链路,文档头部的「第五轮修订」却只列改名与纠偏、四处接线一个字没提,于是 §五 T8/T11、§六 验收 1/12/17、§八.10/12/14、§十 表全部停在第四轮结论上(**第六轮清出至少 12 处过期**)。**「本轮开工时 grep 的结论」不等于「本轮交付时的事实」;改了行为就在同一轮把对应小节改对,别留给下一轮。** **第六轮的形态换了个方向,同样要记**:这一轮的过期不再是「代码改了、文档停在上一轮」,而是**「文档提前写成了完成态、代码只做了一半」**——§五 T11 写着「六个本体已建齐**并注册**」,实际只建了目录;§六 验收 10 / §五 T8 写着「实测 `spent.tokens` 递增且能单独触发 `exhausted`」,而 mock 协议把 `usage` 硬编码成 0,那个数用这套终验手法根本产不出来。**「我打算这样做」和「我做完了」在文档里长得一模一样,所以回填时要逐条问「这句话我是怎么验的」,验不出来的就不要写成实测。** diff --git a/docs/todo/system-design/27-p3b-real-run-runway.md b/docs/todo/system-design/27-p3b-real-run-runway.md deleted file mode 100644 index 416deb7..0000000 --- a/docs/todo/system-design/27-p3b-real-run-runway.md +++ /dev/null @@ -1,1015 +0,0 @@ -# 27-P3B. 真实运行前置 —— 从 mock 走到真 Claude CLI - -> **所属**: [27 Graph Agent 架构](./27-graph-agent-architecture.md) 的 P3 收尾。P3 主体见 [27-P3/P4](./27-p3-p4-work-graph-console.md)。 -> **版本**: v0.1 -> **日期**: 2026-08-16 -> **起因**: [27-REQ](./27-requirement.md) R-18(招聘归 `workgraph-planning`;停用 mock 改真 CLI) -> **前置**: P3 主体已实现并提交(`c3abf4a` 起七轮),测试 719/719 全绿 -> **状态**(2026-08-20 校准): 📝 **仍在推进**。第一批验收 1/3 已验、2 的后半与 4 未在干净环境重验; -> 第二批验收 7 ✅ **已修**(`58a2865`「W7 零边图」,守卫 `company-hire-org-layer.test.js` 的 -> `CO_ZEROEDGE` + `W7-fix`)—— 下面 §六bis 缺陷③ 那段是**当时的现场记录**,不是当前状态; -> 验收 2 ✅、验收 8 ✅ 由 2026-08-20 的真跑验掉,验收 4 ✅ 由零注入链路验掉, -> **验收 9 仍开**(产出端已通、消费端没等到那个形状)—— 逐条证据见 §六ter。 -> §六bis 缺陷② 的**根因**(边界守卫误判并行会话的改动)已于 2026-08-20 修掉, -> 见 [27-P3C §17.2](../../done/system-design/27-p3c-goal-task-model.md) —— 但**根因修了不等于验收点验了**,那几条仍要重跑一遍。 - ---- - -## 一、为什么需要这份文档 - -P3 主体七轮做下来,**引擎编排层是扎实的**(状态机、双层熔断、三层级联、跨层回退,都在脚本 executor 下逐环验过),但两件事一直没解决: - -1. **验证一直建立在 mock 之上。** `ClaudeCliProtocol._sendMockPrompt` 完全不读提示词,只按 agent 名回放 fixture。所以前六轮报的「端到端生产成立」,验的是引擎编排,不是 agent 链路。第七轮换真 CLI 后,链路在**第一个 work 节点**就断。 -2. **agent 供给链没有执行者。** work graph 节点引用的本体,没有任何流程会把它雇进公司。 - -本文档收敛这两件事,目标是:**能在控制台上点一下,让真实 Claude CLI 跑完一个最小任务,且不写坏宿主仓库、验收不假放行。** - ---- - -## 二、目标与非目标 - -### 目标 - -1. `workgraph-planning` 规划完图后自己雇齐引用到的本体(R-18.2 方案 a)。 -2. 真实 Claude CLI 能跑完一个单 org node 的最小任务,产出落在分配好的项目根内。 -3. 验收链路不会「假放行」。 -4. 界面上有入口触发 `orggraph-planning` 真跑(R-18.1),不再靠脚本代劳。 - -### 非目标 - -- **不做 fan-out 并行**(沿用 P3 §一:卡在实例隔离)。 -- **不做 L4**。 -- **不追求一次跑通复杂任务**:第一批的验收标准是「单 org node 的最小图能真跑完」,不是「开发型公司三节点全流程」。前七轮的教训是没跑起来之前的判断经常是错的,先拿到真实结果再排后面。 - ---- - -## 三、现状核实(决定工作量的证据) - -| # | 事实 | 证据 | -|---|---|---| -| 1 | `seedTemplates()` **没有任何运行时调用方** | 只有测试与手动的 `scripts/graph-seed.js` 引用它 | -| 2 | 因此 `system/templates/` 新增的本体进不了 `system_agents` 表 | 而 `work_graph_nodes.system_agent_id` 是指向该表的外键 | -| 3 | 库里有 **10 条悬空 `company_hired` 行**,`legacy_path` 指向已删目录 | 含 `validator-triager` / `prd-planning` / `sdd-planning` / `brd-planning` / `benchmark-validator` / `testdata-validator` / `workgraph-planning` / `org-validator` | -| 4 | `seedTemplates` 遇到 id 已被别的 `source_kind` 占用会 **skip** | 所以第 3 条那些永远变不回 `template` | -| 5 | 后果链:`lookupSharedAgentMd` 恒 null → `hire` 静默退回全量拷贝 | **模板改动对已雇公司不生效** | -| 6 | `resolveAgentName` 只查目录存不存在,**不做按需 hire** | 同文件的 `resolvePlanningAgentName` 对 `workgraph-planning` 倒是做了 | -| 7 | `path-resolver.allocateProjectRoot` 已实现,sandbox 模式给 `data/companies/{cid}/projects/{pid}`,另有 `isSensitivePath` 黑名单 | 图执行侧没有调用它 | -| 8 | 图执行侧的 agent_call 节点**没有产出位置也没有沙箱** | 真 CLI 实测已往宿主仓库写入 `server/routes/echo.js` 等三处(已复原) | -| 9 | `FAILING_VERDICTS = {attributed, inconclusive}`,**不含 `"fail"`** | 而 `benchmark-validator` / `testdata-validator` 的 Step 表教 agent 写 `verdict: "fail"` | -| 10 | `system/skills/` 目录**不存在** | base agent 的 Hot Swap 是 `existsSync` 守卫下的空转(W11 后仍空转,但已改成显式 `console.warn`)。**W11 另查出**:工具权限来自 `config.json` 的 `cli.toolPolicy`(建协议时读一次,与 phase 无关),Hot Swap 换的只是提示词文本 —— 补技能包也关不上权限闸 | - ---- - -## 四、工作项 - -### 第一批 · 真跑的最小前提 - -做完这四件就能真跑,且不会写坏仓库、验收不会假放行。 - -| # | 内容 | 文件 | 量 | -|---|---|---|---| -| **W1** ✅ | 清 10 条悬空 `company_hired` 行;把 `seedTemplates()` 接进服务启动流程(或让 `hire` 顺手 upsert `system_agents`)。**这是 W5/W6 的前置**——不修,agent 雇完人提交图照样外键挂 | `server/services/graph-seed-service.js`、`server/index.js` | 小 | -| **W2** ✅ | 每个 root run 分配一次项目根(复用 `path-resolver.allocateProjectRoot` sandbox 模式),落在 `runs/{rootRunId}/artifacts/project-root.json`(**未加 schema 列**),进 agent_call 与 planning 两种提示词 + `callAgent` metadata | `server/services/graph-engine.js`、`server/tests/unit/graph-boundary.test.js` | 小 | -| **W3** ⚠️ 部分 | 边界守卫落地为「提示词声明 + 事后取证 + 结算拒收」,**不是沙箱**:能拦住宿主仓库内可见的越界写(判 fail + timeline 留痕),拦不住仓库外与被 gitignore 的写。详见下方「W3 能力边界」 | `server/services/graph-engine.js`、`server/tests/unit/graph-boundary.test.js` | 小 | -| **W4** ✅ | **修 fail-open 的验收 bug**:`benchmark-validator` / `testdata-validator` 的 Step 表与引擎 `FAILING_VERDICTS` 对齐。`planner-validator/agent.md` 已有一段红字警告这一点,另两份 Step 表恰恰在教 agent 犯它 | `system/templates/{benchmark,testdata,org}-validator/agent.md`、`server/tests/unit/template-verdicts.test.js` | 小改动,**性质严重** | - -> W4 的失效方向是**放行**不是拦截:契约非法的产物会被当合格通过。真跑之前不修,验收形同虚设。 - -#### W1 落地记录(已完成) - -§三 第 1/3/4/5 条已修复,现状核实表里那条失效链断掉了: - -- **启动接线**:`graph-seed-service.runStartupSeedSafely()` 由 `server/index.js` 的 `start()`(`listen()` 之前)与 `server/agent-server.js`(同上)调用。跑的是 `reconcileDanglingRows()` → `seedGlobalAgents()` → `seedTemplates()`。**不跑 `seedCompanyAgents()`**:那一步的数据源是运行时状态 `company.json`,归 `hire()/fire()` 管,启动时反推 `org_graph_nodes` 会跟它们抢所有权(也正是 W7 要收敛的地方);它继续留在 `scripts/graph-seed.js` 手动跑。全同步幂等,失败只打日志降级,不挡启动。 -- **悬空行回收规则**(三条同时成立才回收,任一不成立就保留):`source_kind='company_hired'` + `legacy_path` 指向的目录已不存在 + 没有任何**活着的公司**在 `company.json.agents[]` 里点名这个 id。附带清掉「磁盘上已无 `company.json`」的公司遗留的 `org_graph_nodes`/`org_graph_edges`(`data/companies/` 整个目录缺失时跳过这一步,分不清「没有公司」和「盘没挂上」)。删不掉(被 `work_graph_nodes` 等外键引着)的行进 `retained`,不抛错。 -- **占位 id 转正**:`seedTemplates()` 撞上被占用的 id 时,只有当那行是**悬空的 `company_hired`** 才就地转正(`source_kind`/`scope`/`owner_company_id`/`legacy_path` 一并改写,保留行 id 让外键继续有效);`global_agent` 占位照旧 skip。 -- **实测结果**:生产 `data/graph.sqlite` 上跑完 → `company_hired` 11→0,`template` 37→45,`org_graph_nodes` 清掉 4 家已删公司的 7 行,`verifyCounts()` 由 false 转 true;`lookupSharedAgentMd('workgraph-planning' / 'prd-planning')` 从恒 `null` 变成能解析到 `system/templates/**/agent.md`,`hire()` 不再静默退回全量拷贝。 -- **测试**:`server/tests/unit/graph-seed-service.test.js` 已 hermetic 化(新增 `GRAPH_SEED_ROOT` 测试缝,夹具根建在 `/tmp`,行数改为写死的精确值),2 条 → 13 条。 - -#### W4 落地记录(已完成) - -§三 第 9 条已修复。三处改动 + 一道防线: - -1. `benchmark-validator/agent.md` Step 1 表、`testdata-validator/agent.md` Step 1 表: - `verdict: "fail"` → `verdict: "attributed"`,各补一段红字说明「写 `"fail"` 引擎读成没失败」。 -2. `org-validator/agent.md` 的审计字段 `verdict: pass|fail` → **`audit_verdict`**。 - 它本来就是审计产物(org 节点是 `subgraph`,成败由 `deriveChildOutcome` 从子 run 推出), - 但它写的文件路径与 work 层判词**完全相同**(`nodes/<nodeRunId>/attempt-<n>.json`), - 只靠「本节点恰好是 subgraph」才没被 `readNodeProduct` 当判词读。改名后两套词彻底分开; - 即便哪天真被当判词读,引擎也会退到「有 `fail_reason` 就算失败」的缺省规则上,仍然判对。 -3. 防线 `server/tests/unit/template-verdicts.test.js`(对标 `template-refs.test.js`): - 从 `graph-engine.js` **源码文本**里取 `FAILING_VERDICTS` / `ATTRIBUTION_REASONS` - (两者都没 export,import 整个模块会把 graph db 拉起来;取不到就抛,不静默空跑), - 扫全部 `system/templates/**/*.md` 的赋值形态(`verdict: "X"`)与判词表整格取值, - 再加扫 `system/{work,org}-graph-templates/*.json` 的边 `failReason`(不在域内 = 这条边永远匹配不上)。 - 两条扫描路径都做过变异验证:改回 `"fail"` / 把表格取值改成 `"ok"`,都会炸。 - -> **`FAILING_VERDICTS` 要不要放宽收 `"fail"`:不要那样补,要把判据反过来** -> (失败词白名单 → 通过词白名单,不认识的一律判失败)。理由与改法见 §八。 -> `graph-engine.js` 归 W2/W3 那一轮,本轮不动它。 - -#### W2 落地记录(已完成) - -§三 第 7/8 条的前半段(**缺产出位置**)已修复。 - -- **分配**:`graph-engine.advanceOne` 在发起调用之前调 `ensureProjectRoot(ctx)`,内部复用 - `devloop/path-resolver.allocateProjectRoot({company_id, mode:'sandbox'})` → - `data/companies/{cid}/projects/{pid}/`。**每个 root run 一次**,子 run / 后续节点读回同一个 - (`runRootDir` 本来就按 `root_run_id` 拼,天然共享)。`GRAPH_RUNS_DIR` 覆盖时项目根跟着挪到 - `{GRAPH_RUNS_DIR}/{rootRunId}/project`,理由与 run 目录那处 override 完全一样:否则测试每跑 - 一次就往仓库 `data/companies/` 堆一个空项目目录。 -- **存哪:没有改 schema**。记录落在 `runs/{rootRunId}/artifacts/project-root.json` - (`{schema:"graph-run-project-root/v1", pid, root_path, mode, root_run_id, company_id, - allocated_by, allocated_at}`),与 `artifacts/task-goal.json` 同类、同目录,读回路径已经通。 - `graph_runs` 不需要加列——这条信息按 `root_run_id` 唯一,加列反而要在每一行上对齐一次。 -- **进提示词**(两种节点都进,参照 `describeArtifactContract` 的做法):agent_call 节点由 - `describeWriteBoundary` 在任务描述头部拼一段 `[写入边界 —— 硬约束,越界会被判失败]`; - subgraph 节点(起的是 `workgraph-planning`,同样是一次真 CLI 调用、同样会写盘)由 - `buildPlanningPrompt` 的「## 写入边界(硬约束)」拼同一份约束。两处都给绝对路径,并明说 - 「进程 cwd 不是项目根,别用相对路径」。`callAgent` 的 `metadata.projectRoot` 是它的机器可读副本。 -- **四环闭合**:分配(`ensureProjectRoot`)→ 传递(`ctx.projectRoot` → 提示词 + metadata)→ - 落库(`project-root.json`,`wx` 独占创建,并发下先到先得)→ 读回(下一个节点 / 子 run - `readProjectRootRecord`)。timeline 上有 `node.boundary` 一条「本次任务的项目根:…(sandbox)」。 -- **实测**(mock 模式 + 默认执行器,未起真 CLI):单节点 run 跑完 → - `data/companies/{cid}/projects/p-xxxxxx/` 被建出、`project-root.json` 内容正确、节点提示词里 - 有该绝对路径、宿主仓库 `git status` 无新增。测试 `server/tests/unit/graph-boundary.test.js`。 - -#### W3 落地记录(部分完成,**不是沙箱**) - -真 CLI 是子进程直接写盘、还带 `--dangerously-skip-permissions`,**引擎不可能在写发生的那一刻 -拦住它**。所以本轮落地的是「提示词声明(调用前)+ 事后取证 + 结算拒收」三件,能力边界如下, -不要当成「产出目录已被隔离」。 - -**能拦住的** - -1. **宿主仓库内可见的越界写**:`advanceOne` 在调用前后各跑一次 `git status --porcelain` - (cwd=仓库根),差集非空 = 本次调用在仓库里留下了痕迹 → 记一条 `node.boundary` - (`violation:'boundary_escape'` + 改动清单,最多 20 条)并**把结果降级成 `fail`** - (`success` 也降级;`fail_reason` 保持 `null`,走兜底边——引擎不发明 fail_reason)。 - 这正好覆盖已经实际发生过的那次事故(写 `server/routes/echo.js`、改 `server/index.js`)。 - executor 抛异常那条路径同样查(先写坏再崩,痕迹一样在)。 -2. **敏感路径**:项目根分配后过 `path-resolver.isSensitivePath`,命中则拒用并留痕 - (这是唯一一道**调用前就强制**的守卫)。 -3. **mock 产物越界**:`mock/files` 事件里逃出节点产物目录的路径照旧被丢弃(原有行为)。 - -**拦不住的(明确未解决)** - -1. **宿主仓库之外的写**完全看不见:`~/Documents/别的项目`、`/tmp`、`$HOME` 下任何路径。 -2. **被 `.gitignore` 忽略的路径**看不见(含 `data/companies/`、`*.log`)。 -3. **调用前就已 modified、调用中又被改一次**的文件:porcelain 行不变,看不见。 -4. 越界的写**已经发生**,守卫只拒收结果,**绝不自动回滚**——回滚等于替开发者丢弃未提交的 - 改动,代价方向比误判更贵。清理靠人工看 timeline 里的清单。 -5. **误判**:开发者本人或并行的另一个 agent 在同一时刻改仓库,会被算到本节点头上。 -6. 守卫只在**会真起 CLI 的执行器**下开(默认 executor / 默认规划器);测试注入的假 executor - 不扫(进程内函数,扫仓库既无意义又要给每个用例加两次 git 调用)。自定义 executor 若自己 - 起子进程,不在守卫范围内。 -7. `git` 跑不起来(非 git 仓库 / 超时)时守卫**自动降级为不检测**,只留一条 - `degraded:'boundary_guard_unavailable'`。 - -**真正的隔离还没有做**:最强的一招是让 CLI 的 cwd 落在项目根——但 `agent-manager._runViaProtocol` -的 `effectiveWorkDir = agent.workDir || this.workDir`,动态解析出来的 agent 一律用自己的目录, -`callAgent` 没有透传 cwd 的口子;改它不在本轮范围。要么改 `agent-manager` 加 cwd 透传, -要么上容器 / 文件系统级沙箱。**在这一条做掉之前,真跑仍应在可丢弃的工作副本里做,不要在主仓库上跑。** - -> 本轮只动了 `graph-engine.js` 的 W2/W3 部分;§八 提到的 `FAILING_VERDICTS` 白名单反转**没有做**, -> 那条仍然待办。 - -#### 第一批终验记录(独立复核,未起真 CLI) - -**结论:W1 / W2 / W4 三项到位;W3 按其自述「部分完成」到位。但第一批合起来仍然 -不足以支撑一次真 CLI 端到端跑 —— 断点不在第一批,在 §三 第 6 条(归 W5/W6)。** - -| 项 | 复核方式 | 结果 | -|---|---|---| -| 全量测试 | `npm run test:unit` | **736/736 绿,68 文件**(起点 719/66,新增 17 条,无改期望) | -| 前端 | `npm run web:build` | 通过 | -| W1 启动接线 | 读 `server/index.js` `start()` / `agent-server.js` | 均在 `listen()` 前调 `runStartupSeedSafely()` ✅ | -| W1 库状态 | 生产 `data/graph.sqlite` 实查 | `company_hired` **0 行**,`template` 45,`global_agent` 8,`verifyCounts().ok=true`,`PRAGMA foreign_key_check` 空 ✅ | -| W1 新模板自动进表 | `graph-seed-service.test.js` hermetic 用例 + 生产库重跑 `seedTemplates()` 幂等 | ✅ | -| W1 占位 id 回收 | 三条判据 + 就地转正(保留行 id 保外键) | ✅ | -| W2 四环 | 读码 + `graph-boundary.test.js` | 分配→提示词/metadata→`project-root.json`→读回,四环闭合 ✅。子 run 经 `createRun` 继承 `root_run_id`,`runRootDir` 同目录,跨层共享同一个根 —— 已核实 | -| W3 守卫 | 读码 + 变异 | 判据 `diffRepoStatus` 有测试;**降级路径(success→fail)无任何测试**,见下 | -| W4 模板改动 | 读三份 agent.md | ✅ 无 `verdict: "fail"` 残留 | -| W4 防线有效性 | **三次反例变异** | 全部变红且报出精确行号:① `benchmark-validator` 改回 `"fail"` → `agent.md:60 → verdict: "fail" 不在引擎取值域内`;② `org-validator` 的 `audit_verdict` 改回 `verdict` → `agent.md:183`;③ 造 `verdict: "ok"` → `agent.md:59`。变异后已逐字还原 ✅ | - -**W3 文档与代码一致性核查(本轮重点)**:`§四 W3` 与 `applyBoundaryGuard` 的注释 -**没有把做不到的写成已解决** —— 状态标 `⚠️ 部分`,7 条「拦不住」逐条列明, -并写了「在 cwd 隔离做掉之前,真跑仍应在可丢弃的工作副本里做」。与代码一致。 -两点补充: - -1. **「正好覆盖已发生过的那次事故」这句在当前工作树里只成立一半。** 事故是 - *写 `server/routes/echo.js`(未跟踪)+ 改 `server/index.js`(已跟踪)*。 - 而 `server/index.js` **此刻本来就是 modified**(W1 的改动),命中「拦不住」第 3 条 - (调用前已 modified、调用中再改一次 → porcelain 行不变)。所以现状下守卫只看得见 - `?? server/routes/echo.js` 那一半。这不是代码 bug,是两条自述互相打架,措辞该收敛。 -2. **降级路径零测试覆盖。** `applyBoundaryGuard` 有真实调用方(`advanceOne`),不属于 - 「零调用方」那一类;但 `boundaryGuardApplicable` 要求执行器 `=== defaultAgentExecutor`, - 而单测一律注入假 executor,`applyBoundaryGuard` 又没 export —— - **「越界 → success 降级成 fail」这条唯一有实际后果的分支,只能靠真 CLI 才走得到。** - `graph-boundary.test.js` 测的是纯函数 `diffRepoStatus`,不是接线。 - → **已在「W12 补完」那一轮补上**:`applyBoundaryGuard` 导出 + 多一个可选的 `after` - 快照参数(生产路径仍只传三个参数、仍现拍 `hostRepoStatus()`),判罚三条分支各一例。 - 仍需真 CLI 验的只剩「git 扫描能不能真的扫到那次越界写」。 - -#### 阻断真跑的真正断点(§三 第 6 条,归第二批 W5/W6) - -`AgentManager.getAgentConfig()` 解析裸名时只查 `system/agents/`,**从不查 `system/templates/`** -(`agent-manager.js:141`);带公司前缀的 `{cid}:{id}` 才查 `data/companies/{cid}/agents/`。 -而 `resolveAgentName()` 只做「目录存不存在」的判断,**不做按需 hire**,查不到就退回裸名。 - -实测(纯文件系统解析,未起 CLI): - -``` -workgraph-planning THROWS: Agent not found: workgraph-planning -prd-planning THROWS: Agent not found: prd-planning -sdd-planning THROWS: Agent not found: sdd-planning -benchmark-validator THROWS: Agent not found: benchmark-validator -testdata-validator THROWS: Agent not found: testdata-validator -validator-triager THROWS: Agent not found: validator-triager -org-validator THROWS: Agent not found: org-validator -master RESOLVED -``` - -`workgraph-planning` 在真链路里能过,只因为 `resolvePlanningAgentName()` 单独给它做了 -按需 hire;**它规划出来的每一个 work 节点引用的本体都没有这层保护**。 -所以一次真跑的必然走向是:org 节点 → 规划成功 → 第一个 work 节点 `Agent not found` → 节点判失败。 -**这正是 W5/W6 要解决的问题,第一批四项做得再对也绕不过去。** - -> **W5/W6 落地后的复测**:把 `prd-planning` / `benchmark-validator` / `validator-triager` -> 三个本体 `hire` 进一家探针公司后,`getAgentConfig('<cid>:<role>')` 由 `THROWS` 变 **RESOLVED** -> (裸名照旧 `THROWS`,符合预期——引擎走的是作用域名)。断链的解法确认就是「先雇」。 -> 详见下方「W5 / W6 落地记录」。 - -> 推论:**现在起真 CLI 不会产出新信息** —— 断点已被零成本静态证明, -> 而按 §五 的量级,跑一次是 $3-8 / 15-40 分钟。应先做 W5/W6 再真跑。 - -#### 第 12 处「四环漏一环」:`outcome.output` 全程被丢 - -`graph_run_nodes` **没有 `output` 列**(`NODE_PATCH_COLUMNS` 只有 9 个键,不含 `output`), -`settleNode()` 也不把 `outcome.output` 传给 `transitionNode`,`node.output` 这条 SSE 事件的 -data 里同样只有 `status/fail_reason/output_ref/child_run_id`。于是: - -**产生(executor / 守卫 banner)→ 传递(`outcome.output`)→ 落库(无)→ 读回(无)**,第三环断。 - -三个写入方全部落空:`defaultAgentExecutor`(:370/:386,存量)、W3 的越界清单 banner(:2538,本轮新增)、 -以及**最要命的那个**—— - -```js -} catch (err) { - outcome = { status: 'fail', output: err.message, failReason: null, tokens: err.tokens || 0 }; -} -``` - -`advanceOne` 里 executor 抛异常这条路径:`err.message` 写进 `outcome.output` 后**人间蒸发**, -既不落库、不进 SSE、也不写 timeline,`fail_reason` 还是 `null`。 -**操作者只会看到一个节点 fail、没有任何原因。** - -严重性判断:W3 banner 那一份是冗余的(越界清单在 `node.boundary` 的 timeline 里有 `entries`, -证据不丢);**但异常那一份是净损失**,而且正好砸在下一步最需要它的地方—— -上一节那个 `Agent not found: prd-planning` 就是从这条路径抛的,真跑起来将表现为 -「节点失败,无原因,无痕迹」。建议在 W5/W6 之前先补一条 timeline(最小改动:catch 里加 -`timelineNote(ctx,'node.error',err.message,{...})`),否则真跑的排障成本会被这一环吃掉。 - -> **W12 · 本轮状态:✅ 已修(选「只留痕,不加列」)** — `server/services/graph-engine.js` -> -> - **不加 `output` 列**(`graph_run_nodes` 保持 16 列不变)。理由:`outcome.output` 长度无上限, -> 而 `getRunState` 是 `SELECT *` 全表读回,控制台每次轮询都会把所有历史输出重传一遍; -> 完整产物已有地址(`output_ref` → `nodes/{nodeRunId}/`),再存一份就是第二个真相源; -> `fail_reason` 是**选边的匹配键**,更不能借来装自由文本。 -> - **catch 那份净损失补上**:executor 抛异常时发 `node.error`(`timelineNote`),带 -> `node_run_id / node_name / attempt / node_kind / system_agent_id / error / error_type / error_stack`(栈顶 6 行)。 -> 四环 = 产生(catch)→ 传递(`timelineNote`)→ 落库(`timeline.ndjson` + `stream.log`,append-only) -> → 读回(`GET /api/graph-runs/:id/stream?lastEventId=0` 全量重放)。 -> - **`outcome.output` 也终于有了消费方**:`node.output` 事件 data 加定长 `output_excerpt` -> (≤600 字符,超长截断并注明「完整输出见 output_ref」),三个产生方(`defaultAgentExecutor` -> 的 `res.output`、W3 越界 banner、catch 的 `err.message`)一并接上。前端 `NodeRunLog` -> 已按 `data.node_run_id` 消费 `node.output`,无需改动即可看到。 -> - W3 banner 依建议**去掉重复的改动清单**(清单仍在 `node.boundary` 事件的 `entries` 里), -> 只留「为什么被判 fail」一句,免得 20 行清单把 agent 真实输出挤出摘要窗口。 -> - **仍需前端配合一行**:`web/src/components/graph-console/GraphRunPane.vue` 的 -> `es.addEventListener` 白名单里没有 `node.error`(命名事件不会走 `onmessage`), -> 加上 `'node.error'` 这条错误才会出现在浏览器的事件轨;不加也不丢数据(在 -> `timeline.ndjson` / `stream.log` 里),只是控制台看不到。 -> - 锁:`server/tests/unit/graph-engine-node-error.test.js`(4 例)。 - -> **W12 补完 · 本轮状态:✅ 已修(`fail_reason` 那一半)** — `server/services/graph-engine.js` -> -> 上一轮只做了「原因文本进 timeline / `output_excerpt`」,`graph_run_nodes.fail_reason` 与 -> `node.output` 的 `fail_reason` 仍然恒 `null`。**真跑与界面走查确认的后果**:点开失败节点, -> **概览 tab 第一眼是「失败、原因 —」**,要知道去翻第三个 tab(运行日志)才看得到 -> 「Agent not found: prd-planning」。「引擎判定的失败」与「agent 归因产出的失败」在这一列上 -> 被区别对待,没有道理——本轮把三类引擎自判失败都署上名。 -> -> - **取值:另起一个集合 `ENGINE_FAIL_REASONS`,不动 `ATTRIBUTION_REASONS`** -> (`{node_error, boundary_escape, child_run_failed}`)。理由三条,逐条写在引擎的注释里: -> ① `ATTRIBUTION_REASONS` 是 `readNodeProduct` 的**入口白名单**,管的是「agent 在 -> `attempt-{n}.json` 里允许写哪几个词」;往里加取值等于放宽**别人**的取值域, -> 而本轮要落的是**引擎自己**的判定,两件事不共用一个白名单(也意味着 agent 仍然不能 -> 自称 `node_error`,写了照旧被降级成 `null` + 一条 `invalid_fail_reason`)。 -> ② 复用现成四个取值更糟:`infra_flake` **有引擎特判**(原地重跑、不烧任何计数), -> `Agent not found` 这种确定性失败判成它会原地重跑到预算耗尽;`impl_bug` / -> `input_underspecified` 是图上**精确 fail 边**的匹配键,典型图里指向「退回 impl 重写」 -> 「退回 planner 补输入」,把「本体没雇进公司」判成 `impl_bug` 会让引擎真的退回去重写代码, -> 而根因一行代码都不在那儿——**比 `null` 更坏**。 -> ③ 新取值的**路由行为与今天的 `null` 完全一致**:`selectFailEdge` 精确匹配不到就退兜底边, -> 现有图骨架没有任何边写这三个词。**只加信息,不改选边。** -> - **三个产生点**:`advanceOne` 的 executor catch → `node_error`;`applyBoundaryGuard` -> 判越界 → `boundary_escape`(agent 自己给了归因则尊重它,越界只是附加事实); -> `deriveChildOutcome` 子 run 收敛为 fail 而子图一个归因都没有 → `child_run_failed` -> (**只是兜底**,真有更具体的根因一律优先透传)。 -> - **四环**:产生(引擎判定)→ 传递(`outcome.failReason` → `settleNode`)→ -> 落库(`graph_run_nodes.fail_reason`,与状态**同一次条件更新**)→ -> 读回(`getRunState` → `GET /api/graph-runs/:id` → 概览 tab;同一份值也进 `node.output` 事件 data)。 -> 读回这一环本来就通(`getRunState` 一直在读这一列),断的只有第一环。 -> - **前端不需要改**(名字订正:实现叫 `failInfo`,不是 `failSignal`;见下方 W12-UI 落地记录): -> `NodeDetailPanel.vue` 的 `failInfo` 第一条判据就是 -> `node.failReason`(W12-UI 那一轮已铺好),本轮起它会真的有值。可选优化:给这三个词 -> 加中文标签(「执行器异常 / 越界写入 / 子图失败」),纯展示层,不加也不缺信息。 -> - **顺带补上第一批终验点名的那处零覆盖**:`applyBoundaryGuard` 现在导出,并多一个 -> **可选的 `after` 快照参数**(生产路径一字未变,`advanceOne` 仍只传三个参数)。 -> 「越界 → success 降级成 fail」这条唯一有实际后果的分支,从此不必起真 CLI 就能验。 -> - **改了两条既有断言**(`graph-engine-node-error.test.js:132/163`: -> `expect(node.failReason).toBeNull()` → `toBe('node_error')`)。它们锁的正是本轮要改的行为, -> 不是被绕过的期望;两处都留了说明为什么改。 -> - 锁:`server/tests/unit/graph-engine-fail-reason.test.js`(13 例,含四处产生点各自的变异验证)。 - -#### W12-UI 落地记录(✅ 已完成;mock 服务 + 浏览器实点走查,未起真 CLI) - -只动前端四个文件 + 一个新模块:`web/src/components/graph-console/{node-signals.js(新), GraphRunPane.vue, -NodeDetailPanel.vue, NodeRunLog.vue}`、`web/src/components/WorkGraphCanvas.vue`、`web/src/views/GraphConsole.vue`。 - -**先修的是一个更靠前的断点:整类事件根本没到浏览器。** -`GraphRunPane.connect()` 里只 `addEventListener` 了 6 个事件名,而 EventSource 的 `onmessage` -**只接没有 `event:` 字段的帧**。引擎实际发 16 种,于是 `node.error`(执行器异常的原因)/ -`node.triage`(归因判词)/ `node.boundary` / `node.contract` / `node.subgraph` / -`loop.terminated` / `loop.cross_layer` / `plan.*` **全部被浏览器丢弃** —— -stream.log 里有、timeline 里有、界面上一条都看不到。名单收敛成 -`node-signals.js` 的 `RUN_EVENT_KINDS`,由 `GraphRunPane` 唯一消费。 - -**① 概览 tab 的失败原因:三级回落 + 来源标**(`NodeDetailPanel.failInfo`) - -| 级 | 来源标 | 取值 | 什么时候走这一条 | -|---|---|---|---| -| 1 | 引擎判定 | `node.failReason`(`graph_run_nodes.fail_reason`) | 引擎判出了根因(W12 补完那一轮起会真有值) | -| 2 | 归因产出 | 本轮 `node.triage` 事件的 `fail_reason` | 归因文件里有,但还没落到节点行上 | -| 3 | 摘自输出 | `node.output.output_excerpt` / `node.error.error` / 越界横幅 | **`fail_reason` 合法地为 `null`**(= 没匹配上精确 fail 边,走兜底边)时唯一能说清「到底出了什么事」的东西 | - -`fail_reason=null` 在这套引擎里是常态而非异常,所以旧版「只读 `node.failReason`,取不到显示 `—`」 -等于让失败节点常态显示成「失败、无原因」。现在 null 也会写明「没有匹配上精确 fail 边 → 走兜底边」, -并把 excerpt 以终端块展示。`remediation_note` 同款回落(节点行 → `node.triage`), -「输入输出」tab 的 `consumedSource` 一并接上这条链。 -**与并行的 W12 引擎侧解耦**:引擎给了 `fail_reason` 走第 1 级,没给走 2/3 级,两条路都实测过。 - -**② 单步进度感:只报真实信号,不画进度条** - -- **画布节点卡片**:RUNNING 节点右下角 `⏱ mm:ss` 秒表(`WorkGraphCanvas.elapsedLabels`), - 状态灯改成呼吸点。秒表单独一个 computed,**不塞进 `laidOut`** —— 否则整张图的拓扑排序 + 回退边分道 - 每秒重算一遍。ticker 只在真有节点 running 时转。 -- **详情面板**:tabs 之上一条常驻「运行中 mm:ss + 最近事件摘要(N 前)」,四个 tab 都看得见。 -- **`duration` 以前是死的**:它用 `Date.now()`,而 `Date.now()` 不是响应式的, - 节点在跑的那几分钟里「耗时」一格是冻住的。现在挂在只在 running 时转的 ticker 上。 -- **明确不做假进度条**:引擎不知道 agent 还剩多久(CLI 不上报进度),面板上有一句 title 写明这件事。 -- 真 CLI 单节点期间常常**几分钟一条事件都没有**,所以「最近事件」多半停在 `agent.call` — - 秒表 + 「N 前」正是为这种真空设计的,不是补充。 - -**③ NodeDetailPanel 不跟 SSE 走(doc 记的已知缺陷):根因与修法** - -根因不在 SSE,在 `GraphConsole.vue` 的分发:`onMidState` 会把 `selectedNode` 指到最新快照, -**`onRootState` 完全不管**。而左栏(org run)选中的节点走的正是 `onRootState` 这条路 —— -于是左栏已经 `RUNNING·#2`,右栏还是上次点进去时的那张快照,要点别的节点再点回来才刷新。 -修法是加一个 `selectedFrom: 'root' | 'mid'` 记住选中节点属于哪一层,两栏的刷新各自喂回右栏。 -同一个字段顺带修掉两处串层:`loopRecords` 与 `events` 以前**恒取中栏那份**, -选中 org node 时读的是另一张图的账本与事件轨。另外,org node 是被拾取时才现规划出 work graph 的, -子 run 一出现就把中栏指过去(仅在下钻栈为空时,不抢用户位置)。 - -**顺带**:`NodeRunLog` 的日志行以前回落成 `JSON.stringify(data)`(SSE 帧里**没有** summary, -引擎只把它写进 timeline.ndjson),现在由 `describeEvent` 按事件类型拼人话。 - -**四环自查**(读回那一环是本轮的全部):产生(引擎 `emitEvent`/`timelineNote`)→ -传递(stream.log + SSE 具名事件,**本轮修的就是这一环漏了 10 种事件名**)→ -落库(stream.log,`lastEventId=0` 全量重放)→ 读回(`node-signals` → 面板三处)。 - -**验证** -- `npm run web:build` 通过;`npm run test:unit` **786/786 绿,76 文件**(本轮新增 4 例,无改期望)。 -- 锁:`server/tests/unit/graph-console-node-signals.test.js`。第一例是**防线**: - 从 `graph-engine.js` / `graph-run-sse.js` 的源码文本扫出引擎实际发的事件名, - 与 `RUN_EVENT_KINDS` 双向对账(扫不到 8 个以上直接抛,不静默空跑)。 - 变异验证:从名单里删掉 `node.error` → 立刻变红并报出精确名字。 - ⚠️ **引擎将来新增事件名,这条会红** —— 那正是它的用途,修法是把新名字加进 `RUN_EVENT_KINDS`。 -- 浏览器实点走查(`LEMONBOSS_CLAUDE_MODE=mock` 起 3010 + vite,临时公司 `w12ui-demo`, - 一个 running 节点 + 一个 fail 节点 + 手工写入的事件轨): - 秒表 5m29s → 5m49s 真的在走;画布卡片上 `⏱ 5m 58s`; - 失败节点概览依次显示「摘自输出」→(补一条 `node.triage` 后)「归因产出 contract_violation」→ - (落库 `fail_reason` 后)「引擎判定 infra_flake」,三级回落全部走到; - `POST /nodes/:id/retry` 后右栏**没有任何点击**就从 `FAIL·attempt 1` 变成 `READY·attempt 2` - (即缺陷③已修);运行日志 tab 的 `node.error` / `node.triage` 两类**第一次出现在界面上**; - 无 console 报错。走查完临时公司已按收尾要求删净(`graph_runs` / `graph_run_nodes` / - `org_graph_*` / `system_agents` 全部 0 行,`PRAGMA foreign_key_check` 空,目录已 rm)。 - -**未做 / 已知上限** -- `agent.call` 与 `node.output` 之间那几分钟仍然**没有任何中间信号**,因为引擎没把 - `onAcpEvent` 转成 `agent.chunk` / `agent.tool` 事件(`NodeRunLog` 里那条黄框写的就是这件事)。 - 接上之后「最近事件」会自然变活,前端不用再改。 -- 事件缓冲 `MAX_EVENTS=400`,长 run 会丢最早的行(原有行为,未动)。 - -### 第二批 · 招聘走方案 a - -| # | 内容 | 文件 | 量 | -|---|---|---|---| -| **W5** ✅ 真跑已验(§六bis) | `workgraph-planning` 模板改语义:撤掉「你不招人也不辞人」,加招聘步骤,`tools.md` 给 `company hire` 用法 | `system/templates/workgraph-planning/**` | 小 | -| **W6** ✅ 真跑已验(§六bis:先雇后提交,图未外键挂) | 写死顺序:**先雇、再提交图**(反了必外键挂);雇失败的重试与降级 | 同上 | 小 | -| **W7** ⚠️ 部分(真跑打脸:零边最小图上判据全空,见 §六bis 缺陷③) | **work 层招聘不得污染 org graph**:`hire()` 现在会把任何被雇 agent upsert 成一行 `org_graph_nodes`。方案 a 下每次任务规划都雇人,org graph 会被越塞越满(界面走查已实际撞到)。让这类招聘不写 org 节点,或写了打标区分 | `server/services/company-service.js`、`server/services/graph-service.js` | 中 | -| **W8** ✅ UI / 🟡 mock | 控制台加「重新规划组织」按钮,调 `POST /:companyId/org-graph/replan`(R-18.1)。该端点已实现,全仓库无前端调用方 | `web/src/views/GraphConsole.vue` 或 `CompanyDashboard.vue` | 小 | - -#### W5 / W6 落地记录(模板层已改,真跑仍未验) - -改的是 `system/templates/workgraph-planning/` 三份:`agent.md`(主体)、`tools.md`、`identity.md`。 - -- **语义反转**:`tools.md` 的「你不招人也不辞人」那一行**已删**,替换成「你只招不辞」+ 一段明写 - 「旧提示词里那句已作废,以本节为准」的警示(老话可能还留在别处的上下文里)。 - `agent.md` 身份段、角色边界表、`identity.md` 同步改成「图里引用到的本体由你雇」。 -- **顺序写死**:工作流从七步改八步,**Step 6 招聘 → Step 7 提交图**,Step 6 开头一张表列出 - 「反了会怎样」的两条后果(`work_graph_nodes.system_agent_id` 外键 / 节点跑起来 `resolveAgentName` - 找不到公司目录退回裸名),并写明「都发生在你已经下班之后,补不了」。八步表 + 思维隔离自查第 9 条 - 两处复述同一条顺序。 -- **失败分级**:`status` 四档(`hired_from_template` / `already_exists` / `hired_from_base` 三档继续, - `training` 一档**不算成功**——脚手架路径不写 `system_agents`,图提交必被 `rule=reference` 打回); - 出错三级(瞬时故障重试 3 次 → 供不出来就换本体、同一本体最多换一次 → 都不行就**停**, - 不许提交引用了没雇上本体的图)。 -- **`already_exists` 的死角也写进去了**:它是提前返回,**不补 `system_agents` 那一行**。 - 所以「目录在、表里没有」这种组合下重雇永远只返回 `already_exists`,`rule=reference` 修不好—— - 模板要求直接换掉该本体并点名汇报,避免在这里死循环。 -- **提示词组装的实测结论(决定了内容该写在哪)**:图执行侧走 - `graph-engine → AgentManager.callAgent → PromptBuilder.build`,而 `build()` 只拼 - **`agent.md` + `company-context.md`**,**`tools.md` / `soul.md` / `identity.md` 都不进这条提示词** - (`packages/agent-core/Agent.js` 那套读六个文件的 bootstrap 是另一条链路)。 - 所以招聘所需的一切——命令行、参数表、curl 降级、四档判读、三级分级——**全部内联在 `agent.md`**, - `tools.md` 只作为人类可读的重复件。 -- **实测(未起真 CLI,临时 `GRAPH_DB_PATH` + 用完即删的探针公司)**: - `hire` 三个 work 层本体均 `hired_from_template`,重雇均 `already_exists`(幂等成立); - 雇后 `AgentManager.getAgentConfig('<cid>:<role>')` **RESOLVED**,裸名仍 `THROWS: Agent not found` - —— 与 §三 第 6 条的断链分析完全一致,**「雇进公司」正是解法**。 - 另用 `PromptBuilder.build()` 实拼一次 `workgraph-planning` 的 system prompt(20.8k 字符), - `company hire` / `--role` / `先雇` / 三个 `status` 取值 / `ls data/companies/` 全部命中。 -- **仍未验**:真 CLI 下 agent 是否照着做(mock 不读提示词,验不了)。归第三阶段。 -- **`--layer` 不是模板的入参**:模板明写「别自己发明这个参数」(`parseArgs` 严格模式,多给未知选项 - 会让整条命令报错)。work 层招聘写不写 org 节点由服务端判据决定,见 W7。 - -#### W7 落地记录(选「不写 org 节点」,判据在服务端;真跑未验) - -- **选「不写」而不是「写了打标」**:打标要么在 `org_graph_nodes` 加列(schema 归 `graph-db.js`)、 - 要么在 `readGraph`/`getGraph` 上加过滤(归 `org-graph-service.js`),两处都不在本轮可动的文件里; - 更关键的是**这行不只是画图用**——`work-graph-db.js:473` 起 org run 时读的就是这张表的 active 行, - 混进去的 work 层本体会变成一个**真的会被执行**的 org 节点。所以不是「显示时过滤掉」够用的问题。 -- **调用方不表达层级,判据只看数据**:唯一会雇 work 层本体的真实调用方是 - `POST /api/company/:companyId/hire`(`workgraph-planning` 在 CLI 里 curl 它), - 该路由按字段解构 body、不透传未知字段,所以「加个 `opts.layer` 参数」在本轮会是一个 - **永远没人传的死开关**(第一版就是这么写的,已删)。何况「谁雇的」本来也不该决定 - 「它是不是组织节点」。`company-service.decideOrgNodeRecording()` 三条判据,命中任一不写 org 节点: - ① `config.type ∈ {meta-bootstrap, meta-planning}`(`orggraph-planning` / `workgraph-planning` / - `brd|prd|sdd-planning` / `goal-benchmark`);② 本体在 `system/work-graph-templates/*.json` 里被 - 点名当节点(`planner-validator` / `validator-triager` / `benchmark-validator` / `testdata-validator` - ——它们 `type='validator'`,判据①盖不住;`org-validator` 只作为 `ownerOrgNode` 出现,不会被误伤); - ③ 公司已有**成型**的 org graph(存在 active 的 `org_graph_edges`)而该角色不在图上。 -- **判据③为什么看边不看节点**:P1/P2 的 `hire` 自己就会造节点,拿「有没有节点」当信号会把 - 「同一家公司连雇两个人,第二个不入图」——`graph-agent-p1-hire.test.js` 正是这么用的——直接废掉。 - 第一版就是拿节点当信号,跑全量测试当场红了这一条,才改成看边。 -- **不写 org 节点时仍然补 `system_agents`**(`graph-service.ensureSystemAgentRow()`,从 - `upsertOrgGraphNode` 里拆出来单独导出):`work_graph_nodes.system_agent_id` 是指向该表的外键, - 少这行 W6 的「先雇再提交图」照样外键挂(W1 记过同一条失效链)。 -- **顺带修掉一个老问题**:org 节点 `role_name='org-coder'` 绑定本体 `coder` 时,在成型图上再雇一次 - `coder` 以前会多出一行同义节点,现在不会。 -- **默认行为 = 写**,P1/P2 语义在「公司还没有成型的图」这个前提下原样保住。 -- **已知残留(不提前加规则)**:① 单 org node 的最小图**没有边**,判据③不生效,这时只有 ①② 认识的 - 本体会被拦下——`executor.json` 的 `{{implAgent}}` / `{{validatorAgent}}` 现填出来的本体仍会入图; - ② `hire()` 的 `already_exists` 是提前返回,**不补 `system_agents`**(W5/W6 记录里那条死角), - 本轮**故意没补**:目录在而表里没有时插一行 `company_hired` 会**squat 掉模板 id**, - 正是 W1 清掉的那条失效链(`seedTemplates` 撞上被占用的 id 会 skip,且此时目录还在、 - 不满足悬空回收条件),代价比收益大;③ `graph-seed-service.seedCompanyAgents()`(手动脚本 - `scripts/graph-seed.js` 才跑)只镜像了判据①的 `meta-bootstrap` 那半条,跑它仍会把 work 层本体 - 塞回 org graph——启动流程不跑它,暂时无害,等它有运行时调用方时必须同步这三条判据。 -- **测试**:`server/tests/unit/company-hire-org-layer.test.js`(9 例,真 `hire()` + 从 - `org-graph-service.getGraph()` 读回,即 GET org-graph 的同一条读路径)。三条判据各做过变异验证 - (分别短路掉 → 红 3 / 1 / 3 例)。每条「不入图」都配了一条只换一个条件的正例, - 免得测试退化成「这个角色被整体拉黑」。 -- **仍未验**:真 CLI 下 `workgraph-planning` 雇完一轮后控制台左栏的实况(验收标准 7),归第三阶段。 - -#### W8 落地记录(按钮已有真实调用方;mock 演示仍差两环,两环都不在 UI 侧) - -- **入口两处,同一个组件** `web/src/components/graph-console/ReplanOrgGraphButton.vue`: - `GraphConsole` 左栏(有图 → 标题栏小按钮;**无图 → 左栏空态里的主操作**,没有 org graph 的公司 - 整个控制台无事可做,入口在这里必须是主按钮而不是一句「先跑 orggraph-planning」的提示语); - `CompanyDashboard` 的「🕸️ 组织流程」块(同样有图挂标题栏、空态给大按钮——新建公司后 - 用户最先看到的就是这一屏的「暂无组织流程」)。至此 `POST /:companyId/org-graph/replan` - 有了前端调用方,不再只能 curl。 -- **过渡态**:`posting` → `planning mm:ss`(按钮禁用防连点)→ `done` / `timeout`(6 分钟,可「继续等」)。 - 已有图时先 `confirm`:`writeGraph` 是整图覆盖,旧图作废。 -- **刷新:轮询 `GET /:companyId/org-graph` 的图指纹(4s),不是 SSE**。replan 内部那条 agent 对话的 - SSE 由服务端自己消费(`triggerOrgGraphPlanning` 的 fetch),浏览器没有把手,端点也不回 - `conversationId`;控制台现成的 `/graph-runs/:id/stream` 是 graph run 的事件轨,而 org graph - 规划不产生 graph run。指纹**含节点 id**(`writeGraph` 每次换新 id),否则「重规划出同一张图」 - 会被误判成超时。变化即 `emit('planned')` → 父组件重载左栏定义 + run 列表(旧 run 的节点与新图对不上,必须一起重拉)。 -- **fixture**:新增 `fixtures/devloop-mock/orggraph-planning.json`(此前回放 `default.json`, - 建公司时的自动规划是空转)。产出 `org-planner → org-coder → org-validator` + 两条 fail 回退边, - 形状对齐 `system/org-graph-templates/dev.json`。实测把它 chunks 里那段图 POST 给真正的写入方: - `{"ok":true,"nodeCount":3,"edgeCount":4}`,GET 读回四条边齐全(含 `input_underspecified` 那条)。 - 锁:`server/tests/unit/orggraph-planning-fixture.test.js`(4 例:图在 chunks 不在 result / - 形状对齐 dev.json / 过五项校验 / mock 协议按 agent id 与 `{cid}:` 作用域名都能回放)。 -- **🟡 mock 下「点一下就出图」还差两环**(真 CLI 模式不受影响,agent 自己跑 curl): - 1. **fixture 命不中**:`_sendMockPrompt` 用 `agentInfo.name` 冒号后那段查 fixture。 - `graph-engine` 传 `{cid}:{agentId}`(命中),`agent-chat.js:684` 的 `getOrCreateProtocol` - 传的是 **identity.md 的显示名**——`orggraph-planning` 的 identity.md 没有「名称:」行, - 退化成字面量 `'Agent'` → 回放 `default.json`(实测就是那两块 38 / 83 字符)。 - `orggraph-planning` 只有 chat 这一条入口。最小修法:把 agent id 传给协议 - (同时协议侧 `rawName` 要改成 `options.agentName` 优先,否则 `agentInfo.name` 恒真)。 - 2. **mock 没有落库环**:真实 agent 是 Step 6 自己 `curl -d @/tmp/x.json` POST `/org-graph` - 写进去的,而 mock 只回放 chunks/files/result,**不执行任何工具调用**,HTTP 副作用为零。 - work 层没这个问题(`extractPlannedGraph` 从 output 解析)。要让 mock 也能演示, - 需在 `triggerOrgGraphPlanning` 消费完输出后 `extractPlannedGraph` → `writeGraph`(仅当图仍为空)。 - 实测:mock 下 `POST .../org-graph/replan` → `{"status":"replanning"}`,日志 - `[Replan] ✅ 已触发 {cid}:orggraph-planning`,但 `GET /org-graph` 仍是 `{nodes:[],edges:[]}`。 - **验收标准 5 仍要留到真 CLI 那一轮验。** -- 顺带确认:`CompanyDashboard` 的「🕸️ 执行控制台」入口仍在(`goToGraphConsole`)。 -- 附带发现(与 W1 相关,正面):`writeGraph` 会给未登记的 `systemAgentId` 自动补 `company_hired` 行; - 本轮测试公司删掉后 `graph-seed-service.reconcileDanglingRows()` 一次收干净 - (`orgNodesRemoved:3 / orgEdgesRemoved:4 / agentsRemoved:[org-planner, org-coder]`)。 - -### 第三批 · 让结果可信 - -| # | 内容 | 量 | -|---|---|---| -| **W9** ✅ 已实现,🟡 真跑未验 | 失败上下文进提示词:`validator-triager` 的 agent.md 承诺「给你哪个节点失败、第几 attempt、失败摘要」,全仓库没有代码提供。已由 `describeFailureContext` 补上,详见下方「W9 落地记录」 | 中 | -| **W10** ✅ | ~~跨层地址要 `target_org_node` + `target_node` **两半都写**才合法~~ —— **原表述与引擎相反,已订正**:`target_node` 是唯一必填半,`target_org_node` 可选。模板已按引擎逐字改写,并补上「org node 名从哪来」。详见下方「W10 落地记录」 | 小 | -| **W11** ✅ | `system/skills/meta-*` 缺失导致 base agent 的 Hot Swap 静默空转。**选方案 (b)**:不补技能包,改成显式告警 + 如实改文档。核实出更深一层:补技能包也关不上权限闸(工具权限来自 `config.json` 的 `toolPolicy`,与 phase 无关)。详见下方「W11 落地记录」 | 中 | -| **W13** ✅ 真跑已验(§六bis:整轮真跑 git status 与基线逐字相同) | agent 运行态文件从 git 移出去(守卫误判的**一半**根因;另一半是并行 agent,见 §六bis 缺陷②,W13 管不到)| 小 | - -#### W9 落地记录(已实现;未起真 CLI,用注入 executor 验提示词文本) - -- **接在哪**:`graph-engine.advanceOne` 组好 `ctx` 之后、拼产物契约之前, - `ctx.task += describeFailureContext(ctx)`。**两种节点都拼**:`agent_call` 的 `ctx.task` - 直接就是提示词正文;`subgraph` 的 `ctx.task` 被 `buildPlanningPrompt` 原样放进 - 「## ② 任务描述」,规划本体重规划时同样需要知道上一轮死在哪。 -- **段落形状**(只在**确实有前序失败**时出现,没有就是空串,不塞空段落白烧上下文): - - ``` - [前序失败上下文 —— 引擎提供,本 run 里最近的一次节点失败] - 失败节点:**benchmark-validator**(node_run_id=grn_xxx) - 第几次尝试:attempt-1(人类计数第 2 次) - 当时落库的 fail_reason:impl_bug - 失败摘要(来自 node.output):契约非法:acceptance 缺字段…(已截断,完整输出见 output_ref) - 你若是归因节点(validator-triager):上面这个**节点名**就是 agent.md 里 `target_node` - 三个取名来源中的第 (a) 个(引擎点名的失败节点),可直接用;判断它不是真正的根因时再走 (b)/(c)。 - ``` - - 这一段正是 agent.md 那三个取名来源里**第 (a) 个**的供给端 —— 在此之前它恒为空, - 归因只能靠 (b)/(c) 猜。 -- **数据源是 `timeline.ndjson`,不是 `graph_run_nodes`**(这是本项的关键判断): - 直觉写法 `SELECT … WHERE status IN ('fail','blocked')` 在**最需要它的那条路径上恰好取不到值**—— - `benchmark-validator` 失败 → backward 边退回 `validator-triager` → - `handleBackwardEdge` 调 `resetForwardDownstream`,而 `benchmark-validator` 正是 - `validator-triager` 的 forward 下游,**被重置成 `pending` 且 `fail_reason` / `remediation_note` - 一并 `SET NULL`**。等归因节点真正跑起来时,库里已经没有任何一行记着刚才谁失败了。 - `timeline.ndjson` 是 append-only、永不改写已有行的审计轨,失败那一刻的 `node.output` - (带 W12 那一轮加的 `output_excerpt`)原样躺在里面。 - 测试里显式断言了那一刻库里确实**零失败行**、而提示词里仍然有上下文。 -- **顺带给 W12 的 `output_excerpt` 加了第二个消费方**:它不再只是「给人看的」, - 引擎自己会把它读回来喂给下一个节点。 -- **四环**:产生(节点失败时的 `node.output` / `node.error` 事件,W12 那一轮落的)→ - 落库(`timeline.ndjson`,append-only)→ 读回(`readRunTimeline` + `findLastFailure`)→ - 传递(`ctx.task` → `AgentManager.callAgent` 的提示词 / `buildPlanningPrompt` 的 ②)。 -- **已知上限(ponytail)**:每个节点起跑前读一次整份 `timeline.ndjson`(切尾 2000 行)。 - O(文件大小),而文件大小被任务级预算(十几到几十次调用)钳住,眼下无所谓; - 真要拆的话升级路径是给 `graph-run-sse` 加「按 ref 倒读最近 N 条」的接口,**不是**加一列缓存。 -- **作用域是「本 run」**:按 timeline 行的 `run_id` 过滤。子 run 里的失败不会串到父 run 的 - 提示词里(父层拿到的是 `deriveChildOutcome` 那条透传),反之亦然。 -- **仍未验**:真 CLI 下 `validator-triager` 是否真的照着 (a) 取名(mock 不读提示词,验不了)。 - 对应验收标准 8。 -- 锁:`server/tests/unit/graph-engine-fail-reason.test.js` 后 6 例(含 backward 回退那条关键路径)。 - -#### W13 落地记录(已完成;未起真 CLI,用 mock + 服务层验) - -**根因**:system agent 的 Claude CLI workDir **就是它自己的源码目录** `system/agents/<id>/`, -而那是被 git 跟踪的源码树。它每跑一次就重写自己的 `todo.md` / `plan.md`,Claude CLI 还改 -`.claude/scheduled_tasks.json`;边界守卫扫的是整仓 `git status`,于是这些改动被归属到 -「当时正在跑的那个 work 节点」头上 —— 一个真正成功的节点被判越界 fail(W3 那次真跑的断点)。 - -**已移出 git 索引(`git rm --cached` + `.gitignore`)**,共 17 个文件: - -| 路径模式 | 判据 | -|---|---| -| `system/agents/*/todo.md`(8) | 内容是 agent 自写的任务流水(最大 112KB / `innovator-evolver`),无人工维护内容 | -| `system/agents/*/plan.md`(5) | 同上 | -| `system/agents/*/.claude/scheduled_tasks.json`(3) | Claude CLI 自己写的 cron 状态(含 `lastFiredAt` 时间戳),必然每跑必变 | -| `system/agents/*/tasks/*.json`(1) | agent 自己落的任务快照,正规任务元数据在已忽略的 `system/tasklog/` | - -**不移的**(排查后确认是源码 / 人工维护): -`system/templates/**` 全部(`todo.md` / `plan.md` 是 hire 的**拷贝源**, -`company-service.TEMPLATE_FILES` 白名单从这里取)、`cron.json`(人工配置, -`9f15ed3` 特意把 `enabled` 关成 false 防夜间 IO 崩,且只由 `PUT /api/agent/:id/cron` 显式改写)、 -`config.json` / `agent.md` / `soul.md` / `identity.md` / `tools.md` / `user.md` / `heartbeat.md`。 -已经在 `.gitignore` 里的运行态目录(`memory/` `workspace/` `conversations/` `logs/` -`system/tasklog/`)不需要动 —— `memory.md` 和 `_lessons.md` 分别落在 `memory/`、`workspace/` 下,本来就没被跟踪。 - -**缺省逻辑**:读侧唯一入口是 `agent-chat.js:loadFile()`(`readFileSync` 包 `try/catch` → `''`), -缺失只是提示词里少一段,不崩;`_copyTemplate()` 逐个文件 `existsSync` 后再拷; -新 agent 的这两个文件由 `AgentScaffold` / `_copyTemplate` 从模板生成。 -**新 clone 验证**:把这 17 个文件移走后 `LEMONBOSS_CLAUDE_MODE=mock node server/index.js` 起服务, -`/api/health` `/api/agent/list` `/api/agent/:id/files` `/api/agent/:id/cron` `/api/company/templates` -全部正常,服务端日志零 error / 零 ENOENT。 - -**`_registry.json` 的 usedCount 同类问题**:`company-service._updateTemplateUsedCount()` 每次 hire 都 -原地 +1 改写这份**人工维护**的模板清单(`template-refs.test.js` 拿它当登记表校验),于是任何一次真跑 -都留一条 `M system/templates/_registry.json`。现在计数搬到 `data/template-usage.json`(已 gitignore), -registry 里的 `usedCount` 退化成**冻结的人工基线**,读回时基线 + 增量相加,前端 badge 数字不回退。 -四环:`hire()` 命中模板分支 `_updateTemplateUsedCount()` → 写 `data/template-usage.json` → -`listTemplates()` 合并 → `GET /api/company/templates` → `CompanyDashboard.vue` / `TemplateAgents.vue` 的 -`tpl.usedCount`。跑完整套件后 registry 字节不变、usage 文件里有 11 个模板的真实计数 —— 四环 + 真调用方同时被证。 - -**`graph-engine` 的守卫豁免保留**(结论):根治只覆盖「这批已知文件」, -agent 在自己目录里新写一个没进清单的文件仍会被扫到;而且 `system/agents/*/` 里还有 -`system/agents/templates/`、`system/agents/innovator-hunt/system/templates/` 这类 -agent 路径解析错误留下的**历史目录**,说明它确实会往意想不到的地方写。留作第二道防线, -但注释要从「缓解不是根治」改成「根治已做(W13),这里是兜底」。 - -> **28 号改造后已改名并放宽**(旧名 `AGENT_RUNTIME_STATE_RE` → 现 `AGENT_REGISTRY_TREE_RE = /^system\/agents\//`): -> 本节写下这条结论时,豁免是「本体根上的五个固定文件名」;28 之后 `system/agents/` 成了 -> **agent 注册表**而不是纯源码树 —— 新本体的落点从(被 ignore 的)`data/companies/{cid}/agents/` -> 搬到了(被 git 跟踪的)`system/agents/{id}/`,于是 `hireFromBaseMetaAgent` / agent-creator -> 在运行期新建的目录会以 `?? system/agents/{newId}/` **一整条目录项**进 `git status`, -> 五文件名的名单接不住 → 一个**真正成功**的招聘节点被判 `boundary_escape` fail。 -> gitignore 永远挡不住这一类(新本体的骨架本身就是要进版本库的),所以必须在守卫侧解决: -> 整棵注册表树归 `unattributable`(留痕不判罚),判罚留给 `server/` `web/` `packages/` 这些真源码目录。 -> 两道防线因此不重叠 —— gitignore 管「运行态不进 git」,守卫管「注册表树里的合法写入不判罚」。 -> 详见 28 号设计 §5.5b。 - -**测试**:`server/tests/unit/agent-runtime-state.test.js`(6 例)—— 锁 `git ls-files` 不再返回这些路径、 -`git check-ignore` 真的挡得住、模板本体仍被跟踪、usedCount 三环 + 坏文件降级。 - -#### W10 落地记录(已完成;只改模板 + 加一个测试文件,未起真 CLI) - -**先说结论:W10 原文写反了。** 原文说「`target_org_node` + `target_node` 两半都写才合法, -只写一半会静默退化」。拿到引擎上实跑,**只写 `target_node` 是完全有效的**: - -| 环节 | 代码(`graph-engine.js`) | 实际行为 | -|---|---|---| -| 产生 | `readNodeProduct`:`normalizeNodeRef({ orgNode: doc.target_org_node, workNode: doc.target_node })` | 读的就是这两个**顶层**键 | -| 合法性 | `normalizeNodeRef`:`if (!workNode) return null` → `orgNode` 取不到就写死 `null` | **只有 `workNode` 缺失才判非法**;`orgNode` 缺省是合法的 | -| 落库 | `settleNode` → `failNodeRef` 列(与 `fail_reason` 同一次条件更新) | 存的是 `{"orgNode":null,"workNode":"…"}` | -| 解析 | `resolveCrossLayerTarget` 第 ④ 关:`if (ref.orgNode && ref.orgNode !== orgNodeName)` | `orgNode` 为 null 时**这一关根本不进**,以回退边的目标为准 | -| 读回 | `NodeDetailPanel.vue` 的 `readRef`(`v.workNode` 为真才算有地址)+ 概览渲染 `failRef.orgNode \|\| '(按回退边的目标)'` | 前端本来就按「org 那半可缺」写的 | - -**真正无声的是反过来那种写法**:只写 `target_org_node`、不写 `target_node`。 -此时 `normalizeNodeRef` 返回 null,而降级 timeline 的守卫是 `if (doc.target_node != null && !failNodeRef)` -—— 你根本没写 `target_node`,**一条记录都不会留**,控制台只显示「整张 work graph 重跑」。 -这是这条链上唯一一处完全没有痕迹的退化。 - -**改了什么**(`system/templates/validator-triager/{agent.md,tools.md}`): - -1. 粒度块的键表加了「必填?」一列,`target_node` 标 ✅ 必填、`target_org_node` 标 ⭕ 可选, - 并把「两个都给全」的措辞整段换掉(那句话会诱导 agent 去**编** org node 名, - 恰是同一份 agent.md 自己那条红线); -2. 明写反向那种写法是「全链路唯一一处完全无声的退化」,要删就两个键一起删; -3. 四道关的第 ④ 关改成「**若你写了** `target_org_node`」,与 `if (ref.orgNode && …)` 对齐; -4. **补上原本缺的那一环:`<rootRunId>` 从哪来**。agent 的提示词里没有 org 层上下文 - (`describeTask` 只拼 task-goal + 任务标识),但 `[节点产物目录]` 那段给的是**绝对路径** - `…/data/companies/<companyId>/runs/<rootRunId>/nodes/<nodeRunId>/`(`nodeArtifactDir` → - `runRootDir`,用的是 `run.root_run_id`,子 run 里的节点也落在根 run 目录下)。切出 `runs/` - 后面那一段就能 `GET /api/graph-runs/<rootRunId>` 拿到**组织层**那张图: - `run.graph_kind==="org"` 确认层级,`nodes[].name` 是 org node 名、`nodes[].nodeKind` - 筛出 `subgraph`、`nodes[].childRunId` 再查一层就是可填进 `target_node` 的候选节点名。 - 原 tools.md 里那两条 curl 是对的,但 `<rootRunId>` 一直**没有出处**,agent 填不出来。 - -**测试**:`server/tests/unit/graph-cross-layer-address.test.js`(3 例,全部走注入假 executor,不起 CLI) -—— ① 只写 `target_node` → 定点重置生效、`impl-b` 一次都没重跑、账本 `target_node_ref` 里的 -`orgNode` 由引擎补成 `coder`;② 只写 `target_org_node` → 地址整条没落库、整张子图重跑; -③ 模板里的键名与引擎逐字一致,且不许出现 `targetNode` / `target_work_node` 之类近似拼写、 -不许再出现「两个都给全」「两半都写」这两句已被推翻的措辞。 - -> 顺带说明**为什么不去修引擎那处无声降级**(给「只写了 org 半」也补一条 timeline): -> `graph-engine.js` 本轮由别的并行线独占,本轮只在模板侧堵。要修的话是把 -> `if (doc.target_node != null && !failNodeRef)` 放宽成 -> `if ((doc.target_node != null || doc.target_org_node != null) && !failNodeRef)`,**一行**,留给后续轮次。 - -#### W11 落地记录(已完成;选 **方案 (b)**) - -**选 (b) 的论证 —— 关键证据是本轮新查出来的,不是原文里那条**: - -`system/skills/` 目录确实不存在(`agent-chat.js` 的 `existsSync` 恒 false),这一点原文没错。 -但补出四个 `meta-*` 技能包**并不能兑现「EXECUTE 阶段才拿破坏性权限」**: - -- Hot Swap 做的事只有 `copyFileSync(sourceSkill, agentDir/.claude/skills/main_skill/SKILL.md)`; - 那个文件的**唯一消费者**是 `buildSystemPrompt`,被当作提示词文本拼进「我的核心主技能」一段。 - 也就是说 **`meta-*` 能改的只有话术,改不了权限**。 -- 工具权限的**唯一**来源是 agent 自己 `config.json` 的 `cli.toolPolicy`, - 由 `getOrCreateProtocol` 在**建协议时读一次**转成 `--allowedTools` / `--disallowedTools`, - 与 `phase` **没有任何关联**。 -- 更直接的反证:`system/templates/base/config.json` 现在是 - `{"mode":"restricted","allowedTools":["bash","read_file","str_replace"]}` —— - **`bash` 和 `str_replace` 从 RESEARCH 第一秒就在手上**。所以「RESEARCH 阶段照样能写文件」 - 的成因是 `config.json` 直接给全了工具,**不是**技能包缺失。 - -**收尾补一处:agent 自己的提示词还在骗它自己(2026-08-17)** - -上面查清了「技能包改不了权限」,但 `system/templates/base/agent.md` **仍然在对 agent 本人** -写着这三句: - -- 开场「你当前默认只被挂载了 `meta-research` 这个元技能」—— 那个包不存在,没挂载过; -- 阶段三「*系统给你换上 `meta-execute` 技能时,你才会拥有修改核心文件的破坏性权限*」 - —— **这是纯假承诺**,权限自始至终由 `config.json` 决定,与 phase 无关; -- 阶段一/二「给你换上 Plan 技能」—— 同上。 - -方案 (b) 的本意是「如实降级」,只把 `agent-chat.js` 的空转喊出来是喊给**运维**听的; -agent 读的是 `agent.md`,那一侧不改,模型就会继续以为「前三阶段我写不动文件」—— -而它其实从第一秒就握着 `bash` 和 `str_replace`。**一个以为自己被拦着的 agent, -比知道没人拦它的 agent 更危险。** - -已改成如实表述:开场加一段⚠️说明「技能包不存在、换装不会发生、权限全程不变、 -四段式真正给的是**上下文隔离**而不是权限隔离」,并把三处「换上 X 技能」改成 -「记忆已清空,你手上只有 <artifact>」。阶段三那句改成 -「你的写入权限从第一阶段起就没变过 —— 『前三步不落盘』是纪律要求,不是系统给你的限制」。 - -至此 W11 的 (b) 才算真的闭合:**运维侧有告警、agent 侧不再被误导、文档侧记清了根因**。 -真要落地按阶段收权限,见 27-P3/P4 §九bis 的结论(必须让 `transition` 同期改写生效中的 -`toolPolicy`,且它与「四段式改写成引擎上的标准图模板」是同一件事的两半)。 - -结论:走 (a) 会造出一个**比现状更危险的状态** —— 四个文件躺着、日志说「已切换」、文档可以打勾, -而权限闸门一如既往地开着。这正是 T9 那次「落地了降级却既没核实也没回改文档」的同型。 -真要落地阶段化权限,必须让 `transition` 同时按 phase 改写生效中的 `toolPolicy`, -那与 §九bis 已列为「P3 之后」的「四段式改写成引擎上的标准图模板」是同一件事 -(改造后每个阶段是一个独立节点、独立一次 agent 调用,`toolPolicy` 才第一次有机会按阶段取值)。 - -**改了什么**: - -- `server/routes/agent-chat.js` Hot Swap 段补 `else` 分支,打一条 `console.warn`, - 写明「元技能包不存在(附完整路径)→ 本次流转**未换装** → 工具权限仍由 `config.json` 的 - `cli.toolPolicy` 决定,与阶段无关」。**只加了这一个 else 分支,别处未动。** -- `27-P3/P4` §九bis:Hot Swap 那行由「❌ 静默失效」改成「❌ 仍然失效(不再静默,改成显式告警)」, - 新增一整块「为什么不选方案 a」的论证与证据;「另两处顺带发现」(`phase` 无校验、 - 可跳阶段、直接拼进路径)标注为**均未修、仍然成立**;§九bis 末尾那三条改造收益的第 2 条 - 补上「改造后 `toolPolicy` 才有机会按阶段取值」这半 —— 那正是方案 a 单独做不到的部分。 - -**没做什么(明确不留第三种状态)**:`system/skills/` 目录仍然不存在,四个 `meta-*` 技能包**一个都没建**。 -这一维的**能力没有变化**,变的只是它不再无声。 - ---- - -## 五、成本预估 - -一次 3 个 org node 的任务:3 次规划调用 + 每张 work graph 3-4 个节点,不含回退约 **15 次调用**,带一两次回退到 **20-25 次**。按 `plan-cost-concurrency.md` 的实测量级推算,单次 **$3-8、墙钟 15-40 分钟**。 - -**调试期一律用单 org node 的最小图**(省约三分之二)。第一批的验收就按这个规模定。 - ---- - -## 六、验收标准 - -**第一批** - -| # | 标准 | 状态 | -|---|---|---| -| 1 | 服务启动后 `system_agents` 与 `system/templates/` 一致,无悬空 `company_hired` 行;新加一个模板目录、重启服务,它自动出现在表里 | ✅ **已验**(生产库实查 + hermetic 用例)。一处**已知且合理**的偏差:`system/agents/` 与 `system/templates/` 同名的 6 个 id(`agent-creator` / `org-innovator` / `innovator-{digest,evolver,hunt}` / `evolver-youtube-crawler`)按设计 skip,仍是 `global_agent`。图用到的本体都不在这 6 个里 | -| 2 | 起一个真 CLI 的 run,节点产出全部落在 `data/companies/{cid}/projects/{pid}/` 内;`git status` 在宿主仓库无任何新增/修改 | 🟡 **前半已验、后半被并行 agent 打破**(2026-08-17,§六bis)。**2026-08-20 补记**:后半那个「被并行 agent 打破」本身已经不再导致误判 —— 守卫改成「节点开跑那一刻仓库就脏 → 只记证据、不判罚」,见 [27-P3C §17.2](../../done/system-design/27-p3c-goal-task-model.md)。**但这条验收标准本身仍未在干净环境下重验**。当时的原始记录:`workgraph-planning` 的产物真的落在 `data/companies/p3bfin/projects/p-24vxi8/workspace/` 内,`project-root.json` 四环闭合;宿主仓库出现的两处改动经查是**并行的 Master agent** 写的(缺陷②),已复原。被判罚的那个 work 节点自己一个字节都没往仓库写 | -| 3 | 故意让节点往产物目录外写,被守卫拒绝并在 timeline 留痕 | ✅ **真 CLI 已验**(2026-08-17,§六bis):git 扫描确实扫到了宿主仓库里的那处改动,`node.boundary` 事件带完整 `entries`,结果被降级判 fail 且 `fail_reason='boundary_escape'` 落库。**但同一次也验出了误判**:那处改动是并行的 Master agent 写的,不是被判罚的节点写的(W3「拦不住」第 5 条,见 §六bis 缺陷②)。判罚逻辑侧另有 W12 补完那一轮加的三条分支用例。**2026-08-20 补记**:误判那一半已修([27-P3C §17.2](../../done/system-design/27-p3c-goal-task-model.md),`BOUNDARY_GUARD=strict|record` 两个开关 + 8 例守卫,含「仓库干净时同样一处越界仍判 fail」的对照) | -| 4 | 造一份非法契约跑 `benchmark-validator`:判决为失败且引擎按失败处理(**不是** success) | 🟡 **静态成立,未真跑**。模板已改写 `verdict: "attributed"`,而 `FAILING_VERDICTS={attributed,inconclusive}` → `readNodeProduct` 置 `failingVerdict=true` → `callAgentNode` 判 `fail`(`graph-engine.js:473/385`),链路读通;防线测试三次反例变异全部变红 | - -> 2 与 3 都要真 CLI 才验得了,而真 CLI 现在会先死在 W5/W6 上。**这两条应挪到第二批之后一起验。** - -**第二批** - -5. 新建公司 → 界面点「重新规划组织」→ `orggraph-planning` 真跑 → org graph 出现,且它雇的三个 org 节点都在。 -6. 下达任务 → `workgraph-planning` 真跑 → 它引用的本体被自己雇进公司 → 图提交成功(不再外键挂)。 -7. 上条跑完后,控制台左栏 org graph **仍然只有 org 节点**,没有混进 `prd-planning` 这类 work 层本体。 - ✅ **已修**(`58a2865`「W7 零边图」,2026-08-17 当天)。判据加了主判据 - `authored_by='planner'` 的节点存在 —— 「orggraph-planning 提交过一张图」的直接证据, - 零边也认;边数降为存量行的回落判据。守卫 `company-hire-org-layer.test.js` 的 - `CO_ZEROEDGE`(零边图上 work 层本体不入图)+ `W7-fix`(零边图上图里点名的 org 节点 - 重复雇仍入图,P2 语义不被误伤)。 - **以下是修之前的现场记录,留档不是当前状态**:三条判据都盖不住 - 「零条边的最小 org 图 + 一个既非 `meta-*`、又没在 work graph 骨架里点过名的本体」, - `readme-writer` 被真的写进了 `org_graph_nodes`,`GET /org-graph` 返回两个节点, - 下一次任务的左栏并排显示两张 org 卡片。 - 服务层那 9 例测试仍然全绿——它们造的是**有边**的成型图,正好绕开了这个形状。 - -**第三批** - -8. `validator-triager` 的提示词里能看到「哪个节点失败、第几 attempt、失败摘要」。 - 🟡 **代码已成立,真 CLI 未验**:`describeFailureContext` 已接进 `advanceOne`, - 6 例测试(注入 executor 抓 `ctx.task` 原文)覆盖 forward fail 边、backward 回退、 - subgraph 规划、超长截断、以及「无前序失败时一个字都不塞」。见上方 W9 落地记录。 - 真 CLI 下 agent 是否照着 (a) 取名,归真跑那一轮。 -9. 一次真实的跨层回退:`target_org_node` + `target_node` 两半齐全,只有被点名的节点被重置。 - ---- - -## 六bis、第三次真 CLI 终验(2026-08-17,未改一行代码) - -**跑法**:主仓库 vite(5173,前端代码)+ **worktree** `.claude/worktrees/wf_55ba803e-5ac-6` 的真 CLI 服务 -(3010,`data/` 独立)。worktree 先 `git reset --hard 3d9cb5b`,再把主工作树里未提交的本轮改动 -(14 改 + 5 新增 + W13 的 17 个 `git rm --cached`)拷进去。公司 `p3bfin`,单 org 节点最小图。 -预算 `total_agent_calls` 实跑 2/15。跑完公司已删净(DB 四表 0 行、`PRAGMA foreign_key_check` 空、目录 rm)。 - -| 验收点 | 结果 | -|---|---| -| 建公司 → `orggraph-planning` 真跑落图并自雇(R-18.1) | ✅ 成立。创建后 1 分钟内 `org_graph_nodes` 出现 `org-writer`,`orggraph-planning` / `workgraph-planning` 按 W7 判据①**没有**混进 org 节点(日志逐条留痕) | -| 界面点「下达任务」→ 起 run(W14) | ✅ 成立。点按钮 → 弹出「任务描述 + 手动/自动」→ 点「下达」→ 新 root run `run_9da101273663` RUNNING、预算条 `calls 0/100` 出现、入口节点 READY | -| `workgraph-planning` 真跑规划 + 自雇(W5/W6) | ✅ 成立。`wg_55c7e53f959f` 两节点(`org-writer-impl`→`readme-writer`、`org-writer-validator`→`planner-validator`)+ 正向边 + backward fail 兜底边;两个本体都被它自己 `hire` 进 `data/companies/p3bfin/agents/`,图提交没有外键挂。**这一段是 Master 自主巡检下的单,不是我点的「下一步」**——同一条链路、同一个端点 | -| RUNNING 中的节点有进度感(本轮新增) | ✅ 成立。org 节点卡片 `⏱ 2m 23s` 秒表在走 + 呼吸点;详情面板常驻条「运行中 2m 40s · 最近事件 `plan.call` 调 workgraph-planning 规划子图(第 1 轮)(2m 40s 前)」;work 节点上换成 `agent.call · 调用 readme-writer(attempt #1)`;概览 tab 的「耗时」不再冻结 | -| 跑一个真 work 节点 → success | ⛔ **本轮没跑到**。`org-writer-impl` 跑了 7m19s 后 fail,真实根因是 `AgentManager._runViaProtocol` 的 `idleTimeoutMs` 硬编码 120s:`readme-writer` 通读 `graph-engine.js` 期间静默超过 2 分钟被掐死(`idle timeout: no stream data for 120000ms`)。详见下方「本轮查出的三处缺陷」① | -| 失败节点的概览 tab 能看到原因(不是 —) | ✅ 成立。work 节点与 org 节点的概览都显示 `fail_reason` = `[引擎判定] boundary_escape`,下面一块终端样式 excerpt 写明「[边界守卫] …留下 1 处改动」+ agent 原始错误串;「运行日志」tab 里 `node.boundary` / `node.output` / `node.status` 七条事件都到了浏览器(W12-UI 的具名事件白名单在真 CLI 下生效) | -| `fail_reason` 真落库 | ✅ 成立。`graph_run_nodes.fail_reason='boundary_escape'`,work 节点与**父 org 节点**两行都有(子 run 终态回写父节点那个修复没被打破)。四环(产生→传递→落库→读回)在真 CLI 下走通 | -| W13:真跑期间 `git status` 不再出现 agent 运行态条目 | ✅ 成立。整轮真跑(含 `agent-creator` / `master` 各一次真 CLI)结束时 worktree `git status` 与基线**逐字相同**;`git check-ignore -v` 四条模式全部命中;`system/templates/_registry.json` **零 diff**,3 次 hire 的计数落在已忽略的 `data/template-usage.json`。守卫这一轮**没有**用到 `splitUnattributable` 兜任何一条 agent 运行态文件 | -| W3 守卫:真 CLI 下 git 扫描能不能扫到越界写 | ✅ **扫得到**(第一批验收标准 3 从 🟡 转 ✅)—— 但扫到的是**并行的 Master agent** 写的,不是本节点写的。见下方缺陷② | -| W10 跨层地址两半齐全 | ⚪ **本轮无回退发生**(没有节点走到归因产出这一步),未验 | -| 第一批验收标准 2(产出全落项目根、宿主仓库无新增) | 🟡 **一半成立**:项目根 `data/companies/p3bfin/projects/p-24vxi8/` 真的被分配并写入(`workspace/workgraph-plan-T-*.md`、`workgraph-T-*.json`),`project-root.json` 四环闭合;但宿主仓库**有**两处新增改动 —— 来源是并行的 Master agent(缺陷②) | - -### 本轮查出的三处缺陷(都未改代码) - -**① `AgentManager` 的 120s idle timeout 掐死长思考的 work 节点** — `server/src/core/agent-manager.js:324` - -```js -const idleTimeoutMs = this.config?.agents?.[agentName]?.idleTimeout || 120_000; -``` - -同进程里 `ClaudeCliProtocol` 早就把自己的 idle 上限放到 24h(日志里那句「🔧 工具执行中 → 超时切为 86400s」), -`stream-executor` 的 JSDoc 默认值写的是 `1_800_000`(30 分钟)。这里的 120s 把两者都压掉一个数量级, -而且 `...options` 里若透传 `idleTimeoutMs` 会被下一行覆盖掉 —— 调用方无法覆写。 -真 CLI 下任何一个「先读几千行源码再动笔」的 work 节点都会在动笔前被杀,产物为空。 -**这是本轮「真 work 节点 → success」没跑成的唯一原因**,与 W1-W14 无关,属新缺陷。 - -**② 边界守卫在有并行 agent 时误判(W3「拦不住」第 5 条真实发生)** - -`org-writer-impl` 跑到第 7 分钟时,**另一个进程里的 Master agent**(`retrain-watchdog` 每 5 分钟一次的智能巡检唤起) -在同一个 worktree 里改了两个源文件: - -- `server/tests/unit/company-hire-org-layer.test.js`(+82/-6,给 W7 加了一条判据④和配套用例) -- `server/src/core/agent-manager.js`(改上面那个 idle timeout;且引用了**未定义**的 `DEFAULT_IDLE_TIMEOUT_MS`, - 真跑起来是 ReferenceError) - -守卫的 `git status` 差集把第一处算到了正在跑的 work 节点头上 → `boundary_escape` → 该节点被降级判 fail。 -两处改动已 `git checkout` 复原(diff 存档 `/tmp/master-agent-pollution{,-2}.diff`),主仓库全程零污染。 -**结论**:真跑期间不能有任何别的 agent 在同一工作树里活动,否则守卫必误判; -`retrain-watchdog` 的 Master 巡检就是这样一个 agent,它默认开着且没有开关。 - -**③ W7 的「已知残留」在真跑里被踩中,且后果比注释写的更直接** - -`decideOrgNodeRecording` 判据③要求「公司已有 active 的 `org_graph_edges`」, -而单 org 节点的最小图**零条边**,判据③恒假;`readme-writer` 既不是 `meta-*`(判据①) -也不在 `system/work-graph-templates/*.json` 里(判据②)—— 于是它被写进 `org_graph_nodes`。 -`GET /api/company/p3bfin/org-graph` 实测返回 **两个** org 节点(`org-writer` + `readme-writer`), -下一次「下达任务」的控制台左栏就并排显示两张卡片、两个都 READY、都会被 `advanceOne` 当 org 节点执行 -(截图 `screenshot-1786937704828-44.jpg`)。 -→ **验收标准 7 在「零边图」这一形状下不成立**,状态应从 🟡 改为 ⛔(下方已改)。 - -**④ 顺带:`blocked` 的 root run 让控制台变成死胡同**(前端/后端取值域不一致) - -`web/src/views/GraphConsole.vue:183` 的 `ACTIVE_RUN` 含 `blocked`, -而 `server/routes/company.js` 的 `ACTIVE_ROOT_RUN_STATUS` 只有 `['ready','running','paused']`。 -run 收敛成 `blocked` 后:「下达任务」按钮被前端藏起来(后端其实会放行), -`GraphRunPane` 的「停止」也被 `can('stop')` 置灰(后端 `POST /:id/stop` 实测能把 blocked → stopped)。 -**一家公司只要失败过一次,控制台上就再也下不了第二个任务**,只能走 API。 -按 W14 那段注释自述的意图(「按钮的显隐要和端点的 409 条件一致」),这是它自己没对齐。 - -### 本轮没做的 - -- **没点「下一步」**:断点(缺陷①)在第一个 work 节点上已经明确,再花一次 3-5 分钟的规划调用 - 只是重跑一条同一轮里已经走通的路径,按 §七.1 的成本纪律停手。 -- **没验 W10**(没有回退发生)、**没验第一批标准 4**(`benchmark-validator` 没进这张图)。 -- **没造「引用不存在本体」的失败节点**:真跑自己给出了一个更真实的失败样本 - (`boundary_escape` + idle timeout),第 6 项的两个验收点(概览有原因、`fail_reason` 落库) - 已由它满足,不必再造一个。 - ---- - -## 七、风险 - -1. **成本不可控**:真 CLI 下一次跑错方向就是几美元 + 半小时。缓解:调试期强制单 org node;任务级预算的 `total_agent_calls` 压到 15 以内先跑通。 -2. **W7 的语义决定要谨慎**:`hire()` 写 `org_graph_nodes` 是 P2 §4.2 定的(「hire 成功后写一行,幂等」),当时的前提是只有 org 节点会被雇。方案 a 打破了这个前提。改动要确认不破 P2 已有的 24 条测试。 -3. **真 CLI 会暴露一批模板质量问题**:前七轮所有 agent 模板都只在 mock 下「跑过」,而 mock 不读提示词——**这些提示词实际上从未被任何模型读过**。第一次真跑大概率会集中暴露表述矛盾(W4 就是一例,`benchmark-validator` 的 Step 表与自己的产出格式表就互相打架)。 -4. **不要在没有 W2/W3 的情况下跑真 CLI**:已经发生过一次 agent 写入宿主仓库。 - ---- - -## 八、`FAILING_VERDICTS` 要不要放宽(W4 的附带结论) - -> **`graph-engine.js` 归 W2/W3 那一轮改,本节只给结论与理由,不动代码。** - -### 结论 - -**要动,但不要用「把 `"fail"` 加进 `FAILING_VERDICTS`」这个改法——应该把判据反过来。** - -```js -// 现在(白名单记「失败词」,凡不认识的一律当通过 = fail-open) -const FAILING_VERDICTS = new Set(['attributed', 'inconclusive']); -failingVerdict = verdict === null ? raw !== null : FAILING_VERDICTS.has(verdict); - -// 建议(白名单记「通过词」,凡不认识的一律当失败 = fail-closed) -const PASSING_VERDICTS = new Set(['passed', 'skipped', 'produced', 'no_prior_failure']); -failingVerdict = verdict === null ? raw !== null : !PASSING_VERDICTS.has(verdict); -// 并对不认识的取值记一条 timeline(degraded: 'unknown_verdict'),与 fail_reason 的处理对称 -``` - -### 理由 - -1. **只补 `"fail"` 是打补丁,漏的是一整类。** 判据的形状才是缺陷:失败侧是白名单, - 于是 `failed` / `rejected` / `not_met` / `error` / `no` / `不通过` —— 任何一个模型 - 临场想出来的失败词,引擎都读成「没失败」。补一个词只堵一个洞,洞的数量是开放的。 -2. **模型不受模板约束,这一点 W4 本身就是证据。** mock 从不读提示词,所以这批提示词 - 在第一次真跑前从未被任何模型读过(§七.3)。模板只能提高写对的概率,不能保证; - 引擎是最后一道,它必须对没见过的词取安全的那一侧。 -3. **安全侧是「判失败」而不是「判通过」。** 判错成失败的代价是多跑一轮(烧计数、走兜底边, - 熔断有 `LOOP_HARD_CAP_EDGE/RUN` 兜底);判错成通过的代价是不合格产物直接放行, - 而且**不留任何痕迹**——这正是 W4 的失效方向。两种错代价不对称,判据就该偏向贵的那侧。 -4. **引擎里已有同形状的正确先例。** `fail_reason` 那一侧是封闭域(`ATTRIBUTION_REASONS`), - 自创取值 → 显式降级成 `null` + 一条 timeline,不静默相信。`verdict` 应当对称处理。 -5. **`verdict === null` 那条缺省分支不用动**:缺 `verdict` 但有 `fail_reason` 判失败, - 本来就是 fail-closed 的,改动只影响「写了 `verdict` 但引擎不认识」这一支。 -6. **代价接近零。** 通过词一共四个,现有模板全部命中(`template-verdicts.test.js` - 的 `PASSING_VERDICTS` 与它逐字一致,实测扫描无一例外)。反转后 - `attributed` / `inconclusive` 继续判失败,语义不变。 - -### 两点必须一起注意 - -- **反转不能替代 W4 的模板改动。** 反转之后 `verdict: "fail"` 会被判成失败, - 但那是「猜对了」,不是「写对了」:产出仍然带着一个引擎不承认的词, - `org-validator` 那种 pass/fail 审计词也仍然会串到判词字段上。模板必须写对,引擎兜底。 -- **反转会让 `template-verdicts.test.js` 报错,这是设计好的。** 它按名字从 - `graph-engine.js` 源码取 `FAILING_VERDICTS`,取不到就抛 - (「找不到 FAILING_VERDICTS——取值域防线失去依据,不要让它静默空跑」)。 - 真去反转的话,把该测试里 `engineSet('FAILING_VERDICTS')` 换成 `engineSet('PASSING_VERDICTS')` - 并把 `VERDICT_DOMAIN` 的组成对应改一行即可;**不要**把断言删掉了事。 - ---- - -## 六ter、第四次真跑复验(2026-08-20) - -**跑法**:主仓库、真 Claude CLI、工作区干净(`git status` 空)。公司 `e2e-demo`, -把 2026-08-18 卡住的那条 run(`run_9168bd24f46c`,做 lemonai 官网)解开后逐节点 `/next` 推到收敛。 -19 次真 agent 调用、177,462 tokens,全树 `completed`,goal → `done`。 -过程记在 [27-P3C §17.5](../../done/system-design/27-p3c-goal-task-model.md)。 - -### 逐条结果 - -| 验收 | 结果 | 证据 | -|---|---|---| -| **2** 产出落在项目根内、宿主仓库 `git status` 无新增/修改 | ✅ **成立** | 19 次真 agent 调用之后 `git status --porcelain` **完全为空**;交付物(`index.html` / `styles.css` / `app.js` / `assets/img/` / `DELIVERY.md`)都在 `data/companies/e2e-demo/projects/p-bkhylu/` 内。这是 §六bis「后半被并行 agent 打破」那一条的干净环境重验 | -| **3** 越界被守卫拒绝并留痕 | ✅ 早已成立;误判那一半 2026-08-20 修掉 | 见 [27-P3C §17.2](../../done/system-design/27-p3c-goal-task-model.md) | -| **4** 非法契约 → validator 判失败且引擎按失败处理 | ✅ **成立**,但**不是真 CLI 验的** | `graph-real-chain-smoke.test.js` ⑥(零注入链路,只换掉 CLI 子进程):fixture 产出 `verdict: "attributed"` → 节点 `fail`、`fail_reason=input_underspecified`、沿 fail 边点亮下游、判词落在下游节点行上。反向验过:verdict 改成 `passed` 立刻红 | -| **7** work 层本体不混进 org graph | ✅ 已修(`58a2865`) | `CO_ZEROEDGE` / `W7-fix` | -| **8** validator-triager 的提示词里有「哪个节点失败、第几 attempt、失败摘要」 | ✅ **成立(真 CLI)** | 判据是**它引用了只可能来自提示词的内部 id**:triager 的判词原文写着「`org-coder-impl` 私有目录 `rn_96a8cd696dcc` 下只有 `_research.md`」—— `rn_96a8cd696dcc` 是 `graph_run_nodes.id`,agent 没有任何别的途径知道它,只能来自 `describeFailureContext` 注入的那一段 | -| **9** 一次真实的跨层回退,两半地址齐全、只重置被点名的节点 | 🟡 **产出端通了,消费端没等到那个形状** | 见下 | - -### 验收 9 的准确状态 - -真跑里 triager **确实产出了两半地址**: - -``` -node.triage | 归因 verdict=attributed fail_reason=impl_bug → {"orgNode":"org-coder","workNode":"org-coder-impl"} -``` - -这一条本身有分量:[27-P3/P4](./27-p3-p4-work-graph-console.md) §八.22 的**第 13 处**记的正是 -「引擎四环全通,但产出方 `validator-triager` 的 agent.md 里根本没有这个键」—— -**那半条现在通了**,真实 agent 真的写出了 `orgNode` + `workNode`。 - -但紧接着的一条: - -``` -node.triage | 归因点名了 org-coder-impl,但回退目标 org-coder-impl 不是 subgraph 节点,忽略跨层地址 -``` - -**这不是缺陷,是判据在正常工作**:这次回退发生在 **work 层内部** -(`org-coder-validator` → `org-coder-impl`),`edge.to` 本身就是 `agent_call` 节点, -已经是最细粒度,没有「跨层」可言 —— `resolveCrossLayerTarget` 第 ② 关就是拦这个的。 - -所以验收 9 缺的是**那个形状本身**:需要一次 **org 层的回退边、且目标是 subgraph 节点**。 -今天这条 run 的三个 org 节点重试后一次过,没有 org 层失败。 - -> **别把「机制没验」和「机制没有」混为一谈**:定点重置的逻辑侧有 -> `graph-cross-layer-address.test.js` 三例注入式覆盖(只写 `target_node` / 只写 -> `target_org_node` / 模板键名逐字对齐)。缺的只是「真跑里发生过一次」。 - -### 顺带发现的一条缺陷(未修) - -真跑的 timeline 里,**每个节点的产物索引写入都失败了**,且被降级成一条日志: - -``` -node.output | 产物索引写入失败 contract.json: UNIQUE constraint failed: index 'idx_graph_files_path' -node.output | 产物索引写入失败 sdd.md: UNIQUE constraint failed: index 'idx_graph_files_path' -``` - -**根因已查明并修复(2026-08-20)**,而且它是个**存量库专属**缺陷: - -生产库里还留着产物索引加「版本」之前的那条**无条件**唯一索引 -`idx_graph_files_path`(`UNIQUE(company_id, COALESCE(task_id,''), rel_path)`)。 -加版本之后建表块换成了带谓词的 `idx_graph_files_current`(`WHERE superseded_by IS NULL`), -**但没人删旧的那条** —— `CREATE ... IF NOT EXISTS` 只管不重复建,不会删已有的。 - -于是 `recordFile` 那套「先把旧行 `superseded_by` 指向新行、再插新行」的版本化写法 -在存量库上整个失效:旧索引不认 `superseded_by`,同路径第二行直接撞。 -新建库压根没有那条索引,所以**只有存量库会踩**,任何单测都照不到。 - -修法是迁移里一句 `DROP INDEX IF EXISTS idx_graph_files_path`, -守卫 `graph-files-stale-index.test.js` 走存量库重启那条路(反向验过:不删就红)。 -生产库已实跑验证:删索引后同路径连产两次不再抛,当前版本仍只有一条。 - -> 这是**同一类问题的第三次**(`tasks` 加列的索引顺序、墙钟锚点迁移、这次)。 -> 判据可以固定成一句话:**凡是碰 schema,「存量库重启」那条路必须单独走一遍** -> —— 新建库的路径由单测天然覆盖,存量库的不会。 diff --git a/docs/todo/system-design/27-p4-parallel-and-artifacts.md b/docs/todo/system-design/27-p4-parallel-and-artifacts.md deleted file mode 100644 index 46d1bf8..0000000 --- a/docs/todo/system-design/27-p4-parallel-and-artifacts.md +++ /dev/null @@ -1,709 +0,0 @@ -# 27-P4. 并行执行与产物索引 - -> **所属**: [27 Graph Agent 架构](./27-graph-agent-architecture.md) 的 Phase P4 -> **版本**: v0.2(阶段 A/B/C 均已实现,见 §五 2026-08-21 记录;T5 是可插拔桩子,真实计费接口未接) -> **日期**: 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 的多实例隔离 - -**这是并行的前置条件,必须先做。** - -利用 §三.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_<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`)的真实提示词里出现:<br>`[上游输入 —— 引擎已按图上声明的数据契约解析好,直接读这些路径]`<br>`- brd:…/runs/run_fc7e9b8a29d5/nodes/rn_2e8dbb1c6ed7/brd.md`<br>`- contract(任务级共享):…/rn_2e8dbb1c6ed7/contract.json`<br>对照第四次真跑同一个本体的提示词:`[上游输入` **一次都没出现过** | -| **下游还会不会说「拿不到输入」** | ✅ 不会了。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-<taskId>.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/<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`)的真实提示词逐字复现第五次的形状:<br>`[上游输入 —— 引擎已按图上声明的数据契约解析好,直接读这些路径]`<br>`- brd:…/runs/run_5274dad6b10c/nodes/rn_9836aeb60827/brd.md`<br>`- contract(任务级共享):…/rn_9836aeb60827/contract.json`<br>**一个 ⚠️ 都没有**。行为侧也一致: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`、全量与 `?`=可选进 `<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` —— **已完成**(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)`。**改 HTML 后必须跑 `npm run spec:extract`** | `docs/prd/newfunction/*.html` | 无 | -| ~~**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 可安全并发调用 —— **T1 已完成**,只剩 T2(DevLoop 规格放宽) | 基本具备 | -| ~~**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-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/*.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 长期抢不到额度会静默卡住——需要一个「等待超时」告警,否则表现为「不动了」。**留待实现时定阈值。**~~ **按 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 自己也能并行。 diff --git a/docs/todo/system-design/27-requirement.md b/docs/todo/system-design/27-requirement.md index d273a43..89fcf9c 100644 --- a/docs/todo/system-design/27-requirement.md +++ b/docs/todo/system-design/27-requirement.md @@ -421,7 +421,7 @@ R-16 正文中的原话引用**保留 `validator-reflection` 的用词不改**ï¼ **推翻了什么**:前六轮的「端到端生产成立」结论建立在 mock 之上,而 `ClaudeCliProtocol._sendMockPrompt` **完全不读提示词**,只按 agent 名回放 fixture。第七轮换真 CLI 后,链路在第一个 work 节点就断。本条要求彻底改用真实调用来验证。 -**落点**:[27-P3B 真实运行前置](./27-p3b-real-run-runway.md)(本条派生出的工作项文档)。 +**落点**:[27-P3B 真实运行前置](../../done/system-design/27-p3b-real-run-runway.md)(本条派生出的工作项文档)。 --- @@ -438,7 +438,7 @@ R-16 正文中的原话引用**保留 `validator-reflection` 的用词不改**ï¼ > 我记得之前说过这个,要所有的 agent 把自己的产出都放在 data/company/company-id/workspace 下,你可以约束一下所有的 agent subagent graph agent,统一一下输出和输入的共享路径。这个路径下有一个 json 文件描述节点,边(输入,输出文件),时间,作为所有文件的索引。 **核实结果**:这条在 [27-REQ](./27-requirement.md) 里**没有记录**(本页此前 0 处提 workspace), -但设计确实存在 —— [27-P4](./27-p4-parallel-and-artifacts.md) §4.6「产物统一目录 + `files.json`」, +但设计确实存在 —— [27-P4](../../done/system-design/27-p4-parallel-and-artifacts.md) §4.6「产物统一目录 + `files.json`」, 文中自己标着「用户要求的第 3 项,**P3 未实现**」。所以是**设计在、需求源头漏记、实现未做**。 ### R-19.2 · 建表而不是 JSON 文件(原话) @@ -486,7 +486,7 @@ git diff,最后 W13 只能把计数挪去另一个被 ignore 的文件。`file 这类图结构错误现在完全查不出来,只能等跑到那一步 agent 说「我拿不到输入」 (第四次真跑就是这么发现的)。这比事后归因强得多。 -**落点**:[27-P4](./27-p4-parallel-and-artifacts.md) §4.6 重写。 +**落点**:[27-P4](../../done/system-design/27-p4-parallel-and-artifacts.md) §4.6 重写。 --- diff --git a/docs/todo/system-design/41-graph-vs-fde-reliability-discussion.md b/docs/todo/system-design/41-graph-vs-fde-reliability-discussion.md index b161ca7..d803992 100644 --- a/docs/todo/system-design/41-graph-vs-fde-reliability-discussion.md +++ b/docs/todo/system-design/41-graph-vs-fde-reliability-discussion.md @@ -97,8 +97,8 @@ for (let i = startFromIndex; i < stepConfigs.length; i++) { // :216 **2. 但有一处明确的例外没堵上:`infra_flake` 分支绕过了这套跨层寻址** `routeFailure` 里 `infra_flake` 走的是完全独立的"原地重跑"分支(:1983-2020),**根本不查 `target_node`**,直接重跑判定失败的那个节点本身。30 号文档 §四记的那次死循环正是这个口子:诊断节点说"该重跑 validator",但地址信息被这条分支忽略,只会一遍遍重跑诊断节点自己。2026-08-23 的修复(`2f177c7`)只加了熔断计数,**没有**让这条分支也去认 `target_node`——文档原话是"没查清楚之前不改选边的语义",这是一处**故意留着、待后续设计澄清**的真实缺口,不是随口一提。 -**3. 多节点子图的并发 fan-out:这块是真的还没做,不是做坏了** -`27-p4-parallel-and-artifacts.md` 状态是"📝 设计中,未实现"——同一 agent 多实例隔离、真并发 fan-out、`files.json` 产物索引都还没上生产。所以"带分支的多节点子图"目前跑的都还是**顺序执行**,没有真正测过高并发下的资源竞争会不会暴露新问题——这不是"设计有问题被发现了 bug",是"这段设计还没写完"。 +**3.(2026-09-08 更新:本节写作时是真的,现在已经不成立)多节点子图的并发 fan-out 已经做完了** +`27-p4-parallel-and-artifacts.md` 本节写作时状态是"📝 设计中,未实现",2026-09-08 已彻底收口:同一 agent 多实例隔离、真并发 fan-out、产物索引(形态从设计里的 `files.json` 改成了 `graph_files` 表)全部上了生产,外加当时没单独列出的 `advanceTree` 兄弟子 run 并发也一并补齐。"带分支的多节点子图"现在真的会并发跑,不再是顺序执行——高并发下的资源竞争验证见 [27-P4 §4.2](../../done/system-design/27-p4-parallel-and-artifacts.md) 的接线记录。 **4. 部门级多图(37 号)也是同一类**——org graph 目前按 `company_id` 单例,一家公司只能有一张组织图,HR/财务这类跟交付流水线无关的常设职能没图可挂。调用点扫描发现改造面是最初估算的 4 倍,目前停在"设计讨论,尚未立项"。 @@ -363,7 +363,7 @@ FDE 无关,纯粹为了让 org-hunt 做真调研)反复 `rerun` 同一个 ta 已经靠 `INFRA_FLAKE_HARD_CAP` 熔断收敛,属于本条修复范围之外。这条修复堵的是更窄的 另一种情形:诊断点名的目标恰好是本节点自己真实存在的祖先时,不该绕开正常的回退 记账。回归测试见 `graph-engine.test.js` 的 `infra_flake` describe 块新增两条用例。 -- [ ] `27-p4-parallel-and-artifacts.md` 并发 fan-out 设计推进。 +- [x] `27-p4-parallel-and-artifacts.md` 并发 fan-out 设计推进——2026-09-08 已彻底收口。 - [ ] 若要落地部门级 org graph(37 号文档方向),仍是"设计讨论,尚未立项"状态。 - [ ] **可选文档补丁**:`org-hunt/digest/evolve` 是"元 agent 不能直接入图,要建原生协议独立副本" 这条红线的第一个实操案例,但这个"怎么复刻 FDE 到 graph"的操作模式本身还没被沉淀成 diff --git a/docs/todo/system-design/49-antigravity-inspired-parallel-verification-proposal.md b/docs/todo/system-design/49-antigravity-inspired-parallel-verification-proposal.md index 08f5f45..8f048b0 100644 --- a/docs/todo/system-design/49-antigravity-inspired-parallel-verification-proposal.md +++ b/docs/todo/system-design/49-antigravity-inspired-parallel-verification-proposal.md @@ -12,22 +12,21 @@ ## 一、先纠正一个前提:27 号 P4「真并行」现在剩的东西,比想象的少很多 -写这份文档前重读了 [27-P4](./27-p4-parallel-and-artifacts.md) 全文——它虽然还挂在 `todo/`,但文档 +写这份文档前重读了 [27-P4](../../done/system-design/27-p4-parallel-and-artifacts.md) 全文——它虽然还挂在 `todo/`,但文档 自己 §五末尾已经写明**阶段 A(隔离)/ B(并行)/ C(产物索引)全部已实现**(2026-08-21,`advanceBatch` 真并发、`Promise.allSettled` fan-out、`graph_files` 产物索引、控制台 fan-in 可视化都已落地并有单测/真跑 验收)。这是本仓库第二次撞见「文档落后于代码」这个模式(第一次是文档自己 §3.6/§五校准块记录的)。 -**真正还没做的,只有四件小事**: +**写这份文档时还没做的,有四件小事——2026-09-08 复核时发现其中两条本身就判断错了**: | 项 | 现状 | 是否与本文档的新提案相关 | |---|---|---| -| T2:DevLoop 规格 `(pid,agent)` → `(pid,agent,scope)` | 改 HTML 重跑 `spec:extract` 即可,纯机械改动 | 无关,独立小任务 | +| ~~T2:DevLoop 规格 `(pid,agent)` → `(pid,agent,scope)`~~ ✅ | 2026-09-08 已做;且原计划「改 HTML 重跑 spec:extract」是错的——`CONVENTIONS.md` 不是任何 HTML 的生成物,是手写文件,直接改 | 无关,独立小任务 | | T5:账户欠费门 | 桩子已落地,卡在计费系统接口确认,非本仓库能单方面推进 | 无关 | -| `graph_edge_contracts.role` 列缺失 | `produces`/`needs` 落库后不可区分,只影响诊断展示 | 无关 | -| **`advanceTree` 不会同时推「兄弟子 run」** | 一次只推最深的一个 active run;两个并行 `subgraph` 节点各自的子 run 靠外层循环轮流服务,不是真并发。**这是当前唯一还成立的"并行"缺口** | **相关**——本文档 §二 的新提案建立在"节点内部并发已具备"之上,兄弟子 run 并发是它的地基 | +| ~~`graph_edge_contracts.role` 列缺失~~ ❌**这条本身是误判** | 2026-09-08 复核:`role` 列与 `normalizeContracts()` 的 produces/needs 区分早就落库了(`graph-edge-contract-role.test.js` 的提交日期早于本文档写作日期),本文档没有独立验证就照抄了 27-P4 当时那张表的过期状态 | 无关 | +| ~~**`advanceTree` 不会同时推「兄弟子 run」**~~ ✅ | 2026-09-08 已按代码里当时写好的升级路径改成 `Promise.allSettled` 并发推进 | **相关**——本文档 §二 的新提案建立在"节点内部并发已具备"之上,兄弟子 run 并发是它的地基,现已补齐 | -也就是说,「27 号 P4 真并行」作为独立议题基本可以**收口**(只剩 T2/T5/role 列三件机械/外部依赖的小事, -不需要新设计)。真正值得用 Antigravity 这篇文章来对照、能长出新东西的,是**同一个 run 内部并行做完之后, +也就是说,「27 号 P4 真并行」作为独立议题现在**已经彻底收口**(2026-09-08)。真正值得用 Antigravity 这篇文章来对照、能长出新东西的,是**同一个 run 内部并行做完之后, "要不要做竞争式的多候选并行"**这个更高一层的问题——这是 Antigravity 有而我们没有的能力,不是把 27-P4 剩下的小尾巴做完就能自动获得的。下面 §二 是这条新提案。 @@ -219,7 +218,7 @@ Antigravity 把验证器发现的缺陷提炼成一个**与具体答案无关的 | 项 | 内容 | 依赖 | |---|---|---| -| P4 收口 | T2(DevLoop 规格)、role 列、`advanceTree` 兄弟子 run 并发 | 无,可独立随时做 | +| ~~P4 收口~~ ✅ | ~~T2(DevLoop 规格)、role 列、`advanceTree` 兄弟子 run 并发~~ **2026-09-08 已全部做完**——且核实时发现这行本身有两处误判:`graph_edge_contracts.role` 列其实在本文档写作前就已落库(`graph-edge-contract-role.test.js` 早于本文档日期,见 27-P4 §四 校准),T1 的✅当时只做完了 `maxSessions` 上限,`scope` 参数从未真正接线(本轮一并补上,见 27-P4 §4.2)。三件事详见 [27-P4](../../done/system-design/27-p4-parallel-and-artifacts.md) 对应小节 | — | | 提案 A(竞争候选) | 已拆分成独立的详细设计草稿,见 [50 号文档](./50-competing-candidates-critic-synthesis-design.md)——需要先找到一个真实任务撞上"单次成功率低、需要多解法比较"的场景,再决定要不要做骨架 + critic/synthesis 角色 | 无实证需求前不建议立项 | | 提案 B-1(证据泛化) | `goal-contract.js` schema 扩展 + `org-validator`/`coder-validator` agent.md 调整 | 35 号提案 D 已有的机制,扩大范围即可,成本可控 | | 提案 B-1b(`test_script` 引导) | **已评估,不建议做**——见 §3.2b,撞上 `test_script.path` 解析根不含项目根的结构性冲突,改造面远超预期 | 不立项 | @@ -233,7 +232,7 @@ B-1(证据泛化)次之;提案 A(竞争候选)需要先看 50 号文æ¡ ## 五、与既有文档的关系 -- [27-P4](./27-p4-parallel-and-artifacts.md):本文档 §一 是对它当前状态的复核,不修改它的结论; +- [27-P4](../../done/system-design/27-p4-parallel-and-artifacts.md):本文档 §一 是对它当前状态的复核,不修改它的结论; §二的竞争候选提案建立在它已完成的 fan-out 能力之上。 - [50-competing-candidates-critic-synthesis-design](./50-competing-candidates-critic-synthesis-design.md): §二提案 A 的详细设计草稿(骨架 JSON、命名、critic/synthesis 分工、预算风险量化),本文档只保留结论。 diff --git a/docs/todo/system-design/50-competing-candidates-critic-synthesis-design.md b/docs/todo/system-design/50-competing-candidates-critic-synthesis-design.md index d0b69a6..d7f47f1 100644 --- a/docs/todo/system-design/50-competing-candidates-critic-synthesis-design.md +++ b/docs/todo/system-design/50-competing-candidates-critic-synthesis-design.md @@ -6,7 +6,7 @@ > 把"如果要做,该怎么做"想清楚,供下一步决定要不要排期时参考,不是开工指令。 > **日期**: 2026-09-04 > **前置阅读**: [49 号文档](./49-antigravity-inspired-parallel-verification-proposal.md) §二(提案动机与 -> 场景边界)、[27-P4](./27-p4-parallel-and-artifacts.md)(并行执行引擎,本设计的地基)、 +> 场景边界)、[27-P4](../../done/system-design/27-p4-parallel-and-artifacts.md)(并行执行引擎,本设计的地基)、 > [27-naming-conventions.md](./27-naming-conventions.md)(命名规则) --- @@ -160,7 +160,7 @@ agent 的多实例隔离"(`session-pool.js` 的 `scope` 参数)正好对上â - [49-antigravity-inspired-parallel-verification-proposal](./49-antigravity-inspired-parallel-verification-proposal.md): 本文档是它 §二 提案 A 的展开,结论一致——没有真实场景前不建议立项。 -- [27-P4](./27-p4-parallel-and-artifacts.md):本设计完全建立在它已完成的并行执行能力(`advanceBatch`/ +- [27-P4](../../done/system-design/27-p4-parallel-and-artifacts.md):本设计完全建立在它已完成的并行执行能力(`advanceBatch`/ 多实例 session 隔离)之上,不需要 27-P4 补任何代码。 - `system/work-graph-templates/deliverable.json`:critic/synthesis 分离的设计依据直接来自它记录的 `qa-judge` 真实反模式事故,本文档延续同一条教训。 diff --git a/server/services/graph-engine.js b/server/services/graph-engine.js index 40b0369..bd1bac5 100644 --- a/server/services/graph-engine.js +++ b/server/services/graph-engine.js @@ -438,6 +438,11 @@ async function defaultAgentExecutor(ctx) { try { res = await manager.callAgent(resolveAgentName(ctx.companyId, ctx.systemAgentId), task, { onAcpEvent, + // 27-P4 §4.2:taskId 只到「一次 run」的粒度,fan-out 撞同一个 systemAgentId 的两个 + // 并发节点(如 executor-fanout.json 的 impl-a/impl-b)taskId 相同、会话键相同, + // 会互相顶掉对方的 CLI 子进程。按节点运行实例 id 多加一维,保证并发的两次调用 + // 一定落在不同 session 上。 + sessionScope: `gr:${ctx.nodeRunId}`, metadata: { taskId: ctx.runId, layer: 'graph-node', nodeRunId: ctx.nodeRunId, // 产物目录是**提示词**里那份约定的机器可读副本(提示词由 describeArtifactContract 拼, @@ -738,6 +743,9 @@ async function defaultPlanWorkGraph(ctx) { try { res = await manager.callAgent(agentName, prompt, { onAcpEvent, + // 同 §4.2 的道理:两个并发的 subgraph 节点若引用同一个 planning 本体 + // (如两个 org node 同时规划各自的 work graph),taskId 相同会撞会话键。 + sessionScope: `gr:${ctx.nodeRunId}`, metadata: { taskId: ctx.runId, layer: 'graph-plan', nodeRunId: ctx.nodeRunId, rootRunId: ctx.rootRunId, attempt: ctx.attempt, @@ -4524,31 +4532,40 @@ function hashExport(value) { * 传子 run 拿到**零行**,循环第 0 步就 break:HTTP 回 `ok:true / auto:true`, * 而那个 run 里的 ready 节点纹丝不动(返回 ok 却什么都不做,是最坏的形态)。 * - * ponytail: 仍然只推**一个**「最深 active run」,同一 tick 里的**兄弟**子 run(比如两个并行 - * subgraph 节点各自起的子 run)不会同时被推——它们靠 runAuto 的外层循环一轮一轮轮流服务, - * 不是真并发。真并发只发生在**同一个 run 内部**(advanceBatch 的 fan-out)。上限是「兄弟子 run - * 之间」这一维的吞吐,不影响正确性(不会有子 run 被饿死,只是不同时跑);升级路径是把这个 - * for 循环也从「找到第一个 ok 就 return」改成对多个 active run 并发调 advanceBatch。 + * 兄弟子 run(比如两个并行 subgraph 节点各自起的子 run)**同一 tick 内并发推进** + * (27-P4 收口):`Promise.allSettled` 对树里每一个 active run 各调一次 `advanceBatch`。 + * 安全性同 `advanceBatch` 自己并发拾取同一 run 内候选节点的道理——不同 runId 的行互不 + * 覆盖,`transitionNode` 的逐行条件更新连「同一 run 内竞争」都兜得住,跨 run 更没有共享的 + * 可写状态;一个 run 结算时可能连带 `settleRun` 到共同的父 run,那也只是「幂等地被判定 + * 收敛好几次」(§4.3 已经论证过的浪费而非 bug)。不再需要「深度优先」:advanceBatch 对没有 + * ready 节点的 run 只会原地返回 `no-ready-node`,谁先谁后不影响正确性。 */ async function advanceTree(runId, opts = {}) { const self = getRun(runId); if (!self) return result(runId, null, 'run-not-found'); // 仍然只做索引查询:按 root_run_id 拿全树,再在内存里裁出「自己 + 后代」这棵子树。 - // 保留 listRunTree 的 created_at 顺序 —— 后建的一定更深,倒序即「最深优先」。 const scope = new Set([self.id, ...descendantRuns(self).map((r) => r.id)]); const tree = listRunTree(self.root_run_id) .filter((r) => scope.has(r.id) && r.run_status === 'running'); - // 后建的 run 一定更深(子 run 总是在父节点 running 之后建的),倒序即「最深优先」 + + const settled = await Promise.allSettled(tree.map((r) => advanceBatch(r.id, opts))); + + let firstOk = null; let informative = null; - for (const r of tree.reverse()) { - const res = await advanceBatch(r.id, opts); - if (res.ok) return res; + for (let i = 0; i < settled.length; i++) { + const s = settled[i]; + // advanceBatch 内部已经把「executor 炸了」转成 fail outcome 喂给 settleNode,不会走到 + // 这里;这一分支兜的是它自己没兜住的意外(比如 defIndex 读图定义时抛的同步异常)。 + const res = s.status === 'fulfilled' + ? s.value + : { ok: false, runId: tree[i].id, reason: 'advance-error', error: s.reason && s.reason.message }; + if (res.ok && !firstOk) firstOk = res; // minor 3:ok:false 时**不能**把所有原因都塌缩成 'no-ready-node'。子层返回 // 'budget-exhausted'(还带着 budget.exhausted 明细)而树层报「没活可干」, // 自动模式的调用方和 REST 层就分不清「预算耗尽等人工加额度」和「真的跑完了」。 - // 最深的那条有信息量的原因胜出;全是 no-ready-node 才落回原来的兜底。 if (!informative && res.reason && res.reason !== 'no-ready-node') informative = res; } + if (firstOk) return firstOk; return { ...result(runId, (getRun(runId) || {}).run_status, informative ? informative.reason : 'no-ready-node'), // budget 只有预算闸门那条路径才带,透上来给控制台顶栏用 @@ -4562,8 +4579,8 @@ async function advanceTree(runId, opts = {}) { * 自动驱动:只要 pause_requested / stop_requested 都是假就在上一次 resolve 后立刻发起下一次。 * 与 advanceTree 同款语义 —— runId 是**子树根**,对子 run 起自动就只驱动它这棵子树。 * maxSteps 只是防跑飞的安全阀,真正的终止条件是预算(§四.A.3)与熔断(§四.D.2.1)。 - * 27-P4 T4/T6 接入后,「同一个 run 内部」已经能并发;跨兄弟子 run 的并发仍是 advanceTree - * 那条 ponytail(一次只推最深的一个),不在本函数里。 + * 「同一个 run 内部」(27-P4 T4/T6)与「跨兄弟子 run」(27-P4 收口)现在都走并发, + * 这里不需要再做任何事。 */ async function runAuto(runId, opts = {}) { const maxSteps = opts.maxSteps || 1000; diff --git a/server/src/core/agent-manager.js b/server/src/core/agent-manager.js index 5536bf4..040f941 100644 --- a/server/src/core/agent-manager.js +++ b/server/src/core/agent-manager.js @@ -405,11 +405,16 @@ class AgentManager extends EventEmitter { // 优先使用 Agent 自带的 workDir —— body 模式下它已经是任务态目录 // system/agents/{aid}/{cid}/{taskId}/(getAgentConfig 已 mkdir,cwd 不存在 CLI 起不来)。 - // 会话隔离**不靠**这个路径,靠 agent.companyId / agent.taskId 进 sessionPool.getKey。 + // 会话隔离**主要**靠 agent.companyId / agent.taskId 进 sessionPool.getKey; + // 但 (companyId, taskId) 只到「一次 run」的粒度——同一 run 内两个并发节点引用同一个 + // system agent(fan-out 撞同 agentId,如 executor-fanout.json 的 impl-a/impl-b)时 + // taskId 相同、会话键相同,会互相顶掉对方的 CLI 子进程。调用方(graph-engine)按 + // 节点运行实例 id 传 options.sessionScope 来加一维,这里原样透传给 getKey 的第四个参数。 const effectiveWorkDir = agent.workDir || this.workDir; + const sessionScope = options.sessionScope; let session = await this.sessionPool.getOrCreate( - agentName, agent, resolved.launcherConfig, protocolName, effectiveWorkDir + agentName, agent, resolved.launcherConfig, protocolName, effectiveWorkDir, sessionScope ); for (let attempt = 0; attempt < 2; attempt++) { @@ -456,7 +461,7 @@ class AgentManager extends EventEmitter { }); await this.sessionPool.reset(session.key); session = await this.sessionPool.getOrCreate( - agentName, agent, resolved.launcherConfig, protocolName, effectiveWorkDir + agentName, agent, resolved.launcherConfig, protocolName, effectiveWorkDir, sessionScope ); this._emitSessionEvent({ agentName, protocol: protocolName, status: 'reconnected', diff --git a/server/tests/unit/agent-manager-session-scope.test.js b/server/tests/unit/agent-manager-session-scope.test.js new file mode 100644 index 0000000..2fe7dbb --- /dev/null +++ b/server/tests/unit/agent-manager-session-scope.test.js @@ -0,0 +1,118 @@ +/** + * Unit Tests — `options.sessionScope` 真的从 `AgentManager.callAgent` 一路传到 + * `SessionPool.getKey`(27-P4 §4.2 补的那一维) + * + * ── 这条测试还的是哪笔债 ────────────────────────────────────────────── + * [27-P4](../../../docs/todo/system-design/27-p4-parallel-and-artifacts.md) §4.2 + * 设计了「图引擎并发调用同一 system agent 时按 `gr:{nodeRunId}` 传 scope,避免撞会话键」, + * `SessionPool.getKey()` 的 `scope` 参数也确实早就在(`session-pool-scope-key.test.js` 锁的 + * 正是 `SessionPool` 自己的存取一致性),但**从 `AgentManager._runViaProtocol` 到 + * `SessionPool.getOrCreate` 这一段接线此前从未写过**——`callAgent` 的 options 解构里 + * `sessionScope` 只是摆设,`_runViaProtocol` 两处 `getOrCreate` 调用都固定只传 5 个参数。 + * + * 后果:`executor-fanout.json` 这类骨架里 `{{orgNode}}-impl-a` / `{{orgNode}}-impl-b` + * 引用同一个 `systemAgentId`,若真的被 `advanceBatch` 并发拾取(两者同一个 run, + * `taskId` 都等于 `ctx.runId`),`sessionPool.getKey` 算出来的键完全相同——第二次调用会 + * 复用第一次那个还在跑的 CLI 会话,两个"并行"分支实际上在同一个会话队列里排队执行, + * 且不报错、不留痕迹。 + * + * 本测试只钉「接线通不通」,不追真实 CLI(用注册制协议工厂换一个内存桩,同 + * `session-pool-scope-key.test.js` 的手法):真实 agent 目录 + 真实 `AgentManager.callAgent` + * 全量跑一遍,`_runViaProtocol` 不做任何桩替换。 + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { EventEmitter } from 'events'; +import fs from 'fs'; +import path from 'path'; + +const AgentManager = require('../../src/core/agent-manager'); +const { registerProtocol } = require('../../src/core/protocol-factory'); +const locator = require('../../services/agent-locator'); + +/** 只满足 SessionPool.create() 用到的方法 + runPromptInSession 要的 sendPrompt。 */ +let stubCounter = 0; +class StubProtocol extends EventEmitter { + constructor(agentInfo) { + super(); + this.agentInfo = agentInfo; + this.isConnected = false; + this.sessionId = null; + } + async connect() { this.isConnected = true; } + async initialize() {} + async newSession() { this.sessionId = `stub-session-${++stubCounter}`; } + async sendPrompt() { return undefined; } // 立即"跑完",不 emit 任何流式事件 + disconnect() { this.isConnected = false; } +} + +beforeAll(() => registerProtocol('claude-cli', () => StubProtocol)); +afterAll(() => registerProtocol('claude-cli', () => require('@mas/acp-core').ClaudeCliProtocol)); + +describe('AgentManager.callAgent — sessionScope 接线(27-P4 §4.2)', () => { + const AGENT_ID = 'zz-scope-agent'; // zz- 前缀 = 测试建的注册表条目 + const BODY_DIR = locator.bodyDir(AGENT_ID); + const CO = 'zz-scope-co'; + const ADDR = `${CO}:${AGENT_ID}`; + + const newManager = () => { + const m = new AgentManager({}, locator.REPO_ROOT, {}); + m._resolveLogSession = async () => null; + m.hookRunner = { run: async () => {} }; + return m; + }; + + beforeAll(() => { + fs.mkdirSync(BODY_DIR, { recursive: true }); + fs.writeFileSync(path.join(BODY_DIR, 'agent.md'), '# 会话作用域测试本体\n', 'utf-8'); + fs.writeFileSync(path.join(BODY_DIR, 'config.json'), + JSON.stringify({ id: AGENT_ID, name: AGENT_ID, type: 'skill', status: 'active' }, null, 2), 'utf-8'); + }); + + afterAll(() => { + fs.rmSync(locator.companyDir(AGENT_ID, CO), { recursive: true, force: true }); + }); + + it('不传 sessionScope:同一 run(同 taskId)两次调用撞上同一个会话(这是被 27-P4 §4.2 指出的问题本身)', async () => { + const manager = newManager(); + + await manager.callAgent(ADDR, 'T1', { metadata: { taskId: 'run-1' } }); + await manager.callAgent(ADDR, 'T2', { metadata: { taskId: 'run-1' } }); + + // 两次都落在同一个 taskId 下、没有 scope 区分 —— 会话池里只会有 1 个会话 + expect(manager.sessionPool.sessions.size).toBe(1); + }); + + it('传 sessionScope=gr:{nodeRunId}:同一 run 内两个并发节点各自拿到独立会话,互不覆盖', async () => { + const manager = newManager(); + + const [a, b] = await Promise.all([ + manager.callAgent(ADDR, 'impl-a', { + metadata: { taskId: 'run-2' }, sessionScope: 'gr:node-a', + }), + manager.callAgent(ADDR, 'impl-b', { + metadata: { taskId: 'run-2' }, sessionScope: 'gr:node-b', + }), + ]); + + expect(a.status).toBe('success'); + expect(b.status).toBe('success'); + // 同一个 taskId、不同 scope —— 两个会话都要留在池子里,不能有一个把另一个顶掉 + expect(manager.sessionPool.sessions.size).toBe(2); + + const keys = [...manager.sessionPool.sessions.keys()]; + expect(keys.some((k) => k.endsWith(':gr:node-a'))).toBe(true); + expect(keys.some((k) => k.endsWith(':gr:node-b'))).toBe(true); + }); + + it('断链复现:把 sessionScope 从 options 里去掉,上一条用例会退回到 1 个会话(反向验证这条测试真的在盯接线)', async () => { + const manager = newManager(); + + await Promise.all([ + manager.callAgent(ADDR, 'impl-a', { metadata: { taskId: 'run-3' } }), + manager.callAgent(ADDR, 'impl-b', { metadata: { taskId: 'run-3' } }), + ]); + + // 没有 scope 区分:并发的两次调用会挤在同一个会话键上 + expect(manager.sessionPool.sessions.size).toBe(1); + }); +}); diff --git a/server/tests/unit/graph-engine-advance-batch.test.js b/server/tests/unit/graph-engine-advance-batch.test.js index ebafef7..8f891ed 100644 --- a/server/tests/unit/graph-engine-advance-batch.test.js +++ b/server/tests/unit/graph-engine-advance-batch.test.js @@ -287,4 +287,52 @@ describe('advanceBatch(27-P4 T4/T6)', () => { expect(wdb.getRun(runId).run_status).toBe('completed'); expect(maxActive).toBeGreaterThanOrEqual(3); // entry 之后 a/b/c 应该同时在跑,而不是依次 }); + + it('advanceTree 真并发推进兄弟子 run(27-P4 收口:不再是「一次只推最深的一个」)', async () => { + // entry → sub-a, sub-b 两个并行 subgraph 节点,各自规划出一张只有一个 leaf 节点的子图。 + // 两个 subgraph 节点没有依赖关系,同一批 advanceBatch 里会一起从 ready 变 running; + // 各自的 leaf 节点则要等到**下一次** advanceTree 才会被推——这正是本用例要验的那一跳。 + const companyId = nextCompany(); + ensureAgents(['entry', 'sub-a', 'sub-b']); + const g = wdb.createWorkGraph({ + companyId, + nodes: ['entry', { nodeName: 'sub-a', nodeKind: 'subgraph' }, { nodeName: 'sub-b', nodeKind: 'subgraph' }], + edges: [{ from: 'entry', to: 'sub-a' }, { from: 'entry', to: 'sub-b' }], + }); + expect(g.errors || []).toEqual([]); + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' }); + engine.markRunReady(run.id); + const runId = run.id; + + engine.setPlanWorkGraph(async (ctx) => { + ensureAgents(['leaf']); + const childGraph = wdb.createWorkGraph({ companyId, nodes: ['leaf'], edges: [] }); + expect(childGraph.errors || []).toEqual([]); + return { workGraphId: childGraph.id }; + }); + + let active = 0; + let maxActive = 0; + engine.setAgentExecutor(async (ctx) => { + if (ctx.nodeName !== 'leaf') return { status: 'success' }; // entry:立即成功,不参与并发计数 + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((r) => { setTimeout(r, 15); }); // 真实异步延迟,制造时间窗口重叠 + active -= 1; + return { status: 'success' }; + }); + + await engine.startRun(runId, { mode: 'auto' }).driver; + + expect(wdb.getRun(runId).run_status).toBe('completed'); + // 两个子 run 各自的 leaf 节点要在同一个时间窗口里跑——如果 advanceTree 仍然只推 + // 「最深 active run」,sub-a/sub-b 的子 run 会被 runAuto 的外层循环一轮一轮轮流服务, + // maxActive 永远是 1。 + expect(maxActive).toBeGreaterThanOrEqual(2); + + const subA = nodeByName(runId, 'sub-a'); + const subB = nodeByName(runId, 'sub-b'); + expect(wdb.getRunState(subA.childRunId).nodes.find((n) => n.name === 'leaf').status).toBe('success'); + expect(wdb.getRunState(subB.childRunId).nodes.find((n) => n.name === 'leaf').status).toBe('success'); + }); }); diff --git a/server/tests/unit/graph-real-chain-smoke.test.js b/server/tests/unit/graph-real-chain-smoke.test.js index eb93d96..d07f986 100644 --- a/server/tests/unit/graph-real-chain-smoke.test.js +++ b/server/tests/unit/graph-real-chain-smoke.test.js @@ -334,6 +334,68 @@ describe('图执行真实链路 smoke(零注入,只换掉真 CLI 子进程ï¼ } }, 60_000); + it('⑧ 双层熔断真的会撞顶收敛到 blocked(27-P3/P4 §四.D.2.1,此前只有注入式覆盖)', async () => { + // 复用 ⑦ 的形状(prd --success--> sdd --fail(backward)--> prd):sdd 这份 fixture + // 每次都确定性判 input_underspecified,一直回退到同一条 backward 边, + // 真实驱动 nextStep 反复跑,直到单边硬顶 LOOP_HARD_CAP_EDGE=3 撞顶 + // (第 4 次命中该边)——验证熔断不是靠假 executor 摆出来的分支覆盖, + // 是真实链路(真 AgentManager → 真 ClaudeCliProtocol mock 回放)跑出来的收敛结果。 + const SDD_AGENT = 'sdd-planning'; + const CO = `${COMPANY}-t8`; + const db = wdb.getDb(); + const now = new Date().toISOString(); + db.prepare(`INSERT OR IGNORE INTO system_agents + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`) + .run(SDD_AGENT, SDD_AGENT, `system/agents/${SDD_AGENT}`, now, now); + + const g = wdb.createWorkGraph({ + companyId: CO, + nodes: [ + { nodeName: 'prd', systemAgentId: AGENT }, + { nodeName: 'sdd', systemAgentId: SDD_AGENT }, + ], + edges: [ + { from: 'prd', to: 'sdd', condition: 'success' }, + { from: 'sdd', to: 'prd', condition: 'fail', edgeKind: 'backward' }, + ], + }); + expect(g.errors || []).toEqual([]); + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId: CO, executionMode: 'manual' }); + engine.markRunReady(run.id); + engine.startRun(run.id); + + // 一路真跑到收敛(blocked)——安全阀 20 步防跑飞(每轮 prd+sdd 两步,撞顶最多需要 4 轮)。 + let lastRun = wdb.getRun(run.id); + for (let i = 0; i < 20 && lastRun.run_status === 'running'; i++) { + const step = await engine.nextStep(run.id, { task: `T8 smoke:第 ${i} 步` }); + if (!step.ok) break; // 没有 ready 节点了——要么收敛完,要么在等别的东西 + lastRun = wdb.getRun(run.id); + } + + expect(lastRun.run_status).toBe('blocked'); + + // 熔断确实是撞的**单边**硬顶(这张图只有一条 backward 边,不会先撞 run 级), + // 且账本上留痕:最后一条记录 verdict=force_terminated,loop_count 越过 LOOP_HARD_CAP_EDGE。 + const records = wdb.listLoopRecords(run.id); + expect(records.length).toBeGreaterThan(0); + const terminated = records.find((r) => r.verdict === 'force_terminated'); + expect(terminated).toBeTruthy(); + expect(terminated.loop_count).toBeGreaterThan(engine.LOOP_HARD_CAP_EDGE); + + // 被熔断收敛的是 sdd(回退边的源节点),不是 prd + const sddRow = wdb.getDb() + .prepare(`SELECT status FROM graph_run_nodes + WHERE run_id = ? AND def_node_id = (SELECT id FROM work_graph_nodes WHERE work_graph_id = ? AND node_name = ?) + ORDER BY attempt DESC LIMIT 1`) + .get(run.id, g.id, 'sdd'); + expect(sddRow.status).toBe('blocked'); + + for (const a of [SDD_AGENT, AGENT]) { + fs.rmSync(path.join(__dirname, '../../../system/agents', a, CO), { recursive: true, force: true }); + } + }, 60_000); + it('③ 边界守卫在这条路径上是**开着**的(注入假 executor 时它恒为假,那条分支零覆盖)', () => { // 判据取自引擎自己的开关函数,而不是去数 timeline 事件: // 守卫在宿主仓库干净时本来就不产生事件,拿「没有事件」当「没开」会反过来判错。 -- libgit2 0.21.4