Watch
0
0
Fork
You've already forked hyperhive
0

drop the applied repo: the hive keeps no copy of an agent's config, only the deploy markers. Refs #5913

This commit is contained in:
damocles 2026-11-01 17:19:41 +01:00
commit 862495530c
39 changed files with 190 additions and 1634 deletions

View file

@ -41,22 +41,17 @@ 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 and publishes a deploy request carrying that commit on that hive's
deploy subject. With no such hive, or more than one, it deploys deploy subject. With no such hive, or more than one, it deploys
nothing and logs a warning naming them. nothing and logs a warning naming them.
5. If the hive's `applied/main` already is that commit, it does nothing. 5. If the commit already is the one the agent's last successful build pinned
Otherwise it fetches the config repo's `main`. A commit that (the `applied/.<n>.config-rev` marker), the hive does nothing. Otherwise it
`applied/main` already contains is a no-op too: requests for two merges queues a rebuild, which resolves its pins from `internal/meta` like any
can arrive in either order. A commit that descends from `applied/main` rebuild. The request's commit only feeds that check: the hive keeps no copy
is fast-forwarded to and a rebuild queued. A failure before the rebuild of the config. An agent with no container on that hive yet ignores the
(the fetch, or a commit that has diverged from `applied/main`) deploys commit; its first deploy builds whatever `internal/meta` pins.
nothing and logs `swarm events: rev not
applied; nothing queued` at error, which fires the Grafana alert
`config rev refused`. An agent with no container on that hive yet ignores
the commit; its first deploy builds what it seeds.
This path runs **no eval-verify**. A merged config that doesn't This path runs **no eval-verify**. A merged config that doesn't
evaluate or build fails the rebuild, and `applied/main` stays at that evaluate or build fails the rebuild, so the agent's rebuilds keep failing
commit, so the agent's rebuilds keep failing until a fix merges. A until a fix merges. A failed rebuild shows on the hive like any other. A merge
failed rebuild shows on the hive like any other. A merge whose webhook delivery never reaches the controller whose webhook delivery never reaches the controller deploys nothing.
deploys nothing.
Creating a brand-new agent is swarm-level (`swarmctl agent create`, Creating a brand-new agent is swarm-level (`swarmctl agent create`,
`POST /api/agents` on the swarm controller), and no hive can originate `POST /api/agents` on the swarm controller), and no hive can originate
@ -100,12 +95,11 @@ body strings before calling it.
`HostRequest::Destroy { name, purge }` is the lifecycle tear-down. `HostRequest::Destroy { name, purge }` is the lifecycle tear-down.
Stops + removes the nspawn container and drops the systemd drop-in. Stops + removes the nspawn container and drops the systemd drop-in.
Persistent state (the applied repo, claude credentials, `/state/` Persistent state (claude credentials, `/state/` notes) is
notes) is
**kept by default** — recreating the agent with the same name **kept by default** — recreating the agent with the same name
reuses prior config + login. With `purge = true` the agent's reuses prior notes + login. With `purge = true` the agent's
`/var/lib/hyperhive/{agents,applied}/<name>/` trees are also `/var/lib/hyperhive/agents/<name>/` tree is also
wiped (config history + creds + notes gone forever). The wiped (creds + notes gone forever). The
root/bootstrap container is destroyable like any other, and stays gone root/bootstrap container is destroyable like any other, and stays gone
until you create ruth again from the swarm dashboard. until you create ruth again from the swarm dashboard.
@ -123,8 +117,8 @@ agent's forge config repo, the one config PRs merge into) and one
address) aren't in it: address) aren't in it:
`hive-priv` writes them to the container's `/etc/hyperhive-hive-facts` at `hive-priv` writes them to the container's `/etc/hyperhive-hive-facts` at
start (see `docs/process/conventions.md`). start (see `docs/process/conventions.md`).
A config that sits only in the hive's `applied/<n>` mirror, not on forge A config that sits only in a local checkout, not on forge `main`, never
`main`, never deploys. deploys.
What `hive-c0re` still keeps under `/var/lib/hyperhive/meta/` is plain What `hive-c0re` still keeps under `/var/lib/hyperhive/meta/` is plain
state, bound read-only into the root agent's container as `/meta`: state, bound read-only into the root agent's container as `/meta`:
@ -184,59 +178,41 @@ the swarm builder built the agent from the updated repo (the
reconcile ([below](#autoupdate-on-startup)) and the dashboard's update chip reconcile ([below](#autoupdate-on-startup)) and the dashboard's update chip
read them. read them.
## The applied repo per agent ## Deploy markers per agent
``` ```
/var/lib/hyperhive/applied/<name>/ applied - core-only /var/lib/hyperhive/applied/ deploy markers - core-only
├── .git/ # tag-rich history ├── .<name>.hyperhive-rev # hyperhive commit of the last good build
├── flake.nix # tracked, fixed └── .<name>.config-rev # config commit of the last good build
│ # boilerplate exporting
│ # nixosModules.default
├── agent.nix # working tree of main
└── <other committed files> # also tracked
/var/lib/hyperhive/meta/ plain hive state - core /var/lib/hyperhive/meta/ plain hive state - core
├── topology.json # the live agent set ├── topology.json # the live agent set
└── swarm-resource-limits.json # the swarm's per-agent caps └── swarm-resource-limits.json # the swarm's per-agent caps
``` ```
hive-c0re seeds the applied repo at first spawn from the forge's hive-c0re keeps no copy of an agent's config: the forge's `agent-configs/<name>`
`agent-configs/<name>` `main`, or from a template when the forge has none yet, `main` is the only one, and a config change is a PR against it. The two markers
and fast-forwards it to each merged config commit that deploys. An agent never record what the agent's last successful build pinned, and nothing else.
edits it: it isn't mounted into any container, and a config change is a PR
against the agent's forge repo. Older hives also kept an *applied* repo at `applied/<name>` and a *proposed*
Older hives also kept a *proposed* clone at `agents/<name>/config`. Nothing clone at `agents/<name>/config`. The startup migration removes the applied
reads it any more, and you can delete it. repos. Nothing reads the proposed clone any more, and you can delete it.
The container's `--flake` ref is `internal/meta` (see "Meta flake" above). The container's `--flake` ref is `internal/meta` (see "Meta flake" above).
The agent's own `applied/<n>/flake.nix` The agent's `agent-configs/<n>/flake.nix`
is a fixed boilerplate that exports `nixosModules.default = is a fixed boilerplate that exports `nixosModules.default =
import ./agent.nix`; the meta flake imports that module and import ./agent.nix`; the meta flake imports that module and
wraps it with identity + `HIVE_PORT` / `HIVE_LABEL`. wraps it with identity + `HIVE_PORT` / `HIVE_LABEL`.
### Tag state machine
hive-c0re plants `deployed/0` on the seed commit at first spawn. A
merged config commit that deploys plants no tag: the merged PR on the
forge and `internal/meta`'s commits record it. A config PR nobody merges
carries no extra state on the forge side: the PR stays open, and
the submitter pushes again (or closes it) to retry.
### Forge config repos ### Forge config repos
The bundled `hive-forge` container runs on the swarm's forge host The bundled `hive-forge` container runs on the swarm's forge host
(`deploy.forgejo.enable`, see [`../swarm/services.md`](../swarm/services.md)). (`deploy.forgejo.enable`, see [`../swarm/services.md`](../swarm/services.md)).
Every agent created through the swarm has a repo in the private Every agent created through the swarm has a repo in the private
`agent-configs` Forgejo org, created and seeded by swarm-controller. `agent-configs` Forgejo org, created and seeded by swarm-controller.
hive-c0re pushes nothing to it: the applied repo's tags (`deployed/0`) hive-c0re pushes nothing to it and keeps no clone: forge `main` advances only
exist only locally, and forge `main` advances only by an operator's merge. by an operator's merge. The hive reads the head of `main`, to resolve the
hive-c0re reads from it - reseeding a purged `applied`, catching `applied` up, and commit `internal/meta` pins.
and seeding `applied` at first spawn (`forge::fetch_config_main_into_applied`).
A legacy agent spawned by the hive alone has no swarm-seeded repo; if its
`agent-configs/<name>` has no `main`, a first spawn falls back to the local
template, and reseeding a purged `applied` fails with a `destroy --purge`
and re-spawn instruction.
Each agent is a **write collaborator on its own** `agent-configs/<name>` Each agent is a **write collaborator on its own** `agent-configs/<name>`
repo — so it can push a branch and open a config PR — but not a member repo — so it can push a branch and open a config PR — but not a member
@ -245,7 +221,7 @@ the forge. Branch protection keeps the agent off the `main` push
allowlist and allowlists merging to the `operators` team only, so an allowlist and allowlists merging to the `operators` team only, so an
agent can't fast-forward its own config or self-merge its PR (see agent can't fast-forward its own config or self-merge its PR (see
[Config changes](#config-changes) above). hive-c0re passes the credential to [Config changes](#config-changes) above). hive-c0re passes the credential to
each `git` call inline, never writing it into `applied/<n>/.git/config`, so each `git` call inline, never writing it into a repo's config, so
no stored token sits in a repo an agent might one day read. no stored token sits in a repo an agent might one day read.
The dashboard deep-links into this org — a `config repo` link The dashboard deep-links into this org — a `config repo` link
@ -291,15 +267,14 @@ it never touches a key that's already set.
## Startup migrations (older hosts) ## Startup migrations (older hosts)
hive-c0re runs a couple of idempotent migrations on every startup so a hive-c0re runs a couple of idempotent migrations on every startup so a
host set up before the tag-driven-deploy scheme (described above) host set up before the current deploy scheme (described above)
converges to it automatically. Each phase is a no-op converges to it automatically. Each phase is a no-op
once already applied: once already applied:
- **Tags**: hive-c0re tags agents from before the tag-driven scheme - **Applied repos**: deletes the per-agent `applied/<n>` repos an earlier hive
`deployed/0` on `main` once. Non-destructive — it doesn't touch live kept. The deploy markers beside them stay. Non-destructive for everything
containers, state dirs, or claude creds. else — it doesn't touch live containers, state dirs, or claude creds.
- **Agent files**: rewrites each `applied/<n>/flake.nix` to the - **Agent files**: refreshes the files that follow the agent set
module-only boilerplate and refreshes the files that follow the agent set
(`agent_set::refresh`) from the current agent list. Set (`agent_set::refresh`) from the current agent list. Set
`HIVE_SKIP_META_MIGRATION=1` on the service to defer this phase. `HIVE_SKIP_META_MIGRATION=1` on the service to defer this phase.
- **Legacy meta repo**: deletes the leftover `.git` in - **Legacy meta repo**: deletes the leftover `.git` in
@ -308,7 +283,7 @@ once already applied:
stay. The forge-side `core/meta` repo and `core` account aren't touched. stay. The forge-side `core/meta` repo and `core` account aren't touched.
No state loss in either migration: claude creds, `/state/` notes, the No state loss in either migration: claude creds, `/state/` notes, the
events DB, and the applied history all survive. The root events DB all survive. The root
agent keeps its session; sub-agents stay logged in. agent keeps its session; sub-agents stay logged in.
## The root/bootstrap container ## The root/bootstrap container
@ -316,8 +291,7 @@ agent keeps its session; sub-agents stay logged in.
The root agent container runs through the **same lifecycle as The root agent container runs through the **same lifecycle as
sub-agents**. Nothing creates it at hive boot: you create ruth from the sub-agents**. Nothing creates it at hive boot: you create ruth from the
swarm dashboard like any agent, and an existing ruth container starts with swarm dashboard like any agent, and an existing ruth container starts with
the hive like every other. The root agent's flake lives at the hive like every other. The root agent can clone
`/var/lib/hyperhive/applied/ruth/`. The root agent can clone
`agent-configs/ruth` to read its own `agent.nix` and open a config PR there `agent-configs/ruth` to read its own `agent.nix` and open a config PR there
for operator review, same as any other agent. for operator review, same as any other agent.
@ -414,8 +388,8 @@ 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. agent stays stopped until the update lands and you ask for the start again.
The dashboard surfaces pending config changes per agent: a clickable The dashboard surfaces pending config changes per agent: a clickable
"needs update ↻" badge appears whenever the agent's applied repo has a "needs update ↻" badge appears whenever the config commit that `internal/meta`
commit that its last build didn't pin. The badge POSTs `/api/rebuild/<name>`, which inserts the pins for the agent's next build differs from the one its last build pinned. The badge POSTs `/api/rebuild/<name>`, which inserts the
same `job_queue::templates::rebuild` DAG so manual triggers and 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 startup scan can't drift. When at least one container is stale, a
top-level `↻ UPD4TE 4LL` button appears that loops over every top-level `↻ UPD4TE 4LL` button appears that loops over every

View file

@ -543,12 +543,12 @@ whenever the forge has moved on.
Isolation still holds by default: a container has its *own* dirs and, Isolation still holds by default: a container has its *own* dirs and,
unless it holds the permission, nothing else. unless it holds the permission, nothing else.
Under `/var/lib/hyperhive/applied/<name>/` — the hive-c0re-only Under `/var/lib/hyperhive/applied/` — the hive-c0re-only deploy markers
applied repo. Tracks `flake.nix` (module-only boilerplate; never `.<name>.hyperhive-rev` and `.<name>.config-rev`: the hyperhive and config
edited after first spawn) + `agent.nix` (the actual config; edits land commits the agent's last successful build pinned. No config lives here; the
here from merged config PRs) + any other files those PRs commit. forge's `agent-configs/<name>` is the only copy. A hive that predates this
`.git/` carries the proposal / approved / building / deployed / failed / finds per-agent `applied/<name>` repos here; `migrate::run` deletes them at
denied tag history. startup.
Under `/var/lib/hyperhive/meta/` — system-level config files, plain state Under `/var/lib/hyperhive/meta/` — system-level config files, plain state
with no git history. The flake agents build from is the with no git history. The flake agents build from is the
@ -594,7 +594,7 @@ reads it at every container start for `manage_root_agent`.
`/var/lib/hyperhive/swarm-agent-archetypes.json` holds the archetype each of `/var/lib/hyperhive/swarm-agent-archetypes.json` holds the archetype each of
this hive's archetype agents runs, as last read from the swarm's this hive's archetype agents runs, as last read from the swarm's
`agent-archetype` bucket at the agent's provision and each rebuild. An agent `agent-archetype` bucket at the agent's provision and each rebuild. An agent
with an entry has no `applied/<n>` repo and no `agent-<n>` meta input; see with an entry has no config repo and no `agent-<n>` meta input; see
[archetypes](../swarm/archetypes.md). [archetypes](../swarm/archetypes.md).
hive-c0re creates containers from `internal/meta` (`nixosConfigurations.<n>`). hive-c0re creates containers from `internal/meta` (`nixosConfigurations.<n>`).
@ -607,7 +607,7 @@ an agent's state. The mechanics, for completeness:
- `DESTR0Y` also drops the systemd drop-in; reviving the agent is - `DESTR0Y` also drops the systemd drop-in; reviving the agent is
`swarmctl agent create` with the same name and hive, which reuses the `swarmctl agent create` with the same name and hive, which reuses the
kept state. kept state.
- `PURG3` wipes `/var/lib/hyperhive/{agents,applied}/<name>/` — the - `PURG3` wipes `/var/lib/hyperhive/agents/<name>/` — the
union of everything `DESTR0Y` left behind. union of everything `DESTR0Y` left behind.
`actions::destroy` has no guard for the root/bootstrap agent: it destroys ruth `actions::destroy` has no guard for the root/bootstrap agent: it destroys ruth
@ -636,7 +636,7 @@ until an explicit opt-in upgrade.
a subvolume root, so purge first calls hive-priv a subvolume root, so purge first calls hive-priv
(`DeleteAgentSubvolume`) which `btrfs subvolume delete`s it iff it's (`DeleteAgentSubvolume`) which `btrfs subvolume delete`s it iff it's
actually a subvolume, then the normal `remove_dir_all` sweep covers actually a subvolume, then the normal `remove_dir_all` sweep covers
plain-dir agents + the applied dir. plain-dir agents.
Per-subvolume disk-usage accounting and optional quotas have since Per-subvolume disk-usage accounting and optional quotas have since
landed as the qgroup work: `hivectl quota-enable` turns on btrfs landed as the qgroup work: `hivectl quota-enable` turns on btrfs

View file

@ -63,10 +63,7 @@ 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 none reads nothing. These reads go through it, and none of them needs a local
forge container: forge container:
- fetching a merged config commit for a swarm deploy, and - resolving the config commit `internal/meta` pins for a build
`hivectl forge reconcile-config`
- reseeding a purged `applied` repo
- seeding `applied` from `agent-configs/<agent>` on first deploy
- cloning and pulling `/knowledge` - cloning and pulling `/knowledge`
The hive builds nothing, so no nix process reads the forge as the hive's The hive builds nothing, so no nix process reads the forge as the hive's

View file

@ -206,7 +206,7 @@ means anything that has ever leaked into a task's captured output is
in the retained snapshots as well. in the retained snapshots as well.
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->
It does **not** cover the agent's applied config (`/applied/<name>/`) or It does **not** cover the agent's config (the forge's `agent-configs/<name>`) or
its topology entry, both of which live outside the subvolume. A restore its topology entry, both of which live outside the subvolume. A restore
therefore yields an agent's memory without its definition. therefore yields an agent's memory without its definition.

View file

@ -402,8 +402,8 @@ sets `EXTRA_NSPAWN_FLAGS` — plus the systemd resource-limits drop-in)
into the `Swap` node, then runs `nixos-container update` + stop + into the `Swap` node, then runs `nixos-container update` + stop +
start across the `StopForUpdate → Swap → RebuildBookkeeping` start across the `StopForUpdate → Swap → RebuildBookkeeping`
brace and the tail `Reconcile` node. `flake.nix` itself lives in the agent's brace and the tail `Reconcile` node. `flake.nix` itself lives in the agent's
the applied repo and rides along on every fetch (see `agent-configs/<name>` repo and rides along with its pinned commit (see
`docs/agent-lifecycle/config-changes.md::The applied repo per agent`). `docs/agent-lifecycle/config-changes.md::Deploy markers per agent`).
Anything that changes per-container state on the host should be Anything that changes per-container state on the host should be
re-applied here so a manual `↻ R3BU1LD` from the dashboard is re-applied here so a manual `↻ R3BU1LD` from the dashboard is

View file

@ -55,7 +55,7 @@ Cheap — no build slot:
| Node | Behavior | | 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 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 |
| `Provision` | first-spawn pre-create provisioning — the applied repo, state subvolume, agent-set refresh (`agent_set::refresh`); runs ahead of `Create`. Store-only, no container yet | | `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 | | `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 | | `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 |
| `Stop` | mechanical container stop — `nixos-container` kill, MCP listener unregister, the `Killed` manager notify. Fanned out by a `Reconcile` that observed `wanted = Offline` and up | | `Stop` | mechanical container stop — `nixos-container` kill, MCP listener unregister, the `Killed` manager notify. Fanned out by a `Reconcile` that observed `wanted = Offline` and up |
@ -67,7 +67,7 @@ Cheap — no build slot:
| `PauseSignal` | write the pause marker + mark `pause_pending`. No kick, unlike `Signal` — the harness's between-turns poll is already responsive enough, and `Signal`'s kick-message body ("you were just (re)started") would be actively misleading here | | `PauseSignal` | write the pause marker + mark `pause_pending`. No kick, unlike `Signal` — the harness's between-turns poll is already responsive enough, and `Signal`'s kick-message body ("you were just (re)started") would be actively misleading here |
| `PauseDrain` | await the harness reporting `PauseAcknowledged`, bounded timeout; best-effort like `Drain` | | `PauseDrain` | await the harness reporting `PauseAcknowledged`, bounded timeout; best-effort like `Drain` |
| `DestroyContainer` | `nixos-container destroy` + un-registration (drop from the roster, clear the ephemeral runtime dir). Runs downstream of a `Stop`, so deliberately excluded from `takes_container_down` — the container is already down by the time it claims | | `DestroyContainer` | `nixos-container destroy` + un-registration (drop from the roster, clear the ephemeral runtime dir). Runs downstream of a `Stop`, so deliberately excluded from `takes_container_down` — the container is already down by the time it claims |
| `PurgeState` | the `purge = true` half of a destroy: delete the agent's state subvolume (via hive-priv) plus its state/applied dirs. Own node because it's conditional and the irreversible step | | `PurgeState` | the `purge = true` half of a destroy: delete the agent's state subvolume (via hive-priv) plus its state dir. Own node because it's conditional and the irreversible step |
| `DestroyBookkeeping` | the post-destroy tail — meta sync, drop the power intent, notify the manager, rescan, re-emit the tombstone. Same split rationale as `RebuildBookkeeping`/`Swap`. The destructive work is `PurgeState`'s | | `DestroyBookkeeping` | the post-destroy tail — meta sync, drop the power intent, notify the manager, rescan, re-emit the tombstone. Same split rationale as `RebuildBookkeeping`/`Swap`. The destructive work is `PurgeState`'s |
| `SetWanted` | write the durable power intent (`wanted = Up`/`Offline`) as the head node of a power-op DAG. Takes the agent lease even though it's a store write, so the intent write and the tail `Reconcile` are atomic per-agent — two racing power ops can't clobber each other's intent before either reconciles | | `SetWanted` | write the durable power intent (`wanted = Up`/`Offline`) as the head node of a power-op DAG. Takes the agent lease even though it's a store write, so the intent write and the tail `Reconcile` are atomic per-agent — two racing power ops can't clobber each other's intent before either reconciles |
| `EmitRebuilt` | tail of a rebuild — emit the agent's `Rebuilt` manager event (ok/fail per outcome, nothing on cancel). One node per agent _and_ per outcome | | `EmitRebuilt` | tail of a rebuild — emit the agent's `Rebuilt` manager event (ok/fail per outcome, nothing on cancel). One node per agent _and_ per outcome |

View file

@ -130,9 +130,7 @@ provisions the agent and on every rebuild, and keeps the last value read in
it has now. When the read fails, a rebuild keeps the stored value. For an it has now. When the read fails, a rebuild keeps the stored value. For an
archetype agent the hive also: archetype agent the hive also:
- seeds no `proposed` or `applied` repo; - ignores the `rev` of a swarm deploy request.
- ignores the `rev` of a swarm deploy request;
- mounts an empty `/agents/<name>/config/`.
The agent's system prompt says it has no config repo and names its The agent's system prompt says it has no config repo and names its
archetype. archetype.

View file

@ -5,8 +5,6 @@ This document contains the help content for the `hivectl` command-line program.
**Command Overview:** **Command Overview:**
* [`hivectl`↴](#hivectl) * [`hivectl`↴](#hivectl)
* [`hivectl forge`↴](#hivectl-forge)
* [`hivectl forge reconcile-config`↴](#hivectl-forge-reconcile-config)
* [`hivectl agent`↴](#hivectl-agent) * [`hivectl agent`↴](#hivectl-agent)
* [`hivectl agent restart`↴](#hivectl-agent-restart) * [`hivectl agent restart`↴](#hivectl-agent-restart)
* [`hivectl agent pause`↴](#hivectl-agent-pause) * [`hivectl agent pause`↴](#hivectl-agent-pause)
@ -49,7 +47,6 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that
###### **Subcommands:** ###### **Subcommands:**
* `forge` — Reconcile an agent's config between this hive and the forge
* `agent` — Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running * `agent` — Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running
* `list-agents` — Show all managed agents with their status and technical state * `list-agents` — Show all managed agents with their status and technical state
* `quota-enable` — Enable btrfs qgroup accounting on the agent-state filesystem * `quota-enable` — Enable btrfs qgroup accounting on the agent-state filesystem
@ -69,46 +66,6 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that
## `hivectl forge`
Reconcile an agent's config between this hive and the forge.
A human's first SSO login to the forge makes their forge account.
**Usage:** `hivectl forge <COMMAND>`
###### **Subcommands:**
* `reconcile-config` — Show + reconcile the divergence between an agent's local applied config checkout and its forge `agent-configs/<agent>` main
## `hivectl forge reconcile-config`
Show + reconcile the divergence between an agent's local applied config checkout and its forge `agent-configs/<agent>` main.
Always prints the diff first. `--from forge` resets the local checkout to forge main (effective on the next deploy); `--from local` isn't supported yet. With no `--from`, prompts for the direction.
**Usage:** `hivectl forge reconcile-config [OPTIONS] <AGENT>`
###### **Arguments:**
* `<AGENT>` — Agent whose config branches to reconcile
###### **Options:**
* `--from <FROM>` — Which side to reconcile from. Omit it and hivectl prompts you after the diff
Possible values:
- `forge`:
Reset the local applied checkout to forge main
- `local`:
Advance forge main from local — not supported yet
* `--verbose` — Include the full diff (not just `--stat`) in the report
## `hivectl agent` ## `hivectl agent`
Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running. Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running.

View file

@ -23,24 +23,10 @@ markdown-docs > docs/tools/hivectl-cli.md`.
## Forge ## Forge
Reconciles an agent's config between this hive and the forge. `hivectl` `hivectl` has no forge verbs and makes no forge accounts: a human's first SSO
makes no forge accounts: a human's first SSO login to the forge makes login to the forge makes theirs, and `swarmctl forge make-admin <name>` makes it
theirs, and `swarmctl forge make-admin <name>` makes it a site admin. a site admin. swarm-controller makes an agent's, and `swarmctl agent
swarm-controller makes an agent's, and `swarmctl agent mint-forge-token mint-forge-token <agent>` checks and, if needed, re-mints its token.
<agent>` checks and, if needed, re-mints its token.
```bash
hivectl forge reconcile-config iris # show local-applied <-> forge config divergence, then prompt
hivectl forge reconcile-config iris --from forge # reset local applied checkout to forge main (effective next deploy)
hivectl forge reconcile-config iris --verbose # include the full diff, not just --stat
```
- `reconcile-config <agent>` shows the divergence between the agent's local
applied config checkout and its forge `agent-configs/<agent>` `main`, then
reconciles. `--from forge` resets the local checkout to forge `main` (takes
effect on the next deploy — it doesn't autorebuild). `--from local` isn't
supported yet (forge `main` is branch-protected; resolve via a config
PR). With no `--from` it prompts for the direction after the diff.
## Agents ## Agents

View file

@ -81,12 +81,12 @@ same figures go out as OTEL metrics and appear in Grafana (see
caps live per agent in the swarm UI. caps live per agent in the swarm UI.
**K3PT ST4T3** — agents with kept state and **no container** (size + age + **K3PT ST4T3** — agents with kept state and **no container** (size + age +
claude-creds badge). One action: `PURG3` (wipes state + applied claude-creds badge). One action: `PURG3` (wipes the state
dirs; `POST /api/purge-tombstone/{name}`). dir; `POST /api/purge-tombstone/{name}`).
⚠️ **Not only *destroyed* agents.** Nothing records a destroy: `Provision` ⚠️ **Not only *destroyed* agents.** Nothing records a destroy: `Provision`
writes every definition-side artifact (state subvolume, applied writes every definition-side artifact (state subvolume,
repo, meta registration, topology entry) *before* the container exists, meta registration, topology entry) *before* the container exists,
and that artifact set survives `lifecycle::destroy`. An agent and that artifact set survives `lifecycle::destroy`. An agent
part-way through a spawn is byte-identical on disk to a tombstone, and part-way through a spawn is byte-identical on disk to a tombstone, and
the pane lists both. The pane carries a standing warning to that effect, and the pane lists both. The pane carries a standing warning to that effect, and

View file

@ -5,127 +5,16 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::{Context as _, Result, bail};
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
use crate::lifecycle;
/// `applied/main`, and whether `target` descends from it. Fast-forwarding
/// `main` to a `target` that does not is a rewind: it drops every commit on
/// `main` that `target` lacks.
async fn applied_main_under(applied_dir: &std::path::Path, target: &str) -> Result<(String, bool)> {
let current_main = lifecycle::git_rev_parse(applied_dir, "refs/heads/main")
.await
.map_err(|e| anyhow::anyhow!("read applied/main: {e:#}"))?;
let descends = lifecycle::git_is_ancestor(applied_dir, &current_main, target)
.await
.map_err(|e| anyhow::anyhow!("ancestry check {current_main}..{target}: {e:#}"))?;
Ok((current_main, descends))
}
/// What [`advance_applied_to_rev`] did to `applied/main`.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum RevAdvance {
/// `applied/main` already was the rev, so there is nothing to deploy.
AlreadyApplied,
/// `applied/main` was fast-forwarded to the rev.
Advanced,
/// The rev is an ancestor of `applied/main`, so a later commit already
/// carries it and there is nothing to deploy. Deploy requests are not
/// ordered: two merges' requests can arrive in either order, and a
/// forge fetch fast-forwards `main` to the forge's on its own.
Superseded,
}
/// Fast-forward `agent`'s `applied/main` to `rev`, a commit the swarm asks
/// this hive to deploy, so the dashboard's update chip compares against it.
///
/// No eval-verify on this path: a config that does not evaluate fails the
/// rebuild instead, with `applied/main` already at `rev`.
///
/// # Errors
///
/// Returns an error if the forge fetch fails, if `rev` has diverged from
/// `applied/main` (neither descendant nor ancestor), or if `main` moved during the fast-forward. `main` does not
/// move in any of these.
pub(crate) async fn advance_applied_to_rev(agent: &str, rev: &str) -> Result<RevAdvance> {
advance_applied_main(
&crate::paths::applied_dir(agent),
rev,
crate::forge::fetch_forge_main(agent),
)
.await
}
/// [`advance_applied_to_rev`] with the forge fetch passed in, so it is
/// testable against a local repo.
///
/// `applied/main` is compared to `rev` twice: before the fetch, so an applied
/// rev costs no forge round-trip, and after it, because a forge fetch
/// fast-forwards `main` to the forge's and may do so meanwhile.
async fn advance_applied_main<T>(
applied_dir: &std::path::Path,
rev: &str,
fetch: impl std::future::Future<Output = Result<T>>,
) -> Result<RevAdvance> {
let main = lifecycle::git_rev_parse(applied_dir, "refs/heads/main")
.await
.map_err(|e| anyhow::anyhow!("read applied/main: {e:#}"))?;
if main == rev {
return Ok(RevAdvance::AlreadyApplied);
}
fetch.await.context("fetch the forge config main")?;
let (main, descends) = applied_main_under(applied_dir, rev).await?;
if main == rev {
return Ok(RevAdvance::AlreadyApplied);
}
if !descends {
let contained = lifecycle::git_is_ancestor(applied_dir, rev, &main)
.await
.map_err(|e| anyhow::anyhow!("ancestry check {rev}..{main}: {e:#}"))?;
if contained {
return Ok(RevAdvance::Superseded);
}
bail!(
"{rev} does not descend from applied/main ({main}); fast-forwarding to it would \
discard commits"
);
}
ff_applied_main(applied_dir, rev, &main).await?;
Ok(RevAdvance::Advanced)
}
/// Fast-forward `applied/main` to `target` and sync the working tree.
///
/// Compare-and-swap, not a bare set: `main` must still be `expected_main`, the
/// sha the caller's ancestry gate ([`applied_main_under`]) ran against. A plain
/// `update-ref` moves the branch to `target` whatever it currently points at,
/// which turns "fast-forward" into "discard anything that landed in the
/// meantime" — the gate only proves `target` safe against the `main` observed
/// *then*, so this is what makes that proof still true *now*.
async fn ff_applied_main(
applied_dir: &std::path::Path,
target: &str,
expected_main: &str,
) -> Result<()> {
lifecycle::git_update_ref_cas(applied_dir, "refs/heads/main", target, expected_main)
.await
.map_err(|e| {
anyhow::anyhow!("ff main {expected_main} -> {target} (concurrent move?): {e:#}")
})?;
lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main")
.await
.map_err(|e| anyhow::anyhow!("read-tree to main: {e:#}"))
}
/// Tear down a sub-agent container. By default this is non-destructive to /// Tear down a sub-agent container. By default this is non-destructive to
/// persistent state: the applied config repo and the Claude /// persistent state: the state dir and the Claude
/// credentials dir under `/var/lib/hyperhive/{agents,applied}/<name>/` are /// credentials dir under `/var/lib/hyperhive/agents/<name>/` are
/// kept, so recreating an agent of the same name reuses prior config + creds /// kept, so recreating an agent of the same name reuses prior notes + creds
/// (no re-login). The ephemeral runtime dir under `/run/hyperhive/agents/` /// (no re-login). The ephemeral runtime dir under `/run/hyperhive/agents/`
/// is cleared because its contents (the mcp socket) don't survive restarts /// is cleared because its contents (the mcp socket) don't survive restarts
/// anyway. With `purge=true` the persistent trees are also wiped — config /// anyway. With `purge=true` the persistent trees are also wiped — claude
/// history, claude creds, notes — there is no undo. /// creds, notes — there is no undo.
/// The bootstrap/root container is destroyable like any other. Nothing /// The bootstrap/root container is destroyable like any other. Nothing
/// recreates it afterwards: ruth stays gone until she is created again from /// recreates it afterwards: ruth stays gone until she is created again from
/// the swarm dashboard. /// the swarm dashboard.
@ -144,113 +33,3 @@ pub fn destroy(coord: &Arc<Coordinator>, name: &str, purge: bool) {
} }
coord.emit_rebuild_queue_snapshot(); coord.emit_rebuild_queue_snapshot();
} }
#[cfg(test)]
mod tests {
use super::{RevAdvance, advance_applied_main};
use crate::lifecycle;
/// A repo with `main` at `b`, child of `a`, and `c`, a sibling of `b` off
/// `a`. Returns the dir and the shas `[a, b, c]`.
async fn repo() -> (tempfile::TempDir, [String; 3]) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path();
let commit = |msg: &'static str| async move {
lifecycle::git(
path,
&[
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-q",
"--allow-empty",
"-m",
msg,
],
)
.await
.expect("commit");
lifecycle::git_rev_parse(path, "HEAD").await.expect("HEAD")
};
lifecycle::git(path, &["init", "-q", "--initial-branch=main"])
.await
.expect("init");
let a = commit("a").await;
let b = commit("b").await;
lifecycle::git(path, &["checkout", "-q", "-b", "side", &a])
.await
.expect("branch");
let c = commit("c").await;
lifecycle::git(path, &["checkout", "-q", "main"])
.await
.expect("checkout main");
(dir, [a, b, c])
}
async fn main_of(dir: &tempfile::TempDir) -> String {
lifecycle::git_rev_parse(dir.path(), "refs/heads/main")
.await
.expect("main")
}
#[tokio::test]
async fn the_applied_rev_is_a_no_op_without_a_fetch() {
let (dir, [_, b, _]) = repo().await;
let fetched = std::cell::Cell::new(false);
let fetch = async {
fetched.set(true);
anyhow::Ok(())
};
let outcome = advance_applied_main(dir.path(), &b, fetch).await;
assert_eq!(outcome.expect("no-op"), RevAdvance::AlreadyApplied);
assert!(!fetched.get(), "an applied rev must not reach the forge");
assert_eq!(main_of(&dir).await, b);
}
#[tokio::test]
async fn a_rev_not_descending_from_main_is_refused_and_main_stays() {
let (dir, [_, b, c]) = repo().await;
let outcome = advance_applied_main(dir.path(), &c, async { anyhow::Ok(()) }).await;
let err = outcome.expect_err("a sibling of main is not a fast-forward");
assert!(
format!("{err:#}").contains("does not descend from applied/main"),
"{err:#}"
);
assert_eq!(main_of(&dir).await, b);
}
#[tokio::test]
async fn an_ancestor_of_main_is_superseded_not_refused() {
let (dir, [a, b, _]) = repo().await;
let outcome = advance_applied_main(dir.path(), &a, async { anyhow::Ok(()) }).await;
assert_eq!(outcome.expect("no-op"), RevAdvance::Superseded);
assert_eq!(main_of(&dir).await, b);
}
#[tokio::test]
async fn a_descendant_rev_fast_forwards_main() {
let (dir, [a, b, _]) = repo().await;
lifecycle::git_update_ref(dir.path(), "refs/heads/main", &a)
.await
.expect("rewind main");
let outcome = advance_applied_main(dir.path(), &b, async { anyhow::Ok(()) }).await;
assert_eq!(outcome.expect("fast-forward"), RevAdvance::Advanced);
assert_eq!(main_of(&dir).await, b);
}
#[tokio::test]
async fn a_failed_fetch_leaves_main_alone() {
let (dir, [a, b, _]) = repo().await;
lifecycle::git_update_ref(dir.path(), "refs/heads/main", &a)
.await
.expect("rewind main");
let outcome = advance_applied_main(dir.path(), &b, async {
Err::<(), _>(anyhow::anyhow!("forge down"))
})
.await;
assert!(outcome.is_err());
assert_eq!(main_of(&dir).await, a);
}
}

View file

@ -156,8 +156,7 @@ pub async fn build_all() -> anyhow::Result<Vec<ContainerView>> {
let deployed_full = let deployed_full =
std::fs::read_to_string(crate::paths::applied_config_marker(logical.as_str())).ok(); std::fs::read_to_string(crate::paths::applied_config_marker(logical.as_str())).ok();
let needs_update = let needs_update =
crate::auto_update::agent_config_pending(logical.as_str(), deployed_full.as_deref()) crate::auto_update::agent_config_pending(logical.as_str(), deployed_full.as_deref());
.await;
let deployed_sha = deployed_full.as_deref().map(short_rev); let deployed_sha = deployed_full.as_deref().map(short_rev);
// One `systemctl` call for both facts: `unit_state` is the same // One `systemctl` call for both facts: `unit_state` is the same
// shell-out `is_running` makes, minus `--quiet`. Asking twice would // shell-out `is_running` makes, minus `--quiet`. Asking twice would

View file

@ -286,8 +286,6 @@ mod serve_config_tests {
pub struct AgentPaths { pub struct AgentPaths {
/// Runtime socket dir (tmpfs, recreated per boot). /// Runtime socket dir (tmpfs, recreated per boot).
pub agent: PathBuf, pub agent: PathBuf,
/// Hive-c0re-authoritative applied config repo.
pub applied: PathBuf,
/// Claude OAuth credentials (survives purge boundary). /// Claude OAuth credentials (survives purge boundary).
pub claude: PathBuf, pub claude: PathBuf,
/// Agent durable notes + forge token (survives purge boundary). /// Agent durable notes + forge token (survives purge boundary).
@ -436,7 +434,6 @@ impl Coordinator {
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
Ok(AgentPaths { Ok(AgentPaths {
agent: agent_dir, agent: agent_dir,
applied: crate::paths::applied_dir(name.as_str()),
claude: Self::agent_claude_dir(&name), claude: Self::agent_claude_dir(&name),
notes: Self::agent_notes_dir(&name), notes: Self::agent_notes_dir(&name),
}) })

View file

@ -175,15 +175,11 @@ pub(super) async fn post_purge_tombstone(
return error_response(&reason); return error_response(&reason);
} }
let mut errors = Vec::new(); let mut errors = Vec::new();
for dir in [ let dir = crate::paths::agent_state_dir(&name);
crate::paths::agent_state_dir(&name), if dir.exists()
crate::paths::applied_dir(name.as_str()), && let Err(e) = std::fs::remove_dir_all(&dir)
] { {
if dir.exists() errors.push(format!("{}: {e}", dir.display()));
&& let Err(e) = std::fs::remove_dir_all(&dir)
{
errors.push(format!("{}: {e}", dir.display()));
}
} }
if errors.is_empty() { if errors.is_empty() {
tracing::info!(%name, "tombstone purged"); tracing::info!(%name, "tombstone purged");

View file

@ -6,13 +6,9 @@
mod internal_meta; mod internal_meta;
mod reader; mod reader;
mod reconcile;
mod repos;
pub(crate) use internal_meta::internal_meta_source; pub(crate) use internal_meta::internal_meta_source;
pub use reader::reader_token; pub use reader::reader_token;
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
pub use repos::fetch_config_main_into_applied;
use std::future::IntoFuture; use std::future::IntoFuture;
use std::sync::OnceLock; use std::sync::OnceLock;

View file

@ -1,158 +0,0 @@
//! Reconcile an agent's local applied config checkout against its forge
//! `agent-configs/<agent>` `main`. Backs `hivectl forge reconcile-config`
//! via the host-socket `ReconcileConfig{Status,Apply}` requests. hive-c0re
//! owns the applied checkout + the forge credential, so the reconcile op
//! lives here rather than in the CLI.
//!
//! `Status` is read-only (fetches forge `main` into a scratch ref, reports
//! the divergence). `Apply(Forge)` resets the local applied checkout to
//! forge `main`; `internal/meta` pins forge `main` itself, not this tree. It
//! does NOT auto-deploy. `Apply(Local)` is not supported: forge `main` is
//! branch-protected, so it only advances through a config-PR merge.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use hive_host_sock::{HostResponse, ReconcileDirection};
use super::{CONFIG_ORG, forge_git_url, reader_token};
/// Scratch ref the forge `main` is fetched into — outside the normal
/// branch/tag namespace so it never collides with real refs.
pub(crate) const FORGE_MAIN_REF: &str = "refs/hyperhive/forge-config-main";
/// Fetch `agent-configs/<agent>` `main` into the applied repo's scratch
/// ref (read-only, no working-tree change) and return the applied dir.
/// Also how a swarm deploy of a merged commit gets that commit's objects.
pub async fn fetch_forge_main(agent: &str) -> Result<PathBuf> {
let Some(reader) = reader_token().await else {
anyhow::bail!(
"no forge read token: the swarm store holds none for this hive and there is no core \
token file"
);
};
let dir = crate::paths::applied_dir(agent);
if !dir.join(".git").exists() {
anyhow::bail!("agent `{agent}` has no applied config checkout");
}
let url = forge_git_url(&format!("{CONFIG_ORG}/{agent}"));
crate::lifecycle::git_authed(
&dir,
&["fetch", "--force", &url, &format!("main:{FORGE_MAIN_REF}")],
&reader.auth_header(),
)
.await
.context("fetch forge config main (does forge main exist yet?)")?;
Ok(dir)
}
/// Run a git command in `dir` and capture stdout, erroring on non-zero.
async fn git_out(dir: &Path, args: &[&str]) -> Result<String> {
let out = crate::lifecycle::git_command()
.current_dir(dir)
.args(args)
.output()
.await
.with_context(|| format!("invoke git {args:?}"))?;
if !out.status.success() {
anyhow::bail!(
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// Append `lines` (from a git text block) to `messages`, each indented.
fn push_indented(messages: &mut Vec<String>, block: &str) {
messages.extend(block.lines().map(|l| format!(" {l}")));
}
/// Report the divergence between the local applied checkout and forge
/// `main` (host request `ReconcileConfigStatus`). Read-only.
pub async fn reconcile_config_status(agent: &str, verbose: bool) -> Result<HostResponse> {
let dir = fetch_forge_main(agent).await?;
let counts = git_out(
&dir,
&[
"rev-list",
"--left-right",
"--count",
&format!("HEAD...{FORGE_MAIN_REF}"),
],
)
.await?;
let mut parts = counts.split_whitespace();
let local_ahead = parts.next().unwrap_or("0");
let forge_ahead = parts.next().unwrap_or("0");
let mut messages = vec![
format!("agent {agent}: local applied main <-> forge agent-configs/{agent} main"),
format!(" local ahead by {local_ahead}, forge ahead by {forge_ahead} commit(s)"),
];
if local_ahead == "0" && forge_ahead == "0" {
messages.push(" in sync — nothing to reconcile".to_owned());
return Ok(HostResponse::messages(messages));
}
if forge_ahead != "0" {
messages.push(" forge-only commits (applied by --from forge):".to_owned());
push_indented(
&mut messages,
&git_out(
&dir,
&["log", "--oneline", &format!("HEAD..{FORGE_MAIN_REF}")],
)
.await?,
);
}
if local_ahead != "0" {
messages.push(" local-only commits (discarded by --from forge):".to_owned());
push_indented(
&mut messages,
&git_out(
&dir,
&["log", "--oneline", &format!("{FORGE_MAIN_REF}..HEAD")],
)
.await?,
);
}
messages.push(" diff --stat (local HEAD -> forge main):".to_owned());
push_indented(
&mut messages,
&git_out(&dir, &["diff", "--stat", "HEAD", FORGE_MAIN_REF]).await?,
);
if verbose {
messages.push(" full diff:".to_owned());
push_indented(
&mut messages,
&git_out(&dir, &["diff", "HEAD", FORGE_MAIN_REF]).await?,
);
}
Ok(HostResponse::messages(messages))
}
/// Apply a reconcile in `direction` (host request `ReconcileConfigApply`).
/// `Forge` resets the local applied checkout to forge `main`; `Local` is
/// unsupported and returns a clear error.
pub async fn reconcile_config_apply(
agent: &str,
direction: ReconcileDirection,
) -> Result<HostResponse> {
match direction {
ReconcileDirection::Local => Ok(HostResponse::error(
"reconciling forge from local is not supported yet: forge main is \
branch-protected. Resolve via a config PR, or use \
`--from forge` to reset the local checkout to forge main.",
)),
ReconcileDirection::Forge => {
let dir = fetch_forge_main(agent).await?;
crate::lifecycle::git(&dir, &["reset", "--hard", FORGE_MAIN_REF])
.await
.context("reset applied main to forge main")?;
Ok(HostResponse::messages(vec![
format!("reconciled applied/{agent} to forge agent-configs/{agent} main"),
format!("effective on next deploy — rebuild {agent} to apply now"),
]))
}
}
}

View file

@ -1,122 +0,0 @@
//! Repo plumbing on the local Forgejo: seeding an agent's `applied` repo from
//! `agent-configs/<name>`. The typed API-client constructor + org-name
//! constants live in the module root (`super`).
use std::path::Path;
use super::{CONFIG_ORG, forge_git_url, reader_token};
/// Seed an agent's `applied` repo, missing its `.git` entirely (first spawn,
/// or a revival after `destroy --purge`), by fetching `agent-configs/<name>`'s
/// `main` from the forge. Forge `main` advances with every operator-merged
/// config PR, so this reflects the last merged config. A config deployed
/// through hive-c0re without a forge merge never reaches it.
///
/// Best-effort: returns `false` (not an error) rather than bailing when there
/// is no [`reader_token`], `applied` already has a `.git` (nothing to do), the
/// forge has no `main` for the agent yet (the normal case right after
/// `swarm-controller` creates it, before it has pushed anything), or any git
/// step fails. Callers fall back to the template or the original hard error. A
/// failed seed leaves the directory empty, so a later "is it seeded" check reads
/// the same as before the attempt. Only `true` once `applied` is seeded and
/// tagged `deployed/0`.
pub async fn fetch_config_main_into_applied(name: &str) -> bool {
let Some(reader) = reader_token().await else {
return false;
};
let applied = crate::paths::applied_dir(name);
if applied.join(".git").exists() {
return false;
}
let seeded = async {
if let Err(e) = std::fs::create_dir_all(&applied) {
tracing::warn!(%name, error = ?e, "forge: applied reseed mkdir failed");
return false;
}
if let Err(e) = crate::lifecycle::git(&applied, &["init", "--initial-branch=main"]).await {
tracing::warn!(%name, error = ?e, "forge: applied reseed init failed");
return false;
}
let url = forge_git_url(&format!("{CONFIG_ORG}/{name}"));
let auth = reader.auth_header();
let out = crate::lifecycle::git_command_authed(&auth)
.current_dir(&applied)
.args([
"fetch",
"--no-tags",
"--update-head-ok",
&url,
"refs/heads/main:refs/heads/main",
])
.output()
.await;
match out {
Ok(o) if o.status.success() => {}
Ok(o) => {
// `info`, not `warn`: the normal case right after
// `swarm-controller` creates the agent, before it has pushed
// anything into the repo it just made, and nothing is wrong.
tracing::info!(
%name,
stderr = %String::from_utf8_lossy(&o.stderr).trim(),
"forge: no config to fetch for this agent yet"
);
return false;
}
Err(e) => {
tracing::warn!(%name, error = ?e, "forge: applied reseed fetch failed");
return false;
}
}
if let Err(e) = crate::lifecycle::git_read_tree_reset(&applied, "refs/heads/main").await {
tracing::warn!(%name, error = ?e, "forge: applied reseed read-tree failed");
return false;
}
if let Err(e) = crate::lifecycle::git_tag(&applied, "deployed/0", "refs/heads/main").await {
tracing::warn!(%name, error = ?e, "forge: applied reseed tag failed");
return false;
}
tracing::info!(%name, "forge: seeded applied repo from agent-configs main");
true
}
.await;
if !seeded {
clear_dir(&applied);
}
seeded
}
/// Empty `dir`, keeping the directory itself: a failed seed may have written
/// the working tree as well as the `.git`, and the template fallback must start
/// from nothing rather than adopt files that were meant for the forge's commit.
fn clear_dir(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let _ = if path.is_dir() {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
};
}
}
#[cfg(test)]
mod tests {
use super::clear_dir;
#[test]
fn clear_dir_empties_a_dir_but_keeps_it() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(dir.path().join(".git")).expect("mkdir .git");
std::fs::write(dir.path().join(".git/HEAD"), "ref").expect("write HEAD");
std::fs::write(dir.path().join("agent.nix"), "{}").expect("write agent.nix");
clear_dir(dir.path());
assert!(dir.path().is_dir(), "the directory itself stays");
assert_eq!(std::fs::read_dir(dir.path()).expect("read_dir").count(), 0);
}
}

View file

@ -68,7 +68,7 @@ pub(super) trait Host: Sync {
async fn notify(&self, key: String, summary: String); async fn notify(&self, key: String, summary: String);
/// Re-read the agent's archetype from the swarm. /// Re-read the agent's archetype from the swarm.
async fn refresh_archetype(&self, name: &str) -> Result<()>; async fn refresh_archetype(&self, name: &str) -> Result<()>;
/// Rebuild preamble: port check, applied repo and state dirs. /// Rebuild preamble: port check and state dirs.
async fn prepare_rebuild_dirs(&self, name: &str, paths: &AgentPaths) -> Result<()>; async fn prepare_rebuild_dirs(&self, name: &str, paths: &AgentPaths) -> Result<()>;
/// Rewrite the host-side files that follow the agent set. /// Rewrite the host-side files that follow the agent set.
async fn refresh_agent_set(&self) -> Result<()>; async fn refresh_agent_set(&self) -> Result<()>;
@ -216,13 +216,11 @@ impl Host for RealHost {
fn purge_dirs(&self, name: &str) -> Vec<PathBuf> { fn purge_dirs(&self, name: &str) -> Vec<PathBuf> {
// A malformed name can't have a persistent state tree (the state dir is // A malformed name can't have a persistent state tree (the state dir is
// only ever created under a validated Ident), so only the applied dir // only ever created under a validated Ident), so there is nothing to clear.
// is left to clear.
hive_types::Ident::parse(name) hive_types::Ident::parse(name)
.ok() .ok()
.map(|id| crate::paths::agent_state_dir(&id)) .map(|id| crate::paths::agent_state_dir(&id))
.into_iter() .into_iter()
.chain([crate::paths::applied_dir(name)])
.collect() .collect()
} }
@ -426,7 +424,7 @@ async fn run_purge_state(host: &impl Host, agent: &str) {
// The state root may be a btrfs subvolume: a subvolume root can't be // The state root may be a btrfs subvolume: a subvolume root can't be
// removed with rmdir/`remove_dir_all`, so delete it via hive-priv (root) // removed with rmdir/`remove_dir_all`, so delete it via hive-priv (root)
// first. No-op for plain-dir agents — the loop below then handles the // first. No-op for plain-dir agents — the loop below then handles the
// plain-dir state root plus the applied dir. // plain-dir state root.
if let Err(e) = host.delete_state_subvolume(agent).await { if let Err(e) = host.delete_state_subvolume(agent).await {
tracing::warn!(error = ?e, %agent, "purge: delete state subvolume failed"); tracing::warn!(error = ?e, %agent, "purge: delete state subvolume failed");
} }
@ -731,8 +729,8 @@ async fn run_rebuild_bookkeeping(
Ok(()) Ok(())
} }
/// First-spawn pre-create provisioning: the applied repo, state /// First-spawn pre-create provisioning: the state subvolume and the
/// subvolume, and the agent-set refresh. /// agent-set refresh.
async fn run_provision(host: &impl Host, name: &str) -> Result<()> { async fn run_provision(host: &impl Host, name: &str) -> Result<()> {
let agent_dir = crate::paths::agent_runtime_dir(name); let agent_dir = crate::paths::agent_runtime_dir(name);
let paths = Coordinator::agent_paths(name, agent_dir)?; let paths = Coordinator::agent_paths(name, agent_dir)?;

View file

@ -51,7 +51,7 @@ pub enum NodeKind {
DestroyContainer { agent: String }, DestroyContainer { agent: String },
/// The `purge = true` half of a destroy: delete the agent's state /// The `purge = true` half of a destroy: delete the agent's state
/// subvolume (via hive-priv, since a subvolume root defeats /// subvolume (via hive-priv, since a subvolume root defeats
/// `remove_dir_all`) plus its state and applied dirs. /// `remove_dir_all`) plus its state dir.
PurgeState { agent: String }, PurgeState { agent: String },
/// The post-destroy bookkeeping tail. /// The post-destroy bookkeeping tail.
DestroyBookkeeping { agent: String }, DestroyBookkeeping { agent: String },

View file

@ -282,7 +282,7 @@ pub fn rebuild(builder: &JobBuilder, agent: &str) -> Vec<hive_jobq::NodeGuid> {
} }
/// First deploy of an agent this hive has never seen, asked for by the swarm: /// First deploy of an agent this hive has never seen, asked for by the swarm:
/// `Provision` (applied repo, state subvolume, agent-set registration) /// `Provision` (state subvolume, agent-set registration)
/// then `Create` (`nixos-container create`), drop-in write, then `Reconcile` /// then `Create` (`nixos-container create`), drop-in write, then `Reconcile`
/// starts the container (`wanted = Up`, seeded by /// starts the container (`wanted = Up`, seeded by
/// `swarm_status::queue_first_deploy`). All-or-nothing: `Provision` /// `swarm_status::queue_first_deploy`). All-or-nothing: `Provision`

Some files were not shown because too many files have changed in this diff Show more