refactor(sock): one socket client, retry as a policy value
Six places in the tree hand-rolled the same connect / write one JSON line / read one JSON line back. Two of them — the harness serve loop's client and the MCP server's — were byte-identical apart from a six-line wrapper, ~145 lines of literal copy-paste. The other four each reimplemented a subset, and the subsets had drifted: some named the socket path in their errors and some did not, one classified transient against fatal failures and the rest retried nothing at all, two drained the response and two decoded it. That duplication was defended when the daemons were split out, on the grounds that a daemon's socket etiquette should stay visible in the crate that depends on it. The etiquette genuinely does differ. The code does not, and five copies is where "each daemon documents its own etiquette" stops paying for itself. `hive-sock-client` now owns the transport once, generic over the request and response types so it is protocol-agnostic: the host-served control socket and the harness's in-agent socket both use it with their own wire-type crates. The two real differences become values instead of forks. Retry is `Retry::RideOutRestart` (2/4/8/16/30s, sized to ride out a service restart) for callers with no natural retry of their own, or `Retry::None` for callers already inside a poll loop where the poll interval is the retry — and the reason each caller picked one is a comment at the call site rather than a reimplementation. The response is either decoded (`request`) or half-closed and drained (`notify`, where the drain exists so the server's write-back doesn't land on a closed socket). Whether a failure propagates or is logged and swallowed stays at the call site, because that is the caller's choice and not a property of the transport. Errors always name the socket path now, everywhere. That detail is load-bearing: a permission problem on a socket that reads as "is the daemon running?" sends the operator to fix the wrong thing. The transient-against-fatal enum is gone rather than moved. Serialising happens before the retry loop and deserialising after it, so only connect, I/O and short-read failures can reach the loop at all — a deterministic failure is now unretryable by construction instead of by classification. It is deliberately a new crate and not part of `hive-agent-sock`. The `*-sock` crates are pure wire types by convention — `hive-agent-sock` depends on serde and nothing else — and the two largest copies talk to the host socket, whose types live in a different crate entirely. A transport in either wire-type crate would drag tokio into it and point the wrong way besides. No wire-format change: same JSON line in, same line out.
This commit is contained in:
parent
bd14cc5c46
commit
7a826f9ee2
23 changed files with 498 additions and 501 deletions
|
|
@ -17,6 +17,7 @@ workspace = true
|
|||
anyhow.workspace = true
|
||||
forgejo-api.workspace = true
|
||||
hive-agent-sock.workspace = true
|
||||
hive-sock-client.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -13,7 +13,15 @@
|
|||
//! forge-less agent settles instead of restart-looping.
|
||||
|
||||
mod notify;
|
||||
mod todo_client;
|
||||
|
||||
/// Retry policy for the harness's in-agent socket. Deliberately fail-fast:
|
||||
/// both callers are inside the 30s poll loop and both treat a failed
|
||||
/// request as "leave the thread unread and try again next tick", so the
|
||||
/// poll interval *is* the retry — a second, in-request backoff would only
|
||||
/// stack sleeps on top of it and delay the rest of the batch. That is the
|
||||
/// opposite trade-off from the serve loop's client, which rides out a
|
||||
/// hive-c0re restart because its callers have no natural retry of their own.
|
||||
const TODO_SOCKET_RETRY: hive_sock_client::Retry = hive_sock_client::Retry::None;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
|
|
|
|||
|
|
@ -1078,10 +1078,13 @@ async fn poll_once(
|
|||
summary: body,
|
||||
source: None,
|
||||
};
|
||||
let deliver_result =
|
||||
crate::todo_client::request::<_, hive_agent_sock::Response>(socket, &req)
|
||||
.await
|
||||
.map(|_| ());
|
||||
let deliver_result = hive_sock_client::request::<_, hive_agent_sock::Response>(
|
||||
socket,
|
||||
&req,
|
||||
crate::TODO_SOCKET_RETRY,
|
||||
)
|
||||
.await
|
||||
.map(|_| ());
|
||||
match deliver_result {
|
||||
Ok(()) => {
|
||||
debug!(%id, "forge_notify: todo upserted");
|
||||
|
|
@ -1245,7 +1248,13 @@ async fn update_assigned_rollup(
|
|||
}
|
||||
};
|
||||
|
||||
match crate::todo_client::request::<_, hive_agent_sock::Response>(socket, &req).await {
|
||||
match hive_sock_client::request::<_, hive_agent_sock::Response>(
|
||||
socket,
|
||||
&req,
|
||||
crate::TODO_SOCKET_RETRY,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => debug!(total, "forge_notify: assigned rollup todo updated"),
|
||||
Err(e) => debug!("forge_notify: assigned rollup todo update failed: {e}"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
//! One-shot JSON-line client for the harness's in-agent socket
|
||||
//! (`HIVE_AGENT_SOCKET`) — the only channel this daemon has back into the
|
||||
//! harness.
|
||||
//!
|
||||
//! Deliberately has no retry/backoff schedule. The two callers are both
|
||||
//! inside the 30s poll loop and both treat a failed request as "leave the
|
||||
//! thread unread and try again next tick", so the poll interval *is* the
|
||||
//! retry — a second, in-request backoff would only stack sleeps on top of
|
||||
//! it and delay the rest of the batch. That is the opposite trade-off from
|
||||
//! the serve loop's client, which rides out a hive-c0re restart because
|
||||
//! its callers have no natural retry of their own.
|
||||
//!
|
||||
//! Each sibling per-agent daemon carries its own small helper like this
|
||||
//! one rather than sharing the harness's, so a daemon's socket etiquette
|
||||
//! stays visible in the crate that depends on it.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
/// Write one JSON request line to `socket` and read the single JSON
|
||||
/// response line back.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the socket cannot be connected, if the request
|
||||
/// cannot be serialised, if the write or read fails, if the server closes
|
||||
/// without responding, or if the response does not deserialise into
|
||||
/// `Resp`.
|
||||
pub async fn request<Req, Resp>(socket: &Path, req: &Req) -> Result<Resp>
|
||||
where
|
||||
Req: Serialize + ?Sized,
|
||||
Resp: DeserializeOwned,
|
||||
{
|
||||
let stream = UnixStream::connect(socket)
|
||||
.await
|
||||
.with_context(|| format!("connect to {}", socket.display()))?;
|
||||
let (read, mut write) = stream.into_split();
|
||||
|
||||
let mut payload = serde_json::to_string(req)?;
|
||||
payload.push('\n');
|
||||
write
|
||||
.write_all(payload.as_bytes())
|
||||
.await
|
||||
.with_context(|| format!("write to {}", socket.display()))?;
|
||||
write
|
||||
.flush()
|
||||
.await
|
||||
.with_context(|| format!("flush {}", socket.display()))?;
|
||||
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut line = String::new();
|
||||
let read_bytes = reader
|
||||
.read_line(&mut line)
|
||||
.await
|
||||
.with_context(|| format!("read from {}", socket.display()))?;
|
||||
if read_bytes == 0 || line.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"{} closed the connection without responding",
|
||||
socket.display()
|
||||
));
|
||||
}
|
||||
Ok(serde_json::from_str(line.trim())?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::request;
|
||||
|
||||
/// Connecting to a path that does not exist is an error, not a hang —
|
||||
/// the caller (a poll tick) needs it to come back promptly so the rest
|
||||
/// of the batch still runs.
|
||||
#[tokio::test]
|
||||
async fn missing_socket_is_an_error() {
|
||||
let bogus = std::path::Path::new("/nonexistent/hive/agent.sock");
|
||||
let err = request::<(), serde_json::Value>(bogus, &())
|
||||
.await
|
||||
.expect_err("connect to a non-existent socket must fail");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("connect to"), "missing connect context: {msg}");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue