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.
53struct Job {
54    state: JobState,
55    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    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    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
111/// The `run_background` tool: spawn a command and return immediately.
112pub struct RunBackground {
113    root: PathBuf,
114    manager: Arc<Mutex<BackgroundJobManager>>,
115}
116
117impl RunBackground {
118    pub fn new(root: impl Into<PathBuf>, manager: Arc<Mutex<BackgroundJobManager>>) -> Self {
119        Self {
120            root: root.into(),
121            manager,
122        }
123    }
124}
125
126#[async_trait]
127impl Tool for RunBackground {
128    fn spec(&self) -> ToolSpec {
129        ToolSpec {
130            name: "run_background".into(),
131            description:
132                "Run a shell command in the background and return immediately with a job ID. \
133                 Use `check_background` later to retrieve the output. Useful for long-running \
134                 commands (e.g. builds, tests, downloads) that you don't want to block on."
135                    .into(),
136            parameters: json!({
137                "type": "object",
138                "properties": {
139                    "command": {
140                        "type": "string",
141                        "description": "Command line to execute via sh -c"
142                    },
143                    "cwd": {
144                        "type": "string",
145                        "description": "Optional subdirectory (relative to workspace root) to run the command in. Must stay inside the workspace."
146                    },
147                    "env": {
148                        "type": "object",
149                        "description": "Optional extra env vars set for this command only. Values must be strings; non-string values are rejected.",
150                        "additionalProperties": {
151                            "type": "string"
152                        }
153                    },
154                    "timeout_secs": {
155                        "type": "integer",
156                        "description": "Optional timeout in seconds (default 3600, max 86400)",
157                        "default": 3600
158                    }
159                },
160                "required": ["command"]
161            }),
162        }
163    }
164
165    async fn execute(&self, args: Value) -> Result<String> {
166        let command = args["command"].as_str().ok_or_else(|| Error::BadToolArgs {
167            name: "run_background".into(),
168            message: "missing `command`".into(),
169        })?;
170
171        let cwd = if let Some(rel) = args.get("cwd").and_then(|v| v.as_str()) {
172            resolve_within(&self.root, rel).map_err(|e| Error::BadToolArgs {
173                name: "run_background".into(),
174                message: format!("cwd: {e}"),
175            })?
176        } else {
177            self.root.clone()
178        };
179
180        let timeout_secs = args
181            .get("timeout_secs")
182            .and_then(|v| v.as_i64())
183            .unwrap_or(DEFAULT_JOB_TIMEOUT as i64)
184            .clamp(1, 86400) as u64;
185
186        let mut cmd = Command::new("/bin/sh");
187        cmd.arg("-c").arg(command);
188        cmd.current_dir(&cwd);
189        cmd.stdout(Stdio::piped());
190        cmd.stderr(Stdio::piped());
191
192        // Apply optional env overrides
193        if let Some(env_map) = args.get("env").and_then(|v| v.as_object()) {
194            for (key, val) in env_map {
195                let val_str = val.as_str().ok_or_else(|| Error::BadToolArgs {
196                    name: "run_background".to_string(),
197                    message: format!("env value for `{key}` must be a string, got {:?}", val),
198                })?;
199                cmd.env(key, val_str);
200            }
201        }
202
203        // Spawn the process
204        let mut child = cmd.spawn().map_err(|e| Error::Tool {
205            name: "run_background".into(),
206            message: format!("spawn failed: {e}"),
207        })?;
208
209        let stdout_handle = child.stdout.take();
210        let stderr_handle = child.stderr.take();
211
212        // Generate a job ID and store it
213        let mut manager = self.manager.lock().await;
214        manager.cleanup();
215        let job_id = manager.insert(Job {
216            state: JobState::Running,
217            created_at: Instant::now(),
218        });
219        drop(manager);
220
221        // Spawn a background task to wait for the process and capture output
222        let manager_clone = self.manager.clone();
223        let job_id_clone = job_id.clone();
224        tokio::spawn(async move {
225            let result = run_background_job(
226                child,
227                stdout_handle,
228                stderr_handle,
229                Duration::from_secs(timeout_secs),
230            )
231            .await;
232
233            let mut mgr = manager_clone.lock().await;
234            mgr.update(&job_id_clone, result);
235        });
236
237        Ok(json!({
238            "job_id": job_id,
239            "status": "spawned",
240            "message": format!("Background job `{}` spawned. Use `check_background` with job_id `{}` to retrieve output.", command, job_id)
241        })
242        .to_string())
243    }
244}
245
246/// The `check_background` tool: poll a previously spawned background job.
247pub struct CheckBackground {
248    manager: Arc<Mutex<BackgroundJobManager>>,
249}
250
251impl CheckBackground {
252    pub fn new(manager: Arc<Mutex<BackgroundJobManager>>) -> Self {
253        Self { manager }
254    }
255}
256
257#[async_trait]
258impl Tool for CheckBackground {
259    fn spec(&self) -> ToolSpec {
260        ToolSpec {
261            name: "check_background".into(),
262            description: "Check the status and output of a previously spawned background job. \
263                 Returns the current state (running, completed, failed, or timed out) \
264                 along with any captured stdout/stderr."
265                .into(),
266            parameters: json!({
267                "type": "object",
268                "properties": {
269                    "job_id": {
270                        "type": "string",
271                        "description": "The job ID returned by `run_background`"
272                    }
273                },
274                "required": ["job_id"]
275            }),
276        }
277    }
278
279    async fn execute(&self, args: Value) -> Result<String> {
280        let job_id = args["job_id"].as_str().ok_or_else(|| Error::BadToolArgs {
281            name: "check_background".into(),
282            message: "missing `job_id`".into(),
283        })?;
284
285        let mut manager = self.manager.lock().await;
286        manager.cleanup();
287
288        match manager.get_state(job_id) {
289            None => Ok(json!({
290                "job_id": job_id,
291                "status": "unknown",
292                "message": format!("No job found with ID `{}`. It may have expired or the ID is invalid.", job_id)
293            })
294            .to_string()),
295            Some(JobState::Running) => Ok(json!({
296                "job_id": job_id,
297                "status": "running",
298                "message": "Job is still running. Check again later."
299            })
300            .to_string()),
301            Some(JobState::Completed { stdout, stderr, exit_code }) => {
302                // Clean up completed job after returning its output
303                manager.update(job_id, JobState::Completed {
304                    stdout: String::new(),
305                    stderr: String::new(),
306                    exit_code,
307                });
308                Ok(json!({
309                    "job_id": job_id,
310                    "status": "completed",
311                    "exit_code": exit_code,
312                    "stdout": stdout,
313                    "stderr": stderr
314                })
315                .to_string())
316            }
317            Some(JobState::Failed { message }) => {
318                Ok(json!({
319                    "job_id": job_id,
320                    "status": "failed",
321                    "message": message
322                })
323                .to_string())
324            }
325            Some(JobState::TimedOut) => Ok(json!({
326                "job_id": job_id,
327                "status": "timed_out",
328                "message": "Job exceeded its timeout."
329            })
330            .to_string()),
331        }
332    }
333}
334
335/// Wait for a background process to finish, capturing its output.
336async fn run_background_job(
337    mut child: tokio::process::Child,
338    stdout_opt: Option<tokio::process::ChildStdout>,
339    stderr_opt: Option<tokio::process::ChildStderr>,
340    job_timeout: Duration,
341) -> JobState {
342    let stdout_task = async {
343        let mut out = String::new();
344        if let Some(mut reader) = stdout_opt {
345            let mut buf = [0u8; 8192];
346            let mut total = 0usize;
347            loop {
348                match reader.read(&mut buf).await {
349                    Ok(0) => break,
350                    Ok(n) => {
351                        if total + n > MAX_OUTPUT_BYTES {
352                            let take = MAX_OUTPUT_BYTES.saturating_sub(total);
353                            out.push_str(&String::from_utf8_lossy(&buf[..take]));
354                            out.push_str("\n... [stdout truncated]");
355                            // Drain remaining
356                            let _ = tokio::io::copy(&mut reader, &mut tokio::io::sink()).await;
357                            break;
358                        }
359                        out.push_str(&String::from_utf8_lossy(&buf[..n]));
360                        total += n;
361                    }
362                    Err(_) => break,
363                }
364            }
365        }
366        out
367    };
368
369    let stderr_task = async {
370        let mut err = String::new();
371        if let Some(mut reader) = stderr_opt {
372            let mut buf = [0u8; 8192];
373            let mut total = 0usize;
374            loop {
375                match reader.read(&mut buf).await {
376                    Ok(0) => break,
377                    Ok(n) => {
378                        if total + n > MAX_OUTPUT_BYTES {
379                            let take = MAX_OUTPUT_BYTES.saturating_sub(total);
380                            err.push_str(&String::from_utf8_lossy(&buf[..take]));
381                            err.push_str("\n... [stderr truncated]");
382                            let _ = tokio::io::copy(&mut reader, &mut tokio::io::sink()).await;
383                            break;
384                        }
385                        err.push_str(&String::from_utf8_lossy(&buf[..n]));
386                        total += n;
387                    }
388                    Err(_) => break,
389                }
390            }
391        }
392        err
393    };
394
395    // Wait for the process with a timeout
396    let wait_result = timeout(job_timeout, child.wait()).await;
397
398    match wait_result {
399        Err(_) => {
400            // Timed out — kill the process
401            let _ = child.start_kill();
402            JobState::TimedOut
403        }
404        Ok(Err(e)) => JobState::Failed {
405            message: format!("wait failed: {e}"),
406        },
407        Ok(Ok(status)) => {
408            let stdout = stdout_task.await;
409            let stderr = stderr_task.await;
410            let exit_code = status.code().unwrap_or(-1);
411            JobState::Completed {
412                stdout,
413                stderr,
414                exit_code,
415            }
416        }
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use serde_json::json;
424    use tempfile::TempDir;
425    use tokio::time::sleep;
426
427    /// Poll `check_background` until the job is no longer running, with a timeout.
428    async fn poll_until_done(
429        check_tool: &CheckBackground,
430        job_id: &str,
431        max_wait: Duration,
432    ) -> Value {
433        let start = Instant::now();
434        loop {
435            let result = check_tool.execute(json!({"job_id": job_id})).await.unwrap();
436            let parsed: Value = serde_json::from_str(&result).unwrap();
437            if parsed["status"] != "running" {
438                return parsed;
439            }
440            if start.elapsed() > max_wait {
441                panic!("timed out waiting for job {job_id} to complete");
442            }
443            sleep(Duration::from_millis(50)).await;
444        }
445    }
446
447    #[tokio::test]
448    async fn run_and_check_background() {
449        let tmp = TempDir::new().unwrap();
450        let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
451
452        let run_tool = RunBackground::new(tmp.path(), manager.clone());
453        let check_tool = CheckBackground::new(manager.clone());
454
455        // Run a quick command
456        let result = run_tool
457            .execute(json!({"command": "echo hello world"}))
458            .await
459            .unwrap();
460
461        let parsed: Value = serde_json::from_str(&result).unwrap();
462        let job_id = parsed["job_id"].as_str().unwrap().to_string();
463        assert_eq!(parsed["status"], "spawned");
464
465        let parsed = poll_until_done(&check_tool, &job_id, Duration::from_secs(5)).await;
466        assert_eq!(parsed["status"], "completed");
467        assert_eq!(parsed["exit_code"], 0);
468        assert!(parsed["stdout"].as_str().unwrap().contains("hello world"));
469    }
470
471    #[tokio::test]
472    async fn background_job_timeout() {
473        let tmp = TempDir::new().unwrap();
474        let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
475
476        let run_tool = RunBackground::new(tmp.path(), manager.clone());
477        let check_tool = CheckBackground::new(manager.clone());
478
479        // Run a command that sleeps longer than the timeout
480        let result = run_tool
481            .execute(json!({"command": "sleep 10", "timeout_secs": 1}))
482            .await
483            .unwrap();
484
485        let parsed: Value = serde_json::from_str(&result).unwrap();
486        let job_id = parsed["job_id"].as_str().unwrap().to_string();
487
488        let parsed = poll_until_done(&check_tool, &job_id, Duration::from_secs(5)).await;
489        assert_eq!(parsed["status"], "timed_out");
490    }
491
492    #[tokio::test]
493    async fn background_job_nonzero_exit() {
494        let tmp = TempDir::new().unwrap();
495        let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
496
497        let run_tool = RunBackground::new(tmp.path(), manager.clone());
498        let check_tool = CheckBackground::new(manager.clone());
499
500        let result = run_tool
501            .execute(json!({"command": "exit 42"}))
502            .await
503            .unwrap();
504
505        let parsed: Value = serde_json::from_str(&result).unwrap();
506        let job_id = parsed["job_id"].as_str().unwrap().to_string();
507
508        let parsed = poll_until_done(&check_tool, &job_id, Duration::from_secs(5)).await;
509        assert_eq!(parsed["status"], "completed");
510        assert_eq!(parsed["exit_code"], 42);
511    }
512
513    #[tokio::test]
514    async fn check_unknown_job() {
515        let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
516        let check_tool = CheckBackground::new(manager);
517
518        let result = check_tool
519            .execute(json!({"job_id": "bg-999"}))
520            .await
521            .unwrap();
522
523        let parsed: Value = serde_json::from_str(&result).unwrap();
524        assert_eq!(parsed["status"], "unknown");
525    }
526
527    #[tokio::test]
528    async fn run_background_with_cwd() {
529        let tmp = TempDir::new().unwrap();
530        let sub = tmp.path().join("subdir");
531        std::fs::create_dir(&sub).unwrap();
532        std::fs::write(sub.join("marker.txt"), "content").unwrap();
533
534        let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
535        let run_tool = RunBackground::new(tmp.path(), manager.clone());
536        let check_tool = CheckBackground::new(manager.clone());
537
538        let result = run_tool
539            .execute(json!({"command": "ls", "cwd": "subdir"}))
540            .await
541            .unwrap();
542
543        let parsed: Value = serde_json::from_str(&result).unwrap();
544        let job_id = parsed["job_id"].as_str().unwrap().to_string();
545
546        let parsed = poll_until_done(&check_tool, &job_id, Duration::from_secs(5)).await;
547        assert_eq!(parsed["status"], "completed");
548        assert!(parsed["stdout"].as_str().unwrap().contains("marker.txt"));
549    }
550
551    #[tokio::test]
552    async fn run_background_with_env() {
553        let tmp = TempDir::new().unwrap();
554        let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
555        let run_tool = RunBackground::new(tmp.path(), manager.clone());
556        let check_tool = CheckBackground::new(manager.clone());
557
558        let result = run_tool
559            .execute(json!({"command": "echo $MY_VAR", "env": {"MY_VAR": "hello"}}))
560            .await
561            .unwrap();
562
563        let parsed: Value = serde_json::from_str(&result).unwrap();
564        let job_id = parsed["job_id"].as_str().unwrap().to_string();
565
566        let parsed = poll_until_done(&check_tool, &job_id, Duration::from_secs(5)).await;
567        assert_eq!(parsed["status"], "completed");
568        assert!(parsed["stdout"].as_str().unwrap().contains("hello"));
569    }
570
571    #[tokio::test]
572    async fn run_background_missing_command() {
573        let tmp = TempDir::new().unwrap();
574        let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
575        let run_tool = RunBackground::new(tmp.path(), manager);
576
577        let err = run_tool.execute(json!({})).await.unwrap_err();
578        assert!(matches!(err, Error::BadToolArgs { .. }));
579    }
580
581    #[tokio::test]
582    async fn check_background_missing_job_id() {
583        let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
584        let check_tool = CheckBackground::new(manager);
585
586        let err = check_tool.execute(json!({})).await.unwrap_err();
587        assert!(matches!(err, Error::BadToolArgs { .. }));
588    }
589}