Skip to content

Commit 7b0ab2f

Browse files
edelaunanavedmerchant
authored andcommitted
fix(delegation): serialize delegateParentAndOpenChild with atomicReadAndUpdate (Zoo-Code-Org#691)
* fix(delegation): serialize delegateParentAndOpenChild read-modify-write atomically * refactor(TaskHistoryStore): coderabbit feedback * refactor(TaskHistoryStore): use structuredClone instead of hacky JSON.stringigy and parse methods --------- Co-authored-by: Naved Merchant <naved.merchant@gmail.com>
1 parent a0af970 commit 7b0ab2f

4 files changed

Lines changed: 370 additions & 113 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// npx vitest run __tests__/delegation-concurrent.spec.ts
2+
3+
import { describe, it, expect, vi, beforeEach } from "vitest"
4+
import type { HistoryItem } from "@roo-code/types"
5+
6+
vi.mock("fs/promises", () => ({
7+
mkdir: vi.fn().mockResolvedValue(undefined),
8+
readFile: vi.fn().mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })),
9+
readdir: vi.fn().mockResolvedValue([]),
10+
unlink: vi.fn().mockResolvedValue(undefined),
11+
}))
12+
13+
vi.mock("fs", () => ({
14+
default: {
15+
watch: vi.fn().mockReturnValue({ on: vi.fn(), close: vi.fn() }),
16+
existsSync: vi.fn().mockReturnValue(false),
17+
},
18+
watch: vi.fn().mockReturnValue({ on: vi.fn(), close: vi.fn() }),
19+
existsSync: vi.fn().mockReturnValue(false),
20+
}))
21+
22+
vi.mock("../utils/safeWriteJson", () => ({
23+
safeWriteJson: vi.fn().mockResolvedValue(undefined),
24+
}))
25+
26+
vi.mock("../utils/storage", () => ({
27+
getStorageBasePath: vi.fn().mockResolvedValue("/tmp/test-storage"),
28+
}))
29+
30+
import { TaskHistoryStore } from "../core/task-persistence/TaskHistoryStore"
31+
32+
const makeItem = (id: string, overrides: Partial<HistoryItem> = {}): HistoryItem =>
33+
({
34+
id,
35+
ts: Date.now(),
36+
task: "test task",
37+
tokensIn: 0,
38+
tokensOut: 0,
39+
totalCost: 0,
40+
status: "active",
41+
mode: "code",
42+
workspace: "/tmp",
43+
...overrides,
44+
}) as HistoryItem
45+
46+
describe("TaskHistoryStore.atomicReadAndUpdate", () => {
47+
let store: TaskHistoryStore
48+
49+
beforeEach(() => {
50+
vi.clearAllMocks()
51+
store = new TaskHistoryStore("/tmp/test-storage")
52+
})
53+
54+
it("serializes concurrent operations — second caller reads the state written by the first", async () => {
55+
// Seed the cache with an item that has no childIds yet.
56+
const item = makeItem("parent-task", { childIds: [] })
57+
;(store as any).cache.set(item.id, item)
58+
59+
// Two concurrent delegations each append their child ID.
60+
// Because they are serialized by the lock, the second caller must
61+
// read the cache state that the first caller wrote — not the original.
62+
const delegation1 = store.atomicReadAndUpdate("parent-task", (current) => ({
63+
...current,
64+
childIds: [...(current.childIds ?? []), "child-A"],
65+
}))
66+
67+
const delegation2 = store.atomicReadAndUpdate("parent-task", (current) => ({
68+
...current,
69+
childIds: [...(current.childIds ?? []), "child-B"],
70+
}))
71+
72+
await Promise.all([delegation1, delegation2])
73+
74+
// Both child IDs must be present: delegation2 saw delegation1's write.
75+
const final = (store as any).cache.get("parent-task") as HistoryItem
76+
expect(final.childIds).toContain("child-A")
77+
expect(final.childIds).toContain("child-B")
78+
})
79+
80+
it("two concurrent delegations produce consistent HistoryItem state (full field set)", async () => {
81+
const item = makeItem("parent-task", { status: "active", childIds: [] })
82+
;(store as any).cache.set(item.id, item)
83+
84+
// Each delegation sets awaitingChildId and appends to childIds.
85+
const delegation1 = store.atomicReadAndUpdate("parent-task", (historyItem) => {
86+
const childIds = Array.from(new Set([...(historyItem.childIds ?? []), "child-A"]))
87+
return {
88+
...historyItem,
89+
status: "delegated",
90+
delegatedToId: "child-A",
91+
awaitingChildId: "child-A",
92+
childIds,
93+
}
94+
})
95+
96+
const delegation2 = store.atomicReadAndUpdate("parent-task", (historyItem) => {
97+
const childIds = Array.from(new Set([...(historyItem.childIds ?? []), "child-B"]))
98+
return {
99+
...historyItem,
100+
status: "delegated",
101+
delegatedToId: "child-B",
102+
awaitingChildId: "child-B",
103+
childIds,
104+
}
105+
})
106+
107+
await Promise.all([delegation1, delegation2])
108+
109+
const final = (store as any).cache.get("parent-task") as HistoryItem
110+
// Both child IDs present — neither write clobbered the other's childIds.
111+
expect(final.childIds).toContain("child-A")
112+
expect(final.childIds).toContain("child-B")
113+
// The last writer wins on scalar fields; whichever child ran second is authoritative.
114+
expect(final.status).toBe("delegated")
115+
expect(["child-A", "child-B"]).toContain(final.awaitingChildId)
116+
expect(final.delegatedToId).toBe(final.awaitingChildId)
117+
expect(final.childIds).toContain(final.awaitingChildId)
118+
})
119+
120+
it("completes without deadlock — updater is pure and does not re-acquire the lock", async () => {
121+
const item = makeItem("task-1", { childIds: [] })
122+
;(store as any).cache.set(item.id, item)
123+
124+
// With the typed (taskId, updater) API, the updater is synchronous and
125+
// cannot call upsert/withLock — no re-entrancy, no deadlock.
126+
const result = await Promise.race([
127+
store
128+
.atomicReadAndUpdate("task-1", (current) => ({
129+
...current,
130+
childIds: [...(current.childIds ?? []), "child-1"],
131+
}))
132+
.then(() => "completed"),
133+
new Promise<string>((resolve) => setTimeout(() => resolve("deadlocked"), 100)),
134+
])
135+
136+
expect(result).toBe("completed")
137+
138+
const final = (store as any).cache.get("task-1") as HistoryItem
139+
expect(final.childIds).toContain("child-1")
140+
})
141+
142+
it("throws if the task ID is not in the cache", async () => {
143+
await expect(
144+
store.atomicReadAndUpdate("nonexistent-task", (current) => ({ ...current, status: "delegated" })),
145+
).rejects.toThrow("nonexistent-task")
146+
})
147+
})

0 commit comments

Comments
 (0)