Six places in the tree hand-rolled the same connect / write one JSON line / read one JSON line back. Two of them — the harness serve loop's client and the MCP server's — were byte-identical apart from a six-line wrapper, ~145 lines of literal copy-paste. The other four each reimplemented a subset, and the subsets had drifted: some named the socket path in their errors and some did not, one classified transient against fatal failures and the rest retried nothing at all, two drained the response and two decoded it. That duplication was defended when the daemons were split out, on the grounds that a daemon's socket etiquette should stay visible in the crate that depends on it. The etiquette genuinely does differ. The code does not, and five copies is where "each daemon documents its own etiquette" stops paying for itself. `hive-sock-client` now owns the transport once, generic over the request and response types so it is protocol-agnostic: the host-served control socket and the harness's in-agent socket both use it with their own wire-type crates. The two real differences become values instead of forks. Retry is `Retry::RideOutRestart` (2/4/8/16/30s, sized to ride out a service restart) for callers with no natural retry of their own, or `Retry::None` for callers already inside a poll loop where the poll interval is the retry — and the reason each caller picked one is a comment at the call site rather than a reimplementation. The response is either decoded (`request`) or half-closed and drained (`notify`, where the drain exists so the server's write-back doesn't land on a closed socket). Whether a failure propagates or is logged and swallowed stays at the call site, because that is the caller's choice and not a property of the transport. Errors always name the socket path now, everywhere. That detail is load-bearing: a permission problem on a socket that reads as "is the daemon running?" sends the operator to fix the wrong thing. The transient-against-fatal enum is gone rather than moved. Serialising happens before the retry loop and deserialising after it, so only connect, I/O and short-read failures can reach the loop at all — a deterministic failure is now unretryable by construction instead of by classification. It is deliberately a new crate and not part of `hive-agent-sock`. The `*-sock` crates are pure wire types by convention — `hive-agent-sock` depends on serde and nothing else — and the two largest copies talk to the host socket, whose types live in a different crate entirely. A transport in either wire-type crate would drag tokio into it and point the wrong way besides. No wire-format change: same JSON line in, same line out.
182 lines
6.4 KiB
Rust
182 lines
6.4 KiB
Rust
//! Todo writer: pushes matrix *todos* (loose-ends v2) to the harness's
|
|
//! in-agent socket (`HIVE_AGENT_SOCKET`) when rooms have unread messages
|
|
//! or pending invites, so claude drives a turn to handle them. One JSON
|
|
//! line per op (`upsert_todo` / `clear_todo`), keyed by room id so
|
|
//! re-pushing an unchanged item is an idempotent no-op and resolving one
|
|
//! clears it. The harness owns the todo store locally and signals its own
|
|
//! turn loop — no hive-c0re round-trip.
|
|
//!
|
|
//! Todo summaries stay short: a SHORT TEASER, not the full message — the
|
|
//! agent then reads the unmarked event via the `read_room` MCP tool.
|
|
//! Truncation to ~100 chars keeps the summary focused.
|
|
|
|
use anyhow::Result;
|
|
use hive_sock_client::{Retry, notify};
|
|
|
|
/// Retry policy for the in-agent socket. Fail-fast: every caller here is
|
|
/// inside the sync loop, which re-derives the whole todo set on its next
|
|
/// pass — that pass *is* the retry, and it carries fresher state than a
|
|
/// backoff replaying a stale summary would.
|
|
const TODO_SOCKET_RETRY: Retry = Retry::None;
|
|
|
|
/// The harness-served in-agent socket (`HIVE_AGENT_SOCKET`) where todo ops
|
|
/// go — distinct from the host-served control socket used by [`send_wake`].
|
|
/// `None` when unset/empty, in which case todo sends are a best-effort
|
|
/// no-op (a standalone daemon without the harness socket).
|
|
fn agent_socket() -> Option<std::path::PathBuf> {
|
|
std::env::var_os("HIVE_AGENT_SOCKET")
|
|
.filter(|v| !v.is_empty())
|
|
.map(std::path::PathBuf::from)
|
|
}
|
|
|
|
/// Upsert a matrix-subsystem *todo* (loose-ends v2) on the harness's
|
|
/// in-agent socket — the replacement for a direct wake. `key` is the room
|
|
/// id (the dedup key); the harness signals a turn iff the todo is new or
|
|
/// its `summary` changed. Best-effort: a no-op when `HIVE_AGENT_SOCKET`
|
|
/// isn't configured.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error on socket connect failure, serialisation failure,
|
|
/// or I/O error writing to or reading from the socket.
|
|
pub async fn send_todo_upsert(key: &str, summary: impl AsRef<str>) -> Result<()> {
|
|
let Some(socket) = agent_socket() else {
|
|
return Ok(());
|
|
};
|
|
let payload = serde_json::json!({
|
|
"cmd": "upsert_todo",
|
|
"subsystem": "matrix",
|
|
"key": key,
|
|
"summary": summary.as_ref(),
|
|
});
|
|
notify(&socket, &payload, TODO_SOCKET_RETRY).await
|
|
}
|
|
|
|
/// Clear matrix-subsystem todos on the harness's in-agent socket. `key =
|
|
/// Some(room)` clears one room's todo (it was read); `all = true` wipes the
|
|
/// whole matrix set (cancel-and-recreate on daemon restart). Best-effort:
|
|
/// a no-op when `HIVE_AGENT_SOCKET` isn't configured.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error on socket connect failure, serialisation failure,
|
|
/// or I/O error writing to or reading from the socket.
|
|
pub async fn send_todo_clear(key: Option<&str>, all: bool) -> Result<()> {
|
|
let Some(socket) = agent_socket() else {
|
|
return Ok(());
|
|
};
|
|
let payload = serde_json::json!({
|
|
"cmd": "clear_todo",
|
|
"subsystem": "matrix",
|
|
"key": key,
|
|
"all": all,
|
|
});
|
|
notify(&socket, &payload, TODO_SOCKET_RETRY).await
|
|
}
|
|
|
|
/// Format a wake-message body from a list of per-room unread summaries.
|
|
/// Single-room / single-message case collapses to the terse one-liner
|
|
/// format; multiple rooms expand to a bulleted list. Always appends a
|
|
/// read-hint line so the agent knows which tools to reach for.
|
|
#[must_use]
|
|
pub fn format_unread_summary(rooms: &[crate::protocol::RoomUnread]) -> String {
|
|
use std::fmt::Write as _;
|
|
if rooms.is_empty() {
|
|
return String::new();
|
|
}
|
|
// Terse path: exactly one room, exactly one unread with body.
|
|
if rooms.len() == 1 {
|
|
let r = &rooms[0];
|
|
if r.count == 1
|
|
&& let (Some(body), Some(sender)) = (&r.last_body, &r.last_sender)
|
|
{
|
|
return format!(
|
|
"[matrix] {sender} in {label}: {body} — use read_room to view, mark_read to clear",
|
|
label = r.label
|
|
);
|
|
}
|
|
return format!(
|
|
"[matrix] {} unread in {} — use read_room to view, mark_read to clear",
|
|
r.count, r.label
|
|
);
|
|
}
|
|
// Multi-room path.
|
|
let mut out = String::from("[matrix] unread messages:");
|
|
for r in rooms {
|
|
if r.count == 1
|
|
&& let (Some(body), Some(sender)) = (&r.last_body, &r.last_sender)
|
|
{
|
|
let _ = write!(out, "\n- {}: {sender}: {body}", r.label);
|
|
continue;
|
|
}
|
|
let _ = write!(out, "\n- {}: {} unread", r.label, r.count);
|
|
}
|
|
out.push_str("\nUse list_rooms + read_room to view, mark_read to clear.");
|
|
out
|
|
}
|
|
|
|
/// Prepend an account marker to a wake `body` when the daemon serves
|
|
/// more than one matrix account. `tag` is `Some(name)` only in
|
|
/// multi-account mode; `None` returns `body` unchanged so single-account
|
|
/// wakes keep their exact format. Shape: `[acct:<name>] <body>`.
|
|
#[must_use]
|
|
pub fn tag_account(tag: Option<&str>, body: String) -> String {
|
|
match tag {
|
|
Some(name) => format!("[acct:{name}] {body}"),
|
|
None => body,
|
|
}
|
|
}
|
|
|
|
/// Truncate `s` to `max` Unicode chars, appending `…` when cut.
|
|
/// Char-based not byte-based so multi-byte content (most chat) doesn't
|
|
/// get cut mid-codepoint.
|
|
#[must_use]
|
|
pub fn truncate_chars(s: &str, max: usize) -> String {
|
|
let mut end = s.len();
|
|
for (count, (i, _)) in s.char_indices().enumerate() {
|
|
if count == max {
|
|
end = i;
|
|
break;
|
|
}
|
|
}
|
|
if end == s.len() {
|
|
s.to_owned()
|
|
} else {
|
|
format!("{}…", &s[..end])
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn truncate_chars_handles_multibyte() {
|
|
// `ü` is 2 bytes / 1 char. truncating to 3 chars on "üüüüüü"
|
|
// should yield "üüü…" not "üü\xc3…" (mid-codepoint).
|
|
let s = "üüüüüü";
|
|
let t = truncate_chars(s, 3);
|
|
assert_eq!(t, "üüü…");
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_chars_no_op_below_limit() {
|
|
let s = "hi";
|
|
assert_eq!(truncate_chars(s, 100), "hi");
|
|
}
|
|
|
|
#[test]
|
|
fn tag_account_none_is_passthrough() {
|
|
let body = "[matrix] @a:s in #x: hi".to_owned();
|
|
assert_eq!(tag_account(None, body.clone()), body);
|
|
}
|
|
|
|
#[test]
|
|
fn tag_account_some_prepends_marker() {
|
|
let body = "[matrix] @a:s in #x: hi".to_owned();
|
|
assert_eq!(
|
|
tag_account(Some("ccc"), body),
|
|
"[acct:ccc] [matrix] @a:s in #x: hi"
|
|
);
|
|
}
|
|
}
|