From 25295fd33fa2c39fc9dd6d487de16b4c56f13154 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 1 Nov 2026 14:25:46 +0100 Subject: [PATCH] drop the proposed config repo: applied seeds from forge main, and the dead pending-init topology list goes with it --- docs/agent-lifecycle/config-changes.md | 68 ++++----- docs/agent-lifecycle/persistence.md | 10 +- docs/integrations/forge.md | 2 +- docs/process/conventions.md | 4 +- docs/scheduler/coordinator.md | 2 +- docs/web-ui/dashboard.md | 4 +- hive-c0re/src/actions.rs | 2 +- hive-c0re/src/agent_config/topology.rs | 47 ++---- hive-c0re/src/agent_set.rs | 6 +- hive-c0re/src/coordinator.rs | 31 ---- hive-c0re/src/dashboard/tombstones.rs | 2 +- hive-c0re/src/forge/mod.rs | 2 +- hive-c0re/src/forge/repos.rs | 184 +++++++++--------------- hive-c0re/src/job_queue/exec.rs | 2 +- hive-c0re/src/job_queue/templates.rs | 2 +- hive-c0re/src/lifecycle/git.rs | 21 +-- hive-c0re/src/lifecycle/host_config.rs | 2 +- hive-c0re/src/lifecycle/mod.rs | 20 ++- hive-c0re/src/lifecycle/setup.rs | 192 +++++++------------------ hive-c0re/src/lifecycle/tests.rs | 52 +++---- hive-c0re/src/main.rs | 3 +- hive-c0re/src/migrate.rs | 76 +--------- hive-c0re/src/paths.rs | 3 +- hive-sh4re/src/container.rs | 2 +- nix/agent-modules/default.nix | 2 +- swarm-controller/src/forge.rs | 8 +- 26 files changed, 227 insertions(+), 522 deletions(-) diff --git a/docs/agent-lifecycle/config-changes.md b/docs/agent-lifecycle/config-changes.md index 72f13437..7a5acf1d 100644 --- a/docs/agent-lifecycle/config-changes.md +++ b/docs/agent-lifecycle/config-changes.md @@ -102,7 +102,7 @@ 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 (proposed/applied repos, claude credentials, `/state/` +Persistent state (the applied repo, 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 @@ -186,20 +186,10 @@ the swarm builder built the agent from the updated repo (the reconcile ([below](#autoupdate-on-startup)) and the dashboard's update chip read them. -## Two repos per agent +## The applied repo per agent ``` -/var/lib/hyperhive/agents//config/ proposed — parent mount is RO -└── # any files the submitting - # agent wants in the commit. - # agent.nix is the - # convention entry - # point; flake.nix is - # tracked boilerplate - # (submitting agent doesn't - # edit it). - -/var/lib/hyperhive/applied// applied — core-only +/var/lib/hyperhive/applied// applied - core-only ├── .git/ # tag-rich history ├── flake.nix # tracked, fixed │ # boilerplate exporting @@ -207,16 +197,18 @@ the swarm builder built the agent from the updated repo (the ├── agent.nix # working tree of main └── # also tracked -/var/lib/hyperhive/meta/ plain hive state — core +/var/lib/hyperhive/meta/ plain hive state - core ├── topology.json # the live agent set └── swarm-resource-limits.json # the swarm's per-agent caps ``` -Why two physical repos: the submitting agent's `/agents//config/` is -RW — a buggy or hostile agent can `git clean -fdx` its own -proposed tree. The applied repo is never bind-mounted (except -the read-only `.git` exposure described below) so a destructive -move inside the container can't reach it. +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: the agent sees it read-only at `/agents//config`, so it can +read what governs it, while a config change is a PR against its forge repo. +Older hives also kept a *proposed* clone at `agents//config`. Nothing +reads it 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` @@ -240,9 +232,8 @@ 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` -(`forge::fetch_config_main_into_applied`), catching `applied` up, and -seeding `proposed` at first spawn (`forge::clone_config_into_proposed`). +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 @@ -284,9 +275,9 @@ the swarm's `agent-permissions` record; see `docs/process/conventions.md#permissions`) get additional host-side bind mounts via `set_nspawn_flags`: -- `/var/lib/hyperhive/agents/` → `/agents/` (RW) — **every** agent's - proposed repo, not just direct children. The permission means "may - manage any agent", so the mounted set is every agent. +- `/var/lib/hyperhive/agents//state` → `/agents//state` (RW) for + **every** agent, and `/agents//config` (RO, the applied repo). The + permission means "may manage any agent," so the mounted set is every agent. - `/var/lib/hyperhive/applied/` → `/applied/` (RO) — every agent's authoritative applied repo, including `.git`. - `/var/lib/hyperhive/meta/` → `/meta/` (RO) — the hive's plain state @@ -306,17 +297,13 @@ permissions were unset, swarm-controller writes the default plus `manage_root_agent` into her key once at start, so she keeps her mounts; it never touches a key that's already set. -Each proposed repo (`/agents//config/`) is pre-configured -with `applied` as a git remote pointing at -`/applied//.git`. Useful incantations from inside an agent with -the full `/applied` mount: +With the full `/applied` mount, an agent can read what hive-c0re deployed, +failed or denied for any agent: ```sh -git -C /agents//config fetch applied -git -C /agents//config log applied/main --oneline -git -C /agents//config show applied/refs/tags/deployed/0 # the seed commit -git -C /agents//config show applied/refs/tags/denied/ # body = operator note -git -C /agents//config rebase applied/main # base in-flight work on what's deployed +git -C /applied/ log main --oneline +git -C /applied/ show refs/tags/deployed/0 # the seed commit +git -C /applied/ show refs/tags/denied/ # body = operator note ``` The RO binds block push at the kernel level — git plumbing inside the @@ -333,17 +320,16 @@ once already applied: `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, wires the `applied` remote in each proposed - repo, and refreshes the files that follow the agent set + module-only boilerplate and refreshes the files that follow the agent set (`agent_set::refresh`) from the current agent list. Set `HIVE_SKIP_META_MIGRATION=1` on the service to defer this phase. - **Legacy meta repo**: deletes the leftover `.git` in - `/var/lib/hyperhive/meta/`, the `meta` remote in each proposed repo and the + `/var/lib/hyperhive/meta/` and the unread `/var/lib/hyperhive/forge-core-token`. The files beside the `.git` 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 both proposed + applied history all survive. The root +events DB, and the applied history all survive. The root agent keeps its session; sub-agents stay logged in. ## The root/bootstrap container @@ -352,8 +338,7 @@ 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/`; its proposed config at -`/var/lib/hyperhive/agents/ruth/config/`. The root agent can edit its own +`/var/lib/hyperhive/applied/ruth/`. The root agent can read its own `agent.nix` (visible inside the container at `/agents/ruth/config/`) and open a config PR on `agent-configs/ruth` for operator review, same as any other agent. @@ -368,8 +353,7 @@ Differences from sub-agents: FNV-1a hash as every other agent (8100..8999 range). - The root agent holds `manage_root_agent` (the swarm UI's setup assistant creates her with it), for which `set_nspawn_flags` adds two extra binds: - `/var/lib/hyperhive/agents` → `/agents` (RW) so the root agent can edit - per-agent proposed repos, + `/var/lib/hyperhive/agents` → `/agents` (RW) for every agent's state, and `/var/lib/hyperhive/applied` → `/applied` (RO) so the root agent can `git fetch` deployed/failed/denied tags from any agent's authoritative applied repo (see "Root-agent view of applied" below). diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index 93dc3be7..b4083375 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -254,11 +254,9 @@ than an accident of storage. Under `/var/lib/hyperhive/agents//`: -- `config/` — the proposed nix repo (root-agent-editable). Bind-mounted - **read-only** to `/agents//config` inside the sub-agent's own - container so the agent can inspect what defines it and request - precise changes from the root agent; RW into the root agent via the - `/agents` tree bind. +- `config/` — unused. Older hives kept a proposed nix repo here; nothing + reads it any more, and you can delete it. The agent's config mount at + `/agents//config` is the applied repo, read-only. - `claude/` — claude OAuth credentials, bind-mounted RW to `/home//.claude` inside the container. - `state/` — durable notes and `hyperhive-harness.json`. Bind-mounted @@ -487,7 +485,7 @@ boundary a convention rather than a permission. ⚠️ Don't confuse it with the config-repo seeding hive-c0re does at -spawn (`lifecycle::setup_proposed`): that writes the agent's initial +spawn (`lifecycle::setup_applied`): that writes the agent's initial config repo as **hive-c0re, against the host path**, and `read_only` on a bind constrains writers *inside* a container only. The two are unrelated — conflating them can lead you to reason your way into thinking this diff --git a/docs/integrations/forge.md b/docs/integrations/forge.md index 1eda1507..d17518f4 100644 --- a/docs/integrations/forge.md +++ b/docs/integrations/forge.md @@ -66,7 +66,7 @@ forge container: - fetching a merged config commit for a swarm deploy, and `hivectl forge reconcile-config` - reseeding a purged `applied` repo -- seeding `proposed` from `agent-configs/` on first deploy +- seeding `applied` from `agent-configs/` on first deploy - cloning and pulling `/knowledge` The hive builds nothing, so no nix process reads the forge as the hive's diff --git a/docs/process/conventions.md b/docs/process/conventions.md index 7e36f4dd..eda3f669 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 -proposed/applied repos and rides along on every fetch (see -`docs/agent-lifecycle/config-changes.md::Two repos per agent`). +the applied repo and rides along on every fetch (see +`docs/agent-lifecycle/config-changes.md::The applied repo 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 2966f16f..354dbb9d 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 — proposed/applied repos, state subvolume, agent-set refresh (`agent_set::refresh`); runs ahead of `Create`. Store-only, no container yet | +| `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 | | `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 | diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 23b324bb..cb6e8c7d 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -87,8 +87,8 @@ claude-creds badge). One action: `PURG3` (wipes state + applied dirs; `POST /api/purge-tombstone/{name}`). ⚠️ **Not only *destroyed* agents.** Nothing records a destroy: `Provision` -writes every definition-side artifact (state subvolume, proposed + applied -repos, meta registration, topology entry) *before* the container exists, +writes every definition-side artifact (state subvolume, applied +repo, 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 8ab31335..8675c133 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -119,7 +119,7 @@ async fn ff_applied_main( } /// Tear down a sub-agent container. By default this is non-destructive to -/// persistent state: the proposed/applied config repos and the Claude +/// 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 /// (no re-login). The ephemeral runtime dir under `/run/hyperhive/agents/` diff --git a/hive-c0re/src/agent_config/topology.rs b/hive-c0re/src/agent_config/topology.rs index 5369936b..a7b713bb 100644 --- a/hive-c0re/src/agent_config/topology.rs +++ b/hive-c0re/src/agent_config/topology.rs @@ -102,19 +102,14 @@ pub fn all_agents_in(topo: &BTreeSet) -> Vec { /// caller. The write is atomic, as a sorted array (`BTreeSet` iterates in /// key order) to keep git diffs minimal. /// -/// `pending` lists agents that have a provisioned proposed config repo -/// but no container yet (provisioned, not yet spawned). They are KEPT -/// (not dropped) so an agent that exists on disk but has never booted is -/// still a name the hive knows about. -/// /// Fails without writing when the existing file can't be read or parsed: -/// rebuilding it from the live set alone would drop every pending name. -pub fn reconcile(agent_names: &[String], pending: &[String]) -> std::io::Result { - reconcile_at(&topology_path(), agent_names, pending) +/// rebuilding it from the live set alone would silently rewrite it. +pub fn reconcile(agent_names: &[String]) -> std::io::Result { + reconcile_at(&topology_path(), agent_names) } -fn reconcile_at(path: &Path, agent_names: &[String], pending: &[String]) -> std::io::Result { - let (next, changed) = apply_reconcile(&read_from(path)?, agent_names, pending); +fn reconcile_at(path: &Path, agent_names: &[String]) -> std::io::Result { + let (next, changed) = apply_reconcile(&read_from(path)?, agent_names); if changed { super::write_map(path, &next)?; } @@ -122,13 +117,12 @@ fn reconcile_at(path: &Path, agent_names: &[String], pending: &[String]) -> std: } /// Pure form of [`reconcile`] for unit tests. Adds missing live agents, -/// drops entries for agents that are neither live nor pending-init, and -/// reports whether anything changed. +/// drops entries for agents that are not live, and reports whether anything +/// changed. #[must_use] pub fn apply_reconcile( current: &BTreeSet, agent_names: &[String], - pending: &[String], ) -> (BTreeSet, bool) { let mut next = current.clone(); let mut changed = false; @@ -137,8 +131,7 @@ pub fn apply_reconcile( changed = true; } } - let known: std::collections::HashSet<&String> = - agent_names.iter().chain(pending.iter()).collect(); + let known: std::collections::HashSet<&String> = agent_names.iter().collect(); next.retain(|name| { let keep = known.contains(name); if !keep { @@ -166,7 +159,7 @@ mod tests { #[test] fn apply_reconcile_adds_missing_live_agent() { let live = vec!["alice".to_owned(), "newbie".to_owned()]; - let (next, changed) = apply_reconcile(&BTreeSet::new(), &live, &[]); + let (next, changed) = apply_reconcile(&BTreeSet::new(), &live); assert!(changed); assert!(next.contains("newbie")); assert!(next.contains("alice")); @@ -175,30 +168,17 @@ mod tests { #[test] fn apply_reconcile_drops_vanished_agent() { let live = vec!["alice".to_owned()]; - let (next, changed) = apply_reconcile(&roster_three(), &live, &[]); + let (next, changed) = apply_reconcile(&roster_three(), &live); assert!(changed); assert!(!next.contains("bob")); assert!(!next.contains("carol")); assert!(next.contains("alice")); } - #[test] - fn apply_reconcile_keeps_pending_init_agent() { - // `dora` was provisioned but has no container yet, so it's absent - // from the live set. It must NOT be dropped. - let mut roster = roster_three(); - roster.insert("dora".to_owned()); - let live = vec!["alice".to_owned(), "bob".to_owned(), "carol".to_owned()]; - let pending = vec!["dora".to_owned()]; - let (next, changed) = apply_reconcile(&roster, &live, &pending); - assert!(!changed, "no change expected: {next:?}"); - assert!(next.contains("dora")); - } - #[test] fn apply_reconcile_is_a_noop_when_already_in_sync() { let live = vec!["alice".to_owned(), "bob".to_owned(), "carol".to_owned()]; - let (next, changed) = apply_reconcile(&roster_three(), &live, &[]); + let (next, changed) = apply_reconcile(&roster_three(), &live); assert!(!changed); assert_eq!(next, roster_three()); } @@ -255,8 +235,7 @@ mod tests { fn reconcile_leaves_a_corrupt_file_untouched() { let (_dir, path) = corrupt_file(); let live = vec!["alice".to_owned(), "bob".to_owned()]; - let err = - reconcile_at(&path, &live, &[]).expect_err("a corrupt file must not be rewritten"); + let err = reconcile_at(&path, &live).expect_err("a corrupt file must not be rewritten"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); assert_eq!(std::fs::read(&path).expect("read"), TRUNCATED.as_bytes()); } @@ -273,7 +252,7 @@ mod tests { let path = dir.path().join(TOPOLOGY_FILE); assert!(read_from(&path).expect("missing file").is_empty()); let live = vec!["alice".to_owned(), "bob".to_owned(), "carol".to_owned()]; - assert!(reconcile_at(&path, &live, &[]).expect("reconcile")); + assert!(reconcile_at(&path, &live).expect("reconcile")); assert_eq!(read_from(&path).expect("read"), roster_three()); let entries: Vec<_> = std::fs::read_dir(dir.path()) .expect("read_dir") diff --git a/hive-c0re/src/agent_set.rs b/hive-c0re/src/agent_set.rs index fe63487d..e8cc44cc 100644 --- a/hive-c0re/src/agent_set.rs +++ b/hive-c0re/src/agent_set.rs @@ -37,11 +37,7 @@ pub async fn refresh(agents: &[hive_meta_flake::AgentSpec]) -> Result<()> { }; remove_stale_ca_files(&dir); - let pending: Vec = crate::coordinator::Coordinator::pending_init_names() - .into_iter() - .map(hive_types::Ident::into_string) - .collect(); - crate::topology::reconcile(&agent_names, &pending) + crate::topology::reconcile(&agent_names) .with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?; // Every agent binds a unix socket (`HIVE_WEB_SOCKET`); the gateway routes diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 2f1ad3cc..51f7c38e 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, - /// Manager-editable proposed config repo. - pub proposed: PathBuf, /// Hive-c0re-authoritative applied config repo. pub applied: PathBuf, /// Claude OAuth credentials (survives purge boundary). @@ -438,7 +436,6 @@ impl Coordinator { .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; Ok(AgentPaths { agent: agent_dir, - proposed: Self::agent_proposed_dir(&name), applied: crate::paths::applied_dir(name.as_str()), claude: Self::agent_claude_dir(&name), notes: Self::agent_notes_dir(&name), @@ -829,17 +826,6 @@ impl Coordinator { crate::paths::agent_runtime_dir(name).join("mcp.sock") } - /// The *proposed* config repo: where a config change lands before it is - /// applied, and what an approved deploy promotes into `applied_dir`. - /// - /// **Not bind-mounted into any container.** An agent that edits a config - /// clones it from the forge itself; `/agents//config` shows the - /// applied (deployed) tree instead — see `config_bind_source` in - /// `lifecycle/host_config.rs`. - pub fn agent_proposed_dir(name: &hive_types::Ident) -> PathBuf { - crate::paths::agent_state_dir(name).join("config") - } - /// Per-agent Claude credentials dir. Bind-mounted RW into the agent /// container at `/root/.claude` so OAuth state survives container /// destroy/recreate. Each agent owns its own token lineage — sharing @@ -950,23 +936,6 @@ impl Coordinator { out.sort(); out } - - /// Agents that have an operator-approved proposed config repo but - /// were never deployed — proposed `.git` exists, applied `.git` does - /// not. Their `topology.json` parent edge (written before the first - /// spawn) must survive `topology::reconcile` until the first - /// apply-commit spawns the container. Distinct from tombstones, - /// which have an applied repo from a prior deploy. - #[must_use] - pub fn pending_init_names() -> Vec { - Self::kept_state_names() - .into_iter() - .filter(|n| { - Self::agent_proposed_dir(n).join(".git").exists() - && !crate::paths::applied_dir(n.as_str()).join(".git").exists() - }) - .collect() - } } /// `push_todo` summary text for a rebuild outcome, for diff --git a/hive-c0re/src/dashboard/tombstones.rs b/hive-c0re/src/dashboard/tombstones.rs index 74caffa4..f6c031ca 100644 --- a/hive-c0re/src/dashboard/tombstones.rs +++ b/hive-c0re/src/dashboard/tombstones.rs @@ -37,7 +37,7 @@ pub struct TombstoneView { /// ones** — a mid-spawn agent (state dir seeded by `Provision`, container not /// yet made by `Create`) is indistinguishable from a tombstone here, because /// *nothing records a destroy*. Every definition-side artifact — state -/// subvolume, proposed + applied repos, `deployed/0`, meta registration, +/// subvolume, applied repo, `deployed/0`, meta registration, /// topology entry — is written before the container exists and survives /// `lifecycle::destroy`, which removes the container and leaves the rest. /// diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index 1a063e98..fbf73341 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -12,7 +12,7 @@ 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::{clone_config_into_proposed, fetch_config_main_into_applied}; +pub use repos::fetch_config_main_into_applied; use std::future::IntoFuture; use std::sync::OnceLock; diff --git a/hive-c0re/src/forge/repos.rs b/hive-c0re/src/forge/repos.rs index df288a7b..b2500b48 100644 --- a/hive-c0re/src/forge/repos.rs +++ b/hive-c0re/src/forge/repos.rs @@ -1,26 +1,23 @@ -//! Repo plumbing on the local Forgejo: reseeding an agent's `applied` and -//! `proposed` repos from `agent-configs/`. The typed API-client -//! constructor + org-name constants live in the module root (`super`). - -use std::path::Path; +//! 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 super::{CONFIG_ORG, forge_git_url, reader_token}; -/// Reseed a revived agent's `applied` repo — missing its `.git` entirely, -/// e.g. after `destroy --purge` — by fetching `agent-configs/`'s -/// `main` from the forge. Unlike `proposed` (seeded once at first spawn and -/// never touched again — see `lifecycle::setup_proposed`'s own doc -/// comment), forge `main` advances with every operator-merged config PR, so -/// this reflects the last merged config rather than a stale creation-time -/// snapshot. A config deployed through hive-c0re without a forge merge never -/// reaches it. +/// 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), or -/// any git step fails — callers fall back to a less-current source or the -/// original hard error. Only `true` once `applied` is -/// actually seeded and tagged `deployed/0`, mirroring first-spawn's own -/// `setup_applied` seed shape exactly. +/// 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 no `.git` behind, 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; @@ -29,111 +26,60 @@ pub async fn fetch_config_main_into_applied(name: &str) -> bool { if applied.join(".git").exists() { return false; } - 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) => { - tracing::warn!( - %name, - stderr = %String::from_utf8_lossy(&o.stderr).trim(), - "forge: applied reseed fetch failed" - ); + 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; } - Err(e) => { - tracing::warn!(%name, error = ?e, "forge: applied reseed fetch failed"); + 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 } - 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: reseeded applied repo from agent-configs main"); - true -} - -/// Seed an agent's `proposed` repo from `agent-configs/` on the forge. -/// -/// When the swarm creates an agent it writes that agent's config to the forge -/// before any hive is told to deploy it, so the hive's job here is to **take** -/// that config rather than author a second one beside it. The local template -/// is the fallback, not the default. -/// -/// Best-effort like [`fetch_config_main_into_applied`], and `false` is an -/// ordinary answer rather than an error: there is no [`reader_token`], the -/// forge is unreachable, or `agent-configs/` has no `main` — the last of -/// which is the *normal* case right after `swarm-controller` creates the -/// agent, before it has pushed anything into the repo it just made. -/// -/// `clone` rather than `init` + `fetch`: git removes a directory it created -/// when the clone fails, and cloning into an existing empty dir leaves no -/// `.git` behind either — so the caller's "does this repo already exist" -/// check reads exactly the same before and after a failed attempt. -pub async fn clone_config_into_proposed(dir: &Path, name: &str) -> bool { - let Some(reader) = reader_token().await else { - return false; - }; - let url = forge_git_url(&format!("{CONFIG_ORG}/{name}")); - // `--branch main` fails outright against a repo with no commits, so an - // empty repo reads as "nothing to take" instead of cloning to an unborn - // HEAD that would then look like a seeded checkout. - let out = crate::lifecycle::git_command_authed(&reader.auth_header()) - .args([ - "clone", - "--branch", - "main", - &url, - &dir.display().to_string(), - ]) - .output() - .await; - match out { - Ok(o) if o.status.success() => { - tracing::info!(%name, "forge: seeded proposed repo from agent-configs main"); - true - } - 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. - // Logged at all because "where did this agent's config come - // from" is worth answering from the journal, and proposed is - // seeded exactly once. - tracing::info!( - %name, - stderr = %String::from_utf8_lossy(&o.stderr).trim(), - "forge: no config to clone for this agent; seeding the template" - ); - false - } - Err(e) => { - tracing::warn!(%name, error = ?e, "forge: proposed clone failed to run"); - false - } + .await; + if !seeded { + let _ = std::fs::remove_dir_all(applied.join(".git")); } + seeded } diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index 309e3e85..7ea2bf29 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -731,7 +731,7 @@ async fn run_rebuild_bookkeeping( Ok(()) } -/// First-spawn pre-create provisioning: proposed/applied repos, state +/// First-spawn pre-create provisioning: the applied repo, 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); diff --git a/hive-c0re/src/job_queue/templates.rs b/hive-c0re/src/job_queue/templates.rs index 7e4bef86..5f5518e5 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` (proposed/applied repos, state subvolume, agent-set registration) +/// `Provision` (applied repo, 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 c2cbb406..62d14b65 100644 --- a/hive-c0re/src/lifecycle/git.rs +++ b/hive-c0re/src/lifecycle/git.rs @@ -1,4 +1,4 @@ -//! Git shellout helpers for the per-agent proposed/applied repos: run +//! 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. @@ -71,25 +71,6 @@ async fn drain(pipe: Option) -> std::io::Result const GIT_NAME: &str = "c0re"; const GIT_EMAIL: &str = "c0re@hyperhive.local"; -/// Return the SHA of the root (oldest, no-parent) commit in a repo. -/// Used to seed the applied repo at the template baseline rather than at -/// `main`, so `deployed/0` records the template, not the manager's first commit. -pub(super) async fn git_root_commit(dir: &Path) -> Result { - let out = git_command() - .current_dir(dir) - .args(["rev-list", "--max-parents=0", "HEAD"]) - .output() - .await - .with_context(|| format!("git rev-list --max-parents=0 HEAD in {}", dir.display()))?; - if !out.status.success() { - anyhow::bail!( - "git rev-list --max-parents=0 failed: {}", - String::from_utf8_lossy(&out.stderr).trim() - ); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned()) -} - pub(super) async fn git_commit(dir: &Path, message: &str) -> Result<()> { git( dir, diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index 637d26bb..15215ece 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -338,7 +338,7 @@ fn config_bind_source(name: &str) -> PathBuf { /// /// ⚠️ The config-repo seeding hive-c0re does at spawn is **not** affected by /// the `config` flag and must not be read as a reason to widen it: that runs -/// as hive-c0re against the host path (see `lifecycle::setup_proposed`), and +/// as hive-c0re against the host path (see `lifecycle::setup_applied`), and /// `read_only` on a bind constrains writers *inside* the container only. fn bind_child_agent_dirs(child: &str, binds: &mut Vec) { let Ok(child) = hive_types::Ident::parse(child) else { diff --git a/hive-c0re/src/lifecycle/mod.rs b/hive-c0re/src/lifecycle/mod.rs index f2654c70..47b4ff8a 100644 --- a/hive-c0re/src/lifecycle/mod.rs +++ b/hive-c0re/src/lifecycle/mod.rs @@ -18,7 +18,7 @@ pub use git::{ pub use host_config::write_dropins; pub use setup::{ ensure_agent_state_subvolume, ensure_claude_dir, ensure_state_dir, initial_flake_nix, - setup_applied, setup_proposed, + setup_applied, }; use anyhow::{Context, Result, bail}; @@ -205,8 +205,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 proposed/applied -/// repos + state subvolume + claude/notes dirs, then register the new +/// node. Fail fast on a port collision, set up the applied +/// repo + 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. @@ -222,8 +222,7 @@ pub async fn provision_container(name: &str, paths: &AgentPaths) -> Result<()> { // repo, and the build takes it from the archetype. crate::archetypes::refresh(name).await?; if crate::archetypes::of(name)?.is_none() { - setup_proposed(&paths.proposed, name).await?; - setup_applied(&paths.applied, Some(&paths.proposed), name).await?; + setup_applied(&paths.applied, name).await?; } ensure_agent_state_subvolume(name).await?; ensure_claude_dir(&paths.claude)?; @@ -250,12 +249,9 @@ pub async fn create_only(name: &str, toplevel: &str) -> Result<()> { /// 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 fallback to -/// `proposed`**: that repo is seeded once at first spawn and never touched -/// again (`setup_proposed`'s own doc comment), so it can be arbitrarily -/// stale for a long-lived agent — restoring from it would silently revive -/// the *wrong* config rather than recovering the right one, which is worse -/// than the clear bail this replaces. +/// 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. pub async fn prepare_rebuild_dirs(name: &str, paths: &AgentPaths) -> Result<()> { validate(name)?; if let Some(other) = port_collision(name).await? { @@ -276,7 +272,7 @@ pub async fn prepare_rebuild_dirs(name: &str, paths: &AgentPaths) -> Result<()> paths.applied.display() ); } - setup_applied(&paths.applied, None, name).await?; + setup_applied(&paths.applied, name).await?; } ensure_agent_state_subvolume(name).await?; ensure_claude_dir(&paths.claude)?; diff --git a/hive-c0re/src/lifecycle/setup.rs b/hive-c0re/src/lifecycle/setup.rs index ba6fae37..9243c4f2 100644 --- a/hive-c0re/src/lifecycle/setup.rs +++ b/hive-c0re/src/lifecycle/setup.rs @@ -1,159 +1,53 @@ -//! First-spawn provisioning: seed the agent's proposed config repo and -//! the hive-c0re-owned applied repo, and ensure the per-agent state / -//! claude-credentials dirs (btrfs subvolume when available) exist. +//! 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 either repo gets from here. Later config -//! changes arrive as forge PRs authored in a clone — nothing edits a -//! working tree in place. +//! 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. use std::path::Path; use anyhow::{Context, Result, bail}; -use super::git::{ - git, git_command, git_commit, git_read_tree_reset, git_rev_parse, git_root_commit, git_tag, -}; +use super::git::{git, git_commit, git_rev_parse, git_tag}; -/// Initialize an agent's config repo, preferring the one the forge -/// already has. `agent-configs/` is authored by whoever created -/// the agent — at swarm level that is the controller, which writes the -/// config before any hive is told to deploy it — so this **clones** -/// when there is something to clone and seeds 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. +/// 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 what this still falls back to when -/// there's genuinely nothing to clone yet — `swarm-controller` creates -/// `agent-configs/` before telling a hive to deploy, but hasn't -/// necessarily pushed `main` to it yet, or the forge is unreachable, or -/// this hive has no forge read token. +/// 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 whole of hive-c0re's write.** Changes to the repo -/// arrive as PRs from a clone, via the forge, like any other code -/// change — nothing edits a working tree in place, here or in the -/// agent's bind-mounted `config/` (which is a read-only copy). +/// 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. /// -/// Touched by hive-c0re only on first spawn — never again — so a -/// hive-c0re commit or working-tree reset can't surprise anyone -/// holding a clone. -pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> { - let fresh = !proposed_dir.join(".git").exists(); - if fresh { - std::fs::create_dir_all(proposed_dir) - .with_context(|| format!("create {}", proposed_dir.display()))?; - if !crate::forge::clone_config_into_proposed(proposed_dir, name).await { - seed_template(proposed_dir, name).await?; - } - } - // Idempotently wire the `applied` remote — purely for the - // manager's ergonomics. The URL is the path inside the manager - // container (`/applied//.git`), where the RO bind in - // `set_nspawn_flags` makes it real. hive-c0re itself never - // dereferences this remote. - ensure_applied_remote(proposed_dir, name).await -} - -/// 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_proposed`]: reached only when the forge has -/// no config for this agent to take. -async fn seed_template(proposed_dir: &Path, name: &str) -> Result<()> { - let agent_path = proposed_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 = proposed_dir.join("flake.nix"); - if !flake_path.exists() { - std::fs::write(&flake_path, initial_flake_nix()) - .with_context(|| format!("write {}", flake_path.display()))?; - } - git(proposed_dir, &["init", "--initial-branch=main"]).await?; - git(proposed_dir, &["add", "agent.nix", "flake.nix"]).await?; - git_commit(proposed_dir, "hive-c0re init").await -} - -async fn ensure_applied_remote(proposed_dir: &Path, name: &str) -> Result<()> { - let want = format!("/applied/{name}/.git"); - let existing = git_command() - .current_dir(proposed_dir) - .args(["remote", "get-url", "applied"]) - .output() - .await - .with_context(|| format!("git remote get-url applied in {}", proposed_dir.display()))?; - if existing.status.success() { - let current = String::from_utf8_lossy(&existing.stdout).trim().to_owned(); - if current == want { - return Ok(()); - } - // URL drifted (path scheme changed, etc.) — re-point it. - return git(proposed_dir, &["remote", "set-url", "applied", &want]).await; - } - git(proposed_dir, &["remote", "add", "applied", &want]).await -} - -/// Set up the applied repo. First-spawn only: init the repo, pull -/// proposed's initial commit in via `git fetch`, tag it `deployed/0`. -/// This is the *only* time hive-c0re reads from `proposed` for an -/// agent — subsequent config changes are fetched from the forge's `main` -/// (see `actions::advance_applied_to_rev`). -/// -/// `proposed_dir` is `None` on rebuild paths where the repo already -/// exists — we just verify it's the right shape and bail otherwise. -/// Unlike the pre-overhaul code path, `flake.nix` is no longer -/// regenerated at the host level: it's tracked in proposed (seeded by -/// `setup_proposed`) and rides along on every fetch. -pub async fn setup_applied( - applied_dir: &Path, - proposed_dir: Option<&Path>, - name: &str, -) -> Result<()> { +/// 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() { - let Some(proposed) = proposed_dir else { - bail!( - "applied repo at {} is missing its .git directory; \ - cannot rebuild without a proposed source to seed from. \ - destroy --purge and re-spawn this agent.", - applied_dir.display() - ); - }; - git(applied_dir, &["init", "--initial-branch=main"]).await?; - let proposed_str = proposed.display().to_string(); - // Seed the applied repo at the root (template) commit of proposed, - // not at `main`, so `deployed/0` is the template baseline rather - // than whatever the manager may have already committed on top. - let root_sha = git_root_commit(proposed).await?; - git( - applied_dir, - // --update-head-ok lets us fetch into refs/heads/main while - // HEAD still points there. git's default safeguard refuses - // to avoid index/working-tree desync, but the working tree - // is empty (we just `init`'d) and we read-tree-reset right - // after, so the safeguard is moot here. - &[ - "fetch", - "--no-tags", - "--update-head-ok", - &proposed_str, - &format!("{root_sha}:refs/heads/main"), - ], - ) - .await?; - git_read_tree_reset(applied_dir, "refs/heads/main").await?; + 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, + // 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 @@ -168,6 +62,30 @@ pub async fn setup_applied( 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 +} + /// 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 /// valid session; credential files inside (`.credentials.json` etc.) are 0600 so diff --git a/hive-c0re/src/lifecycle/tests.rs b/hive-c0re/src/lifecycle/tests.rs index 56632b51..b4a5bb40 100644 --- a/hive-c0re/src/lifecycle/tests.rs +++ b/hive-c0re/src/lifecycle/tests.rs @@ -3,27 +3,31 @@ use super::*; -/// Regression test: `setup_proposed` must seed both agent.nix and flake.nix -/// in the initial commit. Before commit 5b5a93e flake.nix was missing from -/// the scaffold, requiring manual creation (seen with the damocles agent). +/// 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 clone from in a test -/// (`forge::is_present` needs the priv socket), so `setup_proposed` falls -/// through to `seed_template` — which is the arm this asserts about. +/// 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_proposed_seeds_flake_nix() { +async fn setup_applied_seeds_flake_nix() { let dir = tempfile::tempdir().expect("tempdir"); - let proposed = dir.path().join("proposed"); - setup_proposed(&proposed, "test-agent") + let applied = dir.path().join("applied"); + setup_applied(&applied, "test-agent") .await - .expect("setup_proposed"); + .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!(proposed.join("agent.nix").exists(), "agent.nix missing"); - assert!(proposed.join("flake.nix").exists(), "flake.nix missing"); + 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(proposed.join("flake.nix")).unwrap(); + let flake = std::fs::read_to_string(applied.join("flake.nix")).unwrap(); assert!( flake.contains("nixosModules.default"), "flake.nix does not export nixosModules.default" @@ -31,7 +35,7 @@ async fn setup_proposed_seeds_flake_nix() { // Both files must be tracked in the initial git commit. let out = git_command() - .current_dir(&proposed) + .current_dir(&applied) .args(["show", "--name-only", "--format=", "HEAD"]) .output() .await @@ -128,22 +132,22 @@ fn network_isolation_errors_name_the_offending_variable() { ); } -/// `setup_proposed` is idempotent: calling it on an existing repo is a -/// no-op (the fresh guard skips all writes). +/// `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_proposed_idempotent() { +async fn setup_applied_idempotent() { let dir = tempfile::tempdir().expect("tempdir"); - let proposed = dir.path().join("proposed"); - setup_proposed(&proposed, "test-agent") + 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_proposed(&proposed, "test-agent") + setup_applied(&applied, "test-agent") .await .expect("second call"); // Still one commit. let out = git_command() - .current_dir(&proposed) + .current_dir(&applied) .args(["rev-list", "--count", "HEAD"]) .output() .await @@ -158,10 +162,10 @@ async fn setup_proposed_idempotent() { /// 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("proposed"); - setup_proposed(&repo, "test-agent") + let repo = dir.path().join("applied"); + setup_applied(&repo, "test-agent") .await - .expect("setup_proposed"); + .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"); diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 231900f9..31c425b1 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -241,8 +241,7 @@ async fn cmd_serve( let dashboard_port = env.dashboard_port; let coord = Arc::new(Coordinator::open(&db, env, model_prices, build_slots)?); // Idempotent pre-flight: rewrite pre-meta-layout applied - // repos, ensure proposed repos carry the `applied` - // remote, refresh the files that follow the agent set. + // repos, refresh the files that follow the agent set. // Runs before the boot reconcile. if let Err(e) = migrate::run().await { tracing::warn!(error = ?e, "startup migration failed"); diff --git a/hive-c0re/src/migrate.rs b/hive-c0re/src/migrate.rs index 04f04d9c..9920d498 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, applied + proposed repos, the agent-set files, plus the +//! harness files, the 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. @@ -80,10 +80,10 @@ pub async fn run() -> Result<()> { Err(e) => tracing::warn!(error = ?e, "migration: removing the legacy meta .git failed"), } - // Phase 1 + 2: per-agent applied + proposed. - tracing::debug!("migration: phase 1+2 (applied + proposed repos)"); - // An archetype agent has neither repo, and seeding one here would hand it - // a config it is not built from. + // 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() @@ -92,26 +92,10 @@ pub async fn run() -> Result<()> { if archetypes.contains_key(name.as_str()) { continue; } - tracing::debug!(%name, "migration: applied+proposed"); + 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"); } - let proposed_dir = Coordinator::agent_proposed_dir(name); - match remove_meta_remote(&proposed_dir).await { - Ok(true) => tracing::info!(%name, "migration: removed the legacy meta remote"), - Ok(false) => {} - Err(e) => { - tracing::warn!(%name, error = ?e, "migration: removing the meta remote failed"); - } - } - let proposed = lifecycle::setup_proposed(&proposed_dir, name.as_str()); - match tokio::time::timeout(GIT_TIMEOUT, proposed).await { - Ok(Err(e)) => tracing::warn!(%name, error = ?e, "migration: setup_proposed failed"), - Err(_) => { - tracing::warn!(%name, timeout = ?GIT_TIMEOUT, "migration: setup_proposed timed out — skipping"); - } - Ok(Ok(())) => {} - } } // Phase 3: the files that follow the agent set. @@ -190,19 +174,6 @@ fn remove_core_token(state: &Path) -> Result { } } -/// Remove the `meta` remote from the proposed repo at `dir`. `true` when it -/// was there; a missing remote, or a `dir` that is no repo, is `false`. -async fn remove_meta_remote(dir: &Path) -> Result { - let mut cmd = lifecycle::git_command(); - cmd.current_dir(dir).args(["remote", "get-url", "meta"]); - let probe = output_with_timeout(cmd, GIT_TIMEOUT, "git remote get-url meta").await?; - if !probe.status.success() { - return Ok(false); - } - raw_git(dir, &["remote", "remove", "meta"]).await?; - Ok(true) -} - async fn enumerate_agents() -> Result> { let containers = lifecycle::list().await?; Ok(containers @@ -329,35 +300,6 @@ mod tests { assert!(dir.path().join("topology.json").exists()); } - #[tokio::test] - async fn meta_remote_goes_and_the_others_stay() { - let dir = tempfile::tempdir().expect("tempdir"); - raw_git(dir.path(), &["init", "-q", "--initial-branch=main"]) - .await - .expect("init"); - for (name, url) in [ - ("meta", "http://forge/core/meta.git"), - ("applied", "/applied/x/.git"), - ] { - raw_git(dir.path(), &["remote", "add", name, url]) - .await - .expect("add remote"); - } - - assert!(remove_meta_remote(dir.path()).await.expect("first")); - let remotes = lifecycle::git_command() - .current_dir(dir.path()) - .arg("remote") - .output() - .await - .expect("list"); - assert_eq!(String::from_utf8_lossy(&remotes.stdout).trim(), "applied"); - assert!( - !remove_meta_remote(dir.path()).await.expect("second"), - "no-op" - ); - } - #[test] fn core_token_goes_once_and_its_neighbours_stay() { let dir = tempfile::tempdir().expect("tempdir"); @@ -370,12 +312,6 @@ mod tests { assert!(!remove_core_token(dir.path()).expect("second"), "no-op"); } - #[tokio::test] - async fn a_dir_that_is_no_repo_has_no_meta_remote() { - let dir = tempfile::tempdir().expect("tempdir"); - assert!(!remove_meta_remote(dir.path()).await.expect("probe")); - } - #[tokio::test] async fn unreadable_flake_is_left_untouched() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/hive-c0re/src/paths.rs b/hive-c0re/src/paths.rs index 11aed6b1..4a2dfbc4 100644 --- a/hive-c0re/src/paths.rs +++ b/hive-c0re/src/paths.rs @@ -114,8 +114,7 @@ pub fn agents_root() -> PathBuf { PathBuf::from(AGENTS_ROOT) } -/// `applied/` — per-agent *applied* (deployed) config repos + rev markers, -/// distinct from the proposed configs under `agents//config`. +/// `applied/` — per-agent *applied* (deployed) config repos + rev markers. // nix: read by hive-c0re only, but paired with `agents/` in the deploy flow. #[must_use] pub fn applied_root() -> PathBuf { diff --git a/hive-sh4re/src/container.rs b/hive-sh4re/src/container.rs index d35f87a7..a6c5b7fb 100644 --- a/hive-sh4re/src/container.rs +++ b/hive-sh4re/src/container.rs @@ -35,7 +35,7 @@ pub struct AgentStatusRow { #[serde(default)] pub failed: bool, /// Config commit is pending — the locked rev differs from the - /// agent's proposed/applied config (a rebuild would change it). + /// agent's applied config (a rebuild would change it). pub needs_update: bool, /// The agent has no live claude session and is parked waiting for /// the operator's re-auth flow. diff --git a/nix/agent-modules/default.nix b/nix/agent-modules/default.nix index 9682b8b8..5a8e4100 100644 --- a/nix/agent-modules/default.nix +++ b/nix/agent-modules/default.nix @@ -287,7 +287,7 @@ in }; # Git is needed by claude's Bash tool (for the agent <-> manager config - # request flow) and by hive-c0re's own setup_applied / setup_proposed. + # request flow) and by hive-c0re's own setup_applied. # The per-agent `applied//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`. diff --git a/swarm-controller/src/forge.rs b/swarm-controller/src/forge.rs index 062938cd..503f306b 100644 --- a/swarm-controller/src/forge.rs +++ b/swarm-controller/src/forge.rs @@ -793,11 +793,11 @@ 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 clones this repo - /// (`hive-c0re::forge::clone_config_into_proposed`) rather than writing - /// the same files again locally; the byte-identical template in + /// 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 clone. + /// repo exists here to take. /// /// Idempotent by construction rather than by catching a conflict: an /// unconditional `repo_change_files` against an already-seeded repo