Prompted by hyperhive/hyperhive#3050: a reviewer held a formal
approve/request-changes verdict for CI to go green, which delayed a
merge that was only waiting on the review. CI, review approval, and
auto-merge arming are three independent gate conditions — a reviewer
is only responsible for the second one.
Documents the asymmetry that follows from auto-merge sometimes being
armed before a review lands: an unnecessary request-changes costs a
round-trip, but an approve given on lingering doubt can ship
immediately with no further human check. Default to holding when
unsure.
Measured after mara's "2/3 of this is docs, most of it duplicated": 243 of 380
added .rs lines were comments. The brace rationale was written out in full in
`model.rs`, the `templates.rs` module header, `quiesce`, `rebuild_subtree` and
`docs/coordinator.md` — five copies of one argument, which is why four docs
needed correcting earlier in this branch. Correcting every copy preserves the
thing that made them go stale.
`docs/coordinator.md` (_Braces_) is now the single home. The rest state what a
node *is* and point there. Also drops the per-operation DAG diagram from the
`templates.rs` header, which the same doc already carries, and cuts
`rebuild_subtree`'s node-by-node walkthrough down to the three choices a reader
would otherwise undo — the code below it is the source of truth for the shape.
Comments -59 lines, no behaviour change, 317 tests unchanged.
The `Signal` -> `Drain` pair was built in three places, in three different
shapes: siblings under `SetWanted` in `stop_chain`, `Drain` nested under a
lease-holding `Signal` in `restart_chain`, and — as of this branch — a hybrid in
`rebuild_subtree` that was brace-held like the first and nested like the second.
`templates::quiesce(builder, agent, brace)` is now the one definition, returning
the `Drain` handle a caller edges its stop onto. Both nodes hang off the brace as
dep-ordered siblings and declare nothing, borrowing the lease it already holds.
That also fixes an inconsistency this branch introduced: the PR argued that a
brace makes nesting unnecessary and used it to flatten `StopForUpdate` off
`Signal`, then left `Drain` nested under `Signal` two lines away. Nesting is only
load-bearing where `Signal` is itself the lease holder.
`stop_chain`'s pair loses its own `Agent` declaration as a result — `SetWanted`
holds the lease for the subtree, so those were redundant re-entrant borrows.
`restart_chain` is left alone and says why in place: it has no brace, so `Signal`
holds the lease and the nesting under it is what keeps the grant continuous.
Giving it one would unify all three sites at the cost of an extra no-op node on
every graceful restart, which an operator would see — not something to change as
a side effect of a rebuild-shape PR.
argus caught `approvals.md` still describing the old serial chain under the old
node name. Grepping the name across *all* tracked files rather than just `*.rs`
turned up three more, all in `coordinator.md`: the node-inventory rows for `Swap`
and the bookkeeping tail, and the rebuild shape diagram.
Three of the four were in the file I had edited in the previous commit to add the
brace section, which is the point worth recording: I grepped the *concept* I had
changed (`lease`) and the *symbol* I had renamed, but scoped the rename grep to
Rust. Neither pass could see an old node name sitting in prose.
Also adds the missing `AgentWindow` row to the node inventory.
The pre-push comment-block lint rejected two 40-line doc blocks, correctly: the
module doc and `rebuild_subtree`'s now carry the trigger and a pointer, and the
reasoning lives in a new `#### Braces` section.
That move surfaced a third doc the resource change had falsified. The scheduler's
lease-acquirer list still named `StopForUpdate` / `Swap` / `Signal` / `Drain`,
all of which are now exempt. The list now separates container-affecting nodes
from braces, and says why the rebuild subtree's members are exempt for a
different reason than `MetaSync` / `Prebuild`: they do touch the container, but
their brace holds the lease above them.
It reads as a swap thing but is post-*rebuild* bookkeeping: rev marker, forge and
matrix sync, manager kick, container rescan, meta-inputs snapshot. Wire label
follows (`post_swap` -> `rebuild_bookkeeping`); the graph view renders whatever
label it is sent, so nothing keys on the old string.
Also corrects a doc the previous commit falsified: this node's comment still said
it declares the agent lease as a re-entrant borrow, after that declaration moved
to the brace. Clippy and the whole suite pass over a stale doc comment, so it
took reading the file to find.
Verified by grepping the new name for places it has no business being, which
caught the sed rewriting a *historical* test name in a `// Replaces ...` comment
- reverted, since prose about the past must keep its old spelling.
The graceful path hung `Signal` under `Prebuild`, and a child only starts once
its parent's own logic completes — so the drain window waited for the entire nix
build before the agent was even asked to checkpoint. Up to the full
GRACEFUL_STOP_TIMEOUT hidden behind the build, per agent, on every boot sweep.
`Prebuild` needs the build slot and `Signal`/`Drain` need the agent lease, so
there was never any contention to justify the nesting.
Adds `NodeKind::AgentWindow`, a pure resource holder in the `DeployWindow`
pattern. It declares the build slot and the agent lease atomically and holds
both for its whole subtree; `Prebuild` and the quiesce chain hang off it as
siblings and run concurrently. `StopForUpdate` is AfterOk *both*, so the
container still goes down only once the build is ready and the agent has
checkpointed — running the drain early is the win, stopping early would just be
downtime.
Two things this deliberately reverses, both documented in place:
* The coordinated children now declare no resources. `templates.rs`'s module doc
said each node must declare its own, precisely so one running under a holding
ancestor could not get away with declaring nothing. That rule stands; the
brace is named as its one exception, because declaring a resource means "I
need this exclusively" and the lease is single-unit — two siblings that both
declared it could never overlap, which is the whole point of the shape.
* `rebuild_chain_declares_the_slot_where_the_nix_work_is` asserted the old
principle in its name. Renamed to `..._declares_its_resources_on_the_brace`
rather than left saying something the code no longer does.
Hoisting the build slot is not new serialisation: a unit is held until the
acquirer's subtree settles, and everything downstream already sat inside
`Prebuild`, so the slot already spanned the entire rebuild.
`graceful_rebuild_chain_drains_before_stopping` now asserts full rows instead of
the kind list — the kind list is identical whether the chain runs beside the
build or under it, so it could not see this bug. Verified by mutation: re-nesting
`Signal` under `Prebuild` fails exactly that one test out of 317.
It was written into gateway.md, which only owns half the story: that
doc describes the web.sock path, and before this branch it never
mentioned agent.sock at all. Putting a rule shared by hive-c0re, the
harness and nginx inside the gateway walkthrough means someone asking
"why can't c0re dial agent.sock" has no reason to look there.
boundary.md already covers who may touch what across the trust boundary
-- including the sibling case of hive-priv's socket getting its mode
from the unit rather than the process -- so the rule lives there now,
with the three principals as a table. gateway.md keeps a two-line note
about the one fact it needs (nginx traverses via o=--x) and links out.
Both the gateway doc and the tmpfiles comment said "a directory without
the sticky bit lets any user unlink files in it". True of the old 0777,
but it names the wrong lever: write permission on a directory is what
confers the right to unlink its entries, and the sticky bit is only a
restraint on that -- it was never set here, so it is not what 0751
changes. Dropping o=w removes the permission outright.
The fix is unchanged; this is so a future reader doesn't go looking for
a sticky bit that was never there. Caught in review by argus.
/run/hive-agent/<name> was 0777 root root in steady state, not just during
first spawn. A directory without the sticky bit lets any user unlink files
in it, and the gateway container has all of /run/hive-agent bind-mounted
in, so anything that could reach the path could delete an agent's
agent.sock, bind its own, and receive that agent's todos from hive-c0re.
Two mechanisms were writing the dir and undoing each other: the tmpfiles.d
entry wrote 0777 root root, then hive-c0re round-tripped through hive-priv's
ChownSocketDir to narrow it. `d` re-asserts mode and owner on every apply
and the file is regenerated on any agent's spawn or destroy, so every such
event reset every agent's dir back to world-writable.
SyncAgentTmpfiles now carries each agent's container uid/gid and the entry
declares the answer: 0751 <uid> <gid>. Three principals need the dir and no
two share a group -- the harness binds its sockets (owner rwx), hive-c0re
dials agent.sock and the gateway's nginx dials web.sock (both only need
traverse, and both sockets are already 0666).
Deletes ChownSocketDir and ChmodSocketDir, both priv_client wrappers, the
either/or in host_config with its two swallowed warn!s, and the now-dead
socket_dir_path -- two verbs off the privileged helper's surface and one
round-trip off every agent spawn.
Also makes the two tmpfiles rules for /run/hive-agent itself agree: the
gateway module said hive-core, the generated file said root, and which won
depended on the order systemd read them in.
hive-c0re pushes todos into each agent over the in-agent socket, and
every one of those dials has been failing with EACCES. The socket is
created by todo_server::bind with no mode set at all, so it lands at
0777 & ~umask -- typically 0755. connect(2) on a unix socket requires
*write* permission, and hive-core is neither the socket's owner nor in
its group, so it is locked out.
The tell is the sibling socket. web.sock is bound in the same
directory, by the same process, as the same user, and does set its mode
(0666) immediately after bind. Only the socket missing that call fails,
which is also why no ownership or chown theory explained it: both
sockets share every directory they live in, so anything at the
directory level would have broken them together.
Fix is the two lines web.sock already had. Access control for these
sockets is the containing directory's job, not the socket's -- the mode
here only has to not exclude the host daemon that is supposed to reach
it.
Observable effect: scheduled prompts and message wakes reach agents
again. An agent whose wake is dropped still sees its messages whenever
something else wakes it, so the failure presents as agents that look
healthy but answer late, or not at all if nothing else is waking them.
The disk_watch todo producer isn't turn-loop-shaped -- it's disk-space
state tracked in persistence.md's State dirs (per agent) section, right
next to hyperhive-todos.sqlite's own description. Leave a lean pointer
in the boot-wiring narrative instead of duplicating the detail.
Fixes#2727
The deletion PR removed the types but left ~10 sites still describing
them. Two are real breakage rather than staleness: rustdoc intra-doc
links to deleted items ([NodeView::kind] and [Self::snapshot] in
job_queue/mod.rs). Neither clippy --all-targets -D warnings nor cargo
test resolves intra-doc links, so the tree was green with both already
dangling.
The rest reassert facts the deletion made false: docs/coordinator.md
documented the event as RebuildQueueChanged { seq, queue: [DagView...] }
with a per-node field list, and three sites pointed at the removed
/api/state.rebuild_queue endpoint.
One is pointer rot rather than a rename, and no grep for a deleted name
finds it: SchedulesChanged justified itself as "same snapshot-shape
rationale as RebuildQueueChanged" -- which the deletion turned into the
one event that is not a snapshot. Repointed at TombstonesChanged /
MetaInputsChanged, in both the Rust doc and the dashboard doc.
Two are pre-existing and strictly out of scope, swept under the
pfadfinderregel because the same grep surfaced them: hive-sh4re/README
advertised a jobs module that crate has not had since the host-sock
split, and hive-host-sock/README claimed its own payload types live in
hive-sh4re.
Docs and comments only -- no behaviour, no API, no test changes.
Two views of the same graph existed: the typed `DagView`/`NodeView`
(`/api/state.rebuild_queue`, the `QueueDag` socket request, and the
`RebuildQueueChanged` payload) and `hive-jobq-wire`'s generic
`GraphNode` (`/api/jobq/graph`, `QueueNodes`). Every consumer has moved
to the generic one, so the typed pair is deleted rather than kept in
agreement with it.
What that removes, beyond the types: the `QueueDag` request and
`HostResponse::dags`; `Queue::snapshot`; `dag_view`, `visible_dags`,
`shown_on_wire`, `dag_finished_at` and `containers`; and the
`rebuild_queue` field on `/api/state`. `RebuildQueueChanged` keeps its
seq and loses its payload — nothing read it, and shipping the graph
both on an event and on an endpoint is the duplication this issue is
about. It stays an event rather than becoming a poll because
push-on-change is what every other live surface here does.
Two behaviours came out simpler for a structural reason. `await_dags`
needed two rules — settled means "gone from the snapshot" *or* "present
with every node terminal" — because the typed view evicted finished
groups; the generic view doesn't, so pending is just "some node isn't
terminal". And `state_of` in the tests no longer derives a roll-up at
all: a group root's own state is the scheduler's answer.
That second one found a bug. `cancelled_dag_still_runs_its_approval
tail` asserted the group reads `Cancelled` while the tail it exists to
protect was still pending — `rollup_state` flattened the surviving
child away and called the group settled. The root reads `Finishing`,
which is what the scheduler documents: own logic done, children still
running. The test now asserts that, with the reasoning inline so it
doesn't get "fixed" back.
Kept: `Source`, `State`, `PermPayload` and the `NodeId` alias in
`hive-host-sock::jobs` — shared vocabulary, still used by hivectl.
hyperhive#3033 (jobq rollup endpoint) merged, unblocking hyperhive#3036.
The banner (removed on PR#3031 rather than ship it on an interim
GET /api/jobq/graph client-side derivation) is back, now reading
GET /api/jobq/rollup — hive-jobq-wire::state_rollup's pre-tallied
Vec<StateCount>, not the full graph. running sums the Running and
Finishing entries' roots (Finishing = own work done, subtree still
going); queued reads the Pending entry's roots. roots specifically,
matching the banner's established "N whole operations" meaning, not
the endpoint's parallel nodes count (~7 nodes per rebuild, 1 root).
Re-adds the rebuild_queue_changed SSE subscription dropped alongside
the banner, wired as a payload-less refetch trigger — confirmed with
atlas on the DagView-deletion tracker that this is the intended final
shape (keep the event, drop the payload) rather than deleting it and
falling back to polling.
Verified the running/queued derivation against constructed
StateCount[] fixtures (running+queued mix, Finishing-counts-as-running,
settled states never contribute, multi-state sums) before touching
swarm.js — 6/6 checks passed. npm run build clean, tracker-tag +
comment-block pre-push lints clean. docs/web-ui/dashboard.md updated
to describe the restored banner + the two prior shapes it went
through.
A consumer that wants "how much is in flight" — a summary line, a badge,
a health check — had to fetch the whole graph and tally it client-side,
on every poll, in every consumer.
`hive_jobq_wire::state_rollup` counts `roots` and their subtrees by
state, straight off a `Graph<N, R>` with **no bound on either
parameter**. A node's state is a scheduler concept, so counting by state
needs to know nothing about what the payload or the resource are;
bounding it like the projection does would make a host implement two
display traits to be allowed to count, which is a requirement about
rendering imposed on arithmetic.
It takes the roots for the same reason `wire_snapshot` does — which
groups are in view is the host's policy, and nothing is ever removed
from a graph — so passing the same set makes the roll-up describe
exactly the graph beside it.
Each entry carries BOTH counts: `nodes` (the whole subtree) and `roots`
(just the group tops). One rebuild is ~7 nodes and 1 root, so a summary
meaning *operations* and one meaning *steps* are different numbers over
the same queue, and picking one here would make this crate decide what
counts as a job — the domain question it exists not to answer. It
reports both structural facts; the viewer chooses.
A pair, not a map: JSON object keys are strings, so a map would spell
the state twice and give the wire no ordering. Every state rides with
its zeros in a fixed order, so a consumer can index positionally and
never handles a missing bucket. Tallying positionally against
`ALL_STATES` means a new upstream `State` fails the exhaustive match in
`state_index` rather than silently landing in an existing bucket.
hive-c0re serves it at `GET /api/jobq/rollup`. The queue-side method is
a call site, not an implementation: it supplies the lock and the same
`visible_roots` as `graph_snapshot`, so the summary cannot describe a
different visible set than the graph it summarises.
`MATRIX_HTTP` was `http://localhost:8008`, compiled in, used at 18 call
sites. That address is right only while the homeserver happens to share
this daemon's netns, and its doc comment asserted exactly that as a
general fact. A hive whose homeserver lives anywhere else builds fine
and then talks to the wrong machine.
It now reads `HIVE_MATRIX_API_URL`, which `hive-c0re.nix` sets from
`hyperhive.matrix.apiUrl`. The matrix module fills that in with its own
loopback listener when it is the thing running tuwunel — there it is not
a guess but a fact about what it just started — and the operator sets it
by hand otherwise. There is no compiled-in fallback, for the same reason
`forge_http_base()` has none.
`is_present()` follows. It used to scan `nixos-container list` for
`hive-matrix`, which answers "is the homeserver a container on this
host" — a different question, and the reason a remote homeserver would
silently no-op no matter how it was addressed. It now asks whether a URL
is configured. A co-located hive is unaffected: the module supplies the
loopback URL whenever it runs tuwunel itself. It also stops being
`async`, since it no longer does IO, and `require_matrix_present`'s
message names both ways to have a homeserver rather than only the local
container.
Absent a URL, every matrix path no-ops exactly as it did with no
container, and the two accessors make that structural: `Option` for the
callers that fall back to `None`, a `Result` flavour naming the skipped
`is_present()` gate for the ones that propagate.
Nix half of the 4th layer mara found (47535: core cannot assume matrix
is on localhost). Rust half (matrix.rs MATRIX_HTTP) NOT done.
Parked here rather than left dirty: she has redirected me to jobq as
prio 1, and uncommitted files migrate across a checkout.
Third and last of #2860's agent-facing URL fallbacks. The operator's
ruling was "any special casing is done on the nix side - same binaries,
no hard coded fallback", so the default is deleted rather than replaced.
Every layer guessed the same wrong thing, and each guess was only ever
correct for a process sharing the host netns:
- nix/agent-modules/matrix.nix: matrixUrlDefault = localhost:8008, both
as the option's default and as a sentinel the daemon unit compared
against to decide whether to write HIVE_MATRIX_URL. Now nullOr str,
default null, the guard is != null, and the doc says what forge.url's
already says: null means "no matrix", not "guess one".
- nix/host-modules/hive-c0re/environment.nix: forwarded
http://127.0.0.1:<port> when no gatewayHost was set. hive-c0re shares
the host netns so it reads as harmless, but the value is handed to
agents, which do not -- there it names the agent itself. Now forwarded
only when there is a gateway vhost to name, matching the guard
HIVE_MATRIX_PUBLIC_URL already uses twelve lines below.
- hive-matrix-mcp: paths::DEFAULT_HOMESERVER was the same address
compiled in, so dropping the nix defaults alone would have left the
daemon dialling loopback inside the agent's own netns -- the very bug,
one layer down. homeserver_url() is now Option, and an account with no
homeserver is skipped with a log, exactly as one with no token is.
discover_token_accounts already refused to guess for the same reason.
Two comments taught the assumption back to the next reader ("shared host
netns means every agent container resolves localhost to the same
machine"); both now say which side of the netns boundary they describe.
MATRIX_HTTP keeps its value -- hive-c0re really does share the host
netns -- but no longer claims agents do.
Gated with nix eval against the extended agent-base config, as a pair:
with no url set the daemon unit carries no HIVE_MATRIX_URL, and with one
set it carries exactly that. Either check alone passes on a broken guard.
mara, on the already-approved PR: "dont replace one legacy thing with
another. then we will have to either wait with this pr or split it
into what can and cannot be done now."
Splitting: the transients-only per-agent badge fix is real, correct,
and fixes a live regression (the old DagView fields it read no longer
exist) — nothing about it depends on job-queue data at all, so it
ships as-is. The queue-summary banner is the part that doesn't belong
in this shape: it was reading GET /api/jobq/graph directly and
deriving counts client-side as an interim stand-in for the dedicated
rollup endpoint mara separately asked for — exactly the kind of
stopgap-on-a-stopgap her comment is calling out, since the endpoint
that should serve it doesn't exist on main yet.
Removes jobqNodesState, refreshJobqGraph(), the rebuild_queue_changed
SSE subscription, and the banner's render block from swarm.js/tabs.js
entirely — swarm.js now reads no job-queue state of any kind, fully
satisfying "swarm.js should not need to pull in the jobq to do its
job." The banner comes back once the rollup endpoint
(hyperhive#2985's follow-up) exists, reading that directly instead of
the full graph. Until then the per-agent transient pills still show
what's actually running on each card; only the hive-wide "N running /
M queued" summary line is temporarily gone.
CSS classes for the banner (.queue-summary/.queue-summary-link) kept
in dashboard.css rather than deleted-then-restored — commented as
currently unused, expected to come back unchanged.
docs/web-ui/dashboard.md updated to match (Container-row pending-
badge section, the removed Build-queue-summary-banner section, and
the BU1LDS-page note that used to describe SW4RM's now-removed
parallel fetch).
mara, on review: "swarm.js should not need to pull in the jobq to do
its job" followed by "remove the per agent pending stuff - only show
what is running."
Deletes queuedOpsByAgent() entirely — no more per-agent badge derived
from Pending-state job-queue nodes. A card's pending badges are now
driven exclusively by transientsState (i.e. actually-running work);
queued-but-not-started work shows nothing on the card until a node
starts. jobqNodesState + refreshJobqGraph() stay, now feeding only
the queue-summary banner (a separate, still-open question — mara
separately asked for a dedicated rollup endpoint for that, tracked
apart from this PR).
Collapses the now-always-coincident `pending`/`pending-running` row
classes into one (`pending-running`) — there's no more queued-only
row state to visually distinguish it from.
docs/web-ui/dashboard.md's Container-row section rewritten to match:
the two-store priority-fallback description is gone, replaced with
"transients only."
hyperhive#2822/PR#3026 moved swarm.js's per-agent in-flight status off
the rebuild queue. Two other reads of the same rebuild_queue field
survived that PR by design (a different feature, atlas flagged it on
#2985) and are the last DagView/NodeView consumers on the frontend:
queuedOpsByAgent()'s pending-row fallback and the SW4RM queue-summary
banner. Both now read GET /api/jobq/graph (hive-jobq-wire's generic
GraphNode shape) instead, matching the pattern builds.js already
established for <hive-jobq-graph>.
Along the way: DagView no longer carries state/kind fields (removed
in an earlier refactor that pushed roll-up derivation client-side),
so both migrated functions were silently reading undefined fields and
had become permanent no-ops — the pending-badge fallback never lit
and the queue-summary banner never rendered. This restores real
behavior rather than porting broken logic forward.
The queue-summary banner's node-count-vs-group-count question (flagged
on hyperhive#3028 as needing a decision) resolves cleanly: a GraphNode
group root (parent: null) is an ordinary node whose own state already
IS the group's roll-up per hive-jobq-wire's contract, so counting
roots by state is a direct filter, not a parent-chain walk or a
client-side rollup calculation.
Verified the derivation logic against constructed GraphNode fixtures
(multi-step chains, settled history that must not count, Finishing
roots, multi-agent single-DAG groups) before wiring it in — 13/13
checks passed.
docs/web-ui/dashboard.md's Container-row + BU1LDS sections updated to
match.
mara, PR #3026 review: "drop queuedLabelFor - just show what the
backend sends".
`queuedLabelFor` translated the rebuild-queue entry's `kind` field
through a curated English-phrase table ("meta_update" -> "meta-update
queued", "graceful_stop" -> "stop queued", etc.) for the one fallback
case where no transient exists for an agent. Removed the lookup
entirely -- `queuedOpsByAgent()` now returns the raw `kind` string
directly, and the render loop uses it as-is, the same opaque-string
treatment a transient's own `kind` already got (never run through a
lookup, per docs/web-ui/dashboard.md's existing "treat it as an opaque
display string" note). The queued-vs-running visual distinction still
comes entirely from the row's CSS classes (no ring/tint for queued,
amber ring + tint for running) -- the text was never carrying that
signal on its own, so nothing is lost by not reformatting it.
Updated docs/web-ui/dashboard.md's Container-row section to match.
npm run build clean, standalone verification re-run (17/17 checks,
one updated for the new raw-string expectation).
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.
mara, PR review: "i dont like that they still have different styling.
if anything, there should be distinction between info,warning,error
(semantics). all warnings should be styled identically."
Replaces the severity ('amber'|'red') + standalone pulse boolean with
a single level ('info'|'warning'|'error') attribute -- a fixed
three-tier ladder instead of an open combination. Colours reuse
theme.css's own already-documented semantics rather than inventing new
ones: --cyan is already "info accents", --amber already "warnings",
--red already "errors, fail state". Pulse is now baked into `error`
specifically rather than a separate opt-in knob, since the one call
site that wanted attention-grabbing (an active incident) is also the
one that's semantically error -- tying the two together removes a
combination that shouldn't exist independently of the tier.
Reclassified the three call sites explicitly (no implicit default
relied on): credentials.html's GitHub PAT advisory and core.js's K3PT
ST4T3 caveat are both `level="warning"` (same as before, and now
identical to each other by construction, not by coincidence);
swarm.js's port-collision banner is `level="error"` (an active
incident needing operator action now, not a standing caveat).
npm run build clean both packages, grepped for leftover
severity/pulse references (only prose mentions describing what this
replaces).
Consolidates three independently-written instances of the same thing:
.cred-warning (credentials.html, static markup), .tombstone-warn
(core.js, JS-built), .port-conflict (swarm.js, JS-built) -- the first
two differed only by an undeliberate 10% vs 8% tint, the strongest
argument this was drift rather than three genuinely different needs.
New autonomous custom element, frontend/packages/shared/src/hive-warn/,
matching the established one-component-one-dir shadow-DOM pattern
(hive-btn, hive-toast, hive-dialog). Purely presentational -- no
lifecycle beyond attaching its shadow root once, no
attributeChangedCallback needed since severity/pulse are read directly
by :host([...]) CSS selectors rather than mirrored onto an inner
element. Content passes through via a single default <slot>, so every
call site keeps writing its existing <strong>/<code> markup unchanged.
API: <hive-warn> (amber, default) / <hive-warn severity="red"> for an
active incident vs a standing caveat, plus an opt-in pulse boolean
modifier (only the port-collision banner wants it -- a banner that's
always present and always pulsing just trains you to stop seeing it).
Tint is a single canonical 8% for both severities now, replacing the
10%/8% split.
Registered once in dashboard's common.js (same side-effect-import
pattern as <hive-side-panel>) so every dashboard page picks it up
without a per-file import, since all three call sites (core.js,
swarm.js, credentials.js) already transitively import it.
Verified: npm run build clean for both dashboard and agent packages,
grepped source for leftover cred-warning/tombstone-warn/port-conflict
references (none), confirmed hive-warn/HiveWarn/hive-warn-pulse present
in every affected dist bundle, confirmed the still-live
questions-pulse keyframe (.questions) untouched.
Deletes `TransientState`, adds `transient_kind` to `TransientCleared`, and
fixes a crash misreport — three changes over the same functions.
`TransientState` was `RunningTransient` with `agent` dropped and
`takes_container_down` renamed; that rename was the only thing it did, and
its three consumers each read a disjoint subset. `transient_snapshot` now
returns `RunningTransient` directly.
`recent_transient` was keyed by agent alone and overwritten on each clear.
An agent can clear several pills in one grace window, so a `Prebuild`
(`takes_container_down = false`) landing after a `StopForUpdate` (`true`)
left the tombstone reading `false` and the crash watcher reported a
deliberate stop as a container crash. Keyed by `(agent, label)` now, with
`recent_transient_within` folding back per agent by OR — the same question
`crash_watch` asks of the active set.
`TransientCleared` gains the label for the same reason: a client holding
two open pills for one agent could not tell which one a clear referred to.
The out-of-band suppression guard has no node and so no label; it uses
`NO_NODE_LABEL`, angle-bracketed to stay out of the `NodeKind::as_str`
namespace.
`build_tombstone_views` folded `transient_snapshot`'s keys into its `live`
set, so an agent with in-flight transient work was treated as not-a-tombstone.
Since #3010 that set is derived from the running job graph, which made a
page about on-disk state a function of the scheduler.
Dropping the filter exposes what was always true underneath: nothing records
a destroy. Every definition-side artifact — state subvolume, proposed +
applied repos, `deployed/0`, meta registration, topology entry — is written by
`Provision` before the container exists and survives `lifecycle::destroy`. So
a mid-spawn agent is byte-identical on disk to a tombstone.
Per mara on #3020: remove the filter, warn on the page, keep the issue open
for the swarm-controller / snapshot-storage rework where the problem shape
changes anyway.
- dashboard/tombstones.rs: drop the param + the chain; document the real
semantics
- core.js: amber caveat banner above the rows; row badge `destroyed` ->
`offline`, which is what an absent container actually proves
- dashboard.css: `.tombstone-warn`, modelled on `.port-conflict` but amber and
without the pulse — a permanent banner that pulses trains you to ignore it
- docs/web-ui/dashboard.md: the pane was described as "destroyed-but-state-kept
agents", now the exact wrong claim
`nix_logged` wrote its `build_logs` row with `node_id = None`, so a deploy's
eval/relock log was reachable from the agent+kind+time listing but not from
the queue node that ran it.
The comment justifying the `None` said both callers are "reached from outside
the queue as well as from inside it". That is no longer true: `verify_commit`
and `prepare_deploy` have exactly one caller each, and both sit under the
`MergeVerify` / `DeployApply` arms of `exec.rs`'s node dispatch, where the
`NodeId` is already in scope.
Threads `Option<u64>` from the dispatch down, mirroring `prebuild_toplevel`'s
existing `Some(id.get())` at exec.rs:241. Kept as `Option` rather than a bare
`u64` because `meta::prepare_deploy` / `meta::verify_commit` are public API and
a future non-queue caller has no node to name; the comment now says that
instead of the stale claim.
No behaviour change beyond the log row gaining its node link.
Six more, in the crate's standalone README: the summary, the model section,
NodeId, the Graph entry and the Node serde note. Same wrong claim as the
module doc, in the sibling file the first sweep didn't look at.
A crate's README and its //! module doc are the same document in two files,
so a fix that touches one and not the other is the default outcome rather
than an unlucky miss.
The crate described itself as a persistent scheduler and NodeId promised
stability across restarts. Neither is true: hive-c0re constructs an empty
Graph on every boot and re-derives desired state with its reconcile sweep,
and nothing in the workspace writes or loads a graph. hive-c0re's own
job_queue module doc has said "runtime-only (no persistence)" all along —
only the extracted library's prose drifted.
Seven claims corrected across the module doc, NodeId, the id-counter error
and the Graph type, plus the repo map. The module doc now states the fact
positively rather than just dropping the word: serde exists so the graph can
be projected onto a wire and so a store could be added later, ids and
timestamps are stable within a run.
NodeId spells out the consequence, since that is the part that could mislead
someone: an id stored outside the process is a historical record, not a
handle that will resolve after a restart.
Fixes hyperhive#3014.
docs/README.md is a genuine hand-written index for the docs/ tree -
task-oriented reading paths grouped by topic, covering every top-level
doc plus the three subdirectories that already have their own landing
page (web-ui/, tools/, turn-loop/). Adapted from CLAUDE.md's existing
"Reading paths" section (already curated and kept current) rather than
written from scratch, reorganized into headed groups since this is a
landing page, not a flat reference list. Explicitly covers
docs/tools/matrix.md and docs/github.md, per the note on hyperhive#3014
about the content that moved out of README.md's deleted section on
PR #3006 not going undiscoverable.
Top-level README.md's reading-path table (enumerating every doc)
replaced with a single prominent docs-site link, per mara's "replace
docs links table in readme md with a prominent link to the docs
(public host and git relative path)" - both forms present (rendered
site URL, git-relative docs/ path).
Verified every link in docs/README.md resolves to a real file (33
files checked). nix fmt clean, tracker-tag grep clean.
Companion to hyperhive/website#47/#48 - this is what that PRs "index.html
half" needs to exist before it can render, per atlas's note on #48.
The container node now carries its own `created_at` like every other node,
so the payload copy recorded the same instant a second time — and only the
payload one was reachable to a viewer that doesn't know what a Dag is.
`dag_view` reads `node.created_at` off the container instead. `DagView`
keeps the field on the wire: hivectl's dag_progress uses it as the elapsed
fallback for a DAG that hasn't started yet. It just has one source now.
Removing the field left chrono entirely unused in model.rs, which is the
compiler confirming the payload had no other use for a timestamp.
`Node` carried two of the three lifecycle timestamps; the third lived on
hive-c0re's `NodeKind::Dag` container payload, a core-specific wrapper the
graph knows nothing about. Give it its real home so the container's copy
becomes redundant rather than load-bearing.
Not an `Option` like its neighbours: starting and finishing are events that
may never happen, but a node that exists was created. Modelling it as
optional would encode a state the graph cannot be in.
`hive-jobq-wire::GraphNode` gains the field in the same commit — it already
carries the other two, and without this one the value cannot reach a viewer
when the container's copy is deleted.
The \bJob\b pass rewrote a doc reference too: docs/coordinator.md::Job
queue became ::JobBuilder queue, pointing at a section that does not
exist. The section is still "## Job queue".
Renaming `pub type Job` fixed the definition and left every use site
reading `b` and `job` — including `job: super::JobBuilder`, where the
parameter still asserted it was a job while its type said otherwise.
The propagation is what the issue was about, so the parameters are the
half that matters at a call site.
Two spots deliberately untouched: `auto_update`'s `sort_by(|a, b| …)`
comparator, and the prose that means the job *queue* (main.rs's
"Job-queue scheduler", scheduler.rs's "not this module's job any more",
the "grown job rejected" log).
315 tests pass unchanged.
A JobBuilder holds pending nodes that are not in the graph yet — it is
the thing you declare into. Naming the alias Job claimed it was the work
itself, and the name propagated into every parameter derived from it
(job: super::Job in run_node read as if it carried the DAG).
Prose uses meaning the job *queue* are left alone: main.rs's "Job-queue
scheduler" comment and the docs/coordinator.md reference.
315 tests pass unchanged.
Per mara on #2822: status is the only test. The agent comes off the
node's own payload rather than a declared Resource::Agent edge, so the
lease-exempt kinds (Prebuild, MetaSync) that name an agent without
holding its lease now light a pill — they are work on that agent.
Dropping that test breaks the one-pill-per-agent invariant, since
lease-exemption is exactly what lets one DAG build for an agent while
another holds its lease. Everything keyed by agent alone had to follow:
- reconcile_transients keys (agent, label) via TransientSeen, so a
second pill cannot evict the first — and cannot lose its
takes_container_down, which the crash watcher reads at clear time.
- transient_snapshot returns a Vec per agent for the same reason. The
collapse was silent: a Prebuild could evict a StopForUpdate and its
deliberate_stop, making an intentional stop report as a crash.
- crash_watch asks whether ANY running node expects the container down.
- the dashboard renders one row per node instead of one per agent.
takes_container_down never reached the frontend; no wire change needed.
315 tests pass unchanged.