Skip to content

Commit 0cd7dea

Browse files
committed
feat(TaskScheduler): fan-out — parent stays active while child runs
1 parent 1ae8b5b commit 0cd7dea

10 files changed

Lines changed: 601 additions & 29 deletions

File tree

apps/vscode-e2e/src/fixtures/subtasks.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,19 @@ const SUBTASK_FAST_CHILD_MARKER = "SUBTASK_CHILD_IMMEDIATE_COMPLETION"
1414
const SUBTASK_XPROFILE_PARENT_MARKER = "SUBTASK_PARENT_CROSS_PROFILE"
1515
const SUBTASK_XPROFILE_SAME_CHILD_MARKER = "SUBTASK_CHILD_SAME_PROFILE"
1616
const SUBTASK_XPROFILE_DIFFERENT_CHILD_MARKER = "SUBTASK_CHILD_DIFFERENT_PROFILE"
17+
const SUBTASK_FANOUT_PARENT_MARKER = "SUBTASK_PARENT_FANOUT_CONCURRENT"
18+
const SUBTASK_FANOUT_CHILD_MARKER = "SUBTASK_CHILD_FANOUT_CONCURRENT"
1719

1820
const SUBTASK_CHILD_PROMPT = `${SUBTASK_CHILD_MARKER}: Ask the user exactly this follow-up question: What is the square root of 81? After the user answers, complete with only the answer.`
1921
export const SUBTASK_PARENT_PROMPT = `${SUBTASK_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_CHILD_PROMPT}" Do not answer directly.`
2022
export const SUBTASK_CHILD_FOLLOWUP_ANSWER = "9"
2123
export const SUBTASK_FAST_CHILD_RESULT = "Fast child completed"
2224
const SUBTASK_FAST_CHILD_PROMPT = `${SUBTASK_FAST_CHILD_MARKER}: Complete immediately with the exact result "${SUBTASK_FAST_CHILD_RESULT}".`
2325
export const SUBTASK_FAST_PARENT_PROMPT = `${SUBTASK_FAST_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_FAST_CHILD_PROMPT}" Do not answer directly.`
26+
export const SUBTASK_FANOUT_PARENT_FOLLOWUP = "Parent fan-out is still active?"
27+
export const SUBTASK_FANOUT_CHILD_RESULT = "Fan-out child completed"
28+
const SUBTASK_FANOUT_CHILD_PROMPT = `${SUBTASK_FANOUT_CHILD_MARKER}: Complete with the exact result "${SUBTASK_FANOUT_CHILD_RESULT}".`
29+
export const SUBTASK_FANOUT_PARENT_PROMPT = `${SUBTASK_FANOUT_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_FANOUT_CHILD_PROMPT}" After delegation, ask the user exactly this follow-up question: ${SUBTASK_FANOUT_PARENT_FOLLOWUP}`
2430

2531
const SUBTASK_INTERRUPT_CHILD_PROMPT = `${SUBTASK_INTERRUPT_CHILD_MARKER}: Ask the user exactly this follow-up question: What is the square root of 81? After the user answers, complete with only the answer.`
2632
export const SUBTASK_INTERRUPT_PARENT_PROMPT = `${SUBTASK_INTERRUPT_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_INTERRUPT_CHILD_PROMPT}" Do not answer directly. When the subtask returns, complete with the exact result "Interrupted parent resumed".`
@@ -135,6 +141,63 @@ export function addSubtaskFixtures(mock: InstanceType<typeof LLMock>) {
135141
},
136142
})
137143

