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
52pub struct Job {
54 pub state: JobState,
55 pub 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 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 pub 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 pub fn clear(&mut self) {
112 self.jobs.clear();
113 }
114
115 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 { stdout, stderr, exit_code } => {
130 format!(
131 "exit code: {exit_code}\nstdout:\n{stdout}\nstderr:\n{stderr}"
132 )
133 }
134 JobState::Failed { message } => {
135 format!("failed: {message}")
136 }
137 JobState::TimedOut => {
138 "timed out".to_string()
139 }
140 JobState::Running => unreachable!(), };
142 Some((id, output))
143 }
144}
145
146pub 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 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 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 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 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
281pub 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 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
370async 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 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 let wait_result = timeout(job_timeout, child.wait()).await;
432
433 match wait_result {
434 Err(_) => {
435 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)]
456mod tests {
457 use super::*;
458 use serde_json::json;
459 use tempfile::TempDir;
460 use tokio::time::sleep;
461
462 async fn poll_until_done(
464 check_tool: &CheckBackground,
465 job_id: &str,
466 max_wait: Duration,
467 ) -> Value {
468 let start = Instant::now();
469 loop {
470 let result = check_tool.execute(json!({"job_id": job_id})).await.unwrap();
471 let parsed: Value = serde_json::from_str(&result).unwrap();
472 if parsed["status"] != "running" {
473 return parsed;
474 }
475 if start.elapsed() > max_wait {
476 panic!("timed out waiting for job {job_id} to complete");
477 }
478 sleep(Duration::from_millis(50)).await;
479 }
480 }
481
482 #[tokio::test]
483 async fn run_and_check_background() {
484 let tmp = TempDir::new().unwrap();
485 let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
486
487 let run_tool = RunBackground::new(tmp.path(), manager.clone());
488 let check_tool = CheckBackground::new(manager.clone());
489
490 let result = run_tool
492 .execute(json!({"command": "echo hello world"}))
493 .await
494 .unwrap();
495
496 let parsed: Value = serde_json::from_str(&result).unwrap();
497 let job_id = parsed["job_id"].as_str().unwrap().to_string();
498 assert_eq!(parsed["status"], "spawned");
499
500 let parsed = poll_until_done(&check_tool, &job_id, Duration::from_secs(5)).await;
501 assert_eq!(parsed["status"], "completed");
502 assert_eq!(parsed["exit_code"], 0);
503 assert!(parsed["stdout"].as_str().unwrap().contains("hello world"));
504 }
505
506 #[tokio::test]
507 async fn background_job_timeout() {
508 let tmp = TempDir::new().unwrap();
509 let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
510
511 let run_tool = RunBackground::new(tmp.path(), manager.clone());
512 let check_tool = CheckBackground::new(manager.clone());
513
514 let result = run_tool
516 .execute(json!({"command": "sleep 10", "timeout_secs": 1}))
517 .await
518 .unwrap();
519
520 let parsed: Value = serde_json::from_str(&result).unwrap();
521 let job_id = parsed["job_id"].as_str().unwrap().to_string();
522
523 let parsed = poll_until_done(&check_tool, &job_id, Duration::from_secs(5)).await;
524 assert_eq!(parsed["status"], "timed_out");
525 }
526
527 #[tokio::test]
528 async fn background_job_nonzero_exit() {
529 let tmp = TempDir::new().unwrap();
530 let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
531
532 let run_tool = RunBackground::new(tmp.path(), manager.clone());
533 let check_tool = CheckBackground::new(manager.clone());
534
535 let result = run_tool
536 .execute(json!({"command": "exit 42"}))
537 .await
538 .unwrap();
539
540 let parsed: Value = serde_json::from_str(&result).unwrap();
541 let job_id = parsed["job_id"].as_str().unwrap().to_string();
542
543 let parsed = poll_until_done(&check_tool, &job_id, Duration::from_secs(5)).await;
544 assert_eq!(parsed["status"], "completed");
545 assert_eq!(parsed["exit_code"], 42);
546 }
547
548 #[tokio::test]
549 async fn check_unknown_job() {
550 let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
551 let check_tool = CheckBackground::new(manager);
552
553 let result = check_tool
554 .execute(json!({"job_id": "bg-999"}))
555 .await
556 .unwrap();
557
558 let parsed: Value = serde_json::from_str(&result).unwrap();
559 assert_eq!(parsed["status"], "unknown");
560 }
561
562 #[tokio::test]
563 async fn run_background_with_cwd() {
564 let tmp = TempDir::new().unwrap();
565 let sub = tmp.path().join("subdir");
566 std::fs::create_dir(&sub).unwrap();
567 std::fs::write(sub.join("marker.txt"), "content").unwrap();
568
569 let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
570 let run_tool = RunBackground::new(tmp.path(), manager.clone());
571 let check_tool = CheckBackground::new(manager.clone());
572
573 let result = run_tool
574 .execute(json!({"command": "ls", "cwd": "subdir"}))
575 .await
576 .unwrap();
577
578 let parsed: Value = serde_json::from_str(&result).unwrap();
579 let job_id = parsed["job_id"].as_str().unwrap().to_string();
580
581 let parsed = poll_until_done(&check_tool, &job_id, Duration::from_secs(5)).await;
582 assert_eq!(parsed["status"], "completed");
583 assert!(parsed["stdout"].as_str().unwrap().contains("marker.txt"));
584 }
585
586 #[tokio::test]
587 async fn run_background_with_env() {
588 let tmp = TempDir::new().unwrap();
589 let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
590 let run_tool = RunBackground::new(tmp.path(), manager.clone());
591 let check_tool = CheckBackground::new(manager.clone());
592
593 let result = run_tool
594 .execute(json!({"command": "echo $MY_VAR", "env": {"MY_VAR": "hello"}}))
595 .await
596 .unwrap();
597
598 let parsed: Value = serde_json::from_str(&result).unwrap();
599 let job_id = parsed["job_id"].as_str().unwrap().to_string();
600
601 let parsed = poll_until_done(&check_tool, &job_id, Duration::from_secs(5)).await;
602 assert_eq!(parsed["status"], "completed");
603 assert!(parsed["stdout"].as_str().unwrap().contains("hello"));
604 }
605
606 #[tokio::test]
607 async fn run_background_missing_command() {
608 let tmp = TempDir::new().unwrap();
609 let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
610 let run_tool = RunBackground::new(tmp.path(), manager);
611
612 let err = run_tool.execute(json!({})).await.unwrap_err();
613 assert!(matches!(err, Error::BadToolArgs { .. }));
614 }
615
616 #[tokio::test]
617 async fn check_background_missing_job_id() {
618 let manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
619 let check_tool = CheckBackground::new(manager);
620
621 let err = check_tool.execute(json!({})).await.unwrap_err();
622 assert!(matches!(err, Error::BadToolArgs { .. }));
623 }
624
625 #[tokio::test]
626 async fn take_completed_returns_finished_job() {
627 let mut manager = BackgroundJobManager::new();
628 let running_id = manager.insert(Job {
630 state: JobState::Running,
631 created_at: Instant::now(),
632 });
633 let completed_id = manager.insert(Job {
635 state: JobState::Completed {
636 stdout: "hello".into(),
637 stderr: "".into(),
638 exit_code: 0,
639 },
640 created_at: Instant::now(),
641 });
642
643 let (id, output) = manager.take_completed().unwrap();
645 assert_eq!(id, completed_id);
646 assert!(output.contains("hello"));
647 assert!(output.contains("exit code: 0"));
648
649 assert!(manager.get_state(&running_id).is_some());
651 assert!(manager.get_state(&completed_id).is_none());
653
654 assert!(manager.take_completed().is_none());
656 }
657
658 #[tokio::test]
659 async fn take_completed_returns_failed_job() {
660 let mut manager = BackgroundJobManager::new();
661 manager.insert(Job {
662 state: JobState::Failed {
663 message: "something went wrong".into(),
664 },
665 created_at: Instant::now(),
666 });
667
668 let (id, output) = manager.take_completed().unwrap();
669 assert!(id.starts_with("bg-"));
670 assert!(output.contains("failed"));
671 assert!(output.contains("something went wrong"));
672 }
673
674 #[tokio::test]
675 async fn take_completed_returns_timed_out_job() {
676 let mut manager = BackgroundJobManager::new();
677 manager.insert(Job {
678 state: JobState::TimedOut,
679 created_at: Instant::now(),
680 });
681
682 let (id, output) = manager.take_completed().unwrap();
683 assert!(id.starts_with("bg-"));
684 assert!(output.contains("timed out"));
685 }
686
687 #[tokio::test]
688 async fn take_completed_empty_when_no_finished_jobs() {
689 let mut manager = BackgroundJobManager::new();
690 manager.insert(Job {
691 state: JobState::Running,
692 created_at: Instant::now(),
693 });
694 assert!(manager.take_completed().is_none());
695 }
696}