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}