Skip to content

Commit ba61ec4

Browse files
committed
fix(core): serialize task history updates across parallel tabs
1 parent 367013f commit ba61ec4

4 files changed

Lines changed: 279 additions & 12 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Cross-instance lock for task history mutations.
3+
*
4+
* When multiple `ClineProvider` instances exist (e.g., parallel tabs or windows),
5+
* each has its own `TaskHistoryStore` with an independent in-process write lock.
6+
* This singleton lock serializes all `updateTaskHistory()` calls across instances
7+
* to prevent lost entries due to concurrent writes.
8+
*
9+
* The lock is a simple Promise chain — each operation waits for the previous one
10+
* to complete before starting. This guarantees that only one mutation sequence
11+
* runs at a time, eliminating race conditions between parallel tabs.
12+
*/
13+
14+
export class TaskHistoryLock {
15+
private queue: Promise<unknown> = Promise.resolve()
16+
17+
/**
18+
* Acquires the lock and executes `fn` sequentially.
19+
* Subsequent calls will wait for this one to finish.
20+
*/
21+
async withLock<T>(fn: () => Promise<T>): Promise<T> {
22+
const result = this.queue.then(fn, fn)
23+
this.queue = result.then(
24+
() => undefined,
25+
() => undefined,
26+
)
27+
return result
28+
}
29+
30+
/**
31+
* Resets the lock queue to a clean state.
32+
* Useful for testing or when the provider is disposed.
33+
*/
34+
reset(): void {
35+
this.queue = Promise.resolve()
36+
}
37+
}
38+
39+
// Singleton instance shared across all ClineProvider instances
40+
export const taskHistoryLock = new TaskHistoryLock()
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { TaskHistoryLock } from "../TaskHistoryLock"
2+
3+
describe("TaskHistoryLock", () => {
4+
it("serializes concurrent operations", async () => {
5+
const lock = new TaskHistoryLock()
6+
let activeCount = 0
7+
let maxActiveCount = 0
8+
const order: string[] = []
9+
10+
const run = (id: string) =>
11+
lock.withLock(async () => {
12+
activeCount++
13+
maxActiveCount = Math.max(maxActiveCount, activeCount)
14+
order.push(`start:${id}`)
15+
await new Promise((resolve) => setTimeout(resolve, 5))
16+
order.push(`end:${id}`)
17+
activeCount--
18+
return id
19+
})
20+
21+
const results = await Promise.all([run("a"), run("b"), run("c")])
22+
23+
expect(results).toEqual(["a", "b", "c"])
24+
expect(maxActiveCount).toBe(1)
25+
expect(order).toEqual(["start:a", "end:a", "start:b", "end:b", "start:c", "end:c"])
26+
})
27+
28+
it("continues processing after a previous operation rejects", async () => {
29+
const lock = new TaskHistoryLock()
30+
const order: string[] = []
31+
32+
const failed = lock.withLock(async () => {
33+
order.push("start:fail")
34+
throw new Error("simulated failure")
35+
})
36+
37+
const succeeded = lock.withLock(async () => {
38+
order.push("start:success")
39+
return "ok"
40+
})
41+
42+
await expect(failed).rejects.toThrow("simulated failure")
43+
await expect(succeeded).resolves.toBe("ok")
44+
expect(order).toEqual(["start:fail", "start:success"])
45+
})
46+
47+
it("reset clears the queue for subsequent operations", async () => {
48+
const lock = new TaskHistoryLock()
49+
let releaseFirstOperation!: () => void
50+
const order: string[] = []
51+
52+
const blocked = lock.withLock(
53+
() =>
54+
new Promise<string>((resolve) => {
55+
order.push("start:blocked")
56+
releaseFirstOperation = () => {
57+
order.push("end:blocked")
58+
resolve("blocked")
59+
}
60+
}),
61+
)
62+
63+
lock.reset()
64+
65+
const afterReset = await lock.withLock(async () => {
66+
order.push("after-reset")
67+
return "after-reset"
68+
})
69+
70+
expect(afterReset).toBe("after-reset")
71+
expect(order).toEqual(["start:blocked", "after-reset"])
72+
73+
releaseFirstOperation()
74+
await expect(blocked).resolves.toBe("blocked")
75+
})
76+
})

src/core/webview/ClineProvider.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ import {
109109
TaskHistoryStore,
110110
assertValidTransition,
111111
} from "../task-persistence"
112+
import { taskHistoryLock } from "../task-persistence/TaskHistoryLock"
112113
import { readTaskMessages } from "../task-persistence/taskMessages"
113114
import { getNonce } from "./getNonce"
114115
import { getUri } from "./getUri"
@@ -2092,10 +2093,12 @@ export class ClineProvider
20922093
}
20932094

