返回 CodeWhale
scheduler.rs
根目录 / crates / tui / src / fleet / scheduler.rs
1 //! Fleet scheduler policy: leases, heartbeats, backpressure, and recovery.
2
3 #![allow(dead_code)]
4
5 use std::collections::{BTreeMap, BTreeSet};
6 use std::path::Path;
7 use std::time::Duration;
8
9 use anyhow::{Context, Result, anyhow};
10 use chrono::{DateTime, SecondsFormat, Utc};
11 use codewhale_protocol::fleet::*;
12 use serde_json::Value;
13
14 use super::ledger::{FleetLedger, FleetLedgerState, FleetTaskLedgerStatus, FleetTaskState};
15
16 #[derive(Debug, Clone)]
17 pub struct FleetSchedulerPolicy {
18 pub max_workers_per_run: usize,
19 pub max_workers_per_host: usize,
20 pub max_workers_per_task_class: usize,
21 pub lease_seconds: u64,
22 pub heartbeat_timeout: Duration,
23 }
24
25 impl Default for FleetSchedulerPolicy {
26 fn default() -> Self {
27 Self {
28 max_workers_per_run: 4,
29 max_workers_per_host: 4,
30 max_workers_per_task_class: 4,
31 lease_seconds: 300,
32 heartbeat_timeout: Duration::from_secs(120),
33 }
34 }
35 }
36
37 #[derive(Debug, Clone, Default, PartialEq, Eq)]
38 pub struct FleetSchedulerReport {
39 pub launched: usize,
40 pub heartbeats: usize,
41 pub marked_stale: usize,
42 pub restarted: usize,
43 pub failed: usize,
44 pub cancelled: usize,
45 pub alerts: usize,
46 }
47
48 #[derive(Debug)]
49 pub struct FleetScheduler {
50 ledger: FleetLedger,
51 policy: FleetSchedulerPolicy,
52 now: DateTime<Utc>,
53 }
54
55 impl FleetScheduler {
56 pub fn open(workspace: impl AsRef<Path>, policy: FleetSchedulerPolicy) -> Result<Self> {
57 Ok(Self {
58 ledger: FleetLedger::open(workspace.as_ref())?,
59 policy,
60 now: Utc::now(),
61 })
62 }
63
64 pub fn set_now(&mut self, now: DateTime<Utc>) {
65 self.now = now;
66 }
67
68 #[cfg(test)]
69 pub fn tick_run(&self, run_id: &FleetRunId) -> Result<FleetSchedulerReport> {
70 let mut report = FleetSchedulerReport::default();
71 self.recover_unhealthy_work(run_id, &mut report, &mut |_, _, _, _| Ok(()))?;
72 self.launch_queued_work(run_id, &mut report)?;
73 self.refresh_run_status(run_id)?;
74 Ok(report)
75 }
76
77 /// Resume reconciliation after a manager restart: detect orphaned/stale
78 /// in-flight leases left by a prior process and apply retry/escalation
79 /// policy, then recompute run status.
80 ///
81 /// Unlike `tick_run`, this launches no new queued work and does not
82 /// re-process tasks that already reached a terminal state, so it is safe
83 /// and idempotent to call on a fresh process: a task re-leased by an
84 /// earlier resume is no longer stale at the same instant, and a terminally
85 /// failed task is never failed or escalated twice.
86 #[cfg(test)]
87 pub fn resume_run(&self, run_id: &FleetRunId) -> Result<FleetSchedulerReport> {
88 self.resume_run_with_restart_callback(run_id, |_, _, _, _| Ok(()))
89 }
90
91 pub(crate) fn resume_run_with_restart_callback(
92 &self,
93 run_id: &FleetRunId,
94 mut on_restarting: impl FnMut(
95 &FleetLedgerState,
96 &FleetTaskState,
97 &FleetTaskSpec,
98 &str,
99 ) -> Result<()>,
100 ) -> Result<FleetSchedulerReport> {
101 let mut report = FleetSchedulerReport::default();
102 self.reconcile_stale_leases(run_id, &mut report, &mut on_restarting)?;
103 self.refresh_run_status(run_id)?;
104 Ok(report)
105 }
106
107 pub fn cancel_run(&self, run_id: &FleetRunId, reason: &str) -> Result<FleetSchedulerReport> {
108 let state = self.ledger.rebuild_state()?;
109 let mut report = FleetSchedulerReport::default();
110 for task in state
111 .tasks
112 .values()
113 .filter(|task| task.entry.run_id == *run_id)
114 {
115 if !matches!(
116 task.status,
117 FleetTaskLedgerStatus::Enqueued | FleetTaskLedgerStatus::Leased
118 ) {
119 continue;
120 }
121 if self.ledger.cancel_task_if_active(
122 &task.entry.run_id,
123 &task.entry.task_id,
124 None,
125 &self.timestamp(),
126 Some(reason),
127 Some("scheduler"),
128 )? {
129 report.cancelled += 1;
130 }
131 }
132 self.ledger
133 .update_run_status(run_id, FleetRunStatus::Cancelled, &self.timestamp())?;
134 Ok(report)
135 }
136
137 fn recover_unhealthy_work(
138 &self,
139 run_id: &FleetRunId,
140 report: &mut FleetSchedulerReport,
141 on_restarting: &mut dyn FnMut(
142 &FleetLedgerState,
143 &FleetTaskState,
144 &FleetTaskSpec,
145 &str,
146 ) -> Result<()>,
147 ) -> Result<()> {
148 let state = self.ledger.rebuild_state()?;
149 for task in state
150 .tasks
151 .values()
152 .filter(|task| task.entry.run_id == *run_id)
153 {
154 let Some(task_spec) = task_spec_for(&state, task) else {
155 continue;
156 };
157 match task.status {
158 FleetTaskLedgerStatus::Leased if self.task_is_stale(task, &state) => {
159 let worker_id = task
160 .leased_to
161 .clone()
162 .unwrap_or_else(|| "fleet-scheduler".to_string());
163 let heartbeat_at = state
164 .heartbeats
165 .get(&worker_id)
166 .map(|heartbeat| heartbeat.timestamp.as_str());
167 let latest_seq = state
168 .latest_seq
169 .get(&format!(
170 "{}:{}:{}",
171 worker_id, task.entry.run_id.0, task.entry.task_id
172 ))
173 .copied()
174 .unwrap_or(0);
175 let Some(stale_event) = self.ledger.append_event_if_lease_unchanged(
176 &task.entry.run_id,
177 &worker_id,
178 &task.entry.task_id,
179 task.entry.attempts,
180 latest_seq,
181 heartbeat_at,
182 &self.timestamp(),
183 FleetWorkerEventPayload::Stale {
184 last_heartbeat_at: heartbeat_at.map(str::to_string),
185 },
186 )?
187 else {
188 continue;
189 };
190 report.marked_stale += 1;
191 self.retry_or_fail(
192 &state,
193 task,
194 &task_spec,
195 &worker_id,
196 stale_event.seq,
197 heartbeat_at,
198 report,
199 on_restarting,
200 )
201 .with_context(|| format!("recovering stale task {}", task.entry.task_id))?;
202 }
203 FleetTaskLedgerStatus::Failed => {
204 let worker_id = task
205 .leased_to
206 .clone()
207 .unwrap_or_else(|| "fleet-scheduler".to_string());
208 let latest_seq = state
209 .latest_seq
210 .get(&format!(
211 "{}:{}:{}",
212 worker_id, task.entry.run_id.0, task.entry.task_id
213 ))
214 .copied()
215 .unwrap_or(0);
216 let heartbeat_at = state
217 .heartbeats
218 .get(&worker_id)
219 .map(|heartbeat| heartbeat.timestamp.as_str());
220 self.retry_or_fail(
221 &state,
222 task,
223 &task_spec,
224 &worker_id,
225 latest_seq,
226 heartbeat_at,
227 report,
228 on_restarting,
229 )
230 .with_context(|| format!("recovering failed task {}", task.entry.task_id))?;
231 }
232 _ => {}
233 }
234 }
235 Ok(())
236 }
237
238 /// Reconcile only orphaned/stale in-flight leases (the restart-recovery
239 /// subset of `recover_unhealthy_work`): a `Leased` task whose worker has
240 /// stopped heartbeating is marked stale and routed through the shared
241 /// retry/escalation budget. Terminal and healthy tasks are left untouched,
242 /// which keeps `resume_run` idempotent.
243 fn reconcile_stale_leases(
244 &self,
245 run_id: &FleetRunId,
246 report: &mut FleetSchedulerReport,
247 on_restarting: &mut dyn FnMut(
248 &FleetLedgerState,
249 &FleetTaskState,
250 &FleetTaskSpec,
251 &str,
252 ) -> Result<()>,
253 ) -> Result<()> {
254 let state = self.ledger.rebuild_state()?;
255 for task in state
256 .tasks
257 .values()
258 .filter(|task| task.entry.run_id == *run_id)
259 {
260 if !matches!(task.status, FleetTaskLedgerStatus::Leased)
261 || !self.task_is_stale(task, &state)
262 {
263 continue;
264 }
265 let Some(task_spec) = task_spec_for(&state, task) else {
266 continue;
267 };
268 let worker_id = task
269 .leased_to
270 .clone()
271 .unwrap_or_else(|| "fleet-scheduler".to_string());
272 let heartbeat_at = state
273 .heartbeats
274 .get(&worker_id)
275 .map(|heartbeat| heartbeat.timestamp.as_str());
276 let latest_seq = state
277 .latest_seq
278 .get(&format!(
279 "{}:{}:{}",
280 worker_id, task.entry.run_id.0, task.entry.task_id
281 ))
282 .copied()
283 .unwrap_or(0);
284 let Some(stale_event) = self.ledger.append_event_if_lease_unchanged(
285 &task.entry.run_id,
286 &worker_id,
287 &task.entry.task_id,
288 task.entry.attempts,
289 latest_seq,
290 heartbeat_at,
291 &self.timestamp(),
292 FleetWorkerEventPayload::Stale {
293 last_heartbeat_at: heartbeat_at.map(str::to_string),
294 },
295 )?
296 else {
297 continue;
298 };
299 report.marked_stale += 1;
300 self.retry_or_fail(
301 &state,
302 task,
303 &task_spec,
304 &worker_id,
305 stale_event.seq,
306 heartbeat_at,
307 report,
308 on_restarting,
309 )
310 .with_context(|| format!("resuming stale task {}", task.entry.task_id))?;
311 }
312 Ok(())
313 }
314
315 #[allow(clippy::too_many_arguments)]
316 fn retry_or_fail(
317 &self,
318 state: &FleetLedgerState,
319 task: &FleetTaskState,
320 task_spec: &FleetTaskSpec,
321 worker_id: &str,
322 expected_latest_seq: u64,
323 expected_heartbeat_at: Option<&str>,
324 report: &mut FleetSchedulerReport,
325 on_restarting: &mut dyn FnMut(
326 &FleetLedgerState,
327 &FleetTaskState,
328 &FleetTaskSpec,
329 &str,
330 ) -> Result<()>,
331 ) -> Result<()> {
332 let retry_policy = task_spec.retry_policy.clone().unwrap_or_default();
333 if task.entry.attempts < retry_policy.max_attempts {
334 let lease_expires_at = self.lease_expires_at();
335 if !self.ledger.restart_task_if_unchanged_with_callback(
336 &task.entry.run_id,
337 &task.entry.task_id,
338 worker_id,
339 task.status,
340 task.entry.attempts,
341 expected_latest_seq,
342 expected_heartbeat_at,
343 &self.timestamp(),
344 Some(&lease_expires_at),
345 task.entry.attempts,
346 || on_restarting(state, task, task_spec, worker_id),
347 )? {
348 return Ok(());
349 }
350 report.restarted += 1;
351 return Ok(());
352 }
353
354 if task.status == FleetTaskLedgerStatus::Failed {
355 report.alerts += self.record_alerts(
356 &task.entry.run_id,
357 &task.entry.task_id,
358 worker_id,
359 task.entry.attempts,
360 task_spec,
361 FleetAlertEventClass::RestartExhausted,
362 )?;
363 return Ok(());
364 }
365
366 let terminal = self.ledger.append_terminal_event_if_lease_unchanged(
367 &task.entry.run_id,
368 worker_id,
369 &task.entry.task_id,
370 task.entry.attempts,
371 expected_latest_seq,
372 expected_heartbeat_at,
373 &self.timestamp(),
374 FleetWorkerEventPayload::Failed {
375 reason: format!(
376 "retry attempts exhausted after {} attempt(s)",
377 task.entry.attempts
378 ),
379 recoverable: false,
380 },
381 )?;
382 if terminal.is_none() {
383 return Ok(());
384 }
385 report.failed += 1;
386 report.alerts += self.record_alerts(
387 &task.entry.run_id,
388 &task.entry.task_id,
389 worker_id,
390 task.entry.attempts,
391 task_spec,
392 FleetAlertEventClass::RestartExhausted,
393 )?;
394 Ok(())
395 }
396
397 fn launch_queued_work(
398 &self,
399 run_id: &FleetRunId,
400 report: &mut FleetSchedulerReport,
401 ) -> Result<()> {
402 loop {
403 let state = self.ledger.rebuild_state()?;
404 let run = state
405 .runs
406 .get(&run_id.0)
407 .ok_or_else(|| anyhow!("Fleet run {} does not exist", run_id.0))?;
408 let active = active_tasks_for_run(&state, run_id);
409 if active.len() >= self.policy.max_workers_per_run {
410 return Ok(());
411 }
412 let counts = active_counts(&state, run);
413 let Some((worker_id, task)) = self.next_launch(run, &state, &counts) else {
414 return Ok(());
415 };
416 let lease_expires_at = self.lease_expires_at();
417 if !self.ledger.start_task_if_enqueued(
418 &task.entry.run_id,
419 &task.entry.task_id,
420 &worker_id,
421 &self.timestamp(),
422 Some(&lease_expires_at),
423 Some(self.policy.max_workers_per_run),
424 vec![
425 FleetWorkerEventPayload::Leased {
426 lease_expires_at: Some(lease_expires_at.clone()),
427 },
428 FleetWorkerEventPayload::Starting,
429 FleetWorkerEventPayload::Running,
430 ],
431 || Ok(()),
432 )? {
433 continue;
434 }
435 report.launched += 1;
436 report.heartbeats += 1;
437 }
438 }
439
440 fn next_launch(
441 &self,
442 run: &FleetRun,
443 state: &FleetLedgerState,
444 counts: &ActiveCounts,
445 ) -> Option<(String, FleetTaskState)> {
446 let active_workers: BTreeSet<_> = active_tasks_for_run(state, &run.id)
447 .into_iter()
448 .filter_map(|task| task.leased_to)
449 .collect();
450 let mut queued: Vec<_> = state
451 .tasks
452 .values()
453 .filter(|task| {
454 task.entry.run_id == run.id
455 && matches!(task.status, FleetTaskLedgerStatus::Enqueued)
456 })
457 .cloned()
458 .collect();
459 queued.sort_by_key(|task| {
460 (
461 task.entry.priority,
462 task.entry.enqueued_at.clone(),
463 task.entry.task_id.clone(),
464 )
465 });
466 for task in queued {
467 let task_spec = run
468 .task_specs
469 .iter()
470 .find(|spec| spec.id == task.entry.task_id)?;
471 let task_class = task_class(task_spec);
472 if counts.by_task_class.get(&task_class).copied().unwrap_or(0)
473 >= self.policy.max_workers_per_task_class
474 {
475 continue;
476 }
477 for worker in &run.worker_specs {
478 if active_workers.contains(&worker.id) {
479 continue;
480 }
481 let host_key = host_key(worker);
482 if counts.by_host.get(&host_key).copied().unwrap_or(0)
483 >= self.policy.max_workers_per_host
484 {
485 continue;
486 }
487 return Some((worker.id.clone(), task));
488 }
489 }
490 None
491 }
492
493 fn task_is_stale(&self, task: &FleetTaskState, state: &FleetLedgerState) -> bool {
494 if let Some(worker_id) = task.leased_to.as_deref()
495 && let Some(heartbeat) = state.heartbeats.get(worker_id)
496 && let Ok(last) = DateTime::parse_from_rfc3339(&heartbeat.timestamp)
497 {
498 let age = self.now.signed_duration_since(last.with_timezone(&Utc));
499 return age
500 .to_std()
501 .map_or(true, |age| age > self.policy.heartbeat_timeout);
502 }
503 if let Some(deadline) = task.entry.lease_deadline.as_deref()
504 && let Ok(deadline) = DateTime::parse_from_rfc3339(deadline)
505 {
506 return self.now > deadline.with_timezone(&Utc);
507 }
508 true
509 }
510
511 fn record_alerts(
512 &self,
513 run_id: &FleetRunId,
514 task_id: &str,
515 worker_id: &str,
516 expected_attempts: u32,
517 task_spec: &FleetTaskSpec,
518 event_class: FleetAlertEventClass,
519 ) -> Result<usize> {
520 let Some(policy) = &task_spec.alert_policy else {
521 return Ok(0);
522 };
523 if !alert_policy_matches(policy, event_class) {
524 return Ok(0);
525 }
526 let mut count = 0;
527 for (channel_index, channel) in policy.channels.iter().enumerate() {
528 let label = alert_channel_label(channel);
529 // A policy may contain multiple endpoints of the same kind. The
530 // run snapshot preserves channel order while redacting secrets, so
531 // a kind + ordinal key is stable, non-secret, and instance-unique.
532 let channel_key = format!("{label}#{channel_index}");
533 if self.ledger.record_failed_attempt_alert_once(
534 run_id,
535 task_id,
536 worker_id,
537 expected_attempts,
538 label,
539 &channel_key,
540 &self.timestamp(),
541 )? {
542 count += 1;
543 }
544 }
545 Ok(count)
546 }
547
548 fn refresh_run_status(&self, run_id: &FleetRunId) -> Result<()> {
549 let state = self.ledger.rebuild_state()?;
550 let mut has_open = false;
551 let mut has_failed = false;
552 let mut has_cancelled = false;
553 for task in state
554 .tasks
555 .values()
556 .filter(|task| task.entry.run_id == *run_id)
557 {
558 match task.status {
559 FleetTaskLedgerStatus::Enqueued | FleetTaskLedgerStatus::Leased => has_open = true,
560 FleetTaskLedgerStatus::Failed => has_failed = true,
561 FleetTaskLedgerStatus::Cancelled => has_cancelled = true,
562 FleetTaskLedgerStatus::Completed => {}
563 }
564 }
565 let status = if has_open {
566 FleetRunStatus::Running
567 } else if has_failed {
568 FleetRunStatus::Failed
569 } else if has_cancelled {
570 FleetRunStatus::Cancelled
571 } else {
572 FleetRunStatus::Completed
573 };
574 self.ledger
575 .update_run_status(run_id, status, &self.timestamp())
576 }
577
578 fn append_worker_event(
579 &self,
580 run_id: &FleetRunId,
581 worker_id: &str,
582 task_id: &str,
583 payload: FleetWorkerEventPayload,
584 ) -> Result<FleetWorkerEvent> {
585 self.ledger
586 .append_event_next_seq(run_id, worker_id, task_id, &self.timestamp(), payload)
587 }
588
589 fn timestamp(&self) -> String {
590 self.now.to_rfc3339_opts(SecondsFormat::Secs, true)
591 }
592
593 fn lease_expires_at(&self) -> String {
594 (self.now + chrono::Duration::seconds(self.policy.lease_seconds as i64))
595 .to_rfc3339_opts(SecondsFormat::Secs, true)
596 }
597 }
598
599 #[derive(Debug, Default)]
600 struct ActiveCounts {
601 by_host: BTreeMap<String, usize>,
602 by_task_class: BTreeMap<String, usize>,
603 }
604
605 fn active_counts(state: &FleetLedgerState, run: &FleetRun) -> ActiveCounts {
606 let mut counts = ActiveCounts::default();
607 for task in active_tasks_for_run(state, &run.id) {
608 if let Some(worker_id) = task.leased_to.as_deref()
609 && let Some(worker) = run
610 .worker_specs
611 .iter()
612 .find(|worker| worker.id == worker_id)
613 {
614 *counts.by_host.entry(host_key(worker)).or_default() += 1;
615 }
616 if let Some(task_spec) = run
617 .task_specs
618 .iter()
619 .find(|spec| spec.id == task.entry.task_id)
620 {
621 *counts
622 .by_task_class
623 .entry(task_class(task_spec))
624 .or_default() += 1;
625 }
626 }
627 counts
628 }
629
630 fn active_tasks_for_run(state: &FleetLedgerState, run_id: &FleetRunId) -> Vec<FleetTaskState> {
631 state
632 .tasks
633 .values()
634 .filter(|task| {
635 task.entry.run_id == *run_id && matches!(task.status, FleetTaskLedgerStatus::Leased)
636 })
637 .cloned()
638 .collect()
639 }
640
641 fn task_spec_for(state: &FleetLedgerState, task: &FleetTaskState) -> Option<FleetTaskSpec> {
642 state
643 .runs
644 .get(&task.entry.run_id.0)?
645 .task_specs
646 .iter()
647 .find(|spec| spec.id == task.entry.task_id)
648 .cloned()
649 }
650
651 fn host_key(worker: &FleetWorkerSpec) -> String {
652 match &worker.host {
653 FleetHostSpec::Local => "local".to_string(),
654 FleetHostSpec::Ssh { host, .. } => format!("ssh:{host}"),
655 FleetHostSpec::Docker { image, .. } => format!("docker:{image}"),
656 }
657 }
658
659 fn task_class(task: &FleetTaskSpec) -> String {
660 task.metadata
661 .get("class")
662 .and_then(Value::as_str)
663 .filter(|value| !value.trim().is_empty())
664 .unwrap_or("default")
665 .to_string()
666 }
667
668 fn alert_channel_label(channel: &FleetAlertChannel) -> &'static str {
669 match channel {
670 FleetAlertChannel::Slack { .. } => "slack",
671 FleetAlertChannel::Webhook { .. } => "webhook",
672 FleetAlertChannel::PagerDuty { .. } => "pagerduty",
673 }
674 }
675
676 fn alert_policy_matches(policy: &FleetAlertPolicy, class: FleetAlertEventClass) -> bool {
677 policy.events.is_empty() || policy.events.contains(&class)
678 }
679
680 #[cfg(test)]
681 mod tests {
682 use super::*;
683 use std::sync::{Arc, Barrier};
684 use std::thread;
685 use tempfile::TempDir;
686
687 fn base_now() -> DateTime<Utc> {
688 DateTime::parse_from_rfc3339("2026-06-13T01:00:00Z")
689 .unwrap()
690 .with_timezone(&Utc)
691 }
692
693 fn scheduler(tmp: &TempDir, max_workers: usize) -> FleetScheduler {
694 let mut scheduler = FleetScheduler::open(
695 tmp.path(),
696 FleetSchedulerPolicy {
697 max_workers_per_run: max_workers,
698 max_workers_per_host: max_workers,
699 max_workers_per_task_class: max_workers,
700 lease_seconds: 30,
701 heartbeat_timeout: Duration::from_secs(5),
702 },
703 )
704 .unwrap();
705 scheduler.set_now(base_now());
706 scheduler
707 }
708
709 fn worker(id: &str) -> FleetWorkerSpec {
710 FleetWorkerSpec {
711 id: id.to_string(),
712 name: id.to_string(),
713 host: FleetHostSpec::Local,
714 trust_level: Some(FleetTrustLevel::Local),
715 labels: BTreeMap::new(),
716 capabilities: vec!["local".to_string()],
717 max_concurrent_tasks: Some(1),
718 }
719 }
720
721 fn task(id: &str, max_attempts: u32) -> FleetTaskSpec {
722 FleetTaskSpec {
723 id: id.to_string(),
724 name: id.to_string(),
725 description: None,
726 objective: Some(format!("Schedule {id}")),
727 instructions: format!("do {id}"),
728 worker: None,
729 workspace: None,
730 input_files: Vec::new(),
731 context: Vec::new(),
732 budget: None,
733 tags: Vec::new(),
734 expected_artifacts: vec![FleetArtifactKind::Log],
735 scorer: None,
736 retry_policy: Some(FleetRetryPolicy {
737 max_attempts,
738 ..FleetRetryPolicy::default()
739 }),
740 alert_policy: None,
741 timeout_seconds: None,
742 metadata: BTreeMap::new(),
743 }
744 }
745
746 fn create_run(
747 scheduler: &FleetScheduler,
748 run_id: &str,
749 tasks: Vec<FleetTaskSpec>,
750 workers: usize,
751 ) {
752 let run_id = FleetRunId::from(run_id);
753 scheduler
754 .ledger
755 .create_run(&FleetRun {
756 id: run_id.clone(),
757 name: "scheduler smoke".to_string(),
758 status: FleetRunStatus::Queued,
759 target: None,
760 workflow: None,
761 roles: Vec::new(),
762 max_workers: Some(workers),
763 usage_ceiling: None,
764 task_specs: tasks.clone(),
765 worker_specs: (1..=workers)
766 .map(|idx| worker(&format!("worker-{idx}")))
767 .collect(),
768 labels: BTreeMap::new(),
769 security_policy: None,
770 created_at: scheduler.timestamp(),
771 updated_at: None,
772 completed_at: None,
773 })
774 .unwrap();
775 for task in tasks {
776 scheduler
777 .ledger
778 .enqueue(FleetInboxEntry {
779 run_id: run_id.clone(),
780 task_id: task.id,
781 priority: 0,
782 enqueued_at: scheduler.timestamp(),
783 lease_deadline: None,
784 attempts: 0,
785 })
786 .unwrap();
787 }
788 }
789
790 fn ledger_text(scheduler: &FleetScheduler) -> String {
791 std::fs::read_to_string(scheduler.ledger.path()).unwrap()
792 }
793
794 #[test]
795 fn fleet_scheduler_backpressure_prevents_over_launch() {
796 let tmp = TempDir::new().unwrap();
797 let scheduler = scheduler(&tmp, 2);
798 create_run(
799 &scheduler,
800 "run-1",
801 vec![task("task-a", 3), task("task-b", 3), task("task-c", 3)],
802 3,
803 );
804
805 let report = scheduler.tick_run(&FleetRunId::from("run-1")).unwrap();
806
807 assert_eq!(report.launched, 2);
808 let state = scheduler.ledger.rebuild_state().unwrap();
809 assert_eq!(
810 state.tasks["run-1:task-a"].status,
811 FleetTaskLedgerStatus::Leased
812 );
813 assert_eq!(
814 state.tasks["run-1:task-b"].status,
815 FleetTaskLedgerStatus::Leased
816 );
817 assert_eq!(
818 state.tasks["run-1:task-c"].status,
819 FleetTaskLedgerStatus::Enqueued
820 );
821 }
822
823 #[test]
824 fn fleet_scheduler_lost_heartbeat_restarts_within_retry_limit() {
825 let tmp = TempDir::new().unwrap();
826 let mut scheduler = scheduler(&tmp, 1);
827 create_run(&scheduler, "run-1", vec![task("task-a", 2)], 1);
828 scheduler.tick_run(&FleetRunId::from("run-1")).unwrap();
829 scheduler.set_now(base_now() + chrono::Duration::seconds(10));
830
831 let report = scheduler.tick_run(&FleetRunId::from("run-1")).unwrap();
832
833 assert_eq!(report.marked_stale, 1);
834 assert_eq!(report.restarted, 1);
835 let state = scheduler.ledger.rebuild_state().unwrap();
836 let task = &state.tasks["run-1:task-a"];
837 assert_eq!(task.status, FleetTaskLedgerStatus::Leased);
838 assert_eq!(task.entry.attempts, 2);
839 let ledger = ledger_text(&scheduler);
840 assert!(ledger.contains("\"state\":\"stale\""));
841 assert!(ledger.contains("\"state\":\"restarted\""));
842 }
843
844 #[test]
845 fn fleet_scheduler_restart_exhaustion_records_terminal_failure_and_alert() {
846 let tmp = TempDir::new().unwrap();
847 let mut scheduler = scheduler(&tmp, 1);
848 let mut failing = task("task-a", 1);
849 failing.alert_policy = Some(FleetAlertPolicy {
850 events: vec![FleetAlertEventClass::RestartExhausted],
851 channels: vec![
852 FleetAlertChannel::Slack {
853 webhook: FleetAlertEndpoint::inline("https://hooks.slack.invalid/secret-a"),
854 },
855 FleetAlertChannel::Slack {
856 webhook: FleetAlertEndpoint::inline("https://hooks.slack.invalid/secret-b"),
857 },
858 ],
859 after_attempts: Some(1),
860 after_minutes_stale: Some(1),
861 });
862 create_run(&scheduler, "run-1", vec![failing], 1);
863 scheduler.tick_run(&FleetRunId::from("run-1")).unwrap();
864 scheduler.set_now(base_now() + chrono::Duration::seconds(10));
865
866 let report = scheduler.tick_run(&FleetRunId::from("run-1")).unwrap();
867
868 assert_eq!(report.marked_stale, 1);
869 assert_eq!(report.restarted, 0);
870 assert_eq!(report.failed, 1);
871 assert_eq!(report.alerts, 2);
872 let state = scheduler.ledger.rebuild_state().unwrap();
873 assert_eq!(
874 state.tasks["run-1:task-a"].status,
875 FleetTaskLedgerStatus::Failed
876 );
877 assert_eq!(state.alerts.len(), 2);
878 assert_eq!(state.escalated_events.len(), 1);
879 let ledger = ledger_text(&scheduler);
880 assert!(ledger.contains("\"state\":\"failed\""));
881 assert!(ledger.contains("\"record\":\"alert_sent\""));
882 assert_eq!(
883 ledger
884 .lines()
885 .filter(|line| line.contains("\"record\":\"alert_sent\""))
886 .count(),
887 2
888 );
889 assert!(!ledger.contains("hooks.slack.invalid/secret"));
890 }
891
892 #[test]
893 fn exhausted_failed_attempt_alerts_exactly_once_across_competing_schedulers() {
894 let tmp = TempDir::new().unwrap();
895 let owner = scheduler(&tmp, 1);
896 let mut failing = task("task-a", 1);
897 failing.alert_policy = Some(FleetAlertPolicy {
898 events: vec![FleetAlertEventClass::RestartExhausted],
899 channels: vec![FleetAlertChannel::Slack {
900 webhook: FleetAlertEndpoint::inline("https://hooks.slack.invalid/secret"),
901 }],
902 after_attempts: Some(1),
903 after_minutes_stale: Some(1),
904 });
905 create_run(&owner, "run-1", vec![failing], 1);
906 owner.tick_run(&FleetRunId::from("run-1")).unwrap();
907 owner
908 .ledger
909 .append_terminal_event_if_leased(
910 &FleetRunId::from("run-1"),
911 "worker-1",
912 "task-a",
913 1,
914 &owner.timestamp(),
915 FleetWorkerEventPayload::Failed {
916 reason: "worker failed before scheduler recovery".to_string(),
917 recoverable: true,
918 },
919 )
920 .unwrap()
921 .unwrap();
922
923 let root = tmp.path().to_path_buf();
924 let barrier = Arc::new(Barrier::new(2));
925 let handles = (0..2)
926 .map(|_| {
927 let root = root.clone();
928 let barrier = Arc::clone(&barrier);
929 thread::spawn(move || {
930 let mut contender = FleetScheduler::open(
931 root,
932 FleetSchedulerPolicy {
933 max_workers_per_run: 1,
934 max_workers_per_host: 1,
935 max_workers_per_task_class: 1,
936 lease_seconds: 30,
937 heartbeat_timeout: Duration::from_secs(5),
938 },
939 )
940 .unwrap();
941 contender.set_now(base_now() + chrono::Duration::seconds(10));
942 barrier.wait();
943 contender.tick_run(&FleetRunId::from("run-1")).unwrap()
944 })
945 })
946 .collect::<Vec<_>>();
947 let reports = handles
948 .into_iter()
949 .map(|handle| handle.join().unwrap())
950 .collect::<Vec<_>>();
951
952 assert_eq!(reports.iter().map(|report| report.alerts).sum::<usize>(), 1);
953 assert_eq!(reports.iter().map(|report| report.failed).sum::<usize>(), 0);
954 let state = owner.ledger.rebuild_state().unwrap();
955 assert_eq!(state.alerts.len(), 1);
956 assert_eq!(state.escalated_events.len(), 1);
957 assert_eq!(
958 ledger_text(&owner)
959 .lines()
960 .filter(|line| line.contains("\"record\":\"alert_sent\""))
961 .count(),
962 1
963 );
964
965 owner.ledger.compact().unwrap();
966 let mut after_compaction = scheduler(&tmp, 1);
967 after_compaction.set_now(base_now() + chrono::Duration::seconds(20));
968 let report = after_compaction
969 .tick_run(&FleetRunId::from("run-1"))
970 .unwrap();
971 assert_eq!(report.alerts, 0);
972 assert_eq!(
973 after_compaction
974 .ledger
975 .rebuild_state()
976 .unwrap()
977 .alerts
978 .len(),
979 1
980 );
981 }
982
983 #[test]
984 fn fleet_scheduler_slow_provider_response_with_fresh_heartbeat_is_not_stale() {
985 let tmp = TempDir::new().unwrap();
986 let mut scheduler = scheduler(&tmp, 1);
987 create_run(&scheduler, "run-1", vec![task("task-a", 2)], 1);
988 scheduler.tick_run(&FleetRunId::from("run-1")).unwrap();
989 scheduler.set_now(base_now() + chrono::Duration::seconds(4));
990 scheduler
991 .append_worker_event(
992 &FleetRunId::from("run-1"),
993 "worker-1",
994 "task-a",
995 FleetWorkerEventPayload::ModelWait {
996 model: Some("deepseek-v4-pro".to_string()),
997 },
998 )
999 .unwrap();
1000 scheduler
1001 .ledger
1002 .heartbeat("worker-1", &scheduler.timestamp(), None, None)
1003 .unwrap();
1004
1005 let report = scheduler.tick_run(&FleetRunId::from("run-1")).unwrap();
1006
1007 assert_eq!(report.marked_stale, 0);
1008 assert_eq!(report.restarted, 0);
1009 let state = scheduler.ledger.rebuild_state().unwrap();
1010 assert_eq!(state.tasks["run-1:task-a"].entry.attempts, 1);
1011 assert_eq!(state.workers["worker-1"], FleetWorkerStatus::Busy);
1012 }
1013
1014 #[test]
1015 fn fleet_scheduler_cancel_run_interrupts_active_and_cancels_queued() {
1016 let tmp = TempDir::new().unwrap();
1017 let scheduler = scheduler(&tmp, 1);
1018 create_run(
1019 &scheduler,
1020 "run-1",
1021 vec![task("task-a", 3), task("task-b", 3), task("task-c", 3)],
1022 2,
1023 );
1024 scheduler.tick_run(&FleetRunId::from("run-1")).unwrap();
1025
1026 let report = scheduler
1027 .cancel_run(&FleetRunId::from("run-1"), "operator")
1028 .unwrap();
1029
1030 assert_eq!(report.cancelled, 3);
1031 let state = scheduler.ledger.rebuild_state().unwrap();
1032 for task in state.tasks.values() {
1033 assert_eq!(task.status, FleetTaskLedgerStatus::Cancelled);
1034 }
1035 let ledger = ledger_text(&scheduler);
1036 assert!(ledger.contains("\"state\":\"interrupted\""));
1037 assert!(ledger.contains("\"state\":\"cancelled\""));
1038 }
1039 }
1040
1040 lines RUST