Commit 41a0492e632f49f04fea103612071e4d3d963018

Authored by bboonnee
1 parent a68f0adb

Org Graph unification Phase 2c §2.1/§2.2: drop work_graph_run.org_node

A merged graph's one run genuinely spans several org_nodes now (Planner/
Coder/Validator all append their own nodes into the SAME run) -- a single
run-level org_node column was never accurate once Phase 2b/2c landed, and
real per-node ownership already lives in work_node_execution.subagent_type.

Schema: work_graph_run.org_node column removed; ux_work_graph_run_active
narrowed from UNIQUE(task_id, org_node) to UNIQUE(task_id) WHERE status
NOT IN ('done', 'failed') -- 'blocked' now counts as active on purpose,
since resuming a blocked run always appends into the same row rather than
inserting a fresh one. New migrate_drop_work_graph_run_org_node() handles
already-populated databases, wired into backend_server.py's startup path.

Call sites updated: work_graph_plan.py/work_graph_fork.py/
root_cause_trace.py's INSERTs; work_graph_run_query.py/task_query.py now
return "org_nodes_involved" (derived from work_node_execution.subagent_type)
instead of the removed single-value "org_node"; frontend/app.js's org-strip
and run-list rendering updated to the list-based shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/todo/org-graph-unification-phase2-universal-self-planning-design.md
@@ -3,7 +3,9 @@ @@ -3,7 +3,9 @@
3 **日期**: 2026-08-08 3 **日期**: 2026-08-08
4 **性质**: 详细设计,对应 `docs/todo/org-graph-unification-and-universal-self-planning-requirements.md` 的 Phase 2(FR-2/FR-3/FR-4),按 §4/§4.1 已拍板的合图(方案 A)路径设计。 4 **性质**: 详细设计,对应 `docs/todo/org-graph-unification-and-universal-self-planning-requirements.md` 的 Phase 2(FR-2/FR-3/FR-4),按 §4/§4.1 已拍板的合图(方案 A)路径设计。
5 **依赖**: 硬依赖 Phase 1(`docs/done/org-graph-unification-phase1-review-gate-design.md`)——已实现(commit `ea19c7d`,2026-08-08)。§9 接口核对清单已于 2026-08-12 逐条核对完成:三项全部吻合(`plan_and_review_documents()` 经 `_review_planner_output_with_retries()` 透传 `plan_documents()` 的 `{"run_id", "artifact_ids"}` 返回形状,含 `run_id`;`task.status` 新值确认是 `reviewing_planner_output`;`roles/planner_reviewer/role.json` 目前 `skills: ["planner-output-review", "unattended-agent-operations"]`,未预留 §7.1 新 skill 位置,Phase 2 实施时新增一行即可,不是不兼容)。 5 **依赖**: 硬依赖 Phase 1(`docs/done/org-graph-unification-phase1-review-gate-design.md`)——已实现(commit `ea19c7d`,2026-08-08)。§9 接口核对清单已于 2026-08-12 逐条核对完成:三项全部吻合(`plan_and_review_documents()` 经 `_review_planner_output_with_retries()` 透传 `plan_documents()` 的 `{"run_id", "artifact_ids"}` 返回形状,含 `run_id`;`task.status` 新值确认是 `reviewing_planner_output`;`roles/planner_reviewer/role.json` 目前 `skills: ["planner-output-review", "unattended-agent-operations"]`,未预留 §7.1 新 skill 位置,Phase 2 实施时新增一行即可,不是不兼容)。
6 -**状态**: Phase 2a(commit `86fc0bb`)+ Phase 2b(commit `7c46a2f`)已实现,2026-08-12。Phase 2c 的 Validator 自规划改造(§6 核心部分)已实现,2026-08-12:`validate_implementation_with_own_graph()` 改为 `plan_work_graph(conn, "validator", ..., append_to_run_id=run_id)` 追加进 Planner/Coder 共享的同一个 `work_graph_run`,终审 verdict 变成图内一个真实节点(`node_type='final_verdict'`,`judgment.kind='mechanical'`、`check_ref='verdict_says_pass'`,`on_fail.action='dynamic_loop_back'`——Validator 图里唯一允许用这个 action 的节点),硬门槛(脚本真实非零退出码强制 FAIL,不听 LLM 辩解)通过 `_verdict_node_build_prompt`/`_verdict_says_pass_check` 的共享可变状态闭包保留,Python 强制而非只是 prompt 里说说。`_VALIDATOR_MANIFEST`/`_VALIDATOR_DOC_PROMPTS`/`_VALIDATOR_VERDICT_PROMPT`/`_validator_prompt_builder` 保留但不再使用(同 `_CODER_PROMPT` 先例)。`python3 -m unittest discover -s src`(756 tests)+ `discover -s supervisor`(126 tests)全绿。**尚未实施**:§2.1(`org_node` 列删除)、§2.2(`ux_work_graph_run_active` 收紧到 `UNIQUE(task_id)`)、§7.1(新 skill `work-graph-node-review` + 三个 role.json 追加)、§7.2 的收口验证(见 §10 Phase 2c 小节末尾新增的分析:一旦 verdict 节点自己的 `dynamic_loop_back` 内部消化重试,`validate_implementation_with_own_graph()` 正常返回时 verdict 必为 PASS,`org_level_loop.py` 的旧粗粒度路由对这条新路径自然不可达,不需要显式新旧流程判断——决定保留旧分支代码不删,加注释说明,不做额外改动)。**2026-08-08 修订**:写作过程中,需求文档 §7 的四个开放问题已被用户直接拍板(详见该文档 §7 对应条目),其中两条推翻了本文档最初的设计判断——Coder/Validator 自规划改为"建议节点清单"而非完全自由(§3),`work_graph_run.org_node` 改为彻底移除而非保留(§2.1)——本版本已按拍板结果改写,不是原始草稿。**2026-08-08 追加**(同日,见需求文档新增 §3.1):FR-4 的目标被明确为"失败即进化"完整方法论(判断失败 → Reflection-review 动态诊断 → 系统性缺陷则先 Fix-Skill → 跳回执行),当时 §5 设计的 `dynamic_loop_back` 只覆盖第 1/2/4 步,第 3 步(Fix-Skill)是已知缺口。**2026-08-12 补齐**:§5 已扩写为完整四步设计(§5.4 Reflection-review 现在同时判断目标节点和缺陷性质,§5.5 新增 Fix-Skill,真实调用既有但从未真正投入使用的 `skill_update.py::update_skill()`),过程中发现两个既有事实(`skills:` frontmatter 只按名字动态加载内容、不需要重新部署;`org_level_loop.py` 的 `_ROUTE_FOR_CATEGORY` 从来没有 `skill_defect` 这个 key,`update_skill()` 在这个代码库里投产以来从未被真正调用过)——详见 §5 开头。由哪个身份执笔改 Skill 已通过 `AskUserQuestion` 确认(2026-08-12):统一用 Validator,见 §5.5。 6 +**状态**: Phase 2a(commit `86fc0bb`)+ Phase 2b(commit `7c46a2f`)已实现,2026-08-12。Phase 2c 的 Validator 自规划改造(§6 核心部分)已实现,2026-08-12:`validate_implementation_with_own_graph()` 改为 `plan_work_graph(conn, "validator", ..., append_to_run_id=run_id)` 追加进 Planner/Coder 共享的同一个 `work_graph_run`,终审 verdict 变成图内一个真实节点(`node_type='final_verdict'`,`judgment.kind='mechanical'`、`check_ref='verdict_says_pass'`,`on_fail.action='dynamic_loop_back'`——Validator 图里唯一允许用这个 action 的节点),硬门槛(脚本真实非零退出码强制 FAIL,不听 LLM 辩解)通过 `_verdict_node_build_prompt`/`_verdict_says_pass_check` 的共享可变状态闭包保留,Python 强制而非只是 prompt 里说说。`_VALIDATOR_MANIFEST`/`_VALIDATOR_DOC_PROMPTS`/`_VALIDATOR_VERDICT_PROMPT`/`_validator_prompt_builder` 保留但不再使用(同 `_CODER_PROMPT` 先例)。§7.2 的收口验证已完成分析(见 §10 Phase 2c 小节末尾:一旦 verdict 节点自己的 `dynamic_loop_back` 内部消化重试,`validate_implementation_with_own_graph()` 正常返回时 verdict 必为 PASS,`org_level_loop.py` 的旧粗粒度路由对这条新路径自然不可达,不需要显式新旧流程判断——决定保留旧分支代码不删,已在 `scheduler.py::finish_task_lifecycle` 加注释说明)。
  7 +
  8 +**2026-08-13 §2.1/§2.2 已实施**:`work_graph_run.org_node` 列彻底删除,`ux_work_graph_run_active` 收紧为 `UNIQUE(task_id) WHERE status NOT IN ('done', 'failed')`('blocked' 故意算作活跃——恢复一个 blocked 的 run 现在总是走 `append_to_run_id` 追加进同一行,不会再 INSERT 新行)。新迁移函数 `schema.migrate_drop_work_graph_run_org_node()`(内部先幂等调用 `migrate_relax_org_node_constraints()`,再做 create-copy-drop-rename,`PRAGMA foreign_keys=OFF` 全程包裹,理由同 `migrate_add_reviewing_planner_output_status` 的真实重启事故记录——`work_graph_run` 是被多张表 REFERENCES 的父表)已接入 `backend_server.py::_ensure_schema()`。下游改动:`work_graph_plan.py`/`work_graph_fork.py`(含 `create_fork()`/`_reconstruct_build_prompt()`/`execute_fork()`,`fork_info["org_node"]` 改名 `fork_info["subagent_type"]`,源节点自己的执行身份)/`root_cause_trace.py` 的三处 INSERT 全部去掉 `org_node` 列;`work_graph_run_query.py`(`list_work_graph_runs`/`get_work_graph_run`)与 `task_query.py::get_task` 的返回形状从单值 `"org_node"` 改成 `"org_nodes_involved"`(从 `work_node_execution.subagent_type` 去重派生的列表,`org_node=` 查询参数语义从等值匹配改成 EXISTS 子查询),前端 `frontend/app.js` 对应改造(`getLatestRunForOrgNode`/`orgNodesLabel`/`renderOrgGraphTopology` 等消费点全部改用列表语义)。发现一处设计文档本节 §2.1.3 没预料到的遗漏点:`task_query.py::get_task` 也有同样的单值 `org_node` 读取,一并修完。`root_cause_trace.py::judge_root_cause_via_work_graph` 的 INSERT 有一个已知、记录在代码注释里的边界情况:索引收紧后,若这个函数被调到时对应 task 已有一个仍处于活跃/blocked 状态的共享 run,会撞索引——目前不是真实风险(这个函数唯一的调用方 `org_level_loop.run_fix_loop` 只在一个已经不可达的分支里被调用,且它自己的测试夹具里对应 task 从未预先插入过 work_graph_run 行),但如果未来重新接上这条路径需要重新评估。`python3 -m unittest discover -s src`(768 tests)+ `discover -s supervisor`(126 tests)+ 4 个 frontend smoke test 全绿。**仍未实施**:§7.1(新 skill `work-graph-node-review` + 三个 role.json 追加)。**2026-08-08 修订**:写作过程中,需求文档 §7 的四个开放问题已被用户直接拍板(详见该文档 §7 对应条目),其中两条推翻了本文档最初的设计判断——Coder/Validator 自规划改为"建议节点清单"而非完全自由(§3),`work_graph_run.org_node` 改为彻底移除而非保留(§2.1)——本版本已按拍板结果改写,不是原始草稿。**2026-08-08 追加**(同日,见需求文档新增 §3.1):FR-4 的目标被明确为"失败即进化"完整方法论(判断失败 → Reflection-review 动态诊断 → 系统性缺陷则先 Fix-Skill → 跳回执行),当时 §5 设计的 `dynamic_loop_back` 只覆盖第 1/2/4 步,第 3 步(Fix-Skill)是已知缺口。**2026-08-12 补齐**:§5 已扩写为完整四步设计(§5.4 Reflection-review 现在同时判断目标节点和缺陷性质,§5.5 新增 Fix-Skill,真实调用既有但从未真正投入使用的 `skill_update.py::update_skill()`),过程中发现两个既有事实(`skills:` frontmatter 只按名字动态加载内容、不需要重新部署;`org_level_loop.py` 的 `_ROUTE_FOR_CATEGORY` 从来没有 `skill_defect` 这个 key,`update_skill()` 在这个代码库里投产以来从未被真正调用过)——详见 §5 开头。由哪个身份执笔改 Skill 已通过 `AskUserQuestion` 确认(2026-08-12):统一用 Validator,见 §5.5。
7 9
8 --- 10 ---
9 11
frontend/app.js
@@ -692,10 +692,23 @@ const ORG_NODE_ORDER = [&quot;planner&quot;, &quot;coder&quot;, &quot;validator&quot;]; @@ -692,10 +692,23 @@ const ORG_NODE_ORDER = [&quot;planner&quot;, &quot;coder&quot;, &quot;validator&quot;];
692 // where on the already-rendered page to scroll/highlight. 692 // where on the already-rendered page to scroll/highlight.
693 693
694 function getLatestRunForOrgNode(task, orgNode) { 694 function getLatestRunForOrgNode(task, orgNode) {
695 - const runs = task.work_graph_runs.filter((r) => r.org_node === orgNode); 695 + // 2026-08-12 (Org Graph unification Phase 2c §2.1): a run's single
  696 + // org_node field is gone -- a merged run genuinely spans several
  697 + // org_nodes now (Planner/Coder/Validator can all append nodes into the
  698 + // SAME run), so "the run for this org_node" is membership in
  699 + // org_nodes_involved (a list, one entry per distinct subagent_type
  700 + // among the run's nodes), not equality against a single value.
  701 + const runs = task.work_graph_runs.filter((r) => (r.org_nodes_involved || []).includes(orgNode));
696 return runs.length ? runs[runs.length - 1] : null; 702 return runs.length ? runs[runs.length - 1] : null;
697 } 703 }
698 704
  705 +// Display label for a run's org_nodes_involved list -- everywhere this
  706 +// file used to show the single old org_node value directly.
  707 +function orgNodesLabel(run) {
  708 + const nodes = run.org_nodes_involved || [];
  709 + return nodes.length ? nodes.join(" + ") : "(no nodes yet)";
  710 +}
  711 +
