1use crate::contract::backend::BackendError;
4
5#[derive(thiserror::Error, Debug)]
6pub enum SchedulerError {
7 #[error("unknown backend: {0}")]
8 UnknownBackend(String),
9 #[error("no backend registered")]
10 NoBackendRegistered,
11 #[error("run not initialized: {0}")]
12 RunNotFound(crate::contract::RunId),
13 #[error("quota exceeded: limit={limit}, used={used}")]
14 QuotaExceeded { limit: u32, used: u32 },
15 #[error("run cancelled")]
16 RunCancelled,
17 #[error("agent cancelled")]
18 AgentCancelled,
19 #[error("backend error (non-retryable): {0}")]
20 NonRetryable(#[from] BackendError),
21 #[error("backend error after {attempts} attempts: {source}")]
22 Exhausted { attempts: u32, source: BackendError },
23 #[error("output schema validation failed: {0}")]
24 SchemaValidation(String),
25}
26
27#[cfg(test)]
28mod tests {
29 use super::*;
30 use crate::contract::BackendError;
31 use crate::contract::RunId;
32
33 #[test]
36 fn display_unknown_backend_includes_id() {
37 let e = SchedulerError::UnknownBackend("opencode".to_string());
38 assert_eq!(e.to_string(), "unknown backend: opencode");
39 }
40
41 #[test]
42 fn display_no_backend_registered_is_static() {
43 let e = SchedulerError::NoBackendRegistered;
44 assert_eq!(e.to_string(), "no backend registered");
45 }
46
47 #[test]
48 fn display_run_not_found_includes_id() {
49 let id = RunId::nil();
50 let e = SchedulerError::RunNotFound(id);
51 let s = e.to_string();
52 assert!(s.starts_with("run not initialized: "), "got: {s}");
53 assert!(s.contains(&id.to_string()), "missing uuid in: {s}");
54 }
55
56 #[test]
57 fn display_quota_exceeded_uses_both_fields() {
58 let e = SchedulerError::QuotaExceeded {
59 limit: 10,
60 used: 11,
61 };
62 assert_eq!(e.to_string(), "quota exceeded: limit=10, used=11");
63 }
64
65 #[test]
66 fn display_run_cancelled_is_static() {
67 assert_eq!(SchedulerError::RunCancelled.to_string(), "run cancelled");
68 }
69
70 #[test]
71 fn display_agent_cancelled_is_static() {
72 assert_eq!(
73 SchedulerError::AgentCancelled.to_string(),
74 "agent cancelled"
75 );
76 }
77
78 #[test]
79 fn display_non_retryable_includes_backend_message() {
80 let inner = BackendError::Protocol("bad frame".to_string());
81 let e = SchedulerError::NonRetryable(inner);
82 assert_eq!(
83 e.to_string(),
84 "backend error (non-retryable): protocol error: bad frame"
85 );
86 }
87
88 #[test]
89 fn display_exhausted_uses_attempts_and_source() {
90 let inner = BackendError::Timeout;
91 let e = SchedulerError::Exhausted {
92 attempts: 3,
93 source: inner,
94 };
95 assert_eq!(
96 e.to_string(),
97 "backend error after 3 attempts: backend timed out"
98 );
99 }
100
101 #[test]
102 fn display_schema_validation_includes_reason() {
103 let e = SchedulerError::SchemaValidation("missing field 'answer'".to_string());
104 assert_eq!(
105 e.to_string(),
106 "output schema validation failed: missing field 'answer'"
107 );
108 }
109
110 #[test]
113 fn construct_unknown_backend() {
114 let e = SchedulerError::UnknownBackend(String::from("claude"));
115 match e {
116 SchedulerError::UnknownBackend(s) => assert_eq!(s, "claude"),
117 _ => panic!("wrong variant"),
118 }
119 }
120
121 #[test]
122 fn construct_no_backend_registered() {
123 let e = SchedulerError::NoBackendRegistered;
124 assert!(matches!(e, SchedulerError::NoBackendRegistered));
125 }
126
127 #[test]
128 fn construct_run_not_found() {
129 let id = RunId::now_v7();
130 match SchedulerError::RunNotFound(id) {
131 SchedulerError::RunNotFound(got) => assert_eq!(got, id),
132 _ => panic!("wrong variant"),
133 }
134 }
135
136 #[test]
137 fn construct_quota_exceeded() {
138 let e = SchedulerError::QuotaExceeded { limit: 5, used: 5 };
139 match e {
140 SchedulerError::QuotaExceeded { limit, used } => {
141 assert_eq!(limit, 5);
142 assert_eq!(used, 5);
143 }
144 _ => panic!("wrong variant"),
145 }
146 }
147
148 #[test]
149 fn construct_run_cancelled() {
150 assert!(matches!(
151 SchedulerError::RunCancelled,
152 SchedulerError::RunCancelled
153 ));
154 }
155
156 #[test]
157 fn construct_agent_cancelled() {
158 assert!(matches!(
159 SchedulerError::AgentCancelled,
160 SchedulerError::AgentCancelled
161 ));
162 }
163
164 #[test]
165 fn construct_non_retryable() {
166 let inner = BackendError::Connection("refused".to_string());
167 match SchedulerError::NonRetryable(inner) {
168 SchedulerError::NonRetryable(got) => {
169 assert!(matches!(got, BackendError::Connection(_)));
170 }
171 _ => panic!("wrong variant"),
172 }
173 }
174
175 #[test]
176 fn construct_exhausted() {
177 let inner = BackendError::Spawn("oops".to_string());
178 let e = SchedulerError::Exhausted {
179 attempts: 7,
180 source: inner,
181 };
182 match e {
183 SchedulerError::Exhausted { attempts, source } => {
184 assert_eq!(attempts, 7);
185 assert!(matches!(source, BackendError::Spawn(_)));
186 }
187 _ => panic!("wrong variant"),
188 }
189 }
190
191 #[test]
192 fn construct_schema_validation() {
193 let e = SchedulerError::SchemaValidation(String::from("type mismatch"));
194 match e {
195 SchedulerError::SchemaValidation(s) => assert_eq!(s, "type mismatch"),
196 _ => panic!("wrong variant"),
197 }
198 }
199
200 #[test]
203 fn from_backend_error_yields_non_retryable() {
204 let inner = BackendError::Parse("json".to_string());
205 let converted: SchedulerError = inner.into();
206 assert!(matches!(
207 converted,
208 SchedulerError::NonRetryable(BackendError::Parse(_))
209 ));
210 }
211
212 #[test]
213 fn from_backend_error_preserves_message() {
214 let inner = BackendError::Io("disk full".to_string());
215 let converted: SchedulerError = inner.into();
216 assert_eq!(
217 converted.to_string(),
218 "backend error (non-retryable): IO error: disk full"
219 );
220 }
221
222 #[test]
223 fn from_backend_error_question_mark_works_in_fallible_fn() {
224 fn make_err() -> Result<(), SchedulerError> {
225 let _ = BackendError::Timeout;
226 Err(BackendError::Timeout)?;
227 Ok(())
228 }
229 let err = make_err().unwrap_err();
230 assert!(matches!(
231 err,
232 SchedulerError::NonRetryable(BackendError::Timeout)
233 ));
234 }
235
236 #[test]
239 fn source_for_non_retryable_is_backend_error() {
240 let inner = BackendError::Protocol("x".to_string());
241 let e = SchedulerError::NonRetryable(inner);
242 let src = std::error::Error::source(&e).expect("NonRetryable should expose a source");
243 let downcast = src
244 .downcast_ref::<BackendError>()
245 .expect("source should be BackendError");
246 assert!(matches!(downcast, BackendError::Protocol(_)));
247 }
248
249 #[test]
250 fn source_for_exhausted_is_backend_error() {
251 let inner = BackendError::Spawn("boom".to_string());
252 let e = SchedulerError::Exhausted {
253 attempts: 4,
254 source: inner,
255 };
256 let src = std::error::Error::source(&e).expect("Exhausted should expose a source");
257 let downcast = src
258 .downcast_ref::<BackendError>()
259 .expect("source should be BackendError");
260 assert!(matches!(downcast, BackendError::Spawn(_)));
261 }
262
263 #[test]
264 fn source_for_plain_variants_is_none() {
265 let cases = [
266 SchedulerError::UnknownBackend("x".to_string()),
267 SchedulerError::NoBackendRegistered,
268 SchedulerError::RunNotFound(RunId::nil()),
269 SchedulerError::QuotaExceeded { limit: 0, used: 0 },
270 SchedulerError::RunCancelled,
271 SchedulerError::AgentCancelled,
272 SchedulerError::SchemaValidation(String::new()),
273 ];
274 for e in cases {
275 assert!(
276 std::error::Error::source(&e).is_none(),
277 "{e:?} should have no source"
278 );
279 }
280 }
281
282 #[test]
285 fn scheduler_error_is_send_and_sync() {
286 fn assert_send<T: Send>() {}
287 fn assert_sync<T: Sync>() {}
288 assert_send::<SchedulerError>();
289 assert_sync::<SchedulerError>();
290 }
291
292 #[test]
293 fn scheduler_error_implements_std_error() {
294 fn assert_error<T: std::error::Error + 'static>() {}
295 assert_error::<SchedulerError>();
296 }
297
298 #[test]
301 fn debug_all_variants_contains_variant_name() {
302 let cases: Vec<(SchedulerError, &str)> = vec![
303 (
304 SchedulerError::UnknownBackend("id".to_string()),
305 "UnknownBackend",
306 ),
307 (SchedulerError::NoBackendRegistered, "NoBackendRegistered"),
308 (SchedulerError::RunNotFound(RunId::nil()), "RunNotFound"),
309 (
310 SchedulerError::QuotaExceeded { limit: 1, used: 2 },
311 "QuotaExceeded",
312 ),
313 (SchedulerError::RunCancelled, "RunCancelled"),
314 (SchedulerError::AgentCancelled, "AgentCancelled"),
315 (
316 SchedulerError::NonRetryable(BackendError::Timeout),
317 "NonRetryable",
318 ),
319 (
320 SchedulerError::Exhausted {
321 attempts: 1,
322 source: BackendError::Timeout,
323 },
324 "Exhausted",
325 ),
326 (
327 SchedulerError::SchemaValidation("x".to_string()),
328 "SchemaValidation",
329 ),
330 ];
331 for (e, expected) in cases {
332 let dbg = format!("{e:?}");
333 assert!(
334 dbg.contains(expected),
335 "Debug of {expected} did not contain variant name: {dbg}"
336 );
337 }
338 }
339
340 #[test]
343 fn unknown_backend_with_empty_string() {
344 let e = SchedulerError::UnknownBackend(String::new());
345 assert_eq!(e.to_string(), "unknown backend: ");
346 }
347
348 #[test]
349 fn unknown_backend_with_unicode_and_special_chars() {
350 let e = SchedulerError::UnknownBackend("后端-1/💥".to_string());
351 let s = e.to_string();
352 assert!(s.starts_with("unknown backend: "));
353 assert!(s.contains("后端-1/💥"));
354 }
355
356 #[test]
357 fn run_not_found_with_nil_uuid() {
358 let e = SchedulerError::RunNotFound(RunId::nil());
359 assert!(e.to_string().contains(&RunId::nil().to_string()));
360 }
361
362 #[test]
363 fn run_not_found_with_random_uuid() {
364 let id = RunId::now_v7();
365 let e = SchedulerError::RunNotFound(id);
366 assert!(e.to_string().contains(&id.to_string()));
367 }
368
369 #[test]
370 fn quota_exceeded_with_zero_limit_and_zero_used() {
371 let e = SchedulerError::QuotaExceeded { limit: 0, used: 0 };
372 assert_eq!(e.to_string(), "quota exceeded: limit=0, used=0");
373 }
374
375 #[test]
376 fn quota_exceeded_with_max_u32_values() {
377 let e = SchedulerError::QuotaExceeded {
378 limit: u32::MAX,
379 used: u32::MAX,
380 };
381 assert_eq!(
382 e.to_string(),
383 "quota exceeded: limit=4294967295, used=4294967295"
384 );
385 }
386
387 #[test]
388 fn quota_exceeded_with_used_greater_than_limit() {
389 let e = SchedulerError::QuotaExceeded {
390 limit: 3,
391 used: 100,
392 };
393 let s = e.to_string();
394 assert!(s.contains("limit=3"));
395 assert!(s.contains("used=100"));
396 }
397
398 #[test]
399 fn exhausted_with_zero_attempts_is_valid() {
400 let e = SchedulerError::Exhausted {
401 attempts: 0,
402 source: BackendError::Cancelled,
403 };
404 assert_eq!(e.to_string(), "backend error after 0 attempts: cancelled");
405 }
406
407 #[test]
408 fn exhausted_with_max_attempts() {
409 let e = SchedulerError::Exhausted {
410 attempts: u32::MAX,
411 source: BackendError::Timeout,
412 };
413 let s = e.to_string();
414 assert!(s.contains("4294967295 attempts"));
415 assert!(s.contains("backend timed out"));
416 }
417
418 #[test]
419 fn exhausted_preserves_inner_backend_error() {
420 let inner = BackendError::Execution("nope".to_string());
421 let e = SchedulerError::Exhausted {
422 attempts: 2,
423 source: inner,
424 };
425 match e {
426 SchedulerError::Exhausted { source, .. } => match source {
427 BackendError::Execution(msg) => assert_eq!(msg, "nope"),
428 _ => panic!("inner error lost"),
429 },
430 _ => panic!("wrong variant"),
431 }
432 }
433
434 #[test]
435 fn non_retryable_for_each_backend_variant_via_from() {
436 let cases: Vec<BackendError> = vec![
437 BackendError::Spawn("s".to_string()),
438 BackendError::Protocol("p".to_string()),
439 BackendError::Connection("c".to_string()),
440 BackendError::Timeout,
441 BackendError::Cancelled,
442 BackendError::Config("cfg".to_string()),
443 BackendError::Io("io".to_string()),
444 BackendError::Parse("parse".to_string()),
445 BackendError::Execution("exec".to_string()),
446 BackendError::Other(anyhow::anyhow!("anyhow-err")),
447 ];
448 for inner in cases {
449 let debug_repr = format!("{inner:?}");
450 let converted: SchedulerError = inner.into();
451 assert!(
452 matches!(converted, SchedulerError::NonRetryable(_)),
453 "From conversion failed for {debug_repr}"
454 );
455 let s = converted.to_string();
456 assert!(
457 s.starts_with("backend error (non-retryable): "),
458 "wrong display prefix for {debug_repr}: {s}"
459 );
460 }
461 }
462
463 #[test]
464 fn schema_validation_with_empty_string() {
465 let e = SchedulerError::SchemaValidation(String::new());
466 assert_eq!(e.to_string(), "output schema validation failed: ");
467 }
468
469 #[test]
470 fn schema_validation_with_multiline_message() {
471 let msg = "line one\nline two\n indented".to_string();
472 let e = SchedulerError::SchemaValidation(msg);
473 let s = e.to_string();
474 assert!(s.starts_with("output schema validation failed: "));
475 assert!(s.contains("line one"));
476 assert!(s.contains("line two"));
477 assert!(s.contains("indented"));
478 }
479
480 #[test]
486 fn exhaustive_match_counts_all_variants() {
487 let variants: Vec<SchedulerError> = vec![
488 SchedulerError::UnknownBackend(String::new()),
489 SchedulerError::NoBackendRegistered,
490 SchedulerError::RunNotFound(RunId::nil()),
491 SchedulerError::QuotaExceeded { limit: 0, used: 0 },
492 SchedulerError::RunCancelled,
493 SchedulerError::AgentCancelled,
494 SchedulerError::NonRetryable(BackendError::Timeout),
495 SchedulerError::Exhausted {
496 attempts: 1,
497 source: BackendError::Timeout,
498 },
499 SchedulerError::SchemaValidation(String::new()),
500 ];
501 let mut seen = std::collections::HashSet::new();
502 for v in variants {
503 let key = match &v {
504 SchedulerError::UnknownBackend(_) => "UnknownBackend",
505 SchedulerError::NoBackendRegistered => "NoBackendRegistered",
506 SchedulerError::RunNotFound(_) => "RunNotFound",
507 SchedulerError::QuotaExceeded { .. } => "QuotaExceeded",
508 SchedulerError::RunCancelled => "RunCancelled",
509 SchedulerError::AgentCancelled => "AgentCancelled",
510 SchedulerError::NonRetryable(_) => "NonRetryable",
511 SchedulerError::Exhausted { .. } => "Exhausted",
512 SchedulerError::SchemaValidation(_) => "SchemaValidation",
513 };
514 assert!(seen.insert(key), "duplicate variant: {key}");
515 }
516 assert_eq!(seen.len(), 9, "expected 9 distinct variants");
517 }
518}