# Work Graph 执行历史 Time-Travel 调试——详细设计(SDD) **日期**: 2026-08-06 **性质**: 详细设计,对应需求文档 `docs/done/work-graph-time-travel-debugging-requirements.md`(条目 D,已确认)。回答该需求文档第 8 节的开放问题。 **状态**: 设计草案,实施中,后端+控制台一起做(用户 2026-08-06 确认)。 ## 1. `ux_work_graph_run_active` 比需求文档描述的风险更小 `src/schema.py:134` 那条唯一索引是**带 WHERE 条件的部分索引**:`WHERE status IN ('planning', 'running', 'merging', 'verifying')`——'done'/'failed'/'blocked' 状态的 run 完全不受它约束。这意味着"从一个已经跑完很久的 task 分叉调试"(最常见的真实场景)根本不会撞上这条约束。 **v1 范围裁剪(跟用户讨论后确认)**:只支持从**父 run 已经到达终态**(`done`/`failed`/`blocked`)的节点分叉。这样新 fork 出的 run 完全落在这条部分索引的约束范围之外,不需要碰这条索引本身,也不需要梳理"还有哪些代码路径隐式假设了一个 task_id+org_node 只有一个活跃 run"这个更大的排查面(需求文档风险第 1 条点名的工作)。从还在跑的父 run 分叉,留给以后有真实需求再做。 ## 2. 关键设计决定:替换输出,不重新提示(解决 FR-1 的歧义) 需求文档 FR-1 原文:"人工可以指定新的输入(覆盖原 `input_payload` 的部分或全部字段),触发从这个节点开始的重新执行。" 但 `input_payload` 在现有代码里几乎总是一个薄壳(`{"node_id": ...}`)——真正的提示词内容来自调用方各自不同的机制(Planner 靠 `artifact_type` 查 `_DOC_PROMPTS`,E 的通用路径靠节点自带的 `task_prompt` 字段,F 的评审团靠运行时闭包)。要让分叉机制对这些不同调用方都通用,"覆盖 input_payload 重新触发 LLM 调用"这条路走不通,会绑死在某一种调用方约定上。 **决定**:分叉点这个节点本身**不重新执行**——它的 `output_payload` 被人工提供的新内容直接替换(验收标准 1 的例子"换一个不同的 PRD 内容"本来就是这个意思:人工已经有了想要的新内容,不是要求 Agent 重新生成一个不一样的版本)。只有**严格下游**的节点才会真的重新跑一次 `claude -p`。这个决定让分叉机制完全不需要知道原图是哪个调用方产出的、用的是哪种 `build_prompt` 约定——只需要持久化的 `graph_manifest` 和各节点的 `output_payload`,这两样任何一条 Work Graph run 都有。 ## 3. 实现机制:100% 复用 REQ-06 断点续跑,零改动执行引擎 `work_node_run.run_work_node()` 已有的逻辑:一个 `work_node_execution` 行如果 `status='done'`,直接返回持久化的 `output_payload`,不调用 `run_fn`。`work_graph_orchestrate.run_graph()`/`topological_layers()` 对此完全无感——它只是正常跑一遍拓扑分层调度,每层每个节点各自决定"是短路返回还是真的执行"是 `run_work_node()` 内部的事。 这意味着 `fork_work_graph_run()` 不需要裁剪 `graph_manifest`、不需要给 `run_graph()` 传一个"预填充的 outputs"参数(`run_graph()` 也没有这个参数,不需要新加)——**只需要在调用 `run_graph()` 之前,往新 `run_id` 下预先插入一批 `work_node_execution` 行**: - 分叉点本身:`status='done'`,`output_payload` = 人工提供的新内容。 - 分叉点的**非下游**节点(严格下游子图之外的所有节点,包括分叉点的上游和跟分叉点无关的兄弟分支):`status='done'`,`output_payload` = 原始 run 里这个节点已经产出的内容,原样复制。 - 分叉点的**严格下游**节点:什么都不插入,留给 `run_graph()` 正常调度、真实执行。 `run_graph()` 拿到的还是**完整的原始 `graph_manifest`**(不裁剪),对每个节点走到 `_ensure_node_row()` 时,发现 `(new_run_id, node_id)` 已经有行了(我们预先插的),直接复用那个 `node_exec_id`;`run_work_node()` 一看 `status='done'`,立刻短路返回——这条路径是 `RunGraphHappyPathTest.test_resumes_without_rerunning_done_nodes` 已经在测的行为,不是新写的逻辑。严格下游的节点走的是全新的 `node_exec_id`,正常执行,`build_prompt(node, outputs)` 拿到的 `outputs` 里已经有了(无论是复用来的还是刚替换的)上游内容——这跟"进程重启后 resume"时 `outputs` 字典的填充方式完全一样,天然支持 G 的并发调度(如果严格下游子图里有互不依赖的节点,一样会被分进同一层并发执行,不需要额外代码)。 `_downstream_node_ids(graph_manifest, source_node_id)`:从 `depends_on`/`edges` 正向遍历依赖图(复用 `topological_layers()` 已经在用的同一套 dependents 构建逻辑,抽出来共享,不重复实现一遍)。 ## 4. Schema 改动 `work_graph_run` 新增两个可空字段:`parent_run_id`(指向被分叉出去的原始 run)、`fork_of_node_exec_id`(指向具体是从哪个节点分叉的)。纯新增字段,**不改** `ux_work_graph_run_active`(见第 1 节,v1 范围内不需要)。新数据库直接建在 `SCHEMA_SQL` 里;已有数据库用 `migrate_add_work_graph_fork_columns()`(跟 `migrate_expand_artifact_types` 同一个先例,但更简单——纯 `ALTER TABLE ADD COLUMN`,不需要建新表复制数据那套,因为不是改 CHECK 约束)。 ## 5. 查询层 + 控制台(FR-4) `work_graph_run_query.list_work_graph_runs`/`get_work_graph_run`、`task_query.get_task` 的 `work_graph_runs` 列表,都加上 `parent_run_id`/`fork_of_node_exec_id` 两个字段——前端靠这两个字段判断"这条是原始记录还是分叉分支",以及"分叉自哪一条"。 控制台改动(`frontend/app.js`): - 运行历史列表/任务详情页的每条 `work_graph_run` 卡片,`parent_run_id` 非空时显示一个"🔀 从 `` 分叉自 ``"的标记,点击可跳转到父 run。 - 节点详情面板(已有的 `renderGraphVisualizerPanel`/节点 hover 面板)里,`status='done'` 的节点新增一个"从这里分叉"按钮,点开一个表单填新的 output 内容(JSON),提交调用新端点。 - 不做 FR-5 提到的"设为主线"之类的合并操作——需求文档非目标 #3 已经说了第一版不做 diff/合并 UI,只读展示 + 触发分叉。 ## 6. HTTP 端点 `POST /api/work-node-executions/:node_exec_id/fork`,body `{"new_output_payload": {...}}`,返回 `{"run_id", "outputs"}`。放在 `backend_server.py` 现有的路由风格里(正则匹配路径 + 方法分派,参考已有的 `/api/tasks/:id/...` 系列端点)。 ## 7. 不做的部分(如实记录) - **从还在跑的父 run 分叉**(第 1 节裁剪掉的部分)。 - **FR-5 的"设为主线"交互**——只读展示分叉分支,人工另外决定怎么处理分叉产物(需求文档非目标 #3 已经说了 diff/比较视图留给后续)。 - **分叉分支的生命周期/清理策略**(需求文档 §8 开放问题最后一条)——先不清理,跟原始执行日志的保留策略是两个独立的问题,等真的堆积成问题再处理。 ## 8. 回答需求文档第 8 节开放问题 - `parent_run_id`/`fork_of_node_exec_id` 具体 schema:见第 4 节,纯新增可空字段,不动唯一索引(因为 v1 范围裁剪掉了会冲突的场景,见第 1 节)。 - 触发入口:控制台 + 一个后端 API(`POST /api/work-node-executions/:node_exec_id/fork`),两者复用同一个 `fork_work_graph_run()`。 - FR-5 交互:不做"设为主线"按钮,纯只读展示(见第 5、7 节)。 - 生命周期/清理策略:本轮不做(见第 7 节)。