Skip to content

Controller: dependency assembly and the Task factory

源码版本v4.0.10

Responsibilities

The Controller is the middle layer between extension.ts and the Task. The webview.controller you get after activate() runs initialize() is exactly this (class Controller:70). It holds the Task instance (this.task), manages long-lived services — StateManager, AuthService, OcaAuthService, ClineAccountService, BannerService, McpHub — and centralizes cross-cutting capabilities like task history, telemetry, and remote config. In one line: the Controller is "a Task factory plus a shared-service container across tasks."

The methods it exposes fall roughly into three groups. One group is task lifecycle: initTask, cancelTask, clearTask, reinitExistingTaskFromId. Another is state synchronization: postStateToWebview and getStateToPostToWebview, which pack the current task's clineMessages, taskHistory, apiConfiguration, and a large pile of state into an ExtensionState and push it to the webview. The third group is settings and accounts: handleSignOut, setUserInfo, updateTelemetrySetting, toggleActModeForYoloMode, and so on. Every gRPC request coming from the webview ultimately lands on some specific method on the controller.

Design motivation

  • The Controller does not own the LLM stream: the Controller does not deal with LLM calls, block parsing, or tool execution — all of that lives inside the Task. The Controller only "creates a Task, gives the Task its dependencies, and reclaims the Task when it finishes." This layering means unit-testing the Task does not require mocking the Controller.
  • Task factory pattern: initTask is the single entry point for creating a Task (initTask:231). It first calls clearTask to prevent co-existence, reads settings like autoApproval and shell integration timeout, acquires the task lock, and finally new Task({...}) injects every dependency through TaskParams. The Task never news its own dependencies; they all come in from the Controller.
  • Centralized state push via postStateToWebview: the Controller does not let the Task postMessage directly. Instead it passes postStateToWebview: () => this.postStateToWebview() as a callback into the Task (postStateToWebview callback:311). That way the Task only mutates clineMessages inside messageStateHandler, and the Controller is responsible for serializing state into ExtensionState and pushing it to the webview.
  • Periodic remote config fetch: startRemoteConfigTimer is started asynchronously at the end of the constructor (startRemoteConfigTimer:114), fetching immediately and then once per hour, so enterprise policies (yoloModeAllowed, allowedMCPServers, etc.) can hot-reload.
  • cancelTask is idempotent: the cancelInProgress flag prevents re-entry when the user hammers the cancel button (cancelInProgress:428). The cancel flow needs abortTask + waiting for the stream to stop + re-initializing a Task from history, which can take seconds; during that window it must be idempotent.
  • Callbacks over inheritance: the Controller stuffs updateTaskHistory, reinitExistingTaskFromId, cancelTask, and other methods into TaskParams as arrow functions (TaskParams callbacks:310). When the Task calls this.cancelTask(), the this it captures is the Controller's. This is looser coupling than having the Task hold a Controller reference.

Key files

  • class Controller:70 — class declaration; fields include task?, mcpHub, accountService, authService, stateManager, workspaceManager.
  • constructor:121 — assembles StateManager, AuthService, OcaAuthService, BannerService, McpHub; runs cleanupLegacyCheckpoints and checkCliInstallation.
  • startRemoteConfigTimer:114 — fetches remote config once immediately, then once per hour.
  • initTask:231 — Task factory entry; reads settings, acquires the lock, news the Task, and calls startTask or resumeTaskFromHistory.
  • tryAcquireTaskLockWithRetry:286 — prevents the same task from running in two Cline instances at once; throws when cross-window contention occurs.
  • new Task:307 — injects every dependency into the Task via TaskParams; the Task news none of the cross-cutting services itself.
  • cancelTask:426 — phased cancel: re-entry guard → abortTask → wait for stream to stop → restore from history or clearTask.
  • postStateToWebview:846 — calls getStateToPostToWebview to assemble state, then sendStateUpdate to push it to the webview.
  • getStateToPostToWebview:851 — assembles 30+ stateKeys into ExtensionState, including clineMessages, taskHistory, apiConfiguration.
  • clearTask:1016 — clears the task settings cache, calls task.abortTask, and sets this.task = undefined for GC.
  • dispose:168 — clears the remote config timer, clears the task, and calls mcpHub.dispose.
  • new Controller:19 — WebviewProvider synchronously news the Controller in its constructor; the two are bound 1:1.

