broker: recv_batch(max) — drain a bursty inbox in one round-trip

This commit is contained in:
damocles 2026-05-19 00:40:31 +02:00
parent 96ffb0e39a
commit 77b89bf2c6
9 changed files with 354 additions and 11 deletions

View file

@ -183,6 +183,29 @@ read them à la carte.
In-flight or recent context that hasn't earned a section yet.
Prune freely.
- **Just landed:** inbox batching via `recv_batch(max)`. New
MCP tool on both agent + manager surfaces pops up to `max`
pending messages in a single round-trip (server-side cap
32, `max = 0` short-circuits). Same delivery + ack
bookkeeping as `recv`: every popped row is marked
`delivered_at = NOW`, tracked on the per-recipient
`unacked_ids` list (so the next `AckTurn` closes them out),
and tagged `redelivered: true` if it was resurfaced by
`RequeueInflight`. Wake prompt's pending-inbox hint
rewritten to recommend `recv_batch(max: N)` instead of N
consecutive `recv`s. Wire: new
`AgentRequest::RecvBatch { max }` /
`AgentResponse::Batch { messages: Vec<DeliveredMessage> }`
(+ manager mirror) and a new `DeliveredMessage` struct in
`hive-sh4re` shared by both. `format_recv_batch` on the
mcp side renders the popped list with per-message
redelivery banners + `---` separators; empty batch
collapses to "(empty)" like single `recv`. 4 new broker
tests cover FIFO + cap, empty-when-idle, zero-max
short-circuit, and redelivered-flag propagation. Closes
the "inbox batching hint" item from the ergonomics
wishlist — the hint that was already in `format_wake_prompt`
is now actionable in one tool call instead of N.
- **Just landed:** lease-style message delivery / no-drop
on turn fail. The `messages` table gained an `acked_at`
column (idempotent ALTER + backfill = `delivered_at` so

View file

@ -35,15 +35,6 @@
Filed by damocles, who actually lives in this thing. Loosely ranked by
how often the friction bites in normal use.
- **Inbox batching hint in the wake prompt** — when the harness pops a
message and there are N more waiting, the wake prompt should say so
(e.g. `"(+3 more queued; consider draining before acting)"`) so claude
knows to call `recv()` again in the same turn instead of doing the
expensive Read/Edit dance once per message over N turns. The data's
already in the broker (`Broker::pending_count(agent)`); just thread it
into the prompt builder in `hive-ag3nt::turn.rs`. Even better: add a
one-shot `recv_batch(max: u32)` MCP tool that returns up to `max`
pending messages in a single round-trip.
- **Optional `in_reply_to: <msg_id>` on send** — pure wire addition; no
behavioural change. The dashboard could render conversation threads
(already wants this for the agent-to-agent question UI in the

View file

@ -265,6 +265,7 @@ async fn serve(
Ok(
AgentResponse::Ok
| AgentResponse::Status { .. }
| AgentResponse::Batch { .. }
| AgentResponse::Recent { .. }
| AgentResponse::QuestionQueued { .. }
| AgentResponse::LooseEnds { .. }
@ -301,7 +302,8 @@ fn format_wake_prompt(from: &str, body: &str, unread: u64, redelivered: bool) ->
String::new()
} else {
format!(
"\n\n({unread} more message(s) pending in your inbox — drain via `mcp__hyperhive__recv` if relevant.)"
"\n\n({unread} more message(s) pending in your inbox — call `mcp__hyperhive__recv_batch` \
with `max: {unread}` to drain them all in one round-trip before acting.)"
)
};
format!("{banner}Incoming message from `{from}`:\n---\n{body}\n---{pending}")

View file

@ -221,6 +221,7 @@ async fn serve(
Ok(
ManagerResponse::Ok
| ManagerResponse::Status { .. }
| ManagerResponse::Batch { .. }
| ManagerResponse::QuestionQueued { .. }
| ManagerResponse::Recent { .. }
| ManagerResponse::Logs { .. }
@ -256,7 +257,8 @@ fn format_wake_prompt(from: &str, body: &str, unread: u64, redelivered: bool) ->
String::new()
} else {
format!(
"\n\n({unread} more message(s) pending in your inbox — drain via `mcp__hyperhive__recv` if relevant.)"
"\n\n({unread} more message(s) pending in your inbox — call `mcp__hyperhive__recv_batch` \
with `max: {unread}` to drain them all in one round-trip before acting.)"
)
};
format!("{banner}Incoming message from `{from}`:\n---\n{body}\n---{pending}")

View file

@ -46,6 +46,10 @@ pub enum SocketReply {
id: i64,
redelivered: bool,
},
/// Result of `recv_batch`: zero or more messages popped in one
/// round-trip. Empty vec is the equivalent of `Empty` for a
/// single `recv` — the format helper collapses it to "(empty)".
Batch(Vec<hive_sh4re::DeliveredMessage>),
Empty,
Status(u64),
QuestionQueued(i64),
@ -77,6 +81,7 @@ impl From<hive_sh4re::AgentResponse> for SocketReply {
redelivered,
},
hive_sh4re::AgentResponse::Empty => Self::Empty,
hive_sh4re::AgentResponse::Batch { messages } => Self::Batch(messages),
hive_sh4re::AgentResponse::Status { unread } => Self::Status(unread),
hive_sh4re::AgentResponse::Recent { rows } => Self::Recent(rows),
hive_sh4re::AgentResponse::QuestionQueued { id } => Self::QuestionQueued(id),
@ -114,6 +119,7 @@ impl From<hive_sh4re::ManagerResponse> for SocketReply {
redelivered,
},
hive_sh4re::ManagerResponse::Empty => Self::Empty,
hive_sh4re::ManagerResponse::Batch { messages } => Self::Batch(messages),
hive_sh4re::ManagerResponse::Status { unread } => Self::Status(unread),
hive_sh4re::ManagerResponse::QuestionQueued { id } => Self::QuestionQueued(id),
hive_sh4re::ManagerResponse::Recent { rows } => Self::Recent(rows),
@ -179,6 +185,35 @@ pub fn format_recv(resp: Result<SocketReply, anyhow::Error>) -> String {
pub const REDELIVERY_HINT: &str =
"[redelivered after harness restart — may already be handled]\n";
/// Format helper for `recv_batch`: renders zero or more popped
/// messages as a single string. Empty batch collapses to "(empty)"
/// so claude doesn't go hunting for content. Each message is rendered
/// in the same `from: <name>\n\n<body>` shape as `format_recv` —
/// per-message redelivery banner included — separated by a thin rule
/// so the model can tell where one body ends and the next begins.
pub fn format_recv_batch(resp: Result<SocketReply, anyhow::Error>) -> String {
use std::fmt::Write as _;
let messages = match resp {
Ok(SocketReply::Batch(m)) => m,
Ok(SocketReply::Err(m)) => return format!("recv_batch failed: {m}"),
Ok(other) => return format!("recv_batch unexpected response: {other:?}"),
Err(e) => return format!("recv_batch transport error: {e:#}"),
};
if messages.is_empty() {
return "(empty)".to_owned();
}
let n = messages.len();
let mut out = format!("popped {n} message(s):\n\n");
for (i, m) in messages.iter().enumerate() {
if i > 0 {
out.push_str("\n---\n\n");
}
let banner = if m.redelivered { REDELIVERY_HINT } else { "" };
let _ = write!(out, "{banner}from: {}\n\n{}", m.from, m.body);
}
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
@ -338,6 +373,17 @@ pub struct RecvArgs {
pub wait_seconds: Option<u64>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct RecvBatchArgs {
/// Maximum number of messages to pop in this round-trip. Capped
/// at 32 server-side; values above the cap clamp silently.
/// Returns whatever is currently pending (possibly zero) without
/// long-polling — call when you've been told the inbox is busy
/// (e.g. the wake prompt mentioned N pending) and want to drain
/// them in one tool call instead of N separate `recv` calls.
pub max: u32,
}
/// MCP tool args for `remind`. Exactly one of `delay_seconds` or
/// `at_unix_timestamp` must be set; both / neither is a tool-side error.
/// Hides the tagged `ReminderTiming` enum behind a flatter schema so the
@ -511,6 +557,26 @@ impl AgentServer {
.await
}
#[tool(
description = "Pop up to `max` messages from this agent's inbox in a single round-trip \
(no long-poll returns whatever's pending immediately, possibly zero). Use this \
when the wake prompt tells you the inbox has more messages queued, or any time you \
know you'll be draining several at once: one tool call beats N consecutive `recv`s. \
Per-message redelivery banners + ack bookkeeping work the same as `recv`. `max` \
caps at 32 server-side; pass whatever you reasonably expect to handle this turn."
)]
async fn recv_batch(&self, Parameters(args): Parameters<RecvBatchArgs>) -> String {
let log = format!("{args:?}");
let max = args.max;
run_tool_envelope("recv_batch", log, async move {
let (resp, retries) = self
.dispatch(hive_sh4re::AgentRequest::RecvBatch { max })
.await;
annotate_retries(format_recv_batch(resp), retries)
})
.await
}
#[tool(
description = "List loose ends pending against this agent: unanswered questions \
where you are the asker (waiting on someone) or the target (someone's waiting on \
@ -819,6 +885,26 @@ impl ManagerServer {
.await
}
#[tool(
description = "Pop up to `max` messages from the manager inbox in a single round-trip \
(no long-poll returns whatever's pending immediately, possibly zero). Use this \
when the wake prompt tells you the inbox has more messages queued, or any time you \
know you'll be draining several at once: one tool call beats N consecutive `recv`s. \
Per-message redelivery banners + ack bookkeeping work the same as `recv`. `max` \
caps at 32 server-side; pass whatever you reasonably expect to handle this turn."
)]
async fn recv_batch(&self, Parameters(args): Parameters<RecvBatchArgs>) -> String {
let log = format!("{args:?}");
let max = args.max;
run_tool_envelope("recv_batch", log, async move {
let (resp, retries) = self
.dispatch(hive_sh4re::ManagerRequest::RecvBatch { max })
.await;
annotate_retries(format_recv_batch(resp), retries)
})
.await
}
#[tool(
description = "Queue a Spawn approval for a brand-new sub-agent. The operator \
approves on the dashboard before the container is actually created."

View file

@ -87,6 +87,15 @@ async fn serve(stream: UnixStream, agent: String, coord: Arc<Coordinator>) -> Re
/// positive `wait_seconds`.
const RECV_LONG_POLL_MAX: std::time::Duration = std::time::Duration::from_secs(180);
/// Server-side hard cap on `RecvBatch.max`. Bounds the size of a
/// single round-trip so a confused caller can't drain the entire
/// inbox in one go and blow past wire-buffer sizes; everything above
/// the cap silently clamps. 32 is comfortably above the burst sizes
/// we've seen in practice (post-rebuild rescue, multi-agent reply
/// storms) and well under the per-message `MESSAGE_MAX_BYTES` * N
/// envelope budget.
const RECV_BATCH_MAX: u32 = 32;
fn recv_timeout(wait_seconds: Option<u64>) -> std::time::Duration {
match wait_seconds {
Some(s) => std::time::Duration::from_secs(s).min(RECV_LONG_POLL_MAX),
@ -114,6 +123,25 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
message: format!("{e:#}"),
},
},
AgentRequest::RecvBatch { max } => {
let cap = (*max).min(RECV_BATCH_MAX) as usize;
match broker.recv_batch(agent, cap) {
Ok(deliveries) => AgentResponse::Batch {
messages: deliveries
.into_iter()
.map(|d| hive_sh4re::DeliveredMessage {
from: d.message.from,
body: d.message.body,
id: d.id,
redelivered: d.redelivered,
})
.collect(),
},
Err(e) => AgentResponse::Err {
message: format!("{e:#}"),
},
}
}
AgentRequest::Status => match broker.count_pending(agent) {
Ok(unread) => AgentResponse::Status { unread },
Err(e) => AgentResponse::Err {

View file

@ -362,6 +362,85 @@ impl Broker {
}))
}
/// Pop up to `max` pending messages for `recipient` in one
/// round-trip. Same per-row semantics as `recv`: every popped row
/// is marked `delivered_at = NOW`, pushed onto the per-recipient
/// `unacked_ids` list (so the next `ack_turn` closes them out),
/// and tagged with `redelivered = true` if it was resurfaced by
/// the most recent `requeue_inflight`. Emits one
/// `MessageEvent::Delivered` per popped row so the dashboard
/// forwarder stream stays consistent with the single-row path.
///
/// `max == 0` short-circuits to an empty vec (no DB hit); any
/// positive value caps the batch at `max`. FIFO order matches
/// `recv`.
pub fn recv_batch(&self, recipient: &str, max: usize) -> Result<Vec<Delivery>> {
if max == 0 {
return Ok(Vec::new());
}
// Same lock order as `recv` / `ack_turn` / `requeue_inflight`.
let mut inflight = self.inflight.lock().unwrap();
let conn = self.conn.lock().unwrap();
let max_i = i64::try_from(max).unwrap_or(i64::MAX);
let mut stmt = conn.prepare(
"SELECT id, sender, recipient, body
FROM messages
WHERE recipient = ?1 AND delivered_at IS NULL
ORDER BY id ASC
LIMIT ?2",
)?;
let rows: Vec<(i64, String, String, String)> = stmt
.query_map(params![recipient, max_i], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})?
.collect::<rusqlite::Result<_>>()?;
drop(stmt);
if rows.is_empty() {
return Ok(Vec::new());
}
// Stamp all popped rows in a single UPDATE — under the broker
// mutex, well within sqlite's 999-param default.
let now = now_unix();
let ids: Vec<i64> = rows.iter().map(|(id, _, _, _)| *id).collect();
let placeholders = std::iter::repeat_n("?", ids.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!("UPDATE messages SET delivered_at = ? WHERE id IN ({placeholders})");
let mut params_vec: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(ids.len() + 1);
params_vec.push(&now);
for id in &ids {
params_vec.push(id);
}
conn.execute(&sql, params_vec.as_slice())?;
drop(conn);
// Bookkeeping + assemble the Delivery list. Per-row
// `requeued_ids` lookup runs once per pop, same as `recv`.
let slot = inflight.entry(recipient.to_owned()).or_default();
let mut deliveries = Vec::with_capacity(rows.len());
for (id, from, to, body) in rows {
slot.unacked_ids.push(id);
let redelivered = slot.requeued_ids.remove(&id);
deliveries.push(Delivery {
id,
redelivered,
message: Message { from, to, body },
});
}
drop(inflight);
// Mirror the per-row Delivered emit `recv` does so the
// dashboard forwarder sees one event per message regardless of
// which surface the harness used.
for d in &deliveries {
let _ = self.events.send(MessageEvent::Delivered {
from: d.message.from.clone(),
to: d.message.to.clone(),
body: d.message.body.clone(),
at: now,
});
}
Ok(deliveries)
}
/// Drain the per-recipient unacked-id list and mark every row
/// `acked_at = NOW`. Fired by the harness after `TurnOutcome::Ok`.
/// Returns the number of rows acked (zero is normal — claude
@ -867,6 +946,70 @@ mod tests {
);
}
/// Happy path for `recv_batch`: pops in FIFO order, respects
/// `max`, leaves the rest pending for the next call.
#[test]
fn recv_batch_pops_fifo_capped_at_max() {
let h = open_broker();
let broker = &h.broker;
for i in 0..5 {
broker.send(&msg("a", "b", &format!("m{i}"))).unwrap();
}
let batch = broker.recv_batch("b", 3).unwrap();
let bodies: Vec<_> = batch.iter().map(|d| d.message.body.as_str()).collect();
assert_eq!(bodies, vec!["m0", "m1", "m2"]);
// Remaining two stay pending; a second batch drains them.
let next = broker.recv_batch("b", 10).unwrap();
let bodies: Vec<_> = next.iter().map(|d| d.message.body.as_str()).collect();
assert_eq!(bodies, vec!["m3", "m4"]);
// ack_turn closes out all five popped rows in one go.
assert_eq!(broker.ack_turn("b").unwrap(), 5);
}
/// `recv_batch` with no pending traffic returns an empty vec
/// (the "(empty)" path), not an error.
#[test]
fn recv_batch_returns_empty_when_idle() {
let h = open_broker();
let batch = h.broker.recv_batch("ghost", 5).unwrap();
assert!(batch.is_empty());
}
/// `max = 0` short-circuits without touching the DB (covered by
/// asserting we don't accidentally pop a pending row).
#[test]
fn recv_batch_zero_max_pops_nothing() {
let h = open_broker();
let broker = &h.broker;
broker.send(&msg("a", "b", "stay")).unwrap();
assert!(broker.recv_batch("b", 0).unwrap().is_empty());
// The pending row is still in flight for the next real recv.
let d = broker.recv("b").unwrap().expect("still pending");
assert_eq!(d.message.body, "stay");
}
/// `recv_batch` tags requeued rows with `redelivered: true` and
/// drains them from the per-recipient `requeued_ids` set so a
/// fresh follow-up recv after the batch doesn't double-tag.
#[test]
fn recv_batch_propagates_redelivered_flag() {
let h = open_broker();
let broker = &h.broker;
broker.send(&msg("a", "b", "one")).unwrap();
broker.send(&msg("a", "b", "two")).unwrap();
broker.recv("b").unwrap().expect("popped 1");
broker.recv("b").unwrap().expect("popped 2");
broker.requeue_inflight("b").unwrap();
let batch = broker.recv_batch("b", 5).unwrap();
assert_eq!(batch.len(), 2);
assert!(batch.iter().all(|d| d.redelivered));
// Fresh send after the batch is NOT tagged redelivered.
broker.send(&msg("a", "b", "three")).unwrap();
let d = broker.recv("b").unwrap().expect("re-pop 3");
assert_eq!(d.message.body, "three");
assert!(!d.redelivered);
}
/// Per-recipient isolation: `requeue_inflight("a")` doesn't touch
/// b's inflight rows.
#[test]

