diff --git a/hive-ag3nt/src/client.rs b/hive-ag3nt/src/client.rs index 5f4963be..33e9fa80 100644 --- a/hive-ag3nt/src/client.rs +++ b/hive-ag3nt/src/client.rs @@ -1,7 +1,7 @@ use std::path::Path; use std::time::Duration; -use anyhow::{Context, Result, anyhow}; +use anyhow::{Result, anyhow}; use serde::Serialize; use serde::de::DeserializeOwned; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -92,10 +92,29 @@ where Req: Serialize + ?Sized, Resp: DeserializeOwned, { - let stream = UnixStream::connect(socket) - .await - .with_context(|| format!("connect to {}", socket.display())) - .map_err(RequestError::Transient)?; + let stream = match UnixStream::connect(socket).await { + Ok(stream) => stream, + Err(e) => { + // A refused or missing socket usually means hive-c0re is + // mid-restart (operator redeploy / rebuild) — the socket is + // recreated on its boot and `request_retried` rides it out. When + // the error *does* surface (retries exhausted, or a non-retried + // caller) add that context so claude reads it as a likely + // transient rather than a hard failure worth escalating. + let restarting = matches!( + e.kind(), + std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound + ); + let mut err = anyhow::Error::new(e).context(format!("connect to {}", socket.display())); + if restarting { + err = err.context( + "hive-c0re may be restarting (e.g. an operator redeploy); \ + the harness already retried ~60s before surfacing this", + ); + } + return Err(RequestError::Transient(err)); + } + }; let (read, mut write) = stream.into_split(); let mut payload = serde_json::to_string(req).map_err(|e| RequestError::Fatal(e.into()))?; @@ -122,3 +141,25 @@ where } serde_json::from_str(line.trim()).map_err(|e| RequestError::Fatal(e.into())) } + +#[cfg(test)] +mod tests { + use super::{RequestError, try_once}; + + /// A connect to a non-existent socket path (ENOENT → `NotFound`) is + /// classified transient AND annotated with the "hive-c0re is restarting" + /// hint, so a surfaced tool error reads as the expected transient. + #[tokio::test] + async fn missing_socket_connect_is_transient_with_restart_hint() { + let bogus = std::path::Path::new("/nonexistent/hive/mcp.sock"); + match try_once::<(), serde_json::Value>(bogus, &()).await { + Err(RequestError::Transient(e)) => { + let msg = format!("{e:#}"); + assert!(msg.contains("restarting"), "missing restart hint: {msg}"); + assert!(msg.contains("connect to"), "missing connect context: {msg}"); + } + Err(RequestError::Fatal(e)) => panic!("expected transient, got fatal: {e:#}"), + Ok(_) => panic!("expected connect failure to a non-existent socket"), + } + } +}