agentmux_srv\backend\blockcontroller/
subprocess.rs

1// Copyright 2025, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! SubprocessController: manages agent CLI as stateless per-turn subprocess invocations.
5//!
6//! Architecture:
7//!   Each user message spawns a fresh `claude -p` process.
8//!   Multi-turn continuity uses `--resume <session-id>`.
9//!   The process reads one JSON message from stdin, runs the agentic loop,
10//!   streams NDJSON on stdout, then exits.
11//!
12//! State machine:
13//!   INIT ─(spawn)─> RUNNING ─(process exits)─> DONE
14//!   DONE ─(new message)─> RUNNING (re-spawn with --resume)
15//!
16//! I/O model (2 async tasks per turn):
17//! 1. stdout_reader: piped stdout → .jsonl persistence + WPS blockfile events on "output" subject
18//! 2. process_waiter: wait for exit, update status, publish lifecycle event
19
20
21use std::collections::{HashMap, VecDeque};
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::sync::{Arc, Mutex};
24
25use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
26use futures_util::StreamExt as _;
27
28
29use super::{
30    BlockControllerRuntimeStatus, BlockInputUnion, Controller, STATUS_DONE, STATUS_INIT,
31    STATUS_RUNNING,
32};
33use super::core;
34use super::health::{classify_output_line, HealthMonitor};
35use crate::backend::eventbus::EventBus;
36use crate::backend::storage::filestore::FileStore;
37use crate::backend::storage::store::Store;
38use crate::backend::wps;
39
40/// WPS file subject name for subprocess output (replaces "term" from PTY).
41pub const SUBPROCESS_OUTPUT_SUBJECT: &str = "output";
42
43pub const BLOCK_CONTROLLER_SUBPROCESS: &str = "subprocess";
44
45/// Configuration for spawning a subprocess turn.
46#[derive(Debug, Clone)]
47pub struct SubprocessSpawnConfig {
48    /// CLI executable (e.g., "claude").
49    pub cli_command: String,
50    /// CLI arguments (e.g., ["-p", "--output-format", "stream-json", ...]).
51    pub cli_args: Vec<String>,
52    /// Working directory for the subprocess.
53    pub working_dir: String,
54    /// Environment variables to set.
55    pub env_vars: HashMap<String, String>,
56    /// The user's JSON message to write to stdin.
57    pub message: String,
58    /// Flag used to resume a previous session, e.g. "--resume" (Claude), "-r" (Gemini).
59    /// Empty string means this provider does not support simple-flag resume.
60    pub resume_flag: String,
61    /// JSON field name in the CLI's init event that contains the session/thread ID.
62    /// e.g. "session_id" (Claude/Gemini) or "thread_id" (Codex).
63    pub session_id_field: String,
64    /// Optional client-supplied message id. Echoed back via the
65    /// `agent-message-accepted` event when this config transitions from
66    /// queued → running so the frontend can pair the event with a
67    /// pending `PendingMessage`. None means no feedback is emitted.
68    pub message_id: Option<String>,
69    /// Session id to hydrate `inner.session_id` with BEFORE the first
70    /// turn — used by the picker "My Agents" reattach path. The
71    /// caller reads this from `block.meta["agent:sessionid"]` which
72    /// the frontend pre-populates from the prior block's session id
73    /// when launching with `continueOfInstanceId`. Without this
74    /// hydration `spawn_turn` would only see the captured session id
75    /// AFTER the first turn — meaning the first turn always launches
76    /// the CLI fresh (no `--resume <sid>`) and starts a new
77    /// conversation that re-injects the startup context.
78    ///
79    /// Empty / `None` means "no prior session" (greenfield launch).
80    ///
81    /// Best-effort, not authoritative: if the hydrated id is stale,
82    /// the CLI's stdout-emitted session id always overwrites it at
83    /// capture time (see `spawn_turn`'s stdout-reader block). The
84    /// reattach turn passes the (possibly stale) hydrated id via
85    /// `--resume`; the CLI either accepts it or starts a new
86    /// session and emits its own id, which then becomes the in-
87    /// memory authority for every subsequent turn.
88    pub session_id: Option<String>,
89}
90
91/// Inner state protected by mutex.
92struct SubprocessControllerInner {
93    /// Current process status.
94    proc_status: String,
95    /// Process exit code from the most recent turn.
96    proc_exit_code: i32,
97    /// Status version counter (incremented on each change).
98    status_version: i32,
99    /// Session ID captured from the first `system/init` message.
100    session_id: Option<String>,
101    /// PID of the currently running subprocess (None if idle).
102    current_pid: Option<u32>,
103    /// Handle to kill the current subprocess.
104    kill_tx: Option<tokio::sync::oneshot::Sender<bool>>,
105    /// Messages queued while a turn is in progress.
106    /// Drained sequentially after the current turn exits.
107    pending_messages: VecDeque<SubprocessSpawnConfig>,
108}
109
110/// SubprocessController manages per-turn subprocess lifecycle for agent blocks.
111///
112/// Unlike `ShellController` which maintains a long-running PTY process,
113/// `SubprocessController` spawns a fresh process for each user turn.
114/// Multi-turn continuity comes from `--resume <session-id>`.
115pub struct SubprocessController {
116    #[allow(dead_code)]
117    tab_id: String,
118    block_id: String,
119    /// Prevents concurrent spawns.
120    run_lock: Arc<AtomicBool>,
121    /// Protected inner state.
122    inner: Arc<Mutex<SubprocessControllerInner>>,
123    /// WPS broker for publishing events (blockfile, controllerstatus).
124    broker: Option<Arc<wps::Broker>>,
125    /// Event bus for obj:update broadcasts.
126    event_bus: Option<Arc<EventBus>>,
127    /// Wave object store for block metadata persistence.
128    wstore: Option<Arc<Store>>,
129    /// FileStore for write-through persistence of output lines (Phase 1.3).
130    filestore: Option<Arc<FileStore>>,
131    /// Agent health monitor (output activity + error tracking).
132    health_monitor: Arc<HealthMonitor>,
133    /// Weak self-reference for queue drain. Set by `set_self_ref` after
134    /// the controller is wrapped in Arc.
135    self_ref: Mutex<Option<std::sync::Weak<Self>>>,
136}
137
138impl SubprocessController {
139    /// Create a new SubprocessController.
140    pub fn new(
141        tab_id: String,
142        block_id: String,
143        broker: Option<Arc<wps::Broker>>,
144        event_bus: Option<Arc<EventBus>>,
145        wstore: Option<Arc<Store>>,
146        filestore: Option<Arc<FileStore>>,
147    ) -> Self {
148        let health_monitor = Arc::new(HealthMonitor::new(
149            block_id.clone(),
150            broker.clone(),
151        ));
152        Self {
153            tab_id,
154            block_id,
155            run_lock: Arc::new(AtomicBool::new(false)),
156            inner: Arc::new(Mutex::new(SubprocessControllerInner {
157                proc_status: STATUS_INIT.to_string(),
158                proc_exit_code: 0,
159                status_version: 0,
160                session_id: None,
161                current_pid: None,
162                kill_tx: None,
163                pending_messages: VecDeque::new(),
164            })),
165            broker,
166            event_bus,
167            wstore,
168            filestore,
169            health_monitor,
170            self_ref: Mutex::new(None),
171        }
172    }
173
174    /// Store a weak self-reference so the process_waiter can drain queued
175    /// messages by calling spawn_turn after the current turn exits.
176    /// Must be called after wrapping in Arc.
177    pub fn set_self_ref(self: &Arc<Self>) {
178        *self.self_ref.lock().unwrap() = Some(Arc::downgrade(self));
179    }
180
181    /// Try to acquire the run lock. Returns false if a turn is already in progress.
182    fn try_lock_run(&self) -> bool {
183        self.run_lock
184            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
185            .is_ok()
186    }
187
188    /// Release the run lock.
189    fn unlock_run(&self) {
190        self.run_lock.store(false, Ordering::SeqCst);
191    }
192
193    /// Update process status and increment version (must hold inner lock).
194    fn set_status(inner: &mut SubprocessControllerInner, status: &str) {
195        inner.proc_status = status.to_string();
196        inner.status_version += 1;
197    }
198
199    /// Get the runtime status (snapshot).
200    fn get_status_snapshot(&self) -> BlockControllerRuntimeStatus {
201        let inner = self.inner.lock().unwrap();
202        BlockControllerRuntimeStatus {
203            blockid: self.block_id.clone(),
204            version: inner.status_version,
205            shellprocstatus: inner.proc_status.clone(),
206            shellprocconnname: "local".to_string(),
207            shellprocexitcode: inner.proc_exit_code,
208            spawn_ts_ms: None,
209            is_agent_pane: false,
210        }
211    }
212
213    /// Publish current controller status via the WPS broker.
214    fn publish_status(&self) {
215        if let Some(ref broker) = self.broker {
216            let status = self.get_status_snapshot();
217            super::publish_controller_status(broker, &status);
218        }
219    }
220
221    /// Emit `agent-message-accepted` for the given config, if it carries
222    /// a `message_id`. Called from both `spawn_turn` (direct path) and
223    /// the `process_waiter` drain site (queue path). No-op if the config
224    /// has no id, or the broker isn't configured.
225    fn emit_message_accepted(&self, config: &SubprocessSpawnConfig) {
226        let Some(id) = config.message_id.as_deref() else { return };
227        let Some(ref broker) = self.broker else { return };
228        let event = super::super::wps::WaveEvent {
229            event: super::super::wps::EVENT_AGENT_MESSAGE_ACCEPTED.to_string(),
230            scopes: vec![format!("block:{}", self.block_id)],
231            sender: String::new(),
232            persist: 0,
233            data: Some(serde_json::json!({
234                "block_id": self.block_id,
235                "message_id": id,
236            })),
237        };
238        broker.publish(event);
239        tracing::info!(
240            block_id = %self.block_id,
241            message_id = %id,
242            "emitted agent-message-accepted"
243        );
244    }
245
246    /// Get the stored session ID (if any).
247    #[allow(dead_code)]
248    pub fn session_id(&self) -> Option<String> {
249        self.inner.lock().unwrap().session_id.clone()
250    }
251
252    /// Record an authoritative session id captured from the CLI's
253    /// stdout init/`thread.started` event. The CLI is the source of
254    /// truth for which session is live, so this ALWAYS overwrites
255    /// any prior value of `inner.session_id` — including values
256    /// previously hydrated from config on a picker reattach (which
257    /// may be stale by the time the CLI speaks).
258    ///
259    /// Free-function form (taking `&Arc<Mutex<…Inner>>` instead of
260    /// `&self`) so the spawn_turn stdout-reader tokio task can call
261    /// it without holding an `Arc<Self>` reference. The
262    /// `&SubprocessController` method below just delegates.
263    ///
264    /// Returns `true` when the value changed (caller should
265    /// broadcast the meta update + persist to block meta). Returns
266    /// `false` when the new id matches the current one — common
267    /// when the CLI emits the same `session_id` on every NDJSON
268    /// frame within a single turn.
269    pub(crate) fn record_captured_session_id_inner(
270        inner: &Mutex<SubprocessControllerInner>,
271        sid: &str,
272    ) -> bool {
273        if sid.is_empty() {
274            return false;
275        }
276        let mut guard = inner.lock().unwrap();
277        let differs = guard.session_id.as_deref() != Some(sid);
278        if differs {
279            guard.session_id = Some(sid.to_string());
280        }
281        differs
282    }
283
284    /// `&self` convenience wrapper around
285    /// `record_captured_session_id_inner` — used by tests that
286    /// already hold a `SubprocessController`.
287    #[cfg(test)]
288    pub(crate) fn record_captured_session_id(&self, sid: &str) -> bool {
289        Self::record_captured_session_id_inner(&self.inner, sid)
290    }
291
292    /// Hydrate `inner.session_id` from a config-supplied id when the
293    /// controller hasn't seen a value yet.
294    ///
295    /// Picker reattach path: a fresh `SubprocessController` is
296    /// registered for the new block, so its `inner.session_id` is
297    /// `None`. The frontend persisted the prior block's session id
298    /// into `agent:sessionid` meta, the websocket / app_api caller
299    /// read it into `SubprocessSpawnConfig::session_id`, and this
300    /// method copies it to inner so the spawn_turn args-builder
301    /// appends `--resume <sid>` on the FIRST turn.
302    ///
303    /// **Hydration is best-effort, not authoritative.** If
304    /// `inner.session_id` is already `Some` we no-op (don't overwrite
305    /// a value already in place — could be a captured-from-stdout
306    /// id from an earlier turn, or a prior hydration on the same
307    /// reattach). Critically, the **CLI's stdout-emitted session id
308    /// is authoritative** and overwrites any prior value at capture
309    /// time (see the stdout-reader block in `spawn_turn`). So if the
310    /// hydrated value is stale, the FIRST turn passes the stale id
311    /// via `--resume` (likely accepted as a no-op or rejected with a
312    /// "no such session" error from the CLI), the CLI then emits its
313    /// own session id in the init event, and `inner.session_id` is
314    /// overwritten with that authoritative value for subsequent
315    /// turns. Without the capture overwrite, a stale hydrated id
316    /// would be re-used forever — that was the bug codex flagged on
317    /// PR #1018 first cut.
318    ///
319    /// Empty `&str` is treated as "no value" so the caller can use
320    /// it unconditionally without filtering.
321    pub(crate) fn hydrate_session_id_from_config(&self, config_sid: Option<&str>) {
322        let Some(sid) = config_sid.filter(|s| !s.is_empty()) else {
323            return;
324        };
325        let mut inner = self.inner.lock().unwrap();
326        if inner.session_id.is_some() {
327            return;
328        }
329        tracing::info!(
330            block_id = %self.block_id,
331            session_id = %sid,
332            "hydrated session_id from config (picker reattach)"
333        );
334        inner.session_id = Some(sid.to_string());
335    }
336
337    /// Spawn a single turn of the agent CLI.
338    ///
339    /// This is the core method — it spawns `claude -p`, writes the user message to stdin,
340    /// reads NDJSON from stdout (publishing WPS events), and waits for exit.
341    ///
342    /// If a session_id exists from a previous turn, `--resume <sid>` is appended to args.
343    pub fn spawn_turn(&self, config: SubprocessSpawnConfig) -> Result<(), String> {
344        if !self.try_lock_run() {
345            // Turn in progress — queue the message for after it exits.
346            let mut inner = self.inner.lock().unwrap();
347            tracing::info!(
348                block_id = %self.block_id,
349                queue_depth = inner.pending_messages.len() + 1,
350                "subprocess busy — message queued"
351            );
352            inner.pending_messages.push_back(config);
353            return Ok(());
354        }
355
356        // Direct-spawn path (queue was empty): emit the accepted event
357        // now so the frontend can promote its pending entry. The
358        // drain-from-queue path (in process_waiter) emits the same
359        // event just before calling spawn_turn recursively.
360        self.emit_message_accepted(&config);
361
362        // Hydrate inner.session_id from the config-supplied id if the
363        // controller hasn't captured one yet. See
364        // `hydrate_session_id_from_config` for the full rationale.
365        self.hydrate_session_id_from_config(config.session_id.as_deref());
366
367        // Build CLI args, appending resume flag + session_id if we have one and the provider supports it
368        let mut args = config.cli_args.clone();
369        {
370            let inner = self.inner.lock().unwrap();
371            if let Some(ref sid) = inner.session_id {
372                if !config.resume_flag.is_empty() {
373                    args.push(config.resume_flag.clone());
374                    args.push(sid.clone());
375                }
376            }
377        }
378
379        // Update status to running
380        {
381            let mut inner = self.inner.lock().unwrap();
382            Self::set_status(&mut inner, STATUS_RUNNING);
383        }
384        self.publish_status();
385        self.health_monitor.set_active_turn(true);
386
387        // Build command — on Windows, .cmd batch wrappers can't be reliably spawned
388        // via cmd.exe /C with piped stdio. Resolve to node <script> instead.
389        let mut cmd = crate::server::cli_handlers::make_cli_cmd(&config.cli_command);
390        cmd.args(&args);
391
392        // On Windows: suppress console-window allocation. Without CREATE_NO_WINDOW,
393        // node.exe spawned from a windowless sidecar may try to create/attach to a
394        // console, causing stdout to go to that console rather than the pipe.
395        #[cfg(windows)]
396        {
397            const CREATE_NO_WINDOW: u32 = 0x0800_0000;
398            cmd.creation_flags(CREATE_NO_WINDOW);
399        }
400        core::apply_working_dir(&mut cmd, &self.block_id, &config.working_dir, &config.env_vars);
401        cmd.stdin(std::process::Stdio::piped());
402        cmd.stdout(std::process::Stdio::piped());
403        cmd.stderr(std::process::Stdio::piped());
404
405        // Spawn
406        let mut child = cmd.spawn().map_err(|e| {
407            let mut inner = self.inner.lock().unwrap();
408            Self::set_status(&mut inner, STATUS_DONE);
409            inner.proc_exit_code = -1;
410            self.unlock_run();
411            format!("failed to spawn subprocess: {e}")
412        })?;
413
414        let pid = child.id().unwrap_or(0);
415        tracing::info!(
416            block_id = %self.block_id,
417            pid = pid,
418            cmd = %config.cli_command,
419            args = ?args,
420            "subprocess spawned"
421        );
422
423        // Assign the child to this block's process tracker so every
424        // descendant it spawns (bg bash, dev servers, watchers, etc.)
425        // is caught by the per-platform tracking mechanism and surfaces
426        // in the swarm activity panel. No-op if the tracker global
427        // hasn't been initialized (tests) or on platforms without a
428        // real tracker impl yet (stub handle accepts silently).
429        // See `backend::process_tracker`.
430        if pid != 0 {
431            if let Some(registry) = crate::backend::process_tracker::registry::global() {
432                let tracker = registry.ensure_tracker(&self.block_id);
433                if let Err(e) = tracker.assign_process(pid) {
434                    tracing::warn!(
435                        block_id = %self.block_id,
436                        pid = pid,
437                        err = %e,
438                        "[process-tracker] assign_process failed"
439                    );
440                }
441            }
442        }
443
444        // Store PID
445        let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<bool>();
446        {
447            let mut inner = self.inner.lock().unwrap();
448            inner.current_pid = Some(pid);
449            inner.kill_tx = Some(kill_tx);
450        }
451
452        // Take ownership of stdin/stdout (piped via Stdio::piped() in spawn config).
453        let stdin = child.stdin.take()
454            .ok_or_else(|| format!("[subprocess] stdin not captured for block {}", self.block_id))?;
455        let stdout = child.stdout.take()
456            .ok_or_else(|| format!("[subprocess] stdout not captured for block {}", self.block_id))?;
457        let stderr = child.stderr.take()
458            .ok_or_else(|| format!("[subprocess] stderr not captured for block {}", self.block_id))?;
459
460        // Write user message to stdin, then close it.
461        // CRITICAL: This must complete BEFORE the child's stdin timeout
462        // (Claude CLI: 3s). Using std::thread + synchronous write to
463        // bypass the Tokio task scheduler — a tokio::spawn'd task may
464        // not run for seconds on a busy runtime, causing the child to
465        // time out with "no stdin data received in 3s".
466        let message = config.message;
467        let block_id_stdin = self.block_id.clone();
468        {
469            // Convert Tokio's async ChildStdin to a raw OS handle, then
470            // wrap in a std::fs::File for synchronous write. The pipe
471            // buffer (4-64KB on Windows) easily fits our message, so
472            // write_all returns instantly without blocking.
473            #[cfg(unix)]
474            let raw_handle = {
475                use std::os::unix::io::{AsRawFd, FromRawFd};
476                let fd = stdin.as_raw_fd();
477                unsafe { std::fs::File::from_raw_fd(fd) }
478            };
479            #[cfg(windows)]
480            let raw_handle = {
481                use std::os::windows::io::{AsRawHandle, FromRawHandle};
482                let handle = stdin.as_raw_handle();
483                unsafe { std::fs::File::from_raw_handle(handle) }
484            };
485
486            // Spawn a real OS thread (not a Tokio task) for the write.
487            // This ensures it runs immediately regardless of runtime load.
488            // The raw handle is valid as long as `stdin` lives — we move
489            // `stdin` into the thread via a guard to keep it alive.
490            std::thread::spawn(move || {
491                use std::io::Write;
492                let _keep_alive = stdin; // prevent Tokio ChildStdin drop
493                let mut pipe = raw_handle;
494                let payload = format!("{}\n", message);
495                if let Err(e) = pipe.write_all(payload.as_bytes()) {
496                    tracing::warn!(block_id = %block_id_stdin, "subprocess stdin write error: {}", e);
497                    std::mem::forget(pipe); // don't close the handle — _keep_alive owns it
498                    return;
499                }
500                if let Err(e) = pipe.flush() {
501                    tracing::warn!(block_id = %block_id_stdin, "subprocess stdin flush error: {}", e);
502                }
503                std::mem::forget(pipe); // don't double-close — _keep_alive owns the handle
504                // _keep_alive (Tokio ChildStdin) drops here → EOF to the subprocess
505            });
506        }
507
508        // Spawn stdout_reader task
509        let block_id_read = self.block_id.clone();
510        let broker_read = self.broker.clone();
511        let inner_read = Arc::clone(&self.inner);
512        let wstore_read = self.wstore.clone();
513        let event_bus_read = self.event_bus.clone();
514        let filestore_read = self.filestore.clone();
515        let health_read = Arc::clone(&self.health_monitor);
516        let session_id_field = config.session_id_field.clone();
517        // Resolve the agent's GLOBAL transcript zone once (see persistent.rs).
518        let global_output_zone =
519            super::shell::resolve_global_output_zone(&self.wstore, &self.block_id);
520        // Retain the terminal `result` frame so a failure reported on STDOUT
521        // (auth / rate-limit / usage — the common case; claude may even exit 0)
522        // can be classified, not just stderr-reported ones. Shared with the
523        // process_waiter below.
524        let last_result_frame: Arc<Mutex<Option<serde_json::Value>>> = Arc::new(Mutex::new(None));
525        let last_result_frame_read = Arc::clone(&last_result_frame);
526        // Track in-band API errors delivered as `assistant` frames (e.g. a
527        // 401 auth failure that claude wraps in a synthetic assistant message
528        // with `"error":"authentication_failed"` and exit 0 — bypasses the
529        // `is_error` flag on the `result` frame entirely).
530        let last_inband_error: Arc<Mutex<Option<serde_json::Value>>> = Arc::new(Mutex::new(None));
531        let last_inband_error_read = Arc::clone(&last_inband_error);
532        let stdout_reader_handle = tokio::spawn(async move {
533            let reader = BufReader::new(stdout);
534            let mut lines = reader.lines();
535            let mut stats = super::session_stats::SessionStatsAccumulator::new(block_id_read.clone());
536
537            tracing::info!(block_id = %block_id_read, "stdout_reader started");
538
539            loop {
540                match lines.next_line().await {
541                    Err(e) => {
542                        tracing::warn!(block_id = %block_id_read, error = %e, "subprocess stdout read error");
543                        break;
544                    }
545                    Ok(None) => {
546                        tracing::info!(block_id = %block_id_read, "subprocess stdout EOF");
547                        break;
548                    }
549                    Ok(Some(line)) => {
550                        let trimmed = line.trim();
551                        if trimmed.is_empty() {
552                            continue;
553                        }
554
555                        // Track session metadata (debounced 1 s).
556                        // Use `line.len()` (not `trimmed.len()`) to match persistent.rs
557                        // so token_estimate stays consistent across controller types.
558                        stats.record_line(line.len(), &wstore_read);
559
560                        // Classify output for health monitoring + retain the
561                        // terminal `result` frame for failure classification.
562                        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
563                            let (meaningful, error) = classify_output_line(&parsed);
564                            health_read.record_output(meaningful);
565                            if let Some((class, msg)) = error {
566                                health_read.record_error(class, msg);
567                            }
568                            if parsed.get("type").and_then(|v| v.as_str()) == Some("result") {
569                                *last_result_frame_read.lock().unwrap() = Some(parsed);
570                            } else if parsed.get("type").and_then(|v| v.as_str()) == Some("assistant")
571                                && (parsed.get("isApiErrorMessage").and_then(|v| v.as_bool()).unwrap_or(false)
572                                    || parsed.get("error").is_some())
573                            {
574                                // In-band API error: 401 / auth failures arrive as a synthetic
575                                // assistant message (exit 0, is_error:false on result frame) —
576                                // capture it so the process_waiter can trip the failure gate.
577                                *last_inband_error_read.lock().unwrap() = Some(parsed);
578                            }
579                        }
580
581                        // Try to capture session/thread ID from the provider's init event.
582                        // Claude: {"type":"system","subtype":"init","session_id":"..."}
583                        // Gemini: {"type":"init","session_id":"..."}
584                        // Codex:  {"type":"thread.started","thread_id":"..."}
585                        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
586                            if let Some(sid) = parsed.get(&session_id_field).and_then(|v| v.as_str()) {
587                                let sid_string = sid.to_string();
588                                // Authoritative CLI capture —
589                                // overwrites any prior value
590                                // (including stale hydrated ids
591                                // from picker reattach). De-dups
592                                // when the same id repeats across
593                                // turns. See
594                                // `record_captured_session_id_inner`
595                                // for the unit-tested form.
596                                let changed = SubprocessController::record_captured_session_id_inner(
597                                    &inner_read,
598                                    &sid_string,
599                                );
600                                if changed {
601                                    tracing::info!(
602                                        block_id = %block_id_read,
603                                        field = %session_id_field,
604                                        session_id = %sid_string,
605                                        "captured session id"
606                                    );
607                                    core::persist_session_id(&block_id_read, &sid_string, &wstore_read, &event_bus_read);
608                                }
609                            }
610                        }
611
612                        // Publish the NDJSON line as a WPS blockfile event on the "output" subject
613                        // and write-through to FileStore for persistent history (Phase 1.3).
614                        if let Some(ref broker) = broker_read {
615                            tracing::info!(block_id = %block_id_read, line = %trimmed, "subprocess stdout → blockfile");
616                            // Include the newline so the frontend line splitter works correctly
617                            let line_with_newline = format!("{}\n", trimmed);
618                            super::shell::handle_append_block_file(
619                                broker,
620                                &block_id_read,
621                                SUBPROCESS_OUTPUT_SUBJECT,
622                                line_with_newline.as_bytes(),
623                                filestore_read.as_ref(),
624                                global_output_zone.as_deref(),
625                            );
626                        }
627                    }
628                }
629            }
630
631            tracing::info!(block_id = %block_id_read, "stdout_reader exiting");
632        });
633
634        // Capture a bounded tail of stderr so a non-zero exit can be classified
635        // into a real cause (SPEC_AGENT_FAILURE_DIAGNOSTICS Phase 2) instead of a
636        // bare "exit N". Shared with the process_waiter below.
637        let stderr_tail: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
638        let stderr_tail_reader = Arc::clone(&stderr_tail);
639        // Spawn stderr reader (logs warnings + retains a tail for classification)
640        let block_id_err = self.block_id.clone();
641        let stderr_reader_handle = tokio::spawn(async move {
642            let reader = BufReader::new(stderr);
643            let mut lines = reader.lines();
644            loop {
645                match lines.next_line().await {
646                    Err(e) => {
647                        tracing::warn!(block_id = %block_id_err, error = %e, "subprocess stderr read error");
648                        break;
649                    }
650                    Ok(None) => break,
651                    Ok(Some(line)) => {
652                        if !line.trim().is_empty() {
653                            tracing::info!(
654                                block_id = %block_id_err,
655                                stderr = %line,
656                                "subprocess stderr"
657                            );
658                            // Retain the last ~40 non-empty lines for classification.
659                            let mut buf = stderr_tail_reader.lock().unwrap();
660                            buf.push(line);
661                            let overflow = buf.len().saturating_sub(40);
662                            if overflow > 0 {
663                                buf.drain(0..overflow);
664                            }
665                        }
666                    }
667                }
668            }
669        });
670
671        core::spawn_health_watchdog(&self.health_monitor);
672
673        // Spawn process_waiter task
674        let inner_wait = Arc::clone(&self.inner);
675        let block_id_wait = self.block_id.clone();
676        let broker_wait = self.broker.clone();
677        let run_lock = Arc::clone(&self.run_lock);
678        let health_wait = Arc::clone(&self.health_monitor);
679        let self_ref_wait = self.self_ref.lock().unwrap().clone().unwrap_or_default();
680        let stderr_tail_wait = Arc::clone(&stderr_tail);
681        let last_result_frame_wait = Arc::clone(&last_result_frame);
682        let last_inband_error_wait = Arc::clone(&last_inband_error);
683        let wstore_wait = self.wstore.clone();
684        let event_bus_wait = self.event_bus.clone();
685        tokio::spawn(async move {
686            // Classified failure cause, surfaced to the pane after the readers drain.
687            let mut run_failure: Option<crate::agents::failure::AgentFailure> = None;
688            // Set on a clean (non-killed) exit so classification runs AFTER the
689            // stdout/stderr readers are joined — otherwise the final error line can
690            // race the buffer read and be lost (reagent P1).
691            let mut clean_exit: Option<(i32, Option<i32>)> = None;
692            // Wait for either process exit or kill signal
693            tokio::select! {
694                exit_result = child.wait() => {
695                    let (exit_code, exit_signal) = match exit_result {
696                        Ok(status) => {
697                            let code = status.code().unwrap_or(-1);
698                            #[cfg(unix)]
699                            let sig = std::os::unix::process::ExitStatusExt::signal(&status);
700                            #[cfg(not(unix))]
701                            let sig: Option<i32> = None;
702                            (code, sig)
703                        }
704                        Err(e) => {
705                            tracing::warn!(
706                                block_id = %block_id_wait,
707                                error = %e,
708                                "subprocess wait error"
709                            );
710                            (-1, None)
711                        }
712                    };
713
714                    tracing::info!(
715                        block_id = %block_id_wait,
716                        exit_code = exit_code,
717                        "subprocess exited"
718                    );
719
720                    // Update inner state
721                    {
722                        let mut inner = inner_wait.lock().unwrap();
723                        inner.proc_exit_code = exit_code;
724                        SubprocessController::set_status(&mut inner, STATUS_DONE);
725                        inner.current_pid = None;
726                        inner.kill_tx = None;
727                    }
728
729                    // Defer classification until after the readers are joined
730                    // (below); a user-initiated stop (kill arm) stays unclassified.
731                    clean_exit = Some((exit_code, exit_signal));
732                }
733                force = kill_rx => {
734                    let force = force.unwrap_or(false);
735                    tracing::info!(
736                        block_id = %block_id_wait,
737                        force = force,
738                        "subprocess kill requested"
739                    );
740
741                    if force {
742                        let _ = child.kill().await;
743                    } else {
744                        // On Unix, send SIGTERM. On Windows, kill() is the only option.
745                        #[cfg(unix)]
746                        {
747                            if let Some(pid) = child.id() {
748                                unsafe { libc::kill(pid as i32, libc::SIGTERM); }
749                            }
750                            // Give it a moment to exit gracefully
751                            tokio::time::sleep(tokio::time::Duration::from_millis(
752                                super::DEFAULT_GRACEFUL_KILL_WAIT_MS,
753                            )).await;
754                            let _ = child.kill().await;
755                        }
756                        #[cfg(not(unix))]
757                        {
758                            let _ = child.kill().await;
759                        }
760                    }
761
762                    let _ = child.wait().await;
763
764                    {
765                        let mut inner = inner_wait.lock().unwrap();
766                        inner.proc_exit_code = -1;
767                        SubprocessController::set_status(&mut inner, STATUS_DONE);
768                        inner.current_pid = None;
769                        inner.kill_tx = None;
770                    }
771                }
772            }
773
774            // Classify a genuine non-zero exit OR a failure reported on stdout as
775            // an error `result` frame (auth / rate-limit / usage — claude may even
776            // exit 0). Join the stdout + stderr readers first (bounded) so their
777            // final lines — the ones carrying the error text — are in the buffers
778            // before we read them (reagent P1).
779            if let Some((exit_code, exit_signal)) = clean_exit {
780                let drain = std::time::Duration::from_secs(2);
781                let _ = tokio::time::timeout(drain, stdout_reader_handle).await;
782                let _ = tokio::time::timeout(drain, stderr_reader_handle).await;
783                let result_frame = last_result_frame_wait.lock().unwrap().clone();
784                let frame_is_error = result_frame
785                    .as_ref()
786                    .and_then(|f| f.get("is_error"))
787                    .and_then(|v| v.as_bool())
788                    .unwrap_or(false);
789                // Also catch in-band API errors (e.g. auth 401) delivered as
790                // synthetic assistant messages with exit 0 and is_error:false.
791                let inband_error_frame = last_inband_error_wait.lock().unwrap().clone();
792                let inband_is_api_error = inband_error_frame.is_some();
793                if exit_code != 0 || frame_is_error || inband_is_api_error {
794                    let tail = stderr_tail_wait.lock().unwrap().join("\n");
795                    // Merge the in-band error text so classify() sees the
796                    // "authentication_failed" / "401" string even though it
797                    // arrived on stdout, not stderr.
798                    let inband_text = inband_error_frame.as_ref().map(|f| {
799                        let err_str = f.get("error").and_then(|v| v.as_str()).unwrap_or("");
800                        let content_text = f.pointer("/message/content/0/text")
801                            .and_then(|v| v.as_str())
802                            .unwrap_or("");
803                        format!("{err_str} {content_text}")
804                    }).unwrap_or_default();
805                    let combined_tail = if inband_text.trim().is_empty() {
806                        tail
807                    } else {
808                        format!("{tail}\n{inband_text}")
809                    };
810                    run_failure = Some(crate::agents::failure::classify(
811                        Some(exit_code),
812                        exit_signal,
813                        &combined_tail,
814                        result_frame.as_ref(),
815                    ));
816                }
817            }
818
819            // Update health monitor with exit status
820            {
821                let inner = inner_wait.lock().unwrap();
822                health_wait.set_exited(inner.proc_exit_code);
823            }
824
825            // Publish done status
826            if let Some(ref broker) = broker_wait {
827                let status = {
828                    let inner = inner_wait.lock().unwrap();
829                    BlockControllerRuntimeStatus {
830                        blockid: block_id_wait.clone(),
831                        version: inner.status_version,
832                        shellprocstatus: inner.proc_status.clone(),
833                        shellprocconnname: "local".to_string(),
834                        shellprocexitcode: inner.proc_exit_code,
835                        spawn_ts_ms: None,
836                        is_agent_pane: false,
837                    }
838                };
839                super::publish_controller_status(broker, &status);
840            }
841
842            // Persist or clear agent:last_failure in block meta so the recovery
843            // banner survives tab switches and page reloads (P1.1 of
844            // SPEC_AGENT_ERROR_FRAMEWORK_2026_06_20). Done before the WPS
845            // publish so the durable state is written first; the event is then
846            // a low-latency push to any active subscriber.
847            core::persist_last_failure(
848                &block_id_wait,
849                run_failure.as_ref(),
850                &wstore_wait,
851                &event_bus_wait,
852            );
853
854            // Surface the classified failure cause to the pane (Phase 2 of
855            // SPEC_AGENT_FAILURE_DIAGNOSTICS). persist:1 so reconnecting
856            // subscribers also receive the last failure without needing a
857            // separate meta read (belt-and-suspenders with the meta write above).
858            if let (Some(failure), Some(broker)) = (run_failure.as_ref(), broker_wait.as_ref()) {
859                broker.publish(wps::WaveEvent {
860                    event: wps::EVENT_AGENT_FAILURE.to_string(),
861                    scopes: vec![format!("block:{}", block_id_wait)],
862                    sender: String::new(),
863                    persist: 1,
864                    data: serde_json::to_value(failure).ok(),
865                });
866            }
867
868            // Release run lock
869            run_lock.store(false, Ordering::SeqCst);
870
871            // Drain message queue: if messages were queued while this turn
872            // was running, pop the next one and spawn it via the weak
873            // self-reference.
874            let next_config = {
875                let mut inner = inner_wait.lock().unwrap();
876                inner.pending_messages.pop_front()
877            };
878            if let Some(config) = next_config {
879                if let Some(ctrl) = self_ref_wait.upgrade() {
880                    tracing::info!(
881                        block_id = %block_id_wait,
882                        "draining queued message"
883                    );
884                    if let Err(e) = ctrl.spawn_turn(config) {
885                        tracing::warn!(
886                            block_id = %block_id_wait,
887                            error = %e,
888                            "failed to spawn queued turn"
889                        );
890                    }
891                }
892            }
893        });
894
895        Ok(())
896    }
897
898    /// Spawn a container agent turn via Docker socket (P1a: no secrets in argv).
899    ///
900    /// This is the secure alternative to `spawn_turn` for container agents. Instead
901    /// of running `docker exec -e KEY=VALUE ...` as a CLI subprocess (which exposes
902    /// secrets in process argv / `/proc/<pid>/cmdline`, CWE-214), this method calls
903    /// `ContainerManager::exec` directly, passing env vars through
904    /// `CreateExecOptions.env` (Docker socket). The exec I/O (stdin write + stdout
905    /// NDJSON stream) drives the same state machine as `spawn_turn`:
906    ///   • appends `--resume <sid>` if a prior session_id is known
907    ///   • writes the JSON message to exec stdin
908    ///   • reads NDJSON from the output stream, publishing WPS blockfile events
909    ///   • captures session_id from the provider's init event
910    ///   • transitions status running → done
911    ///   • drains the pending-message queue when the exec exits
912    ///
913    /// `base_cmd` is `[cli_command] + cli_args` WITHOUT resume — this method appends
914    /// `--resume <sid>` internally before starting the exec.
915    /// The exec env is derived from THIS message's `config.env_vars` (denylist
916    /// applied here, per-turn) — not carried across queue drains — so a queued
917    /// message runs with its own freshly-resolved auth/env, matching `spawn_turn`.
918    ///
919    /// Takes `cm` and `container_name` by value (not reference) so the returned
920    /// future is `'static` — required for `tokio::spawn` in the queue-drain path.
921    pub fn spawn_container_turn(
922        &self,
923        cm: crate::backend::container::ContainerManager,
924        container_name: String,
925        base_cmd: Vec<String>,
926        config: SubprocessSpawnConfig,
927    ) -> Result<(), String> {
928        if !self.try_lock_run() {
929            let mut inner = self.inner.lock().unwrap();
930            tracing::info!(
931                block_id = %self.block_id,
932                queue_depth = inner.pending_messages.len() + 1,
933                "container exec busy — message queued"
934            );
935            inner.pending_messages.push_back(config);
936            return Ok(());
937        }
938
939        self.emit_message_accepted(&config);
940        self.hydrate_session_id_from_config(config.session_id.as_deref());
941
942        // Derive the exec env from THIS message's own env_vars (apply the
943        // container denylist here, per-turn) rather than carrying a pre-filtered
944        // list across drains — so a message queued behind a running turn uses its
945        // own freshly-resolved auth/env, not the prior turn's stale values.
946        let container_env: Vec<(String, String)> = config.env_vars.iter()
947            .filter(|(k, _)| !crate::backend::container::CONTAINER_ENV_DENYLIST.contains(&k.as_str()))
948            .map(|(k, v)| (k.clone(), v.clone()))
949            .collect();
950
951        // Snapshot container params for the queue-drain path before base_cmd is consumed.
952        let cm_for_drain = cm.clone();
953        let container_name_for_drain = container_name.clone();
954        let base_cmd_for_drain = base_cmd.clone();
955
956        // Command name to pkill if the turn is interrupted (see the kill path in
957        // the reader select below). base_cmd[0] is the container-local CLI (e.g.
958        // `claude`); -f matches its full cmdline inside the container.
959        let kill_pattern = base_cmd.first().cloned().unwrap_or_else(|| "claude".to_string());
960
961        // Build final command: append --resume <sid> if we have a prior session.
962        let mut cmd = base_cmd;
963        {
964            let inner = self.inner.lock().unwrap();
965            if let Some(ref sid) = inner.session_id {
966                if !config.resume_flag.is_empty() {
967                    cmd.push(config.resume_flag.clone());
968                    cmd.push(sid.clone());
969                }
970            }
971        }
972
973        // Clone all self fields needed by the inner tokio::spawn so we don't
974        // borrow `self` across the async boundary (which would make the future
975        // non-'static and break tokio::spawn).
976        let inner_arc = Arc::clone(&self.inner);
977        let run_lock = Arc::clone(&self.run_lock);
978        let broker = self.broker.clone();
979        let event_bus = self.event_bus.clone();
980        let wstore = self.wstore.clone();
981        let filestore = self.filestore.clone();
982        let health_monitor = Arc::clone(&self.health_monitor);
983        let block_id = self.block_id.clone();
984        let self_ref_done = self.self_ref.lock().unwrap().clone().unwrap_or_default();
985
986        // Spawn all async work (exec + I/O) into a background task so this
987        // function returns synchronously. This is required so the queue-drain
988        // path inside the reader task can call `spawn_container_turn` without
989        // needing the returned future to be `'static`.
990        tokio::spawn(async move {
991            use bollard::container::LogOutput;
992
993            // Start the exec via Docker socket — env vars travel through
994            // CreateExecOptions.env (Docker API), never in process argv.
995            let exec_result = cm
996                .exec(&container_name, &cmd, None, &container_env)
997                .await;
998            let exec_session = match exec_result {
999                Ok(s) => s,
1000                Err(e) => {
1001                    tracing::warn!(block_id = %block_id, error = %e, "container exec failed");
1002                    // A failed exec must still run the SAME completion + queue
1003                    // drain as the normal-exit path below: publish a terminal
1004                    // status so the client sees the turn end (exit 1), mark the
1005                    // health monitor exited, release run_lock, AND drain any
1006                    // queued message — otherwise the run_lock is freed but
1007                    // pending_messages is never popped, stranding the queue.
1008                    {
1009                        let mut inner = inner_arc.lock().unwrap();
1010                        inner.proc_exit_code = 1;
1011                        Self::set_status(&mut inner, STATUS_DONE);
1012                        inner.current_pid = None;
1013                        inner.kill_tx = None;
1014                    }
1015                    health_monitor.set_exited(1);
1016                    if let Some(ref b) = broker {
1017                        let status = {
1018                            let inner = inner_arc.lock().unwrap();
1019                            super::BlockControllerRuntimeStatus {
1020                                blockid: block_id.clone(),
1021                                version: inner.status_version,
1022                                shellprocstatus: inner.proc_status.clone(),
1023                                shellprocconnname: "local".to_string(),
1024                                shellprocexitcode: inner.proc_exit_code,
1025                                spawn_ts_ms: None,
1026                                is_agent_pane: false,
1027                            }
1028                        };
1029                        super::publish_controller_status(b, &status);
1030                    }
1031                    run_lock.store(false, Ordering::SeqCst);
1032                    let next_config = {
1033                        let mut inner = inner_arc.lock().unwrap();
1034                        inner.pending_messages.pop_front()
1035                    };
1036                    if let Some(cfg) = next_config {
1037                        if let Some(ctrl) = self_ref_done.upgrade() {
1038                            tracing::info!(block_id = %block_id, "draining queued container message after exec failure");
1039                            if let Err(e) = ctrl.spawn_container_turn(
1040                                cm_for_drain,
1041                                container_name_for_drain,
1042                                base_cmd_for_drain,
1043                                cfg,
1044                            ) {
1045                                tracing::warn!(error = %e, "failed to spawn queued container turn");
1046                            }
1047                        }
1048                    }
1049                    return;
1050                }
1051            };
1052
1053            // Install a kill channel so stop_subprocess can interrupt this
1054            // in-flight exec. docker exec has no kill API, so the reader below
1055            // selects on kill_rx and pkills the in-container process. Stored only
1056            // after a successful exec start (the early-return failure path above
1057            // leaves kill_tx None — nothing to interrupt). Mirrors spawn_turn.
1058            let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<bool>();
1059
1060            // Update status to running
1061            {
1062                let mut inner = inner_arc.lock().unwrap();
1063                inner.kill_tx = Some(kill_tx);
1064                Self::set_status(&mut inner, STATUS_RUNNING);
1065            }
1066            if let Some(ref b) = broker {
1067                let status = {
1068                    let inner = inner_arc.lock().unwrap();
1069                    super::BlockControllerRuntimeStatus {
1070                        blockid: block_id.clone(),
1071                        version: inner.status_version,
1072                        shellprocstatus: inner.proc_status.clone(),
1073                        shellprocconnname: "local".to_string(),
1074                        shellprocexitcode: inner.proc_exit_code,
1075                        spawn_ts_ms: None,
1076                        is_agent_pane: false,
1077                    }
1078                };
1079                super::publish_controller_status(b, &status);
1080            }
1081            health_monitor.set_active_turn(true);
1082
1083            // Health watchdog: drive check() every 5s while the turn is active,
1084            // mirroring spawn_turn. set_active_turn(true) alone never calls
1085            // check(), so without this a container turn gets no Stalled/Dead
1086            // detection. Self-terminates when the turn ends — completion calls
1087            // health_monitor.set_exited(), which clears the active-turn flag.
1088            core::spawn_health_watchdog(&health_monitor);
1089
1090            let crate::backend::container::ExecSession { exec_id, mut input, output } = exec_session;
1091
1092            // Write the turn message to container stdin INLINE — not via a
1093            // detached `tokio::spawn`, which may not be scheduled for seconds
1094            // under runtime load and would trip the in-container CLI's "no
1095            // stdin data received in 3s" abort (the host path uses a dedicated
1096            // OS thread for the same reason — see spawn_turn). Awaiting here in
1097            // the already-running exec task guarantees the bytes hit the Docker
1098            // attach stream immediately. The CLI drains stdin to EOF before it
1099            // emits output, so this write cannot deadlock the read loop below.
1100            {
1101                let payload = format!("{}\n", config.message);
1102                if let Err(e) = input.write_all(payload.as_bytes()).await {
1103                    tracing::warn!(block_id = %block_id, "container exec stdin write error: {}", e);
1104                } else if let Err(e) = input.flush().await {
1105                    tracing::warn!(block_id = %block_id, "container exec stdin flush error: {}", e);
1106                }
1107                drop(input); // EOF to the container process
1108            }
1109
1110            // Read stdout — accumulate bytes into lines.
1111            let mut line_buf = String::new();
1112            let mut stats = super::session_stats::SessionStatsAccumulator::new(block_id.clone());
1113            let session_id_field = config.session_id_field.clone();
1114            // Tracks an aborted output stream (`Some(Err(_))`). The exec may have
1115            // exited cleanly with a non-zero code OR the attach stream itself
1116            // failed mid-turn; either way the turn did not complete normally, so
1117            // this forces a non-zero exit even if inspect_exec can't be reached.
1118            let mut stream_errored = false;
1119
1120            // Resolve the agent's GLOBAL transcript zone once (see persistent.rs)
1121            // so every container-exec `output` line is also mirrored to the
1122            // cross-channel store. `None` for non-agent blocks.
1123            let global_output_zone =
1124                super::shell::resolve_global_output_zone(&wstore, &block_id);
1125
1126            tracing::info!(block_id = %block_id, "container exec output reader started");
1127
1128            let mut pinned = std::pin::pin!(output);
1129            // Set when the turn is interrupted via stop_subprocess (Esc / agent.stop)
1130            // — drives a non-zero exit so an interrupted turn isn't reported as Idle.
1131            let mut killed = false;
1132            loop {
1133                tokio::select! {
1134                    // Prioritise the kill signal so Esc is responsive even under a
1135                    // steady output stream.
1136                    biased;
1137                    kill = &mut kill_rx => {
1138                        let force = kill.unwrap_or(false);
1139                        tracing::info!(block_id = %block_id, force, "container turn interrupt — pkill in container");
1140                        // Best-effort: actually terminate the in-container process.
1141                        // Even if this fails (e.g. no procps on an old image), we
1142                        // still break + finalize so AgentMux honours the stop.
1143                        if let Err(e) = cm.signal_exec_process(&container_name, &kill_pattern, force).await {
1144                            tracing::warn!(block_id = %block_id, error = %e, "container interrupt pkill failed");
1145                        }
1146                        killed = true;
1147                        break;
1148                    }
1149                    item = pinned.next() => {
1150                        match item {
1151                            None => {
1152                                // Stream ended — flush any remaining partial line.
1153                                if !line_buf.trim().is_empty() {
1154                                    Self::publish_line(&line_buf, &block_id, &session_id_field, &inner_arc, &wstore, &event_bus, &broker, &filestore, &health_monitor, &mut stats, global_output_zone.as_deref());
1155                                }
1156                                tracing::info!(block_id = %block_id, "container exec output EOF");
1157                                break;
1158                            }
1159                            Some(Err(e)) => {
1160                                tracing::warn!(block_id = %block_id, error = %e, "container exec output read error");
1161                                stream_errored = true;
1162                                break;
1163                            }
1164                            Some(Ok(log_output)) => {
1165                                let bytes = match log_output {
1166                                    LogOutput::StdOut { message } => message,
1167                                    LogOutput::StdErr { message } => {
1168                                        // Log stderr but don't publish as blockfile output.
1169                                        let s = String::from_utf8_lossy(&message);
1170                                        for line in s.lines() {
1171                                            if !line.trim().is_empty() {
1172                                                tracing::info!(block_id = %block_id, stderr = %line, "container exec stderr");
1173                                            }
1174                                        }
1175                                        continue;
1176                                    }
1177                                    _ => continue,
1178                                };
1179                                let chunk = String::from_utf8_lossy(&bytes);
1180                                for ch in chunk.chars() {
1181                                    if ch == '\n' {
1182                                        if !line_buf.trim().is_empty() {
1183                                            Self::publish_line(&line_buf, &block_id, &session_id_field, &inner_arc, &wstore, &event_bus, &broker, &filestore, &health_monitor, &mut stats, global_output_zone.as_deref());
1184                                        }
1185                                        line_buf.clear();
1186                                    } else {
1187                                        line_buf.push(ch);
1188                                    }
1189                                }
1190                            }
1191                        }
1192                    }
1193                }
1194            }
1195
1196            tracing::info!(block_id = %block_id, "container exec output reader exiting");
1197
1198            // Determine the real turn exit code. The output stream ending is NOT
1199            // the process exit status (unlike the host path's `child.wait()`), so
1200            // inspect the exec over the Docker socket. A mid-turn stream error, an
1201            // unavailable code, or a failed inspect is treated as a failure so a
1202            // crashed / non-zero in-container CLI is never misreported to the
1203            // client and to the health monitor as a successful (Idle) turn.
1204            let exit_code: i32 = if killed {
1205                // Interrupted by stop_subprocess — report non-zero (matches the
1206                // host spawn_turn kill path) so health treats it as not-Idle.
1207                -1
1208            } else if stream_errored {
1209                1
1210            } else {
1211                match cm.inspect_exec(&exec_id).await {
1212                    Ok(Some(code)) => code as i32,
1213                    Ok(None) => {
1214                        tracing::warn!(block_id = %block_id, "inspect_exec returned no exit code; treating turn as failed");
1215                        1
1216                    }
1217                    Err(e) => {
1218                        tracing::warn!(block_id = %block_id, error = %e, "inspect_exec failed; treating turn as failed");
1219                        1
1220                    }
1221                }
1222            };
1223
1224            // Mark done
1225            {
1226                let mut inner = inner_arc.lock().unwrap();
1227                inner.proc_exit_code = exit_code;
1228                SubprocessController::set_status(&mut inner, STATUS_DONE);
1229                inner.current_pid = None;
1230                inner.kill_tx = None;
1231            }
1232
1233            {
1234                let inner = inner_arc.lock().unwrap();
1235                health_monitor.set_exited(inner.proc_exit_code);
1236            }
1237
1238            if let Some(ref b) = broker {
1239                let status = {
1240                    let inner = inner_arc.lock().unwrap();
1241                    super::BlockControllerRuntimeStatus {
1242                        blockid: block_id.clone(),
1243                        version: inner.status_version,
1244                        shellprocstatus: inner.proc_status.clone(),
1245                        shellprocconnname: "local".to_string(),
1246                        shellprocexitcode: inner.proc_exit_code,
1247                        spawn_ts_ms: None,
1248                        is_agent_pane: false,
1249                    }
1250                };
1251                super::publish_controller_status(b, &status);
1252            }
1253
1254            run_lock.store(false, std::sync::atomic::Ordering::SeqCst);
1255
1256            // Drain queued messages via spawn_container_turn so the container
1257            // context (cm, container_name, base_cmd, container_env) is preserved.
1258            // spawn_turn has no container awareness and would spawn an empty command
1259            // on the host, silently losing the queued message.
1260            let next_config = {
1261                let mut inner = inner_arc.lock().unwrap();
1262                inner.pending_messages.pop_front()
1263            };
1264            if let Some(cfg) = next_config {
1265                if let Some(ctrl) = self_ref_done.upgrade() {
1266                    tracing::info!(block_id = %block_id, "draining queued container message via spawn_container_turn");
1267                    if let Err(e) = ctrl.spawn_container_turn(
1268                        cm_for_drain,
1269                        container_name_for_drain,
1270                        base_cmd_for_drain,
1271                        cfg,
1272                    ) {
1273                        tracing::warn!(error = %e, "failed to spawn queued container turn");
1274                    }
1275                }
1276            }
1277        });
1278
1279        Ok(())
1280    }
1281
1282    /// Publish a single NDJSON line from container exec output: session-id capture,
1283    /// health classification, WPS blockfile event, and FileStore write-through.
1284    /// Used by `spawn_container_turn`'s output reader task.
1285    fn publish_line(
1286        line: &str,
1287        block_id: &str,
1288        session_id_field: &str,
1289        inner: &std::sync::Mutex<SubprocessControllerInner>,
1290        wstore: &Option<Arc<crate::backend::storage::store::Store>>,
1291        event_bus: &Option<Arc<crate::backend::eventbus::EventBus>>,
1292        broker: &Option<Arc<crate::backend::wps::Broker>>,
1293        filestore: &Option<Arc<crate::backend::storage::filestore::FileStore>>,
1294        health: &Arc<super::health::HealthMonitor>,
1295        stats: &mut super::session_stats::SessionStatsAccumulator,
1296        global_output_zone: Option<&str>,
1297    ) {
1298        let trimmed = line.trim();
1299        if trimmed.is_empty() {
1300            return;
1301        }
1302        stats.record_line(trimmed.len(), wstore);
1303
1304        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
1305            let (meaningful, error) = super::health::classify_output_line(&parsed);
1306            health.record_output(meaningful);
1307            if let Some((class, msg)) = error {
1308                health.record_error(class, msg);
1309            }
1310
1311            // Capture session_id from provider init event.
1312            if let Some(sid) = parsed.get(session_id_field).and_then(|v| v.as_str()) {
1313                let changed = SubprocessController::record_captured_session_id_inner(inner, sid);
1314                if changed {
1315                    tracing::info!(block_id = %block_id, session_id = %sid, "container exec: captured session id");
1316                    core::persist_session_id(block_id, sid, &wstore, &event_bus);
1317                }
1318            }
1319        }
1320
1321        if let Some(ref broker) = broker {
1322            let line_with_newline = format!("{}\n", trimmed);
1323            super::shell::handle_append_block_file(
1324                broker,
1325                block_id,
1326                SUBPROCESS_OUTPUT_SUBJECT,
1327                line_with_newline.as_bytes(),
1328                filestore.as_ref(),
1329                global_output_zone,
1330            );
1331        }
1332    }
1333
1334    /// Stop the currently running subprocess.
1335    pub fn stop_subprocess(&self, force: bool) -> Result<(), String> {
1336        let kill_tx = {
1337            let mut inner = self.inner.lock().unwrap();
1338            inner.kill_tx.take()
1339        };
1340        match kill_tx {
1341            Some(tx) => {
1342                let _ = tx.send(force);
1343                Ok(())
1344            }
1345            None => Ok(()), // No running process
1346        }
1347    }
1348}
1349
1350impl Controller for SubprocessController {
1351    fn start(
1352        &self,
1353        _block_meta: super::super::obj::MetaMapType,
1354        _rt_opts: Option<serde_json::Value>,
1355        _force: bool,
1356    ) -> Result<(), String> {
1357        // SubprocessController doesn't auto-start on resync.
1358        // Turns are initiated by SubprocessSpawnCommand / AgentInputCommand.
1359        tracing::info!(
1360            block_id = %self.block_id,
1361            "subprocess controller registered (no auto-start)"
1362        );
1363        Ok(())
1364    }
1365
1366    fn stop(&self, _graceful: bool, new_status: &str) -> Result<(), String> {
1367        // Stop any running subprocess
1368        self.stop_subprocess(true)?;
1369
1370        let mut inner = self.inner.lock().unwrap();
1371        if inner.proc_status != new_status {
1372            Self::set_status(&mut inner, new_status);
1373        }
1374
1375        Ok(())
1376    }
1377
1378    fn get_runtime_status(&self) -> BlockControllerRuntimeStatus {
1379        self.get_status_snapshot()
1380    }
1381
1382    fn send_input(&self, input: BlockInputUnion, _seq: Option<u64>) -> Result<(), String> {
1383        // SubprocessController doesn't accept raw PTY input — user messages
1384        // go through spawn_turn() (via AgentInputCommand RPC).
1385        //
1386        // Signals ARE accepted though: the agent-pane composer's Esc
1387        // handler sends SIGINT via `ControllerInputCommand({signame:"SIGINT"})`
1388        // when the user wants to cancel an in-flight turn. Route that to
1389        // `stop_subprocess(force=true)` so the current subprocess is
1390        // killed via `kill_tx`. Without this, Esc was silently rejected
1391        // and the agent kept running.
1392        if let Some(sig) = input.sig_name.as_deref() {
1393            if sig == "SIGINT" || sig == "SIGTERM" {
1394                tracing::info!(
1395                    block_id = %self.block_id,
1396                    sig = %sig,
1397                    "subprocess controller: received signal, killing current turn"
1398                );
1399                return self.stop_subprocess(true);
1400            }
1401            return Err(format!(
1402                "subprocess controller: unsupported signal {sig} (only SIGINT/SIGTERM)"
1403            ));
1404        }
1405        if input.input_data.is_some() {
1406            return Err("subprocess controller does not accept raw input; use AgentInputCommand".to_string());
1407        }
1408        // Term resize / other input types: accepted-no-op.
1409        Ok(())
1410    }
1411
1412    fn controller_type(&self) -> &str {
1413        BLOCK_CONTROLLER_SUBPROCESS
1414    }
1415
1416    fn block_id(&self) -> &str {
1417        &self.block_id
1418    }
1419
1420    fn as_any(&self) -> &dyn std::any::Any {
1421        self
1422    }
1423}
1424
1425#[cfg(test)]
1426mod tests {
1427    use super::*;
1428
1429    #[test]
1430    fn test_subprocess_controller_new() {
1431        let ctrl = SubprocessController::new(
1432            "tab-1".to_string(),
1433            "block-1".to_string(),
1434            None,
1435            None,
1436            None,
1437            None,
1438        );
1439        assert_eq!(ctrl.controller_type(), BLOCK_CONTROLLER_SUBPROCESS);
1440        assert_eq!(ctrl.block_id(), "block-1");
1441
1442        let status = ctrl.get_runtime_status();
1443        assert_eq!(status.shellprocstatus, STATUS_INIT);
1444        assert_eq!(status.blockid, "block-1");
1445    }
1446
1447    #[test]
1448    fn test_subprocess_controller_rejects_raw_input() {
1449        let ctrl = SubprocessController::new(
1450            "tab-1".to_string(),
1451            "block-1".to_string(),
1452            None,
1453            None,
1454            None,
1455            None,
1456        );
1457        let result = ctrl.send_input(BlockInputUnion::data(b"hello".to_vec()), None);
1458        assert!(result.is_err());
1459        assert!(result.unwrap_err().contains("AgentInputCommand"));
1460    }
1461
1462    #[test]
1463    fn test_subprocess_controller_start_is_noop() {
1464        let ctrl = SubprocessController::new(
1465            "tab-1".to_string(),
1466            "block-1".to_string(),
1467            None,
1468            None,
1469            None,
1470            None,
1471        );
1472        let result = ctrl.start(HashMap::new(), None, false);
1473        assert!(result.is_ok());
1474
1475        // Still in init state — no auto-start
1476        let status = ctrl.get_runtime_status();
1477        assert_eq!(status.shellprocstatus, STATUS_INIT);
1478    }
1479
1480    #[test]
1481    fn test_subprocess_controller_stop_when_idle() {
1482        let ctrl = SubprocessController::new(
1483            "tab-1".to_string(),
1484            "block-1".to_string(),
1485            None,
1486            None,
1487            None,
1488            None,
1489        );
1490        let result = ctrl.stop(true, STATUS_DONE);
1491        assert!(result.is_ok());
1492
1493        let status = ctrl.get_runtime_status();
1494        assert_eq!(status.shellprocstatus, STATUS_DONE);
1495    }
1496
1497    #[test]
1498    fn test_subprocess_controller_session_id_initially_none() {
1499        let ctrl = SubprocessController::new(
1500            "tab-1".to_string(),
1501            "block-1".to_string(),
1502            None,
1503            None,
1504            None,
1505            None,
1506        );
1507        assert!(ctrl.session_id().is_none());
1508    }
1509
1510    #[test]
1511    fn test_subprocess_controller_concurrent_spawn_blocked() {
1512        let ctrl = SubprocessController::new(
1513            "tab-1".to_string(),
1514            "block-1".to_string(),
1515            None,
1516            None,
1517            None,
1518            None,
1519        );
1520
1521        // Manually acquire run lock
1522        ctrl.run_lock.store(true, Ordering::SeqCst);
1523
1524        let config = SubprocessSpawnConfig {
1525            cli_command: "echo".to_string(),
1526            cli_args: vec![],
1527            working_dir: String::new(),
1528            env_vars: HashMap::new(),
1529            message: "test".to_string(),
1530            resume_flag: String::new(),
1531            session_id_field: "session_id".to_string(),
1532            message_id: None,
1533            session_id: None,
1534        };
1535
1536        let result = ctrl.spawn_turn(config);
1537        // spawn_turn now queues instead of rejecting when busy
1538        assert!(result.is_ok());
1539
1540        // Verify the message was queued
1541        let inner = ctrl.inner.lock().unwrap();
1542        assert_eq!(inner.pending_messages.len(), 1);
1543        assert_eq!(inner.pending_messages[0].message, "test");
1544        drop(inner);
1545
1546        // Release lock
1547        ctrl.run_lock.store(false, Ordering::SeqCst);
1548    }
1549
1550    #[test]
1551    fn hydrate_session_id_populates_inner_when_none() {
1552        // Regression test for the 2026-05-24 "clicking My Agents
1553        // re-inserts the startup context" report. A fresh
1554        // SubprocessController is created for the reattached block;
1555        // its inner.session_id starts as None. The picker reattach
1556        // flow persists the prior block's session id into
1557        // `agent:sessionid` meta, the caller plumbs it into
1558        // `SubprocessSpawnConfig::session_id`, and spawn_turn calls
1559        // `hydrate_session_id_from_config` before building args.
1560        // After hydration, the existing args-builder appends
1561        // `--resume <sid>` on this very first turn — no
1562        // re-injected startup context.
1563        let ctrl = SubprocessController::new(
1564            "tab-1".to_string(),
1565            "block-reattach".to_string(),
1566            None,
1567            None,
1568            None,
1569            None,
1570        );
1571        assert!(ctrl.inner.lock().unwrap().session_id.is_none());
1572
1573        ctrl.hydrate_session_id_from_config(Some("prior-sid-from-meta"));
1574        assert_eq!(
1575            ctrl.inner.lock().unwrap().session_id.as_deref(),
1576            Some("prior-sid-from-meta")
1577        );
1578    }
1579
1580    #[test]
1581    fn hydrate_session_id_is_noop_when_value_already_present() {
1582        // Hydration is best-effort, not authoritative — it only
1583        // sets `inner.session_id` when None. The reason isn't
1584        // captured-id-wins (that's enforced at CAPTURE time below);
1585        // it's just to avoid re-hydrating on every spawn_turn call
1586        // within a controller lifetime. A stale value here is fine
1587        // because the next CLI emit at `record_captured_session_id_inner`
1588        // will overwrite.
1589        let ctrl = SubprocessController::new(
1590            "tab-1".to_string(),
1591            "block-resume".to_string(),
1592            None,
1593            None,
1594            None,
1595            None,
1596        );
1597        ctrl.inner.lock().unwrap().session_id = Some("captured-sid".to_string());
1598
1599        ctrl.hydrate_session_id_from_config(Some("different-config-sid"));
1600        assert_eq!(
1601            ctrl.inner.lock().unwrap().session_id.as_deref(),
1602            Some("captured-sid"),
1603            "hydration must not overwrite an existing value"
1604        );
1605    }
1606
1607    #[test]
1608    fn record_captured_overwrites_hydrated_value() {
1609        // The CLI is authoritative for session id once it speaks.
1610        // Codex P1 on PR #1018 first cut: my original
1611        // `if !already_captured` guard in the stdout reader meant
1612        // that a hydrated (possibly stale) session id would lock
1613        // out every subsequent CLI-emitted value, so a wrong
1614        // `--resume <stale>` would be passed forever. The fix
1615        // (`record_captured_session_id_inner`) always overwrites
1616        // and returns whether the value changed.
1617        let ctrl = SubprocessController::new(
1618            "tab-1".to_string(),
1619            "block-overwrite".to_string(),
1620            None,
1621            None,
1622            None,
1623            None,
1624        );
1625        ctrl.hydrate_session_id_from_config(Some("stale-hydrated-sid"));
1626        assert_eq!(
1627            ctrl.session_id().as_deref(),
1628            Some("stale-hydrated-sid")
1629        );
1630
1631        let changed = ctrl.record_captured_session_id("authoritative-sid");
1632        assert!(changed, "value differs from hydrated; must report changed");
1633        assert_eq!(
1634            ctrl.session_id().as_deref(),
1635            Some("authoritative-sid"),
1636            "CLI-emitted id must overwrite hydrated value"
1637        );
1638    }
1639
1640    #[test]
1641    fn record_captured_dedups_same_value() {
1642        // Real CLI streams emit `session_id` on every NDJSON frame,
1643        // not just the first. The dedup is a perf knob (skips the
1644        // meta-update broadcast on repeats), not a correctness
1645        // gate — captured-id is still authoritative on first emit.
1646        let ctrl = SubprocessController::new(
1647            "tab-1".to_string(),
1648            "block-dedup".to_string(),
1649            None,
1650            None,
1651            None,
1652            None,
1653        );
1654        assert!(ctrl.record_captured_session_id("sid-1"));
1655        assert!(!ctrl.record_captured_session_id("sid-1"),
1656            "second call with same value must return false (no broadcast)");
1657        assert_eq!(ctrl.session_id().as_deref(), Some("sid-1"));
1658    }
1659
1660    #[test]
1661    fn record_captured_ignores_empty() {
1662        // Defensive: empty string from a malformed CLI emit must
1663        // not clear a valid prior value.
1664        let ctrl = SubprocessController::new(
1665            "tab-1".to_string(),
1666            "block-empty".to_string(),
1667            None,
1668            None,
1669            None,
1670            None,
1671        );
1672        ctrl.record_captured_session_id("real-sid");
1673        assert!(!ctrl.record_captured_session_id(""),
1674            "empty CLI emit must be ignored");
1675        assert_eq!(ctrl.session_id().as_deref(), Some("real-sid"));
1676    }
1677
1678    #[test]
1679    fn hydrate_session_id_ignores_empty_and_none() {
1680        // Greenfield launches pass `None` (or `Some("")` if the
1681        // caller didn't filter) — hydration must be a no-op in
1682        // either case so inner.session_id stays None until the CLI
1683        // captures its own.
1684        let ctrl = SubprocessController::new(
1685            "tab-1".to_string(),
1686            "block-greenfield".to_string(),
1687            None,
1688            None,
1689            None,
1690            None,
1691        );
1692        ctrl.hydrate_session_id_from_config(None);
1693        assert!(ctrl.inner.lock().unwrap().session_id.is_none());
1694
1695        ctrl.hydrate_session_id_from_config(Some(""));
1696        assert!(ctrl.inner.lock().unwrap().session_id.is_none());
1697    }
1698
1699    #[test]
1700    fn spawn_turn_preserves_session_id_in_queued_config() {
1701        // When the controller is busy, spawn_turn queues the config
1702        // for the drain-from-queue path. The hydration ONLY runs on
1703        // the direct-spawn path (after try_lock_run), so the queued
1704        // config must carry session_id through unchanged for the
1705        // drain path's recursive call to see it.
1706        let ctrl = SubprocessController::new(
1707            "tab-1".to_string(),
1708            "block-queued".to_string(),
1709            None,
1710            None,
1711            None,
1712            None,
1713        );
1714        ctrl.run_lock.store(true, Ordering::SeqCst);
1715
1716        let config = SubprocessSpawnConfig {
1717            cli_command: "claude".to_string(),
1718            cli_args: vec!["-p".to_string()],
1719            working_dir: String::new(),
1720            env_vars: HashMap::new(),
1721            message: "hi".to_string(),
1722            resume_flag: "--resume".to_string(),
1723            session_id_field: "session_id".to_string(),
1724            message_id: None,
1725            session_id: Some("prior-sid".to_string()),
1726        };
1727        let _ = ctrl.spawn_turn(config);
1728
1729        let inner = ctrl.inner.lock().unwrap();
1730        assert_eq!(inner.pending_messages.len(), 1);
1731        assert_eq!(
1732            inner.pending_messages[0].session_id.as_deref(),
1733            Some("prior-sid"),
1734        );
1735        // Hydration didn't run yet — direct-spawn path was bypassed
1736        // by the busy lock; the drain will hydrate when it dequeues.
1737        assert!(inner.session_id.is_none());
1738    }
1739}