fix(#2492): split rebuild-queue DAG lines on fan-out points, not just WCC

nodeComponents() split a DAG into weakly-connected components via deps
edges, but post #2476/#2450 every agent's rebuild subgraph hangs off a
shared MetaLock node via AfterOk, so the whole meta-update cascade is one
connected component and rendered as a single wall-of-chips line.

Add a second pass (splitFanOut) that further splits a component's
topo-ordered nodes on out-degree>1 points: a node with more than one
direct dependent renders as its own one-node line, and each dependent
becomes the root of an independent line. Purely deps-structure-driven,
same as the existing WCC split - no agent-field grouping involved. A
component with no fan-out (the common single-agent case) is unaffected.
This commit is contained in:
iris 2026-07-15 23:10:33 +02:00
commit 990f021c32

View file

@ -178,6 +178,64 @@ function entryAgents(entry) {
return seen.join(',');
}
// Further split one weakly-connected component's topo-ordered nodes on
// *fan-out* points — a node with more than one direct dependent — so a
// shared gate/lock node (e.g. MetaLock, which every agent's rebuild
// subgraph now hangs off via AfterOk since the meta-update cascade was
// folded into one in-DAG growth instead of separate per-agent child DAGs)
// doesn't merge N otherwise-independent per-agent chains into one
// wall-of-chips line. Pure
// `deps`-structure-driven, same as the WCC split above — no `agent` field
// involved. Rule: a node with out-degree > 1 renders as its own one-node
// line; each of its direct dependents becomes the root of an independent
// line, walked forward until the next fan-out point or a dead end. A
// component with no fan-out (the common single-agent case) comes back
// unchanged as one line.
function splitFanOut(orderedNodes) {
const ids = new Set(orderedNodes.map((n) => n.id));
const children = new Map(orderedNodes.map((n) => [n.id, []])); // dep -> direct dependents
for (const n of orderedNodes) {
for (const d of n.deps || []) {
if (children.has(d)) children.get(d).push(n.id);
}
}
const byId = new Map(orderedNodes.map((n) => [n.id, n]));
const indeg = new Map(orderedNodes.map((n) => [n.id, 0]));
for (const n of orderedNodes) {
for (const d of n.deps || []) {
if (ids.has(d)) indeg.set(n.id, indeg.get(n.id) + 1);
}
}
const roots = orderedNodes.filter((n) => indeg.get(n.id) === 0).map((n) => n.id);
const lines = [];
const visited = new Set();
function walk(startId, chain) {
let cur = startId;
while (cur != null && !visited.has(cur)) {
visited.add(cur);
const kids = children.get(cur) || [];
if (kids.length > 1) {
if (chain.length) lines.push(chain);
lines.push([byId.get(cur)]);
for (const k of kids) walk(k, []);
return;
}
chain.push(byId.get(cur));
cur = kids.length === 1 ? kids[0] : null;
}
if (chain.length) lines.push(chain);
}
for (const r of roots) walk(r, []);
// Any leftover (shouldn't happen for a DAG reachable from its roots, but
// guard against a dep loop / disconnected leftover rather than dropping
// nodes from the display).
for (const n of orderedNodes) {
if (!visited.has(n.id)) lines.push([n]);
}
return lines.length ? lines : [orderedNodes];
}
// 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
@ -185,7 +243,9 @@ function entryAgents(entry) {
// 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.
// order rather than raw array order. Each component is then further split
// on fan-out points (see `splitFanOut`) so a shared gate node doesn't merge
// independent branches into one line.
function nodeComponents(nodes) {
if (!nodes.length) return [];
const byId = new Map(nodes.map((n) => [n.id, n]));
@ -243,7 +303,7 @@ function nodeComponents(nodes) {
for (const cn of compNodes) {
if (remaining.has(cn.id)) ordered.push(cn);
}
components.push(ordered);
for (const line of splitFanOut(ordered)) components.push(line);
}
return components;
}