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}