返回 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; 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
220 lines RUST