Watch
0
0
Fork
You've already forked hyperhive
0

drop the proposed config repo: applied seeds from forge main, and the dead pending-init topology list goes with it

This commit is contained in:
damocles 2026-11-01 14:25:46 +01:00 • committed by mara
commit 25295fd33f
26 changed files with 225 additions and 520 deletions

View file

@ -102,7 +102,7 @@ body strings before calling it.
`HostRequest::Destroy { name, purge }` is the lifecycle tear-down. `HostRequest::Destroy { name, purge }` is the lifecycle tear-down.
Stops + removes the nspawn container and drops the systemd drop-in. Stops + removes the nspawn container and drops the systemd drop-in.
Persistent state (proposed/applied repos, claude credentials, `/state/` Persistent state (the applied repo, claude credentials, `/state/`
notes) is notes) is
**kept by default** — recreating the agent with the same name **kept by default** — recreating the agent with the same name
reuses prior config + login. With `purge = true` the agent's reuses prior 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 reconcile ([below](#autoupdate-on-startup)) and the dashboard's update chip
read them. read them.
## Two repos per agent ## The applied repo per agent
``` ```
/var/lib/hyperhive/agents/<name>/config/ proposed — parent mount is RO /var/lib/hyperhive/applied/<name>/ applied - core-only
└── <anything> # 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/<name>/ applied — core-only
├── .git/ # tag-rich history ├── .git/ # tag-rich history
├── flake.nix # tracked, fixed ├── flake.nix # tracked, fixed
│ # boilerplate exporting │ # boilerplate exporting
@ -207,16 +197,18 @@ the swarm builder built the agent from the updated repo (the
├── agent.nix # working tree of main ├── agent.nix # working tree of main
└── <other committed files> # also tracked └── <other committed files> # also tracked
/var/lib/hyperhive/meta/ plain hive state — core /var/lib/hyperhive/meta/ plain hive state - core
├── topology.json # the live agent set ├── topology.json # the live agent set
└── swarm-resource-limits.json # the swarm's per-agent caps └── swarm-resource-limits.json # the swarm's per-agent caps
``` ```
Why two physical repos: the submitting agent's `/agents/<n>/config/` is hive-c0re seeds the applied repo at first spawn from the forge's
RW — a buggy or hostile agent can `git clean -fdx` its own `agent-configs/<name>` `main`, or from a template when the forge has none yet,
proposed tree. The applied repo is never bind-mounted (except and fast-forwards it to each merged config commit that deploys. An agent never
the read-only `.git` exposure described below) so a destructive edits it: the agent sees it read-only at `/agents/<name>/config`, so it can
move inside the container can't reach it. read what governs it, while a config change is a PR against its forge repo.
Older hives also kept a *proposed* clone at `agents/<name>/config`. Nothing
reads it any more, and you can delete it.
The container's `--flake` ref is `internal/meta` (see "Meta flake" above). The container's `--flake` ref is `internal/meta` (see "Meta flake" above).
The agent's own `applied/<n>/flake.nix` The agent's own `applied/<n>/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. `agent-configs` Forgejo org, created and seeded by swarm-controller.
hive-c0re pushes nothing to it: the applied repo's tags (`deployed/0`) hive-c0re pushes nothing to it: the applied repo's tags (`deployed/0`)
exist only locally, and forge `main` advances only by an operator's merge. exist only locally, and forge `main` advances only by an operator's merge.
hive-c0re reads from it — reseeding a purged `applied` hive-c0re reads from it - reseeding a purged `applied`, catching `applied` up, and
(`forge::fetch_config_main_into_applied`), catching `applied` up, and and seeding `applied` at first spawn (`forge::fetch_config_main_into_applied`).
seeding `proposed` at first spawn (`forge::clone_config_into_proposed`).
A legacy agent spawned by the hive alone has no swarm-seeded repo; if its A legacy agent spawned by the hive alone has no swarm-seeded repo; if its
`agent-configs/<name>` has no `main`, a first spawn falls back to the local `agent-configs/<name>` 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 `docs/process/conventions.md#permissions`) get additional host-side bind
mounts via `set_nspawn_flags`: mounts via `set_nspawn_flags`:
- `/var/lib/hyperhive/agents/` → `/agents/` (RW) — **every** agent's - `/var/lib/hyperhive/agents/<n>/state` → `/agents/<n>/state` (RW) for
proposed repo, not just direct children. The permission means "may **every** agent, and `/agents/<n>/config` (RO, the applied repo). The
manage any agent", so the mounted set is every agent. permission means "may manage any agent," so the mounted set is every agent.
- `/var/lib/hyperhive/applied/` → `/applied/` (RO) — every agent's - `/var/lib/hyperhive/applied/` → `/applied/` (RO) — every agent's
authoritative applied repo, including `.git`. authoritative applied repo, including `.git`.
- `/var/lib/hyperhive/meta/` → `/meta/` (RO) — the hive's plain state - `/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; `manage_root_agent` into her key once at start, so she keeps her mounts;
it never touches a key that's already set. it never touches a key that's already set.
Each proposed repo (`/agents/<n>/config/`) is pre-configured With the full `/applied` mount, an agent can read what hive-c0re deployed,
with `applied` as a git remote pointing at failed or denied for any agent:
`/applied/<n>/.git`. Useful incantations from inside an agent with
the full `/applied` mount:
```sh ```sh
git -C /agents/<n>/config fetch applied git -C /applied/<n> log main --oneline
git -C /agents/<n>/config log applied/main --oneline git -C /applied/<n> show refs/tags/deployed/0 # the seed commit
git -C /agents/<n>/config show applied/refs/tags/deployed/0 # the seed commit git -C /applied/<n> show refs/tags/denied/<id> # body = operator note
git -C /agents/<n>/config show applied/refs/tags/denied/<id> # body = operator note
git -C /agents/<n>/config rebase applied/main # base in-flight work on what's deployed
``` ```
The RO binds block push at the kernel level — git plumbing inside the 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 `deployed/0` on `main` once. Non-destructive — it doesn't touch live
containers, state dirs, or claude creds. containers, state dirs, or claude creds.
- **Agent files**: rewrites each `applied/<n>/flake.nix` to the - **Agent files**: rewrites each `applied/<n>/flake.nix` to the
module-only boilerplate, wires the `applied` remote in each proposed module-only boilerplate and refreshes the files that follow the agent set
repo, and refreshes the files that follow the agent set
(`agent_set::refresh`) from the current agent list. Set (`agent_set::refresh`) from the current agent list. Set
`HIVE_SKIP_META_MIGRATION=1` on the service to defer this phase. `HIVE_SKIP_META_MIGRATION=1` on the service to defer this phase.
- **Legacy meta repo**: deletes the leftover `.git` in - **Legacy meta repo**: deletes the leftover `.git` in
`/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` 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. stay. The forge-side `core/meta` repo and `core` account aren't touched.
No state loss in either migration: claude creds, `/state/` notes, the No state loss in either migration: claude creds, `/state/` notes, the
events DB, and 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. agent keeps its session; sub-agents stay logged in.
## The root/bootstrap container ## 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 sub-agents**. Nothing creates it at hive boot: you create ruth from the
swarm dashboard like any agent, and an existing ruth container starts with swarm dashboard like any agent, and an existing ruth container starts with
the hive like every other. The root agent's flake lives at the hive like every other. The root agent's flake lives at
`/var/lib/hyperhive/applied/ruth/`; its proposed config at `/var/lib/hyperhive/applied/ruth/`. The root agent can read its own
`/var/lib/hyperhive/agents/ruth/config/`. The root agent can edit its own
`agent.nix` (visible inside the container at `/agents/ruth/config/`) `agent.nix` (visible inside the container at `/agents/ruth/config/`)
and open a config PR on `agent-configs/ruth` for operator review, and open a config PR on `agent-configs/ruth` for operator review,
same as any other agent. same as any other agent.
@ -368,8 +353,7 @@ Differences from sub-agents:
FNV-1a hash as every other agent (8100..8999 range). FNV-1a hash as every other agent (8100..8999 range).
- The root agent holds `manage_root_agent` (the swarm UI's setup - 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: 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 `/var/lib/hyperhive/agents` → `/agents` (RW) for every agent's state,
per-agent proposed repos,
and `/var/lib/hyperhive/applied` → `/applied` (RO) so the root agent and `/var/lib/hyperhive/applied` → `/applied` (RO) so the root agent
can `git fetch` deployed/failed/denied tags from any agent's can `git fetch` deployed/failed/denied tags from any agent's
authoritative applied repo (see "Root-agent view of applied" below). authoritative applied repo (see "Root-agent view of applied" below).

View file

@ -254,11 +254,9 @@ than an accident of storage.
Under `/var/lib/hyperhive/agents/<name>/`: Under `/var/lib/hyperhive/agents/<name>/`:
- `config/` — the proposed nix repo (root-agent-editable). Bind-mounted - `config/` — unused. Older hives kept a proposed nix repo here; nothing
**read-only** to `/agents/<name>/config` inside the sub-agent's own reads it any more, and you can delete it. The agent's config mount at
container so the agent can inspect what defines it and request `/agents/<name>/config` is the applied repo, read-only.
precise changes from the root agent; RW into the root agent via the
`/agents` tree bind.
- `claude/` — claude OAuth credentials, bind-mounted RW to - `claude/` — claude OAuth credentials, bind-mounted RW to
`/home/<name>/.claude` inside the container. `/home/<name>/.claude` inside the container.
- `state/` — durable notes and `hyperhive-harness.json`. Bind-mounted - `state/` — durable notes and `hyperhive-harness.json`. Bind-mounted
@ -487,7 +485,7 @@ boundary a convention rather than a permission.
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->
⚠️ Don't confuse it with the config-repo seeding hive-c0re does at ⚠️ 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 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 — constrains writers *inside* a container only. The two are unrelated —
conflating them can lead you to reason your way into thinking this conflating them can lead you to reason your way into thinking this

View file

@ -66,7 +66,7 @@ forge container:
- fetching a merged config commit for a swarm deploy, and - fetching a merged config commit for a swarm deploy, and
`hivectl forge reconcile-config` `hivectl forge reconcile-config`
- reseeding a purged `applied` repo - reseeding a purged `applied` repo
- seeding `proposed` from `agent-configs/<agent>` on first deploy - seeding `applied` from `agent-configs/<agent>` on first deploy
- cloning and pulling `/knowledge` - cloning and pulling `/knowledge`
The hive builds nothing, so no nix process reads the forge as the hive's The hive builds nothing, so no nix process reads the forge as the hive's

View file

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

View file

@ -55,7 +55,7 @@ Cheap — no build slot:
| Node | Behavior | | Node | Behavior |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MetaSync` | the rebuild's preamble — rebuild-dir prep, the agent-set refresh (`agent_set::refresh`), and the `internal/meta` commit `Prebuild` and `Swap` expect the swarm's closure to be built from, resolved once and recorded; fails when the repo doesn't pin the agent's current config. Declares no resources | | `MetaSync` | the rebuild's preamble — rebuild-dir prep, the agent-set refresh (`agent_set::refresh`), and the `internal/meta` commit `Prebuild` and `Swap` expect the swarm's closure to be built from, resolved once and recorded; fails when the repo doesn't pin the agent's current config. Declares no resources |
| `Provision` | first-spawn pre-create provisioning — 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 | | `Reconcile` | idempotent power converge: read `wanted` (below) + observed state; start if `Up` & down (cold-start fallback included), stop if `Offline` & up, else noop |
| `Start` | mechanical container start — runtime dir + drop-ins, `start_with_fallback`, MCP listener registration, the manager kick. Fanned out by a `Reconcile` that observed `wanted = Up` and the container down | | `Start` | mechanical container start — runtime dir + drop-ins, `start_with_fallback`, MCP listener registration, the manager kick. Fanned out by a `Reconcile` that observed `wanted = Up` and the container down |
| `Stop` | mechanical container stop — `nixos-container` kill, MCP listener unregister, the `Killed` manager notify. Fanned out by a `Reconcile` that observed `wanted = Offline` and up | | `Stop` | mechanical container stop — `nixos-container` kill, MCP listener unregister, the `Killed` manager notify. Fanned out by a `Reconcile` that observed `wanted = Offline` and up |

View file

@ -87,8 +87,8 @@ claude-creds badge). One action: `PURG3` (wipes state + applied
dirs; `POST /api/purge-tombstone/{name}`). dirs; `POST /api/purge-tombstone/{name}`).
⚠️ **Not only *destroyed* agents.** Nothing records a destroy: `Provision` ⚠️ **Not only *destroyed* agents.** Nothing records a destroy: `Provision`
writes every definition-side artifact (state subvolume, proposed + applied writes every definition-side artifact (state subvolume, applied
repos, meta registration, topology entry) *before* the container exists, repo, meta registration, topology entry) *before* the container exists,
and that artifact set survives `lifecycle::destroy`. An agent and that artifact set survives `lifecycle::destroy`. An agent
part-way through a spawn is byte-identical on disk to a tombstone, and part-way through a spawn is byte-identical on disk to a tombstone, and
the pane lists both. The pane carries a standing warning to that effect, and the pane lists both. The pane carries a standing warning to that effect, and

View file

@ -119,7 +119,7 @@ async fn ff_applied_main(
} }
/// Tear down a sub-agent container. By default this is non-destructive to /// Tear down a sub-agent container. By default this is non-destructive to
/// persistent state: the proposed/applied config repos and the Claude /// persistent state: the applied config repo and the Claude
/// credentials dir under `/var/lib/hyperhive/{agents,applied}/<name>/` are /// credentials dir under `/var/lib/hyperhive/{agents,applied}/<name>/` are
/// kept, so recreating an agent of the same name reuses prior config + creds /// kept, so recreating an agent of the same name reuses prior config + creds
/// (no re-login). The ephemeral runtime dir under `/run/hyperhive/agents/` /// (no re-login). The ephemeral runtime dir under `/run/hyperhive/agents/`

View file

@ -102,19 +102,14 @@ pub fn all_agents_in(topo: &BTreeSet<String>) -> Vec<String> {
/// caller. The write is atomic, as a sorted array (`BTreeSet` iterates in /// caller. The write is atomic, as a sorted array (`BTreeSet` iterates in
/// key order) to keep git diffs minimal. /// 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: /// 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. /// rebuilding it from the live set alone would silently rewrite it.
pub fn reconcile(agent_names: &[String], pending: &[String]) -> std::io::Result<bool> { pub fn reconcile(agent_names: &[String]) -> std::io::Result<bool> {
reconcile_at(&topology_path(), agent_names, pending) reconcile_at(&topology_path(), agent_names)
} }
fn reconcile_at(path: &Path, agent_names: &[String], pending: &[String]) -> std::io::Result<bool> { fn reconcile_at(path: &Path, agent_names: &[String]) -> std::io::Result<bool> {
let (next, changed) = apply_reconcile(&read_from(path)?, agent_names, pending); let (next, changed) = apply_reconcile(&read_from(path)?, agent_names);
if changed { if changed {
super::write_map(path, &next)?; 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, /// Pure form of [`reconcile`] for unit tests. Adds missing live agents,
/// drops entries for agents that are neither live nor pending-init, and /// drops entries for agents that are not live, and reports whether anything
/// reports whether anything changed. /// changed.
#[must_use] #[must_use]
pub fn apply_reconcile( pub fn apply_reconcile(
current: &BTreeSet<String>, current: &BTreeSet<String>,
agent_names: &[String], agent_names: &[String],
pending: &[String],
) -> (BTreeSet<String>, bool) { ) -> (BTreeSet<String>, bool) {
let mut next = current.clone(); let mut next = current.clone();
let mut changed = false; let mut changed = false;
@ -137,8 +131,7 @@ pub fn apply_reconcile(
changed = true; changed = true;
} }
} }
let known: std::collections::HashSet<&String> = let known: std::collections::HashSet<&String> = agent_names.iter().collect();
agent_names.iter().chain(pending.iter()).collect();
next.retain(|name| { next.retain(|name| {
let keep = known.contains(name); let keep = known.contains(name);
if !keep { if !keep {
@ -166,7 +159,7 @@ mod tests {
#[test] #[test]
fn apply_reconcile_adds_missing_live_agent() { fn apply_reconcile_adds_missing_live_agent() {
let live = vec!["alice".to_owned(), "newbie".to_owned()]; 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!(changed);
assert!(next.contains("newbie")); assert!(next.contains("newbie"));
assert!(next.contains("alice")); assert!(next.contains("alice"));
@ -175,30 +168,17 @@ mod tests {
#[test] #[test]
fn apply_reconcile_drops_vanished_agent() { fn apply_reconcile_drops_vanished_agent() {
let live = vec!["alice".to_owned()]; 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!(changed);
assert!(!next.contains("bob")); assert!(!next.contains("bob"));
assert!(!next.contains("carol")); assert!(!next.contains("carol"));
assert!(next.contains("alice")); 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] #[test]
fn apply_reconcile_is_a_noop_when_already_in_sync() { fn apply_reconcile_is_a_noop_when_already_in_sync() {
let live = vec!["alice".to_owned(), "bob".to_owned(), "carol".to_owned()]; 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!(!changed);
assert_eq!(next, roster_three()); assert_eq!(next, roster_three());
} }
@ -255,8 +235,7 @@ mod tests {
fn reconcile_leaves_a_corrupt_file_untouched() { fn reconcile_leaves_a_corrupt_file_untouched() {
let (_dir, path) = corrupt_file(); let (_dir, path) = corrupt_file();
let live = vec!["alice".to_owned(), "bob".to_owned()]; let live = vec!["alice".to_owned(), "bob".to_owned()];
let err = let err = reconcile_at(&path, &live).expect_err("a corrupt file must not be rewritten");
reconcile_at(&path, &live, &[]).expect_err("a corrupt file must not be rewritten");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert_eq!(std::fs::read(&path).expect("read"), TRUNCATED.as_bytes()); assert_eq!(std::fs::read(&path).expect("read"), TRUNCATED.as_bytes());
} }
@ -273,7 +252,7 @@ mod tests {
let path = dir.path().join(TOPOLOGY_FILE); let path = dir.path().join(TOPOLOGY_FILE);
assert!(read_from(&path).expect("missing file").is_empty()); assert!(read_from(&path).expect("missing file").is_empty());
let live = vec!["alice".to_owned(), "bob".to_owned(), "carol".to_owned()]; 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()); assert_eq!(read_from(&path).expect("read"), roster_three());
let entries: Vec<_> = std::fs::read_dir(dir.path()) let entries: Vec<_> = std::fs::read_dir(dir.path())
.expect("read_dir") .expect("read_dir")

View file

@ -37,11 +37,7 @@ pub async fn refresh(agents: &[hive_meta_flake::AgentSpec]) -> Result<()> {
}; };
remove_stale_ca_files(&dir); remove_stale_ca_files(&dir);
let pending: Vec<String> = crate::coordinator::Coordinator::pending_init_names() crate::topology::reconcile(&agent_names)
.into_iter()
.map(hive_types::Ident::into_string)
.collect();
crate::topology::reconcile(&agent_names, &pending)
.with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?; .with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?;
// Every agent binds a unix socket (`HIVE_WEB_SOCKET`); the gateway routes // Every agent binds a unix socket (`HIVE_WEB_SOCKET`); the gateway routes

View file

@ -286,8 +286,6 @@ mod serve_config_tests {
pub struct AgentPaths { pub struct AgentPaths {
/// Runtime socket dir (tmpfs, recreated per boot). /// Runtime socket dir (tmpfs, recreated per boot).
pub agent: PathBuf, pub agent: PathBuf,
/// Manager-editable proposed config repo.
pub proposed: PathBuf,
/// Hive-c0re-authoritative applied config repo. /// Hive-c0re-authoritative applied config repo.
pub applied: PathBuf, pub applied: PathBuf,
/// Claude OAuth credentials (survives purge boundary). /// Claude OAuth credentials (survives purge boundary).
@ -438,7 +436,6 @@ impl Coordinator {
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
Ok(AgentPaths { Ok(AgentPaths {
agent: agent_dir, agent: agent_dir,
proposed: Self::agent_proposed_dir(&name),
applied: crate::paths::applied_dir(name.as_str()), applied: crate::paths::applied_dir(name.as_str()),
claude: Self::agent_claude_dir(&name), claude: Self::agent_claude_dir(&name),
notes: Self::agent_notes_dir(&name), notes: Self::agent_notes_dir(&name),
@ -829,17 +826,6 @@ impl Coordinator {
crate::paths::agent_runtime_dir(name).join("mcp.sock") 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/<name>/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 /// Per-agent Claude credentials dir. Bind-mounted RW into the agent
/// container at `/root/.claude` so OAuth state survives container /// container at `/root/.claude` so OAuth state survives container
/// destroy/recreate. Each agent owns its own token lineage — sharing /// destroy/recreate. Each agent owns its own token lineage — sharing
@ -950,23 +936,6 @@ impl Coordinator {
out.sort(); out.sort();
out 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<hive_types::Ident> {
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 /// `push_todo` summary text for a rebuild outcome, for

View file

@ -37,7 +37,7 @@ pub struct TombstoneView {
/// ones** — a mid-spawn agent (state dir seeded by `Provision`, container not /// ones** — a mid-spawn agent (state dir seeded by `Provision`, container not
/// yet made by `Create`) is indistinguishable from a tombstone here, because /// yet made by `Create`) is indistinguishable from a tombstone here, because
/// *nothing records a destroy*. Every definition-side artifact — state /// *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 /// topology entry — is written before the container exists and survives
/// `lifecycle::destroy`, which removes the container and leaves the rest. /// `lifecycle::destroy`, which removes the container and leaves the rest.
/// ///

View file

@ -12,7 +12,7 @@ mod repos;
pub(crate) use internal_meta::internal_meta_source; pub(crate) use internal_meta::internal_meta_source;
pub use reader::reader_token; pub use reader::reader_token;
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status}; pub use 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::future::IntoFuture;
use std::sync::OnceLock; use std::sync::OnceLock;

View file

@ -1,26 +1,23 @@
//! Repo plumbing on the local Forgejo: reseeding an agent's `applied` and //! Repo plumbing on the local Forgejo: seeding an agent's `applied` repo from
//! `proposed` repos from `agent-configs/<name>`. The typed API-client //! `agent-configs/<name>`. The typed API-client constructor + org-name
//! constructor + org-name constants live in the module root (`super`). //! constants live in the module root (`super`).
use std::path::Path;
use super::{CONFIG_ORG, forge_git_url, reader_token}; use super::{CONFIG_ORG, forge_git_url, reader_token};
/// Reseed a revived agent's `applied` repo — missing its `.git` entirely, /// Seed an agent's `applied` repo, missing its `.git` entirely (first spawn,
/// e.g. after `destroy --purge` — by fetching `agent-configs/<name>`'s /// or a revival after `destroy --purge`), by fetching `agent-configs/<name>`'s
/// `main` from the forge. Unlike `proposed` (seeded once at first spawn and /// `main` from the forge. Forge `main` advances with every operator-merged
/// never touched again — see `lifecycle::setup_proposed`'s own doc /// config PR, so this reflects the last merged config. A config deployed
/// comment), forge `main` advances with every operator-merged config PR, so /// through hive-c0re without a forge merge never reaches it.
/// 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.
/// ///
/// Best-effort: returns `false` (not an error) rather than bailing when there /// 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 /// is no [`reader_token`], `applied` already has a `.git` (nothing to do), the
/// any git step fails — callers fall back to a less-current source or the /// forge has no `main` for the agent yet (the normal case right after
/// original hard error. Only `true` once `applied` is /// `swarm-controller` creates it, before it has pushed anything), or any git
/// actually seeded and tagged `deployed/0`, mirroring first-spawn's own /// step fails. Callers fall back to the template or the original hard error. A
/// `setup_applied` seed shape exactly. /// 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 { pub async fn fetch_config_main_into_applied(name: &str) -> bool {
let Some(reader) = reader_token().await else { let Some(reader) = reader_token().await else {
return false; return false;
@ -29,111 +26,60 @@ pub async fn fetch_config_main_into_applied(name: &str) -> bool {
if applied.join(".git").exists() { if applied.join(".git").exists() {
return false; return false;
} }
if let Err(e) = std::fs::create_dir_all(&applied) { let seeded = async {
tracing::warn!(%name, error = ?e, "forge: applied reseed mkdir failed"); if let Err(e) = std::fs::create_dir_all(&applied) {
return false; tracing::warn!(%name, error = ?e, "forge: applied reseed mkdir 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) => {
tracing::warn!(
%name,
stderr = %String::from_utf8_lossy(&o.stderr).trim(),
"forge: applied reseed fetch failed"
);
return false; return false;
} }
Err(e) => { if let Err(e) = crate::lifecycle::git(&applied, &["init", "--initial-branch=main"]).await {
tracing::warn!(%name, error = ?e, "forge: applied reseed fetch failed"); tracing::warn!(%name, error = ?e, "forge: applied reseed init failed");
return false; 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 { .await;
tracing::warn!(%name, error = ?e, "forge: applied reseed read-tree failed"); if !seeded {
return false; let _ = std::fs::remove_dir_all(applied.join(".git"));
}
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/<name>` 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/<name>` 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
}
} }
seeded
} }

View file

@ -731,7 +731,7 @@ async fn run_rebuild_bookkeeping(
Ok(()) 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. /// subvolume, and the agent-set refresh.
async fn run_provision(host: &impl Host, name: &str) -> Result<()> { async fn run_provision(host: &impl Host, name: &str) -> Result<()> {
let agent_dir = crate::paths::agent_runtime_dir(name); let agent_dir = crate::paths::agent_runtime_dir(name);

View file

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

View file

@ -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 //! `git` with the hive-c0re identity, resolve/plant refs and tags, and
//! fetch proposal commits into the applied repo. //! fetch proposal commits into the applied repo.
@ -71,25 +71,6 @@ async fn drain<R: AsyncRead + Unpin>(pipe: Option<R>) -> std::io::Result<Vec<u8>
const GIT_NAME: &str = "c0re"; const GIT_NAME: &str = "c0re";
const GIT_EMAIL: &str = "c0re@hyperhive.local"; 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<String> {
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<()> { pub(super) async fn git_commit(dir: &Path, message: &str) -> Result<()> {
git( git(
dir, dir,

View file

@ -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-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 /// 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. /// `read_only` on a bind constrains writers *inside* the container only.
fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) { fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
let Ok(child) = hive_types::Ident::parse(child) else { let Ok(child) = hive_types::Ident::parse(child) else {

View file

@ -18,7 +18,7 @@ pub use git::{
pub use host_config::write_dropins; pub use host_config::write_dropins;
pub use setup::{ pub use setup::{
ensure_agent_state_subvolume, ensure_claude_dir, ensure_state_dir, initial_flake_nix, ensure_agent_state_subvolume, ensure_claude_dir, ensure_state_dir, initial_flake_nix,
setup_applied, setup_proposed, setup_applied,
}; };
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
@ -205,8 +205,8 @@ async fn port_collision(self_name: &str) -> Result<Option<String>> {
} }
/// First-spawn pre-create provisioning — the job queue's `Provision` /// First-spawn pre-create provisioning — the job queue's `Provision`
/// node. Fail fast on a port collision, set up the proposed/applied /// node. Fail fast on a port collision, set up the applied
/// repos + state subvolume + claude/notes dirs, then register the new /// repo + state subvolume + claude/notes dirs, then register the new
/// agent in the host-side files that follow the agent set /// agent in the host-side files that follow the agent set
/// ([`crate::agent_set::refresh`]). Does NOT /// ([`crate::agent_set::refresh`]). Does NOT
/// create the container — that's `create_only` / the `Create` node. /// 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. // repo, and the build takes it from the archetype.
crate::archetypes::refresh(name).await?; crate::archetypes::refresh(name).await?;
if crate::archetypes::of(name)?.is_none() { if crate::archetypes::of(name)?.is_none() {
setup_proposed(&paths.proposed, name).await?; setup_applied(&paths.applied, name).await?;
setup_applied(&paths.applied, Some(&paths.proposed), name).await?;
} }
ensure_agent_state_subvolume(name).await?; ensure_agent_state_subvolume(name).await?;
ensure_claude_dir(&paths.claude)?; 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/<name>` on the /// in `provision_container`. Recover from `agent-configs/<name>` on the
/// forge when that's the case (forge `main` is the last operator-merged /// 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) /// config — see `forge::fetch_config_main_into_applied`'s own doc comment)
/// rather than bailing outright. Deliberately **no fallback to /// rather than bailing outright. Deliberately **no template fallback**: a
/// `proposed`**: that repo is seeded once at first spawn and never touched /// template would silently revive the *wrong* config rather than recovering
/// again (`setup_proposed`'s own doc comment), so it can be arbitrarily /// the right one, which is worse than the clear bail this replaces.
/// 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.
pub async fn prepare_rebuild_dirs(name: &str, paths: &AgentPaths) -> Result<()> { pub async fn prepare_rebuild_dirs(name: &str, paths: &AgentPaths) -> Result<()> {
validate(name)?; validate(name)?;
if let Some(other) = port_collision(name).await? { 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() paths.applied.display()
); );
} }
setup_applied(&paths.applied, None, name).await?; setup_applied(&paths.applied, name).await?;
} }
ensure_agent_state_subvolume(name).await?; ensure_agent_state_subvolume(name).await?;
ensure_claude_dir(&paths.claude)?; ensure_claude_dir(&paths.claude)?;

