Commit graph hyperhive/hive-c0re/src
Author SHA1 Message Date
atlas
7f920718f2 refactor(#2815): drop the imperative transient path entirely
mara on !2910: "also remove imperative path for the things that are not
nodes yet, file follow up issue to fix that".

`TransientGuard`, the stored map and both manual set/clear are gone.
`transient_snapshot()` is derived and nothing else — destroy and
migration show no pill, because there is no node to derive one from. The
pill returns for free when they become nodes.

What those three guards were actually doing, though, was suppressing the
crash watcher, not drawing a pill. `migrate.rs` said so in its own
comment: without it, `crash_watch` fires `ContainerCrash` for every
migrated agent and the manager tries to recover containers that were
stopped on purpose. Destroy is the same — the container disappears
deliberately and nothing in the graph says so.

Deleting them outright would therefore have traded a dashboard pill for
false crash alerts on every destroy and every migration. So the
suppression survives as its own thing, `suppress_crash_watch`, with a
name that says what it is. It is still RAII, and still held for the
operation rather than stamped once, because the crash watcher's grace
window is finite and a destroy is not — a single tombstone would expire
mid-operation. The drop stamps the tombstone, covering the poll that
lands just after.

That leaves RAII in the codebase for exactly one purpose instead of two.
Untangling the pill from the suppression is what made the transient layer
deletable at all.

Follow-up issue for making destroy + migration real queue nodes to
follow; at that point this guard goes too.

Checked with clippy (`--all-targets -D warnings`), `cargo test -p
hive-c0re -p hive-jobq` (322 + 41 passed) and `nix fmt`.
2026-08-01 18:15:27 +02:00
atlas
884e39ba63 refactor(#2815): derive the transient snapshot, don't mirror it
mara on !2910: "why is set_transient still a thing if it completely
derives from nodes?"

It was still a thing because the scheduler mirrored the derived set into
a stored map that every consumer read — derived state computed once and
then cached, with the reconciliation loop existing only to keep the cache
honest. `transient_snapshot()` now derives: `running_transients()` off the
live graph, with the handful of entries that have no node behind them
(destroy, migration) overlaid on top. There is no cached copy left to go
stale or disagree with what is running.

`set_transient` / `clear_transient` split by what they actually do:
`set_manual_transient` / `clear_manual_transient` own the stored map for
the no-node callers, and `emit_transient_set` / `emit_transient_cleared`
publish the edges both paths need.

Two things had to survive, and both are edges rather than state:

- The dashboard's `TransientSet` / `TransientCleared` events. The
  scheduler carries the previous derived value and emits the diff.
- The crash watcher's grace window. `recent_transient_within` answers
  "was a transient cleared just now?", which is what stops a deliberate
  stop from reading as a crash on the next 10s poll — a derived read of
  current state cannot answer it, so the clear still stamps. The
  scheduler keeps `deliberate_stop` alongside the label precisely so it
  is available at clear time: the node it came from is, by definition, no
  longer running to be asked.

`TransientState::since` becomes wall-clock and, for derived entries, is
the node's own `started_at` — the true start of the operation rather than
the moment a watcher first noticed it, which is what the old
guard-creation timestamp actually measured.

`running_transients` returns a named `RunningTransient` rather than a
4-tuple; two of its fields are strings and one is a bool whose meaning is
not guessable at a call site.

Note for anyone reaching for a timestamp here: chrono is vendored with
`default-features = false`, so there is no `Utc::now()`. The workspace
convention is `wire_time::now_unix()` / `from_secs()`.

Checked with clippy (`--all-targets -D warnings`), `cargo test -p
hive-c0re -p hive-jobq` (322 + 41 passed) and `nix fmt`.
2026-08-01 17:47:08 +02:00
atlas
56202065d5 refactor(#2815): the scheduler publishes pill edges, it doesn't own pills
mara on !2910: "transient guard as well - should be removable now?" — for
the queue path, yes.

`set_transient`'s own doc explained why the RAII guard existed: a
cancelled future must not leak an imperatively-set transient and pin the
dashboard on "rebuilding…" forever. That cannot happen to a derived set.
`running_transients()` is recomputed from the graph every loop, so a node
that stops running stops appearing — there is nothing to own and nothing
to leak.

So the scheduler no longer holds a guard per pill. It keeps the previous
derived value and publishes the transitions, which is the one thing a
derived read cannot express: the dashboard wants `TransientSet` /
`TransientCleared` edges, and the crash watcher wants the *moment* a pill
cleared, since its grace window is what stops an operator stop from
reading as a crash.

That also retires a hazard rather than restating it. The old code carried
a warning that stale guards had to be dropped before new ones were
created, because `TransientGuard::drop` clears by agent with no notion of
which label it was clearing — so a same-agent label change could clear
the pill it had just set. With no guards there is no ordering to get
wrong; clears are emitted before sets so a relabel reads as
clear-then-set rather than two overlapping pills.

`set_transient` / `clear_transient` become `pub(crate)`. The guard stays
for destroy and migration, which have no node behind them and where the
cancellation concern is real.

Checked with clippy (`--all-targets -D warnings`), `cargo test -p
hive-c0re -p hive-jobq` (322 + 41 passed) and `nix fmt`.
2026-08-01 17:30:05 +02:00
atlas
17500a391d refactor(#2815): held_transients -> running_transients
mara on !2910: "rename now, we will see if we can remove it later when
some of the users have been removed or work differently."

Nothing is held. The old name described a transient the DAG declared and
kept for its whole lifetime — precisely the thing this PR replaces — so
it outlived its own meaning the moment the derivation landed. The value
is recomputed from the running set on every call.

Kept as a function rather than inlined at its single call site, per the
above: removing it is a later step that depends on its users changing,
not something this PR should force.

Rename plus its two references (the call in `reconcile_transients` and
the module doc link). No behaviour change; the doc comment records what
the old name meant so the rename doesn't erase the reason for it.

Checked with clippy (`--all-targets -D warnings`), `cargo test -p
hive-c0re -p hive-jobq` (322 + 41 passed) and `nix fmt`.
2026-08-01 16:40:27 +02:00
atlas
d3d73b5ffb refactor(#2815): derive the transient pill from the running node
The dashboard pill was declared once per DAG at submit time, so a rebuild
reported `rebuilding` for its entire life — through the prebuild, the
stop, the swap, the tail and the reconcile. It named the intent of the
request, not what was happening.

It is now read off the nodes actually running. A node lights a pill when
it is `Running` and declares the agent's own resource. Declaring is the
test, not targeting: `Prebuild` and `MetaSync` name an agent but are
lease-exempt on purpose (the container keeps serving), so they must not
light one. It is also not the lease *owner* — `resource_state()` answers
"who holds the slot", which is a different question from "what is
running", and a descendant that borrows an ancestor's grant never
appears in that map.

`TransientKind` is gone entirely rather than being re-derived. The label
is the node's own wire tag (`NodeKind::as_str`) — the same vocabulary
`NodeView.kind` already ships, so a pill and a DAG node name an operation
identically and there is no second taxonomy to keep in step. Work with no
node behind it (destroy, migration) supplies its own literal.

`DagSpec::transient`, `Claim::transient`, `DagMeta::transient` and
`NodeKind::Dag`'s `transient` field all go with it.

## the safety half, which is deliberately not the display half

`crash_watch::is_deliberate_stop` used to match a `TransientKind` to
decide whether a vanished container was intentional or a crash. That made
a pill's display vocabulary decide an alerting question, so renaming or
adding a label would silently move the alerting boundary.

`TransientState` now carries two independent fields: `label` (rendered,
nothing branches on it) and `deliberate_stop` (read only by the crash
watcher). The producer sets the second, because the producer is the only
thing that knows — it is not recoverable from the first.

For queue work that value is `NodeKind::takes_container_down()`, and it
is emphatically not "holds a lease": `Create` and `Start` hold the
agent's lease exactly like `Stop` does, and a container dying *while
starting* is a real crash that must keep reporting as one. The default is
`false` on purpose — a wrong `false` costs a spurious crash event, a
wrong `true` swallows a real crash silently.

## known cost, accepted on the issue

A restart no longer reads `restarting`. No `NodeKind` is unique to a
restart — `restart_chain` reuses `Signal` / `StopForUpdate` / `Drain` /
`Reconcile` — because "restart" is a property of the DAG's shape, not of
any node. A restart now reads `signal` / `stop_for_update`, then the
agent returns.

`Start` / `Stop` / `PostSwap` run inside a lease-holding ancestor and
re-declare nothing, so they light no pill and the agent reads idle for
those windows. Closing that is the resources-where-constructed work
(#2818), not this change.

Checked with clippy (`--all-targets -D warnings`), `cargo test -p
hive-c0re -p hive-jobq` (321 + 40 passed) and `nix fmt`.
2026-08-01 16:06:06 +02:00
atlas
0aac20d863 refactor(#2908): resolve a wire id in the graph, not the c0re layer
mara on !2909: "shouldnt node_by_id be part of jobq?" — yes. Resolving a
raw value to a `NodeId` is the exact inverse of `NodeId::get`, which
already lives in hive-jobq, and it is only a search because the graph
owns the counter that makes ids unfabricable. Both halves of that
round-trip belong on the same side of the crate boundary.

Placing it in `QueueInner` also put it in a layer slated for removal, so
the c0re-side helper would have had to move later anyway — and it was
private there, leaving any other caller needing the same resolution to
write the same `nodes().find_map(…)` by hand.

`Graph::resolve_id` replaces it, with a unit test covering the
round-trip and the rejection of a value that was never an id.

While re-reading the diff for that question: the doc comment added in
the previous commit landed *between* `container`'s doc comment and its
signature, silently reattaching "The container node of `dag_id`" to the
new helper and leaving `container` undocumented. Restored.

No behaviour change and no wire change — same search, same call site.
Checked with clippy (`--all-targets -D warnings`), `cargo test -p
hive-jobq -p hive-c0re` (41 + 322 passed) and `nix fmt`.
2026-08-01 15:53:46 +02:00
atlas
eb557ee3c1 refactor(#2908): cancel a node, not a DAG
`JobQueue::cancel(dag_id)` resolved the id to a `NodeKind::Dag` container
and cancelled that. But the container lookup was the only DAG-specific
part — everything that makes cancel work already lives in the scheduler:
`cancel_node` marks the node `Cancelled` and cascades to its pending
descendants, sparing any node whose edge accepts `Cancelled` (which is
what keeps a dropped approval DAG from dangling its row).

So `cancel` now takes any node id. A group root cancels the whole group,
which is what the dashboard's button does today and why nothing about
its behaviour changes: a DAG id *is* its root node's id. An interior
node cancels just that branch — a capability the DAG-scoped version
could not express, covered by the new test (a hive-wide restart drops
one agent's subgraph while the other keeps running).

`QueueInner::node_by_id` replaces `container()` here: same search, same
cost, without asserting the node is a DAG container. `container()` stays
for `first_error` and the append-subgraph guard, which are genuinely
DAG-scoped.

No wire change. The route is `POST /api/rebuild-queue/{id}/cancel` with
a `u64` path param — same type, same route, and the client keeps sending
the same number. Only the param's documented meaning moves from "DAG id"
to "node id".

Checked with clippy (`--all-targets -D warnings`), `cargo test -p
hive-c0re` (322 passed) and `nix fmt`. No option surface touched, so no
nix-eval gate.
2026-08-01 15:53:46 +02:00
damocles
4835ca8c91 hive-c0re: list_descendants reads cached container snapshot instead of live systemctl calls 2026-08-01 15:30:57 +02:00
damocles
a72009099a fix list_containers reporting stale running state
handle_list_descendants derived a container's running state from
membership in nixos-container list output, but that command returns
every registered container - stopped and ghost-registered (machined
still holds the name after the process died) ones included - not just
running ones. use lifecycle::is_running (systemctl is-active on the
container's systemd unit) per container instead, same source
container_view::build_all already uses for the dashboard.

fixes hyperhive/hyperhive#2846
2026-08-01 15:30:57 +02:00
atlas
d09ec31431 feat(#2899): add host.arch + service.version to container metrics
The container-resource exporter identified the samples it sent by
container and by hive, but not by machine or by build — so a sample
could not be attributed to the host it came from or the deploy that
produced it.

Both go on the OTEL resource rather than on each data point, for the
same reason `hive` / `swarm` already do: they are constant across one
hive-c0re. Only per-container facts stay per-data-point.

- `host.arch` — mapped to the semconv spelling, not forwarded from
  rust's. The two disagree on exactly the architectures this runs on
  (`x86_64` / `aarch64` vs `amd64` / `arm64`), and the failure mode is
  silent: a dashboard filtering the standard value matches nothing.
  A test pins this, since nothing else would catch it.
- `service.version` — the running flake rev, via
  `auto_update::current_flake_rev`, the same source the dashboard
  snapshot and `get_agent_meta` already use. NOT the crate version:
  that's a workspace constant that never moves between deploys, so it
  could not answer "which build produced this sample?". Omitted rather
  than guessed when the flake ref carries no rev.

`spawn_exporter` takes the flake ref to reach the rev — the string it
needs, not the whole `Coordinator`, so the module's coupling doesn't
widen for one attribute.

The issue's third item, `container`, needs no change: `attrs()` has
emitted `container.name` per data point since this exporter landed.

Checked with clippy (`--all-targets -D warnings`), `cargo test -p
hive-c0re` (321 passed) and `nix fmt`. No option surface is touched, so
no nix-eval gate.
2026-08-01 14:21:10 +02:00
atlas
af2b1ce0e2 refactor(#2897): carry the meta-update inputs on the MetaLock node
Second of the `Dag` field removals, and the same shape as the first:
`DagSpec`/`NodeKind::Dag` carried an `inputs: Vec<String>` that exactly
one node ever read. Both reads live inside `run_meta_lock` — the
`meta::lock_update` call and the `meta_update_cascade_agents` fan-out —
so the list now rides `NodeKind::MetaLock` itself.

The executor stops touching `Claim` for this node entirely: its dispatch
arm already destructured `MetaLock { sweep, fanout }`, so `inputs` joins
them and the `claim` parameter, which had no other use, is gone.

Falls out of that:
- `Claim::inputs` and `DagMeta::inputs` delete.
- `dag_view`'s DAG-level projection onto the `MetaLock` node reads the
  payload instead. The wire `NodeView::inputs` is unchanged: still
  populated on the `meta_lock` node alone.
- the boot sweep names no inputs (it bumps `hyperhive` alone via
  `lock_update_hyperhive`), which the construction site now says out loud
  rather than leaving implicit in an empty DAG-level field.

Checked with clippy (`--all-targets -D warnings`), `cargo test -p
hive-c0re` (320 passed) and `nix fmt`. No option surface is touched, so
no nix-eval gate.
2026-08-01 13:23:19 +02:00
atlas
84aed5fb51 refactor(#2897): carry the approval id on the deploy nodes, not the Dag
`DagSpec`/`NodeKind::Dag` carried an `Option<i64>` approval id that four
deploy phases read back out through `Claim`, via a fallible helper whose
error ("approval deploy dag N has no approval_id") described a state the
type system should have forbidden. Two other templates (`spawn`,
`meta_update`) set the field for nothing: their approval is resolved by
the `ResolveApproval` tails, which already carry the id themselves.

So the id moves onto the nodes that actually need it —
`DeployWindow` / `MergeVerify` / `DeployApply` / `FinalizeDeploy` /
`DeployTail` each take an `i64`, the same way `ResolveApproval` always
has. `templates::approval_deploy` builds all of them in one place with
the value in hand, and `deploy_rebuild_nodes` takes it as a parameter so
the `FinalizeDeploy` it appends at runtime is constructed the same way.

Falls out of that:
- `deploy_approval_id` and its runtime error path delete; each executor
  takes the id from its own node payload at dispatch.
- `run_deploy_window` had nothing left to do but validate that id, so the
  node joins `Dag` on the shared no-op arm.
- `Claim::approval_id` and `DagMeta::approval_id` delete.
- `dag_view`'s DAG-level projection onto `DeployWindow` reads the payload
  instead. The wire `NodeView::approval_id` is unchanged: still set on
  the deploy root alone, so the dashboard still renders one approval link
  per DAG rather than one per phase.

No option surface is touched, so there is no nix-eval gate here; checked
with clippy (`--all-targets -D warnings`), `cargo test -p hive-c0re`
(320 passed) and `nix fmt`.
2026-08-01 13:12:35 +02:00
atlas
772482a52a fix(#2898): carry hive/swarm display names as build-time options
The OTEL resource attributes are baked into every agent's
managed-settings.json at evaluation time, but claude-settings.nix read
the names from the container's environment.variables - where they are
never set. meta.rs forwards them as runtime env only, so the reader hit
its "unknown" fallback and every agent shipped

    service.name=hyperhive-agent,agent=<a>,hive=unknown,swarm=unknown

on every metric, while the same process's env held pr1ma/constellation.
Measured in this container's /etc/claude-code/managed-settings.json.

The map from forwarded env var to agent option already existed for the
service URLs, with a doc comment naming this exact hazard: "setting only
one leaves the other on its default". The names were simply never added
to it. They are now, and the constant is renamed FORWARDED_VAR_OPTIONS
since it no longer holds only URLs.

hyperhive.hiveName / hyperhive.swarmName follow the forge.url shape:
nullOr str defaulting to null, where null means the hive did not name
itself and "unknown" is an honest label rather than a guess baked at
eval time.

Also fixes, unasked: CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX reads the
same value, so remote-control sessions were named "unknown-<agent>".

Gated with nix, not just cargo - the blast radius here is module eval,
which fmt/clippy/test cannot see:

  agent-base assertions            -> [] (no failures)
  extendModules with both options  -> hive=pr1ma,swarm=constellation

Note the value is baked, so every agent needs a rebuild before the new
label appears on its metrics.
2026-08-01 12:35:50 +02:00
iris
4c37ce9150 dashboard: consolidate NodeView.has_log into build_log_id
Per mara's review on #2896: has_log: bool was fully redundant once
build_log_id: Option<i64> existed alongside it (has_log was always
just build_log_id.is_some()). Dropped has_log, threading the single
Option<i64> field through job_queue::mod.rs, the hivectl NodeView
test-helper literal, and the one remaining frontend consumer
(findLiveBuild's live-log-panel gate, which now checks
build_log_id != null instead of the separate bool).

Also fixed a now-stale doc comment on GET /api/build-log/{node_id}
that claimed the dashboard used on-demand node-id fetches "instead
of an inline build_log_id on the wire" -- no longer true after this
PR put one there for the BUILD L0GS deep-link.

cargo build/clippy/test clean across the three touched crates; nix
fmt clean; frontend build verified (0 has_log references, 3
build_log_id references in the built builds.js bundle).
2026-08-01 11:38:23 +02:00
iris
662e303e72 dashboard: queue log link opens BUILD L0GS tab instead of downloading
Fixes hyperhive#2895. The rebuild-queue tree's per-node log icon (the
printer-glyph "open" affordance next to each node in the R3BU1LD
QU3U3 tab) linked directly to the raw-text download endpoint
(/api/build-log/<node_id>/raw, which sets Content-Disposition:
attachment server-side) -- surprising, since nothing about that icon
signals "this leaves the app", unlike the other two explicit
"download raw"/"raw" links elsewhere on the page.

Point it at the existing ?id=N#buildlogs deep-link into the BUILD
L0GS tab instead (builds.js's fetchBuild already auto-expands +
scrolls to the matching row there). That deep-link's id is the
build-log history row id -- a different id space than the queue
tree's NodeId, and wasn't exposed to the frontend before (only a
derived has_log bool was). Added NodeView.build_log_id: Option<i64>
to the wire type alongside the existing has_log (kept, since
findLiveBuild's separate live-log-panel gate still needs a plain
bool), threaded through job_queue::mod.rs, updated hivectl's NodeView
test-helper literal.

The raw download is still one click away once on that row's BUILD
L0GS detail (the two already-explicit raw-download links are
untouched). cargo build/clippy/test clean across the three touched
crates (hive-c0re, hive-host-sock, hivectl); nix fmt clean; frontend
build verified (grep for build_log_id in the built builds.js bundle).
2026-08-01 11:38:23 +02:00
damocles
d10eebd455 hive-c0re: annotate the 3 SSE/stream dashboard routes with utoipa 2026-08-01 00:55:46 +02:00
atlas
dbff9f0987 fix(#2860): refuse to write a meta flake with no forge URL
With the agent option nullable, a missing `HIVE_FORGE_URL` would no
longer fail anything — it would deploy a whole fleet of agents that
silently never log into the forge. The forge is not optional on a
running hive, so the hive asserts that itself rather than leaning on a
module that legitimately allows "no forge" when evaluated standalone.

`sync_agents` checks it before writing anything. That is the moment the
hive commits to a flake, and it keeps `render_flake` a pure string
operation: the renderer is exercised directly by a dozen tests, so
making *it* env-dependent would force each of them to either set a
process-wide var — the parallel-test race this module already avoids —
or fail for reasons unrelated to what they assert.

`require_service_urls` is pure over the already-collected pairs, so its
two tests need no process env at all.

Refs #2860
2026-08-01 00:47:30 +02:00
damocles
2c51fb70d0 hive-c0re: wire routes individually — routes! macro panics on multi-path batches 2026-07-31 23:37:05 +02:00
damocles
423427abeb hive-c0re: fix clippy doc_markdown nit in matrix_accounts.rs 2026-07-31 23:37:05 +02:00
damocles
4f55566694 hive-c0re: wire the newly-annotated routes into the OpenApiRouter 2026-07-31 23:37:05 +02:00
damocles
582ebe5eee hive-c0re: annotate remaining dashboard routes with utoipa 2026-07-31 23:37:05 +02:00
atlas
8ad3b8e987 fix(#2860): drop hive-c0re's hardcoded localhost forge fallbacks
Two sites, same class, different blast radius:

`forge_http_base()` fell back to `http://localhost:3000` when
`HIVE_FORGE_URL` was unset. The NixOS module sets that variable
unconditionally, so the fallback could only ever fire for a process
started outside the module — where guessing produces a confusing
"connection refused" far from its cause. It now panics saying exactly
that.

`forge_git_url()` had a second, nastier fallback: a base URL with no
`://` produced `http://core:<token>@localhost:3000/...`, sending a
*credentialed* git push at whatever happened to answer on the local
port. Split the credential-insertion half out as `git_url_with_base`,
which panics on a malformed base.

That split also lets the tests cover the shape without setting a
process-wide env var, which would race every other test in the binary.
Adds a case pinning that the scheme is carried through rather than
assumed — the old hardcoded `http://` would have silently downgraded a
TLS-fronted forge.

Refs #2860
2026-07-31 22:53:59 +02:00
atlas
ba71e45486 refactor(#2862): one snapshot store per swarm, not one per peer
The push side modelled a store per peer hive: a --peer argument, a
swarm.peers.<domain>.snapshotStorePort option, and a swarm_peers module
whose entire job was answering "which peer". A swarm has exactly one
store, so none of that had anything to select between.

The receiver already proved it. It keys destination directories by
agent, not by sending hive, precisely so an agent that migrates keeps
one unbroken incremental chain -- which only makes sense if every hive
pushes to the same place. Per-hive stores would split the chain in two,
the case that keying exists to prevent.

So the destination moves to services.hyperhive.swarm.snapshotStore,
rendered into HYPERHIVE_SNAPSHOT_STORE, and swarm_peers is deleted
rather than adapted. address has no default because it is a
deployment fact this host cannot derive; port defaults because it is a
convention both ends read from the same option docs. An unset or empty
address fails naming the option instead of connecting somewhere
arbitrary, and a test asserts the message suggests no value.
2026-07-31 22:15:37 +02:00
atlas
282bbc3709 feat(#2862): push a snapshot to a peer hive's store over the mesh
Adds the caller the fd-passing machinery existed for: hivectl agent
<name> subvol snapshot push --peer <hive> resolves the peer, connects
to its snapshot store, writes the agent header, and hands the connected
socket to hive-priv, which runs btrfs send straight into it.

The split keeps the root helper ignorant. Everything that involves
knowing where a peer is, what the wire protocol looks like, and which
hive to trust happens in the unprivileged daemon; hive-priv only ever
receives an already-open descriptor. Once btrfs send starts, neither
process is in the data path, so a multi-gigabyte transfer costs no
per-byte work and survives a hive-c0re restart.

call_with_fd takes the descriptor by value and closes it as soon as the
kernel has it. A socket stays open until every copy closes, so holding
one back would leave the receiver waiting for an EOF that never comes:
btrfs receive blocks and this side reports success for a transfer the
peer never committed. Ownership makes that unrepresentable.

The peer's store port is a new swarm.peers.<domain>.snapshotStorePort
option rather than a constant matching the module default. A pushing
hive cannot read the receiver's configuration, so assuming 51821 would
push at a port nobody promised to listen on; absent, the push fails
naming the option. swarm_peers parses the mesh address the host module
has always rendered into HYPERHIVE_PEERS but nothing read.
2026-07-31 22:15:37 +02:00
damocles
44651544a8 hive-c0re: wire up openapi spec + swagger ui (#2872) 2026-07-31 21:48:02 +02:00
damocles
b39bf67cb3 add /health/live and /health/ready hive-wide health endpoints 2026-07-31 21:01:23 +02:00
atlas
5643c327b6 feat(#2860): render the forge + matrix URLs as agent options
Step 1 of removing the localhost fallbacks: make the renderer emit the
value it already knows, so the option stops being a second, disagreeing
source of truth.

These options existed but nothing ever set them, so every agent fell
back to their localhost:<port> defaults while the real value reached
the container only as an env var. The two are consumed at different
times — the option is baked into scripts at build time (tea-login's
FORGE_URL), the env var is read at runtime — so which answer a given
code path gets depends on which one it happens to read.

Emitting them here follows the shape the otel block already uses: host
state becomes build-time agent module config. It is the precondition
for deleting the defaults, which is the actual fix: a loopback address
is only correct when the callee shares the caller's netns, and the
forge and homeserver are moving to swarm level, possibly onto other
hosts.

An absent var emits nothing rather than a guess. Once the defaults are
gone that surfaces as an eval failure, which is the point — better a
build that stops than an agent quietly talking to a port on the wrong
machine.

The emit is a pure helper rather than an inline loop so it can be
tested without process env. The first version of the test set env vars
and rendered the whole flake; it failed because the parallel runner
raced it against the existing env-mutating test, not because of any
defect. Testing the pure function has no such hazard, and the
render-level variant is kept #[ignore]d with that reason recorded.
2026-07-31 20:12:44 +02:00
iris
761f4b8351 dashboard: surface the hive's hyperhive rev on the H0M3 start page
Adds hyperhive_rev to the dashboard's /api/state StateSnapshot,
resolved via the same current_flake_rev helper get_agent_meta's
per-agent hyperhive_rev already uses. home.js renders it next to the
existing hive-identity line, truncated to the last 12 chars with the
full value in title=, hidden when the flake ref isn't a local path pin.

Requested by annika (infra.run) via dmatrix, hyperhive#2854.
2026-07-31 19:32:20 +02:00
damocles
c16c31c0f9 job_queue scheduler: observe shutdown every loop iteration, document the no-persistence invariant
run_worker only polled shutdown.changed() inside the select! arm, which the
claim-and-spawn branch skips via continue whenever there's ready work. Under
a sustained stream of ready claims (a boot sweep across agents is the
realistic case) exit was deferred until the queue happened to drain instead
of being observed promptly.

check *shutdown.borrow() explicitly at the top of every loop iteration
instead of relying solely on the select! arm, so a busy loop still sees
shutdown promptly.

also documented why dropping pending Queued DAGs on shutdown is safe: every
NodeKind is idempotent-convergent, which is a property of the node set, not
of the queue, and isn't enforced by the type system. flagged the side-effect
tails (EmitRebuilt, ResolveApproval) as the ones closest to the edge.

fixes hyperhive/hyperhive#2848
2026-07-30 12:17:38 +02:00
atlas
be27a62fb1 refactor(#2825): complete_node takes only the node id
`NodeId` has been globally unique across DAGs since #2801, so the dag id
carried no information the node id didn't. The parameter was already
underscore-prefixed as unused, but still populated by `claim_ready`, carried
through the scheduler's mpsc on every `Claim`, and passed at the call site —
three layers of plumbing feeding a dead argument.

Removing it surfaced four more dead things it had been keeping alive:
`settle_approval_tail`, `settle_rebuild_tail` and `drain_meta_syncs` each took
a dag id they only forwarded to `complete_node`, and one `submit` binding was
never read. Those are deleted rather than underscore-prefixed — prefixing is
what let the original argument survive this long.

`Claim.dag_id` stays: it has live consumers in the tracing spans,
`append_subgraph`'s container guard, `Ctx` for the build-log link,
`first_error`, and the approval-deploy context.
2026-07-28 12:42:31 +02:00
damocles
ef1554a1b2 add per-agent and hive-wide skill invocation stats 2026-07-27 23:53:07 +02:00
atlas
b9aab7e923 refactor(#2808): the wire state enum is the scheduler's own
`hive_host_sock::jobs::State` was a hand-maintained copy of
`hive_jobq::State` — five variants spelled the same in both, kept in sync
by whoever remembered. Adding `Skipped` last week meant adding it twice.
The wire crate now re-exports the scheduler's enum and `to_wire_state` is
gone.

Two states that were hidden now reach clients. `to_wire_state` renamed
`Pending` to `Queued` and folded `Finishing` into `Running`, so the
dashboard could not distinguish a node waiting on its dependencies from
one whose own work is done while its sub-nodes still run. Both are now
visible, and consumers say which they mean.

Every consumer had to move with it, and only the Rust ones said so: the
exhaustive matches in `hivectl` and `DagView::rollup_state` failed to
compile, while the dashboard's fourteen string comparisons would have
gone quietly wrong — a `finishing` node no longer counting as running,
a `pending` node no longer as queued.

The frontend also builds CSS class names out of the state string
(`rqe-` + state, `rqe-node-` + state) and keys its glyph map on it, all
lowercase. Those go through a `stateSlug` helper now; comparisons use the
wire spelling, presentation lowercases. Without that split every queue
entry and node chip would have silently lost its styling.

Dropping the `State as JobState` alias in hive-c0re falls out of this:
the alias only existed to tell two `State` types apart, and there is one
now.
2026-07-27 21:50:24 +02:00
atlas
6f551334de refactor(#2802): drop the wrappers that now only forward to the graph
`dag_of` and `dag_first_error` had shrunk to a single delegating call once
the walks moved into `hive-jobq`; their callers say what they mean without
the hop.

`subtree` was worse than redundant. It collected the descendant ids into a
`Vec` and both callers then looked each node up again by id — `dag_view`
needed a `let … else { continue }` for a lookup that could not fail.
Iterating `descendants()` hands back the node directly, so the round-trip
and the re-lookup both go.
2026-07-27 21:27:17 +02:00
atlas
5c5c8776d2 refactor(#2802): cancelling a DAG is a scheduler operation
`JobQueue::cancel` decided whether a DAG could be cancelled by reading node
run-state, walked the subtree, judged per node whether that node had asked
to observe cancellation, and re-ran the container's roll-up. Every one of
those is a fact the scheduler owns; core was reaching across the boundary
to compute them.

`Scheduler::cancel_node` now takes the whole subtree: cancelling a node
cancels the work under it, since a group is abandoned by abandoning its
root. The existing method generalises rather than gaining a sibling — it
had one production caller, which this replaces.

The gate runs over the work *under* the node, not the node itself: a group
root's state is its subtree's roll-up rather than a step that ran, so a
container is `Finishing` and never `Pending`, and gating on it would refuse
every cancel. A node with no children is its own work, which keeps the
previous single-node behaviour.

`observes_cancellation` moves in with it — it reads a node's declared edges
and knows nothing about what the payload means.

Core keeps the one genuinely domain-specific step, resolving a wire
`dag_id` to its container node, and is three lines otherwise.
2026-07-27 21:27:17 +02:00
atlas
8c5de704be fix(#2772): is_settled distinguishes "not finished" from "no such node"
Returning `false` for an unknown id gave the same answer as a node that is
merely still running, so a caller polling a stale id would wait forever for
a state that can never arrive. `Option<bool>` makes the two cases separate,
matching `node()`'s convention that `None` means the id isn't in the graph.
2026-07-27 21:26:34 +02:00
atlas
4de5a8dd7c refactor(#2772): graph walks belong to jobq, not to its caller
hive-c0re hand-rolled four traversals over a graph it doesn't own, because
`Graph` exposed only `node()` and `nodes()`. They are generic — nothing in
them knows what a hyperhive DAG is — so they move to `hive-jobq` and core
delegates.

`Graph` gains `root_of`, `descendants`, `roots`, `is_settled` and
`first_error`; they reuse the private `is_descendant` the crate already had
for its dep-scope rule. `subtree` gets faster on the way: core walked every
node's whole parent chain to the root for every node in the graph, where
`is_descendant` stops as soon as it sees the ancestor.

`first_error` deliberately looks for the first `Failed` descendant that
*carries* an error rather than the first `Failed` one. A node that rolled
its failure up from a child holds no error of its own and sorts before that
child, so the simpler version reports `None` for the common case and the
dashboard loses the reason. The distinction has its own test.

`dag_is_terminal` is deleted rather than moved: it was already a plain
`state.is_terminal()` read, and its three call sites now ask the graph.
2026-07-27 21:26:34 +02:00
damocles
9d5c7a7f7e hivectl: add agent <name> watch to follow live events from the CLI 2026-07-27 21:04:00 +02:00
atlas
657d1b5061 feat(#2788): carry Skipped to the wire as its own state
A node ruled out by its own dependency edges settles `Skipped` host-side,
but the wire folded it into `Cancelled` and `dag_view` filtered it out
entirely, so a client never saw which branch a run didn't take. Post-#2785
that is not a rare shape: every approval DAG has two not-taken tails and
every rebuild has one, on the happy path as much as on failure.

`State` gains `Skipped`, and it counts as terminal — the wait loops in
hivectl's progress display and the daemon's dag-settled check decide
"finished" with `all(is_terminal)`, so omitting it would hang them on
essentially every DAG.

`dag_view` now emits skipped nodes but no longer lets them keep a DAG
alive. Serialization and completion were the same expression: a DAG left
the snapshot because its nodes had all been filtered away. Keeping skipped
nodes on the wire under that rule would pin every finished deploy in the
queue view forever, so the completion test is now its own flag.

`rollupState` in the dashboard gains the matching arm. It has no `done`
case — `done` is inferred by falling off the wire — so its trailing
`return 'queued'` catches anything it doesn't recognise, and a green
deploy would have read as permanently queued the moment the backend
started emitting the new state. The Rust and JS roll-ups have silently
disagreed before; they are edited together here and say so.
2026-07-27 20:27:58 +02:00
atlas
15a9d5b652 feat(#2454): drain agents before stopping them in the boot sweep
A host restart brings hive-c0re up and the startup sweep rebuilds every
stale agent. Until now that stop was mechanical: `StopForUpdate` hung
straight off `Prebuild`, so an agent that was mid-turn when the host went
down had its turn cut off rather than finished.

The sweep now builds the same `Signal` -> `Drain` -> `StopForUpdate` chain
a graceful `hivectl restart` already uses, reusing the existing nodes and
`GRACEFUL_STOP_TIMEOUT` unchanged. Cost is bounded: the per-agent drains
overlap, so the sweep waits one timeout in total rather than one per agent.

`Signal` parents the rest of the stop instead of sitting beside it. All
three of `Signal` / `Drain` / `StopForUpdate` declare the agent lease, and
a resource is held across its holder's whole subtree — as siblings each
would take the lease separately, leaving a window between them for another
DAG to claim the agent mid-bounce.

Scope is the boot sweep alone: a manual rebuild, a meta-update cascade
child and a deploy all still stop mechanically, and a test pins that shape.

`rebuild_nodes` takes a `RebuildOpts` struct rather than a second
positional `bool`, which two adjacent flags would have made easy to swap at
a call site. Its callers no longer hard-code the subgraph's length either:
the `EmitRebuilt` tails and `FinalizeDeploy` used literal indices that
silently encoded "this builder emits exactly six nodes with `Reconcile`
last", which a variable-length subgraph turns into a wrong-node edge rather
than a compile error. They read the index off the emitted list now.
2026-07-27 20:16:56 +02:00
damocles
03afbd1316 hivectl: rename hivectl agents to hivectl agent <name> <verb> 2026-07-27 19:07:18 +02:00
atlas
940c928fee refactor(#2772): enumflags2 for the edge set; drop re-export shims
Review follow-ups on #2785.

`DepWhen` wraps `BitFlags<TerminalState>` instead of a hand-rolled `u8`,
so the bit manipulation belongs to the library and `TerminalState` gains
its flag value from `#[bitflags]` rather than a `bit()` match anyone
could get wrong. `of`/`accepts`/`is_empty` become one-liners over it.

Serialization is written out by hand rather than derived: clippy's
`unsafe_derive_deserialize` fires on deriving over a type with unsafe
internals, and the honest fix is to say what the wire form is. It is now
the list of accepted outcomes — `["done","failed"]` — which reads better
than a bitmask and survives the bits being renumbered.

Also drops the `pub use` re-export of `DepWhen` / `TerminalState` from
hive-c0re's `model`. It existed so that `use super::model::…` kept
compiling, which is a shim for one consumer's convenience; the sites
import from `hive_jobq` directly now.

And removes comments narrating what the code used to be. Git holds that.
2026-07-27 19:06:27 +02:00
atlas
07078b76ef feat(#2772): branch on outcome in the graph, not inside the node
Splits what was one `Cancelled` outcome into two, because they were two
different facts wearing one name:

- `Skipped` — the node's own edges ruled it out. Expected; the failure
  branch of a run that succeeded is `Skipped`. A parent's roll-up
  **ignores** it.
- `Cancelled` — the work was dropped before it could start. Still
  not-success for the roll-up, as before.

Without that split, branching on outcome defeats itself: exactly one
branch is always ruled out, `any_child_failed` counted it, and every DAG
containing a branch would have rolled up failed no matter how the run
went. Caught in review before it was written, not after.

`AFTER_ANY` becomes `{Done, Failed, Skipped}` — "anything except the work
being dropped". That is what it always meant; it only swept in
cancellation because cancellation wasn't distinguishable from
elimination. Audited every user rather than assuming, which is how the
one regression in my own proposal surfaced: `{Done, Failed}` would have
refused to run rebuild's recovery `Reconcile` after a failed `MetaSync`
(that eliminates `Prebuild`, so the tail's dep is `Skipped`, not
`Failed`) and left the container down.

With that, the templates stop computing outcomes and let the graph pick:

- `ResolveApproval { approval_id, outcome }` — one tail per outcome, each
  edged to accept only its own, so exactly one is ever runnable.
- `EmitRebuilt { agent, ok }` — a pair. `ok` is not derived, it is which
  of the two the graph let run.

Edges are conjunctive, so "any of these roots failed" is not directly
sayable. The composition: the success branch is `AFTER_OK` on every root
(so it is itself eliminated the moment one doesn't succeed), and the
failure branch keys off *that* elimination. The failure branch also
waits on every root — without it, a failed `Prebuild` eliminates the
success branch immediately and the failure would be announced while the
recovery `Reconcile` was still running. The tests caught that one.

Deletes, all of them #2770's host-side debt:

- `Claim.deps`, `DepOutcome`, `Claim::deps_state`, `Claim::deps_error`
  and the dep-snapshotting loop in `claim_ready`. Executors read their
  own variant now; nothing inspects anything.
- `NodeKind::is_tail()` and the `cancel` exemption built on it. Sparing
  is derived from the edges: `cancel` keeps a node iff one of its edges
  accepts `Cancelled`. An approval tail names it and survives to resolve
  the row; `Reconcile` doesn't and is cancelled with the rest. My earlier
  claim that this couldn't dissolve was only true while `AFTER_ANY`
  accepted cancellation.

`resolve_approval_dag` / `deploy_terminal_tag` now take `TerminalState`
rather than the wire `State`, so both matches are exhaustive instead of
ending in a catch-all.

Skipped nodes are filtered off the wire alongside `Done` ones. That costs
some dashboard detail on a failed rebuild — which steps were skipped —
and the tests say so with a pointer to the follow-up. Surfacing them as
`Cancelled` instead would be worse: the client roll-up ranks `Cancelled`
above `Running`, so a successful DAG with a not-taken branch would read
as cancelled.
2026-07-27 19:06:27 +02:00
atlas
affedecaa5 feat(#2772): make a dependency edge a set of accepted outcomes
`DepWhen` was two named cases, so every new combination wanted a new
variant. It is now a set over the terminal outcomes: a `u8` bitset
newtype, no dependency, with `AFTER_OK` / `AFTER_ANY` kept as the two
constants the templates actually use. "Run regardless" is all outcomes,
"anything that isn't a failure" is `{Done, Cancelled}`, a compensation
branch is `{Failed}` — closed under combination, so it never needs
another variant.

`TerminalState` is its own type rather than a subset of `State`, so an
edge cannot name `Pending` / `Running` / `Finishing`. Those are
meaningless in a dependency and are better unrepresentable than
validated against. The empty set is the one thing that can't be typed
away — nothing satisfies it, so `validate` rejects it next to the cycle
check.

Two consequences worth calling out:

- `cascade_cancel` collapses to one rule: a pending node is doomed once
  any edge it names can no longer be satisfied. The hardcoded `AfterOk`
  special case is gone, and a weak-edged node survives its dependency's
  cancellation because of its own edge rather than by exemption.
- The cascade now runs on **any** terminal outcome, `Done` included.
  With sets, success rules dependents out just as failure does — a
  `{Failed}` branch is unsatisfiable the moment its dependency succeeds,
  and leaving it `Pending` would wedge the subtree non-terminal forever.
  That is a hang, not a wrong answer, so it is the load-bearing half of
  this commit.

Edges are conjunctive, so "any of these N failed" is not directly
sayable. The composition that works is in the tests: the success branch
depends `AFTER_OK` on every root, so it is itself cancelled the moment
one of them doesn't succeed, and the failure branch hangs off *that*
with `{Cancelled}`. Exactly one of the two runs.

Also deletes hive-c0re's duplicate `DepWhen` enum and the
`to_crate_when` translation beside it. The copy bought nothing and had
to be widened in lockstep with the crate's edge model — it is the
in-between layer #2772 exists to remove, and it is what broke the build
when the crate's spelling changed.

All 34 jobq tests pass, including the four new ones covering both
directions of a failure-only branch, weak-edge survival of a cancelled
dependency, and the aggregator composition.
2026-07-27 19:06:27 +02:00
atlas
36918e0432 fix(#2756): a cancelled DAG must not read back as Queued
Review catch from argus on #2770: with the terminal hook gone, cancel
spares the DAG's tail node so it can report the cancellation — which
leaves that node `Pending` until the scheduler's next pass.
`post_rebuild_queue_cancel` emits its snapshot synchronously, and
`rollup_state` ranked `Queued` above `Cancelled`, so the operator who
just cancelled a DAG saw it go back to **Queued**: "the cancel didn't
take". Asserted in `cancel_clears_queued_dag`, which failed before this.

Fixing it surfaced an older disagreement. `builds.js::rollupState` has
always been `failed > cancelled > running > queued`; the Rust was
`failed > running > queued > cancelled`. The two had silently diverged
under a doc-comment claiming they agree. Harmless until now only because
cancelled nodes never coexisted with live ones — a spared tail running
over cancelled work would have rendered `Running` host-side and
`Cancelled` in the dashboard.

The JS was the correct side, so the Rust moves to match it exactly:
`Failed > Cancelled > Running > Queued > Done`. No frontend change.
2026-07-27 15:06:27 +02:00
atlas
e8e6998ac5 refactor(#2756): replace the DAG terminal hook with real tail nodes
The queue carried a per-DAG `HookKind` that fired an inline side effect
from outside the graph when a container rolled up terminal. mara asked
three times why this could not be an ordinary node; the answer in the
code was a doc-comment claiming a node could not work, and it was wrong.

`DepWhen::AfterAny` already existed with two live users, and a weak edge
is satisfied by a `Cancelled` dep, so a tail node runs on success,
failure and cancel alike. What was genuinely missing was smaller than a
hook: a node had no way to learn how the work it followed ended.

So: `Claim` now carries `deps: Vec<DepOutcome>`, snapshotted at claim
time from the graph the scheduler already holds (no `hive-jobq` change).
`Claim::deps_state()` / `deps_error()` roll that up, and two new kinds
consume it — `ResolveApproval { approval_id }` and `EmitRebuilt { agent }`.
Templates append one as a group-root with `AfterAny` edges onto the DAG's
other group roots; a root's state is its subtree's roll-up, so that
covers every node without fanning out to each of them.

Deleted: `HookKind`, `DagSpec.hook`, `NodeKind::Dag.hook`, `DagMeta.hook`,
`TerminalDag`, `terminal_dag()`, `terminal_summary()`, `dag_agents()`,
`dag_rollup()`, `fire_terminal_hook()`, `run_terminal_hook()`,
`emit_rebuilt()`. `complete_node` returns `()`.

Load-bearing details:

- `JobQueue::cancel` spares tail nodes instead of cancelling the whole
  subtree, and returns `bool`. Without this a cancelled approval DAG
  would dangle its approval forever — the hazard `tests.rs` already
  named. The spared tail's deps are `Cancelled`, which satisfies its weak
  edge, so the scheduler claims it and it resolves the row as cancelled.
  `hive-jobq` anticipated exactly this: `cancel_node`'s doc already says
  to settle afterwards so "a weak-edge terminal node observing the
  cancellation" can advance.
- The existing `complete(container)` call after cancelling is kept and is
  deliberately a no-op when a tail was spared (a non-terminal child parks
  the container back in `Finishing`), so power ops still settle
  synchronously with no branch.
- `DeployTail` is NOT `is_tail()`: it does real compensating work, and a
  cancelled DAG has nothing to compensate.
- `exec::failure_reason` falls back to `first_error(dag_id)` because a
  group root that rolled up `Failed` from a child carries no error of its
  own — without it every tail-reported failure would lose its reason.
- `EmitRebuilt` is per agent, so a multi-agent DAG reports each agent's
  own outcome rather than painting all of them with the DAG roll-up.
- `ResolveApproval` is agentless: the approval row already names its
  agent, and that is also what lets one tail close a multi-agent DAG.

Transients-derived-from-running-nodes and the frontend's node-kind
strings stay out of this change; they touch iris's slice and review
better next to their own diff.
2026-07-27 15:06:27 +02:00
atlas
2ad4b43118 refactor(#2693): null, not "", for the unpinned claude-code
mara on PR #2769: "make the default null instead of special casing """.

`claude_code_path` was a `String` whose empty value meant "no host-level
pin". That is a sentinel doing an `Option`'s job — the same shape argus
and mara already rejected on #2755's weights, and the same
empty-field cruft mara called out on #2756.

So it is `Option<String>` end to end:

- host module: `claudeCodePath` evaluates to `null` when
  `claudeCodePackage` is unset, so `serve.json` carries JSON `null`
  rather than `""`.
- `Coordinator` + `HiveEnv`: `Option<String>`, defaulting to `None`.
- `render_flake`/`render_flake_with_lookup`: `Option<&str>`, and the
  emission is an `if let Some(path)` instead of an `is_empty()` guard.
- agent module: `hyperhive.claudeCodePath` is `nullOr str`, default
  `null`.

Behaviour is unchanged in both directions; only the way "unset" is
spelled moves. The `builtins.hasContext` assertion still guards the
pinned case (short-circuited by the null check, so an unpinned hive
never evaluates it).

16/16 `meta::` tests, clippy clean, `nix fmt` no-op, `nix build .#docs`
green.
2026-07-27 13:56:28 +02:00
atlas
b08176f089 feat(#2693): let the operator pin the claude-code every agent runs
Agents run whatever `claude-code` the meta flake's `nixpkgs` resolves
to, and that is normally a release channel. This one package moves fast
enough that stable trails unstable by weeks — 26.05 is on 2.1.187 while
unstable carries 2.1.220 — and an agent cannot fix it for itself: it
only ever sees the single nixpkgs hive-c0re injects, so an `agent.nix`
has no other tree to reach for.

New host option `services.hyperhive.c0re.claudeCodePackage` takes the
package directly and rides the existing `hyperhiveDocs` threading path —
serveConfigJson -> HiveEnv -> render_flake — to reach each agent as
`hyperhive.claudeCodePath`. Null (the default) is today's behaviour.

What travels is the store *path*, as a plain string literal, not a flake
input: containers share the host's `/nix/store`, so the build is already
reachable inside them with its whole closure and has nothing to travel.
An input would be worse than useless — a `path:/nix/store/<pkg>` input
is re-copied as a reference-less `-source`, which strips exactly the
closure the binary needs.

The catch is that a path written into a generated flake is text, so
nothing in the container's closure keeps the binary alive. The host does
that instead, and gets it for free: the package is interpolated into
`/etc/hyperhive/serve.json`, `builtins.toJSON` preserves string context,
so the /etc entry references it and the system closure gc-roots it for
as long as that generation is the one the agents were rendered from. An
assertion pins that property, because losing the context is invisible at
eval and at deploy — it would surface only as every agent failing to
spawn `claude` whenever the next gc ran.

Container side wraps the path in a symlink farm rather than putting it
on PATH directly: `systemd.services.<name>.path` and
`environment.systemPackages` both coerce a store-path *string* through
`lib.toDerivation`, i.e. `builtins.storePath`, which pure evaluation
rejects. Interpolating the path into a builder is just text and
evaluates anywhere. `claude-code` drops out of systemPackages when a
pin is set, so there is exactly one claude in the container.

Refs #2693
2026-07-27 13:56:28 +02:00
atlas
ca7146e4f0 refactor(#2756): declare the terminal hook instead of inferring it
`Template` was a DAG-level enum that three different things read back
out: `terminal_hook()` mapped it to a side effect, the retention pass
bucketed history by it, and a tracing field printed it. None of those
needed a *label* — they needed the two facts the label happened to
encode. So the enum was a lossy stand-in for intent, and every new DAG
shape had to pick the variant whose inferred behaviour matched, whether
or not the name fit (`reparent` rode `MetaUpdate` for exactly this
reason, with a 10-line comment apologising for it).

Replace the inference with a declaration: `DagSpec.hook:
Option<HookKind>`. Only the builder assembling a DAG knows why it did
so, so only the builder can say what should happen when it settles.
`run_terminal_hook` becomes a field read, and `reparent`'s apology
becomes `hook: None`.

Hook assignment is byte-identical to the old precedence rule
(`approval_id.is_some()` wins, then `Rebuild | PermChange`), checked
site by site; `meta_update` is the only builder with a variable
approval id and so the only remaining conditional.

Retention loses the per-template bucket with the enum that keyed it.
The dashboard renders one recent-builds list, so one flat newest-first
cap (`MAX_HISTORY_DAGS`) bounds it. `HISTORY_GRACE_SECS` goes too — it
existed to stop a burst of same-template DAGs evicting each other
inside one poll interval, which is not a failure mode a flat cap has.
That takes `snapshot_capped()` and the `snapshot_no_grace()` test hook
with it.

The queue is runtime-only (empty graph on boot), so the serde changes
carry no migration risk.
2026-07-27 13:40:57 +02:00
atlas
a8728ac532 job_queue: move the jobs wire types to hive-host-sock
The DagView / NodeView / Source / State / PermPayload types only ever
travel on the host admin socket and the dashboard channels hive-c0re
serves off the same snapshot; their whole consumer set is hive-c0re,
hivectl and the socket protocol crate itself. Living in hive-sh4re made
the five other crates that depend on it carry job-queue types they never
name.

Pure move: git mv of the module plus the import sweep, no type changes.
hive-sh4re keeps its own chrono (wire_time still needs it).
2026-07-27 13:40:57 +02:00
atlas
5d3f2af75e refactor(#2754): make the container weights Option, not a 0 sentinel
Encoding "not configured" as weight 0 worked (the writer omitted the
line) but the type lied: 0 is not a legal cgroup v2 weight, and every
reader had to know the sentinel. Use Option<u32> end to end instead —
wire type, priv_client, HiveEnv, drop-in writer — so "unset" is a state
of the type rather than a magic value.

The nix options become nullOr, keeping their default of 80; null now
expresses "leave the setting out of the drop-in entirely" declaratively,
which is the useful shape on a host whose IO scheduler ignores
io.weight anyway.

Backward compat is unchanged: the fields stay #[serde(default)], so a
request from an older hive-c0re deserialises to None and reproduces the
pre-weights drop-in byte for byte. The test that pins that now passes
None instead of 0.
2026-07-27 10:55:29 +02:00