host.sock: push a live agent-status stream instead of poll-only

This commit is contained in:
damocles 2026-09-07 18:24:22 +02:00 committed by mara
commit 3871749da9
3 changed files with 181 additions and 27 deletions

View file

@ -96,6 +96,38 @@ pub struct ContainerView {
pub memory_max: String,
}
impl From<ContainerView> for hive_sh4re::container::AgentStatusRow {
/// The one place a `ContainerView` becomes the wire row `host.sock`
/// serves it as — `handle_agent_status`'s poll path and the
/// `SubscribeAgentStatus` push path both go through this, so the two
/// can never quietly diverge on which fields make the cut.
///
/// `pending_reminders` has no source here: reminders are agent-local
/// now, and c0re has no cross-agent visibility into pending counts
/// anymore. Stubbed to `0` rather than dropping the wire field
/// outright — leaves `hivectl status`/the dashboard column intact
/// syntactically, just always empty, until iris's frontend follow-up
/// decides whether to drop the column entirely. `port`, `cpu_quota`,
/// `memory_max` have no equivalent on the row at all and are simply
/// not carried over.
fn from(v: ContainerView) -> Self {
Self {
name: v.name,
running: v.running,
failed: v.failed,
needs_update: v.needs_update,
needs_login: v.needs_login,
deployed_sha: v.deployed_sha,
pending_reminders: 0,
parent: v.parent,
paused: v.paused,
active_model: v.active_model,
status_text: v.status_text,
status_set_at: v.status_set_at,
}
}
}
/// Build the full container list. Wraps `lifecycle::list()` and
/// resolves every per-agent attribute the dashboard surfaces.
///
@ -468,4 +500,53 @@ mod tests {
let ok = lock(r#""a":"a""#, r#""a":{"locked":{"rev":"abc"}}"#);
assert_eq!(parse_locked_revs(&ok).len(), 1);
}
/// The `From` impl both `handle_agent_status`'s poll path and
/// `stream_agent_status`'s push path go through — pins the field
/// mapping so the two can't quietly diverge, and that `port` /
/// `cpu_quota` / `memory_max` (no equivalent on the row) are dropped
/// on purpose rather than by omission.
#[test]
fn agent_status_row_carries_every_field_the_row_has_and_drops_the_rest() {
use super::ContainerView;
use chrono::{TimeZone, Utc};
use hive_sh4re::container::AgentStatusRow;
let view = ContainerView {
name: "alice".to_owned(),
container: "h-alice".to_owned(),
port: 7000,
running: true,
failed: false,
needs_update: true,
needs_login: false,
deployed_sha: Some("abc123def456".to_owned()),
parent: Some("bob".to_owned()),
active_model: Some("claude-opus".to_owned()),
status_text: Some("shipping".to_owned()),
status_set_at: Some(Utc.timestamp_opt(1_700_000_000, 0).unwrap()),
paused: true,
cpu_quota: "400%".to_owned(),
memory_max: "8G".to_owned(),
};
let row = AgentStatusRow::from(view);
assert_eq!(row.name, "alice");
assert!(row.running);
assert!(!row.failed);
assert!(row.needs_update);
assert!(!row.needs_login);
assert_eq!(row.deployed_sha.as_deref(), Some("abc123def456"));
assert_eq!(row.parent.as_deref(), Some("bob"));
assert!(row.paused);
assert_eq!(row.active_model.as_deref(), Some("claude-opus"));
assert_eq!(row.status_text.as_deref(), Some("shipping"));
assert_eq!(
row.status_set_at,
Some(Utc.timestamp_opt(1_700_000_000, 0).unwrap())
);
// No source for reminders on this side (see the `From` impl's doc
// comment) — always the stub, never left uninitialised.
assert_eq!(row.pending_reminders, 0);
}
}

View file

