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