# 27-P3/P4. Work Graph 执行控制台 — 需求与设计文档 > **所属**: [27 Graph Agent 架构](./27-graph-agent-architecture.md) 的 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 落地](./27-p2-org-graph.md)(`org_graph_nodes` 是本设计里"每个 org node"的数据来源,本文档假定 P2 已落地)。P1(虚拟引用寻址)**已实现并提交**(commit `226489d`),与本设计无强依赖,见 §九。 > > ⚠️ **v0.2 修正(2026-08-15)**:本文档 v0.1 有三处基于过期或不完整调研的陈述,端到端流程梳理时被推翻,**动工前必读** [27-E2E 端到端任务执行流程](./27-e2e-task-execution-flow.md) §六与§九: > 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](./27-p2-org-graph.md) 的分层列表设计),点击任意一个 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=` 直接 `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 文件) ```sql -- 一次具体的 Work Graph 执行实例(图的定义和运行时状态在同一行—— -- 与 §3.2 指出的"定义 vs 实例分离"原则不矛盾:Work Graph 本来就是"规划完即执行、 -- 执行完即归档"的一次性产物(27 号文档 §二术语定义),不像 Pipeline 那样"定义可以脱离某次 -- run 单独被复用",所以这里定义即实例,没有必要拆两张表) CREATE TABLE IF NOT EXISTS work_graphs ( id TEXT PRIMARY KEY, -- wg_ 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_ 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` 独立成页的先例): ``` ┌─────────────────────────────────────────────────────────────────┐ │ 组织与执行 │ ├───────────────┬─────────────────────────────────┬─────────────────┤ │ 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 + `` 箭头 + **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](./27-p2-org-graph.md) 已经落地——如果 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 端到端任务执行流程](./27-e2e-task-execution-flow.md)。该文档同时指出本文档需要补的四项:`node_kind='org_node'`(让 work graph 节点能指向 org node)、fan-out 并行 + 同 agent 多实例隔离、跨层级共享的任务级总预算、三层暂停/停止的级联语义。 - **与 27 号文档 P3/P4 原始路线图**:本文档实质上把这两个 phase 的交付物做了细化和合并(理由见 §二),且额外交付了 27 号文档路线图里没有单独列出的"执行控制台页面"。建议 27 号文档 §六路线图表的 P3/P4 两行补充链接指向本文档,与 [27-P2](./27-p2-org-graph.md) 现有的链接方式一致。