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 {
139 limit: 5,
140 used: 5,
141 };
142 match e {
143 SchedulerError::QuotaExceeded { limit, used } => {
144 assert_eq!(limit, 5);
145 assert_eq!(used, 5);
146 }
147 _ => panic!("wrong variant"),
148 }
149 }
150
151 #[test]
152 fn construct_run_cancelled() {
153 assert!(matches!(
154 SchedulerError::RunCancelled,
155 SchedulerError::RunCancelled
156 ));
157 }
158
159 #[test]
160 fn construct_agent_cancelled() {
161 assert!(matches!(
162 SchedulerError::AgentCancelled,
163 SchedulerError::AgentCancelled
164 ));
165 }
166
167 #[test]
168 fn construct_non_retryable() {
169 let inner = BackendError::Connection("refused".to_string());
170 match SchedulerError::NonRetryable(inner) {
171 SchedulerError::NonRetryable(got) => {
172 assert!(matches!(got, BackendError::Connection(_)));
173 }
174 _ => panic!("wrong variant"),
175 }
176 }
177
178 #[test]
179 fn construct_exhausted() {
180 let inner = BackendError::Spawn("oops".to_string());
181 let e = SchedulerError::Exhausted {
182 attempts: 7,
183 source: inner,
184 };
185 match e {
186 SchedulerError::Exhausted { attempts, source } => {
187 assert_eq!(attempts, 7);
188 assert!(matches!(source, BackendError::Spawn(_)));
189 }
190 _ => panic!("wrong variant"),
191 }
192 }
193
194 #[test]
195 fn construct_schema_validation() {
196 let e = SchedulerError::SchemaValidation(String::from("type mismatch"));
197 match e {
198 SchedulerError::SchemaValidation(s) => assert_eq!(s, "type mismatch"),
199 _ => panic!("wrong variant"),
200 }
201 }
202
203 #[test]
206 fn from_backend_error_yields_non_retryable() {
207 let inner = BackendError::Parse("json".to_string());
208 let converted: SchedulerError = inner.into();
209 assert!(matches!(
210 converted,
211 SchedulerError::NonRetryable(BackendError::Parse(_))
212 ));
213 }
214
215 #[test]
216 fn from_backend_error_preserves_message() {
217 let inner = BackendError::Io("disk full".to_string());
218 let converted: SchedulerError = inner.into();
219 assert_eq!(
220 converted.to_string(),
221 "backend error (non-retryable): IO error: disk full"
222 );
223 }
224
225 #[test]
226 fn from_backend_error_question_mark_works_in_fallible_fn() {
227 fn make_err() -> Result<(), SchedulerError> {
228 let _ = BackendError::Timeout;
229 Err(BackendError::Timeout)?;
230 Ok(())
231 }
232 let err = make_err().unwrap_err();
233 assert!(matches!(
234 err,
235 SchedulerError::NonRetryable(BackendError::Timeout)
236 ));
237 }
238
239 #[test]
242 fn source_for_non_retryable_is_backend_error() {
243 let inner = BackendError::Protocol("x".to_string());
244 let e = SchedulerError::NonRetryable(inner);
245 let src = std::error::Error::source(&e).expect("NonRetryable should expose a source");
246 let downcast = src
247 .downcast_ref::<BackendError>()
248 .expect("source should be BackendError");
249 assert!(matches!(downcast, BackendError::Protocol(_)));
250 }
251
252 #[test]
253 fn source_for_exhausted_is_backend_error() {
254 let inner = BackendError::Spawn("boom".to_string());
255 let e = SchedulerError::Exhausted {
256 attempts: 4,
257 source: inner,
258 };
259 let src = std::error::Error::source(&e).expect("Exhausted should expose a source");
260 let downcast = src
261 .downcast_ref::<BackendError>()
262 .expect("source should be BackendError");
263 assert!(matches!(downcast, BackendError::Spawn(_)));
264 }
265
266 #[test]
267 fn source_for_plain_variants_is_none() {
268 let cases = [
269 SchedulerError::UnknownBackend("x".to_string()),
270 SchedulerError::NoBackendRegistered,
271 SchedulerError::RunNotFound(RunId::nil()),
272 SchedulerError::QuotaExceeded { limit: 0, used: 0 },
273 SchedulerError::RunCancelled,
274 SchedulerError::AgentCancelled,
275 SchedulerError::SchemaValidation(String::new()),
276 ];
277 for e in cases {
278 assert!(
279 std::error::Error::source(&e).is_none(),
280 "{e:?} should have no source"
281 );
282 }
283 }
284
285 #[test]
288 fn scheduler_error_is_send_and_sync() {
289 fn assert_send<T: Send>() {}
290 fn assert_sync<T: Sync>() {}
291 assert_send::<SchedulerError>();
292 assert_sync::<SchedulerError>();
293 }
294
295 #[test]
296 fn scheduler_error_implements_std_error() {
297 fn assert_error<T: std::error::Error + 'static>() {}
298 assert_error::<SchedulerError>();
299 }
300
301 #[test]
304 fn debug_all_variants_contains_variant_name() {
305 let cases: Vec<(SchedulerError, &str)> = vec![
306 (
307 SchedulerError::UnknownBackend("id".to_string()),
308 "UnknownBackend",
309 ),
310 (
311 SchedulerError::NoBackendRegistered,
312 "NoBackendRegistered",
313 ),
314 (
315 SchedulerError::RunNotFound(RunId::nil()),
316 "RunNotFound",
317 ),
318 (
319 SchedulerError::QuotaExceeded {
320 limit: 1,
321 used: 2,
322 },
323 "QuotaExceeded",
324 ),
325 (SchedulerError::RunCancelled, "RunCancelled"),
326 (SchedulerError::AgentCancelled, "AgentCancelled"),
327 (
328 SchedulerError::NonRetryable(BackendError::Timeout),
329 "NonRetryable",
330 ),
331 (
332 SchedulerError::Exhausted {
333 attempts: 1,
334 source: BackendError::Timeout,
335 },
336 "Exhausted",
337 ),
338 (
339 SchedulerError::SchemaValidation("x".to_string()),
340 "SchemaValidation",
341 ),
342 ];
343 for (e, expected) in cases {
344 let dbg = format!("{e:?}");
345 assert!(
346 dbg.contains(expected),
347 "Debug of {expected} did not contain variant name: {dbg}"
348 );
349 }
350 }
351
352 #[test]
355 fn unknown_backend_with_empty_string() {
356 let e = SchedulerError::UnknownBackend(String::new());
357 assert_eq!(e.to_string(), "unknown backend: ");
358 }
359
360 #[test]
361 fn unknown_backend_with_unicode_and_special_chars() {
362 let e = SchedulerError::UnknownBackend("后端-1/💥".to_string());
363 let s = e.to_string();
364 assert!(s.starts_with("unknown backend: "));
365 assert!(s.contains("后端-1/💥"));
366 }
367
368 #[test]
369 fn run_not_found_with_nil_uuid() {
370 let e = SchedulerError::RunNotFound(RunId::nil());
371 assert!(e.to_string().contains(&RunId::nil().to_string()));
372 }
373
374 #[test]
375 fn run_not_found_with_random_uuid() {
376 let id = RunId::now_v7();
377 let e = SchedulerError::RunNotFound(id);
378 assert!(e.to_string().contains(&id.to_string()));
379 }
380
381 #[test]
382 fn quota_exceeded_with_zero_limit_and_zero_used() {
383 let e = SchedulerError::QuotaExceeded {
384 limit: 0,
385 used: 0,
386 };
387 assert_eq!(e.to_string(), "quota exceeded: limit=0, used=0");
388 }
389
390 #[test]
391 fn quota_exceeded_with_max_u32_values() {
392 let e = SchedulerError::QuotaExceeded {
393 limit: u32::MAX,
394 used: u32::MAX,
395 };
396 assert_eq!(
397 e.to_string(),
398 "quota exceeded: limit=4294967295, used=4294967295"
399 );
400 }
401
402 #[test]
403 fn quota_exceeded_with_used_greater_than_limit() {
404 let e = SchedulerError::QuotaExceeded {
405 limit: 3,
406 used: 100,
407 };
408 let s = e.to_string();
409 assert!(s.contains("limit=3"));
410 assert!(s.contains("used=100"));
411 }
412
413 #[test]
414 fn exhausted_with_zero_attempts_is_valid() {
415 let e = SchedulerError::Exhausted {
416 attempts: 0,
417 source: BackendError::Cancelled,
418 };
419 assert_eq!(
420 e.to_string(),
421 "backend error after 0 attempts: cancelled"
422 );
423 }
424
425 #[test]
426 fn exhausted_with_max_attempts() {
427 let e = SchedulerError::Exhausted {
428 attempts: u32::MAX,
429 source: BackendError::Timeout,
430 };
431 let s = e.to_string();
432 assert!(s.contains("4294967295 attempts"));
433 assert!(s.contains("backend timed out"));
434 }
435
436 #[test]
437 fn exhausted_preserves_inner_backend_error() {
438 let inner = BackendError::Execution("nope".to_string());
439 let e = SchedulerError::Exhausted {
440 attempts: 2,
441 source: inner,
442 };
443 match e {
444 SchedulerError::Exhausted { source, .. } => match source {
445 BackendError::Execution(msg) => assert_eq!(msg, "nope"),
446 _ => panic!("inner error lost"),
447 },
448 _ => panic!("wrong variant"),
449 }
450 }
451
452 #[test]
453 fn non_retryable_for_each_backend_variant_via_from() {
454 let cases: Vec<BackendError> = vec![
455 BackendError::Spawn("s".to_string()),
456 BackendError::Protocol("p".to_string()),
457 BackendError::Connection("c".to_string()),
458 BackendError::Timeout,
459 BackendError::Cancelled,
460 BackendError::Config("cfg".to_string()),
461 BackendError::Io("io".to_string()),
462 BackendError::Parse("parse".to_string()),
463 BackendError::Execution("exec".to_string()),
464 BackendError::Other(anyhow::anyhow!("anyhow-err")),
465 ];
466 for inner in cases {
467 let debug_repr = format!("{inner:?}");
468 let converted: SchedulerError = inner.into();
469 assert!(
470 matches!(converted, SchedulerError::NonRetryable(_)),
471 "From conversion failed for {debug_repr}"
472 );
473 let s = converted.to_string();
474 assert!(
475 s.starts_with("backend error (non-retryable): "),
476 "wrong display prefix for {debug_repr}: {s}"
477 );
478 }
479 }
480
481 #[test]
482 fn schema_validation_with_empty_string() {
483 let e = SchedulerError::SchemaValidation(String::new());
484 assert_eq!(e.to_string(), "output schema validation failed: ");
485 }
486
487 #[test]
488 fn schema_validation_with_multiline_message() {
489 let msg = "line one\nline two\n indented".to_string();
490 let e = SchedulerError::SchemaValidation(msg);
491 let s = e.to_string();
492 assert!(s.starts_with("output schema validation failed: "));
493 assert!(s.contains("line one"));
494 assert!(s.contains("line two"));
495 assert!(s.contains("indented"));
496 }
497
498 #[test]
504 fn exhaustive_match_counts_all_variants() {
505 let variants: Vec<SchedulerError> = vec![
506 SchedulerError::UnknownBackend(String::new()),
507 SchedulerError::NoBackendRegistered,
508 SchedulerError::RunNotFound(RunId::nil()),
509 SchedulerError::QuotaExceeded {
510 limit: 0,
511 used: 0,
512 },
513 SchedulerError::RunCancelled,
514 SchedulerError::AgentCancelled,
515 SchedulerError::NonRetryable(BackendError::Timeout),
516 SchedulerError::Exhausted {
517 attempts: 1,
518 source: BackendError::Timeout,
519 },
520 SchedulerError::SchemaValidation(String::new()),
521 ];
522 let mut seen = std::collections::HashSet::new();
523 for v in variants {
524 let key = match &v {
525 SchedulerError::UnknownBackend(_) => "UnknownBackend",
526 SchedulerError::NoBackendRegistered => "NoBackendRegistered",
527 SchedulerError::RunNotFound(_) => "RunNotFound",
528 SchedulerError::QuotaExceeded { .. } => "QuotaExceeded",
529 SchedulerError::RunCancelled => "RunCancelled",
530 SchedulerError::AgentCancelled => "AgentCancelled",
531 SchedulerError::NonRetryable(_) => "NonRetryable",
532 SchedulerError::Exhausted { .. } => "Exhausted",
533 SchedulerError::SchemaValidation(_) => "SchemaValidation",
534 };
535 assert!(seen.insert(key), "duplicate variant: {key}");
536 }
537 assert_eq!(seen.len(), 9, "expected 9 distinct variants");
538 }
539}