From abfbe7f789c00a8084901158cedb9e1ca1ac4982 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 19:59:46 +0800 Subject: [PATCH 01/29] feat(checkpoints): per-write checkpoints, task-start baseline, and perWriteCheckpoints setting (B1, #1375) --- packages/types/src/global-settings.ts | 13 + packages/types/src/vscode-extension-host.ts | 1 + src/core/task/Task.ts | 14 + src/core/task/__tests__/Task.spec.ts | 92 +++++ src/core/tools/ApplyPatchTool.ts | 67 +++- src/core/tools/EditFileTool.ts | 6 + src/core/tools/WriteToFileTool.ts | 8 + .../__tests__/applyPatchTool.execute.spec.ts | 331 ++++++++++++++++++ src/core/tools/__tests__/editFileTool.spec.ts | 42 +++ .../tools/__tests__/writeToFileTool.spec.ts | 77 ++++ src/core/webview/ClineProvider.ts | 4 + .../webview/__tests__/ClineProvider.spec.ts | 43 +++ .../settings/CheckpointSettings.tsx | 21 +- .../src/components/settings/SettingsView.tsx | 4 + .../__tests__/CheckpointSettings.spec.tsx | 125 +++++++ .../src/context/ExtensionStateContext.tsx | 1 + .../__tests__/ExtensionStateContext.spec.tsx | 2 + webview-ui/src/i18n/locales/ca/settings.json | 4 + webview-ui/src/i18n/locales/de/settings.json | 4 + webview-ui/src/i18n/locales/en/settings.json | 4 + webview-ui/src/i18n/locales/es/settings.json | 4 + webview-ui/src/i18n/locales/fr/settings.json | 4 + webview-ui/src/i18n/locales/hi/settings.json | 4 + webview-ui/src/i18n/locales/id/settings.json | 4 + webview-ui/src/i18n/locales/it/settings.json | 4 + webview-ui/src/i18n/locales/ja/settings.json | 4 + webview-ui/src/i18n/locales/ko/settings.json | 4 + webview-ui/src/i18n/locales/nl/settings.json | 4 + webview-ui/src/i18n/locales/pl/settings.json | 4 + .../src/i18n/locales/pt-BR/settings.json | 4 + webview-ui/src/i18n/locales/ru/settings.json | 4 + webview-ui/src/i18n/locales/tr/settings.json | 4 + webview-ui/src/i18n/locales/vi/settings.json | 4 + .../src/i18n/locales/zh-CN/settings.json | 4 + .../src/i18n/locales/zh-TW/settings.json | 4 + 35 files changed, 904 insertions(+), 19 deletions(-) create mode 100644 webview-ui/src/components/settings/__tests__/CheckpointSettings.spec.tsx diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 95f246dbe7..4f21ca9607 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -99,6 +99,13 @@ export const MAX_CHECKPOINT_TIMEOUT_SECONDS = 60 */ export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15 +/** + * Whether per-write checkpoints and task-start baseline are enabled by default. + * Master switch for the B cluster of checkpoint features. + * @default true + */ +export const DEFAULT_PER_WRITE_CHECKPOINTS = true + /** * GlobalSettings */ @@ -200,6 +207,12 @@ export const globalSettingsSchema = z.object({ .min(MIN_CHECKPOINT_TIMEOUT_SECONDS) .max(MAX_CHECKPOINT_TIMEOUT_SECONDS) .optional(), + /** + * Whether to record a shadow-git checkpoint after every successful write_to_file, + * edit_file, and apply_patch (per-write checkpoints), plus a task-start baseline. + * @default true + */ + perWriteCheckpoints: z.boolean().optional(), ttsEnabled: z.boolean().optional(), ttsSpeed: z.number().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 5f6b579779..20756d7a68 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -348,6 +348,7 @@ export type ExtensionState = Pick< enableCheckpoints: boolean checkpointTimeout: number // Timeout for checkpoint initialization in seconds (default: 15) + perWriteCheckpoints: boolean maxOpenTabsContext: number // Maximum number of VSCode open tabs to include in context (0-500) maxWorkspaceFiles: number // Maximum number of files to include in current working directory details (0-500) showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 349d9c51d3..61203a3046 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -314,6 +314,9 @@ export class Task extends EventEmitter implements TaskLike { public lastMessageTs?: number private autoApprovalTimeoutRef?: NodeJS.Timeout + // B1: task-start baseline, recorded at most once (initiateTaskLoop also runs on resume). + private taskStartBaselineDone = false + // Tool Use consecutiveMistakeCount: number = 0 consecutiveMistakeLimit: number @@ -2492,6 +2495,17 @@ export class Task extends EventEmitter implements TaskLike { // arm needed. void getCheckpointService(this) + // B1 task-start baseline: a suppressed pre-task root commit (default-on). + if (!this.taskStartBaselineDone) { + this.taskStartBaselineDone = true + const baselineEnabled = (await this.providerRef.deref()?.getState())?.perWriteCheckpoints + if (baselineEnabled !== false) { + // allowEmpty=true so a clean workspace still produces the baseline + // commit; awaited so the first per-write checkpoint cannot interleave. + await this.checkpointSave(true, true) + } + } + let nextUserContent = userContent let includeFileDetails = true diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 37e228f887..4ca5d7495c 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -3289,6 +3289,98 @@ describe("Cline", () => { }) }) + describe("task-start baseline (B1 perWriteCheckpoints)", () => { + it("records one suppressed baseline checkpoint per Task instance at loop start", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "baseline task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue(undefined) + const state = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue(state) + + task.abort = true + + await taskAccess.initiateTaskLoop([]) + await taskAccess.initiateTaskLoop([]) + + expect(saveSpy).toHaveBeenCalledOnce() + expect(saveSpy).toHaveBeenCalledWith(true, true) + }) + + it("records the baseline checkpoint when the setting is unset (default-on)", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "baseline unset task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue(undefined) + const state = await mockProvider.getState() + // Unset: the property is absent from the state, so default-on applies. + const unsetState = { ...state } + Reflect.deleteProperty(unsetState, "perWriteCheckpoints") + vi.spyOn(mockProvider, "getState").mockResolvedValue(unsetState as typeof state) + + task.abort = true + + await taskAccess.initiateTaskLoop([]) + + expect(saveSpy).toHaveBeenCalledOnce() + expect(saveSpy).toHaveBeenCalledWith(true, true) + }) + + it("does not record a baseline checkpoint when perWriteCheckpoints is disabled", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "baseline disabled task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue(undefined) + const state = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...state, perWriteCheckpoints: false }) + + task.abort = true + + await taskAccess.initiateTaskLoop([]) + + expect(saveSpy).not.toHaveBeenCalled() + }) + + it("awaits the baseline checkpoint before entering the request loop", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "baseline await task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + type SaveResult = Awaited> + let resolveSave: (value: SaveResult | PromiseLike) => void = () => {} + const saveSpy = vi + .spyOn(task, "checkpointSave") + .mockImplementation(() => new Promise((resolve) => (resolveSave = resolve))) + const requestSpy = vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true) + vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...(await mockProvider.getState()) }) + const loopPromise = taskAccess.initiateTaskLoop([]) + + // The loop must not enter while the baseline checkpoint is still in flight. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(saveSpy).toHaveBeenCalledOnce() + expect(requestSpy).not.toHaveBeenCalled() + + resolveSave() + await loopPromise + expect(requestSpy).toHaveBeenCalled() + }) + }) + describe("start()", () => { it("should be a no-op if the task was already started in the constructor", () => { const task = new Task({ diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 56b2bf8909..f42a4ebf03 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -6,6 +6,7 @@ import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" import { getReadablePath } from "../../utils/path" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { Task } from "../task/Task" +import { checkpointSave } from "../checkpoints" import { formatResponse } from "../prompts/responses" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { fileExistsAtPath } from "../../utils/fs" @@ -102,7 +103,10 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { return } - // Process each file change + // Process each file change. The handlers report whether their file + // operation succeeded, so a rejected approval or a failed local write + // does not get checkpointed as if the patch had succeeded. + let patchSucceeded = true for (const change of changes) { const relPath = change.path const absolutePath = path.resolve(task.cwd, relPath) @@ -120,17 +124,39 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (change.type === "add") { // Create new file - await this.handleAddFile(change, absolutePath, relPath, task, callbacks, isWriteProtected) + patchSucceeded = + (await this.handleAddFile(change, absolutePath, relPath, task, callbacks, isWriteProtected)) && + patchSucceeded } else if (change.type === "delete") { // Delete file - await this.handleDeleteFile(absolutePath, relPath, task, callbacks, isWriteProtected) + patchSucceeded = + (await this.handleDeleteFile(absolutePath, relPath, task, callbacks, isWriteProtected)) && + patchSucceeded } else if (change.type === "update") { // Update file - await this.handleUpdateFile(change, absolutePath, relPath, task, callbacks, isWriteProtected) + patchSucceeded = + (await this.handleUpdateFile( + change, + absolutePath, + relPath, + task, + callbacks, + isWriteProtected, + )) && patchSucceeded } } task.consecutiveMistakeCount = 0 + + // B1: one checkpoint for the whole patch (not per file), and only when + // every file operation succeeded. Live setting with default-on + // semantics: skip only when explicitly false. + if (patchSucceeded) { + const perWriteCheckpoints = (await task.providerRef?.deref()?.getState())?.perWriteCheckpoints + if (perWriteCheckpoints !== false) { + void checkpointSave(task, false, true).catch(() => {}) + } + } } catch (error) { await handleError("apply patch", error as Error) await task.diffViewProvider.reset() @@ -144,7 +170,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, - ): Promise { + ): Promise { const { askApproval, pushToolResult } = callbacks // Check if file already exists @@ -155,7 +181,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const errorMessage = `File already exists: ${relPath}. Use Update File instead.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) - return + return false } const newContent = change.newContent || "" @@ -209,7 +235,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } pushToolResult("Changes were rejected by the user.") await task.diffViewProvider.reset() - return + return false } // Save the changes @@ -227,6 +253,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { pushToolResult(message) await task.diffViewProvider.reset() task.processQueuedMessages() + return true } private async handleDeleteFile( @@ -235,7 +262,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, - ): Promise { + ): Promise { const { askApproval, pushToolResult } = callbacks // Check if file exists @@ -246,7 +273,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const errorMessage = `File not found: ${relPath}. Cannot delete a non-existent file.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) - return + return false } const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) @@ -268,7 +295,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (!didApprove) { pushToolResult("Delete operation was rejected by the user.") - return + return false } // Delete the file @@ -278,12 +305,13 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const errorMessage = `Failed to delete file '${relPath}': ${error instanceof Error ? error.message : String(error)}` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) - return + return false } task.didEditFile = true pushToolResult(`Successfully deleted ${relPath}`) task.processQueuedMessages() + return true } private async handleUpdateFile( @@ -293,7 +321,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, - ): Promise { + ): Promise { const { askApproval, pushToolResult } = callbacks // Check if file exists @@ -304,7 +332,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const errorMessage = `File not found: ${relPath}. Cannot update a non-existent file.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) - return + return false } const originalContent = change.originalContent || "" @@ -318,9 +346,11 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Generate and validate diff const diff = formatResponse.createPrettyPatch(relPath, originalContent, newContent) if (!diff) { + // A no-op change is not a failure: the patch processed cleanly and + // nothing was written, so the whole-patch success state is kept. pushToolResult(`No changes needed for '${relPath}'`) await task.diffViewProvider.reset() - return + return true } // Check experiment settings @@ -366,7 +396,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } pushToolResult("Changes were rejected by the user.") await task.diffViewProvider.reset() - return + return false } // Handle file move if specified @@ -379,7 +409,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.say("rooignore_error", change.movePath) pushToolResult(formatResponse.rooIgnoreError(change.movePath)) await task.diffViewProvider.reset() - return + return false } // Check if destination path is write-protected @@ -391,7 +421,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) await task.diffViewProvider.reset() - return + return false } // Check if destination path is outside workspace @@ -403,7 +433,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) await task.diffViewProvider.reset() - return + return false } // Save new content to the new path @@ -447,6 +477,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { pushToolResult(message) await task.diffViewProvider.reset() task.processQueuedMessages() + return true } override async handlePartial(task: Task, block: ToolUse<"apply_patch">): Promise { diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index a7301e2ac9..0a3cb5d2e8 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -11,6 +11,7 @@ import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { fileExistsAtPath } from "../../utils/fs" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" +import { checkpointSave } from "../../core/checkpoints" import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -392,6 +393,7 @@ export class EditFileTool extends BaseTool<"edit_file"> { const state = await provider?.getState() const diagnosticsEnabled = state?.diagnosticsEnabled ?? true const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS + const perWriteCheckpoints = state?.perWriteCheckpoints ?? true const isPreventFocusDisruptionEnabled = experiments.isEnabled( state?.experiments ?? {}, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, @@ -463,6 +465,10 @@ export class EditFileTool extends BaseTool<"edit_file"> { pushToolResult(message + replacementInfo) + if (perWriteCheckpoints) { + void checkpointSave(task, false, true).catch(() => {}) + } + await task.diffViewProvider.reset() this.resetPartialState() diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index ae026b4b86..cf1500a510 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -14,6 +14,7 @@ import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { unescapeHtmlEntities } from "../../utils/text-normalization" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" +import { checkpointSave } from "../checkpoints" import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -103,6 +104,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const state = await provider?.getState() const diagnosticsEnabled = state?.diagnosticsEnabled ?? true const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS + const perWriteCheckpoints = state?.perWriteCheckpoints ?? true const isPreventFocusDisruptionEnabled = experiments.isEnabled( state?.experiments ?? {}, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, @@ -179,6 +181,12 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { pushToolResult(message) + if (perWriteCheckpoints) { + // Await so the checkpoint (staging + commit) finishes before the next + // queued write starts; otherwise two writes can collapse into one commit. + await checkpointSave(task, false, true).catch(() => {}) + } + await task.diffViewProvider.reset() this.resetPartialState() diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 72ffb112bc..f5f64c939e 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -4,13 +4,29 @@ import type { MockedFunction } from "vitest" import { fileExistsAtPath } from "../../../utils/fs" import { isPathOutsideWorkspace } from "../../../utils/pathUtils" +import * as fsPromises from "fs/promises" import type { Task } from "../../task/Task" +import { checkpointSave } from "../../checkpoints" import { ApplyPatchTool } from "../ApplyPatchTool" +// The vi.mock factory exposes the fs/promises functions under a `default` +// property (matching the SUT's default import), which the static module type +// does not declare; cast once at this boundary rather than at each call site. +const mockedFsPromises = vi.mocked( + fsPromises as unknown as { + default: { + unlink: MockedFunction + writeFile: MockedFunction + } + }, +) + vi.mock("fs/promises", () => ({ default: { readFile: vi.fn().mockResolvedValue("original file content\n"), unlink: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), }, })) @@ -22,6 +38,10 @@ vi.mock("../../../utils/pathUtils", () => ({ isPathOutsideWorkspace: vi.fn().mockReturnValue(false), })) +vi.mock("../../checkpoints", () => ({ + checkpointSave: vi.fn().mockResolvedValue(undefined), +})) + describe("ApplyPatchTool.execute - delete file success path", () => { const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction const mockedIsPathOutsideWorkspace = isPathOutsideWorkspace as MockedFunction @@ -38,6 +58,9 @@ describe("ApplyPatchTool.execute - delete file success path", () => { | "say" | "processQueuedMessages" | "didEditFile" + | "providerRef" + | "diffViewProvider" + | "fileContextTracker" > let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> @@ -52,6 +75,11 @@ describe("ApplyPatchTool.execute - delete file success path", () => { mockTask = { cwd: "/workspace/project", consecutiveMistakeCount: 0, + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({}), + }), + } as unknown as Task["providerRef"], recordToolUsage: vi.fn(), recordToolError: vi.fn(), rooIgnoreController: { @@ -63,6 +91,21 @@ describe("ApplyPatchTool.execute - delete file success path", () => { say: vi.fn().mockResolvedValue(undefined), processQueuedMessages: vi.fn(), didEditFile: false, + diffViewProvider: { + editType: "modify", + originalContent: undefined, + open: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + scrollToFirstDiff: vi.fn(), + revertChanges: vi.fn().mockResolvedValue(undefined), + reset: vi.fn().mockResolvedValue(undefined), + saveDirectly: vi.fn().mockResolvedValue({ finalContent: "saved" }), + saveChanges: vi.fn().mockResolvedValue(undefined), + pushToolWriteResult: vi.fn().mockResolvedValue("File saved successfully"), + } as unknown as Task["diffViewProvider"], + fileContextTracker: { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } as unknown as Task["fileContextTracker"], } mockAskApproval = vi.fn().mockResolvedValue(true) @@ -93,4 +136,292 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockTask.recordToolUsage).not.toHaveBeenCalled() expect(mockTask.recordToolError).not.toHaveBeenCalled() }) + + describe("per-write checkpoints (B1)", () => { + const deletePatch = `*** Begin Patch +*** Delete File: src/obsolete.ts +*** End Patch` + const mockedCheckpointSave = checkpointSave as MockedFunction + + it("records one suppressed checkpoint for the whole patch (default-on)", async () => { + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully deleted")) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true) + }) + + it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { + // Structural cast for the test double (matches the mock style used for the controllers above). + const ref = (mockTask["providerRef"] as unknown as { deref: MockedFunction<() => unknown> }).deref + ref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ perWriteCheckpoints: false }), + }) + + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully deleted")) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when patch processing fails", async () => { + // A malformed patch fails at parse time, before the change loop and + // the post-loop checkpoint hook. + const badPatch = `*** Begin Patch +*** This is not a valid hunk +*** End Patch` + + await tool.execute({ patch: badPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.recordToolError).toHaveBeenCalledWith("apply_patch") + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + }) + + describe("checkpoint only for fully successful patches (B1)", () => { + const mockedCheckpointSave = checkpointSave as MockedFunction + const deletePatch = `*** Begin Patch +*** Delete File: src/obsolete.ts +*** End Patch` + const addPatch = `*** Begin Patch +*** Add File: src/new.ts ++hello ++world +*** End Patch` + const updatePatch = `*** Begin Patch +*** Update File: src/test.ts +@@ +-original file content ++modified content +*** End Patch` + const updateNoDiffPatch = `*** Begin Patch +*** Update File: src/test.ts +@@ +-original file content ++original file content +*** End Patch` + const movePatch = `*** Begin Patch +*** Update File: src/test.ts +*** Move to: src/moved.ts +@@ +-original file content ++modified content +*** End Patch` + + it("does not record a checkpoint when the user rejects the patch", async () => { + // Rejected approval: the handler early-returns without recording a + // tool error, so the success flag must come from the handler itself. + mockAskApproval.mockResolvedValue(false) + + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith("Delete operation was rejected by the user.") + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the file to delete does not exist", async () => { + mockedFileExistsAtPath.mockResolvedValue(false) + + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("File not found")) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the delete write fails", async () => { + mockedFsPromises.default.unlink.mockRejectedValueOnce(new Error("EBUSY")) + + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Failed to delete file")) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the added file already exists", async () => { + // fileExistsAtPath resolves true by default in beforeEach. + + await tool.execute({ patch: addPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("File already exists")) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the user rejects the add", async () => { + mockedFileExistsAtPath.mockResolvedValue(false) + mockAskApproval.mockResolvedValue(false) + + await tool.execute({ patch: addPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith("Changes were rejected by the user.") + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("records a checkpoint when the add succeeds", async () => { + mockedFileExistsAtPath.mockResolvedValue(false) + + await tool.execute({ patch: addPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith("File saved successfully") + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + }) + + it("does not record a checkpoint when the file to update does not exist", async () => { + mockedFileExistsAtPath.mockResolvedValue(false) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("File not found")) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("records a checkpoint when the update is a no-op (no changes needed)", async () => { + // A no-op change is not a failure, so the whole-patch checkpoint still runs. + + await tool.execute({ patch: updateNoDiffPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("No changes needed")) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + }) + + it("does not record a checkpoint when the user rejects the update", async () => { + mockAskApproval.mockResolvedValue(false) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith("Changes were rejected by the user.") + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the move destination is not allowed", async () => { + // First validateAccess call (source path, in the execute loop) passes; + // the move destination check inside the handler fails. + const validateAccess = ( + mockTask["rooIgnoreController"] as unknown as { validateAccess: MockedFunction<() => boolean> } + ).validateAccess + validateAccess.mockReturnValueOnce(true).mockReturnValue(false) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.say).toHaveBeenCalledWith("rooignore_error", "src/moved.ts") + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the move destination is write-protected", async () => { + // Source path check (execute loop) passes; the move destination fails. + const isWriteProtected = ( + mockTask["rooProtectedController"] as unknown as { + isWriteProtected: MockedFunction<(p: string) => boolean> + } + ).isWriteProtected + isWriteProtected.mockReturnValueOnce(false).mockReturnValue(true) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Cannot move file to write-protected path"), + ) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the move destination is outside the workspace", async () => { + // Source path (first call) is inside; the move destination (second) + // call is outside the workspace. + mockedIsPathOutsideWorkspace.mockReturnValueOnce(false).mockReturnValue(true) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Cannot move file to path outside workspace"), + ) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("records a checkpoint when the move succeeds", async () => { + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // path is platform-dependent (Windows resolves cwd to a drive path); + // assert on the written content instead. + expect(mockedFsPromises.default.writeFile).toHaveBeenCalledWith( + expect.any(String), + "modified content\n", + "utf8", + ) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + }) + + it("records a checkpoint when the in-place update succeeds", async () => { + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith("File saved successfully") + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + }) + }) }) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 1ff8d52a8d..5b645a0074 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -7,6 +7,7 @@ import { fileExistsAtPath } from "../../../utils/fs" import { isPathOutsideWorkspace } from "../../../utils/pathUtils" import { getReadablePath } from "../../../utils/path" import { ToolUse, ToolResponse, AskApproval, HandleError, PushToolResult } from "../../../shared/tools" +import { checkpointSave } from "../../checkpoints" import { editFileTool } from "../EditFileTool" vi.mock("fs/promises", () => ({ @@ -59,6 +60,10 @@ vi.mock("../../diff/stats", () => ({ computeDiffStats: vi.fn(() => ({ additions: 1, deletions: 1 })), })) +vi.mock("../../checkpoints", () => ({ + checkpointSave: vi.fn().mockResolvedValue(undefined), +})) + vi.mock("vscode", () => ({ window: { showWarningMessage: vi.fn().mockResolvedValue(undefined), @@ -774,4 +779,41 @@ describe("editFileTool", () => { expect(mockAskApproval).toHaveBeenCalled() }) }) + + describe("per-write checkpoints (B1)", () => { + const mockedCheckpointSave = checkpointSave as MockedFunction + + it("records one suppressed checkpoint after a successful edit (default-on)", async () => { + await executeEditFileTool({}) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask, false, true) + }) + + it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { + mockTask.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: {}, + perWriteCheckpoints: false, + }), + }) + + await executeEditFileTool({}) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the edit fails", async () => { + mockTask.diffViewProvider.saveChanges.mockRejectedValue(new Error("save failed")) + + await executeEditFileTool({}) + + expect(mockHandleError).toHaveBeenCalledWith("edit_file", expect.any(Error)) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 52a7e3c052..f9286af36d 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -8,6 +8,7 @@ import { getReadablePath } from "../../../utils/path" import { unescapeHtmlEntities } from "../../../utils/text-normalization" import { everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text" import { ToolUse, ToolResponse, AskApproval, HandleError, PushToolResult } from "../../../shared/tools" +import { checkpointSave } from "../../checkpoints" import { writeToFileTool } from "../WriteToFileTool" vi.mock("path", async () => { @@ -89,6 +90,10 @@ vi.mock("../../ignore/RooIgnoreController", () => ({ }, })) +vi.mock("../../checkpoints", () => ({ + checkpointSave: vi.fn().mockResolvedValue(undefined), +})) + describe("writeToFileTool", () => { // Test data const testFilePath = "test/file.txt" @@ -472,4 +477,76 @@ describe("writeToFileTool", () => { expect(mockHandleError).toHaveBeenCalledWith("handling partial write_to_file", expect.any(Error)) }) }) + + describe("per-write checkpoints (B1)", () => { + const mockedCheckpointSave = checkpointSave as MockedFunction + + it("records one suppressed checkpoint after a successful write (default-on)", async () => { + await executeWriteFileTool({}) + + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockCline, false, true) + }) + + it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { + mockCline.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi + .fn() + .mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, perWriteCheckpoints: false }), + }) + + await executeWriteFileTool({}) + + expect(mockCline.consecutiveMistakeCount).toBe(0) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the write fails", async () => { + mockCline.diffViewProvider.open.mockRejectedValue(new Error("write failed")) + + await executeWriteFileTool({}) + + expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("waits for the per-write checkpoint before the tool completes", async () => { + let checkpointStarted = false + let releaseCheckpoint: () => void = () => {} + mockedCheckpointSave.mockImplementationOnce(() => { + checkpointStarted = true + return new Promise((resolve) => { + releaseCheckpoint = () => resolve(undefined) + }) + }) + const processQueuedSpy = vi.fn() + mockCline.processQueuedMessages = processQueuedSpy + + const toolPromise = executeWriteFileTool({}) + + // Advance microtasks until the tool reaches the checkpoint call (all + // preceding awaits are mocked resolutions, no real timers involved). + for (let i = 0; i < 50 && !checkpointStarted; i++) { + await Promise.resolve() + } + expect(checkpointStarted).toBe(true) + + let settled = false + void toolPromise.then(() => { + settled = true + }) + + // The tool must not complete while the checkpoint is still + // staging/committing: a later write started by the task loop would + // otherwise collapse into the same (or a missing) commit. + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(settled).toBe(false) + expect(processQueuedSpy).not.toHaveBeenCalled() + + releaseCheckpoint() + await toolPromise + expect(settled).toBe(true) + expect(processQueuedSpy).toHaveBeenCalledOnce() + }) + }) }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 4621cb3fc4..481904ed57 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -51,6 +51,7 @@ import { ORGANIZATION_ALLOW_ALL, DEFAULT_MODES, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + DEFAULT_PER_WRITE_CHECKPOINTS, getModelId, isRetiredProvider, providerIdentifiers, @@ -2556,6 +2557,7 @@ export class ClineProvider ttsSpeed, enableCheckpoints, checkpointTimeout, + perWriteCheckpoints, soundVolume, writeDelayMs, diffFuzzyThreshold, @@ -2715,6 +2717,7 @@ export class ClineProvider ttsSpeed: ttsSpeed ?? 1.0, enableCheckpoints: enableCheckpoints ?? true, checkpointTimeout: checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + perWriteCheckpoints: perWriteCheckpoints ?? DEFAULT_PER_WRITE_CHECKPOINTS, shouldShowAnnouncement: telemetrySetting !== "unset" && lastShownAnnouncementId !== this.latestAnnouncementId, allowedCommands: mergedAllowedCommands, @@ -2951,6 +2954,7 @@ export class ClineProvider ttsSpeed: stateValues.ttsSpeed ?? 1.0, enableCheckpoints: stateValues.enableCheckpoints ?? true, checkpointTimeout: stateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + perWriteCheckpoints: stateValues.perWriteCheckpoints ?? DEFAULT_PER_WRITE_CHECKPOINTS, soundVolume: stateValues.soundVolume, writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, diffFuzzyThreshold: stateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 731124cccc..7ec6bb782b 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -720,6 +720,7 @@ describe("ClineProvider", () => { soundEnabled: false, ttsEnabled: false, enableCheckpoints: false, + perWriteCheckpoints: false, writeDelayMs: 1000, mcpEnabled: true, mode: defaultModeSlug, @@ -1401,6 +1402,48 @@ describe("ClineProvider", () => { expect(state.destructiveCommandGuardEnabled).toBe(false) }) + test("getState returns the saved per-write checkpoints setting", async () => { + await provider.contextProxy.setValue("perWriteCheckpoints", false) + + const state = await provider.getState() + + expect(state.perWriteCheckpoints).toBe(false) + }) + + test("getState defaults per-write checkpoints to true when unset", async () => { + const state = await provider.getState() + + expect(state.perWriteCheckpoints).toBe(true) + }) + + test("getStateToPostToWebview returns the saved per-write checkpoints setting", async () => { + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setValue("perWriteCheckpoints", true) + + const state = await provider.getStateToPostToWebview() + + expect(state.perWriteCheckpoints).toBe(true) + }) + + test("getStateToPostToWebview returns false when per-write checkpoints is saved as false", async () => { + // The default is also true, so only an explicit false proves that the + // stored value (rather than the default) reaches the webview state. + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setValue("perWriteCheckpoints", false) + + const state = await provider.getStateToPostToWebview() + + expect(state.perWriteCheckpoints).toBe(false) + }) + + test("getStateToPostToWebview defaults per-write checkpoints to true when unset", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const state = await provider.getStateToPostToWebview() + + expect(state.perWriteCheckpoints).toBe(true) + }) + test("language is set to VSCode language", async () => { // Mock VSCode language as Spanish ;(vscode.env as any).language = "pt-BR" diff --git a/webview-ui/src/components/settings/CheckpointSettings.tsx b/webview-ui/src/components/settings/CheckpointSettings.tsx index dd28f6615f..7ea12ef873 100644 --- a/webview-ui/src/components/settings/CheckpointSettings.tsx +++ b/webview-ui/src/components/settings/CheckpointSettings.tsx @@ -13,17 +13,20 @@ import { DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, MAX_CHECKPOINT_TIMEOUT_SECONDS, MIN_CHECKPOINT_TIMEOUT_SECONDS, + DEFAULT_PER_WRITE_CHECKPOINTS, } from "@roo-code/types" type CheckpointSettingsProps = HTMLAttributes & { enableCheckpoints?: boolean checkpointTimeout?: number - setCachedStateField: SetCachedStateField<"enableCheckpoints" | "checkpointTimeout"> + perWriteCheckpoints?: boolean + setCachedStateField: SetCachedStateField<"enableCheckpoints" | "checkpointTimeout" | "perWriteCheckpoints"> } export const CheckpointSettings = ({ enableCheckpoints, checkpointTimeout, + perWriteCheckpoints, setCachedStateField, ...props }: CheckpointSettingsProps) => { @@ -33,6 +36,22 @@ export const CheckpointSettings = ({ {t("settings:sections.checkpoints")}
+ + { + setCachedStateField("perWriteCheckpoints", e.target.checked) + }}> + {t("settings:checkpoints.perWrite.label")} + +
+ {t("settings:checkpoints.perWrite.description")} +
+
+ (({ onDone, t autoCondenseContextPercent, enableCheckpoints, checkpointTimeout, + perWriteCheckpoints, experiments, maxOpenTabsContext, maxWorkspaceFiles, @@ -410,6 +412,7 @@ const SettingsView = forwardRef(({ onDone, t ttsSpeed, enableCheckpoints: enableCheckpoints ?? false, checkpointTimeout: checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + perWriteCheckpoints: perWriteCheckpoints ?? DEFAULT_PER_WRITE_CHECKPOINTS, writeDelayMs, diffFuzzyThreshold, terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? 30_000, @@ -847,6 +850,7 @@ const SettingsView = forwardRef(({ onDone, t )} diff --git a/webview-ui/src/components/settings/__tests__/CheckpointSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/CheckpointSettings.spec.tsx new file mode 100644 index 0000000000..b08c15ace7 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/CheckpointSettings.spec.tsx @@ -0,0 +1,125 @@ +// npx vitest src/components/settings/__tests__/CheckpointSettings.spec.tsx + +import { render, screen, fireEvent } from "@/utils/test-utils" +import { CheckpointSettings } from "../CheckpointSettings" + +// Mock the translation hook +vi.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => { + if (key === "settings:checkpoints.perWrite.label") { + return "Checkpoint after each file write" + } + if (key === "settings:checkpoints.perWrite.description") { + return "Record a checkpoint snapshot after every successful file write by the agent" + } + return key + }, + }), +})) + +// Mock the UI components (async factory: vi.importActual resolves asynchronously). +vi.mock("@/components/ui", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + Slider: ({ defaultValue, onValueChange, "data-testid": dataTestId }: any) => ( + onValueChange?.([100])} + data-testid={dataTestId} + role="slider" + /> + ), + } +}) + +// Mock vscode utilities +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock VSCode components to behave like standard HTML elements +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeCheckbox: ({ checked, onChange, children, ...props }: any) => ( + + ), + VSCodeLink: ({ children, ...props }: any) => {children}, +})) + +describe("CheckpointSettings", () => { + const setCachedStateField = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders the per-write checkpoints checkbox checked by default when the value is unset", () => { + render() + + const checkbox = screen.getByRole("checkbox", { name: "Checkpoint after each file write" }) + expect(checkbox).toBeChecked() + }) + + it("unchecks the per-write checkpoints checkbox when the saved value is false", () => { + render( + , + ) + + const checkbox = screen.getByRole("checkbox", { name: "Checkpoint after each file write" }) + expect(checkbox).not.toBeChecked() + }) + + it("keeps the per-write checkpoints checkbox checked when the saved value is true", () => { + render( + , + ) + + const checkbox = screen.getByRole("checkbox", { name: "Checkpoint after each file write" }) + expect(checkbox).toBeChecked() + }) + + it("caches a toggle to enable per-write checkpoints when the user checks the box", () => { + render( + , + ) + + const checkbox = screen.getByRole("checkbox", { name: "Checkpoint after each file write" }) + fireEvent.click(checkbox) + + expect(setCachedStateField).toHaveBeenCalledWith("perWriteCheckpoints", true) + }) + + it("caches a toggle to disable per-write checkpoints when the user unchecks the box", () => { + render() + + const checkbox = screen.getByRole("checkbox", { name: "Checkpoint after each file write" }) + fireEvent.click(checkbox) + + expect(setCachedStateField).toHaveBeenCalledWith("perWriteCheckpoints", false) + }) +}) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 377c8eb721..203594275d 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -211,6 +211,7 @@ const createInitialExtensionState = (): ExtensionState => ({ ttsEnabled: false, ttsSpeed: 1.0, enableCheckpoints: true, + perWriteCheckpoints: true, checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, // Default to 15 seconds language: "en", // Default language code writeDelayMs: 1000, diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 23ac911585..f0655b55e0 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -407,6 +407,7 @@ describe("mergeExtensionState", () => { taskHistory: [], shouldShowAnnouncement: false, enableCheckpoints: true, + perWriteCheckpoints: true, writeDelayMs: 1000, mode: "default", experiments: {} as Record, @@ -477,6 +478,7 @@ describe("mergeExtensionState", () => { taskHistory: [], shouldShowAnnouncement: false, enableCheckpoints: true, + perWriteCheckpoints: true, writeDelayMs: 1000, mode: "default", experiments: {} as Record, diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 52805e74e8..dca198cc7b 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Habilitar punts de control automàtics", "description": "Quan està habilitat, Zoo crearà automàticament punts de control durant l'execució de tasques, facilitant la revisió de canvis o la reversió a estats anteriors. <0>Més informació" + }, + "perWrite": { + "label": "Punt de control després de cada escriptura de fitxer", + "description": "Registra una instantània de punt de control després de cada escriptura de fitxer reeixida de l’agent" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index b895717422..530c799733 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Automatische Kontrollpunkte aktivieren", "description": "Wenn aktiviert, erstellt Zoo automatisch Kontrollpunkte während der Aufgabenausführung, was die Überprüfung von Änderungen oder die Rückkehr zu früheren Zuständen erleichtert. <0>Mehr erfahren" + }, + "perWrite": { + "label": "Kontrollpunkt nach jedem Dateischreibvorgang", + "description": "Ein Kontrollpunkt-Snapshot wird nach jedem erfolgreichen Dateischreibvorgang des Agents erfasst" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index eaa37b7034..5f8b40ade2 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -781,6 +781,10 @@ "enable": { "label": "Enable automatic checkpoints", "description": "When enabled, Zoo will automatically create checkpoints during task execution, making it easy to review changes or revert to earlier states. <0>Learn more" + }, + "perWrite": { + "label": "Checkpoint after each file write", + "description": "Record a checkpoint snapshot after every successful file write by the agent" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index abb8a60609..704f8d1d31 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Habilitar puntos de control automáticos", "description": "Cuando está habilitado, Zoo creará automáticamente puntos de control durante la ejecución de tareas, facilitando la revisión de cambios o la reversión a estados anteriores. <0>Más información" + }, + "perWrite": { + "label": "Punto de control después de cada escritura de archivo", + "description": "Registra una instantánea de punto de control después de cada escritura de archivo exitosa del agente" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 272f21a6ee..5db4e0dfe7 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Activer les points de contrôle automatiques", "description": "Lorsque cette option est activée, Zoo créera automatiquement des points de contrôle pendant l'exécution des tâches, facilitant la révision des modifications ou le retour à des états antérieurs. <0>En savoir plus" + }, + "perWrite": { + "label": "Point de contrôle après chaque écriture de fichier", + "description": "Enregistre un instantané de point de contrôle après chaque écriture de fichier réussie par l’agent" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 0a4152b17a..4570f85117 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "स्वचालित चेकपॉइंट सक्षम करें", "description": "जब सक्षम होता है, तो Zoo कार्य निष्पादन के दौरान स्वचालित रूप से चेकपॉइंट बनाएगा, जिससे परिवर्तनों की समीक्षा करना या पहले की स्थितियों पर वापस जाना आसान हो जाएगा। <0>अधिक जानें" + }, + "perWrite": { + "label": "हर फ़ाइल लिखने के बाद चेकपॉइंट", + "description": "एजेंट द्वारा हर सफल फ़ाइल लिखने के बाद एक चेकपॉइंट स्नैपशॉट दर्ज किया जाता है" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index b8abe9ab25..2918f14870 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Aktifkan checkpoint otomatis", "description": "Ketika diaktifkan, Zoo akan secara otomatis membuat checkpoint selama eksekusi tugas, memudahkan untuk meninjau perubahan atau kembali ke state sebelumnya. <0>Pelajari lebih lanjut" + }, + "perWrite": { + "label": "Checkpoint setelah setiap penulisan file", + "description": "Merekam snapshot checkpoint setelah setiap penulisan file yang berhasil oleh agen" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index e49ede9cec..6909e6a1a2 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Abilita punti di controllo automatici", "description": "Quando abilitato, Zoo creerà automaticamente punti di controllo durante l'esecuzione dei compiti, facilitando la revisione delle modifiche o il ritorno a stati precedenti. <0>Scopri di più" + }, + "perWrite": { + "label": "Punto di controllo dopo ogni scrittura del file", + "description": "Registra uno snapshot di punto di controllo dopo ogni scrittura del file riuscita dell’agente" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index d58c86c95d..6deaeca4d6 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "自動チェックポイントを有効化", "description": "有効にすると、Zooはタスク実行中に自動的にチェックポイントを作成し、変更の確認や以前の状態への復帰を容易にします。 <0>詳細情報" + }, + "perWrite": { + "label": "ファイルの書き込みごとにチェックポイント", + "description": "エージェントによる各ファイルの書き込み成功後にチェックポイントのスナップショットを記録します" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 68ce8b2523..a6c6583347 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "자동 체크포인트 활성화", "description": "활성화되면 Zoo는 작업 실행 중에 자동으로 체크포인트를 생성하여 변경 사항을 검토하거나 이전 상태로 되돌리기 쉽게 합니다. <0>더 알아보기" + }, + "perWrite": { + "label": "파일을 쓸 때마다 체크포인트", + "description": "에이전트가 파일 쓰기에 성공할 때마다 체크포인트 스냅샷을 기록합니다" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 8d90d7747e..77f6f5797b 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Automatische checkpoints inschakelen", "description": "Indien ingeschakeld, maakt Zoo automatisch checkpoints tijdens het uitvoeren van taken, zodat je eenvoudig wijzigingen kunt bekijken of terugzetten. <0>Meer informatie" + }, + "perWrite": { + "label": "Checkpoint na elke bestandsschrijving", + "description": "Neemt een checkpoint-snapshot op na elke succesvolle bestandsschrijving door de agent" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index ffc1cdf1a4..49b341cb9f 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Włącz automatyczne punkty kontrolne", "description": "Gdy włączone, Zoo automatycznie utworzy punkty kontrolne podczas wykonywania zadań, ułatwiając przeglądanie zmian lub powrót do wcześniejszych stanów. <0>Dowiedz się więcej" + }, + "perWrite": { + "label": "Punkt kontrolny po każdym zapisaniu pliku", + "description": "Rejestruje migawkę punktu kontrolnego po każdym udanym zapisaniu pliku przez agenta" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index cf92b76ac7..71694fe203 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Ativar pontos de verificação automáticos", "description": "Quando ativado, o Zoo criará automaticamente pontos de verificação durante a execução de tarefas, facilitando a revisão de alterações ou o retorno a estados anteriores. <0>Saiba mais" + }, + "perWrite": { + "label": "Ponto de verificação após cada gravação de arquivo", + "description": "Registra um snapshot de ponto de verificação após cada gravação de arquivo bem-sucedida pelo agente" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 23ff32faa9..e9a3f5938b 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Включить автоматические контрольные точки", "description": "Если включено, Zoo будет автоматически создавать контрольные точки во время выполнения задач, что упрощает просмотр изменений или возврат к предыдущим состояниям. <0>Подробнее" + }, + "perWrite": { + "label": "Контрольная точка после каждой записи файла", + "description": "Записывает снимок контрольной точки после каждой успешной записи файла агентом" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index f674e116d2..aff7402428 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Otomatik kontrol noktalarını etkinleştir", "description": "Etkinleştirildiğinde, Zoo görev yürütme sırasında otomatik olarak kontrol noktaları oluşturarak değişiklikleri gözden geçirmeyi veya önceki durumlara dönmeyi kolaylaştırır. <0>Daha fazla bilgi" + }, + "perWrite": { + "label": "Her dosya yazımından sonra kontrol noktası", + "description": "Ajanın her başarılı dosya yazımından sonra bir kontrol noktası görüntüsü kaydeder" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 4b908ca658..e944a56780 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Bật điểm kiểm tra tự động", "description": "Khi được bật, Zoo sẽ tự động tạo các điểm kiểm tra trong quá trình thực hiện nhiệm vụ, giúp dễ dàng xem lại các thay đổi hoặc quay lại trạng thái trước đó. <0>Tìm hiểu thêm" + }, + "perWrite": { + "label": "Điểm kiểm tra sau mỗi lần ghi file", + "description": "Ghi lại ảnh chụp nhanh điểm kiểm tra sau mỗi lần ghi file thành công của agent" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index d79edca302..05d04463e1 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "启用自动存档点", "description": "开启后自动创建任务存档点,方便回溯修改。 <0>了解更多" + }, + "perWrite": { + "label": "每次文件写入后创建存档点", + "description": "智能体每次成功写入文件后都会记录一个存档点快照" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 250cc2111b..a2ef47b841 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -728,6 +728,10 @@ "enable": { "label": "啟用自動檢查點", "description": "啟用後,Zoo 將在工作執行期間自動建立檢查點,方便檢視變更或回到較早的狀態。 <0>了解更多" + }, + "perWrite": { + "label": "每次檔案寫入後建立檢查點", + "description": "代理每次成功寫入檔案後都會記錄一個檢查點快照" } }, "notifications": { From b98f15903e1e026f2a6d004dc44ec7a7b25a63e2 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 28 Aug 2026 10:10:47 +0800 Subject: [PATCH 02/29] fix(fws): export the webview initializer and test the pre-hydration checkpoint default createInitialExtensionState is exported and a focused test asserts it initializes perWriteCheckpoints to true: a regression that dropped the key from the initializer (rather than from a merge fixture) would otherwise stay hidden (CodeRabbit finding on trial #1413). --- .../src/context/ExtensionStateContext.tsx | 2 +- .../__tests__/ExtensionStateContext.spec.tsx | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 203594275d..f1cddc3cf7 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -196,7 +196,7 @@ export const mergeExtensionState = (prevState: ExtensionState, newState: Partial } } -const createInitialExtensionState = (): ExtensionState => ({ +export const createInitialExtensionState = (): ExtensionState => ({ apiConfiguration: {}, version: "", clineMessages: [], diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index f0655b55e0..9bc77489bc 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -13,7 +13,12 @@ import { DEFAULT_DIFF_FUZZY_THRESHOLD, } from "@roo-code/types" -import { ExtensionStateContextProvider, useExtensionState, mergeExtensionState } from "../ExtensionStateContext" +import { + ExtensionStateContextProvider, + useExtensionState, + mergeExtensionState, + createInitialExtensionState, +} from "../ExtensionStateContext" const TestComponent = () => { const { allowedCommands, setAllowedCommands, soundEnabled, showRooIgnoredFiles, setShowRooIgnoredFiles } = @@ -396,6 +401,16 @@ describe("ExtensionStateContext", () => { }), ) }) + + it("initializes the per-write checkpoint default before hydration", () => { + // The initializer itself (not a merge fixture) must carry the per-write + // checkpoint default: a regression that dropped it from + // createInitialExtensionState would otherwise stay hidden because the + // merge tests supply the key manually. + const state = createInitialExtensionState() + + expect(state.perWriteCheckpoints).toBe(true) + }) }) describe("mergeExtensionState", () => { From abc10b43e9483e8b8b878e8737d2e7726e7c8537 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 30 Aug 2026 12:33:23 +0800 Subject: [PATCH 03/29] =?UTF-8?q?chore(ci):=20empty=20commit=20=E2=80=94?= =?UTF-8?q?=20re-trigger=20CI=20and=20the=20CodeRabbit=20current-head=20re?= =?UTF-8?q?view=20gate=20(no=20code=20change)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 23e945144ccb1724ac7d1001138ca0825853ba6e Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 29 Sep 2026 08:35:26 +0800 Subject: [PATCH 04/29] test(visual): regenerate electron chat-dark sidebar reference for #1404 The extension-host visual smoke scenario now renders the fws per-step change-card/rollback buttons ("See New Changes" / "Restore Changes") below the Task Completed card. The committed reference predates the feature (main-era render) and fails with a deterministic 432px diff (stable across repeats, same render on B1 and B2 heads). Reference regenerated from this head's CI render (visual job artifact); the diff is exactly the two-button region and nothing else. Main's reference will need the same regeneration once the fws units land. --- .../electron-chat-dark-sidebar.png | Bin 30321 -> 33176 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png b/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png index e8497070ef3440fe4a3bbb3ef49f9a1de9d4c4d9..a27af05b17ee1787a3a13f48cf2a39fc7fa9011e 100644 GIT binary patch literal 33176 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 ACjbBd literal 30321 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 From c07bea30216b25bf1cf6d7d630bdb303f761bba7 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 00:58:02 +0800 Subject: [PATCH 05/29] fix(checkpoints): skip task-start baseline on history resume The baseline guard was instance-local, so a Task constructed from a history item (resumeTaskFromHistory) recorded a second task-start baseline; the design in issue 1397 requires exactly one baseline per task, and the resumed task's shadow repo already carries the original baseline for the first post-resume write checkpoint to diff against. Gate the baseline on the original start path and add a regression test. Review feedback on PR 1404; the identical fix is ported to every unit carrying the B1 task-loop code. --- src/core/task/Task.ts | 5 ++++- src/core/task/__tests__/Task.spec.ts | 29 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 5ff778a943..2776375e49 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3709,7 +3709,10 @@ export class Task extends EventEmitter implements TaskLike { void getCheckpointService(this) // B1 task-start baseline: a suppressed pre-task root commit (default-on). - if (!this.taskStartBaselineDone) { + // History resumes skip it: the baseline belongs to the original task start + // (exactly once per task), and the resumed task's shadow repo already carries + // a baseline for the first post-resume write checkpoint to diff against. + if (!this.taskStartBaselineDone && !this._isHistoryTask) { this.taskStartBaselineDone = true const baselineEnabled = (await this.providerRef.deref()?.getState())?.perWriteCheckpoints if (baselineEnabled !== false) { diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 1920347e7f..12349bfbb1 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -6049,6 +6049,35 @@ describe("Cline", () => { expect(saveSpy).toHaveBeenCalledWith(true, true) }) + it("does not record a baseline when the task resumes from history", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "resumed-baseline-task", + number: 1, + ts: Date.now(), + task: "resumed baseline task", + tokensIn: 10, + tokensOut: 5, + totalCost: 0.001, + }, + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue(undefined) + const state = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue(state) + + task.abort = true + + await taskAccess.initiateTaskLoop([]) + + // A history resume must not re-record the baseline: it belongs to the + // original task start (exactly once per task). + expect(saveSpy).not.toHaveBeenCalled() + }) + it("records the baseline checkpoint when the setting is unset (default-on)", async () => { const task = new Task({ provider: mockProvider, From 93e3640e4cfef6cb6a4e65c9c544b18d6283dd5f Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 02:05:09 +0800 Subject: [PATCH 06/29] fix(checkpoints): skip baseline on aborted task; share default constant - initiateTaskLoop skips the task-start baseline when the task is already aborted: the loop will never run, so the awaited save would only add latency to a task being disposed. Regression test added. - WriteToFileTool/EditFileTool read perWriteCheckpoints through the shared DEFAULT_PER_WRITE_CHECKPOINTS constant instead of a hardcoded true, matching the provider reader (AGENTS.md: consumers use the same default). Review feedback on PR 1410; applied at the owning unit and ported across the chain. --- src/core/task/Task.ts | 4 +++- src/core/task/__tests__/Task.spec.ts | 29 +++++++++++++++++++++++++--- src/core/tools/EditFileTool.ts | 4 ++-- src/core/tools/WriteToFileTool.ts | 4 ++-- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 2776375e49..7217bad5b7 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3712,7 +3712,9 @@ export class Task extends EventEmitter implements TaskLike { // History resumes skip it: the baseline belongs to the original task start // (exactly once per task), and the resumed task's shadow repo already carries // a baseline for the first post-resume write checkpoint to diff against. - if (!this.taskStartBaselineDone && !this._isHistoryTask) { + // An already-aborted task skips it too: the loop will never run, so the + // save would only add latency to a task that is being disposed. + if (!this.taskStartBaselineDone && !this._isHistoryTask && !this.abort) { this.taskStartBaselineDone = true const baselineEnabled = (await this.providerRef.deref()?.getState())?.perWriteCheckpoints if (baselineEnabled !== false) { diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 12349bfbb1..a824a5f8f3 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -6040,7 +6040,9 @@ describe("Cline", () => { const state = await mockProvider.getState() vi.spyOn(mockProvider, "getState").mockResolvedValue(state) - task.abort = true + // End the loop after the first request; abort stays false so the + // baseline block (not the loop) is what the save spy observes. + vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true) await taskAccess.initiateTaskLoop([]) await taskAccess.initiateTaskLoop([]) @@ -6069,7 +6071,7 @@ describe("Cline", () => { const state = await mockProvider.getState() vi.spyOn(mockProvider, "getState").mockResolvedValue(state) - task.abort = true + vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true) await taskAccess.initiateTaskLoop([]) @@ -6093,7 +6095,7 @@ describe("Cline", () => { Reflect.deleteProperty(unsetState, "perWriteCheckpoints") vi.spyOn(mockProvider, "getState").mockResolvedValue(unsetState as typeof state) - task.abort = true + vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true) await taskAccess.initiateTaskLoop([]) @@ -6113,10 +6115,31 @@ describe("Cline", () => { const state = await mockProvider.getState() vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...state, perWriteCheckpoints: false }) + vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true) + + await taskAccess.initiateTaskLoop([]) + + expect(saveSpy).not.toHaveBeenCalled() + }) + + it("does not record a baseline when the task is already aborted", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "aborted baseline task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue(undefined) + const state = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue(state) + task.abort = true await taskAccess.initiateTaskLoop([]) + // A task cancelled before the loop starts must not pay for (or await) + // a baseline checkpoint it will never use. expect(saveSpy).not.toHaveBeenCalled() }) diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index 0a3cb5d2e8..db52324330 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" -import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" +import { type ClineSayTool, DEFAULT_PER_WRITE_CHECKPOINTS, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" import { getReadablePath } from "../../utils/path" import { isPathOutsideWorkspace } from "../../utils/pathUtils" @@ -393,7 +393,7 @@ export class EditFileTool extends BaseTool<"edit_file"> { const state = await provider?.getState() const diagnosticsEnabled = state?.diagnosticsEnabled ?? true const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS - const perWriteCheckpoints = state?.perWriteCheckpoints ?? true + const perWriteCheckpoints = state?.perWriteCheckpoints ?? DEFAULT_PER_WRITE_CHECKPOINTS const isPreventFocusDisruptionEnabled = experiments.isEnabled( state?.experiments ?? {}, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 6e0d7a0e80..e3e7bf1728 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -1,7 +1,7 @@ import path from "path" import fs from "fs/promises" -import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" +import { type ClineSayTool, DEFAULT_PER_WRITE_CHECKPOINTS, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" @@ -103,7 +103,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const state = await provider?.getState() const diagnosticsEnabled = state?.diagnosticsEnabled ?? true const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS - const perWriteCheckpoints = state?.perWriteCheckpoints ?? true + const perWriteCheckpoints = state?.perWriteCheckpoints ?? DEFAULT_PER_WRITE_CHECKPOINTS const isPreventFocusDisruptionEnabled = experiments.isEnabled( state?.experiments ?? {}, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, From faf3f9d3303f2eef00c7b036288f9ad908fab743 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 03:15:49 +0800 Subject: [PATCH 07/29] fix(checkpoints): recheck cancellation after the baseline state lookup Cancellation can land while the perWriteCheckpoints state lookup is pending; the pre-lookup guard alone would still await checkpointSave for a canceled task. Recheck this.abort after the lookup and before the save, and cover the deferred-lookup cancellation window with a regression test. Review feedback on PR 1410; ported to every unit carrying the task-loop code. --- src/core/task/Task.ts | 4 +++- src/core/task/__tests__/Task.spec.ts | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 7217bad5b7..de92ccb73c 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3717,7 +3717,9 @@ export class Task extends EventEmitter implements TaskLike { if (!this.taskStartBaselineDone && !this._isHistoryTask && !this.abort) { this.taskStartBaselineDone = true const baselineEnabled = (await this.providerRef.deref()?.getState())?.perWriteCheckpoints - if (baselineEnabled !== false) { + // Cancellation can arrive while the state lookup is pending; recheck + // before the save so a canceled task performs no checkpoint work. + if (baselineEnabled !== false && !this.abort) { // allowEmpty=true so a clean workspace still produces the baseline // commit; awaited so the first per-write checkpoint cannot interleave. await this.checkpointSave(true, true) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index a824a5f8f3..c02c33afb7 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -6143,6 +6143,31 @@ describe("Cline", () => { expect(saveSpy).not.toHaveBeenCalled() }) + it("does not record a baseline when cancellation arrives during the state lookup", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "racy baseline task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue(undefined) + const state = await mockProvider.getState() + type StateResult = Awaited> + let resolveState: (value: StateResult | PromiseLike) => void = () => {} + const statePromise = new Promise((resolve) => (resolveState = resolve)) + vi.spyOn(mockProvider, "getState").mockReturnValue(statePromise) + + const loopPromise = taskAccess.initiateTaskLoop([]) + // Cancellation lands while the state lookup is still pending. + task.abort = true + resolveState({ ...state, perWriteCheckpoints: true }) + await loopPromise + + // The post-lookup recheck must skip the save for the canceled task. + expect(saveSpy).not.toHaveBeenCalled() + }) + it("awaits the baseline checkpoint before entering the request loop", async () => { const task = new Task({ provider: mockProvider, From fb576709d3528cc9d95be77630477da10eca0903 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 05:45:35 +0800 Subject: [PATCH 08/29] fix(checkpoints): await write-path checkpoint saves before state handoff EditFileTool and ApplyPatchTool fired checkpointSave without awaiting it: the diff state reset and queued work could proceed while the checkpoint was still staging/committing, a queued write's own checkpoint could interleave, and shutdown could lose the commit entirely. Await the save (WriteToFileTool already did), matching the semantics the later units of this series settled on. Held-promise regression tests pin the ordering on both paths. Review feedback on PR 1404; the EditFileTool half is ported to the sibling unit that still carries the unawaited call. --- src/core/tools/ApplyPatchTool.ts | 5 +++- src/core/tools/EditFileTool.ts | 6 ++++- .../__tests__/applyPatchTool.execute.spec.ts | 25 +++++++++++++++++++ src/core/tools/__tests__/editFileTool.spec.ts | 20 +++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index f42a4ebf03..51119cc7d9 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -154,7 +154,10 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (patchSucceeded) { const perWriteCheckpoints = (await task.providerRef?.deref()?.getState())?.perWriteCheckpoints if (perWriteCheckpoints !== false) { - void checkpointSave(task, false, true).catch(() => {}) + // Await so the whole-patch checkpoint persists before the tool + // completes; an unawaited save lets queued work interleave its own + // checkpoint and lets shutdown lose the commit. + await checkpointSave(task, false, true).catch(() => {}) } } } catch (error) { diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index db52324330..9f17cd5c2a 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -466,7 +466,11 @@ export class EditFileTool extends BaseTool<"edit_file"> { pushToolResult(message + replacementInfo) if (perWriteCheckpoints) { - void checkpointSave(task, false, true).catch(() => {}) + // Await so the checkpoint (staging + commit) completes before the + // diff state resets and queued work proceeds; fire-and-forget lets a + // queued write's own checkpoint interleave and lets shutdown lose the + // commit entirely. + await checkpointSave(task, false, true).catch(() => {}) } await task.diffViewProvider.reset() diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index f5f64c939e..93807a3b3a 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -155,6 +155,31 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true) }) + it("awaits the checkpoint before completing the patch", async () => { + // An unawaited save let the tool complete (and queued work start) while + // the checkpoint was still committing - a queued write could interleave + // its own checkpoint and shutdown could lose the commit. + let releaseCheckpoint: () => void = () => {} + mockedCheckpointSave.mockImplementationOnce( + () => new Promise((resolve) => (releaseCheckpoint = resolve)), + ) + let settled = false + const run = tool + .execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + .then(() => { + settled = true + }) + await vi.waitFor(() => expect(mockedCheckpointSave).toHaveBeenCalledOnce()) + expect(settled).toBe(false) + releaseCheckpoint() + await run + expect(settled).toBe(true) + }) + it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { // Structural cast for the test double (matches the mock style used for the controllers above). const ref = (mockTask["providerRef"] as unknown as { deref: MockedFunction<() => unknown> }).deref diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 5b645a0074..0772b20c11 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -791,6 +791,26 @@ describe("editFileTool", () => { expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask, false, true) }) + it("awaits the checkpoint before resetting the diff state", async () => { + // Fire-and-forget would let diffViewProvider.reset() and the queued-work + // handoff race an unfinished checkpoint commit (and lose it at + // shutdown): the save must complete before the reset. + let releaseCheckpoint: () => void = () => {} + mockedCheckpointSave.mockImplementationOnce( + () => new Promise((resolve) => (releaseCheckpoint = resolve)), + ) + let settled = false + const run = executeEditFileTool({}).then(() => { + settled = true + }) + await vi.waitFor(() => expect(mockedCheckpointSave).toHaveBeenCalledOnce()) + expect(settled).toBe(false) + expect(mockTask.diffViewProvider.reset).not.toHaveBeenCalled() + releaseCheckpoint() + await run + expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() + }) + it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { mockTask.providerRef.deref = vi.fn().mockReturnValue({ getState: vi.fn().mockResolvedValue({ From cae09fbd5071009ec68ed7e4cf7aa6df8cedf780 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 05:48:09 +0800 Subject: [PATCH 09/29] test(checkpoints): pin one-checkpoint-per-patch for multi-file patches The walkthrough's Regression Evidence row asked for a multi-file apply_patch test asserting exactly one checkpointSave for the complete patch - B1's core contract (a regression to per-change saves would double the checkpoint count). Covered on the journal-carrying unit already; this adds the equivalent on the base unit. --- .../__tests__/applyPatchTool.execute.spec.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 93807a3b3a..0e68acdb31 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -444,8 +444,33 @@ describe("ApplyPatchTool.execute - delete file success path", () => { handleError: mockHandleError, pushToolResult: mockPushToolResult, }) + }) - expect(mockPushToolResult).toHaveBeenCalledWith("File saved successfully") + it("records exactly one checkpoint for a multi-file patch", async () => { + // B1's core contract: one checkpoint per patch, not per file - a + // regression to per-change saves would double the checkpoint count. + const multiPatch = [ + "*** Begin Patch", + "*** Add File: src/new.ts", + "+hello", + "+world", + "*** Update File: src/test.ts", + "@@", + "-original file content", + "+modified content", + "*** End Patch", + ].join(String.fromCharCode(10)) + mockedFileExistsAtPath.mockImplementation((filePath: string) => + Promise.resolve(!String(filePath).toLowerCase().endsWith("new.ts")), + ) + + await tool.execute({ patch: multiPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockHandleError).not.toHaveBeenCalled() expect(mockedCheckpointSave).toHaveBeenCalledOnce() }) }) From c6a23c511d124cee6f952faae0eb7a44a8d917e0 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 08:11:46 +0800 Subject: [PATCH 10/29] fix(tools): defer the queued-message handoff until the patch checkpoint completes The per-file handlers processed queued messages right after each successful write, before execute() awaited the whole-patch checkpoint. processQueuedMessages schedules submitUserMessage on a timer, so a queued turn - and the writes it triggers - could start while the patch checkpoint was still staging and committing, interleaving its own checkpoint with this commit. The handoff is now deferred to after the checkpoint attempt: one handoff per successful patch, independent of the checkpoint setting or outcome, so no path strands queued messages and no queued turn can race the commit. This matches the semantics the journal-carrying unit of this series already settled on and brings the remaining units in line. Review feedback on PR 1404; ports carry the equivalent deferral and ordering tests. --- src/core/tools/ApplyPatchTool.ts | 56 +++++++++++++------ .../__tests__/applyPatchTool.execute.spec.ts | 51 +++++++++++++++++ 2 files changed, 89 insertions(+), 18 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 51119cc7d9..01b5cb3202 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -57,6 +57,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const { patch } = params const { askApproval, handleError, pushToolResult } = callbacks + // The queued-message handoff is deferred to the finally block below, after + // the whole-patch checkpoint attempt: a handler that hands off early lets a + // queued turn start while the checkpoint is still staging or committing. + + let handoffPending = false + try { // Validate required parameters if (!patch) { @@ -124,25 +130,33 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (change.type === "add") { // Create new file - patchSucceeded = - (await this.handleAddFile(change, absolutePath, relPath, task, callbacks, isWriteProtected)) && - patchSucceeded + const ok = await this.handleAddFile( + change, + absolutePath, + relPath, + task, + callbacks, + isWriteProtected, + ) + handoffPending = handoffPending || ok + patchSucceeded = ok && patchSucceeded } else if (change.type === "delete") { // Delete file - patchSucceeded = - (await this.handleDeleteFile(absolutePath, relPath, task, callbacks, isWriteProtected)) && - patchSucceeded + const ok = await this.handleDeleteFile(absolutePath, relPath, task, callbacks, isWriteProtected) + handoffPending = handoffPending || ok + patchSucceeded = ok && patchSucceeded } else if (change.type === "update") { // Update file - patchSucceeded = - (await this.handleUpdateFile( - change, - absolutePath, - relPath, - task, - callbacks, - isWriteProtected, - )) && patchSucceeded + const ok = await this.handleUpdateFile( + change, + absolutePath, + relPath, + task, + callbacks, + isWriteProtected, + ) + handoffPending = handoffPending || ok + patchSucceeded = ok && patchSucceeded } } @@ -163,6 +177,15 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } catch (error) { await handleError("apply patch", error as Error) await task.diffViewProvider.reset() + } finally { + // One handoff per successful patch, after the checkpoint attempt and + // independent of the checkpoint setting or outcome: a queued turn can + // never start while the patch checkpoint is still staging or + // committing, and no path (disabled checkpoints, failed save, early + // return, error) strands the queued messages. + if (handoffPending) { + task.processQueuedMessages() + } } } @@ -255,7 +278,6 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, true) pushToolResult(message) await task.diffViewProvider.reset() - task.processQueuedMessages() return true } @@ -313,7 +335,6 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task.didEditFile = true pushToolResult(`Successfully deleted ${relPath}`) - task.processQueuedMessages() return true } @@ -479,7 +500,6 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, false) pushToolResult(message) await task.diffViewProvider.reset() - task.processQueuedMessages() return true } diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 0e68acdb31..4a4bcbb332 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -179,6 +179,57 @@ describe("ApplyPatchTool.execute - delete file success path", () => { await run expect(settled).toBe(true) }) + it("defers the queued-message handoff until the checkpoint completes", async () => { + // processQueuedMessages schedules submitUserMessage on a timer; a + // handler that hands off before the whole-patch checkpoint completes + // lets the queued turn start while the commit is still running. + let releaseCheckpoint: () => void = () => {} + mockedCheckpointSave.mockImplementationOnce( + () => new Promise((resolve) => (releaseCheckpoint = resolve)), + ) + const run = tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + await vi.waitFor(() => expect(mockedCheckpointSave).toHaveBeenCalledOnce()) + expect(mockTask.processQueuedMessages).not.toHaveBeenCalled() + releaseCheckpoint() + await run + expect(mockTask.processQueuedMessages).toHaveBeenCalled() + }) + + it("hands off queued messages when checkpoints are disabled", async () => { + // The handoff must not depend on the checkpoint setting: disabling + // checkpoints must not strand queued messages. + const ref = (mockTask["providerRef"] as unknown as { deref: MockedFunction<() => unknown> }).deref + ref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ perWriteCheckpoints: false }), + }) + + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockedCheckpointSave).not.toHaveBeenCalled() + expect(mockTask.processQueuedMessages).toHaveBeenCalled() + }) + + it("does not hand off queued messages when the patch fails", async () => { + // A failed file operation must not hand off: the model is waiting + // on the error result, not on queued input. + mockedFsPromises.default.unlink.mockRejectedValueOnce(new Error("EBUSY")) + + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.processQueuedMessages).not.toHaveBeenCalled() + }) it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { // Structural cast for the test double (matches the mock style used for the controllers above). From 484d5cf0ec1837ed7795ecae3b5cf3f071e16344 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 08:35:57 +0800 Subject: [PATCH 11/29] fix(task): never dequeue queued messages for an aborted task Tools now await the write checkpoint before handing off queued work, so a cancellation can land between the write and the handoff. processQueuedMessages dequeued unconditionally and scheduled submitUserMessage, which rejects once abort is set - the queued message was consumed and dropped instead of being left for the resume/abort flow. The dequeue is now skipped while the task is aborted, guarding every handoff site at the lowest layer. Regression test pins that an aborted task leaves the queue intact (negative control verified: the unconditional dequeue reddens it). Review feedback on PR 1404 (Lifecycle Resource Cleanup row); ported to every unit of this series. --- src/core/task/Task.ts | 8 ++++ src/core/task/__tests__/Task.spec.ts | 28 +++++++++++ .../settings/__tests__/SettingsView.spec.tsx | 47 +++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index de92ccb73c..ca88557eda 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -6310,6 +6310,14 @@ export class Task extends EventEmitter implements TaskLike { * @param context - Context string for logging (e.g., the calling tool name) */ public processQueuedMessages(): void { + // An aborted task must not dequeue queued messages: submitUserMessage + // rejects once abort is set, so consuming the queue here would drop the + // message instead of leaving it for the resume/abort flow. Tools await + // checkpoint saves before handing off, so a cancellation can land between + // the write and this call. + if (this.abort) { + return + } try { if (!this.messageQueueService.isEmpty()) { const queued = this.messageQueueService.dequeueMessage() diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index c02c33afb7..ffdacc56be 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -7119,6 +7119,34 @@ describe("Queued message processing after condense", () => { expect(submitSpy).toHaveBeenCalledWith("queued text", ["img1.png"]) expect(task.messageQueueService.isEmpty()).toBe(true) }) + it("does not dequeue queued messages once the task is aborted", () => { + // Tools await the checkpoint before handing off queued work; a + // cancellation landing during that await must not consume the queued + // message: submitUserMessage rejects once abort is set, so dequeuing + // would drop the input instead of leaving it for the resume flow. + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + const provider = createProvider() + const task = new Task({ + provider, + apiConfiguration: apiConfig, + task: "initial task", + startTask: false, + }) + const submitSpy = vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + + task.messageQueueService.addMessage("queued text") + task.abort = true + try { + task.processQueuedMessages() + } finally { + task.abort = false + } + + expect(submitSpy).not.toHaveBeenCalled() + expect(task.messageQueueService.isEmpty()).toBe(false) + }) it("does not cross-drain queues between separate tasks", async () => { const providerA = createProvider() diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx index 97b390f6f4..6f5f3ac00d 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx @@ -1008,3 +1008,50 @@ describe("SettingsView - openAiStrictToolSchemas save round trip", () => { expect(posted()?.apiConfiguration?.openAiStrictToolSchemas).toBeUndefined() }) }) + +describe("SettingsView - per-write checkpoints save (B1)", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("saves the default-on payload when Save is clicked with the setting unset", () => { + // An unset setting shows (and saves) the shared default-on value: + // the cached state seeds DEFAULT_PER_WRITE_CHECKPOINTS, so the host + // receives an explicit boolean instead of an omitted key. + const { activateTab, getSettingsContent } = renderSettingsView() + activateTab("checkpoints") + + const content = getSettingsContent() + const perWrite = within(content).getByRole("checkbox", { name: "settings:checkpoints.perWrite.label" }) + expect(perWrite).toBeChecked() + // Toggle a sibling setting so Save is enabled without touching perWrite. + fireEvent.click(within(content).getByRole("checkbox", { name: "settings:checkpoints.enable.label" })) + fireEvent.click(screen.getByTestId("save-button")) + + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ perWriteCheckpoints: true }), + }), + ) + }) + + it("saves an explicit false payload after unchecking and clicking Save", () => { + const { activateTab, getSettingsContent } = renderSettingsView({ perWriteCheckpoints: true }) + activateTab("checkpoints") + + const content = getSettingsContent() + const perWrite = within(content).getByRole("checkbox", { name: "settings:checkpoints.perWrite.label" }) + expect(perWrite).toBeChecked() + fireEvent.click(perWrite) + expect(perWrite).not.toBeChecked() + fireEvent.click(screen.getByTestId("save-button")) + + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ perWriteCheckpoints: false }), + }), + ) + }) +}) From 0310c997eaa070bf519a93ba7acad0d27531ddec Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 09:11:46 +0800 Subject: [PATCH 12/29] test(tools): pin the partial-failure handoff semantics of the patch checkpoint fix Upstream handed off queued messages in each handler right after every successful write, so a patch whose first write succeeded and whose later write failed - or whose later file failed the access check and returned early - still drained the queue. The deferral preserves that condition and only moves its timing after the checkpoint attempt; requiring full-patch success would strand queued input on any partial failure, a behavior change beyond the checkpoint-ordering fix this unit ships. Two tests pin the fidelity semantics against future regressions in either direction. Review feedback on PR 1404 (thread on the finally-block handoff). --- .../__tests__/applyPatchTool.execute.spec.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 4a4bcbb332..0e5b034339 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -231,6 +231,57 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockTask.processQueuedMessages).not.toHaveBeenCalled() }) + it("hands off queued messages after a partial patch failure", async () => { + // Fidelity: upstream handed off in each handler right after every + // successful write, so a patch whose first write succeeded and whose + // later write failed still drained the queue. The deferral preserves + // that condition (a write happened) and only moves its timing after + // the checkpoint attempt; requiring full-patch success would strand + // queued input on any partial failure. + const mixedPatch = `*** Begin Patch +*** Add File: src/new.ts ++hello +*** Delete File: src/obsolete.ts +*** End Patch` + // Add sees no existing file and succeeds; the delete's unlink fails. + mockedFileExistsAtPath.mockResolvedValueOnce(false) + mockedFsPromises.default.unlink.mockRejectedValueOnce(new Error("EBUSY")) + + await tool.execute({ patch: mixedPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // No whole-patch checkpoint (the patch failed), but the successful + // write still hands off. + expect(mockedCheckpointSave).not.toHaveBeenCalled() + expect(mockTask.processQueuedMessages).toHaveBeenCalled() + }) + + it("hands off queued messages when a later file fails the access check", async () => { + // The rooignored path makes execute return early, but the earlier + // successful write had already handed off upstream - the deferred + // handoff preserves that too. + const mixedPatch = `*** Begin Patch +*** Delete File: src/obsolete.ts +*** Add File: src/secret.ts ++hidden +*** End Patch` + const validateAccess = mockTask.rooIgnoreController?.validateAccess as MockedFunction< + (path: string) => boolean + > + validateAccess.mockReturnValueOnce(true).mockReturnValueOnce(false) + + await tool.execute({ patch: mixedPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.processQueuedMessages).toHaveBeenCalled() + }) + it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { // Structural cast for the test double (matches the mock style used for the controllers above). const ref = (mockTask["providerRef"] as unknown as { deref: MockedFunction<() => unknown> }).deref From a83a105ff1ce28b668f82f45368f865b7c2f105e Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 14 Oct 2026 16:38:54 +0800 Subject: [PATCH 13/29] chore: remove carried-over Stryker suppression directives from touched files The mutation gate no longer accepts suppression comments in mutation-tested files, and suppressions left by earlier revisions are removed when their file is touched. This drops the seven directives carried in the streaming/telemetry region of Task.ts (equivalences: event.name is always a string on those paths, streamingKey can never be undefined there, and the getStreamingKey region is unreachable defensive code). Comment-only change; no behavior delta. --- src/core/task/Task.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index ca88557eda..a1bdebd4fa 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -4165,7 +4165,6 @@ export class Task extends EventEmitter implements TaskLike { // under the canonical name slip past a name comparison. The first call wins. const idAlreadyUsed = this.assistantMessageContent.some( (entry) => - // Stryker disable next-line ConditionalExpression: only tool_use and mcp_tool_use entries ever carry an id — the text block pushed in the text case has no id field and the start events reaching this guard always carry the string id locked in by processRawChunk — so the type check cannot change the outcome of entry.id === event.id for any entry this pipeline produces; the false replacement is additionally killed by the rejection tests. (entry.type === "tool_use" || entry.type === "mcp_tool_use") && (entry as { id?: string }).id === event.id, ) @@ -4218,7 +4217,6 @@ export class Task extends EventEmitter implements TaskLike { // keep the deprecated single-entry-per-ID lookup. let streamingKey: string | undefined - // Stryker disable next-line ConditionalExpression: event.name is always a string on this path — the only delta events reaching this handler are emitted by processRawChunk with the tracked start name (never undefined), so the legacy nameless branch below is unreachable defensive code and the conditional always takes the named side. if (event.name !== undefined) { streamingKey = NativeToolCallParser.makeStreamingKey(event.id, event.name) } else { @@ -4235,7 +4233,6 @@ export class Task extends EventEmitter implements TaskLike { } const partialToolUse = - // Stryker disable next-line ConditionalExpression: streamingKey can never be undefined here — makeStreamingKey always returns a string and the legacy lookup branch above is unreachable (see the directive on the event.name check) — so the null side is unreachable defensive code. streamingKey === undefined ? null : NativeToolCallParser.processStreamingChunk( @@ -4244,7 +4241,6 @@ export class Task extends EventEmitter implements TaskLike { nativeToolCallParserScope, ) - // Stryker disable next-line ConditionalExpression, EqualityOperator: streamingKey is always a defined string on this path (makeStreamingKey never returns undefined; the legacy branch is unreachable per the directives above), and processStreamingChunk returns a partial ToolUse for a known key, so both defensive conditions reduce to the partialToolUse check and cannot change the observed behavior. if (partialToolUse && streamingKey !== undefined) { // Reuse the compound key built above: it is exactly the key the start event // registered under, so the delta lookup needs no second encoding. @@ -4635,7 +4631,6 @@ export class Task extends EventEmitter implements TaskLike { if (event.type === "tool_call_end") { // Create compound key for deduplication (same pattern as streaming handler). // End events carry the tool name; fall back to the id for events that don't. - // Stryker disable next-line LogicalOperator: event.name is always a string on this path — finalizeRawChunks emits end events with the tracked start name (a string, set before the tracker can start), so the ?? event.id fallback guards a nameless end event this pipeline cannot produce. let eventName = event.name ?? event.id let dedupKey = NativeToolCallParser.makeStreamingKey(event.id, eventName) @@ -4652,13 +4647,11 @@ export class Task extends EventEmitter implements TaskLike { // name (provider quirk), the compound-key lookup above misses and both the // parser entry and the dedup tracking would stay stale. Resolve the tracked // entry by id and target the real compound key for finalization and cleanup. - // Stryker disable next-line ConditionalExpression, LogicalOperator, EqualityOperator: the both-miss case is unreachable via the raw-chunk pipeline — end events carry the locked start name so the compound key resolves the registered entry, and a same-ID rejected-start end is always processed after the accepted start's end has already removed that entry; this defensive path protects against a cross-name end event the pipeline cannot produce. if (finalToolUse === null && toolUseIndex === undefined) { const resolved = NativeToolCallParser.getStreamingToolCallById( event.id, nativeToolCallParserScope, ) - // Stryker disable next-line ConditionalExpression: unreachable defensive region — getStreamingToolCallById can only resolve when the both-miss case above is reachable, which the raw-chunk pipeline cannot produce (see the directive there). if (resolved) { eventName = resolved.name dedupKey = NativeToolCallParser.makeStreamingKey(resolved.id, resolved.name) From d21a437f494f4eb0ebe1609cf4b44b9750bf7885 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 15 Oct 2026 10:28:22 +0800 Subject: [PATCH 14/29] test: strengthen the early-return handoff assertions Assert the skipped whole-patch checkpoint and exactly-once handoff, not just that a handoff happened. --- src/core/tools/__tests__/applyPatchTool.execute.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 0e5b034339..2ac3480c18 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -279,7 +279,8 @@ describe("ApplyPatchTool.execute - delete file success path", () => { pushToolResult: mockPushToolResult, }) - expect(mockTask.processQueuedMessages).toHaveBeenCalled() + expect(mockedCheckpointSave).not.toHaveBeenCalled() + expect(mockTask.processQueuedMessages).toHaveBeenCalledTimes(1) }) it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { From 6666de5b25d9bd6944448071730690f31578629d Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 15 Oct 2026 11:21:08 +0800 Subject: [PATCH 15/29] style: apply Prettier formatting to callback fixture edits The toolCallId insertions pushed several tool.execute call sites past the print width; Prettier reflows them. --- .../__tests__/applyPatchTool.execute.spec.ts | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index a7f7a4a5a8..081284c4ca 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -149,7 +149,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully deleted")) @@ -171,7 +171,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) .then(() => { settled = true @@ -194,7 +194,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) await vi.waitFor(() => expect(mockedCheckpointSave).toHaveBeenCalledOnce()) expect(mockTask.processQueuedMessages).not.toHaveBeenCalled() @@ -215,7 +215,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) expect(mockedCheckpointSave).not.toHaveBeenCalled() @@ -231,7 +231,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) expect(mockTask.processQueuedMessages).not.toHaveBeenCalled() @@ -257,7 +257,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) // No whole-patch checkpoint (the patch failed), but the successful @@ -284,7 +284,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) expect(mockedCheckpointSave).not.toHaveBeenCalled() @@ -302,7 +302,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully deleted")) @@ -320,7 +320,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) expect(mockTask.recordToolError).toHaveBeenCalledWith("apply_patch") @@ -367,7 +367,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith("Delete operation was rejected by the user.") @@ -381,7 +381,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("File not found")) @@ -395,7 +395,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Failed to delete file")) @@ -409,7 +409,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("File already exists")) @@ -424,7 +424,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith("Changes were rejected by the user.") @@ -438,7 +438,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith("File saved successfully") @@ -452,7 +452,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("File not found")) @@ -466,7 +466,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("No changes needed")) @@ -480,7 +480,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith("Changes were rejected by the user.") @@ -499,7 +499,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) expect(mockTask.say).toHaveBeenCalledWith("rooignore_error", "src/moved.ts") @@ -519,7 +519,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith( @@ -537,7 +537,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith( @@ -551,7 +551,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) // path is platform-dependent (Windows resolves cwd to a drive path); @@ -569,7 +569,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) }) @@ -595,7 +595,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) expect(mockHandleError).not.toHaveBeenCalled() From 339152830029b351692f719c4ea06938c0f9c2b4 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 15 Oct 2026 15:28:17 +0800 Subject: [PATCH 16/29] test: assert the in-place update checkpoint contract The test named for the in-place update asserted nothing; it now asserts no error handling ran and exactly one whole-patch checkpointSave with the standard arguments. --- src/core/tools/__tests__/applyPatchTool.execute.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 081284c4ca..41a24b0135 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -571,6 +571,10 @@ describe("ApplyPatchTool.execute - delete file success path", () => { pushToolResult: mockPushToolResult, toolCallId: "test-tool-call-id", }) + + expect(mockHandleError).not.toHaveBeenCalled() + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true) }) it("records exactly one checkpoint for a multi-file patch", async () => { From 6f00e59700b3a8abec333cdab2f6fc4eda50afda Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 16 Oct 2026 14:52:43 +0800 Subject: [PATCH 17/29] fix(apply-patch): fail moves when source deletion fails; checkpoint partial patches - Move handler: when the source unlink fails, remove the destination (restoring the pre-move state), report the failure through the tool error result, and return false so the whole-patch checkpoint is not recorded over a duplicated file. - Partial patch failure: persist an explicit partial-state checkpoint for the retained successful operations before the queued-message handoff, instead of carrying unpersisted writes forward. - Access-check failure now breaks to the same partial-failure path. --- src/core/tools/ApplyPatchTool.ts | 34 ++++++++- .../__tests__/applyPatchTool.execute.spec.ts | 76 ++++++++++++++++++- 2 files changed, 103 insertions(+), 7 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index a2a3a62ca6..fad6729177 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -122,7 +122,11 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (!accessAllowed) { await task.say("rooignore_error", relPath) pushToolResult(formatResponse.rooIgnoreError(relPath)) - return + // Partial failure: earlier successful writes are retained; break so the + // explicit partial-state checkpoint below persists them before the + // queued-message handoff in the finally block. + patchSucceeded = false + break } // Check if file is write-protected @@ -170,7 +174,18 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (perWriteCheckpoints !== false) { // Await so the whole-patch checkpoint persists before the tool // completes; an unawaited save lets queued work interleave its own - // checkpoint and lets shutdown lose the commit. + // checkpoint and lets shutdown lose the commit. Called only after + // every filesystem operation succeeded. + await checkpointSave(task, false, true).catch(() => {}) + } + } else if (handoffPending) { + // Partial failure: the earlier successful operations keep their writes + // in the workspace. Persist an explicit partial-state checkpoint before + // the queued-message handoff (the finally block below) so those retained + // writes have rollback metadata instead of being carried forward + // unpersisted; the failed operation already reported itself. + const perWriteCheckpoints = (await task.providerRef?.deref()?.getState())?.perWriteCheckpoints + if (perWriteCheckpoints !== false) { await checkpointSave(task, false, true).catch(() => {}) } } @@ -480,7 +495,20 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { try { await fs.unlink(absolutePath) } catch (error) { - console.error(`Failed to delete original file after move: ${error}`) + // The source is still present (locked or protected): undo the destination + // write so the file does not live in two places, and report the move as + // failed. Returning true here would let the whole-patch checkpoint + // persist a duplicated file as if the move had succeeded. + try { + await fs.rm(moveAbsolutePath, { force: true }) + } catch (restoreError) { + console.error(`Failed to restore pre-move state after source deletion failure: ${restoreError}`) + } + const moveErrorMessage = `Failed to delete original file after move '${relPath}': ${error instanceof Error ? error.message : String(error)}` + await task.say("error", moveErrorMessage) + pushToolResult(formatResponse.toolError(moveErrorMessage)) + await task.diffViewProvider.reset() + return false } await task.fileContextTracker.trackFileContext(change.movePath, "roo_edited" as RecordSource) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 41a24b0135..aa0849e62b 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -16,6 +16,7 @@ const mockedFsPromises = vi.mocked( fsPromises as unknown as { default: { unlink: MockedFunction + rm: MockedFunction writeFile: MockedFunction } }, @@ -25,6 +26,7 @@ vi.mock("fs/promises", () => ({ default: { readFile: vi.fn().mockResolvedValue("original file content\n"), unlink: vi.fn().mockResolvedValue(undefined), + rm: vi.fn().mockResolvedValue(undefined), mkdir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined), }, @@ -144,6 +146,62 @@ describe("ApplyPatchTool.execute - delete file success path", () => { *** End Patch` const mockedCheckpointSave = checkpointSave as MockedFunction + it("restores the pre-move state and fails the operation when source deletion fails", async () => { + const patch = `*** Begin Patch +*** Update File: src/old.ts +*** Move to: src/new.ts +@@ +-original file content ++new content +*** End Patch` + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + mockedFsPromises.default.unlink.mockRejectedValueOnce( + Object.assign(new Error("EBUSY: resource busy or locked"), { code: "EBUSY" }), + ) + // The restore itself fails too: the failure is logged, not swallowed. + mockedFsPromises.default.rm.mockRejectedValueOnce(new Error("EROFS: read-only file system")) + + await tool.execute({ patch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // The destination write is undone so the file does not live in two places. + expect(mockedFsPromises.default.rm).toHaveBeenCalledWith(expect.stringContaining("new.ts"), { force: true }) + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to restore pre-move state")) + errorSpy.mockRestore() + // The move is reported as failed rather than as a successful write... + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Failed to delete original file after move"), + ) + // ...and no whole-patch checkpoint is recorded for the failed move. + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("persists an explicit partial-state checkpoint before handoff when a later operation fails", async () => { + const patch = `*** Begin Patch +*** Delete File: src/obsolete.ts +*** Delete File: src/locked.ts +*** End Patch` + // The first delete succeeds; the second fails. + mockedFsPromises.default.unlink.mockRejectedValueOnce(new Error("EPERM: operation not permitted")) + + await tool.execute({ patch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // The retained successful delete gets an explicit partial-state + // checkpoint before the queued-message handoff. + expect(mockedCheckpointSave).toHaveBeenCalledTimes(1) + const checkpointAt = mockedCheckpointSave.mock.invocationCallOrder[0] ?? Infinity + const handoffAt = vi.mocked(mockTask.processQueuedMessages).mock.invocationCallOrder[0] ?? Infinity + expect(checkpointAt).toBeLessThan(handoffAt) + }) it("records one suppressed checkpoint for the whole patch (default-on)", async () => { await tool.execute({ patch: deletePatch }, mockTask as Task, { askApproval: mockAskApproval, @@ -260,10 +318,13 @@ describe("ApplyPatchTool.execute - delete file success path", () => { toolCallId: "test-tool-call-id", }) - // No whole-patch checkpoint (the patch failed), but the successful - // write still hands off. - expect(mockedCheckpointSave).not.toHaveBeenCalled() + // The retained successful write gets an explicit partial-state + // checkpoint before the queued-message handoff. + expect(mockedCheckpointSave).toHaveBeenCalledTimes(1) expect(mockTask.processQueuedMessages).toHaveBeenCalled() + expect(mockedCheckpointSave.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(mockTask.processQueuedMessages).mock.invocationCallOrder[0] ?? Infinity, + ) }) it("hands off queued messages when a later file fails the access check", async () => { @@ -287,8 +348,15 @@ describe("ApplyPatchTool.execute - delete file success path", () => { toolCallId: "test-tool-call-id", }) - expect(mockedCheckpointSave).not.toHaveBeenCalled() + // The earlier successful delete is retained: an explicit partial-state + // checkpoint persists it before the deferred handoff. + expect(mockedCheckpointSave).toHaveBeenCalledTimes(1) expect(mockTask.processQueuedMessages).toHaveBeenCalledTimes(1) + // The access-failed file is never written and the loop stops there. + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalledWith( + "src/secret.ts", + expect.anything(), + ) }) it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { From 20af1c7814d1d18db9411e13b1d90d4dbce0962d Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 17 Oct 2026 17:29:46 +0800 Subject: [PATCH 18/29] fix(tools): checkpoint retained writes before handoff after a handler error A later file operation can reject outright (e.g. the approval stream dies) after earlier handlers succeeded. The outer catch skipped both checkpoint branches, but the finally block still handed off queued messages, so the queued turn could start with the retained writes carried forward without rollback metadata. The catch now persists an explicit partial-state checkpoint (same default-on semantics as the success/partial branches) before handleError and the handoff. Addresses the CodeRabbit review thread on ApplyPatchTool. --- src/core/tools/ApplyPatchTool.ts | 13 ++++++++++ .../__tests__/applyPatchTool.execute.spec.ts | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index fad6729177..ca70bbc2c3 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -190,6 +190,19 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } } } catch (error) { + // A later file operation can reject outright (e.g. the approval + // stream dies) after earlier handlers succeeded. This catch skips + // both checkpoint branches above, but the finally block still + // hands off queued messages, so persist an explicit partial-state + // checkpoint here (same default-on semantics as the branches + // above) instead of letting the queued turn start with the + // retained writes carried forward without rollback metadata. + if (handoffPending) { + const perWriteCheckpoints = (await task.providerRef?.deref()?.getState())?.perWriteCheckpoints + if (perWriteCheckpoints !== false) { + await checkpointSave(task, false, true).catch(() => {}) + } + } await handleError("apply patch", error as Error) await task.diffViewProvider.reset() } finally { diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index aa0849e62b..f4dbc52c70 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -202,6 +202,31 @@ describe("ApplyPatchTool.execute - delete file success path", () => { const handoffAt = vi.mocked(mockTask.processQueuedMessages).mock.invocationCallOrder[0] ?? Infinity expect(checkpointAt).toBeLessThan(handoffAt) }) + it("persists a partial-state checkpoint when a later handler rejects outright after an earlier write", async () => { + const patch = `*** Begin Patch +*** Delete File: src/obsolete.ts +*** Delete File: src/locked.ts +*** End Patch` + // The first delete approves and succeeds; the second handler rejects + // outright (e.g. the approval stream dies), so the outer catch runs + // and the partial-state checkpoint branch above it is skipped. + mockAskApproval.mockResolvedValueOnce(true).mockRejectedValueOnce(new Error("approval stream died")) + + await tool.execute({ patch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // The retained first delete still gets rollback metadata before the + // queued-message handoff in the finally block. + expect(mockedCheckpointSave).toHaveBeenCalledTimes(1) + const checkpointAt = mockedCheckpointSave.mock.invocationCallOrder[0] ?? Infinity + const handoffAt = vi.mocked(mockTask.processQueuedMessages).mock.invocationCallOrder[0] ?? Infinity + expect(checkpointAt).toBeLessThan(handoffAt) + expect(mockHandleError).toHaveBeenCalled() + }) it("records one suppressed checkpoint for the whole patch (default-on)", async () => { await tool.execute({ patch: deletePatch }, mockTask as Task, { askApproval: mockAskApproval, From a0d292efef9426fa8c3ce0441f053e391126915c Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 17 Oct 2026 20:05:12 +0800 Subject: [PATCH 19/29] fix(task): task-start checkpoint baseline errors no longer abort task startup The baseline block runs before TaskStarted and is best-effort: a rejected provider-state lookup or a checkpoint error now logs and continues instead of escaping initiateTaskLoop (startTask rethrows non-abort errors). --- src/core/task/Task.ts | 22 +++++++++++++++------- src/core/task/__tests__/Task.spec.ts | 25 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 75a97d1900..fcda4c94c0 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3854,13 +3854,21 @@ export class Task extends EventEmitter implements TaskLike { // save would only add latency to a task that is being disposed. if (!this.taskStartBaselineDone && !this._isHistoryTask && !this.abort) { this.taskStartBaselineDone = true - const baselineEnabled = (await this.providerRef.deref()?.getState())?.perWriteCheckpoints - // Cancellation can arrive while the state lookup is pending; recheck - // before the save so a canceled task performs no checkpoint work. - if (baselineEnabled !== false && !this.abort) { - // allowEmpty=true so a clean workspace still produces the baseline - // commit; awaited so the first per-write checkpoint cannot interleave. - await this.checkpointSave(true, true) + try { + const baselineEnabled = (await this.providerRef.deref()?.getState())?.perWriteCheckpoints + // Cancellation can arrive while the state lookup is pending; recheck + // before the save so a canceled task performs no checkpoint work. + if (baselineEnabled !== false && !this.abort) { + // allowEmpty=true so a clean workspace still produces the baseline + // commit; awaited so the first per-write checkpoint cannot interleave. + await this.checkpointSave(true, true) + } + } catch (error) { + // The baseline is best-effort and runs before TaskStarted: a rejected + // state lookup or a checkpoint error must not abort task startup + // (startTask rethrows non-abort errors from initiateTaskLoop). Log + // and continue; per-write saves still report their own failures. + console.error("[Task] task-start checkpoint baseline failed:", error) } } diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 54894de6ea..7f7a1f7bf9 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -6991,6 +6991,31 @@ describe("Cline", () => { await loopPromise expect(requestSpy).toHaveBeenCalled() }) + + it("continues task startup when the baseline checkpoint fails", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "baseline failure task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const boom = new Error("baseline boom") + vi.spyOn(task, "checkpointSave").mockRejectedValue(boom) + const state = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue(state) + const requestSpy = vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + // The baseline runs before TaskStarted: a checkpoint error must not + // abort task startup (startTask rethrows non-abort errors from + // initiateTaskLoop). The loop must still be entered. + await taskAccess.initiateTaskLoop([]) + + expect(requestSpy).toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith("[Task] task-start checkpoint baseline failed:", boom) + consoleErrorSpy.mockRestore() + }) }) describe("start()", () => { From 8865c4ad16b194ca55c68aa909361415d9a87f79 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 18 Oct 2026 08:32:24 +0800 Subject: [PATCH 20/29] fix(checkpoints): restore a clobbered move destination when source deletion fails Capture the destination's prior content before the move write. On rollback the destination is restored to that content instead of being removed, so a failed move no longer destroys a pre-existing file; when the destination cannot be inspected (non-ENOENT read failure) the move is refused before any write rather than treating the file as absent. Also clarify the perWrite checkpoint setting description: a multi-file patch produces a single checkpoint (all 18 locales). --- src/core/tools/ApplyPatchTool.ts | 31 ++++++- .../__tests__/applyPatchTool.execute.spec.ts | 86 +++++++++++++++++++ webview-ui/src/i18n/locales/ca/settings.json | 2 +- webview-ui/src/i18n/locales/de/settings.json | 2 +- webview-ui/src/i18n/locales/en/settings.json | 2 +- webview-ui/src/i18n/locales/es/settings.json | 2 +- webview-ui/src/i18n/locales/fr/settings.json | 2 +- webview-ui/src/i18n/locales/hi/settings.json | 2 +- webview-ui/src/i18n/locales/id/settings.json | 2 +- webview-ui/src/i18n/locales/it/settings.json | 2 +- webview-ui/src/i18n/locales/ja/settings.json | 2 +- webview-ui/src/i18n/locales/ko/settings.json | 2 +- webview-ui/src/i18n/locales/nl/settings.json | 2 +- webview-ui/src/i18n/locales/pl/settings.json | 2 +- .../src/i18n/locales/pt-BR/settings.json | 2 +- webview-ui/src/i18n/locales/ru/settings.json | 2 +- webview-ui/src/i18n/locales/tr/settings.json | 2 +- webview-ui/src/i18n/locales/vi/settings.json | 2 +- .../src/i18n/locales/zh-CN/settings.json | 2 +- .../src/i18n/locales/zh-TW/settings.json | 2 +- 20 files changed, 133 insertions(+), 20 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index ca70bbc2c3..3bed2d8837 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -488,6 +488,27 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { return false } + // Capture the destination state before either supported write path: a + // failed move must restore an existing destination instead of deleting + // whatever lived there. A read error other than ENOENT means the + // destination cannot be inspected, so the move is refused before any + // write rather than risking an unrecoverable clobber. + let destinationBackup: Buffer | undefined + try { + destinationBackup = await fs.readFile(moveAbsolutePath) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code !== "ENOENT") { + task.consecutiveMistakeCount++ + task.recordToolError("apply_patch") + const inspectErrorMessage = `Cannot inspect move destination '${change.movePath}': ${error instanceof Error ? error.message : String(error)}` + await task.say("error", inspectErrorMessage) + pushToolResult(formatResponse.toolError(inspectErrorMessage)) + await task.diffViewProvider.reset() + return false + } + } + // Save new content to the new path if (isPreventFocusDisruptionEnabled) { await task.diffViewProvider.saveDirectly( @@ -511,9 +532,15 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // The source is still present (locked or protected): undo the destination // write so the file does not live in two places, and report the move as // failed. Returning true here would let the whole-patch checkpoint - // persist a duplicated file as if the move had succeeded. + // persist a duplicated file as if the move had succeeded. An existing + // destination is restored to its captured content; the destination is + // removed only when it was confirmed absent before the write. try { - await fs.rm(moveAbsolutePath, { force: true }) + if (destinationBackup !== undefined) { + await fs.writeFile(moveAbsolutePath, destinationBackup) + } else { + await fs.rm(moveAbsolutePath, { force: true }) + } } catch (restoreError) { console.error(`Failed to restore pre-move state after source deletion failure: ${restoreError}`) } diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index f4dbc52c70..dbf246fb26 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -15,6 +15,7 @@ import { ApplyPatchTool } from "../ApplyPatchTool" const mockedFsPromises = vi.mocked( fsPromises as unknown as { default: { + readFile: MockedFunction unlink: MockedFunction rm: MockedFunction writeFile: MockedFunction @@ -155,6 +156,14 @@ describe("ApplyPatchTool.execute - delete file success path", () => { +new content *** End Patch` const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + // The move destination does not exist: the rollback must remove the + // destination rather than restore content that was never there. + mockedFsPromises.default.readFile.mockImplementation(async (p: unknown) => { + if (String(p).includes("new.ts")) { + throw Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + } + return "original file content\n" + }) mockedFsPromises.default.unlink.mockRejectedValueOnce( Object.assign(new Error("EBUSY: resource busy or locked"), { code: "EBUSY" }), ) @@ -178,6 +187,83 @@ describe("ApplyPatchTool.execute - delete file success path", () => { ) // ...and no whole-patch checkpoint is recorded for the failed move. expect(mockedCheckpointSave).not.toHaveBeenCalled() + // Restore the default readFile behavior for later tests. + mockedFsPromises.default.readFile.mockResolvedValue("original file content\n") + }) + + it("restores an existing destination when source deletion fails after the move clobbered it", async () => { + const patch = `*** Begin Patch +*** Update File: src/old.ts +*** Move to: src/new.ts +@@ +-original file content ++new content +*** End Patch` + // The destination already exists with its own content; the move writes + // over it before the source unlink runs. + mockedFsPromises.default.readFile.mockImplementation(async (p: unknown) => { + if (String(p).includes("new.ts")) { + return "existing destination content" + } + return "original file content\n" + }) + mockedFsPromises.default.unlink.mockRejectedValueOnce( + Object.assign(new Error("EBUSY: resource busy or locked"), { code: "EBUSY" }), + ) + + await tool.execute({ patch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // The captured destination content is restored, not deleted: rm must not + // run for a destination that existed before the move. + expect(mockedFsPromises.default.writeFile).toHaveBeenCalledWith( + expect.stringContaining("new.ts"), + "existing destination content", + ) + expect(mockedFsPromises.default.rm).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Failed to delete original file after move"), + ) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + // Restore the default readFile behavior for later tests. + mockedFsPromises.default.readFile.mockResolvedValue("original file content\n") + }) + + it("refuses the move before writing when the destination cannot be inspected", async () => { + const patch = `*** Begin Patch +*** Update File: src/old.ts +*** Move to: src/new.ts +@@ +-original file content ++new content +*** End Patch` + // An EACCES on the destination is not "absent": treating every read + // failure as missing could delete an inaccessible destination. + mockedFsPromises.default.readFile.mockImplementation(async (p: unknown) => { + if (String(p).includes("new.ts")) { + throw Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + } + return "original file content\n" + }) + + await tool.execute({ patch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // The destination is never written and the source is never unlinked. + expect(mockedFsPromises.default.writeFile).not.toHaveBeenCalled() + expect(mockedFsPromises.default.unlink).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Cannot inspect move destination")) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + // Restore the default readFile behavior for later tests. + mockedFsPromises.default.readFile.mockResolvedValue("original file content\n") }) it("persists an explicit partial-state checkpoint before handoff when a later operation fails", async () => { diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index e97470e299..560e5a054d 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -737,7 +737,7 @@ }, "perWrite": { "label": "Punt de control després de cada escriptura de fitxer", - "description": "Registra una instantània de punt de control després de cada escriptura de fitxer reeixida de l’agent" + "description": "Registra una instantània de punt de control després de cada operació d’escriptura reeixida; un pegat de diversos fitxers genera un únic punt de control." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index a8b1136a2c..a9e3404f7e 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -737,7 +737,7 @@ }, "perWrite": { "label": "Kontrollpunkt nach jedem Dateischreibvorgang", - "description": "Ein Kontrollpunkt-Snapshot wird nach jedem erfolgreichen Dateischreibvorgang des Agents erfasst" + "description": "Erstellt nach jedem erfolgreichen Schreibvorgang einen Checkpoint-Schnappschuss; ein Patch mit mehreren Dateien erzeugt genau einen Checkpoint." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 060d5c964f..dc746ab1a9 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -817,7 +817,7 @@ }, "perWrite": { "label": "Checkpoint after each file write", - "description": "Record a checkpoint snapshot after every successful file write by the agent" + "description": "Record a checkpoint snapshot after each successful write operation; a multi-file patch produces a single checkpoint." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index c2b480f176..bf5c31076e 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -737,7 +737,7 @@ }, "perWrite": { "label": "Punto de control después de cada escritura de archivo", - "description": "Registra una instantánea de punto de control después de cada escritura de archivo exitosa del agente" + "description": "Registra una instantánea de punto de control después de cada operación de escritura exitosa; un parche de varios archivos produce un único punto de control." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 91994a1dc3..6e78660d96 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -737,7 +737,7 @@ }, "perWrite": { "label": "Point de contrôle après chaque écriture de fichier", - "description": "Enregistre un instantané de point de contrôle après chaque écriture de fichier réussie par l’agent" + "description": "Enregistre un instantané de point de contrôle après chaque opération d’écriture réussie ; un correctif multi-fichiers produit un seul point de contrôle." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index a1780a028f..cbfa6bcfbc 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -737,7 +737,7 @@ }, "perWrite": { "label": "हर फ़ाइल लिखने के बाद चेकपॉइंट", - "description": "एजेंट द्वारा हर सफल फ़ाइल लिखने के बाद एक चेकपॉइंट स्नैपशॉट दर्ज किया जाता है" + "description": "प्रत्येक सफल लेखन कार्रवाई के बाद एक चेकपॉइंट स्नैपशॉट रिकॉर्ड करें; एक मल्टी-फ़ाइल पैच केवल एक चेकपॉइंट बनाता है।" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 3536c1702f..f1b92c7b99 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -737,7 +737,7 @@ }, "perWrite": { "label": "Checkpoint setelah setiap penulisan file", - "description": "Merekam snapshot checkpoint setelah setiap penulisan file yang berhasil oleh agen" + "description": "Rekam snapshot titik pemeriksaan setelah setiap operasi tulis berhasil; patch multi-file menghasilkan satu titik pemeriksaan." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 06074ffa17..a38caa7eb2 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -737,7 +737,7 @@ }, "perWrite": { "label": "Punto di controllo dopo ogni scrittura del file", - "description": "Registra uno snapshot di punto di controllo dopo ogni scrittura del file riuscita dell’agente" + "description": "Registra un’istantanea del punto di controllo dopo ogni operazione di scrittura riuscita; una patch con più file produce un unico punto di controllo." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 89922c60ce..84ccf4f539 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -737,7 +737,7 @@ }, "perWrite": { "label": "ファイルの書き込みごとにチェックポイント", - "description": "エージェントによる各ファイルの書き込み成功後にチェックポイントのスナップショットを記録します" + "description": "各書き込み操作の成功後にチェックポイントスナップショットを記録します。複数ファイルのパッチは単一のチェックポイントを作成します。" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 02f4d4f64b..2fe4d8bcf4 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -737,7 +737,7 @@ }, "perWrite": { "label": "파일을 쓸 때마다 체크포인트", - "description": "에이전트가 파일 쓰기에 성공할 때마다 체크포인트 스냅샷을 기록합니다" + "description": "각 쓰기 작업이 성공할 때마다 체크포인트 스냅샷을 기록합니다. 다중 파일 패치는 단일 체크포인트를 생성합니다." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 54a804a860..a9b5726e69 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -737,7 +737,7 @@ }, "perWrite": { "label": "Checkpoint na elke bestandsschrijving", - "description": "Neemt een checkpoint-snapshot op na elke succesvolle bestandsschrijving door de agent" + "description": "Leg na elke succesvolle schrijfbewerking een checkpointmomentopname vast; een patch met meerdere bestanden produceert één checkpoint." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 972999fce0..f8111c9d3e 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -737,7 +737,7 @@ }, "perWrite": { "label": "Punkt kontrolny po każdym zapisaniu pliku", - "description": "Rejestruje migawkę punktu kontrolnego po każdym udanym zapisaniu pliku przez agenta" + "description": "Zapisuje migawkę punktu kontrolnego po każdej udanej operacji zapisu; patch obejmujący wiele plików tworzy jeden punkt kontrolny." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 763d1771d2..4e20908c81 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -737,7 +737,7 @@ }, "perWrite": { "label": "Ponto de verificação após cada gravação de arquivo", - "description": "Registra um snapshot de ponto de verificação após cada gravação de arquivo bem-sucedida pelo agente" + "description": "Registra um snapshot de ponto de verificação após cada operação de gravação bem-sucedida; um patch com vários arquivos produz um único ponto de verificação." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 123863e919..f920f6de20 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -737,7 +737,7 @@ }, "perWrite": { "label": "Контрольная точка после каждой записи файла", - "description": "Записывает снимок контрольной точки после каждой успешной записи файла агентом" + "description": "Записывает снимок контрольной точки после каждой успешной операции записи; патч с несколькими файлами создаёт одну контрольную точку." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 7d050841b3..fcdeed5fce 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -737,7 +737,7 @@ }, "perWrite": { "label": "Her dosya yazımından sonra kontrol noktası", - "description": "Ajanın her başarılı dosya yazımından sonra bir kontrol noktası görüntüsü kaydeder" + "description": "Her başarılı yazma işleminden sonra bir kontrol noktası anlık görüntüsü kaydeder; çok dosyalı bir yama tek bir kontrol noktası üretir." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 1fe3356f99..058ac18705 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -737,7 +737,7 @@ }, "perWrite": { "label": "Điểm kiểm tra sau mỗi lần ghi file", - "description": "Ghi lại ảnh chụp nhanh điểm kiểm tra sau mỗi lần ghi file thành công của agent" + "description": "Ghi ảnh chụp điểm kiểm tra sau mỗi thao tác ghi thành công; một vá nhiều tệp chỉ tạo một điểm kiểm tra." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 55418f52fa..1b7d0e52ab 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -737,7 +737,7 @@ }, "perWrite": { "label": "每次文件写入后创建存档点", - "description": "智能体每次成功写入文件后都会记录一个存档点快照" + "description": "在每次成功写入操作后记录检查点快照;多文件补丁只会生成一个检查点。" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 8cd2d3cd62..149f454132 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -764,7 +764,7 @@ }, "perWrite": { "label": "每次檔案寫入後建立檢查點", - "description": "代理每次成功寫入檔案後都會記錄一個檢查點快照" + "description": "在每次成功的寫入操作後記錄檢查點快照;多檔案修補程式只會產生一個檢查點。" } }, "notifications": { From 911ae24e96242241ba5d02e57d1e556f33df8a7e Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 18 Oct 2026 09:33:43 +0800 Subject: [PATCH 21/29] fix(checkpoints): harden task-start baseline and service init per review - Task: settings-read failure falls back to default-on; baseline done flag set only after a real commit so a no-commit save is retried on a later loop entry - getCheckpointService: never store/return an uninitialized service; clear the initializing flag when initShadowGit fails - checkpointSave (B3+): recovery reuses only an initialized handle - changeJournal (B3+): prune settled journalQueues entries; move and correct the appendChanges/loadChanges doc comments - specs: baseline retry/reject tests, getCheckpointService init-failure regression tests, recovery assertion strengthening, rollback.spec typed doubles, journal queue lifecycle test --- .../__tests__/getCheckpointService.spec.ts | 126 ++++++++++++++++++ src/core/checkpoints/index.ts | 13 ++ src/core/task/Task.ts | 39 ++++-- src/core/task/__tests__/Task.spec.ts | 57 +++++++- 4 files changed, 222 insertions(+), 13 deletions(-) create mode 100644 src/core/checkpoints/__tests__/getCheckpointService.spec.ts diff --git a/src/core/checkpoints/__tests__/getCheckpointService.spec.ts b/src/core/checkpoints/__tests__/getCheckpointService.spec.ts new file mode 100644 index 0000000000..99a8b5b788 --- /dev/null +++ b/src/core/checkpoints/__tests__/getCheckpointService.spec.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import type { Task } from "../../task/Task" +import { RepoPerTaskCheckpointService } from "../../../services/checkpoints" +import { checkGitInstalled } from "../../../utils/git" +import { getCheckpointService } from "../index" + +vi.mock("../../../services/checkpoints", () => ({ + RepoPerTaskCheckpointService: { create: vi.fn() }, +})) + +vi.mock("../../../utils/git", () => ({ + checkGitInstalled: vi.fn(), +})) + +vi.mock("../../../utils/path", () => ({ + getWorkspacePath: vi.fn(() => "/workspace"), +})) + +vi.mock("../../../i18n", () => ({ + t: (key: string) => key, +})) + +/** + * Structural service double: getCheckpointService only registers listeners, + * awaits initShadowGit, and reads isInitialized. The double assertion at the + * create() call sites is confined to this file (AGENTS.md last-resort cast): + * the real class carries a Git handle and event surface the test never + * exercises. + */ +function serviceDouble(initShadowGitImpl: () => Promise) { + const listeners = new Map void>() + let initialized = false + const svc = { + on: vi.fn((event: string, cb: (...args: unknown[]) => void) => { + listeners.set(event, cb) + }), + initShadowGit: vi.fn(() => initShadowGitImpl()), + get isInitialized() { + return initialized + }, + completeInit: () => { + initialized = true + listeners.get("initialize")?.() + }, + } + return svc +} + +function makeTask(): Task { + return { + taskId: "task-init", + cwd: "/workspace", + enableCheckpoints: true, + checkpointService: undefined, + checkpointServiceInitializing: false, + checkpointTimeout: 1, + providerRef: { + deref: vi.fn().mockReturnValue({ + context: { globalStorageUri: { fsPath: "/tmp/b3-init-storage" } }, + postMessageToWebview: vi.fn(), + log: vi.fn(), + }), + }, + say: vi.fn().mockResolvedValue(undefined), + } as unknown as Task +} + +describe("getCheckpointService initialization failures", () => { + beforeEach(() => { + vi.mocked(checkGitInstalled).mockReset() + vi.mocked(RepoPerTaskCheckpointService.create).mockReset() + }) + + it("does not store a service whose shadow-git initialization failed", async () => { + vi.mocked(checkGitInstalled).mockResolvedValue(true) + const failing = serviceDouble(async () => { + throw new Error("shadow git init exploded") + }) + vi.mocked(RepoPerTaskCheckpointService.create).mockReturnValue( + failing as unknown as RepoPerTaskCheckpointService, + ) + + const task = makeTask() + const service = await getCheckpointService(task) + + // The failed service must not be stored: a stored uninitialized handle + // would be returned by the fast path to every later save, and the + // initializing flag must not strand later lookups in a wait for an + // initialization that already died. + expect(service).toBeUndefined() + expect(task.checkpointService).toBeUndefined() + expect(task.checkpointServiceInitializing).toBe(false) + expect(task.enableCheckpoints).toBe(false) + }) + + it("does not store a service when Git is not installed", async () => { + vi.mocked(checkGitInstalled).mockResolvedValue(false) + const svc = serviceDouble(async () => {}) + vi.mocked(RepoPerTaskCheckpointService.create).mockReturnValue(svc as unknown as RepoPerTaskCheckpointService) + + const task = makeTask() + const service = await getCheckpointService(task) + + expect(service).toBeUndefined() + expect(task.checkpointService).toBeUndefined() + expect(task.checkpointServiceInitializing).toBe(false) + expect(task.enableCheckpoints).toBe(false) + }) + + it("stores an initialized service on the success path", async () => { + vi.mocked(checkGitInstalled).mockResolvedValue(true) + const svc = serviceDouble(async () => { + svc.completeInit() + }) + vi.mocked(RepoPerTaskCheckpointService.create).mockReturnValue(svc as unknown as RepoPerTaskCheckpointService) + + const task = makeTask() + const service = await getCheckpointService(task) + + expect(service).toBe(svc) + expect(task.checkpointService).toBe(svc) + expect(task.checkpointServiceInitializing).toBe(false) + expect(task.enableCheckpoints).toBe(true) + }) +}) diff --git a/src/core/checkpoints/index.ts b/src/core/checkpoints/index.ts index de424b03f7..96c1930126 100644 --- a/src/core/checkpoints/index.ts +++ b/src/core/checkpoints/index.ts @@ -113,6 +113,15 @@ export async function getCheckpointService(task: Task, { interval = 250 }: { int const service = RepoPerTaskCheckpointService.create(options) task.checkpointServiceInitializing = true await checkGitInstallation(task, service, log, provider) + if (!service.isInitialized) { + // checkGitInstallation disables checkpoints when Git is unavailable and + // logs initShadowGit failures without throwing: storing such a service + // would hand later saves (and the fast path above) an uninitialized + // handle whose isInitialized can never flip, so a failed + // initialization must resolve to no service at all. + task.checkpointServiceInitializing = false + return undefined + } task.checkpointService = service if (task.enableCheckpoints) { sendCheckpointInitWarn(task) @@ -200,6 +209,10 @@ async function checkGitInstallation( } catch (err) { log(`[Task#getCheckpointService] initShadowGit -> ${err.message}`) task.enableCheckpoints = false + // The "initialize" event never fires for a failed initShadowGit, so + // clear the flag here or later lookups wait on an initialization + // that already died. + task.checkpointServiceInitializing = false } } catch (err) { log(`[Task#getCheckpointService] Unexpected error during Git check: ${err.message}`) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index fcda4c94c0..0a730dafc3 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3853,22 +3853,37 @@ export class Task extends EventEmitter implements TaskLike { // An already-aborted task skips it too: the loop will never run, so the // save would only add latency to a task that is being disposed. if (!this.taskStartBaselineDone && !this._isHistoryTask && !this.abort) { - this.taskStartBaselineDone = true + let baselineEnabled: boolean | undefined try { - const baselineEnabled = (await this.providerRef.deref()?.getState())?.perWriteCheckpoints - // Cancellation can arrive while the state lookup is pending; recheck - // before the save so a canceled task performs no checkpoint work. - if (baselineEnabled !== false && !this.abort) { + baselineEnabled = (await this.providerRef.deref()?.getState())?.perWriteCheckpoints + } catch (error) { + // A rejected state lookup must not abort task startup: fall back + // to the default-on semantics used when the setting is absent. + console.error("[Task] task-start baseline settings read failed:", error) + } + // Cancellation can arrive while the state lookup is pending; recheck + // before the save so a canceled task performs no checkpoint work. + if (baselineEnabled === false || this.abort) { + this.taskStartBaselineDone = true + } else { + try { // allowEmpty=true so a clean workspace still produces the baseline // commit; awaited so the first per-write checkpoint cannot interleave. - await this.checkpointSave(true, true) + const baseline = await this.checkpointSave(true, true) + // The flag is set only once the baseline actually committed: a save + // that no-ops behind a failed or timed-out service initialization is + // retried on a later loop entry instead of being marked done without + // a rollback point. + if (baseline?.commit) { + this.taskStartBaselineDone = true + } + } catch (error) { + // The baseline is best-effort and runs before TaskStarted: a + // checkpoint error must not abort task startup (startTask rethrows + // non-abort errors from initiateTaskLoop). Log and continue; the + // next loop entry retries the baseline. + console.error("[Task] task-start checkpoint baseline failed:", error) } - } catch (error) { - // The baseline is best-effort and runs before TaskStarted: a rejected - // state lookup or a checkpoint error must not abort task startup - // (startTask rethrows non-abort errors from initiateTaskLoop). Log - // and continue; per-write saves still report their own failures. - console.error("[Task] task-start checkpoint baseline failed:", error) } } diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 7f7a1f7bf9..f9facfcac6 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -6833,7 +6833,10 @@ describe("Cline", () => { startTask: false, }) const taskAccess = getTaskTestAccess(task) - const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue(undefined) + // A committed baseline: the done flag is set only on a real + // commit, so the double must resolve one for the once-per-instance + // assertion to exercise the flag flip. + const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue({ commit: "baseline-commit" }) const state = await mockProvider.getState() vi.spyOn(mockProvider, "getState").mockResolvedValue(state) @@ -7016,6 +7019,58 @@ describe("Cline", () => { expect(consoleErrorSpy).toHaveBeenCalledWith("[Task] task-start checkpoint baseline failed:", boom) consoleErrorSpy.mockRestore() }) + + it("retries the baseline on a later loop entry when a save produced no commit", async () => { + // getCheckpointService can resolve to no service (init failure or a + // bounded init wait timing out): the save then completes without a + // commit, and the baseline must not be marked done - a later loop + // entry gets to retry it instead of losing it forever. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "retry baseline task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi + .spyOn(task, "checkpointSave") + .mockResolvedValueOnce(undefined) + .mockResolvedValue({ commit: "baseline-commit" }) + const state = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue(state) + + vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true) + + await taskAccess.initiateTaskLoop([]) + expect(saveSpy).toHaveBeenCalledOnce() + + await taskAccess.initiateTaskLoop([]) + expect(saveSpy).toHaveBeenCalledTimes(2) + }) + + it("starts the task default-on when the baseline settings read rejects", async () => { + // ClineProvider.getState awaits custom-mode loading and can reject: + // a failed settings read must not stop task startup. Default-on + // semantics apply (the setting is treated as absent) and the loop + // still runs. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "rejected-read baseline task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue({ commit: "baseline-commit" }) + vi.spyOn(mockProvider, "getState").mockRejectedValueOnce(new Error("custom mode load failed")) + + const requestSpy = vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true) + + await taskAccess.initiateTaskLoop([]) + + expect(saveSpy).toHaveBeenCalledOnce() + expect(saveSpy).toHaveBeenCalledWith(true, true) + expect(requestSpy).toHaveBeenCalled() + }) }) describe("start()", () => { From 377c0822c348aef2cc5ab5df338759295959419d Mon Sep 17 00:00:00 2001 From: dsh-agent Date: Thu, 22 Oct 2026 03:06:22 +0800 Subject: [PATCH 22/29] fix(task): bound the task-start baseline save; report a failed move rollback The baseline checkpoint is awaited before TaskStarted with allowEmpty. A stalled git add/commit blocked startup forever and could overlap the first file write. The await is now bounded by checkpointTimeout; a timed-out baseline logs a warning and startup continues, and because checkpointSave serializes per task a late baseline cannot interleave with the first per-write save. savePromise gets its own catch so a timeout that wins leaves no unhandled rejection. ApplyPatchTool: when the source unlink fails and restoring the pre-move destination also fails, the restore error is appended to the tool failure instead of only being logged, so the model and user know the destination may still hold the new content. Debug logging is kept. --- src/core/task/Task.ts | 37 ++++++++++++++++--- src/core/task/__tests__/Task.spec.ts | 28 ++++++++++++++ src/core/tools/ApplyPatchTool.ts | 12 +++++- .../__tests__/applyPatchTool.execute.spec.ts | 8 +++- 4 files changed, 77 insertions(+), 8 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 0a730dafc3..12427ee86a 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3869,12 +3869,37 @@ export class Task extends EventEmitter implements TaskLike { try { // allowEmpty=true so a clean workspace still produces the baseline // commit; awaited so the first per-write checkpoint cannot interleave. - const baseline = await this.checkpointSave(true, true) - // The flag is set only once the baseline actually committed: a save - // that no-ops behind a failed or timed-out service initialization is - // retried on a later loop entry instead of being marked done without - // a rollback point. - if (baseline?.commit) { + // The await is bounded: a stalled git add/commit must not hold task + // startup before TaskStarted. A baseline that finishes late is + // harmless - checkpointSave serializes per task, so a still-running + // baseline cannot interleave with the first per-write save. + const savePromise = this.checkpointSave(true, true) + // A timeout that wins must not leave an unhandled rejection behind. + savePromise.catch(() => undefined) + const BASELINE_TIMED_OUT = Symbol("baseline-timed-out") + let baselineTimer: ReturnType | undefined + const baseline = await Promise.race([ + savePromise, + new Promise((resolve) => { + baselineTimer = setTimeout( + () => resolve(BASELINE_TIMED_OUT), + Math.max(1, this.checkpointTimeout * 1000), + ) + }), + ]).finally(() => { + if (baselineTimer !== undefined) { + clearTimeout(baselineTimer) + } + }) + if (baseline === BASELINE_TIMED_OUT) { + console.warn( + `[Task] task-start checkpoint baseline still running after ${this.checkpointTimeout}s; entering the request loop and leaving the baseline to the per-task checkpoint lock.`, + ) + } else if (baseline?.commit) { + // The flag is set only once the baseline actually committed: a save + // that no-ops behind a failed or timed-out service initialization is + // retried on a later loop entry instead of being marked done without + // a rollback point. this.taskStartBaselineDone = true } } catch (error) { diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index f9facfcac6..f0afa0c45a 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -6995,6 +6995,34 @@ describe("Cline", () => { expect(requestSpy).toHaveBeenCalled() }) + it("enters the request loop when the baseline save stalls past checkpointTimeout", async () => { + // A stalled git add/commit must not hold startup before TaskStarted. The + // await is bounded; the late baseline is left to the per-task checkpoint + // lock, which serializes it with the first per-write save. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "baseline stall task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + type SaveResult = Awaited> + vi.spyOn(task, "checkpointSave").mockImplementation(() => new Promise(() => {})) + const requestSpy = vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true) + const state = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue(state) + task.checkpointTimeout = 0.01 + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await taskAccess.initiateTaskLoop([]) + + expect(requestSpy).toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("task-start checkpoint baseline still running"), + ) + warnSpy.mockRestore() + }) + it("continues task startup when the baseline checkpoint fails", async () => { const task = new Task({ provider: mockProvider, diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 3bed2d8837..a2990015cd 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -535,6 +535,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // persist a duplicated file as if the move had succeeded. An existing // destination is restored to its captured content; the destination is // removed only when it was confirmed absent before the write. + let restoreFailure: unknown try { if (destinationBackup !== undefined) { await fs.writeFile(moveAbsolutePath, destinationBackup) @@ -542,9 +543,18 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await fs.rm(moveAbsolutePath, { force: true }) } } catch (restoreError) { + // The failed operation is reported through the tool result below; a + // failed rollback means the destination may now hold the new content + // while the source still exists, and both the model and the user must + // know that instead of a console-only note. + restoreFailure = restoreError console.error(`Failed to restore pre-move state after source deletion failure: ${restoreError}`) } - const moveErrorMessage = `Failed to delete original file after move '${relPath}': ${error instanceof Error ? error.message : String(error)}` + const restoreSuffix = + restoreFailure === undefined + ? "" + : ` The destination could not be restored and may still contain the new content: ${restoreFailure instanceof Error ? restoreFailure.message : String(restoreFailure)}` + const moveErrorMessage = `Failed to delete original file after move '${relPath}': ${error instanceof Error ? error.message : String(error)}${restoreSuffix}` await task.say("error", moveErrorMessage) pushToolResult(formatResponse.toolError(moveErrorMessage)) await task.diffViewProvider.reset() diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index dbf246fb26..3aa18c21e5 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -181,10 +181,16 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockedFsPromises.default.rm).toHaveBeenCalledWith(expect.stringContaining("new.ts"), { force: true }) expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to restore pre-move state")) errorSpy.mockRestore() - // The move is reported as failed rather than as a successful write... + // The move is reported as failed rather than as a successful write, and the + // failed rollback is part of the message: the destination may still hold the + // new content, which the model and user must know. expect(mockPushToolResult).toHaveBeenCalledWith( expect.stringContaining("Failed to delete original file after move"), ) + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("could not be restored and may still contain the new content"), + ) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("EROFS")) // ...and no whole-patch checkpoint is recorded for the failed move. expect(mockedCheckpointSave).not.toHaveBeenCalled() // Restore the default readFile behavior for later tests. From b6d8698cfe0f10118b5694efff1ded860bb2e9f7 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:32:33 +0800 Subject: [PATCH 23/29] fix(tools): make the apply_patch move transactional with atomic staging and renames Persistence Integrity (CodeRabbit pre-merge check): the move path wrote the destination with fs.writeFile before deleting the source, so a crash or a failed source unlink could leave a truncated or duplicated file, and the rollback rewrote the captured bytes non-atomically. - Write the new content to a sibling .staging file and rename it into place (atomic on one volume). - Keep the pre-move destination on disk under a .backup name (atomic rename) until the move commits. - On a failed source unlink, restore the destination by renaming the backup over it (one atomic replacement) or remove it when the destination did not exist. - When that restore itself fails, persist the approved content as .zoo-recovery- and name the path in the tool error, so a half-applied move has a deterministic recovery artifact. - DiffViewProvider.saveDirectly now writes through the same atomic staging helper, so every direct write offers all-or-nothing visibility. Tests updated to the new contract (atomic write + rename assertions, rollback via rename, recovery-copy message); 129 tests across the apply_patch, DiffViewProvider and checkpoint suites pass. --- src/core/tools/ApplyPatchTool.ts | 108 +++++++++++++----- .../__tests__/applyPatchTool.execute.spec.ts | 16 ++- src/integrations/editor/DiffViewProvider.ts | 27 ++++- .../editor/__tests__/DiffViewProvider.spec.ts | 29 ++++- 4 files changed, 142 insertions(+), 38 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index a2990015cd..6dde59dbba 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -509,20 +509,54 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } } - // Save new content to the new path - if (isPreventFocusDisruptionEnabled) { - await task.diffViewProvider.saveDirectly( - change.movePath, - newContent, - false, - diagnosticsEnabled, - writeDelayMs, - ) - } else { - // Write to new path and delete old file - const parentDir = path.dirname(moveAbsolutePath) - await fs.mkdir(parentDir, { recursive: true }) - await fs.writeFile(moveAbsolutePath, newContent, "utf8") + // Stage the move so it is all-or-nothing: the pre-move destination bytes + // stay on disk under a backup name, the new content is written to a + // staging file and renamed into place atomically, and only then is the + // source unlinked. Any failure renames the backup back, so the workspace + // never shows a half-applied move. + const moveStamp = `${process.pid}.${Date.now()}` + const moveDir = path.dirname(moveAbsolutePath) + const moveBase = path.basename(moveAbsolutePath) + const stagingPath = path.join(moveDir, `.${moveBase}.${moveStamp}.staging`) + const backupPath = path.join(moveDir, `.${moveBase}.${moveStamp}.backup`) + await fs.mkdir(moveDir, { recursive: true }) + + let destinationCommitted = false + try { + await fs.writeFile(stagingPath, newContent, "utf8") + if (destinationBackup !== undefined) { + // Preserve the pre-move bytes on disk (atomic rename): the in-memory + // copy alone cannot restore the destination after a crash. + await fs.rename(moveAbsolutePath, backupPath) + } + await fs.rename(stagingPath, moveAbsolutePath) + destinationCommitted = true + if (isPreventFocusDisruptionEnabled) { + // Diagnostics/bookkeeping only: the bytes were committed above, and + // saveDirectly writes the same content through the same atomic write. + await task.diffViewProvider.saveDirectly( + change.movePath, + newContent, + false, + diagnosticsEnabled, + writeDelayMs, + ) + } + } catch (writeError) { + await fs.rm(stagingPath, { force: true }).catch(() => {}) + if (destinationCommitted) { + await fs.rm(moveAbsolutePath, { force: true }).catch(() => {}) + } + if (destinationBackup !== undefined) { + await fs.rename(backupPath, moveAbsolutePath).catch(() => {}) + } + task.consecutiveMistakeCount++ + task.recordToolError("apply_patch") + const writeErrorMessage = `Failed to write move destination '${change.movePath}': ${writeError instanceof Error ? writeError.message : String(writeError)}` + await task.say("error", writeErrorMessage) + pushToolResult(formatResponse.toolError(writeErrorMessage)) + await task.diffViewProvider.reset() + return false } // Delete the original file @@ -530,37 +564,59 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await fs.unlink(absolutePath) } catch (error) { // The source is still present (locked or protected): undo the destination - // write so the file does not live in two places, and report the move as - // failed. Returning true here would let the whole-patch checkpoint - // persist a duplicated file as if the move had succeeded. An existing - // destination is restored to its captured content; the destination is - // removed only when it was confirmed absent before the write. + // so the file does not live in two places, and report the move as + // failed. Returning true here would let the whole-patch checkpoint persist + // a duplicated file as if the move had succeeded. let restoreFailure: unknown + let recoveryPath: string | undefined try { if (destinationBackup !== undefined) { - await fs.writeFile(moveAbsolutePath, destinationBackup) + // Atomic restore of the captured destination bytes: the rename + // replaces the clobbered destination in one step, so there is no + // window where the file is missing. + await fs.rename(backupPath, moveAbsolutePath) } else { await fs.rm(moveAbsolutePath, { force: true }) } } catch (restoreError) { - // The failed operation is reported through the tool result below; a - // failed rollback means the destination may now hold the new content - // while the source still exists, and both the model and the user must - // know that instead of a console-only note. restoreFailure = restoreError - console.error(`Failed to restore pre-move state after source deletion failure: ${restoreError}`) } + if (restoreFailure !== undefined) { + // The destination could not be restored. Persist the approved + // content under a recovery name instead of dropping it, so a + // half-applied move still has a deterministic recovery artifact. + const recoveryCandidate = path.join(moveDir, `${moveBase}.zoo-recovery-${moveStamp}`) + try { + await fs.writeFile(recoveryCandidate, newContent, "utf8") + recoveryPath = recoveryCandidate + console.error( + `Failed to restore pre-move state: ${restoreFailure instanceof Error ? restoreFailure.message : String(restoreFailure)}; kept the approved content at ${recoveryCandidate}`, + ) + } catch (recoveryError) { + console.error(`Failed to persist a move recovery copy: ${recoveryError}`) + } + } + // Best-effort cleanup of the staging file; the backup is gone once the + // restore rename committed (or is the only copy left on failure). + await fs.rm(stagingPath, { force: true }).catch(() => {}) const restoreSuffix = restoreFailure === undefined ? "" : ` The destination could not be restored and may still contain the new content: ${restoreFailure instanceof Error ? restoreFailure.message : String(restoreFailure)}` - const moveErrorMessage = `Failed to delete original file after move '${relPath}': ${error instanceof Error ? error.message : String(error)}${restoreSuffix}` + const recoverySuffix = + recoveryPath === undefined + ? "" + : ` The approved new content was kept at '${getReadablePath(task.cwd, path.relative(task.cwd, recoveryPath))}'.` + const moveErrorMessage = `Failed to delete original file after move '${relPath}': ${error instanceof Error ? error.message : String(error)}${restoreSuffix}${recoverySuffix}` await task.say("error", moveErrorMessage) pushToolResult(formatResponse.toolError(moveErrorMessage)) await task.diffViewProvider.reset() return false } + // The move committed: drop the destination backup. + await fs.rm(backupPath, { force: true }).catch(() => {}) + await task.fileContextTracker.trackFileContext(change.movePath, "roo_edited" as RecordSource) } else { // Save changes to the same file diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 3aa18c21e5..51f164f72e 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -18,6 +18,7 @@ const mockedFsPromises = vi.mocked( readFile: MockedFunction unlink: MockedFunction rm: MockedFunction + rename: MockedFunction writeFile: MockedFunction } }, @@ -28,6 +29,7 @@ vi.mock("fs/promises", () => ({ readFile: vi.fn().mockResolvedValue("original file content\n"), unlink: vi.fn().mockResolvedValue(undefined), rm: vi.fn().mockResolvedValue(undefined), + rename: vi.fn().mockResolvedValue(undefined), mkdir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined), }, @@ -191,6 +193,8 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect.stringContaining("could not be restored and may still contain the new content"), ) expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("EROFS")) + // The approved content survives the failed rollback as an explicit recovery artifact. + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("The approved new content was kept at")) // ...and no whole-patch checkpoint is recorded for the failed move. expect(mockedCheckpointSave).not.toHaveBeenCalled() // Restore the default readFile behavior for later tests. @@ -224,13 +228,17 @@ describe("ApplyPatchTool.execute - delete file success path", () => { toolCallId: "test-tool-call-id", }) - // The captured destination content is restored, not deleted: rm must not - // run for a destination that existed before the move. - expect(mockedFsPromises.default.writeFile).toHaveBeenCalledWith( + // The captured destination content is restored by renaming the on-disk backup + // over the clobbered destination (one atomic replacement), never by rewriting + // the captured bytes and never by deleting a destination that existed before. + expect(mockedFsPromises.default.writeFile).not.toHaveBeenCalledWith( expect.stringContaining("new.ts"), "existing destination content", ) - expect(mockedFsPromises.default.rm).not.toHaveBeenCalled() + expect(mockedFsPromises.default.rename).toHaveBeenCalledWith( + expect.stringContaining(".backup"), + expect.stringMatching(/[\\/]new\.ts$/), + ) expect(mockPushToolResult).toHaveBeenCalledWith( expect.stringContaining("Failed to delete original file after move"), ) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index bb3368f063..b74997b248 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -25,6 +25,28 @@ export const DIFF_VIEW_URI_SCHEME = "cline-diff" export const DIFF_VIEW_LABEL_CHANGES = "Original ↔ Zoo's Changes" // TODO: https://github.com/cline/cline/pull/3354 +/** + * Write a file so readers only ever observe the previous bytes or the complete + * new bytes: the content goes to a sibling staging file first and is renamed + * into place (same directory, so the rename is atomic on one volume). + */ +async function writeFileAtomically(absolutePath: string, content: string): Promise { + // Derive the sibling staging path with plain string operations: this helper runs + // under unit tests that mock the "path" module down to the few functions the + // provider itself uses. + const separatorIndex = Math.max(absolutePath.lastIndexOf("/"), absolutePath.lastIndexOf("\\")) + const directory = separatorIndex >= 0 ? absolutePath.slice(0, separatorIndex) : "." + const baseName = separatorIndex >= 0 ? absolutePath.slice(separatorIndex + 1) : absolutePath + const stagingPath = `${directory}/.${baseName}.${process.pid}.${Date.now()}.staging` + try { + await fs.writeFile(stagingPath, content, "utf-8") + await fs.rename(stagingPath, absolutePath) + } catch (error) { + await fs.rm(stagingPath, { force: true }).catch(() => {}) + throw error + } +} + export class DiffViewProvider { // Properties to store the results of saveChanges newProblemsMessage?: string @@ -1154,9 +1176,10 @@ export class DiffViewProvider { // Get diagnostics before editing the file this.preDiagnostics = vscode.languages.getDiagnostics() - // Write the content directly to the file + // Write the content directly to the file, atomically: a crash mid-write must + // not leave the destination truncated. await createDirectoriesForFile(absolutePath) - await fs.writeFile(absolutePath, content, "utf-8") + await writeFileAtomically(absolutePath, content) // Open the document to ensure diagnostics are loaded // When openFile is false (PREVENT_FOCUS_DISRUPTION enabled), we only open in memory diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index a740ebed5c..f3933d2bda 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -14,6 +14,8 @@ vi.mock("delay", () => ({ vi.mock("fs/promises", () => ({ readFile: vi.fn().mockResolvedValue("file content"), writeFile: vi.fn().mockResolvedValue(undefined), + rename: vi.fn().mockResolvedValue(undefined), + rm: vi.fn().mockResolvedValue(undefined), access: vi.fn().mockResolvedValue(undefined), })) @@ -791,9 +793,14 @@ describe("DiffViewProvider", () => { const result = await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 2000) - // Verify file was written + // Verify the content was written through an atomic staging file + rename const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + expect(fs.writeFile).toHaveBeenCalledWith(expect.stringContaining(".staging"), "new content", "utf-8") + const stagingPath = (fs.writeFile as unknown as { mock: { calls: [string, string, string][] } }).mock.calls.find( + (call) => call[1] === "new content", + )?.[0] + expect(stagingPath).toContain(".test.ts.") + expect(fs.rename).toHaveBeenCalledWith(stagingPath, `${mockCwd}/test.ts`) // Verify file was opened without focus expect(vscode.window.showTextDocument).toHaveBeenCalledWith( @@ -814,9 +821,14 @@ describe("DiffViewProvider", () => { it("should not open file when openWithoutFocus is false", async () => { await diffViewProvider.saveDirectly("test.ts", "new content", false, true, 1000) - // Verify file was written + // Verify the content was written through an atomic staging file + rename const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + expect(fs.writeFile).toHaveBeenCalledWith(expect.stringContaining(".staging"), "new content", "utf-8") + const stagingPath = (fs.writeFile as unknown as { mock: { calls: [string, string, string][] } }).mock.calls.find( + (call) => call[1] === "new content", + )?.[0] + expect(stagingPath).toContain(".test.ts.") + expect(fs.rename).toHaveBeenCalledWith(stagingPath, `${mockCwd}/test.ts`) // Verify file was NOT opened expect(vscode.window.showTextDocument).not.toHaveBeenCalled() @@ -829,9 +841,14 @@ describe("DiffViewProvider", () => { await diffViewProvider.saveDirectly("test.ts", "new content", true, false, 1000) - // Verify file was written + // Verify the content was written through an atomic staging file + rename const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + expect(fs.writeFile).toHaveBeenCalledWith(expect.stringContaining(".staging"), "new content", "utf-8") + const stagingPath = (fs.writeFile as unknown as { mock: { calls: [string, string, string][] } }).mock.calls.find( + (call) => call[1] === "new content", + )?.[0] + expect(stagingPath).toContain(".test.ts.") + expect(fs.rename).toHaveBeenCalledWith(stagingPath, `${mockCwd}/test.ts`) // Verify delay was NOT called expect(mockDelay).not.toHaveBeenCalled() From 4b44f10be09ecc1fcb2837b516b92ec31bb90ef8 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:53:22 +0800 Subject: [PATCH 24/29] fix(tools): preserve destination identity and permissions in the atomic write paths Security Boundaries (CodeRabbit pre-merge check): writing the staged bytes with fs.writeFile defaults and renaming them over the destination widened permissions (a 0600 or executable file came back with process defaults) and replaced symlinks with regular files. - Move the atomic writer into src/integrations/editor/atomicWrite.ts and resolve the destination first: a symlink is updated through its target instead of being replaced, the resolved target's mode is applied to the staging file (masked to permission bits), and ownership is preserved where permitted. - ApplyPatchTool's move staging now captures and re-applies the destination mode before the atomic rename. - Rollback message precedence: when the destination existed and its restore failed, the original bytes are still at the backup path - report that path and skip the recovery copy of the new content; only a move with no pre-move destination keeps the approved content as a recovery artifact. Coverage: new atomicWrite.spec (new file, executable mode, symlink target - POSIX lanes), a saveDirectly rename-failure test (same error rethrown, staging removed with force), and the move test now pins the destination mode reaching the staging file. 131 tests pass (+2 POSIX-only skipped on Windows), eslint and tsc clean. --- src/core/tools/ApplyPatchTool.ts | 53 +++++++++++---- .../__tests__/applyPatchTool.execute.spec.ts | 7 ++ src/integrations/editor/DiffViewProvider.ts | 23 +------ .../editor/__tests__/DiffViewProvider.spec.ts | 23 +++++++ .../editor/__tests__/atomicWrite.spec.ts | 64 +++++++++++++++++++ src/integrations/editor/atomicWrite.ts | 50 +++++++++++++++ 6 files changed, 185 insertions(+), 35 deletions(-) create mode 100644 src/integrations/editor/__tests__/atomicWrite.spec.ts create mode 100644 src/integrations/editor/atomicWrite.ts diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 6dde59dbba..9e47334510 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -509,6 +509,17 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } } + // Capture the destination mode before replacing it: the atomic staging write + // would otherwise install the process-default permissions and widen a 0600 + // or executable destination. + let destinationMode: number | undefined + if (destinationBackup !== undefined) { + destinationMode = await fs.stat(moveAbsolutePath).then( + (stat) => stat.mode, + () => undefined, + ) + } + // Stage the move so it is all-or-nothing: the pre-move destination bytes // stay on disk under a backup name, the new content is written to a // staging file and renamed into place atomically, and only then is the @@ -524,6 +535,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { let destinationCommitted = false try { await fs.writeFile(stagingPath, newContent, "utf8") + if (destinationMode !== undefined) { + await fs.chmod(stagingPath, destinationMode) + } if (destinationBackup !== undefined) { // Preserve the pre-move bytes on disk (atomic rename): the in-memory // copy alone cannot restore the destination after a crash. @@ -569,6 +583,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // a duplicated file as if the move had succeeded. let restoreFailure: unknown let recoveryPath: string | undefined + let recoveryFailure: unknown try { if (destinationBackup !== undefined) { // Atomic restore of the captured destination bytes: the rename @@ -582,18 +597,28 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { restoreFailure = restoreError } if (restoreFailure !== undefined) { - // The destination could not be restored. Persist the approved - // content under a recovery name instead of dropping it, so a - // half-applied move still has a deterministic recovery artifact. - const recoveryCandidate = path.join(moveDir, `${moveBase}.zoo-recovery-${moveStamp}`) - try { - await fs.writeFile(recoveryCandidate, newContent, "utf8") - recoveryPath = recoveryCandidate + if (destinationBackup !== undefined) { + // The original destination bytes are still on disk under the backup + // name: keep them as the recovery artifact and report where they are. + // Writing a copy of the approved new content here would hide the + // original content that this branch is supposed to preserve. console.error( - `Failed to restore pre-move state: ${restoreFailure instanceof Error ? restoreFailure.message : String(restoreFailure)}; kept the approved content at ${recoveryCandidate}`, + `Failed to restore pre-move state: ${restoreFailure instanceof Error ? restoreFailure.message : String(restoreFailure)}; the original content is preserved at ${backupPath}`, ) - } catch (recoveryError) { - console.error(`Failed to persist a move recovery copy: ${recoveryError}`) + } else { + // There was no pre-existing destination, so the approved content is + // the only thing that could be lost: persist it under a recovery name. + const recoveryCandidate = path.join(moveDir, `${moveBase}.zoo-recovery-${moveStamp}`) + try { + await fs.writeFile(recoveryCandidate, newContent, "utf8") + recoveryPath = recoveryCandidate + console.error( + `Failed to restore pre-move state: ${restoreFailure instanceof Error ? restoreFailure.message : String(restoreFailure)}; kept the approved content at ${recoveryCandidate}`, + ) + } catch (recoveryError) { + recoveryFailure = recoveryError + console.error(`Failed to persist a move recovery copy: ${recoveryError}`) + } } } // Best-effort cleanup of the staging file; the backup is gone once the @@ -604,9 +629,11 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { ? "" : ` The destination could not be restored and may still contain the new content: ${restoreFailure instanceof Error ? restoreFailure.message : String(restoreFailure)}` const recoverySuffix = - recoveryPath === undefined - ? "" - : ` The approved new content was kept at '${getReadablePath(task.cwd, path.relative(task.cwd, recoveryPath))}'.` + recoveryFailure !== undefined && recoveryPath === undefined + ? ` The original content is preserved at '${getReadablePath(task.cwd, path.relative(task.cwd, backupPath))}'.` + : recoveryPath === undefined + ? "" + : ` The approved new content was kept at '${getReadablePath(task.cwd, path.relative(task.cwd, recoveryPath))}'.` const moveErrorMessage = `Failed to delete original file after move '${relPath}': ${error instanceof Error ? error.message : String(error)}${restoreSuffix}${recoverySuffix}` await task.say("error", moveErrorMessage) pushToolResult(formatResponse.toolError(moveErrorMessage)) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 51f164f72e..58a13a73c8 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -19,6 +19,8 @@ const mockedFsPromises = vi.mocked( unlink: MockedFunction rm: MockedFunction rename: MockedFunction + stat: MockedFunction + chmod: MockedFunction writeFile: MockedFunction } }, @@ -30,6 +32,8 @@ vi.mock("fs/promises", () => ({ unlink: vi.fn().mockResolvedValue(undefined), rm: vi.fn().mockResolvedValue(undefined), rename: vi.fn().mockResolvedValue(undefined), + stat: vi.fn().mockResolvedValue({ mode: 0o755, uid: 0, gid: 0 }), + chmod: vi.fn().mockResolvedValue(undefined), mkdir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined), }, @@ -239,6 +243,9 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect.stringContaining(".backup"), expect.stringMatching(/[\\/]new\.ts$/), ) + // The replacement keeps the destination's mode instead of installing the + // process defaults on the staging file. + expect(mockedFsPromises.default.chmod).toHaveBeenCalledWith(expect.stringContaining(".staging"), 0o755) expect(mockPushToolResult).toHaveBeenCalledWith( expect.stringContaining("Failed to delete original file after move"), ) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index b74997b248..9f6ab0e987 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -14,6 +14,7 @@ import { } from "@roo-code/types" import { createDirectoriesForFile } from "../../utils/fs" +import { writeFileAtomically } from "./atomicWrite" import { arePathsEqual, getReadablePath } from "../../utils/path" import { formatResponse } from "../../core/prompts/responses" import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics" @@ -25,28 +26,6 @@ export const DIFF_VIEW_URI_SCHEME = "cline-diff" export const DIFF_VIEW_LABEL_CHANGES = "Original ↔ Zoo's Changes" // TODO: https://github.com/cline/cline/pull/3354 -/** - * Write a file so readers only ever observe the previous bytes or the complete - * new bytes: the content goes to a sibling staging file first and is renamed - * into place (same directory, so the rename is atomic on one volume). - */ -async function writeFileAtomically(absolutePath: string, content: string): Promise { - // Derive the sibling staging path with plain string operations: this helper runs - // under unit tests that mock the "path" module down to the few functions the - // provider itself uses. - const separatorIndex = Math.max(absolutePath.lastIndexOf("/"), absolutePath.lastIndexOf("\\")) - const directory = separatorIndex >= 0 ? absolutePath.slice(0, separatorIndex) : "." - const baseName = separatorIndex >= 0 ? absolutePath.slice(separatorIndex + 1) : absolutePath - const stagingPath = `${directory}/.${baseName}.${process.pid}.${Date.now()}.staging` - try { - await fs.writeFile(stagingPath, content, "utf-8") - await fs.rename(stagingPath, absolutePath) - } catch (error) { - await fs.rm(stagingPath, { force: true }).catch(() => {}) - throw error - } -} - export class DiffViewProvider { // Properties to store the results of saveChanges newProblemsMessage?: string diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index f3933d2bda..28b89a6a5a 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -17,6 +17,12 @@ vi.mock("fs/promises", () => ({ rename: vi.fn().mockResolvedValue(undefined), rm: vi.fn().mockResolvedValue(undefined), access: vi.fn().mockResolvedValue(undefined), + // The atomic writer resolves an existing destination and preserves its mode; + // the default double behaves like a brand-new file (ENOENT). + realpath: vi.fn().mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })), + stat: vi.fn().mockResolvedValue({ mode: 0o644, uid: 0, gid: 0 }), + chmod: vi.fn().mockResolvedValue(undefined), + chown: vi.fn().mockResolvedValue(undefined), })) // Mock utils @@ -818,6 +824,23 @@ describe("DiffViewProvider", () => { expect(result.finalContent).toBe("new content") }) + it("keeps the same error and cleans up the staging file when the final rename fails", async () => { + const fs = await import("fs/promises") + const renameError = Object.assign(new Error("EPERM: rename blocked"), { code: "EPERM" }) + vi.mocked(fs.rename).mockRejectedValueOnce(renameError) + + await expect(diffViewProvider.saveDirectly("test.ts", "new content", true, true, 1000)).rejects.toBe( + renameError, + ) + + const stagingPath = (fs.writeFile as unknown as { mock: { calls: [string, string, string][] } }).mock.calls.find( + (call) => call[1] === "new content", + )?.[0] + expect(renameError).toBeInstanceOf(Error) + expect(fs.rename).toHaveBeenCalledWith(stagingPath, `${mockCwd}/test.ts`) + expect(fs.rm).toHaveBeenCalledWith(stagingPath, { force: true }) + }) + it("should not open file when openWithoutFocus is false", async () => { await diffViewProvider.saveDirectly("test.ts", "new content", false, true, 1000) diff --git a/src/integrations/editor/__tests__/atomicWrite.spec.ts b/src/integrations/editor/__tests__/atomicWrite.spec.ts new file mode 100644 index 0000000000..7b74e05768 --- /dev/null +++ b/src/integrations/editor/__tests__/atomicWrite.spec.ts @@ -0,0 +1,64 @@ +// npx vitest run integrations/editor/__tests__/atomicWrite.spec.ts + +import fs from "fs/promises" +import os from "os" +import path from "path" + +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { writeFileAtomically } from "../atomicWrite" + +describe("writeFileAtomically", () => { + let dir: string + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "atomic-write-")) + }) + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }) + }) + + it("creates a new file with the complete content and no staging leftovers", async () => { + const target = path.join(dir, "new.txt") + + await writeFileAtomically(target, "hello") + + expect(await fs.readFile(target, "utf8")).toBe("hello") + expect(await fs.readdir(dir)).toEqual(["new.txt"]) + }) + + // Windows only maps the read-only attribute onto chmod, so POSIX mode bits are + // not observable there; the ubuntu lane covers this regression. + const posixOnly = it.skipIf(process.platform === "win32") + + posixOnly("preserves the destination mode for an executable file", async () => { + const target = path.join(dir, "script.sh") + await fs.writeFile(target, "old", "utf8") + await fs.chmod(target, 0o755) + + await writeFileAtomically(target, "new") + + expect(await fs.readFile(target, "utf8")).toBe("new") + const stat = await fs.stat(target) + expect(stat.mode & 0o777).toBe(0o755) + }) + + posixOnly("updates a symlink through its target without replacing the symlink", async () => { + const target = path.join(dir, "real.txt") + const link = path.join(dir, "link.txt") + await fs.writeFile(target, "old", "utf8") + try { + await fs.symlink(target, link) + } catch (error) { + // Creating symlinks needs Developer Mode or admin rights on Windows. + if ((error as NodeJS.ErrnoException).code === "EPERM") return + throw error + } + + await writeFileAtomically(link, "new") + + expect(await fs.readFile(target, "utf8")).toBe("new") + expect((await fs.lstat(link)).isSymbolicLink()).toBe(true) + }) +}) diff --git a/src/integrations/editor/atomicWrite.ts b/src/integrations/editor/atomicWrite.ts new file mode 100644 index 0000000000..4fe796098b --- /dev/null +++ b/src/integrations/editor/atomicWrite.ts @@ -0,0 +1,50 @@ +import * as fs from "fs/promises" + +/** + * Write a file so readers only ever observe the previous bytes or the complete + * new bytes: the content goes to a sibling staging file first and is renamed into + * place (same directory, so the rename is atomic on one volume). + * + * The destination is resolved first so a symlink is updated through its target + * instead of being replaced by a regular file, and the resolved target's mode (and, + * where permitted, ownership) is applied to the staging file: a fresh staging file + * carries the process defaults, which would otherwise widen a 0600 or executable + * destination. + */ +export async function writeFileAtomically(absolutePath: string, content: string, mode?: number): Promise { + let targetPath = absolutePath + let targetMode = mode + let targetOwnership: { uid: number; gid: number } | undefined + try { + targetPath = await fs.realpath(absolutePath) + const stat = await fs.stat(targetPath) + targetMode = targetMode ?? (stat.mode & 0o7777) + targetOwnership = { uid: stat.uid, gid: stat.gid } + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw error + } + } + // Derive the sibling staging path with plain string operations so the helper stays + // usable in unit tests that mock the "path" module down to a few functions. + const separatorIndex = Math.max(targetPath.lastIndexOf("/"), targetPath.lastIndexOf("\\")) + const directory = separatorIndex >= 0 ? targetPath.slice(0, separatorIndex) : "." + const baseName = separatorIndex >= 0 ? targetPath.slice(separatorIndex + 1) : targetPath + const stagingPath = `${directory}/.${baseName}.${process.pid}.${Date.now()}.staging` + try { + await fs.writeFile(stagingPath, content, "utf-8") + if (targetMode !== undefined) { + await fs.chmod(stagingPath, targetMode) + } + if (targetOwnership !== undefined) { + // Preserve ownership where the platform permits it; a chown the writer may + // not perform (EPERM without privileges) leaves the writer as owner, which is + // also the normal outcome for newly created files. + await fs.chown(stagingPath, targetOwnership.uid, targetOwnership.gid).catch(() => {}) + } + await fs.rename(stagingPath, targetPath) + } catch (error) { + await fs.rm(stagingPath, { force: true }).catch(() => {}) + throw error + } +} From 5a32d91ffe836830842360649a2911e816726bc9 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:55:59 +0800 Subject: [PATCH 25/29] fix(checkpoints): serialize per-task checkpoint saves Persistence Integrity (CodeRabbit pre-merge check): Task.initiateTaskLoop starts the task-start baseline checkpoint and only waits with Promise.race, so a later per-write save could run while the baseline is still staging or committing. checkpointSave now runs every save through a per-task promise queue, so stageAll + commit never overlap for the same task and a per-write save always waits for a still-running baseline. The queue entry is dropped once it drains so finished tasks do not leak. Coverage: a concurrency test drives two checkpointSave calls and asserts the second never reaches saveCheckpoint until the first settles; checkpoint suite 29/29, apply_patch + DiffViewProvider + atomicWrite suites 131 passed (+2 POSIX-only skipped); eslint and tsc clean. --- .../checkpoints/__tests__/checkpoint.test.ts | 28 ++++++++++++++ src/core/checkpoints/index.ts | 38 +++++++++++++++---- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/core/checkpoints/__tests__/checkpoint.test.ts b/src/core/checkpoints/__tests__/checkpoint.test.ts index 9b2fc1d1aa..9150ca9c21 100644 --- a/src/core/checkpoints/__tests__/checkpoint.test.ts +++ b/src/core/checkpoints/__tests__/checkpoint.test.ts @@ -113,6 +113,34 @@ describe("Checkpoint functionality", () => { }) describe("checkpointSave", () => { + it("serializes concurrent saves for the same task so stageAll + commit never overlap", async () => { + const order: string[] = [] + let resolveFirst: (value: { commit: string }) => void = () => {} + mockCheckpointService.saveCheckpoint + .mockImplementationOnce(() => { + order.push("first:start") + return new Promise((resolve) => { + resolveFirst = resolve + }) + }) + .mockImplementationOnce(async () => { + order.push("second:start") + return { commit: "second-commit" } + }) + + const first = checkpointSave(mockTask, false, true) + const second = checkpointSave(mockTask, false, true) + + // The second save must not reach the service while the first is still running: + // a queued save owns the stageAll + commit pair until it settles. + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(order).toEqual(["first:start"]) + + resolveFirst({ commit: "first-commit" }) + await Promise.all([first, second]) + expect(order).toEqual(["first:start", "second:start"]) + }) + it("should wait for checkpoint service initialization before saving", async () => { // Set up task with uninitialized service mockCheckpointService.isInitialized = false diff --git a/src/core/checkpoints/index.ts b/src/core/checkpoints/index.ts index 96c1930126..9a1ad89517 100644 --- a/src/core/checkpoints/index.ts +++ b/src/core/checkpoints/index.ts @@ -222,6 +222,29 @@ async function checkGitInstallation( } } +// Serialize checkpoint saves per task. saveCheckpoint() runs stageAll + commit, so two +// concurrent saves for the same task (for example the task-start baseline racing the first +// per-write save after the baseline timeout) can stage each other's writes or interleave +// commits. A per-task promise queue makes every save wait for the previous one, including +// a baseline that is still running. +const checkpointSaveQueues = new Map>() + +function enqueueCheckpointSave(taskId: string, operation: () => Promise): Promise { + const previous = checkpointSaveQueues.get(taskId) ?? Promise.resolve() + const next = previous.then(operation, operation) + const tail = next.then( + () => undefined, + () => undefined, + ) + checkpointSaveQueues.set(taskId, tail) + void tail.finally(() => { + if (checkpointSaveQueues.get(taskId) === tail) { + checkpointSaveQueues.delete(taskId) + } + }) + return next +} + export async function checkpointSave(task: Task, force = false, suppressMessage = false) { const service = await getCheckpointService(task) @@ -231,13 +254,14 @@ export async function checkpointSave(task: Task, force = false, suppressMessage TelemetryService.instance.captureCheckpointCreated(task.taskId) - // Start the checkpoint process in the background. - return service - .saveCheckpoint(`Task: ${task.taskId}, Time: ${Date.now()}`, { allowEmpty: force, suppressMessage }) - .catch((err) => { - console.error("[Task#checkpointSave] caught unexpected error, disabling checkpoints", err) - task.enableCheckpoints = false - }) + // Start the checkpoint process in the background, queued behind any save already + // running for this task so stageAll + commit never overlap. + return enqueueCheckpointSave(task.taskId, () => + service.saveCheckpoint(`Task: ${task.taskId}, Time: ${Date.now()}`, { allowEmpty: force, suppressMessage }), + ).catch((err) => { + console.error("[Task#checkpointSave] caught unexpected error, disabling checkpoints", err) + task.enableCheckpoints = false + }) } export type CheckpointRestoreOptions = { From 2e66db5d70f72182688614e0166b8e55a81f894b Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:18:34 +0800 Subject: [PATCH 26/29] fix(tools): keep staging owner-only and make partial patch mutations checkpointable Security Boundaries (pre-merge check + two review threads): every staging file is now created with mode 0o600 before any content is written, and the intended destination mode/ownership is applied afterwards, in both writeFileAtomically and the apply_patch move path. Persistence Integrity (pre-merge check): handoffPending was only raised when a handler returned true, so a handler that mutated the workspace and then failed (a move whose destination was already replaced before the source unlink failed) produced a retained write with no rollback metadata. Handlers now return { ok, mutated }: mutated is set at the first filesystem mutation, and the partial-state checkpoint uses it. Review threads: the recoverySuffix condition now names the backup path only when a pre-move destination exists (it previously claimed a preserved original for a no-destination move), and the DiffViewProvider test reads vi.mocked(fs.writeFile).mock.calls instead of a double assertion. Tests: rollback cases now assert the partial-state checkpoint is recorded (they previously asserted no checkpoint); apply_patch + DiffViewProvider + atomicWrite + checkpoints: 132 passed (+2 POSIX-only skipped); eslint/tsc clean. --- src/core/tools/ApplyPatchTool.ts | 78 +++++++++++-------- .../__tests__/applyPatchTool.execute.spec.ts | 19 +++-- .../editor/__tests__/DiffViewProvider.spec.ts | 31 ++++---- src/integrations/editor/atomicWrite.ts | 4 +- 4 files changed, 76 insertions(+), 56 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 9e47334510..89e9a338d0 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -21,6 +21,9 @@ interface ApplyPatchParams { patch: string } +/** Result of one file operation: `mutated` is true when the workspace changed even if the operation eventually failed. */ +type PatchFileResult = { ok: boolean; mutated: boolean } + export class ApplyPatchTool extends BaseTool<"apply_patch"> { readonly name = "apply_patch" as const @@ -134,7 +137,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (change.type === "add") { // Create new file - const ok = await this.handleAddFile( + const result = await this.handleAddFile( change, absolutePath, relPath, @@ -142,16 +145,16 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { callbacks, isWriteProtected, ) - handoffPending = handoffPending || ok - patchSucceeded = ok && patchSucceeded + handoffPending = handoffPending || result.mutated + patchSucceeded = result.ok && patchSucceeded } else if (change.type === "delete") { // Delete file - const ok = await this.handleDeleteFile(absolutePath, relPath, task, callbacks, isWriteProtected) - handoffPending = handoffPending || ok - patchSucceeded = ok && patchSucceeded + const result = await this.handleDeleteFile(absolutePath, relPath, task, callbacks, isWriteProtected) + handoffPending = handoffPending || result.mutated + patchSucceeded = result.ok && patchSucceeded } else if (change.type === "update") { // Update file - const ok = await this.handleUpdateFile( + const result = await this.handleUpdateFile( change, absolutePath, relPath, @@ -159,8 +162,8 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { callbacks, isWriteProtected, ) - handoffPending = handoffPending || ok - patchSucceeded = ok && patchSucceeded + handoffPending = handoffPending || result.mutated + patchSucceeded = result.ok && patchSucceeded } } @@ -224,8 +227,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, - ): Promise { + ): Promise { const { askApproval, pushToolResult } = callbacks + let mutated = false // Check if file already exists const fileExists = await fileExistsAtPath(absolutePath) @@ -235,7 +239,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const errorMessage = `File already exists: ${relPath}. Use Update File instead.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) - return false + return { ok: false, mutated } } const newContent = change.newContent || "" @@ -289,7 +293,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } pushToolResult("Changes were rejected by the user.") await task.diffViewProvider.reset() - return false + return { ok: false, mutated } } // Save the changes @@ -301,12 +305,13 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Track file edit operation await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) + mutated = true task.didEditFile = true const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, true) pushToolResult(message) await task.diffViewProvider.reset() - return true + return { ok: true, mutated } } private async handleDeleteFile( @@ -315,8 +320,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, - ): Promise { + ): Promise { const { askApproval, pushToolResult } = callbacks + let mutated = false // Check if file exists const fileExists = await fileExistsAtPath(absolutePath) @@ -326,7 +332,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const errorMessage = `File not found: ${relPath}. Cannot delete a non-existent file.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) - return false + return { ok: false, mutated } } const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) @@ -348,7 +354,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (!didApprove) { pushToolResult("Delete operation was rejected by the user.") - return false + return { ok: false, mutated } } // Delete the file @@ -358,12 +364,13 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const errorMessage = `Failed to delete file '${relPath}': ${error instanceof Error ? error.message : String(error)}` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) - return false + return { ok: false, mutated } } + mutated = true task.didEditFile = true pushToolResult(`Successfully deleted ${relPath}`) - return true + return { ok: true, mutated } } private async handleUpdateFile( @@ -373,8 +380,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, - ): Promise { + ): Promise { const { askApproval, pushToolResult } = callbacks + let mutated = false // Check if file exists const fileExists = await fileExistsAtPath(absolutePath) @@ -384,7 +392,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const errorMessage = `File not found: ${relPath}. Cannot update a non-existent file.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) - return false + return { ok: false, mutated } } const originalContent = change.originalContent || "" @@ -402,7 +410,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // nothing was written, so the whole-patch success state is kept. pushToolResult(`No changes needed for '${relPath}'`) await task.diffViewProvider.reset() - return true + return { ok: true, mutated } } // Check experiment settings @@ -448,7 +456,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } pushToolResult("Changes were rejected by the user.") await task.diffViewProvider.reset() - return false + return { ok: false, mutated } } // Handle file move if specified @@ -461,7 +469,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.say("rooignore_error", change.movePath) pushToolResult(formatResponse.rooIgnoreError(change.movePath)) await task.diffViewProvider.reset() - return false + return { ok: false, mutated } } // Check if destination path is write-protected @@ -473,7 +481,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) await task.diffViewProvider.reset() - return false + return { ok: false, mutated } } // Check if destination path is outside workspace @@ -485,7 +493,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) await task.diffViewProvider.reset() - return false + return { ok: false, mutated } } // Capture the destination state before either supported write path: a @@ -505,7 +513,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.say("error", inspectErrorMessage) pushToolResult(formatResponse.toolError(inspectErrorMessage)) await task.diffViewProvider.reset() - return false + return { ok: false, mutated } } } @@ -534,7 +542,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { let destinationCommitted = false try { - await fs.writeFile(stagingPath, newContent, "utf8") + // Owner-only from creation: the staged content must never be readable by + // others, even between the write and the rename. + await fs.writeFile(stagingPath, newContent, { encoding: "utf8", mode: 0o600 }) if (destinationMode !== undefined) { await fs.chmod(stagingPath, destinationMode) } @@ -545,6 +555,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } await fs.rename(stagingPath, moveAbsolutePath) destinationCommitted = true + // The destination content changed: even a later failed source unlink (which + // rolls the destination back) is a workspace mutation the checkpoint must see. + mutated = true if (isPreventFocusDisruptionEnabled) { // Diagnostics/bookkeeping only: the bytes were committed above, and // saveDirectly writes the same content through the same atomic write. @@ -570,7 +583,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.say("error", writeErrorMessage) pushToolResult(formatResponse.toolError(writeErrorMessage)) await task.diffViewProvider.reset() - return false + return { ok: false, mutated } } // Delete the original file @@ -583,7 +596,6 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // a duplicated file as if the move had succeeded. let restoreFailure: unknown let recoveryPath: string | undefined - let recoveryFailure: unknown try { if (destinationBackup !== undefined) { // Atomic restore of the captured destination bytes: the rename @@ -616,7 +628,6 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { `Failed to restore pre-move state: ${restoreFailure instanceof Error ? restoreFailure.message : String(restoreFailure)}; kept the approved content at ${recoveryCandidate}`, ) } catch (recoveryError) { - recoveryFailure = recoveryError console.error(`Failed to persist a move recovery copy: ${recoveryError}`) } } @@ -629,7 +640,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { ? "" : ` The destination could not be restored and may still contain the new content: ${restoreFailure instanceof Error ? restoreFailure.message : String(restoreFailure)}` const recoverySuffix = - recoveryFailure !== undefined && recoveryPath === undefined + destinationBackup !== undefined && restoreFailure !== undefined && recoveryPath === undefined ? ` The original content is preserved at '${getReadablePath(task.cwd, path.relative(task.cwd, backupPath))}'.` : recoveryPath === undefined ? "" @@ -638,7 +649,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.say("error", moveErrorMessage) pushToolResult(formatResponse.toolError(moveErrorMessage)) await task.diffViewProvider.reset() - return false + return { ok: false, mutated } } // The move committed: drop the destination backup. @@ -653,6 +664,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } + mutated = true await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) } @@ -661,7 +673,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, false) pushToolResult(message) await task.diffViewProvider.reset() - return true + return { ok: true, mutated } } override async handlePartial(task: Task, block: ToolUse<"apply_patch">): Promise { diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 58a13a73c8..e4a8e93ef2 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -200,7 +200,9 @@ describe("ApplyPatchTool.execute - delete file success path", () => { // The approved content survives the failed rollback as an explicit recovery artifact. expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("The approved new content was kept at")) // ...and no whole-patch checkpoint is recorded for the failed move. - expect(mockedCheckpointSave).not.toHaveBeenCalled() + // The destination was written (then rolled back): that is a workspace mutation, so + // the partial-state checkpoint is recorded even though the patch reports failure. + expect(mockedCheckpointSave).toHaveBeenCalledOnce() // Restore the default readFile behavior for later tests. mockedFsPromises.default.readFile.mockResolvedValue("original file content\n") }) @@ -249,7 +251,9 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockPushToolResult).toHaveBeenCalledWith( expect.stringContaining("Failed to delete original file after move"), ) - expect(mockedCheckpointSave).not.toHaveBeenCalled() + // The destination was written (then rolled back): that is a workspace mutation, so + // the partial-state checkpoint is recorded even though the patch reports failure. + expect(mockedCheckpointSave).toHaveBeenCalledOnce() // Restore the default readFile behavior for later tests. mockedFsPromises.default.readFile.mockResolvedValue("original file content\n") }) @@ -756,11 +760,12 @@ describe("ApplyPatchTool.execute - delete file success path", () => { // path is platform-dependent (Windows resolves cwd to a drive path); // assert on the written content instead. - expect(mockedFsPromises.default.writeFile).toHaveBeenCalledWith( - expect.any(String), - "modified content\n", - "utf8", - ) + // The staging file is created owner-only; the destination mode is re-applied + // separately before the atomic rename. + expect(mockedFsPromises.default.writeFile).toHaveBeenCalledWith(expect.any(String), "modified content\n", { + encoding: "utf8", + mode: 0o600, + }) expect(mockedCheckpointSave).toHaveBeenCalledOnce() }) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 28b89a6a5a..e8b2ad6678 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -801,10 +801,11 @@ describe("DiffViewProvider", () => { // Verify the content was written through an atomic staging file + rename const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(expect.stringContaining(".staging"), "new content", "utf-8") - const stagingPath = (fs.writeFile as unknown as { mock: { calls: [string, string, string][] } }).mock.calls.find( - (call) => call[1] === "new content", - )?.[0] + expect(fs.writeFile).toHaveBeenCalledWith(expect.stringContaining(".staging"), "new content", { + encoding: "utf-8", + mode: 0o600, + }) + const stagingPath = vi.mocked(fs.writeFile).mock.calls.find((call) => call[1] === "new content")?.[0] expect(stagingPath).toContain(".test.ts.") expect(fs.rename).toHaveBeenCalledWith(stagingPath, `${mockCwd}/test.ts`) @@ -833,9 +834,7 @@ describe("DiffViewProvider", () => { renameError, ) - const stagingPath = (fs.writeFile as unknown as { mock: { calls: [string, string, string][] } }).mock.calls.find( - (call) => call[1] === "new content", - )?.[0] + const stagingPath = vi.mocked(fs.writeFile).mock.calls.find((call) => call[1] === "new content")?.[0] expect(renameError).toBeInstanceOf(Error) expect(fs.rename).toHaveBeenCalledWith(stagingPath, `${mockCwd}/test.ts`) expect(fs.rm).toHaveBeenCalledWith(stagingPath, { force: true }) @@ -846,10 +845,11 @@ describe("DiffViewProvider", () => { // Verify the content was written through an atomic staging file + rename const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(expect.stringContaining(".staging"), "new content", "utf-8") - const stagingPath = (fs.writeFile as unknown as { mock: { calls: [string, string, string][] } }).mock.calls.find( - (call) => call[1] === "new content", - )?.[0] + expect(fs.writeFile).toHaveBeenCalledWith(expect.stringContaining(".staging"), "new content", { + encoding: "utf-8", + mode: 0o600, + }) + const stagingPath = vi.mocked(fs.writeFile).mock.calls.find((call) => call[1] === "new content")?.[0] expect(stagingPath).toContain(".test.ts.") expect(fs.rename).toHaveBeenCalledWith(stagingPath, `${mockCwd}/test.ts`) @@ -866,10 +866,11 @@ describe("DiffViewProvider", () => { // Verify the content was written through an atomic staging file + rename const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(expect.stringContaining(".staging"), "new content", "utf-8") - const stagingPath = (fs.writeFile as unknown as { mock: { calls: [string, string, string][] } }).mock.calls.find( - (call) => call[1] === "new content", - )?.[0] + expect(fs.writeFile).toHaveBeenCalledWith(expect.stringContaining(".staging"), "new content", { + encoding: "utf-8", + mode: 0o600, + }) + const stagingPath = vi.mocked(fs.writeFile).mock.calls.find((call) => call[1] === "new content")?.[0] expect(stagingPath).toContain(".test.ts.") expect(fs.rename).toHaveBeenCalledWith(stagingPath, `${mockCwd}/test.ts`) diff --git a/src/integrations/editor/atomicWrite.ts b/src/integrations/editor/atomicWrite.ts index 4fe796098b..d686d18f9d 100644 --- a/src/integrations/editor/atomicWrite.ts +++ b/src/integrations/editor/atomicWrite.ts @@ -32,7 +32,9 @@ export async function writeFileAtomically(absolutePath: string, content: string, const baseName = separatorIndex >= 0 ? targetPath.slice(separatorIndex + 1) : targetPath const stagingPath = `${directory}/.${baseName}.${process.pid}.${Date.now()}.staging` try { - await fs.writeFile(stagingPath, content, "utf-8") + // Owner-only from creation: staged content must never be readable by others, + // even between this write and the final rename. + await fs.writeFile(stagingPath, content, { encoding: "utf-8", mode: 0o600 }) if (targetMode !== undefined) { await fs.chmod(stagingPath, targetMode) } From c585b1ffa1e00eeb8222e9e30e82def821671c5b Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:18:51 +0800 Subject: [PATCH 27/29] style: apply prettier formatting to the staging and mutation-tracking files --- src/core/tools/__tests__/applyPatchTool.execute.spec.ts | 4 +++- src/integrations/editor/atomicWrite.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index e4a8e93ef2..563b064003 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -198,7 +198,9 @@ describe("ApplyPatchTool.execute - delete file success path", () => { ) expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("EROFS")) // The approved content survives the failed rollback as an explicit recovery artifact. - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("The approved new content was kept at")) + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("The approved new content was kept at"), + ) // ...and no whole-patch checkpoint is recorded for the failed move. // The destination was written (then rolled back): that is a workspace mutation, so // the partial-state checkpoint is recorded even though the patch reports failure. diff --git a/src/integrations/editor/atomicWrite.ts b/src/integrations/editor/atomicWrite.ts index d686d18f9d..6bef23ec4f 100644 --- a/src/integrations/editor/atomicWrite.ts +++ b/src/integrations/editor/atomicWrite.ts @@ -18,7 +18,7 @@ export async function writeFileAtomically(absolutePath: string, content: string, try { targetPath = await fs.realpath(absolutePath) const stat = await fs.stat(targetPath) - targetMode = targetMode ?? (stat.mode & 0o7777) + targetMode = targetMode ?? stat.mode & 0o7777 targetOwnership = { uid: stat.uid, gid: stat.gid } } catch (error) { if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { From c6bcc620816f229f68cb3067e7cec66452ede970 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:30:07 +0800 Subject: [PATCH 28/29] fix(tools): track committed mutations outside the handler-return boundary Persistence Integrity (pre-merge check): a handler can commit a workspace write and then reject during later bookkeeping (for example diagnostics/pushToolWriteResult), so the outer catch saw no pending handoff and skipped both the partial-state checkpoint and the queued-message handoff. execute() now owns a committed-mutation tracker that handlers set at the write boundary (add, delete, update, and the move destination commit). The catch and finally blocks use it, so a mutation that never returned a result is still checkpointed and handed off. Regression test: an add-file patch whose write succeeds but whose pushToolWriteResult rejects must record the partial-state checkpoint, hand off queued messages, and report through handleError. apply_patch suite 31/31, affected suites 133 passed (+2 POSIX-only skipped); eslint/tsc clean. --- src/core/tools/ApplyPatchTool.ts | 27 ++++++++++++++++--- .../__tests__/applyPatchTool.execute.spec.ts | 25 +++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 89e9a338d0..14ad81bef7 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -65,6 +65,11 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // queued turn start while the checkpoint is still staging or committing. let handoffPending = false + // Committed-mutation tracker: a handler can commit a workspace write and then + // reject during later bookkeeping, so the outer catch must still persist the + // partial-state checkpoint. The flag is therefore owned here, outside the + // handler-return boundary. + const mutation = { committed: false } try { // Validate required parameters @@ -144,12 +149,20 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task, callbacks, isWriteProtected, + mutation, ) handoffPending = handoffPending || result.mutated patchSucceeded = result.ok && patchSucceeded } else if (change.type === "delete") { // Delete file - const result = await this.handleDeleteFile(absolutePath, relPath, task, callbacks, isWriteProtected) + const result = await this.handleDeleteFile( + absolutePath, + relPath, + task, + callbacks, + isWriteProtected, + mutation, + ) handoffPending = handoffPending || result.mutated patchSucceeded = result.ok && patchSucceeded } else if (change.type === "update") { @@ -161,6 +174,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task, callbacks, isWriteProtected, + mutation, ) handoffPending = handoffPending || result.mutated patchSucceeded = result.ok && patchSucceeded @@ -200,7 +214,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // checkpoint here (same default-on semantics as the branches // above) instead of letting the queued turn start with the // retained writes carried forward without rollback metadata. - if (handoffPending) { + if (handoffPending || mutation.committed) { const perWriteCheckpoints = (await task.providerRef?.deref()?.getState())?.perWriteCheckpoints if (perWriteCheckpoints !== false) { await checkpointSave(task, false, true).catch(() => {}) @@ -214,7 +228,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // never start while the patch checkpoint is still staging or // committing, and no path (disabled checkpoints, failed save, early // return, error) strands the queued messages. - if (handoffPending) { + if (handoffPending || mutation.committed) { task.processQueuedMessages() } } @@ -227,6 +241,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, + mutation: { committed: boolean }, ): Promise { const { askApproval, pushToolResult } = callbacks let mutated = false @@ -306,6 +321,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Track file edit operation await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) mutated = true + mutation.committed = true task.didEditFile = true const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, true) @@ -320,6 +336,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, + mutation: { committed: boolean }, ): Promise { const { askApproval, pushToolResult } = callbacks let mutated = false @@ -368,6 +385,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } mutated = true + mutation.committed = true task.didEditFile = true pushToolResult(`Successfully deleted ${relPath}`) return { ok: true, mutated } @@ -380,6 +398,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, + mutation: { committed: boolean }, ): Promise { const { askApproval, pushToolResult } = callbacks let mutated = false @@ -558,6 +577,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // The destination content changed: even a later failed source unlink (which // rolls the destination back) is a workspace mutation the checkpoint must see. mutated = true + mutation.committed = true if (isPreventFocusDisruptionEnabled) { // Diagnostics/bookkeeping only: the bytes were committed above, and // saveDirectly writes the same content through the same atomic write. @@ -665,6 +685,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } mutated = true + mutation.committed = true await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) } diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 563b064003..4034de3615 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -153,6 +153,31 @@ describe("ApplyPatchTool.execute - delete file success path", () => { *** End Patch` const mockedCheckpointSave = checkpointSave as MockedFunction + it("records the partial-state checkpoint when post-write bookkeeping rejects after the write", async () => { + const patch = `*** Begin Patch +*** Add File: src/new.ts ++file content +*** End Patch` + mockedFileExistsAtPath.mockResolvedValueOnce(false) + // The write commits, then a later bookkeeping call rejects before the handler can + // return: the committed mutation is tracked outside the handler-return boundary, so + // the partial-state checkpoint and the queued-message handoff must still happen. + vi.mocked(mockTask.diffViewProvider.pushToolWriteResult).mockRejectedValueOnce( + new Error("diagnostics unavailable"), + ) + + await tool.execute({ patch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockTask.processQueuedMessages).toHaveBeenCalledOnce() + expect(mockHandleError).toHaveBeenCalled() + }) + it("restores the pre-move state and fails the operation when source deletion fails", async () => { const patch = `*** Begin Patch *** Update File: src/old.ts From e2417cbd64c36a1bd7adadf0723b4f892881d724 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:59:07 +0800 Subject: [PATCH 29/29] chore: re-trigger CI after the e2e restart-phase timeout The e2e-mock lane failed in its restart phase: `[restart:verify] [createTaskWithHistoryItem] taskScheduler.schedule failed: TaskMessagesReadError: Failed to read task messages ... at /tmp/roo-vscode-e2e-restart-...` followed by 'Timeout after 30s'. This PR does not change task messages, task history, or the task scheduler (it changes apply_patch, the atomic writer, and checkpoint serialization); the same lane passed on the current main baseline and on sibling PRs. Re-run the lanes.