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    /// Ids of the live background shells owned by `owner_thread_id`, sorted
244    /// for stable output. Read-only: finished shells are not pruned here.
245    pub fn ids_for(&self, owner_thread_id: &str) -> Vec<String> {
246        let mut ids: Vec<String> = self
247            .shells
248            .iter()
249            .filter(|(_, entry)| entry.owner_thread_id == owner_thread_id)
250            .map(|(id, _)| id.clone())
251            .collect();
252        ids.sort();
253        ids
254    }
255
256    pub fn get_running_info(&mut self, owner_thread_id: &str) -> String {
257        self.prune_finished_shells();
258
259        if !self.shells.values().any(|entry| entry.owner_thread_id == owner_thread_id) {
260            return "No command running in background.\n".to_string();
261        }
262
263        let mut running = Vec::new();
264        for (id, entry) in &self.shells {
265            if entry.owner_thread_id != owner_thread_id {
266                continue;
267            }
268            if let Ok(guard) = entry.shell.try_lock() {
269                if let Some(bash) = guard.as_ref() {
270                    if bash.command_running {
271                        running.push(format!(
272                            "Command: {}, bg_command_id: {}",
273                            one_line_command(&bash.last_command),
274                            id
275                        ));
276                    }
277                }
278            } else {
279                running.push(format!("Command: <busy>, bg_command_id: {id}"));
280            }
281        }
282
283        if running.is_empty() {
284            "No command running in background.\n".to_string()
285        } else {
286            format!("Following background commands are attached:\n{}\n", running.join("\n"))
287        }
288    }
289}
290
291/// Human-only summaries must stay on one line. Machine-readable command state is
292/// transported separately, but escaping control characters also prevents a child
293/// command from visually forging Winx status blocks in terminal output.
294fn one_line_command(command: &str) -> String {
295    command.chars().fold(String::new(), |mut output, character| {
296        match character {
297            '\n' => output.push_str("\\n"),
298            '\r' => output.push_str("\\r"),
299            '\t' => output.push_str("\\t"),
300            character if character.is_control() => {
301                use std::fmt::Write as _;
302                let _ = write!(output, "\\u{{{:x}}}", u32::from(character));
303            }
304            character => output.push(character),
305        }
306        output
307    })
308}
309
310#[cfg(test)]
311mod tests {
312    use super::{one_line_command, BackgroundShellEntry, BackgroundShellManager, ExitedShellInfo};
313    use crate::errors::WinxError;
314    use std::path::PathBuf;
315    use std::sync::Arc;
316    use std::time::Instant;
317    use tokio::sync::Mutex;
318
319    fn empty_entry(owner_thread_id: &str) -> BackgroundShellEntry {
320        BackgroundShellEntry {
321            owner_thread_id: owner_thread_id.to_string(),
322            shell: Arc::new(Mutex::new(None)),
323        }
324    }
325
326    fn tombstone(owner_thread_id: &str) -> ExitedShellInfo {
327        ExitedShellInfo {
328            owner_thread_id: owner_thread_id.to_string(),
329            last_command: "true".to_string(),
330            final_output: Arc::from("done"),
331            exit_code: Some(0),
332            cwd: PathBuf::from("/tmp"),
333            output_truncated: false,
334            scratch_path: None,
335            exited_at: Instant::now(),
336        }
337    }
338
339    #[test]
340    fn command_summary_escapes_multiline_status_lookalikes() {
341        let command = "sleep 5\n\n---\n\nstatus = process exited";
342        let summary = one_line_command(command);
343        assert!(!summary.contains('\n'));
344        assert!(summary.contains("\\n\\n---\\n\\nstatus = process exited"));
345    }
346
347    #[test]
348    fn background_capacity_is_bounded_per_thread_and_globally() {
349        let mut manager = BackgroundShellManager::new();
350        for index in 0..BackgroundShellManager::MAX_SHELLS_PER_THREAD {
351            manager.shells.insert(format!("owned-{index}"), empty_entry("owner"));
352        }
353        assert!(matches!(
354            manager.ensure_capacity("owner"),
355            Err(WinxError::CommandExecutionError(message)) if message.contains("this thread")
356        ));
357        assert!(manager.ensure_capacity("another-owner").is_ok());
358
359        for index in manager.shells.len()..BackgroundShellManager::MAX_SHELLS {
360            manager
361                .shells
362                .insert(format!("global-{index}"), empty_entry(&format!("owner-{index}")));
363        }
364        assert!(matches!(
365            manager.ensure_capacity("new-owner"),
366            Err(WinxError::CommandExecutionError(message)) if message.contains("Global")
367        ));
368    }
369
370    #[test]
371    fn tombstone_capacity_is_bounded_per_thread_and_globally() {
372        let mut manager = BackgroundShellManager::new();
373        for index in 0..BackgroundShellManager::MAX_TOMBSTONES_PER_THREAD + 3 {
374            manager.insert_tombstone(format!("owned-{index}"), tombstone("owner"));
375        }
376        assert_eq!(
377            manager.tombstones.values().filter(|entry| entry.owner_thread_id == "owner").count(),
378            BackgroundShellManager::MAX_TOMBSTONES_PER_THREAD
379        );
380
381        for index in 0..BackgroundShellManager::MAX_TOMBSTONES + 5 {
382            manager.insert_tombstone(format!("global-{index}"), tombstone(&format!("o-{index}")));
383        }
384        assert_eq!(manager.tombstones.len(), BackgroundShellManager::MAX_TOMBSTONES);
385    }
386}