Skip to main content

luft_core/scheduler/
mod.rs

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