fix(#2639): cancel_loose_end kind="todo" clears without a bash round-trip

Adds a real MCP-side path for the workaround #2639 documented: dial the
in-container HIVE_AGENT_SOCKET directly from cancel_loose_end (kind:"todo")
instead of shelling out via a tracked bash task (nc -U ...), which is what
was spawning a fresh completion todo on every clear and cascading forever.

hive-agent-mcp/src/mcp/render.rs: renamed local_todos's socket-dial guts
into a shared dial_agent_socket(req) helper, added mark_local_todo_done(id)
on top of it (MarkTodoDone request already existed server-side, unused
until now). mod.rs wires kind:"todo" into cancel_loose_end ahead of the
question/reminder/approval parse. args.rs + render.rs + docs/tools/bash.md
text updated to point at the new path instead of the old raw nc invocation.
This commit is contained in:
damocles 2026-07-22 21:46:16 +02:00
commit c4deca99db
4 changed files with 70 additions and 20 deletions

View file

@ -114,9 +114,11 @@ todo (`key = task id`) on the harness's in-agent socket (`HIVE_AGENT_SOCKET`)
— "running" at start, then the completion summary when it finishes. The
summary change signals the harness turn loop directly (in-process, no broker
round-trip), so the agent is driven a turn to handle it via `get_loose_ends`,
then clears the todo with `mark_todo_done`. Same mechanism the matrix daemon
uses for unread rooms. An inline `wait_seconds` / `status` observation that
already delivered the result instead clears the keyed todo, so no redundant
then clears the todo with `cancel_loose_end(kind: "todo", id: N)` (dials the
in-container socket directly — no bash task involved, so clearing doesn't
spawn another todo; see #2639). Same mechanism the matrix daemon uses for
unread rooms. An inline `wait_seconds` / `status` observation that already
delivered the result instead clears the keyed todo, so no redundant
loose-end follows.
## Relationship to the `execution` tool group

View file

@ -188,7 +188,8 @@ pub struct AnswerArgs {
pub struct CancelLooseEndArgs {
/// Which kind of thread to cancel — `"question"` for an open
/// `ask` that's still waiting on an answer, `"reminder"` for a
/// scheduled `remind` that hasn't fired yet. Use the `kind`
/// scheduled `remind` that hasn't fired yet, or `"todo"` for a
/// loose-ends-v2 todo (bash/matrix/forge). Use the `kind`
/// field straight off the `get_loose_ends` row.
pub kind: String,
/// Row id from the matching `get_loose_ends` entry (or the

View file

@ -35,8 +35,8 @@ pub use args::{
pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv};
use render::{
format_matrix_summary, local_todos, loose_end_kind_label, matrix_unread_summary,
parse_loose_end_kind, render_loose_ends, reply_err,
format_matrix_summary, local_todos, loose_end_kind_label, mark_local_todo_done,
matrix_unread_summary, parse_loose_end_kind, render_loose_ends, reply_err,
};
/// Write (or remove) the status file in the agent's own `state/` directory.
@ -413,12 +413,34 @@ impl AgentServer {
cancel rows where you're the asker / owner. Returns `ok` or an error string.\n\
`kind` may also be `\"approval\"` to withdraw a pending approval you submitted \
(before the operator acts on it) root agent (`ruth`) only; the server rejects \
`approval` kind for all other callers."
`approval` kind for all other callers.\n\
`kind` may also be `\"todo\"` to clear one of your own loose-ends-v2 todos \
(bash-task completions, matrix unread, forge activity the id in the \
`get_loose_ends` `todo #N` line) this dials the in-container socket directly, \
no bash task involved, so it's safe to call repeatedly without spawning more \
todos."
)]
async fn cancel_loose_end(&self, Parameters(args): Parameters<CancelLooseEndArgs>) -> String {
let log = format!("{args:?}");
let id = args.id;
run_tool_envelope("cancel_loose_end", log, async move {
if args.kind.trim().eq_ignore_ascii_case("todo") {
return match mark_local_todo_done(id).await {
Some(hive_agent_sock::Response::Acked { count }) if count > 0 => {
format!("cleared todo {id}")
}
Some(hive_agent_sock::Response::Acked { .. }) => {
format!("no such todo {id} (already cleared, or never existed)")
}
Some(hive_agent_sock::Response::Err { message }) => {
format!("cancel_loose_end failed: {message}")
}
Some(other) => format!("cancel_loose_end unexpected response: {other:?}"),
None => "cancel_loose_end: local todo socket unavailable \
(HIVE_AGENT_SOCKET unset or harness unreachable)"
.to_owned(),
};
}
let kind = match parse_loose_end_kind(&args.kind) {
Ok(k) => k,
Err(e) => return e,

View file

@ -252,7 +252,8 @@ pub(super) fn render_loose_ends(loose_ends: &[hive_sh4re::LooseEnd]) -> String {
.unwrap_or_default();
let _ = writeln!(
out,
"- todo #{id} [{subsystem}{key}, {age_seconds}s old]: {summary}{src} (mark_todo_done to clear)"
"- todo #{id} [{subsystem}{key}, {age_seconds}s old]: {summary}{src} \
(cancel_loose_end kind:\"todo\" id:{id} to clear)"
);
}
}
@ -297,11 +298,17 @@ pub(super) async fn matrix_unread_summary() -> Option<Vec<MatrixRoomUnread>> {
serde_json::from_value(serde_json::Value::Array(arr.clone())).ok()
}
/// Query the harness's in-agent socket for this agent's local todos
/// (loose-ends v2). Returns `None` when `HIVE_AGENT_SOCKET` is unset /
/// absent or the query fails — best-effort, like [`matrix_unread_summary`],
/// so an agent without the socket is not penalised.
pub(super) async fn local_todos() -> Option<Vec<hive_sh4re::LooseEnd>> {
/// Dial the harness's in-agent socket (`HIVE_AGENT_SOCKET`) with a single
/// one-shot JSON-line request and decode the response. Returns `None` when
/// the socket is unset/absent or on any transport/decode failure —
/// best-effort, single-shot (no retry, unlike the broker's
/// `client::request_retried`): this socket lives in the SAME container, so
/// a connect failure means the harness itself isn't up, which a retry
/// won't fix within a tool call's budget. Shared by [`local_todos`] and
/// [`mark_local_todo_done`].
pub(super) async fn dial_agent_socket(
req: &hive_agent_sock::Request,
) -> Option<hive_agent_sock::Response> {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
let socket = std::env::var_os("HIVE_AGENT_SOCKET").map(std::path::PathBuf::from)?;
@ -309,18 +316,36 @@ pub(super) async fn local_todos() -> Option<Vec<hive_sh4re::LooseEnd>> {
return None;
}
let mut stream = UnixStream::connect(&socket).await.ok()?;
let mut req =
serde_json::to_string(&hive_agent_sock::Request::ListTodos { subsystem: None }).ok()?;
req.push('\n');
stream.write_all(req.as_bytes()).await.ok()?;
let mut line = serde_json::to_string(req).ok()?;
line.push('\n');
stream.write_all(line.as_bytes()).await.ok()?;
let mut lines = BufReader::new(stream).lines();
let line = lines.next_line().await.ok()??;
match serde_json::from_str::<hive_agent_sock::Response>(&line).ok()? {
let resp_line = lines.next_line().await.ok()??;
serde_json::from_str(&resp_line).ok()
}
/// Query the harness's in-agent socket for this agent's local todos
/// (loose-ends v2). Returns `None` when `HIVE_AGENT_SOCKET` is unset /
/// absent or the query fails — best-effort, like [`matrix_unread_summary`],
/// so an agent without the socket is not penalised.
pub(super) async fn local_todos() -> Option<Vec<hive_sh4re::LooseEnd>> {
match dial_agent_socket(&hive_agent_sock::Request::ListTodos { subsystem: None }).await? {
hive_agent_sock::Response::LooseEnds { loose_ends } => Some(loose_ends),
_ => None,
}
}
/// Mark one of this agent's local todos (loose-ends v2) done by id, via
/// the harness's in-agent socket — reachable through `cancel_loose_end`
/// kind `"todo"` so clearing a todo never has to shell out through a
/// tracked bash task (which would just spawn a fresh completion todo of
/// its own). `None` means the socket is unset/absent or unreachable,
/// distinct from a decoded `Err` response (unknown id) so the caller can
/// tell the two apart.
pub(super) async fn mark_local_todo_done(id: i64) -> Option<hive_agent_sock::Response> {
dial_agent_socket(&hive_agent_sock::Request::MarkTodoDone { id }).await
}
/// Format a `Vec<MatrixRoomUnread>` into a per-room summary string.
/// Single room / single message collapses to one line; multi-room
/// expands to a bulleted list. Returns an empty string for empty input.
@ -357,7 +382,7 @@ pub(super) fn parse_loose_end_kind(raw: &str) -> Result<hive_sh4re::CancelLooseE
"approval" | "a" => Ok(hive_sh4re::CancelLooseEndKind::Approval),
other => Err(format!(
"cancel_loose_end: unknown kind '{other}' \
(expected \"question\", \"reminder\", or \"approval\")"
(expected \"question\", \"reminder\", \"approval\", or \"todo\")"
)),
}
}