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 task.prompt = if fallback_text.is_some() {
326 format!(
327 "{original_prompt}\n\n\
328 ---\n\
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```",
338 original_prompt = original_prompt,
339 last_output = last_output,
340 schema = schema_json,
341 )
342 } else {
343 format!(
344 "{original_prompt}\n\n\
345 ---\n\
346 Your previous response did not match the required schema.\n\
347 Error: {error}\n\
348 \n\
349 Your output was:\n\
350 ```json\n{last_output}\n```\n\
351 \n\
352 Required JSON Schema:\n\
353 ```json\n{schema}\n```\n\
354 \n\
355 Call the `workflow_validate_schema` tool with a JSON object that\n\
356 matches this schema exactly. Include ALL required fields.",
357 original_prompt = original_prompt,
358 error = error,
359 last_output = last_output,
360 schema = schema_json,
361 )
362 };
363 continue;
364 }
365 }
366 break Ok(result);
367 }
368 Err(e) => {
369 if agent_token.is_cancelled() || matches!(e, BackendError::Cancelled) {
370 tracing::debug!("agent cancelled");
371 break Err(cancel_kind(&run_cancel));
372 }
373 if !e.is_retryable() {
374 tracing::error!(error = %e, "non-retryable backend error");
375 break Err(SchedulerError::NonRetryable(e));
376 }
377 attempt += 1;
378 if attempt > self.config.retry.max_attempts {
379 tracing::error!(attempts = attempt, error = %e, "agent exhausted retries");
380 break Err(SchedulerError::Exhausted {
381 attempts: attempt,
382 source: e,
383 });
384 }
385 let backoff = self.config.retry.backoff(attempt);
386 tracing::warn!(
387 attempt, backoff_ms = backoff.as_millis() as u64, error = %e,
388 "retryable backend error; retrying"
389 );
390 tokio::select! {
391 _ = tokio::time::sleep(backoff) => {}
392 _ = agent_token.cancelled() => break Err(cancel_kind(&run_cancel)),
393 }
394 }
395 }
396 };
397
398 let elapsed_ms = start.elapsed().as_millis() as u64;
399 let (status, tokens) = match &outcome {
400 Ok(r) => (r.status.clone(), r.tokens_used),
401 Err(SchedulerError::AgentCancelled) | Err(SchedulerError::RunCancelled) => {
402 (AgentStatus::Cancelled, TokenUsage::default())
403 }
404 Err(_) => (AgentStatus::Error, TokenUsage::default()),
405 };
406 let _ = events.send(AgentEvent::AgentDone {
407 run_id,
408 agent_id: task.agent_id,
409 status: status.clone(),
410 tokens,
411 elapsed_ms,
412 name: task.name.clone(),
413 agent_seq: task.agent_seq,
414 output: match &outcome {
415 Ok(r) => r.output.clone(),
416 Err(_) => serde_json::Value::Null,
417 },
418 findings: match &outcome {
419 Ok(r) => r.findings.clone(),
420 Err(_) => Vec::new(),
421 },
422 prompt: task.prompt.clone(),
423 retry_count: attempt,
424 ts: Utc::now(),
425 });
426 tracing::info!(?status, elapsed_ms, "agent finished");
427
428 if let Some(ref cb) = self.journal_callback {
430 let output = match &outcome {
431 Ok(r) => r.output.clone(),
432 Err(_) => serde_json::Value::Null,
433 };
434 let agent_status = status.clone();
435 let tokens_used = tokens;
436 let agent_id = task.agent_id;
437 let phase_id = task.phase_id;
438 cb.on_agent_done(agent_id, phase_id, agent_status, output, tokens_used)
439 .await;
440 }
441
442 drop(permit);
443 self.cleanup_agent(run_id, task.agent_id);
444 outcome
445 }
446
447 pub async fn run_parallel(
451 &self,
452 run_id: RunId,
453 tasks: Vec<(AgentTask, Option<String>)>,
454 ) -> Vec<Result<AgentResult, SchedulerError>> {
455 let futs = tasks.into_iter().map(|(task, backend)| async move {
456 self.run_agent(run_id, task, backend.as_deref()).await
457 });
458 futures::future::join_all(futs).await
459 }
460
461 pub fn cancel_agent(&self, run_id: RunId, agent_id: AgentId) {
463 if let Some(rs) = self.runs.get(&run_id) {
464 if let Some(tok) = rs.agent_cancels.get(&agent_id) {
465 tok.cancel();
466 }
467 }
468 }
469
470 pub fn cancel_run(&self, run_id: RunId) {
472 if let Some(rs) = self.runs.get(&run_id) {
473 rs.run_cancel.cancel();
474 }
475 }
476
477 pub fn quota_used(&self, run_id: RunId) -> Option<u32> {
479 self.runs
480 .get(&run_id)
481 .map(|rs| rs.quota_used.load(Ordering::Relaxed))
482 }
483
484 fn cleanup_agent(&self, run_id: RunId, agent_id: AgentId) {
485 if let Some(rs) = self.runs.get(&run_id) {
486 rs.agent_cancels.remove(&agent_id);
487 }
488 }
489}
490
491fn cancel_kind(run_cancel: &CancellationToken) -> SchedulerError {
492 if run_cancel.is_cancelled() {
493 SchedulerError::RunCancelled
494 } else {
495 SchedulerError::AgentCancelled
496 }
497}
498
499fn preview(s: &str) -> String {
500 s.chars().take(60).collect()
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506 use crate::mock_backend::{FailKind, MockBackend, MockBehavior};
507 use std::path::PathBuf;
508 use std::sync::atomic::AtomicUsize;
509 use std::sync::Mutex;
510 use std::time::Duration;
511 use uuid::Uuid;
512
513 fn fast_config(max_concurrency: usize, quota: u32) -> SchedulerConfig {
514 SchedulerConfig {
515 max_concurrency,
516 quota_per_run: quota,
517 retry: RetryPolicy {
518 max_attempts: 2,
519 initial_backoff: Duration::from_millis(1),
520 backoff_multiplier: 2.0,
521 max_backoff: Duration::from_millis(5),
522 schema_retry_max: 1,
523 },
524 }
525 }
526
527 fn mk_task(prompt: &str) -> AgentTask {
528 AgentTask {
529 agent_id: Uuid::now_v7(),
530 phase_id: 0,
531 prompt: prompt.to_string(),
532 model: None,
533 allowlist: None,
534 workdir: PathBuf::from("."),
535 mcp_endpoint: None,
536 timeout: None,
537 output_schema: None,
538 workdir_override: None,
539 description: None,
540 role: None,
541 name: None,
542 agent_seq: 0,
543 session_id: None,
544 }
545 }
546
547 fn mk_task_with_schema(prompt: &str) -> AgentTask {
548 let mut task = mk_task(prompt);
549 task.output_schema = Some(serde_json::json!({
550 "type": "object",
551 "properties": {
552 "answer": { "type": "string" }
553 },
554 "required": ["answer"]
555 }));
556 task
557 }
558
559 fn fallback_output(text: &str) -> serde_json::Value {
560 serde_json::json!({
561 "_agent_fallback_text": true,
562 "text": text,
563 })
564 }
565
566 fn ok_result(id: AgentId) -> AgentResult {
567 AgentResult {
568 agent_id: id,
569 status: AgentStatus::Ok,
570 output: serde_json::Value::Null,
571 findings: vec![],
572 tokens_used: TokenUsage::default(),
573 artifacts: vec![],
574 logs: LogRef::default(),
575 session_id: None,
576 }
577 }
578
579 fn sched_with(backend: Arc<dyn AgentBackend>, cfg: SchedulerConfig) -> Arc<Scheduler> {
580 Scheduler::new(cfg, BackendRegistry::new().with(backend), None)
581 }
582
583 struct ProbeBackend {
585 cur: Arc<AtomicUsize>,
586 peak: Arc<AtomicUsize>,
587 delay: Duration,
588 }
589
590 #[async_trait::async_trait]
591 impl AgentBackend for ProbeBackend {
592 fn id(&self) -> &'static str {
593 "probe"
594 }
595 fn capabilities(&self) -> AgentCapabilities {
596 AgentCapabilities::default()
597 }
598 fn as_any(&self) -> &dyn std::any::Any {
599 self
600 }
601 async fn run(
602 &self,
603 task: AgentTask,
604 _ctx: RunContext,
605 ) -> Result<AgentResult, BackendError> {
606 let c = self.cur.fetch_add(1, Ordering::SeqCst) + 1;
607 self.peak.fetch_max(c, Ordering::SeqCst);
608 tokio::time::sleep(self.delay).await;
609 self.cur.fetch_sub(1, Ordering::SeqCst);
610 Ok(ok_result(task.agent_id))
611 }
612 }
613
614 #[tokio::test]
615 async fn test_default_config_concurrency() {
616 let c = SchedulerConfig::default().max_concurrency;
617 assert_eq!(c, 1);
618 }
619
620 struct IdBackend {
623 id: &'static str,
624 }
625
626 #[async_trait::async_trait]
627 impl AgentBackend for IdBackend {
628 fn id(&self) -> &'static str {
629 self.id
630 }
631 fn capabilities(&self) -> AgentCapabilities {
632 AgentCapabilities::default()
633 }
634 fn as_any(&self) -> &dyn std::any::Any {
635 self
636 }
637 async fn run(&self, task: AgentTask, _ctx: RunContext) -> Result<AgentResult, BackendError> {
638 Ok(AgentResult {
639 agent_id: task.agent_id,
640 status: AgentStatus::Ok,
641 output: serde_json::Value::String(self.id.to_string()),
642 findings: vec![],
643 tokens_used: TokenUsage::default(),
644 artifacts: vec![],
645 logs: LogRef::default(),
646 session_id: None,
647 })
648 }
649 }
650
651 #[tokio::test]
652 #[serial_test::serial]
653 async fn per_task_backend_routes_to_named_backend() {
654 crate::contract::clear_current_backend();
657 let a = Arc::new(IdBackend { id: "alpha" }) as Arc<dyn AgentBackend>;
658 let b = Arc::new(IdBackend { id: "beta" }) as Arc<dyn AgentBackend>;
659 let sched = Arc::new(Scheduler::new(
661 fast_config(4, 1000),
662 BackendRegistry::new().with(a).with(b),
663 None,
664 ));
665 let run_id = Uuid::now_v7();
666 let _rx = sched.init_run(run_id, 256);
667
668 let r = sched
670 .run_agent(run_id, mk_task("t1"), Some("beta"))
671 .await
672 .unwrap();
673 assert_eq!(r.output, serde_json::Value::String("beta".to_string()));
674
675 let r = sched.run_agent(run_id, mk_task("t2"), None).await.unwrap();
677 assert_eq!(r.output, serde_json::Value::String("alpha".to_string()));
678 crate::contract::clear_current_backend();
679 }
680
681 #[tokio::test]
682 #[serial_test::serial]
683 async fn per_task_backend_follows_current_backend() {
684 crate::contract::clear_current_backend();
688 let a = Arc::new(IdBackend { id: "alpha" }) as Arc<dyn AgentBackend>;
689 let b = Arc::new(IdBackend { id: "beta" }) as Arc<dyn AgentBackend>;
690 let sched = Arc::new(Scheduler::new(
691 fast_config(4, 1000),
692 BackendRegistry::new().with(a).with(b),
693 None,
694 ));
695 let run_id = Uuid::now_v7();
696 let _rx = sched.init_run(run_id, 256);
697
698 crate::contract::set_current_backend(crate::contract::CurrentBackend {
700 id: "beta".to_string(),
701 name: "beta".to_string(),
702 version: "0".to_string(),
703 title: None,
704 client: crate::contract::ClientIdentity {
705 name: "luft".to_string(),
706 version: "test".to_string(),
707 title: None,
708 },
709 });
710
711 let r = sched.run_agent(run_id, mk_task("t"), None).await.unwrap();
714 assert_eq!(r.output, serde_json::Value::String("beta".to_string()));
715 crate::contract::clear_current_backend();
716 }
717
718 #[tokio::test]
719 #[serial_test::serial]
720 async fn per_task_backend_falls_back_when_current_unregistered() {
721 crate::contract::clear_current_backend();
724 let a = Arc::new(IdBackend { id: "alpha" }) as Arc<dyn AgentBackend>;
725 let sched = Arc::new(Scheduler::new(
726 fast_config(4, 1000),
727 BackendRegistry::new().with(a),
728 None,
729 ));
730 let run_id = Uuid::now_v7();
731 let _rx = sched.init_run(run_id, 256);
732
733 crate::contract::set_current_backend(crate::contract::CurrentBackend {
734 id: "gone".to_string(),
735 name: "gone".to_string(),
736 version: "0".to_string(),
737 title: None,
738 client: crate::contract::ClientIdentity {
739 name: "luft".to_string(),
740 version: "test".to_string(),
741 title: None,
742 },
743 });
744 let r = sched.run_agent(run_id, mk_task("t"), None).await.unwrap();
745 assert_eq!(r.output, serde_json::Value::String("alpha".to_string()));
746 crate::contract::clear_current_backend();
747 }
748
749 #[tokio::test]
750 async fn per_task_backend_unknown_id_errors() {
751 let a = Arc::new(IdBackend { id: "alpha" }) as Arc<dyn AgentBackend>;
752 let sched = Arc::new(Scheduler::new(
753 fast_config(4, 1000),
754 BackendRegistry::new().with(a),
755 None,
756 ));
757 let run_id = Uuid::now_v7();
758 let _rx = sched.init_run(run_id, 256);
759 assert!(sched
760 .run_agent(run_id, mk_task("t"), Some("nope"))
761 .await
762 .is_err());
763 }
764
765 #[tokio::test]
766 async fn test_concurrency_limit() {
767 let cur = Arc::new(AtomicUsize::new(0));
768 let peak = Arc::new(AtomicUsize::new(0));
769 let backend = Arc::new(ProbeBackend {
770 cur: cur.clone(),
771 peak: peak.clone(),
772 delay: Duration::from_millis(40),
773 });
774 let sched = sched_with(backend, fast_config(2, 1000));
775 let run_id = Uuid::now_v7();
776 let _rx = sched.init_run(run_id, 256);
777
778 let tasks: Vec<_> = (0..6).map(|i| (mk_task(&format!("t{i}")), None)).collect();
779 let results = sched.run_parallel(run_id, tasks).await;
780
781 assert!(results.iter().all(|r| r.is_ok()));
782 assert!(
783 peak.load(Ordering::SeqCst) <= 2,
784 "peak {}",
785 peak.load(Ordering::SeqCst)
786 );
787 }
788
789 #[tokio::test]
790 async fn test_quota_exceeded() {
791 let backend = Arc::new(MockBackend::new(
792 "mock",
793 vec![MockBehavior::Success {
794 output: serde_json::Value::Null,
795 tokens: TokenUsage::default(),
796 delay: Duration::from_millis(5),
797 }],
798 ));
799 let sched = sched_with(backend, fast_config(8, 3));
800 let run_id = Uuid::now_v7();
801 let _rx = sched.init_run(run_id, 256);
802
803 let tasks: Vec<_> = (0..4).map(|i| (mk_task(&format!("t{i}")), None)).collect();
804 let results = sched.run_parallel(run_id, tasks).await;
805
806 let ok = results.iter().filter(|r| r.is_ok()).count();
807 let quota_err = results
808 .iter()
809 .filter(|r| matches!(r, Err(SchedulerError::QuotaExceeded { .. })))
810 .count();
811 assert_eq!(ok, 3);
812 assert_eq!(quota_err, 1);
813 }
814
815 #[tokio::test]
816 async fn test_retry_on_retryable_error() {
817 let backend = Arc::new(MockBackend::new(
818 "mock",
819 vec![
820 MockBehavior::fail(FailKind::Spawn),
821 MockBehavior::fail(FailKind::Spawn),
822 MockBehavior::Success {
823 output: serde_json::Value::Null,
824 tokens: TokenUsage::default(),
825 delay: Duration::ZERO,
826 },
827 ],
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!(r.is_ok(), "{r:?}");
836 assert_eq!(probe.call_count(), 3);
837 }
838
839 #[tokio::test]
840 async fn test_no_retry_on_non_retryable() {
841 let backend = Arc::new(MockBackend::new(
842 "mock",
843 vec![MockBehavior::fail(FailKind::Protocol)],
844 ));
845 let probe = backend.clone();
846 let sched = sched_with(backend, fast_config(4, 1000));
847 let run_id = Uuid::now_v7();
848 let _rx = sched.init_run(run_id, 64);
849
850 let r = sched.run_agent(run_id, mk_task("x"), None).await;
851 assert!(matches!(r, Err(SchedulerError::NonRetryable(_))), "{r:?}");
852 assert_eq!(probe.call_count(), 1);
853 }
854
855 #[tokio::test]
856 async fn test_retry_exhausted() {
857 let backend = Arc::new(MockBackend::new(
858 "mock",
859 vec![MockBehavior::fail(FailKind::Spawn)],
860 ));
861 let probe = backend.clone();
862 let sched = sched_with(backend, fast_config(4, 1000));
863 let run_id = Uuid::now_v7();
864 let _rx = sched.init_run(run_id, 64);
865
866 let r = sched.run_agent(run_id, mk_task("x"), None).await;
867 assert!(
868 matches!(r, Err(SchedulerError::Exhausted { attempts: 3, .. })),
869 "{r:?}"
870 );
871 assert_eq!(probe.call_count(), 3);
872 }
873
874 #[tokio::test]
875 async fn test_schema_fallback_then_succeeds() {
876 let backend = Arc::new(MockBackend::new(
877 "mock",
878 vec![
879 MockBehavior::Success {
880 output: fallback_output("i forgot the tool"),
881 tokens: TokenUsage::default(),
882 delay: Duration::ZERO,
883 },
884 MockBehavior::Success {
885 output: serde_json::json!({"answer": "ok"}),
886 tokens: TokenUsage::default(),
887 delay: Duration::ZERO,
888 },
889 ],
890 ));
891 let probe = backend.clone();
892 let sched = sched_with(backend, fast_config(4, 1000));
893 let run_id = Uuid::now_v7();
894 let mut rx = sched.init_run(run_id, 64);
895
896 let task = mk_task_with_schema("respond");
897 let r = sched.run_agent(run_id, task, None).await;
898 assert!(r.is_ok(), "{r:?}");
899 assert_eq!(probe.call_count(), 2);
900
901 let mut prompt_with_feedback = None;
902 while let Ok(event) = rx.try_recv() {
903 if let AgentEvent::AgentDone { prompt, .. } = event {
904 prompt_with_feedback = Some(prompt);
905 }
906 }
907 let prompt = prompt_with_feedback.expect("AgentDone event with prompt");
908 assert!(prompt.contains("workflow_validate_schema"));
909 assert!(prompt.contains("Required JSON Schema"));
910 }
911
912 #[tokio::test]
913 async fn test_schema_mismatch_then_succeeds() {
914 let backend = Arc::new(MockBackend::new(
915 "mock",
916 vec![
917 MockBehavior::Success {
918 output: serde_json::json!({"wrong": "field"}),
919 tokens: TokenUsage::default(),
920 delay: Duration::ZERO,
921 },
922 MockBehavior::Success {
923 output: serde_json::json!({"answer": "ok"}),
924 tokens: TokenUsage::default(),
925 delay: Duration::ZERO,
926 },
927 ],
928 ));
929 let probe = backend.clone();
930 let sched = sched_with(backend, fast_config(4, 1000));
931 let run_id = Uuid::now_v7();
932 let _rx = sched.init_run(run_id, 64);
933
934 let task = mk_task_with_schema("respond");
935 let r = sched.run_agent(run_id, task, None).await;
936 assert!(r.is_ok(), "{r:?}");
937 assert_eq!(probe.call_count(), 2);
938 }
939
940 struct SessionRetryBackend {
941 calls: Arc<Mutex<Vec<Option<String>>>>,
942 }
943
944 #[async_trait::async_trait]
945 impl AgentBackend for SessionRetryBackend {
946 fn id(&self) -> &'static str {
947 "session-retry"
948 }
949
950 fn capabilities(&self) -> AgentCapabilities {
951 AgentCapabilities {
952 session_resume: true,
953 ..Default::default()
954 }
955 }
956
957 fn as_any(&self) -> &dyn std::any::Any {
958 self
959 }
960
961 async fn run(
962 &self,
963 task: AgentTask,
964 _ctx: RunContext,
965 ) -> Result<AgentResult, BackendError> {
966 let mut calls = self.calls.lock().unwrap();
967 let attempt = calls.len();
968 calls.push(task.session_id.clone());
969 Ok(AgentResult {
970 agent_id: task.agent_id,
971 status: AgentStatus::Ok,
972 output: if attempt == 0 {
973 serde_json::json!({"wrong": "field"})
974 } else {
975 serde_json::json!({"answer": "ok"})
976 },
977 findings: vec![],
978 tokens_used: TokenUsage::default(),
979 artifacts: vec![],
980 logs: LogRef::default(),
981 session_id: Some("acp-session-1".to_string()),
982 })
983 }
984 }
985
986 #[tokio::test]
987 async fn schema_retry_reuses_returned_session_id() {
988 let calls = Arc::new(Mutex::new(Vec::new()));
989 let backend = Arc::new(SessionRetryBackend {
990 calls: calls.clone(),
991 });
992 let sched = sched_with(backend, fast_config(4, 1000));
993 let run_id = Uuid::now_v7();
994 let _rx = sched.init_run(run_id, 64);
995
996 let result = sched
997 .run_agent(run_id, mk_task_with_schema("respond"), None)
998 .await;
999 assert!(result.is_ok(), "{result:?}");
1000 assert_eq!(
1001 *calls.lock().unwrap(),
1002 vec![None, Some("acp-session-1".to_string())]
1003 );
1004 }
1005
1006 #[tokio::test]
1007 async fn test_schema_fallback_exhausted() {
1008 let backend = Arc::new(MockBackend::new(
1009 "mock",
1010 vec![MockBehavior::Success {
1011 output: fallback_output("still no tool"),
1012 tokens: TokenUsage::default(),
1013 delay: Duration::ZERO,
1014 }],
1015 ));
1016 let probe = backend.clone();
1017 let sched = sched_with(backend, fast_config(4, 1000));
1018 let run_id = Uuid::now_v7();
1019 let _rx = sched.init_run(run_id, 64);
1020
1021 let task = mk_task_with_schema("respond");
1022 let r = sched.run_agent(run_id, task, None).await;
1023 assert!(
1024 matches!(r, Err(SchedulerError::SchemaValidation(_))),
1025 "{r:?}"
1026 );
1027 assert_eq!(probe.call_count(), 2);
1028 }
1029
1030 #[tokio::test]
1031 async fn test_cancel_run() {
1032 let backend = Arc::new(MockBackend::new("mock", vec![MockBehavior::Hang]));
1033 let sched = sched_with(backend, fast_config(8, 1000));
1034 let run_id = Uuid::now_v7();
1035 let _rx = sched.init_run(run_id, 64);
1036
1037 let s2 = sched.clone();
1038 let handle = tokio::spawn(async move {
1039 let tasks: Vec<_> = (0..3).map(|i| (mk_task(&format!("h{i}")), None)).collect();
1040 s2.run_parallel(run_id, tasks).await
1041 });
1042 tokio::time::sleep(Duration::from_millis(20)).await;
1043 sched.cancel_run(run_id);
1044
1045 let results = handle.await.unwrap();
1046 assert_eq!(results.len(), 3);
1047 assert!(results
1048 .iter()
1049 .all(|r| matches!(r, Err(SchedulerError::RunCancelled))));
1050 }
1051
1052 #[tokio::test]
1053 async fn test_cancel_agent() {
1054 let backend = Arc::new(MockBackend::new("mock", vec![MockBehavior::Hang]));
1055 let sched = sched_with(backend, fast_config(8, 1000));
1056 let run_id = Uuid::now_v7();
1057 let _rx = sched.init_run(run_id, 64);
1058
1059 let task = mk_task("hang");
1060 let agent_id = task.agent_id;
1061 let s2 = sched.clone();
1062 let handle = tokio::spawn(async move { s2.run_agent(run_id, task, None).await });
1063 tokio::time::sleep(Duration::from_millis(20)).await;
1064 sched.cancel_agent(run_id, agent_id);
1065
1066 let r = handle.await.unwrap();
1067 assert!(matches!(r, Err(SchedulerError::AgentCancelled)), "{r:?}");
1068 }
1069
1070 #[tokio::test]
1071 async fn test_parallel_partial_failure() {
1072 let backend = Arc::new(MockBackend::new(
1073 "mock",
1074 vec![
1075 MockBehavior::Success {
1076 output: serde_json::Value::Null,
1077 tokens: TokenUsage::default(),
1078 delay: Duration::ZERO,
1079 },
1080 MockBehavior::fail(FailKind::Protocol),
1081 MockBehavior::Success {
1082 output: serde_json::Value::Null,
1083 tokens: TokenUsage::default(),
1084 delay: Duration::ZERO,
1085 },
1086 ],
1087 ));
1088 let sched = sched_with(backend, fast_config(1, 1000)); let run_id = Uuid::now_v7();
1090 let _rx = sched.init_run(run_id, 64);
1091
1092 let tasks: Vec<_> = (0..3).map(|i| (mk_task(&format!("p{i}")), None)).collect();
1093 let results = sched.run_parallel(run_id, tasks).await;
1094
1095 assert_eq!(results.len(), 3);
1096 assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 2);
1097 assert_eq!(results.iter().filter(|r| r.is_err()).count(), 1);
1098 }
1099
1100 #[tokio::test]
1101 async fn test_event_sequence() {
1102 let backend = Arc::new(MockBackend::new(
1103 "mock",
1104 vec![MockBehavior::Success {
1105 output: serde_json::Value::Null,
1106 tokens: TokenUsage {
1107 input: 10,
1108 output: 5,
1109 ..Default::default()
1110 },
1111 delay: Duration::ZERO,
1112 }],
1113 ));
1114 let sched = sched_with(backend, fast_config(4, 1000));
1115 let run_id = Uuid::now_v7();
1116 let mut rx = sched.init_run(run_id, 64);
1117
1118 let r = sched.run_agent(run_id, mk_task("x"), None).await;
1119 assert!(r.is_ok());
1120
1121 let e1 = rx.recv().await.unwrap();
1122 assert!(matches!(e1, AgentEvent::AgentStarted { .. }), "{e1:?}");
1123 let e2 = rx.recv().await.unwrap();
1124 match e2 {
1125 AgentEvent::AgentDone { status, tokens, .. } => {
1126 assert_eq!(status, AgentStatus::Ok);
1127 assert_eq!(tokens.input, 10);
1128 }
1129 other => panic!("expected AgentDone, got {other:?}"),
1130 }
1131 }
1132}