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