Skip to main content

rch_common/
readiness.rs

1//! Readiness split + diagnose incident-chain replay
2//! (bd-session-history-remediation-ocv9i.4.3).
3//!
4//! Session history showed a `remote_ready` banner appearing while the selected
5//! command actually had **zero** admissible workers — a single boolean hiding
6//! four distinct questions. This module splits readiness into its real
7//! dimensions — daemon reachability, desired fleet, live health, and
8//! command-admissible workers — and computes a single decisive blocker, with the
9//! hard invariant that **`remote_ready` is never true when zero workers are
10//! admissible for the command**.
11//!
12//! [`assess_readiness`] is pure and total: given the split plus proof-mode and
13//! recent-incident context it yields a [`ReadinessReport`] carrying the split,
14//! the decisive [`DecisiveBlocker`], and a replayed incident chain — exactly
15//! what `rch diagnose --json -- <command>` surfaces. The report is deterministic
16//! (no clock) so the scenario JSON goldens are stable.
17
18use serde::{Deserialize, Serialize};
19
20use crate::admission_rejection::AdmissionRejectionCategory;
21use crate::incident::{IncidentEvent, IncidentReasonCode};
22
23/// The four real readiness dimensions, never collapsed into one boolean.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25pub struct ReadinessSplit {
26    /// The daemon Unix socket answered.
27    pub daemon_reachable: bool,
28    /// Workers the configuration desires in the fleet.
29    pub desired_fleet_workers: u32,
30    /// Workers currently live and healthy.
31    pub live_healthy_workers: u32,
32    /// Workers admissible for the *selected command* (capability-checked).
33    pub command_admissible_workers: u32,
34}
35
36/// The single decisive reason remote execution is (not) ready.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum DecisiveBlocker {
40    /// Ready — at least one worker is admissible and nothing blocks.
41    None,
42    /// The daemon socket did not answer.
43    DaemonDown,
44    /// Proof mode refused before execution.
45    ProofRefused,
46    /// No workers are configured/desired in the fleet.
47    NoDesiredFleet,
48    /// Workers are desired but none are live and healthy.
49    NoHealthyWorkers,
50    /// Healthy workers exist but none can run THIS command (capability gap).
51    NoCommandCapability,
52    /// Admissibility blocked by critical resource pressure.
53    PressureBlocked,
54    /// The most recent attempt failed at artifact retrieval.
55    ArtifactMissed,
56}
57
58impl DecisiveBlocker {
59    /// Stable lowercase token.
60    #[must_use]
61    pub fn as_str(self) -> &'static str {
62        match self {
63            DecisiveBlocker::None => "none",
64            DecisiveBlocker::DaemonDown => "daemon_down",
65            DecisiveBlocker::ProofRefused => "proof_refused",
66            DecisiveBlocker::NoDesiredFleet => "no_desired_fleet",
67            DecisiveBlocker::NoHealthyWorkers => "no_healthy_workers",
68            DecisiveBlocker::NoCommandCapability => "no_command_capability",
69            DecisiveBlocker::PressureBlocked => "pressure_blocked",
70            DecisiveBlocker::ArtifactMissed => "artifact_missed",
71        }
72    }
73}
74
75/// One compact entry in the replayed incident chain.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct IncidentChainEntry {
78    /// Stable reason code (`RCH-Innn`).
79    pub reason_code: IncidentReasonCode,
80    /// Event time as Unix epoch milliseconds.
81    pub occurred_at_unix_ms: u64,
82}
83
84impl From<&IncidentEvent> for IncidentChainEntry {
85    fn from(event: &IncidentEvent) -> Self {
86        Self {
87            reason_code: event.reason_code,
88            occurred_at_unix_ms: event.occurred_at_unix_ms,
89        }
90    }
91}
92
93/// Inputs for the readiness assessment. Plain data; pure assessment.
94#[derive(Debug, Clone)]
95pub struct ReadinessInputs {
96    pub split: ReadinessSplit,
97    /// Proof mode refused execution (see [`crate::proof_policy`]).
98    pub proof_refused: bool,
99    /// The dominant rejection category when no worker is admissible (lets a
100    /// pressure block read as `pressure_blocked` rather than a generic gap).
101    pub dominant_rejection: Option<AdmissionRejectionCategory>,
102    /// Recent incidents for the command/project, oldest-first.
103    pub recent_incidents: Vec<IncidentChainEntry>,
104}
105
106/// The readiness report `rch diagnose --json` surfaces.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct ReadinessReport {
109    pub split: ReadinessSplit,
110    /// True iff there is no decisive blocker. By construction this is impossible
111    /// when `command_admissible_workers == 0`.
112    pub remote_ready: bool,
113    pub blocker: DecisiveBlocker,
114    /// The replayed recent incident chain, oldest-first.
115    pub incident_chain: Vec<IncidentChainEntry>,
116    pub detail: String,
117}
118
119impl ReadinessReport {
120    /// The invariant guard: `remote_ready` implies at least one admissible
121    /// worker. Always true for a report from [`assess_readiness`]; exposed so
122    /// callers/tests can assert it.
123    #[must_use]
124    pub fn invariant_holds(&self) -> bool {
125        !self.remote_ready || self.split.command_admissible_workers > 0
126    }
127}
128
129/// Assess readiness. Pure and total. Decisive-blocker precedence (most
130/// fundamental first): daemon down, proof refused, no desired fleet, no healthy
131/// workers, no command capability (pressure-distinguished), then a trailing
132/// artifact-miss from the most recent incident. Only when none apply is it
133/// `remote_ready`.
134#[must_use]
135pub fn assess_readiness(inputs: &ReadinessInputs) -> ReadinessReport {
136    let s = &inputs.split;
137    let blocker = if !s.daemon_reachable {
138        DecisiveBlocker::DaemonDown
139    } else if inputs.proof_refused {
140        DecisiveBlocker::ProofRefused
141    } else if s.desired_fleet_workers == 0 {
142        DecisiveBlocker::NoDesiredFleet
143    } else if s.live_healthy_workers == 0 {
144        DecisiveBlocker::NoHealthyWorkers
145    } else if s.command_admissible_workers == 0 {
146        if inputs.dominant_rejection == Some(AdmissionRejectionCategory::CriticalPressure) {
147            DecisiveBlocker::PressureBlocked
148        } else {
149            DecisiveBlocker::NoCommandCapability
150        }
151    } else if inputs
152        .recent_incidents
153        .last()
154        .is_some_and(|e| e.reason_code == IncidentReasonCode::ArtifactMiss)
155    {
156        DecisiveBlocker::ArtifactMissed
157    } else {
158        DecisiveBlocker::None
159    };
160
161    let remote_ready = blocker == DecisiveBlocker::None;
162    let detail = match blocker {
163        DecisiveBlocker::None => "remote execution ready".to_string(),
164        DecisiveBlocker::DaemonDown => "daemon socket unreachable".to_string(),
165        DecisiveBlocker::ProofRefused => "proof mode refused before execution".to_string(),
166        DecisiveBlocker::NoDesiredFleet => "no workers configured in the fleet".to_string(),
167        DecisiveBlocker::NoHealthyWorkers => {
168            format!(
169                "{} desired worker(s) but none live/healthy",
170                s.desired_fleet_workers
171            )
172        }
173        DecisiveBlocker::NoCommandCapability => format!(
174            "{} healthy worker(s) but none admissible for this command",
175            s.live_healthy_workers
176        ),
177        DecisiveBlocker::PressureBlocked => {
178            "admissibility blocked by critical resource pressure".to_string()
179        }
180        DecisiveBlocker::ArtifactMissed => {
181            "ready, but the most recent attempt failed at artifact retrieval".to_string()
182        }
183    };
184
185    ReadinessReport {
186        split: *s,
187        remote_ready,
188        blocker,
189        incident_chain: inputs.recent_incidents.clone(),
190        detail,
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    fn entry(reason: IncidentReasonCode, ts: u64) -> IncidentChainEntry {
199        IncidentChainEntry {
200            reason_code: reason,
201            occurred_at_unix_ms: ts,
202        }
203    }
204
205    fn split(daemon: bool, desired: u32, healthy: u32, admissible: u32) -> ReadinessSplit {
206        ReadinessSplit {
207            daemon_reachable: daemon,
208            desired_fleet_workers: desired,
209            live_healthy_workers: healthy,
210            command_admissible_workers: admissible,
211        }
212    }
213
214    fn inputs(split: ReadinessSplit) -> ReadinessInputs {
215        ReadinessInputs {
216            split,
217            proof_refused: false,
218            dominant_rejection: None,
219            recent_incidents: Vec::new(),
220        }
221    }
222
223    // --- The hard invariant -------------------------------------------------
224
225    #[test]
226    fn remote_ready_never_appears_with_zero_admissible() {
227        // Exhaustively: any split with zero admissible workers is NOT ready.
228        for daemon in [false, true] {
229            for desired in [0u32, 2] {
230                for healthy in [0u32, 2] {
231                    let report = assess_readiness(&inputs(split(daemon, desired, healthy, 0)));
232                    assert!(!report.remote_ready, "ready with 0 admissible: {report:?}");
233                    assert!(report.invariant_holds());
234                }
235            }
236        }
237    }
238
239    // --- The six required golden scenarios ----------------------------------
240
241    #[test]
242    fn golden_healthy() {
243        let report = assess_readiness(&inputs(split(true, 3, 3, 2)));
244        let v = serde_json::to_value(&report).unwrap();
245        assert_eq!(v["remote_ready"], true);
246        assert_eq!(v["blocker"], "none");
247        assert_eq!(v["split"]["command_admissible_workers"], 2);
248        assert!(report.invariant_holds());
249    }
250
251    #[test]
252    fn golden_daemon_down() {
253        let report = assess_readiness(&inputs(split(false, 3, 0, 0)));
254        let v = serde_json::to_value(&report).unwrap();
255        assert_eq!(v["remote_ready"], false);
256        assert_eq!(v["blocker"], "daemon_down");
257    }
258
259    #[test]
260    fn golden_healthy_but_no_command_capability() {
261        // Daemon up, healthy workers, but none admissible for the command.
262        let report = assess_readiness(&inputs(split(true, 3, 3, 0)));
263        let v = serde_json::to_value(&report).unwrap();
264        assert_eq!(v["remote_ready"], false);
265        assert_eq!(v["blocker"], "no_command_capability");
266        assert_eq!(v["split"]["live_healthy_workers"], 3);
267    }
268
269    #[test]
270    fn golden_pressure_blocked() {
271        let mut i = inputs(split(true, 3, 3, 0));
272        i.dominant_rejection = Some(AdmissionRejectionCategory::CriticalPressure);
273        let report = assess_readiness(&i);
274        let v = serde_json::to_value(&report).unwrap();
275        assert_eq!(v["remote_ready"], false);
276        assert_eq!(v["blocker"], "pressure_blocked");
277    }
278
279    #[test]
280    fn golden_proof_refused() {
281        let mut i = inputs(split(true, 3, 3, 2));
282        i.proof_refused = true;
283        let report = assess_readiness(&i);
284        let v = serde_json::to_value(&report).unwrap();
285        assert_eq!(v["remote_ready"], false);
286        assert_eq!(v["blocker"], "proof_refused");
287    }
288
289    #[test]
290    fn golden_artifact_missed() {
291        // Ready by capacity, but the most recent incident is an artifact miss.
292        let mut i = inputs(split(true, 3, 3, 2));
293        i.recent_incidents = vec![
294            entry(IncidentReasonCode::LocalFallback, 1_700_000_000_000),
295            entry(IncidentReasonCode::ArtifactMiss, 1_700_000_001_000),
296        ];
297        let report = assess_readiness(&i);
298        let v = serde_json::to_value(&report).unwrap();
299        assert_eq!(v["remote_ready"], false);
300        assert_eq!(v["blocker"], "artifact_missed");
301        // The incident chain is replayed, oldest-first.
302        assert_eq!(v["incident_chain"][0]["reason_code"], "RCH-I011");
303        assert_eq!(v["incident_chain"][1]["reason_code"], "RCH-I014");
304        assert_eq!(
305            v["incident_chain"][1]["occurred_at_unix_ms"],
306            1_700_000_001_000u64
307        );
308    }
309
310    // --- Precedence + structure --------------------------------------------
311
312    #[test]
313    fn daemon_down_outranks_everything() {
314        let mut i = inputs(split(false, 0, 0, 0));
315        i.proof_refused = true;
316        assert_eq!(assess_readiness(&i).blocker, DecisiveBlocker::DaemonDown);
317    }
318
319    #[test]
320    fn no_healthy_distinct_from_no_desired_and_no_capability() {
321        assert_eq!(
322            assess_readiness(&inputs(split(true, 0, 0, 0))).blocker,
323            DecisiveBlocker::NoDesiredFleet
324        );
325        assert_eq!(
326            assess_readiness(&inputs(split(true, 3, 0, 0))).blocker,
327            DecisiveBlocker::NoHealthyWorkers
328        );
329        assert_eq!(
330            assess_readiness(&inputs(split(true, 3, 3, 0))).blocker,
331            DecisiveBlocker::NoCommandCapability
332        );
333    }
334
335    #[test]
336    fn artifact_miss_only_blocks_when_it_is_the_latest_incident() {
337        // An older artifact-miss followed by a clean event does NOT block.
338        let mut i = inputs(split(true, 3, 3, 2));
339        i.recent_incidents = vec![
340            entry(IncidentReasonCode::ArtifactMiss, 1_700_000_000_000),
341            entry(IncidentReasonCode::LocalFallback, 1_700_000_001_000),
342        ];
343        assert_eq!(assess_readiness(&i).blocker, DecisiveBlocker::None);
344        assert!(assess_readiness(&i).remote_ready);
345    }
346
347    #[test]
348    fn report_round_trips() {
349        let report = assess_readiness(&inputs(split(true, 3, 3, 2)));
350        let v = serde_json::to_value(&report).unwrap();
351        let back: ReadinessReport = serde_json::from_value(v).unwrap();
352        assert_eq!(back, report);
353    }
354
355    #[test]
356    fn incident_chain_entry_from_event() {
357        use crate::incident::{IncidentEvent, IncidentEventType, IncidentSource, SelectedMode};
358        let event = IncidentEvent::new(
359            IncidentEventType::ArtifactRetrieval,
360            IncidentReasonCode::ArtifactMiss,
361            IncidentSource::Hook,
362            "proj",
363            "cargo build",
364            SelectedMode::Remote,
365            true,
366            1_700_000_002_000,
367        );
368        let chain_entry = IncidentChainEntry::from(&event);
369        assert_eq!(chain_entry.reason_code, IncidentReasonCode::ArtifactMiss);
370        assert_eq!(chain_entry.occurred_at_unix_ms, 1_700_000_002_000);
371    }
372}