refactor(#2456): split hive-agent-wake into its own bin crate
This commit is contained in:
parent
c8bd0f7180
commit
59dd3a0aa7
5 changed files with 225 additions and 60 deletions
14
Cargo.lock
generated
14
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
21
hive-agent-wake/Cargo.toml
Normal file
21
hive-agent-wake/Cargo.toml
Normal 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
189
hive-agent-wake/src/main.rs
Normal 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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue