Watch
0
0
Fork
You've already forked hyperhive
0

swarm: the contract for an agent's bash task status and output

A status key per task in the bash-task-status bucket and the task's output rows
in a per-agent stream, kept apart as for subagents. The controller creates the
bucket and the streams, and the agent credential may publish its own keys and
subjects. Nothing publishes yet; the bash MCP half follows.
This commit is contained in:
iris 2026-11-15 17:23:22 +01:00 • committed by mara
commit 1779fa0568
2 changed files with 388 additions and 1 deletions

250
swarm-wire/src/bash_task.rs Normal file
View file

@ -0,0 +1,250 @@
//! What an agent's bash MCP tells the swarm about its background tasks, so the
//! swarm UI can list the running ones and read their output without calling
//! the agent. It follows the subagents: a small status per task, kept apart
//! from the task's output so listing the tasks never reads an output stream.
//!
//! **Status** is one key per task in the [`BUCKET`] KV bucket, `<agent>.<task>`,
//! written with the agent's own queue credential, which may publish
//! `$KV.bash-task-status.<agent>.*` and nothing else of `JetStream`. The writer
//! publishes to [`status_subject`] directly instead of opening the bucket;
//! `swarm-controller` creates it (`swarm_queue_client::bash_task::open_status`).
//!
//! **Output** is rows on [`output_subject`], `$SWARM.term.<agent>.bash.<task>`,
//! in the stream [`output_stream_name`] that `swarm-controller` creates per
//! agent (`swarm_queue_client::bash_task::open_output`). The agent may publish
//! `$SWARM.term.<agent>.bash.>` and nothing else of `JetStream`. The family is
//! disjoint from the agent's own terminal (`$SWARM.term.<agent>`) and its
//! subagents' (`.sub.`), so none of their readers receives these rows.
//!
//! A task id is a single subject token: the bash MCP's task names are
//! `[a-z0-9-]` and its generated ids are hex.
use serde::{Deserialize, Serialize};
use crate::TERM_SUBJECT_PREFIX;
/// The KV bucket the task statuses are published into.
pub const BUCKET: &str = "bash-task-status";
/// How often the writer republishes a `running` status while the task's
/// process lives, so a writer that died shows up as stale.
pub const HEARTBEAT_S: i64 = 60;
/// A `running` status whose [`Status::ts`] is older than this is stale.
pub const STALE_AFTER_S: i64 = 3 * HEARTBEAT_S;
/// The longest [`Status::cmd`], in characters; a longer command is cut.
pub const CMD_MAX_CHARS: usize = 200;
/// The token between the agent and the task id in an output subject.
const BASH_TOKEN: &str = "bash";
/// How long the output stream keeps a row.
pub const OUTPUT_MAX_AGE: std::time::Duration = std::time::Duration::from_hours(24);
/// The message header on an output row naming the stream it came from:
/// `stdout` or `stderr`.
pub const HEADER_STREAM: &str = "Swarm-Stream";
/// The task's lifecycle state. The names are `hive_sh4re::bash_task::TaskStatus`'s,
/// so a writer maps one to the other without a table.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum State {
/// Accepted, not started.
Pending,
/// The process is running.
Running,
/// It exited by itself; [`Status::exit`] says how.
Done,
/// It ran past its timeout and was stopped.
TimedOut,
/// The daemon restarted while it ran, so the process is gone.
Interrupted,
/// It was killed on request.
Killed,
}
impl State {
/// Whether the task can still change: the writer keeps republishing it.
#[must_use]
pub fn is_live(self) -> bool {
matches!(self, Self::Pending | Self::Running)
}
}
/// The value under one task's key.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Status {
/// The task's lifecycle state.
pub state: State,
/// The command, cut to [`CMD_MAX_CHARS`] characters.
pub cmd: String,
/// Unix seconds when the task was accepted.
pub created: i64,
/// Unix seconds when the task entered [`Self::state`].
pub since: i64,
/// The exit status once the process exited by itself; `None` otherwise,
/// and when a signal ended it.
pub exit: Option<i32>,
/// Unix seconds when the writer published this value; a live status
/// refreshes it every [`HEARTBEAT_S`].
pub ts: i64,
}
impl Status {
/// Whether a reader at `now_s` should show the task as running: live and
/// refreshed within [`STALE_AFTER_S`].
#[must_use]
pub fn is_running(&self, now_s: i64) -> bool {
self.state.is_live() && now_s - self.ts <= STALE_AFTER_S
}
}
/// `cmd` cut to [`CMD_MAX_CHARS`] characters, with `…` when it was cut.
#[must_use]
pub fn clip_cmd(cmd: &str) -> String {
if cmd.chars().count() <= CMD_MAX_CHARS {
return cmd.to_owned();
}
let mut cut: String = cmd.chars().take(CMD_MAX_CHARS - 1).collect();
cut.push('…');
cut
}
/// The key of `task` under `agent`: `<agent>.<task>`.
#[must_use]
pub fn key(agent: &str, task: &str) -> String {
format!("{agent}.{task}")
}
/// The subject `task`'s status is written on: `$KV.<bucket>.<key>`.
#[must_use]
pub fn status_subject(agent: &str, task: &str) -> String {
format!("$KV.{BUCKET}.{}", key(agent, task))
}
/// The task a bucket key names under `agent`, or `None` for a key that is not
/// exactly one task token below `agent`.
#[must_use]
pub fn task_from_key<'a>(agent: &str, key: &'a str) -> Option<&'a str> {
let name = key.strip_prefix(agent)?.strip_prefix('.')?;
(!name.is_empty() && !name.contains('.')).then_some(name)
}
/// The stream holding `agent`'s task output: `term-bash-<agent>`.
#[must_use]
pub fn output_stream_name(agent: &str) -> String {
format!("term-bash-{agent}")
}
/// The subject `task`'s output rows are published on.
#[must_use]
pub fn output_subject(agent: &str, task: &str) -> String {
format!("{TERM_SUBJECT_PREFIX}.{agent}.{BASH_TOKEN}.{task}")
}
/// Every [`output_subject`] of `agent` at once: the stream's subjects and the
/// publish grant's shape.
#[must_use]
pub fn output_stream_subjects(agent: &str) -> String {
format!("{TERM_SUBJECT_PREFIX}.{agent}.{BASH_TOKEN}.>")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_names_match_the_queue_grant() {
assert_eq!(
status_subject("iris", "build"),
"$KV.bash-task-status.iris.build"
);
assert_eq!(output_stream_name("iris"), "term-bash-iris");
assert_eq!(
output_subject("iris", "build"),
"$SWARM.term.iris.bash.build"
);
assert_eq!(output_stream_subjects("iris"), "$SWARM.term.iris.bash.>");
}
/// The output family never overlaps the subagent family or the agent's own.
#[test]
fn the_output_subjects_are_disjoint_from_the_other_terminals() {
assert_ne!(
output_stream_subjects("iris"),
crate::subagent_term::stream_subjects("iris")
);
assert!(
!output_subject("iris", "x").starts_with(&crate::subagent_term::subject("iris", ""))
);
}
#[test]
fn a_key_names_one_task_of_its_agent_or_nothing() {
assert_eq!(task_from_key("iris", "iris.build"), Some("build"));
assert_eq!(task_from_key("iris", "iris.a.b"), None);
assert_eq!(task_from_key("iris", "iris."), None);
assert_eq!(task_from_key("iris", "irisx.build"), None);
}
#[test]
fn only_a_pending_or_running_task_is_live() {
assert!(State::Pending.is_live() && State::Running.is_live());
for s in [
State::Done,
State::TimedOut,
State::Interrupted,
State::Killed,
] {
assert!(!s.is_live(), "{s:?}");
}
}
#[test]
fn a_live_status_goes_stale_after_three_missed_heartbeats() {
let status = |state, ts| Status {
state,
cmd: "x".to_owned(),
created: 0,
since: 0,
exit: None,
ts,
};
let live = status(State::Running, 10_000);
assert!(live.is_running(10_000 + STALE_AFTER_S));
assert!(!live.is_running(10_000 + STALE_AFTER_S + 1));
assert!(!status(State::Done, 10_000).is_running(10_000));
}
#[test]
fn a_long_command_is_cut_with_an_ellipsis() {
assert_eq!(clip_cmd("ls"), "ls");
let long = "x".repeat(CMD_MAX_CHARS + 50);
let cut = clip_cmd(&long);
assert_eq!(cut.chars().count(), CMD_MAX_CHARS);
assert!(cut.ends_with('…'));
assert_eq!(
clip_cmd(&"y".repeat(CMD_MAX_CHARS)).chars().count(),
CMD_MAX_CHARS
);
}
#[test]
fn the_wire_form_is_snake_case_json() {
let wire = serde_json::to_string(&Status {
state: State::TimedOut,
cmd: "sleep 9".to_owned(),
created: 1,
since: 2,
exit: None,
ts: 3,
})
.expect("encodes");
assert_eq!(
wire,
r#"{"state":"timed_out","cmd":"sleep 9","created":1,"since":2,"exit":null,"ts":3}"#
);
}
}

View file

@ -16,6 +16,7 @@ pub mod agent_permissions;
pub mod agent_schedules;
pub mod agent_status;
pub mod agent_term;
pub mod bash_task;
pub mod builder;
pub mod claude_login;
pub mod egress_allow;