Commit b1e0a879c2209dad12ad237c0dc133113ba69158

Authored by bboonnee
1 parent f7103fd7

Org Graph unification Phase 3: frontend Org meta-graph + loop_back replay timeline

FR-5a: a new, standalone four-stage Org meta-graph (renderOrgMetaGraph and
friends) rather than extending the existing 3-node renderOrgGraphTopology
(left untouched, it's pinned by test_graph_console_smoke.js). Each stage
expands in place: planner/coder/validator reuse renderGraphVisualizerPanel
unmodified against that stage's latest run (identified via org_nodes_involved,
Phase 2c's replacement for the removed single-value org_node column);
planner_reviewer has no self-planned work_graph_run, so it shows its
planner_review_verdict execution_event history instead (new
task_query.py::_planner_review_events_for_task(), mirroring the existing
_loop_back_events_for_run() pattern).

FR-5b: an attempt_number-ordered, click-to-reveal feedback timeline built
from loop_back_events, which was already being fetched but never rendered.

New frontend/test_org_meta_graph_smoke.js (35 assertions, same vm.Context
sandbox convention as the existing 4 smoke tests).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/todo/org-graph-unification-and-universal-self-planning-requirements.md renamed to docs/done/org-graph-unification-and-universal-self-planning-requirements.md
docs/todo/org-graph-unification-phase2-universal-self-planning-design.md renamed to docs/done/org-graph-unification-phase2-universal-self-planning-design.md
docs/todo/scheduler-new-goal-dispatch-requirements.md renamed to docs/done/scheduler-new-goal-dispatch-requirements.md
docs/todo/work-graph-node-execution-deblackbox-requirements.md renamed to docs/done/work-graph-node-execution-deblackbox-requirements.md
frontend/app.js
... ... @@ -1957,6 +1957,141 @@ function renderOrgGraphTopology(activeOrgNode, runStatusByOrgNode, route) {
1957 1957 return '<div class="graph-topo-row">' + nodesHtml + "</div>" + retryEdgeHtml;
1958 1958 }
1959 1959  
  1960 +// ---- Org 元图 (FR-5a, Org Graph unification Phase 3, 2026-08-13): a
  1961 +// standalone four-stage top-level meta-graph (Planner -> 复核 Planner
  1962 +// Reviewer -> Coder -> 终审 Validator), separate from renderOrgGraphTopology
  1963 +// above -- that function is still called by mountGraphConsole's own
  1964 +// existing "Org Graph" panel and is pinned by test_graph_console_smoke.js's
  1965 +// 3-node ORG_NODE_ORDER shape, so it's left untouched rather than stretched
  1966 +// to 4 stages. Each stage expands in place into that stage's real Work
  1967 +// Graph run, reusing renderGraphVisualizerPanel unmodified -- except
  1968 +// planner_reviewer, which never self-plans a Work Graph
  1969 +// (org_node_trigger.trigger_org_node() is one flat call, see
  1970 +// e2e_pipeline.py::_record_planner_review_event) and instead shows its own
  1971 +// verdict trail (task.planner_review_events, task_query.py::
  1972 +// _planner_review_events_for_task).
  1973 +const ORG_META_STAGE_ORDER = ["planner", "planner_reviewer", "coder", "validator"];
  1974 +const ORG_META_STAGE_LABELS = {
  1975 + planner: "Planner", planner_reviewer: "复核(Planner Reviewer)",
  1976 + coder: "Coder", validator: "终审(Validator)",
  1977 +};
  1978 +
  1979 +function orgMetaStageStatus(task, stage) {
  1980 + if (stage === "planner_reviewer") {
  1981 + const events = task.planner_review_events || [];
  1982 + if (events.length === 0) return "not_started";
  1983 + return events[events.length - 1].verdict === "PASS" ? "done" : "reflecting";
  1984 + }
  1985 + const run = getLatestRunForOrgNode(task, stage);
  1986 + return run ? run.status : "not_started";
  1987 +}
  1988 +
  1989 +function renderOrgMetaGraph(task) {
  1990 + const nodesHtml = ORG_META_STAGE_ORDER.map((stage, i) => {
  1991 + const status = orgMetaStageStatus(task, stage);
  1992 + const arrow = i < ORG_META_STAGE_ORDER.length - 1 ? '<div class="graph-edge">→</div>' : "";
  1993 + return (
  1994 + '<div class="graph-topo-node status-' + status + '" data-meta-stage="' + stage + '" style="cursor:pointer;">'
  1995 + + '<div class="role">' + escapeHtml(ORG_META_STAGE_LABELS[stage]) + "</div>"
  1996 + + '<div class="hint">' + escapeHtml(status) + "</div>"
  1997 + + "</div>" + arrow
  1998 + );
  1999 + }).join("");
  2000 + const detailsHtml = ORG_META_STAGE_ORDER.map((stage) =>
  2001 + '<div class="org-meta-stage-detail" id="org-meta-detail-' + stage + '" data-meta-stage="' + stage + '" style="display:none;"></div>'
  2002 + ).join("");
  2003 + return '<div class="graph-topo-row">' + nodesHtml + "</div>" + detailsHtml;
  2004 +}
  2005 +
  2006 +function renderPlannerReviewEventsList(events) {
  2007 + if (!events || events.length === 0) return '<div class="hint">尚无复核记录</div>';
  2008 + return '<ul class="simple-list">' + events.map((e) =>
  2009 + "<li><span>attempt " + e.attempt + " · " + escapeHtml(e.verdict)
  2010 + + (e.used_existing_artifacts ? "(复用已有产出)" : "") + "</span><span>"
  2011 + + escapeHtml(e.created_at || "") + "</span></li>"
  2012 + ).join("") + "</ul>";
  2013 +}
  2014 +
  2015 +// planner/coder/validator branch mirrors renderOrgComboPanelContent's own
  2016 +// empty-run/empty-DAG fallback text -- same two states, this is just the
  2017 +// meta-graph's own expand target rather than v_task_detail's combo panel.
  2018 +function renderOrgMetaStageDetailHtml(task, stage) {
  2019 + if (stage === "planner_reviewer") {
  2020 + return '<div class="panel"><h2>Planner 产出复核记录</h2>' + renderPlannerReviewEventsList(task.planner_review_events) + "</div>";
  2021 + }
  2022 + const run = getLatestRunForOrgNode(task, stage);
  2023 + if (!run) return '<div class="hint">' + escapeHtml(stage) + " 尚未开始(暂无 Work Graph run)</div>";
  2024 + const panelHtml = renderGraphVisualizerPanel(run, { enableNodeClick: false });
  2025 + return panelHtml
  2026 + ? panelHtml + renderLoopBackTimelineHtml(run)
  2027 + : '<div class="hint">最新一次 run_id=' + escapeHtml(run.run_id) + "(status=" + escapeHtml(run.status) + ")· 该 run 尚未生成 Work Graph</div>";
  2028 +}
  2029 +
  2030 +function mountOrgMetaGraph(container, task) {
  2031 + for (const el of container.querySelectorAll(".graph-topo-node[data-meta-stage]")) {
  2032 + el.addEventListener("click", () => {
  2033 + const stage = el.dataset.metaStage;
  2034 + const detail = container.querySelector("#org-meta-detail-" + stage);
  2035 + if (!detail) return;
  2036 + if (detail.style.display !== "none") {
  2037 + detail.style.display = "none";
  2038 + detail.innerHTML = "";
  2039 + return;
  2040 + }
  2041 + detail.innerHTML = renderOrgMetaStageDetailHtml(task, stage);
  2042 + detail.style.display = "block";
  2043 + mountGraphVisualizerTooltips(detail);
  2044 + mountGraphVisualizerEdges(detail);
  2045 + mountLoopBackTimeline(detail);
  2046 + });
  2047 + }
  2048 +}
  2049 +
  2050 +// ---- Loop-back 历史回放时间轴 (FR-5b, Org Graph unification Phase 3,
  2051 +// 2026-08-13): task_query.py's _loop_back_events_for_run() already exposes
  2052 +// run.loop_back_events (source_node_id/target_node_id/attempt_number/
  2053 +// feedback[]/created_at) -- renderGraphVisualizerPanel only ever reduces
  2054 +// them to a self-loop badge count / on_failure edge trigger count, never
  2055 +// the feedback text itself. This is a separate, additive component (not a
  2056 +// rewrite of the DAG renderer) rendered alongside it at each of its call
  2057 +// sites, ordered by attempt_number, one entry per real loop_back.
  2058 +function renderLoopBackTimelineHtml(run) {
  2059 + const events = run.loop_back_events || [];
  2060 + if (events.length === 0) return "";
  2061 + const itemsHtml = events.map((e, i) =>
  2062 + '<li class="timeline-item" data-attempt-index="' + i + '" style="cursor:pointer;">'
  2063 + + "<span>attempt " + e.attempt_number + " · " + escapeHtml(e.source_node_id) + " → " + escapeHtml(e.target_node_id) + "</span>"
  2064 + + "<span>" + escapeHtml(e.created_at || "") + "</span>"
  2065 + + "</li>"
  2066 + ).join("");
  2067 + const feedbackPanelsHtml = events.map((e, i) =>
  2068 + '<div class="timeline-feedback" data-attempt-index="' + i + '" style="display:none;">'
  2069 + + ((e.feedback || []).length
  2070 + ? "<ul>" + e.feedback.map((f) => "<li>" + escapeHtml(f) + "</li>").join("") + "</ul>"
  2071 + : '<div class="hint">该次 loop_back 未记录 feedback</div>')
  2072 + + "</div>"
  2073 + ).join("");
  2074 + return (
  2075 + '<div class="panel loop-back-timeline"><h2>Loop-back 历史回放(按 attempt_number)</h2>'
  2076 + + '<ul class="simple-list timeline-list">' + itemsHtml + "</ul>"
  2077 + + feedbackPanelsHtml
  2078 + + "</div>"
  2079 + );
  2080 +}
  2081 +
  2082 +function mountLoopBackTimeline(container) {
  2083 + for (const timeline of container.querySelectorAll(".loop-back-timeline")) {
  2084 + for (const item of timeline.querySelectorAll(".timeline-item[data-attempt-index]")) {
  2085 + item.addEventListener("click", () => {
  2086 + const idx = item.dataset.attemptIndex;
  2087 + for (const panel of timeline.querySelectorAll(".timeline-feedback")) {
  2088 + panel.style.display = panel.dataset.attemptIndex === idx ? "block" : "none";
  2089 + }
  2090 + });
  2091 + }
  2092 + }
  2093 +}
  2094 +
1960 2095 const GC_DOC_ARTIFACT_TYPES = ["brd", "prd", "sdd", "benchmark"];
1961 2096 const GC_CODE_ARTIFACT_TYPES = ["code_diff", "test_report"];
1962 2097  
... ... @@ -2049,6 +2184,10 @@ function renderGraphConsole() {
2049 2184 <h2>Org Graph</h2>
2050 2185 <div id="gc_org_topology"><div class="hint">加载中…</div></div>
2051 2186 </div>
  2187 + <div class="panel">
  2188 + <h2>Org 元图(点击每段展开真实 Work Graph 详情)</h2>
  2189 + <div id="gc_org_meta_graph"><div class="hint">加载中…</div></div>
  2190 + </div>
2052 2191 <div id="gc_work_graph_panels"><div class="hint">加载中…</div></div>
2053 2192 <div class="tooltip" id="graph-tooltip"></div>
2054 2193 <div id="gc_artifacts_overview"><div class="hint">加载中…</div></div>
... ... @@ -2062,6 +2201,7 @@ function mountGraphConsole(queryParams) {
2062 2201  
2063 2202 const taskSelect = document.getElementById("gc_task_select");
2064 2203 const orgTopologyEl = document.getElementById("gc_org_topology");
  2204 + const orgMetaGraphEl = document.getElementById("gc_org_meta_graph");
2065 2205 const workGraphPanelsEl = document.getElementById("gc_work_graph_panels");
2066 2206 const artifactsOverviewEl = document.getElementById("gc_artifacts_overview");
2067 2207  
... ... @@ -2127,9 +2267,11 @@ function mountGraphConsole(queryParams) {
2127 2267 for (const orgNode of run.org_nodes_involved || []) runStatusByOrgNode[orgNode] = run.status;
2128 2268 }
2129 2269 orgTopologyEl.innerHTML = renderOrgGraphTopology(task.assigned_org_node, runStatusByOrgNode, task.route);
  2270 + orgMetaGraphEl.innerHTML = renderOrgMetaGraph(task);
  2271 + mountOrgMetaGraph(orgMetaGraphEl, task);
2130 2272  
2131 2273 const panelsHtml = task.work_graph_runs
2132   - .map((run) => renderGraphVisualizerPanel(run, { enableNodeClick: true }))
  2274 + .map((run) => renderGraphVisualizerPanel(run, { enableNodeClick: true }) + renderLoopBackTimelineHtml(run))
2133 2275 .join("");
2134 2276 if (!panelsHtml) {
2135 2277 workGraphPanelsEl.innerHTML = '<div class="panel"><div class="hint">尚未开始规划</div></div>';
... ... @@ -2139,6 +2281,7 @@ function mountGraphConsole(queryParams) {
2139 2281 mountGraphVisualizerTooltips(workGraphPanelsEl);
2140 2282 mountGraphVisualizerEdges(workGraphPanelsEl);
2141 2283 mountWorkNodeClickExpand(workGraphPanelsEl, { onNodeClick: onWorkNodeClick });
  2284 + mountLoopBackTimeline(workGraphPanelsEl);
2142 2285 }
2143 2286  
2144 2287 async function populateTaskSelect() {
... ... @@ -2146,6 +2289,7 @@ function mountGraphConsole(queryParams) {
2146 2289 if (tasks.length === 0) {
2147 2290 taskSelect.innerHTML = '<option value="">暂无任务</option>';
2148 2291 orgTopologyEl.innerHTML = '<div class="hint">暂无任务 · <a href="#/goals/new">提出第一个目标 →</a></div>';
  2292 + orgMetaGraphEl.innerHTML = "";
2149 2293 workGraphPanelsEl.innerHTML = "";
2150 2294 return;
2151 2295 }
... ...
frontend/test_org_meta_graph_smoke.js 0 → 100644
  1 +// Org Graph unification Phase 3 (FR-5a + FR-5b) smoke test. Same no-
  2 +// framework/no-fixture convention as the other frontend/test_*_smoke.js
  3 +// files -- loads app.js in a bare vm.Context and asserts on generated HTML
  4 +// strings for the new pieces:
  5 +// - FR-5a: ORG_META_STAGE_ORDER / orgMetaStageStatus / renderOrgMetaGraph
  6 +// (the four-stage top-level meta-graph, Planner -> 复核 -> Coder ->
  7 +// 终审, separate from the existing 3-node renderOrgGraphTopology) and
  8 +// renderOrgMetaStageDetailHtml's planner_reviewer special-case (a
  9 +// verdict list, not a faked Work Graph panel).
  10 +// - FR-5b: renderLoopBackTimelineHtml (the attempt_number-ordered
  11 +// loop_back replay list, reusing run.loop_back_events which
  12 +// task_query.py already exposed but nothing rendered before this).
  13 +// Mount-time DOM wiring (mountOrgMetaGraph/mountLoopBackTimeline's click
  14 +// handlers) needs real querySelectorAll traversal this bare sandbox doesn't
  15 +// provide -- same "manual browser verification only" boundary the other
  16 +// smoke tests already draw around mount*/edge-drawing functions; this file
  17 +// only pins the pure render* HTML-string output.
  18 +//
  19 +// Run: node frontend/test_org_meta_graph_smoke.js
  20 +
  21 +"use strict";
  22 +const fs = require("fs");
  23 +const path = require("path");
  24 +const vm = require("vm");
  25 +const assert = require("assert");
  26 +
  27 +function makeFakeDiv() {
  28 + let text = "";
  29 + return {
  30 + set textContent(v) { text = v == null ? "" : String(v); },
  31 + get textContent() { return text; },
  32 + get innerHTML() {
  33 + return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
  34 + },
  35 + };
  36 +}
  37 +
  38 +const sandbox = {
  39 + document: {
  40 + createElement: () => makeFakeDiv(),
  41 + getElementById: () => null,
  42 + querySelectorAll: () => [],
  43 + addEventListener: () => {},
  44 + },
  45 + window: {
  46 + location: { protocol: "http:", host: "localhost", hash: "" },
  47 + addEventListener: () => {},
  48 + },
  49 + localStorage: { getItem: () => null, setItem: () => {} },
  50 + console,
  51 +};
  52 +vm.createContext(sandbox);
  53 +
  54 +const src = fs.readFileSync(path.join(__dirname, "app.js"), "utf8");
  55 +// Top-level `const` stays lexically scoped to the script and never reaches
  56 +// the sandbox object (same Annex B quirk the other smoke tests already
  57 +// document for e.g. ARTIFACT_DETAIL_RE) -- re-export what this test needs.
  58 +const augmentedSrc = src + "\nglobalThis.ORG_META_STAGE_ORDER = ORG_META_STAGE_ORDER;\n";
  59 +new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox);
  60 +
  61 +let assertionCount = 0;
  62 +function check(condition, message) {
  63 + assertionCount++;
  64 + assert.ok(condition, message);
  65 +}
  66 +
  67 +// ---- FR-5a: ORG_META_STAGE_ORDER has all four stages, in order ----
  68 +{
  69 + check(
  70 + sandbox.ORG_META_STAGE_ORDER.join(",") === "planner,planner_reviewer,coder,validator",
  71 + "the meta-graph must cover all four stages in Planner -> 复核 -> Coder -> 终审 order"
  72 + );
  73 +}
  74 +
  75 +// ---- FR-5a: orgMetaStageStatus ----
  76 +{
  77 + const emptyTask = { work_graph_runs: [], planner_review_events: [] };
  78 + check(sandbox.orgMetaStageStatus(emptyTask, "planner") === "not_started", "no run yet -> not_started");
  79 + check(sandbox.orgMetaStageStatus(emptyTask, "planner_reviewer") === "not_started", "no review events yet -> not_started");
  80 +
  81 + const taskWithRuns = {
  82 + work_graph_runs: [
  83 + { run_id: "run-1", org_nodes_involved: ["coder"], status: "running", started_at: "2026-08-01 00:00:00" },
  84 + ],
  85 + planner_review_events: [],
  86 + };
  87 + check(sandbox.orgMetaStageStatus(taskWithRuns, "coder") === "running", "status mirrors the latest run's own status");
  88 +
  89 + const passedReview = { work_graph_runs: [], planner_review_events: [
  90 + { attempt: 1, verdict: "REJECTED", created_at: "t1" },
  91 + { attempt: 2, verdict: "PASS", created_at: "t2" },
  92 + ] };
  93 + check(sandbox.orgMetaStageStatus(passedReview, "planner_reviewer") === "done", "latest verdict PASS -> done");
  94 +
  95 + const rejectedReview = { work_graph_runs: [], planner_review_events: [
  96 + { attempt: 1, verdict: "REJECTED", created_at: "t1" },
  97 + ] };
  98 + check(sandbox.orgMetaStageStatus(rejectedReview, "planner_reviewer") === "reflecting", "latest verdict not PASS -> reflecting, not done");
  99 +}
  100 +
  101 +// ---- FR-5a: renderOrgMetaGraph -- four boxes, three arrows, real statuses ----
  102 +{
  103 + const task = {
  104 + work_graph_runs: [
  105 + { run_id: "run-1", org_nodes_involved: ["planner"], status: "done", started_at: "t1" },
  106 + { run_id: "run-2", org_nodes_involved: ["coder"], status: "running", started_at: "t2" },
  107 + ],
  108 + planner_review_events: [{ attempt: 1, verdict: "PASS", created_at: "t1.5" }],
  109 + };
  110 + const html = sandbox.renderOrgMetaGraph(task);
  111 + for (const stage of ["planner", "planner_reviewer", "coder", "validator"]) {
  112 + check(html.includes('data-meta-stage="' + stage + '"'), "every stage box must carry data-meta-stage=" + stage);
  113 + }
  114 + check(html.includes("graph-topo-node status-done"), "planner shows its real run status (done)");
  115 + check(html.includes("graph-topo-node status-running"), "coder shows its real run status (running)");
  116 + check(html.includes("graph-topo-node status-not_started"), "validator has no run yet -> not_started");
  117 + check((html.match(/graph-edge/g) || []).length === 3, "four stages are joined by exactly three arrows");
  118 + check(html.includes('id="org-meta-detail-planner_reviewer"'), "every stage gets its own (initially hidden) expand target");
  119 + check(html.includes('style="display:none;"'), "expand targets start collapsed");
  120 +}
  121 +
  122 +// ---- FR-5a: planner_reviewer special-case detail (verdict list, not a
  123 +// faked Work Graph panel) ----
  124 +{
  125 + const emptyEventsHtml = sandbox.renderOrgMetaStageDetailHtml({ planner_review_events: [] }, "planner_reviewer");
  126 + check(emptyEventsHtml.includes("尚无复核记录"), "zero review events -> explicit empty state");
  127 + check(!emptyEventsHtml.includes("work-graph"), "planner_reviewer must never render a fake DAG panel");
  128 +
  129 + const task = {
  130 + planner_review_events: [
  131 + { attempt: 1, verdict: "REJECTED", used_existing_artifacts: false, created_at: "2026-08-12 10:00:00" },
  132 + { attempt: 2, verdict: "PASS", used_existing_artifacts: true, created_at: "2026-08-12 10:05:00" },
  133 + ],
  134 + };
  135 + const html = sandbox.renderOrgMetaStageDetailHtml(task, "planner_reviewer");
  136 + check(html.includes("attempt 1"), "each attempt is listed");
  137 + check(html.includes("REJECTED") && html.includes("PASS"), "each attempt's real verdict is shown");
  138 + check(html.includes("2026-08-12 10:00:00") && html.includes("2026-08-12 10:05:00"), "each attempt's real timestamp is shown");
  139 + check(html.includes("复用已有产出"), "used_existing_artifacts=true is surfaced, not silently dropped");
  140 +}
  141 +
  142 +// ---- FR-5a: planner/coder/validator detail reuses renderGraphVisualizerPanel
  143 +// unmodified, with the existing empty-run / empty-DAG fallbacks ----
  144 +{
  145 + const noRunTask = { work_graph_runs: [] };
  146 + const noRunHtml = sandbox.renderOrgMetaStageDetailHtml(noRunTask, "coder");
  147 + check(noRunHtml.includes("尚未开始"), "no run yet for this stage -> explicit empty state");
  148 +
  149 + const emptyDagTask = {
  150 + work_graph_runs: [{ run_id: "run-1", org_nodes_involved: ["coder"], status: "failed", graph_manifest: null }],
  151 + };
  152 + const emptyDagHtml = sandbox.renderOrgMetaStageDetailHtml(emptyDagTask, "coder");
  153 + check(emptyDagHtml.includes("run-1") && emptyDagHtml.includes("尚未生成 Work Graph"), "run exists but has no DAG yet -> explicit fallback naming the run");
  154 +
  155 + const realRunTask = {
  156 + work_graph_runs: [{
  157 + run_id: "run-2", org_nodes_involved: ["coder"], status: "running",
  158 + graph_manifest: { nodes: [{ node_id: "n1", depends_on: [] }] },
  159 + nodes: [{ node_id: "n1", node_exec_id: "exec-1", subagent_type: "coder", status: "running" }],
  160 + }],
  161 + };
  162 + const realHtml = sandbox.renderOrgMetaStageDetailHtml(realRunTask, "coder");
  163 + check(realHtml.includes("run_id=run-2"), "a real run renders the actual renderGraphVisualizerPanel DAG output");
  164 + check(!realHtml.includes("clickable"), "meta-graph expand opts out of click-to-expand-logs (enableNodeClick: false)");
  165 +}
  166 +
  167 +// ---- FR-5b: renderLoopBackTimelineHtml ----
  168 +{
  169 + const noEventsHtml = sandbox.renderLoopBackTimelineHtml({ loop_back_events: [] });
  170 + check(noEventsHtml === "", "a run with no loop_back_events renders nothing, not an empty panel");
  171 + check(sandbox.renderLoopBackTimelineHtml({}) === "", "missing loop_back_events entirely is treated the same as empty");
  172 +
  173 + const run = {
  174 + run_id: "run-1",
  175 + loop_back_events: [
  176 + { source_node_id: "benchmark", target_node_id: "sdd", attempt_number: 1, feedback: ["missing scenario"], created_at: "t1" },
  177 + { source_node_id: "benchmark", target_node_id: "sdd", attempt_number: 2, feedback: ["still missing", "and X"], created_at: "t2" },
  178 + ],
  179 + };
  180 + const html = sandbox.renderLoopBackTimelineHtml(run);
  181 + check(html.includes("attempt 1") && html.includes("attempt 2"), "every attempt gets its own timeline entry, in order");
  182 + check(html.includes("benchmark") && html.includes("sdd"), "each entry names its real source/target node");
  183 + check(html.includes('data-attempt-index="0"') && html.includes('data-attempt-index="1"'), "entries are addressable by index for the click-to-reveal wiring");
  184 + check(html.includes("missing scenario"), "attempt 1's real feedback text is rendered");
  185 + check(html.includes("still missing") && html.includes("and X"), "attempt 2's real (multi-item) feedback text is rendered");
  186 + check(html.includes('style="display:none;"'), "feedback panels start collapsed -- a click reveals one at a time");
  187 +
  188 + const noFeedbackRun = {
  189 + loop_back_events: [{ source_node_id: "a", target_node_id: "a", attempt_number: 1, feedback: [], created_at: "t1" }],
  190 + };
  191 + const noFeedbackHtml = sandbox.renderLoopBackTimelineHtml(noFeedbackRun);
  192 + check(noFeedbackHtml.includes("未记录 feedback"), "an attempt with empty feedback[] still renders an explicit state, not blank");
  193 +}
  194 +
  195 +console.log("frontend/test_org_meta_graph_smoke.js: all " + assertionCount + " assertions passed");
... ...
src/new_goal_dispatch.py 0 → 100644
  1 +"""Periodic auto-dispatch for new Goals that were created but never
  2 +started (2026-08-13, `docs/todo/scheduler-new-goal-dispatch-requirements.md`
  3 +FR-1). `scheduler.run_scheduler_once()` exists and works, but nothing in
  4 +production ever called it: a `task.status='queued'` row (created via the
  5 +API/console, never given to `run_goal_to_completion`) sat there until a
  6 +human clicked the console's Start button.
  7 +
  8 +Scope: deliberately NOT `task_reconciliation.py`'s job -- that module
  9 +resumes tasks that already started and got abandoned mid-flight (status in
  10 +`task_execution._RESUMABLE_STATUSES`, stale by `_DEFAULT_STALE_AFTER_SECONDS`
  11 += 30 min). This module only ever looks at `status='queued'` (a task that has
  12 +never been dispatched at all) and does not apply a staleness window --
  13 +`docs/done/scheduler-task-reconciliation-design.md` §7 records the decision
  14 +to keep the two loops independent, not merged into one "万能调度循环", and
  15 +that a 'queued' task should NOT wait out a 30-minute staleness window before
  16 +its very first dispatch (the whole point of FR-1 is near-real-time pickup of
  17 +brand-new Goals, not eventual recovery of stuck ones)."""
  18 +
  19 +from src import schema, task_execution
  20 +
  21 +
  22 +def find_queued_tasks(conn):
  23 + """Returns [{"task_id", "target_folder_path", "goal_text"}, ...] for
  24 + every `status='queued'` task not already in flight in this process.
  25 + Read-only -- kept separate from `dispatch_once()` so candidate selection
  26 + is independently testable without spinning up real background threads
  27 + (same split as `task_reconciliation.find_stale_resumable_tasks`)."""
  28 + rows = conn.execute(
  29 + "SELECT t.task_id, p.target_folder_path, t.goal_text "
  30 + "FROM task t JOIN project p ON p.project_id = t.project_id "
  31 + "WHERE t.status = 'queued'"
  32 + ).fetchall()
  33 +
  34 + candidates = []
  35 + for task_id, target_folder_path, goal_text in rows:
  36 + if task_execution.is_in_flight(task_id):
  37 + continue
  38 + candidates.append({"task_id": task_id, "target_folder_path": target_folder_path, "goal_text": goal_text})
  39 + return candidates
  40 +
  41 +
  42 +def dispatch_once(db_path):
  43 + """One dispatch pass: find candidates, dispatch each via
  44 + `task_execution.start_task_execution_in_background()`. Own short-lived
  45 + connection for the scan, closed before dispatching -- same pattern as
  46 + `task_reconciliation.reconcile_once`. A candidate that fails to dispatch
  47 + (e.g. the console's Start button won the race between scan and here) is
  48 + skipped, not treated as fatal for the whole pass. Returns the candidate
  49 + list (dispatched or not) for callers/tests to inspect."""
  50 + conn = schema.connect(db_path)
  51 + try:
  52 + candidates = find_queued_tasks(conn)
  53 + finally:
  54 + conn.close()
  55 + for c in candidates:
  56 + try:
  57 + task_execution.start_task_execution_in_background(
  58 + db_path, c["task_id"], c["target_folder_path"], c["goal_text"]
  59 + )
  60 + except Exception:
  61 + pass
  62 + return candidates
... ...
src/task_query.py
... ... @@ -187,9 +187,32 @@ def get_task(conn, task_id):
187 187 # see docs/done/work-graph-self-planning-and-loop-back-design.md §7).
188 188 "loop_back_events": _loop_back_events_for_run(conn, w[0]),
189 189 })
  190 + # 2026-08-13 (Org Graph unification Phase 3, FR-5a): planner_reviewer has
  191 + # no self-planned work_graph_run (org_node_trigger.trigger_org_node() is
  192 + # one flat call, see e2e_pipeline.py::_record_planner_review_event) --
  193 + # its only real audit trail is this task-scoped event log, so the Org
  194 + # meta-graph reads it here instead of faking a run for that stage.
  195 + task["planner_review_events"] = _planner_review_events_for_task(conn, task_id)
190 196 return task
191 197  
192 198  
  199 +def _planner_review_events_for_task(conn, task_id):
  200 + rows = conn.execute(
  201 + "SELECT payload, created_at FROM execution_event "
  202 + "WHERE task_id = ? AND event_type = 'planner_review_verdict' ORDER BY created_at",
  203 + (task_id,),
  204 + ).fetchall()
  205 + events = []
  206 + for payload, created_at in rows:
  207 + data = json.loads(payload)
  208 + events.append({
  209 + "attempt": data["attempt"], "verdict": data["verdict"],
  210 + "used_existing_artifacts": data.get("used_existing_artifacts", False),
  211 + "created_at": created_at,
  212 + })
  213 + return events
  214 +
  215 +
193 216 def _loop_back_events_for_run(conn, run_id):
194 217 rows = conn.execute(
195 218 "SELECT payload, created_at FROM execution_event "
... ...
src/test_new_goal_dispatch.py 0 → 100644
  1 +"""new_goal_dispatch.py tests (2026-08-13, FR-1 --
  2 +docs/todo/scheduler-new-goal-dispatch-requirements.md): same mocked-at-the-
  3 +subprocess-boundary / real-sqlite split as test_task_reconciliation.py.
  4 +
  5 +Run: python3 -m unittest src.test_new_goal_dispatch -v
  6 +"""
  7 +
  8 +import subprocess
  9 +import tempfile
  10 +import unittest
  11 +from unittest.mock import patch
  12 +
  13 +from src import new_goal_dispatch, project_scaffold, schema, task_execution
  14 +
  15 +
  16 +class NewGoalDispatchTestBase(unittest.TestCase):
  17 + def setUp(self):
  18 + self.tmp = tempfile.TemporaryDirectory()
  19 + self.addCleanup(self.tmp.cleanup)
  20 + subprocess.run(["git", "init"], cwd=self.tmp.name, check=True, capture_output=True)
  21 + self.conn = schema.connect(":memory:")
  22 + schema.create_schema(self.conn)
  23 + project = project_scaffold.create_project(self.conn, self.tmp.name, init_git_if_missing=False)
  24 + self.project_id = project["project_id"]
  25 +
  26 + def tearDown(self):
  27 + self.conn.close()
  28 +
  29 + def _insert_task(self, task_id, status, goal_text="a goal"):
  30 + self.conn.execute(
  31 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node, created_at) "
  32 + "VALUES (?, ?, ?, 'goal', ?, 'planner', CURRENT_TIMESTAMP)",
  33 + (task_id, self.project_id, goal_text, status),
  34 + )
  35 + self.conn.commit()
  36 +
  37 +
  38 +class FindQueuedTasksTest(NewGoalDispatchTestBase):
  39 + def test_finds_queued_task(self):
  40 + self._insert_task("task-1", "queued")
  41 + result = new_goal_dispatch.find_queued_tasks(self.conn)
  42 + self.assertEqual(result, [{"task_id": "task-1", "target_folder_path": self.tmp.name, "goal_text": "a goal"}])
  43 +
  44 + def test_skips_non_queued_statuses(self):
  45 + for status in ("coding", "validating", "done", "blocked_pending_human", "planning_brd_prd_sdd"):
  46 + self._insert_task("task-" + status, status)
  47 + result = new_goal_dispatch.find_queued_tasks(self.conn)
  48 + self.assertEqual(result, [])
  49 +
  50 + def test_excludes_in_flight_task(self):
  51 + self._insert_task("task-1", "queued")
  52 + with patch.object(task_execution, "is_in_flight", return_value=True):
  53 + result = new_goal_dispatch.find_queued_tasks(self.conn)
  54 + self.assertEqual(result, [])
  55 +
  56 + def test_no_candidates_returns_empty_list(self):
  57 + result = new_goal_dispatch.find_queued_tasks(self.conn)
  58 + self.assertEqual(result, [])
  59 +
  60 +
  61 +class DispatchOnceTest(unittest.TestCase):
  62 + """Real file db (own connection, mirrors ReconcileOnceTest) -- dispatch
  63 + itself is mocked so this stays at the "did we correctly decide to call
  64 + it" boundary."""
  65 +
  66 + def setUp(self):
  67 + self.tmp = tempfile.TemporaryDirectory()
  68 + self.addCleanup(self.tmp.cleanup)
  69 + subprocess.run(["git", "init"], cwd=self.tmp.name, check=True, capture_output=True)
  70 + self.tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
  71 + self.tmp_db.close()
  72 + self.db_path = self.tmp_db.name
  73 + self.addCleanup(lambda: __import__("os").path.exists(self.db_path) and __import__("os").remove(self.db_path))
  74 +
  75 + conn = schema.connect(self.db_path)
  76 + schema.create_schema(conn)
  77 + project = project_scaffold.create_project(conn, self.tmp.name, init_git_if_missing=False)
  78 + self.project_id = project["project_id"]
  79 + conn.execute(
  80 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node, created_at) "
  81 + "VALUES ('queued-1', ?, 'goal 1', 'goal', 'queued', 'planner', CURRENT_TIMESTAMP)",
  82 + (self.project_id,),
  83 + )
  84 + conn.execute(
  85 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node, created_at) "
  86 + "VALUES ('coding-1', ?, 'goal 2', 'goal', 'coding', 'coder', CURRENT_TIMESTAMP)",
  87 + (self.project_id,),
  88 + )
  89 + conn.commit()
  90 + conn.close()
  91 +
  92 + def test_dispatches_only_queued_candidates(self):
  93 + with patch.object(task_execution, "start_task_execution_in_background") as mock_start:
  94 + result = new_goal_dispatch.dispatch_once(self.db_path)
  95 + self.assertEqual([c["task_id"] for c in result], ["queued-1"])
  96 + mock_start.assert_called_once_with(self.db_path, "queued-1", self.tmp.name, "goal 1")
  97 +
  98 + def test_one_dispatch_failure_does_not_block_others(self):
  99 + conn = schema.connect(self.db_path)
  100 + conn.execute(
  101 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node, created_at) "
  102 + "VALUES ('queued-2', ?, 'goal 3', 'goal', 'queued', 'planner', CURRENT_TIMESTAMP)",
  103 + (self.project_id,),
  104 + )
  105 + conn.commit()
  106 + conn.close()
  107 +
  108 + with patch.object(
  109 + task_execution, "start_task_execution_in_background",
  110 + side_effect=[task_execution.TaskExecutionError("already in flight"), None],
  111 + ) as mock_start:
  112 + result = new_goal_dispatch.dispatch_once(self.db_path)
  113 + self.assertEqual(len(result), 2)
  114 + self.assertEqual(mock_start.call_count, 2)
  115 +
  116 + def test_no_candidates_returns_gracefully(self):
  117 + conn = schema.connect(self.db_path)
  118 + conn.execute("UPDATE task SET status = 'done'")
  119 + conn.commit()
  120 + conn.close()
  121 +
  122 + with patch.object(task_execution, "start_task_execution_in_background") as mock_start:
  123 + result = new_goal_dispatch.dispatch_once(self.db_path)
  124 + self.assertEqual(result, [])
  125 + mock_start.assert_not_called()
  126 +
  127 +
  128 +if __name__ == "__main__":
  129 + unittest.main()
