remove the hive-side auto-spawn of ruth and the ruthless option
ruth is created from the swarm dashboard like any agent. An existing ruth container starts at boot through the boot reconcile, like every other agent. A definition of services.hyperhive.ruthless now fails evaluation with a removal message.
This commit is contained in:
parent
af70b0ba40
commit
90e8158f37
13 changed files with 59 additions and 268 deletions
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -225,15 +225,6 @@ async fn port_collision(self_name: &str) -> Result<Option<String>> {
|
|||
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.
|
||||
|
|
|
|||
|
|
@ -272,21 +272,13 @@ async fn cmd_serve(
|
|||
// repos, ensure proposed repos carry the `applied`
|
||||
// remote, bootstrap the meta repo, repoint containers at
|
||||
// `meta#<name>` (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.
|
||||
|
|
|
|||
|
|
@ -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<Vec<String>>) -> 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<Coordinator>) -> 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<Vec<String>> =
|
||||
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<Vec<String>> = 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<Vec<String>> = 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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue