Skip to content

Commit 31b6a82

Browse files
committed
fix: harden Roo history import retries
Skip existing Zoo task directories and resolve duplicate Roo task IDs deterministically during Roo history imports. Keep failed import state visible until a new attempt starts and add backend and frontend regression coverage for repeated imports and retry flows.
1 parent c1d9d42 commit 31b6a82

4 files changed

Lines changed: 271 additions & 93 deletions

File tree

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

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,91 @@ describe("importRooTaskHistory", () => {
146146
})
147147
})
148148

149+
it("does not overwrite an existing Zoo task directory when the same Roo history is imported again", async () => {
150+
const zooGlobalStoragePath = path.join(tempRoot, "globalStorage", "zoocodeorganization.zoo-code")
151+
const rooDefaultStorageRoot = path.join(tempRoot, "globalStorage", "rooveterinaryinc.roo-cline")
152+
153+
mockStorageConfiguration()
154+
155+
await fs.mkdir(path.join(rooDefaultStorageRoot, "tasks", "task-repeat"), { recursive: true })
156+
await fs.writeFile(
157+
path.join(rooDefaultStorageRoot, "tasks", "task-repeat", "history_item.json"),
158+
JSON.stringify({ id: "task-repeat", source: "first-import" }),
159+
)
160+
await fs.writeFile(path.join(rooDefaultStorageRoot, "tasks", "task-repeat", "ui_messages.json"), "first-ui")
161+
162+
const firstImportResult = await importRooTaskHistory(zooGlobalStoragePath)
163+
164+
expect(firstImportResult.importedTaskCount).toBe(1)
165+
expect(firstImportResult.importedFileCount).toBe(2)
166+
167+
await fs.writeFile(
168+
path.join(rooDefaultStorageRoot, "tasks", "task-repeat", "history_item.json"),
169+
JSON.stringify({ id: "task-repeat", source: "second-import" }),
170+
)
171+
await fs.writeFile(path.join(rooDefaultStorageRoot, "tasks", "task-repeat", "ui_messages.json"), "second-ui")
172+
173+
const secondImportResult = await importRooTaskHistory(zooGlobalStoragePath)
174+
175+
expect(secondImportResult.importedTaskCount).toBe(0)
176+
expect(secondImportResult.importedFileCount).toBe(0)
177+
expect(
178+
await fs.readFile(path.join(zooGlobalStoragePath, "tasks", "task-repeat", "history_item.json"), "utf8"),
179+
).toBe(JSON.stringify({ id: "task-repeat", source: "first-import" }))
180+
expect(
181+
await fs.readFile(path.join(zooGlobalStoragePath, "tasks", "task-repeat", "ui_messages.json"), "utf8"),
182+
).toBe("first-ui")
183+
})
184+
185+
it("deterministically keeps the first importable Roo task when duplicate task IDs exist across roots", async () => {
186+
const zooGlobalStoragePath = path.join(tempRoot, "globalStorage", "zoocodeorganization.zoo-code")
187+
const rooDefaultStorageRoot = path.join(tempRoot, "globalStorage", "rooveterinaryinc.roo-cline")
188+
const rooCustomStorageRoot = path.join(tempRoot, "roo-custom")
189+
190+
mockStorageConfiguration({ roo: rooCustomStorageRoot })
191+
192+
await fs.mkdir(path.join(rooDefaultStorageRoot, "tasks", "task-shared"), { recursive: true })
193+
await fs.writeFile(
194+
path.join(rooDefaultStorageRoot, "tasks", "task-shared", "history_item.json"),
195+
JSON.stringify({ id: "task-shared", source: "default-root" }),
196+
)
197+
await fs.writeFile(path.join(rooDefaultStorageRoot, "tasks", "task-shared", "ui_messages.json"), "default-ui")
198+
199+
await fs.mkdir(path.join(rooCustomStorageRoot, "tasks", "task-shared"), { recursive: true })
200+
await fs.writeFile(
201+
path.join(rooCustomStorageRoot, "tasks", "task-shared", "history_item.json"),
202+
JSON.stringify({ id: "task-shared", source: "custom-root" }),
203+
)
204+
await fs.writeFile(path.join(rooCustomStorageRoot, "tasks", "task-shared", "ui_messages.json"), "custom-ui")
205+
206+
await fs.mkdir(path.join(rooCustomStorageRoot, "tasks", "task-custom-only"), { recursive: true })
207+
await fs.writeFile(
208+
path.join(rooCustomStorageRoot, "tasks", "task-custom-only", "history_item.json"),
209+
JSON.stringify({ id: "task-custom-only", source: "custom-root" }),
210+
)
211+
await fs.writeFile(
212+
path.join(rooCustomStorageRoot, "tasks", "task-custom-only", "ui_messages.json"),
213+
"custom-only-ui",
214+
)
215+
216+
const result = await importRooTaskHistory(zooGlobalStoragePath)
217+
218+
expect(result.importedTaskCount).toBe(2)
219+
expect(result.importedFileCount).toBe(4)
220+
expect(
221+
await fs.readFile(path.join(zooGlobalStoragePath, "tasks", "task-shared", "history_item.json"), "utf8"),
222+
).toBe(JSON.stringify({ id: "task-shared", source: "default-root" }))
223+
expect(
224+
await fs.readFile(path.join(zooGlobalStoragePath, "tasks", "task-shared", "ui_messages.json"), "utf8"),
225+
).toBe("default-ui")
226+
expect(
227+
await fs.readFile(
228+
path.join(zooGlobalStoragePath, "tasks", "task-custom-only", "history_item.json"),
229+
"utf8",
230+
),
231+
).toBe(JSON.stringify({ id: "task-custom-only", source: "custom-root" }))
232+
})
233+
149234
it("reports Roo history import progress as files are copied", async () => {
150235
const zooGlobalStoragePath = path.join(tempRoot, "globalStorage", "zoocodeorganization.zoo-code")
151236
const rooDefaultStorageRoot = path.join(tempRoot, "globalStorage", "rooveterinaryinc.roo-cline")
@@ -340,6 +425,31 @@ describe("importRooTaskHistory", () => {
340425
expect(await fs.readFile(path.join(existingZooTaskDirectory, "history_item.json"), "utf8")).toBe("existing")
341426
})
342427

428+
it("does not overwrite an existing Zoo task when the Roo task is otherwise importable", async () => {
429+
const zooGlobalStoragePath = path.join(tempRoot, "globalStorage", "zoocodeorganization.zoo-code")
430+
const rooDefaultStorageRoot = path.join(tempRoot, "globalStorage", "rooveterinaryinc.roo-cline")
431+
const existingZooTaskDirectory = path.join(zooGlobalStoragePath, "tasks", "task-existing")
432+
433+
mockStorageConfiguration()
434+
435+
await fs.mkdir(path.join(rooDefaultStorageRoot, "tasks", "task-existing"), { recursive: true })
436+
await fs.writeFile(
437+
path.join(rooDefaultStorageRoot, "tasks", "task-existing", "history_item.json"),
438+
JSON.stringify({ id: "task-existing", source: "roo" }),
439+
)
440+
await fs.writeFile(path.join(rooDefaultStorageRoot, "tasks", "task-existing", "ui_messages.json"), "roo-ui")
441+
await fs.mkdir(existingZooTaskDirectory, { recursive: true })
442+
await fs.writeFile(path.join(existingZooTaskDirectory, "history_item.json"), "existing")
443+
await fs.writeFile(path.join(existingZooTaskDirectory, "ui_messages.json"), "existing-ui")
444+
445+
const result = await importRooTaskHistory(zooGlobalStoragePath)
446+
447+
expect(result.importedTaskCount).toBe(0)
448+
expect(result.importedFileCount).toBe(0)
449+
expect(await fs.readFile(path.join(existingZooTaskDirectory, "history_item.json"), "utf8")).toBe("existing")
450+
expect(await fs.readFile(path.join(existingZooTaskDirectory, "ui_messages.json"), "utf8")).toBe("existing-ui")
451+
})
452+
343453
it("rethrows unexpected task-root errors while importing Roo history", async () => {
344454
const zooGlobalStoragePath = path.join(tempRoot, "globalStorage", "zoocodeorganization.zoo-code")
345455
const rooDefaultStorageRoot = path.join(tempRoot, "globalStorage", "rooveterinaryinc.roo-cline")

src/core/task-persistence/importRooTaskHistory.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ const collectImportableTaskPlans = async (sourceRoots: string[]) => {
147147
continue
148148
}
149149

150+
// Preserve source-root priority: the first importable occurrence of a task ID wins.
151+
if (taskIds.has(entry.name)) {
152+
continue
153+
}
154+
150155
const sourceTaskDirectory = path.join(sourceTasksRoot, entry.name)
151156
const fileNames = await getImportableTaskFileNames(sourceTaskDirectory)
152157

@@ -165,7 +170,7 @@ const collectImportableTaskPlans = async (sourceRoots: string[]) => {
165170

166171
return {
167172
taskPlans,
168-
totalTaskCount: taskIds.size,
173+
totalTaskCount: taskPlans.length,
169174
}
170175
}
171176

@@ -193,11 +198,25 @@ export const importRooTaskHistory = async (
193198
(sourceRoot) => toComparablePath(sourceRoot) !== destinationComparablePath,
194199
)
195200
const destinationTasksRoot = path.join(paths.zooStorageRoot, "tasks")
196-
const { taskPlans, totalTaskCount } = await collectImportableTaskPlans(sourceRoots)
201+
const { taskPlans } = await collectImportableTaskPlans(sourceRoots)
197202
const importedTaskIds = new Set<string>()
198203
let importedFileCount = 0
199-
let totalFileCount = taskPlans.reduce((count, taskPlan) => count + taskPlan.fileNames.length, 0)
200204
let copiedFileCount = 0
205+
const importableTaskPlans: ImportableTaskPlan[] = []
206+
207+
await fs.mkdir(destinationTasksRoot, { recursive: true })
208+
209+
for (const taskPlan of taskPlans) {
210+
const destinationTaskDirectory = path.join(destinationTasksRoot, taskPlan.taskId)
211+
if (await pathExists(destinationTaskDirectory)) {
212+
continue
213+
}
214+
215+
importableTaskPlans.push(taskPlan)
216+
}
217+
218+
const totalTaskCount = importableTaskPlans.length
219+
let totalFileCount = importableTaskPlans.reduce((count, taskPlan) => count + taskPlan.fileNames.length, 0)
201220

202221
const reportProgress = async (currentTaskId?: string, currentFileName?: string) => {
203222
if (!onProgress) {
@@ -214,13 +233,17 @@ export const importRooTaskHistory = async (
214233
})
215234
}
216235

217-
await fs.mkdir(destinationTasksRoot, { recursive: true })
218-
219236
await reportProgress()
220237

221-
for (const taskPlan of taskPlans) {
238+
for (const taskPlan of importableTaskPlans) {
222239
const destinationTaskDirectory = path.join(destinationTasksRoot, taskPlan.taskId)
223240
const destinationTaskDirectoryExisted = await pathExists(destinationTaskDirectory)
241+
242+
if (destinationTaskDirectoryExisted) {
243+
totalFileCount -= taskPlan.fileNames.length
244+
continue
245+
}
246+
224247
const historyItemCopied = await copyTaskFileIfPresent(
225248
taskPlan.sourceTaskDirectory,
226249
destinationTaskDirectory,

webview-ui/src/components/settings/About.tsx

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export const About = ({ telemetrySetting, setTelemetrySetting, debug, setDebug,
3838
}
3939

4040
const progress = message.rooHistoryImportProgress
41-
if (progress.status === "failed" || (progress.status === "finished" && progress.totalFileCount === 0)) {
41+
if (progress.status === "finished" && progress.totalFileCount === 0) {
4242
setRooHistoryImportProgress(null)
4343
return
4444
}
@@ -52,18 +52,24 @@ export const About = ({ telemetrySetting, setTelemetrySetting, debug, setDebug,
5252

5353
const isImporting =
5454
rooHistoryImportProgress?.status === "starting" || rooHistoryImportProgress?.status === "copying"
55-
const shouldShowImportProgress =
56-
!!rooHistoryImportProgress &&
57-
(isImporting || (rooHistoryImportProgress.status === "finished" && rooHistoryImportProgress.totalFileCount > 0))
55+
const isImportFailed = rooHistoryImportProgress?.status === "failed"
56+
const isImportSuccessful =
57+
rooHistoryImportProgress?.status === "finished" && rooHistoryImportProgress.totalFileCount > 0
58+
const shouldShowImportProgress = !!rooHistoryImportProgress && (isImporting || isImportFailed || isImportSuccessful)
5859
const importProgressPercent =
5960
rooHistoryImportProgress && rooHistoryImportProgress.totalFileCount > 0
6061
? Math.round((rooHistoryImportProgress.copiedFileCount / rooHistoryImportProgress.totalFileCount) * 100)
6162
: 0
62-
const importProgressSummary = rooHistoryImportProgress
63-
? `${rooHistoryImportProgress.copiedFileCount} of ${rooHistoryImportProgress.totalFileCount} files copied`
64-
: ""
65-
const importProgressDetail =
66-
rooHistoryImportProgress && rooHistoryImportProgress.importedTaskCount > 0
63+
const importProgressSummary = !rooHistoryImportProgress
64+
? ""
65+
: isImportFailed
66+
? rooHistoryImportProgress.totalFileCount > 0
67+
? `${rooHistoryImportProgress.copiedFileCount} of ${rooHistoryImportProgress.totalFileCount} files copied before the import stopped.`
68+
: "The import stopped before any files were copied."
69+
: `${rooHistoryImportProgress.copiedFileCount} of ${rooHistoryImportProgress.totalFileCount} files copied`
70+
const importProgressDetail = isImportFailed
71+
? "Start a new import attempt to try again."
72+
: rooHistoryImportProgress && rooHistoryImportProgress.importedTaskCount > 0
6773
? `Imported ${rooHistoryImportProgress.importedTaskCount} of ${Math.max(
6874
rooHistoryImportProgress.totalTaskCount,
6975
rooHistoryImportProgress.importedTaskCount,
@@ -227,11 +233,17 @@ export const About = ({ telemetrySetting, setTelemetrySetting, debug, setDebug,
227233
<div className="flex items-center gap-2 text-vscode-foreground">
228234
{isImporting ? (
229235
<span className="codicon codicon-loading codicon-modifier-spin text-vscode-button-background" />
236+
) : isImportFailed ? (
237+
<span className="codicon codicon-error text-[var(--vscode-testing-iconFailed)]" />
230238
) : (
231239
<span className="codicon codicon-check text-[var(--vscode-testing-iconPassed)]" />
232240
)}
233241
<span className="font-medium">
234-
{isImporting ? "Importing history" : "Import complete"}
242+
{isImporting
243+
? "Importing history"
244+
: isImportFailed
245+
? "Import failed"
246+
: "Import complete"}
235247
</span>
236248
</div>
237249
<div className="text-sm font-medium text-vscode-descriptionForeground">
@@ -250,7 +262,9 @@ export const About = ({ telemetrySetting, setTelemetrySetting, debug, setDebug,
250262
"h-full rounded-full transition-[width] duration-200",
251263
isImporting
252264
? "bg-[var(--vscode-progressBar-background)]"
253-
: "bg-[var(--vscode-testing-iconPassed)]",
265+
: isImportFailed
266+
? "bg-[var(--vscode-testing-iconFailed)]"
267+
: "bg-[var(--vscode-testing-iconPassed)]",
254268
)}
255269
style={{ width: `${importProgressPercent}%` }}
256270
/>

0 commit comments

Comments
 (0)