27-p3-p4-work-graph-console.md 177 KB

27-P3/P4. 图执行引擎与执行控制台 — 需求与设计文档

所属: 27 Graph Agent 架构 的 Phase P3(最小闭环)+ P4(回退 + 熔断),外加 27 号文档原始路线图里没有单独立项的两块新增交付物——一个执行控制台页面,以及 workgraph-planning agent + 用户→公司的任务入口。 版本: v0.3(按 27-REQ 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 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 落地 阶段 A/B。P0/P1 均已实现并提交(P1 = commit 226489d)。 命名: 全文遵循 27-NAMING,含本文档 §五 T-P2 要求新增的 validator 命名轴。 配套: 27-E2E 端到端流程、27-EXAMPLE 参考场景 设计方法论: 核心设计(§四)用 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 §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 §7.4)。必须先做同一 system agent 的多实例隔离,是独立工作量。v1 直接约束「一家公司同时只有一个 active root run」,新任务排队——注意这条约束在 org 层就需要,不是等到 work 层 fan-out 才需要。
  • 不做 L4(system agent 自我迭代)。见 27-E2E §八,且它是三级恢复叠加里最贵、爆炸半径最大的一级。
  • 不做对 DevLoop 现有 loop-controller.js / unit-registry.js 的收编。它是本架构的参考实现(27-E2E §二结论 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 + <marker> 箭头 + 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 文件)

-- ── 定义层:work graph(org graph 的两张表 P2 已建) ──────────────

