Skip to main content

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}