View file

@ -75,6 +75,11 @@ async fn serve(stream: UnixStream, coord: Arc<Coordinator>) -> Result<()> {
/// seconds (clamped at MAX).
const MANAGER_RECV_LONG_POLL_MAX: std::time::Duration = std::time::Duration::from_secs(180);
/// Same shape + rationale as `agent_server::RECV_BATCH_MAX`. Kept
/// numerically aligned across surfaces so a tool description that
/// quotes the cap stays accurate either way.
const MANAGER_RECV_BATCH_MAX: u32 = 32;
fn manager_recv_timeout(wait_seconds: Option<u64>) -> std::time::Duration {
match wait_seconds {
Some(s) => std::time::Duration::from_secs(s).min(MANAGER_RECV_LONG_POLL_MAX),
@ -149,6 +154,25 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> ManagerResp
message: format!("{e:#}"),
},
},
ManagerRequest::RecvBatch { max } => {
let cap = (*max).min(MANAGER_RECV_BATCH_MAX) as usize;
match coord.broker.recv_batch(MANAGER_AGENT, cap) {
Ok(deliveries) => ManagerResponse::Batch {
messages: deliveries
.into_iter()
.map(|d| hive_sh4re::DeliveredMessage {
from: d.message.from,
body: d.message.body,
id: d.id,
redelivered: d.redelivered,
})
.collect(),
},
Err(e) => ManagerResponse::Err {
message: format!("{e:#}"),
},
}
}
ManagerRequest::RequestSpawn { name, description } => {
tracing::info!(%name, "manager: request_spawn");
match coord.approvals.submit_kind(

View file

@ -174,6 +174,28 @@ pub struct InboxRow {
pub at: i64,
}
/// One delivered message in a `RecvBatch` response. Same fields as
/// `AgentResponse::Message` / `ManagerResponse::Message` without the
/// variant wrapper — a batch returns a `Vec<DeliveredMessage>` so the
/// harness can iterate without unpicking N separate top-level frames.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeliveredMessage {
pub from: String,
pub body: String,
/// Broker row id, mirrored from the `Delivery` struct. Opaque to
/// claude but tracked by the harness so the broker's in-memory
/// unacked list can be drained on `AckTurn`. Marked `default` for
/// wire backwards-compat — pre-feature peers parse to 0.
#[serde(default)]
pub id: i64,
/// `true` when this row was previously popped, never acked, and
/// resurfaced by `RequeueInflight`. The format helper prepends the
/// "may already be handled" hint to the rendered body so claude
/// sees the warning per-message in the batch.
#[serde(default)]
pub redelivered: bool,
}
/// Reminder timing: either relative (wait N seconds) or absolute (at unix
/// timestamp).
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -271,6 +293,15 @@ pub enum AgentRequest {
#[serde(default)]
wait_seconds: Option<u64>,
},
/// Pop up to `max` pending messages in one round-trip. No
/// long-poll — returns whatever's currently queued (possibly
/// zero) immediately. Same delivery + ack bookkeeping as `Recv`:
/// every popped row is marked `delivered_at = NOW`, tracked in
/// the broker's per-recipient `unacked_ids` list (so the next
/// `AckTurn` closes them out), and tagged `redelivered = true` if
/// it was resurfaced by `RequeueInflight`. Used by the harness to
/// drain a bursty inbox without N socket round-trips.
RecvBatch { max: u32 },
/// Non-mutating: how many pending messages are addressed to me?
/// Used by the harness to render a status line after each tool call.
Status,
@ -408,6 +439,10 @@ pub enum AgentResponse {
},
/// `Recv` found nothing pending.
Empty,
/// `RecvBatch` result. `messages` is in FIFO order and may be
/// empty (treated like `Empty` for `Recv`); never longer than the
/// `max` the caller passed.
Batch { messages: Vec<DeliveredMessage> },
/// `Status` result: how many pending messages are in this agent's inbox.
Status { unread: u64 },
/// `Recent` result: newest-first inbox rows.
@ -583,6 +618,10 @@ pub enum ManagerRequest {
#[serde(default)]
wait_seconds: Option<u64>,
},
/// Mirror of `AgentRequest::RecvBatch` on the manager surface —
/// pop up to `max` pending messages in one round-trip, no
/// long-poll. Same ack + redelivery bookkeeping.
RecvBatch { max: u32 },
/// Non-mutating: pending message count, used to render a status line
/// after each MCP tool call (mirrors `AgentRequest::Status`).
Status,
@ -734,6 +773,11 @@ pub enum ManagerResponse {
redelivered: bool,
},
Empty,
/// Mirror of `AgentResponse::Batch` on the manager surface.
/// `messages` is in FIFO order and may be empty.
Batch {
messages: Vec<DeliveredMessage>,
},
Status {
unread: u64,
},