agentmux_srv/backend/blockcontroller/subprocess/container_spawn.rs
1// Copyright 2025, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Container-exec turn spawn (`spawn_container_turn`) for [`SubprocessController`].
5//!
6//! Docker-exec analog of `host_spawn::spawn_turn`, used for container agents.
7//! Drives the exec via `bollard`'s Docker socket API instead of a host
8//! `std::process` child (P1a: no secrets in argv — env vars travel through
9//! `CreateExecOptions.env`, never process argv / `/proc/<pid>/cmdline`).
10//!
11//! `spawn_container_turn` is one continuous, non-trivially-ordered state
12//! machine and is moved here WHOLE rather than decomposed further.
13//! `publish_line` is its private output-line helper (used only here).
14
15use std::sync::atomic::Ordering;
16use std::sync::Arc;
17
18use futures_util::StreamExt as _;
19
20use crate::backend::blockcontroller::{
21 core, publish_controller_status, session_stats, shell, STATUS_DONE, STATUS_RUNNING,
22};
23
24use super::{argv::build_turn_argv, SubprocessController, SubprocessControllerInner, SubprocessSpawnConfig, SUBPROCESS_OUTPUT_SUBJECT};
25
26/// Wrap the CLI argv so it reads the turn prompt from `prompt_path`, a file
27/// already written into the container by
28/// [`ContainerManager::upload_turn_prompt`].
29///
30/// Redirecting from a file is the only transport that satisfies all three
31/// constraints at once (see `upload_turn_prompt`'s doc for the measurements):
32/// the CLI gets a real stdin that really reaches EOF, the message never touches
33/// argv or env — so `docker top` / host `ps` / `/proc/<pid>/cmdline` can't see a
34/// secret pasted into a chat message — and there is no `MAX_ARG_STRLEN` ceiling.
35///
36/// The CLI argv is passed through as `"$@"` rather than interpolated into the
37/// script, so there is no shell quoting to get wrong, and it is passed
38/// UNCHANGED — including a trailing `-` (codex) or a `-p ""` placeholder
39/// (gemini/qwen/kimi/antigravity), both of which mean "read the prompt from
40/// stdin" and are correct again now that stdin genuinely works (codex P1,
41/// PR #2883).
42///
43/// The prompt file is removed after the CLI exits, and the CLI's own exit
44/// status is preserved for `inspect_exec` to classify the turn.
45///
46/// The directory the provider CLI is installed into on first start is appended
47/// to `PATH`, after the image's own, so a CLI baked into an older image still
48/// wins over the installed one.
49fn container_turn_exec(prompt_path: &str, cmd: Vec<String>) -> Vec<String> {
50 let mut out = vec![
51 "sh".to_string(),
52 "-c".to_string(),
53 // $1 = prompt path, "$@" (after shift) = the CLI and its args.
54 format!(
55 r#"PATH="$PATH:{}"; export PATH; F="$1"; shift; "$@" < "$F"; rc=$?; rm -f "$F"; exit $rc"#,
56 crate::backend::container_cli::cli_bin_dir()
57 ),
58 // $0 for the script -- a label only.
59 "agentmux-turn".to_string(),
60 prompt_path.to_string(),
61 ];
62 out.extend(cmd);
63 out
64}
65
66#[cfg(test)]
67mod turn_exec_tests {
68 use super::container_turn_exec;
69
70 fn v(xs: &[&str]) -> Vec<String> {
71 xs.iter().map(|s| s.to_string()).collect()
72 }
73
74 /// The security property this shape exists for (reagent P1, PR #2883): the
75 /// message must never reach argv, where `docker top` / `ps` /
76 /// `/proc/<pid>/cmdline` would expose a pasted secret. It can't — the argv
77 /// is built from the prompt's PATH and never sees the message at all.
78 #[test]
79 fn the_argv_carries_only_a_path_never_the_message() {
80 let argv = container_turn_exec("/tmp/agentmux-turn-abc", v(&["claude", "-p"]));
81 assert!(argv.iter().any(|a| a == "/tmp/agentmux-turn-abc"), "the path is there");
82 assert!(
83 argv.iter().all(|a| !a.contains("ghp_") && !a.contains("secret")),
84 "nothing message-shaped is: {argv:?}",
85 );
86 }
87
88 /// The CLI must actually read the file — this redirect IS the fix.
89 #[test]
90 fn redirects_the_clis_stdin_from_the_prompt_file() {
91 let argv = container_turn_exec("/tmp/p", v(&["claude", "-p"]));
92 assert!(argv[2].contains(r#""$@" < "$F""#), "must redirect: {}", argv[2]);
93 }
94
95 /// The prompt file is transient state; leaving it behind would accumulate
96 /// one file per turn for the life of the container.
97 #[test]
98 fn removes_the_prompt_file_afterwards() {
99 let argv = container_turn_exec("/tmp/p", v(&["claude", "-p"]));
100 assert!(argv[2].contains(r#"rm -f "$F""#), "must clean up: {}", argv[2]);
101 }
102
103 /// `inspect_exec` classifies the turn from the exit code, so the CLI's own
104 /// status must survive the cleanup that runs after it.
105 #[test]
106 fn preserves_the_clis_exit_status_across_cleanup() {
107 let argv = container_turn_exec("/tmp/p", v(&["claude", "-p"]));
108 assert!(argv[2].contains("rc=$?"), "captures status before rm");
109 assert!(argv[2].contains("exit $rc"), "and re-raises it: {}", argv[2]);
110 }
111
112 /// The installed CLI is found after the image's own `PATH`, not before it.
113 #[test]
114 fn appends_the_install_dir_to_path_after_the_images_own() {
115 let argv = container_turn_exec("/tmp/p", v(&["claude", "-p"]));
116 assert!(
117 argv[2].starts_with(r#"PATH="$PATH:/home/agent/.agentmux/cli/node_modules/.bin"; export PATH;"#),
118 "{}",
119 argv[2]
120 );
121 }
122
123 /// Passed as "$@", so the CLI argv survives verbatim and in order.
124 #[test]
125 fn passes_the_cli_argv_through_unchanged() {
126 let argv = container_turn_exec("/tmp/p", v(&["claude", "-p", "--model", "opus"]));
127 assert_eq!(&argv[0], "sh");
128 assert_eq!(&argv[1], "-c");
129 // argv[3] is $0, argv[4] is the prompt path; the CLI argv follows.
130 assert_eq!(&argv[5..], &v(&["claude", "-p", "--model", "opus"])[..]);
131 }
132
133 /// codex's trailing `-` means "read the prompt from stdin" and must NOT be
134 /// stripped: stdin is a real, EOF-terminating file now. An earlier cut of
135 /// this fix removed it, which was only necessary while the message was
136 /// being smuggled through argv.
137 #[test]
138 fn keeps_codexs_trailing_stdin_positional() {
139 let argv = container_turn_exec("/tmp/p", v(&["codex", "exec", "--json", "-"]));
140 assert_eq!(argv.last().unwrap(), "-");
141 }
142
143 /// Same for the `-p ""` placeholder gemini/qwen/kimi/antigravity use to
144 /// mean "prompt comes from stdin" (codex P1, PR #2883).
145 #[test]
146 fn keeps_an_empty_prompt_placeholder() {
147 let argv = container_turn_exec("/tmp/p", v(&["gemini", "-p", "", "--json"]));
148 assert_eq!(&argv[5..], &v(&["gemini", "-p", "", "--json"])[..]);
149 }
150}
151
152impl SubprocessController {
153 /// Spawn a container agent turn via Docker socket (P1a: no secrets in argv).
154 ///
155 /// This is the secure alternative to `spawn_turn` for container agents. Instead
156 /// of running `docker exec -e KEY=VALUE ...` as a CLI subprocess (which exposes
157 /// secrets in process argv / `/proc/<pid>/cmdline`, CWE-214), this method calls
158 /// `ContainerManager::exec` directly, passing env vars through
159 /// `CreateExecOptions.env` (Docker socket). The exec I/O (file-backed prompt
160 /// + stdout NDJSON stream) drives the same state machine as `spawn_turn`:
161 /// • appends `--resume <sid>` if a prior session_id is known
162 /// • uploads the turn message into the container as a file and redirects
163 /// the CLI's stdin from it — see `container_turn_exec` for why the
164 /// exec's own stdin, argv, and env are all unusable for this
165 /// • reads NDJSON from the output stream, publishing MPS blockfile events
166 /// • captures session_id from the provider's init event
167 /// • transitions status running → done
168 /// • drains the pending-message queue when the exec exits
169 ///
170 /// `base_cmd` is `[cli_command] + cli_args` WITHOUT resume — this method appends
171 /// `--resume <sid>` internally before starting the exec.
172 /// The exec env is derived from THIS message's `config.env_vars` (denylist
173 /// applied here, per-turn) — not carried across queue drains — so a queued
174 /// message runs with its own freshly-resolved auth/env, matching `spawn_turn`.
175 ///
176 /// Takes `cm` and `container_name` by value (not reference) so the returned
177 /// future is `'static` — required for `tokio::spawn` in the queue-drain path.
178 pub fn spawn_container_turn(
179 &self,
180 cm: crate::backend::container::ContainerManager,
181 container_name: String,
182 base_cmd: Vec<String>,
183 config: SubprocessSpawnConfig,
184 ) -> Result<(), String> {
185 // The container path keeps the prompt out of argv on purpose (module
186 // doc: a pasted secret must not show in `ps`), so a provider that
187 // can only take its prompt as an argument doesn't run here.
188 if !config.prompt_arg_flag.is_empty() {
189 return Err(
190 "This agent's CLI takes its message on the command line, which the container runtime doesn't do. Run it on the host."
191 .to_string(),
192 );
193 }
194 if !self.try_lock_run() {
195 let mut inner = self.inner.lock().unwrap();
196 tracing::info!(
197 block_id = %self.block_id,
198 queue_depth = inner.pending_messages.len() + 1,
199 "container exec busy — message queued"
200 );
201 inner.pending_messages.push_back(config);
202 return Ok(());
203 }
204
205 self.hydrate_session_id_from_config(config.session_id.as_deref());
206
207 let session_id_hint = self.inner.lock().unwrap().session_id.clone();
208 let cmd = match build_turn_argv(
209 &base_cmd,
210 &config.resume_strategy,
211 &config.resume_flag,
212 session_id_hint.as_deref(),
213 ) {
214 Ok(cmd) => cmd,
215 Err(error) => {
216 self.unlock_run();
217 return Err(error);
218 }
219 };
220
221 self.emit_message_accepted(&config);
222
223 // Derive the exec env from THIS message's own env_vars (apply the
224 // container denylist here, per-turn) rather than carrying a pre-filtered
225 // list across drains — so a message queued behind a running turn uses its
226 // own freshly-resolved auth/env, not the prior turn's stale values.
227 // The instance key is swapped for this block's container token, which
228 // srv honours only on agent routes (backend::container_credential).
229 let container_env: Vec<(String, String)> =
230 crate::backend::container_credential::container_exec_env(&self.block_id, &config.env_vars);
231 // Identity M4a: record what this turn's `docker exec` is actually given.
232 crate::backend::identity_spawn::record_process_spawn(
233 &self.block_id,
234 crate::backend::identity_spawn::SpawnPath::Container,
235 &container_env.iter().cloned().collect(),
236 );
237
238 // Snapshot container params for the queue-drain path before base_cmd is consumed.
239 let cm_for_drain = cm.clone();
240 let container_name_for_drain = container_name.clone();
241 let base_cmd_for_drain = base_cmd.clone();
242
243 // Command name to pkill if the turn is interrupted (see the kill path in
244 // the reader select below). base_cmd[0] is the container-local CLI (e.g.
245 // `claude`); -f matches its full cmdline inside the container.
246 let kill_pattern = base_cmd.first().cloned().unwrap_or_else(|| "claude".to_string());
247
248 // Clone all self fields needed by the inner tokio::spawn so we don't
249 // borrow `self` across the async boundary (which would make the future
250 // non-'static and break tokio::spawn).
251 let inner_arc = Arc::clone(&self.inner);
252 let run_lock = Arc::clone(&self.run_lock);
253 let broker = self.broker.clone();
254 let event_bus = self.event_bus.clone();
255 let mstore = self.mstore.clone();
256 let filestore = self.filestore.clone();
257 let health_monitor = Arc::clone(&self.health_monitor);
258 let block_id = self.block_id.clone();
259 // The prompt is written into the container as a file per turn (see
260 // `container_turn_exec`). A fresh id per turn keeps two turns — this
261 // block's next one, or another block sharing the container — from
262 // racing on the same path, including the `rm -f` that cleans it up.
263 let turn_id = uuid::Uuid::new_v4().to_string();
264 let turn_message = config.message.clone();
265 let user_record = self.user_record_sink();
266 let self_ref_done = self.self_ref.lock().unwrap().clone().unwrap_or_default();
267
268 // Spawn all async work (exec + I/O) into a background task so this
269 // function returns synchronously. This is required so the queue-drain
270 // path inside the reader task can call `spawn_container_turn` without
271 // needing the returned future to be `'static`.
272 tokio::spawn(async move {
273 use bollard::container::LogOutput;
274
275 // Install the kill channel BEFORE any await, so a stop issued
276 // while this turn is still starting is recorded rather than
277 // dropped.
278 //
279 // `stop_subprocess` reports success if `inner.kill_tx` is None,
280 // treating "nothing to interrupt" as "already stopped". While
281 // kill_tx was installed only after the exec had started, every
282 // await before that point was a hole: a stop landing in it was
283 // silently discarded and the turn then ran on, flipping the status
284 // back to running (codex P2, PR #2883). That hole always existed
285 // for the exec round-trip, and this change widened it materially —
286 // the identity probe plus a prompt upload that can be hundreds of
287 // KB now sit inside it.
288 //
289 // A stop that arrives during startup is not lost: `kill_rx` holds
290 // the value, and the reader loop below selects on it `biased`, so
291 // it is observed on the first poll and interrupted immediately —
292 // including the prompt-file cleanup. The failure paths above/below
293 // clear `kill_tx` again, so nothing is left dangling.
294 let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<bool>();
295 {
296 let mut inner = inner_arc.lock().unwrap();
297 inner.kill_tx = Some(kill_tx);
298 Self::set_status(&mut inner, STATUS_RUNNING);
299 }
300 // turn_active=true on the start publish — the start edge the
301 // notification Router needs (same as host_spawn).
302 health_monitor.set_active_turn(true);
303 if let Some(ref b) = broker {
304 let status = {
305 let inner = inner_arc.lock().unwrap();
306 SubprocessController::build_status_snapshot(&inner, &block_id, true)
307 };
308 publish_controller_status(b, &status);
309 }
310
311 // Start the exec via Docker socket — env vars travel through
312 // CreateExecOptions.env (Docker API), never in process argv.
313 // Set once the upload succeeds; the interrupt path uses it to remove
314 // a prompt file whose wrapper was killed before it could.
315 let mut prompt_path_for_kill = String::new();
316 // Write the prompt into the container FIRST, then run the CLI with
317 // its stdin redirected from that file. See `container_turn_exec`
318 // and `ContainerManager::upload_turn_prompt` for why neither the
319 // exec's own stdin, nor argv, nor an env var can carry it.
320 let exec_result = match cm
321 .upload_turn_prompt(&container_name, &turn_id, &turn_message)
322 .await
323 {
324 Ok(prompt_path) => {
325 // Retained for the interrupt path below, which has to do
326 // the cleanup the killed wrapper can't.
327 prompt_path_for_kill = prompt_path.clone();
328 let wrapped = container_turn_exec(&prompt_path, cmd);
329 // attach_stdin: false — the CLI's stdin is the prompt file.
330 match cm.exec(&container_name, &wrapped, None, &container_env, false).await {
331 Ok(session) => {
332 // The exec started with the prompt as its stdin:
333 // now the transcript may say so (Codex review).
334 user_record.write(&turn_message);
335 Ok(session)
336 }
337 Err(e) => {
338 // The wrapper never ran, so neither did its
339 // `rm -f "$F"` — clean up here or the prompt is
340 // orphaned in the container (reagent P2, PR #2883).
341 cm.remove_turn_prompt(&container_name, &prompt_path).await;
342 Err(e)
343 }
344 }
345 }
346 Err(e) => Err(e),
347 };
348 let exec_session = match exec_result {
349 Ok(s) => s,
350 Err(e) => {
351 tracing::warn!(block_id = %block_id, error = %e, "container exec failed");
352 // Surface the error in the agent pane so the user sees what went wrong.
353 if let Some(ref b) = broker {
354 let error_frame = serde_json::json!({
355 "type": "result",
356 "is_error": true,
357 "subtype": "error_during_execution",
358 "error": {"message": format!("[AgentMux] container exec failed: {e}")}
359 }).to_string();
360 // Into the agent's global zone too, like every other
361 // record: that is what the pane reads on reload
362 // (Phase 5a-3c).
363 let global_zone = shell::resolve_global_output_zone(&mstore, &block_id);
364 shell::handle_append_block_file(
365 b, &block_id, SUBPROCESS_OUTPUT_SUBJECT,
366 format!("{error_frame}\n").as_bytes(),
367 filestore.as_ref(), global_zone.as_deref(),
368 );
369 }
370 // A failed exec must still run the SAME completion + queue
371 // drain as the normal-exit path below: publish a terminal
372 // status so the client sees the turn end (exit 1), mark the
373 // health monitor exited, release run_lock, AND drain any
374 // queued message — otherwise the run_lock is freed but
375 // pending_messages is never popped, stranding the queue.
376 {
377 let mut inner = inner_arc.lock().unwrap();
378 inner.proc_exit_code = 1;
379 Self::set_status(&mut inner, STATUS_DONE);
380 inner.current_pid = None;
381 inner.kill_tx = None;
382 }
383 // Clears active_turn as well as recording the exit code —
384 // which is why the earlier `set_active_turn(true)`, now
385 // hoisted above the upload, needs no explicit undo here.
386 health_monitor.set_exited(1);
387 if let Some(ref b) = broker {
388 let status = {
389 let inner = inner_arc.lock().unwrap();
390 SubprocessController::build_status_snapshot(&inner, &block_id, false)
391 };
392 publish_controller_status(b, &status);
393 }
394 run_lock.store(false, Ordering::SeqCst);
395 let next_config = {
396 let mut inner = inner_arc.lock().unwrap();
397 inner.pending_messages.pop_front()
398 };
399 if let Some(cfg) = next_config {
400 if let Some(ctrl) = self_ref_done.upgrade() {
401 tracing::info!(block_id = %block_id, "draining queued container message after exec failure");
402 if let Err(e) = ctrl.spawn_container_turn(
403 cm_for_drain,
404 container_name_for_drain,
405 base_cmd_for_drain,
406 cfg,
407 ) {
408 tracing::warn!(error = %e, "failed to spawn queued container turn");
409 Self::publish_queued_spawn_error(
410 &block_id,
411 &e,
412 &broker,
413 &filestore,
414 &mstore,
415 );
416 }
417 }
418 }
419 return;
420 }
421 };
422
423 // `input` is unused by design — the CLI reads its prompt from the
424 // uploaded file, and only that file's PATH travels in argv (see
425 // `container_turn_exec`). Dropping it immediately keeps no handle
426 // on a write half that can never be closed anyway.
427 let crate::backend::container::ExecSession { exec_id, input, output } = exec_session;
428 drop(input);
429
430 // Read stdout — accumulate bytes into lines.
431 let mut line_buf = String::new();
432 let mut stats = session_stats::SessionStatsAccumulator::new(block_id.clone());
433 let session_id_field = config.session_id_field.clone();
434 // Tracks an aborted output stream (`Some(Err(_))`). The exec may have
435 // exited cleanly with a non-zero code OR the attach stream itself
436 // failed mid-turn; either way the turn did not complete normally, so
437 // this forces a non-zero exit even if inspect_exec can't be reached.
438 let mut stream_errored = false;
439
440 // Capture a bounded tail of stderr so a non-zero exit can be classified
441 // into a real cause, mirroring host_spawn.rs's `stderr_tail`. No
442 // Arc<Mutex<>> needed here (unlike host_spawn.rs) — this whole turn
443 // runs in a single task, so a plain owned Vec captured by this async
444 // block is sufficient.
445 let mut stderr_tail: Vec<String> = Vec::new();
446 // Retain the terminal `result` frame and any in-band API error
447 // (e.g. a synthetic assistant message carrying a 401/auth failure
448 // with exit 0 / is_error:false) so a completion-time classifier has
449 // the same inputs host_spawn.rs's process_waiter does — this
450 // controller has no HealthMonitor-driven route to AgentFailure now
451 // that the silence watchdog is gone, so this is its only path.
452 let mut last_result_frame: Option<serde_json::Value> = None;
453 let mut last_inband_error: Option<serde_json::Value> = None;
454
455 // Resolve the agent's GLOBAL transcript zone once (see persistent/spawn.rs)
456 // so every container-exec `output` line is also mirrored to the
457 // cross-channel store. `None` for non-agent blocks.
458 let global_output_zone =
459 shell::resolve_global_output_zone(&mstore, &block_id);
460
461 tracing::info!(block_id = %block_id, "container exec output reader started");
462
463 let mut pinned = std::pin::pin!(output);
464 // Set when the turn is interrupted via stop_subprocess (Esc / agent.stop)
465 // — drives a non-zero exit so an interrupted turn isn't reported as Idle.
466 let mut killed = false;
467 loop {
468 tokio::select! {
469 // Prioritise the kill signal so Esc is responsive even under a
470 // steady output stream.
471 biased;
472 kill = &mut kill_rx => {
473 let force = kill.unwrap_or(false);
474 tracing::info!(block_id = %block_id, force, "container turn interrupt — pkill in container");
475 // Best-effort: actually terminate the in-container process.
476 // Even if this fails (e.g. no procps on an old image), we
477 // still break + finalize so AgentMux honours the stop.
478 if let Err(e) = cm.signal_exec_process(&container_name, &kill_pattern, force).await {
479 tracing::warn!(block_id = %block_id, error = %e, "container interrupt pkill failed");
480 }
481 // The wrapper carries the `rm -f`, and `pkill -f
482 // <cli>` matches the wrapper too -- its argv contains
483 // the CLI name as an argument -- so the shell dies
484 // alongside the CLI and its cleanup never runs. An
485 // interrupted turn would otherwise leave a mode-0600
486 // prompt, possibly holding a pasted secret, in a
487 // container that outlives the turn (codex P2,
488 // PR #2883). Clean up from out here, where nothing was
489 // killed.
490 cm.remove_turn_prompt(&container_name, &prompt_path_for_kill).await;
491 killed = true;
492 break;
493 }
494 item = pinned.next() => {
495 match item {
496 None => {
497 // Stream ended — flush any remaining partial line.
498 if !line_buf.trim().is_empty() {
499 Self::publish_line(&line_buf, &block_id, &session_id_field, &inner_arc, &mstore, &event_bus, &broker, &filestore, &mut stats, global_output_zone.as_deref(), &mut last_result_frame, &mut last_inband_error);
500 }
501 tracing::info!(block_id = %block_id, "container exec output EOF");
502 break;
503 }
504 Some(Err(e)) => {
505 tracing::warn!(block_id = %block_id, error = %e, "container exec output read error");
506 stream_errored = true;
507 break;
508 }
509 Some(Ok(log_output)) => {
510 let bytes = match log_output {
511 LogOutput::StdOut { message } => message,
512 LogOutput::StdErr { message } => {
513 // Log stderr but don't publish as blockfile output.
514 let s = String::from_utf8_lossy(&message);
515 for line in s.lines() {
516 if !line.trim().is_empty() {
517 tracing::info!(block_id = %block_id, stderr = %line, "container exec stderr");
518 // Retain the last ~40 non-empty lines for classification
519 // (mirrors host_spawn.rs's stderr_tail cap).
520 stderr_tail.push(line.to_string());
521 let overflow = stderr_tail.len().saturating_sub(40);
522 if overflow > 0 {
523 stderr_tail.drain(0..overflow);
524 }
525 }
526 }
527 continue;
528 }
529 _ => continue,
530 };
531 let chunk = String::from_utf8_lossy(&bytes);
532 for ch in chunk.chars() {
533 if ch == '\n' {
534 if !line_buf.trim().is_empty() {
535 Self::publish_line(&line_buf, &block_id, &session_id_field, &inner_arc, &mstore, &event_bus, &broker, &filestore, &mut stats, global_output_zone.as_deref(), &mut last_result_frame, &mut last_inband_error);
536 }
537 line_buf.clear();
538 } else {
539 line_buf.push(ch);
540 }
541 }
542 }
543 }
544 }
545 }
546 }
547
548 tracing::info!(block_id = %block_id, "container exec output reader exiting");
549
550 // Determine the real turn exit code. The output stream ending is NOT
551 // the process exit status (unlike the host path's `child.wait()`), so
552 // inspect the exec over the Docker socket. A mid-turn stream error, an
553 // unavailable code, or a failed inspect is treated as a failure so a
554 // crashed / non-zero in-container CLI is never misreported to the
555 // client and to the health monitor as a successful (Idle) turn.
556 let exit_code: i32 = if killed {
557 // Interrupted by stop_subprocess — report non-zero (matches the
558 // host spawn_turn kill path) so health treats it as not-Idle.
559 -1
560 } else if stream_errored {
561 1
562 } else {
563 match cm.inspect_exec(&exec_id).await {
564 Ok(Some(code)) => code as i32,
565 Ok(None) => {
566 tracing::warn!(block_id = %block_id, "inspect_exec returned no exit code; treating turn as failed");
567 1
568 }
569 Err(e) => {
570 tracing::warn!(block_id = %block_id, error = %e, "inspect_exec failed; treating turn as failed");
571 1
572 }
573 }
574 };
575
576 // Classify a genuine non-zero exit OR a failure reported in-band as
577 // an error `result` frame / synthetic assistant error (auth /
578 // rate-limit / usage — the container CLI may even exit 0), mirroring
579 // host_spawn.rs's own independent completion-time classifier. This
580 // controller has no HealthMonitor-driven route to AgentFailure now
581 // that the silence watchdog is gone — this is its only path to a
582 // persisted/published failure for container-backed agents.
583 let mut run_failure: Option<crate::agents::failure::AgentFailure> = None;
584 {
585 let frame_is_error = last_result_frame
586 .as_ref()
587 .and_then(|f| f.get("is_error"))
588 .and_then(|v| v.as_bool())
589 .unwrap_or(false);
590 let inband_is_api_error = last_inband_error.is_some();
591 if exit_code != 0 || frame_is_error || inband_is_api_error {
592 let tail = stderr_tail.join("\n");
593 // Merge the in-band error text so classify() sees the
594 // "authentication_failed" / "401" string even though it
595 // arrived on stdout, not stderr.
596 let inband_text = last_inband_error.as_ref().map(|f| {
597 let err_str = f.get("error").and_then(|v| v.as_str()).unwrap_or("");
598 let content_text = f.pointer("/message/content/0/text")
599 .and_then(|v| v.as_str())
600 .unwrap_or("");
601 format!("{err_str} {content_text}")
602 }).unwrap_or_default();
603 let combined_tail = if inband_text.trim().is_empty() {
604 tail
605 } else {
606 format!("{tail}\n{inband_text}")
607 };
608 run_failure = Some(crate::agents::failure::classify(
609 Some(exit_code),
610 None, // container exec has no OS process signal
611 &combined_tail,
612 last_result_frame.as_ref(),
613 ));
614 }
615 }
616
617 // Mark done
618 {
619 let mut inner = inner_arc.lock().unwrap();
620 inner.proc_exit_code = exit_code;
621 SubprocessController::set_status(&mut inner, STATUS_DONE);
622 inner.current_pid = None;
623 inner.kill_tx = None;
624 }
625
626 {
627 let inner = inner_arc.lock().unwrap();
628 health_monitor.set_exited(inner.proc_exit_code);
629 }
630
631 if let Some(ref b) = broker {
632 let status = {
633 let inner = inner_arc.lock().unwrap();
634 SubprocessController::build_status_snapshot(&inner, &block_id, false)
635 };
636 publish_controller_status(b, &status);
637 }
638
639 // Persist or clear agent:last_failure in block meta so the recovery
640 // banner survives tab switches and page reloads, and a previously
641 // persisted failure is cleared after a later successful turn — this
642 // call handles both cases regardless of whether run_failure is
643 // Some or None. Mirrors host_spawn.rs's process_waiter.
644 core::persist_last_failure(&block_id, run_failure.as_ref(), &mstore, &event_bus);
645
646 // Surface the classified failure cause to the pane. persist:1 so
647 // reconnecting subscribers also receive the last failure without a
648 // separate meta read (belt-and-suspenders with the meta write above).
649 if let (Some(failure), Some(ref b)) = (run_failure.as_ref(), broker.as_ref()) {
650 b.publish_event::<crate::backend::mps_events::AgentFailed>(failure, vec![format!("block:{}", block_id)], 1);
651 }
652
653 run_lock.store(false, std::sync::atomic::Ordering::SeqCst);
654
655 // Drain queued messages via spawn_container_turn so the container
656 // context (cm, container_name, base_cmd, container_env) is preserved.
657 // spawn_turn has no container awareness and would spawn an empty command
658 // on the host, silently losing the queued message.
659 let next_config = {
660 let mut inner = inner_arc.lock().unwrap();
661 inner.pending_messages.pop_front()
662 };
663 if let Some(cfg) = next_config {
664 if let Some(ctrl) = self_ref_done.upgrade() {
665 tracing::info!(block_id = %block_id, "draining queued container message via spawn_container_turn");
666 if let Err(e) = ctrl.spawn_container_turn(
667 cm_for_drain,
668 container_name_for_drain,
669 base_cmd_for_drain,
670 cfg,
671 ) {
672 tracing::warn!(error = %e, "failed to spawn queued container turn");
673 Self::publish_queued_spawn_error(
674 &block_id,
675 &e,
676 &broker,
677 &filestore,
678 &mstore,
679 );
680 }
681 }
682 }
683 });
684
685 Ok(())
686 }
687
688 /// A queued message has already been removed from `pending_messages` when
689 /// its next spawn is attempted. If provider argv validation rejects that
690 /// spawn, persist a transcript error so the message does not disappear
691 /// with only a server log. Mirrors the host queue-drain failure path.
692 fn publish_queued_spawn_error(
693 block_id: &str,
694 error: &str,
695 broker: &Option<Arc<crate::backend::mps::Broker>>,
696 filestore: &Option<Arc<crate::backend::storage::filestore::FileStore>>,
697 mstore: &Option<Arc<crate::backend::storage::store::Store>>,
698 ) {
699 let Some(broker) = broker else {
700 return;
701 };
702 let error_frame = serde_json::json!({
703 "type": "result",
704 "is_error": true,
705 "subtype": "error_during_execution",
706 "error": {"message": format!("[AgentMux] queued message could not be sent: {error}")}
707 }).to_string();
708 // Into the agent's global zone too: that is what the pane reads on
709 // reload (Phase 5a-3c).
710 let global_zone = shell::resolve_global_output_zone(mstore, block_id);
711 shell::handle_append_block_file(
712 broker,
713 block_id,
714 SUBPROCESS_OUTPUT_SUBJECT,
715 format!("{error_frame}\n").as_bytes(),
716 filestore.as_ref(),
717 global_zone.as_deref(),
718 );
719 }
720
721 /// Publish a single NDJSON line from container exec output: session-id
722 /// capture, MPS blockfile event, and FileStore write-through. Used by
723 /// `spawn_container_turn`'s output reader task.
724 fn publish_line(
725 line: &str,
726 block_id: &str,
727 session_id_field: &str,
728 inner: &std::sync::Mutex<SubprocessControllerInner>,
729 mstore: &Option<Arc<crate::backend::storage::store::Store>>,
730 event_bus: &Option<Arc<crate::backend::eventbus::EventBus>>,
731 broker: &Option<Arc<crate::backend::mps::Broker>>,
732 filestore: &Option<Arc<crate::backend::storage::filestore::FileStore>>,
733 stats: &mut session_stats::SessionStatsAccumulator,
734 global_output_zone: Option<&str>,
735 last_result_frame: &mut Option<serde_json::Value>,
736 last_inband_error: &mut Option<serde_json::Value>,
737 ) {
738 let trimmed = line.trim();
739 if trimmed.is_empty() {
740 return;
741 }
742 stats.record_line(trimmed.len(), mstore);
743
744 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
745 // Capture session_id from provider init event.
746 if let Some(sid) = parsed.get(session_id_field).and_then(|v| v.as_str()) {
747 let changed = SubprocessController::record_captured_session_id_inner(inner, sid);
748 if changed {
749 tracing::info!(block_id = %block_id, session_id = %sid, "container exec: captured session id");
750 core::persist_session_id(block_id, sid, &mstore, &event_bus);
751 }
752 }
753
754 // Retain the terminal `result` frame for completion-time failure
755 // classification (mirrors host_spawn.rs's stdout_reader).
756 if parsed.get("type").and_then(|v| v.as_str()) == Some("result") {
757 *last_result_frame = Some(parsed);
758 } else if parsed.get("type").and_then(|v| v.as_str()) == Some("assistant")
759 && (parsed.get("isApiErrorMessage").and_then(|v| v.as_bool()).unwrap_or(false)
760 || parsed.get("error").is_some())
761 {
762 // In-band API error: 401 / auth failures arrive as a synthetic
763 // assistant message (exit 0, is_error:false on result frame) —
764 // capture it so the completion-time classifier below can trip
765 // the failure gate.
766 *last_inband_error = Some(parsed);
767 }
768 }
769
770 if let Some(ref broker) = broker {
771 shell::append_output_line(broker, block_id, trimmed, filestore.as_ref(), global_output_zone);
772 }
773 }
774}