Skip to main content

winx_code_agent/tools/
background_shell.rs

1//! Background shell session lifecycle and shared manager state.
2
3use rand::RngExt;
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8use tokio::sync::Mutex;
9use tracing::info;
10
11use crate::errors::{Result, WinxError};
12use crate::state::pty::{PtyShell, SharedPtyShell};
13
14/// Snapshot of a background shell that has exited but whose final output has not
15/// yet been consumed by the caller. We keep it around so the next call (typically
16/// a `status_check`) can return the trailing output before the entry is gone.
17#[derive(Debug, Clone)]
18pub struct ExitedShellInfo {
19    pub owner_thread_id: String,
20    pub last_command: String,
21    pub final_output: Arc<str>,
22    pub exit_code: Option<i32>,
23    pub cwd: PathBuf,
24    pub output_truncated: bool,
25    pub scratch_path: Option<PathBuf>,
26    pub exited_at: Instant,
27}
28
29#[derive(Debug)]
30struct BackgroundShellEntry {
31    owner_thread_id: String,
32    shell: SharedPtyShell,
33}
34
35/// Manages background shell sessions - matches WCGW Python's `background_shells` dict
36#[derive(Debug, Default)]
37pub struct BackgroundShellManager {
38    shells: HashMap<String, BackgroundShellEntry>,
39    /// Recently exited shells that still owe their final output to the caller.
40    /// Entries are consumed the first time the caller queries the id, then dropped.
41    tombstones: HashMap<String, ExitedShellInfo>,
42}
43
44impl BackgroundShellManager {
45    /// Tombstones older than this are garbage-collected on the next prune pass.
46    const TOMBSTONE_TTL: Duration = Duration::from_secs(300);
47    const MAX_SHELLS: usize = 32;
48    const MAX_SHELLS_PER_THREAD: usize = 8;
49    const MAX_TOMBSTONES: usize = 32;
50    const MAX_TOMBSTONES_PER_THREAD: usize = 8;
51
52    /// Create a new background shell manager
53    pub fn new() -> Self {
54        Self { shells: HashMap::new(), tombstones: HashMap::new() }
55    }
56
57    /// Start a new background shell and return its command ID
58    /// Register an already-built shell and return its id.
59    ///
60    /// The caller builds the `PtyShell` BEFORE taking the manager lock —
61    /// `PtyShell::new` forks+execs and does a ~300ms blocking prompt init, which
62    /// must not run under the global `std::Mutex` (it would serialize every other
63    /// background-shell op behind one slow spawn).
64    pub fn register_shell(&mut self, owner_thread_id: &str, shell: PtyShell) -> Result<String> {
65        self.prune_finished_shells();
66        self.ensure_capacity(owner_thread_id)?;
67
68        let cid = format!("{:016x}", rand::rng().random::<u64>());
69        self.shells.insert(
70            cid.clone(),
71            BackgroundShellEntry {
72                owner_thread_id: owner_thread_id.to_string(),
73                shell: Arc::new(Mutex::new(Some(shell))),
74            },
75        );
76        info!("Started background shell with id: {}", cid);
77        Ok(cid)
78    }
79
80    fn ensure_capacity(&self, owner_thread_id: &str) -> Result<()> {
81        let owned =
82            self.shells.values().filter(|entry| entry.owner_thread_id == owner_thread_id).count();
83        if owned >= Self::MAX_SHELLS_PER_THREAD {
84            return Err(WinxError::CommandExecutionError(format!(
85                "Background shell limit reached for this thread ({}). Reuse or stop an existing shell.",
86                Self::MAX_SHELLS_PER_THREAD
87            )));
88        }
89        if self.shells.len() >= Self::MAX_SHELLS {
90            return Err(WinxError::CommandExecutionError(format!(
91                "Global background shell limit reached ({}). Wait for an existing shell to exit.",
92                Self::MAX_SHELLS
93            )));
94        }
95        Ok(())
96    }
97
98    /// Get a background shell by its command ID
99    pub fn get_shell(&self, owner_thread_id: &str, bg_command_id: &str) -> Option<SharedPtyShell> {
100        self.shells
101            .get(bg_command_id)
102            .filter(|entry| entry.owner_thread_id == owner_thread_id)
103            .map(|entry| entry.shell.clone())
104    }
105
106    /// Remove and cleanup a background shell
107    pub fn remove_shell(&mut self, bg_command_id: &str) -> bool {
108        if let Some(entry) = self.shells.remove(bg_command_id) {
109            if let Ok(mut guard) = entry.shell.try_lock() {
110                *guard = None;
111            }
112            info!("Removed background shell: {}", bg_command_id);
113            true
114        } else {
115            false
116        }
117    }
118
119    pub(crate) fn prune_finished_shells(&mut self) {
120        // GC old tombstones first.
121        let now = Instant::now();
122        self.tombstones.retain(|_, info| now.duration_since(info.exited_at) < Self::TOMBSTONE_TTL);
123
124        let mut finished: Vec<(String, Option<ExitedShellInfo>)> = Vec::new();
125
126        for (id, entry) in &self.shells {
127            // A routed status/screen/wait call owns another Arc while it reads this
128            // shell. Never turn the shell into a tombstone underneath that caller:
129            // it would observe `None` and return an empty final response while the
130            // real output sat in the newly-created tombstone.
131            if Arc::strong_count(&entry.shell) > 1 {
132                continue;
133            }
134            let Ok(mut guard) = entry.shell.try_lock() else {
135                continue;
136            };
137
138            let Some(shell) = guard.as_mut() else {
139                finished.push((id.clone(), None));
140                continue;
141            };
142
143            if !shell.is_alive() {
144                let tombstone = Self::exited_shell_info(&entry.owner_thread_id, shell, now);
145                finished.push((id.clone(), Some(tombstone)));
146                continue;
147            }
148
149            // Never prune shells that haven't received a command yet.
150            // The global BG_SHELL_MANAGER is shared across parallel tests; a freshly
151            // spawned shell would otherwise be evicted between start_new_shell and
152            // the first send_command, leading to "Failed to get background shell".
153            if shell.last_command.is_empty() {
154                continue;
155            }
156
157            if shell.command_running {
158                // Non-blocking: draining here must NOT hold the global manager
159                // lock across a 100ms blocking read (that serializes every other
160                // background-shell op behind a single slow PTY).
161                shell.poll_output_nonblocking();
162            }
163
164            if !shell.command_running {
165                let tombstone = Self::exited_shell_info(&entry.owner_thread_id, shell, now);
166                finished.push((id.clone(), Some(tombstone)));
167            }
168        }
169
170        for (id, tombstone) in finished {
171            self.remove_shell(&id);
172            if let Some(info) = tombstone {
173                self.insert_tombstone(id, info);
174            }
175        }
176    }
177
178    fn exited_shell_info(
179        owner_thread_id: &str,
180        shell: &mut PtyShell,
181        now: Instant,
182    ) -> ExitedShellInfo {
183        ExitedShellInfo {
184            owner_thread_id: owner_thread_id.to_string(),
185            last_command: shell.last_command.clone(),
186            final_output: Arc::from(std::mem::take(&mut shell.output_buffer)),
187            exit_code: shell.last_exit_code,
188            cwd: shell.current_cwd().to_path_buf(),
189            output_truncated: shell.output_truncated,
190            scratch_path: shell.scratch_path().map(Path::to_path_buf),
191            exited_at: now,
192        }
193    }
194
195    fn insert_tombstone(&mut self, id: String, info: ExitedShellInfo) {
196        let owner = info.owner_thread_id.clone();
197        self.tombstones.insert(id, info);
198
199        while self.tombstones.values().filter(|entry| entry.owner_thread_id == owner).count()
200            > Self::MAX_TOMBSTONES_PER_THREAD
201        {
202            let oldest = self
203                .tombstones
204                .iter()
205                .filter(|(_, entry)| entry.owner_thread_id == owner)
206                .min_by_key(|(_, entry)| entry.exited_at)
207                .map(|(id, _)| id.clone());
208            if let Some(id) = oldest {
209                self.tombstones.remove(&id);
210            }
211        }
212
213        while self.tombstones.len() > Self::MAX_TOMBSTONES {
214            let oldest = self
215                .tombstones
216                .iter()
217                .min_by_key(|(_, entry)| entry.exited_at)
218                .map(|(id, _)| id.clone());
219            if let Some(id) = oldest {
220                self.tombstones.remove(&id);
221            }
222        }
223    }
224
225    /// Look up the tombstone for a recently-exited shell, if any.
226    ///
227    /// The entry stays in the map until the TTL expires (see
228    /// `prune_finished_shells`), so repeated `status_check` calls on the same
229    /// `bg_command_id` keep returning the cached final output instead of
230    /// flipping to "shell not found" after the first read.
231    pub fn peek_tombstone(
232        &self,
233        owner_thread_id: &str,
234        bg_command_id: &str,
235    ) -> Option<ExitedShellInfo> {
236        self.tombstones
237            .get(bg_command_id)
238            .filter(|entry| entry.owner_thread_id == owner_thread_id)
239            .cloned()
240    }
241
242    /// Get info about all running background shells - matches WCGW Python `get_bg_running_commandsinfo`
243    pub fn get_running_info(&mut self, owner_thread_id: &str) -> String {
244        self.prune_finished_shells();
245
246        if !self.shells.values().any(|entry| entry.owner_thread_id == owner_thread_id) {
247            return "No command running in background.\n".to_string();
248        }
249
250        let mut running = Vec::new();
251        for (id, entry) in &self.shells {
252            if entry.owner_thread_id != owner_thread_id {
253                continue;
254            }
255            if let Ok(guard) = entry.shell.try_lock() {
256                if let Some(bash) = guard.as_ref() {
257                    if bash.command_running {
258                        running.push(format!(
259                            "Command: {}, bg_command_id: {}",
260                            one_line_command(&bash.last_command),
261                            id
262                        ));
263                    }
264                }
265            } else {
266                running.push(format!("Command: <busy>, bg_command_id: {id}"));
267            }
268        }
269
270        if running.is_empty() {
271            "No command running in background.\n".to_string()
272        } else {
273            format!("Following background commands are attached:\n{}\n", running.join("\n"))
274        }
275    }
276}
277
278/// Human-only summaries must stay on one line. Machine-readable command state is
279/// transported separately, but escaping control characters also prevents a child
280/// command from visually forging Winx status blocks in terminal output.
281fn one_line_command(command: &str) -> String {
282    command.chars().fold(String::new(), |mut output, character| {
283        match character {
284            '\n' => output.push_str("\\n"),
285            '\r' => output.push_str("\\r"),
286            '\t' => output.push_str("\\t"),
287            character if character.is_control() => {
288                use std::fmt::Write as _;
289                let _ = write!(output, "\\u{{{:x}}}", u32::from(character));
290            }
291            character => output.push(character),
292        }
293        output
294    })
295}
296
297#[cfg(test)]
298mod tests {
299    use super::{one_line_command, BackgroundShellEntry, BackgroundShellManager, ExitedShellInfo};
300    use crate::errors::WinxError;
301    use std::path::PathBuf;
302    use std::sync::Arc;
303    use std::time::Instant;
304    use tokio::sync::Mutex;
305
306    fn empty_entry(owner_thread_id: &str) -> BackgroundShellEntry {
307        BackgroundShellEntry {
308            owner_thread_id: owner_thread_id.to_string(),
309            shell: Arc::new(Mutex::new(None)),
310        }
311    }
312
313    fn tombstone(owner_thread_id: &str) -> ExitedShellInfo {
314        ExitedShellInfo {
315            owner_thread_id: owner_thread_id.to_string(),
316            last_command: "true".to_string(),
317            final_output: Arc::from("done"),
318            exit_code: Some(0),
319            cwd: PathBuf::from("/tmp"),
320            output_truncated: false,
321            scratch_path: None,
322            exited_at: Instant::now(),
323        }
324    }
325
326    #[test]
327    fn command_summary_escapes_multiline_status_lookalikes() {
328        let command = "sleep 5\n\n---\n\nstatus = process exited";
329        let summary = one_line_command(command);
330        assert!(!summary.contains('\n'));
331        assert!(summary.contains("\\n\\n---\\n\\nstatus = process exited"));
332    }
333
334    #[test]
335    fn background_capacity_is_bounded_per_thread_and_globally() {
336        let mut manager = BackgroundShellManager::new();
337        for index in 0..BackgroundShellManager::MAX_SHELLS_PER_THREAD {
338            manager.shells.insert(format!("owned-{index}"), empty_entry("owner"));
339        }
340        assert!(matches!(
341            manager.ensure_capacity("owner"),
342            Err(WinxError::CommandExecutionError(message)) if message.contains("this thread")
343        ));
344        assert!(manager.ensure_capacity("another-owner").is_ok());
345
346        for index in manager.shells.len()..BackgroundShellManager::MAX_SHELLS {
347            manager
348                .shells
349                .insert(format!("global-{index}"), empty_entry(&format!("owner-{index}")));
350        }
351        assert!(matches!(
352            manager.ensure_capacity("new-owner"),
353            Err(WinxError::CommandExecutionError(message)) if message.contains("Global")
354        ));
355    }
356
357    #[test]
358    fn tombstone_capacity_is_bounded_per_thread_and_globally() {
359        let mut manager = BackgroundShellManager::new();
360        for index in 0..BackgroundShellManager::MAX_TOMBSTONES_PER_THREAD + 3 {
361            manager.insert_tombstone(format!("owned-{index}"), tombstone("owner"));
362        }
363        assert_eq!(
364            manager.tombstones.values().filter(|entry| entry.owner_thread_id == "owner").count(),
365            BackgroundShellManager::MAX_TOMBSTONES_PER_THREAD
366        );
367
368        for index in 0..BackgroundShellManager::MAX_TOMBSTONES + 5 {
369            manager.insert_tombstone(format!("global-{index}"), tombstone(&format!("o-{index}")));
370        }
371        assert_eq!(manager.tombstones.len(), BackgroundShellManager::MAX_TOMBSTONES);
372    }
373}