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_text = match &result.output {
260                            serde_json::Value::String(s) => Some(s.clone()),
261                            obj if obj.get("_agent_fallback_text").is_some() => obj
262                                .get("text")
263                                .and_then(|v| v.as_str())
264                                .map(|s| s.to_string()),
265                            _ => None,
266                        };
267                        let validation_err = if fallback_text.is_some() {
268                            Some(
269                                "agent returned text instead of calling workflow_validate_schema tool"
270                                    .to_string(),
271                            )
272                        } else {
273                            validate_output(&result.output, schema)
274                                .err()
275                                .map(|e| e.to_string())
276                        };
277
278                        if let Some(error) = validation_err {
279                            schema_retry_count += 1;
280                            if schema_retry_count > self.config.retry.schema_retry_max {
281                                tracing::error!(
282                                    error = %error,
283                                    attempts = schema_retry_count,
284                                    "agent output failed schema validation, retries exhausted"
285                                );
286                                break Err(SchedulerError::SchemaValidation(error));
287                            }
288                            let _ = events.send(AgentEvent::SchemaRetry {
289                                run_id,
290                                agent_id: task.agent_id,
291                                attempt: schema_retry_count,
292                                max: self.config.retry.schema_retry_max,
293                            });
294                            tracing::warn!(
295                                error = %error,
296                                attempt = schema_retry_count,
297                                "schema validation failed, retrying with feedback"
298                            );
299                            let schema_json =
300                                serde_json::to_string_pretty(schema).unwrap_or_default();
301                            let last_output = fallback_text
302                                .clone()
303                                .unwrap_or_else(|| {
304                                    serde_json::to_string_pretty(&result.output)
305                                        .unwrap_or_default()
306                                });
307                            task.prompt = if fallback_text.is_some() {
308                                format!(
309                                    "{original_prompt}\n\n\
310                                     ---\n\
311                                     You returned your result as plain text instead of calling the `workflow_validate_schema` tool.\n\
312                                     You MUST call the `workflow_validate_schema` tool to submit your result.\n\
313                                     Do NOT return the result as a text message.\n\
314                                     \n\
315                                     Your text output was:\n\
316                                     ```\n{last_output}\n```\n\
317                                     \n\
318                                     Required JSON Schema:\n\
319                                     ```json\n{schema}\n```",
320                                    original_prompt = original_prompt,
321                                    last_output = last_output,
322                                    schema = schema_json,
323                                )
324                            } else {
325                                format!(
326                                    "{original_prompt}\n\n\
327                                     ---\n\
328                                     Your previous response did not match the required schema.\n\
329                                     Error: {error}\n\
330                                     \n\
331                                     Your output was:\n\
332                                     ```json\n{last_output}\n```\n\
333                                     \n\
334                                     Required JSON Schema:\n\
335                                     ```json\n{schema}\n```\n\
336                                     \n\
337                                     Call the `workflow_validate_schema` tool with a JSON object that\n\
338                                     matches this schema exactly. Include ALL required fields.",
339                                    original_prompt = original_prompt,
340                                    error = error,
341                                    last_output = last_output,
342                                    schema = schema_json,
343                                )
344                            };
345                            continue;
346                        }
347                    }
348                    break Ok(result);
349                }
350                Err(e) => {
351                    if agent_token.is_cancelled() || matches!(e, BackendError::Cancelled) {
352                        tracing::debug!("agent cancelled");
353                        break Err(cancel_kind(&run_cancel));
354                    }
355                    if !e.is_retryable() {
356                        tracing::error!(error = %e, "non-retryable backend error");
357                        break Err(SchedulerError::NonRetryable(e));
358                    }
359                    attempt += 1;
360                    if attempt > self.config.retry.max_attempts {
361                        tracing::error!(attempts = attempt, error = %e, "agent exhausted retries");
362                        break Err(SchedulerError::Exhausted {
363                            attempts: attempt,
364                            source: e,
365                        });
366                    }
367                    let backoff = self.config.retry.backoff(attempt);
368                    tracing::warn!(
369                        attempt, backoff_ms = backoff.as_millis() as u64, error = %e,
370                        "retryable backend error; retrying"
371                    );
372                    tokio::select! {
373                        _ = tokio::time::sleep(backoff) => {}
374                        _ = agent_token.cancelled() => break Err(cancel_kind(&run_cancel)),
375                    }
376                }
377            }
378        };
379
380        let elapsed_ms = start.elapsed().as_millis() as u64;
381        let (status, tokens) = match &outcome {
382            Ok(r) => (r.status.clone(), r.tokens_used),
383            Err(SchedulerError::AgentCancelled) | Err(SchedulerError::RunCancelled) => {
384                (AgentStatus::Cancelled, TokenUsage::default())
385            }
386            Err(_) => (AgentStatus::Error, TokenUsage::default()),
387        };
388        let _ = events.send(AgentEvent::AgentDone {
389            run_id,
390            agent_id: task.agent_id,
391            status: status.clone(),
392            tokens,
393            elapsed_ms,
394            name: task.name.clone(),
395            agent_seq: task.agent_seq,
396            output: match &outcome {
397                Ok(r) => r.output.clone(),
398                Err(_) => serde_json::Value::Null,
399            },
400            findings: match &outcome {
401                Ok(r) => r.findings.clone(),
402                Err(_) => Vec::new(),
403            },
404            prompt: task.prompt.clone(),
405            retry_count: attempt,
406            ts: Utc::now(),
407        });
408        tracing::info!(?status, elapsed_ms, "agent finished");
409
410        // Invoke journal callback if configured (M1 transparent persistence).
411        if let Some(ref cb) = self.journal_callback {
412            let output = match &outcome {
413                Ok(r) => r.output.clone(),
414                Err(_) => serde_json::Value::Null,
415            };
416            let agent_status = status.clone();
417            let tokens_used = tokens;
418            let agent_id = task.agent_id;
419            let phase_id = task.phase_id;
420            cb.on_agent_done(agent_id, phase_id, agent_status, output, tokens_used)
421                .await;
422        }
423
424        drop(permit);
425        self.cleanup_agent(run_id, task.agent_id);
426        outcome
427    }
428
429    /// Run a batch of tasks concurrently (the `parallel()` primitive). Bounded
430    /// by the same global semaphore; does not short-circuit on failure — results
431    /// preserve input order.
432    pub async fn run_parallel(
433        &self,
434        run_id: RunId,
435        tasks: Vec<(AgentTask, Option<String>)>,
436    ) -> Vec<Result<AgentResult, SchedulerError>> {
437        let futs = tasks.into_iter().map(|(task, backend)| async move {
438            self.run_agent(run_id, task, backend.as_deref()).await
439        });
440        futures::future::join_all(futs).await
441    }
442
443    /// Cancel one agent (fires its token; the backend observes `ctx.cancel`).
444    pub fn cancel_agent(&self, run_id: RunId, agent_id: AgentId) {
445        if let Some(rs) = self.runs.get(&run_id) {
446            if let Some(tok) = rs.agent_cancels.get(&agent_id) {
447                tok.cancel();
448            }
449        }
450    }
451
452    /// Cancel the whole run (all child agent tokens fire).
453    pub fn cancel_run(&self, run_id: RunId) {
454        if let Some(rs) = self.runs.get(&run_id) {
455            rs.run_cancel.cancel();
456        }
457    }
458
459    /// Quota consumed by a run, if initialised.
460    pub fn quota_used(&self, run_id: RunId) -> Option<u32> {
461        self.runs
462            .get(&run_id)
463            .map(|rs| rs.quota_used.load(Ordering::Relaxed))
464    }
465
466    fn cleanup_agent(&self, run_id: RunId, agent_id: AgentId) {
467        if let Some(rs) = self.runs.get(&run_id) {
468            rs.agent_cancels.remove(&agent_id);
469        }
470    }
471}
472
473fn cancel_kind(run_cancel: &CancellationToken) -> SchedulerError {
474    if run_cancel.is_cancelled() {
475        SchedulerError::RunCancelled
476    } else {
477        SchedulerError::AgentCancelled
478    }
479}
480
481fn preview(s: &str) -> String {
482    s.chars().take(60).collect()
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488    use crate::mock_backend::{FailKind, MockBackend, MockBehavior};
489    use std::path::PathBuf;
490    use std::sync::atomic::AtomicUsize;
491    use std::time::Duration;
492    use uuid::Uuid;
493
494    fn fast_config(max_concurrency: usize, quota: u32) -> SchedulerConfig {
495        SchedulerConfig {
496            max_concurrency,
497            quota_per_run: quota,
498            retry: RetryPolicy {
499                max_attempts: 2,
500                initial_backoff: Duration::from_millis(1),
501                backoff_multiplier: 2.0,
502                max_backoff: Duration::from_millis(5),
503                schema_retry_max: 1,
504            },
505        }
506    }
507
508    fn mk_task(prompt: &str) -> AgentTask {
509        AgentTask {
510            agent_id: Uuid::now_v7(),
511            phase_id: 0,
512            prompt: prompt.to_string(),
513            model: None,
514            allowlist: None,
515            workdir: PathBuf::from("."),
516            mcp_endpoint: None,
517            timeout: None,
518            output_schema: None,
519            workdir_override: None,
520            description: None,
521            role: None,
522            name: None,
523            agent_seq: 0,
524            thread_id: None,
525        }
526    }
527
528    fn mk_task_with_schema(prompt: &str) -> AgentTask {
529        let mut task = mk_task(prompt);
530        task.output_schema = Some(serde_json::json!({
531            "type": "object",
532            "properties": {
533                "answer": { "type": "string" }
534            },
535            "required": ["answer"]
536        }));
537        task
538    }
539
540    fn fallback_output(text: &str) -> serde_json::Value {
541        serde_json::json!({
542            "_agent_fallback_text": true,
543            "text": text,
544        })
545    }
546
547    fn ok_result(id: AgentId) -> AgentResult {
548        AgentResult {
549            agent_id: id,
550            status: AgentStatus::Ok,
551            output: serde_json::Value::Null,
552            findings: vec![],
553            tokens_used: TokenUsage::default(),
554            artifacts: vec![],
555            logs: LogRef::default(),
556            thread_id: None,
557        }
558    }
559
560    fn sched_with(backend: Arc<dyn AgentBackend>, cfg: SchedulerConfig) -> Arc<Scheduler> {
561        Scheduler::new(cfg, BackendRegistry::new().with(backend), None)
562    }
563
564    // A backend that records peak concurrency.
565    struct ProbeBackend {
566        cur: Arc<AtomicUsize>,
567        peak: Arc<AtomicUsize>,
568        delay: Duration,
569    }
570
571    #[async_trait::async_trait]
572    impl AgentBackend for ProbeBackend {
573        fn id(&self) -> &'static str {
574            "probe"
575        }
576        fn capabilities(&self) -> AgentCapabilities {
577            AgentCapabilities::default()
578        }
579        fn as_any(&self) -> &dyn std::any::Any {
580            self
581        }
582        async fn run(
583            &self,
584            task: AgentTask,
585            _ctx: RunContext,
586        ) -> Result<AgentResult, BackendError> {
587            let c = self.cur.fetch_add(1, Ordering::SeqCst) + 1;
588            self.peak.fetch_max(c, Ordering::SeqCst);
589            tokio::time::sleep(self.delay).await;
590            self.cur.fetch_sub(1, Ordering::SeqCst);
591            Ok(ok_result(task.agent_id))
592        }
593    }
594
595    #[tokio::test]
596    async fn test_default_config_concurrency() {
597        let c = SchedulerConfig::default().max_concurrency;
598        assert_eq!(c, 1);
599    }
600
601    /// A backend whose `id()` is parameterised and which records that id in the
602    /// agent result output, so per-task routing can be observed.
603    struct IdBackend {
604        id: &'static str,
605    }
606
607    #[async_trait::async_trait]
608    impl AgentBackend for IdBackend {
609        fn id(&self) -> &'static str {
610            self.id
611        }
612        fn capabilities(&self) -> AgentCapabilities {
613            AgentCapabilities::default()
614        }
615        fn as_any(&self) -> &dyn std::any::Any {
616            self
617        }
618        async fn run(&self, task: AgentTask, _ctx: RunContext) -> Result<AgentResult, BackendError> {
619            Ok(AgentResult {
620                agent_id: task.agent_id,
621                status: AgentStatus::Ok,
622                output: serde_json::Value::String(self.id.to_string()),
623                findings: vec![],
624                tokens_used: TokenUsage::default(),
625                artifacts: vec![],
626                logs: LogRef::default(),
627                thread_id: None,
628            })
629        }
630    }
631
632    #[tokio::test]
633    #[serial_test::serial]
634    async fn per_task_backend_routes_to_named_backend() {
635        // No captured current_backend -> the omitted-backend case must fall
636        // back to the registry default (alpha, first-registered).
637        crate::contract::clear_current_backend();
638        let a = Arc::new(IdBackend { id: "alpha" }) as Arc<dyn AgentBackend>;
639        let b = Arc::new(IdBackend { id: "beta" }) as Arc<dyn AgentBackend>;
640        // alpha is the default (first-registered); beta is selectable.
641        let sched = Arc::new(Scheduler::new(
642            fast_config(4, 1000),
643            BackendRegistry::new().with(a).with(b),
644            None,
645        ));
646        let run_id = Uuid::now_v7();
647        let _rx = sched.init_run(run_id, 256);
648
649        // Explicit backend id -> routes to that backend.
650        let r = sched
651            .run_agent(run_id, mk_task("t1"), Some("beta"))
652            .await
653            .unwrap();
654        assert_eq!(r.output, serde_json::Value::String("beta".to_string()));
655
656        // Omitted backend, no current_backend captured -> registry default (alpha).
657        let r = sched.run_agent(run_id, mk_task("t2"), None).await.unwrap();
658        assert_eq!(r.output, serde_json::Value::String("alpha".to_string()));
659        crate::contract::clear_current_backend();
660    }
661
662    #[tokio::test]
663    #[serial_test::serial]
664    async fn per_task_backend_follows_current_backend() {
665        // When the ACP handshake has captured a current backend, an omitted
666        // `backend` routes to it (via its registry id), even if it is not the
667        // registry default.
668        crate::contract::clear_current_backend();
669        let a = Arc::new(IdBackend { id: "alpha" }) as Arc<dyn AgentBackend>;
670        let b = Arc::new(IdBackend { id: "beta" }) as Arc<dyn AgentBackend>;
671        let sched = Arc::new(Scheduler::new(
672            fast_config(4, 1000),
673            BackendRegistry::new().with(a).with(b),
674            None,
675        ));
676        let run_id = Uuid::now_v7();
677        let _rx = sched.init_run(run_id, 256);
678
679        // Simulate the ACP handshake capturing "beta" as the current backend.
680        crate::contract::set_current_backend(crate::contract::CurrentBackend {
681            id: "beta".to_string(),
682            name: "beta".to_string(),
683            version: "0".to_string(),
684            title: None,
685            client: crate::contract::ClientIdentity {
686                name: "luft".to_string(),
687                version: "test".to_string(),
688                title: None,
689            },
690        });
691
692        // Omitted backend -> routes to current_backend ("beta"), not the
693        // registry default ("alpha").
694        let r = sched.run_agent(run_id, mk_task("t"), None).await.unwrap();
695        assert_eq!(r.output, serde_json::Value::String("beta".to_string()));
696        crate::contract::clear_current_backend();
697    }
698
699    #[tokio::test]
700    #[serial_test::serial]
701    async fn per_task_backend_falls_back_when_current_unregistered() {
702        // current_backend captured but its id is not registered -> fall back
703        // to the registry default instead of erroring.
704        crate::contract::clear_current_backend();
705        let a = Arc::new(IdBackend { id: "alpha" }) as Arc<dyn AgentBackend>;
706        let sched = Arc::new(Scheduler::new(
707            fast_config(4, 1000),
708            BackendRegistry::new().with(a),
709            None,
710        ));
711        let run_id = Uuid::now_v7();
712        let _rx = sched.init_run(run_id, 256);
713
714        crate::contract::set_current_backend(crate::contract::CurrentBackend {
715            id: "gone".to_string(),
716            name: "gone".to_string(),
717            version: "0".to_string(),
718            title: None,
719            client: crate::contract::ClientIdentity {
720                name: "luft".to_string(),
721                version: "test".to_string(),
722                title: None,
723            },
724        });
725        let r = sched.run_agent(run_id, mk_task("t"), None).await.unwrap();
726        assert_eq!(r.output, serde_json::Value::String("alpha".to_string()));
727        crate::contract::clear_current_backend();
728    }
729
730    #[tokio::test]
731    async fn per_task_backend_unknown_id_errors() {
732        let a = Arc::new(IdBackend { id: "alpha" }) as Arc<dyn AgentBackend>;
733        let sched = Arc::new(Scheduler::new(
734            fast_config(4, 1000),
735            BackendRegistry::new().with(a),
736            None,
737        ));
738        let run_id = Uuid::now_v7();
739        let _rx = sched.init_run(run_id, 256);
740        assert!(sched
741            .run_agent(run_id, mk_task("t"), Some("nope"))
742            .await
743            .is_err());
744    }
745
746    #[tokio::test]
747    async fn test_concurrency_limit() {
748        let cur = Arc::new(AtomicUsize::new(0));
749        let peak = Arc::new(AtomicUsize::new(0));
750        let backend = Arc::new(ProbeBackend {
751            cur: cur.clone(),
752            peak: peak.clone(),
753            delay: Duration::from_millis(40),
754        });
755        let sched = sched_with(backend, fast_config(2, 1000));
756        let run_id = Uuid::now_v7();
757        let _rx = sched.init_run(run_id, 256);
758
759        let tasks: Vec<_> = (0..6).map(|i| (mk_task(&format!("t{i}")), None)).collect();
760        let results = sched.run_parallel(run_id, tasks).await;
761
762        assert!(results.iter().all(|r| r.is_ok()));
763        assert!(
764            peak.load(Ordering::SeqCst) <= 2,
765            "peak {}",
766            peak.load(Ordering::SeqCst)
767        );
768    }
769
770    #[tokio::test]
771    async fn test_quota_exceeded() {
772        let backend = Arc::new(MockBackend::new(
773            "mock",
774            vec![MockBehavior::Success {
775                output: serde_json::Value::Null,
776                tokens: TokenUsage::default(),
777                delay: Duration::from_millis(5),
778            }],
779        ));
780        let sched = sched_with(backend, fast_config(8, 3));
781        let run_id = Uuid::now_v7();
782        let _rx = sched.init_run(run_id, 256);
783
784        let tasks: Vec<_> = (0..4).map(|i| (mk_task(&format!("t{i}")), None)).collect();
785        let results = sched.run_parallel(run_id, tasks).await;
786
787        let ok = results.iter().filter(|r| r.is_ok()).count();
788        let quota_err = results
789            .iter()
790            .filter(|r| matches!(r, Err(SchedulerError::QuotaExceeded { .. })))
791            .count();
792        assert_eq!(ok, 3);
793        assert_eq!(quota_err, 1);
794    }
795
796    #[tokio::test]
797    async fn test_retry_on_retryable_error() {
798        let backend = Arc::new(MockBackend::new(
799            "mock",
800            vec![
801                MockBehavior::fail(FailKind::Spawn),
802                MockBehavior::fail(FailKind::Spawn),
803                MockBehavior::Success {
804                    output: serde_json::Value::Null,
805                    tokens: TokenUsage::default(),
806                    delay: Duration::ZERO,
807                },
808            ],
809        ));
810        let probe = backend.clone();
811        let sched = sched_with(backend, fast_config(4, 1000));
812        let run_id = Uuid::now_v7();
813        let _rx = sched.init_run(run_id, 64);
814
815        let r = sched.run_agent(run_id, mk_task("x"), None).await;
816        assert!(r.is_ok(), "{r:?}");
817        assert_eq!(probe.call_count(), 3);
818    }
819
820    #[tokio::test]
821    async fn test_no_retry_on_non_retryable() {
822        let backend = Arc::new(MockBackend::new(
823            "mock",
824            vec![MockBehavior::fail(FailKind::Protocol)],
825        ));
826        let probe = backend.clone();
827        let sched = sched_with(backend, fast_config(4, 1000));
828        let run_id = Uuid::now_v7();
829        let _rx = sched.init_run(run_id, 64);
830
831        let r = sched.run_agent(run_id, mk_task("x"), None).await;
832        assert!(matches!(r, Err(SchedulerError::NonRetryable(_))), "{r:?}");
833        assert_eq!(probe.call_count(), 1);
834    }
835
836    #[tokio::test]
837    async fn test_retry_exhausted() {
838        let backend = Arc::new(MockBackend::new(
839            "mock",
840            vec![MockBehavior::fail(FailKind::Spawn)],
841        ));
842        let probe = backend.clone();
843        let sched = sched_with(backend, fast_config(4, 1000));
844        let run_id = Uuid::now_v7();
845        let _rx = sched.init_run(run_id, 64);
846
847        let r = sched.run_agent(run_id, mk_task("x"), None).await;
848        assert!(
849            matches!(r, Err(SchedulerError::Exhausted { attempts: 3, .. })),
850            "{r:?}"
851        );
852        assert_eq!(probe.call_count(), 3);
853    }
854
855    #[tokio::test]
856    async fn test_schema_fallback_then_succeeds() {
857        let backend = Arc::new(MockBackend::new(
858            "mock",
859            vec![
860                MockBehavior::Success {
861                    output: fallback_output("i forgot the tool"),
862                    tokens: TokenUsage::default(),
863                    delay: Duration::ZERO,
864                },
865                MockBehavior::Success {
866                    output: serde_json::json!({"answer": "ok"}),
867                    tokens: TokenUsage::default(),
868                    delay: Duration::ZERO,
869                },
870            ],
871        ));
872        let probe = backend.clone();
873        let sched = sched_with(backend, fast_config(4, 1000));
874        let run_id = Uuid::now_v7();
875        let mut rx = sched.init_run(run_id, 64);
876
877        let task = mk_task_with_schema("respond");
878        let r = sched.run_agent(run_id, task, None).await;
879        assert!(r.is_ok(), "{r:?}");
880        assert_eq!(probe.call_count(), 2);
881
882        let mut prompt_with_feedback = None;
883        while let Ok(event) = rx.try_recv() {
884            if let AgentEvent::AgentDone { prompt, .. } = event {
885                prompt_with_feedback = Some(prompt);
886            }
887        }
888        let prompt = prompt_with_feedback.expect("AgentDone event with prompt");
889        assert!(prompt.contains("workflow_validate_schema"));
890        assert!(prompt.contains("Required JSON Schema"));
891    }
892
893    #[tokio::test]
894    async fn test_schema_mismatch_then_succeeds() {
895        let backend = Arc::new(MockBackend::new(
896            "mock",
897            vec![
898                MockBehavior::Success {
899                    output: serde_json::json!({"wrong": "field"}),
900                    tokens: TokenUsage::default(),
901                    delay: Duration::ZERO,
902                },
903                MockBehavior::Success {
904                    output: serde_json::json!({"answer": "ok"}),
905                    tokens: TokenUsage::default(),
906                    delay: Duration::ZERO,
907                },
908            ],
909        ));
910        let probe = backend.clone();
911        let sched = sched_with(backend, fast_config(4, 1000));
912        let run_id = Uuid::now_v7();
913        let _rx = sched.init_run(run_id, 64);
914
915        let task = mk_task_with_schema("respond");
916        let r = sched.run_agent(run_id, task, None).await;
917        assert!(r.is_ok(), "{r:?}");
918        assert_eq!(probe.call_count(), 2);
919    }
920
921    #[tokio::test]
922    async fn test_schema_fallback_exhausted() {
923        let backend = Arc::new(MockBackend::new(
924            "mock",
925            vec![MockBehavior::Success {
926                output: fallback_output("still no tool"),
927                tokens: TokenUsage::default(),
928                delay: Duration::ZERO,
929            }],
930        ));
931        let probe = backend.clone();
932        let sched = sched_with(backend, fast_config(4, 1000));
933        let run_id = Uuid::now_v7();
934        let _rx = sched.init_run(run_id, 64);
935
936        let task = mk_task_with_schema("respond");
937        let r = sched.run_agent(run_id, task, None).await;
938        assert!(
939            matches!(r, Err(SchedulerError::SchemaValidation(_))),
940            "{r:?}"
941        );
942        assert_eq!(probe.call_count(), 2);
943    }
944
945    #[tokio::test]
946    async fn test_cancel_run() {
947        let backend = Arc::new(MockBackend::new("mock", vec![MockBehavior::Hang]));
948        let sched = sched_with(backend, fast_config(8, 1000));
949        let run_id = Uuid::now_v7();
950        let _rx = sched.init_run(run_id, 64);
951
952        let s2 = sched.clone();
953        let handle = tokio::spawn(async move {
954            let tasks: Vec<_> = (0..3).map(|i| (mk_task(&format!("h{i}")), None)).collect();
955            s2.run_parallel(run_id, tasks).await
956        });
957        tokio::time::sleep(Duration::from_millis(20)).await;
958        sched.cancel_run(run_id);
959
960        let results = handle.await.unwrap();
961        assert_eq!(results.len(), 3);
962        assert!(results
963            .iter()
964            .all(|r| matches!(r, Err(SchedulerError::RunCancelled))));
965    }
966
967    #[tokio::test]
968    async fn test_cancel_agent() {
969        let backend = Arc::new(MockBackend::new("mock", vec![MockBehavior::Hang]));
970        let sched = sched_with(backend, fast_config(8, 1000));
971        let run_id = Uuid::now_v7();
972        let _rx = sched.init_run(run_id, 64);
973
974        let task = mk_task("hang");
975        let agent_id = task.agent_id;
976        let s2 = sched.clone();
977        let handle = tokio::spawn(async move { s2.run_agent(run_id, task, None).await });
978        tokio::time::sleep(Duration::from_millis(20)).await;
979        sched.cancel_agent(run_id, agent_id);
980
981        let r = handle.await.unwrap();
982        assert!(matches!(r, Err(SchedulerError::AgentCancelled)), "{r:?}");
983    }
984
985    #[tokio::test]
986    async fn test_parallel_partial_failure() {
987        let backend = Arc::new(MockBackend::new(
988            "mock",
989            vec![
990                MockBehavior::Success {
991                    output: serde_json::Value::Null,
992                    tokens: TokenUsage::default(),
993                    delay: Duration::ZERO,
994                },
995                MockBehavior::fail(FailKind::Protocol),
996                MockBehavior::Success {
997                    output: serde_json::Value::Null,
998                    tokens: TokenUsage::default(),
999                    delay: Duration::ZERO,
1000                },
1001            ],
1002        ));
1003        let sched = sched_with(backend, fast_config(1, 1000)); // serialize for deterministic behavior order
1004        let run_id = Uuid::now_v7();
1005        let _rx = sched.init_run(run_id, 64);
1006
1007        let tasks: Vec<_> = (0..3).map(|i| (mk_task(&format!("p{i}")), None)).collect();
1008        let results = sched.run_parallel(run_id, tasks).await;
1009
1010        assert_eq!(results.len(), 3);
1011        assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 2);
1012        assert_eq!(results.iter().filter(|r| r.is_err()).count(), 1);
1013    }
1014
1015    #[tokio::test]
1016    async fn test_event_sequence() {
1017        let backend = Arc::new(MockBackend::new(
1018            "mock",
1019            vec![MockBehavior::Success {
1020                output: serde_json::Value::Null,
1021                tokens: TokenUsage {
1022                    input: 10,
1023                    output: 5,
1024                    ..Default::default()
1025                },
1026                delay: Duration::ZERO,
1027            }],
1028        ));
1029        let sched = sched_with(backend, fast_config(4, 1000));
1030        let run_id = Uuid::now_v7();
1031        let mut rx = sched.init_run(run_id, 64);
1032
1033        let r = sched.run_agent(run_id, mk_task("x"), None).await;
1034        assert!(r.is_ok());
1035
1036        let e1 = rx.recv().await.unwrap();
1037        assert!(matches!(e1, AgentEvent::AgentStarted { .. }), "{e1:?}");
1038        let e2 = rx.recv().await.unwrap();
1039        match e2 {
1040            AgentEvent::AgentDone { status, tokens, .. } => {
1041                assert_eq!(status, AgentStatus::Ok);
1042                assert_eq!(tokens.input, 10);
1043            }
1044            other => panic!("expected AgentDone, got {other:?}"),
1045        }
1046    }
1047}