Commit ed477539aa5fd9f0097247ad39c1a056f7a9d897
1 parent
04aa7971
graphlemon: auto-commit uncommitted changes for 3e75741a-2cbb-4f80-90c6-8804b94f…
…76ae-main-checkout-drift
Showing
3 changed files
with
165 additions
and
5 deletions
frontend/app.js
| ... | ... | @@ -1126,6 +1126,18 @@ const GRAPH_LEGEND_HTML = [ |
| 1126 | 1126 | '<div class="legend-item"><span class="legend-swatch ' + cls + '"></span>' + label + "</div>" |
| 1127 | 1127 | ).join(""); |
| 1128 | 1128 | |
| 1129 | +// 2026-08-08 (Work Graph self-planning + loop-back): how many times | |
| 1130 | +// node_id was sent back to ITSELF (on_fail.action='retry_self' -- source | |
| 1131 | +// and target are the same node, so there's no on_failure edge to draw, | |
| 1132 | +// see work_graph_orchestrate.py's _LoopBackRequested/_on_failure_target | |
| 1133 | +// design comment). Returns 0 if run.loop_back_events is absent (older | |
| 1134 | +// runs/tasks predating this field) or empty. | |
| 1135 | +function countSelfLoopAttempts(run, nodeId) { | |
| 1136 | + return (run.loop_back_events || []).filter( | |
| 1137 | + (e) => e.source_node_id === nodeId && e.target_node_id === nodeId | |
| 1138 | + ).length; | |
| 1139 | +} | |
| 1140 | + | |
| 1129 | 1141 | function renderGraphVisualizerPanel(run, options) { |
| 1130 | 1142 | if (!run.graph_manifest || run.graph_manifest.nodes.length === 0) return ""; |
| 1131 | 1143 | // FR-4 (v_graph_console, 2026-08-03): `options` is new and optional -- |
| ... | ... | @@ -1149,10 +1161,19 @@ function renderGraphVisualizerPanel(run, options) { |
| 1149 | 1161 | const clickable = enableNodeClick && wn; |
| 1150 | 1162 | const clickableAttrs = clickable ? ' data-clickable-node-exec-id="' + wn.node_exec_id + '"' : ""; |
| 1151 | 1163 | const logSlotHtml = clickable ? '<div class="log-viewer-slot"></div>' : ""; |
| 1164 | + // 2026-08-08: retry_self loops never touch graph_manifest.edges (the | |
| 1165 | + // target IS the source, so there's nothing to draw a line to) -- | |
| 1166 | + // the only place this is visible is a badge on the node itself, | |
| 1167 | + // read from loop_back_events (execution_event-backed, see | |
| 1168 | + // countSelfLoopAttempts's own comment). | |
| 1169 | + const selfLoopCount = countSelfLoopAttempts(run, gn.node_id); | |
| 1170 | + const loopBadgeHtml = selfLoopCount > 0 | |
| 1171 | + ? '<span class="loop-badge" title="已原地重试 ' + selfLoopCount + ' 次(on_fail.action=retry_self)">↺ ' + selfLoopCount + "</span>" | |
| 1172 | + : ""; | |
| 1152 | 1173 | return ( |
| 1153 | - '<div class="work-node status-' + status + (clickable ? " clickable" : "") + '" data-node-exec-id="' + (wn ? wn.node_exec_id : "") + '"' | |
| 1174 | + '<div class="work-node status-' + status + (clickable ? " clickable" : "") + '" data-node-exec-id="' + (wn ? wn.node_exec_id : "") + '" data-node-id="' + escapeHtml(gn.node_id) + '"' | |
| 1154 | 1175 | + quotaAttrs + clickableAttrs + ">" |
| 1155 | - + '<div class="node-id">' + escapeHtml(gn.node_id) + "</div>" | |
| 1176 | + + '<div class="node-id">' + escapeHtml(gn.node_id) + loopBadgeHtml + "</div>" | |
| 1156 | 1177 | + '<div class="node-skill">' + escapeHtml(wn ? wn.subagent_type : "尚未开始") + "</div>" |
| 1157 | 1178 | + logSlotHtml |
| 1158 | 1179 | + "</div>" |
| ... | ... | @@ -1161,17 +1182,92 @@ function renderGraphVisualizerPanel(run, options) { |
| 1161 | 1182 | return '<div class="work-row">' + cellsHtml + "</div>"; |
| 1162 | 1183 | }).join(""); |
| 1163 | 1184 | |
| 1185 | + // 2026-08-08: on_failure edges (graph_manifest.edges, declared whenever a | |
| 1186 | + // node has on_fail.action='loop_back') are the ones actually worth | |
| 1187 | + // drawing as lines -- normal depends_on/needs_judgment/zero_token edges | |
| 1188 | + // are already visually implied by row order (computeGraphLayers), so | |
| 1189 | + // drawing them too would just be visual noise on top of what the rows | |
| 1190 | + // already show. Each edge is annotated with how many times it was | |
| 1191 | + // actually traversed (loop_back_events), not just declared -- a | |
| 1192 | + // loop_back that was planned but never triggered still shows as a | |
| 1193 | + // (0x) line, which is itself useful information. | |
| 1194 | + const onFailureEdges = (run.graph_manifest.edges || []).filter((e) => e.edge_type === "on_failure"); | |
| 1195 | + const edgesDataJson = escapeHtml(JSON.stringify(onFailureEdges.map((e) => { | |
| 1196 | + const triggerCount = (run.loop_back_events || []).filter( | |
| 1197 | + (ev) => ev.source_node_id === e.from && ev.target_node_id === e.to | |
| 1198 | + ).length; | |
| 1199 | + return { from: e.from, to: e.to, triggerCount: triggerCount }; | |
| 1200 | + }))); | |
| 1201 | + | |
| 1164 | 1202 | return ( |
| 1165 | 1203 | // id="graph-panel-<run_id>": Org Graph 点击可达详情's scroll/highlight |
| 1166 | 1204 | // target (focusOrgNodePanel above) -- additive, doesn't change any |
| 1167 | 1205 | // existing class/data-* this function already emitted. |
| 1168 | 1206 | '<div class="panel" id="graph-panel-' + run.run_id + '"><h2>' + run.org_node + " Work Graph DAG 视图(v_graph_visualizer,run_id=" + run.run_id + ")</h2>" |
| 1169 | - + '<div class="work-graph">' + rowsHtml + "</div>" | |
| 1170 | - + '<div class="legend" style="margin-top:14px;">' + GRAPH_LEGEND_HTML + "</div>" | |
| 1171 | - + "</div>" | |
| 1207 | + + '<div class="work-graph" data-on-failure-edges="' + edgesDataJson + '">' | |
| 1208 | + + '<svg class="graph-edges-svg"></svg>' | |
| 1209 | + + rowsHtml + "</div>" | |
| 1210 | + + '<div class="legend" style="margin-top:14px;">' + GRAPH_LEGEND_HTML | |
| 1211 | + + (onFailureEdges.length ? '<div class="legend-item"><span class="edge-sample edge-on-failure-sample"></span>loop_back(失败回路)</div>' : "") | |
| 1212 | + + "</div></div>" | |
| 1172 | 1213 | ); |
| 1173 | 1214 | } |
| 1174 | 1215 | |
| 1216 | +// 2026-08-08 (Work Graph self-planning + loop-back): draws the on_failure | |
| 1217 | +// edges renderGraphVisualizerPanel embedded as data-on-failure-edges -- | |
| 1218 | +// requires REAL layout (getBoundingClientRect), so it can only run after | |
| 1219 | +// the panel's HTML is actually in the document, same reason | |
| 1220 | +// mountGraphVisualizerTooltips is a separate mount step rather than | |
| 1221 | +// inline in the render* function. A .work-node's real width depends on | |
| 1222 | +// its content, so positions can't be precomputed at render time. | |
| 1223 | +function mountGraphVisualizerEdges(container) { | |
| 1224 | + for (const graph of container.querySelectorAll(".work-graph[data-on-failure-edges]")) { | |
| 1225 | + let edges; | |
| 1226 | + try { | |
| 1227 | + edges = JSON.parse(graph.dataset.onFailureEdges || "[]"); | |
| 1228 | + } catch (e) { | |
| 1229 | + edges = []; | |
| 1230 | + } | |
| 1231 | + const svg = graph.querySelector(".graph-edges-svg"); | |
| 1232 | + if (!svg || edges.length === 0) continue; | |
| 1233 | + | |
| 1234 | + const graphRect = graph.getBoundingClientRect(); | |
| 1235 | + svg.setAttribute("width", graphRect.width); | |
| 1236 | + svg.setAttribute("height", graphRect.height); | |
| 1237 | + if (!svg.querySelector("defs")) { | |
| 1238 | + svg.innerHTML = | |
| 1239 | + '<defs><marker id="arrow-on-failure" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">' | |
| 1240 | + + '<path d="M0,0 L6,3 L0,6 Z" fill="#dc2626"></path></marker></defs>'; | |
| 1241 | + } | |
| 1242 | + | |
| 1243 | + const nodeCenter = (nodeId) => { | |
| 1244 | + const el = graph.querySelector('.work-node[data-node-id="' + CSS.escape(nodeId) + '"]'); | |
| 1245 | + if (!el) return null; | |
| 1246 | + const r = el.getBoundingClientRect(); | |
| 1247 | + return { x: r.left - graphRect.left + r.width / 2, y: r.top - graphRect.top + r.height / 2, w: r.width, h: r.height }; | |
| 1248 | + }; | |
| 1249 | + | |
| 1250 | + const linesHtml = edges.map((edge) => { | |
| 1251 | + const from = nodeCenter(edge.from); | |
| 1252 | + const to = nodeCenter(edge.to); | |
| 1253 | + if (!from || !to) return ""; | |
| 1254 | + // pull the endpoint back to the target node's edge (roughly, using | |
| 1255 | + // its own half-height) so the arrowhead doesn't render buried | |
| 1256 | + // underneath the node box. | |
| 1257 | + const dy = to.y - from.y; | |
| 1258 | + const pullBack = dy === 0 ? 0 : Math.min(to.h / 2, Math.abs(dy) / 2) * Math.sign(dy); | |
| 1259 | + const label = edge.triggerCount > 0 ? edge.triggerCount + "x" : "0x(已声明,未触发)"; | |
| 1260 | + return ( | |
| 1261 | + '<line x1="' + from.x + '" y1="' + from.y + '" x2="' + to.x + '" y2="' + (to.y - pullBack) | |
| 1262 | + + '" class="edge-on-failure" marker-end="url(#arrow-on-failure)"></line>' | |
| 1263 | + + '<text x="' + (from.x + to.x) / 2 + '" y="' + (from.y + to.y) / 2 | |
| 1264 | + + '" class="edge-label">' + escapeHtml(label) + "</text>" | |
| 1265 | + ); | |
| 1266 | + }).join(""); | |
| 1267 | + svg.innerHTML = svg.querySelector("defs").outerHTML + linesHtml; | |
| 1268 | + } | |
| 1269 | +} | |
| 1270 | + | |
| 1175 | 1271 | function mountGraphVisualizerTooltips(container) { |
| 1176 | 1272 | const tooltip = document.getElementById("graph-tooltip"); |
| 1177 | 1273 | if (!tooltip) return; |
| ... | ... | @@ -1481,6 +1577,7 @@ function mountTaskDetail(taskId, initialOrgNode) { |
| 1481 | 1577 | }); |
| 1482 | 1578 | } |
| 1483 | 1579 | mountGraphVisualizerTooltips(content); |
| 1580 | + mountGraphVisualizerEdges(content); | |
| 1484 | 1581 | |
| 1485 | 1582 | // Org Graph 三节点条点击可达详情: clicking a node here (already on |
| 1486 | 1583 | // v_task_detail) stays on this page -- update focusedOrgNode + the URL |
| ... | ... | @@ -1777,6 +1874,7 @@ function mountRunDetail(runId) { |
| 1777 | 1874 | } |
| 1778 | 1875 | mountForkPanels(content); |
| 1779 | 1876 | mountGraphVisualizerTooltips(content); |
| 1877 | + mountGraphVisualizerEdges(content); | |
| 1780 | 1878 | |
| 1781 | 1879 | if (!socket) { |
| 1782 | 1880 | socket = connectEventSocket(projectId, run.task_id, { | ... | ... |
src/task_query.py
| ... | ... | @@ -169,12 +169,36 @@ def get_task(conn, task_id): |
| 169 | 169 | # run was forked from a historical node (src/work_graph_fork.py). |
| 170 | 170 | "parent_run_id": w[6], "fork_of_node_exec_id": w[7], |
| 171 | 171 | "nodes": _work_nodes_for_run(conn, w[0]), |
| 172 | + # 2026-08-08 (Work Graph self-planning + loop-back): the console's | |
| 173 | + # v_graph_visualizer needs this to actually draw a loop_back's | |
| 174 | + # on_failure edge / a retry_self node's "sent back Nx" badge -- | |
| 175 | + # neither is derivable from graph_manifest/work_node_execution | |
| 176 | + # alone (loop_back_triggered is execution_event-only by design, | |
| 177 | + # see docs/todo/work-graph-self-planning-and-loop-back-design.md §7). | |
| 178 | + "loop_back_events": _loop_back_events_for_run(conn, w[0]), | |
| 172 | 179 | } |
| 173 | 180 | for w in runs |
| 174 | 181 | ] |
| 175 | 182 | return task |
| 176 | 183 | |
| 177 | 184 | |
| 185 | +def _loop_back_events_for_run(conn, run_id): | |
| 186 | + rows = conn.execute( | |
| 187 | + "SELECT payload, created_at FROM execution_event " | |
| 188 | + "WHERE run_id = ? AND event_type = 'loop_back_triggered' ORDER BY created_at", | |
| 189 | + (run_id,), | |
| 190 | + ).fetchall() | |
| 191 | + events = [] | |
| 192 | + for payload, created_at in rows: | |
| 193 | + data = json.loads(payload) | |
| 194 | + events.append({ | |
| 195 | + "source_node_id": data["source_node_id"], "target_node_id": data["target_node_id"], | |
| 196 | + "attempt_number": data["attempt_number"], "feedback": data.get("feedback") or [], | |
| 197 | + "created_at": created_at, | |
| 198 | + }) | |
| 199 | + return events | |
| 200 | + | |
| 201 | + | |
| 178 | 202 | def _work_nodes_for_run(conn, run_id): |
| 179 | 203 | # input_schema_ref/output_schema_ref/quota_limit_type/quota_wait_started_at |
| 180 | 204 | # added for U-026's hover tooltip + waiting_for_quota display (02-V §2.7: | ... | ... |
src/test_task_query.py
| ... | ... | @@ -146,6 +146,44 @@ class GetTaskTest(TaskQueryTestBase): |
| 146 | 146 | self.assertIsNone(run["parent_run_id"]) |
| 147 | 147 | self.assertIsNone(run["fork_of_node_exec_id"]) |
| 148 | 148 | |
| 149 | + def test_includes_loop_back_events_for_the_run(self): | |
| 150 | + # 2026-08-08 (Work Graph self-planning + loop-back): v_graph_visualizer | |
| 151 | + # needs this to draw an on_failure edge/retry badge -- not derivable | |
| 152 | + # from graph_manifest or work_node_execution alone. | |
| 153 | + self._insert_task("task-1") | |
| 154 | + self.conn.execute( | |
| 155 | + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) " | |
| 156 | + "VALUES ('run-1', 'task-1', 'planner', '{}', 'running')" | |
| 157 | + ) | |
| 158 | + self.conn.execute( | |
| 159 | + "INSERT INTO execution_event (event_id, project_id, task_id, run_id, event_type, payload) " | |
| 160 | + "VALUES ('evt-1', 'proj-1', 'task-1', 'run-1', 'loop_back_triggered', ?)", | |
| 161 | + (json.dumps({ | |
| 162 | + "source_node_id": "benchmark", "target_node_id": "benchmark", | |
| 163 | + "attempt_number": 1, "feedback": ["not enough detail"], | |
| 164 | + }),), | |
| 165 | + ) | |
| 166 | + self.conn.commit() | |
| 167 | + | |
| 168 | + result = task_query.get_task(self.conn, "task-1") | |
| 169 | + run = result["work_graph_runs"][0] | |
| 170 | + self.assertEqual(len(run["loop_back_events"]), 1) | |
| 171 | + event = run["loop_back_events"][0] | |
| 172 | + self.assertEqual(event["source_node_id"], "benchmark") | |
| 173 | + self.assertEqual(event["target_node_id"], "benchmark") | |
| 174 | + self.assertEqual(event["attempt_number"], 1) | |
| 175 | + self.assertEqual(event["feedback"], ["not enough detail"]) | |
| 176 | + | |
| 177 | + def test_no_loop_back_events_is_an_empty_list_not_missing_key(self): | |
| 178 | + self._insert_task("task-1") | |
| 179 | + self.conn.execute( | |
| 180 | + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) " | |
| 181 | + "VALUES ('run-1', 'task-1', 'planner', '{}', 'done')" | |
| 182 | + ) | |
| 183 | + self.conn.commit() | |
| 184 | + result = task_query.get_task(self.conn, "task-1") | |
| 185 | + self.assertEqual(result["work_graph_runs"][0]["loop_back_events"], []) | |
| 186 | + | |
| 149 | 187 | def test_forked_run_surfaces_parent_and_source_node(self): |
| 150 | 188 | # D (2026-08-06): v_task_detail must be able to tell a fork branch |
| 151 | 189 | # apart from the original run it descends from. | ... | ... |