130 lines
5.4 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|