返回 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 task_specs: tasks.clone(),
764 worker_specs: (1..=workers)
765 .map(|idx| worker(&format!("worker-{idx}")))
766 .collect(),
767 labels: BTreeMap::new(),
768 security_policy: None,
769 created_at: scheduler.timestamp(),
770 updated_at: None,
771 completed_at: None,
772 })
773 .unwrap();
774 for task in tasks {
775 scheduler
776 .ledger
777 .enqueue(FleetInboxEntry {
778 run_id: run_id.clone(),
779 task_id: task.id,
780 priority: 0,
781 enqueued_at: scheduler.timestamp(),
782 lease_deadline: None,
783 attempts: 0,
784 })
785 .unwrap();
786 }
787 }
788
789 fn ledger_text(scheduler: &FleetScheduler) -> String {
790 std::fs::read_to_string(scheduler.ledger.path()).unwrap()
791 }
792
793 #[test]
794 fn fleet_scheduler_backpressure_prevents_over_launch() {
795 let tmp = TempDir::new().unwrap();
796 let scheduler = scheduler(&tmp, 2);
797 create_run(
798 &scheduler,
799 "run-1",
800 vec![task("task-a", 3), task("task-b", 3), task("task-c", 3)],
801 3,
802 );
803
804 let report = scheduler.tick_run(&FleetRunId::from("run-1")).unwrap();
805
806 assert_eq!(report.launched, 2);
807 let state = scheduler.ledger.rebuild_state().unwrap();
808 assert_eq!(
809 state.tasks["run-1:task-a"].status,
810 FleetTaskLedgerStatus::Leased
811 );
812 assert_eq!(
813 state.tasks["run-1:task-b"].status,
814 FleetTaskLedgerStatus::Leased
815 );
816 assert_eq!(
817 state.tasks["run-1:task-c"].status,
818 FleetTaskLedgerStatus::Enqueued
819 );
820 }
821
822 #[test]
823 fn fleet_scheduler_lost_heartbeat_restarts_within_retry_limit() {
824 let tmp = TempDir::new().unwrap();
825 let mut scheduler = scheduler(&tmp, 1);
826 create_run(&scheduler, "run-1", vec![task("task-a", 2)], 1);
827 scheduler.tick_run(&FleetRunId::from("run-1")).unwrap();
828 scheduler.set_now(base_now() + chrono::Duration::seconds(10));
829
830 let report = scheduler.tick_run(&FleetRunId::from("run-1")).unwrap();
831
832 assert_eq!(report.marked_stale, 1);
833 assert_eq!(report.restarted, 1);
834 let state = scheduler.ledger.rebuild_state().unwrap();
835 let task = &state.tasks["run-1:task-a"];
836 assert_eq!(task.status, FleetTaskLedgerStatus::Leased);
837 assert_eq!(task.entry.attempts, 2);
838 let ledger = ledger_text(&scheduler);
839 assert!(ledger.contains("\"state\":\"stale\""));
840 assert!(ledger.contains("\"state\":\"restarted\""));
841 }
842
843 #[test]
844 fn fleet_scheduler_restart_exhaustion_records_terminal_failure_and_alert() {
845 let tmp = TempDir::new().unwrap();
846 let mut scheduler = scheduler(&tmp, 1);
847 let mut failing = task("task-a", 1);
848 failing.alert_policy = Some(FleetAlertPolicy {
849 events: vec![FleetAlertEventClass::RestartExhausted],
850 channels: vec![
851 FleetAlertChannel::Slack {
852 webhook: FleetAlertEndpoint::inline("https://hooks.slack.invalid/secret-a"),
853 },
854 FleetAlertChannel::Slack {
855 webhook: FleetAlertEndpoint::inline("https://hooks.slack.invalid/secret-b"),
856 },
857 ],
858 after_attempts: Some(1),
859 after_minutes_stale: Some(1),
860 });
861 create_run(&scheduler, "run-1", vec![failing], 1);
862 scheduler.tick_run(&FleetRunId::from("run-1")).unwrap();
863 scheduler.set_now(base_now() + chrono::Duration::seconds(10));
864
865 let report = scheduler.tick_run(&FleetRunId::from("run-1")).unwrap();
866
867 assert_eq!(report.marked_stale, 1);
868 assert_eq!(report.restarted, 0);
869 assert_eq!(report.failed, 1);
870 assert_eq!(report.alerts, 2);
871 let state = scheduler.ledger.rebuild_state().unwrap();
872 assert_eq!(
873 state.tasks["run-1:task-a"].status,
874 FleetTaskLedgerStatus::Failed
875 );
876 assert_eq!(state.alerts.len(), 2);
877 assert_eq!(state.escalated_events.len(), 1);
878 let ledger = ledger_text(&scheduler);
879 assert!(ledger.contains("\"state\":\"failed\""));
880 assert!(ledger.contains("\"record\":\"alert_sent\""));
881 assert_eq!(
882 ledger
883 .lines()
884 .filter(|line| line.contains("\"record\":\"alert_sent\""))
885 .count(),
886 2
887 );
888 assert!(!ledger.contains("hooks.slack.invalid/secret"));
889 }
890
891 #[test]
892 fn exhausted_failed_attempt_alerts_exactly_once_across_competing_schedulers() {
893 let tmp = TempDir::new().unwrap();
894 let owner = scheduler(&tmp, 1);
895 let mut failing = task("task-a", 1);
896 failing.alert_policy = Some(FleetAlertPolicy {
897 events: vec![FleetAlertEventClass::RestartExhausted],
898 channels: vec![FleetAlertChannel::Slack {
899 webhook: FleetAlertEndpoint::inline("https://hooks.slack.invalid/secret"),
900 }],
901 after_attempts: Some(1),
902 after_minutes_stale: Some(1),
903 });
904 create_run(&owner, "run-1", vec![failing], 1);
905 owner.tick_run(&FleetRunId::from("run-1")).unwrap();
906 owner
907 .ledger
908 .append_terminal_event_if_leased(
909 &FleetRunId::from("run-1"),
910 "worker-1",
911 "task-a",
912 1,
913 &owner.timestamp(),
914 FleetWorkerEventPayload::Failed {
915 reason: "worker failed before scheduler recovery".to_string(),
916 recoverable: true,
917 },
918 )
919 .unwrap()
920 .unwrap();
921
922 let root = tmp.path().to_path_buf();
923 let barrier = Arc::new(Barrier::new(2));
924 let handles = (0..2)
925 .map(|_| {
926 let root = root.clone();
927 let barrier = Arc::clone(&barrier);
928 thread::spawn(move || {
929 let mut contender = FleetScheduler::open(
930 root,
931 FleetSchedulerPolicy {
932 max_workers_per_run: 1,
933 max_workers_per_host: 1,
934 max_workers_per_task_class: 1,
935 lease_seconds: 30,
936 heartbeat_timeout: Duration::from_secs(5),
937 },
938 )
939 .unwrap();
940 contender.set_now(base_now() + chrono::Duration::seconds(10));
941 barrier.wait();
942 contender.tick_run(&FleetRunId::from("run-1")).unwrap()
943 })
944 })
945 .collect::<Vec<_>>();
946 let reports = handles
947 .into_iter()
948 .map(|handle| handle.join().unwrap())
949 .collect::<Vec<_>>();
950
951 assert_eq!(reports.iter().map(|report| report.alerts).sum::<usize>(), 1);
952 assert_eq!(reports.iter().map(|report| report.failed).sum::<usize>(), 0);
953 let state = owner.ledger.rebuild_state().unwrap();
954 assert_eq!(state.alerts.len(), 1);
955 assert_eq!(state.escalated_events.len(), 1);
956 assert_eq!(
957 ledger_text(&owner)
958 .lines()
959 .filter(|line| line.contains("\"record\":\"alert_sent\""))
960 .count(),
961 1
962 );
963
964 owner.ledger.compact().unwrap();
965 let mut after_compaction = scheduler(&tmp, 1);
966 after_compaction.set_now(base_now() + chrono::Duration::seconds(20));
967 let report = after_compaction
968 .tick_run(&FleetRunId::from("run-1"))
969 .unwrap();
970 assert_eq!(report.alerts, 0);
971 assert_eq!(
972 after_compaction
973 .ledger
974 .rebuild_state()
975 .unwrap()
976 .alerts
977 .len(),
978 1
979 );
980 }
981
982 #[test]
983 fn fleet_scheduler_slow_provider_response_with_fresh_heartbeat_is_not_stale() {
984 let tmp = TempDir::new().unwrap();
985 let mut scheduler = scheduler(&tmp, 1);
986 create_run(&scheduler, "run-1", vec![task("task-a", 2)], 1);
987 scheduler.tick_run(&FleetRunId::from("run-1")).unwrap();
988 scheduler.set_now(base_now() + chrono::Duration::seconds(4));
989 scheduler
990 .append_worker_event(
991 &FleetRunId::from("run-1"),
992 "worker-1",
993 "task-a",
994 FleetWorkerEventPayload::ModelWait {
995 model: Some("deepseek-v4-pro".to_string()),
996 },
997 )
998 .unwrap();
999 scheduler
1000 .ledger
1001 .heartbeat("worker-1", &scheduler.timestamp(), None, None)
1002 .unwrap();
1003
1004 let report = scheduler.tick_run(&FleetRunId::from("run-1")).unwrap();
1005
1006 assert_eq!(report.marked_stale, 0);
1007 assert_eq!(report.restarted, 0);
1008 let state = scheduler.ledger.rebuild_state().unwrap();
1009 assert_eq!(state.tasks["run-1:task-a"].entry.attempts, 1);
1010 assert_eq!(state.workers["worker-1"], FleetWorkerStatus::Busy);
1011 }
1012
1013 #[test]
1014 fn fleet_scheduler_cancel_run_interrupts_active_and_cancels_queued() {
1015 let tmp = TempDir::new().unwrap();
1016 let scheduler = scheduler(&tmp, 1);
1017 create_run(
1018 &scheduler,
1019 "run-1",
1020 vec![task("task-a", 3), task("task-b", 3), task("task-c", 3)],
1021 2,
1022 );
1023 scheduler.tick_run(&FleetRunId::from("run-1")).unwrap();
1024
1025 let report = scheduler
1026 .cancel_run(&FleetRunId::from("run-1"), "operator")
1027 .unwrap();
1028
1029 assert_eq!(report.cancelled, 3);
1030 let state = scheduler.ledger.rebuild_state().unwrap();
1031 for task in state.tasks.values() {
1032 assert_eq!(task.status, FleetTaskLedgerStatus::Cancelled);
1033 }
1034 let ledger = ledger_text(&scheduler);
1035 assert!(ledger.contains("\"state\":\"interrupted\""));
1036 assert!(ledger.contains("\"state\":\"cancelled\""));
1037 }
1038 }
1039
1039 lines RUST