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