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
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
//! Shared observation of the mob actor loop's liveness.
//!
//! meerkat's mob actor is ONE serialized command loop. The unified runtime's
//! probe (`run_actor_loop_probe`) is the only unconditional witness of that
//! loop, and until now its verdict lived solely in the paging channel
//! (`ErrorEvent::ActorLoopStalled` / `ActorLoopRecovered`). The delivery path
//! could not see it: a send issued while a stall was open queued behind the
//! wedged command and waited the full admission budget (600 s by default)
//! before failing with a scope-less timeout. Production 2026-09-04 (OB3): five
//! console sends, each abandoned after exactly 600 s, while the probe had
//! already paged the stall.
//!
//! This module is the seam between the two. The probe publishes its verdict
//! here; the bridge's admission path reads it and fails fast, typed and naming
//! the open `stall_id`, instead of waiting. It is OBSERVATION, not authority:
//! nothing here mutates the actor, and a `Live` reading is never proof of
//! health (the probe measures whether the loop drains, not whether the mob is
//! healthy).
//!
//! Three states, deliberately not a boolean:
//!
//! - [`ActorLoopHealthState::Live`]: no open stall. Deliveries proceed under
//! their ordinary admission budget.
//! - [`ActorLoopHealthState::Stalled`]: the probe's round trip is parked
//! unanswered. Deliveries fail fast until the probe closes this exact
//! `stall_id`.
//! - [`ActorLoopHealthState::Terminated`]: the parked probe resolved with the
//! actor's channels CLOSED. The loop did not recover; it is gone. Nothing in
//! this process can bring it back, so the state is terminal and deliveries
//! fail fast with a distinct error telling the operator to restart.
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tokio::sync::watch;
/// One probe verdict about the serialized mob actor loop.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ActorLoopHealthState {
/// No open stall.
Live,
/// The probe's round trip has been parked unanswered past its budget.
Stalled {
/// The `ErrorEvent::ActorLoopStalled { stall_id }` this corresponds
/// to, so an operator can join the fast-failed delivery to the page.
stall_id: u64,
/// When the probe opened the stall (monotonic).
since: Instant,
},
/// The actor's command or reply channel closed: the loop terminated.
/// Terminal for the process; only a restart clears it.
Terminated {
/// The stall this resolved, when the termination was observed on a
/// parked probe rather than on a fresh round trip.
stall_id: Option<u64>,
/// The error text the probe observed.
detail: String,
/// When the termination was observed (monotonic).
at: Instant,
},
}
impl ActorLoopHealthState {
/// The open stall id, when the loop is stalled or terminated during a
/// stall.
#[must_use]
pub fn open_stall_id(&self) -> Option<u64> {
match self {
Self::Live => None,
Self::Stalled { stall_id, .. } => Some(*stall_id),
Self::Terminated { stall_id, .. } => *stall_id,
}
}
/// Whether the delivery path must fail fast instead of queueing a
/// command onto the actor.
#[must_use]
pub fn refuses_admission(&self) -> bool {
!matches!(self, Self::Live)
}
/// Wire projection for `mobkit/member_health` and operator surfaces.
#[must_use]
pub fn report(&self) -> ActorLoopHealthReport {
match self {
Self::Live => ActorLoopHealthReport {
state: ActorLoopHealthKind::Live,
stall_id: None,
stalled_for_secs: None,
detail: None,
},
Self::Stalled { stall_id, since } => ActorLoopHealthReport {
state: ActorLoopHealthKind::Stalled,
stall_id: Some(*stall_id),
stalled_for_secs: Some(since.elapsed().as_secs()),
detail: None,
},
Self::Terminated {
stall_id,
detail,
at,
} => ActorLoopHealthReport {
state: ActorLoopHealthKind::Terminated,
stall_id: *stall_id,
stalled_for_secs: Some(at.elapsed().as_secs()),
detail: Some(detail.clone()),
},
}
}
}
/// Wire vocabulary for [`ActorLoopHealthReport::state`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ActorLoopHealthKind {
Live,
Stalled,
Terminated,
/// No probe is wired into this runtime (validation-only compositions),
/// so the loop's liveness is not observed. Distinct from `Live` so a
/// reader cannot mistake "nobody is watching" for "healthy".
Unobserved,
}
/// Serializable projection of the actor loop's health for operator reads.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActorLoopHealthReport {
pub state: ActorLoopHealthKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stall_id: Option<u64>,
/// Seconds since the stall opened (`stalled`) or since termination was
/// observed (`terminated`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stalled_for_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
impl ActorLoopHealthReport {
/// The report for a runtime that has no probe wired.
#[must_use]
pub fn unobserved() -> Self {
Self {
state: ActorLoopHealthKind::Unobserved,
stall_id: None,
stalled_for_secs: None,
detail: None,
}
}
}
/// Shared, late-bound verdict slot. The probe writes; admission paths read
/// and can await the next unhealthy transition.
#[derive(Debug)]
pub struct ActorLoopHealth {
state: watch::Sender<ActorLoopHealthState>,
}
impl Default for ActorLoopHealth {
fn default() -> Self {
Self::new()
}
}
impl ActorLoopHealth {
/// A fresh slot reading `Live`.
#[must_use]
pub fn new() -> Self {
let (state, _) = watch::channel(ActorLoopHealthState::Live);
Self { state }
}
/// Convenience for composition roots.
#[must_use]
pub fn shared() -> Arc<Self> {
Arc::new(Self::new())
}
/// Current verdict.
#[must_use]
pub fn snapshot(&self) -> ActorLoopHealthState {
self.state.borrow().clone()
}
/// Wire projection of the current verdict.
#[must_use]
pub fn report(&self) -> ActorLoopHealthReport {
self.snapshot().report()
}
/// The probe opened a stall. A terminated loop never goes back to
/// stalled: termination is terminal for the process.
pub fn mark_stalled(&self, stall_id: u64) {
self.state.send_if_modified(|state| match state {
ActorLoopHealthState::Terminated { .. } => false,
_ => {
*state = ActorLoopHealthState::Stalled {
stall_id,
since: Instant::now(),
};
true
}
});
}
/// The probe's parked round trip for `stall_id` drained. Only the stall
/// that is actually open is closed; a late resolution for an older id
/// cannot clear a newer stall.
pub fn mark_recovered(&self, stall_id: u64) {
self.state.send_if_modified(|state| match state {
ActorLoopHealthState::Stalled { stall_id: open, .. } if *open == stall_id => {
*state = ActorLoopHealthState::Live;
true
}
_ => false,
});
}
/// The probe observed the actor's channels closed. Terminal.
pub fn mark_terminated(&self, stall_id: Option<u64>, detail: impl Into<String>) {
let detail = detail.into();
self.state.send_if_modified(|state| match state {
ActorLoopHealthState::Terminated { .. } => false,
_ => {
*state = ActorLoopHealthState::Terminated {
stall_id,
detail,
at: Instant::now(),
};
true
}
});
}
/// Resolve as soon as the verdict is (or becomes) `Stalled` or
/// `Terminated`. Never resolves while the loop stays `Live`, so callers
/// race it against their own bounded call rather than awaiting it alone.
pub async fn unhealthy(&self) -> ActorLoopHealthState {
let mut rx = self.state.subscribe();
loop {
{
let current = rx.borrow_and_update();
if current.refuses_admission() {
return current.clone();
}
}
if rx.changed().await.is_err() {
// The sender is gone only when the runtime that owned the
// probe is gone; nothing left to observe, park forever so the
// caller's own deadline decides.
std::future::pending::<()>().await;
}
}
}
/// How long the current stall has been open, when one is open.
#[must_use]
pub fn open_stall_duration(&self) -> Option<Duration> {
match self.snapshot() {
ActorLoopHealthState::Stalled { since, .. } => Some(since.elapsed()),
_ => None,
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn starts_live_and_reports_no_stall() {
let health = ActorLoopHealth::new();
assert_eq!(health.snapshot(), ActorLoopHealthState::Live);
assert_eq!(health.report().state, ActorLoopHealthKind::Live);
assert!(health.snapshot().open_stall_id().is_none());
assert!(!health.snapshot().refuses_admission());
}
#[test]
fn stall_then_recovery_round_trips_by_id() {
let health = ActorLoopHealth::new();
health.mark_stalled(3);
assert_eq!(health.snapshot().open_stall_id(), Some(3));
assert!(health.snapshot().refuses_admission());
// A stale resolution must not clear a different open stall.
health.mark_recovered(2);
assert_eq!(health.snapshot().open_stall_id(), Some(3));
health.mark_recovered(3);
assert_eq!(health.snapshot(), ActorLoopHealthState::Live);
}
#[test]
fn termination_is_terminal() {
let health = ActorLoopHealth::new();
health.mark_stalled(1);
health.mark_terminated(Some(1), "mob actor command channel closed");
assert!(matches!(
health.snapshot(),
ActorLoopHealthState::Terminated {
stall_id: Some(1),
..
}
));
// Neither a later stall nor a recovery can undo termination.
health.mark_stalled(2);
health.mark_recovered(1);
assert!(matches!(
health.snapshot(),
ActorLoopHealthState::Terminated { .. }
));
let report = health.report();
assert_eq!(report.state, ActorLoopHealthKind::Terminated);
assert_eq!(
report.detail.as_deref(),
Some("mob actor command channel closed")
);
}
#[tokio::test]
async fn unhealthy_resolves_immediately_when_already_stalled() {
let health = ActorLoopHealth::new();
health.mark_stalled(7);
let state = tokio::time::timeout(Duration::from_millis(50), health.unhealthy())
.await
.expect("an open stall must resolve the wait immediately");
assert_eq!(state.open_stall_id(), Some(7));
}
#[tokio::test]
async fn unhealthy_wakes_when_a_stall_opens_and_parks_while_live() {
let health = Arc::new(ActorLoopHealth::new());
assert!(
tokio::time::timeout(Duration::from_millis(20), health.unhealthy())
.await
.is_err(),
"a live loop must not resolve the unhealthy wait"
);
let waiter = {
let health = Arc::clone(&health);
tokio::spawn(async move { health.unhealthy().await })
};
tokio::task::yield_now().await;
health.mark_stalled(11);
let state = tokio::time::timeout(Duration::from_secs(1), waiter)
.await
.expect("the waiter must wake on the stall")
.expect("waiter task");
assert_eq!(state.open_stall_id(), Some(11));
}
#[test]
fn report_wire_shape_is_snake_case_and_minimal() {
let live = serde_json::to_value(ActorLoopHealthReport::unobserved()).expect("serialize");
assert_eq!(live, serde_json::json!({ "state": "unobserved" }));
let health = ActorLoopHealth::new();
health.mark_stalled(5);
let stalled = serde_json::to_value(health.report()).expect("serialize");
assert_eq!(stalled["state"], serde_json::json!("stalled"));
assert_eq!(stalled["stall_id"], serde_json::json!(5));
assert!(stalled.get("detail").is_none());
}
}