20942095
async deleteTaskFromState(id: string) {
2095-
await this.taskHistoryStore.delete(id)
2096-
this.recentTasksCache = undefined
2096+
await taskHistoryLock.withLock(async () => {
2097+
await this.taskHistoryStore.delete(id)
2098+
this.recentTasksCache = undefined
20972099

2098-
await this.postStateToWebview()
2100+
await this.postStateToWebview()
2101+
})
20992102
}
21002103

21012104
async refreshWorkspace() {
@@ -2736,17 +2739,20 @@ export class ClineProvider
27362739
async updateTaskHistory(item: HistoryItem, options: { broadcast?: boolean } = {}): Promise<HistoryItem[]> {
27372740
const { broadcast = true } = options
27382741

2739-
const history = await this.taskHistoryStore.upsert(item)
2740-
this.recentTasksCache = undefined
2742+
// Serialize all task history mutations across parallel tabs using the shared lock.
2743+
return taskHistoryLock.withLock(async () => {
2744+
const history = await this.taskHistoryStore.upsert(item)
2745+
this.recentTasksCache = undefined
27412746

2742-
// Broadcast the updated history to the webview if requested.
2743-
// Prefer per-item updates to avoid repeatedly cloning/sending the full history.
2744-
if (broadcast && this.isViewLaunched) {
2745-
const updatedItem = this.taskHistoryStore.get(item.id) ?? item
2746-
await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem })
2747-
}
2747+
// Broadcast the updated history to the webview if requested.
2748+
// Prefer per-item updates to avoid repeatedly cloning/sending the full history.
2749+
if (broadcast && this.isViewLaunched) {
2750+
const updatedItem = this.taskHistoryStore.get(item.id) ?? item
2751+
await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem })
2752+
}
27482753

2749-
return history
2754+
return history
2755+
})
27502756
}
27512757