... ...
src/test_org_node_trigger_e2e.py 0 → 100644
  1 +"""trigger_org_node_structured's real-call coverage (2026-08,
  2 +work-graph-node-execution-deblackbox-requirements.md FR-4 -- the migration
  3 +from Dynamic Workflow scripts to flat `claude --agent` calls for the three
  4 +call sites in work_graph_orchestrate.py/e2e_pipeline.py that used to render
  5 +one-off `.js` Workflow scripts purely to get "agent identity + schema-shaped
  6 +output"). Gated behind RUN_REAL_AGENT_E2E=1 (CLAUDE.md Testing constraint) --
  7 +costs real API usage, skipped by default, same as src/test_agent_e2e.py.
  8 +
  9 +Run: RUN_REAL_AGENT_E2E=1 python3 -m unittest src.test_org_node_trigger_e2e -v
  10 +
  11 +**Real result, recorded 2026-08-13** (one real call, `--agent validator
  12 +--model claude-haiku-4-5`, target org_node's persona = agents/validator/SOUL.md
  13 +via .claude/agents/validator.md): all three FR-4 questions confirmed together
  14 +in a single real subprocess call (spied via `wraps=subprocess.run` so the
  15 +production `trigger_org_node_structured` code path runs completely for real,
  16 +while this test still gets to inspect the raw CompletedProcess underneath it):
  17 +
  18 +1. **Persona loaded correctly** -- the self-identify answer named itself an
  19 + independent QA/verification agent (PASS/FAIL/INCONCLUSIVE judgment
  20 + framework, "does not write or fix the Coder's business code"), matching
  21 + agents/validator/SOUL.md's own red lines, not the generic Claude Code
  22 + assistant persona. Same technique U-031 used to validate the (now-retired)
  23 + Dynamic Workflow `agentType` path -- ask the agent to self-identify, check
  24 + for persona-specific vocabulary, not a canned/generic answer.
  25 +2. **`--model` override took effect for real** -- `claude --agent <org_node>`
  26 + combined with `--model claude-haiku-4-5` is a genuinely different CLI
  27 + combination from anything DSL-internal Workflow options ever validated
  28 + (05 §5.4.1.5's "two different paths" lesson) -- confirmed empirically, not
  29 + assumed: the raw JSON response's own `modelUsage` key names
  30 + "claude-haiku-4-5" as the model that actually served the request, not
  31 + validator's role-declared default (`claude-sonnet-5`, roles/validator/
  32 + role.json).
  33 +3. **```json fence extraction is stable on real model output** -- the
  34 + real free-text response (identity narrative + a trailing ```json fenced
  35 + block, since `--json-schema` is never combined with `--agent`, see 05
  36 + §5.4.1.1) parsed cleanly via `extract_json_payload`/`trigger_org_node_
  37 + structured`'s required-keys matching, returning the schema-shaped dict
  38 + with no manual fallback needed -- the same extraction algorithm
  39 + `work_graph_plan.py::plan_work_graph()` already validated for real in
  40 + production (`_extract_graph_manifest`), now proven for `_JSON_FENCE_
  41 + INSTRUCTION`'s more generic prompt wording too.
  42 +"""
  43 +
  44 +import json
  45 +import subprocess
  46 +import tempfile
  47 +import unittest
  48 +from unittest.mock import patch
  49 +
  50 +from src import agent_profile, org_node_trigger, project_scaffold, schema
  51 +from src.test_support import skip_unless_env
  52 +
  53 +_SELF_IDENTIFY_SCHEMA = {
  54 + "type": "object",
  55 + "required": ["job", "never_does"],
  56 + "properties": {
  57 + "job": {"type": "string", "description": "one short sentence describing your role"},
  58 + "never_does": {"type": "string", "description": "one short sentence: something you never do"},
  59 + },
  60 +}
  61 +
  62 +
  63 +@skip_unless_env("RUN_REAL_AGENT_E2E")
  64 +class RealTriggerOrgNodeStructuredTest(unittest.TestCase):
  65 + def test_identity_model_override_and_json_extraction_all_real(self):
  66 + with tempfile.TemporaryDirectory() as tmp:
  67 + subprocess.run(["git", "init"], cwd=tmp, check=True, capture_output=True)
  68 + conn = schema.connect(":memory:")
  69 + schema.create_schema(conn)
  70 + project_scaffold.create_project(conn, tmp, init_git_if_missing=False)
  71 + agent_profile.deploy_agent_definitions(tmp)
  72 +
  73 + real_run = subprocess.run
  74 + captured = {}
  75 +
  76 + def spying_run(*args, **kwargs):
  77 + result = real_run(*args, **kwargs)
  78 + captured["result"] = result
  79 + return result
  80 +
  81 + # wraps the real subprocess.run rather than replacing it -- this
  82 + # call goes out to the real `claude` CLI exactly like production
  83 + # code would, this test just also gets to inspect the raw
  84 + # CompletedProcess trigger_org_node_structured's own return value
  85 + # (the extracted payload alone) doesn't expose.
  86 + with patch("src.org_node_trigger.subprocess.run", side_effect=spying_run):
  87 + extracted = org_node_trigger.trigger_org_node_structured(
  88 + "validator",
  89 + "In one short sentence each: what is your job, and what do you never do? "
  90 + "Answer only, take no other action.",
  91 + tmp,
  92 + _SELF_IDENTIFY_SCHEMA,
  93 + model="claude-haiku-4-5",
  94 + timeout=120,
  95 + )
  96 +
  97 + # (3) JSON fence extraction worked on real model output -- a dict
  98 + # with both required keys, not None/a StructuredOutputExtractionError.
  99 + self.assertIn("job", extracted)
  100 + self.assertIn("never_does", extracted)
  101 +
  102 + # (1) persona genuinely loaded -- validator's own red lines
  103 + # (agents/validator/SOUL.md), not a generic assistant answer.
  104 + identity_text = extracted["job"] + " " + extracted["never_does"]
  105 + self.assertTrue(
  106 + any(kw in identity_text for kw in (
  107 + "QA", "验收", "PASS", "FAIL", "INCONCLUSIVE", "independent", "verif", "Verif",
  108 + )),
  109 + "validator's answer doesn't look like the migrated QA identity: " + identity_text,
  110 + )
  111 +
  112 + # (2) --model override genuinely took effect -- the real response's
  113 + # own modelUsage key names the override model, not validator's
  114 + # role-declared default (claude-sonnet-5, roles/validator/role.json).
  115 + raw_payload = json.loads(captured["result"].stdout)
  116 + model_usage = raw_payload.get("modelUsage") or {}
  117 + self.assertIn(
  118 + "claude-haiku-4-5", model_usage,
  119 + "--model override not reflected in the real response's modelUsage: " + json.dumps(raw_payload)[:2000],
  120 + )
  121 +
  122 +
  123 +if __name__ == "__main__":
  124 + unittest.main()
