Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 17 additions & 57 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,6 @@ export class ClineProvider
private recentTasksCache?: string[]
public readonly taskHistoryStore: TaskHistoryStore
private taskHistoryStoreInitialized = false
private globalStateWriteThroughTimer: ReturnType<typeof setTimeout> | null = null
private static readonly GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS = 5000 // 5 seconds
private pendingOperations: Map<string, PendingEditOperation> = new Map()
private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds

Expand Down Expand Up @@ -166,14 +164,8 @@ export class ClineProvider

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}`)
})
Expand Down Expand Up @@ -580,7 +572,6 @@ export class ClineProvider
this.skillsManager = undefined
this.customModesManager?.dispose()
this.taskHistoryStore.dispose()
this.flushGlobalStateWriteThrough()
this.log("Disposed all disposables")
ClineProvider.activeInstances.delete(this)

Expand Down Expand Up @@ -1260,9 +1251,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 })
Expand Down Expand Up @@ -1478,9 +1467,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 })
Expand Down Expand Up @@ -1632,15 +1619,26 @@ 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
apiConversationHistoryFilePath: string
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")
Expand Down Expand Up @@ -2276,44 +2274,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.
Expand Down
30 changes: 30 additions & 0 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3419,6 +3419,36 @@ 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(),
number: 1,
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
}
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("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) => {
Expand Down
76 changes: 39 additions & 37 deletions src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,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()

Expand Down Expand Up @@ -274,8 +280,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(),
Expand Down Expand Up @@ -327,8 +333,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(),
Expand Down Expand Up @@ -365,8 +371,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(),
Expand Down Expand Up @@ -488,8 +494,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(),
Expand Down Expand Up @@ -546,25 +552,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
Expand Down Expand Up @@ -775,8 +777,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(),
Expand Down Expand Up @@ -842,8 +844,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(),
Expand Down Expand Up @@ -929,8 +931,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(),
Expand Down Expand Up @@ -985,8 +987,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(),
Expand Down Expand Up @@ -1054,8 +1056,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(),
Expand Down
26 changes: 26 additions & 0 deletions src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,32 @@ 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()
})

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)
Expand Down
Loading