hyperhive/hive-c0re/src/loose_ends.rs
atlas 729c5b4f42 hive-agent-mcp, hive-c0re, hive-sh4re: drop agent param from get_loose_ends
get_loose_ends now always returns the caller's own loose ends, for every
caller including the manager (ruth) — there is no separate manager
surface, ruth is a normal agent with different default capabilities.

- AgentGetLooseEndsArgs removed; get_loose_ends takes no args.
- Wire Request::GetLooseEnds collapses from an Option<String> target to
  a unit variant.
- hive-c0re's handle_get_loose_ends drops the "*" hive-wide branch and
  the subtree/capability resolver (resolve_agent_state_target); both
  are gone since there is no longer a target to resolve.
- loose_ends::hive_wide and Capability::QueryAgentState removed as
  dead code — their only callers were the two functions above.
- is_descendant_of is untouched (still used by lifecycle_handlers.rs
  and schedules.rs independently of this change).
- Docs updated: docs/turn-loop/mcp.md, docs/web-ui/dashboard.md,
  docs/process/conventions.md (Loose-ends wire shape + capabilities
  table), plus the doc comments in hive-core-agent-sock, mcp_config.rs
  and capabilities.rs that described the old shape.

Refs #4480
2026-09-20 05:36:49 +02:00

77 lines
3.3 KiB
Rust

//! Loose-ends aggregator. Walks the `approvals` table once per call and
//! assembles a `Vec<LooseEnd>` for a single agent (`for_agent`) — always
//! the caller's own. `Request::GetLooseEnds` from either the agent or
//! manager socket lands here so the age-seconds derivation stays in one
//! place. Reminders are agent-local (in-container
//! `hive-agent::reminders` store) and never sourced from here. The
//! `ask`/`answer` MCP tools, their wire protocol (`hive-c0re::questions`,
//! `stores::operator_questions`), and the operator dashboard's questions
//! pane have all been removed entirely — this file never had a
//! `Question` loose-end path to begin with (it only ever emitted
//! `PendingMessages`/`Approval`), so nothing here changed shape when
//! that removal landed.
//!
//! Call frequency is low (an agent doing self-introspection between
//! turns), so the sweep happens fresh every time — no caching, no
//! mutation events. If the sweep ever shows up in a profile, the sqlite
//! queries already filter on the same index (`idx_approvals_pending`)
//! that the dashboard uses, so the bottleneck would be json
//! (de)serialisation, not the read.
use anyhow::Result;
use chrono::Utc;
use hive_sh4re::inbox::{LooseEnd, saturating_age};
use crate::coordinator::Coordinator;
/// Open threads pending against `agent`:
/// - undelivered inbox messages this agent still owes itself a `recv`
/// for (only when the count is non-zero);
/// - pending approvals where this agent is the submitter (a parent
/// agent with the `approvals` group submits for its children; the
/// root submits for top-level agents). Legacy rows with no recorded
/// submitter count as the root's.
///
/// Ordered `pending_messages` (when non-zero) → approvals within the
/// returned vector. Within each kind, source-of-truth ordering (sqlite's
/// `pending()` query returns newest-first within its index).
///
/// # Errors
///
/// Propagates errors from `count_pending` and the pending-approval
/// sqlite query.
pub fn for_agent(coord: &Coordinator, agent: &str) -> Result<Vec<LooseEnd>> {
let now = Utc::now().timestamp();
let mut out = Vec::new();
// Undelivered inbox messages this agent still owes itself a `recv`
// for. Surfaced first (most actionable) and only when non-zero so a
// clean inbox doesn't add noise. The wake-message that drove the
// current turn is already delivered, so this counts only what's
// genuinely still queued.
let pending_messages = coord.broker.count_pending(agent)?;
if pending_messages > 0 {
out.push(LooseEnd::PendingMessages {
count: pending_messages,
});
}
// Show each pending approval to the agent that submitted it. The
// submitter column is NULL for rows predating it; those count as
// operator-initiated (no agent tracking predates the column).
for a in coord.approvals.pending()? {
let submitter = coord
.approvals
.submitter_of(a.id)?
.unwrap_or_else(|| "operator".to_owned());
if submitter != agent {
continue;
}
out.push(LooseEnd::Approval {
id: a.id,
agent: a.agent.to_string(),
commit_ref: a.commit_ref,
description: a.description,
age_seconds: saturating_age(now, a.requested_at.timestamp()),
});
}
Ok(out)
}