返回 CodeWhale
termination.rs
根目录 / crates / tui / src / core / runtime_contract / termination.rs
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. For failed turns, the terminal error
67 /// category wins over historical tool failures from earlier, recovered steps;
68 /// approval/tool evidence is the fallback when the terminal event has no
69 /// typed cause.
70 #[must_use]
71 pub fn classify_turn_termination(
72 status: crate::core::events::TurnOutcomeStatus,
73 error_category: Option<crate::error_taxonomy::ErrorCategory>,
74 tool_error_seen: bool,
75 approval_required: bool,
76 ) -> RunTerminationReason {
77 use crate::core::events::TurnOutcomeStatus;
78 use crate::error_taxonomy::ErrorCategory;
79
80 match status {
81 TurnOutcomeStatus::Completed => RunTerminationReason::Resolved,
82 TurnOutcomeStatus::Interrupted => RunTerminationReason::Canceled,
83 // A provider-declared incomplete response is the terminal cause even
84 // if an earlier tool in the same run needed approval. Historical
85 // approval evidence must not relabel model truncation.
86 TurnOutcomeStatus::Failed
87 if matches!(error_category, Some(ErrorCategory::InvalidInput)) =>
88 {
89 RunTerminationReason::ModelError
90 }
91 TurnOutcomeStatus::Failed if approval_required => RunTerminationReason::ApprovalRequired,
92 TurnOutcomeStatus::Failed => match error_category {
93 Some(ErrorCategory::Budget) => RunTerminationReason::BudgetExhausted,
94 Some(ErrorCategory::Timeout) => RunTerminationReason::Timeout,
95 Some(
96 ErrorCategory::Network
97 | ErrorCategory::Authentication
98 | ErrorCategory::Authorization
99 | ErrorCategory::RateLimit
100 | ErrorCategory::InvalidInput
101 | ErrorCategory::Parse,
102 ) => RunTerminationReason::ModelError,
103 Some(ErrorCategory::Tool) => RunTerminationReason::ToolError,
104 Some(ErrorCategory::State | ErrorCategory::Internal) => {
105 RunTerminationReason::InfrastructureError
106 }
107 None if tool_error_seen => RunTerminationReason::ToolError,
108 None => RunTerminationReason::Unresolved,
109 },
110 }
111 }
112
113 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114 #[serde(rename_all = "snake_case")]
115 pub enum VerificationOutcome {
116 NotRun,
117 PassedFirstAttempt,
118 PassedAfterRepair,
119 Failed,
120 Incomplete,
121 }
122
123 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124 pub struct VerificationReceipt {
125 pub command: String,
126 pub outcome: VerificationOutcome,
127 pub attempts: u32,
128 pub duration_ms: u64,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub artifact: Option<PathBuf>,
131 }
132
133 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134 pub struct RunReceipt {
135 pub reason: RunTerminationReason,
136 pub summary: String,
137 #[serde(default)]
138 pub files_changed: Vec<PathBuf>,
139 #[serde(default)]
140 pub verification: Vec<VerificationReceipt>,
141 #[serde(default)]
142 pub remaining_risks: Vec<String>,
143 }
144
145 impl RunReceipt {
146 pub fn validate(&self) -> Result<(), String> {
147 if self.summary.trim().is_empty() {
148 return Err("run receipt summary cannot be empty".to_string());
149 }
150 if self.reason == RunTerminationReason::Resolved
151 && self
152 .verification
153 .iter()
154 .any(|receipt| receipt.outcome == VerificationOutcome::Failed)
155 {
156 return Err("resolved run cannot contain a failed verification receipt".to_string());
157 }
158 Ok(())
159 }
160
161 #[must_use]
162 pub fn pass_at_one(&self) -> bool {
163 !self.verification.is_empty()
164 && self.verification.iter().all(|receipt| {
165 receipt.outcome == VerificationOutcome::PassedFirstAttempt && receipt.attempts == 1
166 })
167 }
168 }
169
170 #[cfg(test)]
171 mod tests {
172 use super::*;
173
174 #[test]
175 fn approval_required_has_machine_distinct_exit() {
176 assert_eq!(
177 RunTerminationReason::ApprovalRequired.process_exit_code(),
178 3
179 );
180 assert!(!RunTerminationReason::ApprovalRequired.is_success());
181 }
182
183 #[test]
184 fn repair_success_is_not_pass_at_one() {
185 let receipt = RunReceipt {
186 reason: RunTerminationReason::Resolved,
187 summary: "fixed".to_string(),
188 files_changed: vec![PathBuf::from("src/lib.rs")],
189 verification: vec![VerificationReceipt {
190 command: "cargo test".to_string(),
191 outcome: VerificationOutcome::PassedAfterRepair,
192 attempts: 2,
193 duration_ms: 10,
194 artifact: None,
195 }],
196 remaining_risks: Vec::new(),
197 };
198 assert!(!receipt.pass_at_one());
199 assert!(receipt.validate().is_ok());
200 }
201
202 #[test]
203 fn failed_turns_keep_model_tool_and_infrastructure_distinct() {
204 use crate::core::events::TurnOutcomeStatus;
205 use crate::error_taxonomy::ErrorCategory;
206
207 assert_eq!(
208 classify_turn_termination(
209 TurnOutcomeStatus::Failed,
210 Some(ErrorCategory::Network),
211 false,
212 false,
213 ),
214 RunTerminationReason::ModelError
215 );
216 assert_eq!(
217 classify_turn_termination(
218 TurnOutcomeStatus::Failed,
219 Some(ErrorCategory::InvalidInput),
220 false,
221 false,
222 ),
223 RunTerminationReason::ModelError
224 );
225 assert_eq!(
226 classify_turn_termination(
227 TurnOutcomeStatus::Failed,
228 Some(ErrorCategory::Internal),
229 false,
230 false,
231 ),
232 RunTerminationReason::InfrastructureError
233 );
234 assert_eq!(
235 classify_turn_termination(TurnOutcomeStatus::Failed, None, true, false),
236 RunTerminationReason::ToolError
237 );
238 assert_eq!(
239 classify_turn_termination(
240 TurnOutcomeStatus::Failed,
241 Some(ErrorCategory::InvalidInput),
242 true,
243 false,
244 ),
245 RunTerminationReason::ModelError,
246 "a recovered tool error must not hide the terminal model failure"
247 );
248 assert_eq!(
249 classify_turn_termination(
250 TurnOutcomeStatus::Failed,
251 Some(ErrorCategory::InvalidInput),
252 true,
253 true,
254 ),
255 RunTerminationReason::ModelError,
256 "historical approval evidence must not hide terminal truncation"
257 );
258 }
259 }
260
260 lines RUST