1use serde::{Deserialize, Serialize};
13use std::future::Future;
14use std::pin::Pin;
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::sync::Arc;
17use std::time::Duration;
18
19#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
21pub struct LoopModeConfig {
22 #[serde(default = "default_interval_secs")]
24 pub interval_secs: u64,
25 #[serde(default)]
27 pub max_iterations: Option<u64>,
28 #[serde(default = "default_halt_threshold")]
30 pub halt_on_consecutive_failures: u32,
31 #[serde(default)]
34 pub fallback_prompt: Option<String>,
35 #[serde(default = "default_count_failures")]
37 pub count_failed_iterations: bool,
38}
39
40fn default_interval_secs() -> u64 {
41 180
42}
43fn default_halt_threshold() -> u32 {
44 3
45}
46fn default_count_failures() -> bool {
47 true
48}
49
50impl LoopModeConfig {
51 pub fn interval(&self) -> Duration {
53 Duration::from_secs(self.interval_secs).max(Duration::from_millis(1))
54 }
55}
56
57impl Default for LoopModeConfig {
58 fn default() -> Self {
59 Self {
60 interval_secs: default_interval_secs(),
61 max_iterations: None,
62 halt_on_consecutive_failures: default_halt_threshold(),
63 fallback_prompt: None,
64 count_failed_iterations: default_count_failures(),
65 }
66 }
67}
68
69#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
71pub struct LoopModeState {
72 pub iterations_run: u64,
73 pub iterations_succeeded: u64,
74 pub iterations_failed: u64,
75 pub consecutive_failures: u32,
76 pub started_at_epoch_secs: u64,
77 pub last_tick_epoch_secs: u64,
78}
79
80impl LoopModeState {
81 pub fn record_success(&mut self, now_epoch_secs: u64) {
83 self.iterations_run += 1;
84 self.iterations_succeeded += 1;
85 self.consecutive_failures = 0;
86 self.last_tick_epoch_secs = now_epoch_secs;
87 }
88
89 pub fn record_failure(&mut self, now_epoch_secs: u64) {
91 self.iterations_run += 1;
92 self.iterations_failed += 1;
93 self.consecutive_failures += 1;
94 self.last_tick_epoch_secs = now_epoch_secs;
95 }
96
97 pub fn should_halt(&self, config: &LoopModeConfig) -> Option<LoopFinishReason> {
99 if self.consecutive_failures >= config.halt_on_consecutive_failures {
100 return Some(LoopFinishReason::ConsecutiveFailures);
101 }
102 if let Some(max) = config.max_iterations {
103 let counted = if config.count_failed_iterations {
104 self.iterations_run
105 } else {
106 self.iterations_succeeded
107 };
108 if counted >= max {
109 return Some(LoopFinishReason::MaxIterationsReached);
110 }
111 }
112 None
113 }
114}
115
116#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
118#[serde(rename_all = "snake_case")]
119pub enum LoopFinishReason {
120 MaxIterationsReached,
122 ConsecutiveFailures,
124 ExternalStop,
126 FatalError,
128}
129
130pub type TickFn =
132 Box<dyn Fn() -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>> + Send + Sync>;
133
134pub struct LoopRunner {
136 pub config: LoopModeConfig,
137 pub state: LoopModeState,
138 stop: Arc<AtomicBool>,
139}
140
141impl LoopRunner {
142 pub fn new(config: LoopModeConfig) -> Self {
143 Self {
144 config,
145 state: LoopModeState::default(),
146 stop: Arc::new(AtomicBool::new(false)),
147 }
148 }
149
150 pub fn stop_handle(&self) -> Arc<AtomicBool> {
151 self.stop.clone()
152 }
153
154 pub async fn run(&mut self, tick: &TickFn) -> LoopFinishReason {
155 self.run_with_state_observer(tick, |_| async {}).await
156 }
157
158 pub async fn run_with_state_observer<F, Fut>(
159 &mut self,
160 tick: &TickFn,
161 mut observe: F,
162 ) -> LoopFinishReason
163 where
164 F: FnMut(LoopModeState) -> Fut,
165 Fut: Future<Output = ()>,
166 {
167 let now = std::time::SystemTime::now()
168 .duration_since(std::time::UNIX_EPOCH)
169 .unwrap_or_default()
170 .as_secs();
171 self.state.started_at_epoch_secs = now;
172 observe(self.state.clone()).await;
173
174 let mut interval = tokio::time::interval(self.config.interval());
175 interval.tick().await; loop {
178 if self.stop.load(Ordering::Relaxed) {
179 return LoopFinishReason::ExternalStop;
180 }
181
182 let now = std::time::SystemTime::now()
183 .duration_since(std::time::UNIX_EPOCH)
184 .unwrap_or_default()
185 .as_secs();
186
187 match tick().await {
188 Ok(()) => self.state.record_success(now),
189 Err(_) => self.state.record_failure(now),
190 }
191 observe(self.state.clone()).await;
192
193 if let Some(reason) = self.state.should_halt(&self.config) {
194 return reason;
195 }
196
197 interval.tick().await;
198 }
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn config_defaults_are_sensible() {
208 let cfg = LoopModeConfig::default();
209 assert_eq!(cfg.interval_secs, 180);
210 assert_eq!(cfg.interval(), Duration::from_secs(180));
211 assert_eq!(cfg.max_iterations, None);
212 assert_eq!(cfg.halt_on_consecutive_failures, 3);
213 assert!(cfg.fallback_prompt.is_none());
214 assert!(cfg.count_failed_iterations);
215 }
216
217 #[test]
218 fn config_round_trips_through_toml() {
219 let cfg = LoopModeConfig {
220 interval_secs: 60,
221 max_iterations: Some(100),
222 halt_on_consecutive_failures: 5,
223 fallback_prompt: Some("expand test coverage".into()),
224 count_failed_iterations: false,
225 };
226 let serialized = toml::to_string(&cfg).expect("serialize");
227 let parsed: LoopModeConfig = toml::from_str(&serialized).expect("parse");
228 assert_eq!(cfg, parsed);
229 }
230
231 #[test]
232 fn state_record_success_resets_consecutive_failures() {
233 let mut state = LoopModeState::default();
234 state.record_failure(10);
235 state.record_failure(20);
236 assert_eq!(state.consecutive_failures, 2);
237 state.record_success(30);
238 assert_eq!(state.consecutive_failures, 0);
239 assert_eq!(state.iterations_run, 3);
240 assert_eq!(state.iterations_succeeded, 1);
241 assert_eq!(state.iterations_failed, 2);
242 assert_eq!(state.last_tick_epoch_secs, 30);
243 }
244
245 #[test]
246 fn should_halt_on_consecutive_failures() {
247 let cfg = LoopModeConfig {
248 halt_on_consecutive_failures: 3,
249 ..LoopModeConfig::default()
250 };
251 let mut state = LoopModeState::default();
252 state.record_failure(10);
253 state.record_failure(20);
254 assert_eq!(state.should_halt(&cfg), None);
255 state.record_failure(30);
256 assert_eq!(
257 state.should_halt(&cfg),
258 Some(LoopFinishReason::ConsecutiveFailures)
259 );
260 }
261
262 #[test]
263 fn should_halt_on_max_iterations_counting_failures() {
264 let cfg = LoopModeConfig {
265 max_iterations: Some(2),
266 halt_on_consecutive_failures: 999,
267 count_failed_iterations: true,
268 ..LoopModeConfig::default()
269 };
270 let mut state = LoopModeState::default();
271 state.record_success(10);
272 assert_eq!(state.should_halt(&cfg), None);
273 state.record_failure(20);
274 assert_eq!(
275 state.should_halt(&cfg),
276 Some(LoopFinishReason::MaxIterationsReached)
277 );
278 }
279
280 #[test]
281 fn should_halt_on_max_iterations_ignoring_failures() {
282 let cfg = LoopModeConfig {
283 max_iterations: Some(2),
284 halt_on_consecutive_failures: 999,
285 count_failed_iterations: false,
286 ..LoopModeConfig::default()
287 };
288 let mut state = LoopModeState::default();
289 state.record_failure(10);
290 state.record_failure(20);
291 state.record_failure(30);
292 assert_eq!(state.should_halt(&cfg), None);
293 state.record_success(40);
294 state.record_success(50);
295 assert_eq!(
296 state.should_halt(&cfg),
297 Some(LoopFinishReason::MaxIterationsReached)
298 );
299 }
300
301 #[test]
302 fn unbounded_loop_never_halts_without_failures() {
303 let cfg = LoopModeConfig::default();
304 let mut state = LoopModeState::default();
305 for i in 0..1000 {
306 state.record_success(i);
307 }
308 assert_eq!(state.should_halt(&cfg), None);
309 }
310
311 #[test]
312 fn finish_reason_serializes_snake_case() {
313 assert_eq!(
314 serde_json::to_string(&LoopFinishReason::MaxIterationsReached).unwrap(),
315 "\"max_iterations_reached\""
316 );
317 assert_eq!(
318 serde_json::to_string(&LoopFinishReason::ConsecutiveFailures).unwrap(),
319 "\"consecutive_failures\""
320 );
321 }
322
323 #[tokio::test]
324 async fn loop_runner_halts_on_max_iterations() {
325 let config = LoopModeConfig {
326 interval_secs: 0,
327 max_iterations: Some(3),
328 halt_on_consecutive_failures: 999,
329 ..LoopModeConfig::default()
330 };
331 let mut runner = LoopRunner::new(config);
332 let tick: TickFn = Box::new(|| Box::pin(async { Ok(()) }));
333 let reason = runner.run(&tick).await;
334 assert_eq!(reason, LoopFinishReason::MaxIterationsReached);
335 assert_eq!(runner.state.iterations_run, 3);
336 }
337
338 #[tokio::test]
339 async fn loop_runner_observes_started_and_tick_state() {
340 let config = LoopModeConfig {
341 interval_secs: 0,
342 max_iterations: Some(2),
343 halt_on_consecutive_failures: 999,
344 ..LoopModeConfig::default()
345 };
346 let mut runner = LoopRunner::new(config);
347 let observed = std::sync::Arc::new(tokio::sync::Mutex::new(Vec::new()));
348 let observed_for_callback = observed.clone();
349 let tick: TickFn = Box::new(|| Box::pin(async { Ok(()) }));
350
351 let reason = runner
352 .run_with_state_observer(&tick, move |state| {
353 let observed = observed_for_callback.clone();
354 async move {
355 observed.lock().await.push(state);
356 }
357 })
358 .await;
359
360 assert_eq!(reason, LoopFinishReason::MaxIterationsReached);
361 let snapshots = observed.lock().await;
362 assert!(snapshots[0].started_at_epoch_secs > 0);
363 assert_eq!(snapshots[0].iterations_run, 0);
364 assert_eq!(snapshots[1].iterations_run, 1);
365 assert_eq!(snapshots[2].iterations_run, 2);
366 }
367
368 #[tokio::test]
369 async fn loop_runner_halts_on_consecutive_failures() {
370 let config = LoopModeConfig {
371 interval_secs: 0,
372 max_iterations: None,
373 halt_on_consecutive_failures: 2,
374 ..LoopModeConfig::default()
375 };
376 let mut runner = LoopRunner::new(config);
377 let tick: TickFn = Box::new(|| Box::pin(async { Err("fail".into()) }));
378 let reason = runner.run(&tick).await;
379 assert_eq!(reason, LoopFinishReason::ConsecutiveFailures);
380 assert_eq!(runner.state.consecutive_failures, 2);
381 }
382
383 #[tokio::test]
384 async fn loop_runner_external_stop() {
385 let config = LoopModeConfig {
386 interval_secs: 0,
387 max_iterations: None,
388 halt_on_consecutive_failures: 999,
389 ..LoopModeConfig::default()
390 };
391 let mut runner = LoopRunner::new(config);
392 let stop = runner.stop_handle();
393 stop.store(true, std::sync::atomic::Ordering::Relaxed);
394 let tick: TickFn = Box::new(|| Box::pin(async { Ok(()) }));
395 let reason = runner.run(&tick).await;
396 assert_eq!(reason, LoopFinishReason::ExternalStop);
397 }
398}