mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-25 05:53:57 +00:00
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:
parent
3b3fe80ae1
commit
862d263cb8
7 changed files with 387 additions and 165 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue