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