699 // v_dashboard's org-strip: role + hint text, only ever links out to 712 // v_dashboard's org-strip: role + hint text, only ever links out to
700 // v_task_detail (a genuine page change, so plain hash assignment is fine). 713 // v_task_detail (a genuine page change, so plain hash assignment is fine).
701 function renderDashboardOrgStripHtml(activeOrgNode, runStatusByOrgNode) { 714 function renderDashboardOrgStripHtml(activeOrgNode, runStatusByOrgNode) {
@@ -894,7 +907,7 @@ function mountDashboard() { @@ -894,7 +907,7 @@ function mountDashboard() {
894 const runStatusByOrgNode = {}; 907 const runStatusByOrgNode = {};
895 let waitingNode = null; 908 let waitingNode = null;
896 for (const run of detail.work_graph_runs) { 909 for (const run of detail.work_graph_runs) {
897 - runStatusByOrgNode[run.org_node] = run.status; 910 + for (const orgNode of run.org_nodes_involved || []) runStatusByOrgNode[orgNode] = run.status;
898 if (!waitingNode) waitingNode = run.nodes.find((n) => n.status === "waiting_for_quota"); 911 if (!waitingNode) waitingNode = run.nodes.find((n) => n.status === "waiting_for_quota");
899 } 912 }
900 renderOrgStrip(active.assigned_org_node, runStatusByOrgNode); 913 renderOrgStrip(active.assigned_org_node, runStatusByOrgNode);
@@ -1207,7 +1220,7 @@ function renderGraphVisualizerPanel(run, options) { @@ -1207,7 +1220,7 @@ function renderGraphVisualizerPanel(run, options) {
1207 // id="graph-panel-<run_id>": Org Graph 点击可达详情's scroll/highlight 1220 // id="graph-panel-<run_id>": Org Graph 点击可达详情's scroll/highlight
1208 // target (focusOrgNodePanel above) -- additive, doesn't change any 1221 // target (focusOrgNodePanel above) -- additive, doesn't change any
1209 // existing class/data-* this function already emitted. 1222 // existing class/data-* this function already emitted.
1210 - '<div class="panel" id="graph-panel-' + run.run_id + '"><h2>' + run.org_node + " Work Graph DAG 视图(v_graph_visualizer,run_id=" + run.run_id + ")</h2>" 1223 + '<div class="panel" id="graph-panel-' + run.run_id + '"><h2>' + orgNodesLabel(run) + " Work Graph DAG 视图(v_graph_visualizer,run_id=" + run.run_id + ")</h2>"
1211 + '<div class="work-graph" data-on-failure-edges="' + edgesDataJson + '">' 1224 + '<div class="work-graph" data-on-failure-edges="' + edgesDataJson + '">'
1212 + '<svg class="graph-edges-svg"></svg>' 1225 + '<svg class="graph-edges-svg"></svg>'
1213 + rowsHtml + "</div>" 1226 + rowsHtml + "</div>"
@@ -1456,7 +1469,7 @@ function mountTaskDetail(taskId, initialOrgNode) { @@ -1456,7 +1469,7 @@ function mountTaskDetail(taskId, initialOrgNode) {
1456 return ( 1469 return (
1457 // id="todo-tree-<run_id>": secondary scroll/highlight target when the 1470 // id="todo-tree-<run_id>": secondary scroll/highlight target when the
1458 // DAG panel above didn't render (graph_manifest missing). 1471 // DAG panel above didn't render (graph_manifest missing).
1459 - '<div class="panel" id="todo-tree-' + run.run_id + '"><h2>' + run.org_node + " Work Graph 的 to-do 树(run_id=" + run.run_id + ")</h2>" 1472 + '<div class="panel" id="todo-tree-' + run.run_id + '"><h2>' + orgNodesLabel(run) + " Work Graph 的 to-do 树(run_id=" + run.run_id + ")</h2>"
1460 + '<ul class="todo-tree">' + items + "</ul>" 1473 + '<ul class="todo-tree">' + items + "</ul>"
1461 + '<div class="see-more">DAG 视图(同一份 graph_manifest 的另一种呈现)见下方 v_graph_visualizer</div>' 1474 + '<div class="see-more">DAG 视图(同一份 graph_manifest 的另一种呈现)见下方 v_graph_visualizer</div>'
1462 + "</div>" 1475 + "</div>"
@@ -1467,7 +1480,9 @@ function mountTaskDetail(taskId, initialOrgNode) { @@ -1467,7 +1480,9 @@ function mountTaskDetail(taskId, initialOrgNode) {
1467 closeAllLogViewers(); 1480 closeAllLogViewers();
1468 const activeSet = new Set([task.assigned_org_node]); 1481 const activeSet = new Set([task.assigned_org_node]);
1469 for (const run of task.work_graph_runs) { 1482 for (const run of task.work_graph_runs) {
1470 - if (run.status !== "done" && run.status !== "failed") activeSet.add(run.org_node); 1483 + if (run.status !== "done" && run.status !== "failed") {
  1484 + for (const orgNode of run.org_nodes_involved || []) activeSet.add(orgNode);
  1485 + }
1471 } 1486 }
1472 const orgStripHtml = renderTaskDetailOrgStripHtml(activeSet); 1487 const orgStripHtml = renderTaskDetailOrgStripHtml(activeSet);
1473 1488
@@ -1848,7 +1863,7 @@ function mountRunDetail(runId) { @@ -1848,7 +1863,7 @@ function mountRunDetail(runId) {
1848 function renderDetail(run) { 1863 function renderDetail(run) {
1849 closeAllLogViewers(); 1864 closeAllLogViewers();
1850 content.innerHTML = ( 1865 content.innerHTML = (
1851 - "<h1>" + escapeHtml(run.org_node) + " Work Graph Run</h1>" 1866 + "<h1>" + escapeHtml(orgNodesLabel(run)) + " Work Graph Run</h1>"
1852 + renderForkBadgeHtml(run) 1867 + renderForkBadgeHtml(run)
1853 + '<div class="meta-row">' 1868 + '<div class="meta-row">'
1854 + "<div><b>RUN_ID</b>" + escapeHtml(run.run_id) + "</div>" 1869 + "<div><b>RUN_ID</b>" + escapeHtml(run.run_id) + "</div>"
@@ -1979,7 +1994,7 @@ async function mountArtifactsOverviewPanel(container, projectId) { @@ -1979,7 +1994,7 @@ async function mountArtifactsOverviewPanel(container, projectId) {
1979 1994
1980 const runsHtml = runs.length === 0 ? '<div class="hint">暂无 Graph 运行记录</div>' : ( 1995 const runsHtml = runs.length === 0 ? '<div class="hint">暂无 Graph 运行记录</div>' : (
1981 '<ul class="simple-list" id="gc_runs_list">' + runs.slice(0, 10).map((r) => 1996 '<ul class="simple-list" id="gc_runs_list">' + runs.slice(0, 10).map((r) =>
1982 - '<li data-run-id="' + r.run_id + '" style="cursor:pointer;"><span>' + escapeHtml(r.org_node) + " · run " 1997 + '<li data-run-id="' + r.run_id + '" style="cursor:pointer;"><span>' + escapeHtml(orgNodesLabel(r)) + " · run "
1983 + r.run_id.slice(0, 8) + (r.parent_run_id ? " 🔀" : "") + "</span><span>" 1998 + r.run_id.slice(0, 8) + (r.parent_run_id ? " 🔀" : "") + "</span><span>"
1984 + escapeHtml(r.status) + " · " + r.node_count + " 节点</span></li>" 1999 + escapeHtml(r.status) + " · " + r.node_count + " 节点</span></li>"
1985 ).join("") + "</ul>" 2000 ).join("") + "</ul>"
@@ -2108,7 +2123,9 @@ function mountGraphConsole(queryParams) { @@ -2108,7 +2123,9 @@ function mountGraphConsole(queryParams) {
2108 const task = await resp.json(); 2123 const task = await resp.json();
2109 2124
2110 const runStatusByOrgNode = {}; 2125 const runStatusByOrgNode = {};
2111 - for (const run of task.work_graph_runs) runStatusByOrgNode[run.org_node] = run.status; 2126 + for (const run of task.work_graph_runs) {
  2127 + for (const orgNode of run.org_nodes_involved || []) runStatusByOrgNode[orgNode] = run.status;
  2128 + }
2112 orgTopologyEl.innerHTML = renderOrgGraphTopology(task.assigned_org_node, runStatusByOrgNode, task.route); 2129 orgTopologyEl.innerHTML = renderOrgGraphTopology(task.assigned_org_node, runStatusByOrgNode, task.route);
2113 2130
2114 const panelsHtml = task.work_graph_runs 2131 const panelsHtml = task.work_graph_runs
frontend/test_org_click_through_smoke.js
@@ -96,9 +96,9 @@ function check(condition, message) { @@ -96,9 +96,9 @@ function check(condition, message) {
96 { 96 {
97 const task = { 97 const task = {
98 work_graph_runs: [ 98 work_graph_runs: [
99 - { run_id: "run-old", org_node: "planner", status: "done", started_at: "2026-08-01 00:00:00" },  
100 - { run_id: "run-new", org_node: "planner", status: "done", started_at: "2026-08-02 00:00:00" },  
101 - { run_id: "run-coder", org_node: "coder", status: "running", started_at: "2026-08-02 01:00:00" }, 99 + { run_id: "run-old", org_nodes_involved: ["planner"], status: "done", started_at: "2026-08-01 00:00:00" },
  100 + { run_id: "run-new", org_nodes_involved: ["planner"], status: "done", started_at: "2026-08-02 00:00:00" },
  101 + { run_id: "run-coder", org_nodes_involved: ["coder"], status: "running", started_at: "2026-08-02 01:00:00" },
102 ], 102 ],
103 }; 103 };
104 const latestPlanner = sandbox.getLatestRunForOrgNode(task, "planner"); 104 const latestPlanner = sandbox.getLatestRunForOrgNode(task, "planner");
@@ -119,7 +119,7 @@ function check(condition, message) { @@ -119,7 +119,7 @@ function check(condition, message) {
119 119
120 const taskWithRun = { 120 const taskWithRun = {
121 work_graph_runs: [ 121 work_graph_runs: [
122 - { run_id: "run-1", org_node: "coder", status: "done", graph_manifest: { nodes: [{ node_id: "n1", depends_on: [] }] } }, 122 + { run_id: "run-1", org_nodes_involved: ["coder"], status: "done", graph_manifest: { nodes: [{ node_id: "n1", depends_on: [] }] } },
123 ], 123 ],
124 artifacts: [ 124 artifacts: [
125 { artifact_id: "art-1", produced_by_run_id: "run-1", file_path: "src/x.py", artifact_type: "code_diff", validation_status: "passed" }, 125 { artifact_id: "art-1", produced_by_run_id: "run-1", file_path: "src/x.py", artifact_type: "code_diff", validation_status: "passed" },
@@ -132,7 +132,7 @@ function check(condition, message) { @@ -132,7 +132,7 @@ function check(condition, message) {
132 check(!populatedHtml.includes('data-artifact-id="art-2"'), "artifact from a different run_id must be filtered out"); 132 check(!populatedHtml.includes('data-artifact-id="art-2"'), "artifact from a different run_id must be filtered out");
133 133
134 const taskNoArtifacts = { 134 const taskNoArtifacts = {
135 - work_graph_runs: [{ run_id: "run-2", org_node: "coder", status: "failed", graph_manifest: null }], 135 + work_graph_runs: [{ run_id: "run-2", org_nodes_involved: ["coder"], status: "failed", graph_manifest: null }],
136 artifacts: [], 136 artifacts: [],
137 }; 137 };
138 const noArtifactsHtml = sandbox.renderOrgComboPanelContent(taskNoArtifacts, "coder"); 138 const noArtifactsHtml = sandbox.renderOrgComboPanelContent(taskNoArtifacts, "coder");
src/backend_server.py
@@ -1041,6 +1041,7 @@ def _ensure_schema(conn): @@ -1041,6 +1041,7 @@ def _ensure_schema(conn):
1041 schema.migrate_add_reviewing_planner_output_status(conn) 1041 schema.migrate_add_reviewing_planner_output_status(conn)
1042 schema.migrate_add_planner_review_verdict_event_type(conn) 1042 schema.migrate_add_planner_review_verdict_event_type(conn)
1043 schema.migrate_relax_agent_profile_migrated_from_profile(conn) 1043 schema.migrate_relax_agent_profile_migrated_from_profile(conn)
  1044 + schema.migrate_drop_work_graph_run_org_node(conn)
1044 1045
1045 1046
1046 class _Server(ThreadingHTTPServer): 1047 class _Server(ThreadingHTTPServer):
src/root_cause_trace.py
@@ -219,10 +219,21 @@ def judge_root_cause_via_work_graph( @@ -219,10 +219,21 @@ def judge_root_cause_via_work_graph(
219 manifest = _build_jury_graph_manifest(list(perspective_prompts)) 219 manifest = _build_jury_graph_manifest(list(perspective_prompts))
220 220
221 run_id = str(uuid.uuid4()) 221 run_id = str(uuid.uuid4())
  222 + # org_node column removed (Org Graph unification Phase 2c §2.1,
  223 + # 2026-08-12) -- see work_graph_plan.py's own INSERT for the general
  224 + # rationale. Note: ux_work_graph_run_active is now UNIQUE(task_id) (was
  225 + # (task_id, org_node)), so this INSERT would now collide with a task's
  226 + # own still-active shared Planner/Coder/Validator run if one existed --
  227 + # not a real concern today since this function's only caller
  228 + # (org_level_loop.run_fix_loop) is only reachable from a task that
  229 + # never got a work_graph_run row in the first place (see scheduler.py::
  230 + # finish_task_lifecycle's own comment on why that branch is effectively
  231 + # dead now that validate_implementation_with_own_graph() only ever
  232 + # returns PASS or raises), but worth knowing if this function is ever
  233 + # wired up to a live task with its own in-flight run again.
222 conn.execute( 234 conn.execute(
223 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
224 - "VALUES (?, ?, ?, ?, 'running')",  
225 - (run_id, task_id, org_node, json.dumps(manifest)), 235 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) VALUES (?, ?, ?, 'running')",
  236 + (run_id, task_id, json.dumps(manifest)),
226 ) 237 )
227 conn.commit() 238 conn.commit()
228 239
src/scheduler.py
@@ -162,7 +162,22 @@ def _record_core_engine_audit_if_self_target(conn, task_id, target_folder_path, @@ -162,7 +162,22 @@ def _record_core_engine_audit_if_self_target(conn, task_id, target_folder_path,
162 def finish_task_lifecycle(conn, run_result, target_folder_path, goal_text): 162 def finish_task_lifecycle(conn, run_result, target_folder_path, goal_text):
163 """PASS -> `close_task_done`; FAIL/INCONCLUSIVE -> `org_level_loop. 163 """PASS -> `close_task_done`; FAIL/INCONCLUSIVE -> `org_level_loop.
164 run_fix_loop` (root-cause judge + reroute + fix + reverify, U-017). 164 run_fix_loop` (root-cause judge + reroute + fix + reverify, U-017).
165 - Adds 'final_status' to `run_result` and returns it.""" 165 + Adds 'final_status' to `run_result` and returns it.
  166 +
  167 + 2026-08-12 (Org Graph unification Phase 2c): the FAIL/INCONCLUSIVE
  168 + branch below is effectively dead code for tasks going through the
  169 + current e2e_pipeline.py flow -- `validate_implementation_with_own_
  170 + graph()`'s verdict is now a self-planned node with `judgment.kind=
  171 + 'mechanical'`/`on_fail.action='dynamic_loop_back'` (see that function's
  172 + own docstring), which retries internally via work_graph_orchestrate.py's
  173 + Reflection-review + [Fix-Skill] + loop-back machinery; a normal return
  174 + from that function can therefore only ever carry verdict='PASS' -- a
  175 + goal that can't resolve within the retry budget raises
  176 + ConsistencyCheckFailedError instead, which routes to
  177 + blocked_pending_human upstream of this function ever being called with
  178 + a non-PASS verdict. Kept, not deleted, per minimal-change philosophy
  179 + (a caller using the older, still-live `validate_implementation()`/
  180 + hardcoded-manifest path -- none currently do -- would still need it)."""
166 task_id = run_result["task_id"] 181 task_id = run_result["task_id"]
167 verdict = run_result["verdict"]["verdict"] 182 verdict = run_result["verdict"]["verdict"]
168 if verdict == "PASS": 183 if verdict == "PASS":
src/schema.py
@@ -17,7 +17,9 @@ Interpretive conventions used below, where 01-M leaves the physical type implici @@ -17,7 +17,9 @@ Interpretive conventions used below, where 01-M leaves the physical type implici
17 - task.assigned_org_node is documented as "not null, fk" but there is no standalone 17 - task.assigned_org_node is documented as "not null, fk" but there is no standalone
18 org-node lookup table to reference (planner/coder/validator only exist as entries 18 org-node lookup table to reference (planner/coder/validator only exist as entries
19 inside org_graph_definition.nodes, a JSON column) -- implemented as a CHECK enum 19 inside org_graph_definition.nodes, a JSON column) -- implemented as a CHECK enum
20 - instead, matching work_graph_run.org_node / skill.owner_org_node / agent_profile.org_node. 20 + instead, matching skill.owner_org_node / agent_profile.org_node / work_node_execution.subagent_type
  21 + (work_graph_run.org_node existed the same way until it was removed, Org Graph unification
  22 + Phase 2c §2.1, 2026-08-12 -- see that column's own removal comment above work_graph_run).
21 - bug_record.root_cause_category gained 'core_engine' in U-034 (2026-08-02, PH5) -- 23 - bug_record.root_cause_category gained 'core_engine' in U-034 (2026-08-02, PH5) --
22 05 §5.8: self-target scenarios (`.claude/agents/*.md` identity file changes, Graph 24 05 §5.8: self-target scenarios (`.claude/agents/*.md` identity file changes, Graph
23 Engine scheduler/executor core logic changes, this schema's own changes), routed by 25 Engine scheduler/executor core logic changes, this schema's own changes), routed by
@@ -126,8 +128,13 @@ CREATE INDEX idx_task_project_id ON task(project_id); @@ -126,8 +128,13 @@ CREATE INDEX idx_task_project_id ON task(project_id);
126 CREATE TABLE work_graph_run ( 128 CREATE TABLE work_graph_run (
127 run_id TEXT PRIMARY KEY, 129 run_id TEXT PRIMARY KEY,
128 task_id TEXT NOT NULL REFERENCES task(task_id), 130 task_id TEXT NOT NULL REFERENCES task(task_id),
129 - -- H (2026-08-06): see skill.owner_org_node's comment above -- same relaxation.  
130 - org_node TEXT NOT NULL CHECK (org_node != ''), 131 + -- org_node column removed (Org Graph unification Phase 2c §2.1,
  132 + -- 2026-08-12, user-confirmed 2026-08-08): a merged graph's one run
  133 + -- genuinely spans several org_nodes (planner/coder/validator all
  134 + -- append their own nodes into the SAME run), so a single run-level
  135 + -- value was never accurate once Phase 2b/2c landed -- the real
  136 + -- per-node ownership already lives in work_node_execution.subagent_type
  137 + -- (see §1.2 of the design doc). See migrate_drop_work_graph_run_org_node.
131 graph_manifest TEXT NOT NULL, 138 graph_manifest TEXT NOT NULL,
132 orchestration_script_ref TEXT, 139 orchestration_script_ref TEXT,
133 status TEXT NOT NULL CHECK (status IN ( 140 status TEXT NOT NULL CHECK (status IN (
@@ -141,16 +148,23 @@ CREATE TABLE work_graph_run ( @@ -141,16 +148,23 @@ CREATE TABLE work_graph_run (
141 -- Set together when this run was forked from a historical node instead 148 -- Set together when this run was forked from a historical node instead
142 -- of planned fresh -- see src/work_graph_fork.py. v1 only forks from a 149 -- of planned fresh -- see src/work_graph_fork.py. v1 only forks from a
143 -- parent run that has already reached a terminal status, so a forked 150 -- parent run that has already reached a terminal status, so a forked
144 - -- run's (task_id, org_node) pair never collides with  
145 - -- ux_work_graph_run_active below (that index only covers non-terminal  
146 - -- statuses) -- no change needed to the index itself. 151 + -- run's task_id never collides with ux_work_graph_run_active below
  152 + -- (that index only covers non-terminal statuses) -- no change needed
  153 + -- to the index itself.
147 parent_run_id TEXT REFERENCES work_graph_run(run_id), 154 parent_run_id TEXT REFERENCES work_graph_run(run_id),
148 fork_of_node_exec_id TEXT REFERENCES work_node_execution(node_exec_id) 155 fork_of_node_exec_id TEXT REFERENCES work_node_execution(node_exec_id)
149 ); 156 );
150 157
151 --- only one active (non-terminal) run per (task_id, org_node) at a time  
152 -CREATE UNIQUE INDEX ux_work_graph_run_active ON work_graph_run(task_id, org_node)  
153 - WHERE status IN ('planning', 'running', 'merging', 'verifying'); 158 +-- Org Graph unification Phase 2c §2.2 (2026-08-12): narrowed from
  159 +-- (task_id, org_node) to plain task_id -- a merged graph means a task has
  160 +-- ONE run spanning its whole lifecycle now, not one per org_node. 'blocked'
  161 +-- stays counted as active on purpose (not just 'planning'/'running'/
  162 +-- 'merging'/'verifying'/'reflecting'): resuming a blocked run appends into
  163 +-- the SAME row (plan_work_graph(..., append_to_run_id=...)), it never
  164 +-- INSERTs a fresh one, so a blocked run must still hold the (task_id) slot.
  165 +-- Only 'done'/'failed' are genuinely terminal.
  166 +CREATE UNIQUE INDEX ux_work_graph_run_active ON work_graph_run(task_id)
  167 + WHERE status NOT IN ('done', 'failed');
154 168
155 -- 1.4 work_node_execution 169 -- 1.4 work_node_execution
156 CREATE TABLE work_node_execution ( 170 CREATE TABLE work_node_execution (
@@ -802,3 +816,84 @@ def migrate_add_planner_review_verdict_event_type(conn): @@ -802,3 +816,84 @@ def migrate_add_planner_review_verdict_event_type(conn):
802 """ 816 """
803 ) 817 )
804 conn.commit() 818 conn.commit()
  819 +
  820 +
  821 +def migrate_drop_work_graph_run_org_node(conn):
  822 + """Org Graph unification Phase 2c §2.1/§2.2 (2026-08-12, user-confirmed
  823 + 2026-08-08): drops work_graph_run.org_node on an ALREADY-POPULATED
  824 + database and narrows ux_work_graph_run_active from (task_id, org_node)
  825 + to plain task_id in the SAME rebuild -- see both columns'/the index's
  826 + own comments on the CREATE TABLE above for why (a merged graph's one
  827 + run genuinely spans several org_nodes now, so neither the column nor
  828 + the old per-org_node uniqueness grain is accurate anymore). Both
  829 + changes have to happen in one rebuild since the old index is defined
  830 + on the column being dropped.
  831 +
  832 + work_graph_run is a PARENT table (work_node_execution/artifact/
  833 + execution_event/bug_record/work_graph_run's own self-reference all
  834 + REFERENCES it) -- same PRAGMA foreign_keys=OFF/rebuild/
  835 + foreign_key_check/ON dance as migrate_add_reviewing_planner_output_status
  836 + for the exact same reason (see that function's own docstring for the
  837 + real-restart incident this precedent comes from); a plain executescript()
  838 + without toggling foreign_keys would trip FK enforcement on DROP TABLE
  839 + the instant any child row exists.
  840 +
  841 + Depends on migrate_relax_org_node_constraints() having already run
  842 + against this database (called here unconditionally, it's idempotent) --
  843 + that migration is what first relaxed org_node's CHECK from the old
  844 + 3-value enum; this one goes further and removes the column outright,
  845 + but only after that intermediate state, matching the accumulated-history
  846 + convention every other CHECK-enum migration in this file already follows
  847 + (each embeds the full column/constraint set valid as of ITS place in the
  848 + sequence, not retroactively edited by later migrations).
  849 +
  850 + Idempotent: skipped if work_graph_run no longer has an org_node column,
  851 + so a fresh create_schema()'d database (already on this final shape) is a
  852 + no-op, same convention as every other migrate_* function here."""
  853 + migrate_relax_org_node_constraints(conn)
  854 + conn.commit()
  855 +
  856 + cols = {row[1] for row in conn.execute("PRAGMA table_info(work_graph_run)").fetchall()}
  857 + if "org_node" not in cols:
  858 + return
  859 + conn.execute("PRAGMA foreign_keys = OFF")
  860 + try:
  861 + conn.executescript(
  862 + """
  863 + CREATE TABLE work_graph_run_new (
  864 + run_id TEXT PRIMARY KEY,
  865 + task_id TEXT NOT NULL REFERENCES task(task_id),
  866 + graph_manifest TEXT NOT NULL,
  867 + orchestration_script_ref TEXT,
  868 + status TEXT NOT NULL CHECK (status IN (
  869 + 'planning', 'running', 'merging', 'verifying',
  870 + 'reflecting', 'blocked', 'done', 'failed'
  871 + )),
  872 + started_at TEXT,
  873 + completed_at TEXT,
  874 + result_artifact_refs TEXT,
  875 + parent_run_id TEXT REFERENCES work_graph_run(run_id),
  876 + fork_of_node_exec_id TEXT REFERENCES work_node_execution(node_exec_id)
  877 + );
  878 + INSERT INTO work_graph_run_new (
  879 + run_id, task_id, graph_manifest, orchestration_script_ref, status,
  880 + started_at, completed_at, result_artifact_refs, parent_run_id, fork_of_node_exec_id
  881 + )
  882 + SELECT
  883 + run_id, task_id, graph_manifest, orchestration_script_ref, status,
  884 + started_at, completed_at, result_artifact_refs, parent_run_id, fork_of_node_exec_id
  885 + FROM work_graph_run;
  886 + DROP TABLE work_graph_run;
  887 + ALTER TABLE work_graph_run_new RENAME TO work_graph_run;
  888 + CREATE UNIQUE INDEX ux_work_graph_run_active ON work_graph_run(task_id)
  889 + WHERE status NOT IN ('done', 'failed');
  890 + """
  891 + )
  892 + conn.commit()
  893 + violations = conn.execute("PRAGMA foreign_key_check").fetchall()
  894 + if violations:
  895 + raise RuntimeError(
  896 + "migrate_drop_work_graph_run_org_node left dangling foreign keys: {}".format(violations)
  897 + )
  898 + finally:
  899 + conn.execute("PRAGMA foreign_keys = ON")
src/task_query.py
@@ -155,20 +155,30 @@ def get_task(conn, task_id): @@ -155,20 +155,30 @@ 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, graph_manifest, status, started_at, completed_at, "
159 "parent_run_id, fork_of_node_exec_id " 159 "parent_run_id, fork_of_node_exec_id "
160 "FROM work_graph_run WHERE task_id = ? ORDER BY started_at", 160 "FROM work_graph_run WHERE task_id = ? ORDER BY started_at",
161 (task_id,), 161 (task_id,),
162 ).fetchall() 162 ).fetchall()
163 - task["work_graph_runs"] = [  
164 - {  
165 - "run_id": w[0], "org_node": w[1],  
166 - "graph_manifest": json.loads(w[2]) if w[2] else None,  
167 - "status": w[3], "started_at": w[4], "completed_at": w[5], 163 + task["work_graph_runs"] = []
  164 + for w in runs:
  165 + nodes = _work_nodes_for_run(conn, w[0])
  166 + task["work_graph_runs"].append({
  167 + "run_id": w[0],
  168 + "graph_manifest": json.loads(w[1]) if w[1] else None,
  169 + "status": w[2], "started_at": w[3], "completed_at": w[4],
168 # D (2026-08-06): both None for a normal run; both set when this 170 # 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). 171 # 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],  
171 - "nodes": _work_nodes_for_run(conn, w[0]), 172 + "parent_run_id": w[5], "fork_of_node_exec_id": w[6],
  173 + # 2026-08-12 (Org Graph unification Phase 2c §2.1.1): replaces
  174 + # the removed single-value work_graph_run.org_node -- a merged
  175 + # run genuinely spans several org_nodes now, computed from this
  176 + # run's own nodes (already fetched below for "nodes") rather
  177 + # than a second query. Same shape as work_graph_run_query.py's
  178 + # get_work_graph_run/list_work_graph_runs, kept in lockstep per
  179 + # this module's own docstring.
  180 + "org_nodes_involved": sorted({n["subagent_type"] for n in nodes if n["subagent_type"]}),
  181 + "nodes": nodes,
172 # 2026-08-08 (Work Graph self-planning + loop-back): the console's 182 # 2026-08-08 (Work Graph self-planning + loop-back): the console's
173 # v_graph_visualizer needs this to actually draw a loop_back's 183 # v_graph_visualizer needs this to actually draw a loop_back's
174 # on_failure edge / a retry_self node's "sent back Nx" badge -- 184 # on_failure edge / a retry_self node's "sent back Nx" badge --
@@ -176,9 +186,7 @@ def get_task(conn, task_id): @@ -176,9 +186,7 @@ def get_task(conn, task_id):
176 # alone (loop_back_triggered is execution_event-only by design, 186 # alone (loop_back_triggered is execution_event-only by design,
177 # see docs/done/work-graph-self-planning-and-loop-back-design.md §7). 187 # see docs/done/work-graph-self-planning-and-loop-back-design.md §7).
178 "loop_back_events": _loop_back_events_for_run(conn, w[0]), 188 "loop_back_events": _loop_back_events_for_run(conn, w[0]),
179 - }  
180 - for w in runs  
181 - ] 189 + })
182 return task 190 return task
183 191
184 192
src/test_artifact_query.py
@@ -28,8 +28,8 @@ class ArtifactQueryTestBase(unittest.TestCase): @@ -28,8 +28,8 @@ class ArtifactQueryTestBase(unittest.TestCase):
28 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'coding', 'coder')" 28 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'coding', 'coder')"
29 ) 29 )
30 self.conn.execute( 30 self.conn.execute(
31 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
32 - "VALUES ('run-1', 'task-1', 'planner', '{}', 'done')" 31 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  32 + "VALUES ('run-1', 'task-1', '{}', 'done')"
33 ) 33 )
34 self.conn.commit() 34 self.conn.commit()
35 35
src/test_artifact_write.py
@@ -35,8 +35,8 @@ class ArtifactWriteTestBase(unittest.TestCase): @@ -35,8 +35,8 @@ class ArtifactWriteTestBase(unittest.TestCase):
35 "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'planning_brd_prd_sdd', 'planner')" 35 "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'planning_brd_prd_sdd', 'planner')"
36 ) 36 )
37 self.conn.execute( 37 self.conn.execute(
38 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
39 - "VALUES ('run-1', 'task-1', 'planner', '{}', 'running')" 38 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  39 + "VALUES ('run-1', 'task-1', '{}', 'running')"
40 ) 40 )
41 self.conn.commit() 41 self.conn.commit()
42 42
src/test_backend_server.py
@@ -1135,8 +1135,8 @@ class ExecutionLogRouteTest(unittest.TestCase): @@ -1135,8 +1135,8 @@ class ExecutionLogRouteTest(unittest.TestCase):
1135 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'coding', 'coder')" 1135 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'coding', 'coder')"
1136 ) 1136 )
1137 conn.execute( 1137 conn.execute(
1138 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
1139 - "VALUES ('run-1', 'task-1', 'coder', '{}', 'running')" 1138 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  1139 + "VALUES ('run-1', 'task-1', '{}', 'running')"
1140 ) 1140 )
1141 conn.execute( 1141 conn.execute(
1142 "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) " 1142 "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
@@ -1291,8 +1291,8 @@ class ArtifactRouteTest(unittest.TestCase): @@ -1291,8 +1291,8 @@ class ArtifactRouteTest(unittest.TestCase):
1291 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'coding', 'coder')" 1291 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'coding', 'coder')"
1292 ) 1292 )
1293 conn.execute( 1293 conn.execute(
1294 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
1295 - "VALUES ('run-1', 'task-1', 'planner', '{}', 'done')" 1294 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  1295 + "VALUES ('run-1', 'task-1', '{}', 'done')"
1296 ) 1296 )
1297 with open(os.path.join(self.tmp.name, "sdd.md"), "w") as f: 1297 with open(os.path.join(self.tmp.name, "sdd.md"), "w") as f:
1298 f.write("# SDD content") 1298 f.write("# SDD content")
@@ -1366,8 +1366,8 @@ class WorkGraphRunListRouteTest(unittest.TestCase): @@ -1366,8 +1366,8 @@ class WorkGraphRunListRouteTest(unittest.TestCase):
1366 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'coding', 'coder')" 1366 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'coding', 'coder')"
1367 ) 1367 )
1368 conn.execute( 1368 conn.execute(
1369 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status, started_at) "  
1370 - "VALUES ('run-1', 'task-1', 'coder', '{}', 'done', '2026-08-01T00:00:00')" 1369 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status, started_at) "
  1370 + "VALUES ('run-1', 'task-1', '{}', 'done', '2026-08-01T00:00:00')"
1371 ) 1371 )
1372 conn.commit() 1372 conn.commit()
1373 1373
@@ -1403,6 +1403,11 @@ class WorkGraphRunListRouteTest(unittest.TestCase): @@ -1403,6 +1403,11 @@ class WorkGraphRunListRouteTest(unittest.TestCase):
1403 self.assertEqual(body["error"], "INVALID_PARAM") 1403 self.assertEqual(body["error"], "INVALID_PARAM")
1404 1404
1405 def test_list_org_node_filter(self): 1405 def test_list_org_node_filter(self):
  1406 + # 2026-08-12 (Org Graph unification Phase 2c §2.1.1): org_node now
  1407 + # filters by whether any node in the run has that subagent_type --
  1408 + # this fixture's run-1 has zero nodes, so ANY org_node filter
  1409 + # (including one that would have matched the old run-level column)
  1410 + # now excludes it.
1406 status, body = self._get("/api/work-graph-runs?project_id=proj-1&org_node=planner") 1411 status, body = self._get("/api/work-graph-runs?project_id=proj-1&org_node=planner")
1407 self.assertEqual(status, 200) 1412 self.assertEqual(status, 200)
1408 self.assertEqual(body, []) 1413 self.assertEqual(body, [])
@@ -1412,7 +1417,10 @@ class WorkGraphRunListRouteTest(unittest.TestCase): @@ -1412,7 +1417,10 @@ class WorkGraphRunListRouteTest(unittest.TestCase):
1412 self.assertEqual(status, 200) 1417 self.assertEqual(status, 200)
1413 self.assertEqual(body["run_id"], "run-1") 1418 self.assertEqual(body["run_id"], "run-1")
1414 self.assertEqual(body["task_id"], "task-1") 1419 self.assertEqual(body["task_id"], "task-1")
1415 - self.assertEqual(body["org_node"], "coder") 1420 + # run-1 has zero nodes seeded above -- org_nodes_involved is derived
  1421 + # from work_node_execution.subagent_type (Phase 2c §2.1.1), so it's
  1422 + # empty here, not the old run-level org_node column's fixed value.
  1423 + self.assertEqual(body["org_nodes_involved"], [])
1416 self.assertEqual(body["nodes"], []) 1424 self.assertEqual(body["nodes"], [])
1417 1425
1418 def test_get_nonexistent_run_returns_404(self): 1426 def test_get_nonexistent_run_returns_404(self):
@@ -1444,8 +1452,8 @@ class WorkNodeForkRouteTest(unittest.TestCase): @@ -1444,8 +1452,8 @@ class WorkNodeForkRouteTest(unittest.TestCase):
1444 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'validating', 'planner')" 1452 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'validating', 'planner')"
1445 ) 1453 )
1446 conn.execute( 1454 conn.execute(
1447 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
1448 - "VALUES ('run-1', 'task-1', 'planner', '{}', 'done')" 1455 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  1456 + "VALUES ('run-1', 'task-1', '{}', 'done')"
1449 ) 1457 )
1450 conn.execute( 1458 conn.execute(
1451 "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) " 1459 "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
src/test_e2e_pipeline.py
@@ -218,8 +218,8 @@ class WriteCompletedNodeArtifactsTest(PipelineTestBase): @@ -218,8 +218,8 @@ class WriteCompletedNodeArtifactsTest(PipelineTestBase):
218 "VALUES ('task-1', (SELECT project_id FROM project), 'goal', 'goal', 'coding', 'coder')" 218 "VALUES ('task-1', (SELECT project_id FROM project), 'goal', 'goal', 'coding', 'coder')"
219 ) 219 )
220 self.conn.execute( 220 self.conn.execute(
221 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
222 - "VALUES ('run-1', 'task-1', 'coder', '{}', 'running')" 221 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  222 + "VALUES ('run-1', 'task-1', '{}', 'running')"
223 ) 223 )
224 self.conn.execute( 224 self.conn.execute(
225 "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) " 225 "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
@@ -330,9 +330,9 @@ class PlanDocumentsTest(PipelineTestBase): @@ -330,9 +330,9 @@ class PlanDocumentsTest(PipelineTestBase):
330 330
331 # plan_work_graph's own real work_graph_run INSERT actually ran (not bypassed) 331 # plan_work_graph's own real work_graph_run INSERT actually ran (not bypassed)
332 run_row = self.conn.execute( 332 run_row = self.conn.execute(
333 - "SELECT org_node, status FROM work_graph_run WHERE run_id = ?", (plan_result["run_id"],) 333 + "SELECT status FROM work_graph_run WHERE run_id = ?", (plan_result["run_id"],)
334 ).fetchone() 334 ).fetchone()
335 - self.assertEqual(run_row, ("planner", "planning")) 335 + self.assertEqual(run_row, ("planning",))
336 336
337 def test_earlier_nodes_survive_a_later_node_failing_verification(self): 337 def test_earlier_nodes_survive_a_later_node_failing_verification(self):
338 # 2026-08-07 real incident (docs/todo/work-graph-partial-artifact- 338 # 2026-08-07 real incident (docs/todo/work-graph-partial-artifact-
@@ -441,17 +441,14 @@ class RunEndToEndTest(PipelineTestBase): @@ -441,17 +441,14 @@ class RunEndToEndTest(PipelineTestBase):
441 # Phase 2c (2026-08-12): Validator now ALSO appends its self-planned 441 # Phase 2c (2026-08-12): Validator now ALSO appends its self-planned
442 # nodes onto the SAME shared run (plan_work_graph(..., append_to_ 442 # nodes onto the SAME shared run (plan_work_graph(..., append_to_
443 # run_id=...)) instead of creating its own separate row -- only ONE 443 # run_id=...)) instead of creating its own separate row -- only ONE
444 - # work_graph_run row exists for this task now, still labeled  
445 - # org_node='planner' (that column reflects who ORIGINATED the run,  
446 - # not every org_node that has since contributed nodes to it -- §2.1  
447 - # is expected to retire this column's meaning once it's no longer  
448 - # a reliable "who's involved" signal at all). 444 + # work_graph_run row exists for this task now. org_node column
  445 + # removed entirely (§2.1) -- real per-node ownership lives in
  446 + # work_node_execution.subagent_type, checked below via node_ids.
449 run_rows = self.conn.execute( 447 run_rows = self.conn.execute(
450 - "SELECT run_id, org_node, status FROM work_graph_run WHERE task_id = ?", (result["task_id"],) 448 + "SELECT run_id, status FROM work_graph_run WHERE task_id = ?", (result["task_id"],)
451 ).fetchall() 449 ).fetchall()
452 self.assertEqual(len(run_rows), 1) 450 self.assertEqual(len(run_rows), 1)
453 - self.assertEqual(run_rows[0][1], "planner")  
454 - self.assertEqual(run_rows[0][2], "done") 451 + self.assertEqual(run_rows[0][1], "done")
455 452
456 # Coder's "impl" AND Validator's "verdict" nodes both genuinely 453 # Coder's "impl" AND Validator's "verdict" nodes both genuinely
457 # landed in the SAME run as Planner's own nodes. 454 # landed in the SAME run as Planner's own nodes.
@@ -558,8 +555,8 @@ class ReviewPlannerOutputWithRetriesTest(PipelineTestBase): @@ -558,8 +555,8 @@ class ReviewPlannerOutputWithRetriesTest(PipelineTestBase):
558 def test_pass_on_first_attempt_reuses_existing_artifacts_no_replan(self): 555 def test_pass_on_first_attempt_reuses_existing_artifacts_no_replan(self):
559 for artifact_type, content in (("sdd", "# SDD\nreal"), ("brd", "# BRD\nreal")): 556 for artifact_type, content in (("sdd", "# SDD\nreal"), ("brd", "# BRD\nreal")):
560 self.conn.execute( 557 self.conn.execute(
561 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
562 - "VALUES ('run-1', ?, 'planner', '{}', 'done') ON CONFLICT(run_id) DO NOTHING", 558 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  559 + "VALUES ('run-1', ?, '{}', 'done') ON CONFLICT(run_id) DO NOTHING",
563 (self.task_id,), 560 (self.task_id,),
564 ) 561 )
565 file_path = ".graphlemon/runs/{}/{}.md".format(self.task_id, artifact_type) 562 file_path = ".graphlemon/runs/{}/{}.md".format(self.task_id, artifact_type)
@@ -1093,8 +1090,8 @@ class ValidateImplementationWithOwnGraphTest(unittest.TestCase): @@ -1093,8 +1090,8 @@ class ValidateImplementationWithOwnGraphTest(unittest.TestCase):
1093 # see test_task_execution.py for that), just needs to genuinely 1090 # see test_task_execution.py for that), just needs to genuinely
1094 # exist for append_to_run_id's own existence check. 1091 # exist for append_to_run_id's own existence check.
1095 self.conn.execute( 1092 self.conn.execute(
1096 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
1097 - "VALUES ('run-shared', 'task-1', 'planner', '{\"nodes\": [], \"edges\": []}', 'running')" 1093 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  1094 + "VALUES ('run-shared', 'task-1', '{\"nodes\": [], \"edges\": []}', 'running')"
1098 ) 1095 )
1099 self.conn.commit() 1096 self.conn.commit()
1100 1097
src/test_execution_log.py
@@ -29,8 +29,8 @@ class ExecutionLogTestBase(unittest.TestCase): @@ -29,8 +29,8 @@ class ExecutionLogTestBase(unittest.TestCase):
29 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'coding', 'coder')" 29 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'coding', 'coder')"
30 ) 30 )
31 self.conn.execute( 31 self.conn.execute(
32 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
33 - "VALUES ('run-1', 'task-1', 'coder', '{}', 'running')" 32 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  33 + "VALUES ('run-1', 'task-1', '{}', 'running')"
34 ) 34 )
35 self.conn.execute( 35 self.conn.execute(
36 "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) " 36 "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
src/test_org_node_lifecycle.py
@@ -28,8 +28,8 @@ class LifecycleTestBase(unittest.TestCase): @@ -28,8 +28,8 @@ class LifecycleTestBase(unittest.TestCase):
28 28
29 def _pass_artifact(self, artifact_type, run_id="run-1"): 29 def _pass_artifact(self, artifact_type, run_id="run-1"):
30 self.conn.execute( 30 self.conn.execute(
31 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
32 - "VALUES (?, ?, 'planner', '{}', 'done') ON CONFLICT(run_id) DO NOTHING", 31 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  32 + "VALUES (?, ?, '{}', 'done') ON CONFLICT(run_id) DO NOTHING",
33 (run_id, self.task_id), 33 (run_id, self.task_id),
34 ) 34 )
35 self.conn.execute( 35 self.conn.execute(
src/test_org_node_lifecycle_e2e.py
@@ -49,8 +49,7 @@ class RealChainedTriggerTest(unittest.TestCase): @@ -49,8 +49,7 @@ class RealChainedTriggerTest(unittest.TestCase):
49 def _seed_passing_artifacts(conn, task_id, artifact_types): 49 def _seed_passing_artifacts(conn, task_id, artifact_types):
50 run_id = str(uuid.uuid4()) 50 run_id = str(uuid.uuid4())
51 conn.execute( 51 conn.execute(
52 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
53 - "VALUES (?, ?, 'planner', '{}', 'done')", 52 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) VALUES (?, ?, '{}', 'done')",
54 (run_id, task_id), 53 (run_id, task_id),
55 ) 54 )
56 for artifact_type in artifact_types: 55 for artifact_type in artifact_types:
src/test_schema.py
@@ -91,11 +91,10 @@ class ConstraintTestBase(unittest.TestCase): @@ -91,11 +91,10 @@ class ConstraintTestBase(unittest.TestCase):
91 self.conn.commit() 91 self.conn.commit()
92 return skill_id 92 return skill_id
93 93
94 - def insert_work_graph_run(self, run_id="run-1", task_id="task-1", org_node="coder", status="planning"): 94 + def insert_work_graph_run(self, run_id="run-1", task_id="task-1", status="planning"):
95 self.conn.execute( 95 self.conn.execute(
96 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
97 - "VALUES (?, ?, ?, '{}', ?)",  
98 - (run_id, task_id, org_node, status), 96 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) VALUES (?, ?, '{}', ?)",
  97 + (run_id, task_id, status),
99 ) 98 )
100 self.conn.commit() 99 self.conn.commit()
101 return run_id 100 return run_id
@@ -167,24 +166,41 @@ class ForeignKeyEnforcementTest(ConstraintTestBase): @@ -167,24 +166,41 @@ class ForeignKeyEnforcementTest(ConstraintTestBase):
167 166
168 167
169 class WorkGraphRunActiveUniqueIndexTest(ConstraintTestBase): 168 class WorkGraphRunActiveUniqueIndexTest(ConstraintTestBase):
170 - """01-M 1.3: only one active (planning/running/merging/verifying) run per  
171 - (task_id, org_node) at a time -- terminal-status runs don't count.""" 169 + """Org Graph unification Phase 2c §2.2 (2026-08-12): only one active run
  170 + per task_id at a time now (narrowed from (task_id, org_node) -- a merged
  171 + graph means one task has ONE run spanning its whole lifecycle, not one
  172 + per org_node). Active = any status except 'done'/'failed' -- 'blocked'
  173 + now counts as active on purpose (a blocked run is resumed by appending
  174 + into the SAME row, never by inserting a fresh one)."""
172 175
173 def setUp(self): 176 def setUp(self):
174 super().setUp() 177 super().setUp()
175 self.insert_project() 178 self.insert_project()
176 self.insert_task() 179 self.insert_task()
177 180
178 - def test_second_active_run_for_same_task_and_org_node_rejected(self):  
179 - self.insert_work_graph_run("run-1", "task-1", "coder", status="running") 181 + def test_second_active_run_for_same_task_rejected(self):
  182 + self.insert_work_graph_run("run-1", "task-1", status="running")
  183 + with self.assertRaises(sqlite3.IntegrityError):
  184 + self.insert_work_graph_run("run-2", "task-1", status="planning")
  185 +
  186 + def test_blocked_run_still_counts_as_active(self):
  187 + self.insert_work_graph_run("run-1", "task-1", status="blocked")
180 with self.assertRaises(sqlite3.IntegrityError): 188 with self.assertRaises(sqlite3.IntegrityError):
181 - self.insert_work_graph_run("run-2", "task-1", "coder", status="planning") 189 + self.insert_work_graph_run("run-2", "task-1", status="planning")
  190 +
  191 + def test_second_run_allowed_once_first_is_done(self):
  192 + self.insert_work_graph_run("run-1", "task-1", status="done")
  193 + self.insert_work_graph_run("run-2", "task-1", status="planning")
  194 + count = self.conn.execute(
  195 + "SELECT COUNT(*) FROM work_graph_run WHERE task_id = 'task-1'"
  196 + ).fetchone()[0]
  197 + self.assertEqual(count, 2)
182 198
183 - def test_second_run_allowed_once_first_is_terminal(self):  
184 - self.insert_work_graph_run("run-1", "task-1", "coder", status="done")  
185 - self.insert_work_graph_run("run-2", "task-1", "coder", status="planning") 199 + def test_second_run_allowed_once_first_is_failed(self):
  200 + self.insert_work_graph_run("run-1", "task-1", status="failed")
  201 + self.insert_work_graph_run("run-2", "task-1", status="planning")
186 count = self.conn.execute( 202 count = self.conn.execute(
187 - "SELECT COUNT(*) FROM work_graph_run WHERE task_id = 'task-1' AND org_node = 'coder'" 203 + "SELECT COUNT(*) FROM work_graph_run WHERE task_id = 'task-1'"
188 ).fetchone()[0] 204 ).fetchone()[0]
189 self.assertEqual(count, 2) 205 self.assertEqual(count, 2)
190 206
@@ -247,8 +263,8 @@ class MigrateExpandArtifactTypesTest(unittest.TestCase): @@ -247,8 +263,8 @@ class MigrateExpandArtifactTypesTest(unittest.TestCase):
247 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'queued', 'planner')" 263 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'queued', 'planner')"
248 ) 264 )
249 self.conn.execute( 265 self.conn.execute(
250 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
251 - "VALUES ('run-1', 'task-1', 'planner', '{}', 'planning')" 266 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  267 + "VALUES ('run-1', 'task-1', '{}', 'planning')"
252 ) 268 )
253 self.conn.execute( 269 self.conn.execute(
254 "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, " 270 "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, "
@@ -309,8 +325,8 @@ class MigrateExpandArtifactTypesTest(unittest.TestCase): @@ -309,8 +325,8 @@ class MigrateExpandArtifactTypesTest(unittest.TestCase):
309 "VALUES ('t', 'p', 'g', 'goal', 'queued', 'planner')" 325 "VALUES ('t', 'p', 'g', 'goal', 'queued', 'planner')"
310 ) 326 )
311 fresh.execute( 327 fresh.execute(
312 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
313 - "VALUES ('r', 't', 'planner', '{}', 'planning')" 328 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  329 + "VALUES ('r', 't', '{}', 'planning')"
314 ) 330 )
315 fresh.execute( 331 fresh.execute(
316 "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, " 332 "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, "
@@ -519,8 +535,8 @@ class MigrateAddWorkGraphForkColumnsTest(unittest.TestCase): @@ -519,8 +535,8 @@ class MigrateAddWorkGraphForkColumnsTest(unittest.TestCase):
519 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'queued', 'planner')" 535 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'queued', 'planner')"
520 ) 536 )
521 self.conn.execute( 537 self.conn.execute(
522 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
523 - "VALUES ('run-1', 'task-1', 'planner', '{}', 'done')" 538 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  539 + "VALUES ('run-1', 'task-1', '{}', 'done')"
524 ) 540 )
525 self.conn.commit() 541 self.conn.commit()
526 542
@@ -552,6 +568,28 @@ class MigrateAddWorkGraphForkColumnsTest(unittest.TestCase): @@ -552,6 +568,28 @@ class MigrateAddWorkGraphForkColumnsTest(unittest.TestCase):
552 fresh.close() 568 fresh.close()
553 569
554 570
  571 +_OLD_WORK_GRAPH_RUN_BLOCK = """-- 1.3 work_graph_run
  572 +CREATE TABLE work_graph_run (
  573 + run_id TEXT PRIMARY KEY,
  574 + task_id TEXT NOT NULL REFERENCES task(task_id),
  575 + org_node TEXT NOT NULL CHECK (org_node IN ('planner', 'coder', 'validator')),
  576 + graph_manifest TEXT NOT NULL,
  577 + orchestration_script_ref TEXT,
  578 + status TEXT NOT NULL CHECK (status IN (
  579 + 'planning', 'running', 'merging', 'verifying',
  580 + 'reflecting', 'blocked', 'done', 'failed'
  581 + )),
  582 + started_at TEXT,
  583 + completed_at TEXT,
  584 + result_artifact_refs TEXT
  585 +);
  586 +
  587 +CREATE UNIQUE INDEX ux_work_graph_run_active ON work_graph_run(task_id, org_node)
  588 + WHERE status IN ('planning', 'running', 'merging', 'verifying');
  589 +
  590 +"""
  591 +
  592 +
555 def _old_org_node_enum_schema_sql(): 593 def _old_org_node_enum_schema_sql():
556 """H (2026-08-06): simulates a pre-migration database whose 4 594 """H (2026-08-06): simulates a pre-migration database whose 4
557 org_node-ish columns still have the old CHECK(x IN ('planner','coder', 595 org_node-ish columns still have the old CHECK(x IN ('planner','coder',
@@ -559,9 +597,22 @@ def _old_org_node_enum_schema_sql(): @@ -559,9 +597,22 @@ def _old_org_node_enum_schema_sql():
559 too (migrate_relax_org_node_constraints depends on 597 too (migrate_relax_org_node_constraints depends on
560 migrate_add_work_graph_fork_columns having run, so the test fixture 598 migrate_add_work_graph_fork_columns having run, so the test fixture
561 should start from a database that's had NEITHER migration, same as a 599 should start from a database that's had NEITHER migration, same as a
562 - real old production db would be)."""  
563 - sql = schema.SCHEMA_SQL  
564 - for column in ("owner_org_node", "assigned_org_node", "org_node"): 600 + real old production db would be).
  601 +
  602 + 2026-08-12 (Org Graph unification Phase 2c §2.1): work_graph_run.org_node
  603 + no longer exists in schema.SCHEMA_SQL at all (removed outright, not just
  604 + relaxed -- see migrate_drop_work_graph_run_org_node), so this function
  605 + can no longer derive the 'old' work_graph_run shape via regex
  606 + substitution the way it still does for owner_org_node/assigned_org_node/
  607 + agent_profile.org_node below (those 3 are unaffected by Phase 2c, still
  608 + present in SCHEMA_SQL). The 1.3 section is spliced out and replaced with
  609 + a hardcoded legacy block instead -- the historical shape this test
  610 + exercises no longer has a live counterpart to derive from."""
  611 + before, _, rest = schema.SCHEMA_SQL.partition("-- 1.3 work_graph_run")
  612 + _, _, after = rest.partition("-- 1.4 work_node_execution")
  613 + sql = before + _OLD_WORK_GRAPH_RUN_BLOCK + "-- 1.4 work_node_execution" + after
  614 +
  615 + for column in ("owner_org_node", "assigned_org_node"):
565 sql = re.sub( 616 sql = re.sub(
566 r"(?: --[^\n]*\n)* " + column + r" TEXT( NOT NULL)? CHECK \(" + column + r" != ''\)", 617 r"(?: --[^\n]*\n)* " + column + r" TEXT( NOT NULL)? CHECK \(" + column + r" != ''\)",
567 column + r" TEXT\1 CHECK (" + column + " IN ('planner', 'coder', 'validator'))", 618 column + r" TEXT\1 CHECK (" + column + " IN ('planner', 'coder', 'validator'))",
@@ -572,12 +623,6 @@ def _old_org_node_enum_schema_sql(): @@ -572,12 +623,6 @@ def _old_org_node_enum_schema_sql():
572 "org_node TEXT PRIMARY KEY CHECK (org_node IN ('planner', 'coder', 'validator'))", 623 "org_node TEXT PRIMARY KEY CHECK (org_node IN ('planner', 'coder', 'validator'))",
573 sql, 624 sql,
574 ) 625 )
575 - sql = re.sub(  
576 - r",\n( --[^\n]*\n)* parent_run_id TEXT REFERENCES work_graph_run\(run_id\),\n"  
577 - r" fork_of_node_exec_id TEXT REFERENCES work_node_execution\(node_exec_id\)\n\);",  
578 - "\n);",  
579 - sql,  
580 - )  
581 return sql 626 return sql
582 627
583 628
@@ -739,6 +784,138 @@ class MigrateRelaxOrgNodeConstraintsTest(unittest.TestCase): @@ -739,6 +784,138 @@ class MigrateRelaxOrgNodeConstraintsTest(unittest.TestCase):
739 fresh.close() 784 fresh.close()
740 785
741 786
  787 +class MigrateDropWorkGraphRunOrgNodeTest(unittest.TestCase):
  788 + """Org Graph unification Phase 2c §2.1/§2.2 (2026-08-12): real upgrade
  789 + path for an already-populated database whose work_graph_run still has
  790 + the org_node column (with the OLDEST possible starting shape -- the
  791 + strict 3-value enum, same fixture as MigrateRelaxOrgNodeConstraintsTest
  792 + -- to also exercise this migration's own internal dependency call to
  793 + migrate_relax_org_node_constraints())."""
  794 +
  795 + def setUp(self):
  796 + old_sql = _old_org_node_enum_schema_sql()
  797 + self.conn = schema.connect(":memory:")
  798 + self.conn.execute("PRAGMA foreign_keys = ON")
  799 + self.conn.executescript(old_sql)
  800 + self.conn.commit()
  801 + self.conn.execute(
  802 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  803 + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')"
  804 + )
  805 + self.conn.execute(
  806 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  807 + "VALUES ('task-1', 'proj-1', 'g', 'goal', 'queued', 'planner')"
  808 + )
  809 + self.conn.execute(
  810 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  811 + "VALUES ('run-1', 'task-1', 'planner', '{}', 'done')"
  812 + )
  813 + self.conn.execute(
  814 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
  815 + "VALUES ('skill-1', 'planner', 'skills/x/', 'sha', 'new', 'active')"
  816 + )
  817 + self.conn.commit()
  818 +
  819 + def tearDown(self):
  820 + self.conn.close()
  821 +
  822 + def test_org_node_column_actually_dropped(self):
  823 + schema.migrate_drop_work_graph_run_org_node(self.conn)
  824 + cols = {row[1] for row in self.conn.execute("PRAGMA table_info(work_graph_run)").fetchall()}
  825 + self.assertNotIn("org_node", cols)
  826 +
  827 + def test_migration_preserves_existing_rows(self):
  828 + schema.migrate_drop_work_graph_run_org_node(self.conn)
  829 + self.assertEqual(
  830 + self.conn.execute(
  831 + "SELECT run_id, task_id, status FROM work_graph_run WHERE run_id = 'run-1'"
  832 + ).fetchone(),
  833 + ("run-1", "task-1", "done"),
  834 + )
  835 +
  836 + def test_ux_work_graph_run_active_narrowed_to_task_id(self):
  837 + schema.migrate_drop_work_graph_run_org_node(self.conn)
  838 + self.conn.execute(
  839 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  840 + "VALUES ('run-2', 'task-1', '{}', 'running')"
  841 + )
  842 + with self.assertRaises(sqlite3.IntegrityError):
  843 + self.conn.execute(
  844 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  845 + "VALUES ('run-3', 'task-1', '{}', 'running')"
  846 + )
  847 +
  848 + def test_blocked_run_still_counts_as_active_after_migration(self):
  849 + schema.migrate_drop_work_graph_run_org_node(self.conn)
  850 + self.conn.execute(
  851 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  852 + "VALUES ('run-2', 'task-1', '{}', 'blocked')"
  853 + )
  854 + with self.assertRaises(sqlite3.IntegrityError):
  855 + self.conn.execute(
  856 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  857 + "VALUES ('run-3', 'task-1', '{}', 'planning')"
  858 + )
  859 +
  860 + def test_second_run_allowed_once_first_is_done(self):
  861 + # run-1 (seeded in setUp) is already status='done' -- a second run
  862 + # for the SAME task_id must be allowed.
  863 + schema.migrate_drop_work_graph_run_org_node(self.conn)
  864 + self.conn.execute(
  865 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  866 + "VALUES ('run-2', 'task-1', '{}', 'planning')"
  867 + )
  868 + self.conn.commit()
  869 + count = self.conn.execute("SELECT COUNT(*) FROM work_graph_run WHERE task_id = 'task-1'").fetchone()[0]
  870 + self.assertEqual(count, 2)
  871 +
  872 + def test_foreign_key_relationships_survive_rebuild(self):
  873 + schema.migrate_drop_work_graph_run_org_node(self.conn)
  874 + self.conn.execute(
  875 + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
  876 + "input_schema_ref, output_schema_ref, input_payload, status) "
  877 + "VALUES ('node-1', 'run-1', 'n1', 'planner', 'skill-1', 'in.json', 'out.json', '{}', 'done')"
  878 + )
  879 + self.conn.commit()
  880 + with self.assertRaises(sqlite3.IntegrityError):
  881 + self.conn.execute(
  882 + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
  883 + "input_schema_ref, output_schema_ref, input_payload, status) "
  884 + "VALUES ('node-2', 'no-such-run', 'n1', 'planner', 'skill-1', 'in.json', 'out.json', '{}', 'done')"
  885 + )
  886 +
  887 + def test_idempotent_on_a_database_already_migrated(self):
  888 + schema.migrate_drop_work_graph_run_org_node(self.conn)
  889 + schema.migrate_drop_work_graph_run_org_node(self.conn) # must not raise
  890 + count = self.conn.execute("SELECT COUNT(*) FROM work_graph_run").fetchone()[0]
  891 + self.assertEqual(count, 1)
  892 +
  893 + def test_foreign_keys_pragma_restored_after_migration(self):
  894 + schema.migrate_drop_work_graph_run_org_node(self.conn)
  895 + self.assertEqual(self.conn.execute("PRAGMA foreign_keys").fetchone()[0], 1)
  896 +
  897 + def test_noop_on_a_freshly_created_database(self):
  898 + fresh = schema.connect(":memory:")
  899 + try:
  900 + schema.create_schema(fresh)
  901 + schema.migrate_drop_work_graph_run_org_node(fresh) # must not raise
  902 + fresh.execute(
  903 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  904 + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')"
  905 + )
  906 + fresh.execute(
  907 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  908 + "VALUES ('task-1', 'proj-1', 'g', 'goal', 'queued', 'planner')"
  909 + )
  910 + fresh.execute(
  911 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  912 + "VALUES ('run-1', 'task-1', '{}', 'planning')"
  913 + )
  914 + fresh.commit()
  915 + finally:
  916 + fresh.close()
  917 +
  918 +
742 class MigrateAddWorkGraphLoopLimitConfigTest(unittest.TestCase): 919 class MigrateAddWorkGraphLoopLimitConfigTest(unittest.TestCase):
743 """2026-08-08: real upgrade path for an already-populated database that 920 """2026-08-08: real upgrade path for an already-populated database that
744 predates work_graph_loop_limit_config (plain CREATE TABLE IF NOT 921 predates work_graph_loop_limit_config (plain CREATE TABLE IF NOT
@@ -880,8 +1057,8 @@ class MigrateAddReviewingPlannerOutputStatusTest(unittest.TestCase): @@ -880,8 +1057,8 @@ class MigrateAddReviewingPlannerOutputStatusTest(unittest.TestCase):
880 # every test below previously ran against a task row with ZERO 1057 # every test below previously ran against a task row with ZERO
881 # referencing children, so FK enforcement was never truly exercised. 1058 # referencing children, so FK enforcement was never truly exercised.
882 self.conn.execute( 1059 self.conn.execute(
883 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
884 - "VALUES ('run-1', 'task-1', 'planner', '{}', 'done')" 1060 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  1061 + "VALUES ('run-1', 'task-1', '{}', 'done')"
885 ) 1062 )
886 self.conn.execute( 1063 self.conn.execute(
887 "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, " 1064 "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, "
src/test_task_execution.py
@@ -128,8 +128,8 @@ class TaskExecutionTestBase(unittest.TestCase): @@ -128,8 +128,8 @@ class TaskExecutionTestBase(unittest.TestCase):
128 "edges": [], 128 "edges": [],
129 }) 129 })
130 self.conn.execute( 130 self.conn.execute(
131 - "INSERT OR IGNORE INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
132 - "VALUES (?, ?, 'planner', ?, 'done')", (run_id, task_id, manifest), 131 + "INSERT OR IGNORE INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  132 + "VALUES (?, ?, ?, 'done')", (run_id, task_id, manifest),
133 ) 133 )
134 # Phase 2c (2026-08-12, found while adding Validator self-planning 134 # Phase 2c (2026-08-12, found while adding Validator self-planning
135 # tests): a real Planner run_graph() call ALSO leaves a real 135 # tests): a real Planner run_graph() call ALSO leaves a real
@@ -527,8 +527,8 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase): @@ -527,8 +527,8 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase):
527 "edges": [], 527 "edges": [],
528 }) 528 })
529 conn.execute( 529 conn.execute(
530 - "INSERT OR IGNORE INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
531 - "VALUES (?, ?, 'planner', ?, 'done')", (run_id, task_id, manifest), 530 + "INSERT OR IGNORE INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  531 + "VALUES (?, ?, ?, 'done')", (run_id, task_id, manifest),
532 ) 532 )
533 work_graph_orchestrate.ensure_placeholder_skill(conn, "planner-doc-writing", "planner") 533 work_graph_orchestrate.ensure_placeholder_skill(conn, "planner-doc-writing", "planner")
534 conn.execute( 534 conn.execute(
src/test_task_query.py
@@ -94,8 +94,7 @@ class GetTaskTest(TaskQueryTestBase): @@ -94,8 +94,7 @@ class GetTaskTest(TaskQueryTestBase):
94 def test_includes_artifacts(self): 94 def test_includes_artifacts(self):
95 self._insert_task("task-1") 95 self._insert_task("task-1")
96 self.conn.execute( 96 self.conn.execute(
97 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
98 - "VALUES ('run-1', 'task-1', 'planner', '{}', 'done')" 97 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) VALUES ('run-1', 'task-1', '{}', 'done')"
99 ) 98 )
100 self.conn.execute( 99 self.conn.execute(
101 "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, file_path, " 100 "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, file_path, "
@@ -121,8 +120,8 @@ class GetTaskTest(TaskQueryTestBase): @@ -121,8 +120,8 @@ class GetTaskTest(TaskQueryTestBase):
121 self._insert_task("task-1") 120 self._insert_task("task-1")
122 manifest = {"nodes": [{"node_id": "n1", "artifact_type": "brd", "depends_on": []}], "edges": []} 121 manifest = {"nodes": [{"node_id": "n1", "artifact_type": "brd", "depends_on": []}], "edges": []}
123 self.conn.execute( 122 self.conn.execute(
124 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
125 - "VALUES ('run-1', 'task-1', 'planner', ?, 'running')", 123 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  124 + "VALUES ('run-1', 'task-1', ?, 'running')",
126 (json.dumps(manifest),), 125 (json.dumps(manifest),),
127 ) 126 )
128 self.conn.execute( 127 self.conn.execute(
@@ -152,8 +151,8 @@ class GetTaskTest(TaskQueryTestBase): @@ -152,8 +151,8 @@ class GetTaskTest(TaskQueryTestBase):
152 # from graph_manifest or work_node_execution alone. 151 # from graph_manifest or work_node_execution alone.
153 self._insert_task("task-1") 152 self._insert_task("task-1")
154 self.conn.execute( 153 self.conn.execute(
155 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
156 - "VALUES ('run-1', 'task-1', 'planner', '{}', 'running')" 154 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  155 + "VALUES ('run-1', 'task-1', '{}', 'running')"
157 ) 156 )
158 self.conn.execute( 157 self.conn.execute(
159 "INSERT INTO execution_event (event_id, project_id, task_id, run_id, event_type, payload) " 158 "INSERT INTO execution_event (event_id, project_id, task_id, run_id, event_type, payload) "
@@ -177,8 +176,7 @@ class GetTaskTest(TaskQueryTestBase): @@ -177,8 +176,7 @@ class GetTaskTest(TaskQueryTestBase):
177 def test_no_loop_back_events_is_an_empty_list_not_missing_key(self): 176 def test_no_loop_back_events_is_an_empty_list_not_missing_key(self):
178 self._insert_task("task-1") 177 self._insert_task("task-1")
179 self.conn.execute( 178 self.conn.execute(
180 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
181 - "VALUES ('run-1', 'task-1', 'planner', '{}', 'done')" 179 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) VALUES ('run-1', 'task-1', '{}', 'done')"
182 ) 180 )
183 self.conn.commit() 181 self.conn.commit()
184 result = task_query.get_task(self.conn, "task-1") 182 result = task_query.get_task(self.conn, "task-1")
@@ -189,8 +187,8 @@ class GetTaskTest(TaskQueryTestBase): @@ -189,8 +187,8 @@ class GetTaskTest(TaskQueryTestBase):
189 # apart from the original run it descends from. 187 # apart from the original run it descends from.
190 self._insert_task("task-1") 188 self._insert_task("task-1")
191 self.conn.execute( 189 self.conn.execute(
192 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
193 - "VALUES ('run-orig', 'task-1', 'planner', '{}', 'done')" 190 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  191 + "VALUES ('run-orig', 'task-1', '{}', 'done')"
194 ) 192 )
195 self.conn.execute( 193 self.conn.execute(
196 "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) " 194 "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
@@ -202,9 +200,9 @@ class GetTaskTest(TaskQueryTestBase): @@ -202,9 +200,9 @@ class GetTaskTest(TaskQueryTestBase):
202 "VALUES ('node-1', 'run-orig', 'n1', 'planner', 'skill-1', 'in.json', 'out.json', '{}', '{}', 'done')" 200 "VALUES ('node-1', 'run-orig', 'n1', 'planner', 'skill-1', 'in.json', 'out.json', '{}', '{}', 'done')"
203 ) 201 )
204 self.conn.execute( 202 self.conn.execute(
205 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status, " 203 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status, "
206 "parent_run_id, fork_of_node_exec_id) " 204 "parent_run_id, fork_of_node_exec_id) "
207 - "VALUES ('run-fork', 'task-1', 'planner', '{}', 'done', 'run-orig', 'node-1')" 205 + "VALUES ('run-fork', 'task-1', '{}', 'done', 'run-orig', 'node-1')"
208 ) 206 )
209 self.conn.commit() 207 self.conn.commit()
210 208
@@ -219,8 +217,8 @@ class GetTaskTest(TaskQueryTestBase): @@ -219,8 +217,8 @@ class GetTaskTest(TaskQueryTestBase):
219 # quota_limit_type + how long it's been waiting (02-V §2.7) 217 # quota_limit_type + how long it's been waiting (02-V §2.7)
220 self._insert_task("task-1") 218 self._insert_task("task-1")
221 self.conn.execute( 219 self.conn.execute(
222 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
223 - "VALUES ('run-1', 'task-1', 'coder', '{}', 'running')" 220 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  221 + "VALUES ('run-1', 'task-1', '{}', 'running')"
224 ) 222 )
225 self.conn.execute( 223 self.conn.execute(
226 "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) " 224 "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
src/test_work_graph_fork.py
@@ -64,9 +64,8 @@ class ForkTestBase(unittest.TestCase): @@ -64,9 +64,8 @@ class ForkTestBase(unittest.TestCase):
64 row plus one 'done' work_node_execution row per node, each holding 64 row plus one 'done' work_node_execution row per node, each holding
65 contents[node_id] as its output_payload.""" 65 contents[node_id] as its output_payload."""
66 self.conn.execute( 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), 67 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) VALUES (?, 'task-1', ?, ?)",
  68 + (run_id, json.dumps(graph_manifest), status),
70 ) 69 )
71 node_exec_ids = {} 70 node_exec_ids = {}
72 for node in graph_manifest["nodes"]: 71 for node in graph_manifest["nodes"]:
@@ -254,8 +253,8 @@ class MultipleForksFromSameNodeTest(ForkTestBase): @@ -254,8 +253,8 @@ class MultipleForksFromSameNodeTest(ForkTestBase):
254 class ForkValidationErrorsTest(ForkTestBase): 253 class ForkValidationErrorsTest(ForkTestBase):
255 def test_forking_a_non_done_node_raises(self): 254 def test_forking_a_non_done_node_raises(self):
256 self.conn.execute( 255 self.conn.execute(
257 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
258 - "VALUES ('run-1', 'task-1', 'planner', ?, 'running')", 256 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  257 + "VALUES ('run-1', 'task-1', ?, 'running')",
259 (json.dumps(_BRD_PRD_SDD_BENCHMARK_MANIFEST),), 258 (json.dumps(_BRD_PRD_SDD_BENCHMARK_MANIFEST),),
260 ) 259 )
261 self.conn.execute( 260 self.conn.execute(
@@ -321,8 +320,8 @@ class StartForkInBackgroundTest(unittest.TestCase): @@ -321,8 +320,8 @@ class StartForkInBackgroundTest(unittest.TestCase):
321 "VALUES ('planner-doc-writing', 'planner', 'skills/planner-doc-writing/', 'unknown', 'new', 'active')" 320 "VALUES ('planner-doc-writing', 'planner', 'skills/planner-doc-writing/', 'unknown', 'new', 'active')"
322 ) 321 )
323 conn.execute( 322 conn.execute(
324 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
325 - "VALUES ('run-orig', 'task-1', 'planner', ?, 'done')", 323 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  324 + "VALUES ('run-orig', 'task-1', ?, 'done')",
326 (json.dumps(_BRD_PRD_SDD_BENCHMARK_MANIFEST),), 325 (json.dumps(_BRD_PRD_SDD_BENCHMARK_MANIFEST),),
327 ) 326 )
328 contents = { 327 contents = {
src/test_work_graph_orchestrate.py
@@ -234,8 +234,8 @@ class RunGraphTestBase(unittest.TestCase): @@ -234,8 +234,8 @@ class RunGraphTestBase(unittest.TestCase):
234 "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'coding', 'planner')" 234 "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'coding', 'planner')"
235 ) 235 )
236 self.conn.execute( 236 self.conn.execute(
237 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
238 - "VALUES ('run-1', 'task-1', 'planner', '{}', 'running')" 237 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  238 + "VALUES ('run-1', 'task-1', '{}', 'running')"
239 ) 239 )
240 self.conn.commit() 240 self.conn.commit()
241 241
@@ -829,8 +829,8 @@ class RunGraphRealSqliteConcurrencyTest(unittest.TestCase): @@ -829,8 +829,8 @@ class RunGraphRealSqliteConcurrencyTest(unittest.TestCase):
829 "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'coding', 'planner')" 829 "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'coding', 'planner')"
830 ) 830 )
831 conn.execute( 831 conn.execute(
832 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
833 - "VALUES ('run-1', 'task-1', 'planner', '{}', 'running')" 832 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  833 + "VALUES ('run-1', 'task-1', '{}', 'running')"
834 ) 834 )
835 conn.commit() 835 conn.commit()
836 836
src/test_work_graph_plan.py
@@ -308,13 +308,12 @@ class HappyPathTest(PlanWorkGraphTestBase): @@ -308,13 +308,12 @@ class HappyPathTest(PlanWorkGraphTestBase):
308 self.conn, "planner", "task-1", "实现贪吃蛇游戏", "/tmp/proj-1" 308 self.conn, "planner", "task-1", "实现贪吃蛇游戏", "/tmp/proj-1"
309 ) 309 )
310 row = self.conn.execute( 310 row = self.conn.execute(
311 - "SELECT task_id, org_node, status, graph_manifest FROM work_graph_run WHERE run_id = ?", 311 + "SELECT task_id, status, graph_manifest FROM work_graph_run WHERE run_id = ?",
312 (result["run_id"],), 312 (result["run_id"],),
313 ).fetchone() 313 ).fetchone()
314 self.assertEqual(row[0], "task-1") 314 self.assertEqual(row[0], "task-1")
315 - self.assertEqual(row[1], "planner")  
316 - self.assertEqual(row[2], "planning")  
317 - self.assertEqual(json.loads(row[3]), _MANIFEST) 315 + self.assertEqual(row[1], "planning")
  316 + self.assertEqual(json.loads(row[2]), _MANIFEST)
318 317
319 def test_invokes_claude_with_expected_args(self): 318 def test_invokes_claude_with_expected_args(self):
320 with patch("src.work_graph_plan.subprocess.run") as run: 319 with patch("src.work_graph_plan.subprocess.run") as run:
@@ -448,8 +447,7 @@ class AppendToRunIdTest(PlanWorkGraphTestBase): @@ -448,8 +447,7 @@ class AppendToRunIdTest(PlanWorkGraphTestBase):
448 447
449 def _seed_run(self, run_id, manifest): 448 def _seed_run(self, run_id, manifest):
450 self.conn.execute( 449 self.conn.execute(
451 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
452 - "VALUES (?, 'task-1', 'planner', ?, 'running')", 450 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) VALUES (?, 'task-1', ?, 'running')",
453 (run_id, json.dumps(manifest)), 451 (run_id, json.dumps(manifest)),
454 ) 452 )
455 self.conn.commit() 453 self.conn.commit()
src/test_work_graph_run_query.py
@@ -29,24 +29,31 @@ class WorkGraphRunQueryTestBase(unittest.TestCase): @@ -29,24 +29,31 @@ class WorkGraphRunQueryTestBase(unittest.TestCase):
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 parent_run_id=None, fork_of_node_exec_id=None):
  32 + """2026-08-12 (Org Graph unification Phase 2c §2.1.1):
  33 + work_graph_run.org_node no longer exists -- `org_node` here now
  34 + means "the subagent_type to stamp this run's seeded nodes with"
  35 + (org_nodes_involved is derived from work_node_execution.
  36 + subagent_type, not a run-level column). A run with node_count=0
  37 + genuinely has no org_nodes_involved at all now; tests that care
  38 + about org_node-based filtering/listing must pass node_count >= 1."""
32 self.conn.execute( 39 self.conn.execute(
33 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status, " 40 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status, "
34 "started_at, completed_at, parent_run_id, fork_of_node_exec_id) " 41 "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), 42 + "VALUES (?, ?, '{}', ?, ?, ?, ?, ?)",
  43 + (run_id, task_id, status, started_at, completed_at, parent_run_id, fork_of_node_exec_id),
37 ) 44 )
38 for i in range(node_count): 45 for i in range(node_count):
39 node_exec_id = "{}-node-{}".format(run_id, i) 46 node_exec_id = "{}-node-{}".format(run_id, i)
40 self.conn.execute( 47 self.conn.execute(
41 "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) " 48 "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
42 - "VALUES (?, 'coder', 'skills/x.md', 'sha1', 'new', 'active')",  
43 - ("skill-{}".format(node_exec_id),), 49 + "VALUES (?, ?, 'skills/x.md', 'sha1', 'new', 'active')",
  50 + ("skill-{}".format(node_exec_id), org_node),
44 ) 51 )
45 self.conn.execute( 52 self.conn.execute(
46 "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, " 53 "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
47 "input_schema_ref, output_schema_ref, input_payload, status) " 54 "input_schema_ref, output_schema_ref, input_payload, status) "
48 - "VALUES (?, ?, ?, 'coder', ?, 'schema', 'schema', '{}', 'done')",  
49 - (node_exec_id, run_id, "node-{}".format(i), "skill-{}".format(node_exec_id)), 55 + "VALUES (?, ?, ?, ?, ?, 'schema', 'schema', '{}', 'done')",
  56 + (node_exec_id, run_id, "node-{}".format(i), org_node, "skill-{}".format(node_exec_id)),
50 ) 57 )
51 self.conn.commit() 58 self.conn.commit()
52 59
@@ -87,12 +94,30 @@ class ListWorkGraphRunsTest(WorkGraphRunQueryTestBase): @@ -87,12 +94,30 @@ class ListWorkGraphRunsTest(WorkGraphRunQueryTestBase):
87 self.assertEqual([r["run_id"] for r in result], ["run-1"]) 94 self.assertEqual([r["run_id"] for r in result], ["run-1"])
88 95
89 def test_org_node_filter(self): 96 def test_org_node_filter(self):
90 - self._insert_run("run-1", "planner", started_at="2026-08-01T00:00:00")  
91 - self._insert_run("run-2", "coder", started_at="2026-08-02T00:00:00")  
92 - self._insert_run("run-3", "coder", started_at="2026-08-03T00:00:00") 97 + self._insert_run("run-1", "planner", started_at="2026-08-01T00:00:00", node_count=1)
  98 + self._insert_run("run-2", "coder", started_at="2026-08-02T00:00:00", node_count=1)
  99 + self._insert_run("run-3", "coder", started_at="2026-08-03T00:00:00", node_count=1)
93 result = work_graph_run_query.list_work_graph_runs(self.conn, "proj-1", org_node="coder") 100 result = work_graph_run_query.list_work_graph_runs(self.conn, "proj-1", org_node="coder")
94 self.assertEqual({r["run_id"] for r in result}, {"run-2", "run-3"}) 101 self.assertEqual({r["run_id"] for r in result}, {"run-2", "run-3"})
95 102
  103 + def test_org_nodes_involved_reflects_merged_run_nodes(self):
  104 + # Org Graph unification Phase 2c §2.1.1: a merged run's nodes can
  105 + # span several org_nodes -- this is the whole reason the old
  106 + # single-value org_node column was removed.
  107 + self._insert_run("run-1", "planner", node_count=1)
  108 + self.conn.execute(
  109 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
  110 + "VALUES ('skill-extra', 'coder', 'skills/x.md', 'sha1', 'new', 'active')"
  111 + )
  112 + self.conn.execute(
  113 + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
  114 + "input_schema_ref, output_schema_ref, input_payload, status) "
  115 + "VALUES ('run-1-extra', 'run-1', 'extra', 'coder', 'skill-extra', 'schema', 'schema', '{}', 'done')"
  116 + )
  117 + self.conn.commit()
  118 + result = work_graph_run_query.list_work_graph_runs(self.conn, "proj-1")
  119 + self.assertEqual(sorted(result[0]["org_nodes_involved"]), ["coder", "planner"])
  120 +
96 def test_limit_one_returns_latest(self): 121 def test_limit_one_returns_latest(self):
97 self._insert_run("run-1", "planner", started_at="2026-08-01T00:00:00") 122 self._insert_run("run-1", "planner", started_at="2026-08-01T00:00:00")
98 self._insert_run("run-2", "coder", started_at="2026-08-02T00:00:00") 123 self._insert_run("run-2", "coder", started_at="2026-08-02T00:00:00")
@@ -128,7 +153,7 @@ class GetWorkGraphRunTest(WorkGraphRunQueryTestBase): @@ -128,7 +153,7 @@ class GetWorkGraphRunTest(WorkGraphRunQueryTestBase):
128 result = work_graph_run_query.get_work_graph_run(self.conn, "run-1") 153 result = work_graph_run_query.get_work_graph_run(self.conn, "run-1")
129 self.assertEqual(result["run_id"], "run-1") 154 self.assertEqual(result["run_id"], "run-1")
130 self.assertEqual(result["task_id"], "task-1") 155 self.assertEqual(result["task_id"], "task-1")
131 - self.assertEqual(result["org_node"], "coder") 156 + self.assertEqual(result["org_nodes_involved"], ["coder"])
132 self.assertEqual(result["status"], "running") 157 self.assertEqual(result["status"], "running")
133 self.assertEqual(result["graph_manifest"], {}) 158 self.assertEqual(result["graph_manifest"], {})
134 self.assertEqual(len(result["nodes"]), 2) 159 self.assertEqual(len(result["nodes"]), 2)
src/test_work_node_run.py
@@ -49,8 +49,8 @@ class WorkNodeRunTestBase(unittest.TestCase): @@ -49,8 +49,8 @@ class WorkNodeRunTestBase(unittest.TestCase):
49 "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'coding', 'coder')" 49 "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'coding', 'coder')"
50 ) 50 )
51 self.conn.execute( 51 self.conn.execute(
52 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
53 - "VALUES ('run-1', 'task-1', 'coder', '{}', 'running')" 52 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
  53 + "VALUES ('run-1', 'task-1', '{}', 'running')"
54 ) 54 )
55 self.conn.execute( 55 self.conn.execute(
56 "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) " 56 "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
src/work_graph_fork.py
@@ -91,7 +91,7 @@ def _downstream_node_ids(graph_manifest, start_node_id): @@ -91,7 +91,7 @@ def _downstream_node_ids(graph_manifest, start_node_id):
91 return seen 91 return seen
92 92
93 93
94 -def _reconstruct_build_prompt(conn, task_id, org_node, graph_manifest): 94 +def _reconstruct_build_prompt(conn, task_id, graph_manifest):
95 """Rebuilds a build_prompt(node, outputs) usable for the re-executed 95 """Rebuilds a build_prompt(node, outputs) usable for the re-executed
96 downstream subgraph, purely from persisted state -- no access to the 96 downstream subgraph, purely from persisted state -- no access to the
97 original call site's Python closures. Two supported node shapes 97 original call site's Python closures. Two supported node shapes
@@ -110,11 +110,12 @@ def _reconstruct_build_prompt(conn, task_id, org_node, graph_manifest): @@ -110,11 +110,12 @@ def _reconstruct_build_prompt(conn, task_id, org_node, graph_manifest):
110 (work_graph_plan.dispatch_build_prompt) instead of once for the whole 110 (work_graph_plan.dispatch_build_prompt) instead of once for the whole
111 graph -- every pre-Phase-2 graph_manifest still has just one shape 111 graph -- every pre-Phase-2 graph_manifest still has just one shape
112 throughout, so this is a strict generalization, not a behavior change 112 throughout, so this is a strict generalization, not a behavior change
113 - for anything that isn't a merged graph. `org_node` is unused by this  
114 - per-node dispatch itself (kept as a parameter -- callers still pass it  
115 - for other purposes, e.g. run_graph()'s own org_node argument;  
116 - removing it from work_graph_run/this signature is Phase 2c's job  
117 - alongside the actual org_node column removal, not this fix's).""" 113 + for anything that isn't a merged graph.
  114 +
  115 + 2026-08-12 (Phase 2c §2.1.2): `org_node` parameter dropped -- it was
  116 + already unused by this per-node dispatch (kept only because Phase 2b
  117 + hadn't yet done the org_node column removal this fix was explicitly
  118 + deferring to)."""
118 nodes = graph_manifest["nodes"] 119 nodes = graph_manifest["nodes"]
119 if not nodes: 120 if not nodes:
120 raise ForkGraphShapeUnsupportedError("graph_manifest has no nodes to reconstruct a build_prompt for") 121 raise ForkGraphShapeUnsupportedError("graph_manifest has no nodes to reconstruct a build_prompt for")
@@ -147,7 +148,7 @@ def create_fork(conn, source_node_exec_id, new_output_payload): @@ -147,7 +148,7 @@ def create_fork(conn, source_node_exec_id, new_output_payload):
147 uses for the manual Start button.""" 148 uses for the manual Start button."""
148 row = conn.execute( 149 row = conn.execute(
149 "SELECT wne.run_id, wne.node_id, wne.status, wne.subagent_type, wne.skill_ref, " 150 "SELECT wne.run_id, wne.node_id, wne.status, wne.subagent_type, wne.skill_ref, "
150 - "wgr.task_id, wgr.org_node, wgr.graph_manifest, wgr.status " 151 + "wgr.task_id, wgr.graph_manifest, wgr.status "
151 "FROM work_node_execution wne JOIN work_graph_run wgr ON wgr.run_id = wne.run_id " 152 "FROM work_node_execution wne JOIN work_graph_run wgr ON wgr.run_id = wne.run_id "
152 "WHERE wne.node_exec_id = ?", 153 "WHERE wne.node_exec_id = ?",
153 (source_node_exec_id,), 154 (source_node_exec_id,),
@@ -156,7 +157,7 @@ def create_fork(conn, source_node_exec_id, new_output_payload): @@ -156,7 +157,7 @@ def create_fork(conn, source_node_exec_id, new_output_payload):
156 raise ValueError("no such work_node_execution: " + source_node_exec_id) 157 raise ValueError("no such work_node_execution: " + source_node_exec_id)
157 ( 158 (
158 source_run_id, source_node_id, source_status, subagent_type, skill_ref, 159 source_run_id, source_node_id, source_status, subagent_type, skill_ref,
159 - task_id, org_node, graph_manifest_json, parent_run_status, 160 + task_id, graph_manifest_json, parent_run_status,
160 ) = row 161 ) = row
161 if source_status != "done": 162 if source_status != "done":
162 raise ForkSourceNotDoneError( 163 raise ForkSourceNotDoneError(
@@ -173,7 +174,7 @@ def create_fork(conn, source_node_exec_id, new_output_payload): @@ -173,7 +174,7 @@ def create_fork(conn, source_node_exec_id, new_output_payload):
173 ) 174 )
174 175
175 graph_manifest = json.loads(graph_manifest_json) 176 graph_manifest = json.loads(graph_manifest_json)
176 - build_prompt = _reconstruct_build_prompt(conn, task_id, org_node, graph_manifest) 177 + build_prompt = _reconstruct_build_prompt(conn, task_id, graph_manifest)
177 178
178 downstream = _downstream_node_ids(graph_manifest, source_node_id) - {source_node_id} 179 downstream = _downstream_node_ids(graph_manifest, source_node_id) - {source_node_id}
179 original_outputs = { 180 original_outputs = {
@@ -184,10 +185,13 @@ def create_fork(conn, source_node_exec_id, new_output_payload): @@ -184,10 +185,13 @@ def create_fork(conn, source_node_exec_id, new_output_payload):
184 } 185 }
185 186
186 new_run_id = str(uuid.uuid4()) 187 new_run_id = str(uuid.uuid4())
  188 + # org_node column removed (Org Graph unification Phase 2c §2.1,
  189 + # 2026-08-12) -- see work_graph_plan.py's own INSERT for the general
  190 + # rationale.
187 conn.execute( 191 conn.execute(
188 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status, started_at, "  
189 - "parent_run_id, fork_of_node_exec_id) VALUES (?, ?, ?, ?, 'running', CURRENT_TIMESTAMP, ?, ?)",  
190 - (new_run_id, task_id, org_node, graph_manifest_json, source_run_id, source_node_exec_id), 192 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status, started_at, "
  193 + "parent_run_id, fork_of_node_exec_id) VALUES (?, ?, ?, 'running', CURRENT_TIMESTAMP, ?, ?)",
  194 + (new_run_id, task_id, graph_manifest_json, source_run_id, source_node_exec_id),
191 ) 195 )
192 conn.commit() 196 conn.commit()
193 197
@@ -209,7 +213,12 @@ def create_fork(conn, source_node_exec_id, new_output_payload): @@ -209,7 +213,12 @@ def create_fork(conn, source_node_exec_id, new_output_payload):
209 conn.commit() 213 conn.commit()
210 214
211 return { 215 return {
212 - "run_id": new_run_id, "org_node": org_node, "skill_ref": skill_ref, 216 + # "org_node" key renamed to "subagent_type" (Phase 2c §2.1.2): the
  217 + # source node's own execution identity, not a run-level org_node --
  218 + # execute_fork() passes this straight through as run_graph()'s
  219 + # org_node argument (its default for nodes with no per-node
  220 + # subagent_type override, see work_graph_orchestrate.py's own §4.1).
  221 + "run_id": new_run_id, "subagent_type": subagent_type, "skill_ref": skill_ref,
213 "graph_manifest": graph_manifest, "build_prompt": build_prompt, 222 "graph_manifest": graph_manifest, "build_prompt": build_prompt,
214 } 223 }
215 224
@@ -228,7 +237,7 @@ def execute_fork(conn, fork_info, target_folder_path, timeout=600, sleep=time.sl @@ -228,7 +237,7 @@ def execute_fork(conn, fork_info, target_folder_path, timeout=600, sleep=time.sl
228 run_id = fork_info["run_id"] 237 run_id = fork_info["run_id"]
229 try: 238 try:
230 outputs = work_graph_orchestrate.run_graph( 239 outputs = work_graph_orchestrate.run_graph(
231 - conn, run_id, fork_info["org_node"], fork_info["skill_ref"], target_folder_path, 240 + conn, run_id, fork_info["subagent_type"], fork_info["skill_ref"], target_folder_path,
232 fork_info["graph_manifest"], fork_info["build_prompt"], _FORK_INPUT_SCHEMA, _FORK_OUTPUT_SCHEMA, 241 fork_info["graph_manifest"], fork_info["build_prompt"], _FORK_INPUT_SCHEMA, _FORK_OUTPUT_SCHEMA,
233 timeout=timeout, sleep=sleep, now=now, 242 timeout=timeout, sleep=sleep, now=now,
234 ) 243 )
src/work_graph_plan.py
@@ -566,10 +566,15 @@ def plan_work_graph(conn, org_node, task_id, goal_text, target_folder_path, time @@ -566,10 +566,15 @@ def plan_work_graph(conn, org_node, task_id, goal_text, target_folder_path, time
566 566
567 if existing_manifest is None: 567 if existing_manifest is None:
568 run_id = str(uuid.uuid4()) 568 run_id = str(uuid.uuid4())
  569 + # org_node column removed (Org Graph unification Phase 2c §2.1,
  570 + # 2026-08-12) -- this run's real per-node ownership lives in each
  571 + # node's own work_node_execution.subagent_type (set per node in
  572 + # _run_one_node()), not a single run-level value; a merged run
  573 + # genuinely spans several org_nodes by design (Coder/Validator
  574 + # append their own nodes into this SAME run via append_to_run_id).
569 conn.execute( 575 conn.execute(
570 - "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "  
571 - "VALUES (?, ?, ?, ?, 'planning')",  
572 - (run_id, task_id, org_node, json.dumps(new_manifest)), 576 + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) VALUES (?, ?, ?, 'planning')",
  577 + (run_id, task_id, json.dumps(new_manifest)),
573 ) 578 )
574 conn.commit() 579 conn.commit()
575 return {"run_id": run_id, "graph_manifest": new_manifest, "new_node_ids": []} 580 return {"run_id": run_id, "graph_manifest": new_manifest, "new_node_ids": []}
src/work_graph_run_query.py
@@ -38,6 +38,15 @@ class InvalidParamError(Exception): @@ -38,6 +38,15 @@ class InvalidParamError(Exception):
38 38
39 39
40 def list_work_graph_runs(conn, project_id, org_node=None, limit=20): 40 def list_work_graph_runs(conn, project_id, org_node=None, limit=20):
  41 + """`org_node` (2026-08-12, Org Graph unification Phase 2c §2.1.1):
  42 + work_graph_run.org_node no longer exists (a merged run genuinely spans
  43 + several org_nodes) -- this filter now means "runs with at least one
  44 + node whose subagent_type matches", via an EXISTS subquery against
  45 + work_node_execution instead of a plain column match. Each returned row
  46 + carries "org_nodes_involved" (a list, via GROUP_CONCAT DISTINCT over
  47 + the same table) instead of the old single-value "org_node" -- Phase 3's
  48 + Org-level meta-graph (FR-5a) needs exactly this "which stages does this
  49 + run span" signal anyway."""
41 if not project_id: 50 if not project_id:
42 raise InvalidParamError("project_id is required") 51 raise InvalidParamError("project_id is required")
43 if org_node is not None and org_node not in _VALID_ORG_NODES: 52 if org_node is not None and org_node not in _VALID_ORG_NODES:
@@ -52,14 +61,19 @@ def list_work_graph_runs(conn, project_id, org_node=None, limit=20): @@ -52,14 +61,19 @@ def list_work_graph_runs(conn, project_id, org_node=None, limit=20):
52 raise InvalidParamError("limit must be in [1, 100], got {!r}".format(limit)) 61 raise InvalidParamError("limit must be in [1, 100], got {!r}".format(limit))
53 62
54 query = ( 63 query = (
55 - "SELECT wgr.run_id, wgr.task_id, wgr.org_node, wgr.status, wgr.started_at, wgr.completed_at, " 64 + "SELECT wgr.run_id, wgr.task_id, wgr.status, wgr.started_at, wgr.completed_at, "
56 "(SELECT COUNT(*) FROM work_node_execution wne WHERE wne.run_id = wgr.run_id) AS node_count, " 65 "(SELECT COUNT(*) FROM work_node_execution wne WHERE wne.run_id = wgr.run_id) AS node_count, "
  66 + "(SELECT GROUP_CONCAT(DISTINCT wne.subagent_type) FROM work_node_execution wne "
  67 + " WHERE wne.run_id = wgr.run_id) AS org_nodes_involved, "
57 "wgr.parent_run_id, wgr.fork_of_node_exec_id " 68 "wgr.parent_run_id, wgr.fork_of_node_exec_id "
58 "FROM work_graph_run wgr JOIN task t ON t.task_id = wgr.task_id WHERE t.project_id = ?" 69 "FROM work_graph_run wgr JOIN task t ON t.task_id = wgr.task_id WHERE t.project_id = ?"
59 ) 70 )
60 params = [project_id] 71 params = [project_id]
61 if org_node: 72 if org_node:
62 - query += " AND wgr.org_node = ?" 73 + query += (
  74 + " AND EXISTS (SELECT 1 FROM work_node_execution wne "
  75 + "WHERE wne.run_id = wgr.run_id AND wne.subagent_type = ?)"
  76 + )
63 params.append(org_node) 77 params.append(org_node)
64 query += " ORDER BY wgr.started_at DESC LIMIT ?" 78 query += " ORDER BY wgr.started_at DESC LIMIT ?"
65 params.append(limit) 79 params.append(limit)
@@ -67,8 +81,9 @@ def list_work_graph_runs(conn, project_id, org_node=None, limit=20): @@ -67,8 +81,9 @@ def list_work_graph_runs(conn, project_id, org_node=None, limit=20):
67 rows = conn.execute(query, params).fetchall() 81 rows = conn.execute(query, params).fetchall()
68 return [ 82 return [
69 { 83 {
70 - "run_id": r[0], "task_id": r[1], "org_node": r[2], "status": r[3],  
71 - "started_at": r[4], "completed_at": r[5], "node_count": r[6], 84 + "run_id": r[0], "task_id": r[1], "status": r[2],
  85 + "started_at": r[3], "completed_at": r[4], "node_count": r[5],
  86 + "org_nodes_involved": r[6].split(",") if r[6] else [],
72 # D (2026-08-06): both None for a normal run; both set when this 87 # D (2026-08-06): both None for a normal run; both set when this
73 # run was forked from a historical node (src/work_graph_fork.py). 88 # run was forked from a historical node (src/work_graph_fork.py).
74 "parent_run_id": r[7], "fork_of_node_exec_id": r[8], 89 "parent_run_id": r[7], "fork_of_node_exec_id": r[8],
@@ -82,20 +97,27 @@ class WorkGraphRunNotFoundError(Exception): @@ -82,20 +97,27 @@ class WorkGraphRunNotFoundError(Exception):
82 97
83 98
84 def get_work_graph_run(conn, run_id): 99 def get_work_graph_run(conn, run_id):
  100 + """`org_nodes_involved` (2026-08-12, Phase 2c §2.1.1): same replacement
  101 + for the removed single-value `org_node` as list_work_graph_runs above
  102 + -- computed from this one run's own nodes (already fetched via
  103 + _nodes_for_run below) rather than a second query."""
85 row = conn.execute( 104 row = conn.execute(
86 - "SELECT run_id, task_id, org_node, graph_manifest, status, started_at, completed_at, " 105 + "SELECT run_id, task_id, graph_manifest, status, started_at, completed_at, "
87 "parent_run_id, fork_of_node_exec_id " 106 "parent_run_id, fork_of_node_exec_id "
88 "FROM work_graph_run WHERE run_id = ?", 107 "FROM work_graph_run WHERE run_id = ?",
89 (run_id,), 108 (run_id,),
90 ).fetchone() 109 ).fetchone()
91 if row is None: 110 if row is None:
92 raise WorkGraphRunNotFoundError(run_id) 111 raise WorkGraphRunNotFoundError(run_id)
  112 + nodes = _nodes_for_run(conn, row[0])
  113 + org_nodes_involved = sorted({n["subagent_type"] for n in nodes if n["subagent_type"]})
93 return { 114 return {
94 - "run_id": row[0], "task_id": row[1], "org_node": row[2],  
95 - "graph_manifest": json.loads(row[3]) if row[3] else None,  
96 - "status": row[4], "started_at": row[5], "completed_at": row[6],  
97 - "parent_run_id": row[7], "fork_of_node_exec_id": row[8],  
98 - "nodes": _nodes_for_run(conn, row[0]), 115 + "run_id": row[0], "task_id": row[1],
  116 + "graph_manifest": json.loads(row[2]) if row[2] else None,
  117 + "status": row[3], "started_at": row[4], "completed_at": row[5],
  118 + "parent_run_id": row[6], "fork_of_node_exec_id": row[7],
  119 + "org_nodes_involved": org_nodes_involved,
  120 + "nodes": nodes,
99 } 121 }
100 122
101 123