diff --git a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx index d1f21557e..a0335b6bc 100644 --- a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -269,7 +269,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/common/types/tools.ts b/src/common/types/tools.ts index fc71d350c..8e9850c26 100644 --- a/src/common/types/tools.ts +++ b/src/common/types/tools.ts @@ -7,6 +7,7 @@ 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) } interface CommonBashFields { @@ -26,6 +27,12 @@ export type BashToolResult = totalLines: number; }; }) + | (CommonBashFields & { + success: true; + output: string; + exitCode: 0; + backgroundProcessId: string; // Background spawn succeeded + }) | (CommonBashFields & { success: false; output?: string; @@ -190,6 +197,54 @@ export interface StatusSetToolArgs { url?: string; } +// Bash Background Tool Types +export interface BashBackgroundReadArgs { + process_id: string; + stdout_tail?: number; // Last N lines of stdout + stderr_tail?: number; // Last N lines of stderr + stdout_regex?: string; // Filter stdout by regex + stderr_regex?: string; // Filter stderr by regex +} + +export type BashBackgroundReadResult = + | { + success: true; + process_id: string; + status: "running" | "exited" | "killed" | "failed"; + script: string; + uptime_ms: number; + exitCode?: number; + stdout: string[]; + stderr: string[]; + } + | { + success: false; + error: string; + }; + +export interface BashBackgroundTerminateArgs { + process_id: string; +} + +export type BashBackgroundTerminateResult = + | { success: true; message: 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; +} + +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 66e180ab3..7b1991296 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -52,6 +52,19 @@ 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 for status checking and termination. " + + "Output is buffered (max 1000 lines per stream, oldest evicted when full). " + + "Poll frequently with bash_background_read for high-output processes. " + + "Process persists across tool calls until terminated or workspace is removed." + ), }), }, file_read: { @@ -229,6 +242,56 @@ export const TOOL_DEFINITIONS = { }) .strict(), }, + bash_background_read: { + description: + "Check status and read output from a background bash process. " + + "Use this to inspect long-running processes started with run_in_background=true. " + + "Supports filtering output with tail and regex options.", + schema: z.object({ + process_id: z + .string() + .regex(/^bg-[0-9a-f]{8}$/, "Invalid process ID format") + .describe("Background process ID returned from bash tool"), + stdout_tail: z + .number() + .int() + .positive() + .optional() + .describe("Return last N lines of stdout (default: all buffered)"), + stderr_tail: z + .number() + .int() + .positive() + .optional() + .describe("Return last N lines of stderr (default: all buffered)"), + stdout_regex: z + .string() + .optional() + .describe("Filter stdout lines by regex pattern (applied before tail)"), + stderr_regex: z + .string() + .optional() + .describe("Filter stderr lines by regex pattern (applied before tail)"), + }), + }, + bash_background_list: { + description: + "List all background processes for the current workspace. " + + "Useful for discovering running processes after context loss or resuming a conversation.", + schema: z.object({}), + }, + bash_background_terminate: { + description: + "Terminate a background bash process. " + + "Sends SIGTERM, waits briefly, then sends SIGKILL if needed. " + + "Process output remains available for inspection 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. ` + @@ -271,6 +334,9 @@ export function getAvailableTools(modelString: string): string[] { // Base tools available for all models const baseTools = [ "bash", + "bash_background_read", + "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 873e6a8c3..013120470 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -1,6 +1,9 @@ import { type Tool } from "ai"; import { createFileReadTool } from "@/node/services/tools/file_read"; import { createBashTool } from "@/node/services/tools/bash"; +import { createBashBackgroundReadTool } from "@/node/services/tools/bash_background_read"; +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 +15,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 @@ -29,6 +33,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; } /** @@ -99,6 +107,11 @@ 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)), + // TODO: These aren't supported by the SSH runtime yet, but they will be, + // so always add them. + bash_background_read: wrap(createBashBackgroundReadTool(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 4eb8f39d9..3f87060d0 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -540,6 +540,18 @@ if (gotTheLock) { } }); + // Track if we're already quitting to avoid re-entrancy + let isQuitting = false; + app.on("before-quit", (event) => { + if (ipcMain && !isQuitting) { + isQuitting = true; + event.preventDefault(); + ipcMain.terminateAllBackgroundProcesses().finally(() => { + app.exit(0); + }); + } + }); + app.on("activate", () => { // Only create window if app is ready and no window exists // This prevents "Cannot create BrowserWindow before app is ready" error diff --git a/src/node/runtime/LocalBackgroundHandle.ts b/src/node/runtime/LocalBackgroundHandle.ts new file mode 100644 index 000000000..dae85bee9 --- /dev/null +++ b/src/node/runtime/LocalBackgroundHandle.ts @@ -0,0 +1,127 @@ +import type { BackgroundHandle } from "./Runtime"; +import type { DisposableProcess } from "@/node/utils/disposableExec"; +import { log } from "@/node/services/log"; + +/** + * Handle to a local background process. + * + * Buffers early events until callbacks are registered, since the manager + * registers callbacks after spawn() returns (but output may arrive before). + */ +export class LocalBackgroundHandle implements BackgroundHandle { + private stdoutCallback?: (line: string) => void; + private stderrCallback?: (line: string) => void; + private exitCallback?: (exitCode: number) => void; + private terminated = false; + + // Buffers for events that arrive before callbacks are registered + private pendingStdout: string[] = []; + private pendingStderr: string[] = []; + private pendingExitCode?: number; + + constructor(private readonly disposable: DisposableProcess) {} + + onStdout(callback: (line: string) => void): void { + this.stdoutCallback = callback; + // Flush buffered events + for (const line of this.pendingStdout) { + callback(line); + } + this.pendingStdout = []; + } + + onStderr(callback: (line: string) => void): void { + this.stderrCallback = callback; + // Flush buffered events + for (const line of this.pendingStderr) { + callback(line); + } + this.pendingStderr = []; + } + + onExit(callback: (exitCode: number) => void): void { + this.exitCallback = callback; + // Flush buffered event + if (this.pendingExitCode !== undefined) { + callback(this.pendingExitCode); + this.pendingExitCode = undefined; + } + } + + /** Internal: called when stdout line arrives */ + _emitStdout(line: string): void { + if (this.stdoutCallback) { + this.stdoutCallback(line); + } else { + this.pendingStdout.push(line); + } + } + + /** Internal: called when stderr line arrives */ + _emitStderr(line: string): void { + if (this.stderrCallback) { + this.stderrCallback(line); + } else { + this.pendingStderr.push(line); + } + } + + /** Internal: called when process exits */ + _emitExit(exitCode: number): void { + if (this.exitCallback) { + this.exitCallback(exitCode); + } else { + this.pendingExitCode = exitCode; + } + } + + isRunning(): Promise { + const child = this.disposable.underlying; + // Process is dead if either exitCode or signalCode is set + // (signal-killed processes have signalCode set but exitCode remains null) + return Promise.resolve(child.exitCode === null && child.signalCode === null); + } + + async terminate(): Promise { + if (this.terminated) return; + + const pid = this.disposable.underlying.pid; + if (pid === undefined) { + this.terminated = true; + return; + } + + try { + // Send SIGTERM to the process group for graceful shutdown + // Use negative PID to kill the entire process group (detached processes are group leaders) + const pgid = -pid; + log.debug(`LocalBackgroundHandle: Sending SIGTERM to process group (PGID: ${pgid})`); + process.kill(pgid, "SIGTERM"); + + // Wait 2 seconds for graceful shutdown + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Check if process is still running + if (await this.isRunning()) { + log.debug(`LocalBackgroundHandle: Process still running, sending SIGKILL`); + process.kill(pgid, "SIGKILL"); + } + } catch (error) { + // Process may already be dead - that's fine + log.debug( + `LocalBackgroundHandle: Error during terminate: ${error instanceof Error ? error.message : String(error)}` + ); + } + + this.terminated = true; + } + + dispose(): Promise { + return Promise.resolve(this.disposable[Symbol.dispose]()); + } + + /** Get the underlying child process (for spawn event waiting) */ + get child() { + return this.disposable.underlying; + } +} diff --git a/src/node/runtime/LocalRuntime.ts b/src/node/runtime/LocalRuntime.ts index 81012cd12..d299e7952 100644 --- a/src/node/runtime/LocalRuntime.ts +++ b/src/node/runtime/LocalRuntime.ts @@ -15,7 +15,10 @@ import type { WorkspaceForkParams, WorkspaceForkResult, InitLogger, + BackgroundSpawnOptions, + BackgroundSpawnResult, } from "./Runtime"; +import { LocalBackgroundHandle } from "./LocalBackgroundHandle"; import { RuntimeError as RuntimeErrorClass } from "./Runtime"; import { NON_INTERACTIVE_ENV_VARS } from "@/common/constants/env"; import { getBashPath } from "@/node/utils/main/bashPath"; @@ -30,6 +33,29 @@ import { import { execAsync, DisposableProcess } from "@/node/utils/disposableExec"; import { getProjectName } from "@/node/utils/runtime/helpers"; import { getErrorMessage } from "@/common/utils/errors"; +import { once } from "node:events"; +import { log } from "@/node/services/log"; + +/** + * Convert Node.js exit event args to a single exit code. + * When a process is killed by signal, Node provides code=null and signal name. + * This converts to Unix-conventional exit codes (128 + signal_number). + */ +function getExitCode(code: number | null, signal: NodeJS.Signals | null): number { + if (code !== null) return code; + if (signal) { + const signalNumbers: Record = { + SIGHUP: 1, + SIGINT: 2, + SIGQUIT: 3, + SIGKILL: 9, + SIGTERM: 15, + }; + return 128 + (signalNumbers[signal] ?? 0); + } + return 0; +} + import { expandTilde } from "./tildeExpansion"; /** @@ -179,6 +205,105 @@ export class LocalRuntime implements Runtime { return { stdout, stderr, stdin, exitCode, duration }; } + async spawnBackground( + script: string, + options: BackgroundSpawnOptions + ): Promise { + log.debug(`LocalRuntime.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}` }; + } + + // Build command with optional niceness + const isWindows = process.platform === "win32"; + const bashPath = getBashPath(); + const spawnCommand = options.niceness !== undefined && !isWindows ? "nice" : bashPath; + const spawnArgs = + options.niceness !== undefined && !isWindows + ? ["-n", options.niceness.toString(), bashPath, "-c", script] + : ["-c", script]; + + const childProcess = spawn(spawnCommand, spawnArgs, { + cwd: options.cwd, + env: { + ...process.env, + ...(options.env ?? {}), + ...NON_INTERACTIVE_ENV_VARS, + }, + stdio: ["pipe", "pipe", "pipe"], + detached: true, + }); + + const disposable = new DisposableProcess(childProcess); + + // Declare handle before setting up listeners + // eslint-disable-next-line prefer-const + let handle: LocalBackgroundHandle; + + // Set up line-buffered output streaming + // Use separate decoders per stream - TextDecoder with stream:true buffers + // partial multibyte sequences, so sharing would corrupt output when chunks interleave + const stdoutDecoder = new TextDecoder(); + const stderrDecoder = new TextDecoder(); + let stdoutBuffer = ""; + let stderrBuffer = ""; + + childProcess.stdout.on("data", (chunk: Buffer) => { + stdoutBuffer += stdoutDecoder.decode(chunk, { stream: true }); + const lines = stdoutBuffer.split("\n"); + stdoutBuffer = lines.pop() ?? ""; + for (const line of lines) { + handle._emitStdout(line); + } + }); + + childProcess.stderr.on("data", (chunk: Buffer) => { + stderrBuffer += stderrDecoder.decode(chunk, { stream: true }); + const lines = stderrBuffer.split("\n"); + stderrBuffer = lines.pop() ?? ""; + for (const line of lines) { + handle._emitStderr(line); + } + }); + + childProcess.on("exit", (code, signal) => { + // Flush decoder buffers (emits any incomplete multibyte sequences) + stdoutBuffer += stdoutDecoder.decode(); + stderrBuffer += stderrDecoder.decode(); + // Flush remaining partial lines + if (stdoutBuffer) handle._emitStdout(stdoutBuffer); + if (stderrBuffer) handle._emitStderr(stderrBuffer); + handle._emitExit(getExitCode(code, signal)); + }); + + handle = new LocalBackgroundHandle(disposable); + + // Wait for spawn or error + try { + await Promise.race([ + once(childProcess, "spawn"), + once(childProcess, "error").then(([err]) => { + throw err; + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("Spawn did not complete in time")), 2000) + ), + ]); + } catch (e) { + const err = e as Error; + log.debug(`LocalRuntime.spawnBackground: Failed to spawn: ${err.message}`); + await handle.dispose(); + return { success: false, error: err.message }; + } + + log.debug(`LocalRuntime.spawnBackground: Spawned with PID ${childProcess.pid ?? "unknown"}`); + return { success: true, handle }; + } + readFile(filePath: string, _abortSignal?: AbortSignal): ReadableStream { // Note: _abortSignal ignored for local operations (fast, no need for cancellation) const nodeStream = fs.createReadStream(filePath); diff --git a/src/node/runtime/Runtime.ts b/src/node/runtime/Runtime.ts index 4e01a0ceb..a75ecfe03 100644 --- a/src/node/runtime/Runtime.ts +++ b/src/node/runtime/Runtime.ts @@ -64,6 +64,64 @@ export interface ExecOptions { forcePTY?: boolean; } +/** + * Options for spawning a background process + */ +export interface BackgroundSpawnOptions { + /** Working directory for command execution */ + cwd: 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. + */ +export interface BackgroundHandle { + /** + * Register callback for stdout lines. + * For local: called in real-time as output arrives. + * For SSH: called when output is polled/read. + */ + onStdout(callback: (line: string) => void): void; + + /** + * Register callback for stderr lines. + */ + onStderr(callback: (line: string) => void): void; + + /** + * Register callback for process exit. + * @param callback Receives exit code (128+signal for signal termination) + */ + onExit(callback: (exitCode: number) => void): void; + + /** + * Check if process is still running. + */ + isRunning(): Promise; + + /** + * Terminate the process (SIGTERM → wait → SIGKILL). + */ + terminate(): Promise; + + /** + * Clean up resources (called after process exits or on error). + */ + dispose(): Promise; +} + +/** + * Result of spawning a background process + */ +export type BackgroundSpawnResult = + | { success: true; handle: BackgroundHandle } + | { success: false; error: string }; + /** * Streaming result from executing a command */ @@ -211,6 +269,17 @@ 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. + * + * @param script Bash script to execute + * @param options Execution options (cwd, 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/SSHRuntime.ts b/src/node/runtime/SSHRuntime.ts index 22588e873..f6e027e18 100644 --- a/src/node/runtime/SSHRuntime.ts +++ b/src/node/runtime/SSHRuntime.ts @@ -13,6 +13,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"; @@ -247,6 +249,18 @@ export class SSHRuntime implements Runtime { return { stdout, stderr, stdin, exitCode, duration }; } + spawnBackground( + _script: string, + _options: BackgroundSpawnOptions + ): Promise { + // SSH background execution not yet implemented + // Would require: remote process management, output polling, etc. + return Promise.resolve({ + success: false, + error: "Background process execution is not supported for SSH workspaces", + }); + } + /** * Read file contents over SSH as a stream */ diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 6cb748f91..8aee553d6 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -20,6 +20,7 @@ import { getToolsForModel } from "@/common/utils/tools/tools"; import { createRuntime } from "@/node/runtime/runtimeFactory"; 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, @@ -141,12 +142,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 @@ -156,6 +159,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(); @@ -715,9 +719,11 @@ export class AIService extends EventEmitter { } // Get workspace path - handle both worktree and in-place modes - const runtime = createRuntime( - metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir } - ); + const runtimeConfig = metadata.runtimeConfig ?? { + type: "local" as const, + srcBaseDir: this.config.srcDir, + }; + const runtime = createRuntime(runtimeConfig); // In-place workspaces (CLI/benchmarks) have projectPath === name // Use path directly instead of reconstructing via getWorkspacePath const isInPlace = metadata.projectPath === metadata.name; @@ -762,6 +768,8 @@ export class AIService extends EventEmitter { runtime, secrets: secretsToRecord(projectSecrets), 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 000000000..7dbf25a50 --- /dev/null +++ b/src/node/services/backgroundProcessManager.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, beforeEach } from "bun:test"; +import { BackgroundProcessManager } from "./backgroundProcessManager"; +import { LocalRuntime } from "@/node/runtime/LocalRuntime"; +import type { Runtime } from "@/node/runtime/Runtime"; + +// Create test runtime (uses local machine) +function createTestRuntime(): Runtime { + return new LocalRuntime(process.cwd()); +} + +describe("BackgroundProcessManager", () => { + let manager: BackgroundProcessManager; + let runtime: Runtime; + const testWorkspaceId = "workspace-1"; + const testWorkspaceId2 = "workspace-2"; + + beforeEach(() => { + manager = new BackgroundProcessManager(); + runtime = createTestRuntime(); + }); + + describe("spawn", () => { + it("should spawn a background process and return process ID", 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-/); + } + }); + + 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 capture stdout and stderr", 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 captured + await new Promise((resolve) => setTimeout(resolve, 100)); + + const process = manager.getProcess(result.processId); + expect(process).not.toBeNull(); + expect(process?.stdoutBuffer.toArray()).toContain("hello"); + expect(process?.stderrBuffer.toArray()).toContain("world"); + } + }); + + it("should handle ring buffer overflow", async () => { + // Generate more than 1000 lines + const script = Array(1100) + .fill(0) + .map((_, i) => `echo line${i}`) + .join("; "); + + const result = await manager.spawn(runtime, testWorkspaceId, script, { + cwd: process.cwd(), + }); + + expect(result.success).toBe(true); + if (result.success) { + await new Promise((resolve) => setTimeout(resolve, 500)); + + const process = manager.getProcess(result.processId); + expect(process).not.toBeNull(); + // Buffer should be capped at 1000 lines + expect(process!.stdoutBuffer.length).toBeLessThanOrEqual(1000); + } + }); + }); + + 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 process = manager.getProcess(spawnResult.processId); + expect(process).not.toBeNull(); + expect(process?.id).toBe(spawnResult.processId); + expect(process?.status).toBe("running"); + } + }); + + it("should return null for non-existent process", () => { + const process = manager.getProcess("bg-nonexistent"); + expect(process).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 = 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 = manager.list(testWorkspaceId); + const ws2Processes = 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 process = manager.getProcess(spawnResult.processId); + expect(process?.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", 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 = manager.list(testWorkspaceId); + const ws2Processes = 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("process state tracking", () => { + it("should track process exit", 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 process = manager.getProcess(result.processId); + expect(process?.status).toBe("exited"); + expect(process?.exitCode).toBe(42); + expect(process?.exitTime).not.toBeNull(); + } + }); + + it("should keep buffer 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 process = manager.getProcess(result.processId); + expect(process?.status).toBe("exited"); + expect(process?.stdoutBuffer.toArray()).toContain("test"); + } + }); + + it("should preserve killed status after onExit callback fires", 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); + + // Wait for onExit callback to fire + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Status should still be "killed", not "exited" + const proc = 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); + + // Wait for exit to be recorded + await new Promise((resolve) => setTimeout(resolve, 100)); + + const proc = 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); + } + }); + }); +}); diff --git a/src/node/services/backgroundProcessManager.ts b/src/node/services/backgroundProcessManager.ts new file mode 100644 index 000000000..ff1cbc071 --- /dev/null +++ b/src/node/services/backgroundProcessManager.ts @@ -0,0 +1,209 @@ +import type { Runtime, BackgroundHandle } from "@/node/runtime/Runtime"; +import { log } from "./log"; +import { randomBytes } from "crypto"; +import { CircularBuffer } from "./circularBuffer"; + +/** + * Represents a background process with buffered output + */ +export interface BackgroundProcess { + id: string; // Short unique ID (e.g., "bg-abc123") + workspaceId: string; // Owning workspace + script: string; // Original command + startTime: number; // Timestamp when started + stdoutBuffer: CircularBuffer; // Circular buffer (max 1000 lines) + stderrBuffer: CircularBuffer; // Circular buffer (max 1000 lines) + exitCode?: number; // Undefined if still running + exitTime?: number; // Timestamp when exited (undefined if running) + status: "running" | "exited" | "killed" | "failed"; + handle: BackgroundHandle | null; // For process interaction +} + +const MAX_BUFFER_LINES = 1000; + +/** + * 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 { + 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 } + ): Promise<{ success: true; processId: string } | { success: false; error: string }> { + log.debug(`BackgroundProcessManager.spawn() called for workspace ${workspaceId}`); + + // Generate unique process ID + const processId = `bg-${randomBytes(4).toString("hex")}`; + + // Create circular buffers for output + const stdoutBuffer = new CircularBuffer(MAX_BUFFER_LINES); + const stderrBuffer = new CircularBuffer(MAX_BUFFER_LINES); + + const proc: BackgroundProcess = { + id: processId, + workspaceId, + script, + startTime: Date.now(), + stdoutBuffer, + stderrBuffer, + status: "running", + handle: null, + }; + + // Spawn via runtime + const result = await runtime.spawnBackground(script, { + cwd: config.cwd, + 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 = result.handle; + + // Wire up callbacks to buffers + handle.onStdout((line: string) => { + stdoutBuffer.push(line); + }); + + handle.onStderr((line: string) => { + stderrBuffer.push(line); + }); + + handle.onExit((exitCode: number) => { + log.debug(`Background process ${processId} exited with code ${exitCode}`); + proc.exitCode = exitCode; + proc.exitTime ??= Date.now(); + // Don't overwrite status if already marked as killed/failed by terminate() + if (proc.status === "running") { + proc.status = "exited"; + } + }); + + proc.handle = handle; + + // Store process in map + this.processes.set(processId, proc); + + log.debug(`Background process ${processId} spawned successfully`); + return { success: true, processId }; + } + + /** + * Get a background process by ID + */ + getProcess(processId: string): BackgroundProcess | null { + log.debug(`BackgroundProcessManager.getProcess(${processId}) called`); + return this.processes.get(processId) ?? null; + } + + /** + * List all background processes, optionally filtered by workspace + */ + list(workspaceId?: string): BackgroundProcess[] { + log.debug(`BackgroundProcessManager.list(${workspaceId ?? "all"}) called`); + const allProcesses = Array.from(this.processes.values()); + return workspaceId ? allProcesses.filter((p) => p.workspaceId === workspaceId) : allProcesses; + } + + /** + * 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 }; + } + + // Check if we have a valid handle + if (!proc.handle) { + log.debug(`Process ${processId} has no handle, marking as failed`); + proc.status = "failed"; + proc.exitTime = Date.now(); + return { success: true }; + } + + try { + await proc.handle.terminate(); + + // Update process status + proc.status = "killed"; + proc.exitTime ??= Date.now(); + + // 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(); + // Ensure handle is cleaned up even on error + if (proc.handle) { + await proc.handle.dispose(); + } + return { success: true }; + } + } + + /** + * Clean up all processes for a workspace. + * Terminates running processes and removes them from memory. + */ + 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 all processes from memory + matching.forEach((p) => this.processes.delete(p.id)); + + log.debug(`Cleaned up ${matching.length} process(es) for workspace ${workspaceId}`); + } + + /** + * Terminate all background processes across all workspaces. + * Called on 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)`); + } +} diff --git a/src/node/services/bashExecutionService.ts b/src/node/services/bashExecutionService.ts index 9f46c455c..f7be41115 100644 --- a/src/node/services/bashExecutionService.ts +++ b/src/node/services/bashExecutionService.ts @@ -171,8 +171,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); @@ -180,7 +182,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/circularBuffer.test.ts b/src/node/services/circularBuffer.test.ts new file mode 100644 index 000000000..3a6713947 --- /dev/null +++ b/src/node/services/circularBuffer.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "bun:test"; +import { CircularBuffer } from "./circularBuffer"; + +describe("CircularBuffer", () => { + it("should store items up to capacity", () => { + const buffer = new CircularBuffer(3); + buffer.push(1); + buffer.push(2); + buffer.push(3); + + expect(buffer.length).toBe(3); + expect(buffer.toArray()).toEqual([1, 2, 3]); + }); + + it("should overwrite oldest items when full", () => { + const buffer = new CircularBuffer(3); + buffer.push(1); + buffer.push(2); + buffer.push(3); + buffer.push(4); // Should evict 1 + buffer.push(5); // Should evict 2 + + expect(buffer.length).toBe(3); + expect(buffer.toArray()).toEqual([3, 4, 5]); + }); + + it("should handle many overwrites efficiently", () => { + const buffer = new CircularBuffer(5); + + // Add 100 items, only last 5 should remain + for (let i = 1; i <= 100; i++) { + buffer.push(i); + } + + expect(buffer.length).toBe(5); + expect(buffer.toArray()).toEqual([96, 97, 98, 99, 100]); + }); + + it("should handle empty buffer", () => { + const buffer = new CircularBuffer(10); + + expect(buffer.length).toBe(0); + expect(buffer.isEmpty()).toBe(true); + expect(buffer.isFull()).toBe(false); + expect(buffer.toArray()).toEqual([]); + }); + + it("should detect full state", () => { + const buffer = new CircularBuffer(2); + + expect(buffer.isFull()).toBe(false); + buffer.push(1); + expect(buffer.isFull()).toBe(false); + buffer.push(2); + expect(buffer.isFull()).toBe(true); + buffer.push(3); + expect(buffer.isFull()).toBe(true); + }); + + it("should clear all items", () => { + const buffer = new CircularBuffer(3); + buffer.push(1); + buffer.push(2); + buffer.push(3); + + buffer.clear(); + + expect(buffer.length).toBe(0); + expect(buffer.isEmpty()).toBe(true); + expect(buffer.toArray()).toEqual([]); + }); + + it("should work with strings (real use case)", () => { + const buffer = new CircularBuffer(1000); + + // Simulate process output + for (let i = 1; i <= 1500; i++) { + buffer.push(`line ${i}`); + } + + expect(buffer.length).toBe(1000); + const lines = buffer.toArray(); + expect(lines[0]).toBe("line 501"); + expect(lines[999]).toBe("line 1500"); + }); +}); diff --git a/src/node/services/circularBuffer.ts b/src/node/services/circularBuffer.ts new file mode 100644 index 000000000..168aa6524 --- /dev/null +++ b/src/node/services/circularBuffer.ts @@ -0,0 +1,77 @@ +/** + * Efficient circular buffer with fixed capacity + * Avoids O(n) array operations when evicting old items + */ +export class CircularBuffer { + private buffer: T[]; + private head = 0; // Index of oldest item + private tail = 0; // Index where next item will be written + private size = 0; // Current number of items + + constructor(private readonly capacity: number) { + this.buffer = new Array(capacity); + } + + /** + * Add an item to the buffer + * If buffer is full, oldest item is overwritten + */ + push(item: T): void { + this.buffer[this.tail] = item; + this.tail = (this.tail + 1) % this.capacity; + + if (this.size < this.capacity) { + this.size++; + } else { + // Buffer is full, head moves forward (oldest item overwritten) + this.head = (this.head + 1) % this.capacity; + } + } + + /** + * Get all items in order (oldest to newest) + */ + toArray(): T[] { + if (this.size === 0) { + return []; + } + + const result: T[] = []; + let idx = this.head; + for (let i = 0; i < this.size; i++) { + result.push(this.buffer[idx]); + idx = (idx + 1) % this.capacity; + } + return result; + } + + /** + * Get number of items currently stored + */ + get length(): number { + return this.size; + } + + /** + * Check if buffer is empty + */ + isEmpty(): boolean { + return this.size === 0; + } + + /** + * Check if buffer is at capacity + */ + isFull(): boolean { + return this.size === this.capacity; + } + + /** + * Clear all items + */ + clear(): void { + this.head = 0; + this.tail = 0; + this.size = 0; + } +} diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index 0700c49f2..e4df00e8f 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -38,6 +38,7 @@ import { DisposableTempDir } from "@/node/services/tempDir"; import { InitStateManager } from "@/node/services/initStateManager"; import { createRuntime } from "@/node/runtime/runtimeFactory"; import type { RuntimeConfig } from "@/common/types/runtime"; +import { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import { isSSHRuntime } from "@/common/types/runtime"; import { validateProjectPath } from "@/node/utils/pathUtils"; import { PTYService } from "@/node/services/ptyService"; @@ -66,6 +67,7 @@ export class IpcMain { private readonly initStateManager: InitStateManager; private readonly extensionMetadata: ExtensionMetadataService; private readonly ptyService: PTYService; + private readonly backgroundProcessManager: BackgroundProcessManager; private terminalWindowManager?: TerminalWindowManager; private readonly sessions = new Map(); private projectDirectoryPicker?: (event: IpcMainInvokeEvent) => Promise; @@ -86,11 +88,14 @@ export class IpcMain { 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 ); // Terminal services - PTYService is cross-platform this.ptyService = new PTYService(); @@ -107,6 +112,13 @@ export class IpcMain { await this.extensionMetadata.initialize(); } + /** + * Terminate all background processes. Called on app shutdown. + */ + async terminateAllBackgroundProcesses(): Promise { + await this.backgroundProcessManager.terminateAll(); + } + /** * Configure a picker used to select project directories (desktop mode only). * Server mode does not provide a native directory picker. @@ -1305,6 +1317,7 @@ export class IpcMain { niceness: options?.niceness, runtimeTempDir: tempDir.path, overflow_policy: "truncate", + workspaceId, }); // Execute the script with provided options @@ -1408,6 +1421,9 @@ export class IpcMain { metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir } ); + // Clean up background processes before deleting workspace + await this.backgroundProcessManager.cleanup(workspaceId); + // Delegate deletion to runtime - it handles all path computation, existence checks, and pruning const deleteResult = await runtime.deleteWorkspace(projectPath, metadata.name, options.force); diff --git a/src/node/services/tools/bash.test.ts b/src/node/services/tools/bash.test.ts index b2c95103f..8f6239c18 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 @@ -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 @@ -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 @@ -274,6 +289,7 @@ describe("bash tool", () => { }); const args: BashToolArgs = { + run_in_background: false, script: "for i in {1..400}; do echo line$i; done", timeout_secs: 5, }; @@ -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, }; @@ -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, }; @@ -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; @@ -448,6 +467,7 @@ describe("bash tool", () => { // 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, }; @@ -490,6 +510,7 @@ describe("bash tool", () => { // 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, }; @@ -520,6 +541,7 @@ describe("bash tool", () => { // 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 -a "sleep-${token}" sleep 100 `, 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 @@ -1264,6 +1311,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; @@ -1285,6 +1333,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; @@ -1298,3 +1347,76 @@ 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(process.cwd()); + config.runtimeTempDir = tempDir.path; + config.backgroundProcessManager = manager; + config.workspaceId = "test-workspace"; + // config.runtime is already set by createTestToolConfig + + 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(process.cwd()); + config.runtimeTempDir = tempDir.path; + config.backgroundProcessManager = manager; + config.workspaceId = "test-workspace"; + // config.runtime is already set by createTestToolConfig + + 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 c0559a86d..9cef5731c 100644 --- a/src/node/services/tools/bash.ts +++ b/src/node/services/tools/bash.ts @@ -229,11 +229,75 @@ 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 }, + { abortSignal } + ): Promise => { // Validate script input const validationError = validateScript(script, config); if (validationError) return validationError; + // Handle background execution + if (run_in_background) { + // TODO: Add Windows support for background processes (process groups work differently) + if (process.platform === "win32") { + return { + success: false, + error: "Background execution is not yet supported on Windows", + exitCode: -1, + wall_duration_ms: 0, + }; + } + + 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, + } + ); + + if (!spawnResult.success) { + return { + success: false, + error: spawnResult.error, + exitCode: -1, + wall_duration_ms: Math.round(performance.now() - startTime), + }; + } + + return { + success: true, + output: `Background process started with ID: ${spawnResult.processId}`, + exitCode: 0, + wall_duration_ms: Math.round(performance.now() - startTime), + backgroundProcessId: spawnResult.processId, + }; + } + // 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 000000000..8a681d314 --- /dev/null +++ b/src/node/services/tools/bash_background_list.test.ts @@ -0,0 +1,141 @@ +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 (uses local machine) +function createTestRuntime(): Runtime { + return new LocalRuntime(process.cwd()); +} + +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 runtime = createTestRuntime(); + const tempDir = new TestTempDir("test-bash-bg-list"); + const config = createTestToolConfig(process.cwd()); + 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(); + } + + // Cleanup + await manager.terminate(spawnResult.processId); + tempDir[Symbol.dispose](); + }); + + it("should only list processes for the current workspace", async () => { + const manager = new BackgroundProcessManager(); + const runtime = createTestRuntime(); + + const tempDir = new TestTempDir("test-bash-bg-list"); + const config = createTestToolConfig(process.cwd(), { workspaceId: "workspace-a" }); + 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.terminate(spawnA.processId); + await manager.terminate(spawnB.processId); + 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 000000000..491ce1180 --- /dev/null +++ b/src/node/services/tools/bash_background_list.ts @@ -0,0 +1,43 @@ +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: (): BashBackgroundListResult => { + 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 = 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, + })), + }; + }, + }); +}; diff --git a/src/node/services/tools/bash_background_read.test.ts b/src/node/services/tools/bash_background_read.test.ts new file mode 100644 index 000000000..50a19c357 --- /dev/null +++ b/src/node/services/tools/bash_background_read.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect } from "bun:test"; +import { createBashBackgroundReadTool } from "./bash_background_read"; +import { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; +import { LocalRuntime } from "@/node/runtime/LocalRuntime"; +import type { Runtime } from "@/node/runtime/Runtime"; +import type { BashBackgroundReadArgs, BashBackgroundReadResult } 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 (uses local machine) +function createTestRuntime(): Runtime { + return new LocalRuntime(process.cwd()); +} + +describe("bash_background_read tool", () => { + it("should return error when manager not available", async () => { + const tempDir = new TestTempDir("test-bash-bg-read"); + const config = createTestToolConfig(process.cwd()); + config.runtimeTempDir = tempDir.path; + + const tool = createBashBackgroundReadTool(config); + const args: BashBackgroundReadArgs = { + process_id: "bg-test", + }; + + const result = (await tool.execute!(args, mockToolCallOptions)) as BashBackgroundReadResult; + + 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-read"); + const config = createTestToolConfig(process.cwd()); + config.runtimeTempDir = tempDir.path; + config.backgroundProcessManager = manager; + + const tool = createBashBackgroundReadTool(config); + const args: BashBackgroundReadArgs = { + process_id: "bg-nonexistent", + }; + + const result = (await tool.execute!(args, mockToolCallOptions)) as BashBackgroundReadResult; + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("Process not found"); + } + + tempDir[Symbol.dispose](); + }); + + it("should return process status and output", async () => { + const manager = new BackgroundProcessManager(); + const runtime = createTestRuntime(); + const tempDir = new TestTempDir("test-bash-bg-read"); + const config = createTestToolConfig(process.cwd()); + config.runtimeTempDir = tempDir.path; + config.backgroundProcessManager = manager; + + // Spawn a process + const spawnResult = await manager.spawn(runtime, "test-workspace", "echo hello; sleep 1", { + cwd: process.cwd(), + }); + + if (!spawnResult.success) { + throw new Error("Failed to spawn process"); + } + + // Wait for output + await new Promise((resolve) => setTimeout(resolve, 100)); + + const tool = createBashBackgroundReadTool(config); + const args: BashBackgroundReadArgs = { + process_id: spawnResult.processId, + }; + + const result = (await tool.execute!(args, mockToolCallOptions)) as BashBackgroundReadResult; + + expect(result.success).toBe(true); + if (result.success) { + expect(result.process_id).toBe(spawnResult.processId); + expect(result.status).toBe("running"); + expect(result.stdout).toContain("hello"); + expect(result.uptime_ms).toBeGreaterThan(0); + } + + tempDir[Symbol.dispose](); + }); + + it("should handle tail filtering", async () => { + const manager = new BackgroundProcessManager(); + const runtime = createTestRuntime(); + const tempDir = new TestTempDir("test-bash-bg-read"); + const config = createTestToolConfig(process.cwd()); + config.runtimeTempDir = tempDir.path; + config.backgroundProcessManager = manager; + + // Spawn a process with multiple lines + const spawnResult = await manager.spawn( + runtime, + "test-workspace", + "echo line1; echo line2; echo line3", + { + cwd: process.cwd(), + } + ); + + if (!spawnResult.success) { + throw new Error("Failed to spawn process"); + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const tool = createBashBackgroundReadTool(config); + const args: BashBackgroundReadArgs = { + process_id: spawnResult.processId, + stdout_tail: 2, + }; + + const result = (await tool.execute!(args, mockToolCallOptions)) as BashBackgroundReadResult; + + expect(result.success).toBe(true); + if (result.success) { + expect(result.stdout.length).toBeLessThanOrEqual(2); + } + + tempDir[Symbol.dispose](); + }); + + it("should handle regex filtering", async () => { + const manager = new BackgroundProcessManager(); + const runtime = createTestRuntime(); + const tempDir = new TestTempDir("test-bash-bg-read"); + const config = createTestToolConfig(process.cwd()); + config.runtimeTempDir = tempDir.path; + config.backgroundProcessManager = manager; + + const spawnResult = await manager.spawn( + runtime, + "test-workspace", + "echo ERROR: test; echo INFO: test", + { cwd: process.cwd() } + ); + + if (!spawnResult.success) { + throw new Error("Failed to spawn process"); + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const tool = createBashBackgroundReadTool(config); + const args: BashBackgroundReadArgs = { + process_id: spawnResult.processId, + stdout_regex: "ERROR", + }; + + const result = (await tool.execute!(args, mockToolCallOptions)) as BashBackgroundReadResult; + + expect(result.success).toBe(true); + if (result.success) { + expect(result.stdout.every((line) => line.includes("ERROR"))).toBe(true); + } + + tempDir[Symbol.dispose](); + }); + + it("should return error for invalid regex pattern", async () => { + const manager = new BackgroundProcessManager(); + const runtime = createTestRuntime(); + const tempDir = new TestTempDir("test-bash-bg-read"); + const config = createTestToolConfig(process.cwd()); + config.runtimeTempDir = tempDir.path; + config.backgroundProcessManager = manager; + + const spawnResult = await manager.spawn(runtime, "test-workspace", "echo test", { + cwd: process.cwd(), + }); + + if (!spawnResult.success) { + throw new Error("Failed to spawn process"); + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const tool = createBashBackgroundReadTool(config); + const args: BashBackgroundReadArgs = { + process_id: spawnResult.processId, + stdout_regex: "[invalid(", // Invalid regex pattern + }; + + const result = (await tool.execute!(args, mockToolCallOptions)) as BashBackgroundReadResult; + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("Invalid regex pattern"); + expect(result.error).toContain("stdout_regex"); + } + + tempDir[Symbol.dispose](); + }); + + it("should not read processes from other workspaces", async () => { + const manager = new BackgroundProcessManager(); + const runtime = createTestRuntime(); + + const tempDir = new TestTempDir("test-bash-bg-read"); + // Config is for workspace-a + const config = createTestToolConfig(process.cwd(), { workspaceId: "workspace-a" }); + config.runtimeTempDir = tempDir.path; + config.backgroundProcessManager = manager; + + // Spawn process in workspace-b + const spawnResult = await manager.spawn(runtime, "workspace-b", "echo secret", { + cwd: process.cwd(), + }); + + if (!spawnResult.success) { + throw new Error("Failed to spawn process"); + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Try to read from workspace-a (should fail) + const tool = createBashBackgroundReadTool(config); + const args: BashBackgroundReadArgs = { + process_id: spawnResult.processId, + }; + + const result = (await tool.execute!(args, mockToolCallOptions)) as BashBackgroundReadResult; + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("Process not found"); + } + + tempDir[Symbol.dispose](); + }); +}); diff --git a/src/node/services/tools/bash_background_read.ts b/src/node/services/tools/bash_background_read.ts new file mode 100644 index 000000000..872ed69ad --- /dev/null +++ b/src/node/services/tools/bash_background_read.ts @@ -0,0 +1,97 @@ +import { tool } from "ai"; +import type { BashBackgroundReadResult } from "@/common/types/tools"; +import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; +import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; + +/** + * Tool for reading status and output from background processes + */ +export const createBashBackgroundReadTool: ToolFactory = (config: ToolConfiguration) => { + return tool({ + description: TOOL_DEFINITIONS.bash_background_read.description, + inputSchema: TOOL_DEFINITIONS.bash_background_read.schema, + execute: ({ + process_id, + stdout_tail, + stderr_tail, + stdout_regex, + stderr_regex, + }): BashBackgroundReadResult => { + if (!config.backgroundProcessManager) { + return { + success: false, + error: "Background process manager not available", + }; + } + + if (!config.workspaceId) { + return { + success: false, + error: "Workspace ID not available", + }; + } + + // Get process from manager and verify workspace ownership + const process = config.backgroundProcessManager.getProcess(process_id); + if (!process || process.workspaceId !== config.workspaceId) { + return { + success: false, + error: `Process not found: ${process_id}`, + }; + } + + // Apply filtering (regex first, then tail) + let stdout = process.stdoutBuffer.toArray(); + let stderr = process.stderrBuffer.toArray(); + + // Apply regex filters first + if (stdout_regex) { + try { + const regex = new RegExp(stdout_regex); + stdout = stdout.filter((line) => regex.test(line)); + } catch (error) { + return { + success: false, + error: `Invalid regex pattern for stdout_regex: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + if (stderr_regex) { + try { + const regex = new RegExp(stderr_regex); + stderr = stderr.filter((line) => regex.test(line)); + } catch (error) { + return { + success: false, + error: `Invalid regex pattern for stderr_regex: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + // Apply tail filters after regex + if (stdout_tail !== undefined) { + stdout = stdout.slice(-stdout_tail); + } + if (stderr_tail !== undefined) { + stderr = stderr.slice(-stderr_tail); + } + + // Compute uptime + const uptime_ms = + process.exitTime !== undefined + ? process.exitTime - process.startTime + : Date.now() - process.startTime; + + return { + success: true, + process_id: process.id, + status: process.status, + script: process.script, + uptime_ms, + exitCode: process.exitCode, + stdout, + stderr, + }; + }, + }); +}; 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 000000000..06f9ae65d --- /dev/null +++ b/src/node/services/tools/bash_background_terminate.test.ts @@ -0,0 +1,188 @@ +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 (uses local machine) +function createTestRuntime(): Runtime { + return new LocalRuntime(process.cwd()); +} + +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 runtime = createTestRuntime(); + const tempDir = new TestTempDir("test-bash-bg-term"); + const config = createTestToolConfig(process.cwd()); + 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 = manager.getProcess(spawnResult.processId); + expect(bgProcess?.status).not.toBe("running"); + + tempDir[Symbol.dispose](); + }); + + it("should be idempotent (double-terminate succeeds)", async () => { + const manager = new BackgroundProcessManager(); + const runtime = createTestRuntime(); + const tempDir = new TestTempDir("test-bash-bg-term"); + const config = createTestToolConfig(process.cwd()); + 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); + + tempDir[Symbol.dispose](); + }); + + it("should not terminate processes from other workspaces", async () => { + const manager = new BackgroundProcessManager(); + const runtime = createTestRuntime(); + + const tempDir = new TestTempDir("test-bash-bg-term"); + // Config is for workspace-a + const config = createTestToolConfig(process.cwd(), { workspaceId: "workspace-a" }); + 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 = manager.getProcess(spawnResult.processId); + expect(proc?.status).toBe("running"); + + // Cleanup + await manager.terminate(spawnResult.processId); + 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 000000000..ddb343e84 --- /dev/null +++ b/src/node/services/tools/bash_background_terminate.ts @@ -0,0 +1,48 @@ +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 = 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`, + }; + } + + return result; + }, + }); +}; diff --git a/src/node/services/tools/status_set.test.ts b/src/node/services/tools/status_set.test.ts index 27c0c161d..05c176940 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 77beb749e..a753c4f0b 100644 --- a/src/node/services/tools/testHelpers.ts +++ b/src/node/services/tools/testHelpers.ts @@ -50,13 +50,14 @@ function getTestInitStateManager(): InitStateManager { */ export function createTestToolConfig( tempDir: string, - options?: { niceness?: number } + options?: { niceness?: number; workspaceId?: string } ): ToolConfiguration { return { cwd: tempDir, runtime: new LocalRuntime(tempDir), runtimeTempDir: tempDir, niceness: options?.niceness, + workspaceId: options?.workspaceId ?? "test-workspace", }; }