Explorar el Código

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

Chris Estreich hace 1 día
padre
commit
77b76a891f
Se han modificado 2 ficheros con 113 adiciones y 3 borrados
  1. 37 3
      src/core/task/Task.ts
  2. 76 0
      src/core/task/__tests__/Task.spec.ts

+ 37 - 3
src/core/task/Task.ts

@@ -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")
 		}
 	}
 

+ 76 - 0
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", () => {