Skip to content

Commit 6e05ae9

Browse files
authored
fix(task-history): route invalidate() and invalidateAll() through withLock (#912)
* fix: lock task history invalidation * fix(task-history): address invalidation review feedback
1 parent c322f3c commit 6e05ae9

3 files changed

Lines changed: 117 additions & 12 deletions

File tree

src/core/task-persistence/TaskHistoryStore.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -430,23 +430,27 @@ export class TaskHistoryStore {
430430
* Invalidate a single task's cache entry (re-read from disk on next access).
431431
*/
432432
async invalidate(taskId: string): Promise<void> {
433-
try {
434-
const item = await this.readTaskFile(taskId)
435-
if (item) {
436-
this.cache.set(taskId, item)
437-
} else {
433+
return this.withLock(async () => {
434+
try {
435+
const item = await this.readTaskFile(taskId)
436+
if (item) {
437+
this.cache.set(taskId, item)
438+
} else {
439+
this.cache.delete(taskId)
440+
}
441+
} catch {
438442
this.cache.delete(taskId)
439443
}
440-
} catch {
441-
this.cache.delete(taskId)
442-
}
444+
})
443445
}
444446

445447
/**
446-
* Clear all in-memory cache and reload from index.
448+
* Clear all in-memory cache entries; a subsequent `reconcile()` repopulates them from task files.
447449
*/
448-
invalidateAll(): void {
449-
this.cache.clear()
450+
async invalidateAll(): Promise<void> {
451+
return this.withLock(async () => {
452+
this.cache.clear()
453+
})
450454
}
451455

452456
// ────────────────────────────── Migration ──────────────────────────────

src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,107 @@ describe("TaskHistoryStore", () => {
440440

441441
expect(store.get("gone-task")).toBeUndefined()
442442
})
443+
444+
it("waits for an in-flight write before refreshing the cache", async () => {
445+
await store.initialize()
446+
447+
const item = makeHistoryItem({ id: "invalidate-locked", tokensIn: 100 })
448+
await store.upsert(item)
449+
450+
let signalWriteStarted!: () => void
451+
const writeStarted = new Promise<void>((resolve) => {
452+
signalWriteStarted = resolve
453+
})
454+
let releaseWrite!: () => void
455+
const writeCanFinish = new Promise<void>((resolve) => {
456+
releaseWrite = resolve
457+
})
458+
let releaseStaleRead!: () => void
459+
const staleReadCanFinish = new Promise<void>((resolve) => {
460+
releaseStaleRead = resolve
461+
})
462+
let writeReleased = false
463+
464+
const storeAny = store as any
465+
const originalWriteTaskFile = storeAny.writeTaskFile.bind(store)
466+
const originalReadTaskFile = storeAny.readTaskFile.bind(store)
467+
vi.spyOn(storeAny, "writeTaskFile").mockImplementation(async (...args: unknown[]) => {
468+
const next = args[0] as HistoryItem
469+
if (next.id === item.id && next.tokensIn === 999) {
470+
signalWriteStarted()
471+
await writeCanFinish
472+
}
473+
return originalWriteTaskFile(...args)
474+
})
475+
vi.spyOn(storeAny, "readTaskFile").mockImplementation(async (...args: unknown[]) => {
476+
if (args[0] === item.id && !writeReleased) {
477+
await staleReadCanFinish
478+
return item
479+
}
480+
return originalReadTaskFile(...args)
481+
})
482+
483+
const write = store.upsert({ ...item, tokensIn: 999 })
484+
await writeStarted
485+
const invalidation = store.invalidate(item.id)
486+
487+
writeReleased = true
488+
releaseWrite()
489+
await write
490+
releaseStaleRead()
491+
await invalidation
492+
493+
expect(store.get(item.id)?.tokensIn).toBe(999)
494+
})
495+
})
496+
497+
describe("invalidateAll()", () => {
498+
it("waits for an in-flight write before clearing the cache", async () => {
499+
const onWrite = vi.fn().mockResolvedValue(undefined)
500+
store = new TaskHistoryStore(tmpDir, { onWrite })
501+
await store.initialize()
502+
503+
const first = makeHistoryItem({ id: "invalidate-all-first", ts: 1000, tokensIn: 100 })
504+
const second = makeHistoryItem({ id: "invalidate-all-second", ts: 2000 })
505+
await store.upsert(first)
506+
await store.upsert(second)
507+
onWrite.mockClear()
508+
509+
let signalWriteStarted!: () => void
510+
const writeStarted = new Promise<void>((resolve) => {
511+
signalWriteStarted = resolve
512+
})
513+
let releaseWrite!: () => void
514+
const writeCanFinish = new Promise<void>((resolve) => {
515+
releaseWrite = resolve
516+
})
517+
518+
const storeAny = store as any
519+
const originalWriteTaskFile = storeAny.writeTaskFile.bind(store)
520+
vi.spyOn(storeAny, "writeTaskFile").mockImplementation(async (...args: unknown[]) => {
521+
const item = args[0] as HistoryItem
522+
if (item.id === first.id && item.tokensIn === 999) {
523+
signalWriteStarted()
524+
await writeCanFinish
525+
}
526+
return originalWriteTaskFile(...args)
527+
})
528+
529+
const write = store.upsert({ ...first, tokensIn: 999 })
530+
await writeStarted
531+
const invalidation = store.invalidateAll()
532+
533+
releaseWrite()
534+
await write
535+
await invalidation
536+
537+
expect(onWrite).toHaveBeenCalledTimes(1)
538+
expect(onWrite.mock.calls[0][0].map((item: HistoryItem) => item.id).sort()).toEqual([
539+
"invalidate-all-first",
540+
"invalidate-all-second",
541+
])
542+
expect(store.getAll()).toEqual([])
543+
})
443544
})
444545

445546
describe("atomicUpdatePair()", () => {

src/core/webview/webviewMessageHandler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -963,7 +963,7 @@ export const webviewMessageHandler = async (
963963

964964
// Refresh history whenever Roo tasks were found — even if all already existed —
965965
// so a retry after a partial-copy failure still reconciles the store.
966-
provider.taskHistoryStore.invalidateAll()
966+
await provider.taskHistoryStore.invalidateAll()
967967
await provider.taskHistoryStore.reconcile()
968968
await provider.taskHistoryStore.flushIndex()
969969
await provider.postStateToWebview()

0 commit comments

Comments
 (0)