144+
mock.addFixture({
145+
match: {
146+
userMessage: new RegExp(SUBTASK_FANOUT_PARENT_MARKER),
147+
sequenceIndex: 0,
148+
},
149+
response: {
150+
toolCalls: [
151+
{
152+
name: "new_task",
153+
arguments: JSON.stringify({
154+
mode: "ask",
155+
message: SUBTASK_FANOUT_CHILD_PROMPT,
156+
}),
157+
id: "call_subtasks_fanout_parent_new_task_001",
158+
},
159+
],
160+
},
161+
})
162+
163+
mock.addFixture({
164+
match: {
165+
predicate: (req: ChatCompletionRequest) =>
166+
lastUserMessageContains(req, SUBTASK_FANOUT_CHILD_MARKER) &&
167+
!requestContains(req, [SUBTASK_FANOUT_PARENT_MARKER]),
168+
},
169+
latency: 15_000,
170+
response: {
171+
toolCalls: [
172+
{
173+
name: "attempt_completion",
174+
arguments: JSON.stringify({ result: SUBTASK_FANOUT_CHILD_RESULT }),
175+
id: "call_subtasks_fanout_child_completion_002",
176+
},
177+
],
178+
},
179+
})
180+
181+
mock.addFixture({
182+
match: {
183+
predicate: (req: ChatCompletionRequest) =>
184+
requestContains(req, [SUBTASK_FANOUT_PARENT_MARKER, "Delegated to child task"]) &&
185+
!requestContains(req, [SUBTASK_RESULT_INJECTION]),
186+
},
187+
response: {
188+
toolCalls: [
189+
{
190+
name: "ask_followup_question",
191+
arguments: JSON.stringify({
192+
question: SUBTASK_FANOUT_PARENT_FOLLOWUP,
193+
follow_up: [{ text: "continue" }],
194+
}),
195+
id: "call_subtasks_fanout_parent_followup_003",
196+
},
197+
],
198+
},
199+
})
200+
138201
// The parent prompt embeds SUBTASK_FAST_CHILD_MARKER verbatim, so parent-resume turns
139202
// can also match a bare substring check (same collision class as #561). Exclude the
140203
// parent marker so those turns fall through to the parent-resume fixture below.

apps/vscode-e2e/src/suite/subtasks.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
SUBTASK_API_HANG_RESUME_MESSAGE,
2020
SUBTASK_CHILD_FOLLOWUP_ANSWER,
2121
SUBTASK_FAST_CHILD_RESULT,
22+
SUBTASK_FANOUT_PARENT_FOLLOWUP,
23+
SUBTASK_FANOUT_PARENT_PROMPT,
2224
SUBTASK_FAST_PARENT_PROMPT,
2325
SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER,
2426
SUBTASK_INTERRUPT_PARENT_PROMPT,
@@ -129,6 +131,64 @@ suite("Roo Code Subtasks", function () {
129131
}
130132
})
131133

134+
test("fan-out keeps parent executing while child request is in flight", async () => {
135+
const api = globalThis.api
136+
const asks: Record<string, ClineMessage[]> = {}
137+
138+
const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => {
139+
if (message.type === "ask") {
140+
asks[taskId] = asks[taskId] || []
141+
asks[taskId].push(message)
142+
}
143+
}
144+
145+
api.on(RooCodeEventName.Message, messageHandler)
146+
147+
try {
148+
api.setTaskSchedulerMaxConcurrency(2)
149+
150+
const parentTaskId = await api.startNewTask({
151+
configuration: {
152+
mode: "ask",
153+
alwaysAllowModeSwitch: true,
154+
alwaysAllowSubtasks: true,
155+
autoApprovalEnabled: true,
156+
enableCheckpoints: false,
157+
},
158+
text: SUBTASK_FANOUT_PARENT_PROMPT,
159+
})
160+
161+
let childTaskId: string | undefined
162+
await waitFor(() => {
163+
const stack = api.getCurrentTaskStack()
164+
const current = stack.at(-1)
165+
if (current && current !== parentTaskId) {
166+
childTaskId = current
167+
return stack.includes(parentTaskId)
168+
}
169+
return false
170+
})
171+
172+
await waitFor(() =>
173+
(asks[parentTaskId] ?? []).some(
174+
({ ask, text }) => ask === "followup" && text?.includes(SUBTASK_FANOUT_PARENT_FOLLOWUP),
175+
),
176+
)
177+
178+
const stack = api.getCurrentTaskStack()
179+
assert.ok(stack.includes(parentTaskId), "Fan-out parent should remain in the live task stack")
180+
assert.ok(stack.includes(childTaskId!), "Fan-out child should remain in the live task stack")
181+
assert.strictEqual(stack.at(-1), childTaskId, "Child should remain the focused task while parent runs")
182+
} finally {
183+
api.off(RooCodeEventName.Message, messageHandler)
184+
while (api.getCurrentTaskStack().length > 0) {
185+
await api.clearCurrentTask()
186+
}
187+
api.setTaskSchedulerMaxConcurrency(1)
188+
await waitFor(() => api.getCurrentTaskStack().length === 0).catch(() => {})
189+
}
190+
})
191+
132192
// Smoke: child completing normally must resume the parent task.
133193
test("child task returns to parent after normal completion", async () => {
134194
const api = globalThis.api

packages/types/src/api.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ export interface RooCodeAPI extends EventEmitter<RooCodeAPIEvents> {
5656
* @returns An array of task IDs.
5757
*/
5858
getCurrentTaskStack(): string[]
59+
/**
60+
* Sets the TaskScheduler concurrency for extension-host tests.
61+
* Intended for test/integration harnesses that need to exercise fan-out.
62+
*/
63+
setTaskSchedulerMaxConcurrency(maxConcurrency: number): void
5964
/**
6065
* Clears the current task.
6166
*/

src/__tests__/provider-delegation.spec.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,9 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
369369
isViewLaunched: false,
370370
recentTasksCache: undefined,
371371
taskHistoryStore,
372+
// Rollback looks up the just-created child by id to decide whether it
373+
// still needs evicting, independent of current focus.
374+
taskRegistry: { getById: vi.fn((id: string) => (id === "child-1" ? child : undefined)) },
372375
} as unknown as ClineProvider
373376

374377
await expect(
@@ -381,8 +384,11 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
381384
).rejects.toThrow(persistError)
382385

383386
expect(childRun).not.toHaveBeenCalled()
387+
// 1st call (step 3): closes the parent to enforce the single-open invariant.
384388
expect(removeClineFromStack).toHaveBeenNthCalledWith(1)
385-
expect(removeClineFromStack).toHaveBeenNthCalledWith(2)
389+
// 2nd call (rollback): evicts the just-created child by id, regardless of
390+
// current focus — see Story 3.2b fan-out rollback fix.
391+
expect(removeClineFromStack).toHaveBeenNthCalledWith(2, "child-1")
386392
expect(deleteTaskWithId).toHaveBeenCalledWith("child-1", false)
387393
expect(createTaskWithHistoryItem).toHaveBeenCalledWith(parentHistoryItem)
388394
})

