Skip to main content

agentmux_srv/backend/blockcontroller/persistent/
process_waiter.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The persistent CLI's process waiter task, started by `spawn_process`
5//! (`spawn.rs`): the `child.wait()` arm (the process exited on its own) and
6//! the kill arm (a stop or restart asked for it).
7
8use super::*;
9
10/// What the process waiter task captures from `spawn_process`.
11pub(super) struct ProcessWaiterCtx {
12    pub(super) child: tokio::process::Child,
13    pub(super) kill_rx: tokio::sync::oneshot::Receiver<KillRequest>,
14    pub(super) stderr_reader_handle: Option<tokio::task::JoinHandle<()>>,
15    pub(super) stdout_reader_handle: tokio::task::JoinHandle<()>,
16    pub(super) segment_wait: super::segments::SegmentRef,
17    pub(super) global_output_zone_wait: Option<String>,
18    pub(super) record_fence_wait: Option<crate::backend::agent_admission::RecordFence>,
19    pub(super) block_id_wait: String,
20    pub(super) inner_wait: Arc<Mutex<PersistentInner>>,
21    pub(super) broker_wait: Option<Arc<mps::Broker>>,
22    pub(super) mstore_wait: Option<Arc<Store>>,
23    pub(super) event_bus_wait: Option<Arc<EventBus>>,
24    pub(super) health_wait: Arc<TurnActivityTracker>,
25    pub(super) filestore_wait: Option<Arc<FileStore>>,
26    pub(super) agent_id_wait: Option<String>,
27    pub(super) self_ref_wait: std::sync::Weak<PersistentSubprocessController>,
28    pub(super) my_generation_wait: u64,
29    pub(super) pid_wait: u32,
30    pub(super) nonce_wait: u64,
31    /// This spawn's registration nonce, which the stdout reader recorded its
32    /// MCP report under (`backend::mcp_observed`).
33    pub(super) mcp_owner_wait: u64,
34}
35
36impl PersistentSubprocessController {
37    /// Body of the process waiter task.
38    pub(super) async fn run_process_waiter(ctx: ProcessWaiterCtx) {
39        let ProcessWaiterCtx {
40            mut child,
41            kill_rx,
42            stderr_reader_handle,
43            stdout_reader_handle,
44            segment_wait,
45            global_output_zone_wait,
46            record_fence_wait,
47            block_id_wait,
48            inner_wait,
49            broker_wait,
50            mstore_wait,
51            event_bus_wait,
52            health_wait,
53            filestore_wait,
54            agent_id_wait,
55            self_ref_wait,
56            my_generation_wait,
57            pid_wait,
58            nonce_wait,
59            mcp_owner_wait,
60        } = ctx;
61        tokio::select! {
62            status = child.wait() => {
63                let exit_code = status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1);
64                super::segments::record_segment_end(
65                    &segment_wait,
66                    crate::backend::continuity_segments::exit_reason(exit_code),
67                    crate::backend::agent_admission::fenced_zone(global_output_zone_wait.as_deref(), &record_fence_wait),
68                );
69                tracing::info!(
70                    block_id = %block_id_wait,
71                    exit_code = exit_code,
72                    "persistent process exited"
73                );
74
75                // Give the stderr reader a bounded chance to fully
76                // drain and react to whatever it saw right before this
77                // process exited — codex P1/P2 on PR #2360 (second
78                // review pass): `child.wait()` resolving does NOT mean
79                // the stderr reader (an independently-scheduled task)
80                // has already called `poison_resume` for a "No
81                // conversation found" line, or finished ITS OWN
82                // subsequent `persist_session_id("")` call. Without
83                // this, two failure modes were possible: (1) this task
84                // could resolve the resume state (`ProcessExited`) below
85                // before the stderr reader ever promotes it to
86                // `ConfirmedRetry`, permanently losing
87                // the retry for the exact case it exists to catch, and
88                // (2) a confirmed retry's fresh session id (persisted
89                // by the NEW process's own stdout reader once
90                // respawned) could be silently overwritten by this
91                // exiting process's stderr task finally getting around
92                // to persisting an empty one, corrupting continuity
93                // despite the retry having succeeded. 500ms is
94                // generous — the stderr pipe closes and drains almost
95                // immediately once the process has genuinely exited.
96                //
97                // If it DOES take longer than that (e.g. `persist_session_id`
98                // blocked on a slow store call), a bare `timeout()` alone
99                // is not enough — codex P1 on PR #2360 (fifth review
100                // pass): `timeout()` only stops WAITING for the handle,
101                // it does not cancel the underlying task, which keeps
102                // running in the background and can still call
103                // `poison_resume`/`persist_session_id("")` AFTER this
104                // task has already moved on (discarded the still-
105                // tentative retry, or started a fresh child whose own
106                // new session id that late write would then corrupt).
107                // `abort()` on a separately-obtained `AbortHandle`
108                // actually cancels it — the task stops at its next
109                // yield point and can never reach either call again.
110                //
111                // codex P1 on PR #2371 (round 1): the stdout reader
112                // performs synchronous FileStore/SQLite writes that
113                // can legitimately contend for multiple seconds
114                // (SQLite's own busy timeout) — a short bound (the
115                // stderr reader's 500ms above) would abort it
116                // mid-line under ordinary contention, discarding
117                // still-unread assistant/result frames and
118                // truncating the persisted transcript.
119                //
120                // codex P1 on PR #2371 (round 2): but an UNBOUNDED
121                // await isn't safe either — if the CLI spawned a
122                // background descendant that inherited its stdout
123                // descriptor, killing/waiting for the direct child
124                // does NOT close that descriptor, so this reader's
125                // `lines.next_line()` may never see EOF, hanging this
126                // exit-handling step (and everything after it —
127                // health status, muxbus deregistration, the retry
128                // decision itself) forever.
129                //
130                // 10s is the compromise: generous enough that
131                // ordinary SQLite contention never triggers the
132                // abort (avoiding the round-1 truncation risk), but
133                // still a hard ceiling so a genuinely stuck
134                // descendant-held pipe (or anything else gone wrong)
135                // can't hang this task indefinitely (closing the
136                // round-2 gap). Aborting our own read loop doesn't
137                // require the OS pipe to actually close — it just
138                // stops OUR wait, accepting we may not have drained
139                // every last buffered line, the same risk profile
140                // the original 500ms bound already accepted, just at
141                // a bound wide enough not to fire under normal load.
142                settle_reader_tasks(&block_id_wait, stderr_reader_handle, stdout_reader_handle, "process exit").await;
143                forget_mcp_servers(mcp_owner_wait, broker_wait.as_deref(), &block_id_wait);
144
145                // Wait (briefly, bounded) for the drain to finish
146                // appending whatever message it's currently
147                // mid-delivery on before deciding the retry batch
148                // below is final — reagentx P1 on PR #2360 (sixth
149                // review pass, round 9): `drain_queue_after_
150                // successful_spawn`'s own "send, then append to the
151                // retry batch" sequence has an unavoidable gap at the
152                // `.await` (a mutex can't be held across it). Without
153                // this wait, the `.take()` below could run in that
154                // exact gap and dispatch a retry missing a message
155                // the doomed process's channel had ALREADY accepted —
156                // it stays marked "accepted" and gets persisted, but
157                // is never actually delivered to any process again.
158                // Same 500ms bound as the stderr-reader wait above,
159                // for the same "best effort, don't hang forever"
160                // reason.
161                for _ in 0..50 {
162                    if !inner_wait.lock().unwrap().drain_send_in_flight {
163                        break;
164                    }
165                    tokio::time::sleep(std::time::Duration::from_millis(10)).await;
166                }
167
168                let mut inner = inner_wait.lock().unwrap();
169                // reagentx P1 (round 6 on PR #2373, extended round 8):
170                // this belated exit-handling can run AFTER a fresh
171                // spawn has already superseded this generation (see
172                // `respawn_once_for_leftover_queue`'s own doc comment
173                // and the kill arm below for the documented race that
174                // makes this reachable) — `inner.spawn_generation` is
175                // bumped on every NEW spawn, so a mismatch here means
176                // this exit is for an already-superseded generation.
177                // Everything gated below belongs to THIS exact
178                // process (its own pid/exit code/stdin/kill channel,
179                // the shared `proc_status` this process last knew to
180                // be true, its own health-monitor/muxbus/registry/
181                // session-recovery registration) — running any of it
182                // unconditionally would corrupt or tear down a newer,
183                // actively-running generation's own state as if IT
184                // had exited. reagentx round 8: the round-6 fix only
185                // gated the field writes above, missing
186                // `health_wait.set_exited` (a shared `TurnActivityTracker`
187                // across generations) and the deregistration block
188                // below — both keyed by `block_id`/`agent_id`, not
189                // generation, so a stale exit incorrectly marked a
190                // live process's health as exited and tore down its
191                // muxbus/registry/session-recovery registration while
192                // it kept running.
193                let is_current_generation = inner.spawn_generation == my_generation_wait;
194                if is_current_generation {
195                    inner.proc_exit_code = exit_code;
196                    inner.current_pid = None;
197                    inner.stdin_tx = None;
198                    inner.kill_tx = None;
199                    // This process no longer drives the agent: release
200                    // its single-live-instance lease (on the last `Arc`).
201                    inner.agent_lease = None;
202                }
203                // One event resolves the ENTIRE retry/error-line
204                // decision — including any earlier `StopRequested`
205                // (see `stop_process`), already baked into the state
206                // by `persistent_resume::update` before this event
207                // ever arrives. See `persistent_resume`'s module doc
208                // comment for why this replaced four separate field
209                // reads/writes. Safe to call unconditionally even for
210                // a superseded generation — `update()`'s own
211                // generation-matching arms (and `NotTracking`'s
212                // `current_generation`) already no-op a stale event
213                // on their own.
214                let effects = inner
215                    .apply_resume_event(persistent_resume::ResumeEvent::ProcessExited { generation: my_generation_wait });
216                if is_current_generation {
217                    Self::set_status(&mut inner, STATUS_DONE);
218                }
219                // A committed config restart whose process exited on its
220                // own before the kill was consumed: this arm won the
221                // `select!`, so it must finish the restart too (codex P1 on
222                // #3990). Acted on at the end of this arm, like the kill
223                // arm; a stale-resume retry spawning first supersedes it.
224                let respawn_after_restart = Self::config_restart_due_locked(&inner, my_generation_wait);
225                drop(inner);
226                // The process is gone: say so, whatever else this arm publishes.
227                if is_current_generation {
228                    if let Some(b) = broker_wait.as_ref() {
229                        super::status::publish_runtime_event(&inner_wait, b, &block_id_wait);
230                    }
231                }
232
233                if is_current_generation {
234                    // The process is gone — nothing is waiting on the user
235                    // any more (Phase 5 notifications).
236                    if let Some(r) = broker_wait.as_ref().and_then(crate::backend::notify::router::get) {
237                        r.resolve_nonblocking(&block_id_wait, crate::backend::notify::policy::Family::Input);
238                    }
239                    // Notify health monitor so Stalled/Dead watchdog stops.
240                    health_wait.set_exited(exit_code);
241
242                    // Deregister from muxbus so later sends fall through to the
243                    // lower tiers instead of resolving to a dead block. Mirrors
244                    // the shell controller's exit path. This exact process's
245                    // resources are gone either way; if a retry/fallback
246                    // respawn has ALREADY re-registered, the guards below
247                    // leave its fresh registration in place.
248                    // All removals are compare-and-remove keyed on this
249                    // spawn's process-wide registration nonce (issue
250                    // #2363): the `is_current_generation` gate above
251                    // was read once, and a fallback/retry respawn's
252                    // fresh registration can land on a parallel task
253                    // between that read and these calls — an
254                    // unconditional removal here would wipe the NEW
255                    // spawn's entry with nothing left to re-register
256                    // it. Nonce, not generation: a replacement
257                    // controller's spawn restarts at generation 1 and
258                    // could collide with ours (codex P1 on PR #2500).
259                    let registration_was_ours = crate::backend::reactive::get_global_handler()
260                        .unregister_block_if_nonce(&block_id_wait, nonce_wait);
261                    if let Some(ref agent_id) = agent_id_wait {
262                        let data_dir = crate::backend::base::get_mux_data_dir();
263                        crate::backend::reactive::registry::remove_if_nonce(
264                            &data_dir,
265                            agent_id,
266                            nonce_wait,
267                        );
268                        crate::backend::reactive::registry::remove_shared_from_env_if_nonce(
269                            agent_id,
270                            nonce_wait,
271                        );
272                        // The cloud subscriber's agent set records no
273                        // per-agent identity to compare against, so the
274                        // in-memory registration's outcome above stands
275                        // in: if a newer spawn already re-registered,
276                        // its cloud subscription must survive too.
277                        // …but a registration already gone reads `false`
278                        // too; then nobody holds the agent and it must go,
279                        // or its WAN lease is renewed forever.
280                        // `drop_if_orphaned` re-checks after removing, so a
281                        // respawn registering meanwhile keeps its entry
282                        // (Codex P2 on #3897).
283                        let handler = crate::backend::reactive::get_global_handler();
284                        let still_held = handler.has_live_name(agent_id);
285                        if crate::muxbus::cloud_subscriber::drop_cloud_subscription_on_exit(
286                            registration_was_ours,
287                            still_held,
288                        ) {
289                            if let Some(sub) = crate::muxbus::cloud_subscriber::get_global_subscriber() {
290                                sub.drop_if_orphaned(agent_id, |a| handler.has_live_name(a));
291                            }
292                        }
293                    }
294
295                    // Clear active pid — clean exit, no recovery needed.
296                    // Compare-and-clear (issue #2363): only if the
297                    // recorded pid is still THIS process's — a fallback
298                    // respawn may have re-registered a fresh pid on a
299                    // parallel task between the generation gate above
300                    // and this call.
301                    if let Some(ref mstore) = mstore_wait {
302                        super::super::session_recovery::clear_active_pid_if_pid(mstore, &block_id_wait, pid_wait);
303                    }
304                }
305
306                // A stale `--resume <sid>` is exactly what killed this
307                // process (`retry_after_resume_failure`'s doc comment) —
308                // retry the same message once, fresh, WITHOUT publishing
309                // this transient failure as a completed turn first.
310                // codex P2 on PR #2360 (second review pass): publishing
311                // "done"/turn_active:false here would let the mounted
312                // UI (trackTurnJustEnded, a deferred controller refresh)
313                // treat this failed attempt as the real end of the
314                // user's turn before the retry's own fresh "running"
315                // status ever lands. reagentx/codex never reviewed this
316                // controller type in PR #2338 (see docs/retro/
317                // RETRO_STALE_RESUME_SESSION_ID_ACROSS_CHANNELS_2026_07_29.md).
318                for effect in effects {
319                    match effect {
320                        // SPEC_AGENT_PANE_HISTORY_ALIGNMENT_2026_08_05.md
321                        // §2.1: the `ConfirmedRetry` + `ProcessExited`
322                        // (not-stopped) arm now bundles this alongside
323                        // `FireRetry` — the resume's fate (Fresh) is
324                        // already known here, even though the retry
325                        // below hasn't launched yet.
326                        persistent_resume::ResumeEffect::EmitSessionOutcome {
327                            outcome,
328                            attempted_sid,
329                            actual_sid,
330                        } => {
331                            publish_resume_retry_status(&broker_wait, &block_id_wait, "resolved");
332                            // Same retraction as the stdout-reader site
333                            // (`stdout_reader.rs`) — this arm reaches `Fresh` today, but
334                            // the clear is keyed on the outcome rather
335                            // than on which arm produced it, so it stays
336                            // correct if that ever changes.
337                            if matches!(outcome, persistent_resume::SessionOutcome::Resumed) {
338                                if let Some(ref store) = mstore_wait {
339                                    super::super::session_recovery::clear_resume_failed(
340                                        store,
341                                        &event_bus_wait,
342                                        &block_id_wait,
343                                    );
344                                }
345                            }
346                            if let Some(ref broker) = broker_wait {
347                                let line = session_outcome_line(outcome, attempted_sid, actual_sid);
348                                super::super::shell::handle_append_block_file(
349                                    broker,
350                                    &block_id_wait,
351                                    PERSISTENT_OUTPUT_SUBJECT,
352                                    line.as_bytes(),
353                                    filestore_wait.as_ref(),
354                                    crate::backend::agent_admission::fenced_zone(global_output_zone_wait.as_deref(), &record_fence_wait),
355                                );
356                            }
357                        }
358                        persistent_resume::ResumeEffect::PersistImmediately(line)
359                        | persistent_resume::ResumeEffect::FlushErrorLine(line) => {
360                            // Safety net: a "Reconnecting…" left showing from
361                            // an earlier retry attempt on this same block must
362                            // not get stuck forever just because THIS exit
363                            // ended up genuinely non-retryable — a harmless
364                            // no-op publish in the (common) case where no
365                            // retry was in flight at all.
366                            publish_resume_retry_status(&broker_wait, &block_id_wait, "resolved");
367                            if let Some(ref broker) = broker_wait {
368                                super::super::shell::handle_append_block_file(
369                                    broker,
370                                    &block_id_wait,
371                                    PERSISTENT_OUTPUT_SUBJECT,
372                                    line.as_bytes(),
373                                    filestore_wait.as_ref(),
374                                    crate::backend::agent_admission::fenced_zone(global_output_zone_wait.as_deref(), &record_fence_wait),
375                                );
376                            }
377                            // Classify + surface this exit's error to the
378                            // pane's failure-recovery UI —
379                            // SPEC_PERSISTENT_CONTROLLER_FAILURE_CLASSIFICATION.
380                            // Reached only for PersistImmediately/FlushErrorLine
381                            // — the resume machinery has already decided this
382                            // exit is NOT being silently retried (contrast
383                            // FireRetry below, which must stay invisible to
384                            // the user).
385                            surface_error_line(
386                                &block_id_wait,
387                                Some(exit_code),
388                                &line,
389                                broker_wait.as_deref(),
390                                &mstore_wait,
391                                &event_bus_wait,
392                            );
393                        }
394                        persistent_resume::ResumeEffect::FireRetry { retry, held_error_line, attempted_sid } => {
395                            // Issue #2368: the retry is firing and will
396                            // very likely succeed within milliseconds —
397                            // the doomed attempt's own terminal error
398                            // result must never reach the user, so it's
399                            // dropped (not flushed) as long as the retry
400                            // actually launches. Handed to
401                            // `retry_after_resume_failure` itself (codex
402                            // P2 on PR #2371) rather than dropped here
403                            // unconditionally — a retry that turns out
404                            // NOT to launch (this controller already
405                            // gone, or the fresh spawn itself failing)
406                            // must still flush it, or an already-
407                            // accepted prompt ends in total silence.
408                            if let Some(ctrl) = self_ref_wait.upgrade() {
409                                tracing::warn!(
410                                    block_id = %block_id_wait,
411                                    "stale --resume session id caused this exit — retrying now (find_recovery_session_id may still resume a real, on-disk session rather than starting blank)"
412                                );
413                                ctrl.retry_after_resume_failure(
414                                    my_generation_wait,
415                                    retry.config,
416                                    retry.messages,
417                                    held_error_line,
418                                    attempted_sid,
419                                );
420                            } else {
421                                // reagentx P2 on PR #2776: the controller
422                                // itself is already gone — no retry will
423                                // ever fire for this batch, so any
424                                // "Reconnecting…" left showing from an
425                                // earlier attempt on this same block must
426                                // be resolved here; nothing else will.
427                                publish_resume_retry_status(&broker_wait, &block_id_wait, "resolved");
428                                if let Some(line) = held_error_line {
429                                // The controller itself is already gone
430                                // (weak ref invalidated) — nothing can
431                                // retry this batch at all, so flush
432                                // directly via this task's own captured
433                                // broker/filestore rather than going
434                                // through `ctrl`.
435                                if let Some(ref broker) = broker_wait {
436                                    super::super::shell::handle_append_block_file(
437                                        broker,
438                                        &block_id_wait,
439                                        PERSISTENT_OUTPUT_SUBJECT,
440                                        line.as_bytes(),
441                                        filestore_wait.as_ref(),
442                                        crate::backend::agent_admission::fenced_zone(global_output_zone_wait.as_deref(), &record_fence_wait),
443                                    );
444                                }
445                                }
446                            }
447                        }
448                        persistent_resume::ResumeEffect::PublishDone => {
449                            if let Some(ref broker) = broker_wait {
450                                let status = super::super::agent_runtime_status(
451                                    &block_id_wait,
452                                    0,
453                                    STATUS_DONE,
454                                    exit_code,
455                                    false,
456                                );
457                                super::super::publish_controller_status(broker, &status);
458                            }
459                        }
460                    }
461                }
462
463                if respawn_after_restart {
464                    if let Some(ctrl) = self_ref_wait.upgrade() {
465                        ctrl.respawn_after_config_restart(my_generation_wait);
466                    }
467                }
468            }
469            Ok(request) = kill_rx => {
470                let graceful_deadline = match request {
471                    KillRequest::Force => None,
472                    KillRequest::Graceful(deadline) => Some(deadline),
473                };
474                // Why it's going away, read before the kill changes
475                // anything: `shutdown` marks a pane close for this
476                // generation; `restart_pending` a runtime-config restart.
477                let segment_end_reason = {
478                    let inner = inner_wait.lock().unwrap();
479                    crate::backend::continuity_segments::kill_reason(
480                        inner.shutdown_generation == Some(my_generation_wait),
481                        inner.restart_pending,
482                    )
483                };
484                tracing::info!(
485                    block_id = %block_id_wait,
486                    force = graceful_deadline.is_none(),
487                    "persistent process kill requested"
488                );
489                if let Some(deadline) = graceful_deadline {
490                    // Graceful: drop stdin to send EOF, then wait briefly
491                    {
492                        let mut inner = inner_wait.lock().unwrap();
493                        inner.stdin_tx = None; // drops the sender → stdin writer exits → stdin closes
494                        // reagentx P0 on PR #2360 (sixth review pass,
495                        // round 10): must clear pending_send_messages/
496                        // spawning_in_progress in this SAME lock
497                        // acquisition as stdin_tx, not only later
498                        // (after child.wait()/the 5s timeout below).
499                        // During that window,
500                        // drain_queue_after_successful_spawn's
501                        // independently-scheduled background task
502                        // could observe stdin_tx.is_none() with
503                        // messages still queued, classify it as a
504                        // stall, and call
505                        // respawn_once_for_leftover_queue — spawning a
506                        // brand-new CLI process while the user's
507                        // graceful stop is still in progress. The
508                        // force-kill path doesn't have this gap (it
509                        // never clears stdin_tx early — all three
510                        // clear together below, after child.kill()
511                        // resolves), only this graceful one, since it
512                        // specifically needs stdin_tx gone early to
513                        // trigger EOF.
514                        inner.pending_send_messages.clear();
515                        inner.spawning_in_progress = false;
516                    }
517                    // Wait until the caller's deadline (a close shares
518                    // one deadline across every agent it stops — spec
519                    // §9.2), then kill.
520                    let killed = tokio::select! {
521                        _ = child.wait() => false,
522                        _ = tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)) => {
523                            let _ = child.kill().await;
524                            true
525                        }
526                    };
527                    inner_wait.lock().unwrap().stop_exit = Some((my_generation_wait, killed));
528                } else {
529                    let _ = child.kill().await;
530                    inner_wait.lock().unwrap().stop_exit = Some((my_generation_wait, true));
531                }
532                super::segments::record_segment_end(
533                    &segment_wait,
534                    segment_end_reason,
535                    crate::backend::agent_admission::fenced_zone(global_output_zone_wait.as_deref(), &record_fence_wait),
536                );
537
538                // Mirror the child.wait() arm's bounded await+abort of
539                // both reader tasks (above) before resolving the resume
540                // state (`StopRequested` + `ProcessExited`) below
541                // (#2371). Without this, a stop racing the doomed
542                // attempt's in-flight terminal error line could resolve
543                // the state before the stdout reader holds the line
544                // back; the lagging reader would then find `NotTracking`
545                // and persist the line on its own, instead of it being
546                // flushed below as part of this stop.
547                // codex P1 on PR #2371 (round 2): a bounded wait, not
548                // an unconditional one — see the child.wait() arm's
549                // identical comment above for the full reasoning. This
550                // matters MORE here: codex flagged that every
551                // remaining cleanup step (clearing current_pid/
552                // kill_tx, STATUS_DONE, muxbus deregistration) runs
553                // AFTER this await, so an unbounded hang here would
554                // leave a user-initiated Stop making the controller
555                // appear permanently alive if a descendant process
556                // ever holds the stdout descriptor open.
557                settle_reader_tasks(&block_id_wait, stderr_reader_handle, stdout_reader_handle, "kill").await;
558                forget_mcp_servers(mcp_owner_wait, broker_wait.as_deref(), &block_id_wait);
559
560                let mut inner = inner_wait.lock().unwrap();
561                // reagentx P1 (round 6 on PR #2373, extended round 8):
562                // same reasoning as the child.wait() arm above — this
563                // graceful-stop cleanup can itself run AFTER the
564                // documented race just above (dropping `stdin_tx`
565                // early to trigger EOF lets
566                // `respawn_once_for_leftover_queue` spawn a brand-new
567                // generation while this kill is still mid-flight) has
568                // already superseded this generation. Everything
569                // gated below belongs to THIS exact kill (its own
570                // pid/exit code/stdin/kill channel, its own spawn
571                // claim/queue, the shared `proc_status` this stop
572                // last knew to be true, its own health-monitor/
573                // muxbus/registry/session-recovery registration) —
574                // running any of it unconditionally would corrupt or
575                // tear down a newer, actively-running generation's
576                // own state as if IT had been stopped.
577                let is_current_generation = inner.spawn_generation == my_generation_wait;
578                if is_current_generation {
579                    inner.proc_exit_code = -1;
580                    inner.current_pid = None;
581                    inner.stdin_tx = None;
582                    inner.kill_tx = None;
583                    // This process no longer drives the agent: release
584                    // its single-live-instance lease (on the last `Arc`).
585                    inner.agent_lease = None;
586                }
587                // A user-initiated kill overrides any resume-retry
588                // decision in flight, for a REUSED controller instance
589                // (`resync_controller` can reuse the same instance
590                // across a kill+restart cycle) the same way an
591                // in-flight Stop already does for the child.wait() arm
592                // — reusing that exact `StopRequested` + `ProcessExited`
593                // event pair here instead of duplicating the "stop
594                // wins" logic against raw fields. codex P2 on PR #2371:
595                // this also means a stashed error line is never
596                // silently lost on a user-initiated stop (it may be a
597                // genuine error the stop itself interrupted) — it's
598                // flushed below via the effects this produces, same as
599                // the "genuinely done" case. Safe to call
600                // unconditionally even for a superseded generation —
601                // `update()`'s own generation-matching arms already
602                // no-op a stale event on their own.
603                inner.apply_resume_event(persistent_resume::ResumeEvent::StopRequested {
604                    generation: my_generation_wait,
605                });
606                let effects = inner
607                    .apply_resume_event(persistent_resume::ResumeEvent::ProcessExited { generation: my_generation_wait });
608                // codex P1 on PR #2360 (sixth review pass, round 5):
609                // an active spawn claim's own background drain
610                // (`drain_queue_after_successful_spawn`) is a
611                // SEPARATE, independently-scheduled task — killing
612                // this process does not cancel it. Left untouched, its
613                // next check would see `stdin_tx` gone with messages
614                // still queued, treat that as a stall, and hand off to
615                // `respawn_once_for_leftover_queue` — silently
616                // reviving the agent moments after the user explicitly
617                // stopped it. Clearing the queue AND releasing the
618                // claim here means that same check instead sees an
619                // empty queue and just concludes normally, with no
620                // fallback respawn triggered. Gated the same way as
621                // above — a NEWER generation's own claim/queue must
622                // never be cleared by this stale one's cleanup.
623                if is_current_generation {
624                    inner.pending_send_messages.clear();
625                    inner.spawning_in_progress = false;
626                    Self::set_status(&mut inner, STATUS_DONE);
627                }
628                // This kill was a deferred config restart of THIS
629                // generation (`config_restart_due_locked`). Acted on at the
630                // end of this arm, after the deregistration below, because
631                // the replacement registers itself afresh. Only read here:
632                // the spawn claim consumes the token, so a Stop landing
633                // before then still wins.
634                let respawn_after_restart = Self::config_restart_due_locked(&inner, my_generation_wait);
635                drop(inner);
636                // The process is gone: say so, whatever else this arm publishes.
637                if is_current_generation {
638                    if let Some(b) = broker_wait.as_ref() {
639                        super::status::publish_runtime_event(&inner_wait, b, &block_id_wait);
640                    }
641                }
642
643                // reagentx P1 on PR #2776: a user-initiated Stop can
644                // land at any point, including mid stale-`--resume`
645                // retry — "Reconnecting…" has no other clearing path on
646                // this branch (unlike `compacting`, which several
647                // turn-end transitions defensively reset), so without
648                // this it would stick on-screen forever with a growing
649                // counter, and a later retry on the same pane would
650                // wrongly keep the stale `startedAt` (ResumeRetryStarted
651                // is a no-op while already reconnecting). Unconditional
652                // and un-gated by `is_current_generation` on purpose —
653                // a harmless no-op when nothing was reconnecting, and
654                // the pane is stopping either way, so there's no
655                // "which generation" ambiguity worth encoding here.
656                publish_resume_retry_status(&broker_wait, &block_id_wait, "resolved");
657
658                // Flush a held-back error line now, if the resume
659                // state machine produced one — the `StopRequested`
660                // sent above guarantees `ProcessExited` resolves via
661                // the "stop wins" branch (see `persistent_resume::
662                // update`), so `FireRetry` is never actually possible
663                // here, but it's still matched defensively rather
664                // than assumed.
665                for effect in effects {
666                    let line = match effect {
667                        persistent_resume::ResumeEffect::PersistImmediately(line)
668                        | persistent_resume::ResumeEffect::FlushErrorLine(line) => Some(line),
669                        persistent_resume::ResumeEffect::FireRetry { held_error_line, .. } => held_error_line,
670                        persistent_resume::ResumeEffect::PublishDone => None,
671                        // Not actually reachable here today — the "stop
672                        // wins" branch of `update()`'s ConfirmedRetry +
673                        // ProcessExited arm never produces this effect
674                        // (see SPEC_AGENT_PANE_HISTORY_ALIGNMENT_2026_08_05.md
675                        // §2.1) — but matched defensively, same as
676                        // `FireRetry` above, rather than assumed. Reuses
677                        // the same "append this line" path as every
678                        // other variant here.
679                        persistent_resume::ResumeEffect::EmitSessionOutcome {
680                            outcome,
681                            attempted_sid,
682                            actual_sid,
683                        } => Some(session_outcome_line(outcome, attempted_sid, actual_sid)),
684                    };
685                    if let Some(line) = line {
686                    if let Some(ref broker) = broker_wait {
687                        super::super::shell::handle_append_block_file(
688                            broker,
689                            &block_id_wait,
690                            PERSISTENT_OUTPUT_SUBJECT,
691                            line.as_bytes(),
692                            filestore_wait.as_ref(),
693                            crate::backend::agent_admission::fenced_zone(global_output_zone_wait.as_deref(), &record_fence_wait),
694                        );
695                    }
696                    }
697                }
698
699                if is_current_generation {
700                    // Notify the turn-activity tracker of the exit —
701                    // shared `Arc<TurnActivityTracker>` across
702                    // generations, gated the same way as the
703                    // child.wait() arm above.
704                    health_wait.set_exited(-1);
705
706                    // Deregister from muxbus (see the clean-exit arm above).
707                    // All removals are compare-and-remove keyed on this
708                    // spawn's process-wide registration nonce (issue
709                    // #2363): the `is_current_generation` gate above
710                    // was read once, and a fallback/retry respawn's
711                    // fresh registration can land on a parallel task
712                    // between that read and these calls — an
713                    // unconditional removal here would wipe the NEW
714                    // spawn's entry with nothing left to re-register
715                    // it. Nonce, not generation: a replacement
716                    // controller's spawn restarts at generation 1 and
717                    // could collide with ours (codex P1 on PR #2500).
718                    let registration_was_ours = crate::backend::reactive::get_global_handler()
719                        .unregister_block_if_nonce(&block_id_wait, nonce_wait);
720                    if let Some(ref agent_id) = agent_id_wait {
721                        let data_dir = crate::backend::base::get_mux_data_dir();
722                        crate::backend::reactive::registry::remove_if_nonce(
723                            &data_dir,
724                            agent_id,
725                            nonce_wait,
726                        );
727                        crate::backend::reactive::registry::remove_shared_from_env_if_nonce(
728                            agent_id,
729                            nonce_wait,
730                        );
731                        // The cloud subscriber's agent set records no
732                        // per-agent identity to compare against, so the
733                        // in-memory registration's outcome above stands
734                        // in: if a newer spawn already re-registered,
735                        // its cloud subscription must survive too.
736                        // …but a registration already gone reads `false`
737                        // too; then nobody holds the agent and it must go,
738                        // or its WAN lease is renewed forever.
739                        // `drop_if_orphaned` re-checks after removing, so a
740                        // respawn registering meanwhile keeps its entry
741                        // (Codex P2 on #3897).
742                        let handler = crate::backend::reactive::get_global_handler();
743                        let still_held = handler.has_live_name(agent_id);
744                        if crate::muxbus::cloud_subscriber::drop_cloud_subscription_on_exit(
745                            registration_was_ours,
746                            still_held,
747                        ) {
748                            if let Some(sub) = crate::muxbus::cloud_subscriber::get_global_subscriber() {
749                                sub.drop_if_orphaned(agent_id, |a| handler.has_live_name(a));
750                            }
751                        }
752                    }
753
754                    // Clear active pid — user-initiated stop, no recovery needed.
755                    // Compare-and-clear (issue #2363), same as the
756                    // clean-exit arm: the `is_current_generation` gate
757                    // above was read once under the lock, and a fallback
758                    // respawn's re-registration can land between that
759                    // read and this call.
760                    if let Some(ref mstore) = mstore_wait {
761                        super::super::session_recovery::clear_active_pid_if_pid(mstore, &block_id_wait, pid_wait);
762                    }
763                }
764
765                if respawn_after_restart {
766                    if let Some(ctrl) = self_ref_wait.upgrade() {
767                        ctrl.respawn_after_config_restart(my_generation_wait);
768                    }
769                }
770            }
771        }
772    }
773}
774
775/// The CLI is gone, so its MCP servers are too (`backend::mcp_observed`),
776/// unless a newer spawn already reports its own. Here, not at the reader's
777/// end of input: a reader can be aborted unfinished (`settle_reader_tasks`),
778/// and a descendant holding stdout open keeps it from ending at all.
779fn forget_mcp_servers(owner: u64, broker: Option<&mps::Broker>, block_id: &str) {
780    if crate::backend::mcp_observed::forget_owned(block_id, owner) {
781        crate::backend::mcp_observed::publish(broker, block_id);
782    }
783}
784
785/// Wait for the process's reader tasks once it has ended (stderr up to 500ms,
786/// stdout up to 10s), aborting any that overruns. `after` names how the process
787/// ended ("process exit" or "kill") in the warning.
788async fn settle_reader_tasks(
789    block_id: &str,
790    stderr_reader_handle: Option<tokio::task::JoinHandle<()>>,
791    stdout_reader_handle: tokio::task::JoinHandle<()>,
792    after: &str,
793) {
794    if let Some(handle) = stderr_reader_handle {
795        let abort_handle = handle.abort_handle();
796        if tokio::time::timeout(std::time::Duration::from_millis(500), handle).await.is_err() {
797            tracing::warn!(
798                block_id = %block_id,
799                "stderr reader did not finish within 500ms of {after} — aborting it"
800            );
801            abort_handle.abort();
802        }
803    }
804    let abort_handle = stdout_reader_handle.abort_handle();
805    if tokio::time::timeout(std::time::Duration::from_secs(10), stdout_reader_handle)
806        .await
807        .is_err()
808    {
809        tracing::warn!(
810            block_id = %block_id,
811            "stdout reader did not finish within 10s of {after} \
812             (SQLite contention, or a descendant process holding stdout open?) \
813             — aborting it"
814        );
815        abort_handle.abort();
816    }
817}