pub mod config;
pub mod metrics;
pub mod poller;
pub mod pr_cleanup_tick;
pub mod publish;
mod watchdog;
#[cfg(test)]
mod tests;
use std::future::Future;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::{error, info, warn};
use crate::activity::monitor::{ActivityMonitor, LlmClassifier};
use crate::core::auto_resume;
use crate::core::paths::FrameworkPaths;
use crate::session_manager::SessionManager;
pub use config::SupervisorConfig;
pub use metrics::{FleetMetrics, PendingDecision, SupervisorRunStats};
pub use poller::{TickReport, run_tick};
pub use publish::{PublishedMetrics, SupervisorMetricsStatus};
pub struct Supervisor<C: LlmClassifier> {
mgr: Arc<SessionManager>,
cfg: SupervisorConfig,
monitor: Option<Arc<ActivityMonitor<C>>>,
stats: SupervisorRunStats,
auto_resume_path: PathBuf,
last_override: Option<bool>,
metrics_path: PathBuf,
last_pr_cleanup: Option<std::time::Instant>,
fleet_sweep: watchdog::DetachedSweep<TickReport>,
cleanup_sweep: watchdog::DetachedSweep<usize>,
cleanup: CleanupFn,
}
type CleanupFn = Arc<
dyn Fn(Arc<SessionManager>) -> std::pin::Pin<Box<dyn Future<Output = usize>>> + Send + Sync,
>;
impl<C: LlmClassifier + 'static> Supervisor<C> {
pub fn new(
mgr: Arc<SessionManager>,
cfg: SupervisorConfig,
monitor: Option<ActivityMonitor<C>>,
) -> Self {
Self {
mgr,
cfg,
monitor: monitor.map(Arc::new),
stats: SupervisorRunStats::default(),
auto_resume_path: auto_resume::desired_path(&FrameworkPaths::default()),
last_override: None,
metrics_path: publish::metrics_path(&FrameworkPaths::default()),
last_pr_cleanup: None,
fleet_sweep: watchdog::DetachedSweep::new("fleet sweep"),
cleanup_sweep: watchdog::DetachedSweep::new("post-merge cleanup sweep"),
cleanup: Arc::new(|mgr| {
Box::pin(async move { pr_cleanup_tick::run_sweep(&mgr).await })
}),
}
}
#[cfg(test)]
#[must_use]
pub(crate) fn with_cleanup(mut self, cleanup: CleanupFn) -> Self {
self.cleanup = cleanup;
self
}
#[must_use]
pub fn with_auto_resume_path(mut self, path: impl Into<PathBuf>) -> Self {
self.auto_resume_path = path.into();
self
}
#[must_use]
pub fn with_metrics_path(mut self, path: impl Into<PathBuf>) -> Self {
self.metrics_path = path.into();
self
}
pub fn metrics_path(&self) -> &std::path::Path {
&self.metrics_path
}
fn resolve_auto_resume(&mut self) -> bool {
match auto_resume::read_override_at(&self.auto_resume_path) {
Ok(Some(desired)) => {
if self.last_override != Some(desired) {
info!(
desired,
path = %self.auto_resume_path.display(),
"supervisor: auto-resume desired state applied from console"
);
self.last_override = Some(desired);
}
desired
}
Ok(None) => {
if self.last_override.is_some() {
info!(
boot_flag = self.cfg.auto_resume,
path = %self.auto_resume_path.display(),
"supervisor: auto-resume override removed; reverting to boot flag"
);
self.last_override = None;
}
self.cfg.auto_resume
}
Err(e) => {
let held = self.last_override.unwrap_or(self.cfg.auto_resume);
error!(
path = %self.auto_resume_path.display(),
auto_resume = held,
"supervisor: cannot read auto-resume desired state: {e}; \
holding the last known value (NOT failing open to off)"
);
held
}
}
}
pub fn stats(&self) -> &SupervisorRunStats {
&self.stats
}
pub async fn tick(&mut self) -> TickReport {
let cfg = SupervisorConfig {
auto_resume: self.resolve_auto_resume(),
..self.cfg.clone()
};
let (mgr, monitor) = (Arc::clone(&self.mgr), self.monitor.clone());
let sweep = move || async move { run_tick(&mgr, &cfg, monitor.as_deref()).await };
let Some(report) = self.fleet_sweep.run(self.cfg.tick_timeout, sweep).await else {
self.stats.sweeps_abandoned += 1;
self.stats.consecutive_sweeps_abandoned += 1;
return TickReport::default();
};
self.stats.consecutive_sweeps_abandoned = 0;
self.pr_cleanup_tick().await;
self.stats.sweeps += 1;
self.stats.auto_resumed += report.resumed.len() as u64;
self.stats.resume_failures += report.resume_failures as u64;
self.stats.classified += report.classified as u64;
report
}
async fn pr_cleanup_tick(&mut self) {
let now = std::time::Instant::now();
if !pr_cleanup_tick::due(self.cfg.pr_cleanup_interval, self.last_pr_cleanup, now) {
return;
}
self.last_pr_cleanup = Some(now);
let (mgr, cleanup) = (Arc::clone(&self.mgr), Arc::clone(&self.cleanup));
let sweep = move || cleanup(mgr);
let limit = self.cfg.tick_timeout;
let Some(cleaned) = self.cleanup_sweep.run(limit, sweep).await else {
self.stats.cleanup_sweeps_abandoned += 1;
return;
};
if cleaned > 0 {
info!(cleaned, "supervisor: post-merge cleanup swept merged PRs");
}
}
pub async fn snapshot(&self) -> FleetMetrics {
let records = self.mgr.list().await;
let mut m = FleetMetrics::from_records(&records);
m.run_stats = self.stats.clone();
m
}
pub async fn publish_snapshot(&self) {
let read = self.snapshot();
let Some(snapshot) =
watchdog::bounded(self.cfg.tick_timeout, "metrics snapshot", read).await
else {
return;
};
if let Err(e) = publish::write_at(
&self.metrics_path,
&snapshot,
self.cfg.interval,
chrono::Utc::now(),
) {
error!(
path = %self.metrics_path.display(),
"supervisor: cannot publish metrics snapshot: {e}; \
the console will report it as unavailable/stale"
);
}
}
pub async fn run(self) -> anyhow::Result<()> {
self.run_until(shutdown_signal()).await
}
pub async fn run_until(mut self, shutdown: impl Future<Output = ()>) -> anyhow::Result<()> {
let boot_auto_resume = self.resolve_auto_resume();
info!(
interval_secs = self.cfg.interval.as_secs(),
auto_resume = boot_auto_resume,
auto_resume_env = self.cfg.auto_resume,
auto_resume_path = %self.auto_resume_path.display(),
classify_idle = self.cfg.classify_idle,
"supervisor loop starting"
);
if !boot_auto_resume {
warn!(
"supervisor: auto-resume DISABLED ({}=1, or the console toggle writing {}, \
to enable); running as observe-only",
config::ENV_AUTO_RESUME,
self.auto_resume_path.display()
);
}
let mut timer = tokio::time::interval(self.cfg.interval);
self.publish_snapshot().await;
let watchdog =
watchdog::spawn_heartbeat_watchdog(self.metrics_path.clone(), self.cfg.interval);
let mut exit_guard = watchdog::LoopExitGuard::new(Some(watchdog));
let mut shutdown = std::pin::pin!(shutdown);
loop {
tokio::select! {
biased;
() = &mut shutdown => {
exit_guard.disarm();
info!(
sweeps = self.stats.sweeps,
auto_resumed = self.stats.auto_resumed,
"supervisor received shutdown signal; stopping cleanly"
);
return Ok(());
}
_ = timer.tick() => {
self.tick().await;
self.publish_snapshot().await;
}
}
}
}
}
async fn shutdown_signal() {
let ctrl_c = async {
if let Err(e) = tokio::signal::ctrl_c().await {
warn!("failed to listen for Ctrl-C: {e}");
}
};
#[cfg(unix)]
let terminate = async {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut sig) => {
sig.recv().await;
}
Err(e) => warn!("failed to install SIGTERM handler: {e}"),
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
() = ctrl_c => {},
() = terminate => {},
}
}