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

Commit 35f04e6

Browse files
committed
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
1 parent 8922418 commit 35f04e6

7 files changed

Lines changed: 501 additions & 14 deletions

File tree

packages/types/src/history.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
import { z } from "zod"
22

3+
/**
4+
* SubtaskQueueItem — a single queued subtask definition for sequential fan-out.
5+
* Used by the orchestrator to define a pipeline of subtasks that execute one after another.
6+
*/
7+
export const subtaskQueueItemSchema = z.object({
8+
mode: z.string(),
9+
message: z.string(),
10+
})
11+
12+
export type SubtaskQueueItem = z.infer<typeof subtaskQueueItemSchema>
13+
14+
/**
15+
* SubtaskResult — the result of a completed subtask in a queue.
16+
*/
17+
export const subtaskResultSchema = z.object({
18+
taskId: z.string(),
19+
mode: z.string(),
20+
summary: z.string(),
21+
})
22+
23+
export type SubtaskResult = z.infer<typeof subtaskResultSchema>
24+
325
/**
426
* HistoryItem
527
*/
@@ -26,6 +48,10 @@ export const historyItemSchema = z.object({
2648
awaitingChildId: z.string().optional(), // Child currently awaited (set when delegated)
2749
completedByChildId: z.string().optional(), // Child that completed and resumed this parent
2850
completionResultSummary: z.string().optional(), // Summary from completed child
51+
// Sequential fan-out queue (Phase 2)
52+
subtaskQueue: z.array(subtaskQueueItemSchema).optional(), // Remaining subtasks to execute
53+
subtaskQueueIndex: z.number().optional(), // Current position in the original queue (0-based)
54+
subtaskResults: z.array(subtaskResultSchema).optional(), // Results from completed queue subtasks
2955
})
3056

