Skip to content

Commit 3eb6271

Browse files
fix(task): preserve lifecycle on state push failures
Log throttled state scheduling and flush failures without dropping message persistence or abort cleanup. Cover Task rejection paths and failures from a pending debounced flush. Signed-off-by: JunyongParkDev <jun94.park@samsung.com>
1 parent 82eae9c commit 3eb6271

3 files changed

Lines changed: 111 additions & 3 deletions

File tree

src/core/task/Task.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,9 +1047,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
10471047
// Unanswered asks must reach the webview before Message listeners can respond against its state.
10481048
const requiresImmediateState =
10491049
message.partial === true || (message.type === "ask" && message.isAnswered !== true)
1050-
await provider?.postStateToWebviewThrottled()
1050+
try {
1051+
await provider?.postStateToWebviewThrottled()
1052+
} catch (error) {
1053+
console.error("[Task#addToClineMessages] postStateToWebviewThrottled failed:", error)
1054+
}
10511055
if (requiresImmediateState) {
1052-
await provider?.flushPostStateToWebviewThrottled()
1056+
try {
1057+
await provider?.flushPostStateToWebviewThrottled()
1058+
} catch (error) {
1059+
console.error("[Task#addToClineMessages] flushPostStateToWebviewThrottled failed:", error)
1060+
}
10531061
}
10541062
this.emit(RooCodeEventName.Message, { action: "created", message })
10551063
await this.saveClineMessages()
@@ -2251,7 +2259,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
22512259
// Force final token usage update before abort event
22522260
this.emitFinalTokenUsageUpdate()
22532261

2254-
await this.providerRef.deref()?.flushPostStateToWebviewThrottled()
2262+
try {
2263+
await this.providerRef.deref()?.flushPostStateToWebviewThrottled()
2264+
} catch (error) {
2265+
console.error(
2266+
`[Task#abortTask] flushPostStateToWebviewThrottled failed for ${this.taskId}.${this.instanceId}:`,
2267+
error,
2268+
)
2269+
}
22552270

22562271
this.emit(RooCodeEventName.TaskAborted)
22572272

src/core/task/__tests__/Task.spec.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1733,6 +1733,49 @@ describe("Cline", () => {
17331733
expect(messageListener).toHaveBeenCalledWith({ action: "created", message })
17341734
})
17351735

