Compare commits

...
Author SHA1 Message Date
iris
44286996ed fix(#2465): split node chain on deps graph, not agent field
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.
2026-07-15 18:23:47 +02:00
iris
45cdd62116 fix(#2465): render multi-agent DAG nodes as per-agent subgraph lines
renderQueueEntry flattened entry.nodes into one arrow-joined chain
regardless of which agent each node belongs to. A multi-agent DAG
(e.g. hivectl restart --graceful with several agents) runs independent
per-agent subgraphs concurrently, no cross-agent deps, so joining them
all into one sequential-looking chain misrepresented the actual DAG
shape (mara's report: 'expected one dag that forks after the start
node into the per agent sub dags').

Group nodes by n.agent (stable, first-seen order) and render one
.rqe-nodes line per agent, with a small agent-label chip when the DAG
spans more than one. Single-agent DAGs (the common case) collapse back
to exactly the prior one-line render — no visible change there.
2026-07-15 18:23:47 +02:00
2 changed files with 113 additions and 18 deletions

View file

@ -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,
@ -307,27 +377,48 @@ function renderQueueEntry(entry, _byId, isChild) {
// state glyph, live step label, and build-log link. This is the
// node-aware render that makes queue jumps / partial progress
// visible (e.g. reconcile running while swap failed).
//
// 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 chain = el('div', { class: 'rqe-nodes' });
nodes.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,
title: n.kind + ' · ' + n.state + (n.error ? ' — ' + n.error : ''),
},
(QUEUE_STATE_GLYPH[n.state] || '?') + ' ' + (NODE_KIND_LABEL[n.kind] || n.kind));
chain.append(chip);
if (n.build_log_id != null) {
chain.append(el('a', {
class: 'rqe-log-link rqe-node-log',
href: '/builds.html?id=' + n.build_log_id + '#buildlogs',
target: '_blank',
title: 'view build log #' + n.build_log_id,
}, '⎙'));
const components = nodeComponents(nodes);
const multi = components.length > 1;
for (const compNodes of components) {
const chain = el('div', { class: 'rqe-nodes' });
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(',')));
}
});
li.append(chain);
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,
title: n.kind + ' · ' + n.state + (n.error ? ' — ' + n.error : ''),
},
(QUEUE_STATE_GLYPH[n.state] || '?') + ' ' + (NODE_KIND_LABEL[n.kind] || n.kind));
chain.append(chip);
if (n.build_log_id != null) {
chain.append(el('a', {
class: 'rqe-log-link rqe-node-log',
href: '/builds.html?id=' + n.build_log_id + '#buildlogs',
target: '_blank',
title: 'view build log #' + n.build_log_id,
}, '⎙'));
}
});
li.append(chain);
}
}
const running = runningNode(entry);
if (running && running.step) {

View file

@ -161,6 +161,10 @@
.rqe-node-cancelled { opacity: 0.55; text-decoration: line-through; }
.rqe-node-arrow { color: var(--muted); }
.rqe-node-log { margin-left: 0.1em; text-decoration: none; }
/* Per-agent subgraph label, shown only when a DAG's nodes span more than
one agent (independent concurrent subgraphs, one `.rqe-nodes` line each
see renderQueueEntry in builds.js). */
.rqe-node-agent-label { color: var(--amber); margin-right: 0.3em; }
.rqe-step {
flex-basis: 100%;
margin: 0.1em 0 0 1.8em;