From d9c58576a15d2ca03cfc02f4bb76990ad7ef5982 Mon Sep 17 00:00:00 2001 From: "Dhanji R. Prasanna" Date: Thu, 25 Dec 2025 18:23:10 +1100 Subject: [PATCH] feat: add background_process tool for launching long-running processes Adds a new tool that allows launching processes (like game servers) in the background while g3 continues to operate. The process runs independently with stdout/stderr captured to a log file. Features: - Named process tracking for easy reference - Automatic log capture to logs/background_processes/ - Returns PID and log file path for use with shell tool - Automatic cleanup on agent shutdown via Drop trait Usage: Use shell tool to interact with the process: - Read logs: tail -100 - Check status: ps -p - Stop process: kill Files: - New: crates/g3-core/src/background_process.rs - New: crates/g3-core/tests/background_process_demo_test.rs - Modified: crates/g3-core/src/lib.rs (tool definition + handler) - Modified: crates/g3-core/src/prompts.rs (documentation) --- crates/g3-core/src/background_process.rs | 279 ++++++++++++++++++ crates/g3-core/src/lib.rs | 72 +++++ crates/g3-core/src/prompts.rs | 6 + .../tests/background_process_demo_test.rs | 68 +++++ 4 files changed, 425 insertions(+) create mode 100644 crates/g3-core/src/background_process.rs create mode 100644 crates/g3-core/tests/background_process_demo_test.rs diff --git a/crates/g3-core/src/background_process.rs b/crates/g3-core/src/background_process.rs new file mode 100644 index 0000000..e2fbabe --- /dev/null +++ b/crates/g3-core/src/background_process.rs @@ -0,0 +1,279 @@ +//! Background process management for long-running tasks like game servers. +//! +//! This module provides a way to launch processes in the background with: +//! - Automatic log capture to files (stdout/stderr combined) +//! - Named process tracking for easy reference +//! - Process lifecycle management (start, stop via shell) +//! +//! The design is intentionally minimal - only one tool (`background_process`) is exposed. +//! Users can use the regular `shell` tool to: +//! - Read logs: `cat /path/to/logs.txt` or `tail -100 /path/to/logs.txt` +//! - Stop processes: `kill ` or `pkill -f ` +//! - Check status: `ps aux | grep ` + +use std::collections::HashMap; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tracing::debug; + +/// Information about a running background process +#[derive(Debug, Clone)] +pub struct ProcessInfo { + /// User-provided name for the process + pub name: String, + /// The command that was executed + pub command: String, + /// Process ID + pub pid: u32, + /// Path to the log file (combined stdout/stderr) + pub log_file: PathBuf, + /// Timestamp when the process was started + pub started_at: u64, + /// Working directory where the process was started + pub working_dir: PathBuf, +} + +/// Manages background processes launched by the agent +#[derive(Debug)] +pub struct BackgroundProcessManager { + /// Map of process name -> process info + processes: Arc>>, + /// Map of process name -> child handle (for cleanup) + children: Arc>>, + /// Directory where log files are stored + log_dir: PathBuf, +} + +impl BackgroundProcessManager { + /// Create a new background process manager + pub fn new(log_dir: PathBuf) -> Self { + // Ensure log directory exists + if let Err(e) = fs::create_dir_all(&log_dir) { + debug!("Failed to create log directory {:?}: {}", log_dir, e); + } + + Self { + processes: Arc::new(Mutex::new(HashMap::new())), + children: Arc::new(Mutex::new(HashMap::new())), + log_dir, + } + } + + /// Start a new background process + /// + /// # Arguments + /// * `name` - A unique name for this process (used to reference it later) + /// * `command` - The shell command to execute + /// * `working_dir` - The directory to run the command in + /// + /// # Returns + /// ProcessInfo on success, or an error message + pub fn start( + &self, + name: &str, + command: &str, + working_dir: &PathBuf, + ) -> Result { + // Check if a process with this name already exists + { + let processes = self.processes.lock().unwrap(); + if processes.contains_key(name) { + return Err(format!( + "A process named '{}' is already running. Stop it first or use a different name.", + name + )); + } + } + + // Create log file with timestamp + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let log_filename = format!("{}_{}.log", name, timestamp); + let log_file = self.log_dir.join(&log_filename); + + // Open log file for writing + let log_handle = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&log_file) + .map_err(|e| format!("Failed to create log file: {}", e))?; + + // Write header to log file + { + let mut file = &log_handle; + writeln!(file, "=== Background Process Log ===").ok(); + writeln!(file, "Name: {}", name).ok(); + writeln!(file, "Command: {}", command).ok(); + writeln!(file, "Working Directory: {:?}", working_dir).ok(); + writeln!(file, "Started: {}", timestamp).ok(); + writeln!(file, "================================\n").ok(); + } + + // Clone the file handle for stderr + let log_handle_stderr = log_handle + .try_clone() + .map_err(|e| format!("Failed to clone log file handle: {}", e))?; + + // Spawn the process + let child = Command::new("bash") + .arg("-c") + .arg(command) + .current_dir(working_dir) + .stdout(Stdio::from(log_handle)) + .stderr(Stdio::from(log_handle_stderr)) + .spawn() + .map_err(|e| format!("Failed to spawn process: {}", e))?; + + let pid = child.id(); + + let info = ProcessInfo { + name: name.to_string(), + command: command.to_string(), + pid, + log_file: log_file.clone(), + started_at: timestamp, + working_dir: working_dir.clone(), + }; + + // Store process info and child handle + { + let mut processes = self.processes.lock().unwrap(); + processes.insert(name.to_string(), info.clone()); + } + { + let mut children = self.children.lock().unwrap(); + children.insert(name.to_string(), child); + } + + debug!( + "Started background process '{}' (PID: {}) with logs at {:?}", + name, pid, log_file + ); + + Ok(info) + } + + /// List all tracked background processes + pub fn list(&self) -> Vec { + let processes = self.processes.lock().unwrap(); + processes.values().cloned().collect() + } + + /// Get info about a specific process by name + pub fn get(&self, name: &str) -> Option { + let processes = self.processes.lock().unwrap(); + processes.get(name).cloned() + } + + /// Check if a process is still running + pub fn is_running(&self, name: &str) -> bool { + let mut children = self.children.lock().unwrap(); + if let Some(child) = children.get_mut(name) { + match child.try_wait() { + Ok(Some(_)) => false, // Process has exited + Ok(None) => true, // Still running + Err(_) => false, // Error checking, assume not running + } + } else { + false + } + } + + /// Remove a process from tracking (call after it has been killed) + pub fn remove(&self, name: &str) -> Option { + let info = { + let mut processes = self.processes.lock().unwrap(); + processes.remove(name) + }; + { + let mut children = self.children.lock().unwrap(); + children.remove(name); + } + info + } + + /// Clean up all processes on shutdown + pub fn cleanup(&self) { + let mut children = self.children.lock().unwrap(); + for (name, mut child) in children.drain() { + debug!("Cleaning up background process '{}'", name); + let _ = child.kill(); + } + } +} + +impl Drop for BackgroundProcessManager { + fn drop(&mut self) { + self.cleanup(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + use std::time::Duration; + + #[test] + fn test_start_and_list_process() { + let temp_dir = std::env::temp_dir().join("g3_bg_test"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + let manager = BackgroundProcessManager::new(temp_dir.clone()); + + // Start a simple process that sleeps + let result = manager.start("test_sleep", "sleep 10", &temp_dir); + assert!(result.is_ok()); + + let info = result.unwrap(); + assert_eq!(info.name, "test_sleep"); + assert!(info.pid > 0); + assert!(info.log_file.exists()); + + // List should contain our process + let list = manager.list(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "test_sleep"); + + // Should be running + assert!(manager.is_running("test_sleep")); + + // Cleanup + manager.cleanup(); + + // Give it a moment to clean up + thread::sleep(Duration::from_millis(100)); + + let _ = fs::remove_dir_all(&temp_dir); + } + + #[test] + fn test_duplicate_name_rejected() { + let temp_dir = std::env::temp_dir().join("g3_bg_test_dup"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + let manager = BackgroundProcessManager::new(temp_dir.clone()); + + // Start first process + let result1 = manager.start("my_game", "sleep 10", &temp_dir); + assert!(result1.is_ok()); + + // Try to start another with same name + let result2 = manager.start("my_game", "sleep 5", &temp_dir); + assert!(result2.is_err()); + assert!(result2.unwrap_err().contains("already running")); + + // Cleanup + manager.cleanup(); + let _ = fs::remove_dir_all(&temp_dir); + } +} diff --git a/crates/g3-core/src/lib.rs b/crates/g3-core/src/lib.rs index f6df224..9eafc76 100644 --- a/crates/g3-core/src/lib.rs +++ b/crates/g3-core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod background_process; pub mod code_search; pub mod error_handling; pub mod feedback_extraction; @@ -865,6 +866,7 @@ pub struct Agent { requirements_sha: Option, /// Working directory for tool execution (set by --codebase-fast-start) working_dir: Option, + background_process_manager: std::sync::Arc, } impl Agent { @@ -1178,6 +1180,10 @@ impl Agent { tool_call_count: 0, requirements_sha: None, working_dir: None, + background_process_manager: std::sync::Arc::new( + background_process::BackgroundProcessManager::new( + paths::get_logs_dir().join("background_processes") + )), }) } @@ -2796,6 +2802,28 @@ impl Agent { "required": ["command"] }), }, + Tool { + name: "background_process".to_string(), + description: "Launch a long-running process in the background (e.g., game servers, dev servers). The process runs independently and logs are captured to a file. Use the regular 'shell' tool to read logs (cat/tail), check status (ps), or stop the process (kill). Returns the PID and log file path.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "A unique name for this process (e.g., 'game_server', 'my_app'). Used to identify the process and its log file." + }, + "command": { + "type": "string", + "description": "The shell command to execute in the background" + }, + "working_dir": { + "type": "string", + "description": "Optional working directory. Defaults to current directory if not specified." + } + }, + "required": ["name", "command"] + }), + }, Tool { name: "read_file".to_string(), description: "Read the contents of a file. For image files (png, jpg, jpeg, gif, bmp, tiff, webp), automatically extracts text using OCR. For text files, optionally read a specific character range.".to_string(), @@ -4793,6 +4821,50 @@ impl Agent { Ok("❌ Missing command argument".to_string()) } } + "background_process" => { + debug!("Processing background_process tool call"); + let name = tool_call.args.get("name") + .and_then(|v| v.as_str()); + let name = match name { + Some(n) => n, + None => return Ok("❌ Missing 'name' argument".to_string()), + }; + + let command = tool_call.args.get("command") + .and_then(|v| v.as_str()); + let command = match command { + Some(c) => c, + None => return Ok("❌ Missing 'command' argument".to_string()), + }; + + // Use provided working_dir, or fall back to agent's working_dir, or current dir + let work_dir = tool_call.args.get("working_dir") + .and_then(|v| v.as_str()) + .map(|s| std::path::PathBuf::from(shellexpand::tilde(s).as_ref())) + .or_else(|| working_dir.map(std::path::PathBuf::from)) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + + match self.background_process_manager.start(name, command, &work_dir) { + Ok(info) => { + Ok(format!( + "✅ Background process '{}' started\n\n\ + **PID:** {}\n\ + **Log file:** {}\n\ + **Working dir:** {}\n\n\ + To interact with this process, use the shell tool:\n\ + - View logs: `tail -100 {}`\n\ + - Follow logs: `tail -f {}` (blocks until Ctrl+C)\n\ + - Check status: `ps -p {}`\n\ + - Stop process: `kill {}`", + info.name, info.pid, + info.log_file.display(), info.working_dir.display(), + info.log_file.display(), info.log_file.display(), + info.pid, info.pid + )) + } + Err(e) => Ok(format!("❌ Failed to start background process: {}", e)), + } + } "read_file" => { debug!("Processing read_file tool call"); if let Some(file_path) = tool_call.args.get("file_path") { diff --git a/crates/g3-core/src/prompts.rs b/crates/g3-core/src/prompts.rs index 55cc24c..0f4a619 100644 --- a/crates/g3-core/src/prompts.rs +++ b/crates/g3-core/src/prompts.rs @@ -248,6 +248,12 @@ Short description for providers without native calling specs: - Format: {\"tool\": \"shell\", \"args\": {\"command\": \"your_command_here\"} - Example: {\"tool\": \"shell\", \"args\": {\"command\": \"ls ~/Downloads\"} +- **background_process**: Launch a long-running process in the background (e.g., game servers, dev servers) + - Format: {\"tool\": \"background_process\", \"args\": {\"name\": \"unique_name\", \"command\": \"your_command\"}} + - Example: {\"tool\": \"background_process\", \"args\": {\"name\": \"game_server\", \"command\": \"./run.sh\"}} + - Returns PID and log file path. Use shell tool to read logs (`tail -100 `), check status (`ps -p `), or stop (`kill `) + - Note: Process runs independently; logs are captured to a file for later inspection + - **read_file**: Read the contents of a file (supports partial reads via start/end) - Format: {\"tool\": \"read_file\", \"args\": {\"file_path\": \"path/to/file\", \"start\": 0, \"end\": 100} - Example: {\"tool\": \"read_file\", \"args\": {\"file_path\": \"src/main.rs\"} diff --git a/crates/g3-core/tests/background_process_demo_test.rs b/crates/g3-core/tests/background_process_demo_test.rs new file mode 100644 index 0000000..319d82b --- /dev/null +++ b/crates/g3-core/tests/background_process_demo_test.rs @@ -0,0 +1,68 @@ +use g3_core::background_process::BackgroundProcessManager; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; +use std::fs; + +#[test] +fn demo_background_process_with_script() { + // Create temp directories + let test_dir = std::env::temp_dir().join("g3_bg_demo"); + let _ = fs::remove_dir_all(&test_dir); + fs::create_dir_all(&test_dir).unwrap(); + + // Create a test script + let script_path = test_dir.join("test.sh"); + fs::write(&script_path, r#"#!/bin/bash +echo "Starting..." +for i in 1 2 3; do + echo "Tick $i" + sleep 0.5 +done +echo "Done!" +"#).unwrap(); + + // Make executable + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).unwrap(); + } + + let log_dir = test_dir.join("logs"); + let manager = BackgroundProcessManager::new(log_dir); + + println!("\n=== Background Process Demo ==="); + + match manager.start("demo", "./test.sh", &test_dir) { + Ok(info) => { + println!("✅ Started '{}' with PID {}", info.name, info.pid); + println!(" Log file: {:?}", info.log_file); + + // Wait for script to produce some output + thread::sleep(Duration::from_millis(800)); + + // Read logs + let logs = fs::read_to_string(&info.log_file).unwrap_or_default(); + println!("\n📜 Logs so far:\n{}", logs); + + // Should still be running + assert!(manager.is_running("demo"), "Process should still be running"); + println!("🔍 Process is running: true"); + + // Wait for completion + thread::sleep(Duration::from_secs(2)); + + // Read final logs + let final_logs = fs::read_to_string(&info.log_file).unwrap_or_default(); + println!("\n📜 Final logs:\n{}", final_logs); + + assert!(final_logs.contains("Done!"), "Should have completed"); + } + Err(e) => panic!("Failed to start: {}", e), + } + + // Cleanup + let _ = fs::remove_dir_all(&test_dir); + println!("\n✅ Demo complete!"); +}