Watch
0
0
Fork
You've already forked hyperhive
0

the agent state stream sends a new client the latest header first

The stream was a live tail with no replay, and the agent re-sends its header
once a minute, so a card opened mid-idle read "no report yet" for up to a
minute. The controller already follows every agent's header for the inference
route; it now keeps the newest one as published, and the stream sends it
first when it is under 150 seconds old. An agent that stopped keeps no state
older than that.
This commit is contained in:
iris 2026-11-01 01:37:49 +01:00 • committed by mara
commit ff3d119c63

View file

@ -16,6 +16,13 @@
//! alternative would be a KV bucket written on every turn-state flip, which
//! is the write rate this feature exists to avoid.
//!
//! **A client that attaches mid-idle gets the latest header at once.**
//! `crate::inference::Reported` keeps it, and the stream sends it first when
//! it is under [`REPLAY_MAX_AGE`] old: two re-send intervals, so an agent that
//! stopped does not keep a state it no longer has. After that it is the live
//! tail. A controller that has just started holds none and, as before, waits
//! for the agent's next header.
//!
//! **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
@ -23,11 +30,15 @@
//! reads its own fields out of the same subject separately.
use std::convert::Infallible;
use std::time::SystemTime;
use std::time::{Duration, SystemTime};
use axum::extract::{Path, State};
use axum::response::sse::{Event, KeepAlive, Sse};
use futures_util::{Stream, StreamExt as _};
use futures_util::{Stream, StreamExt as _, stream};
/// How old a header the stream still hands a new client first: two of the
/// agent's one-minute re-sends, so one missed re-send does not hide it.
const REPLAY_MAX_AGE: Duration = Duration::from_secs(150);
use crate::routes::AppState;
@ -41,7 +52,8 @@ use crate::routes::AppState;
`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`, `needs_login`. Sent on transition and re-sent once a \
minute, live, no replay",
minute, live. A new client is first sent the latest one the controller holds, \
if it is under 150 seconds old",
body = String, content_type = "text/event-stream"),
(status = 400, description = "the agent name is not shaped like an identifier \
(problem+json)", body = String),
@ -115,7 +127,17 @@ pub(crate) async fn stream_agent_state(
"state",
)
.await?;
let stream =
// Read after subscribing, so a header published in between is in the tail
// too; the client may see one twice, never miss one.
let latest = state
.reported_inference
.as_ref()
.and_then(|reported| reported.header_within(&agent, REPLAY_MAX_AGE));
let first = stream::iter(latest.map(|payload| {
Ok::<_, Infallible>(Event::default().data(String::from_utf8_lossy(&payload)))
}));
let live =
subscriber.map(|msg| Ok(Event::default().data(String::from_utf8_lossy(&msg.payload))));
let stream = first.chain(live);
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
}