Skip to main content

agentmux_srv/backend/blockcontroller/persistent/
lifecycle.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Stop/restart and session-identity queries: `stop_process`, the kill request
5//! path, `session_id`, `needs_spawn`, and the deferred-restart flag.
6
7use super::*;
8
9impl PersistentSubprocessController {
10    pub fn stop_process(&self, force: bool) -> Result<(), String> {
11        let request = if force {
12            KillRequest::Force
13        } else {
14            KillRequest::Graceful(std::time::Instant::now() + super::super::SHUTDOWN_GRACE)
15        };
16        self.request_stop(request)
17    }
18
19    /// Another process already running on session `sid`: `(block id, closing)`.
20    /// Checks live persistent controllers in the registry, then blocks
21    /// mid-close (out of the registry, process not yet exited).
22    pub(super) fn session_held_elsewhere(&self, sid: &str) -> Option<(String, bool)> {
23        for (block_id, ctrl) in super::super::get_all_controllers() {
24            if block_id == self.block_id {
25                continue;
26            }
27            if let Some(other) = ctrl.as_any().downcast_ref::<PersistentSubprocessController>() {
28                let g = other.inner.lock().unwrap();
29                if g.current_pid.is_some() && g.session_id.as_deref() == Some(sid) {
30                    return Some((block_id, false));
31                }
32            }
33        }
34        let store = self.mstore.as_ref()?;
35        super::super::closing_blocks_still_running()
36            .into_iter()
37            .filter(|block_id| *block_id != self.block_id)
38            .find(|block_id| {
39                store
40                    .get::<crate::backend::obj::Block>(block_id)
41                    .ok()
42                    .flatten()
43                    .is_some_and(|b| crate::backend::obj::meta_get_string(&b.meta, core::META_SESSION_ID, "") == sid)
44            })
45            .map(|block_id| (block_id, true))
46    }
47
48    /// Another pane of this process holding agent `uid`'s single-live-instance
49    /// lease: its block id. Whatever session either pane holds — a pane with
50    /// a stale id refused because the agent's live pane holds the lease is
51    /// still "open in another pane", not "another instance" (Camper on #3935).
52    pub(super) fn agent_held_by_other_pane(&self, uid: &str) -> Option<String> {
53        super::super::get_all_controllers().into_iter().find_map(|(block_id, ctrl)| {
54            if block_id == self.block_id {
55                return None;
56            }
57            let other = ctrl.as_any().downcast_ref::<PersistentSubprocessController>()?;
58            let holds = other.inner.lock().unwrap().agent_lease.as_ref().is_some_and(|l| l.agent_uid() == uid);
59            holds.then_some(block_id)
60        })
61    }
62
63    pub(super) fn request_stop(&self, request: KillRequest) -> Result<(), String> {
64        Self::request_stop_on(&self.inner, request)
65    }
66
67    /// [`request_stop`] over just the shared state, for the `'static`
68    /// future [`Controller::shutdown`] returns.
69    pub(super) fn request_stop_on(inner_arc: &Arc<Mutex<PersistentInner>>, request: KillRequest) -> Result<(), String> {
70        Self::request_stop_inner(inner_arc, request, false, false);
71        Ok(())
72    }
73
74    /// Apply a deferred runtime-config restart: stop the current process (as
75    /// `stop_process(false)` does) WITHOUT clearing the restart token the turn
76    /// boundary set (`PersistentInner::config_restart_generation`), so the exit
77    /// handler brings the replacement up. It never sets the token either: a
78    /// Stop that landed between the boundary and this call already cleared it
79    /// and took the kill channel, and must stay in force (codex P1 on #3990).
80    pub(super) fn stop_for_config_restart(&self) {
81        let request = KillRequest::Graceful(std::time::Instant::now() + super::super::SHUTDOWN_GRACE);
82        Self::request_stop_inner(&self.inner, request, false, true);
83    }
84
85    /// A kill for teardown: drains the deferred queue in the SAME `inner`
86    /// acquisition that issues the kill, and returns what it drained for the
87    /// caller to report. Draining in a second acquisition left a window
88    /// after the kill request where a flush could write a deferred message
89    /// into the process being killed, and leave the report an empty queue:
90    /// lost, with no stranded warning (codex P2 on #3562). Once this lock is
91    /// released, `stop_pending` holds anything queued later.
92    pub(super) fn request_stop_draining_deferred(&self, request: KillRequest) -> Vec<String> {
93        Self::request_stop_inner(&self.inner, request, true, false)
94    }
95
96    /// `config_restart`: this stop is a deferred runtime-config restart's own
97    /// ([`Self::stop_for_config_restart`]), which leaves the restart token as
98    /// the boundary left it. Every other stop clears it in this same
99    /// acquisition, so a Stop or pane close landing after the restart was
100    /// committed wins, and nothing respawns behind it.
101    pub(super) fn request_stop_inner(
102        inner_arc: &Arc<Mutex<PersistentInner>>,
103        request: KillRequest,
104        drain_deferred: bool,
105        config_restart: bool,
106    ) -> Vec<String> {
107        let (kill_tx, drained) = {
108            let mut inner = inner_arc.lock().unwrap();
109            // Recorded unconditionally, not only when `kill_tx` is already
110            // `None` — codex P1 on PR #2360 (round 16, commit ce1642d90):
111            // `stop_process` can race a process that already exited (a
112            // confirmed stale-`--resume` death) and is about to be
113            // silently retried — sending through `kill_tx` is futile in
114            // that window regardless of whether it's already `None` (the
115            // exit-handler cleared it) or still `Some` (`tokio::select!`
116            // already committed to the `child.wait()` exit arm before
117            // this call reached the lock, so the `kill_rx` arm will never
118            // be polled again even if the send succeeds). Recording this
119            // as a `StopRequested` event — resolved by
120            // `persistent_resume::update` once `ProcessExited` arrives —
121            // is the only way the exit-handler's retry decision can know
122            // the user explicitly asked to stop.
123            let generation = inner.spawn_generation;
124            inner.apply_resume_event(persistent_resume::ResumeEvent::StopRequested { generation });
125            inner.stop_pending = true;
126            if !config_restart {
127                inner.config_restart_generation = None;
128            }
129            let drained = if drain_deferred {
130                inner.deferred_deliveries.drain(..).collect()
131            } else {
132                Vec::new()
133            };
134            (inner.kill_tx.take(), drained)
135        };
136        if let Some(tx) = kill_tx {
137            let _ = tx.send(request);
138        }
139        drained
140    }
141
142    pub fn session_id(&self) -> Option<String> {
143        self.inner.lock().unwrap().session_id.clone()
144    }
145
146    /// True when this controller is registered but has **no live process and no
147    /// spawn already in flight** — the state a lazily-registered controller sits
148    /// in between `register` ("spawns on first message") and its first
149    /// `send_message`.
150    ///
151    /// In that state `inject_message` cannot deliver: it has no spawn config and
152    /// only writes to a live `stdin_tx`, so it returns "persistent process not
153    /// running". The caller can, however, start a *turn* — `run_agent_turn`
154    /// re-reads the spawn config from block metadata and calls `send_message`,
155    /// which does spawn. This predicate is what lets the reactive delivery path
156    /// tell those two situations apart. See
157    /// `docs/reports/REPORT_JEKT_DELIVERY_DROPS_UNSPAWNED_PERSISTENT_AGENTS_2026_09_03.md`.
158    ///
159    /// **The invariant this must uphold:** `needs_spawn() == true` implies a
160    /// subsequent `send_message` takes `decide_send_action`'s `BecomeSpawner`
161    /// branch — i.e. it really spawns and delivers. It must never be true in a
162    /// state where `send_message` would instead return `SendAction::Queued`,
163    /// because `Queued` returns `Ok(())` having delivered nothing, the reactive
164    /// path maps that to `Ok(true)`, and the caller is told the message landed
165    /// when it is still sitting in the queue. `cloud_subscriber` only retries
166    /// on `!success`, so a false success is a permanently lost message.
167    ///
168    /// That is why all three exclusions are here, and each mirrors one of
169    /// `decide_send_action`'s own guards:
170    ///
171    /// - `stdin_tx.is_none()` — a live process is steerable; `inject_message`
172    ///   handles it and no turn start is wanted.
173    /// - `!spawning_in_progress` — a caller has already claimed this spawn
174    ///   round (see that field's doc comment on the concurrent-spawn TOCTOU
175    ///   race). Reporting "needs spawn" here would invite a second, racing
176    ///   spawn and orphan a child.
177    /// - `!drain_claim` — a `RetryFlush` drain owns the round. This one is
178    ///   subtle and was missed on the first cut (reagent P1 on PR #2960): when
179    ///   a drain's target process dies mid-flush, the drain **deliberately
180    ///   retains** its claim for the fallback respawn while the exit handler
181    ///   clears `stdin_tx`. Without this term, that window reports "needs
182    ///   spawn", `decide_send_action` then returns `Queued` on the
183    ///   still-held claim, and the message is silently queued while the caller
184    ///   is told it was delivered.
185    ///
186    /// Both excluded windows are retryable rather than lost: the caller gets
187    /// the original delivery error back, which is what lets it come again.
188    ///
189    /// Racy by nature, and safe to be: the process can exit the instant after
190    /// this returns `false`. It is a routing hint, not a guarantee — the spawn
191    /// claim inside `send_message` is what actually serialises spawners. What
192    /// it must not do is report `true` for a state that cannot spawn.
193    pub fn needs_spawn(&self) -> bool {
194        let inner = self.inner.lock().unwrap();
195        inner.stdin_tx.is_none() && !inner.spawning_in_progress && !inner.drain_claim
196    }
197
198    /// Ask this controller to restart itself once the current turn ends,
199    /// instead of being torn down and replaced right now.
200    ///
201    /// Returns `true` when the restart was deferred (a turn is in flight, so
202    /// the caller must NOT replace the controller), `false` when the pane is
203    /// idle and an immediate replace is safe — the caller then proceeds
204    /// exactly as before.
205    ///
206    /// Called from `resync_controller`'s forced-replace path. See
207    /// [`PersistentInner::restart_when_idle`] for what the old
208    /// kill-immediately behaviour destroyed.
209    ///
210    /// Deliberately keyed on the health monitor's `is_active_turn` rather
211    /// than on `stdin_tx.is_some()`: a live process between turns is idle and
212    /// should be replaced immediately (that's the common `/model` case, and
213    /// deferring it would leave the change unapplied until the next turn
214    /// happened to end). It's specifically an IN-FLIGHT turn that must not be
215    /// interrupted.
216    pub fn request_restart_when_idle(&self) -> bool {
217        // The turn-active read and the flag write happen under ONE acquisition
218        // of `inner`, and the turn-end consumer clears `active_turn` under that
219        // same lock — so the two serialize (codex P2 on PR #2858). Interleaved,
220        // this would otherwise observe an active turn, have the consumer run to
221        // completion (seeing `restart_when_idle` still false, so restarting
222        // nothing), and then set the flag — leaving it stuck until the NEXT
223        // turn ended, so the user's next prompt ran with the old config.
224        //
225        // Lock order is `inner` -> health monitor here and in the consumer;
226        // `TurnActivityTracker` never holds its own lock across an `inner`
227        // acquisition, so the order can't invert.
228        let mut inner = self.inner.lock().unwrap();
229        if !self.health_monitor.is_active_turn() {
230            return false;
231        }
232        inner.restart_when_idle = true;
233        drop(inner);
234        // Tell the menu a restart is coming, so a selection that has not
235        // reached the process yet reads as pending and not as a mismatch.
236        self.publish_status();
237        tracing::info!(
238            block_id = %self.block_id,
239            "runtime-config change arrived mid-turn — deferring the restart to the end of this turn \
240             instead of killing the in-flight message"
241        );
242        true
243    }
244}
245
246/// The MCP self-heal's steps for this pane (`backend::mcp_health` decides
247/// them; docs/specs/SPEC_AGENT_MCP_SELF_HEAL_2026_10_10.md).
248impl PersistentSubprocessController {
249    /// A frame from process `generation`: the `agentmux` MCP server's status
250    /// (`system/init`, our `mcp_status`) or a reconnect's outcome. Anything
251    /// else, or a frame from a replaced process, is ignored.
252    pub(super) fn observe_mcp_frame(&self, frame: &serde_json::Value, generation: u64) {
253        use crate::backend::mcp_health as heal;
254        if self.inner.lock().unwrap().spawn_generation != generation {
255            return;
256        }
257        let process = self.mcp_process_key(generation);
258        let now = agentmux_common::time::now_ms_u64();
259        let report = heal::status_from_init(frame)
260            .map(|status| (status, None))
261            .or_else(|| heal::status_from_control_response(frame));
262        let decision = if let Some((status, error)) = report {
263            heal::on_status(&self.block_id, &process, status, error, heal::auto_enabled(), now)
264        } else if let Some(result) = heal::reconnect_result(frame) {
265            heal::on_reconnect(&self.block_id, &process, result, now)
266        } else {
267            return;
268        };
269        self.carry_out_mcp_step(decision, generation);
270    }
271
272    /// `agentmux-mcp doctor --heal`: start the repair over now. `Err` says why
273    /// nothing started (`mcp_health::heal_now`).
274    pub(crate) fn mcp_heal_now(&self) -> Result<(), &'static str> {
275        let generation = self.inner.lock().unwrap().spawn_generation;
276        let now = agentmux_common::time::now_ms_u64();
277        let decision = crate::backend::mcp_health::heal_now(&self.block_id, &self.mcp_process_key(generation), now)?;
278        self.carry_out_mcp_step(decision, generation);
279        Ok(())
280    }
281
282    /// One CLI process of this controller: a replacement controller restarts
283    /// its generations, so the generation alone could name an older process.
284    fn mcp_process_key(&self, generation: u64) -> String {
285        format!("{:p}-{generation}", Arc::as_ptr(&self.inner))
286    }
287
288    fn carry_out_mcp_step(&self, decision: crate::backend::mcp_health::Decision, generation: u64) {
289        use crate::backend::mcp_health::{self as heal, Action};
290        if let Some(notice) = &decision.notice {
291            self.show_mcp_notice(notice);
292        }
293        match decision.action {
294            Action::None => {}
295            Action::Reconnect { after } => self.send_mcp_request(heal::reconnect_request_line(), after, generation),
296            Action::CheckStatus { after } => self.send_mcp_request(heal::status_request_line(), after, generation),
297            Action::SafeProfileRestart => self.restart_now_or_after_turn(),
298            Action::GiveUp => self.tell_agent_mcp_is_down(),
299        }
300    }
301
302    /// Write a control request to process `generation`, after `after`; not if
303    /// it has been replaced by then.
304    fn send_mcp_request(&self, line: String, after: std::time::Duration, generation: u64) {
305        let inner = Arc::clone(&self.inner);
306        let send = move || {
307            if inner.lock().unwrap().spawn_generation == generation {
308                Self::push_stdin(&inner, line);
309            }
310        };
311        if after.is_zero() {
312            send();
313        } else if let Ok(rt) = tokio::runtime::Handle::try_current() {
314            rt.spawn(async move {
315                tokio::time::sleep(after).await;
316                send();
317            });
318        }
319    }
320
321    /// Restart the CLI (the same session) with the config now in force, which
322    /// for a pane in the safe profile means without the shell prefix: at once
323    /// when idle, else when the turn ends, through the deferred runtime-config
324    /// restart (`turn_boundary_locked`, `respawn_after_config_restart`). The
325    /// turn check and the flag share one acquisition, as in
326    /// [`Self::request_restart_when_idle`].
327    fn restart_now_or_after_turn(&self) {
328        let mut inner = self.inner.lock().unwrap();
329        if inner.stdin_tx.is_none() || inner.stop_pending {
330            return;
331        }
332        if self.health_monitor.is_active_turn() {
333            inner.restart_when_idle = true;
334            drop(inner);
335            self.publish_status();
336            return;
337        }
338        inner.restart_pending = true;
339        inner.config_restart_generation = Some(inner.spawn_generation);
340        drop(inner);
341        tracing::info!(block_id = %self.block_id, "MCP self-heal: restarting the agent in the safe profile");
342        self.stop_for_config_restart();
343    }
344
345    fn show_mcp_notice(&self, notice: &crate::backend::mcp_health::Notice) {
346        tracing::info!(block_id = %self.block_id, state = notice.state, step = notice.step, error = ?notice.error, "MCP self-heal");
347        let (Some(broker), Some(filestore), Some(mstore)) = (&self.broker, &self.filestore, &self.mstore) else {
348            return;
349        };
350        let (broker, filestore, mstore) = (Arc::clone(broker), Arc::clone(filestore), Arc::clone(mstore));
351        let (block_id, frame) = (self.block_id.clone(), crate::backend::mcp_health::notice_frame(notice));
352        let append = move || crate::backend::cli_notice::append_to_pane(&broker, &filestore, &mstore, &block_id, &frame);
353        match tokio::runtime::Handle::try_current() {
354            Ok(rt) => drop(rt.spawn_blocking(append)),
355            Err(_) => append(),
356        }
357    }
358
359    /// The `[AgentMux]` note that AgentMux gave up, so the agent tells the
360    /// operator and knows its tools are gone. By UID when the pane has one.
361    fn tell_agent_mcp_is_down(&self) {
362        let error = crate::backend::mcp_health::snapshot(&self.block_id).and_then(|s| s.last_error);
363        let text = crate::backend::mcp_health::give_up_note(error.as_deref());
364        let handler = crate::backend::reactive::get_global_handler();
365        let Some(target) = handler
366            .list_agents()
367            .into_iter()
368            .find(|r| r.block_id == self.block_id)
369            .map(|r| r.uid.unwrap_or(r.agent_id))
370        else {
371            tracing::info!(block_id = %self.block_id, "MCP self-heal: no registered agent to tell");
372            return;
373        };
374        if let Err(e) = handler.deliver_system_note(&target, &text) {
375            tracing::info!(block_id = %self.block_id, error = %e, "MCP self-heal note not delivered");
376        }
377    }
378}