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 agent_configured: bool,
10    pub hours_open: bool,
11    pub at_capacity: bool,
12    pub has_queued_activation: bool,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Ineligible {
17    Failed { attempts: i64 },
18    Held,
19    Blocked,
20    Claimed { run: String },
21    NoAgentConfigured,
22    Paused,
23    OutsideRunningHours,
24    AtCapacity,
25    NoActivation,
26}
27
28impl Ineligible {
29    pub fn describe(&self) -> String {
30        match self {
31            Self::Failed { attempts } => {
32                format!("failed after {attempts} attempt(s); requeue with `sloop retry`")
33            }
34            Self::Held => "held by operator; release with `sloop ready`".into(),
35            Self::Blocked => "blocked".into(),
36            Self::Claimed { run } => format!("claimed by run {run}"),
37            Self::NoAgentConfigured => "no agent targets configured".into(),
38            Self::Paused => "scheduler is paused; resume with `sloop resume`".into(),
39            Self::OutsideRunningHours => "outside configured running hours".into(),
40            Self::AtCapacity => "all agent slots are busy".into(),
41            Self::NoActivation => "ready but no queued activation; enqueue with `sloop run`".into(),
42        }
43    }
44}
45
46/// Ticket-level reasons win over global gates: a failed ticket is failed
47/// whether or not the scheduler is paused.
48pub fn ticket_ineligibility(
49    state: &str,
50    attempts: i64,
51    active_run: Option<&str>,
52    gates: &Gates,
53) -> Option<Ineligible> {
54    match state {
55        "failed" => return Some(Ineligible::Failed { attempts }),
56        "held" => return Some(Ineligible::Held),
57        "blocked" => return Some(Ineligible::Blocked),
58        "claimed" => {
59            return Some(Ineligible::Claimed {
60                run: active_run.unwrap_or("?").to_owned(),
61            });
62        }
63        "merged" | "needs_review" => return None,
64        _ => {}
65    }
66    if !gates.agent_configured {
67        Some(Ineligible::NoAgentConfigured)
68    } else if gates.paused {
69        Some(Ineligible::Paused)
70    } else if !gates.hours_open {
71        Some(Ineligible::OutsideRunningHours)
72    } else if gates.at_capacity {
73        Some(Ineligible::AtCapacity)
74    } else if !gates.has_queued_activation {
75        Some(Ineligible::NoActivation)
76    } else {
77        None
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    fn open_gates() -> Gates {
86        Gates {
87            paused: false,
88            agent_configured: true,
89            hours_open: true,
90            at_capacity: false,
91            has_queued_activation: true,
92        }
93    }
94
95    #[test]
96    fn ticket_states_explain_themselves_before_global_gates() {
97        let mut gates = open_gates();
98        gates.paused = true; // ticket-level reasons must win over global ones
99        assert!(matches!(
100            ticket_ineligibility("failed", 2, None, &gates),
101            Some(Ineligible::Failed { attempts: 2 })
102        ));
103        assert!(matches!(
104            ticket_ineligibility("held", 0, None, &gates),
105            Some(Ineligible::Held)
106        ));
107        assert!(matches!(
108            ticket_ineligibility("blocked", 0, None, &gates),
109            Some(Ineligible::Blocked)
110        ));
111        let claimed = ticket_ineligibility("claimed", 1, Some("R4"), &gates);
112        assert!(matches!(claimed, Some(Ineligible::Claimed { ref run }) if run == "R4"));
113    }
114
115    #[test]
116    fn terminal_states_need_no_reason() {
117        let gates = open_gates();
118        assert!(ticket_ineligibility("merged", 1, None, &gates).is_none());
119        assert!(ticket_ineligibility("needs_review", 1, None, &gates).is_none());
120    }
121
122    #[test]
123    fn global_gates_apply_to_ready_tickets_in_priority_order() {
124        let mut gates = open_gates();
125        gates.agent_configured = false;
126        gates.paused = true;
127        assert!(matches!(
128            ticket_ineligibility("ready", 0, None, &gates),
129            Some(Ineligible::NoAgentConfigured)
130        ));
131
132        let mut gates = open_gates();
133        gates.paused = true;
134        assert!(matches!(
135            ticket_ineligibility("ready", 0, None, &gates),
136            Some(Ineligible::Paused)
137        ));
138
139        let mut gates = open_gates();
140        gates.hours_open = false;
141        assert!(matches!(
142            ticket_ineligibility("ready", 0, None, &gates),
143            Some(Ineligible::OutsideRunningHours)
144        ));
145
146        let mut gates = open_gates();
147        gates.at_capacity = true;
148        assert!(matches!(
149            ticket_ineligibility("ready", 0, None, &gates),
150            Some(Ineligible::AtCapacity)
151        ));
152
153        let mut gates = open_gates();
154        gates.has_queued_activation = false;
155        assert!(matches!(
156            ticket_ineligibility("ready", 0, None, &gates),
157            Some(Ineligible::NoActivation)
158        ));
159    }
160
161    #[test]
162    fn a_dispatchable_ready_ticket_has_no_reason() {
163        assert!(ticket_ineligibility("ready", 0, None, &open_gates()).is_none());
164    }
165
166    #[test]
167    fn descriptions_are_actionable() {
168        assert_eq!(
169            Ineligible::Failed { attempts: 2 }.describe(),
170            "failed after 2 attempt(s); requeue with `sloop retry`"
171        );
172        assert_eq!(
173            Ineligible::NoActivation.describe(),
174            "ready but no queued activation; enqueue with `sloop run`"
175        );
176    }
177}