feat(#2290): StartableAgent token — start_with_fallback requires preamble proof

- 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.
This commit is contained in:
atlas 2026-07-08 23:31:30 +02:00
commit 950a13bc69
2 changed files with 56 additions and 19 deletions

View file

@ -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<StartableAgent> {
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 {