Commit 41a0492e632f49f04fea103612071e4d3d963018
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>
Showing
26 changed files
with
564 additions
and
178 deletions
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 = ["planner", "coder", "validator"]; | @@ -692,10 +692,23 @@ const ORG_NODE_ORDER = ["planner", "coder", "validator"]; | ||
| 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 |