Skip to main content

recursive/tools/
run_background.rs

1//! `run_background` and `check_background`: spawn long-running commands
2//! asynchronously and poll their status later.
3//!
4//! `run_background` returns a job ID immediately so the agent can continue
5//! working while the command runs. `check_background` retrieves the output
6//! (or partial output) of a previously spawned job.
7//!
8//! Jobs are stored in a shared `BackgroundJobManager` and cleaned up after
9//! a configurable TTL or when explicitly checked.
10
11use async_trait::async_trait;
12use serde_json::{json, Value};
13use std::collections::HashMap;
14use std::path::PathBuf;
15use std::process::Stdio;
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18use tokio::io::AsyncReadExt;
19use tokio::process::Command;
20use tokio::sync::Mutex;
21use tokio::time::timeout;
22
23use super::resolve_within;
24use super::Tool;
25use crate::error::{Error, Result};
26use crate::llm::ToolSpec;
27
28/// Maximum bytes of stdout/stderr to capture per job.
29const MAX_OUTPUT_BYTES: usize = 128 * 1024;
30
31/// Default timeout for a background job (if none specified).
32const DEFAULT_JOB_TIMEOUT: u64 = 3600; // 1 hour
33
34/// How long a completed/failed job's output is kept before cleanup.
35const JOB_TTL: Duration = Duration::from_secs(3600);
36
37/// State of a background job.
38#[derive(Debug, Clone)]
39pub enum JobState {
40    Running,
41    Completed {
42        stdout: String,
43        stderr: String,
44        exit_code: i32,
45    },
46    Failed {
47        message: String,
48    },
49    TimedOut,
50}
51
52/// A single background job.
53pub struct Job {
54    pub state: JobState,
55    pub created_at: Instant,
56}
57
58/// Shared manager for background jobs.
59pub struct BackgroundJobManager {
60    jobs: HashMap<String, Job>,
61    next_id: u64,
62}
63
64impl Default for BackgroundJobManager {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70impl BackgroundJobManager {
71    pub fn new() -> Self {
72        Self {
73            jobs: HashMap::new(),
74            next_id: 1,
75        }
76    }
77
78    /// Generate a new unique job ID.
79    fn next_id(&mut self) -> String {
80        let id = self.next_id;
81        self.next_id += 1;
82        format!("bg-{id}")
83    }
84
85    /// Insert a new job and return its ID.
86    pub fn insert(&mut self, job: Job) -> String {
87        let id = self.next_id();
88        self.jobs.insert(id.clone(), job);
89        id
90    }
91
92    /// Get a reference to a job's state.
93    pub fn get_state(&self, id: &str) -> Option<JobState> {
94        self.jobs.get(id).map(|j| j.state.clone())
95    }
96
97    /// Update a job's state.
98    fn update(&mut self, id: &str, state: JobState) {
99        if let Some(job) = self.jobs.get_mut(id) {
100            job.state = state;
101        }
102    }
103
104    /// Remove stale jobs (older than TTL).
105    fn cleanup(&mut self) {
106        let cutoff = Instant::now() - JOB_TTL;
107        self.jobs.retain(|_, job| job.created_at > cutoff);
108    }
109
110    /// Remove all jobs immediately.
111    pub fn clear(&mut self) {
112        self.jobs.clear();
113    }
114
115    /// Remove and return the first completed job, if any.
116    ///
117    /// Returns `Some((job_id, output_string))` where `output_string` includes
118    /// the exit code and captured stdout/stderr. Completed jobs are removed
119    /// from the tracked set.
120    pub fn take_completed(&mut self) -> Option<(String, String)> {
121        let id = self.jobs.iter().find_map(|(id, job)| match &job.state {
122            JobState::Completed { .. } | JobState::Failed { .. } | JobState::TimedOut => {
123                Some(id.clone())
124            }
125            JobState::Running => None,
126        })?;
127        let job = self.jobs.remove(&id)?;
128        let output = match &job.state {
129            JobState::Completed {
130                stdout,
131                stderr,
132                exit_code,
133            } => {
134                format!("exit code: {exit_code}\nstdout:\n{stdout}\nstderr:\n{stderr}")
135            }
136            JobState::Failed { message } => {
137                format!("failed: {message}")
138            }
139            JobState::TimedOut => "timed out".to_string(),
140            JobState::Running => unreachable!(), // filtered above
141        };
142        Some((id, output))
143    }
144}
145
146/// The `run_background` tool: spawn a command and return immediately.
147pub struct RunBackground {
148    root: PathBuf,
149    manager: Arc<Mutex<BackgroundJobManager>>,
150}
151
152impl RunBackground {
153    pub fn new(root: impl Into<PathBuf>, manager: Arc<Mutex<BackgroundJobManager>>) -> Self {
154        Self {
155            root: root.into(),
156            manager,
157        }
158    }
159}
160
161#[async_trait]
162impl Tool for RunBackground {
163    fn spec(&self) -> ToolSpec {
164        ToolSpec {
165            name: "run_background".into(),
166            description:
167                "Run a shell command in the background and return immediately with a job ID. \
168                 Use `check_background` later to retrieve the output. Useful for long-running \
169                 commands (e.g. builds, tests, downloads) that you don't want to block on."
170                    .into(),
171            parameters: json!({
172                "type": "object",
173                "properties": {
174                    "command": {
175                        "type": "string",
176                        "description": "Command line to execute via sh -c"
177                    },
178                    "cwd": {
179                        "type": "string",
180                        "description": "Optional subdirectory (relative to workspace root) to run the command in. Must stay inside the workspace."
181                    },
182                    "env": {
183                        "type": "object",
184                        "description": "Optional extra env vars set for this command only. Values must be strings; non-string values are rejected.",
185                        "additionalProperties": {
186                            "type": "string"
187                        }
188                    },
189                    "timeout_secs": {
190                        "type": "integer",
191                        "description": "Optional timeout in seconds (default 3600, max 86400)",
192                        "default": 3600
193                    }
194                },
195                "required": ["command"]
196            }),
197        }
198    }
199
200    async fn execute(&self, args: Value) -> Result<String> {
201        let command = args["command"].as_str().ok_or_else(|| Error::BadToolArgs {
202            name: "run_background".into(),
203            message: "missing `command`".into(),
204        })?;
205
206        let cwd = if let Some(rel) = args.get("cwd").and_then(|v| v.as_str()) {
207            resolve_within(&self.root, rel).map_err(|e| Error::BadToolArgs {
208                name: "run_background".into(),
209                message: format!("cwd: {e}"),
210            })?
211        } else {
212            self.root.clone()
213        };
214
215        let timeout_secs = args
216            .get("timeout_secs")
217            .and_then(|v| v.as_i64())
218            .unwrap_or(DEFAULT_JOB_TIMEOUT as i64)
219            .clamp(1, 86400) as u64;
220
221        let mut cmd = Command::new("/bin/sh");
222        cmd.arg("-c").arg(command);
223        cmd.current_dir(&cwd);
224        cmd.stdout(Stdio::piped());
225        cmd.stderr(Stdio::piped());
226
227        // Apply optional env overrides
228        if let Some(env_map) = args.get("env").and_then(|v| v.as_object()) {
229            for (key, val) in env_map {
230                let val_str = val.as_str().ok_or_else(|| Error::BadToolArgs {
231                    name: "run_background".to_string(),
232                    message: format!("env value for `{key}` must be a string, got {:?}", val),
233                })?;
234                cmd.env(key, val_str);
235            }
236        }
237
238        // Spawn the process
239        let mut child = cmd.spawn().map_err(|e| Error::Tool {
240            name: "run_background".into(),
241            message: format!("spawn failed: {e}"),
242        })?;
243
244        let stdout_handle = child.stdout.take();
245        let stderr_handle = child.stderr.take();
246
247        // Generate a job ID and store it
248        let mut manager = self.manager.lock().await;
249        manager.cleanup();
250        let job_id = manager.insert(Job {
251            state: JobState::Running,
252            created_at: Instant::now(),
253        });
254        drop(manager);
255
256        // Spawn a background task to wait for the process and capture output
257        let manager_clone = self.manager.clone();
258        let job_id_clone = job_id.clone();
259        tokio::spawn(async move {
260            let result = run_background_job(
261                child,
262                stdout_handle,
263                stderr_handle,
264                Duration::from_secs(timeout_secs),
265            )
266            .await;
267
268            let mut mgr = manager_clone.lock().await;
269            mgr.update(&job_id_clone, result);
270        });
271
272        Ok(json!({
273            "job_id": job_id,
274            "status": "spawned",
275            "message": format!("Background job `{}` spawned. Use `check_background` with job_id `{}` to retrieve output.", command, job_id)
276        })
277        .to_string())
278    }
279}
280
281/// The `check_background` tool: poll a previously spawned background job.
282pub struct CheckBackground {
283    manager: Arc<Mutex<BackgroundJobManager>>,
284}
285
286impl CheckBackground {
287    pub fn new(manager: Arc<Mutex<BackgroundJobManager>>) -> Self {
288        Self { manager }
289    }
290}
291
292#[async_trait]
293impl Tool for CheckBackground {
294    fn spec(&self) -> ToolSpec {
295        ToolSpec {
296            name: "check_background".into(),
297            description: "Check the status and output of a previously spawned background job. \
298                 Returns the current state (running, completed, failed, or timed out) \
299                 along with any captured stdout/stderr."
300                .into(),
301            parameters: json!({
302                "type": "object",
303                "properties": {
304                    "job_id": {
305                        "type": "string",
306                        "description": "The job ID returned by `run_background`"
307                    }
308                },
309                "required": ["job_id"]
310            }),
311        }
312    }
313
314    async fn execute(&self, args: Value) -> Result<String> {
315        let job_id = args["job_id"].as_str().ok_or_else(|| Error::BadToolArgs {
316            name: "check_background".into(),
317            message: "missing `job_id`".into(),
318        })?;
319
320        let mut manager = self.manager.lock().await;
321        manager.cleanup();
322
323        match manager.get_state(job_id) {
324            None => Ok(json!({
325                "job_id": job_id,
326                "status": "unknown",
327                "message": format!("No job found with ID `{}`. It may have expired or the ID is invalid.", job_id)
328            })
329            .to_string()),
330            Some(JobState::Running) => Ok(json!({
331                "job_id": job_id,
332                "status": "running",
333                "message": "Job is still running. Check again later."
334            })
335            .to_string()),
336            Some(JobState::Completed { stdout, stderr, exit_code }) => {
337                // Clean up completed job after returning its output
338                manager.update(job_id, JobState::Completed {
339                    stdout: String::new(),
340                    stderr: String::new(),
341                    exit_code,
342                });
343                Ok(json!({
344                    "job_id": job_id,
345                    "status": "completed",
346                    "exit_code": exit_code,
347                    "stdout": stdout,
348                    "stderr": stderr
349                })
350                .to_string())
351            }
352            Some(JobState::Failed { message }) => {
353                Ok(json!({
354                    "job_id": job_id,
355                    "status": "failed",
356                    "message": message
357                })
358                .to_string())
359            }
360            Some(JobState::TimedOut) => Ok(json!({
361                "job_id": job_id,
362                "status": "timed_out",
363                "message": "Job exceeded its timeout."
364            })
365            .to_string()),
366        }
367    }
368}
369
370/// Wait for a background process to finish, capturing its output.
371async fn run_background_job(
372    mut child: tokio::process::Child,
373    stdout_opt: Option<tokio::process::ChildStdout>,
374    stderr_opt: Option<tokio::process::ChildStderr>,
375    job_timeout: Duration,
376) -> JobState {
377    let stdout_task = async {
378        let mut out = String::new();
379        if let Some(mut reader) = stdout_opt {
380            let mut buf = [0u8; 8192];
381            let mut total = 0usize;
382            loop {
383                match reader.read(&mut buf).await {
384                    Ok(0) => break,
385                    Ok(n) => {
386                        if total + n > MAX_OUTPUT_BYTES {
387                            let take = MAX_OUTPUT_BYTES.saturating_sub(total);
388                            out.push_str(&String::from_utf8_lossy(&buf[..take]));
389                            out.push_str("\n... [stdout truncated]");
390                            // Drain remaining
391                            let _ = tokio::io::copy(&mut reader, &mut tokio::io::sink()).await;
392                            break;
393                        }
394                        out.push_str(&String::from_utf8_lossy(&buf[..n]));
395                        total += n;
396                    }
397                    Err(_) => break,
398                }
399            }
400        }
401        out
402    };
403
404    let stderr_task = async {
405        let mut err = String::new();
406        if let Some(mut reader) = stderr_opt {
407            let mut buf = [0u8; 8192];
408            let mut total = 0usize;
409            loop {
410                match reader.read(&mut buf).await {
411                    Ok(0) => break,
412                    Ok(n) => {
413                        if total + n > MAX_OUTPUT_BYTES {
414                            let take = MAX_OUTPUT_BYTES.saturating_sub(total);
415                            err.push_str(&String::from_utf8_lossy(&buf[..take]));
416                            err.push_str("\n... [stderr truncated]");
417                            let _ = tokio::io::copy(&mut reader, &mut tokio::io::sink()).await;
418                            break;
419                        }
420                        err.push_str(&String::from_utf8_lossy(&buf[..n]));
421                        total += n;
422                    }
423                    Err(_) => break,
424                }
425            }
426        }
427        err
428    };
429
430    // Wait for the process with a timeout
431    let wait_result = timeout(job_timeout, child.wait()).await;
432
433    match wait_result {
434        Err(_) => {
435            // Timed out — kill the process
436            let _ = child.start_kill();
437            JobState::TimedOut
438        }
439        Ok(Err(e)) => JobState::Failed {
440            message: format!("wait failed: {e}"),
441        },
442        Ok(Ok(status)) => {
443            let stdout = stdout_task.await;
444            let stderr = stderr_task.await;
445            let exit_code = status.code().unwrap_or(-1);
446            JobState::Completed {
447                stdout,
448                stderr,
449                exit_code,
450            }
451        }
452    }
453}
454
455#[cfg(test)]
456#[cfg(not(target_os = "windows"))]
457mod tests {
458    use super::*;
459    use serde_json::json;
460    use tempfile::TempDir;
461    use tokio::time::sleep;
462
463    /// Poll `check_background` until the job is no longer running, with a timeout.
464    async fn poll_until_done(
465        check_tool: &CheckBackground,
466        job_id: &str,
467        max_wait: Duration,
468    ) -> Value {
469        let start = Instant::now();
470        loop {
471            let result = check_tool.execute(json!({"job_id": job_id})).await.unwrap();
472            let parsed: Value = serde_json::from_str(&result).unwrap();
473            if parsed["status"] != "running" {
474                return parsed;
475            }
476            if start.elapsed() > max_wait {
477                panic!("timed out waiting for job {job_id} to complete");
478            }
479            sleep(Duration::from_millis(50)).await;
480        }
481    }
482
483    #[tokio::test]
484    async fn run_and_check_background() {
485        let tmp = TempDir::new().unwrap();
486        let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
487
488        let run_tool = RunBackground::new(tmp.path(), manager.clone());
489        let check_tool = CheckBackground::new(manager.clone());
490
491        // Run a quick command
492        let result = run_tool
493            .execute(json!({"command": "echo hello world"}))
494            .await
495            .unwrap();
496
497        let parsed: Value = serde_json::from_str(&result).unwrap();
498        let job_id = parsed["job_id"].as_str().unwrap().to_string();
499        assert_eq!(parsed["status"], "spawned");
500
501        let parsed = poll_until_done(&check_tool, &job_id, Duration::from_secs(5)).await;
502        assert_eq!(parsed["status"], "completed");
503        assert_eq!(parsed["exit_code"], 0);
504        assert!(parsed["stdout"].as_str().unwrap().contains("hello world"));
505    }
506
507    #[tokio::test]
508    async fn background_job_timeout() {
509        let tmp = TempDir::new().unwrap();
510        let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
511
512        let run_tool = RunBackground::new(tmp.path(), manager.clone());
513        let check_tool = CheckBackground::new(manager.clone());
514
515        // Run a command that sleeps longer than the timeout
516        let result = run_tool
517            .execute(json!({"command": "sleep 10", "timeout_secs": 1}))
518            .await
519            .unwrap();
520
521        let parsed: Value = serde_json::from_str(&result).unwrap();
522        let job_id = parsed["job_id"].as_str().unwrap().to_string();
523
524        let parsed = poll_until_done(&check_tool, &job_id, Duration::from_secs(5)).await;
525        assert_eq!(parsed["status"], "timed_out");
526    }
527
528    #[tokio::test]
529    async fn background_job_nonzero_exit() {
530        let tmp = TempDir::new().unwrap();
531        let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
532
533        let run_tool = RunBackground::new(tmp.path(), manager.clone());
534        let check_tool = CheckBackground::new(manager.clone());
535
536        let result = run_tool
537            .execute(json!({"command": "exit 42"}))
538            .await
539            .unwrap();
540
541        let parsed: Value = serde_json::from_str(&result).unwrap();
542        let job_id = parsed["job_id"].as_str().unwrap().to_string();
543
544        let parsed = poll_until_done(&check_tool, &job_id, Duration::from_secs(5)).await;
545        assert_eq!(parsed["status"], "completed");
546        assert_eq!(parsed["exit_code"], 42);
547    }
548
549    #[tokio::test]
550    async fn check_unknown_job() {
551        let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
552        let check_tool = CheckBackground::new(manager);
553
554        let result = check_tool
555            .execute(json!({"job_id": "bg-999"}))
556            .await
557            .unwrap();
558
559        let parsed: Value = serde_json::from_str(&result).unwrap();
560        assert_eq!(parsed["status"], "unknown");
561    }
562
563    #[tokio::test]
564    async fn run_background_with_cwd() {
565        let tmp = TempDir::new().unwrap();
566        let sub = tmp.path().join("subdir");
567        std::fs::create_dir(&sub).unwrap();
568        std::fs::write(sub.join("marker.txt"), "content").unwrap();
569
570        let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
571        let run_tool = RunBackground::new(tmp.path(), manager.clone());
572        let check_tool = CheckBackground::new(manager.clone());
573
574        let result = run_tool
575            .execute(json!({"command": "ls", "cwd": "subdir"}))
576            .await
577            .unwrap();
578
579        let parsed: Value = serde_json::from_str(&result).unwrap();
580        let job_id = parsed["job_id"].as_str().unwrap().to_string();
581
582        let parsed = poll_until_done(&check_tool, &job_id, Duration::from_secs(5)).await;
583        assert_eq!(parsed["status"], "completed");
584        assert!(parsed["stdout"].as_str().unwrap().contains("marker.txt"));
585    }
586
587    #[tokio::test]
588    async fn run_background_with_env() {
589        let tmp = TempDir::new().unwrap();
590        let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
591        let run_tool = RunBackground::new(tmp.path(), manager.clone());
592        let check_tool = CheckBackground::new(manager.clone());
593
594        let result = run_tool
595            .execute(json!({"command": "echo $MY_VAR", "env": {"MY_VAR": "hello"}}))
596            .await
597            .unwrap();
598
599        let parsed: Value = serde_json::from_str(&result).unwrap();
600        let job_id = parsed["job_id"].as_str().unwrap().to_string();
601
602        let parsed = poll_until_done(&check_tool, &job_id, Duration::from_secs(5)).await;
603        assert_eq!(parsed["status"], "completed");
604        assert!(parsed["stdout"].as_str().unwrap().contains("hello"));
605    }
606
607    #[tokio::test]
608    async fn run_background_missing_command() {
609        let tmp = TempDir::new().unwrap();
610        let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
611        let run_tool = RunBackground::new(tmp.path(), manager);
612
613        let err = run_tool.execute(json!({})).await.unwrap_err();
614        assert!(matches!(err, Error::BadToolArgs { .. }));
615    }
616
617    #[tokio::test]
618    async fn check_background_missing_job_id() {
619        let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
620        let check_tool = CheckBackground::new(manager);
621
622        let err = check_tool.execute(json!({})).await.unwrap_err();
623        assert!(matches!(err, Error::BadToolArgs { .. }));
624    }
625
626    #[tokio::test]
627    async fn take_completed_returns_finished_job() {
628        let mut manager = BackgroundJobManager::new();
629        // Insert a running job
630        let running_id = manager.insert(Job {
631            state: JobState::Running,
632            created_at: Instant::now(),
633        });
634        // Insert a completed job
635        let completed_id = manager.insert(Job {
636            state: JobState::Completed {
637                stdout: "hello".into(),
638                stderr: "".into(),
639                exit_code: 0,
640            },
641            created_at: Instant::now(),
642        });
643
644        // take_completed should return the completed job
645        let (id, output) = manager.take_completed().unwrap();
646        assert_eq!(id, completed_id);
647        assert!(output.contains("hello"));
648        assert!(output.contains("exit code: 0"));
649
650        // Running job should still be there
651        assert!(manager.get_state(&running_id).is_some());
652        // Completed job should be removed
653        assert!(manager.get_state(&completed_id).is_none());
654
655        // No more completed jobs
656        assert!(manager.take_completed().is_none());
657    }
658
659    #[tokio::test]
660    async fn take_completed_returns_failed_job() {
661        let mut manager = BackgroundJobManager::new();
662        manager.insert(Job {
663            state: JobState::Failed {
664                message: "something went wrong".into(),
665            },
666            created_at: Instant::now(),
667        });
668
669        let (id, output) = manager.take_completed().unwrap();
670        assert!(id.starts_with("bg-"));
671        assert!(output.contains("failed"));
672        assert!(output.contains("something went wrong"));
673    }
674
675    #[tokio::test]
676    async fn take_completed_returns_timed_out_job() {
677        let mut manager = BackgroundJobManager::new();
678        manager.insert(Job {
679            state: JobState::TimedOut,
680            created_at: Instant::now(),
681        });
682
683        let (id, output) = manager.take_completed().unwrap();
684        assert!(id.starts_with("bg-"));
685        assert!(output.contains("timed out"));
686    }
687
688    #[tokio::test]
689    async fn take_completed_empty_when_no_finished_jobs() {
690        let mut manager = BackgroundJobManager::new();
691        manager.insert(Job {
692            state: JobState::Running,
693            created_at: Instant::now(),
694        });
695        assert!(manager.take_completed().is_none());
696    }
697}