Skip to content

Commit 9f263ed

Browse files
committed
fix: address PR review feedback for task cancellation
- Add proper resource disposal in resetToResumableState() to prevent memory leaks - Add JSDoc documentation for skipSave parameter in abortTask() - Add error handling for resetToResumableState() and ask() operations - Extract resumption logic into dedicated handleUserCancellationResume() method - Add 30-second timeout safety net for cancellation in ClineProvider These changes ensure proper cleanup of resources (browsers, terminals) during task cancellation while maintaining the no-rerender behavior.
1 parent 048ea9e commit 9f263ed

File tree

2 files changed

+91
-27
lines changed

2 files changed

+91
-27
lines changed

src/core/task/Task.ts

Lines changed: 71 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1273,6 +1273,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
12731273
}
12741274
}
12751275

1276+
/**
1277+
* Aborts the current task and optionally saves messages.
1278+
*
1279+
* @param isAbandoned - If true, marks the task as abandoned (no cleanup needed)
1280+
* @param skipSave - If true, skips saving messages (used during user cancellation when messages are already saved)
1281+
*/
12761282
public async abortTask(isAbandoned = false, skipSave = false) {
12771283
console.log(`[subtasks] aborting task ${this.taskId}.${this.instanceId}`)
12781284

@@ -1305,6 +1311,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
13051311
/**
13061312
* Reset the task to a resumable state without recreating the instance.
13071313
* This is used when canceling a task to avoid unnecessary rerenders.
1314+
*
1315+
* IMPORTANT: This method cleans up resources to prevent memory leaks
1316+
* while preserving the task instance for resumption.
13081317
*/
13091318
public async resetToResumableState() {
13101319
console.log(`[subtasks] resetting task ${this.taskId}.${this.instanceId} to resumable state`)
@@ -1350,9 +1359,27 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
13501359
await this.diffViewProvider.reset()
13511360
}
13521361

1353-
// The task is now ready to be resumed
1354-
// The API request status has already been updated by abortStream
1355-
// We don't add the resume_task message here because ask() will add it
1362+
// Dispose of resources that could accumulate if tasks are repeatedly cancelled
1363+
// These will be recreated as needed when the task resumes
1364+
try {
1365+
// Close browser sessions to free memory and browser processes
1366+
if (this.urlContentFetcher) {
1367+
this.urlContentFetcher.closeBrowser()
1368+
}
1369+
if (this.browserSession) {
1370+
this.browserSession.closeBrowser()
1371+
}
1372+
1373+
// Release any terminals associated with this task
1374+
// They will be recreated if needed when the task resumes
1375+
if (this.terminalProcess) {
1376+
this.terminalProcess.abort()
1377+
this.terminalProcess = undefined
1378+
}
1379+
} catch (error) {
1380+
console.error(`Error disposing resources during resetToResumableState: ${error}`)
1381+
// Continue even if resource cleanup fails
1382+
}
13561383

13571384
// Keep messages and history intact for resumption
13581385
// The task is now ready to be resumed without recreation
@@ -1758,30 +1785,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
17581785

17591786
// Need to call here in case the stream was aborted.
17601787
if (this.abort || this.abandoned) {
1761-
// If this was a user cancellation, reset and show resume prompt
1788+
// If this was a user cancellation, handle resumption
17621789
if (this.abort && !this.abandoned) {
1763-
// Reset the task to a resumable state
1764-
this.abort = false
1765-
await this.resetToResumableState()
1766-
1767-
// Show the resume prompt
1768-
const { response, text, images } = await this.ask("resume_task")
1769-
1770-
if (response === "messageResponse") {
1771-
await this.say("user_feedback", text, images)
1772-
// Continue with the new user input
1773-
const newUserContent: Anthropic.Messages.ContentBlockParam[] = []
1774-
if (text) {
1775-
newUserContent.push({ type: "text", text })
1776-
}
1777-
if (images && images.length > 0) {
1778-
newUserContent.push(...formatResponse.imageBlocks(images))
1779-
}
1780-
// Recursively continue with the new content
1781-
return await this.recursivelyMakeClineRequests(newUserContent)
1782-
}
1783-
// If not messageResponse, the task will end
1784-
return true
1790+
return await this.handleUserCancellationResume()
17851791
}
17861792
throw new Error(`[RooCode#recursivelyMakeRooRequests] task ${this.taskId}.${this.instanceId} aborted`)
17871793
}
@@ -2253,6 +2259,45 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
22532259
}
22542260
}
22552261

2262+
/**
2263+
* Handles the resumption flow after a user cancels a task.
2264+
* This method resets the task state, shows the resume prompt,
2265+
* and continues with new user input if provided.
2266+
*
2267+
* @returns Promise<boolean> - true if the task should end, false to continue
2268+
*/
2269+
private async handleUserCancellationResume(): Promise<boolean> {
2270+
try {
2271+
// Reset the task to a resumable state
2272+
this.abort = false
2273+
await this.resetToResumableState()
2274+
2275+
// Show the resume prompt
2276+
const { response, text, images } = await this.ask("resume_task")
2277+
2278+
if (response === "messageResponse") {
2279+
await this.say("user_feedback", text, images)
2280+
// Continue with the new user input
2281+
const newUserContent: Anthropic.Messages.ContentBlockParam[] = []
2282+
if (text) {
2283+
newUserContent.push({ type: "text", text })
2284+
}
2285+
if (images && images.length > 0) {
2286+
newUserContent.push(...formatResponse.imageBlocks(images))
2287+
}
2288+
// Recursively continue with the new content
2289+
return await this.recursivelyMakeClineRequests(newUserContent)
2290+
}
2291+
// If not messageResponse, the task will end
2292+
return true
2293+
} catch (error) {
2294+
// If there's an error during resumption, log it and end the task
2295+
console.error(`Error during user cancellation resume: ${error}`)
2296+
// Re-throw to maintain existing error handling behavior
2297+
throw error
2298+
}
2299+
}
2300+
22562301
// Getters
22572302

22582303
public get cwd() {

src/core/webview/ClineProvider.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1153,8 +1153,27 @@ export class ClineProvider
11531153
// Just set the abort flag - the task will handle its own resumption
11541154
cline.abort = true
11551155

1156+
// Add a timeout safety net to ensure the task doesn't hang indefinitely
1157+
// If the task doesn't respond to cancellation within 30 seconds, force abort it
1158+
const timeoutMs = 30000 // 30 seconds
1159+
const timeoutPromise = new Promise<void>((resolve) => {
1160+
setTimeout(async () => {
1161+
// Check if the task is still in an aborted state
1162+
if (cline.abort && !cline.abandoned) {
1163+
console.log(
1164+
`[subtasks] task ${cline.taskId}.${cline.instanceId} did not respond to cancellation within ${timeoutMs}ms, forcing abort`,
1165+
)
1166+
// Force abandon the task to ensure cleanup
1167+
cline.abandoned = true
1168+
// Remove it from the stack
1169+
await this.removeClineFromStack()
1170+
resolve()
1171+
}
1172+
}, timeoutMs)
1173+
})
1174+
11561175
// The task's streaming loop will detect the abort flag and handle the resumption
1157-
// No need to wait or do anything else here
1176+
// The timeout ensures we don't wait indefinitely
11581177
}
11591178

11601179
async updateCustomInstructions(instructions?: string) {

0 commit comments

Comments
 (0)