Commit b1e0a879c2209dad12ad237c0dc133113ba69158
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>
Showing
11 changed files
with
725 additions
and
1 deletions
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); | |
| 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() | ... | ... |