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:
parent
ca51bb9d54
commit
1779fa0568
2 changed files with 388 additions and 1 deletions
250
swarm-wire/src/bash_task.rs
Normal file
250
swarm-wire/src/bash_task.rs
Normal 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}"#
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue