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 dashmap::DashMap;
19use std::sync::atomic::{AtomicU32, Ordering};
20use std::sync::Arc;
21use std::time::Instant;
22use tokio::sync::{broadcast, Semaphore};
23use tokio_util::sync::CancellationToken;
24
25#[async_trait::async_trait]
30pub trait JournalCallback: Send + Sync {
31 async fn on_agent_done(
33 &self,
34 agent_id: AgentId,
35 phase_id: PhaseId,
36 status: AgentStatus,
37 output: serde_json::Value,
38 tokens: TokenUsage,
39 );
40}
41
42struct RunState {
44 quota_used: Arc<AtomicU32>,
45 run_cancel: CancellationToken,
46 events: EventSender,
47 agent_cancels: DashMap<AgentId, CancellationToken>,
49}
50
51pub struct Scheduler {
54 config: SchedulerConfig,
55 semaphore: Arc<Semaphore>,
56 registry: BackendRegistry,
57 runs: DashMap<RunId, RunState>,
58 journal_callback: Option<Arc<dyn JournalCallback>>,
61}
62
63impl Scheduler {
64 pub fn new(
65 config: SchedulerConfig,
66 registry: BackendRegistry,
67 journal_callback: Option<Arc<dyn JournalCallback>>,
68 ) -> Arc<Self> {
69 let semaphore = Arc::new(Semaphore::new(config.max_concurrency));
70 Arc::new(Self {
71 config,
72 semaphore,
73 registry,
74 runs: DashMap::new(),
75 journal_callback,
76 })
77 }
78
79 pub fn config(&self) -> &SchedulerConfig {
80 &self.config
81 }
82
83 pub fn init_run(
86 &self,
87 run_id: RunId,
88 event_capacity: usize,
89 ) -> broadcast::Receiver<AgentEvent> {
90 let (tx, rx) = broadcast::channel(event_capacity);
91 self.init_run_with(run_id, tx);
92 rx
93 }
94
95 pub fn init_run_with(&self, run_id: RunId, events: EventSender) {
101 self.runs.insert(
102 run_id,
103 RunState {
104 quota_used: Arc::new(AtomicU32::new(0)),
105 run_cancel: CancellationToken::new(),
106 events,
107 agent_cancels: DashMap::new(),
108 },
109 );
110 }
111
112 #[tracing::instrument(
119 name = "agent",
120 skip_all,
121 fields(
122 run_id = %run_id,
123 agent_id = %task.agent_id,
124 phase_id = task.phase_id,
125 model = task.model.as_deref().unwrap_or("default"),
126 )
127 )]
128 pub async fn run_agent(
129 &self,
130 run_id: RunId,
131 mut task: AgentTask,
132 backend_id: Option<&str>,
133 ) -> Result<AgentResult, SchedulerError> {
134 let backend = match backend_id {
135 Some(id) => self.registry.get(id)?,
136 None => self.registry.default_backend()?,
137 };
138
139 let (quota_used, run_cancel, events) = {
141 let rs = self
142 .runs
143 .get(&run_id)
144 .ok_or(SchedulerError::RunNotFound(run_id))?;
145 (
146 rs.quota_used.clone(),
147 rs.run_cancel.clone(),
148 rs.events.clone(),
149 )
150 };
151
152 let used = quota_used.fetch_add(1, Ordering::Relaxed) + 1;
154 if used > self.config.quota_per_run {
155 tracing::warn!(
156 used,
157 limit = self.config.quota_per_run,
158 "run quota exceeded"
159 );
160 let _ = events.send(AgentEvent::AgentDone {
161 run_id,
162 agent_id: task.agent_id,
163 status: AgentStatus::Error,
164 tokens: TokenUsage::default(),
165 elapsed_ms: 0,
166 name: task.name.clone(),
167 agent_seq: task.agent_seq,
168 output: serde_json::Value::Null,
169 findings: Vec::new(),
170 prompt: task.prompt.clone(),
171 retry_count: 0,
172 });
173 return Err(SchedulerError::QuotaExceeded {
174 limit: self.config.quota_per_run,
175 used,
176 });
177 }
178
179 let agent_token = run_cancel.child_token();
182 if let Some(rs) = self.runs.get(&run_id) {
183 rs.agent_cancels.insert(task.agent_id, agent_token.clone());
184 }
185
186 let permit = tokio::select! {
188 p = self.semaphore.clone().acquire_owned() => p.expect("semaphore never closed"),
189 _ = agent_token.cancelled() => {
190 let _ = events.send(AgentEvent::AgentDone {
191 run_id,
192 agent_id: task.agent_id,
193 status: AgentStatus::Cancelled,
194 tokens: TokenUsage::default(),
195 elapsed_ms: 0,
196 name: task.name.clone(),
197 agent_seq: task.agent_seq,
198 output: serde_json::Value::Null,
199 findings: Vec::new(),
200 prompt: task.prompt.clone(),
201 retry_count: 0,
202 });
203 self.cleanup_agent(run_id, task.agent_id);
204 return Err(cancel_kind(&run_cancel));
205 }
206 };
207
208 let _ = events.send(AgentEvent::AgentStarted {
209 run_id,
210 phase_id: task.phase_id,
211 agent_id: task.agent_id,
212 prompt_preview: preview(&task.prompt),
213 model: task.model.clone(),
214 description: task.description.clone(),
215 role: task.role.clone(),
216 name: task.name.clone(),
217 agent_seq: task.agent_seq,
218 });
219
220 let start = Instant::now();
221 let mut attempt = 0u32;
222 let original_prompt = task.prompt.clone();
223 let mut schema_retry_count = 0u32;
224 let outcome: Result<AgentResult, SchedulerError> = loop {
225 let ctx = RunContext {
226 run_id,
227 cancel: agent_token.clone(),
228 events: events.clone(),
229 };
230 let run_fut = backend.run(task.clone(), ctx);
231 let res = match task.timeout {
232 Some(t) => match tokio::time::timeout(t, run_fut).await {
233 Ok(r) => r,
234 Err(_) => Err(BackendError::Timeout),
235 },
236 None => run_fut.await,
237 };
238
239 match res {
240 Ok(result) => {
241 if let Some(ref schema) = task.output_schema {
242 let fallback = result.output.get("_agent_fallback_text").is_some();
243 let validation_err = if fallback {
244 Some(
245 "agent returned text instead of calling structured_output tool"
246 .to_string(),
247 )
248 } else {
249 validate_output(&result.output, schema)
250 .err()
251 .map(|e| e.to_string())
252 };
253
254 if let Some(error) = validation_err {
255 schema_retry_count += 1;
256 if schema_retry_count > self.config.retry.schema_retry_max {
257 tracing::error!(
258 error = %error,
259 attempts = schema_retry_count,
260 "agent output failed schema validation, retries exhausted"
261 );
262 break Err(SchedulerError::SchemaValidation(error));
263 }
264 let _ = events.send(AgentEvent::SchemaRetry {
265 run_id,
266 agent_id: task.agent_id,
267 attempt: schema_retry_count,
268 max: self.config.retry.schema_retry_max,
269 });
270 tracing::warn!(
271 error = %error,
272 attempt = schema_retry_count,
273 "schema validation failed, retrying with feedback"
274 );
275 let schema_json =
276 serde_json::to_string_pretty(schema).unwrap_or_default();
277 let last_output = if fallback {
278 result
279 .output
280 .get("text")
281 .and_then(|v| v.as_str())
282 .unwrap_or("")
283 .to_string()
284 } else {
285 serde_json::to_string_pretty(&result.output).unwrap_or_default()
286 };
287 task.prompt = if fallback {
288 format!(
289 "{original_prompt}\n\n\
290 ---\n\
291 You returned your result as plain text instead of calling the `structured_output` tool.\n\
292 You MUST call the `structured_output` tool to submit your result.\n\
293 Do NOT return the result as a text message.\n\
294 \n\
295 Your text output was:\n\
296 ```\n{last_output}\n```\n\
297 \n\
298 Required JSON Schema:\n\
299 ```json\n{schema}\n```",
300 original_prompt = original_prompt,
301 last_output = last_output,
302 schema = schema_json,
303 )
304 } else {
305 format!(
306 "{original_prompt}\n\n\
307 ---\n\
308 Your previous response did not match the required schema.\n\
309 Error: {error}\n\
310 \n\
311 Your output was:\n\
312 ```json\n{last_output}\n```\n\
313 \n\
314 Required JSON Schema:\n\
315 ```json\n{schema}\n```\n\
316 \n\
317 Call the `structured_output` tool with a JSON object that\n\
318 matches this schema exactly. Include ALL required fields.",
319 original_prompt = original_prompt,
320 error = error,
321 last_output = last_output,
322 schema = schema_json,
323 )
324 };
325 continue;
326 }
327 }
328 break Ok(result);
329 }
330 Err(e) => {
331 if agent_token.is_cancelled() || matches!(e, BackendError::Cancelled) {
332 tracing::debug!("agent cancelled");
333 break Err(cancel_kind(&run_cancel));
334 }
335 if !e.is_retryable() {
336 tracing::error!(error = %e, "non-retryable backend error");
337 break Err(SchedulerError::NonRetryable(e));
338 }
339 attempt += 1;
340 if attempt > self.config.retry.max_attempts {
341 tracing::error!(attempts = attempt, error = %e, "agent exhausted retries");
342 break Err(SchedulerError::Exhausted {
343 attempts: attempt,
344 source: e,
345 });
346 }
347 let backoff = self.config.retry.backoff(attempt);
348 tracing::warn!(
349 attempt, backoff_ms = backoff.as_millis() as u64, error = %e,
350 "retryable backend error; retrying"
351 );
352 tokio::select! {
353 _ = tokio::time::sleep(backoff) => {}
354 _ = agent_token.cancelled() => break Err(cancel_kind(&run_cancel)),
355 }
356 }
357 }
358 };
359
360 let elapsed_ms = start.elapsed().as_millis() as u64;
361 let (status, tokens) = match &outcome {
362 Ok(r) => (r.status.clone(), r.tokens_used),
363 Err(SchedulerError::AgentCancelled) | Err(SchedulerError::RunCancelled) => {
364 (AgentStatus::Cancelled, TokenUsage::default())
365 }
366 Err(_) => (AgentStatus::Error, TokenUsage::default()),
367 };
368 let _ = events.send(AgentEvent::AgentDone {
369 run_id,
370 agent_id: task.agent_id,
371 status: status.clone(),
372 tokens,
373 elapsed_ms,
374 name: task.name.clone(),
375 agent_seq: task.agent_seq,
376 output: match &outcome {
377 Ok(r) => r.output.clone(),
378 Err(_) => serde_json::Value::Null,
379 },
380 findings: match &outcome {
381 Ok(r) => r.findings.clone(),
382 Err(_) => Vec::new(),
383 },
384 prompt: task.prompt.clone(),
385 retry_count: attempt,
386 });
387 tracing::info!(?status, elapsed_ms, "agent finished");
388
389 if let Some(ref cb) = self.journal_callback {
391 let output = match &outcome {
392 Ok(r) => r.output.clone(),
393 Err(_) => serde_json::Value::Null,
394 };
395 let agent_status = status.clone();
396 let tokens_used = tokens;
397 let agent_id = task.agent_id;
398 let phase_id = task.phase_id;
399 cb.on_agent_done(agent_id, phase_id, agent_status, output, tokens_used)
400 .await;
401 }
402
403 drop(permit);
404 self.cleanup_agent(run_id, task.agent_id);
405 outcome
406 }
407
408 pub async fn run_parallel(
412 &self,
413 run_id: RunId,
414 tasks: Vec<(AgentTask, Option<String>)>,
415 ) -> Vec<Result<AgentResult, SchedulerError>> {
416 let futs = tasks.into_iter().map(|(task, backend)| async move {
417 self.run_agent(run_id, task, backend.as_deref()).await
418 });
419 futures::future::join_all(futs).await
420 }
421
422 pub fn cancel_agent(&self, run_id: RunId, agent_id: AgentId) {
424 if let Some(rs) = self.runs.get(&run_id) {
425 if let Some(tok) = rs.agent_cancels.get(&agent_id) {
426 tok.cancel();
427 }
428 }
429 }
430
431 pub fn cancel_run(&self, run_id: RunId) {
433 if let Some(rs) = self.runs.get(&run_id) {
434 rs.run_cancel.cancel();
435 }
436 }
437
438 pub fn active_concurrency(&self) -> usize {
440 self.config.max_concurrency - self.semaphore.available_permits()
441 }
442
443 pub fn quota_used(&self, run_id: RunId) -> Option<u32> {
445 self.runs
446 .get(&run_id)
447 .map(|rs| rs.quota_used.load(Ordering::Relaxed))
448 }
449
450 fn cleanup_agent(&self, run_id: RunId, agent_id: AgentId) {
451 if let Some(rs) = self.runs.get(&run_id) {
452 rs.agent_cancels.remove(&agent_id);
453 }
454 }
455}
456
457fn cancel_kind(run_cancel: &CancellationToken) -> SchedulerError {
458 if run_cancel.is_cancelled() {
459 SchedulerError::RunCancelled
460 } else {
461 SchedulerError::AgentCancelled
462 }
463}
464
465fn preview(s: &str) -> String {
466 s.chars().take(60).collect()
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472 use crate::mock_backend::{FailKind, MockBackend, MockBehavior};
473 use std::path::PathBuf;
474 use std::sync::atomic::AtomicUsize;
475 use std::time::Duration;
476 use uuid::Uuid;
477
478 fn fast_config(max_concurrency: usize, quota: u32) -> SchedulerConfig {
479 SchedulerConfig {
480 max_concurrency,
481 quota_per_run: quota,
482 retry: RetryPolicy {
483 max_attempts: 2,
484 initial_backoff: Duration::from_millis(1),
485 backoff_multiplier: 2.0,
486 max_backoff: Duration::from_millis(5),
487 schema_retry_max: 1,
488 },
489 }
490 }
491
492 fn mk_task(prompt: &str) -> AgentTask {
493 AgentTask {
494 agent_id: Uuid::now_v7(),
495 phase_id: 0,
496 prompt: prompt.to_string(),
497 model: None,
498 allowlist: None,
499 workdir: PathBuf::from("."),
500 mcp_endpoint: None,
501 timeout: None,
502 output_schema: None,
503 description: None,
504 role: None,
505 name: None,
506 agent_seq: 0,
507 }
508 }
509
510 fn mk_task_with_schema(prompt: &str) -> AgentTask {
511 let mut task = mk_task(prompt);
512 task.output_schema = Some(serde_json::json!({
513 "type": "object",
514 "properties": {
515 "answer": { "type": "string" }
516 },
517 "required": ["answer"]
518 }));
519 task
520 }
521
522 fn fallback_output(text: &str) -> serde_json::Value {
523 serde_json::json!({
524 "_agent_fallback_text": true,
525 "text": text,
526 })
527 }
528
529 fn ok_result(id: AgentId) -> AgentResult {
530 AgentResult {
531 agent_id: id,
532 status: AgentStatus::Ok,
533 output: serde_json::Value::Null,
534 findings: vec![],
535 tokens_used: TokenUsage::default(),
536 artifacts: vec![],
537 logs: LogRef::default(),
538 }
539 }
540
541 fn sched_with(backend: Arc<dyn AgentBackend>, cfg: SchedulerConfig) -> Arc<Scheduler> {
542 Scheduler::new(cfg, BackendRegistry::new().with(backend), None)
543 }
544
545 struct ProbeBackend {
547 cur: Arc<AtomicUsize>,
548 peak: Arc<AtomicUsize>,
549 delay: Duration,
550 }
551
552 #[async_trait::async_trait]
553 impl AgentBackend for ProbeBackend {
554 fn id(&self) -> &'static str {
555 "probe"
556 }
557 fn capabilities(&self) -> AgentCapabilities {
558 AgentCapabilities::default()
559 }
560 fn as_any(&self) -> &dyn std::any::Any {
561 self
562 }
563 async fn run(
564 &self,
565 task: AgentTask,
566 _ctx: RunContext,
567 ) -> Result<AgentResult, BackendError> {
568 let c = self.cur.fetch_add(1, Ordering::SeqCst) + 1;
569 self.peak.fetch_max(c, Ordering::SeqCst);
570 tokio::time::sleep(self.delay).await;
571 self.cur.fetch_sub(1, Ordering::SeqCst);
572 Ok(ok_result(task.agent_id))
573 }
574 }
575
576 #[tokio::test]
577 async fn test_default_config_concurrency() {
578 let c = SchedulerConfig::default().max_concurrency;
579 assert!((4..=16).contains(&c), "got {c}");
580 }
581
582 #[tokio::test]
583 async fn test_concurrency_limit() {
584 let cur = Arc::new(AtomicUsize::new(0));
585 let peak = Arc::new(AtomicUsize::new(0));
586 let backend = Arc::new(ProbeBackend {
587 cur: cur.clone(),
588 peak: peak.clone(),
589 delay: Duration::from_millis(40),
590 });
591 let sched = sched_with(backend, fast_config(2, 1000));
592 let run_id = Uuid::now_v7();
593 let _rx = sched.init_run(run_id, 256);
594
595 let tasks: Vec<_> = (0..6).map(|i| (mk_task(&format!("t{i}")), None)).collect();
596 let results = sched.run_parallel(run_id, tasks).await;
597
598 assert!(results.iter().all(|r| r.is_ok()));
599 assert!(
600 peak.load(Ordering::SeqCst) <= 2,
601 "peak {}",
602 peak.load(Ordering::SeqCst)
603 );
604 }
605
606 #[tokio::test]
607 async fn test_quota_exceeded() {
608 let backend = Arc::new(MockBackend::new(
609 "mock",
610 vec![MockBehavior::Success {
611 output: serde_json::Value::Null,
612 tokens: TokenUsage::default(),
613 delay: Duration::from_millis(5),
614 }],
615 ));
616 let sched = sched_with(backend, fast_config(8, 3));
617 let run_id = Uuid::now_v7();
618 let _rx = sched.init_run(run_id, 256);
619
620 let tasks: Vec<_> = (0..4).map(|i| (mk_task(&format!("t{i}")), None)).collect();
621 let results = sched.run_parallel(run_id, tasks).await;
622
623 let ok = results.iter().filter(|r| r.is_ok()).count();
624 let quota_err = results
625 .iter()
626 .filter(|r| matches!(r, Err(SchedulerError::QuotaExceeded { .. })))
627 .count();
628 assert_eq!(ok, 3);
629 assert_eq!(quota_err, 1);
630 }
631
632 #[tokio::test]
633 async fn test_retry_on_retryable_error() {
634 let backend = Arc::new(MockBackend::new(
635 "mock",
636 vec![
637 MockBehavior::fail(FailKind::Spawn),
638 MockBehavior::fail(FailKind::Spawn),
639 MockBehavior::Success {
640 output: serde_json::Value::Null,
641 tokens: TokenUsage::default(),
642 delay: Duration::ZERO,
643 },
644 ],
645 ));
646 let probe = backend.clone();
647 let sched = sched_with(backend, fast_config(4, 1000));
648 let run_id = Uuid::now_v7();
649 let _rx = sched.init_run(run_id, 64);
650
651 let r = sched.run_agent(run_id, mk_task("x"), None).await;
652 assert!(r.is_ok(), "{r:?}");
653 assert_eq!(probe.call_count(), 3);
654 }
655
656 #[tokio::test]
657 async fn test_no_retry_on_non_retryable() {
658 let backend = Arc::new(MockBackend::new(
659 "mock",
660 vec![MockBehavior::fail(FailKind::Protocol)],
661 ));
662 let probe = backend.clone();
663 let sched = sched_with(backend, fast_config(4, 1000));
664 let run_id = Uuid::now_v7();
665 let _rx = sched.init_run(run_id, 64);
666
667 let r = sched.run_agent(run_id, mk_task("x"), None).await;
668 assert!(matches!(r, Err(SchedulerError::NonRetryable(_))), "{r:?}");
669 assert_eq!(probe.call_count(), 1);
670 }
671
672 #[tokio::test]
673 async fn test_retry_exhausted() {
674 let backend = Arc::new(MockBackend::new(
675 "mock",
676 vec![MockBehavior::fail(FailKind::Spawn)],
677 ));
678 let probe = backend.clone();
679 let sched = sched_with(backend, fast_config(4, 1000));
680 let run_id = Uuid::now_v7();
681 let _rx = sched.init_run(run_id, 64);
682
683 let r = sched.run_agent(run_id, mk_task("x"), None).await;
684 assert!(
685 matches!(r, Err(SchedulerError::Exhausted { attempts: 3, .. })),
686 "{r:?}"
687 );
688 assert_eq!(probe.call_count(), 3);
689 }
690
691 #[tokio::test]
692 async fn test_schema_fallback_then_succeeds() {
693 let backend = Arc::new(MockBackend::new(
694 "mock",
695 vec![
696 MockBehavior::Success {
697 output: fallback_output("i forgot the tool"),
698 tokens: TokenUsage::default(),
699 delay: Duration::ZERO,
700 },
701 MockBehavior::Success {
702 output: serde_json::json!({"answer": "ok"}),
703 tokens: TokenUsage::default(),
704 delay: Duration::ZERO,
705 },
706 ],
707 ));
708 let probe = backend.clone();
709 let sched = sched_with(backend, fast_config(4, 1000));
710 let run_id = Uuid::now_v7();
711 let mut rx = sched.init_run(run_id, 64);
712
713 let task = mk_task_with_schema("respond");
714 let r = sched.run_agent(run_id, task, None).await;
715 assert!(r.is_ok(), "{r:?}");
716 assert_eq!(probe.call_count(), 2);
717
718 let mut prompt_with_feedback = None;
719 while let Ok(event) = rx.try_recv() {
720 if let AgentEvent::AgentDone { prompt, .. } = event {
721 prompt_with_feedback = Some(prompt);
722 }
723 }
724 let prompt = prompt_with_feedback.expect("AgentDone event with prompt");
725 assert!(prompt.contains("structured_output"));
726 assert!(prompt.contains("Required JSON Schema"));
727 }
728
729 #[tokio::test]
730 async fn test_schema_mismatch_then_succeeds() {
731 let backend = Arc::new(MockBackend::new(
732 "mock",
733 vec![
734 MockBehavior::Success {
735 output: serde_json::json!({"wrong": "field"}),
736 tokens: TokenUsage::default(),
737 delay: Duration::ZERO,
738 },
739 MockBehavior::Success {
740 output: serde_json::json!({"answer": "ok"}),
741 tokens: TokenUsage::default(),
742 delay: Duration::ZERO,
743 },
744 ],
745 ));
746 let probe = backend.clone();
747 let sched = sched_with(backend, fast_config(4, 1000));
748 let run_id = Uuid::now_v7();
749 let _rx = sched.init_run(run_id, 64);
750
751 let task = mk_task_with_schema("respond");
752 let r = sched.run_agent(run_id, task, None).await;
753 assert!(r.is_ok(), "{r:?}");
754 assert_eq!(probe.call_count(), 2);
755 }
756
757 #[tokio::test]
758 async fn test_schema_fallback_exhausted() {
759 let backend = Arc::new(MockBackend::new(
760 "mock",
761 vec![MockBehavior::Success {
762 output: fallback_output("still no tool"),
763 tokens: TokenUsage::default(),
764 delay: Duration::ZERO,
765 }],
766 ));
767 let probe = backend.clone();
768 let sched = sched_with(backend, fast_config(4, 1000));
769 let run_id = Uuid::now_v7();
770 let _rx = sched.init_run(run_id, 64);
771
772 let task = mk_task_with_schema("respond");
773 let r = sched.run_agent(run_id, task, None).await;
774 assert!(
775 matches!(r, Err(SchedulerError::SchemaValidation(_))),
776 "{r:?}"
777 );
778 assert_eq!(probe.call_count(), 2);
779 }
780
781 #[tokio::test]
782 async fn test_cancel_run() {
783 let backend = Arc::new(MockBackend::new("mock", vec![MockBehavior::Hang]));
784 let sched = sched_with(backend, fast_config(8, 1000));
785 let run_id = Uuid::now_v7();
786 let _rx = sched.init_run(run_id, 64);
787
788 let s2 = sched.clone();
789 let handle = tokio::spawn(async move {
790 let tasks: Vec<_> = (0..3).map(|i| (mk_task(&format!("h{i}")), None)).collect();
791 s2.run_parallel(run_id, tasks).await
792 });
793 tokio::time::sleep(Duration::from_millis(20)).await;
794 sched.cancel_run(run_id);
795
796 let results = handle.await.unwrap();
797 assert_eq!(results.len(), 3);
798 assert!(results
799 .iter()
800 .all(|r| matches!(r, Err(SchedulerError::RunCancelled))));
801 }
802
803 #[tokio::test]
804 async fn test_cancel_agent() {
805 let backend = Arc::new(MockBackend::new("mock", vec![MockBehavior::Hang]));
806 let sched = sched_with(backend, fast_config(8, 1000));
807 let run_id = Uuid::now_v7();
808 let _rx = sched.init_run(run_id, 64);
809
810 let task = mk_task("hang");
811 let agent_id = task.agent_id;
812 let s2 = sched.clone();
813 let handle = tokio::spawn(async move { s2.run_agent(run_id, task, None).await });
814 tokio::time::sleep(Duration::from_millis(20)).await;
815 sched.cancel_agent(run_id, agent_id);
816
817 let r = handle.await.unwrap();
818 assert!(matches!(r, Err(SchedulerError::AgentCancelled)), "{r:?}");
819 }
820
821 #[tokio::test]
822 async fn test_parallel_partial_failure() {
823 let backend = Arc::new(MockBackend::new(
824 "mock",
825 vec![
826 MockBehavior::Success {
827 output: serde_json::Value::Null,
828 tokens: TokenUsage::default(),
829 delay: Duration::ZERO,
830 },
831 MockBehavior::fail(FailKind::Protocol),
832 MockBehavior::Success {
833 output: serde_json::Value::Null,
834 tokens: TokenUsage::default(),
835 delay: Duration::ZERO,
836 },
837 ],
838 ));
839 let sched = sched_with(backend, fast_config(1, 1000)); let run_id = Uuid::now_v7();
841 let _rx = sched.init_run(run_id, 64);
842
843 let tasks: Vec<_> = (0..3).map(|i| (mk_task(&format!("p{i}")), None)).collect();
844 let results = sched.run_parallel(run_id, tasks).await;
845
846 assert_eq!(results.len(), 3);
847 assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 2);
848 assert_eq!(results.iter().filter(|r| r.is_err()).count(), 1);
849 }
850
851 #[tokio::test]
852 async fn test_event_sequence() {
853 let backend = Arc::new(MockBackend::new(
854 "mock",
855 vec![MockBehavior::Success {
856 output: serde_json::Value::Null,
857 tokens: TokenUsage {
858 input: 10,
859 output: 5,
860 ..Default::default()
861 },
862 delay: Duration::ZERO,
863 }],
864 ));
865 let sched = sched_with(backend, fast_config(4, 1000));
866 let run_id = Uuid::now_v7();
867 let mut rx = sched.init_run(run_id, 64);
868
869 let r = sched.run_agent(run_id, mk_task("x"), None).await;
870 assert!(r.is_ok());
871
872 let e1 = rx.recv().await.unwrap();
873 assert!(matches!(e1, AgentEvent::AgentStarted { .. }), "{e1:?}");
874 let e2 = rx.recv().await.unwrap();
875 match e2 {
876 AgentEvent::AgentDone { status, tokens, .. } => {
877 assert_eq!(status, AgentStatus::Ok);
878 assert_eq!(tokens.input, 10);
879 }
880 other => panic!("expected AgentDone, got {other:?}"),
881 }
882 }
883}