swarm: show subagent terminals in the swarm UI
An agent's subagent daemon publishes each subagent's output as terminal
rows on `$SWARM.term.<agent>.sub.<subagent>`, as the agent, into a
per-agent stream it creates itself; swarm-controller lists an agent's
subagents from that stream's subjects and relays one subagent's rows as
SSE; the swarm UI lists them under the agent's terminal preview and
reuses AgentTermPreview, full-screen tab included, with no input.
- swarm-nats.nix: the agent token may also publish
`$SWARM.term.{agent}.sub.>` and `$JS.API.STREAM.CREATE|INFO` on
`term-sub-{agent}`, and nothing else of JetStream. A module-eval arm
pins the agent-token grant as an exact list.
- mcp.nix: hive-subagent-daemon loads the agent's store identity
(`hive-agent-bao-cert/-key/-server-ca`, the ones hive-agent loads)
whenever the agent has a store, not only on the opencode preset. The
agent's own queue secret lives in the store, so this is the credential
the harness connects with.
- hive-subagent-mcp: `swarm_term` reads the agent's queue secret under
that identity, connects with the agent token, opens or creates
`term-sub-<agent>` (max_age 24h), and publishes classified rows from
the sink every subagent line already passes through. The sink only
queues (bounded, drop-and-count); a missing store, refused credential,
failed stream create or failed publish is a log line.
- The stream-json classifier (`stream_enrich`) and the `TermMsg` row
types plus `fit` move from the hive-agent binary into hive-sh4re, so
the subagent daemon publishes the rows AgentTermPreview already
renders. hive-agent keeps its LiveEvent classifier on top.
- swarm-controller: `GET /api/agents/{name}/subagents` and
`GET /api/agents/{name}/subagents/{subagent}/term/stream`.
- docs/swarm: what the UI shows and what the queue carries.
Closes #4827
This commit is contained in:
parent
b90be9e65e
commit
d6f94e5247
35 changed files with 1529 additions and 340 deletions
|
|
@ -20,7 +20,8 @@
|
|||
//! **A running turn is not necessarily a working turn.** A wedged child
|
||||
//! satisfies "running" as fully as a busy one, so every line of the child's
|
||||
//! streams bumps `last_event_at` (`LivenessSink`) and `status` reports its
|
||||
//! age — the timestamp says the child is alive, never what it said.
|
||||
//! age — the timestamp says the child is alive, never what it said. What it
|
||||
//! said goes to the swarm as the subagent's terminal (`crate::swarm_term`).
|
||||
//!
|
||||
//! **`continue` doesn't pre-check the session's existence — it waits for
|
||||
//! the answer instead.** claude's own `--resume` is the authority, so
|
||||
|
|
@ -98,6 +99,8 @@ use hive_runtime::{
|
|||
use hive_sh4re::permissions::ToolGroup;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::swarm_term::Output;
|
||||
|
||||
/// How long a `continue` holds its tool call open waiting to find out
|
||||
/// whether the resume landed. A cap, not a delay: both real outcomes settle
|
||||
/// it well inside this, and it is only ever reached by a child that neither
|
||||
|
|
@ -380,6 +383,9 @@ pub struct State {
|
|||
/// What a subagent's turns run on: claude unless [`State::on_runtime`]
|
||||
/// says otherwise.
|
||||
runtime: SubagentRuntime,
|
||||
/// Where every subagent's output lines go up to the swarm, when this
|
||||
/// agent can publish there ([`State::publishing_to`]).
|
||||
term: Option<crate::swarm_term::Publisher>,
|
||||
}
|
||||
|
||||
/// Which opaque URL segment belongs to which session — the whole of a
|
||||
|
|
@ -432,9 +438,17 @@ impl State {
|
|||
signal_base,
|
||||
signal_tokens: Mutex::new(SignalTokens::default()),
|
||||
runtime: SubagentRuntime::Claude,
|
||||
term: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand every subagent's output lines to `term` as they arrive.
|
||||
#[must_use]
|
||||
pub fn publishing_to(mut self, term: Option<crate::swarm_term::Publisher>) -> Self {
|
||||
self.term = term;
|
||||
self
|
||||
}
|
||||
|
||||
/// Run every subagent on `runtime` — the daemon passes the parent
|
||||
/// agent's, read from its environment at startup. On ACP this resolves
|
||||
/// the harness dir, which only exists inside a container.
|
||||
|
|
@ -1488,23 +1502,23 @@ fn continue_on_runtime(
|
|||
}
|
||||
|
||||
/// Bumps `name`'s liveness clock on every line of the turn's output, and
|
||||
/// does nothing else with it. Replaces the `NoopSink` this daemon used to
|
||||
/// run turns against, which discarded the stream wholesale and left `status`
|
||||
/// unable to tell a working child from a wedged one.
|
||||
/// offers the line to the swarm terminal publisher when this agent has one
|
||||
/// ([`crate::swarm_term`]).
|
||||
///
|
||||
/// **All three callbacks, deliberately.** A stderr line or a stdout line
|
||||
/// that didn't parse as JSON is proof the child is alive every bit as much
|
||||
/// as a stream-json event is, and the failure that matters here is reporting
|
||||
/// a live subagent as wedged — so anything the child says counts, and what
|
||||
/// it said is never read: classifying *what* the subagent is doing is a
|
||||
/// separate question from whether it's doing anything.
|
||||
/// a live subagent as wedged — so anything the child says counts. Liveness
|
||||
/// never reads what it said: classifying *what* the subagent is doing is the
|
||||
/// publisher's job and a separate question from whether it's doing anything.
|
||||
///
|
||||
/// Sink methods are called synchronously from the driver's stream readers as
|
||||
/// lines arrive, so the body has to stay cheap — one uncontended map write
|
||||
/// is, and forwarding to a channel to do the same write elsewhere would cost
|
||||
/// more than it saved. `settle` adds a second uncontended lock on a
|
||||
/// `resume`d turn only, and finds an already-emptied slot after the first
|
||||
/// event — strictly less work than the map write next to it.
|
||||
/// event — strictly less work than the map write next to it. The publisher's
|
||||
/// `offer` copies the line into a bounded channel and never waits.
|
||||
///
|
||||
/// **Liveness counts every callback; "the turn is underway" does not.** A
|
||||
/// resume that matched nothing is not silent: claude writes the reason to
|
||||
|
|
@ -1513,8 +1527,7 @@ fn continue_on_runtime(
|
|||
/// every missed resume as a successful start. The narrowest fact that
|
||||
/// separates the two is the event's own kind — a `result` is stream-json's
|
||||
/// end-of-turn marker, so an event that isn't one is a turn still in
|
||||
/// progress. That's the envelope, not the content: nothing here reads what
|
||||
/// the subagent said.
|
||||
/// progress. That's the envelope, not the content.
|
||||
struct LivenessSink {
|
||||
state: Arc<State>,
|
||||
name: String,
|
||||
|
|
@ -1530,14 +1543,27 @@ impl hive_claude::Sink for LivenessSink {
|
|||
if event.get("type").and_then(serde_json::Value::as_str) != Some("result") {
|
||||
settle(self.verdict.as_ref(), ResumeVerdict::Underway);
|
||||
}
|
||||
self.publish(|| Output::Event(event.clone()));
|
||||
}
|
||||
|
||||
fn on_stdout_line(&self, _line: &str) {
|
||||
fn on_stdout_line(&self, line: &str) {
|
||||
self.state.note_event(&self.name);
|
||||
self.publish(|| Output::Stdout(line.to_owned()));
|
||||
}
|
||||
|
||||
fn on_stderr_line(&self, _line: &str) {
|
||||
fn on_stderr_line(&self, line: &str) {
|
||||
self.state.note_event(&self.name);
|
||||
self.publish(|| Output::Stderr(line.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
impl LivenessSink {
|
||||
/// Offer a line to the publisher, building the copy only when there is
|
||||
/// one to take it.
|
||||
fn publish(&self, line: impl FnOnce() -> Output) {
|
||||
if let Some(term) = &self.state.term {
|
||||
term.offer(&self.name, line());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue