diff --git a/docs/agent-lifecycle/agent-roster.md b/docs/agent-lifecycle/agent-roster.md index c8e1ec65..14189b60 100644 --- a/docs/agent-lifecycle/agent-roster.md +++ b/docs/agent-lifecycle/agent-roster.md @@ -80,19 +80,14 @@ module docs for the exact call chain. The **manager (`ruth`)** differs from other agents in naming/bootstrap, its socket flavour and its default permissions; its prompt, tool -allow-list and state dirs work as for every other agent. A hive with -`services.hyperhive.ruthless = true` runs no manager: `hive-c0re` skips -its root-agent create/start sweep -(`hive-c0re/src/workers/auto_update.rs`), and this section doesn't -apply. +allow-list and state dirs work as for every other agent. - **Naming/bootstrap** — the manager's broker recipient name, state-dir key, and nixos-container name are all `ruth` (container `h-ruth`). - `hive-c0re` spawns it directly at boot if missing, with no operator - approval step — every other agent is created at swarm level - (`swarmctl agent create`). + Like every agent it comes from the swarm dashboard or + `swarmctl agent create`; `hive-c0re` creates nothing at boot. Roster-wise, `ruth` is just another entry. - **Wire-protocol** — `hive-core-agent-sock`'s unified `Request` enum is the same on both socket flavours. Container lifecycle diff --git a/docs/agent-lifecycle/config-changes.md b/docs/agent-lifecycle/config-changes.md index 03364edc..76f218dd 100644 --- a/docs/agent-lifecycle/config-changes.md +++ b/docs/agent-lifecycle/config-changes.md @@ -106,9 +106,8 @@ notes) is reuses prior config + login. With `purge = true` the agent's `/var/lib/hyperhive/{agents,applied}//` trees are also wiped (config history + creds + notes gone forever). The -root/bootstrap container is destroyable like any other — hive-c0re -recreates it on the next startup if it's absent, so destroying it's -transient. +root/bootstrap container is destroyable like any other, and stays gone +until you create ruth again from the swarm dashboard. ## Meta flake @@ -143,7 +142,7 @@ autoupdate flake-rev bump (one shot before per-agent rebuilds). `meta::sync_agents(hive: &HiveEnv, agents: &[AgentSpec])` — `hive` carries `hyperhive_flake`, `dashboard_port`, and the rest of the -per-hive config — is the idempotent reconciler called by `spawn`, +per-hive config — is the idempotent reconciler called by `provision_container`, `destroy`, `rebuild`, and the startup migration. Renders `flake.nix` from the agent list; if it differs from disk, runs `nix flake lock` + commits as `regenerate meta flake` (or @@ -314,11 +313,12 @@ No state loss in either migration: claude creds, `/state/` notes, the events DB, and both proposed + applied history all survive. The root agent keeps its session; sub-agents stay logged in. -## The root/bootstrap container is hive-c0re-managed +## The root/bootstrap container The root agent container runs through the **same lifecycle as -sub-agents**. On `hive-c0re serve` startup, if `ruth` is missing, -hive-c0re creates it. The root agent's flake lives at +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 `agent.nix` (visible inside the container at `/agents/ruth/config/`) @@ -342,8 +342,8 @@ Differences from sub-agents: not a special manager-only path. **Migration note** (for older hosts): drop any `containers.root = -{ ... }` block from your host NixOS config. hyperhive creates and -updates the root agent itself. +{ ... }` block from your host NixOS config, and any `services.hyperhive.ruthless` +definition, then create ruth from the swarm dashboard. ## Root-agent policy diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index 06b255b4..bdcc6bf8 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -31,9 +31,9 @@ Beyond that: restart and rebuild** — only an explicit purge (or a hive `--purge`-style host operation, or the agent's own choices) touches them. -- The **root/bootstrap agent is special**: it isn't really destroyable - in practice — hive-c0re recreates it automatically on its next - startup if it's ever gone. +- The **root/bootstrap agent has no guard**: destroying ruth is as final as + for any agent, and she stays gone until you create her again from the + swarm dashboard. Everything below this point is implementation detail: exact table schemas, file layouts, and internal migration mechanics. @@ -432,11 +432,9 @@ an agent's state. The mechanics, for completeness: - `PURG3` wipes `/var/lib/hyperhive/{agents,applied}//` — the union of everything `DESTR0Y` left behind. -`actions::destroy` implements the root/bootstrap agent's specialness as a soft policy -guard that refuses to destroy it, backstopped by -`auto_update::ensure_root_agent`, which recreates it on the next -hive-c0re startup if it's ever absent — so even without the guard, -destroying it would only be transient. +`actions::destroy` has no guard for the root/bootstrap agent: it destroys ruth +like any other agent, and she stays gone until you create her again from the +swarm dashboard. ### btrfs subvolumes for `/var/lib/hyperhive/agents/` diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index 11b3fb72..913a2bcc 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -89,24 +89,16 @@ swarmctl forge make-admin mara If the forge already has a local account with your username, the first SSO login asks for its forge password once, to link the two. -## 4 · Ruth's store identity +## 4 · Ruth -hive-c0re creates the manager agent, ruth, on its own at startup — so -unlike agents made through the swarm, she has no store identity yet: +hive-c0re creates no agent at startup. You create ruth, the manager agent, +like any other: open the swarm UI start page and click the paperclip, which +opens the create form with `ruth` filled in. The form declares her, creates +her config repo and deploys her to the hive you pick. -```bash -swarmctl agent create ruth --hive # on the swarm-controller's host -hivectl agent ruth rebuild # on ruth's hive: hands her the identity -``` - -`agent create` declares an agent the swarm has no declaration for as -`paused`, so without a further step ruth keeps running but drives no turns. -`swarmctl` has no verb for wanted state: open `/agents` in the swarm UI and set -her `up` from her card. - -Within about ten minutes the controller creates her forge user and matrix -account and mints their tokens into the store, where she fetches them. -`swarmctl agent mint-forge-token ruth` skips the wait for the forge token. +On a hive that already has a ruth container from an earlier release, create +her once the same way, so that her config repo and store identity exist. The +container keeps starting with the hive meanwhile. ## 5 · Matrix diff --git a/docs/scheduler/coordinator.md b/docs/scheduler/coordinator.md index 82010b29..366b9638 100644 --- a/docs/scheduler/coordinator.md +++ b/docs/scheduler/coordinator.md @@ -178,7 +178,7 @@ Power toggles never commit to the meta repo. Every operator power surface — dashboard buttons, the MCP tools, and `hivectl stop/start/restart/kill` — rides the queue through that power layer, so intent, lease serialization, and crash-watch suppression can't drift per surface; the only direct starts -left are the root-agent bootstrap and infra containers (no lease, no +left are infra containers (no lease, no harness). Cancelling a still-queued power DAG reverts `wanted` to the observed state — a cancel means "don't do it," not "do it later." hive-c0re seeds agents without a row from observed state on first touch (running ⇒ diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 841e123e..dd9d72e6 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -126,10 +126,9 @@ async fn ff_applied_main( /// is cleared because its contents (the mcp socket) don't survive restarts /// anyway. With `purge=true` the persistent trees are also wiped — config /// history, claude creds, notes — there is no undo. -/// The bootstrap/root container is destroyable like any other: it's -/// imperative infra that `auto_update::ensure_root_agent` recreates on the -/// next hive-c0re startup if absent, so destroying it is transient rather -/// than something to refuse at the API. +/// The bootstrap/root container is destroyable like any other. Nothing +/// recreates it afterwards: ruth stays gone until she is created again from +/// the swarm dashboard. /// /// Submits the teardown DAG and returns — it does not wait for the container to /// go away. Same contract as every other lifecycle op (`rebuild`, `kill`, diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 1592ed09..8fe5730c 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -496,7 +496,7 @@ impl Coordinator { } /// Snapshot the hive-wide configuration fields as a [`HiveEnv`]. - /// Pass the result to `lifecycle::spawn` / `rebuild` / `meta::sync_agents` + /// Pass the result to `lifecycle::provision_container` / `rebuild` / `meta::sync_agents` /// instead of threading the individual fields separately. #[must_use] pub fn hive_env(&self) -> HiveEnv { diff --git a/hive-c0re/src/lifecycle/mod.rs b/hive-c0re/src/lifecycle/mod.rs index 580e509b..4decfdac 100644 --- a/hive-c0re/src/lifecycle/mod.rs +++ b/hive-c0re/src/lifecycle/mod.rs @@ -225,15 +225,6 @@ async fn port_collision(self_name: &str) -> Result> { Ok(None) } -pub async fn spawn(name: &str, hive: &HiveEnv, paths: &AgentPaths) -> Result<()> { - create_container(name, hive, paths).await?; - // Runtime dir must exist before nixos-container start (nspawn bind-mount - // source). Create it here so callers don't need a separate preamble step. - ensure_agent_runtime_dir(name)?; - write_dropins(name, hive, paths).await?; - priv_run("start", name).await -} - /// 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 @@ -266,16 +257,6 @@ pub async fn create_only(name: &str) -> Result<()> { priv_run("create", name).await } -/// First-spawn provisioning + `nixos-container create`, without the -/// drop-in write or the start. `spawn` composes this with -/// `write_dropins` + start for direct callers (root-agent bootstrap); -/// the job queue instead runs `provision_container` (`Provision` node) -/// and `create_only` (`Create` node) as separate DAG steps. -pub async fn create_container(name: &str, hive: &HiveEnv, paths: &AgentPaths) -> Result<()> { - provision_container(name, hive, paths).await?; - create_only(name).await -} - /// Rebuild-path preamble, run by the job queue's `Prebuild` node: fail fast on /// a port collision, then make sure the applied repo + state dirs exist. /// Container untouched. diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 761d32ea..06c8a8ac 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -272,21 +272,13 @@ async fn cmd_serve( // repos, ensure proposed repos carry the `applied` // remote, bootstrap the meta repo, repoint containers at // `meta#` (one-shot, guarded by a marker file). - // Runs before manager auto-spawn so the new manager is - // built against meta from the first attempt. + // Runs before the boot reconcile, so agents are built against meta. if let Err(e) = migrate::run(&coord).await { tracing::warn!(error = ?e, "startup migration failed"); } // Before any agent starts, so the bind mounts it gets from the stored // permissions are the meta repo's grants until the swarm answers. crate::workers::permissions_import::seed_local(); - // Auto-create the root agent container if it isn't there yet. Block - // on this — without root the system has no manager harness. - // Failures are logged but allowed: a broken auto-spawn shouldn't - // make the dashboard unreachable for debugging. - if let Err(e) = auto_update::ensure_root_agent(&coord).await { - tracing::warn!(error = ?e, "auto-spawn root agent failed"); - } // Auto-update in the background — don't block service start. // Sub-agent rebuilds can take tens of seconds; we want the admin // socket up immediately. diff --git a/hive-c0re/src/workers/auto_update.rs b/hive-c0re/src/workers/auto_update.rs index b02305b8..a11fbafd 100644 --- a/hive-c0re/src/workers/auto_update.rs +++ b/hive-c0re/src/workers/auto_update.rs @@ -22,7 +22,7 @@ use std::sync::Arc; use anyhow::Result; use crate::coordinator::Coordinator; -use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME}; +use crate::lifecycle::{self, AGENT_PREFIX}; /// Resolve the current rev of `hyperhive_flake`. For a path on disk we /// canonicalize (following symlinks) so a /etc/hyperhive → /nix/store/... @@ -66,150 +66,6 @@ pub async fn agent_config_pending(name: &str, deployed_sha: Option<&str>) -> boo } } -/// Whether this hive is "ruthless" — running with no root/manager agent at -/// all (no ruth). When true, hive-c0re skips the root-agent create/start -/// sweep entirely. Controlled by the host option -/// `services.hyperhive.ruthless`, threaded in via the `HYPERHIVE_RUTHLESS` -/// env var. Defaults to `false` when the var is unset (back-compat: the -/// root agent was always auto-managed before this opt-out existed); only -/// an explicit `true` / `1` / `yes` enables ruthless mode. -fn ruthless() -> bool { - match std::env::var("HYPERHIVE_RUTHLESS") { - Ok(v) => matches!(v.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes"), - Err(_) => false, - } -} - -/// What `ensure_root_agent` does with a `lifecycle::list()` result, before -/// it even gets to check whether the manager is present. -#[derive(Debug, PartialEq, Eq)] -enum RootAgentPlan { - /// The manager is in this readable list. - Present, - /// The manager is absent from this readable list: spawn it. - Absent, - /// The list could not be read. That is not "manager absent" — it - /// proves nothing either way, so the auto-spawn decision is skipped - /// for this attempt rather than risking a spawn over a manager that's - /// actually there. - Unreadable, -} - -/// Turn a `lifecycle::list()` result into `ensure_root_agent`'s plan. -fn plan_root_agent(list_result: anyhow::Result>) -> RootAgentPlan { - match list_result { - Ok(names) - if names - .iter() - .any(|c| c.strip_prefix(AGENT_PREFIX) == Some(MANAGER_NAME)) => - { - RootAgentPlan::Present - } - Ok(_) => RootAgentPlan::Absent, - Err(_) => RootAgentPlan::Unreadable, - } -} - -/// Auto-create the manager container on startup if it isn't already there. -/// hive-c0re manages the manager end-to-end: operators no longer declare -/// `containers.h-ruth` in their host NixOS config. The root/manager is -/// auto-managed by default. Operators who -/// don't want a root agent at all set `services.hyperhive.ruthless = true`, -/// which short-circuits this whole function. Idempotent. -pub async fn ensure_root_agent(coord: &Arc) -> Result<()> { - if ruthless() { - tracing::info!( - "ruthless mode (services.hyperhive.ruthless = true) - skipping root agent create/start" - ); - return Ok(()); - } - let list_result = lifecycle::list().await; - if let Err(e) = &list_result { - tracing::warn!( - error = ?e, - "manager container list unreadable — skipping auto-spawn check for this attempt" - ); - } - let plan = plan_root_agent(list_result); - let current_rev = current_flake_rev(&coord.hyperhive_flake); - if plan == RootAgentPlan::Unreadable { - // An unreadable list is not "manager absent" — spawning on it would - // both waste `provision_container`'s work and hit `nixos-container - // create`'s "already exists" failure if the manager is actually - // there. Skip the decision this attempt; there is no periodic - // retry for this boot-time call, so the next chance is the next - // `hive-c0re` restart. - return Ok(()); - } - if plan == RootAgentPlan::Present { - // Container exists already. If it predates the unified lifecycle - // (no applied flake on disk) we must rebuild — otherwise it's - // running whatever the host-declarative config was at create - // time, with a wrong systemd unit and port. - let applied_flake = crate::paths::applied_dir(MANAGER_NAME).join("flake.nix"); - if !applied_flake.exists() && current_rev.is_some() { - tracing::warn!( - "manager container exists but no applied flake — forcing rebuild to migrate" - ); - if let Err(e) = coord.job_queue.insert_job(|b| { - crate::job_queue::templates::rebuild(b, MANAGER_NAME, true); - Vec::new() - }) { - tracing::warn!(error = ?e, "manager migration rebuild insert failed"); - } - } else { - tracing::debug!("manager container already present"); - } - // hive-c0re auto-manages the root/manager by default, so a - // present-but-stopped root (e.g. a first-start failure on a fresh - // install) is brought back up here: the startup sweep's rebuild only - // restarts a container that was already running, so without this it - // stays down until a manual `nixos-container start`. The sub-agent - // `was_running` guard is intentionally left untouched. (Operators - // opt out of this whole auto-management with - // `services.hyperhive.ruthless = true`, gated at the top of - // this function.) - if !lifecycle::is_running(MANAGER_NAME).await { - tracing::info!("manager container present but not running — starting"); - if let Err(e) = coord.power.set(MANAGER_NAME, crate::power::Wanted::Up) { - tracing::warn!(error = ?e, "agent_power: set manager wanted=up failed"); - } - // Through the preamble: after a reboot its bind sources and limits - // drop-in in `/run` are gone, and nothing else recreates them. - let hive = coord.hive_env(); - let started = async { - let paths = Coordinator::agent_paths( - MANAGER_NAME, - crate::paths::agent_runtime_dir(MANAGER_NAME), - )?; - let token = lifecycle::converge_start_preamble(MANAGER_NAME, &hive, &paths).await?; - lifecycle::start_with_fallback(token).await - } - .await; - if let Err(e) = started { - tracing::warn!(error = ?e, "manager start failed"); - } - } - return Ok(()); - } - tracing::info!("manager container missing — spawning"); - // lifecycle::spawn creates the runtime dir internally; no manual - // ensure_agent_runtime_dir needed here. - let runtime = crate::paths::agent_runtime_dir(MANAGER_NAME); - let hive = coord.hive_env(); - let paths = Coordinator::agent_paths(MANAGER_NAME, runtime)?; - lifecycle::spawn(MANAGER_NAME, &hive, &paths).await?; - if let Err(e) = coord.power.set(MANAGER_NAME, crate::power::Wanted::Up) { - tracing::warn!(error = ?e, "agent_power: set manager wanted=up failed"); - } - if let Some(rev) = current_rev - && let Err(e) = std::fs::write(crate::paths::applied_rev_marker(MANAGER_NAME), &rev) - { - tracing::warn!(name = MANAGER_NAME, error = ?e, "write rev marker failed"); - } - Ok(()) -} - /// Boot reconcile (see the module doc): classify every agent by rev /// freshness + persisted `wanted` intent, submit one `Boot` DAG /// (hyperhive lock bump growing an in-DAG rebuild subgraph per stale @@ -463,35 +319,9 @@ fn submit_boot_tree( #[cfg(test)] mod tests { - use super::{BootAction, MANAGER_NAME, RootAgentPlan, boot_action, plan_root_agent}; - use crate::lifecycle::AGENT_PREFIX; + use super::{BootAction, boot_action}; use crate::power::Wanted; - // ----------------------------------------------------------------------- - // `ensure_root_agent`'s spawn-or-not decision. An unreadable list must - // never be read as "manager absent" — see `plan_root_agent`'s doc. - - /// The regression this fix exists for: a read failure must not spawn. - #[test] - fn unreadable_list_skips_without_spawning() { - let result: anyhow::Result> = - Err(anyhow::anyhow!("connect to hive-priv socket")); - assert_eq!(plan_root_agent(result), RootAgentPlan::Unreadable); - } - - /// Control: a genuinely empty, readable list still plans a spawn. - #[test] - fn readable_list_missing_manager_spawns() { - let result: anyhow::Result> = Ok(Vec::new()); - assert_eq!(plan_root_agent(result), RootAgentPlan::Absent); - } - - #[test] - fn readable_list_with_manager_present_is_a_noop() { - let result: anyhow::Result> = Ok(vec![format!("{AGENT_PREFIX}{MANAGER_NAME}")]); - assert_eq!(plan_root_agent(result), RootAgentPlan::Present); - } - // The regression the `Unreadable` variant exists for: the arm used to // substitute `from_running(running)`, which is `Noop` against BOTH // observations — so no combination of inputs could reach a reconcile. diff --git a/nix/host-modules/hive-c0re/environment.nix b/nix/host-modules/hive-c0re/environment.nix index ad8ba96a..818afe55 100644 --- a/nix/host-modules/hive-c0re/environment.nix +++ b/nix/host-modules/hive-c0re/environment.nix @@ -46,10 +46,6 @@ in # prompts). `hive_sh4re::assets::*` reads paths underneath. # `forge/users.rs` reads the core avatar PNG from here on startup. HIVE_ASSETS_DIR = "${cfg.assets}/share/hyperhive"; - # Whether this hive runs ruthless — no root/manager agent at all - # (`auto_update::ensure_root_agent`). Default false = root - # auto-managed; true makes the sweep a no-op. - HYPERHIVE_RUTHLESS = lib.boolToString config.services.hyperhive.ruthless; } // { # Identity env vars threaded into c0re's own service env and diff --git a/nix/host-modules/hyperhive.nix b/nix/host-modules/hyperhive.nix index 8d92f381..8741446a 100644 --- a/nix/host-modules/hyperhive.nix +++ b/nix/host-modules/hyperhive.nix @@ -19,6 +19,11 @@ in [ "services" "hyperhive" "swarmName" ] [ "services" "hyperhive" "swarm" "name" ] ) + (lib.mkRemovedOptionModule [ "services" "hyperhive" "ruthless" ] '' + hive-c0re no longer creates ruth at boot. Remove this definition, and create + ruth from the swarm dashboard like any other agent; a ruth container that + already exists keeps starting with the hive. + '') ]; # Canonical hive DNS domain shared by every subsystem that needs a @@ -194,23 +199,6 @@ in # the values it asserts. It is a deployment mode rather than a setting # this module's options read, so it lives with its consequences. - # Whether this hive runs "ruthless" — with no root/manager agent at - # all. Some hives don't want a root agent — see issue tracker - # "scope concept: special agents". - options.services.hyperhive.ruthless = lib.mkOption { - type = lib.types.bool; - default = false; - example = true; - description = '' - Run this hive "ruthless" — with no root (manager) agent at all (no - ruth). When `true`, hive-c0re skips the root-agent auto-management - sweep entirely (it otherwise creates the root agent's container when - missing and restarts it when present but stopped). Defaults to - `false` (the root agent is auto-managed as required - infrastructure). Exposed to hive-c0re as `HYPERHIVE_RUTHLESS`. - ''; - }; - options.services.hyperhive.github.enable = lib.mkOption { type = lib.types.bool; default = true; diff --git a/nix/module-eval/core-toggle.nix b/nix/module-eval/core-toggle.nix index 38837ea0..15d4f69b 100644 --- a/nix/module-eval/core-toggle.nix +++ b/nix/module-eval/core-toggle.nix @@ -1044,6 +1044,26 @@ let } && !(refusedHiveName { deploy.hive-controller.enable = false; }); } + { + # The control is a host that defines no such option; the removal message + # must not appear for it. + name = "a definition of the removed services.hyperhive.ruthless option fails evaluation, pointing at the swarm dashboard"; + ok = + let + refused = + m: + lib.any ( + a: + !a.assertion + && lib.hasInfix "services.hyperhive.ruthless" a.message + && lib.hasInfix "swarm dashboard" a.message + ) m.assertions; + in + refused (hive { + ruthless = true; + }) + && !(refused allLocal); + } { # Refused wherever any hyperhive service runs, the hive or a swarm # service alone. The host enabling nothing is the control that it is