swarm-controller: let creating an agent reuse a destroyed name

Review caught a path the new declaration node breaks: an operator may
already declare an agent `Destroyed` over the per-agent state endpoint,
and `apply` then refuses any transition off that state. Before the
wanted-state node existed the refusal was inert at creation time, but
now creating an agent under a previously-destroyed name builds its
identity, repo and config, fails the declaration, and silently cancels
the deploy — while the caller sees a 200 and a job id.

`AgentState::Destroyed`'s own doc comment already sanctions this case
("no state that brings a destroyed agent back short of a fresh deploy");
nothing implemented it. Give `apply` an `Intent`, keep the refusal for
redeclares, and add `WantedWriter::create` for the one caller that is a
fresh deploy. A separate method rather than a parameter on `set`, so no
other caller can reach the override by passing an argument wrong.
This commit is contained in:
iris 2026-09-18 18:09:47 +02:00 committed by mara
commit 48aa7a1e79
2 changed files with 152 additions and 25 deletions

View file

@ -340,7 +340,12 @@ async fn declare_new_agent(
) -> hive_jobq::scheduler::Outcome {
use hive_jobq::scheduler::Outcome;
match writer.set(hive, agent, NEW_AGENT_WANTED_STATE).await {
// `create`, not `set`: a name that was destroyed earlier still carries a
// terminal entry, and recreating the agent is the fresh deploy that
// entry's own doc comment names as the way back. `set` would refuse it,
// cancelling the deploy of an agent whose identity, repo and config the
// swarm has just built.
match writer.create(hive, agent, NEW_AGENT_WANTED_STATE).await {
Ok(_) => Outcome::Done,
Err(e) => Outcome::Failed(format!("{e:#}")),
}
@ -1387,11 +1392,11 @@ fn declare_agent_job(
// unconfigured option into an agent nobody runs.
//
// `after_ok` on the declaration, though: an agent deployed without its
// pause landing first is the race this node exists to close, and the
// only way the declaration fails without a live queue is a host that has
// no queue at all — on which `TriggerDeploy` has nothing to publish to
// either, so the strict edge cancels a node that could not have
// succeeded.
// pause landing first is the race this node exists to close, so a
// declaration that did not land must not be deployed past. A host with no
// queue at all fails this node — but `TriggerDeploy` has nothing to
// publish to on such a host either, so the strict edge cancels a node
// that could not have succeeded anyway.
let _trigger_deploy = b
.node(SwarmNodeKind::TriggerDeploy {
hive: hive.to_owned(),