27-P3/P4. 图执行引擎与执行控制台 — 需求与设计文档
所属: 27 Graph Agent 架构 的 Phase P3(最小闭环)+ P4(回退 + 熔断),外加 27 号文档原始路线图里没有单独立项的两块新增交付物——一个执行控制台页面,以及
workgraph-planningagent + 用户→公司的任务入口。 版本: 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-contractCLI 的人读输出不再吞失败原因——短路分支(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(attempt0→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.js364→375、work-graph-db.js827→846,planWorkGraph的throw、startRunSync的位置全部顶偏)。 结论本身都是对的,坏的只是坐标——所以本轮不逐个去追行号,而是换成抗漂移的表述。 少数确实需要行号才说得清的地方(如「12 处状态转换分别在哪」),保留并标注「截至 commitc3abf4a」, 读的时候按函数名去 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 推翻,改为向下级联。
一、目标与非目标
目标
- 一套图执行引擎,同时驱动 org graph(company 级 workflow)和 work graph(org node 级 workflow)—— 两者是同一种东西在不同层级的递归(R-9.1),引擎不为它们各写一套。
- 执行控制:启动、下一步、暂停、停止、重试五个操作,同时支持手动(点一次推进一个节点)和自动(一键授权跑到底)两种模式,且在每一层语义完全一致。
- 回退与熔断:
edge_kind='backward'的回退边带循环计数与硬顶,两层通用;跨所有层级共享一个任务级总预算。 - 一个执行控制台页面:左栏 org graph,中间选中 org node 的 work graph 画布,右栏节点详情;回退边可视化带
N/3徽标;主流程 / 异常流程视觉区分;逐层下钻(agent 对话记录 + 嵌套子图)。 - 验收契约:先定义
goal-benchmark/goal-test-data/goal-test-script三种产物的统一格式并建执行器,再动引擎(R-15.6)。 - 规划闭环:
workgraph-planningagent + 用户→公司的任务入口,让「一句自然语言 → 一张可执行的图 → 跑完」端到端成立(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() 是现成的「退回重做」语义。
这正是本文档需要的执行模型:状态是持久化数据,「推进一步」是一次显式、幂等、外部可随时调用的动作,不是占着调用栈不放的循环。手动模式下「下一步」= 用户点一次调一次;自动模式下 = 引擎自己在上一次返回后立刻再调一次。手动和自动共用同一个原语,区别只在谁触发下一次调用。
两处不能照抄的坑:
CompanyDashboard.vue的loadPipelineDetail()请求的是流水线定义,但模板里:class="{'step-current': selectedPipeline.currentStep === idx}"引用的currentStep只存在于 run 实例上——这个高亮效果从未生效过。→ 本设计的定义层与运行时层必须是两个各自完整、明确分开的对象(§四.A)。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协议会 emitsession/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)
- 概览:
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)。 - 输入输出:渲染
output_ref指向的文件。「原始产出」和「实际被下游消费时的形态」要能分别展示——大多数正向边上两者相同,但经过回退、拼接了「上一轮 remediation_note」之后就不同了,不能假设总是相同。若该节点读过artifacts/,一并列出读了哪些。 - 运行日志:复用 §3.4 的双轨折叠卡片——
running时走 live 面板流式追加,success/fail后切换成历史视图。展示的是这次 agent 调用内部的对话/工具调用记录。 - 子图:仅当
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 边推进时,无条件执行:
- 备案:按
(run_id, edge_id)查graph_run_loop_records已有记录数 n,loop_count = n + 1,插入一条verdict='registered';同时graph_runs.backward_count += 1。 - 双层硬顶检查:
loop_count > 3(单边)或backward_count > 5(run 级总数)→ 不再调用任何 LLM,直接改verdict='force_terminated',源节点blocked,链路收敛,等人工介入。这一条是纯整数比较,不依赖 agent 调用——保证熔断不会被 LLM 的自由发挥绕过。 - 回退:两个计数都未撞顶时把
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.js19 条全绿(原「回退后 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.jsondeep-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 无独立测试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
- ✅ 过(第四轮的「实现在、从没被跑过」已作废)。手写一份
goal-benchmark+goal-test-data+goal-test-script,执行器能对一个被测产物跑出明确的通过/不通过判决,判决落库可查。 →server/tests/unit/goal-contract.test.js20 条用例实跑通过,其中一条按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
- ✅ 生产成立(第六轮终验升档,原 🧪)。一张只有正向边、3-4 节点的图:手动模式连续点「下一步」逐节点推进到
completed,每次点击只推进一个节点(不因为下游 ready 了就自动继续)。 → 用例「连续「下一步」逐节点推进到 completed」通过;终验另在真 server 上连点 17 次POST /:id/next(w1→w2→w3的三节点正向图 + 两层 run 树),逐条核对每次响应里的node都只有一个、下游 ready 之后不会自己往下跑,直到 run 收敛。 - ✅ 生产成立(第六轮终验升档,原 🧪)。同一张图切自动模式点「启动」:不再需要任何人工点击,自动跑到
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)。 - ✅ 两半都成立(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],随后 rootrunning/pause_requested=1、子 runpaused/pause_requested=1;POST /:root/start {mode:auto}回 200 且resumed精确等于[子 run id];几秒内整棵树(root + 两张 work 子图)自己跑到completed,全树pause_requested归零。 - ⚠️ 主干第六轮起有生产实测,但仍无任何自动化用例(升档:原「实现在,无用例」)。点「停止」:所有
pending/ready节点变skipped,run_status → stopped——stopRun逐层做了这件事。终验实测:对一张正在跑的子 runPOST /stop回 200 且runStatus: 'stopped';父节点没有卡在running(settleRun → propagateToParent当场把它结算掉),整棵树随后收敛,不会因为「停掉一张子 run」而死锁。另外顺带验到「stopped是绝对终态」:对一张已completed的 run 点停止是明确的 400(allowed: [running, paused, blocked]),不是静默成功。仍没验的:pending/ready→skipped这一步没有单独断言过;仍没有任何测试覆盖这条。后半句「若 T9 确认取消可用,正在 running 的节点被中断」已被 T9 的核实否掉(硬取消调用链不存在,§四.C)——这半句作废,正确的验收是「正在 running 的节点不被打断,其结果照常落库」。 - ✅ 生产成立(第六轮终验升档,原 🧪)。一张 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 层同样没验」作废)。 - ✅ 生产成立(第六轮终验实测,从 🧪 升档)。一个
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.attempt0→3、wg-test.attempt恒为 0 且状态恒success(contract_hashunchanged 的下游一步都没重跑)、账本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
- ✅ 生产成立(第六轮终验升档,原 🧪)。构造一个总是失败的节点,其
fail边是指回上游的backward边:第 1-3 次回退正常触发(graph_run_loop_records各一行),第 4 次不再调用任何 agent、直接force_terminated,图收敛到blocked。 → 用例通过,且断言了「熔断之后 agent 调用次数不再增加」。终验实测:真 server 上org-validator → org-coder这条 backward 边连打四轮,账本恰好四行(loop_count1/2/3 =registered,第 4 行 =force_terminated),root run 收敛到blocked,backward_count = 4。 - ✅ 一个节点有两条
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),四个值同源同轨、同一次条件更新落库。 - ✅
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三样都没有。 - ✅ 两层在生产成立、孙 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 条。 - ⚠️ 前半句第六轮起有生产实测、仍无用例;后半句的「不级联」分支实测成立,「级联」分支仍未验证(升档;第四轮那句「
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 条没有任何自动化测试(仓库无前端测试基础设施),下面的判断是逐个组件读源码得出的。
- ✅(读码确认) 画布:回退边渲染为虚线曲线且显示
N/3徽标,随 D.2.1 推进数字正确递增。 →WorkGraphCanvas.vue里分道避让的曲线路由 +latestLoopCount(edgeId)取同edge_id最新一条loop_count,hardCap来自路由。 - ⚠️ 「运行日志」tab:
running时实时追加,success/fail后切历史视图,内容不丢。 → 双轨机制成立(NodeRunLog.vue按 attempt 分组、live/history 同一份事件缓冲,T5 接线后确有事件流入),但流的是引擎事件不是 agent 调用内部的对话/工具记录,§四.B.3 第 3 条要的那半没落地。 - ✅(画布部分读码确认;「作用在子 run 上」R6 起真 server 实测)
subgraph节点的「子图」tab 点进去是完整可交互的嵌套画布,面包屑能逐层返回。 →NodeDetailPanel.vue的subtab 先给只读缩略图,「进入子图」$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)→ 对子 runPOST /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空转这一形态。 - ✅(读码确认) 左栏点一个从未跑过 work graph 的 org node:中栏空态提示,不报错。 →
GraphConsole.vue的empty-state分支 + 画布自己的#empty插槽两层兜底。
阶段 D
- ✅ 端到端生产成立(第四轮的「❌ 不成立、止步于第一个节点」已作废——那根接线在第五轮接上了,见 §五 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
归因粒度:【已定案,待返工】(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 的产生 → 传递 → 落库 → 读回四环,加上「产出方被允许产出」这一前置,全部成立。
【已关闭,R-17.2 定案】 定名validator-triager的命名尚未定案(R-16 遗留)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 行),与本条无关。【缓解已落地,2026-08-16】 引擎只做字符串匹配,取值由图的规划者和 validator 自行约定;风险(归因产出一个图上没有对应边的取值)由fail_reason的取值域不由引擎约束(§四.D.2.2)fail-fallback-edge校验兜住,org / work 两层现均已实装(org-graph-service.validateGraph+work-graph-db.validateFailFallback),且org_graph_edges的唯一索引已改成含COALESCE(fail_reason,''),让「精确 fail 边 + 兜底边」能并存。残留的是提示词侧,见下方第 15 条。org_graph_nodes.id是整数、work_graph_nodes.id是文本,graph_run_nodes.def_node_id只能取 TEXT 且无法建外键(§四.A.2 的注)。引用完整性靠服务层保证。回退边曲线路由是真实的手写前端工程量(§四.B.2)【已关闭,2026-08-16】 手写落地了,没引图形库:WorkGraphCanvas.vue用「按跨度分道 + 侧向绕行」处理多条回退边的避让,Kahn 分波只吃 forward 边。事后看这块的工程量确实是全部前端里最大的一块,但没有超到需要引入 dagre 的程度,当初的担心可以撤销。【已关闭,2026-08-16 T9 逐行核实】结论:硬取消做不到,「停止」定案为软停止。RunRegistry/ProcessGuard的取消调用链没有 100% 核实(§3.6)ProcessGuard在生产代码里从未被实例化(只有测试new它),RunRegistry.add()从未被调用(注册表恒为空),两者之间也不存在引用关系;唯一能杀子进程的ClaudeCliProtocol.disconnect()→SessionPool.killActive()→AgentManager.killCurrentProcess()这条链同样零调用方,且粒度是会话而非 runId。完整证据见 §四.C「『停止』是软停止,不是硬取消」。残留风险已从「能不能取消」转为产品预期管理:用户点停止后正在跑的节点仍会跑完(最长一个节点的时长),UI 必须明示,否则会被当成 bug 报上来。- v1 的「一家公司同时只有一个 active root run」是一条真实的产品限制,不只是技术妥协。它由
CONVENTIONS.md§12 的不变量和 SessionPool key 复用共同决定(§一非目标)。如果用户很快就要「一家公司并行接多个任务」,实例隔离要提前排期。 workgraph-planning的规划质量没有硬校验。§三.7 只保证图结构合法,不保证它适合这个任务。缓解手段与 P2 同款:规划记录落盘供人工复核,发现不合理走增量修正。目前还轮不到担心质量——它压根没被引擎调用过,见下方第 10 条。- 阶段 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)
这一批全部是同一个模式的实例:某个字段或计数,在「产生 → 传递 → 落库 → 读回」四个环节里有一环被静默漏掉。详见 §十。按严重度排序。 划线条目 = 已关闭,保留原文的否定形式以便对照,不要因为「看起来还在列表里」就当成未关闭。
【已关闭,第五轮接线;第六轮复核确认】planWorkGraph生产未接线(阻断阶段 D)defaultPlanWorkGraph现在真调workgraph-planning(PLAN_MAX_ROUNDS=3,校验不过把errors原样贴回去让它自己修,耗尽轮次才抛错并把已烧的tokens挂在 error 上照扣),setPlanWorkGraph(fn)降格为测试注入缝而不是「唯一的接线方式」。§六 验收 17 已端到端跑通(真 server + HTTP + 真AgentManager)。原文那句「看起来像 agent 判失败、其实是引擎没接线」的误导性仍值得记住——它是本文档判断「实现在 ≠ 能跑」的原型案例,被 §十 硬规矩第 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 的入口都必须一并给预算,否则整层预算兜底不存在。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 条)。暂停向下级联,恢复不向下级联【已关闭,第六轮修掉并经终验实测】 原缺陷: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)。【已关闭,第五轮把最后一环接上;第六轮复核确认】(第五轮把原条目一分为三,原文「只在 backward 边落库」已不准确)remediation_note的四环有一环没接- ✅ 落库已修:第四轮给
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写的修复建议。四环(产生 → 传递 → 落库 → 读回)到此全通,这是十二处缺陷里唯一一处走完全程的。
- ✅ 落库已修:第四轮给
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已重画好的两条边对齐。【已关闭,第四轮修掉;第五轮复核确认】advanceTree吞掉子 run 的失败原因advanceTree现在会挑出最深那条有信息量的原因往上带:逐层advanceOne时,凡reason不是no-ready-node的就记下来,全层跑完后连同budget(预算闸门那条路径才有的明细)与sourceRunId(原因来自树上哪一层)一起返回;全是no-ready-node才落回原来的兜底。于是「预算耗尽」「被暂停」「被停止」在runAuto和控制台眼里不再长得一样。agent_run_id在生产恒为null。defaultAgentExecutor写的是res.runId,而AgentManager.callAgent的返回形状是{agent, output, status}。这一列建了但永远空,与 §3.6 / T9 核实到的「RunRegistry恒为空」是同一件事的两端。不必现在修(硬取消已定案不做),但控制台不要拿它当可用的下钻锚点。- 「运行日志」tab 展示的是引擎事件,不是 agent 调用内部记录(§六 验收 14)。双轨 UI 和 SSE 通道都通了,缺的是
defaultAgentExecutor把 agent 的 stdout / 工具调用转发到graph-run-sse上。
第五轮新增
改名【已关闭,第六轮修掉】 第五轮发现时的判断(与 §八.15 同型,四环缺陷模式的第 11 个变体)成立且低估了严重度:生产里真正产出 work graph 的是validator-triager后,workgraph-planning的提示词里仍是旧名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 残留。
第六轮新增
【已关闭,第六轮修掉】 这是四环缺陷模式的第 12 个实例,也是第一个漏在「读回」且落在 CLI 输出上的。goal-contractCLI 吞掉失败原因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:标题。骨架悬空引用:目录补了、
_registry.json没登记,CI 因此是红的 🔴 【现存 blocker,第六轮终验回填】- 控制操作那条线已关闭:§四.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挪出这个文件,要么接受它长期脏。
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.jsderiveChildOutcome上方(改成「R-17.1 已定案,父层连fail_node_ref一起拿走,可定点重置」)、graph-db.js文件头(改成指向graph-engine.js「跨层节点地址」一节的实话注)、goal-contract.jsexecuteContract里(改成陈述分工:判决只回答「达没达成」因为它手上没有图的信息,定点由下游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兼容分支,属合理保留,别一起清。
- 4 处在提示词里(
- ⏳ 提示词那一半(
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 换技能包 | ❌ 静默失效 | agent-chat.js Hot Swap 段 |
失效原因:代码是 const sourceSkill = .../system/skills/meta-${phase}/SKILL.md 后跟 if (fs.existsSync(sourceSkill)) 才拷贝,而 system/skills/ 这个目录在仓库里根本不存在,四个 meta-* 技能一个都没有。所以每次流转的「动态换脑」是空转,那句 🧠 动态技能树已切换 的日志压根不会打印,也没有任何报错。
后果:「EXECUTE 阶段才拿到破坏性权限」这个设计意图目前只靠 prompt 话术约束,没有任何机制保障——agent 在 RESEARCH 阶段照样能写文件。
另两处顺带发现:phase 参数没有任何校验,既不校验流转顺序(可以 research 直接跳 verify、跳过中间两段),也不校验取值本身,而它被直接拼进文件路径。
对 P3 的影响
原来那条待办(「保证两套状态机语义不冲突」)应当升级为一个更具体、收益也更明确的目标:把四段式改写成引擎上的一张标准图模板。这样做同时解决三件事:
- 消除「引擎层的图」和「agent 内部的四段式」两套并行状态机;
meta-*技能的挂载正好对应 work graph 节点的system_agent_id引用,顺手把system/skills/缺失这个洞补上——现在这批技能没有读取方,改造后引擎就是读取方;- 阶段顺序校验由引擎的边定义天然提供,不需要另写。
仍列为 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/companies20 → 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 条):
- 新增任何跨环节的字段/计数,四环各写一句话在 PR 描述里(谁产生、经谁传递、落到哪张表/哪个文件、谁读回)。十三处里有九处只要问一句「谁产生它」就能当场发现;第 10、11、12 三处则是问「谁读回它」才发现的——四环要问满,不能只问产生端。而且「读回」不止是代码读,还包括读给人/agent 看的那一层:第 12 处的数据一直好好躺在 verdict JSON 里,只是 CLI 没打出来,消费它的 agent 就等于没有。
第 13 处又把这一条推进了一步:产生端是 agent 时,「谁产生它」的答案不是一段代码,而是一份提示词——所以问完还要再问一句「那份提示词现在到底怎么写的」。第 13 处的引擎侧四环无懈可击,
describeArtifactContract也确实注入了写法,坏就坏在 agent 自己的agent.md里有一段返工前写的、方向相反的粒度说明,两份指令打架时 agent 照哪份写没人知道。 - 测试全绿 ≠ 能跑。P3 的引擎用例(第六轮 R-17.1 返工后 19 条)至今仍一律注入假 executor / 假 planner,第 7、8、9、10 号缺陷和当时的「
planWorkGraph未接线」全部在覆盖范围之外——夹具正好绕开了缺失的那一环。第五轮补了一条不注入假实现的端到端(§六 验收 17)才把这个洞照出来,但它是人工脚本、没进 CI,随时会腐坏。凡是靠注入点验的能力,都要有一条真实路径的 smoke 进 CI 才算验收完成——这条欠账仍未还。 回填对账的段落必须在本轮代码定稿之后重跑一遍核实。这条被连续犯了三轮:第四轮代码把
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,那个数用这套终验手法根本产不出来。「我打算这样做」和「我做完了」在文档里长得一模一样,所以回填时要逐条问「这句话我是怎么验的」,验不出来的就不要写成实测。自己新增的 CI 防线,交付前必须亲自跑一次全量。第六轮 R6-F1 写了
template-refs.test.js来挡悬空引用,这是对的;但它同轮只做完了「建目录」、没做「登记_registry.json」,于是自己的防线抓住了自己,npm run test:unit直接变红交付出去(§八.21)。防线红了不是防线的错——错在没跑。加防线和让防线变绿是同一件事的两半,不能只交前半。