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