Skip to content

Commit 2ff09f0

Browse files
committed
test(importRooTaskHistory): check that only one import writes atomically
1 parent fc6f60b commit 2ff09f0

2 files changed

Lines changed: 48 additions & 4 deletions

File tree

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,4 +561,48 @@ describe("importRooTaskHistory", () => {
561561
code: "ENOTDIR",
562562
})
563563
})
564+
565+
it("imports the complete file set when two calls run concurrently against the same Roo task", async () => {
566+
const zooGlobalStoragePath = path.join(tempRoot, "globalStorage", "zoocodeorganization.zoo-code")
567+
const rooDefaultStorageRoot = path.join(tempRoot, "globalStorage", "rooveterinaryinc.roo-cline")
568+
569+
mockStorageConfiguration()
570+
571+
await fs.mkdir(path.join(rooDefaultStorageRoot, "tasks", "task-concurrent"), { recursive: true })
572+
await fs.writeFile(
573+
path.join(rooDefaultStorageRoot, "tasks", "task-concurrent", "history_item.json"),
574+
makeHistoryItem("task-concurrent"),
575+
)
576+
await fs.writeFile(
577+
path.join(rooDefaultStorageRoot, "tasks", "task-concurrent", "ui_messages.json"),
578+
"concurrent-ui",
579+
)
580+
await fs.writeFile(
581+
path.join(rooDefaultStorageRoot, "tasks", "task-concurrent", "api_conversation_history.json"),
582+
"concurrent-api",
583+
)
584+
585+
const [result1, result2] = await Promise.all([
586+
importRooTaskHistory(zooGlobalStoragePath),
587+
importRooTaskHistory(zooGlobalStoragePath),
588+
])
589+
590+
// Exactly one call should win the atomic rename; the other should skip gracefully.
591+
expect(result1.importedTaskCount + result2.importedTaskCount).toBe(1)
592+
593+
const destTaskDir = path.join(zooGlobalStoragePath, "tasks", "task-concurrent")
594+
595+
// The winning import must have written all three files completely.
596+
expect(await fs.readFile(path.join(destTaskDir, "history_item.json"), "utf8")).toBe(
597+
makeHistoryItem("task-concurrent"),
598+
)
599+
expect(await fs.readFile(path.join(destTaskDir, "ui_messages.json"), "utf8")).toBe("concurrent-ui")
600+
expect(await fs.readFile(path.join(destTaskDir, "api_conversation_history.json"), "utf8")).toBe(
601+
"concurrent-api",
602+
)
603+
604+
// No staging directories should be left behind.
605+
const tasksEntries = await fs.readdir(path.join(zooGlobalStoragePath, "tasks"))
606+
expect(tasksEntries.filter((e) => e.startsWith("_staging_"))).toHaveLength(0)
607+
})
564608
})

src/core/task-persistence/importRooTaskHistory.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -280,10 +280,10 @@ export const importRooTaskHistory = async (
280280
continue
281281
}
282282

283-
// Stage into a temp directory, then atomically rename to avoid leaving
284-
// partial task directories that a retry would skip as already-present.
285-
const stagingDirectory = path.join(destinationTasksRoot, `_staging_${taskPlan.taskId}`)
286-
await fs.rm(stagingDirectory, { recursive: true, force: true })
283+
// Stage into a unique temp directory, then atomically rename to avoid
284+
// leaving partial task directories that a retry would skip as already-present.
285+
// Using mkdtemp ensures concurrent imports for the same task ID don't collide.
286+
const stagingDirectory = await fs.mkdtemp(path.join(destinationTasksRoot, `_staging_${taskPlan.taskId}_`))
287287
stagingFileCount = 0
288288

289289
try {

0 commit comments

Comments
 (0)