Commit ed477539aa5fd9f0097247ad39c1a056f7a9d897

Authored by graphlemon
1 parent 04aa7971

graphlemon: auto-commit uncommitted changes for 3e75741a-2cbb-4f80-90c6-8804b94f…

…76ae-main-checkout-drift
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.
... ...