refactor(#2455): split hive-agent-mcp into its own bin crate

This commit is contained in:
damocles 2026-07-14 23:45:33 +02:00
commit e7f8254788
15 changed files with 334 additions and 100 deletions

16
Cargo.lock generated
View file

@ -1489,6 +1489,22 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "hive-agent-mcp"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"clap",
"hive-sh4re",
"rmcp",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]] [[package]]
name = "hive-agent-wake" name = "hive-agent-wake"
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-mcp",
"hive-agent-wake", "hive-agent-wake",
"hive-bash-mcp", "hive-bash-mcp",
"hive-c0re", "hive-c0re",

View file

@ -10,9 +10,7 @@ pub mod harness_state;
pub mod identity; pub mod identity;
pub mod login; pub mod login;
pub mod login_session; pub mod login_session;
pub mod mcp;
pub mod mcp_config; pub mod mcp_config;
pub mod mcp_loose_ends;
pub mod paths; pub mod paths;
pub mod plugins; pub mod plugins;
pub mod prompt; pub mod prompt;

View file

@ -2,10 +2,9 @@
//! set into the `--allowedTools` / `--tools` argument strings and renders the //! set into the `--allowedTools` / `--tools` argument strings and renders the
//! `--mcp-config` blob claude reads at spawn (built-in hyperhive server + //! `--mcp-config` blob claude reads at spawn (built-in hyperhive server +
//! any `hyperhive.extraMcpServers`). Pure config-string generation consumed by //! any `hyperhive.extraMcpServers`). Pure config-string generation consumed by
//! [`crate::turn`] when it builds the claude command — it never touches the //! [`crate::turn`] when it builds the claude command. It never touches the
//! running MCP server ([`crate::mcp`]). The `send` allow-list check //! running MCP server (a separate binary) — the `send` allow-list check that
//! ([`check_send_allowed`]) lives here too since it's driven by the same //! server enforces lives alongside it in the `hive-agent-mcp` crate.
//! `/etc/hyperhive/*.json` operator config.
/// Name of the hyperhive MCP server inside claude's view. Claude prefixes /// Name of the hyperhive MCP server inside claude's view. Claude prefixes
/// tools as `mcp__<this>__<tool>` (e.g. `mcp__hyperhive__send`). /// tools as `mcp__<this>__<tool>` (e.g. `mcp__hyperhive__send`).
@ -251,59 +250,6 @@ pub fn builtin_tools_arg() -> String {
/// `mcp__<key>__<tool>` pattern in `--allowedTools`. /// `mcp__<key>__<tool>` pattern in `--allowedTools`.
const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json"; const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json";
/// Where the NixOS module writes the per-agent send allow-list (see
/// `nix/templates/harness/`). Empty list = unrestricted (the
/// default). Non-empty list constrains `mcp__hyperhive__send`'s `to`
/// field; the manager is always implicitly permitted regardless of
/// the list contents.
const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json";
/// Enforce the per-agent send allow-list. Returns `Ok` when the
/// recipient is permitted (no list configured, `<parent>` sentinel
/// always allowed, or `to` is in the list); returns `Err(refusal)`
/// with a claude-readable string when blocked <20><><EFBFBD> the harness surfaces
/// the refusal as the tool result so claude knows the message didn't
/// land and can react (e.g. route via `<parent>` instead).
pub fn check_send_allowed(to: &str) -> Result<(), String> {
if to == hive_sh4re::PARENT_RECIPIENT {
// Always allow `<parent>` — the allow-list constrains peer
// chatter, not the structural reporting line; the operator
// can rewire who the parent IS via `set_parent` without
// having to remember to update the per-agent allow-list.
// The broker resolves the sentinel to the real parent label
// on the host side per topology.json (falls back to `operator`
// for root agents).
return Ok(());
}
let Ok(raw) = std::fs::read_to_string(SEND_ALLOW_PATH) else {
return Ok(()); // file missing → no policy configured → unrestricted
};
let allow: Vec<String> = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
path = SEND_ALLOW_PATH,
error = ?e,
"send allow-list parse failed; falling back to unrestricted",
);
return Ok(());
}
};
if allow.is_empty() {
return Ok(()); // empty list = unrestricted (back-compat)
}
if allow.iter().any(|n| n == to) {
return Ok(());
}
Err(format!(
"send refused: recipient '{to}' not in hyperhive.allowedRecipients \
(configured in agent.nix). Allowed: {allow:?}. Your structural \
parent is always reachable route through `send(to: \"{}\", …)` \
if you need to reach someone outside the allow-list.",
hive_sh4re::PARENT_RECIPIENT
))
}
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
struct ExtraMcpServer { struct ExtraMcpServer {
command: String, command: String,

View file

@ -5,7 +5,6 @@
//! stay in the binary because they touch the request enum variants directly. //! stay in the binary because they touch the request enum variants directly.
use crate::events::Bus; use crate::events::Bus;
use crate::mcp::REDELIVERY_HINT;
use crate::turn::{TurnError, TurnOutcome}; use crate::turn::{TurnError, TurnOutcome};
use crate::turn_stats::TurnStatRow; use crate::turn_stats::TurnStatRow;
pub use hive_sh4re::wire_time::now_unix; pub use hive_sh4re::wire_time::now_unix;
@ -23,38 +22,20 @@ pub fn format_wake_prompt(
unread: u64, unread: u64,
redelivered: bool, redelivered: bool,
) -> String { ) -> String {
let banner = if redelivered { REDELIVERY_HINT } else { "" }; let banner = if redelivered {
hive_sh4re::REDELIVERY_HINT
} else {
""
};
let tag = if id > 0 { let tag = if id > 0 {
format!("[msg #{id}] ") format!("[msg #{id}] ")
} else { } else {
String::new() String::new()
}; };
let pending = pending_hint(unread); let pending = hive_sh4re::pending_hint(unread);
format!("{banner}{tag}Incoming message from `{from}`:\n---\n{body}\n---{pending}") format!("{banner}{tag}Incoming message from `{from}`:\n---\n{body}\n---{pending}")
} }
/// Shared "(N more message(s) pending …)" advisory appended after both the
/// wake prompt body and the `recv` tool result whenever the inbox still has
/// queued messages once the current message/batch is popped. Returns an empty
/// string when `remaining == 0`. The leading `\n\n` separates it from the
/// preceding body/message block, and the suggested `max` is clamped to the
/// server-side recv cap so the hint never asks for more than one round-trip
/// can deliver. One builder so the wake prompt and the in-turn recv result
/// stay identical.
#[must_use]
pub fn pending_hint(remaining: u64) -> String {
if remaining == 0 {
return String::new();
}
let batch = remaining.min(u64::from(hive_sh4re::RECV_BATCH_MAX));
format!(
"\n\n({remaining} more message(s) pending in your inbox — call `mcp__hyperhive__recv` \
with `max: {batch}` to drain the next batch before acting. If the \
backlog is stale/already handled, `ack_until(up_to: <highest [msg #N] seen>)` \
clears everything up to that id in one call instead.)"
)
}
/// Field-named args for [`build_row`]. Mirrors the turn-stats row /// Field-named args for [`build_row`]. Mirrors the turn-stats row
/// columns; `outcome` and `bus` borrow for the duration of the call. /// columns; `outcome` and `bus` borrow for the duration of the call.
pub struct TurnRowArgs<'a> { pub struct TurnRowArgs<'a> {

23
hive-agent-mcp/Cargo.toml Normal file
View file

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

View file

@ -0,0 +1,152 @@
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 agent-side claude session having
/// to handle the transient itself — burning tokens on a tool-error retry
/// loop is more expensive than 60s of in-harness sleep.
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 and reporting how many retries it took past the initial
/// attempt (0 = succeeded first try). MCP tool handlers
/// use this so they can append a one-line hint to the tool result when
/// retries happened — that way claude knows the prior socket flake
/// wasn't a content error and shouldn't trigger an LLM-level retry of
/// its own.
///
/// # Errors
///
/// Returns an error if all retries are exhausted, or on a fatal protocol
/// error (serialization / deserialization failure).
///
/// # Panics
///
/// Panics if `RETRY_BACKOFFS_MS.len()` does not fit in a `u32`, which
/// cannot happen with the current compile-time constant.
pub async fn request_retried<Req, Resp>(socket: &Path, req: &Req) -> Result<(Resp, u32)>
where
Req: Serialize + ?Sized,
Resp: DeserializeOwned,
{
let mut last_err: Option<anyhow::Error> = None;
let max_retries = u32::try_from(RETRY_BACKOFFS_MS.len()).unwrap();
for attempt in 0..=max_retries {
match try_once::<Req, Resp>(socket, req).await {
Ok(resp) => return Ok((resp, attempt)),
Err(RequestError::Fatal(e)) => return Err(e),
Err(RequestError::Transient(e)) => {
if attempt < max_retries {
let sleep_ms = RETRY_BACKOFFS_MS[attempt as usize];
tracing::warn!(
attempt = attempt + 1,
sleep_ms,
error = %e,
"hive socket attempt failed; retrying"
);
last_err = Some(e);
tokio::time::sleep(Duration::from_millis(sleep_ms)).await;
} else {
last_err = Some(e);
}
}
}
}
// Reaching here means the final attempt returned `Transient`, which always
// sets `last_err` — so this is infallible.
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 `request_retried` rides it out. When
// the error *does* surface (retries exhausted, or a non-retried
// caller) add that context so claude reads it as a likely
// transient rather than a hard failure worth escalating.
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 harness 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()))
}
#[cfg(test)]
mod tests {
use super::{RequestError, try_once};
/// A connect to a non-existent socket path (ENOENT → `NotFound`) is
/// classified transient AND annotated with the "hive-c0re is restarting"
/// hint, so a surfaced tool error reads as the expected transient.
#[tokio::test]
async fn missing_socket_connect_is_transient_with_restart_hint() {
let bogus = std::path::Path::new("/nonexistent/hive/mcp.sock");
match try_once::<(), serde_json::Value>(bogus, &()).await {
Err(RequestError::Transient(e)) => {
let msg = format!("{e:#}");
assert!(msg.contains("restarting"), "missing restart hint: {msg}");
assert!(msg.contains("connect to"), "missing connect context: {msg}");
}
Err(RequestError::Fatal(e)) => panic!("expected transient, got fatal: {e:#}"),
Ok(_) => panic!("expected connect failure to a non-existent socket"),
}
}
}

