1use std::collections::BTreeMap;
38use std::fs::{self, OpenOptions};
39use std::io::{BufRead, BufReader, Write};
40use std::path::{Path, PathBuf};
41
42use serde::{Deserialize, Serialize};
43
44use crate::disk_pressure_report::ExecFailureClass;
45use crate::incident::IncidentReasonCode;
46use crate::proof_intent::ReplayDecision;
47use crate::readiness::DecisiveBlocker;
48use crate::schema_versions::{SchemaComponent, current_version};
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum ProofState {
61 Queued,
64 Blocked,
67 Replaying,
69 Passed,
71 Failed,
75 Stale,
78}
79
80impl ProofState {
81 pub const ALL: &'static [ProofState] = &[
83 Self::Queued,
84 Self::Blocked,
85 Self::Replaying,
86 Self::Passed,
87 Self::Failed,
88 Self::Stale,
89 ];
90
91 #[must_use]
93 pub fn as_str(self) -> &'static str {
94 match self {
95 ProofState::Queued => "queued",
96 ProofState::Blocked => "blocked",
97 ProofState::Replaying => "replaying",
98 ProofState::Passed => "passed",
99 ProofState::Failed => "failed",
100 ProofState::Stale => "stale",
101 }
102 }
103
104 #[must_use]
106 pub fn is_terminal(self) -> bool {
107 matches!(
108 self,
109 ProofState::Passed | ProofState::Failed | ProofState::Stale
110 )
111 }
112
113 #[must_use]
117 pub fn can_transition_to(self, next: ProofState) -> bool {
118 if self == next {
119 return true;
120 }
121 match self {
122 ProofState::Queued => matches!(
124 next,
125 ProofState::Blocked | ProofState::Replaying | ProofState::Stale
126 ),
127 ProofState::Blocked => matches!(
128 next,
129 ProofState::Queued | ProofState::Replaying | ProofState::Stale
130 ),
131 ProofState::Replaying => matches!(
135 next,
136 ProofState::Passed | ProofState::Failed | ProofState::Queued | ProofState::Stale
137 ),
138 ProofState::Passed | ProofState::Failed | ProofState::Stale => false,
140 }
141 }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub enum ReplayOutcome {
148 Succeeded,
150 ProductFailed,
152 InfrastructureFailed,
155}
156
157impl ReplayOutcome {
158 #[must_use]
165 pub fn classify(success: bool, failure_class: ExecFailureClass) -> Self {
166 if success {
167 return ReplayOutcome::Succeeded;
168 }
169 match failure_class {
170 ExecFailureClass::ProductCompile => ReplayOutcome::ProductFailed,
171 ExecFailureClass::WorkerEnvironment | ExecFailureClass::Indeterminate => {
172 ReplayOutcome::InfrastructureFailed
173 }
174 }
175 }
176
177 #[must_use]
179 pub fn resolved_state(self) -> ProofState {
180 match self {
181 ReplayOutcome::Succeeded => ProofState::Passed,
182 ReplayOutcome::ProductFailed => ProofState::Failed,
183 ReplayOutcome::InfrastructureFailed => ProofState::Queued,
184 }
185 }
186}
187
188#[derive(Debug, Clone)]
191pub struct ConveyorSignals {
192 pub replay: ReplayDecision,
195 pub blocker: DecisiveBlocker,
198 pub replay_capacity_available: bool,
200}
201
202impl ConveyorSignals {
203 #[must_use]
206 pub fn ready() -> Self {
207 Self {
208 replay: ReplayDecision::Replayable,
209 blocker: DecisiveBlocker::None,
210 replay_capacity_available: true,
211 }
212 }
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218pub struct ConveyorDecision {
219 pub next_state: ProofState,
221 pub attempt_replay: bool,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub reason: Option<IncidentReasonCode>,
228 pub detail: String,
230}
231
232impl ConveyorDecision {
233 fn new(
234 next_state: ProofState,
235 attempt_replay: bool,
236 reason: Option<IncidentReasonCode>,
237 detail: impl Into<String>,
238 ) -> Self {
239 Self {
240 next_state,
241 attempt_replay,
242 reason,
243 detail: detail.into(),
244 }
245 }
246}
247
248#[must_use]
262pub fn decide(current: ProofState, signals: &ConveyorSignals) -> ConveyorDecision {
263 if current.is_terminal() {
264 return ConveyorDecision::new(
265 current,
266 false,
267 None,
268 format!("intent is in terminal state `{}`", current.as_str()),
269 );
270 }
271
272 if let ReplayDecision::Rejected { reason, detail } = &signals.replay {
274 return ConveyorDecision::new(ProofState::Stale, false, Some(*reason), detail.clone());
275 }
276
277 match signals.blocker {
279 DecisiveBlocker::None => {
280 if signals.replay_capacity_available {
281 ConveyorDecision::new(
282 ProofState::Replaying,
283 true,
284 None,
285 "remote ready and replay capacity available; replaying",
286 )
287 } else {
288 ConveyorDecision::new(
289 ProofState::Queued,
290 false,
291 Some(IncidentReasonCode::InsufficientSlots),
292 "remote ready but holding replay to avoid starving interactive work",
293 )
294 }
295 }
296 DecisiveBlocker::DaemonDown => ConveyorDecision::new(
297 ProofState::Queued,
298 false,
299 Some(IncidentReasonCode::DaemonSocketRefused),
300 "daemon socket unreachable; queued pending daemon recovery",
301 ),
302 DecisiveBlocker::NoDesiredFleet => ConveyorDecision::new(
303 ProofState::Queued,
304 false,
305 Some(IncidentReasonCode::NoAdmissibleWorkers),
306 "no workers configured/desired; queued pending fleet configuration",
307 ),
308 DecisiveBlocker::NoHealthyWorkers => ConveyorDecision::new(
309 ProofState::Queued,
310 false,
311 Some(IncidentReasonCode::NoAdmissibleWorkers),
312 "workers desired but none live/healthy; queued pending recovery/canary rejoin",
313 ),
314 DecisiveBlocker::PressureBlocked => ConveyorDecision::new(
315 ProofState::Queued,
316 false,
317 Some(IncidentReasonCode::CriticalPressure),
318 "admissibility blocked by critical pressure; queued pending pressure recovery",
319 ),
320 DecisiveBlocker::ArtifactMissed => ConveyorDecision::new(
321 ProofState::Queued,
322 false,
323 Some(IncidentReasonCode::ArtifactMiss),
324 "last attempt failed at artifact retrieval; queued to retry",
325 ),
326 DecisiveBlocker::NoCommandCapability => ConveyorDecision::new(
327 ProofState::Blocked,
328 false,
329 Some(IncidentReasonCode::MissingRuntimeToolchainTarget),
330 "no worker can run this command (capability gap); blocked pending capability",
331 ),
332 DecisiveBlocker::ProofRefused => ConveyorDecision::new(
333 ProofState::Blocked,
334 false,
335 Some(IncidentReasonCode::ProofRefusal),
336 "proof refused before execution (placement/policy); blocked pending operator",
337 ),
338 }
339}
340
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
345pub struct ProofReplayRecord {
346 pub schema_version: String,
348 pub intent_id: String,
350 pub recorded_at_unix_ms: u64,
354 pub state: ProofState,
356 #[serde(default)]
358 pub attempts: u32,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub last_reason: Option<IncidentReasonCode>,
362 pub last_detail: String,
364 pub updated_at_unix_ms: u64,
367}
368
369impl ProofReplayRecord {
370 #[must_use]
372 pub fn queued(intent_id: impl Into<String>, recorded_at_unix_ms: u64) -> Self {
373 Self {
374 schema_version: proof_replay_schema_version().to_string(),
375 intent_id: intent_id.into(),
376 recorded_at_unix_ms,
377 state: ProofState::Queued,
378 attempts: 0,
379 last_reason: None,
380 last_detail: "recorded; queued for deferred replay".to_string(),
381 updated_at_unix_ms: recorded_at_unix_ms,
382 }
383 }
384
385 #[must_use]
390 pub fn apply(&self, decision: &ConveyorDecision, now_unix_ms: u64) -> Self {
391 let mut next = self.clone();
392 next.updated_at_unix_ms = now_unix_ms;
393 if !self.state.can_transition_to(decision.next_state) {
394 return next;
395 }
396 next.state = decision.next_state;
397 next.last_reason = decision.reason;
398 next.last_detail = decision.detail.clone();
399 if decision.attempt_replay {
400 next.attempts = next.attempts.saturating_add(1);
401 }
402 next
403 }
404
405 #[must_use]
409 pub fn resolve(&self, outcome: ReplayOutcome, now_unix_ms: u64) -> Self {
410 let next_state = outcome.resolved_state();
411 let mut next = self.clone();
412 next.updated_at_unix_ms = now_unix_ms;
413 if !self.state.can_transition_to(next_state) {
414 return next;
415 }
416 next.state = next_state;
417 next.last_detail = match outcome {
418 ReplayOutcome::Succeeded => "replay succeeded remotely; proof passed".to_string(),
419 ReplayOutcome::ProductFailed => {
420 "replay ran remotely; product failed (compile/test)".to_string()
421 }
422 ReplayOutcome::InfrastructureFailed => {
423 "replay hit an infrastructure failure; re-queued for retry".to_string()
424 }
425 };
426 next.last_reason = match outcome {
427 ReplayOutcome::Succeeded | ReplayOutcome::ProductFailed => None,
428 ReplayOutcome::InfrastructureFailed => Some(IncidentReasonCode::ArtifactMiss),
429 };
430 next
431 }
432}
433
434#[must_use]
436pub fn proof_replay_schema_version() -> &'static str {
437 current_version(SchemaComponent::ProofReplayState)
438}
439
440#[derive(Debug, Clone)]
443pub struct ConveyorScanItem {
444 pub intent_id: String,
446 pub current_state: ProofState,
448 pub signals: ConveyorSignals,
450 pub recorded_at_unix_ms: u64,
452}
453
454#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
456pub struct ConveyorScanResult {
457 pub intent_id: String,
459 pub decision: ConveyorDecision,
461}
462
463#[must_use]
473pub fn plan_scan(
474 items: &[ConveyorScanItem],
475 max_concurrent_replays: usize,
476) -> Vec<ConveyorScanResult> {
477 let mut decisions: Vec<ConveyorDecision> = items
479 .iter()
480 .map(|it| decide(it.current_state, &it.signals))
481 .collect();
482
483 let mut replay_candidates: Vec<usize> = decisions
485 .iter()
486 .enumerate()
487 .filter(|(_, d)| d.next_state == ProofState::Replaying)
488 .map(|(i, _)| i)
489 .collect();
490 replay_candidates.sort_by(|&a, &b| {
491 items[a]
492 .recorded_at_unix_ms
493 .cmp(&items[b].recorded_at_unix_ms)
494 .then_with(|| items[a].intent_id.cmp(&items[b].intent_id))
495 });
496
497 for &idx in replay_candidates.iter().skip(max_concurrent_replays) {
499 decisions[idx] = ConveyorDecision::new(
500 ProofState::Queued,
501 false,
502 Some(IncidentReasonCode::InsufficientSlots),
503 "replay deferred: concurrent replay cap reached (fairness/anti-starvation)",
504 );
505 }
506
507 items
508 .iter()
509 .zip(decisions)
510 .map(|(it, decision)| ConveyorScanResult {
511 intent_id: it.intent_id.clone(),
512 decision,
513 })
514 .collect()
515}
516
517#[derive(Debug, Clone)]
521pub struct ProofReplayStateStore {
522 path: PathBuf,
523}
524
525impl ProofReplayStateStore {
526 #[must_use]
527 pub fn with_path(path: impl Into<PathBuf>) -> Self {
528 Self { path: path.into() }
529 }
530
531 #[must_use]
532 pub fn path(&self) -> &Path {
533 &self.path
534 }
535
536 pub fn put(&self, record: &ProofReplayRecord) -> std::io::Result<()> {
539 if let Some(parent) = self.path.parent()
540 && !parent.as_os_str().is_empty()
541 {
542 fs::create_dir_all(parent)?;
543 }
544 let mut line = serde_json::to_string(record)
545 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
546 line.push('\n');
547 let mut file = OpenOptions::new()
548 .create(true)
549 .append(true)
550 .open(&self.path)?;
551 file.write_all(line.as_bytes())?;
552 file.flush()
553 }
554
555 #[must_use]
558 pub fn all(&self) -> Vec<ProofReplayRecord> {
559 let file = match fs::File::open(&self.path) {
560 Ok(f) => f,
561 Err(_) => return Vec::new(),
562 };
563 let mut order: Vec<String> = Vec::new();
564 let mut latest: BTreeMap<String, ProofReplayRecord> = BTreeMap::new();
565 for line in BufReader::new(file).lines() {
566 let Ok(line) = line else { break };
567 let trimmed = line.trim();
568 if trimmed.is_empty() {
569 continue;
570 }
571 if let Ok(record) = serde_json::from_str::<ProofReplayRecord>(trimmed) {
572 if !latest.contains_key(&record.intent_id) {
573 order.push(record.intent_id.clone());
574 }
575 latest.insert(record.intent_id.clone(), record);
576 }
577 }
578 order
579 .into_iter()
580 .filter_map(|id| latest.remove(&id))
581 .collect()
582 }
583
584 #[must_use]
586 pub fn get(&self, intent_id: &str) -> Option<ProofReplayRecord> {
587 self.all().into_iter().find(|r| r.intent_id == intent_id)
588 }
589
590 #[must_use]
592 pub fn by_state(&self, state: ProofState) -> Vec<ProofReplayRecord> {
593 self.all()
594 .into_iter()
595 .filter(|r| r.state == state)
596 .collect()
597 }
598
599 #[must_use]
601 pub fn census(&self) -> BTreeMap<String, usize> {
602 let mut census: BTreeMap<String, usize> = BTreeMap::new();
603 for record in self.all() {
604 *census.entry(record.state.as_str().to_string()).or_insert(0) += 1;
605 }
606 census
607 }
608
609 pub fn advance(
621 &self,
622 signals_by_intent: &BTreeMap<String, ConveyorSignals>,
623 max_concurrent_replays: usize,
624 now_unix_ms: u64,
625 ) -> std::io::Result<Vec<ConveyorScanResult>> {
626 let records: Vec<ProofReplayRecord> = self
627 .all()
628 .into_iter()
629 .filter(|r| !r.state.is_terminal())
630 .collect();
631
632 let items: Vec<ConveyorScanItem> = records
633 .iter()
634 .filter_map(|r| {
635 signals_by_intent
636 .get(&r.intent_id)
637 .map(|signals| ConveyorScanItem {
638 intent_id: r.intent_id.clone(),
639 current_state: r.state,
640 signals: signals.clone(),
641 recorded_at_unix_ms: r.recorded_at_unix_ms,
642 })
643 })
644 .collect();
645
646 let results = plan_scan(&items, max_concurrent_replays);
647
648 for result in &results {
649 if let Some(record) = records.iter().find(|r| r.intent_id == result.intent_id) {
650 let next = record.apply(&result.decision, now_unix_ms);
651 self.put(&next)?;
652 }
653 }
654 Ok(results)
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use crate::proof_intent::{
662 ProofIntent, ReplayConstraints, ReplayContext, SourceFingerprint, StaleSourcePolicy,
663 validate_replay,
664 };
665
666 fn rejected(detail: &str) -> ReplayDecision {
667 ReplayDecision::Rejected {
668 reason: IncidentReasonCode::ProofRefusal,
669 detail: detail.to_string(),
670 }
671 }
672
673 #[test]
676 fn states_have_unique_stable_tokens() {
677 assert_eq!(ProofState::ALL.len(), 6);
678 let mut tokens: Vec<&str> = ProofState::ALL.iter().map(|s| s.as_str()).collect();
679 let before = tokens.len();
680 tokens.sort_unstable();
681 tokens.dedup();
682 assert_eq!(before, tokens.len(), "duplicate proof-state tokens");
683 }
684
685 #[test]
686 fn terminal_states_are_final() {
687 for terminal in [ProofState::Passed, ProofState::Failed, ProofState::Stale] {
688 assert!(terminal.is_terminal());
689 for &next in ProofState::ALL {
690 let legal = terminal.can_transition_to(next);
691 assert_eq!(legal, next == terminal, "{terminal:?} -> {next:?}");
693 }
694 }
695 }
696
697 #[test]
698 fn non_terminal_states_can_reach_terminals() {
699 for start in [
700 ProofState::Queued,
701 ProofState::Blocked,
702 ProofState::Replaying,
703 ] {
704 assert!(!start.is_terminal());
705 assert!(start.can_transition_to(ProofState::Stale));
706 }
707 assert!(ProofState::Replaying.can_transition_to(ProofState::Passed));
708 assert!(ProofState::Replaying.can_transition_to(ProofState::Failed));
709 assert!(!ProofState::Queued.can_transition_to(ProofState::Passed));
711 assert!(!ProofState::Queued.can_transition_to(ProofState::Failed));
712 }
713
714 #[test]
715 fn states_serialize_with_snake_case_tokens() {
716 assert_eq!(
717 serde_json::to_value(ProofState::Replaying).unwrap(),
718 "replaying"
719 );
720 let back: ProofState = serde_json::from_str("\"stale\"").unwrap();
721 assert_eq!(back, ProofState::Stale);
722 }
723
724 #[test]
727 fn outcome_classification_and_resolution() {
728 assert_eq!(
729 ReplayOutcome::classify(true, ExecFailureClass::Indeterminate),
730 ReplayOutcome::Succeeded
731 );
732 assert_eq!(
733 ReplayOutcome::classify(false, ExecFailureClass::ProductCompile),
734 ReplayOutcome::ProductFailed
735 );
736 assert_eq!(
737 ReplayOutcome::classify(false, ExecFailureClass::WorkerEnvironment),
738 ReplayOutcome::InfrastructureFailed
739 );
740 assert_eq!(
741 ReplayOutcome::classify(false, ExecFailureClass::Indeterminate),
742 ReplayOutcome::InfrastructureFailed
743 );
744 assert_eq!(
745 ReplayOutcome::Succeeded.resolved_state(),
746 ProofState::Passed
747 );
748 assert_eq!(
749 ReplayOutcome::ProductFailed.resolved_state(),
750 ProofState::Failed
751 );
752 assert_eq!(
753 ReplayOutcome::InfrastructureFailed.resolved_state(),
754 ProofState::Queued
755 );
756 }
757
758 #[test]
761 fn decide_replays_when_ready_with_capacity() {
762 let d = decide(ProofState::Queued, &ConveyorSignals::ready());
763 assert_eq!(d.next_state, ProofState::Replaying);
764 assert!(d.attempt_replay);
765 assert!(d.reason.is_none());
766 }
767
768 #[test]
769 fn decide_holds_queued_when_ready_but_no_capacity_protecting_interactive() {
770 let signals = ConveyorSignals {
773 replay_capacity_available: false,
774 ..ConveyorSignals::ready()
775 };
776 let d = decide(ProofState::Queued, &signals);
777 assert_eq!(d.next_state, ProofState::Queued);
778 assert!(!d.attempt_replay);
779 assert_eq!(d.reason, Some(IncidentReasonCode::InsufficientSlots));
780 }
781
782 #[test]
783 fn scenario_recovery_after_disk_pressure() {
784 let pressured = ConveyorSignals {
786 replay: ReplayDecision::Replayable,
787 blocker: DecisiveBlocker::PressureBlocked,
788 replay_capacity_available: true,
789 };
790 let d = decide(ProofState::Queued, &pressured);
791 assert_eq!(d.next_state, ProofState::Queued);
792 assert!(!d.attempt_replay);
793 assert_eq!(d.reason, Some(IncidentReasonCode::CriticalPressure));
794
795 let d2 = decide(ProofState::Queued, &ConveyorSignals::ready());
797 assert_eq!(d2.next_state, ProofState::Replaying);
798 assert!(d2.attempt_replay);
799 }
800
801 #[test]
802 fn scenario_source_changed_while_queued_goes_stale() {
803 let signals = ConveyorSignals {
804 replay: rejected("source fingerprint mismatch (source changed)"),
805 blocker: DecisiveBlocker::None,
807 replay_capacity_available: true,
808 };
809 let d = decide(ProofState::Queued, &signals);
810 assert_eq!(d.next_state, ProofState::Stale);
811 assert!(!d.attempt_replay);
812 assert!(d.detail.contains("source changed"));
813 }
814
815 #[test]
816 fn scenario_capability_still_missing_stays_blocked() {
817 let signals = ConveyorSignals {
818 replay: ReplayDecision::Replayable,
819 blocker: DecisiveBlocker::NoCommandCapability,
820 replay_capacity_available: true,
821 };
822 let d = decide(ProofState::Queued, &signals);
823 assert_eq!(d.next_state, ProofState::Blocked);
824 assert!(!d.attempt_replay);
825 assert_eq!(
826 d.reason,
827 Some(IncidentReasonCode::MissingRuntimeToolchainTarget)
828 );
829 }
830
831 #[test]
832 fn scenario_worker_eligible_after_canary_rejoin() {
833 let no_healthy = ConveyorSignals {
835 replay: ReplayDecision::Replayable,
836 blocker: DecisiveBlocker::NoHealthyWorkers,
837 replay_capacity_available: true,
838 };
839 let d = decide(ProofState::Queued, &no_healthy);
840 assert_eq!(d.next_state, ProofState::Queued);
841 assert!(d.detail.contains("canary rejoin"));
842
843 let d2 = decide(ProofState::Queued, &ConveyorSignals::ready());
845 assert_eq!(d2.next_state, ProofState::Replaying);
846 assert!(d2.attempt_replay);
847 }
848
849 #[test]
850 fn decide_never_replays_under_any_safety_block() {
851 for blocker in [
853 DecisiveBlocker::DaemonDown,
854 DecisiveBlocker::NoDesiredFleet,
855 DecisiveBlocker::NoHealthyWorkers,
856 DecisiveBlocker::NoCommandCapability,
857 DecisiveBlocker::PressureBlocked,
858 DecisiveBlocker::ArtifactMissed,
859 DecisiveBlocker::ProofRefused,
860 ] {
861 let signals = ConveyorSignals {
862 replay: ReplayDecision::Replayable,
863 blocker,
864 replay_capacity_available: true,
865 };
866 let d = decide(ProofState::Queued, &signals);
867 assert!(!d.attempt_replay, "must not replay under {blocker:?}");
868 assert_ne!(d.next_state, ProofState::Replaying);
869 }
870 }
871
872 #[test]
873 fn decide_is_noop_on_terminal_states() {
874 for terminal in [ProofState::Passed, ProofState::Failed, ProofState::Stale] {
875 let d = decide(terminal, &ConveyorSignals::ready());
876 assert_eq!(d.next_state, terminal);
877 assert!(!d.attempt_replay);
878 }
879 }
880
881 #[test]
882 fn decide_uses_real_validate_replay_for_staleness() {
883 let intent = ProofIntent::new(
885 "blake3:abc",
886 "/data/projects/foo",
887 Some("rev-1".to_string()),
888 "pooled",
889 IncidentReasonCode::ProofRefusal,
890 StaleSourcePolicy::RejectIfChanged,
891 ReplayConstraints {
892 require_same_revision: true,
893 require_unchanged_sources: true,
894 max_age_secs: Some(3600),
895 },
896 1_700_000_000_000,
897 )
898 .with_source_fingerprints(vec![SourceFingerprint {
899 path: "src/lib.rs".to_string(),
900 blake3: "deadbeef".to_string(),
901 }]);
902 let ctx = ReplayContext {
904 current_revision: Some("rev-1".to_string()),
905 current_fingerprints: vec![SourceFingerprint {
906 path: "src/lib.rs".to_string(),
907 blake3: "CHANGED".to_string(),
908 }],
909 age_secs: 10,
910 };
911 let signals = ConveyorSignals {
912 replay: validate_replay(&intent, &ctx),
913 blocker: DecisiveBlocker::None,
914 replay_capacity_available: true,
915 };
916 let _ = &intent;
920 assert_eq!(
921 decide(ProofState::Queued, &signals).next_state,
922 ProofState::Stale
923 );
924 }
925
926 #[test]
929 fn record_apply_advances_and_counts_attempts() {
930 let rec = ProofReplayRecord::queued("pi-1", 1_000);
931 assert_eq!(rec.state, ProofState::Queued);
932 assert_eq!(rec.attempts, 0);
933
934 let d = decide(ProofState::Queued, &ConveyorSignals::ready());
935 let replaying = rec.apply(&d, 2_000);
936 assert_eq!(replaying.state, ProofState::Replaying);
937 assert_eq!(replaying.attempts, 1, "attempt counted on replay");
938 assert_eq!(replaying.updated_at_unix_ms, 2_000);
939
940 let passed = replaying.resolve(ReplayOutcome::Succeeded, 3_000);
941 assert_eq!(passed.state, ProofState::Passed);
942 assert_eq!(passed.attempts, 1, "resolve does not re-count");
943 }
944
945 #[test]
946 fn record_apply_rejects_illegal_transition_from_terminal() {
947 let passed = ProofReplayRecord::queued("pi-1", 1)
948 .apply(&decide(ProofState::Queued, &ConveyorSignals::ready()), 2)
949 .resolve(ReplayOutcome::Succeeded, 3);
950 assert_eq!(passed.state, ProofState::Passed);
951 let illegal = ConveyorDecision {
953 next_state: ProofState::Queued,
954 attempt_replay: false,
955 reason: None,
956 detail: "spurious".to_string(),
957 };
958 let after = passed.apply(&illegal, 4);
959 assert_eq!(after.state, ProofState::Passed, "terminal state preserved");
960 assert_eq!(after.updated_at_unix_ms, 4);
961 }
962
963 #[test]
964 fn record_resolve_infra_failure_requeues_for_retry() {
965 let replaying = ProofReplayRecord::queued("pi-1", 1)
966 .apply(&decide(ProofState::Queued, &ConveyorSignals::ready()), 2);
967 let requeued = replaying.resolve(ReplayOutcome::InfrastructureFailed, 3);
968 assert_eq!(requeued.state, ProofState::Queued);
969 assert_eq!(requeued.attempts, 1);
970 assert_eq!(requeued.last_reason, Some(IncidentReasonCode::ArtifactMiss));
971 }
972
973 #[test]
974 fn record_roundtrips_and_stamps_schema_version() {
975 let rec = ProofReplayRecord::queued("pi-xyz", 42);
976 assert_eq!(rec.schema_version, proof_replay_schema_version());
977 let back: ProofReplayRecord =
978 serde_json::from_str(&serde_json::to_string(&rec).unwrap()).unwrap();
979 assert_eq!(rec, back);
980 }
981
982 fn ready_item(id: &str, recorded: u64) -> ConveyorScanItem {
985 ConveyorScanItem {
986 intent_id: id.to_string(),
987 current_state: ProofState::Queued,
988 signals: ConveyorSignals::ready(),
989 recorded_at_unix_ms: recorded,
990 }
991 }
992
993 #[test]
994 fn plan_scan_caps_concurrent_replays_oldest_first() {
995 let items = vec![
997 ready_item("pi-new", 3_000),
998 ready_item("pi-old", 1_000),
999 ready_item("pi-mid", 2_000),
1000 ];
1001 let plan = plan_scan(&items, 2);
1002 let by_id = |id: &str| {
1003 plan.iter()
1004 .find(|r| r.intent_id == id)
1005 .unwrap()
1006 .decision
1007 .clone()
1008 };
1009 assert_eq!(by_id("pi-old").next_state, ProofState::Replaying);
1011 assert_eq!(by_id("pi-mid").next_state, ProofState::Replaying);
1012 let newest = by_id("pi-new");
1014 assert_eq!(newest.next_state, ProofState::Queued);
1015 assert!(!newest.attempt_replay);
1016 assert_eq!(newest.reason, Some(IncidentReasonCode::InsufficientSlots));
1017 assert_eq!(plan[0].intent_id, "pi-new");
1019 }
1020
1021 #[test]
1022 fn plan_scan_zero_cap_admits_no_replays() {
1023 let items = vec![ready_item("pi-1", 1), ready_item("pi-2", 2)];
1024 let plan = plan_scan(&items, 0);
1025 assert!(plan.iter().all(|r| !r.decision.attempt_replay));
1026 assert!(
1027 plan.iter()
1028 .all(|r| r.decision.next_state == ProofState::Queued)
1029 );
1030 }
1031
1032 #[test]
1033 fn plan_scan_does_not_demote_non_replay_states() {
1034 let mut blocked = ready_item("pi-blocked", 1);
1036 blocked.signals.blocker = DecisiveBlocker::NoCommandCapability;
1037 let mut stale = ready_item("pi-stale", 2);
1038 stale.signals.replay = rejected("source changed");
1039 let items = vec![blocked, stale, ready_item("pi-ready", 3)];
1040 let plan = plan_scan(&items, 0);
1041 let by_id = |id: &str| plan.iter().find(|r| r.intent_id == id).unwrap();
1042 assert_eq!(by_id("pi-blocked").decision.next_state, ProofState::Blocked);
1043 assert_eq!(by_id("pi-stale").decision.next_state, ProofState::Stale);
1044 assert_eq!(by_id("pi-ready").decision.next_state, ProofState::Queued);
1046 }
1047
1048 #[test]
1051 fn store_dedups_and_reports_latest_state() {
1052 let dir = tempfile::tempdir().unwrap();
1053 let store = ProofReplayStateStore::with_path(dir.path().join("replay.jsonl"));
1054 let rec = ProofReplayRecord::queued("pi-1", 1);
1055 store.put(&rec).unwrap();
1056 let replaying = rec.apply(&decide(ProofState::Queued, &ConveyorSignals::ready()), 2);
1057 store.put(&replaying).unwrap();
1058 let passed = replaying.resolve(ReplayOutcome::Succeeded, 3);
1059 store.put(&passed).unwrap();
1060
1061 let all = store.all();
1062 assert_eq!(all.len(), 1, "dedup by intent_id");
1063 assert_eq!(all[0].state, ProofState::Passed, "latest wins");
1064 assert_eq!(store.get("pi-1").unwrap().state, ProofState::Passed);
1065 assert!(store.get("pi-missing").is_none());
1066 }
1067
1068 #[test]
1069 fn store_by_state_and_census() {
1070 let dir = tempfile::tempdir().unwrap();
1071 let store = ProofReplayStateStore::with_path(dir.path().join("replay.jsonl"));
1072 store.put(&ProofReplayRecord::queued("pi-q1", 1)).unwrap();
1073 store.put(&ProofReplayRecord::queued("pi-q2", 2)).unwrap();
1074 let blocked = {
1075 let mut sig = ConveyorSignals::ready();
1076 sig.blocker = DecisiveBlocker::NoCommandCapability;
1077 ProofReplayRecord::queued("pi-b", 3).apply(&decide(ProofState::Queued, &sig), 4)
1078 };
1079 store.put(&blocked).unwrap();
1080
1081 assert_eq!(store.by_state(ProofState::Queued).len(), 2);
1082 assert_eq!(store.by_state(ProofState::Blocked).len(), 1);
1083 let census = store.census();
1084 assert_eq!(census.get("queued"), Some(&2));
1085 assert_eq!(census.get("blocked"), Some(&1));
1086 }
1087
1088 #[test]
1089 fn store_is_corruption_tolerant() {
1090 let dir = tempfile::tempdir().unwrap();
1091 let path = dir.path().join("replay.jsonl");
1092 let store = ProofReplayStateStore::with_path(&path);
1093 store.put(&ProofReplayRecord::queued("pi-1", 1)).unwrap();
1094 {
1095 let mut f = OpenOptions::new().append(true).open(&path).unwrap();
1096 writeln!(f, "{{ not json").unwrap();
1097 }
1098 store.put(&ProofReplayRecord::queued("pi-2", 2)).unwrap();
1099 assert_eq!(store.all().len(), 2, "corrupt line skipped, not fatal");
1100 }
1101
1102 #[test]
1103 fn missing_store_reads_empty() {
1104 let dir = tempfile::tempdir().unwrap();
1105 let store = ProofReplayStateStore::with_path(dir.path().join("nope.jsonl"));
1106 assert!(store.all().is_empty());
1107 assert!(store.census().is_empty());
1108 }
1109
1110 #[test]
1111 fn record_preserves_recorded_at_across_transitions() {
1112 let rec = ProofReplayRecord::queued("pi-1", 5_000);
1113 let replaying = rec.apply(
1114 &decide(ProofState::Queued, &ConveyorSignals::ready()),
1115 6_000,
1116 );
1117 let passed = replaying.resolve(ReplayOutcome::Succeeded, 7_000);
1118 assert_eq!(rec.recorded_at_unix_ms, 5_000);
1119 assert_eq!(replaying.recorded_at_unix_ms, 5_000, "preserved on apply");
1120 assert_eq!(passed.recorded_at_unix_ms, 5_000, "preserved on resolve");
1121 assert_eq!(passed.updated_at_unix_ms, 7_000);
1122 }
1123
1124 #[test]
1127 fn advance_persists_transitions_and_honors_fairness_and_terminals() {
1128 let dir = tempfile::tempdir().unwrap();
1129 let store = ProofReplayStateStore::with_path(dir.path().join("replay.jsonl"));
1130
1131 store
1133 .put(&ProofReplayRecord::queued("pi-old", 1_000))
1134 .unwrap();
1135 store
1136 .put(&ProofReplayRecord::queued("pi-mid", 2_000))
1137 .unwrap();
1138 store
1139 .put(&ProofReplayRecord::queued("pi-new", 3_000))
1140 .unwrap();
1141 let passed = ProofReplayRecord::queued("pi-done", 500)
1142 .apply(&decide(ProofState::Queued, &ConveyorSignals::ready()), 600)
1143 .resolve(ReplayOutcome::Succeeded, 700);
1144 store.put(&passed).unwrap();
1145
1146 let mut signals: BTreeMap<String, ConveyorSignals> = BTreeMap::new();
1149 for id in ["pi-old", "pi-mid", "pi-new", "pi-done"] {
1150 signals.insert(id.to_string(), ConveyorSignals::ready());
1151 }
1152
1153 let results = store.advance(&signals, 1, 10_000).unwrap();
1155
1156 assert!(results.iter().all(|r| r.intent_id != "pi-done"));
1158 assert_eq!(results.len(), 3);
1159
1160 let replaying: Vec<&str> = results
1162 .iter()
1163 .filter(|r| r.decision.attempt_replay)
1164 .map(|r| r.intent_id.as_str())
1165 .collect();
1166 assert_eq!(replaying, vec!["pi-old"]);
1167
1168 assert_eq!(store.get("pi-old").unwrap().state, ProofState::Replaying);
1170 assert_eq!(store.get("pi-old").unwrap().attempts, 1);
1171 assert_eq!(store.get("pi-mid").unwrap().state, ProofState::Queued);
1172 assert_eq!(store.get("pi-done").unwrap().state, ProofState::Passed);
1173
1174 let results2 = store.advance(&signals, 8, 11_000).unwrap();
1176 assert!(
1178 results2
1179 .iter()
1180 .any(|r| r.intent_id == "pi-mid" && r.decision.attempt_replay)
1181 );
1182 }
1183
1184 #[test]
1185 fn advance_skips_intents_without_signals() {
1186 let dir = tempfile::tempdir().unwrap();
1187 let store = ProofReplayStateStore::with_path(dir.path().join("replay.jsonl"));
1188 store.put(&ProofReplayRecord::queued("pi-seen", 1)).unwrap();
1189 store
1190 .put(&ProofReplayRecord::queued("pi-unseen", 2))
1191 .unwrap();
1192
1193 let mut signals: BTreeMap<String, ConveyorSignals> = BTreeMap::new();
1194 signals.insert("pi-seen".to_string(), ConveyorSignals::ready());
1195
1196 let results = store.advance(&signals, 8, 100).unwrap();
1197 assert_eq!(results.len(), 1);
1198 assert_eq!(results[0].intent_id, "pi-seen");
1199 assert_eq!(store.get("pi-unseen").unwrap().state, ProofState::Queued);
1201 assert_eq!(store.get("pi-unseen").unwrap().updated_at_unix_ms, 2);
1202 }
1203}