diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 7c0355e498..8f1b1eaed4 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1701,19 +1701,43 @@ export class Task extends EventEmitter implements TaskLike { this.abort = true this.emit(RooCodeEventName.TaskAborted) - try { - this.dispose() // Call the centralized dispose method - } catch (error) { - console.error(`Error during task ${this.taskId}.${this.instanceId} disposal:`, error) - // Don't rethrow - we want abort to always succeed - } - // Save the countdown message in the automatic retry or other content. - try { - // Save the countdown message in the automatic retry or other content. - await this.saveClineMessages() - } catch (error) { - console.error(`Error saving messages during abort for task ${this.taskId}.${this.instanceId}:`, error) - } + // Run disposal and message saving with a timeout to prevent hanging + // This is crucial for unreachable endpoints that might cause network timeouts + const abortTasks = [ + // Dispose task resources with timeout + // Wrap in Promise.resolve().then() to handle synchronous errors properly + Promise.race([ + Promise.resolve().then(() => this.dispose()), + new Promise((resolve) => + setTimeout(() => { + console.warn(`[abortTask] Disposal timed out for task ${this.taskId}.${this.instanceId}`) + resolve(undefined) + }, 2000), + ), // 2 second timeout for disposal + ]).catch((error) => { + console.error(`Error during task ${this.taskId}.${this.instanceId} disposal:`, error) + // Don't rethrow - we want abort to always succeed + }), + + // Save messages with timeout + // Wrap in Promise.resolve().then() to handle synchronous errors properly + Promise.race([ + Promise.resolve().then(() => this.saveClineMessages()), + new Promise((resolve) => + setTimeout(() => { + console.warn(`[abortTask] Message saving timed out for task ${this.taskId}.${this.instanceId}`) + resolve(undefined) + }, 1000), + ), // 1 second timeout for saving messages + ]).catch((error) => { + console.error(`Error saving messages during abort for task ${this.taskId}.${this.instanceId}:`, error) + // Don't rethrow - we want abort to always succeed + }), + ] + + // Wait for all abort tasks to complete (or timeout) + // Using allSettled ensures we wait for all operations regardless of success/failure + await Promise.allSettled(abortTasks) } public dispose(): void { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index ff97d5f030..b54017a720 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2734,44 +2734,66 @@ export class ClineProvider // Immediately mark the original instance as abandoned to prevent any residual activity task.abandoned = true - await pWaitFor( - () => - this.getCurrentTask()! === undefined || - this.getCurrentTask()!.isStreaming === false || - this.getCurrentTask()!.didFinishAbortingStream || - // If only the first chunk is processed, then there's no - // need to wait for graceful abort (closes edits, browser, - // etc). - this.getCurrentTask()!.isWaitingForFirstChunk, - { - timeout: 3_000, - }, - ).catch(() => { - console.error("Failed to abort task") - }) + // Don't block the UI - handle the abort completion and rehydration asynchronously + // This allows the UI to respond immediately to the cancel button click + const handleAbortAndRehydrate = async () => { + try { + // Wait for abort to complete (with a shorter timeout to be more responsive) + await pWaitFor( + () => + this.getCurrentTask()! === undefined || + this.getCurrentTask()!.isStreaming === false || + this.getCurrentTask()!.didFinishAbortingStream || + // If only the first chunk is processed, then there's no + // need to wait for graceful abort (closes edits, browser, + // etc). + this.getCurrentTask()!.isWaitingForFirstChunk, + { + timeout: 1_000, // Reduced from 3 seconds to 1 second for better responsiveness + }, + ).catch(() => { + console.error("Abort wait timed out, proceeding with rehydration") + }) + } catch (error) { + console.error("Error waiting for abort:", error) + } - // Defensive safeguard: if current instance already changed, skip rehydrate - const current = this.getCurrentTask() - if (current && current.instanceId !== originalInstanceId) { - this.log( - `[cancelTask] Skipping rehydrate: current instance ${current.instanceId} != original ${originalInstanceId}`, - ) - return - } - - // Final race check before rehydrate to avoid duplicate rehydration - { - const currentAfterCheck = this.getCurrentTask() - if (currentAfterCheck && currentAfterCheck.instanceId !== originalInstanceId) { + // Defensive safeguard: if current instance already changed, skip rehydrate + const current = this.getCurrentTask() + if (current && current.instanceId !== originalInstanceId) { this.log( - `[cancelTask] Skipping rehydrate after final check: current instance ${currentAfterCheck.instanceId} != original ${originalInstanceId}`, + `[cancelTask] Skipping rehydrate: current instance ${current.instanceId} != original ${originalInstanceId}`, ) return } + + // Final race check before rehydrate to avoid duplicate rehydration + { + const currentAfterCheck = this.getCurrentTask() + if (currentAfterCheck && currentAfterCheck.instanceId !== originalInstanceId) { + this.log( + `[cancelTask] Skipping rehydrate after final check: current instance ${currentAfterCheck.instanceId} != original ${originalInstanceId}`, + ) + return + } + } + + try { + // Rehydrate the task with the history item + await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask }) + } catch (error) { + this.log(`[cancelTask] Failed to rehydrate task: ${error}`) + } } - // Clears task again, so we need to abortTask manually above. - await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask }) + // Start the abort and rehydration process in the background + // This returns immediately so the UI is not blocked + handleAbortAndRehydrate().catch((error) => { + this.log(`[cancelTask] Background abort/rehydration failed: ${error}`) + }) + + // Immediately update the UI to show the task is being cancelled + await this.postStateToWebview() } // Clear the current task without treating it as a subtask.