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.
This commit is contained in:
parent
45cdd62116
commit
44286996ed
1 changed files with 89 additions and 23 deletions
|
|
@ -178,6 +178,76 @@ function entryAgents(entry) {
|
||||||
return seen.join(',');
|
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) {
|
function rebuildQueueEntryFingerprint(entry, isChild) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
state: entry.state,
|
state: entry.state,
|
||||||
|
|
@ -308,33 +378,29 @@ function renderQueueEntry(entry, _byId, isChild) {
|
||||||
// node-aware render that makes queue jumps / partial progress
|
// node-aware render that makes queue jumps / partial progress
|
||||||
// visible (e.g. reconcile running while swap failed).
|
// visible (e.g. reconcile running while swap failed).
|
||||||
//
|
//
|
||||||
// A DAG can span multiple agents (e.g. a hive-wide restart) as
|
// A DAG's actual shape is its `deps` graph, not an incidental property
|
||||||
// independent per-agent subgraphs with no cross-agent deps — they run
|
// like `n.agent` — render *that* structure (via nodeComponents, which
|
||||||
// concurrently, not sequentially. Joining every node in array order
|
// splits on `deps` and topo-sorts each piece), not a heuristic grouping.
|
||||||
// with a single `→` chain misrepresents that as one long sequential
|
// A DAG made of independent subgraphs (e.g. a multi-agent restart, no
|
||||||
// pipeline. Group by `n.agent` (stable, first-seen order) and render
|
// cross-agent deps) naturally comes back as multiple components and
|
||||||
// each agent's subgraph on its own line instead; single-agent DAGs
|
// gets one line each; a single connected DAG (the common case) stays
|
||||||
// (the common case) collapse back to exactly the old one-line render.
|
// one component and renders exactly as the old one-line chain did.
|
||||||
const nodes = entry.nodes || [];
|
const nodes = entry.nodes || [];
|
||||||
if (nodes.length) {
|
if (nodes.length) {
|
||||||
const groups = [];
|
const components = nodeComponents(nodes);
|
||||||
const groupByAgent = new Map();
|
const multi = components.length > 1;
|
||||||
for (const n of nodes) {
|
for (const compNodes of components) {
|
||||||
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 chain = el('div', { class: 'rqe-nodes' });
|
const chain = el('div', { class: 'rqe-nodes' });
|
||||||
if (multiAgent) {
|
if (multi) {
|
||||||
chain.append(el('code', { class: 'rqe-node-agent-label' }, agent));
|
// 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' }, ' → '));
|
if (i > 0) chain.append(el('span', { class: 'rqe-node-arrow' }, ' → '));
|
||||||
const chip = el('span', {
|
const chip = el('span', {
|
||||||
class: 'rqe-node rqe-node-' + n.state,
|
class: 'rqe-node rqe-node-' + n.state,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue