Skip to main content

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(&reg, &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(&reg, &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(&reg, "s1", "block-a", "npm run dev");
719        register_running_for_block(&reg, "s2", "block-b", "task dev");
720        register_exited(&reg, "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}