Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 903ff8c

Browse files
committed
feat: add Phase 4 background read-only concurrency (BackgroundTaskRunner)
Implements the MVP for Phase 4 of the parallel execution roadmap: - Add BackgroundTaskRunner service that manages concurrent read-only background tasks separately from the clineStack - Add isBackgroundTask flag to Task class that suppresses webview updates and auto-approves all tool uses - Extend new_task tool with optional background parameter - Background tasks are restricted to read-only tools only - Results are delivered asynchronously to the parent task via onBackgroundComplete callback - Configurable concurrency limit (default 3) and timeout (default 5min) - Proper cleanup on task cancellation, parent cancellation, and provider disposal - 17 new tests for BackgroundTaskRunner, all existing tests pass Issue #12330
1 parent 8922418 commit 903ff8c

8 files changed

Lines changed: 619 additions & 12 deletions

File tree

src/core/prompts/tools/native-tools/new_task.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ const MESSAGE_PARAMETER_DESCRIPTION = `Initial user instructions or context for
1010

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

13+
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.`
14+
1315
export default {
1416
type: "function",
1517
function: {
@@ -31,8 +33,12 @@ export default {
3133
type: ["string", "null"],
3234
description: TODOS_PARAMETER_DESCRIPTION,
3335
},
36+
background: {
37+
type: ["string", "null"],
38+
description: BACKGROUND_PARAMETER_DESCRIPTION,
39+
},
3440
},
35-
required: ["mode", "message", "todos"],
41+
required: ["mode", "message", "todos", "background"],
3642
additionalProperties: false,
3743
},
3844
},
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/**
2+
* BackgroundTaskRunner manages read-only background tasks that run concurrently
3+
* alongside the user's active foreground task. Background tasks:
4+
* - Are completely webview-silent (no UI updates)
5+
* - Auto-approve all tool uses (no user interaction)
6+
* - Are restricted to read-only tools only
7+
* - Have a configurable timeout to prevent runaway execution
8+
* - Are not added to the clineStack
9+
*
10+
* This is Phase 4 of the parallel execution roadmap: Background Read-Only Concurrency.
11+
*/
12+
13+
import { Task, TaskOptions } from "./Task"
14+
15+
/** Read-only tools that background tasks are allowed to use. */
16+
export const BACKGROUND_TASK_ALLOWED_TOOLS = [
17+
"read_file",
18+
"list_files",
19+
"search_files",
20+
"codebase_search",
21+
"ask_followup_question",
22+
"attempt_completion",
23+
] as const
24+
25+
/** Default maximum number of concurrent background tasks. */
26+
export const DEFAULT_MAX_BACKGROUND_TASKS = 3
27+
28+
/** Default timeout for background tasks in milliseconds (5 minutes). */
29+
export const DEFAULT_BACKGROUND_TASK_TIMEOUT_MS = 5 * 60 * 1000
30+
31+
export interface BackgroundTaskInfo {
32+
task: Task
33+
parentTaskId: string
34+
startedAt: number
35+
timeoutHandle: ReturnType<typeof setTimeout>
36+
}
37+
38+
export class BackgroundTaskRunner {
39+
private backgroundTasks: Map<string, BackgroundTaskInfo> = new Map()
40+
private maxConcurrentTasks: number
41+
private taskTimeoutMs: number
42+
43+
constructor(
44+
maxConcurrentTasks: number = DEFAULT_MAX_BACKGROUND_TASKS,
45+
taskTimeoutMs: number = DEFAULT_BACKGROUND_TASK_TIMEOUT_MS,
46+
) {
47+
this.maxConcurrentTasks = maxConcurrentTasks
48+
this.taskTimeoutMs = taskTimeoutMs
49+
}
50+
51+
/**
52+
* Returns the number of currently running background tasks.
53+
*/
54+
get activeCount(): number {
55+
return this.backgroundTasks.size
56+
}
57+
58+
/**
59+
* Returns whether the runner can accept more background tasks.
60+
*/
61+
get canAcceptTask(): boolean {
62+
return this.backgroundTasks.size < this.maxConcurrentTasks
63+
}
64+
65+
/**
66+
* Register a background task after it has been created.
67+
* The task should already have isBackgroundTask=true and be started.
68+
*/
69+
registerTask(task: Task, parentTaskId: string): void {
70+
if (this.backgroundTasks.has(task.taskId)) {
71+
console.warn(`[BackgroundTaskRunner] Task ${task.taskId} already registered`)
72+
return
73+
}
74+
75+
if (!this.canAcceptTask) {
76+
throw new Error(
77+
`[BackgroundTaskRunner] Cannot accept more background tasks. ` +
78+
`Current: ${this.backgroundTasks.size}, Max: ${this.maxConcurrentTasks}`,
79+
)
80+
}
81+
82+
const timeoutHandle = setTimeout(() => {
83+
this.timeoutTask(task.taskId)
84+
}, this.taskTimeoutMs)
85+
86+
this.backgroundTasks.set(task.taskId, {
87+
task,
88+
parentTaskId,
89+
startedAt: Date.now(),
90+
timeoutHandle,
91+
})
92+
93+
console.log(
94+
`[BackgroundTaskRunner] Registered background task ${task.taskId} ` +
95+
`(parent: ${parentTaskId}, active: ${this.backgroundTasks.size}/${this.maxConcurrentTasks})`,
96+
)
97+
}
98+
99+
/**
100+
* Called when a background task completes. Cleans up tracking state.
101+
*/
102+
onTaskCompleted(taskId: string): BackgroundTaskInfo | undefined {
103+
const info = this.backgroundTasks.get(taskId)
104+
105+
if (!info) {
106+
return undefined
107+
}
108+
109+
clearTimeout(info.timeoutHandle)
110+
this.backgroundTasks.delete(taskId)
111+
112+
console.log(
113+
`[BackgroundTaskRunner] Background task ${taskId} completed ` +
114+
`(active: ${this.backgroundTasks.size}/${this.maxConcurrentTasks})`,
115+
)
116+
117+
return info
118+
}
119+
120+
/**
121+
* Get info about a specific background task.
122+
*/
123+
getTaskInfo(taskId: string): BackgroundTaskInfo | undefined {
124+
return this.backgroundTasks.get(taskId)
125+
}
126+
127+
/**
128+
* Check if a task is a registered background task.
129+
*/
130+
isBackgroundTask(taskId: string): boolean {
131+
return this.backgroundTasks.has(taskId)
132+
}
133+
134+
/**
135+
* Cancel all background tasks spawned by a specific parent task.
136+
*/
137+
async cancelTasksByParent(parentTaskId: string): Promise<void> {
138+
const tasksToCancel: BackgroundTaskInfo[] = []
139+
140+
for (const [, info] of this.backgroundTasks) {
141+
if (info.parentTaskId === parentTaskId) {
142+
tasksToCancel.push(info)
143+
}
144+
}
145+
146+
for (const info of tasksToCancel) {
147+
await this.cancelTask(info.task.taskId)
148+
}
149+
}
150+
151+
/**
152+
* Cancel a specific background task.
153+
*/
154+
async cancelTask(taskId: string): Promise<void> {
155+
const info = this.backgroundTasks.get(taskId)
156+
157+
if (!info) {
158+
return
159+
}
160+
161+
clearTimeout(info.timeoutHandle)
162+
163+
try {
164+
await info.task.abortTask(true)
165+
} catch (error) {
166+
console.error(
167+
`[BackgroundTaskRunner] Error aborting background task ${taskId}: ${
168+
error instanceof Error ? error.message : String(error)
169+
}`,
170+
)
171+
}
172+
173+
this.backgroundTasks.delete(taskId)
174+
175+
console.log(
176+
`[BackgroundTaskRunner] Cancelled background task ${taskId} ` +
177+
`(active: ${this.backgroundTasks.size}/${this.maxConcurrentTasks})`,
178+
)
179+
}
180+
181+
/**
182+
* Cancel all background tasks. Called during provider disposal.
183+
*/
184+
async dispose(): Promise<void> {
185+
const taskIds = Array.from(this.backgroundTasks.keys())
186+
187+
for (const taskId of taskIds) {
188+
await this.cancelTask(taskId)
189+
}
190+
}
191+
192+
/**
193+
* Handle timeout of a background task.
194+
*/
195+
private async timeoutTask(taskId: string): Promise<void> {
196+
console.warn(`[BackgroundTaskRunner] Background task ${taskId} timed out after ${this.taskTimeoutMs}ms`)
197+
await this.cancelTask(taskId)
198+
}
199+
}

