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