1mod 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#[async_trait::async_trait]
31pub trait JournalCallback: Send + Sync {
32 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
43struct RunState {
45 quota_used: Arc<AtomicU32>,
46 run_cancel: CancellationToken,
47 events: EventSender,
48 agent_cancels: DashMap<AgentId, CancellationToken>,
50}
51
52pub struct Scheduler {
55 config: SchedulerConfig,
56 semaphore: Arc<Semaphore>,
57 registry: BackendRegistry,
58 runs: DashMap<RunId, RunState>,
59 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 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 pub fn init_run_with(&self, run_id: RunId, events: EventSender) {
102 self.runs.insert(
103 run_id,
104 RunState {
105 quota_used: Arc::new(AtomicU32::new(0)),
106 run_cancel: CancellationToken::new(),
107 events,
108 agent_cancels: DashMap::new(),
109 },
110 );
111 }
112
113 #[tracing::instrument(
120 name = "agent",
121 skip_all,
122 fields(
123 run_id = %run_id,
124 agent_id = %task.agent_id,
125 phase_id = task.phase_id,
126 model = task.model.as_deref().unwrap_or("default"),
127 )
128 )]
129 pub async fn run_agent(
130 &self,
131 run_id: RunId,
132 mut task: AgentTask,
133 backend_id: Option<&str>,
134 ) -> Result<AgentResult, SchedulerError> {
135 let backend = match backend_id {
136 Some(id) => self.registry.get(id)?,
137 None => {
138 let from_current = crate::contract::current_backend()
145 .and_then(|cb| self.registry.get(&cb.id).ok());
146 match from_current {
147 Some(b) => b,
148 None => self.registry.default_backend()?,
149 }
150 }
151 };
152
153 let (quota_used, run_cancel, events) = {
155 let rs = self
156 .runs
157 .get(&run_id)
158 .ok_or(SchedulerError::RunNotFound(run_id))?;
159 (
160 rs.quota_used.clone(),
161 rs.run_cancel.clone(),
162 rs.events.clone(),
163 )
164 };
165
166 let used = quota_used.fetch_add(1, Ordering::Relaxed) + 1;
168 if used > self.config.quota_per_run {
169 tracing::warn!(
170 used,
171 limit = self.config.quota_per_run,
172 "run quota exceeded"
173 );
174 let _ = events.send(AgentEvent::AgentDone {
175 run_id,
176 agent_id: task.agent_id,
177 status: AgentStatus::Error,
178 tokens: TokenUsage::default(),
179 elapsed_ms: 0,
180 name: task.name.clone(),
181 agent_seq: task.agent_seq,
182 output: serde_json::Value::Null,
183 findings: Vec::new(),
184 prompt: task.prompt.clone(),
185 retry_count: 0,
186 ts: Utc::now(),
187 });
188 return Err(SchedulerError::QuotaExceeded {
189 limit: self.config.quota_per_run,
190 used,
191 });
192 }
193
194 let agent_token = run_cancel.child_token();
197 if let Some(rs) = self.runs.get(&run_id) {
198 rs.agent_cancels.insert(task.agent_id, agent_token.clone());
199 }
200
201 let permit = tokio::select! {
203 p = self.semaphore.clone().acquire_owned() => p.expect("semaphore never closed"),
204 _ = agent_token.cancelled() => {
205 let _ = events.send(AgentEvent::AgentDone {
206 run_id,
207 agent_id: task.agent_id,
208 status: AgentStatus::Cancelled,
209 tokens: TokenUsage::default(),
210 elapsed_ms: 0,
211 name: task.name.clone(),
212 agent_seq: task.agent_seq,
213 output: serde_json::Value::Null,
214 findings: Vec::new(),
215 prompt: task.prompt.clone(),
216 retry_count: 0,
217 ts: Utc::now(),
218 });
219 self.cleanup_agent(run_id, task.agent_id);
220 return Err(cancel_kind(&run_cancel));
221 }
222 };
223
224 let _ = events.send(AgentEvent::AgentStarted {
225 run_id,
226 phase_id: task.phase_id,
227 agent_id: task.agent_id,
228 prompt_preview: preview(&task.prompt),
229 model: task.model.clone(),
230 description: task.description.clone(),
231 role: task.role.clone(),
232 name: task.name.clone(),
233 agent_seq: task.agent_seq,
234 ts: Utc::now(),
235 });
236
237 let start = Instant::now();
238 let mut attempt = 0u32;
239 let original_prompt = task.prompt.clone();
240 let mut schema_retry_count = 0u32;
241 let outcome: Result<AgentResult, SchedulerError> = loop {
242 let ctx = RunContext {
243 run_id,
244 cancel: agent_token.clone(),
245 events: events.clone(),
246 };
247 let run_fut = backend.run(task.clone(), ctx);
248 let res = match task.timeout {
249 Some(t) => match tokio::time::timeout(t, run_fut).await {
250 Ok(r) => r,
251 Err(_) => Err(BackendError::Timeout),
252 },
253 None => run_fut.await,
254 };
255
256 match res {
257 Ok(result) => {
258 if let Some(ref schema) = task.output_schema {
259 let fallback_text = match &result.output {
260 serde_json::Value::String(s) => Some(s.clone()),
261 obj if obj.get("_agent_fallback_text").is_some() => obj
262 .get("text")
263 .and_then(|v| v.as_str())
264 .map(|s| s.to_string()),
265 _ => None,
266 };
267 let validation_err = if fallback_text.is_some() {
268 Some(
269 "agent returned text instead of calling workflow_validate_schema tool"
270 .to_string(),
271 )
272 } else {
273 validate_output(&result.output, schema)
274 .err()
275 .map(|e| e.to_string())
276 };
277
278 if let Some(error) = validation_err {
279 schema_retry_count += 1;
280 if schema_retry_count > self.config.retry.schema_retry_max {
281 tracing::error!(
282 error = %error,
283 attempts = schema_retry_count,
284 "agent output failed schema validation, retries exhausted"
285 );
286 break Err(SchedulerError::SchemaValidation(error));
287 }
288 let _ = events.send(AgentEvent::SchemaRetry {
289 run_id,
290 agent_id: task.agent_id,
291 attempt: schema_retry_count,
292 max: self.config.retry.schema_retry_max,
293 });
294 tracing::warn!(
295 error = %error,
296 attempt = schema_retry_count,
297 "schema validation failed, retrying with feedback"
298 );
299 let schema_json =
300 serde_json::to_string_pretty(schema).unwrap_or_default();
301 let last_output = fallback_text
302 .clone()
303 .unwrap_or_else(|| {
304 serde_json::to_string_pretty(&result.output)
305 .unwrap_or_default()
306 });
307 task.prompt = if fallback_text.is_some() {
308 format!(
309 "{original_prompt}\n\n\
310 ---\n\
311 You returned your result as plain text instead of calling the `workflow_validate_schema` tool.\n\
312 You MUST call the `workflow_validate_schema` tool to submit your result.\n\
313 Do NOT return the result as a text message.\n\
314 \n\
315 Your text output was:\n\
316 ```\n{last_output}\n```\n\
317 \n\
318 Required JSON Schema:\n\
319 ```json\n{schema}\n```",
320 original_prompt = original_prompt,
321 last_output = last_output,
322 schema = schema_json,
323 )
324 } else {
325 format!(
326 "{original_prompt}\n\n\
327 ---\n\
328 Your previous response did not match the required schema.\n\
329 Error: {error}\n\
330 \n\
331 Your output was:\n\
332 ```json\n{last_output}\n```\n\
333 \n\
334 Required JSON Schema:\n\
335 ```json\n{schema}\n```\n\
336 \n\
337 Call the `workflow_validate_schema` tool with a JSON object that\n\
338 matches this schema exactly. Include ALL required fields.",
339 original_prompt = original_prompt,
340 error = error,
341 last_output = last_output,
342 schema = schema_json,
343 )
344 };
345 continue;
346 }
347 }
348 break Ok(result);
349 }
350 Err(e) => {
351 if agent_token.is_cancelled() || matches!(e, BackendError::Cancelled) {
352 tracing::debug!("agent cancelled");
353 break Err(cancel_kind(&run_cancel));
354 }
355 if !e.is_retryable() {
356 tracing::error!(error = %e, "non-retryable backend error");
357 break Err(SchedulerError::NonRetryable(e));
358 }
359 attempt += 1;
360 if attempt > self.config.retry.max_attempts {
361 tracing::error!(attempts = attempt, error = %e, "agent exhausted retries");
362 break Err(SchedulerError::Exhausted {
363 attempts: attempt,
364 source: e,
365 });
366 }
367 let backoff = self.config.retry.backoff(attempt);
368 tracing::warn!(
369 attempt, backoff_ms = backoff.as_millis() as u64, error = %e,
370 "retryable backend error; retrying"
371 );
372 tokio::select! {
373 _ = tokio::time::sleep(backoff) => {}
374 _ = agent_token.cancelled() => break Err(cancel_kind(&run_cancel)),
375 }
376 }
377 }
378 };
379
380 let elapsed_ms = start.elapsed().as_millis() as u64;
381 let (status, tokens) = match &outcome {
382 Ok(r) => (r.status.clone(), r.tokens_used),
383 Err(SchedulerError::AgentCancelled) | Err(SchedulerError::RunCancelled) => {
384 (AgentStatus::Cancelled, TokenUsage::default())
385 }
386 Err(_) => (AgentStatus::Error, TokenUsage::default()),
387 };
388 let _ = events.send(AgentEvent::AgentDone {
389 run_id,
390 agent_id: task.agent_id,
391 status: status.clone(),
392 tokens,
393 elapsed_ms,
394 name: task.name.clone(),
395 agent_seq: task.agent_seq,
396 output: match &outcome {
397 Ok(r) => r.output.clone(),
398 Err(_) => serde_json::Value::Null,
399 },
400 findings: match &outcome {
401 Ok(r) => r.findings.clone(),
402 Err(_) => Vec::new(),
403 },
404 prompt: task.prompt.clone(),
405 retry_count: attempt,
406 ts: Utc::now(),
407 });
408 tracing::info!(?status, elapsed_ms, "agent finished");
409
410 if let Some(ref cb) = self.journal_callback {
412 let output = match &outcome {
413 Ok(r) => r.output.clone(),
414 Err(_) => serde_json::Value::Null,
415 };
416 let agent_status = status.clone();
417 let tokens_used = tokens;
418 let agent_id = task.agent_id;
419 let phase_id = task.phase_id;
420 cb.on_agent_done(agent_id, phase_id, agent_status, output, tokens_used)
421 .await;
422 }
423
424 drop(permit);
425 self.cleanup_agent(run_id, task.agent_id);
426 outcome
427 }
428
429 pub async fn run_parallel(
433 &self,
434 run_id: RunId,
435 tasks: Vec<(AgentTask, Option<String>)>,
436 ) -> Vec<Result<AgentResult, SchedulerError>> {
437 let futs = tasks.into_iter().map(|(task, backend)| async move {
438 self.run_agent(run_id, task, backend.as_deref()).await
439 });
440 futures::future::join_all(futs).await
441 }
442
443 pub fn cancel_agent(&self, run_id: RunId, agent_id: AgentId) {
445 if let Some(rs) = self.runs.get(&run_id) {
446 if let Some(tok) = rs.agent_cancels.get(&agent_id) {
447 tok.cancel();
448 }
449 }
450 }
451
452 pub fn cancel_run(&self, run_id: RunId) {
454 if let Some(rs) = self.runs.get(&run_id) {
455 rs.run_cancel.cancel();
456 }
457 }
458
459 pub fn quota_used(&self, run_id: RunId) -> Option<u32> {
461 self.runs
462 .get(&run_id)
463 .map(|rs| rs.quota_used.load(Ordering::Relaxed))
464 }
465
466 fn cleanup_agent(&self, run_id: RunId, agent_id: AgentId) {
467 if let Some(rs) = self.runs.get(&run_id) {
468 rs.agent_cancels.remove(&agent_id);
469 }
470 }
471}
472
473fn cancel_kind(run_cancel: &CancellationToken) -> SchedulerError {
474 if run_cancel.is_cancelled() {
475 SchedulerError::RunCancelled
476 } else {
477 SchedulerError::AgentCancelled
478 }
479}
480
481fn preview(s: &str) -> String {
482 s.chars().take(60).collect()
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488 use crate::mock_backend::{FailKind, MockBackend, MockBehavior};
489 use std::path::PathBuf;
490 use std::sync::atomic::AtomicUsize;
491 use std::time::Duration;
492 use uuid::Uuid;
493
494 fn fast_config(max_concurrency: usize, quota: u32) -> SchedulerConfig {
495 SchedulerConfig {
496 max_concurrency,
497 quota_per_run: quota,
498 retry: RetryPolicy {
499 max_attempts: 2,
500 initial_backoff: Duration::from_millis(1),
501 backoff_multiplier: 2.0,
502 max_backoff: Duration::from_millis(5),
503 schema_retry_max: 1,
504 },
505 }
506 }
507
508 fn mk_task(prompt: &str) -> AgentTask {
509 AgentTask {
510 agent_id: Uuid::now_v7(),
511 phase_id: 0,
512 prompt: prompt.to_string(),
513 model: None,
514 allowlist: None,
515 workdir: PathBuf::from("."),
516 mcp_endpoint: None,
517 timeout: None,
518 output_schema: None,
519 workdir_override: None,
520 description: None,
521 role: None,
522 name: None,
523 agent_seq: 0,
524 thread_id: None,
525 }
526 }
527
528 fn mk_task_with_schema(prompt: &str) -> AgentTask {
529 let mut task = mk_task(prompt);
530 task.output_schema = Some(serde_json::json!({
531 "type": "object",
532 "properties": {
533 "answer": { "type": "string" }
534 },
535 "required": ["answer"]
536 }));
537 task
538 }
539
540 fn fallback_output(text: &str) -> serde_json::Value {
541 serde_json::json!({
542 "_agent_fallback_text": true,
543 "text": text,
544 })
545 }
546
547 fn ok_result(id: AgentId) -> AgentResult {
548 AgentResult {
549 agent_id: id,
550 status: AgentStatus::Ok,
551 output: serde_json::Value::Null,
552 findings: vec![],
553 tokens_used: TokenUsage::default(),
554 artifacts: vec![],
555 logs: LogRef::default(),
556 thread_id: None,
557 }
558 }
559
560 fn sched_with(backend: Arc<dyn AgentBackend>, cfg: SchedulerConfig) -> Arc<Scheduler> {
561 Scheduler::new(cfg, BackendRegistry::new().with(backend), None)
562 }
563
564 struct ProbeBackend {
566 cur: Arc<AtomicUsize>,
567 peak: Arc<AtomicUsize>,
568 delay: Duration,
569 }
570
571 #[async_trait::async_trait]
572 impl AgentBackend for ProbeBackend {
573 fn id(&self) -> &'static str {
574 "probe"
575 }
576 fn capabilities(&self) -> AgentCapabilities {
577 AgentCapabilities::default()
578 }
579 fn as_any(&self) -> &dyn std::any::Any {
580 self
581 }
582 async fn run(
583 &self,
584 task: AgentTask,
585 _ctx: RunContext,
586 ) -> Result<AgentResult, BackendError> {
587 let c = self.cur.fetch_add(1, Ordering::SeqCst) + 1;
588 self.peak.fetch_max(c, Ordering::SeqCst);
589 tokio::time::sleep(self.delay).await;
590 self.cur.fetch_sub(1, Ordering::SeqCst);
591 Ok(ok_result(task.agent_id))
592 }
593 }
594
595 #[tokio::test]
596 async fn test_default_config_concurrency() {
597 let c = SchedulerConfig::default().max_concurrency;
598 assert_eq!(c, 1);
599 }
600
601 struct IdBackend {
604 id: &'static str,
605 }
606
607 #[async_trait::async_trait]
608 impl AgentBackend for IdBackend {
609 fn id(&self) -> &'static str {
610 self.id
611 }
612 fn capabilities(&self) -> AgentCapabilities {
613 AgentCapabilities::default()
614 }
615 fn as_any(&self) -> &dyn std::any::Any {
616 self
617 }
618 async fn run(&self, task: AgentTask, _ctx: RunContext) -> Result<AgentResult, BackendError> {
619 Ok(AgentResult {
620 agent_id: task.agent_id,
621 status: AgentStatus::Ok,
622 output: serde_json::Value::String(self.id.to_string()),
623 findings: vec![],
624 tokens_used: TokenUsage::default(),
625 artifacts: vec![],
626 logs: LogRef::default(),
627 thread_id: None,
628 })
629 }
630 }
631
632 #[tokio::test]
633 #[serial_test::serial]
634 async fn per_task_backend_routes_to_named_backend() {
635 crate::contract::clear_current_backend();
638 let a = Arc::new(IdBackend { id: "alpha" }) as Arc<dyn AgentBackend>;
639 let b = Arc::new(IdBackend { id: "beta" }) as Arc<dyn AgentBackend>;
640 let sched = Arc::new(Scheduler::new(
642 fast_config(4, 1000),
643 BackendRegistry::new().with(a).with(b),
644 None,
645 ));
646 let run_id = Uuid::now_v7();
647 let _rx = sched.init_run(run_id, 256);
648
649 let r = sched
651 .run_agent(run_id, mk_task("t1"), Some("beta"))
652 .await
653 .unwrap();
654 assert_eq!(r.output, serde_json::Value::String("beta".to_string()));
655
656 let r = sched.run_agent(run_id, mk_task("t2"), None).await.unwrap();
658 assert_eq!(r.output, serde_json::Value::String("alpha".to_string()));
659 crate::contract::clear_current_backend();
660 }
661
662 #[tokio::test]
663 #[serial_test::serial]
664 async fn per_task_backend_follows_current_backend() {
665 crate::contract::clear_current_backend();
669 let a = Arc::new(IdBackend { id: "alpha" }) as Arc<dyn AgentBackend>;
670 let b = Arc::new(IdBackend { id: "beta" }) as Arc<dyn AgentBackend>;
671 let sched = Arc::new(Scheduler::new(
672 fast_config(4, 1000),
673 BackendRegistry::new().with(a).with(b),
674 None,
675 ));
676 let run_id = Uuid::now_v7();
677 let _rx = sched.init_run(run_id, 256);
678
679 crate::contract::set_current_backend(crate::contract::CurrentBackend {
681 id: "beta".to_string(),
682 name: "beta".to_string(),
683 version: "0".to_string(),
684 title: None,
685 client: crate::contract::ClientIdentity {
686 name: "luft".to_string(),
687 version: "test".to_string(),
688 title: None,
689 },
690 });
691
692 let r = sched.run_agent(run_id, mk_task("t"), None).await.unwrap();
695 assert_eq!(r.output, serde_json::Value::String("beta".to_string()));
696 crate::contract::clear_current_backend();
697 }
698
699 #[tokio::test]
700 #[serial_test::serial]
701 async fn per_task_backend_falls_back_when_current_unregistered() {
702 crate::contract::clear_current_backend();
705 let a = Arc::new(IdBackend { id: "alpha" }) as Arc<dyn AgentBackend>;
706 let sched = Arc::new(Scheduler::new(
707 fast_config(4, 1000),
708 BackendRegistry::new().with(a),
709 None,
710 ));
711 let run_id = Uuid::now_v7();
712 let _rx = sched.init_run(run_id, 256);
713
714 crate::contract::set_current_backend(crate::contract::CurrentBackend {
715 id: "gone".to_string(),
716 name: "gone".to_string(),
717 version: "0".to_string(),
718 title: None,
719 client: crate::contract::ClientIdentity {
720 name: "luft".to_string(),
721 version: "test".to_string(),
722 title: None,
723 },
724 });
725 let r = sched.run_agent(run_id, mk_task("t"), None).await.unwrap();
726 assert_eq!(r.output, serde_json::Value::String("alpha".to_string()));
727 crate::contract::clear_current_backend();
728 }
729
730 #[tokio::test]
731 async fn per_task_backend_unknown_id_errors() {
732 let a = Arc::new(IdBackend { id: "alpha" }) as Arc<dyn AgentBackend>;
733 let sched = Arc::new(Scheduler::new(
734 fast_config(4, 1000),
735 BackendRegistry::new().with(a),
736 None,
737 ));
738 let run_id = Uuid::now_v7();
739 let _rx = sched.init_run(run_id, 256);
740 assert!(sched
741 .run_agent(run_id, mk_task("t"), Some("nope"))
742 .await
743 .is_err());
744 }
745
746 #[tokio::test]
747 async fn test_concurrency_limit() {
748 let cur = Arc::new(AtomicUsize::new(0));
749 let peak = Arc::new(AtomicUsize::new(0));
750 let backend = Arc::new(ProbeBackend {
751 cur: cur.clone(),
752 peak: peak.clone(),
753 delay: Duration::from_millis(40),
754 });
755 let sched = sched_with(backend, fast_config(2, 1000));
756 let run_id = Uuid::now_v7();
757 let _rx = sched.init_run(run_id, 256);
758
759 let tasks: Vec<_> = (0..6).map(|i| (mk_task(&format!("t{i}")), None)).collect();
760 let results = sched.run_parallel(run_id, tasks).await;
761
762 assert!(results.iter().all(|r| r.is_ok()));
763 assert!(
764 peak.load(Ordering::SeqCst) <= 2,
765 "peak {}",
766 peak.load(Ordering::SeqCst)
767 );
768 }
769
770 #[tokio::test]
771 async fn test_quota_exceeded() {
772 let backend = Arc::new(MockBackend::new(
773 "mock",
774 vec![MockBehavior::Success {
775 output: serde_json::Value::Null,
776 tokens: TokenUsage::default(),
777 delay: Duration::from_millis(5),
778 }],
779 ));
780 let sched = sched_with(backend, fast_config(8, 3));
781 let run_id = Uuid::now_v7();
782 let _rx = sched.init_run(run_id, 256);
783
784 let tasks: Vec<_> = (0..4).map(|i| (mk_task(&format!("t{i}")), None)).collect();
785 let results = sched.run_parallel(run_id, tasks).await;
786
787 let ok = results.iter().filter(|r| r.is_ok()).count();
788 let quota_err = results
789 .iter()
790 .filter(|r| matches!(r, Err(SchedulerError::QuotaExceeded { .. })))
791 .count();
792 assert_eq!(ok, 3);
793 assert_eq!(quota_err, 1);
794 }
795
796 #[tokio::test]
797 async fn test_retry_on_retryable_error() {
798 let backend = Arc::new(MockBackend::new(
799 "mock",
800 vec![
801 MockBehavior::fail(FailKind::Spawn),
802 MockBehavior::fail(FailKind::Spawn),
803 MockBehavior::Success {
804 output: serde_json::Value::Null,
805 tokens: TokenUsage::default(),
806 delay: Duration::ZERO,
807 },
808 ],
809 ));
810 let probe = backend.clone();
811 let sched = sched_with(backend, fast_config(4, 1000));
812 let run_id = Uuid::now_v7();
813 let _rx = sched.init_run(run_id, 64);
814
815 let r = sched.run_agent(run_id, mk_task("x"), None).await;
816 assert!(r.is_ok(), "{r:?}");
817 assert_eq!(probe.call_count(), 3);
818 }
819
820 #[tokio::test]
821 async fn test_no_retry_on_non_retryable() {
822 let backend = Arc::new(MockBackend::new(
823 "mock",
824 vec![MockBehavior::fail(FailKind::Protocol)],
825 ));
826 let probe = backend.clone();
827 let sched = sched_with(backend, fast_config(4, 1000));
828 let run_id = Uuid::now_v7();
829 let _rx = sched.init_run(run_id, 64);
830
831 let r = sched.run_agent(run_id, mk_task("x"), None).await;
832 assert!(matches!(r, Err(SchedulerError::NonRetryable(_))), "{r:?}");
833 assert_eq!(probe.call_count(), 1);
834 }
835
836 #[tokio::test]
837 async fn test_retry_exhausted() {
838 let backend = Arc::new(MockBackend::new(
839 "mock",
840 vec![MockBehavior::fail(FailKind::Spawn)],
841 ));
842 let probe = backend.clone();
843 let sched = sched_with(backend, fast_config(4, 1000));
844 let run_id = Uuid::now_v7();
845 let _rx = sched.init_run(run_id, 64);
846
847 let r = sched.run_agent(run_id, mk_task("x"), None).await;
848 assert!(
849 matches!(r, Err(SchedulerError::Exhausted { attempts: 3, .. })),
850 "{r:?}"
851 );
852 assert_eq!(probe.call_count(), 3);
853 }
854
855 #[tokio::test]
856 async fn test_schema_fallback_then_succeeds() {
857 let backend = Arc::new(MockBackend::new(
858 "mock",
859 vec![
860 MockBehavior::Success {
861 output: fallback_output("i forgot the tool"),
862 tokens: TokenUsage::default(),
863 delay: Duration::ZERO,
864 },
865 MockBehavior::Success {
866 output: serde_json::json!({"answer": "ok"}),
867 tokens: TokenUsage::default(),
868 delay: Duration::ZERO,
869 },
870 ],
871 ));
872 let probe = backend.clone();
873 let sched = sched_with(backend, fast_config(4, 1000));
874 let run_id = Uuid::now_v7();
875 let mut rx = sched.init_run(run_id, 64);
876
877 let task = mk_task_with_schema("respond");
878 let r = sched.run_agent(run_id, task, None).await;
879 assert!(r.is_ok(), "{r:?}");
880 assert_eq!(probe.call_count(), 2);
881
882 let mut prompt_with_feedback = None;
883 while let Ok(event) = rx.try_recv() {
884 if let AgentEvent::AgentDone { prompt, .. } = event {
885 prompt_with_feedback = Some(prompt);
886 }
887 }
888 let prompt = prompt_with_feedback.expect("AgentDone event with prompt");
889 assert!(prompt.contains("workflow_validate_schema"));
890 assert!(prompt.contains("Required JSON Schema"));
891 }
892
893 #[tokio::test]
894 async fn test_schema_mismatch_then_succeeds() {
895 let backend = Arc::new(MockBackend::new(
896 "mock",
897 vec![
898 MockBehavior::Success {
899 output: serde_json::json!({"wrong": "field"}),
900 tokens: TokenUsage::default(),
901 delay: Duration::ZERO,
902 },
903 MockBehavior::Success {
904 output: serde_json::json!({"answer": "ok"}),
905 tokens: TokenUsage::default(),
906 delay: Duration::ZERO,
907 },
908 ],
909 ));
910 let probe = backend.clone();
911 let sched = sched_with(backend, fast_config(4, 1000));
912 let run_id = Uuid::now_v7();
913 let _rx = sched.init_run(run_id, 64);
914
915 let task = mk_task_with_schema("respond");
916 let r = sched.run_agent(run_id, task, None).await;
917 assert!(r.is_ok(), "{r:?}");
918 assert_eq!(probe.call_count(), 2);
919 }
920
921 #[tokio::test]
922 async fn test_schema_fallback_exhausted() {
923 let backend = Arc::new(MockBackend::new(
924 "mock",
925 vec![MockBehavior::Success {
926 output: fallback_output("still no tool"),
927 tokens: TokenUsage::default(),
928 delay: Duration::ZERO,
929 }],
930 ));
931 let probe = backend.clone();
932 let sched = sched_with(backend, fast_config(4, 1000));
933 let run_id = Uuid::now_v7();
934 let _rx = sched.init_run(run_id, 64);
935
936 let task = mk_task_with_schema("respond");
937 let r = sched.run_agent(run_id, task, None).await;
938 assert!(
939 matches!(r, Err(SchedulerError::SchemaValidation(_))),
940 "{r:?}"
941 );
942 assert_eq!(probe.call_count(), 2);
943 }
944
945 #[tokio::test]
946 async fn test_cancel_run() {
947 let backend = Arc::new(MockBackend::new("mock", vec![MockBehavior::Hang]));
948 let sched = sched_with(backend, fast_config(8, 1000));
949 let run_id = Uuid::now_v7();
950 let _rx = sched.init_run(run_id, 64);
951
952 let s2 = sched.clone();
953 let handle = tokio::spawn(async move {
954 let tasks: Vec<_> = (0..3).map(|i| (mk_task(&format!("h{i}")), None)).collect();
955 s2.run_parallel(run_id, tasks).await
956 });
957 tokio::time::sleep(Duration::from_millis(20)).await;
958 sched.cancel_run(run_id);
959
960 let results = handle.await.unwrap();
961 assert_eq!(results.len(), 3);
962 assert!(results
963 .iter()
964 .all(|r| matches!(r, Err(SchedulerError::RunCancelled))));
965 }
966
967 #[tokio::test]
968 async fn test_cancel_agent() {
969 let backend = Arc::new(MockBackend::new("mock", vec![MockBehavior::Hang]));
970 let sched = sched_with(backend, fast_config(8, 1000));
971 let run_id = Uuid::now_v7();
972 let _rx = sched.init_run(run_id, 64);
973
974 let task = mk_task("hang");
975 let agent_id = task.agent_id;
976 let s2 = sched.clone();
977 let handle = tokio::spawn(async move { s2.run_agent(run_id, task, None).await });
978 tokio::time::sleep(Duration::from_millis(20)).await;
979 sched.cancel_agent(run_id, agent_id);
980
981 let r = handle.await.unwrap();
982 assert!(matches!(r, Err(SchedulerError::AgentCancelled)), "{r:?}");
983 }
984
985 #[tokio::test]
986 async fn test_parallel_partial_failure() {
987 let backend = Arc::new(MockBackend::new(
988 "mock",
989 vec![
990 MockBehavior::Success {
991 output: serde_json::Value::Null,
992 tokens: TokenUsage::default(),
993 delay: Duration::ZERO,
994 },
995 MockBehavior::fail(FailKind::Protocol),
996 MockBehavior::Success {
997 output: serde_json::Value::Null,
998 tokens: TokenUsage::default(),
999 delay: Duration::ZERO,
1000 },
1001 ],
1002 ));
1003 let sched = sched_with(backend, fast_config(1, 1000)); let run_id = Uuid::now_v7();
1005 let _rx = sched.init_run(run_id, 64);
1006
1007 let tasks: Vec<_> = (0..3).map(|i| (mk_task(&format!("p{i}")), None)).collect();
1008 let results = sched.run_parallel(run_id, tasks).await;
1009
1010 assert_eq!(results.len(), 3);
1011 assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 2);
1012 assert_eq!(results.iter().filter(|r| r.is_err()).count(), 1);
1013 }
1014
1015 #[tokio::test]
1016 async fn test_event_sequence() {
1017 let backend = Arc::new(MockBackend::new(
1018 "mock",
1019 vec![MockBehavior::Success {
1020 output: serde_json::Value::Null,
1021 tokens: TokenUsage {
1022 input: 10,
1023 output: 5,
1024 ..Default::default()
1025 },
1026 delay: Duration::ZERO,
1027 }],
1028 ));
1029 let sched = sched_with(backend, fast_config(4, 1000));
1030 let run_id = Uuid::now_v7();
1031 let mut rx = sched.init_run(run_id, 64);
1032
1033 let r = sched.run_agent(run_id, mk_task("x"), None).await;
1034 assert!(r.is_ok());
1035
1036 let e1 = rx.recv().await.unwrap();
1037 assert!(matches!(e1, AgentEvent::AgentStarted { .. }), "{e1:?}");
1038 let e2 = rx.recv().await.unwrap();
1039 match e2 {
1040 AgentEvent::AgentDone { status, tokens, .. } => {
1041 assert_eq!(status, AgentStatus::Ok);
1042 assert_eq!(tokens.input, 10);
1043 }
1044 other => panic!("expected AgentDone, got {other:?}"),
1045 }
1046 }
1047}