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.
This commit is contained in:
iris 2026-07-15 01:18:15 +02:00 committed by mara
commit 45cdd62116
2 changed files with 47 additions and 18 deletions

View file

@ -307,27 +307,52 @@ 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 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.
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 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]);
}
});
li.append(chain);
g.push(n);
}
const multiAgent = groups.length > 1;
for (const [agent, groupNodes] of groups) {
const chain = el('div', { class: 'rqe-nodes' });
if (multiAgent) {
chain.append(el('code', { class: 'rqe-node-agent-label' }, agent));
}
groupNodes.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;