Repository navigation
[Story 3.2a] Introduce TaskRegistry and migrate clineStack #367
Description
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[] = []withprivate 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.currentthis.clineStack.length→this.taskRegistry.lengththis.clineStack.map(...)/ index access →this.taskRegistry.getAll()/getById()
- Pay special attention to two non-trivial access patterns:
- Replacement-at-index (~line 1189,
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. - Reverse-iteration lookup (~line 2033,
condenseTaskContext): finds a task by ID walking the stack backwards. Migrate totaskRegistry.getById(taskId).
- Replacement-at-index (~line 1189,
- Implement
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.ts
Tests (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.- Dual-write invariant: After every operation, assert
tasks.size === stack.lengthandgetById(currentTaskId) === current.
Acceptance Criteria
- All existing tests pass without modification — this is pure indirection.
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).
Activity