From 35f04e6212d2393a0d19ea53026f8b892db6c605 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 12 May 2026 03:28:52 +0000 Subject: [PATCH 1/3] feat: implement sequential fan-out / fan-in for orchestrator (Phase 2 of #12330) Adds subtask queue support to the new_task tool, allowing the orchestrator to define multiple subtasks that execute automatically in sequence without returning to the parent between each one. This saves LLM API calls and enables more efficient multi-agent workflows. Key changes: - SubtaskQueueItem, SubtaskResult types in packages/types/src/history.ts - task_queue parameter on new_task tool (optional JSON array) - NewTaskTool parses and validates queued subtasks, stores on parent - delegateParentAndOpenChild persists queue in parent HistoryItem - reopenParentFromDelegation auto-advances queue via advanceSubtaskQueue - formatAggregatedQueueResults aggregates all results when queue completes - 9 new tests covering queue advance, exhaustion, and result formatting - All 56 existing delegation tests continue to pass --- packages/types/src/history.ts | 26 ++ src/__tests__/sequential-fan-out.spec.ts | 242 ++++++++++++++++++ .../assistant-message/NativeToolCallParser.ts | 2 + .../prompts/tools/native-tools/new_task.ts | 10 +- src/core/tools/NewTaskTool.ts | 49 +++- src/core/webview/ClineProvider.ts | 181 ++++++++++++- src/shared/tools.ts | 5 +- 7 files changed, 501 insertions(+), 14 deletions(-) create mode 100644 src/__tests__/sequential-fan-out.spec.ts diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index a60d1a75b65..9089e4c0d18 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -1,5 +1,27 @@ import { z } from "zod" +/** + * SubtaskQueueItem — a single queued subtask definition for sequential fan-out. + * Used by the orchestrator to define a pipeline of subtasks that execute one after another. + */ +export const subtaskQueueItemSchema = z.object({ + mode: z.string(), + message: z.string(), +}) + +export type SubtaskQueueItem = z.infer + +/** + * SubtaskResult — the result of a completed subtask in a queue. + */ +export const subtaskResultSchema = z.object({ + taskId: z.string(), + mode: z.string(), + summary: z.string(), +}) + +export type SubtaskResult = z.infer + /** * HistoryItem */ @@ -26,6 +48,10 @@ export const historyItemSchema = z.object({ awaitingChildId: z.string().optional(), // Child currently awaited (set when delegated) completedByChildId: z.string().optional(), // Child that completed and resumed this parent completionResultSummary: z.string().optional(), // Summary from completed child + // Sequential fan-out queue (Phase 2) + subtaskQueue: z.array(subtaskQueueItemSchema).optional(), // Remaining subtasks to execute + subtaskQueueIndex: z.number().optional(), // Current position in the original queue (0-based) + subtaskResults: z.array(subtaskResultSchema).optional(), // Results from completed queue subtasks }) export type HistoryItem = z.infer diff --git a/src/__tests__/sequential-fan-out.spec.ts b/src/__tests__/sequential-fan-out.spec.ts new file mode 100644 index 00000000000..44d7a4d41e4 --- /dev/null +++ b/src/__tests__/sequential-fan-out.spec.ts @@ -0,0 +1,242 @@ +/** + * Tests for Phase 2: Sequential fan-out / fan-in. + * + * Tests the subtask queue mechanism where an orchestrator can define + * multiple subtasks that execute one after another with automatic transitions. + */ + +import { describe, it, expect, vi } from "vitest" +import { RooCodeEventName } from "@roo-code/types" +import type { HistoryItem, SubtaskQueueItem } from "@roo-code/types" + +import { ClineProvider } from "../core/webview/ClineProvider" + +describe("Sequential fan-out queue types", () => { + it("SubtaskQueueItem has required mode and message fields", () => { + const item: SubtaskQueueItem = { mode: "code", message: "Implement feature X" } + expect(item.mode).toBe("code") + expect(item.message).toBe("Implement feature X") + }) + + it("HistoryItem can include subtask queue fields", () => { + const historyItem: Partial = { + id: "test-1", + subtaskQueue: [ + { mode: "code", message: "Step 1" }, + { mode: "debug", message: "Step 2" }, + ], + subtaskQueueIndex: 0, + subtaskResults: [{ taskId: "child-1", mode: "code", summary: "Done" }], + } + expect(historyItem.subtaskQueue).toHaveLength(2) + expect(historyItem.subtaskQueueIndex).toBe(0) + expect(historyItem.subtaskResults).toHaveLength(1) + }) + + it("HistoryItem subtask queue fields are optional", () => { + const historyItem: Partial = { + id: "test-2", + status: "active", + } + expect(historyItem.subtaskQueue).toBeUndefined() + expect(historyItem.subtaskQueueIndex).toBeUndefined() + expect(historyItem.subtaskResults).toBeUndefined() + }) +}) + +describe("advanceSubtaskQueue", () => { + const makeHistoryItem = (overrides: Partial = {}): HistoryItem => ({ + id: "parent-1", + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status: "delegated", + ...overrides, + }) + + it("returns handled=true when there are more subtasks in the queue", async () => { + const emitSpy = vi.fn() + const mockChild = { taskId: "child-2", start: vi.fn() } + const provider = { + getCurrentTask: vi.fn().mockReturnValue({ taskId: "child-1" }), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + getTaskWithId: vi.fn().mockResolvedValue({ + historyItem: makeHistoryItem({ id: "child-1", status: "active" }), + }), + updateTaskHistory: vi.fn().mockResolvedValue(undefined), + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(mockChild), + emit: emitSpy, + log: vi.fn(), + } + + const subtaskQueue: SubtaskQueueItem[] = [ + { mode: "code", message: "Step 1" }, + { mode: "debug", message: "Step 2" }, + ] + + const historyItem = makeHistoryItem({ + subtaskQueue, + subtaskQueueIndex: 0, + subtaskResults: [], + childIds: ["child-1"], + }) + + const result = await (ClineProvider.prototype as any).advanceSubtaskQueue.call(provider, { + parentTaskId: "parent-1", + childTaskId: "child-1", + completionResultSummary: "Step 1 done", + historyItem, + }) + + expect(result.handled).toBe(true) + + // Should have closed the current child + expect(provider.removeClineFromStack).toHaveBeenCalled() + + // Should have marked child as completed + expect(provider.updateTaskHistory).toHaveBeenCalledWith( + expect.objectContaining({ id: "child-1", status: "completed" }), + ) + + // Should have switched mode to next subtask's mode + expect(provider.handleModeSwitch).toHaveBeenCalledWith("debug") + + // Should have created the next child with the queued message + expect(provider.createTask).toHaveBeenCalledWith("Step 2", undefined, undefined, { + initialTodos: [], + initialStatus: "active", + startTask: false, + }) + + // Should have started the next child + expect(mockChild.start).toHaveBeenCalled() + + // Should have updated parent with advanced queue index + expect(provider.updateTaskHistory).toHaveBeenCalledWith( + expect.objectContaining({ + id: "parent-1", + subtaskQueueIndex: 1, + subtaskResults: [{ taskId: "child-1", mode: "unknown", summary: "Step 1 done" }], + awaitingChildId: "child-2", + delegatedToId: "child-2", + }), + ) + + // Should have emitted delegation events + expect(emitSpy).toHaveBeenCalledWith( + RooCodeEventName.TaskDelegationCompleted, + "parent-1", + "child-1", + "Step 1 done", + ) + expect(emitSpy).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-2") + }) + + it("returns handled=false with aggregated summary when queue is exhausted", async () => { + const provider = { + getCurrentTask: vi.fn().mockReturnValue({ taskId: "child-2" }), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + getTaskWithId: vi.fn().mockResolvedValue({ + historyItem: makeHistoryItem({ id: "child-2", status: "active" }), + }), + updateTaskHistory: vi.fn().mockResolvedValue(undefined), + handleModeSwitch: vi.fn(), + createTask: vi.fn(), + emit: vi.fn(), + log: vi.fn(), + formatAggregatedQueueResults: (ClineProvider.prototype as any).formatAggregatedQueueResults, + } + + const subtaskQueue: SubtaskQueueItem[] = [{ mode: "code", message: "Step 1" }] + + const historyItem = makeHistoryItem({ + subtaskQueue, + subtaskQueueIndex: 0, + subtaskResults: [{ taskId: "child-1", mode: "code", summary: "Step 1 done" }], + childIds: ["child-1", "child-2"], + }) + + const result = await (ClineProvider.prototype as any).advanceSubtaskQueue.call(provider, { + parentTaskId: "parent-1", + childTaskId: "child-2", + completionResultSummary: "Step 2 done", + historyItem, + }) + + expect(result.handled).toBe(false) + expect(result.aggregatedSummary).toContain("Sequential Fan-Out Complete") + expect(result.aggregatedSummary).toContain("Step 1 done") + expect(result.aggregatedSummary).toContain("Step 2 done") + + // Should NOT have created a new child + expect(provider.createTask).not.toHaveBeenCalled() + + // Should have cleared queue from parent metadata + expect(provider.updateTaskHistory).toHaveBeenCalledWith( + expect.objectContaining({ + subtaskQueue: undefined, + subtaskQueueIndex: undefined, + }), + ) + }) + + it("returns handled=false immediately when queue is empty", async () => { + const provider = { + getCurrentTask: vi.fn(), + removeClineFromStack: vi.fn(), + getTaskWithId: vi.fn(), + updateTaskHistory: vi.fn(), + emit: vi.fn(), + log: vi.fn(), + formatAggregatedQueueResults: (ClineProvider.prototype as any).formatAggregatedQueueResults, + } + + const historyItem = makeHistoryItem({ + subtaskQueue: [], + subtaskQueueIndex: 0, + }) + + const result = await (ClineProvider.prototype as any).advanceSubtaskQueue.call(provider, { + parentTaskId: "parent-1", + childTaskId: "child-1", + completionResultSummary: "Done", + historyItem, + }) + + expect(result.handled).toBe(false) + expect(result.aggregatedSummary).toBe("Done") + }) +}) + +describe("formatAggregatedQueueResults", () => { + it("formats multiple results into a structured summary", () => { + const results = [ + { taskId: "child-1", mode: "code", summary: "Implemented feature X" }, + { taskId: "child-2", mode: "debug", summary: "Fixed bugs in feature X" }, + ] + + const formatted = (ClineProvider.prototype as any).formatAggregatedQueueResults(results, "Final result") + + expect(formatted).toContain("Sequential Fan-Out Complete (2 subtasks)") + expect(formatted).toContain("Subtask 1 (code)") + expect(formatted).toContain("Implemented feature X") + expect(formatted).toContain("Subtask 2 (debug)") + expect(formatted).toContain("Fixed bugs in feature X") + }) + + it("returns last summary when results array is empty", () => { + const formatted = (ClineProvider.prototype as any).formatAggregatedQueueResults([], "Just a summary") + expect(formatted).toBe("Just a summary") + }) + + it("handles single result", () => { + const results = [{ taskId: "child-1", mode: "code", summary: "Done" }] + const formatted = (ClineProvider.prototype as any).formatAggregatedQueueResults(results, "Done") + expect(formatted).toContain("Sequential Fan-Out Complete (1 subtask)") + expect(formatted).toContain("Subtask 1 (code)") + }) +}) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index c391f9852cf..30bf119e4fd 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -633,6 +633,7 @@ export class NativeToolCallParser { mode: partialArgs.mode, message: partialArgs.message, todos: partialArgs.todos, + task_queue: partialArgs.task_queue, } } break @@ -982,6 +983,7 @@ export class NativeToolCallParser { mode: args.mode, message: args.message, todos: args.todos, + task_queue: args.task_queue, } as NativeArgsFor } break diff --git a/src/core/prompts/tools/native-tools/new_task.ts b/src/core/prompts/tools/native-tools/new_task.ts index f8e29e549d9..051351a9a80 100644 --- a/src/core/prompts/tools/native-tools/new_task.ts +++ b/src/core/prompts/tools/native-tools/new_task.ts @@ -2,7 +2,9 @@ import type OpenAI from "openai" const NEW_TASK_DESCRIPTION = `Create a new task instance in the chosen mode using your provided message and initial todo list (if required). -CRITICAL: This tool MUST be called alone. Do NOT call this tool alongside other tools in the same message turn. If you need to gather information before delegating, use other tools in a separate turn first, then call new_task by itself in the next turn.` +CRITICAL: This tool MUST be called alone. Do NOT call this tool alongside other tools in the same message turn. If you need to gather information before delegating, use other tools in a separate turn first, then call new_task by itself in the next turn. + +SEQUENTIAL FAN-OUT: You can optionally provide a task_queue parameter to define additional subtasks that will execute automatically in sequence after the first subtask completes. Each queued subtask runs one after another without returning to the parent in between, saving time and API calls. Use this when you have planned multiple independent subtasks upfront. The first subtask is defined by the mode and message parameters; subsequent subtasks are defined in the task_queue array.` const MODE_PARAMETER_DESCRIPTION = `Slug of the mode to begin the new task in (e.g., code, debug, architect)` @@ -10,6 +12,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 TASK_QUEUE_PARAMETER_DESCRIPTION = `Optional JSON array of additional subtasks to execute sequentially after the first subtask completes. Each element is an object with "mode" (string) and "message" (string). Example: [{"mode":"code","message":"Implement feature X"},{"mode":"debug","message":"Test feature X"}]. When provided, the system automatically transitions between subtasks without returning to the parent, collecting all results. The parent receives aggregated results when the entire queue completes.` + export default { type: "function", function: { @@ -31,6 +35,10 @@ export default { type: ["string", "null"], description: TODOS_PARAMETER_DESCRIPTION, }, + task_queue: { + type: ["string", "null"], + description: TASK_QUEUE_PARAMETER_DESCRIPTION, + }, }, required: ["mode", "message", "todos"], additionalProperties: false, diff --git a/src/core/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index f36d8e1e379..636343a489b 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode" import { TodoItem } from "@roo-code/types" +import type { SubtaskQueueItem } from "@roo-code/types" import { Task } from "../task/Task" import { getModeBySlug } from "../../shared/modes" @@ -15,13 +16,14 @@ interface NewTaskParams { mode: string message: string todos?: string + task_queue?: string } export class NewTaskTool extends BaseTool<"new_task"> { readonly name = "new_task" as const async execute(params: NewTaskParams, task: Task, callbacks: ToolCallbacks): Promise { - const { mode, message, todos } = params + const { mode, message, todos, task_queue } = params const { askApproval, handleError, pushToolResult } = callbacks try { @@ -96,11 +98,47 @@ export class NewTaskTool extends BaseTool<"new_task"> { return } + // Parse task_queue if provided (sequential fan-out) + let queueItems: SubtaskQueueItem[] = [] + if (task_queue) { + try { + const parsed = JSON.parse(task_queue) + if (Array.isArray(parsed)) { + for (const item of parsed) { + if (typeof item.mode === "string" && typeof item.message === "string") { + // Validate each queued mode exists + const queuedMode = getModeBySlug(item.mode, state?.customModes) + if (!queuedMode) { + pushToolResult( + formatResponse.toolError( + `Invalid mode in task_queue: "${item.mode}". All queued subtasks must use valid modes.`, + ), + ) + return + } + queueItems.push({ mode: item.mode, message: item.message }) + } + } + } + } catch { + task.consecutiveMistakeCount++ + task.recordToolError("new_task") + task.didToolFailInCurrentTurn = true + pushToolResult( + formatResponse.toolError( + "Invalid task_queue format: must be a JSON array of objects with 'mode' and 'message' properties.", + ), + ) + return + } + } + const toolMessage = JSON.stringify({ tool: "newTask", mode: targetMode.name, content: message, todos: todoItems, + taskQueue: queueItems.length > 0 ? queueItems : undefined, }) const didApprove = await askApproval("tool", toolMessage) @@ -115,10 +153,15 @@ export class NewTaskTool extends BaseTool<"new_task"> { message: unescapedMessage, initialTodos: todoItems, mode, + subtaskQueue: queueItems.length > 0 ? queueItems : undefined, }) // Reflect delegation in tool result (no pause/unpause, no wait) - pushToolResult(`Delegated to child task ${child.taskId}`) + const queueMsg = + queueItems.length > 0 + ? ` (${queueItems.length} additional subtask${queueItems.length > 1 ? "s" : ""} queued)` + : "" + pushToolResult(`Delegated to child task ${child.taskId}${queueMsg}`) return } catch (error) { await handleError("creating new task", error) @@ -130,12 +173,14 @@ export class NewTaskTool extends BaseTool<"new_task"> { const mode: string | undefined = block.params.mode const message: string | undefined = block.params.message const todos: string | undefined = block.params.todos + const taskQueue: string | undefined = block.params.task_queue const partialMessage = JSON.stringify({ tool: "newTask", mode: mode ?? "", content: message ?? "", todos: todos, + taskQueue: taskQueue, }) await task.ask("tool", partialMessage, block.partial).catch(() => {}) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index aecdb17f316..3678df3ece8 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -82,7 +82,7 @@ import { CustomModesManager } from "../config/CustomModesManager" import { Task } from "../task/Task" import { webviewMessageHandler } from "./webviewMessageHandler" -import type { ClineMessage, TodoItem } from "@roo-code/types" +import type { ClineMessage, TodoItem, SubtaskQueueItem } from "@roo-code/types" import { readApiMessages, saveApiMessages, saveTaskMessages, TaskHistoryStore } from "../task-persistence" import { readTaskMessages } from "../task-persistence/taskMessages" import { getNonce } from "./getNonce" @@ -2785,8 +2785,9 @@ export class ClineProvider message: string initialTodos: TodoItem[] mode: string + subtaskQueue?: SubtaskQueueItem[] }): Promise { - const { parentTaskId, message, initialTodos, mode } = params + const { parentTaskId, message, initialTodos, mode, subtaskQueue } = params // Metadata-driven delegation is always enabled @@ -2889,6 +2890,9 @@ export class ClineProvider delegatedToId: child.taskId, awaitingChildId: child.taskId, childIds, + ...(subtaskQueue && subtaskQueue.length > 0 + ? { subtaskQueue, subtaskQueueIndex: 0, subtaskResults: [] } + : {}), } await this.updateTaskHistory(updatedHistory) } catch (err) { @@ -2920,12 +2924,28 @@ export class ClineProvider childTaskId: string completionResultSummary: string }): Promise { - const { parentTaskId, childTaskId, completionResultSummary } = params + const { parentTaskId, childTaskId } = params + let effectiveSummary = params.completionResultSummary const globalStoragePath = this.contextProxy.globalStorageUri.fsPath // 1) Load parent from history and current persisted messages const { historyItem } = await this.getTaskWithId(parentTaskId) + // PHASE 2: Sequential fan-out — check if parent has queued subtasks + if (historyItem.subtaskQueue && historyItem.subtaskQueue.length > 0) { + const queueAdvanceResult = await this.advanceSubtaskQueue({ + parentTaskId, + childTaskId, + completionResultSummary: effectiveSummary, + historyItem, + }) + if (queueAdvanceResult.handled) { + return // Queue advanced to next subtask; do NOT reopen parent + } + // Queue exhausted — use aggregated summary and continue with normal reopen + effectiveSummary = queueAdvanceResult.aggregatedSummary + } + let parentClineMessages: ClineMessage[] = [] try { parentClineMessages = await readTaskMessages({ @@ -2956,7 +2976,7 @@ export class ClineProvider const subtaskUiMessage: ClineMessage = { type: "say", say: "subtask_result", - text: completionResultSummary, + text: effectiveSummary, ts, } parentClineMessages.push(subtaskUiMessage) @@ -2989,7 +3009,7 @@ export class ClineProvider for (const block of lastMsg.content) { if (block.type === "tool_result" && block.tool_use_id === toolUseId) { // Update the existing tool_result content - block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` + block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${effectiveSummary}` alreadyHasToolResult = true break } @@ -3004,7 +3024,7 @@ export class ClineProvider { type: "tool_result" as const, tool_use_id: toolUseId, - content: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, + content: `Subtask ${childTaskId} completed.\n\nResult:\n${effectiveSummary}`, }, ], ts, @@ -3027,7 +3047,7 @@ export class ClineProvider content: [ { type: "text" as const, - text: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, + text: `Subtask ${childTaskId} completed.\n\nResult:\n${effectiveSummary}`, }, ], ts, @@ -3069,7 +3089,7 @@ export class ClineProvider ...historyItem, status: "active", completedByChildId: childTaskId, - completionResultSummary, + completionResultSummary: effectiveSummary, awaitingChildId: undefined, childIds, } @@ -3077,7 +3097,7 @@ export class ClineProvider // 6) Emit TaskDelegationCompleted (provider-level) try { - this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, completionResultSummary) + this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, effectiveSummary) } catch { // non-fatal } @@ -3111,6 +3131,149 @@ export class ClineProvider } } + /** + * Advance the sequential fan-out subtask queue. + * Called when a child completes and the parent has a subtaskQueue. + * + * Returns { handled: true } if the next subtask was started (caller should return). + * Returns { handled: false, aggregatedSummary } if queue is exhausted (caller should continue with normal reopen). + */ + private async advanceSubtaskQueue(params: { + parentTaskId: string + childTaskId: string + completionResultSummary: string + historyItem: HistoryItem + }): Promise<{ handled: true } | { handled: false; aggregatedSummary: string }> { + const { parentTaskId, childTaskId, completionResultSummary, historyItem } = params + const { subtaskQueue, subtaskQueueIndex, subtaskResults } = historyItem + if (!subtaskQueue || subtaskQueue.length === 0) { + return { handled: false, aggregatedSummary: completionResultSummary } + } + + const currentIndex = subtaskQueueIndex ?? 0 + const nextIndex = currentIndex + 1 + + // Record this child's result + const completedMode = historyItem.mode ?? "unknown" + const updatedResults = [ + ...(subtaskResults ?? []), + { taskId: childTaskId, mode: completedMode, summary: completionResultSummary }, + ] + + // Close current child if still open + const current = this.getCurrentTask() + if (current?.taskId === childTaskId) { + await this.removeClineFromStack() + } + + // Mark child as completed + try { + const { historyItem: childHistory } = await this.getTaskWithId(childTaskId) + await this.updateTaskHistory({ ...childHistory, status: "completed" }) + } catch (err) { + this.log( + `[advanceSubtaskQueue] Failed to persist child completed status for ${childTaskId}: ${ + (err as Error)?.message ?? String(err) + }`, + ) + } + + // Emit completion event for the finished child + try { + this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, completionResultSummary) + } catch { + // non-fatal + } + + if (nextIndex <= subtaskQueue.length - 1) { + // More subtasks in queue — start the next one + const nextSubtask = subtaskQueue[nextIndex] + this.log( + `[advanceSubtaskQueue] Auto-advancing queue: subtask ${nextIndex + 1}/${subtaskQueue.length} (mode: ${nextSubtask.mode})`, + ) + + // Switch mode + try { + await this.handleModeSwitch(nextSubtask.mode as any) + } catch (e) { + this.log( + `[advanceSubtaskQueue] handleModeSwitch failed for queued mode '${nextSubtask.mode}': ${ + (e as Error)?.message ?? String(e) + }`, + ) + } + + // Create next child + const nextChild = await this.createTask(nextSubtask.message, undefined, undefined, { + initialTodos: [], + initialStatus: "active", + startTask: false, + }) + + // Update parent metadata + const childIds = Array.from(new Set([...(historyItem.childIds ?? []), childTaskId, nextChild.taskId])) + await this.updateTaskHistory({ + ...historyItem, + status: "delegated", + delegatedToId: nextChild.taskId, + awaitingChildId: nextChild.taskId, + childIds, + subtaskQueue, + subtaskQueueIndex: nextIndex, + subtaskResults: updatedResults, + }) + + // Start the child + nextChild.start() + + try { + this.emit(RooCodeEventName.TaskDelegated, parentTaskId, nextChild.taskId) + } catch { + // non-fatal + } + + return { handled: true } + } + + // Queue exhausted — aggregate results and let normal reopen proceed + const aggregatedSummary = this.formatAggregatedQueueResults(updatedResults, completionResultSummary) + + // Clear queue from parent metadata (will be fully updated by caller) + const childIds = Array.from(new Set([...(historyItem.childIds ?? []), childTaskId])) + await this.updateTaskHistory({ + ...historyItem, + subtaskQueue: undefined, + subtaskQueueIndex: undefined, + subtaskResults: updatedResults, + childIds, + }) + + return { handled: false, aggregatedSummary } + } + + /** + * Format aggregated results from all completed subtasks in a queue. + */ + private formatAggregatedQueueResults( + results: Array<{ taskId: string; mode: string; summary: string }>, + lastSummary: string, + ): string { + if (results.length === 0) { + return lastSummary + } + + const lines = [`## Sequential Fan-Out Complete (${results.length} subtask${results.length > 1 ? "s" : ""})`, ""] + + for (let i = 0; i < results.length; i++) { + const r = results[i] + lines.push(`### Subtask ${i + 1} (${r.mode}) — ${r.taskId}`) + lines.push(r.summary) + lines.push("") + } + + return lines.join("\n") + } + /** * Convert a file path to a webview-accessible URI * This method safely converts file paths to URIs that can be loaded in the webview diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 4cac8335ea7..b85639d0ae6 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -56,6 +56,7 @@ export const toolParamNames = [ "start_line", "end_line", "todos", + "task_queue", "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; task_queue?: 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, "mode" | "message" | "todos">> + params: Partial, "mode" | "message" | "todos" | "task_queue">> } export interface RunSlashCommandToolUse extends ToolUse<"run_slash_command"> { From c7021f5350a956f5229a95012095d881843b5aa5 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 12 May 2026 03:45:30 +0000 Subject: [PATCH 2/3] fix: correct queue advancement off-by-one and child mode tracking in advanceSubtaskQueue - Fix off-by-one: dispatch subtaskQueue[currentIndex] instead of subtaskQueue[nextIndex], preventing the first queued item from being skipped - Fix completedMode: fetch child history to get the child actual mode instead of incorrectly using the parent historyItem.mode - Update tests to reflect corrected queue semantics (subtaskQueueIndex represents the next item to dispatch, not the currently running item) --- src/__tests__/sequential-fan-out.spec.ts | 27 +++++++++++++--------- src/core/webview/ClineProvider.ts | 29 +++++++++++++----------- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/src/__tests__/sequential-fan-out.spec.ts b/src/__tests__/sequential-fan-out.spec.ts index 44d7a4d41e4..792278caee3 100644 --- a/src/__tests__/sequential-fan-out.spec.ts +++ b/src/__tests__/sequential-fan-out.spec.ts @@ -64,7 +64,7 @@ describe("advanceSubtaskQueue", () => { getCurrentTask: vi.fn().mockReturnValue({ taskId: "child-1" }), removeClineFromStack: vi.fn().mockResolvedValue(undefined), getTaskWithId: vi.fn().mockResolvedValue({ - historyItem: makeHistoryItem({ id: "child-1", status: "active" }), + historyItem: makeHistoryItem({ id: "child-1", mode: "code", status: "active" }), }), updateTaskHistory: vi.fn().mockResolvedValue(undefined), handleModeSwitch: vi.fn().mockResolvedValue(undefined), @@ -73,6 +73,8 @@ describe("advanceSubtaskQueue", () => { log: vi.fn(), } + // Queue items represent ADDITIONAL subtasks after the initial child. + // subtaskQueueIndex=0 means queue[0] is the next to dispatch. const subtaskQueue: SubtaskQueueItem[] = [ { mode: "code", message: "Step 1" }, { mode: "debug", message: "Step 2" }, @@ -88,7 +90,7 @@ describe("advanceSubtaskQueue", () => { const result = await (ClineProvider.prototype as any).advanceSubtaskQueue.call(provider, { parentTaskId: "parent-1", childTaskId: "child-1", - completionResultSummary: "Step 1 done", + completionResultSummary: "Initial task done", historyItem, }) @@ -102,11 +104,11 @@ describe("advanceSubtaskQueue", () => { expect.objectContaining({ id: "child-1", status: "completed" }), ) - // Should have switched mode to next subtask's mode - expect(provider.handleModeSwitch).toHaveBeenCalledWith("debug") + // Should have switched mode to queue[0]'s mode (the next item to dispatch) + expect(provider.handleModeSwitch).toHaveBeenCalledWith("code") - // Should have created the next child with the queued message - expect(provider.createTask).toHaveBeenCalledWith("Step 2", undefined, undefined, { + // Should have created the next child with queue[0]'s message + expect(provider.createTask).toHaveBeenCalledWith("Step 1", undefined, undefined, { initialTodos: [], initialStatus: "active", startTask: false, @@ -115,12 +117,13 @@ describe("advanceSubtaskQueue", () => { // Should have started the next child expect(mockChild.start).toHaveBeenCalled() - // Should have updated parent with advanced queue index + // Should have updated parent with advanced queue index (0 -> 1) + // completedMode comes from child's history (mode: "code") expect(provider.updateTaskHistory).toHaveBeenCalledWith( expect.objectContaining({ id: "parent-1", subtaskQueueIndex: 1, - subtaskResults: [{ taskId: "child-1", mode: "unknown", summary: "Step 1 done" }], + subtaskResults: [{ taskId: "child-1", mode: "code", summary: "Initial task done" }], awaitingChildId: "child-2", delegatedToId: "child-2", }), @@ -131,7 +134,7 @@ describe("advanceSubtaskQueue", () => { RooCodeEventName.TaskDelegationCompleted, "parent-1", "child-1", - "Step 1 done", + "Initial task done", ) expect(emitSpy).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-2") }) @@ -141,7 +144,7 @@ describe("advanceSubtaskQueue", () => { getCurrentTask: vi.fn().mockReturnValue({ taskId: "child-2" }), removeClineFromStack: vi.fn().mockResolvedValue(undefined), getTaskWithId: vi.fn().mockResolvedValue({ - historyItem: makeHistoryItem({ id: "child-2", status: "active" }), + historyItem: makeHistoryItem({ id: "child-2", mode: "code", status: "active" }), }), updateTaskHistory: vi.fn().mockResolvedValue(undefined), handleModeSwitch: vi.fn(), @@ -151,11 +154,13 @@ describe("advanceSubtaskQueue", () => { formatAggregatedQueueResults: (ClineProvider.prototype as any).formatAggregatedQueueResults, } + // Queue has 1 item, subtaskQueueIndex=1 means queue[0] was already dispatched. + // Now that child completes and the queue is exhausted. const subtaskQueue: SubtaskQueueItem[] = [{ mode: "code", message: "Step 1" }] const historyItem = makeHistoryItem({ subtaskQueue, - subtaskQueueIndex: 0, + subtaskQueueIndex: 1, subtaskResults: [{ taskId: "child-1", mode: "code", summary: "Step 1 done" }], childIds: ["child-1", "child-2"], }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 3678df3ece8..6bba40986ff 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3150,15 +3150,10 @@ export class ClineProvider return { handled: false, aggregatedSummary: completionResultSummary } } + // currentIndex is the next queue item to dispatch (0-based). + // When the initial child (from mode/message params) completes, currentIndex is 0, + // meaning queue[0] should be dispatched first. const currentIndex = subtaskQueueIndex ?? 0 - const nextIndex = currentIndex + 1 - - // Record this child's result - const completedMode = historyItem.mode ?? "unknown" - const updatedResults = [ - ...(subtaskResults ?? []), - { taskId: childTaskId, mode: completedMode, summary: completionResultSummary }, - ] // Close current child if still open const current = this.getCurrentTask() @@ -3166,9 +3161,11 @@ export class ClineProvider await this.removeClineFromStack() } - // Mark child as completed + // Fetch child history to get the child's actual mode and mark it completed + let completedMode = "unknown" try { const { historyItem: childHistory } = await this.getTaskWithId(childTaskId) + completedMode = childHistory.mode ?? "unknown" await this.updateTaskHistory({ ...childHistory, status: "completed" }) } catch (err) { this.log( @@ -3178,6 +3175,12 @@ export class ClineProvider ) } + // Record this child's result using the child's actual mode + const updatedResults = [ + ...(subtaskResults ?? []), + { taskId: childTaskId, mode: completedMode, summary: completionResultSummary }, + ] + // Emit completion event for the finished child try { this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, completionResultSummary) @@ -3185,11 +3188,11 @@ export class ClineProvider // non-fatal } - if (nextIndex <= subtaskQueue.length - 1) { + if (currentIndex < subtaskQueue.length) { // More subtasks in queue — start the next one - const nextSubtask = subtaskQueue[nextIndex] + const nextSubtask = subtaskQueue[currentIndex] this.log( - `[advanceSubtaskQueue] Auto-advancing queue: subtask ${nextIndex + 1}/${subtaskQueue.length} (mode: ${nextSubtask.mode})`, + `[advanceSubtaskQueue] Auto-advancing queue: subtask ${currentIndex + 1}/${subtaskQueue.length} (mode: ${nextSubtask.mode})`, ) // Switch mode @@ -3219,7 +3222,7 @@ export class ClineProvider awaitingChildId: nextChild.taskId, childIds, subtaskQueue, - subtaskQueueIndex: nextIndex, + subtaskQueueIndex: currentIndex + 1, subtaskResults: updatedResults, }) From 20cd7073af01750d60f71cae4286644519f297d1 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 12 May 2026 04:03:56 +0000 Subject: [PATCH 3/3] fix: add retry logic to copyDir/copyPaths for EBUSY errors on Windows The Windows CI bundle step fails with EBUSY when antivirus or indexing services hold brief locks on files during copyFileSync. Add a copyFileWithRetry helper (matching the existing rmDir retry pattern) that retries up to 5 times with exponential backoff for EBUSY, EPERM, and EACCES errors. --- packages/build/src/esbuild.ts | 40 +++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/build/src/esbuild.ts b/packages/build/src/esbuild.ts index 952e823eeca..c4898015128 100644 --- a/packages/build/src/esbuild.ts +++ b/packages/build/src/esbuild.ts @@ -4,6 +4,42 @@ import { execSync } from "child_process" import { ViewsContainer, Views, Menus, Configuration, Keybindings, contributesSchema } from "./types.js" +/** + * Copy a single file with retry logic to handle transient Windows file-locking + * errors (EBUSY, EPERM, EACCES) that occur when antivirus or indexing services + * hold brief locks on files during CI builds. + */ +function copyFileWithRetry(src: string, dst: string, maxRetries: number = 5): void { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + fs.copyFileSync(src, dst) + return + } catch (error) { + const isRetryable = + error instanceof Error && + "code" in error && + ((error as NodeJS.ErrnoException).code === "EBUSY" || + (error as NodeJS.ErrnoException).code === "EPERM" || + (error as NodeJS.ErrnoException).code === "EACCES") + + if (!isRetryable || attempt === maxRetries) { + throw error + } + + const baseDelay = process.platform === "win32" ? 200 : 100 + const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), 2000) + console.warn(`[copyFileWithRetry] Attempt ${attempt} failed for ${src}, retrying in ${delay}ms...`) + + // Synchronous sleep (same pattern as rmDir). + const start = Date.now() + + while (Date.now() - start < delay) { + /* Busy wait */ + } + } + } +} + function copyDir(srcDir: string, dstDir: string, count: number): number { const entries = fs.readdirSync(srcDir, { withFileTypes: true }) @@ -16,7 +52,7 @@ function copyDir(srcDir: string, dstDir: string, count: number): number { count = copyDir(srcPath, dstPath, count) } else { count = count + 1 - fs.copyFileSync(srcPath, dstPath) + copyFileWithRetry(srcPath, dstPath) } } @@ -98,7 +134,7 @@ export function copyPaths(copyPaths: [string, string, CopyPathOptions?][], srcDi const count = copyDir(path.join(srcDir, srcRelPath), path.join(dstDir, dstRelPath), 0) console.log(`[copyPaths] Copied ${count} files from ${srcRelPath} to ${dstRelPath}`) } else { - fs.copyFileSync(path.join(srcDir, srcRelPath), path.join(dstDir, dstRelPath)) + copyFileWithRetry(path.join(srcDir, srcRelPath), path.join(dstDir, dstRelPath)) console.log(`[copyPaths] Copied ${srcRelPath} to ${dstRelPath}`) } } catch (error) {