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,7 +62,7 @@ | ||
| 62 | 62 | ||
| 63 | ## D. Time-travel 调试要不要立项 | 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 | **来源**:综述版对照 §6 | 67 | **来源**:综述版对照 §6 |
| 68 | 68 | ||
| @@ -70,7 +70,9 @@ | @@ -70,7 +70,9 @@ | ||
| 70 | 70 | ||
| 71 | **确认结果(2026-08-05)**:立项。 | 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,7 +144,7 @@ | ||
| 142 | | **F** 三视角评审团 fan-out | `judge_root_cause` 从 Python 函数改成 Work Graph 菱形拓扑 | ✅ **已实现**(2026-08-06,586 测试全绿) | M | 已完成 | 无(G 已满足) | | 144 | | **F** 三视角评审团 fan-out | `judge_root_cause` 从 Python 函数改成 Work Graph 菱形拓扑 | ✅ **已实现**(2026-08-06,586 测试全绿) | M | 已完成 | 无(G 已满足) | |
| 143 | | **B** loop-until-dry Skill | Validator 未知规模扫描收敛能力 | ✅ **已实现**(2026-08-06,587 测试全绿) | S/M | 已完成 | 无 | | 145 | | **B** loop-until-dry Skill | Validator 未知规模扫描收敛能力 | ✅ **已实现**(2026-08-06,587 测试全绿) | S/M | 已完成 | 无 | |
| 144 | | **H** Role Package 抽象层 | 统一 role/context/skills/applications/workflows/permissions/acceptance 打包格式 | ✅ 需求评审通过(2026-08-06),待设计 | XL | P2(战略级,不阻塞其他项) | workflows 字段依赖 E 的产出(已满足) | | 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 | | C 按节点选模型 | — | ⛔ 已确认不做 | — | — | — | | 148 | | C 按节点选模型 | — | ⛔ 已确认不做 | — | — | — | |
| 147 | | A 术语对照 | — | ✅ 已完成 | — | — | — | | 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,10 +1487,26 @@ function mountTaskDetail(taskId, initialOrgNode) { | ||
| 1487 | // existing auto-scroll checkbox keeps working without touching | 1487 | // existing auto-scroll checkbox keeps working without touching |
| 1488 | // mountLogViewer itself. | 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 | function renderRunNodeListHtml(run) { | 1496 | function renderRunNodeListHtml(run) { |
| 1491 | if (!run.nodes.length) return '<div class="hint">该运行尚无节点</div>'; | 1497 | if (!run.nodes.length) return '<div class="hint">该运行尚无节点</div>'; |
| 1492 | const items = run.nodes.map((wn) => { | 1498 | const items = run.nodes.map((wn) => { |
| 1493 | const status = wn.status; | 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 | return ( | 1510 | return ( |
| 1495 | '<li class="status-' + status + '">' | 1511 | '<li class="status-' + status + '">' |
| 1496 | + '<div class="todo-row" data-node-exec-id="' + wn.node_exec_id + '">' | 1512 | + '<div class="todo-row" data-node-exec-id="' + wn.node_exec_id + '">' |
| @@ -1502,6 +1518,7 @@ function renderRunNodeListHtml(run) { | @@ -1502,6 +1518,7 @@ function renderRunNodeListHtml(run) { | ||
| 1502 | + '<div class="todo-detail" id="todo-detail-' + wn.node_exec_id + '" data-status="' + status + '">' | 1518 | + '<div class="todo-detail" id="todo-detail-' + wn.node_exec_id + '" data-status="' + status + '">' |
| 1503 | + (wn.reflection_notes ? "reflection_notes: " + escapeHtml(wn.reflection_notes) + "<br>" : "") | 1519 | + (wn.reflection_notes ? "reflection_notes: " + escapeHtml(wn.reflection_notes) + "<br>" : "") |
| 1504 | + '<div class="log-viewer-slot" id="log-slot-' + wn.node_exec_id + '"></div>' | 1520 | + '<div class="log-viewer-slot" id="log-slot-' + wn.node_exec_id + '"></div>' |
| 1521 | + + forkHtml | ||
| 1505 | + "</div>" | 1522 | + "</div>" |
| 1506 | + "</li>" | 1523 | + "</li>" |
| 1507 | ); | 1524 | ); |
| @@ -1509,6 +1526,58 @@ function renderRunNodeListHtml(run) { | @@ -1509,6 +1526,58 @@ function renderRunNodeListHtml(run) { | ||
| 1509 | return '<ul class="todo-tree">' + items + "</ul>"; | 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 | function renderRunDetail(runId) { | 1581 | function renderRunDetail(runId) { |
| 1513 | if (!getCurrentProjectId()) return renderNoProjectSelected(); | 1582 | if (!getCurrentProjectId()) return renderNoProjectSelected(); |
| 1514 | return ` | 1583 | return ` |
| @@ -1540,6 +1609,7 @@ function mountRunDetail(runId) { | @@ -1540,6 +1609,7 @@ function mountRunDetail(runId) { | ||
| 1540 | closeAllLogViewers(); | 1609 | closeAllLogViewers(); |
| 1541 | content.innerHTML = ( | 1610 | content.innerHTML = ( |
| 1542 | "<h1>" + escapeHtml(run.org_node) + " Work Graph Run</h1>" | 1611 | "<h1>" + escapeHtml(run.org_node) + " Work Graph Run</h1>" |
| 1612 | + + renderForkBadgeHtml(run) | ||
| 1543 | + '<div class="meta-row">' | 1613 | + '<div class="meta-row">' |
| 1544 | + "<div><b>RUN_ID</b>" + escapeHtml(run.run_id) + "</div>" | 1614 | + "<div><b>RUN_ID</b>" + escapeHtml(run.run_id) + "</div>" |
| 1545 | + "<div><b>STATUS</b>" + escapeHtml(run.status) + "</div>" | 1615 | + "<div><b>STATUS</b>" + escapeHtml(run.status) + "</div>" |
| @@ -1566,6 +1636,7 @@ function mountRunDetail(runId) { | @@ -1566,6 +1636,7 @@ function mountRunDetail(runId) { | ||
| 1566 | } | 1636 | } |
| 1567 | }); | 1637 | }); |
| 1568 | } | 1638 | } |
| 1639 | + mountForkPanels(content); | ||
| 1569 | mountGraphVisualizerTooltips(content); | 1640 | mountGraphVisualizerTooltips(content); |
| 1570 | 1641 | ||
| 1571 | if (!socket) { | 1642 | if (!socket) { |
| @@ -1668,7 +1739,8 @@ async function mountArtifactsOverviewPanel(container, projectId) { | @@ -1668,7 +1739,8 @@ async function mountArtifactsOverviewPanel(container, projectId) { | ||
| 1668 | const runsHtml = runs.length === 0 ? '<div class="hint">暂无 Graph 运行记录</div>' : ( | 1739 | const runsHtml = runs.length === 0 ? '<div class="hint">暂无 Graph 运行记录</div>' : ( |
| 1669 | '<ul class="simple-list" id="gc_runs_list">' + runs.slice(0, 10).map((r) => | 1740 | '<ul class="simple-list" id="gc_runs_list">' + runs.slice(0, 10).map((r) => |
| 1670 | '<li data-run-id="' + r.run_id + '" style="cursor:pointer;"><span>' + escapeHtml(r.org_node) + " · run " | 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 | ).join("") + "</ul>" | 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,3 +191,12 @@ pre.code { background: #0b1120; color: #d1d5db; padding: 14px; border-radius: 8p | ||
| 191 | .iteration .summary { font-size: 13px; margin-bottom: 8px; } | 191 | .iteration .summary { font-size: 13px; margin-bottom: 8px; } |
| 192 | .iteration .actions { display: flex; gap: 8px; } | 192 | .iteration .actions { display: flex; gap: 8px; } |
| 193 | .iteration .diff-output { margin-top: 10px; } | 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,6 +53,9 @@ const src = fs.readFileSync(path.join(__dirname, "app.js"), "utf8"); | ||
| 53 | // reach the sandbox object (same Annex B quirk the other smoke tests | 53 | // reach the sandbox object (same Annex B quirk the other smoke tests |
| 54 | // already document) -- re-export what this test needs. | 54 | // already document) -- re-export what this test needs. |
| 55 | const augmentedSrc = src + "\nglobalThis.RUN_DETAIL_RE = RUN_DETAIL_RE;\n"; | 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 | new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox); | 59 | new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox); |
| 57 | 60 | ||
| 58 | let assertionCount = 0; | 61 | let assertionCount = 0; |
| @@ -114,4 +117,34 @@ function check(condition, message) { | @@ -114,4 +117,34 @@ function check(condition, message) { | ||
| 114 | check(!li.includes("data-run-task-id"), "the old task-switcher click target must be gone"); | 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 | console.log("frontend/test_run_detail_smoke.js: all " + assertionCount + " assertions passed"); | 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,8 +39,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | ||
| 39 | from src import ( | 39 | from src import ( |
| 40 | artifact_query, budget_guardrail, bug_record, deployment_query, execution_log, | 40 | artifact_query, budget_guardrail, bug_record, deployment_query, execution_log, |
| 41 | org_loop_route, paths, project_query, project_scaffold, schema, settings_config, | 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 | _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend") | 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,6 +131,7 @@ _ARTIFACTS_PREFIX = "/api/artifacts/" | ||
| 131 | _SKILLS_PREFIX = "/api/skills/" | 131 | _SKILLS_PREFIX = "/api/skills/" |
| 132 | _DEPLOYMENTS_PREFIX = "/api/deployments/" | 132 | _DEPLOYMENTS_PREFIX = "/api/deployments/" |
| 133 | _WORK_GRAPH_RUNS_PREFIX = "/api/work-graph-runs/" | 133 | _WORK_GRAPH_RUNS_PREFIX = "/api/work-graph-runs/" |
| 134 | +_FORK_SUFFIX = "/fork" | ||
| 134 | 135 | ||
| 135 | 136 | ||
| 136 | class _Handler(BaseHTTPRequestHandler): | 137 | class _Handler(BaseHTTPRequestHandler): |
| @@ -254,6 +255,9 @@ class _Handler(BaseHTTPRequestHandler): | @@ -254,6 +255,9 @@ class _Handler(BaseHTTPRequestHandler): | ||
| 254 | elif self.path.startswith(_SKILLS_PREFIX) and self.path.endswith("/rollback"): | 255 | elif self.path.startswith(_SKILLS_PREFIX) and self.path.endswith("/rollback"): |
| 255 | skill_id = self.path[len(_SKILLS_PREFIX):-len("/rollback")] | 256 | skill_id = self.path[len(_SKILLS_PREFIX):-len("/rollback")] |
| 256 | self._handle_skill_rollback(skill_id) | 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 | else: | 261 | else: |
| 258 | self._send_json(404, {"error": "NOT_FOUND"}) | 262 | self._send_json(404, {"error": "NOT_FOUND"}) |
| 259 | 263 | ||
| @@ -686,6 +690,53 @@ class _Handler(BaseHTTPRequestHandler): | @@ -686,6 +690,53 @@ class _Handler(BaseHTTPRequestHandler): | ||
| 686 | else: | 690 | else: |
| 687 | self._send_json(200, result) | 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 | def _handle_artifact_get(self, artifact_id): | 740 | def _handle_artifact_get(self, artifact_id): |
| 690 | try: | 741 | try: |
| 691 | with self.server.conn_lock: | 742 | with self.server.conn_lock: |
src/schema.py
| @@ -127,7 +127,16 @@ CREATE TABLE work_graph_run ( | @@ -127,7 +127,16 @@ CREATE TABLE work_graph_run ( | ||
| 127 | )), | 127 | )), |
| 128 | started_at TEXT, | 128 | started_at TEXT, |
| 129 | completed_at TEXT, | 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 | -- only one active (non-terminal) run per (task_id, org_node) at a time | 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,3 +407,22 @@ def migrate_expand_artifact_types(conn): | ||
| 398 | """ | 407 | """ |
| 399 | ) | 408 | ) |
| 400 | conn.commit() | 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,7 +155,8 @@ def get_task(conn, task_id): | ||
| 155 | ] | 155 | ] |
| 156 | 156 | ||
| 157 | runs = conn.execute( | 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 | "FROM work_graph_run WHERE task_id = ? ORDER BY started_at", | 160 | "FROM work_graph_run WHERE task_id = ? ORDER BY started_at", |
| 160 | (task_id,), | 161 | (task_id,), |
| 161 | ).fetchall() | 162 | ).fetchall() |
| @@ -164,6 +165,9 @@ def get_task(conn, task_id): | @@ -164,6 +165,9 @@ def get_task(conn, task_id): | ||
| 164 | "run_id": w[0], "org_node": w[1], | 165 | "run_id": w[0], "org_node": w[1], |
| 165 | "graph_manifest": json.loads(w[2]) if w[2] else None, | 166 | "graph_manifest": json.loads(w[2]) if w[2] else None, |
| 166 | "status": w[3], "started_at": w[4], "completed_at": w[5], | 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 | "nodes": _work_nodes_for_run(conn, w[0]), | 171 | "nodes": _work_nodes_for_run(conn, w[0]), |
| 168 | } | 172 | } |
| 169 | for w in runs | 173 | for w in runs |
src/test_backend_server.py
| @@ -1368,6 +1368,117 @@ class WorkGraphRunListRouteTest(unittest.TestCase): | @@ -1368,6 +1368,117 @@ class WorkGraphRunListRouteTest(unittest.TestCase): | ||
| 1368 | self.assertEqual(body["error"], "NOT_FOUND") | 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 | class DeploymentRouteTest(unittest.TestCase): | 1482 | class DeploymentRouteTest(unittest.TestCase): |
| 1372 | """U-043: c_deployment_list/c_deployment_current/c_deployment_get for | 1483 | """U-043: c_deployment_list/c_deployment_current/c_deployment_get for |
| 1373 | v_deployment_status.""" | 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 +6,7 @@ total) and widened `bug_record.root_cause_category` to accept `core_engine`. | ||
| 6 | Run: python3 -m unittest src.test_schema -v | 6 | Run: python3 -m unittest src.test_schema -v |
| 7 | """ | 7 | """ |
| 8 | 8 | ||
| 9 | +import re | ||
| 9 | import sqlite3 | 10 | import sqlite3 |
| 10 | import unittest | 11 | import unittest |
| 11 | 12 | ||
| @@ -489,5 +490,65 @@ class DeployHealthSignalTest(ConstraintTestBase): | @@ -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 | if __name__ == "__main__": | 553 | if __name__ == "__main__": |
| 493 | unittest.main() | 554 | unittest.main() |
src/test_task_query.py
| @@ -143,10 +143,38 @@ class GetTaskTest(TaskQueryTestBase): | @@ -143,10 +143,38 @@ class GetTaskTest(TaskQueryTestBase): | ||
| 143 | self.assertEqual(run["graph_manifest"], manifest) | 143 | self.assertEqual(run["graph_manifest"], manifest) |
| 144 | self.assertEqual(len(run["nodes"]), 1) | 144 | self.assertEqual(len(run["nodes"]), 1) |
| 145 | self.assertEqual(run["nodes"][0]["node_id"], "n1") | 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 | def test_work_node_includes_quota_wait_fields_for_v_graph_visualizer(self): | 179 | def test_work_node_includes_quota_wait_fields_for_v_graph_visualizer(self): |
| 152 | # U-026: v_graph_visualizer's waiting_for_quota state needs | 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,11 +27,13 @@ class WorkGraphRunQueryTestBase(unittest.TestCase): | ||
| 27 | self.conn.close() | 27 | self.conn.close() |
| 28 | 28 | ||
| 29 | def _insert_run(self, run_id, org_node, status="done", started_at="2026-08-01T00:00:00", | 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 | self.conn.execute( | 32 | self.conn.execute( |
| 32 | "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status, " | 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 | for i in range(node_count): | 38 | for i in range(node_count): |
| 37 | node_exec_id = "{}-node-{}".format(run_id, i) | 39 | node_exec_id = "{}-node-{}".format(run_id, i) |
| @@ -145,6 +147,36 @@ class GetWorkGraphRunTest(WorkGraphRunQueryTestBase): | @@ -145,6 +147,36 @@ class GetWorkGraphRunTest(WorkGraphRunQueryTestBase): | ||
| 145 | result = work_graph_run_query.get_work_graph_run(self.conn, "run-1") | 147 | result = work_graph_run_query.get_work_graph_run(self.conn, "run-1") |
| 146 | self.assertEqual(result["nodes"], []) | 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 | if __name__ == "__main__": | 181 | if __name__ == "__main__": |
| 150 | unittest.main() | 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,7 +46,8 @@ def list_work_graph_runs(conn, project_id, org_node=None, limit=20): | ||
| 46 | 46 | ||
| 47 | query = ( | 47 | query = ( |
| 48 | "SELECT wgr.run_id, wgr.task_id, wgr.org_node, wgr.status, wgr.started_at, wgr.completed_at, " | 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 | "FROM work_graph_run wgr JOIN task t ON t.task_id = wgr.task_id WHERE t.project_id = ?" | 51 | "FROM work_graph_run wgr JOIN task t ON t.task_id = wgr.task_id WHERE t.project_id = ?" |
| 51 | ) | 52 | ) |
| 52 | params = [project_id] | 53 | params = [project_id] |
| @@ -61,6 +62,9 @@ def list_work_graph_runs(conn, project_id, org_node=None, limit=20): | @@ -61,6 +62,9 @@ def list_work_graph_runs(conn, project_id, org_node=None, limit=20): | ||
| 61 | { | 62 | { |
| 62 | "run_id": r[0], "task_id": r[1], "org_node": r[2], "status": r[3], | 63 | "run_id": r[0], "task_id": r[1], "org_node": r[2], "status": r[3], |
| 63 | "started_at": r[4], "completed_at": r[5], "node_count": r[6], | 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 | for r in rows | 69 | for r in rows |
| 66 | ] | 70 | ] |
| @@ -72,7 +76,8 @@ class WorkGraphRunNotFoundError(Exception): | @@ -72,7 +76,8 @@ class WorkGraphRunNotFoundError(Exception): | ||
| 72 | 76 | ||
| 73 | def get_work_graph_run(conn, run_id): | 77 | def get_work_graph_run(conn, run_id): |
| 74 | row = conn.execute( | 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 | "FROM work_graph_run WHERE run_id = ?", | 81 | "FROM work_graph_run WHERE run_id = ?", |
| 77 | (run_id,), | 82 | (run_id,), |
| 78 | ).fetchone() | 83 | ).fetchone() |
| @@ -82,6 +87,7 @@ def get_work_graph_run(conn, run_id): | @@ -82,6 +87,7 @@ def get_work_graph_run(conn, run_id): | ||
| 82 | "run_id": row[0], "task_id": row[1], "org_node": row[2], | 87 | "run_id": row[0], "task_id": row[1], "org_node": row[2], |
| 83 | "graph_manifest": json.loads(row[3]) if row[3] else None, | 88 | "graph_manifest": json.loads(row[3]) if row[3] else None, |
| 84 | "status": row[4], "started_at": row[5], "completed_at": row[6], | 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 | "nodes": _nodes_for_run(conn, row[0]), | 91 | "nodes": _nodes_for_run(conn, row[0]), |
| 86 | } | 92 | } |
| 87 | 93 |