Skip to main content

luft_core/scheduler/
mod.rs

1//! Concurrent scheduler (M1, §2): concurrency limiting, per-run quota, retry,
2//! cancellation, and event reporting.
3//!
4//! Design note (§9.2 C1): the public `run_agent` returns
5//! `Result<AgentResult, SchedulerError>` rather than the design doc's
6//! `(AgentResult, TaskHandle)` tuple — per-agent cancellation is keyed by
7//! `agent_id` via [`Scheduler::cancel_agent`], so a handle is unnecessary.
8
9mod config;
10mod error;
11mod registry;
12
13pub use config::{RetryPolicy, SchedulerConfig};
14pub use error::SchedulerError;
15pub use registry::BackendRegistry;
16
17use crate::contract::*;
18use chrono::Utc;
19use dashmap::DashMap;
20use std::sync::atomic::{AtomicU32, Ordering};
21use std::sync::Arc;
22use std::time::Instant;
23use tokio::sync::{broadcast, Semaphore};
24use tokio_util::sync::CancellationToken;
25
26/// Callback invoked by the scheduler when an agent completes.
27/// Implemented by JournalStore to enable transparent persistence.
28/// Defined here (not in journal.rs) to avoid circular dependency:
29///   scheduler → journal → scheduler.
30#[async_trait::async_trait]
31pub trait JournalCallback: Send + Sync {
32    /// Called when an agent completes (success or non-retryable failure).
33    async fn on_agent_done(
34        &self,
35        agent_id: AgentId,
36        phase_id: PhaseId,
37        status: AgentStatus,
38        output: serde_json::Value,
39        tokens: TokenUsage,
40    );
41}
42
43/// Per-run state held inside the scheduler.
44struct RunState {
45    quota_used: Arc<AtomicU32>,
46    run_cancel: CancellationToken,
47    events: EventSender,
48    /// Per-agent cancel tokens (children of `run_cancel`), keyed by agent id.
49    agent_cancels: DashMap<AgentId, CancellationToken>,
50}
51
52/// Concurrency-controlled agent scheduler. Held as `Arc<Scheduler>` and shared
53/// across orchestration coroutines.
54pub struct Scheduler {
55    config: SchedulerConfig,
56    semaphore: Arc<Semaphore>,
57    registry: BackendRegistry,
58    runs: DashMap<RunId, RunState>,
59    /// Optional journal callback invoked after each agent completes.
60    /// Used by JournalStore for transparent checkpoint persistence.
61    journal_callback: Option<Arc<dyn JournalCallback>>,
62}
63
64impl Scheduler {
65    pub fn new(
66        config: SchedulerConfig,
67        registry: BackendRegistry,
68        journal_callback: Option<Arc<dyn JournalCallback>>,
69    ) -> Arc<Self> {
70        let semaphore = Arc::new(Semaphore::new(config.max_concurrency));
71        Arc::new(Self {
72            config,
73            semaphore,
74            registry,
75            runs: DashMap::new(),
76            journal_callback,
77        })
78    }
79
80    pub fn config(&self) -> &SchedulerConfig {
81        &self.config
82    }
83
84    /// Initialise per-run state. Must be called before any `run_agent`.
85    /// Returns the broadcast receiver; further consumers use `resubscribe()`.
86    pub fn init_run(
87        &self,
88        run_id: RunId,
89        event_capacity: usize,
90    ) -> broadcast::Receiver<AgentEvent> {
91        let (tx, rx) = broadcast::channel(event_capacity);
92        self.init_run_with(run_id, tx);
93        rx
94    }
95
96    /// Initialise per-run state using an externally-owned event sender.
97    ///
98    /// This lets the orchestration layer share a single event bus between the
99    /// scheduler (`AgentStarted`/`AgentDone`, plus the [`RunContext`] handed to
100    /// backends) and the runtime SDK (`phase`/`log`/`pipeline`/`RunDone`).
101    pub fn init_run_with(&self, run_id: RunId, events: EventSender) {
102        self.runs.insert(
103            run_id,
104            RunState {
105                quota_used: Arc::new(AtomicU32::new(0)),
106                run_cancel: CancellationToken::new(),
107                events,
108                agent_cancels: DashMap::new(),
109            },
110        );
111    }
112
113    /// Schedule and run a single agent task: quota check → permit → retry loop →
114    /// events. Cancellation flows via `RunContext::cancel`.
115    ///
116    /// The `agent` span carries `run_id`/`agent_id`/`phase_id`/`model` so every
117    /// log emitted on this task's async path inherits them (see
118    /// `docs/design/program-logging.md`).
119    #[tracing::instrument(
120        name = "agent",
121        skip_all,
122        fields(
123            run_id = %run_id,
124            agent_id = %task.agent_id,
125            phase_id = task.phase_id,
126            model = task.model.as_deref().unwrap_or("default"),
127        )
128    )]
129    pub async fn run_agent(
130        &self,
131        run_id: RunId,
132        mut task: AgentTask,
133        backend_id: Option<&str>,
134    ) -> Result<AgentResult, SchedulerError> {
135        let backend = match backend_id {
136            Some(id) => self.registry.get(id)?,
137            None => {
138                // Default routing: prefer the ACP-captured "current backend",
139                // resolved via its registry id. Falls back to the registry's
140                // designated default when no handshake has captured one yet
141                // (the first agent of a run, or a mock / non-ACP backend that
142                // never handshakes), or when the captured id is no longer
143                // registered.
144                let from_current = crate::contract::current_backend()
145                    .and_then(|cb| self.registry.get(&cb.id).ok());
146                match from_current {
147                    Some(b) => b,
148                    None => self.registry.default_backend()?,
149                }
150            }
151        };
152
153        // Snapshot per-run handles without holding the DashMap guard across await.
154        let (quota_used, run_cancel, events) = {
155            let rs = self
156                .runs
157                .get(&run_id)
158                .ok_or(SchedulerError::RunNotFound(run_id))?;
159            (
160                rs.quota_used.clone(),
161                rs.run_cancel.clone(),
162                rs.events.clone(),
163            )
164        };
165
166        // Quota.
167        let used = quota_used.fetch_add(1, Ordering::Relaxed) + 1;
168        if used > self.config.quota_per_run {
169            tracing::warn!(
170                used,
171                limit = self.config.quota_per_run,
172                "run quota exceeded"
173            );
174            let _ = events.send(AgentEvent::AgentDone {
175                run_id,
176                agent_id: task.agent_id,
177                status: AgentStatus::Error,
178                tokens: TokenUsage::default(),
179                elapsed_ms: 0,
180                name: task.name.clone(),
181                agent_seq: task.agent_seq,
182                output: serde_json::Value::Null,
183                findings: Vec::new(),
184                prompt: task.prompt.clone(),
185                retry_count: 0,
186                ts: Utc::now(),
187            });
188            return Err(SchedulerError::QuotaExceeded {
189                limit: self.config.quota_per_run,
190                used,
191            });
192        }
193
194        // Per-agent cancel token: a child of the run token, so it fires when the
195        // run is cancelled OR this agent is cancelled individually.
196        let agent_token = run_cancel.child_token();
197        if let Some(rs) = self.runs.get(&run_id) {
198            rs.agent_cancels.insert(task.agent_id, agent_token.clone());
199        }
200
201        // Acquire a permit (cancellable while waiting).
202        let permit = tokio::select! {
203            p = self.semaphore.clone().acquire_owned() => p.expect("semaphore never closed"),
204            _ = agent_token.cancelled() => {
205                let _ = events.send(AgentEvent::AgentDone {
206                    run_id,
207                    agent_id: task.agent_id,
208                    status: AgentStatus::Cancelled,
209                    tokens: TokenUsage::default(),
210                    elapsed_ms: 0,
211                    name: task.name.clone(),
212                    agent_seq: task.agent_seq,
213                    output: serde_json::Value::Null,
214                    findings: Vec::new(),
215                    prompt: task.prompt.clone(),
216                    retry_count: 0,
217                    ts: Utc::now(),
218                });
219                self.cleanup_agent(run_id, task.agent_id);
220                return Err(cancel_kind(&run_cancel));
221            }
222        };
223
224        let _ = events.send(AgentEvent::AgentStarted {
225            run_id,
226            phase_id: task.phase_id,
227            agent_id: task.agent_id,
228            prompt_preview: preview(&task.prompt),
229            model: task.model.clone(),
230            description: task.description.clone(),
231            role: task.role.clone(),
232            name: task.name.clone(),
233            agent_seq: task.agent_seq,
234            ts: Utc::now(),
235        });
236
237        let start = Instant::now();
238        let mut attempt = 0u32;
239        let original_prompt = task.prompt.clone();
240        let mut schema_retry_count = 0u32;
241        let outcome: Result<AgentResult, SchedulerError> = loop {
242            let ctx = RunContext {
243                run_id,
244                cancel: agent_token.clone(),
245                events: events.clone(),
246            };
247            let run_fut = backend.run(task.clone(), ctx);
248            let res = match task.timeout {
249                Some(t) => match tokio::time::timeout(t, run_fut).await {
250                    Ok(r) => r,
251                    Err(_) => Err(BackendError::Timeout),
252                },
253                None => run_fut.await,
254            };
255
256            match res {
257                Ok(result) => {
258                    if let Some(ref schema) = task.output_schema {
259                        let fallback = result.output.get("_agent_fallback_text").is_some();
260                        let validation_err = if fallback {
261                            Some(
262                                "agent returned text instead of calling structured_output tool"
263                                    .to_string(),
264                            )
265                        } else {
266                            validate_output(&result.output, schema)
267                                .err()
268                                .map(|e| e.to_string())
269                        };
270
271                        if let Some(error) = validation_err {
272                            schema_retry_count += 1;
273                            if schema_retry_count > self.config.retry.schema_retry_max {
274                                tracing::error!(
275                                    error = %error,
276                                    attempts = schema_retry_count,
277                                    "agent output failed schema validation, retries exhausted"
278                                );
279                                break Err(SchedulerError::SchemaValidation(error));
280                            }
281                            let _ = events.send(AgentEvent::SchemaRetry {
282                                run_id,
283                                agent_id: task.agent_id,
284                                attempt: schema_retry_count,
285                                max: self.config.retry.schema_retry_max,
286                            });
287                            tracing::warn!(
288                                error = %error,
289                                attempt = schema_retry_count,
290                                "schema validation failed, retrying with feedback"
291                            );
292                            let schema_json =
293                                serde_json::to_string_pretty(schema).unwrap_or_default();
294                            let last_output = if fallback {
295                                result
296                                    .output
297                                    .get("text")
298                                    .and_then(|v| v.as_str())
299                                    .unwrap_or("")
300                                    .to_string()
301                            } else {
302                                serde_json::to_string_pretty(&result.output).unwrap_or_default()
303                            };
304                            task.prompt = if fallback {
305                                format!(
306                                    "{original_prompt}\n\n\
307                                     ---\n\
308                                     You returned your result as plain text instead of calling the `structured_output` tool.\n\
309                                     You MUST call the `structured_output` tool to submit your result.\n\
310                                     Do NOT return the result as a text message.\n\
311                                     \n\
312                                     Your text output was:\n\
313                                     ```\n{last_output}\n```\n\
314                                     \n\
315                                     Required JSON Schema:\n\
316                                     ```json\n{schema}\n```",
317                                    original_prompt = original_prompt,
318                                    last_output = last_output,
319                                    schema = schema_json,
320                                )
321                            } else {
322                                format!(
323                                    "{original_prompt}\n\n\
324                                     ---\n\
325                                     Your previous response did not match the required schema.\n\
326                                     Error: {error}\n\
327                                     \n\
328                                     Your output was:\n\
329                                     ```json\n{last_output}\n```\n\
330                                     \n\
331                                     Required JSON Schema:\n\
332                                     ```json\n{schema}\n```\n\
333                                     \n\
334                                     Call the `structured_output` tool with a JSON object that\n\
335                                     matches this schema exactly. Include ALL required fields.",
336                                    original_prompt = original_prompt,
337                                    error = error,
338                                    last_output = last_output,
339                                    schema = schema_json,
340                                )
341                            };
342                            continue;
343                        }
344                    }
345                    break Ok(result);
346                }
347                Err(e) => {
348                    if agent_token.is_cancelled() || matches!(e, BackendError::Cancelled) {
349                        tracing::debug!("agent cancelled");
350                        break Err(cancel_kind(&run_cancel));
351                    }
352                    if !e.is_retryable() {
353                        tracing::error!(error = %e, "non-retryable backend error");
354                        break Err(SchedulerError::NonRetryable(e));
355                    }
356                    attempt += 1;
357                    if attempt > self.config.retry.max_attempts {
358                        tracing::error!(attempts = attempt, error = %e, "agent exhausted retries");
359                        break Err(SchedulerError::Exhausted {
360                            attempts: attempt,
361                            source: e,
362                        });
363                    }
364                    let backoff = self.config.retry.backoff(attempt);
365                    tracing::warn!(
366                        attempt, backoff_ms = backoff.as_millis() as u64, error = %e,
367                        "retryable backend error; retrying"
368                    );
369                    tokio::select! {
370                        _ = tokio::time::sleep(backoff) => {}
371                        _ = agent_token.cancelled() => break Err(cancel_kind(&run_cancel)),
372                    }
373                }
374            }
375        };
376
377        let elapsed_ms = start.elapsed().as_millis() as u64;
378        let (status, tokens) = match &outcome {
379            Ok(r) => (r.status.clone(), r.tokens_used),
380            Err(SchedulerError::AgentCancelled) | Err(SchedulerError::RunCancelled) => {
381                (AgentStatus::Cancelled, TokenUsage::default())
382            }
383            Err(_) => (AgentStatus::Error, TokenUsage::default()),
384        };
385        let _ = events.send(AgentEvent::AgentDone {
386            run_id,
387            agent_id: task.agent_id,
388            status: status.clone(),
389            tokens,
390            elapsed_ms,
391            name: task.name.clone(),
392            agent_seq: task.agent_seq,
393            output: match &outcome {
394                Ok(r) => r.output.clone(),
395                Err(_) => serde_json::Value::Null,
396            },
397            findings: match &outcome {
398                Ok(r) => r.findings.clone(),
399                Err(_) => Vec::new(),
400            },
401            prompt: task.prompt.clone(),
402            retry_count: attempt,
403            ts: Utc::now(),
404        });
405        tracing::info!(?status, elapsed_ms, "agent finished");
406
407        // Invoke journal callback if configured (M1 transparent persistence).
408        if let Some(ref cb) = self.journal_callback {
409            let output = match &outcome {
410                Ok(r) => r.output.clone(),
411                Err(_) => serde_json::Value::Null,
412            };
413            let agent_status = status.clone();
414            let tokens_used = tokens;
415            let agent_id = task.agent_id;
416            let phase_id = task.phase_id;
417            cb.on_agent_done(agent_id, phase_id, agent_status, output, tokens_used)
418                .await;
419        }
420
421        drop(permit);
422        self.cleanup_agent(run_id, task.agent_id);
423        outcome
424    }
425
426    /// Run a batch of tasks concurrently (the `parallel()` primitive). Bounded
427    /// by the same global semaphore; does not short-circuit on failure — results
428    /// preserve input order.
429    pub async fn run_parallel(
430        &self,
431        run_id: RunId,
432        tasks: Vec<(AgentTask, Option<String>)>,
433    ) -> Vec<Result<AgentResult, SchedulerError>> {
434        let futs = tasks.into_iter().map(|(task, backend)| async move {
435            self.run_agent(run_id, task, backend.as_deref()).await
436        });
437        futures::future::join_all(futs).await
438    }
439
440    /// Cancel one agent (fires its token; the backend observes `ctx.cancel`).
441    pub fn cancel_agent(&self, run_id: RunId, agent_id: AgentId) {
442        if let Some(rs) = self.runs.get(&run_id) {
443            if let Some(tok) = rs.agent_cancels.get(&agent_id) {
444                tok.cancel();
445            }
446        }
447    }
448
449    /// Cancel the whole run (all child agent tokens fire).
450    pub fn cancel_run(&self, run_id: RunId) {
451        if let Some(rs) = self.runs.get(&run_id) {
452            rs.run_cancel.cancel();
453        }
454    }
455
456    /// Quota consumed by a run, if initialised.
457    pub fn quota_used(&self, run_id: RunId) -> Option<u32> {
458        self.runs
459            .get(&run_id)
460            .map(|rs| rs.quota_used.load(Ordering::Relaxed))
461    }
462
463    fn cleanup_agent(&self, run_id: RunId, agent_id: AgentId) {
464        if let Some(rs) = self.runs.get(&run_id) {
465            rs.agent_cancels.remove(&agent_id);
466        }
467    }
468}
469
470fn cancel_kind(run_cancel: &CancellationToken) -> SchedulerError {
471    if run_cancel.is_cancelled() {
472        SchedulerError::RunCancelled
473    } else {
474        SchedulerError::AgentCancelled
475    }
476}
477
478fn preview(s: &str) -> String {
479    s.chars().take(60).collect()
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use crate::mock_backend::{FailKind, MockBackend, MockBehavior};
486    use std::path::PathBuf;
487    use std::sync::atomic::AtomicUsize;
488    use std::time::Duration;
489    use uuid::Uuid;
490
491    fn fast_config(max_concurrency: usize, quota: u32) -> SchedulerConfig {
492        SchedulerConfig {
493            max_concurrency,
494            quota_per_run: quota,
495            retry: RetryPolicy {
496                max_attempts: 2,
497                initial_backoff: Duration::from_millis(1),
498                backoff_multiplier: 2.0,
499                max_backoff: Duration::from_millis(5),
500                schema_retry_max: 1,
501            },
502        }
503    }
504
505    fn mk_task(prompt: &str) -> AgentTask {
506        AgentTask {
507            agent_id: Uuid::now_v7(),
508            phase_id: 0,
509            prompt: prompt.to_string(),
510            model: None,
511            allowlist: None,
512            workdir: PathBuf::from("."),
513            mcp_endpoint: None,
514            timeout: None,
515            output_schema: None,
516            workdir_override: None,
517            description: None,
518            role: None,
519            name: None,
520            agent_seq: 0,
521            thread_id: None,
522        }
523    }
524
525    fn mk_task_with_schema(prompt: &str) -> AgentTask {
526        let mut task = mk_task(prompt);
527        task.output_schema = Some(serde_json::json!({
528            "type": "object",
529            "properties": {
530                "answer": { "type": "string" }
531            },
532            "required": ["answer"]
533        }));
534        task
535    }
536
537    fn fallback_output(text: &str) -> serde_json::Value {
538        serde_json::json!({
539            "_agent_fallback_text": true,
540            "text": text,
541        })
542    }
543
544    fn ok_result(id: AgentId) -> AgentResult {
545        AgentResult {
546            agent_id: id,
547            status: AgentStatus::Ok,
548            output: serde_json::Value::Null,
549            findings: vec![],
550            tokens_used: TokenUsage::default(),
551            artifacts: vec![],
552            logs: LogRef::default(),
553            thread_id: None,
554        }
555    }
556
557    fn sched_with(backend: Arc<dyn AgentBackend>, cfg: SchedulerConfig) -> Arc<Scheduler> {
558        Scheduler::new(cfg, BackendRegistry::new().with(backend), None)
559    }
560
561    // A backend that records peak concurrency.
562    struct ProbeBackend {
563        cur: Arc<AtomicUsize>,
564        peak: Arc<AtomicUsize>,
565        delay: Duration,
566    }
567
568    #[async_trait::async_trait]
569    impl AgentBackend for ProbeBackend {
570        fn id(&self) -> &'static str {
571            "probe"
572        }
573        fn capabilities(&self) -> AgentCapabilities {
574            AgentCapabilities::default()
575        }
576        fn as_any(&self) -> &dyn std::any::Any {
577            self
578        }
579        async fn run(
580            &self,
581            task: AgentTask,
582            _ctx: RunContext,
583        ) -> Result<AgentResult, BackendError> {
584            let c = self.cur.fetch_add(1, Ordering::SeqCst) + 1;
585            self.peak.fetch_max(c, Ordering::SeqCst);
586            tokio::time::sleep(self.delay).await;
587            self.cur.fetch_sub(1, Ordering::SeqCst);
588            Ok(ok_result(task.agent_id))
589        }
590    }
591
592    #[tokio::test]
593    async fn test_default_config_concurrency() {
594        let c = SchedulerConfig::default().max_concurrency;
595        assert_eq!(c, 1);
596    }
597
598    /// A backend whose `id()` is parameterised and which records that id in the
599    /// agent result output, so per-task routing can be observed.
600    struct IdBackend {
601        id: &'static str,
602    }
603
604    #[async_trait::async_trait]
605    impl AgentBackend for IdBackend {
606        fn id(&self) -> &'static str {
607            self.id
608        }
609        fn capabilities(&self) -> AgentCapabilities {
610            AgentCapabilities::default()
611        }
612        fn as_any(&self) -> &dyn std::any::Any {
613            self
614        }
615        async fn run(&self, task: AgentTask, _ctx: RunContext) -> Result<AgentResult, BackendError> {
616            Ok(AgentResult {
617                agent_id: task.agent_id,
618                status: AgentStatus::Ok,
619                output: serde_json::Value::String(self.id.to_string()),
620                findings: vec![],
621                tokens_used: TokenUsage::default(),
622                artifacts: vec![],
623                logs: LogRef::default(),
624                thread_id: None,
625            })
626        }
627    }
628
629    #[tokio::test]
630    #[serial_test::serial]
631    async fn per_task_backend_routes_to_named_backend() {
632        // No captured current_backend -> the omitted-backend case must fall
633        // back to the registry default (alpha, first-registered).
634        crate::contract::clear_current_backend();
635        let a = Arc::new(IdBackend { id: "alpha" }) as Arc<dyn AgentBackend>;
636        let b = Arc::new(IdBackend { id: "beta" }) as Arc<dyn AgentBackend>;
637        // alpha is the default (first-registered); beta is selectable.
638        let sched = Arc::new(Scheduler::new(
639            fast_config(4, 1000),
640            BackendRegistry::new().with(a).with(b),
641            None,
642        ));
643        let run_id = Uuid::now_v7();
644        let _rx = sched.init_run(run_id, 256);
645
646        // Explicit backend id -> routes to that backend.
647        let r = sched
648            .run_agent(run_id, mk_task("t1"), Some("beta"))
649            .await
650            .unwrap();
651        assert_eq!(r.output, serde_json::Value::String("beta".to_string()));
652
653        // Omitted backend, no current_backend captured -> registry default (alpha).
654        let r = sched.run_agent(run_id, mk_task("t2"), None).await.unwrap();
655        assert_eq!(r.output, serde_json::Value::String("alpha".to_string()));
656        crate::contract::clear_current_backend();
657    }
658
659    #[tokio::test]
660    #[serial_test::serial]
661    async fn per_task_backend_follows_current_backend() {
662        // When the ACP handshake has captured a current backend, an omitted
663        // `backend` routes to it (via its registry id), even if it is not the
664        // registry default.
665        crate::contract::clear_current_backend();
666        let a = Arc::new(IdBackend { id: "alpha" }) as Arc<dyn AgentBackend>;
667        let b = Arc::new(IdBackend { id: "beta" }) as Arc<dyn AgentBackend>;
668        let sched = Arc::new(Scheduler::new(
669            fast_config(4, 1000),
670            BackendRegistry::new().with(a).with(b),
671            None,
672        ));
673        let run_id = Uuid::now_v7();
674        let _rx = sched.init_run(run_id, 256);
675
676        // Simulate the ACP handshake capturing "beta" as the current backend.
677        crate::contract::set_current_backend(crate::contract::CurrentBackend {
678            id: "beta".to_string(),
679            name: "beta".to_string(),
680            version: "0".to_string(),
681            title: None,
682        });
683
684        // Omitted backend -> routes to current_backend ("beta"), not the
685        // registry default ("alpha").
686        let r = sched.run_agent(run_id, mk_task("t"), None).await.unwrap();
687        assert_eq!(r.output, serde_json::Value::String("beta".to_string()));
688        crate::contract::clear_current_backend();
689    }
690
691    #[tokio::test]
692    #[serial_test::serial]
693    async fn per_task_backend_falls_back_when_current_unregistered() {
694        // current_backend captured but its id is not registered -> fall back
695        // to the registry default instead of erroring.
696        crate::contract::clear_current_backend();
697        let a = Arc::new(IdBackend { id: "alpha" }) as Arc<dyn AgentBackend>;
698        let sched = Arc::new(Scheduler::new(
699            fast_config(4, 1000),
700            BackendRegistry::new().with(a),
701            None,
702        ));
703        let run_id = Uuid::now_v7();
704        let _rx = sched.init_run(run_id, 256);
705
706        crate::contract::set_current_backend(crate::contract::CurrentBackend {
707            id: "gone".to_string(),
708            name: "gone".to_string(),
709            version: "0".to_string(),
710            title: None,
711        });
712        let r = sched.run_agent(run_id, mk_task("t"), None).await.unwrap();
713        assert_eq!(r.output, serde_json::Value::String("alpha".to_string()));
714        crate::contract::clear_current_backend();
715    }
716
717    #[tokio::test]
718    async fn per_task_backend_unknown_id_errors() {
719        let a = Arc::new(IdBackend { id: "alpha" }) as Arc<dyn AgentBackend>;
720        let sched = Arc::new(Scheduler::new(
721            fast_config(4, 1000),
722            BackendRegistry::new().with(a),
723            None,
724        ));
725        let run_id = Uuid::now_v7();
726        let _rx = sched.init_run(run_id, 256);
727        assert!(sched
728            .run_agent(run_id, mk_task("t"), Some("nope"))
729            .await
730            .is_err());
731    }
732
733    #[tokio::test]
734    async fn test_concurrency_limit() {
735        let cur = Arc::new(AtomicUsize::new(0));
736        let peak = Arc::new(AtomicUsize::new(0));
737        let backend = Arc::new(ProbeBackend {
738            cur: cur.clone(),
739            peak: peak.clone(),
740            delay: Duration::from_millis(40),
741        });
742        let sched = sched_with(backend, fast_config(2, 1000));
743        let run_id = Uuid::now_v7();
744        let _rx = sched.init_run(run_id, 256);
745
746        let tasks: Vec<_> = (0..6).map(|i| (mk_task(&format!("t{i}")), None)).collect();
747        let results = sched.run_parallel(run_id, tasks).await;
748
749        assert!(results.iter().all(|r| r.is_ok()));
750        assert!(
751            peak.load(Ordering::SeqCst) <= 2,
752            "peak {}",
753            peak.load(Ordering::SeqCst)
754        );
755    }
756
757    #[tokio::test]
758    async fn test_quota_exceeded() {
759        let backend = Arc::new(MockBackend::new(
760            "mock",
761            vec![MockBehavior::Success {
762                output: serde_json::Value::Null,
763                tokens: TokenUsage::default(),
764                delay: Duration::from_millis(5),
765            }],
766        ));
767        let sched = sched_with(backend, fast_config(8, 3));
768        let run_id = Uuid::now_v7();
769        let _rx = sched.init_run(run_id, 256);
770
771        let tasks: Vec<_> = (0..4).map(|i| (mk_task(&format!("t{i}")), None)).collect();
772        let results = sched.run_parallel(run_id, tasks).await;
773
774        let ok = results.iter().filter(|r| r.is_ok()).count();
775        let quota_err = results
776            .iter()
777            .filter(|r| matches!(r, Err(SchedulerError::QuotaExceeded { .. })))
778            .count();
779        assert_eq!(ok, 3);
780        assert_eq!(quota_err, 1);
781    }
782
783    #[tokio::test]
784    async fn test_retry_on_retryable_error() {
785        let backend = Arc::new(MockBackend::new(
786            "mock",
787            vec![
788                MockBehavior::fail(FailKind::Spawn),
789                MockBehavior::fail(FailKind::Spawn),
790                MockBehavior::Success {
791                    output: serde_json::Value::Null,
792                    tokens: TokenUsage::default(),
793                    delay: Duration::ZERO,
794                },
795            ],
796        ));
797        let probe = backend.clone();
798        let sched = sched_with(backend, fast_config(4, 1000));
799        let run_id = Uuid::now_v7();
800        let _rx = sched.init_run(run_id, 64);
801
802        let r = sched.run_agent(run_id, mk_task("x"), None).await;
803        assert!(r.is_ok(), "{r:?}");
804        assert_eq!(probe.call_count(), 3);
805    }
806
807    #[tokio::test]
808    async fn test_no_retry_on_non_retryable() {
809        let backend = Arc::new(MockBackend::new(
810            "mock",
811            vec![MockBehavior::fail(FailKind::Protocol)],
812        ));
813        let probe = backend.clone();
814        let sched = sched_with(backend, fast_config(4, 1000));
815        let run_id = Uuid::now_v7();
816        let _rx = sched.init_run(run_id, 64);
817
818        let r = sched.run_agent(run_id, mk_task("x"), None).await;
819        assert!(matches!(r, Err(SchedulerError::NonRetryable(_))), "{r:?}");
820        assert_eq!(probe.call_count(), 1);
821    }
822
823    #[tokio::test]
824    async fn test_retry_exhausted() {
825        let backend = Arc::new(MockBackend::new(
826            "mock",
827            vec![MockBehavior::fail(FailKind::Spawn)],
828        ));
829        let probe = backend.clone();
830        let sched = sched_with(backend, fast_config(4, 1000));
831        let run_id = Uuid::now_v7();
832        let _rx = sched.init_run(run_id, 64);
833
834        let r = sched.run_agent(run_id, mk_task("x"), None).await;
835        assert!(
836            matches!(r, Err(SchedulerError::Exhausted { attempts: 3, .. })),
837            "{r:?}"
838        );
839        assert_eq!(probe.call_count(), 3);
840    }
841
842    #[tokio::test]
843    async fn test_schema_fallback_then_succeeds() {
844        let backend = Arc::new(MockBackend::new(
845            "mock",
846            vec![
847                MockBehavior::Success {
848                    output: fallback_output("i forgot the tool"),
849                    tokens: TokenUsage::default(),
850                    delay: Duration::ZERO,
851                },
852                MockBehavior::Success {
853                    output: serde_json::json!({"answer": "ok"}),
854                    tokens: TokenUsage::default(),
855                    delay: Duration::ZERO,
856                },
857            ],
858        ));
859        let probe = backend.clone();
860        let sched = sched_with(backend, fast_config(4, 1000));
861        let run_id = Uuid::now_v7();
862        let mut rx = sched.init_run(run_id, 64);
863
864        let task = mk_task_with_schema("respond");
865        let r = sched.run_agent(run_id, task, None).await;
866        assert!(r.is_ok(), "{r:?}");
867        assert_eq!(probe.call_count(), 2);
868
869        let mut prompt_with_feedback = None;
870        while let Ok(event) = rx.try_recv() {
871            if let AgentEvent::AgentDone { prompt, .. } = event {
872                prompt_with_feedback = Some(prompt);
873            }
874        }
875        let prompt = prompt_with_feedback.expect("AgentDone event with prompt");
876        assert!(prompt.contains("structured_output"));
877        assert!(prompt.contains("Required JSON Schema"));
878    }
879
880    #[tokio::test]
881    async fn test_schema_mismatch_then_succeeds() {
882        let backend = Arc::new(MockBackend::new(
883            "mock",
884            vec![
885                MockBehavior::Success {
886                    output: serde_json::json!({"wrong": "field"}),
887                    tokens: TokenUsage::default(),
888                    delay: Duration::ZERO,
889                },
890                MockBehavior::Success {
891                    output: serde_json::json!({"answer": "ok"}),
892                    tokens: TokenUsage::default(),
893                    delay: Duration::ZERO,
894                },
895            ],
896        ));
897        let probe = backend.clone();
898        let sched = sched_with(backend, fast_config(4, 1000));
899        let run_id = Uuid::now_v7();
900        let _rx = sched.init_run(run_id, 64);
901
902        let task = mk_task_with_schema("respond");
903        let r = sched.run_agent(run_id, task, None).await;
904        assert!(r.is_ok(), "{r:?}");
905        assert_eq!(probe.call_count(), 2);
906    }
907
908    #[tokio::test]
909    async fn test_schema_fallback_exhausted() {
910        let backend = Arc::new(MockBackend::new(
911            "mock",
912            vec![MockBehavior::Success {
913                output: fallback_output("still no tool"),
914                tokens: TokenUsage::default(),
915                delay: Duration::ZERO,
916            }],
917        ));
918        let probe = backend.clone();
919        let sched = sched_with(backend, fast_config(4, 1000));
920        let run_id = Uuid::now_v7();
921        let _rx = sched.init_run(run_id, 64);
922
923        let task = mk_task_with_schema("respond");
924        let r = sched.run_agent(run_id, task, None).await;
925        assert!(
926            matches!(r, Err(SchedulerError::SchemaValidation(_))),
927            "{r:?}"
928        );
929        assert_eq!(probe.call_count(), 2);
930    }
931
932    #[tokio::test]
933    async fn test_cancel_run() {
934        let backend = Arc::new(MockBackend::new("mock", vec![MockBehavior::Hang]));
935        let sched = sched_with(backend, fast_config(8, 1000));
936        let run_id = Uuid::now_v7();
937        let _rx = sched.init_run(run_id, 64);
938
939        let s2 = sched.clone();
940        let handle = tokio::spawn(async move {
941            let tasks: Vec<_> = (0..3).map(|i| (mk_task(&format!("h{i}")), None)).collect();
942            s2.run_parallel(run_id, tasks).await
943        });
944        tokio::time::sleep(Duration::from_millis(20)).await;
945        sched.cancel_run(run_id);
946
947        let results = handle.await.unwrap();
948        assert_eq!(results.len(), 3);
949        assert!(results
950            .iter()
951            .all(|r| matches!(r, Err(SchedulerError::RunCancelled))));
952    }
953
954    #[tokio::test]
955    async fn test_cancel_agent() {
956        let backend = Arc::new(MockBackend::new("mock", vec![MockBehavior::Hang]));
957        let sched = sched_with(backend, fast_config(8, 1000));
958        let run_id = Uuid::now_v7();
959        let _rx = sched.init_run(run_id, 64);
960
961        let task = mk_task("hang");
962        let agent_id = task.agent_id;
963        let s2 = sched.clone();
964        let handle = tokio::spawn(async move { s2.run_agent(run_id, task, None).await });
965        tokio::time::sleep(Duration::from_millis(20)).await;
966        sched.cancel_agent(run_id, agent_id);
967
968        let r = handle.await.unwrap();
969        assert!(matches!(r, Err(SchedulerError::AgentCancelled)), "{r:?}");
970    }
971
972    #[tokio::test]
973    async fn test_parallel_partial_failure() {
974        let backend = Arc::new(MockBackend::new(
975            "mock",
976            vec![
977                MockBehavior::Success {
978                    output: serde_json::Value::Null,
979                    tokens: TokenUsage::default(),
980                    delay: Duration::ZERO,
981                },
982                MockBehavior::fail(FailKind::Protocol),
983                MockBehavior::Success {
984                    output: serde_json::Value::Null,
985                    tokens: TokenUsage::default(),
986                    delay: Duration::ZERO,
987                },
988            ],
989        ));
990        let sched = sched_with(backend, fast_config(1, 1000)); // serialize for deterministic behavior order
991        let run_id = Uuid::now_v7();
992        let _rx = sched.init_run(run_id, 64);
993
994        let tasks: Vec<_> = (0..3).map(|i| (mk_task(&format!("p{i}")), None)).collect();
995        let results = sched.run_parallel(run_id, tasks).await;
996
997        assert_eq!(results.len(), 3);
998        assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 2);
999        assert_eq!(results.iter().filter(|r| r.is_err()).count(), 1);
1000    }
1001
1002    #[tokio::test]
1003    async fn test_event_sequence() {
1004        let backend = Arc::new(MockBackend::new(
1005            "mock",
1006            vec![MockBehavior::Success {
1007                output: serde_json::Value::Null,
1008                tokens: TokenUsage {
1009                    input: 10,
1010                    output: 5,
1011                    ..Default::default()
1012                },
1013                delay: Duration::ZERO,
1014            }],
1015        ));
1016        let sched = sched_with(backend, fast_config(4, 1000));
1017        let run_id = Uuid::now_v7();
1018        let mut rx = sched.init_run(run_id, 64);
1019
1020        let r = sched.run_agent(run_id, mk_task("x"), None).await;
1021        assert!(r.is_ok());
1022
1023        let e1 = rx.recv().await.unwrap();
1024        assert!(matches!(e1, AgentEvent::AgentStarted { .. }), "{e1:?}");
1025        let e2 = rx.recv().await.unwrap();
1026        match e2 {
1027            AgentEvent::AgentDone { status, tokens, .. } => {
1028                assert_eq!(status, AgentStatus::Ok);
1029                assert_eq!(tokens.input, 10);
1030            }
1031            other => panic!("expected AgentDone, got {other:?}"),
1032        }
1033    }
1034}