From c4deca99db5eb7b7866508980dcd4a24bff1a37e Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 22 Jul 2026 21:46:16 +0200 Subject: [PATCH] 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. --- docs/tools/bash.md | 8 +++-- hive-agent-mcp/src/mcp/args.rs | 3 +- hive-agent-mcp/src/mcp/mod.rs | 28 ++++++++++++++++-- hive-agent-mcp/src/mcp/render.rs | 51 ++++++++++++++++++++++++-------- 4 files changed, 70 insertions(+), 20 deletions(-) diff --git a/docs/tools/bash.md b/docs/tools/bash.md index d0a4b500..1c05740f 100644 --- a/docs/tools/bash.md +++ b/docs/tools/bash.md @@ -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 diff --git a/hive-agent-mcp/src/mcp/args.rs b/hive-agent-mcp/src/mcp/args.rs index 24ed05c3..bb2ddb5c 100644 --- a/hive-agent-mcp/src/mcp/args.rs +++ b/hive-agent-mcp/src/mcp/args.rs @@ -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 diff --git a/hive-agent-mcp/src/mcp/mod.rs b/hive-agent-mcp/src/mcp/mod.rs index e9ec17cb..0ca18172 100644 --- a/hive-agent-mcp/src/mcp/mod.rs +++ b/hive-agent-mcp/src/mcp/mod.rs @@ -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) -> 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, diff --git a/hive-agent-mcp/src/mcp/render.rs b/hive-agent-mcp/src/mcp/render.rs index 858e75ba..8402c995 100644 --- a/hive-agent-mcp/src/mcp/render.rs +++ b/hive-agent-mcp/src/mcp/render.rs @@ -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> { 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> { +/// 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 { 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> { 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::(&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> { + 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 { + dial_agent_socket(&hive_agent_sock::Request::MarkTodoDone { id }).await +} + /// Format a `Vec` 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 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\")" )), } }