car-server-core 0.47.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! Periodic reaper for the observe-only agent registry (`~/.car/registry/*.json`).
//!
//! Agents self-register and heartbeat into `car_registry::AgentRegistry`; one
//! that dies without calling `registry.unregister` leaves its entry behind.
//! Every *reader* already filters by heartbeat freshness
//! (`REGISTRY_STALE_AFTER_SECS`), so a stale entry never changes behavior — but
//! nothing ever deleted the files, so they accumulated indefinitely (a solver
//! agent that stopped ~16 days earlier was still on disk). `reap_stale` was
//! built for exactly this and documented as "call from the menubar every ~30s",
//! yet no scheduled caller was ever wired — headless or otherwise.
//!
//! This is that caller, daemon-side so it runs even with no menubar. Reaping is
//! safe: the threshold sits far beyond any live agent's heartbeat interval;
//! `reap_stale` re-reads each entry immediately before the unlink, so an agent
//! that beat again after the snapshot is kept (it does NOT rely on a reaped
//! agent re-registering — `heartbeat` won't recreate a deleted file); and
//! readers ignore anything past the freshness window regardless — so a reap only
//! ever deletes a file a reader was already treating as absent. Note this
//! touches only the observe-only self-report registry; the supervised-agent list
//! (`agents.list`, what CarHost renders) is a separate source and untouched.
//!
//! Mirrors [`crate::spawn_command_scheduler`]: one non-overlapping task that
//! dies with the runtime.

use std::time::Duration;

/// How often the reaper wakes. One small directory read, so a modest cadence
/// keeps the directory clean at negligible cost.
const REAP_POLL_SECS: u64 = 300;

/// Delete registry entries whose last heartbeat is older than this. Set well
/// above the consumer freshness window (`REGISTRY_STALE_AFTER_SECS = 60`): a
/// live agent that briefly missed a beat is never reaped out from under a reader
/// — it would already read as stale, but this avoids needlessly churning the
/// file. An entry idle this long is definitively dead.
const REAP_MAX_AGE_SECS: u64 = 900;

/// Spawn the observe-only registry reaper. Call once at boot; the task lives for
/// the process lifetime. Best-effort — a read/delete failure is logged and the
/// next tick retries. The interval's first tick fires immediately, so this also
/// serves as a boot-time sweep of entries orphaned while the daemon was down.
pub fn spawn_stale_registry_reaper() {
    tokio::spawn(async move {
        let mut ticker = tokio::time::interval(Duration::from_secs(REAP_POLL_SECS));
        ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
        loop {
            ticker.tick().await;
            // `None` → default registry path (`~/.car/registry`).
            match car_ffi_common::registry::reap_stale_agents(REAP_MAX_AGE_SECS, None) {
                Ok(json) => match serde_json::from_str::<Vec<String>>(&json) {
                    Ok(reaped) if !reaped.is_empty() => tracing::info!(
                        target: "car::registry",
                        count = reaped.len(),
                        agents = ?reaped,
                        "reaped stale agent registry entries"
                    ),
                    Ok(_) => {}
                    Err(e) => tracing::warn!(
                        target: "car::registry",
                        error = %e,
                        "registry reaper returned unparseable report"
                    ),
                },
                Err(e) => tracing::warn!(
                    target: "car::registry",
                    error = %e,
                    "stale registry reaper tick failed"
                ),
            }
        }
    });
}