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
7/// Classification of how the agent process ended. This is the adapter-level
8/// reading of the raw wait status; vendor-specific classifications such as
9/// rate limits join in a later phase.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ExitClass {
12    /// Exit status zero. Not proof of successful work on its own.
13    Success,
14    /// A nonzero exit status.
15    Failure(i32),
16    /// No exit code: the process was ended by a signal, including `cancel`.
17    KilledBySignal,
18}
19
20pub fn classify_exit(exit_code: Option<i32>) -> ExitClass {
21    match exit_code {
22        Some(0) => ExitClass::Success,
23        Some(code) => ExitClass::Failure(code),
24        None => ExitClass::KilledBySignal,
25    }
26}
27
28/// Result of one executed aftercare stage.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum StageOutcome {
31    Passed,
32    Failed,
33}
34
35/// Result of an attempted merge of the run branch into the default branch.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum MergeOutcome {
38    Merged,
39    /// The merge conflicted (or otherwise failed); a human must reconcile.
40    /// A default branch that merely moved is handled with a merge commit.
41    Diverged,
42}
43
44/// Everything observed about one finished run. Fields are facts gathered by
45/// the supervisor; none of them are claims made by the agent.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct RunEvidence {
48    /// An operator recorded cancellation intent before the exit was handled.
49    pub cancelled: bool,
50    pub exit: ExitClass,
51    /// Commits on the run branch that are not on the default branch.
52    pub commit_count: u64,
53    /// `None` when no test stage ran: either the exit and commit evidence
54    /// never justified testing, or no test command is configured.
55    pub tests: Option<StageOutcome>,
56    /// `None` when a merge was never attempted.
57    pub merge: Option<MergeOutcome>,
58}
59
60/// Terminal classification of a run. `Cancelled` and `Orphaned` release the
61/// ticket back to `ready`; the other outcomes are also recorded on the ticket.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
63#[serde(rename_all = "snake_case")]
64pub enum Outcome {
65    Merged,
66    Failed,
67    NeedsReview,
68    Cancelled,
69    /// Recovery found neither a live process nor committed work. The
70    /// 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::Orphaned => "orphaned",
82        }
83    }
84}
85
86/// Whether exit and commit evidence justify running the test stage. Tests
87/// exist to qualify committed work for merging, so anything else skips them.
88pub fn wants_tests(exit: ExitClass, commit_count: u64) -> bool {
89    exit == ExitClass::Success && commit_count > 0
90}
91
92/// Whether the evidence so far justifies attempting a merge. A test stage
93/// that ran and failed blocks the merge; an unconfigured test stage does
94/// not, because the operator chose auto-merge policy without one.
95pub fn wants_merge(exit: ExitClass, commit_count: u64, tests: Option<StageOutcome>) -> bool {
96    wants_tests(exit, commit_count) && tests != Some(StageOutcome::Failed)
97}
98
99/// Maps complete evidence to the run's terminal outcome.
100///
101/// Constraints fixed by the design documents:
102/// - Cancellation always wins: the outcome is `Cancelled` regardless of
103///   other evidence, so racing exit and cancel events stay idempotent.
104/// - Exit zero with no commits is NOT successful work.
105/// - A run may only be `Merged` when the merge itself succeeded
106///   (`Some(MergeOutcome::Merged)`).
107/// - A nonzero or killed exit that left commits must preserve the evidence
108///   for a human rather than merging or silently discarding it.
109///
110/// Policy decisions taken here:
111/// - Committed work whose tests failed is `NeedsReview`, not `Failed`:
112///   commits are evidence a human may want to salvage, and discarding them
113///   silently would violate the preserve-the-work constraint above.
114/// - A merge attempt that conflicted is `NeedsReview`: the work passed
115///   tests, only integration needs a human.
116/// - `Failed` is reserved for runs that produced no commits at all; there
117///   is nothing to review.
118pub fn derive_outcome(evidence: &RunEvidence) -> Outcome {
119    if evidence.cancelled {
120        return Outcome::Cancelled;
121    }
122    if evidence.merge == Some(MergeOutcome::Merged) {
123        return Outcome::Merged;
124    }
125    if evidence.commit_count == 0 {
126        return Outcome::Failed;
127    }
128    Outcome::NeedsReview
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    fn evidence() -> RunEvidence {
136        RunEvidence {
137            cancelled: false,
138            exit: ExitClass::Success,
139            commit_count: 0,
140            tests: None,
141            merge: None,
142        }
143    }
144
145    #[test]
146    fn exit_zero_without_commits_is_not_successful_work() {
147        let outcome = derive_outcome(&evidence());
148        assert_ne!(outcome, Outcome::Merged);
149        assert_ne!(outcome, Outcome::Cancelled);
150    }
151
152    #[test]
153    fn a_successful_merge_is_the_only_path_to_merged() {
154        let merged = derive_outcome(&RunEvidence {
155            commit_count: 2,
156            tests: Some(StageOutcome::Passed),
157            merge: Some(MergeOutcome::Merged),
158            ..evidence()
159        });
160        assert_eq!(merged, Outcome::Merged);
161
162        let diverged = derive_outcome(&RunEvidence {
163            commit_count: 2,
164            tests: Some(StageOutcome::Passed),
165            merge: Some(MergeOutcome::Diverged),
166            ..evidence()
167        });
168        assert_ne!(diverged, Outcome::Merged);
169    }
170
171    #[test]
172    fn failed_tests_never_reach_merged() {
173        let outcome = derive_outcome(&RunEvidence {
174            commit_count: 1,
175            tests: Some(StageOutcome::Failed),
176            ..evidence()
177        });
178        assert_ne!(outcome, Outcome::Merged);
179        assert_ne!(outcome, Outcome::Cancelled);
180    }
181
182    #[test]
183    fn a_crashed_agent_with_commits_preserves_the_work_for_a_human() {
184        let outcome = derive_outcome(&RunEvidence {
185            exit: ExitClass::Failure(1),
186            commit_count: 3,
187            ..evidence()
188        });
189        assert_eq!(outcome, Outcome::NeedsReview);
190    }
191
192    #[test]
193    fn cancellation_wins_over_every_other_reading() {
194        let outcome = derive_outcome(&RunEvidence {
195            cancelled: true,
196            exit: ExitClass::KilledBySignal,
197            commit_count: 5,
198            tests: Some(StageOutcome::Passed),
199            merge: Some(MergeOutcome::Merged),
200        });
201        assert_eq!(outcome, Outcome::Cancelled);
202    }
203
204    #[test]
205    fn exit_classification_reads_the_wait_status() {
206        assert_eq!(classify_exit(Some(0)), ExitClass::Success);
207        assert_eq!(classify_exit(Some(2)), ExitClass::Failure(2));
208        assert_eq!(classify_exit(None), ExitClass::KilledBySignal);
209    }
210
211    #[test]
212    fn test_and_merge_gates_follow_the_evidence() {
213        assert!(wants_tests(ExitClass::Success, 1));
214        assert!(!wants_tests(ExitClass::Success, 0));
215        assert!(!wants_tests(ExitClass::Failure(1), 4));
216        assert!(!wants_tests(ExitClass::KilledBySignal, 4));
217
218        assert!(wants_merge(
219            ExitClass::Success,
220            1,
221            Some(StageOutcome::Passed)
222        ));
223        assert!(wants_merge(ExitClass::Success, 1, None));
224        assert!(!wants_merge(
225            ExitClass::Success,
226            1,
227            Some(StageOutcome::Failed)
228        ));
229        assert!(!wants_merge(ExitClass::Failure(1), 1, None));
230    }
231}