Repository navigation
[Story 3.1] Implement TaskScheduler #368
Description
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.activeTasksandTaskRegistry.tasks(from Story 3.2a) will both hold references to the sameTaskobjects.TaskSchedulerowns lifecycle (semaphore permits, cancellation, completion callbacks);TaskRegistryowns routing and UI focus. When a task completes or is removed, both must be updated — remove fromactiveTasksin the scheduler callback, remove from the registry inClineProvider. 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
TaskSchedulerin the provider constructor (defaultmaxConcurrency = 1). delegateParentAndOpenChilddelegates tothis.taskScheduler.scheduleSubtask(...)internally.- Register scheduler in
ClineProvider.dispose().
In src/core/task/Task.ts:
- Add optional
cancellationToken?: vscode.CancellationTokentoTaskOptions. - 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: secondscheduleSubtaskawaits until first child'sonChildCompletefires.maxConcurrency = 2: both children start immediately;semaphore.available === 0.cancelAll()callsabortTask(true)on all active children.dispose()releases allCancellationTokenSourceinstances.- All
Taskinstances are mocks; usevi.useFakeTimers().
Acceptance Criteria
- At
maxConcurrency = 1: behavior is identical to current sequential delegation — existinghistory-resume-delegation.spec.tspasses without modification. dispose()is registered inClineProvider— no listener leaks on extension deactivation.- Structured logs emitted for all lifecycle transitions.
Activity