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}