Watch
0
0
Fork
You've already forked hyperhive
0

swarm-controller: state route refuses an agent the swarm has not declared on that hive

A placing state is accepted only for an agent declared or queued on the hive; anything else gets 404 telling the caller to create it at swarm level. swarm-ui's wanted badge no longer offers a first declaration for an undeclared row.
This commit is contained in:
atlas 2026-10-25 15:58:24 +01:00
commit 716290e4d2
3 changed files with 131 additions and 40 deletions

View file

@ -33,6 +33,14 @@ Pages that refresh on a timer share one polling rule: the page sends no new
request while the last one is still running, so an older answer never overtakes
a slow one, and a failed refresh keeps the rows already on screen under an
error.
The **wanted** badge changes the state of an agent the swarm has declared. For an
agent the swarm never declared on its hive, such as one only its hive knows
about, the badge reads **no declaration** and does not open: create the agent
with `swarmctl agent create` or **+ agent**, which declares it `paused`. The API
answers `PUT /api/hives/{hive}/agents/{agent}/state` for such an agent with
404 and says to create it at swarm level.
An agent created here or with `swarmctl agent create` starts `paused`; set it
`up` from its card. A card shows **login needed** when the agent's own
turn-state header says its claude has no session, and a **log in** button

View file

@ -1,13 +1,8 @@
// <WantedMenu> — the "wanted" state control: one badge showing the
// current declaration, opening a `Dropdown` with the four explicit
// states — replaces the old toggle-badge-plus-separate-destroy-badge
// pair. Explicit options also fix a real bug the toggle had: for an
// undeclared row, the toggle inferred a target as "the opposite of
// `snapshot.running`", so a click on an undeclared-but-running agent
// silently declared it *offline* rather than making its real state
// explicit — a dropdown just lets "up" be picked directly regardless
// of what's inferred, no flip-to-the-opposite-of-a-guess involved
// (mara: "when no state is declared, i want to set it to online").
// states. An agent with no declaration has none to change: its badge is
// disabled and points at creating it at swarm level, because the state
// route refuses an agent the swarm never declared on that hive.
// Presentational only, same split as `StatusChips`'s
// `StatusMenu`: the caller owns what each selection actually does.
//
@ -66,6 +61,8 @@ export function WantedMenu({
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
const destroyed = row.wanted === "destroyed";
const undeclared = !row.wanted;
const canDeclare = !!row.hive && !destroyed && !undeclared;
const tone: BadgeTone =
row.wanted === "up"
? "positive"
@ -87,20 +84,20 @@ export function WantedMenu({
<div class="ui-dropdown-anchor" ref={anchorRef}>
<Badge
tone={tone}
value={pending ? "…" : (row.wanted ?? "no declaration")}
// `destroyed` blocks opening the menu at all — same UX-only
// guard as before (real enforcement is server-side, see
// `declareState`'s caller), just applied to the trigger instead
// of a `disabled` toggle badge.
onClick={row.hive && !destroyed ? () => setOpen((o) => !o) : undefined}
// `destroyed` and an undeclared row block opening the menu at
// all — a UX-only guard, the server enforces both (see
// `declareState`'s caller).
onClick={canDeclare ? () => setOpen((o) => !o) : undefined}
expanded={open}
disabled={pending || !row.hive || destroyed}
disabled={pending || !canDeclare}
title={
destroyed
? `${row.name} is destroyed — redeploy via "+ agent" to bring it back`
: row.hive
? `declare a new state for ${row.name}`
: "no hive on record for this agent — nothing to declare against"
: !row.hive
? "no hive on record for this agent — nothing to declare against"
: undeclared
? `${row.name} is not declared at swarm level — create it there`
: `declare a new state for ${row.name}`
}
/>
<Dropdown

View file

@ -148,6 +148,7 @@ fn declaration_target(
responses(
(status = 200, description = "the declaration as now published", body = Vec<AgentDeclaration>),
(status = 400, description = "a name is not an identifier, the hive is not in this swarm, the state is unknown, or `agent` is new and reserved or refused by the forge (problem+json)", body = String),
(status = 404, description = "the swarm has neither declared nor queued `agent` on this hive; create it at swarm level first (problem+json)", body = String),
(status = 409, description = "the swarm has already placed `agent` on a different hive (problem+json)", body = String),
(status = 503, description = "no swarm queue is wired up, or it is not connected, or `agent` is new here and breaks a naming rule but whether it already exists could not be checked (problem+json)", body = String),
(status = 500, description = "the declaration could not be published, or another hive's wanted state could not be read (problem+json)", body = String),
@ -165,17 +166,17 @@ pub(crate) async fn set_agent_state(
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
.into_string();
// Only a first declaration of the name on this hive is checked, against
// what `create_agent` refuses: an agent already declared here changes
// state whatever its name. An undeclared name that passes is accepted:
// that is how the swarm adopts an agent that predates swarm-level
// creation. Deliberate, pending the operator's decision on whether a name
// the swarm never placed should be refused here instead.
// A placing state is accepted only for an agent the swarm already declared
// or queued on this hive; anything else is refused, so this route never
// creates an agent.
//
// The gate is held from the checks through the write. A destroy places
// nothing, so it is not checked and does not wait on creations.
let _gate = if places_agent(req.state) {
let gate = state.create_gate.lock().await;
// Before the declaration is read, so a creation finishing in between
// is in the snapshot or in the declaration.
let queued = queued_placements_now(&state);
let here = writer.view(&hive).await.map_err(|e| {
let detail = format!(
"cannot tell whether {agent:?} is already declared on hive {hive:?}, so it was \
@ -189,7 +190,7 @@ pub(crate) async fn set_agent_state(
here.as_ref(),
hive_types::reserved_names_raw().as_deref(),
|| in_roster(state.auth.as_deref(), &agent),
|| queued_placements_now(&state),
queued,
|| declarations_elsewhere(&state, &agent, &hive),
)
.await?;
@ -229,10 +230,11 @@ pub(crate) fn places_agent(state: swarm_wire::wanted::AgentState) -> bool {
}
}
/// Refuse a first placement of `agent` on `hive` that `create_agent`
/// refuses: 400 for a new name that breaks a naming rule, 409 for a name the
/// swarm has placed on another hive. An agent `here`, the hive's current
/// declaration, already declares in a placing state is placed on this hive,
/// Refuse a placement of `agent` on `hive` that the swarm never made there:
/// 400 for a new name that breaks a naming rule, 409 for a name the swarm has
/// placed on another hive, 404 for a name it has neither declared nor queued
/// on this hive. An agent `here`, the hive's current declaration, already
/// declares in a placing state, or one in `queued`, is placed on this hive,
/// and passes.
///
/// The roster is read only for a name that breaks a rule. Unlike in
@ -244,7 +246,7 @@ async fn refuse_placement<R, D>(
here: Option<&swarm_wire::wanted::HiveWanted>,
reserved: Option<&str>,
read_roster: impl FnOnce() -> R,
snapshot_queued: impl FnOnce() -> Vec<(String, String)>,
queued: Vec<(String, String)>,
read_declared: impl FnOnce() -> D,
) -> Result<(), problem_details::ProblemDetails>
where
@ -262,6 +264,7 @@ where
if declared_here {
return Ok(());
}
let queued_here = queued.iter().any(|(h, a)| h == hive && a == agent);
// Both calls log what they push here; this response has nowhere to
// carry warnings.
let mut warnings = Vec::new();
@ -278,7 +281,7 @@ where
name_verdict(agent, &broken, Ok(existing), &mut warnings)
.map_err(|detail| error_problem(axum::http::StatusCode::BAD_REQUEST, &detail))?;
}
let elsewhere = placements_elsewhere(agent, hive, snapshot_queued, read_declared).await?;
let elsewhere = placements_elsewhere(agent, hive, || queued, read_declared).await?;
if !elsewhere.is_empty() {
let detail = format!(
"agent {agent:?} is placed on hive {}, not {hive:?} — agent names are unique across \
@ -287,6 +290,17 @@ where
);
return Err(error_problem(axum::http::StatusCode::CONFLICT, &detail));
}
if !queued_here {
tracing::warn!(
hive,
agent,
"refused a state for an agent the swarm has not declared on this hive"
);
let detail = format!(
"agent {agent:?} is not declared at swarm level on hive {hive:?}; create it there"
);
return Err(error_problem(axum::http::StatusCode::NOT_FOUND, &detail));
}
Ok(())
}
@ -1853,7 +1867,7 @@ mod tests {
here.as_ref(),
Some("admin"),
|| std::future::ready(roster),
Vec::new,
Vec::new(),
|| std::future::ready(Ok(declared)),
)
.await
@ -1935,9 +1949,17 @@ mod tests {
Some(axum::http::StatusCode::SERVICE_UNAVAILABLE),
"{err:?}"
);
refuse_on_pr1ma_with("atlas", Vec::new(), unreadable())
.await
.expect("an ordinary name does not depend on the roster");
refuse_on_pr1ma_with(
"atlas",
vec![declaring(
"pr1ma",
"atlas",
swarm_wire::wanted::AgentState::Up,
)],
unreadable(),
)
.await
.expect("an ordinary name does not depend on the roster");
}
/// A destroyed agent is gone from its hive, so declaring it again is a
@ -1989,13 +2011,77 @@ mod tests {
assert!(format!("{err:?}").contains("sec0nd"), "{err:?}");
}
/// An agent that runs on `pr1ma` but was never declared anywhere, and is
/// not in the roster, is one the swarm adopts by declaring it.
/// A name the swarm never declared or queued on this hive is refused with
/// 404, naming the fix.
#[tokio::test]
async fn adopting_an_undeclared_agent_on_its_own_hive_is_accepted() {
refuse_on_pr1ma("legacy", Vec::new())
async fn an_undeclared_agent_is_refused() {
let err = refuse_on_pr1ma("legacy", Vec::new())
.await
.expect("an undeclared agent must still be adoptable");
.expect_err("an agent the swarm never declared must be refused");
assert_eq!(
err.status,
Some(axum::http::StatusCode::NOT_FOUND),
"{err:?}"
);
assert!(format!("{err:?}").contains("create it there"), "{err:?}");
}
/// An agent declared `Destroyed` here is gone from the hive, so it is
/// undeclared again.
#[tokio::test]
async fn a_destroyed_agent_is_refused_like_an_undeclared_one() {
use swarm_wire::wanted::AgentState;
let err = refuse_on_pr1ma(
"atlas",
vec![declaring("pr1ma", "atlas", AgentState::Destroyed)],
)
.await
.expect_err("a destroyed agent must be created again, not declared");
assert_eq!(
err.status,
Some(axum::http::StatusCode::NOT_FOUND),
"{err:?}"
);
}
/// Presence control for the two refusals above: a creation accepted but
/// not yet written passes.
#[tokio::test]
async fn an_agent_queued_for_this_hive_is_accepted() {
let queued = vec![("pr1ma".to_owned(), "atlas".to_owned())];
super::refuse_placement(
"atlas",
"pr1ma",
None,
Some("admin"),
|| std::future::ready(Ok(false)),
queued,
|| std::future::ready(Ok(Vec::new())),
)
.await
.expect("a queued creation is declared");
}
/// A creation queued for another hive does not declare the agent here.
#[tokio::test]
async fn an_agent_queued_for_another_hive_is_refused() {
let queued = vec![("sec0nd".to_owned(), "atlas".to_owned())];
let err = super::refuse_placement(
"atlas",
"pr1ma",
None,
Some("admin"),
|| std::future::ready(Ok(false)),
queued,
|| std::future::ready(Ok(Vec::new())),
)
.await
.expect_err("queued elsewhere is placed elsewhere");
assert_eq!(
err.status,
Some(axum::http::StatusCode::CONFLICT),
"{err:?}"
);
}
#[tokio::test]