StateManager: VSCode state persistence and migration
Responsibilities
StateManager is a singleton Cline builds at Extension startup. It takes VSCode's three native stores — globalState, workspaceState, and secrets — and unifies them behind a layer of in-memory cache. All reads go straight to memory; writes hit the cache first, then flush to the underlying store asynchronously with a 500ms debounce. The goal is to free upper-layer modules (API config, task history, workspace state, remote config) from caring whether something lives in globalState, workspaceState, or secrets, and to coalesce a burst of consecutive writes into a single setBatch flush.
It sits very low in the architecture — almost every service reaches for this singleton via StateManager.get(). At Extension startup, initialize reads all state from disk into the cache (initialize:128), populates the cache, and starts a file watcher on taskHistory (setupTaskHistoryWatcher:541) before other modules are allowed to use it. State-version evolution is handled by a set of one-shot migration functions in state-migrations.ts (migrateWorkspaceToGlobalStorage:8).
Design motivation
- Singleton + synchronous reads:
static get()returns the in-memory cache for synchronous reads (get instance:163), avoiding anawaiton VSCode's async storage API every time. - 500ms debounce on flush: bursts of consecutive writes are coalesced into a single
persistPendingState(scheduleDebouncedPersistence:791), cutting disk IO. - Bucketed cache: five independent caches — globalState, taskState (per-task workspace settings), secrets, workspaceState, and remoteConfig (
caches:61). - taskHistory on a separate file: task history tends to grow, so it's split out of globalState into its own file (
taskHistory routing:821). - File watcher without a feedback loop: when task history is edited externally, the watcher only updates the cache and does not trigger a writeback (
syncTaskHistoryFromDisk:558). - Migrations centralized and idempotent: each migration checks "have I already run?" first (
early return if empty:79), andlastShownAnnouncementIdserves as the overall migration marker (hasMigrated check:733). - flushPendingState forces a write: critical paths (task switching, Extension deactivation) can bypass the debounce and persist immediately (
flushPendingState:777). - model info cache is separate: dynamic providers' model-list results are kept in a 1-hour-TTL memory cache (
MODEL_CACHE_TTL_MS:76) and never enter the persistence layer.
Key files
StateManager class:58— the singleton; holds five cache buckets, the file watcher, and the model cache.initialize:128— async load of globalState / secrets / workspaceState from disk into the cache.readGlobalStateFromStorage:141— calls into the disk layer to read all of globalState.get instance:163— synchronous singleton access; cache is ready.setupTaskHistoryWatcher:541— chokidar watch ontaskHistory.json; external changes sync back into the cache.persistPendingState:747— flushes the four pending sets in parallel.persistGlobalStateBatch:816— batch flush of globalState; taskHistory is routed to a separate file.persistTaskStateBatch:838— batch flush of per-taskId settings keys.flushPendingState:777— immediate flush entry point, bypasses the debounce.reInitialize:698— resets the cache and re-reads from disk, used in hot-reload scenarios.migrateWorkspaceToGlobalStorage:8— moves API config keys from workspace storage back into globalState.migrateTaskHistoryToFile:70— migrates taskHistory from globalState into its own JSON file.migrateCustomInstructionsToGlobalRules:147— merges the legacycustom_instructions.mdinto the global Cline rules directory.migrateWelcomeViewCompleted:561— backfills the welcomeViewCompleted flag based on existing API keys.cleanupLegacyVSCodeStorage:729— Extension-startup entry point that runs all migrations.
Data flow
At startup the Extension runs migrations before initializing the StateManager. The migrations gate on lastShownAnnouncementId for idempotency; if it's already set, they return immediately:
// apps/vscode/src/extension.ts
async function cleanupLegacyVSCodeStorage(context: ExtensionContext): Promise<void> {
try {
await cleanupOldApiKey(context)
// Migrate is not done if the new storage does not have the lastShownAnnouncementId flag
const hasMigrated = context.globalState.get("lastShownAnnouncementId")
if (hasMigrated !== undefined) {
return
}
Logger.info("[VS Code Storage Migrations] Starting")
// Migrate custom instructions to global Cline rules (one-time cleanup)
await migrateCustomInstructionsToGlobalRules(context)
// Migrate welcomeViewCompleted setting based on existing API keys (one-time cleanup)
await migrateWelcomeViewCompleted(context)
// Migrate workspace storage values back to global storage (reverting previous migration)
await migrateWorkspaceToGlobalStorage(context)
// Ensure taskHistory.json exists and migrate legacy state (runs once)
await migrateTaskHistoryToFile(context)
// Clean up MCP marketplace catalog from global state (moved to disk cache)
await cleanupMcpMarketplaceCatalogFromGlobalState(context)
// ...
} catch (error) {
Logger.warn("[VS Code Storage Migrations] Failed" + (error instanceof Error ? `: ${error.message}` : ""))
}
}This sits near cleanupLegacyVSCodeStorage:729. After migrations finish, StateManager.initialize loads file contents into the cache. At runtime a write follows this path:
// apps/vscode/src/core/storage/StateManager.ts
// caller writes a value
this.globalStateCache[key] = value
// mark pending
this.pendingGlobalState.add(key)
// schedule a batched flush 500ms later
this.scheduleDebouncedPersistence()
// when the timer fires:
private async persistPendingState(): Promise<void> {
if (this.pendingGlobalState.size === 0 && /* ... */) {
return
}
await Promise.all([
this.persistGlobalStateBatch(this.pendingGlobalState),
this.persistSecretsBatch(this.pendingSecrets),
this.persistWorkspaceStateBatch(this.pendingWorkspaceState),
this.persistTaskStateBatch(this.pendingTaskState),
])
this.pendingGlobalState.clear()
// ...
}persistGlobalStateBatch routes taskHistory to writeTaskHistoryToState (taskHistory routing:823) and writes the remaining keys in one shot via setBatch. When the taskHistory file is edited externally, the watcher takes the on-disk data; if the cache is empty it replaces, otherwise it keeps the in-memory version so it doesn't overwrite a freshly written update (onDisk sync:564).
Boundaries and failure modes
- Duplicate initialize throws: if
isInitializedis already true, it throws immediately to prevent double-construction (already initialized:133). - Migration failures don't block startup: each
migrate*function is wrapped in try/catch; on failure it logs and continues (continue on failure:188). - taskHistory write verification:
writeTaskHistoryToStatereads back after writing and compares lengths; on mismatch it logs an error without clearing the legacy globalState (write verification:109). - persistence error callback:
onPersistenceErrorlets upper layers decide the recovery strategy (onPersistenceError:116). - File watcher 300ms self-update skip: after writing taskHistory, external file-change events in a brief window are ignored to avoid a feedback loop (
unlink handler:577). - reInitialize clears the cache:
reInitializeempties the cache first and re-reads (clear caches:733); during that window any read returns an empty value. - migrateWorkspaceToGlobalStorage only moves undefined keys: it migrates a key only when workspaceValue exists and globalValue is undefined (
conditional migrate:56), so it won't overwrite a user's manual edits in the new location. - secrets in a separate batch: sensitive data like API keys goes through
persistSecretsBatchand is not interleaved with ordinary global state writes (persistSecretsBatch:864).
Summary
StateManager is the low-level module that unifies VSCode's three storage backends behind one cache plus a debounced flush; all migration logic is centralized in state-migrations.ts. To see how MCP server config flows through StateManager, read mcp/mcp-hub; to see how context-compaction reads and writes task directories, read mcp/context-manager; to see how hook execution interacts with StateManager, read hooks.
See official docs: Cline docs · README