agentmux_srv/backend/shell_node.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! ShellNodeRunner — spawns a shell command and streams output to the
5//! frontend as `shell_chunk` MPS events scoped to the agent's block.
6//!
7//! Launched by `handle_shell_create` (server/mod.rs) via `tokio::spawn`.
8//! The runner is fire-and-forget; the HTTP handler returns the `shell_id`
9//! to the MCP caller immediately while output streams asynchronously.
10//!
11//! Phase 2 of SPEC_PERSISTENT_SHELL_NODE_2026_06_11.md. Uses
12//! `tokio::process::Command` with captured pipes (no PTY in this phase;
13//! PTY support is a Phase 3 follow-up that requires portable-pty wiring
14//! similar to the existing ShellController).
15
16#[cfg(windows)]
17use agentmux_common::win32::NoWindow;
18use std::collections::{HashMap, VecDeque};
19use std::sync::Arc;
20use parking_lot::Mutex;
21use serde::Serialize;
22use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
23use tokio::sync::{mpsc, oneshot};
24
25use crate::backend::mps::Broker;
26use agentmux_common::api_types::ShellInputFailure;
27
28use agentmux_common::time::now_ms_u64 as now_ms;
29
30/// Live status of a running (or recently exited) shell. Updated by
31/// `ShellNodeRunner` as output arrives and on exit.
32///
33/// `block_id`/`cmd`/`title`/`started_at` are populated once at spawn (see
34/// `ShellNodeRunner::run`) and never change — kept here (rather than a
35/// separate lookup) so `list_active` (SPEC_SWARM_LONG_RUNNING_PROCESS_
36/// ROWS_2026_07_20) can answer "which shells belong to block X" from the
37/// same map `get_status`/`ShellStatus` already query, with no new registry
38/// or second source of truth.
39#[derive(Clone, Default)]
40pub struct ShellStatusInfo {
41 pub running: bool,
42 pub exit_code: Option<i32>,
43 pub line_count: u64,
44 pub block_id: String,
45 pub cmd: String,
46 pub title: String,
47 pub started_at: u64,
48}
49
50/// Snapshot of one currently-running shell for the Swarm pane's per-agent
51/// long-running-process rows. See `ShellSessionRegistry::list_active`.
52/// Carries `block_id` (unfiltered list — same shape as `subagent.ListActive`/
53/// `ActiveSubagent.parent_block_id`) so the Swarm pane's `buildTree()` groups
54/// by block client-side in one pass, instead of one RPC call per tracked
55/// agent block.
56#[derive(Clone, Serialize)]
57pub struct ShellSummary {
58 pub shell_id: String,
59 pub block_id: String,
60 pub cmd: String,
61 pub title: String,
62 pub started_at: u64,
63 pub line_count: u64,
64}
65
66// Per-shell registry entry: stop handle + optional stdin channel (Phase 3b).
67// The stdin_tx is an mpsc sender to a relay task that owns the real ChildStdin.
68// Sending text is non-blocking (unbounded channel); the relay handles the
69// actual write. When all senders are dropped, the channel closes, the relay
70// exits, ChildStdin is dropped, and the child sees EOF on stdin.
71struct ShellEntry {
72 stop_tx: oneshot::Sender<()>,
73 // None for entries created by unit tests (no relay task / ChildStdin).
74 stdin_tx: Option<mpsc::UnboundedSender<String>>,
75}
76
77/// Per-shell stop handles + status. `ShellStop` fires the oneshot; `ShellInput`
78/// sends to the stdin relay; `ShellStatus` reads the live status arc.
79/// Lives in `AppState.shell_sessions`.
80/// Max number of EXITED shell statuses retained for post-exit ShellStatus
81/// queries. Running shells are always kept regardless of this cap; only
82/// exited entries beyond it are evicted oldest-first. Bounds memory for
83/// long-lived sidecars that spawn many short shells.
84const MAX_EXITED_STATUS: usize = 512;
85
86#[derive(Default)]
87pub struct ShellSessionRegistry {
88 shells: Mutex<HashMap<String, ShellEntry>>,
89 // Status entries persist after exit so ShellStatus can query final
90 // exit_code/line_count. Exited entries are capped at MAX_EXITED_STATUS
91 // (oldest-first eviction via status_order); running entries are never
92 // evicted. stop_all() clears everything on srv shutdown.
93 status_map: Mutex<HashMap<String, Arc<Mutex<ShellStatusInfo>>>>,
94 // Spawn-order queue of shell_ids, used to evict the oldest exited status
95 // entries first when over the cap.
96 status_order: Mutex<VecDeque<String>>,
97}
98
99impl ShellSessionRegistry {
100 pub fn new() -> Arc<Self> {
101 Arc::new(Self::default())
102 }
103
104 // Used by unit tests — registers a stop handle with no stdin relay/status.
105 #[cfg(test)]
106 fn insert(&self, shell_id: String, tx: oneshot::Sender<()>) {
107 self.shells.lock().insert(shell_id, ShellEntry { stop_tx: tx, stdin_tx: None });
108 }
109
110 // Full registration called by the runner after spawning the child.
111 // stdin_tx is None when capture_stdin is false (stdin is /dev/null).
112 // Returns shell_ids evicted by the prune pass — the caller (which holds
113 // the Broker, unlike this registry) purges their `shell:<id>` persisted
114 // MuxEvent history so the broker's persist_map key set stays bounded
115 // in step with this registry's own MAX_EXITED_STATUS cap.
116 //
117 // `pub(crate)` (rather than private) so `server::shell_handlers`'s own
118 // tests can seed a running/exited shell directly, without spawning a
119 // real child process, to exercise the `shellstatus` RPC handler's JSON
120 // shape end-to-end for both branches — the same pre-tested primitive
121 // this module's own tests already use, not duplicated logic.
122 pub(crate) fn register_full(
123 &self,
124 shell_id: String,
125 stop_tx: oneshot::Sender<()>,
126 stdin_tx: Option<mpsc::UnboundedSender<String>>,
127 status: Arc<Mutex<ShellStatusInfo>>,
128 ) -> Vec<String> {
129 self.shells.lock().insert(shell_id.clone(), ShellEntry { stop_tx, stdin_tx });
130 self.status_map.lock().insert(shell_id.clone(), status);
131 self.status_order.lock().push_back(shell_id);
132 // Bound retained exited statuses; every spawn is a natural prune trigger.
133 self.prune_exited_statuses()
134 }
135
136 /// Evict oldest EXITED status entries beyond `MAX_EXITED_STATUS`. Running
137 /// shells are always retained (their status is read live via ShellStatus).
138 /// Also drops any order-queue ids whose status entry is already gone.
139 /// Returns the shell_ids actually evicted (status entry removed) so the
140 /// caller can purge their broker-side persisted history too.
141 fn prune_exited_statuses(&self) -> Vec<String> {
142 let mut map = self.status_map.lock();
143 let mut order = self.status_order.lock();
144 let mut evicted = Vec::new();
145 let mut exited = map
146 .values()
147 .filter(|arc| !arc.lock().running)
148 .count();
149 let mut i = 0;
150 while exited > MAX_EXITED_STATUS && i < order.len() {
151 let id = order[i].clone();
152 match map.get(&id) {
153 // Exited entry over the cap → evict it.
154 Some(arc) if !arc.lock().running => {
155 map.remove(&id);
156 order.remove(i);
157 exited -= 1;
158 evicted.push(id);
159 }
160 // Still running → keep in place, advance.
161 Some(_) => i += 1,
162 // Stale order id (already removed) → drop from queue.
163 None => {
164 order.remove(i);
165 }
166 }
167 }
168 evicted
169 }
170
171 /// Request stop of a running shell. Returns false if unknown or already exited.
172 /// Dropping the stdin_tx here closes the relay channel → ChildStdin drops →
173 /// child sees EOF, unblocking any stdin-reading process before the kill.
174 pub fn stop(&self, shell_id: &str) -> bool {
175 // Do NOT remove from status_map here — the runner task is still live
176 // and will persist the final exit status after child.wait() returns.
177 if let Some(entry) = self.shells.lock().remove(shell_id) {
178 // stdin_tx dropped here → relay closes → child gets stdin EOF
179 let _ = entry.stop_tx.send(());
180 true
181 } else {
182 false
183 }
184 }
185
186 /// Called by the runner on natural exit (stdout/stderr closed). Removes the
187 /// shells entry (dropping stdin_tx → child gets stdin EOF). Status entry is
188 /// intentionally kept so ShellStatus can query the final exit code/line count.
189 fn remove(&self, shell_id: &str) {
190 self.shells.lock().remove(shell_id);
191 // status_map entry is NOT removed — it persists so get_status() can
192 // return the final running:false / exit_code / line_count after exit.
193 }
194
195 /// Stop every running shell. For srv-shutdown cleanup.
196 pub fn stop_all(&self) -> usize {
197 self.status_map.lock().clear();
198 self.status_order.lock().clear();
199 let drained: Vec<_> = self.shells.lock().drain().map(|(_, e)| e.stop_tx).collect();
200 let n = drained.len();
201 for tx in drained {
202 let _ = tx.send(());
203 }
204 n
205 }
206
207 /// Resolve where a `ShellInput` write should go, distinguishing two
208 /// failure modes:
209 /// - `Ok(tx)` — running with captured stdin
210 /// - `Err(StdinNotCaptured)` — running but created without capture_stdin
211 /// - `Err(NotRunning)` — unknown id or already exited
212 pub fn resolve_stdin(
213 &self,
214 shell_id: &str,
215 ) -> Result<mpsc::UnboundedSender<String>, ShellInputFailure> {
216 let shells = self.shells.lock();
217 match shells.get(shell_id) {
218 Some(entry) => match &entry.stdin_tx {
219 Some(tx) => Ok(tx.clone()),
220 None => Err(ShellInputFailure::StdinNotCaptured),
221 },
222 None => Err(ShellInputFailure::NotRunning),
223 }
224 }
225
226 /// Return a snapshot of the shell's status. Returns `running: false` if unknown.
227 /// Existing, documented contract for `POST /api/v1/shell/status` (the MCP
228 /// `ShellStatus` tool) — do not change: an agent calling this for an id
229 /// it doesn't recognize expects a plain "not running" answer, not an error.
230 pub fn get_status(&self, shell_id: &str) -> ShellStatusInfo {
231 self.status_map.lock()
232 .get(shell_id)
233 .map(|arc| arc.lock().clone())
234 .unwrap_or_default()
235 }
236
237 /// Like [`Self::get_status`], but `None` when no entry exists at all —
238 /// distinguishing "genuinely unknown" from "known, and not running."
239 ///
240 /// Needed by the `shellstatus` RPC command (unlike the MCP tool above,
241 /// which is fine collapsing "unknown" into "not running"): `register_full`
242 /// only runs after the runner task spawns the child process, but
243 /// `shell_node_create` is published to the frontend BEFORE that task is
244 /// even scheduled (`handle_shell_create` in server/mod.rs). A caller that
245 /// checks status immediately after seeing `shell_node_create` can race
246 /// ahead of registration — reagent P1 on PR #2770: collapsing that race
247 /// window into `running: false` was indistinguishable from a genuinely
248 /// already-exited shell, so a fast, still-registering, genuinely LIVE
249 /// shell (e.g. a real `task dev`) could be shown as failed in the
250 /// Activity Dock for its entire run.
251 pub(crate) fn get_status_if_known(&self, shell_id: &str) -> Option<ShellStatusInfo> {
252 self.status_map.lock().get(shell_id).map(|arc| arc.lock().clone())
253 }
254
255 /// List every currently-RUNNING shell across all blocks — the Swarm
256 /// pane's data source for its per-agent long-running-process rows
257 /// (SPEC_SWARM_LONG_RUNNING_PROCESS_ROWS_2026_07_20). Unfiltered, like
258 /// `subagent.ListActive`/`ListDispatches` — the Swarm pane fetches once
259 /// and groups by `block_id` client-side in `buildTree()`, rather than
260 /// one RPC per tracked agent block. Exited shells are intentionally
261 /// excluded: Phase 1's scope is "what's happening now," not a shell
262 /// history view.
263 pub fn list_active(&self) -> Vec<ShellSummary> {
264 self.status_map
265 .lock()
266 .iter()
267 .filter_map(|(shell_id, arc)| {
268 let s = arc.lock();
269 if s.running {
270 Some(ShellSummary {
271 shell_id: shell_id.clone(),
272 block_id: s.block_id.clone(),
273 cmd: s.cmd.clone(),
274 title: s.title.clone(),
275 started_at: s.started_at,
276 line_count: s.line_count,
277 })
278 } else {
279 None
280 }
281 })
282 .collect()
283 }
284}
285
286/// Kill the entire process tree rooted at `pid`. `Child::kill` / `kill_on_drop`
287/// only reap the wrapper shell (`cmd /C` / `sh -c`); a `task dev` spawns
288/// `task.exe` → `cargo`/`node` grandchildren that survive otherwise.
289///
290/// Scoped strictly to OUR `pid` — never by image name. Killing `task.exe`
291/// by name is the cross-instance hazard that let agent "Mazs" nuke peer
292/// dev servers (see SPEC_PERSISTENT_SHELL_PHASE3_STOP §1).
293fn kill_tree(pid: u32) {
294 // Group-kill with SIGTERM → 300ms → SIGKILL escalation on Unix (the child
295 // is spawned via process_group(0), so the negative-pid signal reaches its
296 // descendants); `taskkill /F /T /PID` on Windows. The implementation is
297 // shared with the other by-PID kill sites in `agentmux_common::process`.
298 agentmux_common::process::kill_process_group(pid);
299}
300
301pub struct ShellNodeRunner {
302 pub shell_id: String,
303 pub block_id: String,
304 pub cmd: String,
305 /// Caller-supplied display title, defaulting to `cmd` — mirrors
306 /// `handle_shell_create`'s own `title` local, threaded through so
307 /// `ShellStatusInfo`/`list_active` can show it without a second lookup.
308 pub title: String,
309 pub cwd: Option<String>,
310 /// The program and arguments when the command runs elsewhere: `wsl.exe`
311 /// for a WSL distro (`remote::wsl::launch`), `ssh` for an SSH host
312 /// (`remote::ssh::launch_exec`). `None` runs it here through `cmd`/`sh`.
313 pub launch: Option<(String, Vec<String>)>,
314 /// The askpass secret of an SSH command (`remote::askpass`), revoked when
315 /// this runner ends, however it ends.
316 pub askpass_secret: Option<String>,
317 pub extra_env: HashMap<String, String>,
318 pub broker: Arc<Broker>,
319 /// Stop registry — the runner registers its `shell_id` here so
320 /// `ShellStop` can tree-kill it, and removes itself on natural exit.
321 pub registry: Arc<ShellSessionRegistry>,
322 /// If true, pipe stdin and start the relay task so ShellInput() works.
323 /// If false (default), stdin is /dev/null — avoids blocking programs that
324 /// read stdin to EOF (e.g. `cat` with no args).
325 pub capture_stdin: bool,
326}
327
328impl ShellNodeRunner {
329 pub async fn run(self) {
330 let shell_id = self.shell_id.clone();
331 let block_id = self.block_id.clone();
332 let broker = self.broker.clone();
333
334 let _askpass = self.askpass_secret.clone().map(crate::backend::remote::askpass::Revoke);
335 let mut child_cmd = if let Some((program, args)) = &self.launch {
336 let mut c = tokio::process::Command::new(program);
337 c.args(args);
338 c
339 } else if cfg!(windows) {
340 let mut c = tokio::process::Command::new("cmd");
341 c.args(["/C", &self.cmd]);
342 c
343 } else {
344 let mut c = tokio::process::Command::new("sh");
345 c.args(["-c", &self.cmd]);
346 c
347 };
348
349 // This runner backs POST /api/v1/shell/create — the MCP `Shell` tool,
350 // which CLAUDE.md names as the way for an agent to launch `task dev` /
351 // `task package`. Without this it inherited agentmux-srv's full
352 // AGENTMUX_* environment, so a build started here adopted the LAUNCHING
353 // instance's channel and data dir: the exact failure this is meant to
354 // prevent, on the most likely path for it to happen. Missed by the
355 // first revision of the fix and caught in review (ReAgent P0 on #3326).
356 crate::backend::pane_env::sanitize_process_command(&mut child_cmd);
357
358 // Only set the working directory if it actually exists. The cwd is
359 // normalized upstream (handle_shell_create → base::normalize_working_dir),
360 // but a stale or mistyped path would otherwise make the spawn fail hard
361 // with os error 267 (ERROR_DIRECTORY) on Windows. Like the agent-CLI
362 // spawn (blockcontroller/core.rs::apply_working_dir, which skips
363 // `current_dir` when the directory is missing and can't be created),
364 // warn via a system chunk and run in the server's cwd rather than
365 // killing the shell before it starts.
366 if let Some(ref cwd) = self.cwd {
367 if std::path::Path::new(cwd).is_dir() {
368 child_cmd.current_dir(cwd);
369 } else {
370 publish_chunk(
371 &broker,
372 &block_id,
373 &shell_id,
374 "system",
375 &format!("[cwd not found: {cwd} — running in the server's working directory]"),
376 now_ms(),
377 );
378 }
379 }
380 for (k, v) in &self.extra_env {
381 child_cmd.env(k, v);
382 }
383
384 child_cmd.stdout(std::process::Stdio::piped());
385 child_cmd.stderr(std::process::Stdio::piped());
386 // stdin: piped only when capture_stdin=true (opt-in). Default is null so
387 // programs that read stdin to EOF (e.g. `cat` with no args) don't block
388 // forever. When piped, a relay task forwards ShellInput() writes; when all
389 // senders drop the relay exits, ChildStdin drops, and the child sees EOF.
390 if self.capture_stdin {
391 child_cmd.stdin(std::process::Stdio::piped());
392 } else {
393 child_cmd.stdin(std::process::Stdio::null());
394 }
395 // Windows: suppress the console window that Windows auto-creates for
396 // CUI-subsystem processes (cmd.exe). stdout/stderr are piped so no
397 // output is lost — the window was decorative noise only.
398 #[cfg(windows)]
399 {
400 child_cmd.no_window();
401 }
402 // Backstop: reap the wrapper shell if this runner task is ever dropped.
403 child_cmd.kill_on_drop(true);
404 // Unix: own process group so kill_tree can signal the whole group
405 // (-pgid). Windows uses taskkill /T on the pid instead.
406 #[cfg(unix)]
407 {
408 child_cmd.process_group(0);
409 }
410
411 // Joins the agent's tracker before it runs, so the shell and everything
412 // it starts (`task dev` → task.exe/node) end with the agent on every
413 // teardown path (agent_teardown spec §6.5).
414 let mut child = match crate::backend::process_tracker::registry::spawn_tracked(&block_id, &mut child_cmd, crate::backend::process_tracker::registry::Join::Adopted) {
415 Ok(c) => c,
416 Err(e) => {
417 publish_chunk(&broker, &block_id, &shell_id, "system", &format!("[spawn error: {e}]"), now_ms());
418 publish_exit(&broker, &block_id, &shell_id, 1, false, now_ms());
419 return;
420 }
421 };
422
423 let pid = child.id();
424 tracing::info!(shell_id = %shell_id, pid = ?pid, capture_stdin = self.capture_stdin, "shell.spawn");
425
426 // Spawn stdin relay only when capture_stdin=true.
427 let stdin_tx: Option<mpsc::UnboundedSender<String>> = if self.capture_stdin {
428 let child_stdin = child.stdin.take().expect("stdin piped");
429 let (tx, mut rx) = mpsc::unbounded_channel::<String>();
430 tokio::spawn(async move {
431 let mut writer = BufWriter::new(child_stdin);
432 while let Some(text) = rx.recv().await {
433 if writer.write_all(text.as_bytes()).await.is_err() { break; }
434 if writer.flush().await.is_err() { break; }
435 }
436 // All senders dropped or write error → ChildStdin dropped → child EOF
437 });
438 Some(tx)
439 } else {
440 None
441 };
442
443 let status_arc = Arc::new(Mutex::new(ShellStatusInfo {
444 running: true,
445 exit_code: None,
446 line_count: 0,
447 block_id: block_id.clone(),
448 cmd: self.cmd.clone(),
449 title: self.title.clone(),
450 started_at: now_ms(),
451 }));
452 let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
453 let evicted = self.registry.register_full(
454 shell_id.clone(),
455 cancel_tx,
456 stdin_tx,
457 Arc::clone(&status_arc),
458 );
459 for evicted_id in evicted {
460 broker.purge_scope(&format!("shell:{evicted_id}"));
461 }
462 let kill_task = tokio::spawn(async move {
463 match cancel_rx.await {
464 Ok(()) => {
465 if let Some(pid) = pid {
466 let _ = tokio::task::spawn_blocking(move || kill_tree(pid)).await;
467 }
468 true // stopped by request
469 }
470 Err(_) => false, // sender dropped → natural exit
471 }
472 });
473
474 let stdout = child.stdout.take().expect("stdout piped");
475 let stderr = child.stderr.take().expect("stderr piped");
476
477 // Collect both stdout and stderr into a single ordered channel.
478 // Each task owns its half of the pipe; the channel closes when both
479 // tasks finish, at which point the main loop exits and we wait for exit.
480 type Line = (&'static str, String, u64);
481 let (tx, mut rx) = mpsc::unbounded_channel::<Line>();
482
483 let tx_out = tx.clone();
484 let t_stdout = tokio::spawn(async move {
485 let mut lines = BufReader::new(stdout).lines();
486 while let Ok(Some(line)) = lines.next_line().await {
487 let ts = now_ms();
488 let _ = tx_out.send(("stdout", line, ts));
489 }
490 });
491
492 let tx_err = tx.clone();
493 let t_stderr = tokio::spawn(async move {
494 let mut lines = BufReader::new(stderr).lines();
495 while let Ok(Some(line)) = lines.next_line().await {
496 let ts = now_ms();
497 let _ = tx_err.send(("stderr", line, ts));
498 }
499 });
500
501 // Drop original sender so channel closes once both reader tasks finish.
502 drop(tx);
503
504 let mut line_count: u64 = 0;
505 while let Some((kind, content, ts)) = rx.recv().await {
506 line_count += 1;
507 status_arc.lock().line_count = line_count;
508 publish_chunk(&broker, &block_id, &shell_id, kind, &content, ts);
509 }
510
511 let _ = tokio::join!(t_stdout, t_stderr);
512
513 // Drop our stop handle (no-op if ShellStop already removed it) so the
514 // natural-exit path lets `kill_task` resolve.
515 self.registry.remove(&shell_id);
516
517 let exit_code = match child.wait().await {
518 Ok(status) => status.code().unwrap_or(-1),
519 Err(_) => -1,
520 };
521
522 let was_stopped = kill_task.await.unwrap_or(false);
523 tracing::info!(shell_id = %shell_id, exit_code, was_stopped, line_count, "shell.exit");
524
525 // Persist final status in the status_map so ShellStatus can still query it.
526 {
527 let mut s = status_arc.lock();
528 s.running = false;
529 s.exit_code = Some(exit_code);
530 s.line_count = line_count;
531 }
532
533 publish_exit(&broker, &block_id, &shell_id, exit_code, was_stopped, now_ms());
534 }
535}
536
537// Every shell_chunk / exit event is published under a SINGLE scope:
538// - `shell:<shell_id>` → a per-shell persistence ring buffer (1024 entries).
539//
540// Single-scope delivery (chosen over the fallback of strengthening the reducer's
541// dedup): an earlier design also published every chunk under `block:<block_id>`
542// for "live" delivery, so output produced before the frontend's `shell:<id>`
543// subscription established (the common case — process spawn + first lines beat
544// the WS resub round-trip) arrived LIVE via the block scope AND then again in the
545// replay burst when the broker replayed the whole `shell:<id>` ring on first
546// subscribe. The reducer's last-chunk-only `isDuplicate` let those non-adjacent
547// dups through → doubled output (full duplication on WS reconnect).
548//
549// Now chunks/exit go ONLY to `shell:<shell_id>`. The frontend subscribes to that
550// scope when it sees the (block-scoped, persist:64) `shell_node_create`. Because
551// the broker persists the ring regardless of subscribers (mps.rs persist_event
552// runs inside publish whenever persist>0), any output produced before the
553// subscription establishes is retained in the persist:1024 ring and replayed
554// exactly once on subscribe (guarded by the broker's per-route+event+scope
555// `replayed` set). No chunk is ever delivered via two paths → no duplication.
556//
557// This still preserves the reason the per-shell ring was introduced: each shell
558// has its OWN ring, so a chatty shell can't evict another shell's `exit` event
559// (the bug that left a sibling's row stuck `running` after a remount when all
560// shells shared the block ring).
561fn shell_scopes(shell_id: &str) -> Vec<String> {
562 vec![format!("shell:{shell_id}")]
563}
564
565/// `shell_chunk`: one line a Shell command wrote, or its exit. Scoped `shell:<shell_id>`.
566#[derive(Debug, Clone, Serialize, ts_rs::TS)]
567#[ts(export, export_to = "../../../frontend/types/rpc/")]
568#[serde(tag = "op", rename_all = "lowercase")]
569pub enum ShellChunkEvent {
570 Chunk {
571 shell_id: String,
572 #[ts(type = r#""stdout" | "stderr" | "system""#)]
573 kind: String,
574 content: String,
575 #[ts(type = "number")]
576 timestamp: u64,
577 },
578 Exit {
579 shell_id: String,
580 exit_code: i32,
581 /// The exit was caused by ShellStop (tree-killed), so the frontend shows the grey
582 /// "stopped" status instead of exited-err.
583 stopped: bool,
584 #[ts(type = "number")]
585 timestamp: u64,
586 },
587}
588
589/// `shell_node_create`: a Shell command started, so the agent pane adds its row before the first
590/// chunk. Scoped `block:<agent block>`.
591#[derive(Debug, Clone, Serialize, ts_rs::TS)]
592#[ts(export, export_to = "../../../frontend/types/rpc/")]
593pub struct ShellNodeCreateEvent {
594 pub shell_id: String,
595 pub cmd: String,
596 pub cwd: Option<String>,
597 pub title: String,
598 #[ts(type = "number")]
599 pub timestamp: u64,
600}
601
602fn publish_chunk(broker: &Broker, _block_id: &str, shell_id: &str, kind: &str, content: &str, ts: u64) {
603 let event = ShellChunkEvent::Chunk { shell_id: shell_id.to_string(), kind: kind.to_string(), content: content.to_string(), timestamp: ts };
604 broker.publish_event::<crate::backend::mps_events::ShellChunk>(&event, shell_scopes(shell_id), 1024);
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610
611 #[test]
612 fn shell_events_serialize_as_before() {
613 let chunk = ShellChunkEvent::Chunk { shell_id: "s1".into(), kind: "stdout".into(), content: "ok".into(), timestamp: 5 };
614 assert_eq!(
615 serde_json::to_value(&chunk).unwrap(),
616 serde_json::json!({ "shell_id": "s1", "op": "chunk", "kind": "stdout", "content": "ok", "timestamp": 5 })
617 );
618 let exit = ShellChunkEvent::Exit { shell_id: "s1".into(), exit_code: 1, stopped: true, timestamp: 6 };
619 assert_eq!(
620 serde_json::to_value(&exit).unwrap(),
621 serde_json::json!({ "shell_id": "s1", "op": "exit", "exit_code": 1, "stopped": true, "timestamp": 6 })
622 );
623 let created = ShellNodeCreateEvent { shell_id: "s1".into(), cmd: "npm test".into(), cwd: None, title: "npm test".into(), timestamp: 7 };
624 assert_eq!(
625 serde_json::to_value(&created).unwrap(),
626 serde_json::json!({ "shell_id": "s1", "cmd": "npm test", "cwd": null, "title": "npm test", "timestamp": 7 })
627 );
628 }
629
630 #[tokio::test]
631 async fn stop_signals_then_is_idempotent() {
632 let reg = ShellSessionRegistry::new();
633 let (tx, rx) = oneshot::channel::<()>();
634 reg.insert("s1".to_string(), tx);
635 assert!(reg.stop("s1")); // fires the channel
636 assert!(rx.await.is_ok());
637 assert!(!reg.stop("s1")); // second stop: unknown id
638 }
639
640 #[tokio::test]
641 async fn remove_drops_sender_without_signalling() {
642 let reg = ShellSessionRegistry::new();
643 let (tx, rx) = oneshot::channel::<()>();
644 reg.insert("s2".to_string(), tx);
645 reg.remove("s2"); // natural-exit path
646 assert!(rx.await.is_err()); // sender dropped → Err, not Ok
647 assert!(!reg.stop("s2"));
648 }
649
650 #[tokio::test]
651 async fn stop_all_fires_every_handle() {
652 let reg = ShellSessionRegistry::new();
653 let (tx1, rx1) = oneshot::channel::<()>();
654 let (tx2, rx2) = oneshot::channel::<()>();
655 reg.insert("a".to_string(), tx1);
656 reg.insert("b".to_string(), tx2);
657 reg.stop_all();
658 assert!(rx1.await.is_ok());
659 assert!(rx2.await.is_ok());
660 }
661
662 fn register_exited(reg: &ShellSessionRegistry, id: &str, running: bool) {
663 let (tx, _rx) = oneshot::channel::<()>();
664 let status = Arc::new(Mutex::new(ShellStatusInfo {
665 running,
666 exit_code: if running { None } else { Some(0) },
667 line_count: 1,
668 ..Default::default()
669 }));
670 // _rx is intentionally dropped; we only exercise the status-map cap.
671 reg.register_full(id.to_string(), tx, None, status);
672 }
673
674 #[tokio::test]
675 async fn exited_statuses_are_capped_oldest_first() {
676 let reg = ShellSessionRegistry::new();
677 let total = MAX_EXITED_STATUS + 10;
678 for i in 0..total {
679 register_exited(®, &format!("s{i}"), false);
680 }
681 // Never exceeds the cap.
682 assert_eq!(reg.status_map.lock().len(), MAX_EXITED_STATUS);
683 // Oldest 10 evicted → get_status returns default (exit_code None).
684 assert!(reg.get_status("s0").exit_code.is_none());
685 assert!(reg.get_status("s9").exit_code.is_none());
686 // Newest retained with its real exit code.
687 assert_eq!(reg.get_status(&format!("s{}", total - 1)).exit_code, Some(0));
688 }
689
690 #[tokio::test]
691 async fn running_statuses_are_never_evicted() {
692 let reg = ShellSessionRegistry::new();
693 let total = MAX_EXITED_STATUS + 5;
694 for i in 0..total {
695 register_exited(®, &format!("r{i}"), true); // all running
696 }
697 // Running shells exceed the exited cap but none are evicted.
698 assert_eq!(reg.status_map.lock().len(), total);
699 assert!(reg.get_status("r0").running);
700 }
701
702 fn register_running_for_block(reg: &ShellSessionRegistry, id: &str, block_id: &str, cmd: &str) {
703 let (tx, _rx) = oneshot::channel::<()>();
704 let status = Arc::new(Mutex::new(ShellStatusInfo {
705 running: true,
706 block_id: block_id.to_string(),
707 cmd: cmd.to_string(),
708 title: cmd.to_string(),
709 started_at: 1_000,
710 ..Default::default()
711 }));
712 reg.register_full(id.to_string(), tx, None, status);
713 }
714
715 #[tokio::test]
716 async fn list_active_returns_only_running_shells_across_all_blocks() {
717 let reg = ShellSessionRegistry::new();
718 register_running_for_block(®, "s1", "block-a", "npm run dev");
719 register_running_for_block(®, "s2", "block-b", "task dev");
720 register_exited(®, "s3", false); // exited — must not appear
721
722 let mut active = reg.list_active();
723 active.sort_by(|a, b| a.shell_id.cmp(&b.shell_id));
724
725 assert_eq!(active.len(), 2);
726 assert_eq!(active[0].shell_id, "s1");
727 assert_eq!(active[0].block_id, "block-a");
728 assert_eq!(active[0].cmd, "npm run dev");
729 assert_eq!(active[1].shell_id, "s2");
730 assert_eq!(active[1].block_id, "block-b");
731 }
732}
733
734fn publish_exit(broker: &Broker, _block_id: &str, shell_id: &str, exit_code: i32, stopped: bool, ts: u64) {
735 let event = ShellChunkEvent::Exit { shell_id: shell_id.to_string(), exit_code, stopped, timestamp: ts };
736 broker.publish_event::<crate::backend::mps_events::ShellChunk>(&event, shell_scopes(shell_id), 1024);
737}