//! Host-side vacuum of every per-agent events.sqlite. The harness //! writes to `/agents//harness/hyperhive-events.sqlite` //! (bind-mounted from `/var/lib/hyperhive/agents//harness/`); //! we open the same file from the host every hour. //! //! **Type-scoped retention**: only the verbose `stream` rows (the raw //! claude `stream-json` deltas — one per text chunk / `tool_use`, the //! bulk of the file's size) are pruned, and only once they're older //! than [`STREAM_KEEP_SECS`]. Every other kind (`turn_start`, //! `turn_end`, `note`, `status_changed`, `model_changed`, //! `token_usage_changed`, `turn_state_changed`) is kept — they're //! small and carry the meaningful per-turn history the operator wants //! to scroll back through. This keeps events.sqlite small-and-bounded //! without throwing away semantic history. Keeping retention on the //! host means agents don't need any cleanup wiring of their own, and a //! misbehaving harness can't disable its own vacuum. use std::path::Path; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use rusqlite::{Connection, Result, params}; use crate::coordinator::Coordinator; const VACUUM_INTERVAL: Duration = Duration::from_hours(1); /// How long verbose `stream` rows are kept before pruning. Other event /// kinds are never deleted by this sweep. const STREAM_KEEP_SECS: i64 = 14 * 24 * 3600; /// Background loop: sweep every existing agent state dir hourly, run /// the vacuum SQL against its events.sqlite if present. Errors are /// logged but don't tear the loop down. pub fn spawn(coord: &Arc) { let mut shutdown = coord.shutdown_rx(); tokio::spawn(async move { loop { sweep_once(); tokio::select! { () = tokio::time::sleep(VACUUM_INTERVAL) => {} _ = shutdown.changed() => { tracing::info!("events vacuum: shutdown signal received"); break; } } } }); } fn sweep_once() { for name in Coordinator::kept_state_names() { let path = Coordinator::agent_harness_dir(&name).join("hyperhive-events.sqlite"); if !path.exists() { continue; } match vacuum_file(&path) { Ok(0) => {} Ok(n) => tracing::info!(agent = %name, removed = n, "events vacuum"), Err(e) => tracing::warn!(agent = %name, error = ?e, "events vacuum failed"), } } } fn vacuum_file(path: &Path) -> Result { let conn = Connection::open(path)?; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .ok() .and_then(|d| i64::try_from(d.as_secs()).ok()) .unwrap_or(0); let cutoff = now - STREAM_KEEP_SECS; // Prune only the verbose `stream` deltas; semantic events are kept. let removed = conn.execute( "DELETE FROM events WHERE kind = 'stream' AND ts < ?1", params![cutoff], )?; Ok(u64::try_from(removed).unwrap_or(0)) }