1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
//! Cross-process metrics publication: the supervisor writes a file, the daemon reads it.
//!
//! Why (#6288): the supervisor used to publish its snapshot on a second HTTP
//! listener (`127.0.0.1:7881`). Nothing in the workspace read it — the daemon's
//! `console_metrics` / `supervisor_status` rebuilt `FleetMetrics` from the
//! session store and left `run_stats` at its default, so both tools reported
//! zero sweeps and zero auto-resumes no matter how long the supervisor had been
//! running. ADR-0032 makes UDS (not a TCP port) the inter-service transport, and
//! a whole socket for one JSON blob a reader polls is more machinery than the
//! problem needs. A file under the framework root is the transport already used
//! for supervisor↔daemon state: `crate::core::auto_resume` passes the console's
//! desired flag the other way through `~/.trusty-mpm/auto_resume`, and the
//! supervisor re-reads it every sweep.
//! What: [`metrics_path`] names `<framework root>/supervisor-metrics.json`;
//! [`write_at`] publishes a [`PublishedMetrics`] there atomically (temp file +
//! rename, through the crate's shared [`atomic_write`] entry point) so a reader
//! never observes a half-written file; [`read_status_at`] classifies what a
//! reader finds as [`SupervisorMetricsStatus::Current`],
//! [`SupervisorMetricsStatus::Stale`], or
//! [`SupervisorMetricsStatus::Unavailable`] — never as a silent zero. Staleness
//! is judged against the cadence the writing supervisor actually runs at
//! ([`stale_after_secs`]), not a fixed wall-clock constant.
//! Test: `published_metrics_round_trip`, `read_status_absent_is_unavailable`,
//! `read_status_corrupt_is_unavailable`, `read_status_old_snapshot_is_stale`,
//! `supervisor_publishes_run_stats_after_sweeps`,
//! `supervisor_metrics_merge_reports_real_run_stats`,
//! `stale_threshold_tracks_the_configured_interval`,
//! `read_status_respects_a_slow_configured_interval`,
//! `snapshot_without_interval_falls_back_to_the_default_cadence` in
//! `super::tests`.
use ;
use Duration;
use ;
use ;
use crateatomic_write;
use crateFrameworkPaths;
use crateFleetMetrics;
/// Filename (under the framework root) holding the published snapshot.
///
/// Why: one named constant keeps the writing supervisor and the reading daemon
/// from drifting apart, the same way [`crate::core::auto_resume::AUTO_RESUME_FILE`]
/// does for the flag travelling the other way.
/// What: `supervisor-metrics.json`.
/// Test: `metrics_path_is_under_root`.
pub const SUPERVISOR_METRICS_FILE: &str = "supervisor-metrics.json";
/// How many missed publishes make a snapshot stale.
///
/// Why: staleness has to be measured in SWEEPS, not wall-clock seconds. The
/// cadence is operator-tunable and unbounded — `config.rs` documents slow
/// overnight cadences as a supported case — so a fixed threshold marks a
/// perfectly healthy supervisor stale for part of every cycle once the interval
/// exceeds it, and permanently once it exceeds it twice over. Three because one
/// missed publish is an ordinary slow sweep (the sweep lists the fleet and may
/// call an LLM to classify panes), two is unusual, and three consecutive misses
/// is a stopped, wedged, or crash-looping process rather than a slow one.
/// What: the multiplier applied to the writer's configured interval by
/// [`stale_after_secs`].
/// Test: `stale_threshold_tracks_the_configured_interval`,
/// `read_status_respects_a_slow_configured_interval`.
pub const STALE_AFTER_SWEEPS: i64 = 3;
/// Lower bound on the staleness threshold, whatever the interval.
///
/// Why: a fast cadence must not make the status flap. At the 30s default,
/// three sweeps is 90 seconds — close enough to ordinary scheduling jitter, a
/// `launchctl` restart, or one slow LLM-backed sweep that a reader would see
/// `stale` flicker on a healthy supervisor. The floor also keeps the default
/// install's threshold exactly where it was before the interval became part of
/// the calculation.
/// What: 300 seconds — ten sweeps at the 30s default
/// ([`crate::supervisor::config::DEFAULT_INTERVAL_SECS`]).
/// Test: `stale_threshold_tracks_the_configured_interval`,
/// `read_status_old_snapshot_is_stale`.
pub const STALE_FLOOR_SECS: i64 = 300;
/// The age at which a snapshot written by a supervisor on `interval` is stale.
///
/// Why: see [`STALE_AFTER_SWEEPS`] — the threshold has to scale with the
/// writer's cadence or a slow overnight supervisor reads as dead.
/// What: `max(STALE_AFTER_SWEEPS * interval, STALE_FLOOR_SECS)`, saturating so a
/// nonsensical interval cannot overflow the multiplication.
/// Test: `stale_threshold_tracks_the_configured_interval`.
/// Errors publishing or reading the supervisor's metrics snapshot.
///
/// Why: the library-crate rule — a structured error, not `anyhow`, so the daemon
/// can render the failure to an operator and the supervisor can log it without
/// either side parsing a string.
/// What: an I/O failure, or a snapshot file that is not valid JSON of the
/// expected shape.
/// Test: `read_status_corrupt_is_unavailable`.
/// The snapshot the supervisor publishes after every sweep.
///
/// Why: a reader needs both the fleet state the supervisor observed AND when it
/// observed it. Without the timestamp there is no way to tell a live supervisor
/// from a stopped one whose last file is still on disk, which is how a stale
/// counter gets presented as current.
/// What: `written_at` (the publish instant), `interval_secs` (the cadence the
/// writer is configured at, so the reader can size its staleness window), and
/// the full [`FleetMetrics`], which carries the
/// [`crate::supervisor::SupervisorRunStats`] counters — sweeps, auto-resumes,
/// resume failures, classifications — inside its `run_stats` field.
/// Test: `published_metrics_round_trip`,
/// `snapshot_without_interval_falls_back_to_the_default_cadence`.
/// Serde fallback for a snapshot written before `interval_secs` existed.
/// What a reader found at the snapshot path.
///
/// Why (#6288 Fail-Open Check): the defect this module replaces was a SILENT
/// zero — `run_stats` defaulted and nothing said why. Every read outcome must
/// therefore be nameable on the wire: fresh counters, last-known counters that
/// are too old to trust, or no counters at all with the reason attached. There
/// is deliberately no variant that means "assume zero and say nothing".
/// What: `Current` and `Stale` both carry the snapshot (a stale counter is still
/// the last real observation, and hiding it would re-create the silent zero);
/// `Unavailable` carries the operator-facing reason.
/// Test: `read_status_absent_is_unavailable`, `read_status_corrupt_is_unavailable`,
/// `read_status_old_snapshot_is_stale`.
/// Resolve the path of the published snapshot file.
///
/// Why: centralising the path keeps the supervisor and the daemon consistent and
/// lets tests point at a temp root instead of the developer's real `~/.trusty-mpm`.
/// What: `<root>/supervisor-metrics.json` derived from the given [`FrameworkPaths`].
/// Test: `metrics_path_is_under_root`.
/// Publish a snapshot to an explicit path, atomically.
///
/// Why: the daemon may read at any instant, including mid-write. A plain
/// `fs::write` truncates first, so a reader can observe an empty or truncated
/// file and report `Unavailable` for a supervisor that is working fine. Routing
/// through the crate's shared [`atomic_write`] (temp file + rename) makes the
/// publish a single rename the reader either sees or does not.
/// What: creates the parent directory if absent, serialises `fleet`, `now`, and
/// the writer's `interval` into a [`PublishedMetrics`], and swaps it into place.
/// The interval travels with the snapshot because the reader cannot otherwise
/// know how long a gap between publishes is normal for this supervisor.
/// Test: `published_metrics_round_trip`, `supervisor_publishes_run_stats_after_sweeps`.
/// Read a published snapshot from an explicit path.
///
/// Why: separating the path-taking core from the home-resolving wrapper keeps
/// the file logic hermetically testable.
/// What: `Ok(None)` when the file does not exist; `Ok(Some(_))` when it parses;
/// `Err` for every other I/O failure and for a file that is not a valid
/// [`PublishedMetrics`] — a corrupt file is never flattened into "absent".
/// Test: `published_metrics_round_trip`, `read_status_corrupt_is_unavailable`.
/// Classify what a reader finds at `path` as of `now`.
///
/// Why: this is the whole fail-open contract in one function. Every caller gets
/// a variant it must name on the wire, so there is no code path where a missing
/// or ancient snapshot silently becomes zeroed counters.
/// What: absent / unreadable / unparseable → `Unavailable` with the reason;
/// otherwise `Current` or `Stale` by comparing the snapshot age against
/// [`stale_after_secs`] computed from the snapshot's OWN `interval_secs`, so a
/// supervisor on a 15-minute overnight cadence is not called dead 5 minutes into
/// every cycle. A snapshot dated in the future (a clock step) has a negative age
/// and counts as current — the alternative, calling it stale, would hide live
/// counters over a clock adjustment. #8335: a fresh snapshot whose last fleet
/// sweep was abandoned (`consecutive_sweeps_abandoned > 0`) is `Stale` too —
/// the loop still publishes while its sweeps are abandoned every tick.
/// Test: `read_status_absent_is_unavailable`, `read_status_corrupt_is_unavailable`,
/// `read_status_old_snapshot_is_stale`,
/// `read_status_respects_a_slow_configured_interval`,
/// `abandoned_fleet_sweeps_are_counted_and_read_as_stale`.