... ...
src/test_task_query.py
... ... @@ -90,6 +90,7 @@ class GetTaskTest(TaskQueryTestBase):
90 90 self.assertEqual(result["artifacts"], [])
91 91 self.assertEqual(result["bug_records"], [])
92 92 self.assertEqual(result["work_graph_runs"], [])
  93 + self.assertEqual(result["planner_review_events"], [])
93 94  
94 95 def test_includes_artifacts(self):
95 96 self._insert_task("task-1")
... ... @@ -239,6 +240,52 @@ class GetTaskTest(TaskQueryTestBase):
239 240 self.assertEqual(node["quota_wait_started_at"], "2026-07-31 09:00:00")
240 241  
241 242  
  243 + def test_includes_planner_review_events_for_the_task(self):
  244 + # 2026-08-13 (Org Graph unification Phase 3, FR-5a): planner_reviewer
  245 + # has no work_graph_run of its own -- its verdicts live in
  246 + # execution_event (event_type='planner_review_verdict'), see
  247 + # e2e_pipeline.py::_record_planner_review_event.
  248 + self._insert_task("task-1")
  249 + self.conn.execute(
  250 + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) "
  251 + "VALUES ('evt-1', 'proj-1', 'task-1', 'planner_review_verdict', ?)",
  252 + (json.dumps({"attempt": 1, "verdict": "REJECTED", "used_existing_artifacts": False}),),
  253 + )
  254 + self.conn.execute(
  255 + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) "
  256 + "VALUES ('evt-2', 'proj-1', 'task-1', 'planner_review_verdict', ?)",
  257 + (json.dumps({"attempt": 2, "verdict": "PASS", "used_existing_artifacts": True}),),
  258 + )
  259 + self.conn.commit()
  260 +
  261 + result = task_query.get_task(self.conn, "task-1")
  262 + events = result["planner_review_events"]
  263 + self.assertEqual(len(events), 2)
  264 + self.assertEqual(events[0]["attempt"], 1)
  265 + self.assertEqual(events[0]["verdict"], "REJECTED")
  266 + self.assertEqual(events[0]["used_existing_artifacts"], False)
  267 + self.assertEqual(events[1]["attempt"], 2)
  268 + self.assertEqual(events[1]["verdict"], "PASS")
  269 + self.assertEqual(events[1]["used_existing_artifacts"], True)
  270 +
  271 + def test_no_planner_review_events_is_an_empty_list_not_missing_key(self):
  272 + self._insert_task("task-1")
  273 + result = task_query.get_task(self.conn, "task-1")
  274 + self.assertEqual(result["planner_review_events"], [])
  275 +
  276 + def test_other_task_planner_review_events_excluded(self):
  277 + self._insert_task("task-1")
  278 + self._insert_task("task-2")
  279 + self.conn.execute(
  280 + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) "
  281 + "VALUES ('evt-1', 'proj-1', 'task-2', 'planner_review_verdict', ?)",
  282 + (json.dumps({"attempt": 1, "verdict": "PASS", "used_existing_artifacts": False}),),
  283 + )
  284 + self.conn.commit()
  285 + result = task_query.get_task(self.conn, "task-1")
  286 + self.assertEqual(result["planner_review_events"], [])
  287 +
  288 +
242 289 class ListTasksSortTest(TaskQueryTestBase):
243 290 def setUp(self):
244 291 super().setUp()
... ...