Part of #358 (Epic 3: TaskScheduler + Subtask Fan-out).
Depends on: #361 (Story 1.1 — RateLimitClock), #362 (Story 1.2 — TaskSemaphore).
Must land before Story 3.1 — both touch ClineProvider.ts and this is a blanket 23-site refactor. Story 3.1 rebases onto this.
Relates to #126 (concurrent conversation sidebar — this story delivers the data layer TaskRegistry that makes task identity stable).
Context
clineStack: Task[] (line 167, ClineProvider.ts) is a LIFO array with 23 usage sites. It cannot represent multiple concurrent tasks. Converting it directly to a Map<string, Task> would be a risky big-bang change. Instead, use a two-phase Expand-Contract migration via a TaskRegistry adapter — this story is Phase A+B: introduce the adapter, migrate all call sites, and implement concurrent access methods (setCurrent(), getRunning(), hasRunning()). Behavior is identical to today, just indirection, but the concurrent access paths are available for Story 3.2b. The Gemini CLI used the same SchedulerStateManager pattern for this migration.
Developer Notes
Create src/core/task/TaskRegistry.ts:
export class TaskRegistry {
private tasks = new Map<string, Task>()
private stack: string[] = [] // Task IDs in stack order
private _currentTaskId: string | undefined
push(task: Task): void // Adds to both map and stack
pop(): Task | undefined // Removes from both
get current(): Task | undefined // Returns task by _currentTaskId
setCurrent(taskId: string): void // Focus switch without stack mutation
getById(id: string): Task | undefined
getAll(): Task[]
getRunning(): Task[] // All non-suspended tasks
hasRunning(taskId: string): boolean // True if task exists and is non-suspended
remove(taskId: string): Task | undefined // By ID (non-LIFO)
get length(): number
get taskIds(): string[]
// Phase A invariant: tasks.size === stack.length at all times
}
In src/core/webview/ClineProvider.ts:
- Replace
private clineStack: Task[] = [] with private taskRegistry = new TaskRegistry().
- Migrate all 23 usage sites:
this.clineStack.push(task) → this.taskRegistry.push(task)
this.clineStack.pop() → this.taskRegistry.pop()
this.clineStack[this.clineStack.length - 1] → this.taskRegistry.current
this.clineStack.length → this.taskRegistry.length
this.clineStack.map(...) / index access → this.taskRegistry.getAll() / getById()
- Pay special attention to two non-trivial access patterns:
- Replacement-at-index (~line 1189,
createTaskWithHistoryItem rehydration block): aborts and replaces the current task in-place to avoid UI flicker. Migrate to taskRegistry.remove(oldTask.taskId) + taskRegistry.push(newTask) with the same abort sequence.
- Reverse-iteration lookup (~line 2033,
condenseTaskContext): finds a task by ID walking the stack backwards. Migrate to taskRegistry.getById(taskId).
- Implement
setCurrent(taskId), getRunning(), and hasRunning(id) methods (needed by Stories 3.2b and 3.2c).
Phase C (future story): remove internal stack: string[] once all call sites use map-based access.
Files: src/core/task/TaskRegistry.ts (new), src/core/webview/ClineProvider.ts
Tests (src/core/task/__tests__/TaskRegistry.spec.ts):
push() adds to both map and stack; current returns last-pushed task.
pop() removes from both; current updates to new top-of-stack.
getById() returns correct task; returns undefined for unknown IDs.
remove(taskId) removes from both map and stack regardless of position.
setCurrent(taskId) changes current without mutating the stack; throws for unknown IDs.
getRunning() returns all non-suspended tasks; hasRunning(id) is consistent with it.
length and taskIds are consistent after all operations.
- Dual-write invariant: After every operation, assert
tasks.size === stack.length and getById(currentTaskId) === current.
Acceptance Criteria
- All existing tests pass without modification — this is pure indirection.
clineStack no longer exists in ClineProvider.ts.
TaskRegistry invariant tasks.size === stack.length holds after every operation.
setCurrent(), getRunning(), and hasRunning() are implemented and tested (required by Stories 3.2b and 3.2c).
Part of #358 (Epic 3: TaskScheduler + Subtask Fan-out).
Depends on: #361 (Story 1.1 — RateLimitClock), #362 (Story 1.2 — TaskSemaphore).
Must land before Story 3.1 — both touch
ClineProvider.tsand this is a blanket 23-site refactor. Story 3.1 rebases onto this.Relates to #126 (concurrent conversation sidebar — this story delivers the data layer
TaskRegistrythat makes task identity stable).Context
clineStack: Task[](line 167,ClineProvider.ts) is a LIFO array with 23 usage sites. It cannot represent multiple concurrent tasks. Converting it directly to aMap<string, Task>would be a risky big-bang change. Instead, use a two-phase Expand-Contract migration via aTaskRegistryadapter — this story is Phase A+B: introduce the adapter, migrate all call sites, and implement concurrent access methods (setCurrent(),getRunning(),hasRunning()). Behavior is identical to today, just indirection, but the concurrent access paths are available for Story 3.2b. The Gemini CLI used the sameSchedulerStateManagerpattern for this migration.Developer Notes
Create
src/core/task/TaskRegistry.ts:In
src/core/webview/ClineProvider.ts:private clineStack: Task[] = []withprivate taskRegistry = new TaskRegistry().this.clineStack.push(task)→this.taskRegistry.push(task)this.clineStack.pop()→this.taskRegistry.pop()this.clineStack[this.clineStack.length - 1]→this.taskRegistry.currentthis.clineStack.length→this.taskRegistry.lengththis.clineStack.map(...)/ index access →this.taskRegistry.getAll()/getById()createTaskWithHistoryItemrehydration block): aborts and replaces the current task in-place to avoid UI flicker. Migrate totaskRegistry.remove(oldTask.taskId)+taskRegistry.push(newTask)with the same abort sequence.condenseTaskContext): finds a task by ID walking the stack backwards. Migrate totaskRegistry.getById(taskId).setCurrent(taskId),getRunning(), andhasRunning(id)methods (needed by Stories 3.2b and 3.2c).Phase C (future story): remove internal
stack: string[]once all call sites use map-based access.Files:
src/core/task/TaskRegistry.ts(new),src/core/webview/ClineProvider.tsTests (
src/core/task/__tests__/TaskRegistry.spec.ts):push()adds to both map and stack;currentreturns last-pushed task.pop()removes from both;currentupdates to new top-of-stack.getById()returns correct task; returnsundefinedfor unknown IDs.remove(taskId)removes from both map and stack regardless of position.setCurrent(taskId)changescurrentwithout mutating the stack; throws for unknown IDs.getRunning()returns all non-suspended tasks;hasRunning(id)is consistent with it.lengthandtaskIdsare consistent after all operations.tasks.size === stack.lengthandgetById(currentTaskId) === current.Acceptance Criteria
clineStackno longer exists inClineProvider.ts.TaskRegistryinvarianttasks.size === stack.lengthholds after every operation.setCurrent(),getRunning(), andhasRunning()are implemented and tested (required by Stories 3.2b and 3.2c).