3157
export type HistoryItem = z.infer<typeof historyItemSchema>
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
/**
2+
* Tests for Phase 2: Sequential fan-out / fan-in.
3+
*
4+
* Tests the subtask queue mechanism where an orchestrator can define
5+
* multiple subtasks that execute one after another with automatic transitions.
6+
*/
7+
8+
import { describe, it, expect, vi } from "vitest"
9+
import { RooCodeEventName } from "@roo-code/types"
10+
import type { HistoryItem, SubtaskQueueItem } from "@roo-code/types"
11+
12+
import { ClineProvider } from "../core/webview/ClineProvider"
13+
14+
describe("Sequential fan-out queue types", () => {
15+
it("SubtaskQueueItem has required mode and message fields", () => {
16+
const item: SubtaskQueueItem = { mode: "code", message: "Implement feature X" }
17+
expect(item.mode).toBe("code")
18+
expect(item.message).toBe("Implement feature X")
19+
})
20+
21+
it("HistoryItem can include subtask queue fields", () => {
22+
const historyItem: Partial<HistoryItem> = {
23+
id: "test-1",
24+
subtaskQueue: [
25+
{ mode: "code", message: "Step 1" },
26+
{ mode: "debug", message: "Step 2" },
27+
],
28+
subtaskQueueIndex: 0,
29+
subtaskResults: [{ taskId: "child-1", mode: "code", summary: "Done" }],
30+
}
31+
expect(historyItem.subtaskQueue).toHaveLength(2)
32+
expect(historyItem.subtaskQueueIndex).toBe(0)
33+
expect(historyItem.subtaskResults).toHaveLength(1)
34+
})
35+
36+
it("HistoryItem subtask queue fields are optional", () => {
37+
const historyItem: Partial<HistoryItem> = {
38+
id: "test-2",
39+
status: "active",
40+
}
41+
expect(historyItem.subtaskQueue).toBeUndefined()
42+
expect(historyItem.subtaskQueueIndex).toBeUndefined()
43+
expect(historyItem.subtaskResults).toBeUndefined()
44+
})
45+
})
46+
47+
describe("advanceSubtaskQueue", () => {
48+
const makeHistoryItem = (overrides: Partial<HistoryItem> = {}): HistoryItem => ({
49+
id: "parent-1",
50+
number: 1,
51+
ts: Date.now(),
52+
task: "Test task",
53+
tokensIn: 0,
54+
tokensOut: 0,
55+
totalCost: 0,
56+
status: "delegated",
57+
...overrides,
58+
})
59+
60+
it("returns handled=true when there are more subtasks in the queue", async () => {
61+
const emitSpy = vi.fn()
62+
const mockChild = { taskId: "child-2", start: vi.fn() }
63+
const provider = {
64+
getCurrentTask: vi.fn().mockReturnValue({ taskId: "child-1" }),
65+
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
66+
getTaskWithId: vi.fn().mockResolvedValue({
67+
historyItem: makeHistoryItem({ id: "child-1", status: "active" }),
68+
}),
69+
updateTaskHistory: vi.fn().mockResolvedValue(undefined),
70+
handleModeSwitch: vi.fn().mockResolvedValue(undefined),
71+
createTask: vi.fn().mockResolvedValue(mockChild),
72+
emit: emitSpy,
73+
log: vi.fn(),
74+
}
75+
76+
const subtaskQueue: SubtaskQueueItem[] = [
77+
{ mode: "code", message: "Step 1" },
78+
{ mode: "debug", message: "Step 2" },
79+
]
80+
81+
const historyItem = makeHistoryItem({
82+
subtaskQueue,
83+
subtaskQueueIndex: 0,
84+
subtaskResults: [],
85+
childIds: ["child-1"],
86+
})
87+
88+
const result = await (ClineProvider.prototype as any).advanceSubtaskQueue.call(provider, {
89+
parentTaskId: "parent-1",
90+
childTaskId: "child-1",
91+
completionResultSummary: "Step 1 done",
92+
historyItem,
93+
})
94+
95+
expect(result.handled).toBe(true)
96+
97+
// Should have closed the current child
98+
expect(provider.removeClineFromStack).toHaveBeenCalled()
99+
100+
// Should have marked child as completed
101+
expect(provider.updateTaskHistory).toHaveBeenCalledWith(
102+
expect.objectContaining({ id: "child-1", status: "completed" }),
103+
)
104+
105+
// Should have switched mode to next subtask's mode
106+
expect(provider.handleModeSwitch).toHaveBeenCalledWith("debug")
107+
108+
// Should have created the next child with the queued message
109+
expect(provider.createTask).toHaveBeenCalledWith("Step 2", undefined, undefined, {
110+
initialTodos: [],
111+
initialStatus: "active",
112+
startTask: false,
113+
})
114+
115+
// Should have started the next child
116+
expect(mockChild.start).toHaveBeenCalled()
117+
118+
// Should have updated parent with advanced queue index
119+
expect(provider.updateTaskHistory).toHaveBeenCalledWith(
120+
expect.objectContaining({
121+
id: "parent-1",
122+
subtaskQueueIndex: 1,
123+
subtaskResults: [{ taskId: "child-1", mode: "unknown", summary: "Step 1 done" }],
124+
awaitingChildId: "child-2",
125+
delegatedToId: "child-2",
126+
}),
127+
)
128+
129+
// Should have emitted delegation events
130+
expect(emitSpy).toHaveBeenCalledWith(
131+
RooCodeEventName.TaskDelegationCompleted,
132+
"parent-1",
133+
"child-1",
134+
"Step 1 done",
135+
)
136+
expect(emitSpy).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-2")
137+
})
138+
139+
it("returns handled=false with aggregated summary when queue is exhausted", async () => {
140+
const provider = {
141+
getCurrentTask: vi.fn().mockReturnValue({ taskId: "child-2" }),
142+
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
143+
getTaskWithId: vi.fn().mockResolvedValue({
144+
historyItem: makeHistoryItem({ id: "child-2", status: "active" }),
145+
}),
146+
updateTaskHistory: vi.fn().mockResolvedValue(undefined),
147+
handleModeSwitch: vi.fn(),
148+
createTask: vi.fn(),
149+
emit: vi.fn(),
150+
log: vi.fn(),
151+
formatAggregatedQueueResults: (ClineProvider.prototype as any).formatAggregatedQueueResults,
152+
}
153+
154+
const subtaskQueue: SubtaskQueueItem[] = [{ mode: "code", message: "Step 1" }]
155+
156+
const historyItem = makeHistoryItem({
157+
subtaskQueue,
158+
subtaskQueueIndex: 0,
159+
subtaskResults: [{ taskId: "child-1", mode: "code", summary: "Step 1 done" }],
160+
childIds: ["child-1", "child-2"],
161+
})
162+
163+
const result = await (ClineProvider.prototype as any).advanceSubtaskQueue.call(provider, {
164+
parentTaskId: "parent-1",
165+
childTaskId: "child-2",
166+
completionResultSummary: "Step 2 done",
167+
historyItem,
168+
})
169+
170+
expect(result.handled).toBe(false)
171+
expect(result.aggregatedSummary).toContain("Sequential Fan-Out Complete")
172+
expect(result.aggregatedSummary).toContain("Step 1 done")
173+
expect(result.aggregatedSummary).toContain("Step 2 done")
174+
175+
// Should NOT have created a new child
176+
expect(provider.createTask).not.toHaveBeenCalled()
177+
178+
// Should have cleared queue from parent metadata
179+
expect(provider.updateTaskHistory).toHaveBeenCalledWith(
180+
expect.objectContaining({
181+
subtaskQueue: undefined,
182+
subtaskQueueIndex: undefined,
183+
}),
184+
)
185+
})
186+
187+
it("returns handled=false immediately when queue is empty", async () => {
188+
const provider = {
189+
getCurrentTask: vi.fn(),
190+
removeClineFromStack: vi.fn(),
191+
getTaskWithId: vi.fn(),
192+
updateTaskHistory: vi.fn(),
193+
emit: vi.fn(),
194+
log: vi.fn(),
195+
formatAggregatedQueueResults: (ClineProvider.prototype as any).formatAggregatedQueueResults,
196+
}
197+
198+
const historyItem = makeHistoryItem({
199+
subtaskQueue: [],
200+
subtaskQueueIndex: 0,
201+
})
202+
203+
const result = await (ClineProvider.prototype as any).advanceSubtaskQueue.call(provider, {
204+
parentTaskId: "parent-1",
205+
childTaskId: "child-1",
206+
completionResultSummary: "Done",
207+
historyItem,
208+
})
209+
210+
expect(result.handled).toBe(false)
211+
expect(result.aggregatedSummary).toBe("Done")
212+
})
213+
})
214+
215+
describe("formatAggregatedQueueResults", () => {
216+
it("formats multiple results into a structured summary", () => {
217+
const results = [
218+
{ taskId: "child-1", mode: "code", summary: "Implemented feature X" },
219+
{ taskId: "child-2", mode: "debug", summary: "Fixed bugs in feature X" },
220+
]
221+
222+
const formatted = (ClineProvider.prototype as any).formatAggregatedQueueResults(results, "Final result")
223+
224+
expect(formatted).toContain("Sequential Fan-Out Complete (2 subtasks)")
225+
expect(formatted).toContain("Subtask 1 (code)")
226+
expect(formatted).toContain("Implemented feature X")
227+
expect(formatted).toContain("Subtask 2 (debug)")
228+
expect(formatted).toContain("Fixed bugs in feature X")
229+
})
230+
231+
it("returns last summary when results array is empty", () => {
232+
const formatted = (ClineProvider.prototype as any).formatAggregatedQueueResults([], "Just a summary")
233+
expect(formatted).toBe("Just a summary")
234+
})
235+
236+
it("handles single result", () => {
237+
const results = [{ taskId: "child-1", mode: "code", summary: "Done" }]
238+
const formatted = (ClineProvider.prototype as any).formatAggregatedQueueResults(results, "Done")
239+
expect(formatted).toContain("Sequential Fan-Out Complete (1 subtask)")
240+
expect(formatted).toContain("Subtask 1 (code)")
241+
})
242+
})

src/core/assistant-message/NativeToolCallParser.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,7 @@ export class NativeToolCallParser {
633633
mode: partialArgs.mode,
634634
message: partialArgs.message,
635635
todos: partialArgs.todos,
636+
task_queue: partialArgs.task_queue,
636637
}
637638
}
638639
break
@@ -982,6 +983,7 @@ export class NativeToolCallParser {
982983
mode: args.mode,
983984
message: args.message,
984985
todos: args.todos,
986+
task_queue: args.task_queue,
985987
} as NativeArgsFor<TName>
986988
}
987989
break

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@ import type OpenAI from "openai"
22

33
const NEW_TASK_DESCRIPTION = `Create a new task instance in the chosen mode using your provided message and initial todo list (if required).
44
5-
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.`
5+
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.
6+
7+
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.`
68

79
const MODE_PARAMETER_DESCRIPTION = `Slug of the mode to begin the new task in (e.g., code, debug, architect)`
810

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

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

15+
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.`
16+
1317
export default {
1418
type: "function",
1519
function: {
@@ -31,6 +35,10 @@ export default {
3135
type: ["string", "null"],
3236
description: TODOS_PARAMETER_DESCRIPTION,
3337
},
38+
task_queue: {
39+
type: ["string", "null"],
40+
description: TASK_QUEUE_PARAMETER_DESCRIPTION,
41+
},
3442
},
3543
required: ["mode", "message", "todos"],
3644
additionalProperties: false,

0 commit comments

Comments
 (0)