hive-agent: publish the agent terminal to the swarm queue

The harness has had its queue coordinates since the credential reached
the container, but nothing used them. This offers each terminal row
upward on `$SWARM.term.<hive>.<agent>`, so a swarm-level terminal can
render an agent without reaching into the hive that hosts it.

It publishes the same `TermMsg` the web UI is handed rather than a
second model of the same events, so a new tool or a reclassified event
changes both surfaces together. It subscribes to the event bus rather
than to the SSE handler: the handler classifies per connected browser,
so hanging this off it would mean an agent nobody is watching publishes
nothing. That also means its own long-lived `ClassifyCtx`, since a
publisher restarting its correlation state would lose the `tool_use` →
name mapping a `tool_result` needs to render.

The hive in the subject is derived from the queue client id, not from
the harness's hive display name. Those come from different sources with
no rule tying them together, and the responder builds its grant from the
client id — so deriving it from the display name yields a publish the
broker refuses, reaching an operator as a terminal that is merely empty.
The prefix and suffix that bracket the hive are the responder's flags,
which the agent is not told; it restates their defaults, and the symptom
of a deployment retuning one without changing this is every publish
refused rather than a wrong subject accepted.

Oversize rows degrade in the publisher. Exceeding `max_payload` is not a
truncation: the server refuses the message and closes the connection, so
an oversize publish costs the row, the connection, and the rows racing
behind it through the reconnect. The body is the only unbounded field —
summaries are already trimmed at classification — so it is the field
spent, and the row keeps its icon, level, summary and coalesce key. A
row that does not fit even then is logged and dropped rather than sent.
The limit is read off the connection, so `8388608` stays spelled once in
the queue's own module; size is measured by serializing, because JSON
escaping separates character count from wire length by an unbounded
factor on exactly the rows already near the limit.

Best-effort throughout: no queue, an unparseable client id and a failed
connect each disable the publisher with one log line, and a failed
publish loses its row and nothing else. The turn loop and the web UI
never block on the queue.

Refs #3805
This commit is contained in:
atlas 2026-09-13 11:59:55 +02:00
commit 2cdd7f2ff1
5 changed files with 375 additions and 2 deletions

View file

@ -17,6 +17,8 @@
use std::path::Path;
use std::sync::OnceLock;
use tokio::sync::OnceCell;
use swarm_queue_client::QueueConfig;
/// Variable prefix for this agent's coordinates. Distinct from `HIVE_C0RE`'s
@ -27,6 +29,11 @@ const ENV_PREFIX: &str = "HIVE_AGENT";
/// one answer rather than re-deriving it per call.
static CONFIG: OnceLock<Option<QueueConfig>> = OnceLock::new();
/// The one connection every publisher in this process shares. Separate from
/// [`CONFIG`] because resolving the coordinates is synchronous boot work and
/// connecting is not — see [`client`].
static CLIENT: OnceCell<Option<async_nats::Client>> = OnceCell::const_new();
/// The four variables the harness unit sets, before the client-id file is
/// read. Collected into a struct so [`decide`] is pure over them and the
/// process env is touched in exactly one place.
@ -158,6 +165,52 @@ pub fn init() {
let _ = CONFIG.set(resolved);
}
/// What [`init`] resolved, or `None` when this agent has no queue.
///
/// Borrowed from the `OnceLock` rather than cloned: a caller wants the client
/// id to derive its subject from, and handing out an owned copy of a struct
/// holding a credential path invites it being stored somewhere with a
/// different lifetime than the one place that owns it.
///
/// `None` before [`init`] has run, which is the same answer as "no queue" and
/// deliberately not a panic — the ordering is a boot detail, and a harness
/// that reordered its boot should lose the queue, not die.
pub fn config() -> Option<&'static QueueConfig> {
CONFIG.get()?.as_ref()
}
/// The shared queue connection, made on first call and memoized for the rest
/// of the process.
///
/// One connection per process, not per publisher: the agent authenticates as
/// one client, so a second `connect` would be a second token mint and a second
/// live connection for the same identity rather than a second credential.
///
/// `None` covers both "no queue coordinates" and "configured but the connect
/// failed" — a caller does nothing differently between them, since either way
/// there is nothing to publish onto. Connecting is lazy so that an agent on a
/// hive with no queue pays nothing at boot.
pub async fn client() -> Option<async_nats::Client> {
CLIENT.get_or_init(connect_once).await.clone()
}
async fn connect_once() -> Option<async_nats::Client> {
let cfg = config()?;
match swarm_queue_client::connect(cfg.clone()).await {
Ok(client) => Some(client),
Err(e) => {
// `chain`, not `{:#}`: this is `swarm_queue_client::Error`, whose
// `Display` ignores the alternate flag, so `{:#}` renders the
// headline and drops the cause that says which half failed.
tracing::warn!(
error = %swarm_queue_client::chain(&e),
"swarm queue connect failed; this agent publishes nothing upward"
);
None
}
}
}
#[cfg(test)]
mod tests {
use super::{QueueEnv, Resolution, decide, read_client_id};