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

27-P3/P4. Work Graph 执行控制台 — 需求与设计文档

所属: 27 Graph Agent 架构 的 Phase P3(Work Graph 最小闭环)+ P4(回退 + Loop-Validator),外加一块 27 号文档原始路线图里没有单独立项的新增交付物——一个新页面:展示公司 Org Graph、以及每个 org node 当前/历史跑过的 Work Graph,并提供启动/下一步/暂停/停止/重试的执行控制、节点日志、边的输入输出、循环回退连线、主流程/异常流程可视化、子流程(agent 对话记录 + 嵌套子 Work Graph)下钻。 版本: v0.1(设计阶段,未实现) 日期: 2026-08-15 前置: 27-P2 Org Graph 落地(org_graph_nodes 是本设计里"每个 org node"的数据来源,本文档假定 P2 已落地)。P1(虚拟引用寻址)已实现并提交(commit 226489d),与本设计无强依赖,见 §九。

⚠️ v0.2 修正(2026-08-15):本文档 v0.1 有三处基于过期或不完整调研的陈述,端到端流程梳理时被推翻,动工前必读 27-E2E 端到端任务执行流程 §六与§九:

  1. §三.1 说 runProject 是 501 未实现 —— 它已完整实现(loop-controller.js:576),含 Kahn 拓扑分波 _groupUnitsByWave()、L2 闸门、Integrator、BugTriager 四分支调度、预算熔断、全套 SSE 埋点。
  2. §四.B.2 / §六 T3 把"手写分层布局算法"当成新工作量 —— web/src/components/devloop/UnitsDAG.vue 已经实现了 SVG + Kahn 分波布局 + 节点状态着色 + 点击下钻(107 行),应改为扩展既有组件。同目录下 LiveAgentStream.vue(带 Last-Event-ID 断线重连的 SSE 消费)、UnitDrawer.vue(节点详情抽屉)同样可复用。
  3. 本文档整体把"回退/循环/预算"当作全新设计 —— docs/prd/newfunction/_spec/loops-{overview,l1,l2,l3}.md + sdd-process.md P-1/P-2/P-7 已经有一套完整的三层循环规格(含四分类失败归因、差异化预算消耗、contract_hash 闸门、增量 unchanged 免重跑),本文档 §四.D 的设计应当从它抽象而来,而不是平行发明。 设计方法论:按用户要求,正文用 Model / View / Control / Process(MVCP) 四段组织核心设计(§四),前置背景与走查、后置任务划分与验收标准按仓库既有文档惯例编排。

一、目标与非目标

目标

  1. 一个新页面:给定一家公司,左侧展示它的 Org Graph(复用 27-P2 §4.7 的分层列表设计),点击任意一个 org node,右侧展示这个节点当前正在跑的 / 历史跑过的 Work Graph。
  2. 执行控制:启动、下一步、暂停、停止、重试五个操作,且要同时支持手动(每次点"下一步"才推进一个节点)和自动(一键授权后从头跑到尾,一个节点成功自动跳转执行下一个节点)两种模式——这是本文档动笔前用户明确的一条硬需求,直接决定了 §四.C 的状态机设计不能走 DevLoop 现有 runUnit() 那种"一次函数调用跑到底、外部无法中途插手"的模型(见 §三.1 走查结论)。
  3. 节点运行日志:每个 Work Graph 节点对应一次 agent 调用,要能看到这次调用的完整过程(不只是成功/失败两个字)。
  4. 边的输入输出展示:一条边连接的两个节点之间,上游节点产出了什么、下游节点实际收到了什么,要可视化出来。
  5. 循环回退连线:Loop-Validator 相关的回退边(27 号文档 §3.5/§3.4 第 4/5 点)要在图上可视化成一条区别于正常前进边的连线,并显示当前是第几次循环、硬顶还剩几次。
  6. 主流程 / 异常流程处理:节点成功后按 success 边前进是主流程;节点失败后按 fail 边路由(可能进 Loop-Validator 回退,也可能是死路进入需要人工介入的状态)是异常流程,两者在引擎状态机和 UI 上都要有明确区分。
  7. 子流程下钻:点开一个节点,能看到两类下钻内容——① 这次 agent 调用内部的对话/工具调用记录(类似现有 AgentChat.vue 的 sub-agent 面板);② 如果这个节点本身触发了一张更小的嵌套 Work Graph,能在同一个页面里逐层下钻查看子图的执行状态(用户已确认两种下钻语义都要)。

非目标(本阶段明确不做)

  • 不做 workgraph-planning agent 本身的详细设计("一张 Work Graph 的节点/边最初是怎么被规划出来的")——27 号文档 §六已经把这个交付物列在 P3 里,本文档假定图已经规划好、满足 27 号文档 §3.4 第 5 点的执行前结构校验,只负责"图规划完之后,怎么跑、怎么控制、怎么看"。这不是回避,是刻意收窄范围:规划("这张图长什么样")和执行控制("这张图怎么被跑起来、怎么被人盯着")是两个复杂度都不低的问题,硬塞进一份文档会让两边都设计得不扎实。
  • 不做对 DevLoop 现有 loop-controller.js/unit-registry.js 的收编或改造——27 号文档 §五把这个列为 P6(可选),本文档的执行引擎是全新的、只服务 Work Graph,不改 DevLoop 一行代码。§三.1 会说明为什么 DevLoop 现有引擎的执行模型不能直接套用到本文档的需求上。
  • 不做多人协同编辑同一张 Work Graph(比如两个人同时点"下一步")的并发控制之外的复杂协作特性(评论、@提醒等)——并发控制本身要做(§四.A 幂等设计),但"协作"停留在"不冲突",不做"多人分工"这类产品特性。
  • 不做跨公司/跨 Org Graph 的全局视图——页面粒度是"一家公司的一个 org node 的一张 Work Graph",不做"看所有公司所有 Work Graph"的总览大屏。
  • 不做节点执行中途的"硬中断后从中间状态精确恢复"(比如 Coder 写到一半被强杀,下次重试不是从头写而是接着上次的文件状态续写)——重试语义是"整个节点重新执行一次",不是"断点续传",见 §四.C 重试小节的说明。

