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. In `gated` especially, a
7//! healthy tick and a wedged loop look identical from the outside:
8//! both publish 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///
37/// (The plan's draft listed `copy-probe-failed`; §8 Q2 withdrew the
38/// CopyObject staging engine entirely, so the analogous reason names
39/// the machinery that actually exists — the versioning conformance
40/// probe.)
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Withheld {
43    /// Staged work waiting for a coherent point that has not arrived.
44    QuiescePending,
45    /// Staged work past quiescence, waiting on this tick's citation.
46    AwaitingBoundary,
47    /// A foreign 412 parked at least one path (it is NOT ours to
48    /// publish, and the conflict record says so).
49    Parked412,
50    /// The citation CAS lost its races.
51    CasConflict,
52    /// The version surface probe failed — gated mode is refused, not
53    /// degraded.
54    VersionProbeFailed,
55}
56
57impl Withheld {
58    pub fn as_str(&self) -> &'static str {
59        match self {
60            Withheld::QuiescePending => "quiesce-pending",
61            Withheld::AwaitingBoundary => "awaiting-boundary",
62            Withheld::Parked412 => "parked-412",
63            Withheld::CasConflict => "cas-conflict",
64            Withheld::VersionProbeFailed => "version-probe-failed",
65        }
66    }
67}
68
69#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70pub struct Gauges {
71    /// `live` | `fenced` — must agree with `capabilities.json`. An
72    /// agent reading only the operational file must not conclude a
73    /// zombie is healthy.
74    pub state: String,
75    pub boundary_mode: String,
76    /// Since the last DURABLE write (a staged byte or an installed
77    /// barrier), not since the last visible one.
78    ///
79    /// It is elapsed time, NOT exposure: an idle healthy workspace has
80    /// nothing at risk and a growing `rpo_secs`. Pair it with
81    /// `staged_uncited_count`/`withheld_reason` — those carry whether
82    /// there is anything to lose. Alerting on this number alone pages
83    /// someone for a workspace that is simply quiet.
84    pub rpo_secs: u64,
85    /// Since the last CITATION. In cadence/hybrid this equals
86    /// `rpo_secs` by construction; in gated it is the number the lag
87    /// cap bounds.
88    ///
89    /// Deliberately the SAME arithmetic `citation_due` tests, elapsed
90    /// time and all: a gauge that reported a different number than the
91    /// mechanism it describes would be worse than no gauge — an
92    /// operator would tune `visibilityLagBoundSecs` against a figure
93    /// the cap never sees.
94    pub visibility_lag_secs: u64,
95    pub staged_uncited_count: u64,
96    pub staged_uncited_bytes: u64,
97    /// How long the OLDEST still-cited version has been noncurrent —
98    /// D8's inversion, gauged. Gated staging makes the cited version
99    /// noncurrent, so the retention backstop runs a clock against live
100    /// cited data; this is that clock, and `noncurrentRetentionDays` is
101    /// the number it must never reach.
102    pub cited_noncurrent_age_max_secs: u64,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub withheld_reason: Option<String>,
105    pub sentinel_budget_remaining: u64,
106    /// Cumulative `forced-lag-cap` + `forced-backlog-cap` citations.
107    /// A workspace that forces every citation has no coherence
108    /// contract left, and this is how anyone finds out.
109    pub forced_citation_count: u64,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub last_boundary: Option<LastBoundary>,
112    /// Unix time of the first renewal the store refused with 401/403
113    /// (`StoreError::Auth`) since the last successful one — a
114    /// credential or broker fault, never contention and never a lease
115    /// conflict. `None` once a renewal succeeds.
116    ///
117    /// This is the ONLY liveness fact a credential-paused holder can
118    /// still record. The renewal that would carry it into the lease
119    /// echo is precisely the request that is failing, so the store
120    /// cannot be told; local evidence is all there is. Without it an
121    /// operator sees a lease going stale and a pod that is plainly
122    /// Running, and nothing that connects the two — the diagnosis
123    /// `StoreError::Auth` was split out of `Other` to make possible in
124    /// the first place (`flint-store/src/lib.rs`), stopping one layer
125    /// short of the binary that needed it.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub auth_paused_since_unix: Option<u64>,
128    /// Refreshed on EVERY tick, news or not: an idle-but-healthy
129    /// workspace must be distinguishable from a dead one (the same
130    /// heartbeat rule `remote.seq` carries).
131    pub updated_unix: u64,
132    /// Carried across ticks so `rpo_secs` measures durability rather
133    /// than "time since this process started".
134    #[serde(default)]
135    pub last_durable_unix: u64,
136}
137
138impl Syncer {
139    fn gauges_path(&self) -> std::path::PathBuf {
140        self.cfg.state_dir().join(GAUGES)
141    }
142
143    /// Last written gauges, or defaults. Never fails on a corrupt file:
144    /// a diagnosis surface that panics is worse than a stale one.
145    pub fn load_gauges(&self) -> LeanResult<Gauges> {
146        let p = self.gauges_path();
147        if !p.exists() {
148            return Ok(Gauges::default());
149        }
150        Ok(std::fs::read(&p)
151            .ok()
152            .and_then(|b| serde_json::from_slice(&b).ok())
153            .unwrap_or_default())
154    }
155
156    /// Record that a citation installed. Bumps the forced counter when
157    /// the source was a cap rather than a declared coherent point.
158    pub fn note_boundary(&self, source: &str, seq: u64) -> LeanResult<()> {
159        let mut g = self.load_gauges()?;
160        if source.starts_with("forced-") {
161            g.forced_citation_count += 1;
162        }
163        g.last_boundary = Some(LastBoundary { source: source.into(), seq, unix: now_unix() });
164        g.last_durable_unix = now_unix();
165        self.save_gauges(&g)
166    }
167
168    /// Record that bytes were made durable without being cited (the
169    /// gated upload lane).
170    pub fn note_durable(&self) -> LeanResult<()> {
171        let mut g = self.load_gauges()?;
172        g.last_durable_unix = now_unix();
173        self.save_gauges(&g)
174    }
175
176    /// Record that the store refused our credentials on a renewal.
177    /// First refusal wins: the gauge answers "since when", so a second
178    /// consecutive failure must not keep resetting the clock to now.
179    pub fn note_auth_pause(&self) -> LeanResult<()> {
180        let mut g = self.load_gauges()?;
181        if g.auth_paused_since_unix.is_none() {
182            g.auth_paused_since_unix = Some(now_unix());
183            self.save_gauges(&g)?;
184        }
185        Ok(())
186    }
187
188    /// Clear the pause. Writes only on an actual transition: this runs
189    /// on every successful renewal, and the healthy path must not
190    /// rewrite the file every heartbeat for no change.
191    pub fn clear_auth_pause(&self) -> LeanResult<()> {
192        let mut g = self.load_gauges()?;
193        if g.auth_paused_since_unix.is_some() {
194            g.auth_paused_since_unix = None;
195            self.save_gauges(&g)?;
196        }
197        Ok(())
198    }
199
200    fn save_gauges(&self, g: &Gauges) -> LeanResult<()> {
201        let bytes = serde_json::to_vec_pretty(g)
202            .map_err(|e| super::LeanError::State(format!("gauges: {e}")))?;
203        super::control::write_atomic(&self.gauges_path(), &bytes)
204    }
205
206    /// Recompute and write the gauges. Deliberately synchronous and
207    /// store-free: a scrape, a tick and an exec all cost zero bucket
208    /// requests, and the type system is what says so.
209    pub fn write_gauges(&self, fenced: bool, withheld: Option<Withheld>) -> LeanResult<Gauges> {
210        let now = now_unix();
211        let prev = self.load_gauges()?;
212        let stage = self.load_stage()?;
213        let budget = self.load_budget().unwrap_or_default();
214
215        let staged_bytes: u64 = stage.entries.values().map(|e| e.size).sum();
216        // The noncurrent clock starts when a version BECOMES noncurrent
217        // — i.e. when our staging PUT landed over it — so the oldest
218        // staged entry is the one nearest the backstop.
219        let oldest_stage = stage.entries.values().map(|e| e.staged_unix).min();
220        let cited_noncurrent_age_max_secs =
221            oldest_stage.map(|t| now.saturating_sub(t)).unwrap_or(0);
222
223        // Withheld is a fact about the stage, so an argument of `None`
224        // must not paper over staged work the caller forgot to describe.
225        let withheld = withheld.or_else(|| {
226            if stage.entries.is_empty() && stage.withheld_deletes.is_empty() {
227                None
228            } else if stage.stable_since_unix > 0 {
229                Some(Withheld::AwaitingBoundary)
230            } else {
231                Some(Withheld::QuiescePending)
232            }
233        });
234
235        let last_citation = stage.last_citation_unix.max(
236            prev.last_boundary.as_ref().map(|b| b.unix).unwrap_or(0),
237        );
238        let g = Gauges {
239            state: if fenced { "fenced".into() } else { "live".into() },
240            boundary_mode: self.cfg.boundary_mode.as_str().to_string(),
241            rpo_secs: if prev.last_durable_unix == 0 {
242                0
243            } else {
244                now.saturating_sub(prev.last_durable_unix)
245            },
246            visibility_lag_secs: if last_citation == 0 {
247                0
248            } else {
249                now.saturating_sub(last_citation)
250            },
251            staged_uncited_count: stage.entries.len() as u64,
252            staged_uncited_bytes: staged_bytes,
253            cited_noncurrent_age_max_secs,
254            withheld_reason: withheld.map(|w| w.as_str().to_string()),
255            sentinel_budget_remaining: budget.remaining(now, self.cfg.sentinel_hourly_budget),
256            forced_citation_count: prev.forced_citation_count,
257            last_boundary: prev.last_boundary.clone(),
258            updated_unix: now,
259            last_durable_unix: prev.last_durable_unix,
260            // Carried, not recomputed: `write_gauges` is store-free by
261            // construction, so it cannot observe the credential state
262            // that set this. Dropping it here would erase the pause on
263            // the very next tick — the gauge would exist and always
264            // read `None`.
265            auth_paused_since_unix: prev.auth_paused_since_unix,
266        };
267        self.save_gauges(&g)?;
268        Ok(g)
269    }
270}
271
272/// What `flint-sync status` renders. Read STRICTLY from files: the verb
273/// exists to diagnose a workspace whose syncer is dead or deposed, so
274/// it must neither claim the lease (which would depose the very syncer
275/// under diagnosis) nor take the state-directory occupancy lock (which
276/// a live syncer already holds).
277#[derive(Debug, Clone, Serialize)]
278pub struct StatusReport {
279    pub root: String,
280    pub prefix: String,
281    pub gauges: Option<Gauges>,
282    pub capabilities: Option<super::control::Capabilities>,
283    pub remote_seq: Option<super::control::RemoteSeq>,
284    pub pending_stage_entries: usize,
285    pub pending_stage_bytes: u64,
286    pub withheld_deletes: usize,
287    pub baseline_seq: u64,
288    pub incarnation_epoch: Option<u64>,
289    pub incarnation_holder: Option<String>,
290    /// A pending sentinel still standing (verb name) — the "my agent is
291    /// blocked on an ack" question.
292    pub pending_sentinels: Vec<String>,
293    pub recent_conflicts: usize,
294    pub checkout_complete: bool,
295}
296
297fn read_json<T: serde::de::DeserializeOwned>(p: &std::path::Path) -> Option<T> {
298    serde_json::from_slice(&std::fs::read(p).ok()?).ok()
299}
300
301pub fn status_report(cfg: &super::LeanConfig) -> LeanResult<StatusReport> {
302    let sd = cfg.state_dir();
303    let cd = cfg.control_dir();
304    let stage: super::gated::PendingStage = read_json(&sd.join("pending.json")).unwrap_or_default();
305    let baseline: super::state::Baseline = read_json(&sd.join("baseline.json")).unwrap_or_default();
306    let inc: Option<super::state::Incarnation> = read_json(&sd.join("incarnation.json"));
307    let mut pending_sentinels = vec![];
308    // Named by the SAME function that writes them. The previous form
309    // built the name a second time and got it wrong, so this field —
310    // the "is my agent blocked on an ack?" answer — was permanently
311    // empty on a workspace that had a sentinel standing.
312    for verb in [super::sentinel::Verb::Publish, super::sentinel::Verb::Sync] {
313        if sd.join(verb.pending_name()).exists() {
314            pending_sentinels.push(verb.sentinel_name().to_string());
315        }
316    }
317    let conflicts = std::fs::read_to_string(sd.join("conflicts.jsonl"))
318        .map(|s| s.lines().filter(|l| !l.trim().is_empty()).count())
319        .unwrap_or(0);
320    Ok(StatusReport {
321        root: cfg.root.display().to_string(),
322        prefix: cfg.prefix.clone(),
323        gauges: read_json(&sd.join(GAUGES)),
324        capabilities: read_json(&cd.join(super::control::CAPABILITIES)),
325        remote_seq: read_json(&cd.join(super::control::REMOTE_SEQ)),
326        pending_stage_entries: stage.entries.len(),
327        pending_stage_bytes: stage.entries.values().map(|e| e.size).sum(),
328        withheld_deletes: stage.withheld_deletes.len(),
329        baseline_seq: baseline.seq,
330        incarnation_epoch: inc.as_ref().map(|i| i.epoch),
331        incarnation_holder: inc.map(|i| i.holder_id),
332        pending_sentinels,
333        recent_conflicts: conflicts,
334        checkout_complete: sd.join("checkout-complete").exists(),
335    })
336}