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(
49 "conflict detected for fact '{id}': existing content '{existing}' vs new content '{new}'"
50 )]
51 Conflict {
52 id: String,
54 existing: String,
56 new: String,
58 context: Box<ContextState>,
60 },
61}
62
63impl ConvergeError {
64 #[must_use]
66 pub fn context(&self) -> Option<&ContextState> {
67 match self {
68 Self::InvariantViolation { context, .. } | Self::Conflict { context, .. } => {
69 Some(context)
70 }
71 Self::BudgetExhausted { .. }
72 | Self::AgentFailed { .. }
73 | Self::InvalidResume { .. } => None,
74 }
75 }
76
77 #[must_use]
79 pub fn stop_reason(&self) -> StopReason {
80 match self {
81 Self::BudgetExhausted { kind } => StopReason::Error {
82 message: format!("budget exhausted: {kind}"),
83 category: crate::gates::ErrorCategory::Resource,
84 },
85 Self::InvariantViolation {
86 name,
87 class,
88 reason,
89 ..
90 } => StopReason::invariant_violated(*class, name.clone(), reason.clone()),
91 Self::AgentFailed { agent_id } => StopReason::AgentRefused {
92 agent_id: agent_id.clone(),
93 reason: "agent execution failed".to_string(),
94 },
95 Self::InvalidResume { reason } => StopReason::Error {
96 message: format!("invalid gate resume: {reason}"),
97 category: crate::gates::ErrorCategory::Internal,
98 },
99 Self::Conflict {
100 id, existing, new, ..
101 } => StopReason::Error {
102 message: format!("conflict for fact '{id}': existing '{existing}' vs new '{new}'"),
103 category: crate::gates::ErrorCategory::Internal,
104 },
105 }
106 }
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 fn empty_context() -> ContextState {
114 ContextState::default()
115 }
116
117 #[test]
118 fn budget_exhausted_display() {
119 let err = ConvergeError::BudgetExhausted {
120 kind: "cycles".into(),
121 };
122 assert_eq!(err.to_string(), "budget exhausted: cycles");
123 }
124
125 #[test]
126 fn budget_exhausted_has_no_context() {
127 let err = ConvergeError::BudgetExhausted {
128 kind: "tokens".into(),
129 };
130 assert!(err.context().is_none());
131 }
132
133 #[test]
134 fn agent_failed_display() {
135 let err = ConvergeError::AgentFailed {
136 agent_id: "agent-x".into(),
137 };
138 assert_eq!(err.to_string(), "agent failed: agent-x");
139 }
140
141 #[test]
142 fn agent_failed_has_no_context() {
143 let err = ConvergeError::AgentFailed {
144 agent_id: "a".into(),
145 };
146 assert!(err.context().is_none());
147 }
148
149 #[test]
150 fn invariant_violation_has_context() {
151 let err = ConvergeError::InvariantViolation {
152 name: "no_empty".into(),
153 class: InvariantClass::Structural,
154 reason: "empty found".into(),
155 context: Box::new(empty_context()),
156 };
157 assert!(err.context().is_some());
158 }
159
160 #[test]
161 fn invariant_violation_display() {
162 let err = ConvergeError::InvariantViolation {
163 name: "no_empty".into(),
164 class: InvariantClass::Semantic,
165 reason: "bad".into(),
166 context: Box::new(empty_context()),
167 };
168 assert_eq!(
169 err.to_string(),
170 "Semantic invariant 'no_empty' violated: bad"
171 );
172 }
173
174 #[test]
175 fn conflict_has_context() {
176 let err = ConvergeError::Conflict {
177 id: "fact-1".into(),
178 existing: "old".into(),
179 new: "new".into(),
180 context: Box::new(empty_context()),
181 };
182 assert!(err.context().is_some());
183 }
184
185 #[test]
186 fn conflict_display() {
187 let err = ConvergeError::Conflict {
188 id: "f".into(),
189 existing: "a".into(),
190 new: "b".into(),
191 context: Box::new(empty_context()),
192 };
193 assert!(err.to_string().contains("conflict detected for fact 'f'"));
194 }
195
196 #[test]
197 fn stop_reason_budget_exhausted() {
198 let err = ConvergeError::BudgetExhausted {
199 kind: "time".into(),
200 };
201 let reason = err.stop_reason();
202 assert!(matches!(reason, StopReason::Error { .. }));
203 }
204
205 #[test]
206 fn stop_reason_invariant_violated() {
207 let err = ConvergeError::InvariantViolation {
208 name: "inv".into(),
209 class: InvariantClass::Acceptance,
210 reason: "fail".into(),
211 context: Box::new(empty_context()),
212 };
213 let reason = err.stop_reason();
214 assert!(matches!(reason, StopReason::InvariantViolated { .. }));
215 }
216
217 #[test]
218 fn stop_reason_agent_refused() {
219 let err = ConvergeError::AgentFailed {
220 agent_id: "bot".into(),
221 };
222 let reason = err.stop_reason();
223 assert!(matches!(reason, StopReason::AgentRefused { .. }));
224 }
225
226 #[test]
227 fn invalid_resume_display() {
228 let err = ConvergeError::InvalidResume {
229 reason: "gate_id mismatch".into(),
230 };
231 assert_eq!(err.to_string(), "invalid gate resume: gate_id mismatch");
232 }
233
234 #[test]
235 fn invalid_resume_has_no_context() {
236 let err = ConvergeError::InvalidResume {
237 reason: "test".into(),
238 };
239 assert!(err.context().is_none());
240 }
241
242 #[test]
243 fn stop_reason_invalid_resume() {
244 let err = ConvergeError::InvalidResume {
245 reason: "wrong gate".into(),
246 };
247 let reason = err.stop_reason();
248 assert!(matches!(reason, StopReason::Error { .. }));
249 }
250
251 #[test]
252 fn stop_reason_conflict() {
253 let err = ConvergeError::Conflict {
254 id: "x".into(),
255 existing: "old".into(),
256 new: "new".into(),
257 context: Box::new(empty_context()),
258 };
259 let reason = err.stop_reason();
260 assert!(matches!(reason, StopReason::Error { .. }));
261 }
262}