View file

@ -6,12 +6,25 @@
//! sole transport — there is no stdio mode. Sibling of `hive-agent` (the //! sole transport — there is no stdio mode. Sibling of `hive-agent` (the
//! serve loop that renders the `--mcp-config` blob pointing here) and //! serve loop that renders the `--mcp-config` blob pointing here) and
//! `hive-agent-wake`. //! `hive-agent-wake`.
//!
//! Standalone bin crate: the MCP surface (`mcp/`) plus its small support
//! modules (socket client, send allow-list, loose-end scanner, path
//! resolver) live here rather than in the `hive-ag3nt` harness lib, so the
//! server binary doesn't link the whole turn loop.
use std::path::PathBuf; use std::path::PathBuf;
use anyhow::Result; use anyhow::Result;
use clap::Parser; use clap::Parser;
use hive_ag3nt::{DEFAULT_SOCKET, mcp};
mod client;
mod loose_ends;
mod mcp;
mod paths;
mod send_allow;
/// Per-agent MCP socket, bind-mounted from the host into every container.
const DEFAULT_SOCKET: &str = "/run/hive/mcp.sock";
#[derive(Parser)] #[derive(Parser)]
#[command(name = "hive-agent-mcp", about = "hyperhive MCP server")] #[command(name = "hive-agent-mcp", about = "hyperhive MCP server")]

