`CPUQuota=`/`MemoryMax=` are hard caps: they throttle an agent even when
the host is idle, so they are the wrong tool for "be polite under
contention". The cgroup v2 relative shares are, and neither was wired.
Adds `services.hyperhive.{agentCpuWeight,agentIoWeight}` (1..=10000,
default 80) threaded through the existing drop-in path: HiveEnv ->
write_dropins -> WriteResourceLimits -> hyperhive-limits.conf, next to
the caps already there. Hive-wide only, as the operator scoped it on the
issue: no per-agent override, no resource-limits.json field, no
dashboard form.
The default of 80 is below the kernel's 100, so agent containers yield
to everything *not* on this drop-in path -- host services and the infra
containers (hive-ci, hive-forge, hive-gateway, hive-matrix). It does not
rank agents against each other; they all carry the same weight.
`WriteResourceLimits` gains two `#[serde(default)]` fields, and the
writer treats weight 0 as "not configured" and omits the line, so an
older hive-c0re talking to a newer hive-priv still produces the exact
pre-weights drop-in. The body is extracted into `limits_dropin_body` so
that is covered by a test rather than asserted by eye.
On a repo with no CI configured forgejo returns the combined-status
`statuses` field as an explicit `null` rather than `[]`.
`#[serde(default)]` only covers a *missing* key — a present null still
fails to deserialize, so `pr-status` died with
`invalid type: null, expected a sequence` instead of reporting the PR.
Deserialize the field through an `Option<Vec<_>>` so both null and
absent map to an empty vec.
Closes#2752.
`Coordinator::set_paused` wrote the marker directly with `std::fs::write`
from hive-c0re, which runs as the unprivileged `hive-core` user. The
agent's harness dir is chowned to the agent user on every container boot
(`user.nix`'s activation chown), mode 0755 — so hive-core can stat the
marker but gets EACCES creating or unlinking it. Pause therefore only
ever worked on an agent that had never booted; the read side works
because a stat needs traverse, not write, which is why the paused pill
and `is_paused` looked healthy.
Route both directions through hive-priv, the root helper that already
owns the other writes into agent-owned directories:
- `PrivRequest::SetAgentPaused { agent_name, paused }`, with the marker
filename constant moved to hive-priv-sock. That is the narrowest crate
all three sides share (hive-priv deliberately does not depend on
hive-sh4re, which re-exports it for the in-container resolver). A
private copy on any one side would break pause silently, since every
reader just sees "no marker".
- `write_agent_state_file` generalised to `write_agent_dir_file`, taking
the target directory: `state/` and `harness/` are both agent-owned,
which is the same reason both need root.
- resume unlinks via `remove_file`, which acts on the leaf and never
follows a symlink — an agent could otherwise plant a link at the
marker path and have root delete an arbitrary file.
`Coordinator::set_paused` becomes an async round-trip; its three call
sites were already async. Both directions stay idempotent because the
dashboard toggle and `hivectl pause|resume` fire without reading the
current state first.
hive-c0re, hive-claude, hive-forge and hivectl each ship a README.md but
never declared it in their package manifest, so cargo/docs.rs metadata
did not pick it up. Every other workspace member already sets the field;
this closes the gap left after the README backfill.
The agents root is 0700 and owned by the daemon's user, so hivectl's
client-side existence guard hit EACCES on traversal for anyone not root.
It reported that as "this command needs root; re-run with sudo", which
turned three verbs' pre-flight check into a permission error about the
wrong thing: `choom`, `subvol upgrade` and `subvol snapshot create` all
failed at the guard rather than at whatever they actually needed.
The daemon runs as the owning user and already answers this question for
its own provisioning paths, so expose it on the host socket as
`AgentExists` and have hivectl ask. Operators reach that socket through
the `hive-admin` group, so the guard now works without sudo.
`choom` still needs root for `machinectl shell` — we ship no polkit rule
granting those actions — so it now checks the effective uid and says so
directly instead of failing later inside systemd's authorisation.
Six places in the tree hand-rolled the same connect / write one JSON
line / read one JSON line back. Two of them — the harness serve loop's
client and the MCP server's — were byte-identical apart from a six-line
wrapper, ~145 lines of literal copy-paste. The other four each
reimplemented a subset, and the subsets had drifted: some named the
socket path in their errors and some did not, one classified transient
against fatal failures and the rest retried nothing at all, two drained
the response and two decoded it.
That duplication was defended when the daemons were split out, on the
grounds that a daemon's socket etiquette should stay visible in the
crate that depends on it. The etiquette genuinely does differ. The code
does not, and five copies is where "each daemon documents its own
etiquette" stops paying for itself.
`hive-sock-client` now owns the transport once, generic over the
request and response types so it is protocol-agnostic: the host-served
control socket and the harness's in-agent socket both use it with their
own wire-type crates. The two real differences become values instead of
forks. Retry is `Retry::RideOutRestart` (2/4/8/16/30s, sized to ride out
a service restart) for callers with no natural retry of their own, or
`Retry::None` for callers already inside a poll loop where the poll
interval is the retry — and the reason each caller picked one is a
comment at the call site rather than a reimplementation. The response is
either decoded (`request`) or half-closed and drained (`notify`, where
the drain exists so the server's write-back doesn't land on a closed
socket). Whether a failure propagates or is logged and swallowed stays
at the call site, because that is the caller's choice and not a property
of the transport.
Errors always name the socket path now, everywhere. That detail is
load-bearing: a permission problem on a socket that reads as "is the
daemon running?" sends the operator to fix the wrong thing.
The transient-against-fatal enum is gone rather than moved. Serialising
happens before the retry loop and deserialising after it, so only
connect, I/O and short-read failures can reach the loop at all — a
deterministic failure is now unretryable by construction instead of by
classification.
It is deliberately a new crate and not part of `hive-agent-sock`. The
`*-sock` crates are pure wire types by convention — `hive-agent-sock`
depends on serde and nothing else — and the two largest copies talk to
the host socket, whose types live in a different crate entirely. A
transport in either wire-type crate would drag tokio into it and point
the wrong way besides.
No wire-format change: same JSON line in, same line out.
The new daemon's package was added to `nix/packages/default.nix` and
referenced from the forge agent module, but not to the `inherit` list in
the flake's `agentPackages` module — so `hyperhive.packages` never gained
the attribute and every agent container failed to evaluate.
Also drop the crate's `[[bin]]` section: the binary name defaults to the
package name and the path to `src/main.rs`, so all of it was restating
cargo's defaults. The comment explaining why this is a separate process
moves to the top of the manifest, where it isn't attached to a section
that no longer exists.
The poller was a `tokio::spawn` inside the `hive-agent` serve loop. It
never needed anything from that loop except a socket path, so being
in-process bought nothing and cost two things: a harness restart took
forge notifications down with it, and the whole forge/HTTP dependency
tree was linked into the serve-loop binary.
It is now `hive-forge-notify`, a per-agent daemon with its own systemd
unit, a sibling of `hive-bash-daemon` and `hive-matrix-daemon`. Same
contract as those two: it reaches the harness only by upserting todos on
the in-agent socket, and nowhere else.
The module moves verbatim (`notify.rs`) — the formatters, the activation
gates, the dedupe map and all 33 tests are unchanged. Only the socket
call sites are rewritten, onto a small local `todo_client` rather than
the harness's. That mirrors what both sibling daemons already do, and
the etiquette differs on purpose: the harness's client carries a 60s
backoff schedule sized to ride out a hive-c0re restart, which its
callers need because they have no retry of their own. This poller's two
call sites both sit inside the 30s poll loop and both treat a failure as
"leave the thread unread, try next tick", so the poll interval already
is the retry; a second backoff would only stack sleeps and delay the
rest of the batch.
The unit is `Restart=on-failure`, not `always`. An agent with no forge
account is a supported configuration and the poller reports it by
logging why and exiting 0 — under `always` that clean exit would be a
restart loop on every forge-less agent.
`forgejo-api`, `url` and `time` drop out of `hive-agent`'s dependencies
with the module.
Also corrects docs that outlived the code they described: the persisted
`forge_cursor` field is long gone (forge's own read-state is the durable
record of what has been delivered), but `docs/persistence.md` and the
`harness_state` module docs still documented it as live.
Agents had the official marketplace configured out of the box but an
empty plugin list, so nothing was installed from it unless an agent's
own config asked. skill-creator is the one plugin that pays for itself
generically: it teaches an agent to write, refine, and evaluate its own
skills, which is exactly the capability an agent can't bootstrap by
being told about it once in a prompt.
Defaulting the option keeps this consistent with claudeMarketplaces,
which already ships the official marketplace the same way. Documented
the list-option semantics next to both: a per-agent definition replaces
the default rather than extending it, so an agent that sets its own
plugin list has to name skill-creator alongside its entries.
`nixos-container stop` exiting 0 does not mean machined has dropped the
registration. A process sitting in the machine cgroup without being a
child of the container's init never receives the shutdown's SIGTERM if
it has been SIGSTOP'd, so the registration outlives the "successful"
stop. Every later start then fails with "Failed to register machine:
already exists", and machined re-persists the stale record across its
own restart, so there is no cleaning it up afterwards.
StopContainer now asks for the stop, waits for machined to release the
name, escalates to SIGKILL if it hasn't, re-verifies, and fails loudly
if the name is still held — so a caller is never told the stop worked
and then walks into the confusing registration error.
The probe resolves the name through machined's GetMachine, the same
lookup that later rejects the registration, rather than checking the
container's systemd unit: the unit going inactive while the name is
still held is precisely the case being caught.
Verify-and-escalate lives in the helper, not at a call site, so every
stop gets it — dashboard, reconcile, destroy, cold-start fallback. The
start path already distrusts its own exit code the same way; this is
the missing half of that pair.
Forgejo reports `"state": ""` in the combined-status response for a
commit that has no CI contexts at all. The typed `forgejo-api` client
models that field as an enum with no empty variant, so deserialization
failed and both verbs died outright — on exactly the pull requests
where "no CI ran here" is the useful answer. `pr-merge` was the worse
of the two: the crash sat in its pre-merge readiness check, blocking a
merge it should have waved through.
Route both call sites through the existing raw-JSON escape hatch
(`Client::get_api_json`), which exists for this failure mode: the
crate pins one schema while the server tracks the latest release line.
A lenient local `CombinedStatus` keeps `state` a plain `String` and
the per-context statuses as opaque values, so an empty or unknown
state is reported rather than fatal. `status_state_str` and its enum
mapping go away with it.
Closes#2735
Both groups only ever act on a single managed agent's state dir, so
they belong in the `agents` namespace rather than as top-level verbs
next to `forge` / `matrix` / `wg`.
Renames `quota limit` -> `quota set`: the enclosing group already
carries the noun, so the bare verb matches the flat `set-parent` /
`set-limits` spelling without stuttering, and it removes the
`set-limits` (cpu/mem) vs `quota limit` (disk) ambiguity. Adds a
cross-pointer from `set-limits` to `agents quota`.
Handlers stay in their own modules; `run_agents` gains the reparenting
glue. Regenerates docs/tools/hivectl-cli.md.
Refs #2724
The gateway container's /etc/resolv.conf is a one-shot copy: nixos-container
cps it in from the host in its start script, and nspawn's --resolv-conf=auto
copies (not binds) for a writable host-netns container. systemd-nspawn(1)
states the consequence outright — "no further propagation of configuration is
generally done after the one-time early initialization (this is because the
file is usually updated through copying and renaming)".
dnsmasq has no explicit upstream and follows that file, so a host network
change strands it on a resolver that no longer answers and every non-hive
lookup from every agent hangs. Agents' own resolvers point at the static
bridge IP and never go stale, which is why the symptom presents as "the
gateway needs a kick".
Add a host-side hive-gateway-resolv path unit watching /etc/resolv.conf.
On change it machinectl copy-to's the file into the container and reloads
dnsmasq — ExecReload is kill -HUP, so upstreams are re-read and the cache
flushed without dropping anything; nginx never notices.
- watched from the HOST: a rename on the host doesn't cross the nspawn mount
namespace, so an in-container path unit can't see it (same reason c0re
reloads nginx from the host side)
- copy, not a file bind-mount: openresolv renames over the file, so a bind
would pin the first inode forever — strictly worse than today
- machinectl copy-to writes through the container's own mount namespace, so
this holds regardless of how the container assembles /etc
- armed Before=network-pre.target so the boot's first DHCP write is caught,
and re-run on gateway start for changes made while it was down
- a host file with no nameserver line is skipped, not pushed, so a
mid-rewrite snapshot can't blank hive DNS
- deliberately no fallback server=: dnsmasq queries all known upstreams in
parallel, so a hardcoded public resolver would take a share of normal
traffic rather than only covering the gap
After each notification poll, query GET /api/v1/issues/search with
assigned=true for both issues and pulls, read X-Total-Count, and
upsert/clear a keyed 'rollup' todo (subsystem=forge, key=rollup).
When the total is > 0 the summary reads e.g. '3 open assigned: 2 issues,
1 PR'. When it drops to 0 the rollup todo is cleared. The 'rollup' key is
distinct from per-thread numeric keys so clearing it never touches
notification todos.
Closes#2725.
Closes 2718.
The operator has been going through agent dirs by hand with ncdu,
deleting 20+GB target dirs. Agents had no way to know they were the
ones sitting on the space.
New `disk_watch` module in the harness: every 15 minutes it statvfs's
the filesystem backing the agent's state dir and, past 80%, raises a
keyed `disk` todo telling the agent to free space — with the operator's
rules inline: only delete things that are actually big, build output
first, and never delete something still needed, ask for more space
instead.
Over threshold it also walks the agent's own tree (`/agents/<label>`
plus `$HOME`) and names the directories worth looking at, so the todo
says where the bytes actually went rather than just that the disk is
full. The walk is bounded on every axis — entry budget, recursion cap,
report depth — pinned to the state dir's device so it can't wander into
`/nix` or the shared bind mounts, and it does not traverse symlinks. It
reports the deepest oversized directory on each branch, so the agent
gets pointed at `<workspace>/target` rather than at `/agents/<label>`.
Anti-nag is the whole design constraint. The todo is keyed, and the
summary is deliberately stable: the percentage is bucketed to 5 points
and no raw byte counts appear anywhere in it. An unchanged situation
re-upserts as `changed == false` and never fires the wake, so a disk
that has been steady at 89% for a week sits quietly in the loose-ends
list; crossing into a new bucket speaks up once. Dropping back under
the threshold clears the row.
Harness-local by construction, per the operator's call that this gets
no core wiring: hive-c0re cannot push a todo at all (the store and its
wake live inside the container), and running in-process means this
skips even the in-agent socket and calls `Todos::upsert` directly.
Worth recording, since it shaped the scope: btrfs does NOT fold qgroup
limits into statfs. Measured with quota counting enabled and a 20G
limit set on a real subvolume, statvfs returns byte-identical whole-FS
numbers for that subvolume, an ordinary agent dir, and the root. So
this watches host-FS pressure, which is valid before and after the
planned subvolume migration; per-agent quota awareness would need the
limit handed to the agent explicitly.
`hivectl open forge` on a host where the daemon is fine and the socket is
fine printed "could not reach the hive-c0re daemon for URLs — is hive-c0re
running?". It was running. The operator was not in `hive-admin` in that
shell, and the connect got EACCES.
The message was a guess, not a diagnosis, because `query_hive_urls`
returned `Option` and threw the cause away with `.ok()`. Three different
failures — not in the group, no socket at all, nobody listening — all
arrived as the same sentence, and only one of the three is fixed by
looking at the daemon.
Classify the connect error in `client::request`, which every
daemon-assisted verb goes through, and keep the io error as the anyhow
cause so the output reads fix-first. EACCES names `hive-admin`,
`services.hyperhive.adminUsers`, and — the part that actually bites — the
re-login, since secondary group membership is only applied at login, so a
shell opened before the grant still cannot connect. ENOENT and
ECONNREFUSED point at the units instead.
Then stop discarding it: `query_hive_urls` returns `Result<Option<_>>`,
`open` and `require_hive_domain` propagate, and `daemon_request` drops its
own "connect to daemon socket" context, which only buried the actionable
line under a vaguer one. `wg init`'s domain lookup stays best-effort by
an explicit `.ok().flatten()` rather than by accident.
Same footgun `agent_exists` was already fixed for: a permission error
collapsed into a value that reads as a different, wrong story.
Closes#2720 (partial — kept state + infra card layout).
core.html doesn't load dashboard.css so the .container-row styles from
the operator SPA weren't available. Add equivalent card rules directly in
core.css: .containers flex column, .container-row with bg-elev background
+ border + border-radius, .tombstone dashed variant, .head flex row with
badge + meta, .actions flex row for the action buttons. Matches the visual
weight of agent cards on the main dashboard.
Closes#2720 (partial — schedules + call history fixes).
schedules:
- Agent-name column headers: switch from -45° CSS transform (which clipped
names mid-glyph) to writing-mode:vertical-rl + rotate(180deg). Names now
read bottom-to-top without truncation in their 28px column.
- Shrink 'next' column from 8em → 5.5em and 'every' from 7em → 5em;
these only hold short duration strings so the wider widths wasted space.
call history:
- Approval history <li> items now get a lightweight card treatment
(bg-elev background, 1px border, 3px left accent) matching the visual
weight of the pending .approval-card items above them.
- Left border colour reflects outcome via :has(.glyph-*): green for
approved, red for denied, amber for failed.
Closes#2720 (partial — stats tab placement + contrast).
The time-window nav was in <main> below the page title, so it controlled
hash routing but wasn't visually part of the page chrome. Move it into
<header> (same row as ← home / ST4TS), fill the remaining header space,
and right-align the buttons — consistent with the /logs.html tabbar pattern.
Also set explicit color/border on inactive buttons so they remain readable
on lower-contrast operator colour schemes (var(--subtext1) fallback to
var(--muted)).
Closes#2720 (partial — logs scroll fix).
Make body.logs-shell a full-viewport flex column so the visible .logs-pane
fills the remaining height and .journal-output (already flex:1 overflow:auto)
scrolls its content. Also handle the AUDIT tab's <div> output the same way.
The tab bar and toolbar stay anchored at the top; only the log text scrolls.
The revert hook is dead by construction, so it can only ever be wrong.
DAG state `Cancelled` has exactly one producer: `JobQueue::cancel`, which
refuses unless every work node is still `Pending`. A cancel *cascade*
(some node failed, downstream cancelled) rolls up `Failed` instead —
`dag_rollup` short-circuits on any failed subtree node. So on a DAG that
reaches `Cancelled`, no node ever executed: the `SetWanted` head provably
never ran and `wanted` still reads whatever the operator last set it to.
There is therefore nothing to revert, and `revert_intent` did not revert
anything — it wrote `Wanted::from_running(observed)`, i.e. the agent's
*observed* state, over an intent the DAG never touched. Harmless when
observed already matched, silent corruption otherwise: cancel a queued
start for an agent that is down but `wanted = Up` (crashed, or caught
mid-bounce) and the intent flips to `Offline`, leaving it
deliberately-stopped as far as reconcile and crash-watch are concerned.
The hook made sense when `set_wanted` was a pre-submit side effect
written before the DAG ran; moving it into the DAG as a node left the
hook vestigial.
Drop `HookKind::RevertIntent`, `revert_intent`, and the power-op arm of
`terminal_hook` — start / stop / graceful-stop now settle with no
terminal hook, same as restart always did. The test asserts the general
statement across restart/stop/start x graceful x running: stop and start
carry a `SetWanted` head, and cancelling them still fires no hook.
`terminal_hook` mapped `Restart` / `GracefulRestart` to `RevertIntent`, but
a restart never writes `wanted` — `restart_chain` deliberately has no
`SetWanted` head, so the tail `Reconcile` converges to the agent's existing
intent and a deliberately-stopped agent isn't forced up by a hive-wide
restart.
`revert_intent` writes `Wanted::from_running(observed)` unconditionally on a
cancelled DAG. So for an agent that is `wanted = Up` but currently down
(crashed, or caught behind another queued op), submitting a restart and then
cancelling it writes `wanted = Offline` — reverting an intent the DAG never
touched, to a value nobody asked for. Reconcile and crash-watch both then
read the agent as deliberately-stopped and leave it down.
It's invisible for a running agent, since `from_running(true)` equals the
intent already on file, which is why it went unnoticed. `cancel` only
succeeds while every node is still `Pending`, so the reachable window is
exactly "queued restart + observed != intent" — precisely when someone
restarts and then thinks better of it.
Drop both restart templates from the `RevertIntent` arm; they fall through
to no terminal hook, which is correct for a DAG that writes no intent.
Document the invariant on `HookKind::RevertIntent` and on `revert_intent`
itself: the hook writes *observed* state, so dispatching it for a template
with no `SetWanted` head doesn't restore an intent, it invents one.
Test covers all four restart shapes (graceful x running), asserting both
that the spec carries no `SetWanted` and that a cancelled restart dispatches
no hook, with a contrast arm pinning stop's revert in place.
Fixeshyperhive/hyperhive#2710
Adds CPU/memory cap columns and an inline edit form to the container-load
table in /core.html, backed by a new POST /api/resource-limits/{name}
dashboard endpoint.
## Backend (hive-c0re)
lifecycle_ops.rs — new post_resource_limits handler:
- Parses ResourceLimitsForm { cpu_quota, memory_max } (both optional; empty
string = clear override, fall back to hive-wide default).
- Validates each non-empty value via resource_limits::validate_cpu_quota /
validate_memory_max — returns 422 UNPROCESSABLE_ENTITY with a human-
readable message on invalid input so the dashboard can surface it inline.
- Calls meta::commit_resource_limits (staged git write under META_LOCK, same
as hivectl set-limits).
- Re-applies the drop-in immediately via lifecycle::write_dropins so the new
ceilings take effect on the next container start without waiting for a
rebuild.
- Triggers rescan_containers_and_emit so ContainerView.cpu_quota/memory_max
update via SSE without waiting for the next periodic sweep.
dashboard/mod.rs — registers the route:
POST /api/resource-limits/{name}
## Frontend (core.js + system-sections.css)
core.js:
- containersState derived from /api/state snapshot alongside tombstonesState
— supplies configured cpu_quota/memory_max to the LOAD table.
- lastLoadRows stash lets SSE-triggered re-renders call renderContainerLoad
without waiting for the next 5s poll.
- renderContainerLoad: adds cpu cap / mem cap columns (muted; tooltip
'configured ceiling — takes effect on next start') sourced from
ContainerView, plus a per-row S3T toggle button that expands an inline
edit form with cpu_quota / memory_max text inputs and a S4V3 button.
The edit form shows a restart hint, surfaces validation errors inline, and
collapses on success.
- container_state_changed SSE handler: updates containersState in place and
re-renders the LOAD table so the cap columns flip immediately after a save.
system-sections.css:
- CSS for the new cap columns (.cload-cap-th, .cload-cap) and inline edit
form (.cload-edit-row, .cload-edit-form, .cload-edit-label, etc.).
- Remove dead .rqe-step rule (step sub-step label retired from the wire in
'job_queue: retire the now-off-wire step sub-step label').
A config deploy could silently discard committed agent config. sock's
icon commit carried a full proposal/approved/building/deployed tag set
yet was not an ancestor of `main` — genuinely deployed, then dropped.
Two gaps compounded.
`prepare_applied_target` is documented as "fast-forward applied/main to
target", but `git_update_ref` is `git update-ref <ref> <target>` with no
old-value guard: an unconditional force move. Anything reachable from the
old `main` but not from `target` leaves the branch without a word.
And nothing checked that it *was* a fast-forward. `run_deploy_merge_verify`
asserts exactly one thing about history — that the live PR head still
equals the reviewed sha. That is a drift gate on the *head*; it says
nothing about the *base*. A PR opened from a stale base passes it
unchanged and then rewinds `main` when it lands.
Adds, in the order they run:
- an ancestry gate as step 3 of MergeVerify — the reviewed head must
descend from `applied/main`, else bail before the irreversible merge.
It sits after the fetch (the commit has to be local to check
reachability) and before the eval, so it stays inside the region where
nothing is mutated and the node is still safely retryable.
- `git_update_ref_cas`, used for the `applied/main` move.
`git update-ref <ref> <new> <old>` refuses, and leaves the ref alone,
when the current value is not `old`. The ancestry gate only proves the
target is safe against the `main` observed *then*; the CAS is what
keeps that proof true *now*. `run_deploy_apply` already reads
`prev_main` to park the rollback ref, so that value is threaded in —
re-reading it inside the callee would reintroduce the race.
`git_is_ancestor` returns `Ok(false)` for exit 1 rather than treating
"not an ancestor" as a failure. Its doc comment notes this is not the
"did this branch land upstream" question: a squash-merge rewrites the
commit, so `--is-ancestor` correctly answers false for a branch whose
contents were merged. Different question, same command.
Tests cover both directions of the ancestry check, and that a stale CAS
both errors *and* leaves the ref where it was — a guard that fails while
still moving the ref would be worse than none.
Not covered here, deliberately: the non-PR apply path also writes `main`
and wants the same treatment. Kept separate to stay reviewable.
The `step` label was taken off the wire in #2661, when each deploy phase
became a first-class DAG node. Since then it has been written but never
read: `NodeRuntime` derives only `Debug, Default, Clone` — no serde — so
the field could not reach any client, and the only reads of it were the
dedup checks inside its own setters. This deletes the machinery.
Removed:
- `NodeRuntime.step`, `set_step`, `set_step_running`, and the
`rt.step = None` clear in `complete_node`. `NodeRuntime` keeps its
remaining `build_log_id` field (deliberately still a struct — collapsing
it to a bare `Option<i64>` would churn every call site for no gain).
- `Ctx::step` and its ~15 call sites in `job_queue/exec.rs`. `Ctx` itself
stays: it is the build-log sink, which `run_prebuild` and `run_swap`
still use.
- `Coordinator::set_queue_step` and its 11 callers in `actions.rs`.
- `JobQueue::running_node_of`, reachable only from `set_queue_step`.
- `swap_update`'s `on_step` parameter and its one body call.
- The `set_step_only_on_running_and_signals_change` test.
Dropping the calls orphaned parameters, which are removed with their call
sites: `ctx` on ten executors that used it only as a step sink, and
`queue_entry_id` on `run_deploy_merge_verify` / `run_deploy_apply` /
`run_finalize_deploy` plus both `coord` and `queue_entry_id` on
`prepare_applied_target`. `run_deploy_tail` KEEPS its `queue_entry_id` —
that one has a genuine surviving use (the build-log link in the failure
comment posted to the PR).
One behavioural change, called out so it is not mistaken for a dropped
dashboard refresh: `Ctx::step` and `set_queue_step` each emitted a
`rebuild_queue_changed` snapshot when the label changed, and those
emissions go away with them. This is safe — the snapshot payload has no
step field, so those pushes carried nothing a client could observe. Real
state transitions still emit from the scheduler's claim and completion
paths, from `submit`, and from the three `actions.rs` sites. Net effect is
strictly fewer redundant SSE pushes.
Docs: `docs/coordinator.md` still listed `step` as a `NodeView` wire field
and `docs/web-ui/dashboard.md` documented a cyan `↳ <step>` sub-line under
each queue row. Neither has existed since #2661 — both corrected here, plus
the `job_queue/model.rs` module doc.
Not touched: `frontend/packages/dashboard/src/system-sections.css` has a
dead `.rqe-step` rule with no JS referencing it. Left for the frontend
owner rather than deleted here.
Closes: #2664
logs.html/logs.js previously showed infra containers (hive-ci, hive-forge,
hive-gateway, hive-matrix) in an optgroup within the AGENT tab selector.
This was confusing because infra containers don't run the per-agent hive
daemons, making the unit filter meaningless for them.
Changes:
- Add INFRA tab (between AGENT and SYSTEM) with its own container selector
and full-machine-journal fetch (no unit filter).
- Remove the infra optgroup from the AGENT tab — it now shows agents only.
- loadContainerLists() replaces loadAgentList(): fetches /api/state once and
populates both selectors, avoiding a duplicate network request.
- Deep-link (?agent=hive-ci) now routes to the INFRA tab when the named
container is an infra container, falling back to AGENT otherwise.
- Remove syncUnitSelectForSelection() — no longer needed since the AGENT
tab no longer contains infra containers.
- Extend the 30s timestamp ticker to cover the INFRA tab fetch time.
No backend changes: /api/journal/{name} already supports infra container names.
`container_view::build_all` renders every container on every scan, and
each agent's row resolved its limits through `effective()`, which reads
and parses `resource-limits.json` from disk. That is one file read per
agent per scan of a file that is identical for all of them.
Split the resolution in two: `effective_from` takes an already-loaded
map, and `effective` keeps the read-then-resolve shape for the
single-agent callers (`write_dropins`, which runs once per spawn and
rebuild and has no map to hand). `build_all` now loads the map once at
the top — the same treatment `topology::read()` already gets there —
and calls `effective_from` per agent.
No behaviour change: the fallback matrix lives in `resolve`, which both
paths still go through, and its tests are untouched.
The now-redundant `limits_for` is gone; `effective_from` covers its one
caller.
The hive applies one `agentCpuQuota` / `agentMemoryMax` to every
container. That's the right default and the wrong ceiling: a build-heavy
agent needs headroom the other twelve don't, and raising the hive-wide
value to suit it hands that headroom to everyone.
Adds a per-agent override, persisted host-side and resolved per-field
against the hive defaults.
Follows the existing `meta/*.json` pattern (`capabilities.json`,
`tool-groups.json`): a host-side map read by `hive-c0re`, staged and
committed in the meta repo so every change lands in the audit trail.
```json
{ "sock": { "cpu_quota": "400%", "memory_max": "8G" } }
```
Fallback is **per field**, not per agent: an entry with only
`memory_max` leaves that agent on the hive-wide CPU quota. Absent file,
absent agent and absent field all resolve to the hive default, so the
feature is inert until someone opts an agent in.
Unlike the other meta files this one is **not** injected into the
container — a limit is something done *to* an agent, not something it
reads about itself.
```
hivectl agents set-limits sock --cpu-quota 400% --memory-max 8G
hivectl agents set-limits sock --reset
```
Values are validated before they're persisted: they go into a systemd
drop-in verbatim, and a typo there makes the unit fail to *start* —
turning a fat-fingered quota into a container that won't come back.
The command is declarative: each call replaces the agent's whole entry.
That makes a forgotten flag a silent revert, so a bare `set-limits
<name>` is rejected at the clap layer and clearing needs an explicit
`--reset`.
`ContainerView` gains `cpu_quota` / `memory_max`, both always populated:
there's no "unset" state to render, only "same as everyone else". They
reflect what the drop-in *says* — what the next start will enforce — not
a live cgroup reading.
The write goes through `meta::commit_resource_limits` rather than the
bare setter, so it's staged and committed under `META_LOCK`. Writing
without committing would leave the meta working tree dirty for the next
`prepare_deploy` to trip over.
Docs: `persistence.md` (the new meta file, and why it isn't injected),
`tools/hivectl.md` (the prose guide), `tools/hivectl-cli.md`
(regenerated clap dump).
Closes: internal/requests issue 25
When ContainerView.paused is true, show a clickable yellow `⏸ paused`
badge on the agent card that POSTs to the new /api/resume/{name} endpoint
to un-park the turn loop. The badge doubles as the resume button so the
state is self-documenting and one click to fix.
The agent action menu gains ⏸ P4US3 (when not paused) and ▶ R3SUM3
(when paused), orthogonal to the running/stopped start/stop actions.
On the backend, /api/pause/{name} and /api/resume/{name} POST routes
wire to Coordinator::set_paused and trigger an immediate rescan so the
badge flips via the existing SSE ContainerUpdate without polling.
Depends on the ContainerView.paused field and Coordinator::set_paused
added in the parent PR.
dag_progress.rs's test-only node() constructor was missing the new
parent field added to NodeView in hive-sh4re. Add parent: None to
silence the missing-field compile error.
Replace the Unicode prefix string approach (└─ / ├─ / │ built up as text
in a single <span class="rqe-tree-indent">) with positioned DOM elements
that draw real lines:
- rqe-tree-guide: fixed-width ancestor column, optionally draws a full
vertical border-left when the ancestor has siblings below it
(.rqe-tree-guide-line).
- rqe-tree-connector: draws the L/T shape via ::before (vertical stem,
top→center for last child, full height for mid child) and ::after
(horizontal spur, center→right). .rqe-tree-connector-last vs
.rqe-tree-connector-mid controls stem length.
renderTreeNode() now takes ancestorLines: boolean[] instead of a prefix
string. Each entry is true when the ancestor at that depth was not the
last child (so a vertical guide is still needed through that column).
childAncestorLines propagates depth === 0 correctly (root nodes have no
guide columns, so their children start with an empty array).
Lines are drawn with var(--border) so they follow the theme and work at
any font size without alignment drift. Addresses the review note on PR 2686.
Add NodeView::parent to the wire (hive-sh4re + hive-c0re dag_view), then
render the recursive parent/child tree in the dashboard build queue instead
of the previous flat chain/fan-out layout.
Wire change (hive-sh4re, hive-c0re):
- NodeView gains parent: Option<NodeId> (skip_serializing_if = None)
- dag_view() projects node.parent, filtering out the Dag container id
(top-level work nodes become parent: None on the wire)
Frontend (builds.js):
- Replace nodeComponents + splitFanOut with buildNodeTree (uses parent
edges directly) + topoSort helper (orders siblings by deps)
- renderTreeNode walks the tree depth-first, rendering indented rows
with └─/├─ connectors and agent label per chip
- Flat chains and fan-out heuristics are gone; structure comes straight
from the scheduler's parent axis
Closes: none (parent issue tracked in forge)
A paused agent keeps its container, its claude session and its
dashboard/todo servers up, but stops driving turns. Messages queue
unacked and are drained on resume.
The whole protocol is a single marker file, `<harness>/paused`. That
directory is already a bind-mount shared between host and container, so
both sides just stat the same path: the harness reads it to decide
whether to drive a turn, hive-c0re reads it to render the badge and
writes/removes it for `hivectl pause|resume`. No new wire protocol, no
container round-trip, and it is sticky across restarts by construction.
Not calling `recv_next` while paused *is* the queueing semantic, so
there is no fencing to get wrong: reminders buffer in their unbounded
channel, the todo `Notify` permit coalesces, and a `request_next_turn`
that raced the pause survives because the gate sits above
`self_continue.take()`.
Graceful stop is handled host-side rather than in the harness: a paused
agent provably has no turn in flight, so `run_signal` skips the fence
entirely instead of eating the full `GRACEFUL_STOP_TIMEOUT` waiting for
a checkpoint turn that will never run.
`paused` is reported on `ContainerView` / `AgentStatusRow` for the
dashboard, orthogonal to `running` and reported for stopped containers
too.
Closes: hyperhive/hyperhive issue 2271