Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.
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
40 changes: 38 additions & 2 deletions packages/build/src/esbuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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) {
Expand Down
26 changes: 26 additions & 0 deletions packages/types/src/history.ts
Original file line number Diff line number Diff line change
@@ -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<typeof subtaskQueueItemSchema>

/**
* 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<typeof subtaskResultSchema>

/**
* HistoryItem
*/
Expand All @@ -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<typeof historyItemSchema>
247 changes: 247 additions & 0 deletions src/__tests__/sequential-fan-out.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
/**
* 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<HistoryItem> = {
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<HistoryItem> = {
id: "test-2",
status: "active",
}
expect(historyItem.subtaskQueue).toBeUndefined()
expect(historyItem.subtaskQueueIndex).toBeUndefined()
expect(historyItem.subtaskResults).toBeUndefined()
})
})

describe("advanceSubtaskQueue", () => {
const makeHistoryItem = (overrides: Partial<HistoryItem> = {}): 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", mode: "code", status: "active" }),
}),
updateTaskHistory: vi.fn().mockResolvedValue(undefined),
handleModeSwitch: vi.fn().mockResolvedValue(undefined),
createTask: vi.fn().mockResolvedValue(mockChild),
emit: emitSpy,
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" },
]

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: "Initial task 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 queue[0]'s mode (the next item to dispatch)
expect(provider.handleModeSwitch).toHaveBeenCalledWith("code")

// Should have created the next child with queue[0]'s message
expect(provider.createTask).toHaveBeenCalledWith("Step 1", 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 (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: "code", summary: "Initial task done" }],
awaitingChildId: "child-2",
delegatedToId: "child-2",
}),
)

// Should have emitted delegation events
expect(emitSpy).toHaveBeenCalledWith(
RooCodeEventName.TaskDelegationCompleted,
"parent-1",
"child-1",
"Initial task 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", mode: "code", 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,
}

// 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: 1,
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)")
})
})
2 changes: 2 additions & 0 deletions src/core/assistant-message/NativeToolCallParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,7 @@ export class NativeToolCallParser {
mode: partialArgs.mode,
message: partialArgs.message,
todos: partialArgs.todos,
task_queue: partialArgs.task_queue,
}
}
break
Expand Down Expand Up @@ -982,6 +983,7 @@ export class NativeToolCallParser {
mode: args.mode,
message: args.message,
todos: args.todos,
task_queue: args.task_queue,
} as NativeArgsFor<TName>
}
break
Expand Down
10 changes: 9 additions & 1 deletion src/core/prompts/tools/native-tools/new_task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ 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)`

const MESSAGE_PARAMETER_DESCRIPTION = `Initial user instructions or context for the new task`

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: {
Expand All @@ -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,
Expand Down
Loading
Loading