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