Watch
0
0
Fork
You've already forked hyperhive
0

swarm: subagent-status KV bucket, the run state of each subagent

One key per subagent (<agent>.<subagent>) holding running/finished, since,
exit, model, effort and the publish time. The controller creates the bucket,
and the agent credential may publish its own keys. Nothing reads or writes
it yet; the daemon publisher and the controller list follow.
This commit is contained in:
iris 2026-11-15 14:39:37 +01:00 • committed by mara
commit c7d7f0df55
2 changed files with 214 additions and 0 deletions

View file

@ -29,6 +29,7 @@ pub mod router_api;
pub mod router_sink;
pub mod router_todo;
pub mod stt;
pub mod subagent_status;
pub mod subagent_term;
pub mod wanted;

View file

@ -0,0 +1,164 @@
//! The run state of an agent's subagents: one key per subagent in the
//! `subagent-status` KV bucket, holding whether it is running and since when.
//!
//! The terminal rows of a subagent (`crate::subagent_term`) say what it
//! printed, not whether it is still going: a subagent that thinks quietly
//! publishes nothing, and one that finished leaves its last row behind. The
//! swarm UI used to guess from the age of the newest row. This bucket says it
//! outright, and keeps it apart from the rows so listing the subagents of an
//! agent reads one small value each and never touches a terminal stream.
//!
//! **Each agent's subagent daemon writes its own keys**, with the agent's own
//! queue credential. The auth callout grants that credential
//! `$KV.subagent-status.<agent>.*` and nothing else of `JetStream`. The writer
//! publishes to [`subject`] directly instead of opening the bucket, as the
//! agent icon writer does (`crate::agent_icon`); `swarm-controller` creates the
//! bucket (`swarm_queue_client::subagent_status::open_or_create`). A write that
//! arrives before the bucket exists is refused with "no stream", and the
//! writer tries again on its next heartbeat.
//!
//! **Keyed by the agent and the subagent**, `<agent>.<subagent>`, so an agent
//! moving between hives keeps its entries, for the reason
//! `crate::agent_icon` gives. A subagent name is a single subject token, the
//! same rule `crate::subagent_term::subagent_from_subject` applies to the
//! terminal subjects.
use serde::{Deserialize, Serialize};
/// The KV bucket the statuses are published into.
///
/// A constant and not an option, for the reason `crate::agent_status::BUCKET`
/// gives: writer and reader must name the same bucket.
pub const BUCKET: &str = "subagent-status";
/// How often the writer republishes a `running` status while the subagent's
/// process lives, so a writer that died shows up as stale instead of running
/// forever.
pub const HEARTBEAT_S: i64 = 60;
/// A `running` status whose [`Status::ts`] is older than this is stale: the
/// writer missed three heartbeats in a row. Readers show it as not running.
pub const STALE_AFTER_S: i64 = 3 * HEARTBEAT_S;
/// Whether a subagent's process is alive.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum State {
/// A run is in progress.
Running,
/// The last run ended; [`Status::exit`] says how.
Finished,
}
/// The value under one subagent's key.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Status {
/// Whether the subagent is running.
pub state: State,
/// Unix seconds when the subagent entered [`Self::state`].
pub since: i64,
/// The process's exit status once it finished. `None` while running, and
/// when the process was killed by a signal.
pub exit: Option<i32>,
/// The model it runs; `None` on the harness's default.
pub model: Option<String>,
/// Its reasoning effort; `None` when not set.
pub effort: Option<String>,
/// Unix seconds when the writer published this value. A `running` status
/// refreshes it every [`HEARTBEAT_S`].
pub ts: i64,
}
impl Status {
/// Whether a reader at `now_s` (unix seconds) should show the subagent as
/// running: [`State::Running`] and refreshed within [`STALE_AFTER_S`].
#[must_use]
pub fn is_running(&self, now_s: i64) -> bool {
self.state == State::Running && now_s - self.ts <= STALE_AFTER_S
}
}
/// The key of `subagent` under `agent`: `<agent>.<subagent>`.
#[must_use]
pub fn key(agent: &str, subagent: &str) -> String {
format!("{agent}.{subagent}")
}
/// The subject `subagent`'s status is written on: `$KV.<bucket>.<key>`.
///
/// A KV put is a `JetStream` publish to this subject, so a writer that
/// publishes directly has to use the same subject the bucket's `get` reads.
#[must_use]
pub fn subject(agent: &str, subagent: &str) -> String {
format!("$KV.{BUCKET}.{}", key(agent, subagent))
}
/// The subagent a bucket key names under `agent`, or `None` for a key that is
/// not exactly one subagent token below `agent`.
#[must_use]
pub fn subagent_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)
}
#[cfg(test)]
mod tests {
use super::*;
/// Two tokens after the bucket, the agent and the subagent: what the
/// per-agent grant `$KV.subagent-status.{agent}.*` expands to.
#[test]
fn the_published_subject_carries_the_agent_and_the_subagent() {
assert_eq!(subject("iris", "scout"), "$KV.subagent-status.iris.scout");
}
#[test]
fn a_key_names_one_subagent_of_its_agent_or_nothing() {
assert_eq!(subagent_from_key("iris", "iris.scout"), Some("scout"));
assert_eq!(subagent_from_key("iris", "iris.a.b"), None);
assert_eq!(subagent_from_key("iris", "iris."), None);
assert_eq!(subagent_from_key("iris", "irisx.scout"), None);
assert_eq!(subagent_from_key("iris", "other.scout"), None);
}
fn status(state: State, ts: i64) -> Status {
Status {
state,
since: 1_000,
exit: None,
model: None,
effort: None,
ts,
}
}
#[test]
fn a_running_status_goes_stale_after_three_missed_heartbeats() {
let s = status(State::Running, 10_000);
assert!(s.is_running(10_000));
assert!(s.is_running(10_000 + STALE_AFTER_S));
assert!(!s.is_running(10_000 + STALE_AFTER_S + 1));
}
#[test]
fn a_finished_status_is_never_running() {
assert!(!status(State::Finished, 10_000).is_running(10_000));
}
#[test]
fn the_wire_form_is_snake_case_json() {
let wire = serde_json::to_string(&Status {
state: State::Finished,
since: 5,
exit: Some(0),
model: Some("opus".to_owned()),
effort: None,
ts: 6,
})
.expect("encodes");
assert_eq!(
wire,
r#"{"state":"finished","since":5,"exit":0,"model":"opus","effort":null,"ts":6}"#
);
}
}