Skip to content

[Story 3.1] Implement TaskScheduler #368

Description

@edelauna

Part of #358 (Epic 3: TaskScheduler + Subtask Fan-out).

Depends on: #361 (Story 1.1 — RateLimitClock), #362 (Story 1.2 — TaskSemaphore), Story 3.2a (TaskRegistry — must land first, then this story rebases onto it).

Relates to #133 (queue and steer delivery modes — this story delivers the TaskScheduler queue primitive; steer mode is out of scope).

Context

The delegation flow is currently hand-sequenced across 6–9 steps in ClineProvider with no central owner. TaskScheduler centralizes task lifecycle transitions, enforces concurrency limits via TaskSemaphore (src/utils/TaskSemaphore.ts), and propagates vscode.CancellationToken to child tasks. At maxConcurrency = 1 it is a transparent wrapper around existing behavior — the key milestone for validating the architecture before any user-visible change.

Developer Notes

Create src/core/task/TaskScheduler.ts:

import { TaskSemaphore } from "../../utils/TaskSemaphore"

export class TaskScheduler implements vscode.Disposable {
  private semaphore: TaskSemaphore
  private activeTasks: Map<string, Task> = new Map()
  private cancellationSources: Map<string, vscode.CancellationTokenSource> = new Map()

  constructor(private provider: ClineProvider, maxConcurrency = 1) {
    this.semaphore = new TaskSemaphore(maxConcurrency)
  }

  async scheduleSubtask(params: SubtaskParams): Promise<Task>
  onChildComplete(childTaskId: string): void
  cancelAll(): void
  dispose(): void
}

Ownership note: TaskScheduler.activeTasks and TaskRegistry.tasks (from Story 3.2a) will both hold references to the same Task objects. TaskScheduler owns lifecycle (semaphore permits, cancellation, completion callbacks); TaskRegistry owns routing and UI focus. When a task completes or is removed, both must be updated — remove from activeTasks in the scheduler callback, remove from the registry in ClineProvider. The scheduler is not the source of truth for which task the UI is focused on.

scheduleSubtask, onChildComplete, and cancelAll should emit structured logs with taskId, childTaskId, semaphore.available, and semaphore.waiting.

Critical: semaphore permit release must be in a finally block. If a child crashes without triggering onChildComplete, the permit leaks and — at maxConcurrency = 1 — permanently deadlocks the scheduler:

const release = await this.semaphore.acquire()
try {
  // ... run child task ...
} finally {
  release()
}

In src/core/webview/ClineProvider.ts:

  • Construct TaskScheduler in the provider constructor (default maxConcurrency = 1).
  • delegateParentAndOpenChild delegates to this.taskScheduler.scheduleSubtask(...) internally.
  • Register scheduler in ClineProvider.dispose().

In src/core/task/Task.ts:

  • Add optional cancellationToken?: vscode.CancellationToken to TaskOptions.
  • Hook abortTask(true) when the token fires.

Files: src/core/task/TaskScheduler.ts (new), src/core/webview/ClineProvider.ts, src/core/task/Task.ts

Tests (src/core/task/__tests__/TaskScheduler.spec.ts):

  • maxConcurrency = 1: second scheduleSubtask awaits until first child's onChildComplete fires.
  • maxConcurrency = 2: both children start immediately; semaphore.available === 0.
  • cancelAll() calls abortTask(true) on all active children.
  • dispose() releases all CancellationTokenSource instances.
  • All Task instances are mocks; use vi.useFakeTimers().

Acceptance Criteria

  • At maxConcurrency = 1: behavior is identical to current sequential delegation — existing history-resume-delegation.spec.ts passes without modification.
  • dispose() is registered in ClineProvider — no listener leaks on extension deactivation.
  • Structured logs emitted for all lifecycle transitions.

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