cflx 0.6.64

Conflux – a spec-driven parallel coding orchestrator that runs AI agents on git worktrees
use crate::config::StallDetectionConfig;
use std::collections::HashMap;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StallPhase {
    Apply,
    #[allow(dead_code)]
    Archive,
}

#[derive(Debug, Default, Clone)]
struct StallCounters {
    apply: u32,
    archive: u32,
}

#[derive(Debug, Clone)]
pub struct StallDetector {
    config: StallDetectionConfig,
    counts: HashMap<String, StallCounters>,
}

impl StallDetector {
    pub fn new(config: StallDetectionConfig) -> Self {
        let threshold = config.threshold.max(1);
        Self {
            config: StallDetectionConfig {
                threshold,
                ..config
            },
            counts: HashMap::new(),
        }
    }

    pub fn config(&self) -> &StallDetectionConfig {
        &self.config
    }

    pub fn register_commit(&mut self, change_id: &str, phase: StallPhase, is_empty: bool) -> bool {
        if !self.config.enabled {
            return false;
        }

        let counters = self.counts.entry(change_id.to_string()).or_default();
        let counter = match phase {
            StallPhase::Apply => &mut counters.apply,
            StallPhase::Archive => &mut counters.archive,
        };

        if is_empty {
            *counter = counter.saturating_add(1);
        } else {
            *counter = 0;
        }

        *counter >= self.config.threshold
    }

    pub fn current_count(&self, change_id: &str, phase: StallPhase) -> u32 {
        self.counts
            .get(change_id)
            .map(|counters| match phase {
                StallPhase::Apply => counters.apply,
                StallPhase::Archive => counters.archive,
            })
            .unwrap_or(0)
    }

    pub fn clear_change(&mut self, change_id: &str) {
        self.counts.remove(change_id);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn enabled_config(threshold: u32) -> StallDetectionConfig {
        StallDetectionConfig {
            enabled: true,
            threshold,
            ..Default::default()
        }
    }

    #[test]
    fn test_stall_detector_triggers_after_threshold() {
        let mut detector = StallDetector::new(enabled_config(3));

        assert!(!detector.register_commit("change-a", StallPhase::Apply, true));
        assert_eq!(detector.current_count("change-a", StallPhase::Apply), 1);
        assert!(!detector.register_commit("change-a", StallPhase::Apply, true));
        assert_eq!(detector.current_count("change-a", StallPhase::Apply), 2);
        assert!(detector.register_commit("change-a", StallPhase::Apply, true));
        assert_eq!(detector.current_count("change-a", StallPhase::Apply), 3);
    }

    #[test]
    fn test_stall_detector_resets_on_non_empty_commit() {
        let mut detector = StallDetector::new(enabled_config(2));

        assert!(!detector.register_commit("change-a", StallPhase::Apply, true));
        assert_eq!(detector.current_count("change-a", StallPhase::Apply), 1);

        assert!(!detector.register_commit("change-a", StallPhase::Apply, false));
        assert_eq!(detector.current_count("change-a", StallPhase::Apply), 0);
    }

    #[test]
    fn test_stall_detector_disabled_never_triggers() {
        let config = StallDetectionConfig {
            enabled: false,
            threshold: 2,
            ..Default::default()
        };
        let mut detector = StallDetector::new(config);

        assert!(!detector.register_commit("change-a", StallPhase::Apply, true));
        assert_eq!(detector.current_count("change-a", StallPhase::Apply), 0);
    }
}