Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/core/prompts/tools/native-tools/new_task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const MESSAGE_PARAMETER_DESCRIPTION = `Initial user instructions or context for

const TODOS_PARAMETER_DESCRIPTION = `Optional initial todo list written as a markdown checklist; required when the workspace mandates todos`

const BACKGROUND_PARAMETER_DESCRIPTION = `When set to "true", the task runs in the background concurrently with the current task. Background tasks are restricted to read-only tools only (read_file, list_files, search_files, codebase_search). Results are delivered asynchronously when the background task completes. Use for research, analysis, or documentation lookup while continuing other work.`

export default {
type: "function",
function: {
Expand All @@ -31,8 +33,12 @@ export default {
type: ["string", "null"],
description: TODOS_PARAMETER_DESCRIPTION,
},
background: {
type: ["string", "null"],
description: BACKGROUND_PARAMETER_DESCRIPTION,
},
},
required: ["mode", "message", "todos"],
required: ["mode", "message", "todos", "background"],
additionalProperties: false,
},
},
Expand Down
225 changes: 225 additions & 0 deletions src/core/task/BackgroundTaskRunner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/**
* BackgroundTaskRunner manages read-only background tasks that run concurrently
* alongside the user's active foreground task. Background tasks:
* - Are completely webview-silent (no UI updates)
* - Auto-approve all tool uses (no user interaction)
* - Are restricted to read-only tools only
* - Have a configurable timeout to prevent runaway execution
* - Are not added to the clineStack
*
* This is Phase 4 of the parallel execution roadmap: Background Read-Only Concurrency.
*/

import { Task, TaskOptions } from "./Task"

/** Read-only tools that background tasks are allowed to use. */
export const BACKGROUND_TASK_ALLOWED_TOOLS = [
"read_file",
"list_files",
"search_files",
"codebase_search",
"ask_followup_question",
"attempt_completion",
] as const

/** Default maximum number of concurrent background tasks. */
export const DEFAULT_MAX_BACKGROUND_TASKS = 3

/** Default timeout for background tasks in milliseconds (5 minutes). */
export const DEFAULT_BACKGROUND_TASK_TIMEOUT_MS = 5 * 60 * 1000

export interface BackgroundTaskInfo {
task: Task
parentTaskId: string
startedAt: number
timeoutHandle: ReturnType<typeof setTimeout>
}

/**
* Optional callbacks that allow the owner (e.g. ClineProvider) to react to
* background task lifecycle events such as completion, timeout, or errors.
*/
export interface BackgroundTaskRunnerCallbacks {
/** Called when a background task times out. */
onTaskTimeout?: (taskId: string, parentTaskId: string) => void
/** Called when aborting a background task throws an error. */
onTaskError?: (taskId: string, parentTaskId: string, error: Error) => void
}

export class BackgroundTaskRunner {
private backgroundTasks: Map<string, BackgroundTaskInfo> = new Map()
private maxConcurrentTasks: number
private taskTimeoutMs: number
private callbacks: BackgroundTaskRunnerCallbacks

constructor(
maxConcurrentTasks: number = DEFAULT_MAX_BACKGROUND_TASKS,
taskTimeoutMs: number = DEFAULT_BACKGROUND_TASK_TIMEOUT_MS,
callbacks: BackgroundTaskRunnerCallbacks = {},
) {
this.maxConcurrentTasks = maxConcurrentTasks
this.taskTimeoutMs = taskTimeoutMs
this.callbacks = callbacks
}

/**
* Returns the number of currently running background tasks.
*/
get activeCount(): number {
return this.backgroundTasks.size
}

/**
* Returns whether the runner can accept more background tasks.
*/
get canAcceptTask(): boolean {
return this.backgroundTasks.size < this.maxConcurrentTasks
}

/**
* Register a background task after it has been created.
* The task should already have isBackgroundTask=true and be started.
*/
registerTask(task: Task, parentTaskId: string): void {
if (this.backgroundTasks.has(task.taskId)) {
console.warn(`[BackgroundTaskRunner] Task ${task.taskId} already registered`)
return
}

if (!this.canAcceptTask) {
throw new Error(
`[BackgroundTaskRunner] Cannot accept more background tasks. ` +
`Current: ${this.backgroundTasks.size}, Max: ${this.maxConcurrentTasks}`,
)
}

const timeoutHandle = setTimeout(() => {
this.timeoutTask(task.taskId)
}, this.taskTimeoutMs)

this.backgroundTasks.set(task.taskId, {
task,
parentTaskId,
startedAt: Date.now(),
timeoutHandle,
})

console.log(
`[BackgroundTaskRunner] Registered background task ${task.taskId} ` +
`(parent: ${parentTaskId}, active: ${this.backgroundTasks.size}/${this.maxConcurrentTasks})`,
)
}

/**
* Called when a background task completes. Cleans up tracking state.
*/
onTaskCompleted(taskId: string): BackgroundTaskInfo | undefined {
const info = this.backgroundTasks.get(taskId)

if (!info) {
return undefined
}

clearTimeout(info.timeoutHandle)
this.backgroundTasks.delete(taskId)

console.log(
`[BackgroundTaskRunner] Background task ${taskId} completed ` +
`(active: ${this.backgroundTasks.size}/${this.maxConcurrentTasks})`,
)

return info
}

/**
* Get info about a specific background task.
*/
getTaskInfo(taskId: string): BackgroundTaskInfo | undefined {
return this.backgroundTasks.get(taskId)
}

/**
* Check if a task is a registered background task.
*/
isBackgroundTask(taskId: string): boolean {
return this.backgroundTasks.has(taskId)
}

/**
* Cancel all background tasks spawned by a specific parent task.
*/
async cancelTasksByParent(parentTaskId: string): Promise<void> {
const tasksToCancel: BackgroundTaskInfo[] = []

for (const [, info] of this.backgroundTasks) {
if (info.parentTaskId === parentTaskId) {
tasksToCancel.push(info)
}
}

for (const info of tasksToCancel) {
await this.cancelTask(info.task.taskId)
}
}

/**
* Cancel a specific background task.
*/
async cancelTask(taskId: string): Promise<void> {
const info = this.backgroundTasks.get(taskId)

if (!info) {
return
}

clearTimeout(info.timeoutHandle)

try {
await info.task.abortTask(true)
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error))
console.error(`[BackgroundTaskRunner] Error aborting background task ${taskId}: ${err.message}`)
try {
this.callbacks.onTaskError?.(taskId, info.parentTaskId, err)
} catch {
// Callback errors must not break cleanup.
}
}

this.backgroundTasks.delete(taskId)

console.log(
`[BackgroundTaskRunner] Cancelled background task ${taskId} ` +
`(active: ${this.backgroundTasks.size}/${this.maxConcurrentTasks})`,
)
}

/**
* Cancel all background tasks. Called during provider disposal.
*/
async dispose(): Promise<void> {
const taskIds = Array.from(this.backgroundTasks.keys())

for (const taskId of taskIds) {
await this.cancelTask(taskId)
}
}

/**
* Handle timeout of a background task.
*/
private async timeoutTask(taskId: string): Promise<void> {
const info = this.backgroundTasks.get(taskId)
const parentTaskId = info?.parentTaskId ?? "unknown"

console.warn(`[BackgroundTaskRunner] Background task ${taskId} timed out after ${this.taskTimeoutMs}ms`)

try {
this.callbacks.onTaskTimeout?.(taskId, parentTaskId)
} catch {
// Callback errors must not break cleanup.
}

await this.cancelTask(taskId)
}
}
56 changes: 49 additions & 7 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ export interface TaskOptions extends CreateTaskOptions {
workspacePath?: string
/** Initial status for the task's history item (e.g., "active" for child tasks) */
initialStatus?: "active" | "delegated" | "completed"
/** When true, the task runs in the background: webview updates are suppressed and all tool uses are auto-approved. */
isBackgroundTask?: boolean
/** Callback invoked when a background task completes (via attempt_completion). */
onBackgroundComplete?: (taskId: string, result: string) => void
}

export class Task extends EventEmitter<TaskEvents> implements TaskLike {
Expand All @@ -165,6 +169,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
readonly instanceId: string
readonly metadata: TaskMetadata

/** When true, this task runs in the background with webview silencing and auto-approval. */
readonly isBackgroundTask: boolean
/** Callback for background task completion result delivery. */
readonly onBackgroundComplete?: (taskId: string, result: string) => void

todoList?: TodoItem[]

readonly rootTask: Task | undefined = undefined
Expand Down Expand Up @@ -430,6 +439,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
initialTodos,
workspacePath,
initialStatus,
isBackgroundTask = false,
onBackgroundComplete,
}: TaskOptions) {
super()

Expand Down Expand Up @@ -491,6 +502,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.parentTask = parentTask
this.taskNumber = taskNumber
this.initialStatus = initialStatus
this.isBackgroundTask = isBackgroundTask
this.onBackgroundComplete = onBackgroundComplete

this.assistantMessageParser = undefined

Expand Down Expand Up @@ -1143,10 +1156,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

private async addToClineMessages(message: ClineMessage) {
this.clineMessages.push(message)
const provider = this.providerRef.deref()
// Avoid resending large, mostly-static fields (notably taskHistory) on every chat message update.
// taskHistory is maintained in-memory in the webview and updated via taskHistoryItemUpdated.
await provider?.postStateToWebviewWithoutTaskHistory()

if (!this.isBackgroundTask) {
const provider = this.providerRef.deref()
// Avoid resending large, mostly-static fields (notably taskHistory) on every chat message update.
// taskHistory is maintained in-memory in the webview and updated via taskHistoryItemUpdated.
await provider?.postStateToWebviewWithoutTaskHistory()
}

this.emit(RooCodeEventName.Message, { action: "created", message })
await this.saveClineMessages()
}
Expand All @@ -1158,8 +1175,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}

private async updateClineMessage(message: ClineMessage) {
const provider = this.providerRef.deref()
await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message })
if (!this.isBackgroundTask) {
const provider = this.providerRef.deref()
await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message })
}

this.emit(RooCodeEventName.Message, { action: "updated", message })
}

Expand Down Expand Up @@ -1195,7 +1215,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// - Final state is emitted when updates stop (trailing: true)
this.debouncedEmitTokenUsage(tokenUsage, this.toolUsage)

await this.providerRef.deref()?.updateTaskHistory(historyItem)
if (!this.isBackgroundTask) {
await this.providerRef.deref()?.updateTaskHistory(historyItem)
}
return true
} catch (error) {
console.error("Failed to save Roo messages:", error)
Expand Down Expand Up @@ -1315,6 +1337,26 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

let timeouts: NodeJS.Timeout[] = []

// Background tasks auto-approve all asks immediately (no user interaction).
// Design decision: Full auto-approval is safe here because background tasks
// are restricted to read-only tools only (read_file, list_files, search_files,
// codebase_search). They cannot modify files, execute commands, or perform any
// destructive operations. If a future phase introduces write-capable background
// tasks, this auto-approval should be revisited to allow selective user input
// for dangerous operations.
if (this.isBackgroundTask) {
this.approveAsk()
await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 })
if (this.lastMessageTs !== askTs) {
throw new AskIgnoredError("superseded")
}
const result = { response: this.askResponse!, text: this.askResponseText, images: this.askResponseImages }
this.askResponse = undefined
this.askResponseText = undefined
this.askResponseImages = undefined
return result
}

// Automatically approve if the ask according to the user's settings.
const provider = this.providerRef.deref()
const state = provider ? await provider.getState() : undefined
Expand Down
Loading
Loading