Skip to content

Commit 8273a44

Browse files
committed
fix(provider): recover timed-out profile mutations
1 parent bb936be commit 8273a44

2 files changed

Lines changed: 101 additions & 11 deletions

File tree

src/core/webview/ClineProvider.ts

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -205,18 +205,43 @@ export class ClineProvider
205205

206206
private enqueueProviderProfileMutation<T>(fn: () => Promise<T>): Promise<T> {
207207
const run = this.providerProfileMutationQueue.then(fn, fn)
208-
const callerResult = this.withProviderProfileMutationTimeout(run)
209-
this.providerProfileMutationQueue = run.then(
208+
let timedOut = false
209+
const callerResult = this.withProviderProfileMutationTimeout(run, () => {
210+
timedOut = true
211+
this.log("Provider profile mutation timed out; releasing the mutation queue")
212+
})
213+
214+
void run.then(
215+
() => {
216+
if (timedOut) {
217+
this.log("Provider profile mutation completed after timing out")
218+
}
219+
},
220+
(error) => {
221+
if (timedOut) {
222+
this.log(
223+
`Provider profile mutation failed after timing out: ${
224+
error instanceof Error ? error.message : String(error)
225+
}`,
226+
)
227+
}
228+
},
229+
)
230+
231+
// Advance from the timeout-bounded caller result, rather than the raw operation. A
232+
// provider call that never settles must not block all subsequent profile changes.
233+
this.providerProfileMutationQueue = callerResult.then(
210234
() => undefined,
211235
() => undefined,
212236
)
213237
return callerResult
214238
}
215239

216-
private withProviderProfileMutationTimeout<T>(operation: Promise<T>): Promise<T> {
240+
private withProviderProfileMutationTimeout<T>(operation: Promise<T>, onTimeout: () => void): Promise<T> {
217241
let timeoutId: ReturnType<typeof setTimeout> | undefined
218242
const timeout = new Promise<never>((_, reject) => {
219243
timeoutId = setTimeout(() => {
244+
onTimeout()
220245
reject(new Error("Provider profile mutation timed out"))
221246
}, ClineProvider.PENDING_OPERATION_TIMEOUT_MS)
222247
})
@@ -1535,8 +1560,10 @@ export class ClineProvider
15351560
* @param targetTask The task whose in-memory mode should be updated. Defaults to the
15361561
* current task. Pass null to apply only global mode/profile effects for a pending child.
15371562
*/
1538-
public async handleModeSwitch(newMode: Mode, targetTask: Task | null | undefined = this.getCurrentTask()) {
1539-
return this.enqueueProviderProfileMutation(() => this.handleModeSwitchUnlocked(newMode, targetTask))
1563+
public async handleModeSwitch(newMode: Mode, targetTask?: Task | null) {
1564+
return this.enqueueProviderProfileMutation(() =>
1565+
this.handleModeSwitchUnlocked(newMode, targetTask === undefined ? this.getCurrentTask() : targetTask),
1566+
)
15401567
}
15411568

15421569
private async handleModeSwitchUnlocked(newMode: Mode, targetTask: Task | null | undefined): Promise<void> {
@@ -1821,12 +1848,14 @@ export class ClineProvider
18211848
const persistTaskHistory = options?.persistTaskHistory ?? true
18221849
const skipCurrentTaskRebuild = options?.skipCurrentTaskRebuild ?? false
18231850

1824-
// See `upsertProviderProfile` for a description of what this is doing.
1825-
await Promise.all([
1826-
this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()),
1827-
this.contextProxy.setValue("currentApiConfigName", name),
1828-
this.contextProxy.setProviderSettings(providerSettings),
1829-
])
1851+
if (!skipCurrentTaskRebuild) {
1852+
// See `upsertProviderProfile` for a description of what this is doing.
1853+
await Promise.all([
1854+
this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()),
1855+
this.contextProxy.setValue("currentApiConfigName", name),
1856+
this.contextProxy.setProviderSettings(providerSettings),
1857+
])
1858+
}
18301859

18311860
const { mode } = await this.getState()
18321861

src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,63 @@ describe("ClineProvider - API Handler Rebuild Guard", () => {
472472
await expect(provider.activateProviderProfile({ name: "second-profile" })).resolves.toBeUndefined()
473473
})
474474

475+
test("provider profile mutation timeout releases later queued mutations", async () => {
476+
vi.useFakeTimers()
477+
const logSpy = vi.spyOn(provider, "log")
478+
provider["providerSettingsManager"].activateProfile = vi
479+
.fn()
480+
.mockImplementationOnce(() => new Promise<never>(() => {}))
481+
.mockResolvedValueOnce({
482+
name: "second-profile",
483+
id: "second-id",
484+
apiProvider: "openrouter",
485+
openRouterModelId: "openai/gpt-4.1-mini",
486+
})
487+
488+
try {
489+
const first = provider.activateProviderProfile({ name: "first-profile" })
490+
const firstResult = expect(first).rejects.toThrow("Provider profile mutation timed out")
491+
await vi.advanceTimersByTimeAsync(30_000)
492+
await firstResult
493+
494+
await expect(provider.activateProviderProfile({ name: "second-profile" })).resolves.toBeUndefined()
495+
expect(logSpy).toHaveBeenCalledWith("Provider profile mutation timed out; releasing the mutation queue")
496+
} finally {
497+
vi.useRealTimers()
498+
}
499+
})
500+
501+
test("mode switch resolves its default task when its queued mutation starts", async () => {
502+
let releaseProfileActivation!: () => void
503+
const profileActivation = new Promise<void>((resolve) => {
504+
releaseProfileActivation = resolve
505+
})
506+
provider["providerSettingsManager"].activateProfile = vi.fn().mockImplementationOnce(async () => {
507+
await profileActivation
508+
return {
509+
name: "first-profile",
510+
id: "first-id",
511+
apiProvider: "openrouter",
512+
openRouterModelId: "openai/gpt-4",
513+
}
514+
})
515+
516+
const firstTask = new Task(defaultTaskOptions)
517+
const secondTask = new Task(defaultTaskOptions)
518+
await provider.addClineToStack(firstTask)
519+
520+
const profileSwitch = provider.activateProviderProfile({ name: "first-profile" })
521+
const modeSwitch = provider.handleModeSwitch("ask" as Mode)
522+
await provider.addClineToStack(secondTask)
523+
524+
releaseProfileActivation()
525+
await profileSwitch
526+
await modeSwitch
527+
528+
expect((firstTask as unknown as { _taskMode?: Mode })._taskMode).not.toBe("ask")
529+
expect((secondTask as unknown as { _taskMode?: Mode })._taskMode).toBe("ask")
530+
})
531+
475532
test("fan-out preparation leaves the focused task untouched", async () => {
476533
const mockTask = new Task({
477534
...defaultTaskOptions,
@@ -499,6 +556,8 @@ describe("ClineProvider - API Handler Rebuild Guard", () => {
499556
})
500557
const emitSpy = vi.spyOn(provider, "emit")
501558
const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined)
559+
const setValueSpy = vi.spyOn(provider.contextProxy, "setValue")
560+
const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings")
502561

503562
await provider.handleModeSwitch("ask" as Mode, null)
504563

@@ -509,6 +568,8 @@ describe("ClineProvider - API Handler Rebuild Guard", () => {
509568
expect.objectContaining({ name: "ask-profile" }),
510569
)
511570
expect(postStateSpy).not.toHaveBeenCalled()
571+
expect(setValueSpy).not.toHaveBeenCalledWith("currentApiConfigName", "ask-profile")
572+
expect(setProviderSettingsSpy).not.toHaveBeenCalled()
512573
})
513574

514575
test("calls updateApiConfiguration when provider/model unchanged but settings differ (explicit profile switch)", async () => {

0 commit comments

Comments
 (0)