From f1a0dd5d9e83fff0b4beeff26beb138bb21dd141 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 16 Sep 2026 14:17:11 +0000 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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) From b92f0a36597ad0dbd2f17c13aa8d7946bf2d85dc Mon Sep 17 00:00:00 2001 From: Amp Date: Sun, 20 Sep 2026 10:42:42 +0000 Subject: [PATCH 6/7] fix: remove legacy task history after successful migration Amp-Thread-ID: https://ampcode.com/threads/T-01a0be65-3651-76cf-999d-1c931f5de52a --- src/core/webview/ClineProvider.ts | 7 ++ .../ClineProvider.taskHistory.spec.ts | 88 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 378487ffe5..160a078f2c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -508,6 +508,13 @@ export class ClineProvider } this.taskHistoryStoreInitialized = true + + // Also remove blobs left by earlier versions that already completed migration. + // A cleanup failure must not make stale legacy history authoritative again; + // leave the store initialized and retry cleanup on the next startup. + if (this.context.globalState.get("taskHistory") !== undefined) { + await this.context.globalState.update("taskHistory", undefined) + } } catch (error) { this.log(`[initializeTaskHistoryStore] Error: ${error instanceof Error ? error.message : String(error)}`) } diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 61254a67d6..0c02caa5f4 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -387,6 +387,94 @@ describe("ClineProvider Task History Synchronization", () => { return calls.filter((call) => call[0]?.type === type) } + describe("legacy history cleanup", () => { + let legacyHistory: HistoryItem[] + + beforeEach(async () => { + legacyHistory = [createHistoryItem({ id: "legacy-task", task: "Legacy task" })] + await mockContext.globalState.update("taskHistory", legacyHistory) + await mockContext.globalState.update("taskHistoryMigratedToFiles", undefined) + vi.mocked(mockContext.globalState.update).mockClear() + provider["taskHistoryStoreInitialized"] = false + }) + + it.each([false, true])( + "clears legacy history after migration (already migrated: %s)", + async (alreadyMigrated) => { + await mockContext.globalState.update("taskHistoryMigratedToFiles", alreadyMigrated) + vi.mocked(mockContext.globalState.update).mockClear() + const migrate = vi + .spyOn(provider.taskHistoryStore, "migrateFromGlobalState") + .mockImplementation(async () => { + expect(mockContext.globalState.get("taskHistory")).toEqual(legacyHistory) + expect(mockContext.globalState.get("taskHistoryMigratedToFiles")).toBe(false) + }) + + await provider["initializeTaskHistoryStore"]() + + expect(migrate).toHaveBeenCalledTimes(alreadyMigrated ? 0 : 1) + if (!alreadyMigrated) { + expect(migrate).toHaveBeenCalledWith(legacyHistory) + } + expect(vi.mocked(mockContext.globalState.update).mock.calls).toEqual( + alreadyMigrated + ? [["taskHistory", undefined]] + : [ + ["taskHistoryMigratedToFiles", true], + ["taskHistory", undefined], + ], + ) + expect(mockContext.globalState.get("taskHistory")).toBeUndefined() + expect(provider["taskHistoryStoreInitialized"]).toBe(true) + }, + ) + + it.each(["initialization", "migration", "marker"])("preserves legacy history when %s fails", async (stage) => { + const error = new Error(`${stage} failed`) + const migrate = vi.spyOn(provider.taskHistoryStore, "migrateFromGlobalState").mockResolvedValue(undefined) + if (stage === "initialization") { + vi.spyOn(provider.taskHistoryStore, "initialize").mockRejectedValueOnce(error) + } else if (stage === "migration") { + migrate.mockRejectedValueOnce(error) + } else { + vi.mocked(mockContext.globalState.update).mockRejectedValueOnce(error) + } + + await provider["initializeTaskHistoryStore"]() + + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("taskHistory", undefined) + expect(mockContext.globalState.get("taskHistory")).toEqual(legacyHistory) + expect(mockContext.globalState.get("taskHistoryMigratedToFiles")).toBeUndefined() + expect(provider["taskHistoryStoreInitialized"]).toBe(false) + }) + + it("keeps the store authoritative if cleanup fails and retries cleanup on initialization", async () => { + await mockContext.globalState.update("taskHistoryMigratedToFiles", true) + vi.mocked(mockContext.globalState.update).mockRejectedValueOnce(new Error("cleanup failed")) + + await provider["initializeTaskHistoryStore"]() + + expect(mockContext.globalState.get("taskHistory")).toEqual(legacyHistory) + expect(provider["taskHistoryStoreInitialized"]).toBe(true) + await expect(provider.getTaskWithId("legacy-task")).rejects.toThrow("Task not found") + + await provider["initializeTaskHistoryStore"]() + + expect(mockContext.globalState.get("taskHistory")).toBeUndefined() + }) + + it("does not write globalState again after cleanup", async () => { + await mockContext.globalState.update("taskHistoryMigratedToFiles", true) + await mockContext.globalState.update("taskHistory", undefined) + vi.mocked(mockContext.globalState.update).mockClear() + + await provider["initializeTaskHistoryStore"]() + + expect(mockContext.globalState.update).not.toHaveBeenCalled() + expect(provider["taskHistoryStoreInitialized"]).toBe(true) + }) + }) + it("uses per-task files without registering a globalState write-through callback", () => { expect(provider.taskHistoryStore["onWrite"]).toBeUndefined() }) From 720f1e98eeaba711beddcb3aaf9f72a2f16f780c Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Thu, 24 Sep 2026 21:12:54 +0200 Subject: [PATCH 7/7] revert: keep legacy task history blob cleanup out of this PR Reverts b92f0a365 at the maintainer's request. Clearing the pre-migration globalState "taskHistory" blob needs its own design for handling old blobs, so it will be tracked in a separate issue. This PR stays scoped to stopping new task history writes to globalState. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/core/webview/ClineProvider.ts | 7 -- .../ClineProvider.taskHistory.spec.ts | 88 ------------------- 2 files changed, 95 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 160a078f2c..378487ffe5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -508,13 +508,6 @@ export class ClineProvider } this.taskHistoryStoreInitialized = true - - // Also remove blobs left by earlier versions that already completed migration. - // A cleanup failure must not make stale legacy history authoritative again; - // leave the store initialized and retry cleanup on the next startup. - if (this.context.globalState.get("taskHistory") !== undefined) { - await this.context.globalState.update("taskHistory", undefined) - } } catch (error) { this.log(`[initializeTaskHistoryStore] Error: ${error instanceof Error ? error.message : String(error)}`) } diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 0c02caa5f4..61254a67d6 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -387,94 +387,6 @@ describe("ClineProvider Task History Synchronization", () => { return calls.filter((call) => call[0]?.type === type) } - describe("legacy history cleanup", () => { - let legacyHistory: HistoryItem[] - - beforeEach(async () => { - legacyHistory = [createHistoryItem({ id: "legacy-task", task: "Legacy task" })] - await mockContext.globalState.update("taskHistory", legacyHistory) - await mockContext.globalState.update("taskHistoryMigratedToFiles", undefined) - vi.mocked(mockContext.globalState.update).mockClear() - provider["taskHistoryStoreInitialized"] = false - }) - - it.each([false, true])( - "clears legacy history after migration (already migrated: %s)", - async (alreadyMigrated) => { - await mockContext.globalState.update("taskHistoryMigratedToFiles", alreadyMigrated) - vi.mocked(mockContext.globalState.update).mockClear() - const migrate = vi - .spyOn(provider.taskHistoryStore, "migrateFromGlobalState") - .mockImplementation(async () => { - expect(mockContext.globalState.get("taskHistory")).toEqual(legacyHistory) - expect(mockContext.globalState.get("taskHistoryMigratedToFiles")).toBe(false) - }) - - await provider["initializeTaskHistoryStore"]() - - expect(migrate).toHaveBeenCalledTimes(alreadyMigrated ? 0 : 1) - if (!alreadyMigrated) { - expect(migrate).toHaveBeenCalledWith(legacyHistory) - } - expect(vi.mocked(mockContext.globalState.update).mock.calls).toEqual( - alreadyMigrated - ? [["taskHistory", undefined]] - : [ - ["taskHistoryMigratedToFiles", true], - ["taskHistory", undefined], - ], - ) - expect(mockContext.globalState.get("taskHistory")).toBeUndefined() - expect(provider["taskHistoryStoreInitialized"]).toBe(true) - }, - ) - - it.each(["initialization", "migration", "marker"])("preserves legacy history when %s fails", async (stage) => { - const error = new Error(`${stage} failed`) - const migrate = vi.spyOn(provider.taskHistoryStore, "migrateFromGlobalState").mockResolvedValue(undefined) - if (stage === "initialization") { - vi.spyOn(provider.taskHistoryStore, "initialize").mockRejectedValueOnce(error) - } else if (stage === "migration") { - migrate.mockRejectedValueOnce(error) - } else { - vi.mocked(mockContext.globalState.update).mockRejectedValueOnce(error) - } - - await provider["initializeTaskHistoryStore"]() - - expect(mockContext.globalState.update).not.toHaveBeenCalledWith("taskHistory", undefined) - expect(mockContext.globalState.get("taskHistory")).toEqual(legacyHistory) - expect(mockContext.globalState.get("taskHistoryMigratedToFiles")).toBeUndefined() - expect(provider["taskHistoryStoreInitialized"]).toBe(false) - }) - - it("keeps the store authoritative if cleanup fails and retries cleanup on initialization", async () => { - await mockContext.globalState.update("taskHistoryMigratedToFiles", true) - vi.mocked(mockContext.globalState.update).mockRejectedValueOnce(new Error("cleanup failed")) - - await provider["initializeTaskHistoryStore"]() - - expect(mockContext.globalState.get("taskHistory")).toEqual(legacyHistory) - expect(provider["taskHistoryStoreInitialized"]).toBe(true) - await expect(provider.getTaskWithId("legacy-task")).rejects.toThrow("Task not found") - - await provider["initializeTaskHistoryStore"]() - - expect(mockContext.globalState.get("taskHistory")).toBeUndefined() - }) - - it("does not write globalState again after cleanup", async () => { - await mockContext.globalState.update("taskHistoryMigratedToFiles", true) - await mockContext.globalState.update("taskHistory", undefined) - vi.mocked(mockContext.globalState.update).mockClear() - - await provider["initializeTaskHistoryStore"]() - - expect(mockContext.globalState.update).not.toHaveBeenCalled() - expect(provider["taskHistoryStoreInitialized"]).toBe(true) - }) - }) - it("uses per-task files without registering a globalState write-through callback", () => { expect(provider.taskHistoryStore["onWrite"]).toBeUndefined() })