diff --git a/docs/agent-lifecycle/config-changes.md b/docs/agent-lifecycle/config-changes.md index ae64cda0..b31a6dae 100644 --- a/docs/agent-lifecycle/config-changes.md +++ b/docs/agent-lifecycle/config-changes.md @@ -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/..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}//` 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//` 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/` 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// applied - core-only -├── .git/ # tag-rich history -├── flake.nix # tracked, fixed -│ # boilerplate exporting -│ # nixosModules.default -├── agent.nix # working tree of main -└── # also tracked +/var/lib/hyperhive/applied/ deploy markers - core-only +├── ..hyperhive-rev # hyperhive commit of the last good build +└── ..config-rev # config commit of the last good build /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/` `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//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/` +`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/` and a *proposed* +clone at `agents//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//flake.nix` +The agent's `agent-configs//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/` 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/` 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//.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//flake.nix` to the - module-only boilerplate and refreshes the files that follow the agent set +- **Applied repos**: deletes the per-agent `applied/` 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/`, 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/`, 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 diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index e215c40d..08686810 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -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//` — 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 +`..hyperhive-rev` and `..config-rev`: the hyperhive and config +commits the agent's last successful build pinned. No config lives here; the +forge's `agent-configs/` is the only copy. A hive that predates this +finds per-agent `applied/` 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/` repo and no `agent-` meta input; see +with an entry has no config repo and no `agent-` meta input; see [archetypes](../swarm/archetypes.md). hive-c0re creates containers from `internal/meta` (`nixosConfigurations.`). @@ -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}//` — the +- `PURG3` wipes `/var/lib/hyperhive/agents//` — 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 diff --git a/docs/integrations/forge.md b/docs/integrations/forge.md index d17518f4..3d92d29c 100644 --- a/docs/integrations/forge.md +++ b/docs/integrations/forge.md @@ -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/` 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 diff --git a/docs/networking/snapshot-store.md b/docs/networking/snapshot-store.md index 5bbd4a8f..d52fcd3e 100644 --- a/docs/networking/snapshot-store.md +++ b/docs/networking/snapshot-store.md @@ -206,7 +206,7 @@ means anything that has ever leaked into a task's captured output is in the retained snapshots as well. -It does **not** cover the agent's applied config (`/applied//`) or +It does **not** cover the agent's config (the forge's `agent-configs/`) or its topology entry, both of which live outside the subvolume. A restore therefore yields an agent's memory without its definition. diff --git a/docs/process/conventions.md b/docs/process/conventions.md index d1081233..a9021eb8 100644 --- a/docs/process/conventions.md +++ b/docs/process/conventions.md @@ -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/` 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 diff --git a/docs/scheduler/coordinator.md b/docs/scheduler/coordinator.md index a210697a..8ab87edf 100644 --- a/docs/scheduler/coordinator.md +++ b/docs/scheduler/coordinator.md @@ -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 | diff --git a/docs/swarm/archetypes.md b/docs/swarm/archetypes.md index 72f6501c..6ae72a29 100644 --- a/docs/swarm/archetypes.md +++ b/docs/swarm/archetypes.md @@ -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//config/`. +- ignores the `rev` of a swarm deploy request. The agent's system prompt says it has no config repo and names its archetype. diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index 1a35f7ad..167eba48 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -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 ` - -###### **Subcommands:** - -* `reconcile-config` — Show + reconcile the divergence between an agent's local applied config checkout and its forge `agent-configs/` main - - - -## `hivectl forge reconcile-config` - -Show + reconcile the divergence between an agent's local applied config checkout and its forge `agent-configs/` 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] ` - -###### **Arguments:** - -* `` — Agent whose config branches to reconcile - -###### **Options:** - -* `--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. diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 5bc426a2..8d0ac643 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -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 ` makes it a site admin. -swarm-controller makes an agent's, and `swarmctl agent mint-forge-token -` 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 ` shows the divergence between the agent's local - applied config checkout and its forge `agent-configs/` `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 ` makes it +a site admin. swarm-controller makes an agent's, and `swarmctl agent +mint-forge-token ` checks and, if needed, re-mints its token. ## Agents diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 4329234c..93ee943f 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -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 diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 8675c133..51b946a6 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -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, ¤t_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 { - 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( - applied_dir: &std::path::Path, - rev: &str, - fetch: impl std::future::Future>, -) -> Result { - 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}//` 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//` 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, 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); - } -} diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index eb3703bd..c28a5e5f 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -156,8 +156,7 @@ pub async fn build_all() -> anyhow::Result> { 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 diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 51f7c38e..8b4a81be 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -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), }) diff --git a/hive-c0re/src/dashboard/tombstones.rs b/hive-c0re/src/dashboard/tombstones.rs index f6c031ca..a56f11b9 100644 --- a/hive-c0re/src/dashboard/tombstones.rs +++ b/hive-c0re/src/dashboard/tombstones.rs @@ -175,15 +175,11 @@ 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()), - ] { - if dir.exists() - && let Err(e) = std::fs::remove_dir_all(&dir) - { - errors.push(format!("{}: {e}", dir.display())); - } + 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"); diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index fbf73341..23fe4f00 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -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; diff --git a/hive-c0re/src/forge/reconcile.rs b/hive-c0re/src/forge/reconcile.rs deleted file mode 100644 index 35844f08..00000000 --- a/hive-c0re/src/forge/reconcile.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Reconcile an agent's local applied config checkout against its forge -//! `agent-configs/` `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/` `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 { - 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 { - 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, 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 { - 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 { - 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"), - ])) - } - } -} diff --git a/hive-c0re/src/forge/repos.rs b/hive-c0re/src/forge/repos.rs deleted file mode 100644 index 0985410e..00000000 --- a/hive-c0re/src/forge/repos.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Repo plumbing on the local Forgejo: seeding an agent's `applied` repo from -//! `agent-configs/`. 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/`'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); - } -} diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index 7ea2bf29..f1beb1fb 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -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 { // 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)?; diff --git a/hive-c0re/src/job_queue/model.rs b/hive-c0re/src/job_queue/model.rs index 6c3e67e5..29dd68bd 100644 --- a/hive-c0re/src/job_queue/model.rs +++ b/hive-c0re/src/job_queue/model.rs @@ -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 }, diff --git a/hive-c0re/src/job_queue/templates.rs b/hive-c0re/src/job_queue/templates.rs index 5f5518e5..0ed34cac 100644 --- a/hive-c0re/src/job_queue/templates.rs +++ b/hive-c0re/src/job_queue/templates.rs @@ -282,7 +282,7 @@ pub fn rebuild(builder: &JobBuilder, agent: &str) -> Vec { } /// 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` diff --git a/hive-c0re/src/lifecycle/git.rs b/hive-c0re/src/lifecycle/git.rs index 62d14b65..41a08882 100644 --- a/hive-c0re/src/lifecycle/git.rs +++ b/hive-c0re/src/lifecycle/git.rs @@ -1,92 +1,8 @@ -//! Git shellout helpers for the per-agent applied repo: run -//! `git` with the hive-c0re identity, resolve/plant refs and tags, and -//! fetch proposal commits into the applied repo. +//! Spawning `git` from hive-c0re: the binary lookup and the credential +//! handoff. -use std::path::Path; -use std::process::{Output, Stdio}; -use std::time::Duration; - -use anyhow::{Context, Result, bail}; -use tokio::io::{AsyncRead, AsyncReadExt}; use tokio::process::Command; -/// Deadline for a child that talks to a remote (forge fetch/push): a healthy -/// one takes seconds, so ten minutes only ever fires on a hang. -pub const FETCH_DEADLINE: Duration = Duration::from_mins(10); - -/// Deadline for a git child that touches only the local repo. -pub const GIT_LOCAL_DEADLINE: Duration = Duration::from_mins(2); - -/// Run `cmd` to completion and capture its output, failing once `deadline` -/// passes. `what` names the command in the error. The child leads its own -/// process group and the whole group is `SIGKILL`ed on timeout, so a -/// grandchild (a `git` or credential helper it spawned) dies with it instead -/// of outliving the lock. -pub async fn run_with_deadline( - cmd: &mut Command, - deadline: Duration, - what: &str, -) -> Result { - // `kill_on_drop` covers the caller dropping this future; `process_group(0)` - // makes the child's pid the group id the timeout path kills. - let mut child = cmd - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true) - .process_group(0) - .spawn() - .with_context(|| what.to_owned())?; - // Read before waiting: a reaped child has no id. - let pgid = child.id().and_then(|pid| i32::try_from(pid).ok()); - let (stdout, stderr) = (child.stdout.take(), child.stderr.take()); - let io = async { tokio::join!(child.wait(), drain(stdout), drain(stderr)) }; - if let Ok((status, stdout, stderr)) = tokio::time::timeout(deadline, io).await { - return Ok(Output { - status: status.with_context(|| what.to_owned())?, - stdout: stdout.with_context(|| what.to_owned())?, - stderr: stderr.with_context(|| what.to_owned())?, - }); - } - if let Some(pgid) = pgid { - // SAFETY: plain `kill(2)`; `-pgid` targets the child's own process - // group, which `process_group(0)` made it lead. - unsafe { - libc::kill(-pgid, libc::SIGKILL); - } - } - child.wait().await.with_context(|| what.to_owned())?; - bail!("{what} timed out after {}s", deadline.as_secs_f64()) -} - -/// Everything `pipe` yields until EOF. -async fn drain(pipe: Option) -> std::io::Result> { - let mut buf = Vec::new(); - if let Some(mut p) = pipe { - p.read_to_end(&mut buf).await?; - } - Ok(buf) -} - -const GIT_NAME: &str = "c0re"; -const GIT_EMAIL: &str = "c0re@hyperhive.local"; - -pub(super) async fn git_commit(dir: &Path, message: &str) -> Result<()> { - git( - dir, - &[ - "-c", - &format!("user.name={GIT_NAME}"), - "-c", - &format!("user.email={GIT_EMAIL}"), - "commit", - "-m", - message, - ], - ) - .await -} - /// Spawn `git` honoring the `HYPERHIVE_GIT` env var (absolute path baked in /// by the NixOS module), falling back to bare `git` (PATH lookup) otherwise. /// @@ -121,125 +37,3 @@ pub fn git_command_authed(auth_header: &str) -> Command { .env("GIT_CONFIG_VALUE_0", auth_header); cmd } - -/// Run `cmd` as `git ` in `dir` within `deadline`, erroring on a -/// non-zero exit. -async fn run(mut cmd: Command, dir: &Path, args: &[&str], deadline: Duration) -> Result<()> { - let what = format!("git {} in {}", args.join(" "), dir.display()); - let out = run_with_deadline(cmd.current_dir(dir).args(args), deadline, &what).await?; - if !out.status.success() { - bail!( - "git {} failed ({}): {}", - args.join(" "), - out.status, - String::from_utf8_lossy(&out.stderr).trim() - ); - } - Ok(()) -} - -pub async fn git(dir: &Path, args: &[&str]) -> Result<()> { - run(git_command(), dir, args, GIT_LOCAL_DEADLINE).await -} - -/// [`git`] against an authenticated remote — for the paths that talk to the -/// forge. The credential rides the environment, so `args` (and therefore the -/// error above) stay free of it. -pub async fn git_authed(dir: &Path, args: &[&str], auth_header: &str) -> Result<()> { - run(git_command_authed(auth_header), dir, args, FETCH_DEADLINE).await -} - -/// Resolve `refname` (a tag, branch, or sha) in `dir` to its full sha. -pub async fn git_rev_parse(dir: &Path, refname: &str) -> Result { - let out = run_with_deadline( - git_command().current_dir(dir).args(["rev-parse", refname]), - GIT_LOCAL_DEADLINE, - &format!("git rev-parse {refname} in {}", dir.display()), - ) - .await?; - if !out.status.success() { - bail!( - "git rev-parse {refname} failed ({}): {}", - out.status, - String::from_utf8_lossy(&out.stderr).trim() - ); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned()) -} - -/// Plant a lightweight tag at `target`. Errors if the tag already -/// exists — we want loud failures on id reuse, not silent -/// overwrites. -pub async fn git_tag(dir: &Path, name: &str, target: &str) -> Result<()> { - git(dir, &["tag", name, target]).await -} - -/// Replace working tree + index with the tree at `target` without -/// moving HEAD. `applied/main` stays pointing at the last known-good -/// `deployed/*` while we let `nixos-container update` evaluate the -/// candidate. On build failure callers reset back to HEAD; on -/// success they fast-forward main to `target`. -pub async fn git_read_tree_reset(dir: &Path, target: &str) -> Result<()> { - git(dir, &["read-tree", "--reset", "-u", target]).await -} - -/// Hard-set a ref to `target` with `update-ref`, so it works regardless of -/// where HEAD currently sits. Test fixtures only: production moves refs with -/// [`git_update_ref_cas`]. -#[cfg(test)] -pub async fn git_update_ref(dir: &Path, refname: &str, target: &str) -> Result<()> { - git(dir, &["update-ref", refname, target]).await -} - -/// Compare-and-swap a ref: move `refname` to `new` only if it currently points -/// at `old`. `git update-ref ` refuses — leaving the ref -/// untouched — when the current value differs. That refusal is the whole -/// difference between "advance this branch" and "overwrite whatever is there". -/// -/// Prefer this over a plain `update-ref` whenever the caller already knows the -/// value it believes the ref holds: a plain `update-ref` that raced another -/// writer discards the other writer's commits without a word. -/// -/// # Errors -/// -/// Returns an error if the ref does not currently point at `old` (the CAS lost) -/// or if the `git` invocation itself fails. -pub async fn git_update_ref_cas(dir: &Path, refname: &str, new: &str, old: &str) -> Result<()> { - git(dir, &["update-ref", refname, new, old]).await -} - -/// True when `ancestor` is reachable from `descendant` — i.e. moving a branch -/// from `ancestor` to `descendant` is a genuine fast-forward that discards -/// nothing. -/// -/// Not the same question as "did this branch land upstream": a squash-merge -/// rewrites the commit, so `--is-ancestor` correctly answers `false` for a -/// branch whose *contents* were merged. This helper compares two commits with -/// real shared ancestry inside one repo, which is precisely what it decides. -/// -/// # Errors -/// -/// Returns an error if `git` fails to run or either revision can't be resolved. -/// A clean "no, not an ancestor" is `Ok(false)`, not an error. -pub async fn git_is_ancestor(dir: &Path, ancestor: &str, descendant: &str) -> Result { - let out = git_command() - .current_dir(dir) - .args(["merge-base", "--is-ancestor", ancestor, descendant]) - .output() - .await - .with_context(|| { - format!( - "git merge-base --is-ancestor {ancestor} {descendant} in {}", - dir.display() - ) - })?; - match out.status.code() { - Some(0) => Ok(true), - Some(1) => Ok(false), - _ => bail!( - "git merge-base --is-ancestor {ancestor} {descendant} failed ({}): {}", - out.status, - String::from_utf8_lossy(&out.stderr).trim() - ), - } -} diff --git a/hive-c0re/src/lifecycle/mod.rs b/hive-c0re/src/lifecycle/mod.rs index f9c4231a..b4ac7a4f 100644 --- a/hive-c0re/src/lifecycle/mod.rs +++ b/hive-c0re/src/lifecycle/mod.rs @@ -7,19 +7,9 @@ mod setup; #[cfg(test)] mod tests; -#[cfg(test)] -pub use git::git_update_ref; -#[cfg(test)] -pub use git::run_with_deadline; -pub use git::{ - git, git_authed, git_command, git_command_authed, git_is_ancestor, git_read_tree_reset, - git_rev_parse, git_tag, git_update_ref_cas, -}; +pub use git::{git_command, git_command_authed}; pub use host_config::write_dropins; -pub use setup::{ - ensure_agent_state_subvolume, ensure_claude_dir, ensure_state_dir, initial_flake_nix, - setup_applied, -}; +pub use setup::{ensure_agent_state_subvolume, ensure_claude_dir, ensure_state_dir}; use anyhow::{Context, Result, bail}; use tokio::process::Command; @@ -205,8 +195,8 @@ async fn port_collision(self_name: &str) -> Result> { } /// First-spawn pre-create provisioning — the job queue's `Provision` -/// node. Fail fast on a port collision, set up the applied -/// repo + state subvolume + claude/notes dirs, then register the new +/// node. Fail fast on a port collision, set up the state +/// subvolume + claude/notes dirs, then register the new /// agent in the host-side files that follow the agent set /// ([`crate::agent_set::refresh`]). Does NOT /// create the container — that's `create_only` / the `Create` node. @@ -218,12 +208,9 @@ pub async fn provision_container(name: &str, paths: &AgentPaths) -> Result<()> { agent_web_port(name) ); } - // Read before anything is seeded: an archetype agent gets no config - // repo, and the build takes it from the archetype. + // Read before the build: an archetype agent gets no config repo, and + // the build takes it from the archetype. crate::archetypes::refresh(name).await?; - if crate::archetypes::of(name)?.is_none() { - setup_applied(&paths.applied, name).await?; - } ensure_agent_state_subvolume(name).await?; ensure_claude_dir(&paths.claude)?; ensure_state_dir(&paths.notes)?; @@ -239,19 +226,7 @@ pub async fn create_only(name: &str, toplevel: &str) -> Result<()> { } /// Rebuild-path preamble, run by the job queue's `Prebuild` node: fail fast on -/// a port collision, then make sure the applied repo + state dirs exist. -/// Container untouched. -/// -/// `applied` is normally already the right shape here (this runs against a -/// previously-provisioned agent), but a `destroy --purge` removes it -/// (`job_queue::exec::run_purge_state`) — a later revive of the *same* -/// agent name is still a rebuild, not a first-spawn, so it lands here, not -/// in `provision_container`. Recover from `agent-configs/` on the -/// forge when that's the case (forge `main` is the last operator-merged -/// config — see `forge::fetch_config_main_into_applied`'s own doc comment) -/// rather than bailing outright. Deliberately **no template fallback**: a -/// template would silently revive the *wrong* config rather than recovering -/// the right one, which is worse than the clear bail this replaces. +/// a port collision, then make sure the state dirs exist. Container untouched. pub async fn prepare_rebuild_dirs(name: &str, paths: &AgentPaths) -> Result<()> { validate(name)?; if let Some(other) = port_collision(name).await? { @@ -260,20 +235,6 @@ pub async fn prepare_rebuild_dirs(name: &str, paths: &AgentPaths) -> Result<()> agent_web_port(name) ); } - // An archetype agent has no applied repo to check or recover. - if crate::archetypes::of(name)?.is_none() { - if !paths.applied.join(".git").exists() - && !crate::forge::fetch_config_main_into_applied(name).await - { - bail!( - "applied repo at {} is missing its .git directory and could not be reseeded from \ - the forge (agent-configs/{name} unreachable, has no `main` — never merged or \ - never seeded — or absent); destroy --purge and re-spawn this agent.", - paths.applied.display() - ); - } - setup_applied(&paths.applied, name).await?; - } ensure_agent_state_subvolume(name).await?; ensure_claude_dir(&paths.claude)?; ensure_state_dir(&paths.notes)?; diff --git a/hive-c0re/src/lifecycle/setup.rs b/hive-c0re/src/lifecycle/setup.rs index 9243c4f2..df6dddd0 100644 --- a/hive-c0re/src/lifecycle/setup.rs +++ b/hive-c0re/src/lifecycle/setup.rs @@ -1,90 +1,9 @@ -//! First-spawn provisioning: seed the hive-c0re-owned applied repo, and -//! ensure the per-agent state / claude-credentials dirs (btrfs subvolume when -//! available) exist. -//! -//! Seeding is the only write the repo gets from here. Later config changes -//! arrive as forge PRs authored in a clone - nothing edits a working tree in -//! place. +//! First-spawn provisioning: ensure the per-agent state / claude-credentials +//! dirs (btrfs subvolume when available) exist. use std::path::Path; -use anyhow::{Context, Result, bail}; - -use super::git::{git, git_commit, git_rev_parse, git_tag}; - -/// Seed an agent's applied repo on first spawn, preferring the config the -/// forge already has. `agent-configs/` is authored by whoever created -/// the agent (at swarm level the controller, which writes the config before -/// any hive is told to deploy it), so this takes the forge's `main` when -/// there is one and writes a template only when there is not. Two authors for -/// one file is the failure mode being avoided: whichever wrote second would -/// win, and neither knows about the other. -/// -/// The template ([`seed_template`]) is the fallback when there is genuinely -/// nothing to take yet: `swarm-controller` creates `agent-configs/` -/// before telling a hive to deploy, but hasn't necessarily pushed `main` to -/// it, or the forge is unreachable, or this hive has no forge read token. -/// -/// Seeding is the only write hive-c0re makes here. Later config changes -/// arrive as forge PRs and are fetched from the forge's `main` (see -/// `actions::advance_applied_to_rev`). `flake.nix` is tracked in the seed -/// and rides along on every fetch. -/// -/// A repo that is already there is only checked for its shape (a rebuild -/// finds it already seeded): a missing `deployed/0` tag means it predates the -/// meta-flake layout and is an error. -pub async fn setup_applied(applied_dir: &Path, name: &str) -> Result<()> { - std::fs::create_dir_all(applied_dir) - .with_context(|| format!("create {}", applied_dir.display()))?; - - if !applied_dir.join(".git").exists() { - if crate::forge::fetch_config_main_into_applied(name).await { - return Ok(()); - } - seed_template(applied_dir, name).await?; - git_tag(applied_dir, "deployed/0", "refs/heads/main").await?; - } else if git_rev_parse(applied_dir, "refs/tags/deployed/0") - .await - .is_err() - { - // Pre-overhaul applied repo - no deployed/* tag scheme, - // flake.nix may be untracked, agent.nix possibly authored by - // hive-c0re directly. The startup auto-migration fixes this - // in place; if it didn't run (or got skipped), surface a - // clear error. - bail!( - "applied repo at {} predates the meta-flake layout. \ - Restart hive-c0re to let the auto-migration run, or \ - destroy --purge {name} and re-spawn.", - applied_dir.display() - ); - } - Ok(()) -} - -/// Author the initial config in place: `agent.nix` (the agent's own module) -/// and `flake.nix` (the boilerplate that lets the meta flake import this repo -/// as an input: meta locks at a specific sha and reads -/// `nixosModules.default`, so `flake.nix` must be in the commit). `flake.nix` -/// isn't meant to be edited, but it's tracked so it can be read. -/// -/// The fallback half of [`setup_applied`]: reached only when the forge has -/// no config for this agent to take. -async fn seed_template(dir: &Path, name: &str) -> Result<()> { - let agent_path = dir.join("agent.nix"); - if !agent_path.exists() { - std::fs::write(&agent_path, initial_agent_nix(name)) - .with_context(|| format!("write {}", agent_path.display()))?; - } - let flake_path = dir.join("flake.nix"); - if !flake_path.exists() { - std::fs::write(&flake_path, initial_flake_nix()) - .with_context(|| format!("write {}", flake_path.display()))?; - } - git(dir, &["init", "--initial-branch=main"]).await?; - git(dir, &["add", "agent.nix", "flake.nix"]).await?; - git_commit(dir, "hive-c0re init").await -} +use anyhow::{Context, Result}; /// Create the per-agent Claude credentials dir if missing. Mode 0755 — hive-core /// needs read+execute to list the directory so `claude_has_session` can detect a @@ -170,27 +89,3 @@ pub async fn ensure_agent_state_subvolume(name: &str) -> Result<()> { .await .with_context(|| format!("ensure btrfs subvolume for agent {name}")) } - -fn initial_agent_nix(name: &str) -> String { - format!( - "{{ config, pkgs, lib, ... }}:\n{{\n # Per-agent overrides for {name}. This is a regular NixOS module\n # — add packages, services, modules, imports as needed.\n #\n # imports = [ ./extra-module.nix ];\n # environment.systemPackages = with pkgs; [ ];\n}}\n", - ) -} - -/// Module-only flake exposed by every agent's repo. Consumed by the -/// swarm's `internal/meta` flake as a flake input. The wrapper is -/// intentionally permissive: -/// -/// - Manager edits `inputs.* = …` to add other flakes (e.g. an MCP -/// server's own flake) — the lock for those lands in the agent's -/// own `flake.lock` and rolls up into meta's lock transitively. -/// - The outputs block forwards every input (minus `self`) into -/// `agent.nix` as the `flakeInputs` module argument, so the -/// manager just references `flakeInputs..packages.${pkgs.system}.default` -/// without further plumbing. -/// -/// Identity injection (`HIVE_PORT` / `HIVE_LABEL` / dashboard port / -/// git committer) still lives in the meta flake's wrapper. -pub fn initial_flake_nix() -> &'static str { - "{\n description = \"hyperhive agent\";\n inputs = { };\n outputs =\n { self, ... }@inputs:\n {\n nixosModules.default = {\n imports = [ ./agent.nix ];\n _module.args.flakeInputs = builtins.removeAttrs inputs [ \"self\" ];\n };\n };\n}\n" -} diff --git a/hive-c0re/src/lifecycle/tests.rs b/hive-c0re/src/lifecycle/tests.rs index 99582472..9cc5f895 100644 --- a/hive-c0re/src/lifecycle/tests.rs +++ b/hive-c0re/src/lifecycle/tests.rs @@ -3,48 +3,6 @@ use super::*; -/// Regression test: `setup_applied` must seed both agent.nix and flake.nix -/// in the initial commit, and tag it `deployed/0`. Before commit 5b5a93e -/// flake.nix was missing from the scaffold, requiring manual creation (seen -/// with the damocles agent). -/// -/// Exercises the template arm: there is no forge to take a config from in a -/// test (no reader token), so `setup_applied` falls through to -/// `seed_template` — which is the arm this asserts about. -#[tokio::test] -async fn setup_applied_seeds_flake_nix() { - let dir = tempfile::tempdir().expect("tempdir"); - let applied = dir.path().join("applied"); - setup_applied(&applied, "test-agent") - .await - .expect("setup_applied"); - git_rev_parse(&applied, "refs/tags/deployed/0") - .await - .expect("the seed commit is tagged deployed/0"); - - // Both files must exist on disk. - assert!(applied.join("agent.nix").exists(), "agent.nix missing"); - assert!(applied.join("flake.nix").exists(), "flake.nix missing"); - - // flake.nix must export nixosModules.default (the meta-flake contract). - let flake = std::fs::read_to_string(applied.join("flake.nix")).unwrap(); - assert!( - flake.contains("nixosModules.default"), - "flake.nix does not export nixosModules.default" - ); - - // Both files must be tracked in the initial git commit. - let out = git_command() - .current_dir(&applied) - .args(["show", "--name-only", "--format=", "HEAD"]) - .output() - .await - .expect("git show"); - let tracked = String::from_utf8_lossy(&out.stdout); - assert!(tracked.contains("agent.nix"), "agent.nix not committed"); - assert!(tracked.contains("flake.nix"), "flake.nix not committed"); -} - #[test] fn bridge_gateway_ip_extracts_verbatim_address() { // HIVE_NETWORK_SUBNET carries the bridge IP verbatim, not the @@ -132,122 +90,6 @@ fn network_isolation_errors_name_the_offending_variable() { ); } -/// `setup_applied` is idempotent: calling it on an already seeded repo is a -/// no-op (the shape check passes and nothing is written). -#[tokio::test] -async fn setup_applied_idempotent() { - let dir = tempfile::tempdir().expect("tempdir"); - let applied = dir.path().join("applied"); - setup_applied(&applied, "test-agent") - .await - .expect("first call"); - // Second call must not error even though .git already exists. - setup_applied(&applied, "test-agent") - .await - .expect("second call"); - // Still one commit. - let out = git_command() - .current_dir(&applied) - .args(["rev-list", "--count", "HEAD"]) - .output() - .await - .expect("git rev-list"); - let count = String::from_utf8_lossy(&out.stdout).trim().to_owned(); - assert_eq!( - count, "1", - "expected exactly one commit after idempotent call" - ); -} - -/// Build a two-commit repo and return `(dir, repo_path, first, second)`. -async fn two_commit_repo() -> (tempfile::TempDir, std::path::PathBuf, String, String) { - let dir = tempfile::tempdir().expect("tempdir"); - let repo = dir.path().join("applied"); - setup_applied(&repo, "test-agent") - .await - .expect("setup_applied"); - let first = git_rev_parse(&repo, "HEAD").await.expect("rev-parse first"); - - std::fs::write(repo.join("second.txt"), "second").expect("write second.txt"); - git(&repo, &["add", "second.txt"]).await.expect("git add"); - git( - &repo, - &[ - "-c", - "user.name=test", - "-c", - "user.email=test@example.com", - "commit", - "-m", - "second", - ], - ) - .await - .expect("git commit"); - let second = git_rev_parse(&repo, "HEAD") - .await - .expect("rev-parse second"); - - assert_ne!(first, second, "second commit did not advance HEAD"); - (dir, repo, first, second) -} - -/// `git_is_ancestor` answers reachability in both directions. This is the -/// check that decides whether moving a branch is a real fast-forward or a -/// rewind that discards commits. -#[tokio::test] -async fn is_ancestor_distinguishes_direction() { - let (_dir, repo, first, second) = two_commit_repo().await; - - assert!( - git_is_ancestor(&repo, &first, &second) - .await - .expect("ancestor check forward"), - "first must be an ancestor of second" - ); - assert!( - !git_is_ancestor(&repo, &second, &first) - .await - .expect("ancestor check reverse"), - "second must NOT be an ancestor of first" - ); -} - -/// Regression test for the deploy path that ate a committed agent config: -/// `git_update_ref_cas` must refuse to move a ref whose current value is not -/// the expected one, and must leave the ref untouched when it refuses. A bare -/// `update-ref` accepts that move and silently drops everything in between. -#[tokio::test] -async fn update_ref_cas_refuses_stale_expectation() { - let (_dir, repo, first, second) = two_commit_repo().await; - - // Park a ref at `first`, then CAS it forward with the correct old value. - git_update_ref(&repo, "refs/heads/cas", &first) - .await - .expect("plant ref"); - git_update_ref_cas(&repo, "refs/heads/cas", &second, &first) - .await - .expect("CAS with the correct old value must succeed"); - assert_eq!( - git_rev_parse(&repo, "refs/heads/cas").await.unwrap(), - second - ); - - // Now retry with the SAME (now stale) expectation, as a racing deploy - // holding a pre-merge sha would: it must fail rather than rewind. - assert!( - git_update_ref_cas(&repo, "refs/heads/cas", &first, &first) - .await - .is_err(), - "CAS accepted a stale old value" - ); - assert_eq!( - git_rev_parse(&repo, "refs/heads/cas").await.unwrap(), - second, - "ref moved even though the CAS failed" - ); -} - /// `systemctl is-active` prints the state with a trailing newline, which is /// the whole reason this parser trims rather than comparing raw. #[test] @@ -416,60 +258,6 @@ async fn a_systemctl_answer_inside_the_bound_passes_through() { assert_eq!(got, Some(7)); } -/// A child that outlives the deadline yields an error naming the command and -/// the deadline, and the child and its grandchild are both killed rather than -/// orphaned. -#[tokio::test] -async fn run_with_deadline_times_out_and_kills_the_process_group() { - let dir = tempfile::tempdir().expect("tempdir"); - let (shpid, sleeppid) = (dir.path().join("sh.pid"), dir.path().join("sleep.pid")); - let mut cmd = tokio::process::Command::new("sh"); - cmd.arg("-c").arg(format!( - "echo $$ > {}; sleep 300 & echo $! > {}; wait", - shpid.display(), - sleeppid.display() - )); - let err = run_with_deadline( - &mut cmd, - std::time::Duration::from_millis(500), - "fake fetch", - ) - .await - .expect_err("a sleeping child must hit the deadline"); - let msg = format!("{err:#}"); - assert!(msg.contains("fake fetch timed out after 0.5s"), "{msg}"); - - for file in [&shpid, &sleeppid] { - // The pid file proves the process existed before the kill. - let pid = std::fs::read_to_string(file) - .expect("process wrote its pid before the deadline") - .trim() - .to_owned(); - let proc = std::path::PathBuf::from("/proc").join(&pid); - let mut gone = false; - for _ in 0..100 { - if !proc.exists() { - gone = true; - break; - } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } - assert!(gone, "pid {pid} ({}) still running", file.display()); - } -} - -/// Presence control for the timeout test: a child that finishes in time -/// returns its output. -#[tokio::test] -async fn run_with_deadline_returns_output_of_a_fast_child() { - let mut cmd = tokio::process::Command::new("echo"); - cmd.arg("hi"); - let out = run_with_deadline(&mut cmd, std::time::Duration::from_secs(30), "echo") - .await - .expect("echo finishes"); - assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hi"); -} - /// The per-host values reach the container as facts: the dashboard port and /// pinned claude from the hive's config, the OTEL labels and the queue /// address from the daemon's environment. diff --git a/hive-c0re/src/migrate.rs b/hive-c0re/src/migrate.rs index 9920d498..05684afd 100644 --- a/hive-c0re/src/migrate.rs +++ b/hive-c0re/src/migrate.rs @@ -1,5 +1,5 @@ //! Startup convergence. Three phases, all idempotent and unguarded: -//! harness files, the applied repos, the agent-set files, plus the +//! harness files, the leftover applied repos, the agent-set files, plus the //! removal of the legacy meta repo's leftovers. They re-run every //! boot on purpose — each one is a no-op once its state is already //! correct. @@ -23,29 +23,21 @@ use std::path::Path; use std::time::Duration; use anyhow::{Context, Result}; -use tokio::process::Command; use crate::coordinator::Coordinator; use crate::lifecycle::{self, AGENT_PREFIX}; const KILL_SWITCH: &str = "HIVE_SKIP_META_MIGRATION"; -/// Per-shellout timeout for the blocking startup convergence. `run` is +/// Timeout for the shellout in the blocking startup convergence. `run` is /// awaited *before* the daemon starts serving (main.rs), so any child /// process that wedges here freezes the whole daemon — admin socket + /// dashboard included — with no diagnostics: a git shellout was observed -/// blocked for 86min under a concurrent `nixos-rebuild`. Every shellout -/// runs under a timeout that kills the child on elapse, so a stuck phase -/// degrades to a logged warning instead of a hung boot. +/// blocked for 86min under a concurrent `nixos-rebuild`. The shellout +/// runs under a timeout, so a stuck phase degrades to a logged warning +/// instead of a hung boot. const GIT_TIMEOUT: Duration = Duration::from_mins(2); -/// Substring that identifies the *current* agent flake boilerplate. -/// Bumped whenever the template changes so the startup migration -/// re-renders existing agents onto the new shape. Today the marker -/// is the `flakeInputs` module-arg forwarding line — older templates -/// (raw `import ./agent.nix`) get rewritten on next hive-c0re start. -const MODULE_FLAKE_MARKER: &str = "_module.args.flakeInputs"; - pub async fn run() -> Result<()> { if std::env::var(KILL_SWITCH).is_ok() { tracing::info!("migration: {KILL_SWITCH} set — skipping"); @@ -80,26 +72,16 @@ pub async fn run() -> Result<()> { Err(e) => tracing::warn!(error = ?e, "migration: removing the legacy meta .git failed"), } - // Phase 1 + 2: per-agent applied repo. - tracing::debug!("migration: phase 1+2 (applied repo)"); - // An archetype agent has no applied repo, and seeding one here would hand - // it a config it is not built from. - let archetypes = crate::archetypes::read_at(&crate::archetypes::path()).unwrap_or_else(|e| { - tracing::warn!(error = ?e, "migration: stored archetypes unreadable; treating every agent as custom"); - crate::archetypes::Stored::new() - }); - for name in &names { - if archetypes.contains_key(name.as_str()) { - continue; - } - tracing::debug!(%name, "migration: applied"); - if let Err(e) = migrate_applied_repo(name.as_str()).await { - tracing::warn!(%name, error = ?e, "migration: applied repo rewrite failed"); - } + // Phase 1: the applied repos an earlier hive kept per agent. + tracing::debug!("migration: phase 1 (leftover applied repos)"); + match remove_applied_repos(&crate::paths::applied_root()) { + Ok(0) => {} + Ok(n) => tracing::info!(count = n, "migration: removed leftover applied repos"), + Err(e) => tracing::warn!(error = ?e, "migration: removing the applied repos failed"), } - // Phase 3: the files that follow the agent set. - tracing::debug!("migration: phase 3 (agent_set refresh)"); + // Phase 2: the files that follow the agent set. + tracing::debug!("migration: phase 2 (agent_set refresh)"); // `refresh` takes the agents it is given as the whole set, so an empty // stand-in for an unreadable list would drop every agent from the topology. let agents = match lifecycle::agents_for_meta_listing().await { @@ -185,89 +167,27 @@ async fn enumerate_agents() -> Result> { .collect()) } -async fn migrate_applied_repo(name: &str) -> Result<()> { - migrate_applied_dir(name, &crate::paths::applied_dir(name)).await -} - -/// Rewrites the applied repo at `dir` to the module-only flake. A missing -/// `flake.nix` counts as "marker absent"; any other read error fails the -/// migration so the agent's file is never overwritten unread. -async fn migrate_applied_dir(name: &str, dir: &Path) -> Result<()> { - if !dir.join(".git").exists() { - return Ok(()); - } - let flake_path = dir.join("flake.nix"); - let cur = match std::fs::read_to_string(&flake_path) { - Ok(cur) => cur, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(e) => return Err(e).with_context(|| format!("read {}", flake_path.display())), +/// Remove the per-agent `applied/` repos an earlier hive kept in `root`, +/// destroyed agents' included. The forge's `agent-configs/` is the only +/// copy of a config now, so nothing reads them. The dot-files beside them (the +/// deploy markers) stay. Returns how many it removed. +fn remove_applied_repos(root: &Path) -> Result { + let entries = match std::fs::read_dir(root) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(e) => return Err(e).with_context(|| format!("reading {}", root.display())), }; - if cur.contains(MODULE_FLAKE_MARKER) { - return Ok(()); + let mut removed = 0; + for entry in entries { + let entry = entry.with_context(|| format!("reading {}", root.display()))?; + if !entry.file_type()?.is_dir() { + continue; + } + let path = entry.path(); + std::fs::remove_dir_all(&path).with_context(|| format!("removing {}", path.display()))?; + removed += 1; } - let want = lifecycle::initial_flake_nix(); - std::fs::write(&flake_path, want).with_context(|| format!("write {}", flake_path.display()))?; - raw_git( - dir, - &[ - "-c", - "user.name=c0re", - "-c", - "user.email=c0re@hyperhive.local", - "add", - "flake.nix", - ], - ) - .await?; - raw_git( - dir, - &[ - "-c", - "user.name=c0re", - "-c", - "user.email=c0re@hyperhive.local", - "commit", - "-m", - "migration: module-only flake", - ], - ) - .await?; - // Relocate deployed/0 to the migration commit so - // setup_applied's existence check passes. - raw_git(dir, &["tag", "-f", "deployed/0", "HEAD"]).await?; - tracing::info!(%name, "migration: applied repo migrated to module-only flake"); - Ok(()) -} - -/// Run a command to completion under a timeout, capturing its output. On -/// timeout the child is killed (`kill_on_drop`) and an error is returned, -/// so a wedged shellout can never freeze startup migration. `what` is a -/// human label surfaced in the timeout error + `with_context`. -async fn output_with_timeout( - mut cmd: Command, - timeout: Duration, - what: &str, -) -> Result { - cmd.kill_on_drop(true); - match tokio::time::timeout(timeout, cmd.output()).await { - Ok(r) => r.with_context(|| format!("run {what}")), - Err(_) => anyhow::bail!("{what} timed out after {timeout:?} (child killed)"), - } -} - -async fn raw_git(dir: &Path, args: &[&str]) -> Result<()> { - let mut cmd = lifecycle::git_command(); - cmd.current_dir(dir).args(args); - let label = format!("git {} in {}", args.join(" "), dir.display()); - let out = output_with_timeout(cmd, GIT_TIMEOUT, &label).await?; - if !out.status.success() { - anyhow::bail!( - "git {} failed: {}", - args.join(" "), - String::from_utf8_lossy(&out.stderr).trim() - ); - } - Ok(()) + Ok(removed) } #[cfg(test)] @@ -312,17 +232,29 @@ mod tests { assert!(!remove_core_token(dir.path()).expect("second"), "no-op"); } - #[tokio::test] - async fn unreadable_flake_is_left_untouched() { - let dir = tempfile::tempdir().expect("tempdir"); - std::fs::create_dir(dir.path().join(".git")).expect("mkdir .git"); - let flake = dir.path().join("flake.nix"); - let bytes = [0xff_u8, 0xfe, b'x']; - std::fs::write(&flake, bytes).expect("write flake"); + #[test] + fn applied_repos_go_and_the_markers_beside_them_stay() { + let root = tempfile::tempdir().expect("tempdir"); + for name in ["alice", "bob"] { + std::fs::create_dir_all(root.path().join(name).join(".git")).expect("mkdir repo"); + std::fs::write(root.path().join(name).join("agent.nix"), "{}").expect("write config"); + std::fs::write(root.path().join(format!(".{name}.config-rev")), "abc").expect("marker"); + } - let res = migrate_applied_dir("t", dir.path()).await; - - assert!(res.is_err(), "non-UTF-8 flake.nix must fail the migration"); - assert_eq!(std::fs::read(&flake).expect("read back"), bytes); + assert_eq!(remove_applied_repos(root.path()).expect("first"), 2); + assert!(!root.path().join("alice").exists()); + assert!( + root.path().join(".alice.config-rev").exists(), + "marker stays" + ); + assert_eq!( + remove_applied_repos(root.path()).expect("second"), + 0, + "no-op" + ); + assert_eq!( + remove_applied_repos(&root.path().join("missing")).expect("absent root"), + 0 + ); } } diff --git a/hive-c0re/src/paths.rs b/hive-c0re/src/paths.rs index 4a2dfbc4..5916a8b4 100644 --- a/hive-c0re/src/paths.rs +++ b/hive-c0re/src/paths.rs @@ -114,19 +114,14 @@ pub fn agents_root() -> PathBuf { PathBuf::from(AGENTS_ROOT) } -/// `applied/` — per-agent *applied* (deployed) config repos + rev markers. +/// `applied/` — per-agent deploy rev markers (and, on a hive that predates +/// their removal, leftover config repos the startup migration clears). // nix: read by hive-c0re only, but paired with `agents/` in the deploy flow. #[must_use] pub fn applied_root() -> PathBuf { state_root().join("applied") } -/// `applied/` — one agent's applied config working tree. -#[must_use] -pub fn applied_dir(name: &str) -> PathBuf { - applied_root().join(name) -} - /// `applied/..hyperhive-rev` — marker recording the hyperhive commit /// `internal/meta` pinned when an agent was last successfully built (the boot /// reconcile's staleness check). diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 08f7882d..5c51a7a5 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -203,12 +203,6 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { // federation peer-config block) and open the web surfaces // (`hivectl open`). HostRequest::Urls => HostResponse::urls(hive_urls()), - HostRequest::ReconcileConfigStatus { agent, verbose } => { - crate::forge::reconcile_config_status(agent.as_str(), *verbose).await? - } - HostRequest::ReconcileConfigApply { agent, direction } => { - crate::forge::reconcile_config_apply(agent.as_str(), *direction).await? - } HostRequest::QuotaEnable => handle_quota_enable().await?, HostRequest::QuotaLimit { name, limit } => { handle_quota_limit(name.as_str(), *limit).await? diff --git a/hive-c0re/src/swarm_status.rs b/hive-c0re/src/swarm_status.rs index d8816ea7..5756bc97 100644 --- a/hive-c0re/src/swarm_status.rs +++ b/hive-c0re/src/swarm_status.rs @@ -309,9 +309,9 @@ async fn handle_deploy_request( } }; - // Only a rebuild applies `rev`. A first deploy ignores it and builds the - // `applied` repo it seeds from the forge's `main`, or the one it finds. - // An archetype agent has no `applied` repo, so its rebuild ignores it too. + // Only a rebuild looks at `rev`. A first deploy ignores it: the build takes + // whatever commit `internal/meta` pins. An archetype agent has no config + // repo, so its rebuild ignores it too. let archetype = match crate::archetypes::of(&agent) { Ok(archetype) => archetype, Err(e) => { @@ -322,8 +322,9 @@ async fn handle_deploy_request( if known && archetype.is_none() && let Some(rev) = rev.as_deref() - && !apply_rev(&agent, rev).await + && rev_already_deployed(&agent, rev) { + tracing::info!(%agent, %rev, "swarm events: deploy requested for the deployed rev; nothing queued"); return; } @@ -351,31 +352,14 @@ async fn handle_deploy_request( } } -/// Move `agent`'s `applied/main` to `rev` ahead of its rebuild, and say -/// whether that rebuild should be queued. A `rev` already applied is taken as -/// deployed or deploying, since every path that moves `applied/main` queues a -/// rebuild with it. The exception is `hivectl forge reconcile-config`, which -/// does not deploy, so a rev it put in place is skipped too. -/// -/// The refusal's message is matched verbatim by the `hh-config-rev-refused` -/// alert rule in `nix/host-modules/swarm-grafana.nix`; change both together. -/// A failed rebuild surfaces where every rebuild failure does, on this hive. -async fn apply_rev(agent: &str, rev: &str) -> bool { - match crate::actions::advance_applied_to_rev(agent, rev).await { - Ok(crate::actions::RevAdvance::Advanced) => true, - Ok(crate::actions::RevAdvance::AlreadyApplied) => { - tracing::info!(%agent, %rev, "swarm events: deploy requested for the applied rev; nothing queued"); - false - } - Ok(crate::actions::RevAdvance::Superseded) => { - tracing::info!(%agent, %rev, "swarm events: deploy requested for a rev applied/main already contains; nothing queued"); - false - } - Err(e) => { - tracing::error!(%agent, %rev, error = %format!("{e:#}"), "swarm events: rev not applied; nothing queued"); - false - } - } +/// Whether `rev` is the config commit `agent`'s last successful build pinned +/// (`paths::applied_config_marker`), so a rebuild would deploy nothing new. A +/// request for a rev that is still building, or whose build failed, is not +/// deployed yet and queues again: the rebuild resolves its pins from +/// `internal/meta` either way, so a second one is redundant but harmless. +fn rev_already_deployed(agent: &str, rev: &str) -> bool { + std::fs::read_to_string(crate::paths::applied_config_marker(agent)) + .is_ok_and(|deployed| deployed.trim() == rev) } /// Queue the first deploy of an agent this hive does not have yet: seed its diff --git a/hive-c0re/src/workers/auto_update.rs b/hive-c0re/src/workers/auto_update.rs index 0807b7f5..02f979a6 100644 --- a/hive-c0re/src/workers/auto_update.rs +++ b/hive-c0re/src/workers/auto_update.rs @@ -39,31 +39,21 @@ pub fn current_flake_rev(hyperhive_flake: &str) -> Option { .map(|p| p.display().to_string()) } -/// Returns true when the applied repo has commits that have not yet been -/// deployed (i.e. the applied HEAD differs from the config commit the agent's -/// last successful build pinned, `paths::applied_config_marker`). This is the -/// semantic the dashboard `needs_update` chip conveys: "there is a config -/// change ready to apply via rebuild." +/// Returns true when the config commit `internal/meta` pins for the agent's +/// next build (`meta_rev::recorded`) differs from the one its last successful +/// build pinned (`deployed`, the `paths::applied_config_marker` content). This +/// is the semantic the dashboard `needs_update` chip conveys: "there is a +/// config change ready to apply via rebuild." An archetype agent pins no +/// config commit and never has one pending. /// -/// Async on purpose: this runs per agent inside `container_view::build_all`, -/// which fires on the ~10s dashboard sweep, every `AgentStatus` request, and -/// every `rescan_containers_and_emit` after a lifecycle step. A synchronous -/// `git` fork here blocks a tokio worker for the whole exec — under -/// nix-build disk saturation that's long enough that concurrent sweeps -/// starved the runtime and stalled the per-agent sockets. -pub async fn agent_config_pending(name: &str, deployed_sha: Option<&str>) -> bool { - let applied = crate::paths::applied_dir(name); - let applied_head = crate::lifecycle::git_command() - .args(["-C", &applied.to_string_lossy(), "rev-parse", "HEAD"]) - .output() - .await - .ok() - .filter(|o| o.status.success()) - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_owned()); - - match (applied_head.as_deref(), deployed_sha) { - (Some(head), Some(sha)) => !head.starts_with(sha) && !sha.starts_with(head), +/// Reads one small file and the marker the caller already holds, no `git`: +/// this runs per agent inside `container_view::build_all`, on the ~10s +/// dashboard sweep and every `AgentStatus` request. +#[must_use] +pub fn agent_config_pending(name: &str, deployed: Option<&str>) -> bool { + let pinned = crate::meta_rev::recorded(name).and_then(|build| build.config); + match (pinned.as_deref(), deployed) { + (Some(pinned), Some(deployed)) => pinned != deployed.trim(), _ => false, } } diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index c79cc7de..f96b4290 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -76,17 +76,6 @@ pub fn container_name(name: &str) -> String { format!("{AGENT_PREFIX}{name}") } -/// Which way to reconcile an agent's config branches -/// ([`HostRequest::ReconcileConfigApply`]). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ReconcileDirection { - /// Reset the local applied checkout to forge `main`. - Forge, - /// Advance forge `main` from local — not supported yet. - Local, -} - /// Requests on the host admin socket. /// /// Wire format: one JSON object per line. @@ -223,28 +212,6 @@ pub enum HostRequest { #[serde(default)] scope: LifecycleScope, }, - /// Report the divergence between agent `agent`'s local applied config - /// checkout and its forge `agent-configs/` `main`. The daemon - /// fetches forge `main` read-only and returns a human-readable report - /// (ahead/behind counts, commit-range summary, `git diff --stat`, and - /// the full diff when `verbose`) in [`HostResponse::messages`]. Read-only - /// — never mutates either side. Backs `hivectl forge reconcile-config` - /// (the diff it always shows first). - ReconcileConfigStatus { - agent: Ident, - #[serde(default)] - verbose: bool, - }, - /// Reconcile agent `agent`'s config branches in `direction`. - /// `Forge` resets the local applied checkout to forge `main` (takes - /// effect on the next deploy); `Local` is not supported yet and returns - /// an [`HostResponse::error`] (advancing the protected forge `main` from - /// local needs lifting branch protection — resolve via a config PR). - /// Backs `hivectl forge reconcile-config --from `. - ReconcileConfigApply { - agent: Ident, - direction: ReconcileDirection, - }, /// Turn on btrfs qgroup accounting on the agent-state filesystem, via /// the privileged helper. Daemon-side equivalent of `hivectl /// quota-enable`. Returns advisory lines in [`HostResponse::messages`]. diff --git a/hivectl/src/cli.rs b/hivectl/src/cli.rs index 5fde3867..e1212e40 100644 --- a/hivectl/src/cli.rs +++ b/hivectl/src/cli.rs @@ -27,14 +27,6 @@ pub struct Cli { #[derive(Subcommand)] pub enum Cmd { - /// Reconcile an agent's config between this hive and the forge. - /// - /// A human's first SSO login to the forge makes their forge - /// account. - Forge { - #[command(subcommand)] - cmd: ForgeCmd, - }, /// Lifecycle actions on ONE managed agent container. Needs the /// hive-c0re daemon running. /// @@ -199,45 +191,6 @@ impl ScopeArgs { } } -#[derive(Subcommand)] -pub enum ForgeCmd { - /// Show + reconcile the divergence between an agent's local applied - /// config checkout and its forge `agent-configs/` 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. - ReconcileConfig { - /// Agent whose config branches to reconcile. - agent: String, - /// Which side to reconcile from. Omit it and hivectl prompts you after the diff. - #[arg(long, value_enum)] - from: Option, - /// Include the full diff (not just `--stat`) in the report. - #[arg(long)] - verbose: bool, - }, -} - -/// Which side to reconcile config branches from (`hivectl forge -/// reconcile-config --from`). Maps to [`hive_host_sock::ReconcileDirection`]. -#[derive(Clone, Copy, clap::ValueEnum)] -pub enum ReconcileFrom { - /// Reset the local applied checkout to forge main. - Forge, - /// Advance forge main from local — not supported yet. - Local, -} - -impl From for hive_host_sock::ReconcileDirection { - fn from(from: ReconcileFrom) -> Self { - match from { - ReconcileFrom::Forge => Self::Forge, - ReconcileFrom::Local => Self::Local, - } - } -} - #[derive(Subcommand)] pub enum WgCmd { /// Generate this hive's WireGuard key (if absent) and print its public diff --git a/hivectl/src/forge.rs b/hivectl/src/forge.rs deleted file mode 100644 index 15fdb33e..00000000 --- a/hivectl/src/forge.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! `hivectl forge reconcile-config ` — show and reconcile an -//! agent's config divergence via the daemon. - -use std::io::{self, Write}; -use std::path::Path; - -use anyhow::Result; -use hive_host_sock::{HostRequest, ReconcileDirection}; - -use crate::cli::ReconcileFrom; -use crate::util::daemon_request; - -/// `hivectl forge reconcile-config [--from ] [--verbose]`. -/// Always shows the divergence first (daemon computes it read-only), then -/// applies the chosen direction — from `--from` or an interactive prompt. -pub(crate) async fn forge_reconcile_config( - socket: &Path, - agent: &str, - from: Option, - verbose: bool, -) -> Result<()> { - daemon_request( - socket, - HostRequest::ReconcileConfigStatus { - agent: crate::util::parse_ident(agent)?, - verbose, - }, - "forge", - ) - .await?; - let direction = match from { - Some(f) => Some(f.into()), - None => prompt_direction()?, - }; - let Some(direction) = direction else { - println!("forge: aborted — no changes made"); - return Ok(()); - }; - daemon_request( - socket, - HostRequest::ReconcileConfigApply { - agent: crate::util::parse_ident(agent)?, - direction, - }, - "forge", - ) - .await -} - -/// Prompt the operator for the reconcile direction after showing the diff. -/// Returns `None` to abort (empty / `q` / unrecognized input). -fn prompt_direction() -> Result> { - print!("reconcile from [forge/local] (or q to abort): "); - io::stdout().flush()?; - let mut line = String::new(); - io::stdin().read_line(&mut line)?; - match line.trim().to_ascii_lowercase().as_str() { - "forge" | "f" => Ok(Some(ReconcileDirection::Forge)), - "local" | "l" => Ok(Some(ReconcileDirection::Local)), - "" | "q" | "quit" => Ok(None), - other => { - println!("forge: unrecognized direction {other:?} — aborting"); - Ok(None) - } - } -} diff --git a/hivectl/src/main.rs b/hivectl/src/main.rs index 94655042..69c07c32 100644 --- a/hivectl/src/main.rs +++ b/hivectl/src/main.rs @@ -26,7 +26,7 @@ mod client; /// Rebuild-queue node progress rendering (`wait_for_nodes` + the spinner / /// plain renderers), split out to keep this file manageable. mod dag_progress; -use cli::{Cli, Cmd, ForgeCmd, WgCmd}; +use cli::{Cli, Cmd, WgCmd}; mod completions; mod quota; mod util; @@ -35,11 +35,9 @@ mod open; use open::open_url; mod wg; use wg::{peer_config, require_hive_domain, wg_init, wg_peer, wg_status}; -mod choom; -mod forge; -mod watch; -use forge::forge_reconcile_config; mod agents; +mod choom; +mod watch; use agents::{agents_list, run_agent}; mod power; use power::{restart, start, stop}; @@ -56,13 +54,6 @@ async fn main() -> Result<()> { let cli = Cli::parse(); let socket = cli.socket; match cli.cmd { - Cmd::Forge { cmd } => match cmd { - ForgeCmd::ReconcileConfig { - agent, - from, - verbose, - } => forge_reconcile_config(&socket, &agent, from, verbose).await, - }, Cmd::Agent { name, cmd } => run_agent(&socket, &name, cmd).await, Cmd::ListAgents { json } => agents_list(&socket, json).await, Cmd::QuotaEnable => quota::quota_enable(&socket).await, diff --git a/nix/agent-modules/default.nix b/nix/agent-modules/default.nix index 5a8e4100..df559f1c 100644 --- a/nix/agent-modules/default.nix +++ b/nix/agent-modules/default.nix @@ -287,8 +287,8 @@ in }; # Git is needed by claude's Bash tool (for the agent <-> manager config - # request flow) and by hive-c0re's own setup_applied. - # The per-agent `applied//flake.nix` overrides `user.name` and + # request flow). + # The per-agent `agent-configs//flake.nix` overrides `user.name` and # `user.email` with the agent's identity — values here are `mkDefault` # so the per-agent override wins without needing `mkForce`. programs.git = { diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index 17039af3..5a3c1392 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -4,9 +4,8 @@ # everything; `services.hyperhive.deploy.hive-controller.enable = true` # runs a hive on this host. # -# Every swarm needs the forge — hive-c0re mirrors every agent's applied -# config repo into it and it's the canonical store for the meta flake -# + `internal/*` repos — but only the host with `deploy.forgejo.enable` +# Every swarm needs the forge — it's the canonical store for every agent's +# config repo, the meta flake and the `internal/*` repos — but only the host with `deploy.forgejo.enable` # runs it (derived from `deploy.allSwarmServices`, see # ./swarm-required-services.nix). hive-matrix is opt-in (off by default). All # subsystems rely on `services.hyperhive.domain`, which is required diff --git a/nix/host-modules/hive-c0re/default.nix b/nix/host-modules/hive-c0re/default.nix index 7441493f..cd33fae9 100644 --- a/nix/host-modules/hive-c0re/default.nix +++ b/nix/host-modules/hive-c0re/default.nix @@ -37,7 +37,7 @@ let # Privsep splits ownership across users, so git/libgit2's dubious- # ownership guard trips on legitimate cross-user reads: hive-c0re - # (hive-core) fetches the agent-owned proposed-config repos. The process is + # (hive-core) reads repos another user owns. The process is # trusted and can already read the files; this gitconfig only satisfies the # ownership guard. libgit2 honours the literal `*` (mid-path globs aren't # supported, so per-agent repos can't be listed); in practice this process @@ -49,7 +49,7 @@ let directory = * [http] # Abort a stalled fetch instead of blocking startup indefinitely. If a - # git http transfer from the forge (e.g. filling an applied mirror) + # git http transfer from the forge (e.g. the /knowledge clone) # drops below 1 KiB/s for 30s -- forge unreachable / not ready on cold # boot -- git fails fast rather than hanging the whole daemon. # Pairs with GIT_TERMINAL_PROMPT=0 (no credential-prompt hang) and the diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index 7e167aac..35e50e95 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -1660,28 +1660,13 @@ in for = "0s"; summary = "swarm-controller failed to mint agent {{ $labels.F_AGENT }}'s store identity; the reason is the `F_ERROR` field of the swarm-controller log line"; }) - # Logged once per refused deploy request, by - # `apply_rev` in hive-c0re's swarm_status.rs; the window is - # how long the rule stays firing after it. Host journal - # records carry the tracing fields as `F_` and no - # `hive` attribute, so the machine is `_HOSTNAME`. The unit - # filter keeps out agent logs that quote the message. - (logCountRule { - uid = "hh-config-rev-refused"; - title = "config rev refused"; - windowSeconds = 900; - expr = ''_time:15m _SYSTEMD_UNIT:="hive-c0re.service" _msg:"swarm events: rev not applied; nothing queued" | stats by (_HOSTNAME, F_AGENT, F_REV) count() as refusals''; - for = "0s"; - summary = "{{ $labels._HOSTNAME }} refused to deploy merged config commit {{ $labels.F_REV }} of agent {{ $labels.F_AGENT }}; the reason is the `F_ERROR` field of the hive-c0re log line"; - }) # Logged once per detected crash, by `emit_crash_transitions` # in hive-c0re's crash_watch.rs (10s poll interval). Replaces # the old per-hive dashboard `agents_crashing` banner — see # that function's module doc for why the banner went away # in favour of this. Host journal records carry the # tracing fields as `F_` and no `hive` attribute, - # so the machine is `_HOSTNAME` - # (same shape as `hh-config-rev-refused` above). The unit + # so the machine is `_HOSTNAME`. The unit # filter keeps out an agent's own log line if one ever # quotes the same message. (logCountRule { diff --git a/nix/templates/agent.nix b/nix/templates/agent.nix index ea51ff8e..ebbd5f2a 100644 --- a/nix/templates/agent.nix +++ b/nix/templates/agent.nix @@ -3,5 +3,5 @@ imports = [ ../agent-modules ]; # Entry-point for sub-agent containers. Referenced from `flake.nix` # (`nixosConfigurations.agent-base`) and the meta-flake's - # `applied//flake.nix`. + # `agent-configs//flake.nix`. } diff --git a/swarm-controller/src/forge.rs b/swarm-controller/src/forge.rs index 503f306b..52979830 100644 --- a/swarm-controller/src/forge.rs +++ b/swarm-controller/src/forge.rs @@ -793,11 +793,8 @@ impl Client { /// job of the `InitAgentConfigRepo` node. /// /// **This is the agent's config, not a copy of it.** A hive told to - /// deploy the agent takes this repo's `main` - /// (`hive-c0re::forge::fetch_config_main_into_applied`) rather than - /// writing the same files again locally; the byte-identical template in - /// `hive-c0re::lifecycle::setup::seed_template` is only reached when no - /// repo exists here to take. + /// deploy the agent builds the commit `internal/meta` pins from this + /// repo's `main`; it keeps no config of its own. /// /// Idempotent by construction rather than by catching a conflict: an /// unconditional `repo_change_files` against an already-seeded repo