-
Notifications
You must be signed in to change notification settings - Fork 219
fix(provider): isolate profile mutations from focused tasks #1087
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f1e74ec
fd33c9c
1f3dfa9
b8646bb
07fd132
18df117
0c1a2db
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -196,12 +196,68 @@ export class ClineProvider | |
| private taskHistoryStoreInitialized = false | ||
| private globalStateWriteThroughTimer: ReturnType<typeof setTimeout> | null = null | ||
| private static readonly GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS = 5000 // 5 seconds | ||
| private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds | ||
| public static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds | ||
| private providerProfileMutationQueue = Promise.resolve() | ||
|
|
||
| private runDelegationTransition<T>(parentTaskId: string, fn: () => Promise<T>): Promise<T> { | ||
| this.delegationTransitionLocks ??= new Map() | ||
| return runDelegationTransition(this.delegationTransitionLocks, parentTaskId, fn) | ||
| } | ||
|
|
||
| private enqueueProviderProfileMutation<T>(fn: (signal: AbortSignal) => Promise<T>): Promise<T> { | ||
| const controller = new AbortController() | ||
| // Run fn after either outcome so a rejected mutation never poisons the queue. | ||
| const run = this.providerProfileMutationQueue.then( | ||
| () => fn(controller.signal), | ||
| () => fn(controller.signal), | ||
| ) | ||
| const callerResult = this.withProviderProfileMutationTimeout(run, () => { | ||
| controller.abort() | ||
| this.log("Provider profile mutation timed out; aborting in-flight mutation") | ||
| }) | ||
|
|
||
| void run.then( | ||
| () => { | ||
| if (controller.signal.aborted) { | ||
| this.log("Provider profile mutation completed after cancellation") | ||
| } | ||
| }, | ||
| (error) => { | ||
| if (controller.signal.aborted) { | ||
| this.log( | ||
| `Provider profile mutation errored after cancellation: ${ | ||
| error instanceof Error ? error.message : String(error) | ||
| }`, | ||
| ) | ||
| } | ||
| }, | ||
| ) | ||
|
|
||
| // Advance from the timeout-bounded result. Each fn checks its AbortSignal before | ||
| // writing state, so advancing the queue on timeout cannot produce stale overwrites. | ||
| this.providerProfileMutationQueue = callerResult.then( | ||
| () => undefined, | ||
| () => undefined, | ||
| ) | ||
| return callerResult | ||
| } | ||
|
|
||
| private withProviderProfileMutationTimeout<T>(operation: Promise<T>, onTimeout: () => void): Promise<T> { | ||
| let timeoutId: ReturnType<typeof setTimeout> | undefined | ||
| const timeout = new Promise<never>((_, reject) => { | ||
| timeoutId = setTimeout(() => { | ||
| onTimeout() | ||
| reject(new Error("Provider profile mutation timed out")) | ||
| }, ClineProvider.PENDING_OPERATION_TIMEOUT_MS) | ||
| }) | ||
|
|
||
| return Promise.race([operation, timeout]).finally(() => { | ||
| if (timeoutId) { | ||
| clearTimeout(timeoutId) | ||
| } | ||
| }) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| private readonly pendingEditOperations: PendingEditOperationStore | ||
|
|
||
| private cloudOrganizationsCache: CloudOrganizationMembership[] | null = null | ||
|
|
@@ -1507,9 +1563,21 @@ export class ClineProvider | |
| /** | ||
| * Handle switching to a new mode, including updating the associated API configuration | ||
| * @param newMode The mode to switch to | ||
| * @param targetTask The task whose in-memory mode should be updated. Defaults to the | ||
| * current task. Pass null to apply only global mode/profile effects for a pending child. | ||
| */ | ||
| public async handleModeSwitch(newMode: Mode) { | ||
| const task = this.getCurrentTask() | ||
| public async handleModeSwitch(newMode: Mode, targetTask: Task | null | undefined = this.getCurrentTask()) { | ||
| return this.enqueueProviderProfileMutation((signal) => | ||
| this.handleModeSwitchUnlocked(newMode, targetTask, signal), | ||
| ) | ||
| } | ||
|
|
||
| private async handleModeSwitchUnlocked( | ||
| newMode: Mode, | ||
| targetTask: Task | null | undefined, | ||
| signal?: AbortSignal, | ||
| ): Promise<void> { | ||
| const task = targetTask | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| if (task) { | ||
| TelemetryService.instance.captureModeSwitch(task.taskId, newMode) | ||
|
|
@@ -1546,14 +1614,20 @@ export class ClineProvider | |
| // If workspace lock is on, keep the current API config — don't load mode-specific config | ||
| const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) | ||
| if (lockApiConfigAcrossModes) { | ||
| await this.postStateToWebview() | ||
| if (targetTask !== null) { | ||
| await this.postStateToWebview() | ||
| } | ||
| return | ||
| } | ||
|
|
||
| if (signal?.aborted) return | ||
|
|
||
| // Load the saved API config for the new mode if it exists. | ||
| const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode) | ||
| const listApiConfig = await this.providerSettingsManager.listConfig() | ||
|
|
||
| if (signal?.aborted) return | ||
|
|
||
| // Update listApiConfigMeta first to ensure UI has latest data. | ||
| await this.updateGlobalState("listApiConfigMeta", listApiConfig) | ||
|
|
||
|
|
@@ -1572,7 +1646,11 @@ export class ClineProvider | |
| const hasActualSettings = !!fullProfile.apiProvider | ||
|
|
||
| if (hasActualSettings) { | ||
| await this.activateProviderProfile({ name: profile.name }) | ||
| await this.activateProviderProfileUnlocked( | ||
| { name: profile.name }, | ||
| targetTask === null ? { skipCurrentTaskRebuild: true } : undefined, | ||
| signal, | ||
| ) | ||
| } else { | ||
| // The task will continue with the current/default configuration. | ||
| } | ||
|
|
@@ -1592,7 +1670,9 @@ export class ClineProvider | |
| } | ||
| } | ||
|
|
||
| await this.postStateToWebview() | ||
| if (targetTask !== null) { | ||
| await this.postStateToWebview() | ||
| } | ||
| } | ||
|
|
||
| // Provider Profile Management | ||
|
|
@@ -1608,8 +1688,9 @@ export class ClineProvider | |
| */ | ||
| private updateTaskApiHandlerIfNeeded( | ||
| providerSettings: ProviderSettings, | ||
| options: { forceRebuild?: boolean } = {}, | ||
| options: { forceRebuild?: boolean; skipCurrentTaskRebuild?: boolean } = {}, | ||
| ): void { | ||
| if (options.skipCurrentTaskRebuild) return | ||
| const task = this.getCurrentTask() | ||
| if (!task) return | ||
|
|
||
|
|
@@ -1653,45 +1734,49 @@ export class ClineProvider | |
| activate: boolean = true, | ||
| ): Promise<string | undefined> { | ||
| try { | ||
| // TODO: Do we need to be calling `activateProfile`? It's not | ||
| // clear to me what the source of truth should be; in some cases | ||
| // we rely on the `ContextProxy`'s data store and in other cases | ||
| // we rely on the `ProviderSettingsManager`'s data store. It might | ||
| // be simpler to unify these two. | ||
| const id = await this.providerSettingsManager.saveConfig(name, providerSettings) | ||
|
|
||
| if (activate) { | ||
| const { mode } = await this.getState() | ||
|
|
||
| // These promises do the following: | ||
| // 1. Adds or updates the list of provider profiles. | ||
| // 2. Sets the current provider profile. | ||
| // 3. Sets the current mode's provider profile. | ||
| // 4. Copies the provider settings to the context. | ||
| // | ||
| // Note: 1, 2, and 4 can be done in one `ContextProxy` call: | ||
| // this.contextProxy.setValues({ ...providerSettings, listApiConfigMeta: ..., currentApiConfigName: ... }) | ||
| // We should probably switch to that and verify that it works. | ||
| // I left the original implementation in just to be safe. | ||
| await Promise.all([ | ||
| this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), | ||
| this.updateGlobalState("currentApiConfigName", name), | ||
| this.providerSettingsManager.setModeConfig(mode, id), | ||
| this.contextProxy.setProviderSettings(providerSettings), | ||
| ]) | ||
|
|
||
| // Change the provider for the current task. | ||
| // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). | ||
| this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) | ||
|
|
||
| // Keep the current task's sticky provider profile in sync with the newly-activated profile. | ||
| await this.persistStickyProviderProfileToCurrentTask(name) | ||
| } else { | ||
| await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()) | ||
| } | ||
| return await this.enqueueProviderProfileMutation(async (signal) => { | ||
| // TODO: Do we need to be calling `activateProfile`? It's not | ||
| // clear to me what the source of truth should be; in some cases | ||
| // we rely on the `ContextProxy`'s data store and in other cases | ||
| // we rely on the `ProviderSettingsManager`'s data store. It might | ||
| // be simpler to unify these two. | ||
| const id = await this.providerSettingsManager.saveConfig(name, providerSettings) | ||
|
|
||
| if (signal.aborted) return id | ||
|
|
||
| if (activate) { | ||
| const { mode } = await this.getState() | ||
|
|
||
| // These promises do the following: | ||
| // 1. Adds or updates the list of provider profiles. | ||
| // 2. Sets the current provider profile. | ||
| // 3. Sets the current mode's provider profile. | ||
| // 4. Copies the provider settings to the context. | ||
| // | ||
| // Note: 1, 2, and 4 can be done in one `ContextProxy` call: | ||
| // this.contextProxy.setValues({ ...providerSettings, listApiConfigMeta: ..., currentApiConfigName: ... }) | ||
| // We should probably switch to that and verify that it works. | ||
| // I left the original implementation in just to be safe. | ||
| await Promise.all([ | ||
| this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), | ||
| this.updateGlobalState("currentApiConfigName", name), | ||
| this.providerSettingsManager.setModeConfig(mode, id), | ||
| this.contextProxy.setProviderSettings(providerSettings), | ||
| ]) | ||
|
|
||
| // Change the provider for the current task. | ||
| // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). | ||
| this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) | ||
|
|
||
| // Keep the current task's sticky provider profile in sync with the newly-activated profile. | ||
| await this.persistStickyProviderProfileToCurrentTask(name) | ||
| } else { | ||
| await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()) | ||
| } | ||
|
|
||
| await this.postStateToWebview() | ||
| return id | ||
| await this.postStateToWebview() | ||
| return id | ||
| }) | ||
| } catch (error) { | ||
| this.log( | ||
| `Error create new api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, | ||
|
|
@@ -1725,7 +1810,11 @@ export class ClineProvider | |
| await this.postStateToWebview() | ||
| } | ||
|
|
||
| private async persistStickyProviderProfileToCurrentTask(apiConfigName: string): Promise<void> { | ||
| private async persistStickyProviderProfileToCurrentTask( | ||
| apiConfigName: string, | ||
| options: { skipCurrentTaskRebuild?: boolean } = {}, | ||
| ): Promise<void> { | ||
| if (options.skipCurrentTaskRebuild) return | ||
| const task = this.getCurrentTask() | ||
| if (!task) { | ||
| return | ||
|
|
@@ -1755,19 +1844,42 @@ export class ClineProvider | |
|
|
||
| async activateProviderProfile( | ||
| args: { name: string } | { id: string }, | ||
| options?: { persistModeConfig?: boolean; persistTaskHistory?: boolean }, | ||
| options?: { | ||
| persistModeConfig?: boolean | ||
| persistTaskHistory?: boolean | ||
| skipCurrentTaskRebuild?: boolean | ||
| }, | ||
| ) { | ||
| return this.enqueueProviderProfileMutation((signal) => | ||
| this.activateProviderProfileUnlocked(args, options, signal), | ||
| ) | ||
| } | ||
|
|
||
| private async activateProviderProfileUnlocked( | ||
| args: { name: string } | { id: string }, | ||
| options?: { | ||
| persistModeConfig?: boolean | ||
| persistTaskHistory?: boolean | ||
| skipCurrentTaskRebuild?: boolean | ||
| }, | ||
| signal?: AbortSignal, | ||
| ): Promise<void> { | ||
| const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args) | ||
|
|
||
| if (signal?.aborted) return | ||
|
|
||
| const persistModeConfig = options?.persistModeConfig ?? true | ||
| const persistTaskHistory = options?.persistTaskHistory ?? true | ||
|
|
||
| // See `upsertProviderProfile` for a description of what this is doing. | ||
| await Promise.all([ | ||
| this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), | ||
| this.contextProxy.setValue("currentApiConfigName", name), | ||
| this.contextProxy.setProviderSettings(providerSettings), | ||
| ]) | ||
| const skipCurrentTaskRebuild = options?.skipCurrentTaskRebuild ?? false | ||
|
|
||
| if (!skipCurrentTaskRebuild) { | ||
| // See `upsertProviderProfile` for a description of what this is doing. | ||
| await Promise.all([ | ||
| this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), | ||
| this.contextProxy.setValue("currentApiConfigName", name), | ||
| this.contextProxy.setProviderSettings(providerSettings), | ||
| ]) | ||
| } | ||
|
Comment on lines
1867
to
+1882
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
fd -t f 'ProviderSettingsManager.ts' --exec ast-grep outline {} --items all
fd -t f 'ProviderSettingsManager.ts' --exec rg -n -C 25 'activateProfile' {}Repository: Zoo-Code-Org/Zoo-Code Length of output: 3025 Avoid synchronizing the store when
🤖 Prompt for AI Agents |
||
|
|
||
| const { mode } = await this.getState() | ||
|
|
||
|
|
@@ -1776,17 +1888,19 @@ export class ClineProvider | |
| } | ||
|
|
||
| // Change the provider for the current task. | ||
| this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) | ||
| this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true, skipCurrentTaskRebuild }) | ||
|
|
||
| // Update the current task's sticky provider profile, unless this activation is | ||
| // being used purely as a non-persisting restoration (e.g., reopening a task from history). | ||
| if (persistTaskHistory) { | ||
| await this.persistStickyProviderProfileToCurrentTask(name) | ||
| await this.persistStickyProviderProfileToCurrentTask(name, { skipCurrentTaskRebuild }) | ||
| } | ||
|
|
||
| await this.postStateToWebview() | ||
| if (!skipCurrentTaskRebuild) { | ||
| await this.postStateToWebview() | ||
| } | ||
|
|
||
| if (providerSettings.apiProvider) { | ||
| if (providerSettings.apiProvider && !skipCurrentTaskRebuild) { | ||
| this.emit(RooCodeEventName.ProviderProfileChanged, { name, provider: providerSettings.apiProvider }) | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
The comment claims a stronger guarantee than the code provides.
The queue advances from
callerResult, so after a 30-second timeout a new mutation starts while the abandoned mutation may still run. The comment states that eachfnchecks itsAbortSignalbefore writing state. The current implementations do not meet that condition:handleModeSwitchUnlockedwrites task history,_taskMode,mode, and emitsModeChanged(lines 1588-1612) before any abort check.handleModeSwitchUnlockedwriteslistApiConfigMeta(line 1632) and callssetModeConfig(line 1668) after the last abort check, with several awaits in between.activateProviderProfileUnlockedperforms all context and profile writes after its single check at line 1869.upsertProviderProfileperforms the whole activation block (lines 1747-1777) after its single check at line 1745.An abandoned mutation can therefore overwrite state written by the mutation that followed it in the queue. The caller also receives a rejection while the write can still land later, so the reported outcome and the persisted state can disagree.
Add an abort check immediately before each state write, or route writes through a helper that returns early when the signal is aborted. Alternatively, weaken the comment to describe the actual guarantee.
🤖 Prompt for AI Agents