work-graph-time-travel-debugging-design.md 7.75 KB

Work Graph 执行历史 Time-Travel 调试——详细设计(SDD)

日期: 2026-08-06 性质: 详细设计,对应需求文档 docs/todo/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 非空时显示一个"🔀 从 <fork_of_node_exec_id 对应的 node_id> 分叉自 <parent_run_id 前 8 位>"的标记,点击可跳转到父 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 节)。