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
deploy subject. With no such hive, or more than one, it deploys
nothing and logs a warning naming them.
5. If the hive's `applied/main` already is that commit, it does nothing.
Otherwise it fetches the config repo's `main`. A commit that
`applied/main` already contains is a no-op too: requests for two merges
can arrive in either order. A commit that descends from `applied/main`
is fast-forwarded to and a rebuild queued. A failure before the rebuild
(the fetch, or a commit that has diverged from `applied/main`) deploys
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.
5. If the commit already is the one the agent's last successful build pinned
(the `applied/.<n>.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.
This path runs **no eval-verify**. A merged config that doesn't
evaluate or build fails the rebuild, and `applied/main` stays at that
commit, so the agent's rebuilds keep failing until a fix merges. A
failed rebuild shows on the hive like any other. A merge whose webhook delivery never reaches the controller
deploys nothing.
evaluate or build fails the rebuild, so the agent's rebuilds keep failing
until a fix merges. A failed rebuild shows on the hive like any other. A merge
whose webhook delivery never reaches the controller deploys nothing.
Creating a brand-new agent is swarm-level (`swarmctl agent create`,
`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.
Stops + removes the nspawn container and drops the systemd drop-in.
Persistent state (the applied repo, claude credentials, `/state/`
notes) is
Persistent state (claude credentials, `/state/` notes) is
**kept by default** — recreating the agent with the same name
reuses prior config + login. With `purge = true` the agent's
`/var/lib/hyperhive/{agents,applied}/<name>/` trees are also
wiped (config history + creds + notes gone forever). The
reuses prior notes + login. With `purge = true` the agent's
`/var/lib/hyperhive/agents/<name>/` tree is also
wiped (creds + notes gone forever). The
root/bootstrap container is destroyable like any other, and stays gone
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:
`hive-priv` writes them to the container's `/etc/hyperhive-hive-facts` at
start (see `docs/process/conventions.md`).
A config that sits only in the hive's `applied/<n>` mirror, not on forge
`main`, never deploys.
A config that sits only in a local checkout, not on forge `main`, never
deploys.
What `hive-c0re` still keeps under `/var/lib/hyperhive/meta/` is plain
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
read them.
## The applied repo per agent
## Deploy markers per agent
```
/var/lib/hyperhive/applied/<name>/ applied - core-only
├── .git/ # tag-rich history
├── flake.nix # tracked, fixed
│ # boilerplate exporting
│ # nixosModules.default
├── agent.nix # working tree of main
└── <other committed files> # also tracked
/var/lib/hyperhive/applied/ deploy markers - core-only
├── .<name>.hyperhive-rev # hyperhive commit of the last good build
└── .<name>.config-rev # config commit of the last good build
/var/lib/hyperhive/meta/ plain hive state - core
├── topology.json # the live agent set
└── swarm-resource-limits.json # the swarm's per-agent caps
```
hive-c0re seeds the applied repo at first spawn from the forge's
`agent-configs/<name>` `main`, or from a template when the forge has none yet,
and fast-forwards it to each merged config commit that deploys. An agent never
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 a *proposed* clone at `agents/<name>/config`. Nothing
reads it any more, and you can delete it.
hive-c0re keeps no copy of an agent's config: the forge's `agent-configs/<name>`
`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.
Older hives also kept an *applied* repo at `applied/<name>` and a *proposed*
clone at `agents/<name>/config`. The startup migration removes the applied
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 agent's own `applied/<n>/flake.nix`
The agent's `agent-configs/<n>/flake.nix`
is a fixed boilerplate that exports `nixosModules.default =
import ./agent.nix`; the meta flake imports that module and
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
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: the applied repo's tags (`deployed/0`)
exist only locally, and forge `main` advances only by an operator's merge.
hive-c0re reads from it - reseeding a purged `applied`, catching `applied` up, and
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.
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.
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
@ -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
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
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.
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)
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
once already applied:
- **Tags**: hive-c0re tags agents from before the tag-driven scheme
`deployed/0` on `main` once. Non-destructive — it doesn't touch live
containers, state dirs, or claude creds.
- **Agent files**: rewrites each `applied/<n>/flake.nix` to the
module-only boilerplate and refreshes the files that follow the agent set
- **Applied repos**: deletes the per-agent `applied/<n>` repos an earlier hive
kept. The deploy markers beside them stay. Non-destructive for everything
else — it doesn't touch live containers, state dirs, or claude creds.
- **Agent files**: refreshes the files that follow the agent set
(`agent_set::refresh`) from the current agent list. Set
`HIVE_SKIP_META_MIGRATION=1` on the service to defer this phase.
- **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.
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.
## 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
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
the hive like every other. The root agent's flake lives at
`/var/lib/hyperhive/applied/ruth/`. The root agent can clone
the hive like every other. The root agent can clone
`agent-configs/ruth` to read its own `agent.nix` and open a config PR there
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.
The dashboard surfaces pending config changes per agent: a clickable
"needs update ↻" badge appears whenever the agent's applied repo has a
commit that its last build didn't pin. The badge POSTs `/api/rebuild/<name>`, which inserts the
"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/<name>`, 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

View file

@ -543,12 +543,12 @@ whenever the forge has moved on.
Isolation still holds by default: a container has its *own* dirs and,
unless it holds the permission, nothing else.
Under `/var/lib/hyperhive/applied/<name>/` — the hive-c0re-only
applied repo. Tracks `flake.nix` (module-only boilerplate; never
edited after first spawn) + `agent.nix` (the actual config; edits land
here from merged config PRs) + any other files those PRs commit.
`.git/` carries the proposal / approved / building / deployed / failed /
denied tag history.
Under `/var/lib/hyperhive/applied/` — the hive-c0re-only deploy markers
`.<name>.hyperhive-rev` and `.<name>.config-rev`: the hyperhive and config
commits the agent's last successful build pinned. No config lives here; the
forge's `agent-configs/<name>` is the only copy. A hive that predates this
finds per-agent `applied/<name>` repos here; `migrate::run` deletes them at
startup.
Under `/var/lib/hyperhive/meta/` — system-level config files, plain state
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
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
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).
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
`swarmctl agent create` with the same name and hive, which reuses the
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.
`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
(`DeleteAgentSubvolume`) which `btrfs subvolume delete`s it iff it's
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
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
forge container:
- fetching a merged config commit for a swarm deploy, and
`hivectl forge reconcile-config`
- reseeding a purged `applied` repo
- seeding `applied` from `agent-configs/<agent>` on first deploy
- 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

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.
<!-- 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
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 +
start across the `StopForUpdate → Swap → RebuildBookkeeping`
brace and the tail `Reconcile` node. `flake.nix` itself lives in the agent's
the applied repo and rides along on every fetch (see
`docs/agent-lifecycle/config-changes.md::The applied repo per agent`).
`agent-configs/<name>` repo and rides along with its pinned commit (see
`docs/agent-lifecycle/config-changes.md::Deploy markers per agent`).
Anything that changes per-container state on the host should be
re-applied here so a manual `↻ R3BU1LD` from the dashboard is

View file

@ -55,7 +55,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 |
| `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 |
| `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 |
@ -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 |
| `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 |
| `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 |
| `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 |

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
archetype agent the hive also:
- seeds no `proposed` or `applied` repo;
- ignores the `rev` of a swarm deploy request;
- mounts an empty `/agents/<name>/config/`.
- ignores the `rev` of a swarm deploy request.
The agent's system prompt says it has no config repo and names its
archetype.

