diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 7aa370f7d2a..571a22d1b78 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -149,6 +149,12 @@ export class ClineProvider private uiUpdatePaused: boolean = false private pendingState: ExtensionState | null = null + // Resumption gating (hotfix) + // When true, provider.cancelTask() will not schedule presentResumableAsk + private suppressResumeAsk: boolean = false + // Deduplicate presentResumableAsk scheduling per taskId + private resumeAskScheduledForTaskId?: string + public isViewLaunched = false public settingsImportedAt?: number public readonly latestAnnouncementId = "nov-2025-v3.30.0-pr-fixer" // v3.30.0 PR Fixer announcement @@ -1641,6 +1647,14 @@ export class ClineProvider } } + /** + * Hotfix: Suppress scheduling of the "Present Resume/Terminate" ask in cancel path. + * Used to prevent overlap with checkpoint restore or other resumption flows. + */ + public setSuppressResumeAsk(suppress: boolean): void { + this.suppressResumeAsk = suppress + } + async postStateToWebview() { const state = await this.getStateToPostToWebview() @@ -2695,18 +2709,54 @@ export class ClineProvider // Update UI immediately to reflect current state await this.postStateToWebview() - // Schedule non-blocking resumption to present "Resume Task" ask + // Schedule non-blocking resumption to present "Resume Task" ask. + // Hotfix gating: suppress and dedupe to avoid concurrent resumptions. + if (this.suppressResumeAsk) { + console.log( + `[cancelTask] suppressResumeAsk=true; skipping resumable ask scheduling for ${task.taskId}.${task.instanceId}`, + ) + return + } + + // Deduplicate scheduling for the same task + if (this.resumeAskScheduledForTaskId === task.taskId) { + console.log(`[cancelTask] resume ask already scheduled for ${task.taskId}.${task.instanceId}`) + return + } + this.resumeAskScheduledForTaskId = task.taskId + // Use setImmediate to avoid blocking the webview handler setImmediate(() => { - if (task && !task.abandoned) { + try { + // Re-check suppression at callback time + if (this.suppressResumeAsk) { + this.resumeAskScheduledForTaskId = undefined + return + } + + // Guard against task switch or abandonment + const current = this.getCurrentTask() + if (!current || current.taskId !== task.taskId || current.abandoned) { + this.resumeAskScheduledForTaskId = undefined + return + } + // Present a resume ask without rehydrating - just show the Resume/Terminate UI - task.presentResumableAsk().catch((error) => { - console.error( - `[cancelTask] Failed to present resume ask: ${ - error instanceof Error ? error.message : String(error) - }`, - ) - }) + current + .presentResumableAsk() + .catch((error) => { + console.error( + `[cancelTask] Failed to present resume ask: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + }) + .finally(() => { + this.resumeAskScheduledForTaskId = undefined + }) + } catch (e) { + this.resumeAskScheduledForTaskId = undefined + throw e } }) } diff --git a/src/core/webview/__tests__/ClineProvider.cancelTask.present-ask.spec.ts b/src/core/webview/__tests__/ClineProvider.cancelTask.present-ask.spec.ts index f89dcc4c5ad..f9141094737 100644 --- a/src/core/webview/__tests__/ClineProvider.cancelTask.present-ask.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.cancelTask.present-ask.spec.ts @@ -57,4 +57,28 @@ describe("ClineProvider.cancelTask - schedules presentResumableAsk", () => { await Promise.resolve() expect(mockTask.presentResumableAsk).toHaveBeenCalledTimes(1) }) + + it("skips scheduling when suppressResumeAsk is true", async () => { + // Arrange + provider.setSuppressResumeAsk(true) + + // Act + await (provider as any).cancelTask() + + // Assert + vi.runAllTimers() + await Promise.resolve() + expect(mockTask.presentResumableAsk).not.toHaveBeenCalled() + }) + + it("dedupes multiple cancelTask calls for same taskId", async () => { + // Act: call cancel twice rapidly + await (provider as any).cancelTask() + await (provider as any).cancelTask() + + // Assert + vi.runAllTimers() + await Promise.resolve() + expect(mockTask.presentResumableAsk).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.resume-gating.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.resume-gating.spec.ts new file mode 100644 index 00000000000..e62b775eac0 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.resume-gating.spec.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { webviewMessageHandler } from "../webviewMessageHandler" + +describe("webviewMessageHandler - resume gating on checkpointRestore", () => { + let mockProvider: any + + beforeEach(() => { + vi.clearAllMocks() + + const mockCline = { + isInitialized: true, + checkpointRestore: vi.fn().mockResolvedValue(undefined), + } + + mockProvider = { + beginStateTransaction: vi.fn(), + endStateTransaction: vi.fn().mockResolvedValue(undefined), + setSuppressResumeAsk: vi.fn(), + cancelTask: vi.fn().mockResolvedValue(undefined), + getCurrentTask: vi.fn(() => mockCline), + } + }) + + it("sets suppressResumeAsk around cancel + restore flow", async () => { + await webviewMessageHandler(mockProvider, { + type: "checkpointRestore", + payload: { + commitHash: "abc123", + ts: Date.now(), + mode: "restore", + }, + } as any) + + // Ensure gating is toggled on then off + expect(mockProvider.setSuppressResumeAsk).toHaveBeenCalledWith(true) + expect(mockProvider.cancelTask).toHaveBeenCalledTimes(1) + expect(mockProvider.endStateTransaction).toHaveBeenCalledTimes(1) + expect(mockProvider.setSuppressResumeAsk).toHaveBeenLastCalledWith(false) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 0a4bd9abb90..97e7748fbff 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1034,6 +1034,7 @@ export const webviewMessageHandler = async ( if (result.success) { // Begin transaction to buffer state updates + provider.setSuppressResumeAsk(true) provider.beginStateTransaction() try { @@ -1053,6 +1054,7 @@ export const webviewMessageHandler = async ( } finally { // End transaction and post consolidated state await provider.endStateTransaction() + provider.setSuppressResumeAsk(false) } }