1use serde::Serialize;
7use thiserror::Error;
8
9use crate::context::ContextState;
10use crate::gates::StopReason;
11use crate::invariant::InvariantClass;
12
13#[derive(Debug, Error, Serialize)]
18pub enum ConvergeError {
19 #[error("budget exhausted: {kind}")]
21 BudgetExhausted { kind: String },
22
23 #[error("{class:?} invariant '{name}' violated: {reason}")]
25 InvariantViolation {
26 name: String,
28 class: InvariantClass,
30 reason: String,
32 context: Box<ContextState>,
34 },
35
36 #[error("agent failed: {agent_id}")]
38 AgentFailed { agent_id: String },
39
40 #[error("invalid gate resume: {reason}")]
42 InvalidResume {
43 reason: String,
45 },
46
47 #[error("invalid admission: {reason}")]
49 InvalidAdmission {
50 reason: String,
52 },
53
54 #[error("invalid context snapshot: {reason}")]
56 InvalidSnapshot {
57 reason: String,
59 },
60
61 #[error(
63 "conflict detected for fact '{id}': existing content '{existing}' vs new content '{new}'"
64 )]
65 Conflict {
66 id: String,
68 existing: String,
70 new: String,
72 context: Box<ContextState>,
74 },
75}
76
77impl ConvergeError {
78 #[must_use]
80 pub fn context(&self) -> Option<&ContextState> {
81 match self {
82 Self::InvariantViolation { context, .. } | Self::Conflict { context, .. } => {
83 Some(context)
84 }
85 Self::BudgetExhausted { .. }
86 | Self::AgentFailed { .. }
87 | Self::InvalidResume { .. }
88 | Self::InvalidAdmission { .. }
89 | Self::InvalidSnapshot { .. } => None,
90 }
91 }
92
93 #[must_use]
95 pub fn stop_reason(&self) -> StopReason {
96 match self {
97 Self::BudgetExhausted { kind } => StopReason::Error {
98 message: format!("budget exhausted: {kind}"),
99 category: crate::gates::ErrorCategory::Resource,
100 },
101 Self::InvariantViolation {
102 name,
103 class,
104 reason,
105 ..
106 } => StopReason::invariant_violated(*class, name.clone(), reason.clone()),
107 Self::AgentFailed { agent_id } => StopReason::AgentRefused {
108 agent_id: agent_id.clone(),
109 reason: "agent execution failed".to_string(),
110 },
111 Self::InvalidResume { reason } => StopReason::Error {
112 message: format!("invalid gate resume: {reason}"),
113 category: crate::gates::ErrorCategory::Internal,
114 },
115 Self::InvalidAdmission { reason } => StopReason::Error {
116 message: format!("invalid admission: {reason}"),
117 category: crate::gates::ErrorCategory::Configuration,
118 },
119 Self::InvalidSnapshot { reason } => StopReason::Error {
120 message: format!("invalid context snapshot: {reason}"),
121 category: crate::gates::ErrorCategory::Configuration,
122 },
123 Self::Conflict {
124 id, existing, new, ..
125 } => StopReason::Error {
126 message: format!("conflict for fact '{id}': existing '{existing}' vs new '{new}'"),
127 category: crate::gates::ErrorCategory::Internal,
128 },
129 }
130 }
131}
132
133impl From<crate::AdmissionError> for ConvergeError {
134 fn from(value: crate::AdmissionError) -> Self {
135 Self::InvalidAdmission {
136 reason: value.to_string(),
137 }
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 fn empty_context() -> ContextState {
146 ContextState::default()
147 }
148
149 #[test]
150 fn budget_exhausted_display() {
151 let err = ConvergeError::BudgetExhausted {
152 kind: "cycles".into(),
153 };
154 assert_eq!(err.to_string(), "budget exhausted: cycles");
155 }
156
157 #[test]
158 fn budget_exhausted_has_no_context() {
159 let err = ConvergeError::BudgetExhausted {
160 kind: "tokens".into(),
161 };
162 assert!(err.context().is_none());
163 }
164
165 #[test]
166 fn agent_failed_display() {
167 let err = ConvergeError::AgentFailed {
168 agent_id: "agent-x".into(),
169 };
170 assert_eq!(err.to_string(), "agent failed: agent-x");
171 }
172
173 #[test]
174 fn agent_failed_has_no_context() {
175 let err = ConvergeError::AgentFailed {
176 agent_id: "a".into(),
177 };
178 assert!(err.context().is_none());
179 }
180
181 #[test]
182 fn invariant_violation_has_context() {
183 let err = ConvergeError::InvariantViolation {
184 name: "no_empty".into(),
185 class: InvariantClass::Structural,
186 reason: "empty found".into(),
187 context: Box::new(empty_context()),
188 };
189 assert!(err.context().is_some());
190 }
191
192 #[test]
193 fn invariant_violation_display() {
194 let err = ConvergeError::InvariantViolation {
195 name: "no_empty".into(),
196 class: InvariantClass::Semantic,
197 reason: "bad".into(),
198 context: Box::new(empty_context()),
199 };
200 assert_eq!(
201 err.to_string(),
202 "Semantic invariant 'no_empty' violated: bad"
203 );
204 }
205
206 #[test]
207 fn conflict_has_context() {
208 let err = ConvergeError::Conflict {
209 id: "fact-1".into(),
210 existing: "old".into(),
211 new: "new".into(),
212 context: Box::new(empty_context()),
213 };
214 assert!(err.context().is_some());
215 }
216
217 #[test]
218 fn conflict_display() {
219 let err = ConvergeError::Conflict {
220 id: "f".into(),
221 existing: "a".into(),
222 new: "b".into(),
223 context: Box::new(empty_context()),
224 };
225 assert!(err.to_string().contains("conflict detected for fact 'f'"));
226 }
227
228 #[test]
229 fn stop_reason_budget_exhausted() {
230 let err = ConvergeError::BudgetExhausted {
231 kind: "time".into(),
232 };
233 let reason = err.stop_reason();
234 assert!(matches!(reason, StopReason::Error { .. }));
235 }
236
237 #[test]
238 fn stop_reason_invariant_violated() {
239 let err = ConvergeError::InvariantViolation {
240 name: "inv".into(),
241 class: InvariantClass::Acceptance,
242 reason: "fail".into(),
243 context: Box::new(empty_context()),
244 };
245 let reason = err.stop_reason();
246 assert!(matches!(reason, StopReason::InvariantViolated { .. }));
247 }
248
249 #[test]
250 fn stop_reason_agent_refused() {
251 let err = ConvergeError::AgentFailed {
252 agent_id: "bot".into(),
253 };
254 let reason = err.stop_reason();
255 assert!(matches!(reason, StopReason::AgentRefused { .. }));
256 }
257
258 #[test]
259 fn invalid_resume_display() {
260 let err = ConvergeError::InvalidResume {
261 reason: "gate_id mismatch".into(),
262 };
263 assert_eq!(err.to_string(), "invalid gate resume: gate_id mismatch");
264 }
265
266 #[test]
267 fn invalid_resume_has_no_context() {
268 let err = ConvergeError::InvalidResume {
269 reason: "test".into(),
270 };
271 assert!(err.context().is_none());
272 }
273
274 #[test]
275 fn stop_reason_invalid_resume() {
276 let err = ConvergeError::InvalidResume {
277 reason: "wrong gate".into(),
278 };
279 let reason = err.stop_reason();
280 assert!(matches!(reason, StopReason::Error { .. }));
281 }
282
283 #[test]
284 fn stop_reason_conflict() {
285 let err = ConvergeError::Conflict {
286 id: "x".into(),
287 existing: "old".into(),
288 new: "new".into(),
289 context: Box::new(empty_context()),
290 };
291 let reason = err.stop_reason();
292 assert!(matches!(reason, StopReason::Error { .. }));
293 }
294}