@ -63,17 +63,43 @@ async fn handle(stream: UnixStream, coord: Arc<Coordinator>) -> Result<()> {
if n == 0 {
return Ok(());
}
let resp = match serde_json::from_str::<HostRequest>(line.trim()) {
Ok(req) => dispatch(&req, coord.clone()).await,
Err(e) => HostResponse::error(format!("parse error: {e}")),
let req = match serde_json::from_str::<HostRequest>(line.trim()) {
Ok(req) => req,
Err(e) => {
write_response(
&mut write,
&HostResponse::error(format!("parse error: {e}")),
)
.await?;
continue;
}
};
let mut payload = serde_json::to_string(&resp)?;
payload.push('\n');
write.write_all(payload.as_bytes()).await?;
write.flush().await?;
// The one verb that answers with more than one response — see its
// doc comment. Hands `write` over and stops reading further
// requests on this connection; a client wanting anything else
// opens a fresh one.
if matches!(req, HostRequest::SubscribeAgentStatus) {
return stream_agent_status(write, coord).await;
}
let resp = dispatch(&req, coord.clone()).await;
write_response(&mut write, &resp).await?;
}
}
/// Serialize one `HostResponse` as a JSON line and flush it. Shared by the
/// ordinary one-response-per-request path and `stream_agent_status`'s
/// multi-response one, so the two can't quietly drift on framing.
async fn write_response(
write: &mut tokio::net::unix::OwnedWriteHalf,
resp: &HostResponse,
) -> Result<()> {
let mut payload = serde_json::to_string(resp)?;
payload.push('\n');
write.write_all(payload.as_bytes()).await?;
write.flush().await?;
Ok(())
}
#[allow(
clippy::too_many_lines,
reason = "flat one-arm-per-HostRequest-variant router; each arm just \
@ -159,6 +185,13 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
// see the request's doc comment for why the client asks.
HostRequest::AgentExists { name } => HostResponse::agent_exists(agent_exists(name)?),
HostRequest::AgentStatus => handle_agent_status(&coord).await,
// Intercepted in `handle()` before a request ever reaches
// `dispatch` — see `stream_agent_status`'s doc comment. This
// arm exists only so the match stays exhaustive; reaching it
// would mean a future caller invoked `dispatch` directly.
HostRequest::SubscribeAgentStatus => {
HostResponse::error("SubscribeAgentStatus is handled by the connection loop")
}
// The hive domain + per-surface public URLs are injected into
// c0re's service env by the hyperhive module; surface them so the
// operator CLI can fill in this hive's own identity (the
@ -368,30 +401,54 @@ async fn handle_agent_status(coord: &Arc<Coordinator>) -> HostResponse {
let rows = crate::container_view::build_all(&coord.hive_env())
.await
.into_iter()
.map(|v| hive_sh4re::container::AgentStatusRow {
name: v.name,
running: v.running,
failed: v.failed,
needs_update: v.needs_update,
needs_login: v.needs_login,
deployed_sha: v.deployed_sha,
// Reminders are agent-local now; c0re has no cross-agent
// visibility into pending counts anymore. Stubbed
// to 0 rather than deleting the wire field outright — leaves
// `hivectl status`/the dashboard column intact syntactically,
// just always empty, until iris's frontend follow-up decides
// whether to drop the column entirely.
pending_reminders: 0,
parent: v.parent,
paused: v.paused,
active_model: v.active_model,
status_text: v.status_text,
status_set_at: v.status_set_at,
})
.map(hive_sh4re::container::AgentStatusRow::from)
.collect();
HostResponse::agent_statuses(rows)
}
/// `SubscribeAgentStatus` — ack once, then push one single-row
/// [`HostResponse::agent_statuses`] per agent-status change for as long as
/// the client stays connected. Consumes `write` (rather than borrowing it,
/// like every other handler here) because this is the one verb that keeps
/// writing after its own response, so `handle()` hands over ownership and
/// stops reading further requests on this connection once it calls this.
///
/// Rides the same `Coordinator.dashboard_events` broadcast channel the
/// dashboard's own SSE route reads (`dashboard/state_snapshot.rs`) —
/// `SetStatus` already triggers a `rescan_containers_and_emit` on every
/// status change (`socket_server/mod.rs::handle_set_status`), so that
/// channel already carries every event this needs; no separate plumbing.
async fn stream_agent_status(
mut write: tokio::net::unix::OwnedWriteHalf,
coord: Arc<Coordinator>,
) -> Result<()> {
let mut events = coord.dashboard_subscribe();
write_response(&mut write, &HostResponse::success()).await?;
loop {
match events.recv().await {
Ok(crate::dashboard_events::DashboardEvent::ContainerStateChanged {
container,
..
}) => {
let row = hive_sh4re::container::AgentStatusRow::from(container);
write_response(&mut write, &HostResponse::agent_statuses(vec![row])).await?;
}
// Every other event kind on this channel is dashboard-only
// (approvals, broker traffic, the queue, …) — nothing this
// subscriber asked for.
Ok(_) => {}
// Best-effort, same contract the dashboard's own live channel
// already has (see `HostRequest::SubscribeAgentStatus`'s doc
// comment) — a slow reader drops updates rather than stalling
// the broadcaster for everyone else on the channel.
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
tracing::warn!(skipped, "agent-status subscribe: receiver lagged");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => return Ok(()),
}
}
}
// ---------------------------------------------------------------------------
// Matrix provisioning handlers
//

View file

@ -206,6 +206,22 @@ pub enum HostRequest {
/// pending reminders) — the `hivectl list-agents` roster view.
/// Reuses the dashboard's per-agent `ContainerView` aggregation.
AgentStatus,
/// Turn this connection into a live push feed of agent-status changes,
/// so a polling loop against [`HostRequest::AgentStatus`] is no longer
/// the only way to notice one. The one verb on this socket that answers
/// with more than one response: the server acks with a bare
/// [`HostResponse::success()`] first, then keeps the connection open
/// and writes one further [`HostResponse::agent_statuses`] (always a
/// single-row `Vec`) per changed agent, for as long as the client stays
/// connected. No further requests are read on this connection after
/// this one — open a fresh connection for anything else.
///
/// Best-effort, same contract the dashboard's own live channel already
/// has: a slow reader can miss updates rather than back-pressuring the
/// daemon, so a client that cares about the *current* state after a gap
/// should still fall back to a fresh [`HostRequest::AgentStatus`] poll,
/// not assume the stream alone is authoritative.
SubscribeAgentStatus,
/// Report this hive's canonical DNS domain
/// (`services.hyperhive.domain`) plus the browser-facing home /
/// forge / matrix URLs, daemon-sourced so custom forge/matrix