Commit 836e16f64882a2a00f1f2a23575029f42f2c75bc

Authored by bboonnee
1 parent 762ba779

D: Work Graph time-travel debugging (fork from a historical node)

Implements docs/todo/work-graph-time-travel-debugging-design.md, backend
and console together (user confirmed 2026-08-06).

Key finding that shrank the v1 scope: ux_work_graph_run_active is a
PARTIAL unique index (only covers non-terminal statuses), not a blanket
one -- forking from a parent run that's already reached a terminal status
never collides with it at all. v1 restricts forking to exactly that case,
so no index/constraint change is needed, only two new nullable columns
(parent_run_id, fork_of_node_exec_id).

Resolved the requirements doc's FR-1 ambiguity ("override input_payload,
re-trigger execution") as OUTPUT SUBSTITUTION instead: the fork point's
own output_payload is replaced with the human-supplied content directly,
it is never re-executed via a new claude -p call -- only the strict
downstream subgraph actually re-runs. This sidesteps reconciling the
different build_prompt/input_payload conventions each caller uses
(Planner's artifact_type lookup, E's generic task_prompt field, F's jury)
-- work_graph_fork.py only needs the persisted graph_manifest and each
node's output_payload.

Implementation needs ZERO changes to the execution engine: pre-seeding
every non-downstream node as a 'done' work_node_execution row before
calling the unmodified run_graph() on the unmodified graph_manifest is
enough -- run_work_node()'s existing REQ-06 breakpoint-recovery
short-circuit (already-'done' nodes return their persisted output without
calling run_fn) does all the "reuse without re-executing" work for free.
_reconstruct_build_prompt supports two graph shapes generically from
persisted state alone (Planner's canonical doc chain via
e2e_pipeline._planner_prompt_builder, and E's generic task_prompt field);
anything else raises ForkGraphShapeUnsupportedError rather than producing
broken prompts.

New: src/work_graph_fork.py (create_fork/execute_fork/fork_work_graph_run/
start_fork_in_background, the last splitting fast DB-only setup from slow
real execution so an HTTP caller gets run_id back immediately, same
pattern task_execution.start_task_execution_in_background already uses).
work_graph_run gets parent_run_id/fork_of_node_exec_id (SCHEMA_SQL +
migrate_add_work_graph_fork_columns for existing databases).
work_graph_run_query.py/task_query.py surface both fields. New endpoint
POST /api/work-nodes/:node_exec_id/fork. Console: a "从这里分叉" form on
each 'done' node in v_run_detail, a fork badge linking back to the parent
run, and a 🔀 marker on v_graph_console's run list -- no diff/merge UI
(non-goal #3), read-only branch visibility only.

Verified end-to-end for real: started the actual backend, sent a real
HTTP fork request, confirmed the new run persisted with correct
parent_run_id/fork_of_node_exec_id lineage.

Tests cover requirements doc acceptance standards 1-3 (PRD swapped ->
SDD/Benchmark genuinely re-execute, BRD untouched; original run completely
unaffected; the same node forked twice produces independent branches) plus
schema migration idempotency, query-layer field surfacing, the HTTP route,
and 3 frontend smoke-test assertions. Full src suite (608 tests) + all 3
frontend smoke tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/todo/graph-engineering-open-items.md
... ... @@ -62,7 +62,7 @@
62 62  
63 63 ## D. Time-travel 调试要不要立项
64 64  
65   -**状态**:✅ 已确认立项
  65 +**状态**:✅ 已实现(2026-08-06)→ 需求文档 `docs/todo/work-graph-time-travel-debugging-requirements.md` + 设计文档 `docs/todo/work-graph-time-travel-debugging-design.md`
66 66  
67 67 **来源**:综述版对照 §6
68 68  
... ... @@ -70,7 +70,9 @@
70 70  
71 71 **确认结果(2026-08-05)**:立项。
72 72  
73   -**待办**:✅ 需求文档已产出 → `docs/work-graph-time-travel-debugging-requirements.md`。已明确跟 Deploy Supervisor 自动回滚(`docs/done/supervisor-auto-rollback-design.md`)的命名消歧。最大的数据模型影响点:`work_graph_run` 表现有的 `ux_work_graph_run_active` 唯一约束(同一 `task_id`+`org_node` 同时只能有一个非终态 run)会挡住分叉分支,需要在设计阶段决定怎么扩展。
  73 +**待办**:✅ 已实现(2026-08-06,后端+控制台一起做,用户确认)。核心发现:`ux_work_graph_run_active` 唯一索引是**带 WHERE 条件的部分索引**(只约束非终态 run),比背景描述的风险小——v1 裁剪成只支持从**已终态**的父 run 分叉,完全绕开这条约束,不需要碰它。FR-1 的"覆盖 input_payload 重新触发执行"的歧义,最终决定成**替换输出、不重新提示**(分叉点本身不重新跑 `claude -p`,只把它的 `output_payload` 换成人工提供的新内容,只有严格下游节点真的重新执行)——设计文档第 2 节记录了为什么这样解决了跨调用方约定不统一的问题。实现依赖**零改动执行引擎**:预先把非下游节点插入成 `status='done'` 的 `work_node_execution` 行,`run_work_node()` 已有的 REQ-06 断点续跑短路逻辑自动完成"复用而不重跑",`run_graph()`/`topological_layers()` 一行都没动。
  74 +
  75 +新增:`src/work_graph_fork.py`(`create_fork`/`execute_fork`/`fork_work_graph_run`/`start_fork_in_background`,`_reconstruct_build_prompt` 支持 Planner 固定文档链和 E 的通用 `task_prompt` 两种图形状,其余形状明确报错而不是产出错误提示词);`work_graph_run`新增 `parent_run_id`/`fork_of_node_exec_id`(`migrate_add_work_graph_fork_columns` 迁移已有数据库);查询层(`work_graph_run_query.py`/`task_query.py`)透出这两个字段;新 HTTP 端点 `POST /api/work-nodes/:node_exec_id/fork`(`_handle_task_execute` 同款"同步创建+后台真实执行"模式);控制台 `v_run_detail` 新增"从这里分叉"表单(仅 `done` 节点)+ 分叉徽标(链接回父 run),`v_graph_console` 的运行列表用 🔀 标出分叉分支。真实跑通了一次端到端验证(启动真实 backend、真实 HTTP fork 请求、确认新 run 正确落库并可追溯)。测试覆盖需求文档验收标准 1-3(PRD 换内容后下游真实重跑/BRD 不动、原始 run 完全不受影响、同一节点可分叉多次互不干扰)。`python3 -m unittest discover -s src`(608 tests)+ 前端 3 个 smoke test 全绿;`supervisor` 套件里一个真实候选进程冒烟测试用 `git stash create` 快照当前工作树来测试未提交状态,只快照 tracked 文件——本轮新增的 3 个文件当时还是 untracked,被这个测试技巧本身漏掉导致候选进程 import 失败,不是代码问题,提交后自然解决(真实生产的 `stage_deployment()` 用的是真实 commit hash,不会有这个问题)。
74 76  
75 77 ---
76 78  
... ... @@ -142,7 +144,7 @@
142 144 | **F** 三视角评审团 fan-out | `judge_root_cause` 从 Python 函数改成 Work Graph 菱形拓扑 | ✅ **已实现**(2026-08-06,586 测试全绿) | M | 已完成 | 无(G 已满足) |
143 145 | **B** loop-until-dry Skill | Validator 未知规模扫描收敛能力 | ✅ **已实现**(2026-08-06,587 测试全绿) | S/M | 已完成 | 无 |
144 146 | **H** Role Package 抽象层 | 统一 role/context/skills/applications/workflows/permissions/acceptance 打包格式 | ✅ 需求评审通过(2026-08-06),待设计 | XL | P2(战略级,不阻塞其他项) | workflows 字段依赖 E 的产出(已满足) |
145   -| **D** Time-travel 调试 | 回到历史节点分叉重跑 | 仅需求 | L | P3 | 无强依赖,但价值随图变复杂(E 落地后)才会显现 |
  147 +| **D** Time-travel 调试 | 回到历史节点分叉重跑 | ✅ **已实现**(2026-08-06,608 测试全绿) | L | 已完成 | 无 |
146 148 | C 按节点选模型 | — | ⛔ 已确认不做 | — | — | — |
147 149 | A 术语对照 | — | ✅ 已完成 | — | — | — |
148 150  
... ...
docs/todo/work-graph-time-travel-debugging-design.md 0 → 100644
  1 +# Work Graph 执行历史 Time-Travel 调试——详细设计(SDD)
  2 +
  3 +**日期**: 2026-08-06
  4 +**性质**: 详细设计,对应需求文档 `docs/todo/work-graph-time-travel-debugging-requirements.md`(条目 D,已确认)。回答该需求文档第 8 节的开放问题。
  5 +**状态**: 设计草案,实施中,后端+控制台一起做(用户 2026-08-06 确认)。
  6 +
  7 +## 1. `ux_work_graph_run_active` 比需求文档描述的风险更小
  8 +
  9 +`src/schema.py:134` 那条唯一索引是**带 WHERE 条件的部分索引**:`WHERE status IN ('planning', 'running', 'merging', 'verifying')`——'done'/'failed'/'blocked' 状态的 run 完全不受它约束。这意味着"从一个已经跑完很久的 task 分叉调试"(最常见的真实场景)根本不会撞上这条约束。
  10 +
  11 +**v1 范围裁剪(跟用户讨论后确认)**:只支持从**父 run 已经到达终态**(`done`/`failed`/`blocked`)的节点分叉。这样新 fork 出的 run 完全落在这条部分索引的约束范围之外,不需要碰这条索引本身,也不需要梳理"还有哪些代码路径隐式假设了一个 task_id+org_node 只有一个活跃 run"这个更大的排查面(需求文档风险第 1 条点名的工作)。从还在跑的父 run 分叉,留给以后有真实需求再做。
  12 +
  13 +## 2. 关键设计决定:替换输出,不重新提示(解决 FR-1 的歧义)
  14 +
  15 +需求文档 FR-1 原文:"人工可以指定新的输入(覆盖原 `input_payload` 的部分或全部字段),触发从这个节点开始的重新执行。" 但 `input_payload` 在现有代码里几乎总是一个薄壳(`{"node_id": ...}`)——真正的提示词内容来自调用方各自不同的机制(Planner 靠 `artifact_type` 查 `_DOC_PROMPTS`,E 的通用路径靠节点自带的 `task_prompt` 字段,F 的评审团靠运行时闭包)。要让分叉机制对这些不同调用方都通用,"覆盖 input_payload 重新触发 LLM 调用"这条路走不通,会绑死在某一种调用方约定上。
  16 +
  17 +**决定**:分叉点这个节点本身**不重新执行**——它的 `output_payload` 被人工提供的新内容直接替换(验收标准 1 的例子"换一个不同的 PRD 内容"本来就是这个意思:人工已经有了想要的新内容,不是要求 Agent 重新生成一个不一样的版本)。只有**严格下游**的节点才会真的重新跑一次 `claude -p`。这个决定让分叉机制完全不需要知道原图是哪个调用方产出的、用的是哪种 `build_prompt` 约定——只需要持久化的 `graph_manifest` 和各节点的 `output_payload`,这两样任何一条 Work Graph run 都有。
  18 +
  19 +## 3. 实现机制:100% 复用 REQ-06 断点续跑,零改动执行引擎
  20 +
  21 +`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()` 内部的事。
  22 +
  23 +这意味着 `fork_work_graph_run()` 不需要裁剪 `graph_manifest`、不需要给 `run_graph()` 传一个"预填充的 outputs"参数(`run_graph()` 也没有这个参数,不需要新加)——**只需要在调用 `run_graph()` 之前,往新 `run_id` 下预先插入一批 `work_node_execution` 行**:
  24 +
  25 +- 分叉点本身:`status='done'`,`output_payload` = 人工提供的新内容。
  26 +- 分叉点的**非下游**节点(严格下游子图之外的所有节点,包括分叉点的上游和跟分叉点无关的兄弟分支):`status='done'`,`output_payload` = 原始 run 里这个节点已经产出的内容,原样复制。
  27 +- 分叉点的**严格下游**节点:什么都不插入,留给 `run_graph()` 正常调度、真实执行。
  28 +
  29 +`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 的并发调度(如果严格下游子图里有互不依赖的节点,一样会被分进同一层并发执行,不需要额外代码)。
  30 +
  31 +`_downstream_node_ids(graph_manifest, source_node_id)`:从 `depends_on`/`edges` 正向遍历依赖图(复用 `topological_layers()` 已经在用的同一套 dependents 构建逻辑,抽出来共享,不重复实现一遍)。
  32 +
  33 +## 4. Schema 改动
  34 +
  35 +`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 约束)。
  36 +
  37 +## 5. 查询层 + 控制台(FR-4)
  38 +
  39 +`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` 两个字段——前端靠这两个字段判断"这条是原始记录还是分叉分支",以及"分叉自哪一条"。
  40 +
  41 +控制台改动(`frontend/app.js`):
  42 +- 运行历史列表/任务详情页的每条 `work_graph_run` 卡片,`parent_run_id` 非空时显示一个"🔀 从 `<fork_of_node_exec_id 对应的 node_id>` 分叉自 `<parent_run_id 前 8 位>`"的标记,点击可跳转到父 run。
  43 +- 节点详情面板(已有的 `renderGraphVisualizerPanel`/节点 hover 面板)里,`status='done'` 的节点新增一个"从这里分叉"按钮,点开一个表单填新的 output 内容(JSON),提交调用新端点。
  44 +- 不做 FR-5 提到的"设为主线"之类的合并操作——需求文档非目标 #3 已经说了第一版不做 diff/合并 UI,只读展示 + 触发分叉。
  45 +
  46 +## 6. HTTP 端点
  47 +
  48 +`POST /api/work-node-executions/:node_exec_id/fork`,body `{"new_output_payload": {...}}`,返回 `{"run_id", "outputs"}`。放在 `backend_server.py` 现有的路由风格里(正则匹配路径 + 方法分派,参考已有的 `/api/tasks/:id/...` 系列端点)。
  49 +
  50 +## 7. 不做的部分(如实记录)
  51 +
  52 +- **从还在跑的父 run 分叉**(第 1 节裁剪掉的部分)。
  53 +- **FR-5 的"设为主线"交互**——只读展示分叉分支,人工另外决定怎么处理分叉产物(需求文档非目标 #3 已经说了 diff/比较视图留给后续)。
  54 +- **分叉分支的生命周期/清理策略**(需求文档 §8 开放问题最后一条)——先不清理,跟原始执行日志的保留策略是两个独立的问题,等真的堆积成问题再处理。
  55 +
  56 +## 8. 回答需求文档第 8 节开放问题
  57 +
  58 +- `parent_run_id`/`fork_of_node_exec_id` 具体 schema:见第 4 节,纯新增可空字段,不动唯一索引(因为 v1 范围裁剪掉了会冲突的场景,见第 1 节)。
  59 +- 触发入口:控制台 + 一个后端 API(`POST /api/work-node-executions/:node_exec_id/fork`),两者复用同一个 `fork_work_graph_run()`。
  60 +- FR-5 交互:不做"设为主线"按钮,纯只读展示(见第 5、7 节)。
  61 +- 生命周期/清理策略:本轮不做(见第 7 节)。
... ...
frontend/app.js
... ... @@ -1487,10 +1487,26 @@ function mountTaskDetail(taskId, initialOrgNode) {
1487 1487 // existing auto-scroll checkbox keeps working without touching
1488 1488 // mountLogViewer itself.
1489 1489  
  1490 +// D (2026-08-06, time-travel debugging, FR-4): only a 'done' node is a
  1491 +// valid fork point (work_graph_fork.create_fork's own FR-1 requirement --
  1492 +// its output_payload has to exist to be substituted). The form itself is
  1493 +// plain textarea + button, no native prompt()/confirm() (this codebase
  1494 +// doesn't use those anywhere, matches the existing btn-rollback pattern's
  1495 +// inline-form style instead).
1490 1496 function renderRunNodeListHtml(run) {
1491 1497 if (!run.nodes.length) return '<div class="hint">该运行尚无节点</div>';
1492 1498 const items = run.nodes.map((wn) => {
1493 1499 const status = wn.status;
  1500 + const forkHtml = status === "done" ? (
  1501 + '<div class="fork-panel">'
  1502 + + '<button class="btn btn-fork" type="button">🔀 从这里分叉</button>'
  1503 + + '<div class="fork-form" style="display:none;">'
  1504 + + '<textarea class="fork-content" rows="4" placeholder="新的 content(替换这个节点的产出,下游节点会用这份新内容真正重新执行)"></textarea>'
  1505 + + '<button class="btn btn-fork-submit" type="button">提交分叉</button>'
  1506 + + '<div class="fork-error error-banner" style="display:none;"></div>'
  1507 + + "</div>"
  1508 + + "</div>"
  1509 + ) : "";
1494 1510 return (
1495 1511 '<li class="status-' + status + '">'
1496 1512 + '<div class="todo-row" data-node-exec-id="' + wn.node_exec_id + '">'
... ... @@ -1502,6 +1518,7 @@ function renderRunNodeListHtml(run) {
1502 1518 + '<div class="todo-detail" id="todo-detail-' + wn.node_exec_id + '" data-status="' + status + '">'
1503 1519 + (wn.reflection_notes ? "reflection_notes: " + escapeHtml(wn.reflection_notes) + "<br>" : "")
1504 1520 + '<div class="log-viewer-slot" id="log-slot-' + wn.node_exec_id + '"></div>'
  1521 + + forkHtml
1505 1522 + "</div>"
1506 1523 + "</li>"
1507 1524 );
... ... @@ -1509,6 +1526,58 @@ function renderRunNodeListHtml(run) {
1509 1526 return '<ul class="todo-tree">' + items + "</ul>";
1510 1527 }
1511 1528  
  1529 +// D (2026-08-06, FR-4): a forked run shows where it came from, linking
  1530 +// back to the parent run -- read-only, no "set as primary" action (the
  1531 +// requirements doc's own non-goal #3, see design doc §5/§7).
  1532 +function renderForkBadgeHtml(run) {
  1533 + if (!run.parent_run_id) return "";
  1534 + return (
  1535 + '<div class="fork-badge">🔀 从节点 <code>' + escapeHtml(run.fork_of_node_exec_id) + '</code> 分叉自 '
  1536 + + '<a href="#/runs/' + encodeURIComponent(run.parent_run_id) + '">' + escapeHtml(run.parent_run_id) + "</a></div>"
  1537 + );
  1538 +}
  1539 +
  1540 +function mountForkPanels(container) {
  1541 + for (const panel of container.querySelectorAll(".fork-panel")) {
  1542 + const nodeExecId = panel.closest(".todo-detail").id.replace("todo-detail-", "");
  1543 + const toggleBtn = panel.querySelector(".btn-fork");
  1544 + const form = panel.querySelector(".fork-form");
  1545 + const textarea = panel.querySelector(".fork-content");
  1546 + const submitBtn = panel.querySelector(".btn-fork-submit");
  1547 + const errorEl = panel.querySelector(".fork-error");
  1548 +
  1549 + toggleBtn.addEventListener("click", (e) => {
  1550 + e.stopPropagation();
  1551 + form.style.display = form.style.display === "none" ? "block" : "none";
  1552 + });
  1553 + // clicking inside the form must not toggle the parent .todo-detail row
  1554 + form.addEventListener("click", (e) => e.stopPropagation());
  1555 +
  1556 + submitBtn.addEventListener("click", async () => {
  1557 + const content = textarea.value;
  1558 + if (!content.trim()) {
  1559 + errorEl.textContent = "请输入新的 content";
  1560 + errorEl.style.display = "block";
  1561 + return;
  1562 + }
  1563 + errorEl.style.display = "none";
  1564 + submitBtn.disabled = true;
  1565 + const resp = await fetch("/api/work-nodes/" + encodeURIComponent(nodeExecId) + "/fork", {
  1566 + method: "POST",
  1567 + body: JSON.stringify({ new_output_payload: { content: content } }),
  1568 + });
  1569 + submitBtn.disabled = false;
  1570 + const body = await resp.json();
  1571 + if (resp.status !== 200) {
  1572 + errorEl.textContent = body.error + (body.detail ? ":" + body.detail : "");
  1573 + errorEl.style.display = "block";
  1574 + return;
  1575 + }
  1576 + window.location.hash = "#/runs/" + encodeURIComponent(body.run_id);
  1577 + });
  1578 + }
  1579 +}
  1580 +
1512 1581 function renderRunDetail(runId) {
1513 1582 if (!getCurrentProjectId()) return renderNoProjectSelected();
1514 1583 return `
... ... @@ -1540,6 +1609,7 @@ function mountRunDetail(runId) {
1540 1609 closeAllLogViewers();
1541 1610 content.innerHTML = (
1542 1611 "<h1>" + escapeHtml(run.org_node) + " Work Graph Run</h1>"
  1612 + + renderForkBadgeHtml(run)
1543 1613 + '<div class="meta-row">'
1544 1614 + "<div><b>RUN_ID</b>" + escapeHtml(run.run_id) + "</div>"
1545 1615 + "<div><b>STATUS</b>" + escapeHtml(run.status) + "</div>"
... ... @@ -1566,6 +1636,7 @@ function mountRunDetail(runId) {
1566 1636 }
1567 1637 });
1568 1638 }
  1639 + mountForkPanels(content);
1569 1640 mountGraphVisualizerTooltips(content);
1570 1641  
1571 1642 if (!socket) {
... ... @@ -1668,7 +1739,8 @@ async function mountArtifactsOverviewPanel(container, projectId) {
1668 1739 const runsHtml = runs.length === 0 ? '<div class="hint">暂无 Graph 运行记录</div>' : (
1669 1740 '<ul class="simple-list" id="gc_runs_list">' + runs.slice(0, 10).map((r) =>
1670 1741 '<li data-run-id="' + r.run_id + '" style="cursor:pointer;"><span>' + escapeHtml(r.org_node) + " · run "
1671   - + r.run_id.slice(0, 8) + "</span><span>" + escapeHtml(r.status) + " · " + r.node_count + " 节点</span></li>"
  1742 + + r.run_id.slice(0, 8) + (r.parent_run_id ? " 🔀" : "") + "</span><span>"
  1743 + + escapeHtml(r.status) + " · " + r.node_count + " 节点</span></li>"
1672 1744 ).join("") + "</ul>"
1673 1745 );
1674 1746  
... ...
frontend/style.css
... ... @@ -191,3 +191,12 @@ pre.code { background: #0b1120; color: #d1d5db; padding: 14px; border-radius: 8p
191 191 .iteration .summary { font-size: 13px; margin-bottom: 8px; }
192 192 .iteration .actions { display: flex; gap: 8px; }
193 193 .iteration .diff-output { margin-top: 10px; }
  194 +
  195 +/* D (2026-08-06): v_run_detail time-travel fork controls, and the fork
  196 + marker shown on run list rows (v_graph_console) / the run-detail header
  197 + itself when the run being viewed is a fork. */
  198 +.fork-badge { background: #eef2ff; color: #4338ca; border-radius: 8px; padding: 8px 12px; font-size: 13px; margin-bottom: 12px; }
  199 +.fork-panel { margin-top: 10px; }
  200 +.fork-form { margin-top: 8px; display: flex; flex-direction: column; gap: 8px; max-width: 480px; }
  201 +.fork-content { font-family: inherit; font-size: 13px; padding: 8px; border: 1px solid #e5e7eb; border-radius: 6px; resize: vertical; }
  202 +.btn-fork-submit { align-self: flex-start; }
... ...
frontend/test_run_detail_smoke.js
... ... @@ -53,6 +53,9 @@ const src = fs.readFileSync(path.join(__dirname, &quot;app.js&quot;), &quot;utf8&quot;);
53 53 // reach the sandbox object (same Annex B quirk the other smoke tests
54 54 // already document) -- re-export what this test needs.
55 55 const augmentedSrc = src + "\nglobalThis.RUN_DETAIL_RE = RUN_DETAIL_RE;\n";
  56 +// (renderRunNodeListHtml/renderForkBadgeHtml/mountForkPanels are already
  57 +// top-level function declarations, so they land in the sandbox globalThis
  58 +// directly -- no extra re-export needed for those, unlike the const regex.)
56 59 new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox);
57 60  
58 61 let assertionCount = 0;
... ... @@ -114,4 +117,34 @@ function check(condition, message) {
114 117 check(!li.includes("data-run-task-id"), "the old task-switcher click target must be gone");
115 118 }
116 119  
  120 +// ---- 4. D (2026-08-06, time-travel debugging): fork panel only on 'done' nodes ----
  121 +{
  122 + const run = {
  123 + run_id: "run-1", org_node: "planner", status: "running",
  124 + nodes: [
  125 + { node_exec_id: "exec-running", node_id: "prd", subagent_type: "planner", status: "running", retry_count: 0 },
  126 + { node_exec_id: "exec-done", node_id: "sdd", subagent_type: "planner", status: "done", retry_count: 0 },
  127 + ],
  128 + };
  129 + const html = sandbox.renderRunNodeListHtml(run);
  130 + const doneDetail = html.slice(html.indexOf('id="todo-detail-exec-done"'));
  131 + const runningDetail = html.slice(
  132 + html.indexOf('id="todo-detail-exec-running"'), html.indexOf('id="todo-detail-exec-done"')
  133 + );
  134 + check(doneDetail.includes("btn-fork"), "a 'done' node must offer a fork trigger");
  135 + check(doneDetail.includes("fork-content"), "the fork form's textarea must be present for a 'done' node");
  136 + check(!runningDetail.includes("btn-fork"), "a non-'done' node must not offer a fork trigger (source must be 'done', FR-1)");
  137 +}
  138 +
  139 +// ---- 5. D: renderForkBadgeHtml -- only shown for an actual fork ----
  140 +{
  141 + const original = { run_id: "run-1", parent_run_id: null, fork_of_node_exec_id: null };
  142 + check(sandbox.renderForkBadgeHtml(original) === "", "a normal (non-forked) run must render no badge");
  143 +
  144 + const forked = { run_id: "run-2", parent_run_id: "run-1", fork_of_node_exec_id: "run-1-prd" };
  145 + const badge = sandbox.renderForkBadgeHtml(forked);
  146 + check(badge.includes("run-1-prd"), "badge must name which node was forked from");
  147 + check(badge.includes('href="#/runs/run-1"'), "badge must link back to the parent run");
  148 +}
  149 +
117 150 console.log("frontend/test_run_detail_smoke.js: all " + assertionCount + " assertions passed");
... ...
src/backend_server.py
... ... @@ -39,8 +39,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
39 39 from src import (
40 40 artifact_query, budget_guardrail, bug_record, deployment_query, execution_log,
41 41 org_loop_route, paths, project_query, project_scaffold, schema, settings_config,
42   - skill_query, skill_update, task_create, task_execution, task_query, work_graph_run_query,
43   - ws_subscribe,
  42 + skill_query, skill_update, task_create, task_execution, task_query, work_graph_fork,
  43 + work_graph_run_query, ws_subscribe,
44 44 )
45 45  
46 46 _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend")
... ... @@ -131,6 +131,7 @@ _ARTIFACTS_PREFIX = &quot;/api/artifacts/&quot;
131 131 _SKILLS_PREFIX = "/api/skills/"
132 132 _DEPLOYMENTS_PREFIX = "/api/deployments/"
133 133 _WORK_GRAPH_RUNS_PREFIX = "/api/work-graph-runs/"
  134 +_FORK_SUFFIX = "/fork"
134 135  
135 136  
136 137 class _Handler(BaseHTTPRequestHandler):
... ... @@ -254,6 +255,9 @@ class _Handler(BaseHTTPRequestHandler):
254 255 elif self.path.startswith(_SKILLS_PREFIX) and self.path.endswith("/rollback"):
255 256 skill_id = self.path[len(_SKILLS_PREFIX):-len("/rollback")]
256 257 self._handle_skill_rollback(skill_id)
  258 + elif self.path.startswith(_WORK_NODES_PREFIX) and self.path.endswith(_FORK_SUFFIX):
  259 + node_exec_id = self.path[len(_WORK_NODES_PREFIX):-len(_FORK_SUFFIX)]
  260 + self._handle_work_node_fork(node_exec_id)
257 261 else:
258 262 self._send_json(404, {"error": "NOT_FOUND"})
259 263  
... ... @@ -686,6 +690,53 @@ class _Handler(BaseHTTPRequestHandler):
686 690 else:
687 691 self._send_json(200, result)
688 692  
  693 + def _handle_work_node_fork(self, node_exec_id):
  694 + """D (2026-08-06, time-travel debugging), POST
  695 + /api/work-nodes/:node_exec_id/fork, body {"new_output_payload": {...}}.
  696 + Mirrors _handle_task_execute's shape: create_fork() runs
  697 + synchronously (fast, DB-only) so the response carries the new
  698 + run_id immediately, real execution continues in the background --
  699 + the console navigates straight to #/runs/:run_id and watches it
  700 + progress live via the existing work-graph-run endpoints, no new
  701 + polling mechanism needed."""
  702 + payload, error = self._read_json_body()
  703 + if error:
  704 + self._send_json(400, error)
  705 + return
  706 + new_output_payload = payload.get("new_output_payload")
  707 + if not isinstance(new_output_payload, dict):
  708 + self._send_json(400, {"error": "INVALID_INPUT", "detail": "new_output_payload must be an object"})
  709 + return
  710 +
  711 + with self.server.conn_lock:
  712 + row = self.server.conn.execute(
  713 + "SELECT p.target_folder_path FROM work_node_execution wne "
  714 + "JOIN work_graph_run wgr ON wgr.run_id = wne.run_id "
  715 + "JOIN task t ON t.task_id = wgr.task_id "
  716 + "JOIN project p ON p.project_id = t.project_id "
  717 + "WHERE wne.node_exec_id = ?",
  718 + (node_exec_id,),
  719 + ).fetchone()
  720 + if row is None:
  721 + self._send_json(404, {"error": "NOT_FOUND"})
  722 + return
  723 + target_folder_path = row[0]
  724 +
  725 + try:
  726 + run_id, _thread = work_graph_fork.start_fork_in_background(
  727 + self.server.db_path, node_exec_id, new_output_payload, target_folder_path,
  728 + )
  729 + except work_graph_fork.ForkSourceNotDoneError as e:
  730 + self._send_json(409, {"error": "FORK_SOURCE_NOT_DONE", "detail": str(e)})
  731 + except work_graph_fork.ForkParentRunNotTerminalError as e:
  732 + self._send_json(409, {"error": "FORK_PARENT_RUN_NOT_TERMINAL", "detail": str(e)})
  733 + except work_graph_fork.ForkGraphShapeUnsupportedError as e:
  734 + self._send_json(422, {"error": "FORK_GRAPH_SHAPE_UNSUPPORTED", "detail": str(e)})
  735 + except ValueError as e:
  736 + self._send_json(404, {"error": "NOT_FOUND", "detail": str(e)})
  737 + else:
  738 + self._send_json(200, {"run_id": run_id, "started": True})
  739 +
689 740 def _handle_artifact_get(self, artifact_id):
690 741 try:
691 742 with self.server.conn_lock:
... ...
src/schema.py
... ... @@ -127,7 +127,16 @@ CREATE TABLE work_graph_run (
127 127 )),
128 128 started_at TEXT,
129 129 completed_at TEXT,
130   - result_artifact_refs TEXT
  130 + result_artifact_refs TEXT,
  131 + -- D (2026-08-06, time-travel debugging): both NULL for a normal run.
  132 + -- Set together when this run was forked from a historical node instead
  133 + -- of planned fresh -- see src/work_graph_fork.py. v1 only forks from a
  134 + -- parent run that has already reached a terminal status, so a forked
  135 + -- run's (task_id, org_node) pair never collides with
  136 + -- ux_work_graph_run_active below (that index only covers non-terminal
  137 + -- statuses) -- no change needed to the index itself.
  138 + parent_run_id TEXT REFERENCES work_graph_run(run_id),
  139 + fork_of_node_exec_id TEXT REFERENCES work_node_execution(node_exec_id)
131 140 );
132 141  
133 142 -- only one active (non-terminal) run per (task_id, org_node) at a time
... ... @@ -398,3 +407,22 @@ def migrate_expand_artifact_types(conn):
398 407 """
399 408 )
400 409 conn.commit()
  410 +
  411 +
  412 +def migrate_add_work_graph_fork_columns(conn):
  413 + """D (2026-08-06, time-travel debugging): adds parent_run_id/
  414 + fork_of_node_exec_id to work_graph_run on an ALREADY-POPULATED
  415 + database. Unlike migrate_expand_artifact_types, this is a plain `ALTER
  416 + TABLE ... ADD COLUMN` (no CHECK constraint involved, so no need for the
  417 + create-copy-drop-rename dance). Idempotent and safe to call
  418 + unconditionally, including against a freshly create_schema()'d database
  419 + (which already has both columns)."""
  420 + cols = {row[1] for row in conn.execute("PRAGMA table_info(work_graph_run)").fetchall()}
  421 + if "parent_run_id" not in cols:
  422 + conn.execute("ALTER TABLE work_graph_run ADD COLUMN parent_run_id TEXT REFERENCES work_graph_run(run_id)")
  423 + if "fork_of_node_exec_id" not in cols:
  424 + conn.execute(
  425 + "ALTER TABLE work_graph_run ADD COLUMN fork_of_node_exec_id TEXT "
  426 + "REFERENCES work_node_execution(node_exec_id)"
  427 + )
  428 + conn.commit()
... ...
src/task_query.py
... ... @@ -155,7 +155,8 @@ def get_task(conn, task_id):
155 155 ]
156 156  
157 157 runs = conn.execute(
158   - "SELECT run_id, org_node, graph_manifest, status, started_at, completed_at "
  158 + "SELECT run_id, org_node, graph_manifest, status, started_at, completed_at, "
  159 + "parent_run_id, fork_of_node_exec_id "
159 160 "FROM work_graph_run WHERE task_id = ? ORDER BY started_at",
160 161 (task_id,),
161 162 ).fetchall()
... ... @@ -164,6 +165,9 @@ def get_task(conn, task_id):
164 165 "run_id": w[0], "org_node": w[1],
165 166 "graph_manifest": json.loads(w[2]) if w[2] else None,
166 167 "status": w[3], "started_at": w[4], "completed_at": w[5],
  168 + # D (2026-08-06): both None for a normal run; both set when this
  169 + # run was forked from a historical node (src/work_graph_fork.py).
  170 + "parent_run_id": w[6], "fork_of_node_exec_id": w[7],
167 171 "nodes": _work_nodes_for_run(conn, w[0]),
168 172 }
169 173 for w in runs
... ...
src/test_backend_server.py
... ... @@ -1368,6 +1368,117 @@ class WorkGraphRunListRouteTest(unittest.TestCase):
1368 1368 self.assertEqual(body["error"], "NOT_FOUND")
1369 1369  
1370 1370  
  1371 +class WorkNodeForkRouteTest(unittest.TestCase):
  1372 + """D (2026-08-06, time-travel debugging): POST
  1373 + /api/work-nodes/:node_exec_id/fork. `start_fork_in_background` is
  1374 + mocked throughout -- same reasoning as TaskExecutionRouteTest just
  1375 + above: it opens its OWN sqlite connection via schema.connect(db_path),
  1376 + which for a ':memory:' test db would silently create a second,
  1377 + disconnected database. Real fork behavior is already covered in
  1378 + test_work_graph_fork.py (real file db, real threading)."""
  1379 +
  1380 + def setUp(self):
  1381 + self.server = backend_server.create_server(db_path=":memory:")
  1382 + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
  1383 + self.thread.start()
  1384 + conn = self.server.conn
  1385 + conn.execute(
  1386 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  1387 + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')"
  1388 + )
  1389 + conn.execute(
  1390 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  1391 + "VALUES ('task-1', 'proj-1', 'g', 'goal', 'validating', 'planner')"
  1392 + )
  1393 + conn.execute(
  1394 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  1395 + "VALUES ('run-1', 'task-1', 'planner', '{}', 'done')"
  1396 + )
  1397 + conn.execute(
  1398 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
  1399 + "VALUES ('skill-1', 'planner', 'skills/skill-1/', 'sha', 'new', 'active')"
  1400 + )
  1401 + conn.execute(
  1402 + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
  1403 + "input_schema_ref, output_schema_ref, input_payload, output_payload, status) "
  1404 + "VALUES ('node-1', 'run-1', 'prd', 'planner', 'skill-1', 'in.json', 'out.json', '{}', '{}', 'done')"
  1405 + )
  1406 + conn.commit()
  1407 +
  1408 + def tearDown(self):
  1409 + self.server.shutdown()
  1410 + self.server.server_close()
  1411 + self.thread.join(timeout=5)
  1412 +
  1413 + def _post(self, path, payload=None):
  1414 + host, port = self.server.server_address
  1415 + conn = http.client.HTTPConnection(host, port, timeout=5)
  1416 + body = json.dumps(payload).encode("utf-8") if payload is not None else b""
  1417 + conn.request("POST", path, body=body)
  1418 + resp = conn.getresponse()
  1419 + raw = resp.read()
  1420 + body = json.loads(raw) if raw else None
  1421 + conn.close()
  1422 + return resp.status, body
  1423 +
  1424 + def test_fork_returns_run_id_and_calls_through_with_target_folder_path(self):
  1425 + with patch.object(backend_server.work_graph_fork, "start_fork_in_background") as mock_start:
  1426 + mock_start.return_value = ("run-fork-1", None)
  1427 + status, body = self._post(
  1428 + "/api/work-nodes/node-1/fork", {"new_output_payload": {"content": "new prd"}}
  1429 + )
  1430 + self.assertEqual(status, 200)
  1431 + self.assertEqual(body, {"run_id": "run-fork-1", "started": True})
  1432 + mock_start.assert_called_once_with(
  1433 + self.server.db_path, "node-1", {"content": "new prd"}, "/tmp/proj-1",
  1434 + )
  1435 +
  1436 + def test_fork_nonexistent_node_returns_404(self):
  1437 + status, body = self._post(
  1438 + "/api/work-nodes/no-such-node/fork", {"new_output_payload": {"content": "x"}}
  1439 + )
  1440 + self.assertEqual(status, 404)
  1441 + self.assertEqual(body["error"], "NOT_FOUND")
  1442 +
  1443 + def test_fork_missing_new_output_payload_returns_400(self):
  1444 + status, body = self._post("/api/work-nodes/node-1/fork", {})
  1445 + self.assertEqual(status, 400)
  1446 + self.assertEqual(body["error"], "INVALID_INPUT")
  1447 +
  1448 + def test_fork_source_not_done_returns_409(self):
  1449 + with patch.object(
  1450 + backend_server.work_graph_fork, "start_fork_in_background",
  1451 + side_effect=backend_server.work_graph_fork.ForkSourceNotDoneError("not done"),
  1452 + ):
  1453 + status, body = self._post(
  1454 + "/api/work-nodes/node-1/fork", {"new_output_payload": {"content": "x"}}
  1455 + )
  1456 + self.assertEqual(status, 409)
  1457 + self.assertEqual(body["error"], "FORK_SOURCE_NOT_DONE")
  1458 +
  1459 + def test_fork_parent_run_not_terminal_returns_409(self):
  1460 + with patch.object(
  1461 + backend_server.work_graph_fork, "start_fork_in_background",
  1462 + side_effect=backend_server.work_graph_fork.ForkParentRunNotTerminalError("still running"),
  1463 + ):
  1464 + status, body = self._post(
  1465 + "/api/work-nodes/node-1/fork", {"new_output_payload": {"content": "x"}}
  1466 + )
  1467 + self.assertEqual(status, 409)
  1468 + self.assertEqual(body["error"], "FORK_PARENT_RUN_NOT_TERMINAL")
  1469 +
  1470 + def test_fork_unsupported_graph_shape_returns_422(self):
  1471 + with patch.object(
  1472 + backend_server.work_graph_fork, "start_fork_in_background",
  1473 + side_effect=backend_server.work_graph_fork.ForkGraphShapeUnsupportedError("nope"),
  1474 + ):
  1475 + status, body = self._post(
  1476 + "/api/work-nodes/node-1/fork", {"new_output_payload": {"content": "x"}}
  1477 + )
  1478 + self.assertEqual(status, 422)
  1479 + self.assertEqual(body["error"], "FORK_GRAPH_SHAPE_UNSUPPORTED")
  1480 +
  1481 +
1371 1482 class DeploymentRouteTest(unittest.TestCase):
1372 1483 """U-043: c_deployment_list/c_deployment_current/c_deployment_get for
1373 1484 v_deployment_status."""
... ...
src/test_schema.py
... ... @@ -6,6 +6,7 @@ total) and widened `bug_record.root_cause_category` to accept `core_engine`.
6 6 Run: python3 -m unittest src.test_schema -v
7 7 """
8 8  
  9 +import re
9 10 import sqlite3
10 11 import unittest
11 12  
... ... @@ -489,5 +490,65 @@ class DeployHealthSignalTest(ConstraintTestBase):
489 490 )
490 491  
491 492  
  493 +class MigrateAddWorkGraphForkColumnsTest(unittest.TestCase):
  494 + """D (2026-08-06): real upgrade path for an already-populated database
  495 + whose work_graph_run table predates the fork columns."""
  496 +
  497 + def setUp(self):
  498 + self.conn = schema.connect(":memory:")
  499 + # Strip the fork columns (and their leading comment block) back out
  500 + # of work_graph_run's CREATE TABLE to simulate a pre-migration
  501 + # database, without hand-maintaining a second copy of the DDL text.
  502 + old_sql = re.sub(
  503 + r",\n( --[^\n]*\n)* parent_run_id TEXT REFERENCES work_graph_run\(run_id\),\n"
  504 + r" fork_of_node_exec_id TEXT REFERENCES work_node_execution\(node_exec_id\)\n\);",
  505 + "\n);",
  506 + schema.SCHEMA_SQL,
  507 + )
  508 + self.assertNotIn("parent_run_id", old_sql) # sanity: the substitution actually matched
  509 + self.conn.executescript(old_sql)
  510 + self.conn.commit()
  511 + self.conn.execute(
  512 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  513 + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')"
  514 + )
  515 + self.conn.execute(
  516 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  517 + "VALUES ('task-1', 'proj-1', 'g', 'goal', 'queued', 'planner')"
  518 + )
  519 + self.conn.execute(
  520 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  521 + "VALUES ('run-1', 'task-1', 'planner', '{}', 'done')"
  522 + )
  523 + self.conn.commit()
  524 +
  525 + def tearDown(self):
  526 + self.conn.close()
  527 +
  528 + def test_migration_preserves_existing_rows_and_adds_nullable_columns(self):
  529 + schema.migrate_add_work_graph_fork_columns(self.conn)
  530 + row = self.conn.execute(
  531 + "SELECT status, parent_run_id, fork_of_node_exec_id FROM work_graph_run WHERE run_id = 'run-1'"
  532 + ).fetchone()
  533 + self.assertEqual(row, ("done", None, None))
  534 +
  535 + def test_idempotent_on_a_database_already_migrated(self):
  536 + schema.migrate_add_work_graph_fork_columns(self.conn)
  537 + schema.migrate_add_work_graph_fork_columns(self.conn) # must not raise
  538 + count = self.conn.execute("SELECT COUNT(*) FROM work_graph_run").fetchone()[0]
  539 + self.assertEqual(count, 1)
  540 +
  541 + def test_noop_on_a_freshly_created_already_expanded_database(self):
  542 + fresh = schema.connect(":memory:")
  543 + try:
  544 + schema.create_schema(fresh)
  545 + schema.migrate_add_work_graph_fork_columns(fresh) # must not raise
  546 + cols = {row[1] for row in fresh.execute("PRAGMA table_info(work_graph_run)").fetchall()}
  547 + self.assertIn("parent_run_id", cols)
  548 + self.assertIn("fork_of_node_exec_id", cols)
  549 + finally:
  550 + fresh.close()
  551 +
  552 +
492 553 if __name__ == "__main__":
493 554 unittest.main()
... ...
src/test_task_query.py
... ... @@ -143,10 +143,38 @@ class GetTaskTest(TaskQueryTestBase):
143 143 self.assertEqual(run["graph_manifest"], manifest)
144 144 self.assertEqual(len(run["nodes"]), 1)
145 145 self.assertEqual(run["nodes"][0]["node_id"], "n1")
146   - self.assertEqual(run["nodes"][0]["status"], "done")
147   - self.assertEqual(run["nodes"][0]["output_payload"], {"x": 1})
148   - self.assertEqual(run["nodes"][0]["input_schema_ref"], "in.json")
149   - self.assertEqual(run["nodes"][0]["output_schema_ref"], "out.json")
  146 + self.assertIsNone(run["parent_run_id"])
  147 + self.assertIsNone(run["fork_of_node_exec_id"])
  148 +
  149 + def test_forked_run_surfaces_parent_and_source_node(self):
  150 + # D (2026-08-06): v_task_detail must be able to tell a fork branch
  151 + # apart from the original run it descends from.
  152 + self._insert_task("task-1")
  153 + self.conn.execute(
  154 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  155 + "VALUES ('run-orig', 'task-1', 'planner', '{}', 'done')"
  156 + )
  157 + self.conn.execute(
  158 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
  159 + "VALUES ('skill-1', 'planner', 'skills/skill-1/', 'sha', 'new', 'active')"
  160 + )
  161 + self.conn.execute(
  162 + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
  163 + "input_schema_ref, output_schema_ref, input_payload, output_payload, status) "
  164 + "VALUES ('node-1', 'run-orig', 'n1', 'planner', 'skill-1', 'in.json', 'out.json', '{}', '{}', 'done')"
  165 + )
  166 + self.conn.execute(
  167 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status, "
  168 + "parent_run_id, fork_of_node_exec_id) "
  169 + "VALUES ('run-fork', 'task-1', 'planner', '{}', 'done', 'run-orig', 'node-1')"
  170 + )
  171 + self.conn.commit()
  172 +
  173 + result = task_query.get_task(self.conn, "task-1")
  174 + by_run_id = {r["run_id"]: r for r in result["work_graph_runs"]}
  175 + self.assertIsNone(by_run_id["run-orig"]["parent_run_id"])
  176 + self.assertEqual(by_run_id["run-fork"]["parent_run_id"], "run-orig")
  177 + self.assertEqual(by_run_id["run-fork"]["fork_of_node_exec_id"], "node-1")
150 178  
151 179 def test_work_node_includes_quota_wait_fields_for_v_graph_visualizer(self):
152 180 # U-026: v_graph_visualizer's waiting_for_quota state needs
... ...
src/test_work_graph_fork.py 0 → 100644
  1 +"""D (2026-08-06) tests: work_graph_fork.py, mocked at the claude
  2 +subprocess boundary (CLAUDE.md Testing constraint) -- same pattern as
  3 +test_work_graph_orchestrate.py. Covers the requirements doc's acceptance
  4 +standards 1-3 (docs/todo/work-graph-time-travel-debugging-requirements.md).
  5 +
  6 +Run: python3 -m unittest src.test_work_graph_fork -v
  7 +"""
  8 +
  9 +import json
  10 +import os
  11 +import tempfile
  12 +import unittest
  13 +from unittest.mock import patch
  14 +
  15 +from src import schema, work_graph_execute, work_graph_fork
  16 +
  17 +
  18 +_BRD_PRD_SDD_BENCHMARK_MANIFEST = {
  19 + "nodes": [
  20 + {"node_id": "brd", "artifact_type": "brd", "depends_on": []},
  21 + {"node_id": "prd", "artifact_type": "prd", "depends_on": ["brd"]},
  22 + {"node_id": "sdd", "artifact_type": "sdd", "depends_on": ["prd"]},
  23 + {"node_id": "benchmark", "artifact_type": "benchmark", "depends_on": ["sdd"]},
  24 + ],
  25 + "edges": [],
  26 +}
  27 +
  28 +
  29 +class ForkTestBase(unittest.TestCase):
  30 + def setUp(self):
  31 + self.home_tmp = tempfile.TemporaryDirectory()
  32 + self.addCleanup(self.home_tmp.cleanup)
  33 + self.old_graphlemon_home = os.environ.get("GRAPHLEMON_HOME")
  34 + os.environ["GRAPHLEMON_HOME"] = self.home_tmp.name
  35 + self.addCleanup(self._restore_graphlemon_home)
  36 +
  37 + self.conn = schema.connect(":memory:", check_same_thread=False)
  38 + schema.create_schema(self.conn)
  39 + self.conn.execute(
  40 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  41 + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')"
  42 + )
  43 + self.conn.execute(
  44 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  45 + "VALUES ('task-1', 'proj-1', '实现贪吃蛇游戏', 'goal', 'validating', 'planner')"
  46 + )
  47 + self.conn.execute(
  48 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
  49 + "VALUES ('planner-doc-writing', 'planner', 'skills/planner-doc-writing/', 'unknown', 'new', 'active')"
  50 + )
  51 + self.conn.commit()
  52 +
  53 + def _restore_graphlemon_home(self):
  54 + if self.old_graphlemon_home is None:
  55 + os.environ.pop("GRAPHLEMON_HOME", None)
  56 + else:
  57 + os.environ["GRAPHLEMON_HOME"] = self.old_graphlemon_home
  58 +
  59 + def tearDown(self):
  60 + self.conn.close()
  61 +
  62 + def _seed_completed_run(self, run_id, org_node, graph_manifest, contents, status="done"):
  63 + """Simulates an already-finished original run: a work_graph_run
  64 + row plus one 'done' work_node_execution row per node, each holding
  65 + contents[node_id] as its output_payload."""
  66 + self.conn.execute(
  67 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  68 + "VALUES (?, 'task-1', ?, ?, ?)",
  69 + (run_id, org_node, json.dumps(graph_manifest), status),
  70 + )
  71 + node_exec_ids = {}
  72 + for node in graph_manifest["nodes"]:
  73 + node_id = node["node_id"]
  74 + node_exec_id = run_id + "-" + node_id
  75 + node_exec_ids[node_id] = node_exec_id
  76 + self.conn.execute(
  77 + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
  78 + "input_schema_ref, output_schema_ref, input_payload, output_payload, status) "
  79 + "VALUES (?, ?, ?, ?, 'planner-doc-writing', 'in.json', 'out.json', ?, ?, 'done')",
  80 + (
  81 + node_exec_id, run_id, node_id, org_node,
  82 + json.dumps({"node_id": node_id}), json.dumps(contents[node_id]),
  83 + ),
  84 + )
  85 + self.conn.commit()
  86 + return node_exec_ids
  87 +
  88 +
  89 +class ForkFromPrdAcceptanceCriterion1Test(ForkTestBase):
  90 + """Acceptance standard 1: fork from PRD with different content, SDD and
  91 + Benchmark re-execute with the new PRD content, BRD is not re-executed."""
  92 +
  93 + def test_downstream_nodes_reexecute_upstream_node_reused(self):
  94 + contents = {
  95 + "brd": {"content": "original brd"}, "prd": {"content": "original prd"},
  96 + "sdd": {"content": "original sdd"}, "benchmark": {"content": "original benchmark"},
  97 + }
  98 + node_exec_ids = self._seed_completed_run(
  99 + "run-orig", "planner", _BRD_PRD_SDD_BENCHMARK_MANIFEST, contents
  100 + )
  101 +
  102 + execute_calls = []
  103 +
  104 + def fake_execute(cwd, name, args, output_schema, timeout=600):
  105 + execute_calls.append(name)
  106 + return {"content": "regenerated for " + name}
  107 +
  108 + with patch.object(work_graph_execute, "write_workflow_script"), \
  109 + patch.object(work_graph_execute, "execute_work_graph", side_effect=fake_execute):
  110 + result = work_graph_fork.fork_work_graph_run(
  111 + self.conn, node_exec_ids["prd"], {"content": "a completely different prd"}, "/tmp/proj-1",
  112 + )
  113 +
  114 + # only SDD and Benchmark actually ran claude -p calls -- BRD and PRD did not.
  115 + self.assertEqual(len(execute_calls), 2)
  116 +
  117 + outputs = result["outputs"]
  118 + self.assertEqual(outputs["brd"], {"content": "original brd"})
  119 + self.assertEqual(outputs["prd"], {"content": "a completely different prd"})
  120 + self.assertNotEqual(outputs["sdd"], {"content": "original sdd"})
  121 + self.assertNotEqual(outputs["benchmark"], {"content": "original benchmark"})
  122 +
  123 + new_run_id = result["run_id"]
  124 + rows = {
  125 + r[0]: (r[1], json.loads(r[2]))
  126 + for r in self.conn.execute(
  127 + "SELECT node_id, status, output_payload FROM work_node_execution WHERE run_id = ?", (new_run_id,)
  128 + ).fetchall()
  129 + }
  130 + self.assertEqual(rows["brd"], ("done", {"content": "original brd"}))
  131 + self.assertEqual(rows["prd"], ("done", {"content": "a completely different prd"}))
  132 + self.assertEqual(rows["sdd"][0], "done")
  133 + self.assertEqual(rows["benchmark"][0], "done")
  134 +
  135 + run_row = self.conn.execute(
  136 + "SELECT status, parent_run_id, fork_of_node_exec_id FROM work_graph_run WHERE run_id = ?",
  137 + (new_run_id,),
  138 + ).fetchone()
  139 + self.assertEqual(run_row, ("done", "run-orig", node_exec_ids["prd"]))
  140 +
  141 +
  142 +class OriginalRunUnaffectedTest(ForkTestBase):
  143 + """Acceptance standard 2: the original run's records are completely
  144 + untouched by a fork."""
  145 +
  146 + def test_original_run_and_nodes_unchanged_after_fork(self):
  147 + contents = {
  148 + "brd": {"content": "original brd"}, "prd": {"content": "original prd"},
  149 + "sdd": {"content": "original sdd"}, "benchmark": {"content": "original benchmark"},
  150 + }
  151 + node_exec_ids = self._seed_completed_run(
  152 + "run-orig", "planner", _BRD_PRD_SDD_BENCHMARK_MANIFEST, contents
  153 + )
  154 + before_run = self.conn.execute("SELECT * FROM work_graph_run WHERE run_id = 'run-orig'").fetchone()
  155 + before_nodes = self.conn.execute(
  156 + "SELECT * FROM work_node_execution WHERE run_id = 'run-orig' ORDER BY node_id"
  157 + ).fetchall()
  158 +
  159 + with patch.object(work_graph_execute, "write_workflow_script"), \
  160 + patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "x"}):
  161 + work_graph_fork.fork_work_graph_run(
  162 + self.conn, node_exec_ids["prd"], {"content": "different"}, "/tmp/proj-1",
  163 + )
  164 +
  165 + after_run = self.conn.execute("SELECT * FROM work_graph_run WHERE run_id = 'run-orig'").fetchone()
  166 + after_nodes = self.conn.execute(
  167 + "SELECT * FROM work_node_execution WHERE run_id = 'run-orig' ORDER BY node_id"
  168 + ).fetchall()
  169 + self.assertEqual(before_run, after_run)
  170 + self.assertEqual(before_nodes, after_nodes)
  171 +
  172 +
  173 +class MultipleForksFromSameNodeTest(ForkTestBase):
  174 + """Acceptance standard 3: the same historical node can be forked
  175 + multiple times, producing independent, separately-traceable branches."""
  176 +
  177 + def test_two_forks_from_same_node_are_independent(self):
  178 + contents = {
  179 + "brd": {"content": "original brd"}, "prd": {"content": "original prd"},
  180 + "sdd": {"content": "original sdd"}, "benchmark": {"content": "original benchmark"},
  181 + }
  182 + node_exec_ids = self._seed_completed_run(
  183 + "run-orig", "planner", _BRD_PRD_SDD_BENCHMARK_MANIFEST, contents
  184 + )
  185 +
  186 + with patch.object(work_graph_execute, "write_workflow_script"), \
  187 + patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "branch-a"}):
  188 + result_a = work_graph_fork.fork_work_graph_run(
  189 + self.conn, node_exec_ids["prd"], {"content": "prd variant A"}, "/tmp/proj-1",
  190 + )
  191 + with patch.object(work_graph_execute, "write_workflow_script"), \
  192 + patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "branch-b"}):
  193 + result_b = work_graph_fork.fork_work_graph_run(
  194 + self.conn, node_exec_ids["prd"], {"content": "prd variant B"}, "/tmp/proj-1",
  195 + )
  196 +
  197 + self.assertNotEqual(result_a["run_id"], result_b["run_id"])
  198 + self.assertEqual(result_a["outputs"]["prd"], {"content": "prd variant A"})
  199 + self.assertEqual(result_b["outputs"]["prd"], {"content": "prd variant B"})
  200 + self.assertEqual(result_a["outputs"]["sdd"], {"content": "branch-a"})
  201 + self.assertEqual(result_b["outputs"]["sdd"], {"content": "branch-b"})
  202 +
  203 + for run_id in (result_a["run_id"], result_b["run_id"]):
  204 + row = self.conn.execute(
  205 + "SELECT parent_run_id, fork_of_node_exec_id FROM work_graph_run WHERE run_id = ?", (run_id,)
  206 + ).fetchone()
  207 + self.assertEqual(row, ("run-orig", node_exec_ids["prd"]))
  208 +
  209 +
  210 +class ForkValidationErrorsTest(ForkTestBase):
  211 + def test_forking_a_non_done_node_raises(self):
  212 + self.conn.execute(
  213 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  214 + "VALUES ('run-1', 'task-1', 'planner', ?, 'running')",
  215 + (json.dumps(_BRD_PRD_SDD_BENCHMARK_MANIFEST),),
  216 + )
  217 + self.conn.execute(
  218 + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
  219 + "input_schema_ref, output_schema_ref, input_payload, status) "
  220 + "VALUES ('node-1', 'run-1', 'prd', 'planner', 'planner-doc-writing', 'in.json', 'out.json', '{}', 'running')"
  221 + )
  222 + self.conn.commit()
  223 + with self.assertRaises(work_graph_fork.ForkSourceNotDoneError):
  224 + work_graph_fork.fork_work_graph_run(self.conn, "node-1", {"content": "x"}, "/tmp/proj-1")
  225 +
  226 + def test_forking_from_a_non_terminal_parent_run_raises(self):
  227 + contents = {"brd": {"content": "b"}, "prd": {"content": "p"}, "sdd": {"content": "s"}, "benchmark": {"content": "bm"}}
  228 + node_exec_ids = self._seed_completed_run(
  229 + "run-orig", "planner", _BRD_PRD_SDD_BENCHMARK_MANIFEST, contents, status="running",
  230 + )
  231 + with self.assertRaises(work_graph_fork.ForkParentRunNotTerminalError):
  232 + work_graph_fork.fork_work_graph_run(
  233 + self.conn, node_exec_ids["prd"], {"content": "x"}, "/tmp/proj-1",
  234 + )
  235 +
  236 + def test_unsupported_graph_shape_raises(self):
  237 + manifest = {"nodes": [{"node_id": "a", "depends_on": []}, {"node_id": "b", "depends_on": ["a"]}], "edges": []}
  238 + node_exec_ids = self._seed_completed_run(
  239 + "run-orig", "coder", manifest, {"a": {"content": "x"}, "b": {"content": "y"}},
  240 + )
  241 + with self.assertRaises(work_graph_fork.ForkGraphShapeUnsupportedError):
  242 + work_graph_fork.fork_work_graph_run(
  243 + self.conn, node_exec_ids["a"], {"content": "z"}, "/tmp/proj-1",
  244 + )
  245 +
  246 +
  247 +class StartForkInBackgroundTest(unittest.TestCase):
  248 + """Real threading (own connection per thread) -- needs a real file
  249 + db_path, not ':memory:' (a fresh ':memory:' connection per thread
  250 + would be a completely separate, empty database), same reasoning as
  251 + test_task_execution.py's own StartTaskExecutionInBackgroundTest."""
  252 +
  253 + def setUp(self):
  254 + self.home_tmp = tempfile.TemporaryDirectory()
  255 + self.addCleanup(self.home_tmp.cleanup)
  256 + self.old_graphlemon_home = os.environ.get("GRAPHLEMON_HOME")
  257 + os.environ["GRAPHLEMON_HOME"] = self.home_tmp.name
  258 + self.addCleanup(self._restore_graphlemon_home)
  259 +
  260 + self.tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
  261 + self.tmp_db.close()
  262 + self.db_path = self.tmp_db.name
  263 + self.addCleanup(lambda: os.path.exists(self.db_path) and os.remove(self.db_path))
  264 +
  265 + conn = schema.connect(self.db_path)
  266 + schema.create_schema(conn)
  267 + conn.execute(
  268 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  269 + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')"
  270 + )
  271 + conn.execute(
  272 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  273 + "VALUES ('task-1', 'proj-1', 'g', 'goal', 'validating', 'planner')"
  274 + )
  275 + conn.execute(
  276 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
  277 + "VALUES ('planner-doc-writing', 'planner', 'skills/planner-doc-writing/', 'unknown', 'new', 'active')"
  278 + )
  279 + conn.execute(
  280 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  281 + "VALUES ('run-orig', 'task-1', 'planner', ?, 'done')",
  282 + (json.dumps(_BRD_PRD_SDD_BENCHMARK_MANIFEST),),
  283 + )
  284 + contents = {
  285 + "brd": {"content": "b"}, "prd": {"content": "p"}, "sdd": {"content": "s"}, "benchmark": {"content": "bm"},
  286 + }
  287 + for node in _BRD_PRD_SDD_BENCHMARK_MANIFEST["nodes"]:
  288 + node_id = node["node_id"]
  289 + conn.execute(
  290 + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
  291 + "input_schema_ref, output_schema_ref, input_payload, output_payload, status) "
  292 + "VALUES (?, 'run-orig', ?, 'planner', 'planner-doc-writing', 'in.json', 'out.json', ?, ?, 'done')",
  293 + ("run-orig-" + node_id, node_id, json.dumps({"node_id": node_id}), json.dumps(contents[node_id])),
  294 + )
  295 + conn.commit()
  296 + conn.close()
  297 +
  298 + def _restore_graphlemon_home(self):
  299 + if self.old_graphlemon_home is None:
  300 + os.environ.pop("GRAPHLEMON_HOME", None)
  301 + else:
  302 + os.environ["GRAPHLEMON_HOME"] = self.old_graphlemon_home
  303 +
  304 + def test_returns_run_id_immediately_and_completes_in_background(self):
  305 + with patch.object(work_graph_execute, "write_workflow_script"), \
  306 + patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "regenerated"}):
  307 + run_id, thread = work_graph_fork.start_fork_in_background(
  308 + self.db_path, "run-orig-prd", {"content": "new prd"}, "/tmp/proj-1",
  309 + )
  310 + self.assertTrue(run_id)
  311 + thread.join(timeout=10)
  312 +
  313 + conn = schema.connect(self.db_path)
  314 + status = conn.execute("SELECT status FROM work_graph_run WHERE run_id = ?", (run_id,)).fetchone()[0]
  315 + sdd_output = conn.execute(
  316 + "SELECT output_payload FROM work_node_execution WHERE run_id = ? AND node_id = 'sdd'", (run_id,)
  317 + ).fetchone()[0]
  318 + conn.close()
  319 + self.assertEqual(status, "done")
  320 + self.assertEqual(json.loads(sdd_output), {"content": "regenerated"})
  321 +
  322 +
  323 +class ForkOfLeafNodeTest(ForkTestBase):
  324 + """Forking the last node in a chain has nothing downstream -- no real
  325 + execution needed, just the substituted output in a new run."""
  326 +
  327 + def test_forking_leaf_node_needs_no_execution(self):
  328 + contents = {
  329 + "brd": {"content": "original brd"}, "prd": {"content": "original prd"},
  330 + "sdd": {"content": "original sdd"}, "benchmark": {"content": "original benchmark"},
  331 + }
  332 + node_exec_ids = self._seed_completed_run(
  333 + "run-orig", "planner", _BRD_PRD_SDD_BENCHMARK_MANIFEST, contents
  334 + )
  335 + with patch.object(work_graph_execute, "write_workflow_script"), \
  336 + patch.object(work_graph_execute, "execute_work_graph") as mock_execute:
  337 + mock_execute.side_effect = AssertionError("should not be called -- nothing downstream of benchmark")
  338 + result = work_graph_fork.fork_work_graph_run(
  339 + self.conn, node_exec_ids["benchmark"], {"content": "different benchmark"}, "/tmp/proj-1",
  340 + )
  341 + self.assertEqual(result["outputs"]["benchmark"], {"content": "different benchmark"})
  342 +
  343 +
  344 +if __name__ == "__main__":
  345 + unittest.main()
... ...
src/test_work_graph_run_query.py
... ... @@ -27,11 +27,13 @@ class WorkGraphRunQueryTestBase(unittest.TestCase):
27 27 self.conn.close()
28 28  
29 29 def _insert_run(self, run_id, org_node, status="done", started_at="2026-08-01T00:00:00",
30   - completed_at=None, task_id="task-1", node_count=0):
  30 + completed_at=None, task_id="task-1", node_count=0,
  31 + parent_run_id=None, fork_of_node_exec_id=None):
31 32 self.conn.execute(
32 33 "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status, "
33   - "started_at, completed_at) VALUES (?, ?, ?, '{}', ?, ?, ?)",
34   - (run_id, task_id, org_node, status, started_at, completed_at),
  34 + "started_at, completed_at, parent_run_id, fork_of_node_exec_id) "
  35 + "VALUES (?, ?, ?, '{}', ?, ?, ?, ?, ?)",
  36 + (run_id, task_id, org_node, status, started_at, completed_at, parent_run_id, fork_of_node_exec_id),
35 37 )
36 38 for i in range(node_count):
37 39 node_exec_id = "{}-node-{}".format(run_id, i)
... ... @@ -145,6 +147,36 @@ class GetWorkGraphRunTest(WorkGraphRunQueryTestBase):
145 147 result = work_graph_run_query.get_work_graph_run(self.conn, "run-1")
146 148 self.assertEqual(result["nodes"], [])
147 149  
  150 + def test_normal_run_has_null_fork_fields(self):
  151 + self._insert_run("run-1", "planner", node_count=0)
  152 + result = work_graph_run_query.get_work_graph_run(self.conn, "run-1")
  153 + self.assertIsNone(result["parent_run_id"])
  154 + self.assertIsNone(result["fork_of_node_exec_id"])
  155 +
  156 + def test_forked_run_surfaces_parent_and_source_node(self):
  157 + # D (2026-08-06): a fork must be traceable back to which run and
  158 + # which specific node it was forked from.
  159 + self._insert_run("run-orig", "planner", node_count=1)
  160 + self._insert_run(
  161 + "run-fork", "planner", node_count=0,
  162 + parent_run_id="run-orig", fork_of_node_exec_id="run-orig-node-0",
  163 + )
  164 + result = work_graph_run_query.get_work_graph_run(self.conn, "run-fork")
  165 + self.assertEqual(result["parent_run_id"], "run-orig")
  166 + self.assertEqual(result["fork_of_node_exec_id"], "run-orig-node-0")
  167 +
  168 +
  169 +class ListWorkGraphRunsForkFieldsTest(WorkGraphRunQueryTestBase):
  170 + def test_fork_fields_surfaced_in_list(self):
  171 + self._insert_run("run-orig", "planner", node_count=1)
  172 + self._insert_run(
  173 + "run-fork", "planner", parent_run_id="run-orig", fork_of_node_exec_id="run-orig-node-0",
  174 + )
  175 + by_run_id = {r["run_id"]: r for r in work_graph_run_query.list_work_graph_runs(self.conn, "proj-1")}
  176 + self.assertIsNone(by_run_id["run-orig"]["parent_run_id"])
  177 + self.assertEqual(by_run_id["run-fork"]["parent_run_id"], "run-orig")
  178 + self.assertEqual(by_run_id["run-fork"]["fork_of_node_exec_id"], "run-orig-node-0")
  179 +
148 180  
149 181 if __name__ == "__main__":
150 182 unittest.main()
... ...
src/work_graph_fork.py 0 → 100644
  1 +"""D (2026-08-06): Work Graph time-travel debugging -- fork a new
  2 +work_graph_run from an already-'done' work_node_execution, substituting a
  3 +human-provided output for that node and re-executing everything strictly
  4 +downstream of it for real, while every other node reuses the original
  5 +run's persisted output_payload untouched. See
  6 +docs/todo/work-graph-time-travel-debugging-design.md for the full design.
  7 +
  8 +**Output substitution, not re-prompting** (design doc §2, resolving the
  9 +requirements doc's FR-1 ambiguity): the fork point's own output_payload is
  10 +replaced directly with what the human supplies -- it is NOT re-executed
  11 +via a new claude -p call. This sidesteps having to reconcile the different
  12 +build_prompt/input_payload conventions different callers use (Planner's
  13 +fixed artifact_type lookup, E's generic task_prompt field, F's jury) --
  14 +this module only needs the persisted graph_manifest and each node's
  15 +output_payload, nothing caller-specific, for the SUBSTITUTED node. The
  16 +nodes that DO get genuinely re-executed (the strict downstream subgraph)
  17 +still need a real build_prompt, which is reconstructed generically -- see
  18 +_reconstruct_build_prompt.
  19 +
  20 +**v1 restricts forking to a parent run that has already reached a
  21 +terminal status** ('done'/'failed'/'blocked') -- design doc §1: this keeps
  22 +the new fork completely outside ux_work_graph_run_active's partial unique
  23 +index (that index only applies to non-terminal statuses), so no schema/
  24 +constraint change is needed to avoid a collision. Forking from a
  25 +still-in-flight parent run is left for a future iteration.
  26 +
  27 +**Zero changes to the execution engine** (design doc §3): pre-seeding
  28 +every non-downstream node as a 'done' work_node_execution row before
  29 +calling run_graph() on the UNCHANGED original graph_manifest is enough --
  30 +run_work_node()'s existing REQ-06 breakpoint-recovery short-circuit
  31 +(already-'done' nodes return their persisted output without calling
  32 +run_fn) does all the "reuse without re-executing" work for free.
  33 +"""
  34 +
  35 +import json
  36 +import threading
  37 +import time
  38 +import uuid
  39 +
  40 +from src import e2e_pipeline, schema, work_graph_orchestrate, work_graph_plan
  41 +
  42 +_FORK_INPUT_SCHEMA = {"type": "object", "required": ["node_id"]}
  43 +_FORK_OUTPUT_SCHEMA = {"type": "object", "required": ["content"]}
  44 +_TERMINAL_RUN_STATUSES = ("done", "failed", "blocked")
  45 +
  46 +
  47 +class ForkSourceNotDoneError(Exception):
  48 + """FR-1: can only fork from a work_node_execution with status='done' --
  49 + its output_payload has to exist to be a valid substitution point."""
  50 +
  51 +
  52 +class ForkParentRunNotTerminalError(Exception):
  53 + """v1 scope cut (design doc §1): forking from a node inside a
  54 + still-in-flight parent run would collide with ux_work_graph_run_active
  55 + (same task_id+org_node, both non-terminal) -- not supported yet."""
  56 +
  57 +
  58 +class ForkGraphShapeUnsupportedError(Exception):
  59 + """The original graph_manifest doesn't carry enough self-contained
  60 + information to reconstruct a build_prompt generically for the
  61 + re-executed downstream nodes -- e.g. F's jury nodes or Coder's single
  62 + ad hoc node, which rely on caller-side closures over state that isn't
  63 + persisted in graph_manifest itself (design doc §2)."""
  64 +
  65 +
  66 +def _downstream_node_ids(graph_manifest, start_node_id):
  67 + """Every node_id reachable from start_node_id via depends_on/edges,
  68 + INCLUDING start_node_id itself. Same dependents-graph construction
  69 + topological_layers() uses, kept local here rather than imported since
  70 + it's a small, self-contained piece not worth threading a shared helper
  71 + for across modules."""
  72 + nodes = {n["node_id"]: n for n in graph_manifest["nodes"]}
  73 + dependents = {node_id: [] for node_id in nodes}
  74 + for node in graph_manifest["nodes"]:
  75 + for dep in (node.get("depends_on") or []):
  76 + if dep in dependents:
  77 + dependents[dep].append(node["node_id"])
  78 + for edge in graph_manifest.get("edges", []):
  79 + from_id, to_id = edge.get("from"), edge.get("to")
  80 + if from_id in dependents and to_id in nodes:
  81 + dependents[from_id].append(to_id)
  82 +
  83 + seen = set()
  84 + frontier = [start_node_id]
  85 + while frontier:
  86 + node_id = frontier.pop()
  87 + if node_id in seen:
  88 + continue
  89 + seen.add(node_id)
  90 + frontier.extend(dependents[node_id])
  91 + return seen
  92 +
  93 +
  94 +def _reconstruct_build_prompt(conn, task_id, org_node, graph_manifest):
  95 + """Rebuilds a build_prompt(node, outputs) usable for the re-executed
  96 + downstream subgraph, purely from persisted state -- no access to the
  97 + original call site's Python closures. Two supported shapes (design doc
  98 + §2): Planner's canonical doc chain (every node has 'artifact_type',
  99 + prompt reconstructed via e2e_pipeline's own builder + task.goal_text)
  100 + and E's generic dynamic path (every node carries its own
  101 + self-contained 'task_prompt'). Anything else raises rather than
  102 + silently building broken prompts."""
  103 + nodes = graph_manifest["nodes"]
  104 + if org_node == "planner" and nodes and all("artifact_type" in n for n in nodes):
  105 + goal_text = conn.execute("SELECT goal_text FROM task WHERE task_id = ?", (task_id,)).fetchone()[0]
  106 + node_artifact_types = {n["node_id"]: n["artifact_type"] for n in nodes}
  107 + return e2e_pipeline._planner_prompt_builder(goal_text, node_artifact_types)
  108 + if nodes and all("task_prompt" in n for n in nodes):
  109 + return work_graph_plan.generic_node_prompt_builder
  110 + raise ForkGraphShapeUnsupportedError(
  111 + "graph_manifest nodes must either all have 'artifact_type' (Planner's canonical chain) "
  112 + "or all have 'task_prompt' (the generic dynamic-planning path) to reconstruct prompts for a fork"
  113 + )
  114 +
  115 +
  116 +def create_fork(conn, source_node_exec_id, new_output_payload):
  117 + """Synchronous, DB-only half of a fork: validates the source node and
  118 + parent run, pre-seeds every non-downstream node as 'done' under a
  119 + brand-new work_graph_run, and returns everything execute_fork() needs
  120 + to actually run the downstream subgraph. Split from execute_fork() so
  121 + an HTTP caller can hand the (fast) run_id back immediately and let the
  122 + (slow, real claude -p calls) execution happen in the background --
  123 + same split task_execution.start_task_execution_in_background already
  124 + uses for the manual Start button."""
  125 + row = conn.execute(
  126 + "SELECT wne.run_id, wne.node_id, wne.status, wne.subagent_type, wne.skill_ref, "
  127 + "wgr.task_id, wgr.org_node, wgr.graph_manifest, wgr.status "
  128 + "FROM work_node_execution wne JOIN work_graph_run wgr ON wgr.run_id = wne.run_id "
  129 + "WHERE wne.node_exec_id = ?",
  130 + (source_node_exec_id,),
  131 + ).fetchone()
  132 + if row is None:
  133 + raise ValueError("no such work_node_execution: " + source_node_exec_id)
  134 + (
  135 + source_run_id, source_node_id, source_status, subagent_type, skill_ref,
  136 + task_id, org_node, graph_manifest_json, parent_run_status,
  137 + ) = row
  138 + if source_status != "done":
  139 + raise ForkSourceNotDoneError(
  140 + "work_node_execution {} is status={}, must be 'done' to fork from".format(
  141 + source_node_exec_id, source_status
  142 + )
  143 + )
  144 + if parent_run_status not in _TERMINAL_RUN_STATUSES:
  145 + raise ForkParentRunNotTerminalError(
  146 + "parent work_graph_run {} is status={}, must be terminal ({}) to fork from -- forking from "
  147 + "a still-in-flight run isn't supported yet".format(
  148 + source_run_id, parent_run_status, _TERMINAL_RUN_STATUSES
  149 + )
  150 + )
  151 +
  152 + graph_manifest = json.loads(graph_manifest_json)
  153 + build_prompt = _reconstruct_build_prompt(conn, task_id, org_node, graph_manifest)
  154 +
  155 + downstream = _downstream_node_ids(graph_manifest, source_node_id) - {source_node_id}
  156 + original_outputs = {
  157 + r[0]: r[1] for r in conn.execute(
  158 + "SELECT node_id, output_payload FROM work_node_execution WHERE run_id = ? AND status = 'done'",
  159 + (source_run_id,),
  160 + ).fetchall()
  161 + }
  162 +
  163 + new_run_id = str(uuid.uuid4())
  164 + conn.execute(
  165 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status, started_at, "
  166 + "parent_run_id, fork_of_node_exec_id) VALUES (?, ?, ?, ?, 'running', CURRENT_TIMESTAMP, ?, ?)",
  167 + (new_run_id, task_id, org_node, graph_manifest_json, source_run_id, source_node_exec_id),
  168 + )
  169 + conn.commit()
  170 +
  171 + for node in graph_manifest["nodes"]:
  172 + node_id = node["node_id"]
  173 + if node_id in downstream:
  174 + continue
  175 + payload = new_output_payload if node_id == source_node_id else json.loads(original_outputs[node_id])
  176 + conn.execute(
  177 + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
  178 + "input_schema_ref, output_schema_ref, input_payload, output_payload, status) "
  179 + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'done')",
  180 + (
  181 + str(uuid.uuid4()), new_run_id, node_id, subagent_type, skill_ref,
  182 + "work_graph_node_input.schema.json", "work_graph_node_output.schema.json",
  183 + json.dumps({"node_id": node_id}), json.dumps(payload),
  184 + ),
  185 + )
  186 + conn.commit()
  187 +
  188 + return {
  189 + "run_id": new_run_id, "org_node": org_node, "skill_ref": skill_ref,
  190 + "graph_manifest": graph_manifest, "build_prompt": build_prompt,
  191 + }
  192 +
  193 +
  194 +def execute_fork(conn, fork_info, target_folder_path, timeout=600, sleep=time.sleep, now=time.time):
  195 + """The real (slow) half: runs the downstream subgraph via the
  196 + unmodified run_graph() -- every pre-seeded node short-circuits, every
  197 + downstream node executes for real (see module docstring). Sets the new
  198 + run's status to 'done'/'failed' on completion -- unlike every other
  199 + real graph_manifest run in this codebase, which never transitions
  200 + work_graph_run.status past its initial value (a pre-existing gap, out
  201 + of D's scope to fix generically -- see design doc §3's own note); the
  202 + forked run gets this narrowly because FR-4's console visibility needs
  203 + SOME real completion signal to show, not because this module is fixing
  204 + the wider gap."""
  205 + run_id = fork_info["run_id"]
  206 + try:
  207 + outputs = work_graph_orchestrate.run_graph(
  208 + conn, run_id, fork_info["org_node"], fork_info["skill_ref"], target_folder_path,
  209 + fork_info["graph_manifest"], fork_info["build_prompt"], _FORK_INPUT_SCHEMA, _FORK_OUTPUT_SCHEMA,
  210 + timeout=timeout, sleep=sleep, now=now,
  211 + )
  212 + except Exception:
  213 + conn.execute(
  214 + "UPDATE work_graph_run SET status = 'failed', completed_at = CURRENT_TIMESTAMP WHERE run_id = ?",
  215 + (run_id,),
  216 + )
  217 + conn.commit()
  218 + raise
  219 + conn.execute(
  220 + "UPDATE work_graph_run SET status = 'done', completed_at = CURRENT_TIMESTAMP WHERE run_id = ?", (run_id,)
  221 + )
  222 + conn.commit()
  223 + return outputs
  224 +
  225 +
  226 +def fork_work_graph_run(conn, source_node_exec_id, new_output_payload, target_folder_path, timeout=600):
  227 + """create_fork() + execute_fork() in one synchronous call -- what
  228 + tests and any fully-synchronous caller should use. HTTP handlers use
  229 + the two halves separately (see start_fork_in_background)."""
  230 + fork_info = create_fork(conn, source_node_exec_id, new_output_payload)
  231 + outputs = execute_fork(conn, fork_info, target_folder_path, timeout=timeout)
  232 + return {"run_id": fork_info["run_id"], "outputs": outputs}
  233 +
  234 +
  235 +def start_fork_in_background(db_path, source_node_exec_id, new_output_payload, target_folder_path, timeout=600):
  236 + """HTTP entrypoint: create_fork() runs synchronously (fast, DB-only)
  237 + so the caller gets run_id back immediately and can navigate the
  238 + console straight to it; execute_fork()'s real claude -p calls then run
  239 + in a background daemon thread, own connection (check_same_thread=False
  240 + -- same reasoning as scheduler.run_goal_to_completion's own conn, see
  241 + that module's comment: this can reach run_graph()'s multi-node
  242 + concurrent layers). Returns (run_id, thread) -- thread is already
  243 + started, callers/tests can .join() it if needed (mirrors
  244 + task_execution.start_task_execution_in_background's own Thread
  245 + return)."""
  246 + conn = schema.connect(db_path, check_same_thread=False)
  247 + fork_info = create_fork(conn, source_node_exec_id, new_output_payload)
  248 +
  249 + def run():
  250 + try:
  251 + execute_fork(conn, fork_info, target_folder_path, timeout=timeout)
  252 + except Exception:
  253 + pass # already reflected in work_graph_run/work_node_execution status
  254 + finally:
  255 + conn.close()
  256 +
  257 + thread = threading.Thread(target=run, daemon=True)
  258 + thread.start()
  259 + return fork_info["run_id"], thread
... ...
src/work_graph_run_query.py
... ... @@ -46,7 +46,8 @@ def list_work_graph_runs(conn, project_id, org_node=None, limit=20):
46 46  
47 47 query = (
48 48 "SELECT wgr.run_id, wgr.task_id, wgr.org_node, wgr.status, wgr.started_at, wgr.completed_at, "
49   - "(SELECT COUNT(*) FROM work_node_execution wne WHERE wne.run_id = wgr.run_id) AS node_count "
  49 + "(SELECT COUNT(*) FROM work_node_execution wne WHERE wne.run_id = wgr.run_id) AS node_count, "
  50 + "wgr.parent_run_id, wgr.fork_of_node_exec_id "
50 51 "FROM work_graph_run wgr JOIN task t ON t.task_id = wgr.task_id WHERE t.project_id = ?"
51 52 )
52 53 params = [project_id]
... ... @@ -61,6 +62,9 @@ def list_work_graph_runs(conn, project_id, org_node=None, limit=20):
61 62 {
62 63 "run_id": r[0], "task_id": r[1], "org_node": r[2], "status": r[3],
63 64 "started_at": r[4], "completed_at": r[5], "node_count": r[6],
  65 + # D (2026-08-06): both None for a normal run; both set when this
  66 + # run was forked from a historical node (src/work_graph_fork.py).
  67 + "parent_run_id": r[7], "fork_of_node_exec_id": r[8],
64 68 }
65 69 for r in rows
66 70 ]
... ... @@ -72,7 +76,8 @@ class WorkGraphRunNotFoundError(Exception):
72 76  
73 77 def get_work_graph_run(conn, run_id):
74 78 row = conn.execute(
75   - "SELECT run_id, task_id, org_node, graph_manifest, status, started_at, completed_at "
  79 + "SELECT run_id, task_id, org_node, graph_manifest, status, started_at, completed_at, "
  80 + "parent_run_id, fork_of_node_exec_id "
76 81 "FROM work_graph_run WHERE run_id = ?",
77 82 (run_id,),
78 83 ).fetchone()
... ... @@ -82,6 +87,7 @@ def get_work_graph_run(conn, run_id):
82 87 "run_id": row[0], "task_id": row[1], "org_node": row[2],
83 88 "graph_manifest": json.loads(row[3]) if row[3] else None,
84 89 "status": row[4], "started_at": row[5], "completed_at": row[6],
  90 + "parent_run_id": row[7], "fork_of_node_exec_id": row[8],
85 91 "nodes": _nodes_for_run(conn, row[0]),
86 92 }
87 93  
... ...