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}