Skip to main content

agentzero_core/
regression.rs

1//! Regression detection for multi-agent file modification conflicts.
2//!
3//! Tracks which files each agent modifies within a delegation tree (identified
4//! by `correlation_id`) and detects when two different agents modify the same
5//! file, which may indicate one agent undoing another's work.
6
7use std::collections::HashMap;
8use std::sync::Mutex;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11/// A single recorded file modification.
12#[derive(Debug, Clone)]
13pub struct FileModEntry {
14    pub agent_id: String,
15    pub run_id: String,
16    pub correlation_id: String,
17    pub timestamp_ms: u64,
18}
19
20/// Warning raised when a file conflict is detected.
21#[derive(Debug, Clone)]
22pub struct RegressionWarning {
23    pub file_path: String,
24    pub conflicting_entries: Vec<(String, String)>, // (agent_id, run_id)
25    pub correlation_id: String,
26}
27
28/// Tracks file modifications across agents and detects conflicts.
29///
30/// A conflict occurs when two different agents modify the same file within the
31/// same correlation tree (delegation chain) within a configurable time window.
32pub struct FileModificationTracker {
33    /// Maps canonical file paths to modification entries.
34    modifications: Mutex<HashMap<String, Vec<FileModEntry>>>,
35    /// Conflict detection window in milliseconds.
36    window_ms: u64,
37}
38
39impl FileModificationTracker {
40    /// Create a new tracker with the given conflict detection window.
41    pub fn new(window_ms: u64) -> Self {
42        Self {
43            modifications: Mutex::new(HashMap::new()),
44            window_ms,
45        }
46    }
47
48    /// Record a file modification and check for conflicts.
49    ///
50    /// Returns `Some(RegressionWarning)` if another agent in the same
51    /// correlation tree modified this file within the detection window.
52    pub fn record_modification(
53        &self,
54        file_path: &str,
55        agent_id: &str,
56        run_id: &str,
57        correlation_id: &str,
58    ) -> Option<RegressionWarning> {
59        let now_ms = SystemTime::now()
60            .duration_since(UNIX_EPOCH)
61            .unwrap_or_default()
62            .as_millis() as u64;
63
64        let entry = FileModEntry {
65            agent_id: agent_id.to_string(),
66            run_id: run_id.to_string(),
67            correlation_id: correlation_id.to_string(),
68            timestamp_ms: now_ms,
69        };
70
71        let mut mods = self.modifications.lock().expect("regression tracker lock");
72        let entries = mods.entry(file_path.to_string()).or_default();
73
74        // Check for conflicts: same file, same correlation tree, different agent,
75        // within the time window.
76        let cutoff = now_ms.saturating_sub(self.window_ms);
77        let conflicting: Vec<(String, String)> = entries
78            .iter()
79            .filter(|e| {
80                e.correlation_id == correlation_id
81                    && e.agent_id != agent_id
82                    && e.timestamp_ms >= cutoff
83            })
84            .map(|e| (e.agent_id.clone(), e.run_id.clone()))
85            .collect();
86
87        entries.push(entry);
88
89        if conflicting.is_empty() {
90            None
91        } else {
92            Some(RegressionWarning {
93                file_path: file_path.to_string(),
94                conflicting_entries: conflicting,
95                correlation_id: correlation_id.to_string(),
96            })
97        }
98    }
99
100    /// Remove entries older than the detection window.
101    pub fn gc(&self) {
102        let now_ms = SystemTime::now()
103            .duration_since(UNIX_EPOCH)
104            .unwrap_or_default()
105            .as_millis() as u64;
106        let cutoff = now_ms.saturating_sub(self.window_ms);
107
108        let mut mods = self.modifications.lock().expect("regression tracker lock");
109        mods.retain(|_, entries| {
110            entries.retain(|e| e.timestamp_ms >= cutoff);
111            !entries.is_empty()
112        });
113    }
114}
115
116impl std::fmt::Debug for FileModificationTracker {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        f.debug_struct("FileModificationTracker")
119            .field("window_ms", &self.window_ms)
120            .finish()
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn no_warning_for_different_files() {
130        let tracker = FileModificationTracker::new(60_000);
131        let result = tracker.record_modification("src/a.rs", "agent-1", "run-1", "corr-1");
132        assert!(result.is_none());
133        let result = tracker.record_modification("src/b.rs", "agent-2", "run-2", "corr-1");
134        assert!(result.is_none());
135    }
136
137    #[test]
138    fn warning_for_same_file_same_correlation() {
139        let tracker = FileModificationTracker::new(60_000);
140        let result = tracker.record_modification("src/main.rs", "agent-1", "run-1", "corr-1");
141        assert!(result.is_none());
142        let result = tracker.record_modification("src/main.rs", "agent-2", "run-2", "corr-1");
143        assert!(result.is_some());
144        let warning = result.expect("should have warning");
145        assert_eq!(warning.file_path, "src/main.rs");
146        assert_eq!(warning.correlation_id, "corr-1");
147        assert_eq!(warning.conflicting_entries.len(), 1);
148        assert_eq!(warning.conflicting_entries[0].0, "agent-1");
149    }
150
151    #[test]
152    fn no_warning_for_different_correlation() {
153        let tracker = FileModificationTracker::new(60_000);
154        let result = tracker.record_modification("src/main.rs", "agent-1", "run-1", "corr-1");
155        assert!(result.is_none());
156        let result = tracker.record_modification("src/main.rs", "agent-2", "run-2", "corr-2");
157        assert!(result.is_none());
158    }
159
160    #[test]
161    fn no_warning_for_same_agent() {
162        let tracker = FileModificationTracker::new(60_000);
163        let result = tracker.record_modification("src/main.rs", "agent-1", "run-1", "corr-1");
164        assert!(result.is_none());
165        let result = tracker.record_modification("src/main.rs", "agent-1", "run-2", "corr-1");
166        assert!(result.is_none());
167    }
168
169    #[test]
170    fn gc_removes_expired_entries() {
171        let tracker = FileModificationTracker::new(1); // 1ms window
172        tracker.record_modification("src/a.rs", "agent-1", "run-1", "corr-1");
173        // Sleep long enough for the entry to expire.
174        std::thread::sleep(std::time::Duration::from_millis(5));
175        tracker.gc();
176        // After GC, recording same file from different agent should NOT conflict
177        // (the old entry was cleaned up).
178        let result = tracker.record_modification("src/a.rs", "agent-2", "run-2", "corr-1");
179        assert!(result.is_none());
180    }
181}