annotate mcp connect-refused as expected hive-c0re restart
This commit is contained in:
parent
6a54866ec7
commit
55229fdf5b
1 changed files with 46 additions and 5 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
|
@ -92,10 +92,29 @@ where
|
||||||
Req: Serialize + ?Sized,
|
Req: Serialize + ?Sized,
|
||||||
Resp: DeserializeOwned,
|
Resp: DeserializeOwned,
|
||||||
{
|
{
|
||||||
let stream = UnixStream::connect(socket)
|
let stream = match UnixStream::connect(socket).await {
|
||||||
.await
|
Ok(stream) => stream,
|
||||||
.with_context(|| format!("connect to {}", socket.display()))
|
Err(e) => {
|
||||||
.map_err(RequestError::Transient)?;
|
// 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 (read, mut write) = stream.into_split();
|
||||||
|
|
||||||
let mut payload = serde_json::to_string(req).map_err(|e| RequestError::Fatal(e.into()))?;
|
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()))
|
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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue