1use serde::Serialize;
6
7use crate::vendor::VendorErrorClass;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ExitClass {
14 Success,
16 Failure(i32),
18 KilledBySignal,
20}
21
22pub fn classify_exit(exit_code: Option<i32>) -> ExitClass {
23 match exit_code {
24 Some(0) => ExitClass::Success,
25 Some(code) => ExitClass::Failure(code),
26 None => ExitClass::KilledBySignal,
27 }
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum FlowHalt {
37 FirstStage,
39 LaterStage,
42}
43
44impl FlowHalt {
45 pub fn at_stage(stage_index: usize) -> Self {
47 if stage_index == 0 {
48 Self::FirstStage
49 } else {
50 Self::LaterStage
51 }
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum MergeOutcome {
58 Merged,
59 Diverged,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct RunEvidence {
68 pub cancelled: bool,
70 pub stalled: bool,
72 pub exit: ExitClass,
73 pub vendor_error: Option<VendorErrorClass>,
75 pub commit_count: Option<usize>,
80 pub halt: Option<FlowHalt>,
82 pub merge: Option<MergeOutcome>,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
89#[serde(rename_all = "snake_case")]
90pub enum Outcome {
91 Merged,
92 Failed,
93 NeedsReview,
94 Cancelled,
95 RateLimited,
97 Orphaned,
100}
101
102impl Outcome {
103 pub fn as_str(self) -> &'static str {
104 match self {
105 Self::Merged => "merged",
106 Self::Failed => "failed",
107 Self::NeedsReview => "needs_review",
108 Self::Cancelled => "cancelled",
109 Self::RateLimited => "rate_limited",
110 Self::Orphaned => "orphaned",
111 }
112 }
113}
114
115pub fn derive_outcome(evidence: &RunEvidence) -> Outcome {
131 if evidence.cancelled {
132 return Outcome::Cancelled;
133 }
134 if evidence.stalled {
135 return Outcome::Failed;
136 }
137 if let Some(class) = evidence.vendor_error {
138 return if class.requires_cooldown() {
139 Outcome::RateLimited
140 } else {
141 Outcome::Failed
142 };
143 }
144 if evidence.merge == Some(MergeOutcome::Merged) {
145 return Outcome::Merged;
146 }
147 if evidence.exit != ExitClass::Success {
148 return Outcome::Failed;
149 }
150 if evidence.halt == Some(FlowHalt::LaterStage) && evidence.commit_count == Some(0) {
151 return Outcome::Failed;
152 }
153 Outcome::NeedsReview
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 fn evidence() -> RunEvidence {
161 RunEvidence {
162 cancelled: false,
163 stalled: false,
164 exit: ExitClass::Success,
165 vendor_error: None,
166 commit_count: Some(0),
167 halt: None,
168 merge: None,
169 }
170 }
171
172 #[test]
173 fn a_successful_exit_without_a_merge_needs_review() {
174 let outcome = derive_outcome(&evidence());
175 assert_eq!(outcome, Outcome::NeedsReview);
176 }
177
178 #[test]
179 fn a_successful_merge_is_the_only_path_to_merged() {
180 let merged = derive_outcome(&RunEvidence {
181 commit_count: Some(1),
182 merge: Some(MergeOutcome::Merged),
183 ..evidence()
184 });
185 assert_eq!(merged, Outcome::Merged);
186
187 let diverged = derive_outcome(&RunEvidence {
188 commit_count: Some(1),
189 merge: Some(MergeOutcome::Diverged),
190 ..evidence()
191 });
192 assert_ne!(diverged, Outcome::Merged);
193 }
194
195 #[test]
199 fn a_first_stage_halt_leaves_the_exit_class_to_speak() {
200 assert_eq!(
201 derive_outcome(&RunEvidence {
202 halt: Some(FlowHalt::FirstStage),
203 ..evidence()
204 }),
205 Outcome::NeedsReview
206 );
207 assert_eq!(
208 derive_outcome(&RunEvidence {
209 halt: Some(FlowHalt::FirstStage),
210 exit: ExitClass::Failure(1),
211 ..evidence()
212 }),
213 Outcome::Failed
214 );
215 assert_eq!(FlowHalt::at_stage(0), FlowHalt::FirstStage);
216 assert_eq!(FlowHalt::at_stage(1), FlowHalt::LaterStage);
217 }
218
219 #[test]
220 fn a_later_stage_halting_with_commits_needs_review() {
221 let outcome = derive_outcome(&RunEvidence {
222 commit_count: Some(1),
223 halt: Some(FlowHalt::LaterStage),
224 ..evidence()
225 });
226 assert_eq!(outcome, Outcome::NeedsReview);
227 }
228
229 #[test]
230 fn a_later_stage_halting_without_commits_fails() {
231 let outcome = derive_outcome(&RunEvidence {
232 halt: Some(FlowHalt::LaterStage),
233 ..evidence()
234 });
235 assert_eq!(outcome, Outcome::Failed);
236 }
237
238 #[test]
239 fn a_later_stage_halting_with_unknown_commits_needs_review() {
240 let outcome = derive_outcome(&RunEvidence {
241 commit_count: None,
242 halt: Some(FlowHalt::LaterStage),
243 ..evidence()
244 });
245 assert_eq!(outcome, Outcome::NeedsReview);
246 }
247
248 #[test]
249 fn a_crashed_agent_fails_regardless_of_git_history() {
250 let outcome = derive_outcome(&RunEvidence {
251 exit: ExitClass::Failure(1),
252 ..evidence()
253 });
254 assert_eq!(outcome, Outcome::Failed);
255 }
256
257 #[test]
258 fn cancellation_wins_over_every_other_reading() {
259 let outcome = derive_outcome(&RunEvidence {
260 cancelled: true,
261 stalled: true,
262 exit: ExitClass::KilledBySignal,
263 vendor_error: None,
264 commit_count: Some(5),
265 halt: None,
266 merge: Some(MergeOutcome::Merged),
267 });
268 assert_eq!(outcome, Outcome::Cancelled);
269 }
270
271 #[test]
272 fn output_stall_fails_before_vendor_classification() {
273 let outcome = derive_outcome(&RunEvidence {
274 stalled: true,
275 vendor_error: Some(VendorErrorClass::RateLimited),
276 ..evidence()
277 });
278 assert_eq!(outcome, Outcome::Failed);
279 }
280
281 #[test]
282 fn exit_classification_reads_the_wait_status() {
283 assert_eq!(classify_exit(Some(0)), ExitClass::Success);
284 assert_eq!(classify_exit(Some(2)), ExitClass::Failure(2));
285 assert_eq!(classify_exit(None), ExitClass::KilledBySignal);
286 }
287 #[test]
288 fn vendor_rejections_follow_code_owned_policy() {
289 for class in [
290 VendorErrorClass::AuthenticationRequired,
291 VendorErrorClass::InvalidConfiguration,
292 ] {
293 assert_eq!(
294 derive_outcome(&RunEvidence {
295 vendor_error: Some(class),
296 ..evidence()
297 }),
298 Outcome::Failed
299 );
300 }
301 for class in [
302 VendorErrorClass::RateLimited,
303 VendorErrorClass::UnknownRejection,
304 ] {
305 assert_eq!(
306 derive_outcome(&RunEvidence {
307 vendor_error: Some(class),
308 ..evidence()
309 }),
310 Outcome::RateLimited
311 );
312 }
313 }
314}