1use std::cmp::Reverse;
37use std::collections::{BTreeMap, BinaryHeap, VecDeque};
38
39use serde::{Deserialize, Serialize};
40
41use crate::fleet_smoke_profile::{SmokeProfileEvent, SmokeScenario};
42use crate::job_identity::{JobIdentity, RemoteBuildId};
43use crate::queue_contract::{
44 AdmissionState, QueueContractResponse, QueueOptions, WaitResult, resolve_queue_contract,
45};
46
47pub mod decision {
51 pub const REMOTE: &str = "remote";
53 pub const QUEUED: &str = "queued";
55 pub const LOCAL_FALLBACK: &str = "local_fallback";
58 pub const QUEUE_TIMEOUT_FALLBACK: &str = "queue_timeout_fallback";
60 pub const PROOF_REFUSED: &str = "proof_refused";
63 pub const CANCELLED: &str = "cancelled";
65}
66
67pub mod event {
70 pub const QUEUED: &str = "queued";
72 pub const STARTED: &str = "started";
74 pub const PASSED: &str = "passed";
76 pub const FELL_BACK: &str = "fell_back";
78 pub const REFUSED: &str = "refused";
80 pub const CANCELLED: &str = "cancelled";
82}
83
84pub const PROOF_REFUSAL_REASON: &str = "RCH-E301";
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum WorkerEligibility {
96 Healthy,
98 Degraded,
100 TemporaryBypass,
102 AdminDisabled,
104 CapabilityInadmissible,
106}
107
108impl WorkerEligibility {
109 #[must_use]
111 pub const fn is_schedulable(self) -> bool {
112 matches!(self, Self::Healthy | Self::Degraded)
113 }
114
115 #[must_use]
117 pub const fn as_str(self) -> &'static str {
118 match self {
119 Self::Healthy => "healthy",
120 Self::Degraded => "degraded",
121 Self::TemporaryBypass => "temporary_bypass",
122 Self::AdminDisabled => "admin_disabled",
123 Self::CapabilityInadmissible => "capability_inadmissible",
124 }
125 }
126}
127
128#[derive(Debug, Clone, PartialEq)]
130pub struct StormWorker {
131 pub id: String,
133 pub total_slots: u32,
135 pub speed: f64,
137 pub eligibility: WorkerEligibility,
139}
140
141impl StormWorker {
142 #[must_use]
144 pub fn healthy(id: impl Into<String>, total_slots: u32, speed: f64) -> Self {
145 Self {
146 id: id.into(),
147 total_slots,
148 speed,
149 eligibility: WorkerEligibility::Healthy,
150 }
151 }
152
153 #[must_use]
156 pub fn with_eligibility(mut self, eligibility: WorkerEligibility) -> Self {
157 self.eligibility = eligibility;
158 self
159 }
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "snake_case")]
166pub enum JobPolicy {
167 FailOpen,
169 ForceRemote,
171 Proof,
173}
174
175impl JobPolicy {
176 #[must_use]
178 pub const fn allows_local_fallback(self) -> bool {
179 matches!(self, Self::FailOpen | Self::ForceRemote)
180 }
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(rename_all = "snake_case")]
186pub enum JobKind {
187 Build,
189 Test,
191 Check,
193}
194
195impl JobKind {
196 #[must_use]
198 pub const fn fingerprint(self) -> &'static str {
199 match self {
200 Self::Build => "cargo build",
201 Self::Test => "cargo test",
202 Self::Check => "cargo check",
203 }
204 }
205}
206
207#[derive(Debug, Clone, PartialEq)]
209pub struct StormJob {
210 pub runtime_ms: u64,
212 pub slots: u32,
214 pub project_root: String,
216 pub policy: JobPolicy,
218 pub kind: JobKind,
220 pub cancel_when_queued: bool,
223}
224
225impl StormJob {
226 #[must_use]
228 pub fn build(runtime_ms: u64, slots: u32, project_root: impl Into<String>) -> Self {
229 Self {
230 runtime_ms,
231 slots,
232 project_root: project_root.into(),
233 policy: JobPolicy::FailOpen,
234 kind: JobKind::Build,
235 cancel_when_queued: false,
236 }
237 }
238
239 #[must_use]
241 pub fn with_policy(mut self, policy: JobPolicy) -> Self {
242 self.policy = policy;
243 self
244 }
245
246 #[must_use]
248 pub fn with_kind(mut self, kind: JobKind) -> Self {
249 self.kind = kind;
250 self
251 }
252
253 #[must_use]
255 pub fn cancelling(mut self) -> Self {
256 self.cancel_when_queued = true;
257 self
258 }
259}
260
261#[derive(Debug, Clone, PartialEq, Eq)]
263pub struct StormConfig {
264 pub run_id: String,
266 pub bead_id: String,
268 pub max_queue_depth: usize,
270 pub queue_timeout_ms: u64,
272 pub fairness_window: usize,
275 pub cancel_delay_ms: u64,
277}
278
279impl StormConfig {
280 #[must_use]
282 pub fn new(run_id: impl Into<String>, bead_id: impl Into<String>) -> Self {
283 Self {
284 run_id: run_id.into(),
285 bead_id: bead_id.into(),
286 max_queue_depth: 64,
287 queue_timeout_ms: 30_000,
288 fairness_window: 16,
289 cancel_delay_ms: 5,
290 }
291 }
292}
293
294#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
297pub struct StormSummary {
298 pub total_jobs: u32,
300 pub remote_successes: u32,
302 pub local_fallbacks: u32,
304 pub proof_refusals: u32,
306 pub queue_timeouts: u32,
308 pub cancellations: u32,
310 pub p95_queue_wait_ms: u64,
312 pub p95_end_to_end_ms: u64,
314 pub per_worker_slot_utilization: BTreeMap<String, f64>,
316}
317
318#[derive(Debug, Clone)]
320pub struct StormRun {
321 pub events: Vec<SmokeProfileEvent>,
323 pub summary: StormSummary,
325}
326
327impl StormRun {
328 pub fn to_jsonl(&self) -> Result<String, serde_json::Error> {
333 let mut out = String::new();
334 for ev in &self.events {
335 out.push_str(&serde_json::to_string(ev)?);
336 out.push('\n');
337 }
338 Ok(out)
339 }
340}
341
342#[derive(Debug, Clone, Copy)]
347enum Disposition {
348 Remote { remote_id: RemoteBuildId },
349 LocalFallback,
350 QueueTimeoutFallback,
351 ProofRefused,
352 Cancelled,
353}
354
355#[derive(Debug)]
356struct JobState {
357 local_id: String,
358 enqueued_at: Option<u64>,
359 queue_wait_ms: u64,
360 started_at: Option<u64>,
361 disposition: Option<Disposition>,
362}
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365enum EvKind {
366 Arrival(usize),
367 Completion {
368 worker: usize,
369 slots: u32,
370 job: usize,
371 },
372 Timeout(usize),
373 Cancel(usize),
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377struct Ev {
378 time: u64,
379 seq: u64,
380 kind: EvKind,
381}
382
383impl Ord for Ev {
385 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
386 self.time.cmp(&other.time).then(self.seq.cmp(&other.seq))
387 }
388}
389impl PartialOrd for Ev {
390 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
391 Some(self.cmp(other))
392 }
393}
394
395#[must_use]
403pub fn simulate_storm(workers: &[StormWorker], jobs: &[StormJob], cfg: &StormConfig) -> StormRun {
404 let mut sim = Sim::new(workers, jobs, cfg);
405 sim.run();
406 sim.finish()
407}
408
409struct Sim<'a> {
410 workers: &'a [StormWorker],
411 jobs: &'a [StormJob],
412 cfg: &'a StormConfig,
413 used: Vec<u32>,
414 busy_slot_ms: Vec<u128>,
415 selection_log: VecDeque<usize>,
416 state: Vec<JobState>,
417 queue: VecDeque<usize>,
418 heap: BinaryHeap<Reverse<Ev>>,
419 events: Vec<SmokeProfileEvent>,
420 next_remote_id: RemoteBuildId,
421 next_seq: u64,
422 clock: u64,
423 makespan: u64,
424 queue_timeouts: u32,
425}
426
427impl<'a> Sim<'a> {
428 fn new(workers: &'a [StormWorker], jobs: &'a [StormJob], cfg: &'a StormConfig) -> Self {
429 let state = (0..jobs.len())
432 .map(|i| JobState {
433 local_id: format!("{}{i:04}", crate::job_identity::LOCAL_WRAPPER_ID_PREFIX),
434 enqueued_at: None,
435 queue_wait_ms: 0,
436 started_at: None,
437 disposition: None,
438 })
439 .collect();
440 let mut heap = BinaryHeap::new();
441 for (i, _) in jobs.iter().enumerate() {
443 heap.push(Reverse(Ev {
444 time: 0,
445 seq: i as u64,
446 kind: EvKind::Arrival(i),
447 }));
448 }
449 Self {
450 workers,
451 jobs,
452 cfg,
453 used: vec![0; workers.len()],
454 busy_slot_ms: vec![0; workers.len()],
455 selection_log: VecDeque::new(),
456 state,
457 queue: VecDeque::new(),
458 heap,
459 events: Vec::new(),
460 next_remote_id: 1,
461 next_seq: jobs.len() as u64,
462 clock: 0,
463 makespan: 0,
464 queue_timeouts: 0,
465 }
466 }
467
468 fn seq(&mut self) -> u64 {
469 let s = self.next_seq;
470 self.next_seq += 1;
471 s
472 }
473
474 fn run(&mut self) {
475 while let Some(Reverse(ev)) = self.heap.pop() {
476 self.clock = ev.time;
477 match ev.kind {
478 EvKind::Arrival(job) => self.on_arrival(job),
479 EvKind::Completion { worker, slots, job } => self.on_completion(worker, slots, job),
480 EvKind::Timeout(job) => self.on_timeout(job),
481 EvKind::Cancel(job) => self.on_cancel(job),
482 }
483 }
484 let stragglers: Vec<usize> = self.queue.drain(..).collect();
487 for job in stragglers {
488 if self.state[job].disposition.is_none() {
489 self.dispose_timeout(job);
490 }
491 }
492 }
493
494 fn try_select(&self, slots: u32) -> Option<usize> {
497 let mut best: Option<(usize, f64)> = None;
498 for (i, w) in self.workers.iter().enumerate() {
499 if !w.eligibility.is_schedulable() {
500 continue;
501 }
502 if w.total_slots < slots {
503 continue; }
505 if w.total_slots - self.used[i] < slots {
506 continue; }
508 let recent = self.selection_log.iter().filter(|&&x| x == i).count();
509 let weight = w.speed / (1.0 + recent as f64);
510 match best {
511 Some((_, bw)) if weight <= bw => {}
512 _ => best = Some((i, weight)),
513 }
514 }
515 best.map(|(i, _)| i)
516 }
517
518 fn ever_feasible(&self, slots: u32) -> bool {
520 self.workers
521 .iter()
522 .any(|w| w.eligibility.is_schedulable() && w.total_slots >= slots)
523 }
524
525 fn queue_depth_u32(&self) -> u32 {
526 u32::try_from(self.queue.len()).unwrap_or(u32::MAX)
527 }
528
529 fn on_arrival(&mut self, job: usize) {
530 let spec = &self.jobs[job];
531 if !self.ever_feasible(spec.slots) {
532 if spec.policy.allows_local_fallback() {
535 self.dispose_local_fallback(job, decision::LOCAL_FALLBACK);
536 } else {
537 self.dispose_proof_refused(job);
538 }
539 return;
540 }
541 if let Some(w) = self.try_select(spec.slots) {
542 self.place(job, w);
543 return;
544 }
545 if !spec.policy.allows_local_fallback() {
547 self.dispose_proof_refused(job);
549 return;
550 }
551 if self.queue.len() >= self.cfg.max_queue_depth {
552 self.dispose_local_fallback(job, decision::LOCAL_FALLBACK);
555 return;
556 }
557 self.enqueue(job);
558 }
559
560 fn enqueue(&mut self, job: usize) {
561 self.state[job].enqueued_at = Some(self.clock);
562 self.queue.push_back(job);
563 let depth = self.queue_depth_u32();
564 let guidance = queued_guidance(&self.state[job].local_id);
567 self.emit(
568 job,
569 event::QUEUED,
570 "queued",
571 decision::QUEUED,
572 None,
573 None,
574 Some(depth),
575 0,
576 Some(guidance),
577 None,
578 );
579 let timeout_at = self.clock + self.cfg.queue_timeout_ms;
581 let s = self.seq();
582 self.heap.push(Reverse(Ev {
583 time: timeout_at,
584 seq: s,
585 kind: EvKind::Timeout(job),
586 }));
587 if self.jobs[job].cancel_when_queued {
588 let cancel_at = self.clock + self.cfg.cancel_delay_ms;
589 let s = self.seq();
590 self.heap.push(Reverse(Ev {
591 time: cancel_at,
592 seq: s,
593 kind: EvKind::Cancel(job),
594 }));
595 }
596 }
597
598 fn place(&mut self, job: usize, worker: usize) {
599 let spec = &self.jobs[job];
600 let slots = spec.slots;
601 let runtime = spec.runtime_ms;
602 self.used[worker] += slots;
603 self.busy_slot_ms[worker] += u128::from(slots) * u128::from(runtime);
604 self.selection_log.push_back(worker);
605 while self.selection_log.len() > self.cfg.fairness_window {
606 self.selection_log.pop_front();
607 }
608 let wait = self.state[job]
609 .enqueued_at
610 .map_or(0, |e| self.clock.saturating_sub(e));
611 self.state[job].queue_wait_ms = wait;
612 self.state[job].started_at = Some(self.clock);
613
614 let remote_id = self.next_remote_id;
615 self.next_remote_id += 1;
616 let mut identity = JobIdentity::new_local();
617 identity.local_wrapper_id = self.state[job].local_id.clone();
618 identity.admit(remote_id);
619 self.state[job].disposition = Some(Disposition::Remote { remote_id });
620
621 let worker_id = self.workers[worker].id.clone();
622 let depth = self.queue_depth_u32();
623 self.emit(
625 job,
626 event::STARTED,
627 "run",
628 decision::REMOTE,
629 Some(worker_id.clone()),
630 Some(worker_id.clone()),
631 Some(depth),
632 0,
633 Some("admitted and running".to_string()),
634 Some(remote_id),
635 );
636 let finish = self.clock + runtime;
637 self.makespan = self.makespan.max(finish);
638 let s = self.seq();
639 self.heap.push(Reverse(Ev {
640 time: finish,
641 seq: s,
642 kind: EvKind::Completion { worker, slots, job },
643 }));
644 }
645
646 fn on_completion(&mut self, worker: usize, slots: u32, job: usize) {
647 self.used[worker] = self.used[worker].saturating_sub(slots);
648 let runtime = self.jobs[job].runtime_ms;
649 let worker_id = self.workers[worker].id.clone();
650 let remote_id = match self.state[job].disposition {
651 Some(Disposition::Remote { remote_id }) => Some(remote_id),
652 _ => None,
653 };
654 self.emit(
656 job,
657 event::PASSED,
658 "ok",
659 decision::REMOTE,
660 Some(worker_id.clone()),
661 Some(worker_id),
662 None,
663 runtime,
664 Some("completed remotely".to_string()),
665 remote_id,
666 );
667 self.drain_queue();
669 }
670
671 fn drain_queue(&mut self) {
672 while let Some(&job) = self.queue.front() {
673 if self.state[job].disposition.is_some() {
675 self.queue.pop_front();
676 continue;
677 }
678 let slots = self.jobs[job].slots;
679 if let Some(w) = self.try_select(slots) {
680 self.queue.pop_front();
681 self.place(job, w);
682 } else {
683 break; }
685 }
686 }
687
688 fn on_timeout(&mut self, job: usize) {
689 if self.state[job].disposition.is_some() || self.state[job].started_at.is_some() {
690 return; }
692 self.remove_from_queue(job);
694 self.dispose_timeout(job);
695 }
696
697 fn on_cancel(&mut self, job: usize) {
698 if self.state[job].disposition.is_some() || self.state[job].started_at.is_some() {
699 return;
700 }
701 self.remove_from_queue(job);
702 self.dispose_cancelled(job);
703 }
704
705 fn remove_from_queue(&mut self, job: usize) {
706 if let Some(pos) = self.queue.iter().position(|&j| j == job) {
707 self.queue.remove(pos);
708 }
709 }
710
711 fn dispose_timeout(&mut self, job: usize) {
712 self.queue_timeouts += 1;
713 let wait = self.state[job]
714 .enqueued_at
715 .map_or(self.cfg.queue_timeout_ms, |e| {
716 (self.clock.saturating_sub(e)).min(self.cfg.queue_timeout_ms)
717 });
718 self.state[job].queue_wait_ms = wait;
719 self.state[job].disposition = Some(Disposition::QueueTimeoutFallback);
720 let guidance = queue_timeout_guidance(&self.state[job].local_id, self.cfg.queue_timeout_ms);
721 self.emit(
722 job,
723 event::FELL_BACK,
724 "local",
725 decision::QUEUE_TIMEOUT_FALLBACK,
726 None,
727 None,
728 None,
729 self.jobs[job].runtime_ms,
730 Some(guidance),
731 None,
732 );
733 }
734
735 fn dispose_local_fallback(&mut self, job: usize, decision_tok: &str) {
736 self.state[job].disposition = Some(Disposition::LocalFallback);
737 let guidance = local_fallback_guidance();
738 self.emit(
739 job,
740 event::FELL_BACK,
741 "local",
742 decision_tok,
743 None,
744 None,
745 None,
746 self.jobs[job].runtime_ms,
747 Some(guidance),
748 None,
749 );
750 }
751
752 fn dispose_proof_refused(&mut self, job: usize) {
753 self.state[job].disposition = Some(Disposition::ProofRefused);
754 self.emit(
755 job,
756 event::REFUSED,
757 "refused",
758 decision::PROOF_REFUSED,
759 None,
760 None,
761 None,
762 0,
763 Some(
764 "proof mode requires immediate remote admission; refused local fallback"
765 .to_string(),
766 ),
767 None,
768 );
769 if let Some(ev) = self.events.last_mut() {
771 ev.reason_code = Some(PROOF_REFUSAL_REASON.to_string());
772 }
773 }
774
775 fn dispose_cancelled(&mut self, job: usize) {
776 let wait = self.state[job]
777 .enqueued_at
778 .map_or(0, |e| self.clock.saturating_sub(e));
779 self.state[job].queue_wait_ms = wait;
780 self.state[job].disposition = Some(Disposition::Cancelled);
781 let guidance = cancel_guidance(&self.state[job].local_id);
782 self.emit(
783 job,
784 event::CANCELLED,
785 "cancelled",
786 decision::CANCELLED,
787 None,
788 None,
789 None,
790 0,
791 Some(guidance),
792 None,
793 );
794 }
795
796 #[allow(clippy::too_many_arguments)] fn emit(
798 &mut self,
799 job: usize,
800 event_tok: &str,
801 status_tok: &str,
802 decision_tok: &str,
803 worker_id: Option<String>,
804 selected_worker: Option<String>,
805 queue_depth: Option<u32>,
806 duration_ms: u64,
807 detail: Option<String>,
808 remote_id: Option<RemoteBuildId>,
809 ) {
810 let ev = make_load_event(
811 &self.cfg.run_id,
812 &self.cfg.bead_id,
813 self.jobs[job].kind,
814 &self.state[job].local_id,
815 event_tok,
816 status_tok,
817 decision_tok,
818 worker_id,
819 selected_worker,
820 queue_depth,
821 duration_ms,
822 detail,
823 remote_id,
824 );
825 self.events.push(ev);
826 }
827
828 fn finish(self) -> StormRun {
829 let mut remote_successes = 0u32;
830 let mut local_fallbacks = 0u32;
831 let mut proof_refusals = 0u32;
832 let mut cancellations = 0u32;
833 let mut queue_waits = Vec::new();
834 let mut end_to_ends = Vec::new();
835
836 for (i, st) in self.state.iter().enumerate() {
837 let runtime = self.jobs[i].runtime_ms;
838 queue_waits.push(st.queue_wait_ms);
839 match st.disposition {
840 Some(Disposition::Remote { .. }) => {
841 remote_successes += 1;
842 end_to_ends.push(st.queue_wait_ms + runtime);
843 }
844 Some(Disposition::LocalFallback) => {
845 local_fallbacks += 1;
846 end_to_ends.push(st.queue_wait_ms + runtime);
847 }
848 Some(Disposition::QueueTimeoutFallback) => {
849 local_fallbacks += 1;
850 end_to_ends.push(st.queue_wait_ms + runtime);
851 }
852 Some(Disposition::ProofRefused) => {
853 proof_refusals += 1;
854 end_to_ends.push(st.queue_wait_ms);
855 }
856 Some(Disposition::Cancelled) => {
857 cancellations += 1;
858 end_to_ends.push(st.queue_wait_ms);
859 }
860 None => {
861 end_to_ends.push(st.queue_wait_ms);
864 }
865 }
866 }
867
868 let makespan = self.makespan.max(1);
869 let mut per_worker_slot_utilization = BTreeMap::new();
870 for (i, w) in self.workers.iter().enumerate() {
871 let capacity = u128::from(w.total_slots) * u128::from(makespan);
872 #[allow(clippy::cast_precision_loss)]
873 let util = if capacity == 0 {
874 0.0
875 } else {
876 self.busy_slot_ms[i] as f64 / capacity as f64
877 };
878 per_worker_slot_utilization.insert(w.id.clone(), util);
879 }
880
881 let summary = StormSummary {
882 total_jobs: u32::try_from(self.jobs.len()).unwrap_or(u32::MAX),
883 remote_successes,
884 local_fallbacks,
885 proof_refusals,
886 queue_timeouts: self.queue_timeouts,
887 cancellations,
888 p95_queue_wait_ms: percentile(&mut queue_waits, 95),
889 p95_end_to_end_ms: percentile(&mut end_to_ends, 95),
890 per_worker_slot_utilization,
891 };
892 StormRun {
893 events: self.events,
894 summary,
895 }
896 }
897}
898
899#[must_use]
901fn percentile(values: &mut [u64], p: u64) -> u64 {
902 if values.is_empty() {
903 return 0;
904 }
905 values.sort_unstable();
906 let n = values.len() as u64;
908 let rank = (p * n).div_ceil(100).max(1);
909 let idx = (rank - 1).min(n - 1) as usize;
910 values[idx]
911}
912
913#[allow(clippy::too_many_arguments)] fn make_load_event(
924 run_id: &str,
925 bead_id: &str,
926 kind: JobKind,
927 local_id: &str,
928 event_tok: &str,
929 status_tok: &str,
930 decision_tok: &str,
931 worker_id: Option<String>,
932 selected_worker: Option<String>,
933 queue_depth: Option<u32>,
934 duration_ms: u64,
935 detail: Option<String>,
936 remote_id: Option<RemoteBuildId>,
937) -> SmokeProfileEvent {
938 let mut ev = SmokeProfileEvent::started(
939 run_id.to_string(),
940 bead_id.to_string(),
941 worker_id,
942 SmokeScenario::LoadStormControl,
943 );
944 ev.event = event_tok.to_string();
945 ev.status = status_tok.to_string();
946 ev.duration_ms = duration_ms;
947 ev.command_fingerprint = Some(kind.fingerprint().to_string());
948 ev = ev
949 .with_job_ids(Some(local_id.to_string()), remote_id)
950 .with_selected_worker(selected_worker)
951 .with_fallback_decision(decision_tok);
952 if let Some(d) = queue_depth {
953 ev = ev.with_queue_depth(d);
954 }
955 if let Some(d) = detail {
956 ev = ev.with_detail(d);
957 }
958 ev
959}
960
961fn queued_guidance(local_id: &str) -> String {
964 QueueContractResponse::build(
965 &resolve_queue_contract(&AdmissionState::Queued, &QueueOptions::default(), None),
966 Some(local_id.to_string()),
967 )
968 .render()
969}
970
971fn local_fallback_guidance() -> String {
974 QueueContractResponse::build(
975 &resolve_queue_contract(
976 &AdmissionState::RejectedBeforeAdmission(
977 "fleet at capacity; ran locally (fail-open)".to_string(),
978 ),
979 &QueueOptions::default(),
980 None,
981 ),
982 None,
983 )
984 .detail
985}
986
987fn queue_timeout_guidance(local_id: &str, queue_timeout_ms: u64) -> String {
989 QueueContractResponse::build(
990 &resolve_queue_contract(
991 &AdmissionState::Queued,
992 &QueueOptions {
993 wait: true,
994 wait_timeout_secs: Some(queue_timeout_ms / 1000),
995 ..QueueOptions::default()
996 },
997 Some(WaitResult::TimedOut),
998 ),
999 Some(local_id.to_string()),
1000 )
1001 .render()
1002}
1003
1004fn cancel_guidance(local_id: &str) -> String {
1006 QueueContractResponse::build(
1007 &resolve_queue_contract(
1008 &AdmissionState::Queued,
1009 &QueueOptions {
1010 wait: true,
1011 ..QueueOptions::default()
1012 },
1013 Some(WaitResult::CancelledBeforeStart),
1014 ),
1015 Some(local_id.to_string()),
1016 )
1017 .render()
1018}
1019
1020#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1037#[serde(rename_all = "snake_case")]
1038pub enum LiveDisposition {
1039 Remote,
1041 LocalFallback,
1043 QueueTimeoutFallback,
1045 ProofRefused,
1047 Cancelled,
1049}
1050
1051impl LiveDisposition {
1052 #[must_use]
1054 pub const fn is_remote(self) -> bool {
1055 matches!(self, Self::Remote)
1056 }
1057
1058 #[must_use]
1060 pub const fn is_local_fallback(self) -> bool {
1061 matches!(self, Self::LocalFallback | Self::QueueTimeoutFallback)
1062 }
1063}
1064
1065#[derive(Debug, Clone, PartialEq)]
1072pub struct LiveJobOutcome {
1073 pub local_job_id: String,
1075 pub remote_job_id: Option<RemoteBuildId>,
1078 pub selected_worker: Option<String>,
1080 pub slots: u32,
1082 pub kind: JobKind,
1084 pub disposition: LiveDisposition,
1086 pub queued: bool,
1089 pub queue_wait_ms: u64,
1091 pub runtime_ms: u64,
1093 pub queue_depth: Option<u32>,
1095}
1096
1097impl LiveJobOutcome {
1098 #[must_use]
1101 pub fn remote(
1102 local_job_id: impl Into<String>,
1103 worker: impl Into<String>,
1104 remote_id: RemoteBuildId,
1105 slots: u32,
1106 runtime_ms: u64,
1107 ) -> Self {
1108 Self {
1109 local_job_id: local_job_id.into(),
1110 remote_job_id: Some(remote_id),
1111 selected_worker: Some(worker.into()),
1112 slots,
1113 kind: JobKind::Check,
1114 disposition: LiveDisposition::Remote,
1115 queued: false,
1116 queue_wait_ms: 0,
1117 runtime_ms,
1118 queue_depth: None,
1119 }
1120 }
1121
1122 #[must_use]
1124 pub fn local_fallback(local_job_id: impl Into<String>, slots: u32, runtime_ms: u64) -> Self {
1125 Self {
1126 local_job_id: local_job_id.into(),
1127 remote_job_id: None,
1128 selected_worker: None,
1129 slots,
1130 kind: JobKind::Check,
1131 disposition: LiveDisposition::LocalFallback,
1132 queued: false,
1133 queue_wait_ms: 0,
1134 runtime_ms,
1135 queue_depth: None,
1136 }
1137 }
1138
1139 #[must_use]
1141 pub fn with_kind(mut self, kind: JobKind) -> Self {
1142 self.kind = kind;
1143 self
1144 }
1145
1146 #[must_use]
1148 pub fn queued_for(mut self, wait_ms: u64, depth: Option<u32>) -> Self {
1149 self.queued = true;
1150 self.queue_wait_ms = wait_ms;
1151 self.queue_depth = depth;
1152 self
1153 }
1154
1155 #[must_use]
1157 pub fn with_disposition(mut self, disposition: LiveDisposition) -> Self {
1158 self.disposition = disposition;
1159 self
1160 }
1161}
1162
1163#[must_use]
1172pub fn build_live_storm_run(
1173 cfg: &StormConfig,
1174 workers: &[StormWorker],
1175 outcomes: &[LiveJobOutcome],
1176) -> StormRun {
1177 let mut events = Vec::new();
1178 for o in outcomes {
1179 if o.queued {
1183 events.push(make_load_event(
1184 &cfg.run_id,
1185 &cfg.bead_id,
1186 o.kind,
1187 &o.local_job_id,
1188 event::QUEUED,
1189 "queued",
1190 decision::QUEUED,
1191 None,
1192 None,
1193 o.queue_depth,
1194 0,
1195 Some(queued_guidance(&o.local_job_id)),
1196 None,
1197 ));
1198 }
1199 match o.disposition {
1200 LiveDisposition::Remote => {
1201 events.push(make_load_event(
1202 &cfg.run_id,
1203 &cfg.bead_id,
1204 o.kind,
1205 &o.local_job_id,
1206 event::STARTED,
1207 "run",
1208 decision::REMOTE,
1209 o.selected_worker.clone(),
1210 o.selected_worker.clone(),
1211 o.queue_depth,
1212 0,
1213 Some("admitted and running".to_string()),
1214 o.remote_job_id,
1215 ));
1216 events.push(make_load_event(
1217 &cfg.run_id,
1218 &cfg.bead_id,
1219 o.kind,
1220 &o.local_job_id,
1221 event::PASSED,
1222 "ok",
1223 decision::REMOTE,
1224 o.selected_worker.clone(),
1225 o.selected_worker.clone(),
1226 None,
1227 o.runtime_ms,
1228 Some("completed remotely".to_string()),
1229 o.remote_job_id,
1230 ));
1231 }
1232 LiveDisposition::LocalFallback => {
1233 events.push(make_load_event(
1234 &cfg.run_id,
1235 &cfg.bead_id,
1236 o.kind,
1237 &o.local_job_id,
1238 event::FELL_BACK,
1239 "local",
1240 decision::LOCAL_FALLBACK,
1241 None,
1242 None,
1243 None,
1244 o.runtime_ms,
1245 Some(local_fallback_guidance()),
1246 None,
1247 ));
1248 }
1249 LiveDisposition::QueueTimeoutFallback => {
1250 events.push(make_load_event(
1251 &cfg.run_id,
1252 &cfg.bead_id,
1253 o.kind,
1254 &o.local_job_id,
1255 event::FELL_BACK,
1256 "local",
1257 decision::QUEUE_TIMEOUT_FALLBACK,
1258 None,
1259 None,
1260 None,
1261 o.runtime_ms,
1262 Some(queue_timeout_guidance(
1263 &o.local_job_id,
1264 cfg.queue_timeout_ms,
1265 )),
1266 None,
1267 ));
1268 }
1269 LiveDisposition::ProofRefused => {
1270 let mut ev = make_load_event(
1271 &cfg.run_id,
1272 &cfg.bead_id,
1273 o.kind,
1274 &o.local_job_id,
1275 event::REFUSED,
1276 "refused",
1277 decision::PROOF_REFUSED,
1278 None,
1279 None,
1280 None,
1281 0,
1282 Some(
1283 "proof mode requires immediate remote admission; refused local fallback"
1284 .to_string(),
1285 ),
1286 None,
1287 );
1288 ev.reason_code = Some(PROOF_REFUSAL_REASON.to_string());
1289 events.push(ev);
1290 }
1291 LiveDisposition::Cancelled => {
1292 events.push(make_load_event(
1293 &cfg.run_id,
1294 &cfg.bead_id,
1295 o.kind,
1296 &o.local_job_id,
1297 event::CANCELLED,
1298 "cancelled",
1299 decision::CANCELLED,
1300 None,
1301 None,
1302 None,
1303 0,
1304 Some(cancel_guidance(&o.local_job_id)),
1305 None,
1306 ));
1307 }
1308 }
1309 }
1310 let summary = summarize_live(workers, outcomes);
1311 StormRun { events, summary }
1312}
1313
1314fn summarize_live(workers: &[StormWorker], outcomes: &[LiveJobOutcome]) -> StormSummary {
1317 let mut remote_successes = 0u32;
1318 let mut local_fallbacks = 0u32;
1319 let mut proof_refusals = 0u32;
1320 let mut queue_timeouts = 0u32;
1321 let mut cancellations = 0u32;
1322 let mut queue_waits = Vec::with_capacity(outcomes.len());
1323 let mut end_to_ends = Vec::with_capacity(outcomes.len());
1324 let mut busy_slot_ms: BTreeMap<&str, u128> = BTreeMap::new();
1325 let mut makespan = 0u64;
1326
1327 for o in outcomes {
1328 queue_waits.push(o.queue_wait_ms);
1329 let end_to_end = o.queue_wait_ms.saturating_add(o.runtime_ms);
1330 match o.disposition {
1331 LiveDisposition::Remote => {
1332 remote_successes += 1;
1333 end_to_ends.push(end_to_end);
1334 makespan = makespan.max(end_to_end);
1335 if let Some(w) = o.selected_worker.as_deref() {
1336 *busy_slot_ms.entry(w).or_default() +=
1337 u128::from(o.slots) * u128::from(o.runtime_ms);
1338 }
1339 }
1340 LiveDisposition::LocalFallback => {
1341 local_fallbacks += 1;
1342 end_to_ends.push(end_to_end);
1343 makespan = makespan.max(end_to_end);
1344 }
1345 LiveDisposition::QueueTimeoutFallback => {
1346 local_fallbacks += 1;
1347 queue_timeouts += 1;
1348 end_to_ends.push(end_to_end);
1349 makespan = makespan.max(end_to_end);
1350 }
1351 LiveDisposition::ProofRefused => {
1352 proof_refusals += 1;
1353 end_to_ends.push(o.queue_wait_ms);
1354 }
1355 LiveDisposition::Cancelled => {
1356 cancellations += 1;
1357 end_to_ends.push(o.queue_wait_ms);
1358 }
1359 }
1360 }
1361
1362 let makespan = makespan.max(1);
1363 let mut per_worker_slot_utilization = BTreeMap::new();
1364 for w in workers {
1365 let capacity = u128::from(w.total_slots) * u128::from(makespan);
1366 let busy = busy_slot_ms.get(w.id.as_str()).copied().unwrap_or(0);
1367 #[allow(clippy::cast_precision_loss)]
1368 let util = if capacity == 0 {
1369 0.0
1370 } else {
1371 busy as f64 / capacity as f64
1372 };
1373 per_worker_slot_utilization.insert(w.id.clone(), util);
1374 }
1375
1376 StormSummary {
1377 total_jobs: u32::try_from(outcomes.len()).unwrap_or(u32::MAX),
1378 remote_successes,
1379 local_fallbacks,
1380 proof_refusals,
1381 queue_timeouts,
1382 cancellations,
1383 p95_queue_wait_ms: percentile(&mut queue_waits, 95),
1384 p95_end_to_end_ms: percentile(&mut end_to_ends, 95),
1385 per_worker_slot_utilization,
1386 }
1387}
1388
1389#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1395pub struct InvariantReport {
1396 pub name: String,
1398 pub passed: bool,
1400 pub detail: String,
1402 pub violations: Vec<String>,
1404}
1405
1406impl InvariantReport {
1407 fn pass(name: &str, detail: impl Into<String>) -> Self {
1408 Self {
1409 name: name.to_string(),
1410 passed: true,
1411 detail: detail.into(),
1412 violations: Vec::new(),
1413 }
1414 }
1415 fn fail(name: &str, detail: impl Into<String>, violations: Vec<String>) -> Self {
1416 Self {
1417 name: name.to_string(),
1418 passed: false,
1419 detail: detail.into(),
1420 violations,
1421 }
1422 }
1423}
1424
1425#[must_use]
1430pub fn check_load_fairness(
1431 events: &[SmokeProfileEvent],
1432 workers: &[StormWorker],
1433 tolerance: f64,
1434) -> InvariantReport {
1435 const NAME: &str = "load_fairness";
1436 let schedulable: Vec<&StormWorker> = workers
1437 .iter()
1438 .filter(|w| w.eligibility.is_schedulable())
1439 .collect();
1440 if schedulable.len() <= 1 {
1441 return InvariantReport::pass(NAME, "single schedulable worker; fairness trivial");
1442 }
1443 let mut counts: BTreeMap<&str, u32> = BTreeMap::new();
1444 for w in &schedulable {
1445 counts.insert(w.id.as_str(), 0);
1446 }
1447 let mut total = 0u32;
1448 for ev in events {
1449 if ev.event == event::STARTED
1450 && let Some(sel) = ev.selected_worker.as_deref()
1451 && let Some(c) = counts.get_mut(sel)
1452 {
1453 *c += 1;
1454 total += 1;
1455 }
1456 }
1457 if total == 0 {
1458 return InvariantReport::pass(NAME, "no remote placements to spread");
1459 }
1460 let fair_share = f64::from(total) / schedulable.len() as f64;
1461 let cap = fair_share * tolerance;
1462 let mut violations = Vec::new();
1463 for (id, c) in &counts {
1464 if f64::from(*c) > cap {
1465 violations.push(format!(
1466 "worker {id} took {c} of {total} placements (cap {cap:.1}, fair share {fair_share:.1})"
1467 ));
1468 }
1469 }
1470 if violations.is_empty() {
1471 InvariantReport::pass(
1472 NAME,
1473 format!("{total} placements spread within {tolerance:.2}× fair share"),
1474 )
1475 } else {
1476 InvariantReport::fail(NAME, "load not spread fairly", violations)
1477 }
1478}
1479
1480#[must_use]
1483pub fn check_no_duplicate_remote_job_ids(events: &[SmokeProfileEvent]) -> InvariantReport {
1484 const NAME: &str = "no_duplicate_remote_job_ids";
1485 let mut owners: BTreeMap<u64, std::collections::BTreeSet<String>> = BTreeMap::new();
1487 for ev in events {
1488 if let Some(rid) = ev.remote_job_id {
1489 let local = ev.local_job_id.clone().unwrap_or_default();
1490 owners.entry(rid).or_default().insert(local);
1491 }
1492 }
1493 let mut violations = Vec::new();
1494 for (rid, locals) in &owners {
1495 if locals.len() > 1 {
1496 violations.push(format!(
1497 "remote job id {rid} shared by local jobs {locals:?}"
1498 ));
1499 }
1500 }
1501 if violations.is_empty() {
1502 InvariantReport::pass(
1503 NAME,
1504 format!("{} distinct remote job ids, none shared", owners.len()),
1505 )
1506 } else {
1507 InvariantReport::fail(NAME, "remote job id collision", violations)
1508 }
1509}
1510
1511#[must_use]
1515pub fn check_no_unbounded_local_fallback_storm(
1516 events: &[SmokeProfileEvent],
1517 summary: &StormSummary,
1518 max_fallback_ratio: f64,
1519) -> InvariantReport {
1520 const NAME: &str = "no_unbounded_local_fallback_storm";
1521 let _ = events;
1522 if summary.total_jobs == 0 {
1523 return InvariantReport::pass(NAME, "no jobs");
1524 }
1525 let ratio = f64::from(summary.local_fallbacks) / f64::from(summary.total_jobs);
1526 if ratio <= max_fallback_ratio {
1527 InvariantReport::pass(
1528 NAME,
1529 format!(
1530 "{}/{} jobs fell back to local ({:.1}% <= {:.1}% cap)",
1531 summary.local_fallbacks,
1532 summary.total_jobs,
1533 ratio * 100.0,
1534 max_fallback_ratio * 100.0
1535 ),
1536 )
1537 } else {
1538 InvariantReport::fail(
1539 NAME,
1540 "local fallback storm: too many jobs bypassed the fleet",
1541 vec![format!(
1542 "{}/{} fell back ({:.1}% > {:.1}% cap)",
1543 summary.local_fallbacks,
1544 summary.total_jobs,
1545 ratio * 100.0,
1546 max_fallback_ratio * 100.0
1547 )],
1548 )
1549 }
1550}
1551
1552#[must_use]
1557pub fn check_attach_cancel_guidance(events: &[SmokeProfileEvent]) -> InvariantReport {
1558 const NAME: &str = "attach_cancel_guidance";
1559 use std::collections::BTreeSet;
1560 let terminal = [
1561 event::PASSED,
1562 event::FELL_BACK,
1563 event::REFUSED,
1564 event::CANCELLED,
1565 ];
1566 let not_started_terminal = [event::FELL_BACK, event::CANCELLED];
1567 let mut all_jobs: BTreeSet<String> = BTreeSet::new();
1568 let mut terminated: BTreeSet<String> = BTreeSet::new();
1569 let mut violations = Vec::new();
1570 for ev in events {
1571 let Some(local) = ev.local_job_id.clone() else {
1572 continue;
1573 };
1574 all_jobs.insert(local.clone());
1575 if terminal.contains(&ev.event.as_str()) {
1576 terminated.insert(local.clone());
1577 }
1578 if not_started_terminal.contains(&ev.event.as_str()) || ev.event == event::QUEUED {
1581 let has_guidance = ev.detail.as_deref().is_some_and(|d| !d.trim().is_empty());
1582 if !has_guidance {
1583 violations.push(format!(
1584 "job {local} event '{}' lacks attach/cancel guidance",
1585 ev.event
1586 ));
1587 }
1588 }
1589 }
1590 for job in all_jobs.difference(&terminated) {
1591 violations.push(format!(
1592 "job {job} never reached a terminal event (stuck wrapper)"
1593 ));
1594 }
1595 if violations.is_empty() {
1596 InvariantReport::pass(
1597 NAME,
1598 format!("{} jobs all terminated with guidance", terminated.len()),
1599 )
1600 } else {
1601 InvariantReport::fail(NAME, "stuck wrapper or missing guidance", violations)
1602 }
1603}
1604
1605#[must_use]
1608pub fn check_no_ineligible_worker_selected(
1609 events: &[SmokeProfileEvent],
1610 workers: &[StormWorker],
1611) -> InvariantReport {
1612 const NAME: &str = "no_ineligible_worker_selected";
1613 let ineligible: BTreeMap<&str, WorkerEligibility> = workers
1614 .iter()
1615 .filter(|w| !w.eligibility.is_schedulable())
1616 .map(|w| (w.id.as_str(), w.eligibility))
1617 .collect();
1618 let mut violations = Vec::new();
1619 for ev in events {
1620 for candidate in [ev.selected_worker.as_deref(), ev.worker_id.as_deref()]
1622 .into_iter()
1623 .flatten()
1624 {
1625 if let Some(elig) = ineligible.get(candidate)
1626 && (ev.event == event::STARTED || ev.event == event::PASSED)
1627 {
1628 violations.push(format!(
1629 "ineligible worker {candidate} ({}) received work on event '{}' (job {:?})",
1630 elig.as_str(),
1631 ev.event,
1632 ev.local_job_id
1633 ));
1634 }
1635 }
1636 }
1637 if violations.is_empty() {
1638 InvariantReport::pass(
1639 NAME,
1640 format!("{} ineligible worker(s) received no work", ineligible.len()),
1641 )
1642 } else {
1643 InvariantReport::fail(NAME, "ineligible worker received work", violations)
1644 }
1645}
1646
1647#[must_use]
1649pub fn check_all_invariants(
1650 run: &StormRun,
1651 workers: &[StormWorker],
1652 fairness_tolerance: f64,
1653 max_fallback_ratio: f64,
1654) -> Vec<InvariantReport> {
1655 vec![
1656 check_load_fairness(&run.events, workers, fairness_tolerance),
1657 check_no_duplicate_remote_job_ids(&run.events),
1658 check_no_unbounded_local_fallback_storm(&run.events, &run.summary, max_fallback_ratio),
1659 check_attach_cancel_guidance(&run.events),
1660 check_no_ineligible_worker_selected(&run.events, workers),
1661 ]
1662}
1663
1664#[must_use]
1666pub fn all_passed(reports: &[InvariantReport]) -> bool {
1667 reports.iter().all(|r| r.passed)
1668}
1669
1670#[cfg(test)]
1671mod tests {
1672 use super::*;
1673
1674 fn cfg() -> StormConfig {
1675 StormConfig::new("storm-run-1", "bd-session-history-remediation-ocv9i.10.4")
1676 }
1677
1678 fn healthy_fleet() -> Vec<StormWorker> {
1679 vec![
1680 StormWorker::healthy("w1", 4, 100.0),
1681 StormWorker::healthy("w2", 4, 100.0),
1682 StormWorker::healthy("w3", 4, 100.0),
1683 ]
1684 }
1685
1686 fn build_jobs(n: usize, runtime: u64, slots: u32) -> Vec<StormJob> {
1687 (0..n)
1688 .map(|i| StormJob::build(runtime, slots, format!("/data/projects/p{}", i % 4)))
1689 .collect()
1690 }
1691
1692 #[test]
1693 fn percentile_nearest_rank() {
1694 let mut v = vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
1695 assert_eq!(percentile(&mut v, 95), 100);
1696 let mut v2 = vec![5];
1697 assert_eq!(percentile(&mut v2, 95), 5);
1698 let mut empty: Vec<u64> = vec![];
1699 assert_eq!(percentile(&mut empty, 95), 0);
1700 let mut v3 = vec![1, 2, 3, 4];
1701 assert_eq!(percentile(&mut v3, 50), 2); }
1703
1704 #[test]
1705 fn healthy_storm_upholds_all_invariants() {
1706 let workers = healthy_fleet();
1707 let jobs = build_jobs(24, 1000, 1);
1709 let run = simulate_storm(&workers, &jobs, &cfg());
1710 let reports = check_all_invariants(&run, &workers, 1.5, 0.25);
1711 for r in &reports {
1712 assert!(r.passed, "invariant {} failed: {:?}", r.name, r.violations);
1713 }
1714 assert_eq!(run.summary.total_jobs, 24);
1715 assert_eq!(run.summary.remote_successes, 24);
1717 assert_eq!(run.summary.local_fallbacks, 0);
1718 }
1719
1720 #[test]
1721 fn fairness_spreads_load_across_homogeneous_fleet() {
1722 let workers = healthy_fleet();
1723 let jobs = build_jobs(30, 500, 1);
1724 let run = simulate_storm(&workers, &jobs, &cfg());
1725 let report = check_load_fairness(&run.events, &workers, 1.4);
1726 assert!(report.passed, "fairness: {:?}", report.violations);
1727 for w in &workers {
1729 let util = run.summary.per_worker_slot_utilization[&w.id];
1730 assert!(util > 0.0, "worker {} starved (util 0)", w.id);
1731 }
1732 }
1733
1734 #[test]
1735 fn unique_remote_job_ids_under_storm() {
1736 let workers = healthy_fleet();
1737 let jobs = build_jobs(50, 300, 1);
1738 let run = simulate_storm(&workers, &jobs, &cfg());
1739 let report = check_no_duplicate_remote_job_ids(&run.events);
1740 assert!(report.passed, "{:?}", report.violations);
1741 }
1742
1743 #[test]
1744 fn queue_full_falls_back_locally_but_not_unboundedly() {
1745 let workers = vec![StormWorker::healthy("only", 1, 100.0)];
1746 let mut c = cfg();
1751 c.max_queue_depth = 2;
1752 c.queue_timeout_ms = 10; let jobs = build_jobs(20, 1000, 1);
1754 let run = simulate_storm(&workers, &jobs, &c);
1755 assert_eq!(run.summary.total_jobs, 20);
1756 let guidance = check_attach_cancel_guidance(&run.events);
1758 assert!(guidance.passed, "{:?}", guidance.violations);
1759 let total_resolved = run.summary.remote_successes
1760 + run.summary.local_fallbacks
1761 + run.summary.proof_refusals
1762 + run.summary.cancellations;
1763 assert_eq!(total_resolved, 20, "every job must resolve");
1764 let strict = check_no_unbounded_local_fallback_storm(&run.events, &run.summary, 0.10);
1767 assert!(!strict.passed, "low cap should flag the fallback storm");
1768 }
1769
1770 #[test]
1771 fn proof_jobs_refuse_rather_than_fall_back() {
1772 let workers = vec![StormWorker::healthy("only", 1, 100.0)];
1775 let jobs: Vec<StormJob> = (0..10)
1776 .map(|i| StormJob::build(1000, 1, format!("/p{i}")).with_policy(JobPolicy::Proof))
1777 .collect();
1778 let run = simulate_storm(&workers, &jobs, &cfg());
1779 assert_eq!(run.summary.local_fallbacks, 0, "proof must never fall back");
1780 assert!(run.summary.proof_refusals >= 9, "most proof jobs refuse");
1781 let refused: Vec<_> = run
1783 .events
1784 .iter()
1785 .filter(|e| e.event == event::REFUSED)
1786 .collect();
1787 assert!(!refused.is_empty());
1788 assert!(
1789 refused
1790 .iter()
1791 .all(|e| e.reason_code.as_deref() == Some(PROOF_REFUSAL_REASON))
1792 );
1793 assert!(check_no_unbounded_local_fallback_storm(&run.events, &run.summary, 0.0).passed);
1794 }
1795
1796 #[test]
1797 fn ineligible_workers_never_receive_work() {
1798 let workers = vec![
1799 StormWorker::healthy("good", 2, 100.0),
1800 StormWorker::healthy("bypassed", 8, 200.0)
1801 .with_eligibility(WorkerEligibility::TemporaryBypass),
1802 StormWorker::healthy("disabled", 8, 200.0)
1803 .with_eligibility(WorkerEligibility::AdminDisabled),
1804 StormWorker::healthy("incapable", 8, 200.0)
1805 .with_eligibility(WorkerEligibility::CapabilityInadmissible),
1806 ];
1807 let jobs = build_jobs(20, 500, 1);
1808 let run = simulate_storm(&workers, &jobs, &cfg());
1809 let report = check_no_ineligible_worker_selected(&run.events, &workers);
1810 assert!(report.passed, "{:?}", report.violations);
1811 for ev in &run.events {
1813 if let Some(sel) = &ev.selected_worker {
1814 assert_eq!(sel, "good", "only the eligible worker may be selected");
1815 }
1816 }
1817 assert_eq!(run.summary.per_worker_slot_utilization["bypassed"], 0.0);
1819 assert_eq!(run.summary.per_worker_slot_utilization["disabled"], 0.0);
1820 assert_eq!(run.summary.per_worker_slot_utilization["incapable"], 0.0);
1821 }
1822
1823 #[test]
1824 fn cancellation_before_start_is_clean() {
1825 let workers = vec![StormWorker::healthy("only", 1, 100.0)];
1826 let mut jobs = build_jobs(6, 1000, 1);
1827 for j in jobs.iter_mut().skip(2) {
1829 j.cancel_when_queued = true;
1830 }
1831 let mut c = cfg();
1832 c.cancel_delay_ms = 5;
1833 c.queue_timeout_ms = 100_000;
1834 let run = simulate_storm(&workers, &jobs, &c);
1835 assert!(run.summary.cancellations >= 1, "some jobs cancel");
1836 let guidance = check_attach_cancel_guidance(&run.events);
1837 assert!(guidance.passed, "{:?}", guidance.violations);
1838 let cancelled: Vec<_> = run
1840 .events
1841 .iter()
1842 .filter(|e| e.event == event::CANCELLED)
1843 .collect();
1844 assert!(!cancelled.is_empty());
1845 assert!(
1846 cancelled
1847 .iter()
1848 .all(|e| e.detail.as_deref().is_some_and(|d| !d.is_empty()))
1849 );
1850 }
1851
1852 #[test]
1853 fn jsonl_carries_every_required_field() {
1854 let workers = healthy_fleet();
1855 let jobs = build_jobs(12, 500, 1);
1856 let run = simulate_storm(&workers, &jobs, &cfg());
1857 let jsonl = run.to_jsonl().expect("serialize");
1858 assert!(!jsonl.is_empty());
1859 let started: Vec<&SmokeProfileEvent> = run
1861 .events
1862 .iter()
1863 .filter(|e| e.event == event::STARTED)
1864 .collect();
1865 assert!(!started.is_empty());
1866 for ev in &started {
1867 assert!(ev.local_job_id.is_some(), "local_job_id present");
1868 assert!(ev.remote_job_id.is_some(), "remote_job_id present");
1869 assert!(ev.selected_worker.is_some(), "selected_worker present");
1870 assert!(ev.queue_depth.is_some(), "queue_depth present");
1871 assert!(ev.fallback_decision.is_some(), "fallback_decision present");
1872 assert_eq!(ev.bead_id, "bd-session-history-remediation-ocv9i.10.4");
1873 }
1874 for line in jsonl.lines() {
1876 let v: serde_json::Value = serde_json::from_str(line).expect("valid json line");
1877 assert_eq!(v["run_id"], "storm-run-1");
1878 assert_eq!(v["scenario"], "load_storm_control");
1879 }
1880 }
1881
1882 #[test]
1883 fn detector_catches_duplicate_remote_ids() {
1884 let mk = |local: &str, rid: u64| {
1886 SmokeProfileEvent::started("r", "b", Some("w".into()), SmokeScenario::LoadStormControl)
1887 .with_job_ids(Some(local.into()), Some(rid))
1888 };
1889 let events = vec![mk("rchw-0", 7), mk("rchw-1", 7)];
1890 let report = check_no_duplicate_remote_job_ids(&events);
1891 assert!(!report.passed);
1892 assert_eq!(report.violations.len(), 1);
1893 }
1894
1895 #[test]
1896 fn detector_catches_ineligible_selection() {
1897 let workers = vec![
1898 StormWorker::healthy("good", 2, 100.0),
1899 StormWorker::healthy("disabled", 2, 100.0)
1900 .with_eligibility(WorkerEligibility::AdminDisabled),
1901 ];
1902 let mut bad = SmokeProfileEvent::started(
1904 "r",
1905 "b",
1906 Some("disabled".into()),
1907 SmokeScenario::LoadStormControl,
1908 );
1909 bad.event = event::STARTED.to_string();
1910 let bad = bad.with_selected_worker(Some("disabled".into()));
1911 let report = check_no_ineligible_worker_selected(&[bad], &workers);
1912 assert!(!report.passed);
1913 }
1914
1915 #[test]
1916 fn detector_catches_stuck_wrapper() {
1917 let ev =
1919 SmokeProfileEvent::started("r", "b", Some("w".into()), SmokeScenario::LoadStormControl)
1920 .with_job_ids(Some("rchw-stuck".into()), Some(1));
1921 let report = check_attach_cancel_guidance(&[ev]);
1922 assert!(!report.passed);
1923 assert!(
1924 report
1925 .violations
1926 .iter()
1927 .any(|v| v.contains("stuck wrapper"))
1928 );
1929 }
1930
1931 #[test]
1932 fn deterministic_runs_are_identical() {
1933 let workers = healthy_fleet();
1934 let jobs = build_jobs(40, 700, 1);
1935 let a = simulate_storm(&workers, &jobs, &cfg());
1936 let b = simulate_storm(&workers, &jobs, &cfg());
1937 assert_eq!(a.to_jsonl().unwrap(), b.to_jsonl().unwrap());
1938 assert_eq!(a.summary, b.summary);
1939 }
1940
1941 #[test]
1942 fn summary_serde_roundtrip() {
1943 let workers = healthy_fleet();
1944 let jobs = build_jobs(10, 500, 1);
1945 let run = simulate_storm(&workers, &jobs, &cfg());
1946 let json = serde_json::to_string(&run.summary).unwrap();
1947 let back: StormSummary = serde_json::from_str(&json).unwrap();
1948 assert_eq!(run.summary, back);
1949 }
1950
1951 #[test]
1952 fn varied_jobs_mixed_policies_kinds_slots() {
1953 let workers = healthy_fleet();
1956 let mut jobs = Vec::new();
1957 for i in 0..18 {
1958 let kind = match i % 3 {
1959 0 => JobKind::Build,
1960 1 => JobKind::Test,
1961 _ => JobKind::Check,
1962 };
1963 let policy = if i % 5 == 0 {
1964 JobPolicy::ForceRemote
1965 } else {
1966 JobPolicy::FailOpen
1967 };
1968 jobs.push(
1969 StormJob::build(
1970 300 + (i as u64 % 4) * 250,
1971 1 + (i as u32 % 3),
1972 format!("/r{}", i % 4),
1973 )
1974 .with_kind(kind)
1975 .with_policy(policy),
1976 );
1977 }
1978 let run = simulate_storm(&workers, &jobs, &cfg());
1979 let reports = check_all_invariants(&run, &workers, 1.6, 0.30);
1980 for r in &reports {
1981 assert!(r.passed, "invariant {} failed: {:?}", r.name, r.violations);
1982 }
1983 }
1984
1985 #[test]
1993 fn live_healthy_run_upholds_all_invariants() {
1994 let workers = healthy_fleet();
1995 let outcomes: Vec<LiveJobOutcome> = (0..30)
1996 .map(|i| {
1997 let w = &workers[i % workers.len()].id;
1998 LiveJobOutcome::remote(format!("rchw-{i:04}"), w.clone(), (i + 1) as u64, 1, 500)
1999 })
2000 .collect();
2001 let run = build_live_storm_run(&cfg(), &workers, &outcomes);
2002 let reports = check_all_invariants(&run, &workers, 1.5, 0.25);
2003 for r in &reports {
2004 assert!(r.passed, "invariant {} failed: {:?}", r.name, r.violations);
2005 }
2006 assert_eq!(run.summary.total_jobs, 30);
2007 assert_eq!(run.summary.remote_successes, 30);
2008 assert_eq!(run.summary.local_fallbacks, 0);
2009 for w in &workers {
2011 assert!(
2012 run.summary.per_worker_slot_utilization[&w.id] > 0.0,
2013 "worker {} starved",
2014 w.id
2015 );
2016 }
2017 }
2018
2019 #[test]
2023 fn live_mixed_run_with_bounded_fallback_passes() {
2024 let workers = healthy_fleet();
2025 let mut outcomes = Vec::new();
2026 for i in 0..24 {
2027 let w = &workers[i % workers.len()].id;
2028 outcomes.push(LiveJobOutcome::remote(
2029 format!("rchw-{i:04}"),
2030 w.clone(),
2031 (i + 1) as u64,
2032 1,
2033 400,
2034 ));
2035 }
2036 for i in 24..28 {
2038 outcomes.push(LiveJobOutcome::local_fallback(
2039 format!("rchw-{i:04}"),
2040 1,
2041 400,
2042 ));
2043 }
2044 let run = build_live_storm_run(&cfg(), &workers, &outcomes);
2045 let reports = check_all_invariants(&run, &workers, 1.5, 0.25);
2046 for r in &reports {
2047 assert!(r.passed, "invariant {} failed: {:?}", r.name, r.violations);
2048 }
2049 assert_eq!(run.summary.remote_successes, 24);
2050 assert_eq!(run.summary.local_fallbacks, 4);
2051 let strict = check_no_unbounded_local_fallback_storm(&run.events, &run.summary, 0.10);
2053 assert!(!strict.passed, "10% cap must flag a 14% fallback rate");
2054 }
2055
2056 #[test]
2059 fn live_summary_accounts_every_disposition() {
2060 let workers = healthy_fleet();
2061 let outcomes = vec![
2062 LiveJobOutcome::remote("rchw-0000", "w1", 1, 1, 300),
2063 LiveJobOutcome::remote("rchw-0001", "w2", 2, 1, 300),
2064 LiveJobOutcome::local_fallback("rchw-0002", 1, 300),
2065 LiveJobOutcome::local_fallback("rchw-0003", 1, 300)
2066 .queued_for(40, Some(2))
2067 .with_disposition(LiveDisposition::QueueTimeoutFallback),
2068 LiveJobOutcome::local_fallback("rchw-0004", 1, 0)
2069 .with_disposition(LiveDisposition::ProofRefused),
2070 LiveJobOutcome::local_fallback("rchw-0005", 1, 0)
2071 .queued_for(10, Some(1))
2072 .with_disposition(LiveDisposition::Cancelled),
2073 ];
2074 let run = build_live_storm_run(&cfg(), &workers, &outcomes);
2075 assert_eq!(run.summary.total_jobs, 6);
2076 assert_eq!(run.summary.remote_successes, 2);
2077 assert_eq!(run.summary.local_fallbacks, 2);
2079 assert_eq!(run.summary.queue_timeouts, 1);
2080 assert_eq!(run.summary.proof_refusals, 1);
2081 assert_eq!(run.summary.cancellations, 1);
2082 assert!(check_attach_cancel_guidance(&run.events).passed);
2084 }
2085
2086 #[test]
2089 fn live_jsonl_carries_load_fields() {
2090 let workers = healthy_fleet();
2091 let outcomes: Vec<LiveJobOutcome> = (0..6)
2092 .map(|i| {
2093 LiveJobOutcome::remote(
2094 format!("rchw-{i:04}"),
2095 workers[i % workers.len()].id.clone(),
2096 (i + 1) as u64,
2097 1,
2098 250,
2099 )
2100 .queued_for(5, Some(1))
2101 })
2102 .collect();
2103 let run = build_live_storm_run(&cfg(), &workers, &outcomes);
2104 let jsonl = run.to_jsonl().expect("serialize");
2105 assert!(!jsonl.is_empty());
2106 let started: Vec<&SmokeProfileEvent> = run
2107 .events
2108 .iter()
2109 .filter(|e| e.event == event::STARTED)
2110 .collect();
2111 assert_eq!(started.len(), 6);
2112 for ev in &started {
2113 assert!(ev.local_job_id.is_some());
2114 assert!(ev.remote_job_id.is_some());
2115 assert!(ev.selected_worker.is_some());
2116 assert!(ev.queue_depth.is_some());
2117 assert_eq!(ev.fallback_decision.as_deref(), Some(decision::REMOTE));
2118 }
2119 for line in jsonl.lines() {
2120 let v: serde_json::Value = serde_json::from_str(line).expect("valid json line");
2121 assert_eq!(v["scenario"], "load_storm_control");
2122 }
2123 }
2124
2125 #[test]
2128 fn live_detector_catches_ineligible_selection() {
2129 let workers = vec![
2130 StormWorker::healthy("good", 4, 100.0),
2131 StormWorker::healthy("disabled", 4, 100.0)
2132 .with_eligibility(WorkerEligibility::AdminDisabled),
2133 ];
2134 let outcomes = vec![
2136 LiveJobOutcome::remote("rchw-0000", "good", 1, 1, 300),
2137 LiveJobOutcome::remote("rchw-0001", "disabled", 2, 1, 300),
2138 ];
2139 let run = build_live_storm_run(&cfg(), &workers, &outcomes);
2140 let report = check_no_ineligible_worker_selected(&run.events, &workers);
2141 assert!(!report.passed);
2142 assert!(report.violations.iter().any(|v| v.contains("disabled")));
2143 }
2144
2145 #[test]
2147 fn live_detector_catches_duplicate_remote_ids() {
2148 let workers = healthy_fleet();
2149 let outcomes = vec![
2150 LiveJobOutcome::remote("rchw-0000", "w1", 7, 1, 300),
2151 LiveJobOutcome::remote("rchw-0001", "w2", 7, 1, 300),
2152 ];
2153 let run = build_live_storm_run(&cfg(), &workers, &outcomes);
2154 let report = check_no_duplicate_remote_job_ids(&run.events);
2155 assert!(!report.passed);
2156 }
2157
2158 #[test]
2161 fn live_proof_refusal_carries_reason_code() {
2162 let workers = healthy_fleet();
2163 let outcomes = vec![
2164 LiveJobOutcome::local_fallback("rchw-0000", 1, 0)
2165 .with_disposition(LiveDisposition::ProofRefused),
2166 ];
2167 let run = build_live_storm_run(&cfg(), &workers, &outcomes);
2168 assert_eq!(run.summary.proof_refusals, 1);
2169 assert_eq!(run.summary.local_fallbacks, 0);
2170 let refused: Vec<&SmokeProfileEvent> = run
2171 .events
2172 .iter()
2173 .filter(|e| e.event == event::REFUSED)
2174 .collect();
2175 assert_eq!(refused.len(), 1);
2176 assert_eq!(
2177 refused[0].reason_code.as_deref(),
2178 Some(PROOF_REFUSAL_REASON)
2179 );
2180 assert!(check_no_unbounded_local_fallback_storm(&run.events, &run.summary, 0.0).passed);
2182 }
2183
2184 #[test]
2187 fn live_builder_matches_simulator_remote_event_shape() {
2188 let workers = vec![StormWorker::healthy("w1", 4, 100.0)];
2189 let jobs = vec![StormJob::build(500, 1, "/p0").with_kind(JobKind::Check)];
2191 let sim = simulate_storm(&workers, &jobs, &cfg());
2192 let sim_started = sim
2193 .events
2194 .iter()
2195 .find(|e| e.event == event::STARTED)
2196 .expect("sim started");
2197 let outcomes = vec![LiveJobOutcome::remote(
2199 sim.events
2200 .iter()
2201 .find_map(|e| e.local_job_id.clone())
2202 .unwrap(),
2203 "w1",
2204 1,
2205 1,
2206 500,
2207 )];
2208 let live = build_live_storm_run(&cfg(), &workers, &outcomes);
2209 let live_started = live
2210 .events
2211 .iter()
2212 .find(|e| e.event == event::STARTED)
2213 .expect("live started");
2214 assert_eq!(sim_started.scenario, live_started.scenario);
2216 assert_eq!(sim_started.status, live_started.status);
2217 assert_eq!(
2218 sim_started.fallback_decision,
2219 live_started.fallback_decision
2220 );
2221 assert_eq!(
2222 sim_started.command_fingerprint,
2223 live_started.command_fingerprint
2224 );
2225 assert_eq!(sim_started.selected_worker, live_started.selected_worker);
2226 assert_eq!(sim_started.detail, live_started.detail);
2227 }
2228}