Skip to content

[Story 3.2a] Introduce TaskRegistry and migrate clineStack #367

Description

@edelauna

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.lengththis.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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions