#![allow(dead_code)]
use crate::admission::{AdmissionGate, AdmissionVerdict};
use crate::cancellation::CancellationOrchestrator;
use crate::disk_pressure::PressureState;
use crate::metrics;
use crate::process_triage::RemediationPipeline;
use crate::repo_convergence::RepoConvergenceService;
use crate::workers::WorkerState;
use rch_common::WorkerId;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tracing::debug;
const DEFAULT_QUARANTINE_THRESHOLD: f64 = 0.7;
const DEFAULT_RECOVERY_THRESHOLD: f64 = 0.3;
const DEFAULT_RECOVERY_TICKS: u32 = 3;
const DEFAULT_MIN_QUARANTINE_DURATION: Duration = Duration::from_secs(60);
const DEFAULT_PROBING_PENALTY: f64 = 0.5;
const DEFAULT_DEGRADED_PENALTY_FLOOR: f64 = 0.05;
const ASSESSMENT_STALENESS: Duration = Duration::from_secs(120);
#[derive(Debug, Clone)]
pub struct SignalWeights {
pub circuit: f64,
pub convergence: f64,
pub pressure: f64,
pub process: f64,
pub cancellation: f64,
}
impl Default for SignalWeights {
fn default() -> Self {
Self {
circuit: 0.30,
convergence: 0.22,
pressure: 0.22,
process: 0.13,
cancellation: 0.13,
}
}
}
impl SignalWeights {
fn normalized(&self) -> Self {
let sum =
self.circuit + self.convergence + self.pressure + self.process + self.cancellation;
if sum <= 0.0 {
return Self::default();
}
Self {
circuit: self.circuit / sum,
convergence: self.convergence / sum,
pressure: self.pressure / sum,
process: self.process / sum,
cancellation: self.cancellation / sum,
}
}
}
#[derive(Debug, Clone)]
pub struct ReliabilityConfig {
pub weights: SignalWeights,
pub quarantine_threshold: f64,
pub recovery_threshold: f64,
pub recovery_ticks: u32,
pub min_quarantine_duration: Duration,
pub probing_penalty: f64,
pub degraded_penalty_floor: f64,
}
impl Default for ReliabilityConfig {
fn default() -> Self {
Self {
weights: SignalWeights::default(),
quarantine_threshold: DEFAULT_QUARANTINE_THRESHOLD,
recovery_threshold: DEFAULT_RECOVERY_THRESHOLD,
recovery_ticks: DEFAULT_RECOVERY_TICKS,
min_quarantine_duration: DEFAULT_MIN_QUARANTINE_DURATION,
probing_penalty: DEFAULT_PROBING_PENALTY,
degraded_penalty_floor: DEFAULT_DEGRADED_PENALTY_FLOOR,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkerHealthState {
Healthy,
Degraded,
Quarantined,
ProbingRecovery,
}
impl std::fmt::Display for WorkerHealthState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Healthy => write!(f, "healthy"),
Self::Degraded => write!(f, "degraded"),
Self::Quarantined => write!(f, "quarantined"),
Self::ProbingRecovery => write!(f, "probing_recovery"),
}
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct SignalDebts {
pub circuit_debt: f64,
pub convergence_debt: f64,
pub pressure_debt: f64,
pub process_debt: f64,
pub cancellation_debt: f64,
}
#[derive(Debug, Clone)]
struct WorkerReliabilityTracker {
state: WorkerHealthState,
last_debt: f64,
last_signals: SignalDebts,
quarantined_at: Option<Instant>,
recovery_tick_count: u32,
last_evaluated_at: Option<Instant>,
}
impl Default for WorkerReliabilityTracker {
fn default() -> Self {
Self {
state: WorkerHealthState::Healthy,
last_debt: 0.0,
last_signals: SignalDebts::default(),
quarantined_at: None,
recovery_tick_count: 0,
last_evaluated_at: None,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ReliabilityAssessment {
pub health_state: WorkerHealthState,
pub aggregated_debt: f64,
pub penalty: f64,
pub hard_exclude: bool,
pub signals: SignalDebts,
}
pub struct ReliabilityAggregator {
config: ReliabilityConfig,
trackers: RwLock<HashMap<String, WorkerReliabilityTracker>>,
convergence: Option<Arc<RepoConvergenceService>>,
admission: Option<Arc<AdmissionGate>>,
remediation: Option<Arc<RemediationPipeline>>,
cancellation: Option<Arc<CancellationOrchestrator>>,
}
impl ReliabilityAggregator {
pub fn new(config: ReliabilityConfig) -> Self {
Self {
config,
trackers: RwLock::new(HashMap::new()),
convergence: None,
admission: None,
remediation: None,
cancellation: None,
}
}
pub fn set_convergence(&mut self, svc: Arc<RepoConvergenceService>) {
self.convergence = Some(svc);
}
pub fn set_admission(&mut self, gate: Arc<AdmissionGate>) {
self.admission = Some(gate);
}
pub fn set_remediation(&mut self, pipeline: Arc<RemediationPipeline>) {
self.remediation = Some(pipeline);
}
pub fn set_cancellation(&mut self, orch: Arc<CancellationOrchestrator>) {
self.cancellation = Some(orch);
}
pub async fn evaluate(&self, worker: &WorkerState, worker_id: &str) -> ReliabilityAssessment {
let evaluate_start = Instant::now();
debug!("Starting reliability evaluation for worker {}", worker_id);
let weights = self.config.weights.normalized();
let circuit_debt = {
let stats = worker.circuit_stats().await;
stats.error_rate().clamp(0.0, 1.0)
};
let convergence_debt = self.compute_convergence_debt(worker_id).await;
let pressure_debt = self.compute_pressure_debt(worker, worker_id).await;
let process_debt = self.compute_process_debt(worker_id).await;
let cancellation_debt = self.compute_cancellation_debt(worker_id).await;
let signals = SignalDebts {
circuit_debt,
convergence_debt,
pressure_debt,
process_debt,
cancellation_debt,
};
let aggregated_debt = (weights.circuit * circuit_debt
+ weights.convergence * convergence_debt
+ weights.pressure * pressure_debt
+ weights.process * process_debt
+ weights.cancellation * cancellation_debt)
.clamp(0.0, 1.0);
let (health_state, penalty, hard_exclude) = self
.transition_state(worker_id, aggregated_debt, &signals)
.await;
debug!(
"Worker {} reliability: state={}, debt={:.3}, penalty={:.3}, exclude={}, \
circuit={:.2}, convergence={:.2}, pressure={:.2}, process={:.2}, cancel={:.2}",
worker_id,
health_state,
aggregated_debt,
penalty,
hard_exclude,
circuit_debt,
convergence_debt,
pressure_debt,
process_debt,
cancellation_debt,
);
metrics::observe_reliability_decision(
"reliability_aggregate",
health_state_label(health_state),
evaluate_start.elapsed(),
);
if hard_exclude {
metrics::inc_reliability_error("reliability_aggregate", "quarantined");
}
ReliabilityAssessment {
health_state,
aggregated_debt,
penalty,
hard_exclude,
signals,
}
}
pub async fn get_assessment(&self, worker_id: &str) -> Option<ReliabilityAssessment> {
let trackers = self.trackers.read().await;
trackers.get(worker_id).map(|tracker| {
let (penalty, hard_exclude) = self.penalty_for_state(tracker.state, tracker.last_debt);
ReliabilityAssessment {
health_state: tracker.state,
aggregated_debt: tracker.last_debt,
penalty,
hard_exclude,
signals: tracker.last_signals.clone(),
}
})
}
pub async fn all_states(&self) -> HashMap<String, WorkerHealthState> {
let trackers = self.trackers.read().await;
trackers
.iter()
.map(|(id, t)| (id.clone(), t.state))
.collect()
}
pub async fn reset(&self, worker_id: &str) {
self.trackers.write().await.remove(worker_id);
}
async fn compute_convergence_debt(&self, worker_id: &str) -> f64 {
let start = Instant::now();
let Some(ref svc) = self.convergence else {
metrics::observe_reliability_decision(
"convergence_signal",
"missing_service",
start.elapsed(),
);
return 0.0; };
let wid = WorkerId::new(worker_id);
let drift_state = svc.get_drift_state(&wid).await;
let (debt, outcome) = match drift_state {
crate::repo_convergence::ConvergenceDriftState::Ready => (0.0, "ready"),
crate::repo_convergence::ConvergenceDriftState::Drifting => {
let debt = if let Some(ws) = svc.get_worker_state(&wid).await {
if ws.required_repos.is_empty() {
0.3
} else {
let missing = ws.missing_repos.len() as f64;
let total = ws.required_repos.len() as f64;
0.3 + (missing / total).clamp(0.0, 1.0) * 0.4
}
} else {
0.4 };
(debt, "drifting")
}
crate::repo_convergence::ConvergenceDriftState::Converging => (0.5, "converging"),
crate::repo_convergence::ConvergenceDriftState::Failed => (1.0, "failed"),
crate::repo_convergence::ConvergenceDriftState::Stale => (0.2, "stale"), };
metrics::observe_reliability_decision("convergence_signal", outcome, start.elapsed());
if outcome == "failed" {
metrics::inc_reliability_error("convergence_signal", "convergence_failed");
}
debt
}
async fn compute_pressure_debt(&self, worker: &WorkerState, worker_id: &str) -> f64 {
let start = Instant::now();
if let Some(ref admission) = self.admission
&& let Some(verdict) = admission.cached_verdict(worker_id).await
{
let (debt, outcome, error_kind) = match &verdict {
AdmissionVerdict::Admit {
pressure_penalty, ..
} if *pressure_penalty <= f64::EPSILON => (0.0, "healthy", None),
AdmissionVerdict::Admit {
pressure_penalty, ..
} => (pressure_penalty.clamp(0.0, 1.0), "warning", None),
AdmissionVerdict::Reject { reason_code, .. }
if reason_code == "admission_critical_pressure" =>
{
(1.0, "critical", Some("critical_pressure"))
}
AdmissionVerdict::Reject { .. } => {
(1.0, "rejected_by_policy", Some("preflight_blocked"))
}
};
metrics::observe_reliability_decision("pressure_signal", outcome, start.elapsed());
if let Some(error_kind) = error_kind {
metrics::inc_reliability_error("pressure_signal", error_kind);
}
return debt;
}
let pressure = worker.pressure_assessment().await;
let (debt, outcome) = match pressure.state {
PressureState::Healthy => (0.0, "healthy"),
PressureState::TelemetryGap => {
if pressure.reason_code.contains("not_evaluated") {
(0.0, "telemetry_gap")
} else {
(0.15, "telemetry_gap")
}
}
PressureState::Warning => (0.6, "warning"),
PressureState::Critical => (1.0, "critical"),
};
metrics::observe_reliability_decision("pressure_signal", outcome, start.elapsed());
if outcome == "critical" {
metrics::inc_reliability_error("pressure_signal", "critical_pressure");
}
debt
}
async fn compute_process_debt(&self, worker_id: &str) -> f64 {
let start = Instant::now();
let Some(ref pipeline) = self.remediation else {
metrics::observe_reliability_decision(
"process_signal",
"missing_service",
start.elapsed(),
);
return 0.0; };
let Some(state) = pipeline.worker_state(worker_id).await else {
metrics::observe_reliability_decision("process_signal", "clean", start.elapsed());
return 0.0; };
let hard_term_debt = (state.hard_terminations as f64 * 0.3).min(0.6);
let failure_debt = (state.consecutive_failures as f64 * 0.15).min(0.3);
let action_churn = (state.total_actions as f64 * 0.02).min(0.2);
let debt = (hard_term_debt + failure_debt + action_churn).clamp(0.0, 1.0);
let outcome = if debt < f64::EPSILON {
"clean"
} else {
"elevated"
};
metrics::observe_reliability_decision("process_signal", outcome, start.elapsed());
debt
}
async fn compute_cancellation_debt(&self, worker_id: &str) -> f64 {
let start = Instant::now();
let Some(ref orch) = self.cancellation else {
metrics::observe_reliability_decision(
"cancellation_signal",
"missing_service",
start.elapsed(),
);
return 0.0; };
let debt = orch.cancellation_debt(worker_id).await;
let outcome = if debt < f64::EPSILON {
"clean"
} else {
"elevated"
};
metrics::observe_reliability_decision("cancellation_signal", outcome, start.elapsed());
debt
}
fn penalty_for_state(&self, state: WorkerHealthState, debt: f64) -> (f64, bool) {
match state {
WorkerHealthState::Healthy => (0.0, false),
WorkerHealthState::Degraded => {
let penalty = debt.max(self.config.degraded_penalty_floor);
(penalty.clamp(0.0, 0.8), false)
}
WorkerHealthState::Quarantined => (1.0, true),
WorkerHealthState::ProbingRecovery => (self.config.probing_penalty, false),
}
}
async fn transition_state(
&self,
worker_id: &str,
debt: f64,
signals: &SignalDebts,
) -> (WorkerHealthState, f64, bool) {
let mut trackers = self.trackers.write().await;
let tracker = trackers.entry(worker_id.to_string()).or_default();
let prev_state = tracker.state;
let now = Instant::now();
let next_health = match prev_state {
WorkerHealthState::Healthy => {
if debt >= self.config.quarantine_threshold {
WorkerHealthState::Quarantined
} else if debt >= self.config.degraded_penalty_floor {
WorkerHealthState::Degraded
} else {
WorkerHealthState::Healthy
}
}
WorkerHealthState::Degraded => {
if debt >= self.config.quarantine_threshold {
WorkerHealthState::Quarantined
} else if debt < self.config.degraded_penalty_floor {
WorkerHealthState::Healthy
} else {
WorkerHealthState::Degraded
}
}
WorkerHealthState::Quarantined => {
let min_dwell_elapsed = tracker
.quarantined_at
.map(|at| now.duration_since(at) >= self.config.min_quarantine_duration)
.unwrap_or(false);
if min_dwell_elapsed && debt <= self.config.recovery_threshold {
tracker.recovery_tick_count += 1;
if tracker.recovery_tick_count >= self.config.recovery_ticks {
WorkerHealthState::ProbingRecovery
} else {
WorkerHealthState::Quarantined
}
} else {
if debt > self.config.recovery_threshold {
tracker.recovery_tick_count = 0;
}
WorkerHealthState::Quarantined
}
}
WorkerHealthState::ProbingRecovery => {
if debt >= self.config.quarantine_threshold {
WorkerHealthState::Quarantined
} else if debt <= self.config.recovery_threshold {
WorkerHealthState::Healthy
} else {
WorkerHealthState::ProbingRecovery
}
}
};
if !matches!(
(&prev_state, &next_health),
(WorkerHealthState::Healthy, WorkerHealthState::Healthy)
| (WorkerHealthState::Degraded, WorkerHealthState::Degraded)
| (
WorkerHealthState::Quarantined,
WorkerHealthState::Quarantined
)
| (
WorkerHealthState::ProbingRecovery,
WorkerHealthState::ProbingRecovery
)
) {
debug!(
"Worker {} reliability transition: {} → {}",
worker_id, prev_state, next_health
);
if matches!(next_health, WorkerHealthState::Quarantined) {
tracker.quarantined_at = Some(now);
tracker.recovery_tick_count = 0;
}
if matches!(next_health, WorkerHealthState::Healthy) {
tracker.quarantined_at = None;
tracker.recovery_tick_count = 0;
}
}
tracker.state = next_health;
tracker.last_debt = debt;
tracker.last_signals = signals.clone();
tracker.last_evaluated_at = Some(now);
let (penalty, hard_exclude) = self.penalty_for_state(next_health, debt);
(next_health, penalty, hard_exclude)
}
}
fn health_state_label(state: WorkerHealthState) -> &'static str {
match state {
WorkerHealthState::Healthy => "healthy",
WorkerHealthState::Degraded => "degraded",
WorkerHealthState::Quarantined => "quarantined",
WorkerHealthState::ProbingRecovery => "probing_recovery",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::EventBus;
use crate::process_triage::RemediationPipelineConfig;
use crate::workers::WorkerPool;
use rch_common::WorkerConfig;
use rch_common::e2e::process_triage::{
PROCESS_TRIAGE_CONTRACT_SCHEMA_VERSION, ProcessClassification, ProcessDescriptor,
ProcessTriageActionClass, ProcessTriageActionRequest, ProcessTriageContract,
ProcessTriageRequest, ProcessTriageTrigger,
};
fn test_config() -> ReliabilityConfig {
ReliabilityConfig {
min_quarantine_duration: Duration::from_millis(10),
recovery_ticks: 2,
..Default::default()
}
}
fn test_events() -> EventBus {
EventBus::new(16)
}
fn test_pipeline() -> Arc<RemediationPipeline> {
Arc::new(RemediationPipeline::new(
ProcessTriageContract::default(),
test_events(),
RemediationPipelineConfig::default(),
))
}
fn test_convergence() -> Arc<RepoConvergenceService> {
Arc::new(RepoConvergenceService::new(test_events()))
}
fn make_worker(id: &str) -> WorkerState {
WorkerState::new(WorkerConfig {
id: WorkerId::new(id),
..WorkerConfig::default()
})
}
async fn make_pool_with_worker(id: &str) -> (WorkerPool, Arc<WorkerState>) {
let pool = WorkerPool::new();
let config = WorkerConfig {
id: WorkerId::new(id),
..WorkerConfig::default()
};
pool.add_worker(config).await;
let Some(worker) = pool.get(&WorkerId::new(id)).await else {
unreachable!("pool must return worker immediately after add_worker");
};
(pool, worker)
}
#[tokio::test]
async fn test_healthy_worker_no_debt() {
let agg = ReliabilityAggregator::new(test_config());
let worker = make_worker("w1");
let assessment = agg.evaluate(&worker, "w1").await;
assert_eq!(assessment.health_state, WorkerHealthState::Healthy);
assert_eq!(assessment.penalty, 0.0);
assert!(!assessment.hard_exclude);
assert!(assessment.aggregated_debt < f64::EPSILON);
}
#[tokio::test]
async fn test_circuit_debt_causes_degradation() {
let agg = ReliabilityAggregator::new(test_config());
let worker = make_worker("w1");
for _ in 0..5 {
worker.record_failure(Some("test error".to_string())).await;
}
worker.record_success().await;
let assessment = agg.evaluate(&worker, "w1").await;
assert!(assessment.aggregated_debt > 0.05);
assert!(assessment.penalty > 0.0);
assert_eq!(assessment.health_state, WorkerHealthState::Degraded);
}
#[tokio::test]
async fn test_convergence_debt_integration() {
let convergence = test_convergence();
let mut agg = ReliabilityAggregator::new(test_config());
agg.set_convergence(convergence.clone());
let worker = make_worker("w1");
let wid = WorkerId::new("w1");
convergence
.update_required_repos(
&wid,
vec!["repo_a".into(), "repo_b".into()],
vec!["repo_a".into()], )
.await;
let assessment = agg.evaluate(&worker, "w1").await;
assert!(assessment.signals.convergence_debt > 0.0);
assert!(assessment.aggregated_debt > 0.0);
}
#[tokio::test]
async fn test_process_debt_from_remediation() {
let pipeline = test_pipeline();
let mut agg = ReliabilityAggregator::new(test_config());
agg.set_remediation(pipeline.clone());
let worker = make_worker("w1");
let clean = agg.evaluate(&worker, "w1").await;
assert!(clean.signals.process_debt < f64::EPSILON);
}
fn hard_terminate_request(
worker_id: &str,
correlation_id: &str,
pid: u32,
) -> ProcessTriageRequest {
ProcessTriageRequest {
schema_version: PROCESS_TRIAGE_CONTRACT_SCHEMA_VERSION.to_string(),
correlation_id: correlation_id.to_string(),
worker_id: worker_id.to_string(),
observed_at_unix_ms: chrono::Utc::now().timestamp_millis(),
trigger: ProcessTriageTrigger::BuildTimeout,
detector_confidence_percent: 99,
retry_attempt: 0,
candidate_processes: vec![ProcessDescriptor {
pid,
ppid: Some(1),
owner: "ubuntu".to_string(),
command: "cargo build --workspace".to_string(),
classification: ProcessClassification::BuildRelated,
cpu_percent_milli: 98_000,
rss_mb: 2048,
runtime_secs: 240,
}],
requested_actions: vec![ProcessTriageActionRequest {
action_class: ProcessTriageActionClass::HardTerminate,
pid,
reason_code: "stuck_hard".to_string(),
signal: Some("KILL".to_string()),
}],
}
}
#[tokio::test]
async fn test_process_debt_from_remediation_hard_terminations_degrade_worker() {
let mut contract = ProcessTriageContract::default();
contract
.safe_action_policy
.allow_action_classes
.push(ProcessTriageActionClass::HardTerminate);
contract
.safe_action_policy
.deny_action_classes
.retain(|class| *class != ProcessTriageActionClass::HardTerminate);
let pipeline = Arc::new(RemediationPipeline::new(
contract,
test_events(),
RemediationPipelineConfig {
dry_run: true,
worker_cooldown: Duration::from_millis(0),
..RemediationPipelineConfig::default()
},
));
let first = hard_terminate_request("w1", "corr-hard-1", 1001);
let second = hard_terminate_request("w1", "corr-hard-2", 1002);
assert_eq!(
pipeline.execute(&first).await.status,
rch_common::e2e::process_triage::ProcessTriageResponseStatus::Applied
);
assert_eq!(
pipeline.execute(&second).await.status,
rch_common::e2e::process_triage::ProcessTriageResponseStatus::Applied
);
let mut agg = ReliabilityAggregator::new(test_config());
agg.set_remediation(pipeline);
let worker = make_worker("w1");
let assessment = agg.evaluate(&worker, "w1").await;
assert!(
assessment.signals.process_debt > 0.6,
"process debt should reflect repeated hard remediations"
);
assert!(
assessment.aggregated_debt >= test_config().degraded_penalty_floor,
"aggregated debt should cross degraded threshold"
);
assert_eq!(assessment.health_state, WorkerHealthState::Degraded);
assert!(assessment.penalty >= test_config().degraded_penalty_floor);
}
#[tokio::test]
async fn test_process_debt_can_quarantine_with_strict_threshold() {
let mut contract = ProcessTriageContract::default();
contract
.safe_action_policy
.allow_action_classes
.push(ProcessTriageActionClass::HardTerminate);
contract
.safe_action_policy
.deny_action_classes
.retain(|class| *class != ProcessTriageActionClass::HardTerminate);
let pipeline = Arc::new(RemediationPipeline::new(
contract,
test_events(),
RemediationPipelineConfig {
dry_run: true,
worker_cooldown: Duration::from_millis(0),
..RemediationPipelineConfig::default()
},
));
let first = hard_terminate_request("w1", "corr-hard-q1", 2001);
let second = hard_terminate_request("w1", "corr-hard-q2", 2002);
let _ = pipeline.execute(&first).await;
let _ = pipeline.execute(&second).await;
let mut config = test_config();
config.quarantine_threshold = 0.08;
let mut agg = ReliabilityAggregator::new(config);
agg.set_remediation(pipeline);
let worker = make_worker("w1");
let assessment = agg.evaluate(&worker, "w1").await;
assert_eq!(assessment.health_state, WorkerHealthState::Quarantined);
assert!(assessment.hard_exclude);
assert_eq!(assessment.penalty, 1.0);
}
#[tokio::test]
async fn test_quarantine_on_high_circuit_debt() {
let config = ReliabilityConfig {
quarantine_threshold: 0.3,
min_quarantine_duration: Duration::from_millis(10),
..Default::default()
};
let agg = ReliabilityAggregator::new(config);
let worker = make_worker("w1");
for _ in 0..10 {
worker.record_failure(Some("test".to_string())).await;
}
let assessment = agg.evaluate(&worker, "w1").await;
assert_eq!(assessment.health_state, WorkerHealthState::Quarantined);
assert!(assessment.hard_exclude);
assert_eq!(assessment.penalty, 1.0);
}
#[tokio::test]
async fn test_quarantine_transition_full_debt() {
let convergence = test_convergence();
let mut config = test_config();
config.quarantine_threshold = 0.5;
let mut agg = ReliabilityAggregator::new(config);
agg.set_convergence(convergence.clone());
let worker = make_worker("w1");
let wid = WorkerId::new("w1");
for _ in 0..10 {
worker.record_failure(Some("test".to_string())).await;
}
convergence
.update_required_repos(
&wid,
vec!["repo_a".into()],
vec![], )
.await;
for _ in 0..3 {
let _ = convergence
.record_convergence_attempt(&wid, 0, 1, 0, 50_000, Some("fail".into()))
.await;
}
let assessment = agg.evaluate(&worker, "w1").await;
assert_eq!(assessment.health_state, WorkerHealthState::Quarantined);
assert!(assessment.hard_exclude);
assert_eq!(assessment.penalty, 1.0);
}
#[tokio::test]
async fn test_quarantine_recovery_hysteresis() {
let config = ReliabilityConfig {
quarantine_threshold: 0.15,
recovery_threshold: 0.05,
recovery_ticks: 2,
min_quarantine_duration: Duration::from_millis(10),
degraded_penalty_floor: 0.05,
..Default::default()
};
let agg = ReliabilityAggregator::new(config);
let worker = make_worker("w1");
for _ in 0..10 {
worker.record_failure(Some("test".to_string())).await;
}
let assessment = agg.evaluate(&worker, "w1").await;
assert_eq!(assessment.health_state, WorkerHealthState::Quarantined);
for _ in 0..500 {
worker.record_success().await;
}
tokio::time::sleep(Duration::from_millis(20)).await;
let a1 = agg.evaluate(&worker, "w1").await;
assert_eq!(a1.health_state, WorkerHealthState::Quarantined);
let a2 = agg.evaluate(&worker, "w1").await;
assert_eq!(a2.health_state, WorkerHealthState::ProbingRecovery);
assert!(!a2.hard_exclude);
let a3 = agg.evaluate(&worker, "w1").await;
assert_eq!(a3.health_state, WorkerHealthState::Healthy);
assert_eq!(a3.penalty, 0.0);
}
#[tokio::test]
async fn test_quarantine_min_dwell_enforced() {
let config = ReliabilityConfig {
quarantine_threshold: 0.15,
recovery_threshold: 0.05,
recovery_ticks: 1,
min_quarantine_duration: Duration::from_secs(60),
..Default::default()
};
let agg = ReliabilityAggregator::new(config);
let worker = make_worker("w1");
for _ in 0..10 {
worker.record_failure(Some("test".to_string())).await;
}
let _ = agg.evaluate(&worker, "w1").await;
for _ in 0..500 {
worker.record_success().await;
}
let assessment = agg.evaluate(&worker, "w1").await;
assert_eq!(assessment.health_state, WorkerHealthState::Quarantined);
}
#[tokio::test]
async fn test_probing_recovery_relapse() {
let config = ReliabilityConfig {
quarantine_threshold: 0.15,
recovery_threshold: 0.05,
recovery_ticks: 1,
min_quarantine_duration: Duration::from_millis(10),
..Default::default()
};
let agg = ReliabilityAggregator::new(config);
let worker = make_worker("w1");
for _ in 0..10 {
worker.record_failure(Some("test".to_string())).await;
}
let _ = agg.evaluate(&worker, "w1").await;
for _ in 0..200 {
worker.record_success().await;
}
tokio::time::sleep(Duration::from_millis(20)).await;
let a1 = agg.evaluate(&worker, "w1").await;
assert_eq!(a1.health_state, WorkerHealthState::ProbingRecovery);
for _ in 0..200 {
worker.record_failure(Some("relapse".to_string())).await;
}
let a2 = agg.evaluate(&worker, "w1").await;
assert_eq!(a2.health_state, WorkerHealthState::Quarantined);
assert!(a2.hard_exclude);
}
#[tokio::test]
async fn test_no_services_all_healthy() {
let agg = ReliabilityAggregator::new(test_config());
let worker = make_worker("w1");
let assessment = agg.evaluate(&worker, "w1").await;
assert_eq!(assessment.health_state, WorkerHealthState::Healthy);
assert_eq!(assessment.aggregated_debt, 0.0);
assert_eq!(assessment.penalty, 0.0);
}
#[tokio::test]
async fn test_degraded_penalty_floor() {
let config = ReliabilityConfig {
degraded_penalty_floor: 0.05,
..test_config()
};
let agg = ReliabilityAggregator::new(config);
let worker = make_worker("w1");
for _ in 0..3 {
worker.record_failure(Some("test".to_string())).await;
}
worker.record_success().await;
let assessment = agg.evaluate(&worker, "w1").await;
assert_eq!(assessment.health_state, WorkerHealthState::Degraded);
assert!(assessment.penalty >= 0.05);
}
#[tokio::test]
async fn test_all_signals_combined() {
let convergence = test_convergence();
let pipeline = test_pipeline();
let mut agg = ReliabilityAggregator::new(test_config());
agg.set_convergence(convergence.clone());
agg.set_remediation(pipeline.clone());
let worker = make_worker("w1");
let wid = WorkerId::new("w1");
convergence
.update_required_repos(&wid, vec!["a".into(), "b".into()], vec!["a".into()])
.await;
for _ in 0..3 {
worker.record_failure(Some("test".to_string())).await;
}
worker.record_success().await;
let assessment = agg.evaluate(&worker, "w1").await;
assert!(assessment.signals.circuit_debt > 0.0);
assert!(assessment.signals.convergence_debt > 0.0);
assert!(assessment.aggregated_debt > 0.0);
assert_eq!(assessment.health_state, WorkerHealthState::Degraded);
}
#[tokio::test]
async fn test_convergence_ready_no_debt() {
let convergence = test_convergence();
let mut agg = ReliabilityAggregator::new(test_config());
agg.set_convergence(convergence.clone());
let worker = make_worker("w1");
let wid = WorkerId::new("w1");
convergence
.update_required_repos(&wid, vec!["repo_a".into()], vec!["repo_a".into()])
.await;
let assessment = agg.evaluate(&worker, "w1").await;
assert!(assessment.signals.convergence_debt < f64::EPSILON);
}
#[tokio::test]
async fn test_convergence_stale_mild_debt() {
let convergence = test_convergence();
let mut agg = ReliabilityAggregator::new(test_config());
agg.set_convergence(convergence.clone());
let worker = make_worker("w1");
let assessment = agg.evaluate(&worker, "w1").await;
assert!((assessment.signals.convergence_debt - 0.2).abs() < f64::EPSILON);
}
#[tokio::test]
async fn test_get_assessment_cached() {
let agg = ReliabilityAggregator::new(test_config());
let worker = make_worker("w1");
assert!(agg.get_assessment("w1").await.is_none());
let _ = agg.evaluate(&worker, "w1").await;
let cached = agg.get_assessment("w1").await;
assert!(cached.is_some());
assert_eq!(cached.unwrap().health_state, WorkerHealthState::Healthy);
}
#[tokio::test]
async fn test_all_states_returns_tracked_workers() {
let agg = ReliabilityAggregator::new(test_config());
let w1 = make_worker("w1");
let w2 = make_worker("w2");
let _ = agg.evaluate(&w1, "w1").await;
let _ = agg.evaluate(&w2, "w2").await;
let states = agg.all_states().await;
assert_eq!(states.len(), 2);
assert_eq!(states["w1"], WorkerHealthState::Healthy);
assert_eq!(states["w2"], WorkerHealthState::Healthy);
}
#[tokio::test]
async fn test_reset_clears_tracker() {
let agg = ReliabilityAggregator::new(test_config());
let worker = make_worker("w1");
for _ in 0..5 {
worker.record_failure(Some("test".to_string())).await;
}
let _ = agg.evaluate(&worker, "w1").await;
assert!(agg.get_assessment("w1").await.is_some());
agg.reset("w1").await;
assert!(agg.get_assessment("w1").await.is_none());
let clean_worker = make_worker("w1");
let assessment = agg.evaluate(&clean_worker, "w1").await;
assert_eq!(assessment.health_state, WorkerHealthState::Healthy);
}
#[test]
fn test_signal_weights_normalize() {
let weights = SignalWeights {
circuit: 1.0,
convergence: 1.0,
pressure: 1.0,
process: 1.0,
cancellation: 1.0,
};
let normalized = weights.normalized();
let sum = normalized.circuit
+ normalized.convergence
+ normalized.pressure
+ normalized.process
+ normalized.cancellation;
assert!((sum - 1.0).abs() < f64::EPSILON);
assert!((normalized.circuit - 0.2).abs() < f64::EPSILON);
}
#[test]
fn test_signal_weights_zero_fallback() {
let weights = SignalWeights {
circuit: 0.0,
convergence: 0.0,
pressure: 0.0,
process: 0.0,
cancellation: 0.0,
};
let normalized = weights.normalized();
assert!(normalized.circuit > 0.0);
}
#[tokio::test]
async fn test_degraded_to_healthy_on_low_debt() {
let agg = ReliabilityAggregator::new(test_config());
let worker = make_worker("w1");
for _ in 0..5 {
worker.record_failure(Some("test".to_string())).await;
}
let d = agg.evaluate(&worker, "w1").await;
assert_eq!(d.health_state, WorkerHealthState::Degraded);
for _ in 0..500 {
worker.record_success().await;
}
let h = agg.evaluate(&worker, "w1").await;
assert_eq!(h.health_state, WorkerHealthState::Healthy);
assert!(h.aggregated_debt < 0.05);
}
#[tokio::test]
async fn test_pressure_healthy_no_debt() {
let agg = ReliabilityAggregator::new(test_config());
let worker = make_worker("w1");
let debt = agg.compute_pressure_debt(&worker, "w1").await;
assert!(debt < f64::EPSILON);
}
#[tokio::test]
async fn test_pressure_debt_uses_cached_admission_reject() {
use crate::disk_pressure::{PressureAssessment, PressureConfidence};
use crate::headroom::{HeadroomConfig, HeadroomEstimator};
use crate::history::BuildHistory;
let history = Arc::new(BuildHistory::new(10));
let headroom = Arc::new(HeadroomEstimator::new(history, HeadroomConfig::default()));
let gate = Arc::new(AdmissionGate::new(
crate::admission::AdmissionConfig::default(),
headroom,
));
let mut agg = ReliabilityAggregator::new(test_config());
agg.set_admission(gate.clone());
let worker = make_worker("w1");
worker
.set_pressure_assessment(PressureAssessment {
state: PressureState::Healthy,
confidence: PressureConfidence::High,
reason_code: "test_healthy_but_low_headroom".to_string(),
policy_rule: "test_policy".to_string(),
disk_free_gb: Some(1.0),
disk_total_gb: Some(500.0),
disk_free_ratio: Some(0.002),
build_disk_free_gb: None,
build_disk_total_gb: None,
disk_io_util_pct: None,
memory_pressure: None,
telemetry_age_secs: Some(5),
telemetry_fresh: true,
evaluated_at_unix_ms: 1000,
})
.await;
let verdict = gate.evaluate(&worker, "w1", "proj-a").await;
assert!(!verdict.is_admitted());
let debt = agg.compute_pressure_debt(&worker, "w1").await;
assert!((debt - 1.0).abs() < f64::EPSILON);
}
#[tokio::test]
async fn test_cancellation_no_orchestrator_zero_debt() {
let agg = ReliabilityAggregator::new(test_config());
let worker = make_worker("w1");
let assessment = agg.evaluate(&worker, "w1").await;
assert!(assessment.signals.cancellation_debt < f64::EPSILON);
}
#[tokio::test]
async fn test_cancellation_signal_included_in_aggregation() {
use crate::cancellation::{CancellationConfig, CancellationOrchestrator};
let orch = Arc::new(CancellationOrchestrator::new(
CancellationConfig::default(),
test_events(),
));
let mut agg = ReliabilityAggregator::new(test_config());
agg.set_cancellation(orch.clone());
let worker = make_worker("w1");
let assessment = agg.evaluate(&worker, "w1").await;
assert!(assessment.signals.cancellation_debt < f64::EPSILON);
assert_eq!(assessment.health_state, WorkerHealthState::Healthy);
}
#[test]
fn test_signal_weights_five_signals_sum_to_one() {
let weights = SignalWeights::default();
let sum = weights.circuit
+ weights.convergence
+ weights.pressure
+ weights.process
+ weights.cancellation;
assert!(
(sum - 1.0).abs() < f64::EPSILON,
"Default weights should sum to 1.0, got {}",
sum
);
}
#[tokio::test]
async fn test_metrics_emitted_for_healthy_reliability_evaluation() {
let worker_id = "metrics-healthy-w1";
let counter_before = crate::metrics::RELIABILITY_DECISIONS_TOTAL
.with_label_values(&["reliability_aggregate", "healthy"])
.get();
let agg = ReliabilityAggregator::new(test_config());
let worker = make_worker(worker_id);
let assessment = agg.evaluate(&worker, worker_id).await;
assert_eq!(assessment.health_state, WorkerHealthState::Healthy);
let counter_after = crate::metrics::RELIABILITY_DECISIONS_TOTAL
.with_label_values(&["reliability_aggregate", "healthy"])
.get();
assert!(counter_after > counter_before);
}
#[tokio::test]
async fn test_metrics_emitted_for_quarantined_reliability_evaluation() {
let worker_id = "metrics-quarantine-w1";
let error_before = crate::metrics::RELIABILITY_ERRORS_TOTAL
.with_label_values(&["reliability_aggregate", "quarantined"])
.get();
let config = ReliabilityConfig {
quarantine_threshold: 0.2,
..test_config()
};
let agg = ReliabilityAggregator::new(config);
let worker = make_worker(worker_id);
for _ in 0..10 {
worker
.record_failure(Some("forced-metric-quarantine".to_string()))
.await;
}
let assessment = agg.evaluate(&worker, worker_id).await;
assert_eq!(assessment.health_state, WorkerHealthState::Quarantined);
assert!(assessment.hard_exclude);
let error_after = crate::metrics::RELIABILITY_ERRORS_TOTAL
.with_label_values(&["reliability_aggregate", "quarantined"])
.get();
assert!(error_after > error_before);
}
}