| 1 | #![allow(dead_code)] // Receipt types land incrementally; stream JSON is the first consumer. |
| 2 | |
| 3 | use std::path::PathBuf; |
| 4 | |
| 5 | use serde::{Deserialize, Serialize}; |
| 6 | |
| 7 | /// Terminal reasons shared by TUI, JSONL, Fleet, and benchmark receipts. |
| 8 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 9 | #[serde(rename_all = "snake_case")] |
| 10 | pub enum RunTerminationReason { |
| 11 | Resolved, |
| 12 | Unresolved, |
| 13 | Canceled, |
| 14 | Stuck, |
| 15 | Timeout, |
| 16 | BudgetExhausted, |
| 17 | ApprovalRequired, |
| 18 | ModelError, |
| 19 | ToolError, |
| 20 | InfrastructureError, |
| 21 | EvidenceMissing, |
| 22 | } |
| 23 | |
| 24 | impl RunTerminationReason { |
| 25 | #[must_use] |
| 26 | pub const fn as_str(self) -> &'static str { |
| 27 | match self { |
| 28 | Self::Resolved => "resolved", |
| 29 | Self::Unresolved => "unresolved", |
| 30 | Self::Canceled => "canceled", |
| 31 | Self::Stuck => "stuck", |
| 32 | Self::Timeout => "timeout", |
| 33 | Self::BudgetExhausted => "budget_exhausted", |
| 34 | Self::ApprovalRequired => "approval_required", |
| 35 | Self::ModelError => "model_error", |
| 36 | Self::ToolError => "tool_error", |
| 37 | Self::InfrastructureError => "infrastructure_error", |
| 38 | Self::EvidenceMissing => "evidence_missing", |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | #[must_use] |
| 43 | pub const fn is_success(self) -> bool { |
| 44 | matches!(self, Self::Resolved) |
| 45 | } |
| 46 | |
| 47 | #[must_use] |
| 48 | pub const fn process_exit_code(self) -> i32 { |
| 49 | match self { |
| 50 | Self::Resolved => 0, |
| 51 | Self::Unresolved | Self::EvidenceMissing => 2, |
| 52 | Self::ApprovalRequired => 3, |
| 53 | Self::Canceled => 130, |
| 54 | Self::Stuck |
| 55 | | Self::Timeout |
| 56 | | Self::BudgetExhausted |
| 57 | | Self::ModelError |
| 58 | | Self::ToolError |
| 59 | | Self::InfrastructureError => 1, |
| 60 | } |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | /// Reduce the existing Engine outcome plus typed subsystem evidence into the |
| 65 | /// terminal status shared by machine-facing projections. Successful and |
| 66 | /// canceled turns are unambiguous; failed turns prefer explicit approval/tool |
| 67 | /// evidence before classifying provider and infrastructure categories. |
| 68 | #[must_use] |
| 69 | pub fn classify_turn_termination( |
| 70 | status: crate::core::events::TurnOutcomeStatus, |
| 71 | error_category: Option<crate::error_taxonomy::ErrorCategory>, |
| 72 | tool_error_seen: bool, |
| 73 | approval_required: bool, |
| 74 | ) -> RunTerminationReason { |
| 75 | use crate::core::events::TurnOutcomeStatus; |
| 76 | use crate::error_taxonomy::ErrorCategory; |
| 77 | |
| 78 | match status { |
| 79 | TurnOutcomeStatus::Completed => RunTerminationReason::Resolved, |
| 80 | TurnOutcomeStatus::Interrupted => RunTerminationReason::Canceled, |
| 81 | TurnOutcomeStatus::Failed if approval_required => RunTerminationReason::ApprovalRequired, |
| 82 | TurnOutcomeStatus::Failed if tool_error_seen => RunTerminationReason::ToolError, |
| 83 | TurnOutcomeStatus::Failed => match error_category { |
| 84 | Some(ErrorCategory::Timeout) => RunTerminationReason::Timeout, |
| 85 | Some( |
| 86 | ErrorCategory::Network |
| 87 | | ErrorCategory::Authentication |
| 88 | | ErrorCategory::Authorization |
| 89 | | ErrorCategory::RateLimit |
| 90 | | ErrorCategory::InvalidInput |
| 91 | | ErrorCategory::Parse, |
| 92 | ) => RunTerminationReason::ModelError, |
| 93 | Some(ErrorCategory::Tool) => RunTerminationReason::ToolError, |
| 94 | Some(ErrorCategory::State | ErrorCategory::Internal) => { |
| 95 | RunTerminationReason::InfrastructureError |
| 96 | } |
| 97 | None => RunTerminationReason::Unresolved, |
| 98 | }, |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 103 | #[serde(rename_all = "snake_case")] |
| 104 | pub enum VerificationOutcome { |
| 105 | NotRun, |
| 106 | PassedFirstAttempt, |
| 107 | PassedAfterRepair, |
| 108 | Failed, |
| 109 | Incomplete, |
| 110 | } |
| 111 | |
| 112 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 113 | pub struct VerificationReceipt { |
| 114 | pub command: String, |
| 115 | pub outcome: VerificationOutcome, |
| 116 | pub attempts: u32, |
| 117 | pub duration_ms: u64, |
| 118 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 119 | pub artifact: Option<PathBuf>, |
| 120 | } |
| 121 | |
| 122 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 123 | pub struct RunReceipt { |
| 124 | pub reason: RunTerminationReason, |
| 125 | pub summary: String, |
| 126 | #[serde(default)] |
| 127 | pub files_changed: Vec<PathBuf>, |
| 128 | #[serde(default)] |
| 129 | pub verification: Vec<VerificationReceipt>, |
| 130 | #[serde(default)] |
| 131 | pub remaining_risks: Vec<String>, |
| 132 | } |
| 133 | |
| 134 | impl RunReceipt { |
| 135 | pub fn validate(&self) -> Result<(), String> { |
| 136 | if self.summary.trim().is_empty() { |
| 137 | return Err("run receipt summary cannot be empty".to_string()); |
| 138 | } |
| 139 | if self.reason == RunTerminationReason::Resolved |
| 140 | && self |
| 141 | .verification |
| 142 | .iter() |
| 143 | .any(|receipt| receipt.outcome == VerificationOutcome::Failed) |
| 144 | { |
| 145 | return Err("resolved run cannot contain a failed verification receipt".to_string()); |
| 146 | } |
| 147 | Ok(()) |
| 148 | } |
| 149 | |
| 150 | #[must_use] |
| 151 | pub fn pass_at_one(&self) -> bool { |
| 152 | !self.verification.is_empty() |
| 153 | && self.verification.iter().all(|receipt| { |
| 154 | receipt.outcome == VerificationOutcome::PassedFirstAttempt && receipt.attempts == 1 |
| 155 | }) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | #[cfg(test)] |
| 160 | mod tests { |
| 161 | use super::*; |
| 162 | |
| 163 | #[test] |
| 164 | fn approval_required_has_machine_distinct_exit() { |
| 165 | assert_eq!( |
| 166 | RunTerminationReason::ApprovalRequired.process_exit_code(), |
| 167 | 3 |
| 168 | ); |
| 169 | assert!(!RunTerminationReason::ApprovalRequired.is_success()); |
| 170 | } |
| 171 | |
| 172 | #[test] |
| 173 | fn repair_success_is_not_pass_at_one() { |
| 174 | let receipt = RunReceipt { |
| 175 | reason: RunTerminationReason::Resolved, |
| 176 | summary: "fixed".to_string(), |
| 177 | files_changed: vec![PathBuf::from("src/lib.rs")], |
| 178 | verification: vec![VerificationReceipt { |
| 179 | command: "cargo test".to_string(), |
| 180 | outcome: VerificationOutcome::PassedAfterRepair, |
| 181 | attempts: 2, |
| 182 | duration_ms: 10, |
| 183 | artifact: None, |
| 184 | }], |
| 185 | remaining_risks: Vec::new(), |
| 186 | }; |
| 187 | assert!(!receipt.pass_at_one()); |
| 188 | assert!(receipt.validate().is_ok()); |
| 189 | } |
| 190 | |
| 191 | #[test] |
| 192 | fn failed_turns_keep_model_tool_and_infrastructure_distinct() { |
| 193 | use crate::core::events::TurnOutcomeStatus; |
| 194 | use crate::error_taxonomy::ErrorCategory; |
| 195 | |
| 196 | assert_eq!( |
| 197 | classify_turn_termination( |
| 198 | TurnOutcomeStatus::Failed, |
| 199 | Some(ErrorCategory::Network), |
| 200 | false, |
| 201 | false, |
| 202 | ), |
| 203 | RunTerminationReason::ModelError |
| 204 | ); |
| 205 | assert_eq!( |
| 206 | classify_turn_termination( |
| 207 | TurnOutcomeStatus::Failed, |
| 208 | Some(ErrorCategory::Internal), |
| 209 | false, |
| 210 | false, |
| 211 | ), |
| 212 | RunTerminationReason::InfrastructureError |
| 213 | ); |
| 214 | assert_eq!( |
| 215 | classify_turn_termination(TurnOutcomeStatus::Failed, None, true, false), |
| 216 | RunTerminationReason::ToolError |
| 217 | ); |
| 218 | } |
| 219 | } |
| 220 |