src/core/task/Task.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,10 @@ export interface TaskOptions extends CreateTaskOptions {
153153
workspacePath?: string
154154
/** Initial status for the task's history item (e.g., "active" for child tasks) */
155155
initialStatus?: "active" | "delegated" | "completed"
156+
/** When true, the task runs in the background: webview updates are suppressed and all tool uses are auto-approved. */
157+
isBackgroundTask?: boolean
158+
/** Callback invoked when a background task completes (via attempt_completion). */
159+
onBackgroundComplete?: (taskId: string, result: string) => void
156160
}
157161

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

172+
/** When true, this task runs in the background with webview silencing and auto-approval. */
173+
readonly isBackgroundTask: boolean
174+
/** Callback for background task completion result delivery. */
175+
readonly onBackgroundComplete?: (taskId: string, result: string) => void
176+
168177
todoList?: TodoItem[]
169178

170179
readonly rootTask: Task | undefined = undefined
@@ -430,6 +439,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
430439
initialTodos,
431440
workspacePath,
432441
initialStatus,
442+
isBackgroundTask = false,
443+
onBackgroundComplete,
433444
}: TaskOptions) {
434445
super()
435446

@@ -491,6 +502,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
491502
this.parentTask = parentTask
492503
this.taskNumber = taskNumber
493504
this.initialStatus = initialStatus
505+
this.isBackgroundTask = isBackgroundTask
506+
this.onBackgroundComplete = onBackgroundComplete
494507

495508
this.assistantMessageParser = undefined
496509

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

11441157
private async addToClineMessages(message: ClineMessage) {
11451158
this.clineMessages.push(message)
1146-
const provider = this.providerRef.deref()
1147-
// Avoid resending large, mostly-static fields (notably taskHistory) on every chat message update.
1148-
// taskHistory is maintained in-memory in the webview and updated via taskHistoryItemUpdated.
1149-
await provider?.postStateToWebviewWithoutTaskHistory()
1159+
1160+
if (!this.isBackgroundTask) {
1161+
const provider = this.providerRef.deref()
1162+
// Avoid resending large, mostly-static fields (notably taskHistory) on every chat message update.
1163+
// taskHistory is maintained in-memory in the webview and updated via taskHistoryItemUpdated.
1164+
await provider?.postStateToWebviewWithoutTaskHistory()
1165+
}
1166+
11501167
this.emit(RooCodeEventName.Message, { action: "created", message })
11511168
await this.saveClineMessages()
11521169
}
@@ -1158,8 +1175,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
11581175
}
11591176

