diff --git a/docs/todo/org-graph-unification-and-universal-self-planning-requirements.md b/docs/done/org-graph-unification-and-universal-self-planning-requirements.md
index 8a10242..8a10242 100644
--- a/docs/todo/org-graph-unification-and-universal-self-planning-requirements.md
+++ b/docs/done/org-graph-unification-and-universal-self-planning-requirements.md
diff --git a/docs/todo/org-graph-unification-phase2-universal-self-planning-design.md b/docs/done/org-graph-unification-phase2-universal-self-planning-design.md
index 4ddd524..4ddd524 100644
--- a/docs/todo/org-graph-unification-phase2-universal-self-planning-design.md
+++ b/docs/done/org-graph-unification-phase2-universal-self-planning-design.md
diff --git a/docs/todo/scheduler-new-goal-dispatch-requirements.md b/docs/done/scheduler-new-goal-dispatch-requirements.md
index 160f5f6..160f5f6 100644
--- a/docs/todo/scheduler-new-goal-dispatch-requirements.md
+++ b/docs/done/scheduler-new-goal-dispatch-requirements.md
diff --git a/docs/todo/work-graph-node-execution-deblackbox-requirements.md b/docs/done/work-graph-node-execution-deblackbox-requirements.md
index 46ca78a..46ca78a 100644
--- a/docs/todo/work-graph-node-execution-deblackbox-requirements.md
+++ b/docs/done/work-graph-node-execution-deblackbox-requirements.md
diff --git a/frontend/app.js b/frontend/app.js
index 945c374..ae88ca3 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -1957,6 +1957,141 @@ function renderOrgGraphTopology(activeOrgNode, runStatusByOrgNode, route) {
return '
' + nodesHtml + "
" + retryEdgeHtml;
}
+// ---- Org 元图 (FR-5a, Org Graph unification Phase 3, 2026-08-13): a
+// standalone four-stage top-level meta-graph (Planner -> 复核 Planner
+// Reviewer -> Coder -> 终审 Validator), separate from renderOrgGraphTopology
+// above -- that function is still called by mountGraphConsole's own
+// existing "Org Graph" panel and is pinned by test_graph_console_smoke.js's
+// 3-node ORG_NODE_ORDER shape, so it's left untouched rather than stretched
+// to 4 stages. Each stage expands in place into that stage's real Work
+// Graph run, reusing renderGraphVisualizerPanel unmodified -- except
+// planner_reviewer, which never self-plans a Work Graph
+// (org_node_trigger.trigger_org_node() is one flat call, see
+// e2e_pipeline.py::_record_planner_review_event) and instead shows its own
+// verdict trail (task.planner_review_events, task_query.py::
+// _planner_review_events_for_task).
+const ORG_META_STAGE_ORDER = ["planner", "planner_reviewer", "coder", "validator"];
+const ORG_META_STAGE_LABELS = {
+ planner: "Planner", planner_reviewer: "复核(Planner Reviewer)",
+ coder: "Coder", validator: "终审(Validator)",
+};
+
+function orgMetaStageStatus(task, stage) {
+ if (stage === "planner_reviewer") {
+ const events = task.planner_review_events || [];
+ if (events.length === 0) return "not_started";
+ return events[events.length - 1].verdict === "PASS" ? "done" : "reflecting";
+ }
+ const run = getLatestRunForOrgNode(task, stage);
+ return run ? run.status : "not_started";
+}
+
+function renderOrgMetaGraph(task) {
+ const nodesHtml = ORG_META_STAGE_ORDER.map((stage, i) => {
+ const status = orgMetaStageStatus(task, stage);
+ const arrow = i < ORG_META_STAGE_ORDER.length - 1 ? '→
' : "";
+ return (
+ ''
+ + '
' + escapeHtml(ORG_META_STAGE_LABELS[stage]) + "
"
+ + '
' + escapeHtml(status) + "
"
+ + "
" + arrow
+ );
+ }).join("");
+ const detailsHtml = ORG_META_STAGE_ORDER.map((stage) =>
+ '
'
+ ).join("");
+ return '' + nodesHtml + "
" + detailsHtml;
+}
+
+function renderPlannerReviewEventsList(events) {
+ if (!events || events.length === 0) return '尚无复核记录
';
+ return '' + events.map((e) =>
+ "attempt " + e.attempt + " · " + escapeHtml(e.verdict)
+ + (e.used_existing_artifacts ? "(复用已有产出)" : "") + " "
+ + escapeHtml(e.created_at || "") + " "
+ ).join("") + " ";
+}
+
+// planner/coder/validator branch mirrors renderOrgComboPanelContent's own
+// empty-run/empty-DAG fallback text -- same two states, this is just the
+// meta-graph's own expand target rather than v_task_detail's combo panel.
+function renderOrgMetaStageDetailHtml(task, stage) {
+ if (stage === "planner_reviewer") {
+ return '
Planner 产出复核记录 ' + renderPlannerReviewEventsList(task.planner_review_events) + "";
+ }
+ const run = getLatestRunForOrgNode(task, stage);
+ if (!run) return '' + escapeHtml(stage) + " 尚未开始(暂无 Work Graph run)
";
+ const panelHtml = renderGraphVisualizerPanel(run, { enableNodeClick: false });
+ return panelHtml
+ ? panelHtml + renderLoopBackTimelineHtml(run)
+ : '最新一次 run_id=' + escapeHtml(run.run_id) + "(status=" + escapeHtml(run.status) + ")· 该 run 尚未生成 Work Graph
";
+}
+
+function mountOrgMetaGraph(container, task) {
+ for (const el of container.querySelectorAll(".graph-topo-node[data-meta-stage]")) {
+ el.addEventListener("click", () => {
+ const stage = el.dataset.metaStage;
+ const detail = container.querySelector("#org-meta-detail-" + stage);
+ if (!detail) return;
+ if (detail.style.display !== "none") {
+ detail.style.display = "none";
+ detail.innerHTML = "";
+ return;
+ }
+ detail.innerHTML = renderOrgMetaStageDetailHtml(task, stage);
+ detail.style.display = "block";
+ mountGraphVisualizerTooltips(detail);
+ mountGraphVisualizerEdges(detail);
+ mountLoopBackTimeline(detail);
+ });
+ }
+}
+
+// ---- Loop-back 历史回放时间轴 (FR-5b, Org Graph unification Phase 3,
+// 2026-08-13): task_query.py's _loop_back_events_for_run() already exposes
+// run.loop_back_events (source_node_id/target_node_id/attempt_number/
+// feedback[]/created_at) -- renderGraphVisualizerPanel only ever reduces
+// them to a self-loop badge count / on_failure edge trigger count, never
+// the feedback text itself. This is a separate, additive component (not a
+// rewrite of the DAG renderer) rendered alongside it at each of its call
+// sites, ordered by attempt_number, one entry per real loop_back.
+function renderLoopBackTimelineHtml(run) {
+ const events = run.loop_back_events || [];
+ if (events.length === 0) return "";
+ const itemsHtml = events.map((e, i) =>
+ ''
+ + "attempt " + e.attempt_number + " · " + escapeHtml(e.source_node_id) + " → " + escapeHtml(e.target_node_id) + " "
+ + "" + escapeHtml(e.created_at || "") + " "
+ + " "
+ ).join("");
+ const feedbackPanelsHtml = events.map((e, i) =>
+ ''
+ + ((e.feedback || []).length
+ ? "
" + e.feedback.map((f) => "" + escapeHtml(f) + " ").join("") + " "
+ : '
该次 loop_back 未记录 feedback
')
+ + "
"
+ ).join("");
+ return (
+ 'Loop-back 历史回放(按 attempt_number) '
+ + '
"
+ + feedbackPanelsHtml
+ + "
"
+ );
+}
+
+function mountLoopBackTimeline(container) {
+ for (const timeline of container.querySelectorAll(".loop-back-timeline")) {
+ for (const item of timeline.querySelectorAll(".timeline-item[data-attempt-index]")) {
+ item.addEventListener("click", () => {
+ const idx = item.dataset.attemptIndex;
+ for (const panel of timeline.querySelectorAll(".timeline-feedback")) {
+ panel.style.display = panel.dataset.attemptIndex === idx ? "block" : "none";
+ }
+ });
+ }
+ }
+}
+
const GC_DOC_ARTIFACT_TYPES = ["brd", "prd", "sdd", "benchmark"];
const GC_CODE_ARTIFACT_TYPES = ["code_diff", "test_report"];
@@ -2049,6 +2184,10 @@ function renderGraphConsole() {
Org Graph
+
+
Org 元图(点击每段展开真实 Work Graph 详情)
+
+
@@ -2062,6 +2201,7 @@ function mountGraphConsole(queryParams) {
const taskSelect = document.getElementById("gc_task_select");
const orgTopologyEl = document.getElementById("gc_org_topology");
+ const orgMetaGraphEl = document.getElementById("gc_org_meta_graph");
const workGraphPanelsEl = document.getElementById("gc_work_graph_panels");
const artifactsOverviewEl = document.getElementById("gc_artifacts_overview");
@@ -2127,9 +2267,11 @@ function mountGraphConsole(queryParams) {
for (const orgNode of run.org_nodes_involved || []) runStatusByOrgNode[orgNode] = run.status;
}
orgTopologyEl.innerHTML = renderOrgGraphTopology(task.assigned_org_node, runStatusByOrgNode, task.route);
+ orgMetaGraphEl.innerHTML = renderOrgMetaGraph(task);
+ mountOrgMetaGraph(orgMetaGraphEl, task);
const panelsHtml = task.work_graph_runs
- .map((run) => renderGraphVisualizerPanel(run, { enableNodeClick: true }))
+ .map((run) => renderGraphVisualizerPanel(run, { enableNodeClick: true }) + renderLoopBackTimelineHtml(run))
.join("");
if (!panelsHtml) {
workGraphPanelsEl.innerHTML = '';
@@ -2139,6 +2281,7 @@ function mountGraphConsole(queryParams) {
mountGraphVisualizerTooltips(workGraphPanelsEl);
mountGraphVisualizerEdges(workGraphPanelsEl);
mountWorkNodeClickExpand(workGraphPanelsEl, { onNodeClick: onWorkNodeClick });
+ mountLoopBackTimeline(workGraphPanelsEl);
}
async function populateTaskSelect() {
@@ -2146,6 +2289,7 @@ function mountGraphConsole(queryParams) {
if (tasks.length === 0) {
taskSelect.innerHTML = '暂无任务 ';
orgTopologyEl.innerHTML = '';
+ orgMetaGraphEl.innerHTML = "";
workGraphPanelsEl.innerHTML = "";
return;
}
diff --git a/frontend/test_org_meta_graph_smoke.js b/frontend/test_org_meta_graph_smoke.js
new file mode 100644
index 0000000..99c7a06
--- /dev/null
+++ b/frontend/test_org_meta_graph_smoke.js
@@ -0,0 +1,195 @@
+// Org Graph unification Phase 3 (FR-5a + FR-5b) smoke test. Same no-
+// framework/no-fixture convention as the other frontend/test_*_smoke.js
+// files -- loads app.js in a bare vm.Context and asserts on generated HTML
+// strings for the new pieces:
+// - FR-5a: ORG_META_STAGE_ORDER / orgMetaStageStatus / renderOrgMetaGraph
+// (the four-stage top-level meta-graph, Planner -> 复核 -> Coder ->
+// 终审, separate from the existing 3-node renderOrgGraphTopology) and
+// renderOrgMetaStageDetailHtml's planner_reviewer special-case (a
+// verdict list, not a faked Work Graph panel).
+// - FR-5b: renderLoopBackTimelineHtml (the attempt_number-ordered
+// loop_back replay list, reusing run.loop_back_events which
+// task_query.py already exposed but nothing rendered before this).
+// Mount-time DOM wiring (mountOrgMetaGraph/mountLoopBackTimeline's click
+// handlers) needs real querySelectorAll traversal this bare sandbox doesn't
+// provide -- same "manual browser verification only" boundary the other
+// smoke tests already draw around mount*/edge-drawing functions; this file
+// only pins the pure render* HTML-string output.
+//
+// Run: node frontend/test_org_meta_graph_smoke.js
+
+"use strict";
+const fs = require("fs");
+const path = require("path");
+const vm = require("vm");
+const assert = require("assert");
+
+function makeFakeDiv() {
+ let text = "";
+ return {
+ set textContent(v) { text = v == null ? "" : String(v); },
+ get textContent() { return text; },
+ get innerHTML() {
+ return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+ },
+ };
+}
+
+const sandbox = {
+ document: {
+ createElement: () => makeFakeDiv(),
+ getElementById: () => null,
+ querySelectorAll: () => [],
+ addEventListener: () => {},
+ },
+ window: {
+ location: { protocol: "http:", host: "localhost", hash: "" },
+ addEventListener: () => {},
+ },
+ localStorage: { getItem: () => null, setItem: () => {} },
+ console,
+};
+vm.createContext(sandbox);
+
+const src = fs.readFileSync(path.join(__dirname, "app.js"), "utf8");
+// Top-level `const` stays lexically scoped to the script and never reaches
+// the sandbox object (same Annex B quirk the other smoke tests already
+// document for e.g. ARTIFACT_DETAIL_RE) -- re-export what this test needs.
+const augmentedSrc = src + "\nglobalThis.ORG_META_STAGE_ORDER = ORG_META_STAGE_ORDER;\n";
+new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox);
+
+let assertionCount = 0;
+function check(condition, message) {
+ assertionCount++;
+ assert.ok(condition, message);
+}
+
+// ---- FR-5a: ORG_META_STAGE_ORDER has all four stages, in order ----
+{
+ check(
+ sandbox.ORG_META_STAGE_ORDER.join(",") === "planner,planner_reviewer,coder,validator",
+ "the meta-graph must cover all four stages in Planner -> 复核 -> Coder -> 终审 order"
+ );
+}
+
+// ---- FR-5a: orgMetaStageStatus ----
+{
+ const emptyTask = { work_graph_runs: [], planner_review_events: [] };
+ check(sandbox.orgMetaStageStatus(emptyTask, "planner") === "not_started", "no run yet -> not_started");
+ check(sandbox.orgMetaStageStatus(emptyTask, "planner_reviewer") === "not_started", "no review events yet -> not_started");
+
+ const taskWithRuns = {
+ work_graph_runs: [
+ { run_id: "run-1", org_nodes_involved: ["coder"], status: "running", started_at: "2026-08-01 00:00:00" },
+ ],
+ planner_review_events: [],
+ };
+ check(sandbox.orgMetaStageStatus(taskWithRuns, "coder") === "running", "status mirrors the latest run's own status");
+
+ const passedReview = { work_graph_runs: [], planner_review_events: [
+ { attempt: 1, verdict: "REJECTED", created_at: "t1" },
+ { attempt: 2, verdict: "PASS", created_at: "t2" },
+ ] };
+ check(sandbox.orgMetaStageStatus(passedReview, "planner_reviewer") === "done", "latest verdict PASS -> done");
+
+ const rejectedReview = { work_graph_runs: [], planner_review_events: [
+ { attempt: 1, verdict: "REJECTED", created_at: "t1" },
+ ] };
+ check(sandbox.orgMetaStageStatus(rejectedReview, "planner_reviewer") === "reflecting", "latest verdict not PASS -> reflecting, not done");
+}
+
+// ---- FR-5a: renderOrgMetaGraph -- four boxes, three arrows, real statuses ----
+{
+ const task = {
+ work_graph_runs: [
+ { run_id: "run-1", org_nodes_involved: ["planner"], status: "done", started_at: "t1" },
+ { run_id: "run-2", org_nodes_involved: ["coder"], status: "running", started_at: "t2" },
+ ],
+ planner_review_events: [{ attempt: 1, verdict: "PASS", created_at: "t1.5" }],
+ };
+ const html = sandbox.renderOrgMetaGraph(task);
+ for (const stage of ["planner", "planner_reviewer", "coder", "validator"]) {
+ check(html.includes('data-meta-stage="' + stage + '"'), "every stage box must carry data-meta-stage=" + stage);
+ }
+ check(html.includes("graph-topo-node status-done"), "planner shows its real run status (done)");
+ check(html.includes("graph-topo-node status-running"), "coder shows its real run status (running)");
+ check(html.includes("graph-topo-node status-not_started"), "validator has no run yet -> not_started");
+ check((html.match(/graph-edge/g) || []).length === 3, "four stages are joined by exactly three arrows");
+ check(html.includes('id="org-meta-detail-planner_reviewer"'), "every stage gets its own (initially hidden) expand target");
+ check(html.includes('style="display:none;"'), "expand targets start collapsed");
+}
+
+// ---- FR-5a: planner_reviewer special-case detail (verdict list, not a
+// faked Work Graph panel) ----
+{
+ const emptyEventsHtml = sandbox.renderOrgMetaStageDetailHtml({ planner_review_events: [] }, "planner_reviewer");
+ check(emptyEventsHtml.includes("尚无复核记录"), "zero review events -> explicit empty state");
+ check(!emptyEventsHtml.includes("work-graph"), "planner_reviewer must never render a fake DAG panel");
+
+ const task = {
+ planner_review_events: [
+ { attempt: 1, verdict: "REJECTED", used_existing_artifacts: false, created_at: "2026-08-12 10:00:00" },
+ { attempt: 2, verdict: "PASS", used_existing_artifacts: true, created_at: "2026-08-12 10:05:00" },
+ ],
+ };
+ const html = sandbox.renderOrgMetaStageDetailHtml(task, "planner_reviewer");
+ check(html.includes("attempt 1"), "each attempt is listed");
+ check(html.includes("REJECTED") && html.includes("PASS"), "each attempt's real verdict is shown");
+ check(html.includes("2026-08-12 10:00:00") && html.includes("2026-08-12 10:05:00"), "each attempt's real timestamp is shown");
+ check(html.includes("复用已有产出"), "used_existing_artifacts=true is surfaced, not silently dropped");
+}
+
+// ---- FR-5a: planner/coder/validator detail reuses renderGraphVisualizerPanel
+// unmodified, with the existing empty-run / empty-DAG fallbacks ----
+{
+ const noRunTask = { work_graph_runs: [] };
+ const noRunHtml = sandbox.renderOrgMetaStageDetailHtml(noRunTask, "coder");
+ check(noRunHtml.includes("尚未开始"), "no run yet for this stage -> explicit empty state");
+
+ const emptyDagTask = {
+ work_graph_runs: [{ run_id: "run-1", org_nodes_involved: ["coder"], status: "failed", graph_manifest: null }],
+ };
+ const emptyDagHtml = sandbox.renderOrgMetaStageDetailHtml(emptyDagTask, "coder");
+ check(emptyDagHtml.includes("run-1") && emptyDagHtml.includes("尚未生成 Work Graph"), "run exists but has no DAG yet -> explicit fallback naming the run");
+
+ const realRunTask = {
+ work_graph_runs: [{
+ run_id: "run-2", org_nodes_involved: ["coder"], status: "running",
+ graph_manifest: { nodes: [{ node_id: "n1", depends_on: [] }] },
+ nodes: [{ node_id: "n1", node_exec_id: "exec-1", subagent_type: "coder", status: "running" }],
+ }],
+ };
+ const realHtml = sandbox.renderOrgMetaStageDetailHtml(realRunTask, "coder");
+ check(realHtml.includes("run_id=run-2"), "a real run renders the actual renderGraphVisualizerPanel DAG output");
+ check(!realHtml.includes("clickable"), "meta-graph expand opts out of click-to-expand-logs (enableNodeClick: false)");
+}
+
+// ---- FR-5b: renderLoopBackTimelineHtml ----
+{
+ const noEventsHtml = sandbox.renderLoopBackTimelineHtml({ loop_back_events: [] });
+ check(noEventsHtml === "", "a run with no loop_back_events renders nothing, not an empty panel");
+ check(sandbox.renderLoopBackTimelineHtml({}) === "", "missing loop_back_events entirely is treated the same as empty");
+
+ const run = {
+ run_id: "run-1",
+ loop_back_events: [
+ { source_node_id: "benchmark", target_node_id: "sdd", attempt_number: 1, feedback: ["missing scenario"], created_at: "t1" },
+ { source_node_id: "benchmark", target_node_id: "sdd", attempt_number: 2, feedback: ["still missing", "and X"], created_at: "t2" },
+ ],
+ };
+ const html = sandbox.renderLoopBackTimelineHtml(run);
+ check(html.includes("attempt 1") && html.includes("attempt 2"), "every attempt gets its own timeline entry, in order");
+ check(html.includes("benchmark") && html.includes("sdd"), "each entry names its real source/target node");
+ check(html.includes('data-attempt-index="0"') && html.includes('data-attempt-index="1"'), "entries are addressable by index for the click-to-reveal wiring");
+ check(html.includes("missing scenario"), "attempt 1's real feedback text is rendered");
+ check(html.includes("still missing") && html.includes("and X"), "attempt 2's real (multi-item) feedback text is rendered");
+ check(html.includes('style="display:none;"'), "feedback panels start collapsed -- a click reveals one at a time");
+
+ const noFeedbackRun = {
+ loop_back_events: [{ source_node_id: "a", target_node_id: "a", attempt_number: 1, feedback: [], created_at: "t1" }],
+ };
+ const noFeedbackHtml = sandbox.renderLoopBackTimelineHtml(noFeedbackRun);
+ check(noFeedbackHtml.includes("未记录 feedback"), "an attempt with empty feedback[] still renders an explicit state, not blank");
+}
+
+console.log("frontend/test_org_meta_graph_smoke.js: all " + assertionCount + " assertions passed");
diff --git a/src/new_goal_dispatch.py b/src/new_goal_dispatch.py
new file mode 100644
index 0000000..1dfcf5f
--- /dev/null
+++ b/src/new_goal_dispatch.py
@@ -0,0 +1,62 @@
+"""Periodic auto-dispatch for new Goals that were created but never
+started (2026-08-13, `docs/todo/scheduler-new-goal-dispatch-requirements.md`
+FR-1). `scheduler.run_scheduler_once()` exists and works, but nothing in
+production ever called it: a `task.status='queued'` row (created via the
+API/console, never given to `run_goal_to_completion`) sat there until a
+human clicked the console's Start button.
+
+Scope: deliberately NOT `task_reconciliation.py`'s job -- that module
+resumes tasks that already started and got abandoned mid-flight (status in
+`task_execution._RESUMABLE_STATUSES`, stale by `_DEFAULT_STALE_AFTER_SECONDS`
+= 30 min). This module only ever looks at `status='queued'` (a task that has
+never been dispatched at all) and does not apply a staleness window --
+`docs/done/scheduler-task-reconciliation-design.md` §7 records the decision
+to keep the two loops independent, not merged into one "万能调度循环", and
+that a 'queued' task should NOT wait out a 30-minute staleness window before
+its very first dispatch (the whole point of FR-1 is near-real-time pickup of
+brand-new Goals, not eventual recovery of stuck ones)."""
+
+from src import schema, task_execution
+
+
+def find_queued_tasks(conn):
+ """Returns [{"task_id", "target_folder_path", "goal_text"}, ...] for
+ every `status='queued'` task not already in flight in this process.
+ Read-only -- kept separate from `dispatch_once()` so candidate selection
+ is independently testable without spinning up real background threads
+ (same split as `task_reconciliation.find_stale_resumable_tasks`)."""
+ rows = conn.execute(
+ "SELECT t.task_id, p.target_folder_path, t.goal_text "
+ "FROM task t JOIN project p ON p.project_id = t.project_id "
+ "WHERE t.status = 'queued'"
+ ).fetchall()
+
+ candidates = []
+ for task_id, target_folder_path, goal_text in rows:
+ if task_execution.is_in_flight(task_id):
+ continue
+ candidates.append({"task_id": task_id, "target_folder_path": target_folder_path, "goal_text": goal_text})
+ return candidates
+
+
+def dispatch_once(db_path):
+ """One dispatch pass: find candidates, dispatch each via
+ `task_execution.start_task_execution_in_background()`. Own short-lived
+ connection for the scan, closed before dispatching -- same pattern as
+ `task_reconciliation.reconcile_once`. A candidate that fails to dispatch
+ (e.g. the console's Start button won the race between scan and here) is
+ skipped, not treated as fatal for the whole pass. Returns the candidate
+ list (dispatched or not) for callers/tests to inspect."""
+ conn = schema.connect(db_path)
+ try:
+ candidates = find_queued_tasks(conn)
+ finally:
+ conn.close()
+ for c in candidates:
+ try:
+ task_execution.start_task_execution_in_background(
+ db_path, c["task_id"], c["target_folder_path"], c["goal_text"]
+ )
+ except Exception:
+ pass
+ return candidates
diff --git a/src/task_query.py b/src/task_query.py
index 5db9810..04a18c2 100644
--- a/src/task_query.py
+++ b/src/task_query.py
@@ -187,9 +187,32 @@ def get_task(conn, task_id):
# see docs/done/work-graph-self-planning-and-loop-back-design.md §7).
"loop_back_events": _loop_back_events_for_run(conn, w[0]),
})
+ # 2026-08-13 (Org Graph unification Phase 3, FR-5a): planner_reviewer has
+ # no self-planned work_graph_run (org_node_trigger.trigger_org_node() is
+ # one flat call, see e2e_pipeline.py::_record_planner_review_event) --
+ # its only real audit trail is this task-scoped event log, so the Org
+ # meta-graph reads it here instead of faking a run for that stage.
+ task["planner_review_events"] = _planner_review_events_for_task(conn, task_id)
return task
+def _planner_review_events_for_task(conn, task_id):
+ rows = conn.execute(
+ "SELECT payload, created_at FROM execution_event "
+ "WHERE task_id = ? AND event_type = 'planner_review_verdict' ORDER BY created_at",
+ (task_id,),
+ ).fetchall()
+ events = []
+ for payload, created_at in rows:
+ data = json.loads(payload)
+ events.append({
+ "attempt": data["attempt"], "verdict": data["verdict"],
+ "used_existing_artifacts": data.get("used_existing_artifacts", False),
+ "created_at": created_at,
+ })
+ return events
+
+
def _loop_back_events_for_run(conn, run_id):
rows = conn.execute(
"SELECT payload, created_at FROM execution_event "
diff --git a/src/test_new_goal_dispatch.py b/src/test_new_goal_dispatch.py
new file mode 100644
index 0000000..5613b57
--- /dev/null
+++ b/src/test_new_goal_dispatch.py
@@ -0,0 +1,129 @@
+"""new_goal_dispatch.py tests (2026-08-13, FR-1 --
+docs/todo/scheduler-new-goal-dispatch-requirements.md): same mocked-at-the-
+subprocess-boundary / real-sqlite split as test_task_reconciliation.py.
+
+Run: python3 -m unittest src.test_new_goal_dispatch -v
+"""
+
+import subprocess
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from src import new_goal_dispatch, project_scaffold, schema, task_execution
+
+
+class NewGoalDispatchTestBase(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ subprocess.run(["git", "init"], cwd=self.tmp.name, check=True, capture_output=True)
+ self.conn = schema.connect(":memory:")
+ schema.create_schema(self.conn)
+ project = project_scaffold.create_project(self.conn, self.tmp.name, init_git_if_missing=False)
+ self.project_id = project["project_id"]
+
+ def tearDown(self):
+ self.conn.close()
+
+ def _insert_task(self, task_id, status, goal_text="a goal"):
+ self.conn.execute(
+ "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node, created_at) "
+ "VALUES (?, ?, ?, 'goal', ?, 'planner', CURRENT_TIMESTAMP)",
+ (task_id, self.project_id, goal_text, status),
+ )
+ self.conn.commit()
+
+
+class FindQueuedTasksTest(NewGoalDispatchTestBase):
+ def test_finds_queued_task(self):
+ self._insert_task("task-1", "queued")
+ result = new_goal_dispatch.find_queued_tasks(self.conn)
+ self.assertEqual(result, [{"task_id": "task-1", "target_folder_path": self.tmp.name, "goal_text": "a goal"}])
+
+ def test_skips_non_queued_statuses(self):
+ for status in ("coding", "validating", "done", "blocked_pending_human", "planning_brd_prd_sdd"):
+ self._insert_task("task-" + status, status)
+ result = new_goal_dispatch.find_queued_tasks(self.conn)
+ self.assertEqual(result, [])
+
+ def test_excludes_in_flight_task(self):
+ self._insert_task("task-1", "queued")
+ with patch.object(task_execution, "is_in_flight", return_value=True):
+ result = new_goal_dispatch.find_queued_tasks(self.conn)
+ self.assertEqual(result, [])
+
+ def test_no_candidates_returns_empty_list(self):
+ result = new_goal_dispatch.find_queued_tasks(self.conn)
+ self.assertEqual(result, [])
+
+
+class DispatchOnceTest(unittest.TestCase):
+ """Real file db (own connection, mirrors ReconcileOnceTest) -- dispatch
+ itself is mocked so this stays at the "did we correctly decide to call
+ it" boundary."""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ subprocess.run(["git", "init"], cwd=self.tmp.name, check=True, capture_output=True)
+ self.tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
+ self.tmp_db.close()
+ self.db_path = self.tmp_db.name
+ self.addCleanup(lambda: __import__("os").path.exists(self.db_path) and __import__("os").remove(self.db_path))
+
+ conn = schema.connect(self.db_path)
+ schema.create_schema(conn)
+ project = project_scaffold.create_project(conn, self.tmp.name, init_git_if_missing=False)
+ self.project_id = project["project_id"]
+ conn.execute(
+ "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node, created_at) "
+ "VALUES ('queued-1', ?, 'goal 1', 'goal', 'queued', 'planner', CURRENT_TIMESTAMP)",
+ (self.project_id,),
+ )
+ conn.execute(
+ "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node, created_at) "
+ "VALUES ('coding-1', ?, 'goal 2', 'goal', 'coding', 'coder', CURRENT_TIMESTAMP)",
+ (self.project_id,),
+ )
+ conn.commit()
+ conn.close()
+
+ def test_dispatches_only_queued_candidates(self):
+ with patch.object(task_execution, "start_task_execution_in_background") as mock_start:
+ result = new_goal_dispatch.dispatch_once(self.db_path)
+ self.assertEqual([c["task_id"] for c in result], ["queued-1"])
+ mock_start.assert_called_once_with(self.db_path, "queued-1", self.tmp.name, "goal 1")
+
+ def test_one_dispatch_failure_does_not_block_others(self):
+ conn = schema.connect(self.db_path)
+ conn.execute(
+ "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node, created_at) "
+ "VALUES ('queued-2', ?, 'goal 3', 'goal', 'queued', 'planner', CURRENT_TIMESTAMP)",
+ (self.project_id,),
+ )
+ conn.commit()
+ conn.close()
+
+ with patch.object(
+ task_execution, "start_task_execution_in_background",
+ side_effect=[task_execution.TaskExecutionError("already in flight"), None],
+ ) as mock_start:
+ result = new_goal_dispatch.dispatch_once(self.db_path)
+ self.assertEqual(len(result), 2)
+ self.assertEqual(mock_start.call_count, 2)
+
+ def test_no_candidates_returns_gracefully(self):
+ conn = schema.connect(self.db_path)
+ conn.execute("UPDATE task SET status = 'done'")
+ conn.commit()
+ conn.close()
+
+ with patch.object(task_execution, "start_task_execution_in_background") as mock_start:
+ result = new_goal_dispatch.dispatch_once(self.db_path)
+ self.assertEqual(result, [])
+ mock_start.assert_not_called()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/test_org_node_trigger_e2e.py b/src/test_org_node_trigger_e2e.py
new file mode 100644
index 0000000..1de5154
--- /dev/null
+++ b/src/test_org_node_trigger_e2e.py
@@ -0,0 +1,124 @@
+"""trigger_org_node_structured's real-call coverage (2026-08,
+work-graph-node-execution-deblackbox-requirements.md FR-4 -- the migration
+from Dynamic Workflow scripts to flat `claude --agent` calls for the three
+call sites in work_graph_orchestrate.py/e2e_pipeline.py that used to render
+one-off `.js` Workflow scripts purely to get "agent identity + schema-shaped
+output"). Gated behind RUN_REAL_AGENT_E2E=1 (CLAUDE.md Testing constraint) --
+costs real API usage, skipped by default, same as src/test_agent_e2e.py.
+
+Run: RUN_REAL_AGENT_E2E=1 python3 -m unittest src.test_org_node_trigger_e2e -v
+
+**Real result, recorded 2026-08-13** (one real call, `--agent validator
+--model claude-haiku-4-5`, target org_node's persona = agents/validator/SOUL.md
+via .claude/agents/validator.md): all three FR-4 questions confirmed together
+in a single real subprocess call (spied via `wraps=subprocess.run` so the
+production `trigger_org_node_structured` code path runs completely for real,
+while this test still gets to inspect the raw CompletedProcess underneath it):
+
+1. **Persona loaded correctly** -- the self-identify answer named itself an
+ independent QA/verification agent (PASS/FAIL/INCONCLUSIVE judgment
+ framework, "does not write or fix the Coder's business code"), matching
+ agents/validator/SOUL.md's own red lines, not the generic Claude Code
+ assistant persona. Same technique U-031 used to validate the (now-retired)
+ Dynamic Workflow `agentType` path -- ask the agent to self-identify, check
+ for persona-specific vocabulary, not a canned/generic answer.
+2. **`--model` override took effect for real** -- `claude --agent `
+ combined with `--model claude-haiku-4-5` is a genuinely different CLI
+ combination from anything DSL-internal Workflow options ever validated
+ (05 §5.4.1.5's "two different paths" lesson) -- confirmed empirically, not
+ assumed: the raw JSON response's own `modelUsage` key names
+ "claude-haiku-4-5" as the model that actually served the request, not
+ validator's role-declared default (`claude-sonnet-5`, roles/validator/
+ role.json).
+3. **```json fence extraction is stable on real model output** -- the
+ real free-text response (identity narrative + a trailing ```json fenced
+ block, since `--json-schema` is never combined with `--agent`, see 05
+ §5.4.1.1) parsed cleanly via `extract_json_payload`/`trigger_org_node_
+ structured`'s required-keys matching, returning the schema-shaped dict
+ with no manual fallback needed -- the same extraction algorithm
+ `work_graph_plan.py::plan_work_graph()` already validated for real in
+ production (`_extract_graph_manifest`), now proven for `_JSON_FENCE_
+ INSTRUCTION`'s more generic prompt wording too.
+"""
+
+import json
+import subprocess
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from src import agent_profile, org_node_trigger, project_scaffold, schema
+from src.test_support import skip_unless_env
+
+_SELF_IDENTIFY_SCHEMA = {
+ "type": "object",
+ "required": ["job", "never_does"],
+ "properties": {
+ "job": {"type": "string", "description": "one short sentence describing your role"},
+ "never_does": {"type": "string", "description": "one short sentence: something you never do"},
+ },
+}
+
+
+@skip_unless_env("RUN_REAL_AGENT_E2E")
+class RealTriggerOrgNodeStructuredTest(unittest.TestCase):
+ def test_identity_model_override_and_json_extraction_all_real(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ subprocess.run(["git", "init"], cwd=tmp, check=True, capture_output=True)
+ conn = schema.connect(":memory:")
+ schema.create_schema(conn)
+ project_scaffold.create_project(conn, tmp, init_git_if_missing=False)
+ agent_profile.deploy_agent_definitions(tmp)
+
+ real_run = subprocess.run
+ captured = {}
+
+ def spying_run(*args, **kwargs):
+ result = real_run(*args, **kwargs)
+ captured["result"] = result
+ return result
+
+ # wraps the real subprocess.run rather than replacing it -- this
+ # call goes out to the real `claude` CLI exactly like production
+ # code would, this test just also gets to inspect the raw
+ # CompletedProcess trigger_org_node_structured's own return value
+ # (the extracted payload alone) doesn't expose.
+ with patch("src.org_node_trigger.subprocess.run", side_effect=spying_run):
+ extracted = org_node_trigger.trigger_org_node_structured(
+ "validator",
+ "In one short sentence each: what is your job, and what do you never do? "
+ "Answer only, take no other action.",
+ tmp,
+ _SELF_IDENTIFY_SCHEMA,
+ model="claude-haiku-4-5",
+ timeout=120,
+ )
+
+ # (3) JSON fence extraction worked on real model output -- a dict
+ # with both required keys, not None/a StructuredOutputExtractionError.
+ self.assertIn("job", extracted)
+ self.assertIn("never_does", extracted)
+
+ # (1) persona genuinely loaded -- validator's own red lines
+ # (agents/validator/SOUL.md), not a generic assistant answer.
+ identity_text = extracted["job"] + " " + extracted["never_does"]
+ self.assertTrue(
+ any(kw in identity_text for kw in (
+ "QA", "验收", "PASS", "FAIL", "INCONCLUSIVE", "independent", "verif", "Verif",
+ )),
+ "validator's answer doesn't look like the migrated QA identity: " + identity_text,
+ )
+
+ # (2) --model override genuinely took effect -- the real response's
+ # own modelUsage key names the override model, not validator's
+ # role-declared default (claude-sonnet-5, roles/validator/role.json).
+ raw_payload = json.loads(captured["result"].stdout)
+ model_usage = raw_payload.get("modelUsage") or {}
+ self.assertIn(
+ "claude-haiku-4-5", model_usage,
+ "--model override not reflected in the real response's modelUsage: " + json.dumps(raw_payload)[:2000],
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/test_task_query.py b/src/test_task_query.py
index 40f4400..f12ceb6 100644
--- a/src/test_task_query.py
+++ b/src/test_task_query.py
@@ -90,6 +90,7 @@ class GetTaskTest(TaskQueryTestBase):
self.assertEqual(result["artifacts"], [])
self.assertEqual(result["bug_records"], [])
self.assertEqual(result["work_graph_runs"], [])
+ self.assertEqual(result["planner_review_events"], [])
def test_includes_artifacts(self):
self._insert_task("task-1")
@@ -239,6 +240,52 @@ class GetTaskTest(TaskQueryTestBase):
self.assertEqual(node["quota_wait_started_at"], "2026-07-31 09:00:00")
+ def test_includes_planner_review_events_for_the_task(self):
+ # 2026-08-13 (Org Graph unification Phase 3, FR-5a): planner_reviewer
+ # has no work_graph_run of its own -- its verdicts live in
+ # execution_event (event_type='planner_review_verdict'), see
+ # e2e_pipeline.py::_record_planner_review_event.
+ self._insert_task("task-1")
+ self.conn.execute(
+ "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) "
+ "VALUES ('evt-1', 'proj-1', 'task-1', 'planner_review_verdict', ?)",
+ (json.dumps({"attempt": 1, "verdict": "REJECTED", "used_existing_artifacts": False}),),
+ )
+ self.conn.execute(
+ "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) "
+ "VALUES ('evt-2', 'proj-1', 'task-1', 'planner_review_verdict', ?)",
+ (json.dumps({"attempt": 2, "verdict": "PASS", "used_existing_artifacts": True}),),
+ )
+ self.conn.commit()
+
+ result = task_query.get_task(self.conn, "task-1")
+ events = result["planner_review_events"]
+ self.assertEqual(len(events), 2)
+ self.assertEqual(events[0]["attempt"], 1)
+ self.assertEqual(events[0]["verdict"], "REJECTED")
+ self.assertEqual(events[0]["used_existing_artifacts"], False)
+ self.assertEqual(events[1]["attempt"], 2)
+ self.assertEqual(events[1]["verdict"], "PASS")
+ self.assertEqual(events[1]["used_existing_artifacts"], True)
+
+ def test_no_planner_review_events_is_an_empty_list_not_missing_key(self):
+ self._insert_task("task-1")
+ result = task_query.get_task(self.conn, "task-1")
+ self.assertEqual(result["planner_review_events"], [])
+
+ def test_other_task_planner_review_events_excluded(self):
+ self._insert_task("task-1")
+ self._insert_task("task-2")
+ self.conn.execute(
+ "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) "
+ "VALUES ('evt-1', 'proj-1', 'task-2', 'planner_review_verdict', ?)",
+ (json.dumps({"attempt": 1, "verdict": "PASS", "used_existing_artifacts": False}),),
+ )
+ self.conn.commit()
+ result = task_query.get_task(self.conn, "task-1")
+ self.assertEqual(result["planner_review_events"], [])
+
+
class ListTasksSortTest(TaskQueryTestBase):
def setUp(self):
super().setUp()