View file

@ -5,8 +5,6 @@ This document contains the help content for the `hivectl` command-line program.
**Command Overview:**
* [`hivectl`↴](#hivectl)
* [`hivectl forge`↴](#hivectl-forge)
* [`hivectl forge reconcile-config`↴](#hivectl-forge-reconcile-config)
* [`hivectl agent`↴](#hivectl-agent)
* [`hivectl agent restart`↴](#hivectl-agent-restart)
* [`hivectl agent pause`↴](#hivectl-agent-pause)
@ -49,7 +47,6 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that
###### **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
* `list-agents` — Show all managed agents with their status and technical state
* `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`
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
Reconciles an agent's config between this hive and the forge. `hivectl`
makes no forge accounts: a human's first SSO login to the forge makes
theirs, and `swarmctl forge make-admin <name>` makes it a site admin.
swarm-controller makes an agent's, and `swarmctl agent mint-forge-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.
`hivectl` has no forge verbs and makes no forge accounts: a human's first SSO
login to the forge makes theirs, and `swarmctl forge make-admin <name>` makes it
a site admin. swarm-controller makes an agent's, and `swarmctl agent
mint-forge-token <agent>` checks and, if needed, re-mints its token.
## 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.
**K3PT ST4T3** — agents with kept state and **no container** (size + age +
claude-creds badge). One action: `PURG3` (wipes state + applied
dirs; `POST /api/purge-tombstone/{name}`).
claude-creds badge). One action: `PURG3` (wipes the state
dir; `POST /api/purge-tombstone/{name}`).
⚠️ **Not only *destroyed* agents.** Nothing records a destroy: `Provision`
writes every definition-side artifact (state subvolume, applied
repo, meta registration, topology entry) *before* the container exists,
writes every definition-side artifact (state subvolume,
meta registration, topology entry) *before* the container exists,
and that artifact set survives `lifecycle::destroy`. An agent
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

View file

@ -5,127 +5,16 @@
use std::sync::Arc;
use anyhow::{Context as _, Result, bail};
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
/// persistent state: the applied config repo and the Claude
/// credentials dir under `/var/lib/hyperhive/{agents,applied}/<name>/` are
/// kept, so recreating an agent of the same name reuses prior config + creds
/// persistent state: the state dir and the Claude
/// credentials dir under `/var/lib/hyperhive/agents/<name>/` are
/// kept, so recreating an agent of the same name reuses prior notes + creds
/// (no re-login). The ephemeral runtime dir under `/run/hyperhive/agents/`
/// is cleared because its contents (the mcp socket) don't survive restarts
/// anyway. With `purge=true` the persistent trees are also wiped — config
/// history, claude creds, notes — there is no undo.
/// anyway. With `purge=true` the persistent trees are also wiped — claude
/// creds, notes — there is no undo.
/// The bootstrap/root container is destroyable like any other. Nothing
/// recreates it afterwards: ruth stays gone until she is created again from
/// the swarm dashboard.
@ -144,113 +33,3 @@ pub fn destroy(coord: &Arc<Coordinator>, name: &str, purge: bool) {
}
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 =
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())
.await;
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

View file

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

View file

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

View file

@ -6,13 +6,9 @@
mod internal_meta;
mod reader;
mod reconcile;
mod repos;
pub(crate) use internal_meta::internal_meta_source;
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::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);
/// Re-read the agent's archetype from the swarm.
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<()>;
/// Rewrite the host-side files that follow the agent set.
async fn refresh_agent_set(&self) -> Result<()>;
@ -216,13 +216,11 @@ impl Host for RealHost {
fn purge_dirs(&self, name: &str) -> Vec<PathBuf> {
// 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
// is left to clear.
// only ever created under a validated Ident), so there is nothing to clear.
hive_types::Ident::parse(name)
.ok()
.map(|id| crate::paths::agent_state_dir(&id))
.into_iter()
.chain([crate::paths::applied_dir(name)])
.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
// 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
// plain-dir state root plus the applied dir.
// plain-dir state root.
if let Err(e) = host.delete_state_subvolume(agent).await {
tracing::warn!(error = ?e, %agent, "purge: delete state subvolume failed");
}
@ -731,8 +729,8 @@ async fn run_rebuild_bookkeeping(
Ok(())
}
/// First-spawn pre-create provisioning: the applied repo, state
/// subvolume, and the agent-set refresh.
/// First-spawn pre-create provisioning: the state subvolume and the
/// agent-set refresh.
async fn run_provision(host: &impl Host, name: &str) -> Result<()> {
let agent_dir = crate::paths::agent_runtime_dir(name);
let paths = Coordinator::agent_paths(name, agent_dir)?;

View file

@ -51,7 +51,7 @@ pub enum NodeKind {
DestroyContainer { agent: String },
/// The `purge = true` half of a destroy: delete the agent's state
/// 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 },
/// The post-destroy bookkeeping tail.
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:
/// `Provision` (applied repo, state subvolume, agent-set registration)
/// `Provision` (state subvolume, agent-set registration)
/// then `Create` (`nixos-container create`), drop-in write, then `Reconcile`
/// starts the container (`wanted = Up`, seeded by
/// `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