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