refactor(#2916): destroy submits a DAG instead of an imperative teardown

Destroy was a straight-line async fn with no queue node behind it, so
nothing in the graph could answer "is this container going down on
purpose?". That gap is why an imperative crash-watch suppression guard
existed: an RAII handle held for the operation's duration, a second way
to say what every other lifecycle op already says through its node.

Reuse the existing Stop node rather than teaching a new node to stop
things:

    Stop -> DestroyContainer -> (PurgeState) -> DestroyBookkeeping

Stop already declares takes_container_down honestly, so the suppression
is now derived from the graph like every other op's. It also turns the
precondition into an edge: DestroyContainer runs only under a completed
Stop, so it operates on an already-stopped container and carries
takes_container_down = false permanently. A container still alive at
that point is a real bug and stays loud instead of being absorbed by a
flag -- which matters because a wrong true silently swallows a crash
while a wrong false only costs a spurious event.

Removes suppress_crash_watch, CrashWatchSuppression, crash_suppressed,
crash_watch_suppressed and NO_NODE_LABEL. The migration call sites went
with the obsolete startup migrations, so destroy was the last caller and
intent now has exactly one home.

destroy() becomes a submit-and-return, matching every sibling endpoint
(rebuild, kill, restart, start, pause, resume) -- it was the only
lifecycle op that awaited its work. The container rescan moves into the
bookkeeping tail, so ContainerRemoved now arrives after the 200 rather
than before it.

Also drops an orphaned doc-comment in coordinator.rs: two stacked blocks
where only the second described crash_suppressed, the first documenting
a field that no longer exists. Removing the field would have re-pointed
it at recent_transient.
This commit is contained in:
atlas 2026-08-13 23:26:17 +02:00 committed by mara
commit 6338939657
9 changed files with 326 additions and 194 deletions

View file

@ -73,6 +73,15 @@ pub(super) async fn run_node(
NodeKind::RebuildBookkeeping { .. } => run_rebuild_bookkeeping(coord, agent).await,
NodeKind::Provision { .. } => run_provision(coord, agent).await,
NodeKind::Create { .. } => run_create(agent).await,
NodeKind::DestroyContainer { .. } => run_destroy_container(coord, agent).await,
NodeKind::PurgeState { .. } => {
run_purge_state(agent).await;
Ok(())
}
NodeKind::DestroyBookkeeping { purge, .. } => {
run_destroy_bookkeeping(coord, agent, *purge).await;
Ok(())
}
NodeKind::MetaLock {
sweep,
fanout,
@ -163,6 +172,112 @@ async fn run_resolve_approval(
Ok(())
}
/// Rerender the meta flake from whatever containers still exist on disk.
/// Idempotent — a no-op when nothing changed. Lives here because the destroy
/// tail is its only caller; it moved with `destroy` when that became a DAG.
async fn sync_meta_after_lifecycle(coord: &Coordinator) -> Result<()> {
let agents = crate::lifecycle::agents_for_meta_listing().await?;
crate::meta::sync_agents(&coord.hive_env(), &agents).await
}
/// `nixos-container destroy`, then drop the agent from the roster and clear
/// its ephemeral runtime dir (the mcp socket, which does not survive a restart
/// anyway).
///
/// The only fallible step is the destroy itself: once the container is gone the
/// un-registration cannot meaningfully fail, and returning early would strand
/// the roster claiming an agent that no longer exists.
async fn run_destroy_container(coord: &Arc<Coordinator>, agent: &str) -> Result<()> {
crate::lifecycle::destroy(agent).await?;
coord.unregister_agent(agent);
let runtime = crate::paths::agent_runtime_dir(agent);
if runtime.exists() {
let _ = std::fs::remove_dir_all(&runtime);
}
Ok(())
}
/// The `purge = true` half: wipe the agent's persistent trees.
///
/// Every step is best-effort-with-a-warning rather than fatal, and that is
/// deliberate — the container is already destroyed by the time this runs, so
/// failing the node would leave the operator with a half-purged agent and a red
/// DAG, when what they can actually act on is the log line naming the path.
async fn run_purge_state(agent: &str) {
// The state root may be a btrfs subvolume: a subvolume root can't be
// removed with rmdir/`remove_dir_all`, so delete it via hive-priv (root)
// first. No-op for plain-dir agents — the loop below then handles the
// plain-dir state root plus the applied dir.
if let Err(e) = crate::priv_client::delete_agent_subvolume(agent).await {
tracing::warn!(error = ?e, %agent, "purge: delete state subvolume failed");
}
// A malformed name can't have a persistent state tree (the state dir is
// only ever created under a validated Ident), so its removal is a no-op —
// skip the state-dir sweep and just clear the applied dir.
let state_dir = hive_types::Ident::parse(agent)
.ok()
.map(|id| crate::paths::agent_state_dir(&id));
for dir in state_dir
.into_iter()
.chain([crate::paths::applied_dir(agent)])
{
if dir.exists()
&& let Err(e) = std::fs::remove_dir_all(&dir)
{
tracing::warn!(error = ?e, dir = %dir.display(), "purge: remove failed");
}
}
}
/// Post-destroy bookkeeping. Infallible by construction: every step is
/// warn-and-continue, because the destroy it follows has already succeeded and
/// none of this is undoable — a failed meta sync or power-store write is a
/// bookkeeping drift to log, not a reason to red a DAG whose container is
/// already gone.
async fn run_destroy_bookkeeping(coord: &Arc<Coordinator>, agent: &str, purge: bool) {
// Meta flake: drop the agent's input + nixosConfiguration so a future spawn
// under the same name re-seeds cleanly, and so the meta lock doesn't
// reference a vanished applied repo.
if let Err(e) = sync_meta_after_lifecycle(coord).await {
tracing::warn!(error = ?e, %agent, "meta sync after destroy failed");
}
let _ = coord.approvals.fail_pending_for_agent(
agent,
if purge {
"agent purged"
} else {
"agent destroyed"
},
);
// Drop the durable power intent — a future agent of the same name seeds
// fresh from its observed state.
if let Err(e) = coord.power.remove(agent) {
tracing::warn!(%agent, error = ?e, "agent_power: remove on destroy failed");
}
let _ = coord
.push_todo(
hive_sh4re::manager::MANAGER_AGENT,
"core",
Some(format!("destroyed:{agent}")),
format!("agent '{agent}' destroyed"),
None,
false,
)
.await;
// Container row disappeared — rescan so the dashboard fires
// `ContainerRemoved` for the gone row, then emit the tombstones snapshot
// (gained one on destroy, lost one on purge — recompute either way).
coord.rescan_containers_and_emit().await;
crate::dashboard::emit_tombstones_snapshot(coord).await;
// Re-emit the schedules snapshot: the rescan above refreshed the live
// roster, so any schedule that still targets the just-destroyed agent now
// drops that ghost column live (no page reload needed).
coord.emit_schedules_snapshot();
// Update tmpfiles.d to remove the destroyed agent's dirs from the boot-time
// pre-creation list. Best-effort: failure is logged only.
tokio::spawn(crate::lifecycle::sync_tmpfiles());
}
/// Emit this agent's rebuild-complete todo. `ok` is not computed — it is which
/// of the tail pair the graph let run. The failure note comes from the DAG's
/// first failing node, since the branch knows *that* it failed but not *why*.