Skip to main content

sloop/
eligibility.rs

1//! Why a ticket is not being dispatched right now. One pure decision used by
2//! the dispatcher's gates and reported verbatim by the `list` verb, so the
3//! operator sees exactly what the scheduler saw.
4
5/// Global dispatcher gates, snapshotted at the moment of the question.
6#[derive(Debug, Clone, Copy)]
7pub struct Gates {
8    pub paused: bool,
9    pub draining: bool,
10    pub storage_writable: bool,
11    pub agent_configured: bool,
12    pub hours_open: bool,
13    pub at_capacity: bool,
14    pub has_queued_activation: bool,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum Ineligible {
19    Failed { attempts: i64 },
20    Held,
21    Blocked { blockers: Vec<String> },
22    Claimed { run: String },
23    NoAgentConfigured,
24    Paused,
25    Draining,
26    StorageFull,
27    OutsideRunningHours,
28    AtCapacity,
29    NoActivation,
30}
31
32impl Ineligible {
33    pub fn describe(&self) -> String {
34        match self {
35            Self::Failed { attempts } => {
36                format!("failed after {attempts} attempt(s); requeue with `sloop retry`")
37            }
38            Self::Held => "held by operator; release with `sloop ready`".into(),
39            Self::Blocked { blockers } => {
40                format!("blocked by unmerged {}", blockers.join(", "))
41            }
42            Self::Claimed { run } => format!("claimed by run {run}"),
43            Self::NoAgentConfigured => "no agent targets configured".into(),
44            Self::Paused => "scheduler is paused; resume with `sloop resume`".into(),
45            Self::Draining => {
46                "scheduler is draining for restart; cancel with `sloop resume`".into()
47            }
48            Self::StorageFull => {
49                "database storage is full; free disk space to resume dispatch".into()
50            }
51            Self::OutsideRunningHours => "outside configured running hours".into(),
52            Self::AtCapacity => "all agent slots are busy".into(),
53            Self::NoActivation => "ready but no queued activation; enqueue with `sloop run`".into(),
54        }
55    }
56}
57
58/// Blocked is derived from dependency state rather than persisted on the
59/// ticket, so display state changes as soon as the last blocker merges.
60pub fn display_state<'a>(state: &'a str, reason: Option<&Ineligible>) -> &'a str {
61    if matches!(reason, Some(Ineligible::Blocked { .. })) {
62        "blocked"
63    } else {
64        state
65    }
66}
67
68/// Ticket-level reasons win over global gates: a failed ticket is failed
69/// whether or not the scheduler is paused.
70pub fn ticket_ineligibility(
71    state: &str,
72    attempts: i64,
73    active_run: Option<&str>,
74    unmerged_blockers: &[String],
75    gates: &Gates,
76) -> Option<Ineligible> {
77    match state {
78        "failed" => return Some(Ineligible::Failed { attempts }),
79        "held" => return Some(Ineligible::Held),
80        "claimed" => {
81            return Some(Ineligible::Claimed {
82                run: active_run.unwrap_or("?").to_owned(),
83            });
84        }
85        "merged" | "needs_review" => return None,
86        _ => {}
87    }
88    if !unmerged_blockers.is_empty() {
89        return Some(Ineligible::Blocked {
90            blockers: unmerged_blockers.to_vec(),
91        });
92    }
93    if !gates.agent_configured {
94        Some(Ineligible::NoAgentConfigured)
95    } else if gates.paused {
96        Some(Ineligible::Paused)
97    } else if gates.draining {
98        Some(Ineligible::Draining)
99    } else if !gates.storage_writable {
100        Some(Ineligible::StorageFull)
101    } else if !gates.hours_open {
102        Some(Ineligible::OutsideRunningHours)
103    } else if gates.at_capacity {
104        Some(Ineligible::AtCapacity)
105    } else if !gates.has_queued_activation {
106        Some(Ineligible::NoActivation)
107    } else {
108        None
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    fn open_gates() -> Gates {
117        Gates {
118            paused: false,
119            draining: false,
120            storage_writable: true,
121            agent_configured: true,
122            hours_open: true,
123            at_capacity: false,
124            has_queued_activation: true,
125        }
126    }
127
128    fn ineligibility(
129        state: &str,
130        attempts: i64,
131        active_run: Option<&str>,
132        gates: &Gates,
133    ) -> Option<Ineligible> {
134        ticket_ineligibility(state, attempts, active_run, &[], gates)
135    }
136
137    #[test]
138    fn ticket_states_explain_themselves_before_global_gates() {
139        let mut gates = open_gates();
140        gates.paused = true; // ticket-level reasons must win over global ones
141        assert!(matches!(
142            ineligibility("failed", 2, None, &gates),
143            Some(Ineligible::Failed { attempts: 2 })
144        ));
145        assert!(matches!(
146            ineligibility("held", 0, None, &gates),
147            Some(Ineligible::Held)
148        ));
149        let claimed = ineligibility("claimed", 1, Some("R4"), &gates);
150        assert!(matches!(claimed, Some(Ineligible::Claimed { ref run }) if run == "R4"));
151    }
152
153    #[test]
154    fn blockers_are_named_before_global_gate_reasons() {
155        let mut gates = open_gates();
156        gates.paused = true;
157        let blockers = vec!["T1".into(), "T2".into()];
158        let reason = ticket_ineligibility("ready", 0, None, &blockers, &gates);
159
160        assert_eq!(
161            reason,
162            Some(Ineligible::Blocked {
163                blockers: blockers.clone()
164            })
165        );
166        assert_eq!(
167            reason.as_ref().unwrap().describe(),
168            "blocked by unmerged T1, T2"
169        );
170        assert_eq!(display_state("ready", reason.as_ref()), "blocked");
171    }
172
173    #[test]
174    fn terminal_states_need_no_reason() {
175        let gates = open_gates();
176        assert!(ineligibility("merged", 1, None, &gates).is_none());
177        assert!(ineligibility("needs_review", 1, None, &gates).is_none());
178    }
179
180    #[test]
181    fn global_gates_apply_to_ready_tickets_in_priority_order() {
182        let mut gates = open_gates();
183        gates.agent_configured = false;
184        gates.paused = true;
185        assert!(matches!(
186            ineligibility("ready", 0, None, &gates),
187            Some(Ineligible::NoAgentConfigured)
188        ));
189
190        let mut gates = open_gates();
191        gates.paused = true;
192        assert!(matches!(
193            ineligibility("ready", 0, None, &gates),
194            Some(Ineligible::Paused)
195        ));
196
197        let mut gates = open_gates();
198        gates.draining = true;
199        assert!(matches!(
200            ineligibility("ready", 0, None, &gates),
201            Some(Ineligible::Draining)
202        ));
203
204        let mut gates = open_gates();
205        gates.storage_writable = false;
206        assert!(matches!(
207            ineligibility("ready", 0, None, &gates),
208            Some(Ineligible::StorageFull)
209        ));
210
211        let mut gates = open_gates();
212        gates.hours_open = false;
213        assert!(matches!(
214            ineligibility("ready", 0, None, &gates),
215            Some(Ineligible::OutsideRunningHours)
216        ));
217
218        let mut gates = open_gates();
219        gates.at_capacity = true;
220        assert!(matches!(
221            ineligibility("ready", 0, None, &gates),
222            Some(Ineligible::AtCapacity)
223        ));
224
225        let mut gates = open_gates();
226        gates.has_queued_activation = false;
227        assert!(matches!(
228            ineligibility("ready", 0, None, &gates),
229            Some(Ineligible::NoActivation)
230        ));
231    }
232
233    #[test]
234    fn a_dispatchable_ready_ticket_has_no_reason() {
235        assert!(ineligibility("ready", 0, None, &open_gates()).is_none());
236    }
237
238    #[test]
239    fn descriptions_are_actionable() {
240        assert_eq!(
241            Ineligible::Failed { attempts: 2 }.describe(),
242            "failed after 2 attempt(s); requeue with `sloop retry`"
243        );
244        assert_eq!(
245            Ineligible::NoActivation.describe(),
246            "ready but no queued activation; enqueue with `sloop run`"
247        );
248    }
249}