agentmux_srv/backend/blockcontroller/subprocess/mod.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 + MPS blockfile events on "output" subject
18//! 2. process_waiter: wait for exit, update status, publish lifecycle event
19//!
20//! ## Module layout
21//!
22//! This module was split from a single ~1770-line `subprocess` file into a
23//! directory so each piece of the (large, deliberately-not-decomposed-further)
24//! turn state machines could live in its own file:
25//! - `mod.rs` (this file): spawn config, inner state, controller struct +
26//! bookkeeping (`new`/lock helpers/status snapshot/publish), and
27//! `impl Controller for SubprocessController`.
28//! - `session`: session-id capture/hydration state machine.
29//! - `host_spawn`: `spawn_turn` — host-subprocess turn via `std::process`.
30//! - `container_spawn`: `spawn_container_turn` — Docker-exec turn via
31//! `bollard`, plus its `publish_line` output helper.
32//! - `tests` (cfg(test)): unit tests, mostly session-id focused.
33//!
34//! `host_spawn` and `container_spawn` are `impl SubprocessController` blocks
35//! in their own files — Rust allows multiple `impl <Type>` blocks across
36//! files/modules for the same type. Each turn method is moved WHOLE into its
37//! file rather than decomposed further: both are continuous, non-trivially-
38//! ordered state machines where splitting the body would make the control
39//! flow harder to follow, not easier.
40
41use std::collections::{HashMap, VecDeque};
42use std::sync::atomic::{AtomicBool, Ordering};
43use std::sync::{Arc, Mutex};
44
45use super::health::TurnActivityTracker;
46use super::{BlockControllerRuntimeStatus, BlockInputUnion, Controller, STATUS_INIT};
47use crate::backend::eventbus::EventBus;
48use crate::backend::storage::filestore::FileStore;
49use crate::backend::storage::store::Store;
50use crate::backend::mps;
51
52// `pub(crate)` (not private): `argv::build_side_question_argv` is reused by
53// the `/btw` side-question turn builder in
54// `server/agent_handlers/side_question.rs`, outside this module. Every
55// other item in `argv` keeps its own narrower visibility.
56pub(crate) mod argv;
57mod container_spawn;
58mod host_spawn;
59mod session;
60#[cfg(test)]
61mod tests;
62
63/// MPS file subject name for subprocess output (replaces "term" from PTY).
64pub const SUBPROCESS_OUTPUT_SUBJECT: &str = "output";
65
66pub const BLOCK_CONTROLLER_SUBPROCESS: &str = "subprocess";
67
68/// Configuration for spawning a subprocess turn.
69#[derive(Debug, Clone)]
70pub struct SubprocessSpawnConfig {
71 /// CLI executable (e.g., "claude").
72 pub cli_command: String,
73 /// CLI arguments (e.g., ["-p", "--output-format", "stream-json", ...]).
74 pub cli_args: Vec<String>,
75 /// Working directory for the subprocess.
76 pub working_dir: String,
77 /// Environment variables to set.
78 pub env_vars: HashMap<String, String>,
79 /// The user's JSON message to write to stdin.
80 pub message: String,
81 /// Flag used to resume a previous session, e.g. "--resume" (Claude), "-r" (Gemini).
82 /// Empty string means this provider does not support simple-flag resume.
83 pub resume_flag: String,
84 /// Resume argv strategy: `none`, `flag`, or `codex-exec`.
85 /// Empty preserves compatibility with blocks created before this metadata
86 /// existed by inferring `flag` from a non-empty `resume_flag`.
87 pub resume_strategy: String,
88 /// When non-empty, the flag that passes the prompt as an argument
89 /// (`agy -p <prompt>`) instead of on stdin; stdin then gets nothing.
90 /// From `agent:prompt_arg_flag`, see `ProviderConfig::prompt_arg_flag`.
91 pub prompt_arg_flag: String,
92 /// JSON field name in the CLI's init event that contains the session/thread ID.
93 /// e.g. "session_id" (Claude/Gemini) or "thread_id" (Codex).
94 pub session_id_field: String,
95 /// Optional client-supplied message id. Echoed back via the
96 /// `agent-message-accepted` event when this config transitions from
97 /// queued → running so the frontend can pair the event with a
98 /// pending `PendingMessage`. None means no feedback is emitted.
99 pub message_id: Option<String>,
100 /// Session id to hydrate `inner.session_id` with BEFORE the first
101 /// turn — used by the picker "My Agents" reattach path. The
102 /// caller reads this from `block.meta["agent:sessionid"]` which
103 /// the frontend pre-populates from the prior block's session id
104 /// when launching with `continueOfInstanceId`. Without this
105 /// hydration `spawn_turn` would only see the captured session id
106 /// AFTER the first turn — meaning the first turn always launches
107 /// the CLI fresh (no `--resume <sid>`) and starts a new
108 /// conversation that re-injects the startup context.
109 ///
110 /// Empty / `None` means "no prior session" (greenfield launch).
111 ///
112 /// Best-effort, not authoritative: if the hydrated id is stale,
113 /// the CLI's stdout-emitted session id always overwrites it at
114 /// capture time (see `spawn_turn`'s stdout-reader block). The
115 /// reattach turn passes the (possibly stale) hydrated id via
116 /// `--resume`; the CLI either accepts it or starts a new
117 /// session and emits its own id, which then becomes the in-
118 /// memory authority for every subsequent turn.
119 pub session_id: Option<String>,
120 /// The registry's `instance_id` (`block.meta["agentId"]`) for this
121 /// agent — the cross-process session-lease key (see
122 /// `registry::LeaseStore`; NOT `session_id` above, which can be
123 /// `None` on a greenfield first turn). Empty string disables
124 /// leasing for this spawn (e.g. the container-mode branch in this
125 /// PR — see `host_spawn::spawn_turn`'s own doc comment).
126 pub instance_id: String,
127}
128
129/// Inner state protected by mutex.
130struct SubprocessControllerInner {
131 /// Current process status.
132 proc_status: String,
133 /// Process exit code from the most recent turn.
134 proc_exit_code: i32,
135 /// Status version counter (incremented on each change).
136 status_version: i32,
137 /// Session ID captured from the first `system/init` message.
138 session_id: Option<String>,
139 /// PID of the currently running subprocess (None if idle).
140 current_pid: Option<u32>,
141 /// Handle to kill the current subprocess.
142 kill_tx: Option<tokio::sync::oneshot::Sender<bool>>,
143 /// Messages queued while a turn is in progress.
144 /// Drained sequentially after the current turn exits.
145 pending_messages: VecDeque<SubprocessSpawnConfig>,
146}
147
148/// SubprocessController manages per-turn subprocess lifecycle for agent blocks.
149///
150/// Unlike `ShellController` which maintains a long-running PTY process,
151/// `SubprocessController` spawns a fresh process for each user turn.
152/// Multi-turn continuity comes from `--resume <session-id>`.
153pub struct SubprocessController {
154 #[allow(dead_code)]
155 tab_id: String,
156 block_id: String,
157 /// Prevents concurrent spawns.
158 run_lock: Arc<AtomicBool>,
159 /// Protected inner state.
160 inner: Arc<Mutex<SubprocessControllerInner>>,
161 /// MPS broker for publishing events (blockfile, controllerstatus).
162 broker: Option<Arc<mps::Broker>>,
163 /// Event bus for obj:update broadcasts.
164 event_bus: Option<Arc<EventBus>>,
165 /// AgentMux object store for block metadata persistence.
166 mstore: Option<Arc<Store>>,
167 /// FileStore for write-through persistence of output lines (Phase 1.3).
168 filestore: Option<Arc<FileStore>>,
169 /// Per-block turn-activity tracker.
170 health_monitor: Arc<TurnActivityTracker>,
171 /// Weak self-reference for queue drain. Set by `set_self_ref` after
172 /// the controller is wrapped in Arc.
173 self_ref: Mutex<Option<std::sync::Weak<Self>>>,
174 /// Cross-process session-ownership lease store. `None` when the
175 /// shared registry can't be resolved (CI / unusual envs) — leasing
176 /// degrades to a no-op in that case, same convention as
177 /// `shared_agent_registry()` elsewhere. See
178 /// `docs/retro/RETRO_DEV_BUILD_SHARED_AGENT_SESSION_COLLISION_2026_07_29.md`.
179 lease_store: Option<Arc<crate::registry::LeaseStore>>,
180 /// This process's boot id — the lease owner id. See `AppState::boot_id`.
181 boot_id: Arc<str>,
182}
183
184impl SubprocessController {
185 /// Create a new SubprocessController.
186 ///
187 /// `registry`/`boot_id` back the cross-process session-ownership
188 /// lease (see `lease_store` field doc). Passing `registry: None`
189 /// (or a registry whose root can't be opened as a `LeaseStore`,
190 /// which is logged and degrades the same way) disables leasing
191 /// for every turn this controller spawns — used by test call
192 /// sites that don't need a real registry.
193 pub fn new(
194 tab_id: String,
195 block_id: String,
196 broker: Option<Arc<mps::Broker>>,
197 event_bus: Option<Arc<EventBus>>,
198 mstore: Option<Arc<Store>>,
199 filestore: Option<Arc<FileStore>>,
200 registry: Option<Arc<crate::registry::Registry>>,
201 boot_id: Arc<str>,
202 ) -> Self {
203 let health_monitor = Arc::new(TurnActivityTracker::for_block(block_id.clone(), broker.as_ref()));
204 let lease_store = registry.and_then(|r| {
205 crate::registry::LeaseStore::open(r.root())
206 .map(Arc::new)
207 .map_err(|e| {
208 tracing::warn!(
209 block_id = %block_id,
210 error = %e,
211 "subprocess controller: failed to open lease store — leasing disabled for this controller"
212 );
213 })
214 .ok()
215 });
216 Self {
217 tab_id,
218 block_id,
219 run_lock: Arc::new(AtomicBool::new(false)),
220 inner: Arc::new(Mutex::new(SubprocessControllerInner {
221 proc_status: STATUS_INIT.to_string(),
222 proc_exit_code: 0,
223 status_version: 0,
224 session_id: None,
225 current_pid: None,
226 kill_tx: None,
227 pending_messages: VecDeque::new(),
228 })),
229 broker,
230 event_bus,
231 mstore,
232 filestore,
233 health_monitor,
234 self_ref: Mutex::new(None),
235 lease_store,
236 boot_id,
237 }
238 }
239
240 /// Store a weak self-reference so the process_waiter can drain queued
241 /// messages by calling spawn_turn after the current turn exits.
242 /// Must be called after wrapping in Arc.
243 pub fn set_self_ref(self: &Arc<Self>) {
244 *self.self_ref.lock().unwrap() = Some(Arc::downgrade(self));
245 }
246
247 /// Try to acquire the run lock. Returns false if a turn is already in progress.
248 fn try_lock_run(&self) -> bool {
249 self.run_lock
250 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
251 .is_ok()
252 }
253
254 /// Release the run lock.
255 fn unlock_run(&self) {
256 self.run_lock.store(false, Ordering::SeqCst);
257 }
258
259 /// Update process status and increment version (must hold inner lock).
260 fn set_status(inner: &mut SubprocessControllerInner, status: &str) {
261 inner.proc_status = status.to_string();
262 inner.status_version += 1;
263 }
264
265 /// Build a `BlockControllerRuntimeStatus` snapshot from inner state.
266 ///
267 /// Factored out of 4+ verbatim-duplicated struct literals (one here, one
268 /// in `host_spawn`'s process_waiter, two in `container_spawn`'s
269 /// running/done/failure transitions) — an audit finding from the
270 /// modularization pass. `turn_active` is passed explicitly because call
271 /// sites disagree on its value: `get_status_snapshot` reads the live
272 /// health-monitor flag, while every turn-completion / exec-failure site
273 /// always publishes `false` (the turn is over by construction at that
274 /// point).
275 fn build_status_snapshot(
276 inner: &SubprocessControllerInner,
277 block_id: &str,
278 turn_active: bool,
279 ) -> BlockControllerRuntimeStatus {
280 BlockControllerRuntimeStatus {
281 durable: false,
282 blockid: block_id.to_string(),
283 version: inner.status_version,
284 shellprocstatus: inner.proc_status.clone(),
285 shellprocconnname: "local".to_string(),
286 shellprocexitcode: inner.proc_exit_code,
287 shellprocpid: None,
288 shellprocname: String::new(),
289 spawn_ts_ms: None,
290 is_agent_pane: false,
291 turn_active,
292 }
293 }
294
295 /// Get the runtime status (snapshot).
296 fn get_status_snapshot(&self) -> BlockControllerRuntimeStatus {
297 let inner = self.inner.lock().unwrap();
298 Self::build_status_snapshot(&inner, &self.block_id, self.health_monitor.is_active_turn())
299 }
300
301 /// Publish current controller status via the MPS broker.
302 fn publish_status(&self) {
303 if let Some(ref broker) = self.broker {
304 let status = self.get_status_snapshot();
305 super::publish_controller_status(broker, &status);
306 }
307 }
308
309 /// Emit `agent-message-accepted` for the given config, if it carries
310 /// a `message_id`. Called from `spawn_turn` / `spawn_container_turn`;
311 /// a queued message gets here too, when the process-waiter task's drain
312 /// calls that same spawn function for it. No-op if the config
313 /// has no id, or the broker isn't configured.
314 fn emit_message_accepted(&self, config: &SubprocessSpawnConfig) {
315 let Some(id) = config.message_id.as_deref() else { return };
316 let Some(ref broker) = self.broker else { return };
317 super::publish_message_accepted(broker, &self.block_id, id);
318 tracing::info!(
319 block_id = %self.block_id,
320 message_id = %id,
321 "emitted agent-message-accepted"
322 );
323 }
324
325 /// A handle that writes the user's message to the transcript — see
326 /// [`UserRecordSink`]. Cloned out so the delivery paths (the stdin writer
327 /// thread, the container exec task) can write it once the prompt has
328 /// actually reached the CLI.
329 pub(super) fn user_record_sink(&self) -> UserRecordSink {
330 UserRecordSink {
331 broker: self.broker.clone(),
332 block_id: self.block_id.clone(),
333 filestore: self.filestore.clone(),
334 mstore: self.mstore.clone(),
335 }
336 }
337
338 /// Get the stored session ID (if any).
339 #[allow(dead_code)]
340 pub fn session_id(&self) -> Option<String> {
341 self.inner.lock().unwrap().session_id.clone()
342 }
343
344 /// Stop the currently running subprocess.
345 pub fn stop_subprocess(&self, force: bool) -> Result<(), String> {
346 let kill_tx = {
347 let mut inner = self.inner.lock().unwrap();
348 inner.kill_tx.take()
349 };
350 match kill_tx {
351 Some(tx) => {
352 let _ = tx.send(force);
353 Ok(())
354 }
355 None => Ok(()), // No running process
356 }
357 }
358}
359
360impl Controller for SubprocessController {
361 fn start(
362 &self,
363 _block_meta: super::super::obj::MetaMapType,
364 _rt_opts: Option<serde_json::Value>,
365 _force: bool,
366 ) -> Result<(), String> {
367 // SubprocessController doesn't auto-start on resync.
368 // Turns are initiated by SubprocessSpawnCommand / AgentInputCommand.
369 tracing::info!(
370 block_id = %self.block_id,
371 "subprocess controller registered (no auto-start)"
372 );
373 Ok(())
374 }
375
376 fn stop(&self, _graceful: bool, new_status: &str) -> Result<(), String> {
377 // Stop any running subprocess
378 self.stop_subprocess(true)?;
379
380 let mut inner = self.inner.lock().unwrap();
381 if inner.proc_status != new_status {
382 Self::set_status(&mut inner, new_status);
383 }
384
385 Ok(())
386 }
387
388 fn get_runtime_status(&self) -> BlockControllerRuntimeStatus {
389 self.get_status_snapshot()
390 }
391
392 fn send_input(&self, input: BlockInputUnion, _seq: Option<u64>) -> Result<(), String> {
393 // SubprocessController doesn't accept raw PTY input — user messages
394 // go through spawn_turn() (via AgentInputCommand RPC).
395 //
396 // Signals ARE accepted though: the agent-pane composer's Esc
397 // handler sends SIGINT via `ControllerInputCommand({signame:"SIGINT"})`
398 // when the user wants to cancel an in-flight turn. Route that to
399 // `stop_subprocess(force=true)` so the current subprocess is
400 // killed via `kill_tx`. Without this, Esc was silently rejected
401 // and the agent kept running.
402 if let Some(sig) = input.sig_name.as_deref() {
403 if sig == "SIGINT" || sig == "SIGTERM" {
404 tracing::info!(
405 block_id = %self.block_id,
406 sig = %sig,
407 "subprocess controller: received signal, killing current turn"
408 );
409 return self.stop_subprocess(true);
410 }
411 return Err(format!(
412 "subprocess controller: unsupported signal {sig} (only SIGINT/SIGTERM)"
413 ));
414 }
415 if input.input_data.is_some() {
416 return Err("subprocess controller does not accept raw input; use AgentInputCommand".to_string());
417 }
418 // Term resize / other input types: accepted-no-op.
419 Ok(())
420 }
421
422 fn turn_tracker(&self) -> Option<Arc<TurnActivityTracker>> {
423 Some(Arc::clone(&self.health_monitor))
424 }
425
426 fn controller_type(&self) -> &str {
427 BLOCK_CONTROLLER_SUBPROCESS
428 }
429
430 fn block_id(&self) -> &str {
431 &self.block_id
432 }
433
434 fn as_any(&self) -> &dyn std::any::Any {
435 self
436 }
437}
438
439/// Output formats whose CLI never writes the user's prompt into its own
440/// output, so the transcript would otherwise hold no record of it. Gemini's
441/// CLI echoes the prompt itself (rendered since #3620) — a second record would
442/// show the message twice on replay. ACP and the Codex App Server run through
443/// other controllers.
444pub(crate) fn persists_user_record(output_format: &str) -> bool {
445 matches!(
446 output_format,
447 // agy-stream-json: `agy` doesn't echo the prompt either.
448 "claude-stream-json" | "codex-json" | "kimi-stream-json" | "agy-stream-json" | "qwen-stream-json"
449 )
450}
451
452/// The transcript record for a user message: the same shape the persistent
453/// Claude controller writes, which every replay translator reads.
454pub(crate) fn user_record(message: &str) -> serde_json::Value {
455 serde_json::json!({
456 "type": "user",
457 "message": { "role": "user", "content": message }
458 })
459}
460
461/// Writes the user's message to the transcript, as the record the persistent
462/// Claude controller writes for each stdin line (Phase 5a-3c): a
463/// `{"type":"user","message":{...}}` line in the block's file and the agent's
464/// global zone, published as an `echo: "stdin"` event — the pane already shows
465/// the message (its `agent-message-accepted` node), so the event only keeps
466/// its stream gap-free. Without it these CLIs' transcripts hold no trace of
467/// what the user sent: gone on reload, absent from History, and a turn the
468/// live feed can't roll off
469/// (SPEC_AGENT_PANE_BOUNDED_LIVE_WINDOW_MIGRATION_2026_09_23.md §6.9).
470///
471/// Written only AFTER the prompt was delivered — the stdin write succeeded,
472/// or the container exec started — like the persistent controller's
473/// delivery-before-persistence order: a failed spawn, pipe or exec must not
474/// leave the transcript claiming a turn that never happened (Codex review).
475/// Only for output formats whose CLI doesn't echo the prompt itself (see
476/// [`persists_user_record`]).
477pub(super) struct UserRecordSink {
478 broker: Option<Arc<mps::Broker>>,
479 block_id: String,
480 filestore: Option<Arc<FileStore>>,
481 mstore: Option<Arc<Store>>,
482}
483
484impl UserRecordSink {
485 pub(super) fn write(&self, message: &str) {
486 let Some(store) = self.mstore.as_ref() else {
487 return;
488 };
489 let Ok(block) = store.must_get::<crate::backend::obj::Block>(&self.block_id) else {
490 return;
491 };
492 let format = crate::backend::obj::meta_get_string(&block.meta, "agentOutputFormat", "");
493 if !persists_user_record(&format) {
494 return;
495 }
496 let global_zone = crate::backend::agent_session::agent_zone_for_block_meta(&block.meta);
497 let line = format!("{}\n", user_record(message));
498 super::shell::persist_user_line(
499 self.broker.as_deref(),
500 &self.block_id,
501 line.as_bytes(),
502 self.filestore.as_ref(),
503 global_zone.as_deref(),
504 );
505 }
506}