pub mod config;
pub mod metrics;
pub mod poller;
pub mod pr_cleanup_tick;
pub mod publish;
#[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<ActivityMonitor<C>>,
stats: SupervisorRunStats,
auto_resume_path: PathBuf,
last_override: Option<bool>,
metrics_path: PathBuf,
last_pr_cleanup: Option<std::time::Instant>,
}
impl<C: LlmClassifier> Supervisor<C> {
pub fn new(
mgr: Arc<SessionManager>,
cfg: SupervisorConfig,
monitor: Option<ActivityMonitor<C>>,
) -> Self {
Self {
mgr,
cfg,
monitor,
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,
}
}
#[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 report = run_tick(&self.mgr, &cfg, self.monitor.as_ref()).await;
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 cleaned = pr_cleanup_tick::run_sweep(&self.mgr).await;
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 snapshot = self.snapshot().await;
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 mut shutdown = std::pin::pin!(shutdown);
loop {
tokio::select! {
biased;
() = &mut shutdown => {
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 => {},
}
}