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_error::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/// Result of an attempted merge of the run branch into the default branch.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum MergeOutcome {
33    Merged,
34    /// The merge conflicted (or otherwise failed); a human must reconcile.
35    /// A default branch that merely moved is handled with a merge commit.
36    Diverged,
37}
38
39/// Everything observed about one finished run. Fields are facts gathered by
40/// the supervisor; none of them are claims made by the agent.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct RunEvidence {
43    /// An operator recorded cancellation intent before the exit was handled.
44    pub cancelled: bool,
45    pub exit: ExitClass,
46    /// A rejection recognized from the adapter's captured output.
47    pub vendor_error: Option<VendorErrorClass>,
48    /// Commits are activity metadata except when aftercare fails: committed
49    /// work is then preserved for review, while a known unchanged branch
50    /// failed. `None` means commit enumeration was incomplete.
51    pub commit_count: Option<usize>,
52    /// Whether a flow stage after the first stage failed.
53    pub aftercare_failed: bool,
54    /// `None` when a merge was never attempted.
55    pub merge: Option<MergeOutcome>,
56}
57
58/// Terminal classification of a run. `Cancelled` and `Orphaned` release the
59/// ticket back to `ready`; the other outcomes are also recorded on the ticket.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
61#[serde(rename_all = "snake_case")]
62pub enum Outcome {
63    Merged,
64    Failed,
65    NeedsReview,
66    Cancelled,
67    /// A retryable vendor rejection released the ticket under a cooldown.
68    RateLimited,
69    /// Recovery found no live process before the agent exit was checkpointed.
70    /// The worktree stays available for inspection and the ticket is released.
71    Orphaned,
72}
73
74impl Outcome {
75    pub fn as_str(self) -> &'static str {
76        match self {
77            Self::Merged => "merged",
78            Self::Failed => "failed",
79            Self::NeedsReview => "needs_review",
80            Self::Cancelled => "cancelled",
81            Self::RateLimited => "rate_limited",
82            Self::Orphaned => "orphaned",
83        }
84    }
85}
86
87/// Maps complete evidence to the run's terminal outcome.
88///
89/// Constraints fixed by the design documents:
90/// - Cancellation always wins: the outcome is `Cancelled` regardless of
91///   other evidence, so racing exit and cancel events stay idempotent.
92/// - A run may only be `Merged` when the merge itself succeeded
93///   (`Some(MergeOutcome::Merged)`).
94///
95/// Policy decisions taken here:
96/// - A successful exit whose aftercare failed is `NeedsReview` when its run
97///   branch has commits, otherwise `Failed`.
98/// - A nonzero or killed exit is `Failed`; Git history does not upgrade or
99///   downgrade the verdict.
100/// - A merge attempt that conflicted is `NeedsReview`: the work passed its
101///   preceding stages, only integration needs a human.
102pub fn derive_outcome(evidence: &RunEvidence) -> Outcome {
103    if evidence.cancelled {
104        return Outcome::Cancelled;
105    }
106    if let Some(class) = evidence.vendor_error {
107        return if class.requires_cooldown() {
108            Outcome::RateLimited
109        } else {
110            Outcome::Failed
111        };
112    }
113    if evidence.merge == Some(MergeOutcome::Merged) {
114        return Outcome::Merged;
115    }
116    if evidence.exit != ExitClass::Success {
117        return Outcome::Failed;
118    }
119    if evidence.aftercare_failed && evidence.commit_count == Some(0) {
120        return Outcome::Failed;
121    }
122    Outcome::NeedsReview
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    fn evidence() -> RunEvidence {
130        RunEvidence {
131            cancelled: false,
132            exit: ExitClass::Success,
133            vendor_error: None,
134            commit_count: Some(0),
135            aftercare_failed: false,
136            merge: None,
137        }
138    }
139
140    #[test]
141    fn a_successful_exit_without_a_merge_needs_review() {
142        let outcome = derive_outcome(&evidence());
143        assert_eq!(outcome, Outcome::NeedsReview);
144    }
145
146    #[test]
147    fn a_successful_merge_is_the_only_path_to_merged() {
148        let merged = derive_outcome(&RunEvidence {
149            commit_count: Some(1),
150            merge: Some(MergeOutcome::Merged),
151            ..evidence()
152        });
153        assert_eq!(merged, Outcome::Merged);
154
155        let diverged = derive_outcome(&RunEvidence {
156            commit_count: Some(1),
157            merge: Some(MergeOutcome::Diverged),
158            ..evidence()
159        });
160        assert_ne!(diverged, Outcome::Merged);
161    }
162
163    #[test]
164    fn failed_aftercare_with_commits_needs_review() {
165        let outcome = derive_outcome(&RunEvidence {
166            commit_count: Some(1),
167            aftercare_failed: true,
168            ..evidence()
169        });
170        assert_eq!(outcome, Outcome::NeedsReview);
171    }
172
173    #[test]
174    fn failed_aftercare_without_commits_fails() {
175        let outcome = derive_outcome(&RunEvidence {
176            aftercare_failed: true,
177            ..evidence()
178        });
179        assert_eq!(outcome, Outcome::Failed);
180    }
181
182    #[test]
183    fn failed_aftercare_with_unknown_commits_needs_review() {
184        let outcome = derive_outcome(&RunEvidence {
185            commit_count: None,
186            aftercare_failed: true,
187            ..evidence()
188        });
189        assert_eq!(outcome, Outcome::NeedsReview);
190    }
191
192    #[test]
193    fn a_crashed_agent_fails_regardless_of_git_history() {
194        let outcome = derive_outcome(&RunEvidence {
195            exit: ExitClass::Failure(1),
196            ..evidence()
197        });
198        assert_eq!(outcome, Outcome::Failed);
199    }
200
201    #[test]
202    fn cancellation_wins_over_every_other_reading() {
203        let outcome = derive_outcome(&RunEvidence {
204            cancelled: true,
205            exit: ExitClass::KilledBySignal,
206            vendor_error: None,
207            commit_count: Some(5),
208            aftercare_failed: false,
209            merge: Some(MergeOutcome::Merged),
210        });
211        assert_eq!(outcome, Outcome::Cancelled);
212    }
213
214    #[test]
215    fn exit_classification_reads_the_wait_status() {
216        assert_eq!(classify_exit(Some(0)), ExitClass::Success);
217        assert_eq!(classify_exit(Some(2)), ExitClass::Failure(2));
218        assert_eq!(classify_exit(None), ExitClass::KilledBySignal);
219    }
220    #[test]
221    fn vendor_rejections_follow_code_owned_policy() {
222        for class in [
223            VendorErrorClass::AuthenticationRequired,
224            VendorErrorClass::InvalidConfiguration,
225        ] {
226            assert_eq!(
227                derive_outcome(&RunEvidence {
228                    vendor_error: Some(class),
229                    ..evidence()
230                }),
231                Outcome::Failed
232            );
233        }
234        for class in [
235            VendorErrorClass::RateLimited,
236            VendorErrorClass::UnknownRejection,
237        ] {
238            assert_eq!(
239                derive_outcome(&RunEvidence {
240                    vendor_error: Some(class),
241                    ..evidence()
242                }),
243                Outcome::RateLimited
244            );
245        }
246    }
247}