From 862d263cb89a311e90cbb52050e2db4d7d49df3a Mon Sep 17 00:00:00 2001 From: davincidreams Date: Sat, 25 Apr 2026 03:05:14 -0400 Subject: [PATCH] feat(swarm-p1): replace clineStack with tasks Map + concurrent spawn path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the LIFO `clineStack: Task[]` with a flat `tasks: Map` (+ `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 --- .../removeClineFromStack-delegation.spec.ts | 38 +- src/__tests__/single-open-invariant.spec.ts | 6 +- src/core/tools/AttemptCompletionTool.ts | 24 ++ src/core/tools/SpawnParallelTasksTool.ts | 60 ++- src/core/webview/ClineProvider.ts | 370 +++++++++++++----- .../ClineProvider.flicker-free-cancel.spec.ts | 49 ++- .../webview/__tests__/ClineProvider.spec.ts | 5 +- 7 files changed, 387 insertions(+), 165 deletions(-) diff --git a/src/__tests__/removeClineFromStack-delegation.spec.ts b/src/__tests__/removeClineFromStack-delegation.spec.ts index a72f580d6f..e32afb240f 100644 --- a/src/__tests__/removeClineFromStack-delegation.spec.ts +++ b/src/__tests__/removeClineFromStack-delegation.spec.ts @@ -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, + 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() diff --git a/src/__tests__/single-open-invariant.spec.ts b/src/__tests__/single-open-invariant.spec.ts index 2dd466a992..069b6c1533 100644 --- a/src/__tests__/single-open-invariant.spec.ts +++ b/src/__tests__/single-open-invariant.spec.ts @@ -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 }, diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index 864a7ba454..2e7a8b7a09 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -33,6 +33,15 @@ interface DelegationProvider { completionPayload?: Record childFailed?: boolean }): Promise + /** 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 + failed?: boolean + }): Promise } 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, diff --git a/src/core/tools/SpawnParallelTasksTool.ts b/src/core/tools/SpawnParallelTasksTool.ts index c459b82d75..ae36cd564f 100644 --- a/src/core/tools/SpawnParallelTasksTool.ts +++ b/src/core/tools/SpawnParallelTasksTool.ts @@ -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 { - 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) } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e287b61c81..11e2fc97c6 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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 = new Map() + private focusedTaskId?: string + private leaderTaskId?: string + private childCompletionHandlers: Map< + string, + { + resolve: (result: { summary: string; payload?: Record }) => 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 { - 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; 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 + 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; 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 + failed?: boolean + }): Promise { + 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 { 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) { diff --git a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts index 4bb01347a3..9900583af7 100644 --- a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts @@ -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) }) }) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index da0fb2003f..94b1f0a477 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -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]