agentmux_srv/backend/blockcontroller/shell/controller.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `ShellController` struct + inner state definitions, constructor, and the
5//! small accessor / lock / meta helpers. The `Controller` trait impl and the
6//! spawn/IO orchestration live in [`super::lifecycle`].
7
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10use std::sync::Mutex;
11use std::time::Instant;
12
13use tokio::sync::mpsc;
14
15use super::super::{
16 BlockControllerRuntimeStatus, BlockInputUnion, META_KEY_CMD, META_KEY_CMD_ARGS,
17 META_KEY_CMD_CLEAR_ON_START, META_KEY_CMD_CLOSE_ON_EXIT, META_KEY_CMD_CLOSE_ON_EXIT_DELAY,
18 META_KEY_CMD_CLOSE_ON_EXIT_FORCE, META_KEY_CMD_RUN_ONCE, META_KEY_CMD_RUN_ON_START,
19 META_KEY_CONNECTION, STATUS_INIT,
20};
21use crate::backend::eventbus::EventBus;
22use crate::backend::obj::{self, MetaMapType};
23use crate::backend::shellexec::ConnInterface;
24use crate::backend::storage::filestore::FileStore;
25use crate::backend::storage::store::Store;
26use crate::backend::mps;
27use crate::backend::wconfig::{ConfigState, SettingsType};
28
29/// Cap on the out-of-order input reorder buffer (`input_seq_buf`).
30///
31/// The input channel itself is now **unbounded** (`unbounded_channel`): a
32/// bounded `try_send` silently dropped input on burst (large pastes arriving
33/// faster than the PTY write loop drains — the original truncation bug), and
34/// the proper backpressure remedy (`send().await`) can't be applied here
35/// because `send_input` is a synchronous trait method holding a `std::Mutex`.
36/// An unbounded channel guarantees no input is ever dropped; terminal input is
37/// human-paced (and the frontend now chunks large pastes at 4 KB / 5 ms), and
38/// the PTY drain loop keeps up, so the queue does not grow in practice. This
39/// constant only bounds the reorder buffer (pathological out-of-order seqs).
40pub(super) const SHELL_INPUT_CH_SIZE: usize = 256;
41
42/// Inner state protected by mutex.
43/// Grace period (seconds) between SIGTERM and SIGKILL during stop().
44#[allow(dead_code)]
45pub(super) const KILL_GRACE_SECS: u64 = 5;
46
47pub(super) struct ShellControllerInner {
48 /// Current process status.
49 pub(super) proc_status: String,
50 /// Process exit code.
51 pub(super) proc_exit_code: i32,
52 /// Status version counter (incremented on each change).
53 pub(super) status_version: i32,
54 /// Connection name for the shell process.
55 pub(super) conn_name: String,
56 /// Input channel sender (sends to the PTY input loop). Unbounded so input
57 /// is never dropped on burst — see `SHELL_INPUT_CH_SIZE` doc.
58 pub(super) input_tx: Option<mpsc::UnboundedSender<BlockInputUnion>>,
59 /// Input channel receiver (consumed by the PTY input loop).
60 #[allow(dead_code)]
61 pub(super) input_rx: Option<mpsc::UnboundedReceiver<BlockInputUnion>>,
62 /// OS PID of the running child process, kept for signal delivery in stop().
63 pub(super) child_pid: Option<u32>,
64 /// Program name of that child (`pwsh`, `bash`, …), for the Shell drawer's
65 /// info line. Empty until the first spawn.
66 pub(super) shell_name: String,
67 /// Unix timestamp (ms) when the process was spawned; None until first spawn.
68 pub(super) spawn_ts_ms: Option<i64>,
69 /// Monotonic instant of the most recent PTY read; None until first output.
70 pub(super) last_pty_output: Option<Instant>,
71 /// True if this pane is running an agent CLI (e.g. claude).
72 pub(super) is_agent_pane: bool,
73 /// This pane's own jekt/muxbus identity, captured once at spawn time
74 /// from `resolve_agent_id_for_jekt` (see that fn's doc comment) — the
75 /// independent source of truth `Controller::agent_id()` exposes for
76 /// `inject_message_inner`'s recipient-identity check. `None` for a
77 /// plain (non-agent) terminal pane.
78 pub(super) agent_id: Option<String>,
79 /// Next expected input seq number (per-TermViewModel monotonic counter).
80 pub(super) input_seq_next: u64,
81 /// Out-of-order input packets waiting for their seq slot (capped at SHELL_INPUT_CH_SIZE).
82 pub(super) input_seq_buf: std::collections::BTreeMap<u64, BlockInputUnion>,
83}
84
85/// Factory function type for creating ConnInterface instances.
86/// This allows dependency injection for testing.
87pub type ConnFactory =
88 Box<dyn Fn(&str, &MetaMapType) -> Result<Box<dyn ConnInterface>, String> + Send + Sync>;
89
90/// ShellController manages one shell or command block.
91pub struct ShellController {
92 /// Controller type: "shell" or "cmd".
93 pub(super) controller_type: String,
94 pub(super) tab_id: String,
95 pub(super) block_id: String,
96 /// Prevents concurrent run() calls.
97 pub(super) run_lock: Arc<AtomicBool>,
98 /// Protected inner state.
99 pub(super) inner: Arc<Mutex<ShellControllerInner>>,
100 /// Optional factory for creating ConnInterface (for testing).
101 pub(super) conn_factory: Mutex<Option<ConnFactory>>,
102 /// MPS broker for publishing events (blockfile, controllerstatus).
103 pub(super) broker: Option<Arc<mps::Broker>>,
104 /// Event bus (unused for now, reserved for future event routing).
105 #[allow(dead_code)]
106 pub(super) event_bus: Option<Arc<EventBus>>,
107 /// AgentMux object store — used to seed cmd:cwd on shell spawn.
108 pub(super) mstore: Option<Arc<Store>>,
109 /// FileStore write-through target for PTY output persistence
110 /// (SPEC_TERMINAL_SCROLLBACK_PERSISTENCE_2026_07_23.md §2.1) — lets
111 /// `handle_append_block_file`'s "term" writes survive a reconnect,
112 /// mirroring `PersistentSubprocessController`/`SubprocessController`'s
113 /// existing filestore wiring.
114 pub(super) filestore: Option<Arc<FileStore>>,
115 /// This instance's API auth key, re-injected into the spawned PTY as
116 /// `AGENTMUX_AUTH_KEY` (see `lifecycle.rs`) so `muxsh`/`muxspect`/
117 /// `muxopen`/`muxlog`, run by a human from a plain Terminal-widget pane,
118 /// can authenticate the same way an agent pane's process already does
119 /// (`PersistentSpawnConfig`/`SubprocessSpawnConfig`'s own `env_vars`).
120 /// Can't be read back from the process env at spawn time the way
121 /// `AGENTMUX_LOCAL_URL` is — `config.rs` deliberately scrubs it from
122 /// this process's own environment right after startup (PR #801) — so it
123 /// has to be threaded in explicitly instead.
124 pub(super) auth_key: String,
125 /// The live, file-watched config (`bootstrap/background.rs`'s
126 /// `config_watcher`, kept current by `config_watcher_fs` and the
127 /// `setconfig` handler), read at each spawn for the global `cmd:env`
128 /// defaults. `None` in tests and mock contexts, which then get no global
129 /// defaults. Set via [`with_config`](Self::with_config).
130 pub(super) config: Option<Arc<ConfigState>>,
131}
132
133impl ShellController {
134 /// Create a new ShellController.
135 pub fn new(
136 controller_type: String,
137 tab_id: String,
138 block_id: String,
139 broker: Option<Arc<mps::Broker>>,
140 event_bus: Option<Arc<EventBus>>,
141 mstore: Option<Arc<Store>>,
142 filestore: Option<Arc<FileStore>>,
143 auth_key: String,
144 ) -> Self {
145 Self {
146 controller_type,
147 tab_id,
148 block_id,
149 run_lock: Arc::new(AtomicBool::new(false)),
150 inner: Arc::new(Mutex::new(ShellControllerInner {
151 proc_status: STATUS_INIT.to_string(),
152 proc_exit_code: 0,
153 status_version: 0,
154 conn_name: String::new(),
155 input_tx: None,
156 input_rx: None,
157 child_pid: None,
158 shell_name: String::new(),
159 spawn_ts_ms: None,
160 last_pty_output: None,
161 is_agent_pane: false,
162 agent_id: None,
163 input_seq_next: 0,
164 input_seq_buf: std::collections::BTreeMap::new(),
165 })),
166 conn_factory: Mutex::new(None),
167 broker,
168 event_bus,
169 mstore,
170 filestore,
171 auth_key,
172 config: None,
173 }
174 }
175
176 /// Attach the live config handle the spawn reads global `cmd:env`
177 /// defaults from. Builder-style, like `PersistentSubprocessController::
178 /// with_identity_stores`.
179 pub fn with_config(mut self, config: Option<Arc<ConfigState>>) -> Self {
180 self.config = config;
181 self
182 }
183
184 /// Current global settings. Read from the live config on every call, not
185 /// captured at construction, so a settings.json edit reaches the next
186 /// spawn of an existing pane too. Defaults when no config is attached.
187 pub(super) fn live_settings(&self) -> SettingsType {
188 self.config
189 .as_ref()
190 .map(|config| config.get_settings())
191 .unwrap_or_default()
192 }
193
194 /// Set a custom ConnInterface factory (for testing).
195 #[allow(dead_code)]
196 pub fn set_conn_factory(&self, factory: ConnFactory) {
197 *self.conn_factory.lock().unwrap() = Some(factory);
198 }
199
200 /// Try to acquire the run lock. Returns false if already running.
201 pub(super) fn try_lock_run(&self) -> bool {
202 self.run_lock
203 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
204 .is_ok()
205 }
206
207 /// Release the run lock.
208 pub(super) fn unlock_run(&self) {
209 self.run_lock.store(false, Ordering::SeqCst);
210 }
211
212 /// Update process status and increment version (must hold inner lock).
213 pub(super) fn set_status(inner: &mut ShellControllerInner, status: &str) {
214 inner.proc_status = status.to_string();
215 inner.status_version += 1;
216 }
217
218 /// Get the runtime status (snapshot).
219 pub(super) fn get_status_snapshot(&self) -> BlockControllerRuntimeStatus {
220 let inner = self.inner.lock().unwrap();
221 BlockControllerRuntimeStatus {
222 durable: false,
223 blockid: self.block_id.clone(),
224 version: inner.status_version,
225 shellprocstatus: inner.proc_status.clone(),
226 shellprocconnname: inner.conn_name.clone(),
227 shellprocexitcode: inner.proc_exit_code,
228 spawn_ts_ms: inner.spawn_ts_ms,
229 shellprocpid: inner.child_pid,
230 shellprocname: inner.shell_name.clone(),
231 is_agent_pane: inner.is_agent_pane,
232 // The shell/PTY controller has no NDJSON-derived health monitor
233 // (no structured turn-end marker to key off, unlike
234 // persistent/stdout_reader.rs and acp.rs) — leave unset rather than
235 // guess. Mount reconciliation falls back to today's Idle default
236 // for these panes, same as before this field existed.
237 turn_active: false,
238 }
239 }
240
241 /// Seconds since last PTY output, or None if no output yet.
242 pub fn last_output_secs_ago(&self) -> Option<u64> {
243 self.inner.lock().unwrap().last_pty_output.map(|t| t.elapsed().as_secs())
244 }
245
246 /// True if this pane is running an agent CLI.
247 #[allow(dead_code)]
248 pub fn is_agent_pane(&self) -> bool {
249 self.inner.lock().unwrap().is_agent_pane
250 }
251
252 /// Check block meta for whether to run on start.
253 pub(super) fn should_run_on_start(meta: &MetaMapType) -> bool {
254 obj::meta_get_bool(meta, META_KEY_CMD_RUN_ON_START, true)
255 }
256
257 /// Check block meta for run-once mode (used in full lifecycle integration).
258 #[allow(dead_code)]
259 pub(super) fn should_run_once(meta: &MetaMapType) -> bool {
260 obj::meta_get_bool(meta, META_KEY_CMD_RUN_ONCE, false)
261 }
262
263 /// Check block meta for clear-on-start (used in full lifecycle integration).
264 #[allow(dead_code)]
265 pub(super) fn should_clear_on_start(meta: &MetaMapType) -> bool {
266 obj::meta_get_bool(meta, META_KEY_CMD_CLEAR_ON_START, false)
267 }
268
269 /// Check block meta for close-on-exit, as literally set — no default
270 /// inference. Use [`effective_close_on_exit`](Self::effective_close_on_exit)
271 /// at call sites that need the controller-type-aware default; this raw
272 /// getter exists so that default logic has a single, testable "was it
273 /// explicitly set" primitive to build on.
274 pub(super) fn should_close_on_exit(meta: &MetaMapType) -> bool {
275 obj::meta_get_bool(meta, META_KEY_CMD_CLOSE_ON_EXIT, false)
276 }
277
278 /// Whether this block should close its pane when the underlying process
279 /// exits — [`should_close_on_exit`](Self::should_close_on_exit) layered
280 /// with a controller-type-aware default for when `cmd:closeonexit`
281 /// isn't set at all. An explicit value in meta always wins either way;
282 /// this only decides the unset case.
283 ///
284 /// Defaults to `true` for `BLOCK_CONTROLLER_SHELL` (the interactive PTY
285 /// both the `Terminal` widget and an agent's composer-drawer shell
286 /// use) — matches the ordinary expectation that typing `exit` closes
287 /// the pane, the default behavior of most terminal apps. Defaults to
288 /// `false` for `BLOCK_CONTROLLER_CMD` (one-shot, non-interactive
289 /// command execution, e.g. a build command) — that pane exists
290 /// specifically to show its output/exit code after the command
291 /// finishes, and auto-closing it by default would hide exactly the
292 /// information it's for. See
293 /// docs/specs/SPEC_TERM_EXIT_RESPAWN_LOOP_2026_09_15.md §10.
294 pub(super) fn effective_close_on_exit(meta: &MetaMapType, controller_type: &str) -> bool {
295 if meta.contains_key(META_KEY_CMD_CLOSE_ON_EXIT) {
296 return Self::should_close_on_exit(meta);
297 }
298 controller_type == super::super::BLOCK_CONTROLLER_SHELL
299 }
300
301 /// Check block meta for force close-on-exit. **Not currently read by
302 /// any call site** — kept as a meta getter (parsed, tested, and
303 /// available for a future distinction) rather than deleted outright,
304 /// because two attempts at giving it a real meaning during this PR
305 /// both turned out unworkable, not because the getter itself is wrong:
306 ///
307 /// - Exit-code-based ("close even on a non-zero exit"): `wait()`'s
308 /// exit-code capture is itself lossy — portable-pty's `ExitStatus`
309 /// doesn't expose a raw code on all platforms, so success/failure is
310 /// all that's reliably known (see the wait/cleanup task's own
311 /// comment in `lifecycle.rs`) — not enough to build a principled
312 /// "force" distinction on.
313 /// - Flusher-drain-timeout-based ("close even if a background
314 /// descendant might still be producing output"): tried and reverted
315 /// in this same PR after live-testing on Windows showed
316 /// `FLUSHER_DRAIN_TIMEOUT` (10s) gets hit on essentially every
317 /// ordinary `cmd.exe` exit — ConPTY's own console-host process
318 /// routinely outlives the direct child by design — which made
319 /// gating close-on-exit on it fire almost never, defeating the
320 /// whole feature on the one platform this was tested on. See
321 /// `docs/specs/SPEC_TERM_EXIT_RESPAWN_LOOP_2026_09_15.md` §10.
322 #[allow(dead_code)]
323 pub(super) fn should_close_on_exit_force(meta: &MetaMapType) -> bool {
324 obj::meta_get_bool(meta, META_KEY_CMD_CLOSE_ON_EXIT_FORCE, false)
325 }
326
327 /// Get the close-on-exit delay in ms — how long to leave the pane
328 /// showing its final output before actually closing it.
329 ///
330 /// **Defaults to 0 (close immediately).** The knob predates any caller
331 /// and its original 2000 was never exercised; when close-on-exit was
332 /// first wired up (§10) that 2s was kept as-is, and the reporter's
333 /// first working retest asked for it to go — a shell you just typed
334 /// `exit` into has already shown you whatever it was going to show, so
335 /// the pause reads as lag, not as a chance to read the output. Still
336 /// honored when set explicitly via `cmd:closeonexitdelay`, for a pane
337 /// that genuinely wants a beat before vanishing. See
338 /// docs/specs/SPEC_TERM_EXIT_RESPAWN_LOOP_2026_09_15.md §13.
339 pub(super) fn close_on_exit_delay_ms(meta: &MetaMapType) -> u64 {
340 match meta.get(META_KEY_CMD_CLOSE_ON_EXIT_DELAY) {
341 Some(serde_json::Value::Number(n)) => n.as_u64().unwrap_or(0),
342 _ => 0,
343 }
344 }
345
346 /// Get the connection name from block meta.
347 pub(super) fn get_conn_name(meta: &MetaMapType) -> String {
348 obj::meta_get_string(meta, META_KEY_CONNECTION, "local")
349 }
350
351 /// Get the command string from block meta.
352 pub(super) fn get_cmd_str(meta: &MetaMapType) -> String {
353 obj::meta_get_string(meta, META_KEY_CMD, "")
354 }
355
356 /// Get cmd:args array from block meta.
357 pub(super) fn get_cmd_args(meta: &MetaMapType) -> Vec<String> {
358 match meta.get(META_KEY_CMD_ARGS) {
359 Some(serde_json::Value::Array(arr)) => arr
360 .iter()
361 .filter_map(|v| v.as_str().map(|s| s.to_string()))
362 .collect(),
363 _ => vec![],
364 }
365 }
366
367 /// Check if cmd:interactive is set in block meta.
368 pub(super) fn is_interactive(meta: &MetaMapType) -> bool {
369 obj::meta_get_bool(meta, "cmd:interactive", false)
370 }
371
372 /// Publish current controller status via the MPS broker.
373 pub(super) fn publish_status(&self) {
374 if let Some(ref broker) = self.broker {
375 let status = self.get_status_snapshot();
376 super::super::publish_controller_status(broker, &status);
377 }
378 }
379}