CREATE TABLE IF NOT EXISTS work_graphs (
  id                TEXT PRIMARY KEY,        -- wg_<nanoid>
  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_<nanoid>
  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_<nanoid>
  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_<nanoid>
  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 §7.1 指出的最严重问题:三级恢复(跳回节点 → 重新 planning → 迭代 agent)如果每级都重置下一级的计数器,整个循环没有终止条件。分层预算(retry_budget / l2_budget)只是局部快速失败信号,不是兜底。

有了 run 层,兜底的挂载点很自然——budget_json 只在 root run 上非空,所有子 run 通过 root_run_id 扣同一个池:

{
  "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 §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 独立成页的先例):

┌──────────────────────────────────────────────────────────────────┐
│  <company name>  组织与执行        预算 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 §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 §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 §八——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 §7.1「三级恢复叠加会让熔断失效」的解法。

谁验收「验收标准」本身:按 R-9.3「planner 生成、validator 执行」,产出者和被验收者天然分离,不存在「自己给自己出考题」。但「标准写错了」这一层仍未解决,两条缓解都要:① planner-validator 明确承担一项职责——审「这套验收标准是否可度量、是否覆盖原始需求、是否存在平凡通过的漏洞」,不只审 PRD/SDD 本身;② 保留人工 sign-off 作为最终关卡,v1 阶段不做全自动闭环。


五、任务划分

五个阶段(每个可独立验收、独立上线)

切法与 27-P2 §五「三个可独立验收的阶段」一致:不起 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.jsadvanceOne / 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

  1. ✅ 生产成立(第六轮终验升档,原 🧪)。一张只有正向边、3-4 节点的图:手动模式连续点「下一步」逐节点推进到 completed,每次点击只推进一个节点(不因为下游 ready 了就自动继续)。 → 用例「连续「下一步」逐节点推进到 completed」通过;终验另在真 server 上连点 17 次 POST /:id/next(w1→w2→w3 的三节点正向图 + 两层 run 树),逐条核对每次响应里的 node 都只有一个、下游 ready 之后不会自己往下跑,直到 run 收敛。
  2. ✅ 生产成立(第六轮终验升档,原 🧪)。同一张图切自动模式点「启动」:不再需要任何人工点击,自动跑到 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)。
  3. ✅ 两半都成立(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 归零。
  4. ⚠️ 主干第六轮起有生产实测,但仍无任何自动化用例(升档:原「实现在,无用例」)。点「停止」:所有 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 的节点不被打断,其结果照常落库」。
  5. ✅ 生产成立(第六轮终验升档,原 🧪)。一张 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 层同样没验」作废)。
  6. ✅ 生产成立(第六轮终验实测,从 🧪 升档)。一个 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

  1. ✅ 生产成立(第六轮终验升档,原 🧪)。构造一个总是失败的节点,其 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。
  2. ✅ 一个节点有两条 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),四个值同源同轨、同一次条件更新落库。
  3. ✅ 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 三样都没有。
  4. ✅ 两层在生产成立、孙 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 条。
  5. ⚠️ 前半句第六轮起有生产实测、仍无用例;后半句的「不级联」分支实测成立,「级联」分支仍未验证(升档;第四轮那句「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 条没有任何自动化测试(仓库无前端测试基础设施),下面的判断是逐个组件读源码得出的。

  1. ✅(读码确认) 画布:回退边渲染为虚线曲线且显示 N/3 徽标,随 D.2.1 推进数字正确递增。 → WorkGraphCanvas.vue 里分道避让的曲线路由 + latestLoopCount(edgeId) 取同 edge_id 最新一条 loop_count,hardCap 来自路由。
  2. ⚠️ 「运行日志」tab:running 时实时追加,success/fail 后切历史视图,内容不丢。 → 双轨机制成立(NodeRunLog.vue 按 attempt 分组、live/history 同一份事件缓冲,T5 接线后确有事件流入),但流的是引擎事件不是 agent 调用内部的对话/工具记录,§四.B.3 第 3 条要的那半没落地。
  3. ✅(画布部分读码确认;「作用在子 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 空转这一形态。
  4. ✅(读码确认) 左栏点一个从未跑过 work graph 的 org node:中栏空态提示,不报错。 → GraphConsole.vue 的 empty-state 分支 + 画布自己的 #empty 插槽两层兜底。

阶段 D

  1. ✅ 端到端生产成立(第四轮的「❌ 不成立、止步于第一个节点」已作废——那根接线在第五轮接上了,见 §五 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 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 的产生 → 传递 → 落库 → 读回四环,加上「产出方被允许产出」这一前置,全部成立。

  1. 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 §3.5、本文档全文;引擎侧的 ATTRIBUTION_NODE_NAME / node.triage 事件名已改完,ATTRIBUTION_NODE_ALIASES 保留旧名做兼容。第六轮收尾了 workgraph-planning 提示词等最后一批,见 §八.19。遗留的仍未定案项只剩前缀取父节点全名还是职能(27-NAMING §3.5 第 1 行),与本条无关。
  2. 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 条。
  3. org_graph_nodes.id 是整数、work_graph_nodes.id 是文本,graph_run_nodes.def_node_id 只能取 TEXT 且无法建外键(§四.A.2 的注)。引用完整性靠服务层保证。
  4. 回退边曲线路由是真实的手写前端工程量(§四.B.2) 【已关闭,2026-08-16】 手写落地了,没引图形库:WorkGraphCanvas.vue用「按跨度分道 + 侧向绕行」处理多条回退边的避让,Kahn 分波只吃 forward 边。事后看这块的工程量确实是全部前端里最大的一块,但没有超到需要引入 dagre 的程度,当初的担心可以撤销。
  5. RunRegistry/ProcessGuard 的取消调用链没有 100% 核实(§3.6) 【已关闭,2026-08-16 T9 逐行核实】结论:硬取消做不到,「停止」定案为软停止。 ProcessGuard 在生产代码里从未被实例化(只有测试 new 它),RunRegistry.add() 从未被调用(注册表恒为空),两者之间也不存在引用关系;唯一能杀子进程的 ClaudeCliProtocol.disconnect() → SessionPool.killActive() → AgentManager.killCurrentProcess() 这条链同样零调用方,且粒度是会话而非 runId。完整证据见 §四.C「『停止』是软停止,不是硬取消」。残留风险已从「能不能取消」转为产品预期管理:用户点停止后正在跑的节点仍会跑完(最长一个节点的时长),UI 必须明示,否则会被当成 bug 报上来。
  6. v1 的「一家公司同时只有一个 active root run」是一条真实的产品限制,不只是技术妥协。它由 CONVENTIONS.md §12 的不变量和 SessionPool key 复用共同决定(§一非目标)。如果用户很快就要「一家公司并行接多个任务」,实例隔离要提前排期。
  7. workgraph-planning 的规划质量没有硬校验。§三.7 只保证图结构合法,不保证它适合这个任务。缓解手段与 P2 同款:规划记录落盘供人工复核,发现不合理走增量修正。目前还轮不到担心质量——它压根没被引擎调用过,见下方第 10 条。
  8. 阶段 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)

这一批全部是同一个模式的实例:某个字段或计数,在「产生 → 传递 → 落库 → 读回」四个环节里有一环被静默漏掉。详见 §十。按严重度排序。 划线条目 = 已关闭,保留原文的否定形式以便对照,不要因为「看起来还在列表里」就当成未关闭。

  1. planWorkGraph 生产未接线(阻断阶段 D) 【已关闭,第五轮接线;第六轮复核确认】 defaultPlanWorkGraph 现在真调 workgraph-planning(PLAN_MAX_ROUNDS=3,校验不过把 errors 原样贴回去让它自己修,耗尽轮次才抛错并把已烧的 tokens 挂在 error 上照扣),setPlanWorkGraph(fn) 降格为测试注入缝而不是「唯一的接线方式」。§六 验收 17 已端到端跑通(真 server + HTTP + 真 AgentManager)。原文那句「看起来像 agent 判失败、其实是引擎没接线」的误导性仍值得记住——它是本文档判断「实现在 ≠ 能跑」的原型案例,被 §十 硬规矩第 2 条引为依据。
  2. 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 的入口都必须一并给预算,否则整层预算兜底不存在。
  3. 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 条)。
  4. 暂停向下级联,恢复不向下级联 【已关闭,第六轮修掉并经终验实测】 原缺陷: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)。
  5. 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 写的修复建议。四环(产生 → 传递 → 落库 → 读回)到此全通,这是十二处缺陷里唯一一处走完全程的。
  6. 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 已重画好的两条边对齐。
  7. advanceTree 吞掉子 run 的失败原因 【已关闭,第四轮修掉;第五轮复核确认】 advanceTree 现在会挑出最深那条有信息量的原因往上带:逐层 advanceOne 时,凡 reason 不是 no-ready-node 的就记下来,全层跑完后连同 budget(预算闸门那条路径才有的明细)与 sourceRunId(原因来自树上哪一层)一起返回;全是 no-ready-node 才落回原来的兜底。于是「预算耗尽」「被暂停」「被停止」在 runAuto 和控制台眼里不再长得一样。
  8. agent_run_id 在生产恒为 null。defaultAgentExecutor 写的是 res.runId,而 AgentManager.callAgent 的返回形状是 {agent, output, status}。这一列建了但永远空,与 §3.6 / T9 核实到的「RunRegistry 恒为空」是同一件事的两端。不必现在修(硬取消已定案不做),但控制台不要拿它当可用的下钻锚点。
  9. 「运行日志」tab 展示的是引擎事件,不是 agent 调用内部记录(§六 验收 14)。双轨 UI 和 SSE 通道都通了,缺的是 defaultAgentExecutor 把 agent 的 stdout / 工具调用转发到 graph-run-sse 上。

第五轮新增

  1. 改名 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 的「<reflection 的 nodeRunId>」→「<validator-triager 的 nodeRunId>」;同一份文件上方已写「归因节点定名 validator-triager」、下方却用 reflection 指代同一个节点,agent 读起来会以为是两个东西。

仍保留旧名的地方只剩说明性引用:27-REQ R-16 正文的原话引用(照搬原则)、R-17.2 改名记录本身、27-NAMING §3.5「validator-reflection 是历史名」、27-GRAPH-AGENT 那处历史引用、三个 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 残留。

第六轮新增

  1. 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: 标题。

  2. 骨架悬空引用:目录补了、_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 号改造 从 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 挪出这个文件,要么接受它长期脏。
  3. 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 §「待定案」里四处 // 假设(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 §「待定案」第 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-EXAMPLE。

九bis、base agent 四段式的现状核实

27-E2E §八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 <id> --phase <next> --artifact <path> 才能换脑子换技能包;每次流转清空上下文(原文叫「斩断记忆 / 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)加两条与事实不符的说明注释(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 才算验收完成——这条欠账仍未还。
  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)。防线红了不是防线的错——错在没跑。加防线和让防线变绿是同一件事的两半,不能只交前半。