diff --git a/hive-ag3nt/src/web_ui/actions.rs b/hive-ag3nt/src/web_ui/actions.rs index 49fce257..3feae0a3 100644 --- a/hive-ag3nt/src/web_ui/actions.rs +++ b/hive-ag3nt/src/web_ui/actions.rs @@ -8,9 +8,7 @@ use axum::{ }; use serde::Deserialize; -use crate::client; - -use super::{AppState, SOCKET_FETCH_TIMEOUT, error_response}; +use super::{AppState, error_response}; #[derive(Deserialize)] pub(super) struct SendForm { @@ -25,33 +23,23 @@ pub(super) async fn post_send( if body.is_empty() { return error_response(StatusCode::BAD_REQUEST, "send: `body` required"); } - let result = match tokio::time::timeout( - SOCKET_FETCH_TIMEOUT, - client::request::<_, hive_sh4re::Response>( - &state.socket, - &hive_sh4re::Request::OperatorMsg { body }, - ), - ) - .await - { - Ok(Ok(hive_sh4re::Response::Ok)) => Ok(()), - Ok(Ok(hive_sh4re::Response::Err { message })) => Err(message), - Ok(Ok(other)) => Err(format!("unexpected response: {other:?}")), - Ok(Err(e)) => Err(format!("transport: {e:#}")), - Err(_) => Err("timed out — hive-c0re busy, retry".to_owned()), - }; - match result { + match super::broker_request(&state.socket, &hive_sh4re::Request::OperatorMsg { body }).await { // 200 instead of 303 → the client doesn't refetch /api/state. // The operator message becomes a broker `Sent` (already shown // server-side in the dashboard); on the agent side, the // resulting `TurnStart` SSE event drives the terminal + the // inbox row gets consumed by the time `TurnEnd` fires the // existing turn-end refresh. - Ok(()) => (axum::http::StatusCode::OK, "ok").into_response(), - Err(e) => error_response( + Ok(hive_sh4re::Response::Ok) => (axum::http::StatusCode::OK, "ok").into_response(), + Ok(hive_sh4re::Response::Err { message }) => error_response( StatusCode::INTERNAL_SERVER_ERROR, - &format!("send failed: {e}"), + &format!("send failed: {message}"), ), + Ok(other) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("send failed: unexpected response: {other:?}"), + ), + Err(e) => super::broker_error_response(&e, "send"), } } diff --git a/hive-ag3nt/src/web_ui/mod.rs b/hive-ag3nt/src/web_ui/mod.rs index 1827e3da..ce158ff7 100644 --- a/hive-ag3nt/src/web_ui/mod.rs +++ b/hive-ag3nt/src/web_ui/mod.rs @@ -267,3 +267,52 @@ fn error_response(status: StatusCode, message: &str) -> Response { // in its alert, so a benign "busy, retry" must not read as a 500. (status, message.to_owned()).into_response() } + +/// Why a deadline-bounded broker request via the per-agent socket didn't +/// yield a response. Kept distinct so action handlers pick the right status +/// code (see [`broker_error_response`]) while decorative fetches `.ok()` both. +enum BrokerError { + /// Outran [`SOCKET_FETCH_TIMEOUT`] — hive-c0re is busy or stalled. A + /// retryable state conflict (→ 409), not a server fault. + Timeout, + /// The socket transport itself failed (connect / encode / decode). + Transport(anyhow::Error), +} + +/// Issue a broker request over the per-agent socket, bounded by +/// [`SOCKET_FETCH_TIMEOUT`] so a busy or stalled hive-c0re degrades the +/// response instead of hanging it. Callers match the returned [`Response`] +/// variant themselves; the error side distinguishes a retryable timeout from +/// a transport failure. This is the one shared broker-call scaffold — every +/// web-UI handler that talks to the broker goes through it. +async fn broker_request( + socket: &Path, + req: &hive_sh4re::Request, +) -> std::result::Result { + match tokio::time::timeout( + SOCKET_FETCH_TIMEOUT, + crate::client::request::<_, hive_sh4re::Response>(socket, req), + ) + .await + { + Ok(Ok(resp)) => Ok(resp), + Ok(Err(e)) => Err(BrokerError::Transport(e)), + Err(_) => Err(BrokerError::Timeout), + } +} + +/// Map a [`BrokerError`] to an operator-facing error response: a timeout is a +/// retryable "busy" conflict (409), a transport failure is a 500. `action` +/// prefixes the message (e.g. `"send"`, `"get_loose_ends"`). +fn broker_error_response(err: &BrokerError, action: &str) -> Response { + match err { + BrokerError::Timeout => error_response( + StatusCode::CONFLICT, + &format!("{action}: timed out — hive-c0re busy, retry"), + ), + BrokerError::Transport(e) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("{action}: transport: {e:#}"), + ), + } +} diff --git a/hive-ag3nt/src/web_ui/state.rs b/hive-ag3nt/src/web_ui/state.rs index f67ea0e2..b2a8f289 100644 --- a/hive-ag3nt/src/web_ui/state.rs +++ b/hive-ag3nt/src/web_ui/state.rs @@ -3,11 +3,10 @@ use axum::extract::State; use serde::Serialize; -use crate::client; use crate::login::LoginState; use crate::login_session::drop_if_finished; -use super::{AppState, SOCKET_FETCH_TIMEOUT}; +use super::AppState; pub(super) async fn api_state(State(state): State) -> axum::Json { // Capture seq *before* any reads so the dedupe contract is @@ -371,18 +370,10 @@ struct ExtraLink { /// failure — the inbox section is decorative, not authoritative. async fn recent_inbox(socket: &std::path::Path) -> Vec { const LIMIT: u64 = 30; - // Deadline-bounded: `/api/state` must render even when hive-c0re is - // busy — an empty inbox section beats a hung snapshot. - match tokio::time::timeout( - SOCKET_FETCH_TIMEOUT, - client::request::<_, hive_sh4re::Response>( - socket, - &hive_sh4re::Request::Recent { limit: LIMIT }, - ), - ) - .await - { - Ok(Ok(hive_sh4re::Response::Recent { rows })) => rows, + // Deadline-bounded (via `broker_request`): `/api/state` must render even + // when hive-c0re is busy — an empty inbox section beats a hung snapshot. + match super::broker_request(socket, &hive_sh4re::Request::Recent { limit: LIMIT }).await { + Ok(hive_sh4re::Response::Recent { rows }) => rows, _ => Vec::new(), } } @@ -394,19 +385,16 @@ pub(super) async fn fetch_reminder_stats( socket: &std::path::Path, window_secs: u64, ) -> Option { - match tokio::time::timeout( - SOCKET_FETCH_TIMEOUT, - client::request::<_, hive_sh4re::Response>( - socket, - &hive_sh4re::Request::ReminderRollup { - since_secs: window_secs, - agent: None, - }, - ), + match super::broker_request( + socket, + &hive_sh4re::Request::ReminderRollup { + since_secs: window_secs, + agent: None, + }, ) .await { - Ok(Ok(hive_sh4re::Response::ReminderRollup(stats))) => Some(stats), + Ok(hive_sh4re::Response::ReminderRollup(stats)) => Some(stats), _ => None, } } diff --git a/hive-ag3nt/src/web_ui/stats.rs b/hive-ag3nt/src/web_ui/stats.rs index 9c0ace5e..29555d22 100644 --- a/hive-ag3nt/src/web_ui/stats.rs +++ b/hive-ag3nt/src/web_ui/stats.rs @@ -5,10 +5,8 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use serde::Deserialize; -use crate::client; - use super::state::fetch_reminder_stats; -use super::{AppState, SOCKET_FETCH_TIMEOUT, error_response}; +use super::{AppState, error_response}; #[derive(Deserialize)] pub(super) struct StatsQuery { @@ -37,42 +35,22 @@ pub(super) async fn api_stats( /// the `mcp__hyperhive__get_loose_ends` tool sees from inside the /// container. pub(super) async fn api_loose_ends(State(state): State) -> Response { - let loose_ends: Vec = match tokio::time::timeout( - SOCKET_FETCH_TIMEOUT, - client::request::<_, hive_sh4re::Response>( - &state.socket, - &hive_sh4re::Request::GetLooseEnds { agent: None }, - ), - ) - .await + match super::broker_request(&state.socket, &hive_sh4re::Request::GetLooseEnds { agent: None }) + .await { - Ok(Ok(hive_sh4re::Response::LooseEnds { loose_ends })) => loose_ends, - Ok(Ok(hive_sh4re::Response::Err { message })) => { - return error_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("get_loose_ends: {message}"), - ); + Ok(hive_sh4re::Response::LooseEnds { loose_ends }) => { + axum::Json(serde_json::json!({ "loose_ends": loose_ends })).into_response() } - Ok(Ok(other)) => { - return error_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("unexpected response: {other:?}"), - ); - } - Ok(Err(e)) => { - return error_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("transport: {e:#}"), - ); - } - Err(_) => { - return error_response( - StatusCode::CONFLICT, - "get_loose_ends: timed out — hive-c0re busy, retry", - ); - } - }; - axum::Json(serde_json::json!({ "loose_ends": loose_ends })).into_response() + Ok(hive_sh4re::Response::Err { message }) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("get_loose_ends: {message}"), + ), + Ok(other) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("get_loose_ends: unexpected response: {other:?}"), + ), + Err(e) => super::broker_error_response(&e, "get_loose_ends"), + } } /// `GET /api/bash-tasks` — snapshot of this agent's in-flight bash tasks.