Skip to main content

flint_lean/
gauges.rs

1//! `.flint-sync/gauges.json` — the Phase-3 observability minimum
2//! (§2.6; review ledger OF-6).
3//!
4//! The question this file exists to answer is "why is the manifest not
5//! advancing", and it has to be answerable from inside the pod, with
6//! `cat`, before Phase 6's `/metrics` exists. A healthy no-change tick
7//! and a wedged loop look identical from the outside: both publish
8//! nothing.
9//!
10//! **Every field here is computed from LOCAL state — no bucket request,
11//! ever.** That is enforced by the signature rather than by convention:
12//! `write_gauges` is not `async` and takes no store, so it *cannot*
13//! issue one. This is the "instrument reports on itself" class the
14//! runas campaign paid for five times; here it would also make the
15//! zero-added-cost oracle (leg B8) intermittently red and blame the
16//! syncer for its own instrument.
17//!
18//! Phase 6 renders `/metrics` from this same struct — one renderer over
19//! one struct cannot drift; two computations would.
20
21use serde::{Deserialize, Serialize};
22
23use super::{now_unix, LeanResult, Syncer};
24
25const GAUGES: &str = "gauges.json";
26
27/// Which coherent point last installed a citation, and what it named.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct LastBoundary {
30    pub source: String,
31    pub seq: u64,
32    pub unix: u64,
33}
34
35/// Why visibility is currently withheld. `None` = nothing is withheld.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Withheld {
38    /// A foreign 412 parked at least one path (it is NOT ours to
39    /// publish, and the conflict record says so).
40    Parked412,
41    /// The barrier held at least one path the syncer may not read, or
42    /// whose name is not UTF-8 (`BarrierReport::held`); the rest of the
43    /// tree publishes.
44    Unreadable,
45}
46
47impl Withheld {
48    pub fn as_str(&self) -> &'static str {
49        match self {
50            Withheld::Parked412 => "parked-412",
51            Withheld::Unreadable => "unreadable",
52        }
53    }
54}
55
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
57pub struct Gauges {
58    /// Since the last boundary was installed. Elapsed time, NOT
59    /// exposure: an idle healthy workspace has nothing at risk and a
60    /// growing `rpo_secs`. Pair it with `withheld_reason`, which carries
61    /// whether there is anything to lose. Alerting on this number alone
62    /// pages someone for a workspace that is simply quiet.
63    pub rpo_secs: u64,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub withheld_reason: Option<String>,
66    pub sentinel_budget_remaining: u64,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub last_boundary: Option<LastBoundary>,
69    /// Unix time of the first renewal the store refused with 401/403
70    /// (`StoreError::Auth`) since the last successful one — a
71    /// credential or broker fault, never contention and never a lease
72    /// conflict. `None` once a renewal succeeds.
73    ///
74    /// This is the ONLY liveness fact a credential-paused holder can
75    /// still record. The renewal that would carry it into the lease
76    /// echo is precisely the request that is failing, so the store
77    /// cannot be told; local evidence is all there is. Without it an
78    /// operator sees a lease going stale and a pod that is plainly
79    /// Running, and nothing that connects the two — the diagnosis
80    /// `StoreError::Auth` was split out of `Other` to make possible in
81    /// the first place (`flint-store/src/lib.rs`), stopping one layer
82    /// short of the binary that needed it.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub auth_paused_since_unix: Option<u64>,
85    /// Refreshed on EVERY tick, news or not: an idle-but-healthy
86    /// workspace must be distinguishable from a dead one (the same
87    /// heartbeat rule `remote.seq` carries).
88    pub updated_unix: u64,
89    /// Carried across ticks so `rpo_secs` measures durability rather
90    /// than "time since this process started".
91    #[serde(default)]
92    pub last_durable_unix: u64,
93}
94
95impl Syncer {
96    fn gauges_path(&self) -> std::path::PathBuf {
97        self.cfg.state_dir().join(GAUGES)
98    }
99
100    /// Last written gauges, or defaults. Never fails on a corrupt file:
101    /// a diagnosis surface that panics is worse than a stale one.
102    pub fn load_gauges(&self) -> LeanResult<Gauges> {
103        let p = self.gauges_path();
104        if !p.exists() {
105            return Ok(Gauges::default());
106        }
107        Ok(std::fs::read(&p)
108            .ok()
109            .and_then(|b| serde_json::from_slice(&b).ok())
110            .unwrap_or_default())
111    }
112
113    /// Record that a boundary installed.
114    pub fn note_boundary(&self, source: &str, seq: u64) -> LeanResult<()> {
115        let mut g = self.load_gauges()?;
116        g.last_boundary = Some(LastBoundary { source: source.into(), seq, unix: now_unix() });
117        g.last_durable_unix = now_unix();
118        self.save_gauges(&g)
119    }
120
121    /// Record that the store refused our credentials on a renewal.
122    /// First refusal wins: the gauge answers "since when", so a second
123    /// consecutive failure must not keep resetting the clock to now.
124    pub fn note_auth_pause(&self) -> LeanResult<()> {
125        let mut g = self.load_gauges()?;
126        if g.auth_paused_since_unix.is_none() {
127            g.auth_paused_since_unix = Some(now_unix());
128            self.save_gauges(&g)?;
129        }
130        Ok(())
131    }
132
133    /// Clear the pause. Writes only on an actual transition: this runs
134    /// on every successful renewal and floor tick, and the healthy path
135    /// must not rewrite the file every tick for no change.
136    pub fn clear_auth_pause(&self) -> LeanResult<()> {
137        let mut g = self.load_gauges()?;
138        if g.auth_paused_since_unix.is_some() {
139            g.auth_paused_since_unix = None;
140            self.save_gauges(&g)?;
141        }
142        Ok(())
143    }
144
145    fn save_gauges(&self, g: &Gauges) -> LeanResult<()> {
146        let bytes = serde_json::to_vec_pretty(g)
147            .map_err(|e| super::LeanError::State(format!("gauges: {e}")))?;
148        super::control::write_atomic(&self.gauges_path(), &bytes)
149    }
150
151    /// Recompute and write the gauges. Deliberately synchronous and
152    /// store-free: a scrape, a tick and an exec all cost zero bucket
153    /// requests, and the type system is what says so.
154    pub fn write_gauges(&self, withheld: Option<Withheld>) -> LeanResult<Gauges> {
155        let now = now_unix();
156        let prev = self.load_gauges()?;
157        let budget = self.load_budget().unwrap_or_default();
158        let g = Gauges {
159            rpo_secs: if prev.last_durable_unix == 0 {
160                0
161            } else {
162                now.saturating_sub(prev.last_durable_unix)
163            },
164            withheld_reason: withheld.map(|w| w.as_str().to_string()),
165            sentinel_budget_remaining: budget.remaining(now, self.cfg.sentinel_hourly_budget),
166            last_boundary: prev.last_boundary.clone(),
167            updated_unix: now,
168            last_durable_unix: prev.last_durable_unix,
169            // Carried, not recomputed: `write_gauges` is store-free by
170            // construction, so it cannot observe the credential state
171            // that set this. Dropping it here would erase the pause on
172            // the very next tick — the gauge would exist and always
173            // read `None`.
174            auth_paused_since_unix: prev.auth_paused_since_unix,
175        };
176        self.save_gauges(&g)?;
177        Ok(g)
178    }
179}
180
181/// What `flint-sync status` renders. Read STRICTLY from files: the verb
182/// exists to diagnose a workspace whose syncer is dead or deposed, so
183/// it must neither claim the lease (which would depose the very syncer
184/// under diagnosis) nor take the state-directory occupancy lock (which
185/// a live syncer already holds).
186#[derive(Debug, Clone, Serialize)]
187pub struct StatusReport {
188    pub root: String,
189    pub prefix: String,
190    pub gauges: Option<Gauges>,
191    pub capabilities: Option<super::control::Capabilities>,
192    pub remote_seq: Option<super::control::RemoteSeq>,
193    pub baseline_seq: u64,
194    pub incarnation_epoch: Option<u64>,
195    pub incarnation_holder: Option<String>,
196    /// A pending sentinel still standing (verb name) — the "my agent is
197    /// blocked on an ack" question.
198    pub pending_sentinels: Vec<String>,
199    pub recent_conflicts: usize,
200    pub checkout_complete: bool,
201}
202
203fn read_json<T: serde::de::DeserializeOwned>(p: &std::path::Path) -> Option<T> {
204    serde_json::from_slice(&std::fs::read(p).ok()?).ok()
205}
206
207pub fn status_report(cfg: &super::LeanConfig) -> LeanResult<StatusReport> {
208    let sd = cfg.state_dir();
209    let cd = cfg.control_dir();
210    // The snapshot AND its journal (lever D): the snapshot alone is the
211    // baseline as of the last whole write.
212    let baseline = super::state::read_baseline_at(&sd).unwrap_or_default();
213    let inc: Option<super::state::Incarnation> = read_json(&sd.join("incarnation.json"));
214    let mut pending_sentinels = vec![];
215    // Named by the SAME function that writes them. The previous form
216    // built the name a second time and got it wrong, so this field —
217    // the "is my agent blocked on an ack?" answer — was permanently
218    // empty on a workspace that had a sentinel standing.
219    for verb in [super::sentinel::Verb::Publish, super::sentinel::Verb::Sync] {
220        if sd.join(verb.pending_name()).exists() {
221            pending_sentinels.push(verb.sentinel_name().to_string());
222        }
223    }
224    let conflicts = std::fs::read_to_string(sd.join("conflicts.jsonl"))
225        .map(|s| s.lines().filter(|l| !l.trim().is_empty()).count())
226        .unwrap_or(0);
227    Ok(StatusReport {
228        root: cfg.root.display().to_string(),
229        prefix: cfg.prefix.clone(),
230        gauges: read_json(&sd.join(GAUGES)),
231        capabilities: read_json(&cd.join(super::control::CAPABILITIES)),
232        remote_seq: read_json(&cd.join(super::control::REMOTE_SEQ)),
233        baseline_seq: baseline.seq,
234        incarnation_epoch: inc.as_ref().map(|i| i.epoch),
235        incarnation_holder: inc.map(|i| i.holder_id),
236        pending_sentinels,
237        recent_conflicts: conflicts,
238        checkout_complete: sd.join("checkout-complete").exists(),
239    })
240}