Skip to main content

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}