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 workdir_override: None,
504 description: None,
505 role: None,
506 name: None,
507 agent_seq: 0,
508 }
509 }
510
511 fn mk_task_with_schema(prompt: &str) -> AgentTask {
512 let mut task = mk_task(prompt);
513 task.output_schema = Some(serde_json::json!({
514 "type": "object",
515 "properties": {
516 "answer": { "type": "string" }
517 },
518 "required": ["answer"]
519 }));
520 task
521 }
522
523 fn fallback_output(text: &str) -> serde_json::Value {
524 serde_json::json!({
525 "_agent_fallback_text": true,
526 "text": text,
527 })
528 }
529
530 fn ok_result(id: AgentId) -> AgentResult {
531 AgentResult {
532 agent_id: id,
533 status: AgentStatus::Ok,
534 output: serde_json::Value::Null,
535 findings: vec![],
536 tokens_used: TokenUsage::default(),
537 artifacts: vec![],
538 logs: LogRef::default(),
539 }
540 }
541
542 fn sched_with(backend: Arc<dyn AgentBackend>, cfg: SchedulerConfig) -> Arc<Scheduler> {
543 Scheduler::new(cfg, BackendRegistry::new().with(backend), None)
544 }
545
546 struct ProbeBackend {
548 cur: Arc<AtomicUsize>,
549 peak: Arc<AtomicUsize>,
550 delay: Duration,
551 }
552
553 #[async_trait::async_trait]
554 impl AgentBackend for ProbeBackend {
555 fn id(&self) -> &'static str {
556 "probe"
557 }
558 fn capabilities(&self) -> AgentCapabilities {
559 AgentCapabilities::default()
560 }
561 fn as_any(&self) -> &dyn std::any::Any {
562 self
563 }
564 async fn run(
565 &self,
566 task: AgentTask,
567 _ctx: RunContext,
568 ) -> Result<AgentResult, BackendError> {
569 let c = self.cur.fetch_add(1, Ordering::SeqCst) + 1;
570 self.peak.fetch_max(c, Ordering::SeqCst);
571 tokio::time::sleep(self.delay).await;
572 self.cur.fetch_sub(1, Ordering::SeqCst);
573 Ok(ok_result(task.agent_id))
574 }
575 }
576
577 #[tokio::test]
578 async fn test_default_config_concurrency() {
579 let c = SchedulerConfig::default().max_concurrency;
580 assert!((4..=16).contains(&c), "got {c}");
581 }
582
583 #[tokio::test]
584 async fn test_concurrency_limit() {
585 let cur = Arc::new(AtomicUsize::new(0));
586 let peak = Arc::new(AtomicUsize::new(0));
587 let backend = Arc::new(ProbeBackend {
588 cur: cur.clone(),
589 peak: peak.clone(),
590 delay: Duration::from_millis(40),
591 });
592 let sched = sched_with(backend, fast_config(2, 1000));
593 let run_id = Uuid::now_v7();
594 let _rx = sched.init_run(run_id, 256);
595
596 let tasks: Vec<_> = (0..6).map(|i| (mk_task(&format!("t{i}")), None)).collect();
597 let results = sched.run_parallel(run_id, tasks).await;
598
599 assert!(results.iter().all(|r| r.is_ok()));
600 assert!(
601 peak.load(Ordering::SeqCst) <= 2,
602 "peak {}",
603 peak.load(Ordering::SeqCst)
604 );
605 }
606
607 #[tokio::test]
608 async fn test_quota_exceeded() {
609 let backend = Arc::new(MockBackend::new(
610 "mock",
611 vec![MockBehavior::Success {
612 output: serde_json::Value::Null,
613 tokens: TokenUsage::default(),
614 delay: Duration::from_millis(5),
615 }],
616 ));
617 let sched = sched_with(backend, fast_config(8, 3));
618 let run_id = Uuid::now_v7();
619 let _rx = sched.init_run(run_id, 256);
620
621 let tasks: Vec<_> = (0..4).map(|i| (mk_task(&format!("t{i}")), None)).collect();
622 let results = sched.run_parallel(run_id, tasks).await;
623
624 let ok = results.iter().filter(|r| r.is_ok()).count();
625 let quota_err = results
626 .iter()
627 .filter(|r| matches!(r, Err(SchedulerError::QuotaExceeded { .. })))
628 .count();
629 assert_eq!(ok, 3);
630 assert_eq!(quota_err, 1);
631 }
632
633 #[tokio::test]
634 async fn test_retry_on_retryable_error() {
635 let backend = Arc::new(MockBackend::new(
636 "mock",
637 vec![
638 MockBehavior::fail(FailKind::Spawn),
639 MockBehavior::fail(FailKind::Spawn),
640 MockBehavior::Success {
641 output: serde_json::Value::Null,
642 tokens: TokenUsage::default(),
643 delay: Duration::ZERO,
644 },
645 ],
646 ));
647 let probe = backend.clone();
648 let sched = sched_with(backend, fast_config(4, 1000));
649 let run_id = Uuid::now_v7();
650 let _rx = sched.init_run(run_id, 64);
651
652 let r = sched.run_agent(run_id, mk_task("x"), None).await;
653 assert!(r.is_ok(), "{r:?}");
654 assert_eq!(probe.call_count(), 3);
655 }
656
657 #[tokio::test]
658 async fn test_no_retry_on_non_retryable() {
659 let backend = Arc::new(MockBackend::new(
660 "mock",
661 vec![MockBehavior::fail(FailKind::Protocol)],
662 ));
663 let probe = backend.clone();
664 let sched = sched_with(backend, fast_config(4, 1000));
665 let run_id = Uuid::now_v7();
666 let _rx = sched.init_run(run_id, 64);
667
668 let r = sched.run_agent(run_id, mk_task("x"), None).await;
669 assert!(matches!(r, Err(SchedulerError::NonRetryable(_))), "{r:?}");
670 assert_eq!(probe.call_count(), 1);
671 }
672
673 #[tokio::test]
674 async fn test_retry_exhausted() {
675 let backend = Arc::new(MockBackend::new(
676 "mock",
677 vec![MockBehavior::fail(FailKind::Spawn)],
678 ));
679 let probe = backend.clone();
680 let sched = sched_with(backend, fast_config(4, 1000));
681 let run_id = Uuid::now_v7();
682 let _rx = sched.init_run(run_id, 64);
683
684 let r = sched.run_agent(run_id, mk_task("x"), None).await;
685 assert!(
686 matches!(r, Err(SchedulerError::Exhausted { attempts: 3, .. })),
687 "{r:?}"
688 );
689 assert_eq!(probe.call_count(), 3);
690 }
691
692 #[tokio::test]
693 async fn test_schema_fallback_then_succeeds() {
694 let backend = Arc::new(MockBackend::new(
695 "mock",
696 vec![
697 MockBehavior::Success {
698 output: fallback_output("i forgot the tool"),
699 tokens: TokenUsage::default(),
700 delay: Duration::ZERO,
701 },
702 MockBehavior::Success {
703 output: serde_json::json!({"answer": "ok"}),
704 tokens: TokenUsage::default(),
705 delay: Duration::ZERO,
706 },
707 ],
708 ));
709 let probe = backend.clone();
710 let sched = sched_with(backend, fast_config(4, 1000));
711 let run_id = Uuid::now_v7();
712 let mut rx = sched.init_run(run_id, 64);
713
714 let task = mk_task_with_schema("respond");
715 let r = sched.run_agent(run_id, task, None).await;
716 assert!(r.is_ok(), "{r:?}");
717 assert_eq!(probe.call_count(), 2);
718
719 let mut prompt_with_feedback = None;
720 while let Ok(event) = rx.try_recv() {
721 if let AgentEvent::AgentDone { prompt, .. } = event {
722 prompt_with_feedback = Some(prompt);
723 }
724 }
725 let prompt = prompt_with_feedback.expect("AgentDone event with prompt");
726 assert!(prompt.contains("structured_output"));
727 assert!(prompt.contains("Required JSON Schema"));
728 }
729
730 #[tokio::test]
731 async fn test_schema_mismatch_then_succeeds() {
732 let backend = Arc::new(MockBackend::new(
733 "mock",
734 vec![
735 MockBehavior::Success {
736 output: serde_json::json!({"wrong": "field"}),
737 tokens: TokenUsage::default(),
738 delay: Duration::ZERO,
739 },
740 MockBehavior::Success {
741 output: serde_json::json!({"answer": "ok"}),
742 tokens: TokenUsage::default(),
743 delay: Duration::ZERO,
744 },
745 ],
746 ));
747 let probe = backend.clone();
748 let sched = sched_with(backend, fast_config(4, 1000));
749 let run_id = Uuid::now_v7();
750 let _rx = sched.init_run(run_id, 64);
751
752 let task = mk_task_with_schema("respond");
753 let r = sched.run_agent(run_id, task, None).await;
754 assert!(r.is_ok(), "{r:?}");
755 assert_eq!(probe.call_count(), 2);
756 }
757
758 #[tokio::test]
759 async fn test_schema_fallback_exhausted() {
760 let backend = Arc::new(MockBackend::new(
761 "mock",
762 vec![MockBehavior::Success {
763 output: fallback_output("still no tool"),
764 tokens: TokenUsage::default(),
765 delay: Duration::ZERO,
766 }],
767 ));
768 let probe = backend.clone();
769 let sched = sched_with(backend, fast_config(4, 1000));
770 let run_id = Uuid::now_v7();
771 let _rx = sched.init_run(run_id, 64);
772
773 let task = mk_task_with_schema("respond");
774 let r = sched.run_agent(run_id, task, None).await;
775 assert!(
776 matches!(r, Err(SchedulerError::SchemaValidation(_))),
777 "{r:?}"
778 );
779 assert_eq!(probe.call_count(), 2);
780 }
781
782 #[tokio::test]
783 async fn test_cancel_run() {
784 let backend = Arc::new(MockBackend::new("mock", vec![MockBehavior::Hang]));
785 let sched = sched_with(backend, fast_config(8, 1000));
786 let run_id = Uuid::now_v7();
787 let _rx = sched.init_run(run_id, 64);
788
789 let s2 = sched.clone();
790 let handle = tokio::spawn(async move {
791 let tasks: Vec<_> = (0..3).map(|i| (mk_task(&format!("h{i}")), None)).collect();
792 s2.run_parallel(run_id, tasks).await
793 });
794 tokio::time::sleep(Duration::from_millis(20)).await;
795 sched.cancel_run(run_id);
796
797 let results = handle.await.unwrap();
798 assert_eq!(results.len(), 3);
799 assert!(results
800 .iter()
801 .all(|r| matches!(r, Err(SchedulerError::RunCancelled))));
802 }
803
804 #[tokio::test]
805 async fn test_cancel_agent() {
806 let backend = Arc::new(MockBackend::new("mock", vec![MockBehavior::Hang]));
807 let sched = sched_with(backend, fast_config(8, 1000));
808 let run_id = Uuid::now_v7();
809 let _rx = sched.init_run(run_id, 64);
810
811 let task = mk_task("hang");
812 let agent_id = task.agent_id;
813 let s2 = sched.clone();
814 let handle = tokio::spawn(async move { s2.run_agent(run_id, task, None).await });
815 tokio::time::sleep(Duration::from_millis(20)).await;
816 sched.cancel_agent(run_id, agent_id);
817
818 let r = handle.await.unwrap();
819 assert!(matches!(r, Err(SchedulerError::AgentCancelled)), "{r:?}");
820 }
821
822 #[tokio::test]
823 async fn test_parallel_partial_failure() {
824 let backend = Arc::new(MockBackend::new(
825 "mock",
826 vec![
827 MockBehavior::Success {
828 output: serde_json::Value::Null,
829 tokens: TokenUsage::default(),
830 delay: Duration::ZERO,
831 },
832 MockBehavior::fail(FailKind::Protocol),
833 MockBehavior::Success {
834 output: serde_json::Value::Null,
835 tokens: TokenUsage::default(),
836 delay: Duration::ZERO,
837 },
838 ],
839 ));
840 let sched = sched_with(backend, fast_config(1, 1000)); let run_id = Uuid::now_v7();
842 let _rx = sched.init_run(run_id, 64);
843
844 let tasks: Vec<_> = (0..3).map(|i| (mk_task(&format!("p{i}")), None)).collect();
845 let results = sched.run_parallel(run_id, tasks).await;
846
847 assert_eq!(results.len(), 3);
848 assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 2);
849 assert_eq!(results.iter().filter(|r| r.is_err()).count(), 1);
850 }
851
852 #[tokio::test]
853 async fn test_event_sequence() {
854 let backend = Arc::new(MockBackend::new(
855 "mock",
856 vec![MockBehavior::Success {
857 output: serde_json::Value::Null,
858 tokens: TokenUsage {
859 input: 10,
860 output: 5,
861 ..Default::default()
862 },
863 delay: Duration::ZERO,
864 }],
865 ));
866 let sched = sched_with(backend, fast_config(4, 1000));
867 let run_id = Uuid::now_v7();
868 let mut rx = sched.init_run(run_id, 64);
869
870 let r = sched.run_agent(run_id, mk_task("x"), None).await;
871 assert!(r.is_ok());
872
873 let e1 = rx.recv().await.unwrap();
874 assert!(matches!(e1, AgentEvent::AgentStarted { .. }), "{e1:?}");
875 let e2 = rx.recv().await.unwrap();
876 match e2 {
877 AgentEvent::AgentDone { status, tokens, .. } => {
878 assert_eq!(status, AgentStatus::Ok);
879 assert_eq!(tokens.input, 10);
880 }
881 other => panic!("expected AgentDone, got {other:?}"),
882 }
883 }
884}