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

@ -870,54 +870,59 @@ fetch entirely.
`config` link — remain visible regardless of run state.
When the container is running, status badges follow — `⊘ rate
limited` (red, while the harness is parked after a 429), `needs
login`, `needs update` — in-flight `◐ pending-state…` pill
(replaces buttons during operator-initiated start / stop /
restart / rebuild / destroy). Additionally, when a rebuild-queue
entry for this agent is `queued` or `running` but no
operator-initiated transient is set, the card surfaces a
`building…` / `meta-updating…` / `starting…` / `stopping…` badge
(per queue `kind` — e.g. a `start` entry shows `starting…` /
`start queued`, `stop` and `graceful_stop` both show `stopping…` /
`stop queued`) sourced from `rebuildQueueState` — so the SW4RM tab
shows the same progress visible on the BU1LDS page's R3BU1LD QU3U3.
The row visual splits queued vs running: a **queued** entry shows
only the pending-state pill (no row tint, so a long queue doesn't
paint half the tab amber); a **running** entry keeps the amber
row tint AND draws a **rotating amber ring** around the agent
icon, so it's obvious at a glance which container is actually
moving.
**Pending-state derivation:** the pill is sourced from two
separate stores in priority order. (1) The **transient**
(`transientsState`) — covers the create-and-start window where the
container literally isn't up yet, before any backend state event
has fired.
login`, `needs update` — plus **one `◐ pending-state…` pill per
active transient** (replaces buttons during operator-initiated
start / stop / restart / rebuild / destroy). An agent can carry
**several transients at once** (mara: "show all running nodes that
name the agent") — e.g. a lease-exempt `prebuild` running alongside
a `stop_for_update` on the same agent — and each renders as its own
independent badge rather than being collapsed into one label,
matching the existing multi-badge convention this line already uses
for `paused`/`needs_update`/model/ctx.
The row visual splits queued vs running: a **queued** entry (no
transient yet, see below) shows only the pending-state pill (no row
tint, so a long queue doesn't paint half the tab amber); a
**running** entry (at least one transient) keeps the amber row tint
AND draws a **rotating amber ring** around the agent icon, so it's
obvious at a glance which container is actually moving.
A transient is **derived from the job-queue node currently
running** against that agent, not declared per request, so its
label follows the operation as it progresses (a rebuild reads
`stop_for_update`, then `swap`, then `reconcile` rather than one
constant `rebuilding` for its whole life). Two consequences for
anything rendering it:
**Pending-badge derivation:** two separate stores, but no longer a
priority *order* between them — the second only ever applies when
the first has nothing to say. (1) **Transients**
(`transientsState`, keyed `agent -> Map<kind, since_unix>`) — a
transient is **derived from a job-queue node currently `Running`**
against that agent, not declared per request, so its label follows
the operation as it progresses (a rebuild reads `stop_for_update`,
then `swap`, then `reconcile` rather than one constant `rebuilding`
for its whole life). Two consequences for anything rendering it:
- The label vocabulary is **open** — it is the node's own wire tag
(`NodeKind::as_str`, the same strings `NodeView.kind` carries),
not a fixed set. Treat it as an opaque display string; do not
switch on specific values. `restarting` in particular no longer
exists, because no node kind is unique to a restart.
- It is **not** exclusively operator-initiated. Work the operator
never clicked (a meta-update cascade, a crash-recover rebuild)
lights the same pill, since it is the running node that sets it.
- It is **not** exclusively operator-initiated, and **not** limited
to rebuild-shaped work — `running_transients()` on the backend is
a status-only test (any `Running` node whose payload names a
non-empty agent lights a pill), so work the operator never
clicked (a meta-update cascade, a crash-recover rebuild, a
lease-exempt `prebuild`) lights the same mechanism.
Ops with no queue node behind them (destroy, migration) supply
their own label directly. (2) If no transient is set, the **rebuild-queue
entry** for this agent is consulted (`rebuildQueueState`); this
covers worker-driven ops — meta-update cascades, crash-recover
rebuilds, approval-driven rebuilds — that the operator didn't
click. `ContainerStateChanged` carries neither signal, so the
dashboard reads from the two snapshots directly. The
`opRunning` flag (driving the `pending-running` row class +
spinner) is true when (1) is set OR (2) is in `running` state;
queued entries leave `opRunning` false.
their own label directly via `TransientSet`/`TransientCleared`
events carrying no backing node at all.
(2) The **rebuild-queue fallback** (`rebuildQueueState`) only
fires when an agent has **zero** transients — since (1) now covers
every `Running` node unconditionally, a `Running` rebuild-queue
entry can never usefully reach this fallback by the time it's
consulted; the fallback exists purely for the **`Pending`
(queued, not yet started)** case, which `running_transients()`'s
Running-only test cannot represent. `queuedOpsByAgent()` (swarm.js)
reflects this: it only ever looks at `Pending`-state queue nodes.
`opRunning` (driving the `pending-running` row class + spinner) is
simply "does this agent have at least one transient" — a queued-only
entry (no transient yet) leaves it false.
An **active model badge** (`model · <name>`, blue) appears when the
container is running and the harness has persisted a model name
(`harness/hyperhive-model`). Read by hive-c0re's `ContainerView`