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}