refactor(mcp): drop vestigial SocketReply, match hive_sh4re::Response directly; remove dead code

This commit is contained in:
müde 2026-07-05 22:12:45 +02:00
commit 9a0d525fdf

View file

@ -24,117 +24,6 @@ use rmcp::{
use crate::client; use crate::client;
/// Wire-protocol-agnostic view of a hyperhive socket response. Both flavors
/// of `AgentServer` convert into this so the tool formatters can be shared.
#[derive(Debug)]
pub enum SocketReply {
Ok,
Err(String),
/// Unified `recv` result: zero or more messages popped in one
/// round-trip. Empty vec = "(empty)" path; single-message = the
/// standard wake body; multi = batch render with per-message
/// separators. Per-row `id` is rendered as a `[msg #<id>]` marker
/// so claude can bulk-triage via `ack_until` (turn-level ack still
/// rides `AckTurn`); `redelivered` triggers the "may already be
/// handled" banner in `format_recv` for that specific row.
Messages(Vec<hive_sh4re::DeliveredMessage>),
Status(u64),
/// `ack_until` result: rows newly marked handled.
Acked(u64),
QuestionQueued(i64),
Recent(Vec<hive_sh4re::InboxRow>),
Logs(String),
HostJournal(String),
/// `list_schedules` result — returned by `list_schedules` (scheduling tool group).
Schedules(Vec<hive_sh4re::WireSchedule>),
/// `list_containers` result — descendant containers with running status.
Containers(Vec<hive_sh4re::ContainerInfo>),
LooseEnds(Vec<hive_sh4re::LooseEnd>),
PendingRemindersCount(u64),
ReminderRollup(hive_sh4re::ReminderStats),
AgentMeta {
name: String,
running: bool,
hyperhive_rev: Option<String>,
status_text: Option<String>,
status_set_at: Option<i64>,
hive_name: Option<String>,
swarm_name: Option<String>,
matrix_accounts: Vec<hive_sh4re::MatrixIdentity>,
},
/// `create_repo` result — the new repo's full name + clone URL.
RepoCreated {
full_name: String,
clone_url: String,
},
}
impl From<hive_sh4re::Response> for SocketReply {
fn from(r: hive_sh4re::Response) -> Self {
match r {
hive_sh4re::Response::Ok => Self::Ok,
hive_sh4re::Response::Err { message } => Self::Err(message),
hive_sh4re::Response::Messages { messages } => Self::Messages(messages),
hive_sh4re::Response::Status { unread } => Self::Status(unread),
hive_sh4re::Response::Acked { count } => Self::Acked(count),
hive_sh4re::Response::Recent { rows } => Self::Recent(rows),
hive_sh4re::Response::QuestionQueued { id } => Self::QuestionQueued(id),
hive_sh4re::Response::LooseEnds { loose_ends } => Self::LooseEnds(loose_ends),
hive_sh4re::Response::PendingRemindersCount { count } => {
Self::PendingRemindersCount(count)
}
hive_sh4re::Response::ReminderRollup(stats) => Self::ReminderRollup(stats),
hive_sh4re::Response::Logs { content } => Self::Logs(content),
hive_sh4re::Response::HostJournal { content } => Self::HostJournal(content),
hive_sh4re::Response::Schedules { schedules } => Self::Schedules(schedules),
hive_sh4re::Response::Containers { containers } => Self::Containers(containers),
// A graceful stop is pending — the inbox is fenced. Returning an
// *empty* inbox here is ambiguous: claude's "park on recv" habit
// makes it long-poll again instead of ending the turn, so the
// stop-checkpoint turn never finishes and the drain wait times out
// into a hard stop. Return a single explicit directive instead, so
// every recv during the stop unmissably tells claude to flush + end.
hive_sh4re::Response::GracefulStop => Self::Messages(vec![hive_sh4re::DeliveredMessage {
from: "graceful-stop".into(),
body: "⛔ GRACEFUL STOP IN PROGRESS — the container shuts down as soon as this turn \
ends. Flush anything worth keeping to your durable /state files, then END \
YOUR TURN now. Do NOT call recv again: the inbox is fenced and recv will \
only keep returning this same notice."
.into(),
id: 0,
redelivered: false,
in_reply_to: None,
}]),
hive_sh4re::Response::AgentMeta {
name,
running,
hyperhive_rev,
status_text,
status_set_at,
hive_name,
swarm_name,
matrix_accounts,
} => Self::AgentMeta {
name,
running,
hyperhive_rev,
status_text,
status_set_at,
hive_name,
swarm_name,
matrix_accounts,
},
hive_sh4re::Response::RepoCreated {
full_name,
clone_url,
} => Self::RepoCreated {
full_name,
clone_url,
},
}
}
}
/// Write (or remove) the status file in the agent's own `state/` directory. /// Write (or remove) the status file in the agent's own `state/` directory.
/// Called by `AgentServer::set_status` for both agent and manager flavors /// Called by `AgentServer::set_status` for both agent and manager flavors
/// before dispatching the wire `SetStatus` request (which only triggers a /// before dispatching the wire `SetStatus` request (which only triggers a
@ -186,9 +75,9 @@ fn write_status_file(text: &str) -> Result<(), String> {
/// transport error: …"`. Handlers match their own happy-path variant and route /// transport error: …"`. Handlers match their own happy-path variant and route
/// everything else here via a catch-all arm (`other => reply_err(other, tool)`), /// everything else here via a catch-all arm (`other => reply_err(other, tool)`),
/// so the triplet lives in exactly one place. /// so the triplet lives in exactly one place.
fn reply_err(resp: Result<SocketReply, anyhow::Error>, tool: &str) -> String { fn reply_err(resp: Result<hive_sh4re::Response, anyhow::Error>, tool: &str) -> String {
match resp { match resp {
Ok(SocketReply::Err(m)) => format!("{tool} failed: {m}"), Ok(hive_sh4re::Response::Err { message }) => format!("{tool} failed: {message}"),
Ok(other) => format!("{tool} unexpected response: {other:?}"), Ok(other) => format!("{tool} unexpected response: {other:?}"),
Err(e) => format!("{tool} transport error: {e:#}"), Err(e) => format!("{tool} transport error: {e:#}"),
} }
@ -198,9 +87,13 @@ fn reply_err(resp: Result<SocketReply, anyhow::Error>, tool: &str) -> String {
/// `tool` and `ok_msg` only appear in the result string; they don't change /// `tool` and `ok_msg` only appear in the result string; they don't change
/// behavior. /// behavior.
#[must_use] #[must_use]
pub fn format_ack(resp: Result<SocketReply, anyhow::Error>, tool: &str, ok_msg: String) -> String { pub fn format_ack(
resp: Result<hive_sh4re::Response, anyhow::Error>,
tool: &str,
ok_msg: String,
) -> String {
match resp { match resp {
Ok(SocketReply::Ok) => ok_msg, Ok(hive_sh4re::Response::Ok) => ok_msg,
other => reply_err(other, tool), other => reply_err(other, tool),
} }
} }
@ -216,13 +109,37 @@ pub fn format_ack(resp: Result<SocketReply, anyhow::Error>, tool: &str, ok_msg:
/// so the model can tell where one ends and the next begins; /// so the model can tell where one ends and the next begins;
/// per-message redelivery banners included. /// per-message redelivery banners included.
#[must_use] #[must_use]
pub fn format_recv(resp: Result<SocketReply, anyhow::Error>, waited: bool) -> String { pub fn format_recv(resp: Result<hive_sh4re::Response, anyhow::Error>, waited: bool) -> String {
match resp { match resp {
Ok(SocketReply::Messages(m)) => render_recv_messages(&m, waited), Ok(hive_sh4re::Response::Messages { messages }) => render_recv_messages(&messages, waited),
// A graceful stop is pending — the inbox is fenced. Render a single
// explicit directive (not an empty inbox, which claude's "park on recv"
// habit would long-poll again, stalling the stop-checkpoint turn until
// the drain wait times out into a hard stop) so every recv during the
// stop unmissably tells claude to flush + end.
Ok(hive_sh4re::Response::GracefulStop) => {
render_recv_messages(&[graceful_stop_message()], waited)
}
other => reply_err(other, "recv"), other => reply_err(other, "recv"),
} }
} }
/// The synthetic single-message directive rendered for a fenced (graceful-stop)
/// inbox — see the `GracefulStop` arm of [`format_recv`].
fn graceful_stop_message() -> hive_sh4re::DeliveredMessage {
hive_sh4re::DeliveredMessage {
from: "graceful-stop".into(),
body: "⛔ GRACEFUL STOP IN PROGRESS — the container shuts down as soon as this turn \
ends. Flush anything worth keeping to your durable /state files, then END \
YOUR TURN now. Do NOT call recv again: the inbox is fenced and recv will \
only keep returning this same notice."
.into(),
id: 0,
redelivered: false,
in_reply_to: None,
}
}
/// Render the popped-message payload of a successful `recv` (see `format_recv` /// Render the popped-message payload of a successful `recv` (see `format_recv`
/// for the empty/single/batch shapes). /// for the empty/single/batch shapes).
fn render_recv_messages(messages: &[hive_sh4re::DeliveredMessage], waited: bool) -> String { fn render_recv_messages(messages: &[hive_sh4re::DeliveredMessage], waited: bool) -> String {
@ -284,10 +201,9 @@ pub const IDLE_WAIT_HINT: &str = " — nothing arrived before the wait timed out
If you have other useful work (assigned issues, in-flight PRs, a docs sweep, \ If you have other useful work (assigned issues, in-flight PRs, a docs sweep, \
notes to update), do that now rather than immediately parking on recv again."; notes to update), do that now rather than immediately parking on recv again.";
/// Inner renderer for a `Vec<LooseEnd>` already extracted from the /// Inner renderer for a `Vec<LooseEnd>` already extracted from the socket
/// socket reply. Called by both `format_loose_ends` (which handles the /// reply. Called by the `get_loose_ends` handler, which injects the
/// `Result<SocketReply>` wrapper) and the augmented `get_loose_ends` /// `UnreadMatrix` entry before formatting.
/// handler (which injects the `UnreadMatrix` entry before formatting).
fn render_loose_ends(loose_ends: &[hive_sh4re::LooseEnd]) -> String { fn render_loose_ends(loose_ends: &[hive_sh4re::LooseEnd]) -> String {
use std::fmt::Write as _; use std::fmt::Write as _;
if loose_ends.is_empty() { if loose_ends.is_empty() {
@ -366,21 +282,6 @@ fn render_loose_ends(loose_ends: &[hive_sh4re::LooseEnd]) -> String {
out out
} }
/// Format helper for `get_loose_ends`: renders a short bulleted list
/// of pending approvals + questions + reminders. Empty list collapses
/// to a clear marker so claude doesn't go hunting for a payload that
/// isn't there.
#[must_use]
pub fn format_loose_ends(resp: Result<SocketReply, anyhow::Error>) -> String {
let loose_ends = match resp {
Ok(SocketReply::LooseEnds(t)) => t,
Ok(SocketReply::Err(m)) => return format!("get_loose_ends failed: {m}"),
Ok(other) => return format!("get_loose_ends unexpected response: {other:?}"),
Err(e) => return format!("get_loose_ends transport error: {e:#}"),
};
render_loose_ends(&loose_ends)
}
/// Per-room unread entry returned by `matrix_unread_summary`. Mirrors /// Per-room unread entry returned by `matrix_unread_summary`. Mirrors
/// `hive-matrix-mcp`'s `RoomUnread` but defined locally to avoid a /// `hive-matrix-mcp`'s `RoomUnread` but defined locally to avoid a
/// cross-crate dep on the matrix-sdk crate tree. /// cross-crate dep on the matrix-sdk crate tree.
@ -481,9 +382,9 @@ fn loose_end_kind_label(kind: hive_sh4re::CancelLooseEndKind) -> &'static str {
/// `running: no` line tells the caller WHY. See /// `running: no` line tells the caller WHY. See
/// `docs/turn-loop.md::Sub-agent tools` (`get_agent_meta`). /// `docs/turn-loop.md::Sub-agent tools` (`get_agent_meta`).
#[must_use] #[must_use]
pub fn format_agent_meta(resp: Result<SocketReply, anyhow::Error>) -> String { pub fn format_agent_meta(resp: Result<hive_sh4re::Response, anyhow::Error>) -> String {
match resp { match resp {
Ok(SocketReply::AgentMeta { Ok(hive_sh4re::Response::AgentMeta {
name, name,
running, running,
hyperhive_rev, hyperhive_rev,
@ -693,18 +594,18 @@ impl AgentServer {
Self { socket } Self { socket }
} }
/// Issue any `Request` through the retry-aware client and pull /// Issue any `Request` through the retry-aware client. Returns the raw
/// the reply through `SocketReply`. Returns the retry count so tool /// `Response` plus the retry count so tool handlers can annotate their
/// handlers can annotate their result (see `annotate_retries`). /// result (see `annotate_retries`).
/// ///
/// `AgentRequest` / `ManagerRequest` / `Request` are all the same type /// `AgentRequest` / `ManagerRequest` / `Request` are all the same type
/// (hive-sh4re type aliases), so this single method covers both sockets. /// (hive-sh4re type aliases), so this single method covers both sockets.
async fn dispatch( async fn dispatch(
&self, &self,
req: hive_sh4re::Request, req: hive_sh4re::Request,
) -> (Result<SocketReply, anyhow::Error>, u32) { ) -> (Result<hive_sh4re::Response, anyhow::Error>, u32) {
match client::request_retried::<_, hive_sh4re::Response>(&self.socket, &req).await { match client::request_retried::<_, hive_sh4re::Response>(&self.socket, &req).await {
Ok((r, n)) => (Ok(SocketReply::from(r)), n), Ok((r, n)) => (Ok(r), n),
Err(e) => (Err(e), 0), Err(e) => (Err(e), 0),
} }
} }
@ -770,7 +671,7 @@ impl AgentServer {
}) })
.await; .await;
let s = match resp { let s = match resp {
Ok(SocketReply::QuestionQueued(id)) => format!( Ok(hive_sh4re::Response::QuestionQueued { id }) => format!(
"question queued (id={id}); answer will arrive as a system \ "question queued (id={id}); answer will arrive as a system \
`question_answered` event in your inbox" `question_answered` event in your inbox"
), ),
@ -858,7 +759,7 @@ impl AgentServer {
.dispatch(hive_sh4re::Request::AckUntil { up_to: args.up_to }) .dispatch(hive_sh4re::Request::AckUntil { up_to: args.up_to })
.await; .await;
let rendered = match resp { let rendered = match resp {
Ok(SocketReply::Acked(count)) => { Ok(hive_sh4re::Response::Acked { count }) => {
format!("acked {count} message(s) up to id {}", args.up_to) format!("acked {count} message(s) up to id {}", args.up_to)
} }
other => reply_err(other, "ack_until"), other => reply_err(other, "ack_until"),
@ -891,7 +792,7 @@ impl AgentServer {
.await; .await;
// Extract the vec so we can augment before rendering. // Extract the vec so we can augment before rendering.
let mut loose_ends = match resp { let mut loose_ends = match resp {
Ok(SocketReply::LooseEnds(t)) => t, Ok(hive_sh4re::Response::LooseEnds { loose_ends }) => loose_ends,
other => return annotate_retries(reply_err(other, "get_loose_ends"), retries), other => return annotate_retries(reply_err(other, "get_loose_ends"), retries),
}; };
// Prepend matrix unread entry for self-queries only (can't // Prepend matrix unread entry for self-queries only (can't
@ -1030,7 +931,7 @@ impl AgentServer {
.dispatch(hive_sh4re::Request::CreateRepo { repo: args.repo }) .dispatch(hive_sh4re::Request::CreateRepo { repo: args.repo })
.await; .await;
let s = match resp { let s = match resp {
Ok(SocketReply::RepoCreated { Ok(hive_sh4re::Response::RepoCreated {
full_name, full_name,
clone_url, clone_url,
}) => format!("created repo {full_name} — clone: {clone_url}"), }) => format!("created repo {full_name} — clone: {clone_url}"),
@ -1191,7 +1092,7 @@ impl AgentServer {
run_tool_envelope("list_containers", String::new(), async move { run_tool_envelope("list_containers", String::new(), async move {
let (resp, retries) = self.dispatch(hive_sh4re::Request::ListDescendants).await; let (resp, retries) = self.dispatch(hive_sh4re::Request::ListDescendants).await;
let body = match resp { let body = match resp {
Ok(SocketReply::Containers(containers)) => { Ok(hive_sh4re::Response::Containers { containers }) => {
if containers.is_empty() { if containers.is_empty() {
"no descendant containers".to_owned() "no descendant containers".to_owned()
} else { } else {
@ -1245,7 +1146,7 @@ impl AgentServer {
}) })
.await; .await;
let result = match resp { let result = match resp {
Ok(SocketReply::HostJournal(content)) => content, Ok(hive_sh4re::Response::HostJournal { content }) => content,
other => reply_err(other, "get_host_journal"), other => reply_err(other, "get_host_journal"),
}; };
annotate_retries(result, retries) annotate_retries(result, retries)
@ -1370,7 +1271,7 @@ impl AgentServer {
}) })
.await; .await;
let s = match resp { let s = match resp {
Ok(SocketReply::Logs(content)) => { Ok(hive_sh4re::Response::Logs { content }) => {
if content.is_empty() { if content.is_empty() {
format!("(no journal output for {agent})") format!("(no journal output for {agent})")
} else { } else {
@ -1563,7 +1464,7 @@ impl AgentServer {
run_tool_envelope("list_schedules", String::new(), async move { run_tool_envelope("list_schedules", String::new(), async move {
let (resp, retries) = self.dispatch(hive_sh4re::Request::ListSchedules).await; let (resp, retries) = self.dispatch(hive_sh4re::Request::ListSchedules).await;
let body = match resp { let body = match resp {
Ok(SocketReply::Schedules(schedules)) => serde_json::to_string(&schedules) Ok(hive_sh4re::Response::Schedules { schedules }) => serde_json::to_string(&schedules)
.unwrap_or_else(|e| format!("list_schedules: serialise: {e:#}")), .unwrap_or_else(|e| format!("list_schedules: serialise: {e:#}")),
other => reply_err(other, "list_schedules"), other => reply_err(other, "list_schedules"),
}; };
@ -1580,27 +1481,23 @@ impl AgentServer {
)] )]
impl ServerHandler for AgentServer {} impl ServerHandler for AgentServer {}
/// Run an MCP server over stdio for the given flavor. Returns when the client disconnects. /// Run the MCP server over stdio. Used by all roles. Returns when the client
/// disconnects.
/// ///
/// # Errors /// # Errors
/// ///
/// Returns an error if the MCP server fails to initialize or the transport /// Returns an error if the MCP server fails to initialize or the transport
/// encounters a fatal error. /// encounters a fatal error.
pub async fn serve_stdio(socket: PathBuf) -> Result<()> { pub async fn serve_agent_stdio(socket: PathBuf) -> Result<()> {
let server = AgentServer::new(socket); let server = AgentServer::new(socket);
let service = server.serve(stdio()).await?; let service = server.serve(stdio()).await?;
service.waiting().await?; service.waiting().await?;
Ok(()) Ok(())
} }
/// Run the MCP server over stdio. Used by all roles.
pub async fn serve_agent_stdio(socket: PathBuf) -> Result<()> {
serve_stdio(socket).await
}
/// Run the MCP server over HTTP (rmcp streamable-http transport) on `addr`. /// Run the MCP server over HTTP (rmcp streamable-http transport) on `addr`.
/// ///
/// Unlike [`serve_stdio`] — a fresh stdio child claude respawns every turn — /// Unlike [`serve_agent_stdio`] — a fresh stdio child claude respawns every turn —
/// this is meant to run as a long-lived in-container daemon. claude reconnects /// this is meant to run as a long-lived in-container daemon. claude reconnects
/// to the stable URL each turn instead of respawning and re-registering a stdio /// to the stable URL each turn instead of respawning and re-registering a stdio
/// subprocess, which removes the per-turn MCP registration race that can strand /// subprocess, which removes the per-turn MCP registration race that can strand
@ -1750,17 +1647,6 @@ pub struct CancelLooseEndArgs {
pub id: i64, pub id: i64,
} }
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct GetLooseEndsArgs {
/// Whose loose ends to list. Omit (or `null`) for your own: approvals
/// you submitted + questions where you are asker/target + your own
/// pending reminders. Pass `"*"` for a hive-wide view of EVERY pending
/// approval, unanswered question, and reminder across the swarm. Pass a
/// specific agent name to inspect just that agent's threads.
#[serde(default)]
pub agent: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct AgentGetLooseEndsArgs { pub struct AgentGetLooseEndsArgs {
/// Whose loose ends to list. Omit (or `null`) for your own. You may /// Whose loose ends to list. Omit (or `null`) for your own. You may
@ -2316,20 +2202,20 @@ pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> Str
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{IDLE_WAIT_HINT, SocketReply, format_recv}; use super::{IDLE_WAIT_HINT, format_recv};
use super::{SERVER_NAME, allowed_mcp_tools}; use super::{SERVER_NAME, allowed_mcp_tools};
use hive_sh4re::ToolGroup; use hive_sh4re::ToolGroup;
#[test] #[test]
fn empty_recv_after_wait_appends_idle_hint() { fn empty_recv_after_wait_appends_idle_hint() {
let out = format_recv(Ok(SocketReply::Messages(vec![])), true); let out = format_recv(Ok(hive_sh4re::Response::Messages { messages: vec![] }), true);
assert!(out.starts_with("(empty)")); assert!(out.starts_with("(empty)"));
assert!(out.contains(IDLE_WAIT_HINT)); assert!(out.contains(IDLE_WAIT_HINT));
} }
#[test] #[test]
fn empty_recv_without_wait_has_no_hint() { fn empty_recv_without_wait_has_no_hint() {
let out = format_recv(Ok(SocketReply::Messages(vec![])), false); let out = format_recv(Ok(hive_sh4re::Response::Messages { messages: vec![] }), false);
assert_eq!(out, "(empty)"); assert_eq!(out, "(empty)");
} }