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
This commit is contained in:
Roo Code 2025-10-13 05:54:28 +00:00
parent 6b8c21f873
commit e06469a31c
5 changed files with 523 additions and 33 deletions

View file

@ -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<boolean>("disableDaemonDetection", false)
// Load user-defined daemon patterns from configuration
if (!disableDaemonDetection) {
clearUserDaemonPatterns()
const userDaemonPatterns = vscode.workspace.getConfiguration(Package.name).get<string[]>("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
// 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 timeoutPromise = new Promise<void>((_, reject) => {
timeoutId = setTimeout(() => {
isTimedOut = true
task.terminalProcess?.abort()
reject(new Error(`Command execution timed out after ${commandExecutionTimeout}ms`))
}, commandExecutionTimeout)
const initialOutputPromise = new Promise<void>((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<void>((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<void>((_, 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
}
}
}

View file

@ -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": {

View file

@ -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)",

View file

@ -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")
})
})
})

View file

@ -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"
}