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