git_paw/supervisor/
stall.rs1use std::time::Duration;
10
11use crate::broker::BrokerState;
12
13pub const TERMINAL_STATUSES: &[&str] = &["done", "verified", "blocked", "committed"];
17
18#[must_use]
25pub fn detect_stalled_agents(state: &BrokerState, threshold: Duration) -> Vec<String> {
26 let inner = state.read();
27 inner
28 .agents
29 .values()
30 .filter(|record| !TERMINAL_STATUSES.contains(&record.status.as_str()))
31 .filter(|record| record.last_seen.elapsed() >= threshold)
32 .map(|record| record.agent_id.clone())
33 .collect()
34}
35
36#[cfg(test)]
37mod tests {
38 use super::*;
39 use crate::broker::messages::{BrokerMessage, StatusPayload};
40 use crate::broker::{AgentRecord, BrokerState};
41 use std::time::{Duration, Instant};
42
43 fn insert_record(state: &BrokerState, id: &str, status: &str, last_seen: Instant) {
44 let mut inner = state.write();
45 inner.agents.insert(
46 id.to_string(),
47 AgentRecord {
48 agent_id: id.to_string(),
49 status: status.to_string(),
50 last_seen,
51 last_message: Some(BrokerMessage::Status {
52 agent_id: id.to_string(),
53 payload: StatusPayload {
54 status: status.to_string(),
55 modified_files: Vec::new(),
56 message: None,
57 ..Default::default()
58 },
59 }),
60 last_committed_at: None,
61 },
62 );
63 }
64
65 #[test]
66 fn fresh_working_agent_is_not_stalled() {
67 let state = BrokerState::new(None);
68 insert_record(&state, "agent-fresh", "working", Instant::now());
69 let stalled = detect_stalled_agents(&state, Duration::from_secs(30));
70 assert!(
71 stalled.is_empty(),
72 "fresh working agent must not be stalled"
73 );
74 }
75
76 #[test]
77 fn stale_working_agent_is_stalled() {
78 let state = BrokerState::new(None);
79 let past = Instant::now().checked_sub(Duration::from_mins(2)).unwrap();
80 insert_record(&state, "agent-stuck", "working", past);
81 let stalled = detect_stalled_agents(&state, Duration::from_secs(30));
82 assert_eq!(stalled, vec!["agent-stuck".to_string()]);
83 }
84
85 #[test]
86 fn terminal_status_done_is_skipped_even_if_stale() {
87 let state = BrokerState::new(None);
88 let past = Instant::now().checked_sub(Duration::from_mins(10)).unwrap();
89 insert_record(&state, "agent-done", "done", past);
90 let stalled = detect_stalled_agents(&state, Duration::from_secs(30));
91 assert!(stalled.is_empty(), "done is terminal — never stalled");
92 }
93
94 #[test]
95 fn terminal_statuses_are_all_skipped() {
96 let state = BrokerState::new(None);
97 let past = Instant::now().checked_sub(Duration::from_mins(10)).unwrap();
98 for status in TERMINAL_STATUSES {
99 insert_record(&state, &format!("a-{status}"), status, past);
100 }
101 let stalled = detect_stalled_agents(&state, Duration::from_secs(30));
102 assert!(stalled.is_empty());
103 }
104
105 #[test]
106 fn unknown_status_label_treated_as_active() {
107 let state = BrokerState::new(None);
110 let past = Instant::now().checked_sub(Duration::from_mins(2)).unwrap();
111 insert_record(&state, "agent-x", "researching", past);
112 let stalled = detect_stalled_agents(&state, Duration::from_secs(30));
113 assert_eq!(stalled, vec!["agent-x".to_string()]);
114 }
115}