From f1a0dd5d9e83fff0b4beeff26beb138bb21dd141 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 16 Sep 2026 14:17:11 +0000 Subject: [PATCH 1/5] perf(webview): stop task history globalState writes Amp-Thread-ID: https://ampcode.com/threads/T-01a0aa02-b6de-7763-97b8-1650ea4b46da --- src/core/webview/ClineProvider.ts | 51 +------------------ .../ClineProvider.taskHistory.spec.ts | 4 ++ 2 files changed, 6 insertions(+), 49 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 495fe454b7..0b3c55e607 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -242,8 +242,6 @@ export class ClineProvider private recentTasksCache?: string[] public readonly taskHistoryStore: TaskHistoryStore private taskHistoryStoreInitialized = false - private globalStateWriteThroughTimer: ReturnType | null = null - private static readonly GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS = 5000 // 5 seconds public static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds private providerProfileMutationQueue = Promise.resolve() private historyTaskCreationQueue = Promise.resolve() @@ -343,14 +341,8 @@ export class ClineProvider this.mdmService = mdmService void this.updateGlobalState("codebaseIndexModels", EMBEDDING_MODEL_PROFILES) - // Initialize the per-task file-based history store. - // The globalState write-through is debounced separately (not on every mutation) - // since per-task files are authoritative and globalState is only for downgrade compat. - this.taskHistoryStore = new TaskHistoryStore(this.contextProxy.globalStorageUri.fsPath, { - onWrite: async () => { - this.scheduleGlobalStateWriteThrough() - }, - }) + // Initialize the authoritative per-task file-based history store. + this.taskHistoryStore = new TaskHistoryStore(this.contextProxy.globalStorageUri.fsPath) this.initializeTaskHistoryStore().catch((error) => { this.log(`Failed to initialize TaskHistoryStore: ${error}`) }) @@ -896,7 +888,6 @@ export class ClineProvider await this.marketplaceManager?.cleanup() this.customModesManager?.dispose() this.taskHistoryStore.dispose() - this.flushGlobalStateWriteThrough() this.log("Disposed all disposables") ClineProvider.activeInstances.delete(this) @@ -3133,44 +3124,6 @@ export class ClineProvider return history } - /** - * Schedule a debounced write-through of task history to globalState. - * Only used for backward compatibility during the transition period. - * Per-task files are authoritative; globalState is the downgrade fallback. - */ - private scheduleGlobalStateWriteThrough(): void { - if (this.globalStateWriteThroughTimer) { - clearTimeout(this.globalStateWriteThroughTimer) - } - - this.globalStateWriteThroughTimer = setTimeout(async () => { - this.globalStateWriteThroughTimer = null - try { - const items = this.taskHistoryStore.getAll() - await this.updateGlobalState("taskHistory", items) - } catch (err) { - this.log( - `[scheduleGlobalStateWriteThrough] Failed: ${err instanceof Error ? err.message : String(err)}`, - ) - } - }, ClineProvider.GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS) - } - - /** - * Flush any pending debounced globalState write-through immediately. - */ - private flushGlobalStateWriteThrough(): void { - if (this.globalStateWriteThroughTimer) { - clearTimeout(this.globalStateWriteThroughTimer) - this.globalStateWriteThroughTimer = null - } - - const items = this.taskHistoryStore.getAll() - this.updateGlobalState("taskHistory", items).catch((err) => { - this.log(`[flushGlobalStateWriteThrough] Failed: ${err instanceof Error ? err.message : String(err)}`) - }) - } - /** * Broadcasts a task history update to the webview. * This sends a lightweight message with just the task history, rather than the full state. diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 2bbf0736c6..3e1596d49f 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -386,6 +386,10 @@ describe("ClineProvider Task History Synchronization", () => { return calls.filter((call) => call[0]?.type === type) } + it("uses per-task files without registering a globalState write-through callback", () => { + expect(provider.taskHistoryStore["onWrite"]).toBeUndefined() + }) + describe("updateTaskHistory", () => { it("broadcasts task history update by default", async () => { await provider.resolveWebviewView(mockWebviewView) From 40450b1485d02d7100d8f0ea2ec3b4facc987ed2 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 16 Sep 2026 15:57:59 +0000 Subject: [PATCH 2/5] fix(history): prevent deleted task fallback Amp-Thread-ID: https://ampcode.com/threads/T-01a0aac1-c3f8-7220-a896-ac2dafbf72e1 --- src/core/webview/ClineProvider.ts | 23 ++++++++++++------- .../webview/__tests__/ClineProvider.spec.ts | 16 +++++++++++++ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 0b3c55e607..df91332044 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1733,9 +1733,7 @@ export class ClineProvider try { // Update the task history with the new mode first. - const taskHistoryItem = - this.taskHistoryStore.get(task.taskId) ?? - (this.getGlobalState("taskHistory") ?? []).find((item) => item.id === task.taskId) + const taskHistoryItem = this.getTaskHistoryItem(task.taskId) if (taskHistoryItem) { await this.updateTaskHistory({ ...taskHistoryItem, mode: newMode }) @@ -1973,9 +1971,7 @@ export class ClineProvider // been persisted into taskHistory (it will be captured on the next save). task.setTaskApiConfigName(apiConfigName) - const taskHistoryItem = - this.taskHistoryStore.get(task.taskId) ?? - (this.getGlobalState("taskHistory") ?? []).find((item) => item.id === task.taskId) + const taskHistoryItem = this.getTaskHistoryItem(task.taskId) if (taskHistoryItem) { await this.updateTaskHistory({ ...taskHistoryItem, apiConfigName }) @@ -2231,6 +2227,18 @@ export class ClineProvider // Task history + private getTaskHistoryItem(id: string): HistoryItem | undefined { + const historyItem = this.taskHistoryStore.get(id) + + // Once initialization and migration succeed, the file-backed store is authoritative. + // Legacy global state is only a fallback while startup is incomplete or has failed. + if (historyItem || this.taskHistoryStoreInitialized) { + return historyItem + } + + return (this.getGlobalState("taskHistory") ?? []).find((item) => item.id === id) + } + async getTaskWithId(id: string): Promise<{ historyItem: HistoryItem taskDirPath: string @@ -2238,8 +2246,7 @@ export class ClineProvider uiMessagesFilePath: string apiConversationHistory: Anthropic.MessageParam[] }> { - const historyItem = - this.taskHistoryStore.get(id) ?? (this.getGlobalState("taskHistory") ?? []).find((item) => item.id === id) + const historyItem = this.getTaskHistoryItem(id) if (!historyItem) { throw new Error("Task not found") diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index bfd4706dcc..71c9048f3a 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -4962,6 +4962,22 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) describe("getTaskWithId", () => { + it("does not restore a deleted file-backed task from legacy history", async () => { + const historyItem = { id: "deleted-task", task: "legacy task", ts: Date.now() } + vi.mocked(mockContext.globalState.get).mockImplementation((key: string) => { + if (key === "taskHistory") { + return [historyItem] + } + return undefined + }) + + provider.taskHistoryStore["cache"].set(historyItem.id, historyItem) + await provider.taskHistoryStore.delete(historyItem.id) + provider["taskHistoryStoreInitialized"] = true + + await expect(provider.getTaskWithId(historyItem.id)).rejects.toThrow("Task not found") + }) + it("returns empty apiConversationHistory when file is missing", async () => { const historyItem = { id: "missing-api-file-task", task: "test task", ts: Date.now() } vi.mocked(mockContext.globalState.get).mockImplementation((key: string) => { From 0b1702b6d0723a55993f5769bbeaacbc66a71171 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 16 Sep 2026 16:11:34 +0000 Subject: [PATCH 3/5] test(history): seed authoritative store in sticky mode tests Amp-Thread-ID: https://ampcode.com/threads/T-01a0aafa-4924-751a-9a0b-dce899b72a16 --- .../ClineProvider.sticky-mode.spec.ts | 76 ++++++++++--------- src/eslint-suppressions.json | 2 +- 2 files changed, 40 insertions(+), 38 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index fedfa13030..55e26a9667 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -212,6 +212,12 @@ describe("ClineProvider - Sticky Mode", () => { let mockWebviewView: vscode.WebviewView let mockPostMessage: any + async function seedTaskHistory(items: HistoryItem[]) { + for (const item of items) { + await provider.taskHistoryStore.upsert(item) + } + } + beforeEach(async () => { vi.clearAllMocks() @@ -325,8 +331,8 @@ describe("ClineProvider - Sticky Mode", () => { // Get the actual taskId from the mock const taskId = (mockTask as any).taskId || "test-task-id" - // Mock getGlobalState to return task history - vi.spyOn(provider as any, "getGlobalState").mockReturnValue([ + // Seed the authoritative file-backed task history. + await seedTaskHistory([ { id: taskId, ts: Date.now(), @@ -378,8 +384,8 @@ describe("ClineProvider - Sticky Mode", () => { // Add task to provider stack await provider.addClineToStack(mockTask as any) - // Mock getGlobalState to return task history - vi.spyOn(provider as any, "getGlobalState").mockReturnValue([ + // Seed the authoritative file-backed task history. + await seedTaskHistory([ { id: mockTask.taskId, ts: Date.now(), @@ -418,8 +424,8 @@ describe("ClineProvider - Sticky Mode", () => { // Get the actual taskId from the mock const taskId = (mockTask as any).taskId || "test-task-id" - // Mock getGlobalState to return task history - vi.spyOn(provider as any, "getGlobalState").mockReturnValue([ + // Seed the authoritative file-backed task history. + await seedTaskHistory([ { id: taskId, ts: Date.now(), @@ -541,8 +547,8 @@ describe("ClineProvider - Sticky Mode", () => { // Get the actual taskId from the mock const taskId = (mockTask as any).taskId || "test-task-id" - // Mock getGlobalState to return task history with our task - vi.spyOn(provider as any, "getGlobalState").mockReturnValue([ + // Seed the authoritative file-backed task history. + await seedTaskHistory([ { id: taskId, ts: Date.now(), @@ -599,25 +605,21 @@ describe("ClineProvider - Sticky Mode", () => { [parentTaskId]: "architect", // Parent starts with architect mode } - // Mock getGlobalState to return task history - const getGlobalStateMock = vi.spyOn(provider as any, "getGlobalState") - getGlobalStateMock.mockImplementation((key) => { - if (key === "taskHistory") { - return Object.entries(taskModes).map(([id, mode]) => ({ - id, - ts: Date.now(), - task: `Task ${id}`, - number: 1, - tokensIn: 0, - tokensOut: 0, - cacheWrites: 0, - cacheReads: 0, - totalCost: 0, - mode, - })) - } - // Return empty array for other keys - return [] + // Read task metadata from the authoritative store's test double. + vi.spyOn(provider.taskHistoryStore, "get").mockImplementation((id) => { + const mode = taskModes[id] + return mode === undefined + ? undefined + : { + id, + ts: Date.now(), + task: `Task ${id}`, + number: 1, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + mode, + } }) // Mock updateTaskHistory to track mode changes @@ -828,8 +830,8 @@ describe("ClineProvider - Sticky Mode", () => { // Add task to provider stack await provider.addClineToStack(mockTask as any) - // Mock getGlobalState to return task history - vi.spyOn(provider as any, "getGlobalState").mockReturnValue([ + // Seed the authoritative file-backed task history. + await seedTaskHistory([ { id: mockTask.taskId, ts: Date.now(), @@ -895,8 +897,8 @@ describe("ClineProvider - Sticky Mode", () => { // Add task to provider stack await provider.addClineToStack(mockTask as any) - // Mock getGlobalState - vi.spyOn(provider as any, "getGlobalState").mockReturnValue([ + // Seed the authoritative file-backed task history. + await seedTaskHistory([ { id: mockTask.taskId, ts: Date.now(), @@ -984,8 +986,8 @@ describe("ClineProvider - Sticky Mode", () => { // Add task to provider stack await provider.addClineToStack(mockTask as any) - // Mock getGlobalState to return task history - vi.spyOn(provider as any, "getGlobalState").mockReturnValue([ + // Seed the authoritative file-backed task history. + await seedTaskHistory([ { id: mockTask.taskId, ts: Date.now(), @@ -1042,8 +1044,8 @@ describe("ClineProvider - Sticky Mode", () => { // Add task to provider stack await provider.addClineToStack(mockTask as any) - // Mock getGlobalState - vi.spyOn(provider as any, "getGlobalState").mockReturnValue([ + // Seed the authoritative file-backed task history. + await seedTaskHistory([ { id: mockTask.taskId, ts: Date.now(), @@ -1111,8 +1113,8 @@ describe("ClineProvider - Sticky Mode", () => { await provider.addClineToStack(task2 as any) await provider.addClineToStack(task3 as any) - // Mock getGlobalState to return all tasks - vi.spyOn(provider as any, "getGlobalState").mockReturnValue([ + // Seed the authoritative file-backed task history. + await seedTaskHistory([ { id: task1.taskId, ts: Date.now(), diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index d90272962b..2c231e7a2a 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1041,7 +1041,7 @@ }, "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 37 + "count": 27 } }, "core/webview/__tests__/ClineProvider.sticky-profile.spec.ts": { From e99bc5c2a35093f09ff123195eeb18726a713774 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 16 Sep 2026 16:13:31 +0000 Subject: [PATCH 4/5] test(history): complete deleted-task regression fixture Amp-Thread-ID: https://ampcode.com/threads/T-01a0aafa-4924-751a-9a0b-dce899b72a16 --- src/core/webview/__tests__/ClineProvider.spec.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 71c9048f3a..95eb26da2e 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -4963,7 +4963,15 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { describe("getTaskWithId", () => { it("does not restore a deleted file-backed task from legacy history", async () => { - const historyItem = { id: "deleted-task", task: "legacy task", ts: Date.now() } + const historyItem = { + id: "deleted-task", + task: "legacy task", + ts: Date.now(), + number: 1, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } vi.mocked(mockContext.globalState.get).mockImplementation((key: string) => { if (key === "taskHistory") { return [historyItem] From d2efbfded9c8bd8ef7b05174890541fe718331e2 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Fri, 18 Sep 2026 15:22:32 +0200 Subject: [PATCH 5/5] test(webview): cover file-backed history cutover --- .../webview/__tests__/ClineProvider.spec.ts | 6 +++++ .../ClineProvider.taskHistory.spec.ts | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 95eb26da2e..41469c765b 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -4986,6 +4986,12 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { await expect(provider.getTaskWithId(historyItem.id)).rejects.toThrow("Task not found") }) + it("rejects a missing task before file-backed history initialization", async () => { + provider["taskHistoryStoreInitialized"] = false + vi.mocked(mockContext.globalState.get).mockReturnValue(undefined) + await expect(provider.getTaskWithId("cold-start-missing-task")).rejects.toThrow("Task not found") + }) + it("returns empty apiConversationHistory when file is missing", async () => { const historyItem = { id: "missing-api-file-task", task: "test task", ts: Date.now() } vi.mocked(mockContext.globalState.get).mockImplementation((key: string) => { diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 3e1596d49f..61254a67d6 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -243,6 +243,7 @@ vi.mock("@roo-code/cloud", () => ({ getOrganizationMemberships: vi.fn().mockResolvedValue([]), getUserSettings: vi.fn().mockReturnValue(null), isTaskSyncEnabled: vi.fn().mockReturnValue(false), + off: vi.fn(), } }, }, @@ -390,6 +391,28 @@ describe("ClineProvider Task History Synchronization", () => { expect(provider.taskHistoryStore["onWrite"]).toBeUndefined() }) + it("does not write task history to globalState after a history mutation", async () => { + vi.mocked(mockContext.globalState.update).mockClear() + + await provider.updateTaskHistory(createHistoryItem({ id: "file-backed-task", task: "File-backed task" }), { + broadcast: false, + }) + + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("taskHistory", expect.anything()) + }) + + it("does not write task history to globalState during disposal", async () => { + await provider.updateTaskHistory( + createHistoryItem({ id: "disposed-file-backed-task", task: "Disposed file-backed task" }), + { broadcast: false }, + ) + vi.mocked(mockContext.globalState.update).mockClear() + + await provider.dispose() + + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("taskHistory", expect.anything()) + }) + describe("updateTaskHistory", () => { it("broadcasts task history update by default", async () => { await provider.resolveWebviewView(mockWebviewView)