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 {
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!(), };
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)]
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 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 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 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 let running_id = manager.insert(Job {
631 state: JobState::Running,
632 created_at: Instant::now(),
633 });
634 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 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 assert!(manager.get_state(&running_id).is_some());
652 assert!(manager.get_state(&completed_id).is_none());
654
655 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}