Skip to main content

git_paw/supervisor/
stall.rs

1//! Stall detection for the supervisor poll loop.
2//!
3//! Implements the stall-detection integration of the
4//! `auto-approve-patterns` change: scan [`crate::broker::BrokerState`] for
5//! agents whose status is `working` (or one of the still-active labels)
6//! and whose `last_seen` exceeds the configured threshold, returning their
7//! agent IDs for the auto-approver to act on.
8
9use std::time::Duration;
10
11use crate::broker::BrokerState;
12
13/// Status labels considered "terminal" — these agents are NOT stalled even
14/// if quiet, so the auto-approver MUST skip them per the `automatic-approval`
15/// spec ("Skip terminal-status agents").
16pub const TERMINAL_STATUSES: &[&str] = &["done", "verified", "blocked", "committed"];
17
18/// Returns the agent IDs whose status is non-terminal but whose `last_seen`
19/// is older than `threshold`.
20///
21/// "Working" here means anything not in [`TERMINAL_STATUSES`] — using a
22/// negative match keeps the supervisor from missing newly introduced
23/// active labels.
24#[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        // A future status label we have not seen before should be treated
108        // as non-terminal so the supervisor still notices it stalled.
109        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}