View file

@ -32,9 +32,7 @@ pub use args::{
RequestInitConfigArgs, RequestSchedulePromptArgs, RestartArgs, SendArgs, SetStatusArgs, RequestInitConfigArgs, RequestSchedulePromptArgs, RestartArgs, SendArgs, SetStatusArgs,
StartArgs, UpdateArgs, UpdateMetaInputsArgs, StartArgs, UpdateArgs, UpdateMetaInputsArgs,
}; };
pub use render::{ pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv};
IDLE_WAIT_HINT, REDELIVERY_HINT, annotate_retries, format_ack, format_agent_meta, format_recv,
};
use render::{ use render::{
format_matrix_summary, loose_end_kind_label, matrix_unread_summary, parse_loose_end_kind, format_matrix_summary, loose_end_kind_label, matrix_unread_summary, parse_loose_end_kind,
@ -149,7 +147,7 @@ impl AgentServer {
let to = args.to.clone(); let to = args.to.clone();
// Check per-agent allow-list (hyperhive.allowedRecipients). When no // Check per-agent allow-list (hyperhive.allowedRecipients). When no
// policy file is present (e.g. manager containers) the check is a no-op. // policy file is present (e.g. manager containers) the check is a no-op.
if let Err(refusal) = crate::mcp_config::check_send_allowed(&to) { if let Err(refusal) = crate::send_allow::check_send_allowed(&to) {
return run_tool_envelope("send", log, async move { refusal }).await; return run_tool_envelope("send", log, async move { refusal }).await;
} }
run_tool_envelope("send", log, async move { run_tool_envelope("send", log, async move {
@ -340,7 +338,7 @@ impl AgentServer {
// Append loose-end items published by external MCP daemons // Append loose-end items published by external MCP daemons
// (e.g. active bash tasks from hive-bash-mcp). Generic — no // (e.g. active bash tasks from hive-bash-mcp). Generic — no
// per-MCP knowledge needed here. // per-MCP knowledge needed here.
let mcp_items = crate::mcp_loose_ends::collect(); let mcp_items = crate::loose_ends::collect();
if !mcp_items.is_empty() { if !mcp_items.is_empty() {
use std::fmt::Write as _; use std::fmt::Write as _;
let n = mcp_items.len(); let n = mcp_items.len();

View file

@ -100,7 +100,11 @@ fn render_recv_messages(
} }
let mut out = if messages.len() == 1 { let mut out = if messages.len() == 1 {
let m = &messages[0]; let m = &messages[0];
let banner = if m.redelivered { REDELIVERY_HINT } else { "" }; let banner = if m.redelivered {
hive_sh4re::REDELIVERY_HINT
} else {
""
};
format!("{banner}{}from: {}\n\n{}", msg_id_tag(m.id), m.from, m.body) format!("{banner}{}from: {}\n\n{}", msg_id_tag(m.id), m.from, m.body)
} else { } else {
let n = messages.len(); let n = messages.len();
@ -109,7 +113,11 @@ fn render_recv_messages(
if i > 0 { if i > 0 {
out.push_str("\n---\n\n"); out.push_str("\n---\n\n");
} }
let banner = if m.redelivered { REDELIVERY_HINT } else { "" }; let banner = if m.redelivered {
hive_sh4re::REDELIVERY_HINT
} else {
""
};
let _ = write!( let _ = write!(
out, out,
"{banner}{}from: {}\n\n{}", "{banner}{}from: {}\n\n{}",
@ -120,7 +128,7 @@ fn render_recv_messages(
} }
out out
}; };
out.push_str(&crate::serve_common::pending_hint(remaining)); out.push_str(&hive_sh4re::pending_hint(remaining));
out out
} }
@ -136,13 +144,6 @@ fn msg_id_tag(id: i64) -> String {
} }
} }
/// 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
/// string surfaces in the wake prompt (see the bin loops) and the
/// in-turn `recv` tool result so claude sees the warning either way.
pub const REDELIVERY_HINT: &str = "[redelivered after harness restart — may already be handled]\n";
/// Appended to the `recv` empty result when the agent parked on a /// Appended to the `recv` empty result when the agent parked on a
/// long-poll (`wait_seconds > 0`) that timed out with nothing new. /// long-poll (`wait_seconds > 0`) that timed out with nothing new.
/// Nudges the model to spend the idle time on other useful work /// Nudges the model to spend the idle time on other useful work

View file

@ -0,0 +1,19 @@
//! Per-agent path resolution for this MCP server. Self-contained (mirrors
//! the sibling helper daemons' own `paths.rs`) so the bin does not link the
//! harness lib just to resolve the agent's state dir. Only the one helper
//! the MCP server needs lives here.
use std::path::PathBuf;
/// Durable state directory for the current agent. Reads `HYPERHIVE_STATE_DIR`
/// first (always set by the meta flake to `/agents/{label}/state`); falls back
/// to the same pattern derived from `HIVE_LABEL` for dev/test environments
/// where the env var may not be set.
#[must_use]
pub fn state_dir() -> PathBuf {
if let Some(p) = std::env::var_os("HYPERHIVE_STATE_DIR") {
return PathBuf::from(p);
}
let label = std::env::var("HIVE_LABEL").unwrap_or_default();
PathBuf::from(format!("/agents/{label}/state"))
}

View file

@ -0,0 +1,58 @@
//! Per-agent `send` allow-list enforcement. Driven by the operator config at
//! `/etc/hyperhive/send-allow.json` (written by the NixOS harness module); the
//! MCP server calls [`check_send_allowed`] before forwarding a `send` tool call
//! so a blocked recipient surfaces as a claude-readable tool result rather than
//! a silent drop.
/// Where the NixOS module writes the per-agent send allow-list (see
/// `nix/templates/harness/`). Empty list = unrestricted (the
/// default). Non-empty list constrains `mcp__hyperhive__send`'s `to`
/// field; the manager is always implicitly permitted regardless of
/// the list contents.
const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json";
/// Enforce the per-agent send allow-list. Returns `Ok` when the
/// recipient is permitted (no list configured, `<parent>` sentinel
/// always allowed, or `to` is in the list); returns `Err(refusal)`
/// with a claude-readable string when blocked — the harness surfaces
/// the refusal as the tool result so claude knows the message didn't
/// land and can react (e.g. route via `<parent>` instead).
pub fn check_send_allowed(to: &str) -> Result<(), String> {
if to == hive_sh4re::PARENT_RECIPIENT {
// Always allow `<parent>` — the allow-list constrains peer
// chatter, not the structural reporting line; the operator
// can rewire who the parent IS via `set_parent` without
// having to remember to update the per-agent allow-list.
// The broker resolves the sentinel to the real parent label
// on the host side per topology.json (falls back to `operator`
// for root agents).
return Ok(());
}
let Ok(raw) = std::fs::read_to_string(SEND_ALLOW_PATH) else {
return Ok(()); // file missing → no policy configured → unrestricted
};
let allow: Vec<String> = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
path = SEND_ALLOW_PATH,
error = ?e,
"send allow-list parse failed; falling back to unrestricted",
);
return Ok(());
}
};
if allow.is_empty() {
return Ok(()); // empty list = unrestricted (back-compat)
}
if allow.iter().any(|n| n == to) {
return Ok(());
}
Err(format!(
"send refused: recipient '{to}' not in hyperhive.allowedRecipients \
(configured in agent.nix). Allowed: {allow:?}. Your structural \
parent is always reachable route through `send(to: \"{}\", …)` \
if you need to reach someone outside the allow-list.",
hive_sh4re::PARENT_RECIPIENT
))
}

View file

@ -18,6 +18,34 @@ pub mod wire_time;
/// constant instead of a scattered magic value. /// constant instead of a scattered magic value.
pub const RECV_BATCH_MAX: u32 = 5; pub const RECV_BATCH_MAX: u32 = 5;
/// Banner prepended to a wake prompt / `recv` result when the message was
/// redelivered after a harness restart (the turn that first drove it never
/// acked). Shared between the harness serve loop (wake prompt) and the MCP
/// server (`recv` tool result) so both surfaces phrase it identically.
pub const REDELIVERY_HINT: &str = "[redelivered after harness restart — may already be handled]\n";
/// Shared "(N more message(s) pending …)" advisory appended after both the
/// wake prompt body and the `recv` tool result whenever the inbox still has
/// queued messages once the current message/batch is popped. Returns an empty
/// string when `remaining == 0`. The leading `\n\n` separates it from the
/// preceding body/message block, and the suggested `max` is clamped to the
/// server-side recv cap so the hint never asks for more than one round-trip
/// can deliver. One builder so the wake prompt (harness serve loop) and the
/// in-turn recv result (MCP server) stay identical.
#[must_use]
pub fn pending_hint(remaining: u64) -> String {
if remaining == 0 {
return String::new();
}
let batch = remaining.min(u64::from(RECV_BATCH_MAX));
format!(
"\n\n({remaining} more message(s) pending in your inbox — call `mcp__hyperhive__recv` \
with `max: {batch}` to drain the next batch before acting. If the \
backlog is stale/already handled, `ack_until(up_to: <highest [msg #N] seen>)` \
clears everything up to that id in one call instead.)"
)
}
/// One row in the approval queue. `commit_ref` is overloaded per /// One row in the approval queue. `commit_ref` is overloaded per
/// `kind` — see `docs/approvals.md::Approval kinds (wire shapes)` /// `kind` — see `docs/approvals.md::Approval kinds (wire shapes)`
/// for the encoding table and lifecycle. /// for the encoding table and lifecycle.