hyperhive/hive-agent/src/todo_server.rs
atlas 7a826f9ee2 refactor(sock): one socket client, retry as a policy value
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.
2026-07-26 22:44:48 +02:00

534 lines
19 KiB
Rust

//! In-agent socket server (loose-ends v2 + harness-local reminders +
//! questions mirror + self-service compact). Binds the harness-owned
//! `HIVE_AGENT_SOCKET` and serves the `hive-agent-sock` protocol to the
//! in-container producers (matrix / bash daemons, forge-notify) and to
//! `hive-agent-mcp`'s `ask`/`answer`/`remind`/`get_loose_ends`/
//! `cancel_loose_end`/`compact` tool impls. Todo ops hit the harness-local
//! [`Todos`] store; a new-or-changed upsert fires an in-process [`Notify`]
//! so the serve loop drives a turn. Reminder ops hit the harness-local
//! [`Reminders`] store (`None` when the store failed to open — every
//! reminder op then returns `Response::Err`); a reminder *firing* is a
//! separate path (`reminder_timer`), not driven through this socket.
//! Question ops hit the harness-local [`Questions`] mirror the same way
//! (`None` when it failed to open) — c0re stays the actual `Ask`/`Answer`
//! routing + delivery rendezvous, this store only mirrors the durable
//! "still owed a reply" view for `get_loose_ends`. `Request::Compact` is
//! the odd one out — it doesn't touch any store, just the harness's
//! [`Bus`] (gate-checked context usage, then the same deferred
//! `compact_pending` flag the operator dashboard's `/compact` button
//! sets). No hive-c0re round-trip, no broker long-poll, no marker files.
//!
//! One request/response line per connection, matching the producers'
//! existing best-effort JSON-line clients (they just change which socket
//! they dial, not the payload).
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use hive_agent_sock::{Request, Response};
use hive_sh4re::LooseEnd;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::Notify;
use crate::events::Bus;
use crate::questions::{QuestionMirror, Questions, Role};
use crate::reminders::{Reminder, Reminders};
use crate::todos::{Todo, Todos};
/// Context-usage floor for `Request::Compact`: below this fraction of the
/// effective context window, an agent-initiated compact is refused (nothing
/// meaningful to reclaim yet, and it'd just burn a compaction pass on a
/// near-empty session). Same number a human operator would eyeball off the
/// dashboard's `ctx·Nk` badge before hitting the `/compact` button.
const COMPACT_MIN_USAGE_FRACTION: f64 = 0.66;
/// Resolve the in-agent socket path from `HIVE_AGENT_SOCKET`. `None` when
/// unset/empty (e.g. a standalone dev run) — the server then stays off.
fn socket_path() -> Option<PathBuf> {
std::env::var_os("HIVE_AGENT_SOCKET")
.filter(|v| !v.is_empty())
.map(PathBuf::from)
}
/// Dial this same in-agent socket from elsewhere IN THIS PROCESS — the
/// harness's own `web_ui` endpoints (`api_todos`, `/api/stats` reminder
/// rollup) and `Surface::post_turn_counts` all need read access to the
/// `Todos`/`Reminders` stores this module owns behind an `Arc` on a
/// different spawned task, and a loopback dial is simpler than threading
/// those `Arc`s through every caller. One-shot, best-effort (no retry,
/// unlike the broker client): a connect failure means the socket server
/// task itself isn't up, which a retry within one call wouldn't fix.
pub(crate) async fn dial(req: &Request) -> Option<Response> {
let path = socket_path()?;
if !path.exists() {
return None;
}
tokio::time::timeout(
std::time::Duration::from_secs(3),
hive_sock_client::request::<_, Response>(&path, req, hive_sock_client::Retry::None),
)
.await
.ok()?
.ok()
}
/// Run the in-agent socket server: bind + accept loop, one request/response
/// line per connection. A no-op (returns `Ok`) when `HIVE_AGENT_SOCKET` is
/// unset, so a standalone harness without producers just skips it.
///
/// # Errors
///
/// Returns an error if the socket path is set but can't be bound.
pub async fn run(
store: Arc<Todos>,
wake: Arc<Notify>,
reminders: Option<Arc<Reminders>>,
questions: Option<Arc<Questions>>,
bus: Bus,
) -> Result<()> {
let Some(path) = socket_path() else {
tracing::info!("HIVE_AGENT_SOCKET unset — in-agent todo socket disabled");
return Ok(());
};
let listener = bind(&path)?;
tracing::info!(socket = %path.display(), "in-agent todo socket listening");
loop {
match listener.accept().await {
Ok((stream, _)) => {
let store = store.clone();
let wake = wake.clone();
let reminders = reminders.clone();
let questions = questions.clone();
let bus = bus.clone();
tokio::spawn(async move {
if let Err(e) = handle_conn(
stream,
&store,
&wake,
reminders.as_deref(),
questions.as_deref(),
&bus,
)
.await
{
tracing::warn!(error = ?e, "in-agent todo connection failed");
}
});
}
Err(e) => tracing::warn!(error = ?e, "in-agent todo accept failed"),
}
}
}
/// Bind a `UnixListener` at `path`, creating the parent dir and unlinking a
/// stale socket left by a prior boot (which would otherwise block `bind`
/// with `EADDRINUSE`).
fn bind(path: &Path) -> Result<UnixListener> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create in-agent socket dir {}", parent.display()))?;
}
if path.exists() {
let _ = std::fs::remove_file(path);
}
UnixListener::bind(path).with_context(|| format!("bind in-agent socket {}", path.display()))
}
/// Handle one connection: read a single JSON request line, apply it to the
/// store, write the JSON response line back.
async fn handle_conn(
stream: UnixStream,
store: &Todos,
wake: &Notify,
reminders: Option<&Reminders>,
questions: Option<&Questions>,
bus: &Bus,
) -> Result<()> {
let (read, mut write) = stream.into_split();
let mut reader = BufReader::new(read);
let mut line = String::new();
if reader.read_line(&mut line).await? == 0 {
return Ok(());
}
let resp = match serde_json::from_str::<Request>(line.trim()) {
Ok(req) => dispatch(req, store, wake, reminders, questions, bus),
Err(e) => Response::Err {
message: format!("bad request: {e}"),
},
};
let mut out = serde_json::to_string(&resp)?;
out.push('\n');
write.write_all(out.as_bytes()).await?;
write.flush().await?;
Ok(())
}
/// Apply one request to the store, firing `wake` on a new/changed upsert so
/// the serve loop runs a turn. Each arm calls a small named handler function
/// directly — no sub-match/`unreachable!()` indirection per family (that
/// pattern got reviewed out of the todo family in the diagnostic-logging
/// follow-up PR; kept the reminder and question families consistent with it
/// here rather than reintroducing it). `reminders`/`questions` are `None` when that store failed to open at
/// boot, in which case every op in that family returns an `Err` — each
/// handler checks for its own `None` case.
fn dispatch(
req: Request,
store: &Todos,
wake: &Notify,
reminders: Option<&Reminders>,
questions: Option<&Questions>,
bus: &Bus,
) -> Response {
match req {
Request::UpsertTodo {
subsystem,
key,
summary,
source,
} => upsert_todo(
store,
wake,
&subsystem,
key.as_deref(),
&summary,
source.as_deref(),
),
Request::ClearTodo {
subsystem,
key,
all,
} => clear_todo(store, &subsystem, key.as_deref(), all),
Request::ListTodos { subsystem } => list_todos(store, subsystem.as_deref()),
Request::MarkTodoDone { id } => mark_todo_done(store, id),
Request::StoreReminder {
message,
timing,
file_path,
} => store_reminder(reminders, &message, &timing, file_path.as_deref()),
Request::ListReminders => list_reminders(reminders),
Request::CancelReminder { id } => cancel_reminder(reminders, id),
Request::CountPendingReminders => count_pending_reminders(reminders),
Request::ReminderRollup { since_secs } => reminder_rollup(reminders, since_secs),
Request::RecordAskedQuestion {
id,
target,
question,
} => record_asked_question(questions, id, &target, &question),
Request::RecordAnsweringQuestion {
id,
asker,
question,
} => record_answering_question(questions, id, &asker, &question),
Request::ClearQuestion { id } => clear_question(questions, id),
Request::ListQuestions => list_questions(questions),
Request::Compact => compact(bus),
}
}
/// `StoreReminder` handler: persists a reminder due at `timing`.
fn store_reminder(
reminders: Option<&Reminders>,
message: &str,
timing: &hive_sh4re::ReminderTiming,
file_path: Option<&str>,
) -> Response {
let Some(r) = reminders else {
return no_reminders_store();
};
match crate::reminder_timer::store(r, message, timing, file_path) {
Ok(_id) => Response::Ok,
Err(message) => Response::Err { message },
}
}
/// `ListReminders` handler: this agent's own pending reminders.
fn list_reminders(reminders: Option<&Reminders>) -> Response {
let Some(r) = reminders else {
return no_reminders_store();
};
match r.list_pending() {
Ok(rows) => Response::LooseEnds {
loose_ends: rows.into_iter().map(reminder_to_loose_end).collect(),
},
Err(e) => err(&e),
}
}
/// `CancelReminder` handler: drops one pending reminder by id.
fn cancel_reminder(reminders: Option<&Reminders>, id: i64) -> Response {
let Some(r) = reminders else {
return no_reminders_store();
};
match r.cancel(id) {
Ok(count) => Response::Acked {
count: u64::try_from(count).unwrap_or(0),
},
Err(e) => err(&e),
}
}
/// `CountPendingReminders` handler.
fn count_pending_reminders(reminders: Option<&Reminders>) -> Response {
let Some(r) = reminders else {
return no_reminders_store();
};
match r.count_pending() {
Ok(count) => Response::PendingRemindersCount { count },
Err(e) => err(&e),
}
}
/// `ReminderRollup` handler: scheduled/delivered/pending counts over a
/// trailing `since_secs` window (`0` = all time).
fn reminder_rollup(reminders: Option<&Reminders>, since_secs: u64) -> Response {
let Some(r) = reminders else {
return no_reminders_store();
};
let since_secs = i64::try_from(since_secs).unwrap_or(i64::MAX);
match r.rollup(since_secs) {
Ok(stats) => Response::ReminderRollup { stats },
Err(e) => err(&e),
}
}
/// `RecordAskedQuestion` handler: mirror a question this agent asked.
fn record_asked_question(
questions: Option<&Questions>,
id: i64,
target: &str,
question: &str,
) -> Response {
let Some(q) = questions else {
return no_questions_store();
};
match q.record(id, Role::Asked, target, question) {
Ok(()) => Response::Ok,
Err(e) => err(&e),
}
}
/// `RecordAnsweringQuestion` handler: mirror a question this agent owes a
/// reply to.
fn record_answering_question(
questions: Option<&Questions>,
id: i64,
asker: &str,
question: &str,
) -> Response {
let Some(q) = questions else {
return no_questions_store();
};
match q.record(id, Role::Answering, asker, question) {
Ok(()) => Response::Ok,
Err(e) => err(&e),
}
}
/// `ClearQuestion` handler: drop the mirror row for `id` (either role).
fn clear_question(questions: Option<&Questions>, id: i64) -> Response {
let Some(q) = questions else {
return no_questions_store();
};
match q.clear(id) {
Ok(count) => Response::Acked {
count: u64::try_from(count).unwrap_or(0),
},
Err(e) => err(&e),
}
}
/// `ListQuestions` handler: this agent's mirrored questions (both roles).
fn list_questions(questions: Option<&Questions>) -> Response {
let Some(q) = questions else {
return no_questions_store();
};
match q.list() {
Ok(rows) => Response::LooseEnds {
loose_ends: rows.into_iter().map(question_to_loose_end).collect(),
},
Err(e) => err(&e),
}
}
/// `UpsertTodo` handler: writes/refreshes a todo row, logs the outcome, and
/// fires `wake` on a new-or-changed upsert so the serve loop runs a turn.
fn upsert_todo(
store: &Todos,
wake: &Notify,
subsystem: &str,
key: Option<&str>,
summary: &str,
source: Option<&str>,
) -> Response {
match store.upsert(subsystem, key, summary, source) {
Ok((id, changed)) => {
tracing::debug!(%subsystem, ?key, id, changed, "todo upsert");
if changed {
wake.notify_one();
}
Response::Ok
}
Err(e) => err(&e),
}
}
/// `ClearTodo` handler: drops one keyed todo, or every todo in `subsystem`
/// when `all` is set.
fn clear_todo(store: &Todos, subsystem: &str, key: Option<&str>, all: bool) -> Response {
let result = if all {
store.clear_subsystem(subsystem)
} else {
store.clear(subsystem, key)
};
match result {
Ok(count) => {
tracing::debug!(%subsystem, ?key, all, count, "todo clear");
Response::Acked {
count: u64::try_from(count).unwrap_or(0),
}
}
Err(e) => err(&e),
}
}
/// `ListTodos` handler: read-only, so no debug logging — not relevant to
/// diagnosing wake behaviour.
fn list_todos(store: &Todos, subsystem: Option<&str>) -> Response {
match store.list(subsystem) {
Ok(todos) => Response::LooseEnds {
loose_ends: todos.into_iter().map(to_loose_end).collect(),
},
Err(e) => err(&e),
}
}
/// `MarkTodoDone` handler: marks a single todo done by id.
fn mark_todo_done(store: &Todos, id: i64) -> Response {
match store.mark_done(id) {
Ok(count) => {
tracing::debug!(id, count, "todo mark-done");
Response::Acked {
count: u64::try_from(count).unwrap_or(0),
}
}
Err(e) => err(&e),
}
}
/// `Request::Compact` handler: gate on context usage, then queue the same
/// deferred `compact_pending` flag the operator's `/compact` button sets.
/// Mirrors `hive-agent::web_ui::actions::post_compact` but reachable from
/// the agent's own MCP tool instead of the dashboard, and refuses below
/// [`COMPACT_MIN_USAGE_FRACTION`] instead of always honouring the request —
/// an agent can call this speculatively, a human clicking the dashboard
/// button already made the judgment call.
fn compact(bus: &Bus) -> Response {
let Some(usage) = bus.last_ctx_usage() else {
return Response::Err {
message: "compact refused: no completed turn yet — nothing to compact".to_owned(),
};
};
let model = bus.model();
let window = bus.effective_context_window(&model);
if window == 0 {
return Response::Err {
message: "compact refused: effective context window is unknown (0)".to_owned(),
};
}
// Token counts stay well under 2^53 in practice, so the f64 conversion
// is exact; this is a threshold ratio, not a byte-count display, but the
// same "cosmetic precision loss" reasoning as `hivectl::quota::human_bytes` applies.
#[allow(clippy::cast_precision_loss)]
let fraction = usage.context_tokens() as f64 / window as f64;
if fraction < COMPACT_MIN_USAGE_FRACTION {
return Response::Err {
message: format!(
"compact refused: context usage is {:.0}% of the {window}-token window, \
below the {:.0}% floor — not worth compacting yet",
fraction * 100.0,
COMPACT_MIN_USAGE_FRACTION * 100.0
),
};
}
bus.request_compact();
bus.emit(crate::events::LiveEvent::Note {
text: "agent: self-requested /compact — running at the end of the current turn".into(),
});
Response::Ok
}
/// Shared "reminders db unavailable" response for every reminder op when
/// the store failed to open at boot (see `main.rs`'s best-effort open).
fn no_reminders_store() -> Response {
Response::Err {
message: "reminders store unavailable on this agent".to_owned(),
}
}
/// Shared "questions mirror unavailable" response for every question op
/// when the store failed to open at boot (see `main.rs`'s best-effort open).
fn no_questions_store() -> Response {
Response::Err {
message: "questions mirror unavailable on this agent".to_owned(),
}
}
/// Map a mirrored [`QuestionMirror`] to a [`LooseEnd::Question`]. `asker`/
/// `target` are derived from `role` — this agent's own label fills whichever
/// side `role` says is us, `peer` fills the other.
fn question_to_loose_end(q: QuestionMirror) -> LooseEnd {
let now = hive_sh4re::wire_time::now_unix();
let age = u64::try_from(now.saturating_sub(q.asked_at)).unwrap_or(0);
let me = crate::identity::label();
let (asker, target) = match q.role {
Role::Asked => (me, Some(q.peer)),
Role::Answering => (q.peer, Some(me)),
};
LooseEnd::Question {
id: q.id,
asker,
target,
question: q.question,
age_seconds: age,
}
}
/// Map a stored [`Reminder`] to a [`LooseEnd::Reminder`], deriving
/// `age_seconds` from `created_at` (mirrors the old c0re rendering —
/// "age" is how long the reminder has been *scheduled*, not how soon
/// it's due).
fn reminder_to_loose_end(r: Reminder) -> LooseEnd {
let now = hive_sh4re::wire_time::now_unix();
let age = u64::try_from(now.saturating_sub(r.created_at)).unwrap_or(0);
LooseEnd::Reminder {
id: r.id,
owner: crate::identity::label(),
message: r.message,
due_at: hive_sh4re::wire_time::from_secs(r.due_at),
age_seconds: age,
}
}
/// Format a store error into an `Err` response.
fn err(e: &anyhow::Error) -> Response {
Response::Err {
message: format!("{e:#}"),
}
}
/// Map a stored [`Todo`] to a [`LooseEnd::Todo`], deriving `age_seconds`
/// from `updated_at` (saturating so a backwards clock step reads 0).
fn to_loose_end(t: Todo) -> LooseEnd {
let now = hive_sh4re::wire_time::now_unix();
let age = u64::try_from(now.saturating_sub(t.updated_at)).unwrap_or(0);
LooseEnd::Todo {
id: t.id,
subsystem: t.subsystem,
subsystem_key: t.subsystem_key,
summary: t.summary,
source: t.source,
age_seconds: age,
}
}