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