27522758
/**

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

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { HistoryItem, ExtensionMessage } from "@roo-code/types"
55
import { TelemetryService } from "@roo-code/telemetry"
66

77
import { ContextProxy } from "../../config/ContextProxy"
8+
import { taskHistoryLock } from "../../task-persistence/TaskHistoryLock"
89
import { ClineProvider } from "../ClineProvider"
910

1011
// Mock setup
@@ -240,6 +241,7 @@ vi.mock("@roo-code/cloud", () => ({
240241
getOrganizationMemberships: vi.fn().mockResolvedValue([]),
241242
getUserSettings: vi.fn().mockReturnValue(null),
242243
isTaskSyncEnabled: vi.fn().mockReturnValue(false),
244+
off: vi.fn(),
243245
}
244246
},
245247
},
@@ -260,6 +262,7 @@ describe("ClineProvider Task History Synchronization", () => {
260262

261263
beforeEach(async () => {
262264
vi.clearAllMocks()
265+
taskHistoryLock.reset()
263266

264267
if (!TelemetryService.hasInstance()) {
265268
TelemetryService.createInstance([])
@@ -779,5 +782,147 @@ describe("ClineProvider Task History Synchronization", () => {
779782
// The second write (tokensIn: 222) should be the last one since writes are serialized
780783
expect(item!.tokensIn).toBe(222)
781784
})
785+
786+
it("serializes concurrent updateTaskHistory from two parallel instances", async () => {
787+
const provider2 = new ClineProvider(
788+
mockContext,
789+
mockOutputChannel,
790+
"sidebar",
791+
new ContextProxy(mockContext),
792+
)
793+
await provider2.taskHistoryStore.initialized
794+
795+
let inCriticalSection = 0
796+
let maxConcurrentMutations = 0
797+
const entered: string[] = []
798+
799+
const makeSerializedUpsert = (instanceName: string) =>
800+
vi.fn(async (item: HistoryItem) => {
801+
inCriticalSection++
802+
maxConcurrentMutations = Math.max(maxConcurrentMutations, inCriticalSection)
803+
entered.push(`${instanceName}:${item.id}`)
804+
await new Promise((resolve) => setTimeout(resolve, 5))
805+
inCriticalSection--
806+
return [item]
807+
})
808+
809+
const provider1Upsert = makeSerializedUpsert("provider1")
810+
const provider2Upsert = makeSerializedUpsert("provider2")
811+
vi.spyOn(provider.taskHistoryStore, "upsert").mockImplementation(provider1Upsert)
812+
vi.spyOn(provider2.taskHistoryStore, "upsert").mockImplementation(provider2Upsert)
813+
814+
try {
815+
const provider1Item = createHistoryItem({ id: "parallel-provider-1", task: "Provider 1" })
816+
const provider2Item = createHistoryItem({ id: "parallel-provider-2", task: "Provider 2" })
817+
818+
await Promise.all([
819+
provider.updateTaskHistory(provider1Item, { broadcast: false }),
820+
provider2.updateTaskHistory(provider2Item, { broadcast: false }),
821+
])
822+
823+
expect(provider1Upsert).toHaveBeenCalledTimes(1)
824+
expect(provider2Upsert).toHaveBeenCalledTimes(1)
825+
expect(entered).toHaveLength(2)
826+
expect(maxConcurrentMutations).toBe(1)
827+
} finally {
828+
await provider2.dispose()
829+
}
830+
})
831+
832+
it("serializes 5+ concurrent updateTaskHistory calls from different tabs", async () => {
833+
const providers = [provider]
834+
835+
for (let i = 1; i < 5; i++) {
836+
const nextProvider = new ClineProvider(
837+
mockContext,
838+
mockOutputChannel,
839+
"sidebar",
840+
new ContextProxy(mockContext),
841+
)
842+
await nextProvider.taskHistoryStore.initialized
843+
providers.push(nextProvider)
844+
}
845+
846+
let inCriticalSection = 0
847+
let maxConcurrentMutations = 0
848+
const entered: string[] = []
849+
850+
try {
851+
providers.forEach((currentProvider, providerIndex) => {
852+
vi.spyOn(currentProvider.taskHistoryStore, "upsert").mockImplementation(
853+
async (item: HistoryItem) => {
854+
inCriticalSection++
855+
maxConcurrentMutations = Math.max(maxConcurrentMutations, inCriticalSection)
856+
entered.push(`provider-${providerIndex}:${item.id}`)
857+
await new Promise((resolve) => setTimeout(resolve, 5))
858+
inCriticalSection--
859+
return [item]
860+
},
861+
)
862+
})
863+
864+
await Promise.all(
865+
providers.map((currentProvider, index) =>
866+
currentProvider.updateTaskHistory(
867+
createHistoryItem({ id: `parallel-tab-${index}`, task: `Parallel Tab ${index}` }),
868+
{ broadcast: false },
869+
),
870+
),
871+
)
872+
873+
expect(entered).toHaveLength(5)
874+
expect(maxConcurrentMutations).toBe(1)
875+
} finally {
876+
for (const currentProvider of providers.slice(1)) {
877+
await currentProvider.dispose()
878+
}
879+
}
880+
})
881+
882+
it("serializes concurrent updateTaskHistory and deleteTaskFromState across tabs", async () => {
883+
const provider2 = new ClineProvider(
884+
mockContext,
885+
mockOutputChannel,
886+
"sidebar",
887+
new ContextProxy(mockContext),
888+
)
889+
await provider2.taskHistoryStore.initialized
890+
891+
let inCriticalSection = 0
892+
let maxConcurrentMutations = 0
893+
const entered: string[] = []
894+
895+
vi.spyOn(provider.taskHistoryStore, "upsert").mockImplementation(async (item: HistoryItem) => {
896+
inCriticalSection++
897+
maxConcurrentMutations = Math.max(maxConcurrentMutations, inCriticalSection)
898+
entered.push(`update:${item.id}`)
899+
await new Promise((resolve) => setTimeout(resolve, 5))
900+
inCriticalSection--
901+
return [item]
902+
})
903+
vi.spyOn(provider2.taskHistoryStore, "delete").mockImplementation(async (id: string) => {
904+
inCriticalSection++
905+
maxConcurrentMutations = Math.max(maxConcurrentMutations, inCriticalSection)
906+
entered.push(`delete:${id}`)
907+
await new Promise((resolve) => setTimeout(resolve, 5))
908+
inCriticalSection--
909+
})
910+
vi.spyOn(provider2, "postStateToWebview").mockResolvedValue(undefined)
911+
912+
try {
913+
await Promise.all([
914+
provider.updateTaskHistory(createHistoryItem({ id: "cross-update", task: "Cross update" }), {
915+
broadcast: false,
916+
}),
917+
provider2.deleteTaskFromState("cross-delete"),
918+
])
919+
920+
expect(entered).toEqual(expect.arrayContaining(["update:cross-update", "delete:cross-delete"]))
921+
expect(entered).toHaveLength(2)
922+
expect(maxConcurrentMutations).toBe(1)
923+
} finally {
924+
await provider2.dispose()
925+
}
926+
})
782927
})
783928
})

0 commit comments

Comments
 (0)