1#[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
58pub 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
68pub 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; 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}