frontend: swarm.js off rebuild-queue-derived in-flight status onto transients

Fixes #2822.

`swarm.js` had two independent per-agent "is this in flight" sources:
`transientsState` (operator/worker-initiated ops the backend chose to
flag) and `inFlightOpsByAgent()`, a separate derivation straight from
`rebuildQueueState` covering everything else. Since #3010/#3016,
`running_transients()` is a status-only test — any `Running` job-queue
node naming a non-empty agent lights a transient pill, not just a
curated subset — so the second source's Running-state handling is now
provably redundant: a Running node with an agent always already has a
transient by the time `queuedOpsByAgent()` (renamed from
`inFlightOpsByAgent`) would be consulted.

## What changed

- `transientsState`: `Map<name, {kind, since_unix}>` (one pill per
  agent) -> `Map<name, Map<kind, since_unix>>` (several pills per
  agent). `applyTransientSet`/`applyTransientCleared` now add/remove
  by `(name, kind)` rather than overwrite/delete by name alone, using
  `TransientCleared`'s `transient_kind` field (landed in #3016) to
  know which pill cleared. `syncTransientsFromSnapshot` groups the
  now-flat `TransientView` list by name instead of assuming one row
  per agent.
- `inFlightOpsByAgent()` -> `queuedOpsByAgent()`: trimmed to the
  `Pending` (queued, not yet started) case only. The `Running` branch
  and its "running beats queued" priority logic are gone entirely —
  dead weight now that transients cover every running case
  unconditionally.
- Render loop: an agent's transients win outright whenever any exist
  (rendered as **one badge per pill**, not collapsed into one label —
  mara: "show all running nodes that name the agent"); the queued
  fallback only applies when a agent has zero transients. `opRunning`
  simplifies to "does this agent have at least one transient".
- `docs/web-ui/dashboard.md`'s Container-row section rewritten to
  match — it described a "transient, then in-flight-queue, in
  priority order" model that's no longer accurate now that the second
  source only ever fires for the one case the first can't represent.

## Verification

`npm run build` clean for both packages (dashboard + agent). Standalone
re-derivation of the transient-map + queued-fallback logic
(`/tmp/verify-swarm-transients.mjs`, not part of this diff) run against
constructed event sequences: single-pill lifecycle, two simultaneous
pills on one agent with independent clear-by-kind, clearing an unknown
kind is a safe no-op, a flat snapshot with duplicate agent names groups
correctly, the queued fallback only fires when no transient exists and
steps aside the instant one arrives, and a Running-state rebuild-queue
entry produces no queued badge (confirming the Pending-only trim is
correct, not just assumed). All 17 checks passed.

Verified directly against the merged backend rather than trusting
summaries: `job_queue/mod.rs::running_transients()` filters
`State::Running` only (not Pending — an earlier note of mine claiming
otherwise was imprecise paraphrasing), and `NodeView.agent` /
`running_transients()`'s agent both resolve through the same
`payload.agent()`, so a Running node's presence in `rebuild_queue`
and its presence as a transient are guaranteed consistent, not just
usually so.

#2985 (DagView/NodeView deletion) unblocks once this merges — atlas is
waiting on a ping.
This commit is contained in:
iris 2026-08-03 18:41:34 +02:00 committed by mara
commit d1f82e725e
2 changed files with 138 additions and 113 deletions

View file

