Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-agent/src/swarm_term.rs
atlas ae9ca4331e swarm: keep an agent's terminal rows and replay them on attach
swarm-controller now creates `term-main-<agent>` for every live agent, in
the same one-minute pass and with the same config style as
`term-sub-<agent>` (#4897): subjects `$SWARM.term.<agent>` only, max_age
24h, max_bytes 64 MiB, discard Old, max_message_size from the queue's
max_payload. An existing stream is opened as it is. The agent keeps its
plain publish on `$SWARM.term.<agent>` and gets no JetStream subject.

`GET /api/agents/{name}/term/stream` creates an ephemeral push consumer on
that stream starting 100 rows back, so a swarm-ui terminal opens with
recent history and then follows new rows, in order, from one
subscription. Each SSE event carries the row's stream sequence as its id,
and a reconnect's Last-Event-ID resumes after it. Without the stream the
route falls back to the live core subscription it used before.

The stream is `term-main-<agent>`, not `term-<agent>`: an agent may be
named `sub-x`, whose `term-<agent>` would be agent `x`'s subagent stream.
Its one literal subject cannot match `$SWARM.term.<agent>.sub.>`, which
needs more tokens, so the two streams never overlap.

The reader gains `$JS.API.CONSUMER.CREATE.*.*.$SWARM.term.*`, the
extended create form whose last tokens are the consumer's filter. Checked
against nats-server 2.14.4, the trailing `*` also admits a literal `*` or
`>` filter, so a consumer under it reaches any terminal row, main or
subagent; those are rows the reader already subscribes to and reads with
`STREAM.MSG.GET.*`.

Closes #4826
2026-10-11 12:35:31 +02:00

137 lines
5.9 KiB
Rust

//! Publishing this agent's terminal rows onto the swarm queue.
//!
//! The per-agent web UI already classifies the event bus into [`TermMsg`]
//! rows; this offers the same rows upward so a swarm-level terminal can
//! render an agent without reaching into its hive. It publishes the value the
//! web UI is handed rather than a second model, so a new tool or a
//! reclassified event changes both surfaces at once.
//!
//! **Subscribes to the bus, not to a browser.** The SSE handler builds its
//! rows per connected client, so hanging this off it would mean an agent
//! whose terminal nobody has open publishes nothing. This is its own
//! subscriber with its own long-lived [`ClassifyCtx`] — a publisher that
//! restarted its correlation state per reader would lose the `tool_use` →
//! name mapping a `tool_result` needs.
//!
//! **A plain core publish.** The queue keeps the rows in the stream
//! `term-main-<agent>`, which swarm-controller creates
//! (`swarm_queue_client::agent_term`), so this needs no `JetStream` call and
//! the agent's grant stays a plain publish. A row published before that
//! stream exists reaches live subscribers only.
//!
//! **Best-effort per row.** The turn loop and the web UI never wait on the
//! queue, so the bus receiver is the only thing this task blocks on; a failed
//! publish is a log line and the next row is still attempted.
use tokio::sync::broadcast;
use crate::events::BusEvent;
use crate::term_msg::{ClassifyCtx, TermMsg, classify};
use hive_sh4re::term_msg::fit;
/// How much room to leave under the announced limit for everything the
/// publish adds around the payload — subject, headers, protocol framing.
///
/// The server measures the payload alone, so this is not required for
/// correctness; it is here because the cost of being one byte over is not one
/// lost byte but a refused message and a dropped connection, and a kilobyte
/// out of eight megabytes buys that margin for nothing.
const HEADROOM: usize = 1024;
/// Start the publish task, if this agent has a label to name its subject with.
pub fn spawn(rx: broadcast::Receiver<BusEvent>) {
let agent = crate::identity::label();
if agent.is_empty() {
tracing::warn!("this agent has no label; not publishing the terminal upward");
return;
}
tokio::spawn(run(rx, agent));
}
async fn run(mut rx: broadcast::Receiver<BusEvent>, agent: String) {
let client = crate::swarm_queue::client().await;
let subject = swarm_queue_client::term_subject(&agent);
tracing::info!(subject, "publishing the agent terminal to the swarm queue");
// One context for the task's whole life — see the module doc.
let mut ctx = ClassifyCtx::default();
loop {
let event = match rx.recv().await {
Ok(event) => event,
// The bus drops events for a subscriber that falls behind. The
// terminal is a tail rather than a log, so the answer is to say
// how many were missed and keep reading, exactly as the web UI's
// own subscriber does.
Err(broadcast::error::RecvError::Lagged(missed)) => {
tracing::warn!(missed, "swarm terminal: lagged, rows skipped");
continue;
}
// Every sender is gone, so the harness is shutting down.
Err(broadcast::error::RecvError::Closed) => return,
};
for msg in classify(&event.event, event.ts, &mut ctx) {
publish(&client, &subject, msg).await;
}
}
}
/// Offer one row. Every failure is terminal for that row and for nothing else.
async fn publish(client: &async_nats::Client, subject: &str, msg: TermMsg) {
// An unconnected client does not fail a publish, it buffers it — and the
// limit read below is the library's pre-connect default until the server
// has announced its own, which is smaller than any deployment sets and
// would degrade rows that would have fit.
if let Err(e) = swarm_queue_client::ensure_connected(client) {
tracing::warn!(error = %swarm_queue_client::chain(&e), "swarm terminal: publish skipped");
return;
}
let limit = swarm_queue_client::max_payload(client).saturating_sub(HEADROOM);
let summary = msg.summary.clone();
let Some(msg) = fit(msg, limit) else {
tracing::warn!(
summary,
limit,
"swarm terminal: row does not fit even without its body, dropped"
);
return;
};
let payload = match serde_json::to_vec(&msg) {
Ok(payload) => payload,
Err(e) => {
tracing::warn!(error = %e, "swarm terminal: serialising failed, row dropped");
return;
}
};
if let Err(e) = client.publish(subject.to_owned(), payload.into()).await {
tracing::warn!(error = %e, "swarm terminal: publish failed, row dropped");
}
}
#[cfg(test)]
mod tests {
use crate::events::LiveEvent;
use crate::term_msg::{ClassifyCtx, classify};
use hive_sh4re::term_msg::fit;
/// The time `classify` renders for the event the test below classifies.
const ROW_TS: &str = "2026-09-13T12:35:03Z";
/// What a subscriber actually receives: the payload this module hands
/// `publish` is the bare row, so the time has to be *in* it. A `ts` that
/// existed in Rust but never serialized would leave the queue exactly as
/// timeless as it was before.
#[test]
fn the_published_payload_carries_the_events_own_time() {
let ev = LiveEvent::TurnEnd {
ok: true,
note: None,
};
let mut ctx = ClassifyCtx::default();
// 2026-09-13T12:35:03Z, a time that is not the time of this run.
let msgs = classify(&ev, 1_789_302_903, &mut ctx);
let payload = serde_json::to_vec(&fit(msgs[0].clone(), 4096).expect("a small row fits"))
.expect("serialises");
let value: serde_json::Value = serde_json::from_slice(&payload).expect("valid json");
assert_eq!(value["ts"], serde_json::json!(ROW_TS));
}
}