hyperhive/hive-agent/src/reminder_timer.rs
atlas be3411e180 feat(#3245): gate rustdoc in nix flake check, and clear the workspace
Nothing in the gate read doc-comments: clippy doesn't check intra-doc
links, cargo test doesn't, and no check built docs. So a [`Foo`] pointing
at a renamed, moved or deleted item rendered as plain text and had no
discoverer but a human happening to read the comment.

That matters here more than in most repos, because the convention is to
put a thing's authoritative description in one doc-comment and point at
it from everywhere else -- the design leans on the pointers being real,
and a dangling link is worse than no link since it names something and
sends the reader looking.

Adds `docs-rustdoc` to nix/checks.nix: craneLib.cargoDoc over
--workspace --no-deps --document-private-items, denying six rustdoc
lints. Listed explicitly rather than -D warnings so a new lint appearing
upstream cannot red the build on a class nobody has triaged.

--document-private-items is load-bearing rather than thoroughness for
its own sake: most of this workspace's doc-comments live on private
items and //! module headers, so without it rustdoc checks a small
fraction of the links and the gate sits green while the rot continues.

Then fixes every error it reports, 40 to 0 across nine crates. The
classes differ and so do the fixes:

- public item, wrong scope -> qualify. Node and Node::parent are both
  public; the link failed only because scheduler.rs does not import
  Node. Six sites become [`crate::Node::parent`].
- private item -> downgrade to backticks. Nothing was made public to
  satisfy a lint; changing API surface to appease a doc check would be
  the tail wagging the dog.
- genuinely dead -> [`JobBuilder::insert_into`] names a method that does
  not exist. Insertion is Scheduler::insert_job.
- prose that looks like markup -> argv[0] parsed as a link, and
  <args>/<hex>/<name> parsed as HTML tags.

Note for future fixes: pub(crate) resolves in an intra-doc link, a plain
private fn in a binary crate does not (wait_for_nodes resolved,
connect_hint did not, same crate, same shape).

The check does not ride the clippy/test artifact cache. It takes
cargoArtifacts, but rustdoc needs its own flavour of dependency
metadata, which cargo build does not produce, so a --no-deps docs build
still compiles dependencies it never documents. Measured at 6m47s cold;
that reasoning is recorded in the check's own comment so the next reader
does not re-derive it.

Verified by running the check's exact command against the pre-cleanup
tree first: 40 errors, build failed. A gate that cannot fail is not
evidence, and building it before the cleanup makes that proof free.
2026-08-14 02:30:55 +02:00

304 lines
12 KiB
Rust

//! Delivery half of the harness-local reminders store. Polls [`Reminders`]
//! for due rows and pushes each as a
//! [`hive_sh4re::inbox::DeliveredMessage`] down an mpsc channel the serve loop
//! races against the broker long-poll — mirrors `todo_server`'s `Notify`
//! wake, but a reminder fire carries real per-row data (message/id), so a
//! bare `Notify` doesn't fit; the channel carries the finished message
//! instead.
//!
//! File-path semantics (large-body auto-save, delivery-time persist)
//! port the old `hive-c0re::workers::reminder_scheduler` /
//! `socket_server::reminders` logic, simplified: this runs *inside* the
//! agent's own container now, so the symlink-escape defense that guarded
//! `hive-c0re` (running outside the container, writing into a path an
//! agent claimed was its own) is moot — the harness IS the agent,
//! already sandboxed by the container boundary. Still keeps the cheap
//! belt-and-suspenders checks: `file_path` must resolve under
//! [`crate::paths::state_dir`] with no `..`/absolute components in the
//! relative tail.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use crate::reminders::Reminders;
/// Per-tick cap on reminders delivered — mirrors the old c0re
/// `REMINDER_BATCH_LIMIT`, same rationale (bound a deep backlog's
/// per-tick cost).
const REMINDER_BATCH_LIMIT: u64 = 100;
/// Poll interval — matches the old c0re `POLL_INTERVAL`.
const POLL_INTERVAL: Duration = Duration::from_secs(5);
/// Same cap the broker used to enforce on `send`/`ask`/`remind` bodies
/// (`hive-c0re`'s `agent_config::limits::MESSAGE_MAX_BYTES`, not
/// reachable from here — hive-agent doesn't depend on hive-c0re).
/// Duplicated rather than shared: this is the last remaining reminder
/// caller of that constant once the c0re-side store is deleted (commit 6).
const REMINDER_BODY_MAX_BYTES: usize = 4096;
/// Maximum pending (undelivered) reminders this agent may hold at once.
/// Exceeding this makes `store` return an error so the caller backs off
/// instead of silently flooding the table. Override via
/// `HIVE_REMIND_MAX_PENDING_PER_AGENT`; `0` disables the cap.
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)
}
/// Run the delivery timer. `store: None` (reminders db failed to open at
/// boot) parks forever instead of looping — keeps `tx` alive so the
/// serve loop's receiver never observes a closed channel (which would
/// otherwise resolve immediately every iteration and busy-loop the
/// select), while cleanly disabling delivery.
pub async fn run(
store: Option<Arc<Reminders>>,
tx: mpsc::UnboundedSender<hive_sh4re::inbox::DeliveredMessage>,
) {
let Some(store) = store else {
tracing::error!("reminders db unavailable — reminder delivery disabled");
std::future::pending::<()>().await;
return;
};
loop {
tick(&store, &tx);
tokio::time::sleep(POLL_INTERVAL).await;
}
}
fn tick(store: &Reminders, tx: &mpsc::UnboundedSender<hive_sh4re::inbox::DeliveredMessage>) {
let now = chrono::Utc::now().timestamp();
let due = match store.due(now, REMINDER_BATCH_LIMIT) {
Ok(rows) => rows,
Err(e) => {
tracing::warn!(error = ?e, "failed to query due reminders");
return;
}
};
for r in due {
let body = prepare_body(&r.message, r.file_path.as_deref());
let dm = hive_sh4re::inbox::DeliveredMessage {
from: "reminder".into(),
body,
id: 0,
redelivered: false,
in_reply_to: None,
};
if tx.send(dm).is_err() {
// Receiver (serve loop) is gone — process is shutting down.
// Leave the row pending; nothing else to do here.
tracing::warn!(reminder_id = r.id, "reminder delivery channel closed");
return;
}
if let Err(e) = store.mark_delivered(r.id) {
tracing::warn!(reminder_id = r.id, error = ?e, "failed to mark reminder delivered");
}
}
}
/// Store a new reminder, applying the pending cap + large-body auto-save
/// dance. Returns the new row id, or a caller-ready error string (used
/// directly as `Response::Err.message` by the in-agent socket dispatch).
pub fn store(
store: &Reminders,
message: &str,
timing: &hive_sh4re::inbox::ReminderTiming,
file_path: Option<&str>,
) -> Result<i64, String> {
let max = remind_max_pending();
if max > 0 {
let pending = store.count_pending().unwrap_or(0);
if pending >= max {
return Err(format!(
"reminder rejected: already {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(message, file_path)?;
store
.store(&stored_message, stored_path.as_deref(), due_at)
.map_err(|e| format!("failed to store reminder: {e:#}"))
}
/// Decide what to actually persist in the reminder row (see the old
/// `prepare_remind_storage` this ports — same three outcomes: verbatim
/// under the cap, auto-saved-to-a-generated-path over cap with no
/// caller path, or auto-saved-to-the-caller's-path over cap).
fn prepare_remind_storage(
message: &str,
file_path: Option<&str>,
) -> Result<(String, Option<String>), String> {
if message.len() <= REMINDER_BODY_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(),
};
let path = resolve_state_path(&req_path)
.map_err(|reason| format!("auto-save path `{req_path}` rejected: {reason}"))?;
write_payload(&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 fresh auto-save path under this agent's own
/// `state/reminders/` dir.
fn auto_reminder_path() -> String {
let ts_ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
crate::paths::state_dir()
.join("reminders")
.join(format!("auto-{ts_ns}.md"))
.to_string_lossy()
.into_owned()
}
/// Build the delivered body for a due reminder: verbatim when
/// `file_path` is unset, otherwise persist to that path and return a
/// short pointer (falling back to inline delivery with a warning note
/// on any rejection/write failure, so the reminder still fires).
fn prepare_body(message: &str, file_path: Option<&str>) -> String {
let Some(req_path) = file_path else {
return message.to_owned();
};
let path = match resolve_state_path(req_path) {
Ok(p) => p,
Err(reason) => return inline_fallback(req_path, &format!("rejected: {reason}"), message),
};
match write_payload(&path, message) {
Ok(()) => format!(
"reminder body persisted to `{req_path}` ({} bytes); read with your filesystem tools",
message.len()
),
Err(reason) => inline_fallback(req_path, &reason, message),
}
}
fn inline_fallback(req_path: &str, reason: &str, message: &str) -> String {
format!("[reminder file_path '{req_path}' {reason}; delivering body inline]\n\n{message}")
}
/// Validate `req_path` is absolute, lives under this agent's own
/// `state_dir()`, and its relative tail has no traversal / absolute
/// components. Returns the (already-real, no host/container
/// translation needed in-container) path.
fn resolve_state_path(req_path: &str) -> Result<PathBuf, String> {
let base = crate::paths::state_dir();
let path = Path::new(req_path);
if !path.is_absolute() {
return Err(format!("must be absolute (got `{req_path}`)"));
}
let Ok(rel) = path.strip_prefix(&base) else {
return Err(format!(
"must live under `{}` (got `{req_path}`)",
base.display()
));
};
if rel.as_os_str().is_empty() {
return Err("file_path must include a filename, not just the state dir".to_owned());
}
for comp in rel.components() {
match comp {
std::path::Component::Normal(_) => {}
other => {
return Err(format!(
"path component `{other:?}` not allowed (no traversal / absolute / root)"
));
}
}
}
Ok(path.to_path_buf())
}
/// Write `message` to `path`, creating parent dirs as needed.
fn write_payload(path: &Path, message: &str) -> Result<(), String> {
let Some(parent) = path.parent() else {
return Err("internal: path has no parent".to_owned());
};
std::fs::create_dir_all(parent).map_err(|e| format!("parent dir create failed: {e}"))?;
std::fs::write(path, message).map_err(|e| format!("write failed: {e}"))
}
/// Resolve the `due_at` unix timestamp for a `StoreReminder` request.
fn resolve_due_at(timing: &hive_sh4re::inbox::ReminderTiming) -> anyhow::Result<i64> {
use hive_sh4re::inbox::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 resolve_state_path_rejects_non_absolute() {
assert!(resolve_state_path("relative.md").is_err());
}
#[test]
fn resolve_due_at_in_seconds_is_close_to_now_plus_n() {
let due =
resolve_due_at(&hive_sh4re::inbox::ReminderTiming::InSeconds { seconds: 60 }).unwrap();
let now = chrono::Utc::now().timestamp();
assert!((due - now - 60).abs() <= 2, "due={due} now={now}");
}
#[test]
fn resolve_due_at_at_passes_through() {
let due = resolve_due_at(&hive_sh4re::inbox::ReminderTiming::At {
unix_timestamp: 123_456,
})
.unwrap();
assert_eq!(due, 123_456);
}
#[test]
fn prepare_remind_storage_passthrough_under_cap() {
let (msg, fp) = prepare_remind_storage("small body", None).unwrap();
assert_eq!(msg, "small body");
assert_eq!(fp, None);
}
#[test]
fn prepare_body_passthrough_when_no_file_path() {
assert_eq!(prepare_body("hello world", None), "hello world");
}
#[test]
fn prepare_body_falls_back_inline_on_bad_path() {
let s = prepare_body("payload", Some("/etc/passwd"));
assert!(s.starts_with("[reminder file_path '/etc/passwd' rejected:"));
assert!(s.contains("payload"));
}
}