@ -37,8 +37,13 @@ const containerRowCache = new Map();
// Derived transient state — cold-loaded from /api/state.transients,
// then mutated live by `transient_set` / `transient_cleared`. Keyed
// by agent name so add/remove are O(1). `since_unix` is wall-clock so
// the elapsed-seconds badge ticks without polling.
// by agent name -> Map<transient_kind, since_unix>: an agent can hold
// several pills at once (every *running* node naming the agent lights
// one now, not just a curated "worth surfacing" subset — see
// docs/web-ui.md::Container row), keyed the same way the backend's
// own tombstone map is, `(agent, label)`, so a clear only removes the
// specific pill it names rather than guessing. `since_unix` is
// wall-clock so the elapsed-seconds badge ticks without polling.
const transientsState = new Map();
// In-memory set of selected agent logical names backing the sticky
@ -54,77 +59,99 @@ export function syncRebuildQueueFromSnapshot(s) {
}
export function applyRebuildQueueChanged(ev) {
rebuildQueueState = (ev.queue || []).slice();
// Re-render the SW4RM tab so newly-queued / newly-running
// rebuild-queue ops light up the right card with a building...
// badge, and finished ops fall back to the regular state badges.
// Re-render the SW4RM tab so newly-queued ops light up the right
// card with a "<kind> queued…" badge, and entries that drop out of
// Pending fall back to whatever transientsState (or nothing) says
// instead. Running work is *not* driven by this event — that's
// transient_set/transient_cleared's job, since every running node
// naming an agent already lights a pill by the time it gets here.
// See docs/web-ui.md::Container row for the badge taxonomy.
renderContainersFromState();
}
// Map from agent name -> highest-priority in-flight op ({ kind, state })
// (`running` beats `queued`). Used by the container row renderer to
// surface "building..." / "meta-updating..." badges on the SW4RM tab
// when an op is still in the rebuild queue but no operator-initiated
// transient is set.
// Map from agent name -> the queued op ({ kind }) backing the SW4RM
// card's "<kind> queued" badge. Pending only — every *running* node
// naming an agent already lights a transient pill (any running node,
// not just a curated "worth it" subset — see docs/web-ui.md::Container
// row), so a Running entry here would always be redundant with
// `transientsState` by the time this is consulted. Queued (not yet
// started) work is the one state transients can't represent, since
// `running_transients()` on the backend is a Running-only test.
//
// Agent is per-node, not per-DAG (a DAG can span agents — e.g. the
// startup sweep's MetaLock cascade, or a hive-wide restart), so this
// derives each agent's in-flight state from its own node(s) within the
// entry rather than the DAG's overall `state`/`kind` — a DAG can be
// `running` overall while a given agent's subgraph hasn't started yet
// (still queued behind an earlier node in its chain), and vice versa.
function inFlightOpsByAgent() {
// derives each agent's queued state from its own node(s) within the
// entry rather than the DAG's overall `state`/`kind`.
function queuedOpsByAgent() {
const out = new Map();
for (const e of rebuildQueueState) {
if (e.state !== 'Pending' && e.state !== 'Running') continue;
if (e.state !== 'Pending') continue;
// spawn ops target an agent that doesn't exist yet as a
// container — the transient store already drives the
// pending row for that case. Skip here to avoid double-
// surfacing if the spawn op happens to land in the queue
// while the row exists transiently.
if (e.kind === 'spawn') continue;
const perAgentState = new Map();
for (const n of e.nodes || []) {
if (!n.agent) continue;
if (n.state !== 'Pending' && n.state !== 'Running') continue;
const cur = perAgentState.get(n.agent);
if (!cur || (cur === 'Pending' && n.state === 'Running')) {
perAgentState.set(n.agent, n.state);
}
}
for (const [agent, state] of perAgentState) {
const cur = out.get(agent);
if (!cur || (cur.state === 'Pending' && state === 'Running')) {
out.set(agent, { kind: e.kind, state });
}
if (!n.agent || n.state !== 'Pending') continue;
// First entry found wins — with only one state to consider
// (Pending), there's no priority to resolve between DAGs.
if (!out.has(n.agent)) out.set(n.agent, { kind: e.kind });
}
}
return out;
}
// "<kind> queued" text for the queuedOpsByAgent fallback badge. Only
// ever applies to this fallback — a transient's own kind is an opaque
// wire tag (docs/web-ui.md::Container row) displayed as-is, never
// run through a lookup like this one.
function queuedLabelFor(kind) {
return kind === 'meta_update' ? 'meta-update queued'
: kind === 'destroy' ? 'destroy queued'
: kind === 'restart' ? 'restart queued'
: kind === 'start' ? 'start queued'
: kind === 'stop' ? 'stop queued'
: kind === 'graceful_stop' ? 'stop queued'
: kind === 'reconcile' ? 'reconcile queued'
: 'rebuild queued';
}
// ─── transients ─────────────────────────────────────────────────────────────
export function syncTransientsFromSnapshot(s) {
transientsState.clear();
const nowUnix = Math.floor(Date.now() / 1000);
for (const t of s.transients || []) {
// Snapshot is a flat list — multiple rows CAN share the same
// `name` (one row per running node) — so group into this agent's
// kind map rather than overwrite.
let byKind = transientsState.get(t.name);
if (!byKind) {
byKind = new Map();
transientsState.set(t.name, byKind);
}
// Snapshot ships `secs` (server-computed); reconstruct an
// approximate since_unix so the live ticker keeps progressing
// without surprising jumps when the next snapshot lands.
const nowUnix = Math.floor(Date.now() / 1000);
transientsState.set(t.name, {
kind: t.kind,
since_unix: t.since_unix ?? (nowUnix - (t.secs || 0)),
});
byKind.set(t.kind, t.since_unix ?? (nowUnix - (t.secs || 0)));
}
}
export function applyTransientSet(ev) {
transientsState.set(ev.name, {
kind: ev.transient_kind,
since_unix: ev.since_unix,
});
let byKind = transientsState.get(ev.name);
if (!byKind) {
byKind = new Map();
transientsState.set(ev.name, byKind);
}
byKind.set(ev.transient_kind, ev.since_unix);
renderContainersFromState();
}
export function applyTransientCleared(ev) {
if (transientsState.delete(ev.name)) renderContainersFromState();
const byKind = transientsState.get(ev.name);
if (!byKind || !byKind.delete(ev.transient_kind)) return;
// Drop the now-empty outer entry too — keeps `transientsState.size`
// meaning "agents with at least one pill" (checked at the
// no-containers-and-no-transients empty-state gate below).
if (byKind.size === 0) transientsState.delete(ev.name);
renderContainersFromState();
}
// Re-render using the last cached snapshot (containers come from
// /api/state, transients overlay from the derived map). The snapshot
@ -327,7 +354,7 @@ function buildContainerLi(c, node, opts) {
} = opts;
const li = el('li', {
class: 'container-row'
+ (pending ? ' pending' : '')
+ (pending.length ? ' pending' : '')
+ (opRunning ? ' pending-running' : '')
+ (selected ? ' selected' : ''),
});
@ -489,9 +516,14 @@ function buildContainerLi(c, node, opts) {
// badge. `needs_login` is still c0re-owned (reads auth sentinel
// files on the host). rate_limited / ctx / status_text are
// agent-owned and rendered by the async dashboard-state fetch above.
if (pending) {
head.append(el('span', { class: 'pending-state' },
el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
if (pending.length) {
// One badge per pill — an agent can carry several transients at
// once now (see docs/web-ui.md::Container row), each rendered
// independently rather than collapsed into one label.
for (const label of pending) {
head.append(el('span', { class: 'pending-state' },
el('span', { class: 'spinner' }, '◐'), ' ', label + '…'));
}
} else if (!c.running) {
head.append(el('span',
{ class: 'badge badge-muted', title: 'container is shut down — start it to bring the harness back up' },
@ -648,11 +680,10 @@ export function renderContainers(s) {
const forgeBase = (s && s.forge_public_url) || null;
const ul = existingUl ?? el('ul', { class: 'containers' });
const tree = buildAgentTree(containers);
// In-flight rebuild / meta-update / destroy ops per agent name —
// see docs/web-ui.md::Container row for the building... badge
// rationale (covers the SYST3M-shows-rebuild-but-SW4RM-shows-stopped
// gap when no operator transient is set).
const inFlight = inFlightOpsByAgent();
// Queued (not-yet-running) ops per agent name — see
// docs/web-ui.md::Container row for why this only covers the
// Pending case now (Running is fully covered by transientsState).
const queuedOps = queuedOpsByAgent();
// Build the ordered list of <li> elements, reusing cached rows
// whose displayed state hasn't changed.
@ -666,31 +697,20 @@ export function renderContainers(s) {
const containerBase = gatewayLinks
? `/agent/${encodeURIComponent(c.name)}`
: `http://${hostname}:${c.port}`;
// Pending-state derivation + queued-vs-running split — see
// docs/web-ui.md::Container row for the transient -> in-flight
// queue priority order and the opRunning rationale.
const transientKind = transientsState.get(c.name)?.kind || null;
const op = !transientKind ? inFlight.get(c.name) : null;
const pending = transientKind
|| (op && (op.state === 'Running'
? (op.kind === 'meta_update' ? 'meta-updating'
: op.kind === 'destroy' ? 'destroying'
: op.kind === 'restart' ? 'restarting'
: op.kind === 'start' ? 'starting'
: op.kind === 'stop' ? 'stopping'
: op.kind === 'graceful_stop' ? 'stopping'
: op.kind === 'reconcile' ? 'reconciling'
: 'rebuilding')
: (op.kind === 'meta_update' ? 'meta-update queued'
: op.kind === 'destroy' ? 'destroy queued'
: op.kind === 'restart' ? 'restart queued'
: op.kind === 'start' ? 'start queued'
: op.kind === 'stop' ? 'stop queued'
: op.kind === 'graceful_stop' ? 'stop queued'
: op.kind === 'reconcile' ? 'reconcile queued'
: 'rebuild queued')));
const opRunning = transientKind != null
|| (op != null && op.state === 'Running');
// Pending-badge derivation: an agent's transients win outright when
// any exist (rendered one badge per pill — mara: "show all running
// nodes that name the agent"), the rebuild-queue's queued-only
// fallback otherwise. See docs/web-ui.md::Container row.
const transientKindsMap = transientsState.get(c.name);
// Sorted for stable badge order across renders — Map iteration
// order is insertion order, which shifts as pills clear/re-add.
const transientKinds = transientKindsMap
? Array.from(transientKindsMap.keys()).sort() : [];
const queuedOp = transientKinds.length === 0 ? queuedOps.get(c.name) : null;
const pending = transientKinds.length > 0
? transientKinds
: (queuedOp ? [queuedLabelFor(queuedOp.kind)] : []);
const opRunning = transientKinds.length > 0;
const selected = selectionState.has(c.name);
// Pending questions where this agent is the asker (awaiting an
// answer) or the target (owes a reply). Derived live from