hyperhive/hive-c0re/src/events_vacuum.rs

78 lines
3 KiB
Rust

//! Host-side vacuum of every per-agent events.sqlite. The harness
//! writes to `/agents/<name>/harness/hyperhive-events.sqlite`
//! (bind-mounted from `/var/lib/hyperhive/agents/<name>/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<Coordinator>) {
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<u64> {
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))
}