Skip to main content

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}