wanted: converge a destroyed declaration by tearing the container down

This commit is contained in:
damocles 2026-09-07 18:01:36 +02:00 committed by mara
commit 6fd5bc1c4f
2 changed files with 61 additions and 8 deletions

View file

@ -212,6 +212,7 @@ async fn converge(coord: &Arc<Coordinator>, declared: &HiveWanted) -> Result<()>
deploy = plan.deploy.len(),
start = plan.start.len(),
stop = plan.stop.len(),
destroy = plan.destroy.len(),
"wanted state: converging"
);
@ -234,8 +235,16 @@ async fn converge(coord: &Arc<Coordinator>, declared: &HiveWanted) -> Result<()>
.await
.context("queueing the declared stops")?;
}
// No purge: this issue's own scope is tearing the container down, not
// wiping its persistent state. `actions::destroy` submits its own DAG
// node and emits its own queue snapshot, so there is nothing to await
// or fold into `queued` here — same fire-and-forget contract every other
// lifecycle op in that module has.
for agent in &plan.destroy {
crate::actions::destroy(coord, agent, false);
}
if queued {
// The power ops emit their own; the first deploys do not.
// The power ops and destroy emit their own; the first deploys do not.
coord.emit_rebuild_queue_snapshot();
}
Ok(())
@ -247,6 +256,7 @@ struct Plan {
deploy: Vec<String>,
start: Vec<String>,
stop: Vec<String>,
destroy: Vec<String>,
}
/// Turn one declaration into that plan — pure, so what the loop does with a
@ -274,6 +284,7 @@ fn plan(
Converge::Deploy => plan.deploy.push(agent.clone()),
Converge::Start => plan.start.push(agent.clone()),
Converge::Stop => plan.stop.push(agent.clone()),
Converge::Destroy => plan.destroy.push(agent.clone()),
Converge::Nothing => {}
}
}
@ -288,6 +299,10 @@ enum Converge {
Deploy,
Start,
Stop,
/// Declared destroyed, and this hive still has a container for it — run
/// the teardown once. Irreversible, so unlike `Start`/`Stop` there is no
/// intent to compare against: presence alone decides it.
Destroy,
/// The hive already agrees with the declaration.
Nothing,
}
@ -305,9 +320,11 @@ fn decide(state: AgentState, present: bool, intent: Option<Wanted>) -> Converge
Converge::Start
}
}
// Declared offline and not here: creating a container in order to
// leave it stopped is not what the declaration asks for.
AgentState::Offline if !present => Converge::Nothing,
// Declared offline/destroyed and not here: creating a container in
// order to leave it stopped, or to immediately re-destroy it, is not
// what either declaration asks for — absence plus a stopped-or-gone
// declaration is agreement, not a repair to queue.
AgentState::Offline | AgentState::Destroyed if !present => Converge::Nothing,
AgentState::Offline => {
if intent == Some(Wanted::Offline) {
Converge::Nothing
@ -315,6 +332,10 @@ fn decide(state: AgentState, present: bool, intent: Option<Wanted>) -> Converge
Converge::Stop
}
}
// Power intent plays no part here — a destroyed agent has no
// running/stopped distinction left to converge to, only present or
// not.
AgentState::Destroyed => Converge::Destroy,
}
}
@ -364,6 +385,7 @@ mod tests {
deploy: vec![],
start: vec!["adopted".to_owned()],
stop: vec![],
destroy: vec![],
}
);
}
@ -432,6 +454,29 @@ mod tests {
assert_eq!(decide(AgentState::Offline, false, None), Converge::Nothing);
}
/// A present agent declared destroyed gets torn down — the whole point of
/// this state. Intent is irrelevant, unlike `Up`/`Offline`: even an
/// agent whose power intent is `Up` still gets destroyed, since a
/// terminal declaration outranks a steady-state one.
#[test]
fn a_present_agent_declared_destroyed_is_destroyed() {
assert_eq!(decide(AgentState::Destroyed, true, None), Converge::Destroy);
assert_eq!(
decide(AgentState::Destroyed, true, Some(Wanted::Up)),
Converge::Destroy
);
}
/// Already gone: destroying an absent agent would be acting on nothing.
/// Its control is the present case above, which must destroy.
#[test]
fn an_absent_agent_declared_destroyed_is_left_absent() {
assert_eq!(
decide(AgentState::Destroyed, false, None),
Converge::Nothing
);
}
/// The watch's half of "absence is not a deletion order". `Put` is the
/// control: without it this would pass on a function that refused
/// everything, which would silently stop the fast path converging at all.
@ -456,7 +501,7 @@ mod tests {
/// pass forever without measuring anything.
#[test]
fn every_state_this_build_knows_is_covered_above() {
for state in [AgentState::Up, AgentState::Offline] {
for state in [AgentState::Up, AgentState::Offline, AgentState::Destroyed] {
let seen = [true, false].iter().any(|present| {
decide(state, *present, None) != Converge::Nothing
|| decide(state, *present, Some(Wanted::Up)) != Converge::Nothing

View file

@ -91,6 +91,11 @@ pub enum AgentState {
Up,
/// Exists on the hive and is not running.
Offline,
/// Torn down entirely — the hive runs the destroy template once, then
/// this agent drops out of the declared set rather than staying as a
/// third steady state. Irreversible: there is no state that brings a
/// destroyed agent back short of a fresh deploy.
Destroyed,
}
impl AgentState {
@ -103,6 +108,7 @@ impl AgentState {
match self {
AgentState::Up => "up",
AgentState::Offline => "offline",
AgentState::Destroyed => "destroyed",
}
}
}
@ -211,10 +217,12 @@ mod tests {
#[test]
fn the_known_states_decode_and_round_trip() {
let doc = r#"{"agents":{"a":{"state":"up"},"b":{"state":"offline"}}}"#;
let doc =
r#"{"agents":{"a":{"state":"up"},"b":{"state":"offline"},"c":{"state":"destroyed"}}}"#;
let decoded: HiveWanted = serde_json::from_str(doc).expect("decodes");
assert_eq!(decoded.agents["a"].state, AgentState::Up);
assert_eq!(decoded.agents["b"].state, AgentState::Offline);
assert_eq!(decoded.agents["c"].state, AgentState::Destroyed);
assert_eq!(serde_json::to_string(&decoded).expect("serialises"), doc);
}
@ -253,10 +261,10 @@ mod tests {
/// to compile here rather than quietly going untested.
#[test]
fn as_str_matches_the_serde_spelling() {
let every = [AgentState::Up, AgentState::Offline];
let every = [AgentState::Up, AgentState::Offline, AgentState::Destroyed];
for state in every {
match state {
AgentState::Up | AgentState::Offline => {}
AgentState::Up | AgentState::Offline | AgentState::Destroyed => {}
}
assert_eq!(
serde_json::to_string(&state).expect("serialises"),