diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index ee9f15e4e30..f7188de64fb 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -749,15 +749,49 @@ export class Task extends EventEmitter 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, 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 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") } } diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index f412525f6a1..1f034f29042 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -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", () => {