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.
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
ClineProviderwith no central owner.TaskSchedulercentralizes task lifecycle transitions, enforces concurrency limits viaTaskSemaphore(src/utils/TaskSemaphore.ts), and propagatesvscode.CancellationTokento child tasks. AtmaxConcurrency = 1it 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:scheduleSubtask,onChildComplete, andcancelAllshould emit structured logs withtaskId,childTaskId,semaphore.available, andsemaphore.waiting.Critical: semaphore permit release must be in a
finallyblock. If a child crashes without triggeringonChildComplete, the permit leaks and — atmaxConcurrency = 1— permanently deadlocks the scheduler:In
src/core/webview/ClineProvider.ts:TaskSchedulerin the provider constructor (defaultmaxConcurrency = 1).delegateParentAndOpenChilddelegates tothis.taskScheduler.scheduleSubtask(...)internally.ClineProvider.dispose().In
src/core/task/Task.ts:cancellationToken?: vscode.CancellationTokentoTaskOptions.abortTask(true)when the token fires.Files:
src/core/task/TaskScheduler.ts(new),src/core/webview/ClineProvider.ts,src/core/task/Task.tsTests (
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.Taskinstances are mocks; usevi.useFakeTimers().Acceptance Criteria
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.