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 3 **日期**: 2026-08-08
4 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 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 692 // where on the already-rendered page to scroll/highlight.
693 693  
694 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 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 712 // v_dashboard's org-strip: role + hint text, only ever links out to
700 713 // v_task_detail (a genuine page change, so plain hash assignment is fine).
701 714 function renderDashboardOrgStripHtml(activeOrgNode, runStatusByOrgNode) {
... ... @@ -894,7 +907,7 @@ function mountDashboard() {
894 907 const runStatusByOrgNode = {};
895 908 let waitingNode = null;
896 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 911 if (!waitingNode) waitingNode = run.nodes.find((n) => n.status === "waiting_for_quota");
899 912 }
900 913 renderOrgStrip(active.assigned_org_node, runStatusByOrgNode);
... ... @@ -1207,7 +1220,7 @@ function renderGraphVisualizerPanel(run, options) {
1207 1220 // id="graph-panel-<run_id>": Org Graph 点击可达详情's scroll/highlight
1208 1221 // target (focusOrgNodePanel above) -- additive, doesn't change any
1209 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 1224 + '<div class="work-graph" data-on-failure-edges="' + edgesDataJson + '">'
1212 1225 + '<svg class="graph-edges-svg"></svg>'
1213 1226 + rowsHtml + "</div>"
... ... @@ -1456,7 +1469,7 @@ function mountTaskDetail(taskId, initialOrgNode) {
1456 1469 return (
1457 1470 // id="todo-tree-<run_id>": secondary scroll/highlight target when the
1458 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 1473 + '<ul class="todo-tree">' + items + "</ul>"
1461 1474 + '<div class="see-more">DAG 视图(同一份 graph_manifest 的另一种呈现)见下方 v_graph_visualizer</div>'
1462 1475 + "</div>"
... ... @@ -1467,7 +1480,9 @@ function mountTaskDetail(taskId, initialOrgNode) {
1467 1480 closeAllLogViewers();
1468 1481 const activeSet = new Set([task.assigned_org_node]);
1469 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 1487 const orgStripHtml = renderTaskDetailOrgStripHtml(activeSet);
1473 1488  
... ... @@ -1848,7 +1863,7 @@ function mountRunDetail(runId) {
1848 1863 function renderDetail(run) {
1849 1864 closeAllLogViewers();
1850 1865 content.innerHTML = (
1851   - "<h1>" + escapeHtml(run.org_node) + " Work Graph Run</h1>"
  1866 + "<h1>" + escapeHtml(orgNodesLabel(run)) + " Work Graph Run</h1>"
1852 1867 + renderForkBadgeHtml(run)
1853 1868 + '<div class="meta-row">'
1854 1869 + "<div><b>RUN_ID</b>" + escapeHtml(run.run_id) + "</div>"
... ... @@ -1979,7 +1994,7 @@ async function mountArtifactsOverviewPanel(container, projectId) {
1979 1994  
1980 1995 const runsHtml = runs.length === 0 ? '<div class="hint">暂无 Graph 运行记录</div>' : (
1981 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 1998 + r.run_id.slice(0, 8) + (r.parent_run_id ? " 🔀" : "") + "</span><span>"
1984 1999 + escapeHtml(r.status) + " · " + r.node_count + " 节点</span></li>"
1985 2000 ).join("") + "</ul>"
... ... @@ -2108,7 +2123,9 @@ function mountGraphConsole(queryParams) {
2108 2123 const task = await resp.json();
2109 2124  
2110 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 2129 orgTopologyEl.innerHTML = renderOrgGraphTopology(task.assigned_org_node, runStatusByOrgNode, task.route);
2113 2130  
2114 2131 const panelsHtml = task.work_graph_runs
... ...
frontend/test_org_click_through_smoke.js
... ... @@ -96,9 +96,9 @@ function check(condition, message) {
96 96 {
97 97 const task = {
98 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 104 const latestPlanner = sandbox.getLatestRunForOrgNode(task, "planner");
... ... @@ -119,7 +119,7 @@ function check(condition, message) {
119 119  
120 120 const taskWithRun = {
121 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 124 artifacts: [
125 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 132 check(!populatedHtml.includes('data-artifact-id="art-2"'), "artifact from a different run_id must be filtered out");
133 133  
134 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 136 artifacts: [],
137 137 };
138 138 const noArtifactsHtml = sandbox.renderOrgComboPanelContent(taskNoArtifacts, "coder");
... ...
src/backend_server.py
... ... @@ -1041,6 +1041,7 @@ def _ensure_schema(conn):
1041 1041 schema.migrate_add_reviewing_planner_output_status(conn)
1042 1042 schema.migrate_add_planner_review_verdict_event_type(conn)
1043 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 1047 class _Server(ThreadingHTTPServer):
... ...
src/root_cause_trace.py
... ... @@ -219,10 +219,21 @@ def judge_root_cause_via_work_graph(
219 219 manifest = _build_jury_graph_manifest(list(perspective_prompts))
220 220  
221 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 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 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 162 def finish_task_lifecycle(conn, run_result, target_folder_path, goal_text):
163 163 """PASS -> `close_task_done`; FAIL/INCONCLUSIVE -> `org_level_loop.
164 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 181 task_id = run_result["task_id"]
167 182 verdict = run_result["verdict"]["verdict"]
168 183 if verdict == "PASS":
... ...
src/schema.py
... ... @@ -17,7 +17,9 @@ Interpretive conventions used below, where 01-M leaves the physical type implici
17 17 - task.assigned_org_node is documented as "not null, fk" but there is no standalone
18 18 org-node lookup table to reference (planner/coder/validator only exist as entries
19 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 23 - bug_record.root_cause_category gained 'core_engine' in U-034 (2026-08-02, PH5) --
22 24 05 §5.8: self-target scenarios (`.claude/agents/*.md` identity file changes, Graph
23 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 128 CREATE TABLE work_graph_run (
127 129 run_id TEXT PRIMARY KEY,
128 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 138 graph_manifest TEXT NOT NULL,
132 139 orchestration_script_ref TEXT,
133 140 status TEXT NOT NULL CHECK (status IN (
... ... @@ -141,16 +148,23 @@ CREATE TABLE work_graph_run (
141 148 -- Set together when this run was forked from a historical node instead
142 149 -- of planned fresh -- see src/work_graph_fork.py. v1 only forks from a
143 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 154 parent_run_id TEXT REFERENCES work_graph_run(run_id),
148 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 169 -- 1.4 work_node_execution
156 170 CREATE TABLE work_node_execution (
... ... @@ -802,3 +816,84 @@ def migrate_add_planner_review_verdict_event_type(conn):
802 816 """
803 817 )
804 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 155 ]
156 156  
157 157 runs = conn.execute(
158   - "SELECT run_id, org_node, graph_manifest, status, started_at, completed_at, "
  158 + "SELECT run_id, graph_manifest, status, started_at, completed_at, "
159 159 "parent_run_id, fork_of_node_exec_id "
160 160 "FROM work_graph_run WHERE task_id = ? ORDER BY started_at",
161 161 (task_id,),
162 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 170 # D (2026-08-06): both None for a normal run; both set when this
169 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 182 # 2026-08-08 (Work Graph self-planning + loop-back): the console's
173 183 # v_graph_visualizer needs this to actually draw a loop_back's
174 184 # on_failure edge / a retry_self node's "sent back Nx" badge --
... ... @@ -176,9 +186,7 @@ def get_task(conn, task_id):
176 186 # alone (loop_back_triggered is execution_event-only by design,
177 187 # see docs/done/work-graph-self-planning-and-loop-back-design.md §7).
178 188 "loop_back_events": _loop_back_events_for_run(conn, w[0]),
179   - }
180   - for w in runs
181   - ]
  189 + })
182 190 return task
183 191  
184 192  
... ...
src/test_artifact_query.py
... ... @@ -28,8 +28,8 @@ class ArtifactQueryTestBase(unittest.TestCase):
28 28 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'coding', 'coder')"
29 29 )
30 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 34 self.conn.commit()
35 35  
... ...
src/test_artifact_write.py
... ... @@ -35,8 +35,8 @@ class ArtifactWriteTestBase(unittest.TestCase):
35 35 "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'planning_brd_prd_sdd', 'planner')"
36 36 )
37 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 41 self.conn.commit()
42 42  
... ...
src/test_backend_server.py
... ... @@ -1135,8 +1135,8 @@ class ExecutionLogRouteTest(unittest.TestCase):
1135 1135 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'coding', 'coder')"
1136 1136 )
1137 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 1141 conn.execute(
1142 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 1291 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'coding', 'coder')"
1292 1292 )
1293 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 1297 with open(os.path.join(self.tmp.name, "sdd.md"), "w") as f:
1298 1298 f.write("# SDD content")
... ... @@ -1366,8 +1366,8 @@ class WorkGraphRunListRouteTest(unittest.TestCase):
1366 1366 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'coding', 'coder')"
1367 1367 )
1368 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 1372 conn.commit()
1373 1373  
... ... @@ -1403,6 +1403,11 @@ class WorkGraphRunListRouteTest(unittest.TestCase):
1403 1403 self.assertEqual(body["error"], "INVALID_PARAM")
1404 1404  
1405 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 1411 status, body = self._get("/api/work-graph-runs?project_id=proj-1&org_node=planner")
1407 1412 self.assertEqual(status, 200)
1408 1413 self.assertEqual(body, [])
... ... @@ -1412,7 +1417,10 @@ class WorkGraphRunListRouteTest(unittest.TestCase):
1412 1417 self.assertEqual(status, 200)
1413 1418 self.assertEqual(body["run_id"], "run-1")
1414 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 1424 self.assertEqual(body["nodes"], [])
1417 1425  
1418 1426 def test_get_nonexistent_run_returns_404(self):
... ... @@ -1444,8 +1452,8 @@ class WorkNodeForkRouteTest(unittest.TestCase):
1444 1452 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'validating', 'planner')"
1445 1453 )
1446 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 1458 conn.execute(
1451 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 218 "VALUES ('task-1', (SELECT project_id FROM project), 'goal', 'goal', 'coding', 'coder')"
219 219 )
220 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 224 self.conn.execute(
225 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 330  
331 331 # plan_work_graph's own real work_graph_run INSERT actually ran (not bypassed)
332 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 334 ).fetchone()
335   - self.assertEqual(run_row, ("planner", "planning"))
  335 + self.assertEqual(run_row, ("planning",))
336 336  
337 337 def test_earlier_nodes_survive_a_later_node_failing_verification(self):
338 338 # 2026-08-07 real incident (docs/todo/work-graph-partial-artifact-
... ... @@ -441,17 +441,14 @@ class RunEndToEndTest(PipelineTestBase):
441 441 # Phase 2c (2026-08-12): Validator now ALSO appends its self-planned
442 442 # nodes onto the SAME shared run (plan_work_graph(..., append_to_
443 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 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 449 ).fetchall()
452 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 453 # Coder's "impl" AND Validator's "verdict" nodes both genuinely
457 454 # landed in the SAME run as Planner's own nodes.
... ... @@ -558,8 +555,8 @@ class ReviewPlannerOutputWithRetriesTest(PipelineTestBase):
558 555 def test_pass_on_first_attempt_reuses_existing_artifacts_no_replan(self):
559 556 for artifact_type, content in (("sdd", "# SDD\nreal"), ("brd", "# BRD\nreal")):
560 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 560 (self.task_id,),
564 561 )
565 562 file_path = ".graphlemon/runs/{}/{}.md".format(self.task_id, artifact_type)
... ... @@ -1093,8 +1090,8 @@ class ValidateImplementationWithOwnGraphTest(unittest.TestCase):
1093 1090 # see test_task_execution.py for that), just needs to genuinely
1094 1091 # exist for append_to_run_id's own existence check.
1095 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 1096 self.conn.commit()
1100 1097  
... ...
src/test_execution_log.py
... ... @@ -29,8 +29,8 @@ class ExecutionLogTestBase(unittest.TestCase):
29 29 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'coding', 'coder')"
30 30 )
31 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 35 self.conn.execute(
36 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 28  
29 29 def _pass_artifact(self, artifact_type, run_id="run-1"):
30 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 33 (run_id, self.task_id),
34 34 )
35 35 self.conn.execute(
... ...
src/test_org_node_lifecycle_e2e.py
... ... @@ -49,8 +49,7 @@ class RealChainedTriggerTest(unittest.TestCase):
49 49 def _seed_passing_artifacts(conn, task_id, artifact_types):
50 50 run_id = str(uuid.uuid4())
51 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 53 (run_id, task_id),
55 54 )
56 55 for artifact_type in artifact_types:
... ...
src/test_schema.py
... ... @@ -91,11 +91,10 @@ class ConstraintTestBase(unittest.TestCase):
91 91 self.conn.commit()
92 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 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 99 self.conn.commit()
101 100 return run_id
... ... @@ -167,24 +166,41 @@ class ForeignKeyEnforcementTest(ConstraintTestBase):
167 166  
168 167  
169 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 176 def setUp(self):
174 177 super().setUp()
175 178 self.insert_project()
176 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 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 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 204 ).fetchone()[0]
189 205 self.assertEqual(count, 2)
190 206  
... ... @@ -247,8 +263,8 @@ class MigrateExpandArtifactTypesTest(unittest.TestCase):
247 263 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'queued', 'planner')"
248 264 )
249 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 269 self.conn.execute(
254 270 "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, "
... ... @@ -309,8 +325,8 @@ class MigrateExpandArtifactTypesTest(unittest.TestCase):
309 325 "VALUES ('t', 'p', 'g', 'goal', 'queued', 'planner')"
310 326 )
311 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 331 fresh.execute(
316 332 "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, "
... ... @@ -519,8 +535,8 @@ class MigrateAddWorkGraphForkColumnsTest(unittest.TestCase):
519 535 "VALUES ('task-1', 'proj-1', 'g', 'goal', 'queued', 'planner')"
520 536 )
521 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 541 self.conn.commit()
526 542  
... ... @@ -552,6 +568,28 @@ class MigrateAddWorkGraphForkColumnsTest(unittest.TestCase):
552 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 593 def _old_org_node_enum_schema_sql():
556 594 """H (2026-08-06): simulates a pre-migration database whose 4
557 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 597 too (migrate_relax_org_node_constraints depends on
560 598 migrate_add_work_graph_fork_columns having run, so the test fixture
561 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 616 sql = re.sub(
566 617 r"(?: --[^\n]*\n)* " + column + r" TEXT( NOT NULL)? CHECK \(" + column + r" != ''\)",
567 618 column + r" TEXT\1 CHECK (" + column + " IN ('planner', 'coder', 'validator'))",
... ... @@ -572,12 +623,6 @@ def _old_org_node_enum_schema_sql():
572 623 "org_node TEXT PRIMARY KEY CHECK (org_node IN ('planner', 'coder', 'validator'))",
573 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 626 return sql
582 627  
583 628  
... ... @@ -739,6 +784,138 @@ class MigrateRelaxOrgNodeConstraintsTest(unittest.TestCase):
739 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 919 class MigrateAddWorkGraphLoopLimitConfigTest(unittest.TestCase):
743 920 """2026-08-08: real upgrade path for an already-populated database that
744 921 predates work_graph_loop_limit_config (plain CREATE TABLE IF NOT
... ... @@ -880,8 +1057,8 @@ class MigrateAddReviewingPlannerOutputStatusTest(unittest.TestCase):
880 1057 # every test below previously ran against a task row with ZERO
881 1058 # referencing children, so FK enforcement was never truly exercised.
882 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 1063 self.conn.execute(
887 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 128 "edges": [],
129 129 })
130 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 134 # Phase 2c (2026-08-12, found while adding Validator self-planning
135 135 # tests): a real Planner run_graph() call ALSO leaves a real
... ... @@ -527,8 +527,8 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase):
527 527 "edges": [],
528 528 })
529 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 533 work_graph_orchestrate.ensure_placeholder_skill(conn, "planner-doc-writing", "planner")
534 534 conn.execute(
... ...
src/test_task_query.py
... ... @@ -94,8 +94,7 @@ class GetTaskTest(TaskQueryTestBase):
94 94 def test_includes_artifacts(self):
95 95 self._insert_task("task-1")
96 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 99 self.conn.execute(
101 100 "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, file_path, "
... ... @@ -121,8 +120,8 @@ class GetTaskTest(TaskQueryTestBase):
121 120 self._insert_task("task-1")
122 121 manifest = {"nodes": [{"node_id": "n1", "artifact_type": "brd", "depends_on": []}], "edges": []}
123 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 125 (json.dumps(manifest),),
127 126 )
128 127 self.conn.execute(
... ... @@ -152,8 +151,8 @@ class GetTaskTest(TaskQueryTestBase):
152 151 # from graph_manifest or work_node_execution alone.
153 152 self._insert_task("task-1")
154 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 157 self.conn.execute(
159 158 "INSERT INTO execution_event (event_id, project_id, task_id, run_id, event_type, payload) "
... ... @@ -177,8 +176,7 @@ class GetTaskTest(TaskQueryTestBase):
177 176 def test_no_loop_back_events_is_an_empty_list_not_missing_key(self):
178 177 self._insert_task("task-1")
179 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 181 self.conn.commit()
184 182 result = task_query.get_task(self.conn, "task-1")
... ... @@ -189,8 +187,8 @@ class GetTaskTest(TaskQueryTestBase):
189 187 # apart from the original run it descends from.
190 188 self._insert_task("task-1")
191 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 193 self.conn.execute(
196 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 200 "VALUES ('node-1', 'run-orig', 'n1', 'planner', 'skill-1', 'in.json', 'out.json', '{}', '{}', 'done')"
203 201 )
204 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 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 207 self.conn.commit()
210 208  
... ... @@ -219,8 +217,8 @@ class GetTaskTest(TaskQueryTestBase):
219 217 # quota_limit_type + how long it's been waiting (02-V §2.7)
220 218 self._insert_task("task-1")
221 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 223 self.conn.execute(
226 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 64 row plus one 'done' work_node_execution row per node, each holding
65 65 contents[node_id] as its output_payload."""
66 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 70 node_exec_ids = {}
72 71 for node in graph_manifest["nodes"]:
... ... @@ -254,8 +253,8 @@ class MultipleForksFromSameNodeTest(ForkTestBase):
254 253 class ForkValidationErrorsTest(ForkTestBase):
255 254 def test_forking_a_non_done_node_raises(self):
256 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 258 (json.dumps(_BRD_PRD_SDD_BENCHMARK_MANIFEST),),
260 259 )
261 260 self.conn.execute(
... ... @@ -321,8 +320,8 @@ class StartForkInBackgroundTest(unittest.TestCase):
321 320 "VALUES ('planner-doc-writing', 'planner', 'skills/planner-doc-writing/', 'unknown', 'new', 'active')"
322 321 )
323 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 325 (json.dumps(_BRD_PRD_SDD_BENCHMARK_MANIFEST),),
327 326 )
328 327 contents = {
... ...
src/test_work_graph_orchestrate.py
... ... @@ -234,8 +234,8 @@ class RunGraphTestBase(unittest.TestCase):
234 234 "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'coding', 'planner')"
235 235 )
236 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 240 self.conn.commit()
241 241  
... ... @@ -829,8 +829,8 @@ class RunGraphRealSqliteConcurrencyTest(unittest.TestCase):
829 829 "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'coding', 'planner')"
830 830 )
831 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 835 conn.commit()
836 836  
... ...
src/test_work_graph_plan.py
... ... @@ -308,13 +308,12 @@ class HappyPathTest(PlanWorkGraphTestBase):
308 308 self.conn, "planner", "task-1", "实现贪吃蛇游戏", "/tmp/proj-1"
309 309 )
310 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 312 (result["run_id"],),
313 313 ).fetchone()
314 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 318 def test_invokes_claude_with_expected_args(self):
320 319 with patch("src.work_graph_plan.subprocess.run") as run:
... ... @@ -448,8 +447,7 @@ class AppendToRunIdTest(PlanWorkGraphTestBase):
448 447  
449 448 def _seed_run(self, run_id, manifest):
450 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 451 (run_id, json.dumps(manifest)),
454 452 )
455 453 self.conn.commit()
... ...
src/test_work_graph_run_query.py
... ... @@ -29,24 +29,31 @@ class WorkGraphRunQueryTestBase(unittest.TestCase):
29 29 def _insert_run(self, run_id, org_node, status="done", started_at="2026-08-01T00:00:00",
30 30 completed_at=None, task_id="task-1", node_count=0,
31 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 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 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 45 for i in range(node_count):
39 46 node_exec_id = "{}-node-{}".format(run_id, i)
40 47 self.conn.execute(
41 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 52 self.conn.execute(
46 53 "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
47 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 58 self.conn.commit()
52 59  
... ... @@ -87,12 +94,30 @@ class ListWorkGraphRunsTest(WorkGraphRunQueryTestBase):
87 94 self.assertEqual([r["run_id"] for r in result], ["run-1"])
88 95  
89 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 100 result = work_graph_run_query.list_work_graph_runs(self.conn, "proj-1", org_node="coder")
94 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 121 def test_limit_one_returns_latest(self):
97 122 self._insert_run("run-1", "planner", started_at="2026-08-01T00:00:00")
98 123 self._insert_run("run-2", "coder", started_at="2026-08-02T00:00:00")
... ... @@ -128,7 +153,7 @@ class GetWorkGraphRunTest(WorkGraphRunQueryTestBase):
128 153 result = work_graph_run_query.get_work_graph_run(self.conn, "run-1")
129 154 self.assertEqual(result["run_id"], "run-1")
130 155 self.assertEqual(result["task_id"], "task-1")
131   - self.assertEqual(result["org_node"], "coder")
  156 + self.assertEqual(result["org_nodes_involved"], ["coder"])
132 157 self.assertEqual(result["status"], "running")
133 158 self.assertEqual(result["graph_manifest"], {})
134 159 self.assertEqual(len(result["nodes"]), 2)
... ...
src/test_work_node_run.py
... ... @@ -49,8 +49,8 @@ class WorkNodeRunTestBase(unittest.TestCase):
49 49 "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'coding', 'coder')"
50 50 )
51 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 55 self.conn.execute(
56 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 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 95 """Rebuilds a build_prompt(node, outputs) usable for the re-executed
96 96 downstream subgraph, purely from persisted state -- no access to the
97 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 110 (work_graph_plan.dispatch_build_prompt) instead of once for the whole
111 111 graph -- every pre-Phase-2 graph_manifest still has just one shape
112 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 119 nodes = graph_manifest["nodes"]
119 120 if not nodes:
120 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 148 uses for the manual Start button."""
148 149 row = conn.execute(
149 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 152 "FROM work_node_execution wne JOIN work_graph_run wgr ON wgr.run_id = wne.run_id "
152 153 "WHERE wne.node_exec_id = ?",
153 154 (source_node_exec_id,),
... ... @@ -156,7 +157,7 @@ def create_fork(conn, source_node_exec_id, new_output_payload):
156 157 raise ValueError("no such work_node_execution: " + source_node_exec_id)
157 158 (
158 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 161 ) = row
161 162 if source_status != "done":
162 163 raise ForkSourceNotDoneError(
... ... @@ -173,7 +174,7 @@ def create_fork(conn, source_node_exec_id, new_output_payload):
173 174 )
174 175  
175 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 179 downstream = _downstream_node_ids(graph_manifest, source_node_id) - {source_node_id}
179 180 original_outputs = {
... ... @@ -184,10 +185,13 @@ def create_fork(conn, source_node_exec_id, new_output_payload):
184 185 }
185 186  
186 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 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 196 conn.commit()
193 197  
... ... @@ -209,7 +213,12 @@ def create_fork(conn, source_node_exec_id, new_output_payload):
209 213 conn.commit()
210 214  
211 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 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 237 run_id = fork_info["run_id"]
229 238 try:
230 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 241 fork_info["graph_manifest"], fork_info["build_prompt"], _FORK_INPUT_SCHEMA, _FORK_OUTPUT_SCHEMA,
233 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 566  
567 567 if existing_manifest is None:
568 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 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 579 conn.commit()
575 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 38  
39 39  
40 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 50 if not project_id:
42 51 raise InvalidParamError("project_id is required")
43 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 61 raise InvalidParamError("limit must be in [1, 100], got {!r}".format(limit))
53 62  
54 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 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 68 "wgr.parent_run_id, wgr.fork_of_node_exec_id "
58 69 "FROM work_graph_run wgr JOIN task t ON t.task_id = wgr.task_id WHERE t.project_id = ?"
59 70 )
60 71 params = [project_id]
61 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 77 params.append(org_node)
64 78 query += " ORDER BY wgr.started_at DESC LIMIT ?"
65 79 params.append(limit)
... ... @@ -67,8 +81,9 @@ def list_work_graph_runs(conn, project_id, org_node=None, limit=20):
67 81 rows = conn.execute(query, params).fetchall()
68 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 87 # D (2026-08-06): both None for a normal run; both set when this
73 88 # run was forked from a historical node (src/work_graph_fork.py).
74 89 "parent_run_id": r[7], "fork_of_node_exec_id": r[8],
... ... @@ -82,20 +97,27 @@ class WorkGraphRunNotFoundError(Exception):
82 97  
83 98  
84 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 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 106 "parent_run_id, fork_of_node_exec_id "
88 107 "FROM work_graph_run WHERE run_id = ?",
89 108 (run_id,),
90 109 ).fetchone()
91 110 if row is None:
92 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 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  
... ...