add ack_until: bulk-ack inbox messages by id + surface msg ids in wake prompts and recv (closes #2125)

This commit is contained in:
damocles 2026-07-02 11:45:12 +02:00 committed by mara
commit 34374fd10a
8 changed files with 221 additions and 16 deletions

View file

@ -33,12 +33,14 @@ pub enum SocketReply {
/// 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 opaque to claude (the bin loops
/// drive ack via `AckTurn`, not per-id); `redelivered` triggers
/// the "may already be handled" banner in `format_recv` for that
/// specific row.
/// 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),
@ -74,6 +76,7 @@ impl From<hive_sh4re::Response> for SocketReply {
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),
@ -219,7 +222,7 @@ pub fn format_recv(resp: Result<SocketReply, anyhow::Error>, waited: bool) -> St
if messages.len() == 1 {
let m = &messages[0];
let banner = if m.redelivered { REDELIVERY_HINT } else { "" };
return format!("{banner}from: {}\n\n{}", m.from, m.body);
return format!("{banner}{}from: {}\n\n{}", msg_id_tag(m.id), m.from, m.body);
}
let n = messages.len();
let mut out = format!("popped {n} message(s):\n\n");
@ -228,11 +231,29 @@ pub fn format_recv(resp: Result<SocketReply, anyhow::Error>, waited: bool) -> St
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);
let _ = write!(
out,
"{banner}{}from: {}\n\n{}",
msg_id_tag(m.id),
m.from,
m.body
);
}
out
}
/// `[msg #<id>] ` marker prefixed to each recv row so the agent knows
/// what to pass to `ack_until` when bulk-triaging a backlog. Transient
/// pings carry the sentinel id 0 (in-memory only, nothing in the
/// broker to ack) and render without the marker.
fn msg_id_tag(id: i64) -> String {
if id > 0 {
format!("[msg #{id}] ")
} else {
String::new()
}
}
/// Header prepended to message bodies that were popped by a prior
/// harness session, never acked (turn crash / OOM / restart), and
/// resurfaced by `RequeueInflight` on this session's boot. Same
@ -601,6 +622,16 @@ pub struct RecvArgs {
pub max: Option<u32>,
}
/// MCP tool args for `ack_until`.
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct AckUntilArgs {
/// Highest broker message id to mark handled: every inbox message
/// with `id <= up_to` (ids show as `[msg #<id>]` in recv output)
/// is acked in one sweep. Pass the highest id you've actually
/// seen/triaged — anything above it stays queued for later turns.
pub up_to: i64,
}
/// 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
@ -798,6 +829,36 @@ impl AgentServer {
.await
}
#[tool(
description = "Bulk-mark inbox messages handled: every message with broker id \
<= `up_to` (ids show as `[msg #<id>]` in recv output and wake prompts) is \
acked in one call pending and already-delivered rows alike. Use this to \
clear a backlog you've already triaged (e.g. a redelivered flood after a \
container restart, or a pile of stale notifications) instead of draining it \
one recv at a time: note the highest `[msg #N]` you've seen, then \
`ack_until(up_to: N)`. Acked messages never redeliver. Only affects YOUR \
inbox rows; messages newer than `up_to` stay queued. Returns how many rows \
were newly acked."
)]
async fn ack_until(&self, Parameters(args): Parameters<AckUntilArgs>) -> String {
let log = format!("{args:?}");
run_tool_envelope("ack_until", log, async move {
let (resp, retries) = self
.dispatch(hive_sh4re::Request::AckUntil { up_to: args.up_to })
.await;
let rendered = match resp {
Ok(SocketReply::Acked(count)) => {
format!("acked {count} message(s) up to id {}", args.up_to)
}
Ok(SocketReply::Err(m)) => format!("ack_until failed: {m}"),
Ok(other) => format!("ack_until unexpected response: {other:?}"),
Err(e) => format!("ack_until transport error: {e:#}"),
};
annotate_retries(rendered, 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 \