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