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