From 47c2acb6406e1be0aca218a45651bd0b14a905bd Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 1 Nov 2026 23:56:40 +0100 Subject: [PATCH] deploy the closure the swarm declares, read no forge head, drop the update badge --- AGENTS.md | 4 +- Cargo.lock | 1 - docs/agent-lifecycle/config-changes.md | 108 ++- docs/agent-lifecycle/persistence.md | 4 +- docs/integrations/forge.md | 10 +- docs/scheduler/coordinator.md | 50 +- docs/scheduler/observability.md | 20 +- docs/swarm/archetypes.md | 6 +- docs/swarm/builder.md | 16 +- docs/tools/hivectl.md | 7 +- docs/web-ui/README.md | 6 +- docs/web-ui/dashboard.md | 20 +- .../src/agent-menu/hive-agent-menu.js | 20 +- frontend/packages/dashboard/src/agents.js | 34 +- hive-agent/src/web_ui/state.rs | 2 +- hive-c0re/Cargo.toml | 1 - hive-c0re/README.md | 3 +- hive-c0re/src/agent_config/archetypes.rs | 9 - hive-c0re/src/attic/closure.rs | 168 ++++- hive-c0re/src/attic/probe.rs | 146 +--- hive-c0re/src/container_view.rs | 63 +- hive-c0re/src/coordinator.rs | 2 +- hive-c0re/src/dashboard/lifecycle_ops.rs | 30 +- hive-c0re/src/dashboard/mod.rs | 2 - hive-c0re/src/dashboard/state_snapshot.rs | 5 - hive-c0re/src/dashboard_events.rs | 4 +- hive-c0re/src/forge/internal_meta.rs | 104 --- hive-c0re/src/forge/mod.rs | 77 +- hive-c0re/src/forge/reader.rs | 5 - hive-c0re/src/job_queue/exec.rs | 198 ++---- hive-c0re/src/job_queue/exec/tests.rs | 312 ++------ hive-c0re/src/job_queue/mod.rs | 10 + hive-c0re/src/job_queue/model.rs | 6 +- hive-c0re/src/job_queue/power.rs | 44 +- hive-c0re/src/job_queue/tests.rs | 15 + hive-c0re/src/lifecycle/mod.rs | 10 - hive-c0re/src/lifecycle/tests.rs | 15 - hive-c0re/src/main.rs | 1 - hive-c0re/src/meta.rs | 4 +- hive-c0re/src/meta_rev.rs | 667 ------------------ hive-c0re/src/paths.rs | 29 +- hive-c0re/src/server.rs | 2 +- hive-c0re/src/stats/host_stats.rs | 2 - hive-c0re/src/swarm_status.rs | 53 +- hive-c0re/src/workers/auto_update.rs | 240 +++++-- hive-host-sock/src/lib.rs | 2 +- hive-meta-flake/src/lib.rs | 79 +-- hive-sh4re/src/container.rs | 7 - hivectl/src/agents.rs | 8 +- swarm-controller/src/meta_flake.rs | 3 +- swarm-queue-client/src/lib.rs | 10 +- 51 files changed, 645 insertions(+), 1999 deletions(-) delete mode 100644 hive-c0re/src/forge/internal_meta.rs delete mode 100644 hive-c0re/src/meta_rev.rs diff --git a/AGENTS.md b/AGENTS.md index 5d425bda..e4a27928 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,8 +95,8 @@ Per-hive (host + in-container): utility crate. - **`hive-metric/`** — one-shot CLI pushing a labeled metric over OTLP. - **`hive-meta-flake/`** — the meta flake's `flake.nix` text, a pure - render, and the reads of its pins. swarm-controller renders with it; - hive-c0re reads the pins of the `internal/meta` it builds from. + render, and the reads of its pins. swarm-controller renders and reads + with it; hive-c0re reads none of its pins. Wire types + socket plumbing: diff --git a/Cargo.lock b/Cargo.lock index 48eae4fa..f9afcf0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1775,7 +1775,6 @@ dependencies = [ "clap-markdown", "clap_complete", "ed25519-dalek", - "forgejo-api", "futures-util", "getrandom 0.3.4", "hive-core-agent-sock", diff --git a/docs/agent-lifecycle/config-changes.md b/docs/agent-lifecycle/config-changes.md index af161a4b..d70b5ea2 100644 --- a/docs/agent-lifecycle/config-changes.md +++ b/docs/agent-lifecycle/config-changes.md @@ -41,12 +41,12 @@ No MCP tool exists for config changes: opening the PR IS the request. and publishes a deploy request carrying that commit on that hive's deploy subject. With no such hive, or more than one, it deploys nothing and logs a warning naming them. -5. If the commit already is the one the agent's last successful build pinned - (the `applied/..config-rev` marker), the hive does nothing. Otherwise it - queues a rebuild, which resolves its pins from `internal/meta` like any - rebuild. The request's commit only feeds that check: the hive keeps no copy - of the config. An agent with no container on that hive yet ignores the - commit; its first deploy builds whatever `internal/meta` pins. +5. If the closure the swarm declares for the agent is the one the hive last + applied (the `applied/..closure` marker), the hive does nothing. + Otherwise it queues a rebuild, which downloads the declared closure. The + request's commit plays no part: the hive keeps no copy of the config and + reads no forge head. An agent with no container on that hive yet gets its + first deploy from the declared closure. This path runs **no eval-verify**. A merged config that doesn't evaluate or build fails the rebuild, so the agent's rebuilds keep failing @@ -130,35 +130,24 @@ nginx include in line with the live agents. `provision_container`, `destroy`, which means `nixos-container list` failed, and it fails when `HIVE_FORGE_URL` is unset, since every agent would come up with no forge. -The dashboard's `deployed:` chip per container row shows the config -commit the agent's last successful build pinned. - ## The swarm's meta flake swarm-controller commits `internal/meta`: one `flake.nix` and `flake.lock` for every agent any hive places (the pins: [`docs/swarm/builder.md`](../swarm/builder.md#meta-flake-lock)). The swarm builder builds every agent from it, and a hive deploys that build. While the -repo has no commit that pins an agent's current config, that agent's deploy -fails and names the agent. The controller queues +swarm has recorded no build of an agent, that agent's deploy fails and names the +agent. The controller queues its update of the repo before the deploy request for a merged config commit and for a new agent, and sends the deploy only when that update succeeded and the swarm builder built the agent from the updated repo (the [deploy gate](../swarm/builder.md#deploy-gate)). A failed build skips the deploy, and the timer builds again. -- **Choosing the commit.** The rebuild's `MetaSync` node reads the repo's - default-branch head with the hive's read token (`forge::reader_token()`) - and takes it when its `flake.nix` builds the agent the way the agent needs now: - a custom agent from the config commit at the head of `agent-configs/` - `main`, an archetype agent from the archetype it has now. An agent switched - between custom and archetype, or to another archetype, fails that test. - A repo with no commit, a head that lacks the agent and a head behind the - config each fail the deploy with an error that names the agent. `MetaSync` records the commit, - with the hyperhive and config commits the head pins, in `hive-c0re`'s own state, - `/var/lib/hyperhive/meta-rev/`, which the agent can't write (the container mounts its runtime - directory read-write); `Prebuild` and `Swap` read that - record, so both expect the closure of one commit. A first deploy resolves the commit in its `Create` - node, since it has no `MetaSync`. +- **Choosing the closure.** The hive doesn't choose. It deploys the closure the + agent's record in `agent-closures` declares, whichever `internal/meta` commit + the builder built it from, and reads no forge head. The rebuild's `MetaSync` + node only prepares it (the agent's archetype, its runtime directories, the + agent set). - **The closure.** The hive builds nothing. `Prebuild`, `Swap` and `Create` read the agent's record in the swarm's `agent-closures` bucket (the controller writes it from the builder's reply) and have `hive-priv` download the closure @@ -168,22 +157,20 @@ the swarm builder built the agent from the updated repo (the that agent returned. On the wire the `hive_priv_sock::PrivRequest` variants carry a required field: `{"op":"update_container","name":"atlas","stream":true,"toplevel":"/nix/store/-nixos-system-atlas"}`. - The three cases when the swarm's closure isn't the one this hive expects - are in [`docs/swarm/builder.md`](../swarm/builder.md#rebuilds-download-the-closure). + A rebuild with no record, or none the builder pushed, fails with an error + that names the agent; see + [`docs/swarm/builder.md`](../swarm/builder.md#rebuilds-download-the-closure). - **Hyperhive.** The commit pins one hyperhive rev for the swarm, so an agent runs the hyperhive of the controller's deploy, not its hive's. -- **Markers.** After a successful build `hive-c0re` writes the hyperhive - commit the head pins to `applied/..hyperhive-rev`, and the config - commit to `applied/..config-rev` (none for an archetype agent). The boot - reconcile ([below](#autoupdate-on-startup)) and the dashboard's update chip - read them. +- **Marker.** After a successful swap or create `hive-c0re` writes the closure's + store path to `applied/..closure`. The autoupdate pass + ([below](#autoupdate-on-startup)) compares it with the record. ## Deploy markers per agent ``` /var/lib/hyperhive/applied/ deploy markers - core-only -├── ..hyperhive-rev # hyperhive commit of the last good build -└── ..config-rev # config commit of the last good build +└── ..closure # store path of the last closure applied /var/lib/hyperhive/meta/ plain hive state - core ├── topology.json # the live agent set @@ -191,8 +178,11 @@ the swarm builder built the agent from the updated repo (the ``` hive-c0re keeps no copy of an agent's config: the forge's `agent-configs/` -`main` is the only one, and a config change is a PR against it. The two markers -record what the agent's last successful build pinned, and nothing else. +`main` is the only one, and a config change is a PR against it. The marker +records the closure last applied, and nothing else. Hives that ran before the +marker existed have `..hyperhive-rev`, `..config-rev` and +`meta-rev/` files instead. Nothing reads them any more, and you can +delete them. Older hives also kept an *applied* repo at `applied/` and a *proposed* clone at `agents//config`. Nothing reads either any more, and you can @@ -210,9 +200,8 @@ The bundled `hive-forge` container runs on the swarm's forge host (`deploy.forgejo.enable`, see [`../swarm/services.md`](../swarm/services.md)). Every agent created through the swarm has a repo in the private `agent-configs` Forgejo org, created and seeded by swarm-controller. -hive-c0re pushes nothing to it and keeps no clone: forge `main` advances only -by an operator's merge. The hive reads the head of `main`, to resolve the -commit `internal/meta` pins. +hive-c0re pushes nothing to it, keeps no clone and reads nothing from it: forge +`main` advances only by an operator's merge. Each agent is a **write collaborator on its own** `agent-configs/` repo — so it can push a branch and open a config PR — but not a member @@ -356,29 +345,30 @@ swarm-level alert could carry the same signal). `hive-c0re serve` runs `auto_update::run` in a background task right after opening the coordinator. It enumerates managed containers and -rebuilds any whose recorded hyperhive rev differs from the one -`internal/meta` pins now. That's how a hive that missed the swarm's -fan-out catches up. Sub-agents and the root agent go through the same -graceful rebuild subgraph. +rebuilds any whose closure marker differs from the store path the agent's +record in `agent-closures` declares. That's how a hive that missed the +swarm's deploy request catches up, and it repeats every five minutes, which +covers a request lost while the hive is up. Sub-agents and the root agent go +through the same graceful rebuild subgraph. -"Rev" = the hyperhive commit pinned by the head of `internal/meta`, as -the hive reads it with its forge token. Marker -file: `/var/lib/hyperhive/applied/..hyperhive-rev`, written after -a successful build. If the hive can't read the pin (no forge token, the -forge down, `internal/meta` not published yet), the scan rebuilds -nothing; a rebuild would have no flake to build from either. A hive deploy -alone doesn't rebuild agents. +The comparison reads the record and the marker +(`/var/lib/hyperhive/applied/..closure`, written after a successful +swap or create) and nothing on the forge. It rebuilds nothing when the hive +can't tell: no connection to the swarm queue, no record, a closure the builder +didn't push, or an agent with no marker. The last case covers every agent on +the first boot after the marker arrived, so that boot rebuilds nothing; the +agent's next deploy request from the swarm writes the marker. The periodic pass +attempts a declared store path once per process, and skips an agent while a +rebuild of it waits or runs, so a closure that fails to deploy doesn't +restart its agent every five minutes. Restarting `hive-c0re`, or a rebuild from +the agent's menu, tries again. A stopped agent gets its rebuild at its next start: the start path upgrades the start of a stale, stopped agent to a rebuild followed by the start. In -the window after a config merge, before the swarm's update has pinned the new -config, that rebuild fails with the error that names the agent, and the -agent stays stopped until the update lands and you ask for the start again. +the window after a config merge, before the swarm's builder has recorded the +new closure, the rebuild deploys the previous one. -The dashboard surfaces pending config changes per agent: a clickable -"needs update ↻" badge appears whenever the config commit that `internal/meta` -pins for the agent's next build differs from the one its last build pinned. The badge POSTs `/api/rebuild/`, which inserts the -same `job_queue::templates::rebuild` DAG so manual triggers and the -startup scan can't drift. When at least one container is stale, a -top-level `↻ UPD4TE 4LL` button appears that loops over every -stale container. +The dashboard shows no pending-update state: the hive applies a changed +declaration itself. The agent menu's rebuild queues the same +`job_queue::templates::rebuild` DAG, which downloads and applies the declared +closure again. diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index 87b113c8..05b33048 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -544,8 +544,8 @@ Isolation still holds by default: a container has its *own* dirs and, unless it holds the permission, nothing else. Under `/var/lib/hyperhive/applied/` — the hive-c0re-only deploy markers -`..hyperhive-rev` and `..config-rev`: the hyperhive and config -commits the agent's last successful build pinned. No config lives here; the +`..closure`: the store path of the closure the agent was last swapped to +or created from. No config lives here; the forge's `agent-configs/` is the only copy. A hive that predates this finds per-agent `applied/` repos here. Nothing reads them any more, and you can delete them. diff --git a/docs/integrations/forge.md b/docs/integrations/forge.md index 3d92d29c..7b7b85eb 100644 --- a/docs/integrations/forge.md +++ b/docs/integrations/forge.md @@ -63,7 +63,6 @@ store certificate, every time it reads the forge. A hive whose store holds none reads nothing. These reads go through it, and none of them needs a local forge container: -- resolving the config commit `internal/meta` pins for a build - cloning and pulling `/knowledge` The hive builds nothing, so no nix process reads the forge as the hive's @@ -107,19 +106,12 @@ Two things live in the `agent-configs` Forgejo organization: `main` directly nor self-merge. An operator's merge in the Forgejo UI deploys the merged commit (see [config-changes.md § Config changes](../agent-lifecycle/config-changes.md#config-changes)). - hive-c0re pushes nothing to this org; it only reads from it. + hive-c0re pushes nothing to this org and reads nothing from it. Repos stay private, so an agent can't read another agent's config. The agent's **persistent meta flake input** is this repo's `main` head, pinned by sha in `internal/meta` (`agent-.url = git+/agent-configs/.git?rev=`; see [config-changes.md § Meta flake](../agent-lifecycle/config-changes.md#meta-flake)). - hive-c0re's own git reads of this repo, which resolve the sha, authenticate with the hive's read - token through `forge::reader_token()` (see - [How a hive reads the forge](#how-a-hive-reads-the-forge)). -- The dashboard links each container's deployed config commit to this - config repo on the swarm's forge (`swarm.forge.publicUrl`; `null` means no - link), so operators can go straight from the 4G3NTS tab into - the rendered repo without an extra `git` step. The `hive-forge` CLI (separate workspace crate, see [`README.md`](../README.md) file map) wraps the Forgejo REST API diff --git a/docs/scheduler/coordinator.md b/docs/scheduler/coordinator.md index 66381c83..79a7126b 100644 --- a/docs/scheduler/coordinator.md +++ b/docs/scheduler/coordinator.md @@ -42,11 +42,11 @@ because there is no malformed spec to reject. Nix-heavy — hold one of the `buildSlots` permits for the node's duration: -| Node | Wraps | -| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Prebuild` | download the swarm's closure for the agent into the store while the container keeps serving (its meta preamble is the upstream `MetaSync` node). With no closure to deploy it fails here, before the stop. Skipped when the container is already down; `Swap` downloads it instead | -| `Swap` | drop-in rewrite + profile-swap onto the swarm's closure (requires the container stopped); the post-swap bookkeeping tail lives in the sibling `RebuildBookkeeping` node | -| `Create` | first-spawn `nixos-container create` proper; assumes the upstream `Provision` node already registered the agent; downloads the closure the swarm built for the agent and creates the container from it (with none to deploy it fails and creates nothing), then marks the agent's pins | +| Node | Wraps | +| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Prebuild` | download the swarm's closure for the agent into the store while the container keeps serving (its meta preamble is the upstream `MetaSync` node). With no closure to deploy it fails here, before the stop. Skipped when the container is already down; `Swap` downloads it instead | +| `Swap` | drop-in rewrite + profile-swap onto the swarm's closure (requires the container stopped); the post-swap bookkeeping tail lives in the sibling `RebuildBookkeeping` node | +| `Create` | first-spawn `nixos-container create` proper; assumes the upstream `Provision` node already registered the agent; downloads the closure the swarm built for the agent and creates the container from it (with none to deploy it fails and creates nothing), then marks the closure applied | Cheap — no build slot: @@ -54,7 +54,7 @@ Cheap — no build slot: | Node | Behavior | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MetaSync` | the rebuild's preamble — rebuild-dir prep, the agent-set refresh (`agent_set::refresh`), and the `internal/meta` commit `Prebuild` and `Swap` expect the swarm's closure to be built from, resolved once and recorded; fails when the repo doesn't pin the agent's current config. Declares no resources | +| `MetaSync` | the rebuild's preamble — rebuild-dir prep, the archetype refresh and the agent-set refresh (`agent_set::refresh`); reads nothing from the forge. Declares no resources | | `Provision` | first-spawn pre-create provisioning — the state subvolume, agent-set refresh (`agent_set::refresh`); runs ahead of `Create`. Store-only, no container yet | | `Reconcile` | idempotent power converge: read `wanted` (below) + observed state; start if `Up` & down (cold-start fallback included), stop if `Offline` & up, else noop | | `Start` | mechanical container start — runtime dir + drop-ins, `start_with_fallback`, MCP listener registration, the manager kick. Fanned out by a `Reconcile` that observed `wanted = Up` and the container down | @@ -103,7 +103,7 @@ state-conditional — skipped for a _down_ agent (nothing to quiesce/stop). Keep closes the TOCTOU window: if an agent flips state between the `is_running` read and node exec, the tail `Reconcile` still converges it in-DAG (with `StopForUpdate`-noop as the backstop) — no reliance on an external reconcile -sweep. `start` folds the per-agent stale-rev upgrade in (a _down + stale_ +sweep. `start` folds the per-agent declared-closure upgrade in (a _down + stale_ agent's subgraph is a rebuild-then-start). ```text @@ -114,7 +114,7 @@ restart(a..): online a: [Signal→Drain→ if graceful] StopForUpdate(a) → Re offline a: Reconcile(a) (nothing to stop; Reconcile converges to existing wanted) start(a..): a: SetWanted(a,Up) → Reconcile(a) (down+stale ⇒ SetWanted(a,Up) → «rebuild subgraph») spawn(a): [wanted=Up at deploy] Provision(a) → Create(a) → WriteDropin(a) → Reconcile(a) -boot: «graceful rebuild subgraph» per stale agent (rev marker ≠ the `internal/meta` pin); +boot: «graceful rebuild subgraph» per stale agent (closure marker ≠ the record's store path); plus Reconcile(a) for every drifted agent (all ONE DAG) ``` @@ -285,25 +285,31 @@ render. ## Boot reconcile -On startup, `auto_update::run` classifies every agent by rev freshness (the -per-agent `.{name}.hyperhive-rev` marker under `/var/lib/hyperhive/applied/` -vs the hyperhive commit the head of `internal/meta` pins) and persisted +On startup, `auto_update::run` classifies every agent by closure freshness (the +per-agent `.{name}.closure` marker under `/var/lib/hyperhive/applied/` vs the +store path the agent's `agent-closures` record declares) and persisted `wanted` intent, then: 1. **Config path** — submit one `Boot` DAG holding a graceful `Rebuild` subgraph for each stale agent whose `wanted = Up` (name-sorted). This is how - a hive that missed the swarm's fan-out catches up. Stale but wanted-offline + a hive that missed the swarm's deploy request catches up. Stale but wanted-offline agents get no boot-time nix work — their rebuild happens on their next start (the start submit path upgrades a stale start to rebuild+start). Each rebuild's tail `Reconcile` brings the agent (back) up, covering both the - running-stale and stopped-but-wanted-up cases. A hive that can't read the - pin (no forge token, the forge down, no `internal/meta` yet) rebuilds - nothing. + running-stale and stopped-but-wanted-up cases. An agent whose declaration + the hive can't read or compare (no queue connection, no record, a closure the + builder didn't push, no marker) rebuilds nothing, and neither does any + agent on the first boot after the marker arrived. 2. **Power path** — every agent whose observed state drifted from `wanted` gets a plain `Reconcile` DAG (`kind = reconcile`, source `auto_update`). -Booting with no pin change performs no rebuild — only reconciles. +Booting with every agent on its declared closure performs no rebuild — only +reconciles. After the boot pass, `auto_update::run` repeats the rebuild half +every five minutes. It skips an agent with a rebuild waiting or running +(`JobQueue::rebuild_live`) and one whose declared store path this process +already attempted, so a closure that fails to deploy gets one try per process. + The boot log line records the rebuild / deferred / up-to-date counts so the operator sees at a glance how much work the boot triggered. `auto_update::run` seeds agents without an `agent_power` row from observed state during @@ -314,8 +320,9 @@ classification (the one-time migration; thereafter the DB is authoritative). Agents build from the swarm's `internal/meta` flake, which swarm-controller commits and every hive reads (see [Config changes](../agent-lifecycle/config-changes.md#the-swarms-meta-flake)). -The hive keeps no flake of its own. `meta_rev.rs` picks the `internal/meta` -commit an agent builds from and records it; `agent_set.rs` keeps the +The hive keeps no flake of its own and deploys the closure the swarm builds +from it, as the agent's `agent-closures` record declares (`attic/closure.rs`). +`agent_set.rs` keeps the host-side files that follow the agent set (`topology.json`, the gateway's agent-socket map and nginx include) in line with the live agents, and `meta.rs` removes the permission seed files once the swarm has them, and @@ -342,9 +349,8 @@ one go, rather than the double-bounce a live `update` would trigger. Sequence for a rebuild DAG (each step is its own queue node): -1. `MetaSync` — rebuild-dir prep, the agent-set refresh, and the choice of the - `internal/meta` commit the swarm's closure should come from. Short, - and it declares no resources. +1. `MetaSync` — rebuild-dir prep, the archetype refresh and the agent-set + refresh. Short, and it declares no resources. 2. `Prebuild` — download the swarm's closure for the agent **before** stopping. The container keeps serving the previous generation while the download happens out-of-band. A missing or unpullable closure fails the DAG here, @@ -353,7 +359,7 @@ Sequence for a rebuild DAG (each step is its own queue node): no uptime to preserve, so `Swap` downloads it. 3. `StopForUpdate` — bring the container down (noop when already stopped). 4. `Swap` — profile-swap onto the swarm's closure (near-instant after the - prebuild). + prebuild), then it writes the closure marker. 5. `Reconcile` — boot into the new generation when `wanted = Up`; the in-container activation script transitions old → new. Holds no build slot, so the next DAG's `Prebuild` overlaps the container boot. diff --git a/docs/scheduler/observability.md b/docs/scheduler/observability.md index 62bc0ea2..70437f48 100644 --- a/docs/scheduler/observability.md +++ b/docs/scheduler/observability.md @@ -334,16 +334,16 @@ exists, so off-the-shelf OTEL/Grafana container dashboards work. Resource `service.name = hyperhive-c0re`; hive-c0re tags each data point `container.name` (= the `h-` machine) and the hive `agent` label: -| Metric | Unit | Kind | Source | -| --------------------------------------- | ---- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `container.cpu.time` | `s` | counter | cumulative `cpu.stat` `usage_usec` → seconds | -| `container.memory.usage` | `By` | gauge | `memory.current` | -| `hyperhive.container.memory.limit` | `By` | gauge | `memory.max` (custom — semconv has no `.limit` metric; omitted when unlimited) | -| `hyperhive.container.memory.peak` | `By` | gauge | `memory.peak` (custom — no semconv metric; omitted if unavailable) | -| `hyperhive.container.storage.usage` | `By` | gauge | `state/` + `work/` + the root template (the live ephemeral layer isn't counted), measured by `hive-priv` every 30 min (custom — semconv only has `disk.io`; omitted while unknown: not measured yet, or the last measurement failed) | -| `hyperhive.container.cpu.percent` | `%` | gauge | host-normalised percent (custom — no `rate()` needed) | -| `hyperhive.agent.unrestricted_internet` | `1` | gauge | the agent's `unrestricted_internet` flag in the swarm permissions this hive last read: `1` on, `0` off; a flag never set reads `1`. Omitted while the stored permissions file is unreadable | -| `hyperhive.agent.closure_pullable` | `1` | gauge | `1` when this hive can fetch the agent's latest closure from the swarm's binary cache and verify its signature, else `0` with a `reason` label (`no-record`, `rev-mismatch`, `not-cached`, `no-credentials`, `fetch-failed`, `bad-signature`; see [the builder docs](../swarm/builder.md#what-hives-do-with-the-cache)). Omitted before the first probe pass, every 5 minutes | +| Metric | Unit | Kind | Source | +| --------------------------------------- | ---- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `container.cpu.time` | `s` | counter | cumulative `cpu.stat` `usage_usec` → seconds | +| `container.memory.usage` | `By` | gauge | `memory.current` | +| `hyperhive.container.memory.limit` | `By` | gauge | `memory.max` (custom — semconv has no `.limit` metric; omitted when unlimited) | +| `hyperhive.container.memory.peak` | `By` | gauge | `memory.peak` (custom — no semconv metric; omitted if unavailable) | +| `hyperhive.container.storage.usage` | `By` | gauge | `state/` + `work/` + the root template (the live ephemeral layer isn't counted), measured by `hive-priv` every 30 min (custom — semconv only has `disk.io`; omitted while unknown: not measured yet, or the last measurement failed) | +| `hyperhive.container.cpu.percent` | `%` | gauge | host-normalised percent (custom — no `rate()` needed) | +| `hyperhive.agent.unrestricted_internet` | `1` | gauge | the agent's `unrestricted_internet` flag in the swarm permissions this hive last read: `1` on, `0` off; a flag never set reads `1`. Omitted while the stored permissions file is unreadable | +| `hyperhive.agent.closure_pullable` | `1` | gauge | `1` when this hive can fetch the agent's latest closure from the swarm's binary cache and verify its signature, else `0` with a `reason` label (`no-record`, `not-cached`, `no-credentials`, `fetch-failed`, `bad-signature`; see [the builder docs](../swarm/builder.md#what-hives-do-with-the-cache)). Omitted before the first probe pass, every 5 minutes | The `hyperhive.`-prefixed metrics have no semconv equivalent (memory limit + peak, on-disk footprint, and an instantaneous cpu percent kept diff --git a/docs/swarm/archetypes.md b/docs/swarm/archetypes.md index 6494599a..b733f5a6 100644 --- a/docs/swarm/archetypes.md +++ b/docs/swarm/archetypes.md @@ -127,12 +127,10 @@ swarm-controller renders `internal/meta`'s `flake.nix` with the The hive builds from `internal/meta` alone. It reads the agent's key when it provisions the agent and on every rebuild, and keeps the last value read in -`/var/lib/hyperhive/swarm-agent-archetypes.json`. It takes the head of -`internal/meta` only when that `flake.nix` lists the agent at the archetype -it has now. When the read fails, a rebuild keeps the stored value. For an +`/var/lib/hyperhive/swarm-agent-archetypes.json`. When the read fails, a rebuild keeps the stored value. For an archetype agent the hive also: -- ignores the `rev` of a swarm deploy request. +- deploys the closure its record declares, as every agent does. The agent's system prompt says it has no config repo and names its archetype. diff --git a/docs/swarm/builder.md b/docs/swarm/builder.md index cf00c877..90b321b8 100644 --- a/docs/swarm/builder.md +++ b/docs/swarm/builder.md @@ -462,12 +462,11 @@ decides the reason: | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `no-credentials` | One of the four leaves is missing or unusable (a URL that isn't `https` is unusable). The swarm has no cache, the controller is too old to copy them, or you haven't seeded them yet. | | `no-record` | `agent-closures` holds no build for the agent. | -| `rev-mismatch` | The record's build came from another `internal/meta` commit than its head that pins the agent's hyperhive or config differently, or the hive can't read the head. | | `not-cached` | The record says the builder didn't push the closure, or the cache answers 404 for the narinfo. | | `fetch-failed` | The request failed, or the cache answered with a status other than 200 or 404 (a refused token is a 401 or 403). | | `bad-signature` | The narinfo describes another path, or no signature by the public key covers it. | -A failed read of the store, the queue or the forge leaves the last result in +A failed read of the store or the queue leaves the last result in place and raises a warning after three failed passes. A hive reports nothing before its first pass. @@ -502,15 +501,14 @@ store, and gives `hive-priv` the store path to apply. A stopped agent skips `Prebuild`, so `Swap` downloads it. A first deploy's `Create` node does the same and creates the container from that path. -A rebuild or create has three outcomes, by what the lookup and the download find: +A rebuild or create has two outcomes, by what the lookup and the download find. The hive deploys the record's closure whatever `internal/meta` commit the builder built it from, and reads no forge head to choose one: -| Found | What happens | -| ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| a record that the builder pushed, for the commit this hive chose | The hive downloads the closure and deploys it. The hive logs `prebuild: substituted ` (`create: substituted ` for a create) at info level. | -| a record that the builder pushed, for another commit (the hive and the builder disagree) | The hive downloads the builder's closure and deploys it, with a warning that names the agent, both commits and the path. The hive evaluates nothing, and records the pins of the commit the builder built the closure from, so the agent's markers say what runs. | -| no record, or one the hive can't use (not pushed, queue or cache unreachable, download or signature failed) | The rebuild or create fails with an error that names the agent and the reason, logged at error level, and nothing changes. A running container keeps running unchanged, because `Prebuild` fails before `StopForUpdate`. A create makes no container. | +| Found | What happens | +| ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| a record that the builder pushed | The hive downloads the closure and deploys it, then writes the store path to the agent's `applied/..closure` marker. The hive logs `prebuild: substituted ` (`create: substituted ` for a create) at info level. | +| no record, or one the hive can't use (not pushed, queue or cache unreachable, download or signature failed) | The rebuild or create fails with an error that names the agent and the reason, logged at error level, and nothing changes. A running container keeps running unchanged, because `Prebuild` fails before `StopForUpdate`. A create makes no container. | -The hive never falls back to a local build. The third row has one owner in the +The hive never falls back to a local build. The second row has one owner in the code, `without_closure` in `hive-c0re/src/job_queue/exec.rs`, so a change to what an unreachable cache costs is one function. diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 8d0ac643..21bad35e 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -44,11 +44,10 @@ hivectl agent iris resume # let it drive turns again, draining what que ``` `list-agents` prints a padded table with one row per managed agent — -`NAME STATUS REV REMIND`. STATUS collapses the health flags -(`running` / `stopped`, plus ` paused` / ` needs-login` / ` needs-update` +`NAME STATUS REMIND`. STATUS collapses the health flags +(`running` / `stopped`, plus ` paused` / ` needs-login` when set — `paused` is orthogonal to running, see below); -REV is the first 12 chars of the agent's locked config sha; REMIND is the -count of pending reminders. It reuses the same per-agent aggregation the dashboard +REMIND is the count of pending reminders. It reuses the same per-agent aggregation the dashboard renders, so the CLI roster and the web UI never drift. `--json` emits the raw rows instead of the table. diff --git a/docs/web-ui/README.md b/docs/web-ui/README.md index 2483fdb4..f558352d 100644 --- a/docs/web-ui/README.md +++ b/docs/web-ui/README.md @@ -48,9 +48,9 @@ See [`config-changes.md`](../agent-lifecycle/config-changes.md#config-changes). **Start, stop, restart, or rebuild an agent.** Select one or more agents on 4G3NTS (select the icon) and use the selection bar, or use the -per-agent `⋮` menu on a single row. Rebuilding re-applies that agent's -current config; use it whenever an agent -shows as "needs update." +per-agent `⋮` menu on a single row. Rebuilding downloads and applies the closure the swarm +declares for the agent again. The hive applies a changed declaration itself, +so use it to retry one that failed. **Watch a build.** The dashboard's R3BU1LD QU3U3 tab shows the rebuild queue live, plus a streaming log of whatever's currently building. Useful right after diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 93ee943f..0004f031 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -65,9 +65,6 @@ one of them and rebuild. `lifecycle::{spawn,rebuild}` also preflight this and refuse with a clear error message naming the conflicting agent. -`↻ UPD4TE 4LL` button appears above the containers list when any -agent is stale. - ## K3PT ST4T3 tab Passive / rare-interaction state. `kept.js` renders it from the @@ -280,8 +277,7 @@ web server is down), so a single badge replaces the badge chain, the nav strip is empty, and status text / rate-limited / ctx badges disappear. The agent icon goes straight to the dimmed `/favicon.svg` fallback instead of attempting a doomed load from the -container's URL. Static fields — `needs_update`, `deployed_sha`, -`pending_reminders`, `parent`, `config` link — remain visible +container's URL. Static fields — `pending_reminders`, `parent`, `config` link — remain visible regardless of run state. @@ -296,7 +292,7 @@ below). restarts and gave up on its own) draws a red `✖ gave up` badge; otherwise a plain muted `■ not running` — a container an operator stopped deliberately. Both states read `running: false`; `failed` is -the orthogonal fact (a fifth one alongside `paused`/`needs_update`/ +the orthogonal fact (a fourth one alongside `paused`/ `needs_login`, same "independent flags, no state machine" shape — see `ContainerView`'s own doc comment) that tells them apart. An older hive-c0re without the field serves `failed: undefined`, which @@ -304,14 +300,14 @@ reads falsy — degrades cleanly to the single `not running` badge. When the container is running, status badges follow — `⊘ rate limited` (red, while the harness parks itself after a 429), `needs -login`, `needs update` — plus **one `◐ pending-state…` pill per +login` — plus **one `◐ pending-state…` pill per active transient** (replaces buttons during operator-initiated start / stop / restart / rebuild / destroy). An agent can carry **multiple transients at once** — for example a lease-exempt `prebuild` running alongside a `stop_for_update` on the same agent — and each renders as its own independent badge instead of collapsing into one label, matching the existing multi-badge convention this -line already uses for `paused`/`needs_update`/model/ctx. +line already uses for `paused`/model/ctx. Any pending badge means the row is actually **running** something right now (see **Pending-badge derivation** below); every row @@ -377,13 +373,6 @@ per-agent actions and navigation links. Contents: - `↻ R3BU1LD` — always available; queues a rebuild for this agent. - `DESTR0Y` / `PURG3` — destructive, each prompts for confirmation via the themed dialog (see **Themed dialogs** below). -- `deployed: ↗` — present when the agent has a `deployed_sha` - and the forge is reachable; links the deployed commit on the forge - agent-configs mirror. - -`↻ UPD4TE 4LL` button appears above the containers list when any -agent is stale. Banner pulses on each broker SSE event -(`pulseBanner` with a 4s grace timer). **Build-queue summary banner** — when the job queue has any active work, a compact amber banner sits above the container list: `◐ build @@ -491,7 +480,6 @@ below — some endpoints aren't in it yet. - `POST /api/{rebuild,kill,restart,start,destroy}/{name}` — lifecycle. `destroy` accepts `purge=on` to also wipe state dirs. - `POST /api/purge-tombstone/{name}` — wipe a tombstone's state dirs. -- `POST /api/update-all` — rebuild every stale container. - `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry. Refuses `Running` / terminal-state entries (in-flight rebuilds can't be safely interrupted). Always 200; body is diff --git a/frontend/packages/dashboard/src/agent-menu/hive-agent-menu.js b/frontend/packages/dashboard/src/agent-menu/hive-agent-menu.js index 8267589f..a4588e19 100644 --- a/frontend/packages/dashboard/src/agent-menu/hive-agent-menu.js +++ b/frontend/packages/dashboard/src/agent-menu/hive-agent-menu.js @@ -72,7 +72,7 @@ class HiveAgentMenu extends HTMLElement { // first, doubling the dropdown. if (this._menu) return; - const { c, forgeBase } = this._opts || {}; + const { c } = this._opts || {}; const btn = el( "button", @@ -206,24 +206,6 @@ class HiveAgentMenu extends HTMLElement { }), ); - if (c.deployed_sha && forgeBase) { - const li = el("li", { role: "presentation" }); - const a = el( - "a", - { - class: "agent-menu-item", - href: `${forgeBase}/agent-configs/${encodeURIComponent(c.name)}/commit/${c.deployed_sha}`, - target: "_blank", - rel: "noopener", - role: "menuitem", - title: "deployed config commit on forge", - }, - `deployed:${c.deployed_sha} ↗`, - ); - li.append(a); - dropdown.append(menuSep(), li); - } - this._menu = document.createElement("hive-menu"); this._menu._opts = { trigger: btn, content: dropdown }; this.append(this._menu); diff --git a/frontend/packages/dashboard/src/agents.js b/frontend/packages/dashboard/src/agents.js index b295a021..5258ee73 100644 --- a/frontend/packages/dashboard/src/agents.js +++ b/frontend/packages/dashboard/src/agents.js @@ -196,9 +196,9 @@ document.addEventListener("click", (e) => { // wrapper matching 's `._opts`-before-append convention, // since a custom element created via `document.createElement` can't // take constructor args. -function buildAgentMenu(c, forgeBase) { +function buildAgentMenu(c) { const menu = document.createElement("hive-agent-menu"); - menu._opts = { c, forgeBase }; + menu._opts = { c }; return menu; } @@ -255,7 +255,6 @@ function containerRowFingerprint( failed: c.failed, paused: c.paused, needs_login: c.needs_login, - needs_update: c.needs_update, active_model: c.active_model, port: c.port, pending, @@ -545,19 +544,6 @@ function buildContainerLi(c, opts) { ), ); } - if (c.needs_update) { - head.append( - form( - "/api/rebuild/" + c.name, - "hive-pill-sm badge-warn btn-inline", - "needs update ↻", - "rebuild " + c.name + "? hot-reloads the container.", - {}, - { noRefresh: true }, - ), - ); - } - if (c.active_model) { head.append( el( @@ -579,7 +565,7 @@ function buildContainerLi(c, opts) { // chip in the head row stays — it's a state-hint, not an // action button. - li.append(icon, body, buildAgentMenu(c, forgeBase)); + li.append(icon, body, buildAgentMenu(c)); return li; } @@ -601,7 +587,6 @@ export function renderContainers(s) { // tree builder handles the ordering — we don't pre-sort here. const containers = Array.from(containersState.values()); const portConflicts = derivePortConflicts(containers); - const anyStale = containers.some((c) => c.needs_update); // Preserve the keyed container list across the header-section // rebuild. We wipe the banners/buttons above the list on every @@ -627,19 +612,6 @@ export function renderContainers(s) { root.append(banner); } - if (anyStale) { - root.append( - form( - "/api/update-all", - "btn-rebuild", - "↻ UPD4TE 4LL", - "rebuild every stale container?", - {}, - { noRefresh: true }, - ), - ); - } - // Queue-summary banner: see initJobqRollup, above. if (!containers.length && !transientsState.size) { diff --git a/hive-agent/src/web_ui/state.rs b/hive-agent/src/web_ui/state.rs index 6172c95d..ba36a676 100644 --- a/hive-agent/src/web_ui/state.rs +++ b/hive-agent/src/web_ui/state.rs @@ -82,7 +82,7 @@ pub(super) async fn api_state(State(state): State) -> axum::Json std::io::Result { super::read_map(path) } -/// `agent`'s archetype per the file at [`path`], `None` for a custom agent. -/// -/// # Errors -/// -/// As [`read_at`]. -pub fn of(agent: &str) -> std::io::Result> { - Ok(read_at(&path())?.remove(agent)) -} - /// Read `agent`'s key and store what it holds. No queue, or a bucket that /// does not answer, leaves the stored entry as it is. /// diff --git a/hive-c0re/src/attic/closure.rs b/hive-c0re/src/attic/closure.rs index 52b5af5c..6260ac0b 100644 --- a/hive-c0re/src/attic/closure.rs +++ b/hive-c0re/src/attic/closure.rs @@ -1,7 +1,10 @@ -//! The closure the swarm built for an agent, as a deploy reads its record -//! before downloading it. +//! The closure the swarm declares for an agent: its record in `agent-closures` +//! read before a deploy downloads it, and the marker of the store path the +//! hive last applied, which a changed declaration is compared with. -use anyhow::{Result, anyhow}; +use std::path::Path; + +use anyhow::{Context as _, Result, anyhow}; use swarm_wire::agent_closures::AgentClosure; use super::probe; @@ -17,3 +20,162 @@ pub async fn record(agent: &str) -> Result> { .ok_or_else(|| anyhow!("this hive has no connection to the swarm queue"))?; probe::read_record(&client, agent).await } + +/// Record that `agent` runs the closure at `store_path`. +/// +/// # Errors +/// The marker could not be written. +pub fn mark_deployed(agent: &str, store_path: &str) -> Result<()> { + mark_deployed_in(&crate::paths::applied_closure_marker(agent), store_path) +} + +/// The store path `agent` was last swapped to; `None` when the hive has no +/// record of one: it never applied a closure, or last did before the marker +/// existed. +#[must_use] +pub fn deployed(agent: &str) -> Option { + deployed_in(&crate::paths::applied_closure_marker(agent)) +} + +/// Written whole to a temp file beside the marker and renamed over it, so a +/// reader sees the old marker or the new one. +fn mark_deployed_in(marker: &Path, store_path: &str) -> Result<()> { + let tmp = marker.with_extension("tmp"); + if let Some(dir) = marker.parent() { + std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; + } + std::fs::write(&tmp, store_path).with_context(|| format!("write {}", tmp.display()))?; + std::fs::rename(&tmp, marker) + .with_context(|| format!("rename {} to {}", tmp.display(), marker.display())) +} + +fn deployed_in(marker: &Path) -> Option { + let stored = std::fs::read_to_string(marker).ok()?; + let stored = stored.trim(); + (!stored.is_empty()).then(|| stored.to_owned()) +} + +/// Whether the swarm's declared closure is one the hive should now deploy: the +/// builder pushed it and it differs from what `deployed` says runs. +/// +/// An agent with no `deployed` marker is not pending, since the hive cannot +/// tell what it runs: every agent starts that way when the hive first reads +/// this marker, and deploying all of them at once is worse than waiting for the +/// swarm's next deploy request, which writes the marker. +#[must_use] +pub fn pending(record: &AgentClosure, deployed: Option<&str>) -> bool { + record.cached && deployed.is_some_and(|deployed| deployed != record.store_path) +} + +/// Whether `deployed` is the closure `record` declares, so deploying it again +/// would change nothing. +#[must_use] +pub fn up_to_date(record: &AgentClosure, deployed: Option<&str>) -> bool { + deployed == Some(record.store_path.as_str()) +} + +/// The store path the swarm declares for `agent` when it is [`pending`]. A record +/// that cannot be read, or none, is logged and counts as nothing pending: the +/// hive then deploys nothing it was not told to. +pub async fn pending_path(agent: &str) -> Option { + match record(agent).await { + Ok(Some(record)) => { + pending(&record, deployed(agent).as_deref()).then_some(record.store_path) + } + Ok(None) => None, + Err(e) => { + tracing::warn!(%agent, error = %format!("{e:#}"), "cannot read the agent's declared closure"); + None + } + } +} + +/// Whether the closure the swarm declares for `agent` is the one it runs; false +/// when that cannot be told, so a deploy request goes ahead. +pub async fn is_up_to_date(agent: &str) -> bool { + match record(agent).await { + Ok(Some(record)) => up_to_date(&record, deployed(agent).as_deref()), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DECLARED: &str = "/nix/store/0123456789abcdfghijklmnpqrsvwxyz-nixos-system-alice"; + const RUNNING: &str = "/nix/store/zyxwvsrqpnmlkjihgfdcbadfhijklmn-nixos-system-alice"; + + fn declared(cached: bool) -> AgentClosure { + AgentClosure { + meta_rev: "a".repeat(40), + store_path: DECLARED.to_owned(), + system: "x86_64-linux".to_owned(), + built_unix: 1, + cached, + private_users: false, + } + } + + #[test] + fn a_declaration_other_than_the_running_closure_is_pending() { + assert!(pending(&declared(true), Some(RUNNING))); + } + + #[test] + fn the_running_closure_is_not_pending() { + assert!(!pending(&declared(true), Some(DECLARED))); + } + + #[test] + fn a_closure_the_builder_did_not_push_is_not_pending() { + assert!(!pending(&declared(false), Some(RUNNING))); + } + + #[test] + fn only_the_declared_closure_is_up_to_date() { + assert!(up_to_date(&declared(true), Some(DECLARED))); + assert!(!up_to_date(&declared(true), Some(RUNNING))); + assert!(!up_to_date(&declared(true), None), "unknown is deployed"); + } + + #[test] + fn an_agent_with_no_marker_is_not_pending() { + assert!(!pending(&declared(true), None)); + } + + #[test] + fn the_marker_holds_the_store_path_and_is_replaced_whole() { + let dir = tempfile::tempdir().unwrap(); + let marker = dir.path().join("applied").join(".alice.closure"); + assert_eq!(deployed_in(&marker), None, "no marker yet"); + mark_deployed_in(&marker, RUNNING).unwrap(); + assert_eq!(deployed_in(&marker).as_deref(), Some(RUNNING)); + mark_deployed_in(&marker, DECLARED).unwrap(); + assert_eq!(deployed_in(&marker).as_deref(), Some(DECLARED)); + } + + /// A hive that ran the previous marker format has a config commit in its + /// old files and none at the new path: the agent is not pending, and the + /// old files are not read or touched. + #[test] + fn markers_in_the_old_format_are_neither_read_nor_removed() { + let dir = tempfile::tempdir().unwrap(); + let old = dir.path().join(".alice.config-rev"); + std::fs::write(&old, "1111111111111111111111111111111111111111").unwrap(); + let marker = dir.path().join(".alice.closure"); + assert_eq!(deployed_in(&marker), None); + assert!(!pending(&declared(true), deployed_in(&marker).as_deref())); + assert!(old.exists()); + mark_deployed_in(&marker, DECLARED).unwrap(); + assert!(old.exists(), "a successful deploy leaves old markers alone"); + } + + #[test] + fn an_empty_marker_is_unknown() { + let dir = tempfile::tempdir().unwrap(); + let marker = dir.path().join(".alice.closure"); + std::fs::write(&marker, "\n").unwrap(); + assert_eq!(deployed_in(&marker), None); + } +} diff --git a/hive-c0re/src/attic/probe.rs b/hive-c0re/src/attic/probe.rs index 726e23cb..d66e2b03 100644 --- a/hive-c0re/src/attic/probe.rs +++ b/hive-c0re/src/attic/probe.rs @@ -7,9 +7,7 @@ //! [`super::closure`]. //! //! The verdict is the first of these that fails, in this order: -//! no credentials (`no-credentials`), no record (`no-record`), a record built -//! from another `internal/meta` commit than the head that pins the agent -//! differently (`rev-mismatch`), a record +//! no credentials (`no-credentials`), no record (`no-record`), a record //! the builder did not push (`not-cached`), the cache not holding the narinfo //! (`not-cached`), a request that failed or was refused (`fetch-failed`), a //! narinfo no signature of the key covers (`bad-signature`). @@ -35,7 +33,6 @@ const FAILURES_BEFORE_WARNING: u32 = 3; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Reason { NoRecord, - RevMismatch, NotCached, NoCredentials, FetchFailed, @@ -47,7 +44,6 @@ impl Reason { pub const fn label(self) -> &'static str { match self { Reason::NoRecord => "no-record", - Reason::RevMismatch => "rev-mismatch", Reason::NotCached => "not-cached", Reason::NoCredentials => "no-credentials", Reason::FetchFailed => "fetch-failed", @@ -84,26 +80,10 @@ impl Cache for Http { } } -/// `record` when it was built from the `internal/meta` commit `head`, or from -/// one that pins the agent as `head` does (`same_pins`), and the builder pushed -/// it to the cache. -pub(crate) fn current_record<'a>( - record: Option<&'a AgentClosure>, - head: Option<&str>, - same_pins: bool, -) -> Result<&'a AgentClosure, Unpullable> { +/// `record` when the builder pushed it to the cache. +pub(crate) fn current_record(record: Option<&AgentClosure>) -> Result<&AgentClosure, Unpullable> { let record = record.ok_or_else(|| no(Reason::NoRecord, "no build is recorded for the agent"))?; - if head != Some(record.meta_rev.as_str()) && !same_pins { - return Err(no( - Reason::RevMismatch, - format!( - "built from {}, internal/meta is at {}", - record.meta_rev, - head.unwrap_or("nothing") - ), - )); - } if !record.cached { return Err(no( Reason::NotCached, @@ -113,14 +93,10 @@ pub(crate) fn current_record<'a>( Ok(record) } -/// Whether the closure `record` names can be pulled with `facts`. `head` is the -/// `internal/meta` commit a deploy would build from; `same_pins` is whether the -/// record's commit pins the agent as `head` does. +/// Whether the closure `record` names can be pulled with `facts`. pub(crate) async fn judge( facts: Option<&Facts>, record: Option<&AgentClosure>, - head: Option<&str>, - same_pins: bool, cache: &impl Cache, ) -> Result<(), Unpullable> { let facts = facts.ok_or_else(|| { @@ -129,7 +105,7 @@ pub(crate) async fn judge( "the swarm has given this hive no cache facts", ) })?; - let record = current_record(record, head, same_pins)?; + let record = current_record(record)?; let text = match cache.narinfo(facts, &record.store_path).await { Ok(Some(text)) => text, Ok(None) => return Err(no(Reason::NotCached, "the cache does not hold the narinfo")), @@ -182,7 +158,7 @@ pub async fn follow(client: async_nats::Client, mut shutdown: tokio::sync::watch /// One pass: publish the facts, then judge every agent. /// /// # Errors -/// A read that failed (the store, the queue, the forge), which leaves the last +/// A read that failed (the store, the queue), which leaves the last /// verdicts as they are: the failure says nothing about any agent. async fn pass(client: &async_nats::Client) -> Result<()> { swarm_queue_client::ensure_connected(client)?; @@ -190,10 +166,6 @@ async fn pass(client: &async_nats::Client) -> Result<()> { let agents = crate::lifecycle::agents_for_meta_listing() .await .context("enumerating this hive's agents")?; - let head = match &facts { - Some(_) => meta_head().await?, - None => None, - }; let http = Http(narinfo::client()?); let mut now = Vec::with_capacity(agents.len()); for spec in agents { @@ -201,18 +173,7 @@ async fn pass(client: &async_nats::Client) -> Result<()> { Some(_) => read_record(client, &spec.name).await?, None => None, }; - let same_pins = match (&record, head.as_deref()) { - (Some(r), Some(head)) => pins_unchanged(&spec.name, &r.meta_rev, head).await, - _ => false, - }; - let verdict = judge( - facts.as_ref(), - record.as_ref(), - head.as_deref(), - same_pins, - &http, - ) - .await; + let verdict = judge(facts.as_ref(), record.as_ref(), &http).await; now.push((spec.name, verdict.as_ref().err().map(|u| u.reason))); report(&now[now.len() - 1], verdict.err().map(|u| u.detail)); } @@ -222,26 +183,6 @@ async fn pass(client: &async_nats::Client) -> Result<()> { Ok(()) } -/// Whether `agent` has the same pins at `built` as at `head`. A commit that -/// cannot be read counts as different, which leaves the verdict to the commit -/// ids. -async fn pins_unchanged(agent: &str, built: &str, head: &str) -> bool { - if built == head { - return true; - } - let read = async { - let archetype = crate::archetypes::of(agent)?.is_some(); - let source = crate::forge::internal_meta_source() - .await - .context("no forge token")?; - crate::meta_rev::pins_unchanged(&source, agent, archetype, built, head).await - }; - read.await.unwrap_or_else(|e| { - tracing::debug!(%agent, error = %format!("{e:#}"), "attic: cannot compare the agent's pins"); - false - }) -} - pub(crate) async fn read_record( client: &async_nats::Client, agent: &str, @@ -256,19 +197,6 @@ pub(crate) async fn read_record( } } -/// The `internal/meta` head, or `None` when the swarm has not published it or -/// this hive has no forge token. -async fn meta_head() -> Result> { - use crate::meta_rev::MetaSource as _; - match crate::forge::internal_meta_source().await { - Some(source) => source - .meta_head() - .await - .context("reading internal/meta's head"), - None => Ok(None), - } -} - /// The log line: at info when the verdict differs from the last pass, else /// debug. fn report((agent, verdict): &(String, Option), detail: Option) { @@ -341,13 +269,9 @@ mod tests { async fn reason( facts: Option<&Facts>, record: Option<&AgentClosure>, - head: Option<&str>, cache: Answers, ) -> Option { - judge(facts, record, head, false, &cache) - .await - .err() - .map(|u| u.reason) + judge(facts, record, &cache).await.err().map(|u| u.reason) } fn good() -> Answers { @@ -355,73 +279,37 @@ mod tests { } #[tokio::test] - async fn a_signed_narinfo_for_the_head_build_is_pullable() { - assert_eq!( - reason(Some(&facts()), Some(&record()), Some(REV), good()).await, - None - ); + async fn a_signed_narinfo_for_the_recorded_build_is_pullable() { + assert_eq!(reason(Some(&facts()), Some(&record()), good()).await, None); } #[tokio::test] async fn no_facts_is_no_credentials_even_with_a_record() { - let r = reason(None, Some(&record()), Some(REV), good()).await; + let r = reason(None, Some(&record()), good()).await; assert_eq!(r, Some(Reason::NoCredentials)); } #[tokio::test] async fn no_record_is_no_record() { - let r = reason(Some(&facts()), None, Some(REV), good()).await; + let r = reason(Some(&facts()), None, good()).await; assert_eq!(r, Some(Reason::NoRecord)); } - #[tokio::test] - async fn a_record_from_another_commit_is_a_rev_mismatch() { - let r = reason(Some(&facts()), Some(&record()), Some("other"), good()).await; - assert_eq!(r, Some(Reason::RevMismatch)); - let r = reason(Some(&facts()), Some(&record()), None, good()).await; - assert_eq!(r, Some(Reason::RevMismatch), "no head to compare with"); - } - - #[tokio::test] - async fn a_record_from_another_commit_with_the_same_pins_is_pullable() { - let (facts, record) = (facts(), record()); - let judged = judge(Some(&facts), Some(&record), Some("other"), true, &good()).await; - assert!(judged.is_ok()); - let unpushed = AgentClosure { - cached: false, - ..record - }; - let judged = judge(Some(&facts), Some(&unpushed), Some("other"), true, &good()).await; - assert_eq!(judged.err().map(|u| u.reason), Some(Reason::NotCached)); - } - #[tokio::test] async fn an_unpushed_record_or_a_missing_narinfo_is_not_cached() { let unpushed = AgentClosure { cached: false, ..record() }; - let r = reason(Some(&facts()), Some(&unpushed), Some(REV), good()).await; + let r = reason(Some(&facts()), Some(&unpushed), good()).await; assert_eq!(r, Some(Reason::NotCached)); - let r = reason( - Some(&facts()), - Some(&record()), - Some(REV), - Answers(Ok(None)), - ) - .await; + let r = reason(Some(&facts()), Some(&record()), Answers(Ok(None))).await; assert_eq!(r, Some(Reason::NotCached)); } #[tokio::test] async fn a_failed_request_is_fetch_failed() { - let r = reason( - Some(&facts()), - Some(&record()), - Some(REV), - Answers(Err("401")), - ) - .await; + let r = reason(Some(&facts()), Some(&record()), Answers(Err("401"))).await; assert_eq!(r, Some(Reason::FetchFailed)); } @@ -430,7 +318,7 @@ mod tests { let forged = Answers(Ok(Some(narinfo_signed_by(&SigningKey::from_bytes( &[9; 32], ))))); - let r = reason(Some(&facts()), Some(&record()), Some(REV), forged).await; + let r = reason(Some(&facts()), Some(&record()), forged).await; assert_eq!(r, Some(Reason::BadSignature)); } @@ -438,7 +326,6 @@ mod tests { fn the_labels_are_the_documented_ones() { let labels = [ Reason::NoRecord, - Reason::RevMismatch, Reason::NotCached, Reason::NoCredentials, Reason::FetchFailed, @@ -449,7 +336,6 @@ mod tests { labels, [ "no-record", - "rev-mismatch", "not-cached", "no-credentials", "fetch-failed", diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index c28a5e5f..0afe035e 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -42,12 +42,7 @@ pub struct ContainerView { /// up is indistinguishable from one an operator stopped on purpose. #[serde(default)] pub failed: bool, - pub needs_update: bool, pub needs_login: bool, - /// First 12 chars of the config commit the agent's last successful build - /// pinned (`paths::applied_config_marker`). - #[serde(skip_serializing_if = "Option::is_none")] - pub deployed_sha: Option, /// The Claude model the agent's harness is currently using, read from /// `state/hyperhive-harness.json["active_model"]`. `None` when the /// agent has never started a turn or the field is absent. Only @@ -97,9 +92,7 @@ impl From for hive_sh4re::container::AgentStatusRow { name: v.name, running: v.running, failed: v.failed, - needs_update: v.needs_update, needs_login: v.needs_login, - deployed_sha: v.deployed_sha, pending_reminders: 0, paused: v.paused, active_model: v.active_model, @@ -153,11 +146,6 @@ pub async fn build_all() -> anyhow::Result> { let Ok(logical) = hive_types::Ident::parse(logical) else { continue; }; - let deployed_full = - std::fs::read_to_string(crate::paths::applied_config_marker(logical.as_str())).ok(); - let needs_update = - crate::auto_update::agent_config_pending(logical.as_str(), deployed_full.as_deref()); - let deployed_sha = deployed_full.as_deref().map(short_rev); // One `systemctl` call for both facts: `unit_state` is the same // shell-out `is_running` makes, minus `--quiet`. Asking twice would // double the per-agent subprocess count on every SSE scan. @@ -211,9 +199,7 @@ pub async fn build_all() -> anyhow::Result> { failed, container: c.clone(), name: logical.into_string(), - needs_update, needs_login, - deployed_sha, active_model, status_text, status_set_at, @@ -378,27 +364,36 @@ pub fn hive_swarm_names() -> (Option, Option) { (read("HYPERHIVE_HIVE_NAME"), read("HYPERHIVE_SWARM_NAME")) } -/// First 12 characters of a rev. Counts chars, not bytes, so a byte slice at 12 -/// cannot split a multi-byte char. -fn short_rev(rev: &str) -> String { - rev.chars().take(12).collect() -} - #[cfg(test)] mod tests { - use super::short_rev; - + /// The row says nothing of an update being pending or of a deployed commit: + /// the hive applies a changed declaration on its own, so there is no state + /// for an operator to act on. #[test] - fn short_rev_truncates_and_keeps_short() { - assert_eq!(short_rev("0123456789abcdef"), "0123456789ab"); - assert_eq!(short_rev("abc123"), "abc123"); - } - - #[test] - fn short_rev_survives_multibyte_at_boundary() { - // 'é' is 2 bytes and straddles byte index 12. - let rev = "0123456789aébcdef"; - assert_eq!(short_rev(rev), "0123456789aé"); + fn the_row_carries_no_update_state() { + use super::ContainerView; + let view = ContainerView { + name: "alice".to_owned(), + container: "h-alice".to_owned(), + port: 7000, + running: true, + failed: false, + needs_login: false, + active_model: None, + status_text: None, + status_set_at: None, + paused: false, + }; + let value = serde_json::to_value(&view).unwrap(); + assert_eq!(value["running"], true, "control: the lookup finds fields"); + for gone in ["needs_update", "deployed_sha"] { + assert!(value.get(gone).is_none(), "{gone} is still serialized"); + } + let row = serde_json::to_value(hive_sh4re::container::AgentStatusRow::from(view)).unwrap(); + assert_eq!(row["running"], true); + for gone in ["needs_update", "deployed_sha"] { + assert!(row.get(gone).is_none(), "{gone} is still serialized"); + } } /// The `From` impl both `handle_agent_status`'s poll path and @@ -417,9 +412,7 @@ mod tests { port: 7000, running: true, failed: false, - needs_update: true, needs_login: false, - deployed_sha: Some("abc123def456".to_owned()), active_model: Some("claude-opus".to_owned()), status_text: Some("shipping".to_owned()), status_set_at: Some(Utc.timestamp_opt(1_700_000_000, 0).unwrap()), @@ -430,9 +423,7 @@ mod tests { assert_eq!(row.name, "alice"); assert!(row.running); assert!(!row.failed); - assert!(row.needs_update); assert!(!row.needs_login); - assert_eq!(row.deployed_sha.as_deref(), Some("abc123def456")); assert!(row.paused); assert_eq!(row.active_model.as_deref(), Some("claude-opus")); assert_eq!(row.status_text.as_deref(), Some("shipping")); diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 6ead7cb2..471beaa1 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -507,7 +507,7 @@ impl Coordinator { /// and one `DashboardEvent::ContainerRemoved` per disappeared row. /// Call after any mutation that could affect what /// `nixos-container list` returns or what a row's - /// `running` / `needs_update` / `needs_login` / `deployed_sha` + /// `running` / `needs_login` /// resolves to — lifecycle ops, destroy, /// rebuild, and the crash-watcher's periodic poll. /// Cheap when nothing changed (one `nixos-container list` + a diff --git a/hive-c0re/src/dashboard/lifecycle_ops.rs b/hive-c0re/src/dashboard/lifecycle_ops.rs index 138fe67e..f5ae20ac 100644 --- a/hive-c0re/src/dashboard/lifecycle_ops.rs +++ b/hive-c0re/src/dashboard/lifecycle_ops.rs @@ -1,6 +1,6 @@ //! Container lifecycle endpoints for the dashboard. //! -//! Rebuild / restart / start / stop (hard + graceful) / update-all all +//! Rebuild / restart / start / stop (hard + graceful) all //! insert DAGs into the job queue — the power ops via //! [`crate::job_queue::power`], the static shapes straight through //! `JobQueue::insert` — so each shows a visible queued→running transient on @@ -311,34 +311,6 @@ fn set_paused_error_response( } } -/// Queue a rebuild DAG for every live agent -/// container. -#[utoipa::path( - post, - path = "/api/update-all", - responses( - (status = 200, description = "rebuilds queued", body = String), - (status = 500, description = "container list unreadable, nothing queued"), - ), - tag = "lifecycle_ops" -)] -pub(super) async fn post_update_all(State(state): State) -> Response { - let agents = match lifecycle::agent_names(lifecycle::list().await) { - Ok(agents) => agents, - Err(e) => return error_response(&format!("update-all: listing containers: {e:#}")), - }; - for logical in agents { - if let Err(e) = state.coord.job_queue.insert_job(|b| { - crate::job_queue::templates::rebuild(b, &logical); - Vec::new() - }) { - tracing::error!(agent = %logical, error = ?e, "update-all: insert failed"); - } - } - state.coord.emit_rebuild_queue_snapshot(); - (StatusCode::OK, "ok").into_response() -} - #[derive(Deserialize, Default, ToSchema)] pub(super) struct DestroyForm { #[serde(default)] diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index 2ff293b2..98ce9f6e 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -119,7 +119,6 @@ fn app(coord: Arc) -> axum::Router { .routes(routes!(lifecycle_ops::post_rebuild)) .routes(routes!(lifecycle_ops::post_pause)) .routes(routes!(lifecycle_ops::post_resume)) - .routes(routes!(lifecycle_ops::post_update_all)) .routes(routes!(tombstones::post_purge_tombstone)) .routes(routes!(build_logs::get_build_log_stream)) .routes(routes!(state_snapshot::dashboard_stream)) @@ -369,7 +368,6 @@ mod router_build_probe { .routes(routes!(lifecycle_ops::post_rebuild)) .routes(routes!(lifecycle_ops::post_pause)) .routes(routes!(lifecycle_ops::post_resume)) - .routes(routes!(lifecycle_ops::post_update_all)) .routes(routes!(tombstones::post_purge_tombstone)) .routes(routes!(build_logs::get_build_log_stream)) .routes(routes!(state_snapshot::dashboard_stream)) diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index c4f1eb24..71722d6d 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -40,7 +40,6 @@ pub(super) struct StateSnapshot { /// can't be resolved. Feeds the dashboard start page so an operator /// can tell what build a hive is running without shelling in. hyperhive_rev: Option, - any_stale: bool, containers: Vec, transients: Vec, /// State dirs (config history + claude creds + /state/ notes) that @@ -176,7 +175,6 @@ pub(super) async fn api_state( ); } let containers = state.coord.containers_snapshot().await; - let any_stale = containers.iter().any(|c| c.needs_update); let transient_snapshot = state.coord.transient_snapshot(); let transients = build_transient_views(&containers, &transient_snapshot); let tombstones = build_tombstone_views(&containers); @@ -186,7 +184,6 @@ pub(super) async fn api_state( seq, hostname, hyperhive_rev: crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake), - any_stale, containers, transients, @@ -493,9 +490,7 @@ mod tests { port, running, failed: false, - needs_update: false, needs_login: false, - deployed_sha: None, active_model: None, status_text: None, status_set_at: None, diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index 13e146b4..1e1dcc0e 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -71,8 +71,8 @@ pub enum DashboardEvent { transient_kind: String, }, /// One container row changed — a new container appeared, an existing - /// one flipped `running` / `needs_update` / - /// `sha`, etc. Clients upsert by `container.name`. Payload carries + /// one flipped `running` / + /// `needs_login`, etc. Clients upsert by `container.name`. Payload carries /// the full row so cold-loaded clients and event-driven clients /// converge on the same render. /// diff --git a/hive-c0re/src/forge/internal_meta.rs b/hive-c0re/src/forge/internal_meta.rs deleted file mode 100644 index f13859e2..00000000 --- a/hive-c0re/src/forge/internal_meta.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! This hive's reads of `internal/meta` and of an agent's config head, as -//! [`crate::meta_rev::MetaSource`], under the hive's forge read token. - -use forgejo_api::structs::RepoGetRawFileQuery; -use forgejo_api::{ApiErrorKind, Forgejo, ForgejoError}; -use reqwest::StatusCode; - -use super::reader::ReaderToken; -use super::{CONFIG_ORG, bounded}; -use crate::meta_rev::MetaSource; - -/// The org and repo the swarm commits its meta flake to. -const META_OWNER: &str = "internal"; -const META_REPO: &str = "meta"; - -/// The branch an agent's config is deployed from, which `fetch_forge_main` -/// and the swarm controller's pins both read. -const CONFIG_BRANCH: &str = "main"; - -/// [`MetaSource`] over the forge API. -pub(crate) struct ForgeSource { - api: Forgejo, -} - -/// A source reading as this hive, or `None` when it holds no forge token. -pub(crate) async fn internal_meta_source() -> Option { - let token: ReaderToken = super::reader_token().await?; - match token.api() { - Ok(api) => Some(ForgeSource { api }), - Err(e) => { - tracing::warn!(error = ?e, "internal/meta: cannot build a forge client"); - None - } - } -} - -fn is_not_found(e: &ForgejoError) -> bool { - match e { - ForgejoError::ApiError(api) => match api.error_kind() { - ApiErrorKind::NotFound { .. } => true, - ApiErrorKind::Other(s) => *s == StatusCode::NOT_FOUND, - _ => false, - }, - ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::NOT_FOUND, - _ => false, - } -} - -impl ForgeSource { - /// The head commit of `branch` in `owner/repo`; `None` when the repo or - /// the branch is not there. - async fn branch_head( - &self, - owner: &str, - repo: &str, - branch: &str, - ) -> anyhow::Result> { - let op = format!("read branch {branch} of {owner}/{repo}"); - match bounded(&op, self.api.repo_get_branch(owner, repo, branch)).await? { - Ok(branch) => Ok(branch.commit.and_then(|c| c.id)), - Err(e) if is_not_found(&e) => Ok(None), - Err(e) => Err(anyhow::Error::new(e).context(op)), - } - } -} - -impl MetaSource for ForgeSource { - async fn meta_head(&self) -> anyhow::Result> { - use anyhow::Context as _; - - let op = format!("read repo {META_OWNER}/{META_REPO}"); - let repo = match bounded(&op, self.api.repo_get(META_OWNER, META_REPO)).await? { - Ok(repo) => repo, - Err(e) if is_not_found(&e) => return Ok(None), - Err(e) => return Err(anyhow::Error::new(e).context(op)), - }; - if repo.empty.unwrap_or(false) { - return Ok(None); - } - let branch = repo - .default_branch - .with_context(|| format!("{META_OWNER}/{META_REPO} names no default branch"))?; - self.branch_head(META_OWNER, META_REPO, &branch).await - } - - async fn flake_at(&self, rev: &str) -> anyhow::Result { - let op = format!("read flake.nix of {META_OWNER}/{META_REPO} at {rev}"); - let query = RepoGetRawFileQuery { - r#ref: Some(rev.to_owned()), - }; - let raw = bounded( - &op, - self.api - .repo_get_raw_file(META_OWNER, META_REPO, "flake.nix", query), - ) - .await? - .map_err(|e| anyhow::Error::new(e).context(op))?; - Ok(String::from_utf8_lossy(&raw).into_owned()) - } - - async fn config_head(&self, agent: &str) -> anyhow::Result> { - self.branch_head(CONFIG_ORG, agent, CONFIG_BRANCH).await - } -} diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index 23fe4f00..b951d8bb 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -4,18 +4,13 @@ //! `internal/docs`, are swarm-controller's. Full design: //! `docs/integrations/forge.md`. -mod internal_meta; mod reader; -pub(crate) use internal_meta::internal_meta_source; pub use reader::reader_token; -use std::future::IntoFuture; use std::sync::OnceLock; -use std::time::Duration; use anyhow::{Context, Result}; -use forgejo_api::{Auth, Forgejo}; use url::Url; const FORGE_CONTAINER: &str = "hive-forge"; @@ -88,15 +83,6 @@ fn basic_auth_header(login: &str, token: &str) -> String { format!("Authorization: Basic {basic}") } -/// Forgejo org grouping every agent's config repo. Each agent is a **write collaborator -/// on its own** `agent-configs/` repo — the editable PR surface it -/// pushes config-change branches to. `main`'s branch protection is -/// swarm-controller's: merge and approval allowlisted to the `operators` team, -/// so the agent can't push `main` or self-merge, and an operator's merge -/// deploys through swarm-controller. The repos remain private, so an agent -/// still can't reach *another* agent's config. hive-c0re only reads from it. -pub(crate) const CONFIG_ORG: &str = "agent-configs"; - /// Probe whether `hive-forge` exists as a nixos-container. Cheap — /// `nixos-container list` is just a directory scan in /etc. Routed /// through hive-priv: `nixos-container` needs root, and hive-c0re runs @@ -108,70 +94,9 @@ pub async fn is_present() -> bool { stdout.lines().any(|l| l.trim() == FORGE_CONTAINER) } -/// Typed Forgejo API client for the local forge ([`forge_http_base()`]), -/// authenticated as `token`. All Forgejo API calls go through clients built -/// here — one place for the base URL and auth. The token is passed per call; -/// the base URL is parsed once. Failures surface as -/// `forgejo_api::ForgejoError`, whose Display carries the HTTP status -/// and the API's error message (e.g. the validation reason on a 422). -pub(crate) fn api(token: &str) -> Result { - static URL: OnceLock = OnceLock::new(); - let url = URL - .get_or_init(|| Url::parse(forge_http_base()).expect("forge_http_base() is a valid URL")) - .clone(); - Forgejo::new(Auth::Token(token), url).context("build forgejo api client") -} - -/// Upper bound on one Forgejo API request. `forgejo-api` takes no -/// caller-built client, so the request itself has no timeout; every request -/// in this module is wrapped in [`bounded`]. -const API_TIMEOUT: Duration = Duration::from_secs(10); - -/// Await one Forgejo API request for at most [`API_TIMEOUT`]. -/// -/// The outer `Err` is the deadline, naming `op`; the inner `Result` is the -/// request's own outcome, left to the caller so it can still match on the -/// error (`409 already exists`, `401`, ...). -async fn bounded(op: &str, request: F) -> Result> -where - F: IntoFuture>, -{ - tokio::time::timeout(API_TIMEOUT, request) - .await - .map_err(|_| anyhow::anyhow!("{op}: no answer from the forge within {API_TIMEOUT:?}")) -} - #[cfg(test)] mod tests { - use super::{API_TIMEOUT, bounded, git_url_with_base}; - - /// A forge that never answers surfaces as an error naming the operation, - /// at exactly [`API_TIMEOUT`] — not as a hang. - #[tokio::test(start_paused = true)] - async fn a_request_the_forge_never_answers_errors_at_the_deadline() { - let started = tokio::time::Instant::now(); - let err = bounded( - "pending request", - std::future::pending::>(), - ) - .await - .expect_err("a pending request must hit the deadline"); - assert_eq!(started.elapsed(), API_TIMEOUT); - assert!(err.to_string().contains("pending request"), "{err}"); - } - - /// The request's own result passes through for the caller to classify. - #[tokio::test(start_paused = true)] - async fn an_answered_request_keeps_its_own_result() { - let ok = bounded("op", async { Ok::<_, ()>(7) }) - .await - .expect("in time"); - assert_eq!(ok, Ok(7)); - let failed = bounded("op", async { Err::<(), _>("409") }) - .await - .expect("in time"); - assert_eq!(failed, Err("409")); - } + use super::git_url_with_base; /// The remote carries **no credential**. `argv` is world-readable through /// `/proc//cmdline`, so a token spliced in here would be published to diff --git a/hive-c0re/src/forge/reader.rs b/hive-c0re/src/forge/reader.rs index 0bc243fa..31075ef1 100644 --- a/hive-c0re/src/forge/reader.rs +++ b/hive-c0re/src/forge/reader.rs @@ -26,11 +26,6 @@ impl ReaderToken { pub fn auth_header(&self) -> String { basic_auth_header(&self.login, &self.value) } - - /// A forge API client acting as this token's account. - pub(crate) fn api(&self) -> anyhow::Result { - super::api(&self.value) - } } /// The token to read the forge with, or `None` when the store holds none for diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index f1beb1fb..7b5ced6d 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -1,5 +1,5 @@ //! Node executors — one async fn per [`NodeKind`], each a thin wrapper -//! over existing `lifecycle.rs` / `meta_rev.rs` / `actions.rs` code. Node +//! over existing `lifecycle.rs` / `actions.rs` code. Node //! executors keep their own internal error handling where it exists //! today (cold-start fallback inside `Reconcile`, warn-only forge sync in //! the `Swap` tail); DAG-level failure handling is cancel-downstream in @@ -15,7 +15,6 @@ use hive_jobq::NodeId; use super::model::NodeKind; use crate::coordinator::{AgentPaths, Coordinator, HiveEnv}; -use crate::meta_rev::MetaBuild; use crate::power::{ReconcileAction, reconcile_action}; use crate::stats::sweep_health::{self, SweepHealth}; @@ -35,21 +34,17 @@ pub const GRACEFUL_STOP_TIMEOUT: std::time::Duration = std::time::Duration::from const PAUSE_ACK_TIMEOUT: std::time::Duration = GRACEFUL_STOP_TIMEOUT; /// The container-state calls the executors below make on the host: systemd, -/// `hive-priv`, the swarm's closure record, the `internal/meta` record. Production uses -/// [`RealHost`], which forwards to `lifecycle` / `meta_rev` / `Coordinator` +/// `hive-priv`, the swarm's closure record and the marker of the closure applied. +/// Production uses [`RealHost`], which forwards to `lifecycle` / `Coordinator` /// unchanged; tests substitute a recording fake. pub(super) trait Host: Sync { /// Whether the agent's container unit is active. async fn is_running(&self, name: &str) -> bool; /// Stop the agent's container. async fn kill(&self, name: &str) -> Result<()>; - /// The `internal/meta` commit `MetaSync` recorded for the agent. - fn recorded_rev(&self, name: &str) -> Result; /// The record the swarm's builder wrote for the agent; `None` when it wrote /// none. async fn closure_record(&self, name: &str) -> Result>; - /// The pins `internal/meta` at the commit `rev` holds for the agent. - async fn pins_at(&self, name: &str, rev: &str) -> Result; /// Download `store_path` from the swarm's cache into the store, never /// building it. async fn realise_closure(&self, name: &str, store_path: &str) -> Result<()>; @@ -72,15 +67,8 @@ pub(super) trait Host: Sync { async fn prepare_rebuild_dirs(&self, name: &str, paths: &AgentPaths) -> Result<()>; /// Rewrite the host-side files that follow the agent set. async fn refresh_agent_set(&self) -> Result<()>; - /// Pin the `internal/meta` commit the agent builds from. - async fn resolve_meta(&self, name: &str) -> Result; - /// Record `build` as the commit `Prebuild` and `Swap` expect the swarm's - /// closure to be built from. - fn record_meta(&self, name: &str, build: &MetaBuild) -> Result<()>; - /// The build `MetaSync` recorded, if any. - fn recorded_build(&self, name: &str) -> Option; - /// Mark the agent as running the pins of `build`. - fn mark_deployed(&self, name: &str, build: &MetaBuild) -> Result<()>; + /// Mark the agent as running the closure at `store_path`. + fn mark_deployed(&self, name: &str, store_path: &str) -> Result<()>; /// First-spawn provisioning: repos, state subvolume, agent-set refresh. async fn provision(&self, name: &str, paths: &AgentPaths) -> Result<()>; /// `nixos-container create` from the closure `toplevel`. @@ -117,18 +105,10 @@ impl Host for RealHost { crate::lifecycle::kill(name).await } - fn recorded_rev(&self, name: &str) -> Result { - Ok(crate::meta_rev::recorded_or_err(name)?.rev) - } - async fn closure_record(&self, name: &str) -> Result> { crate::attic::closure::record(name).await } - async fn pins_at(&self, name: &str, rev: &str) -> Result { - crate::meta_rev::resolve_at(name, rev).await - } - async fn realise_closure(&self, name: &str, store_path: &str) -> Result<()> { crate::priv_client::realise_closure(name, store_path).await } @@ -165,20 +145,8 @@ impl Host for RealHost { crate::agent_set::refresh(&agents).await } - async fn resolve_meta(&self, name: &str) -> Result { - crate::meta_rev::resolve(name).await - } - - fn record_meta(&self, name: &str, build: &MetaBuild) -> Result<()> { - crate::meta_rev::record(name, build) - } - - fn recorded_build(&self, name: &str) -> Option { - crate::meta_rev::recorded(name) - } - - fn mark_deployed(&self, name: &str, build: &MetaBuild) -> Result<()> { - crate::meta_rev::mark_deployed(name, build) + fn mark_deployed(&self, name: &str, store_path: &str) -> Result<()> { + crate::attic::closure::mark_deployed(name, store_path) } async fn provision(&self, name: &str, paths: &AgentPaths) -> Result<()> { @@ -283,7 +251,7 @@ async fn dispatch( NodeKind::MetaSync { .. } => run_meta_sync(host, agent).await, NodeKind::Prebuild { .. } => run_prebuild(host, agent).await, NodeKind::Swap { .. } => run_swap(host, coord, agent, id).await, - NodeKind::RebuildBookkeeping { .. } => run_rebuild_bookkeeping(host, coord, agent).await, + NodeKind::RebuildBookkeeping { .. } => run_rebuild_bookkeeping(coord).await, NodeKind::Provision { .. } => run_provision(host, agent).await, NodeKind::Create { .. } => run_create(host, agent).await, NodeKind::DestroyContainer { .. } => run_destroy_container(host, coord, agent).await, @@ -502,12 +470,9 @@ fn run_set_wanted(coord: &Arc, agent: &str, up: bool) -> Result<()> Ok(()) } -/// The rebuild's preamble: runtime-dir prep, the agent-set refresh, and the -/// `internal/meta` commit `Prebuild` and `Swap` expect the swarm's closure to -/// be built from. -/// -/// Fails when `internal/meta` does not pin the agent's current config yet: -/// there is no commit to deploy. +/// The rebuild's preamble: runtime-dir prep, the archetype and agent-set +/// refresh. Reads nothing from the forge: what a rebuild deploys is the swarm's +/// declared closure, read by `Prebuild` and `Swap`. async fn run_meta_sync(host: &impl Host, name: &str) -> Result<()> { // Runs while the agent is still up — the runtime dir and MCP listener // already exist. Use the pure path accessor; no need to re-register the @@ -521,9 +486,6 @@ async fn run_meta_sync(host: &impl Host, name: &str) -> Result<()> { } host.prepare_rebuild_dirs(name, &paths).await?; host.refresh_agent_set().await?; - // Resolved once here so `Prebuild` and `Swap` expect the same commit. - let build = host.resolve_meta(name).await?; - host.record_meta(name, &build)?; Ok(()) } @@ -534,9 +496,8 @@ async fn run_meta_sync(host: &impl Host, name: &str) -> Result<()> { /// is already down: there is no uptime to protect, and `Swap` fails the same way. async fn run_prebuild(host: &impl Host, name: &str) -> Result<()> { if host.is_running(name).await { - let rev = host.recorded_rev(name)?; - let closure = closure_for(host, name, &rev, "prebuild").await?; - tracing::info!(%name, %rev, "prebuild: substituted {}", closure.path); + let closure = closure_for(host, name, "prebuild").await?; + tracing::info!(%name, "prebuild: substituted {closure}"); } Ok(()) } @@ -550,32 +511,19 @@ enum NoClosure { Unpullable(String), } -/// The closure the swarm built for an agent, in the store. -struct Closure { - path: String, - /// The `internal/meta` commit the builder built it from. - meta_rev: String, -} - -/// The closure the swarm built for `name`, downloaded into the store. The -/// deploy applies the swarm's closure whatever commit it was built from: when -/// that is not the `rev` this hive chose, it is logged, and the caller records -/// the pins of the commit actually deployed ([`deployed_pins`]). +/// The store path of the closure the swarm declares for `name`, downloaded into +/// the store. Whatever `internal/meta` commit it was built from, it is the one +/// deployed. /// /// # Errors /// [`without_closure`]'s refusal, after logging it. -async fn closure_for(host: &impl Host, name: &str, rev: &str, step: &str) -> Result { - pull_closure(host, name, rev, step) +async fn closure_for(host: &impl Host, name: &str, step: &str) -> Result { + pull_closure(host, name) .await - .map_err(|why| without_closure(name, rev, step, &why)) + .map_err(|why| without_closure(name, step, &why)) } -async fn pull_closure( - host: &impl Host, - name: &str, - rev: &str, - step: &str, -) -> Result { +async fn pull_closure(host: &impl Host, name: &str) -> Result { let record = host .closure_record(name) .await @@ -589,52 +537,7 @@ async fn pull_closure( host.realise_closure(name, &record.store_path) .await .map_err(|e| NoClosure::Unpullable(format!("{e:#}")))?; - if record.meta_rev != rev && !same_pins(host, name, &record.meta_rev, rev).await { - tracing::warn!( - %name, %rev, built_from = %record.meta_rev, - "{step}: the swarm built {} from internal/meta {}, not the {rev} this hive chose; deploying the swarm's", - record.store_path, record.meta_rev - ); - } - Ok(Closure { - path: record.store_path, - meta_rev: record.meta_rev, - }) -} - -/// Whether the agent has the same pins at the commits `built` and `chosen`. A -/// commit whose pins cannot be read counts as different. -async fn same_pins(host: &impl Host, name: &str, built: &str, chosen: &str) -> bool { - match ( - host.pins_at(name, built).await, - host.pins_at(name, chosen).await, - ) { - (Ok(built), Ok(chosen)) => built.same_pins(&chosen), - _ => false, - } -} - -/// The pins to record for a deploy of `closure` when this hive chose the commit -/// `rev`: those of the commit the closure was built from, so the markers say -/// what runs. `None` when they are the same commit. -/// -/// # Errors -/// The pins of the closure's commit could not be read. The caller fails before -/// applying anything. -async fn deployed_pins( - host: &impl Host, - name: &str, - closure: &Closure, - rev: &str, -) -> Result> { - if closure.meta_rev == rev { - return Ok(None); - } - let pins = host - .pins_at(name, &closure.meta_rev) - .await - .with_context(|| format!("{name}: reading the pins of the commit the swarm built from"))?; - Ok(Some(pins)) + Ok(record.store_path) } /// What a rebuild or create does when there is no closure to deploy: it @@ -645,18 +548,19 @@ async fn deployed_pins( /// This is the one place that decides what an unpullable closure (the cache /// unreachable, a TLS error, a failed download) costs. A rebuild never builds /// locally on the hive. -fn without_closure(name: &str, rev: &str, step: &str, why: &NoClosure) -> anyhow::Error { +fn without_closure(name: &str, step: &str, why: &NoClosure) -> anyhow::Error { let reason = match why { NoClosure::NoRecord => "the swarm has recorded no build of this agent".to_owned(), NoClosure::Unpullable(e) => format!("the swarm's closure cannot be pulled: {e}"), }; - tracing::error!(%name, %rev, "{step}: {reason}; nothing was changed"); + tracing::error!(%name, "{step}: {reason}; nothing was changed"); anyhow::anyhow!("{name}: {reason}") } /// Profile-swap: re-apply drop-ins (rebuild is the reconcile verb), point the -/// container at the swarm's closure, then the post-rebuild bookkeeping tail -/// (rev marker, `Rebuilt` event, forge sync, kick, rescan). +/// container at the swarm's closure and, once that holds, mark the agent as +/// running it. The post-rebuild bookkeeping tail (`Rebuilt` event, forge sync, +/// kick, rescan) is the next node. /// The recovery-start on failure is NOT here — the DAG's tail /// `Reconcile` runs after this node terminal ok *or* fail. async fn run_swap( @@ -670,21 +574,20 @@ async fn run_swap( let agent_dir = crate::paths::agent_runtime_dir(name); let hive = coord.hive_env(); let paths = Coordinator::agent_paths(name, agent_dir)?; - let rev = host.recorded_rev(name)?; - let closure = closure_for(host, name, &rev, "swap").await?; - let deployed = deployed_pins(host, name, &closure, &rev).await?; - tracing::info!(%name, %rev, "swap: applying {}", closure.path); + let closure = closure_for(host, name, "swap").await?; + tracing::info!(%name, "swap: applying {closure}"); let result = host - .swap_update(name, &hive, &paths, &closure.path, id.get()) + .swap_update(name, &hive, &paths, &closure, id.get()) .await; - // The bookkeeping node marks what the record holds, so it holds what was - // applied. + // The marker names what was applied; a failed write is not a failed swap + // (the container runs the closure), it only means the next comparison + // sees the declaration as new. if result.is_ok() - && let Some(deployed) = &deployed + && let Err(e) = host.mark_deployed(name, &closure) { - host.record_meta(name, deployed)?; + tracing::warn!(%name, error = ?e, "write the deployed-closure marker failed"); } - // On success the Ok-only bookkeeping tail (rev marker, forge + // On success the Ok-only bookkeeping tail (forge // sync, kick, rescan, snapshot) runs in the sibling `RebuildBookkeeping` node, // which deps `AfterOk(Swap)`. On failure `RebuildBookkeeping` is cancel-cascaded // and the tail `Reconcile` (`AfterAny(RebuildBookkeeping)`) handles recovery; here @@ -702,16 +605,7 @@ async fn run_swap( /// means the profile swap succeeded. Store/forge work only — no nix /// build (build-slot-exempt); the agent lease taken at `Swap` is still held /// (the whole chain up to `Reconcile` is one agent's subgraph). -async fn run_rebuild_bookkeeping( - host: &impl Host, - coord: &Arc, - name: &str, -) -> Result<()> { - if let Some(build) = host.recorded_build(name) - && let Err(e) = host.mark_deployed(name, &build) - { - tracing::warn!(%name, error = ?e, "write rev markers failed"); - } +async fn run_rebuild_bookkeeping(coord: &Arc) -> Result<()> { // The `Rebuilt` manager event is emitted exactly once per agent by the DAG's // `EmitRebuilt` tail — emitting ok here and letting a failed tail `Reconcile` // add a contradictory !ok would double-report the same rebuild. @@ -724,7 +618,7 @@ async fn run_rebuild_bookkeeping( // alongside `run_start`'s own kick for the same boot. `hive-agent` now // self-announces once on its own startup instead — one message per // actual boot, not one per DAG node that happens to run during it. - // Rescan so dashboards drop the "needs update" chip. + // Rescan so dashboards see the rebuilt container. coord.rescan_containers_and_emit().await; Ok(()) } @@ -743,19 +637,15 @@ async fn run_provision(host: &impl Host, name: &str) -> Result<()> { /// dir creation and MCP listener registration are deferred to the tail /// `Reconcile` (`converge_start_preamble` + `register_agent`) so this /// node stays purely "create", not "create + start". Marks the agent as -/// running the pins it was created from. +/// running the closure it was created from. /// -/// The container is created from the swarm's closure for the resolved commit; -/// with none to deploy the create fails and makes no container. +/// The container is created from the swarm's declared closure; with none to +/// deploy the create fails and makes no container. async fn run_create(host: &impl Host, name: &str) -> Result<()> { - let build = host.resolve_meta(name).await?; - let closure = closure_for(host, name, &build.rev, "create").await?; - let build = deployed_pins(host, name, &closure, &build.rev) - .await? - .unwrap_or(build); - tracing::info!(%name, rev = %build.rev, "create: substituted {}", closure.path); - host.create_only(name, &closure.path).await?; - host.mark_deployed(name, &build)?; + let closure = closure_for(host, name, "create").await?; + tracing::info!(%name, "create: substituted {closure}"); + host.create_only(name, &closure).await?; + host.mark_deployed(name, &closure)?; Ok(()) } diff --git a/hive-c0re/src/job_queue/exec/tests.rs b/hive-c0re/src/job_queue/exec/tests.rs index ad997c4d..c742706f 100644 --- a/hive-c0re/src/job_queue/exec/tests.rs +++ b/hive-c0re/src/job_queue/exec/tests.rs @@ -19,7 +19,6 @@ use super::{ }; use crate::coordinator::{AgentPaths, Coordinator, HiveEnv}; use crate::job_queue::model::NodeKind; -use crate::meta_rev::MetaBuild; use crate::power::Wanted; use swarm_wire::agent_closures::AgentClosure; @@ -30,19 +29,16 @@ const CLOSURE: &str = "/nix/store/0123456789abcdfghijklmnpqrsvwxyz-nixos-system- #[derive(Default)] struct FakeHost { running: bool, - rev: Option, /// What the swarm's builder recorded for the agent; `None` reads as no /// record. built: Option, /// Store paths a download put in the store. realised: Mutex>, /// Names of the calls that fail (`kill`, `realise`, `closure_record`, `swap_update`, - /// `set_paused`, ...). `recorded_build` fails by finding nothing. + /// `set_paused`, `mark_deployed`, ...). failing: Vec<&'static str>, /// The sweeps and pulls never finish, so only a deadline ends them. hang: bool, - /// The agent has the same pins at every commit. - unmoved: bool, runtime: PathBuf, purge: Vec, calls: Mutex>, @@ -52,8 +48,7 @@ impl FakeHost { fn up() -> Self { Self { running: true, - rev: Some(REV.to_owned()), - built: Some(closure(REV, true)), + built: Some(closure(true)), ..Self::default() } } @@ -61,20 +56,14 @@ impl FakeHost { fn down() -> Self { Self { running: false, - rev: Some(REV.to_owned()), - built: Some(closure(REV, true)), + built: Some(closure(true)), ..Self::default() } } - /// The builder recorded `meta_rev` for the agent, pushed or not. - fn built(mut self, meta_rev: &str, cached: bool) -> Self { - self.built = Some(closure(meta_rev, cached)); - self - } - - fn unmoved(mut self) -> Self { - self.unmoved = true; + /// The builder recorded the closure for the agent, pushed or not. + fn built(mut self, cached: bool) -> Self { + self.built = Some(closure(cached)); self } @@ -124,31 +113,11 @@ impl Host for FakeHost { Ok(()) } - fn recorded_rev(&self, name: &str) -> anyhow::Result { - self.record(format!("recorded_rev {name}")); - self.rev - .clone() - .ok_or_else(|| anyhow::anyhow!("{name}: no internal/meta commit was recorded")) - } - async fn closure_record(&self, name: &str) -> anyhow::Result> { self.step("closure_record", format!("closure_record {name}"))?; Ok(self.built.clone()) } - async fn pins_at(&self, name: &str, rev: &str) -> anyhow::Result { - self.step("pins_at", format!("pins_at {name} {rev}"))?; - Ok(MetaBuild { - rev: rev.to_owned(), - hyperhive: "dh".to_owned(), - config: Some(if self.unmoved { - "dc".to_owned() - } else { - format!("dc-{rev}") - }), - }) - } - async fn realise_closure(&self, name: &str, store_path: &str) -> anyhow::Result<()> { self.record(format!("realise {name} {store_path}")); if self.fails("realise") { @@ -200,24 +169,10 @@ impl Host for FakeHost { self.step("refresh_agent_set", "refresh_agent_set".to_owned()) } - async fn resolve_meta(&self, name: &str) -> anyhow::Result { - self.step("resolve_meta", format!("resolve_meta {name}"))?; - Ok(build()) - } - - fn record_meta(&self, name: &str, build: &MetaBuild) -> anyhow::Result<()> { - self.step("record_meta", format!("record_meta {name} {}", build.rev)) - } - - fn recorded_build(&self, name: &str) -> Option { - self.record(format!("recorded_build {name}")); - (!self.fails("recorded_build")).then(build) - } - - fn mark_deployed(&self, name: &str, build: &MetaBuild) -> anyhow::Result<()> { + fn mark_deployed(&self, name: &str, store_path: &str) -> anyhow::Result<()> { self.step( "mark_deployed", - format!("mark_deployed {name} {}", build.rev), + format!("mark_deployed {name} {store_path}"), ) } @@ -277,10 +232,10 @@ impl Host for FakeHost { } } -/// The builder's record of a closure built from `internal/meta` at `meta_rev`. -fn closure(meta_rev: &str, cached: bool) -> AgentClosure { +/// The builder's record of a closure. +fn closure(cached: bool) -> AgentClosure { AgentClosure { - meta_rev: meta_rev.to_owned(), + meta_rev: REV.to_owned(), store_path: CLOSURE.to_owned(), system: "x86_64-linux".to_owned(), built_unix: 1, @@ -289,14 +244,6 @@ fn closure(meta_rev: &str, cached: bool) -> AgentClosure { } } -fn build() -> MetaBuild { - MetaBuild { - rev: REV.to_owned(), - hyperhive: "h".to_owned(), - config: Some("c".to_owned()), - } -} - fn coordinator() -> (tempfile::TempDir, Arc) { let dir = tempfile::tempdir().expect("tempdir"); let coord = Coordinator::open( @@ -325,8 +272,6 @@ fn node_id() -> NodeId { // --- Prebuild --------------------------------------------------------------- -const OTHER_REV: &str = "3333333333333333333333333333333333333333"; - /// What a block logged, level and text: the message and each field. #[derive(Clone, Default)] struct Logged(Arc>>); @@ -384,11 +329,7 @@ async fn logging(fut: impl std::future::Future) -> (T, Logged) { fn without_a_closure() -> [(&'static str, FakeHost, &'static str); 4] { [ ("no record", FakeHost::up().without_record(), "no build"), - ( - "not pushed", - FakeHost::up().built(REV, false), - "did not push", - ), + ("not pushed", FakeHost::up().built(false), "did not push"), ( "download fails", FakeHost::up().failing("realise"), @@ -411,7 +352,6 @@ async fn prebuild_downloads_the_swarms_closure_when_the_container_is_up() { host.calls(), [ "is_running alice".to_owned(), - "recorded_rev alice".to_owned(), "closure_record alice".to_owned(), format!("realise alice {CLOSURE}"), ] @@ -454,39 +394,6 @@ async fn a_rebuild_without_a_closure_fails_the_prebuild_and_logs_an_error() { } } -/// Another commit that pins the agent the same is nothing to warn about. -#[tokio::test] -async fn a_closure_built_from_a_commit_with_the_same_pins_is_deployed_quietly() { - let host = FakeHost::up().built(OTHER_REV, true).unmoved(); - let (result, log) = logging(run_prebuild(&host, "alice")).await; - result.expect("drift is not a refusal"); - assert!(host.calls().contains(&format!("realise alice {CLOSURE}"))); - assert!(log.at(tracing::Level::WARN).is_empty()); -} - -/// The builder's record is not for the commit this hive chose: its closure is -/// deployed and the difference is a warning. -#[tokio::test] -async fn a_closure_built_from_another_commit_is_deployed_with_a_warning() { - let host = FakeHost::up().built(OTHER_REV, true); - let (result, log) = logging(run_prebuild(&host, "alice")).await; - result.expect("drift is not a refusal"); - assert!( - host.calls().contains(&format!("realise alice {CLOSURE}")), - "{:?}", - host.calls() - ); - let warnings = log.at(tracing::Level::WARN); - assert_eq!(warnings.len(), 1, "{warnings:?}"); - assert!( - [REV, OTHER_REV, CLOSURE] - .iter() - .all(|part| warnings[0].contains(part)), - "{warnings:?}" - ); - assert!(log.at(tracing::Level::ERROR).is_empty()); -} - #[tokio::test] async fn a_stopped_container_looks_up_nothing() { let host = FakeHost::down(); @@ -494,22 +401,6 @@ async fn a_stopped_container_looks_up_nothing() { assert_eq!(host.calls(), ["is_running alice"]); } -#[tokio::test] -async fn prebuild_without_a_recorded_rev_fails_before_looking_up_a_closure() { - let host = FakeHost { - rev: None, - ..FakeHost::up() - }; - let err = run_prebuild(&host, "alice") - .await - .expect_err("nothing to deploy"); - assert!( - format!("{err:#}").contains("no internal/meta commit"), - "{err:#}" - ); - assert!(!host.calls().iter().any(|c| c.starts_with("closure_record"))); -} - // --- Swap ------------------------------------------------------------------- #[tokio::test] @@ -521,10 +412,10 @@ async fn swap_applies_the_swarms_closure() { assert_eq!( host.calls(), [ - "recorded_rev alice".to_owned(), "closure_record alice".to_owned(), format!("realise alice {CLOSURE}"), format!("swap_update alice {CLOSURE} node={}", id.get()), + format!("mark_deployed alice {CLOSURE}"), ] ); } @@ -551,53 +442,40 @@ async fn a_swap_without_a_closure_never_updates_and_logs_an_error() { } } +/// The marker names the closure that was applied, and only an applied one. #[tokio::test] -async fn swap_applies_a_closure_built_from_another_commit_with_a_warning() { +async fn swap_marks_the_applied_closure_deployed_only_after_the_apply() { let (_dir, coord) = coordinator(); - let host = FakeHost::down().built(OTHER_REV, true); + let host = FakeHost::down(); let id = node_id(); - let (result, log) = logging(run_swap(&host, &coord, "alice", id)).await; - result.expect("drift is not a refusal"); - // The pins of the commit the closure came from are read before the apply - // and recorded after it, in place of the ones this hive chose; the - // bookkeeping node then marks those. + run_swap(&host, &coord, "alice", id).await.expect("swap ok"); assert_eq!( - host.calls(), - [ - "recorded_rev alice".to_owned(), - "closure_record alice".to_owned(), - format!("realise alice {CLOSURE}"), - format!("pins_at alice {OTHER_REV}"), - format!("pins_at alice {REV}"), - format!("pins_at alice {OTHER_REV}"), - format!("swap_update alice {CLOSURE} node={}", id.get()), - format!("record_meta alice {OTHER_REV}"), - ] + host.calls().last().map(String::as_str), + Some(format!("mark_deployed alice {CLOSURE}").as_str()), + "{:?}", + host.calls() + ); + + let host = FakeHost::down().failing("swap_update"); + run_swap(&host, &coord, "alice", node_id()) + .await + .expect_err("apply fails"); + assert!( + !host.calls().iter().any(|c| c.starts_with("mark_deployed")), + "{:?}", + host.calls() ); - assert_eq!(log.at(tracing::Level::WARN).len(), 1); - assert!(log.at(tracing::Level::ERROR).is_empty()); } -/// Pins that can't be read, or an apply that fails, leave the record alone. +/// The container runs the closure, so a marker that cannot be written is a +/// warning and not a failed swap. #[tokio::test] -async fn a_drifted_swap_records_pins_only_for_an_apply_that_happened() { - for failing in ["pins_at", "swap_update"] { - let (_dir, coord) = coordinator(); - let host = FakeHost::down().built(OTHER_REV, true).failing(failing); - run_swap(&host, &coord, "alice", node_id()) - .await - .expect_err(failing); - let calls = host.calls(); - assert!( - !calls.iter().any(|c| c.starts_with("record_meta")), - "{failing}: {calls:?}" - ); - assert_eq!( - calls.iter().any(|c| c.starts_with("swap_update")), - failing == "swap_update", - "{failing}: {calls:?}" - ); - } +async fn a_marker_that_cannot_be_written_does_not_fail_the_swap() { + let (_dir, coord) = coordinator(); + let host = FakeHost::down().failing("mark_deployed"); + let (result, log) = logging(run_swap(&host, &coord, "alice", node_id())).await; + result.expect("swap ok"); + assert_eq!(log.at(tracing::Level::WARN).len(), 1); } /// A failed update fails the node and nothing else: the swap never restarts or @@ -611,21 +489,8 @@ async fn a_failed_swap_reports_the_error_and_touches_no_container() { .expect_err("update failure propagates"); assert!(format!("{err:#}").contains("update failed"), "{err:#}"); let calls = host.calls(); - assert_eq!(calls.len(), 4, "{calls:?}"); - assert!(calls[3].starts_with("swap_update alice"), "{calls:?}"); -} - -#[tokio::test] -async fn swap_without_a_recorded_rev_never_updates() { - let (_dir, coord) = coordinator(); - let host = FakeHost { - rev: None, - ..FakeHost::down() - }; - run_swap(&host, &coord, "alice", node_id()) - .await - .expect_err("nothing to swap to"); - assert_eq!(host.calls(), ["recorded_rev alice"]); + assert_eq!(calls.len(), 3, "{calls:?}"); + assert!(calls[2].starts_with("swap_update alice"), "{calls:?}"); } // --- Stop / StopForUpdate --------------------------------------------------- @@ -851,7 +716,7 @@ async fn pause_drain_times_out_ok_and_clears_pending() { // --- MetaSync --------------------------------------------------------------- #[tokio::test] -async fn meta_sync_prepares_then_resolves_and_records_last() { +async fn meta_sync_only_prepares_the_rebuild() { let host = FakeHost::up(); run_meta_sync(&host, "alice").await.expect("ok"); assert_eq!( @@ -860,8 +725,6 @@ async fn meta_sync_prepares_then_resolves_and_records_last() { "refresh_archetype alice".to_owned(), "prepare_rebuild_dirs alice".to_owned(), "refresh_agent_set".to_owned(), - "resolve_meta alice".to_owned(), - format!("record_meta alice {REV}"), ] ); } @@ -870,17 +733,12 @@ async fn meta_sync_prepares_then_resolves_and_records_last() { async fn meta_sync_keeps_going_when_the_archetype_read_fails() { let host = FakeHost::up().failing("refresh_archetype"); run_meta_sync(&host, "alice").await.expect("non-fatal"); - assert_eq!(host.calls().len(), 5); + assert_eq!(host.calls().len(), 3); } #[tokio::test] async fn meta_sync_stops_at_the_first_fatal_step() { - for (fail, calls) in [ - ("prepare_rebuild_dirs", 2), - ("refresh_agent_set", 3), - ("resolve_meta", 4), - ("record_meta", 5), - ] { + for (fail, calls) in [("prepare_rebuild_dirs", 2), ("refresh_agent_set", 3)] { let host = FakeHost::up().failing(fail); run_meta_sync(&host, "alice") .await @@ -913,11 +771,10 @@ async fn create_downloads_the_swarms_closure_and_creates_from_it() { assert_eq!( host.calls(), [ - "resolve_meta alice".to_owned(), "closure_record alice".to_owned(), format!("realise alice {CLOSURE}"), format!("create_only alice {CLOSURE}"), - format!("mark_deployed alice {REV}"), + format!("mark_deployed alice {CLOSURE}"), ] ); assert!( @@ -952,39 +809,11 @@ async fn a_create_without_a_closure_is_refused_and_logs_an_error() { } } -#[tokio::test] -async fn create_uses_a_closure_built_from_another_commit_with_a_warning() { - let host = FakeHost::up().built(OTHER_REV, true); - let (result, log) = logging(run_create(&host, "alice")).await; - result.expect("drift is not a refusal"); - let calls = host.calls(); - // Created from the swarm's closure, and marked as the commit it was built - // from, not the one this hive chose. - assert_eq!( - calls, - [ - "resolve_meta alice".to_owned(), - "closure_record alice".to_owned(), - format!("realise alice {CLOSURE}"), - format!("pins_at alice {OTHER_REV}"), - format!("pins_at alice {REV}"), - format!("pins_at alice {OTHER_REV}"), - format!("create_only alice {CLOSURE}"), - format!("mark_deployed alice {OTHER_REV}"), - ] - ); - assert_eq!(log.at(tracing::Level::WARN).len(), 1); - assert!(log.at(tracing::Level::ERROR).is_empty()); -} - #[tokio::test] async fn a_failed_create_is_never_marked_deployed() { let host = FakeHost::up().failing("create_only"); run_create(&host, "alice").await.expect_err("create fails"); - assert_eq!(host.calls().len(), 4); - let host = FakeHost::up().failing("resolve_meta"); - run_create(&host, "alice").await.expect_err("resolve fails"); - assert_eq!(host.calls(), ["resolve_meta alice"]); + assert_eq!(host.calls().len(), 3); } // --- Start ------------------------------------------------------------------ @@ -1102,38 +931,9 @@ async fn destroy_bookkeeping_continues_past_a_failed_refresh() { // --- RebuildBookkeeping / EmitRebuilt ---------------------------------------- #[tokio::test] -async fn rebuild_bookkeeping_marks_the_recorded_build_deployed() { +async fn rebuild_bookkeeping_rescans_without_touching_the_host() { let (_dir, coord) = coordinator(); - let host = FakeHost::up(); - run_rebuild_bookkeeping(&host, &coord, "alice") - .await - .expect("ok"); - assert_eq!( - host.calls(), - [ - "recorded_build alice".to_owned(), - format!("mark_deployed alice {REV}") - ] - ); -} - -#[tokio::test] -async fn rebuild_bookkeeping_without_a_recording_marks_nothing() { - let (_dir, coord) = coordinator(); - let host = FakeHost::up().failing("recorded_build"); - run_rebuild_bookkeeping(&host, &coord, "alice") - .await - .expect("ok"); - assert_eq!(host.calls(), ["recorded_build alice"]); -} - -#[tokio::test] -async fn a_failed_marker_write_does_not_fail_the_bookkeeping_node() { - let (_dir, coord) = coordinator(); - let host = FakeHost::up().failing("mark_deployed"); - run_rebuild_bookkeeping(&host, &coord, "alice") - .await - .expect("warn only"); + run_rebuild_bookkeeping(&coord).await.expect("ok"); } #[tokio::test] @@ -1234,30 +1034,21 @@ async fn dispatch_routes_each_host_backed_kind_to_its_executor() { "refresh_archetype alice", "prepare_rebuild_dirs alice", "refresh_agent_set", - "resolve_meta alice", - "record_meta alice", ], ), ( alice(|agent| NodeKind::Prebuild { agent }), - &[ - "is_running alice", - "recorded_rev alice", - "closure_record alice", - ], + &["is_running alice", "closure_record alice"], ), ( alice(|agent| NodeKind::Swap { agent }), &[ - "recorded_rev alice", "closure_record alice", "swap_update alice", + "mark_deployed alice", ], ), - ( - alice(|agent| NodeKind::RebuildBookkeeping { agent }), - &["recorded_build alice", "mark_deployed alice"], - ), + (alice(|agent| NodeKind::RebuildBookkeeping { agent }), &[]), ( alice(|agent| NodeKind::Provision { agent }), &["provision alice"], @@ -1265,7 +1056,6 @@ async fn dispatch_routes_each_host_backed_kind_to_its_executor() { ( alice(|agent| NodeKind::Create { agent }), &[ - "resolve_meta alice", "closure_record alice", "create_only alice", "mark_deployed alice", diff --git a/hive-c0re/src/job_queue/mod.rs b/hive-c0re/src/job_queue/mod.rs index 5a32fd74..b87cf7e8 100644 --- a/hive-c0re/src/job_queue/mod.rs +++ b/hive-c0re/src/job_queue/mod.rs @@ -190,6 +190,16 @@ impl JobQueue { Ok(named) } + /// Whether a rebuild of `agent` is queued or running: its `MetaSync` is not + /// terminal yet. + #[must_use] + pub fn rebuild_live(&self, agent: &str) -> bool { + self.lock().graph().nodes().any(|n| { + !n.state.is_terminal() + && matches!(&n.payload, model::NodeKind::MetaSync { agent: a } if a == agent) + }) + } + /// Queue `agent`'s first deploy unless one is already queued or running. /// /// `None` means a `Provision` for the agent is still live. The check and diff --git a/hive-c0re/src/job_queue/model.rs b/hive-c0re/src/job_queue/model.rs index 29dd68bd..c68db691 100644 --- a/hive-c0re/src/job_queue/model.rs +++ b/hive-c0re/src/job_queue/model.rs @@ -16,7 +16,7 @@ pub use hive_host_sock::jobs::State; use serde::Serialize; /// The primitive operations — each kind maps to one executor fn in -/// `exec.rs`, a thin wrapper over existing `lifecycle.rs` / `meta_rev.rs` +/// `exec.rs`, a thin wrapper over existing `lifecycle.rs` /// code. Concurrency is gated by resource classes (see /// [`Resource`](super::resource::Resource), declared per node where the node /// is constructed rather than derived from its kind). @@ -29,8 +29,8 @@ use serde::Serialize; #[serde(tag = "kind", rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum NodeKind { - /// The rebuild's preamble: runtime-dir prep and the `internal/meta` - /// commit the build reads. + /// The rebuild's preamble: runtime-dir prep, the archetype and agent-set + /// refresh. MetaSync { agent: String }, /// Download of the swarm's closure while the container keeps serving. Prebuild { agent: String }, diff --git a/hive-c0re/src/job_queue/power.rs b/hive-c0re/src/job_queue/power.rs index 7d969e55..e0074fe7 100644 --- a/hive-c0re/src/job_queue/power.rs +++ b/hive-c0re/src/job_queue/power.rs @@ -76,8 +76,8 @@ fn stop_chain( wanted.guid() } -/// One agent's **start** subgraph. `SetWanted(Up)` head; a down + stale-rev -/// agent gets the rebuild subgraph (its tail `Reconcile` starts it on +/// One agent's **start** subgraph. `SetWanted(Up)` head; a down agent whose +/// declared closure is not applied gets the rebuild subgraph (its tail `Reconcile` starts it on /// current derivations), otherwise a plain `Reconcile` (which starts a down /// agent and noops an already-running one). /// @@ -277,8 +277,8 @@ pub async fn restart_many( } /// Start `agents` in a **single** DAG. A down agent gets -/// `SetWanted(Up) → Reconcile` (or, rev stale, a rebuild-then-start so it comes -/// up on current derivations); an already-running agent gets the same shape +/// `SetWanted(Up) → Reconcile` (or, when the swarm declares a closure it has not applied, a +/// rebuild-then-start so it comes up on the declared one); an already-running agent gets the same shape /// with the reconcile noop'ing. /// /// # Errors @@ -287,14 +287,12 @@ pub async fn start_many( coord: &Arc, agents: &[String], ) -> anyhow::Result> { - let current = crate::meta_rev::current_hyperhive_pin().await; let mut targets = Vec::with_capacity(agents.len()); for agent in agents { let running = lifecycle::is_running(agent).await; - let stored = read_rev_marker(&crate::paths::applied_rev_marker(agent)); - let stale = !crate::meta_rev::marker_is_fresh(current.as_deref(), stored.as_deref()); + let stale = crate::attic::closure::pending_path(agent).await.is_some(); if !running && stale { - tracing::info!(%agent, "start: rev stale + agent down — rebuild-then-start"); + tracing::info!(%agent, "start: closure declared + agent down — rebuild-then-start"); } targets.push((agent.clone(), running, stale)); } @@ -303,20 +301,6 @@ pub async fn start_many( Ok(ids) } -/// The rev `marker` records, or `None` when there is none. A missing file is -/// the normal never-applied case; any other read error is logged, and the -/// caller still treats the rev as stale. -fn read_rev_marker(marker: &std::path::Path) -> Option { - match std::fs::read_to_string(marker) { - Ok(rev) => Some(rev), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, - Err(e) => { - tracing::warn!(path = %marker.display(), error = %e, "start: applied-rev marker unreadable; treating rev as stale"); - None - } - } -} - /// Stop `agents` in a **single** DAG. A running agent gets /// `SetWanted(Off) → [Signal → Drain →](graceful) Reconcile`; a down agent /// skips the pointless quiesce but keeps the `Reconcile` as the race-up @@ -366,19 +350,3 @@ pub fn pause_many(coord: &Arc, agents: &[String]) -> anyhow::Result coord.emit_rebuild_queue_snapshot(); Ok(ids) } - -#[cfg(test)] -mod tests { - use super::read_rev_marker; - - #[test] - fn a_rev_marker_reads_as_its_contents_and_none_when_absent_or_unreadable() { - let dir = tempfile::tempdir().unwrap(); - let marker = dir.path().join("rev"); - assert_eq!(read_rev_marker(&marker), None); - std::fs::write(&marker, "abc").unwrap(); - assert_eq!(read_rev_marker(&marker).as_deref(), Some("abc")); - // A directory where the file belongs fails with a non-NotFound error. - assert_eq!(read_rev_marker(dir.path()), None); - } -} diff --git a/hive-c0re/src/job_queue/tests.rs b/hive-c0re/src/job_queue/tests.rs index ddb92e7a..175ce7c9 100644 --- a/hive-c0re/src/job_queue/tests.rs +++ b/hive-c0re/src/job_queue/tests.rs @@ -1545,3 +1545,18 @@ fn a_first_deploy_is_not_queued_twice_for_one_agent() { "control: another agent's deploy is independent" ); } + +/// The auto-update pass queues no second rebuild for an agent that has one in +/// flight; another agent's rebuild does not count. +#[test] +fn a_rebuild_is_live_for_its_own_agent_only() { + let q = JobQueue::new(1); + assert!(!q.rebuild_live("alice"), "nothing queued yet"); + q.insert_job(|b| templates::rebuild(b, "alice")) + .expect("insert"); + assert!( + q.rebuild_live("alice"), + "control: the queued rebuild is seen" + ); + assert!(!q.rebuild_live("bob")); +} diff --git a/hive-c0re/src/lifecycle/mod.rs b/hive-c0re/src/lifecycle/mod.rs index b4ac7a4f..2b1a442f 100644 --- a/hive-c0re/src/lifecycle/mod.rs +++ b/hive-c0re/src/lifecycle/mod.rs @@ -687,16 +687,6 @@ pub async fn list() -> Result> { .collect()) } -/// Logical agent names from a [`list`] result. An unreadable list stays an -/// error rather than becoming "no agents": a caller fanning work out over -/// every agent would otherwise report success after doing nothing. -pub fn agent_names(listed: Result>) -> Result> { - Ok(listed? - .into_iter() - .filter_map(|c| c.strip_prefix(AGENT_PREFIX).map(str::to_owned)) - .collect()) -} - /// Ensure the per-agent runtime directory `/run/hyperhive/agents/` /// exists. It is a bind source and `/run` is a tmpfs, so every start path /// creates it before `nixos-container start`. diff --git a/hive-c0re/src/lifecycle/tests.rs b/hive-c0re/src/lifecycle/tests.rs index 9cc5f895..7f7b5680 100644 --- a/hive-c0re/src/lifecycle/tests.rs +++ b/hive-c0re/src/lifecycle/tests.rs @@ -180,21 +180,6 @@ fn failed_destroy_with_an_unreadable_list_fails() { assert!(format!("{err:#}").contains("cannot confirm"), "{err:#}"); } -#[test] -fn agent_names_strips_the_container_prefix() { - let names = agent_names(Ok(vec![format!("{AGENT_PREFIX}iris"), "other".to_owned()])) - .expect("a readable list"); - assert_eq!(names, vec!["iris"]); -} - -/// A failed list is not an empty hive: the error reaches the caller. -#[test] -fn agent_names_propagates_an_unreadable_list() { - let err = agent_names(Err(anyhow::anyhow!("connect to hive-priv socket"))) - .expect_err("an unreadable list must not read as zero agents"); - assert!(format!("{err:#}").contains("hive-priv"), "{err:#}"); -} - /// The setup path creates the agent's `work/` dir beside `state/`, so the /// bind source exists before nspawn starts the container. #[test] diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 31c425b1..5ac6e43f 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -27,7 +27,6 @@ mod job_queue; mod lifecycle; mod loose_ends; mod meta; -mod meta_rev; mod migrate; mod paths; mod priv_client; diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 4dcc9fb4..0d1d9b3f 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -1,7 +1,7 @@ //! The hive's `meta/` directory: plain state beside the hive's other files //! (`topology.json`, the swarm limits) that the manager container sees -//! read-only. Agents are built from the swarm's `internal/meta` -//! ([`crate::meta_rev`]), not from anything here. +//! read-only. Agents are deployed from the closures the swarm builds +//! ([`crate::attic::closure`]), not from anything here. use anyhow::{Context, Result}; diff --git a/hive-c0re/src/meta_rev.rs b/hive-c0re/src/meta_rev.rs deleted file mode 100644 index 700c1523..00000000 --- a/hive-c0re/src/meta_rev.rs +++ /dev/null @@ -1,667 +0,0 @@ -//! The `internal/meta` commit a hive deploys an agent from. -//! -//! `internal/meta` is the swarm's meta flake, committed by swarm-controller; -//! a hive has no meta flake of its own and builds nothing from it. A rebuild's -//! `MetaSync` node resolves the head ([`resolve`]) and records it ([`record`]); -//! `Prebuild` and `Swap` read the record ([`recorded`]) for the commit the -//! swarm's closure is expected to come from. A create resolves and uses the -//! commit directly. When the head does not pin the agent's current config, the -//! deploy fails rather than falling back: the swarm re-pins before it asks a -//! hive to deploy. -//! -//! The record also names the pins a successful deploy applies. [`mark_deployed`] -//! copies them to the agent's markers, which the boot reconcile -//! ([`crate::auto_update`]) and the dashboard's update chip read. - -use anyhow::{Context, Result, bail}; -use hive_priv_sock::valid_meta_rev; -use serde::{Deserialize, Serialize}; -use swarm_queue_client::agent_archetype::ArchetypeRef; - -/// What the forge says about `internal/meta` and an agent's config repo. -pub(crate) trait MetaSource { - /// The default branch's head commit of `internal/meta`; `None` when the - /// repo is absent or has no commit. - async fn meta_head(&self) -> Result>; - /// `internal/meta`'s `flake.nix` at `rev`. - async fn flake_at(&self, rev: &str) -> Result; - /// The head of `agent-configs/`'s `main`; `None` when it has none. - async fn config_head(&self, agent: &str) -> Result>; -} - -/// An `internal/meta` commit and the pins it holds for one agent. -#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct MetaBuild { - /// The `internal/meta` commit. - pub rev: String, - /// The hyperhive commit its flake pins. - pub hyperhive: String, - /// The agent's config commit its flake pins; none for an archetype agent. - pub config: Option, -} - -impl MetaBuild { - /// Whether `other` pins the same hyperhive and config commits: what decides - /// whether the agent's closure is rebuilt, whichever `internal/meta` commit - /// holds them. - pub(crate) fn same_pins(&self, other: &MetaBuild) -> bool { - self.hyperhive == other.hyperhive && self.config == other.config - } -} - -/// The `internal/meta` commit to deploy `agent` from. -/// -/// The head is taken only when its `flake.nix` builds the agent the way it is -/// wanted now: from `archetype` when that is set, otherwise from the config -/// commit the forge's `main` is at. A head that is behind (the swarm's update -/// has not run, or failed), lacks the agent or lists it under another archetype -/// would build an older module than the one being deployed. -/// -/// # Errors -/// No usable head, a head that does not pin the agent's current config, or a -/// forge read that failed. -pub(crate) async fn choose( - source: &impl MetaSource, - agent: &str, - archetype: Option<&ArchetypeRef>, -) -> Result { - let Some(rev) = source.meta_head().await? else { - bail!("{agent}: the swarm has not published internal/meta yet"); - }; - if !valid_meta_rev(&rev) { - bail!("{agent}: internal/meta's head {rev:?} is not a commit id"); - } - let flake = source.flake_at(&rev).await?; - let config = if archetype.is_some() { - None - } else { - source.config_head(agent).await? - }; - if !hive_meta_flake::builds_agent_at(&flake, agent, archetype, config.as_deref()) { - bail!("{agent}: the swarm meta flake does not pin its config yet (internal/meta at {rev})"); - } - let Some(hyperhive) = hive_meta_flake::pinned_rev(&flake, "hyperhive") else { - bail!("{agent}: internal/meta at {rev} pins no hyperhive commit"); - }; - Ok(MetaBuild { - rev, - hyperhive: hyperhive.to_owned(), - config, - }) -} - -/// The hyperhive commit `internal/meta`'s head pins; `None` when the repo or -/// its commit is absent. -/// -/// # Errors -/// A forge read that failed, or a head that pins no hyperhive commit. -pub(crate) async fn hyperhive_pin(source: &impl MetaSource) -> Result> { - let Some(rev) = source.meta_head().await? else { - return Ok(None); - }; - let flake = source.flake_at(&rev).await?; - let pin = hive_meta_flake::pinned_rev(&flake, "hyperhive") - .with_context(|| format!("internal/meta at {rev} pins no hyperhive commit"))?; - Ok(Some(pin.to_owned())) -} - -/// The hyperhive commit `internal/meta` pins now, as this hive reads it: what -/// an agent's marker is compared with to tell it is stale. `None`, logged, -/// when the hive cannot tell (no forge token, a failed read, no commit yet), -/// which callers treat as nothing to rebuild for. -pub(crate) async fn current_hyperhive_pin() -> Option { - let Some(source) = crate::forge::internal_meta_source().await else { - tracing::info!("internal/meta: no forge read token; cannot tell which hyperhive it pins"); - return None; - }; - match hyperhive_pin(&source).await { - Ok(pin) => pin, - Err(e) => { - tracing::warn!(error = %format!("{e:#}"), "internal/meta: cannot read its hyperhive pin"); - None - } - } -} - -/// [`choose`] against the forge this hive reads. -/// -/// # Errors -/// As [`choose`], or this hive holds no forge token or cannot read the -/// agent's archetype. -pub(crate) async fn resolve(agent: &str) -> Result { - let archetype = crate::archetypes::of(agent) - .with_context(|| format!("{agent}: cannot read the agent's archetype"))?; - let Some(source) = crate::forge::internal_meta_source().await else { - bail!("{agent}: this hive holds no forge token to read internal/meta with"); - }; - let build = choose(&source, agent, archetype.as_ref()).await?; - tracing::info!(%agent, rev = %build.rev, "internal/meta: building from the swarm's meta flake"); - Ok(build) -} - -/// The pins `internal/meta` at `rev` holds for `agent`: the hyperhive commit and, -/// for a custom agent (`archetype` false), its config commit. This is what a -/// deploy of the swarm's closure for `rev` applies, which can differ from the -/// head [`choose`] took. -/// -/// # Errors -/// `rev` is not a commit id, the forge read failed, or the flake pins no -/// hyperhive commit or, for a custom agent, no config commit. -pub(crate) async fn pins_at( - source: &impl MetaSource, - agent: &str, - archetype: bool, - rev: &str, -) -> Result { - if !valid_meta_rev(rev) { - bail!("{agent}: {rev:?} is not an internal/meta commit id"); - } - let flake = source.flake_at(rev).await?; - let Some(hyperhive) = hive_meta_flake::pinned_rev(&flake, "hyperhive") else { - bail!("{agent}: internal/meta at {rev} pins no hyperhive commit"); - }; - let config = if archetype { - None - } else { - let Some(config) = hive_meta_flake::pinned_rev(&flake, &format!("agent-{agent}")) else { - bail!("{agent}: internal/meta at {rev} pins no config commit for it"); - }; - Some(config.to_owned()) - }; - Ok(MetaBuild { - rev: rev.to_owned(), - hyperhive: hyperhive.to_owned(), - config, - }) -} - -/// Whether `agent` has the same pins at the commits `built` and `head`, so a -/// closure built from `built` is as good as one built from `head`. -/// -/// # Errors -/// As [`pins_at`], for either commit. -pub(crate) async fn pins_unchanged( - source: &impl MetaSource, - agent: &str, - archetype: bool, - built: &str, - head: &str, -) -> Result { - let built = pins_at(source, agent, archetype, built).await?; - let head = pins_at(source, agent, archetype, head).await?; - Ok(built.same_pins(&head)) -} - -/// [`pins_at`] against the forge this hive reads. -/// -/// # Errors -/// As [`pins_at`], or this hive holds no forge token or cannot read the -/// agent's archetype. -pub(crate) async fn resolve_at(agent: &str, rev: &str) -> Result { - let archetype = crate::archetypes::of(agent) - .with_context(|| format!("{agent}: cannot read the agent's archetype"))?; - let Some(source) = crate::forge::internal_meta_source().await else { - bail!("{agent}: this hive holds no forge token to read internal/meta with"); - }; - pins_at(&source, agent, archetype.is_some(), rev).await -} - -/// Record `build` as the commit `agent`'s rebuild expects the swarm's closure -/// from, and the pins it deploys. The record is in -/// hive-c0re's own state ([`crate::paths::meta_rev_dir`]), where the agent -/// cannot write. -/// -/// # Errors -/// The record file could not be written. -pub(crate) fn record(agent: &str, build: &MetaBuild) -> Result<()> { - record_in(&crate::paths::meta_rev_dir(), agent, build) -} - -/// What [`record`] stored for `agent`, if it is a valid record. -pub(crate) fn recorded(agent: &str) -> Option { - recorded_in(&crate::paths::meta_rev_dir(), agent) -} - -/// Written whole to a temp file beside the record and renamed over it, so a -/// reader sees the old record or the new one and a crash leaves no partial -/// file. -fn record_in(dir: &std::path::Path, agent: &str, build: &MetaBuild) -> Result<()> { - std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; - let path = dir.join(agent); - let tmp = dir.join(format!(".{agent}.tmp")); - std::fs::write(&tmp, serde_json::to_vec(build)?) - .with_context(|| format!("write {}", tmp.display()))?; - std::fs::rename(&tmp, &path) - .with_context(|| format!("rename {} to {}", tmp.display(), path.display())) -} - -fn recorded_in(dir: &std::path::Path, agent: &str) -> Option { - let build: MetaBuild = serde_json::from_slice(&std::fs::read(dir.join(agent)).ok()?).ok()?; - let valid = valid_meta_rev(&build.rev) - && valid_meta_rev(&build.hyperhive) - && build.config.as_deref().is_none_or(valid_meta_rev); - valid.then_some(build) -} - -/// The record of `agent`'s build, or the error naming why a `Prebuild` or -/// `Swap` has no commit to expect. -/// -/// # Errors -/// No valid record: its `MetaSync` did not run, or ran in an older hive-c0re. -pub(crate) fn recorded_or_err(agent: &str) -> Result { - recorded(agent) - .with_context(|| format!("{agent}: no internal/meta commit was recorded for this build")) -} - -/// Mark `agent` as running what `build` pins: the hyperhive commit for the -/// boot reconcile ([`crate::paths::applied_rev_marker`]), the config commit -/// for the dashboard's deployed chip ([`crate::paths::applied_config_marker`]). -/// -/// # Errors -/// A marker could not be written or removed. -pub(crate) fn mark_deployed(agent: &str, build: &MetaBuild) -> Result<()> { - mark_deployed_in( - &crate::paths::applied_rev_marker(agent), - &crate::paths::applied_config_marker(agent), - build, - ) -} - -fn mark_deployed_in( - hyperhive: &std::path::Path, - config: &std::path::Path, - build: &MetaBuild, -) -> Result<()> { - std::fs::write(hyperhive, &build.hyperhive) - .with_context(|| format!("write {}", hyperhive.display()))?; - match &build.config { - Some(rev) => { - std::fs::write(config, rev).with_context(|| format!("write {}", config.display())) - } - None => match std::fs::remove_file(config) { - Err(e) if e.kind() != std::io::ErrorKind::NotFound => { - Err(e).with_context(|| format!("remove {}", config.display())) - } - _ => Ok(()), - }, - } -} - -/// Whether an agent's `stored` marker says it runs the hyperhive commit `pin` -/// names. With no `pin`, because the hive can't read `internal/meta`, nothing -/// is stale: a rebuild would have no commit to deploy either. -pub(crate) fn marker_is_fresh(pin: Option<&str>, stored: Option<&str>) -> bool { - pin.is_none_or(|pin| stored == Some(pin)) -} - -#[cfg(test)] -mod tests { - use super::*; - - const HYPERHIVE: &str = "1111111111111111111111111111111111111111"; - const META: &str = "3333333333333333333333333333333333333333"; - const ARGUS_HEAD: &str = "2222222222222222222222222222222222222222"; - const STALE: &str = "4444444444444444444444444444444444444444"; - - /// A forge holding `internal/meta` at [`META`] with the given flake, and - /// `agent-configs/argus` at `config_head`. - struct Forge { - head: Option<&'static str>, - flake: String, - config_head: Option<&'static str>, - } - - impl MetaSource for Forge { - async fn meta_head(&self) -> Result> { - Ok(self.head.map(str::to_owned)) - } - async fn flake_at(&self, rev: &str) -> Result { - assert_eq!(Some(rev), self.head, "the flake is read at the head"); - Ok(self.flake.clone()) - } - async fn config_head(&self, _: &str) -> Result> { - Ok(self.config_head.map(str::to_owned)) - } - } - - /// `internal/meta`'s flake as swarm-controller renders it: argus pinned at - /// [`ARGUS_HEAD`], and a reviewer from an archetype. - fn flake() -> String { - use swarm_wire::agent_archetype::ArchetypeRef; - - let url = |repo: &str, rev: &str| { - hive_meta_flake::pinned_git_url(&format!("https://f.t/{repo}.git"), Some(rev)).unwrap() - }; - let spec = |name: &str, archetype, pin| hive_meta_flake::AgentSpec { - name: name.to_owned(), - port: hive_meta_flake::agent_web_port(name), - archetype, - pin, - }; - hive_meta_flake::render_flake( - &url("hyperhive/hyperhive", HYPERHIVE), - &[ - spec( - "argus", - None, - Some(hive_meta_flake::AgentPin { - url: url("agent-configs/argus", ARGUS_HEAD), - canonical_inputs: vec![], - }), - ), - spec( - "rev", - Some(ArchetypeRef { - source: "hyperhive".to_owned(), - name: "reviewer".to_owned(), - }), - None, - ), - ], - &[], - false, - ) - .unwrap() - } - - /// `internal/meta` commits, each with its flake. - struct Commits(Vec<(&'static str, String)>); - - impl MetaSource for Commits { - async fn meta_head(&self) -> Result> { - Ok(None) - } - async fn flake_at(&self, rev: &str) -> Result { - self.0 - .iter() - .find(|(r, _)| *r == rev) - .map(|(_, flake)| flake.clone()) - .with_context(|| format!("no commit {rev}")) - } - async fn config_head(&self, _: &str) -> Result> { - Ok(None) - } - } - - #[tokio::test] - async fn pins_unchanged_follows_the_agents_pins_not_the_commit() { - let moved = flake().replace(ARGUS_HEAD, STALE); - let commits = Commits(vec![(META, flake()), (STALE, flake()), (HYPERHIVE, moved)]); - let unchanged = - |agent, archetype, built| pins_unchanged(&commits, agent, archetype, built, META); - // Another commit, the same pins. - assert!(unchanged("argus", false, STALE).await.unwrap()); - assert!(unchanged("rev", true, STALE).await.unwrap()); - // The agent's own config pin moved; an archetype agent has none. - assert!(!unchanged("argus", false, HYPERHIVE).await.unwrap()); - assert!(unchanged("rev", true, HYPERHIVE).await.unwrap()); - // A commit that cannot be read is an error, not a verdict. - assert!(unchanged("argus", false, ARGUS_HEAD).await.is_err()); - } - - fn forge(config_head: Option<&'static str>) -> Forge { - Forge { - head: Some(META), - flake: flake(), - config_head, - } - } - - fn reviewer() -> ArchetypeRef { - ArchetypeRef { - source: "hyperhive".to_owned(), - name: "reviewer".to_owned(), - } - } - - /// The error a build with no usable pin ends in: it names the agent and - /// says the swarm meta flake does not pin its config yet. - fn assert_unpinned(err: &anyhow::Error, agent: &str) { - let msg = format!("{err:#}"); - assert!( - msg.starts_with(&format!("{agent}: ")) - && msg.contains("swarm meta flake does not pin its config yet"), - "{msg}" - ); - } - - #[tokio::test] - async fn the_head_is_taken_when_it_pins_the_agents_current_config() { - assert_eq!( - choose(&forge(Some(ARGUS_HEAD)), "argus", None) - .await - .unwrap(), - MetaBuild { - rev: META.to_owned(), - hyperhive: HYPERHIVE.to_owned(), - config: Some(ARGUS_HEAD.to_owned()), - } - ); - } - - #[tokio::test] - async fn a_head_behind_the_agents_config_is_an_error() { - let err = choose(&forge(Some(STALE)), "argus", None) - .await - .unwrap_err(); - assert_unpinned(&err, "argus"); - } - - #[tokio::test] - async fn a_head_without_the_agent_is_an_error() { - let mut source = forge(Some(ARGUS_HEAD)); - source.flake = source.flake.replace("argus", "someone"); - let err = choose(&source, "argus", None).await.unwrap_err(); - assert_unpinned(&err, "argus"); - } - - #[tokio::test] - async fn an_archetype_agent_is_taken_when_the_head_lists_that_archetype() { - struct Archetypes(Forge); - impl MetaSource for Archetypes { - async fn meta_head(&self) -> Result> { - self.0.meta_head().await - } - async fn flake_at(&self, rev: &str) -> Result { - self.0.flake_at(rev).await - } - async fn config_head(&self, _: &str) -> Result> { - panic!("an archetype agent has no config repo to read") - } - } - let source = Archetypes(forge(None)); - assert_eq!( - choose(&source, "rev", Some(&reviewer())).await.unwrap(), - MetaBuild { - rev: META.to_owned(), - hyperhive: HYPERHIVE.to_owned(), - config: None, - }, - "an archetype agent pins no config" - ); - let other = ArchetypeRef { - name: "planner".to_owned(), - ..reviewer() - }; - assert_unpinned( - &choose(&source, "rev", Some(&other)).await.unwrap_err(), - "rev", - ); - assert_unpinned( - &choose(&source, "ghost", Some(&reviewer())) - .await - .unwrap_err(), - "ghost", - ); - } - - /// The head lists argus as a custom agent and rev as an archetype agent; - /// an agent switched either way is not what the head builds. - #[tokio::test] - async fn an_agent_switched_between_custom_and_archetype_is_an_error() { - let source = forge(Some(ARGUS_HEAD)); - assert_unpinned( - &choose(&source, "argus", Some(&reviewer())) - .await - .unwrap_err(), - "argus", - ); - assert_unpinned(&choose(&source, "rev", None).await.unwrap_err(), "rev"); - } - - #[tokio::test] - async fn the_pins_at_a_commit_are_what_its_flake_holds() { - let source = forge(Some(ARGUS_HEAD)); - let argus = pins_at(&source, "argus", false, META).await.unwrap(); - assert_eq!( - argus, - MetaBuild { - rev: META.to_owned(), - hyperhive: HYPERHIVE.to_owned(), - config: Some(ARGUS_HEAD.to_owned()), - } - ); - // An archetype agent has no config pin of its own. - let rev = pins_at(&source, "rev", true, META).await.unwrap(); - assert_eq!((rev.hyperhive.as_str(), rev.config), (HYPERHIVE, None)); - // A custom agent the flake doesn't list, and a rev that is no commit id. - let err = pins_at(&source, "nobody", false, META).await.unwrap_err(); - assert!(format!("{err:#}").contains("no config commit"), "{err:#}"); - let err = pins_at(&source, "argus", false, "main").await.unwrap_err(); - assert!(format!("{err:#}").contains("not an internal/meta commit")); - } - - #[tokio::test] - async fn no_repo_or_no_commit_is_an_error_naming_the_agent() { - let mut source = forge(Some(ARGUS_HEAD)); - source.head = None; - let err = choose(&source, "argus", None).await.unwrap_err(); - assert!( - format!("{err:#}").starts_with("argus: the swarm has not published internal/meta"), - "{err:#}" - ); - } - - #[tokio::test] - async fn a_custom_agent_with_no_config_commit_is_an_error() { - let err = choose(&forge(None), "argus", None).await.unwrap_err(); - assert_unpinned(&err, "argus"); - } - - #[tokio::test] - async fn the_hyperhive_pin_is_the_one_in_the_head() { - assert_eq!( - hyperhive_pin(&forge(None)).await.unwrap().as_deref(), - Some(HYPERHIVE) - ); - let mut source = forge(None); - source.head = None; - assert_eq!(hyperhive_pin(&source).await.unwrap(), None); - let mut source = forge(None); - source.flake = source.flake.replace("hyperhive.url", "other.url"); - assert!(hyperhive_pin(&source).await.is_err(), "a head with no pin"); - } - - fn build() -> MetaBuild { - MetaBuild { - rev: META.to_owned(), - hyperhive: HYPERHIVE.to_owned(), - config: Some(ARGUS_HEAD.to_owned()), - } - } - - #[test] - fn a_record_is_read_back_and_replaced() { - let dir = tempfile::tempdir().unwrap(); - assert_eq!(recorded_in(dir.path(), "argus"), None); - record_in(dir.path(), "argus", &build()).unwrap(); - assert_eq!(recorded_in(dir.path(), "argus"), Some(build())); - assert_eq!( - recorded_in(dir.path(), "rev"), - None, - "a record is one agent's" - ); - let next = MetaBuild { - rev: STALE.to_owned(), - config: None, - ..build() - }; - record_in(dir.path(), "argus", &next).unwrap(); - assert_eq!(recorded_in(dir.path(), "argus"), Some(next)); - } - - #[test] - fn a_record_leaves_no_temp_file_and_creates_its_dir() { - let dir = tempfile::tempdir().unwrap(); - let records = dir.path().join("meta-rev"); - record_in(&records, "argus", &build()).unwrap(); - let names: Vec<_> = std::fs::read_dir(&records) - .unwrap() - .map(|e| e.unwrap().file_name()) - .collect(); - assert_eq!(names, ["argus"]); - } - - /// Anything but a record naming commit ids is no record: a plain commit id - /// (the older format), a branch name, a bad pin. - #[test] - fn a_record_that_does_not_name_commit_ids_is_no_record() { - let dir = tempfile::tempdir().unwrap(); - for bad in [ - META.to_owned(), - "main\n".to_owned(), - serde_json::to_string(&MetaBuild { - rev: "main".to_owned(), - ..build() - }) - .unwrap(), - serde_json::to_string(&MetaBuild { - hyperhive: "main".to_owned(), - ..build() - }) - .unwrap(), - serde_json::to_string(&MetaBuild { - config: Some("main".to_owned()), - ..build() - }) - .unwrap(), - ] { - std::fs::write(dir.path().join("argus"), &bad).unwrap(); - assert_eq!(recorded_in(dir.path(), "argus"), None, "{bad}"); - } - } - - /// The record is hive-c0re's own state, never the agent-writable runtime - /// dir bind-mounted into the container. - #[test] - fn the_record_is_outside_the_agents_runtime_dir() { - assert!(!crate::paths::meta_rev_dir().starts_with(crate::paths::runtime_root())); - assert!(crate::paths::meta_rev_dir().starts_with(crate::paths::state_root())); - } - - #[test] - fn marking_deployed_writes_both_pins_and_clears_the_config_of_an_archetype_agent() { - let dir = tempfile::tempdir().unwrap(); - let hyperhive = dir.path().join("hyperhive"); - let config = dir.path().join("config"); - mark_deployed_in(&hyperhive, &config, &build()).unwrap(); - assert_eq!(std::fs::read_to_string(&hyperhive).unwrap(), HYPERHIVE); - assert_eq!(std::fs::read_to_string(&config).unwrap(), ARGUS_HEAD); - let archetype = MetaBuild { - config: None, - ..build() - }; - mark_deployed_in(&hyperhive, &config, &archetype).unwrap(); - assert!(!config.exists(), "an archetype agent pins no config"); - mark_deployed_in(&hyperhive, &config, &archetype).unwrap(); - } - - #[test] - fn a_marker_is_fresh_only_at_the_pin() { - assert!(marker_is_fresh(Some(HYPERHIVE), Some(HYPERHIVE))); - assert!(!marker_is_fresh(Some(HYPERHIVE), Some(STALE)), "behind"); - assert!(!marker_is_fresh(Some(HYPERHIVE), None), "never built"); - assert!(marker_is_fresh(None, Some(STALE)), "no pin to compare with"); - assert!(marker_is_fresh(None, None)); - } -} diff --git a/hive-c0re/src/paths.rs b/hive-c0re/src/paths.rs index 5916a8b4..a5b770f5 100644 --- a/hive-c0re/src/paths.rs +++ b/hive-c0re/src/paths.rs @@ -122,20 +122,12 @@ pub fn applied_root() -> PathBuf { state_root().join("applied") } -/// `applied/..hyperhive-rev` — marker recording the hyperhive commit -/// `internal/meta` pinned when an agent was last successfully built (the boot -/// reconcile's staleness check). +/// `applied/..closure` — marker holding the store path of the swarm's +/// closure an agent was last swapped to or created from, which +/// [`crate::attic::closure::pending`] compares the record's with. #[must_use] -pub fn applied_rev_marker(name: &str) -> PathBuf { - applied_root().join(format!(".{name}.hyperhive-rev")) -} - -/// `applied/..config-rev` — marker recording the config commit -/// `internal/meta` pinned when an agent was last successfully built (the -/// dashboard's deployed chip). Absent for an archetype agent. -#[must_use] -pub fn applied_config_marker(name: &str) -> PathBuf { - applied_root().join(format!(".{name}.config-rev")) +pub fn applied_closure_marker(name: &str) -> PathBuf { + applied_root().join(format!(".{name}.closure")) } /// `meta/` — plain state beside the hive's other files (`topology.json`, @@ -147,17 +139,6 @@ pub fn meta_root() -> PathBuf { state_root().join("meta") } -/// `meta-rev/` — one file per agent naming the `internal/meta` commit its -/// rebuild builds from (`crate::meta_rev`). -/// -/// ⚠️ Deliberately **not** in the agent's runtime dir: that is bind-mounted -/// read-write into the container as `/run/hive`, and the commit decides which -/// flake the agent's own toplevel is built from. -#[must_use] -pub fn meta_rev_dir() -> PathBuf { - state_root().join("meta-rev") -} - /// `knowledge/` — local checkout of the `internal/knowledge` repo. A /// `&str` (used as a git `-C` arg / clone target throughout the knowledge /// worker), so it stays a const rather than a `PathBuf` fn. diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 5c51a7a5..070b4ba7 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -624,7 +624,7 @@ async fn handle_start( // (`SetWanted(Up) → Reconcile`, or a rebuild-then-start for a stale // rev), independent roots that run concurrently on their own leases. A // hive-wide `hivectl start` is now a single DAG, not N. Through the - // queue: persists `wanted = Up`, per-agent stale-rev upgrade to a full + // queue: persists `wanted = Up`, per-agent declared-closure upgrade to a full // rebuild, serializes on each agent's lease. The id rides back for // hivectl's wait loop. let mut queued: Vec = Vec::new(); diff --git a/hive-c0re/src/stats/host_stats.rs b/hive-c0re/src/stats/host_stats.rs index 75472431..2158b5b3 100644 --- a/hive-c0re/src/stats/host_stats.rs +++ b/hive-c0re/src/stats/host_stats.rs @@ -239,9 +239,7 @@ mod tests { port: 0, running: true, failed: false, - needs_update: false, needs_login, - deployed_sha: None, active_model: None, status_text: None, status_set_at: None, diff --git a/hive-c0re/src/swarm_status.rs b/hive-c0re/src/swarm_status.rs index 49053fe8..2d9afa41 100644 --- a/hive-c0re/src/swarm_status.rs +++ b/hive-c0re/src/swarm_status.rs @@ -290,7 +290,9 @@ async fn handle_deploy_request( return; } }; - let swarm_queue_client::DeployRequest { agent, rev } = request; + // The request names a config commit for controllers that predate the closure + // record; what a deploy applies is the record, so the field is not read. + let swarm_queue_client::DeployRequest { agent, rev: _ } = request; // Which of the two meanings this request has is decided here, and the // predicate is "does a container exist", not "is one running": @@ -309,22 +311,8 @@ async fn handle_deploy_request( } }; - // Only a rebuild looks at `rev`. A first deploy ignores it: the build takes - // whatever commit `internal/meta` pins. An archetype agent has no config - // repo, so its rebuild ignores it too. - let archetype = match crate::archetypes::of(&agent) { - Ok(archetype) => archetype, - Err(e) => { - tracing::warn!(%agent, error = ?e, "swarm events: cannot read the stored archetypes; deploy dropped"); - return; - } - }; - if known - && archetype.is_none() - && let Some(rev) = rev.as_deref() - && rev_already_deployed(&agent, rev) - { - tracing::info!(%agent, %rev, "swarm events: deploy requested for the deployed rev; nothing queued"); + if known && crate::attic::closure::is_up_to_date(&agent).await { + tracing::info!(%agent, "swarm events: deploy requested for the closure already deployed; nothing queued"); return; } @@ -352,21 +340,6 @@ async fn handle_deploy_request( } } -/// Whether `rev` is the config commit `agent`'s last successful build pinned -/// (`paths::applied_config_marker`), so a rebuild would deploy nothing new. A -/// request for a rev that is still building, or whose build failed, is not -/// deployed yet and queues again: the rebuild resolves its pins from -/// `internal/meta` either way, so a second one is redundant but harmless. -fn rev_already_deployed(agent: &str, rev: &str) -> bool { - marker_holds(&crate::paths::applied_config_marker(agent), rev) -} - -/// Whether the marker file at `marker` holds exactly `rev`; an absent or -/// unreadable marker holds nothing. -fn marker_holds(marker: &std::path::Path, rev: &str) -> bool { - std::fs::read_to_string(marker).is_ok_and(|deployed| deployed.trim() == rev) -} - /// Queue the first deploy of an agent this hive does not have yet: seed its /// power intent, then insert the DAG. /// @@ -429,22 +402,6 @@ async fn publish(client: &async_nats::Client, hive: &str) -> Result<()> { Ok(()) } -#[cfg(test)] -mod rev_marker_tests { - use super::marker_holds; - - #[test] - fn a_rev_is_deployed_only_when_the_marker_holds_it() { - let dir = tempfile::tempdir().expect("tempdir"); - let marker = dir.path().join(".alice.config-rev"); - - assert!(!marker_holds(&marker, "aaa"), "no marker, nothing deployed"); - std::fs::write(&marker, "aaa").expect("write marker"); - assert!(marker_holds(&marker, "aaa")); - assert!(!marker_holds(&marker, "bbb"), "another rev still deploys"); - } -} - #[cfg(test)] mod snapshot_tests { use super::snapshot; diff --git a/hive-c0re/src/workers/auto_update.rs b/hive-c0re/src/workers/auto_update.rs index 17fcd8a8..a0baa8a6 100644 --- a/hive-c0re/src/workers/auto_update.rs +++ b/hive-c0re/src/workers/auto_update.rs @@ -1,30 +1,42 @@ -//! Boot reconcile: on `hive-c0re serve` boot, (a) rebuild the agents whose -//! per-agent rev marker differs from the hyperhive commit the swarm's -//! `internal/meta` pins now — one `Boot` DAG holding a `Rebuild` subgraph for -//! each stale agent whose `wanted` power intent is `Up` — and (b) converge -//! every other drifted agent to its persisted `wanted` via `Reconcile` DAGs. -//! This is how a hive that missed the swarm's fan-out catches up. Three rules -//! keep boot-time nix work minimal: +//! Auto-update: rebuild the agents whose swarm-declared closure the hive has +//! not applied. An agent's `agent-closures` record names the store path the +//! swarm wants it to run; the agent's `applied/..closure` marker holds +//! the one the hive last swapped to ([`crate::attic::closure::pending`]). +//! +//! A pass runs on `hive-c0re serve` boot, then every [`PASS_INTERVAL`]. A boot +//! pass is how a hive that missed the swarm's deploy request catches up; the +//! periodic one covers a request lost while the hive was up. Each pass +//! rebuilds each stale agent whose `wanted` power intent is `Up` as one `Boot` +//! DAG, and the boot pass also converges every other drifted agent to its +//! persisted `wanted` via `Reconcile` DAGs. Rules that keep nix work minimal: //! //! 1. **Stale but wanted-offline agents** get no rebuild — their //! rebuild happens the first time they're started (the start //! submit path upgrades a stale start to rebuild+start). -//! 2. **Agents whose rev marker matches** the pin are skipped — nothing -//! changed, no nix work to do. -//! 3. **A pin the hive cannot read** (no forge token, forge down, no -//! `internal/meta` yet) rebuilds nothing: a rebuild would have no flake -//! to build from either. +//! 2. **Agents running the declared closure** are skipped. +//! 3. **A declaration the hive cannot read or act on** (no connection to the +//! swarm queue, no record, the closure not in the cache, no marker to +//! compare with) rebuilds nothing. +//! 4. **A declaration is attempted once per process** by the periodic pass, and +//! never while a rebuild of the agent is queued or running, so a closure +//! that fails to deploy does not restart its agent every interval. //! //! See `docs/scheduler/coordinator.md::Boot reconcile`. +use std::collections::HashMap; use std::path::Path; use std::sync::Arc; +use std::time::Duration; use anyhow::Result; use crate::coordinator::Coordinator; use crate::lifecycle::{self, AGENT_PREFIX}; +/// How often the swarm's declarations are compared with what the hive runs +/// after the boot pass. +const PASS_INTERVAL: Duration = Duration::from_mins(5); + /// Resolve the current rev of `hyperhive_flake`. For a path on disk we /// canonicalize (following symlinks) so a /etc/hyperhive → /nix/store/... /// update yields a different string. For anything else we return None. @@ -39,47 +51,39 @@ pub fn current_flake_rev(hyperhive_flake: &str) -> Option { .map(|p| p.display().to_string()) } -/// Returns true when the config commit `internal/meta` pins for the agent's -/// next build (`meta_rev::recorded`) differs from the one its last successful -/// build pinned (`deployed`, the `paths::applied_config_marker` content). This -/// is the semantic the dashboard `needs_update` chip conveys: "there is a -/// config change ready to apply via rebuild." An archetype agent pins no -/// config commit and never has one pending. -/// -/// Reads one small file and the marker the caller already holds, no `git`: -/// this runs per agent inside `container_view::build_all`, on the ~10s -/// dashboard sweep and every `AgentStatus` request. -#[must_use] -pub fn agent_config_pending(name: &str, deployed: Option<&str>) -> bool { - let pinned = crate::meta_rev::recorded(name).and_then(|build| build.config); - config_pending(pinned.as_deref(), deployed) +/// What one pass decided. +struct Plan { + /// Stale ∧ wanted up, running agents first. + rebuilds: Vec, + /// Agents whose observed power state drifted from `wanted`; empty after the + /// boot pass. + drifted: Vec, + deferred: usize, + skipped: usize, } -/// The comparison behind [`agent_config_pending`]: pending only when both -/// commits are known and differ. -fn config_pending(pinned: Option<&str>, deployed: Option<&str>) -> bool { - match (pinned, deployed) { - (Some(pinned), Some(deployed)) => pinned != deployed.trim(), - _ => false, - } +/// Whether an agent needs no rebuild this pass: nothing is pending, a rebuild +/// is already queued, or this process already attempted the declaration +/// (`attempted`) and the periodic pass does not retry it. +fn is_fresh(pending: Option<&str>, attempted: Option<&str>, rebuild_live: bool) -> bool { + pending.is_none() || rebuild_live || pending == attempted } -/// Boot reconcile (see the module doc): classify every agent by rev -/// freshness + persisted `wanted` intent, and submit one `Boot` DAG holding a -/// graceful rebuild subgraph per stale wanted-up agent and a `Reconcile` per -/// agent whose observed power state drifted from `wanted`. Returns Ok even -/// if some submissions failed. -pub async fn run(coord: Arc) -> Result<()> { +/// Classify every agent by what the swarm declares + persisted `wanted` +/// intent. `attempted` holds the declaration this process last rebuilt each +/// agent for; the agents it adds to `Plan::rebuilds` are recorded in it. +async fn classify( + coord: &Arc, + attempted: &mut HashMap, +) -> Option { let containers = match lifecycle::list().await { Ok(c) => c, Err(e) => { - tracing::warn!(error = ?e, "boot reconcile: nixos-container list failed"); - return Ok(()); + tracing::warn!(error = ?e, "auto-update: nixos-container list failed"); + return None; } }; - let pin = crate::meta_rev::current_hyperhive_pin().await; - // Resolve container names to logical agent names, then sort. The // parent field this used to depth-sort by is gone; with no // hierarchy left to respect, alphabetical is the whole order — and it @@ -91,15 +95,15 @@ pub async fn run(coord: Arc) -> Result<()> { .collect(); logical_names.sort(); - // Classify. `get_or_seed` doubles as the one-time migration: an + // `get_or_seed` doubles as the one-time migration: an // agent without an `agent_power` row is seeded from its observed // state (running ⇒ Up), after which the DB is authoritative. - // stale ∧ wanted=Up → sweep rebuild. The bool is the agent's observed + // stale ∧ wanted=Up → rebuild. The bool is the agent's observed // running state, used to order running agents first before submit. - let mut fanout: Vec<(String, bool)> = Vec::new(); + let mut rebuilds: Vec<(String, bool)> = Vec::new(); let mut drifted: Vec = Vec::new(); // fresh ∧ wanted≠observed → reconcile - let mut n_deferred = 0usize; - let mut n_skipped = 0usize; + let mut deferred = 0usize; + let mut skipped = 0usize; for name in &logical_names { let running = lifecycle::is_running(name).await; let wanted = match coord.power.get_or_seed(name, running) { @@ -109,19 +113,28 @@ pub async fn run(coord: Arc) -> Result<()> { None } }; - let stored = std::fs::read_to_string(crate::paths::applied_rev_marker(name)).ok(); - let fresh = crate::meta_rev::marker_is_fresh(pin.as_deref(), stored.as_deref()); + let pending = crate::attic::closure::pending_path(name).await; + let fresh = is_fresh( + pending.as_deref(), + attempted.get(name).map(String::as_str), + coord.job_queue.rebuild_live(name), + ); match boot_action(wanted, fresh, running) { - BootAction::Rebuild => fanout.push((name.clone(), running)), + BootAction::Rebuild => { + if let Some(path) = pending { + attempted.insert(name.clone(), path); + } + rebuilds.push((name.clone(), running)); + } BootAction::Defer { reconcile } => { - n_deferred += 1; - tracing::debug!(%name, "boot reconcile: stale but offline — deferring rebuild to on-start"); + deferred += 1; + tracing::debug!(%name, "auto-update: stale but offline — deferring rebuild to on-start"); if reconcile { drifted.push(name.clone()); } } BootAction::Skip { reconcile } => { - n_skipped += 1; + skipped += 1; if reconcile { drifted.push(name.clone()); } @@ -130,40 +143,79 @@ pub async fn run(coord: Arc) -> Result<()> { } } - tracing::info!( - total = containers.len(), - rebuilds = fanout.len(), - reconciles = drifted.len(), - deferred = n_deferred, - up_to_date = n_skipped, - "boot reconcile" - ); - - // Rebuild running agents first. All fanout entries are wanted=Up; - // among them, warm the live/serving agents onto the fresh config before the + // Rebuild running agents first. All entries are wanted=Up; + // among them, warm the live/serving agents onto the declared closure before the // stopped-but-wanted-up ones so the scarce build slots hit uptime-critical // agents first. Stable sort keeps the alphabetical order within each // running/stopped group. The `drifted` reconciles aren't sorted // — they hold no build slot and run concurrently, so their order is moot. - fanout.sort_by_key(|(_, running)| !running); - let fanout: Vec = fanout.into_iter().map(|(name, _)| name).collect(); + rebuilds.sort_by_key(|(_, running)| !running); + Some(Plan { + rebuilds: rebuilds.into_iter().map(|(name, _)| name).collect(), + drifted, + deferred, + skipped, + }) +} - submit_boot_tree(&coord, &fanout, drifted, n_deferred, n_skipped); +/// Auto-update (see the module doc): the boot pass, then a rebuild-only pass +/// every [`PASS_INTERVAL`] until shutdown. Returns Ok even if some submissions +/// failed. +pub async fn run(coord: Arc) -> Result<()> { + let mut shutdown = coord.shutdown_rx(); + let mut attempted = HashMap::new(); + if let Some(plan) = classify(&coord, &mut attempted).await { + tracing::info!( + total = plan.rebuilds.len() + plan.deferred + plan.skipped, + rebuilds = plan.rebuilds.len(), + reconciles = plan.drifted.len(), + deferred = plan.deferred, + up_to_date = plan.skipped, + "boot reconcile" + ); + submit_boot_tree( + &coord, + &plan.rebuilds, + plan.drifted, + plan.deferred, + plan.skipped, + ); + } submit_startup_sweep_nodes(&coord); - Ok(()) + loop { + tokio::select! { + () = tokio::time::sleep(PASS_INTERVAL) => {} + _ = shutdown.changed() => return Ok(()), + } + if let Some(plan) = classify(&coord, &mut attempted).await + && !plan.rebuilds.is_empty() + { + tracing::info!( + rebuilds = plan.rebuilds.len(), + "auto-update: the swarm declared new closures" + ); + submit_boot_tree( + &coord, + &plan.rebuilds, + Vec::new(), + plan.deferred, + plan.skipped, + ); + } + } } /// What the boot sweep does with one agent. #[derive(Debug, PartialEq, Eq)] enum BootAction { - /// Stale ∧ wanted up: rebuild from the pinned `internal/meta`. The DAG's + /// Stale ∧ wanted up: rebuild to the swarm-declared closure. The DAG's /// tail `Reconcile` brings the agent (back) up, covering both the /// running-stale and stopped-but-wanted-up cases. Rebuild, /// Stale ∧ wanted offline: no boot-time nix work — the start submit /// path upgrades a stale start to a rebuild. Defer { reconcile: bool }, - /// Already on the current rev. + /// Already running the declared closure, or nothing to deploy. Skip { reconcile: bool }, /// The intent could not be read. Unreadable, @@ -290,7 +342,7 @@ fn submit_boot_tree( #[cfg(test)] mod tests { - use super::{BootAction, boot_action, config_pending}; + use super::{BootAction, boot_action, is_fresh}; use crate::power::Wanted; // The regression the `Unreadable` variant exists for: the arm used to @@ -357,14 +409,46 @@ mod tests { ); } + /// A changed declaration is rebuilt with no operator step, and with no read + /// of the forge: the boot pass reaches `Rebuild` from the record and marker + /// alone. #[test] - fn config_is_pending_only_when_the_pins_differ() { - assert!(config_pending(Some("bbb"), Some("aaa"))); + fn a_changed_declaration_for_a_wanted_up_agent_is_rebuilt_unprompted() { + let fresh = is_fresh(Some("/nix/store/new"), None, false); + assert_eq!( + boot_action(Some(Wanted::Up), fresh, true), + BootAction::Rebuild + ); + let fresh = is_fresh(None, None, false); + assert_eq!( + boot_action(Some(Wanted::Up), fresh, true), + BootAction::Skip { reconcile: false }, + "control: nothing declared, nothing rebuilt" + ); + } + + #[test] + fn a_declaration_is_attempted_once_and_never_beside_a_live_rebuild() { + assert!(is_fresh(None, None, false), "nothing pending"); assert!( - !config_pending(Some("aaa"), Some("aaa\n")), - "marker newline" + is_fresh(None, Some("/nix/store/b"), false), + "nothing pending now, whatever was attempted" + ); + assert!( + !is_fresh(Some("/nix/store/b"), None, false), + "control: a new declaration" + ); + assert!( + is_fresh(Some("/nix/store/b"), None, true), + "a rebuild is queued" + ); + assert!( + is_fresh(Some("/nix/store/b"), Some("/nix/store/b"), false), + "already attempted" + ); + assert!( + !is_fresh(Some("/nix/store/c"), Some("/nix/store/b"), false), + "a newer declaration is attempted again" ); - assert!(!config_pending(None, Some("aaa")), "archetype agent"); - assert!(!config_pending(Some("aaa"), None), "never built"); } } diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index f96b4290..aa82dc77 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -153,7 +153,7 @@ pub enum HostRequest { /// credentials into the existing state tree. AgentExists { name: Ident }, /// List managed agents with their full status + technical state - /// (running / needs-login / needs-update / deployed sha / + /// (running / needs-login / /// pending reminders) — the `hivectl list-agents` roster view. /// Reuses the dashboard's per-agent `ContainerView` aggregation. AgentStatus, diff --git a/hive-meta-flake/src/lib.rs b/hive-meta-flake/src/lib.rs index ed40b9dd..e4391a32 100644 --- a/hive-meta-flake/src/lib.rs +++ b/hive-meta-flake/src/lib.rs @@ -2,8 +2,8 @@ //! from hyperhive's `agent-base` and the agent's config input or archetype. //! //! Pure: the output is a function of the arguments only. swarm-controller -//! renders through [`render_flake`]; hive-c0re reads the pins of the result -//! ([`pinned_rev`], [`builds_agent_at`]). +//! renders through [`render_flake`] and reads the pins of the result +//! ([`pinned_rev`]). use anyhow::{Context, Result}; use swarm_wire::agent_archetype::ArchetypeRef; @@ -511,27 +511,6 @@ pub fn pinned_rev<'a>(flake_nix: &'a str, input: &str) -> Option<&'a str> { is_rev(rev).then_some(rev) } -/// Whether a flake [`render_flake`] rendered builds agent `name` the way the -/// caller wants it built: from `archetype` when that is set, otherwise from -/// its own config repo at the commit `config_head`. A flake that lists the -/// agent under another archetype, or as custom where `archetype` is set (or -/// the reverse), does not; nor does a custom agent with no `config_head`. -#[must_use] -pub fn builds_agent_at( - flake_nix: &str, - name: &str, - archetype: Option<&ArchetypeRef>, - config_head: Option<&str>, -) -> bool { - let entry = format!("\n{}\n", agent_entry(name, agent_web_port(name), archetype)); - if !flake_nix.contains(&entry) { - return false; - } - archetype.is_some() - || config_head - .is_some_and(|head| pinned_rev(flake_nix, &format!("agent-{name}")) == Some(head)) -} - #[cfg(test)] mod tests { use super::*; @@ -627,54 +606,6 @@ mod tests { ); } - fn reviewer() -> ArchetypeRef { - ArchetypeRef { - source: "hyperhive".to_owned(), - name: "reviewer".to_owned(), - } - } - - #[test] - fn a_custom_agent_is_built_only_at_the_config_head_the_flake_pins() { - let flake = rendered(); - assert!(builds_agent_at(&flake, "argus", None, Some(ARGUS))); - assert!( - !builds_agent_at(&flake, "argus", None, Some(HYPERHIVE)), - "a stale pin" - ); - assert!( - !builds_agent_at(&flake, "argus", None, None), - "a custom agent with no config commit" - ); - assert!( - !builds_agent_at(&flake, "ghost", None, Some(ARGUS)), - "an agent the flake lacks" - ); - } - - #[test] - fn an_archetype_agent_is_built_only_from_the_archetype_the_flake_lists() { - let flake = rendered(); - assert!(builds_agent_at(&flake, "rev", Some(&reviewer()), None)); - let other = ArchetypeRef { - name: "planner".to_owned(), - ..reviewer() - }; - assert!( - !builds_agent_at(&flake, "rev", Some(&other), None), - "another archetype of the same source" - ); - assert!( - !builds_agent_at(&flake, "rev", None, Some(ARGUS)), - "listed as an archetype agent, wanted as a custom one" - ); - assert!( - !builds_agent_at(&flake, "argus", Some(&reviewer()), None), - "listed as a custom agent, wanted as an archetype one" - ); - assert!(!builds_agent_at(&flake, "ghost", Some(&reviewer()), None)); - } - /// The text of `mkAgent`'s `let` binding `name`: from `name =` to the /// next binding at the same indent (`;` ends it at line end). fn binding<'a>(flake: &'a str, name: &str) -> &'a str { @@ -724,12 +655,6 @@ mod tests { ); } - #[test] - fn an_agent_whose_name_ends_another_s_is_not_that_agent() { - let flake = rendered(); - assert!(!builds_agent_at(&flake, "gus", None, Some(ARGUS))); - assert!(!builds_agent_at(&flake, "v", Some(&reviewer()), None)); - } /// An agent that declares `hyperhive` follows meta's own pin, so the /// lock holds one hyperhive; one that declares nothing gets no line (the /// control that the line is the declared input's, not every agent's). diff --git a/hive-sh4re/src/container.rs b/hive-sh4re/src/container.rs index a6c5b7fb..300c640a 100644 --- a/hive-sh4re/src/container.rs +++ b/hive-sh4re/src/container.rs @@ -34,16 +34,9 @@ pub struct AgentStatusRow { /// just off. #[serde(default)] pub failed: bool, - /// Config commit is pending — the locked rev differs from the - /// agent's applied config (a rebuild would change it). - pub needs_update: bool, /// The agent has no live claude session and is parked waiting for /// the operator's re-auth flow. pub needs_login: bool, - /// First 12 chars of the sha the meta flake currently has locked for - /// this agent's input. `None` when the agent has no locked rev yet. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub deployed_sha: Option, /// Count of this agent's pending reminders. #[serde(default)] pub pending_reminders: u64, diff --git a/hivectl/src/agents.rs b/hivectl/src/agents.rs index 0d98eca5..26f2b6e5 100644 --- a/hivectl/src/agents.rs +++ b/hivectl/src/agents.rs @@ -167,19 +167,15 @@ pub(crate) async fn agents_list(socket: &Path, json: bool) -> Result<()> { if r.needs_login { s.push_str(" needs-login"); } - if r.needs_update { - s.push_str(" needs-update"); - } s }; - let headers = ["NAME", "STATUS", "REV", "REMIND"]; - let table: Vec<[String; 4]> = rows + let headers = ["NAME", "STATUS", "REMIND"]; + let table: Vec<[String; 3]> = rows .iter() .map(|r| { [ r.name.clone(), status_of(r), - r.deployed_sha.clone().unwrap_or_else(|| "-".to_owned()), if r.pending_reminders > 0 { r.pending_reminders.to_string() } else { diff --git a/swarm-controller/src/meta_flake.rs b/swarm-controller/src/meta_flake.rs index 0172a4e5..c46090a3 100644 --- a/swarm-controller/src/meta_flake.rs +++ b/swarm-controller/src/meta_flake.rs @@ -21,8 +21,7 @@ use swarm_wire::builder::{LOCK_REPLY_DEADLINE, LOCK_SUBJECT, LockMeta, LockReply use crate::forge::{CONFIG_ORG, DefaultBranchFiles}; -/// The branch an agent's config is deployed from, which hive-c0re's -/// `internal_meta` reads the config head from too. +/// The branch an agent's config is deployed from. const CONFIG_BRANCH: &str = "main"; /// The repo in `internal` the flake is committed to. diff --git a/swarm-queue-client/src/lib.rs b/swarm-queue-client/src/lib.rs index 718634d6..e6a3dd18 100644 --- a/swarm-queue-client/src/lib.rs +++ b/swarm-queue-client/src/lib.rs @@ -305,15 +305,15 @@ const DEPLOY_SUBJECT_PREFIX: &str = "$SWARM.deploy"; /// No hive: the subject already names it, and repeating it here would be two /// places stating one fact, free to disagree. /// -/// With [`Self::rev`] set it names the commit of the agent's config repo to -/// deploy. The config itself stays in git: the hive fetches that commit from -/// the forge, so a hive that misses a message is behind, never wrong. +/// What a hive deploys is the closure in the agent's `agent-closures` record, +/// not anything this message names, so a hive that misses a message is behind, +/// never wrong: it catches up from the record. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct DeployRequest { /// The agent to rebuild, as the swarm knows it. pub agent: String, - /// The commit on the agent's config repo `main` to deploy. `None` - /// rebuilds whatever the hive already has applied. + /// The commit on the agent's config repo `main` the swarm deployed. A hive + /// does not read it; it stays for controllers and hives of other versions. /// /// `serde(default)` so a payload without the field still decodes: the /// controller and the hives are deployed independently.