refactor(#2456): split hive-agent-wake into its own bin crate

This commit is contained in:
damocles 2026-07-14 23:26:06 +02:00
commit 59dd3a0aa7
5 changed files with 225 additions and 60 deletions

14
Cargo.lock generated
View file

@ -1489,6 +1489,20 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "hive-agent-wake"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"hive-sh4re",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]] [[package]]
name = "hive-bash-mcp" name = "hive-bash-mcp"
version = "0.1.0" version = "0.1.0"

View file

@ -2,6 +2,7 @@
resolver = "3" resolver = "3"
members = [ members = [
"hive-ag3nt", "hive-ag3nt",
"hive-agent-wake",
"hive-bash-mcp", "hive-bash-mcp",
"hive-c0re", "hive-c0re",
"hive-claude", "hive-claude",

View file

@ -1,60 +0,0 @@
//! Wake CLI: inject a wake-up event into this container's harness inbox
//! so the next turn fires with the given body. Intended for extra MCP
//! servers / helpers (scraper, webhook listener, etc.) that need to
//! nudge claude on external events; the built-in daemons (matrix, bash)
//! talk to the socket directly instead. Sibling of `hive-agent` and
//! `hive-agent-mcp`.
use std::path::PathBuf;
use anyhow::Result;
use clap::Parser;
use hive_ag3nt::{DEFAULT_SOCKET, client};
use hive_sh4re::{AgentRequest, AgentResponse};
#[derive(Parser)]
#[command(name = "hive-agent-wake", about = "hyperhive harness wake signal")]
struct Cli {
/// Path to the per-agent MCP socket (bind-mounted from the host).
#[arg(long, default_value = DEFAULT_SOCKET)]
socket: PathBuf,
#[arg(long)]
from: String,
/// Body of the wake message. Pass `-` to read from stdin.
#[arg(long)]
body: String,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
let body = if cli.body == "-" {
let mut buf = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
buf
} else {
cli.body
};
let resp: AgentResponse = client::request(
&cli.socket,
&AgentRequest::Wake {
from: cli.from,
body,
},
)
.await?;
match resp {
AgentResponse::Ok => Ok(()),
AgentResponse::Err { message } => anyhow::bail!("wake: {message}"),
other => anyhow::bail!("wake: unexpected response {other:?}"),
}
}

View file

@ -0,0 +1,21 @@
[package]
name = "hive-agent-wake"
version.workspace = true
edition.workspace = true
[[bin]]
name = "hive-agent-wake"
path = "src/main.rs"
[lints]
workspace = true
[dependencies]
anyhow.workspace = true
clap.workspace = true
hive-sh4re.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true

189
hive-agent-wake/src/main.rs Normal file
View file

@ -0,0 +1,189 @@
//! Wake CLI: inject a wake-up event into this container's harness inbox
//! so the next turn fires with the given body. Intended for extra MCP
//! servers / helpers (scraper, webhook listener, etc.) that need to
//! nudge claude on external events; the built-in daemons (matrix, bash)
//! talk to the socket directly instead.
//!
//! Standalone bin crate: it dials the per-agent MCP socket directly and
//! carries its own copy of the retrying request client (below), so it
//! does not link the whole `hive-ag3nt` harness lib — a helper author
//! wiring up an `extraMcpServers` binary only needs this one small crate.
use std::path::PathBuf;
use anyhow::Result;
use clap::Parser;
use hive_sh4re::{AgentRequest, AgentResponse};
/// Per-agent MCP socket, bind-mounted from the host into every container.
const DEFAULT_SOCKET: &str = "/run/hive/mcp.sock";
#[derive(Parser)]
#[command(name = "hive-agent-wake", about = "hyperhive harness wake signal")]
struct Cli {
/// Path to the per-agent MCP socket (bind-mounted from the host).
#[arg(long, default_value = DEFAULT_SOCKET)]
socket: PathBuf,
#[arg(long)]
from: String,
/// Body of the wake message. Pass `-` to read from stdin.
#[arg(long)]
body: String,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
let body = if cli.body == "-" {
let mut buf = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
buf
} else {
cli.body
};
let resp: AgentResponse = client::request(
&cli.socket,
&AgentRequest::Wake {
from: cli.from,
body,
},
)
.await?;
match resp {
AgentResponse::Ok => Ok(()),
AgentResponse::Err { message } => anyhow::bail!("wake: {message}"),
other => anyhow::bail!("wake: unexpected response {other:?}"),
}
}
/// Self-contained retrying unix-socket JSON request client. A trimmed
/// copy of `hive_ag3nt::client` (no `request_retried` variant — the wake
/// CLI never needs the retry-count) so this bin does not link the harness
/// lib. Keeping the retry matters: the socket can be briefly absent while
/// hive-c0re restarts under an operator redeploy, and a helper firing a
/// wake shouldn't spuriously fail then.
mod client {
use std::path::Path;
use std::time::Duration;
use anyhow::{Result, anyhow};
use serde::Serialize;
use serde::de::DeserializeOwned;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
/// Backoff schedule between attempts. Five entries → up to 5 retries on
/// top of the initial attempt; total wall-clock cap = 2+4+8+16+30 = 60s.
/// Sized to ride out a hive-c0re restart (systemd usually has the unix
/// socket back inside ~5s) without the caller having to handle the
/// transient itself.
const RETRY_BACKOFFS_MS: &[u64] = &[2_000, 4_000, 8_000, 16_000, 30_000];
/// Send `req` over the unix socket and decode the single-line JSON
/// response, retrying transient connect/IO failures on the backoff
/// schedule above.
///
/// # Errors
///
/// Returns an error if the socket is unreachable after all retries, or
/// if serialization / deserialization of the request or response fails.
pub async fn request<Req, Resp>(socket: &Path, req: &Req) -> Result<Resp>
where
Req: Serialize + ?Sized,
Resp: DeserializeOwned,
{
let mut last_err: Option<anyhow::Error> = None;
// One attempt per backoff entry, plus a final attempt with no sleep
// after it — so `len + 1` tries, retrying only transient failures.
for attempt in 0..=RETRY_BACKOFFS_MS.len() {
match try_once::<Req, Resp>(socket, req).await {
Ok(resp) => return Ok(resp),
Err(RequestError::Fatal(e)) => return Err(e),
Err(RequestError::Transient(e)) => {
if let Some(&sleep_ms) = RETRY_BACKOFFS_MS.get(attempt) {
tracing::warn!(
attempt = attempt + 1,
sleep_ms,
error = %e,
"hive socket attempt failed; retrying"
);
tokio::time::sleep(Duration::from_millis(sleep_ms)).await;
}
last_err = Some(e);
}
}
}
// The final iteration always sets `last_err` on a transient failure.
Err(last_err.expect("a transient failure on the final attempt set last_err"))
}
/// Transient = connect / IO error worth a retry (server restart, broken
/// pipe). Fatal = serialization / deserialization / protocol error
/// where retrying would just repeat the same failure.
enum RequestError {
Transient(anyhow::Error),
Fatal(anyhow::Error),
}
async fn try_once<Req, Resp>(socket: &Path, req: &Req) -> Result<Resp, RequestError>
where
Req: Serialize + ?Sized,
Resp: DeserializeOwned,
{
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 the retry loop rides it out.
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 CLI 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()))?;
payload.push('\n');
write
.write_all(payload.as_bytes())
.await
.map_err(|e| RequestError::Transient(e.into()))?;
write
.flush()
.await
.map_err(|e| RequestError::Transient(e.into()))?;
let mut reader = BufReader::new(read);
let mut line = String::new();
let read_bytes = reader
.read_line(&mut line)
.await
.map_err(|e| RequestError::Transient(e.into()))?;
if read_bytes == 0 || line.is_empty() {
return Err(RequestError::Transient(anyhow!(
"server closed connection without responding"
)));
}
serde_json::from_str(line.trim()).map_err(|e| RequestError::Fatal(e.into()))
}
}