fix(EXT-696): prevent parent task deletion when API history file missing

This fixes the issue where parent tasks disappear from the UI in orchestrator
mode after delegating to a child task via new_task tool.

Root cause: In getTaskWithId(), if the historyItem existed in taskHistory but
the apiConversationHistory.json file did not exist (due to race conditions
during delegation), the method would incorrectly delete the task from state.

Fix: When historyItem exists but the API history file does not, return the
task with an empty apiConversationHistory array instead of deleting it. This
preserves the task metadata during delegation and prevents the parent task
from disappearing from the UI.

Added tests to verify:
- Task is preserved with empty API history when file is missing
- Task is properly deleted when historyItem does not exist
This commit is contained in:
Roo Code 2026-02-03 07:42:16 +00:00
parent 4647d0f3c5
commit 5f46f32dab
2 changed files with 70 additions and 2 deletions

View file

@ -1684,10 +1684,24 @@ export class ClineProvider
apiConversationHistory,
}
}
// EXT-696: historyItem exists but API conversation file doesn't.
// This can happen during race conditions (e.g., delegation before file is flushed).
// Return with empty apiConversationHistory instead of deleting the task.
// The task's metadata is still valid and should be preserved.
this.log(
`[getTaskWithId] Task ${id} exists in history but API conversation file not found. Returning with empty history.`,
)
return {
historyItem,
taskDirPath,
apiConversationHistoryFilePath,
uiMessagesFilePath,
apiConversationHistory: [],
}
}
// if we tried to get a task that doesn't exist, remove it from state
// FIXME: this seems to happen sometimes when the json file doesnt save to disk for some reason
// Only delete from state if historyItem truly doesn't exist
await this.deleteTaskFromState(id)
throw new Error("Task not found")
}

View file

@ -592,4 +592,58 @@ describe("ClineProvider Task History Synchronization", () => {
expect(state.taskHistory.some((item: HistoryItem) => item.workspace === "/different/workspace")).toBe(true)
})
})
describe("getTaskWithId", () => {
it("returns task with empty apiConversationHistory when file does not exist but historyItem exists (EXT-696 fix)", async () => {
await provider.resolveWebviewView(mockWebviewView)
provider.isViewLaunched = true
const historyItem = createHistoryItem({
id: "delegated-parent-task",
task: "Parent task that was delegated",
})
// Add the task to history
await provider.updateTaskHistory(historyItem)
// Mock fileExistsAtPath to return false (simulating race condition during delegation)
const fsUtils = await import("../../../utils/fs")
const fileExistsSpy = vi.spyOn(fsUtils, "fileExistsAtPath").mockResolvedValue(false)
// Track if deleteTaskFromState was called
const deleteTaskSpy = vi.spyOn(provider, "deleteTaskFromState")
// Call getTaskWithId
const result = await provider.getTaskWithId("delegated-parent-task")
// Should return the task with empty API conversation history, NOT delete it
expect(result.historyItem.id).toBe("delegated-parent-task")
expect(result.historyItem.task).toBe("Parent task that was delegated")
expect(result.apiConversationHistory).toEqual([])
// Should NOT have called deleteTaskFromState
expect(deleteTaskSpy).not.toHaveBeenCalled()
// Clean up
fileExistsSpy.mockRestore()
deleteTaskSpy.mockRestore()
})
it("throws error and deletes task when historyItem does not exist", async () => {
await provider.resolveWebviewView(mockWebviewView)
provider.isViewLaunched = true
// Track if deleteTaskFromState was called
const deleteTaskSpy = vi.spyOn(provider, "deleteTaskFromState").mockResolvedValue()
// Call getTaskWithId with a task that doesn't exist
await expect(provider.getTaskWithId("non-existent-task")).rejects.toThrow("Task not found")
// Should have called deleteTaskFromState
expect(deleteTaskSpy).toHaveBeenCalledWith("non-existent-task")
// Clean up
deleteTaskSpy.mockRestore()
})
})
})