二、背景

27 号文档 §六路线图把 P3 定义为"Work Graph 最小闭环:单向执行,先不做回退"(顺序执行器,success → next,fail → 停止/标记 blocked),P4 才引入回退和 Loop-Validator。本文档没有严格切两份文档去对应这两个 phase,原因是:执行引擎的状态机一旦要支持"暂停/单步/停止/重试"这种可以被外部随时打断和摆布的控制语义,"先做单向执行、再加回退"这种分阶段方式反而会让第一版的状态机设计得过窄,P4 加回退时必须回来重新改数据模型和引擎主循环。所以本文档一次性把 P3(正向执行)+ P4(回退 + Loop-Validator)的数据模型和引擎设计完,落地时仍然可以分两个迭代交付(任务划分 §六 里 T1-T6 是 P3 范围、T7-T9 是 P4 范围),但底层 schema 和状态机不需要中途推倒重来。


三、现状代码走查(决定设计边界的关键证据)

调研了仓库里两处最相关的既有实现,用于确定新引擎"该抄什么、不该抄什么"。

3.1 DevLoop runUnit():一个反面参照——展示了"为什么不能照抄"

⚠️ v0.2 修正:本节原文说"runProject 目前直接 501,注释写明 P3 才补全"——这条已过期。runProject 已完整实现(loop-controller.js:576),且它内部就是一个跨 unit 的图执行器:_groupUnitsByWave()(行 523)做 Kahn 拓扑分波、L2 闸门判定、Integrator、BugTriager 四分支调度、l2_budget 熔断。下面对 runUnit()「不可中断」的分析仍然成立(runProject 同样是一次调用跑到底),但"跨 unit 执行器不存在"这个判断是错的。另注:runProject 的波内实际是串行执行的(行 609 注释 // 串行跑(mock 同步快,不需并行)),分波结构在、并行没做。

server/src/core/devloop/loop-controller.js:345-431 的 runUnit(),本质是一个同步的 while (attempt < retry_budget) 循环:一次函数调用里,Coder → Tester → 失败则 _buildRetryContext() 拼装上次失败摘要 → 重跑,直到测试通过或耗尽预算(默认 5 次)。server/routes/devloop.js 里 POST /projects/:pid/run?only=<unitId> 直接 await loopController.runUnit(...) 再吐响应——这意味着整个 retry 循环(最多 10 次 agent 调用)全部塞在一次 HTTP 请求-响应周期里,外部在它跑完之前完全没有插手的机会。这套模型没有任何"暂停"/"单步"/"停止"的概念,runUnit() 一旦被调用,只能等它跑完或者进程崩溃。

配套的 unit-registry.js 状态机(TRANSITIONS = {pending:[coding], coding:[testing,failed], testing:[passed,failed], failed:[coding,blocked], passed:[pending], blocked:[pending]},行 30-37)设计得很干净,这部分值得直接借鉴——校验非法转换、每次合法转换 emit SSE 事件(见 3.3)——但状态机是"谁在跑循环"这件事的记录者,不是驱动者,驱动者是那个不可中断的 while 循环本身。结论:新引擎的状态机设计可以照抄 unit-registry.js 的模式(显式转换表 + 非法转换报错 + emit 事件),但主循环绝不能照抄 runUnit() 的"一次函数调用跑到底"模型。

3.2 Pipeline Service:正确的参照模型

server/services/pipeline-service.js 的 startRun()(144-181 行)只做状态机初始化(第一步 status='ready',其余 'waiting'),不会同步执行任何 agent。真正推进流程靠外部显式调用 advanceStep(runId, stepIndex, {output, status}):status:'failed' 直接把整个 run 标 failed 终止;否则把 currentStep 前移、下一步置 ready;全部完成则 status:'completed'。rejectStep() 是现成的"退回重做"语义——把某一步打回 ready,历史记入 step.rejections[]。

这正是本文档需要的执行模型:状态是持久化数据,"推进一步"是一次显式、幂等、外部可随时调用的动作,不是一个占着调用栈不放的循环。手动模式下"下一步"按钮 = 用户点一次调一次 advance;自动模式下,引擎自己在每次 advance 成功返回后,检查"还要不要继续",是就立刻自己再调一次 advance——手动和自动共用同一个 advance 原语,区别只在于"谁来触发下一次调用"(§四.C 详细展开)。

两处不能照抄的坑(写新代码时要主动避开):

  1. CompanyDashboard.vue:1019-1024 的 loadPipelineDetail() 请求的是流水线定义,不是某次 run 的实例状态,但模板里 :class="{'step-current': selectedPipeline.currentStep === idx}"(行 258)引用的 currentStep 只存在于 run 实例对象上,定义对象里根本没有——这个"当前步骤高亮"视觉效果实际上从未生效过。新页面的数据契约必须让"图的静态定义"和"某次执行的运行时状态"是两个明确分开、各自完整的对象,不能像这样把运行时字段错绑到定义对象上。
  2. retryPipelineRun()(CompanyDashboard.vue:1034-1040)调用 POST .../advance 但完全没传 stepIndex,而路由端有 if (stepIndex === undefined) return res.status(400) 的校验——现状这个"重试"按钮点了必定 400。新设计的"重试"必须是一个独立、参数自解释的端点(§四.C),不能依赖调用方正确拼出一个复用端点的隐藏参数组合。

3.3 SSE 基础设施:可以直接复用

server/services/devloop/sse-broker.js:每个项目一个广播通道,subscribe(pid, res, {replayFromOffset}) 支持基于 Last-Event-ID 的断线重放(从磁盘 stream.log 按序号过滤),emit(pid, event, data) 同时落盘 + 广播,25s 心跳防连接被中间层判死。这是一套成熟、可直接复用的多订阅者 SSE 基础设施,本文档按 work_graph_id 复用同一套实现(§四.A 的 SSE 小节)。发现的既有缺口:DevLoop 里 timeline-logger.js 的审计事件和 sse-broker.js 的实时推送是两条独立的轨,没有自动桥接——loop-controller.js 往 timeline 里追加一行,不会顺带 emit 一个 SSE 事件。本文档的引擎明确要求状态转换、timeline 写入、SSE 广播三者在同一次原子操作里一起发生(§四.A),不重复这个缺口。

3.4 子流程展示:AgentChat.vue 的双轨折叠卡片模式,直接复用

web/src/views/AgentChat.vue 第四列 "ACP Events" 面板已经实现了本文档 §四.B 需要的下钻 UI 模式:

  • subAgents(以 callId 为 key 的响应式 map)承载正在跑的子调用,每项 {agent, status:'running'|'success'|'error', content, collapsed},靠流式事件(chunk/start/done)持续追加内容、更新终态;卡片 header 是状态 emoji + agent 名 + LIVE 徽标 + 折叠箭头,展开后是等宽字体滚动日志区。
  • subLogList 是已完成/历史的日志卡片列表,外观一致但没有 LIVE 徽标。

这套"live map(进行中)+ history list(已完成)"的双轨模型,跟本文档"节点运行日志"需要的东西完全对应,直接搬过来用,不重新发明。

3.5 进程级取消能力:已经存在,但调用链需要在实现时二次确认

server/src/engine/process-guard.js 的 ProcessGuard.cancel()(82-93 行)会对底层 Claude CLI 子进程先发 SIGTERM、超时后 SIGKILL,server/src/engine/run-registry.js 的 RunRegistry 按 runId 登记每次调用(get(runId)/listByAgent(agentName)),AgentManager 是这套机制的调用方之一。这意味着"停止一个正在执行的节点"理论上可以做到真正打断子进程,不是只能等它自然跑完。但本文档写作时没有逐行确认 RunRegistry 的记录里是否直接暴露了对应 ProcessGuard 实例的引用(run-registry.js 目前看到的公开方法是 add/get/markState/touchOutput/listActive/listByBackend/listByAgent/list/remove/stats/clear,没看到 cancel——取消动作大概率是调用方自己持有 ProcessGuard 引用后直接调用,RunRegistry 只做登记和查询)。这一点列入 §六 T5 的实现前置核实项,不在设计阶段假设死。


四、设计方案(Model / View / Control / Process)

A. Model(数据模型)

A.1 表结构(在 P0/P2 的 graph.sqlite 上追加,复用同一个 DB 文件)

-- 一次具体的 Work Graph 执行实例(图的定义和运行时状态在同一行——
-- 与 §3.2 指出的"定义 vs 实例分离"原则不矛盾:Work Graph 本来就是"规划完即执行、
-- 执行完即归档"的一次性产物(27 号文档 §二术语定义),不像 Pipeline 那样"定义可以脱离某次
-- run 单独被复用",所以这里定义即实例,没有必要拆两张表)
CREATE TABLE IF NOT EXISTS work_graphs (
  id                        TEXT PRIMARY KEY,        -- wg_<nanoid>
  company_id                TEXT NOT NULL,
  triggered_by_org_node_id  INTEGER REFERENCES org_graph_nodes(id),  -- 对应"每个 org node 运行的 work graph"
  parent_node_id            TEXT REFERENCES work_graph_nodes(id),    -- 非空 = 这是另一个节点下钻出来的嵌套子图(§四.D.3)
  goal_id                   TEXT,          -- 一次人类需求描述(唯一,验收边界)
  okr_node_id               TEXT,          -- 形如 O-001.1.1
  task_id                   TEXT,          -- 形如 T-xxx
  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,  -- 0/1,auto 模式下"暂停"先置位,下一个节点边界生效
  stop_requested              INTEGER NOT NULL DEFAULT 0,  -- 0/1,同上,"停止"的软信号
  created_at                TEXT NOT NULL,
  updated_at                TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS work_graph_nodes (
  id               TEXT PRIMARY KEY,        -- wgn_<nanoid>
  work_graph_id    TEXT NOT NULL REFERENCES work_graphs(id),
  system_agent_id  TEXT NOT NULL REFERENCES system_agents(id),
  node_kind        TEXT NOT NULL DEFAULT 'task' CHECK (node_kind IN ('task','loop_validator')),
  status           TEXT NOT NULL DEFAULT 'pending',
    -- pending | ready | running | success | fail | blocked | skipped
  attempt          INTEGER NOT NULL DEFAULT 0,
  agent_run_id     TEXT,        -- 本次 attempt 对应的 RunRegistry runId,用于"停止"时定位要 cancel 哪个子进程(§三.5)
  has_subgraph     INTEGER NOT NULL DEFAULT 0,  -- 0/1,非 0 时应存在一行 work_graphs.parent_node_id = 本节点 id
  output_ref       TEXT,        -- 产出物相对路径,见 A.2 路径约定,不是 DB 存内容本身
  started_at       TEXT,
  completed_at     TEXT,
  created_at       TEXT NOT NULL,
  updated_at       TEXT NOT NULL
);

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')),
  edge_kind        TEXT NOT NULL DEFAULT 'forward' CHECK (edge_kind IN ('forward','backward')),
  created_at       TEXT NOT NULL
);

-- Loop-Validator 每次"备案-审计-修复方案传递校验"的记录(27 号文档 §3.5 三步),
-- 是"循环划线"上显示"第 N/3 次、上次判断"这类信息的数据来源,不是从 work_graph_nodes.attempt 反推
CREATE TABLE IF NOT EXISTS work_graph_loop_records (
  id                 TEXT PRIMARY KEY,
  work_graph_id      TEXT NOT NULL REFERENCES work_graphs(id),
  loop_validator_node_id TEXT NOT NULL REFERENCES work_graph_nodes(id),
  target_node_id     TEXT NOT NULL REFERENCES work_graph_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,                    -- "应该如何修复"的方案文本,供下一轮读取
  created_at         TEXT NOT NULL
);

节点/边的内容(任务描述、日志、上下文)依旧不进 DB——沿用 27 号文档 §四已经定下的原则:output_ref 只存相对路径,指向 System Agent 目录下按 §3.2 已定义的物理位置:system/agents/{agentId}/companies/{companyId}/history/{workGraphId}-{nodeId}-{attempt}.md,本文档不新造一套路径约定,直接复用。

A.2 状态机(照抄 unit-registry.js 的转换表模式,不照抄它的驱动方式)

work_graph_nodes.status 转换表:
  pending  → ready                          (上游全部满足 / 无上游依赖,进入可执行队列)
  ready    → running                        (被 advance 拾取,开始调用 agent)
  running  → success | fail                  (agent 调用结束)
  fail     → ready (loop_validator 回退命中) | blocked (无回退边/回退耗尽) | ready (人工点"重试")
  success  → (终态,除非人工"重试"强制打回 ready)
  blocked  → ready                          (人工点"重试",唯一走出 blocked 的路)
  *        → skipped                        (run 被"停止"时,所有仍处于 pending/ready 的节点)

非法转换(比如对一个 success 节点直接调"下一步"执行它)在服务层直接拒绝并返回明确错误,模式与 unit-registry.js 的 InvalidTransitionError 一致。

work_graphs.run_status 转换表:
  planning → ready         (图规划完成 + 通过 27 号文档 §3.4 第 5 点的结构校验)
  ready    → running       (点"启动")
  running  → paused         (auto 模式下点"暂停",软信号,下一个节点边界生效)
  paused   → running        (点"启动"或"下一步"恢复)
  running  → blocked         (某节点进入 blocked 且不是被 stop 打断)
  blocked  → running         (对 blocked 节点点"重试"后)
  running/paused/blocked → stopped  (点"停止")
  running  → completed       (所有可达节点都到达终态,且没有 pending/ready 的悬空节点)

A.3 幂等与并发

advance 类操作(§四.C)必须幂等:同一节点被并发触发两次"下一步"(比如自动驱动器和人工同时点),第二次调用检测到节点 status 已经不是 ready 就直接返回当前状态而不是报错——这是 §一非目标里"不做复杂协作、但要做到不冲突"的具体实现方式,不需要引入分布式锁,SQLite 的行级事务 + status 字段的 WHERE status='ready' 条件更新(受影响行数=0 即视为"已经被别人拿走了")就够。


B. View(界面设计)

B.1 页面结构

新页面(新路由,不挂在 CompanyDashboard.vue 已有的 tab 体系下——这个页面信息密度和交互复杂度明显超过一个 tab 该承载的量,参照仓库里 PipelineEditor.vue 独立成页的先例):

┌─────────────────────────────────────────────────────────────────┐
│  <company name>  组织与执行                                        │
├───────────────┬─────────────────────────────────┬─────────────────┤
│  Org Graph     │  Work Graph 画布                  │  节点详情面板      │
│  (左栏,复用     │  (选中的 org node 当前/历史        │  (点某个 work      │
│  27-P2 §4.7     │  Work Graph 的 DAG 可视化,        │  graph node 后     │
│  分层列表)       │  见 B.2)                          │  展开,见 B.3)      │
│                │                                   │                 │
│  [CTO]         │  控制栏: ▶启动 ⏭下一步 ⏸暂停       │  状态/耗时/attempt  │
│   └ [HR]       │         ⏹停止 🔁重试               │  [概览|输入输出|     │
│      └ [PM]    │                                   │   运行日志|子图]     │
│                │  ●pending ●running ●success        │                 │
│                │  ●fail ●blocked ●skipped           │                 │
│                │  ┄┄▶ 循环回退边                     │                 │
└───────────────┴─────────────────────────────────┴─────────────────┘

左栏点击某个 org node:右侧主画布加载该节点关联的 Work Graph 列表(一个 org node 生命周期里可能触发过多张 Work Graph,每张对应一次 goal/task,默认显示最新一张,提供下拉切换历史)。

B.2 Work Graph 画布

  • 节点:矩形卡片,标题是 system_agent_id(如 coder),右上角小圆点按 status 着色(沿用仓库既有色彩语义:绿=success、蓝=running、灰=pending/ready、红=fail、橙=blocked、浅灰斜纹=skipped)。loop_validator 节点用菱形(区别于普通 task 节点的矩形),视觉上直接点名"这是一个决策节点,不是执行节点"。
  • 边:edge_kind='forward' 是实线箭头;edge_kind='backward' 是虚线曲线箭头,颜色区别于前进边(比如统一用琥珀色),且在曲线中点位置渲染一个小徽标,内容取自 work_graph_loop_records 里该 (loop_validator_node_id, target_node_id) 组合最新一条记录的 loop_count,格式 2/3——这直接对应用户提出的"loop 的划线"需求:不是随便画一条回退线,是带着"当前循环到第几次、硬顶是几"这个上下文的线。
  • 布局:⚠️ v0.2 修正——不要从零写。web/src/components/devloop/UnitsDAG.vue(107 行)已经实现了完全符合这里需求的方案:SVG + <marker> 箭头 + Kahn 分波拓扑布局(waves computed,按 depends_on 逐波剥离,带 safety 上限防死循环)+ 按 status 着色的节点矩形 + 点击 emit 下钻。P3/P4 应当扩展它(加 loop_validator 菱形节点、edge_kind='backward' 的虚线曲线、循环徽标),而不是平行实现一个。仍然不引入图形库依赖——既有实现已经证明这个规模的图手写 SVG 足够。真正的新增工作量只剩回退边的曲线路由(避免与其他节点重叠),这一块是新的。
  • 主流程 vs 异常流程的视觉区分:正常的 success 前进边和节点是画布里的"主干";任何和 fail 条件相关的边、blocked 状态的节点、loop_validator 节点及其回退边,整体降低主干部分的视觉权重(比如统一给异常相关元素加一层浅色背景框),让人一眼能分清"这张图正常应该怎么走"和"这次实际上因为哪里失败绕了远路"。

B.3 节点详情面板(四个 tab)

  1. 概览:status、attempt/最大重试预算、started_at/completed_at/耗时、如果是 loop_validator 触发的回退,展示 work_graph_loop_records 里这次相关的完整记录(verdict/remediation_note)。
  2. 输入输出:读 output_ref 指向的 markdown/JSON 文件渲染展示("边的输入输出"需求的落地点——一条边的"输出"就是它 from_node 的 output_ref 内容,"输入"就是 to_node 实际执行时读取的上下文,两者在大多数正向边上是同一份内容,只有经过 Loop-Validator 回退、_buildRetryContext() 类似逻辑拼接了"上次失败摘要"的场景下才会不同——这个 tab 需要能同时展示"原始产出"和"实际被下游消费时是否被加工过"两种视图,不能假设两者总是相同)。
  3. 运行日志:复用 §3.4 的 AgentChat.vue 双轨折叠卡片模式——节点正在 running 时走 live 面板(流式追加),success/fail 之后切换成历史日志视图。这一层展示的是这次 agent 调用内部的对话/工具调用记录(用户确认的下钻语义①)。
  4. 子图:仅当 has_subgraph=1 时出现这个 tab,内容是递归复用同一个 Work Graph 画布组件,指向 work_graphs.parent_node_id = 本节点id 的那张子图,画布上方有面包屑(公司 › CTO 组织 › PM 的任务 › 子任务)支持逐层下钻和返回(用户确认的下钻语义②)。

B.4 存量/降级展示

一个 org node 从未触发过 Work Graph(P3 上线初期,或者这个角色本身不承担会触发 Work Graph 的任务):右侧主画布显示空态提示,不报错、不留白页面。


C. Control(控制操作)

操作 可用条件(run_status / execution_mode) 引擎侧动作 对"正在跑的节点"的影响
启动 run_status ∈ {ready, paused, blocked} ready→running;若 execution_mode='auto' 立即调用一次内部 advance() 并让其在每次成功后自我调度下一次(见下);若 manual,只是把 run 标成 running,停在原地等"下一步" 无(这个操作只在没有节点在跑的时候可点)
下一步 run_status='running' 且 execution_mode='manual'(或 auto 但已 paused——暂停后允许人工手动单步推进) 拾取一个当前 status='ready' 的节点(如果有多个并行 ready 的节点,一次"下一步"只推进一个,需要在 UI 上明确标出"接下来要跑哪个",避免用户以为点一下会推进所有分支),执行完(success/fail)后评估出边,把满足条件的下游节点置 ready,不继续往下跑,停在这里等下一次点击 会真正发起一次 agent 调用,不可中途取消(§三.5 的取消能力用在"停止"上,不是给"下一步"配的)
暂停 run_status='running' 且 execution_mode='auto' 置 pause_requested=1。不打断当前正在 running 的节点(agent 调用一旦发起就让它跑完,理由见下方说明),当前节点完成、准备自动调度下一个节点之前检查这个标志位,若为真则停止自我调度,run_status→paused 无——这是"软暂停",生效点是下一个节点的调度前,不是立即打断
停止 run_status ∈ {running, paused, blocked} 置 stop_requested=1;所有仍处于 pending/ready 的节点批量置 skipped;若此刻有节点正 running,尝试通过 §三.5 的机制硬取消对应子进程(取消能力需要 §六 T5 在实现前二次确认调用链,设计上按"能取消"设计,实现时如果确认做不到就退化成"等它跑完、记录结果、但不再调度后续节点");run_status→stopped 尝试硬取消,退化路径是"软停止"
重试 节点 status ∈ {fail, blocked} 把该节点 status 打回 ready,attempt+1;默认只重置这一个节点,不级联重置下游——因为下游节点在这次重试产生新结果之前根本还没执行到(fail/blocked 节点的下游按定义还是 pending),除非是"节点曾经 success 过、后来因为上游数据变化想强制重跑"这种场景才需要级联,这种场景本版本不支持(见 §八.3) 无(fail/blocked 节点本身已经不在 running)

手动/自动的统一原语:无论手动点"下一步"还是自动模式下引擎自己驱动,落到服务层都是同一个 advanceOne(workGraphId) 函数调用——区别只在于谁在调用它、调用节奏由谁决定:手动模式下调用方是收到用户点击的路由 handler,每次一下;自动模式下调用方是引擎自己的一个"节点完成回调",只要 pause_requested/stop_requested 都是假,就在上一次 advanceOne 的 promise resolve 之后立刻发起下一次。这个设计直接对应 §三.2 里"手动和自动共用同一个 advance 原语"的结论,也是本设计避免"两套并行的执行逻辑互相踩"的关键——只有一套推进逻辑,自动模式只是一个不停调用它的循环,不是另一套实现。


D. Process(主流程 / 异常流程 / 子流程处理)

D.1 主流程

节点进入 ready
  → advanceOne 拾取 → running → 调 AgentManager 执行对应 system_agent
  → 调用结束,产出写入 output_ref
  → success:遍历该节点的出边中 condition='success' 的边,目标节点若所有前置条件(可能有多条入边,
     需要"任意一条满足即可"还是"全部满足才能进入 ready"——采用"全部满足"语义,与 27 号文档 §3.4 的
     图规划约定一致,多入边节点代表"汇合点")满足,置 ready
  → 所有节点都到达终态(success/fail 被消化成 blocked 或被路由掉/skipped)且没有 ready/pending 悬空
     → work_graphs.run_status = completed

D.2 异常流程

节点 running → fail
  → 遍历出边中 condition='fail' 的边
     ├─ 存在一条 → to_node:
     │    ├─ to_node.node_kind='loop_validator'(27 号文档 §3.4 第 4 点的结构要求,非法图在规划阶段已被
     │    │    §3.4 第 5 点的执行前校验拦截,本引擎不需要再兜底校验合法性,只需要按图执行)
     │    │    → 进入 Loop-Validator 流程(D.2.1)
     │    └─ to_node 是普通 task 节点(无回退语义的显式失败分支,比如"失败就转人工审核节点")
     │         → 正常按主流程把 to_node 置 ready
     └─ 不存在 fail 边(死路)→ 该节点 status='blocked',run_status 视其余节点情况可能同步变
          'blocked'(如果没有其他节点还在 ready/running)——这是需要人工点"重试"或人工介入的终止态,
          不是系统自动判定"整个 run 失败"(区别于"停止"是人工主动终止,"blocked" 是系统探测到走不下去)

D.2.1 Loop-Validator 流程(落实 27 号文档 §3.5 的三步 + 硬顶):

  1. 备案:进入 loop_validator 节点时,插入一条 work_graph_loop_records(verdict='registered'),loop_count = 该 (validator, target) 组合已有记录数 + 1。
  2. 硬顶检查:loop_count > 3 → 不再调用 LLM 判断,直接 verdict='force_terminated',loop_validator 节点本身 status='fail'(且它没有 fail 出边或出边同样是死路),整条链路收敛到 blocked,等待人工介入。这一条判断是纯代码逻辑(比较整数),不依赖 agent 调用,保证"熔断"不会被 LLM 的自由发挥绕过。
  3. 审计 + 修复方案传递校验(loop_count ≤ 3 时才发生):调用 loop_validator 对应的 system agent(本身是一次 agent 调用,node_kind='loop_validator' 不改变"它也要 running→success/fail"这条状态机路径),产出 verdict ∈ {fixed, not_fixed} + remediation_note,写回同一条记录。fixed → 正常按 success 边继续(回到主流程);not_fixed → 按该节点的 fail 出边指向的回退目标节点(target_node_id)重新置 ready,remediation_note 作为这次重跑的上下文补充写入目标节点下一次 attempt 的 output_ref(沿用 D.1 里"产出物路径"约定,附加一段"上一轮 Loop-Validator 给出的修复建议")。

D.3 子流程(下钻)

两类下钻,数据结构上分别对应:

  • Agent 对话记录(B.3 第 3 个 tab):不建新表,直接关联现有会话持久化(server/conversations/,CLAUDE.md 已列出的既有目录)——work_graph_nodes 不需要冗余存对话内容,agent_run_id/节点执行时机足以反查到对应的会话记录,实现时确认现有会话持久化的查询接口能否直接按"这次特定 attempt 的调用"过滤(如果不能精确过滤到单次 attempt,退化到按时间窗口近似匹配,列入 §六 T4 的实现细节)。
  • 嵌套子 Work Graph(B.3 第 4 个 tab):work_graph_nodes.has_subgraph=1 的节点,触发时机是这个节点对应的 agent 在执行过程中自己判断"这个任务需要再规划一张子图"(比如一个复杂 Coder 任务内部拆成多个子步骤)——子图的创建和执行,复用本文档设计的同一套 work_graphs/advanceOne/控制操作,只是 parent_node_id 非空、triggered_by_org_node_id 可以为空(子图不是直接被某个 org node 触发的,是被上一层 work graph node 触发的)。子图有自己独立的 execution_mode/run_status,默认继承父图当前的 execution_mode(父图 auto,子图默认也 auto;用户可以在下钻进去之后单独改子图的模式,两者互不影响——即"暂停父图"不会自动暂停正在跑的子图,这是刻意的简化,见 §八.4)。

五、任务划分(Task Breakdown)

# 任务 涉及文件/模块 依赖 对应 Phase
T1 schema:work_graphs/work_graph_nodes/work_graph_edges/work_graph_loop_records 四张表,状态转换表实现(照抄 unit-registry.js 模式) server/services/graph-db.js(追加)或新文件 server/services/work-graph-db.js 27-P2 已落地(org_graph_nodes 存在) P3
T2 work-graph-engine.js:advanceOne() 核心原语(拾取 ready 节点→执行→按边推进)+ 幂等更新(A.3) server/services/work-graph-engine.js(新增) T1 P3
T3 Work Graph 画布前端组件:扩展既有 UnitsDAG.vue(Kahn 分波布局 + SVG 渲染已具备),新增 loop_validator 菱形节点、回退边虚线曲线路由、循环徽标 web/src/views/WorkGraphConsole.vue(新增)、web/src/components/devloop/UnitsDAG.vue(抽取复用或拷贝改造) T5(先有数据能画) P3
T4 节点详情面板四个 tab(概览/输入输出/运行日志/子图),运行日志 tab 复用 AgentChat.vue 的双轨折叠卡片模式 同上 + 会话记录查询接口确认 T2, T6 P3
T5 SSE:按 work_graph_id 复用 sse-broker.js 模式,状态转换 + timeline 写入 + SSE 广播三者原子化 server/services/work-graph-sse.js(新增,参照 devloop/sse-broker.js) T2 P3
T6 REST API:POST /work-graphs/:id/start、/next、/pause、/stop、/retry(node 级)、GET /work-graphs/:id(含 nodes/edges 全量状态)、GET /org-graph-nodes/:id/work-graphs(某 org node 的历史 Work Graph 列表) server/routes/work-graph.js(新增) T2 P3
T7 Loop-Validator 流程:D.2.1 三步 + 硬顶,work_graph_loop_records 读写 server/services/work-graph-engine.js 扩展 T2 P4
T8 回退边可视化:循环徽标(N/3)、异常流程视觉降权(B.2 最后一条) T3 组件扩展 T7 P4
T9 §三.5 取消能力的实现前核实 + 落地:确认 RunRegistry/ProcessGuard 的调用链,"停止"操作接入硬取消(或确认做不到、落地降级方案并更新本文档 §四.C 的表述) server/src/engine/(只读研究 + 必要时补一个查询接口,不改现有取消逻辑本身) 无(可以提前做,独立于 T1-T8) P3(阻塞"停止"的完整语义,但不阻塞其余功能先上线)
T10 测试:work-graph-engine.test.js(状态机非法转换、幂等 advance、Loop-Validator 硬顶);一条端到端脚本(建图→启动→若干次 advance→触发一次回退→硬顶终止→查最终状态) server/tests/unit/work-graph-engine.test.js(新增) T2, T7 P3+P4

顺序建议:T1→T2 是地基。T9(取消能力核实)可以最早独立开工,不阻塞其他任务,但要在 T6 的"停止"端点定稿前给出结论。T5/T6 依赖 T2,可并行。T3 依赖有真实数据可画,建议先用 T1 的 schema 手写几行测试数据跑通画布,再接 T6 的真实端点。T7/T8(P4)在 T2/T6 稳定后开始,不阻塞 P3 先上线一个"只有正向执行、没有回退"的初版(这也是为什么 §二解释了"一次设计、分两次交付")。


六、验收标准

  1. 对一张只有正向边、3-4 个节点的 Work Graph:手动模式下连续点"下一步"能逐节点推进到 completed,每次点击只推进一个节点(不会因为下游 ready 了就自动继续)。
  2. 同一张图切到自动模式点"启动":不再需要任何人工点击,自动跑到 completed。
  3. 自动模式运行中点"暂停":当前正在 running 的节点跑完后,run_status 变成 paused,不再自动调度下一个节点;此时点"下一步"能手动继续单步推进;点"启动"能恢复自动模式。
  4. 构造一个"总是失败"的节点、其 fail 边指向一个 loop_validator 节点、loop_validator 的 fail 边指回该节点自身的上游:验证第 1-3 次回退能正常触发(work_graph_loop_records 各有一行 verdict∈{fixed,not_fixed}),第 4 次触发时不再调用 agent、直接 force_terminated,图收敛到 blocked。
  5. 对一个 blocked 节点点"重试":节点回到 ready,后续能继续正常推进(不需要重建整张图)。
  6. 点"停止":所有 pending/ready 节点变 skipped,run_status 变 stopped;如果 T9 确认取消能力可用,正在 running 的节点被中断且状态被正确记录为某种可区分于正常 fail 的终止态(本设计的 skipped/stopped 组合已经覆盖,具体是否需要给"被强制取消"单独一个 status 值,留给 T9 落地时决定)。
  7. Work Graph 画布:回退边渲染为虚线曲线且显示 N/3 循环徽标,随着 D.2.1 流程推进徽标数字正确递增。
  8. 点开一个节点的"运行日志" tab:running 状态下看到实时追加的内容(对应 AgentChat.vue 的 live 面板行为),success/fail 后切换成历史视图,内容不丢失。
  9. 对一个 has_subgraph=1 的节点:出现"子图" tab,点进去是完整可交互(能点"下一步"等)的嵌套 Work Graph 画布,面包屑能返回上一层。
  10. Org Graph 左栏点击一个从未触发过 Work Graph 的 org node:右侧显示空态提示,不报错。

七、影响面 / Blast Radius

文件 改动类型
server/services/graph-db.js(或新 work-graph-db.js) 新增四张表
server/services/work-graph-engine.js 新增:状态机 + advanceOne + Loop-Validator 流程
server/services/work-graph-sse.js 新增:参照 sse-broker.js
server/routes/work-graph.js 新增:控制类 + 只读查询类端点
web/src/views/WorkGraphConsole.vue 新增:主页面
web/src/components/WorkGraphCanvas.vue(视实现拆分情况) 新增:可复用的画布组件(父图/子图共用,见 §四.D.3)
server/src/engine/(run-registry.js 相关) 只读研究为主,T9 视核实结果决定是否需要补一个小的查询接口,不改现有取消逻辑

明确不改:DevLoop 全部现有代码、27-P2 已落地的 org_graph_nodes/edges(本设计只读取,通过 triggered_by_org_node_id 引用,不写入)、CompanyDashboard.vue(新页面独立存在,不改造现有 tab,只是从 Dashboard 某处加一个跳转入口)。


八、风险 / Open Questions

  1. RunRegistry/ProcessGuard 的取消调用链没有 100% 核实(§三.5)——"停止"操作里"硬取消正在跑的节点"这条能力,设计上按能做出发,但 T9 是这个功能能不能达到设计预期的真正决定因素。如果核实后发现做不到干净取消,退化方案("停止"只影响未开始的节点,正在跑的等它自然结束)已经在 §四.C 写明,不会导致整个设计推倒重来,但用户体验会打折扣(点了停止还要再等一会)。
  2. Work Graph 画布的分层布局 + 回退边曲线路由是一块不小的手写前端工程量,没有引入图形库(符合仓库一贯风格),但意味着这部分工时不能按"随便拿个组件库拼一下"估计。如果实现过程中发现手写布局的边界情况(比如多条回退边交叉)处理成本远超预期,值得回来重新评估是否值得为此引入一个轻量布局库(如 dagre 的纯布局计算部分,不需要它的渲染层)——本文档不预先下结论,留给实现阶段视实际复杂度决定。
  3. "重试"默认不级联重置下游 ✅ v0.2 已由既有规格关闭:_spec/loops-l3.md 的增量算法已经给出标准答案——按 contract_hash 把节点分成 added / changed / removed / unchanged 四类,unchanged(id 在且 hash 不变)保持 passed、无须重跑,只有 hash 变了的才回退到 pending。也就是说"要不要级联"不该由重试动作决定,而由产出契约是否真的变了决定。P3/P4 实现时直接采用这套判据。下面保留原始论述作为背景:重试默认不级联重置下游(§四.C 表格备注)——如果实际使用中发现"节点 A 失败重试后产出变了,但 A 的下游 B 早就用旧产出跑完了"这种场景大量出现(比如 B 是在 A 曾经短暂 success 又被人工强制重跑的情况下执行的),现在的设计没有处理"重跑一个已经有下游消费过它产出的节点"这条路径。P2 阶段(本文档当前范围)刻意不支持这个场景(fail/blocked 节点的下游按定义还没跑过,不存在这个问题;真正的场景是"对一个 success 节点也允许点重试",当前设计没开放这个入口)。如果后续需要支持,涉及"下游要不要级联失效"的产品决策,留待有真实需求时再设计。
  4. 子图默认不继承父图的暂停/停止状态(§四.D.3)——"暂停父图"不会暂停正在跑的子图,是刻意简化,避免一次"暂停"要级联遍历整棵下钻树。如果实际使用中这个不一致体验造成困惑(用户以为暂停了父图,子图还在偷偷跑),需要重新评估是否要做级联传播,工作量不小(需要引擎知道"我有没有子图、子图现在什么状态",目前的 parent_node_id 只是单向引用,没有双向感知机制)。
  5. 本文档假定"图已经规划好",不覆盖 workgraph-planning agent 本身怎么工作(§一非目标已声明)——这意味着在 planning agent 真正设计出来之前,T1-T10 的验收(§六)只能用手写测试数据构造图,不能端到端验证"从一句自然语言目标到一张可执行的图"这条链路。这是刻意的范围切分,不是遗漏,但要求 27 号文档后续单独出一份"workgraph-planning agent"设计文档,两者共用本文档定义的 schema(§四.A)。

九、与其他阶段的关系

  • 与 P2(Org Graph):强依赖,triggered_by_org_node_id 直接引用 org_graph_nodes(id),本文档假定 27-P2 已经落地——如果 P2 还没上线,GET /org-graph-nodes/:id/work-graphs 这类按 org node 反查的端点没有数据可读,但 Work Graph 引擎本身(T1-T2/T7 的状态机和 Loop-Validator 逻辑)不依赖 P2 的任何具体实现细节,理论上可以先把引擎和画布用一个假的 triggered_by_org_node_id 跑通,P2 上线后再接上真实数据——两者顺序不是互相阻塞的硬依赖,只是"最终要接起来"。
  • 与 P1(虚拟引用寻址):无直接依赖,且 P1 已实现并提交(commit 226489d)。Work Graph 节点执行时调用 AgentManager 的方式不受影响(P1 改的是 prompt 字段从哪来,不改调用接口)。
  • 与端到端流程:本文档设计的是"一张 Work Graph 怎么被执行和控制",但一个真实任务会有三层图(任务级 → org node 级 → 并行子图级)。第三层之上的两层、任务入口、验收收敛条件,见 27-E2E 端到端任务执行流程。该文档同时指出本文档需要补的四项:node_kind='org_node'(让 work graph 节点能指向 org node)、fan-out 并行 + 同 agent 多实例隔离、跨层级共享的任务级总预算、三层暂停/停止的级联语义。
  • 与 27 号文档 P3/P4 原始路线图:本文档实质上把这两个 phase 的交付物做了细化和合并(理由见 §二),且额外交付了 27 号文档路线图里没有单独列出的"执行控制台页面"。建议 27 号文档 §六路线图表的 P3/P4 两行补充链接指向本文档,与 27-P2 现有的链接方式一致。