From e06469a31c6fe043301a190292ba1a31c7b36e2d Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 13 Oct 2025 05:54:28 +0000 Subject: [PATCH] fix: handle daemon processes without blocking UI - Add daemon process detection utility with common service patterns - Modify executeCommandTool to detect and handle daemons as background processes - Add configuration options for custom daemon patterns - Add comprehensive tests for daemon detection logic Fixes #8636 --- src/core/tools/executeCommandTool.ts | 150 ++++++++++--- src/package.json | 13 ++ src/package.nls.json | 2 + src/utils/__tests__/daemon-detector.spec.ts | 227 ++++++++++++++++++++ src/utils/daemon-detector.ts | 166 ++++++++++++++ 5 files changed, 524 insertions(+), 34 deletions(-) create mode 100644 src/utils/__tests__/daemon-detector.spec.ts create mode 100644 src/utils/daemon-detector.ts diff --git a/src/core/tools/executeCommandTool.ts b/src/core/tools/executeCommandTool.ts index 2c7ce0d023e2..a7ca1adc62b5 100644 --- a/src/core/tools/executeCommandTool.ts +++ b/src/core/tools/executeCommandTool.ts @@ -17,6 +17,13 @@ import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" import { Terminal } from "../../integrations/terminal/Terminal" import { Package } from "../../shared/package" import { t } from "../../i18n" +import { + isDaemonCommand, + getDaemonMessage, + getServiceType, + addDaemonPatterns, + clearUserDaemonPatterns, +} from "../../utils/daemon-detector" class ShellIntegrationError extends Error {} @@ -173,6 +180,24 @@ export async function executeCommand( return [false, `Working directory '${workingDir}' does not exist.`] } + // Check configuration for daemon detection + const disableDaemonDetection = vscode.workspace + .getConfiguration(Package.name) + .get("disableDaemonDetection", false) + + // Load user-defined daemon patterns from configuration + if (!disableDaemonDetection) { + clearUserDaemonPatterns() + const userDaemonPatterns = vscode.workspace.getConfiguration(Package.name).get("daemonCommands", []) + if (userDaemonPatterns.length > 0) { + addDaemonPatterns(userDaemonPatterns) + } + } + + // Check if this is a daemon/long-running process + const isDaemon = !disableDaemonDetection && isDaemonCommand(command) + const serviceType = isDaemon ? getServiceType(command) : null + let message: { text?: string; images?: string[] } | undefined let runInBackground = false let completed = false @@ -252,47 +277,104 @@ export async function executeCommand( const process = terminal.runCommand(command, callbacks) task.terminalProcess = process - // Implement command execution timeout (skip if timeout is 0). - if (commandExecutionTimeout > 0) { - let timeoutId: NodeJS.Timeout | undefined - let isTimedOut = false - - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - isTimedOut = true - task.terminalProcess?.abort() - reject(new Error(`Command execution timed out after ${commandExecutionTimeout}ms`)) - }, commandExecutionTimeout) + // If this is a daemon process, handle it differently + if (isDaemon) { + // Wait for a short time to capture initial output and check for startup errors + const initialOutputTimeout = 3000 // 3 seconds to capture initial output + let initialOutputCaptured = false + + const initialOutputPromise = new Promise((resolve) => { + setTimeout(() => { + initialOutputCaptured = true + resolve() + }, initialOutputTimeout) }) - try { - await Promise.race([process, timeoutPromise]) - } catch (error) { - if (isTimedOut) { - const status: CommandExecutionStatus = { executionId, status: "timeout" } - provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) - await task.say("error", t("common:errors:command_timeout", { seconds: commandExecutionTimeoutSeconds })) - task.terminalProcess = undefined - - return [ - false, - `The command was terminated after exceeding a user-configured ${commandExecutionTimeoutSeconds}s timeout. Do not try to re-run the command.`, - ] - } - throw error - } finally { - if (timeoutId) { - clearTimeout(timeoutId) + // Also resolve if the process completes early (e.g., startup error) + const processCompletePromise = new Promise((resolve) => { + const checkComplete = () => { + if (completed) { + resolve() + } } + // Check periodically if process completed + const interval = setInterval(() => { + checkComplete() + if (completed || initialOutputCaptured) { + clearInterval(interval) + } + }, 100) + }) + + // Wait for either initial output timeout or process completion + await Promise.race([initialOutputPromise, processCompletePromise]) + // If the process completed quickly, it likely failed to start + if (completed) { + // Handle as normal - the process failed task.terminalProcess = undefined + } else { + // Process is still running - it's a daemon + task.terminalProcess = undefined // Clear reference but don't abort + + const daemonMessage = getDaemonMessage(command) + await task.say("text", daemonMessage) + + return [ + false, + `Started ${serviceType} in the background from '${terminal.getCurrentWorkingDirectory().toPosix()}'.\n` + + `The service is running and you can proceed with other tasks.\n` + + `Current output:\n${result}\n` + + `The terminal will continue to show output from this service.`, + ] } } else { - // No timeout - just wait for the process to complete. - try { - await process - } finally { - task.terminalProcess = undefined + // Normal command execution with timeout handling + // Implement command execution timeout (skip if timeout is 0). + if (commandExecutionTimeout > 0) { + let timeoutId: NodeJS.Timeout | undefined + let isTimedOut = false + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + isTimedOut = true + task.terminalProcess?.abort() + reject(new Error(`Command execution timed out after ${commandExecutionTimeout}ms`)) + }, commandExecutionTimeout) + }) + + try { + await Promise.race([process, timeoutPromise]) + } catch (error) { + if (isTimedOut) { + const status: CommandExecutionStatus = { executionId, status: "timeout" } + provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) + await task.say( + "error", + t("common:errors:command_timeout", { seconds: commandExecutionTimeoutSeconds }), + ) + task.terminalProcess = undefined + + return [ + false, + `The command was terminated after exceeding a user-configured ${commandExecutionTimeoutSeconds}s timeout. Do not try to re-run the command.`, + ] + } + throw error + } finally { + if (timeoutId) { + clearTimeout(timeoutId) + } + + task.terminalProcess = undefined + } + } else { + // No timeout - just wait for the process to complete. + try { + await process + } finally { + task.terminalProcess = undefined + } } } diff --git a/src/package.json b/src/package.json index d6d2f4ce60a6..5af7fe5dbd9b 100644 --- a/src/package.json +++ b/src/package.json @@ -377,6 +377,19 @@ "default": false, "description": "%commands.preventCompletionWithOpenTodos.description%" }, + "roo-cline.daemonCommands": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "%commands.daemonCommands.description%" + }, + "roo-cline.disableDaemonDetection": { + "type": "boolean", + "default": false, + "description": "%commands.disableDaemonDetection.description%" + }, "roo-cline.vsCodeLmModelSelector": { "type": "object", "properties": { diff --git a/src/package.nls.json b/src/package.nls.json index 1db69777ac17..15a536fc117d 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -33,6 +33,8 @@ "commands.commandExecutionTimeout.description": "Maximum time in seconds to wait for command execution to complete before timing out (0 = no timeout, 1-600s, default: 0s)", "commands.commandTimeoutAllowlist.description": "Command prefixes that are excluded from the command execution timeout. Commands matching these prefixes will run without timeout restrictions.", "commands.preventCompletionWithOpenTodos.description": "Prevent task completion when there are incomplete todos in the todo list", + "commands.daemonCommands.description": "Additional command patterns to identify as daemon/long-running processes. These commands will be handled as background services that continue running without blocking the workflow.", + "commands.disableDaemonDetection.description": "Disable automatic detection of daemon/long-running processes. When disabled, all commands will wait for completion before proceeding.", "settings.vsCodeLmModelSelector.description": "Settings for VSCode Language Model API", "settings.vsCodeLmModelSelector.vendor.description": "The vendor of the language model (e.g. copilot)", "settings.vsCodeLmModelSelector.family.description": "The family of the language model (e.g. gpt-4)", diff --git a/src/utils/__tests__/daemon-detector.spec.ts b/src/utils/__tests__/daemon-detector.spec.ts new file mode 100644 index 000000000000..4f4c95c84be6 --- /dev/null +++ b/src/utils/__tests__/daemon-detector.spec.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, beforeEach } from "vitest" +import { + isDaemonCommand, + addDaemonPatterns, + clearUserDaemonPatterns, + getDaemonMessage, + getServiceType, +} from "../daemon-detector" + +describe("daemon-detector", () => { + beforeEach(() => { + // Clear any user patterns before each test + clearUserDaemonPatterns() + }) + + describe("isDaemonCommand", () => { + describe("Java/Spring Boot patterns", () => { + it("should detect mvn spring-boot:run", () => { + expect(isDaemonCommand("mvn spring-boot:run")).toBe(true) + expect(isDaemonCommand("MVN SPRING-BOOT:RUN")).toBe(true) + expect(isDaemonCommand("mvn spring-boot:run --debug")).toBe(true) + }) + + it("should detect gradle bootRun", () => { + expect(isDaemonCommand("gradle bootRun")).toBe(true) + expect(isDaemonCommand('gradle bootRun --args="--spring.profiles.active=dev"')).toBe(true) + }) + + it("should detect java -jar commands", () => { + expect(isDaemonCommand("java -jar myapp.jar")).toBe(true) + expect(isDaemonCommand("java -jar /path/to/app.jar")).toBe(true) + expect(isDaemonCommand("java -Xmx512m -jar application.jar")).toBe(true) + }) + }) + + describe("Node.js patterns", () => { + it("should detect npm start/dev/serve commands", () => { + expect(isDaemonCommand("npm start")).toBe(true) + expect(isDaemonCommand("npm run start")).toBe(true) + expect(isDaemonCommand("npm run dev")).toBe(true) + expect(isDaemonCommand("npm serve")).toBe(true) + expect(isDaemonCommand("npm run watch")).toBe(true) + }) + + it("should detect yarn commands", () => { + expect(isDaemonCommand("yarn start")).toBe(true) + expect(isDaemonCommand("yarn dev")).toBe(true) + expect(isDaemonCommand("yarn serve")).toBe(true) + }) + + it("should detect nodemon", () => { + expect(isDaemonCommand("nodemon server.js")).toBe(true) + expect(isDaemonCommand("nodemon --watch src app.js")).toBe(true) + }) + + it("should detect pm2", () => { + expect(isDaemonCommand("pm2 start app.js")).toBe(true) + expect(isDaemonCommand("pm2 start ecosystem.config.js")).toBe(true) + }) + }) + + describe("Python patterns", () => { + it("should detect Python HTTP server", () => { + expect(isDaemonCommand("python -m http.server")).toBe(true) + expect(isDaemonCommand("python3 -m http.server 8000")).toBe(true) + }) + + it("should detect Django runserver", () => { + expect(isDaemonCommand("python manage.py runserver")).toBe(true) + expect(isDaemonCommand("python manage.py runserver 0.0.0.0:8000")).toBe(true) + }) + + it("should detect Flask run", () => { + expect(isDaemonCommand("flask run")).toBe(true) + expect(isDaemonCommand("flask run --host=0.0.0.0")).toBe(true) + }) + + it("should detect Python app.py", () => { + expect(isDaemonCommand("python app.py")).toBe(true) + expect(isDaemonCommand("python3 /path/to/app.py")).toBe(true) + }) + }) + + describe("Ruby patterns", () => { + it("should detect Rails server", () => { + expect(isDaemonCommand("rails server")).toBe(true) + expect(isDaemonCommand("rails s")).toBe(true) + expect(isDaemonCommand("rails server -p 3001")).toBe(true) + }) + }) + + describe("Docker patterns", () => { + it("should detect docker run without --rm", () => { + expect(isDaemonCommand("docker run nginx")).toBe(true) + expect(isDaemonCommand("docker run -p 8080:80 nginx")).toBe(true) + }) + + it("should not detect docker run with --rm", () => { + expect(isDaemonCommand("docker run --rm nginx")).toBe(false) + expect(isDaemonCommand("docker run --rm -it ubuntu bash")).toBe(false) + }) + + it("should detect docker-compose up without -d", () => { + expect(isDaemonCommand("docker-compose up")).toBe(true) + expect(isDaemonCommand("docker-compose up web")).toBe(true) + }) + + it("should not detect docker-compose up with -d", () => { + expect(isDaemonCommand("docker-compose up -d")).toBe(false) + expect(isDaemonCommand("docker-compose up -d web")).toBe(false) + }) + }) + + describe("Non-daemon commands", () => { + it("should not detect regular commands", () => { + expect(isDaemonCommand("ls -la")).toBe(false) + expect(isDaemonCommand("git status")).toBe(false) + expect(isDaemonCommand("npm install")).toBe(false) + expect(isDaemonCommand("npm test")).toBe(false) + expect(isDaemonCommand('echo "hello"')).toBe(false) + expect(isDaemonCommand("cd /path/to/dir")).toBe(false) + }) + }) + }) + + describe("addDaemonPatterns", () => { + it("should add string patterns", () => { + expect(isDaemonCommand("my-custom-process")).toBe(false) + addDaemonPatterns(["my-custom-process"]) + expect(isDaemonCommand("my-custom-process")).toBe(true) + }) + + it("should add regex patterns", () => { + expect(isDaemonCommand("custom-app --background")).toBe(false) + addDaemonPatterns([/^custom-app\s+--background/]) + expect(isDaemonCommand("custom-app --background")).toBe(true) + }) + + it("should handle multiple patterns", () => { + expect(isDaemonCommand("my-process")).toBe(false) + expect(isDaemonCommand("another-process")).toBe(false) + + addDaemonPatterns(["my-process", "another-process"]) + + expect(isDaemonCommand("my-process")).toBe(true) + expect(isDaemonCommand("another-process")).toBe(true) + }) + }) + + describe("clearUserDaemonPatterns", () => { + it("should clear user-defined patterns", () => { + addDaemonPatterns(["my-custom-process"]) + expect(isDaemonCommand("my-custom-process")).toBe(true) + + clearUserDaemonPatterns() + expect(isDaemonCommand("my-custom-process")).toBe(false) + }) + + it("should not affect built-in patterns", () => { + addDaemonPatterns(["my-custom-process"]) + clearUserDaemonPatterns() + + // Built-in patterns should still work + expect(isDaemonCommand("npm start")).toBe(true) + expect(isDaemonCommand("mvn spring-boot:run")).toBe(true) + }) + }) + + describe("getDaemonMessage", () => { + it("should return a message for daemon processes", () => { + const message = getDaemonMessage("npm start") + expect(message).toContain("npm start") + expect(message).toContain("long-running service/daemon") + expect(message).toContain("background") + }) + + it("should truncate long commands", () => { + const longCommand = + "java -Xmx4g -Xms2g -jar /very/long/path/to/application/with/many/options/application.jar --spring.profiles.active=production" + const message = getDaemonMessage(longCommand) + expect(message).toContain("...") + }) + }) + + describe("getServiceType", () => { + it("should identify Spring Boot applications", () => { + expect(getServiceType("mvn spring-boot:run")).toBe("Spring Boot application") + expect(getServiceType("gradle bootRun")).toBe("Spring Boot application") + }) + + it("should identify Node.js applications", () => { + expect(getServiceType("npm start")).toBe("Node.js application") + expect(getServiceType("yarn dev")).toBe("Node.js application") + expect(getServiceType("pnpm serve")).toBe("Node.js application") + }) + + it("should identify Python applications", () => { + expect(getServiceType("python app.py")).toBe("Python application") + expect(getServiceType("flask run")).toBe("Python application") + expect(getServiceType("python manage.py runserver")).toBe("Python application") + }) + + it("should identify Rails applications", () => { + expect(getServiceType("rails server")).toBe("Rails application") + expect(getServiceType("rails s")).toBe("Rails application") + }) + + it("should identify .NET applications", () => { + expect(getServiceType("dotnet run")).toBe(".NET application") + expect(getServiceType("dotnet watch")).toBe(".NET application") + }) + + it("should identify Docker containers", () => { + expect(getServiceType("docker run nginx")).toBe("Docker container") + expect(getServiceType("docker-compose up")).toBe("Docker container") + }) + + it("should identify PHP applications", () => { + expect(getServiceType("php -S localhost:8000")).toBe("PHP application") + expect(getServiceType("php artisan serve")).toBe("PHP application") + }) + + it("should return generic application for unknown types", () => { + expect(getServiceType("unknown-server start")).toBe("application") + }) + }) +}) diff --git a/src/utils/daemon-detector.ts b/src/utils/daemon-detector.ts new file mode 100644 index 000000000000..dce4cf738a4b --- /dev/null +++ b/src/utils/daemon-detector.ts @@ -0,0 +1,166 @@ +/** + * Utility for detecting and managing daemon/long-running processes + */ + +/** + * Common patterns for daemon/service commands + * These are commands that typically start long-running processes + */ +const DAEMON_COMMAND_PATTERNS = [ + // Java/Spring Boot patterns + /^mvn\s+spring-boot:run/i, + /^gradle\s+bootRun/i, + /^java\s+-jar.*\.jar/i, + /^java\s+.*\.(jar|war)$/i, + /^spring\s+boot:run/i, + + // Node.js patterns + /^npm\s+(run\s+)?(start|dev|serve|watch)/i, + /^yarn\s+(start|dev|serve|watch)/i, + /^pnpm\s+(start|dev|serve|watch)/i, + /^node\s+.*server/i, + /^nodemon\s+/i, + /^pm2\s+start/i, + /^forever\s+start/i, + + // Python patterns + /^python3?\s+-m\s+http\.server/i, + /^python3?\s+.*manage\.py\s+runserver/i, // Django + /^flask\s+run/i, + /^uvicorn\s+/i, // FastAPI + /^gunicorn\s+/i, + /^python3?\s+.*app\.py$/i, + + // Ruby patterns + /^rails\s+server/i, + /^rails\s+s$/i, + /^ruby\s+.*server/i, + /^rackup\s+/i, + + // PHP patterns + /^php\s+-S\s+/i, // PHP built-in server + /^php\s+artisan\s+serve/i, // Laravel + + // .NET patterns + /^dotnet\s+run/i, + /^dotnet\s+watch/i, + + // Go patterns + /^go\s+run\s+.*\.go$/i, + + // Docker patterns + /^docker\s+run\s+(?!.*--rm)/i, // Docker run without --rm flag + /^docker-compose\s+up(?!\s+.*-d)/i, // Docker compose up without -d flag + + // Generic server patterns + /\b(server|serve|watch|dev|start)\b.*$/i, + /^.*\s+(--watch|--serve|--server)/i, +] + +/** + * Additional patterns that can be configured by users + */ +let userDefinedPatterns: RegExp[] = [] + +/** + * Check if a command is likely to start a daemon/long-running process + * @param command The command to check + * @returns true if the command is likely a daemon process + */ +export function isDaemonCommand(command: string): boolean { + // Trim and normalize the command + const normalizedCommand = command.trim() + + // Check against built-in patterns + for (const pattern of DAEMON_COMMAND_PATTERNS) { + if (pattern.test(normalizedCommand)) { + return true + } + } + + // Check against user-defined patterns + for (const pattern of userDefinedPatterns) { + if (pattern.test(normalizedCommand)) { + return true + } + } + + return false +} + +/** + * Add user-defined daemon patterns + * @param patterns Array of regex patterns or strings to match + */ +export function addDaemonPatterns(patterns: (string | RegExp)[]): void { + const compiledPatterns = patterns.map((p) => { + if (p instanceof RegExp) { + return p + } + // Convert string to regex, escaping special characters + const escaped = p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + return new RegExp(escaped, "i") + }) + + userDefinedPatterns = [...userDefinedPatterns, ...compiledPatterns] +} + +/** + * Clear user-defined daemon patterns + */ +export function clearUserDaemonPatterns(): void { + userDefinedPatterns = [] +} + +/** + * Get a user-friendly message for daemon processes + * @param command The daemon command + * @returns A message explaining the daemon process handling + */ +export function getDaemonMessage(command: string): string { + const shortCommand = command.length > 50 ? command.substring(0, 50) + "..." : command + + return ( + `The command '${shortCommand}' appears to be starting a long-running service/daemon process. ` + + `The process has been started in the background and will continue running. ` + + `You can proceed with other tasks while this service runs. ` + + `To stop the service, you may need to use Ctrl+C in the terminal or run the appropriate stop command.` + ) +} + +/** + * Extract service type from daemon command for better messaging + * @param command The daemon command + * @returns The type of service being started + */ +export function getServiceType(command: string): string { + const normalizedCommand = command.toLowerCase() + + if (normalizedCommand.includes("spring-boot") || normalizedCommand.includes("bootrun")) { + return "Spring Boot application" + } + if (normalizedCommand.includes("npm") || normalizedCommand.includes("yarn") || normalizedCommand.includes("pnpm")) { + return "Node.js application" + } + if ( + normalizedCommand.includes("python") || + normalizedCommand.includes("flask") || + normalizedCommand.includes("django") + ) { + return "Python application" + } + if (normalizedCommand.includes("rails")) { + return "Rails application" + } + if (normalizedCommand.includes("dotnet")) { + return ".NET application" + } + if (normalizedCommand.includes("docker")) { + return "Docker container" + } + if (normalizedCommand.includes("php")) { + return "PHP application" + } + + return "application" +}