agentzero_core/
regression.rs1use std::collections::HashMap;
8use std::sync::Mutex;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11#[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#[derive(Debug, Clone)]
22pub struct RegressionWarning {
23 pub file_path: String,
24 pub conflicting_entries: Vec<(String, String)>, pub correlation_id: String,
26}
27
28pub struct FileModificationTracker {
33 modifications: Mutex<HashMap<String, Vec<FileModEntry>>>,
35 window_ms: u64,
37}
38
39impl FileModificationTracker {
40 pub fn new(window_ms: u64) -> Self {
42 Self {
43 modifications: Mutex::new(HashMap::new()),
44 window_ms,
45 }
46 }
47
48 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 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 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); tracker.record_modification("src/a.rs", "agent-1", "run-1", "corr-1");
173 std::thread::sleep(std::time::Duration::from_millis(5));
175 tracker.gc();
176 let result = tracker.record_modification("src/a.rs", "agent-2", "run-2", "corr-1");
179 assert!(result.is_none());
180 }
181}