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