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        workdir_override: None,
504            description: None,
505            role: None,
506            name: None,
507            agent_seq: 0,
508        }
509    }
510
511    fn mk_task_with_schema(prompt: &str) -> AgentTask {
512        let mut task = mk_task(prompt);
513        task.output_schema = Some(serde_json::json!({
514            "type": "object",
515            "properties": {
516                "answer": { "type": "string" }
517            },
518            "required": ["answer"]
519        }));
520        task
521    }
522
523    fn fallback_output(text: &str) -> serde_json::Value {
524        serde_json::json!({
525            "_agent_fallback_text": true,
526            "text": text,
527        })
528    }
529
530    fn ok_result(id: AgentId) -> AgentResult {
531        AgentResult {
532            agent_id: id,
533            status: AgentStatus::Ok,
534            output: serde_json::Value::Null,
535            findings: vec![],
536            tokens_used: TokenUsage::default(),
537            artifacts: vec![],
538            logs: LogRef::default(),
539        }
540    }
541
542    fn sched_with(backend: Arc<dyn AgentBackend>, cfg: SchedulerConfig) -> Arc<Scheduler> {
543        Scheduler::new(cfg, BackendRegistry::new().with(backend), None)
544    }
545
546    // A backend that records peak concurrency.
547    struct ProbeBackend {
548        cur: Arc<AtomicUsize>,
549        peak: Arc<AtomicUsize>,
550        delay: Duration,
551    }
552
553    #[async_trait::async_trait]
554    impl AgentBackend for ProbeBackend {
555        fn id(&self) -> &'static str {
556            "probe"
557        }
558        fn capabilities(&self) -> AgentCapabilities {
559            AgentCapabilities::default()
560        }
561        fn as_any(&self) -> &dyn std::any::Any {
562            self
563        }
564        async fn run(
565            &self,
566            task: AgentTask,
567            _ctx: RunContext,
568        ) -> Result<AgentResult, BackendError> {
569            let c = self.cur.fetch_add(1, Ordering::SeqCst) + 1;
570            self.peak.fetch_max(c, Ordering::SeqCst);
571            tokio::time::sleep(self.delay).await;
572            self.cur.fetch_sub(1, Ordering::SeqCst);
573            Ok(ok_result(task.agent_id))
574        }
575    }
576
577    #[tokio::test]
578    async fn test_default_config_concurrency() {
579        let c = SchedulerConfig::default().max_concurrency;
580        assert!((4..=16).contains(&c), "got {c}");
581    }
582
583    #[tokio::test]
584    async fn test_concurrency_limit() {
585        let cur = Arc::new(AtomicUsize::new(0));
586        let peak = Arc::new(AtomicUsize::new(0));
587        let backend = Arc::new(ProbeBackend {
588            cur: cur.clone(),
589            peak: peak.clone(),
590            delay: Duration::from_millis(40),
591        });
592        let sched = sched_with(backend, fast_config(2, 1000));
593        let run_id = Uuid::now_v7();
594        let _rx = sched.init_run(run_id, 256);
595
596        let tasks: Vec<_> = (0..6).map(|i| (mk_task(&format!("t{i}")), None)).collect();
597        let results = sched.run_parallel(run_id, tasks).await;
598
599        assert!(results.iter().all(|r| r.is_ok()));
600        assert!(
601            peak.load(Ordering::SeqCst) <= 2,
602            "peak {}",
603            peak.load(Ordering::SeqCst)
604        );
605    }
606
607    #[tokio::test]
608    async fn test_quota_exceeded() {
609        let backend = Arc::new(MockBackend::new(
610            "mock",
611            vec![MockBehavior::Success {
612                output: serde_json::Value::Null,
613                tokens: TokenUsage::default(),
614                delay: Duration::from_millis(5),
615            }],
616        ));
617        let sched = sched_with(backend, fast_config(8, 3));
618        let run_id = Uuid::now_v7();
619        let _rx = sched.init_run(run_id, 256);
620
621        let tasks: Vec<_> = (0..4).map(|i| (mk_task(&format!("t{i}")), None)).collect();
622        let results = sched.run_parallel(run_id, tasks).await;
623
624        let ok = results.iter().filter(|r| r.is_ok()).count();
625        let quota_err = results
626            .iter()
627            .filter(|r| matches!(r, Err(SchedulerError::QuotaExceeded { .. })))
628            .count();
629        assert_eq!(ok, 3);
630        assert_eq!(quota_err, 1);
631    }
632
633    #[tokio::test]
634    async fn test_retry_on_retryable_error() {
635        let backend = Arc::new(MockBackend::new(
636            "mock",
637            vec![
638                MockBehavior::fail(FailKind::Spawn),
639                MockBehavior::fail(FailKind::Spawn),
640                MockBehavior::Success {
641                    output: serde_json::Value::Null,
642                    tokens: TokenUsage::default(),
643                    delay: Duration::ZERO,
644                },
645            ],
646        ));
647        let probe = backend.clone();
648        let sched = sched_with(backend, fast_config(4, 1000));
649        let run_id = Uuid::now_v7();
650        let _rx = sched.init_run(run_id, 64);
651
652        let r = sched.run_agent(run_id, mk_task("x"), None).await;
653        assert!(r.is_ok(), "{r:?}");
654        assert_eq!(probe.call_count(), 3);
655    }
656
657    #[tokio::test]
658    async fn test_no_retry_on_non_retryable() {
659        let backend = Arc::new(MockBackend::new(
660            "mock",
661            vec![MockBehavior::fail(FailKind::Protocol)],
662        ));
663        let probe = backend.clone();
664        let sched = sched_with(backend, fast_config(4, 1000));
665        let run_id = Uuid::now_v7();
666        let _rx = sched.init_run(run_id, 64);
667
668        let r = sched.run_agent(run_id, mk_task("x"), None).await;
669        assert!(matches!(r, Err(SchedulerError::NonRetryable(_))), "{r:?}");
670        assert_eq!(probe.call_count(), 1);
671    }
672
673    #[tokio::test]
674    async fn test_retry_exhausted() {
675        let backend = Arc::new(MockBackend::new(
676            "mock",
677            vec![MockBehavior::fail(FailKind::Spawn)],
678        ));
679        let probe = backend.clone();
680        let sched = sched_with(backend, fast_config(4, 1000));
681        let run_id = Uuid::now_v7();
682        let _rx = sched.init_run(run_id, 64);
683
684        let r = sched.run_agent(run_id, mk_task("x"), None).await;
685        assert!(
686            matches!(r, Err(SchedulerError::Exhausted { attempts: 3, .. })),
687            "{r:?}"
688        );
689        assert_eq!(probe.call_count(), 3);
690    }
691
692    #[tokio::test]
693    async fn test_schema_fallback_then_succeeds() {
694        let backend = Arc::new(MockBackend::new(
695            "mock",
696            vec![
697                MockBehavior::Success {
698                    output: fallback_output("i forgot the tool"),
699                    tokens: TokenUsage::default(),
700                    delay: Duration::ZERO,
701                },
702                MockBehavior::Success {
703                    output: serde_json::json!({"answer": "ok"}),
704                    tokens: TokenUsage::default(),
705                    delay: Duration::ZERO,
706                },
707            ],
708        ));
709        let probe = backend.clone();
710        let sched = sched_with(backend, fast_config(4, 1000));
711        let run_id = Uuid::now_v7();
712        let mut rx = sched.init_run(run_id, 64);
713
714        let task = mk_task_with_schema("respond");
715        let r = sched.run_agent(run_id, task, None).await;
716        assert!(r.is_ok(), "{r:?}");
717        assert_eq!(probe.call_count(), 2);
718
719        let mut prompt_with_feedback = None;
720        while let Ok(event) = rx.try_recv() {
721            if let AgentEvent::AgentDone { prompt, .. } = event {
722                prompt_with_feedback = Some(prompt);
723            }
724        }
725        let prompt = prompt_with_feedback.expect("AgentDone event with prompt");
726        assert!(prompt.contains("structured_output"));
727        assert!(prompt.contains("Required JSON Schema"));
728    }
729
730    #[tokio::test]
731    async fn test_schema_mismatch_then_succeeds() {
732        let backend = Arc::new(MockBackend::new(
733            "mock",
734            vec![
735                MockBehavior::Success {
736                    output: serde_json::json!({"wrong": "field"}),
737                    tokens: TokenUsage::default(),
738                    delay: Duration::ZERO,
739                },
740                MockBehavior::Success {
741                    output: serde_json::json!({"answer": "ok"}),
742                    tokens: TokenUsage::default(),
743                    delay: Duration::ZERO,
744                },
745            ],
746        ));
747        let probe = backend.clone();
748        let sched = sched_with(backend, fast_config(4, 1000));
749        let run_id = Uuid::now_v7();
750        let _rx = sched.init_run(run_id, 64);
751
752        let task = mk_task_with_schema("respond");
753        let r = sched.run_agent(run_id, task, None).await;
754        assert!(r.is_ok(), "{r:?}");
755        assert_eq!(probe.call_count(), 2);
756    }
757
758    #[tokio::test]
759    async fn test_schema_fallback_exhausted() {
760        let backend = Arc::new(MockBackend::new(
761            "mock",
762            vec![MockBehavior::Success {
763                output: fallback_output("still no tool"),
764                tokens: TokenUsage::default(),
765                delay: Duration::ZERO,
766            }],
767        ));
768        let probe = backend.clone();
769        let sched = sched_with(backend, fast_config(4, 1000));
770        let run_id = Uuid::now_v7();
771        let _rx = sched.init_run(run_id, 64);
772
773        let task = mk_task_with_schema("respond");
774        let r = sched.run_agent(run_id, task, None).await;
775        assert!(
776            matches!(r, Err(SchedulerError::SchemaValidation(_))),
777            "{r:?}"
778        );
779        assert_eq!(probe.call_count(), 2);
780    }
781
782    #[tokio::test]
783    async fn test_cancel_run() {
784        let backend = Arc::new(MockBackend::new("mock", vec![MockBehavior::Hang]));
785        let sched = sched_with(backend, fast_config(8, 1000));
786        let run_id = Uuid::now_v7();
787        let _rx = sched.init_run(run_id, 64);
788
789        let s2 = sched.clone();
790        let handle = tokio::spawn(async move {
791            let tasks: Vec<_> = (0..3).map(|i| (mk_task(&format!("h{i}")), None)).collect();
792            s2.run_parallel(run_id, tasks).await
793        });
794        tokio::time::sleep(Duration::from_millis(20)).await;
795        sched.cancel_run(run_id);
796
797        let results = handle.await.unwrap();
798        assert_eq!(results.len(), 3);
799        assert!(results
800            .iter()
801            .all(|r| matches!(r, Err(SchedulerError::RunCancelled))));
802    }
803
804    #[tokio::test]
805    async fn test_cancel_agent() {
806        let backend = Arc::new(MockBackend::new("mock", vec![MockBehavior::Hang]));
807        let sched = sched_with(backend, fast_config(8, 1000));
808        let run_id = Uuid::now_v7();
809        let _rx = sched.init_run(run_id, 64);
810
811        let task = mk_task("hang");
812        let agent_id = task.agent_id;
813        let s2 = sched.clone();
814        let handle = tokio::spawn(async move { s2.run_agent(run_id, task, None).await });
815        tokio::time::sleep(Duration::from_millis(20)).await;
816        sched.cancel_agent(run_id, agent_id);
817
818        let r = handle.await.unwrap();
819        assert!(matches!(r, Err(SchedulerError::AgentCancelled)), "{r:?}");
820    }
821
822    #[tokio::test]
823    async fn test_parallel_partial_failure() {
824        let backend = Arc::new(MockBackend::new(
825            "mock",
826            vec![
827                MockBehavior::Success {
828                    output: serde_json::Value::Null,
829                    tokens: TokenUsage::default(),
830                    delay: Duration::ZERO,
831                },
832                MockBehavior::fail(FailKind::Protocol),
833                MockBehavior::Success {
834                    output: serde_json::Value::Null,
835                    tokens: TokenUsage::default(),
836                    delay: Duration::ZERO,
837                },
838            ],
839        ));
840        let sched = sched_with(backend, fast_config(1, 1000)); // serialize for deterministic behavior order
841        let run_id = Uuid::now_v7();
842        let _rx = sched.init_run(run_id, 64);
843
844        let tasks: Vec<_> = (0..3).map(|i| (mk_task(&format!("p{i}")), None)).collect();
845        let results = sched.run_parallel(run_id, tasks).await;
846
847        assert_eq!(results.len(), 3);
848        assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 2);
849        assert_eq!(results.iter().filter(|r| r.is_err()).count(), 1);
850    }
851
852    #[tokio::test]
853    async fn test_event_sequence() {
854        let backend = Arc::new(MockBackend::new(
855            "mock",
856            vec![MockBehavior::Success {
857                output: serde_json::Value::Null,
858                tokens: TokenUsage {
859                    input: 10,
860                    output: 5,
861                    ..Default::default()
862                },
863                delay: Duration::ZERO,
864            }],
865        ));
866        let sched = sched_with(backend, fast_config(4, 1000));
867        let run_id = Uuid::now_v7();
868        let mut rx = sched.init_run(run_id, 64);
869
870        let r = sched.run_agent(run_id, mk_task("x"), None).await;
871        assert!(r.is_ok());
872
873        let e1 = rx.recv().await.unwrap();
874        assert!(matches!(e1, AgentEvent::AgentStarted { .. }), "{e1:?}");
875        let e2 = rx.recv().await.unwrap();
876        match e2 {
877            AgentEvent::AgentDone { status, tokens, .. } => {
878                assert_eq!(status, AgentStatus::Ok);
879                assert_eq!(tokens.input, 10);
880            }
881            other => panic!("expected AgentDone, got {other:?}"),
882        }
883    }
884}