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
259                            .push(format!("Command: {}, bg_command_id: {}", bash.last_command, id));
260                    }
261                }
262            } else {
263                running.push(format!("Command: <busy>, bg_command_id: {id}"));
264            }
265        }
266
267        if running.is_empty() {
268            "No command running in background.\n".to_string()
269        } else {
270            format!("Following background commands are attached:\n{}\n", running.join("\n"))
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::{BackgroundShellEntry, BackgroundShellManager, ExitedShellInfo};
278    use crate::errors::WinxError;
279    use std::path::PathBuf;
280    use std::sync::Arc;
281    use std::time::Instant;
282    use tokio::sync::Mutex;
283
284    fn empty_entry(owner_thread_id: &str) -> BackgroundShellEntry {
285        BackgroundShellEntry {
286            owner_thread_id: owner_thread_id.to_string(),
287            shell: Arc::new(Mutex::new(None)),
288        }
289    }
290
291    fn tombstone(owner_thread_id: &str) -> ExitedShellInfo {
292        ExitedShellInfo {
293            owner_thread_id: owner_thread_id.to_string(),
294            last_command: "true".to_string(),
295            final_output: Arc::from("done"),
296            exit_code: Some(0),
297            cwd: PathBuf::from("/tmp"),
298            output_truncated: false,
299            scratch_path: None,
300            exited_at: Instant::now(),
301        }
302    }
303
304    #[test]
305    fn background_capacity_is_bounded_per_thread_and_globally() {
306        let mut manager = BackgroundShellManager::new();
307        for index in 0..BackgroundShellManager::MAX_SHELLS_PER_THREAD {
308            manager.shells.insert(format!("owned-{index}"), empty_entry("owner"));
309        }
310        assert!(matches!(
311            manager.ensure_capacity("owner"),
312            Err(WinxError::CommandExecutionError(message)) if message.contains("this thread")
313        ));
314        assert!(manager.ensure_capacity("another-owner").is_ok());
315
316        for index in manager.shells.len()..BackgroundShellManager::MAX_SHELLS {
317            manager
318                .shells
319                .insert(format!("global-{index}"), empty_entry(&format!("owner-{index}")));
320        }
321        assert!(matches!(
322            manager.ensure_capacity("new-owner"),
323            Err(WinxError::CommandExecutionError(message)) if message.contains("Global")
324        ));
325    }
326
327    #[test]
328    fn tombstone_capacity_is_bounded_per_thread_and_globally() {
329        let mut manager = BackgroundShellManager::new();
330        for index in 0..BackgroundShellManager::MAX_TOMBSTONES_PER_THREAD + 3 {
331            manager.insert_tombstone(format!("owned-{index}"), tombstone("owner"));
332        }
333        assert_eq!(
334            manager.tombstones.values().filter(|entry| entry.owner_thread_id == "owner").count(),
335            BackgroundShellManager::MAX_TOMBSTONES_PER_THREAD
336        );
337
338        for index in 0..BackgroundShellManager::MAX_TOMBSTONES + 5 {
339            manager.insert_tombstone(format!("global-{index}"), tombstone(&format!("o-{index}")));
340        }
341        assert_eq!(manager.tombstones.len(), BackgroundShellManager::MAX_TOMBSTONES);
342    }
343}