Watch
0
0
Fork
You've already forked hyperhive
0

swarmctl: agent set-state declares an agent's wanted state

Wraps PUT /api/hives/{hive}/agents/{agent}/state, the route the swarm UI's
state menu uses. States come from swarm_wire::wanted::AgentState; destroyed
needs --yes.

Closes #6159
This commit is contained in:
atlas 2026-11-07 03:58:39 +01:00 • committed by mara
commit 71d360c9d2
6 changed files with 187 additions and 1 deletions

1
Cargo.lock generated
View file

@ -5366,6 +5366,7 @@ dependencies = [
"serde_norway", "serde_norway",
"sha2 0.11.1", "sha2 0.11.1",
"swarm-controller-api", "swarm-controller-api",
"swarm-wire",
"tokio", "tokio",
"url", "url",
] ]

View file

@ -8,6 +8,7 @@ This document contains the help content for the `swarmctl` command-line program.
* [`swarmctl agent`↴](#swarmctl-agent) * [`swarmctl agent`↴](#swarmctl-agent)
* [`swarmctl agent create`↴](#swarmctl-agent-create) * [`swarmctl agent create`↴](#swarmctl-agent-create)
* [`swarmctl agent mint-forge-token`↴](#swarmctl-agent-mint-forge-token) * [`swarmctl agent mint-forge-token`↴](#swarmctl-agent-mint-forge-token)
* [`swarmctl agent set-state`↴](#swarmctl-agent-set-state)
* [`swarmctl agent inference`↴](#swarmctl-agent-inference) * [`swarmctl agent inference`↴](#swarmctl-agent-inference)
* [`swarmctl agent inference get`↴](#swarmctl-agent-inference-get) * [`swarmctl agent inference get`↴](#swarmctl-agent-inference-get)
* [`swarmctl agent inference set`↴](#swarmctl-agent-inference-set) * [`swarmctl agent inference set`↴](#swarmctl-agent-inference-set)
@ -72,6 +73,7 @@ Manage agents across the swarm
* `create` — Queue creation of a new agent on a hive in this swarm * `create` — Queue creation of a new agent on a hive in this swarm
* `mint-forge-token` — Check one agent's forge token, and mint it if it's missing or stale * `mint-forge-token` — Check one agent's forge token, and mint it if it's missing or stale
* `set-state` — Declare the state a hive converges an agent to: `up`, `offline`, `paused` or `destroyed`
* `inference` — Show the model, effort, subagent models and subagent cap the swarm sets for an agent, or change the model, effort and subagent models * `inference` — Show the model, effort, subagent models and subagent cap the swarm sets for an agent, or change the model, effort and subagent models
* `permissions` — Show or change the permissions the swarm grants an agent * `permissions` — Show or change the permissions the swarm grants an agent
* `agents-md` — Show an agent's AGENTS.md, or edit it with a diff * `agents-md` — Show an agent's AGENTS.md, or edit it with a diff
@ -132,6 +134,33 @@ Queues and returns, the same way `agent create` does — watch the swarm UI's jo
## `swarmctl agent set-state`
Declare the state a hive converges an agent to: `up`, `offline`, `paused` or `destroyed`.
Writes the swarm's declaration for the agent, the one the swarm UI's state menu writes, and returns once the controller has published it; the hive converges on its own clock. The swarm must already know the agent on `--hive`: this never creates one.
`destroyed` can't be undone, so it needs `--yes`.
No approval gate guards this: the caller is root on the controller's host, or logged in through the swarm domain's SSO gate.
**Usage:** `swarmctl agent set-state [OPTIONS] --hive <HIVE> <AGENT> <STATE>`
###### **Arguments:**
* `<AGENT>` — Agent name
* `<STATE>` — The state to declare: `up`, `offline`, `paused` or `destroyed`
###### **Options:**
* `--hive <HIVE>` — The hive that runs the agent
* `--yes` — Confirm `destroyed`, which can't be undone
* `--controller-socket <PATH>` — swarm-controller's unix socket.
Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`.
## `swarmctl agent inference` ## `swarmctl agent inference`
Show the model, effort, subagent models and subagent cap the swarm sets for an agent, or change the model, effort and subagent models. Show the model, effort, subagent models and subagent cap the swarm sets for an agent, or change the model, effort and subagent models.

View file

@ -36,6 +36,8 @@ serde_norway = "0.9.42"
sha2.workspace = true sha2.workspace = true
# The request and response bodies, shared with swarm-controller. # The request and response bodies, shared with swarm-controller.
swarm-controller-api.workspace = true swarm-controller-api.workspace = true
# `AgentState`, the values `agent set-state` takes.
swarm-wire.workspace = true
tokio.workspace = true tokio.workspace = true
url.workspace = true url.workspace = true

View file

@ -133,6 +133,19 @@ agent's forge token and mints it if it's missing or stale. The controller
already does this for every agent with a store identity at start and every already does this for every agent with a store identity at start and every
five minutes; this verb skips the wait. Queues and returns. five minutes; this verb skips the wait. Queues and returns.
## `agent set-state`
```console
# swarmctl agent set-state ruth --hive alpha paused
declared agent "ruth" paused on hive "alpha"; the hive converges on its own clock
```
`PUT /api/hives/{hive}/agents/{agent}/state` on the swarm-controller, the route
the swarm UI's state menu uses. The values are `swarm_wire::wanted::AgentState`'s:
`up`, `offline`, `paused`, `destroyed`. It never creates an agent: the controller
refuses an agent the swarm hasn't declared on that hive. `destroyed` can't be
undone, so it needs `--yes`.
## `forge make-admin` ## `forge make-admin`
```console ```console

View file

@ -112,6 +112,51 @@ pub(crate) fn mint_forge_token(target: &Target, name: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// Parse a state as the wire enum spells it, so the accepted values are
/// `swarm_wire::wanted::AgentState`'s and the error lists them.
pub(crate) fn parse_state(value: &str) -> Result<swarm_wire::wanted::AgentState, String> {
serde_json::from_value(serde_json::Value::String(value.to_owned())).map_err(|e| e.to_string())
}
/// Refuse `destroyed` unless the operator confirmed it.
fn check_confirmed(state: swarm_wire::wanted::AgentState, yes: bool) -> Result<()> {
if state == swarm_wire::wanted::AgentState::Destroyed && !yes {
bail!("`destroyed` can't be undone: pass --yes to declare it");
}
Ok(())
}
/// Run `swarmctl agent set-state`: `PUT /api/hives/{hive}/agents/{agent}/state`.
pub(crate) fn set_state(
target: &Target,
agent: &str,
hive: &str,
state: swarm_wire::wanted::AgentState,
yes: bool,
) -> Result<()> {
check_confirmed(state, yes)?;
let agent = parse_ident(agent, "agent name")?;
let hive = parse_ident(hive, "hive")?;
let wire: &'static str = state.into();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("starting a tokio runtime for the controller request")?;
rt.block_on(send::<_, serde_json::Value>(
target,
hyper::Method::PUT,
&format!("/api/hives/{hive}/agents/{agent}/state"),
Some(&serde_json::json!({ "state": wire })),
"set-state",
))?;
println!(
"declared agent {agent:?} {wire} on hive {hive:?}; the hive converges on its own clock"
);
Ok(())
}
/// One `POST /api/agents` round trip at the controller. /// One `POST /api/agents` round trip at the controller.
async fn post_create( async fn post_create(
target: &Target, target: &Target,
@ -280,7 +325,33 @@ fn connect_hint(socket: &Path) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{describe_error, parse_ident}; use super::{check_confirmed, describe_error, parse_ident, parse_state};
use swarm_wire::wanted::AgentState;
/// The accepted values are the wire enum's: each spelling it writes
/// parses back, and an unknown one names the valid ones.
#[test]
fn states_parse_through_the_wire_enum() {
for state in [
AgentState::Up,
AgentState::Offline,
AgentState::Paused,
AgentState::Destroyed,
] {
let wire: &'static str = state.into();
assert_eq!(parse_state(wire), Ok(state));
}
let err = parse_state("sleeping").expect_err("unknown state");
assert!(err.contains("paused"), "{err}");
}
#[test]
fn destroyed_needs_yes() {
assert!(check_confirmed(AgentState::Destroyed, false).is_err());
assert!(check_confirmed(AgentState::Destroyed, true).is_ok());
// Control: the other states don't ask.
assert!(check_confirmed(AgentState::Paused, false).is_ok());
}
/// The whole point of parsing problem+json: the 400 for an unknown /// The whole point of parsing problem+json: the 400 for an unknown
/// hive carries the roster of hives that would have worked, and that /// hive carries the roster of hives that would have worked, and that

View file

@ -279,6 +279,19 @@ enum AgentVerb {
/// Queues and returns, the same way `agent create` does — watch the /// Queues and returns, the same way `agent create` does — watch the
/// swarm UI's job view for the outcome. /// swarm UI's job view for the outcome.
MintForgeToken(AgentMintForgeTokenArgs), MintForgeToken(AgentMintForgeTokenArgs),
/// Declare the state a hive converges an agent to: `up`, `offline`,
/// `paused` or `destroyed`.
///
/// Writes the swarm's declaration for the agent, the one the swarm UI's
/// state menu writes, and returns once the controller has published it;
/// the hive converges on its own clock. The swarm must already know the
/// agent on `--hive`: this never creates one.
///
/// `destroyed` can't be undone, so it needs `--yes`.
///
/// No approval gate guards this: the caller is root on the controller's
/// host, or logged in through the swarm domain's SSO gate.
SetState(AgentSetStateArgs),
/// Show the model, effort, subagent models and subagent cap the swarm sets /// Show the model, effort, subagent models and subagent cap the swarm sets
/// for an agent, or change the model, effort and subagent models. /// for an agent, or change the model, effort and subagent models.
/// ///
@ -415,6 +428,23 @@ struct InferenceSetArgs {
socket: ControllerSocketArg, socket: ControllerSocketArg,
} }
#[derive(Args)]
struct AgentSetStateArgs {
/// Agent name.
agent: String,
/// The hive that runs the agent.
#[arg(long, value_name = "HIVE")]
hive: String,
/// The state to declare: `up`, `offline`, `paused` or `destroyed`.
#[arg(value_name = "STATE", value_parser = agent::parse_state)]
state: swarm_wire::wanted::AgentState,
/// Confirm `destroyed`, which can't be undone.
#[arg(long)]
yes: bool,
#[command(flatten)]
socket: ControllerSocketArg,
}
#[derive(Args)] #[derive(Args)]
struct AgentCreateArgs { struct AgentCreateArgs {
/// Name for the new agent: 1–63 characters of `[a-z0-9-]`. /// Name for the new agent: 1–63 characters of `[a-z0-9-]`.
@ -690,6 +720,12 @@ fn main() -> Result<()> {
let target = remote.target(args.controller_socket)?; let target = remote.target(args.controller_socket)?;
agent::mint_forge_token(&target, &args.name) agent::mint_forge_token(&target, &args.name)
} }
Verb::Agent {
command: AgentVerb::SetState(args),
} => {
let target = args.socket.resolve(&remote)?;
agent::set_state(&target, &args.agent, &args.hive, args.state, args.yes)
}
Verb::Agent { Verb::Agent {
command: AgentVerb::Inference { command }, command: AgentVerb::Inference { command },
} => inference(&remote, command), } => inference(&remote, command),
@ -1265,6 +1301,40 @@ fn open_new_nofollow(tmp: &Path) -> io::Result<File> {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn set_state_takes_the_wire_enum_values() {
let cli = Cli::try_parse_from([
"swarmctl",
"agent",
"set-state",
"iris",
"--hive",
"alpha",
"paused",
])
.expect("parses");
let Verb::Agent {
command: AgentVerb::SetState(args),
} = cli.command
else {
panic!("parsed as another verb");
};
assert_eq!(args.state, swarm_wire::wanted::AgentState::Paused);
assert!(!args.yes);
assert!(
Cli::try_parse_from([
"swarmctl",
"agent",
"set-state",
"iris",
"--hive",
"alpha",
"sleeping",
])
.is_err()
);
}
/// Root on the controller host, with no `--swarm` and no login: the verbs /// Root on the controller host, with no `--swarm` and no login: the verbs
/// still go over the unix socket, unchanged by the remote path. /// still go over the unix socket, unchanged by the remote path.
#[test] #[test]