Repository navigation
fix(history): prompt on workspace mismatch #1660
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
68ff869
9d6aefa
f0cc263
6b91307
7448eff
31822b1
2733098
16203ba
aba4b2a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -121,8 +121,9 @@ | |
| import { fileExistsAtPath } from "../../utils/fs" | ||
| import { setTtsEnabled, setTtsSpeed } from "../../utils/tts" | ||
| import { getWorkspaceGitInfo } from "../../utils/git" | ||
| import { getWorkspacePath } from "../../utils/path" | ||
| import { arePathsEqual, getWorkspacePath } from "../../utils/path" | ||
| import { OrganizationAllowListViolationError } from "../../utils/errors" | ||
| import { getTaskDirectoryPath } from "../../utils/storage" | ||
|
|
||
| import { getPanel, setPanel } from "../../activate/registerCommands" | ||
|
|
||
|
|
@@ -151,7 +152,7 @@ | |
| interruptDelegatedChild, | ||
| LifecycleTransitionError, | ||
| } from "../task-persistence" | ||
| import { readTaskMessages } from "../task-persistence/taskMessages" | ||
| import { readTaskMessages, updateTaskMessages } from "../task-persistence/taskMessages" | ||
| import { getNonce } from "./getNonce" | ||
| import { getUri } from "./getUri" | ||
| import { omitOriginalContentFromExtensionMessage } from "./stripOriginalContent" | ||
|
|
@@ -2771,7 +2772,11 @@ | |
| if (id !== this.getCurrentTask()?.taskId) { | ||
| // Non-current task. | ||
| const { historyItem } = await this.getTaskWithId(id) | ||
| await this.createTaskWithHistoryItem(historyItem) // Clears existing task. | ||
| const preparedHistoryItem = await this.prepareHistoryItemForResume(historyItem) | ||
| if (!preparedHistoryItem) { | ||
| return | ||
| } | ||
| await this.createTaskWithHistoryItem(preparedHistoryItem) // Clears existing task. | ||
| } | ||
|
|
||
| await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) | ||
|
|
@@ -2795,6 +2800,183 @@ | |
| } | ||
| } | ||
|
|
||
| public async prepareHistoryItemForResume<T extends HistoryItem>(historyItem: T): Promise<T | undefined> { | ||
| const currentWorkspace = this.cwd | ||
| const originalWorkspace = historyItem.workspace | ||
|
|
||
| // The CLI has no modal to answer, and its host would wait forever for a task that is never created. | ||
| if (process.env.ROO_CLI_RUNTIME === "1") { | ||
| return historyItem | ||
| } | ||
|
|
||
| if (!currentWorkspace || !originalWorkspace || arePathsEqual(currentWorkspace, originalWorkspace)) { | ||
| return historyItem | ||
| } | ||
|
|
||
| // A live Task owns this history and would re-add checkpoint rows or rewrite its workspace after a reset. | ||
| if (this.isTaskLiveInAnyProvider(historyItem.id)) { | ||
| return historyItem | ||
| } | ||
|
|
||
| const useCurrentWorkspace = { title: "Use Current Workspace" } | ||
| const openOriginalWorkspace = { title: "Open Original Workspace" } | ||
| const selection = await vscode.window.showWarningMessage( | ||
| `This conversation was created in "${originalWorkspace}", but the current workspace is "${currentWorkspace}". ` + | ||
|
Check warning on line 2824 in src/core/webview/ClineProvider.ts
|
||
| "Choose where to continue. Using the current workspace resets checkpoints created in the original workspace.", | ||
|
Check warning on line 2825 in src/core/webview/ClineProvider.ts
|
||
| { modal: true }, | ||
| useCurrentWorkspace, | ||
| openOriginalWorkspace, | ||
| ) | ||
|
|
||
| if (selection?.title === openOriginalWorkspace.title) { | ||
| await vscode.commands.executeCommand("vscode.openFolder", vscode.Uri.file(originalWorkspace), { | ||
| forceNewWindow: true, | ||
| }) | ||
| return undefined | ||
| } | ||
|
|
||
| if (selection?.title !== useCurrentWorkspace.title) { | ||
| return undefined | ||
| } | ||
|
|
||
| // The modal has no timeout, so the task may have been opened elsewhere while it was shown. | ||
| if (this.isTaskLiveInAnyProvider(historyItem.id)) { | ||
| this.log(`[prepareHistoryItemForResume] Task ${historyItem.id} became live during workspace selection`) | ||
|
Check warning on line 2844 in src/core/webview/ClineProvider.ts
|
||
| return undefined | ||
| } | ||
|
|
||
| const updatedHistoryItem = { ...historyItem, workspace: currentWorkspace } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch, fixed in c47b767. Both the forward write and the rollback now go through |
||
| const persisted = await this.resetTaskCheckpointsForWorkspaceChange(historyItem, updatedHistoryItem) | ||
| // Resume from the record as persisted now, not the snapshot read before the prompt. | ||
| return { ...historyItem, ...persisted, workspace: currentWorkspace } | ||
| } | ||
|
|
||
| private isTaskLiveInAnyProvider(taskId: string): boolean { | ||
| return ClineProvider.getAllInstances().some((provider) => provider.taskRegistry.getById(taskId) !== undefined) | ||
| } | ||
|
|
||
| /** | ||
| * Persist only `workspace`, read-modify-writing the stored record so fields that changed meanwhile | ||
| * (status, pendingAction, ...) are never overwritten from a stale snapshot. With `expectedWorkspace`, | ||
| * the stored record is left unchanged unless it still has that workspace. | ||
| */ | ||
| private async setTaskHistoryWorkspace( | ||
| historyItem: HistoryItem, | ||
| workspace: string | undefined, | ||
| expectedWorkspace?: string, | ||
| ): Promise<HistoryItem | undefined> { | ||
| const taskId = historyItem.id | ||
| if (!this.taskHistoryStore.get(taskId)) { | ||
| // Legacy fallback before the store is initialized: there is no stored record to preserve. | ||
| if (expectedWorkspace !== undefined) return undefined | ||
| await this.updateTaskHistory({ ...historyItem, workspace }) | ||
| return this.taskHistoryStore.get(taskId) | ||
| } | ||
|
|
||
| let changed = false | ||
| await this.taskHistoryStore.atomicReadAndUpdate(taskId, (current) => { | ||
| if (expectedWorkspace !== undefined && current.workspace !== expectedWorkspace) return current | ||
| changed = current.workspace !== workspace | ||
|
Check warning on line 2879 in src/core/webview/ClineProvider.ts
|
||
| return { ...current, workspace } | ||
| }) | ||
|
PierrunoYT marked this conversation as resolved.
Resolved
|
||
| this.recentTasksCache = undefined | ||
| const updated = this.taskHistoryStore.get(taskId) | ||
| if (changed && updated && this.isViewLaunched) { | ||
| await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updated }) | ||
|
Check warning on line 2885 in src/core/webview/ClineProvider.ts
|
||
| } | ||
| return updated | ||
| } | ||
|
|
||
| private async resetTaskCheckpointsForWorkspaceChange( | ||
| originalHistoryItem: HistoryItem, | ||
| updatedHistoryItem: HistoryItem, | ||
| ): Promise<HistoryItem | undefined> { | ||
| const taskId = originalHistoryItem.id | ||
| const globalStoragePath = this.contextProxy.globalStorageUri.fsPath | ||
| let removedCheckpoints: ClineMessage[] = [] | ||
|
Check warning on line 2896 in src/core/webview/ClineProvider.ts
|
||
| let persisted: HistoryItem | undefined | ||
| const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId) | ||
| const checkpointsDir = path.join(taskDir, "checkpoints") | ||
| const checkpointBackupDir = path.join(taskDir, `checkpoints.workspace-change-${crypto.randomUUID()}`) | ||
| let checkpointDirectoryStaged = false | ||
|
Check warning on line 2901 in src/core/webview/ClineProvider.ts
|
||
|
|
||
| try { | ||
| await fs.rename(checkpointsDir, checkpointBackupDir) | ||
| checkpointDirectoryStaged = true | ||
| } catch (error) { | ||
| if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) { | ||
| throw error | ||
| } | ||
| } | ||
|
|
||
| try { | ||
| // History migration can create a record for a task directory that has no ui_messages.json; | ||
| // that is an empty set of checkpoint rows, not an error. Invalid existing content still rejects. | ||
| if (await fileExistsAtPath(path.join(taskDir, GlobalFileNames.uiMessages))) { | ||
| await updateTaskMessages({ | ||
| taskId, | ||
| globalStoragePath, | ||
| update: (messages) => { | ||
| removedCheckpoints = messages.filter( | ||
| (message) => message.type === "say" && message.say === "checkpoint_saved", | ||
| ) | ||
| return messages.filter((message) => !removedCheckpoints.includes(message)) | ||
| }, | ||
| }) | ||
| } | ||
| persisted = await this.setTaskHistoryWorkspace(originalHistoryItem, updatedHistoryItem.workspace) | ||
|
PierrunoYT marked this conversation as resolved.
Resolved
|
||
| } catch (error) { | ||
| if (removedCheckpoints.length > 0) { | ||
| try { | ||
| // Restore only the removed rows; never replace intervening message writes. | ||
| await saveTaskMessages({ messages: removedCheckpoints, taskId, globalStoragePath, merge: true }) | ||
| } catch (rollbackError) { | ||
| this.log( | ||
| `[resetTaskCheckpointsForWorkspaceChange] Failed to restore messages for ${taskId}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, | ||
| ) | ||
| } | ||
| } | ||
| if (checkpointDirectoryStaged) { | ||
| try { | ||
| await fs.rename(checkpointBackupDir, checkpointsDir) | ||
| } catch (rollbackError) { | ||
| this.log( | ||
| `[resetTaskCheckpointsForWorkspaceChange] Failed to restore checkpoints for ${taskId} from ${checkpointBackupDir}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, | ||
| ) | ||
| } | ||
| } | ||
| try { | ||
| // Revert only the workspace, and only if no newer write has moved it again. | ||
| await this.setTaskHistoryWorkspace( | ||
| originalHistoryItem, | ||
| originalHistoryItem.workspace, | ||
| updatedHistoryItem.workspace, | ||
| ) | ||
| } catch (rollbackError) { | ||
| this.log( | ||
| `[resetTaskCheckpointsForWorkspaceChange] Failed to restore history for ${taskId}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, | ||
| ) | ||
| } | ||
| throw error | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
|
|
||
| if (checkpointDirectoryStaged) { | ||
| for (let attempt = 0; attempt < 3; attempt++) { | ||
| try { | ||
| await fs.rm(checkpointBackupDir, { recursive: true, force: true }) | ||
| break | ||
| } catch (error) { | ||
| this.log( | ||
| `[resetTaskCheckpointsForWorkspaceChange] Failed to remove checkpoint backup ${checkpointBackupDir}: ${error instanceof Error ? error.message : String(error)}`, | ||
| ) | ||
| if (attempt < 2) await delay(100) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return persisted | ||
| } | ||
|
|
||
| async exportTaskWithId(id: string) { | ||
| const { historyItem, apiConversationHistory } = await this.getTaskWithId(id) | ||
| const fileName = getTaskFileName(historyItem.ts) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.