Commit 836e16f64882a2a00f1f2a23575029f42f2c75bc
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>
Showing
15 changed files
with
1119 additions
and
17 deletions
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, "app.js"), "utf8"); |
| 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 = "/api/artifacts/" |
| 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 | ... | ... |