Skip to main content

luft_core/scheduler/
error.rs

1//! Scheduler errors (§2.2).
2
3use 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    // ── Display formatting (spec-required strings) ──────────────
34
35    #[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    // ── Construction / variant payload access ───────────────────
111
112    #[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    // ── `From<BackendError>` auto-conversion (via `#[from]`) ────
201
202    #[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    // ── std::error::Error::source() chain ───────────────────────
237
238    #[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    // ── Trait surface (compile-time assertions) ────────────────
283
284    #[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    // ── Debug formatting ────────────────────────────────────────
299
300    #[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    // ── Edge cases / boundary conditions ────────────────────────
341
342    #[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    // ── Pattern matching coverage ───────────────────────────────
481    //
482    // If a new variant is added, the match below becomes non-exhaustive and
483    // the build breaks, prompting test updates.
484
485    #[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}