From 44286996edfdba842a4488e7c65e0d0b49054fdd Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 15 Jul 2026 17:33:08 +0200 Subject: [PATCH] fix(#2465): split node chain on deps graph, not agent field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mara's review comment: the frontend shouldn't apply its own grouping logic on top of the DAG — it should render the structure the backend already provides. The actual structure is the nodes' deps graph, not the incidental n.agent field. Replace the group-by-agent heuristic with nodeComponents(): splits entry.nodes into weakly-connected components via the deps edges (undirected reachability), then topo-sorts each component (Kahn's algorithm) so a chain renders in true dependency order. A DAG made of independent per-agent subgraphs (no cross-agent deps) still comes back as separate components — same visual result for today's templates — but the split is now driven by what the backend actually encodes, and naturally extends to any future non-agent-aligned branching. Agent name is still shown as a per-component label, but purely as adjunct info sourced from that component's own nodes, not the grouping key. --- frontend/packages/dashboard/src/builds.js | 112 +++++++++++++++++----- 1 file changed, 89 insertions(+), 23 deletions(-) diff --git a/frontend/packages/dashboard/src/builds.js b/frontend/packages/dashboard/src/builds.js index 075236e3..8b0768bb 100644 --- a/frontend/packages/dashboard/src/builds.js +++ b/frontend/packages/dashboard/src/builds.js @@ -178,6 +178,76 @@ function entryAgents(entry) { return seen.join(','); } +// Split a DAG's nodes into its actual weakly-connected subgraphs, using the +// `deps` edges the backend provides — not an inferred heuristic like +// grouping by `n.agent`. A DAG with independent subgraphs (e.g. a +// multi-agent restart, no cross-agent deps) naturally splits into one +// component per subgraph; a single connected DAG stays one component. Each +// component's nodes come back topo-sorted (Kahn's algorithm, falling back to +// original array order for ties) so a chain renders in actual dependency +// order rather than raw array order. +function nodeComponents(nodes) { + if (!nodes.length) return []; + const byId = new Map(nodes.map((n) => [n.id, n])); + const adj = new Map(nodes.map((n) => [n.id, new Set()])); // undirected, for component split + for (const n of nodes) { + for (const d of n.deps || []) { + if (!byId.has(d)) continue; // dep outside this node set (shouldn't happen) + adj.get(n.id).add(d); + adj.get(d).add(n.id); + } + } + const seen = new Set(); + const components = []; + for (const n of nodes) { + if (seen.has(n.id)) continue; + const compIds = []; + const stack = [n.id]; + seen.add(n.id); + while (stack.length) { + const id = stack.pop(); + compIds.push(id); + for (const nb of adj.get(id)) { + if (!seen.has(nb)) { + seen.add(nb); + stack.push(nb); + } + } + } + const compSet = new Set(compIds); + const compNodes = nodes.filter((cn) => compSet.has(cn.id)); + // Topo-sort within the component via its actual `deps` edges (directed). + const indeg = new Map(compNodes.map((cn) => [cn.id, 0])); + for (const cn of compNodes) { + for (const d of cn.deps || []) { + if (compSet.has(d)) indeg.set(cn.id, indeg.get(cn.id) + 1); + } + } + const ordered = []; + const ready = compNodes.filter((cn) => indeg.get(cn.id) === 0); + const remaining = new Map(compNodes.map((cn) => [cn.id, cn])); + while (ready.length) { + const cn = ready.shift(); + if (!remaining.has(cn.id)) continue; + remaining.delete(cn.id); + ordered.push(cn); + for (const other of compNodes) { + if ((other.deps || []).includes(cn.id) && remaining.has(other.id)) { + indeg.set(other.id, indeg.get(other.id) - 1); + if (indeg.get(other.id) === 0) ready.push(other); + } + } + } + // Any leftover (cycle, or a dep outside the node set) — append in + // original order rather than dropping nodes from the display. + for (const cn of compNodes) { + if (remaining.has(cn.id)) ordered.push(cn); + } + components.push(ordered); + } + return components; +} + function rebuildQueueEntryFingerprint(entry, isChild) { return JSON.stringify({ state: entry.state, @@ -308,33 +378,29 @@ function renderQueueEntry(entry, _byId, isChild) { // node-aware render that makes queue jumps / partial progress // visible (e.g. reconcile running while swap failed). // - // A DAG can span multiple agents (e.g. a hive-wide restart) as - // independent per-agent subgraphs with no cross-agent deps — they run - // concurrently, not sequentially. Joining every node in array order - // with a single `→` chain misrepresents that as one long sequential - // pipeline. Group by `n.agent` (stable, first-seen order) and render - // each agent's subgraph on its own line instead; single-agent DAGs - // (the common case) collapse back to exactly the old one-line render. + // A DAG's actual shape is its `deps` graph, not an incidental property + // like `n.agent` — render *that* structure (via nodeComponents, which + // splits on `deps` and topo-sorts each piece), not a heuristic grouping. + // A DAG made of independent subgraphs (e.g. a multi-agent restart, no + // cross-agent deps) naturally comes back as multiple components and + // gets one line each; a single connected DAG (the common case) stays + // one component and renders exactly as the old one-line chain did. const nodes = entry.nodes || []; if (nodes.length) { - const groups = []; - const groupByAgent = new Map(); - for (const n of nodes) { - let g = groupByAgent.get(n.agent); - if (!g) { - g = []; - groupByAgent.set(n.agent, g); - groups.push([n.agent, g]); - } - g.push(n); - } - const multiAgent = groups.length > 1; - for (const [agent, groupNodes] of groups) { + const components = nodeComponents(nodes); + const multi = components.length > 1; + for (const compNodes of components) { const chain = el('div', { class: 'rqe-nodes' }); - if (multiAgent) { - chain.append(el('code', { class: 'rqe-node-agent-label' }, agent)); + if (multi) { + // Label from the component's own nodes (informational only — the + // split itself came from `deps`, not from agent). + const agents = []; + for (const n of compNodes) { + if (n.agent && !agents.includes(n.agent)) agents.push(n.agent); + } + chain.append(el('code', { class: 'rqe-node-agent-label' }, agents.join(','))); } - groupNodes.forEach((n, i) => { + compNodes.forEach((n, i) => { if (i > 0) chain.append(el('span', { class: 'rqe-node-arrow' }, ' → ')); const chip = el('span', { class: 'rqe-node rqe-node-' + n.state,