delete c0re-side reminder plumbing (#2635 inc 1 commit 6)

This commit is contained in:
damocles 2026-07-22 23:01:16 +02:00 committed by mara
commit a80d0b0fed
16 changed files with 70 additions and 1136 deletions

View file

@ -23,7 +23,6 @@ use crate::coordinator::Coordinator;
mod config_approvals;
mod lifecycle_handlers;
mod reminders;
mod schedules;
pub(crate) use config_approvals::submit_merge_config_pr;
@ -34,7 +33,6 @@ use config_approvals::{handle_request_init_config, handle_request_update_meta_in
use lifecycle_handlers::{
handle_kill, handle_list_descendants, handle_restart, handle_start, handle_update,
};
use reminders::{handle_remind, resolve_agent_state_target};
use schedules::{
EditSchedulePatch, handle_cancel_schedule, handle_edit_schedule, handle_fire_schedule_now,
handle_list_schedules, handle_request_schedule_prompt,
@ -184,8 +182,8 @@ pub(crate) fn recv_timeout(wait_seconds: Option<u64>) -> std::time::Duration {
/// Handle the subset of `Request` variants that are identical on both
/// the agent socket and the manager socket. Returns `Some(response)` for
/// every variant it handles; returns `None` for variants with socket-specific
/// semantics (e.g. `GetLooseEnds` / `CountPendingReminders` / `ReminderRollup`
/// where the manager can target other agents) or for manager-only variants.
/// semantics (e.g. `GetLooseEnds`, where the manager can target other
/// agents) or for manager-only variants.
///
/// The unified `dispatch` calls this first; the remaining arms (which gate
/// on topology / capabilities / tool-groups) are handled there.
@ -234,11 +232,6 @@ pub(crate) async fn dispatch_shared(
|()| hive_core_agent_sock::Response::Ok,
)
}
hive_core_agent_sock::Request::Remind {
message,
timing,
file_path,
} => handle_remind(coord, agent, message, timing, file_path.as_deref()),
hive_core_agent_sock::Request::SetStatus { text } => handle_set_status(coord, text),
hive_core_agent_sock::Request::GetAgentMeta { name } => {
handle_get_agent_meta(coord, agent, name.as_ref()).await
@ -590,13 +583,6 @@ async fn dispatch(req: &Request, agent: &str, coord: &Arc<Coordinator>) -> Respo
Request::GetLooseEnds { agent: target } => {
handle_get_loose_ends(coord, agent, target.as_deref())
}
Request::CountPendingReminders { agent: target } => {
handle_count_pending_reminders(coord, agent, target.as_deref())
}
Request::ReminderRollup {
since_secs,
agent: target,
} => handle_reminder_rollup(coord, agent, target.as_deref(), *since_secs),
// Orchestration / diagnostics verbs — gated per-verb on tool-group
// membership or topology (see `dispatch_orchestration`).
_ => dispatch_orchestration(req, agent, coord).await,
@ -791,41 +777,38 @@ fn handle_get_loose_ends(coord: &Arc<Coordinator>, agent: &str, target: Option<&
}
}
/// `CountPendingReminders` — resolve the target (own / subtree free, else
/// `QueryAgentState`) then count its pending reminders.
fn handle_count_pending_reminders(
coord: &Arc<Coordinator>,
agent: &str,
target: Option<&str>,
) -> Response {
match resolve_agent_state_target(agent, target) {
Ok(name) => match coord.broker.count_pending_reminders_for(name) {
Ok(count) => Response::PendingRemindersCount { count },
Err(e) => Response::Err {
message: format!("{e:#}"),
},
},
Err(message) => Response::Err { message },
}
}
/// `ReminderRollup` — resolve the target (own / subtree free, else
/// `QueryAgentState`) then roll up its reminders fired in the last
/// `since_secs`.
fn handle_reminder_rollup(
coord: &Arc<Coordinator>,
agent: &str,
target: Option<&str>,
since_secs: u64,
) -> Response {
match resolve_agent_state_target(agent, target) {
Ok(name) => match coord.broker.reminder_rollup_for(name, since_secs) {
Ok(stats) => Response::ReminderRollup(stats),
Err(e) => Response::Err {
message: format!("{e:#}"),
},
},
Err(message) => Response::Err { message },
/// Resolve the target agent name for a *named* `GetLooseEnds` query. Rules:
///
/// - `None` (or `Some(caller)`) → the caller's own threads (always allowed).
/// - `Some(descendant)` in the caller's subtree (the root's subtree is the whole hive) → allowed, no extra capability.
/// - `Some(other)` outside the subtree → requires the `query_agent_state` capability; error otherwise.
/// - `Some("*")` → rejected here; the hive-wide sweep is handled by `handle_get_loose_ends` under the same `query_agent_state` gate.
fn resolve_agent_state_target<'a>(
caller: &'a str,
target: Option<&'a str>,
) -> Result<&'a str, String> {
match target {
None => Ok(caller),
Some("*") => Err(
"hive-wide query (agent=\"*\") is only valid for loose-ends; \
not available for this query"
.to_owned(),
),
Some(name) => {
// Own subtree (the root covers all) is visible without extra
// capability; `is_descendant_of` returns true for `name == caller`.
if crate::topology::is_descendant_of(name, caller) {
return Ok(name);
}
if crate::capabilities::has_cap(caller, hive_sh4re::Capability::QueryAgentState) {
Ok(name)
} else {
Err(format!(
"agent `{caller}` cannot query `{name}`: not in its subtree and \
`query_agent_state` capability is not granted"
))
}
}
}
}

View file

@ -1,229 +0,0 @@
//! Reminder request handling: the `Remind` handler, the shared
//! `store_remind` storage path with its pending-cap and large-body
//! auto-save dance, timing resolution, and the agent-state target
//! resolution shared by the loose-ends / reminder query handlers.
use std::sync::Arc;
use hive_core_agent_sock::Response;
use crate::coordinator::Coordinator;
pub(super) fn handle_remind(
coord: &Arc<Coordinator>,
agent: &str,
message: &str,
timing: &hive_sh4re::ReminderTiming,
file_path: Option<&str>,
) -> Response {
match store_remind(coord, agent, message, timing, file_path) {
Ok(()) => Response::Ok,
Err(message) => Response::Err { message },
}
}
/// Shared remind-storage path used by both the agent and the manager
/// dispatchers. Validates timing, applies the auto-file overflow
/// dance (see [`prepare_remind_storage`]), and writes the reminder
/// row. Returns `Ok(())` on success, or a caller-ready error string
/// the dispatcher wraps in `*Response::Err`.
/// Maximum pending (un-delivered) reminders per agent. Exceeding this
/// causes `store_remind` to return an error so the agent knows to back
/// off instead of silently dropping. Override via
/// `HIVE_REMIND_MAX_PENDING_PER_AGENT`; set to `0` to disable the cap
/// (not recommended — a runaway agent can still flood the scheduler).
const DEFAULT_REMIND_MAX_PENDING: u64 = 50;
fn remind_max_pending() -> u64 {
std::env::var("HIVE_REMIND_MAX_PENDING_PER_AGENT")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.unwrap_or(DEFAULT_REMIND_MAX_PENDING)
}
pub(crate) fn store_remind(
coord: &Arc<Coordinator>,
agent: &str,
message: &str,
timing: &hive_sh4re::ReminderTiming,
file_path: Option<&str>,
) -> Result<(), String> {
let max = remind_max_pending();
if max > 0 {
let pending = coord.broker.count_pending_reminders_for(agent).unwrap_or(0);
if pending >= max {
return Err(format!(
"reminder rejected: agent `{agent}` already has {pending} pending \
reminders (cap {max}). Cancel some via `cancel_loose_end` or wait \
for them to fire before scheduling more. Override the cap with \
`HIVE_REMIND_MAX_PENDING_PER_AGENT`."
));
}
}
let due_at = resolve_due_at(timing).map_err(|e| format!("invalid reminder timing: {e:#}"))?;
let (stored_message, stored_path) = prepare_remind_storage(agent, message, file_path)?;
let id = coord
.broker
.store_reminder(agent, &stored_message, stored_path.as_deref(), due_at)
.map_err(|e| format!("failed to store reminder: {e:#}"))?;
tracing::info!(%id, %agent, %due_at, "reminder scheduled");
coord.emit_reminders_snapshot();
Ok(())
}
/// Decide what we actually store in the reminders row, applying the
/// same byte cap as the rest of the wire protocol
/// ([`crate::limits::MESSAGE_MAX_BYTES`]). Three outcomes:
///
/// 1. Body within the cap → stored verbatim, with whatever `file_path`
/// the caller passed (None or Some). The scheduler honours
/// `file_path` at delivery time as before.
/// 2. Body over the cap, no caller `file_path` → auto-generate a path
/// under `/agents/<agent>/state/reminders/auto-<ts>.md`, write the
/// body to disk now, store a short pointer hint as the message and
/// clear `file_path` (so the scheduler doesn't re-write at
/// delivery and overwrite the body with the hint).
/// 3. Body over the cap, caller provided `file_path` → honour the
/// caller's path: write the body to it now, store the same hint
/// and clear `file_path` for the same reason as (2).
///
/// Returns `(stored_message, stored_file_path)` on success, or a
/// caller-ready error string on auto-save failure (which is the only
/// way a Remind request can be refused for size — the agent never has
/// to think about the cap).
fn prepare_remind_storage(
agent: &str,
message: &str,
file_path: Option<&str>,
) -> Result<(String, Option<String>), String> {
if message.len() <= crate::limits::MESSAGE_MAX_BYTES {
return Ok((message.to_owned(), file_path.map(str::to_owned)));
}
let req_path = match file_path {
Some(p) => p.to_owned(),
None => auto_reminder_path(agent),
};
let host_path = crate::reminder_scheduler::resolve_host_path(agent, &req_path)
.map_err(|reason| format!("auto-save path `{req_path}` rejected: {reason}"))?;
crate::reminder_scheduler::write_payload(agent, &host_path, message).map_err(|reason| {
format!("auto-save of large reminder body to `{req_path}` failed: {reason}")
})?;
let hint = format!(
"[reminder body of {} bytes auto-saved to `{req_path}`; read with your filesystem tools]",
message.len()
);
Ok((hint, None))
}
/// Generate a per-agent path for an auto-saved reminder body. Uses
/// `unix_nanos` plus the agent name to keep collisions infinitesimal
/// across the agent's own state subtree (we're not stamping a hostname
/// since hive-c0re is single-host).
fn auto_reminder_path(agent: &str) -> String {
let ts_ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
format!("/agents/{agent}/state/reminders/auto-{ts_ns}.md")
}
/// Resolve the target agent name for a *named* `GetLooseEnds` /
/// `CountPendingReminders` / `ReminderRollup` query. Rules:
///
/// - `None` (or `Some(caller)`) → the caller's own threads (always allowed).
/// - `Some(descendant)` in the caller's subtree (the root's subtree is the whole hive) → allowed, no extra capability.
/// - `Some(other)` outside the subtree → requires the `query_agent_state` capability; error otherwise.
/// - `Some("*")` → rejected here; the hive-wide sweep is handled by `handle_get_loose_ends` under the same `query_agent_state` gate.
pub(super) fn resolve_agent_state_target<'a>(
caller: &'a str,
target: Option<&'a str>,
) -> Result<&'a str, String> {
match target {
None => Ok(caller),
Some("*") => Err(
"hive-wide query (agent=\"*\") is only valid for loose-ends; \
not available for this query"
.to_owned(),
),
Some(name) => {
// Own subtree (the root covers all) is visible without extra
// capability; `is_descendant_of` returns true for `name == caller`.
if crate::topology::is_descendant_of(name, caller) {
return Ok(name);
}
if crate::capabilities::has_cap(caller, hive_sh4re::Capability::QueryAgentState) {
Ok(name)
} else {
Err(format!(
"agent `{caller}` cannot query `{name}`: not in its subtree and \
`query_agent_state` capability is not granted"
))
}
}
}
}
/// Resolve the `due_at` unix timestamp for a Remind request. Returns
/// distinct error messages for each failure mode (overflow on
/// `InSeconds`, pre-epoch clock, `i64` cast wrap) so the caller can tell
/// what went wrong without inspecting the chain.
fn resolve_due_at(timing: &hive_sh4re::ReminderTiming) -> anyhow::Result<i64> {
use hive_sh4re::ReminderTiming;
match timing {
ReminderTiming::InSeconds { seconds } => {
let now = std::time::SystemTime::now();
let future = now
.checked_add(std::time::Duration::from_secs(*seconds))
.ok_or_else(|| {
anyhow::anyhow!("InSeconds overflow: {seconds}s exceeds system time range")
})?;
let duration = future
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| anyhow::anyhow!("system time before UNIX_EPOCH: {e}"))?;
i64::try_from(duration.as_secs())
.map_err(|e| anyhow::anyhow!("unix timestamp exceeds i64 range: {e}"))
}
ReminderTiming::At { unix_timestamp } => Ok(*unix_timestamp),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auto_reminder_path_format() {
let p = auto_reminder_path("damocles");
assert!(p.starts_with("/agents/damocles/state/reminders/auto-"));
assert!(
std::path::Path::new(&p)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
);
}
#[test]
fn prepare_remind_storage_passthrough_under_cap() {
let (msg, fp) = prepare_remind_storage("foo", "small body", None).unwrap();
assert_eq!(msg, "small body");
assert_eq!(fp, None);
}
#[test]
fn prepare_remind_storage_passthrough_with_caller_file_path() {
let (msg, fp) =
prepare_remind_storage("foo", "small", Some("/agents/foo/state/x.md")).unwrap();
assert_eq!(msg, "small");
assert_eq!(fp.as_deref(), Some("/agents/foo/state/x.md"));
}
#[test]
fn resolve_agent_state_target_self_and_default_are_free() {
// No topology/capability state needed for these: `None` and the
// caller's own name resolve to the caller (`is_descendant_of` short-
// circuits to true when candidate == ancestor); `"*"` is rejected
// (the hive-wide sweep is handled by the loose-ends caller instead).
assert_eq!(resolve_agent_state_target("iris", None), Ok("iris"));
assert_eq!(resolve_agent_state_target("iris", Some("iris")), Ok("iris"));
assert!(resolve_agent_state_target("iris", Some("*")).is_err());
}
}