Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-agent/src/boot_announce.rs

130 lines
5.4 KiB
Rust

//! One-shot startup self-announce: pushes a todo on every harness boot so
//! the agent gets a "you were just (re)started" notice without the host
//! having to send one.
//!
//! Replaces `hive-c0re`'s old `Coordinator::kick_agent` calls at the tail
//! of the rebuild and start DAG nodes, which an operator reported as
//! duplicate restart messages: those fired one broker message per DAG
//! node, so a single rebuild — always a real
//! `StopForUpdate` → `Swap` → `RebuildBookkeeping` → `Reconcile` → `Start`
//! cycle — queued *two* separate restart messages for one actual boot, and
//! an agent paused for days could rack up dozens more from repeated
//! host-side sweeps. The harness itself has perfect knowledge of "did a
//! boot actually happen": it only runs this once, at the top of
//! `serve_main`. Self-announcing here dedups for free — one todo per real
//! process start, no host-side bookkeeping needed.
//!
//! No first-spawn-vs-restart distinction: every boot gets the same notice
//! (the old first-spawn message didn't reliably fire anyway, so unifying
//! the two loses nothing).
//!
//! Keyed on a constant `"boot"` key, not keyless: the store (`Todos`,
//! backed by `paths::state_db()`, a sqlite file) outlives the process, so
//! a keyless insert would never dedup across boots — N harness starts
//! while the agent hasn't acked the previous row would leave N identical
//! rows, exactly the restart-storm this module replaces. With a constant
//! key and the summary text constant too, `Todos::upsert`'s content-
//! unchanged path makes a boot while the previous notice is still pending
//! a no-op (no second wake), and `reopen_if_acked = true` makes a boot
//! after the agent acked it re-open the same row and wake again. One row,
//! ever.
use tokio::sync::Notify;
use crate::todos::Todos;
/// Producing subsystem marker for the boot-announce todo row.
const SUBSYSTEM: &str = "harness";
/// Dedup key for the boot-announce row — constant, so every boot targets
/// the same row instead of inserting a fresh one. See module doc.
const KEY: &str = "boot";
/// Push the "you were just (re)started" todo and wake the turn loop.
/// Best-effort: a store error is logged, never fatal to boot.
pub fn announce(label: &str, todos: &Todos, wake: &Notify) {
let summary = format!(
"You were just (re)started. If you were mid-task, check \
`/agents/{label}/state/` for your notes and pick up where you left \
off — claude's `--continue` session is intact, so prior context is \
still in your window."
);
match todos.upsert(SUBSYSTEM, Some(KEY), &summary, None, true) {
Ok((_, true)) => wake.notify_one(),
Ok((_, false)) => {}
Err(e) => tracing::warn!(error = ?e, "boot_announce: todo upsert failed"),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Drains any pending permit so the next check starts from "no wake
/// armed" — `notify_one` coalesces multiple calls into a single
/// stored permit, so an un-drained leftover from a prior step would
/// make this step's check pass regardless of whether `announce` wove
/// in a fresh one.
fn wake_fired(wake: &Notify) -> bool {
use futures_util::FutureExt as _;
wake.notified().now_or_never().is_some()
}
#[test]
fn two_boots_without_an_ack_leave_one_row() {
let dir = tempfile::tempdir().expect("tempdir");
let todos = Todos::open(&dir.path().join("state.sqlite")).expect("open");
let wake = Notify::new();
announce("a", &todos, &wake);
let rows = todos.list(Some(SUBSYSTEM)).expect("list");
assert_eq!(rows.len(), 1);
let id = rows[0].id;
// A second boot before the first notice is acked: same row, no
// duplicate.
announce("a", &todos, &wake);
let rows = todos.list(Some(SUBSYSTEM)).expect("list");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, id);
// Ack it, then boot again: the row re-opens (still the same row,
// un-acked again), not a second insert.
todos.mark_done(id).expect("mark_done");
assert!(todos.list(Some(SUBSYSTEM)).expect("list").is_empty());
announce("a", &todos, &wake);
let rows = todos.list(Some(SUBSYSTEM)).expect("list");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, id);
}
#[test]
fn only_a_changed_upsert_wakes() {
let dir = tempfile::tempdir().expect("tempdir");
let todos = Todos::open(&dir.path().join("state.sqlite")).expect("open");
let wake = Notify::new();
// First boot: fresh row, must wake.
announce("a", &todos, &wake);
assert!(wake_fired(&wake), "first boot wakes");
// Second boot before any ack: same row, unchanged summary — must
// NOT wake. This is the restart-storm case: a boot while the
// notice is still pending costs nothing extra.
announce("a", &todos, &wake);
assert!(
!wake_fired(&wake),
"repeat boot with the row still open must not wake"
);
// Ack it, then boot again: the row reopens — must wake again.
let id = todos.list(Some(SUBSYSTEM)).expect("list")[0].id;
todos.mark_done(id).expect("mark_done");
announce("a", &todos, &wake);
assert!(
wake_fired(&wake),
"boot after an ack reopens the row and wakes"
);
}
}