agentmux_launcher/teardown_backstop.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! SPEC_LAUNCHER_TEARDOWN_BACKSTOP_2026_07_11 Phase 2 — the armed J0
5//! teardown state machine.
6//!
7//! ```text
8//! Disarmed ──(PoolDrained / OrphanInstance drift)──▶ Armed(t0)
9//! Armed ──(host exits — supervisor disarms)─────▶ Disarmed
10//! Armed ──(any WindowOpened event)──────────────▶ Disarmed
11//! Armed ──(t > t0+GRACE and ≥2 unanswered probes)▶ Teardown
12//! ```
13//!
14//! Consumes Phase 1's `ui_liveness` telemetry: the *decision* to tear down
15//! requires BOTH the grace period elapsing since arming AND the UI-thread
16//! prober reporting ≥ N consecutive unanswered probes — a wedged UI thread
17//! cannot answer, and transport failures never count as misses (the prober
18//! retracts probes whose SEND failed, so silence here is genuinely
19//! UI-thread evidence, not pipe evidence).
20//!
21//! Arm/disarm are driven from `ipc::server`'s post-reducer event hook
22//! (validated events only — same layering as the Phase 1 transport-side
23//! `ReportUiThreadAlive` intercept: this is process-supervision state about
24//! the host, not domain state the reducer owns). The supervisor's select
25//! loop polls `should_teardown` on a low-rate tick and, on `true`,
26//! `TerminateJobObject(J0)`s the tree — the one deliberate exception to
27//! "never kill what a saga can reconcile": zero user windows remain and the
28//! UI thread is provably dead, so there is nothing left to reconcile.
29//!
30//! False-positive guards (spec §Phase 2), and where each lives:
31//! - Startup: nothing arms until a drain/orphan event, which requires a
32//! registered host that already opened (and closed) a user window.
33//! - Crash-restart gap: the supervisor disarms on every host exit; the
34//! machine can only re-arm from a fresh drain report by the NEW host.
35//! - Crash-reproject: reproject re-opens windows within the grace; the
36//! mirror's `WindowOpened` event disarms.
37//! - Probe transport failure: `ui_liveness::retract_probe` keeps failed
38//! sends out of the consecutive-miss count entirely.
39//!
40//! Like `ui_liveness`, all logic lives on the struct (unit-testable with an
41//! injected clock); the module-level functions delegate to one
42//! process-global instance.
43
44use std::sync::{Mutex, OnceLock};
45use std::time::{Duration, Instant};
46
47/// Grace period between arming and the earliest possible teardown. An
48/// order of magnitude above the slowest healthy quit observed
49/// (multi-window with pool sweep: ~2-5s), far below "user annoyed".
50pub const TEARDOWN_GRACE: Duration = Duration::from_secs(30);
51
52/// Consecutive unanswered UI-thread probes required (on top of the grace)
53/// before the host counts as wedged. At the Phase-1 probe rate (60s) this
54/// bounds worst-case wedge→teardown latency to roughly GRACE + 2 probe
55/// intervals — the spec's stated verification envelope.
56pub const TEARDOWN_REQUIRED_MISSES: u32 = 2;
57
58#[derive(Debug, Default)]
59pub struct TeardownBackstop {
60 /// `Some(t0)` = armed since `t0`. Re-arming while armed keeps the
61 /// ORIGINAL `t0` — a second drain report means the host has been
62 /// failing to exit since the first one, not that the clock restarts.
63 armed_since: Option<Instant>,
64}
65
66impl TeardownBackstop {
67 /// Arm the machine. Returns `true` when this call newly armed it
68 /// (callers log the transition); `false` when it was already armed
69 /// (the original arm time is kept).
70 pub fn arm(&mut self, now: Instant) -> bool {
71 if self.armed_since.is_some() {
72 return false;
73 }
74 self.armed_since = Some(now);
75 true
76 }
77
78 /// Disarm. Returns `true` when this call newly disarmed it.
79 pub fn disarm(&mut self) -> bool {
80 self.armed_since.take().is_some()
81 }
82
83 /// The teardown decision: armed for longer than `grace`, AND the
84 /// UI-thread prober has accumulated at least `required_misses`
85 /// consecutive unanswered probes. Pure read — the caller executes.
86 pub fn should_teardown(
87 &self,
88 now: Instant,
89 grace: Duration,
90 consecutive_misses: u32,
91 required_misses: u32,
92 ) -> bool {
93 match self.armed_since {
94 Some(t0) => {
95 now.saturating_duration_since(t0) > grace && consecutive_misses >= required_misses
96 }
97 None => false,
98 }
99 }
100}
101
102fn cell() -> &'static Mutex<TeardownBackstop> {
103 static S: OnceLock<Mutex<TeardownBackstop>> = OnceLock::new();
104 S.get_or_init(|| Mutex::new(TeardownBackstop::default()))
105}
106
107/// See [`TeardownBackstop::arm`]. Process-global instance.
108pub fn arm() -> bool {
109 cell().lock().unwrap().arm(Instant::now())
110}
111
112/// See [`TeardownBackstop::disarm`]. Process-global instance.
113pub fn disarm() -> bool {
114 cell().lock().unwrap().disarm()
115}
116
117/// See [`TeardownBackstop::should_teardown`]. Process-global instance,
118/// evaluated against the spec constants and the live probe-miss count.
119pub fn should_teardown(consecutive_misses: u32) -> bool {
120 cell().lock().unwrap().should_teardown(
121 Instant::now(),
122 TEARDOWN_GRACE,
123 consecutive_misses,
124 TEARDOWN_REQUIRED_MISSES,
125 )
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 fn t(base: Instant, secs: u64) -> Instant {
133 base + Duration::from_secs(secs)
134 }
135
136 #[test]
137 fn disarmed_never_tears_down() {
138 let b = TeardownBackstop::default();
139 let now = Instant::now();
140 assert!(!b.should_teardown(now, TEARDOWN_GRACE, 99, TEARDOWN_REQUIRED_MISSES));
141 }
142
143 #[test]
144 fn armed_within_grace_never_tears_down_even_with_misses() {
145 let mut b = TeardownBackstop::default();
146 let base = Instant::now();
147 assert!(b.arm(base), "first arm is a transition");
148 assert!(!b.should_teardown(t(base, 29), TEARDOWN_GRACE, 99, TEARDOWN_REQUIRED_MISSES));
149 }
150
151 #[test]
152 fn armed_past_grace_without_misses_never_tears_down() {
153 // A host answering probes is alive-but-slow — the host-side quit
154 // watchdog owns that case, not this backstop.
155 let mut b = TeardownBackstop::default();
156 let base = Instant::now();
157 b.arm(base);
158 assert!(!b.should_teardown(t(base, 300), TEARDOWN_GRACE, 1, TEARDOWN_REQUIRED_MISSES));
159 }
160
161 #[test]
162 fn armed_past_grace_with_required_misses_tears_down() {
163 let mut b = TeardownBackstop::default();
164 let base = Instant::now();
165 b.arm(base);
166 assert!(b.should_teardown(t(base, 31), TEARDOWN_GRACE, 2, TEARDOWN_REQUIRED_MISSES));
167 }
168
169 #[test]
170 fn disarm_cancels_a_pending_teardown() {
171 let mut b = TeardownBackstop::default();
172 let base = Instant::now();
173 b.arm(base);
174 assert!(b.disarm(), "disarm of an armed machine is a transition");
175 assert!(!b.disarm(), "second disarm is a no-op");
176 assert!(!b.should_teardown(t(base, 300), TEARDOWN_GRACE, 99, TEARDOWN_REQUIRED_MISSES));
177 }
178
179 #[test]
180 fn rearming_keeps_the_original_arm_time() {
181 let mut b = TeardownBackstop::default();
182 let base = Instant::now();
183 b.arm(base);
184 // A second arm 29s in must NOT restart the grace clock…
185 assert!(!b.arm(t(base, 29)), "re-arm while armed is not a transition");
186 // …so at t=31 the ORIGINAL t0 has aged past the 30s grace.
187 assert!(b.should_teardown(t(base, 31), TEARDOWN_GRACE, 2, TEARDOWN_REQUIRED_MISSES));
188 }
189}