1use 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
28const MAX_OUTPUT_BYTES: usize = 128 * 1024;
30
31const DEFAULT_JOB_TIMEOUT: u64 = 3600; const JOB_TTL: Duration = Duration::from_secs(3600);
36
37#[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
52struct Job {
54 state: JobState,
55 created_at: Instant,
56}
57
58pub 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 fn next_id(&mut self) -> String {
80 let id = self.next_id;
81 self.next_id += 1;
82 format!("bg-{id}")
83 }
84
85 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 fn get_state(&self, id: &str) -> Option<JobState> {
94 self.jobs.get(id).map(|j| j.state.clone())
95 }
96
97 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 fn cleanup(&mut self) {
106 let cutoff = Instant::now() - JOB_TTL;
107 self.jobs.retain(|_, job| job.created_at > cutoff);
108 }
109}
110
111pub 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 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 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 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 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
246pub 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 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
335async 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 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 let wait_result = timeout(job_timeout, child.wait()).await;
397
398 match wait_result {
399 Err(_) => {
400 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 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 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 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}