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
137 lines
5.9 KiB
Rust
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));
|
|
}
|
|
}
|