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 {
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    // ── `From<BackendError>` auto-conversion (via `#[from]`) ────
204
205    #[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    // ── std::error::Error::source() chain ───────────────────────
240
241    #[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    // ── Trait surface (compile-time assertions) ────────────────
286
287    #[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    // ── Debug formatting ────────────────────────────────────────
302
303    #[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    // ── Edge cases / boundary conditions ────────────────────────
353
354    #[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    // ── Pattern matching coverage ───────────────────────────────
499    //
500    // If a new variant is added, the match below becomes non-exhaustive and
501    // the build breaks, prompting test updates.
502
503    #[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}