Skip to main content

rch_common/
proof_replay.rs

1//! Deferred proof replay conveyor and state machine
2//! (bd-session-history-remediation-ocv9i.5.3).
3//!
4//! When proof mode refuses ([`crate::proof_policy`]) it records a durable
5//! [`ProofIntent`](crate::proof_intent::ProofIntent). Those intents are not
6//! dead letters: when the fleet recovers, an eligible worker rejoins, or disk
7//! pressure clears, the proof should run *automatically* — but only when doing
8//! so is genuinely legal and safe.
9//!
10//! This module is that conveyor. It is built from the same pure, deterministic,
11//! clock-free primitives as its siblings: [`decide`] is the single-intent state
12//! transition over observable [`ConveyorSignals`], [`plan_scan`] folds a batch
13//! of queued intents into a fair, starvation-bounded plan, and
14//! [`ProofReplayStateStore`] persists each intent's current [`ProofState`] as an
15//! append-only JSONL log (dedup-by-`intent_id`, latest-wins, corruption-tolerant)
16//! mirroring [`crate::proof_intent::ProofIntentStore`].
17//!
18//! # Invariants the conveyor upholds
19//!
20//! 1. **Source is revalidated before every replay.** A stale intent (changed
21//!    source, changed revision, or expired age — see
22//!    [`validate_replay`](crate::proof_intent::validate_replay)) transitions to
23//!    [`ProofState::Stale`] and is never replayed. A fresh proof must be
24//!    recorded instead (which yields a new `intent_id`).
25//! 2. **Replay never bypasses worker safety policy.** The conveyor only promotes
26//!    an intent to [`ProofState::Replaying`] when the fleet readiness
27//!    [`DecisiveBlocker`] is [`DecisiveBlocker::None`]. Critical pressure, an
28//!    open circuit, a capability gap, a down daemon — every safety block holds
29//!    the intent in [`ProofState::Queued`] or [`ProofState::Blocked`].
30//! 3. **Replay never starves interactive work.** Promotion to `Replaying`
31//!    additionally requires spare capacity ([`ConveyorSignals::replay_capacity_available`]),
32//!    and [`plan_scan`] caps the number of concurrent replays per scan, choosing
33//!    the oldest intents first (FIFO fairness).
34//! 4. **Terminal states are final.** [`ProofState::Passed`], `Failed`, and
35//!    `Stale` are terminal; the conveyor never resurrects them.
36
37use 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/// The published lifecycle state of a deferred proof intent.
51///
52/// Exactly the six states the bead requires. [`Queued`](ProofState::Queued) and
53/// [`Blocked`](ProofState::Blocked) are both "not running yet" but differ in
54/// *why*: a queued intent waits for a transient condition (capacity, health,
55/// pressure) the conveyor retries automatically; a blocked intent waits for a
56/// structural condition (a capability gap, an unhonored placement) that time
57/// alone will not clear.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum ProofState {
61    /// Recorded and waiting on a *transient* recovery (capacity, fleet health,
62    /// pressure). The conveyor re-evaluates it every scan.
63    Queued,
64    /// Waiting on a *structural* condition (no worker can run this command, or a
65    /// requested placement cannot be honored). Held until the condition changes.
66    Blocked,
67    /// Cleared for genuine remote execution this scan.
68    Replaying,
69    /// Replayed remotely and the product succeeded. Terminal.
70    Passed,
71    /// Replayed remotely and the product failed (compile/test error). Terminal —
72    /// re-running an unchanged proof cannot change the result; a fix yields a new
73    /// intent.
74    Failed,
75    /// The recorded source/revision/age no longer matches; the intent can never
76    /// be replayed as-is. Terminal.
77    Stale,
78}
79
80impl ProofState {
81    /// Every state, in stable declaration order (for coverage + iteration).
82    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    /// Stable lowercase token (matches the serde representation).
92    #[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    /// A terminal state never transitions to another state.
105    #[must_use]
106    pub fn is_terminal(self) -> bool {
107        matches!(
108            self,
109            ProofState::Passed | ProofState::Failed | ProofState::Stale
110        )
111    }
112
113    /// Whether a transition from `self` to `next` is legal. Self-transitions are
114    /// always legal (idempotent re-evaluation). Terminal states only transition
115    /// to themselves.
116    #[must_use]
117    pub fn can_transition_to(self, next: ProofState) -> bool {
118        if self == next {
119            return true;
120        }
121        match self {
122            // Non-terminal states freely interconvert and may reach any terminal.
123            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            // A replay attempt resolves to passed/failed, or drops back to queued
132            // on a transient (infrastructure) failure, or to stale if revalidation
133            // failed mid-flight.
134            ProofState::Replaying => matches!(
135                next,
136                ProofState::Passed | ProofState::Failed | ProofState::Queued | ProofState::Stale
137            ),
138            // Terminal: no transition other than the self-transition handled above.
139            ProofState::Passed | ProofState::Failed | ProofState::Stale => false,
140        }
141    }
142}
143
144/// The outcome of a replay attempt that actually ran (or tried to) on a worker.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub enum ReplayOutcome {
148    /// The command ran remotely and the product succeeded.
149    Succeeded,
150    /// The command ran remotely and the product failed (compile/test error).
151    ProductFailed,
152    /// A worker/transport/environment problem prevented a clean result; the
153    /// proof itself is undecided and should be re-queued.
154    InfrastructureFailed,
155}
156
157impl ReplayOutcome {
158    /// Classify the result of an executed replay from its success flag and exec
159    /// failure attribution. A success is [`Succeeded`](ReplayOutcome::Succeeded);
160    /// a failure is a [`ProductFailed`](ReplayOutcome::ProductFailed) only when the
161    /// failure is attributed to the product's own compilation, otherwise it is an
162    /// [`InfrastructureFailed`](ReplayOutcome::InfrastructureFailed) (worker /
163    /// environment / indeterminate), which is retriable.
164    #[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    /// The terminal-or-retry state this outcome resolves a replay to.
178    #[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/// The observable world for a single intent's conveyor decision. Plain data so
189/// the transition is pure and unit-testable.
190#[derive(Debug, Clone)]
191pub struct ConveyorSignals {
192    /// The result of revalidating the intent's source/revision/age against the
193    /// current checkout (see [`validate_replay`](crate::proof_intent::validate_replay)).
194    pub replay: ReplayDecision,
195    /// The decisive fleet-readiness blocker for the intent's command right now
196    /// (see [`assess_readiness`](crate::readiness::assess_readiness)).
197    pub blocker: DecisiveBlocker,
198    /// There is spare capacity to run a replay without starving interactive work.
199    pub replay_capacity_available: bool,
200}
201
202impl ConveyorSignals {
203    /// Signals for an intent whose source is valid, the fleet is fully ready, and
204    /// there is capacity to replay — i.e. it will be promoted to `Replaying`.
205    #[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/// The conveyor's decision for one intent: the next state, whether to actually
216/// attempt the replay now, and a durable reason/detail.
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218pub struct ConveyorDecision {
219    /// The state to transition the intent to.
220    pub next_state: ProofState,
221    /// True iff the conveyor should execute the replay now (only ever true when
222    /// `next_state == Replaying`).
223    pub attempt_replay: bool,
224    /// The decisive incident reason holding/advancing the intent. `None` when the
225    /// intent is cleared to replay (there is no incident).
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub reason: Option<IncidentReasonCode>,
228    /// Operator/agent-facing detail.
229    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/// Decide the next state for a single non-terminal intent given the current
249/// world. Pure and total.
250///
251/// Precedence (most fundamental first):
252/// 1. A terminal `current` is final — the conveyor returns it unchanged.
253/// 2. **Source revalidation wins over fleet state.** If the intent's source /
254///    revision / age no longer validates, it is [`ProofState::Stale`] regardless
255///    of how healthy the fleet is — replaying a stale proof would prove nothing.
256/// 3. Otherwise the decisive fleet [`DecisiveBlocker`] maps to a state: a clean
257///    fleet with spare capacity replays; a clean fleet *without* spare capacity
258///    holds queued (protecting interactive work); a structural capability/
259///    placement gap blocks; every transient block (daemon, health, pressure,
260///    artifact miss) queues for automatic retry.
261#[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    // (2) Source revalidation is decisive and beats any fleet readiness.
273    if let ReplayDecision::Rejected { reason, detail } = &signals.replay {
274        return ConveyorDecision::new(ProofState::Stale, false, Some(*reason), detail.clone());
275    }
276
277    // (3) Map the decisive fleet blocker.
278    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/// A durable record of one intent's conveyor state. Append-only history of these
342/// in the [`ProofReplayStateStore`] makes `rch proof status` answerable without
343/// recomputation.
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
345pub struct ProofReplayRecord {
346    /// Schema version (`SchemaComponent::ProofReplayState`).
347    pub schema_version: String,
348    /// The proof intent this state belongs to (see [`crate::proof_intent`]).
349    pub intent_id: String,
350    /// When the intent was first recorded/queued (Unix epoch ms). Preserved
351    /// across every transition so the conveyor can serve intents oldest-first and
352    /// surface age in status.
353    pub recorded_at_unix_ms: u64,
354    /// The current published state.
355    pub state: ProofState,
356    /// How many replay attempts have been executed for this intent.
357    #[serde(default)]
358    pub attempts: u32,
359    /// The decisive incident reason for the current state, if any.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub last_reason: Option<IncidentReasonCode>,
362    /// Operator/agent-facing detail for the current state.
363    pub last_detail: String,
364    /// Last transition time as Unix epoch milliseconds (caller-supplied →
365    /// deterministic).
366    pub updated_at_unix_ms: u64,
367}
368
369impl ProofReplayRecord {
370    /// A freshly-queued record for a newly-recorded intent.
371    #[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    /// Apply a conveyor [`decide`] result, producing the next record. The
386    /// attempt counter increments only when the decision actually replays. An
387    /// illegal transition (e.g. from a terminal state) is a no-op except for the
388    /// timestamp, preserving the state-machine invariant.
389    #[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    /// Resolve a `Replaying` record with the outcome of the executed replay.
406    /// Succeeded → `Passed`, product failure → `Failed`, infrastructure failure
407    /// → back to `Queued` for a later retry.
408    #[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/// The current proof-replay-state schema version.
435#[must_use]
436pub fn proof_replay_schema_version() -> &'static str {
437    current_version(SchemaComponent::ProofReplayState)
438}
439
440/// One item to evaluate in a conveyor scan: an intent, its current state, the
441/// observed signals, and when it was recorded (for FIFO fairness).
442#[derive(Debug, Clone)]
443pub struct ConveyorScanItem {
444    /// The intent's stable id.
445    pub intent_id: String,
446    /// The intent's current persisted state.
447    pub current_state: ProofState,
448    /// The observed world for this intent.
449    pub signals: ConveyorSignals,
450    /// When the intent was originally recorded (Unix epoch ms) — older first.
451    pub recorded_at_unix_ms: u64,
452}
453
454/// The conveyor's decision for one scanned intent.
455#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
456pub struct ConveyorScanResult {
457    /// The intent's stable id.
458    pub intent_id: String,
459    /// The decision for this intent.
460    pub decision: ConveyorDecision,
461}
462
463/// Plan a full conveyor scan. Each item is evaluated with [`decide`], then the
464/// set promoted to [`ProofState::Replaying`] is capped at `max_concurrent_replays`
465/// — the **oldest** intents (smallest `recorded_at_unix_ms`, ties broken by
466/// `intent_id`) win the available replay slots; the rest are held in
467/// [`ProofState::Queued`]. This is the starvation guard: it bounds how much fleet
468/// capacity deferred proofs may consume in one pass and serves intents fairly.
469///
470/// `max_concurrent_replays == 0` admits no replays this scan (e.g. interactive
471/// demand is saturating the fleet). Results preserve input order.
472#[must_use]
473pub fn plan_scan(
474    items: &[ConveyorScanItem],
475    max_concurrent_replays: usize,
476) -> Vec<ConveyorScanResult> {
477    // First pass: the unconstrained per-intent decision.
478    let mut decisions: Vec<ConveyorDecision> = items
479        .iter()
480        .map(|it| decide(it.current_state, &it.signals))
481        .collect();
482
483    // Identify would-be replays and rank them oldest-first for fair admission.
484    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    // Demote every replay beyond the concurrency cap back to Queued.
498    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/// Append-only, dedup-by-`intent_id`, corruption-tolerant proof-replay-state
518/// store. Mirrors [`crate::proof_intent::ProofIntentStore`]: `put` is O(1) on the
519/// hot path (latest-wins is resolved on read).
520#[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    /// Append a state record. Duplicate `intent_id`s are tolerated on write
537    /// (latest-wins resolved on read).
538    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    /// All current records, deduplicated by `intent_id` (latest write wins), in
556    /// first-seen order. Corrupt lines are skipped.
557    #[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    /// Fetch a single record by intent id (latest wins).
585    #[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    /// Records currently in a given state.
591    #[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    /// A `state -> count` census of every tracked intent (for `rch proof status`).
600    #[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    /// Run one conveyor scan over every tracked, **non-terminal** intent and
610    /// persist the resulting transitions. This is the conveyor's loop body: the
611    /// caller supplies the freshly-observed [`ConveyorSignals`] per intent (which
612    /// require live state — readiness, source revalidation — and so are computed
613    /// at the impure boundary), and this method handles the deterministic part:
614    /// fair, starvation-bounded planning ([`plan_scan`]) and durable persistence.
615    ///
616    /// Intents with no entry in `signals_by_intent` are left untouched (the caller
617    /// could not observe them this tick). Terminal intents are never revisited.
618    /// Returns the per-intent scan results (for the intents that were evaluated).
619    /// `now_unix_ms` stamps every transition deterministically.
620    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    // --- ProofState machine -------------------------------------------------
674
675    #[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                // Only the self-transition is legal from a terminal state.
692                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        // Queued cannot leap straight to Passed/Failed — it must replay first.
710        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    // --- ReplayOutcome ------------------------------------------------------
725
726    #[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    // --- decide(): the four mandated scenarios + invariants -----------------
759
760    #[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        // Anti-starvation: a fully-ready fleet still defers when there is no spare
771        // capacity for replay.
772        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        // While pressured: queued, never replayed.
785        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        // After pressure clears: replays.
796        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            // Even a perfectly healthy fleet must not replay a stale proof.
806            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        // Before rejoin: workers desired but none healthy -> queued.
834        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        // After a canary rejoins and becomes admissible: replays.
844        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        // The cardinal safety invariant: no blocker except `None` may ever replay.
852        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        // Integration with the real proof-intent revalidation primitive.
884        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        // Source changed since recording.
903        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        // The intent carries no conveyor state of its own; the conveyor tracks
917        // that separately. A queued intent whose source no longer validates is
918        // stale regardless of fleet health.
919        let _ = &intent;
920        assert_eq!(
921            decide(ProofState::Queued, &signals).next_state,
922            ProofState::Stale
923        );
924    }
925
926    // --- ProofReplayRecord transitions --------------------------------------
927
928    #[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        // A subsequent "queued" decision must NOT resurrect a passed proof.
952        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    // --- plan_scan(): fairness + anti-starvation ----------------------------
983
984    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        // Three ready intents, but capacity for only two replays this scan.
996        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        // The two oldest win the replay slots.
1010        assert_eq!(by_id("pi-old").next_state, ProofState::Replaying);
1011        assert_eq!(by_id("pi-mid").next_state, ProofState::Replaying);
1012        // The newest is held queued for fairness.
1013        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        // Output preserves input order.
1018        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        // Blocked/stale items are unaffected by the replay cap.
1035        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        // The genuinely-ready one is the only one the cap demotes.
1045        assert_eq!(by_id("pi-ready").decision.next_state, ProofState::Queued);
1046    }
1047
1048    // --- ProofReplayStateStore ----------------------------------------------
1049
1050    #[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    // --- advance(): the conveyor loop body ----------------------------------
1125
1126    #[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        // Three queued intents (oldest first) and one already-passed terminal.
1132        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        // Signals: all three queued are ready; the terminal one is (wrongly) also
1147        // offered a signal to prove terminals are skipped.
1148        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        // Capacity for only one replay this tick.
1154        let results = store.advance(&signals, 1, 10_000).unwrap();
1155
1156        // The terminal intent is never evaluated.
1157        assert!(results.iter().all(|r| r.intent_id != "pi-done"));
1158        assert_eq!(results.len(), 3);
1159
1160        // Exactly one replay, and it is the oldest.
1161        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        // Persistence reflects the plan: pi-old replaying, others queued, terminal intact.
1169        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        // A follow-up tick with more capacity advances the rest.
1175        let results2 = store.advance(&signals, 8, 11_000).unwrap();
1176        // pi-old is now Replaying (non-terminal), so it is re-evaluated too.
1177        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        // The unobserved intent is untouched (still at its original queued state).
1200        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}