Skip to main content

sloop/
outcome.rs

1//! Outcome derivation. Evidence in, verdict out: this module is the only
2//! place a run's terminal outcome is decided, and it is pure so policy can
3//! be tested without a daemon, a process, or a repository.
4
5use serde::Serialize;
6
7use crate::vendor::VendorErrorClass;
8
9/// Classification of how the agent process ended. This is the adapter-level
10/// reading of the raw wait status; vendor-specific classifications such as
11/// rate limits join in a later phase.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ExitClass {
14    /// Exit status zero. Not proof of successful work on its own.
15    Success,
16    /// A nonzero exit status.
17    Failure(i32),
18    /// No exit code: the process was ended by a signal, including `cancel`.
19    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/// Which stage a halted flow walk stopped on. The distinction the outcome
31/// turns on is only ever first-versus-later: the first stage is the run's own
32/// attempt at the work and its failure is fully described by the process exit
33/// already in evidence, while a later one halting means earlier stages passed
34/// and whatever they produced is worth preserving.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum FlowHalt {
37    /// The flow's first stage failed.
38    FirstStage,
39    /// A stage after the first failed, or the driver could not carry the walk
40    /// past one.
41    LaterStage,
42}
43
44impl FlowHalt {
45    /// Classifies a halt by the index of the stage that stopped the walk.
46    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/// Result of an attempted merge of the run branch into the default branch.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum MergeOutcome {
58    Merged,
59    /// The merge conflicted (or otherwise failed); a human must reconcile.
60    /// A default branch that merely moved is handled with a merge commit.
61    Diverged,
62}
63
64/// Everything observed about one finished run. Fields are facts gathered by
65/// the supervisor; none of them are claims made by the agent.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct RunEvidence {
68    /// An operator recorded cancellation intent before the exit was handled.
69    pub cancelled: bool,
70    /// The output watchdog recorded termination intent before process exit.
71    pub stalled: bool,
72    pub exit: ExitClass,
73    /// A rejection recognized from the adapter's captured output.
74    pub vendor_error: Option<VendorErrorClass>,
75    /// Commits are activity metadata except when the walk halts past the
76    /// first stage: committed work is then preserved for review, while a
77    /// known unchanged branch failed. `None` means commit enumeration was
78    /// incomplete.
79    pub commit_count: Option<usize>,
80    /// Where the flow walk stopped short, if it did.
81    pub halt: Option<FlowHalt>,
82    /// `None` when a merge was never attempted.
83    pub merge: Option<MergeOutcome>,
84}
85
86/// Terminal classification of a run. `Cancelled` and `Orphaned` release the
87/// ticket back to `ready`; the other outcomes are also recorded on the ticket.
88#[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    /// A retryable vendor rejection released the ticket under a cooldown.
96    RateLimited,
97    /// Recovery found no live process before the agent exit was checkpointed.
98    /// The worktree stays available for inspection and the ticket is released.
99    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
115/// Maps complete evidence to the run's terminal outcome.
116///
117/// Constraints fixed by the design documents:
118/// - Cancellation always wins: the outcome is `Cancelled` regardless of
119///   other evidence, so racing exit and cancel events stay idempotent.
120/// - A run may only be `Merged` when the merge itself succeeded
121///   (`Some(MergeOutcome::Merged)`).
122///
123/// Policy decisions taken here:
124/// - A successful exit whose walk halted after the first stage is
125///   `NeedsReview` when its run branch has commits, otherwise `Failed`.
126/// - A nonzero or killed exit is `Failed`; Git history does not upgrade or
127///   downgrade the verdict.
128/// - A merge attempt that conflicted is `NeedsReview`: the work passed its
129///   preceding stages, only integration needs a human.
130pub 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    /// The first stage's own failure is already fully described by the exit
196    /// class in evidence, so halting there adds nothing: a clean exit that
197    /// simply produced no commits is still work a human should look at.
198    #[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}