1use serde::{Deserialize, Serialize};
19
20use crate::admission_recommendation::NextAction;
21use crate::disk_pressure_report::ExecFailureClass;
22use crate::incident::IncidentReasonCode;
23use crate::proof_policy::{ProofRefusal, ProofRefusalReason};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ProofFailureClass {
29 ProductFailure,
31 InfrastructureFailure,
33 AdmissionDenial,
35 LocalFallbackRefused,
37}
38
39impl ProofFailureClass {
40 #[must_use]
42 pub fn as_str(self) -> &'static str {
43 match self {
44 ProofFailureClass::ProductFailure => "product_failure",
45 ProofFailureClass::InfrastructureFailure => "infrastructure_failure",
46 ProofFailureClass::AdmissionDenial => "admission_denial",
47 ProofFailureClass::LocalFallbackRefused => "local_fallback_refused",
48 }
49 }
50
51 pub const ALL: &'static [ProofFailureClass] = &[
53 Self::ProductFailure,
54 Self::InfrastructureFailure,
55 Self::AdmissionDenial,
56 Self::LocalFallbackRefused,
57 ];
58
59 #[must_use]
61 pub fn from_exec_failure(class: ExecFailureClass) -> Self {
62 match class {
63 ExecFailureClass::ProductCompile => Self::ProductFailure,
64 ExecFailureClass::WorkerEnvironment => Self::InfrastructureFailure,
65 ExecFailureClass::Indeterminate => Self::InfrastructureFailure,
66 }
67 }
68
69 #[must_use]
71 pub fn from_incident_reason(code: IncidentReasonCode) -> Self {
72 match code {
73 IncidentReasonCode::DaemonSocketRefused
75 | IncidentReasonCode::RsyncVanishedFile
76 | IncidentReasonCode::DiskFull
77 | IncidentReasonCode::CriticalPressure
78 | IncidentReasonCode::WrongUserPathWorkerBinary
79 | IncidentReasonCode::ArtifactMiss => Self::InfrastructureFailure,
80 IncidentReasonCode::LocalFallback => Self::LocalFallbackRefused,
82 _ => Self::AdmissionDenial,
84 }
85 }
86}
87
88fn refusal_class(reason: ProofRefusalReason) -> ProofFailureClass {
90 match reason {
91 ProofRefusalReason::ClassifierChoseLocal => ProofFailureClass::LocalFallbackRefused,
93 ProofRefusalReason::NoAdmissibleWorker
95 | ProofRefusalReason::UnsafeCommandShape
96 | ProofRefusalReason::PlacementProfileUnhonored => ProofFailureClass::AdmissionDenial,
97 }
98}
99
100#[must_use]
102pub fn next_action_for_refusal(reason: ProofRefusalReason) -> NextAction {
103 match reason {
104 ProofRefusalReason::NoAdmissibleWorker => NextAction::QueueProof,
105 ProofRefusalReason::ClassifierChoseLocal => NextAction::RefreshCapabilities,
106 ProofRefusalReason::UnsafeCommandShape => NextAction::ShrinkScope,
107 ProofRefusalReason::PlacementProfileUnhonored => NextAction::ContactOperator,
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct ProofHandoff {
114 pub proof_intent_id: String,
116 pub failure_class: ProofFailureClass,
118 pub reason_code: IncidentReasonCode,
120 pub next_action: NextAction,
122 pub replay_command: String,
124 pub detail: String,
126}
127
128impl ProofHandoff {
129 #[must_use]
131 pub fn for_refusal(
132 intent_id: impl Into<String>,
133 command: &str,
134 refusal: &ProofRefusal,
135 ) -> Self {
136 let next_action = next_action_for_refusal(refusal.reason);
137 Self {
138 proof_intent_id: intent_id.into(),
139 failure_class: refusal_class(refusal.reason),
140 reason_code: refusal.incident_reason,
141 next_action,
142 replay_command: format!("rch exec --proof -- {command}"),
143 detail: refusal.detail.clone(),
144 }
145 }
146
147 #[must_use]
150 pub fn for_queued(intent_id: impl Into<String>, command: &str) -> Self {
151 let intent_id = intent_id.into();
152 Self {
153 replay_command: format!("rch exec --proof -- {command}"),
154 detail: format!("proof queued; reattach with intent {intent_id}"),
155 proof_intent_id: intent_id,
156 failure_class: ProofFailureClass::AdmissionDenial,
157 reason_code: IncidentReasonCode::InsufficientSlots,
158 next_action: NextAction::QueueProof,
159 }
160 }
161
162 #[must_use]
164 pub fn is_product_failure(&self) -> bool {
165 self.failure_class == ProofFailureClass::ProductFailure
166 }
167
168 #[must_use]
170 pub fn render(&self) -> String {
171 format!(
172 "proof handoff [{}] {} ({}) -> {} | replay: {}",
173 self.proof_intent_id,
174 self.failure_class.as_str(),
175 self.reason_code.code(),
176 self.next_action.as_str(),
177 self.replay_command,
178 )
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn refusal_for_no_admissible_worker_is_admission_denial_queue() {
188 let refusal = ProofRefusal::new(ProofRefusalReason::NoAdmissibleWorker);
189 let handoff = ProofHandoff::for_refusal("intent-1", "cargo build", &refusal);
190 assert_eq!(handoff.failure_class, ProofFailureClass::AdmissionDenial);
191 assert_eq!(handoff.reason_code, IncidentReasonCode::ProofRefusal);
192 assert_eq!(handoff.next_action, NextAction::QueueProof);
193 assert_eq!(handoff.replay_command, "rch exec --proof -- cargo build");
194 assert_eq!(handoff.proof_intent_id, "intent-1");
195 }
196
197 #[test]
198 fn classifier_chose_local_is_local_fallback_refused() {
199 let refusal = ProofRefusal::new(ProofRefusalReason::ClassifierChoseLocal);
200 let handoff = ProofHandoff::for_refusal("i2", "cargo fmt", &refusal);
201 assert_eq!(
202 handoff.failure_class,
203 ProofFailureClass::LocalFallbackRefused
204 );
205 assert_eq!(handoff.next_action, NextAction::RefreshCapabilities);
206 }
207
208 #[test]
209 fn unsafe_shape_and_placement_reasons_map_through() {
210 let unsafe_h = ProofHandoff::for_refusal(
211 "i",
212 "x",
213 &ProofRefusal::new(ProofRefusalReason::UnsafeCommandShape),
214 );
215 assert_eq!(unsafe_h.next_action, NextAction::ShrinkScope);
216 assert_eq!(unsafe_h.failure_class, ProofFailureClass::AdmissionDenial);
217 let place_h = ProofHandoff::for_refusal(
218 "i",
219 "x",
220 &ProofRefusal::new(ProofRefusalReason::PlacementProfileUnhonored),
221 );
222 assert_eq!(place_h.next_action, NextAction::ContactOperator);
223 }
224
225 #[test]
226 fn queued_handoff_reattaches_by_intent() {
227 let handoff = ProofHandoff::for_queued("intent-q", "cargo test");
228 assert_eq!(handoff.next_action, NextAction::QueueProof);
229 assert!(handoff.detail.contains("intent-q"));
230 assert!(handoff.replay_command.contains("cargo test"));
231 }
232
233 #[test]
234 fn failure_class_from_exec_failure() {
235 assert_eq!(
236 ProofFailureClass::from_exec_failure(ExecFailureClass::ProductCompile),
237 ProofFailureClass::ProductFailure
238 );
239 assert_eq!(
240 ProofFailureClass::from_exec_failure(ExecFailureClass::WorkerEnvironment),
241 ProofFailureClass::InfrastructureFailure
242 );
243 }
244
245 #[test]
246 fn failure_class_from_incident_reason() {
247 assert_eq!(
248 ProofFailureClass::from_incident_reason(IncidentReasonCode::DaemonSocketRefused),
249 ProofFailureClass::InfrastructureFailure
250 );
251 assert_eq!(
252 ProofFailureClass::from_incident_reason(IncidentReasonCode::DiskFull),
253 ProofFailureClass::InfrastructureFailure
254 );
255 assert_eq!(
256 ProofFailureClass::from_incident_reason(IncidentReasonCode::NoAdmissibleWorkers),
257 ProofFailureClass::AdmissionDenial
258 );
259 assert_eq!(
260 ProofFailureClass::from_incident_reason(IncidentReasonCode::LocalFallback),
261 ProofFailureClass::LocalFallbackRefused
262 );
263 }
264
265 #[test]
266 fn json_carries_all_four_discriminators() {
267 let tokens: Vec<&str> = ProofFailureClass::ALL.iter().map(|c| c.as_str()).collect();
269 assert!(tokens.contains(&"product_failure"));
270 assert!(tokens.contains(&"infrastructure_failure"));
271 assert!(tokens.contains(&"admission_denial"));
272 assert!(tokens.contains(&"local_fallback_refused"));
273 }
274
275 #[test]
276 fn handoff_serializes_standalone_without_beads() {
277 let refusal = ProofRefusal::new(ProofRefusalReason::NoAdmissibleWorker);
279 let handoff = ProofHandoff::for_refusal("intent-1", "cargo build", &refusal);
280 let value = serde_json::to_value(&handoff).unwrap();
281 assert_eq!(value["failure_class"], "admission_denial");
282 assert_eq!(value["reason_code"], "RCH-I012");
283 assert_eq!(value["next_action"], "queue_proof");
284 assert_eq!(value["proof_intent_id"], "intent-1");
285 let back: ProofHandoff = serde_json::from_value(value).unwrap();
286 assert_eq!(back, handoff);
287 }
288
289 #[test]
290 fn render_is_concise_and_machine_greppable() {
291 let refusal = ProofRefusal::new(ProofRefusalReason::NoAdmissibleWorker);
292 let line = ProofHandoff::for_refusal("intent-1", "cargo build", &refusal).render();
293 assert!(line.contains("intent-1"));
294 assert!(line.contains("admission_denial"));
295 assert!(line.contains("queue_proof"));
296 assert!(line.contains("replay:"));
297 }
298}