From 950a13bc69d8958d50c1a2bf8debcb3631a5dcf0 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 8 Jul 2026 23:31:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(#2290):=20StartableAgent=20token=20?= =?UTF-8?q?=E2=80=94=20start=5Fwith=5Ffallback=20requires=20preamble=20pro?= =?UTF-8?q?of?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lifecycle::StartableAgent: opaque token produced only by converge_start_preamble. #[must_use] with a hint to call start_with_fallback(token). - lifecycle::converge_start_preamble(name, hive, paths): runs ensure_agent_runtime_dir + write_dropins, returns StartableAgent. The only way to obtain a token. - lifecycle::start_with_fallback(token: StartableAgent): public API now requires the token. Callers that skip the preamble get a compile error, not a runtime outage. - lifecycle::start_with_fallback_inner(name): private; used internally by rebuild_no_meta where the preamble is already enforced structurally (write_dropins was called on the line above). - exec.rs ReconcileAction::Start: migrated to converge_start_preamble + start_with_fallback(token). The write_dropins + start_with_fallback two-step is now a single typed pipeline. --- hive-c0re/src/job_queue/exec.rs | 27 +++++++------------ hive-c0re/src/lifecycle/mod.rs | 48 +++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 19 deletions(-) diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index ad401278..7e0b560c 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -253,27 +253,20 @@ async fn run_reconcile( .transient .is_none() .then(|| coord.transient_guard(name, crate::coordinator::TransientKind::Starting)); - // Converge the ephemeral host-side state before the start: - // `/run/hyperhive/agents/` + `/run/hive-agent/` - // (both nspawn bind sources — tmpfs, empty after a host - // reboot; nspawn refuses to start with a missing source) - // and the resource-limits drop-in under - // `/run/systemd/system/`. Rebuild DAGs get this from their - // Prebuild/Swap nodes; the bare-Reconcile templates (boot - // reconcile, plain start/restart) otherwise start with - // nothing under /run and fail. - // - // Dir creation is the pure-filesystem part (no Coordinator - // dep). The MCP listener is reconciled by mcp_sockets::spawn_poll - // whose first tick fires immediately on daemon start — the - // container boot takes longer than the 10 s interval anyway. - crate::lifecycle::ensure_agent_runtime_dir(name)?; + // Run the typed start preamble: ensures the runtime dir + // exists and writes the nspawn/resource-limits drop-ins. + // The returned StartableAgent token is the only way to call + // start_with_fallback — omitting this becomes a compile error. + // MCP listener registration is handled by mcp_sockets::spawn_poll + // (first tick immediate); the container boot takes longer than + // the 10 s interval so the listener is ready in time. let agent_dir = Coordinator::agent_dir(name); let hive = coord.hive_env(); let paths = Coordinator::agent_paths(name, agent_dir); - crate::lifecycle::write_dropins(name, &hive, &paths).await?; + let token = + crate::lifecycle::converge_start_preamble(name, &hive, &paths).await?; ctx.step("nixos-container start"); - crate::lifecycle::start_with_fallback(name).await?; + crate::lifecycle::start_with_fallback(token).await?; coord.kick_agent(name, "container started"); coord.rescan_containers_and_emit().await; } diff --git a/hive-c0re/src/lifecycle/mod.rs b/hive-c0re/src/lifecycle/mod.rs index 1e325c2c..8e58ac19 100644 --- a/hive-c0re/src/lifecycle/mod.rs +++ b/hive-c0re/src/lifecycle/mod.rs @@ -420,17 +420,59 @@ pub async fn start(name: &str) -> Result<()> { priv_run("start", name).await } +/// Opaque token produced by [`converge_start_preamble`]. +/// [`start_with_fallback`] requires this as proof that the pre-start +/// preamble (runtime dir + drop-ins) ran. Dropping the token without +/// calling `start_with_fallback` is a no-op. +#[must_use = "call lifecycle::start_with_fallback(token) to start the container"] +pub struct StartableAgent { + name: String, +} + +/// Run the per-agent start preamble: ensure the runtime dir exists and write +/// the nspawn / resource-limits drop-ins. Returns a [`StartableAgent`] token +/// as typed proof that the preamble ran; pass it to [`start_with_fallback`]. +/// Callers that omit this step cannot call `start_with_fallback` — the type +/// system makes forgetting the preamble a compile error. +/// +/// # Errors +/// +/// Returns an error if `ensure_agent_runtime_dir` or `write_dropins` fails. +pub async fn converge_start_preamble( + name: &str, + hive: &HiveEnv, + paths: &AgentPaths, +) -> Result { + ensure_agent_runtime_dir(name)?; + write_dropins(name, hive, paths).await?; + Ok(StartableAgent { + name: name.to_owned(), + }) +} + /// Start with the cold-start fallback: when a plain start fails (the /// activation-error shape), retry once via stop + kill + start before /// giving up. Used by the queue's fast-lane `Start` handler and the /// inline start-after-rebuild path. /// See `docs/coordinator.md::Cold-start fallback`. /// +/// Requires a [`StartableAgent`] token from [`converge_start_preamble`] +/// to prove the preamble ran. For internal use within this module (where +/// the preamble is already enforced structurally) call +/// `start_with_fallback_inner` directly. +/// /// # Errors /// /// Propagates the retry's start error (annotated with the original /// failure) when the fallback also fails. -pub async fn start_with_fallback(name: &str) -> Result<()> { +pub async fn start_with_fallback(token: StartableAgent) -> Result<()> { + start_with_fallback_inner(&token.name).await +} + +/// Internal implementation of the cold-start fallback. Used by +/// [`start_with_fallback`] (public, token-gated) and by +/// [`rebuild_no_meta`] where the preamble is already enforced structurally. +async fn start_with_fallback_inner(name: &str) -> Result<()> { validate(name)?; if let Err(start_err) = priv_run("start", name).await { let container = container_name(name); @@ -577,7 +619,9 @@ pub async fn rebuild_no_meta( return Ok(true); } on_step("nixos-container start"); - start_with_fallback(name).await?; + // write_dropins was called above; use the inner fn directly + // since the preamble is enforced structurally in this path. + start_with_fallback_inner(name).await?; } Ok(false) } else {