View file

@ -1,159 +1,53 @@
//! First-spawn provisioning: seed the agent's proposed config repo and //! First-spawn provisioning: seed the hive-c0re-owned applied repo, and
//! the hive-c0re-owned applied repo, and ensure the per-agent state / //! ensure the per-agent state / claude-credentials dirs (btrfs subvolume when
//! claude-credentials dirs (btrfs subvolume when available) exist. //! available) exist.
//! //!
//! Seeding is the only write either repo gets from here. Later config //! Seeding is the only write the repo gets from here. Later config changes
//! changes arrive as forge PRs authored in a clone — nothing edits a //! arrive as forge PRs authored in a clone - nothing edits a working tree in
//! working tree in place. //! place.
use std::path::Path; use std::path::Path;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use super::git::{ use super::git::{git, git_commit, git_rev_parse, git_tag};
git, git_command, git_commit, git_read_tree_reset, git_rev_parse, git_root_commit, git_tag,
};
/// Initialize an agent's config repo, preferring the one the forge /// Seed an agent's applied repo on first spawn, preferring the config the
/// already has. `agent-configs/<name>` is authored by whoever created /// forge already has. `agent-configs/<name>` is authored by whoever created
/// the agent — at swarm level that is the controller, which writes the /// the agent (at swarm level the controller, which writes the config before
/// config before any hive is told to deploy it — so this **clones** /// any hive is told to deploy it), so this takes the forge's `main` when
/// when there is something to clone and seeds a template only when /// there is one and writes a template only when there is not. Two authors for
/// there is not. Two authors for one file is the failure mode being /// one file is the failure mode being avoided: whichever wrote second would
/// avoided: whichever wrote second would win, and neither knows about /// win, and neither knows about the other.
/// the other.
/// ///
/// The template ([`seed_template`]) is what this still falls back to when /// The template ([`seed_template`]) is the fallback when there is genuinely
/// there's genuinely nothing to clone yet — `swarm-controller` creates /// nothing to take yet: `swarm-controller` creates `agent-configs/<name>`
/// `agent-configs/<name>` before telling a hive to deploy, but hasn't /// before telling a hive to deploy, but hasn't necessarily pushed `main` to
/// necessarily pushed `main` to it yet, or the forge is unreachable, or /// it, or the forge is unreachable, or this hive has no forge read token.
/// this hive has no forge read token.
/// ///
/// **Seeding is the whole of hive-c0re's write.** Changes to the repo /// Seeding is the only write hive-c0re makes here. Later config changes
/// arrive as PRs from a clone, via the forge, like any other code /// arrive as forge PRs and are fetched from the forge's `main` (see
/// change — nothing edits a working tree in place, here or in the /// `actions::advance_applied_to_rev`). `flake.nix` is tracked in the seed
/// agent's bind-mounted `config/` (which is a read-only copy). /// and rides along on every fetch.
/// ///
/// Touched by hive-c0re only on first spawn — never again — so a /// A repo that is already there is only checked for its shape (a rebuild
/// hive-c0re commit or working-tree reset can't surprise anyone /// finds it already seeded): a missing `deployed/0` tag means it predates the
/// holding a clone. /// meta-flake layout and is an error.
pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> { pub async fn setup_applied(applied_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/<n>/.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<()> {
std::fs::create_dir_all(applied_dir) std::fs::create_dir_all(applied_dir)
.with_context(|| format!("create {}", applied_dir.display()))?; .with_context(|| format!("create {}", applied_dir.display()))?;
if !applied_dir.join(".git").exists() { if !applied_dir.join(".git").exists() {
let Some(proposed) = proposed_dir else { if crate::forge::fetch_config_main_into_applied(name).await {
bail!( return Ok(());
"applied repo at {} is missing its .git directory; \ }
cannot rebuild without a proposed source to seed from. \ seed_template(applied_dir, name).await?;
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?;
git_tag(applied_dir, "deployed/0", "refs/heads/main").await?; git_tag(applied_dir, "deployed/0", "refs/heads/main").await?;
} else if git_rev_parse(applied_dir, "refs/tags/deployed/0") } else if git_rev_parse(applied_dir, "refs/tags/deployed/0")
.await .await
.is_err() .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 // flake.nix may be untracked, agent.nix possibly authored by
// hive-c0re directly. The startup auto-migration fixes this // hive-c0re directly. The startup auto-migration fixes this
// in place; if it didn't run (or got skipped), surface a // in place; if it didn't run (or got skipped), surface a
@ -168,6 +62,30 @@ pub async fn setup_applied(
Ok(()) 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 /// 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 /// 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 /// valid session; credential files inside (`.credentials.json` etc.) are 0600 so

View file

@ -3,27 +3,31 @@
use super::*; use super::*;
/// Regression test: `setup_proposed` must seed both agent.nix and flake.nix /// Regression test: `setup_applied` must seed both agent.nix and flake.nix
/// in the initial commit. Before commit 5b5a93e flake.nix was missing from /// in the initial commit, and tag it `deployed/0`. Before commit 5b5a93e
/// the scaffold, requiring manual creation (seen with the damocles agent). /// 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 /// Exercises the template arm: there is no forge to take a config from in a
/// (`forge::is_present` needs the priv socket), so `setup_proposed` falls /// test (no reader token), so `setup_applied` falls through to
/// through to `seed_template` — which is the arm this asserts about. /// `seed_template` — which is the arm this asserts about.
#[tokio::test] #[tokio::test]
async fn setup_proposed_seeds_flake_nix() { async fn setup_applied_seeds_flake_nix() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let proposed = dir.path().join("proposed"); let applied = dir.path().join("applied");
setup_proposed(&proposed, "test-agent") setup_applied(&applied, "test-agent")
.await .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. // Both files must exist on disk.
assert!(proposed.join("agent.nix").exists(), "agent.nix missing"); assert!(applied.join("agent.nix").exists(), "agent.nix missing");
assert!(proposed.join("flake.nix").exists(), "flake.nix missing"); assert!(applied.join("flake.nix").exists(), "flake.nix missing");
// flake.nix must export nixosModules.default (the meta-flake contract). // 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!( assert!(
flake.contains("nixosModules.default"), flake.contains("nixosModules.default"),
"flake.nix does not export 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. // Both files must be tracked in the initial git commit.
let out = git_command() let out = git_command()
.current_dir(&proposed) .current_dir(&applied)
.args(["show", "--name-only", "--format=", "HEAD"]) .args(["show", "--name-only", "--format=", "HEAD"])
.output() .output()
.await .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 /// `setup_applied` is idempotent: calling it on an already seeded repo is a
/// no-op (the fresh guard skips all writes). /// no-op (the shape check passes and nothing is written).
#[tokio::test] #[tokio::test]
async fn setup_proposed_idempotent() { async fn setup_applied_idempotent() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let proposed = dir.path().join("proposed"); let applied = dir.path().join("applied");
setup_proposed(&proposed, "test-agent") setup_applied(&applied, "test-agent")
.await .await
.expect("first call"); .expect("first call");
// Second call must not error even though .git already exists. // Second call must not error even though .git already exists.
setup_proposed(&proposed, "test-agent") setup_applied(&applied, "test-agent")
.await .await
.expect("second call"); .expect("second call");
// Still one commit. // Still one commit.
let out = git_command() let out = git_command()
.current_dir(&proposed) .current_dir(&applied)
.args(["rev-list", "--count", "HEAD"]) .args(["rev-list", "--count", "HEAD"])
.output() .output()
.await .await
@ -158,10 +162,10 @@ async fn setup_proposed_idempotent() {
/// Build a two-commit repo and return `(dir, repo_path, first, second)`. /// Build a two-commit repo and return `(dir, repo_path, first, second)`.
async fn two_commit_repo() -> (tempfile::TempDir, std::path::PathBuf, String, String) { async fn two_commit_repo() -> (tempfile::TempDir, std::path::PathBuf, String, String) {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let repo = dir.path().join("proposed"); let repo = dir.path().join("applied");
setup_proposed(&repo, "test-agent") setup_applied(&repo, "test-agent")
.await .await
.expect("setup_proposed"); .expect("setup_applied");
let first = git_rev_parse(&repo, "HEAD").await.expect("rev-parse first"); let first = git_rev_parse(&repo, "HEAD").await.expect("rev-parse first");
std::fs::write(repo.join("second.txt"), "second").expect("write second.txt"); std::fs::write(repo.join("second.txt"), "second").expect("write second.txt");

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