Skip to main content

agentmux_srv/backend/blockcontroller/subprocess/
host_spawn.rs

1// Copyright 2025, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Host-subprocess turn spawn (`spawn_turn`) for [`SubprocessController`].
5//!
6//! Runs the agent CLI as a `std::process` child (via `tokio::process`),
7//! writes the turn message to its stdin, and drives two background tasks:
8//! a stdout/stderr reader pair and a process-waiter that finalizes status
9//! and drains the queued-message backlog. See `container_spawn` for the
10//! Docker-exec analog used by container agents.
11//!
12//! `spawn_turn` is one continuous, non-trivially-ordered state machine and
13//! is moved here WHOLE rather than decomposed further.
14
15#[cfg(windows)]
16use agentmux_common::win32::NoWindow;
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::{Arc, Mutex};
19
20use tokio::io::{AsyncBufReadExt, BufReader};
21
22#[cfg(unix)]
23use crate::backend::blockcontroller::DEFAULT_GRACEFUL_KILL_WAIT_MS;
24use crate::backend::blockcontroller::{
25    core, publish_controller_status,
26    session_stats, shell, STATUS_DONE, STATUS_RUNNING,
27};
28
29use super::{
30    argv::{append_prompt_arg, args_for_log, build_turn_argv},
31    SubprocessController, SubprocessSpawnConfig, SUBPROCESS_OUTPUT_SUBJECT,
32};
33
34impl SubprocessController {
35    /// Spawn a single turn of the agent CLI.
36    ///
37    /// This is the core method — it spawns `claude -p`, writes the user message to stdin,
38    /// reads NDJSON from stdout (publishing MPS events), and waits for exit.
39    ///
40    /// If a session_id exists from a previous turn, `--resume <sid>` is appended to args.
41    pub fn spawn_turn(&self, config: SubprocessSpawnConfig) -> Result<(), String> {
42        if !self.try_lock_run() {
43            // Turn in progress — queue the message for after it exits.
44            let mut inner = self.inner.lock().unwrap();
45            tracing::info!(
46                block_id = %self.block_id,
47                queue_depth = inner.pending_messages.len() + 1,
48                "subprocess busy — message queued"
49            );
50            inner.pending_messages.push_back(config);
51            return Ok(());
52        }
53
54        // Hydrate inner.session_id from the config-supplied id if the
55        // controller hasn't captured one yet — MUST run before the
56        // session_id_hint read just below. See
57        // `hydrate_session_id_from_config` for the full rationale
58        // (picker reattach: a fresh controller's inner.session_id is
59        // None, but config.session_id carries the persisted id).
60        // Reordering this after the read (reagent P0 on #2359, an
61        // earlier revision of this same PR) silently dropped --resume
62        // on every reattach's first turn — hydration ran too late for
63        // the read below to see it.
64        self.hydrate_session_id_from_config(config.session_id.as_deref());
65
66        // Read the (now-hydrated) session id once, up front — used
67        // both by the lease claim below and by the resume-flag args
68        // built after it.
69        let session_id_hint = self.inner.lock().unwrap().session_id.clone();
70
71        // Build continuation argv before claiming the lease or acknowledging
72        // the message. A malformed provider configuration must fail as an
73        // unstarted turn, not strand the pane in an accepted/running state.
74        let prompt_in_argv = !config.prompt_arg_flag.is_empty();
75        let args = match build_turn_argv(
76            &config.cli_args,
77            &config.resume_strategy,
78            &config.resume_flag,
79            session_id_hint.as_deref(),
80        )
81        .and_then(|mut args| {
82            if prompt_in_argv {
83                append_prompt_arg(&mut args, &config.prompt_arg_flag, &config.message)?;
84            }
85            Ok(args)
86        }) {
87            Ok(args) => args,
88            Err(error) => {
89                self.unlock_run();
90                return Err(error);
91            }
92        };
93
94        // Claim the cross-process session-ownership lease BEFORE
95        // `emit_message_accepted` / flipping status to running — a
96        // refusal here must look like the turn never started at all,
97        // not "frontend was told this message was accepted, then it
98        // silently never ran" (reagent P1 on #2359: emitting accepted
99        // before the claim meant a HeldByOther refusal left the
100        // frontend believing the message was in flight). Empty
101        // `instance_id` (container-mode branch in this PR) or no
102        // `lease_store` (registry unavailable) both mean leasing is a
103        // no-op for this spawn. See `registry::LeaseStore` and
104        // `docs/retro/RETRO_DEV_BUILD_SHARED_AGENT_SESSION_COLLISION_2026_07_29.md`.
105        let claimed_lease: Option<crate::registry::Lease> = if config.instance_id.is_empty() {
106            None
107        } else {
108            match &self.lease_store {
109                None => None,
110                Some(store) => match store.claim(
111                    &config.instance_id,
112                    &self.boot_id,
113                    &self.block_id,
114                    session_id_hint.as_deref(),
115                ) {
116                    Ok(lease) => Some(lease),
117                    Err(crate::registry::LeaseError::HeldByOther { owner_boot_id, age_ms, .. }) => {
118                        self.unlock_run();
119                        return Err(format!(
120                            "session '{}' is already owned by another AgentMux process \
121                             (boot {owner_boot_id}, renewed {age_ms}ms ago) — refusing to \
122                             start a turn against the same session from two processes at once",
123                            config.instance_id
124                        ));
125                    }
126                    Err(crate::registry::LeaseError::Io(e)) => {
127                        tracing::warn!(
128                            block_id = %self.block_id,
129                            instance_id = %config.instance_id,
130                            error = %e,
131                            "lease claim failed (io) — proceeding without a lease for this turn"
132                        );
133                        None
134                    }
135                },
136            }
137        };
138
139        // Direct-spawn path (queue was empty): emit the accepted event
140        // now so the frontend can promote its pending entry. The
141        // drain-from-queue path (in process_waiter) emits the same
142        // event just before calling spawn_turn recursively. Only
143        // reached once the lease claim above has succeeded (or was a
144        // no-op) — see that block's comment.
145        self.emit_message_accepted(&config);
146
147        // Update status to running
148        {
149            let mut inner = self.inner.lock().unwrap();
150            Self::set_status(&mut inner, STATUS_RUNNING);
151        }
152        // Mark the turn active BEFORE publishing, so this "running" status
153        // carries turn_active=true: it is the start edge the notification
154        // Router pairs with the process_waiter's turn_active=false to detect
155        // "finished" (subprocess agents otherwise never showed a true→false).
156        self.health_monitor.set_active_turn(true);
157        self.publish_status();
158
159        // Build command — on Windows, .cmd batch wrappers can't be reliably spawned
160        // via cmd.exe /C with piped stdio. Resolve to node <script> instead.
161        let mut cmd = crate::server::cli_handlers::make_cli_cmd(&config.cli_command);
162        cmd.args(&args);
163
164        // On Windows: suppress console-window allocation. Without CREATE_NO_WINDOW,
165        // node.exe spawned from a windowless sidecar may try to create/attach to a
166        // console, causing stdout to go to that console rather than the pipe.
167        #[cfg(windows)]
168        {
169            cmd.no_window();
170        }
171        core::apply_working_dir(&mut cmd, &self.block_id, &config.working_dir, &config.env_vars);
172        // Identity M4a: record what this process is actually given.
173        crate::backend::identity_spawn::record_process_spawn(
174            &self.block_id,
175            crate::backend::identity_spawn::SpawnPath::Subprocess,
176            &config.env_vars,
177        );
178        cmd.stdin(std::process::Stdio::piped());
179        cmd.stdout(std::process::Stdio::piped());
180        cmd.stderr(std::process::Stdio::piped());
181
182        // Spawn
183        // In the block's tracker before it runs (`spawn_tracked`), so every
184        // descendant it spawns (bg bash, dev servers, watchers) is caught.
185        let spawned = crate::backend::process_tracker::registry::spawn_tracked(&self.block_id, &mut cmd, crate::backend::process_tracker::registry::Join::Agent);
186        if spawned.is_err() {
187            // The turn was marked active (and published) above; a spawn that
188            // never started must not leave it "active" forever — roll back and
189            // publish the terminal state (Codex P2 on #3705). Done before the
190            // map_err below takes the inner lock that publish_status needs.
191            self.health_monitor.set_active_turn(false);
192        }
193        let child = spawned.map_err(|e| {
194            let mut inner = self.inner.lock().unwrap();
195            Self::set_status(&mut inner, STATUS_DONE);
196            inner.proc_exit_code = -1;
197            if let (Some(store), Some(lease)) = (&self.lease_store, &claimed_lease) {
198                if let Err(release_err) = store.release(lease) {
199                    tracing::warn!(
200                        block_id = %self.block_id,
201                        error = %release_err,
202                        "lease release failed after spawn failure (self-heals via TTL expiry)"
203                    );
204                }
205            }
206            self.unlock_run();
207            format!("failed to spawn subprocess: {e}")
208        });
209        if child.is_err() {
210            // Inner lock released by now: publish DONE with turn_active=false.
211            self.publish_status();
212        }
213        let mut child = child?;
214
215        let pid = child.id().unwrap_or(0);
216        tracing::info!(
217            block_id = %self.block_id,
218            pid = pid,
219            cmd = %config.cli_command,
220            args = ?args_for_log(&args, prompt_in_argv),
221            "subprocess spawned"
222        );
223
224        // Store PID
225        let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<bool>();
226        {
227            let mut inner = self.inner.lock().unwrap();
228            inner.current_pid = Some(pid);
229            inner.kill_tx = Some(kill_tx);
230        }
231
232        // Take ownership of stdin/stdout (piped via Stdio::piped() in spawn config).
233        let stdin = child.stdin.take()
234            .ok_or_else(|| format!("[subprocess] stdin not captured for block {}", self.block_id))?;
235        let stdout = child.stdout.take()
236            .ok_or_else(|| format!("[subprocess] stdout not captured for block {}", self.block_id))?;
237        let stderr = child.stderr.take()
238            .ok_or_else(|| format!("[subprocess] stderr not captured for block {}", self.block_id))?;
239
240        // Write user message to stdin, then close it.
241        // CRITICAL: This must complete BEFORE the child's stdin timeout
242        // (Claude CLI: 3s). Using std::thread + synchronous write to
243        // bypass the Tokio task scheduler — a tokio::spawn'd task may
244        // not run for seconds on a busy runtime, causing the child to
245        // time out with "no stdin data received in 3s".
246        let message = config.message;
247        let block_id_stdin = self.block_id.clone();
248        let user_record = self.user_record_sink();
249        {
250            // Convert Tokio's async ChildStdin to a raw OS handle, then
251            // wrap in a std::fs::File for synchronous write. The pipe
252            // buffer (4-64KB on Windows) easily fits our message, so
253            // write_all returns instantly without blocking.
254            #[cfg(unix)]
255            let raw_handle = {
256                use std::os::unix::io::{AsRawFd, FromRawFd};
257                let fd = stdin.as_raw_fd();
258                unsafe { std::fs::File::from_raw_fd(fd) }
259            };
260            #[cfg(windows)]
261            let raw_handle = {
262                use std::os::windows::io::{AsRawHandle, FromRawHandle};
263                let handle = stdin.as_raw_handle();
264                unsafe { std::fs::File::from_raw_handle(handle) }
265            };
266
267            // Spawn a real OS thread (not a Tokio task) for the write.
268            // This ensures it runs immediately regardless of runtime load.
269            // The raw handle is valid as long as `stdin` lives — we move
270            // `stdin` into the thread via a guard to keep it alive.
271            std::thread::spawn(move || {
272                use std::io::Write;
273                let _keep_alive = stdin; // prevent Tokio ChildStdin drop
274                let mut pipe = raw_handle;
275                if prompt_in_argv {
276                    // The prompt went on the command line; stdin just closes.
277                    std::mem::forget(pipe);
278                    user_record.write(&message);
279                    return;
280                }
281                let payload = format!("{}\n", message);
282                if let Err(e) = pipe.write_all(payload.as_bytes()) {
283                    tracing::warn!(block_id = %block_id_stdin, "subprocess stdin write error: {}", e);
284                    std::mem::forget(pipe); // don't close the handle — _keep_alive owns it
285                    return;
286                }
287                if let Err(e) = pipe.flush() {
288                    tracing::warn!(block_id = %block_id_stdin, "subprocess stdin flush error: {}", e);
289                    std::mem::forget(pipe);
290                    return;
291                }
292                std::mem::forget(pipe); // don't double-close — _keep_alive owns the handle
293                // Delivered: now the transcript may say so (Codex review).
294                user_record.write(&message);
295                // _keep_alive (Tokio ChildStdin) drops here → EOF to the subprocess
296            });
297        }
298
299        // Spawn stdout_reader task
300        let block_id_read = self.block_id.clone();
301        let broker_read = self.broker.clone();
302        let inner_read = Arc::clone(&self.inner);
303        let mstore_read = self.mstore.clone();
304        let event_bus_read = self.event_bus.clone();
305        let filestore_read = self.filestore.clone();
306        let session_id_field = config.session_id_field.clone();
307        // Resolve the agent's GLOBAL transcript zone once (see persistent/spawn.rs).
308        let global_output_zone =
309            shell::resolve_global_output_zone(&self.mstore, &self.block_id);
310        // Retain the terminal `result` frame so a failure reported on STDOUT
311        // (auth / rate-limit / usage — the common case; claude may even exit 0)
312        // can be classified, not just stderr-reported ones. Shared with the
313        // process_waiter below.
314        let last_result_frame: Arc<Mutex<Option<serde_json::Value>>> = Arc::new(Mutex::new(None));
315        let last_result_frame_read = Arc::clone(&last_result_frame);
316        // Track in-band API errors delivered as `assistant` frames (e.g. a
317        // 401 auth failure that claude wraps in a synthetic assistant message
318        // with `"error":"authentication_failed"` and exit 0 — bypasses the
319        // `is_error` flag on the `result` frame entirely).
320        let last_inband_error: Arc<Mutex<Option<serde_json::Value>>> = Arc::new(Mutex::new(None));
321        let last_inband_error_read = Arc::clone(&last_inband_error);
322        let stdout_reader_handle = tokio::spawn(async move {
323            let reader = BufReader::new(stdout);
324            let mut lines = reader.lines();
325            let mut stats = session_stats::SessionStatsAccumulator::new(block_id_read.clone());
326
327            tracing::info!(block_id = %block_id_read, "stdout_reader started");
328
329            loop {
330                match lines.next_line().await {
331                    Err(e) => {
332                        tracing::warn!(block_id = %block_id_read, error = %e, "subprocess stdout read error");
333                        break;
334                    }
335                    Ok(None) => {
336                        tracing::info!(block_id = %block_id_read, "subprocess stdout EOF");
337                        break;
338                    }
339                    Ok(Some(line)) => {
340                        let trimmed = line.trim();
341                        if trimmed.is_empty() {
342                            continue;
343                        }
344
345                        // Track session metadata (debounced 1 s).
346                        // Use `line.len()` (not `trimmed.len()`) to match persistent/stdout_reader.rs
347                        // so token_estimate stays consistent across controller types.
348                        stats.record_line(line.len(), &mstore_read);
349
350                        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
351                            // Try to capture session/thread ID from the provider's init event.
352                            // Claude: {"type":"system","subtype":"init","session_id":"..."}
353                            // Gemini: {"type":"init","session_id":"..."}
354                            // Codex:  {"type":"thread.started","thread_id":"..."}
355                            // Read before the frame may move into a slot below.
356                            let sid = parsed
357                                .get(&session_id_field)
358                                .and_then(|v| v.as_str())
359                                .map(str::to_string);
360
361                            // Retain the terminal `result` frame for failure
362                            // classification.
363                            if parsed.get("type").and_then(|v| v.as_str()) == Some("result") {
364                                *last_result_frame_read.lock().unwrap() = Some(parsed);
365                            } else if parsed.get("type").and_then(|v| v.as_str()) == Some("assistant")
366                                && (parsed.get("isApiErrorMessage").and_then(|v| v.as_bool()).unwrap_or(false)
367                                    || parsed.get("error").is_some())
368                            {
369                                // In-band API error: 401 / auth failures arrive as a synthetic
370                                // assistant message (exit 0, is_error:false on result frame) —
371                                // capture it so the process_waiter can trip the failure gate.
372                                *last_inband_error_read.lock().unwrap() = Some(parsed);
373                            }
374
375                            if let Some(sid_string) = sid {
376                                // Authoritative CLI capture —
377                                // overwrites any prior value
378                                // (including stale hydrated ids
379                                // from picker reattach). De-dups
380                                // when the same id repeats across
381                                // turns. See
382                                // `record_captured_session_id_inner`
383                                // for the unit-tested form.
384                                let changed = SubprocessController::record_captured_session_id_inner(
385                                    &inner_read,
386                                    &sid_string,
387                                );
388                                if changed {
389                                    tracing::info!(
390                                        block_id = %block_id_read,
391                                        field = %session_id_field,
392                                        session_id = %sid_string,
393                                        "captured session id"
394                                    );
395                                    core::persist_session_id(&block_id_read, &sid_string, &mstore_read, &event_bus_read);
396                                }
397                            }
398                        }
399
400                        // Publish the NDJSON line as a MPS blockfile event on the "output" subject
401                        // and write-through to FileStore for persistent history (Phase 1.3).
402                        if let Some(ref broker) = broker_read {
403                            // debug, not info: fires on every NDJSON line, and
404                            // logs the FULL line content (not just length like
405                            // persistent/stdout_reader.rs's sibling) — a real contributor
406                            // (~6%) to an unrotated 406 MB launcher-log mirror
407                            // on a real machine (SPEC_WIN10_PAGEFILE_OOM_CRASH_
408                            // 2026_06_29 P1). muxlog.mjs already treats this
409                            // exact line as noise-to-drop-by-default when
410                            // tailing, independent corroboration. Default
411                            // production filter is info, so this is now
412                            // suppressed unless RUST_LOG=debug is set.
413                            tracing::debug!(block_id = %block_id_read, line = %trimmed, "subprocess stdout → blockfile");
414                            shell::append_output_line(
415                                broker,
416                                &block_id_read,
417                                trimmed,
418                                filestore_read.as_ref(),
419                                global_output_zone.as_deref(),
420                            );
421                        }
422                    }
423                }
424            }
425
426            tracing::info!(block_id = %block_id_read, "stdout_reader exiting");
427        });
428
429        // Capture a bounded tail of stderr so a non-zero exit can be classified
430        // into a real cause (SPEC_AGENT_FAILURE_DIAGNOSTICS Phase 2) instead of a
431        // bare "exit N". Shared with the process_waiter below.
432        let stderr_tail: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
433        let stderr_tail_reader = Arc::clone(&stderr_tail);
434        // Spawn stderr reader (logs warnings + retains a tail for classification)
435        let block_id_err = self.block_id.clone();
436        let stderr_reader_handle = tokio::spawn(async move {
437            let reader = BufReader::new(stderr);
438            let mut lines = reader.lines();
439            loop {
440                match lines.next_line().await {
441                    Err(e) => {
442                        tracing::warn!(block_id = %block_id_err, error = %e, "subprocess stderr read error");
443                        break;
444                    }
445                    Ok(None) => break,
446                    Ok(Some(line)) => {
447                        if !line.trim().is_empty() {
448                            tracing::info!(
449                                block_id = %block_id_err,
450                                stderr = %line,
451                                "subprocess stderr"
452                            );
453                            // Retain the last ~40 non-empty lines for classification.
454                            let mut buf = stderr_tail_reader.lock().unwrap();
455                            buf.push(line);
456                            let overflow = buf.len().saturating_sub(40);
457                            if overflow > 0 {
458                                buf.drain(0..overflow);
459                            }
460                        }
461                    }
462                }
463            }
464        });
465
466        // Renew the lease (if any) on its own interval, for as long as
467        // THIS turn is active. Only host-mode claims a lease in this PR
468        // (see module + struct doc comments), so this is a dedicated task
469        // scoped to this controller type rather than shared plumbing.
470        //
471        // Exit condition is a fresh per-turn flag (`turn_done`), NOT
472        // `health_monitor.is_active_turn()` — that flag is shared by
473        // the whole controller, not scoped to this specific spawn.
474        // Queued messages drain synchronously in process_waiter: the
475        // next turn's `set_active_turn(true)` can land in the same
476        // tick this turn's process_waiter releases its lease, with no
477        // async gap. A stale renewal task watching the shared flag
478        // would see it flip back to `true` before its next 5s tick and
479        // loop forever, leaking one never-terminating renewal task
480        // (blocking flock + file write every tick) per turn in a busy,
481        // back-to-back conversation — reagent P1 on #2359.
482        let turn_done = Arc::new(AtomicBool::new(false));
483        if let (Some(store), Some(lease)) = (self.lease_store.clone(), claimed_lease.clone()) {
484            let turn_done_renew = Arc::clone(&turn_done);
485            let block_id_renew = self.block_id.clone();
486            tokio::spawn(async move {
487                let mut interval =
488                    tokio::time::interval(std::time::Duration::from_millis(
489                        crate::registry::RENEW_INTERVAL_MS,
490                    ));
491                loop {
492                    interval.tick().await;
493                    if turn_done_renew.load(Ordering::SeqCst) {
494                        break;
495                    }
496                    // Blocking OS file lock + fsync'd write: off the async
497                    // worker (see `backend::blocking`).
498                    let (s, l) = (Arc::clone(&store), lease.clone());
499                    let renewed = tokio::task::spawn_blocking(move || s.renew(&l).map_err(|e| e.to_string()))
500                        .await
501                        .unwrap_or_else(|e| Err(format!("renew task failed: {e}")));
502                    if let Err(e) = renewed {
503                        // Lost the lease to a TTL reclaim mid-turn — log and
504                        // let the turn finish naturally rather than killing
505                        // it. Force-killing on lost renewal is deliberately
506                        // deferred (see the analysis doc's follow-ups); this
507                        // is a real residual gap, not an oversight.
508                        tracing::warn!(
509                            block_id = %block_id_renew,
510                            instance_id = %lease.instance_id(),
511                            error = %e,
512                            "lease renew failed — lease may have been reclaimed by another process"
513                        );
514                        break;
515                    }
516                }
517            });
518        }
519
520        // Spawn process_waiter task
521        let inner_wait = Arc::clone(&self.inner);
522        let block_id_wait = self.block_id.clone();
523        let broker_wait = self.broker.clone();
524        let run_lock = Arc::clone(&self.run_lock);
525        let health_wait = Arc::clone(&self.health_monitor);
526        let turn_done_wait = Arc::clone(&turn_done);
527        let self_ref_wait = self.self_ref.lock().unwrap().clone().unwrap_or_default();
528        let stderr_tail_wait = Arc::clone(&stderr_tail);
529        let last_result_frame_wait = Arc::clone(&last_result_frame);
530        let last_inband_error_wait = Arc::clone(&last_inband_error);
531        let mstore_wait = self.mstore.clone();
532        let event_bus_wait = self.event_bus.clone();
533        let lease_store_wait = self.lease_store.clone();
534        let claimed_lease_wait = claimed_lease;
535        // For the queued-message-drain failure frame below — must be
536        // PERSISTED, not just live-broadcast (codex P1 on PR #2390: the
537        // call below previously passed None despite its own comment
538        // claiming parity with input.rs's persisted identity/container
539        // frames, so muxspect's last_error_frame could never see it after
540        // the live moment passed).
541        let filestore_wait = self.filestore.clone();
542        tokio::spawn(async move {
543            // Classified failure cause, surfaced to the pane after the readers drain.
544            let mut run_failure: Option<crate::agents::failure::AgentFailure> = None;
545            // Set on a clean (non-killed) exit so classification runs AFTER the
546            // stdout/stderr readers are joined — otherwise the final error line can
547            // race the buffer read and be lost (reagent P1).
548            let mut clean_exit: Option<(i32, Option<i32>)> = None;
549            // Wait for either process exit or kill signal
550            tokio::select! {
551                exit_result = child.wait() => {
552                    let (exit_code, exit_signal) = match exit_result {
553                        Ok(status) => {
554                            let code = status.code().unwrap_or(-1);
555                            #[cfg(unix)]
556                            let sig = std::os::unix::process::ExitStatusExt::signal(&status);
557                            #[cfg(not(unix))]
558                            let sig: Option<i32> = None;
559                            (code, sig)
560                        }
561                        Err(e) => {
562                            tracing::warn!(
563                                block_id = %block_id_wait,
564                                error = %e,
565                                "subprocess wait error"
566                            );
567                            (-1, None)
568                        }
569                    };
570
571                    tracing::info!(
572                        block_id = %block_id_wait,
573                        exit_code = exit_code,
574                        "subprocess exited"
575                    );
576
577                    // Update inner state
578                    {
579                        let mut inner = inner_wait.lock().unwrap();
580                        inner.proc_exit_code = exit_code;
581                        SubprocessController::set_status(&mut inner, STATUS_DONE);
582                        inner.current_pid = None;
583                        inner.kill_tx = None;
584                    }
585
586                    // Defer classification until after the readers are joined
587                    // (below); a user-initiated stop (kill arm) stays unclassified.
588                    clean_exit = Some((exit_code, exit_signal));
589                }
590                force = kill_rx => {
591                    let force = force.unwrap_or(false);
592                    tracing::info!(
593                        block_id = %block_id_wait,
594                        force = force,
595                        "subprocess kill requested"
596                    );
597
598                    if force {
599                        let _ = child.kill().await;
600                    } else {
601                        // On Unix, send SIGTERM. On Windows, kill() is the only option.
602                        #[cfg(unix)]
603                        {
604                            if let Some(pid) = child.id() {
605                                unsafe { libc::kill(pid as i32, libc::SIGTERM); }
606                            }
607                            // Give it a moment to exit gracefully
608                            tokio::time::sleep(tokio::time::Duration::from_millis(
609                                DEFAULT_GRACEFUL_KILL_WAIT_MS,
610                            )).await;
611                            let _ = child.kill().await;
612                        }
613                        #[cfg(not(unix))]
614                        {
615                            let _ = child.kill().await;
616                        }
617                    }
618
619                    let _ = child.wait().await;
620
621                    {
622                        let mut inner = inner_wait.lock().unwrap();
623                        inner.proc_exit_code = -1;
624                        SubprocessController::set_status(&mut inner, STATUS_DONE);
625                        inner.current_pid = None;
626                        inner.kill_tx = None;
627                    }
628                }
629            }
630
631            // Classify a genuine non-zero exit OR a failure reported on stdout as
632            // an error `result` frame (auth / rate-limit / usage — claude may even
633            // exit 0). Join the stdout + stderr readers first (bounded) so their
634            // final lines — the ones carrying the error text — are in the buffers
635            // before we read them (reagent P1).
636            if let Some((exit_code, exit_signal)) = clean_exit {
637                let drain = std::time::Duration::from_secs(2);
638                let _ = tokio::time::timeout(drain, stdout_reader_handle).await;
639                let _ = tokio::time::timeout(drain, stderr_reader_handle).await;
640                let result_frame = last_result_frame_wait.lock().unwrap().clone();
641                let frame_is_error = result_frame
642                    .as_ref()
643                    .and_then(|f| f.get("is_error"))
644                    .and_then(|v| v.as_bool())
645                    .unwrap_or(false);
646                // Also catch in-band API errors (e.g. auth 401) delivered as
647                // synthetic assistant messages with exit 0 and is_error:false.
648                let inband_error_frame = last_inband_error_wait.lock().unwrap().clone();
649                let inband_is_api_error = inband_error_frame.is_some();
650                if exit_code != 0 || frame_is_error || inband_is_api_error {
651                    let tail = stderr_tail_wait.lock().unwrap().join("\n");
652                    // Merge the in-band error text so classify() sees the
653                    // "authentication_failed" / "401" string even though it
654                    // arrived on stdout, not stderr.
655                    let inband_text = inband_error_frame.as_ref().map(|f| {
656                        let err_str = f.get("error").and_then(|v| v.as_str()).unwrap_or("");
657                        let content_text = f.pointer("/message/content/0/text")
658                            .and_then(|v| v.as_str())
659                            .unwrap_or("");
660                        format!("{err_str} {content_text}")
661                    }).unwrap_or_default();
662                    let combined_tail = if inband_text.trim().is_empty() {
663                        tail
664                    } else {
665                        format!("{tail}\n{inband_text}")
666                    };
667                    run_failure = Some(crate::agents::failure::classify(
668                        Some(exit_code),
669                        exit_signal,
670                        &combined_tail,
671                        result_frame.as_ref(),
672                    ));
673                }
674            }
675
676            // Update health monitor with exit status
677            {
678                let inner = inner_wait.lock().unwrap();
679                health_wait.set_exited(inner.proc_exit_code);
680            }
681
682            // Publish done status
683            if let Some(ref broker) = broker_wait {
684                let status = {
685                    let inner = inner_wait.lock().unwrap();
686                    SubprocessController::build_status_snapshot(&inner, &block_id_wait, false)
687                };
688                publish_controller_status(broker, &status);
689            }
690
691            // Persist or clear agent:last_failure in block meta so the recovery
692            // banner survives tab switches and page reloads (P1.1 of
693            // SPEC_AGENT_ERROR_FRAMEWORK_2026_06_20). Done before the MPS
694            // publish so the durable state is written first; the event is then
695            // a low-latency push to any active subscriber.
696            core::persist_last_failure(
697                &block_id_wait,
698                run_failure.as_ref(),
699                &mstore_wait,
700                &event_bus_wait,
701            );
702
703            // Surface the classified failure cause to the pane (Phase 2 of
704            // SPEC_AGENT_FAILURE_DIAGNOSTICS). persist:1 so reconnecting
705            // subscribers also receive the last failure without needing a
706            // separate meta read (belt-and-suspenders with the meta write above).
707            if let (Some(failure), Some(broker)) = (run_failure.as_ref(), broker_wait.as_ref()) {
708                broker.publish_event::<crate::backend::mps_events::AgentFailed>(failure, vec![format!("block:{}", block_id_wait)], 1);
709            }
710
711            // Signal the renewal task to stop BEFORE releasing the
712            // lease/run_lock — a queued next turn (dequeued a few
713            // lines below) may spawn immediately and claim its own
714            // lease; this turn's renewal task must not still be
715            // ticking when that happens (reagent P1 on #2359).
716            turn_done_wait.store(true, Ordering::SeqCst);
717
718            // Release the lease (if any), then the run lock — mirrors
719            // the ordering in the spawn-failure closure above.
720            if let (Some(store), Some(lease)) = (&lease_store_wait, &claimed_lease_wait) {
721                // Synchronous: the release must land before run_lock drops
722                // below and a queued turn claims its own lease.
723                if let Err(e) = crate::backend::blocking::off_async_worker(|| store.release(lease)) {
724                    tracing::warn!(
725                        block_id = %block_id_wait,
726                        instance_id = %lease.instance_id(),
727                        error = %e,
728                        "lease release failed (self-heals via TTL expiry)"
729                    );
730                }
731            }
732            run_lock.store(false, Ordering::SeqCst);
733
734            // Drain message queue: if messages were queued while this turn
735            // was running, pop the next one and spawn it via the weak
736            // self-reference.
737            let next_config = {
738                let mut inner = inner_wait.lock().unwrap();
739                inner.pending_messages.pop_front()
740            };
741            if let Some(config) = next_config {
742                if let Some(ctrl) = self_ref_wait.upgrade() {
743                    tracing::info!(
744                        block_id = %block_id_wait,
745                        "draining queued message"
746                    );
747                    if let Err(e) = ctrl.spawn_turn(config) {
748                        tracing::warn!(
749                            block_id = %block_id_wait,
750                            error = %e,
751                            "failed to spawn queued turn"
752                        );
753                        // The message was already popped from the queue —
754                        // a bare warn-log here means it vanishes with no
755                        // trace the user can see (reagent P1 on #2359,
756                        // most likely to fire on a lease refusal: another
757                        // process claimed this session in the gap between
758                        // this turn's release and the drain). Surface it
759                        // the same way other pre-spawn failures in this
760                        // module do (e.g. input.rs's container
761                        // ensure_running failure): a visible error frame
762                        // in the block, not just a log line.
763                        let error_frame = serde_json::json!({
764                            "type": "result",
765                            "is_error": true,
766                            "subtype": "error_during_execution",
767                            "error": {"message": format!("[AgentMux] queued message could not be sent: {e}")}
768                        }).to_string();
769                        if let Some(ref broker) = broker_wait {
770                            // Into the agent's global zone too: that is what
771                            // the pane reads on reload (Phase 5a-3c).
772                            let global_zone =
773                                shell::resolve_global_output_zone(&mstore_wait, &block_id_wait);
774                            crate::backend::blockcontroller::shell::handle_append_block_file(
775                                broker,
776                                &block_id_wait,
777                                SUBPROCESS_OUTPUT_SUBJECT,
778                                format!("{error_frame}\n").as_bytes(),
779                                filestore_wait.as_ref(),
780                                global_zone.as_deref(),
781                            );
782                        }
783                    }
784                }
785            }
786        });
787
788        Ok(())
789    }
790}