Watch
0
0
Fork
You've already forked hyperhive
0

swarm: cancel an agent's turn from the swarm queue

A separate $SWARM.control.<agent> subject carries commands, apart from
the text subject. swarm-controller exposes POST /api/agents/{name}/cancel
as a NATS request; the harness answers whether a turn was actually
stopped, through the same cancel path as the dashboard button. The agent
credential gets subscribe on its control subject and allow_responses to
answer; only the controller may publish there.

Refs #3803
This commit is contained in:
damocles 2026-10-14 23:08:57 +02:00 • committed by mara
commit fefc565794
11 changed files with 558 additions and 69 deletions

View file

@ -299,6 +299,26 @@ pub const AGENT_INPUT_SUBJECT_WILDCARD: &str = "$SWARM.input.*";
/// Shared by [`agent_input_subject`] and [`AGENT_INPUT_SUBJECT_WILDCARD`].
const AGENT_INPUT_SUBJECT_PREFIX: &str = "$SWARM.input";
/// The subject the swarm controller sends an operator's control command for
/// `agent` on (see [`AgentControl`]).
///
/// A family of its own, apart from [`agent_input_subject`]: a grant to send
/// text never covers a command that stops a turn, and the other way round.
/// Like the input subject it runs controller to agent over the swarm queue
/// with no hive in the path.
#[must_use]
pub fn agent_control_subject(agent: &str) -> String {
format!("{AGENT_CONTROL_SUBJECT_PREFIX}.{agent}")
}
/// The publish grant covering every [`agent_control_subject`], for the one
/// client that may send them. A wildcard for the same no-roster reason as
/// [`AGENT_INPUT_SUBJECT_WILDCARD`].
pub const AGENT_CONTROL_SUBJECT_WILDCARD: &str = "$SWARM.control.*";
/// Shared by [`agent_control_subject`] and [`AGENT_CONTROL_SUBJECT_WILDCARD`].
const AGENT_CONTROL_SUBJECT_PREFIX: &str = "$SWARM.control";
/// The subject `agent` publishes its terminal rows on. One leaf per agent, so
/// a subscriber can follow one agent without filtering the swarm's whole
/// terminal traffic.
@ -376,6 +396,37 @@ pub fn client_inbox_prefix(client_id: &str) -> String {
format!("_INBOX.client.{client_id}")
}
/// What an [`agent_control_subject`] message carries: one command for the
/// agent's harness.
///
/// The harness acts on a command only because the controller is the one
/// client granted publish on the subject, so there is no sender field to
/// trust or forge here, unlike [`AgentInput::from`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentControl {
/// Interrupt the turn in flight, as the agent's own cancel button does.
Cancel,
}
/// What an agent answers an [`AgentControl`] request with.
///
/// The controller sends a command as a request, so a reply is the only
/// confirmation the operator gets that the harness took it. No reply within
/// [`AGENT_CONTROL_REPLY_DEADLINE`] means "not confirmed", which is not the
/// same as "refused".
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AgentControlReply {
/// Whether the command changed anything: `false` for a cancel that
/// found no turn in flight.
pub acted: bool,
}
/// How long the queue lets an agent answer an [`AgentControl`] request, and
/// the controller waits for it. The harness answers as soon as it has acted,
/// so this only bounds a harness that is down or wedged.
pub const AGENT_CONTROL_REPLY_DEADLINE: std::time::Duration = std::time::Duration::from_secs(10);
/// What an [`agent_input_subject`] message carries.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AgentInput {