src/core/task/TaskScheduler.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,37 @@ import { type Task } from "./Task"
1010
*/
1111
export class TaskScheduler {
1212
private readonly sem: TaskSemaphore
13+
readonly maxConcurrency: number
1314

1415
constructor(maxConcurrency = 1) {
16+
this.maxConcurrency = maxConcurrency
1517
this.sem = new TaskSemaphore(maxConcurrency)
1618
}
1719

1820
get waiting(): number {
1921
return this.sem.waiting
2022
}
2123

24+
/** Number of permits not currently held by a running task. */
25+
get available(): number {
26+
return this.sem.available
27+
}
28+
29+
/**
30+
* Reserve a permit only if one is immediately free, without queueing.
31+
* Returns a release function on success, or `undefined` if none was free.
32+
*
33+
* Use this (not `available > 0` followed later by `schedule()`) when a
34+
* caller needs to make an irreversible decision — e.g. keeping a parent
35+
* task alive for fan-out — based on whether a child can actually run
36+
* concurrently. Checking `available` and then `await`-ing other work
37+
* before calling `schedule()` leaves a window where another caller can
38+
* consume the last permit; reserving it immediately closes that window.
39+
*/
40+
async tryReserve(): Promise<(() => void) | undefined> {
41+
return this.sem.tryAcquire()
42+
}
43+
2244
/**
2345
* Acquire a permit for `task`, call `run()`, and release on completion.
2446
*
@@ -30,7 +52,19 @@ export class TaskScheduler {
3052
* without calling `run()`.
3153
*/
3254
async schedule(task: Task, run: () => Promise<void>): Promise<void> {
33-
const release = await this.sem.acquire()
55+
return this.runWithRelease(await this.sem.acquire(), task, run)
56+
}
57+
58+
/**
59+
* Run `task` using a permit already obtained via `tryReserve()`, instead of
60+
* acquiring a new one. Same abort/abandon and release-on-completion
61+
* semantics as `schedule()`.
62+
*/
63+
async runWithReservation(release: () => void, task: Task, run: () => Promise<void>): Promise<void> {
64+
return this.runWithRelease(release, task, run)
65+
}
66+
67+
private async runWithRelease(release: () => void, task: Task, run: () => Promise<void>): Promise<void> {
3468
if (task.abort || task.abandoned) {
3569
release()
3670
return

0 commit comments

Comments
 (0)