diff --git a/frontend/app.js b/frontend/app.js
index c2aa3ec..c746273 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -2104,6 +2104,7 @@ function mountGraphConsole() {
}
workGraphPanelsEl.innerHTML = panelsHtml;
mountGraphVisualizerTooltips(workGraphPanelsEl);
+ mountGraphVisualizerEdges(workGraphPanelsEl);
mountWorkNodeClickExpand(workGraphPanelsEl, { onNodeClick: onWorkNodeClick });
}
diff --git a/frontend/style.css b/frontend/style.css
index 1d4cac4..06b424b 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -136,6 +136,22 @@ table.data-table tr[data-task-id]:hover td { background: #f9fafb; }
.work-node.status-waiting_for_quota::after { content: "🕐"; position: absolute; top: -8px; right: -8px; font-size: 14px; }
.work-node.status-blocked, .work-node.status-failed { border-color: var(--status-blocked); background: #fef2f2; }
.work-node.status-done { border-color: var(--status-done); background: #f0fdf4; }
+
+/* 2026-08-08 (Work Graph self-planning + loop-back): on_failure edges
+ (loop_back) drawn as lines over the existing row layout, plus a badge
+ for retry_self loops (which have no edge to draw -- see
+ mountGraphVisualizerEdges/countSelfLoopAttempts in app.js). The svg
+ sits absolutely over .work-graph (already position:relative) and
+ paints behind the node boxes since it appears earlier in the DOM (see
+ renderGraphVisualizerPanel) -- no z-index juggling needed. */
+.graph-edges-svg { position: absolute; top: 0; left: 0; pointer-events: none; overflow: visible; }
+.edge-on-failure { stroke: #dc2626; stroke-width: 2; stroke-dasharray: 5 3; fill: none; }
+.edge-label { font-size: 10px; fill: #dc2626; text-anchor: middle; paint-order: stroke; stroke: #fff; stroke-width: 3px; }
+.loop-badge {
+ display: inline-block; margin-left: 6px; padding: 0 5px; border-radius: 9px;
+ background: #fee2e2; color: #dc2626; font-size: 10px; font-weight: 600; cursor: help;
+}
+.edge-sample { display: inline-block; width: 20px; height: 0; border-top: 2px dashed #dc2626; }
.legend { display: flex; gap: 16px; flex-wrap: wrap; font-size: 12px; color: #6b7280; }
.legend-item { display: flex; align-items: center; gap: 6px; }
.legend-swatch { width: 14px; height: 14px; border-radius: 4px; border: 2px solid; }
diff --git a/frontend/test_graph_console_smoke.js b/frontend/test_graph_console_smoke.js
index 6d79da1..12772ef 100644
--- a/frontend/test_graph_console_smoke.js
+++ b/frontend/test_graph_console_smoke.js
@@ -121,6 +121,79 @@ new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox);
assert.ok(graphConsoleHtml.includes("work-node status-running clickable"), "opted-in call adds the clickable class");
assert.ok(graphConsoleHtml.includes('data-clickable-node-exec-id="exec-1"'), "opted-in call adds the click-target id");
assert.ok(graphConsoleHtml.includes('
'), "opted-in call reserves a log-viewer-slot");
+ assert.ok(graphConsoleHtml.includes('data-node-id="n1"'), "every node carries data-node-id for edge-drawing lookup");
+}
+
+// ---- renderGraphVisualizerPanel: Work Graph self-planning + loop-back
+// (2026-08-08) -- retry_self badge and on_failure edge data. Real line
+// positions require getBoundingClientRect (mountGraphVisualizerEdges),
+// which this bare vm sandbox can't provide -- that part is manual-browser-
+// verified only, same convention this file's own header note already
+// documents for DOM-rendering functions in general. What CAN be
+// mechanically checked here is the HTML-string generation itself: the
+// badge text/count, and the data-on-failure-edges payload the real mount
+// function would read.
+{
+ const runWithSelfLoop = {
+ org_node: "planner", run_id: "run-2",
+ graph_manifest: { nodes: [{ node_id: "benchmark", depends_on: [] }] },
+ nodes: [{ node_id: "benchmark", node_exec_id: "exec-2", subagent_type: "planner", status: "reflecting" }],
+ loop_back_events: [
+ { source_node_id: "benchmark", target_node_id: "benchmark", attempt_number: 1, feedback: ["missing scenario"] },
+ { source_node_id: "benchmark", target_node_id: "benchmark", attempt_number: 2, feedback: ["still missing"] },
+ ],
+ };
+ const html = sandbox.renderGraphVisualizerPanel(runWithSelfLoop);
+ assert.ok(html.includes('class="loop-badge"'), "a node with self-loop events gets a loop-badge");
+ assert.ok(html.includes(">↺ 2<"), "the badge shows the real count of self-loop attempts, not just a presence flag");
+ // the attribute is always present (simpler than conditionally omitting
+ // it), just empty when there's no on_failure edge to draw.
+ assert.ok(html.includes('data-on-failure-edges="[]"'), "retry_self alone has no on_failure edges -- payload is an empty array");
+
+ const runWithoutLoop = {
+ org_node: "planner", run_id: "run-3",
+ graph_manifest: { nodes: [{ node_id: "brd", depends_on: [] }] },
+ nodes: [{ node_id: "brd", node_exec_id: "exec-3", subagent_type: "planner", status: "done" }],
+ };
+ const plainHtml = sandbox.renderGraphVisualizerPanel(runWithoutLoop);
+ assert.ok(!plainHtml.includes("loop-badge"), "a run with no loop_back_events at all (older tasks) renders no badge");
+
+ const runWithLoopBackEdge = {
+ org_node: "planner", run_id: "run-4",
+ graph_manifest: {
+ nodes: [{ node_id: "sdd", depends_on: [] }, { node_id: "benchmark", depends_on: ["sdd"] }],
+ edges: [{ from: "benchmark", to: "sdd", edge_type: "on_failure" }],
+ },
+ nodes: [
+ { node_id: "sdd", node_exec_id: "exec-sdd", subagent_type: "planner", status: "done" },
+ { node_id: "benchmark", node_exec_id: "exec-bm", subagent_type: "planner", status: "running" },
+ ],
+ loop_back_events: [
+ { source_node_id: "benchmark", target_node_id: "sdd", attempt_number: 1, feedback: ["x"] },
+ ],
+ };
+ const loopBackHtml = sandbox.renderGraphVisualizerPanel(runWithLoopBackEdge);
+ assert.ok(loopBackHtml.includes("data-on-failure-edges"), "a real on_failure edge emits the edges payload for mountGraphVisualizerEdges to read");
+ assert.ok(loopBackHtml.includes(""from":"benchmark""), "the payload's escaped JSON names the real source node");
+ assert.ok(loopBackHtml.includes(""triggerCount":1"), "the payload counts real triggers from loop_back_events, not just declares the edge");
+ assert.ok(loopBackHtml.includes("loop_back(失败回路)"), "the legend explains the on_failure line when one is present");
+
+ const noEdgeLegendHtml = sandbox.renderGraphVisualizerPanel(runWithSelfLoop);
+ assert.ok(!noEdgeLegendHtml.includes("loop_back(失败回路)"), "no on_failure edge -- legend stays unchanged, no dangling explanation");
+}
+
+// ---- countSelfLoopAttempts ----
+{
+ const run = {
+ loop_back_events: [
+ { source_node_id: "a", target_node_id: "a", attempt_number: 1 },
+ { source_node_id: "a", target_node_id: "b", attempt_number: 1 }, // loop_back, not a self-loop -- must not count
+ { source_node_id: "a", target_node_id: "a", attempt_number: 2 },
+ ],
+ };
+ assert.strictEqual(sandbox.countSelfLoopAttempts(run, "a"), 2, "counts only true self-loops (source===target===nodeId)");
+ assert.strictEqual(sandbox.countSelfLoopAttempts(run, "b"), 0, "a node that was only ever a loop_back TARGET (not source) has zero self-loops");
+ assert.strictEqual(sandbox.countSelfLoopAttempts({}, "a"), 0, "missing loop_back_events (older task) is treated as zero, not a crash");
}
// ---- renderArtifactSummaryList (FR-5) ----