# 27-P3/P4. 图执行引擎与执行控制台 — 需求与设计文档 > **所属**: [27 Graph Agent 架构](./27-graph-agent-architecture.md) 的 Phase **P3(最小闭环)+ P4(回退 + 熔断)**,外加 27 号文档原始路线图里没有单独立项的两块新增交付物——**一个执行控制台页面**,以及 **`workgraph-planning` agent + 用户→公司的任务入口**。 > **版本**: v0.3(**按 [27-REQ](./27-requirement.md) R-15 六条定案重写**;v0.1 正文与 v0.2 修正块已合并入正文,不再单列) > **日期**: 2026-08-15(**2026-08-16 回填实现结果**:设计正文未变,改的是与实现对账的部分——§四.A.3 预算口径、§四.D.2.1 终止性、§五 状态列、§六 逐条对账、§八 风险增删,新增 §十 实现记录) > **第五轮修订(2026-08-16)**:**这一轮既改文档也真接了四条生产链路**,两类改动都要记,只列改名/纠偏是不完整的。 > **A. 四处生产接线(代码)**:① `planWorkGraph` 的默认实现从「一句 throw」改成**真调 `workgraph-planning`**(`PLAN_MAX_ROUNDS=3`,校验不过把 `errors` 原样回给 agent 再来一轮),§六 验收 17 因此端到端成立;② `snapshotContract` 改成**恒落盘并记 `present` 位**,`contract_hash` 级联判据不再是死路径;③ `goal-contract.js` 加 **agent 工具层 CLI** 并接进 `org-validator` / `coder-validator` / `planner-validator` 三个模板的 `tools.md`,从「零调用方」变成有生产消费方,配套 20 条单测;④ `describeTask` 补读 `artifacts/task-goal.json`,`consumedNote` 补读 `node.remediationNote`,两条「读回」环接上。 > **B. 改名与纠偏(文档)**:⑤ 按 [27-REQ](./27-requirement.md) **R-17.2** 全文改名 `validator-reflection` → **`validator-triager`**;⑥ 按 **R-17.1** 在 §四.D.2.2 / §四.D.3 / §八.1 标注「归因需精确到 work graph node 层、当前实现为 org node 粒度、待返工」(**本轮不实现**);⑦ 纠正上一轮的过期对账——`total_tokens` 已不是死维度(§四.A.3 / §五 T8 / §六 验收 10 / §八.11)、`advanceTree` 已不吞失败原因(§八.16)、`remediation_note` 落库与引擎侧读回已修(§八.14);⑧ 全文 file:line 坐标改为抗漂移的「函数名 + 小节」表述,见下。 > > **第六轮修订(2026-08-16)**:① `goal-contract` CLI 的**人读输出不再吞失败原因**——短路分支(`target_not_json` / `script_missing` / `script_escapes_artifacts`)的 `reason` + `detail` 现在会打出来,并且**一律进 `verdict.failures`**,退出码 1 的那条路径终于有可归因信息(§十 表第 12 行);② 把 §五 / §六 / §八 / §十 整体补齐到第五、六轮的事实——**第三、四、五轮连续犯的「代码改了、文档停在上一轮」到此为止**(§十 硬规矩第 3 条点名的正是这条)。 > ③ 本轮另有三条并行线在改(骨架悬空引用、改名收尾、恢复自动向下级联)。 > > **第六轮终验回填(2026-08-16,全文所有「⏳ 待终验回填」到此消解)**——终验用**真 server + 真 HTTP + 真 `AgentManager`**(底层 `LEMONBOSS_CLAUDE_MODE=mock`,**不注入** `setPlanWorkGraph` / `setAgentExecutor`)跑了**四个脚本、六组场景**(跨层回退 / 暂停+对子 run 起 auto / 一句话 auto 任务 / 对 root 恢复 / `infra_flake` 活锁 / `stopped` 子 run 复用),结论分四档: > - ✅ **控制操作那条线全通**:多层 run 树暂停后对 root 点「启动」回 200 且 `resumed` 含被级联暂停的子 run,整棵树自己跑到 `completed`、全树 `pause_requested` 归零(验收 4);对**子 run** 单独起 auto 真的推进到 `completed` 且不动父 run 的暂停意图(验收 15);一句自然语言 + `mode=auto`、零人工干预,root run 与两张子图全部 `completed`(验收 17)。 > - ✅ **R-17.1 返工那条线全通**:`org-validator` 的子图用 `system/work-graph-templates/validator.json` **原样落库**(三个节点齐全,外键悬空的 blocker 在 DB 这一侧确实没了);跨层回退实测**只重置被点名的 `wg-impl`**(`attempt` 0→3),`wg-test` 一次都没重跑(`attempt` 恒 0),`child_run_id` 全程不变(复用而非新建),账本 `target_node_ref` 逐条记到 `{orgNode, workNode, childRunId, nodeRunId}`;四轮回退后撞单边硬顶 `force_terminated` 收敛到 `blocked`。**死锁未复发**(独立复核见 §八.1 末段)。 > - ❌ **骨架悬空引用那条线只做了一半,且把 CI 跑红了**:六个本体的**目录建了、`_registry.json` 没登记**,而它自己新增的防线 `server/tests/unit/template-refs.test.js` 恰恰查这一条 —— 全量 `npx vitest run` = **718 passed / 1 failed**。详见 §五 T11 与 §八.21。 > - ⚠️ **返工留下一处「实现在、生产零产出方」**:跨层地址 `target_node` 的四环里,「产生」环被 `validator-triager` / `org-validator` / `workgraph-planning` 三份提示词里**尚未返工的粒度说明**顶掉了 —— 它们明写「你指认到 org node 为止,往里多指一层等于白指」。这是第 13 处四环缺陷,见 §十 表第 13 行与 §八.22。 > > **前置**: [27-P2 Org Graph 落地](../../done/system-design/27-p2-org-graph.md) 阶段 A/B。P0/P1 均已实现并提交(P1 = commit `226489d`)。 > **命名**: 全文遵循 [27-NAMING](./27-naming-conventions.md),含本文档 §五 T-P2 要求新增的 validator 命名轴。 > **配套**: [27-E2E 端到端流程](./27-e2e-task-execution-flow.md)、[27-EXAMPLE 参考场景](./27-example-dev-company-walkthrough.md) > **设计方法论**: 核心设计(§四)用 **Model / View / Control / Process(MVCP)** 四段组织。 > > 📐 **代码引用体例(第五轮改制)**:本文档一律用 **`文件名` + 函数名 / 常量名 / 小节标题** 定位代码,**不写行号**。 > 原因是实测过的:第四、五两轮又改了 `graph-engine.js` / `work-graph-db.js` / `graph-run-sse.js`, > v0.3 里写死的行号**抽查全错**(`graph-engine.js` 从 1642 行长到 1776、`graph-run-sse.js` 364→375、 > `work-graph-db.js` 827→846,`planWorkGraph` 的 `throw`、`startRunSync` 的位置全部顶偏)。 > **结论本身都是对的,坏的只是坐标**——所以本轮不逐个去追行号,而是换成抗漂移的表述。 > 少数确实需要行号才说得清的地方(如「12 处状态转换分别在哪」),保留并标注**「截至 commit `c3abf4a`」**, > 读的时候按函数名去 grep,不要信那个数字。 --- ## 〇、v0.3 重写说明 v0.1/v0.2 的正文有三处**结构性**问题,不是补丁能修的: | # | v0.1/v0.2 的写法 | 为什么不成立 | 本版怎么改 | |---|---|---|---| | 1 | 四张 `work_graph_*` 表,「定义即实例」不拆表 | 该辩护的前提是 work graph「规划完即执行、执行完即归档」。**org graph 也要被同一套引擎执行**(R-15.4),而它是活整个公司生命周期的长期定义,一份定义要被跑很多次 | 定义层与运行时层分离,五张表,§四.A | | 2 | `advanceOne()` 只有一种节点执行语义(调 `AgentManager`) | org node 的「执行」是起一张子图并等它收敛,不是一次 agent 调用 | 两种 node executor,§四.D.0 | | 3 | §一非目标排除 `workgraph-planning` 与任务入口 | 代价是 v0.1 §八.5 自己承认「只能手写测试数据构造图,不能端到端验证」 | 两条非目标作废(R-15.5),收进阶段 D | 另有四处 v0.2 已在头部修正、本版直接写进正文的过期陈述:`runProject` 已实现(不是 501)、`UnitsDAG.vue` 已实现分波布局(不用从零写)、回退/预算应从 `_spec/loops-*.md` 抽象而来(不是平行发明)、P1 已实现。 以及两个已被关掉的 open question:v0.1 §八.3「重试要不要级联下游」由 `loops-l3.md` 的 `contract_hash` unchanged 四分类判据关闭;v0.1 §八.4「子图不继承父图暂停」被 [27-E2E](./27-e2e-task-execution-flow.md) §7.5 推翻,改为向下级联。 --- ## 一、目标与非目标 ### 目标 1. **一套图执行引擎**,同时驱动 org graph(company 级 workflow)和 work graph(org node 级 workflow)—— 两者是同一种东西在不同层级的递归(R-9.1),引擎不为它们各写一套。 2. **执行控制**:启动、下一步、暂停、停止、重试五个操作,同时支持**手动**(点一次推进一个节点)和**自动**(一键授权跑到底)两种模式,且在**每一层语义完全一致**。 3. **回退与熔断**:`edge_kind='backward'` 的回退边带循环计数与硬顶,两层通用;跨所有层级共享一个**任务级总预算**。 4. **一个执行控制台页面**:左栏 org graph,中间选中 org node 的 work graph 画布,右栏节点详情;回退边可视化带 `N/3` 徽标;主流程 / 异常流程视觉区分;逐层下钻(agent 对话记录 + 嵌套子图)。 5. **验收契约**:先定义 `goal-benchmark` / `goal-test-data` / `goal-test-script` 三种产物的统一格式并建执行器,再动引擎(R-15.6)。 6. **规划闭环**:`workgraph-planning` agent + 用户→公司的任务入口,让「一句自然语言 → 一张可执行的图 → 跑完」端到端成立(R-15.5)。 ### 非目标(本阶段明确不做) - **不做 fan-out 并行**。它卡在两个既有不变量上:`CONVENTIONS.md` §12「同 `(pid, agent)` 只允许 1 个 `status=running`」和 SessionPool 的 key 复用([27-E2E](./27-e2e-task-execution-flow.md) §7.4)。必须先做同一 system agent 的多实例隔离,是独立工作量。**v1 直接约束「一家公司同时只有一个 active root run」**,新任务排队——注意这条约束在 org 层就需要,不是等到 work 层 fan-out 才需要。 - **不做 L4**(system agent 自我迭代)。见 [27-E2E](./27-e2e-task-execution-flow.md) §八,且它是三级恢复叠加里最贵、爆炸半径最大的一级。 - **不做**对 DevLoop 现有 `loop-controller.js` / `unit-registry.js` 的收编。它是本架构的参考实现([27-E2E](./27-e2e-task-execution-flow.md) §二结论 2),本引擎从它**抽象**,但不改它一行;收编是 P6。 - **不做**多人协同编辑同一张图的协作特性(评论、@提醒)。并发**冲突控制**要做(§四.A.4 幂等),「协作」停留在「不冲突」。 - **不做**节点执行中途的断点续传。重试语义是「整个节点重新执行一次」。 - **不做**跨公司的全局大屏。页面粒度是「一家公司」。 --- ## 二、背景 27 号总纲 §六把 P3 定义为「Work Graph 最小闭环:单向执行,先不做回退」,P4 才引入回退和 Loop-Validator。本文档不切两份,原因:**执行引擎的状态机一旦要支持「暂停/单步/停止/重试」这种可被外部随时打断和摆布的控制语义,「先做单向、再加回退」会让第一版状态机设计得过窄,P4 加回退时必须回来重改数据模型和主循环**。所以一次性把两个 phase 的数据模型和引擎设计完,落地仍分阶段交付(§五)。 v0.3 在此之上还合并了两块 27 号路线图里没单独立项的东西——控制台页面(R-4)和规划闭环(R-15.5)——理由是没有它们,P3 验收只能靠手写测试数据,验不到「这套东西真的能跑起来」。 --- ## 三、现状代码走查(决定设计边界的关键证据) ### 3.1 DevLoop `runUnit()` / `runProject()`:状态机可抄,驱动方式不可抄 `loop-controller.js` 的 `runUnit()` 本质是一个同步的 `while (attempt < retry_budget)` 循环:一次函数调用里 Coder → Tester → 失败则 `_buildRetryContext()` 拼上次失败摘要 → 重跑。`runProject()`(**已完整实现**,含 `_groupUnitsByWave()` Kahn 分波、L2 闸门、Integrator、BugTriager 四分支、预算熔断)同样是一次调用跑到底。`server/routes/devloop.js` 直接 `await loopController.runUnit(...)` 再吐响应——**整个 retry 循环最多 10 次 agent 调用全部塞在一次 HTTP 请求-响应周期里,外部在它跑完前完全没有插手机会**。 > 补充观察:`runProject` 的波内**实际是串行**的(波内循环那句注释:「串行跑(mock 同步快,不需并行)」),分波结构在、并行没做。这也印证了 §一非目标里「fan-out 并行是独立工作量」。 配套的 `unit-registry.js` 状态机(`TRANSITIONS` 常量表)设计得很干净:显式转换表 + 非法转换报错 + 每次合法转换 emit 事件。**这部分直接借鉴**。 **结论**:状态机照抄 `unit-registry.js` 的模式,主循环**绝不能**照抄「一次函数调用跑到底」。 ### 3.2 Pipeline Service:正确的参照模型 `pipeline-service.js` 的 `startRun()`只做状态机初始化,**不同步执行任何 agent**。推进靠外部显式调用 `advanceStep(runId, stepIndex, {output, status})`。`rejectStep()` 是现成的「退回重做」语义。 **这正是本文档需要的执行模型**:状态是持久化数据,「推进一步」是一次显式、幂等、外部可随时调用的动作,不是占着调用栈不放的循环。手动模式下「下一步」= 用户点一次调一次;自动模式下 = 引擎自己在上一次返回后立刻再调一次。**手动和自动共用同一个原语,区别只在谁触发下一次调用**。 **两处不能照抄的坑**: 1. `CompanyDashboard.vue` 的 `loadPipelineDetail()` 请求的是流水线**定义**,但模板里 `:class="{'step-current': selectedPipeline.currentStep === idx}"`引用的 `currentStep` 只存在于 run 实例上——这个高亮效果**从未生效过**。→ 本设计的定义层与运行时层必须是两个各自完整、明确分开的对象(§四.A)。 2. `retryPipelineRun()`调 `POST .../advance` 但没传 `stepIndex`,而路由端有 `if (stepIndex === undefined) return 400`——这个「重试」按钮点了必定 400。→ 「重试」必须是独立、参数自解释的端点(§四.C)。 ### 3.3 SSE 基础设施:可直接复用 `server/services/devloop/sse-broker.js`:每项目一个广播通道,`subscribe(pid, res, {replayFromOffset})` 支持 `Last-Event-ID` 断线重放,`emit()` 同时落盘 + 广播,25s 心跳。按 `run_id` 复用同一套实现。 **既有缺口不要重复**:DevLoop 里 `timeline-logger.js` 的审计事件和 `sse-broker.js` 的实时推送是两条独立的轨,没有自动桥接。本引擎要求**状态转换、timeline 写入、SSE 广播三者在同一次原子操作里一起发生**。 ### 3.4 子流程展示:`AgentChat.vue` 的双轨折叠卡片,直接复用 第四列 "ACP Events" 面板已实现:`subAgents`(以 `callId` 为 key 的响应式 map)承载**进行中**的子调用,靠流式事件持续追加;`subLogList` 是**已完成**的历史日志卡片列表,外观一致但无 `LIVE` 徽标。这套「live map + history list」双轨模型正是节点运行日志需要的东西。 ### 3.5 前端 DAG:`UnitsDAG.vue` 已实现,扩展而非重写 `web/src/components/devloop/UnitsDAG.vue`已实现 SVG + `` 箭头 + **Kahn 分波拓扑布局**(`waves` computed,带 safety 上限防死循环)+ 按 status 着色 + 点击 emit 下钻。同目录 `LiveAgentStream.vue`(带 Last-Event-ID 重连的 SSE 消费)、`UnitDrawer.vue`(节点详情抽屉)同样可复用。**真正的新增工作量只剩回退边的曲线路由**。 ### 3.6 进程级取消:机制存在,调用链需实现前二次确认 `server/src/engine/process-guard.js` 的 `ProcessGuard.cancel()`对底层 Claude CLI 子进程先 `SIGTERM`、超时后 `SIGKILL`。`run-registry.js` 的 `RunRegistry` 按 `runId` 登记每次调用,但其公开方法(`add/get/markState/touchOutput/listActive/listByBackend/listByAgent/list/remove/stats/clear`)里**没有 `cancel`**——取消动作大概率是调用方自己持有 `ProcessGuard` 引用后直接调,`RunRegistry` 只做登记和查询。列入 §五 T9 的实现前置核实项,不在设计阶段假设死。 ### 3.7 P2 已落地的图校验,直接复用 `server/services/org-graph-service.js` 已实现四项写入前校验:`acyclic`(三色 DFS,仅针对 forward 边)、`single-entry`(forward 意义下入度为 0 的节点恰好一个)、`reachable`(孤立节点拒绝)、`backward-labeled`(构成回路的边必须标 `backward`,且不构成回路的边不许标 `backward`——**双向复核,不信任标注**)。 **work graph 的写入校验直接复用这一套**,不另写。这意味着 work graph 也**强制单入口**(R-13.4 原本只对 org graph 定案,本文档统一到两层)。 **需新增第五项 `fail-fallback-edge`**(R-16,见 §四.D.2.2):任何有 `fail` 出边的节点,必须至少留一条 `fail_reason IS NULL` 的兜底边。这一项 P2 现有实现里没有,两层都要加。 > ⚠️ **已知重复实现(第七轮登记,暂不改动)**:第五项现在**有两份实现**——`org-graph-service.validateGraph` 的「规则 5」内联了一份,`work-graph-db.validateFailFallback` 又独立写了一份。成因是依赖方向:`work-graph-db` 必须 `require('./org-graph-service')` 拿 `validateGraph`,org 层反向 require 就成环,所以复用不了。副作用有两个:① `validateWorkGraph` = `validateGraph`(含规则 5)+ `validateFailFallback`,**同一条违规会报两行**(实测确认,`ok` 判定不受影响);② 两份逻辑目前语义一致,但改一处漏一处就会漂移——这正是 §十 那张表里「同一件事有两个实现,只改了一处」的经典种子。 > 两处注释都曾把对方说成「复用/继承本实现」(`work-graph-db` 侧那句第七轮已改成实话;`org-graph-service.js` 规则 5 上方那句「work 层……复用本函数继承,不再单独实现」仍在,该文件本轮不归本 agent 改)。 > **建议修法**(P4 顺手做):把这条规则抽成一个无依赖的小模块(如 `graph-validate-rules.js`),两层各自 require,`validateWorkGraph` 去掉 concat 的那一份。 --- ## 四、设计方案(Model / View / Control / Process) ### A. Model(数据模型) #### A.1 分层:定义层 ≠ 运行时层 这是 v0.3 相对 v0.1 最大的改动,直接来自 R-15.4。 | | org graph | work graph | |---|---|---| | 生成者 | `orggraph-planning` | `workgraph-planning` | | 生成时机 | 建公司时,一次 | 每个 org node 接到任务时 | | 生命周期 | **长期**,活整个公司生命周期 | **一次性**,规划完即执行 | | 一份定义被跑几次 | **多次**(每来一个任务跑一遍) | 一次 | 因为 org graph 是「一份定义、多次执行」,运行时状态**不能**存在定义表上(否则一家公司永远只能跑一个任务)。所以: ``` 定义层(两套,各自保留,形态同构) org_graph_nodes / org_graph_edges ← P2 已落地,本文档只读不改(除 T-P3 加一列) work_graph_nodes / work_graph_edges ← 本文档新增 运行时层(一套,两层共用,引擎只认这个) graph_runs ← 一次执行实例 graph_run_nodes ← 该次执行里每个节点的状态 graph_run_loop_records ← 回退边的循环计数与判词 ``` `work_graphs` 表在 v0.1 里混装了图的定义元数据和运行时状态(`run_status` / `pause_requested` / `execution_mode`),本版把运行时那半拆去 `graph_runs`。 #### A.2 表结构(追加在 P0/P2 的 `graph.sqlite` 上,复用同一 DB 文件) ```sql -- ── 定义层:work graph(org graph 的两张表 P2 已建) ────────────── CREATE TABLE IF NOT EXISTS work_graphs ( id TEXT PRIMARY KEY, -- wg_ company_id TEXT NOT NULL, owner_org_node_id INTEGER REFERENCES org_graph_nodes(id), -- 这张图属于哪个 org node planned_by TEXT NOT NULL DEFAULT 'workgraph-planning', goal_id TEXT, -- 一次人类需求描述(唯一,验收边界) task_id TEXT, -- 执行单元,一个 goal 多个 okr_node_id TEXT, -- 形如 O-001.1.1,长周期度量归属 created_at TEXT NOT NULL ); -- goal_id / task_id / okr_node_id 三者基数不同、不可混用,见 27-NAMING §四。 -- v0.1 的单字段 goal_task_id 不够用,已拆开。 CREATE TABLE IF NOT EXISTS work_graph_nodes ( id TEXT PRIMARY KEY, -- wgn_ work_graph_id TEXT NOT NULL REFERENCES work_graphs(id), node_name TEXT NOT NULL, -- 图内唯一,如 org-coder-validator system_agent_id TEXT NOT NULL REFERENCES system_agents(id), -- 引用的本体,如 coder-validator node_kind TEXT NOT NULL DEFAULT 'agent_call' CHECK (node_kind IN ('agent_call','subgraph')), created_at TEXT NOT NULL ); -- node_name ≠ system_agent_id:节点名带领域前缀、本体名不带(R-15.3)。 -- 同一本体可被多个节点、多家公司引用,与 P1 虚拟引用寻址一致。 CREATE TABLE IF NOT EXISTS work_graph_edges ( id TEXT PRIMARY KEY, work_graph_id TEXT NOT NULL REFERENCES work_graphs(id), from_node_id TEXT NOT NULL REFERENCES work_graph_nodes(id), to_node_id TEXT NOT NULL REFERENCES work_graph_nodes(id), condition TEXT NOT NULL CHECK (condition IN ('success','fail')), fail_reason TEXT, -- 可空;condition='fail' 时的细分路由键,见 §四.D.2.2 edge_kind TEXT NOT NULL DEFAULT 'forward' CHECK (edge_kind IN ('forward','backward')), created_at TEXT NOT NULL ); -- ── 运行时层:两层共用 ────────────────────────────────────────── CREATE TABLE IF NOT EXISTS graph_runs ( id TEXT PRIMARY KEY, -- run_ graph_kind TEXT NOT NULL CHECK (graph_kind IN ('org','work')), graph_id TEXT NOT NULL, -- kind='org' → company_id;kind='work' → work_graphs.id company_id TEXT NOT NULL, root_run_id TEXT NOT NULL, -- 自身为 root 时 = 自己的 id;冗余存储,避免每次上溯 parent_run_id TEXT REFERENCES graph_runs(id), parent_node_run_id TEXT REFERENCES graph_run_nodes(id), -- 是哪个节点下钻出来的 goal_id TEXT, task_id TEXT, okr_node_id TEXT, execution_mode TEXT NOT NULL DEFAULT 'manual' CHECK (execution_mode IN ('manual','auto')), run_status TEXT NOT NULL DEFAULT 'planning', -- planning | ready | running | paused | blocked | completed | failed | stopped pause_requested INTEGER NOT NULL DEFAULT 0, -- 软信号,下一个节点边界生效 stop_requested INTEGER NOT NULL DEFAULT 0, backward_count INTEGER NOT NULL DEFAULT 0, -- run 级总回退次数,双层熔断的外层,见 §四.D.2.1 budget_json TEXT, -- 仅 root run 非空,见 §四.A.3 created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); -- v1 约束:同一 company_id 至多一个 run_status ∈ {ready,running,paused} 的 root run(§一非目标) 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'); CREATE TABLE IF NOT EXISTS graph_run_nodes ( id TEXT PRIMARY KEY, -- rn_ run_id TEXT NOT NULL REFERENCES graph_runs(id), def_node_id TEXT NOT NULL, -- 按所属 run 的 graph_kind 解释: -- 'org' → org_graph_nodes.id(整数转字符串) -- 'work' → work_graph_nodes.id status TEXT NOT NULL DEFAULT 'pending', -- pending | ready | running | success | fail | blocked | skipped attempt INTEGER NOT NULL DEFAULT 0, agent_run_id TEXT, -- node_kind='agent_call' 时的 RunRegistry runId child_run_id TEXT REFERENCES graph_runs(id), -- node_kind='subgraph' 时本次 attempt 起的子 run output_ref TEXT, -- 产出物相对路径,见 §四.A.5 fail_reason TEXT, -- 本次失败的细分原因,供边路由,见 §四.D.2.2 remediation_note TEXT, -- 留给**这个节点**下一轮的修复建议,见 §八.14 fail_node_ref TEXT, -- R-17.1 跨层节点地址(JSON),见下方「跨层地址的存储形态」 child_run_attempt INTEGER, -- child_run_id 那张子 run 是为哪一次 attempt 起/复用的 started_at TEXT, completed_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS idx_graph_run_nodes_unique ON graph_run_nodes(run_id, def_node_id); CREATE TABLE IF NOT EXISTS graph_run_loop_records ( id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES graph_runs(id), edge_id TEXT NOT NULL, -- 触发回退的那条 backward 边(org/work 边表按 kind 解释) from_node_run_id TEXT NOT NULL REFERENCES graph_run_nodes(id), to_node_run_id TEXT NOT NULL REFERENCES graph_run_nodes(id), loop_count INTEGER NOT NULL, -- 这是第几次沿这条边回退(1-based) verdict TEXT NOT NULL CHECK (verdict IN ('registered','fixed','not_fixed','force_terminated')), remediation_note TEXT, -- 「应该如何修复」,供下一轮读取 target_node_ref TEXT, -- R-17.1:这次回退**实际落到**的跨层地址(解析后) created_at TEXT NOT NULL ); ``` **跨层节点地址的存储形态(R-17.1,第六轮定案 + 落地)** R-17.1 要求「归因到哪个 work graph node 就退到哪个 node」,这个地址必须能存、能传、能读回。三个备选: | 备选 | 结论 | 理由 | |---|---|---| | 在 org 边上加一列(`org_graph_edges.target_work_node`) | ❌ 否决 | 边是**定义层**,被点名的 work graph node 是 `workgraph-planning` **运行时**才规划出来的(每次都是新的 `wgn_*`)。定义层根本写不出这个值;退而写节点名,则一条边只能钉死一个固定目标,而归因是每轮都可能不同的运行时结论 | | 让 `fail_reason` 携带(`impl_bug@impl-a`) | ❌ 否决 | `fail_reason` 是 §四.D.2.2 的**选边匹配键**,掺进地址后所有精确 fail 边一律失配、全落兜底边;且它有封闭取值域(`ATTRIBUTION_REASONS`),复合串会被合法性检查当成自创取值直接降级成 `null` —— 即「归因产出被静默丢掉」的又一个变体 | | **`graph_run_nodes.fail_node_ref` + `graph_run_loop_records.target_node_ref`** | ✅ 采用 | 见下 | **采用的形态**:`fail_node_ref` 存 JSON 串 `{"orgNode":"org-coder","workNode":"impl-a"}`(不用分隔串,节点名里出现分隔符时无歧义),语义是「**这一行**的失败,根因在 `orgNode` 那张 work graph 的 `workNode` 上」。它与 `fail_reason` **同源同轨**:同一份 `attempt-{n}.json` 产出、同一次条件更新落库(`NODE_PATCH_COLUMNS` 白名单)、同一条 `deriveChildOutcome` 透传上去,所以不存在「有根因没地址」的中间态。`orgNode` 可缺省(归因只说得出「哪个环节」时),缺省则以选中的那条 backward 边的目标为准。 **为什么还要账本上那一列**:`fail_node_ref` 是归因的**主张**,`target_node_ref` 是引擎的**动作**(解析后的结果,含 `childRunId` / `nodeRunId`)。主张可能解析不了(图换过、节点名不存在、与选中的边不是同一个 org node),此时账本这一列为空 = 「这次退的是整张图」,控制台据此显示回退粒度。**只用 loop record 当唯一载体不行**:它只在命中 backward 边时才有行,而地址在归因发生时(子 run 内部)就产生了,比回退早好几步——那正是 §八.14 那个「判词只在 backward 路径落库」缺陷的形状。 **四环对账**(产生 → 传递 → 落库 → 读回,§十 那个模式的自检):产生 = `validator-triager` 写 `attempt-{n}.json` 的 `target_node` / `target_org_node`(提示词由 `describeArtifactContract` 注入,所有 `agent_call` 节点都会收到)→ 解析 = `readNodeProduct` → 落库 = 子层节点行 `fail_node_ref` → 读回 = `deriveChildOutcome` → 传递 = `outcome.failNodeRef` → 落库 = 父层节点行 → 读回 = `resolveCrossLayerTarget` → 动作 = 定点重置 → 落库 = 账本 `target_node_ref` → 读回 = REST `loopRecords` → 控制台 `NodeDetailPanel`。 > ⚠️ **一处已知的类型不整齐**:`org_graph_nodes.id` 是 `INTEGER AUTOINCREMENT`(P0 建表时定的),`work_graph_nodes.id` 是 `TEXT`。`graph_run_nodes.def_node_id` 统一取 `TEXT`,org 侧存整数的字符串形式。代价是这一列不能建外键约束,得靠服务层保证引用完整性。备选是把 `org_graph_nodes` 的主键改成 TEXT——那要动 P2 已落地并已有数据的表,不值得。**上限**:`def_node_id` 无 FK 保护,删除定义节点时需服务层显式级联;升级路径是等 org 侧有独立迁移窗口时统一成 TEXT 主键。 #### A.3 任务级总预算:挂 root run [27-E2E](./27-e2e-task-execution-flow.md) §7.1 指出的最严重问题:三级恢复(跳回节点 → 重新 planning → 迭代 agent)如果每级都重置下一级的计数器,整个循环没有终止条件。分层预算(`retry_budget` / `l2_budget`)只是**局部快速失败**信号,不是兜底。 有了 run 层,兜底的挂载点很自然——**`budget_json` 只在 root run 上非空,所有子 run 通过 `root_run_id` 扣同一个池**: ```jsonc { "total_agent_calls": 100, // 所有层级所有 agent 调用的总次数 —— ✅ 可用 "total_tokens": 5000000, // ✅ 可用(第四轮补上了产生端,见下方「三个维度的实际可用性」) "wallclock_ms": 7200000, // 2h —— ✅ 可用 "spent": { "agent_calls": 0, "tokens": 0, "started_at": "..." } } ``` **只增不减**。任何一层发起 agent 调用前先按 `root_run_id` 检查余额;扣完不是失败,是**整棵 run 树挂起等人工**(root run → `blocked`,向下级联 `pause_requested=1`)。 **三个维度的实际可用性(2026-08-16 逐条 grep 核实;第五轮重新核实并改正了 `total_tokens` 那一行——第四轮把它写成「死维度 / 需跨协议层改动」时,同一轮的代码其实已经把产生端补上了,文档与代码在同一轮里对不上)**: | 维度 | 可用性 | 依据(函数名为准,行号会随每轮改动漂移) | |---|---|---| | `total_agent_calls` | ✅ **真的在管用** | `advanceOne` 抢到节点后立刻 `spendBudget(run, {agentCalls: 1})`,检查在拾取节点之后、发起调用之前(`checkBudget` 返回 `budget-exhausted`)。子图节点规划那一次也按一次计。 | | `wallclock_ms` | ✅ **真的在管用** | `spent.started_at` 在第一次 `spendBudget` 时落库,`checkBudget` 用 `Date.now() - Date.parse(started_at)` 比较。 | | `total_tokens` | ✅ **第四轮已接通产生端**(原「❌ 死维度」作废) | 产生端挂在 `defaultAgentExecutor` 上:它给 `AgentManager.callAgent` 传一个 `onAcpEvent` 回调,逐条累加 `session/result` 事件里的 `params.usage.{input,output}`,**三条 return 路径(成功 / 失败 / 抛异常)都带 `tokens`**——炸掉之前烧掉的 token 一样要扣(预算「只增不减」)。`settleNode` 侧 `if (outcome && outcome.tokens) spendBudget(run, {tokens})` 因此不再恒假。**第六轮终验的口径修正(原文「实测 `spent.tokens` 100 → 200 递增并能单独触发 `exhausted`」不实、已删)**:终验直接给 `AgentManager.callAgent` 挂 `onAcpEvent` 复核,`session/result` 事件确实到达、`usage` 确实被读——**接线成立**;但仓库自带的 mock 协议把 `usage` 硬编码成 `{input:0,output:0}`(`ClaudeCliProtocol._sendMockPrompt`),所以「底层换 mock 控成本」这套验证手法下 `spent.tokens` **恒为 0**,那个递增的数产不出来。准确档位是**「产生端接线已验证,数值大小未验证」**;三维里实测能单独触发 `exhausted` 的**只有 `total_agent_calls`**。 | **`total_tokens` 的完整链路**(截至 commit `c3abf4a` 之后的第四轮工作树,函数名为准): `graph-engine.defaultAgentExecutor` 定义 `onAcpEvent` → `AgentManager.callAgent` 在入参里解构它 → 原样 spread 进 `_runViaProtocol` → 传给 `runPromptInSession` → `stream-executor` 的 `onAcpEventWrapped` 在转发链末尾回调它 → 事件源是 `ClaudeCliProtocol` 收到 CLI 的 result 后 `emit('acp_event', {method:'session/result', params:{usage, cost_usd, ...}})`。 > ⚠️ **这一维有一个已知的低计**(`ponytail` 注释已标在 `defaultAgentExecutor` 上):只有 `claude-cli` 协议会 emit `session/result`;走 `AcpProtocol` 的 agent 不 emit,它们的 `tokens` 恒为 0。表现是**低计而不是崩溃或负数**,所以熔断只会晚触发、不会误触发。要补齐得让 `AcpProtocol` 也上报 usage。 **结论**:预算三维**现在都在管用**,终止性论证可以同时依赖三维——但仍受下面这条前置约束(三维全都受它约束,不只是 tokens)。 **另一条同样重要的前置**:`budget_json` **只在建 run 时显式传了 `budget` 才非空**。目前只有 `POST /api/company/:cid/tasks` 这一条入口默认给(`routes/company.js` 的 `DEFAULT_TASK_BUDGET` 常量);直接调 `createRun` 建出来的 run(测试、手工建图、控制台后续可能新增的入口)`budget_json` 为 null = **完全不限额**,预算这层兜底根本不存在。新增任何建 root run 的入口时必须一并给预算。 **预算耗尽之后怎么恢复?—— 现状是「只能改库」(第七轮核实,产品缺口,不是 bug)** 链路是闭合的:`advanceOne` 里 `checkBudget` 返回 `budget-exhausted` → `suspendRunTree(run)` 把**整棵树**挂起——root run `→ blocked`,树上所有非终态 run 置 `pause_requested=1`(下一个节点边界落 `paused`),已经在跑的节点**停在 `running` 不回滚**(进程还在,回滚会造成双写)。这一步的终止性是对的:不烧钱、不失败、等人工。 问题在**人工那一步没有出口**: | 想做的事 | 现有入口 | 结果 | |---|---|---| | 恢复执行 | `POST /api/graph-runs/:id/start`(resumeRun) | 清 `pause_requested` → 推进一步 → `checkBudget` **再次**判定超支 → 又 `suspendRunTree`。原地打转。 | | 加额度 | **没有任何端点** | `budget_json` 只在 `POST /api/company/:cid/tasks` 建 root run 时写一次(`DEFAULT_TASK_BUDGET`),之后**只有 `spendBudget` 会写它**(只增不减)。 | | 放弃 | `POST /:id/stop` | 可用,但那是终止不是恢复。 | 所以今天唯一的恢复手段是**手改 `graph_runs.budget_json`**(root 那一行,调大 `total_*` 或清 `spent`),再调 `/start`。 **建议补法**(P4,一个端点即可):`POST /api/graph-runs/:id/budget`,只接受**调高**(`total_agent_calls` / `total_tokens` / `wallclock_ms` 三选若干,新值必须 ≥ 旧值;`wallclock_ms` 还要允许顺带重置 `spent.started_at`,否则墙钟维度加额度也追不回来),写完顺手 `resumeRun`。三条约束别丢:① 只认 root run(子 run 的 `budget_json` 恒为 null);② 只增不减,避免「调低额度」变成另一种绕过熔断的路径;③ 与 §四.C 的成本告知一致——加额度要把「已花多少」一起回给前端。**这条与回退熔断(`LOOP_HARD_CAP_*`)无关**,那两个硬顶是纯整数、不可通过任何端点调整,是终止性的最后一道。 按 `plan-cost-concurrency.md` 的实测,DevLoop 一个中等项目 ≈ 39 次 agent 调用 / 410K token / $4.8 / 墙钟 20 分钟;本架构还要叠加两层图的规划,粗估中等任务 **60-100 次调用、$10-20、1-2 小时**([27-E2E](./27-e2e-task-execution-flow.md) §7.6)。这个量级下自动模式**启动前必须把预估成本告诉用户**(§四.C)。 #### A.4 状态机 ``` graph_run_nodes.status: pending → ready (上游全部满足 / 无上游依赖) ready → running (被 advanceOne 拾取) running → success | fail (agent 调用结束 / 子 run 收敛) fail → ready (命中一条 backward 边且未超硬顶;或人工「重试」) → blocked (无可走的 fail 边 / 回退硬顶耗尽) success → (终态,除非人工「重试」强制打回 ready) blocked → ready (人工「重试」,唯一走出 blocked 的路) * → skipped (run 被「停止」时,所有仍处 pending/ready 的节点) graph_runs.run_status: planning → ready (图规划完成 + 通过 §三.7 四项校验) ready → running (「启动」) running → paused (「暂停」软信号,下一个节点边界生效) paused → running (「启动」或「下一步」恢复) running → blocked (某节点 blocked 且无其他节点在 ready/running) blocked → running (对 blocked 节点「重试」后) running/paused/blocked → stopped (「停止」) running → completed (所有可达节点到达终态,且无 pending/ready 悬空) ``` 非法转换(如对 `success` 节点直接「下一步」)在服务层拒绝并返回明确错误,模式与 `unit-registry.js` 的 `InvalidTransitionError` 一致。 #### A.5 产物布局与跨节点传递 v0.1 只有 `output_ref`(节点内产物指针),**不足以支撑 R-15.3 推导出的跨 org node 传递**:`goal-benchmark` / `goal-test-data` / `goal-test-script` 在 `org-planner` 这个 org node 的 work graph 里产出,消费者却是 `org-validator` 这个**另一个 org node**。 ``` data/companies/{cid}/runs/{rootRunId}/ ├── artifacts/ # 任务级共享产物区(root run 级,跨 org node 可读) │ ├── goal-benchmark.json # 验收契约,见阶段 0 │ ├── goal-test-data/ │ └── goal-test-script/ └── nodes/{nodeRunId}/ # 节点私有产出,output_ref 指向这里 └── attempt-{n}.md ``` **读写规则(照搬 `CONVENTIONS.md` §12 的隔离思路)**: | | 读 | 写 | |---|---|---| | 自己的 `nodes/{自己}/` | ✅ | ✅ | | 别的节点的 `nodes/{其他}/` | ❌ 禁止 | ❌ | | `artifacts/`(按 `root_run_id` 上溯) | ✅ 任意层级只读 | ⚠️ 需节点在定义里显式声明产出权 | 「下游只能看到上游的契约,看不到实现」这条 DevLoop 已验证的原则原样继承。写 `artifacts/` 是特权动作——只有被 `workgraph-planning` 标记为验收契约产出者的节点才有,防止任意节点往共享区乱写。 节点私有产出的物理位置仍沿用 27 号文档 §四已定的 System Agent 目录约定,本文档不新造路径。 #### A.6 幂等与并发 `advanceOne` 必须幂等:同一节点被并发触发两次(自动驱动器和人工同时点),第二次检测到 `status` 已不是 `ready` 就返回当前状态而不报错。不引入分布式锁——SQLite 行级事务 + `UPDATE ... WHERE status='ready'` 条件更新(受影响行数 = 0 即「已被别人拿走」)就够。 --- ### B. View(界面设计) #### B.1 页面结构 新页面、新路由,不挂在 `CompanyDashboard.vue` 已有的 tab 体系下(信息密度和交互复杂度明显超过一个 tab 该承载的量,参照 `PipelineEditor.vue` 独立成页的先例): ``` ┌──────────────────────────────────────────────────────────────────┐ │ 组织与执行 预算 42/100 calls · $6.2 · 38min │ ├───────────────┬──────────────────────────────┬───────────────────┤ │ Org Graph │ Work Graph 画布 │ 节点详情面板 │ │ (同一个画布 │ (选中 org node 的 work │ (点某个 work │ │ 组件,只是 │ graph,见 B.2) │ graph node 后 │ │ graph_kind= │ │ 展开,见 B.3) │ │ 'org') │ 控制栏: ▶启动 ⏭下一步 │ │ │ │ ⏸暂停 ⏹停止 🔁重试 │ 状态/耗时/attempt │ │ org-planner │ │ [概览|输入输出| │ │ ↓ │ ●pending ●running ●success │ 运行日志|子图] │ │ org-coder │ ●fail ●blocked ●skipped │ │ │ ↓ │ ┄┄▶ 回退边 2/3 │ │ │ org-validator │ │ │ └───────────────┴──────────────────────────────┴───────────────────┘ 面包屑:公司 › org-coder › (子图) ``` 左栏和中栏是**同一个画布组件的两个实例**——因为两层图同构(R-9.1),这是 R-15.4 引擎泛化在前端的对应收益。左栏点击某个 org node → 中栏加载该节点的 work graph(一个 org node 生命周期里可能跑过多张,默认显示最新,下拉切换历史)。 #### B.2 画布 - **节点**:矩形卡片,标题是 `node_name`,右上角小圆点按 `status` 着色(沿用仓库既有色彩语义:绿=success、蓝=running、灰=pending/ready、红=fail、橙=blocked、浅灰斜纹=skipped)。`node_kind='subgraph'` 的节点右下角带一个「⊞」角标,提示可下钻。 - **边**:`forward` 是实线箭头;`backward` 是**虚线曲线箭头**,琥珀色,曲线中点渲染徽标,内容取自 `graph_run_loop_records` 里该 `edge_id` 最新一条记录的 `loop_count`,格式 `2/3`——不是随便画一条回退线,是带着「当前第几次、硬顶是几」上下文的线。 - **布局**:**扩展 `UnitsDAG.vue`**(§3.5),不从零写。新增工作量只有三块:`node_kind='subgraph'` 角标、`backward` 边的虚线曲线路由(避开其他节点,这块是真新的)、循环徽标。不引入图形库——既有实现已证明这个规模手写 SVG 足够。 - **主流程 vs 异常流程**:`success` 前进边和节点是画布主干;所有和 `fail` 相关的边、`blocked` 节点、回退边整体**降低视觉权重**(统一加一层浅色背景框),让人一眼分清「这张图正常该怎么走」和「这次因为哪里失败绕了远路」。 #### B.3 节点详情面板(四个 tab) 1. **概览**:`status`、`attempt`、`started_at`/`completed_at`/耗时、`fail_reason`、**`fail_node_ref`**(R-17.1 跨层地址,渲染成 `orgNode / workNode`);如果这个节点参与过回退,展示 `graph_run_loop_records` 里相关的完整记录(`loop_count` / `verdict` / `remediation_note`),**每条记录标出回退粒度**——有 `target_node_ref` 就是「跨层定点重置 → org node / work node」,没有就是「整张 work graph 重跑」。`subgraph` 节点的 `child_run_id` 一行另标注它是**本轮新起的**还是**复用中的**(`child_run_attempt < attempt`)。 2. **输入输出**:渲染 `output_ref` 指向的文件。**「原始产出」和「实际被下游消费时的形态」要能分别展示**——大多数正向边上两者相同,但经过回退、拼接了「上一轮 remediation_note」之后就不同了,不能假设总是相同。若该节点读过 `artifacts/`,一并列出读了哪些。 3. **运行日志**:复用 §3.4 的双轨折叠卡片——`running` 时走 live 面板流式追加,`success`/`fail` 后切换成历史视图。展示的是**这次 agent 调用内部**的对话/工具调用记录。 4. **子图**:仅当 `node_kind='subgraph'` 且 `child_run_id` 非空时出现,内容是**递归复用同一个画布组件**指向 `child_run_id`,上方面包屑支持逐层下钻和返回。 **层级语义说明块(R-17.1 要求的那处显式表达)**:跨层寻址破坏了层级封装,「暂停 / 单步」作用于哪一层因此变含糊,这个 tab 顶部常驻三条说明——① **「下一步」只推当前这一层**,subgraph 节点的一步 = 「把子 run 交给引擎」,子图内部的节点要**进入子图后**再单步;② **「暂停 / 停止」是全局意图、向下级联**,「启动」是它的镜像;③ **这张子 run 会被跨层复用**,上一层的回退可能只重置子图里**某一个**节点,不是整张子图重跑,所以子图里各节点的 `attempt` 可能不一样。不写这三条的话,用户会把「上一层退了一次」读成「子图从头跑了一遍」,而实际上引擎只重跑了一个节点。 #### B.4 预算条 页头常驻显示 root run 的预算消耗(calls / tokens / 墙钟)。**自动模式点「启动」前弹一次预估成本确认**——按 §四.A.3 的量级,这不是可选的礼貌,是必要的。 #### B.5 空态 一个 org node 从未跑过 work graph:中栏显示空态提示,不报错、不留白页面。 --- ### C. Control(控制操作) | 操作 | 可用条件 | 引擎侧动作 | 对正在跑的节点的影响 | |---|---|---|---| | **启动** | `run_status ∈ {ready, paused, blocked}`,**或 `run_status='running'` 且 `pause_requested=1` 且 `stop_requested=0`**(= 「暂停中」,撤回暂停意图)。后半条不是宽松而是必需:org 层每个节点都是 subgraph,子 run 在飞时父节点恒为 `running`,`landPause` 因 busy 永远落不下 `paused`,只认 `run_status` 的话这一层的「启动」永久 400 / 按钮永久置灰,用户只剩「下一步」一条路(R6-F3)| `→running`(已经是 running 就只清标志位);**清全树 `pause_requested`/`stop_requested` 并把被级联暂停的后代 `paused → running`**(`resumeDescendants`,与「暂停」的级联成镜像;`blocked` 的后代不碰,解除阻塞的正当入口是对节点点「重试」);`auto` 模式随后挂一个自我调度的循环,`manual` 模式停在原地等「下一步」。**驱动的是「以该 run 为根的子树」,不要求它是 root run**——控制台下钻到子图那一层点启动,只驱动这棵子树(`advanceTree`/`runAuto` 的形参是子树根)| 无 | | **下一步** | `run_status='running'` 且(`manual` 或 `auto` 已 `paused`) | 拾取**一个** `status='ready'` 的节点执行,完成后评估出边把满足条件的下游置 `ready`,**不继续往下跑**。多个并行 ready 时 UI 必须明确标出「接下来要跑哪个」,避免用户以为点一下推进所有分支 | 会真正发起一次 agent 调用,不可中途取消 | | **暂停** | `run_status='running'` | 置 `pause_requested=1`,**并沿 `parent_run_id` 向下级联到所有 active 子 run**。不打断当前正在 `running` 的节点,在下一个节点的调度前检查标志位 | 软暂停,生效点是下一个节点边界 | | **停止** | `run_status ∈ {running, paused, blocked}` | 置 `stop_requested=1` 并**向下级联**;随后**自底向上**逐层:仍 `pending`/`ready` 的节点批量 `skipped` → `→stopped` → **`propagateToParent` 回写父节点**(与子 run 正常收敛走同一条路)。少了最后这一步,父层那个 subgraph 节点会永远停在 `running`——run 已是终态、节点却还在跑,控制台读起来像没停下来(R6-F3)。只停子树时父 run 不在停止集里,它就此拿到「子 run 停了」这个失败结果,按图上的 fail 边自行处置。**正在 `running` 的 agent 节点不打断**(T9 已核实,硬取消调用链不存在,见下) | 打不断,等它自己跑完;run 已是 `stopped`,但该节点的结果仍会照常落库 | | **重试** | 节点 `status ∈ {fail, blocked}` | 该节点 `→ready`,`attempt+1`。**默认不级联重置下游** | 无 | **三层级联**(R-15.4 / [27-E2E](./27-e2e-task-execution-flow.md) §7.5):v0.1 §八.4 把「子图不继承父图暂停状态」列为刻意简化,现在层级从两层变三层(root → org node → 嵌套子图),「点了暂停但子图还在偷偷跑」会成为真实困惑点。**定案:暂停/停止向下级联;「下一步」只作用于当前查看的层级**(符合直觉——暂停是全局意图,单步是局部操作)。`root_run_id` 冗余列让级联是一次索引查询,不用递归遍历。 **「重试」不级联下游的判据**(v0.1 §八.3 的 open question,现已关闭):不由重试动作决定,由**产出契约是否真的变了**决定。采用 `loops-l3.md` 的增量算法——按 `contract_hash` 把下游分成 added / changed / removed / **unchanged** 四类,`unchanged`(id 在且 hash 不变)保持原状**无须重跑**,只有 hash 变了的才回退到 `pending`。`hashContract()` 的实现见 `doc-store.js`(对 `contract.exports` 做递归 key 排序的规范化序列化后 sha256 取前 16 位),直接复用。 **手动/自动的统一原语**:两种模式落到服务层都是同一个 `advanceOne(runId)`,区别只在**谁调用它、调用节奏由谁决定**。手动 = 收到用户点击的路由 handler,每次一下;自动 = 引擎自己的「节点完成回调」,只要 `pause_requested`/`stop_requested` 都是假就在上一次 resolve 后立刻发起下一次。**只有一套推进逻辑,自动模式只是一个不停调用它的循环,不是另一套实现**——这是避免两套并行执行逻辑互相踩的关键。 **「停止」是软停止,不是硬取消**(T9 已核实,结论定案)。§3.6 当时留的 open question 现已关闭,**结论是「做不到」**——不是权衡后的取舍,而是硬取消所需的调用链在当前代码里根本不存在: - `ProcessGuard` **在生产代码里从未被实例化**。全仓库 `new ProcessGuard` 只出现在 `server/src/tests/unit/process-guard.test.js`;`server/src/engine/index.js` 只是把它 re-export 出去,没有任何调用方。所以「SIGTERM → 2s → SIGKILL」这段能力(`process-guard.js` 的 `_kill()`)目前是**死代码**。 - `RunRegistry` 被实例化并层层透传(`standalone-entry.js` → `Orchestrator` → `AgentManager`),但**没有任何生产代码调用过它的 `add()`**,注册表恒为空。因此「按 runId 找到某次调用」这个前提本身不成立,`RunRegistry` 里没有、也无从持有 `ProcessGuard` 引用。 - 真正持有子进程的是 `ClaudeCliProtocol._child`(`ClaudeCliProtocol.js` 里那次 `spawn`),杀它的唯一现存路径是 `disconnect()`(同文件,`stdin.end()` + `SIGTERM`,无 SIGKILL 兜底),由 `SessionPool.killActive()` 经 `AgentManager.killCurrentProcess()` 暴露。**这条链现在也没有任何调用方**,且它只能杀「当前 active 会话」,粒度是会话不是 runId。 - 即便接上这条链,`graph-engine.js` 的 `defaultAgentExecutor` 每次调用都 `new AgentManager()` 一个**局部临时实例**,调用返回即不可达,`stopRun` 拿不到它。 **因此本文档定案:「停止」的语义就是「不再调度后续节点」,正在跑的节点等它自己跑完。** 用户点停止后 run 立刻变 `stopped`、`pending`/`ready` 节点立刻 `skipped`,但**已经发出的那一次 agent 调用会继续跑到结束**,其结果照常落库(不因 run 已 stopped 而丢弃——丢弃会浪费已花费的预算,且让产物与 timeline 对不上)。UI 必须把这点说清楚,不能让用户以为点了停止子进程就没了。 要把它升级成真硬取消,是一次**跨 4 个文件的新增接线工作**(让 protocol 暴露 runId 级句柄 → `SessionPool`/`AgentManager` 按 runId 登记进 `RunRegistry` → 引擎持有长生命周期 manager → `stopRun` 按 runId 取消),**不属于 T9 的「只读核实」范围**,需要时另立任务。 --- ### D. Process(执行过程) #### D.0 两种 node executor v0.1 只设计了一种执行语义。org graph 加入后必须分派(R-15.4): | | `agent_call` | `subgraph` | |---|---|---| | 执行 = | 调 `AgentManager` 跑 `system_agent_id`,写 `agent_run_id` | 起一张子图并等它收敛 | | success/fail 来源 | agent 调用结果 | 子 run `completed` → success;`blocked`/`failed` → fail | | 耗时量级 | 秒~分钟 | 分钟~小时 | | 谁用 | work graph 节点(默认) | **org graph 节点(全部)**;work graph 里 agent 自行判断需要拆子图的节点 | `subgraph` 节点的完整动作: ``` 节点 → running → 调 workgraph-planning(全局通用本体,R-15.2),输入 = 该 org node 的角色定义 + 任务描述 + 可读的 artifacts/ + 上一轮 remediation_note(若有) → 产出图 → 过 §三.7 四项校验 → 写 work_graphs / work_graph_nodes / work_graph_edges → 建子 graph_run(parent_run_id / parent_node_run_id / root_run_id 三者填好, execution_mode 默认继承父 run)→ 回填 child_run_id → 递归执行子 run(同一个 advanceOne) → 子 run 收敛 → 回写本节点 success/fail + fail_reason + remediation_note ``` 父节点在子 run 跑完之前一直停在 `running`。这也让 §四.B.3 的「子图」tab 有了明确的数据来源。 **⚠️ 收敛时必须透传三个值,不只是 success/fail**(R-16)。原因是决定「失败后走哪条边」的**不是失败节点自己,而是它子图里的归因节点**(§四.D.2.2):`org-validator` 这个 org node 判定任务未达成时,是它 work graph 里的 `validator-triager` 节点算出了根因,这个结论必须能穿透子 run 边界回到 org 层,否则 org 层选边时无据可依。 | 回写字段 | 来源 | 用途 | |---|---|---| | `success` / `fail` | 子 run 的 `run_status` | 父节点状态转换 | | `fail_reason` | 子图归因节点的产出 | 父层 §四.D.2.2 选边 | | `remediation_note` | 同上 | 随回退传给目标节点,成为它下一次 `workgraph-planning` 的输入 | #### D.1 主流程 ``` 节点进入 ready → advanceOne 拾取 → running → 按 node_kind 分派执行(D.0) → 产出写入 output_ref(特权节点可另写 artifacts/) → success:遍历出边中 condition='success' 的边,目标节点若**所有**入边条件都满足则置 ready (多入边 = 汇合点,采用「全部满足」语义,与 27 号文档 §3.4 的图规划约定一致) → 所有节点到达终态且无 ready/pending 悬空 → run_status = completed → 若本 run 有 parent_node_run_id,回写父节点 success ``` #### D.2 异常流程 ``` 节点 running → fail(附带 fail_reason) → 遍历出边中 condition='fail' 的边,按 D.2.2 选一条 ├─ 选中边 edge_kind='forward' → 普通失败分支(如「失败转人工审核节点」) │ → 按主流程把 to_node 置 ready ├─ 选中边 edge_kind='backward' → 回退,进 D.2.1 熔断流程 └─ 无可走的 fail 边(死路)→ 该节点 blocked → 若无其他节点在 ready/running,run_status 同步 blocked → 这是「需要人工介入」的终止态,区别于「停止」(人工主动终止) ``` **D.2.1 回退熔断(两层通用,取代 v0.1 的 `loop_validator` 强制节点)** 引擎准备沿一条 `backward` 边推进时,无条件执行: 1. **备案**:按 `(run_id, edge_id)` 查 `graph_run_loop_records` 已有记录数 n,`loop_count = n + 1`,插入一条 `verdict='registered'`;同时 `graph_runs.backward_count += 1`。 2. **双层硬顶检查**:`loop_count > 3`(单边)**或** `backward_count > 5`(run 级总数)→ 不再调用任何 LLM,直接改 `verdict='force_terminated'`,源节点 `blocked`,链路收敛,等人工介入。**这一条是纯整数比较,不依赖 agent 调用**——保证熔断不会被 LLM 的自由发挥绕过。 3. **回退**:两个计数都未撞顶时把 `to_node` 置 `ready`、`attempt+1`,把上一轮的 `remediation_note` 作为上下文补充带给它。 > **为什么单边计数不够**(R-16):v0.1/v0.2 只按 `(run_id, edge_id)` 计数,前提是一个节点只有一条回退边。D.2.2 引入多条回退边之后,每条边各自计数,**硬顶实际变成 3×N**——归因节点只要每次归因到不同的 agent、走不同的边,就永远撞不到任何一个计数器的顶。这是 [27-E2E](./27-e2e-task-execution-flow.md) §7.1「每一级都重置下一级的计数器则整个循环没有终止条件」的同型变体:不是重置计数器,但效果一样。 > > 所以**两个计数都要**:单边计数防同一条路径上的死循环,run 级 `backward_count` 防「换个借口继续退」。它与 §四.A.3 的任务级总预算也是两回事——**预算管花了多少钱,`backward_count` 管绕了多少圈**,两者互不替代。 **`infra_flake` 不烧任何计数**:进程超时 / 被杀这类基础设施抖动,归因为 `infra_flake` 后直接原地重跑,**既不写 `graph_run_loop_records`,也不加 `backward_count`,也不加 `attempt`**——沿用 DevLoop 的差异化预算消耗(`_spec/sdd-process.md` P-7)。不设这一类的话,超时会被误判成执行问题白烧一轮。 > ⚠️ **它的终止性靠什么兜(2026-08-16 核实后改写,原文「由任务级总预算兜底」说得太满;第五轮再修一次口径)**:`infra_flake` 原地重跑**仍然每次都过 `spendBudget(agentCalls: 1)`**,所以只要 root run 有预算,`total_agent_calls` 这一维就兜得住。第四轮把 `total_tokens` 的产生端接上之后(§四.A.3),`tokens` 与 `wallclock_ms` 也一并兜得住了——**原文这里写的「只有 total_agent_calls 这一维、tokens 是死维度」已作废**。 > > 但 `budget_json` 只在建 run 时显式传了预算才非空(§四.A.3 末段),**这条前置没变、也是三维共同的前提**。**没有预算的 run**,兜底退化成两条:自动模式靠 `runAuto` 的 `maxSteps`(默认 1000)这个纯粹的防跑飞安全阀;手动模式则没有任何自动兜底——每次重跑都要人点一次「下一步」,闸门是人。 > > 换言之:**「预算兜底」成立的前提是 root run 真的有预算**。新增建 root run 的入口时不给预算,等于把 `infra_flake` 的死循环防护一起去掉了。 > **为什么不强制插 `loop_validator` 节点**:27 号总纲 §3.4 第 4 点要求 work graph 的回退边必须经过 `loop_validator`。但 org graph 是 `orggraph-planning` 生成的「这家公司有哪些角色」,往里塞一个基础设施节点会污染这个语义。**定案:熔断计数由引擎无条件做(两层都有),`loop_validator` 节点降级为可选增强**——`workgraph-planning` 可以在 work graph 里插它,由它做 27 号 §3.5 的「审计 + 修复方案传递校验」(判断上一轮到底修好没有、产出 `remediation_note`,写回 `verdict ∈ {fixed, not_fixed}`),那是 LLM 的活;「数到 3 就停」是代码的活。两者分工清楚,不互相依赖。 **D.2.2 一个节点有多条 fail 边时走哪条(R-16 定案)** > ✅ **R-17.1 返工已完成(第六轮):回退粒度从 org node 收窄到 work graph node,跨层直接寻址。** > > | 面 | 返工前(org node 粒度) | 现在(work graph node 粒度) | > |---|---|---| > | 回退目标 | org 边的 `to_node_id`,指向一个 org node | **跨层节点地址** `{orgNode, workNode}`,存 `graph_run_nodes.fail_node_ref`(形态与论证见 §四.A.2) | > | 重跑范围 | 该 org node **整张 work graph 重跑**(起新子 run) | 复用既有 work graph 定义,**只重置被点名的那个 node** | > | 下游处理 | 整张图重来,无所谓 | 按 `contract_hash` 的 added/changed/removed/**unchanged** 四分类决定级联——**复用既有机制**:定点重置走的就是 `retryNode` → `snapshotContract` → `applyRetryCascade` 这条既有链,没有另写一套 | > | 子 run | 每次回退新建一张 | **复用旧子 run**(`child_run_id` 不变),被点名的节点 `attempt+1` | > > **重新设计后的幂等判据**(这是本次返工最要紧的一条)。第二轮那个死锁(「subgraph 重跑不起新子 run 导致永久死锁」)的成因是**守卫读到上一轮的终态子 run 就返回 pending**,修法是「每次回退都新建子 run 并清空 `child_run_id`」——**当时的正确性正好架在「不复用子 run」这条上**,而 R-17.1 把它拆掉了。所以判据换了一个维度: > > - **旧判据**:「旧子 run 还活着吗」。活着就等它,终态就另起一张。复用之后,「上一轮留下的终态」与「这一轮刚重置过的子 run」在 `run_status` 上长得**一模一样**,这条判据分辨不了。 > - **新判据**:「这张子 run 服务的是**本轮**吗」——`graph_run_nodes.child_run_attempt` 记下它是为父节点的哪一次 `attempt` 起/复用的,与 `child_run_id` **同一句 UPDATE** 写入(分两句写会留下「有子 run、代次未知」的中间态)。三条分支,**没有一条会在子 run 已终态时返回 pending**,死锁那一类在结构上不再存在: > > | 代次 | 子 run 状态 | 动作 | > |---|---|---| > | `== attempt` | 未终态 | 它正在为本轮干活 → 返回 `pending` 等回写(唯一等待分支) | > | `== attempt` | 已终态 | 回写在某一步丢了(进程重启 / 事件竞态)→ **就地按它的结果结算**,绝不返回 `pending` | > | `!= attempt`(含旧库的 `NULL`) | 任意(`stopped` 除外) | 新一轮 → `reopenChildRun` 复用定义与 run 行;`stopped` 是人工终止的结果,不自动复活,落回「重新规划一张」 | > > - **「改节点」与「开跑」分在两个时刻**:定点重置在命中 backward 边那一刻就做完(把被点名的 node 打回 `ready`),但子 run 的 `run_status` 留到父节点真正被拾取时才由 `reopenChildRun` 拉起。反过来做的话,子 run 一变 `running`,`advanceTree` 就会抢在上游 org node 重做完之前先跑子图。 > - **`completed` 的出口只开到 `ready`**(`RUN_TRANSITIONS`),不开到 `running`:`ready` 的语义正是「图还在、已就绪、等启动」,随后仍走既有的 `ready → running`,于是全仓库「怎么起跑」只有一条路径。 > - **`infra_flake` 例外**:它明确不加 `attempt`(见上),代次判据在这条路径上分辨不出新旧轮,所以原地重跑时 `child_run_id` 与 `child_run_attempt` **一起清空**,回到「没有子 run」分支重新规划。 > - **解析不了地址就退化**(不是报错,也不是死路):没地址 / 回退目标不是 `subgraph` 节点 / 目标手上没有可复用的子 run / 地址声称的 org node 与边的目标对不上 / 子图里没有这个节点名 —— 五道关任一不过都记一条 timeline 并退化成「整张 work graph 重跑」,语义与返工前一致。其中「org node 对不上」一条是**以边为准**:边是图上真实存在的路由,地址只是归因的主张,静默照做等于让一句 LLM 输出改写图的拓扑。 > > **验证**:`graph-engine.test.js` 19 条全绿(原「回退后 subgraph 节点重跑应起一张新的子 run」按新语义重写为「复用同一张子 run、只重置被点名的 node」,**是全套里唯一一条改过期望的用例**,用例注释里写明依据 R-17.1;另新增两条——契约变了要级联、地址解析不了要退化)。外加两个独立脚本:一个专打死锁复现(三个场景,含「孤儿终态子 run」这个旧死锁的直接成因;把守卫改回旧写法它会立刻变红),一个是真 server + HTTP 的跨层回退实测(16 条断言,证明只有被点名的节点被重置、`child_run_id` 不变、下游 `impl-b` 一次都没重跑)。 这是 org 层引入后冒出来的新问题。`org-validator` 判定任务未达成时,退回 `org-coder`(实现有问题)还是 `org-planner`(规划有问题)?两条都是从同一节点出去的 `fail` 边,`condition` 单靠 `success`/`fail` 二值区分不了。 **定案:归因是一次显式的 agent 调用,由专职节点做,不是失败节点顺手吐一个值。** `org-validator` 自己的 work graph 里,**第一个节点是 `validator-triager`**,它的唯一职责是判断这次失败的根因:是哪个 agent 的哪个 skill 有问题、是哪个 agent 拿到的输入约束不够、还是哪个 agent 的执行出了问题。归因完成才知道该先改 skill、还是直接让某个 agent 带 bug context 重做。 这与 DevLoop 的既有分工同构——Integrator 判定集成失败、BugTriager 单独归因,是**两个不同的 agent**。理由是同一个 agent 既当裁判又当法医,容易把「我判失败」和「我认为该怪谁」混成一次输出。拆开的额外好处:归因有独立的落盘产物、可审计、可在控制台单独下钻。 **归因取值域与 DevLoop 四分类的对照**: | `fail_reason` | DevLoop 对应 | 回退动作 | 烧计数? | |---|---|---|---| | `impl_bug` | `impl_bug` | 该 agent 带 bug context 重做 | 烧单边 + `backward_count` | | `input_underspecified` | `contract_mismatch` / `spec_ambiguous` | 退回上游产出约束的那个节点 | 同上 | | `skill_defect` | **无对应** | 见下方 ⚠️ | 同上 | | `infra_flake` | `infra_flake` | 原地重跑 | **不烧任何计数**(D.2.1) | ⚠️ **`skill_defect` 这一支即 L4-company,而 L4 在 §一是非目标**。**v1 处理方式:保留这个归因取值让 `validator-triager` 照常判定**(数据先攒着,归因体系一次成型,不用等 L4 做完再回来改分类),**但动作降级为「挂起等人工」,不自动触发 L4**。理由见 [27-E2E](./27-e2e-task-execution-flow.md) §八——L4 是三级恢复里最贵、爆炸半径最大的一级,§十 建议最后做。 **引擎侧的选边规则**(引擎本身不理解上述语义,只做匹配): - 边上有可空的 `fail_reason` 列;节点失败时把 `fail_reason` 写进 `graph_run_nodes`(子图节点的这个值由 §四.D.0 从子 run 透传上来); - **先找 `fail_reason` 精确匹配的边 → 无匹配则退到 `fail_reason IS NULL` 的兜底边 → 再无则视为死路**; - 取值域**不在引擎里硬编码**,由图的规划者(`orggraph-planning` / `workgraph-planning`)与对应 validator 约定。上表是推荐取值,不是引擎约束。 **因此写入前校验要加第五项**:任何有 `fail` 出边的节点,**必须至少留一条 `fail_reason IS NULL` 的兜底边**。否则归因产出一个图上没有对应边的取值时会直接落到死路,表现为「莫名其妙 blocked」。这一项要加进 §三.7 复用的那套校验里。 这对 P2 已落地的 `org_graph_edges` 是**加一列**(T-P3),不动现有唯一索引——两条 fail 边指向不同 `to_node_id`,本来就不冲突。 #### D.3 两级验收的引擎语义(R-15.3) | | work 层 `{orgNode}-validator` | org 层 `org-validator` | |---|---|---| | 在图上是 | work graph 里一个普通 `agent_call` 节点 | org graph 的末节点(`subgraph` 节点,它自己也有 work graph) | | 验什么 | 这个 org node 自己的产出合格吗 | 整个任务达成了吗 | | 引用本体 | `planner-validator` / `coder-validator` | `org-validator` | | fail 触发 | 本张 work graph 内的 backward 边 | **org graph 的** backward 边 → **跨层寻址**到「某个 org node + 它 work graph 里的某个 node」→ 复用该 org node 原来那张子 run,只重置被点名的 node(`attempt+1`),下游按 `contract_hash` 四分类决定级不级联 | > ✅ **R-17.1 已落地(第六轮)**:上表「fail 触发」这一格已按 work graph node 粒度改写。 > 地址由 `validator-triager` 在 `attempt-{n}.json` 里给出(`target_node` + 可选 `target_org_node`), > 经三值透传的**第四值** `fail_node_ref` 上到 org 层,由 `resolveCrossLayerTarget` 解析、`applyCrossLayerReset` 执行。 > 存储形态与论证见 §四.A.2,幂等判据的重新设计、五道降级关、以及与第二轮那个死锁的交互见 §四.D.2.2。 > > **R-17.1 明确接受的代价**(不是遗漏,是定案时就认下的):**层级封装被破坏**——org 层的边现在能点名子图内部的节点, > 两层不再互不透明。直接后果是控制台「暂停 / 单步」作用于哪一层的语义变含糊, > 因此**必须在 UI 上显式表达**,落点在 §四.B.3 的「子图」tab(层级语义说明块 + 回退粒度标注 + 子 run 复用徽标)。 `org-validator` 内部调 `validator-triager` / `benchmark-validator` / `testdata-validator` 等 sub agent **不需要新机制**——它是一个 org node,按定义有自己的 work graph,这些 sub agent 就是那张图的节点: ``` org graph: org-planner → org-coder → org-validator ↓(它自己的 work graph) validator-triager → benchmark-validator → testdata-validator → … ↑ 排第一位:先归因,再决定后面怎么验 / 怎么退 ``` `validator-triager` 的产出(`fail_reason` + `remediation_note`)通过 §四.D.0 的三值透传回到 org 层,成为 §四.D.2.2 选边的依据。 > ✅ **R6 补齐:这张图上的六个本体从"设计里有、仓库里没有"变成真的存在。** > 在此之前 `system/work-graph-templates/validator.json` 引用的 `benchmark-validator` / `testdata-validator` > 与 `planner.json` 引用的 `brd-planning` / `prd-planning` / `sdd-planning` / `goal-benchmark` > **六个全是空引用**:`system/templates/` 下没有目录,`_registry.json` 里没有条目。 > 后果不是"少一个模板"而是**生产必炸**——`work_graph_nodes.system_agent_id` 有外键指向 `system_agents(id)`, > 落库时抛 `FOREIGN KEY constraint failed` → `createWorkGraphSafely` 翻成 `rule=reference` 打回 agent > → 连打三轮后抛错 → 节点按无 `fail_reason` 的 fail 走兜底边。 > 而 `org-validator` 的模板写死了「必须用 `validator.json` 这张骨架」,所以这条路径 100% 会踩; > 之前的端到端验收没暴露它,只是因为 mock 返回的图用的是真实存在的本体 id。 > > 六个本体已按 `validator-triager/` 的形态建齐(`config.json` + `agent.md` + `soul.md` + `tools.md`),并登记进 `_registry.json`。 > 其中两个验收节点的分工按「**LLM 只给分/只记实际输出,算术与阈值交给不起 LLM 的执行器**」切开: > > | 节点 | 它做的 | `goal-contract.js` 做的 | > |---|---|---| > | `benchmark-validator` | 逐维度找证据给分 → 写 `attempt-{n}.scores.json` | 加权求和 + 阈值比较 + 判决落盘 | > | `testdata-validator` | 逐 case 真的喂一遍 → 如实记实际输出到 `attempt-{n}.results.json` | `deep-equal` / `subset` 逐条比对 + 判决落盘 | > > 两个文件名后缀是刻意选的:引擎认判词只认严格的 `attempt-{n}.json`(`ATTEMPT_FILE_RE`), > 不会误认这两份;而 `goal-contract.js` 的 `deriveTargetIds` 又能从同样的文件名推出 > `nodeRunId` 与 `attempt`,判决四元组不缺环。这两个节点因此成为 `goal-contract.js` 的**第一批生产消费方**。 > > ⚠️ **两处已知边界,不是漏画**: > ① 三件套里只建了 `goal-benchmark` 一个产出本体;`goal-test-data` / `goal-test-script` 段 > 由图上任何带 `writesArtifacts: true` 的节点用 `goal-contract.js put --section …` 写,**不另设专用本体**。 > 契约里没有 `test_data` 段时 `testdata-validator` 走 `verdict: "skipped"` 放行,这是常态不是失职。 > ② 两个验收节点都**只认自己那一格**(`sections.benchmark` / `sections.test_data`), > 不看 `run` 返回的顶层 `ok`——顶层是三段的合取,拿它当自己的结论会让同一个失败被两个节点各算一遍, > 回退次数翻倍、熔断提前撞顶。 > > 🛡️ **防线(本轮新增)**:`server/tests/unit/template-refs.test.js` —— `system/work-graph-templates/*.json` > 里每个非占位符 `systemAgentId` 都必须①在 `system/templates/` 下有目录(决定外键成不成立的那一环, > 因为 `seedTemplates()` 的数据源头是目录扫描、`_registry.json` 只补 `benchmarkScore`)、 > ②在 `_registry.json` 里有条目;外加一条**真的建库并把无占位符骨架落一次库**的用例。 > 这类悬空引用从此在 CI 阶段就炸,不再等生产跑到那一步。 > (不覆盖 `system/org-graph-templates/`:那边的节点是 org node 角色名, > 由 `seedCompanyAgents()` 以 `source_kind='company_hired'` 落库,套这套判据会把对的判成错的。) 两级验收的失败都从**同一个 root run 预算池**扣(§四.A.3),这正是 [27-E2E](./27-e2e-task-execution-flow.md) §7.1「三级恢复叠加会让熔断失效」的解法。 **谁验收「验收标准」本身**:按 R-9.3「planner 生成、validator 执行」,产出者和被验收者天然分离,不存在「自己给自己出考题」。但「标准写错了」这一层仍未解决,两条缓解都要:① `planner-validator` 明确承担一项职责——审「这套验收标准是否可度量、是否覆盖原始需求、是否存在平凡通过的漏洞」,不只审 PRD/SDD 本身;② 保留人工 sign-off 作为最终关卡,v1 阶段不做全自动闭环。 --- ## 五、任务划分 ### 五个阶段(每个可独立验收、独立上线) 切法与 [27-P2](../../done/system-design/27-p2-org-graph.md) §五「三个可独立验收的阶段」一致:**不起 LLM 的先做完、能单独验收的先上线**。 | 阶段 | 含 | 交付什么 | 怎么验收 | 起 LLM | |---|---|---|---|---| | **0 · 验收契约** | T0 | 三种验收产物的统一契约格式 + 执行器 | 手写一份契约 + 一个被测产物 → 执行器跑出通过/不通过,判决可持久化 | ❌ | | **A · 引擎地基** | T1→T2→T5、T6、T9、T10 | 五张表、`advanceOne` 幂等原语、两种 node executor、SSE 三者原子化、五个控制端点 | 手写图 JSON → 手动模式逐节点推进到 `completed`;切自动模式一键跑完;暂停后能手动单步、能恢复自动 | ❌ mock | | **B · 回退熔断** | T7、T8 | backward 边计数 + 硬顶、`fail_reason` 路由、任务级预算、三层级联 | 总是失败的节点第 4 次 `force_terminated` 收敛到 `blocked`;预算扣完整棵 run 树挂起;暂停 root 后子 run 也停 | ❌ | | **C · 控制台** | T3、T4 | 扩展 `UnitsDAG.vue`、四 tab 详情、`parent_run_id` 面包屑下钻、预算条 | 回退边虚线 + `N/3` 徽标随流程递增;运行日志 live↔history 切换不丢内容;子图 tab 能逐层进出 | ❌ | | **D · 规划闭环** | T11、T12 | `workgraph-planning` agent + 用户→公司任务入口 | 一句自然语言任务 → org graph 自动逐节点起 work graph → 两层跑完 | ✅ | | **P · P2 连带** | T-P1、T-P2、T-P3 | R-15.3 的改名 + `fail_reason` 加列 | 三个模板末节点为 `org-validator`;NAMING 补 validator 命名轴;`org_graph_edges` 多一列且旧数据不受影响 | ❌ | **阶段间可以停**:只做完 0+A,系统就有了一个「能手工建图、能校验、能按五个操作跑」的通用图引擎;B 让它在失败时不失控;C 让人看得见;D 才让它自己规划。**最快看到端到端效果是 0→A→B→D,C 可后延**。 ### 任务表 > **状态列说明**(**2026-08-16 第六轮终验复核**,逐个文件读 + `npx vitest run` 全量实跑 = **66 个测试文件 / 719 用例 / 718 passed / 1 failed**): > **✅ 已实现** = 代码在、能 require、要求的能力都在;**⚠️ 部分** = 主体在但有明确未完成的子项,缺口写在括号里;**❌ 未做** = 没有对应代码。 > ⚠️ **唯一的红**:`server/tests/unit/template-refs.test.js` 的「每个 `systemAgentId` 在 `_registry.json` 里都有条目」不过(6 条),见 T11。**修好它之前本表任何 ✅ 都不代表可以合并。** > 状态列描述的是**落地进度**,不是设计内容——设计以本文档正文为准,状态过期时以代码为准重新核。 > > ⚠️ **一条贯穿全表的前提(第五轮起口径变了)**:`graph-engine.test.js` 的用例**仍然一律走 `setAgentExecutor` / `setPlanWorkGraph` 注入的假实现**,所以「单测通过」证明的依旧只是**引擎的编排逻辑**正确。**但「生产执行路径从未被跑过」这句已作废**——第五轮终验另跑了一条不注入任何假实现的端到端(真 server + HTTP + 真 `AgentManager` → `SessionPool` → `ClaudeCliProtocol`,只把最底层 CLI 子进程换成仓库自带的 mock 协议来控成本),`defaultAgentExecutor` 与 `defaultPlanWorkGraph` 两条真实路径都跑通了(§六 验收 17)。**它是人工脚本、没进 CI**,所以下表和 §六 仍区分「🧪 仅夹具下过」与「有真实路径背书」两档。 | # | 任务 | 涉及文件 | 依赖 | 阶段 | 状态(2026-08-16 第五/六轮复核) | |---|---|---|---|---|---| | **T0** | 验收契约格式(`goal-benchmark` / `goal-test-data` / `goal-test-script` 三种产物的统一 schema)+ 执行器 + 判决持久化 | `server/services/goal-contract.js`(新增)、契约 schema 文档 | 无 | 0 | **✅ 已实现** — `goal-contract.js`三段 schema 校验 + `executeContract`(`spawn` 真实起 `node` 子进程,`RUNNERS` 白名单)+ `persistVerdict`/`listVerdicts`/`readVerdict`;schema 文档 `27-goal-contract-format.md` 已在。**第五轮补上了缺的两样**(原文「无单测 + 零调用方 = 一个还没有人按过的按钮」已作废):① `server/tests/unit/goal-contract.test.js` **20 条单测**(含直接起子进程跑 CLI 的用例,保证模板里印的那几行命令是真能跑的);② **生产调用方接上了,形态是 agent 工具层 CLI**(不是引擎钩子——契约的产出方和消费方都是 agent,理由见 `27-goal-contract-format.md` §二bis):`put` / `validate` / `run` / `list` / `show` 五个子命令,消费方是 `org-validator` 与 `coder-validator` 的 `tools.md`(两者都印了 `goal-contract.js run --target ...`),`planner-validator` 只 `validate` 不 `run`(它审的是「标准本身写得对不对」,不跑产物)。**第六轮修**:CLI 人读输出原先只打各 section 的 `score`,而短路分支(`target_not_json` / `script_missing` / `script_escapes_artifacts`)返回的对象**根本没有 `score` 键、也不进 `failures`**,于是 agent 只看到「benchmark=FAIL / score=undefined / failures 空」——现在 `reason` + `detail` 一律打出来且短路分支也进 `failures`(§十 表第 12 行) | | **T1** | schema:五张表 + 状态转换表实现(照抄 `unit-registry.js` 模式)+ work graph 写入校验(复用 `org-graph-service.js` 四项) | `server/services/work-graph-db.js`(新增) | 无 | A | **✅ 已实现** — `work-graph-db.js`五张表 + 两个唯一索引齐全;`canTransition` + `InvalidTransitionError` 按 `unit-registry.js` 模式;`validateWorkGraph` 真的 `require` 了 `org-graph-service.validateGraph` 做名字映射后复用,没另写一套 | | **T2** | `graph-engine.js`:`advanceOne(runId)` 核心原语、两种 node executor、幂等条件更新、run 树级联工具函数 | `server/services/graph-engine.js`(新增) | T1 | A | **✅ 已实现** — `graph-engine.js``advanceOne` / `settleNode` / `settleRun` / `executeSubgraph` / `defaultAgentExecutor` / `descendantRuns` 全在;幂等靠 `UPDATE ... WHERE status=?` 条件更新。生产侧 `defaultAgentExecutor` 已补齐产物契约进提示词(`describeArtifactContract`)、判词节点缺产出按 inconclusive 结算等三轮修复 | | **T3** | 画布组件:**扩展 `UnitsDAG.vue`**,加 `subgraph` 角标、backward 虚线曲线路由、循环徽标;org/work 两层共用同一实例 | `web/src/components/WorkGraphCanvas.vue`(从 `UnitsDAG.vue` 抽取)、`web/src/views/GraphConsole.vue`(新增) | T6 | C | **✅ 已实现** — `WorkGraphCanvas.vue`三块新增工作量都在:`⊞` 角标、backward 虚线曲线 + 分道避让、`N/3` 徽标(取同 `edge_id` 最新一条 `loop_count`);异常流程降权样式也在。路由已注册 | | **T4** | 节点详情四 tab + 面包屑下钻 + 预算条;运行日志复用 `AgentChat.vue` 双轨模式 | 同上 + 会话记录查询接口确认 | T2, T6 | C | **⚠️ 部分** — 四 tab(概览/输入输出/运行日志/子图)、面包屑逐层下钻、页头预算条、空态都已实现;`NodeRunLog.vue` 的双轨(live 卡片 + 历史卡片)按 attempt 分组,T5 接线后**已经有事件流进来了,不再恒为空**。**缺**:流进来的是**引擎事件**(`node.status` / `agent.call` / `node.output` 的 summary),而 §四.B.3 第 3 条要的是「**这次 agent 调用内部**的对话/工具调用记录」——`defaultAgentExecutor` 不把 agent 的 stdout / 工具调用往 SSE 上转发,这一半没落地 | | **T5** | SSE:按 `run_id` 复用 `sse-broker.js` 模式;**状态转换 + timeline 写入 + SSE 广播三者原子化**(不重复 DevLoop 的两轨缺口) | `server/services/graph-run-sse.js`(新增,参照 `devloop/sse-broker.js`) | T2 | A | **✅ 已实现**(第二/三轮补上了「缺的那一半」) — `graph-run-sse.js` 的 `subscribe`/`emit`/`replayTo`/心跳 + 原子包装 `transitionNode`/`transitionRun`;`GET /:id/stream` 已挂。**引擎侧现已接线**:`graph-engine.js` 顶部已 `require('./graph-run-sse')`,**全部 12 处状态转换一律走 `sse.transitionNode` / `sse.transitionRun`**(**截至 commit `c3abf4a`** 分布在 `advanceOne` / `settleNode` / `handleBackwardEdge` / `markReady` / `settleRun` / `startRunSync` / `pauseRun` / `stopRun` / `retryNode` 这几个函数里;行号已漂移,按函数名 grep),不再有裸 `updateNodeStatus`/`updateRunStatus` 的旁路——「状态转换 + timeline + SSE 三者原子」这条要求成立 | | **T6** | REST:`POST /api/graph-runs/:id/{start,next,pause,stop}`、`POST /api/graph-runs/:id/nodes/:nodeId/retry`、`GET /api/graph-runs/:id`(含 nodes/edges 全量状态)、`GET /api/graph-runs/:id/stream`、`GET /api/company/:cid/graph-runs` | `server/routes/graph-run.js`(新增) | T2 | A | **✅ 已实现** — 七个端点全在(`start`/`next`/`pause`/`stop`/`nodes/:nodeId/retry`/`GET :id`/`GET :id/stream`)+ `companyRouter`;`index.js` 与 `agent-server.js` 两个入口都已挂载 | | **T7** | 回退熔断:D.2.1 三步 + **双层硬顶**(单边 `loop_count` + run 级 `backward_count`)+ `graph_run_loop_records` 读写;`infra_flake` 不烧计数;D.2.2 的 `fail_reason` 选边 + 子 run 三值透传 | `graph-engine.js` 扩展 | T2, T-P3 | B | **✅ 已实现** — `handleBackwardEdge` 备案→双层硬顶→回退三步齐;`LOOP_HARD_CAP_EDGE=3` / `LOOP_HARD_CAP_RUN=5` 是纯整数比较;`INFRA_FLAKE_REASON` 走原地重跑不烧计数;`selectFailEdge` 精确匹配→兜底边;`deriveChildOutcome` 从 `ATTRIBUTION_NODE_NAME='validator-triager'` 取三值透传 | | **T8** | 任务级总预算(root run 扣减 + 熔断)+ 三层暂停/停止级联 + 「重试」的 `contract_hash` unchanged 判据 | `graph-engine.js` 扩展 | T2 | B | **⚠️ 部分** — 主体在:`checkBudget`/`spendBudget`/`suspendRunTree` 按 `root_run_id` 扣同一池;`descendantRuns` 靠 `root_run_id` 一次查询做级联,`pauseRun`/`stopRun` 都向下级联;`diffContracts`/`applyRetryCascade` 直接 `require` 了 `devloop/doc-store` 的 `hashContract`,没平行发明。**缺口三处,第五/六轮各关掉一处**:~~① 预算三维里 `total_tokens` 是死维度~~ → **已修(接线成立),但「实测能凭这一维触发 `exhausted`」这半句第六轮终验查证为不实、已删**:第四轮在 `defaultAgentExecutor` 上挂了 `onAcpEvent` 累加 `session/result.usage`,三条 return 路径都带 `tokens`,`spendBudget` 真的在扣——终验直接对 `AgentManager.callAgent` 挂 `onAcpEvent` 复核,`session/result` 事件确实到达、`usage` 字段确实被读。**但 mock 协议把 `usage` 硬编码成 `{input:0,output:0}`**(`ClaudeCliProtocol._sendMockPrompt`),所以在「底层换 mock 控成本」这套终验手法下 `spent.tokens` **恒为 0**,前几轮写的「实测递增 / 能单独触发 `exhausted`」用这套方法根本产不出来。准确表述是:**产生端接线已验证、数值大小未验证**;实测能单独触发 `exhausted` 的只有 `total_agent_calls`(终验把预算压到 14 次调用,`advanceOne` 如期返回 `budget-exhausted` 并 `suspendRunTree`)。`wallclock_ms` 仍无任何覆盖(§四.A.3);~~② `contract_hash` unchanged 判据在生产跑不起来、`snapshotContract` 永远拿不到快照~~ → **第五轮已修,这个说法作废**:`snapshotContract` 现在**无论有没有 `contract.json` 都落盘一份 `contract.prev.json`**(信封形状 `{schema:'contract-snapshot/v1', present, hash, contract, capturedAt}`,`hash` 走 `doc-store.hashContract` 的 sha256 前 16 位),并显式记 `present` 位——于是「有→无」「无→有」都能被 `diffContracts` 算成 removed / added 而级联,**落到「不级联」的只剩「重试前后都没有契约」这一种**,那正是「这个节点不产契约」的准确刻画。同时 `describeArtifactContract` 已把 `contract.json` 的路径、形状(`{"exports":{...}}`)和「每轮完整重写」写进提示词。**残留的不是死路径,是「有规约、未验证」**:产出方**仅有提示词那一句**,没有任何代码或模板强制 agent 写 `contract.json`,也没有一条用例证明真实 agent 会照写——四环里「产生」这一环仍属未验证状态(§八.12);~~③ 暂停向下级联、恢复不向下级联~~ → **第六轮已修并经终验实测,作废**:`startRunSync → resumeDescendants` 沿 `descendantRuns` 清全树 `pause_requested`/`stop_requested`,并把**被级联暂停的** `paused` 后代拉回 `running`(`blocked` 后代刻意不碰——解除 blocked 的正当入口是对具体节点点「重试」);REST 侧同轮放开了「`running` 且 `pause_requested=1`」这个 org 层父 run 的常驻形态。**终验实测**:两层 run 树暂停后 `POST /:root/start {mode:auto}` 回 200、`resumed` 精确等于 `[子 run id]`,整棵树自己跑到 `completed`,全树 `pause_requested` 归零(§六 验收 4、§八.13)。**T8 至此只剩 `contract.json` 的产出方与 `wallclock_ms` 两处未验证** | | **T9** | §3.6 取消能力的**实现前核实** + 落地:确认 `RunRegistry`/`ProcessGuard` 调用链,「停止」接入硬取消;或确认做不到、落地降级并回改本文档 §四.C | `server/src/engine/`(只读研究为主,必要时补一个查询接口) | 无,可最早独立开工 | A | **✅ 已实现**(本任务的交付物是「核实 + 二选一落地 + 回改文档」,三件都做了) — 核实结论是**硬取消做不到**:`new ProcessGuard` 全仓库只出现在 `server/src/tests/unit/process-guard.test.js`;`RunRegistry.add()` 只出现在 `server/tests/unit/run-registry.test.js`(注册表在生产恒为空);`AgentManager.killCurrentProcess()` → `SessionPool.killActive()` 这条链**没有任何生产调用方**,且粒度是会话不是 runId。据此落地降级路径(`stopRun` 软停止),并已回改 §四.C(含完整 file:line 证据)+ 关闭 §八.6。**残留**:`defaultAgentExecutor` 里那句「runId 由 T9 核实/补齐」的注释措辞过期(结论是「不补」),属文字瑕疵不属功能缺口 | | **T10** | 测试:状态机非法转换、幂等 `advanceOne`、回退硬顶、预算熔断、级联;一条端到端脚本(建两层图 → 启动 → 若干次 advance → 触发回退 → 硬顶终止 → 查最终状态) | `server/tests/unit/graph-engine.test.js`(新增) | T2, T7, T8 | A/B | **⚠️ 部分** — `graph-engine.test.js` **第六轮 R-17.1 返工后 19 个用例、实跑 19 passed / 0 failed**(返工改了一条用例的期望——「回退后 subgraph 起新子 run」按新语义重写成「复用同一张子 run、只重置被点名的 node」——并新增两条:契约变了要级联、跨层地址解析不了要退化)。任务描述点名的五项(非法转换 / 幂等 / 回退硬顶 / 预算熔断 / 级联)全有对应用例。**缺**:① 没有任何测试覆盖「停止」(验收 5)与「重试」(验收 12)这两个控制操作;② 「一条端到端脚本」被拆成了若干个各测一段的用例,没有一条从建两层图跑到硬顶终止的连贯脚本;③ ~~`goal-contract.js` 无独立测试~~ → **第五轮已补 20 条**(`server/tests/unit/goal-contract.test.js`),**第六轮又加了 4 条骨架引用防线**(`server/tests/unit/template-refs.test.js`,其中一条真建库落 `validator.json`)——**但这个文件现在有一条是红的**,见 T11;`work-graph-db.js` / `routes/graph-run.js` 仍无;④ **单测里**仍全部走注入的假 executor / 假 planner,但「生产执行路径零覆盖」这句**第五轮起不成立**——终验跑了一条**不注入任何假实现**的端到端(真 server + HTTP + 真 `AgentManager`,只把最底层 CLI 子进程换成仓库自带的 mock 协议控成本),结论见 §六 验收 17。**它是一次性的人工验证脚本,没有进 CI**——这仍是 §十 硬规矩第 2 条要求的那条「真实路径 smoke」的欠账,补进 `server/tests/integration/` 才算还清 | | **T11** | `workgraph-planning` agent 本体(全局通用,R-15.2):读 org node 角色定义 + 任务 + `artifacts/` + 上一轮 `remediation_note` → 产出图 → 过校验 → 落库。生成的图**不得在节点内部再套四段式流转**(§九bis) | `system/templates/workgraph-planning/`(新增) | T1, T6 | D | **⚠️ 部分**(第五轮把引擎侧那根线接上了;第六轮补骨架时留了半截,见末段 —— 这是全表唯一一处让 CI 变红的缺口) — **本体在**:`system/templates/workgraph-planning/`(agent.md / config.json / identity.md / soul.md / tools.md)已建并注册进 `_registry.json`,配套 `system/work-graph-templates/`(planner / executor / executor-fanout / validator 四个骨架 + README)也在。**引擎侧已接线**:`defaultPlanWorkGraph` 真调 `AgentManager.callAgent(workgraph-planning, ...)`,与 `defaultAgentExecutor` 共用同一套调用方式(含 `onAcpEvent` 累加 usage,**规划调用同样计预算**);产出既认「agent 自己 POST 落库后回一个 `wg_...` id」也认「把图 JSON 交回引擎落库」,落库前必过 `work-graph-db` 的五项校验,**不过就把 `errors` 原样贴回去让它自己修**,最多 `PLAN_MAX_ROUNDS = 3` 轮(模板给 agent 的自查上限是 5,引擎收紧到 3——每轮都是一次真花钱的 LLM 调用,且外层还有回退/熔断/预算三道兜底);耗尽轮次抛错并把已烧的 `tokens` 挂在 error 上,由 `advanceOne` 的 catch 按 fail 结算。规划本体住在 `system/templates/` 下、裸名寻址不到,`resolvePlanningAgentName` 沿用 `company.js` 对 `orggraph-planning` 的做法(先 hire 进公司再按 `{cid}:{agent}` 寻址)。`setPlanWorkGraph(fn)` **保留为测试注入缝**(不再是「生产零调用方的死接口」)——生产走默认实现。**第六轮补骨架侧的最后一个洞(R6-F1),但只补了一半 —— 本表唯一的 ❌,也是全套测试唯一的红**:四个骨架里被引用的 `systemAgentId` 有 **6 个在仓库里根本不存在**(`benchmark-validator` / `testdata-validator` / `brd-planning` / `prd-planning` / `sdd-planning` / `goal-benchmark`),落库时撞 `work_graph_nodes.system_agent_id` 的外键必炸,而 `org-validator` 又被写死「必须用 `validator.json`」——这条路径 100% 会踩,前五轮端到端能过只是因为 mock 返回的图用的是真实存在的本体 id。 **第六轮终验实测**:① **目录这一半做了**——六个 `system/templates/{benchmark-validator,testdata-validator,brd-planning,prd-planning,sdd-planning,goal-benchmark}/` 已建齐(各含 `agent.md`/`config.json`/`soul.md`/`tools.md`),`seedTemplates()` 扫得到,外键那一端成立;终验用真 server 跑 `org-validator`,`validator.json` 三个节点**原样落库、不再 400**。② **登记这一半没做**——`_registry.json` 里**六条一条都没有**(现有 14 条:`ceo`/`qa-judge`/`base`/`devloop-*`/`workgraph-planning`/`validator-triager`/`org-validator`/`planner-validator`/`coder-validator`)。③ **防线建了,而且它现在是红的**:`server/tests/unit/template-refs.test.js` 的第三条用例「每个 `systemAgentId` 在 `_registry.json` 里都有条目」实跑 **failed**,逐条报出这 6 个 id。**这是一个 blocker**:R6-F1 自己写的 CI 防线抓到了 R6-F1 自己没做完的那一半,`npm run test:unit` 现在必红。修法是往 `_registry.json` 追加 6 条(`benchmarkScore: null` / `trainedAt: null` 即可),改完重跑 `npx vitest run server/tests/unit/template-refs.test.js`(详见 §四.D.3 的补齐框、§八.21) | | **T12** | 用户→公司的任务入口:`POST /api/company/:cid/tasks`,建 root run 并起 org graph 执行;复用既有 `task/dispatch` + `task/report` 回调链路 | `server/routes/company.js` 或新 `routes/task-entry.js` | T2, T11 | D | **✅ 已实现** — `routes/company.js` 的 `POST /:companyId/tasks`:建 `graph_kind='org'` 的 root run → 写 task-goal artifact → `markRunReady` + `startRun` → **同步返回不等图跑完**;v1 单 active root run 约束做了预检查 + 唯一索引兜底两道;auto 模式挂 driver 并在收敛后 `reportRunOutcome`;默认预算 `DEFAULT_TASK_BUDGET` 也在这里给。**第五轮起它启动的 run 真的跑得动了**(原文「跑不动,第一个 org 节点就撞上 T11 那根没接的线」作废):T11 接线后,终验从这个端点下一句自然语言任务,root run 与两张子图全部跑到 `completed`,见 §六 验收 17 | | **T-P1** | 三个 org graph 模板末节点改 `org-validator`;`dev.json` 的回退边目标按 D.2.2 重画(`fail_reason` 区分退 coder 还是退 planner) | `system/org-graph-templates/{dev,content,ops}.json` | T-P3 | P | **✅ 已实现**(模板侧) — `dev`/`content`/`ops` 三张图节点均为 `org-planner` + 中段职能 + `org-validator`;`dev.json` 两条 fail 回退边已按 D.2.2 重画(`failReason: "input_underspecified"` → `org-planner`,兜底边 → `org-coder`)。**上一轮说的「写不进库」已修**:`normalizeEdges` 读它、`insertEdge` 写它、`readDefGraph` 读回,四环打通。**新发现的一处漏**:`system/templates/orggraph-planning/tools.md` 的字段表(`edges[].from/to/condition/edgeKind`)与示例 payload **都没提 `failReason`**——生产里真正建 org graph 的是这个 agent,它不知道有这个字段,除非它逐字照抄 `dev.json` | | **T-P2** | NAMING §三 删掉 `org-validator` 行;**新增 validator 命名轴一节**(节点名带领域前缀、本体名不带,R-15.3 的表) | `docs/todo/system-design/27-naming-conventions.md` | 无 | P | **✅ 已实现** — §三 已按「所在图的层级」重写:§3.1 两层节点名规则、§3.2「节点名 ≠ 本体名」新命名轴表、§3.3「三个 validator 是三个独立本体」、§3.4 存量归位映射表(`code-*` → `org-*` 作为作废记录保留) | | **T-P3** | `org_graph_edges` 加可空 `fail_reason` 列(D.2.2);`graph_runs` 加 `backward_count`;新建三个 validator 本体的 agent 模板 | `server/services/graph-db.js`、`system/templates/{planner-validator,coder-validator,org-validator}/` | 无 | P | **✅ 已实现** — 列加了(`graph-db.js` 里那句 `ALTER TABLE org_graph_edges ADD COLUMN fail_reason`)、`graph_runs.backward_count` 在(`work-graph-db.js` 建表段)、三个 validator 模板已建并注册。上一轮点名的「列建了但永远是 NULL」已修:`normalizeEdges` 读、`insertEdge` 写(唯一索引也改成含 `COALESCE(fail_reason,'')`,让「精确 fail 边 + 兜底边」能并存)、`readDefGraph` 读回。**残留的是提示词侧**,见 T-P1 | | **T13** | `validator-triager` agent 本体(R-16):归因产出 `fail_reason` + `remediation_note`;`org-validator` 的 work graph 骨架把它排在第一位 | `system/templates/validator-triager/`(新增) | T-P3 | B | **✅ 已实现** — `system/templates/validator-triager/` 已建并注册(`_registry.json` 的 description 写明四类归因取值 + `skill_defect` 挂起等人工不触发 L4);引擎侧 `ATTRIBUTION_NODE_NAME` 常量已用于三值透传。**第五轮按 R-17.2 改名 `validator-reflection` → `validator-triager`**:目录、`_registry.json` 条目、`org-validator` 模板里的节点名引用、`work-graph-templates/validator.json` 的 `nodeName`/`systemAgentId`/边端点、27-NAMING §3.5、本文档全文都已改;引擎侧的 `ATTRIBUTION_NODE_NAME`(并保留 `ATTRIBUTION_NODE_ALIASES` 认旧名)与 `node.triage` timeline 事件名也已改完。**第六轮补完最后一批**:`workgraph-planning` 四份提示词、`org-graph-templates/README.md`、三处过期假设注释、两处裸 `reflection` 措辞(见 §八.19)。**这几处必须同步**——节点名、`systemAgentId`、引擎常量三者只要有一处留在旧名,`deriveChildOutcome` 就匹配不上归因节点,三值透传会静默降级为「没有 `fail_reason`」,正是 §十 那个四环缺陷模式。**改名收尾(第六轮终验实测:生产侧已全部归位)**:`grep -rn validator-reflection` 全仓库剩 6 类命中,逐条核对**全在 §八.19 的白名单内**(27-REQ R-16 原话引用与 R-17.2 改名记录、27-NAMING §3.5「历史名」、27-GRAPH-AGENT 的一处历史引用、`coder-validator/agent.md` 的「命名已定案」注记、`graph-engine.js` 的 `ATTRIBUTION_NODE_ALIASES` 兼容项及其两处注释、本文档的改名记录);另有一处**白名单外**的:`server/tests/unit/graph-engine.test.js` 顶部注释仍写着「// 假设(R-16待定案): 归因节点名保留 `validator-reflection`,不改 `validator-triager`」,而 R-17.2 早已定案——该文件下方还有一组用例**故意**用旧名当节点名去测 `ATTRIBUTION_NODE_ALIASES` 的兼容分支(那是合理的),坏的只是那句已过期的「待定案」措辞。裸 `reflection` 的独立 grep 剩下的命中全在 `memory-service.js` / `evolution-loop` / DevLoop `retry-with-reflection` 等**无关子系统**,与本轮改名无关。**归位判定:✅ 通过**(残留 1 处,文字瑕疵,在测试注释里) | | **T14** | 写入前校验新增第五项 `fail-fallback-edge`(§三.7):有 `fail` 出边的节点必须留一条 `fail_reason IS NULL` 兜底边;org / work 两层都加 | `server/services/org-graph-service.js`、`work-graph-db.js` | T1 | B | **✅ 已实现(两层都有了)** — work 层 `work-graph-db.validateFailFallback`接在复用的四项之后;**org 层已补上**(`org-graph-service.validateGraph` 末尾,同一条 `rule: 'fail-fallback-edge'` 与同样的报错文案)。对应的两个测试用例(work 层拒绝且一行不落库 / org 层同一条校验也要有)现均通过 | > ⚠️ **T-P1 / T-P2 / T-P3 是 P2 侧文件的连带修改,但归 P3 执行**。P2 正由另一条线推进中,这批改动写在本文档而非直接改 P2 的文档/模板,是为了避免两边同时写同一批文件产生冲突。P2 那条线**不要动**这三项。 **顺序建议**:T0 和 T9 可最早独立开工,互不阻塞。T-P3 要在 T7 之前(`fail_reason` 是 D.2.2 的地基)。T1→T2 是硬串行地基。T5/T6 依赖 T2,可并行。T3 建议先用 T1 的 schema 手写几行数据跑通画布,再接 T6 的真实端点。 --- ## 六、验收标准 > **对账口径(2026-08-16 第五/六轮逐条重核;第四轮的口径与总账句已过期,整段重写)**。四档: > **✅ 过** = 有自动化用例实跑通过,或代码路径直读可确认; > **🧪 仅夹具下过** = 用例通过,但依赖 `setAgentExecutor` / `setPlanWorkGraph` 注入的假实现,未在真实调用链上验证; > **⚠️ 部分 / ❌ 不过** = 缺口写在条目里。(第六轮终验后**不再有「⏳ 待终验回填」这一档**,原先挂着这个标的三条已全部回填。) > > **一句话总账(第六轮终验,17 条逐条重核)**: > **① 生产成立(真实调用链跑过、断言过)9 条** —— 1、2、3、4、6、7、8、15、17; > **② 生产成立但有明确限定 5 条** —— 5(停止的 run 状态半边验了,`pending/ready → skipped` 没单独断言,且**仍无任何自动化用例**)、9(选边与 `fail-fallback-edge` 成立,`返回 400` 那段 HTTP 语义没验)、10(三维里只有 `total_agent_calls` 有真实背书,`total_tokens` 只有接线、`wallclock_ms` 空白)、11(两层验了,孙 run 那层仍只有夹具)、12(重试与「不级联」验了,「契约变了要级联」那半仍未验); > **③ 只有读码确认、既无夹具也无真实路径 2 条** —— 13、16(阶段 C 前端,仓库无前端测试基础设施); > **④ 不成立 1 条** —— 14(「运行日志」流的是引擎事件,不是 agent 调用内部的对话/工具记录,§四.B.3 第 3 条那半没落地)。 > **相对第五轮:8 条升档、0 条退档** —— 2、3、6、8、9c、9d、11 从 🧪 升到生产成立,5 与 12 从「实现在、无用例」升到「主干有生产实测」,4/7/15 在本轮之前刚由并行线升档、终验独立复跑确认。 > **升档不等于没问题**:验收 7 那条链在生产里目前跑不起来(agent 不产 `target_node`,§八.22),验收 10 的 `total_tokens` 是被**下调**过口径的(§四.A.3)。 > 但**全量测试不是绿的**:`npx vitest run` = **718 passed / 1 failed**,红的是 R6 自己新增的 `template-refs.test.js`(`_registry.json` 少 6 条登记,§五 T11)。而且**验收 7/9d 依赖的跨层归因在生产里仍拿不到 `target_node`**——不是引擎的问题,是三份 agent 提示词还写着「不要跨层指」(§八.22)。所以老话仍要留着:**「测试全绿」既不等于「生产能跑」,这一轮它连绿都不是**。`graph-engine.test.js` 的 19 个用例依旧一律注入假 executor / 假 planner,真实路径的背书仍来自**没进 CI 的人工脚本**(第六轮终验的四个脚本同样没进 CI,欠账见 §十 硬规矩第 2 条)。 **阶段 0** 1. ✅ **过**(第四轮的「实现在、从没被跑过」已作废)。手写一份 `goal-benchmark` + `goal-test-data` + `goal-test-script`,执行器能对一个被测产物跑出明确的通过/不通过判决,判决落库可查。 → `server/tests/unit/goal-contract.test.js` **20 条用例实跑通过**,其中一条按 `put ×3 → validate → run` 的顺序**起真实子进程跑 CLI**,与模板 `tools.md` 里印的命令逐字一致;判决落 `artifacts/verdicts/{verdictId}.json` + `index.ndjson`,`listVerdicts` / `readVerdict` 能按 run / node / attempt 读回(含 `attempt=0` 不被 `||` 吞掉这一条专项用例)。**生产调用方也在了**:`org-validator` / `coder-validator` 的 `tools.md` 印 `goal-contract.js run --target ...`,`planner-validator` 的印 `validate`(只审标准本身,不跑产物)。 **第六轮补的一环(读回)**:CLI 的**人读输出**原先只打各 section 的 `score`,而 `runBenchmark` / `runTestData` / `runTestScript` 的短路分支返回的是 `ok:false + reason + detail`、**没有 `score` 键也不进 `failures`**——实测输出是「benchmark=FAIL / score=undefined / failures 空」,可落盘的 verdict JSON 里 `reason` 和 `detail` 一直都在。`org-validator` 的 `tools.md` 退出码表写着「1 = 判决为 fail,照它打印的 `fail_reason` 与 `failures` 走 Step 5」,**照做的 agent 因此拿不到任何可归因信息**。现已修:短路分支的 `reason` + `detail` 一定打出来,且一律进 `verdict.failures`(形状与正常比对的 failure 一致:`case` / `expected` / `got` / `hint`,`hint` 里带 `reason`),人读输出与落盘 JSON 自洽。 **阶段 A** 2. ✅ **生产成立(第六轮终验升档,原 🧪)**。一张只有正向边、3-4 节点的图:手动模式连续点「下一步」逐节点推进到 `completed`,**每次点击只推进一个节点**(不因为下游 ready 了就自动继续)。 → 用例「连续「下一步」逐节点推进到 completed」通过;**终验另在真 server 上连点 17 次 `POST /:id/next`**(`w1→w2→w3` 的三节点正向图 + 两层 run 树),逐条核对每次响应里的 `node` 都只有一个、下游 ready 之后**不会自己往下跑**,直到 run 收敛。 3. ✅ **生产成立(第六轮终验升档,原 🧪)**。同一张图切自动模式点「启动」:不再需要任何人工点击,自动跑到 `completed`。 → 用例「graph_kind=org 的 run 用同一批 markRunReady / startRun / advanceOne 跑到 completed」以 `mode:'auto'` 跑通;work 层多个用例也走 `startRun(..., {mode:'auto'}).driver`。**终验实测**:`POST /api/company/:cid/tasks {mode:'auto'}` 之后零人工干预,root run + 两张 work 子图全部 `completed`(同验收 17)。 4. ✅ **两半都成立(R6 起真 server 实测)**。自动模式运行中点「暂停」:当前 `running` 节点跑完后 `run_status → paused`,不再自动调度;此时点「下一步」能手动单步(🧪 已验证);**点「启动」能恢复自动 → ✅**。这条走了两轮才通,两处缺口各自独立:R5 补上引擎侧的级联恢复(`startRunSync → resumeDescendants` 清全树标志位 + 把级联 `paused` 的后代拉回 `running`),R6 补上 REST/前端那道守卫(旧条件把 `running` 挡在门外,而 org 层父 run 恒为 `running`+`pause_requested=1`,两种模式实测都吃 400、UI 上按钮直接置灰,见 §四.C 启动行)。**实测(第六轮终验独立复跑,不采信并行线的自证)**:真 server(`node server/index.js` + 真 HTTP + 真 `AgentManager` → `SessionPool` → `ClaudeCliProtocol`,底层 `LEMONBOSS_CLAUDE_MODE=mock`;**没有注入任何假 planner / 假 executor**,`workgraph-planning` 是真被调起来的,只是它的 CLI 子进程被 mock 协议按 fixture 回放)跑两层 run 树 —— `POST /:root/pause` 回 `cascaded: [子 run]`,随后 root `running`/`pause_requested=1`、子 run `paused`/`pause_requested=1`;`POST /:root/start {mode:auto}` 回 **200** 且 `resumed` **精确等于** `[子 run id]`;几秒内整棵树(root + 两张 work 子图)自己跑到 `completed`,全树 `pause_requested` 归零。 5. ⚠️ **主干第六轮起有生产实测,但仍无任何自动化用例**(升档:原「实现在,无用例」)。点「停止」:所有 `pending`/`ready` 节点变 `skipped`,`run_status → stopped` —— `stopRun`逐层做了这件事。**终验实测**:对一张**正在跑**的子 run `POST /stop` 回 200 且 `runStatus: 'stopped'`;父节点没有卡在 `running`(`settleRun → propagateToParent` 当场把它结算掉),整棵树随后收敛,**不会因为「停掉一张子 run」而死锁**。另外顺带验到「`stopped` 是绝对终态」:对一张已 `completed` 的 run 点停止是明确的 400(`allowed: [running, paused, blocked]`),不是静默成功。**仍没验的**:`pending`/`ready` → `skipped` 这一步没有单独断言过;**仍没有任何测试覆盖**这条。后半句「若 T9 确认取消可用,正在 running 的节点被中断」**已被 T9 的核实否掉**(硬取消调用链不存在,§四.C)——**这半句作废**,正确的验收是「正在 running 的节点不被打断,其结果照常落库」。 6. ✅ **生产成立(第六轮终验升档,原 🧪)**。一张 org graph(`graph_kind='org'`)用**同一套端点**跑通上述 2-5,代码路径与 work graph 完全一致。 → 两个 org 用例(auto 跑到 completed / 「下一步」语义一致)通过,`readDefGraph` 按 kind 分派后下游代码完全共用。**终验的每一条端到端都是从 org root run 起的**:`start` / `next` / `pause` / `stop` / `retry` 五个端点在 org 层与 work 层各按同一套 URL 调过一遍,验收 4 的暂停+恢复、验收 5 的停止、验收 12 的重试**在 org 层都验了**(原文「2-5 里的 4、5 两条在 org 层同样没验」作废)。 7. ✅ **生产成立(第六轮终验实测,从 🧪 升档)**。一个 `subgraph` 节点执行时正确起子 run、`child_run_id` 回填、子 run 收敛后父节点拿到正确的 success/fail。 → 用例通过;另有一条用例守住回退后的重跑语义 —— **第六轮按 R-17.1 从「要起新子 run」改成「复用同一张子 run、只重置被点名的 node」**(语义变了,是全套里唯一一条改过期望的用例),第一轮那个死锁改由 `child_run_attempt` 代次判据挡住,见 §四.D.2.2 / §八.1。 **终验实测(真 server + HTTP + 真 `AgentManager`,零注入)**:org graph = `org-coder → org-validator`(两条 fail backward 边:`impl_bug` 精确边 + 兜底边);`org-validator` 的子图用 `system/work-graph-templates/validator.json` **原样落库**;归因产出 `target_node=wg-impl` / `target_org_node=org-coder` 后,父层沿 backward 边回退 —— **`child_run_id` 三轮回退全程不变**(复用,不是新建)、`wg-impl.attempt` 0→3、**`wg-test.attempt` 恒为 0 且状态恒 `success`**(`contract_hash` unchanged 的下游一步都没重跑)、账本 `target_node_ref` 逐条记成 `{"orgNode":"org-coder","workNode":"wg-impl","childRunId":…,"nodeRunId":…}`;第 4 次撞单边硬顶 `force_terminated`,整棵树收敛到 `blocked`。 ⚠️ **但这条链在真实生产里目前起不来**:`target_node` 要由 `validator-triager` 写进 `attempt-{n}.json`,而它自己的模板还写着「往里多指一层等于白指」(§八.22)。终验里那份归因文件是**人代 agent 落盘**的——验的是引擎,不是 agent 会不会照写。 **阶段 B** 8. ✅ **生产成立(第六轮终验升档,原 🧪)**。构造一个总是失败的节点,其 `fail` 边是指回上游的 `backward` 边:第 1-3 次回退正常触发(`graph_run_loop_records` 各一行),**第 4 次不再调用任何 agent、直接 `force_terminated`**,图收敛到 `blocked`。 → 用例通过,且断言了「熔断之后 agent 调用次数不再增加」。**终验实测**:真 server 上 `org-validator → org-coder` 这条 backward 边连打四轮,账本恰好四行(`loop_count` 1/2/3 = `registered`,第 4 行 = `force_terminated`),root run 收敛到 `blocked`,`backward_count = 4`。 9. ✅ 一个节点有两条 `fail` 边(`fail_reason` 分别为 A / 空):节点带 `fail_reason=A` 失败时走第一条,带其他值失败时走兜底边。写入一张有 `fail` 出边却无兜底边的图 → 被 `fail-fallback-edge` 校验拒绝。 → 两个用例(work 层拒绝且一行不落库 / org 层同一条校验也要有)通过;校验是纯数据逻辑,不依赖夹具。**唯一没验的是「返回 400」这一段 HTTP 语义**(路由层无测试)。 9b. 🧪 **多边不架空熔断**:3 条回退边各自都没到 3,但 run 级 `backward_count` 到 5 时仍 `force_terminated`。 → 用例通过。 9c. ✅ **生产成立(第六轮终验升档,原 🧪)**。归因为 `infra_flake` 时原地重跑,`graph_run_loop_records` 不增行、`backward_count` 不递增。 → 用例通过。**终验实测**:让归因恒返回 `infra_flake`,真 server 上一路跑到预算耗尽,全程 `backward_count = 0`、账本零行 —— **这也顺带证明了这条路径上熔断完全不参与,终止性 100% 押在预算上**(活锁分析见 §八.1 末段的复核表)。 9d. ✅ **生产成立(第六轮终验升档,原 🧪)**。一个 `subgraph` 节点的子 run 因内部归因失败而收敛:父节点同时拿到 `fail`、`fail_reason`、`remediation_note` 三个值,并据此在父层正确选边。 → 用例通过(`deriveChildOutcome` 从 `validator-triager` 节点取三值)。**终验实测**:子 run 里 `validator-triager` 判 `attributed / impl_bug`,父层 `org-validator` 那一行在 `GET /api/graph-runs/:id` 上读到 `failReason=impl_bug` + `remediationNote` + `failNodeRef`,并据此选中 backward 边。**R-17.1 把三值扩成了四值**(多一个 `fail_node_ref`),四个值同源同轨、同一次条件更新落库。 10. ✅ **`total_agent_calls` 这一维第六轮起生产成立**(其余两维仍是 🧪 / 未验证)。root run 预算扣完:整棵 run 树挂起,不是某一层单独失败。 → 用例通过;**终验另跑了一条真 server 的**:把 `budget.total_agent_calls` 压到 14,第 14 次 `advanceOne` 如期返回 `budget-exhausted`(带 `exhausted: ["total_agent_calls"]` 明细)并 `suspendRunTree`,root → `blocked`、子 run → `paused`。 **第六轮修正口径(第五轮那句写高了)**:`total_tokens` 的产生端确实在第四轮接通(`defaultAgentExecutor` 的 `onAcpEvent` 累加 `session/result.usage`,三条 return 路径都带),**但「终验实测 `spent.tokens` 递增且能单独触发 `exhausted`」不成立**——mock 协议的 `usage` 恒为 `{0,0}`,这套手法产不出那个数(§四.A.3)。**准确的现状是:`total_agent_calls` 有真实路径背书;`total_tokens` 只有接线背书;`wallclock_ms` 三样都没有。** 11. ✅ **两层在生产成立、孙 run 那层仍只有夹具(第六轮终验升档)**。对 root run 点「暂停」:正在跑的子 run、孙 run 全部在下一个节点边界停下。 → 三层 run 树的用例通过;**终验在真 server 上实测了两层**:`POST /:root/pause` 回 `cascaded: [子 run]`,子 run 当场 `paused` + `pause_requested=1`,root 保持 `running` + `pause_requested=1`(org 层父 run 在等子 run 时的常驻形态)。**它的反向操作(恢复)第六轮已成立**,原文「不成立」作废,见第 4 条。 12. ⚠️ **前半句第六轮起有生产实测、仍无用例;后半句的「不级联」分支实测成立,「级联」分支仍未验证**(升档;第四轮那句「`snapshotContract` 永远返回 null、级联判据是死路径」早已作废)。对一个 `blocked` 节点点「重试」:节点回 `ready` 并能继续推进 → **终验实测**:对一个 `blocked` 的 `subgraph` 父节点 `POST /nodes/:id/retry` 回 200、`nodeStatus: 'ready'`、`contractSnapshot` 真的落了盘,随后 `/next` 一步就把链路重新推起来并最终跑到 `completed`;`retryNode` 仍**无自动化测试**。「下游 `contract_hash` 未变的节点保持原状不重跑」→ **终验把这条的「不级联」那一半跑实了**:跨层定点重置走的正是 `retryNode → snapshotContract → applyRetryCascade` 这条链,三轮重置 `wg-impl` 之后下游 `wg-test` 的 `attempt` 恒为 0、状态恒为 `success`,一次都没被重跑。**「级联」那一半仍未验证**,因为它要求 agent 真的写出 `contract.json`(见下)。原判据本身 → `diffContracts` / `applyRetryCascade` 逻辑正确且复用了 `doc-store.hashContract`;**`snapshotContract` 现在恒落盘 `contract.prev.json`**(含 `present` 位与 sha256 前 16 位的 `hash`),所以「有→无 / 无→有」都会被算成 removed / added 而级联,落到「不级联」的只剩**重试前后都没有契约**这一种。**剩下的唯一薄弱环是「产生」**:`describeArtifactContract` 已把 `contract.json` 的路径与形状写进提示词,但**没有任何代码或模板强制 agent 真的写它**,也没有一条用例或真实跑验证过它会被写出来——四环里这一环的状态是「有规约、未验证」,不是「已打通」(§八.12)。 **阶段 C** > 阶段 C 全部 4 条**没有任何自动化测试**(仓库无前端测试基础设施),下面的判断是逐个组件读源码得出的。 13. ✅(读码确认) 画布:回退边渲染为虚线曲线且显示 `N/3` 徽标,随 D.2.1 推进数字正确递增。 → `WorkGraphCanvas.vue` 里分道避让的曲线路由 + `latestLoopCount(edgeId)` 取同 `edge_id` 最新一条 `loop_count`,`hardCap` 来自路由。 14. ⚠️ 「运行日志」tab:`running` 时实时追加,`success`/`fail` 后切历史视图,内容不丢。 → 双轨机制成立(`NodeRunLog.vue` 按 attempt 分组、live/history 同一份事件缓冲,T5 接线后确有事件流入),**但流的是引擎事件不是 agent 调用内部的对话/工具记录**,§四.B.3 第 3 条要的那半没落地。 15. ✅(画布部分读码确认;**「作用在子 run 上」R6 起真 server 实测**) `subgraph` 节点的「子图」tab 点进去是完整可交互的嵌套画布,面包屑能逐层返回。 → `NodeDetailPanel.vue` 的 `sub` tab 先给只读缩略图,「进入子图」`$emit('dive')` 把子 run 推到中间画布,那一层的五个控制按钮作用在子 run 上;`GraphConsole.vue` 维护面包屑栈。**R6 之前「作用在子 run 上」对「启动+自动」这一个按钮是假的**:`runAuto → advanceTree` 直接 `listRunTree(传入值)`,而那是按 `root_run_id` 查的,传子 run 拿到零行、循环第 0 步就 break —— HTTP 回 `ok:true / runStatus:running / auto:true`,两秒后那个 run 里的 ready 节点纹丝不动(返回 ok 却什么都不做)。现改成以传入 run 为**子树根**裁子树,root 与子 run 同一条代码路径。**实测(第六轮终验独立复跑)**:暂停 root(子 run 级联 `paused`)→ 对**子 run** `POST /start {mode:auto}` 回 200 → 子 run 从 `[w1:success, w2:ready, w3:pending]` 自己跑到 **`[全 success]` / `completed`** 并回写父节点(父层 `org-a` 转 `success`、`org-b` 被点亮成 `ready`),而 root 的 `pause_requested` **仍是 1**(启动只作用于这棵子树,不误清父层的暂停意图);对不可启动的 run 则是明确的 400(如对 `completed` 的 run 点停止 / 对非 `running|paused` 的 run 点「下一步」),不再有 `ok:true` 空转这一形态。 16. ✅(读码确认) 左栏点一个从未跑过 work graph 的 org node:中栏空态提示,不报错。 → `GraphConsole.vue` 的 `empty-state` 分支 + 画布自己的 `#empty` 插槽两层兜底。 **阶段 D** 17. ✅ **端到端生产成立**(第四轮的「❌ 不成立、止步于第一个节点」**已作废**——那根接线在第五轮接上了,见 §五 T11)。对一家 org graph 已就绪的公司下达一句自然语言任务:自动建 root run → 第一个 org node 起 work graph → 两层跑到 `completed`。 **怎么验的**:起**真的 server**(`node server/index.js`)、走**真的 HTTP**(`POST /api/company/:cid/tasks`)、用**真的 `AgentManager` → `SessionPool` → `ClaudeCliProtocol` 调用链**,**不注入任何假 executor / 假 planner**;只把最底层的 claude CLI 子进程换成仓库自带的 mock 协议(`LEMONBOSS_CLAUDE_MODE=mock`)来控成本——**换掉的是子进程,不是调用链**。 **实测到的数**:root run 与它下面的**两张子图全部 `completed`**;`work_graphs` 表真的落了两行,`planned_by = 'workgraph-planning'`;预算 `spent.agent_calls = 6` = **2 次规划 + 4 次节点调用**(**规划确实计预算**,见 §四.A.3 与 `advanceOne` 里那句「subgraph 节点这一次花的是 `workgraph-planning` 的调用,同样按一次计」);并且验到了「第一版图违规(有 `fail` 出边没兜底边)被 `fail-fallback-edge` 打回、`errors` 回给 agent、第二轮交出合法图」这一轮真实交互。 **第六轮终验复验(另一份公司、另一份脚本、同样零注入)**:`POST /api/company/:cid/tasks {mode:'auto'}` 之后**不做任何人工干预**,轮询到 root run 与它下面的两张 work 子图**全部 `completed`**(`["org:completed","work:completed","work:completed"]`),`GET /api/company/:cid/graph-runs` 能查到这 3 条;预算 `spent.agent_calls = 8`(2 次规划 + 6 次节点调用,**规划确实计预算**)。**这条仍然生产成立,没有退档。** **⚠️ 这条的证据仍是一次性人工脚本,仍没进 CI**——第五轮那份、第六轮这四份都没有。按 §十 硬规矩第 2 条,把它固化成 `server/tests/integration/` 下的一条 smoke 才算还清欠账;**这是本阶段最老的一笔未还欠账,已经跨了两轮**。 --- ## 七、影响面 / Blast Radius | 文件 | 改动类型 | |---|---| | `server/services/work-graph-db.js` | 新增:五张表 + 状态转换表 + work graph 写入校验 | | `server/services/graph-engine.js` | 新增:`advanceOne` + 两种 executor + 回退熔断 + 预算 + 级联 | | `server/services/graph-run-sse.js` | 新增:参照 `sse-broker.js` | | `server/services/goal-contract.js` | 新增:验收契约 schema + 执行器 | | `server/routes/graph-run.js` | 新增:控制类 + 只读查询类端点 | | `server/routes/company.js` | 追加:任务入口端点(T12) | | `web/src/views/GraphConsole.vue` | 新增:主页面 | | `web/src/components/WorkGraphCanvas.vue` | 新增:从 `UnitsDAG.vue` 抽取,org/work 两层共用 | | `system/templates/workgraph-planning/` 等四个 | 新增:规划 agent + 三个 validator 本体 | | `server/services/graph-db.js` | 追加:`org_graph_edges` 加 `fail_reason` 列(T-P3) | | `system/org-graph-templates/*.json` | 修改:末节点改名 + 回退边重画(T-P1) | | `docs/todo/system-design/27-naming-conventions.md` | 修改:§三 删行 + 新增 validator 命名轴(T-P2) | | `server/src/engine/run-registry.js` | 只读研究为主,T9 视核实结果决定是否补一个查询接口 | **明确不改**:DevLoop 全部现有代码(`loop-controller.js` / `unit-registry.js` / `doc-store.js` 只被**读取和借鉴**,收编是 P6)、P2 的 `org-graph-service.js` 四项校验(只复用)、`CompanyDashboard.vue`(新页面独立存在,只从 Dashboard 加一个跳转入口)。 --- ## 八、风险 / Open Questions 1. ~~**归因粒度:【已定案,待返工】**(R-16 遗留 → R-17.1 定案)~~ **【已关闭,第六轮返工完成】** R-16 结尾我倾向「保持层级封装」,[27-REQ](./27-requirement.md) **R-17.1 否决了它**,定案为**允许跨层直接寻址**:`validator-triager` 归因到哪个 work graph node,就退到哪个 node。第六轮已按定案返工,四个面全部落地: | 面 | 返工前(org node 粒度) | 现在(work graph node 粒度) | |---|---|---| | 回退目标 | org 边的 `to_node_id`,指向一个 org node | 跨层地址 `{orgNode, workNode}`,存 `graph_run_nodes.fail_node_ref`(形态与三选一的论证见 §四.A.2) | | 重跑范围 | 该 org node 整张 work graph 重跑(起新子 run) | 复用既有 work graph 定义,只重置被点名的 node | | 下游处理 | 整张图重来,无所谓 | 按 `contract_hash` 四分类决定级联——**真的复用了既有机制**(`retryNode → snapshotContract → applyRetryCascade`),没另写一套 | | 子 run | 每次回退新建一张 | 复用旧子 run(`child_run_id` 不变),被点名的节点 `attempt+1` | **已知代价**(R-17.1 明确接受,**已在 UI 上兑现**):破坏层级封装;控制台「暂停 / 单步」作用于哪一层的语义变含糊 —— 落点是 §四.B.3「子图」tab 的层级语义说明块 + 回退记录上的粒度标注 + 子 run 复用徽标。 ⚠️ **返工时最要紧的那一条已处理:第二轮修死锁的修法确实被拆掉了,幂等判据换了维度。** 旧判据「旧子 run 还活着吗」在复用语义下分辨不了「上一轮留下的终态」与「这一轮刚重置过」;新判据是 `child_run_attempt`(这张子 run 服务的是哪一次 `attempt`),三条分支里**没有一条会在子 run 已终态时返回 `pending`**,死锁那一类在结构上不再存在。完整判据表见 §四.D.2.2。**有独立复现脚本背书**:三个场景(跨层定点重置 / 无地址退化 / 孤儿终态子 run),把守卫改回旧写法它立刻变红,说明它真的在测那件事而不是恒绿。 **第六轮终验的独立死锁复核(不采信上面那份自证,另写脚本、走真 server + HTTP + 真 `AgentManager`、零注入)**——四个场景,全部收敛,**死锁未复发**: | 场景 | 打的是哪条分支 | 实测结果 | |---|---|---| | 跨层定点重置连打 4 轮 | `served !== attempt` → `reopenChildRun`(targeted) | `child_run_id` 全程不变,只有被点名的 `wg-impl` 反复重跑,第 4 轮撞单边硬顶 `force_terminated` → 整棵树 `blocked`。**有终止点** | | `infra_flake` 归因(**不烧任何计数**,`routeFailure` 清 `child_run_id` + `child_run_attempt` 逼它重新规划) | 「没有子 run」分支 | 这是唯一一条**熔断管不到**的循环:`backward_count` 全程为 0。把预算压到 14 次调用,第 14 次 `advanceOne` 如期回 `budget-exhausted` 并 `suspendRunTree`,root → `blocked`。**终止性完全押在任务级预算上——`budget_json` 为空的 run 在这条路径上会真的转不停**(§八.11 那句「新增任何建 root run 的入口都必须一并给预算」是硬要求,不是建议) | | 人工 `stop` 一张**正在跑**的子 run | `isChildSettled(stopped)` → 就地结算 | 父节点当场从 `running` 结算掉(没有「抱着终态子 run 永远等」这一形态),run 收敛到 `blocked` | | 承上,再对该父节点点「重试」(`attempt+1`、`child_run_id` 保留) | `reopenChildRun` 的 `stopped` 拒绝分支 | 拒绝复用,回落到 `planWorkGraph` **重新规划一张新子 run**(`child_run_id` 换新),整棵树最终 `completed` | **结论**:第二轮那个「守卫在子 run 已终态时返回 `pending`」的永久死锁,在代次判据下**结构上不再存在**;新语义引入的风险不是死锁而是**活锁**,且活锁的唯一闸门是预算。 **第七轮补记 —— R-17.1 到本轮才算真的完成。** 第六轮关的是**引擎侧四个面**,第七轮补齐的是**生产口的两处**:① 产出方的三份系统提示词原本明令禁止跨层指认(§八.22,同轮 R7-F1 修),不补的话 `fail_node_ref` 在真实 agent 手里恒为 `null`,上表右列全部退化成左列;② 四处 `// 假设(R-16待定案): 归因保持 org node 粒度封装` 注释与实现方向相反,同轮撤清(见 §八.19 / §八.22)。**至此 R-17.1 的产生 → 传递 → 落库 → 读回四环,加上「产出方被允许产出」这一前置,全部成立。** 2. ~~**`validator-triager` 的命名尚未定案**(R-16 遗留)~~ **【已关闭,R-17.2 定案】** 定名 **`validator-triager`**——它做的是**归因**不是**判定**,`triager` 与 DevLoop 已跑通的 BugTriager 对齐。`validator-reflection` 是历史名,除 27-REQ R-16 正文的原话引用(照搬原则)外不再使用。改名已落到:`system/templates/validator-triager/`、`_registry.json`、`org-validator` 模板、`work-graph-templates/validator.json`、[27-NAMING](./27-naming-conventions.md) §3.5、本文档全文;引擎侧的 `ATTRIBUTION_NODE_NAME` / `node.triage` 事件名已改完,`ATTRIBUTION_NODE_ALIASES` 保留旧名做兼容。**第六轮收尾**了 `workgraph-planning` 提示词等最后一批,见 §八.19。**遗留的仍未定案项只剩前缀取父节点全名还是职能**(27-NAMING §3.5 第 1 行),与本条无关。 3. ~~**`fail_reason` 的取值域不由引擎约束**(§四.D.2.2)~~ **【缓解已落地,2026-08-16】** 引擎只做字符串匹配,取值由图的规划者和 validator 自行约定;风险(归因产出一个图上没有对应边的取值)由 `fail-fallback-edge` 校验兜住,**org / work 两层现均已实装**(`org-graph-service.validateGraph` + `work-graph-db.validateFailFallback`),且 `org_graph_edges` 的唯一索引已改成含 `COALESCE(fail_reason,'')`,让「精确 fail 边 + 兜底边」能并存。**残留的是提示词侧**,见下方第 15 条。 4. **`org_graph_nodes.id` 是整数、`work_graph_nodes.id` 是文本**,`graph_run_nodes.def_node_id` 只能取 TEXT 且无法建外键(§四.A.2 的注)。引用完整性靠服务层保证。 5. ~~**回退边曲线路由是真实的手写前端工程量**(§四.B.2)~~ **【已关闭,2026-08-16】** 手写落地了,没引图形库:`WorkGraphCanvas.vue`用「按跨度分道 + 侧向绕行」处理多条回退边的避让,Kahn 分波只吃 forward 边。事后看这块的工程量确实是全部前端里最大的一块,但没有超到需要引入 dagre 的程度,当初的担心可以撤销。 6. ~~**`RunRegistry`/`ProcessGuard` 的取消调用链没有 100% 核实**(§3.6)~~ **【已关闭,2026-08-16 T9 逐行核实】结论:硬取消做不到,「停止」定案为软停止。** `ProcessGuard` 在生产代码里从未被实例化(只有测试 `new` 它),`RunRegistry.add()` 从未被调用(注册表恒为空),两者之间也不存在引用关系;唯一能杀子进程的 `ClaudeCliProtocol.disconnect()` → `SessionPool.killActive()` → `AgentManager.killCurrentProcess()` 这条链同样零调用方,且粒度是会话而非 runId。完整证据见 §四.C「『停止』是软停止,不是硬取消」。**残留风险已从「能不能取消」转为产品预期管理**:用户点停止后正在跑的节点仍会跑完(最长一个节点的时长),UI 必须明示,否则会被当成 bug 报上来。 7. **v1 的「一家公司同时只有一个 active root run」是一条真实的产品限制**,不只是技术妥协。它由 `CONVENTIONS.md` §12 的不变量和 SessionPool key 复用共同决定(§一非目标)。如果用户很快就要「一家公司并行接多个任务」,实例隔离要提前排期。 8. **`workgraph-planning` 的规划质量没有硬校验**。§三.7 只保证图**结构合法**,不保证它**适合这个任务**。缓解手段与 P2 同款:规划记录落盘供人工复核,发现不合理走增量修正。**目前还轮不到担心质量**——它压根没被引擎调用过,见下方第 10 条。 9. **阶段 0 的契约格式一旦定错,阶段 A 的 schema 可能要返工**。这也正是 R-15.6 把它提到最前面的原因——但「提前」只降低概率,不消除风险。建议 T0 先把契约的**存储位置和与 `output_ref` / `artifacts/` 的关联方式**定死,格式内部细节可以后续演进。**现状(第五/六轮更新)**:格式与存储位置都已定死并实现(`goal-contract.js` + `27-goal-contract-format.md`),**反馈渠道也接上了**——20 条单测 + `org-validator` / `coder-validator` / `planner-validator` 三个模板的 `tools.md` 真在调它(§六 验收 1)。原文「一次都没被跑过、没有任何反馈渠道」作废。**返工风险仍在,但性质变了**:不再是「没人按过这个按钮」,而是「按钮被按的样本还很少」——尤其**契约的三段内容目前都由 agent 手写**,格式是否够用要等真实任务攒出几十份 `acceptance.json` 才看得出来。 ### 第三 / 四轮开发中新发现(本组 10-18 共 9 条:**已关 5 条**——11、16 第四轮关,**10、14 第五轮关**,**13 第六轮关**;**现存 4 条**——12(已由「死路径」降级为「有规约、未验证」)、15、17、18) > 这一批全部是同一个模式的实例:**某个字段或计数,在「产生 → 传递 → 落库 → 读回」四个环节里有一环被静默漏掉**。详见 §十。按严重度排序。 > 划线条目 = 已关闭,保留原文的否定形式以便对照,不要因为「看起来还在列表里」就当成未关闭。 10. ~~**`planWorkGraph` 生产未接线(阻断阶段 D)**~~ **【已关闭,第五轮接线;第六轮复核确认】** `defaultPlanWorkGraph` 现在真调 `workgraph-planning`(`PLAN_MAX_ROUNDS=3`,校验不过把 `errors` 原样贴回去让它自己修,耗尽轮次才抛错并把已烧的 `tokens` 挂在 error 上照扣),`setPlanWorkGraph(fn)` 降格为**测试注入缝**而不是「唯一的接线方式」。§六 验收 17 已端到端跑通(真 server + HTTP + 真 `AgentManager`)。**原文那句「看起来像 agent 判失败、其实是引擎没接线」的误导性仍值得记住**——它是本文档判断「实现在 ≠ 能跑」的原型案例,被 §十 硬规矩第 2 条引为依据。 11. ~~**`total_tokens` 是死维度**~~ **【已关闭,第四轮补上产生端;第五轮复核确认】** 产生端挂在 `defaultAgentExecutor` 上:传 `onAcpEvent` 给 `AgentManager.callAgent`,累加 `session/result` 事件的 `params.usage.{input,output}`,**三条 return 路径都带 `tokens`**,`spendBudget` 真的在扣。**第六轮终验下调半档**:接线经复核成立(`session/result` 事件到达、`usage` 被读),但「实测 `spent.tokens` 递增并能单独触发 `exhausted`」产不出来——mock 协议的 `usage` 恒为 `{0,0}`,**数值大小仍未验证**。完整链路与已知低计(只有 `claude-cli` 协议上报 usage,走 `AcpProtocol` 的 agent 恒为 0)见 §四.A.3。**残留的不止是这一维的数值,还有那条对三维一视同仁的前置**:`budget_json` 只在建 run 时显式传了预算才非空,目前只有任务入口默认给——**新增任何建 root run 的入口都必须一并给预算**,否则整层预算兜底不存在。 12. **`contract.json` 的产出方仍只有提示词一句(「有规约、未验证」)**。第五轮修掉的是**读回**那一环:`snapshotContract` 不再「文件不在就 return null 不拍」,而是**恒落盘 `contract.prev.json`**(信封 `{schema:'contract-snapshot/v1', present, hash, contract, capturedAt}`,`hash` 走 `doc-store.hashContract`),显式记 `present` 位,于是「有→无 / 无→有」都能被 `diffContracts` 判成 removed / added 而级联;落到「不级联」的只剩**重试前后都没有契约**。**原文「`snapshotContract` 永远拿不到快照、整条判据是死路径」因此作废。** **但四环里的「产生」仍然只是一句提示词**:`describeArtifactContract` 现在会告诉 agent「写到 `{nodeDir}/contract.json`,形状 `{"exports":{...}}`,每轮完整重写」,**没有任何代码校验它写没写、也没有模板把它列为必交产物**,更没有一条用例或真实跑证明 agent 会照写。所以这一环的准确状态是**「有规约、未验证」**,既不是「没人产出」也不是「已打通」。要收口有两条路:① 让 `readNodeProduct` 那一侧把「缺 `contract.json`」记成一条 timeline 事件(先拿到真实数据,看 agent 到底写不写);② 给需要契约的节点在 work graph 定义里加一个「必交 contract」的标记,缺了就按 `input_underspecified` 结算。**别在没有①的数据之前直接上②**——绝大多数节点本来就不产契约(见 `applyRetryCascade` 的降级理由第 2 条)。 13. ~~**暂停向下级联,恢复不向下级联**~~ **【已关闭,第六轮修掉并经终验实测】** 原缺陷:`pauseRun` 给整棵 run 树置 `pause_requested=1`,而 `startRunSync` 只清自己这一层,多层 run 树暂停后点「启动」推不动——漏的是「传递」的**反向**。修法分两处,缺一不可:① 引擎侧 `startRunSync → resumeDescendants` 沿 `descendantRuns` 清全树软信号,并把**被级联暂停的** `paused` 后代拉回 `running`(`blocked` 后代**刻意不碰**:blocked 的语义是「等人工」,拉成 `running` 只会在下一次 `settleRun` 原地判回去,制造状态抖动;解除 blocked 的正当入口是对具体节点点「重试」);② REST 侧放开「`running` 且 `pause_requested=1`」这一形态——org 层父 run 在等子 run 时恒为 `running`,旧守卫只看 `run_status` 就把它挡在门外,两种模式都吃 400、UI 按钮直接置灰。**终验实测**:`resumed` 精确等于被级联暂停的那张子 run,整棵树自己跑到 `completed`,全树 `pause_requested` 归零(§六 验收 4)。 14. ~~**`remediation_note` 的四环有一环没接**~~ **【已关闭,第五轮把最后一环接上;第六轮复核确认】**(第五轮把原条目一分为三,原文「只在 backward 边落库」已不准确) - ✅ **落库已修**:第四轮给 `graph_run_nodes` 加了 `remediation_note` 列(可空),并把它进了 `updateNodeStatus` 的列白名单——**判词跟着状态转换那一次条件更新一起落库**,不再只有 `handleBackwardEdge` 那一条路。走 forward 的 fail 边、原地重跑、直接 `blocked` 这几条路径上判词不再蒸发。 - ✅ **引擎侧读回已修**:`latestRemediationNote` 改成直接读收件人节点行的 `remediation_note`,不再从 `graph_run_loop_records` 按 `created_at` 猜「最新一条」(那张表毫秒精度、同毫秒排序不定,本来就不可靠)。 - ✅ **控制台那一环第五轮接上了**(原「❌ 还没接」作废):`NodeDetailPanel.vue` 的 `consumedNote` 现在**先读 `node.remediationNote`**(即 `graph_run_nodes.remediation_note` 经 `getRunFull` → REST 吐上来的那份),读不到才回落到 `loopRecords` 按 `to_node_run_id` 找;面板还标出这一条判词的来源是「节点行 `remediation_note`」还是「回退账本 loop record」。于是**没走过 backward 边的那类失败**(forward fail 边 / 原地重跑 / 直接 `blocked`)在控制台上也能看到 `validator-triager` 写的修复建议。**四环(产生 → 传递 → 落库 → 读回)到此全通**,这是十二处缺陷里唯一一处走完全程的。 15. **`orggraph-planning` 的提示词没写 `failReason` 字段**(**第六轮复核:仍然没有**,`grep failReason system/templates/orggraph-planning/` = 0 命中)。`tools.md` 的字段表只列了 `from/to/condition/edgeKind`,示例 payload 也没有。数据链路(读→写→读回)三轮下来终于打通了,但**生产里真正产生这个字段的是 agent,而它不知道有这个字段**。漏的又是「产生」——而且是最隐蔽的一种:代码全对,缺的是提示词里的一行。**后果不是报错而是退化**:agent 建出的 org graph 只有兜底 fail 边,D.2.2 的「按 `fail_reason` 精确选边」在生产里恒走兜底分支,`validator-triager` 辛苦算出的归因**在选边这一步被丢掉**——与 §八.10 同型(看起来像「归因没用」,其实是「图上没有对应的边」)。修法是 `tools.md` 字段表补一行 + 示例 payload 补一条精确 fail 边,与 `dev.json` 已重画好的两条边对齐。 16. ~~**`advanceTree` 吞掉子 run 的失败原因**~~ **【已关闭,第四轮修掉;第五轮复核确认】** `advanceTree` 现在会挑出**最深那条有信息量的原因**往上带:逐层 `advanceOne` 时,凡 `reason` 不是 `no-ready-node` 的就记下来,全层跑完后连同 `budget`(预算闸门那条路径才有的明细)与 `sourceRunId`(原因来自树上哪一层)一起返回;全是 `no-ready-node` 才落回原来的兜底。于是「预算耗尽」「被暂停」「被停止」在 `runAuto` 和控制台眼里不再长得一样。 17. **`agent_run_id` 在生产恒为 `null`**。`defaultAgentExecutor` 写的是 `res.runId`,而 `AgentManager.callAgent` 的返回形状是 `{agent, output, status}`。这一列建了但永远空,与 §3.6 / T9 核实到的「`RunRegistry` 恒为空」是同一件事的两端。**不必现在修**(硬取消已定案不做),但控制台不要拿它当可用的下钻锚点。 18. **「运行日志」tab 展示的是引擎事件,不是 agent 调用内部记录**(§六 验收 14)。双轨 UI 和 SSE 通道都通了,缺的是 `defaultAgentExecutor` 把 agent 的 stdout / 工具调用转发到 `graph-run-sse` 上。 ### 第五轮新增 19. ~~**改名 `validator-triager` 后,`workgraph-planning` 的提示词里仍是旧名**~~ **【已关闭,第六轮修掉】** 第五轮发现时的判断(**与 §八.15 同型**,四环缺陷模式的第 11 个变体)成立且低估了严重度:生产里真正产出 work graph 的是 `workgraph-planning`,它照着自己的提示词画图;提示词说 `validator-reflection`,它就把节点命名成 `validator-reflection`,而 `validator-reflection` 在 `_registry.json` 里**已经没有条目、`system/templates/` 下也已经没有目录**。所以后果**不是**静默降级——POST work graph 会直接因引用不存在的本体被**外键/`reference` 校验打回 400**;引擎的 `ATTRIBUTION_NODE_ALIASES`(两名都认)救得了归因匹配,救不了本体寻址。 第六轮已把这批改完:`system/templates/workgraph-planning/{agent.md(9 处),identity.md,soul.md,tools.md}`、`system/org-graph-templates/README.md`。**关于「撤掉三处已过期假设注释」这句,第七轮逐处核实后改正**:那三处 `// 假设(R-16待定案):` 注释各自包含**两半**——「归因**节点名**保留 `validator-reflection`」与「归因保持 **org node 粒度**封装」;第六轮撤掉的**只有节点名那半**(R-17.2 定案),**粒度那半三处一处没动**,另外 `graph-engine.js` 的 `deriveChildOutcome` 上方还有第四处。四处在 R-17.1 落地后**全都在描述与代码相反的行为**(注释说「不跨层寻址」,紧邻的代码正在取 `fail_node_ref` 做跨层寻址)。**第七轮已把这四处全部撤掉/改写成实话**(见 §八.22)。**核实同时又发现第六轮那次 grep 的范围本身也漏了**:`system/templates/{planner-validator,coder-validator,org-validator,workgraph-planning}/agent.md` 里还有 4 处同款假设注释,第六轮只查了 `.js` 与 `.md` 文档、没查模板目录,因此一直没被数进来(归同轮 R7-F1 处理)。R-17.2 那一半的结论不变:`27-NAMING` §3.5 仅剩「前缀取父节点全名还是职能」一处未定案,与节点名无关。另清掉两处 `grep validator-reflection` 抓不到的**裸 `reflection` 措辞残留**:`org-validator/agent.md` 的判决字段 `reflection_ref` → **`triager_ref`**(该字段无任何代码消费方,纯提示词契约)、`org-validator/tools.md` 的「``」→「``」;同一份文件上方已写「归因节点定名 `validator-triager`」、下方却用 `reflection` 指代同一个节点,agent 读起来会以为是两个东西。 **仍保留旧名的地方只剩说明性引用**:27-REQ R-16 正文的原话引用(照搬原则)、R-17.2 改名记录本身、27-NAMING §3.5「`validator-reflection` 是历史名」、[27-GRAPH-AGENT](./27-graph-agent-architecture.md) 那处历史引用、三个 validator 模板顶部的「命名已定案」注记、以及 `graph-engine.js` 里 `ATTRIBUTION_NODE_ALIASES` 的兼容项与其注释。**除此之外读到 `validator-reflection` 一律视为漏改。** **第六轮终验复核结论:✅ 通过,白名单外只剩 1 处文字瑕疵。** `grep -rn validator-reflection` 逐条比对白名单后,唯一越界的是 `server/tests/unit/graph-engine.test.js` 顶部那句 `// 假设(R-16待定案): 归因节点名保留 validator-reflection,不改 validator-triager` —— R-17.2 早已定案,这句「待定案」措辞过期(同文件下方**故意**用旧名当节点名的那组用例是在测 `ATTRIBUTION_NODE_ALIASES` 的兼容分支,属合理保留)。另跑了一遍**裸 `reflection`** 的独立 grep(本条要求的第二道):命中全部落在 `memory-service.js` / `evolution-loop` / DevLoop 的 `retry-with-reflection` 等**无关子系统**,以及 `graph-engine.js` 里 `ATTRIBUTION_REQUIRED_RE = /(^|[-_])(triager|reflection|validator)$/i` 这个刻意认两名的正则。**P3 范围内无裸 `reflection` 残留。** ### 第六轮新增 20. ~~**`goal-contract` CLI 吞掉失败原因**~~ **【已关闭,第六轮修掉】** 这是四环缺陷模式的**第 12 个实例,也是第一个漏在「读回」且落在 CLI 输出上的**。`cmdRun` 的非 json 分支只打各 section 的 `score` / `passed` / `exit_code`,而 `runBenchmark` / `runTestData` / `runTestScript` 的短路分支(`target_not_json` / `script_missing` / `script_escapes_artifacts`)返回的对象**根本没有这些键,也不往 `verdict.failures` 里塞东西**——实测输出是「`sections: benchmark=FAIL`」「`benchmark score=undefined / -`」「`failures:`(空)」,而**落盘的 verdict JSON 里 `reason` 和 `detail` 一直都在**。 **为什么这不是「少打了一行日志」**:`org-validator` 的 `tools.md` 退出码表写着「1 = 判决为 fail,照它打印的 `fail_reason` 与 `failures` 走 Step 5」,`coder-validator` 同款。**按提示词照做的 agent 因此拿不到任何可归因的信息**——它只知道「失败了」,不知道是「产物不是 JSON」还是「真的没达标」,而这两者在 §四.D.2.2 里对应完全不同的 `fail_reason` 与不同的回退边。 **修法**:① 短路分支一律产出一条 failure(形状与正常比对完全一致:`case` / `expected` / `got` / `hint`,`hint` 里带 `reason` + 人话解释),于是 `executeContract` 的 `failures: active.flatMap(...)` 自然带上它们,**json 分支、落盘 verdict、人读输出三处同源**;② 人读输出改成按字段**是否真的存在**分别打,并且**只要有 `reason` 就一定打 `reason` + `detail`**;`test_script` 那行 `exit=... timed_out=...` 只在真起过进程(有 `duration_ms`)时才打,免得短路分支的 `exit=null` 被读成「跑了、返回 null」;③ 判决为 fail 却一条 failure 都没有时,明确打一句「无逐项明细」,而不是留一个空的 `failures:` 标题。 21. **骨架悬空引用:目录补了、`_registry.json` 没登记,CI 因此是红的** ✅ **【已关闭,2026-08-18 由 28 号 Agent 注册表统一顺带扫掉】** - **关闭实测**:六个 id(`benchmark-validator` / `testdata-validator` / `brd-planning` / `prd-planning` / `sdd-planning` / `goal-benchmark`)现已全部登记在 `system/agents/_registry.json`;`server/tests/unit/template-refs.test.js` **4/4 通过**(全量 999/999 全绿)。注意本体目录已随 [28 号改造](../../done/system-design/28-agent-registry-unification.md) 从 `system/templates/` 并入 `system/agents/`,且 `_registry.json` **不再是数据源头** —— `seedAgents()` 直接扫目录,registry 只用于按 id 补 `benchmark_score`。 - **「顺带查到的第二个隐患」也已关闭**:`usedCount` 已从 `_registry.json` 挪到 `data/template-usage.json`(27-P3B W13),`_registry.json` 只留人工维护的基线,跑完真 CLI E2E 后该文件 `git status` 干净。 - 以下为原始记录,保留备查: - **控制操作那条线已关闭**:§四.C 控制操作表 + §六 验收 4 / 15 + §八.13 全部实测通过,见 §八.13 与 §六 验收 4/15。 - **骨架这条线只做了一半**。做了的:六个本体目录 `system/templates/{benchmark-validator,testdata-validator,brd-planning,prd-planning,sdd-planning,goal-benchmark}/` 已建齐,`graph-seed-service.seedTemplates()`(**按目录扫描**)能把它们 upsert 进 `system_agents`,`work_graph_nodes.system_agent_id` 的外键那一端因此成立——终验用真 server 跑 `org-validator`,`validator.json` 三个节点**原样落库、不再被 `reference` 打回**,这个 blocker 在 DB 这一侧确实消失了。 没做的:**`system/templates/_registry.json` 里这 6 个 id 一条登记都没有**。而同轮新增的防线 `server/tests/unit/template-refs.test.js` 第三条用例查的正是这个,实跑 **failed**(全量 `npx vitest run` = 718 passed / **1 failed**)。 - **为什么这条不能当成小事**:它是「自己写的 CI 防线抓到自己没做完的那一半」,而**防线的价值恰恰在于它红了**——如果因为「反正外键已经成立」就把这条用例删掉或放宽,下一次真正的悬空引用就没人挡了。修法是往 `_registry.json` 追加 6 条(`benchmarkScore: null` / `trainedAt: null` 即可,形状照抄 `validator-triager` 那条),**不要动测试**。 - **顺带查到的第二个隐患(未修,登记在册)**:`_registry.json` 会被 `company-service._updateTemplateUsedCount()` 在每次 `hire` 时**原地改写 `usedCount`**——它既是「人工维护的清单」又是「运行时计数器」,任何一次跑端到端都会让这个 git 跟踪文件产生 diff(本轮终验就制造了若干次)。要么把 `usedCount` 挪出这个文件,要么接受它长期脏。 22. **R-17.1 的跨层地址 `target_node`:引擎全通,但三份 agent 提示词还在明令禁止它** **【第七轮修复中:注释那一半已关闭,提示词那一半由同轮 R7-F1 处理】**(第六轮终验新发现,四环缺陷模式第 13 例) 引擎侧四环全部打通并经实测:`readNodeProduct` 解析 `target_node` / `target_org_node` → 落 `graph_run_nodes.fail_node_ref` → `deriveChildOutcome` 透传 → `resolveCrossLayerTarget` 四道关 → `applyCrossLayerReset` 定点重置 → 账本 `target_node_ref` → REST → `NodeDetailPanel.vue` 渲染「跨层定点重置 → orgNode / workNode」与「子 run 复用中」徽标。`describeArtifactContract` 也确实把「判失败时再多写 `target_node` / `target_org_node` 两个字段」注入到了每个 `agent_call` 节点的任务描述里。 **但产出方的系统提示词写着相反的话**,三处: - `system/templates/validator-triager/agent.md` 顶部粒度块:「⚠️ **粒度:已定案要跨层,但引擎还没返工,当前一律按 org node 粒度写** …… **在返工落地之前**:你指认到 org node 为止,`culprit.org_node` 填 org node 名;**往里多指一层,org 层的边上没有对应的落点,等于白指**。」——返工已经落地了,这段没跟着改; - 同文件「产出格式」的 JSON 模板(agent 会照抄的那份)里**根本没有 `target_node` 键**,只有 `culprit.org_node`,且注释写着「指认到 org node 为止,见顶部粒度说明(R-17.1 返工前)」; - `system/templates/org-validator/agent.md` 与 `system/templates/workgraph-planning/agent.md`「约束六:归因只到 org node 粒度,不跨层寻址」同样明写「**不允许跨层直接指向别人图里的节点**」。 **后果与 §八.15 完全同型**:不是报错,是**静默退化**——真实的 `validator-triager` 按自己的模板产出 `culprit.org_node`、不产 `target_node`,于是 `fail_node_ref` 恒为 `null`,`resolveCrossLayerTarget` 第一道关就返回 `null`,每一次回退都退化成「整张 work graph 重跑」,R-17.1 返工的全部收益在生产里拿不到,而账本上 `target_node_ref` 恒空看起来像「这次就是要整张图重跑」。终验之所以能验通,是因为那份归因文件是**人代 agent 落盘**的。 **判定**:这是「实现在、生产零产出方」的又一例(本阶段第五次),也是 §十 硬规矩第 1 条「四环要问满」漏问「谁产生它」的直接后果。**修法**:三份模板的粒度块与产出格式 JSON 同步到 R-17.1 定案(`culprit.org_node` 保留,另加 `target_node`;顶部那句「返工前 / 等于白指」整段替换成「引擎已支持,指得越准重跑范围越小」),并把 `graph-engine.js` / `graph-db.js` / `goal-contract.js` / [27-CONTRACT](./27-goal-contract-format.md) §「待定案」里四处 `// 假设(R-16待定案): 归因保持 org node 粒度封装` 注释一并撤掉——**它们现在都在描述与代码相反的行为**。 **第七轮处理结果(分两半,分别验收)**: - ✅ **注释那一半已关闭**:四处全部改完——`graph-engine.js` `deriveChildOutcome` 上方(改成「R-17.1 已定案,父层连 `fail_node_ref` 一起拿走,可定点重置」)、`graph-db.js` 文件头(改成指向 `graph-engine.js`「跨层节点地址」一节的实话注)、`goal-contract.js` `executeContract` 里(改成陈述**分工**:判决只回答「达没达成」因为它手上没有图的信息,定点由下游 `validator-triager` 产出)、[27-CONTRACT](./27-goal-contract-format.md) §「待定案」第 4 条(划掉并标已定案)。**清完后全仓库 `grep '假设(R-16待定案)'` 还剩 6 处,两类,都不归本条的代码侧改**: - **4 处在提示词里**(`system/templates/{planner-validator,coder-validator,org-validator,workgraph-planning}/agent.md`)——第六轮只查了 JS 与文档、没查模板,所以这批一直没被数进来。它们和上面三份粒度块是同一件事,**归同轮 R7-F1**;`planner-validator` / `coder-validator` 那两处措辞略有不同(「你作为 work 层节点只对本张图内的回退负责」),**work 层节点确实只在自己图内回退,这句本身没错,错的是把它挂在一条已作废的 R-16 假设名下**,改的时候只需去掉「假设(R-16待定案)」的帽子、保留那句约束。 - **2 处在 `server/tests/unit/graph-engine.test.js` 文件头**(第 18、19 行):一句写「归因保持 org node 粒度封装」、一句写「归因节点名保留 `validator-reflection`」,分别被 R-17.1 / R-17.2 推翻。铁律禁改测试文件,**登记不动**;同文件下方**故意**用旧名当节点名的那组用例是在测 `ATTRIBUTION_NODE_ALIASES` 兼容分支,属合理保留,别一起清。 - ⏳ **提示词那一半**(`validator-triager/agent.md` 的粒度块与产出格式 JSON、`org-validator/agent.md` 与 `workgraph-planning/agent.md` 的「约束六」)由同轮并行的 R7-F1 修,**验收判据**:`grep -rn target_node system/templates/` 在这三份模板里有命中,且 `validator-triager` 的产出格式 JSON 模板里带 `target_node` 键。 **这一例值得单独记一笔**(已进 §十 那张表第 13 行):它是十三例里**最隐蔽的一种**——四环全通、还实测过,缺的不是任何一环,而是**产出方被自己的系统提示词明令禁止产出**。查四环的清单因此要加第五问:「产出方**被允许**产出它吗?」 ## 九、与其他阶段的关系 - **与 P2**:强依赖。P2 产出 org graph 定义,本文档执行它。**两者共用同一套 schema 形态、执行引擎、控制台画布**——这是 R-9.1 新模型最大的收益。本文档只读 `org_graph_nodes/edges`(唯一例外是 T-P3 加一列 `fail_reason`)。P2 侧的三项连带修改(T-P1/T-P2/T-P3)归本阶段执行,见 §五的警告框。 - **与 P1**:无直接依赖,已实现(commit `226489d`)。P1 改的是 prompt 从哪来,不改调用接口。但 R-15.3 的「本体名不带领域前缀」正是 P1「同一份本体、多处引用、各自私有语境」的直接推论。 - **与 P5(自我进化)/ L4**:本文档不含。任务级预算里预留了 `l4_iterations` 的位置但不实现。 - **与 P6(DevLoop 收编)**:本引擎从 DevLoop **抽象**而来(§三),但不改它一行。收编的验收标准是「DevLoop 现有测试套件在新引擎上全部通过」,属 P6。 - **与 base agent 的四段式**:见下方 §九bis——原表述「已强制一套四段式生命周期」不准确,实测有一半没落地。 - **与端到端流程**:本文档设计的是「一张图怎么被执行和控制」,完整的七阶段链路见 [27-E2E](./27-e2e-task-execution-flow.md) §三,跑一遍具体的数见 [27-EXAMPLE](./27-example-dev-company-walkthrough.md)。 --- ## 九bis、base agent 四段式的现状核实 [27-E2E](./27-e2e-task-execution-flow.md) §八bis 说 `system/templates/base/` 「已强制一套四段式生命周期」,并把它当作「每个 node agent 内部也是一张图」的现成雏形。**核实后这个说法只对一半**,本节记录实测结论——它直接决定了 P3 之后那项「统一两套状态机」的待办到底要做什么。 ### 设计意图(`system/templates/base/agent.md`,28 行) ``` RESEARCH ──→ PLAN ──→ EXECUTE ──→ VERIFY ↑ │ └───────── 失败写 _lessons.md 打回 ┘ ``` agent 默认**只挂载 `meta-research` 一个元技能**,必须主动呼叫 `node bin/lemon.js task transition --agentId --phase --artifact ` 才能换脑子换技能包;每次流转**清空上下文**(原文叫「斩断记忆 / Context Flush」)防长对话累积幻觉;EXECUTE 阶段才拿到「修改核心文件的破坏性权限」;VERIFY 通过才可 `report_task({status:"PASS"})`。 **这确实就是一张最小 work graph**:四个节点、线性前进、末端一条回退边,且已具备 P3 引擎需要的三样东西——显式的阶段状态、阶段间的产物传递(`--artifact`)、失败回退。 ### 实测:上下文隔离是真的,权限/技能隔离是假的 `POST /api/agent/:agentId/task/transition`(`server/routes/agent-chat.js` 的 `task/transition` handler)已实现,且比 prompt 里写的还多: | 机制 | 状态 | 位置 | |---|---|---| | Context Flush(断 ACP 进程 + `history.json` 清空 + artifact 注入新起步指令 + 重新唤醒) | ✅ 真的在跑 | `agent-chat.js` `_contextFlush` 段 | | **P0 事前阻断阀**——P0 任务 `plan → execute` 拦截挂起、通知 Master 人工审批才放行 | ✅ 已实现(prompt 里没写这条) | `agent-chat.js` P0 审批段 | | OKR 联动——顺带把节点状态更新为 `researching`/`planning`/`executing`/`verifying` | ✅ | `agent-chat.js` OKR 联动段 | | **Hot Swap 换技能包** | ❌ **仍然失效**(27-P3B W11 后不再静默,改成显式告警) | `agent-chat.js` Hot Swap 段 | 失效原因:代码是 `const sourceSkill = .../system/skills/meta-${phase}/SKILL.md` 后跟 `if (fs.existsSync(sourceSkill))` 才拷贝,而 **`system/skills/` 这个目录在仓库里根本不存在**,四个 `meta-*` 技能一个都没有。所以每次流转的「动态换脑」是空转,那句 `🧠 动态技能树已切换` 的日志压根不会打印。 **27-P3B W11 的处置(方案 b,2026-08-17)**:**不补技能包,如实降级 + 把空转喊出来**。`agent-chat.js` 的 Hot Swap 段补了 `else` 分支,每次流转打一条 `console.warn`,写明「元技能包不存在、本次流转**未换装**、工具权限仍由 config.json 决定」。目录仍然不存在,这一维的能力**没有变**——变的只是它不再无声。 **为什么不选方案 a(补出四个 `meta-*` 技能包)——这条是本轮新核实出来的,比原文更要紧**: > **补上技能包也关不上权限那道闸。** 工具权限的**唯一**来源是 agent 自己 `config.json` 的 `cli.toolPolicy`,由 `agent-chat.js` 的 `getOrCreateProtocol` 在**建协议时读一次**,转成 `ClaudeCliProtocol` 的 `--allowedTools` / `--disallowedTools`。它与 `phase` **没有任何关联**。而 Hot Swap 做的事只是 `copyFileSync` 一份 `SKILL.md` 到 `.claude/skills/main_skill/SKILL.md` —— 那个文件的唯一消费者是 `buildSystemPrompt`,被当作**提示词文本**拼进去(「我的核心主技能(执行准则)」那一段)。 > > 换句话说:**`meta-*` 技能包能改的只有话术,改不了权限。** 真要落地「EXECUTE 阶段才拿破坏性权限」,必须让 `transition` 同时改写生效中的 `toolPolicy`(或按 phase 选一份),那是**另一件事**,不在 `system/skills/` 里。 > > 更直接的证据:`system/templates/base/config.json` 现在写的是 `{"mode":"restricted","allowedTools":["bash","read_file","str_replace"]}` —— **`bash` 和 `str_replace` 从 RESEARCH 第一秒就在手上**。所以「RESEARCH 阶段照样能写文件」不是「技能包没挂上」造成的,是 `config.json` 直接给的。 > > 因此方案 a 会造出一个**比现状更危险的状态**:四个文件躺在那里、日志说「已切换」、文档可以打勾,而权限闸门一如既往地开着 —— 正是 T9 那次「落地了降级却既没核实也没回改文档」的同型。**宁可留一条刺眼的 warn。** **后果(现状,未变)**:「EXECUTE 阶段才拿到破坏性权限」这个设计意图**只靠 prompt 话术约束,没有任何机制保障**——agent 在 RESEARCH 阶段照样能写文件,且原因是 `config.json` 给全了工具,不是技能包缺失。 另两处顺带发现(**均未修,仍然成立**):`phase` 参数**没有任何校验**,既不校验流转顺序(可以 research 直接跳 verify、跳过中间两段),也不校验取值本身,而它被直接拼进文件路径。 ### 对 P3 的影响 原来那条待办(「保证两套状态机语义不冲突」)应当升级为一个更具体、收益也更明确的目标:**把四段式改写成引擎上的一张标准图模板**。这样做同时解决三件事: 1. 消除「引擎层的图」和「agent 内部的四段式」两套并行状态机; 2. `meta-*` 技能的挂载正好对应 work graph 节点的 `system_agent_id` 引用,**顺手把 `system/skills/` 缺失这个洞补上**——现在这批技能没有读取方,改造后引擎就是读取方;**并且**(27-P3B W11 核实后补充)改造后每个阶段是一个独立节点、走一次独立的 agent 调用,`toolPolicy` 才第一次有机会**按阶段取不同的值**——这正是方案 a 单独做不到、必须绑在这项改造里一起做的那一半。 3. 阶段顺序校验由引擎的边定义天然提供,不需要另写。 **仍列为 P3 之后的待办**,不进本阶段范围。但 §五 的 T11(`workgraph-planning`)在生成图时应当**避免与四段式语义打架**——具体是别让 work graph 节点内部再套一层四段式流转,否则同一次执行会有两层 Context Flush 互相清对方的上下文。 --- ## 十、实现记录(七轮,2026-08-15 / 16) 轮次分工:**第一轮**铺地基(五张表、`advanceOne`、两种 executor、REST、画布);**第二轮**补回退熔断 / 预算 / 级联,并做 T9 的只读核实;**第三轮**修前两轮复验挖出的缺陷并把测试推到 17/17 全绿(前三轮的代码全部落在 commit `c3abf4a`);**第四轮**做逐条对账**并顺手修了一批缺陷**——原文把第四轮写成「只做收尾对账」是不实的,那一轮真改了代码:给 `defaultAgentExecutor` 挂 `onAcpEvent` 接通 `total_tokens` 的产生端、给 `graph_run_nodes` 加 `remediation_note` 列并改写 `latestRemediationNote` 的读法、让 `advanceTree` 把最深那条有信息量的失败原因带上来、给 `graph-run-sse` 的缓存加「退化值不落缓存」,改动分布在 `graph-engine.js` / `work-graph-db.js` / `graph-run-sse.js`(代码里以 `R4-F1` / `R4-F2` 标记,尚未提交)。**第五轮**把断掉的生产链路接上并按 R-17 做改名与文档纠偏——**具体接了四处**:`planWorkGraph` 真调 agent(§五 T11)、`snapshotContract` 恒落盘快照(§八.12)、`goal-contract.js` 加 CLI 并进三个 validator 模板的 `tools.md` + 20 条单测(§六 验收 1)、`describeTask` 补读 `task-goal.json` 与 `consumedNote` 补读 `node.remediationNote` 两条读回环(§八.14);这一轮的终验第一次跑通了**不注入假实现的端到端**(§六 验收 17),全量测试 713 passed。**第六轮**(本轮)分四条并行线收尾:骨架悬空引用、改名收尾、控制操作(含恢复向下级联)、以及本节记录的 **`goal-contract` CLI 吞失败原因 + 文档整体补齐到第五/六轮**(§八.20)。收尾另有一条线做 **R-17.1 返工**:归因粒度从 org node 收窄到 work graph node(跨层寻址、复用旧子 run、幂等判据换成 `child_run_attempt`),改动落在 `graph-engine.js` / `work-graph-db.js` / `NodeDetailPanel.vue` 与 §四.A.2 / §四.B.3 / §四.D.2.2 / §四.D.3 / §八.1,把 §八.1 这条挂了两轮的「已定案待返工」关掉。 > **第六轮终验(2026-08-16,只读代码 + 只改本文档,不动一行代码)**。做法:全量 `npx vitest run` + `npm run web:build` + `ls data/companies` 前后对比,外加**四个真 server + 真 HTTP + 真 `AgentManager` 的端到端脚本**(`LEMONBOSS_CLAUDE_MODE=mock`,**全程不用 `setPlanWorkGraph` / `setAgentExecutor`**;`workgraph-planning` 是真被 `AgentManager` 调起来的,只是它的 CLI 子进程被 mock 协议按 fixture 回放,`system/work-graph-templates/validator.json` 原样交回引擎落库)。跑完清掉全部测试公司(`data/companies` 20 → 28 → 20,`graph.sqlite` 里 `org_graph_*` / `work_graph*` / `graph_run*` 的测试行全部删净、`PRAGMA foreign_key_check` 为空),免得 `graph-seed-service.test.js` 这类**非 hermetic**、断言生产 `data/graph.sqlite` 的用例被污染。 > > **终验改了什么**:只改本文档,把三条并行线的 ⏳ 占位全部回填成实测结论(§五 T8/T11/T13、§六 验收 4/5/7/12/15/17、§八.13/19/21、§十 表第 9 行),并**新登记两条前几轮没有的问题**——§八.21 的 `_registry.json` 漏登记(**唯一让 CI 变红的 blocker**)与 §八.22 的跨层地址提示词反向(**四环缺陷模式第 13 例**)。同时**下调两处被写高了的对账**:`total_tokens` 的「实测递增」改成「接线已验证、数值未验证」(mock 的 `usage` 恒为 0),验收 7 的跨层链路补上「生产里 agent 目前不会产出 `target_node`」这句限定。 > > **前几轮的错误方向一直是低估**(把已成立的标成未成立);**第六轮反过来了,是高估**——所以这一轮的口径是:**每一句「实测」后面都要能说出「用什么方法、看到什么数」,说不出来的一律降档。** > 📌 **第四轮那次「文档与代码在同一轮里对不上」值得单独记一笔**:同一轮既改了代码又回填了文档,但 §四.A.3 仍把 `total_tokens` 写成「死维度 / 需要一次跨协议层改动,不属本阶段」——而那一轮的代码已经把产生端接好了。教训是**回填对账的那一段必须在代码定稿之后重跑一遍 grep**,不能拿本轮开工时的核实结论交付。 > **第七轮(2026-08-16):收最后一个洞,两条并行线。** R7-F1 修**产出方的三份系统提示词**(§八.22 那一半,让真实 `validator-triager` 被允许并被要求写 `target_node`);R7-F2(本节记录的这条)清**与实现方向相反的过期注释**——四处 `// 假设(R-16待定案): 归因保持 org node 粒度封装`(`graph-engine.js` / `graph-db.js` / `goal-contract.js` / [27-CONTRACT](./27-goal-contract-format.md))加两条与事实不符的说明注释(`treeBackwardCount` 的理由句停在 R-17.1 返工前的「每轮新建子 run」语义;`work-graph-db.validateFailFallback` 声称「供 org 层复用」,实际 org 层内联重实现了一份,成因是反向 require 会成环),并同轮回填 §八.1 / §八.19 / §八.22、§三.7 的重复实现风险、§四.A.3 的预算耗尽恢复路径。**第六轮 §八.19 那句「同轮撤掉三处过期假设注释」经核实只撤了「节点名」那半,「粒度」那半一处没动,已在 §八.19 就地改正**——这正是硬规矩第 3 条(回填对账要在定稿后重跑一遍核实)的又一次实例,只不过这次错的是**上一轮的对账**而不是本轮的。 **贯穿始终的缺陷模式,到第七轮为止已踩到 13 次**: > **某个字段或计数,在「产生 → 传递 → 落库 → 读回」四个环节里有一环被静默漏掉。** 已确认的十三处(**已修 11 处;第 13 行第七轮修复中——注释那一半已关、提示词那一半同轮 R7-F1;现存 1 处:第 8 行「有规约、未验证」**;§八 15、17、18 是同一模式的其余变体,未单列成行)。 **第 8 行与第 13 行是同一件事的两个面,值得并排看**:两者都是「引擎侧四环全通,就差 agent 真的写那个文件/那个字段」——第 8 行是**没人强制**(提示词说了、没校验),第 13 行更糟,是**提示词自己在说反话**。这也是本模式第一次出现「产生端不是漏了,而是被另一份文档明令关掉」的形态。 **第 13 例是十三例里形式最隐蔽的一个,单独记一句**:**四环都通了、还实测过,但产出方被自己的提示词禁止产出**——查表的时候前四问全都会得到「✅ 通了」的答案,缺陷藏在第五问里。所以四环清单从第 13 例起变成**五问**:产生 → 传递 → 落库 → 读回 → **产出方被允许产出吗**。 | # | 字段 / 计数 | 漏在哪一环 | 现状 | |---|---|---|---| | 1 | `org_graph_edges.fail_reason` | 列建了,`normalizeEdges` 不读、`insertEdge` 不写 → **落库** | ✅ 第三轮修 | | 2 | SSE 事件 | broker 写好了,引擎没 `require` 它,走裸 `updateNodeStatus` → **传递** | ✅ 第二/三轮修 | | 3 | 节点产物目录 | 读的一侧按约定路径去读,写的一侧没进提示词 → **产生** | ✅ 第三轮修 | | 4 | 判词节点的 `attempt-{n}.json` | 缺文件被静默当成 success,而不是「无法判定」→ **读回** | ✅ 第三轮修 | | 5 | subgraph 的 `child_run_id` | 回退重跑时没清,拿旧的终态子 run 返回 pending → **读回** | ✅ 第二轮修(清 `child_run_id` 起新子 run)→ ♻️ **第六轮按 R-17.1 换掉了这条修法**:改成复用旧子 run,幂等判据从「子 run 还活着吗」换成 `child_run_attempt`(「它服务的是本轮吗」),三条分支里没有一条会在子 run 已终态时返回 pending。有独立复现脚本背书(改回旧写法立刻变红),见 §四.D.2.2 / §八.1 | | 6 | org 层 `fail-fallback-edge` | 只在 work 层加了,org 层漏掉 → **传递**(两层同构没同步) | ✅ 第三轮修 | | 7 | `total_tokens` | 三个维度写在一起,只有它没有产出方 → **产生** | ✅ **第四轮修**(`defaultAgentExecutor` 挂 `onAcpEvent` 累加 `session/result.usage`,三条 return 路径都带 `tokens`;§四.A.3) | | 8 | `contract.json` | 判据、hash、四分类全对,没人写这个文件 → **产生** | ⚠️ **第五轮修了读回侧**:`snapshotContract` 恒落盘 `contract.prev.json` 并记 `present` 位,判据不再是死路径;**产生侧仍只有 `describeArtifactContract` 那一句提示词、无强制无验证**——状态是「有规约、未验证」,见 §八.12 | | 9 | `pause_requested` | 暂停向下级联,恢复只清自己这一层 → **传递**(单向) | ✅ **第六轮修,终验实测**:`startRunSync → resumeDescendants` 清全树标志位 + 把级联 `paused` 的后代拉回 `running`(`blocked` 后代不碰);REST 侧同轮放开「`running`+`pause_requested=1`」这一形态。实测 `resumed` 精确等于被级联暂停的子 run,全树跑到 `completed`(§八.13 / §六 验收 4) | | 10 | `remediation_note` | 只在 `graph_run_loop_records` 上有列,走 forward fail 边 / 原地重跑 / 直接 `blocked` 时判词当场蒸发 → **落库** | ✅ **第四轮修落库、第五轮修读回**:加了 `graph_run_nodes.remediation_note` 列并进 `updateNodeStatus` 白名单、`latestRemediationNote` 改读节点行、控制台 `consumedNote` 改成先读 `node.remediationNote`。**十二处里唯一走完四环全程的一条**,见 §八.14 | | 11 | `artifacts/task-goal.json` | 任务入口写了这个文件,`describeTask` 拼提示词时不读它 → **读回**(全仓库没有任何代码读过它,而 `company.js` 的注释还写着「手动模式下每次 /next 需要能把原始 task-goal 读回来」) | ✅ **第五轮修**:`describeTask` 与 `describeArtifacts` 都会读回 `runs/{rootRunId}/artifacts/task-goal.json` 的正文 | | 12 | `goal-contract` 判决的 `reason` / `detail` | 短路分支(`target_not_json` / `script_missing` / `script_escapes_artifacts`)**落盘的 verdict 里有**,CLI 人读输出只打 `score`(打成 `undefined`)、也不进 `failures` → **读回**(且是唯一一处漏在 CLI 输出层的) | ✅ **第六轮修,终验实跑 CLI 复核**:喂一份非 JSON 的被测产物,人读输出现在打出 `benchmark reason=target_not_json detail=Unexpected token…` 并在 `failures:` 下列出同形状条目(`expected` / `got` / `hint` 带 `reason`),`--json` 与落盘 verdict 三处同源、退出码 1,见 §八.20 | | **13** | R-17.1 跨层地址 `target_node` / `fail_node_ref` | 引擎四环全通(解析 → 落库 → 透传 → 解析 → 定点重置 → 账本 → REST → 控制台),`describeArtifactContract` 也注入了写法说明;**但产出方 `validator-triager` 自己的 agent.md 明写「返工落地之前你指认到 org node 为止,往里多指一层等于白指」,产出格式 JSON 里根本没有这个键**,`org-validator` / `workgraph-planning` 的「约束六」同样禁止跨层 → **产生** | ⏳ **第七轮修复中**(第六轮终验发现)。后果是静默退化:真实 agent 不产 `target_node` → `fail_node_ref` 恒 null → 每次回退都退化成整张 work graph 重跑,R-17.1 的收益在生产里拿不到。与第 3 / 第 11 行、§八.15 同型(代码全对,缺的是提示词里那一行)。**第七轮 R7-F2 已清掉四处方向相反的过期注释**(它们是 agent 与人读到的第二份反向指令);**提示词那一半由同轮 R7-F1 交付**,验收判据 = 三份模板里 `grep target_node` 有命中。见 §八.22 | **给后续开发的四条硬规矩**(第六轮终验新增第 4 条): 1. **新增任何跨环节的字段/计数,四环各写一句话在 PR 描述里**(谁产生、经谁传递、落到哪张表/哪个文件、谁读回)。十三处里有九处只要问一句「谁产生它」就能当场发现;第 10、11、12 三处则是问「**谁读回它**」才发现的——**四环要问满,不能只问产生端**。而且「读回」不止是代码读,**还包括读给人/agent 看的那一层**:第 12 处的数据一直好好躺在 verdict JSON 里,只是 CLI 没打出来,消费它的 agent 就等于没有。 **第 13 处又把这一条推进了一步**:产生端**是 agent 时,「谁产生它」的答案不是一段代码,而是一份提示词**——所以问完还要再问一句「**那份提示词现在到底怎么写的**」。第 13 处的引擎侧四环无懈可击,`describeArtifactContract` 也确实注入了写法,坏就坏在 agent 自己的 `agent.md` 里有一段**返工前写的、方向相反的**粒度说明,两份指令打架时 agent 照哪份写没人知道。 2. **测试全绿 ≠ 能跑**。P3 的引擎用例(第六轮 R-17.1 返工后 19 条)至今仍一律注入假 executor / 假 planner,第 7、8、9、10 号缺陷和当时的「`planWorkGraph` 未接线」全部在覆盖范围之外——**夹具正好绕开了缺失的那一环**。第五轮补了一条不注入假实现的端到端(§六 验收 17)才把这个洞照出来,**但它是人工脚本、没进 CI**,随时会腐坏。**凡是靠注入点验的能力,都要有一条真实路径的 smoke 进 CI 才算验收完成**。 > **2026-08-20:这条欠账已部分还上** —— `server/tests/unit/graph-real-chain-smoke.test.js` > 进了 `npm run test:unit`。做法是把「真 LLM」和「真代码路径」拆开:**只把最外层的 > Claude CLI 子进程换成 fixture 回放**(`LEMONBOSS_CLAUDE_MODE=mock`), > `setAgentExecutor` / `setPlanWorkGraph` 一个都不调,链路完整走 > `advanceOne → defaultAgentExecutor → AgentManager → ClaudeCliProtocol → onAcpEvent > → 落盘 → readNodeProduct → settleNode → spendBudget → 边界守卫`。 > > 三条断言各盯一处「假 executor 天然绕开」的接线:① `mock/files` 真的落到节点产物目录; > ② token 用量真的从 `session/result.usage` 数进预算(**就是本文档 §六 验收 10 卡了两轮的那一维**); > ③ `boundaryGuardApplicable` 在这条路径上为真(注入假 executor 时它恒为假,那条分支实际零覆盖)。 > 反向验过:断掉①的落盘、断掉②的累加,对应用例分别变红。 > > 为此改了一处 mock:`session/result.usage` 原来硬编码 `{input:0,output:0}`, > 现在 fixture 可以指定(缺省仍是 0,既有 fixture 行为不变)。**不改这一处, > token 那一维在 CI 里永远产不出数** —— §六 验收 10 当初就是卡在这儿。 > > **同日补上 `subgraph` 那一支**(④⑤):`defaultPlanWorkGraph` 是**另一段**默认实现, > 有自己的 onAcpEvent 累加和自己的守卫开关分支,只覆盖 `agent_call` 的话「规划子图」 > 那半条链仍是零真实覆盖。现在走完 `defaultPlanWorkGraph → AgentManager → 回复解析 > (extractPlannedGraph) → createWorkGraphSafely → 建子 run`,断言子 run 的定义确实来自 > fixture 那张图、且规划那一轮的 token 也进了预算。反向验过:抽掉 fixture 里的图 JSON、 > 或把它的 usage 归零,对应用例分别变红。 > 覆盖边界写在用例注释里:**跳过 `resolvePlanningAgentName` 里的按需 hire** > (预塞一行 company_agents),因为 hire 要一条真实公司记录,而那条路径另有 > `company-hire-org-layer.test.js` 盯着。 > > **还没还的部分,别当成还清了**:(a) 真 LLM 仍然进不了 CI(要额度、分钟级、输出不确定), > `scripts/real-pm-smoke.js` 仍是人工跑;(b) 多节点、回退边、熔断这些形状没进这条 smoke, > 两支各只推一个节点;(c) 按需 hire 那一段没在这条链路里走过。 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,那个数用这套终验手法根本产不出来。**「我打算这样做」和「我做完了」在文档里长得一模一样,所以回填时要逐条问「这句话我是怎么验的」,验不出来的就不要写成实测。** 4. **自己新增的 CI 防线,交付前必须亲自跑一次全量**。第六轮 R6-F1 写了 `template-refs.test.js` 来挡悬空引用,这是对的;但它同轮只做完了「建目录」、没做「登记 `_registry.json`」,于是**自己的防线抓住了自己**,`npm run test:unit` 直接变红交付出去(§八.21)。防线红了不是防线的错——**错在没跑**。加防线和让防线变绿是同一件事的两半,不能只交前半。