11601177
private async updateClineMessage(message: ClineMessage) {
1161-
const provider = this.providerRef.deref()
1162-
await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message })
1178+
if (!this.isBackgroundTask) {
1179+
const provider = this.providerRef.deref()
1180+
await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message })
1181+
}
1182+
11631183
this.emit(RooCodeEventName.Message, { action: "updated", message })
11641184
}
11651185

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

1198-
await this.providerRef.deref()?.updateTaskHistory(historyItem)
1218+
if (!this.isBackgroundTask) {
1219+
await this.providerRef.deref()?.updateTaskHistory(historyItem)
1220+
}
11991221
return true
12001222
} catch (error) {
12011223
console.error("Failed to save Roo messages:", error)
@@ -1315,6 +1337,20 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
13151337

13161338
let timeouts: NodeJS.Timeout[] = []
13171339

1340+
// Background tasks auto-approve all asks immediately (no user interaction).
1341+
if (this.isBackgroundTask) {
1342+
this.approveAsk()
1343+
await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 })
1344+
if (this.lastMessageTs !== askTs) {
1345+
throw new AskIgnoredError("superseded")
1346+
}
1347+
const result = { response: this.askResponse!, text: this.askResponseText, images: this.askResponseImages }
1348+
this.askResponse = undefined
1349+
this.askResponseText = undefined
1350+
this.askResponseImages = undefined
1351+
return result
1352+
}
1353+
13181354
// Automatically approve if the ask according to the user's settings.
13191355
const provider = this.providerRef.deref()
13201356
const state = provider ? await provider.getState() : undefined

0 commit comments

Comments
 (0)