Skip to main content

sloop/
eligibility.rs

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