diff --git a/bun.lock b/bun.lock index c1f630d139..38515a8ff9 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "mux", diff --git a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx index 7f7f22bf3c..c64eca2a1d 100644 --- a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -266,7 +266,8 @@ export const ReviewPanel: React.FC = ({ } const diffOutput = diffResult.data.output ?? ""; - const truncationInfo = diffResult.data.truncated; + const truncationInfo = + "truncated" in diffResult.data ? diffResult.data.truncated : undefined; const fileDiffs = parseDiff(diffOutput); const allHunks = extractAllHunks(fileDiffs); diff --git a/src/cli/run.ts b/src/cli/run.ts index b14d20d7cf..0cb440d6b2 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -17,6 +17,7 @@ import { PartialService } from "@/node/services/partialService"; import { InitStateManager } from "@/node/services/initStateManager"; import { AIService } from "@/node/services/aiService"; import { AgentSession, type AgentSessionChatEvent } from "@/node/services/agentSession"; +import { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import { isCaughtUpMessage, isStreamAbort, @@ -267,7 +268,14 @@ async function main(): Promise { const historyService = new HistoryService(config); const partialService = new PartialService(config, historyService); const initStateManager = new InitStateManager(config); - const aiService = new AIService(config, historyService, partialService, initStateManager); + const backgroundProcessManager = new BackgroundProcessManager(); + const aiService = new AIService( + config, + historyService, + partialService, + initStateManager, + backgroundProcessManager + ); ensureProvidersConfig(config); const session = new AgentSession({ @@ -277,6 +285,7 @@ async function main(): Promise { partialService, aiService, initStateManager, + backgroundProcessManager, }); await session.ensureMetadata({ diff --git a/src/common/orpc/schemas/runtime.ts b/src/common/orpc/schemas/runtime.ts index 32a774e56c..125e39f547 100644 --- a/src/common/orpc/schemas/runtime.ts +++ b/src/common/orpc/schemas/runtime.ts @@ -12,6 +12,12 @@ export const RuntimeModeSchema = z.enum(["local", "worktree", "ssh"]); * * This allows two-way compatibility: users can upgrade/downgrade without breaking workspaces. */ +// Common field for background process output directory +const bgOutputDirField = z + .string() + .optional() + .meta({ description: "Directory for background process output (e.g., /tmp/mux-bashes)" }); + export const RuntimeConfigSchema = z.union([ // Legacy local with srcBaseDir (treated as worktree) z.object({ @@ -19,10 +25,12 @@ export const RuntimeConfigSchema = z.union([ srcBaseDir: z.string().meta({ description: "Base directory where all workspaces are stored (legacy worktree config)", }), + bgOutputDir: bgOutputDirField, }), // New project-dir local (no srcBaseDir) z.object({ type: z.literal("local"), + bgOutputDir: bgOutputDirField, }), // Explicit worktree runtime z.object({ @@ -30,6 +38,7 @@ export const RuntimeConfigSchema = z.union([ srcBaseDir: z .string() .meta({ description: "Base directory where all workspaces are stored (e.g., ~/.mux/src)" }), + bgOutputDir: bgOutputDirField, }), // SSH runtime z.object({ @@ -40,6 +49,7 @@ export const RuntimeConfigSchema = z.union([ srcBaseDir: z .string() .meta({ description: "Base directory on remote host where all workspaces are stored" }), + bgOutputDir: bgOutputDirField, identityFile: z .string() .optional() diff --git a/src/common/types/tools.ts b/src/common/types/tools.ts index fc71d350c6..5f805193e2 100644 --- a/src/common/types/tools.ts +++ b/src/common/types/tools.ts @@ -7,6 +7,8 @@ export interface BashToolArgs { script: string; timeout_secs?: number; // Optional: defaults to 3 seconds for interactivity + run_in_background?: boolean; // Run without blocking (for long-running processes) + display_name?: string; // Human-readable name for background processes } interface CommonBashFields { @@ -26,6 +28,14 @@ export type BashToolResult = totalLines: number; }; }) + | (CommonBashFields & { + success: true; + output: string; + exitCode: 0; + backgroundProcessId: string; // Background spawn succeeded + stdout_path: string; // Path to stdout log file + stderr_path: string; // Path to stderr log file + }) | (CommonBashFields & { success: false; output?: string; @@ -190,6 +200,33 @@ export interface StatusSetToolArgs { url?: string; } +// Bash Background Tool Types +export interface BashBackgroundTerminateArgs { + process_id: string; +} + +export type BashBackgroundTerminateResult = + | { success: true; message: string; display_name?: string } + | { success: false; error: string }; + +// Bash Background List Tool Types +export type BashBackgroundListArgs = Record; + +export interface BashBackgroundListProcess { + process_id: string; + status: "running" | "exited" | "killed" | "failed"; + script: string; + uptime_ms: number; + exitCode?: number; + stdout_path: string; // Path to stdout log file + stderr_path: string; // Path to stderr log file + display_name?: string; // Human-readable name (e.g., "Dev Server") +} + +export type BashBackgroundListResult = + | { success: true; processes: BashBackgroundListProcess[] } + | { success: false; error: string }; + export type StatusSetToolResult = | { success: true; diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index fe388737a3..f53ad142f0 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -52,6 +52,26 @@ export const TOOL_DEFINITIONS = { .describe( `Timeout (seconds, default: ${BASH_DEFAULT_TIMEOUT_SECS}). Start small and increase on retry; avoid large initial values to keep UX responsive` ), + run_in_background: z + .boolean() + .default(false) + .describe( + "Run this command in the background without blocking. " + + "Use for processes running >5s (dev servers, builds, file watchers). " + + "Do NOT use for quick commands (<5s), interactive processes (no stdin support), " + + "or processes requiring real-time output (use foreground with larger timeout instead). " + + "Returns immediately with process_id (e.g., bg-a1b2c3d4), stdout_path, and stderr_path. " + + "Read output with bash (e.g., tail -50 ). " + + "Terminate with bash_background_terminate using the process_id. " + + "Process persists until terminated or workspace is removed." + ), + display_name: z + .string() + .optional() + .describe( + "Human-readable name for background processes (e.g., 'Dev Server', 'TypeCheck Watch'). " + + "Only used when run_in_background=true." + ), }), }, file_read: { @@ -229,6 +249,26 @@ export const TOOL_DEFINITIONS = { }) .strict(), }, + bash_background_list: { + description: + "List all background processes started with bash(run_in_background=true). " + + "Returns process_id, status, script, stdout_path, stderr_path for each process. " + + "Use to find process_id for termination or check output file paths.", + schema: z.object({}), + }, + bash_background_terminate: { + description: + "Terminate a background process started with bash(run_in_background=true). " + + "Use process_id from the original bash response or from bash_background_list. " + + "Sends SIGTERM, waits briefly, then SIGKILL if needed. " + + "Output files remain available after termination.", + schema: z.object({ + process_id: z + .string() + .regex(/^bg-[0-9a-f]{8}$/, "Invalid process ID format") + .describe("Background process ID to terminate"), + }), + }, web_fetch: { description: `Fetch a web page and extract its main content as clean markdown. ` + @@ -272,6 +312,8 @@ export function getAvailableTools(modelString: string): string[] { // Base tools available for all models const baseTools = [ "bash", + "bash_background_list", + "bash_background_terminate", "file_read", "file_edit_replace_string", // "file_edit_replace_lines", // DISABLED: causes models to break repo state diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index 31cf3aa466..3f11ed29c1 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -1,6 +1,8 @@ import { type Tool } from "ai"; import { createFileReadTool } from "@/node/services/tools/file_read"; import { createBashTool } from "@/node/services/tools/bash"; +import { createBashBackgroundListTool } from "@/node/services/tools/bash_background_list"; +import { createBashBackgroundTerminateTool } from "@/node/services/tools/bash_background_terminate"; import { createFileEditReplaceStringTool } from "@/node/services/tools/file_edit_replace_string"; // DISABLED: import { createFileEditReplaceLinesTool } from "@/node/services/tools/file_edit_replace_lines"; import { createFileEditInsertTool } from "@/node/services/tools/file_edit_insert"; @@ -12,6 +14,7 @@ import { log } from "@/node/services/log"; import type { Runtime } from "@/node/runtime/Runtime"; import type { InitStateManager } from "@/node/services/initStateManager"; +import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; /** * Configuration for tools that need runtime context @@ -31,6 +34,10 @@ export interface ToolConfiguration { runtimeTempDir: string; /** Overflow policy for bash tool output (optional, not exposed to AI) */ overflow_policy?: "truncate" | "tmpfile"; + /** Background process manager for bash tool (optional, AI-only) */ + backgroundProcessManager?: BackgroundProcessManager; + /** Workspace ID for tracking background processes (optional for token estimation) */ + workspaceId?: string; } /** @@ -101,6 +108,8 @@ export async function getToolsForModel( // and line number miscalculations. Use file_edit_replace_string instead. // file_edit_replace_lines: wrap(createFileEditReplaceLinesTool(config)), bash: wrap(createBashTool(config)), + bash_background_list: wrap(createBashBackgroundListTool(config)), + bash_background_terminate: wrap(createBashBackgroundTerminateTool(config)), web_fetch: wrap(createWebFetchTool(config)), }; diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 0294dde7e1..29fc55206a 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -564,6 +564,30 @@ if (gotTheLock) { } }); + // Track if we're in the middle of disposing to prevent re-entry + let isDisposing = false; + + app.on("before-quit", (event) => { + // Skip if already disposing or no services to clean up + if (isDisposing || !services) { + return; + } + + // Prevent quit, clean up, then quit again + event.preventDefault(); + isDisposing = true; + + // Race dispose against timeout to ensure app quits even if disposal hangs + const disposePromise = services.dispose().catch((err) => { + console.error("Error during ServiceContainer dispose:", err); + }); + const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 5000)); + + void Promise.race([disposePromise, timeoutPromise]).finally(() => { + app.quit(); + }); + }); + app.on("window-all-closed", () => { if (process.platform !== "darwin") { app.quit(); diff --git a/src/node/runtime/LocalBackgroundHandle.ts b/src/node/runtime/LocalBackgroundHandle.ts new file mode 100644 index 0000000000..41a8c221c8 --- /dev/null +++ b/src/node/runtime/LocalBackgroundHandle.ts @@ -0,0 +1,81 @@ +import type { BackgroundHandle } from "./Runtime"; +import { parseExitCode, buildTerminateCommand } from "./backgroundCommands"; +import { log } from "@/node/services/log"; +import { execAsync } from "@/node/utils/disposableExec"; +import { getBashPath } from "@/node/utils/main/bashPath"; +import * as fs from "fs/promises"; +import * as path from "path"; + +/** + * Handle to a local background process. + * + * Uses file-based status detection (same approach as SSHBackgroundHandle): + * - Process is running if exit_code file doesn't exist + * - Exit code is read from exit_code file (written by bash trap on exit) + * + * Output is written directly to files via shell redirection (nohup ... > file), + * so the process continues writing even if mux closes. + */ +export class LocalBackgroundHandle implements BackgroundHandle { + private terminated = false; + + constructor( + private readonly pid: number, + public readonly outputDir: string + ) {} + + /** + * Get the exit code from the exit_code file. + * Returns null if process is still running (file doesn't exist yet). + */ + async getExitCode(): Promise { + try { + const exitCodePath = path.join(this.outputDir, "exit_code"); + const content = await fs.readFile(exitCodePath, "utf-8"); + return parseExitCode(content); + } catch { + // File doesn't exist or can't be read - process still running or crashed + return null; + } + } + + /** + * Terminate the process by killing the process group. + * Sends SIGTERM (15), waits 2 seconds, then SIGKILL (9) if still running. + * + * Uses buildTerminateCommand for parity with SSH - works on Linux, macOS, and Windows MSYS2. + */ + async terminate(): Promise { + if (this.terminated) return; + + try { + const exitCodePath = path.join(this.outputDir, "exit_code"); + const terminateCmd = buildTerminateCommand(this.pid, exitCodePath); + log.debug(`LocalBackgroundHandle: Terminating process group ${this.pid}`); + using proc = execAsync(terminateCmd, { shell: getBashPath() }); + await proc.result; + } catch (error) { + // Process may already be dead - that's fine + log.debug( + `LocalBackgroundHandle.terminate: Error: ${error instanceof Error ? error.message : String(error)}` + ); + } + + this.terminated = true; + } + + /** + * Clean up resources. + * No local resources to clean - process runs independently via nohup. + */ + async dispose(): Promise { + // No resources to clean up - we don't own the process + } + + /** + * Write meta.json to the output directory. + */ + async writeMeta(metaJson: string): Promise { + await fs.writeFile(path.join(this.outputDir, "meta.json"), metaJson); + } +} diff --git a/src/node/runtime/LocalBaseRuntime.ts b/src/node/runtime/LocalBaseRuntime.ts index eec2290089..2116841e47 100644 --- a/src/node/runtime/LocalBaseRuntime.ts +++ b/src/node/runtime/LocalBaseRuntime.ts @@ -3,6 +3,7 @@ import * as fs from "fs"; import * as fsPromises from "fs/promises"; import * as path from "path"; import { Readable, Writable } from "stream"; +import { randomBytes } from "crypto"; import type { Runtime, ExecOptions, @@ -15,14 +16,20 @@ import type { WorkspaceForkParams, WorkspaceForkResult, InitLogger, + BackgroundSpawnOptions, + BackgroundSpawnResult, } from "./Runtime"; import { RuntimeError as RuntimeErrorClass } from "./Runtime"; import { NON_INTERACTIVE_ENV_VARS } from "@/common/constants/env"; import { getBashPath } from "@/node/utils/main/bashPath"; import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "@/common/constants/exitCodes"; -import { DisposableProcess } from "@/node/utils/disposableExec"; +import { DisposableProcess, execAsync } from "@/node/utils/disposableExec"; import { expandTilde } from "./tildeExpansion"; import { getInitHookPath, createLineBufferedLoggers } from "./initHook"; +import { LocalBackgroundHandle } from "./LocalBackgroundHandle"; +import { buildWrapperScript, buildSpawnCommand, parsePid } from "./backgroundCommands"; +import { log } from "@/node/services/log"; +import { toPosixPath } from "@/node/utils/paths"; /** * Abstract base class for local runtimes (both WorktreeRuntime and LocalRuntime). @@ -44,6 +51,12 @@ import { getInitHookPath, createLineBufferedLoggers } from "./initHook"; * - forkWorkspace() */ export abstract class LocalBaseRuntime implements Runtime { + protected readonly bgOutputDir: string; + + constructor(bgOutputDir: string) { + this.bgOutputDir = expandTilde(bgOutputDir); + } + async exec(command: string, options: ExecOptions): Promise { const startTime = performance.now(); @@ -314,6 +327,73 @@ export abstract class LocalBaseRuntime implements Runtime { return path.resolve(basePath, target); } + /** + * Spawn a background process that persists independently of mux. + * Output is written to files in bgOutputDir/{workspaceId}/{processId}/. + */ + async spawnBackground( + script: string, + options: BackgroundSpawnOptions + ): Promise { + log.debug(`LocalBaseRuntime.spawnBackground: Spawning in ${options.cwd}`); + + // Check if working directory exists + try { + await fsPromises.access(options.cwd); + } catch { + return { success: false, error: `Working directory does not exist: ${options.cwd}` }; + } + + // Generate unique process ID and compute output directory + const processId = `bg-${randomBytes(4).toString("hex")}`; + const outputDir = path.join(this.bgOutputDir, options.workspaceId, processId); + const stdoutPath = path.join(outputDir, "stdout.log"); + const stderrPath = path.join(outputDir, "stderr.log"); + const exitCodePath = path.join(outputDir, "exit_code"); + + // Create output directory and empty files + await fsPromises.mkdir(outputDir, { recursive: true }); + await fsPromises.writeFile(stdoutPath, ""); + await fsPromises.writeFile(stderrPath, ""); + + // Build wrapper script and spawn command using shared builders (same as SSH for parity) + // On Windows, convert paths to POSIX format for Git Bash (C:\foo → /c/foo) + const wrapperScript = buildWrapperScript({ + exitCodePath: toPosixPath(exitCodePath), + cwd: toPosixPath(options.cwd), + env: { ...options.env, ...NON_INTERACTIVE_ENV_VARS }, + script, + }); + + const spawnCommand = buildSpawnCommand({ + wrapperScript, + stdoutPath: toPosixPath(stdoutPath), + stderrPath: toPosixPath(stderrPath), + bashPath: getBashPath(), + niceness: options.niceness, + }); + + try { + // Use bash shell explicitly - spawnCommand uses POSIX commands (nohup, ps) + using proc = execAsync(spawnCommand, { shell: getBashPath() }); + const result = await proc.result; + + const pid = parsePid(result.stdout); + if (!pid) { + log.debug(`LocalBaseRuntime.spawnBackground: Invalid PID: ${result.stdout}`); + return { success: false, error: `Failed to get valid PID from spawn: ${result.stdout}` }; + } + + log.debug(`LocalBaseRuntime.spawnBackground: Spawned with PID ${pid}`); + const handle = new LocalBackgroundHandle(pid, outputDir); + return { success: true, handle, pid }; + } catch (e) { + const err = e as Error; + log.debug(`LocalBaseRuntime.spawnBackground: Failed to spawn: ${err.message}`); + return { success: false, error: err.message }; + } + } + // Abstract methods that subclasses must implement abstract getWorkspacePath(projectPath: string, workspaceName: string): string; diff --git a/src/node/runtime/LocalRuntime.test.ts b/src/node/runtime/LocalRuntime.test.ts index 4aec268cdf..b814a080c0 100644 --- a/src/node/runtime/LocalRuntime.test.ts +++ b/src/node/runtime/LocalRuntime.test.ts @@ -38,7 +38,7 @@ describe("LocalRuntime", () => { describe("constructor and getWorkspacePath", () => { it("stores projectPath and returns it regardless of arguments", () => { - const runtime = new LocalRuntime("/home/user/my-project"); + const runtime = new LocalRuntime("/home/user/my-project", testDir); // Both arguments are ignored - always returns the project path expect(runtime.getWorkspacePath("/other/path", "some-branch")).toBe("/home/user/my-project"); expect(runtime.getWorkspacePath("", "")).toBe("/home/user/my-project"); @@ -46,14 +46,14 @@ describe("LocalRuntime", () => { it("does not expand tilde (unlike WorktreeRuntime)", () => { // LocalRuntime stores the path as-is; callers must pass expanded paths - const runtime = new LocalRuntime("~/my-project"); + const runtime = new LocalRuntime("~/my-project", testDir); expect(runtime.getWorkspacePath("", "")).toBe("~/my-project"); }); }); describe("createWorkspace", () => { it("succeeds when directory exists", async () => { - const runtime = new LocalRuntime(testDir); + const runtime = new LocalRuntime(testDir, testDir); const logger = createMockLogger(); const result = await runtime.createWorkspace({ @@ -72,7 +72,7 @@ describe("LocalRuntime", () => { it("fails when directory does not exist", async () => { const nonExistentPath = path.join(testDir, "does-not-exist"); - const runtime = new LocalRuntime(nonExistentPath); + const runtime = new LocalRuntime(nonExistentPath, testDir); const logger = createMockLogger(); const result = await runtime.createWorkspace({ @@ -90,7 +90,7 @@ describe("LocalRuntime", () => { describe("deleteWorkspace", () => { it("returns success without deleting anything", async () => { - const runtime = new LocalRuntime(testDir); + const runtime = new LocalRuntime(testDir, testDir); // Create a test file to verify it isn't deleted const testFile = path.join(testDir, "delete-test.txt"); @@ -115,7 +115,7 @@ describe("LocalRuntime", () => { }); it("returns success even with force=true (still no-op)", async () => { - const runtime = new LocalRuntime(testDir); + const runtime = new LocalRuntime(testDir, testDir); const result = await runtime.deleteWorkspace(testDir, "main", true); @@ -134,7 +134,7 @@ describe("LocalRuntime", () => { describe("renameWorkspace", () => { it("is a no-op that returns success with same path", async () => { - const runtime = new LocalRuntime(testDir); + const runtime = new LocalRuntime(testDir, testDir); const result = await runtime.renameWorkspace(testDir, "old", "new"); @@ -148,7 +148,7 @@ describe("LocalRuntime", () => { describe("forkWorkspace", () => { it("returns error - operation not supported", async () => { - const runtime = new LocalRuntime(testDir); + const runtime = new LocalRuntime(testDir, testDir); const logger = createMockLogger(); const result = await runtime.forkWorkspace({ @@ -166,7 +166,7 @@ describe("LocalRuntime", () => { describe("inherited LocalBaseRuntime methods", () => { it("exec runs commands in projectPath", async () => { - const runtime = new LocalRuntime(testDir); + const runtime = new LocalRuntime(testDir, testDir); const stream = await runtime.exec("pwd", { cwd: testDir, @@ -187,7 +187,7 @@ describe("LocalRuntime", () => { }); it("stat works on projectPath", async () => { - const runtime = new LocalRuntime(testDir); + const runtime = new LocalRuntime(testDir, testDir); const stat = await runtime.stat(testDir); @@ -195,7 +195,7 @@ describe("LocalRuntime", () => { }); it("resolvePath expands tilde", async () => { - const runtime = new LocalRuntime(testDir); + const runtime = new LocalRuntime(testDir, testDir); const resolved = await runtime.resolvePath("~"); @@ -203,7 +203,7 @@ describe("LocalRuntime", () => { }); it("normalizePath resolves relative paths", () => { - const runtime = new LocalRuntime(testDir); + const runtime = new LocalRuntime(testDir, testDir); const result = runtime.normalizePath(".", testDir); diff --git a/src/node/runtime/LocalRuntime.ts b/src/node/runtime/LocalRuntime.ts index aaabea879f..eb97c65f5f 100644 --- a/src/node/runtime/LocalRuntime.ts +++ b/src/node/runtime/LocalRuntime.ts @@ -25,8 +25,8 @@ import { LocalBaseRuntime } from "./LocalBaseRuntime"; export class LocalRuntime extends LocalBaseRuntime { private readonly projectPath: string; - constructor(projectPath: string) { - super(); + constructor(projectPath: string, bgOutputDir: string) { + super(bgOutputDir); this.projectPath = projectPath; } diff --git a/src/node/runtime/Runtime.ts b/src/node/runtime/Runtime.ts index 4e01a0ceb9..39050d7995 100644 --- a/src/node/runtime/Runtime.ts +++ b/src/node/runtime/Runtime.ts @@ -50,12 +50,15 @@ export interface ExecOptions { /** Environment variables to inject */ env?: Record; /** - * Timeout in seconds (REQUIRED) + * Timeout in seconds. * - * Prevents zombie processes by ensuring all spawned processes are eventually killed. + * When provided, prevents zombie processes by ensuring spawned processes are killed. * Even long-running commands should have a reasonable upper bound (e.g., 3600s for 1 hour). + * + * When omitted, no timeout is applied - use only for internal operations like + * spawning background processes that are designed to run indefinitely. */ - timeout: number; + timeout?: number; /** Process niceness level (-20 to 19, lower = higher priority) */ niceness?: number; /** Abort signal for cancellation */ @@ -64,6 +67,61 @@ export interface ExecOptions { forcePTY?: boolean; } +/** + * Options for spawning a background process + */ +export interface BackgroundSpawnOptions { + /** Working directory for command execution */ + cwd: string; + /** Workspace ID for output directory organization */ + workspaceId: string; + /** Environment variables to inject */ + env?: Record; + /** Process niceness level (-20 to 19, lower = higher priority) */ + niceness?: number; +} + +/** + * Handle to a background process. + * Abstracts away whether process is local or remote. + * + * Output is written directly to files by the runtime. + * This handle is for lifecycle management and output directory operations. + */ +export interface BackgroundHandle { + /** Output directory containing stdout.log, stderr.log, meta.json */ + readonly outputDir: string; + + /** + * Get the exit code if the process has exited. + * Returns null if still running. + * Async because SSH needs to read remote exit_code file. + */ + getExitCode(): Promise; + + /** + * Terminate the process (SIGTERM → wait → SIGKILL). + */ + terminate(): Promise; + + /** + * Clean up resources (called after process exits or on error). + */ + dispose(): Promise; + + /** + * Write meta.json to the output directory. + */ + writeMeta(metaJson: string): Promise; +} + +/** + * Result of spawning a background process + */ +export type BackgroundSpawnResult = + | { success: true; handle: BackgroundHandle; pid: number } + | { success: false; error: string }; + /** * Streaming result from executing a command */ @@ -211,6 +269,21 @@ export interface Runtime { */ exec(command: string, options: ExecOptions): Promise; + /** + * Spawn a detached background process. + * Returns a handle for monitoring output and terminating the process. + * Unlike exec(), background processes have no timeout and run until terminated. + * + * Output directory is determined by runtime implementation: + * - LocalRuntime: {bgOutputDir}/{workspaceId}/{processId}/ (default: /tmp/mux-bashes) + * - SSHRuntime: {bgOutputDir}/{workspaceId}/{processId}/ (default: /tmp/mux-bashes) + * + * @param script Bash script to execute + * @param options Execution options (cwd, workspaceId, processId, env, niceness) + * @returns BackgroundHandle on success, or error + */ + spawnBackground(script: string, options: BackgroundSpawnOptions): Promise; + /** * Read file contents as a stream * @param path Absolute or relative path to file diff --git a/src/node/runtime/SSHBackgroundHandle.ts b/src/node/runtime/SSHBackgroundHandle.ts new file mode 100644 index 0000000000..4c36c11edd --- /dev/null +++ b/src/node/runtime/SSHBackgroundHandle.ts @@ -0,0 +1,108 @@ +import type { BackgroundHandle } from "./Runtime"; +import type { SSHRuntime } from "./SSHRuntime"; +import { execBuffered } from "@/node/utils/runtime/helpers"; +import { expandTildeForSSH } from "./tildeExpansion"; +import { log } from "@/node/services/log"; +import { buildTerminateCommand, parseExitCode } from "./backgroundCommands"; + +/** + * Handle to an SSH background process. + * + * Uses file-based status detection: + * - Process is running if exit_code file doesn't exist (getExitCode returns null) + * - Exit code is read from exit_code file (written by trap on process exit) + * + * Output files (stdout.log, stderr.log) are on the remote machine + * and read by agents via bash("tail ...") commands. + */ +export class SSHBackgroundHandle implements BackgroundHandle { + private terminated = false; + + constructor( + private readonly sshRuntime: SSHRuntime, + private readonly pid: number, + /** Remote path to output directory (e.g., /tmp/mux-bashes/workspace/bg-xxx) */ + public readonly outputDir: string + ) {} + + /** + * Get the exit code from the remote exit_code file. + * Returns null if process is still running (file doesn't exist yet). + */ + async getExitCode(): Promise { + try { + const exitCodePath = expandTildeForSSH(`${this.outputDir}/exit_code`); + const result = await execBuffered( + this.sshRuntime, + `cat ${exitCodePath} 2>/dev/null || echo ""`, + { + cwd: "/", + timeout: 10, + } + ); + return parseExitCode(result.stdout); + } catch (error) { + log.debug( + `SSHBackgroundHandle.getExitCode: Error reading exit code: ${error instanceof Error ? error.message : String(error)}` + ); + return null; + } + } + + /** + * Terminate the process group via SSH. + * Sends SIGTERM to process group, waits briefly, then SIGKILL if still running. + * + * Uses negative PID to kill entire process group (PID === PGID due to set -m). + * Same pattern as Local for parity. + */ + async terminate(): Promise { + if (this.terminated) return; + + try { + // Use shared buildTerminateCommand for parity with Local + // Pass raw path + expandTildeForSSH to avoid double-quoting + // (expandTildeForSSH returns quoted strings, buildTerminateCommand would quote again) + const exitCodePath = `${this.outputDir}/exit_code`; + const terminateCmd = buildTerminateCommand(this.pid, exitCodePath, expandTildeForSSH); + await execBuffered(this.sshRuntime, terminateCmd, { + cwd: "/", + timeout: 15, + }); + log.debug(`SSHBackgroundHandle: Terminated process group ${this.pid}`); + } catch (error) { + // Process may already be dead - that's fine + log.debug( + `SSHBackgroundHandle.terminate: Error during terminate: ${error instanceof Error ? error.message : String(error)}` + ); + } + + this.terminated = true; + } + + /** + * Clean up resources. + * No local resources to clean for SSH handles. + */ + async dispose(): Promise { + // No local resources to clean up + } + + /** + * Write meta.json to the output directory on the remote machine. + */ + async writeMeta(metaJson: string): Promise { + try { + // Use heredoc for safe JSON writing + const metaPath = expandTildeForSSH(`${this.outputDir}/meta.json`); + await execBuffered(this.sshRuntime, `cat > ${metaPath} << 'METAEOF'\n${metaJson}\nMETAEOF`, { + cwd: "/", + timeout: 10, + }); + } catch (error) { + log.debug( + `SSHBackgroundHandle.writeMeta: Error: ${error instanceof Error ? error.message : String(error)}` + ); + } + } +} diff --git a/src/node/runtime/SSHRuntime.ts b/src/node/runtime/SSHRuntime.ts index 076c951987..536ddd60bf 100644 --- a/src/node/runtime/SSHRuntime.ts +++ b/src/node/runtime/SSHRuntime.ts @@ -1,6 +1,7 @@ import { spawn } from "child_process"; import { Readable, Writable } from "stream"; import * as path from "path"; +import { randomBytes } from "crypto"; import type { Runtime, ExecOptions, @@ -13,6 +14,8 @@ import type { WorkspaceForkParams, WorkspaceForkResult, InitLogger, + BackgroundSpawnOptions, + BackgroundSpawnResult, } from "./Runtime"; import { RuntimeError as RuntimeErrorClass } from "./Runtime"; import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "@/common/constants/exitCodes"; @@ -26,10 +29,14 @@ import { getErrorMessage } from "@/common/utils/errors"; import { execAsync, DisposableProcess } from "@/node/utils/disposableExec"; import { getControlPath } from "./sshConnectionPool"; import { getBashPath } from "@/node/utils/main/bashPath"; +import { SSHBackgroundHandle } from "./SSHBackgroundHandle"; +import { execBuffered } from "@/node/utils/runtime/helpers"; +import { shellQuote, buildSpawnCommand, parsePid } from "./backgroundCommands"; /** * Shell-escape helper for remote bash. * Reused across all SSH runtime operations for performance. + * Note: For background process commands, use shellQuote from backgroundCommands for parity. */ const shescape = { quote(value: unknown): string { @@ -48,6 +55,8 @@ export interface SSHRuntimeConfig { host: string; /** Working directory on remote host */ srcBaseDir: string; + /** Directory on remote for background process output (default: /tmp/mux-bashes) */ + bgOutputDir?: string; /** Optional: Path to SSH private key (if not using ~/.ssh/config or ssh-agent) */ identityFile?: string; /** Optional: SSH port (default: 22) */ @@ -71,6 +80,8 @@ export interface SSHRuntimeConfig { export class SSHRuntime implements Runtime { private readonly config: SSHRuntimeConfig; private readonly controlPath: string; + /** Cached resolved bgOutputDir (tilde expanded to absolute path) */ + private resolvedBgOutputDir: string | null = null; constructor(config: SSHRuntimeConfig) { // Note: srcBaseDir may contain tildes - they will be resolved via resolvePath() before use @@ -82,6 +93,35 @@ export class SSHRuntime implements Runtime { this.controlPath = getControlPath(config); } + /** + * Get resolved background output directory (tilde expanded), caching the result. + * This ensures all background process paths are absolute from the start. + */ + private async getBgOutputDir(): Promise { + if (this.resolvedBgOutputDir !== null) { + return this.resolvedBgOutputDir; + } + + let dir = this.config.bgOutputDir ?? "/tmp/mux-bashes"; + + if (dir === "~" || dir.startsWith("~/")) { + const result = await execBuffered(this, 'echo "$HOME"', { cwd: "/", timeout: 10 }); + let home: string; + if (result.exitCode === 0 && result.stdout.trim()) { + home = result.stdout.trim(); + } else { + log.warn( + `SSHRuntime: Failed to resolve $HOME (exitCode=${result.exitCode}). Falling back to /tmp.` + ); + home = "/tmp"; + } + dir = dir === "~" ? home : `${home}/${dir.slice(2)}`; + } + + this.resolvedBgOutputDir = dir; + return this.resolvedBgOutputDir; + } + /** * Get SSH configuration (for PTY terminal spawning) */ @@ -119,14 +159,20 @@ export class SSHRuntime implements Runtime { // Join all parts with && to ensure each step succeeds before continuing let fullCommand = parts.join(" && "); - // Wrap remote command with timeout to ensure the command is killed on the remote side + // Always wrap in bash to ensure consistent shell behavior + // (user's login shell may be fish, zsh, etc. which have different syntax) + fullCommand = `bash -c ${shescape.quote(fullCommand)}`; + + // Optionally wrap with timeout to ensure the command is killed on the remote side // even if the local SSH client is killed but the ControlMaster connection persists // Use timeout command with KILL signal // Set remote timeout slightly longer (+1s) than local timeout to ensure // the local timeout fires first in normal cases (for cleaner error handling) // Note: Using BusyBox-compatible syntax (-s KILL) which also works with GNU timeout - const remoteTimeout = Math.ceil(options.timeout) + 1; - fullCommand = `timeout -s KILL ${remoteTimeout} bash -c ${shescape.quote(fullCommand)}`; + if (options.timeout !== undefined) { + const remoteTimeout = Math.ceil(options.timeout) + 1; + fullCommand = `timeout -s KILL ${remoteTimeout} ${fullCommand}`; + } // Build SSH args // -T: Disable pseudo-terminal allocation (default) @@ -171,7 +217,10 @@ export class SSHRuntime implements Runtime { // 1. Connection establishment can't hang indefinitely (max 15s) // 2. Established connections that die are detected quickly // 3. The overall command timeout is respected from the moment ssh command starts - sshArgs.push("-o", `ConnectTimeout=${Math.min(Math.ceil(options.timeout), 15)}`); + // When no timeout specified, use default 15s connect timeout to prevent hanging on connection + const connectTimeout = + options.timeout !== undefined ? Math.min(Math.ceil(options.timeout), 15) : 15; + sshArgs.push("-o", `ConnectTimeout=${connectTimeout}`); // Set aggressive keepalives to detect dead connections sshArgs.push("-o", "ServerAliveInterval=5"); sshArgs.push("-o", "ServerAliveCountMax=2"); @@ -237,18 +286,145 @@ export class SSHRuntime implements Runtime { }); } - // Handle timeout - const timeoutHandle = setTimeout(() => { - timedOut = true; - disposable[Symbol.dispose](); // Kill process and run cleanup - }, options.timeout * 1000); + // Handle timeout (only if timeout specified) + if (options.timeout !== undefined) { + const timeoutHandle = setTimeout(() => { + timedOut = true; + disposable[Symbol.dispose](); // Kill process and run cleanup + }, options.timeout * 1000); - // Clear timeout if process exits naturally - void exitCode.finally(() => clearTimeout(timeoutHandle)); + // Clear timeout if process exits naturally + void exitCode.finally(() => clearTimeout(timeoutHandle)); + } return { stdout, stderr, stdin, exitCode, duration }; } + /** + * Spawn a background process on the remote machine. + * + * Uses nohup + shell redirection to detach the process from SSH. + * Exit code is captured via bash trap and written to exit_code file. + * Output is written directly to stdout.log and stderr.log on the remote. + * + * Output directory: {bgOutputDir}/{workspaceId}/{processId}/ + */ + async spawnBackground( + script: string, + options: BackgroundSpawnOptions + ): Promise { + log.debug(`SSHRuntime.spawnBackground: Spawning in ${options.cwd}`); + + // Verify working directory exists on remote (parity with local runtime) + const cwdCheck = await execBuffered(this, cdCommandForSSH(options.cwd), { + cwd: "/", + timeout: 10, + }); + if (cwdCheck.exitCode !== 0) { + return { success: false, error: `Working directory does not exist: ${options.cwd}` }; + } + + // Generate unique process ID and compute output directory + // /tmp is cleaned by OS, so no explicit cleanup needed + const processId = `bg-${randomBytes(4).toString("hex")}`; + const bgOutputDir = await this.getBgOutputDir(); + const outputDir = `${bgOutputDir}/${options.workspaceId}/${processId}`; + const stdoutPath = `${outputDir}/stdout.log`; + const stderrPath = `${outputDir}/stderr.log`; + const exitCodePath = `${outputDir}/exit_code`; + + // Use expandTildeForSSH for paths that may contain ~ (shescape.quote prevents tilde expansion) + const outputDirExpanded = expandTildeForSSH(outputDir); + const stdoutPathExpanded = expandTildeForSSH(stdoutPath); + const stderrPathExpanded = expandTildeForSSH(stderrPath); + const exitCodePathExpanded = expandTildeForSSH(exitCodePath); + + // Create output directory and empty files on remote + const mkdirResult = await execBuffered( + this, + `mkdir -p ${outputDirExpanded} && touch ${stdoutPathExpanded} ${stderrPathExpanded}`, + { cwd: "/", timeout: 30 } + ); + if (mkdirResult.exitCode !== 0) { + return { + success: false, + error: `Failed to create output directory: ${mkdirResult.stderr}`, + }; + } + + // Build the wrapper script with trap to capture exit code + // The trap writes exit code to file when the script exits (any exit path) + // Note: SSH uses expandTildeForSSH/cdCommandForSSH for tilde expansion, so we can't + // use buildWrapperScript directly. But we use buildSpawnCommand for parity. + const wrapperParts: string[] = []; + + // Set up trap first (use expanded path for tilde support) + wrapperParts.push(`trap 'echo $? > ${exitCodePathExpanded}' EXIT`); + + // Change to working directory + wrapperParts.push(cdCommandForSSH(options.cwd)); + + // Add environment variable exports (use shellQuote for parity with Local) + const envVars = { ...options.env, ...NON_INTERACTIVE_ENV_VARS }; + for (const [key, value] of Object.entries(envVars)) { + wrapperParts.push(`export ${key}=${shellQuote(value)}`); + } + + // Add the actual script + wrapperParts.push(script); + + const wrapperScript = wrapperParts.join(" && "); + + // Use shared buildSpawnCommand for parity with Local + // Use expandTildeForSSH for path quoting to support ~/... paths + const spawnCommand = buildSpawnCommand({ + wrapperScript, + stdoutPath, + stderrPath, + niceness: options.niceness, + quotePath: expandTildeForSSH, + }); + + try { + // No timeout - the spawn command backgrounds the process and returns immediately, + // but if wrapped in `timeout`, it would wait for the backgrounded process to exit. + // SSH connection hangs are protected by ConnectTimeout (see buildSshArgs in this file). + const result = await execBuffered(this, spawnCommand, { + cwd: "/", // cwd doesn't matter, we cd in the wrapper + }); + + if (result.exitCode !== 0) { + log.debug(`SSHRuntime.spawnBackground: spawn command failed: ${result.stderr}`); + return { + success: false, + error: `Failed to spawn background process: ${result.stderr}`, + }; + } + + const pid = parsePid(result.stdout); + if (!pid) { + log.debug(`SSHRuntime.spawnBackground: Invalid PID: ${result.stdout}`); + return { + success: false, + error: `Failed to get valid PID from spawn: ${result.stdout}`, + }; + } + + log.debug(`SSHRuntime.spawnBackground: Spawned with PID ${pid}`); + + // outputDir is already absolute (getBgOutputDir resolves tildes upfront) + const handle = new SSHBackgroundHandle(this, pid, outputDir); + return { success: true, handle, pid }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log.debug(`SSHRuntime.spawnBackground: Error: ${errorMessage}`); + return { + success: false, + error: `Failed to spawn background process: ${errorMessage}`, + }; + } + } + /** * Read file contents over SSH as a stream */ @@ -1093,7 +1269,7 @@ export class SSHRuntime implements Runtime { if [ -n "$unpushed" ]; then # Get current branch for better error messaging BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) - + # Get default branch (prefer main/master over origin/HEAD since origin/HEAD # might point to a feature branch in some setups) if git rev-parse --verify origin/main >/dev/null 2>&1; then @@ -1104,18 +1280,18 @@ export class SSHRuntime implements Runtime { # Fallback to origin/HEAD if main/master don't exist DEFAULT=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@') fi - + # Check for squash-merge: if all changed files match origin/$DEFAULT, content is merged if [ -n "$DEFAULT" ]; then # Fetch latest to ensure we have current remote state git fetch origin "$DEFAULT" --quiet 2>/dev/null || true - + # Get merge-base between current branch and default MERGE_BASE=$(git merge-base "origin/$DEFAULT" HEAD 2>/dev/null) if [ -n "$MERGE_BASE" ]; then # Get files changed on this branch since fork point CHANGED_FILES=$(git diff --name-only "$MERGE_BASE" HEAD 2>/dev/null) - + if [ -n "$CHANGED_FILES" ]; then # Check if all changed files match what's in origin/$DEFAULT ALL_MERGED=true @@ -1127,7 +1303,7 @@ export class SSHRuntime implements Runtime { break fi done <<< "$CHANGED_FILES" - + if $ALL_MERGED; then # All changes are in default branch - safe to delete (squash-merge case) exit 0 @@ -1138,7 +1314,7 @@ export class SSHRuntime implements Runtime { fi fi fi - + # If we get here, there are real unpushed changes # Show helpful output for debugging if [ -n "$BRANCH" ] && [ -n "$DEFAULT" ] && git show-branch "$BRANCH" "origin/$DEFAULT" >/dev/null 2>&1; then diff --git a/src/node/runtime/WorktreeRuntime.test.ts b/src/node/runtime/WorktreeRuntime.test.ts index 949b309d59..ad45a49fc2 100644 --- a/src/node/runtime/WorktreeRuntime.test.ts +++ b/src/node/runtime/WorktreeRuntime.test.ts @@ -5,7 +5,7 @@ import { WorktreeRuntime } from "./WorktreeRuntime"; describe("WorktreeRuntime constructor", () => { it("should expand tilde in srcBaseDir", () => { - const runtime = new WorktreeRuntime("~/workspace"); + const runtime = new WorktreeRuntime("~/workspace", "/tmp/bg"); const workspacePath = runtime.getWorkspacePath("/home/user/project", "branch"); // The workspace path should use the expanded home directory @@ -14,7 +14,7 @@ describe("WorktreeRuntime constructor", () => { }); it("should handle absolute paths without expansion", () => { - const runtime = new WorktreeRuntime("/absolute/path"); + const runtime = new WorktreeRuntime("/absolute/path", "/tmp/bg"); const workspacePath = runtime.getWorkspacePath("/home/user/project", "branch"); const expected = path.join("/absolute/path", "project", "branch"); @@ -22,7 +22,7 @@ describe("WorktreeRuntime constructor", () => { }); it("should handle bare tilde", () => { - const runtime = new WorktreeRuntime("~"); + const runtime = new WorktreeRuntime("~", "/tmp/bg"); const workspacePath = runtime.getWorkspacePath("/home/user/project", "branch"); const expected = path.join(os.homedir(), "project", "branch"); @@ -32,13 +32,13 @@ describe("WorktreeRuntime constructor", () => { describe("WorktreeRuntime.resolvePath", () => { it("should expand tilde to home directory", async () => { - const runtime = new WorktreeRuntime("/tmp"); + const runtime = new WorktreeRuntime("/tmp", "/tmp/bg"); const resolved = await runtime.resolvePath("~"); expect(resolved).toBe(os.homedir()); }); it("should expand tilde with path", async () => { - const runtime = new WorktreeRuntime("/tmp"); + const runtime = new WorktreeRuntime("/tmp", "/tmp/bg"); // Use a path that likely exists (or use /tmp if ~ doesn't have subdirs) const resolved = await runtime.resolvePath("~/.."); const expected = path.dirname(os.homedir()); @@ -46,20 +46,20 @@ describe("WorktreeRuntime.resolvePath", () => { }); it("should resolve absolute paths", async () => { - const runtime = new WorktreeRuntime("/tmp"); + const runtime = new WorktreeRuntime("/tmp", "/tmp/bg"); const resolved = await runtime.resolvePath("/tmp"); expect(resolved).toBe("/tmp"); }); it("should resolve non-existent paths without checking existence", async () => { - const runtime = new WorktreeRuntime("/tmp"); + const runtime = new WorktreeRuntime("/tmp", "/tmp/bg"); const resolved = await runtime.resolvePath("/this/path/does/not/exist/12345"); // Should resolve to absolute path without checking if it exists expect(resolved).toBe("/this/path/does/not/exist/12345"); }); it("should resolve relative paths from cwd", async () => { - const runtime = new WorktreeRuntime("/tmp"); + const runtime = new WorktreeRuntime("/tmp", "/tmp/bg"); const resolved = await runtime.resolvePath("."); // Should resolve to absolute path expect(path.isAbsolute(resolved)).toBe(true); diff --git a/src/node/runtime/WorktreeRuntime.ts b/src/node/runtime/WorktreeRuntime.ts index 90e4c500c7..2bebef126c 100644 --- a/src/node/runtime/WorktreeRuntime.ts +++ b/src/node/runtime/WorktreeRuntime.ts @@ -12,10 +12,12 @@ import type { import { listLocalBranches } from "@/node/git"; import { checkInitHookExists, getMuxEnv } from "./initHook"; import { execAsync } from "@/node/utils/disposableExec"; +import { getBashPath } from "@/node/utils/main/bashPath"; import { getProjectName } from "@/node/utils/runtime/helpers"; import { getErrorMessage } from "@/common/utils/errors"; import { expandTilde } from "./tildeExpansion"; import { LocalBaseRuntime } from "./LocalBaseRuntime"; +import { toPosixPath } from "@/node/utils/paths"; /** * Worktree runtime implementation that executes commands and file operations @@ -28,8 +30,8 @@ import { LocalBaseRuntime } from "./LocalBaseRuntime"; export class WorktreeRuntime extends LocalBaseRuntime { private readonly srcBaseDir: string; - constructor(srcBaseDir: string) { - super(); + constructor(srcBaseDir: string, bgOutputDir: string) { + super(bgOutputDir); // Expand tilde to actual home directory path for local file system operations this.srcBaseDir = expandTilde(srcBaseDir); } @@ -284,8 +286,11 @@ export class WorktreeRuntime extends LocalBaseRuntime { // Ignore prune errors - we'll still try rm -rf } - // Force delete the directory - using rmProc = execAsync(`rm -rf "${deletedPath}"`); + // Force delete the directory (use bash shell for rm -rf on Windows) + // Convert to POSIX path for Git Bash compatibility on Windows + using rmProc = execAsync(`rm -rf "${toPosixPath(deletedPath)}"`, { + shell: getBashPath(), + }); await rmProc.result; return { success: true, deletedPath }; diff --git a/src/node/runtime/backgroundCommands.test.ts b/src/node/runtime/backgroundCommands.test.ts new file mode 100644 index 0000000000..0010b3bcc6 --- /dev/null +++ b/src/node/runtime/backgroundCommands.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect } from "bun:test"; +import { + shellQuote, + buildWrapperScript, + buildSpawnCommand, + buildTerminateCommand, + parseExitCode, + parsePid, +} from "./backgroundCommands"; + +describe("backgroundCommands", () => { + describe("shellQuote", () => { + it("quotes empty string", () => { + expect(shellQuote("")).toBe("''"); + }); + + it("quotes simple strings and paths", () => { + expect(shellQuote("hello")).toBe("'hello'"); + expect(shellQuote("/path/with spaces/file")).toBe("'/path/with spaces/file'"); + }); + + it("escapes single quotes", () => { + expect(shellQuote("it's")).toBe("'it'\"'\"'s'"); + expect(shellQuote("it's a 'test'")).toBe("'it'\"'\"'s a '\"'\"'test'\"'\"''"); + }); + + it("preserves special characters inside quotes", () => { + expect(shellQuote("$HOME")).toBe("'$HOME'"); + expect(shellQuote("a && b")).toBe("'a && b'"); + expect(shellQuote("foo\nbar")).toBe("'foo\nbar'"); + }); + }); + + describe("buildWrapperScript", () => { + it("builds script with trap, cd, and user script joined by &&", () => { + const result = buildWrapperScript({ + exitCodePath: "/tmp/exit_code", + cwd: "/home/user/project", + script: "echo hello", + }); + + expect(result).toBe( + "trap 'echo $? > '/tmp/exit_code'' EXIT && cd '/home/user/project' && echo hello" + ); + }); + + it("includes env exports", () => { + const result = buildWrapperScript({ + exitCodePath: "/tmp/exit_code", + cwd: "/home/user", + env: { FOO: "bar", BAZ: "qux" }, + script: "env", + }); + + expect(result).toContain("export FOO='bar'"); + expect(result).toContain("export BAZ='qux'"); + }); + + it("quotes paths with spaces", () => { + const result = buildWrapperScript({ + exitCodePath: "/tmp/my dir/exit_code", + cwd: "/home/user/my project", + script: "ls", + }); + + expect(result).toContain("'/tmp/my dir/exit_code'"); + expect(result).toContain("'/home/user/my project'"); + }); + + it("escapes single quotes in env values", () => { + const result = buildWrapperScript({ + exitCodePath: "/tmp/exit_code", + cwd: "/home", + env: { MSG: "it's a test" }, + script: "echo $MSG", + }); + + expect(result).toContain("export MSG='it'\"'\"'s a test'"); + }); + }); + + describe("buildSpawnCommand", () => { + it("uses set -m, nohup, redirections, and echoes PID", () => { + const result = buildSpawnCommand({ + wrapperScript: "echo hello", + stdoutPath: "/tmp/out.log", + stderrPath: "/tmp/err.log", + }); + + expect(result).toMatch(/^\(set -m; nohup 'bash' -c /); + expect(result).toContain("> '/tmp/out.log'"); + expect(result).toContain("2> '/tmp/err.log'"); + expect(result).toContain("< /dev/null"); + expect(result).toContain("& echo $!)"); + }); + + it("includes niceness prefix when provided", () => { + const result = buildSpawnCommand({ + wrapperScript: "echo hello", + stdoutPath: "/tmp/out", + stderrPath: "/tmp/err", + niceness: 10, + }); + + expect(result).toMatch(/^\(set -m; nice -n 10 nohup/); + }); + + it("uses custom bash path (including paths with spaces)", () => { + const result = buildSpawnCommand({ + wrapperScript: "echo hello", + stdoutPath: "/tmp/out", + stderrPath: "/tmp/err", + bashPath: "/c/Program Files/Git/bin/bash.exe", + }); + + expect(result).toContain("'/c/Program Files/Git/bin/bash.exe' -c"); + }); + + it("quotes the wrapper script", () => { + const result = buildSpawnCommand({ + wrapperScript: "echo 'hello world'", + stdoutPath: "/tmp/out", + stderrPath: "/tmp/err", + }); + + expect(result).toContain("-c 'echo '\"'\"'hello world'\"'\"''"); + }); + }); + + describe("buildTerminateCommand", () => { + it("sends SIGTERM then SIGKILL to process group using negative PID", () => { + const result = buildTerminateCommand(1234, "/tmp/exit_code"); + + expect(result).toContain("kill -15 -1234 2>/dev/null || true"); + expect(result).toContain("sleep 2"); + expect(result).toContain("kill -0 -1234"); + expect(result).toContain("kill -9 -1234 2>/dev/null || true"); + expect(result).toContain("echo 137 >"); // SIGKILL exit code + expect(result).toContain("echo 143 >"); // SIGTERM exit code (written after process exits) + }); + + it("quotes exit code path with spaces", () => { + const result = buildTerminateCommand(1234, "/tmp/my dir/exit_code"); + + expect(result).toContain("'/tmp/my dir/exit_code'"); + }); + + it("uses custom quotePath function for SSH tilde expansion", () => { + const expandTilde = (p: string) => (p.startsWith("~/") ? `"$HOME/${p.slice(2)}"` : `"${p}"`); + const result = buildTerminateCommand(1234, "~/mux/exit_code", expandTilde); + + expect(result).toContain('"$HOME/mux/exit_code"'); + }); + }); + + describe("parseExitCode", () => { + it("parses valid exit codes with whitespace", () => { + expect(parseExitCode("0")).toBe(0); + expect(parseExitCode(" 137\n")).toBe(137); + expect(parseExitCode("\t42\t")).toBe(42); + }); + + it("returns null for empty or non-numeric input", () => { + expect(parseExitCode("")).toBeNull(); + expect(parseExitCode(" ")).toBeNull(); + expect(parseExitCode("abc")).toBeNull(); + }); + }); + + describe("parsePid", () => { + it("parses valid PID with whitespace", () => { + expect(parsePid("1234")).toBe(1234); + expect(parsePid(" 1234\n")).toBe(1234); + }); + + it("returns null for invalid input", () => { + expect(parsePid("")).toBeNull(); + expect(parsePid(" ")).toBeNull(); + expect(parsePid("abc")).toBeNull(); + expect(parsePid("-1")).toBeNull(); + expect(parsePid("0")).toBeNull(); + }); + }); +}); diff --git a/src/node/runtime/backgroundCommands.ts b/src/node/runtime/backgroundCommands.ts new file mode 100644 index 0000000000..6e1bbb8343 --- /dev/null +++ b/src/node/runtime/backgroundCommands.ts @@ -0,0 +1,149 @@ +/** Exit code for process killed by SIGKILL (128 + 9) */ +export const EXIT_CODE_SIGKILL = 137; + +/** Exit code for process killed by SIGTERM (128 + 15) */ +export const EXIT_CODE_SIGTERM = 143; + +/** + * Parse exit code from file content. + * Returns null if content is empty or not a valid number. + */ +export function parseExitCode(content: string): number | null { + const code = parseInt(content.trim(), 10); + return isNaN(code) ? null : code; +} +/** + * Parse PID from buildSpawnCommand output. + * Returns the PID or null if invalid. + */ +export function parsePid(output: string): number | null { + const pid = parseInt(output.trim(), 10); + return isNaN(pid) || pid <= 0 ? null : pid; +} + +/** + * Shared command builders for background process management. + * Used by both LocalRuntime and SSHRuntime for parity. + */ + +/** + * Shell-escape a string using POSIX-safe single-quote escaping. + * Handles empty strings and embedded single quotes. + */ +export function shellQuote(value: string): string { + if (value.length === 0) return "''"; + return "'" + value.replace(/'/g, "'\"'\"'") + "'"; +} + +/** + * Options for building the wrapper script that runs inside bash. + */ +export interface WrapperScriptOptions { + /** Path where exit code will be written */ + exitCodePath: string; + /** Working directory for the script */ + cwd: string; + /** Environment variables to export */ + env?: Record; + /** The actual script to run */ + script: string; +} + +/** + * Build the wrapper script that captures exit code and sets up environment. + * Pattern: trap 'echo $? > exit_code' EXIT && cd /path && export K=V && script + */ +export function buildWrapperScript(options: WrapperScriptOptions): string { + const parts: string[] = []; + + // Set up trap first to capture exit code + parts.push(`trap 'echo $? > ${shellQuote(options.exitCodePath)}' EXIT`); + + // Change to working directory + parts.push(`cd ${shellQuote(options.cwd)}`); + + // Add environment variable exports + if (options.env) { + for (const [key, value] of Object.entries(options.env)) { + parts.push(`export ${key}=${shellQuote(value)}`); + } + } + + // Add the actual script + parts.push(options.script); + + return parts.join(" && "); +} + +/** + * Options for building the spawn command. + */ +export interface SpawnCommandOptions { + /** The wrapper script to execute */ + wrapperScript: string; + /** Path for stdout redirection */ + stdoutPath: string; + /** Path for stderr redirection */ + stderrPath: string; + /** Path to bash executable (defaults to "bash") */ + bashPath?: string; + /** Optional niceness value for process priority */ + niceness?: number; + /** Function to quote paths for shell (default: shellQuote). Use expandTildeForSSH for SSH. */ + quotePath?: (path: string) => string; +} + +/** + * Build the spawn command using subshell + nohup pattern. + * + * Uses subshell (...) to isolate the process group so the outer shell exits immediately. + * set -m: enables job control so backgrounded process gets its own process group (PID === PGID) + * nohup: ignores SIGHUP (survives terminal hangup) + * + * Returns PID via echo. With set -m, PID === PGID (process is its own group leader). + */ +export function buildSpawnCommand(options: SpawnCommandOptions): string { + const bash = options.bashPath ?? "bash"; + const nicePrefix = options.niceness !== undefined ? `nice -n ${options.niceness} ` : ""; + const quotePath = options.quotePath ?? shellQuote; + + return ( + `(set -m; ${nicePrefix}nohup ${shellQuote(bash)} -c ${shellQuote(options.wrapperScript)} ` + + `> ${quotePath(options.stdoutPath)} ` + + `2> ${quotePath(options.stderrPath)} ` + + `< /dev/null & echo $!)` + ); +} + +/** + * Build the terminate command for killing a process group. + * + * Uses negative PID to kill entire process group. + * Relies on set -m ensuring PID === PGID (process is its own group leader). + * Sends SIGTERM, waits 2 seconds, then SIGKILL if still running. + * Writes EXIT_CODE_SIGKILL on force kill. + * + * @param pid - Process ID (equals PGID due to set -m in buildSpawnCommand) + * @param exitCodePath - Path to write exit code (raw, will be quoted by quotePath) + * @param quotePath - Function to quote path (default: shellQuote). Use expandTildeForSSH for SSH. + */ +export function buildTerminateCommand( + pid: number, + exitCodePath: string, + quotePath: (p: string) => string = shellQuote +): string { + const negPid = -pid; // Negative PID targets process group (PID === PGID due to set -m) + // Send SIGTERM, wait for process to exit, then write the correct exit code. + // We can't write immediately because the process's EXIT trap would overwrite it. + // After sleep 2, either the process exited (write SIGTERM code) or we escalate to SIGKILL. + return ( + `kill -15 ${negPid} 2>/dev/null || true; ` + + `sleep 2; ` + + `if kill -0 ${negPid} 2>/dev/null; then ` + + `kill -9 ${negPid} 2>/dev/null || true; ` + + `echo ${EXIT_CODE_SIGKILL} > ${quotePath(exitCodePath)}; ` + + `else ` + + `echo ${EXIT_CODE_SIGTERM} > ${quotePath(exitCodePath)}; ` + + `fi` + ); +} diff --git a/src/node/runtime/runtimeFactory.ts b/src/node/runtime/runtimeFactory.ts index 61ce013d58..61acab14c8 100644 --- a/src/node/runtime/runtimeFactory.ts +++ b/src/node/runtime/runtimeFactory.ts @@ -1,14 +1,29 @@ +import * as os from "os"; +import * as path from "path"; + import type { Runtime } from "./Runtime"; import { LocalRuntime } from "./LocalRuntime"; import { WorktreeRuntime } from "./WorktreeRuntime"; import { SSHRuntime } from "./SSHRuntime"; import type { RuntimeConfig } from "@/common/types/runtime"; -import { hasSrcBaseDir, isLocalProjectRuntime } from "@/common/types/runtime"; +import { hasSrcBaseDir } from "@/common/types/runtime"; import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility"; // Re-export for backward compatibility with existing imports export { isIncompatibleRuntimeConfig }; +/** + * Get the default output directory for background processes. + * Uses os.tmpdir() for platform-appropriate temp directory. + * + * Returns native path format (Windows or POSIX) since this is used by Node.js + * filesystem APIs. Conversion to POSIX for Git Bash shell commands happens + * at command construction time via toPosixPath(). + */ +function getDefaultBgOutputDir(): string { + return path.join(os.tmpdir(), "mux-bashes"); +} + /** * Error thrown when a workspace has an incompatible runtime configuration, * typically from a newer version of mux that added new runtime types. @@ -49,13 +64,15 @@ export function createRuntime(config: RuntimeConfig, options?: CreateRuntimeOpti ); } + const bgOutputDir = config.bgOutputDir ?? getDefaultBgOutputDir(); + switch (config.type) { case "local": // Check if this is legacy "local" with srcBaseDir (= worktree semantics) // or new "local" without srcBaseDir (= project-dir semantics) if (hasSrcBaseDir(config)) { // Legacy: "local" with srcBaseDir is treated as worktree - return new WorktreeRuntime(config.srcBaseDir); + return new WorktreeRuntime(config.srcBaseDir, bgOutputDir); } // Project-dir: uses project path directly, no isolation if (!options?.projectPath) { @@ -63,15 +80,16 @@ export function createRuntime(config: RuntimeConfig, options?: CreateRuntimeOpti "LocalRuntime requires projectPath in options for project-dir config (type: 'local' without srcBaseDir)" ); } - return new LocalRuntime(options.projectPath); + return new LocalRuntime(options.projectPath, bgOutputDir); case "worktree": - return new WorktreeRuntime(config.srcBaseDir); + return new WorktreeRuntime(config.srcBaseDir, bgOutputDir); case "ssh": return new SSHRuntime({ host: config.host, srcBaseDir: config.srcBaseDir, + bgOutputDir: config.bgOutputDir, identityFile: config.identityFile, port: config.port, }); @@ -87,5 +105,6 @@ export function createRuntime(config: RuntimeConfig, options?: CreateRuntimeOpti * Helper to check if a runtime config requires projectPath for createRuntime. */ export function runtimeRequiresProjectPath(config: RuntimeConfig): boolean { - return isLocalProjectRuntime(config); + // Project-dir local runtime (no srcBaseDir) requires projectPath + return config.type === "local" && !hasSrcBaseDir(config); } diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 40567af4bc..61503b301d 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -27,6 +27,7 @@ import { createRuntime } from "@/node/runtime/runtimeFactory"; import { MessageQueue } from "./messageQueue"; import type { StreamEndEvent } from "@/common/types/stream"; import { CompactionHandler } from "./compactionHandler"; +import type { BackgroundProcessManager } from "./backgroundProcessManager"; // Type guard for compaction request metadata interface CompactionRequestMetadata { @@ -61,6 +62,7 @@ interface AgentSessionOptions { partialService: PartialService; aiService: AIService; initStateManager: InitStateManager; + backgroundProcessManager: BackgroundProcessManager; } export class AgentSession { @@ -70,6 +72,7 @@ export class AgentSession { private readonly partialService: PartialService; private readonly aiService: AIService; private readonly initStateManager: InitStateManager; + private readonly backgroundProcessManager: BackgroundProcessManager; private readonly emitter = new EventEmitter(); private readonly aiListeners: Array<{ event: string; handler: (...args: unknown[]) => void }> = []; @@ -81,8 +84,15 @@ export class AgentSession { constructor(options: AgentSessionOptions) { assert(options, "AgentSession requires options"); - const { workspaceId, config, historyService, partialService, aiService, initStateManager } = - options; + const { + workspaceId, + config, + historyService, + partialService, + aiService, + initStateManager, + backgroundProcessManager, + } = options; assert(typeof workspaceId === "string", "workspaceId must be a string"); const trimmedWorkspaceId = workspaceId.trim(); @@ -94,6 +104,7 @@ export class AgentSession { this.partialService = partialService; this.aiService = aiService; this.initStateManager = initStateManager; + this.backgroundProcessManager = backgroundProcessManager; this.compactionHandler = new CompactionHandler({ workspaceId: this.workspaceId, @@ -113,6 +124,8 @@ export class AgentSession { // Stop any active stream (fire and forget - disposal shouldn't block) void this.aiService.stopStream(this.workspaceId, { abandonPartial: true }); + // Terminate background processes for this workspace + void this.backgroundProcessManager.cleanup(this.workspaceId); for (const { event, handler } of this.aiListeners) { this.aiService.off(event, handler as never); @@ -368,6 +381,12 @@ export class AgentSession { // Add type: "message" for discriminated union (createMuxMessage doesn't add it) this.emitChatEvent({ ...userMessage, type: "message" }); + // If this is a compaction request, terminate background processes first + // They won't be included in the summary, so continuing with orphaned processes would be confusing + if (isCompactionRequestMetadata(typedMuxMetadata)) { + await this.backgroundProcessManager.cleanup(this.workspaceId); + } + // If this is a compaction request with a continue message, queue it for auto-send after compaction if ( isCompactionRequestMetadata(typedMuxMetadata) && diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 781a09f4c5..d3294654b0 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -22,6 +22,7 @@ import { createRuntime } from "@/node/runtime/runtimeFactory"; import { getMuxEnv, getRuntimeType } from "@/node/runtime/initHook"; import { secretsToRecord } from "@/common/types/secrets"; import type { MuxProviderOptions } from "@/common/types/providerOptions"; +import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import { log } from "./log"; import { transformModelMessages, @@ -242,12 +243,14 @@ export class AIService extends EventEmitter { private readonly initStateManager: InitStateManager; private readonly mockModeEnabled: boolean; private readonly mockScenarioPlayer?: MockScenarioPlayer; + private readonly backgroundProcessManager?: BackgroundProcessManager; constructor( config: Config, historyService: HistoryService, partialService: PartialService, - initStateManager: InitStateManager + initStateManager: InitStateManager, + backgroundProcessManager?: BackgroundProcessManager ) { super(); // Increase max listeners to accommodate multiple concurrent workspace listeners @@ -257,6 +260,7 @@ export class AIService extends EventEmitter { this.historyService = historyService; this.partialService = partialService; this.initStateManager = initStateManager; + this.backgroundProcessManager = backgroundProcessManager; this.streamManager = new StreamManager(historyService, partialService); void this.ensureSessionsDir(); this.setupStreamEventForwarding(); @@ -1012,6 +1016,8 @@ export class AIService extends EventEmitter { metadata.name ), runtimeTempDir, + backgroundProcessManager: this.backgroundProcessManager, + workspaceId, }, workspaceId, this.initStateManager, diff --git a/src/node/services/backgroundProcessManager.test.ts b/src/node/services/backgroundProcessManager.test.ts new file mode 100644 index 0000000000..23d0f775bf --- /dev/null +++ b/src/node/services/backgroundProcessManager.test.ts @@ -0,0 +1,371 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { BackgroundProcessManager, type BackgroundProcessMeta } from "./backgroundProcessManager"; +import { LocalRuntime } from "@/node/runtime/LocalRuntime"; +import type { Runtime } from "@/node/runtime/Runtime"; +import * as fs from "fs/promises"; +import * as path from "path"; +import * as os from "os"; + +describe("BackgroundProcessManager", () => { + let manager: BackgroundProcessManager; + let runtime: Runtime; + let bgOutputDir: string; + // Use unique workspace IDs per test run to avoid collisions + const testRunId = Date.now().toString(36); + const testWorkspaceId = `test-ws1-${testRunId}`; + const testWorkspaceId2 = `test-ws2-${testRunId}`; + + beforeEach(async () => { + manager = new BackgroundProcessManager(); + // Create isolated temp directory for sessions + bgOutputDir = await fs.mkdtemp(path.join(os.tmpdir(), "bg-proc-test-")); + runtime = new LocalRuntime(process.cwd(), bgOutputDir); + }); + + afterEach(async () => { + // Cleanup: terminate all processes + await manager.cleanup(testWorkspaceId); + await manager.cleanup(testWorkspaceId2); + // Remove temp sessions directory + await fs.rm(bgOutputDir, { recursive: true, force: true }).catch(() => undefined); + }); + + describe("spawn", () => { + it("should spawn a background process and return process ID and outputDir", async () => { + const result = await manager.spawn(runtime, testWorkspaceId, "echo hello", { + cwd: process.cwd(), + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.processId).toMatch(/^bg-/); + expect(result.outputDir).toContain(bgOutputDir); + expect(result.outputDir).toContain(testWorkspaceId); + expect(result.outputDir).toContain(result.processId); + } + }); + + it("should return error on spawn failure", async () => { + const result = await manager.spawn(runtime, testWorkspaceId, "echo test", { + cwd: "/nonexistent/path/that/does/not/exist", + }); + + expect(result.success).toBe(false); + }); + + it("should write stdout and stderr to files", async () => { + const result = await manager.spawn(runtime, testWorkspaceId, "echo hello; echo world >&2", { + cwd: process.cwd(), + }); + + expect(result.success).toBe(true); + if (result.success) { + // Wait a moment for output to be written + await new Promise((resolve) => setTimeout(resolve, 100)); + + const stdoutPath = path.join(result.outputDir, "stdout.log"); + const stderrPath = path.join(result.outputDir, "stderr.log"); + + const stdout = await fs.readFile(stdoutPath, "utf-8"); + const stderr = await fs.readFile(stderrPath, "utf-8"); + + expect(stdout).toContain("hello"); + expect(stderr).toContain("world"); + } + }); + + it("should write meta.json with process info", async () => { + const result = await manager.spawn(runtime, testWorkspaceId, "echo test", { + cwd: process.cwd(), + }); + + expect(result.success).toBe(true); + if (result.success) { + const metaPath = path.join(result.outputDir, "meta.json"); + const metaContent = await fs.readFile(metaPath, "utf-8"); + const meta = JSON.parse(metaContent) as BackgroundProcessMeta; + + expect(meta.id).toBe(result.processId); + expect(meta.pid).toBeGreaterThan(0); + expect(meta.script).toBe("echo test"); + expect(meta.status).toBe("running"); + expect(meta.startTime).toBeGreaterThan(0); + } + }); + }); + + describe("getProcess", () => { + it("should return process by ID", async () => { + const spawnResult = await manager.spawn(runtime, testWorkspaceId, "sleep 1", { + cwd: process.cwd(), + }); + + if (spawnResult.success) { + const proc = await manager.getProcess(spawnResult.processId); + expect(proc).not.toBeNull(); + expect(proc?.id).toBe(spawnResult.processId); + expect(proc?.status).toBe("running"); + } + }); + + it("should return null for non-existent process", async () => { + const proc = await manager.getProcess("bg-nonexistent"); + expect(proc).toBeNull(); + }); + }); + + describe("list", () => { + it("should list all processes", async () => { + await manager.spawn(runtime, testWorkspaceId, "sleep 1", { cwd: process.cwd() }); + await manager.spawn(runtime, testWorkspaceId, "sleep 1", { cwd: process.cwd() }); + + const processes = await manager.list(); + expect(processes.length).toBeGreaterThanOrEqual(2); + }); + + it("should filter by workspace ID", async () => { + await manager.spawn(runtime, testWorkspaceId, "sleep 1", { cwd: process.cwd() }); + await manager.spawn(runtime, testWorkspaceId2, "sleep 1", { cwd: process.cwd() }); + + const ws1Processes = await manager.list(testWorkspaceId); + const ws2Processes = await manager.list(testWorkspaceId2); + + expect(ws1Processes.length).toBeGreaterThanOrEqual(1); + expect(ws2Processes.length).toBeGreaterThanOrEqual(1); + expect(ws1Processes.every((p) => p.workspaceId === testWorkspaceId)).toBe(true); + expect(ws2Processes.every((p) => p.workspaceId === testWorkspaceId2)).toBe(true); + }); + }); + + describe("terminate", () => { + it("should terminate a running process", async () => { + const spawnResult = await manager.spawn(runtime, testWorkspaceId, "sleep 10", { + cwd: process.cwd(), + }); + + if (spawnResult.success) { + const terminateResult = await manager.terminate(spawnResult.processId); + expect(terminateResult.success).toBe(true); + + const proc = await manager.getProcess(spawnResult.processId); + expect(proc?.status).toMatch(/killed|exited/); + } + }); + + it("should return error for non-existent process", async () => { + const result = await manager.terminate("bg-nonexistent"); + expect(result.success).toBe(false); + }); + + it("should be idempotent (double-terminate succeeds)", async () => { + const spawnResult = await manager.spawn(runtime, testWorkspaceId, "sleep 10", { + cwd: process.cwd(), + }); + + if (spawnResult.success) { + const result1 = await manager.terminate(spawnResult.processId); + expect(result1.success).toBe(true); + + const result2 = await manager.terminate(spawnResult.processId); + expect(result2.success).toBe(true); + } + }); + }); + + describe("cleanup", () => { + it("should kill all processes for a workspace and remove from memory", async () => { + await manager.spawn(runtime, testWorkspaceId, "sleep 10", { + cwd: process.cwd(), + }); + await manager.spawn(runtime, testWorkspaceId, "sleep 10", { + cwd: process.cwd(), + }); + await manager.spawn(runtime, testWorkspaceId2, "sleep 10", { cwd: process.cwd() }); + + await manager.cleanup(testWorkspaceId); + + const ws1Processes = await manager.list(testWorkspaceId); + const ws2Processes = await manager.list(testWorkspaceId2); + // All testWorkspaceId processes should be removed from memory + expect(ws1Processes.length).toBe(0); + // workspace-2 processes should still exist and be running + expect(ws2Processes.length).toBeGreaterThanOrEqual(1); + expect(ws2Processes.some((p) => p.status === "running")).toBe(true); + }); + }); + + describe("terminateAll", () => { + it("should kill all processes across all workspaces", async () => { + // Spawn processes in multiple workspaces + await manager.spawn(runtime, testWorkspaceId, "sleep 10", { + cwd: process.cwd(), + }); + await manager.spawn(runtime, testWorkspaceId2, "sleep 10", { + cwd: process.cwd(), + }); + + // Verify both workspaces have running processes + const beforeWs1 = await manager.list(testWorkspaceId); + const beforeWs2 = await manager.list(testWorkspaceId2); + expect(beforeWs1.length).toBe(1); + expect(beforeWs2.length).toBe(1); + + // Terminate all + await manager.terminateAll(); + + // Both workspaces should have no processes + const afterWs1 = await manager.list(testWorkspaceId); + const afterWs2 = await manager.list(testWorkspaceId2); + expect(afterWs1.length).toBe(0); + expect(afterWs2.length).toBe(0); + + // Total list should also be empty + const allProcesses = await manager.list(); + expect(allProcesses.length).toBe(0); + }); + + it("should handle empty process list gracefully", async () => { + // No processes spawned - terminateAll should not throw + await manager.terminateAll(); + const allProcesses = await manager.list(); + expect(allProcesses.length).toBe(0); + }); + }); + + describe("process state tracking", () => { + it("should track process exit and update meta.json", async () => { + const result = await manager.spawn(runtime, testWorkspaceId, "exit 42", { + cwd: process.cwd(), + }); + + if (result.success) { + // Wait for process to exit + await new Promise((resolve) => setTimeout(resolve, 200)); + + const proc = await manager.getProcess(result.processId); + expect(proc?.status).toBe("exited"); + expect(proc?.exitCode).toBe(42); + expect(proc?.exitTime).not.toBeNull(); + + // Verify meta.json was updated + const metaPath = path.join(result.outputDir, "meta.json"); + const metaContent = await fs.readFile(metaPath, "utf-8"); + const meta = JSON.parse(metaContent) as BackgroundProcessMeta; + expect(meta.status).toBe("exited"); + expect(meta.exitCode).toBe(42); + } + }); + + it("should keep output files after process exits", async () => { + const result = await manager.spawn(runtime, testWorkspaceId, "echo test; exit 0", { + cwd: process.cwd(), + }); + + if (result.success) { + await new Promise((resolve) => setTimeout(resolve, 200)); + + const proc = await manager.getProcess(result.processId); + expect(proc?.status).toBe("exited"); + + // Verify stdout file still contains output + const stdoutPath = path.join(result.outputDir, "stdout.log"); + const stdout = await fs.readFile(stdoutPath, "utf-8"); + expect(stdout).toContain("test"); + } + }); + + it("should preserve killed status after terminate", async () => { + // Spawn a long-running process + const result = await manager.spawn(runtime, testWorkspaceId, "sleep 60", { + cwd: process.cwd(), + }); + + if (result.success) { + // Terminate it + await manager.terminate(result.processId); + + // Status should be "killed", not "exited" + const proc = await manager.getProcess(result.processId); + expect(proc?.status).toBe("killed"); + } + }); + + it("should report non-zero exit code for signal-terminated processes", async () => { + // Spawn a long-running process + const result = await manager.spawn(runtime, testWorkspaceId, "sleep 60", { + cwd: process.cwd(), + }); + + if (result.success) { + // Terminate it (sends SIGTERM, then SIGKILL after 2s) + await manager.terminate(result.processId); + + const proc = await manager.getProcess(result.processId); + expect(proc).not.toBeNull(); + // Exit code should be 128 + signal number (SIGTERM=15 → 143, SIGKILL=9 → 137) + // Either is acceptable depending on timing + expect(proc!.exitCode).toBeGreaterThanOrEqual(128); + } + }); + }); + + describe("process group termination", () => { + it("should terminate child processes when parent is killed", async () => { + // This test validates that set -m creates a process group where PID === PGID, + // allowing kill -PID to terminate the entire process tree. + + // Spawn a parent that creates a child process + // The parent runs: (sleep 60 &); wait + // This creates: parent bash -> child sleep + const result = await manager.spawn(runtime, testWorkspaceId, "bash -c 'sleep 60 & wait'", { + cwd: process.cwd(), + }); + + expect(result.success).toBe(true); + if (!result.success) return; + + // Give the child process time to start + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify process is running + const procBefore = await manager.getProcess(result.processId); + expect(procBefore?.status).toBe("running"); + + // Terminate - this should kill both parent and child via process group + await manager.terminate(result.processId); + + // Verify parent is killed + const procAfter = await manager.getProcess(result.processId); + expect(procAfter?.status).toBe("killed"); + + // Wait a moment for any orphaned processes to show up + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Verify no orphaned sleep processes from our test + // (checking via ps would be flaky, so we rely on the exit code being set, + // which only happens after the entire process group is dead) + const exitCode = procAfter?.exitCode; + expect(exitCode).not.toBeNull(); + expect(exitCode).toBeGreaterThanOrEqual(128); // Signal exit code + }); + }); + + describe("exit_code file", () => { + it("should write exit_code file when process exits", async () => { + const result = await manager.spawn(runtime, testWorkspaceId, "exit 42", { + cwd: process.cwd(), + }); + + expect(result.success).toBe(true); + if (!result.success) return; + + // Wait for process to exit and exit_code to be written + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Check exit_code file exists and contains correct value + const exitCodePath = path.join(result.outputDir, "exit_code"); + const exitCodeContent = await fs.readFile(exitCodePath, "utf-8"); + expect(exitCodeContent.trim()).toBe("42"); + }); + }); +}); diff --git a/src/node/services/backgroundProcessManager.ts b/src/node/services/backgroundProcessManager.ts new file mode 100644 index 0000000000..97aea9226a --- /dev/null +++ b/src/node/services/backgroundProcessManager.ts @@ -0,0 +1,289 @@ +import type { Runtime, BackgroundHandle } from "@/node/runtime/Runtime"; +import { getErrorMessage } from "@/common/utils/errors"; +import { log } from "./log"; +import * as path from "path"; + +/** + * Metadata written to meta.json for bookkeeping + */ +export interface BackgroundProcessMeta { + id: string; + pid: number; + script: string; + startTime: number; + status: "running" | "exited" | "killed" | "failed"; + exitCode?: number; + exitTime?: number; + displayName?: string; +} + +/** + * Represents a background process with file-based output + */ +export interface BackgroundProcess { + id: string; // Short unique ID (e.g., "bg-abc123") + pid: number; // OS process ID + workspaceId: string; // Owning workspace + outputDir: string; // Directory containing stdout.log, stderr.log, meta.json + script: string; // Original command + startTime: number; // Timestamp when started + exitCode?: number; // Undefined if still running + exitTime?: number; // Timestamp when exited (undefined if running) + status: "running" | "exited" | "killed" | "failed"; + handle: BackgroundHandle; // For process interaction + displayName?: string; // Human-readable name (e.g., "Dev Server") +} + +/** + * Manages background bash processes for workspaces. + * + * Processes are spawned via Runtime.spawnBackground() and tracked by ID. + * Output is stored in circular buffers for later retrieval. + */ +export class BackgroundProcessManager { + // NOTE: This map is in-memory only. Background processes use nohup/setsid so they + // could survive app restarts, but we kill all tracked processes on shutdown via + // dispose(). Rehydrating from meta.json on startup is out of scope for now. + private processes = new Map(); + + /** + * Spawn a new background process. + * @param runtime Runtime to spawn the process on + * @param workspaceId Workspace ID for tracking/filtering + * @param script Bash script to execute + * @param config Execution configuration + */ + async spawn( + runtime: Runtime, + workspaceId: string, + script: string, + config: { + cwd: string; + secrets?: Record; + niceness?: number; + displayName?: string; + } + ): Promise< + { success: true; processId: string; outputDir: string } | { success: false; error: string } + > { + log.debug(`BackgroundProcessManager.spawn() called for workspace ${workspaceId}`); + + // Spawn via runtime - it generates processId and creates outputDir + const result = await runtime.spawnBackground(script, { + cwd: config.cwd, + workspaceId, + env: config.secrets, + niceness: config.niceness, + }); + + if (!result.success) { + log.debug(`BackgroundProcessManager: Failed to spawn: ${result.error}`); + return { success: false, error: result.error }; + } + + const { handle, pid } = result; + const outputDir = handle.outputDir; + // Extract processId from outputDir (last path segment) + const processId = path.basename(outputDir); + const startTime = Date.now(); + + // Write meta.json with process info + const meta: BackgroundProcessMeta = { + id: processId, + pid, + script, + startTime, + status: "running", + displayName: config.displayName, + }; + await handle.writeMeta(JSON.stringify(meta, null, 2)); + + const proc: BackgroundProcess = { + id: processId, + pid, + workspaceId, + outputDir, + script, + startTime, + status: "running", + handle, + displayName: config.displayName, + }; + + // Store process in map + this.processes.set(processId, proc); + + log.debug(`Background process ${processId} spawned successfully with PID ${pid}`); + return { success: true, processId, outputDir }; + } + + /** + * Write/update meta.json for a process + */ + private async updateMetaFile(proc: BackgroundProcess): Promise { + const meta: BackgroundProcessMeta = { + id: proc.id, + pid: proc.pid, + script: proc.script, + startTime: proc.startTime, + status: proc.status, + exitCode: proc.exitCode, + exitTime: proc.exitTime, + }; + const metaJson = JSON.stringify(meta, null, 2); + + await proc.handle.writeMeta(metaJson); + } + + /** + * Get a background process by ID. + * Refreshes status if the process is still marked as running. + */ + async getProcess(processId: string): Promise { + log.debug(`BackgroundProcessManager.getProcess(${processId}) called`); + const proc = this.processes.get(processId); + if (!proc) return null; + + // Refresh status if still running (exit code null = still running) + if (proc.status === "running") { + const exitCode = await proc.handle.getExitCode(); + if (exitCode !== null) { + log.debug(`Background process ${proc.id} has exited`); + proc.status = "exited"; + proc.exitCode = exitCode; + proc.exitTime = Date.now(); + await this.updateMetaFile(proc).catch((err: unknown) => { + log.debug( + `BackgroundProcessManager: Failed to update meta.json: ${getErrorMessage(err)}` + ); + }); + } + } + + return proc; + } + + /** + * List all background processes, optionally filtered by workspace. + * Refreshes status of running processes before returning. + */ + async list(workspaceId?: string): Promise { + log.debug(`BackgroundProcessManager.list(${workspaceId ?? "all"}) called`); + await this.refreshRunningStatuses(); + const allProcesses = Array.from(this.processes.values()); + return workspaceId ? allProcesses.filter((p) => p.workspaceId === workspaceId) : allProcesses; + } + + /** + * Check all "running" processes and update status if they've exited. + * Called lazily from list() to avoid polling overhead. + */ + private async refreshRunningStatuses(): Promise { + const runningProcesses = Array.from(this.processes.values()).filter( + (p) => p.status === "running" + ); + + for (const proc of runningProcesses) { + const exitCode = await proc.handle.getExitCode(); + if (exitCode !== null) { + log.debug(`Background process ${proc.id} has exited`); + proc.status = "exited"; + proc.exitCode = exitCode; + proc.exitTime = Date.now(); + await this.updateMetaFile(proc).catch((err: unknown) => { + log.debug( + `BackgroundProcessManager: Failed to update meta.json: ${getErrorMessage(err)}` + ); + }); + } + } + } + + /** + * Terminate a background process + */ + async terminate( + processId: string + ): Promise<{ success: true } | { success: false; error: string }> { + log.debug(`BackgroundProcessManager.terminate(${processId}) called`); + + // Get process from Map + const proc = this.processes.get(processId); + if (!proc) { + return { success: false, error: `Process not found: ${processId}` }; + } + + // If already terminated, return success (idempotent) + if (proc.status === "exited" || proc.status === "killed" || proc.status === "failed") { + log.debug(`Process ${processId} already terminated with status: ${proc.status}`); + return { success: true }; + } + + try { + await proc.handle.terminate(); + + // Update process status and exit code + proc.status = "killed"; + proc.exitCode = (await proc.handle.getExitCode()) ?? undefined; + proc.exitTime ??= Date.now(); + + // Update meta.json + await this.updateMetaFile(proc).catch((err: unknown) => { + log.debug(`BackgroundProcessManager: Failed to update meta.json: ${getErrorMessage(err)}`); + }); + + // Dispose of the handle + await proc.handle.dispose(); + + log.debug(`Process ${processId} terminated successfully`); + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log.debug(`Error terminating process ${processId}: ${errorMessage}`); + // Mark as killed even if there was an error (process likely already dead) + proc.status = "killed"; + proc.exitTime ??= Date.now(); + // Update meta.json + await this.updateMetaFile(proc).catch((err: unknown) => { + log.debug(`BackgroundProcessManager: Failed to update meta.json: ${getErrorMessage(err)}`); + }); + // Ensure handle is cleaned up even on error + await proc.handle.dispose(); + return { success: true }; + } + } + + /** + * Terminate all background processes across all workspaces. + * Called during app shutdown to prevent orphaned processes. + */ + async terminateAll(): Promise { + log.debug(`BackgroundProcessManager.terminateAll() called`); + const allProcesses = Array.from(this.processes.values()); + await Promise.all(allProcesses.map((p) => this.terminate(p.id))); + this.processes.clear(); + log.debug(`Terminated ${allProcesses.length} background process(es)`); + } + + /** + * Clean up all processes for a workspace. + * Terminates running processes and removes from memory. + * Output directories are left on disk (cleaned by OS for /tmp, or on workspace deletion for local). + */ + async cleanup(workspaceId: string): Promise { + log.debug(`BackgroundProcessManager.cleanup(${workspaceId}) called`); + const matching = Array.from(this.processes.values()).filter( + (p) => p.workspaceId === workspaceId + ); + + // Terminate all running processes + await Promise.all(matching.map((p) => this.terminate(p.id))); + + // Remove from memory (output dirs left on disk for OS/workspace cleanup) + for (const p of matching) { + this.processes.delete(p.id); + } + + log.debug(`Cleaned up ${matching.length} process(es) for workspace ${workspaceId}`); + } +} diff --git a/src/node/services/bashExecutionService.ts b/src/node/services/bashExecutionService.ts index 62199a7f6e..7086b0e119 100644 --- a/src/node/services/bashExecutionService.ts +++ b/src/node/services/bashExecutionService.ts @@ -173,8 +173,10 @@ export class BashExecutionService { errBuf = flushLines(errBuf, true); }); - child.on("close", (code: number | null) => { - log.debug(`BashExecutionService: Process exited with code ${code ?? "unknown"}`); + child.on("close", (code: number | null, signal: NodeJS.Signals | null) => { + log.debug( + `BashExecutionService: Process exited with code ${code ?? "null"}, signal ${signal ?? "none"}` + ); // Flush any remaining partial lines if (outBuf.trim().length > 0) { callbacks.onStdout(outBuf); @@ -182,7 +184,22 @@ export class BashExecutionService { if (errBuf.trim().length > 0) { callbacks.onStderr(errBuf); } - callbacks.onExit(code ?? 0); + + // Convert signal to exit code using Unix convention (128 + signal number) + // Common signals: SIGTERM=15 → 143, SIGKILL=9 → 137 + let exitCode = code ?? 0; + if (code === null && signal) { + const signalNumbers: Record = { + SIGHUP: 1, + SIGINT: 2, + SIGQUIT: 3, + SIGABRT: 6, + SIGKILL: 9, + SIGTERM: 15, + }; + exitCode = 128 + (signalNumbers[signal] ?? 1); + } + callbacks.onExit(exitCode); }); child.on("error", (error: Error) => { diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 245670ebe9..03ea24cbeb 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -18,6 +18,7 @@ import { ServerService } from "@/node/services/serverService"; import { MenuEventService } from "@/node/services/menuEventService"; import { VoiceService } from "@/node/services/voiceService"; import { TelemetryService } from "@/node/services/telemetryService"; +import { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; /** * ServiceContainer - Central dependency container for all backend services. @@ -44,6 +45,7 @@ export class ServiceContainer { private readonly initStateManager: InitStateManager; private readonly extensionMetadata: ExtensionMetadataService; private readonly ptyService: PTYService; + private readonly backgroundProcessManager: BackgroundProcessManager; constructor(config: Config) { this.config = config; @@ -54,11 +56,13 @@ export class ServiceContainer { this.extensionMetadata = new ExtensionMetadataService( path.join(config.rootDir, "extensionMetadata.json") ); + this.backgroundProcessManager = new BackgroundProcessManager(); this.aiService = new AIService( config, this.historyService, this.partialService, - this.initStateManager + this.initStateManager, + this.backgroundProcessManager ); this.workspaceService = new WorkspaceService( config, @@ -66,7 +70,8 @@ export class ServiceContainer { this.partialService, this.aiService, this.initStateManager, - this.extensionMetadata + this.extensionMetadata, + this.backgroundProcessManager ); this.providerService = new ProviderService(config); // Terminal services - PTYService is cross-platform @@ -103,4 +108,12 @@ export class ServiceContainer { setTerminalWindowManager(manager: TerminalWindowManager): void { this.terminalService.setTerminalWindowManager(manager); } + + /** + * Dispose all services. Called on app quit to clean up resources. + * Terminates all background processes to prevent orphans. + */ + async dispose(): Promise { + await this.backgroundProcessManager.terminateAll(); + } } diff --git a/src/node/services/systemMessage.test.ts b/src/node/services/systemMessage.test.ts index 0e47c2fabb..c26cb9fdbc 100644 --- a/src/node/services/systemMessage.test.ts +++ b/src/node/services/systemMessage.test.ts @@ -43,7 +43,7 @@ describe("buildSystemMessage", () => { process.env.MUX_ROOT = globalDir; // Create a local runtime for tests - runtime = new LocalRuntime(tempDir); + runtime = new LocalRuntime(tempDir, tempDir); }); afterEach(async () => { diff --git a/src/node/services/tools/bash.test.ts b/src/node/services/tools/bash.test.ts index 4d4f4c9dde..34854c6a9f 100644 --- a/src/node/services/tools/bash.test.ts +++ b/src/node/services/tools/bash.test.ts @@ -7,6 +7,7 @@ import * as fs from "fs"; import { TestTempDir, createTestToolConfig, getTestDeps } from "./testHelpers"; import { createRuntime } from "@/node/runtime/runtimeFactory"; import type { ToolCallOptions } from "ai"; +import { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; // Mock ToolCallOptions for testing const mockToolCallOptions: ToolCallOptions = { @@ -38,6 +39,7 @@ describe("bash tool", () => { const args: BashToolArgs = { script: "echo hello", timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -55,6 +57,7 @@ describe("bash tool", () => { const args: BashToolArgs = { script: "echo line1 && echo line2 && echo line3", timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -69,6 +72,7 @@ describe("bash tool", () => { using testEnv = createTestBashTool(); const tool = testEnv.tool; const args: BashToolArgs = { + run_in_background: false, script: "for i in {1..400}; do echo line$i; done", // Exceeds 300 line hard cap timeout_secs: 5, }; @@ -87,6 +91,7 @@ describe("bash tool", () => { using testEnv = createTestBashTool(); const tool = testEnv.tool; const args: BashToolArgs = { + run_in_background: false, script: "for i in {1..400}; do echo line$i; done", // Exceeds 300 line hard cap timeout_secs: 5, }; @@ -139,6 +144,7 @@ describe("bash tool", () => { const args: BashToolArgs = { // This will generate 500 lines quickly - should fail at 300 + run_in_background: false, script: "for i in {1..500}; do echo line$i; done", timeout_secs: 5, }; @@ -167,18 +173,21 @@ describe("bash tool", () => { // Generate ~1.5MB of output (1700 lines * 900 bytes) to exceed 1MB byte limit script: 'perl -e \'for (1..1700) { print "A" x 900 . "\\n" }\'', timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; // With truncate policy and overflow, should succeed with truncated field expect(result.success).toBe(true); - expect(result.truncated).toBeDefined(); - if (result.truncated) { - expect(result.truncated.reason).toContain("exceed"); - // Should collect lines up to ~1MB (around 1150-1170 lines with 900 bytes each) - expect(result.truncated.totalLines).toBeGreaterThan(1000); - expect(result.truncated.totalLines).toBeLessThan(1300); + if (result.success && "truncated" in result) { + expect(result.truncated).toBeDefined(); + if (result.truncated) { + expect(result.truncated.reason).toContain("exceed"); + // Should collect lines up to ~1MB (around 1150-1170 lines with 900 bytes each) + expect(result.truncated.totalLines).toBeGreaterThan(1000); + expect(result.truncated.totalLines).toBeLessThan(1300); + } } // Should contain output that's around 1MB @@ -198,7 +207,7 @@ describe("bash tool", () => { const tool = createBashTool({ ...getTestDeps(), cwd: process.cwd(), - runtime: new LocalRuntime(process.cwd()), + runtime: new LocalRuntime(process.cwd(), tempDir.path), runtimeTempDir: tempDir.path, overflow_policy: "truncate", }); @@ -207,17 +216,20 @@ describe("bash tool", () => { // Generate a single 2MB line (exceeds 1MB total limit) script: 'perl -e \'print "A" x 2000000 . "\\n"\'', timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; // Should succeed but with truncation before storing the overlong line expect(result.success).toBe(true); - expect(result.truncated).toBeDefined(); - if (result.truncated) { - expect(result.truncated.reason).toContain("would exceed file preservation limit"); - // Should have 0 lines collected since the first line was too long - expect(result.truncated.totalLines).toBe(0); + if (result.success && "truncated" in result) { + expect(result.truncated).toBeDefined(); + if (result.truncated) { + expect(result.truncated.reason).toContain("would exceed file preservation limit"); + // Should have 0 lines collected since the first line was too long + expect(result.truncated.totalLines).toBe(0); + } } // CRITICAL: Output must NOT contain the 2MB line - should be empty or nearly empty @@ -231,7 +243,7 @@ describe("bash tool", () => { const tool = createBashTool({ ...getTestDeps(), cwd: process.cwd(), - runtime: new LocalRuntime(process.cwd()), + runtime: new LocalRuntime(process.cwd(), tempDir.path), runtimeTempDir: tempDir.path, overflow_policy: "truncate", }); @@ -241,16 +253,19 @@ describe("bash tool", () => { // Second line: 600KB (would exceed 1MB when added) script: 'perl -e \'print "A" x 500000 . "\\n"; print "B" x 600000 . "\\n"\'', timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; expect(result.success).toBe(true); - expect(result.truncated).toBeDefined(); - if (result.truncated) { - expect(result.truncated.reason).toContain("would exceed"); - // Should have collected exactly 1 line (the 500KB line) - expect(result.truncated.totalLines).toBe(1); + if (result.success && "truncated" in result) { + expect(result.truncated).toBeDefined(); + if (result.truncated) { + expect(result.truncated.reason).toContain("would exceed"); + // Should have collected exactly 1 line (the 500KB line) + expect(result.truncated.totalLines).toBe(1); + } } // Output should contain only the first line (~500KB), not the second line @@ -268,12 +283,13 @@ describe("bash tool", () => { const tool = createBashTool({ ...getTestDeps(), cwd: process.cwd(), - runtime: new LocalRuntime(process.cwd()), + runtime: new LocalRuntime(process.cwd(), tempDir.path), runtimeTempDir: tempDir.path, // overflow_policy not specified - should default to tmpfile }); const args: BashToolArgs = { + run_in_background: false, script: "for i in {1..400}; do echo line$i; done", timeout_secs: 5, }; @@ -302,7 +318,7 @@ describe("bash tool", () => { const tool = createBashTool({ ...getTestDeps(), cwd: process.cwd(), - runtime: new LocalRuntime(process.cwd()), + runtime: new LocalRuntime(process.cwd(), tempDir.path), runtimeTempDir: tempDir.path, }); @@ -310,6 +326,7 @@ describe("bash tool", () => { // Each line is ~40 bytes: "line" + number (1-5 digits) + padding = ~40 bytes // 50KB / 40 bytes = ~1250 lines const args: BashToolArgs = { + run_in_background: false, script: "for i in {1..1300}; do printf 'line%04d with some padding text here\\n' $i; done", timeout_secs: 5, }; @@ -355,7 +372,7 @@ describe("bash tool", () => { const tool = createBashTool({ ...getTestDeps(), cwd: process.cwd(), - runtime: new LocalRuntime(process.cwd()), + runtime: new LocalRuntime(process.cwd(), tempDir.path), runtimeTempDir: tempDir.path, }); @@ -363,6 +380,7 @@ describe("bash tool", () => { // Each line is ~100 bytes // 150KB / 100 bytes = ~1500 lines const args: BashToolArgs = { + run_in_background: false, script: "for i in {1..1600}; do printf 'line%04d: '; printf 'x%.0s' {1..80}; echo; done", timeout_secs: 10, }; @@ -399,7 +417,7 @@ describe("bash tool", () => { const tool = createBashTool({ ...getTestDeps(), cwd: process.cwd(), - runtime: new LocalRuntime(process.cwd()), + runtime: new LocalRuntime(process.cwd(), tempDir.path), runtimeTempDir: tempDir.path, }); @@ -409,6 +427,7 @@ describe("bash tool", () => { script: "for i in {1..500}; do printf 'line%04d with padding text\\n' $i; done; echo 'COMPLETION_MARKER'", timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -442,12 +461,13 @@ describe("bash tool", () => { const tool = createBashTool({ ...getTestDeps(), cwd: process.cwd(), - runtime: new LocalRuntime(process.cwd()), + runtime: new LocalRuntime(process.cwd(), tempDir.path), runtimeTempDir: tempDir.path, }); // Generate a single line exceeding 1KB limit, then try to output more const args: BashToolArgs = { + run_in_background: false, script: "printf 'x%.0s' {1..2000}; echo; echo 'SHOULD_NOT_APPEAR'", timeout_secs: 5, }; @@ -483,13 +503,14 @@ describe("bash tool", () => { const tool = createBashTool({ ...getTestDeps(), cwd: process.cwd(), - runtime: new LocalRuntime(process.cwd()), + runtime: new LocalRuntime(process.cwd(), tempDir.path), runtimeTempDir: tempDir.path, }); // Generate ~15KB of output (just under 16KB display limit) // Each line is ~50 bytes, 15KB / 50 = 300 lines exactly (at the line limit) const args: BashToolArgs = { + run_in_background: false, script: "for i in {1..299}; do printf 'line%04d with some padding text here now\\n' $i; done", timeout_secs: 5, }; @@ -514,12 +535,13 @@ describe("bash tool", () => { const tool = createBashTool({ ...getTestDeps(), cwd: process.cwd(), - runtime: new LocalRuntime(process.cwd()), + runtime: new LocalRuntime(process.cwd(), tempDir.path), runtimeTempDir: tempDir.path, }); // Generate exactly 300 lines (hits line limit exactly) const args: BashToolArgs = { + run_in_background: false, script: "for i in {1..300}; do printf 'line%04d\\n' $i; done", timeout_secs: 5, }; @@ -542,6 +564,7 @@ describe("bash tool", () => { const args: BashToolArgs = { script: "echo stdout1 && echo stderr1 >&2 && echo stdout2 && echo stderr2 >&2", timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -562,6 +585,7 @@ describe("bash tool", () => { const args: BashToolArgs = { script: "exit 42", timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -579,6 +603,7 @@ describe("bash tool", () => { const args: BashToolArgs = { script: "while true; do sleep 0.1; done", timeout_secs: 1, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -596,6 +621,7 @@ describe("bash tool", () => { const args: BashToolArgs = { script: "true", timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -617,6 +643,7 @@ describe("bash tool", () => { const args: BashToolArgs = { script: "echo 'test:first-child' | grep ':first-child'", timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -642,6 +669,7 @@ describe("bash tool", () => { const args: BashToolArgs = { script: "echo test | cat", timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -666,6 +694,7 @@ describe("bash tool", () => { script: 'python3 -c "import os,stat;mode=os.fstat(0).st_mode;print(stat.S_IFMT(mode)==stat.S_IFIFO)"', timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -710,6 +739,7 @@ describe("bash tool", () => { const args: BashToolArgs = { script: "echo test", timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -727,6 +757,7 @@ describe("bash tool", () => { const args: BashToolArgs = { script: "echo 'cd' && echo test", timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -747,6 +778,7 @@ describe("bash tool", () => { // Background process that would block if we waited for it script: "while true; do sleep 1; done > /dev/null 2>&1 &", timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -767,6 +799,7 @@ describe("bash tool", () => { // Should not wait for the background process script: "while true; do sleep 1; done > /dev/null 2>&1 & echo $!", timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -790,6 +823,7 @@ describe("bash tool", () => { // Background process with output redirected but still blocking script: "while true; do sleep 0.1; done & wait", timeout_secs: 1, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -809,6 +843,7 @@ describe("bash tool", () => { const args: BashToolArgs = { script: `echo '${longLine}'`, timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -828,6 +863,7 @@ describe("bash tool", () => { const args: BashToolArgs = { script: `for i in {1..${numLines}}; do echo '${lineContent}'; done`, timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -843,6 +879,7 @@ describe("bash tool", () => { using testEnv = createTestBashTool(); const tool = testEnv.tool; const args: BashToolArgs = { + run_in_background: false, script: `for i in {1..1000}; do echo 'This is line number '$i' with some content'; done`, timeout_secs: 5, }; @@ -862,6 +899,7 @@ describe("bash tool", () => { const args: BashToolArgs = { script: "", timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -881,6 +919,7 @@ describe("bash tool", () => { const args: BashToolArgs = { script: " \n\t ", timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -899,6 +938,7 @@ describe("bash tool", () => { const args: BashToolArgs = { script: "sleep 5", timeout_secs: 10, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -919,6 +959,7 @@ describe("bash tool", () => { const args: BashToolArgs = { script: "for i in 1 2 3; do echo $i; sleep 0.1; done", timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -997,6 +1038,7 @@ echo "$VALUE" echo "$RESULT" `, timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -1024,6 +1066,7 @@ if [ $? -ne 0 ]; then fi `, timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -1042,6 +1085,7 @@ fi const args: BashToolArgs = { script: "echo hello", timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -1064,6 +1108,7 @@ fi const args: BashToolArgs = { script: `echo "${marker}"; sleep 100 & echo $!`, timeout_secs: 1, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -1118,6 +1163,7 @@ fi exec sleep ${token} `, timeout_secs: 10, + run_in_background: false, }; // Start the command @@ -1187,6 +1233,7 @@ fi done `, timeout_secs: 120, + run_in_background: false, }; // Start the command @@ -1321,6 +1368,7 @@ describe("SSH runtime redundant cd detection", () => { const args: BashToolArgs = { script: "cd /remote/workspace/project/branch && echo test", timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -1342,6 +1390,7 @@ describe("SSH runtime redundant cd detection", () => { const args: BashToolArgs = { script: "cd /tmp && echo test", timeout_secs: 5, + run_in_background: false, }; const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; @@ -1355,3 +1404,70 @@ describe("SSH runtime redundant cd detection", () => { } }); }); +describe("bash tool - background execution", () => { + it("should reject background mode when manager not available", async () => { + using testEnv = createTestBashTool(); + const tool = testEnv.tool; + const args: BashToolArgs = { + script: "echo test", + run_in_background: true, + }; + + const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("Background execution is only available for AI tool calls"); + } + }); + + it("should reject timeout with background mode", async () => { + const manager = new BackgroundProcessManager(); + + const tempDir = new TestTempDir("test-bash-bg"); + const config = createTestToolConfig(tempDir.path); + config.backgroundProcessManager = manager; + + const tool = createBashTool(config); + const args: BashToolArgs = { + script: "echo test", + timeout_secs: 5, + run_in_background: true, + }; + + const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("Cannot specify timeout with run_in_background"); + } + + tempDir[Symbol.dispose](); + }); + + it("should start background process and return process ID", async () => { + const manager = new BackgroundProcessManager(); + + const tempDir = new TestTempDir("test-bash-bg"); + const config = createTestToolConfig(tempDir.path); + config.backgroundProcessManager = manager; + + const tool = createBashTool(config); + const args: BashToolArgs = { + script: "echo hello", + run_in_background: true, + }; + + const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; + + expect(result.success).toBe(true); + if (result.success && "backgroundProcessId" in result) { + expect(result.backgroundProcessId).toBeDefined(); + expect(result.backgroundProcessId).toMatch(/^bg-/); + } else { + throw new Error("Expected background process ID in result"); + } + + tempDir[Symbol.dispose](); + }); +}); diff --git a/src/node/services/tools/bash.ts b/src/node/services/tools/bash.ts index e5456084bd..d6f5426ef8 100644 --- a/src/node/services/tools/bash.ts +++ b/src/node/services/tools/bash.ts @@ -229,11 +229,71 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => { return tool({ description: TOOL_DEFINITIONS.bash.description + "\nRuns in " + config.cwd + " - no cd needed", inputSchema: TOOL_DEFINITIONS.bash.schema, - execute: async ({ script, timeout_secs }, { abortSignal }): Promise => { + execute: async ( + { script, timeout_secs, run_in_background, display_name }, + { abortSignal } + ): Promise => { // Validate script input const validationError = validateScript(script, config); if (validationError) return validationError; + // Handle background execution + if (run_in_background) { + if (!config.workspaceId || !config.backgroundProcessManager || !config.runtime) { + return { + success: false, + error: + "Background execution is only available for AI tool calls, not direct IPC invocation", + exitCode: -1, + wall_duration_ms: 0, + }; + } + + if (timeout_secs !== undefined) { + return { + success: false, + error: "Cannot specify timeout with run_in_background", + exitCode: -1, + wall_duration_ms: 0, + }; + } + + const startTime = performance.now(); + const spawnResult = await config.backgroundProcessManager.spawn( + config.runtime, + config.workspaceId, + script, + { + cwd: config.cwd, + secrets: config.secrets, + niceness: config.niceness, + displayName: display_name, + } + ); + + if (!spawnResult.success) { + return { + success: false, + error: spawnResult.error, + exitCode: -1, + wall_duration_ms: Math.round(performance.now() - startTime), + }; + } + + const stdoutPath = `${spawnResult.outputDir}/stdout.log`; + const stderrPath = `${spawnResult.outputDir}/stderr.log`; + + return { + success: true, + output: `Background process started with ID: ${spawnResult.processId}`, + exitCode: 0, + wall_duration_ms: Math.round(performance.now() - startTime), + backgroundProcessId: spawnResult.processId, + stdout_path: stdoutPath, + stderr_path: stderrPath, + }; + } + // Setup execution parameters const effectiveTimeout = timeout_secs ?? BASH_DEFAULT_TIMEOUT_SECS; const startTime = performance.now(); diff --git a/src/node/services/tools/bash_background_list.test.ts b/src/node/services/tools/bash_background_list.test.ts new file mode 100644 index 0000000000..77137e43b5 --- /dev/null +++ b/src/node/services/tools/bash_background_list.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect } from "bun:test"; +import { createBashBackgroundListTool } from "./bash_background_list"; +import { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; +import { LocalRuntime } from "@/node/runtime/LocalRuntime"; +import type { Runtime } from "@/node/runtime/Runtime"; +import type { BashBackgroundListResult } from "@/common/types/tools"; +import { TestTempDir, createTestToolConfig } from "./testHelpers"; +import type { ToolCallOptions } from "ai"; + +const mockToolCallOptions: ToolCallOptions = { + toolCallId: "test-call-id", + messages: [], +}; + +// Create test runtime with isolated sessions directory +function createTestRuntime(sessionsDir: string): Runtime { + return new LocalRuntime(process.cwd(), sessionsDir); +} + +describe("bash_background_list tool", () => { + it("should return error when manager not available", async () => { + const tempDir = new TestTempDir("test-bash-bg-list"); + const config = createTestToolConfig(process.cwd()); + config.runtimeTempDir = tempDir.path; + + const tool = createBashBackgroundListTool(config); + const result = (await tool.execute!({}, mockToolCallOptions)) as BashBackgroundListResult; + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("Background process manager not available"); + } + + tempDir[Symbol.dispose](); + }); + + it("should return error when workspaceId not available", async () => { + const manager = new BackgroundProcessManager(); + const tempDir = new TestTempDir("test-bash-bg-list"); + const config = createTestToolConfig(process.cwd()); + config.runtimeTempDir = tempDir.path; + config.backgroundProcessManager = manager; + delete config.workspaceId; // Explicitly remove workspaceId + + const tool = createBashBackgroundListTool(config); + const result = (await tool.execute!({}, mockToolCallOptions)) as BashBackgroundListResult; + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("Workspace ID not available"); + } + + tempDir[Symbol.dispose](); + }); + + it("should return empty list when no processes", async () => { + const manager = new BackgroundProcessManager(); + const tempDir = new TestTempDir("test-bash-bg-list"); + const config = createTestToolConfig(process.cwd()); + config.runtimeTempDir = tempDir.path; + config.backgroundProcessManager = manager; + + const tool = createBashBackgroundListTool(config); + const result = (await tool.execute!({}, mockToolCallOptions)) as BashBackgroundListResult; + + expect(result.success).toBe(true); + if (result.success) { + expect(result.processes).toEqual([]); + } + + tempDir[Symbol.dispose](); + }); + + it("should list spawned processes with correct fields", async () => { + const manager = new BackgroundProcessManager(); + const tempDir = new TestTempDir("test-bash-bg-list"); + const runtime = createTestRuntime(tempDir.path); + const config = createTestToolConfig(process.cwd(), { sessionsDir: tempDir.path }); + config.runtimeTempDir = tempDir.path; + config.backgroundProcessManager = manager; + + // Spawn a process + const spawnResult = await manager.spawn(runtime, "test-workspace", "sleep 10", { + cwd: process.cwd(), + }); + + if (!spawnResult.success) { + throw new Error("Failed to spawn process"); + } + + const tool = createBashBackgroundListTool(config); + const result = (await tool.execute!({}, mockToolCallOptions)) as BashBackgroundListResult; + + expect(result.success).toBe(true); + if (result.success) { + expect(result.processes.length).toBe(1); + const proc = result.processes[0]; + expect(proc.process_id).toBe(spawnResult.processId); + expect(proc.status).toBe("running"); + expect(proc.script).toBe("sleep 10"); + expect(proc.uptime_ms).toBeGreaterThanOrEqual(0); + expect(proc.exitCode).toBeUndefined(); + expect(proc.stdout_path).toContain("stdout.log"); + expect(proc.stderr_path).toContain("stderr.log"); + } + + // Cleanup + await manager.cleanup("test-workspace"); + tempDir[Symbol.dispose](); + }); + + it("should include display_name in listed processes", async () => { + const manager = new BackgroundProcessManager(); + const tempDir = new TestTempDir("test-bash-bg-list"); + const runtime = createTestRuntime(tempDir.path); + const config = createTestToolConfig(process.cwd(), { sessionsDir: tempDir.path }); + config.runtimeTempDir = tempDir.path; + config.backgroundProcessManager = manager; + + // Spawn a process with display_name + const spawnResult = await manager.spawn(runtime, "test-workspace", "sleep 10", { + cwd: process.cwd(), + displayName: "Dev Server", + }); + + if (!spawnResult.success) { + throw new Error("Failed to spawn process"); + } + + const tool = createBashBackgroundListTool(config); + const result = (await tool.execute!({}, mockToolCallOptions)) as BashBackgroundListResult; + + expect(result.success).toBe(true); + if (result.success) { + expect(result.processes.length).toBe(1); + expect(result.processes[0].display_name).toBe("Dev Server"); + } + + // Cleanup + await manager.cleanup("test-workspace"); + tempDir[Symbol.dispose](); + }); + + it("should only list processes for the current workspace", async () => { + const manager = new BackgroundProcessManager(); + const tempDir = new TestTempDir("test-bash-bg-list"); + const runtime = createTestRuntime(tempDir.path); + + const config = createTestToolConfig(process.cwd(), { + workspaceId: "workspace-a", + sessionsDir: tempDir.path, + }); + config.runtimeTempDir = tempDir.path; + config.backgroundProcessManager = manager; + + // Spawn processes in different workspaces + const spawnA = await manager.spawn(runtime, "workspace-a", "sleep 10", { + cwd: process.cwd(), + }); + const spawnB = await manager.spawn(runtime, "workspace-b", "sleep 10", { + cwd: process.cwd(), + }); + + if (!spawnA.success || !spawnB.success) { + throw new Error("Failed to spawn processes"); + } + + const tool = createBashBackgroundListTool(config); + const result = (await tool.execute!({}, mockToolCallOptions)) as BashBackgroundListResult; + + expect(result.success).toBe(true); + if (result.success) { + expect(result.processes.length).toBe(1); + expect(result.processes[0].process_id).toBe(spawnA.processId); + } + + // Cleanup + await manager.cleanup("workspace-a"); + await manager.cleanup("workspace-b"); + tempDir[Symbol.dispose](); + }); +}); diff --git a/src/node/services/tools/bash_background_list.ts b/src/node/services/tools/bash_background_list.ts new file mode 100644 index 0000000000..7e7289fe7e --- /dev/null +++ b/src/node/services/tools/bash_background_list.ts @@ -0,0 +1,46 @@ +import { tool } from "ai"; +import type { BashBackgroundListResult } from "@/common/types/tools"; +import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; +import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; + +/** + * Tool for listing background processes in the current workspace + */ +export const createBashBackgroundListTool: ToolFactory = (config: ToolConfiguration) => { + return tool({ + description: TOOL_DEFINITIONS.bash_background_list.description, + inputSchema: TOOL_DEFINITIONS.bash_background_list.schema, + execute: async (): Promise => { + if (!config.backgroundProcessManager) { + return { + success: false, + error: "Background process manager not available", + }; + } + + if (!config.workspaceId) { + return { + success: false, + error: "Workspace ID not available", + }; + } + + const processes = await config.backgroundProcessManager.list(config.workspaceId); + const now = Date.now(); + + return { + success: true, + processes: processes.map((p) => ({ + process_id: p.id, + status: p.status, + script: p.script, + uptime_ms: p.exitTime !== undefined ? p.exitTime - p.startTime : now - p.startTime, + exitCode: p.exitCode, + stdout_path: `${p.outputDir}/stdout.log`, + stderr_path: `${p.outputDir}/stderr.log`, + display_name: p.displayName, + })), + }; + }, + }); +}; diff --git a/src/node/services/tools/bash_background_terminate.test.ts b/src/node/services/tools/bash_background_terminate.test.ts new file mode 100644 index 0000000000..4687d0235a --- /dev/null +++ b/src/node/services/tools/bash_background_terminate.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect } from "bun:test"; +import { createBashBackgroundTerminateTool } from "./bash_background_terminate"; +import { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; +import { LocalRuntime } from "@/node/runtime/LocalRuntime"; +import type { Runtime } from "@/node/runtime/Runtime"; +import type { + BashBackgroundTerminateArgs, + BashBackgroundTerminateResult, +} from "@/common/types/tools"; +import { TestTempDir, createTestToolConfig } from "./testHelpers"; +import type { ToolCallOptions } from "ai"; + +const mockToolCallOptions: ToolCallOptions = { + toolCallId: "test-call-id", + messages: [], +}; + +// Create test runtime with isolated sessions directory +function createTestRuntime(sessionsDir: string): Runtime { + return new LocalRuntime(process.cwd(), sessionsDir); +} + +describe("bash_background_terminate tool", () => { + it("should return error when manager not available", async () => { + const tempDir = new TestTempDir("test-bash-bg-term"); + const config = createTestToolConfig(process.cwd()); + config.runtimeTempDir = tempDir.path; + + const tool = createBashBackgroundTerminateTool(config); + const args: BashBackgroundTerminateArgs = { + process_id: "bg-test", + }; + + const result = (await tool.execute!( + args, + mockToolCallOptions + )) as BashBackgroundTerminateResult; + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("Background process manager not available"); + } + + tempDir[Symbol.dispose](); + }); + + it("should return error for non-existent process", async () => { + const manager = new BackgroundProcessManager(); + const tempDir = new TestTempDir("test-bash-bg-term"); + const config = createTestToolConfig(process.cwd()); + config.runtimeTempDir = tempDir.path; + config.backgroundProcessManager = manager; + + const tool = createBashBackgroundTerminateTool(config); + const args: BashBackgroundTerminateArgs = { + process_id: "bg-nonexistent", + }; + + const result = (await tool.execute!( + args, + mockToolCallOptions + )) as BashBackgroundTerminateResult; + + expect(result.success).toBe(false); + }); + + it("should terminate a running process", async () => { + const manager = new BackgroundProcessManager(); + const tempDir = new TestTempDir("test-bash-bg-term"); + const runtime = createTestRuntime(tempDir.path); + const config = createTestToolConfig(process.cwd(), { sessionsDir: tempDir.path }); + config.runtimeTempDir = tempDir.path; + config.backgroundProcessManager = manager; + + // Spawn a long-running process + const spawnResult = await manager.spawn(runtime, "test-workspace", "sleep 10", { + cwd: process.cwd(), + }); + + if (!spawnResult.success) { + throw new Error("Failed to spawn process"); + } + + const tool = createBashBackgroundTerminateTool(config); + const args: BashBackgroundTerminateArgs = { + process_id: spawnResult.processId, + }; + + const result = (await tool.execute!( + args, + mockToolCallOptions + )) as BashBackgroundTerminateResult; + + expect(result.success).toBe(true); + if (result.success) { + expect(result.message).toContain(spawnResult.processId); + } + + // Verify process is no longer running + const bgProcess = await manager.getProcess(spawnResult.processId); + expect(bgProcess?.status).not.toBe("running"); + + await manager.cleanup("test-workspace"); + tempDir[Symbol.dispose](); + }); + + it("should be idempotent (double-terminate succeeds)", async () => { + const manager = new BackgroundProcessManager(); + const tempDir = new TestTempDir("test-bash-bg-term"); + const runtime = createTestRuntime(tempDir.path); + const config = createTestToolConfig(process.cwd(), { sessionsDir: tempDir.path }); + config.runtimeTempDir = tempDir.path; + config.backgroundProcessManager = manager; + + // Spawn a process + const spawnResult = await manager.spawn(runtime, "test-workspace", "sleep 10", { + cwd: process.cwd(), + }); + + if (!spawnResult.success) { + throw new Error("Failed to spawn process"); + } + + const tool = createBashBackgroundTerminateTool(config); + const args: BashBackgroundTerminateArgs = { + process_id: spawnResult.processId, + }; + + // First termination + const result1 = (await tool.execute!( + args, + mockToolCallOptions + )) as BashBackgroundTerminateResult; + expect(result1.success).toBe(true); + + // Second termination + const result2 = (await tool.execute!( + args, + mockToolCallOptions + )) as BashBackgroundTerminateResult; + expect(result2.success).toBe(true); + + await manager.cleanup("test-workspace"); + tempDir[Symbol.dispose](); + }); + + it("should not terminate processes from other workspaces", async () => { + const manager = new BackgroundProcessManager(); + const tempDir = new TestTempDir("test-bash-bg-term"); + const runtime = createTestRuntime(tempDir.path); + + // Config is for workspace-a + const config = createTestToolConfig(process.cwd(), { + workspaceId: "workspace-a", + sessionsDir: tempDir.path, + }); + config.runtimeTempDir = tempDir.path; + config.backgroundProcessManager = manager; + + // Spawn process in workspace-b + const spawnResult = await manager.spawn(runtime, "workspace-b", "sleep 10", { + cwd: process.cwd(), + }); + + if (!spawnResult.success) { + throw new Error("Failed to spawn process"); + } + + // Try to terminate from workspace-a (should fail) + const tool = createBashBackgroundTerminateTool(config); + const args: BashBackgroundTerminateArgs = { + process_id: spawnResult.processId, + }; + + const result = (await tool.execute!( + args, + mockToolCallOptions + )) as BashBackgroundTerminateResult; + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("Process not found"); + } + + // Process should still be running + const proc = await manager.getProcess(spawnResult.processId); + expect(proc?.status).toBe("running"); + + // Cleanup + await manager.cleanup("workspace-b"); + tempDir[Symbol.dispose](); + }); +}); diff --git a/src/node/services/tools/bash_background_terminate.ts b/src/node/services/tools/bash_background_terminate.ts new file mode 100644 index 0000000000..9b2c901bf0 --- /dev/null +++ b/src/node/services/tools/bash_background_terminate.ts @@ -0,0 +1,49 @@ +import { tool } from "ai"; +import type { BashBackgroundTerminateResult } from "@/common/types/tools"; +import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; +import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; + +/** + * Tool for terminating background processes + */ +export const createBashBackgroundTerminateTool: ToolFactory = (config: ToolConfiguration) => { + return tool({ + description: TOOL_DEFINITIONS.bash_background_terminate.description, + inputSchema: TOOL_DEFINITIONS.bash_background_terminate.schema, + execute: async ({ process_id }): Promise => { + if (!config.backgroundProcessManager) { + return { + success: false, + error: "Background process manager not available", + }; + } + + if (!config.workspaceId) { + return { + success: false, + error: "Workspace ID not available", + }; + } + + // Verify process belongs to this workspace before terminating + const process = await config.backgroundProcessManager.getProcess(process_id); + if (!process || process.workspaceId !== config.workspaceId) { + return { + success: false, + error: `Process not found: ${process_id}`, + }; + } + + const result = await config.backgroundProcessManager.terminate(process_id); + if (result.success) { + return { + success: true, + message: `Process ${process_id} terminated`, + display_name: process.displayName, + }; + } + + return result; + }, + }); +}; diff --git a/src/node/services/tools/file_read.test.ts b/src/node/services/tools/file_read.test.ts index 9d86c6e118..edd021f36f 100644 --- a/src/node/services/tools/file_read.test.ts +++ b/src/node/services/tools/file_read.test.ts @@ -354,8 +354,8 @@ describe("file_read tool", () => { const tool = createFileReadTool({ ...getTestDeps(), cwd: subDir, - runtime: new LocalRuntime(process.cwd()), - runtimeTempDir: "/tmp", + runtime: new LocalRuntime(process.cwd(), testDir), + runtimeTempDir: testDir, }); const args: FileReadToolArgs = { filePath: "../test.txt", // This goes outside subDir back to testDir diff --git a/src/node/services/tools/status_set.test.ts b/src/node/services/tools/status_set.test.ts index 27c0c161da..05c176940c 100644 --- a/src/node/services/tools/status_set.test.ts +++ b/src/node/services/tools/status_set.test.ts @@ -10,6 +10,7 @@ describe("status_set tool validation", () => { cwd: "/test", runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }), runtimeTempDir: "/tmp", + workspaceId: "test-workspace", }; const mockToolCallOptions: ToolCallOptions = { diff --git a/src/node/services/tools/testHelpers.ts b/src/node/services/tools/testHelpers.ts index c7831afbee..5274957b7a 100644 --- a/src/node/services/tools/testHelpers.ts +++ b/src/node/services/tools/testHelpers.ts @@ -48,16 +48,18 @@ function getTestInitStateManager(): InitStateManager { /** * Create basic tool configuration for testing. * Returns a config object with default values that can be overridden. + * Uses tempDir for both cwd and sessionsDir to isolate tests. */ export function createTestToolConfig( tempDir: string, - options?: { niceness?: number } + options?: { niceness?: number; workspaceId?: string; sessionsDir?: string } ): ToolConfiguration { return { cwd: tempDir, - runtime: new LocalRuntime(tempDir), + runtime: new LocalRuntime(tempDir, options?.sessionsDir ?? tempDir), runtimeTempDir: tempDir, niceness: options?.niceness, + workspaceId: options?.workspaceId ?? "test-workspace", }; } diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 1cd485c573..49cda4476f 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -6,6 +6,7 @@ import type { PartialService } from "./partialService"; import type { AIService } from "./aiService"; import type { InitStateManager } from "./initStateManager"; import type { ExtensionMetadataService } from "./ExtensionMetadataService"; +import type { BackgroundProcessManager } from "./backgroundProcessManager"; // Helper to access private renamingWorkspaces set function addToRenamingWorkspaces(service: WorkspaceService, workspaceId: string): void { @@ -46,6 +47,9 @@ describe("WorkspaceService rename lock", () => { const mockInitStateManager: Partial = {}; const mockExtensionMetadataService: Partial = {}; + const mockBackgroundProcessManager: Partial = { + cleanup: mock(() => Promise.resolve()), + }; workspaceService = new WorkspaceService( mockConfig as Config, @@ -53,7 +57,8 @@ describe("WorkspaceService rename lock", () => { mockPartialService as PartialService, mockAIService, mockInitStateManager as InitStateManager, - mockExtensionMetadataService as ExtensionMetadataService + mockExtensionMetadataService as ExtensionMetadataService, + mockBackgroundProcessManager as BackgroundProcessManager ); }); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index cb5bd7dbf0..5452775922 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -32,6 +32,7 @@ import { hasSrcBaseDir, getSrcBaseDir } from "@/common/types/runtime"; import { defaultModel } from "@/common/utils/ai/models"; import type { StreamEndEvent, StreamAbortEvent } from "@/common/types/stream"; import type { TerminalService } from "@/node/services/terminalService"; +import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import { DisposableTempDir } from "@/node/services/tempDir"; import { createBashTool } from "@/node/services/tools/bash"; @@ -87,7 +88,8 @@ export class WorkspaceService extends EventEmitter { private readonly partialService: PartialService, private readonly aiService: AIService, private readonly initStateManager: InitStateManager, - private readonly extensionMetadata: ExtensionMetadataService + private readonly extensionMetadata: ExtensionMetadataService, + private readonly backgroundProcessManager: BackgroundProcessManager ) { super(); this.setupMetadataListeners(); @@ -223,6 +225,7 @@ export class WorkspaceService extends EventEmitter { partialService: this.partialService, aiService: this.aiService, initStateManager: this.initStateManager, + backgroundProcessManager: this.backgroundProcessManager, }); const chatUnsubscribe = session.onChatEvent((event) => { diff --git a/src/node/utils/disposableExec.ts b/src/node/utils/disposableExec.ts index 0581c32a4d..e96237df22 100644 --- a/src/node/utils/disposableExec.ts +++ b/src/node/utils/disposableExec.ts @@ -113,6 +113,14 @@ class DisposableExec implements Disposable { } } +/** + * Options for execAsync. + */ +export interface ExecAsyncOptions { + /** Shell to use for command execution. If not specified, uses system default (cmd.exe on Windows). */ + shell?: string; +} + /** * Execute command with automatic cleanup via `using` declaration. * Prevents zombie processes by ensuring child is reaped even on error. @@ -120,9 +128,12 @@ class DisposableExec implements Disposable { * @example * using proc = execAsync("git status"); * const { stdout } = await proc.result; + * + * // With explicit shell (needed for POSIX commands on Windows) + * using proc = execAsync("nohup bash -c ...", { shell: getBashPath() }); */ -export function execAsync(command: string): DisposableExec { - const child = exec(command); +export function execAsync(command: string, options?: ExecAsyncOptions): DisposableExec { + const child = exec(command, { shell: options?.shell }); const promise = new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { let stdout = ""; let stderr = ""; diff --git a/src/node/utils/paths.test.ts b/src/node/utils/paths.test.ts index 3cff7f8dbb..7317209de7 100644 --- a/src/node/utils/paths.test.ts +++ b/src/node/utils/paths.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect } from "bun:test"; import { PlatformPaths } from "./paths.main"; +import { toPosixPath } from "./paths"; import * as os from "os"; import * as path from "path"; @@ -144,3 +145,77 @@ describe("PlatformPaths", () => { }); }); }); + +describe("toPosixPath", () => { + describe("on non-Windows platforms", () => { + test("returns POSIX paths unchanged", () => { + if (process.platform !== "win32") { + expect(toPosixPath("/home/user/project")).toBe("/home/user/project"); + expect(toPosixPath("/tmp/mux-bashes")).toBe("/tmp/mux-bashes"); + } + }); + + test("returns paths with spaces unchanged", () => { + if (process.platform !== "win32") { + expect(toPosixPath("/home/user/my project")).toBe("/home/user/my project"); + } + }); + + test("returns relative paths unchanged", () => { + if (process.platform !== "win32") { + expect(toPosixPath("relative/path/file.txt")).toBe("relative/path/file.txt"); + } + }); + + test("returns empty string unchanged", () => { + if (process.platform !== "win32") { + expect(toPosixPath("")).toBe(""); + } + }); + }); + + describe("path format handling", () => { + test("handles paths with special characters", () => { + const input = "/path/with spaces/and-dashes/under_scores"; + const result = toPosixPath(input); + expect(typeof result).toBe("string"); + if (process.platform !== "win32") { + expect(result).toBe(input); + } + }); + + test("handles deeply nested paths", () => { + const input = "/a/b/c/d/e/f/g/h/i/j/file.txt"; + const result = toPosixPath(input); + expect(typeof result).toBe("string"); + if (process.platform !== "win32") { + expect(result).toBe(input); + } + }); + }); + + // Windows-specific behavior documentation + // These tests document expected behavior but can only truly verify on Windows CI + describe("Windows behavior (documented)", () => { + test("converts Windows drive paths to POSIX format on Windows", () => { + // On Windows with Git Bash/MSYS2, cygpath converts: + // "C:\\Users\\test" → "/c/Users/test" + // "C:\\Program Files\\Git" → "/c/Program Files/Git" + // "D:\\Projects\\mux" → "/d/Projects/mux" + // + // On non-Windows, this is a no-op (returns input unchanged) + if (process.platform === "win32") { + // Real Windows test - only runs on Windows CI + const result = toPosixPath("C:\\Users\\test"); + expect(result).toMatch(/^\/c\/Users\/test$/i); + } + }); + + test("falls back to original path if cygpath unavailable", () => { + // If cygpath is not available (edge case), the function catches + // the error and returns the original path unchanged + // This prevents crashes if Git Bash is misconfigured + expect(true).toBe(true); // Cannot easily test without mocking execSync + }); + }); +}); diff --git a/src/node/utils/paths.ts b/src/node/utils/paths.ts new file mode 100644 index 0000000000..2ed2346b17 --- /dev/null +++ b/src/node/utils/paths.ts @@ -0,0 +1,20 @@ +import { execFileSync } from "child_process"; + +/** + * Convert a path to POSIX format for Git Bash on Windows. + * On non-Windows platforms, returns the path unchanged. + * + * Use this when building shell command strings that will run in Git Bash, + * where Windows-style paths (C:\foo\bar) don't work. + */ +export function toPosixPath(windowsPath: string): string { + if (process.platform !== "win32") return windowsPath; + try { + // cygpath converts Windows paths to POSIX format for Git Bash / MSYS2 + // Use execFileSync with args array to avoid shell injection + return execFileSync("cygpath", ["-u", windowsPath], { encoding: "utf8" }).trim(); + } catch { + // Fallback if cygpath unavailable (shouldn't happen with Git Bash) + return windowsPath; + } +} diff --git a/tests/ipc/backgroundBash.test.ts b/tests/ipc/backgroundBash.test.ts new file mode 100644 index 0000000000..fd8ebc0530 --- /dev/null +++ b/tests/ipc/backgroundBash.test.ts @@ -0,0 +1,331 @@ +/** + * Integration tests for background bash process execution + * + * Tests the background process feature via AI tool calls on local runtime. + * SSH runtime tests are intentionally omitted to avoid flakiness. + * + * These tests verify the service wiring is correct - detailed behavior + * is covered by unit tests in backgroundProcessManager.test.ts + */ + +import { + createTestEnvironment, + cleanupTestEnvironment, + shouldRunIntegrationTests, + validateApiKeys, + getApiKey, + setupProviders, +} from "./setup"; +import { + createTempGitRepo, + cleanupTempGitRepo, + generateBranchName, + createWorkspaceWithInit, + sendMessageAndWait, + extractTextFromEvents, + HAIKU_MODEL, +} from "./helpers"; +import type { WorkspaceChatMessage } from "../../src/common/orpc/types"; +import type { ToolPolicy } from "../../src/common/utils/tools/toolPolicy"; + +// Tool policy: Allow bash and bash_background_* tools (bash prefix matches all) +const BACKGROUND_TOOLS: ToolPolicy = [ + { regex_match: "bash", action: "enable" }, + { regex_match: "file_.*", action: "disable" }, +]; + +// Extended timeout for tests making multiple AI calls +const BACKGROUND_TEST_TIMEOUT_MS = 75000; + +/** + * Extract process ID from bash tool output containing "Background process started with ID: bg-xxx" + */ +function extractProcessId(events: WorkspaceChatMessage[]): string | null { + for (const event of events) { + if ( + "type" in event && + event.type === "tool-call-end" && + "toolName" in event && + event.toolName === "bash" + ) { + const result = (event as { result?: { output?: string } }).result?.output; + if (typeof result === "string") { + const match = result.match(/Background process started with ID: (bg-[a-z0-9]+)/); + if (match) return match[1]; + } + } + } + return null; +} + +/** + * Check if any tool output contains a specific string + */ +function toolOutputContains( + events: WorkspaceChatMessage[], + toolName: string, + substring: string +): boolean { + for (const event of events) { + if ( + "type" in event && + event.type === "tool-call-end" && + "toolName" in event && + event.toolName === toolName + ) { + const result = (event as { result?: { output?: string; message?: string } }).result; + const text = result?.output ?? result?.message; + if (typeof text === "string" && text.includes(substring)) { + return true; + } + } + } + return false; +} + +// Skip all tests if TEST_INTEGRATION is not set +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +// Validate API keys before running tests +if (shouldRunIntegrationTests()) { + validateApiKeys(["ANTHROPIC_API_KEY"]); +} + +describeIntegration("Background Bash Execution", () => { + test.concurrent( + "should start a background process and list it", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + // Setup provider + await setupProviders(env, { + anthropic: { + apiKey: getApiKey("ANTHROPIC_API_KEY"), + }, + }); + + // Create workspace + const branchName = generateBranchName("bg-basic"); + const { workspaceId, cleanup } = await createWorkspaceWithInit( + env, + tempGitRepo, + branchName, + undefined, // local runtime + true // waitForInit + ); + + try { + // Start a background process using explicit tool call instruction + const startEvents = await sendMessageAndWait( + env, + workspaceId, + "Use the bash tool with run_in_background=true to run: true && sleep 30", + HAIKU_MODEL, + BACKGROUND_TOOLS, + 30000 + ); + + // Extract process ID from tool output + const processId = extractProcessId(startEvents); + expect(processId).not.toBeNull(); + expect(processId).toMatch(/^bg-[a-z0-9]+$/); + + // List background processes to verify it's tracked + const listEvents = await sendMessageAndWait( + env, + workspaceId, + "Use the bash_background_list tool to show running background processes", + HAIKU_MODEL, + BACKGROUND_TOOLS, + 20000 + ); + + // Verify the process appears in the list + const responseText = extractTextFromEvents(listEvents); + expect( + responseText.includes(processId!) || + toolOutputContains(listEvents, "bash_background_list", processId!) + ).toBe(true); + + // Clean up: terminate the background process + await sendMessageAndWait( + env, + workspaceId, + `Use bash_background_terminate to terminate process ${processId}`, + HAIKU_MODEL, + BACKGROUND_TOOLS, + 20000 + ); + } finally { + await cleanup(); + } + } finally { + await cleanupTempGitRepo(tempGitRepo); + await cleanupTestEnvironment(env); + } + }, + BACKGROUND_TEST_TIMEOUT_MS + ); + + test.concurrent( + "should terminate a background process", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + // Setup provider + await setupProviders(env, { + anthropic: { + apiKey: getApiKey("ANTHROPIC_API_KEY"), + }, + }); + + // Create workspace + const branchName = generateBranchName("bg-terminate"); + const { workspaceId, cleanup } = await createWorkspaceWithInit( + env, + tempGitRepo, + branchName, + undefined, // local runtime + true // waitForInit + ); + + try { + // Start a long-running background process + const startEvents = await sendMessageAndWait( + env, + workspaceId, + "Use bash with run_in_background=true to run: true && sleep 300", + HAIKU_MODEL, + BACKGROUND_TOOLS, + 30000 + ); + + const processId = extractProcessId(startEvents); + expect(processId).not.toBeNull(); + + // Terminate the process + const terminateEvents = await sendMessageAndWait( + env, + workspaceId, + `Use bash_background_terminate to terminate process ${processId}`, + HAIKU_MODEL, + BACKGROUND_TOOLS, + 20000 + ); + + // Verify termination succeeded (tool output should indicate success) + const terminateSuccess = + toolOutputContains(terminateEvents, "bash_background_terminate", "terminated") || + toolOutputContains(terminateEvents, "bash_background_terminate", "success") || + toolOutputContains(terminateEvents, "bash_background_terminate", processId!); + expect(terminateSuccess).toBe(true); + + // List to verify status changed to killed + const listEvents = await sendMessageAndWait( + env, + workspaceId, + "Use bash_background_list to show all background processes including terminated ones", + HAIKU_MODEL, + BACKGROUND_TOOLS, + 20000 + ); + + // Process should show as killed/terminated + const listResponse = extractTextFromEvents(listEvents); + expect( + listResponse.toLowerCase().includes("killed") || + listResponse.toLowerCase().includes("terminated") || + toolOutputContains(listEvents, "bash_background_list", "killed") + ).toBe(true); + } finally { + await cleanup(); + } + } finally { + await cleanupTempGitRepo(tempGitRepo); + await cleanupTestEnvironment(env); + } + }, + BACKGROUND_TEST_TIMEOUT_MS + ); + + test.concurrent( + "should capture output from background process", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + // Setup provider + await setupProviders(env, { + anthropic: { + apiKey: getApiKey("ANTHROPIC_API_KEY"), + }, + }); + + // Create workspace + const branchName = generateBranchName("bg-output"); + const { workspaceId, cleanup } = await createWorkspaceWithInit( + env, + tempGitRepo, + branchName, + undefined, // local runtime + true // waitForInit + ); + + try { + // Start a background process that outputs a unique marker then exits + const marker = `BGTEST_${Date.now()}_${Math.random().toString(36).slice(2)}`; + const startEvents = await sendMessageAndWait( + env, + workspaceId, + `Use bash with run_in_background=true to run: echo "${marker}" && sleep 1`, + HAIKU_MODEL, + BACKGROUND_TOOLS, + 30000 + ); + + const processId = extractProcessId(startEvents); + expect(processId).not.toBeNull(); + + // Wait for process to complete and output to be written + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // List processes - should show the marker in output or process details + const listEvents = await sendMessageAndWait( + env, + workspaceId, + `Use bash_background_list to show details of background processes`, + HAIKU_MODEL, + BACKGROUND_TOOLS, + 20000 + ); + + // The process should have exited (status: exited) after sleep completes + const listResponse = extractTextFromEvents(listEvents); + const hasExited = + listResponse.toLowerCase().includes("exited") || + listResponse.toLowerCase().includes("completed") || + toolOutputContains(listEvents, "bash_background_list", "exited"); + + // Process may still be running or just finished - either is acceptable + // The main assertion is that the process was tracked + expect( + hasExited || + listResponse.includes(processId!) || + toolOutputContains(listEvents, "bash_background_list", processId!) + ).toBe(true); + } finally { + await cleanup(); + } + } finally { + await cleanupTempGitRepo(tempGitRepo); + await cleanupTestEnvironment(env); + } + }, + BACKGROUND_TEST_TIMEOUT_MS + ); +}); diff --git a/tests/runtime/runtime.test.ts b/tests/runtime/runtime.test.ts index e1fe6c522e..afca773356 100644 --- a/tests/runtime/runtime.test.ts +++ b/tests/runtime/runtime.test.ts @@ -17,7 +17,7 @@ import { } from "./ssh-fixture"; import { createTestRuntime, TestWorkspace, type RuntimeType } from "./test-helpers"; import { execBuffered, readFileString, writeFileString } from "@/node/utils/runtime/helpers"; -import type { Runtime } from "@/node/runtime/Runtime"; +import type { BackgroundHandle, Runtime } from "@/node/runtime/Runtime"; import { RuntimeError } from "@/node/runtime/Runtime"; // Skip all tests if TEST_INTEGRATION is not set @@ -1178,6 +1178,205 @@ describeIntegration("Runtime integration tests", () => { } }); }); + + describe("spawnBackground() - Background processes", () => { + // Generate unique IDs for each test to avoid conflicts + const genId = () => `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + // Polling helpers to handle SSH latency variability + async function waitForOutput( + rt: Runtime, + filePath: string, + opts?: { timeout?: number; interval?: number } + ): Promise { + const { timeout = 5000, interval = 100 } = opts ?? {}; + const start = Date.now(); + while (Date.now() - start < timeout) { + const content = await readFileString(rt, filePath); + if (content.trim()) return content; + await new Promise((r) => setTimeout(r, interval)); + } + return await readFileString(rt, filePath); + } + + async function waitForExitCode( + handle: BackgroundHandle, + opts?: { timeout?: number; interval?: number } + ): Promise { + const { timeout = 5000, interval = 100 } = opts ?? {}; + const start = Date.now(); + while (Date.now() - start < timeout) { + const code = await handle.getExitCode(); + if (code !== null) return code; + await new Promise((r) => setTimeout(r, interval)); + } + return await handle.getExitCode(); + } + + test.concurrent("spawns process and captures output to file", async () => { + const runtime = createRuntime(); + await using workspace = await TestWorkspace.create(runtime, type); + const workspaceId = genId(); + + const result = await runtime.spawnBackground('echo "hello from background"', { + cwd: workspace.path, + workspaceId, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + + expect(result.pid).toBeGreaterThan(0); + expect(result.handle.outputDir).toContain(workspaceId); + expect(result.handle.outputDir).toMatch(/bg-[0-9a-f]{8}/); + + // Poll for output (handles SSH latency) + const stdoutPath = `${result.handle.outputDir}/stdout.log`; + const stdout = await waitForOutput(runtime, stdoutPath); + expect(stdout.trim()).toBe("hello from background"); + + await result.handle.dispose(); + }); + + test.concurrent("captures exit code via trap", async () => { + const runtime = createRuntime(); + await using workspace = await TestWorkspace.create(runtime, type); + const workspaceId = genId(); + + // Spawn a process that exits with code 42 + const result = await runtime.spawnBackground("exit 42", { + cwd: workspace.path, + workspaceId, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + + // Poll for exit code (handles SSH latency) + const exitCode = await waitForExitCode(result.handle); + expect(exitCode).toBe(42); + + await result.handle.dispose(); + }); + + test.concurrent("getExitCode() returns null while process runs", async () => { + const runtime = createRuntime(); + await using workspace = await TestWorkspace.create(runtime, type); + const workspaceId = genId(); + + // Spawn a long-running process + const result = await runtime.spawnBackground("sleep 30", { + cwd: workspace.path, + workspaceId, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + + // Should be running (exit code null) + expect(await result.handle.getExitCode()).toBe(null); + + // Terminate it + await result.handle.terminate(); + + // Poll for exit code after termination + const exitCode = await waitForExitCode(result.handle); + expect(exitCode).not.toBe(null); + + await result.handle.dispose(); + }); + + test.concurrent("terminate() kills running process", async () => { + const runtime = createRuntime(); + await using workspace = await TestWorkspace.create(runtime, type); + const workspaceId = genId(); + + // Spawn a process that runs indefinitely + const result = await runtime.spawnBackground("sleep 60", { + cwd: workspace.path, + workspaceId, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + + // Verify it's running (exit code null) + expect(await result.handle.getExitCode()).toBe(null); + + // Terminate + await result.handle.terminate(); + + // Poll for exit code (handles SSH latency) + const exitCode = await waitForExitCode(result.handle); + expect(exitCode).not.toBe(null); + + await result.handle.dispose(); + }); + + test.concurrent("captures stderr to file", async () => { + const runtime = createRuntime(); + await using workspace = await TestWorkspace.create(runtime, type); + const workspaceId = genId(); + + const result = await runtime.spawnBackground('echo "error message" >&2', { + cwd: workspace.path, + workspaceId, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + + // Poll for output (handles SSH latency) + const stderrPath = `${result.handle.outputDir}/stderr.log`; + const stderr = await waitForOutput(runtime, stderrPath); + expect(stderr.trim()).toBe("error message"); + + await result.handle.dispose(); + }); + + test.concurrent("respects working directory", async () => { + const runtime = createRuntime(); + await using workspace = await TestWorkspace.create(runtime, type); + const workspaceId = genId(); + + const result = await runtime.spawnBackground("pwd", { + cwd: workspace.path, + workspaceId, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + + // Poll for output (handles SSH latency) + const stdoutPath = `${result.handle.outputDir}/stdout.log`; + const stdout = await waitForOutput(runtime, stdoutPath); + expect(stdout.trim()).toBe(workspace.path); + + await result.handle.dispose(); + }); + + test.concurrent("passes environment variables", async () => { + const runtime = createRuntime(); + await using workspace = await TestWorkspace.create(runtime, type); + const workspaceId = genId(); + + const result = await runtime.spawnBackground('echo "secret=$MY_SECRET"', { + cwd: workspace.path, + workspaceId, + env: { MY_SECRET: "hunter2" }, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + + // Poll for output (handles SSH latency) + const stdoutPath = `${result.handle.outputDir}/stdout.log`; + const stdout = await waitForOutput(runtime, stdoutPath); + expect(stdout.trim()).toBe("secret=hunter2"); + + await result.handle.dispose(); + }); + }); } ); }); diff --git a/tests/runtime/test-helpers.ts b/tests/runtime/test-helpers.ts index 9b73c5085f..a00ef652cb 100644 --- a/tests/runtime/test-helpers.ts +++ b/tests/runtime/test-helpers.ts @@ -30,7 +30,7 @@ export function createTestRuntime( // Resolve symlinks (e.g., /tmp -> /private/tmp on macOS) to match git worktree paths // Note: "local" in tests means WorktreeRuntime (isolated git worktrees) const resolvedWorkdir = realpathSync(workdir); - return new WorktreeRuntime(resolvedWorkdir); + return new WorktreeRuntime(resolvedWorkdir, resolvedWorkdir); case "ssh": if (!sshConfig) { throw new Error("SSH config required for SSH runtime");