Data flow

initTask is the Controller's most central method. Its flow is "read settings → acquire lock → new Task → start":

typescript
// apps/vscode/src/core/controller/index.ts
await this.clearTask() // ensures that an existing task doesn't exist before starting a new one

const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
const shellIntegrationTimeout = this.stateManager.getGlobalSettingsKey("shellIntegrationTimeout")
// ...more settings reads

const taskId = historyItem?.id || Date.now().toString()

// Acquire task lock
const lockResult: FolderLockWithRetryResult = await tryAcquireTaskLockWithRetry(taskId)
if (!lockResult.acquired && !lockResult.skipped) {
    throw new Error(errorMessage) // Prevents task initialization
}

this.task = new Task({
    controller: this,
    mcpHub: this.mcpHub,
    updateTaskHistory: (historyItem) => this.updateTaskHistory(historyItem),
    postStateToWebview: () => this.postStateToWebview(),
    reinitExistingTaskFromId: (taskId) => this.reinitExistingTaskFromId(taskId),
    cancelTask: () => this.cancelTask(),
    // ...plus shellIntegrationTimeout, terminalReuseEnabled, cwd, taskId, etc.
})

if (historyItem) {
    this.task.resumeTaskFromHistory()
} else if (task || images || files) {
    this.task.startTask(task, images, files)
}

This sits near initTask body:247. Note that the Task receives controller: this, but only actually touches a few fields like controller.stateManager and controller.mcpHub, and every state mutation goes back through the postStateToWebview callback. Once the Task finishes — either by calling cancelTask itself or by the user clicking cancel in the webview — the Controller enters cancelTask: first abortTask lets the recursive loop exit on its own, then pWaitFor waits for the stream to actually stop (pWaitFor stream stop:449), and finally either a fresh Task instance is restored from history and hung on the UI (so the user sees the resume button) or clearTask runs directly.

Boundaries and failure modes

  • Task lock not acquired: when tryAcquireTaskLockWithRetry returns acquired=false with skipped=false, it throws straight away (lock fail throw:288); the Task is never created. skipped=true is the fallback for the VSCode single-instance case, which is allowed to proceed.
  • New-user threshold: once task history reaches 10 entries, isNewUser is flipped to false (new user threshold:261), which collapses the onboarding UI.
  • autoApproval version bump: every initTask increments autoApprovalSettings.version by 1 (autoApproval version bump:267), invalidating older auto-approval configs and forcing the user to reconfirm.
  • cancelTask 3-second timeout: pWaitFor waits at most 3 seconds for the stream to stop (cancel timeout:456); on timeout it only logs an error and does not block, and the task is marked abandoned = true so it no longer affects the UI.
  • StateManager persistence errors don't interrupt: the onPersistenceError callback only logs (onPersistenceError:127); it does not call reInitialize() (that would flip isInitialized=false and break the running task) and does not surface a warning (data is safe in memory and the next debounced persistence will retry).
  • dispose order: Controller dispose first clears the remote config timer, then clearTask, then mcpHub.dispose (dispose:168). The order cannot be reversed — if McpHub is disposed while a task still references it, it crashes.

Summary

The Controller is Cline's key separation between "shared services across tasks" and "the per-task state machine." It never touches the LLM stream itself; it only assembles dependencies, factory-builds Tasks, and centralizes state pushes. To see how the webview and the extension route messages to the Controller, go to /startup/webview-bridge; to see how the Task itself runs its recursive loop, go to /agent-loop/task-class and /agent-loop/recursion.

See official docs:Cline 文档 · README