1736+
it("continues the message lifecycle when throttled state scheduling and flushing fail", async () => {
1737+
const task = new Task({
1738+
provider: mockProvider,
1739+
apiConfiguration: mockApiConfig,
1740+
task: "test task",
1741+
startTask: false,
1742+
})
1743+
const taskAccess = getTaskTestAccess(task)
1744+
const postError = new Error("state schedule failed")
1745+
const flushError = new Error("state flush failed")
1746+
const postSpy = vi.mocked(mockProvider.postStateToWebviewThrottled).mockRejectedValueOnce(postError)
1747+
const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockRejectedValueOnce(flushError)
1748+
const saveSpy = vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true)
1749+
const messageListener = vi.fn()
1750+
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
1751+
task.on(RooCodeEventName.Message, messageListener)
1752+
const message = {
1753+
ts: 1,
1754+
type: "ask" as const,
1755+
ask: "resume_task" as const,
1756+
}
1757+
1758+
await expect(taskAccess.addToClineMessages(message)).resolves.toBeUndefined()
1759+
1760+
expect(consoleErrorSpy).toHaveBeenCalledWith(
1761+
"[Task#addToClineMessages] postStateToWebviewThrottled failed:",
1762+
postError,
1763+
)
1764+
expect(consoleErrorSpy).toHaveBeenCalledWith(
1765+
"[Task#addToClineMessages] flushPostStateToWebviewThrottled failed:",
1766+
flushError,
1767+
)
1768+
expect(postSpy).toHaveBeenCalledOnce()
1769+
expect(flushSpy).toHaveBeenCalledOnce()
1770+
expect(messageListener).toHaveBeenCalledWith({ action: "created", message })
1771+
expect(saveSpy).toHaveBeenCalledOnce()
1772+
expect(postSpy.mock.invocationCallOrder[0]).toBeLessThan(flushSpy.mock.invocationCallOrder[0])
1773+
expect(flushSpy.mock.invocationCallOrder[0]).toBeLessThan(messageListener.mock.invocationCallOrder[0])
1774+
expect(messageListener.mock.invocationCallOrder[0]).toBeLessThan(saveSpy.mock.invocationCallOrder[0])
1775+
1776+
consoleErrorSpy.mockRestore()
1777+
})
1778+
17361779
it("keeps an already answered ask on the throttled path", async () => {
17371780
const task = new Task({
17381781
provider: mockProvider,
@@ -1877,6 +1920,38 @@ describe("Cline", () => {
18771920
)
18781921
})
18791922

1923+
it("continues abort cleanup when flushing pending state fails", async () => {
1924+
const task = new Task({
1925+
provider: mockProvider,
1926+
apiConfiguration: mockApiConfig,
1927+
task: "test task",
1928+
startTask: false,
1929+
})
1930+
const error = new Error("state flush failed")
1931+
const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockRejectedValueOnce(error)
1932+
const taskAbortedListener = vi.fn()
1933+
const disposeSpy = vi.spyOn(task, "dispose").mockImplementation(() => {})
1934+
const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true)
1935+
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
1936+
task.on(RooCodeEventName.TaskAborted, taskAbortedListener)
1937+
1938+
await expect(task.abortTask()).resolves.toBeUndefined()
1939+
1940+
expect(consoleErrorSpy).toHaveBeenCalledWith(
1941+
`[Task#abortTask] flushPostStateToWebviewThrottled failed for ${task.taskId}.${task.instanceId}:`,
1942+
error,
1943+
)
1944+
expect(task.abort).toBe(true)
1945+
expect(flushSpy).toHaveBeenCalledOnce()
1946+
expect(taskAbortedListener).toHaveBeenCalledOnce()
1947+
expect(disposeSpy).toHaveBeenCalledOnce()
1948+
expect(saveSpy).toHaveBeenCalledOnce()
1949+
expect(flushSpy.mock.invocationCallOrder[0]).toBeLessThan(taskAbortedListener.mock.invocationCallOrder[0])
1950+
expect(taskAbortedListener.mock.invocationCallOrder[0]).toBeLessThan(disposeSpy.mock.invocationCallOrder[0])
1951+
1952+
consoleErrorSpy.mockRestore()
1953+
})
1954+
18801955
it("should work with TaskLike interface", async () => {
18811956
const task = new Task({
18821957
provider: mockProvider,

src/core/webview/__tests__/ClineProvider.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -907,6 +907,24 @@ describe("ClineProvider", () => {
907907
)
908908
})
909909

910+
test("handles state post failures while flushing a pending trailing post", async () => {
911+
const error = new Error("state post failed")
912+
const logSpy = vi.spyOn(provider, "log").mockImplementation(() => {})
913+
const postStateSpy = vi
914+
.spyOn(provider, "postStateToWebviewWithoutTaskHistory")
915+
.mockResolvedValueOnce(undefined)
916+
.mockRejectedValueOnce(error)
917+
918+
await provider.postStateToWebviewThrottled()
919+
await provider.postStateToWebviewThrottled()
920+
await expect(provider.flushPostStateToWebviewThrottled()).resolves.toBeUndefined()
921+
922+
expect(postStateSpy).toHaveBeenCalledTimes(2)
923+
expect(logSpy).toHaveBeenCalledWith(
924+
"[ClineProvider#postStateToWebviewThrottled] Failed to post state: state post failed",
925+
)
926+
})
927+
910928
test("cancels pending work on dispose and ignores later schedule or flush calls", async () => {
911929
const postStateSpy = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined)
912930

0 commit comments

Comments
 (0)