Skip to main content

bamboo_tools/tools/
bash_runtime.rs

1use bamboo_agent_core::{AgentEvent, BashCompletionInfo, BashCompletionSink};
2use bamboo_infrastructure::process::{
3    build_command_environment, decode_process_line_lossy, hide_window_for_tokio_command,
4    preferred_bash_shell, trace_windows_command, CommandEnvironmentDiagnostics,
5};
6use dashmap::DashMap;
7use regex::Regex;
8use std::path::Path;
9use std::process::Stdio;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::{Arc, OnceLock};
12use tokio::io::AsyncBufReadExt;
13use tokio::io::AsyncWriteExt;
14use tokio::io::BufReader;
15use tokio::process::{Child, ChildStdin, Command};
16use tokio::sync::mpsc;
17use tokio::sync::Mutex;
18use tokio::sync::Notify;
19use tokio::time::{sleep, timeout, Duration};
20use tracing::warn;
21
22/// Per-stream line cap for a background shell's captured output, AND for the
23/// foreground promotion-seed buffers (`bash.rs`). Shared so a chatty command
24/// can't balloon memory before it promotes (issue #84, phase 2d).
25pub(crate) const MAX_OUTPUT_LINES: usize = 20_000;
26const COMPLETED_SESSION_TTL_SECS: u64 = 300;
27/// Trailing captured lines carried on a background-Bash completion push so the
28/// model sees the result without a mandatory `BashOutput` round-trip (issue #84
29/// Phase 2b follow-up). The full output is still available via `BashOutput`.
30const COMPLETION_TAIL_LINES: usize = 50;
31/// Byte ceiling on that tail; the most recent bytes are kept (front-trimmed on a
32/// char boundary) so a chatty final line can't bloat the injected message.
33const COMPLETION_TAIL_MAX_BYTES: usize = 4096;
34/// Upper bound on a single `write_stdin` so a wedged consumer (full pipe
35/// buffer, child not draining) cannot pin the stdin mutex — and thus block any
36/// queued writer — indefinitely. A timeout surfaces a clear error instead.
37const STDIN_WRITE_TIMEOUT: Duration = Duration::from_secs(30);
38
39#[derive(Debug)]
40pub struct ShellSession {
41    pub id: String,
42    pub command: String,
43    /// Bamboo session id that owns this background shell, if any. Set from the
44    /// dispatch context (issue #84, phase 2a) so the registry can be queried
45    /// per-session. `None` means the shell is untagged (e.g. spawned from tests).
46    pub session_id: Option<String>,
47    pub environment: CommandEnvironmentDiagnostics,
48    /// Kill request for the still-running child. The child handle itself is owned
49    /// by the completion task, which awaits its exit via `child.wait()` (truly
50    /// event-driven — no polling). Because that task holds the handle across the
51    /// await, a kill cannot lock the handle without deadlocking; instead `kill()`
52    /// fires this `Notify` and the completion task's `select!` reaps the child.
53    kill_notify: Arc<Notify>,
54    /// Retained stdin handle for an interactive shell (issue #89). `Some` only
55    /// when the shell was spawned with `interactive: true` (a piped stdin);
56    /// `None` for every non-interactive shell so the default EOF-on-read
57    /// behavior is byte-for-byte unchanged. Guarded by a Mutex so `write_stdin`
58    /// can borrow it without racing the completion poll.
59    stdin: Arc<Mutex<Option<ChildStdin>>>,
60    output: Arc<Mutex<Vec<String>>>,
61    base_index: Arc<Mutex<usize>>,
62    running: Arc<AtomicBool>,
63    exit_code: Arc<Mutex<Option<i32>>>,
64}
65
66impl ShellSession {
67    pub fn status(&self) -> &'static str {
68        if self.running.load(Ordering::Relaxed) {
69            "running"
70        } else {
71            "completed"
72        }
73    }
74
75    pub async fn exit_code(&self) -> Option<i32> {
76        *self.exit_code.lock().await
77    }
78
79    pub async fn read_output_since(
80        &self,
81        cursor: usize,
82        filter: Option<&Regex>,
83    ) -> (Vec<String>, usize, usize) {
84        let output = self.output.lock().await;
85        let base_index = self.base_index.lock().await;
86
87        let base = *base_index;
88        let effective_cursor = cursor.max(base);
89        let dropped_lines = effective_cursor.saturating_sub(cursor);
90        let start = effective_cursor.saturating_sub(base);
91        let new_lines = if start >= output.len() {
92            Vec::new()
93        } else {
94            output[start..]
95                .iter()
96                .filter(|line| filter.map(|re| re.is_match(line)).unwrap_or(true))
97                .cloned()
98                .collect()
99        };
100
101        let next_cursor = base + output.len();
102        (new_lines, next_cursor, dropped_lines)
103    }
104
105    /// Request termination of the background shell. Flips `running` optimistically
106    /// so [`Self::status`] reflects the kill immediately, then signals the
107    /// completion task (the sole owner of the child handle) to `start_kill` and
108    /// reap. The real exit code + the completion event/push are recorded by that
109    /// task when the process is reaped. Never locks the child handle — see
110    /// [`ShellSession::kill_notify`].
111    #[allow(clippy::unused_async)] // async kept: callers `.await` it; symmetry with the other handle ops.
112    pub async fn kill(&self) -> Result<(), String> {
113        self.running.store(false, Ordering::Relaxed);
114        self.kill_notify.notify_one();
115        Ok(())
116    }
117
118    /// Write `data` to the shell's retained stdin pipe (issue #89). When
119    /// `append_newline` is true a trailing `\n` is appended so the input is
120    /// delivered as a complete line (e.g. to satisfy a line-oriented prompt).
121    ///
122    /// Returns a clear error — never panics — when the shell was NOT spawned
123    /// interactive (no stdin pipe to write to) or when the write/flush fails
124    /// (the process has exited and the pipe is closed). Callers must not treat
125    /// either case as fatal: a non-interactive shell simply has no stdin, and an
126    /// exited shell's pipe is gone.
127    pub async fn write_stdin(&self, data: &str, append_newline: bool) -> Result<(), String> {
128        let mut guard = self.stdin.lock().await;
129        let stdin = guard.as_mut().ok_or_else(|| {
130            format!(
131                "Shell '{}' has no interactive stdin pipe; spawn it via Bash with interactive=true",
132                self.id
133            )
134        })?;
135        let mut bytes = data.as_bytes().to_vec();
136        if append_newline {
137            bytes.push(b'\n');
138        }
139        // Bound the write+flush so a consumer that has stopped reading (full
140        // pipe buffer) cannot hold the stdin mutex — and thus block any queued
141        // writer — forever. A timeout surfaces a clear error instead of a hang.
142        timeout(STDIN_WRITE_TIMEOUT, stdin.write_all(&bytes))
143            .await
144            .map_err(|_| {
145                format!(
146                    "Timed out after {}s writing to stdin of shell '{}' (consumer not draining)",
147                    STDIN_WRITE_TIMEOUT.as_secs(),
148                    self.id
149                )
150            })?
151            .map_err(|e| format!("Failed to write to stdin of shell '{}': {}", self.id, e))?;
152        timeout(STDIN_WRITE_TIMEOUT, stdin.flush())
153            .await
154            .map_err(|_| {
155                format!(
156                    "Timed out after {}s flushing stdin of shell '{}'",
157                    STDIN_WRITE_TIMEOUT.as_secs(),
158                    self.id
159                )
160            })?
161            .map_err(|e| format!("Failed to flush stdin of shell '{}': {}", self.id, e))?;
162        Ok(())
163    }
164
165    /// Close the retained stdin pipe (issue #89), sending end-of-file to the
166    /// child. Dropping the `ChildStdin` handle closes the pipe's write end, so
167    /// a consumer that reads stdin until EOF (e.g. `cat`, `sort`, a REPL) can
168    /// terminate normally instead of running until killed.
169    ///
170    /// Idempotent: a no-op on a non-interactive shell (stdin was already
171    /// `None`) or one whose stdin was already closed. Returns `true` when a
172    /// handle was actually taken (i.e. this was an interactive shell whose
173    /// stdin was still open), `false` otherwise, so callers can distinguish.
174    pub async fn close_stdin(&self) -> bool {
175        self.stdin.lock().await.take().is_some()
176    }
177}
178
179fn sessions() -> &'static DashMap<String, Arc<ShellSession>> {
180    static SESSIONS: OnceLock<DashMap<String, Arc<ShellSession>>> = OnceLock::new();
181    SESSIONS.get_or_init(DashMap::new)
182}
183
184async fn push_line(output: &Arc<Mutex<Vec<String>>>, base_index: &Arc<Mutex<usize>>, line: String) {
185    let mut buffer = output.lock().await;
186    buffer.push(line);
187    if buffer.len() > MAX_OUTPUT_LINES {
188        let overflow = buffer.len() - MAX_OUTPUT_LINES;
189        buffer.drain(0..overflow);
190        let mut base = base_index.lock().await;
191        *base += overflow;
192    }
193}
194
195async fn pump_stream_lines<T>(
196    stream_name: &'static str,
197    reader: T,
198    output: Arc<Mutex<Vec<String>>>,
199    base_index: Arc<Mutex<usize>>,
200) where
201    T: tokio::io::AsyncRead + Unpin,
202{
203    let mut reader = BufReader::new(reader);
204    let mut line_bytes = Vec::new();
205
206    loop {
207        line_bytes.clear();
208        match reader.read_until(b'\n', &mut line_bytes).await {
209            Ok(0) => break,
210            Ok(_) => {
211                let line = decode_process_line_lossy(&mut line_bytes);
212                push_line(&output, &base_index, line).await;
213            }
214            Err(e) => {
215                warn!("Background shell {stream_name} read failed: {e}");
216                break;
217            }
218        }
219    }
220}
221
222#[allow(clippy::too_many_arguments)]
223pub async fn spawn_background(
224    command: &str,
225    cwd: Option<&Path>,
226    event_tx: Option<mpsc::Sender<AgentEvent>>,
227    session_id: Option<String>,
228    interactive: bool,
229    bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
230) -> Result<Arc<ShellSession>, String> {
231    let shell = preferred_bash_shell();
232    trace_windows_command(
233        "agent.bash.background",
234        &shell.program,
235        [shell.arg, command],
236    );
237    let overrides = bamboo_llm::Config::current_env_vars();
238    let prepared_env = build_command_environment(&overrides).await;
239    let mut cmd = Command::new(&shell.program);
240    hide_window_for_tokio_command(&mut cmd);
241    if let Some(cwd) = cwd {
242        cmd.current_dir(cwd);
243    }
244    prepared_env.apply_to_tokio_command(&mut cmd);
245    cmd.arg(shell.arg).arg(command);
246    // Interactive (issue #89): a piped stdin lets callers feed input over time
247    // via `write_stdin`/BashInput. Non-interactive keeps `Stdio::null()` so a
248    // command that reads stdin gets immediate EOF — the default behavior is
249    // byte-for-byte unchanged for every existing path.
250    if interactive {
251        cmd.stdin(Stdio::piped());
252    } else {
253        cmd.stdin(Stdio::null());
254    }
255    cmd.stdout(Stdio::piped())
256        .stderr(Stdio::piped())
257        .kill_on_drop(true);
258
259    let mut child = cmd
260        .spawn()
261        .map_err(|e| format!("Failed to spawn background shell: {}", e))?;
262
263    let stdout = child
264        .stdout
265        .take()
266        .ok_or_else(|| "Failed to capture shell stdout".to_string())?;
267    let stderr = child
268        .stderr
269        .take()
270        .ok_or_else(|| "Failed to capture shell stderr".to_string())?;
271    // Only take (and store) the stdin handle when spawned interactive; a
272    // non-interactive child never has a stdin pipe to take.
273    let stdin_handle = if interactive {
274        child.stdin.take()
275    } else {
276        None
277    };
278
279    let shell_id = uuid::Uuid::new_v4().to_string();
280    let output = Arc::new(Mutex::new(Vec::new()));
281    let base_index = Arc::new(Mutex::new(0usize));
282    let running = Arc::new(AtomicBool::new(true));
283    let exit_code = Arc::new(Mutex::new(None));
284    let kill_notify = Arc::new(Notify::new());
285
286    let session = Arc::new(ShellSession {
287        id: shell_id.clone(),
288        command: command.to_string(),
289        session_id,
290        environment: prepared_env.diagnostics.clone(),
291        kill_notify: kill_notify.clone(),
292        stdin: Arc::new(Mutex::new(stdin_handle)),
293        output: output.clone(),
294        base_index: base_index.clone(),
295        running: running.clone(),
296        exit_code: exit_code.clone(),
297    });
298
299    let stdout_pump = {
300        let output = output.clone();
301        let base_index = base_index.clone();
302        tokio::spawn(async move {
303            pump_stream_lines("stdout", stdout, output, base_index).await;
304        })
305    };
306
307    let stderr_pump = {
308        let output = output.clone();
309        let base_index = base_index.clone();
310        tokio::spawn(async move {
311            pump_stream_lines("stderr", stderr, output, base_index).await;
312        })
313    };
314
315    spawn_completion_poll(
316        child,
317        kill_notify,
318        shell_id.clone(),
319        command.to_string(),
320        running,
321        exit_code,
322        output.clone(),
323        session.session_id.clone(),
324        event_tx,
325        bash_completion_sink,
326        vec![stdout_pump, stderr_pump],
327    );
328
329    sessions().insert(shell_id, session.clone());
330    Ok(session)
331}
332
333/// Shared completion-poll task. Polls the child until it exits, then sets the
334/// exit code/running flags, emits a `BashCompleted` event (when a sender is
335/// wired), pushes a loop-facing completion into the owning session via
336/// `bash_completion_sink` (when wired and the shell is session-tagged), and GCs
337/// the shell from the registry after the TTL. Used by both [`spawn_background`]
338/// and [`adopt_running_child`] so the poll/emit logic is never duplicated
339/// (issue #84, phase 2d + Phase 2b follow-up).
340#[allow(clippy::too_many_arguments)]
341fn spawn_completion_poll(
342    mut child: Child,
343    kill_notify: Arc<Notify>,
344    shell_id: String,
345    command: String,
346    running: Arc<AtomicBool>,
347    exit_code: Arc<Mutex<Option<i32>>>,
348    output: Arc<Mutex<Vec<String>>>,
349    session_id: Option<String>,
350    event_tx: Option<mpsc::Sender<AgentEvent>>,
351    bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
352    // Stdout/stderr pump task handles. Awaited (bounded) before reading the
353    // completion tail so it reflects the fully-drained output — observing exit
354    // can race the pumps that are still flushing the final lines.
355    pump_handles: Vec<tokio::task::JoinHandle<()>>,
356) {
357    let session_id_for_gc = shell_id.clone();
358    let bash_id_for_event = shell_id.clone();
359    let bash_id_for_sink = shell_id;
360    let command_for_event = command.clone();
361    let command_for_sink = command;
362    tokio::spawn(async move {
363        // Event-driven exit detection: await the process directly (`child.wait()`
364        // is OS-notified via tokio's process driver — no polling). A kill request
365        // (`kill_notify`, fired by `ShellSession::kill`) races the natural exit via
366        // `select!`; whichever wins, we then reap and read the exit status. This
367        // task owns the child handle, so no lock is contended and no killer blocks.
368        let wait_result = tokio::select! {
369            result = child.wait() => result,
370            _ = kill_notify.notified() => {
371                let _ = child.start_kill();
372                child.wait().await
373            }
374        };
375        let (status_str, exit_code_value) = match wait_result {
376            Ok(status) => {
377                let code = status.code();
378                // `code.is_none()` ⇒ terminated by signal (our SIGKILL, or an
379                // external one) ⇒ "killed"; otherwise a normal exit ⇒ "completed".
380                (
381                    if code.is_none() {
382                        "killed"
383                    } else {
384                        "completed"
385                    },
386                    code,
387                )
388            }
389            Err(_) => ("error", None),
390        };
391        *exit_code.lock().await = exit_code_value;
392        running.store(false, Ordering::Relaxed);
393
394        // Phase 1 (issue #84): emit a completion signal so clients can react
395        // to a long-running background command finishing. This is the ONLY
396        // chance to deliver the signal — the poll task emits exactly once,
397        // then sleeps the GC TTL and removes the shell. A non-blocking
398        // `try_send` would silently drop it under a saturated event channel,
399        // so we bound the await instead (500ms) and fall back to a visible
400        // `warn!` if the channel stays full or closed.
401        if let Some(tx) = &event_tx {
402            let event = AgentEvent::BashCompleted {
403                bash_id: bash_id_for_event,
404                command: command_for_event,
405                exit_code: exit_code_value,
406                status: status_str.to_string(),
407            };
408            if timeout(Duration::from_millis(500), tx.send(event))
409                .await
410                .is_err()
411            {
412                warn!(
413                    bash_id = %session_id_for_gc,
414                    "BashCompleted signal dropped (event channel saturated or closed after 500ms)"
415                );
416            }
417        }
418
419        // Loop-facing completion push (issue #84 Phase 2b follow-up): deliver the
420        // result into the owning session's agent loop the same way a sub-agent
421        // completion is delivered — pushed, not polled. Only when a sink is wired
422        // AND the shell is session-tagged (untagged shells, e.g. from tests, have
423        // no loop to notify). The sink hands off to a detached task, so this call
424        // is cheap. It is best-effort and idempotent with the durable end-of-turn
425        // suspend/poll backstop, which still runs.
426        if let (Some(sink), Some(session_id)) = (bash_completion_sink, session_id) {
427            // Wait (bounded) for the pumps to reach EOF so the tail is complete;
428            // the child's pipes close on exit, so this is the fast common case.
429            for handle in pump_handles {
430                let _ = timeout(Duration::from_secs(1), handle).await;
431            }
432            let output_tail = output_tail(&output).await;
433            sink.on_bash_completed(BashCompletionInfo {
434                session_id,
435                bash_id: bash_id_for_sink,
436                command: command_for_sink,
437                exit_code: exit_code_value,
438                status: status_str.to_string(),
439                output_tail,
440            });
441        }
442
443        sleep(Duration::from_secs(COMPLETED_SESSION_TTL_SECS)).await;
444        let _ = remove_shell(&session_id_for_gc);
445    });
446}
447
448/// Join the last [`COMPLETION_TAIL_LINES`] captured lines into a byte-bounded
449/// tail for a completion push, keeping the most recent bytes (front-trimmed on a
450/// char boundary) when over [`COMPLETION_TAIL_MAX_BYTES`].
451async fn output_tail(output: &Arc<Mutex<Vec<String>>>) -> String {
452    let buffer = output.lock().await;
453    let start = buffer.len().saturating_sub(COMPLETION_TAIL_LINES);
454    let joined = buffer[start..].join("\n");
455    if joined.len() <= COMPLETION_TAIL_MAX_BYTES {
456        return joined;
457    }
458    let mut cut = joined.len() - COMPLETION_TAIL_MAX_BYTES;
459    while cut < joined.len() && !joined.is_char_boundary(cut) {
460        cut += 1;
461    }
462    format!("…{}", &joined[cut..])
463}
464
465/// Adopt a child process that was spawned and partially drained by the
466/// foreground streaming loop (auto-sync promotion, issue #84 phase 2d).
467///
468/// Builds a [`ShellSession`] seeded with the already-captured output lines so
469/// they survive the hand-off and appear in subsequent `read_output_since`
470/// calls, then spawns the same pump + completion-poll tasks as
471/// [`spawn_background`] to keep draining the handed-over readers and eventually
472/// emit `BashCompleted`. The poll/emit logic is shared via
473/// [`spawn_completion_poll`] — it is never duplicated between the two entry
474/// points.
475#[allow(clippy::too_many_arguments)]
476pub async fn adopt_running_child(
477    child: Child,
478    stdout_reader: impl tokio::io::AsyncRead + Unpin + Send + 'static,
479    stderr_reader: impl tokio::io::AsyncRead + Unpin + Send + 'static,
480    seeded_stdout_lines: Vec<String>,
481    seeded_stderr_lines: Vec<String>,
482    command: &str,
483    session_id: Option<String>,
484    environment: CommandEnvironmentDiagnostics,
485    event_tx: Option<mpsc::Sender<AgentEvent>>,
486    bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
487) -> Result<Arc<ShellSession>, String> {
488    let shell_id = uuid::Uuid::new_v4().to_string();
489    let output = Arc::new(Mutex::new(Vec::new()));
490    let base_index = Arc::new(Mutex::new(0usize));
491    let running = Arc::new(AtomicBool::new(true));
492    let exit_code = Arc::new(Mutex::new(None));
493    let kill_notify = Arc::new(Notify::new());
494
495    // Seed the output buffer with already-captured lines so they are not lost
496    // across the foreground→background hand-off. Lines captured by the
497    // foreground phase are pushed here; the pump tasks below will append any
498    // subsequent output produced after promotion.
499    for line in seeded_stdout_lines.iter().chain(seeded_stderr_lines.iter()) {
500        push_line(&output, &base_index, line.clone()).await;
501    }
502
503    let session = Arc::new(ShellSession {
504        id: shell_id.clone(),
505        command: command.to_string(),
506        session_id,
507        environment,
508        kill_notify: kill_notify.clone(),
509        // The foreground streamer always spawns with Stdio::null() (bash.rs), so
510        // a promoted shell has no stdin pipe — None preserves EOF-on-read.
511        stdin: Arc::new(Mutex::new(None)),
512        output: output.clone(),
513        base_index: base_index.clone(),
514        running: running.clone(),
515        exit_code: exit_code.clone(),
516    });
517
518    // Spawn pump tasks to continue draining the handed-over readers. The
519    // readers may still hold buffered data from the foreground phase — wrapping
520    // them in a new BufReader (as pump_stream_lines does) reads through that
521    // buffer first, so no data is lost or double-counted.
522    let stdout_pump = {
523        let output = output.clone();
524        let base_index = base_index.clone();
525        tokio::spawn(async move {
526            pump_stream_lines("stdout", stdout_reader, output, base_index).await;
527        })
528    };
529    let stderr_pump = {
530        let output = output.clone();
531        let base_index = base_index.clone();
532        tokio::spawn(async move {
533            pump_stream_lines("stderr", stderr_reader, output, base_index).await;
534        })
535    };
536
537    spawn_completion_poll(
538        child,
539        kill_notify,
540        shell_id.clone(),
541        command.to_string(),
542        running,
543        exit_code,
544        output.clone(),
545        session.session_id.clone(),
546        event_tx,
547        bash_completion_sink,
548        vec![stdout_pump, stderr_pump],
549    );
550
551    sessions().insert(shell_id, session.clone());
552    Ok(session)
553}
554
555pub fn get_shell(id: &str) -> Option<Arc<ShellSession>> {
556    sessions().get(id).map(|entry| entry.value().clone())
557}
558
559pub fn remove_shell(id: &str) -> Option<Arc<ShellSession>> {
560    sessions().remove(id).map(|(_, value)| value)
561}
562
563/// Returns the ids of background shells owned by `session_id` that are still
564/// running (issue #84, phase 2a). Mirrors the sync `get_shell`/`remove_shell`
565/// helpers over the global registry — not async because the registry is a sync
566/// `DashMap` and `status()` is a sync read. A shell is included only when its
567/// stored `session_id` equals `Some(session_id)` and `status()` is `"running"`,
568/// so completed shells and shells belonging to another session (or none) are
569/// excluded.
570///
571/// The result is a point-in-time snapshot: a returned shell may finish between
572/// this call and the caller acting on its id, so callers must re-check liveness
573/// (e.g. via `get_shell(id).status()`) before treating an id as still running.
574pub fn running_shells_for_session(session_id: &str) -> Vec<String> {
575    sessions()
576        .iter()
577        .filter(|entry| {
578            entry
579                .session_id
580                .as_deref()
581                .is_some_and(|sid| sid == session_id)
582                && entry.status() == "running"
583        })
584        .map(|entry| entry.id.clone())
585        .collect()
586}