Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/agent_state_stream.rs
atlas f08a9cadb6 swarm-controller: end a dead replay, clamp stale resume ids, test on a real server
A replay consumer now has a 5s idle heartbeat, and the row stream ends at
the first consumer error instead of skipping it. When the server drops the
consumer, as on a restart, two missed heartbeats end the SSE response and
the browser reconnects with Last-Event-ID. Before, the response stayed
open and delivered nothing.

A Last-Event-ID above the stream's newest sequence now replays as a fresh
reader. The server accepts a start past the end and delivers nothing
until the stream reaches it, so a stale id stalled the replay.

swarm-controller's stream tests run against a real nats-server on
loopback (`test_nats`, binary from HIVE_NATS_SERVER, exported by
checks.nix and devshell.nix). They cover: ensuring the stream twice, an
existing stream with other limits being opened as it is, the replay
arriving before live rows, a reconnect getting every later row exactly
once, and a dropped consumer ending the row stream.

The limits test now compares against the subagent stream's own config.
Two comments that described the agent's terminal route as live-only are
corrected.
2026-10-11 23:26:24 +02:00

119 lines
5.4 KiB
Rust

//! `GET /api/agents/{name}/state/stream` — a live SSE relay of one agent's
//! turn-state header, sourced straight from the swarm queue.
//!
//! Sibling of [`crate::term_stream`]: the reasoning there for keeping the
//! hive out of the path applies unchanged here and is not repeated. Unlike
//! that route this one is a live tail with no replay, because no stream keeps
//! headers. The two subjects also differ in what they carry: a terminal row
//! is an event that happened, a header is the agent's current state.
//!
//! **That difference is the one thing worth reading twice.** `hive-agent`
//! publishes a header on transition and re-sends it once a minute, to a core
//! subject — so a client that attaches mid-idle sees nothing for up to a
//! minute. A renderer therefore has to open with whatever
//! `GET /api/agents/status` already gives it and let this stream sharpen
//! it, rather than treating an empty stream as an empty agent. The
//! alternative would be a KV bucket written on every turn-state flip, which
//! is the write rate this feature exists to avoid.
//!
//! **The payload is passed through opaquely**, same as the terminal relay:
//! this relay never inspects a header, so it does not depend on
//! `hive-agent`'s type to re-serialise one. The bytes on the wire are what
//! `hive-agent::swarm_agent_state::publish` sent. [`crate::inference`]
//! reads its own fields out of the same subject separately.
use std::convert::Infallible;
use std::time::SystemTime;
use axum::extract::{Path, State};
use axum::response::sse::{Event, KeepAlive, Sse};
use futures_util::{Stream, StreamExt as _};
use crate::AppState;
#[utoipa::path(
get,
path = "/api/agents/{name}/state/stream",
params(("name" = String, Path, description = "agent whose turn state to stream")),
responses(
(status = 200, description = "server-sent event stream; each event's `data` is one \
turn-state header, JSON as hive-agent published it (opaque to this relay) — \
`turn_state`, `turn_state_since` (ISO 8601 UTC), `agent_state`, `model`, `effort`, \
`model_source`, `effort_source`, `resolved_model`, `context_window_tokens`, \
`ctx_usage`, `cost_usage`. Sent on transition and re-sent once a minute, live, \
no replay",
body = String, content_type = "text/event-stream"),
(status = 400, description = "the agent name is not shaped like an identifier \
(problem+json)", body = String),
(status = 404, description = "the agent has never reported to the swarm \
(problem+json)", body = String),
(status = 503, description = "no swarm queue is configured on this host, no \
agent-status reader is wired up, or the queue could not be reached \
(problem+json)", body = String),
(status = 500, description = "reading the agent's status failed, or the \
subscribe itself failed (problem+json)", body = String),
),
tag = "agents"
)]
pub(crate) async fn stream_agent_state(
State(state): State<AppState>,
Path(agent): Path<String>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, problem_details::ProblemDetails> {
let Some(status) = state.status.as_ref() else {
return Err(crate::error_problem(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"no swarm queue is configured on this host",
));
};
let Some(agent_status) = state.agent_status.as_ref() else {
return Err(crate::error_problem(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"no agent-status reader is configured on this host",
));
};
let agent = hive_types::Ident::parse(&agent)
.map_err(|reason| crate::error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
.into_string();
// One-agent "roster": reuses `AgentStatusReader::view`'s already-tested
// row logic rather than a second, narrower lookup that could disagree
// with it about what "never reported" means.
agent_status
.view(std::slice::from_ref(&agent), SystemTime::now())
.await
.map_err(|e| {
let detail = format!("{e:#}");
tracing::warn!(%agent, error = %detail, "state stream: reading agent status failed");
crate::error_problem(axum::http::StatusCode::INTERNAL_SERVER_ERROR, &detail)
})?
.into_iter()
.find_map(|row| row.hive)
.ok_or_else(|| {
crate::error_problem(
axum::http::StatusCode::NOT_FOUND,
&format!("{agent:?} has never reported to the swarm"),
)
})?;
let client = status.queue_client();
// Before subscribing: an unconnected client does not fail a subscribe
// request outright, but there is no point opening a subscription
// against a queue that is not there — the honest answer is 503, the
// same shape every other queue-backed route here already uses.
swarm_queue_client::ensure_connected(&client).map_err(|e| {
crate::error_problem(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
&swarm_queue_client::chain(&e),
)
})?;
let subscriber = crate::term_stream::subscribe(
&client,
swarm_queue_client::agent_state_subject(&agent),
"state",
)
.await?;
let stream =
subscriber.map(|msg| Ok(Event::default().data(String::from_utf8_lossy(&msg.payload))));
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
}