Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 59 additions & 9 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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
}
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
Original file line number Diff line number Diff line change
@@ -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)
})
})
2 changes: 2 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,7 @@ export const webviewMessageHandler = async (

if (result.success) {
// Begin transaction to buffer state updates
provider.setSuppressResumeAsk(true)
provider.beginStateTransaction()

try {
Expand All @@ -1053,6 +1054,7 @@ export const webviewMessageHandler = async (
} finally {
// End transaction and post consolidated state
await provider.endStateTransaction()
provider.setSuppressResumeAsk(false)
}
Comment on lines 1054 to 1058
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If endStateTransaction() throws an exception, setSuppressResumeAsk(false) on line 1057 won't execute, leaving the suppression flag permanently set to true. This would prevent all future resume asks from being scheduled until VS Code is restarted. Nest the cleanup in a second try-finally block to guarantee the flag is always reset:

} finally {
    try {
        await provider.endStateTransaction()
    } finally {
        provider.setSuppressResumeAsk(false)
    }
}

Fix it with Roo Code or mention @roomote and request a fix.

}

Expand Down