use std::sync::Arc;
use tokio::sync::Mutex;
use super::heal_claims::ClaimStore;
use super::heal_config::HealConfig;
use super::heal_config::HEAL_CONFIG_FILE;
use super::heal_tick::{tick, ClaimSink, TickIo, TickOutcome};
use crate::session::ServerState;
pub const DEFAULT_ENGINE: &str = "foreman";
pub const HEAL_INTERVAL_ENV: &str = "CAR_HEAL_INTERVAL_SECS";
pub const DEFAULT_INTERVAL_SECS: u64 = 15 * 60;
pub struct HealService {
config: std::sync::RwLock<HealConfig>,
config_dir: Option<std::path::PathBuf>,
running: Mutex<()>,
state_dir: std::path::PathBuf,
interval_secs: u64,
assembly_error: std::sync::RwLock<Option<String>>,
run_refusal: std::sync::RwLock<Option<String>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
pub struct SweepReport {
pub outcomes: Vec<(String, TickOutcome)>,
pub skipped_overlap: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct HealStatus {
pub config_path: String,
pub enabled: bool,
pub cadence_secs: u64,
pub disabled_reason: Option<String>,
pub targets: Vec<String>,
pub rejected: Vec<RejectedTargetView>,
pub review_models: Vec<String>,
pub panel: Vec<super::heal_review::PanelSeat>,
pub panel_warning: Option<String>,
pub run_refusal: Option<String>,
pub coder_pin: Option<CoderPinView>,
pub engine: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct CoderPinView {
pub model: String,
pub source: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct RejectedTargetView {
pub repo: String,
pub reason: String,
}
impl HealService {
pub fn with_config_dir(
config: HealConfig,
state_dir: std::path::PathBuf,
config_dir: std::path::PathBuf,
) -> Self {
let mut s = Self::new(config, state_dir);
s.config_dir = Some(config_dir);
s
}
fn reload(&self) -> HealConfig {
let Some(dir) = &self.config_dir else {
return self.held();
};
let fresh = HealConfig::load(dir);
if let Ok(mut held) = self.config.write() {
*held = fresh.clone();
}
fresh
}
fn held(&self) -> HealConfig {
match self.config.read() {
Ok(c) => c.clone(),
Err(_) => HealConfig::default(),
}
}
fn config(&self) -> HealConfig {
self.held()
}
pub fn new(config: HealConfig, state_dir: std::path::PathBuf) -> Self {
let interval_secs = std::env::var(HEAL_INTERVAL_ENV)
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|v| *v > 0)
.unwrap_or(DEFAULT_INTERVAL_SECS);
Self {
config: std::sync::RwLock::new(config),
config_dir: None,
running: Mutex::new(()),
state_dir,
interval_secs,
assembly_error: std::sync::RwLock::new(None),
run_refusal: std::sync::RwLock::new(None),
}
}
pub fn record_assembly_error(&self, error: &str) {
if let Ok(mut slot) = self.assembly_error.write() {
*slot = Some(error.to_string());
}
}
fn resolved_coder_pin(config: &HealConfig) -> Option<(String, super::config::PinSource)> {
let coder_toml = config
.coder_model
.is_none()
.then(super::config::CoderConfig::load);
super::config::session_model(
config.coder_model.as_deref(),
coder_toml.as_ref().and_then(|c| c.model.as_deref()),
)
.map(|(m, src)| (m.to_string(), src))
}
fn set_run_refusal(&self, error: Option<&str>) {
if let Ok(mut slot) = self.run_refusal.write() {
*slot = error.map(str::to_string);
}
}
pub fn is_enabled(&self) -> bool {
self.config().is_enabled()
}
pub fn interval_secs(&self) -> u64 {
self.interval_secs
}
pub fn rejected(&self) -> Vec<super::heal_config::RejectedTarget> {
self.config().rejected
}
pub fn status(&self, engine: &Arc<car_inference::InferenceEngine>) -> HealStatus {
let config = self.reload();
let panel = self.panel_composition(engine, &config.review_models);
let coder_pin = Self::resolved_coder_pin(&config);
HealStatus {
config_path: self
.config_dir
.as_ref()
.map(|d| d.join(HEAL_CONFIG_FILE).display().to_string())
.unwrap_or_else(|| "(supplied directly; no file)".into()),
enabled: config.is_enabled(),
cadence_secs: self.interval_secs,
disabled_reason: config.disabled_reason().map(str::to_string).or_else(|| {
self.assembly_error
.read()
.ok()
.and_then(|e| e.clone())
.map(|e| format!("the loop could not be assembled: {e}"))
}),
run_refusal: self.run_refusal.read().ok().and_then(|e| e.clone()),
targets: config.targets.iter().map(|t| t.repo.clone()).collect(),
rejected: config
.rejected
.iter()
.map(|r| RejectedTargetView {
repo: r.repo.clone(),
reason: r.reason.clone(),
})
.collect(),
review_models: config.review_models.clone(),
panel: panel.clone(),
panel_warning: super::heal_review::correlation_warning(&panel),
coder_pin: coder_pin.map(|(model, source)| CoderPinView {
model,
source: match source {
super::config::PinSource::Request => "heal_toml",
super::config::PinSource::Config => "coder_toml",
}
.to_string(),
}),
engine: config
.engine
.clone()
.unwrap_or_else(|| DEFAULT_ENGINE.to_string()),
}
}
fn panel_composition(
&self,
engine: &std::sync::Arc<car_inference::InferenceEngine>,
models: &[String],
) -> Vec<super::heal_review::PanelSeat> {
super::heal_review::composition(models, |m| {
engine
.model_schema(m)
.and_then(|s| s.vendor())
.map(str::to_string)
})
}
pub fn live_io(&self, state: &Arc<ServerState>) -> Result<Arc<dyn TickIo>, String> {
self.live_io_for(state, &self.config())
}
fn live_io_for(
&self,
state: &Arc<ServerState>,
config: &HealConfig,
) -> Result<Arc<dyn TickIo>, String> {
if config.review_models.is_empty() {
return Err("no `review_models` configured; refusing to run without a panel".into());
}
let engine_handle = crate::handler::get_inference_engine(state);
let unknown: Vec<&str> = config
.review_models
.iter()
.chain(config.coder_model.iter())
.filter(|m| !engine_handle.knows_model(m))
.map(String::as_str)
.collect();
if !unknown.is_empty() {
return Err(format!(
"unknown model(s) in `heal.toml`: {} — a seat that cannot be reached is \
not a reviewer, and dropping it would quietly shrink the panel. Run \
`car models list` for the names this daemon knows.",
unknown.join(", ")
));
}
let coder_pin = Self::resolved_coder_pin(config);
check_coder_pin(
coder_pin.as_ref().map(|(m, src)| (m.as_str(), *src)),
&config.review_models,
&canonicalizer(|m| engine_handle.model_schema(m).map(|s| s.id.clone())),
)?;
let engine = match config.engine.as_deref() {
Some(name) => super::router::EngineChoice::parse(name)?,
None => super::router::EngineChoice::parse(DEFAULT_ENGINE)?,
};
let panel = self.panel_composition(engine_handle, &config.review_models);
if let Some(error) = super::heal_review::panel_diversity_error(&panel) {
return Err(error);
}
if let Some(warning) = super::heal_review::correlation_warning(&panel) {
tracing::warn!(target: "car::heal", "{warning}");
}
let mut routing_exclusions = Vec::new();
for seat in &config.review_models {
if let Some(name) = engine_handle.model_schema(seat).map(|schema| &schema.name) {
if !routing_exclusions.contains(name) {
routing_exclusions.push(name.clone());
}
}
}
let runner = super::heal_runner::LiveCoderRunner {
state: state.clone(),
generator: crate::handler::get_inference_engine(state).clone(),
state_dir: self.state_dir.clone(),
reviewers: super::heal_review::panel(state, &config.review_models),
max_wall_secs: super::heal_runner::DEFAULT_ITEM_WALL_SECS,
max_iterations: None,
engine,
model: coder_pin.map(|(m, _)| m),
routing_exclusions,
canonical_model: {
let engine_handle = engine_handle.clone();
Arc::new(canonicalizer(move |m: &str| {
engine_handle.model_schema(m).map(|s| s.id.clone())
}))
},
github: Arc::new(super::merge::GhCli::default()),
};
Ok(Arc::new(super::heal_live::LiveTickIo {
issues: Arc::new(super::fix_issues::GhIssues),
prs: Arc::new(super::heal_intake::GhPullRequests),
oracle: Arc::new(super::provenance::GhPermissions),
coder: Arc::new(runner),
local_signatures: super::provenance::LocalSignatures::from_proposals(&[]),
redactor: car_selfheal::redact::Redactor::from_env(std::env::vars()),
panel,
}))
}
pub async fn run_tick(&self, state: &Arc<ServerState>) -> Result<SweepReport, String> {
let config = self.reload();
let io = match self.live_io_for(state, &config) {
Ok(io) => {
self.set_run_refusal(None);
io
}
Err(e) => {
self.set_run_refusal(Some(&e));
return Err(e);
}
};
Ok(self.sweep_with(&io, &config).await)
}
pub async fn sweep(&self, io: &Arc<dyn TickIo>) -> SweepReport {
let config = self.reload();
self.sweep_with(io, &config).await
}
async fn sweep_with(&self, io: &Arc<dyn TickIo>, config: &HealConfig) -> SweepReport {
let Ok(_guard) = self.running.try_lock() else {
return SweepReport {
outcomes: Vec::new(),
skipped_overlap: true,
};
};
let mut claims = ClaimStore::load(&self.state_dir);
let mut outcomes = Vec::new();
let sink = FileClaimSink {
dir: self.state_dir.clone(),
};
for target in &config.targets {
let run_id = format!("heal-{}", uuid::Uuid::new_v4().simple());
let out = tick(io, target, &mut claims, &run_id, &sink).await;
sink.persist(&claims, io.now_ms());
outcomes.push((target.repo.clone(), out));
}
SweepReport {
outcomes,
skipped_overlap: false,
}
}
}
struct FileClaimSink {
dir: std::path::PathBuf,
}
impl ClaimSink for FileClaimSink {
fn persist(&self, claims: &ClaimStore, now_ms: u64) {
if let Err(e) = claims.save(&self.dir, now_ms) {
tracing::warn!(error = %e, "could not persist heal claims");
}
}
}
pub fn spawn_heal_cadence(state: Arc<ServerState>) -> Option<tokio::task::JoinHandle<()>> {
let service = state.heal.clone();
if !service.is_enabled() {
tracing::info!(
reason = service
.config()
.disabled_reason()
.unwrap_or("no targets configured"),
"self-healing loop not started"
);
return None;
}
for r in service.rejected() {
tracing::warn!(repo = %r.repo, reason = %r.reason, "heal target ignored");
}
let io = match service.live_io(&state) {
Ok(io) => io,
Err(e) => {
service.record_assembly_error(&e);
tracing::warn!(error = %e, "self-healing loop could not be assembled; not starting");
return None;
}
};
let secs = service.interval_secs();
tracing::info!(interval_secs = secs, "self-healing loop started");
Some(tokio::spawn(async move {
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(secs));
ticker.tick().await;
loop {
ticker.tick().await;
let report = service.sweep(&io).await;
if report.skipped_overlap {
tracing::debug!("heal sweep skipped: previous sweep still running");
continue;
}
for (repo, out) in &report.outcomes {
match out {
TickOutcome::Opened {
number,
pr_url,
ci,
delivery,
..
} => tracing::info!(
%repo,
number,
%pr_url,
head_sha = %ci.head_sha,
ci_state = ?ci.state,
%delivery,
"self-heal opened a pull request"
),
TickOutcome::Rejected { number, gate, .. } => {
tracing::info!(%repo, number, %gate, "self-heal stopped at the gate")
}
TickOutcome::Failed { detail } => {
tracing::warn!(%repo, %detail, "self-heal tick failed")
}
TickOutcome::Idle { .. } => {}
}
}
}
}))
}
fn canonicalizer(resolve: impl Fn(&str) -> Option<String>) -> impl Fn(&str) -> String {
move |m| resolve(m).unwrap_or_else(|| m.to_string())
}
fn check_coder_pin(
pin: Option<(&str, super::config::PinSource)>,
seats: &[String],
canonical: &dyn Fn(&str) -> String,
) -> Result<(), String> {
let Some((coder, source)) = pin else {
return Ok(());
};
let Some(seat) = super::heal_review::coder_on_panel(coder, seats, canonical) else {
return Ok(());
};
Err(format!(
"the coder model {} ({}) is also a review seat ({}) — a model cannot review its \
own output, and counting it as a reviewer reports an independence the panel does \
not have. Remove it from `review_models`, or pin a different coder.",
coder,
match source {
super::config::PinSource::Request => "`coder_model` in `heal.toml`",
super::config::PinSource::Config => "`model` in `coder.toml`",
},
seat
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::coder::heal_intake::{Checkout, HealTarget};
struct NoopIo;
#[async_trait::async_trait]
impl TickIo for NoopIo {
async fn candidates(
&self,
_t: &HealTarget,
) -> Result<Vec<crate::coder::heal_select::Candidate>, String> {
Ok(vec![])
}
async fn open_prs(
&self,
_t: &HealTarget,
) -> Result<Vec<crate::coder::heal_intake::RawPullRequest>, String> {
Ok(vec![])
}
async fn intent_for(
&self,
_i: &crate::coder::heal_select::Candidate,
) -> Result<crate::coder::heal_tick::Intent, String> {
Ok(crate::coder::heal_tick::Intent::Gone)
}
fn redact(&self, text: &str) -> String {
text.to_string()
}
async fn run_coder(
&self,
_t: &HealTarget,
_i: &crate::coder::heal_select::Candidate,
_s: &crate::coder::provenance::SessionSeed,
) -> Result<crate::coder::heal_tick::Attempt, crate::coder::heal_tick::RunFailure> {
Err(crate::coder::heal_tick::RunFailure::early("not reached"))
}
async fn deliver(
&self,
_t: &HealTarget,
_i: &crate::coder::heal_select::Candidate,
_s: &str,
_g: &crate::coder::heal_gate::GateOutcome,
) -> Result<crate::coder::merge::PrDeliveryOutcome, crate::coder::heal_tick::DeliverRefusal>
{
Err(crate::coder::heal_tick::DeliverRefusal::retriable(
"not reached",
))
}
async fn abandon(&self, _s: &str) {}
async fn comment(
&self,
_i: &crate::coder::heal_select::Candidate,
_t: &str,
) -> Result<(), String> {
Ok(())
}
fn now_ms(&self) -> u64 {
1_000_000
}
}
fn cfg(targets: Vec<HealTarget>) -> HealConfig {
HealConfig {
targets,
rejected: vec![],
review_models: vec!["reviewer-a".into()],
engine: None,
coder_model: None,
}
}
fn target(repo: &str) -> HealTarget {
HealTarget {
repo: repo.into(),
fix_repo: None,
checkout: Some(Checkout::Project("p".into())),
label: "self-heal".into(),
base: "main".into(),
}
}
fn engine() -> Arc<car_inference::InferenceEngine> {
Arc::new(car_inference::InferenceEngine::new(Default::default()))
}
#[test]
fn no_targets_means_the_loop_is_disabled() {
let dir = tempfile::tempdir().unwrap();
let s = HealService::new(cfg(vec![]), dir.path().into());
assert!(!s.is_enabled());
assert_eq!(
s.status(&engine()).disabled_reason.as_deref(),
Some("no usable targets are configured")
);
}
fn canon(m: &str) -> String {
canonicalizer(|m: &str| (m == "alias").then(|| "vendor/real".to_string()))(m)
}
fn seats() -> Vec<String> {
vec!["gpt-5.6".into(), "vendor/real".into(), "gpt-5.4".into()]
}
fn check(heal: Option<&str>, coder_toml: Option<&str>) -> Result<(), String> {
check_coder_pin(
super::super::config::session_model(heal, coder_toml),
&seats(),
&canon,
)
}
#[test]
fn a_coder_toml_pin_that_is_a_review_seat_is_refused() {
let err = check(None, Some("gpt-5.6")).expect_err("coder.toml pinned a seat");
assert!(err.contains("gpt-5.6"), "{err}");
assert!(err.contains("`model` in `coder.toml`"), "{err}");
assert!(!err.contains("`coder_model` in `heal.toml`"), "{err}");
}
#[test]
fn a_coder_toml_pin_is_matched_through_the_registry_not_by_spelling() {
let err = check(None, Some("alias")).expect_err("alias canonicalizes onto vendor/real");
assert!(err.contains("vendor/real"), "{err}");
}
#[test]
fn the_heal_toml_pin_wins_and_is_the_one_checked() {
let err = check(Some("gpt-5.4"), Some("claude-sonnet-5")).expect_err("heal.toml pinned");
assert!(err.contains("`coder_model` in `heal.toml`"), "{err}");
check(Some("claude-sonnet-5"), Some("gpt-5.6")).expect("the pin that runs is not a seat");
}
#[test]
fn a_blank_heal_pin_falls_through_and_the_message_names_coder_toml() {
let err = check(Some(" "), Some("gpt-5.6")).expect_err("blank falls through");
assert!(err.contains("`model` in `coder.toml`"), "{err}");
}
#[test]
fn an_unpinned_coder_is_not_this_checks_to_refuse() {
check(None, None).expect("unpinned is not a seat");
}
#[test]
fn a_pin_outside_the_registry_is_compared_not_refused() {
check(None, Some("codex-mini")).expect("an unknown pin is not by itself a refusal");
let err = check(None, Some("gpt-5.4")).expect_err("spelling still matches a seat");
assert!(err.contains("gpt-5.4"), "{err}");
}
#[test]
fn targets_without_a_review_panel_do_not_enable_the_loop() {
let dir = tempfile::tempdir().unwrap();
let mut c = cfg(vec![target("acme/one")]);
c.review_models.clear();
let s = HealService::new(c, dir.path().into());
assert!(!s.is_enabled());
assert!(s
.status(&engine())
.disabled_reason
.unwrap()
.contains("review_models"));
}
#[test]
fn status_resolves_the_panel_and_reports_a_correlated_one() {
let dir = tempfile::tempdir().unwrap();
let mut c = cfg(vec![target("acme/one")]);
c.review_models = vec!["gpt-5.4".into(), "gpt-5.5".into()];
let s = HealService::new(c, dir.path().into());
let st = s.status(&engine());
assert_eq!(
st.panel
.iter()
.map(|p| p.model.as_str())
.collect::<Vec<_>>(),
vec!["gpt-5.4", "gpt-5.5"]
);
assert!(
st.panel
.iter()
.all(|p| p.vendor.as_deref() == Some("openai")),
"both seats must resolve to openai: {:?}",
st.panel
);
let w = st
.panel_warning
.expect("a one-vendor panel must be reported");
assert!(w.contains("openai serves 2 of the 2 seats"), "{w}");
}
#[test]
fn status_does_not_warn_about_a_panel_spanning_vendors() {
let dir = tempfile::tempdir().unwrap();
let mut c = cfg(vec![target("acme/one")]);
c.review_models = vec!["gpt-5.4".into(), "claude-opus-5".into()];
let s = HealService::new(c, dir.path().into());
let st = s.status(&engine());
assert_eq!(
st.panel
.iter()
.filter_map(|p| p.vendor.as_deref())
.collect::<Vec<_>>(),
vec!["openai", "anthropic"]
);
assert_eq!(st.panel_warning, None);
}
#[test]
fn assembly_refuses_a_three_seat_single_provider_panel() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(ServerState::standalone(dir.path().join("journal")));
let mut c = cfg(vec![target("acme/one")]);
c.review_models = vec!["gpt-5.4".into(), "gpt-5.5".into(), "gpt-5.6-sol".into()];
c.coder_model = Some("claude-opus-5".into());
let s = HealService::new(c.clone(), dir.path().join("coder"));
let error = s
.live_io_for(&state, &c)
.err()
.expect("one serving provider must refuse assembly");
assert!(error.contains("openai"), "{error}");
for model in &c.review_models {
assert!(error.contains(model), "{model} is missing from: {error}");
}
}
#[test]
fn assembly_refuses_a_coder_that_is_also_a_review_seat() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(ServerState::standalone(dir.path().join("journal")));
let mut c = cfg(vec![target("acme/one")]);
c.review_models = vec!["gpt-5.4".into(), "claude-opus-5".into()];
c.coder_model = Some("gpt-5.4".into());
let s = HealService::new(c.clone(), dir.path().join("coder"));
let error = s
.live_io_for(&state, &c)
.err()
.expect("a coder on its panel must refuse assembly");
assert!(error.contains("gpt-5.4"), "{error}");
assert!(error.contains("also a review seat"), "{error}");
}
#[test]
fn assembly_accepts_a_two_provider_panel_with_a_disjoint_coder() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(ServerState::standalone(dir.path().join("journal")));
let mut c = cfg(vec![target("acme/one")]);
c.review_models = vec!["gpt-5.4".into(), "claude-opus-5".into()];
c.coder_model = Some("gpt-5.5".into());
let s = HealService::new(c.clone(), dir.path().join("coder"));
assert!(s.live_io_for(&state, &c).is_ok());
}
#[test]
fn an_assembly_failure_is_reported_as_the_disabled_reason() {
let dir = tempfile::tempdir().unwrap();
let s = HealService::new(cfg(vec![target("acme/one")]), dir.path().into());
assert_eq!(s.status(&engine()).disabled_reason, None);
s.record_assembly_error("`coder_model` x is also a review seat (x)");
let st = s.status(&engine());
assert!(
st.enabled,
"the config is still valid; the assembly was not"
);
assert!(
st.disabled_reason
.as_deref()
.is_some_and(|r| r.contains("also a review seat")),
"{:?}",
st.disabled_reason
);
}
#[test]
fn status_reports_the_coder_pin_from_heal_toml() {
let dir = tempfile::tempdir().unwrap();
let mut c = cfg(vec![target("acme/one")]);
c.coder_model = Some("gpt-5.5".into());
let s = HealService::new(c, dir.path().into());
let pin = s
.status(&engine())
.coder_pin
.expect("a pinned coder must be reported");
assert_eq!(pin.model, "gpt-5.5");
assert_eq!(pin.source, "heal_toml");
}
#[test]
fn status_falls_through_to_coder_toml_and_says_so() {
let _guard = crate::coder::config::config_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("coder.toml");
std::fs::write(&cfg_path, "[coder]\nmodel = \"claude-opus-5\"\n").unwrap();
let prev = std::env::var_os("CAR_CODER_CONFIG");
std::env::set_var("CAR_CODER_CONFIG", &cfg_path);
let mut c = cfg(vec![target("acme/one")]);
c.coder_model = None;
let st = HealService::new(c, dir.path().into()).status(&engine());
match prev {
Some(v) => std::env::set_var("CAR_CODER_CONFIG", v),
None => std::env::remove_var("CAR_CODER_CONFIG"),
}
let pin = st.coder_pin.expect("coder.toml pins it");
assert_eq!(pin.model, "claude-opus-5");
assert_eq!(pin.source, "coder_toml");
}
#[test]
fn status_reports_no_pin_when_neither_file_names_one() {
let _guard = crate::coder::config::config_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("coder.toml");
std::fs::write(&cfg_path, "[coder]\ndefault_max_iterations = 3\n").unwrap();
let prev = std::env::var_os("CAR_CODER_CONFIG");
std::env::set_var("CAR_CODER_CONFIG", &cfg_path);
let mut c = cfg(vec![target("acme/one")]);
c.coder_model = None;
let st = HealService::new(c, dir.path().into()).status(&engine());
match prev {
Some(v) => std::env::set_var("CAR_CODER_CONFIG", v),
None => std::env::remove_var("CAR_CODER_CONFIG"),
}
assert_eq!(st.coder_pin, None);
}
#[tokio::test]
async fn a_manual_run_records_its_assembly_refusal() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(ServerState::standalone(dir.path().join("journal")));
let mut c = cfg(vec![target("acme/one")]);
c.review_models = vec!["gpt-5.4".into(), "claude-opus-5".into()];
c.coder_model = Some("gpt-5.4".into());
let s = HealService::new(c, dir.path().join("coder"));
assert_eq!(
s.status(&engine()).run_refusal,
None,
"nothing has refused yet"
);
let err = s
.run_tick(&state)
.await
.expect_err("a coder on its panel must refuse");
assert!(err.contains("also a review seat"), "{err}");
let st = s.status(&engine());
assert!(
st.run_refusal
.as_deref()
.is_some_and(|r| r.contains("also a review seat")),
"the refusal must reach heal.status, not just the caller: {:?}",
st.run_refusal
);
assert_eq!(
st.disabled_reason, None,
"the cadence was never assembled here, and a manual refusal is not \
a statement that the loop is disabled"
);
}
#[tokio::test]
async fn a_successful_manual_run_clears_the_refusal() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(ServerState::standalone(dir.path().join("journal")));
let mut bad = cfg(vec![target("acme/one")]);
bad.review_models = vec!["gpt-5.4".into(), "claude-opus-5".into()];
bad.coder_model = Some("gpt-5.4".into());
let s = HealService::new(bad, dir.path().join("coder"));
s.run_tick(&state)
.await
.expect_err("a coder on its panel refuses");
assert!(s.status(&engine()).run_refusal.is_some());
let mut good = cfg(vec![target("acme/one")]);
good.review_models = vec!["gpt-5.4".into(), "claude-opus-5".into()];
good.coder_model = Some("gpt-5.5".into());
if let Ok(mut held) = s.config.write() {
*held = good;
}
s.run_tick(&state)
.await
.expect("the fixed config assembles");
assert_eq!(
s.status(&engine()).run_refusal,
None,
"a run that assembled must clear the refusal it is contradicting"
);
}
#[tokio::test]
async fn a_manual_run_neither_sets_nor_clears_the_boot_assembly_error() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(ServerState::standalone(dir.path().join("journal")));
let mut c = cfg(vec![target("acme/one")]);
c.review_models = vec!["gpt-5.4".into(), "claude-opus-5".into()];
c.coder_model = Some("gpt-5.4".into());
let s = HealService::new(c, dir.path().join("coder"));
s.record_assembly_error("boot: something the cadence could not assemble");
s.run_tick(&state).await.expect_err("still refuses");
let st = s.status(&engine());
assert!(
st.disabled_reason
.as_deref()
.is_some_and(|r| r.contains("boot: something")),
"a manual run must not overwrite the boot error: {:?}",
st.disabled_reason
);
assert!(st.run_refusal.is_some(), "and must record its own");
let mut good = cfg(vec![target("acme/one")]);
good.review_models = vec!["gpt-5.4".into(), "claude-opus-5".into()];
good.coder_model = Some("gpt-5.5".into());
if let Ok(mut held) = s.config.write() {
*held = good;
}
s.run_tick(&state).await.expect("assembles now");
let st = s.status(&engine());
assert_eq!(st.run_refusal, None);
assert!(
st.disabled_reason
.as_deref()
.is_some_and(|r| r.contains("boot: something")),
"the cadence is still dead until restart: {:?}",
st.disabled_reason
);
}
#[test]
fn status_names_the_targets_the_panel_and_the_engine() {
let dir = tempfile::tempdir().unwrap();
let s = HealService::new(cfg(vec![target("acme/one")]), dir.path().into());
let st = s.status(&engine());
assert!(st.enabled);
assert_eq!(st.disabled_reason, None);
assert_eq!(st.targets, vec!["acme/one".to_string()]);
assert_eq!(st.review_models, vec!["reviewer-a".to_string()]);
assert_eq!(st.engine, DEFAULT_ENGINE);
}
#[test]
fn the_default_engine_is_a_real_engine() {
assert!(crate::coder::router::EngineChoice::parse(DEFAULT_ENGINE).is_ok());
}
#[tokio::test]
async fn a_sweep_visits_every_target_not_just_the_first() {
let dir = tempfile::tempdir().unwrap();
let s = HealService::new(
cfg(vec![target("acme/one"), target("acme/two")]),
dir.path().into(),
);
let io: Arc<dyn TickIo> = Arc::new(NoopIo);
let report = s.sweep(&io).await;
assert_eq!(report.outcomes.len(), 2);
assert_eq!(report.outcomes[0].0, "acme/one");
assert_eq!(report.outcomes[1].0, "acme/two");
}
#[tokio::test]
async fn an_overlapping_sweep_stands_down_rather_than_queueing() {
let dir = tempfile::tempdir().unwrap();
let s = Arc::new(HealService::new(
cfg(vec![target("acme/one")]),
dir.path().into(),
));
let io: Arc<dyn TickIo> = Arc::new(NoopIo);
let held = s.running.lock().await;
let report = s.sweep(&io).await;
assert!(report.skipped_overlap);
assert!(report.outcomes.is_empty());
drop(held);
assert!(!s.sweep(&io).await.skipped_overlap);
}
#[tokio::test]
async fn the_ledger_is_written_even_when_nothing_was_claimed() {
let dir = tempfile::tempdir().unwrap();
let s = HealService::new(cfg(vec![target("acme/one")]), dir.path().into());
let io: Arc<dyn TickIo> = Arc::new(NoopIo);
let _ = s.sweep(&io).await;
assert!(
dir.path().join("heal-claims.json").exists(),
"the sweep persists the ledger around every target"
);
}
#[test]
fn the_interval_can_be_overridden_but_never_to_zero() {
let dir = tempfile::tempdir().unwrap();
std::env::set_var(HEAL_INTERVAL_ENV, "0");
let s = HealService::new(cfg(vec![]), dir.path().into());
assert_eq!(s.interval_secs(), DEFAULT_INTERVAL_SECS);
std::env::set_var(HEAL_INTERVAL_ENV, "60");
let s = HealService::new(cfg(vec![]), dir.path().into());
assert_eq!(s.interval_secs(), 60);
std::env::remove_var(HEAL_INTERVAL_ENV);
}
}