Handle cancel/resume abort races without crashing (#11422)

This commit is contained in:
Chris Estreich 2026-02-11 15:55:08 -08:00 committed by GitHub
parent b51af98278
commit 77b76a891f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 113 additions and 3 deletions

View file

@ -749,15 +749,49 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
if (startTask) {
this._started = true
if (task || images) {
this.startTask(task, images)
this.runLifecycleTaskInBackground(this.startTask(task, images), "startTask")
} else if (historyItem) {
this.resumeTaskFromHistory()
this.runLifecycleTaskInBackground(this.resumeTaskFromHistory(), "resumeTaskFromHistory")
} else {
throw new Error("Either historyItem or task/images must be provided")
}
}
}
private runLifecycleTaskInBackground(taskPromise: Promise<void>, operation: "startTask" | "resumeTaskFromHistory") {
void taskPromise.catch((error) => {
if (this.shouldIgnoreBackgroundLifecycleError(error)) {
return
}
console.error(
`[Task#${operation}] task ${this.taskId}.${this.instanceId} failed: ${
error instanceof Error ? error.message : String(error)
}`,
)
})
}
private shouldIgnoreBackgroundLifecycleError(error: unknown): boolean {
if (error instanceof AskIgnoredError) {
return true
}
if (this.abandoned === true || this.abort === true || this.abortReason === "user_cancelled") {
return true
}
if (!(error instanceof Error)) {
return false
}
const abortedByCurrentTask =
error.message.includes(`[RooCode#ask] task ${this.taskId}.${this.instanceId} aborted`) ||
error.message.includes(`[RooCode#say] task ${this.taskId}.${this.instanceId} aborted`)
return abortedByCurrentTask
}
/**
* Initialize the task mode from the provider state.
* This method handles async initialization with proper error handling.
@ -2067,7 +2101,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const { task, images } = this.metadata
if (task || images) {
this.startTask(task ?? undefined, images ?? undefined)
this.runLifecycleTaskInBackground(this.startTask(task ?? undefined, images ?? undefined), "startTask")
}
}

View file

@ -394,6 +394,82 @@ describe("Cline", () => {
new Task({ provider: mockProvider, apiConfiguration: mockApiConfig })
}).toThrow("Either historyItem or task/images must be provided")
})
it("should ignore cancelled background resumeTaskFromHistory errors", async () => {
const resumeSpy = vi
.spyOn(Task.prototype as any, "resumeTaskFromHistory")
.mockImplementationOnce(async function (this: Task) {
this.abort = true
throw new Error("resume aborted")
})
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
historyItem: {
id: "history-task-id",
number: 1,
ts: Date.now(),
task: "historical task",
tokensIn: 0,
tokensOut: 0,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0,
} as any,
startTask: true,
})
await Promise.resolve()
await Promise.resolve()
const lifecycleErrors = consoleErrorSpy.mock.calls.filter(
([message]) => typeof message === "string" && message.includes("[Task#resumeTaskFromHistory]"),
)
expect(lifecycleErrors).toHaveLength(0)
resumeSpy.mockRestore()
consoleErrorSpy.mockRestore()
})
it("should log unexpected background resumeTaskFromHistory errors", async () => {
const resumeSpy = vi
.spyOn(Task.prototype as any, "resumeTaskFromHistory")
.mockRejectedValueOnce(new Error("unexpected resume failure"))
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
historyItem: {
id: "history-task-id",
number: 1,
ts: Date.now(),
task: "historical task",
tokensIn: 0,
tokensOut: 0,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0,
} as any,
startTask: true,
})
await Promise.resolve()
await Promise.resolve()
const lifecycleErrors = consoleErrorSpy.mock.calls.filter(
([message]) =>
typeof message === "string" &&
message.includes("[Task#resumeTaskFromHistory]") &&
message.includes("unexpected resume failure"),
)
expect(lifecycleErrors).toHaveLength(1)
resumeSpy.mockRestore()
consoleErrorSpy.mockRestore()
})
})
describe("getEnvironmentDetails", () => {