feat(swarm-p1): replace clineStack with tasks Map + concurrent spawn path

Replace the LIFO `clineStack: Task[]` with a flat `tasks: Map<string, Task>`
(+ `focusedTaskId`, `leaderTaskId`, `childCompletionHandlers`) so multiple
tasks can be registered simultaneously — the prerequisite for true concurrent
fan-out.

Key changes:
- ClineProvider: swap all 19 clineStack usages; add `getTaskById()`,
  `spawnConcurrentChildren()`, `resolveChildCompletion()`
- SpawnParallelTasksTool: add `concurrent?: boolean` flag; concurrent path
  awaits Promise.all via spawnConcurrentChildren instead of queue drain
- AttemptCompletionTool: detect concurrent vs sequential by checking whether
  parent is still alive in the tasks map; route to resolveChildCompletion
  (concurrent) or reopenParentFromDelegation (sequential)
- Tests: update three spec files that accessed clineStack directly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
davincidreams 2026-04-25 03:05:14 -04:00
parent 3b3fe80ae1
commit 862d263cb8
7 changed files with 387 additions and 165 deletions

View file

@ -5,7 +5,7 @@ import { ClineProvider } from "../core/webview/ClineProvider"
describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
/**
* Helper to build a minimal mock provider with a single task on the stack.
* Helper to build a minimal mock provider with a single task in the map.
* The task's parentTaskId and taskId are configurable.
*/
function buildMockProvider(opts: {
@ -33,7 +33,9 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
})
const provider = {
clineStack: [childTask] as any[],
tasks: new Map([[opts.childTaskId, childTask as any]]),
focusedTaskId: opts.childTaskId as string | undefined,
leaderTaskId: opts.childTaskId as string | undefined,
taskEventListeners: new Map(),
log: vi.fn(),
getTaskWithId,
@ -64,8 +66,8 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
await (ClineProvider.prototype as any).removeClineFromStack.call(provider)
// Stack should be empty after pop
expect(provider.clineStack).toHaveLength(0)
// Map should be empty after removal
expect(provider.tasks.size).toBe(0)
// Parent lookup should have been called
expect(getTaskWithId).toHaveBeenCalledWith("parent-1")
@ -93,8 +95,8 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
await (ClineProvider.prototype as any).removeClineFromStack.call(provider)
// Stack should be empty
expect(provider.clineStack).toHaveLength(0)
// Map should be empty
expect(provider.tasks.size).toBe(0)
// No parent lookup or update should happen
expect(getTaskWithId).not.toHaveBeenCalled()
@ -161,8 +163,8 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
// Should NOT throw
await (ClineProvider.prototype as any).removeClineFromStack.call(provider)
// Stack should still be empty (pop was not blocked)
expect(provider.clineStack).toHaveLength(0)
// Map should still be empty (removal was not blocked)
expect(provider.tasks.size).toBe(0)
// The abort should still have been called
expect(childTask.abortTask).toHaveBeenCalledWith(true)
@ -176,9 +178,11 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
expect(updateTaskHistory).not.toHaveBeenCalled()
})
it("handles empty stack gracefully", async () => {
it("handles empty map gracefully", async () => {
const provider = {
clineStack: [] as any[],
tasks: new Map() as Map<string, any>,
focusedTaskId: undefined as string | undefined,
leaderTaskId: undefined as string | undefined,
taskEventListeners: new Map(),
log: vi.fn(),
getTaskWithId: vi.fn(),
@ -188,7 +192,7 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
// Should not throw
await (ClineProvider.prototype as any).removeClineFromStack.call(provider)
expect(provider.clineStack).toHaveLength(0)
expect(provider.tasks.size).toBe(0)
expect(provider.getTaskWithId).not.toHaveBeenCalled()
expect(provider.updateTaskHistory).not.toHaveBeenCalled()
})
@ -215,8 +219,8 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
// Call with skipDelegationRepair: true (as delegateParentAndOpenChild would)
await (ClineProvider.prototype as any).removeClineFromStack.call(provider, { skipDelegationRepair: true })
// Stack should be empty after pop
expect(provider.clineStack).toHaveLength(0)
// Map should be empty after removal
expect(provider.tasks.size).toBe(0)
// Parent lookup should NOT have been called — repair was skipped entirely
expect(getTaskWithId).not.toHaveBeenCalled()
@ -258,7 +262,9 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
const updateTaskHistory = vi.fn().mockResolvedValue([])
const provider = {
clineStack: [taskB] as any[],
tasks: new Map([["task-B", taskB as any]]),
focusedTaskId: "task-B" as string | undefined,
leaderTaskId: "task-B" as string | undefined,
taskEventListeners: new Map(),
log: vi.fn(),
getTaskWithId,
@ -268,8 +274,8 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
// Simulate what delegateParentAndOpenChild does: pop B with skipDelegationRepair
await (ClineProvider.prototype as any).removeClineFromStack.call(provider, { skipDelegationRepair: true })
// B was popped
expect(provider.clineStack).toHaveLength(0)
// B was removed
expect(provider.tasks.size).toBe(0)
// Grandparent A should NOT have been looked up or modified
expect(getTaskWithId).not.toHaveBeenCalled()

View file

@ -40,8 +40,10 @@ describe("Single-open-task invariant", () => {
const addClineToStack = vi.fn().mockResolvedValue(undefined)
const provider = {
// Simulate an existing task present in stack
clineStack: [{ taskId: "existing-1" }],
// Simulate an existing task present in the map
tasks: new Map([["existing-1", { taskId: "existing-1" }]]),
focusedTaskId: "existing-1",
leaderTaskId: "existing-1",
setValues: vi.fn(),
getState: vi.fn().mockResolvedValue({
apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 },

View file

@ -33,6 +33,15 @@ interface DelegationProvider {
completionPayload?: Record<string, unknown>
childFailed?: boolean
}): Promise<void>
/** Returns the live Task instance if it is still registered (concurrent mode check). */
getTaskById?(taskId: string): import("../task/Task").Task | undefined
/** Resolves a concurrent child's completion Promise in the parent's spawnConcurrentChildren call. */
resolveChildCompletion?(params: {
childTaskId: string
summary: string
payload?: Record<string, unknown>
failed?: boolean
}): Promise<void>
}
export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
@ -182,6 +191,21 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
// Not JSON — leave completionPayload undefined
}
// Concurrent path: if the parent task is still alive in the provider's task map,
// this child was spawned concurrently — resolve its completion Promise rather than
// recreating the parent from history.
const parentIsAlive =
typeof provider.getTaskById === "function" && provider.getTaskById(task.parentTaskId!) !== undefined
if (parentIsAlive && typeof provider.resolveChildCompletion === "function") {
await provider.resolveChildCompletion({
childTaskId: task.taskId,
summary: result,
payload: completionPayload,
})
return "delegated"
}
// Sequential path: parent was disposed; recreate it from history.
await provider.reopenParentFromDelegation({
parentTaskId: task.parentTaskId!,
childTaskId: task.taskId,

View file

@ -18,13 +18,18 @@ interface SpawnParallelTasksParams {
tasks: ParallelTaskSpec[]
/** When true, the entire queue is abandoned if any child task fails or is aborted. Default: false (continue on failure). */
abortOnChildFailure?: boolean
/**
* When true, all child tasks run concurrently (parent stays alive; JS cooperative multitasking).
* When false (default), tasks run sequentially via the parallel queue drain mechanism.
*/
concurrent?: boolean
}
export class SpawnParallelTasksTool extends BaseTool<"spawn_parallel_tasks"> {
readonly name = "spawn_parallel_tasks" as const
async execute(params: SpawnParallelTasksParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
const { tasks, abortOnChildFailure = false } = params
const { tasks, abortOnChildFailure = false, concurrent = false } = params
const { askApproval, handleError, pushToolResult } = callbacks
try {
@ -89,27 +94,42 @@ export class SpawnParallelTasksTool extends BaseTool<"spawn_parallel_tasks"> {
task.consecutiveMistakeCount = 0
// The first task starts immediately; the rest are queued in the parent's history.
// reopenParentFromDelegation will drain the queue, starting each child in turn,
// and resume the parent with aggregated results when the queue is empty.
const [first, ...rest] = parsedTasks
if (concurrent) {
// Concurrent path: all children run simultaneously; parent stays alive.
// spawnConcurrentChildren awaits all completions and returns aggregated results.
const results = await (provider as any).spawnConcurrentChildren({
parentTaskId: task.taskId,
tasks: parsedTasks.map((t) => ({
mode: t.spec.mode,
message: t.spec.message,
worktree: t.spec.worktree || undefined,
todos: t.todoItems,
})),
})
pushToolResult(JSON.stringify(results, null, 2))
} else {
// Sequential fan-out: first task starts immediately; the rest are queued in the
// parent's history. reopenParentFromDelegation drains the queue, starting each
// child in turn, and resumes the parent with aggregated results when empty.
const [first, ...rest] = parsedTasks
await (provider as any).delegateParentAndOpenChild({
parentTaskId: task.taskId,
message: first.spec.message,
initialTodos: first.todoItems,
mode: first.spec.mode,
worktree: first.spec.worktree || undefined,
abortOnChildFailure,
parallelQueue: rest.map((t) => ({
mode: t.spec.mode,
message: t.spec.message,
worktree: t.spec.worktree || undefined,
todos: t.spec.todos || undefined,
})),
})
await (provider as any).delegateParentAndOpenChild({
parentTaskId: task.taskId,
message: first.spec.message,
initialTodos: first.todoItems,
mode: first.spec.mode,
worktree: first.spec.worktree || undefined,
abortOnChildFailure,
parallelQueue: rest.map((t) => ({
mode: t.spec.mode,
message: t.spec.message,
worktree: t.spec.worktree || undefined,
todos: t.spec.todos || undefined,
})),
})
pushToolResult(`Spawned ${tasks.length} sequential tasks. Awaiting all results...`)
pushToolResult(`Spawned ${tasks.length} sequential tasks. Awaiting all results...`)
}
} catch (error) {
await handleError("spawning parallel tasks", error)
}

View file

@ -137,7 +137,16 @@ export class ClineProvider
private disposables: vscode.Disposable[] = []
private webviewDisposables: vscode.Disposable[] = []
private view?: vscode.WebviewView | vscode.WebviewPanel
private clineStack: Task[] = []
private tasks: Map<string, Task> = new Map()
private focusedTaskId?: string
private leaderTaskId?: string
private childCompletionHandlers: Map<
string,
{
resolve: (result: { summary: string; payload?: Record<string, unknown> }) => void
reject: (reason: Error) => void
}
> = new Map()
private codeIndexStatusSubscription?: vscode.Disposable
private codeIndexManager?: CodeIndexManager
private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class
@ -465,14 +474,13 @@ export class ClineProvider
}
}
// Adds a new Task instance to clineStack, marking the start of a new task.
// The instance is pushed to the top of the stack (LIFO order).
// When the task is completed, the top instance is removed, reactivating the
// previous task.
// Registers a Task instance and marks it as focused/leader.
async addClineToStack(task: Task) {
// Add this cline instance into the stack that represents the order of
// all the called tasks.
this.clineStack.push(task)
this.tasks.set(task.taskId, task)
this.focusedTaskId = task.taskId
if (!task.parentTask) {
this.leaderTaskId = task.taskId
}
task.emit(RooCodeEventName.TaskFocused)
// Perform special setup provider specific tasks.
@ -504,85 +512,99 @@ export class ClineProvider
}
}
// Removes and destroys the top Cline instance (the current finished task),
// activating the previous one (resuming the parent task).
async removeClineFromStack(options?: { skipDelegationRepair?: boolean }) {
if (this.clineStack.length === 0) {
// Removes and destroys a Task instance, refocusing the parent if applicable.
// Pass taskId to remove a specific task (e.g. a concurrent child); omit to remove
// whichever task is currently focused.
async removeClineFromStack(options?: { skipDelegationRepair?: boolean; taskId?: string }) {
if (this.tasks.size === 0) {
return
}
// Pop the top Cline instance from the stack.
let task = this.clineStack.pop()
const resolveId = options?.taskId ?? this.focusedTaskId
if (!resolveId) {
return
}
if (task) {
// Capture delegation metadata before abort/dispose, since abortTask(true)
// is async and the task reference is cleared afterwards.
const childTaskId = task.taskId
const parentTaskId = task.parentTaskId
let task = this.tasks.get(resolveId)
if (!task) {
return
}
task.emit(RooCodeEventName.TaskUnfocused)
this.tasks.delete(resolveId)
// Update focus: if we just removed the focused task, focus the parent (or any remaining task).
if (this.focusedTaskId === resolveId) {
const parentId = task.parentTask?.taskId
if (parentId && this.tasks.has(parentId)) {
this.focusedTaskId = parentId
} else {
// Fall back to the most recently added task, or clear.
const remaining = Array.from(this.tasks.keys())
this.focusedTaskId = remaining.length > 0 ? remaining[remaining.length - 1] : undefined
}
}
// Update leader if the leader was removed.
if (this.leaderTaskId === resolveId) {
this.leaderTaskId = this.focusedTaskId
}
// Capture delegation metadata before abort/dispose.
const childTaskId = task.taskId
const parentTaskId = task.parentTaskId
task.emit(RooCodeEventName.TaskUnfocused)
try {
await task.abortTask(true)
} catch (e) {
this.log(
`[ClineProvider#removeClineFromStack] abortTask() failed ${task.taskId}.${task.instanceId}: ${e.message}`,
)
}
const cleanupFunctions = this.taskEventListeners.get(task)
if (cleanupFunctions) {
cleanupFunctions.forEach((cleanup) => cleanup())
this.taskEventListeners.delete(task)
}
// Allow GC.
task = undefined
// Delegation-aware parent metadata repair:
// Skip when called from delegateParentAndOpenChild() during nested delegation
// transitions (A→B→C), where the caller will update the parent to point at the new child.
if (parentTaskId && childTaskId && !options?.skipDelegationRepair) {
try {
// Abort the running task and set isAbandoned to true so
// all running promises will exit as well.
await task.abortTask(true)
} catch (e) {
this.log(
`[ClineProvider#removeClineFromStack] abortTask() failed ${task.taskId}.${task.instanceId}: ${e.message}`,
)
}
const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId)
// Remove event listeners before clearing the reference.
const cleanupFunctions = this.taskEventListeners.get(task)
if (cleanupFunctions) {
cleanupFunctions.forEach((cleanup) => cleanup())
this.taskEventListeners.delete(task)
}
// Make sure no reference kept, once promises end it will be
// garbage collected.
task = undefined
// Delegation-aware parent metadata repair:
// If the popped task was a delegated child, repair the parent's metadata
// so it transitions from "delegated" back to "active" and becomes resumable
// from the task history list.
// Skip when called from delegateParentAndOpenChild() during nested delegation
// transitions (A→B→C), where the caller intentionally replaces the active
// child and will update the parent to point at the new child.
if (parentTaskId && childTaskId && !options?.skipDelegationRepair) {
try {
const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId)
if (parentHistory.status === "delegated" && parentHistory.awaitingChildId === childTaskId) {
await this.updateTaskHistory({
...parentHistory,
status: "active",
awaitingChildId: undefined,
})
this.log(
`[ClineProvider#removeClineFromStack] Repaired parent ${parentTaskId} metadata: delegated → active (child ${childTaskId} removed)`,
)
}
} catch (err) {
// Non-fatal: log but do not block the pop operation.
if (parentHistory.status === "delegated" && parentHistory.awaitingChildId === childTaskId) {
await this.updateTaskHistory({
...parentHistory,
status: "active",
awaitingChildId: undefined,
})
this.log(
`[ClineProvider#removeClineFromStack] Failed to repair parent metadata for ${parentTaskId} (non-fatal): ${
err instanceof Error ? err.message : String(err)
}`,
`[ClineProvider#removeClineFromStack] Repaired parent ${parentTaskId} metadata: delegated → active (child ${childTaskId} removed)`,
)
}
} catch (err) {
this.log(
`[ClineProvider#removeClineFromStack] Failed to repair parent metadata for ${parentTaskId} (non-fatal): ${
err instanceof Error ? err.message : String(err)
}`,
)
}
}
}
getTaskStackSize(): number {
return this.clineStack.length
return this.tasks.size
}
public getCurrentTaskStack(): string[] {
return this.clineStack.map((cline) => cline.taskId)
return Array.from(this.tasks.keys())
}
// Pending Edit Operations Management
@ -673,9 +695,9 @@ export class ClineProvider
this._disposed = true
this.log("Disposing ClineProvider...")
// Clear all tasks from the stack.
while (this.clineStack.length > 0) {
await this.removeClineFromStack()
// Clear all tasks from the map.
for (const taskId of Array.from(this.tasks.keys())) {
await this.removeClineFromStack({ taskId })
}
this.log("Cleared all tasks")
@ -1095,30 +1117,33 @@ export class ClineProvider
})
if (isRehydratingCurrentTask) {
// Replace the current task in-place to avoid UI flicker
const stackIndex = this.clineStack.length - 1
// Replace the focused task in-place to avoid UI flicker.
const oldTask = this.focusedTaskId ? this.tasks.get(this.focusedTaskId) : undefined
// Properly dispose of the old task to ensure garbage collection
const oldTask = this.clineStack[stackIndex]
if (oldTask) {
try {
await oldTask.abortTask(true)
} catch (e) {
this.log(
`[createTaskWithHistoryItem] abortTask() failed for old task ${oldTask.taskId}.${oldTask.instanceId}: ${e.message}`,
)
}
// Abort the old task to stop running processes and mark as abandoned
try {
await oldTask.abortTask(true)
} catch (e) {
this.log(
`[createTaskWithHistoryItem] abortTask() failed for old task ${oldTask.taskId}.${oldTask.instanceId}: ${e.message}`,
)
const cleanupFunctions = this.taskEventListeners.get(oldTask)
if (cleanupFunctions) {
cleanupFunctions.forEach((cleanup) => cleanup())
this.taskEventListeners.delete(oldTask)
}
this.tasks.delete(oldTask.taskId)
}
// Remove event listeners from the old task
const cleanupFunctions = this.taskEventListeners.get(oldTask)
if (cleanupFunctions) {
cleanupFunctions.forEach((cleanup) => cleanup())
this.taskEventListeners.delete(oldTask)
// Register the new task under its own id while preserving focus.
this.tasks.set(task.taskId, task)
this.focusedTaskId = task.taskId
if (!task.parentTask) {
this.leaderTaskId = task.taskId
}
// Replace the task in the stack
this.clineStack[stackIndex] = task
task.emit(RooCodeEventName.TaskFocused)
// Perform preparation tasks and set up event listeners
@ -1860,13 +1885,7 @@ export class ClineProvider
/* Condenses a task's message history to use fewer tokens. */
async condenseTaskContext(taskId: string) {
let task: Task | undefined
for (let i = this.clineStack.length - 1; i >= 0; i--) {
if (this.clineStack[i].taskId === taskId) {
task = this.clineStack[i]
break
}
}
const task = this.tasks.get(taskId)
if (!task) {
throw new Error(`Task with id ${taskId} not found in stack`)
}
@ -2830,11 +2849,14 @@ export class ClineProvider
*/
public getCurrentTask(): Task | undefined {
if (this.clineStack.length === 0) {
if (!this.focusedTaskId) {
return undefined
}
return this.tasks.get(this.focusedTaskId)
}
return this.clineStack[this.clineStack.length - 1]
public getTaskById(taskId: string): Task | undefined {
return this.tasks.get(taskId)
}
public getRecentTasks(): string[] {
@ -2993,12 +3015,12 @@ export class ClineProvider
task: text,
images,
experiments,
rootTask: this.clineStack.length > 0 ? this.clineStack[0] : undefined,
rootTask: this.leaderTaskId ? this.tasks.get(this.leaderTaskId) : undefined,
parentTask,
taskNumber: this.clineStack.length + 1,
taskNumber: this.tasks.size + 1,
onCreated: this.taskCreationCallback,
initialTodos: options.initialTodos,
// Ensure this task is present in clineStack before startTask() emits
// Ensure this task is present in the tasks map before startTask() emits
// its initial state update, so state.currentTaskId is available ASAP.
startTask: false,
...options,
@ -3105,8 +3127,8 @@ export class ClineProvider
// Clear the current task without treating it as a subtask.
// This is used when the user cancels a task that is not a subtask.
public async clearTask(): Promise<void> {
if (this.clineStack.length > 0) {
const task = this.clineStack[this.clineStack.length - 1]
const task = this.getCurrentTask()
if (task) {
console.log(`[clearTask] clearing task ${task.taskId}.${task.instanceId}`)
await this.removeClineFromStack()
}
@ -3778,6 +3800,144 @@ export class ClineProvider
}
}
/**
* Concurrent fan-out: spawn all child tasks simultaneously (parent stays alive in the map),
* then await all completions via Promise.all before returning aggregated results.
*
* The parent task MUST be in this.tasks and is NOT removed — it remains registered
* with its focusedTaskId preserved while children run. Each child calls
* resolveChildCompletion() when it finishes (from AttemptCompletionTool concurrent path).
*/
public async spawnConcurrentChildren(params: {
parentTaskId: string
tasks: Array<{ mode: string; message: string; worktree?: string; todos?: TodoItem[] }>
}): Promise<Array<{ taskId: string; summary: string; payload?: Record<string, unknown>; error?: string }>> {
const { parentTaskId, tasks } = params
const parent = this.tasks.get(parentTaskId)
if (!parent) {
throw new Error(`[spawnConcurrentChildren] Parent task ${parentTaskId} not found in active tasks`)
}
TelemetryService.instance.captureParallelTaskSpawned(parentTaskId, tasks.length)
this.log(`[spawnConcurrentChildren] Spawning ${tasks.length} concurrent children for parent ${parentTaskId}`)
const childPromises = tasks.map(async (spec) => {
// Switch mode and optionally create worktree before spawning.
try {
await this.handleModeSwitch(spec.mode as any)
} catch {
// non-fatal
}
let childWorktreePath: string | undefined
if (spec.worktree) {
childWorktreePath = await this._createWorktreeForTask(parent.workspacePath, spec.worktree, parentTaskId)
}
const child = await this.createTask(spec.message, undefined, parent as any, {
initialTodos: spec.todos ?? [],
initialStatus: "active",
startTask: false,
workspacePath: childWorktreePath,
})
if (childWorktreePath) {
try {
const { historyItem: childHistory } = await this.getTaskWithId(child.taskId)
await this.updateTaskHistory({ ...childHistory, worktreePath: childWorktreePath })
} catch {
// non-fatal
}
}
// Register a completion handler before starting the child.
const completionPromise = new Promise<{
taskId: string
summary: string
payload?: Record<string, unknown>
error?: string
}>((resolve, reject) => {
this.childCompletionHandlers.set(child.taskId, {
resolve: (result) => resolve({ taskId: child.taskId, ...result }),
reject,
})
})
child.start()
this.emit(RooCodeEventName.TaskSpawned, child.taskId)
this.emit(RooCodeEventName.TaskDelegated, parentTaskId, child.taskId)
return completionPromise
})
// Await all children; collect results even for failed ones (Promise.allSettled semantics).
const settled = await Promise.allSettled(childPromises)
const results: Array<{ taskId: string; summary: string; payload?: Record<string, unknown>; error?: string }> =
settled.map((s, i) => {
if (s.status === "fulfilled") {
return s.value
}
// Rejected means child threw; synthesize an error result.
const taskId = tasks[i] ? `child-${i}` : `child-${i}`
const msg = s.reason instanceof Error ? s.reason.message : String(s.reason)
return { taskId, summary: msg, error: msg }
})
const hadFailures = results.some((r) => r.error !== undefined)
TelemetryService.instance.captureParallelTaskCompleted(parentTaskId, results.length, tasks.length, hadFailures)
this.log(
`[spawnConcurrentChildren] All ${tasks.length} children completed for parent ${parentTaskId}. failures=${hadFailures}`,
)
return results
}
/**
* Called by AttemptCompletionTool when a concurrent child completes.
* Resolves the Promise registered in childCompletionHandlers for this child,
* then removes the child from the tasks map.
*/
public async resolveChildCompletion(params: {
childTaskId: string
summary: string
payload?: Record<string, unknown>
failed?: boolean
}): Promise<void> {
const { childTaskId, summary, payload, failed } = params
const handler = this.childCompletionHandlers.get(childTaskId)
this.childCompletionHandlers.delete(childTaskId)
// Update child history to "completed" and clean up worktree.
try {
const { historyItem: childHistory } = await this.getTaskWithId(childTaskId)
const worktreePath = childHistory.worktreePath
await this.updateTaskHistory({ ...childHistory, status: "completed", completionPayload: payload })
if (worktreePath) {
try {
await worktreeService.deleteWorktree(childHistory.workspace ?? this.cwd, worktreePath)
TelemetryService.instance.captureWorktreeDeleted(childTaskId, worktreePath)
} catch {
// non-fatal
}
}
} catch {
// non-fatal
}
// Remove the child from the live task map but do NOT change focus
// (parent is still focused and running).
await this.removeClineFromStack({ taskId: childTaskId, skipDelegationRepair: true })
if (handler) {
if (failed) {
handler.reject(new Error(summary))
} else {
handler.resolve({ summary, payload })
}
}
}
/**
* Scans task history for orphaned worktrees — directories that still exist on disk but whose
* tasks are no longer running. This can happen if VS Code crashes mid-task.
@ -3786,7 +3946,7 @@ export class ClineProvider
public async detectAndCleanOrphanedWorktrees(): Promise<void> {
try {
const allHistory = this.taskHistoryStore.getAll()
const activeTaskIds = new Set(this.clineStack.map((t) => t.taskId))
const activeTaskIds = new Set(this.tasks.keys())
const orphans: Array<{ taskId: string; worktreePath: string }> = []
for (const item of allHistory) {

View file

@ -191,9 +191,11 @@ describe("ClineProvider flicker-free cancel", () => {
vi.mocked(Task).mockImplementation(() => mockTask2 as any)
})
it("should not remove current task from stack when rehydrating same taskId", async () => {
// Setup: Add a task to the stack first
;(provider as any).clineStack = [mockTask1]
it("should not remove current task from map when rehydrating same taskId", async () => {
// Setup: Add a task to the map first
;(provider as any).tasks = new Map([["task-1", mockTask1]])
;(provider as any).focusedTaskId = "task-1"
;(provider as any).leaderTaskId = "task-1"
// Mock event listeners for cleanup
;(provider as any).taskEventListeners = new WeakMap()
@ -221,9 +223,9 @@ describe("ClineProvider flicker-free cancel", () => {
// Assert: removeClineFromStack should NOT be called
expect(removeClineFromStackSpy).not.toHaveBeenCalled()
// Verify the task was replaced in-place
expect((provider as any).clineStack).toHaveLength(1)
expect((provider as any).clineStack[0]).toBe(mockTask2)
// Verify the task was replaced in-place (new task under same focusedTaskId)
expect((provider as any).tasks.size).toBe(1)
expect((provider as any).tasks.get("task-1")).toBe(mockTask2)
// Verify old event listeners were cleaned up
expect(mockCleanupFunctions[0]).toHaveBeenCalled()
@ -233,9 +235,10 @@ describe("ClineProvider flicker-free cancel", () => {
expect(mockTask2.emit).toHaveBeenCalledWith("taskFocused")
})
it("should remove task from stack when creating different task", async () => {
// Setup: Add a task to the stack first
;(provider as any).clineStack = [mockTask1]
it("should remove task from map when creating different task", async () => {
// Setup: Add a task to the map first
;(provider as any).tasks = new Map([["task-1", mockTask1]])
;(provider as any).focusedTaskId = "task-1"
// Spy on removeClineFromStack to verify it IS called
const removeClineFromStackSpy = vi.spyOn(provider, "removeClineFromStack").mockResolvedValue(undefined)
@ -259,9 +262,10 @@ describe("ClineProvider flicker-free cancel", () => {
expect(removeClineFromStackSpy).toHaveBeenCalled()
})
it("should handle empty stack gracefully during rehydration attempt", async () => {
// Setup: Empty stack
;(provider as any).clineStack = []
it("should handle empty map gracefully during rehydration attempt", async () => {
// Setup: Empty map
;(provider as any).tasks = new Map()
;(provider as any).focusedTaskId = undefined
// Spy on removeClineFromStack
const removeClineFromStackSpy = vi.spyOn(provider, "removeClineFromStack").mockResolvedValue(undefined)
@ -285,19 +289,24 @@ describe("ClineProvider flicker-free cancel", () => {
expect(removeClineFromStackSpy).toHaveBeenCalled()
})
it("should maintain task stack integrity during flicker-free replacement", async () => {
// Setup: Stack with multiple tasks
it("should maintain task map integrity during flicker-free replacement", async () => {
// Setup: Map with multiple tasks (parent + current child)
const mockParentTask = {
taskId: "parent-task",
instanceId: "parent-instance",
emit: vi.fn(),
}
;(provider as any).clineStack = [mockParentTask, mockTask1]
;(provider as any).tasks = new Map([
["parent-task", mockParentTask],
["task-1", mockTask1],
])
;(provider as any).focusedTaskId = "task-1"
;(provider as any).leaderTaskId = "parent-task"
;(provider as any).taskEventListeners = new WeakMap()
;(provider as any).taskEventListeners.set(mockTask1, [vi.fn()])
// Act: Rehydrate the current (top) task
// Act: Rehydrate the current (focused) task
const historyItem: HistoryItem = {
id: "task-1",
number: 1,
@ -311,9 +320,9 @@ describe("ClineProvider flicker-free cancel", () => {
await provider.createTaskWithHistoryItem(historyItem)
// Assert: Stack should maintain parent task and replace current task
expect((provider as any).clineStack).toHaveLength(2)
expect((provider as any).clineStack[0]).toBe(mockParentTask)
expect((provider as any).clineStack[1]).toBe(mockTask2)
// Assert: Map should maintain parent task and replace current task
expect((provider as any).tasks.size).toBe(2)
expect((provider as any).tasks.get("parent-task")).toBe(mockParentTask)
expect((provider as any).tasks.get("task-1")).toBe(mockTask2)
})
})

View file

@ -1209,8 +1209,9 @@ describe("ClineProvider", () => {
})
test("handles case when no current task exists", async () => {
// Clear the cline stack
;(provider as any).clineStack = []
// Clear the tasks map
;(provider as any).tasks = new Map()
;(provider as any).focusedTaskId = undefined
// Trigger message deletion
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]