From abfbe7f789c00a8084901158cedb9e1ca1ac4982 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 19:59:46 +0800 Subject: [PATCH 01/48] 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 93a832906677fe35f567d7875800ddff1c2f3a52 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 23:44:12 +0800 Subject: [PATCH 02/48] feat(checkpoints): per-task change journal with torn-tail repair (B2, #1375) --- .../__tests__/changeJournal.spec.ts | 138 ++++++++++++ .../__tests__/checkpointJournal.test.ts | 206 ++++++++++++++++++ src/core/checkpoints/changeJournal.ts | 99 +++++++++ src/core/checkpoints/index.ts | 53 ++++- src/core/tools/ApplyPatchTool.ts | 162 ++++++++++---- src/core/tools/EditFileTool.ts | 9 +- src/core/tools/WriteToFileTool.ts | 27 ++- .../__tests__/applyPatchTool.execute.spec.ts | 201 ++++++++++++++++- src/core/tools/__tests__/editFileTool.spec.ts | 46 +++- .../tools/__tests__/writeToFileTool.spec.ts | 55 ++++- 10 files changed, 940 insertions(+), 56 deletions(-) create mode 100644 src/core/checkpoints/__tests__/changeJournal.spec.ts create mode 100644 src/core/checkpoints/__tests__/checkpointJournal.test.ts create mode 100644 src/core/checkpoints/changeJournal.ts diff --git a/src/core/checkpoints/__tests__/changeJournal.spec.ts b/src/core/checkpoints/__tests__/changeJournal.spec.ts new file mode 100644 index 0000000000..4f3bbda053 --- /dev/null +++ b/src/core/checkpoints/__tests__/changeJournal.spec.ts @@ -0,0 +1,138 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" + +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { appendChange, journalPath, loadChanges, type ChangeJournalEntry } from "../changeJournal" + +describe("changeJournal", () => { + const taskId = "test-task" + + let tmpRoot: string + + beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "b2-journal-")) + }) + + afterEach(async () => { + await fs.rm(tmpRoot, { recursive: true, force: true }) + }) + + function entry(overrides: Partial = {}): ChangeJournalEntry { + return { + path: "src/foo.ts", + operation: "create", + checkpointId: "abc12345", + ...overrides, + } + } + + describe("appendChange", () => { + it("writes one JSON line per entry with the documented field shape", async () => { + await appendChange(tmpRoot, taskId, entry({ operation: "create", checkpointId: "aaa" })) + + const raw = await fs.readFile(journalPath(tmpRoot, taskId), "utf8") + const lines = raw.split("\n").filter((line) => line !== "") + expect(lines).toHaveLength(1) + const parsed = JSON.parse(lines[0]) as ChangeJournalEntry + expect(parsed.path).toBe("src/foo.ts") + expect(parsed.operation).toBe("create") + expect(parsed.checkpointId).toBe("aaa") + }) + + it("appends multiple entries sequentially", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "a" })) + await appendChange(tmpRoot, taskId, entry({ checkpointId: "b" })) + + const raw = await fs.readFile(journalPath(tmpRoot, taskId), "utf8") + expect(raw.split("\n").filter((line) => line !== "")).toHaveLength(2) + }) + }) + + describe("loadChanges", () => { + it("returns [] for an absent journal file", async () => { + expect(await loadChanges(tmpRoot, taskId)).toEqual([]) + }) + + it("returns [] for an empty journal file", async () => { + await fs.mkdir(path.dirname(journalPath(tmpRoot, taskId)), { recursive: true }) + await fs.writeFile(journalPath(tmpRoot, taskId), "") + + expect(await loadChanges(tmpRoot, taskId)).toEqual([]) + }) + + it("parses all entries in order with a clean tail", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "x" })) + await appendChange(tmpRoot, taskId, entry({ checkpointId: "y" })) + await appendChange(tmpRoot, taskId, entry({ checkpointId: "z" })) + + const result = await loadChanges(tmpRoot, taskId) + expect(result).toHaveLength(3) + expect(result[0].checkpointId).toBe("x") + expect(result[1].checkpointId).toBe("y") + expect(result[2].checkpointId).toBe("z") + }) + + it("parses a journal whose final line has no trailing newline", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "ok" })) + + // Rewrite the file without the trailing newline of the last line. + const filePath = journalPath(tmpRoot, taskId) + const content = (await fs.readFile(filePath, "utf8")).replace(/\n$/, "") + await fs.writeFile(filePath, content) + + const result = await loadChanges(tmpRoot, taskId) + expect(result).toHaveLength(1) + expect(result[0].checkpointId).toBe("ok") + }) + + it("discards a torn final line and returns the complete entries", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "ok" })) + + // Append a second line truncated mid-content, with no trailing newline. + await fs.appendFile(journalPath(tmpRoot, taskId), '{"path":"src/half.ts","operation":"upd') + + const result = await loadChanges(tmpRoot, taskId) + expect(result).toHaveLength(1) + expect(result[0].checkpointId).toBe("ok") + }) + + it("skips a corrupt middle line and still loads the later valid entries", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "ok" })) + + // Corrupt the first line in place, then append a valid entry after it. + await fs.writeFile( + journalPath(tmpRoot, taskId), + '"{"path":"src/corrupt.ts","operation":"update"\n' + JSON.stringify(entry({ checkpointId: "after" })) + "\n", + ) + + const result = await loadChanges(tmpRoot, taskId) + expect(result).toHaveLength(1) + expect(result[0].checkpointId).toBe("after") + }) + + it("does not throw when the entire journal is torn", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "first" })) + + // Truncate to a single character — definitely invalid JSON. + await fs.writeFile(journalPath(tmpRoot, taskId), "{") + + expect(await loadChanges(tmpRoot, taskId)).toEqual([]) + }) + + it("includes diffStats when present", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "s", diffStats: { additions: 5, deletions: 2 } })) + + const result = await loadChanges(tmpRoot, taskId) + expect(result[0].diffStats).toEqual({ additions: 5, deletions: 2 }) + }) + + it("omits diffStats when not provided", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "n" })) + + const result = await loadChanges(tmpRoot, taskId) + expect(result[0].diffStats).toBeUndefined() + }) + }) +}) diff --git a/src/core/checkpoints/__tests__/checkpointJournal.test.ts b/src/core/checkpoints/__tests__/checkpointJournal.test.ts new file mode 100644 index 0000000000..5134c3a484 --- /dev/null +++ b/src/core/checkpoints/__tests__/checkpointJournal.test.ts @@ -0,0 +1,206 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" + +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest" + +import type { Task } from "../../task/Task" +import { loadChanges } from "../changeJournal" +import { checkpointSave, type CheckpointWriteInfo } from "../index" + +// Mock the VS Code API surface (index.ts imports vscode at module level). +vi.mock("vscode", () => ({ + window: { + showErrorMessage: vi.fn(), + showInformationMessage: vi.fn(), + createTextEditorDecorationType: vi.fn(() => ({})), + }, + Uri: { + file: vi.fn((p: string) => ({ fsPath: p })), + parse: vi.fn((uri: string) => ({ with: vi.fn(() => ({})) })), + }, + commands: { + executeCommand: vi.fn(), + }, +})) + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureCheckpointCreated: vi.fn(), + captureCheckpointRestored: vi.fn(), + captureCheckpointDiffed: vi.fn(), + }, + }, +})) + +vi.mock("../../../utils/path", () => ({ + getWorkspacePath: vi.fn(() => "/test/workspace"), +})) + +vi.mock("../../../utils/git", () => ({ + checkGitInstalled: vi.fn().mockResolvedValue(true), +})) + +vi.mock("../../../i18n", () => ({ + t: vi.fn((key: string) => key), +})) + +vi.mock("p-wait-for", () => ({ + default: vi.fn(), +})) + +// The real service would require a git repo; the checkpointSave under test +// only needs the pre-initialized service on the task. +vi.mock("../../../services/checkpoints") + +const TASK_ID = "journal-test-task" +const COMMIT = "test-commit-hash" + +interface ServiceLike { + isInitialized: boolean + saveCheckpoint: (...args: unknown[]) => Promise +} + +interface ProviderLike { + context: { globalStorageUri: { fsPath: string } } + log: (...args: unknown[]) => void + postMessageToWebview: (...args: unknown[]) => void +} + +interface TaskLike { + taskId: string + enableCheckpoints: boolean + checkpointService: ServiceLike + checkpointServiceInitializing: boolean + providerRef: { deref: () => ProviderLike | undefined } +} + +describe("checkpointSave change-journal wiring (B2)", () => { + let tmpStorageDir: string + let saveCheckpointSpy: Mock + let mockProvider: ProviderLike + let mockTask: TaskLike + const write: CheckpointWriteInfo = { path: "src/foo.ts", operation: "create", diffStats: { additions: 3, deletions: 0 } } + + beforeEach(async () => { + tmpStorageDir = await fs.mkdtemp(path.join(os.tmpdir(), "b2-journal-wiring-")) + saveCheckpointSpy = vi.fn().mockResolvedValue({ commit: COMMIT }) + mockProvider = { + context: { globalStorageUri: { fsPath: tmpStorageDir } }, + log: vi.fn(), + postMessageToWebview: vi.fn(), + } + // Structural test double for Task (the class is not instantiated at + // this unit layer); the cast is safe because the fields checkpointSave + // reads are exactly these. + mockTask = { + taskId: TASK_ID, + enableCheckpoints: true, + checkpointService: { isInitialized: true, saveCheckpoint: saveCheckpointSpy }, + checkpointServiceInitializing: false, + providerRef: { deref: () => mockProvider }, + } + }) + + afterEach(async () => { + vi.restoreAllMocks() + await fs.rm(tmpStorageDir, { recursive: true, force: true }) + }) + + it("appends exactly one journal line referencing the B1 checkpoint id for a per-write save", async () => { + await checkpointSave(mockTask as Task, false, true, write) + + const entries = await loadChanges(tmpStorageDir, TASK_ID) + expect(entries).toHaveLength(1) + expect(entries[0]).toEqual({ + path: "src/foo.ts", + operation: "create", + checkpointId: COMMIT, + diffStats: { additions: 3, deletions: 0 }, + }) + + // The raw file holds exactly one JSON line. + const journalFile = path.join(tmpStorageDir, "tasks", TASK_ID, "checkpoints", "changes.jsonl") + const raw = await fs.readFile(journalFile, "utf8") + expect(raw.split("\n").filter((line) => line !== "")).toHaveLength(1) + }) + + it("omits diffStats in the journal entry when not provided", async () => { + await checkpointSave(mockTask as Task, false, true, { path: "src/bar.ts", operation: "update" }) + + const entries = await loadChanges(tmpStorageDir, TASK_ID) + expect(entries).toHaveLength(1) + expect(entries[0].path).toBe("src/bar.ts") + expect(entries[0].operation).toBe("update") + expect(entries[0].checkpointId).toBe(COMMIT) + expect(entries[0].diffStats).toBeUndefined() + }) + + it("does not write a journal entry for non-write checkpoint saves (task-start baseline)", async () => { + await checkpointSave(mockTask as Task) + + const journalFile = path.join(tmpStorageDir, "tasks", TASK_ID, "checkpoints", "changes.jsonl") + await expect(fs.stat(journalFile)).rejects.toThrow() + expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) + }) + + it("appends one entry per file change for a multi-file write (apply-patch shape)", async () => { + await checkpointSave(mockTask as Task, false, true, [ + { path: "src/a.ts", operation: "create" }, + { path: "src/b.ts", operation: "update" }, + { path: "src/c.ts", operation: "delete" }, + ]) + + const entries = await loadChanges(tmpStorageDir, TASK_ID) + expect(entries).toHaveLength(3) + // Every entry references the single checkpoint of the whole patch. + expect(entries.map((entry) => entry.checkpointId)).toEqual([COMMIT, COMMIT, COMMIT]) + expect(entries.map((entry) => entry.path)).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]) + expect(entries.map((entry) => entry.operation)).toEqual(["create", "update", "delete"]) + }) + + it("keeps the existing error-swallowing behavior and skips the journal on save failure", async () => { + saveCheckpointSpy.mockRejectedValueOnce(new Error("git exploded")) + + await expect(checkpointSave(mockTask as Task, false, true, write)).resolves.toBeUndefined() + expect(mockTask.enableCheckpoints).toBe(false) + expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) + }) + + it("does not write a journal entry when the checkpoint save is a no-op (empty commit)", async () => { + saveCheckpointSpy.mockResolvedValueOnce(undefined) + + await checkpointSave(mockTask as Task, false, true, write) + + expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) + expect(mockTask.enableCheckpoints).toBe(true) + }) + + it("does not crash when the provider has no globalStorageDir", async () => { + mockTask.providerRef = { deref: () => undefined } + + await expect(checkpointSave(mockTask as Task, false, true, write)).resolves.toMatchObject({ commit: COMMIT }) + expect(mockTask.enableCheckpoints).toBe(true) + expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) + }) + + it("logs and continues when the journal cannot be written (checkpoints stay enabled)", async () => { + // Block the per-task checkpoint dir so the journal mkdir/append fails. + const taskDir = path.join(tmpStorageDir, "tasks", TASK_ID) + await fs.mkdir(taskDir, { recursive: true }) + await fs.writeFile(path.join(taskDir, "checkpoints"), "blocker") + + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined) + + // The journal failure is swallowed (logged, not rethrown), so the + // checkpoint result still resolves exactly as without journaling. + await expect(checkpointSave(mockTask as Task, false, true, write)).resolves.toMatchObject({ commit: COMMIT }) + + expect(mockTask.enableCheckpoints).toBe(true) + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("failed to append change journal entry"), + expect.anything(), + ) + }) +}) diff --git a/src/core/checkpoints/changeJournal.ts b/src/core/checkpoints/changeJournal.ts new file mode 100644 index 0000000000..88983b3663 --- /dev/null +++ b/src/core/checkpoints/changeJournal.ts @@ -0,0 +1,99 @@ +import fs from "fs/promises" +import * as path from "path" + +/** + * A single entry in the per-task change journal (changes.jsonl). + * + * One line is appended for every successful file write that goes through a + * B1 per-write checkpoint hook. WriteToFileTool and EditFileTool emit one + * entry per write; ApplyPatchTool emits one entry per file change of a fully + * successful patch — those entries all reference the single B1 checkpoint + * that the patch's post-loop hook saves for the whole patch. The task-start + * baseline never produces an entry (it is not a file write). + */ +export interface ChangeJournalEntry { + /** The file path as the tool knows it (relative to task cwd). */ + path: string + /** "create" | "update" | "delete" — derived from what the tool did. */ + operation: "create" | "update" | "delete" + /** The B1 checkpoint commit SHA for this write (from checkpointSave result). */ + checkpointId: string + /** { additions, deletions } from the approval diff; null/omit when not computable. */ + diffStats?: { additions: number; deletions: number } +} + +const JOURNAL_FILENAME = "changes.jsonl" + +/** Derive the per-task checkpoint directory from globalStorageDir and taskId. */ +function taskCheckpointDir(globalStorageDir: string, taskId: string): string { + return path.join(globalStorageDir, "tasks", taskId, "checkpoints") +} + +/** Journal file path for a given task. */ +export function journalPath(globalStorageDir: string, taskId: string): string { + return path.join(taskCheckpointDir(globalStorageDir, taskId), JOURNAL_FILENAME) +} + +/** + * Append one change-journal entry to the per-task changes.jsonl file. + * + * Uses appendFile so each write is a single syscall — minimal torn-write risk. + * Creates parent directories if they don't exist yet (e.g. first checkpoint). + */ +export async function appendChange( + globalStorageDir: string, + taskId: string, + entry: ChangeJournalEntry, +): Promise { + const filePath = journalPath(globalStorageDir, taskId) + await fs.mkdir(path.dirname(filePath), { recursive: true }) + const line = JSON.stringify(entry) + "\n" + await fs.appendFile(filePath, line) +} + +/** + * Load all change-journal entries for a task. + * + * Torn-tail repair: if the final line is truncated (JSON.parse fails), it is + * silently discarded. The rest of the file is returned in order. An absent + * or empty journal returns []. + */ +export async function loadChanges( + globalStorageDir: string, + taskId: string, +): Promise { + const filePath = journalPath(globalStorageDir, taskId) + + let content: string + try { + content = await fs.readFile(filePath, "utf8") + } catch { + // File absent or unreadable → empty journal. + return [] + } + + if (!content.trim()) { + return [] + } + + const lines = content.split("\n") + // Remove trailing empty line from a file that ends with \n. + if (lines[lines.length - 1] === "") { + lines.pop() + } + + const entries: ChangeJournalEntry[] = [] + for (let i = 0; i < lines.length; i++) { + try { + entries.push(JSON.parse(lines[i]) as ChangeJournalEntry) + } catch { + // A corrupt line before the final line (e.g. a partially flushed + // append) must not hide the valid entries after it. The final line + // is still discarded as a torn tail — `continue` at the last index + // ends the loop either way. + continue + } + } + + return entries +} diff --git a/src/core/checkpoints/index.ts b/src/core/checkpoints/index.ts index 26a137b939..bcfafb5cd6 100644 --- a/src/core/checkpoints/index.ts +++ b/src/core/checkpoints/index.ts @@ -16,6 +16,8 @@ import { DIFF_VIEW_URI_SCHEME } from "../../integrations/editor/DiffViewProvider import { CheckpointServiceOptions, RepoPerTaskCheckpointService } from "../../services/checkpoints" +import { appendChange, ChangeJournalEntry } from "./changeJournal" + const WARNING_THRESHOLD_MS = 5000 function sendCheckpointInitWarn(task: Task, type?: "WAIT_TIMEOUT" | "INIT_TIMEOUT", timeout?: number) { @@ -209,7 +211,28 @@ async function checkGitInstallation( } } -export async function checkpointSave(task: Task, force = false, suppressMessage = false) { +/** + * Write metadata for the per-task change journal (B2). + * + * `path` is the file path as the tool knows it (relative to the task cwd), + * consistent with what the B1 per-write checkpoint hooks see. `diffStats` is + * the { additions, deletions } pair from the approval diff when it was + * computable; omitted otherwise. ApplyPatchTool passes one entry per file + * change of a fully successful patch (an array), all sharing the single + * checkpoint the patch's post-loop hook saves. + */ +export type CheckpointWriteInfo = { + path: string + operation: "create" | "update" | "delete" + diffStats?: { additions: number; deletions: number } +} + +export async function checkpointSave( + task: Task, + force = false, + suppressMessage = false, + write?: CheckpointWriteInfo | CheckpointWriteInfo[], +) { const service = await getCheckpointService(task) if (!service) { @@ -221,6 +244,34 @@ export async function checkpointSave(task: Task, force = false, suppressMessage // Start the checkpoint process in the background. return service .saveCheckpoint(`Task: ${task.taskId}, Time: ${Date.now()}`, { allowEmpty: force, suppressMessage }) + .then(async (result) => { + // B2: record successful file writes in the per-task change journal. + // Only a real commit produces an entry (an empty or failed save + // resolves to undefined / rejects), and non-write checkpoint calls + // (e.g. the task-start baseline) pass no `write` value at all. + if (result?.commit && write) { + const globalStorageDir = task.providerRef.deref()?.context.globalStorageUri.fsPath + if (globalStorageDir) { + const writes = Array.isArray(write) ? write : [write] + // Append sequentially so journal lines preserve write order. A + // journal failure is logged here and never propagates to the + // checkpoint error handler (checkpoints stay enabled). + try { + for (const w of writes) { + await appendChange(globalStorageDir, task.taskId, { + path: w.path, + operation: w.operation, + checkpointId: result.commit, + ...(w.diffStats ? { diffStats: w.diffStats } : {}), + }) + } + } catch (err) { + console.error("[Task#checkpointSave] failed to append change journal entry", err) + } + } + } + return result + }) .catch((err) => { console.error("[Task#checkpointSave] caught unexpected error, disabling checkpoints", err) task.enableCheckpoints = false diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index f42a4ebf03..dccf51891f 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -21,6 +21,18 @@ interface ApplyPatchParams { patch: string } +/** + * B2: result of a single file operation within a patch. `succeeded` controls + * the whole-patch success state (and therefore the per-patch checkpoint), + * while `wrote` records whether the operation actually wrote a file — a no-op + * update must not produce a change-journal entry for a file that was never + * written. + */ +interface ApplyPatchFileOpResult { + succeeded: boolean + wrote: boolean +} + export class ApplyPatchTool extends BaseTool<"apply_patch"> { readonly name = "apply_patch" as const @@ -104,9 +116,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } // 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. + // operation succeeded (which controls the whole-patch checkpoint) and + // whether it actually wrote a file (which controls the change journal + // — a no-op update must not be journaled). A rejected approval or a + // failed local write never gets checkpointed as a success. let patchSucceeded = true + const successfulChanges: ApplyPatchFileChange[] = [] for (const change of changes) { const relPath = change.path const absolutePath = path.resolve(task.cwd, relPath) @@ -116,7 +131,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (!accessAllowed) { await task.say("rooignore_error", relPath) pushToolResult(formatResponse.rooIgnoreError(relPath)) - return + // B2 partial flush: break, not return - an earlier hunk may have + // already written a file, and those writes must still receive the + // checkpoint, journal entry, and change card. Failing the patch + // also keeps the consecutive-mistake counter from resetting. + patchSucceeded = false + break } // Check if file is write-protected @@ -124,37 +144,71 @@ 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 addResult = await this.handleAddFile(change, absolutePath, relPath, task, callbacks, isWriteProtected) + patchSucceeded = addResult.succeeded && patchSucceeded + if (addResult.wrote) { + successfulChanges.push(change) + } } else if (change.type === "delete") { // Delete file - patchSucceeded = - (await this.handleDeleteFile(absolutePath, relPath, task, callbacks, isWriteProtected)) && - patchSucceeded + const deleteResult = await this.handleDeleteFile(absolutePath, relPath, task, callbacks, isWriteProtected) + patchSucceeded = deleteResult.succeeded && patchSucceeded + if (deleteResult.wrote) { + successfulChanges.push(change) + } } else if (change.type === "update") { - // Update file - patchSucceeded = - (await this.handleUpdateFile( - change, - absolutePath, - relPath, - task, - callbacks, - isWriteProtected, - )) && patchSucceeded + // Update file (a no-op update succeeds without writing) + const updateResult = await this.handleUpdateFile( + change, + absolutePath, + relPath, + task, + callbacks, + isWriteProtected, + ) + patchSucceeded = updateResult.succeeded && patchSucceeded + if (updateResult.wrote) { + successfulChanges.push(change) + } } } - 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. + // Reset the consecutive-mistake counter only after a fully successful + // patch: a failed operation (missing file, rejected move, ...) increments + // the counter, and the count must survive a partially written patch so + // the auto-approval safety net still engages across consecutive failed + // patches. if (patchSucceeded) { + task.consecutiveMistakeCount = 0 + } + + // B1: one checkpoint for the whole patch (not per file). Live + // setting with default-on semantics: skip only when explicitly false. + // B3a partial flush: the checkpoint and journal are also taken when at + // least one file operation wrote, even if a later hunk of the same + // patch failed - the journal then documents exactly the subset that + // was written, and the failed operation was already reported through + // pushToolResult. A fully failed patch (nothing written) leaves no + // checkpoint behind. + if (patchSucceeded || successfulChanges.length > 0) { const perWriteCheckpoints = (await task.providerRef?.deref()?.getState())?.perWriteCheckpoints if (perWriteCheckpoints !== false) { - void checkpointSave(task, false, true).catch(() => {}) + // B2: one journal entry per file that was actually written by + // the patch (the simplest correct design for multi-file patches), + // all referencing the single checkpoint above. A no-op update + // contributes no entry because nothing was written. `movePath`, + // when present, is the file's final location. diffStats is + // omitted: the per-file approval diffs are computed inside the + // handlers and are not retained after the patch completes. + void checkpointSave( + task, + false, + true, + successfulChanges.map((change) => ({ + path: change.movePath ?? change.path, + operation: change.type === "add" ? "create" : change.type, + })), + ).catch(() => {}) } } } catch (error) { @@ -170,7 +224,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 @@ -181,7 +235,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 { succeeded: false, wrote: false } } const newContent = change.newContent || "" @@ -235,7 +289,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } pushToolResult("Changes were rejected by the user.") await task.diffViewProvider.reset() - return false + return { succeeded: false, wrote: false } } // Save the changes @@ -253,7 +307,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { pushToolResult(message) await task.diffViewProvider.reset() task.processQueuedMessages() - return true + return { succeeded: true, wrote: true } } private async handleDeleteFile( @@ -262,7 +316,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, - ): Promise { + ): Promise { const { askApproval, pushToolResult } = callbacks // Check if file exists @@ -273,7 +327,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 { succeeded: false, wrote: false } } const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) @@ -295,7 +349,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (!didApprove) { pushToolResult("Delete operation was rejected by the user.") - return false + return { succeeded: false, wrote: false } } // Delete the file @@ -305,13 +359,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 { succeeded: false, wrote: false } } task.didEditFile = true pushToolResult(`Successfully deleted ${relPath}`) task.processQueuedMessages() - return true + return { succeeded: true, wrote: true } } private async handleUpdateFile( @@ -321,9 +375,13 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, - ): Promise { + ): Promise { const { askApproval, pushToolResult } = callbacks + // A move reports failure when the original file cannot be deleted + // after the copy (both paths would remain on disk). + let moveSucceeded = true + // Check if file exists const fileExists = await fileExistsAtPath(absolutePath) if (!fileExists) { @@ -332,7 +390,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 { succeeded: false, wrote: false } } const originalContent = change.originalContent || "" @@ -347,10 +405,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { 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. + // nothing was written, so the whole-patch success state is kept — + // but `wrote` stays false so the change journal does not document a + // write that never happened. pushToolResult(`No changes needed for '${relPath}'`) await task.diffViewProvider.reset() - return true + return { succeeded: true, wrote: false } } // Check experiment settings @@ -396,7 +456,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } pushToolResult("Changes were rejected by the user.") await task.diffViewProvider.reset() - return false + return { succeeded: false, wrote: false } } // Handle file move if specified @@ -409,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 { succeeded: false, wrote: false } } // Check if destination path is write-protected @@ -421,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 { succeeded: false, wrote: false } } // Check if destination path is outside workspace @@ -433,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 { succeeded: false, wrote: false } } // Save new content to the new path @@ -452,11 +512,19 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await fs.writeFile(moveAbsolutePath, newContent, "utf8") } - // Delete the original file + // Delete the original file. A failed deletion leaves both paths on + // disk, so the move must be reported as a failure rather than + // checkpointed and journaled as a completed move. try { await fs.unlink(absolutePath) } catch (error) { + moveSucceeded = false console.error(`Failed to delete original file after move: ${error}`) + task.consecutiveMistakeCount++ + task.recordToolError("apply_patch") + const errorMessage = `Move of '${relPath}' to '${change.movePath}' failed: could not delete the original file.` + await task.say("error", errorMessage) + pushToolResult(formatResponse.toolError(errorMessage)) } await task.fileContextTracker.trackFileContext(change.movePath, "roo_edited" as RecordSource) @@ -477,7 +545,13 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { pushToolResult(message) await task.diffViewProvider.reset() task.processQueuedMessages() - return true + if (!moveSucceeded) { + // The destination file was written on disk before the source + // deletion failed, so the write must still be checkpointed and + // journaled; the move itself is reported as failed. + return { succeeded: false, wrote: true } + } + return { succeeded: true, wrote: 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 0a3cb5d2e8..87a010e90c 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -466,7 +466,14 @@ export class EditFileTool extends BaseTool<"edit_file"> { pushToolResult(message + replacementInfo) if (perWriteCheckpoints) { - void checkpointSave(task, false, true).catch(() => {}) + // B2: the change-journal entry for this edit is appended inside + // checkpointSave (the hook stays a single call site), keyed by the + // checkpoint commit that call produces. + void checkpointSave(task, false, true, { + path: relPath, + operation: isNewFile ? "create" : "update", + diffStats: diffStats ? { additions: diffStats.added, deletions: diffStats.removed } : undefined, + }).catch(() => {}) } await task.diffViewProvider.reset() diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index cf1500a510..5154af860d 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -13,7 +13,7 @@ import { getReadablePath } from "../../utils/path" 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 { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff, type DiffStats } from "../diff/stats" import { checkpointSave } from "../checkpoints" import type { ToolUse } from "../../shared/tools" @@ -110,6 +110,10 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, ) + // B2: the approval-diff stats for the write, shared by both the + // approval message and the change-journal entry below. + let approvalDiffStats: DiffStats | null = null + if (isPreventFocusDisruptionEnabled) { task.diffViewProvider.editType = fileExists ? "modify" : "create" if (fileExists) { @@ -123,10 +127,11 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { ? formatResponse.createPrettyPatch(relPath, task.diffViewProvider.originalContent, newContent) : convertNewFileToUnifiedDiff(newContent, relPath) unified = sanitizeUnifiedDiff(unified) + approvalDiffStats = computeDiffStats(unified) const completeMessage = JSON.stringify({ ...sharedMessageProps, content: unified, - diffStats: computeDiffStats(unified) || undefined, + diffStats: approvalDiffStats || undefined, } satisfies ClineSayTool) const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) @@ -155,10 +160,11 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { ? formatResponse.createPrettyPatch(relPath, task.diffViewProvider.originalContent, newContent) : convertNewFileToUnifiedDiff(newContent, relPath) unified = sanitizeUnifiedDiff(unified) + approvalDiffStats = computeDiffStats(unified) const completeMessage = JSON.stringify({ ...sharedMessageProps, content: unified, - diffStats: computeDiffStats(unified) || undefined, + diffStats: approvalDiffStats || undefined, } satisfies ClineSayTool) const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) @@ -182,9 +188,18 @@ 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(() => {}) + // B2: the change-journal entry for this write is appended inside + // checkpointSave (the hook stays a single call site), keyed by the + // checkpoint commit that call produces. 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, { + path: relPath, + operation: fileExists ? "update" : "create", + diffStats: approvalDiffStats + ? { additions: approvalDiffStats.added, deletions: approvalDiffStats.removed } + : undefined, + }).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..61ca2850dc 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -39,7 +39,10 @@ vi.mock("../../../utils/pathUtils", () => ({ })) vi.mock("../../checkpoints", () => ({ + getCheckpointService: vi.fn(), checkpointSave: vi.fn().mockResolvedValue(undefined), + checkpointRestore: vi.fn(), + checkpointDiff: vi.fn(), })) describe("ApplyPatchTool.execute - delete file success path", () => { @@ -152,7 +155,11 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully deleted")) expect(mockedCheckpointSave).toHaveBeenCalledOnce() - expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true) + // B2: the delete patch produces one journal write, referencing the + // single checkpoint saved for the whole patch. + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ + { path: "src/obsolete.ts", operation: "delete" }, + ]) }) it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { @@ -358,6 +365,42 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockedCheckpointSave).not.toHaveBeenCalled() }) + it("checkpoints the written subset when a later hunk is access-denied", async () => { + // Hunk 1 (src/first.ts) writes; hunk 2 (src/denied.ts) is rejected by + // validateAccess. The access-denied branch must not bypass the partial + // flush: the earlier write still receives the checkpoint/journal/card. + // Hunk 2's context matches the mocked file content so the patch + // passes pre-processing; the denial happens at the per-file access check. + const partialDenyPatch = `*** Begin Patch +*** Add File: src/first.ts ++hello +*** Update File: src/denied.ts +@@ +-original file content ++new content +*** End Patch` + const validateAccess = ( + mockTask["rooIgnoreController"] as unknown as { validateAccess: MockedFunction<() => boolean> } + ).validateAccess + validateAccess.mockReturnValueOnce(true).mockReturnValueOnce(false) + // The add target does not exist, so hunk 1 writes; fileExistsAtPath + // defaults to true and would otherwise reject the add. + mockedFileExistsAtPath.mockResolvedValueOnce(false) + + await tool.execute({ patch: partialDenyPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.say).toHaveBeenCalledWith("rooignore_error", "src/denied.ts") + // Only the first (written) hunk is checkpointed. + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask, false, true, [ + expect.objectContaining({ path: "src/first.ts", operation: "create" }), + ]) + }) + 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 = ( @@ -396,6 +439,38 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockedCheckpointSave).not.toHaveBeenCalled() }) + it("keeps the mistake count when a patch operation fails", 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, + }) + + // The failed operation incremented the counter; the end-of-loop reset + // must only run for a fully successful patch, so the count survives. + expect(mockTask.consecutiveMistakeCount).toBe(1) + }) + + it("clears the mistake count after a fully successful patch", async () => { + mockTask.consecutiveMistakeCount = 2 + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + }) + it("records a checkpoint when the move succeeds", async () => { await tool.execute({ patch: movePatch }, mockTask as Task, { askApproval: mockAskApproval, @@ -423,5 +498,129 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockPushToolResult).toHaveBeenCalledWith("File saved successfully") expect(mockedCheckpointSave).toHaveBeenCalledOnce() }) + + it("records one journal write per file change for a multi-file patch", async () => { + // src/a.ts does not exist (add); src/b.ts does (update). + mockedFileExistsAtPath.mockImplementation((filePath: string) => + Promise.resolve(!String(filePath).toLowerCase().endsWith("a.ts"))) + const multiPatch = [ + "*** Begin Patch", + "*** Add File: src/a.ts", + "+alpha", + "*** Update File: src/b.ts", + "@@", + "-original file content", + "+second content", + "*** End Patch", + ].join(String.fromCharCode(10)) + + await tool.execute({ patch: multiPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ + { path: "src/a.ts", operation: "create" }, + { path: "src/b.ts", operation: "update" }, + ]) + }) + + it("journals only the files actually written for a mixed no-op and write patch", async () => { + // src/same.ts exists and the hunk rewrites identical content (a + // no-op update); src/new.ts does not exist (a real write). + mockedFileExistsAtPath.mockImplementation((filePath: string) => + Promise.resolve(!String(filePath).toLowerCase().endsWith("new.ts")), + ) + const mixedPatch = [ + "*** Begin Patch", + "*** Update File: src/same.ts", + "@@", + "-original file content", + "+original file content", + "*** Add File: src/new.ts", + "+fresh content", + "*** End Patch", + ].join(String.fromCharCode(10)) + + await tool.execute({ patch: mixedPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // The no-op update is reported to the model... + expect(mockPushToolResult).toHaveBeenCalledWith("No changes needed for 'src/same.ts'") + // ...but the journal documents only the file that was actually + // written, even though the whole patch succeeded. + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ + { path: "src/new.ts", operation: "create" }, + ]) + }) + + it("still checkpoints the successful subset when a later hunk fails", async () => { + // src/first.ts already exists (the add fails); src/second.ts does not + // (the add writes). The whole patch fails, but the written file is + // still documented by the checkpoint and journal. + mockedFileExistsAtPath.mockImplementation((filePath: string) => + Promise.resolve(String(filePath).toLowerCase().endsWith("first.ts")), + ) + const partialPatch = [ + "*** Begin Patch", + "*** Add File: src/first.ts", + "+boom", + "*** Add File: src/second.ts", + "+fresh", + "*** End Patch", + ].join(String.fromCharCode(10)) + + await tool.execute({ patch: partialPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // The failed operation is reported to the model... + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("File already exists")) + // ...and the successful subset is checkpointed and journaled. + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ + { path: "src/second.ts", operation: "create" }, + ]) + }) + + it("reports a failed move when the original file cannot be deleted", async () => { + mockedFsPromises.default.unlink.mockRejectedValueOnce(new Error("EBUSY: resource busy")) + const movePatch = [ + "*** Begin Patch", + "*** Update File: src/old.ts", + "*** Move to: src/new-location.ts", + "@@", + "-original file content", + "+new content", + "*** End Patch", + ].join(String.fromCharCode(10)) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // The copy succeeded but the source still exists, so the move is + // reported as a failed tool error - but the destination write was + // made on disk and must still be covered by the checkpoint/journal. + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith( + mockTask as Task, + false, + true, + [expect.objectContaining({ path: "src/new-location.ts", operation: "update" })], + ) + expect(mockTask.recordToolError).toHaveBeenCalledWith("apply_patch") + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("could not delete the original file")) + }) }) }) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 5b645a0074..bb309cf849 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -8,6 +8,7 @@ import { isPathOutsideWorkspace } from "../../../utils/pathUtils" import { getReadablePath } from "../../../utils/path" import { ToolUse, ToolResponse, AskApproval, HandleError, PushToolResult } from "../../../shared/tools" import { checkpointSave } from "../../checkpoints" +import { computeDiffStats } from "../../diff/stats" import { editFileTool } from "../EditFileTool" vi.mock("fs/promises", () => ({ @@ -57,11 +58,16 @@ vi.mock("../../../utils/path", () => ({ vi.mock("../../diff/stats", () => ({ sanitizeUnifiedDiff: vi.fn((diff) => diff), - computeDiffStats: vi.fn(() => ({ additions: 1, deletions: 1 })), + // The real computeDiffStats returns { added, removed } (DiffStats) — + // keep the mock faithful to the production shape. + computeDiffStats: vi.fn(() => ({ added: 1, removed: 1 })), })) vi.mock("../../checkpoints", () => ({ + getCheckpointService: vi.fn(), checkpointSave: vi.fn().mockResolvedValue(undefined), + checkpointRestore: vi.fn(), + checkpointDiff: vi.fn(), })) vi.mock("vscode", () => ({ @@ -788,7 +794,14 @@ describe("editFileTool", () => { expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockedCheckpointSave).toHaveBeenCalledOnce() - expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask, false, true) + // B2: the write info threads the path, operation, and the approval + // diff stats into the checkpoint hook. + expect(mockedCheckpointSave).toHaveBeenCalledWith( + mockTask, + false, + true, + { path: testFilePath, operation: "update", diffStats: { additions: 1, deletions: 1 } }, + ) }) it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { @@ -815,5 +828,34 @@ describe("editFileTool", () => { expect(mockHandleError).toHaveBeenCalledWith("edit_file", expect.any(Error)) expect(mockedCheckpointSave).not.toHaveBeenCalled() }) + + it("records the checkpoint with a create operation for a new file", async () => { + await executeEditFileTool({ old_string: "", new_string: "New file content" }, { fileExists: false }) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith( + mockTask, + false, + true, + { path: testFilePath, operation: "create", diffStats: { additions: 1, deletions: 1 } }, + ) + }) + + it("omits diff stats from the checkpoint write when the diff has no stats", async () => { + // A null approval diff produces no diffStats on the journal write. + vi.mocked(computeDiffStats).mockReturnValueOnce(null) + + await executeEditFileTool({}) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith( + mockTask, + false, + true, + { path: testFilePath, operation: "update" }, + ) + }) }) }) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index f9286af36d..11224d77bc 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -9,6 +9,7 @@ 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 { formatResponse } from "../../prompts/responses" import { writeToFileTool } from "../WriteToFileTool" vi.mock("path", async () => { @@ -161,6 +162,7 @@ describe("writeToFileTool", () => { userEdits: null, finalContent: "final content", }), + saveDirectly: vi.fn().mockResolvedValue({ finalContent: "saved" }), scrollToFirstDiff: vi.fn(), updateDiagnosticSettings: vi.fn(), pushToolWriteResult: vi.fn().mockImplementation(async function ( @@ -485,7 +487,14 @@ describe("writeToFileTool", () => { await executeWriteFileTool({}) expect(mockedCheckpointSave).toHaveBeenCalledOnce() - expect(mockedCheckpointSave).toHaveBeenCalledWith(mockCline, false, true) + // B2: the write info threads the path, operation, and the approval + // diff stats (3 added lines, 0 removed) into the checkpoint hook. + expect(mockedCheckpointSave).toHaveBeenCalledWith( + mockCline, + false, + true, + { path: testFilePath, operation: "create", diffStats: { additions: 3, deletions: 0 } }, + ) }) it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { @@ -548,5 +557,49 @@ describe("writeToFileTool", () => { expect(settled).toBe(true) expect(processQueuedSpy).toHaveBeenCalledOnce() }) + + it("threads write info with approval diff stats when the prevent-focus-disruption experiment is enabled", async () => { + mockCline.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: { preventFocusDisruption: true }, + }), + }) + + await executeWriteFileTool({}) + + // The experiment branch saves directly (no diff view) and still + // journals the write through the same single checkpoint hook. + expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + testContent, + false, + true, + 1000, + ) + expect(mockedCheckpointSave).toHaveBeenCalledWith( + mockCline, + false, + true, + { path: testFilePath, operation: "create", diffStats: { additions: 3, deletions: 0 } }, + ) + }) + + it("omits diff stats from the checkpoint write when the approval diff is empty", async () => { + // Writing identical content to an existing file produces an empty + // approval diff, so the checkpoint write carries no diffStats. + vi.mocked(formatResponse.createPrettyPatch).mockReturnValueOnce("") + + await executeWriteFileTool({}, { fileExists: true }) + + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith( + mockCline, + false, + true, + { path: testFilePath, operation: "update" }, + ) + }) }) }) From 2500ab3e05fcb1ed020a3f70c2869ba3c14a8a3c Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 28 Aug 2026 01:26:27 +0800 Subject: [PATCH 03/48] feat(checkpoints): per-step change cards and changeCardDetail setting (B3a, #1375) --- packages/types/src/global-settings.ts | 16 ++ packages/types/src/message.ts | 45 ++++ packages/types/src/vscode-extension-host.ts | 3 +- .../checkpoints/__tests__/changeCard.spec.ts | 108 +++++++++ .../__tests__/checkpointJournal.test.ts | 14 +- .../__tests__/checkpointSave.spec.ts | 223 ++++++++++++++++++ src/core/checkpoints/changeCard.ts | 91 +++++++ src/core/checkpoints/index.ts | 27 ++- src/core/tools/ApplyPatchTool.ts | 88 ++++++- src/core/tools/EditFileTool.ts | 24 +- src/core/tools/WriteToFileTool.ts | 29 ++- .../__tests__/applyPatchTool.execute.spec.ts | 102 +++++++- src/core/tools/__tests__/editFileTool.spec.ts | 84 +++++-- .../tools/__tests__/writeToFileTool.spec.ts | 67 ++++-- src/core/tools/apply-patch/apply.ts | 9 + src/core/webview/ClineProvider.ts | 4 + .../webview/__tests__/ClineProvider.spec.ts | 34 +++ .../src/context/ExtensionStateContext.tsx | 1 + .../__tests__/ExtensionStateContext.spec.tsx | 18 +- 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 + 37 files changed, 996 insertions(+), 63 deletions(-) create mode 100644 src/core/checkpoints/__tests__/changeCard.spec.ts create mode 100644 src/core/checkpoints/__tests__/checkpointSave.spec.ts create mode 100644 src/core/checkpoints/changeCard.ts diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 4f21ca9607..3896c711c0 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -12,6 +12,7 @@ import { } from "./provider-settings.js" import { telemetrySettingsSchema } from "./telemetry.js" import { toolNamesSchema } from "./tool.js" +import { changeCardDetailSchema, type ChangeCardDetail } from "./message.js" import { type Keys } from "./type-fu.js" import { languagesSchema } from "./vscode.js" @@ -106,6 +107,14 @@ export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15 */ export const DEFAULT_PER_WRITE_CHECKPOINTS = true +/** + * Default detail level for per-step change cards (B3a). + * "summary" keeps cards compact (file list with +/− counts; the UI fetches + * diffs lazily); "full" carries the unified diff inline per file. + * @default "summary" + */ +export const DEFAULT_CHANGE_CARD_DETAIL: ChangeCardDetail = "summary" + /** * GlobalSettings */ @@ -213,6 +222,13 @@ export const globalSettingsSchema = z.object({ * @default true */ perWriteCheckpoints: z.boolean().optional(), + /** + * Detail level for per-step change cards: "full" includes the unified diff + * inline for every changed file, "summary" carries only the file list with + * +/− counts (diffs are fetched lazily by the UI). + * @default "summary" + */ + changeCardDetail: changeCardDetailSchema.optional(), ttsEnabled: z.boolean().optional(), ttsSpeed: z.number().optional(), diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index 28d5af82ac..b7940d00a4 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -134,6 +134,7 @@ export function isNonBlockingAsk(ask: ClineAsk): ask is NonBlockingAsk { * - `mcp_server_response`: Response received from MCP server * - `subtask_result`: Result of a completed subtask * - `checkpoint_saved`: Indicates a checkpoint has been saved + * - `change_card`: Per-step change card summarizing the files a completed tool step wrote (B3a) * - `rooignore_error`: Error related to .rooignore file processing * - `diff_error`: Error occurred while applying a diff/patch * - `condense_context`: Context condensation/summarization has started @@ -162,6 +163,7 @@ export const clineSays = [ "mcp_server_response", "subtask_result", "checkpoint_saved", + "change_card", "rooignore_error", "diff_error", "condense_context", @@ -235,6 +237,49 @@ export const contextTruncationSchema = z.object({ export type ContextTruncation = z.infer +/** + * ChangeCard + * + * Payload of the per-step change card (B3a). The extension host emits one + * `say: "change_card"` message per completed tool write step, keyed by the + * shadow-git checkpoint the step produced. The JSON payload (see + * {@link ChangeCardData}) is carried in the message `text` field, the same + * way tool approval messages carry their serialized ClineSayTool. + * + * `detail: "full"` carries the unified diff inline for every file so the UI + * can render it directly; `detail: "summary"` carries only the file list with + * +/− counts and the UI fetches diffs lazily (B3b). Auto-approved steps are + * always emitted with `detail: "summary"` regardless of the user setting. + */ +export const changeCardDetailSchema = z.enum(["full", "summary"]) + +export type ChangeCardDetail = z.infer + +export const changeCardFileSchema = z.object({ + path: z.string(), + additions: z.number(), + deletions: z.number(), + /** + * Unified diff for this file. Only present when the card was emitted with + * `detail: "full"`; summary cards leave it out to stay compact. + */ + diff: z.string().optional(), +}) + +export type ChangeCardFile = z.infer + +export const changeCardSchema = z.object({ + /** Opaque step identifier, reserved for future tool-step tracking. */ + stepId: z.string().optional(), + /** Checkpoint commit SHAs produced by the step (one per per-write checkpoint). */ + checkpointIds: z.array(z.string()), + files: z.array(changeCardFileSchema), + totalFiles: z.number(), + detail: changeCardDetailSchema, +}) + +export type ChangeCardData = z.infer + /** * ClineMessage * diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 20756d7a68..9b43a75a1d 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -5,7 +5,7 @@ import type { ProviderSettings, ProviderSettingsEntry } from "./provider-setting import type { HistoryItem } from "./history.js" import type { ModeConfig, PromptComponent } from "./mode.js" import type { Experiments } from "./experiment.js" -import type { ClineMessage, QueuedMessage } from "./message.js" +import type { ChangeCardDetail, ClineMessage, QueuedMessage } from "./message.js" import type { MarketplaceItem, MarketplaceInstalledMetadata, InstallMarketplaceItemOptions } from "./marketplace.js" import type { TodoItem } from "./todo.js" import type { CloudUserInfo, CloudOrganizationMembership, OrganizationAllowList, ShareVisibility } from "./cloud.js" @@ -349,6 +349,7 @@ export type ExtensionState = Pick< enableCheckpoints: boolean checkpointTimeout: number // Timeout for checkpoint initialization in seconds (default: 15) perWriteCheckpoints: boolean + changeCardDetail: ChangeCardDetail 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/checkpoints/__tests__/changeCard.spec.ts b/src/core/checkpoints/__tests__/changeCard.spec.ts new file mode 100644 index 0000000000..82190638a1 --- /dev/null +++ b/src/core/checkpoints/__tests__/changeCard.spec.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest" + +import type { ChangeCardData } from "@roo-code/types" + +import { + buildChangeCard, + buildChangeCardPayload, + isAutoApprovedStep, + resolveChangeCardDetail, + type ChangeCardWrite, +} from "../changeCard" + +describe("changeCard (B3a)", () => { + function write(overrides: Partial = {}): ChangeCardWrite { + return { + path: "src/a.ts", + diffStats: { additions: 2, deletions: 1 }, + diff: "--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1,2 @@\n-old\n+new-1\n+new-2", + ...overrides, + } + } + + describe("isAutoApprovedStep", () => { + it("returns false for an empty step", () => { + expect(isAutoApprovedStep([])).toBe(false) + }) + + it("returns true only when every write was auto-approved", () => { + expect(isAutoApprovedStep([write({ autoApproved: true }), write({ autoApproved: true })])).toBe(true) + expect(isAutoApprovedStep([write({ autoApproved: true }), write()])).toBe(false) + expect(isAutoApprovedStep([write()])).toBe(false) + }) + }) + + describe("resolveChangeCardDetail", () => { + it("forces summary for auto-approved steps even when the setting is full", () => { + const writes = [write({ autoApproved: true })] + expect(resolveChangeCardDetail(writes, "full")).toBe("summary") + expect(resolveChangeCardDetail(writes, undefined)).toBe("summary") + }) + + it("follows the setting for interactive steps, defaulting to summary when unset", () => { + const writes = [write()] + expect(resolveChangeCardDetail(writes, "full")).toBe("full") + expect(resolveChangeCardDetail(writes, "summary")).toBe("summary") + expect(resolveChangeCardDetail(writes, undefined)).toBe("summary") + }) + }) + + describe("buildChangeCard", () => { + it("carries the inline diff per file for full detail on a multi-file step", () => { + const card = buildChangeCard( + "sha-1", + [write(), write({ path: "src/b.ts", diffStats: { additions: 1, deletions: 0 }, diff: "+b" })], + "full", + ) + + expect(card).toEqual({ + checkpointIds: ["sha-1"], + files: [ + { + path: "src/a.ts", + additions: 2, + deletions: 1, + diff: "--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1,2 @@\n-old\n+new-1\n+new-2", + }, + { path: "src/b.ts", additions: 1, deletions: 0, diff: "+b" }, + ], + totalFiles: 2, + detail: "full", + }) + }) + + it("omits the diff per file for summary detail (lazy fetch is B3b)", () => { + const card = buildChangeCard("sha-1", [write()], "summary") + + expect(card.files).toEqual([{ path: "src/a.ts", additions: 2, deletions: 1 }]) + expect(card.files[0]).not.toHaveProperty("diff") + expect(card.detail).toBe("summary") + expect(card.totalFiles).toBe(1) + }) + + it("defaults missing diffStats to zero counts and keeps full detail without diff for a write without one", () => { + const card = buildChangeCard("sha-1", [write({ diffStats: undefined, diff: undefined })], "full") + + expect(card.files[0]).toEqual({ path: "src/a.ts", additions: 0, deletions: 0 }) + }) + }) + + describe("buildChangeCardPayload", () => { + it("resolves the detail level and builds the payload in one call", () => { + // The expectations are typed against the shared ChangeCardData + // contract in @roo-code/types, so the builder's output is checked + // against the same single source of truth the webview consumes. + // Interactive step with the full setting: diff inline. + const full: ChangeCardData = buildChangeCardPayload("sha-1", [write()], "full") + expect(full.detail).toBe("full") + expect(full.files[0].diff).toBe("--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1,2 @@\n-old\n+new-1\n+new-2") + + // Auto-approved step with the full setting: compact summary, no diff. + const compact: ChangeCardData = buildChangeCardPayload("sha-1", [write({ autoApproved: true })], "full") + expect(compact.detail).toBe("summary") + expect(compact.files[0]).not.toHaveProperty("diff") + expect(compact.checkpointIds).toEqual(["sha-1"]) + expect(compact.totalFiles).toBe(1) + }) + }) +}) diff --git a/src/core/checkpoints/__tests__/checkpointJournal.test.ts b/src/core/checkpoints/__tests__/checkpointJournal.test.ts index 5134c3a484..8671bb491c 100644 --- a/src/core/checkpoints/__tests__/checkpointJournal.test.ts +++ b/src/core/checkpoints/__tests__/checkpointJournal.test.ts @@ -66,6 +66,7 @@ interface ProviderLike { context: { globalStorageUri: { fsPath: string } } log: (...args: unknown[]) => void postMessageToWebview: (...args: unknown[]) => void + getState: () => Promise> } interface TaskLike { @@ -74,6 +75,7 @@ interface TaskLike { checkpointService: ServiceLike checkpointServiceInitializing: boolean providerRef: { deref: () => ProviderLike | undefined } + say: (...args: unknown[]) => Promise } describe("checkpointSave change-journal wiring (B2)", () => { @@ -81,7 +83,11 @@ describe("checkpointSave change-journal wiring (B2)", () => { let saveCheckpointSpy: Mock let mockProvider: ProviderLike let mockTask: TaskLike - const write: CheckpointWriteInfo = { path: "src/foo.ts", operation: "create", diffStats: { additions: 3, deletions: 0 } } + const write: CheckpointWriteInfo = { + path: "src/foo.ts", + operation: "create", + diffStats: { additions: 3, deletions: 0 }, + } beforeEach(async () => { tmpStorageDir = await fs.mkdtemp(path.join(os.tmpdir(), "b2-journal-wiring-")) @@ -90,6 +96,8 @@ describe("checkpointSave change-journal wiring (B2)", () => { context: { globalStorageUri: { fsPath: tmpStorageDir } }, log: vi.fn(), postMessageToWebview: vi.fn(), + // B3a: the card emission reads the live settings through getState. + getState: vi.fn().mockResolvedValue({}), } // Structural test double for Task (the class is not instantiated at // this unit layer); the cast is safe because the fields checkpointSave @@ -100,6 +108,10 @@ describe("checkpointSave change-journal wiring (B2)", () => { checkpointService: { isInitialized: true, saveCheckpoint: saveCheckpointSpy }, checkpointServiceInitializing: false, providerRef: { deref: () => mockProvider }, + // B3a: the card emission calls task.say; a resolved double keeps the + // test double complete instead of letting the emission take the + // error path. + say: vi.fn().mockResolvedValue(undefined), } }) diff --git a/src/core/checkpoints/__tests__/checkpointSave.spec.ts b/src/core/checkpoints/__tests__/checkpointSave.spec.ts new file mode 100644 index 0000000000..25afe4cad7 --- /dev/null +++ b/src/core/checkpoints/__tests__/checkpointSave.spec.ts @@ -0,0 +1,223 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import type { Task } from "../../task/Task" +import { journalPath } from "../changeJournal" +import { checkpointSave } from "../index" + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureCheckpointCreated: vi.fn(), + captureCheckpointRestored: vi.fn(), + captureCheckpointDiffed: vi.fn(), + }, + }, +})) + +/** + * Minimal Task double for the checkpointSave wiring tests. Only the members + * touched by getCheckpointService + checkpointSave are provided: a pre-set + * checkpointService (so no git installation happens), the provider context + * (journal dir + setting state), and say. Structural cast at the boundary, + * matching the documented test-double style of the tool specs. + */ +function makeTask(options: { saveCheckpoint?: unknown; state?: Record; enableCheckpoints?: boolean }) { + const say = vi.fn().mockResolvedValue(undefined) + // An explicit `saveCheckpoint: undefined` (a checkpoint that produced no + // commit) must be preserved as-is; only an omitted option falls back to + // the default commit result. + const saveCheckpoint = vi + .fn() + .mockResolvedValue("saveCheckpoint" in options ? options.saveCheckpoint : { commit: "sha-card-1" }) + const providerDeref = { + context: { globalStorageUri: { fsPath: globalStorageDir } }, + getState: vi.fn().mockResolvedValue(options.state ?? {}), + } + + const task = { + taskId: "task-card", + cwd: "/workspace", + enableCheckpoints: options.enableCheckpoints ?? true, + checkpointService: { + saveCheckpoint, + isInitialized: true, + }, + providerRef: { deref: vi.fn().mockReturnValue(providerDeref) }, + say, + } as unknown as Task + + return { task, say, saveCheckpoint } +} + +let globalStorageDir: string + +beforeEach(async () => { + globalStorageDir = await fs.mkdtemp(path.join(os.tmpdir(), "b3a-save-")) +}) + +afterEach(async () => { + await fs.rm(globalStorageDir, { recursive: true, force: true }) +}) + +describe("checkpointSave change-card emission (B3a)", () => { + it("emits a full-detail change card after a successful per-write checkpoint and still appends the journal", async () => { + const { task, say, saveCheckpoint } = makeTask({ state: { changeCardDetail: "full" } }) + + await checkpointSave(task, false, true, { + path: "src/a.ts", + operation: "create", + diffStats: { additions: 2, deletions: 1 }, + diff: "+line-a\n+line-b\n-old", + }) + + expect(saveCheckpoint).toHaveBeenCalledWith(expect.stringContaining("task-card"), expect.any(Object)) + + const cardCalls = say.mock.calls.filter(([type]) => type === "change_card") + expect(cardCalls).toHaveLength(1) + const [type, text, images, partial, sayOptions, _progress, options] = cardCalls[0] as unknown as [ + string, + string, + undefined, + undefined, + undefined, + undefined, + { isNonInteractive?: boolean }, + ] + expect(type).toBe("change_card") + expect(images).toBeUndefined() + expect(options).toEqual({ isNonInteractive: true }) + const card = JSON.parse(text as string) as { + checkpointIds: string[] + files: Array<{ path: string; additions: number; deletions: number; diff?: string }> + totalFiles: number + detail: string + } + expect(card.checkpointIds).toEqual(["sha-card-1"]) + expect(card.totalFiles).toBe(1) + expect(card.detail).toBe("full") + expect(card.files).toEqual([{ path: "src/a.ts", additions: 2, deletions: 1, diff: "+line-a\n+line-b\n-old" }]) + + // B2 regression: the journal entry is still appended with the commit id. + const journalRaw = await fs.readFile(journalPath(globalStorageDir, "task-card"), "utf8") + const entries = journalRaw + .split("\n") + .filter((line) => line !== "") + .map((line) => JSON.parse(line)) + expect(entries).toHaveLength(1) + expect(entries[0]).toMatchObject({ path: "src/a.ts", operation: "create", checkpointId: "sha-card-1" }) + }) + + it("emits a summary card without diffs for the default (summary) setting", async () => { + const { task, say } = makeTask({}) + + await checkpointSave(task, false, true, { + path: "src/a.ts", + operation: "update", + diffStats: { additions: 1, deletions: 0 }, + diff: "+x", + }) + + const cardCall = say.mock.calls.find(([type]) => type === "change_card") + expect(cardCall).toBeDefined() + const card = JSON.parse((cardCall as unknown as [string, string])[1]) as { + files: Array> + detail: string + } + expect(card.detail).toBe("summary") + expect(card.files[0]).not.toHaveProperty("diff") + }) + + it("emits a compact card for auto-approved steps even when the setting is full", async () => { + const { task, say } = makeTask({ state: { changeCardDetail: "full" } }) + + await checkpointSave(task, false, true, { + path: "src/a.ts", + operation: "create", + diffStats: { additions: 1, deletions: 0 }, + diff: "+x", + autoApproved: true, + }) + + const cardCall = say.mock.calls.find(([type]) => type === "change_card") + expect(cardCall).toBeDefined() + const card = JSON.parse((cardCall as unknown as [string, string])[1]) as { + files: Array> + detail: string + } + expect(card.detail).toBe("summary") + expect(card.files[0]).not.toHaveProperty("diff") + }) + + it("emits one card with all writes for a multi-file step", async () => { + const { task, say } = makeTask({ state: { changeCardDetail: "full" } }) + + await checkpointSave(task, false, true, [ + { path: "src/a.ts", operation: "create", diffStats: { additions: 2, deletions: 0 }, diff: "+a1\n+a2" }, + { path: "src/b.ts", operation: "delete", diffStats: { additions: 0, deletions: 3 } }, + ]) + + const cardCalls = say.mock.calls.filter(([type]) => type === "change_card") + expect(cardCalls).toHaveLength(1) + const card = JSON.parse((cardCalls[0] as unknown as [string, string])[1]) as { + files: Array<{ path: string; diff?: string }> + totalFiles: number + } + expect(card.totalFiles).toBe(2) + expect(card.files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]) + expect(card.files[0].diff).toBe("+a1\n+a2") + expect(card.files[1]).not.toHaveProperty("diff") + }) + + it("emits no change card for baseline checkpoints without write info", async () => { + const { task, say } = makeTask({}) + + await checkpointSave(task) + + // `say` is invoked with seven arguments, so a three-argument + // `toHaveBeenCalledWith` negative assertion can never fail; filter the + // recorded calls by type instead. + const cardCalls = say.mock.calls.filter(([type]) => type === "change_card") + expect(cardCalls).toHaveLength(0) + }) + + it("emits no change card when the checkpoint produced no commit", async () => { + const { task, say } = makeTask({ saveCheckpoint: undefined }) + + await checkpointSave(task, false, true, { path: "src/a.ts", operation: "create" }) + + const cardCalls = say.mock.calls.filter(([type]) => type === "change_card") + expect(cardCalls).toHaveLength(0) + }) + + it("emits no change card when checkpoints are disabled for the task", async () => { + const { task, say } = makeTask({ enableCheckpoints: false }) + + await checkpointSave(task, false, true, { path: "src/a.ts", operation: "create", diff: "+x" }) + + expect(say).not.toHaveBeenCalled() + }) + + it("keeps the journal append when a card emission failure occurs", async () => { + const { task, say } = makeTask({}) + say.mockImplementation(async (type: string) => { + if (type === "change_card") { + throw new Error("task aborted") + } + }) + + await checkpointSave(task, false, true, { + path: "src/a.ts", + operation: "create", + diffStats: { additions: 1, deletions: 0 }, + diff: "+x", + }) + + // The say failure is contained: the journal is still written. + const journalRaw = await fs.readFile(journalPath(globalStorageDir, "task-card"), "utf8") + expect(journalRaw).toContain("src/a.ts") + }) +}) diff --git a/src/core/checkpoints/changeCard.ts b/src/core/checkpoints/changeCard.ts new file mode 100644 index 0000000000..b13f03320d --- /dev/null +++ b/src/core/checkpoints/changeCard.ts @@ -0,0 +1,91 @@ +/** + * Per-step change card builder (B3a). + * + * The card is emitted from `checkpointSave` (index.ts) once the per-write + * checkpoint commit exists, so the payload can key the card by the real + * checkpoint ID and reuse the approval diff + stats the tool already + * computed (threaded through {@link CheckpointWriteInfo}). The card is + * informational and always emitted for write steps, including auto-approved + * ones — which always get the compact ("summary") form regardless of the + * `changeCardDetail` setting. + */ +import { DEFAULT_CHANGE_CARD_DETAIL, type ChangeCardData, type ChangeCardDetail } from "@roo-code/types" + +/** + * The write data a change card is built from. Structurally compatible with + * `CheckpointWriteInfo` (src/core/checkpoints/index.ts), minus the + * `operation` field the card does not need. + */ +export interface ChangeCardWrite { + /** The file path as the tool knows it (relative to the task cwd). */ + path: string + /** { additions, deletions } from the approval diff, when computable. */ + diffStats?: { additions: number; deletions: number } + /** The unified approval diff for this file (reused, not recomputed). */ + diff?: string + /** Whether the tool step was auto-approved (no human interaction). */ + autoApproved?: boolean +} + +/** + * Whether every write of the step was auto-approved. Empty steps are not + * auto-approved (there is nothing for the user to have skipped). + */ +export function isAutoApprovedStep(writes: readonly ChangeCardWrite[]): boolean { + return writes.length > 0 && writes.every((write) => write.autoApproved === true) +} + +/** + * Resolve the card detail level for a step: + * - auto-approved steps always get the compact "summary" card, regardless of + * the user setting (cards for steps the user never saw approving are + * informational only); + * - otherwise the `changeCardDetail` setting applies, defaulting to + * "summary" when unset. + */ +export function resolveChangeCardDetail( + writes: readonly ChangeCardWrite[], + setting: ChangeCardDetail | undefined, +): ChangeCardDetail { + if (isAutoApprovedStep(writes)) { + return "summary" + } + return setting ?? DEFAULT_CHANGE_CARD_DETAIL +} + +/** + * Build the typed change-card payload for one step (one per-write checkpoint). + * + * With `detail: "full"` each file carries its unified diff inline; with + * `detail: "summary"` the diff is omitted and the UI fetches it lazily + * (B3b). + */ +export function buildChangeCard( + checkpointId: string, + writes: readonly ChangeCardWrite[], + detail: ChangeCardDetail, +): ChangeCardData { + return { + checkpointIds: [checkpointId], + files: writes.map((write) => ({ + path: write.path, + additions: write.diffStats?.additions ?? 0, + deletions: write.diffStats?.deletions ?? 0, + ...(detail === "full" && write.diff ? { diff: write.diff } : {}), + })), + totalFiles: writes.length, + detail, + } +} + +/** + * Convenience wrapper: resolve the detail level from the step + setting, then + * build the payload. This is what `checkpointSave` calls. + */ +export function buildChangeCardPayload( + checkpointId: string, + writes: readonly ChangeCardWrite[], + setting: ChangeCardDetail | undefined, +): ChangeCardData { + return buildChangeCard(checkpointId, writes, resolveChangeCardDetail(writes, setting)) +} diff --git a/src/core/checkpoints/index.ts b/src/core/checkpoints/index.ts index bcfafb5cd6..d206767ad4 100644 --- a/src/core/checkpoints/index.ts +++ b/src/core/checkpoints/index.ts @@ -17,6 +17,7 @@ import { DIFF_VIEW_URI_SCHEME } from "../../integrations/editor/DiffViewProvider import { CheckpointServiceOptions, RepoPerTaskCheckpointService } from "../../services/checkpoints" import { appendChange, ChangeJournalEntry } from "./changeJournal" +import { buildChangeCardPayload } from "./changeCard" const WARNING_THRESHOLD_MS = 5000 @@ -225,6 +226,16 @@ export type CheckpointWriteInfo = { path: string operation: "create" | "update" | "delete" diffStats?: { additions: number; deletions: number } + /** + * The unified approval diff for this write, reused verbatim by the B3a + * change card (never recomputed). + */ + diff?: string + /** + * Whether the tool step was auto-approved (no human interaction). Auto- + * approved steps always get the compact ("summary") change card. + */ + autoApproved?: boolean } export async function checkpointSave( @@ -250,9 +261,9 @@ export async function checkpointSave( // resolves to undefined / rejects), and non-write checkpoint calls // (e.g. the task-start baseline) pass no `write` value at all. if (result?.commit && write) { + const writes = Array.isArray(write) ? write : [write] const globalStorageDir = task.providerRef.deref()?.context.globalStorageUri.fsPath if (globalStorageDir) { - const writes = Array.isArray(write) ? write : [write] // Append sequentially so journal lines preserve write order. A // journal failure is logged here and never propagates to the // checkpoint error handler (checkpoints stay enabled). @@ -269,6 +280,20 @@ export async function checkpointSave( console.error("[Task#checkpointSave] failed to append change journal entry", err) } } + + // B3a: emit the per-step change card now that the checkpoint commit + // exists. The card reuses the approval diff/stats the tool already + // computed and is always emitted (auto-approved steps included); + // a card failure is logged and never disables checkpoints. + try { + const state = await task.providerRef.deref()?.getState() + const card = buildChangeCardPayload(result.commit, writes, state?.changeCardDetail) + await task.say("change_card", JSON.stringify(card), undefined, undefined, undefined, undefined, { + isNonInteractive: true, + }) + } catch (err) { + console.error("[Task#checkpointSave] failed to emit change card", err) + } } return result }) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index dccf51891f..bba826ea4d 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -7,6 +7,7 @@ import { getReadablePath } from "../../utils/path" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { Task } from "../task/Task" import { checkpointSave } from "../checkpoints" +import { checkAutoApproval } from "../auto-approval" import { formatResponse } from "../prompts/responses" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { fileExistsAtPath } from "../../utils/fs" @@ -144,14 +145,28 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (change.type === "add") { // Create new file - const addResult = await this.handleAddFile(change, absolutePath, relPath, task, callbacks, isWriteProtected) + const addResult = await this.handleAddFile( + change, + absolutePath, + relPath, + task, + callbacks, + isWriteProtected, + ) patchSucceeded = addResult.succeeded && patchSucceeded if (addResult.wrote) { successfulChanges.push(change) } } else if (change.type === "delete") { // Delete file - const deleteResult = await this.handleDeleteFile(absolutePath, relPath, task, callbacks, isWriteProtected) + const deleteResult = await this.handleDeleteFile( + change, + absolutePath, + relPath, + task, + callbacks, + isWriteProtected, + ) patchSucceeded = deleteResult.succeeded && patchSucceeded if (deleteResult.wrote) { successfulChanges.push(change) @@ -194,21 +209,34 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const perWriteCheckpoints = (await task.providerRef?.deref()?.getState())?.perWriteCheckpoints if (perWriteCheckpoints !== false) { // B2: one journal entry per file that was actually written by - // the patch (the simplest correct design for multi-file patches), + // the patch (the simplest correct design for multi-file patches), // all referencing the single checkpoint above. A no-op update // contributes no entry because nothing was written. `movePath`, - // when present, is the file's final location. diffStats is - // omitted: the per-file approval diffs are computed inside the - // handlers and are not retained after the patch completes. - void checkpointSave( + // when present, is the file's final location. B3a: the per-file + // approval diff/stats and auto-approval state, retained by the + // handlers, feed the per-step change card. + // Awaited: a later write must not interleave with this patch's + // staging/commit/journal/change-card work. checkpointSave never + // rejects (service call wrapped in try/catch upstream). + await checkpointSave( task, false, true, successfulChanges.map((change) => ({ path: change.movePath ?? change.path, operation: change.type === "add" ? "create" : change.type, + ...(change.diffStats + ? { + diffStats: { + additions: change.diffStats.added, + deletions: change.diffStats.removed, + }, + } + : {}), + ...(change.diff ? { diff: change.diff } : {}), + ...(change.autoApproved ? { autoApproved: true } : {}), })), - ).catch(() => {}) + ) } } } catch (error) { @@ -274,6 +302,21 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { diffStats, } satisfies ClineSayTool) + // B3a: retain the approval diff/stats and auto-approval state so the + // post-loop checkpoint hook can build the per-step change card. + change.diff = sanitizedDiff + change.diffStats = diffStats + change.autoApproved = + ( + await checkAutoApproval({ + state, + cwd: task.cwd, + ask: "tool", + text: completeMessage, + isProtected: isWriteProtected, + }) + ).decision === "approve" + // Show diff view if focus disruption prevention is disabled if (!isPreventFocusDisruptionEnabled) { await task.diffViewProvider.open(relPath) @@ -311,6 +354,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } private async handleDeleteFile( + change: ApplyPatchFileChange, absolutePath: string, relPath: string, task: Task, @@ -345,6 +389,19 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { isProtected: isWriteProtected, } satisfies ClineSayTool) + // B3a: auto-approval state feeds the per-step change card (deletes have + // no diff to thread). + change.autoApproved = + ( + await checkAutoApproval({ + state: await task.providerRef.deref()?.getState(), + cwd: task.cwd, + ask: "tool", + text: completeMessage, + isProtected: isWriteProtected, + }) + ).decision === "approve" + const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) if (!didApprove) { @@ -441,6 +498,21 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { diffStats, } satisfies ClineSayTool) + // B3a: retain the approval diff/stats and auto-approval state so the + // post-loop checkpoint hook can build the per-step change card. + change.diff = sanitizedDiff + change.diffStats = diffStats + change.autoApproved = + ( + await checkAutoApproval({ + state, + cwd: task.cwd, + ask: "tool", + text: completeMessage, + isProtected: isWriteProtected, + }) + ).decision === "approve" + // Show diff view if focus disruption prevention is disabled if (!isPreventFocusDisruptionEnabled) { await task.diffViewProvider.open(relPath) diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index 87a010e90c..03c20ccee2 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -12,6 +12,7 @@ import { fileExistsAtPath } from "../../utils/fs" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import { checkpointSave } from "../../core/checkpoints" +import { checkAutoApproval } from "../auto-approval" import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -468,12 +469,29 @@ export class EditFileTool extends BaseTool<"edit_file"> { if (perWriteCheckpoints) { // B2: the change-journal entry for this edit is appended inside // checkpointSave (the hook stays a single call site), keyed by the - // checkpoint commit that call produces. - void checkpointSave(task, false, true, { + // checkpoint commit that call produces. B3a threads the approval + // diff (for the change card) and whether the step was auto- + // approved (auto-approved steps always get the compact card). + const autoApproved = + ( + await checkAutoApproval({ + state, + cwd: task.cwd, + ask: "tool", + text: completeMessage, + isProtected: isWriteProtected, + }) + ).decision === "approve" + // Awaited: a later tool block must not interleave with this edit's + // staging/commit/journal/change-card work. checkpointSave never + // rejects (service call wrapped in try/catch upstream). + await checkpointSave(task, false, true, { path: relPath, operation: isNewFile ? "create" : "update", diffStats: diffStats ? { additions: diffStats.added, deletions: diffStats.removed } : undefined, - }).catch(() => {}) + ...(sanitizedDiff ? { diff: sanitizedDiff } : {}), + ...(autoApproved ? { autoApproved: true } : {}), + }) } await task.diffViewProvider.reset() diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 5154af860d..ea9aec48f1 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -15,6 +15,7 @@ import { unescapeHtmlEntities } from "../../utils/text-normalization" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff, type DiffStats } from "../diff/stats" import { checkpointSave } from "../checkpoints" +import { checkAutoApproval } from "../auto-approval" import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -111,8 +112,12 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { ) // B2: the approval-diff stats for the write, shared by both the - // approval message and the change-journal entry below. + // approval message and the change-journal entry below. B3a also + // reuses the sanitized unified diff itself for the per-step change + // card (never recomputed). let approvalDiffStats: DiffStats | null = null + let approvalDiff = "" + let completeMessage = "" if (isPreventFocusDisruptionEnabled) { task.diffViewProvider.editType = fileExists ? "modify" : "create" @@ -128,7 +133,8 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { : convertNewFileToUnifiedDiff(newContent, relPath) unified = sanitizeUnifiedDiff(unified) approvalDiffStats = computeDiffStats(unified) - const completeMessage = JSON.stringify({ + approvalDiff = unified + completeMessage = JSON.stringify({ ...sharedMessageProps, content: unified, diffStats: approvalDiffStats || undefined, @@ -161,7 +167,8 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { : convertNewFileToUnifiedDiff(newContent, relPath) unified = sanitizeUnifiedDiff(unified) approvalDiffStats = computeDiffStats(unified) - const completeMessage = JSON.stringify({ + approvalDiff = unified + completeMessage = JSON.stringify({ ...sharedMessageProps, content: unified, diffStats: approvalDiffStats || undefined, @@ -192,13 +199,27 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { // checkpointSave (the hook stays a single call site), keyed by the // checkpoint commit that call produces. Await so the checkpoint // (staging + commit) finishes before the next queued write starts; - // otherwise two writes can collapse into one commit. + // otherwise two writes can collapse into one commit. B3a threads + // the approval diff (for the change card) and whether the step was + // auto-approved (auto-approved steps always get the compact card). + const autoApproved = + ( + await checkAutoApproval({ + state, + cwd: task.cwd, + ask: "tool", + text: completeMessage, + isProtected: isWriteProtected, + }) + ).decision === "approve" await checkpointSave(task, false, true, { path: relPath, operation: fileExists ? "update" : "create", diffStats: approvalDiffStats ? { additions: approvalDiffStats.added, deletions: approvalDiffStats.removed } : undefined, + ...(approvalDiff ? { diff: approvalDiff } : {}), + ...(autoApproved ? { autoApproved: true } : {}), }).catch(() => {}) } diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 61ca2850dc..10c90d36a6 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -488,6 +488,31 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockedCheckpointSave).toHaveBeenCalledOnce() }) + it("awaits the patch checkpoint before execute settles", async () => { + type SaveResult = Awaited> + let resolveSave: (value: SaveResult | PromiseLike) => void = () => {} + const saveDeferred = new Promise((resolve) => (resolveSave = resolve)) + mockedCheckpointSave.mockImplementationOnce(() => saveDeferred) + + const executePromise = tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // execute must not settle while the checkpoint is still in flight: + // a later write would otherwise interleave with this patch's staged work. + let settled = false + void executePromise.finally(() => (settled = true)) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(settled).toBe(false) + + resolveSave() + await executePromise + expect(settled).toBe(true) + }) + it("records a checkpoint when the in-place update succeeds", async () => { await tool.execute({ patch: updatePatch }, mockTask as Task, { askApproval: mockAskApproval, @@ -502,7 +527,8 @@ describe("ApplyPatchTool.execute - delete file success path", () => { it("records one journal write per file change for a multi-file patch", async () => { // src/a.ts does not exist (add); src/b.ts does (update). mockedFileExistsAtPath.mockImplementation((filePath: string) => - Promise.resolve(!String(filePath).toLowerCase().endsWith("a.ts"))) + Promise.resolve(!String(filePath).toLowerCase().endsWith("a.ts")), + ) const multiPatch = [ "*** Begin Patch", "*** Add File: src/a.ts", @@ -521,9 +547,61 @@ describe("ApplyPatchTool.execute - delete file success path", () => { }) expect(mockedCheckpointSave).toHaveBeenCalledOnce() + // B3a: each write threads the approval diff and stats computed by its + // handler so the per-step change card can reuse them verbatim. + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ + { + path: "src/a.ts", + operation: "create", + diffStats: { additions: 1, deletions: 0 }, + diff: expect.stringContaining("+alpha"), + }, + { + path: "src/b.ts", + operation: "update", + diffStats: { additions: 1, deletions: 1 }, + diff: expect.stringContaining("+second content"), + }, + ]) + }) + }) + + describe("change-card threading (B3a)", () => { + const mockedCheckpointSave = checkpointSave as MockedFunction + const deletePatch = `*** Begin Patch + *** Delete File: src/obsolete.ts + *** End Patch` + + it("threads autoApproved into the checkpoint writes for auto-approved steps", async () => { + // B3a: auto-approved steps carry autoApproved on every write so + // checkpointSave can force the compact (summary) change card. + const ref = (mockTask["providerRef"] as unknown as { deref: MockedFunction<() => unknown> }).deref + ref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ autoApprovalEnabled: true, alwaysAllowWrite: true }), + }) + + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ - { path: "src/a.ts", operation: "create" }, - { path: "src/b.ts", operation: "update" }, + { path: "src/obsolete.ts", operation: "delete", autoApproved: true }, + ]) + }) + + it("omits autoApproved when the step is not auto-approved", async () => { + // The default provider state ({}) disables auto-approval, so no write + // carries the autoApproved flag and the card follows the user setting. + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ + { path: "src/obsolete.ts", operation: "delete" }, ]) }) @@ -556,7 +634,12 @@ describe("ApplyPatchTool.execute - delete file success path", () => { // written, even though the whole patch succeeded. expect(mockedCheckpointSave).toHaveBeenCalledOnce() expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ - { path: "src/new.ts", operation: "create" }, + { + path: "src/new.ts", + operation: "create", + diffStats: { additions: 1, deletions: 0 }, + diff: expect.stringContaining("+fresh content"), + }, ]) }) @@ -587,7 +670,12 @@ describe("ApplyPatchTool.execute - delete file success path", () => { // ...and the successful subset is checkpointed and journaled. expect(mockedCheckpointSave).toHaveBeenCalledOnce() expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ - { path: "src/second.ts", operation: "create" }, + { + path: "src/second.ts", + operation: "create", + diffStats: { additions: 1, deletions: 0 }, + diff: expect.stringContaining("+fresh"), + }, ]) }) @@ -620,7 +708,9 @@ describe("ApplyPatchTool.execute - delete file success path", () => { [expect.objectContaining({ path: "src/new-location.ts", operation: "update" })], ) expect(mockTask.recordToolError).toHaveBeenCalledWith("apply_patch") - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("could not delete the original file")) + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("could not delete the original file"), + ) }) }) }) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index bb309cf849..2f975966b0 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -795,13 +795,14 @@ describe("editFileTool", () => { expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockedCheckpointSave).toHaveBeenCalledOnce() // B2: the write info threads the path, operation, and the approval - // diff stats into the checkpoint hook. - expect(mockedCheckpointSave).toHaveBeenCalledWith( - mockTask, - false, - true, - { path: testFilePath, operation: "update", diffStats: { additions: 1, deletions: 1 } }, - ) + // diff stats into the checkpoint hook. B3a: the approval diff itself is + // threaded verbatim for the per-step change card. + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask, false, true, { + path: testFilePath, + operation: "update", + diffStats: { additions: 1, deletions: 1 }, + diff: "mock-diff", + }) }) it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { @@ -834,28 +835,73 @@ describe("editFileTool", () => { expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockedCheckpointSave).toHaveBeenCalledOnce() - expect(mockedCheckpointSave).toHaveBeenCalledWith( - mockTask, - false, - true, - { path: testFilePath, operation: "create", diffStats: { additions: 1, deletions: 1 } }, - ) + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask, false, true, { + path: testFilePath, + operation: "create", + diffStats: { additions: 1, deletions: 1 }, + diff: "mock-diff", + }) }) it("omits diff stats from the checkpoint write when the diff has no stats", async () => { // A null approval diff produces no diffStats on the journal write. + // The diff itself is still threaded for the change card (B3a). vi.mocked(computeDiffStats).mockReturnValueOnce(null) await executeEditFileTool({}) expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockedCheckpointSave).toHaveBeenCalledOnce() - expect(mockedCheckpointSave).toHaveBeenCalledWith( - mockTask, - false, - true, - { path: testFilePath, operation: "update" }, - ) + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask, false, true, { + path: testFilePath, + operation: "update", + diff: "mock-diff", + }) + }) + + it("threads autoApproved into the checkpoint write for auto-approved steps", async () => { + // B3a: when the step is auto-approved the checkpoint write carries + // autoApproved so checkpointSave can force the compact change card. + mockTask.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: {}, + autoApprovalEnabled: true, + alwaysAllowWrite: true, + }), + }) + + await executeEditFileTool({}) + + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask, false, true, { + path: testFilePath, + operation: "update", + diffStats: { additions: 1, deletions: 1 }, + diff: "mock-diff", + autoApproved: true, + }) + }) + + it("awaits the edit checkpoint before execute settles", async () => { + type SaveResult = Awaited> + let resolveSave: (value: SaveResult | PromiseLike) => void = () => {} + const saveDeferred = new Promise((resolve) => (resolveSave = resolve)) + mockedCheckpointSave.mockImplementationOnce(() => saveDeferred) + + const executePromise = executeEditFileTool({}) + + // execute must not settle while the checkpoint is still in flight: + // a later tool block would otherwise interleave with this edit's staged work. + let settled = false + void executePromise.finally(() => (settled = true)) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(settled).toBe(false) + + resolveSave() + await executePromise + expect(settled).toBe(true) }) }) }) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 11224d77bc..cc2446288f 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -11,6 +11,7 @@ import { ToolUse, ToolResponse, AskApproval, HandleError, PushToolResult } from import { checkpointSave } from "../../checkpoints" import { formatResponse } from "../../prompts/responses" import { writeToFileTool } from "../WriteToFileTool" +import { convertNewFileToUnifiedDiff, sanitizeUnifiedDiff } from "../../diff/stats" vi.mock("path", async () => { const originalPath = await vi.importActual("path") @@ -102,6 +103,10 @@ describe("writeToFileTool", () => { const testContent = "Line 1\nLine 2\nLine 3" const testContentWithMarkdown = "```javascript\nLine 1\nLine 2\n```" + // The exact approval diff the tool computes for a new file (B3a threads it + // into the checkpoint write for the per-step change card). + const newFileApprovalDiff = sanitizeUnifiedDiff(convertNewFileToUnifiedDiff(testContent, testFilePath)) + // Mocked functions with correct types const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction const mockedCreateDirectoriesForFile = createDirectoriesForFile as MockedFunction @@ -489,12 +494,14 @@ describe("writeToFileTool", () => { expect(mockedCheckpointSave).toHaveBeenCalledOnce() // B2: the write info threads the path, operation, and the approval // diff stats (3 added lines, 0 removed) into the checkpoint hook. - expect(mockedCheckpointSave).toHaveBeenCalledWith( - mockCline, - false, - true, - { path: testFilePath, operation: "create", diffStats: { additions: 3, deletions: 0 } }, - ) + // B3a: the approval diff itself is threaded verbatim for the + // per-step change card. + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockCline, false, true, { + path: testFilePath, + operation: "create", + diffStats: { additions: 3, deletions: 0 }, + diff: newFileApprovalDiff, + }) }) it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { @@ -570,7 +577,8 @@ describe("writeToFileTool", () => { await executeWriteFileTool({}) // The experiment branch saves directly (no diff view) and still - // journals the write through the same single checkpoint hook. + // journals the write through the same single checkpoint hook, carrying + // the approval diff for the per-step change card (B3a). expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalledWith( testFilePath, testContent, @@ -578,12 +586,12 @@ describe("writeToFileTool", () => { true, 1000, ) - expect(mockedCheckpointSave).toHaveBeenCalledWith( - mockCline, - false, - true, - { path: testFilePath, operation: "create", diffStats: { additions: 3, deletions: 0 } }, - ) + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockCline, false, true, { + path: testFilePath, + operation: "create", + diffStats: { additions: 3, deletions: 0 }, + diff: newFileApprovalDiff, + }) }) it("omits diff stats from the checkpoint write when the approval diff is empty", async () => { @@ -594,12 +602,33 @@ describe("writeToFileTool", () => { await executeWriteFileTool({}, { fileExists: true }) expect(mockedCheckpointSave).toHaveBeenCalledOnce() - expect(mockedCheckpointSave).toHaveBeenCalledWith( - mockCline, - false, - true, - { path: testFilePath, operation: "update" }, - ) + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockCline, false, true, { + path: testFilePath, + operation: "update", + }) + }) + + it("threads autoApproved into the checkpoint write for auto-approved steps", async () => { + // B3a: when the step is auto-approved the checkpoint write carries + // autoApproved so checkpointSave can force the compact change card. + mockCline.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + autoApprovalEnabled: true, + alwaysAllowWrite: true, + }), + }) + + await executeWriteFileTool({}) + + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockCline, false, true, { + path: testFilePath, + operation: "create", + diffStats: { additions: 3, deletions: 0 }, + diff: newFileApprovalDiff, + autoApproved: true, + }) }) }) }) diff --git a/src/core/tools/apply-patch/apply.ts b/src/core/tools/apply-patch/apply.ts index 4ab377f732..64009d27bf 100644 --- a/src/core/tools/apply-patch/apply.ts +++ b/src/core/tools/apply-patch/apply.ts @@ -29,6 +29,15 @@ export interface ApplyPatchFileChange { originalContent?: string /** New content (for add/update) */ newContent?: string + /** + * B3a: the unified approval diff for this file (computed by the tool + * handler), reused by the per-step change card. + */ + diff?: string + /** B3a: { added, removed } stats of the approval diff, when computable. */ + diffStats?: { added: number; removed: number } + /** B3a: whether this file's approval was auto-approved. */ + autoApproved?: boolean } /** diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 481904ed57..52bb209922 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -52,6 +52,7 @@ import { DEFAULT_MODES, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, DEFAULT_PER_WRITE_CHECKPOINTS, + DEFAULT_CHANGE_CARD_DETAIL, getModelId, isRetiredProvider, providerIdentifiers, @@ -2558,6 +2559,7 @@ export class ClineProvider enableCheckpoints, checkpointTimeout, perWriteCheckpoints, + changeCardDetail, soundVolume, writeDelayMs, diffFuzzyThreshold, @@ -2718,6 +2720,7 @@ export class ClineProvider enableCheckpoints: enableCheckpoints ?? true, checkpointTimeout: checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, perWriteCheckpoints: perWriteCheckpoints ?? DEFAULT_PER_WRITE_CHECKPOINTS, + changeCardDetail: changeCardDetail ?? DEFAULT_CHANGE_CARD_DETAIL, shouldShowAnnouncement: telemetrySetting !== "unset" && lastShownAnnouncementId !== this.latestAnnouncementId, allowedCommands: mergedAllowedCommands, @@ -2955,6 +2958,7 @@ export class ClineProvider enableCheckpoints: stateValues.enableCheckpoints ?? true, checkpointTimeout: stateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, perWriteCheckpoints: stateValues.perWriteCheckpoints ?? DEFAULT_PER_WRITE_CHECKPOINTS, + changeCardDetail: stateValues.changeCardDetail ?? DEFAULT_CHANGE_CARD_DETAIL, 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 7ec6bb782b..caf6bfd002 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -721,6 +721,7 @@ describe("ClineProvider", () => { ttsEnabled: false, enableCheckpoints: false, perWriteCheckpoints: false, + changeCardDetail: "summary", writeDelayMs: 1000, mcpEnabled: true, mode: defaultModeSlug, @@ -1444,6 +1445,39 @@ describe("ClineProvider", () => { expect(state.perWriteCheckpoints).toBe(true) }) + test("getState returns the saved changeCardDetail setting", async () => { + await provider.contextProxy.setValue("changeCardDetail", "full") + + const state = await provider.getState() + + expect(state.changeCardDetail).toBe("full") + }) + + test("getState defaults changeCardDetail to summary when unset", async () => { + const state = await provider.getState() + + expect(state.changeCardDetail).toBe("summary") + }) + + test("getStateToPostToWebview returns the saved changeCardDetail setting", async () => { + // The default is "summary", so only an explicit "full" proves that the + // stored value (rather than the default) reaches the webview state. + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setValue("changeCardDetail", "full") + + const state = await provider.getStateToPostToWebview() + + expect(state.changeCardDetail).toBe("full") + }) + + test("getStateToPostToWebview defaults changeCardDetail to summary when unset", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const state = await provider.getStateToPostToWebview() + + expect(state.changeCardDetail).toBe("summary") + }) + 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/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 203594275d..92c3e5f553 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -212,6 +212,7 @@ const createInitialExtensionState = (): ExtensionState => ({ ttsSpeed: 1.0, enableCheckpoints: true, perWriteCheckpoints: true, + changeCardDetail: "summary", 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 f0655b55e0..38d3b00f0b 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -408,6 +408,7 @@ describe("mergeExtensionState", () => { shouldShowAnnouncement: false, enableCheckpoints: true, perWriteCheckpoints: true, + changeCardDetail: "summary", writeDelayMs: 1000, mode: "default", experiments: {} as Record, @@ -438,12 +439,16 @@ describe("mergeExtensionState", () => { const prevState: ExtensionState = { ...baseState, + // Non-default checkpoint keys so a merge regression that drops or + // resets them cannot hide behind the initial defaults. + perWriteCheckpoints: false, + changeCardDetail: "full", apiConfiguration: { modelMaxTokens: 1234, modelMaxThinkingTokens: 123 }, experiments: {} as Record, checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS - 5, } - const newState: ExtensionState = { + const newState: Partial = { ...baseState, apiConfiguration: { modelMaxThinkingTokens: 456, modelTemperature: 0.3 }, experiments: { @@ -455,6 +460,11 @@ describe("mergeExtensionState", () => { checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS + 5, } + // A partial state push may omit the checkpoint keys entirely; the + // merge must preserve the previous non-default values. + delete newState.perWriteCheckpoints + delete newState.changeCardDetail + const result = mergeExtensionState(prevState, newState) expect(result.apiConfiguration).toEqual({ @@ -468,6 +478,11 @@ describe("mergeExtensionState", () => { runSlashCommand: false, customTools: false, }) + + // A partial push that omits the checkpoint keys must keep the previous + // non-default values. + expect(result.perWriteCheckpoints).toBe(false) + expect(result.changeCardDetail).toBe("full") }) describe("clineMessagesSeq protection", () => { @@ -479,6 +494,7 @@ describe("mergeExtensionState", () => { shouldShowAnnouncement: false, enableCheckpoints: true, perWriteCheckpoints: true, + changeCardDetail: "summary", 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 dca198cc7b..6016f2a274 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -705,6 +705,10 @@ "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" + }, + "changeCardDetail": { + "label": "Mostra la diff completa a les targetes de canvis", + "description": "Inclou la diff unificada completa en línia per a cada fitxer a les targetes de canvis per pas. Desactivada, les targetes només mostren la llista de fitxers amb les línies afegides/eliminades." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 530c799733..73d7018e45 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "Kontrollpunkt nach jedem Dateischreibvorgang", "description": "Ein Kontrollpunkt-Snapshot wird nach jedem erfolgreichen Dateischreibvorgang des Agents erfasst" + }, + "changeCardDetail": { + "label": "Volle Diff in Änderungskarten anzeigen", + "description": "Enthält die vollständige Unified-Diff inline für jede Datei in den Änderungskarten pro Schritt. Deaktiviert zeigen die Karten nur die Dateiliste mit hinzugefügten/entfernten Zeilen." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 5f8b40ade2..7528ded35e 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -785,6 +785,10 @@ "perWrite": { "label": "Checkpoint after each file write", "description": "Record a checkpoint snapshot after every successful file write by the agent" + }, + "changeCardDetail": { + "label": "Show full diff in change cards", + "description": "Include the full unified diff inline for every file in per-step change cards. When off, cards show only the file list with added/removed line counts." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 704f8d1d31..983380ae8a 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -705,6 +705,10 @@ "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" + }, + "changeCardDetail": { + "label": "Mostrar la diff completa en las tarjetas de cambios", + "description": "Incluye la diff unificada completa en línea para cada archivo en las tarjetas de cambios por paso. Al desactivarla, las tarjetas muestran solo la lista de archivos con las líneas añadidas/eliminadas." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 5db4e0dfe7..2548f3ea1e 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -705,6 +705,10 @@ "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" + }, + "changeCardDetail": { + "label": "Afficher la diff complète dans les cartes de modifications", + "description": "Inclut la diff unifiée complète en ligne pour chaque fichier dans les cartes de modifications par étape. Désactivée, les cartes n'affichent que la liste des fichiers avec les lignes ajoutées/retirées." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 4570f85117..14008fc161 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "हर फ़ाइल लिखने के बाद चेकपॉइंट", "description": "एजेंट द्वारा हर सफल फ़ाइल लिखने के बाद एक चेकपॉइंट स्नैपशॉट दर्ज किया जाता है" + }, + "changeCardDetail": { + "label": "बदलाव कार्डों में पूर्ण diff दिखाएँ", + "description": "प्रति-चरण बदलाव कार्डों में हर फ़ाइल के लिए पूर्ण unified diff इनलाइन शामिल करता है। बंद होने पर कार्ड केवल जोड़ी/हटाई गई पंक्तियों के साथ फ़ाइल सूची दिखाते हैं।" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 2918f14870..945fbdc741 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "Checkpoint setelah setiap penulisan file", "description": "Merekam snapshot checkpoint setelah setiap penulisan file yang berhasil oleh agen" + }, + "changeCardDetail": { + "label": "Tampilkan diff lengkap di kartu perubahan", + "description": "Termasuk diff terpadu lengkap secara inline untuk setiap file di kartu perubahan per langkah. Saat dimatikan, kartu hanya menampilkan daftar file dengan baris yang ditambahkan/dihapus." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 6909e6a1a2..f638df43ca 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -705,6 +705,10 @@ "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" + }, + "changeCardDetail": { + "label": "Mostra la diff completa nelle card dei cambiamenti", + "description": "Include la diff unificata completa in linea per ogni file nelle card dei cambiamenti per passo. Se disattivata, le card mostrano solo l'elenco dei file con le righe aggiunte/rimosse." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 6deaeca4d6..d917add07d 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "ファイルの書き込みごとにチェックポイント", "description": "エージェントによる各ファイルの書き込み成功後にチェックポイントのスナップショットを記録します" + }, + "changeCardDetail": { + "label": "変更カードに完全な diff を表示", + "description": "ステップごとの変更カードに各ファイルの完全な unified diff をインラインで含めます。オフにすると、カードは追加/削除行数付きのファイル一覧のみを表示します。" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index a6c6583347..79c1e843f2 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "파일을 쓸 때마다 체크포인트", "description": "에이전트가 파일 쓰기에 성공할 때마다 체크포인트 스냅샷을 기록합니다" + }, + "changeCardDetail": { + "label": "변경 카드에 전체 diff 표시", + "description": "단계별 변경 카드에 각 파일의 전체 unified diff를 인라인으로 포함합니다. 끄면 카드는 추가/제거 줄 수만 있는 파일 목록만 표시합니다." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 77f6f5797b..8295b30ce7 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "Checkpoint na elke bestandsschrijving", "description": "Neemt een checkpoint-snapshot op na elke succesvolle bestandsschrijving door de agent" + }, + "changeCardDetail": { + "label": "Volledige diff tonen in wijzigingskaarten", + "description": "Bevat de volledige unified diff inline voor elk bestand in wijzigingskaarten per stap. Bij uitgeschakeld tonen de kaarten alleen de bestandslijst met toegevoegde/verwijderde regels." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 49b341cb9f..fc6407faef 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "Punkt kontrolny po każdym zapisaniu pliku", "description": "Rejestruje migawkę punktu kontrolnego po każdym udanym zapisaniu pliku przez agenta" + }, + "changeCardDetail": { + "label": "Pokaż pełny diff w kartach zmian", + "description": "Zawiera pełny spójny diff inline dla każdego pliku w kartach zmian per krok. Po wyłączeniu karty pokazują tylko listę plików z dodanymi/usuniętymi liniami." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 71694fe203..06dd7b5ee2 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -705,6 +705,10 @@ "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" + }, + "changeCardDetail": { + "label": "Mostrar diff completo nos cartões de alterações", + "description": "Inclui a diff unificada completa em linha para cada arquivo nos cartões de alterações por etapa. Quando desativado, os cartões mostram apenas a lista de arquivos com linhas adicionadas/removidas." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index e9a3f5938b..4e72a14bcc 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "Контрольная точка после каждой записи файла", "description": "Записывает снимок контрольной точки после каждой успешной записи файла агентом" + }, + "changeCardDetail": { + "label": "Показывать полный diff в карточках изменений", + "description": "Включает полную unified diff для каждого файла в карточках изменений по шагам. При выключенном показе карточки отображают только список файлов с количеством добавленных/удалённых строк." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index aff7402428..e39fa9003a 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -705,6 +705,10 @@ "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" + }, + "changeCardDetail": { + "label": "Değişiklik kartlarında tam diff'i göster", + "description": "Adım başına değişiklik kartlarında her dosya için tam birleşik diff'i satır içi olarak içerir. Kapalıyken kartlar yalnızca eklenen/çıkarılan satır sayıları dosya listesini gösterir." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index e944a56780..3be7cff5be 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -705,6 +705,10 @@ "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" + }, + "changeCardDetail": { + "label": "Hiển thị diff đầy đủ trong thẻ thay đổi", + "description": "Bao gồm diff thống nhất đầy đủ nội tuyến cho từng tệp trong thẻ thay đổi theo bước. Khi tắt, thẻ chỉ hiển thị danh sách tệp với số dòng thêm/xóa." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 05d04463e1..8102ba76d8 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "每次文件写入后创建存档点", "description": "智能体每次成功写入文件后都会记录一个存档点快照" + }, + "changeCardDetail": { + "label": "在变更卡片中显示完整 diff", + "description": "在逐步变更卡片中为每个文件内嵌完整 unified diff。关闭时,卡片仅显示文件列表与新增/删除行数。" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index a2ef47b841..69886d6479 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -732,6 +732,10 @@ "perWrite": { "label": "每次檔案寫入後建立檢查點", "description": "代理每次成功寫入檔案後都會記錄一個檢查點快照" + }, + "changeCardDetail": { + "label": "在變更卡片中顯示完整 diff", + "description": "在逐步變更卡片中為每個檔案內嵌完整 unified diff。關閉時,卡片僅顯示檔案清單與新增/刪除行數。" } }, "notifications": { From d0c60bfbbb52c0981e1143ce0812e312fc71538b Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 28 Aug 2026 01:26:35 +0800 Subject: [PATCH 04/48] feat(checkpoints): per-file and per-step rollback service (B3c, #1375) --- .../checkpoints/__tests__/rollback.spec.ts | 198 ++++++++++++++++++ src/core/checkpoints/rollback.ts | 119 +++++++++++ .../checkpoints/ShadowCheckpointService.ts | 67 ++++++ .../__tests__/ShadowCheckpointService.spec.ts | 112 ++++++++++ 4 files changed, 496 insertions(+) create mode 100644 src/core/checkpoints/__tests__/rollback.spec.ts create mode 100644 src/core/checkpoints/rollback.ts diff --git a/src/core/checkpoints/__tests__/rollback.spec.ts b/src/core/checkpoints/__tests__/rollback.spec.ts new file mode 100644 index 0000000000..36a7031497 --- /dev/null +++ b/src/core/checkpoints/__tests__/rollback.spec.ts @@ -0,0 +1,198 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import type { Task } from "../../task/Task" +import { getCheckpointService } from "../index" +import { appendChange } from "../changeJournal" +import { rollbackFile, rollbackStep } from "../rollback" + +vi.mock("../index", () => ({ + getCheckpointService: vi.fn(), + checkpointSave: vi.fn(), + checkpointRestore: vi.fn(), + checkpointDiff: vi.fn(), +})) + +const mockedGetCheckpointService = getCheckpointService as unknown as ReturnType + +function makeTask(): Task { + return { + taskId: "task-rollback", + providerRef: { + deref: vi.fn().mockReturnValue({ context: { globalStorageUri: { fsPath: globalStorageDir } } }), + }, + } as unknown as Task +} + +let globalStorageDir: string + +beforeEach(async () => { + globalStorageDir = await fs.mkdtemp(path.join(os.tmpdir(), "b3a-rollback-")) + mockedGetCheckpointService.mockReset() +}) + +afterEach(async () => { + await fs.rm(globalStorageDir, { recursive: true, force: true }) +}) + +describe("rollbackFile (B3a)", () => { + it("restores the file through the task's checkpoint service", async () => { + const restoreFile = vi.fn().mockResolvedValue(undefined) + mockedGetCheckpointService.mockResolvedValue({ restoreFile }) + + const outcome = await rollbackFile(makeTask(), "sha-1", "src/a.ts") + + expect(outcome).toEqual({ filePath: "src/a.ts", success: true }) + expect(restoreFile).toHaveBeenCalledWith("sha-1", "src/a.ts") + }) + + it("fails cleanly when checkpoints are not enabled", async () => { + mockedGetCheckpointService.mockResolvedValue(undefined) + + const outcome = await rollbackFile(makeTask(), "sha-1", "src/a.ts") + + expect(outcome).toEqual({ + filePath: "src/a.ts", + success: false, + error: "Checkpoints are not enabled for this task", + }) + }) + + it("reports the service error without throwing", async () => { + mockedGetCheckpointService.mockResolvedValue({ + restoreFile: vi.fn().mockRejectedValue(new Error("pathspec did not match")), + }) + + const outcome = await rollbackFile(makeTask(), "sha-bad", "src/a.ts") + + expect(outcome.success).toBe(false) + expect(outcome.error).toContain("pathspec did not match") + }) + + it("stringifies non-Error rejections into the outcome", async () => { + mockedGetCheckpointService.mockResolvedValue({ + restoreFile: vi.fn().mockRejectedValue("raw failure"), + }) + + const outcome = await rollbackFile(makeTask(), "sha-bad", "src/a.ts") + + expect(outcome).toEqual({ filePath: "src/a.ts", success: false, error: "raw failure" }) + }) +}) + +describe("rollbackStep (B3a)", () => { + it("restores every step file from the step's checkpoint via the journal", async () => { + await appendChange(globalStorageDir, "task-rollback", { + path: "src/a.ts", + operation: "create", + checkpointId: "sha-step", + }) + await appendChange(globalStorageDir, "task-rollback", { + path: "src/b.ts", + operation: "update", + checkpointId: "sha-step", + }) + + const restoreFile = vi.fn().mockResolvedValue(undefined) + mockedGetCheckpointService.mockResolvedValue({ restoreFile }) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/b.ts"], "sha-step") + + expect(outcome.checkpointId).toBe("sha-step") + expect(outcome.files).toEqual([ + { filePath: "src/a.ts", success: true }, + { filePath: "src/b.ts", success: true }, + ]) + expect(restoreFile).toHaveBeenCalledTimes(2) + expect(restoreFile).toHaveBeenNthCalledWith(1, "sha-step", "src/a.ts") + expect(restoreFile).toHaveBeenNthCalledWith(2, "sha-step", "src/b.ts") + }) + + it("rejects a file that is not part of the given step checkpoint", async () => { + await appendChange(globalStorageDir, "task-rollback", { + path: "src/a.ts", + operation: "create", + checkpointId: "sha-step", + }) + + const restoreFile = vi.fn().mockResolvedValue(undefined) + mockedGetCheckpointService.mockResolvedValue({ restoreFile }) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/other.ts"], "sha-step") + + expect(outcome.files[0]).toEqual({ filePath: "src/a.ts", success: true }) + expect(outcome.files[1].success).toBe(false) + expect(outcome.files[1].error).toBe("File is not part of this step's checkpoint") + expect(restoreFile).toHaveBeenCalledTimes(1) + }) + + it("falls back to the latest journal entry per file without a step checkpoint id", async () => { + await appendChange(globalStorageDir, "task-rollback", { + path: "src/a.ts", + operation: "create", + checkpointId: "sha-1", + }) + await appendChange(globalStorageDir, "task-rollback", { + path: "src/a.ts", + operation: "update", + checkpointId: "sha-2", + }) + + const restoreFile = vi.fn().mockResolvedValue(undefined) + mockedGetCheckpointService.mockResolvedValue({ restoreFile }) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts"]) + + expect(outcome.checkpointId).toBe("sha-2") + expect(restoreFile).toHaveBeenCalledWith("sha-2", "src/a.ts") + }) + + it("fails listed files without journal entries and keeps the checkpoint when resolvable", async () => { + await appendChange(globalStorageDir, "task-rollback", { + path: "src/a.ts", + operation: "create", + checkpointId: "sha-1", + }) + + const restoreFile = vi.fn().mockResolvedValue(undefined) + mockedGetCheckpointService.mockResolvedValue({ restoreFile }) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/missing.ts"]) + + expect(outcome.checkpointId).toBe("sha-1") + expect(outcome.files[0]).toEqual({ filePath: "src/a.ts", success: true }) + expect(outcome.files[1].success).toBe(false) + expect(outcome.files[1].error).toBe("No change journal entry for this file") + }) + + it("treats a missing global storage directory as an empty journal", async () => { + // No context on the provider double → no journal location to read. + const task = { + taskId: "task-rollback", + providerRef: { deref: vi.fn().mockReturnValue(undefined) }, + } as unknown as Task + + const restoreFile = vi.fn().mockResolvedValue(undefined) + mockedGetCheckpointService.mockResolvedValue({ restoreFile }) + + const outcome = await rollbackStep(task, ["src/a.ts"], "sha-step") + + expect(outcome.checkpointId).toBe("sha-step") + expect(outcome.files[0].success).toBe(false) + expect(outcome.files[0].error).toBe("File is not part of this step's checkpoint") + expect(restoreFile).not.toHaveBeenCalled() + }) + it("fails every file when checkpoints are not enabled", async () => { + mockedGetCheckpointService.mockResolvedValue(undefined) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts"], "sha-step") + + expect(outcome.checkpointId).toBe("sha-step") + expect(outcome.files).toEqual([ + { filePath: "src/a.ts", success: false, error: "Checkpoints are not enabled for this task" }, + ]) + }) +}) diff --git a/src/core/checkpoints/rollback.ts b/src/core/checkpoints/rollback.ts new file mode 100644 index 0000000000..e2be09b621 --- /dev/null +++ b/src/core/checkpoints/rollback.ts @@ -0,0 +1,119 @@ +/** + * Per-file / per-step checkpoint rollback (B3a). + * + * Restores reuse the existing shadow-git service (`getCheckpointService` → + * `RepoPerTaskCheckpointService.restoreFile`, the same instance whose + * `restoreCheckpoint` the checkpoints UI uses) — nothing is forked. + * + * - `rollbackFile` restores one file to the content it had at an explicit + * checkpoint commit (the "restore to any checkpoint" primitive). + * - `rollbackStep` restores every file a step touched to that step's + * checkpoint. The step's checkpoint is resolved from the B2 change + * journal (`changes.jsonl`), whose entries key each written file by the + * checkpoint commit it produced. When the caller already knows the step's + * checkpoint id (the change-card payload carries it), pass it so the + * journal is filtered to exactly that step's writes. + */ +import type { Task } from "../task/Task" + +import { getCheckpointService } from "./index" +import { loadChanges, type ChangeJournalEntry } from "./changeJournal" + +export interface RollbackFileOutcome { + filePath: string + success: boolean + error?: string +} + +export interface RollbackStepOutcome { + /** The step checkpoint the files were restored from, when resolvable. */ + checkpointId?: string + files: RollbackFileOutcome[] +} + +const NOT_ENABLED_ERROR = "Checkpoints are not enabled for this task" + +/** + * Restore a single file to its state at `checkpointId`. + * + * Only the named file's working-tree content is replaced; the shadow repo's + * HEAD and the checkpoint list are untouched (unlike a full + * `restoreCheckpoint`). + */ +export async function rollbackFile(task: Task, checkpointId: string, filePath: string): Promise { + const service = await getCheckpointService(task) + + if (!service) { + return { filePath, success: false, error: NOT_ENABLED_ERROR } + } + + try { + await service.restoreFile(checkpointId, filePath) + return { filePath, success: true } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`[rollbackFile] failed to restore ${filePath} from checkpoint ${checkpointId}: ${message}`) + return { filePath, success: false, error: message } + } +} + +/** + * Restore every file of a step to the step's checkpoint. + * + * `stepFiles` comes from the B2 journal entries for the step's checkpoint id + * (which the change-card payload also carries). Each file is resolved to its + * step checkpoint through the journal: + * - with `stepCheckpointId`, only entries for that checkpoint are considered + * (exactly the step's writes), and every listed file is restored from it; + * - without it, the latest journal entry per file is used as a fallback. + */ +export async function rollbackStep( + task: Task, + stepFiles: string[], + stepCheckpointId?: string, +): Promise { + const service = await getCheckpointService(task) + + if (!service) { + return { + checkpointId: stepCheckpointId, + files: stepFiles.map((filePath) => ({ filePath, success: false, error: NOT_ENABLED_ERROR })), + } + } + + // loadChanges never throws: an absent or torn journal resolves to its + // readable prefix (or []), so no error handling is needed here. + const globalStorageDir = task.providerRef.deref()?.context.globalStorageUri.fsPath + const entries = globalStorageDir ? await loadChanges(globalStorageDir, task.taskId) : [] + + // Latest journal entry per file (journal lines preserve write order). + const latestByPath = new Map() + for (const entry of entries) { + latestByPath.set(entry.path, entry) + } + + const checkpointId = + stepCheckpointId ?? stepFiles.map((filePath) => latestByPath.get(filePath)?.checkpointId).find(Boolean) + + const files: RollbackFileOutcome[] = [] + for (const filePath of stepFiles) { + const entry = stepCheckpointId + ? entries.find((e) => e.path === filePath && e.checkpointId === stepCheckpointId) + : latestByPath.get(filePath) + + if (!entry) { + files.push({ + filePath, + success: false, + error: stepCheckpointId + ? "File is not part of this step's checkpoint" + : "No change journal entry for this file", + }) + continue + } + + files.push(await rollbackFile(task, entry.checkpointId, filePath)) + } + + return { checkpointId, files } +} diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index 3e3d3d0653..203d1c4727 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -407,6 +407,73 @@ export abstract class ShadowCheckpointService extends EventEmitter { } } + /** + * Restore a single file to its state at `commitHash` without moving the + * branch or truncating the checkpoint list (unlike + * {@link restoreCheckpoint}). + * + * If the file did not exist at `commitHash`, it is removed from the + * working tree instead — rolling a file back to before it was created. + */ + public async restoreFile(commitHash: string, filePath: string): Promise { + try { + this.log(`[${this.constructor.name}#restoreFile] restoring ${filePath} from ${commitHash}`) + + if (!this.git) { + throw new Error("Shadow git repo not initialized") + } + + // Git pathspecs are always POSIX: normalize a native (Windows + // backslash) path before the git calls. Without this, `cat-file -e` on + // a backslashed path never matches, the file is treated as absent at + // the checkpoint, and the delete branch below would remove a file the + // checkpoint actually contains. The local fs.rm join keeps the native + // form, since the OS treats both separators interchangeably there. + const gitPath = filePath.toPosix() + + // Constrain the path to the workspace before either branch: `..` + // segments would otherwise normalize (path.join / git pathspec) to a + // location outside `this.workspaceDir`, and the delete branch could + // remove an unrelated file. `path.resolve` normalizes the segments; + // the trailing-separator prefix check is the containment guard. + const resolvedTarget = path.resolve(this.workspaceDir, filePath) + const workspaceRoot = this.workspaceDir.endsWith(path.sep) + ? this.workspaceDir + : this.workspaceDir + path.sep + if (resolvedTarget !== this.workspaceDir && !resolvedTarget.startsWith(workspaceRoot)) { + throw new Error(`restoreFile target is outside the workspace: ${filePath}`) + } + + const start = Date.now() + const existed = await this.fileExistsInCommit(commitHash, gitPath) + + if (existed) { + await this.git.checkout([commitHash, "--", gitPath]) + } else { + await fs.rm(path.join(this.workspaceDir, filePath), { force: true }) + } + + const duration = Date.now() - start + this.emit("restore", { type: "restore", commitHash, duration }) + this.log(`[${this.constructor.name}#restoreFile] restored ${filePath} in ${duration}ms`) + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)) + this.log(`[${this.constructor.name}#restoreFile] failed to restore file: ${error.message}`) + this.emit("error", { type: "error", error }) + throw error + } + } + + /** Whether `filePath` exists in the tree of `commitHash`. */ + private async fileExistsInCommit(commitHash: string, filePath: string): Promise { + try { + await this.git!.raw(["cat-file", "-e", `${commitHash}:${filePath}`]) + return true + } catch { + return false + } + } + public async getDiff({ from, to }: { from?: string; to?: string }): Promise { if (!this.git) { throw new Error("Shadow git repo not initialized") diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index 1710cc97e8..0b9a48cb75 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -165,6 +165,118 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( }) }) + describe(`${klass.name}#restoreFile`, () => { + it("restores a modified file to a previous checkpoint without touching other files", async () => { + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + const newFile = path.join(service.workspaceDir, "new.txt") + await fs.writeFile(newFile, "New file content") + const commit2 = await service.saveCheckpoint("Second checkpoint") + expect(commit2?.commit).toBeTruthy() + + // Drift both files, then roll back only test.txt to commit 1. + await fs.writeFile(testFile, "Changed after checkpoint") + await fs.writeFile(newFile, "Also changed") + + await service.restoreFile(commit1!.commit, "test.txt") + + expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!") + // new.txt is not part of the restore and keeps its drifted content. + expect(await fs.readFile(newFile, "utf-8")).toBe("Also changed") + }) + + it("deletes a file that did not exist at the checkpoint", async () => { + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + const newFile = path.join(service.workspaceDir, "new.txt") + await fs.writeFile(newFile, "Created after the checkpoint") + + await service.restoreFile(commit1!.commit, "new.txt") + + expect(await fileExistsAtPath(newFile)).toBe(false) + // The untouched file keeps its checkpoint-1 content. + expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!") + }) + + it("accepts a native backslashed path on Windows (git pathspecs are POSIX)", async () => { + if (process.platform !== "win32") { + // Off-Windows the journal paths are already POSIX, so there is + // nothing to normalize; the toPosix() call is still exercised + // as a no-op by every other restoreFile test. + return + } + + // A nested file, so the relative path actually contains a + // separator that Windows writes as a backslash. + const subFile = path.join(path.dirname(testFile), "subdir", "inner.txt") + await fs.mkdir(path.dirname(subFile), { recursive: true }) + await fs.writeFile(subFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + await fs.writeFile(subFile, "Drifted") + + // The change journal and the webview hand backslashed paths to the + // rollback service on Windows; restoreFile must normalize them for + // the git calls. Without the normalization, `cat-file -e` would + // never match and the delete branch would remove a file the + // checkpoint actually contains. + await service.restoreFile(commit1!.commit, "subdir" + path.sep + "inner.txt") + + expect(await fs.readFile(subFile, "utf-8")).toBe("Ahoy, world!") + }) + + it("rejects a restore target that escapes the workspace", async () => { + // `..` segments must be normalized and contained, so neither the + // checkout nor the delete branch can touch a file outside the + // workspace (CWE-22). + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + await expect(service.restoreFile(commit1!.commit, path.join("..", "escape.txt"))).rejects.toThrow( + /outside the workspace/, + ) + }) + + it("emits a restore event when a file is restored", async () => { + const restoreListener = vi.fn() + service.on("restore", restoreListener) + + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + await fs.writeFile(testFile, "Drifted") + await service.restoreFile(commit1!.commit, "test.txt") + + expect(restoreListener).toHaveBeenCalledWith( + expect.objectContaining({ type: "restore", commitHash: commit1!.commit }), + ) + }) + + it("throws and emits an error event when the shadow repo is not initialized", async () => { + // A service that never ran initShadowGit has no git handle; restoreFile + // must fail cleanly and surface the error event. + const raw = await klass.create({ + taskId, + shadowDir: path.join(tmpDir, `noinit-${Date.now()}`), + workspaceDir: path.join(tmpDir, `ws-noinit-${Date.now()}`), + log: () => {}, + }) + + const errorListener = vi.fn() + raw.on("error", errorListener) + + await expect(raw.restoreFile("sha-x", "test.txt")).rejects.toThrow("Shadow git repo not initialized") + expect(errorListener).toHaveBeenCalledWith(expect.objectContaining({ type: "error" })) + }) + }) + describe(`${klass.name}#saveCheckpoint`, () => { it("creates a checkpoint if there are pending changes", async () => { await fs.writeFile(testFile, "Ahoy, world!") From d64389c16087acea883a92fac03e3e9b8be32dce Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 28 Aug 2026 10:02:45 +0800 Subject: [PATCH 05/48] fix(fws): validate checkpoint availability + contain symlinked restoreFile targets restoreFile verifies the checkpoint object (rev-parse --verify; simple-git raw() resolves silently when git exits non-zero without stderr, so cat-file -e would have read a missing checkpoint as present) before the exists-at-commit lookup, and rejects with Checkpoint unavailable instead of deleting the selected file. When the restore target file exists, both the workspace root and the target are fs.realpath-resolved and containment is re-checked, so a link inside the workspace pointing outside it is rejected before any mutation. Regressions: unavailable checkpoint keeps the live file; symlinked ancestor is rejected (POSIX). (CodeRabbit security finding on trial #1413). --- .../checkpoints/ShadowCheckpointService.ts | 33 +++++++++++++ .../__tests__/ShadowCheckpointService.spec.ts | 49 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index 203d1c4727..a4d5a56756 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -444,6 +444,23 @@ export abstract class ShadowCheckpointService extends EventEmitter { throw new Error(`restoreFile target is outside the workspace: ${filePath}`) } + // The lexical check cannot see through a symlinked ancestor: a link + // inside the workspace pointing outside it passes the prefix check + // while the real target resolves elsewhere. Re-check containment on + // the resolved (real) paths whenever the target file currently + // exists — that is the destructive case. A target that does not exist + // cannot be deleted, and the checkout branch only writes files the + // task-owned shadow repo recorded. Resolving both sides keeps a + // legitimate symlinked workspace root working. + if (await fileExistsAtPath(resolvedTarget)) { + const realWorkspaceRoot = await fs.realpath(this.workspaceDir) + const realRoot = realWorkspaceRoot.endsWith(path.sep) ? realWorkspaceRoot : realWorkspaceRoot + path.sep + const realTarget = await fs.realpath(resolvedTarget) + if (realTarget !== realWorkspaceRoot && !realTarget.startsWith(realRoot)) { + throw new Error(`restoreFile target resolves outside the workspace: ${filePath}`) + } + } + const start = Date.now() const existed = await this.fileExistsInCommit(commitHash, gitPath) @@ -466,6 +483,22 @@ export abstract class ShadowCheckpointService extends EventEmitter { /** Whether `filePath` exists in the tree of `commitHash`. */ private async fileExistsInCommit(commitHash: string, filePath: string): Promise { + // A failed lookup is not evidence the file is absent. If the commit object + // itself cannot be read (invalid hash, corrupt or missing shadow repo), + // falling through to the restoreFile delete branch would remove a live + // file. Verify the object first and fail the restore loudly instead. + // + // Verification must be evidence-based: simple-git's raw() only rejects + // when git writes a fatal to stderr, and `git cat-file -e ` + // fails *silently* (exit 1, no output) — a silent resolution would be + // read as "valid commit". `rev-parse --verify` emits the resolved id on + // success and a stderr fatal on every failure mode, so the reject is + // reliable. + try { + await this.git!.raw(["rev-parse", "--verify", `${commitHash}^{commit}`]) + } catch { + throw new Error(`Checkpoint unavailable: ${commitHash}`) + } try { await this.git!.raw(["cat-file", "-e", `${commitHash}:${filePath}`]) return true diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index 0b9a48cb75..f1e11ea003 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -243,6 +243,55 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( ) }) + it("rejects an unavailable checkpoint instead of deleting the live file", async () => { + // A corrupt journal entry can hand the rollback service a checkpoint + // id that does not resolve to a shadow-repo object. The lookup + // failure must not be read as "file absent at the checkpoint" — + // that would route the restore into the delete branch and remove a + // live file. The restore must fail loudly and leave the file intact. + await fs.writeFile(testFile, "Ahoy, world!") + await service.saveCheckpoint("First checkpoint") + + await expect( + service.restoreFile("0000000000000000000000000000000000000000", "test.txt"), + ).rejects.toThrow("Checkpoint unavailable") + + expect(await fileExistsAtPath(testFile)).toBe(true) + expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!") + }) + + it("rejects a target that escapes the workspace through a symlinked ancestor", async () => { + // A lexical prefix check passes for a path that goes through a + // symlink inside the workspace pointing outside it; the resolved + // (real) target must be contained as well, or the delete/checkout + // branch would mutate a file the task never owned (CWE-22). + if (process.platform === "win32") { + // Creating symlinks needs elevated privileges on Windows; the + // lexical-containment test above still covers the portable case. + return + } + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + const outsideDir = path.join(tmpDir, `outside-${Date.now()}`) + await fs.mkdir(outsideDir, { recursive: true }) + const outsideFile = path.join(outsideDir, "sneaky.txt") + await fs.writeFile(outsideFile, "outside") + + // A directory link inside the workspace pointing at the outside dir: + // lexically `link/sneaky.txt` is inside the workspace. + await fs.symlink(outsideDir, path.join(service.workspaceDir, "link"), "dir") + + await expect(service.restoreFile(commit1!.commit, path.join("link", "sneaky.txt"))).rejects.toThrow( + /outside the workspace/, + ) + + // The outside file survives: the restore failed before any mutation. + expect(await fileExistsAtPath(outsideFile)).toBe(true) + expect(await fs.readFile(outsideFile, "utf-8")).toBe("outside") + }) + it("emits a restore event when a file is restored", async () => { const restoreListener = vi.fn() service.on("restore", restoreListener) From 630f273ca093f63130fb420106072d58ed1ab5c8 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 29 Aug 2026 17:48:28 +0800 Subject: [PATCH 06/48] fix(checkpoints): roll change-card files back to their pre-step state and add restore-latest (B3c, #1375) A change card is keyed by the checkpoint its own step produced, so restoring a card file to that checkpoint restored the post-write state - a no-op for the newest card and a backwards-time-travel for older ones. Resolve each file's restore target from the B2 journal instead: the file's immediately preceding journal entry's checkpoint (its pre-step state), or the task-start baseline when no earlier step wrote the file (undoing a create removes the file; undoing a delete restores it). Add restoreLatestFile as the forward direction: a file back to its most recent recorded write, a successful no-op when the task never wrote it. --- .../checkpoints/__tests__/rollback.spec.ts | 297 +++++++++++++----- src/core/checkpoints/rollback.ts | 208 ++++++++---- 2 files changed, 371 insertions(+), 134 deletions(-) diff --git a/src/core/checkpoints/__tests__/rollback.spec.ts b/src/core/checkpoints/__tests__/rollback.spec.ts index 36a7031497..044f93e9eb 100644 --- a/src/core/checkpoints/__tests__/rollback.spec.ts +++ b/src/core/checkpoints/__tests__/rollback.spec.ts @@ -5,9 +5,10 @@ import path from "path" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import type { Task } from "../../task/Task" +import type { ChangeJournalEntry } from "../changeJournal" import { getCheckpointService } from "../index" import { appendChange } from "../changeJournal" -import { rollbackFile, rollbackStep } from "../rollback" +import { restoreLatestFile, rollbackFile, rollbackStep } from "../rollback" vi.mock("../index", () => ({ getCheckpointService: vi.fn(), @@ -27,10 +28,22 @@ function makeTask(): Task { } as unknown as Task } +/** A checkpoint-service double with a recording restoreFile and a baseline. */ +function serviceWith(baseHash: string | undefined) { + const restoreFile = vi.fn().mockResolvedValue(undefined) + return { baseHash, restoreFile } +} + +async function seedJournal(entries: ChangeJournalEntry[]): Promise { + for (const entry of entries) { + await appendChange(globalStorageDir, "task-rollback", entry) + } +} + let globalStorageDir: string beforeEach(async () => { - globalStorageDir = await fs.mkdtemp(path.join(os.tmpdir(), "b3a-rollback-")) + globalStorageDir = await fs.mkdtemp(path.join(os.tmpdir(), "b3c-rollback-")) mockedGetCheckpointService.mockReset() }) @@ -38,15 +51,81 @@ afterEach(async () => { await fs.rm(globalStorageDir, { recursive: true, force: true }) }) -describe("rollbackFile (B3a)", () => { - it("restores the file through the task's checkpoint service", async () => { - const restoreFile = vi.fn().mockResolvedValue(undefined) - mockedGetCheckpointService.mockResolvedValue({ restoreFile }) +describe("rollbackFile (B3c: undo the step's write to the file)", () => { + it("restores the file to the PREVIOUS step's checkpoint when an earlier entry exists", async () => { + await seedJournal([ + { path: "src/a.ts", operation: "create", checkpointId: "sha-1" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-2" }, + ]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackFile(makeTask(), "sha-2", "src/a.ts") + + expect(outcome).toEqual({ filePath: "src/a.ts", success: true }) + // The pre-step state is the previous step's post-write checkpoint — not + // the step's own (post-write) checkpoint. + expect(service.restoreFile).toHaveBeenCalledTimes(1) + expect(service.restoreFile).toHaveBeenCalledWith("sha-1", "src/a.ts") + }) + + it("restores from the task-start baseline when the file has no earlier entry", async () => { + await seedJournal([{ path: "src/a.ts", operation: "create", checkpointId: "sha-1" }]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) const outcome = await rollbackFile(makeTask(), "sha-1", "src/a.ts") expect(outcome).toEqual({ filePath: "src/a.ts", success: true }) - expect(restoreFile).toHaveBeenCalledWith("sha-1", "src/a.ts") + expect(service.restoreFile).toHaveBeenCalledWith("base-0", "src/a.ts") + }) + + it("resolves through the first entry of a multi-write step", async () => { + // One patch writes the same file twice: two entries share the step + // checkpoint. The pre-step state is still the entry before the first + // one of the step. + await seedJournal([ + { path: "src/a.ts", operation: "create", checkpointId: "sha-1" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-2" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-2" }, + ]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackFile(makeTask(), "sha-2", "src/a.ts") + + expect(outcome.success).toBe(true) + expect(service.restoreFile).toHaveBeenCalledWith("sha-1", "src/a.ts") + }) + + it("fails cleanly when the file is not part of the given step checkpoint", async () => { + await seedJournal([{ path: "src/a.ts", operation: "create", checkpointId: "sha-1" }]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackFile(makeTask(), "sha-2", "src/a.ts") + + expect(outcome).toEqual({ + filePath: "src/a.ts", + success: false, + error: "File is not part of this step's checkpoint", + }) + expect(service.restoreFile).not.toHaveBeenCalled() + }) + + it("fails cleanly when no earlier checkpoint exists and the baseline is unavailable", async () => { + await seedJournal([{ path: "src/a.ts", operation: "create", checkpointId: "sha-1" }]) + const service = serviceWith(undefined) + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackFile(makeTask(), "sha-1", "src/a.ts") + + expect(outcome).toEqual({ + filePath: "src/a.ts", + success: false, + error: "No checkpoint available to restore", + }) + expect(service.restoreFile).not.toHaveBeenCalled() }) it("fails cleanly when checkpoints are not enabled", async () => { @@ -62,107 +141,118 @@ describe("rollbackFile (B3a)", () => { }) it("reports the service error without throwing", async () => { - mockedGetCheckpointService.mockResolvedValue({ - restoreFile: vi.fn().mockRejectedValue(new Error("pathspec did not match")), - }) + await seedJournal([{ path: "src/a.ts", operation: "create", checkpointId: "sha-1" }]) + const service = serviceWith("base-0") + service.restoreFile.mockRejectedValue(new Error("pathspec did not match")) + mockedGetCheckpointService.mockResolvedValue(service) - const outcome = await rollbackFile(makeTask(), "sha-bad", "src/a.ts") + const outcome = await rollbackFile(makeTask(), "sha-1", "src/a.ts") expect(outcome.success).toBe(false) expect(outcome.error).toContain("pathspec did not match") }) it("stringifies non-Error rejections into the outcome", async () => { - mockedGetCheckpointService.mockResolvedValue({ - restoreFile: vi.fn().mockRejectedValue("raw failure"), - }) + await seedJournal([{ path: "src/a.ts", operation: "create", checkpointId: "sha-1" }]) + const service = serviceWith("base-0") + service.restoreFile.mockRejectedValue("raw failure") + mockedGetCheckpointService.mockResolvedValue(service) - const outcome = await rollbackFile(makeTask(), "sha-bad", "src/a.ts") + const outcome = await rollbackFile(makeTask(), "sha-1", "src/a.ts") expect(outcome).toEqual({ filePath: "src/a.ts", success: false, error: "raw failure" }) }) }) -describe("rollbackStep (B3a)", () => { - it("restores every step file from the step's checkpoint via the journal", async () => { - await appendChange(globalStorageDir, "task-rollback", { - path: "src/a.ts", - operation: "create", - checkpointId: "sha-step", - }) - await appendChange(globalStorageDir, "task-rollback", { - path: "src/b.ts", - operation: "update", - checkpointId: "sha-step", - }) - - const restoreFile = vi.fn().mockResolvedValue(undefined) - mockedGetCheckpointService.mockResolvedValue({ restoreFile }) +describe("rollbackStep (B3c: undo every file of the step)", () => { + it("restores every step file to its pre-step state", async () => { + await seedJournal([ + { path: "src/a.ts", operation: "create", checkpointId: "sha-1" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-2" }, + { path: "src/b.ts", operation: "update", checkpointId: "sha-2" }, + ]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) - const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/b.ts"], "sha-step") + const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/b.ts"], "sha-2") - expect(outcome.checkpointId).toBe("sha-step") + expect(outcome.checkpointId).toBe("sha-2") expect(outcome.files).toEqual([ { filePath: "src/a.ts", success: true }, { filePath: "src/b.ts", success: true }, ]) - expect(restoreFile).toHaveBeenCalledTimes(2) - expect(restoreFile).toHaveBeenNthCalledWith(1, "sha-step", "src/a.ts") - expect(restoreFile).toHaveBeenNthCalledWith(2, "sha-step", "src/b.ts") + expect(service.restoreFile).toHaveBeenCalledTimes(2) + expect(service.restoreFile).toHaveBeenNthCalledWith(1, "sha-1", "src/a.ts") + // src/b.ts has no earlier entry: its pre-step state is the baseline. + expect(service.restoreFile).toHaveBeenNthCalledWith(2, "base-0", "src/b.ts") }) - it("rejects a file that is not part of the given step checkpoint", async () => { - await appendChange(globalStorageDir, "task-rollback", { - path: "src/a.ts", - operation: "create", - checkpointId: "sha-step", - }) + it("keeps per-file failures isolated from the other step files", async () => { + await seedJournal([ + { path: "src/a.ts", operation: "create", checkpointId: "sha-1" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-2" }, + { path: "src/b.ts", operation: "create", checkpointId: "sha-2" }, + ]) + const service = serviceWith("base-0") + service.restoreFile.mockRejectedValueOnce(new Error("pathspec did not match")) + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/b.ts"], "sha-2") + + expect(outcome.files[0].success).toBe(false) + expect(outcome.files[0].error).toContain("pathspec did not match") + expect(outcome.files[1]).toEqual({ filePath: "src/b.ts", success: true }) + }) + + it("fails the file cleanly when no earlier checkpoint exists and the baseline is unavailable", async () => { + await seedJournal([{ path: "src/a.ts", operation: "create", checkpointId: "sha-2" }]) + const service = serviceWith(undefined) + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts"], "sha-2") + + expect(outcome.checkpointId).toBe("sha-2") + expect(outcome.files).toEqual([ + { filePath: "src/a.ts", success: false, error: "No checkpoint available to restore" }, + ]) + expect(service.restoreFile).not.toHaveBeenCalled() + }) - const restoreFile = vi.fn().mockResolvedValue(undefined) - mockedGetCheckpointService.mockResolvedValue({ restoreFile }) + it("rejects a file that is not part of the given step checkpoint", async () => { + await seedJournal([{ path: "src/a.ts", operation: "create", checkpointId: "sha-2" }]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) - const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/other.ts"], "sha-step") + const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/other.ts"], "sha-2") expect(outcome.files[0]).toEqual({ filePath: "src/a.ts", success: true }) expect(outcome.files[1].success).toBe(false) expect(outcome.files[1].error).toBe("File is not part of this step's checkpoint") - expect(restoreFile).toHaveBeenCalledTimes(1) + expect(service.restoreFile).toHaveBeenCalledTimes(1) }) it("falls back to the latest journal entry per file without a step checkpoint id", async () => { - await appendChange(globalStorageDir, "task-rollback", { - path: "src/a.ts", - operation: "create", - checkpointId: "sha-1", - }) - await appendChange(globalStorageDir, "task-rollback", { - path: "src/a.ts", - operation: "update", - checkpointId: "sha-2", - }) - - const restoreFile = vi.fn().mockResolvedValue(undefined) - mockedGetCheckpointService.mockResolvedValue({ restoreFile }) + await seedJournal([ + { path: "src/a.ts", operation: "create", checkpointId: "sha-1" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-2" }, + ]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) const outcome = await rollbackStep(makeTask(), ["src/a.ts"]) - expect(outcome.checkpointId).toBe("sha-2") - expect(restoreFile).toHaveBeenCalledWith("sha-2", "src/a.ts") + expect(outcome.checkpointId).toBeUndefined() + expect(outcome.files).toEqual([{ filePath: "src/a.ts", success: true }]) + expect(service.restoreFile).toHaveBeenCalledWith("sha-2", "src/a.ts") }) - it("fails listed files without journal entries and keeps the checkpoint when resolvable", async () => { - await appendChange(globalStorageDir, "task-rollback", { - path: "src/a.ts", - operation: "create", - checkpointId: "sha-1", - }) - - const restoreFile = vi.fn().mockResolvedValue(undefined) - mockedGetCheckpointService.mockResolvedValue({ restoreFile }) + it("fails listed files without journal entries", async () => { + await seedJournal([{ path: "src/a.ts", operation: "create", checkpointId: "sha-1" }]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/missing.ts"]) - expect(outcome.checkpointId).toBe("sha-1") expect(outcome.files[0]).toEqual({ filePath: "src/a.ts", success: true }) expect(outcome.files[1].success).toBe(false) expect(outcome.files[1].error).toBe("No change journal entry for this file") @@ -175,24 +265,77 @@ describe("rollbackStep (B3a)", () => { providerRef: { deref: vi.fn().mockReturnValue(undefined) }, } as unknown as Task - const restoreFile = vi.fn().mockResolvedValue(undefined) - mockedGetCheckpointService.mockResolvedValue({ restoreFile }) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) - const outcome = await rollbackStep(task, ["src/a.ts"], "sha-step") + const outcome = await rollbackStep(task, ["src/a.ts"], "sha-2") - expect(outcome.checkpointId).toBe("sha-step") + expect(outcome.checkpointId).toBe("sha-2") expect(outcome.files[0].success).toBe(false) expect(outcome.files[0].error).toBe("File is not part of this step's checkpoint") - expect(restoreFile).not.toHaveBeenCalled() + expect(service.restoreFile).not.toHaveBeenCalled() }) + it("fails every file when checkpoints are not enabled", async () => { mockedGetCheckpointService.mockResolvedValue(undefined) - const outcome = await rollbackStep(makeTask(), ["src/a.ts"], "sha-step") + const outcome = await rollbackStep(makeTask(), ["src/a.ts"], "sha-2") - expect(outcome.checkpointId).toBe("sha-step") + expect(outcome.checkpointId).toBe("sha-2") expect(outcome.files).toEqual([ { filePath: "src/a.ts", success: false, error: "Checkpoints are not enabled for this task" }, ]) }) }) + +describe("restoreLatestFile (B3c: forward direction)", () => { + it("restores the file to its most recent recorded write checkpoint", async () => { + await seedJournal([ + { path: "src/a.ts", operation: "create", checkpointId: "sha-1" }, + { path: "src/b.ts", operation: "update", checkpointId: "sha-1" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-2" }, + ]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await restoreLatestFile(makeTask(), "src/a.ts") + + expect(outcome).toEqual({ filePath: "src/a.ts", success: true }) + expect(service.restoreFile).toHaveBeenCalledWith("sha-2", "src/a.ts") + }) + + it("is a successful no-op for a file the task never wrote", async () => { + await seedJournal([{ path: "src/b.ts", operation: "update", checkpointId: "sha-1" }]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await restoreLatestFile(makeTask(), "src/a.ts") + + expect(outcome).toEqual({ filePath: "src/a.ts", success: true, noOp: true }) + expect(service.restoreFile).not.toHaveBeenCalled() + }) + + it("fails cleanly when checkpoints are not enabled", async () => { + mockedGetCheckpointService.mockResolvedValue(undefined) + + const outcome = await restoreLatestFile(makeTask(), "src/a.ts") + + expect(outcome).toEqual({ + filePath: "src/a.ts", + success: false, + error: "Checkpoints are not enabled for this task", + }) + }) + + it("reports the service error without throwing", async () => { + await seedJournal([{ path: "src/a.ts", operation: "create", checkpointId: "sha-1" }]) + const service = serviceWith("base-0") + service.restoreFile.mockRejectedValue(new Error("index.lock held")) + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await restoreLatestFile(makeTask(), "src/a.ts") + + expect(outcome.success).toBe(false) + expect(outcome.error).toContain("index.lock") + }) +}) diff --git a/src/core/checkpoints/rollback.ts b/src/core/checkpoints/rollback.ts index e2be09b621..3595e0385d 100644 --- a/src/core/checkpoints/rollback.ts +++ b/src/core/checkpoints/rollback.ts @@ -1,18 +1,26 @@ /** - * Per-file / per-step checkpoint rollback (B3a). + * Per-file / per-step checkpoint rollback (B3c). + * + * "Rollback" means UNDOING the change-card step: every file the step touched + * is restored to the state it had BEFORE the step ran. The restore target is + * resolved from the B2 change journal (`changes.jsonl`), whose entries record + * — in write order — the checkpoint commit each successful write produced: + * + * - a file written by an earlier step resolves to that earlier entry's + * checkpoint (the file's post-write state after the previous step, i.e. its + * pre-step state); + * - a file no earlier step wrote resolves to the task-start baseline + * (`service.baseHash`): undoing the step that created a file removes it, + * and undoing the step that deleted one brings it back; + * - `restoreLatestFile` is the forward direction: it brings a file back to + * the content of its most recent recorded write (a successful no-op when + * the task never wrote the file). * * Restores reuse the existing shadow-git service (`getCheckpointService` → * `RepoPerTaskCheckpointService.restoreFile`, the same instance whose - * `restoreCheckpoint` the checkpoints UI uses) — nothing is forked. - * - * - `rollbackFile` restores one file to the content it had at an explicit - * checkpoint commit (the "restore to any checkpoint" primitive). - * - `rollbackStep` restores every file a step touched to that step's - * checkpoint. The step's checkpoint is resolved from the B2 change - * journal (`changes.jsonl`), whose entries key each written file by the - * checkpoint commit it produced. When the caller already knows the step's - * checkpoint id (the change-card payload carries it), pass it so the - * journal is filtered to exactly that step's writes. + * `restoreCheckpoint` the checkpoints UI uses) — nothing is forked. Only the + * named file's working-tree content is replaced; the shadow repo's HEAD and + * the checkpoint list are untouched (unlike a full `restoreCheckpoint`). */ import type { Task } from "../task/Task" @@ -23,49 +31,113 @@ export interface RollbackFileOutcome { filePath: string success: boolean error?: string + /** True when `restoreLatestFile` found no recorded write: the working tree was left as-is. */ + noOp?: boolean } export interface RollbackStepOutcome { - /** The step checkpoint the files were restored from, when resolvable. */ + /** The step checkpoint the files were resolved against, when provided. */ checkpointId?: string files: RollbackFileOutcome[] } const NOT_ENABLED_ERROR = "Checkpoints are not enabled for this task" +const NO_TARGET_ERROR = "No checkpoint available to restore" +const NOT_IN_STEP_ERROR = "File is not part of this step's checkpoint" +const NO_ENTRY_ERROR = "No change journal entry for this file" + +type CheckpointService = NonNullable>> /** - * Restore a single file to its state at `checkpointId`. - * - * Only the named file's working-tree content is replaced; the shadow repo's - * HEAD and the checkpoint list are untouched (unlike a full - * `restoreCheckpoint`). + * Load the task's change journal. `loadChanges` never throws: an absent or + * unreadable file and a torn tail resolve to the readable prefix (or `[]`). */ -export async function rollbackFile(task: Task, checkpointId: string, filePath: string): Promise { - const service = await getCheckpointService(task) +async function loadTaskEntries(task: Task): Promise { + const globalStorageDir = task.providerRef.deref()?.context.globalStorageUri.fsPath - if (!service) { - return { filePath, success: false, error: NOT_ENABLED_ERROR } + return globalStorageDir ? loadChanges(globalStorageDir, task.taskId) : [] +} + +/** + * Resolve the checkpoint a file must be restored from in order to undo the + * step identified by `stepCheckpointId` (see the module docstring for the + * resolution rules). + */ +function preStepRestoreTarget( + entries: ChangeJournalEntry[], + filePath: string, + stepCheckpointId: string, +): { target?: string; baseline?: boolean; error?: string } { + const fileEntries = entries.filter((entry) => entry.path === filePath) + const stepIndex = fileEntries.findIndex((entry) => entry.checkpointId === stepCheckpointId) + + if (stepIndex === -1) { + return { error: NOT_IN_STEP_ERROR } + } + if (stepIndex === 0) { + return { baseline: true } } + return { target: fileEntries[stepIndex - 1].checkpointId } +} +/** + * Run one `restoreFile` and shape the outcome. A failed restore never throws + * out of the rollback API; it is reported on the per-file outcome instead. + */ +async function performRestore( + service: CheckpointService, + target: string, + filePath: string, +): Promise { try { - await service.restoreFile(checkpointId, filePath) + await service.restoreFile(target, filePath) return { filePath, success: true } } catch (error) { const message = error instanceof Error ? error.message : String(error) - console.error(`[rollbackFile] failed to restore ${filePath} from checkpoint ${checkpointId}: ${message}`) + console.error(`[checkpointRollback] failed to restore ${filePath} from checkpoint ${target}: ${message}`) return { filePath, success: false, error: message } } } /** - * Restore every file of a step to the step's checkpoint. + * Restore a single file to the state it had BEFORE the change-card step + * identified by `stepCheckpointId` — undoing that step's write to the file. + */ +export async function rollbackFile( + task: Task, + stepCheckpointId: string, + filePath: string, +): Promise { + const service = await getCheckpointService(task) + + if (!service) { + return { filePath, success: false, error: NOT_ENABLED_ERROR } + } + + const entries = await loadTaskEntries(task) + const resolved = preStepRestoreTarget(entries, filePath, stepCheckpointId) + + if (resolved.error) { + return { filePath, success: false, error: resolved.error } + } + + const target = resolved.baseline ? service.baseHash : resolved.target + + if (!target) { + return { filePath, success: false, error: NO_TARGET_ERROR } + } + + return performRestore(service, target, filePath) +} + +/** + * Restore every file of a step to the state it had before the step ran. * - * `stepFiles` comes from the B2 journal entries for the step's checkpoint id - * (which the change-card payload also carries). Each file is resolved to its - * step checkpoint through the journal: - * - with `stepCheckpointId`, only entries for that checkpoint are considered - * (exactly the step's writes), and every listed file is restored from it; - * - without it, the latest journal entry per file is used as a fallback. + * `stepFiles` comes from the change-card payload (the B2 journal entries for + * the step's checkpoint id). Each file is resolved to its pre-step checkpoint + * through the journal (see the module docstring); without a step checkpoint id + * the latest journal entry per file is used instead (restoring to the file's + * last recorded state, the same direction as `restoreLatestFile`). */ export async function rollbackStep( task: Task, @@ -81,39 +153,61 @@ export async function rollbackStep( } } - // loadChanges never throws: an absent or torn journal resolves to its - // readable prefix (or []), so no error handling is needed here. - const globalStorageDir = task.providerRef.deref()?.context.globalStorageUri.fsPath - const entries = globalStorageDir ? await loadChanges(globalStorageDir, task.taskId) : [] + const entries = await loadTaskEntries(task) + const files: RollbackFileOutcome[] = [] - // Latest journal entry per file (journal lines preserve write order). - const latestByPath = new Map() - for (const entry of entries) { - latestByPath.set(entry.path, entry) - } + for (const filePath of stepFiles) { + if (stepCheckpointId) { + const resolved = preStepRestoreTarget(entries, filePath, stepCheckpointId) - const checkpointId = - stepCheckpointId ?? stepFiles.map((filePath) => latestByPath.get(filePath)?.checkpointId).find(Boolean) + if (resolved.error) { + files.push({ filePath, success: false, error: resolved.error }) + continue + } - const files: RollbackFileOutcome[] = [] - for (const filePath of stepFiles) { - const entry = stepCheckpointId - ? entries.find((e) => e.path === filePath && e.checkpointId === stepCheckpointId) - : latestByPath.get(filePath) - - if (!entry) { - files.push({ - filePath, - success: false, - error: stepCheckpointId - ? "File is not part of this step's checkpoint" - : "No change journal entry for this file", - }) + const target = resolved.baseline ? service.baseHash : resolved.target + + if (!target) { + files.push({ filePath, success: false, error: NO_TARGET_ERROR }) + continue + } + + files.push(await performRestore(service, target, filePath)) continue } - files.push(await rollbackFile(task, entry.checkpointId, filePath)) + const latest = [...entries].reverse().find((entry) => entry.path === filePath) + + if (!latest) { + files.push({ filePath, success: false, error: NO_ENTRY_ERROR }) + continue + } + + files.push(await performRestore(service, latest.checkpointId, filePath)) + } + + return { checkpointId: stepCheckpointId, files } +} + +/** + * Restore one file to the latest recorded version: the content of its most + * recent successful write checkpoint (the forward direction to a rollback). + * A file the task never wrote has no recorded version — the working tree is + * left as-is and the outcome is a successful no-op. + */ +export async function restoreLatestFile(task: Task, filePath: string): Promise { + const service = await getCheckpointService(task) + + if (!service) { + return { filePath, success: false, error: NOT_ENABLED_ERROR } + } + + const entries = await loadTaskEntries(task) + const latest = [...entries].reverse().find((entry) => entry.path === filePath) + + if (!latest) { + return { filePath, success: true, noOp: true } } - return { checkpointId, files } + return performRestore(service, latest.checkpointId, filePath) } From 77c809cdbd634836b56bcd9063d6844b76ff87f0 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 29 Aug 2026 19:06:41 +0800 Subject: [PATCH 07/48] ci: retry ubuntu unit test (flaky vitest-worker teardown race in Task.throttle, no code change; B3c, #1375) From 39afe1b15fd1024df47fc4cdd03595fa57b1a606 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 29 Aug 2026 21:32:28 +0800 Subject: [PATCH 08/48] fix(checkpoints): reject stale-card rollback and fail on unreadable change journal (CodeRabbit, B3c, #1375) Tighten rollback-service semantics (CodeRabbit review of B3c): - rollbackFile / rollbackStep now reject rolling back a step that is not the file's latest journal entry: restoring an older state would silently overwrite the file's newer writes. A full checkpoint restore still reaches any older state. - loadChanges propagates non-ENOENT read failures instead of reporting an empty journal; a journal that cannot be located or read now fails the restore instead of masquerading as "the task wrote nothing" (so restoreLatestFile can no longer report a no-op success for a task whose journal is unavailable). - Spec: stale-card rejection (file and step), unavailable / unreadable journal failures (EISDIR stand-in for a permission failure), non-Error rejection stringification. ShadowCheckpointService spec asserts the symlink guard's exact "resolves outside the workspace" message. --- .../__tests__/changeJournal.spec.ts | 10 ++ .../checkpoints/__tests__/rollback.spec.ts | 122 +++++++++++++++++- src/core/checkpoints/changeJournal.ts | 15 ++- src/core/checkpoints/rollback.ts | 88 +++++++++++-- .../__tests__/ShadowCheckpointService.spec.ts | 5 +- 5 files changed, 219 insertions(+), 21 deletions(-) diff --git a/src/core/checkpoints/__tests__/changeJournal.spec.ts b/src/core/checkpoints/__tests__/changeJournal.spec.ts index 4f3bbda053..aff2e086f3 100644 --- a/src/core/checkpoints/__tests__/changeJournal.spec.ts +++ b/src/core/checkpoints/__tests__/changeJournal.spec.ts @@ -62,6 +62,16 @@ describe("changeJournal", () => { expect(await loadChanges(tmpRoot, taskId)).toEqual([]) }) + it("propagates non-ENOENT read failures instead of reporting an empty journal", async () => { + // A directory at the journal path makes readFile fail with EISDIR — + // a stand-in for any permission or I/O failure (EACCES etc.). Such a + // failure must not be swallowed into "no changes": it would let a + // rollback report a no-op success without reading the history. + await fs.mkdir(journalPath(tmpRoot, taskId), { recursive: true }) + + await expect(loadChanges(tmpRoot, taskId)).rejects.toMatchObject({ code: "EISDIR" }) + }) + it("parses all entries in order with a clean tail", async () => { await appendChange(tmpRoot, taskId, entry({ checkpointId: "x" })) await appendChange(tmpRoot, taskId, entry({ checkpointId: "y" })) diff --git a/src/core/checkpoints/__tests__/rollback.spec.ts b/src/core/checkpoints/__tests__/rollback.spec.ts index 044f93e9eb..0af6384113 100644 --- a/src/core/checkpoints/__tests__/rollback.spec.ts +++ b/src/core/checkpoints/__tests__/rollback.spec.ts @@ -7,7 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import type { Task } from "../../task/Task" import type { ChangeJournalEntry } from "../changeJournal" import { getCheckpointService } from "../index" -import { appendChange } from "../changeJournal" +import * as changeJournal from "../changeJournal" +import { appendChange, journalPath } from "../changeJournal" import { restoreLatestFile, rollbackFile, rollbackStep } from "../rollback" vi.mock("../index", () => ({ @@ -98,6 +99,48 @@ describe("rollbackFile (B3c: undo the step's write to the file)", () => { expect(service.restoreFile).toHaveBeenCalledWith("sha-1", "src/a.ts") }) + it("rejects rolling back a step that is not the file's latest change", async () => { + // The file was written again by a later step (sha-2): rolling back the + // older step (sha-1) would overwrite the newer state, so it is + // rejected instead of silently destroying it. + await seedJournal([ + { path: "src/a.ts", operation: "create", checkpointId: "sha-1" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-2" }, + ]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackFile(makeTask(), "sha-1", "src/a.ts") + + expect(outcome).toEqual({ + filePath: "src/a.ts", + success: false, + error: "File was modified in a later step; roll back the latest change card first", + }) + expect(service.restoreFile).not.toHaveBeenCalled() + }) + + it("fails when the journal location is unavailable (no global storage)", async () => { + // No context on the provider double → the journal cannot even be + // located: a clear failure, not a silent miss on the file lookup. + const task = { + taskId: "task-rollback", + providerRef: { deref: vi.fn().mockReturnValue(undefined) }, + } as unknown as Task + + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackFile(task, "sha-1", "src/a.ts") + + expect(outcome).toEqual({ + filePath: "src/a.ts", + success: false, + error: "Change journal is unavailable for this task", + }) + expect(service.restoreFile).not.toHaveBeenCalled() + }) + it("fails cleanly when the file is not part of the given step checkpoint", async () => { await seedJournal([{ path: "src/a.ts", operation: "create", checkpointId: "sha-1" }]) const service = serviceWith("base-0") @@ -231,6 +274,28 @@ describe("rollbackStep (B3c: undo every file of the step)", () => { expect(service.restoreFile).toHaveBeenCalledTimes(1) }) + it("rejects the stale file of a step while restoring the others", async () => { + // src/a.ts was written again after this step's checkpoint, so its + // sha-2 entry is no longer the file's latest: only src/b.ts (whose + // latest entry IS sha-2) is restored. + await seedJournal([ + { path: "src/a.ts", operation: "create", checkpointId: "sha-1" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-2" }, + { path: "src/b.ts", operation: "create", checkpointId: "sha-2" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-3" }, + ]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/b.ts"], "sha-2") + + expect(outcome.files[0].success).toBe(false) + expect(outcome.files[0].error).toBe("File was modified in a later step; roll back the latest change card first") + expect(outcome.files[1]).toEqual({ filePath: "src/b.ts", success: true }) + expect(service.restoreFile).toHaveBeenCalledTimes(1) + expect(service.restoreFile).toHaveBeenCalledWith("base-0", "src/b.ts") + }) + it("falls back to the latest journal entry per file without a step checkpoint id", async () => { await seedJournal([ { path: "src/a.ts", operation: "create", checkpointId: "sha-1" }, @@ -258,8 +323,10 @@ describe("rollbackStep (B3c: undo every file of the step)", () => { expect(outcome.files[1].error).toBe("No change journal entry for this file") }) - it("treats a missing global storage directory as an empty journal", async () => { - // No context on the provider double → no journal location to read. + it("fails per file when the journal location is unavailable (no global storage)", async () => { + // No context on the provider double → the journal cannot even be + // located. That is a failure, not an empty journal: reporting the + // step as merely "not part of this checkpoint" would be misleading. const task = { taskId: "task-rollback", providerRef: { deref: vi.fn().mockReturnValue(undefined) }, @@ -272,7 +339,7 @@ describe("rollbackStep (B3c: undo every file of the step)", () => { expect(outcome.checkpointId).toBe("sha-2") expect(outcome.files[0].success).toBe(false) - expect(outcome.files[0].error).toBe("File is not part of this step's checkpoint") + expect(outcome.files[0].error).toBe("Change journal is unavailable for this task") expect(service.restoreFile).not.toHaveBeenCalled() }) @@ -315,6 +382,53 @@ describe("restoreLatestFile (B3c: forward direction)", () => { expect(service.restoreFile).not.toHaveBeenCalled() }) + it("fails when the journal location is unavailable (no global storage)", async () => { + // An unavailable journal is not "the task wrote nothing": a no-op + // success would claim a restore that never happened. + const task = { + taskId: "task-rollback", + providerRef: { deref: vi.fn().mockReturnValue(undefined) }, + } as unknown as Task + + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await restoreLatestFile(task, "src/a.ts") + + expect(outcome).toEqual({ + filePath: "src/a.ts", + success: false, + error: "Change journal is unavailable for this task", + }) + expect(service.restoreFile).not.toHaveBeenCalled() + }) + + it("fails when the journal cannot be read (an I/O error is not an empty journal)", async () => { + // A directory at the journal path makes readFile fail with EISDIR — + // a stand-in for any permission or I/O failure (EACCES etc.). + await fs.mkdir(journalPath(globalStorageDir, "task-rollback"), { recursive: true }) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await restoreLatestFile(makeTask(), "src/a.ts") + + expect(outcome.success).toBe(false) + expect(outcome.error).toContain("Change journal could not be read") + expect(service.restoreFile).not.toHaveBeenCalled() + }) + + it("stringifies a non-Error journal read failure into the outcome", async () => { + vi.spyOn(changeJournal, "loadChanges").mockRejectedValueOnce("raw journal failure") + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await restoreLatestFile(makeTask(), "src/a.ts") + + expect(outcome.success).toBe(false) + expect(outcome.error).toBe("Change journal could not be read: raw journal failure") + expect(service.restoreFile).not.toHaveBeenCalled() + }) + it("fails cleanly when checkpoints are not enabled", async () => { mockedGetCheckpointService.mockResolvedValue(undefined) diff --git a/src/core/checkpoints/changeJournal.ts b/src/core/checkpoints/changeJournal.ts index 88983b3663..79ee802eb7 100644 --- a/src/core/checkpoints/changeJournal.ts +++ b/src/core/checkpoints/changeJournal.ts @@ -56,7 +56,9 @@ export async function appendChange( * * Torn-tail repair: if the final line is truncated (JSON.parse fails), it is * silently discarded. The rest of the file is returned in order. An absent - * or empty journal returns []. + * or empty journal returns [] — but only an ABSENT file. Any other read + * failure (permissions, I/O) is rethrown: a journal that cannot be read must + * not be indistinguishable from one that is legitimately empty. */ export async function loadChanges( globalStorageDir: string, @@ -67,9 +69,14 @@ export async function loadChanges( let content: string try { content = await fs.readFile(filePath, "utf8") - } catch { - // File absent or unreadable → empty journal. - return [] + } catch (error) { + // A missing journal is a legitimate empty history; any other read + // failure (permissions, I/O) must propagate. Swallowing it would let + // a rollback report a no-op success without reading the history. + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return [] + } + throw error } if (!content.trim()) { diff --git a/src/core/checkpoints/rollback.ts b/src/core/checkpoints/rollback.ts index 3595e0385d..508789c957 100644 --- a/src/core/checkpoints/rollback.ts +++ b/src/core/checkpoints/rollback.ts @@ -16,6 +16,12 @@ * the content of its most recent recorded write (a successful no-op when * the task never wrote the file). * + * A file is only rolled back from the card of its most recent step: undoing + * an older step for a file that a later step wrote again would overwrite the + * newer state, so such a rollback is rejected (a full checkpoint restore + * still reaches any older state). A journal that cannot be located or read + * fails the restore instead of masquerading as "the task wrote nothing". + * * Restores reuse the existing shadow-git service (`getCheckpointService` → * `RepoPerTaskCheckpointService.restoreFile`, the same instance whose * `restoreCheckpoint` the checkpoints UI uses) — nothing is forked. Only the @@ -45,17 +51,45 @@ const NOT_ENABLED_ERROR = "Checkpoints are not enabled for this task" const NO_TARGET_ERROR = "No checkpoint available to restore" const NOT_IN_STEP_ERROR = "File is not part of this step's checkpoint" const NO_ENTRY_ERROR = "No change journal entry for this file" +const NO_JOURNAL_ERROR = "Change journal is unavailable for this task" +const NOT_LATEST_ERROR = "File was modified in a later step; roll back the latest change card first" type CheckpointService = NonNullable>> -/** - * Load the task's change journal. `loadChanges` never throws: an absent or - * unreadable file and a torn tail resolve to the readable prefix (or `[]`). - */ -async function loadTaskEntries(task: Task): Promise { +/** A readable journal (possibly legitimately empty) or the reason it could not be loaded. */ +type LoadedJournal = { entries: ChangeJournalEntry[] } | { error: string } + +// `undefined` = the journal cannot be located (provider reference gone); read failures (permissions, I/O) propagate. +async function loadTaskEntries(task: Task): Promise { const globalStorageDir = task.providerRef.deref()?.context.globalStorageUri.fsPath - return globalStorageDir ? loadChanges(globalStorageDir, task.taskId) : [] + return globalStorageDir ? loadChanges(globalStorageDir, task.taskId) : undefined +} + +// Single discriminated result for callers: a readable journal (possibly empty) or a failure. +async function loadTaskJournal(task: Task): Promise { + try { + const entries = await loadTaskEntries(task) + + if (entries === undefined) { + return { error: NO_JOURNAL_ERROR } + } + + return { entries } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { error: `Change journal could not be read: ${message}` } + } +} + +/** The most recent journal entry recorded for `filePath`, if any. */ +function latestEntry(entries: ChangeJournalEntry[], filePath: string): ChangeJournalEntry | undefined { + for (let i = entries.length - 1; i >= 0; i--) { + if (entries[i].path === filePath) { + return entries[i] + } + } + return undefined } /** @@ -74,6 +108,16 @@ function preStepRestoreTarget( if (stepIndex === -1) { return { error: NOT_IN_STEP_ERROR } } + + // Only the file's most recent step may be rolled back: restoring an older + // state would overwrite the file's newer writes. A multi-write step shares + // one checkpoint id, so compare on the latest entry's checkpoint id. + const latest = fileEntries[fileEntries.length - 1] + + if (latest.checkpointId !== stepCheckpointId) { + return { error: NOT_LATEST_ERROR } + } + if (stepIndex === 0) { return { baseline: true } } @@ -114,8 +158,13 @@ export async function rollbackFile( return { filePath, success: false, error: NOT_ENABLED_ERROR } } - const entries = await loadTaskEntries(task) - const resolved = preStepRestoreTarget(entries, filePath, stepCheckpointId) + const journal = await loadTaskJournal(task) + + if ("error" in journal) { + return { filePath, success: false, error: journal.error } + } + + const resolved = preStepRestoreTarget(journal.entries, filePath, stepCheckpointId) if (resolved.error) { return { filePath, success: false, error: resolved.error } @@ -153,9 +202,19 @@ export async function rollbackStep( } } - const entries = await loadTaskEntries(task) + const journal = await loadTaskJournal(task) const files: RollbackFileOutcome[] = [] + if ("error" in journal) { + const journalError = journal.error + return { + checkpointId: stepCheckpointId, + files: stepFiles.map((filePath) => ({ filePath, success: false, error: journalError })), + } + } + + const entries = journal.entries + for (const filePath of stepFiles) { if (stepCheckpointId) { const resolved = preStepRestoreTarget(entries, filePath, stepCheckpointId) @@ -176,7 +235,7 @@ export async function rollbackStep( continue } - const latest = [...entries].reverse().find((entry) => entry.path === filePath) + const latest = latestEntry(entries, filePath) if (!latest) { files.push({ filePath, success: false, error: NO_ENTRY_ERROR }) @@ -202,8 +261,13 @@ export async function restoreLatestFile(task: Task, filePath: string): Promise entry.path === filePath) + const journal = await loadTaskJournal(task) + + if ("error" in journal) { + return { filePath, success: false, error: journal.error } + } + + const latest = latestEntry(journal.entries, filePath) if (!latest) { return { filePath, success: true, noOp: true } diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index f1e11ea003..32be9b67e9 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -283,8 +283,11 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( // lexically `link/sneaky.txt` is inside the workspace. await fs.symlink(outsideDir, path.join(service.workspaceDir, "link"), "dir") + // "resolves outside the workspace" is the real-path (symlink) + // guard's message; the bare "outside the workspace" would also + // match the lexical guard's error. await expect(service.restoreFile(commit1!.commit, path.join("link", "sneaky.txt"))).rejects.toThrow( - /outside the workspace/, + /resolves outside the workspace/, ) // The outside file survives: the restore failed before any mutation. From 87471b558f1ffcb05eb0f0ff09c2dc5806b4a54a Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 30 Aug 2026 12:33:36 +0800 Subject: [PATCH 09/48] =?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 10fdd195b69f452ef79394110b5a79e407e5116b Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 30 Aug 2026 18:51:07 +0800 Subject: [PATCH 10/48] fix(checkpoints): address CodeRabbit review feedback (PR 1410) --- .../settings/CheckpointSettings.tsx | 25 ++++++- .../src/components/settings/SettingsView.tsx | 4 ++ .../__tests__/CheckpointSettings.spec.tsx | 69 +++++++++++++++++++ .../settings/__tests__/SettingsView.spec.tsx | 69 +++++++++++++++++++ webview-ui/src/i18n/locales/nl/settings.json | 2 +- 5 files changed, 167 insertions(+), 2 deletions(-) diff --git a/webview-ui/src/components/settings/CheckpointSettings.tsx b/webview-ui/src/components/settings/CheckpointSettings.tsx index 7ea12ef873..e6c8558f16 100644 --- a/webview-ui/src/components/settings/CheckpointSettings.tsx +++ b/webview-ui/src/components/settings/CheckpointSettings.tsx @@ -14,19 +14,25 @@ import { MAX_CHECKPOINT_TIMEOUT_SECONDS, MIN_CHECKPOINT_TIMEOUT_SECONDS, DEFAULT_PER_WRITE_CHECKPOINTS, + DEFAULT_CHANGE_CARD_DETAIL, + type ChangeCardDetail, } from "@roo-code/types" type CheckpointSettingsProps = HTMLAttributes & { enableCheckpoints?: boolean checkpointTimeout?: number perWriteCheckpoints?: boolean - setCachedStateField: SetCachedStateField<"enableCheckpoints" | "checkpointTimeout" | "perWriteCheckpoints"> + changeCardDetail?: ChangeCardDetail + setCachedStateField: SetCachedStateField< + "enableCheckpoints" | "checkpointTimeout" | "perWriteCheckpoints" | "changeCardDetail" + > } export const CheckpointSettings = ({ enableCheckpoints, checkpointTimeout, perWriteCheckpoints, + changeCardDetail, setCachedStateField, ...props }: CheckpointSettingsProps) => { @@ -52,6 +58,23 @@ export const CheckpointSettings = ({ + + { + setCachedStateField("changeCardDetail", e.target.checked ? "full" : "summary") + }} + data-testid="change-card-detail-checkbox"> + {t("settings:checkpoints.changeCardDetail.label")} + +
+ {t("settings:checkpoints.changeCardDetail.description")} +
+
+ (({ onDone, t enableCheckpoints, checkpointTimeout, perWriteCheckpoints, + changeCardDetail, experiments, maxOpenTabsContext, maxWorkspaceFiles, @@ -413,6 +415,7 @@ const SettingsView = forwardRef(({ onDone, t enableCheckpoints: enableCheckpoints ?? false, checkpointTimeout: checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, perWriteCheckpoints: perWriteCheckpoints ?? DEFAULT_PER_WRITE_CHECKPOINTS, + changeCardDetail: changeCardDetail ?? DEFAULT_CHANGE_CARD_DETAIL, writeDelayMs, diffFuzzyThreshold, terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? 30_000, @@ -851,6 +854,7 @@ const SettingsView = forwardRef(({ onDone, t enableCheckpoints={enableCheckpoints} checkpointTimeout={checkpointTimeout} perWriteCheckpoints={perWriteCheckpoints} + changeCardDetail={changeCardDetail} setCachedStateField={setCachedStateField} /> )} diff --git a/webview-ui/src/components/settings/__tests__/CheckpointSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/CheckpointSettings.spec.tsx index b08c15ace7..7d20c2537e 100644 --- a/webview-ui/src/components/settings/__tests__/CheckpointSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/CheckpointSettings.spec.tsx @@ -13,6 +13,12 @@ vi.mock("@/i18n/TranslationContext", () => ({ if (key === "settings:checkpoints.perWrite.description") { return "Record a checkpoint snapshot after every successful file write by the agent" } + if (key === "settings:checkpoints.changeCardDetail.label") { + return "Show full diff in change cards" + } + if (key === "settings:checkpoints.changeCardDetail.description") { + return "Include the full unified diff inline for every file in per-step change cards" + } return key }, }), @@ -122,4 +128,67 @@ describe("CheckpointSettings", () => { expect(setCachedStateField).toHaveBeenCalledWith("perWriteCheckpoints", false) }) + + it("renders the change card detail checkbox unchecked by default when the value is unset", () => { + render() + + const checkbox = screen.getByRole("checkbox", { name: "Show full diff in change cards" }) + expect(checkbox).not.toBeChecked() + }) + + it("renders the change card detail checkbox checked when the saved value is full", () => { + render( + , + ) + + const checkbox = screen.getByRole("checkbox", { name: "Show full diff in change cards" }) + expect(checkbox).toBeChecked() + }) + + it("renders the change card detail checkbox unchecked when the saved value is summary", () => { + render( + , + ) + + const checkbox = screen.getByRole("checkbox", { name: "Show full diff in change cards" }) + expect(checkbox).not.toBeChecked() + }) + + it("caches the changeCardDetail full value when the user checks the box", () => { + render( + , + ) + + const checkbox = screen.getByRole("checkbox", { name: "Show full diff in change cards" }) + fireEvent.click(checkbox) + + expect(setCachedStateField).toHaveBeenCalledWith("changeCardDetail", "full") + }) + + it("caches the changeCardDetail summary value when the user unchecks the box", () => { + render( + , + ) + + const checkbox = screen.getByRole("checkbox", { name: "Show full diff in change cards" }) + fireEvent.click(checkbox) + + expect(setCachedStateField).toHaveBeenCalledWith("changeCardDetail", "summary") + }) }) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx index a3aa131902..8897b0fffa 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx @@ -460,6 +460,75 @@ describe("SettingsView - Sound Settings", () => { ) }) + it("saves the changeCardDetail full selection on save", async () => { + const { activateTab, getSettingsContent } = renderSettingsView({ + settingsImportedAt: new Date().toISOString(), + }) + + activateTab("checkpoints") + const content = getSettingsContent() + const checkbox = await within(content).findByTestId("change-card-detail-checkbox") + expect(checkbox).not.toBeChecked() + + fireEvent.click(checkbox) + fireEvent.click(screen.getByTestId("save-button")) + + await waitFor(() => + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ changeCardDetail: "full" }), + }), + ), + ) + }) + + it("saves the changeCardDetail summary default when the value is unset", async () => { + const { activateTab, getSettingsContent } = renderSettingsView({ + settingsImportedAt: new Date().toISOString(), + }) + + activateTab("checkpoints") + const content = getSettingsContent() + const checkbox = await within(content).findByTestId("change-card-detail-checkbox") + expect(checkbox).not.toBeChecked() + + fireEvent.click(screen.getByTestId("save-button")) + + await waitFor(() => + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ changeCardDetail: "summary" }), + }), + ), + ) + }) + + it("reflects the saved changeCardDetail full value in the checkbox and saves summary when unchecked", async () => { + const { activateTab, getSettingsContent } = renderSettingsView({ + changeCardDetail: "full", + settingsImportedAt: new Date().toISOString(), + }) + + activateTab("checkpoints") + const content = getSettingsContent() + const checkbox = await within(content).findByTestId("change-card-detail-checkbox") + await waitFor(() => expect(checkbox).toBeChecked()) + + fireEvent.click(checkbox) + fireEvent.click(screen.getByTestId("save-button")) + + await waitFor(() => + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ changeCardDetail: "summary" }), + }), + ), + ) + }) + it("shows tts slider when sound is enabled", () => { // Render once and get the activateTab helper const { activateTab, getSettingsContent } = renderSettingsView() diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 8295b30ce7..ba9b2758bd 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -708,7 +708,7 @@ }, "changeCardDetail": { "label": "Volledige diff tonen in wijzigingskaarten", - "description": "Bevat de volledige unified diff inline voor elk bestand in wijzigingskaarten per stap. Bij uitgeschakeld tonen de kaarten alleen de bestandslijst met toegevoegde/verwijderde regels." + "description": "Bevat de volledige unified diff inline voor elk bestand in wijzigingskaarten per stap. Wanneer deze optie is uitgeschakeld, tonen de kaarten alleen de bestandslijst met toegevoegde/verwijderde regels." } }, "notifications": { From 8e51b9cb5479c3e61352b318558f54735d7ebde1 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 29 Sep 2026 08:44:01 +0800 Subject: [PATCH 11/48] test(visual): regenerate electron chat-dark sidebar reference for #1410 The extension-host visual smoke scenario renders the fws change-card/rollback buttons (See New Changes / Restore Changes) below the Task Completed card. The committed reference predates the feature and fails with a deterministic 432px diff (stable across repeats; identical render on all fws branch heads, verified by byte hash). Regenerated from this head's CI render; the diff is exactly the two-button region. --- .../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 25190f8fab8e199e4d46ee9d31374109b834c5c7 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 12 Oct 2026 22:34:34 +0800 Subject: [PATCH 12/48] style(checkpoints): prettier-format files flagged by the new format:check gate Main added a pnpm format:check step to the compile job; these files in this PR's diff were written before the gate existed (changeJournal files ported from B2, applyPatch spec from B3a - same formatting fix as those units). Formatting only. --- src/core/checkpoints/__tests__/changeJournal.spec.ts | 4 +++- src/core/checkpoints/changeJournal.ts | 11 ++--------- .../tools/__tests__/applyPatchTool.execute.spec.ts | 9 +++------ 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/src/core/checkpoints/__tests__/changeJournal.spec.ts b/src/core/checkpoints/__tests__/changeJournal.spec.ts index aff2e086f3..20f53551aa 100644 --- a/src/core/checkpoints/__tests__/changeJournal.spec.ts +++ b/src/core/checkpoints/__tests__/changeJournal.spec.ts @@ -114,7 +114,9 @@ describe("changeJournal", () => { // Corrupt the first line in place, then append a valid entry after it. await fs.writeFile( journalPath(tmpRoot, taskId), - '"{"path":"src/corrupt.ts","operation":"update"\n' + JSON.stringify(entry({ checkpointId: "after" })) + "\n", + '"{"path":"src/corrupt.ts","operation":"update"\n' + + JSON.stringify(entry({ checkpointId: "after" })) + + "\n", ) const result = await loadChanges(tmpRoot, taskId) diff --git a/src/core/checkpoints/changeJournal.ts b/src/core/checkpoints/changeJournal.ts index 79ee802eb7..850d2b7f1d 100644 --- a/src/core/checkpoints/changeJournal.ts +++ b/src/core/checkpoints/changeJournal.ts @@ -40,11 +40,7 @@ export function journalPath(globalStorageDir: string, taskId: string): string { * Uses appendFile so each write is a single syscall — minimal torn-write risk. * Creates parent directories if they don't exist yet (e.g. first checkpoint). */ -export async function appendChange( - globalStorageDir: string, - taskId: string, - entry: ChangeJournalEntry, -): Promise { +export async function appendChange(globalStorageDir: string, taskId: string, entry: ChangeJournalEntry): Promise { const filePath = journalPath(globalStorageDir, taskId) await fs.mkdir(path.dirname(filePath), { recursive: true }) const line = JSON.stringify(entry) + "\n" @@ -60,10 +56,7 @@ export async function appendChange( * failure (permissions, I/O) is rethrown: a journal that cannot be read must * not be indistinguishable from one that is legitimately empty. */ -export async function loadChanges( - globalStorageDir: string, - taskId: string, -): Promise { +export async function loadChanges(globalStorageDir: string, taskId: string): Promise { const filePath = journalPath(globalStorageDir, taskId) let content: string diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 10c90d36a6..b525dfff52 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -701,12 +701,9 @@ describe("ApplyPatchTool.execute - delete file success path", () => { // reported as a failed tool error - but the destination write was // made on disk and must still be covered by the checkpoint/journal. expect(mockedCheckpointSave).toHaveBeenCalledOnce() - expect(mockedCheckpointSave).toHaveBeenCalledWith( - mockTask as Task, - false, - true, - [expect.objectContaining({ path: "src/new-location.ts", operation: "update" })], - ) + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ + expect.objectContaining({ path: "src/new-location.ts", operation: "update" }), + ]) expect(mockTask.recordToolError).toHaveBeenCalledWith("apply_patch") expect(mockPushToolResult).toHaveBeenCalledWith( expect.stringContaining("could not delete the original file"), From 5f881bffa427bbb4b2220fffb9c4bf5279db790e Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 00:28:24 +0800 Subject: [PATCH 13/48] i18n(tr): fix possessive link in changeCardDetail disabled-state sentence The disabled-state sentence read as if the line counts themselves were the file list; add the comitative link so the cards show the file list together with the added/removed line counts. Review feedback on PR 1411; ported to the sibling units carrying the same locale key. --- webview-ui/src/i18n/locales/tr/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index e5497e9fc1..0c5c12e8d6 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -721,7 +721,7 @@ }, "changeCardDetail": { "label": "Değişiklik kartlarında tam diff'i göster", - "description": "Adım başına değişiklik kartlarında her dosya için tam birleşik diff'i satır içi olarak içerir. Kapalıyken kartlar yalnızca eklenen/çıkarılan satır sayıları dosya listesini gösterir." + "description": "Adım başına değişiklik kartlarında her dosya için tam birleşik diff'i satır içi olarak içerir. Kapalıyken kartlar yalnızca eklenen/çıkarılan satır sayılarıyla birlikte dosya listesini gösterir." } }, "notifications": { From 8386e373958bc5cccfcb55180511beab08d245ee Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 00:58:22 +0800 Subject: [PATCH 14/48] 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 604aa01ccda5724afd5f68cb24cfbf910303356f Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 02:05:26 +0800 Subject: [PATCH 15/48] 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. - SettingsView changeCardDetail tests drive the checkbox and save button through @testing-library/user-event (real browser event sequence) per the repo's test learnings. --- 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 +-- .../settings/__tests__/SettingsView.spec.tsx | 14 +++++---- 5 files changed, 42 insertions(+), 13 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 03c20ccee2..3f3f70f572 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" @@ -394,7 +394,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 f008a5bc96..17b3d82ba5 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" @@ -104,7 +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 perWriteCheckpoints = state?.perWriteCheckpoints ?? DEFAULT_PER_WRITE_CHECKPOINTS const isPreventFocusDisruptionEnabled = experiments.isEnabled( state?.experiments ?? {}, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx index 80a2121293..0f46190bb0 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx @@ -2,6 +2,7 @@ import { renderWithExtensionState, screen, fireEvent, within, waitFor } from "@/utils/test-utils" import { act } from "@testing-library/react" +import userEvent from "@testing-library/user-event" import { vscode } from "@/utils/vscode" import { DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, type ProviderSettings } from "@roo-code/types" @@ -497,8 +498,9 @@ describe("SettingsView - Sound Settings", () => { const checkbox = await within(content).findByTestId("change-card-detail-checkbox") expect(checkbox).not.toBeChecked() - fireEvent.click(checkbox) - fireEvent.click(screen.getByTestId("save-button")) + const user = userEvent.setup() + await user.click(checkbox) + await user.click(screen.getByTestId("save-button")) await waitFor(() => expect(vscode.postMessage).toHaveBeenCalledWith( @@ -520,7 +522,8 @@ describe("SettingsView - Sound Settings", () => { const checkbox = await within(content).findByTestId("change-card-detail-checkbox") expect(checkbox).not.toBeChecked() - fireEvent.click(screen.getByTestId("save-button")) + const user = userEvent.setup() + await user.click(screen.getByTestId("save-button")) await waitFor(() => expect(vscode.postMessage).toHaveBeenCalledWith( @@ -543,8 +546,9 @@ describe("SettingsView - Sound Settings", () => { const checkbox = await within(content).findByTestId("change-card-detail-checkbox") await waitFor(() => expect(checkbox).toBeChecked()) - fireEvent.click(checkbox) - fireEvent.click(screen.getByTestId("save-button")) + const user = userEvent.setup() + await user.click(checkbox) + await user.click(screen.getByTestId("save-button")) await waitFor(() => expect(vscode.postMessage).toHaveBeenCalledWith( From 36d4110da67f860c4119b83b580a92bae5fc9024 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 03:16:03 +0800 Subject: [PATCH 16/48] 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 4e48d5e3426443e6b4c138bcde3c690a904e5da1 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 03:23:17 +0800 Subject: [PATCH 17/48] fix(checkpoints): validate containment via deepest existing ancestor restoreFile's realpath guard ran only when the target file existed, so a nonexistent target under a symlinked ancestor could be written outside the workspace by the checkout branch. Resolve the deepest existing ancestor for every target - existing or not - before checkout or deletion, and reject when it escapes the real workspace root. Add a cross-platform regression test (junction link, nonexistent target). Review feedback on PR 1410; ported to the sibling unit carrying the rollback service. --- .../checkpoints/ShadowCheckpointService.ts | 28 +++++++++++------- .../__tests__/ShadowCheckpointService.spec.ts | 29 +++++++++++++++++++ 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index a4d5a56756..bd5d9f6893 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -446,19 +446,25 @@ export abstract class ShadowCheckpointService extends EventEmitter { // The lexical check cannot see through a symlinked ancestor: a link // inside the workspace pointing outside it passes the prefix check - // while the real target resolves elsewhere. Re-check containment on - // the resolved (real) paths whenever the target file currently - // exists — that is the destructive case. A target that does not exist - // cannot be deleted, and the checkout branch only writes files the - // task-owned shadow repo recorded. Resolving both sides keeps a - // legitimate symlinked workspace root working. - if (await fileExistsAtPath(resolvedTarget)) { - const realWorkspaceRoot = await fs.realpath(this.workspaceDir) - const realRoot = realWorkspaceRoot.endsWith(path.sep) ? realWorkspaceRoot : realWorkspaceRoot + path.sep - const realTarget = await fs.realpath(resolvedTarget) - if (realTarget !== realWorkspaceRoot && !realTarget.startsWith(realRoot)) { + // while the real target resolves elsewhere. Validate containment on + // real paths for every target, not only existing ones: the checkout + // branch writes through a symlinked ancestor even when the target + // itself does not exist. Walk up to the deepest existing ancestor + // (the target itself when it exists) and resolve it. Resolving both + // sides keeps a legitimate symlinked workspace root working. + const realWorkspaceRoot = await fs.realpath(this.workspaceDir) + const realRoot = realWorkspaceRoot.endsWith(path.sep) ? realWorkspaceRoot : realWorkspaceRoot + path.sep + let realCandidate = resolvedTarget + while (!(await fileExistsAtPath(realCandidate))) { + const parent = path.dirname(realCandidate) + if (parent === realCandidate) { throw new Error(`restoreFile target resolves outside the workspace: ${filePath}`) } + realCandidate = parent + } + const realChecked = await fs.realpath(realCandidate) + if (realChecked !== realWorkspaceRoot && !realChecked.startsWith(realRoot)) { + throw new Error(`restoreFile target resolves outside the workspace: ${filePath}`) } const start = Date.now() diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index 32be9b67e9..f7aef4c736 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -295,6 +295,35 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( expect(await fs.readFile(outsideFile, "utf-8")).toBe("outside") }) + it("rejects a nonexistent target under a symlinked ancestor before checkout", async () => { + // The checkout branch writes through symlinked ancestors even when + // the target file itself does not exist yet, so containment must be + // validated on the deepest existing ancestor for every target + // (CWE-22 defense-in-depth). "junction" resolves without elevation + // on Windows and behaves as a directory symlink on POSIX. + const innerDir = path.join(service.workspaceDir, "sub") + await fs.mkdir(innerDir) + await fs.writeFile(path.join(innerDir, "newfile.txt"), "v1") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + const outsideDir = path.join(tmpDir, `outside-${Date.now()}`) + await fs.mkdir(outsideDir, { recursive: true }) + + // Replace the real dir with a link to the outside dir: the commit + // still carries sub/newfile.txt, but the working-tree location now + // escapes through the link, and the target does not exist. + await fs.rm(innerDir, { recursive: true, force: true }) + await fs.symlink(outsideDir, innerDir, "junction") + + await expect(service.restoreFile(commit1!.commit, path.join("sub", "newfile.txt"))).rejects.toThrow( + /resolves outside the workspace/, + ) + + // The checkout never wrote through the link. + expect(await fileExistsAtPath(path.join(outsideDir, "newfile.txt"))).toBe(false) + }) + it("emits a restore event when a file is restored", async () => { const restoreListener = vi.fn() service.on("restore", restoreListener) From 60b32cd17a36a94c941db8820010aa5a34959b72 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 04:27:54 +0800 Subject: [PATCH 18/48] fix(checkpoints): reject dangling symlink ancestors in restoreFile The containment walk used an exists()-style check that follows symlinks, so a dangling symlink ancestor was invisible to it: the walk validated the workspace root and the checkout branch either wrote nothing while reporting success (POSIX) or failed with an opaque git error (Windows). Walk with fs.lstat instead - a link entry counts as present, and the following realpath rejects it with a clear message - propagating errors other than ENOENT/ENOTDIR. Add a cross-platform regression test for the dangling-ancestor case (junction link to a missing directory). Review feedback on PR 1410; ported to the sibling unit carrying the rollback service. --- .../checkpoints/ShadowCheckpointService.ts | 28 +++++++++++++++---- .../__tests__/ShadowCheckpointService.spec.ts | 24 ++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index bd5d9f6893..d6b3edaadc 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -449,20 +449,38 @@ export abstract class ShadowCheckpointService extends EventEmitter { // while the real target resolves elsewhere. Validate containment on // real paths for every target, not only existing ones: the checkout // branch writes through a symlinked ancestor even when the target - // itself does not exist. Walk up to the deepest existing ancestor - // (the target itself when it exists) and resolve it. Resolving both - // sides keeps a legitimate symlinked workspace root working. + // itself does not exist. Walk up to the deepest ancestor that has a + // directory entry (lstat, so a dangling symlink counts as present and + // is then rejected by realpath instead of being skipped by an + // exists()-style check, which would validate the root and let the + // checkout report a false success). Resolving both sides keeps a + // legitimate symlinked workspace root working. const realWorkspaceRoot = await fs.realpath(this.workspaceDir) const realRoot = realWorkspaceRoot.endsWith(path.sep) ? realWorkspaceRoot : realWorkspaceRoot + path.sep let realCandidate = resolvedTarget - while (!(await fileExistsAtPath(realCandidate))) { + while (true) { + try { + await fs.lstat(realCandidate) + break + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + if (code !== "ENOENT" && code !== "ENOTDIR") throw err + } const parent = path.dirname(realCandidate) if (parent === realCandidate) { throw new Error(`restoreFile target resolves outside the workspace: ${filePath}`) } realCandidate = parent } - const realChecked = await fs.realpath(realCandidate) + let realChecked: string + try { + realChecked = await fs.realpath(realCandidate) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(`restoreFile target resolves through a dangling link: ${filePath}`) + } + throw err + } if (realChecked !== realWorkspaceRoot && !realChecked.startsWith(realRoot)) { throw new Error(`restoreFile target resolves outside the workspace: ${filePath}`) } diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index f7aef4c736..bdc558ffd2 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -324,6 +324,30 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( expect(await fileExistsAtPath(path.join(outsideDir, "newfile.txt"))).toBe(false) }) + it("rejects a target under a dangling symlink ancestor instead of a false success", async () => { + // A dangling link is invisible to exists()-style checks (they follow + // the link), so an exists()-based containment walk would validate the + // workspace root and let the checkout branch report success without + // ever creating the file. The lstat walk must see the link entry and + // reject through realpath. "junction" needs no elevation on Windows + // and behaves as a directory symlink on POSIX. + const innerDir = path.join(service.workspaceDir, "sub") + await fs.mkdir(innerDir) + await fs.writeFile(path.join(innerDir, "newfile.txt"), "v1") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + await fs.rm(innerDir, { recursive: true, force: true }) + await fs.symlink(path.join(tmpDir, `gone-${Date.now()}`), innerDir, "junction") + + await expect(service.restoreFile(commit1!.commit, path.join("sub", "newfile.txt"))).rejects.toThrow( + /dangling link/, + ) + + // Nothing was created through the dangling link. + expect(await fileExistsAtPath(path.join(innerDir, "newfile.txt"))).toBe(false) + }) + it("emits a restore event when a file is restored", async () => { const restoreListener = vi.fn() service.on("restore", restoreListener) From 21cc6e0d4c4afade0490b92ae1305cb0113fb1ce Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 04:38:32 +0800 Subject: [PATCH 19/48] fix(checkpoints): restoreFile must name one regular file A directory path such as "." or "src" passed the lexical and realpath guards, and git checkout with a directory pathspec restores the whole subtree recursively - a blast radius far beyond the single file the rollback contract promises. Reject directory targets up front (lstat, including "." and "" resolving to the workspace root), and verify the checkpoint tree entry mode is a regular-file blob (ls-tree; cat-file cannot distinguish a symlink blob) before checking out. Two regression tests cover the on-disk directory case and the checkpoint-tree case. Review feedback on PR 1410; ported to the sibling unit carrying the rollback service. --- .../checkpoints/ShadowCheckpointService.ts | 21 +++++++++++++ .../__tests__/ShadowCheckpointService.spec.ts | 30 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index d6b3edaadc..6d355da00a 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -485,10 +485,31 @@ export abstract class ShadowCheckpointService extends EventEmitter { throw new Error(`restoreFile target resolves outside the workspace: ${filePath}`) } + // restoreFile restores exactly one regular file: a directory pathspec + // would recursively check out the whole subtree from the checkpoint, + // so reject directory targets - including "." and "" resolving to the + // workspace root - before either branch runs. + try { + const targetStat = await fs.lstat(resolvedTarget) + if (targetStat.isDirectory()) { + throw new Error(`restoreFile target is a directory: ${filePath}`) + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err + } + const start = Date.now() const existed = await this.fileExistsInCommit(commitHash, gitPath) if (existed) { + // The checkpoint entry can be a tree or a symlink (whose object + // is also a blob); only regular-file modes may be restored through + // this single-file path. ls-tree reports the mode, unlike cat-file. + const entry = await this.git!.raw(["ls-tree", commitHash, "--", gitPath]) + const mode = entry.trim() ? entry.split("\t")[0].split(" ")[0] : "" + if (mode !== "100644" && mode !== "100755") { + throw new Error(`restoreFile target is not a regular file in the checkpoint: ${filePath}`) + } await this.git.checkout([commitHash, "--", gitPath]) } else { await fs.rm(path.join(this.workspaceDir, filePath), { force: true }) diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index bdc558ffd2..162cc2d617 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -348,6 +348,36 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( expect(await fileExistsAtPath(path.join(innerDir, "newfile.txt"))).toBe(false) }) + it("rejects restoring the workspace root as a single file", async () => { + // "." passes the lexical and realpath guards (it IS the workspace) + // but as a pathspec it would recursively restore the entire tree + // from the checkpoint - a blast radius far beyond one file. + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + await expect(service.restoreFile(commit1!.commit, ".")).rejects.toThrow( + /restoreFile target is a directory/, + ) + }) + + it("rejects a directory target that is a tree in the checkpoint", async () => { + // The on-disk directory check cannot catch a directory the task + // already deleted; only the checkpoint tree-entry mode check can + // stop git checkout from recursively restoring the subtree. + const innerDir = path.join(service.workspaceDir, "sub") + await fs.mkdir(innerDir) + await fs.writeFile(path.join(innerDir, "a.txt"), "a") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + await fs.rm(innerDir, { recursive: true, force: true }) + await expect(service.restoreFile(commit1!.commit, "sub")).rejects.toThrow( + /not a regular file in the checkpoint/, + ) + expect(await fileExistsAtPath(path.join(innerDir, "a.txt"))).toBe(false) + }) + it("emits a restore event when a file is restored", async () => { const restoreListener = vi.fn() service.on("restore", restoreListener) From 4dcb8d9cad8565916daa970ba48397a9739e5eac Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 06:49:08 +0800 Subject: [PATCH 20/48] fix(checkpoints): surface unreadable checkpoint trees instead of deleting live files fileExistsInCommit read any cat-file -e failure as "file absent", so a shadow repo whose commit object resolves but whose tree object is corrupt or missing routed restoreFile into the delete branch and rm'd a live file the checkpoint does contain. Existence now uses ls-tree: an empty successful result is genuine absence, while a command failure propagates and fails the restore loudly. The :(literal) pathspec magic keeps paths containing glob characters exact (also applied to the mode check). Regression test corrupts the checkpoint tree object and pins both the rejection and the surviving file. Review feedback on PR 1412; owned by PR 1410's service and byte-copied to the sibling unit carrying the identical file. --- .../checkpoints/ShadowCheckpointService.ts | 17 +++++++++------- .../__tests__/ShadowCheckpointService.spec.ts | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index 6d355da00a..88726f6909 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -505,7 +505,7 @@ export abstract class ShadowCheckpointService extends EventEmitter { // The checkpoint entry can be a tree or a symlink (whose object // is also a blob); only regular-file modes may be restored through // this single-file path. ls-tree reports the mode, unlike cat-file. - const entry = await this.git!.raw(["ls-tree", commitHash, "--", gitPath]) + const entry = await this.git!.raw(["ls-tree", commitHash, "--", `:(literal)${gitPath}`]) const mode = entry.trim() ? entry.split("\t")[0].split(" ")[0] : "" if (mode !== "100644" && mode !== "100755") { throw new Error(`restoreFile target is not a regular file in the checkpoint: ${filePath}`) @@ -544,12 +544,15 @@ export abstract class ShadowCheckpointService extends EventEmitter { } catch { throw new Error(`Checkpoint unavailable: ${commitHash}`) } - try { - await this.git!.raw(["cat-file", "-e", `${commitHash}:${filePath}`]) - return true - } catch { - return false - } + // Existence must distinguish "absent" from "unreadable": ls-tree exits 0 + // with empty output only when the path is genuinely absent from the + // tree, while a corrupt or missing tree object makes it fail - and that + // failure must propagate rather than be misread as absence, which would + // route restoreFile into the delete branch and rm a live file the + // checkpoint does contain. The :(literal) magic keeps paths containing + // glob characters exact. + const entry = await this.git!.raw(["ls-tree", commitHash, "--", `:(literal)${filePath}`]) + return entry.trim().length > 0 } public async getDiff({ from, to }: { from?: string; to?: string }): Promise { diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index 162cc2d617..b8423cb420 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -260,6 +260,26 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!") }) + it("does not delete a live file when the checkpoint tree object is unreadable", async () => { + // The commit object can resolve while its tree is corrupt or missing + // (partial shadow-repo damage). An existence lookup that swallows the + // read failure would report "absent" and delete a live file the + // checkpoint does contain; the failure must propagate instead. + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + const git = service["git"]! + const gitDir = (await git.raw(["rev-parse", "--absolute-git-dir"])).trim() + const treeOid = (await git.raw(["show", "-s", "--format=%T", commit1!.commit])).trim() + await fs.rm(path.join(gitDir, "objects", treeOid.slice(0, 2), treeOid.slice(2)), { force: true }) + + await expect(service.restoreFile(commit1!.commit, "test.txt")).rejects.toThrow() + + expect(await fileExistsAtPath(testFile)).toBe(true) + expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!") + }) + it("rejects a target that escapes the workspace through a symlinked ancestor", async () => { // A lexical prefix check passes for a path that goes through a // symlink inside the workspace pointing outside it; the resolved From af18969bc49382ee7c645e2071aec242cf27765c Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 07:25:26 +0800 Subject: [PATCH 21/48] fix(checkpoints): restore exactly the requested path when names contain glob characters The ls-tree existence lookup matches literally, but the checkout pathspec did not: a filename like [a].ts is a character class as a plain pathspec, so checkout restored a.ts instead of (and in addition to) the requested file, breaking the single-file restore contract. The checkout pathspec now uses the same :(literal) magic as the lookups. Regression test checkpoints [a].ts alongside a.ts, modifies both, and pins that restoring [a].ts leaves a.ts untouched (negative control verified: the plain pathspec reddens it). Review feedback on PR 1410; owned by PR 1410's service and byte-copied to the sibling unit carrying the identical file. --- .../checkpoints/ShadowCheckpointService.ts | 6 ++++- .../__tests__/ShadowCheckpointService.spec.ts | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index 88726f6909..19508ad1c1 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -510,7 +510,11 @@ export abstract class ShadowCheckpointService extends EventEmitter { if (mode !== "100644" && mode !== "100755") { throw new Error(`restoreFile target is not a regular file in the checkpoint: ${filePath}`) } - await this.git.checkout([commitHash, "--", gitPath]) + // The checkout pathspec must be literal too: a filename containing + // glob characters ([a].ts, foo?bar.ts) would otherwise match - or + // miss - other files, restoring more (or the wrong file) than the + // single requested path. + await this.git.checkout([commitHash, "--", `:(literal)${gitPath}`]) } else { await fs.rm(path.join(this.workspaceDir, filePath), { force: true }) } diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index b8423cb420..1c1fe0a930 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -280,6 +280,28 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!") }) + it("restores exactly the requested file when its name contains glob characters", async () => { + // A plain pathspec treats [a].ts as a character class matching + // a.ts, so checkout would restore the wrong file and leave the + // requested one modified. The restore contract is exactly one + // path: the checkout pathspec must be literal. + const workspaceDir = path.dirname(testFile) + const globFile = path.join(workspaceDir, "[a].ts") + const plainFile = path.join(workspaceDir, "a.ts") + await fs.writeFile(globFile, "glob content") + await fs.writeFile(plainFile, "plain content") + const commit = await service.saveCheckpoint("Glob-name checkpoint") + expect(commit?.commit).toBeTruthy() + + await fs.writeFile(globFile, "glob modified") + await fs.writeFile(plainFile, "plain modified") + + await service.restoreFile(commit!.commit, "[a].ts") + + expect(await fs.readFile(globFile, "utf-8")).toBe("glob content") + expect(await fs.readFile(plainFile, "utf-8")).toBe("plain modified") + }) + it("rejects a target that escapes the workspace through a symlinked ancestor", async () => { // A lexical prefix check passes for a path that goes through a // symlink inside the workspace pointing outside it; the resolved From ef2886531d7cc39f8e679feb5b8e042ee80e3491 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 08:11:58 +0800 Subject: [PATCH 22/48] 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 | 12 ++++++-- .../__tests__/applyPatchTool.execute.spec.ts | 30 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index bba826ea4d..d2deb51e04 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -239,6 +239,15 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { ) } } + + // B2: deferred from the per-file handlers - a queued write must not + // start until the post-patch checkpoint above has persisted. The + // handlers used to process queued messages right after each + // successful write; a write happened if and only if + // `successfulChanges` is non-empty, so one call here preserves that. + if (successfulChanges.length > 0) { + task.processQueuedMessages() + } } catch (error) { await handleError("apply patch", error as Error) await task.diffViewProvider.reset() @@ -349,7 +358,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 { succeeded: true, wrote: true } } @@ -421,7 +429,6 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task.didEditFile = true pushToolResult(`Successfully deleted ${relPath}`) - task.processQueuedMessages() return { succeeded: true, wrote: true } } @@ -616,7 +623,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() if (!moveSucceeded) { // The destination file was written on disk before the source // deletion failed, so the write must still be checkpointed and diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index b525dfff52..62a1237392 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -195,6 +195,36 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockTask.recordToolError).toHaveBeenCalledWith("apply_patch") expect(mockedCheckpointSave).not.toHaveBeenCalled() }) + it("awaits the post-patch checkpoint before processing queued messages", async () => { + // A pending checkpoint must not let queued messages (and the + // writes they trigger) start first: execute() awaits the + // post-patch checkpointSave and only then processes queued + // messages, so a queued write's own checkpoint cannot interleave + // with this commit. + const order: string[] = [] + ;(mockTask.processQueuedMessages as MockedFunction<() => void>).mockImplementation(() => { + order.push("processQueuedMessages") + }) + mockedCheckpointSave.mockImplementationOnce(() => { + order.push("checkpointSave:start") + return new Promise((resolve) => { + setTimeout(() => { + order.push("checkpointSave:done") + resolve() + }, 10) + }).then(() => undefined) + }) + + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // The checkpoint fully completes before queued messages are + // processed. + expect(order).toEqual(["checkpointSave:start", "checkpointSave:done", "processQueuedMessages"]) + }) }) describe("checkpoint only for fully successful patches (B1)", () => { From c7c36a0c2790a411871b29484c0e6a0974c633d2 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 08:36:17 +0800 Subject: [PATCH 23/48] 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 ++++++++++++++++++++++++++++ 2 files changed, 36 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() From 1b39f10cadb86aca6a9c6f419444b465578cfe2d Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 14 Oct 2026 16:39:05 +0800 Subject: [PATCH 24/48] 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 55d5cf0ce8ac3ff23b60fbd6e46c446972f26175 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 14 Oct 2026 17:50:19 +0800 Subject: [PATCH 25/48] fix: satisfy unused-variable lint after main refresh The upstream lint cleanup turned on unused-variable checks that the carried journal wiring violated once merged: an unused ChangeJournalEntry import in the checkpoint index, an unused test stub argument, and two unused positional destructures in the checkpoint-save spec. Renamed/removed per the underscore convention. Comment-level fixes only; no behavior delta. --- src/core/checkpoints/__tests__/checkpointJournal.test.ts | 2 +- src/core/checkpoints/__tests__/checkpointSave.spec.ts | 2 +- src/core/checkpoints/index.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/checkpoints/__tests__/checkpointJournal.test.ts b/src/core/checkpoints/__tests__/checkpointJournal.test.ts index 8671bb491c..833138c46f 100644 --- a/src/core/checkpoints/__tests__/checkpointJournal.test.ts +++ b/src/core/checkpoints/__tests__/checkpointJournal.test.ts @@ -17,7 +17,7 @@ vi.mock("vscode", () => ({ }, Uri: { file: vi.fn((p: string) => ({ fsPath: p })), - parse: vi.fn((uri: string) => ({ with: vi.fn(() => ({})) })), + parse: vi.fn((_uri: string) => ({ with: vi.fn(() => ({})) })), }, commands: { executeCommand: vi.fn(), diff --git a/src/core/checkpoints/__tests__/checkpointSave.spec.ts b/src/core/checkpoints/__tests__/checkpointSave.spec.ts index 25afe4cad7..b9095924f4 100644 --- a/src/core/checkpoints/__tests__/checkpointSave.spec.ts +++ b/src/core/checkpoints/__tests__/checkpointSave.spec.ts @@ -78,7 +78,7 @@ describe("checkpointSave change-card emission (B3a)", () => { const cardCalls = say.mock.calls.filter(([type]) => type === "change_card") expect(cardCalls).toHaveLength(1) - const [type, text, images, partial, sayOptions, _progress, options] = cardCalls[0] as unknown as [ + const [type, text, images, _partial, _sayOptions, _progress, options] = cardCalls[0] as unknown as [ string, string, undefined, diff --git a/src/core/checkpoints/index.ts b/src/core/checkpoints/index.ts index 4f404804c4..00a4e5da7b 100644 --- a/src/core/checkpoints/index.ts +++ b/src/core/checkpoints/index.ts @@ -16,7 +16,7 @@ import { DIFF_VIEW_URI_SCHEME } from "../../integrations/editor/DiffViewProvider import { CheckpointServiceOptions, RepoPerTaskCheckpointService } from "../../services/checkpoints" -import { appendChange, ChangeJournalEntry } from "./changeJournal" +import { appendChange } from "./changeJournal" import { buildChangeCardPayload } from "./changeCard" const WARNING_THRESHOLD_MS = 5000 From 7b9b7a0a32d2c34ccf7bbefb3f74317b8eab49f0 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 15 Oct 2026 11:03:06 +0800 Subject: [PATCH 26/48] Merge upstream/main (tool-call state scoping) and adapt execute spec callbacks Main now scopes tool state to each tool call: ToolCallbacks gained a required toolCallId. The execute spec's callback fixtures pass the new field (test-tool-call-id, matching main's own spec convention). No production-code change needed for this unit. --- .../__tests__/applyPatchTool.execute.spec.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index e26f007375..3eee55656d 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -152,6 +152,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully deleted")) @@ -174,6 +175,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully deleted")) @@ -191,6 +193,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockTask.recordToolError).toHaveBeenCalledWith("apply_patch") @@ -220,6 +223,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) // The checkpoint fully completes before queued messages are @@ -267,6 +271,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith("Delete operation was rejected by the user.") @@ -280,6 +285,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("File not found")) @@ -293,6 +299,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Failed to delete file")) @@ -306,6 +313,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("File already exists")) @@ -320,6 +328,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith("Changes were rejected by the user.") @@ -333,6 +342,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith("File saved successfully") @@ -346,6 +356,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("File not found")) @@ -359,6 +370,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("No changes needed")) @@ -372,6 +384,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith("Changes were rejected by the user.") @@ -390,6 +403,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockTask.say).toHaveBeenCalledWith("rooignore_error", "src/moved.ts") @@ -422,6 +436,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockTask.say).toHaveBeenCalledWith("rooignore_error", "src/denied.ts") @@ -445,6 +460,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith( @@ -462,6 +478,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith( @@ -483,6 +500,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) // The failed operation incremented the counter; the end-of-loop reset @@ -497,6 +515,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockTask.consecutiveMistakeCount).toBe(0) @@ -507,6 +526,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) // path is platform-dependent (Windows resolves cwd to a drive path); @@ -529,6 +549,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) // execute must not settle while the checkpoint is still in flight: @@ -549,6 +570,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockPushToolResult).toHaveBeenCalledWith("File saved successfully") @@ -575,6 +597,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockedCheckpointSave).toHaveBeenCalledOnce() @@ -615,6 +638,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ @@ -629,6 +653,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ @@ -657,6 +682,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) // The no-op update is reported to the model... @@ -694,6 +720,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) // The failed operation is reported to the model... @@ -726,6 +753,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) // The copy succeeded but the source still exists, so the move is From 2c3a01f55309e0a00590f34fcbc1b122fbbb1743 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 15 Oct 2026 11:21:49 +0800 Subject: [PATCH 27/48] 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 | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 3eee55656d..bcfe6c807b 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -152,7 +152,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")) @@ -175,7 +175,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")) @@ -193,7 +193,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") @@ -223,7 +223,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) // The checkpoint fully completes before queued messages are @@ -271,7 +271,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.") @@ -285,7 +285,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")) @@ -299,7 +299,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")) @@ -313,7 +313,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")) @@ -328,7 +328,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.") @@ -342,7 +342,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") @@ -356,7 +356,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")) @@ -370,7 +370,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")) @@ -384,7 +384,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.") @@ -403,7 +403,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") @@ -436,7 +436,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/denied.ts") @@ -460,7 +460,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( @@ -478,7 +478,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( @@ -500,7 +500,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) // The failed operation incremented the counter; the end-of-loop reset @@ -515,7 +515,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.consecutiveMistakeCount).toBe(0) @@ -526,7 +526,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); @@ -549,7 +549,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) // execute must not settle while the checkpoint is still in flight: @@ -570,7 +570,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") @@ -597,7 +597,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).toHaveBeenCalledOnce() @@ -638,7 +638,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).toHaveBeenCalledWith(mockTask as Task, false, true, [ @@ -653,7 +653,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).toHaveBeenCalledWith(mockTask as Task, false, true, [ @@ -682,7 +682,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) // The no-op update is reported to the model... @@ -720,7 +720,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) // The failed operation is reported to the model... @@ -753,7 +753,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - toolCallId: "test-tool-call-id", + toolCallId: "test-tool-call-id", }) // The copy succeeded but the source still exists, so the move is From 860c3ee15be91c7351f1e4698d5b2dff12ba4b1c Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 15 Oct 2026 16:52:13 +0800 Subject: [PATCH 28/48] fix: skip change-card emission for no-op checkpoints Port of the sibling-unit fix: a no-op patch still reaches checkpointSave with an empty writes array, and unrelated workspace changes can produce the commit; emitting a card for it would show a change card with zero files. Skip emission when the writes array is empty (the checkpoint itself still runs), assert the empty payload in the no-op tool test, and cover the guard at the emission layer. --- .../checkpoints/__tests__/checkpointSave.spec.ts | 13 +++++++++++++ src/core/checkpoints/index.ts | 5 ++++- .../tools/__tests__/applyPatchTool.execute.spec.ts | 1 + 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/core/checkpoints/__tests__/checkpointSave.spec.ts b/src/core/checkpoints/__tests__/checkpointSave.spec.ts index b9095924f4..291bc4cdea 100644 --- a/src/core/checkpoints/__tests__/checkpointSave.spec.ts +++ b/src/core/checkpoints/__tests__/checkpointSave.spec.ts @@ -193,6 +193,19 @@ describe("checkpointSave change-card emission (B3a)", () => { expect(cardCalls).toHaveLength(0) }) + it("emits no change card for a no-op checkpoint with an empty writes array but still saves", async () => { + const { task, say, saveCheckpoint } = makeTask({}) + + // A no-op patch still reaches checkpointSave with [] write metadata; the + // commit may cover unrelated workspace changes, but a zero-file card must + // not be emitted. + await checkpointSave(task, false, true, []) + + expect(saveCheckpoint).toHaveBeenCalled() + const cardCalls = say.mock.calls.filter(([type]) => type === "change_card") + expect(cardCalls).toHaveLength(0) + }) + it("emits no change card when checkpoints are disabled for the task", async () => { const { task, say } = makeTask({ enableCheckpoints: false }) diff --git a/src/core/checkpoints/index.ts b/src/core/checkpoints/index.ts index 00a4e5da7b..439b6eab8f 100644 --- a/src/core/checkpoints/index.ts +++ b/src/core/checkpoints/index.ts @@ -260,7 +260,10 @@ export async function checkpointSave( // Only a real commit produces an entry (an empty or failed save // resolves to undefined / rejects), and non-write checkpoint calls // (e.g. the task-start baseline) pass no `write` value at all. - if (result?.commit && write) { + // A no-op checkpoint carries an empty writes array: unrelated workspace + // changes can still produce a commit, and emitting a card for it would + // show a change card with zero files. The checkpoint itself still runs. + if (result?.commit && write && (!Array.isArray(write) || write.length > 0)) { const writes = Array.isArray(write) ? write : [write] const globalStorageDir = task.providerRef.deref()?.context.globalStorageUri.fsPath if (globalStorageDir) { diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index bcfe6c807b..fef427da12 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -375,6 +375,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("No changes needed")) expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, []) }) it("does not record a checkpoint when the user rejects the update", async () => { From c18c03f195da557114902bb77c8f7f188ac6439b Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 15 Oct 2026 20:53:51 +0800 Subject: [PATCH 29/48] fix: harden change-journal append and task-ID path containment Port of the sibling-unit fix: a crash can leave the journal without a trailing newline; the next append previously spliced the new entry into the fragment and lost both at load time. appendChange now terminates a torn tail before appending and fsyncs each append so a durable checkpoint implies a durable journal entry. Task IDs are validated as a single path segment before any filesystem operation so a crafted ID cannot move the journal outside /tasks. --- .../__tests__/changeJournal.spec.ts | 41 +++++++++++++++ src/core/checkpoints/changeJournal.ts | 50 +++++++++++++++++-- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/src/core/checkpoints/__tests__/changeJournal.spec.ts b/src/core/checkpoints/__tests__/changeJournal.spec.ts index 20f53551aa..72a3d8b6b1 100644 --- a/src/core/checkpoints/__tests__/changeJournal.spec.ts +++ b/src/core/checkpoints/__tests__/changeJournal.spec.ts @@ -48,6 +48,47 @@ describe("changeJournal", () => { const raw = await fs.readFile(journalPath(tmpRoot, taskId), "utf8") expect(raw.split("\n").filter((line) => line !== "")).toHaveLength(2) }) + + it("repairs a torn tail before appending so both entries load", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "good" })) + // Simulate a crash that tore the last append mid-write: the file now + // ends with an unterminated JSON fragment. + const filePath = journalPath(tmpRoot, taskId) + const raw = await fs.readFile(filePath, "utf8") + await fs.writeFile(filePath, raw + '{"path":"src/half.ts","operation":"upd') + + await appendChange(tmpRoot, taskId, entry({ checkpointId: "after" })) + + const result = await loadChanges(tmpRoot, taskId) + expect(result.map((e) => e.checkpointId)).toEqual(["good", "after"]) + }) + + it("does not insert a blank line when the journal ends cleanly", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "a" })) + await appendChange(tmpRoot, taskId, entry({ checkpointId: "b" })) + + const raw = await fs.readFile(journalPath(tmpRoot, taskId), "utf8") + expect(raw).not.toContain("\n\n") + expect(raw.split("\n").filter((line) => line !== "")).toHaveLength(2) + }) + }) + + describe("task ID containment", () => { + it("rejects task IDs that could escape the tasks directory", async () => { + for (const bad of ["../../outside", "a/b", "a\\b", "", ".", ".."]) { + expect(() => journalPath(tmpRoot, bad)).toThrow(/Unsafe task ID/) + await expect(appendChange(tmpRoot, bad, entry({ checkpointId: "x" }))).rejects.toThrow(/Unsafe task ID/) + await expect(loadChanges(tmpRoot, bad)).rejects.toThrow(/Unsafe task ID/) + // Nothing was created outside the task directory. + await expect(fs.stat(path.join(tmpRoot, "..", "outside"))).rejects.toMatchObject({ code: "ENOENT" }) + } + }) + + it("keeps a safe task ID under the tasks directory", () => { + expect(journalPath(tmpRoot, "task-42")).toBe( + path.join(tmpRoot, "tasks", "task-42", "checkpoints", "changes.jsonl"), + ) + }) }) describe("loadChanges", () => { diff --git a/src/core/checkpoints/changeJournal.ts b/src/core/checkpoints/changeJournal.ts index 850d2b7f1d..522d34fcb1 100644 --- a/src/core/checkpoints/changeJournal.ts +++ b/src/core/checkpoints/changeJournal.ts @@ -24,6 +24,18 @@ export interface ChangeJournalEntry { const JOURNAL_FILENAME = "changes.jsonl" +/** + * Task IDs arrive from task creation and are used as a single path segment + * under /tasks. Reject anything that could escape that + * directory — separators, traversal, absolute paths, empty or dot-only IDs — + * before any filesystem operation touches the journal path. + */ +function assertSafeTaskId(taskId: string): void { + if (!/^[A-Za-z0-9._-]+$/.test(taskId) || taskId === "." || taskId === "..") { + throw new Error(`Unsafe task ID for checkpoint journal path: ${JSON.stringify(taskId)}`) + } +} + /** Derive the per-task checkpoint directory from globalStorageDir and taskId. */ function taskCheckpointDir(globalStorageDir: string, taskId: string): string { return path.join(globalStorageDir, "tasks", taskId, "checkpoints") @@ -31,20 +43,50 @@ function taskCheckpointDir(globalStorageDir: string, taskId: string): string { /** Journal file path for a given task. */ export function journalPath(globalStorageDir: string, taskId: string): string { + assertSafeTaskId(taskId) return path.join(taskCheckpointDir(globalStorageDir, taskId), JOURNAL_FILENAME) } /** * Append one change-journal entry to the per-task changes.jsonl file. * - * Uses appendFile so each write is a single syscall — minimal torn-write risk. - * Creates parent directories if they don't exist yet (e.g. first checkpoint). + * Torn-tail repair: a crash mid-append can leave the file without a trailing + * newline; appending directly would splice the new entry into the fragment and + * lose both at load time, so the tail is terminated before the new line is + * written. The append is fsynced so a durable checkpoint implies a durable + * journal entry; a crash mid-write can still tear the appended line itself, + * which loadChanges tolerates as a torn tail. Creates parent directories if + * they don't exist yet (e.g. first checkpoint). */ export async function appendChange(globalStorageDir: string, taskId: string, entry: ChangeJournalEntry): Promise { const filePath = journalPath(globalStorageDir, taskId) await fs.mkdir(path.dirname(filePath), { recursive: true }) - const line = JSON.stringify(entry) + "\n" - await fs.appendFile(filePath, line) + let payload = JSON.stringify(entry) + "\n" + try { + const stat = await fs.stat(filePath) + if (stat.size > 0) { + const tail = await fs.open(filePath, "r") + try { + const last = Buffer.alloc(1) + await tail.read(last, 0, 1, stat.size - 1) + if (last[0] !== 0x0a) { + payload = "\n" + payload + } + } finally { + await tail.close() + } + } + } catch { + // Journal absent (fresh append) or vanished between stat and open. + } + const data = Buffer.from(payload) + const handle = await fs.open(filePath, "a") + try { + await handle.write(data, 0, data.length) + await handle.sync() + } finally { + await handle.close() + } } /** From 6e5968aac8ea67030c6e539a461c53049c458988 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 15 Oct 2026 22:01:58 +0800 Subject: [PATCH 30/48] fix: propagate non-ENOENT tail-inspection errors and complete partial writes appendChange previously swallowed every tail-inspection error, so an I/O failure could let the append proceed without the required separator and silently lose the entry to a merged line; only ENOENT (fresh journal) is suppressed now. The append also ignored FileHandle.write's bytesWritten, so a partial write became a durable torn record reported as success; the loop now writes the whole record before syncing. Regression tests cover both paths. --- .../__tests__/changeJournal.spec.ts | 55 ++++++++++++++++++- src/core/checkpoints/changeJournal.ts | 18 +++++- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/core/checkpoints/__tests__/changeJournal.spec.ts b/src/core/checkpoints/__tests__/changeJournal.spec.ts index 72a3d8b6b1..bd31cdc476 100644 --- a/src/core/checkpoints/__tests__/changeJournal.spec.ts +++ b/src/core/checkpoints/__tests__/changeJournal.spec.ts @@ -2,7 +2,7 @@ import fs from "fs/promises" import os from "os" import path from "path" -import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { appendChange, journalPath, loadChanges, type ChangeJournalEntry } from "../changeJournal" @@ -71,6 +71,59 @@ describe("changeJournal", () => { expect(raw).not.toContain("\n\n") expect(raw.split("\n").filter((line) => line !== "")).toHaveLength(2) }) + + it("propagates a non-ENOENT tail-inspection failure instead of appending blind", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "good" })) + const filePath = journalPath(tmpRoot, taskId) + const raw = await fs.readFile(filePath, "utf8") + await fs.writeFile(filePath, raw + '{"path":"src/half.ts","operation":"upd') + // The tail cannot be inspected (I/O error): appending would merge the + // new entry into the fragment, so appendChange must fail loudly. + const statSpy = vi + .spyOn(fs, "stat") + .mockRejectedValueOnce(Object.assign(new Error("simulated I/O error"), { code: "EIO" })) + try { + await expect(appendChange(tmpRoot, taskId, entry({ checkpointId: "after" }))).rejects.toMatchObject({ + code: "EIO", + }) + } finally { + statSpy.mockRestore() + } + // The failed append wrote nothing: the next append still repairs and loads. + await appendChange(tmpRoot, taskId, entry({ checkpointId: "after" })) + const result = await loadChanges(tmpRoot, taskId) + expect(result.map((e) => e.checkpointId)).toEqual(["good", "after"]) + }) + + it("writes the full record when the underlying write completes partially", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "first" })) + const realOpen = fs.open + const openSpy = vi.spyOn(fs, "open").mockImplementation(async (pathish, flags) => { + const openAs = realOpen as (p: string | URL, f?: string) => Promise + const handle = await openAs(pathish as string | URL, flags as string | undefined) + if (flags === "a") { + const realWrite = handle.write.bind(handle) + let first = true + handle.write = (async (buf: Uint8Array, off?: number, len?: number) => { + const length = len ?? buf.length + if (first && length > 1) { + first = false + await realWrite(buf, off, 1) + return { bytesWritten: 1, buffer: Buffer.from(buf) } + } + return realWrite(buf, off, length) + }) as typeof handle.write + } + return handle + }) + try { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "second" })) + } finally { + openSpy.mockRestore() + } + const result = await loadChanges(tmpRoot, taskId) + expect(result.map((e) => e.checkpointId)).toEqual(["first", "second"]) + }) }) describe("task ID containment", () => { diff --git a/src/core/checkpoints/changeJournal.ts b/src/core/checkpoints/changeJournal.ts index 522d34fcb1..c745be43b1 100644 --- a/src/core/checkpoints/changeJournal.ts +++ b/src/core/checkpoints/changeJournal.ts @@ -76,13 +76,25 @@ export async function appendChange(globalStorageDir: string, taskId: string, ent await tail.close() } } - } catch { - // Journal absent (fresh append) or vanished between stat and open. + } catch (error) { + // A missing journal is a fresh append. Any other inspection failure + // means we cannot know whether a separator is required, so propagate + // instead of appending blind and losing the entry to a merged line. + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error + } } const data = Buffer.from(payload) const handle = await fs.open(filePath, "a") try { - await handle.write(data, 0, data.length) + // A single write can complete partially; sync only after the whole + // record is on the handle, or a short write would persist a torn entry + // while appendChange reports success. + let written = 0 + while (written < data.length) { + const res = await handle.write(data, written, data.length - written) + written += res.bytesWritten + } await handle.sync() } finally { await handle.close() From 9bed92d240e7934d47e8cc3b8e07205125250606 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 16 Oct 2026 08:34:30 +0800 Subject: [PATCH 31/48] fix: validate journal entry shape on load and sync the new file's directory loadChanges now skips parsed lines that are not entry-shaped (non-object, missing string path/checkpointId, unknown operation) instead of returning them as entries; consumers read path/checkpointId unguarded and a null element made rollback throw. On POSIX, the first append also syncs the parent directory so the newly created changes.jsonl entry is durable after a crash (Windows cannot open directories and is skipped). Regression tests cover both. --- .../__tests__/changeJournal.spec.ts | 39 +++++++++++++++++++ .../__tests__/checkpointJournal.test.ts | 36 +++++++++++++++++ src/core/checkpoints/changeJournal.ts | 32 ++++++++++++++- 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/core/checkpoints/__tests__/changeJournal.spec.ts b/src/core/checkpoints/__tests__/changeJournal.spec.ts index bd31cdc476..b55289aab0 100644 --- a/src/core/checkpoints/__tests__/changeJournal.spec.ts +++ b/src/core/checkpoints/__tests__/changeJournal.spec.ts @@ -72,6 +72,45 @@ describe("changeJournal", () => { expect(raw.split("\n").filter((line) => line !== "")).toHaveLength(2) }) + it("syncs the parent directory after the first append creates the journal (POSIX)", async () => { + const platformDesc = Object.getOwnPropertyDescriptor(process, "platform") + Object.defineProperty(process, "platform", { value: "linux", configurable: true }) + const dirPath = path.dirname(journalPath(tmpRoot, taskId)) + const realOpen = fs.open + const synced: string[] = [] + const closed: string[] = [] + // Windows cannot open directories, so the directory handle is doubled + // here: the code under test only opens it on non-win32 platforms. + const openSpy = vi.spyOn(fs, "open").mockImplementation(async (pathish, flags) => { + const openAs = realOpen as (p: string | URL, f?: string) => Promise + const p = typeof pathish === "string" ? pathish : String(pathish) + if (p === dirPath && flags === "r") { + return { + sync: async () => { + synced.push(p) + }, + close: async () => { + closed.push(p) + }, + } as unknown as fs.FileHandle + } + return openAs(p, flags as string | undefined) + }) + try { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "first" })) + expect(synced).toEqual([dirPath]) + expect(closed).toEqual([dirPath]) + // Subsequent appends do not re-sync the directory. + await appendChange(tmpRoot, taskId, entry({ checkpointId: "second" })) + expect(synced).toHaveLength(1) + } finally { + openSpy.mockRestore() + if (platformDesc) Object.defineProperty(process, "platform", platformDesc) + } + const result = await loadChanges(tmpRoot, taskId) + expect(result.map((e) => e.checkpointId)).toEqual(["first", "second"]) + }) + it("propagates a non-ENOENT tail-inspection failure instead of appending blind", async () => { await appendChange(tmpRoot, taskId, entry({ checkpointId: "good" })) const filePath = journalPath(tmpRoot, taskId) diff --git a/src/core/checkpoints/__tests__/checkpointJournal.test.ts b/src/core/checkpoints/__tests__/checkpointJournal.test.ts index 833138c46f..ca07a7d3ec 100644 --- a/src/core/checkpoints/__tests__/checkpointJournal.test.ts +++ b/src/core/checkpoints/__tests__/checkpointJournal.test.ts @@ -216,3 +216,39 @@ describe("checkpointSave change-journal wiring (B2)", () => { ) }) }) + +describe("loadChanges entry-shape validation (B2)", () => { + let tmpStorageDir: string + + beforeEach(async () => { + tmpStorageDir = await fs.mkdtemp(path.join(os.tmpdir(), "b2-journal-shape-")) + }) + + afterEach(async () => { + await fs.rm(tmpStorageDir, { recursive: true, force: true }) + }) + + it("skips lines that are valid JSON but not entry-shaped", async () => { + const dir = path.join(tmpStorageDir, "tasks", TASK_ID, "checkpoints") + await fs.mkdir(dir, { recursive: true }) + const valid = JSON.stringify({ path: "src/a.ts", operation: "create", checkpointId: "c1" }) + await fs.writeFile( + path.join(dir, "changes.jsonl"), + [ + // A torn append can leave valid JSON that is not an entry. + "1", + "null", + // Missing checkpointId. + JSON.stringify({ path: "src/b.ts", operation: "update" }), + // Unknown operation. + JSON.stringify({ path: "src/c.ts", operation: "rename", checkpointId: "c2" }), + // Non-string path. + JSON.stringify({ path: 42, operation: "create", checkpointId: "c3" }), + valid, + ].join("\n") + "\n", + ) + + const entries = await loadChanges(tmpStorageDir, TASK_ID) + expect(entries).toEqual([{ path: "src/a.ts", operation: "create", checkpointId: "c1" }]) + }) +}) diff --git a/src/core/checkpoints/changeJournal.ts b/src/core/checkpoints/changeJournal.ts index c745be43b1..eaa740acbe 100644 --- a/src/core/checkpoints/changeJournal.ts +++ b/src/core/checkpoints/changeJournal.ts @@ -62,6 +62,7 @@ export async function appendChange(globalStorageDir: string, taskId: string, ent const filePath = journalPath(globalStorageDir, taskId) await fs.mkdir(path.dirname(filePath), { recursive: true }) let payload = JSON.stringify(entry) + "\n" + let created = false try { const stat = await fs.stat(filePath) if (stat.size > 0) { @@ -83,6 +84,7 @@ export async function appendChange(globalStorageDir: string, taskId: string, ent if ((error as NodeJS.ErrnoException).code !== "ENOENT") { throw error } + created = true } const data = Buffer.from(payload) const handle = await fs.open(filePath, "a") @@ -99,13 +101,26 @@ export async function appendChange(globalStorageDir: string, taskId: string, ent } finally { await handle.close() } + if (created && process.platform !== "win32") { + // The append created the file: on POSIX the new directory entry needs + // its own sync to survive a crash (Windows cannot open directories). + const dir = await fs.open(path.dirname(filePath), "r") + try { + await dir.sync() + } finally { + await dir.close() + } + } } /** * Load all change-journal entries for a task. * * Torn-tail repair: if the final line is truncated (JSON.parse fails), it is - * silently discarded. The rest of the file is returned in order. An absent + * silently discarded. Lines that parse to valid JSON but are not + * entry-shaped (non-object, missing string path/checkpointId, or an + * unknown operation) are skipped for the same reason. The rest of the file + * is returned in order. An absent * or empty journal returns [] — but only an ABSENT file. Any other read * failure (permissions, I/O) is rethrown: a journal that cannot be read must * not be indistinguishable from one that is legitimately empty. @@ -139,7 +154,20 @@ export async function loadChanges(globalStorageDir: string, taskId: string): Pro const entries: ChangeJournalEntry[] = [] for (let i = 0; i < lines.length; i++) { try { - entries.push(JSON.parse(lines[i]) as ChangeJournalEntry) + const parsed: unknown = JSON.parse(lines[i]) + // A torn append or manual edit can leave valid JSON that is not an + // entry (a bare number, null, or a differently-shaped object). + // Consumers read path/checkpointId unguarded, so skip any line that + // is not entry-shaped instead of returning it as an entry. + if ( + parsed && + typeof parsed === "object" && + typeof (parsed as ChangeJournalEntry).path === "string" && + typeof (parsed as ChangeJournalEntry).checkpointId === "string" && + ["create", "update", "delete"].includes((parsed as ChangeJournalEntry).operation) + ) { + entries.push(parsed as ChangeJournalEntry) + } } catch { // A corrupt line before the final line (e.g. a partially flushed // append) must not hide the valid entries after it. The final line From fd6b089756f950e6d75bfbd958aa811c9f085789 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 16 Oct 2026 11:16:45 +0800 Subject: [PATCH 32/48] fix: make journal updates serialized and atomic per checkpoint Implement the pre-merge Persistence Integrity demand: appendChanges replaces the per-entry append with one serialized (per-task) atomic update - read current journal, repair a torn tail, write the complete next content to a temp file, fsync it, atomically rename onto changes.jsonl, and fsync the parent directory (POSIX; Windows cannot open directories). A crash leaves either the old or the new complete journal, never a half-applied multi-file update. checkpointSave now persists the whole step's writes in that single update and propagates journal failures instead of reporting a fully durable checkpoint with lost entries; the change card is emitted only after the journal is durable. Save failures keep the historical handler (logged, checkpoints disabled, resolves undefined). Regression tests cover post-commit failure, mid-update rename failure (all-or-nothing, temp cleanup), concurrent-update serialization, restart recovery from a stale temp file and torn tail, and empty updates. --- .../__tests__/changeJournal.spec.ts | 77 +++++++-- .../__tests__/checkpointJournal.test.ts | 16 +- src/core/checkpoints/changeJournal.ts | 157 ++++++++++++------ src/core/checkpoints/index.ts | 102 ++++++------ 4 files changed, 234 insertions(+), 118 deletions(-) diff --git a/src/core/checkpoints/__tests__/changeJournal.spec.ts b/src/core/checkpoints/__tests__/changeJournal.spec.ts index b55289aab0..69a985267d 100644 --- a/src/core/checkpoints/__tests__/changeJournal.spec.ts +++ b/src/core/checkpoints/__tests__/changeJournal.spec.ts @@ -4,7 +4,7 @@ import path from "path" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { appendChange, journalPath, loadChanges, type ChangeJournalEntry } from "../changeJournal" +import { appendChange, appendChanges, journalPath, loadChanges, type ChangeJournalEntry } from "../changeJournal" describe("changeJournal", () => { const taskId = "test-task" @@ -72,7 +72,7 @@ describe("changeJournal", () => { expect(raw.split("\n").filter((line) => line !== "")).toHaveLength(2) }) - it("syncs the parent directory after the first append creates the journal (POSIX)", async () => { + it("syncs the parent directory after every journal replacement (POSIX)", async () => { const platformDesc = Object.getOwnPropertyDescriptor(process, "platform") Object.defineProperty(process, "platform", { value: "linux", configurable: true }) const dirPath = path.dirname(journalPath(tmpRoot, taskId)) @@ -100,9 +100,10 @@ describe("changeJournal", () => { await appendChange(tmpRoot, taskId, entry({ checkpointId: "first" })) expect(synced).toEqual([dirPath]) expect(closed).toEqual([dirPath]) - // Subsequent appends do not re-sync the directory. + // Every replacement renames a new file into place, so the directory + // entry is re-synced on each update. await appendChange(tmpRoot, taskId, entry({ checkpointId: "second" })) - expect(synced).toHaveLength(1) + expect(synced).toHaveLength(2) } finally { openSpy.mockRestore() if (platformDesc) Object.defineProperty(process, "platform", platformDesc) @@ -111,22 +112,23 @@ describe("changeJournal", () => { expect(result.map((e) => e.checkpointId)).toEqual(["first", "second"]) }) - it("propagates a non-ENOENT tail-inspection failure instead of appending blind", async () => { + it("propagates a non-ENOENT journal-read failure instead of replacing blind", async () => { await appendChange(tmpRoot, taskId, entry({ checkpointId: "good" })) const filePath = journalPath(tmpRoot, taskId) const raw = await fs.readFile(filePath, "utf8") await fs.writeFile(filePath, raw + '{"path":"src/half.ts","operation":"upd') - // The tail cannot be inspected (I/O error): appending would merge the - // new entry into the fragment, so appendChange must fail loudly. - const statSpy = vi - .spyOn(fs, "stat") + // The current content cannot be read (I/O error): replacing would + // discard whatever the unreadable journal really held, so appendChange + // must fail loudly. + const readSpy = vi + .spyOn(fs, "readFile") .mockRejectedValueOnce(Object.assign(new Error("simulated I/O error"), { code: "EIO" })) try { await expect(appendChange(tmpRoot, taskId, entry({ checkpointId: "after" }))).rejects.toMatchObject({ code: "EIO", }) } finally { - statSpy.mockRestore() + readSpy.mockRestore() } // The failed append wrote nothing: the next append still repairs and loads. await appendChange(tmpRoot, taskId, entry({ checkpointId: "after" })) @@ -140,7 +142,7 @@ describe("changeJournal", () => { const openSpy = vi.spyOn(fs, "open").mockImplementation(async (pathish, flags) => { const openAs = realOpen as (p: string | URL, f?: string) => Promise const handle = await openAs(pathish as string | URL, flags as string | undefined) - if (flags === "a") { + if (flags === "w") { const realWrite = handle.write.bind(handle) let first = true handle.write = (async (buf: Uint8Array, off?: number, len?: number) => { @@ -163,6 +165,59 @@ describe("changeJournal", () => { const result = await loadChanges(tmpRoot, taskId) expect(result.map((e) => e.checkpointId)).toEqual(["first", "second"]) }) + + it("serializes concurrent updates to the same task", async () => { + await Promise.all([ + appendChange(tmpRoot, taskId, entry({ checkpointId: "c1" })), + appendChange(tmpRoot, taskId, entry({ checkpointId: "c2" })), + ]) + + const result = await loadChanges(tmpRoot, taskId) + expect(result.map((e) => e.checkpointId).sort()).toEqual(["c1", "c2"]) + }) + + it("leaves the journal untouched when the atomic rename fails mid-update", async () => { + await appendChanges(tmpRoot, taskId, [entry({ checkpointId: "kept" })]) + const renameSpy = vi.spyOn(fs, "rename").mockRejectedValueOnce(new Error("simulated rename failure")) + try { + await expect( + appendChanges(tmpRoot, taskId, [ + entry({ checkpointId: "m1", path: "src/m1.ts" }), + entry({ checkpointId: "m2", path: "src/m2.ts" }), + ]), + ).rejects.toThrow("simulated rename failure") + } finally { + renameSpy.mockRestore() + } + // Neither of the two new entries survived: the update is all-or-nothing. + const result = await loadChanges(tmpRoot, taskId) + expect(result.map((e) => e.checkpointId)).toEqual(["kept"]) + // The failed temp file was cleaned up. + const leftovers = (await fs.readdir(path.dirname(journalPath(tmpRoot, taskId)))).filter((name) => + name.endsWith(".tmp"), + ) + expect(leftovers).toEqual([]) + }) + + it("recovers after a crash left a stale temp file and a torn tail", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "before" })) + const dir = path.dirname(journalPath(tmpRoot, taskId)) + // A crash between the temp write and the rename leaves an orphan temp + // file; a crash mid-write can also leave the tail unterminated. + await fs.writeFile(path.join(dir, "changes.jsonl.4242.orphan.tmp"), "partial") + const raw = await fs.readFile(journalPath(tmpRoot, taskId), "utf8") + await fs.writeFile(journalPath(tmpRoot, taskId), raw + '{"path":"src/half.ts","operation":"upd') + + await appendChange(tmpRoot, taskId, entry({ checkpointId: "after" })) + + const result = await loadChanges(tmpRoot, taskId) + expect(result.map((e) => e.checkpointId)).toEqual(["before", "after"]) + }) + + it("does not create a journal for an empty update", async () => { + await appendChanges(tmpRoot, taskId, []) + await expect(fs.stat(journalPath(tmpRoot, taskId))).rejects.toMatchObject({ code: "ENOENT" }) + }) }) describe("task ID containment", () => { diff --git a/src/core/checkpoints/__tests__/checkpointJournal.test.ts b/src/core/checkpoints/__tests__/checkpointJournal.test.ts index ca07a7d3ec..353e74d19f 100644 --- a/src/core/checkpoints/__tests__/checkpointJournal.test.ts +++ b/src/core/checkpoints/__tests__/checkpointJournal.test.ts @@ -197,23 +197,27 @@ describe("checkpointSave change-journal wiring (B2)", () => { expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) }) - it("logs and continues when the journal cannot be written (checkpoints stay enabled)", async () => { - // Block the per-task checkpoint dir so the journal mkdir/append fails. + it("propagates a journal persistence failure after the commit and emits no change card", async () => { + // Block the per-task checkpoint dir so the journal mkdir/read fails. const taskDir = path.join(tmpStorageDir, "tasks", TASK_ID) await fs.mkdir(taskDir, { recursive: true }) await fs.writeFile(path.join(taskDir, "checkpoints"), "blocker") const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined) - // The journal failure is swallowed (logged, not rethrown), so the - // checkpoint result still resolves exactly as without journaling. - await expect(checkpointSave(mockTask as Task, false, true, write)).resolves.toMatchObject({ commit: COMMIT }) + // The checkpoint committed but the journal update did not persist: the + // save must not report a fully durable checkpoint, and the change card + // (which advertises the journaled files) must not be emitted. + await expect(checkpointSave(mockTask as Task, false, true, write)).rejects.toThrow() + // Propagation is not a checkpoint error: checkpoints stay enabled. expect(mockTask.enableCheckpoints).toBe(true) expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining("failed to append change journal entry"), + expect.stringContaining("failed to persist change journal entries"), expect.anything(), ) + const cardCalls = vi.mocked(mockTask.say).mock.calls.filter((call) => call[0] === "change_card") + expect(cardCalls).toHaveLength(0) }) }) diff --git a/src/core/checkpoints/changeJournal.ts b/src/core/checkpoints/changeJournal.ts index eaa740acbe..aa18e5aa8f 100644 --- a/src/core/checkpoints/changeJournal.ts +++ b/src/core/checkpoints/changeJournal.ts @@ -48,69 +48,118 @@ export function journalPath(globalStorageDir: string, taskId: string): string { } /** - * Append one change-journal entry to the per-task changes.jsonl file. + * Per-task update queue: journal replacements for one task never overlap, + * so concurrent checkpoint saves cannot lose each other's entries through + * the read-modify-write cycle below. + */ +const journalQueues = new Map>() + +function withTaskLock(key: string, run: () => Promise): Promise { + const prev = journalQueues.get(key) ?? Promise.resolve() + // The next update runs after the previous one settles, whatever it did. + const next = prev.then(run, run) + // The queued promise swallows its outcome so a failed update neither + // blocks later updates nor surfaces as an unhandled rejection; callers + // observe the failure through `next`. + journalQueues.set( + key, + next.then( + () => undefined, + () => undefined, + ), + ) + return next +} + +/** + * Append change-journal entries to the per-task changes.jsonl file as one + * serialized, atomic update. + * + * The whole multi-entry update is a crash-safe replacement: the current + * journal is read, a torn tail is terminated, the complete next content is + * written to a temporary file in the same directory and fsynced, then + * atomically renamed onto the journal, and the parent directory is fsynced + * (POSIX; Windows cannot open directories). A crash at any point leaves + * either the old complete journal or the new complete journal - never a + * half-applied multi-file update - and a durable checkpoint implies a + * durable journal entry. Creates parent directories if they don't exist + * yet (e.g. first checkpoint). * - * Torn-tail repair: a crash mid-append can leave the file without a trailing - * newline; appending directly would splice the new entry into the fragment and - * lose both at load time, so the tail is terminated before the new line is - * written. The append is fsynced so a durable checkpoint implies a durable - * journal entry; a crash mid-write can still tear the appended line itself, - * which loadChanges tolerates as a torn tail. Creates parent directories if - * they don't exist yet (e.g. first checkpoint). + * Failures propagate: callers must not report a fully durable checkpoint + * when the journal update did not complete. */ -export async function appendChange(globalStorageDir: string, taskId: string, entry: ChangeJournalEntry): Promise { +export async function appendChanges( + globalStorageDir: string, + taskId: string, + entries: ChangeJournalEntry[], +): Promise { + if (entries.length === 0) { + return + } const filePath = journalPath(globalStorageDir, taskId) - await fs.mkdir(path.dirname(filePath), { recursive: true }) - let payload = JSON.stringify(entry) + "\n" - let created = false - try { - const stat = await fs.stat(filePath) - if (stat.size > 0) { - const tail = await fs.open(filePath, "r") + return withTaskLock(filePath, async () => { + const dir = path.dirname(filePath) + await fs.mkdir(dir, { recursive: true }) + let content = "" + try { + content = await fs.readFile(filePath, "utf8") + } catch (error) { + // A missing journal is a fresh history. Any other read failure + // means the complete next content cannot be built, so propagate + // instead of replacing the journal with a partial view of it. + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error + } + } + let payload = entries.map((entry) => JSON.stringify(entry) + "\n").join("") + // Torn-tail repair: a journal left without a trailing newline (e.g. + // a crash before this replacement path completed) must be terminated + // before the new lines or the first appended entry merges into the + // fragment. + if (content && !content.endsWith("\n")) { + payload = "\n" + payload + } + const tmpPath = path.join(dir, `${JOURNAL_FILENAME}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`) + const data = Buffer.from(content + payload) + try { + const handle = await fs.open(tmpPath, "w") try { - const last = Buffer.alloc(1) - await tail.read(last, 0, 1, stat.size - 1) - if (last[0] !== 0x0a) { - payload = "\n" + payload + // A single write can complete partially; sync only after the + // whole content is on the handle, or a short write would + // rename a torn journal into place. + let written = 0 + while (written < data.length) { + const res = await handle.write(data, written, data.length - written) + written += res.bytesWritten } + await handle.sync() } finally { - await tail.close() + await handle.close() } - } - } catch (error) { - // A missing journal is a fresh append. Any other inspection failure - // means we cannot know whether a separator is required, so propagate - // instead of appending blind and losing the entry to a merged line. - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + await fs.rename(tmpPath, filePath) + if (process.platform !== "win32") { + // The rename replaced the directory entry: on POSIX the + // directory itself must be synced for the new entry to + // survive a crash (Windows cannot open directories). + const dirHandle = await fs.open(dir, "r") + try { + await dirHandle.sync() + } finally { + await dirHandle.close() + } + } + } catch (error) { + // Best-effort cleanup: the rename never completed, so the journal + // still holds the previous complete content; drop the temp file. + await fs.rm(tmpPath, { force: true }).catch(() => {}) throw error } - created = true - } - const data = Buffer.from(payload) - const handle = await fs.open(filePath, "a") - try { - // A single write can complete partially; sync only after the whole - // record is on the handle, or a short write would persist a torn entry - // while appendChange reports success. - let written = 0 - while (written < data.length) { - const res = await handle.write(data, written, data.length - written) - written += res.bytesWritten - } - await handle.sync() - } finally { - await handle.close() - } - if (created && process.platform !== "win32") { - // The append created the file: on POSIX the new directory entry needs - // its own sync to survive a crash (Windows cannot open directories). - const dir = await fs.open(path.dirname(filePath), "r") - try { - await dir.sync() - } finally { - await dir.close() - } - } + }) +} + +/** Append a single entry; see appendChanges for the durability contract. */ +export async function appendChange(globalStorageDir: string, taskId: string, entry: ChangeJournalEntry): Promise { + await appendChanges(globalStorageDir, taskId, [entry]) } /** diff --git a/src/core/checkpoints/index.ts b/src/core/checkpoints/index.ts index 439b6eab8f..6e71a17069 100644 --- a/src/core/checkpoints/index.ts +++ b/src/core/checkpoints/index.ts @@ -16,7 +16,7 @@ import { DIFF_VIEW_URI_SCHEME } from "../../integrations/editor/DiffViewProvider import { CheckpointServiceOptions, RepoPerTaskCheckpointService } from "../../services/checkpoints" -import { appendChange } from "./changeJournal" +import { appendChanges } from "./changeJournal" import { buildChangeCardPayload } from "./changeCard" const WARNING_THRESHOLD_MS = 5000 @@ -253,57 +253,65 @@ export async function checkpointSave( TelemetryService.instance.captureCheckpointCreated(task.taskId) // Start the checkpoint process in the background. - return service + // Save failures keep the historical behavior: logged, checkpoints + // disabled, and the save resolves undefined. Journal/card work below runs + // only for a real commit and its failures propagate to the caller + // instead of flowing through this handler. + const result = await service .saveCheckpoint(`Task: ${task.taskId}, Time: ${Date.now()}`, { allowEmpty: force, suppressMessage }) - .then(async (result) => { - // B2: record successful file writes in the per-task change journal. - // Only a real commit produces an entry (an empty or failed save - // resolves to undefined / rejects), and non-write checkpoint calls - // (e.g. the task-start baseline) pass no `write` value at all. - // A no-op checkpoint carries an empty writes array: unrelated workspace - // changes can still produce a commit, and emitting a card for it would - // show a change card with zero files. The checkpoint itself still runs. - if (result?.commit && write && (!Array.isArray(write) || write.length > 0)) { - const writes = Array.isArray(write) ? write : [write] - const globalStorageDir = task.providerRef.deref()?.context.globalStorageUri.fsPath - if (globalStorageDir) { - // Append sequentially so journal lines preserve write order. A - // journal failure is logged here and never propagates to the - // checkpoint error handler (checkpoints stay enabled). - try { - for (const w of writes) { - await appendChange(globalStorageDir, task.taskId, { - path: w.path, - operation: w.operation, - checkpointId: result.commit, - ...(w.diffStats ? { diffStats: w.diffStats } : {}), - }) - } - } catch (err) { - console.error("[Task#checkpointSave] failed to append change journal entry", err) - } - } - - // B3a: emit the per-step change card now that the checkpoint commit - // exists. The card reuses the approval diff/stats the tool already - // computed and is always emitted (auto-approved steps included); - // a card failure is logged and never disables checkpoints. - try { - const state = await task.providerRef.deref()?.getState() - const card = buildChangeCardPayload(result.commit, writes, state?.changeCardDetail) - await task.say("change_card", JSON.stringify(card), undefined, undefined, undefined, undefined, { - isNonInteractive: true, - }) - } catch (err) { - console.error("[Task#checkpointSave] failed to emit change card", err) - } - } - return result - }) .catch((err) => { console.error("[Task#checkpointSave] caught unexpected error, disabling checkpoints", err) task.enableCheckpoints = false }) + // B2: record successful file writes in the per-task change journal. + // Only a real commit produces an entry (an empty or failed save + // resolves to undefined / rejects), and non-write checkpoint calls + // (e.g. the task-start baseline) pass no `write` value at all. + // A no-op checkpoint carries an empty writes array: unrelated workspace + // changes can still produce a commit, and emitting a card for it would + // show a change card with zero files. The checkpoint itself still runs. + if (result?.commit && write && (!Array.isArray(write) || write.length > 0)) { + const writes = Array.isArray(write) ? write : [write] + const globalStorageDir = task.providerRef.deref()?.context.globalStorageUri.fsPath + if (globalStorageDir) { + // Persist the whole step's journal update as one serialized, + // atomic replacement (temp file + fsync + rename + directory + // sync), preserving write order. A journal failure is logged and + // propagated: the checkpoint must not be reported fully durable + // while its journal entries were lost. + try { + await appendChanges( + globalStorageDir, + task.taskId, + writes.map((w) => ({ + path: w.path, + operation: w.operation, + checkpointId: result.commit, + ...(w.diffStats ? { diffStats: w.diffStats } : {}), + })), + ) + } catch (err) { + console.error("[Task#checkpointSave] failed to persist change journal entries", err) + throw err + } + } + + // B3a: emit the per-step change card now that the checkpoint commit + // exists and the journal update is durable (a journal failure above + // propagated and skips the card). The card reuses the approval diff/stats the tool already + // computed and is always emitted (auto-approved steps included); + // a card failure is logged and never disables checkpoints. + try { + const state = await task.providerRef.deref()?.getState() + const card = buildChangeCardPayload(result.commit, writes, state?.changeCardDetail) + await task.say("change_card", JSON.stringify(card), undefined, undefined, undefined, undefined, { + isNonInteractive: true, + }) + } catch (err) { + console.error("[Task#checkpointSave] failed to emit change card", err) + } + } + return result } export type CheckpointRestoreOptions = { From 0344f879f612b9e4fcbfdf9544d553dea2213743 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 16 Oct 2026 14:52:46 +0800 Subject: [PATCH 33/48] fix(checkpoints): route journal persistence failures to explicit tool failure handling - WriteToFileTool no longer swallows checkpointSave rejections; the typed CheckpointPartialPersistenceError (checkpoint id + affected paths) reaches the tool's failure handling and the successful result is emitted only after persistence succeeded. - appendChanges truncates torn tails instead of preserving them. --- .../__tests__/changeJournal.spec.ts | 9 ++++--- .../__tests__/checkpointJournal.test.ts | 6 ++++- src/core/checkpoints/changeJournal.ts | 12 +++++----- src/core/checkpoints/index.ts | 24 ++++++++++++++++++- src/core/tools/ApplyPatchTool.ts | 5 ++-- src/core/tools/EditFileTool.ts | 5 ++-- src/core/tools/WriteToFileTool.ts | 18 ++++++++------ .../tools/__tests__/writeToFileTool.spec.ts | 19 +++++++++++++++ 8 files changed, 76 insertions(+), 22 deletions(-) diff --git a/src/core/checkpoints/__tests__/changeJournal.spec.ts b/src/core/checkpoints/__tests__/changeJournal.spec.ts index 69a985267d..4cc0a2f8a6 100644 --- a/src/core/checkpoints/__tests__/changeJournal.spec.ts +++ b/src/core/checkpoints/__tests__/changeJournal.spec.ts @@ -61,6 +61,12 @@ describe("changeJournal", () => { const result = await loadChanges(tmpRoot, taskId) expect(result.map((e) => e.checkpointId)).toEqual(["good", "after"]) + + // The lost fragment is truncated away, not preserved (a later + // strict reader must never see it. + const repaired = await fs.readFile(filePath, "utf8") + expect(repaired).not.toContain("half.ts") + expect(repaired).not.toContain("half.ts") }) it("does not insert a blank line when the journal ends cleanly", async () => { @@ -114,9 +120,6 @@ describe("changeJournal", () => { it("propagates a non-ENOENT journal-read failure instead of replacing blind", async () => { await appendChange(tmpRoot, taskId, entry({ checkpointId: "good" })) - const filePath = journalPath(tmpRoot, taskId) - const raw = await fs.readFile(filePath, "utf8") - await fs.writeFile(filePath, raw + '{"path":"src/half.ts","operation":"upd') // The current content cannot be read (I/O error): replacing would // discard whatever the unreadable journal really held, so appendChange // must fail loudly. diff --git a/src/core/checkpoints/__tests__/checkpointJournal.test.ts b/src/core/checkpoints/__tests__/checkpointJournal.test.ts index 353e74d19f..3e70241f9a 100644 --- a/src/core/checkpoints/__tests__/checkpointJournal.test.ts +++ b/src/core/checkpoints/__tests__/checkpointJournal.test.ts @@ -208,7 +208,11 @@ describe("checkpointSave change-journal wiring (B2)", () => { // The checkpoint committed but the journal update did not persist: the // save must not report a fully durable checkpoint, and the change card // (which advertises the journaled files) must not be emitted. - await expect(checkpointSave(mockTask as Task, false, true, write)).rejects.toThrow() + await expect(checkpointSave(mockTask as Task, false, true, write)).rejects.toMatchObject({ + name: "CheckpointPartialPersistenceError", + checkpointId: COMMIT, + writes: [{ path: "src/foo.ts", operation: "create", diffStats: { additions: 3, deletions: 0 } }], + }) // Propagation is not a checkpoint error: checkpoints stay enabled. expect(mockTask.enableCheckpoints).toBe(true) diff --git a/src/core/checkpoints/changeJournal.ts b/src/core/checkpoints/changeJournal.ts index aa18e5aa8f..c31571619d 100644 --- a/src/core/checkpoints/changeJournal.ts +++ b/src/core/checkpoints/changeJournal.ts @@ -111,14 +111,14 @@ export async function appendChanges( throw error } } - let payload = entries.map((entry) => JSON.stringify(entry) + "\n").join("") - // Torn-tail repair: a journal left without a trailing newline (e.g. - // a crash before this replacement path completed) must be terminated - // before the new lines or the first appended entry merges into the - // fragment. + // Torn-tail repair: a crash can leave the final line unterminated - a + // fragment of a lost write. The replacement truncates that fragment + // instead of preserving it, so the journal only ever holds complete + // records and a later entry can never mask the lost one. if (content && !content.endsWith("\n")) { - payload = "\n" + payload + content = content.slice(0, content.lastIndexOf("\n") + 1) } + const payload = entries.map((entry) => JSON.stringify(entry) + "\n").join("") const tmpPath = path.join(dir, `${JOURNAL_FILENAME}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`) const data = Buffer.from(content + payload) try { diff --git a/src/core/checkpoints/index.ts b/src/core/checkpoints/index.ts index 6e71a17069..51a6c037ea 100644 --- a/src/core/checkpoints/index.ts +++ b/src/core/checkpoints/index.ts @@ -237,6 +237,22 @@ export type CheckpointWriteInfo = { */ autoApproved?: boolean } +/** + * Thrown when a checkpoint commit succeeded but its journal/card persistence + * did not. Carries the committed checkpoint id and the complete affected write + * set so tool callers can report an explicit partial-persistence state + * instead of a silent full success. + */ +export class CheckpointPartialPersistenceError extends Error { + constructor( + message: string, + readonly checkpointId: string, + readonly writes: CheckpointWriteInfo[], + ) { + super(message) + this.name = "CheckpointPartialPersistenceError" + } +} export async function checkpointSave( task: Task, @@ -292,7 +308,13 @@ export async function checkpointSave( ) } catch (err) { console.error("[Task#checkpointSave] failed to persist change journal entries", err) - throw err + throw new CheckpointPartialPersistenceError( + `Checkpoint ${result.commit} was committed but its change journal could not be persisted (files: ${writes + .map((w) => w.path) + .join(", ")}); rollback metadata is incomplete.`, + result.commit, + writes, + ) } } diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 9944364b72..438c926021 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -216,8 +216,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // approval diff/stats and auto-approval state, retained by the // handlers, feed the per-step change card. // Awaited: a later write must not interleave with this patch's - // staging/commit/journal/change-card work. checkpointSave never - // rejects (service call wrapped in try/catch upstream). + // staging/commit/journal/change-card work. checkpointSave rejects with a + // CheckpointPartialPersistenceError when a committed checkpoint's + // journal update fails; the catch below reports it. await checkpointSave( task, false, diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index 3f3f70f572..6e5d0c72de 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -483,8 +483,9 @@ export class EditFileTool extends BaseTool<"edit_file"> { }) ).decision === "approve" // Awaited: a later tool block must not interleave with this edit's - // staging/commit/journal/change-card work. checkpointSave never - // rejects (service call wrapped in try/catch upstream). + // staging/commit/journal/change-card work. checkpointSave rejects with a + // CheckpointPartialPersistenceError when a committed checkpoint's + // journal update fails; the catch below reports it. await checkpointSave(task, false, true, { path: relPath, operation: isNewFile ? "create" : "update", diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 17b3d82ba5..5be263d595 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -188,18 +188,17 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { task.didEditFile = true - const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, !fileExists) - - pushToolResult(message) - if (perWriteCheckpoints) { // B2: the change-journal entry for this write is appended inside // checkpointSave (the hook stays a single call site), keyed by the // checkpoint commit that call produces. Await so the checkpoint - // (staging + commit) finishes before the next queued write starts; - // otherwise two writes can collapse into one commit. B3a threads + // (staging + commit + journal) finishes before the next queued write + // starts; otherwise two writes can collapse into one commit. B3a threads // the approval diff (for the change card) and whether the step was // auto-approved (auto-approved steps always get the compact card). + // A persistence failure - a committed checkpoint whose journal update + // was lost - propagates to the catch below (explicit tool failure + // handling, surfacing the checkpoint id and affected paths). const autoApproved = ( await checkAutoApproval({ @@ -218,9 +217,14 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { : undefined, ...(approvalDiff ? { diff: approvalDiff } : {}), ...(autoApproved ? { autoApproved: true } : {}), - }).catch(() => {}) + }) } + // Emitted only after checkpoint + journal persistence succeeded. + const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, !fileExists) + + pushToolResult(message) + await task.diffViewProvider.reset() this.resetPartialState() diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 04b4073720..62efae6c7d 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -527,6 +527,25 @@ describe("writeToFileTool", () => { expect(mockedCheckpointSave).not.toHaveBeenCalled() }) + it("routes a journal persistence failure to tool failure handling without a success result", async () => { + // The checkpoint committed but its journal update failed: the tool + // must not report the write as a complete success. + mockedCheckpointSave.mockRejectedValueOnce( + new Error( + "Checkpoint abc123def was committed but its change journal could not be persisted (files: test/file.txt); rollback metadata is incomplete.", + ), + ) + + await executeWriteFileTool({}) + + expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) + const surfaced = String(mockHandleError.mock.calls[0]?.[1]) + expect(surfaced).toContain("abc123def") + expect(surfaced).toContain("test/file.txt") + // The normal successful tool result is not emitted. + expect(mockPushToolResult).not.toHaveBeenCalled() + }) + it("waits for the per-write checkpoint before the tool completes", async () => { let checkpointStarted = false let releaseCheckpoint: () => void = () => {} From 42d6fe59adf1c3fae163f496be67da2fd086a687 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 16 Oct 2026 16:08:02 +0800 Subject: [PATCH 34/48] fix(checkpoints): harden journal durability and defer success until persistence CodeRabbit follow-up wave: - changeJournal: retry transient Windows rename rejections (EPERM/ EBUSY/EACCES) with a short backoff instead of failing the append; preserve a complete final record that lost only its newline instead of truncating it; tolerate fsync-unsupported errnos on the post-rename directory sync (the journal content is already in place) while still propagating genuine I/O failures. - Write/EditFile/ApplyPatch tools: emit success results only after the checkpoint + journal persistence succeeded, and hand off queued messages from finally whenever the file write itself succeeded - a journal persistence failure now surfaces as an explicit tool failure without a success result and without stranding the message queue. - ApplyPatchTool buffers per-file success results until the post-patch checkpoint persists; failure results stay immediate. --- .../__tests__/changeJournal.spec.ts | 122 ++++++++++++++++++ src/core/checkpoints/changeJournal.ts | 87 ++++++++++--- src/core/tools/ApplyPatchTool.ts | 57 +++++--- src/core/tools/EditFileTool.ts | 27 ++-- src/core/tools/WriteToFileTool.ts | 13 +- .../__tests__/applyPatchTool.execute.spec.ts | 38 ++++++ src/core/tools/__tests__/editFileTool.spec.ts | 17 +++ .../tools/__tests__/writeToFileTool.spec.ts | 7 +- 8 files changed, 320 insertions(+), 48 deletions(-) diff --git a/src/core/checkpoints/__tests__/changeJournal.spec.ts b/src/core/checkpoints/__tests__/changeJournal.spec.ts index 4cc0a2f8a6..79ab3c5386 100644 --- a/src/core/checkpoints/__tests__/changeJournal.spec.ts +++ b/src/core/checkpoints/__tests__/changeJournal.spec.ts @@ -169,6 +169,128 @@ describe("changeJournal", () => { expect(result.map((e) => e.checkpointId)).toEqual(["first", "second"]) }) + it("preserves a complete final record that lost only its newline", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "good" })) + // Simulate a crash that flushed a complete record but lost its + // terminating newline: the record is entry-shaped and parseable, so + // the repair must keep it and re-terminate instead of truncating. + const filePath = journalPath(tmpRoot, taskId) + const raw = await fs.readFile(filePath, "utf8") + await fs.writeFile(filePath, raw + JSON.stringify(entry({ checkpointId: "complete" }))) + + await appendChange(tmpRoot, taskId, entry({ checkpointId: "after" })) + + const result = await loadChanges(tmpRoot, taskId) + expect(result.map((e) => e.checkpointId)).toEqual(["good", "complete", "after"]) + }) + + it("retries a Windows rename rejected with EPERM", async () => { + const platformDesc = Object.getOwnPropertyDescriptor(process, "platform") + Object.defineProperty(process, "platform", { value: "win32", configurable: true }) + const realRename = fs.rename + let attempts = 0 + const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async (from, to) => { + attempts++ + if (attempts === 1) { + throw Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" }) + } + await realRename(from as string | URL, to as string | URL) + }) + try { + // An antivirus/indexer transiently holding the destination must + // not fail the append: the rename is retried with a short backoff. + await appendChange(tmpRoot, taskId, entry({ checkpointId: "first" })) + expect(attempts).toBe(2) + } finally { + renameSpy.mockRestore() + if (platformDesc) Object.defineProperty(process, "platform", platformDesc) + } + const result = await loadChanges(tmpRoot, taskId) + expect(result.map((e) => e.checkpointId)).toEqual(["first"]) + }) + + it("does not retry a POSIX rename rejection", async () => { + const platformDesc = Object.getOwnPropertyDescriptor(process, "platform") + Object.defineProperty(process, "platform", { value: "linux", configurable: true }) + let attempts = 0 + const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async () => { + attempts++ + throw Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" }) + }) + try { + await expect(appendChange(tmpRoot, taskId, entry({ checkpointId: "x" }))).rejects.toMatchObject({ + code: "EPERM", + }) + expect(attempts).toBe(1) + } finally { + renameSpy.mockRestore() + if (platformDesc) Object.defineProperty(process, "platform", platformDesc) + } + }) + + it("tolerates an unsupported directory-sync errno after the rename (POSIX)", async () => { + const platformDesc = Object.getOwnPropertyDescriptor(process, "platform") + Object.defineProperty(process, "platform", { value: "linux", configurable: true }) + const dirPath = path.dirname(journalPath(tmpRoot, taskId)) + const realOpen = fs.open + const openSpy = vi.spyOn(fs, "open").mockImplementation(async (pathish, flags) => { + const openAs = realOpen as (p: string | URL, f?: string) => Promise + const p = typeof pathish === "string" ? pathish : String(pathish) + if (p === dirPath && flags === "r") { + return { + sync: async () => { + throw Object.assign(new Error("EINVAL: invalid argument"), { code: "EINVAL" }) + }, + close: async () => {}, + } as unknown as fs.FileHandle + } + return openAs(p, flags as string | undefined) + }) + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + // The rename already replaced the journal: an fsync-unsupported + // mount must not fail the update, only reduce crash durability. + await appendChange(tmpRoot, taskId, entry({ checkpointId: "kept" })) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("directory sync unsupported")) + } finally { + openSpy.mockRestore() + warnSpy.mockRestore() + if (platformDesc) Object.defineProperty(process, "platform", platformDesc) + } + const result = await loadChanges(tmpRoot, taskId) + expect(result.map((e) => e.checkpointId)).toEqual(["kept"]) + }) + + it("propagates a genuine directory-sync failure (POSIX)", async () => { + const platformDesc = Object.getOwnPropertyDescriptor(process, "platform") + Object.defineProperty(process, "platform", { value: "linux", configurable: true }) + const dirPath = path.dirname(journalPath(tmpRoot, taskId)) + const realOpen = fs.open + const openSpy = vi.spyOn(fs, "open").mockImplementation(async (pathish, flags) => { + const openAs = realOpen as (p: string | URL, f?: string) => Promise + const p = typeof pathish === "string" ? pathish : String(pathish) + if (p === dirPath && flags === "r") { + return { + sync: async () => { + throw Object.assign(new Error("EIO: i/o error"), { code: "EIO" }) + }, + close: async () => {}, + } as unknown as fs.FileHandle + } + return openAs(p, flags as string | undefined) + }) + try { + // A real I/O failure is not an "unsupported" case: it still + // propagates so callers know durability is in doubt. + await expect(appendChange(tmpRoot, taskId, entry({ checkpointId: "x" }))).rejects.toMatchObject({ + code: "EIO", + }) + } finally { + openSpy.mockRestore() + if (platformDesc) Object.defineProperty(process, "platform", platformDesc) + } + }) + it("serializes concurrent updates to the same task", async () => { await Promise.all([ appendChange(tmpRoot, taskId, entry({ checkpointId: "c1" })), diff --git a/src/core/checkpoints/changeJournal.ts b/src/core/checkpoints/changeJournal.ts index c31571619d..34b0166337 100644 --- a/src/core/checkpoints/changeJournal.ts +++ b/src/core/checkpoints/changeJournal.ts @@ -88,6 +88,17 @@ function withTaskLock(key: string, run: () => Promise): Promise { * Failures propagate: callers must not report a fully durable checkpoint * when the journal update did not complete. */ +/** True when a parsed JSON value is a complete journal record. */ +function isEntryShaped(value: unknown): value is ChangeJournalEntry { + return ( + !!value && + typeof value === "object" && + typeof (value as ChangeJournalEntry).path === "string" && + typeof (value as ChangeJournalEntry).checkpointId === "string" && + ["create", "update", "delete"].includes((value as ChangeJournalEntry).operation) + ) +} + export async function appendChanges( globalStorageDir: string, taskId: string, @@ -111,12 +122,21 @@ export async function appendChanges( throw error } } - // Torn-tail repair: a crash can leave the final line unterminated - a - // fragment of a lost write. The replacement truncates that fragment - // instead of preserving it, so the journal only ever holds complete - // records and a later entry can never mask the lost one. + // Torn-tail repair: a crash can leave the final line unterminated. If + // that final line is a complete, entry-shaped JSON record it was fully + // written and only its newline was lost - keep it and re-terminate. + // Otherwise it is a fragment of a lost write and the replacement + // truncates it, so the journal only ever holds complete records and a + // later entry can never mask the lost one. if (content && !content.endsWith("\n")) { - content = content.slice(0, content.lastIndexOf("\n") + 1) + const cut = content.lastIndexOf("\n") + 1 + let tail: unknown + try { + tail = JSON.parse(content.slice(cut)) + } catch { + tail = undefined + } + content = isEntryShaped(tail) ? content + "\n" : content.slice(0, cut) } const payload = entries.map((entry) => JSON.stringify(entry) + "\n").join("") const tmpPath = path.join(dir, `${JOURNAL_FILENAME}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`) @@ -136,16 +156,51 @@ export async function appendChanges( } finally { await handle.close() } - await fs.rename(tmpPath, filePath) + // Windows can transiently reject a rename onto the existing journal + // (an antivirus or indexer holding the destination): retry with a + // short backoff before failing. The destination is never unlinked + // to make room, so atomicity is preserved on every failure. + const renameAttempts = process.platform === "win32" ? 5 : 1 + for (let attempt = 1; ; attempt++) { + try { + await fs.rename(tmpPath, filePath) + break + } catch (error) { + const renameCode = (error as NodeJS.ErrnoException).code + if ( + process.platform !== "win32" || + attempt >= renameAttempts || + !["EPERM", "EBUSY", "EACCES"].includes(renameCode ?? "") + ) { + throw error + } + await new Promise((resolve) => setTimeout(resolve, 20 * attempt)) + } + } if (process.platform !== "win32") { // The rename replaced the directory entry: on POSIX the // directory itself must be synced for the new entry to - // survive a crash (Windows cannot open directories). - const dirHandle = await fs.open(dir, "r") + // survive a crash (Windows cannot open directories). The + // journal content is already in place at this point, so a + // sync failure means reduced crash durability, not a lost + // update: errnos that mean "fsync is unsupported on this + // mount" are tolerated and logged; any other failure still + // propagates. try { - await dirHandle.sync() - } finally { - await dirHandle.close() + const dirHandle = await fs.open(dir, "r") + try { + await dirHandle.sync() + } finally { + await dirHandle.close() + } + } catch (error) { + const syncCode = (error as NodeJS.ErrnoException).code + if (!["EINVAL", "EISDIR", "EBADF", "ENOTSUP", "EOPNOTSUPP", "EPERM"].includes(syncCode ?? "")) { + throw error + } + console.warn( + `[changeJournal] directory sync unsupported after journal rename (${syncCode}); crash durability reduced`, + ) } } } catch (error) { @@ -208,14 +263,8 @@ export async function loadChanges(globalStorageDir: string, taskId: string): Pro // entry (a bare number, null, or a differently-shaped object). // Consumers read path/checkpointId unguarded, so skip any line that // is not entry-shaped instead of returning it as an entry. - if ( - parsed && - typeof parsed === "object" && - typeof (parsed as ChangeJournalEntry).path === "string" && - typeof (parsed as ChangeJournalEntry).checkpointId === "string" && - ["create", "update", "delete"].includes((parsed as ChangeJournalEntry).operation) - ) { - entries.push(parsed as ChangeJournalEntry) + if (isEntryShaped(parsed)) { + entries.push(parsed) } } catch { // A corrupt line before the final line (e.g. a partially flushed diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 438c926021..e92018510b 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -32,6 +32,13 @@ interface ApplyPatchParams { interface ApplyPatchFileOpResult { succeeded: boolean wrote: boolean + /** + * Successful per-file result message. Buffered by execute and emitted only + * after the post-patch checkpoint + journal persistence succeeded, so a + * success is never reported before its journal save completes. Failure + * results stay immediate. + */ + resultMessage?: string } export class ApplyPatchTool extends BaseTool<"apply_patch"> { @@ -70,6 +77,11 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const { patch } = params const { handleError, pushToolResult } = callbacks + // Declared before the try so the finally block can hand off queued + // messages when a write succeeded but the post-patch checkpoint failed. + const successfulChanges: ApplyPatchFileChange[] = [] + const bufferedResults: string[] = [] + try { // Validate required parameters if (!patch) { @@ -122,7 +134,6 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // — a no-op update must not be journaled). A rejected approval or a // failed local write never gets checkpointed as a success. let patchSucceeded = true - const successfulChanges: ApplyPatchFileChange[] = [] for (const change of changes) { const relPath = change.path const absolutePath = path.resolve(task.cwd, relPath) @@ -157,6 +168,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (addResult.wrote) { successfulChanges.push(change) } + if (addResult.resultMessage) { + bufferedResults.push(addResult.resultMessage) + } } else if (change.type === "delete") { // Delete file const deleteResult = await this.handleDeleteFile( @@ -171,6 +185,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (deleteResult.wrote) { successfulChanges.push(change) } + if (deleteResult.resultMessage) { + bufferedResults.push(deleteResult.resultMessage) + } } else if (change.type === "update") { // Update file (a no-op update succeeds without writing) const updateResult = await this.handleUpdateFile( @@ -185,6 +202,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (updateResult.wrote) { successfulChanges.push(change) } + if (updateResult.resultMessage) { + bufferedResults.push(updateResult.resultMessage) + } } } @@ -241,17 +261,26 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } } + // CR follow-up: successful per-file results are emitted only after + // the post-patch checkpoint + journal persistence above succeeded. + // A persistence failure routes to the catch below and the buffered + // successes are dropped in favor of the tool failure result; + // failure results stayed immediate. + for (const message of bufferedResults) { + pushToolResult(message) + } + } catch (error) { + await handleError("apply patch", error as Error) + await task.diffViewProvider.reset() + } finally { // B2: deferred from the per-file handlers - a queued write must not // start until the post-patch checkpoint above has persisted. The - // handlers used to process queued messages right after each - // successful write; a write happened if and only if - // `successfulChanges` is non-empty, so one call here preserves that. + // handoff runs even when that checkpoint/journal persistence + // rejected: the file writes themselves succeeded and upstream + // handed off after every successful write. if (successfulChanges.length > 0) { task.processQueuedMessages() } - } catch (error) { - await handleError("apply patch", error as Error) - await task.diffViewProvider.reset() } } @@ -357,9 +386,8 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task.didEditFile = true const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, true) - pushToolResult(message) await task.diffViewProvider.reset() - return { succeeded: true, wrote: true } + return { succeeded: true, wrote: true, resultMessage: message } } private async handleDeleteFile( @@ -429,8 +457,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } task.didEditFile = true - pushToolResult(`Successfully deleted ${relPath}`) - return { succeeded: true, wrote: true } + return { succeeded: true, wrote: true, resultMessage: `Successfully deleted ${relPath}` } } private async handleUpdateFile( @@ -473,9 +500,8 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // nothing was written, so the whole-patch success state is kept — // but `wrote` stays false so the change journal does not document a // write that never happened. - pushToolResult(`No changes needed for '${relPath}'`) await task.diffViewProvider.reset() - return { succeeded: true, wrote: false } + return { succeeded: true, wrote: false, resultMessage: `No changes needed for '${relPath}'` } } // Check experiment settings @@ -622,15 +648,14 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task.didEditFile = true const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, false) - pushToolResult(message) await task.diffViewProvider.reset() if (!moveSucceeded) { // The destination file was written on disk before the source // deletion failed, so the write must still be checkpointed and // journaled; the move itself is reported as failed. - return { succeeded: false, wrote: true } + return { succeeded: false, wrote: true, resultMessage: message } } - return { succeeded: true, wrote: true } + return { succeeded: true, wrote: true, resultMessage: message } } 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 6e5d0c72de..5fe0d4ee95 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -148,6 +148,7 @@ export class EditFileTool extends BaseTool<"edit_file"> { const { askApproval, handleError, pushToolResult } = callbacks let relPathForErrorHandling: string | undefined let operationPreviewForErrorHandling: string | undefined + let didWrite = false const finalizePartialToolAskIfNeeded = async (relPath: string): Promise => { if (!this.didSendPartialToolAsk) { @@ -458,13 +459,7 @@ export class EditFileTool extends BaseTool<"edit_file"> { } task.didEditFile = true - - // Get the formatted response message - const replacementInfo = - !isNewFile && expected_replacements > 1 ? ` (${expected_replacements} replacements)` : "" - const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, isNewFile) - - pushToolResult(message + replacementInfo) + didWrite = true if (perWriteCheckpoints) { // B2: the change-journal entry for this edit is appended inside @@ -495,11 +490,16 @@ export class EditFileTool extends BaseTool<"edit_file"> { }) } + // Emitted only after checkpoint + journal persistence succeeded: a + // persistence failure must not report the edit as fully persisted. + const replacementInfo = + !isNewFile && expected_replacements > 1 ? ` (${expected_replacements} replacements)` : "" + const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, isNewFile) + + pushToolResult(message + replacementInfo) + await task.diffViewProvider.reset() this.resetPartialState() - - // Process any queued messages after file edit completes - task.processQueuedMessages() } catch (error) { if (relPathForErrorHandling) { await finalizePartialToolAskIfNeeded(relPathForErrorHandling) @@ -511,6 +511,13 @@ export class EditFileTool extends BaseTool<"edit_file"> { this.didSendPartialToolAsk = false this.partialToolAskRelPath = undefined this.resetPartialState() + // Hand off queued messages whenever the write itself succeeded - + // including when the post-write checkpoint/journal persistence then + // rejected (the write is real; upstream handed off after every + // successful write). + if (didWrite) { + task.processQueuedMessages() + } } } diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 5be263d595..42bc94f411 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -97,6 +97,8 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { isProtected: isWriteProtected, } + let didWrite = false + try { task.consecutiveMistakeCount = 0 @@ -187,6 +189,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { } task.didEditFile = true + didWrite = true if (perWriteCheckpoints) { // B2: the change-journal entry for this write is appended inside @@ -228,14 +231,20 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { await task.diffViewProvider.reset() this.resetPartialState() - task.processQueuedMessages() - return } catch (error) { await handleError("writing file", error as Error) await task.diffViewProvider.reset() this.resetPartialState() return + } finally { + // Hand off queued messages whenever the write itself succeeded - + // including when the post-write checkpoint/journal persistence then + // rejected (the write is real; upstream handed off after every + // successful write). + if (didWrite) { + task.processQueuedMessages() + } } } diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index fef427da12..eda5460bce 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -164,6 +164,44 @@ describe("ApplyPatchTool.execute - delete file success path", () => { ]) }) + it("emits per-file success results only after the post-patch checkpoint persists", async () => { + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // CR follow-up: the buffered success result is emitted only after + // checkpointSave resolved - a success is never reported before its + // journal save completes. + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully deleted")) + const saveOrder = mockedCheckpointSave.mock.invocationCallOrder[0] + const resultOrder = mockPushToolResult.mock.invocationCallOrder[0] + expect(saveOrder).toBeLessThan(resultOrder) + }) + + it("drops buffered success results and still hands off on a journal persistence failure", async () => { + mockedCheckpointSave.mockRejectedValueOnce( + new Error( + "Checkpoint abc123def was committed but its change journal could not be persisted (files: src/obsolete.ts); rollback metadata is incomplete.", + ), + ) + + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // The delete succeeded on disk, so the queued handoff still runs, + // but no success result is emitted: the persistence failure + // surfaces through handleError instead. + expect(mockPushToolResult).not.toHaveBeenCalled() + expect(mockHandleError).toHaveBeenCalledWith("apply patch", expect.any(Error)) + expect(vi.mocked(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 diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 0c008e090f..ab278492ca 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -906,5 +906,22 @@ describe("editFileTool", () => { await executePromise expect(settled).toBe(true) }) + it("routes a journal persistence failure to tool failure handling without a success result", async () => { + // The checkpoint committed but its journal update failed: the edit + // must not report the write as fully persisted... + mockedCheckpointSave.mockRejectedValueOnce( + new Error( + "Checkpoint abc123def was committed but its change journal could not be persisted (files: test/file.txt); rollback metadata is incomplete.", + ), + ) + + await executeEditFileTool({}) + + expect(mockHandleError).toHaveBeenCalledWith("edit_file", expect.any(Error)) + expect(mockPushToolResult).not.toHaveBeenCalled() + // ...and the queued handoff still runs, because the file write + // itself succeeded (CR follow-up: hand off from finally). + expect(vi.mocked(mockTask.processQueuedMessages)).toHaveBeenCalled() + }) }) }) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 62efae6c7d..e2fa2bca14 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -117,7 +117,9 @@ describe("writeToFileTool", () => { const mockedStripLineNumbers = stripLineNumbers as MockedFunction const mockedPathResolve = path.resolve as MockedFunction - const mockCline: any = {} + const mockCline: any = { + processQueuedMessages: vi.fn(), + } let mockAskApproval: ReturnType> let mockHandleError: ReturnType> let mockPushToolResult: ReturnType> @@ -544,6 +546,9 @@ describe("writeToFileTool", () => { expect(surfaced).toContain("test/file.txt") // The normal successful tool result is not emitted. expect(mockPushToolResult).not.toHaveBeenCalled() + + // The write itself succeeded: the queued handoff still runs (finally). + expect(mockCline.processQueuedMessages).toHaveBeenCalled() }) it("waits for the per-write checkpoint before the tool completes", async () => { From 0947a4e1db55d16c2300347014018db55376cf0e Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 16 Oct 2026 18:37:47 +0800 Subject: [PATCH 35/48] fix(checkpoints): derive restoreFile git pathspec from the validated path The git pathspec was derived from the raw request with a blanket backslash-to-slash conversion, while containment validated the platform-native interpretation. On POSIX a backslash is a legal filename character, so a request like "link\secret.txt" validated as a literal workspace file while git received "link/secret.txt" and acted on a different path: with a symlinked directory at that position, the checkout replaced the symlink with a real directory and restored a file the caller never requested (CWE-22 family). Derive the pathspec from the validated native path (relative to the workspace, real separators converted for git) and delete through the validated path. Windows behavior is unchanged: native backslash paths still normalize to the POSIX form git requires. Regression test: POSIX-only scenario with a symlinked directory replacing a committed directory; asserts the symlink survives and the outside file is untouched (verified against real git on Linux via WSL). --- .../checkpoints/ShadowCheckpointService.ts | 23 ++++++---- .../__tests__/ShadowCheckpointService.spec.ts | 45 ++++++++++++++++++- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index 19508ad1c1..7ebf4bbf8d 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -423,14 +423,6 @@ export abstract class ShadowCheckpointService extends EventEmitter { throw new Error("Shadow git repo not initialized") } - // Git pathspecs are always POSIX: normalize a native (Windows - // backslash) path before the git calls. Without this, `cat-file -e` on - // a backslashed path never matches, the file is treated as absent at - // the checkpoint, and the delete branch below would remove a file the - // checkpoint actually contains. The local fs.rm join keeps the native - // form, since the OS treats both separators interchangeably there. - const gitPath = filePath.toPosix() - // Constrain the path to the workspace before either branch: `..` // segments would otherwise normalize (path.join / git pathspec) to a // location outside `this.workspaceDir`, and the delete branch could @@ -485,6 +477,17 @@ export abstract class ShadowCheckpointService extends EventEmitter { throw new Error(`restoreFile target resolves outside the workspace: ${filePath}`) } + // Git pathspecs are always POSIX, but the path handed to git must be + // the same path containment just validated. Deriving it from the raw + // request with a blanket backslash conversion would reinterpret + // POSIX filename backslashes as separators: `link\secret.txt` (a + // legal POSIX filename) would reach git as `link/secret.txt` and + // could write through a symlinked directory the validated path never + // traversed. Derive the pathspec from the validated native path and + // convert only its real separators; on Windows this is exactly the + // native-to-POSIX normalization the journal and webview hand back. + const gitPath = path.relative(this.workspaceDir, resolvedTarget).split(path.sep).join("/") + // restoreFile restores exactly one regular file: a directory pathspec // would recursively check out the whole subtree from the checkpoint, // so reject directory targets - including "." and "" resolving to the @@ -516,7 +519,9 @@ export abstract class ShadowCheckpointService extends EventEmitter { // single requested path. await this.git.checkout([commitHash, "--", `:(literal)${gitPath}`]) } else { - await fs.rm(path.join(this.workspaceDir, filePath), { force: true }) + // Use the validated path, not a re-join of the raw request: the + // delete branch must remove exactly what containment approved. + await fs.rm(resolvedTarget, { force: true }) } const duration = Date.now() - start diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index 42cc8ec0de..2d8d0532de 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -205,8 +205,9 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( it("accepts a native backslashed path on Windows (git pathspecs are POSIX)", async () => { if (process.platform !== "win32") { // Off-Windows the journal paths are already POSIX, so there is - // nothing to normalize; the toPosix() call is still exercised - // as a no-op by every other restoreFile test. + // nothing to normalize; the native-separator handling is still + // exercised by every other restoreFile test (and the POSIX-only + // backslash case has its own test below). return } @@ -230,6 +231,46 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( expect(await fs.readFile(subFile, "utf-8")).toBe("Ahoy, world!") }) + it("does not let a POSIX backslash path reach a symlinked directory through git", async () => { + // On POSIX a backslash is a legal filename character. Containment + // validates the literal `link\secret.txt` inside the workspace; a + // blanket backslash conversion for the git pathspec would hand git + // `link/secret.txt` - a path through a directory the validated + // path never traversed - and the checkout would act on it: git + // replaces the symlinked directory with a real one and restores a + // file the caller never requested (CWE-22 family: git acting on + // an unvalidated path; verified against real git on Linux). + if (process.platform === "win32") { + // Backslash is a separator on Windows; the scenario is POSIX-only. + return + } + const innerDir = path.join(service.workspaceDir, "link") + await fs.mkdir(innerDir) + await fs.writeFile(path.join(innerDir, "secret.txt"), "inside") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + const outsideDir = path.join(tmpDir, `outside-${Date.now()}`) + await fs.mkdir(outsideDir, { recursive: true }) + const outsideFile = path.join(outsideDir, "secret.txt") + await fs.writeFile(outsideFile, "outside") + + // Replace the committed directory with a symlink to the outside dir. + await fs.rm(innerDir, { recursive: true }) + await fs.symlink(outsideDir, innerDir, "dir") + + await service.restoreFile(commit1!.commit, "link\\secret.txt") + + // The literal name is not in the checkpoint, so the delete branch + // runs against the validated (literal) path: the symlink survives + // untouched (under the old pathspec it was replaced by a real + // directory holding a file nobody asked to restore) and the + // outside file behind it is untouched. + const linkStat = await fs.lstat(innerDir) + expect(linkStat.isSymbolicLink()).toBe(true) + expect(await fs.readFile(outsideFile, "utf-8")).toBe("outside") + }) + it("rejects a restore target that escapes the workspace", async () => { // `..` segments must be normalized and contained, so neither the // checkout nor the delete branch can touch a file outside the From f92312f8d11b890744cdd65c011b2108b5eedd5a Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 16 Oct 2026 18:58:14 +0800 Subject: [PATCH 36/48] fix(checkpoints): require a stable journal location for write-metadata saves checkpointSave read task.providerRef after the awaited Git commit and skipped appendChanges silently when the weak provider reference had disappeared in the meantime: the commit and its change card survived while changes.jsonl did not, with no CheckpointPartialPersistenceError, leaving rollback metadata incomplete with no error surfaced. Capture the journal location before the commit instead. A save that carries write metadata and has no location fails before committing, so the tool reports an explicit failure instead of an unrollable checkpoint; a save whose provider reference dies during the Git save still persists the journal through the captured location. A defensive post-commit check surfaces the partial state if the location is nonetheless gone after a successful commit. Regression tests: provider loss during the commit still persists the journal; provider absence before a write-metadata save fails before committing; non-write baseline saves keep the historical behavior. Negative controls: disabling the pre-commit guard -> exactly 1 red; deferring the deref to the old post-commit timing -> 2 reds, both asserting the new behavior. --- .../__tests__/checkpointJournal.test.ts | 33 ++++++- src/core/checkpoints/index.ts | 86 ++++++++++++------- 2 files changed, 88 insertions(+), 31 deletions(-) diff --git a/src/core/checkpoints/__tests__/checkpointJournal.test.ts b/src/core/checkpoints/__tests__/checkpointJournal.test.ts index 3e70241f9a..d213f0f38b 100644 --- a/src/core/checkpoints/__tests__/checkpointJournal.test.ts +++ b/src/core/checkpoints/__tests__/checkpointJournal.test.ts @@ -189,11 +189,42 @@ describe("checkpointSave change-journal wiring (B2)", () => { expect(mockTask.enableCheckpoints).toBe(true) }) - it("does not crash when the provider has no globalStorageDir", async () => { + it("fails a write-metadata save before committing when the provider has no globalStorageDir", async () => { + // A commit whose journal update would be silently skipped is worse + // than no commit: rollback metadata would be incomplete with no error + // surfaced, so the save must fail before the Git commit runs. mockTask.providerRef = { deref: () => undefined } + await expect(checkpointSave(mockTask as Task, false, true, write)).rejects.toThrow(/stable journal location/) + expect(saveCheckpointSpy).not.toHaveBeenCalled() + expect(mockTask.enableCheckpoints).toBe(true) + expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) + }) + + it("persists the journal through the captured location when the provider disappears during the commit", async () => { + // The weak provider reference dies while the Git save is awaited: + // the journal location captured before the commit must still be used, + // so a successful commit never silently loses its rollback metadata. + saveCheckpointSpy.mockImplementation(async () => { + mockTask.providerRef = { deref: () => undefined } + return { commit: COMMIT } + }) + await expect(checkpointSave(mockTask as Task, false, true, write)).resolves.toMatchObject({ commit: COMMIT }) + + const entries = await loadChanges(tmpStorageDir, TASK_ID) + expect(entries).toHaveLength(1) + expect(entries[0]).toMatchObject({ path: "src/foo.ts", checkpointId: COMMIT }) expect(mockTask.enableCheckpoints).toBe(true) + }) + + it("still runs non-write checkpoint saves when the provider is gone (task-start baseline)", async () => { + // The pre-commit journal-location guard applies only to saves that + // carry write metadata; the baseline save has none and keeps the + // historical behavior. + mockTask.providerRef = { deref: () => undefined } + + await expect(checkpointSave(mockTask as Task)).resolves.toMatchObject({ commit: COMMIT }) expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) }) diff --git a/src/core/checkpoints/index.ts b/src/core/checkpoints/index.ts index 51a6c037ea..9a426c41c6 100644 --- a/src/core/checkpoints/index.ts +++ b/src/core/checkpoints/index.ts @@ -266,6 +266,22 @@ export async function checkpointSave( return } + // A save that carries write metadata needs a stable journal location for + // the whole commit + journal transaction. Capture it before the commit: + // the weak provider reference can disappear while the Git save is + // awaited, and a commit whose journal update was then silently skipped + // leaves rollback metadata incomplete with no error surfaced. Without a + // location at all, fail before committing so the tool reports an + // explicit failure instead of a checkpoint nobody can roll back. + const journalWrites = write === undefined ? undefined : Array.isArray(write) ? write : [write] + const hasWriteMetadata = !!journalWrites && journalWrites.length > 0 + const journalDir = task.providerRef.deref()?.context.globalStorageUri.fsPath + if (hasWriteMetadata && !journalDir) { + throw new Error( + "Checkpoint save with write metadata requires a stable journal location, but the provider reference or global storage path is unavailable.", + ) + } + TelemetryService.instance.captureCheckpointCreated(task.taskId) // Start the checkpoint process in the background. @@ -286,36 +302,46 @@ export async function checkpointSave( // A no-op checkpoint carries an empty writes array: unrelated workspace // changes can still produce a commit, and emitting a card for it would // show a change card with zero files. The checkpoint itself still runs. - if (result?.commit && write && (!Array.isArray(write) || write.length > 0)) { - const writes = Array.isArray(write) ? write : [write] - const globalStorageDir = task.providerRef.deref()?.context.globalStorageUri.fsPath - if (globalStorageDir) { - // Persist the whole step's journal update as one serialized, - // atomic replacement (temp file + fsync + rename + directory - // sync), preserving write order. A journal failure is logged and - // propagated: the checkpoint must not be reported fully durable - // while its journal entries were lost. - try { - await appendChanges( - globalStorageDir, - task.taskId, - writes.map((w) => ({ - path: w.path, - operation: w.operation, - checkpointId: result.commit, - ...(w.diffStats ? { diffStats: w.diffStats } : {}), - })), - ) - } catch (err) { - console.error("[Task#checkpointSave] failed to persist change journal entries", err) - throw new CheckpointPartialPersistenceError( - `Checkpoint ${result.commit} was committed but its change journal could not be persisted (files: ${writes - .map((w) => w.path) - .join(", ")}); rollback metadata is incomplete.`, - result.commit, - writes, - ) - } + if (result?.commit && hasWriteMetadata) { + const writes = journalWrites ?? [] + if (!journalDir) { + // Defensive: the pre-commit guard above normally makes this + // unreachable. If the location is nonetheless gone while the commit + // succeeded, surface the partial state instead of skipping the + // journal silently. + throw new CheckpointPartialPersistenceError( + `Checkpoint ${result.commit} was committed but no stable journal location was available (files: ${writes + .map((w) => w.path) + .join(", ")}); rollback metadata is incomplete.`, + result.commit, + writes, + ) + } + // Persist the whole step's journal update as one serialized, + // atomic replacement (temp file + fsync + rename + directory + // sync), preserving write order. A journal failure is logged and + // propagated: the checkpoint must not be reported fully durable + // while its journal entries were lost. + try { + await appendChanges( + journalDir, + task.taskId, + writes.map((w) => ({ + path: w.path, + operation: w.operation, + checkpointId: result.commit, + ...(w.diffStats ? { diffStats: w.diffStats } : {}), + })), + ) + } catch (err) { + console.error("[Task#checkpointSave] failed to persist change journal entries", err) + throw new CheckpointPartialPersistenceError( + `Checkpoint ${result.commit} was committed but its change journal could not be persisted (files: ${writes + .map((w) => w.path) + .join(", ")}); rollback metadata is incomplete.`, + result.commit, + writes, + ) } // B3a: emit the per-step change card now that the checkpoint commit From 42dace543c9035fdb1845fdf3e6bbf3ec920c808 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 16 Oct 2026 21:19:19 +0800 Subject: [PATCH 37/48] fix(checkpoints): fail loudly on lost commits and keep card diffs out of cloud capture Two pre-merge review findings on the change-card persistence path: - A saveCheckpoint rejection was swallowed for every call: with write metadata the file write had already happened, so the tool reported a success whose rollback metadata did not exist. Write-metadata saves now propagate the failure (checkpoints still disabled); the legacy swallow is reserved for non-write calls such as the task-start baseline. - Full-detail change cards embed raw unified diffs, and task sync captured every non-partial message, uploading the content of secret-bearing files (.env, PII) to the cloud endpoint. change_card messages are now excluded from cloud task-message capture in both addToClineMessages and updateClineMessage; cards stay local to the webview. Regression tests: write-metadata rejection propagates with no journal entry and no change card; non-write failures keep the legacy swallow; change_card says never reach CloudService.captureEvent while ordinary says still sync. Negative controls: removing the write-metadata branch -> exactly 1 red; removing each capture-site guard -> exactly 1 red. --- .../__tests__/checkpointJournal.test.ts | 29 ++++++- src/core/checkpoints/index.ts | 36 ++++++-- src/core/task/Task.ts | 15 +++- src/core/task/__tests__/Task.spec.ts | 84 +++++++++++++++++++ 4 files changed, 151 insertions(+), 13 deletions(-) diff --git a/src/core/checkpoints/__tests__/checkpointJournal.test.ts b/src/core/checkpoints/__tests__/checkpointJournal.test.ts index d213f0f38b..a48d9eea69 100644 --- a/src/core/checkpoints/__tests__/checkpointJournal.test.ts +++ b/src/core/checkpoints/__tests__/checkpointJournal.test.ts @@ -172,14 +172,39 @@ describe("checkpointSave change-journal wiring (B2)", () => { expect(entries.map((entry) => entry.operation)).toEqual(["create", "update", "delete"]) }) - it("keeps the existing error-swallowing behavior and skips the journal on save failure", async () => { + it("keeps the existing error-swallowing behavior for non-write saves on failure", async () => { + // The legacy swallow (log + disable + resolve undefined) is reserved + // for checkpoint calls without write metadata, e.g. the task-start + // baseline: no file write has happened, so there is no orphaned write + // to report. saveCheckpointSpy.mockRejectedValueOnce(new Error("git exploded")) - await expect(checkpointSave(mockTask as Task, false, true, write)).resolves.toBeUndefined() + await expect(checkpointSave(mockTask as Task)).resolves.toBeUndefined() expect(mockTask.enableCheckpoints).toBe(false) expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) }) + it("propagates a saveCheckpoint rejection for a write-metadata save", async () => { + // The file write already happened: swallowing the commit failure + // would report a tool success whose rollback metadata does not exist. + saveCheckpointSpy.mockRejectedValueOnce(new Error("git exploded")) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined) + + await expect(checkpointSave(mockTask as Task, false, true, write)).rejects.toThrow(/no rollback metadata/) + + // Checkpoints are still disabled on failure, and neither the journal + // nor the change card is written for a commit that never happened. + expect(mockTask.enableCheckpoints).toBe(false) + expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) + const cardCalls = vi.mocked(mockTask.say).mock.calls.filter((call) => call[0] === "change_card") + expect(cardCalls).toHaveLength(0) + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("caught unexpected error"), + expect.anything(), + ) + consoleErrorSpy.mockRestore() + }) + it("does not write a journal entry when the checkpoint save is a no-op (empty commit)", async () => { saveCheckpointSpy.mockResolvedValueOnce(undefined) diff --git a/src/core/checkpoints/index.ts b/src/core/checkpoints/index.ts index 9a426c41c6..8bd83af672 100644 --- a/src/core/checkpoints/index.ts +++ b/src/core/checkpoints/index.ts @@ -284,17 +284,37 @@ export async function checkpointSave( TelemetryService.instance.captureCheckpointCreated(task.taskId) - // Start the checkpoint process in the background. - // Save failures keep the historical behavior: logged, checkpoints - // disabled, and the save resolves undefined. Journal/card work below runs - // only for a real commit and its failures propagate to the caller - // instead of flowing through this handler. - const result = await service - .saveCheckpoint(`Task: ${task.taskId}, Time: ${Date.now()}`, { allowEmpty: force, suppressMessage }) - .catch((err) => { + // Save failures keep the historical behavior for non-write calls: + // logged, checkpoints disabled, and the save resolves undefined. A + // write-metadata save must not swallow a failed commit: the file write + // already happened, so the caller must see an explicit failure instead + // of a success whose rollback metadata does not exist. Journal/card + // work below runs only for a real commit, and its failures propagate to + // the caller instead of flowing through this handler. + const savePromise = service.saveCheckpoint(`Task: ${task.taskId}, Time: ${Date.now()}`, { + allowEmpty: force, + suppressMessage, + }) + // `void` keeps the historical union member of the awaited legacy + // catch path (and of this function's return type) unchanged. + let result: Awaited | void + if (hasWriteMetadata) { + try { + result = await savePromise + } catch (err) { + console.error("[Task#checkpointSave] caught unexpected error, disabling checkpoints", err) + task.enableCheckpoints = false + throw new Error( + `Checkpoint save failed after a file write; the write has no rollback metadata: ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, + ) + } + } else { + result = await savePromise.catch((err) => { console.error("[Task#checkpointSave] caught unexpected error, disabling checkpoints", err) task.enableCheckpoints = false }) + } // B2: record successful file writes in the per-task change journal. // Only a real commit produces an entry (an empty or failed save // resolves to undefined / rejects), and non-write checkpoint calls diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index a0913c9b2d..161f41904b 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1684,7 +1684,12 @@ export class Task extends EventEmitter implements TaskLike { this.emit(RooCodeEventName.Message, { action: "created", message }) await this.saveClineMessages() - const shouldCaptureMessage = message.partial !== true && CloudService.isEnabled() + // Change cards embed raw unified diffs of approved writes (full + // detail mode): keep them local to the webview so task sync never + // uploads the content of secret-bearing files (.env, PII) to the + // cloud endpoint. + const shouldCaptureMessage = + message.partial !== true && CloudService.isEnabled() && message.say !== "change_card" if (shouldCaptureMessage) { CloudService.instance.captureEvent({ @@ -1734,8 +1739,12 @@ export class Task extends EventEmitter implements TaskLike { await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message }) this.emit(RooCodeEventName.Message, { action: "updated", message }) - // Check if we should sync to cloud and haven't already synced this message - const shouldCaptureMessage = message.partial !== true && CloudService.isEnabled() + // Check if we should sync to cloud and haven't already synced this + // message. Change cards stay out of cloud capture (full diffs embed + // file content; see addToClineMessages) - the guard is repeated here + // because a card message is never tracked as synced. + const shouldCaptureMessage = + message.partial !== true && CloudService.isEnabled() && message.say !== "change_card" const hasNotBeenSynced = !this.cloudSyncedMessageTimestamps.has(message.ts) if (shouldCaptureMessage && hasNotBeenSynced) { diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index a94d1fbac5..b5eb133a43 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -7514,6 +7514,90 @@ describe("Cline", () => { ) }) }) + + // Change cards embed raw unified diffs of approved writes (full detail + // mode): task sync must not upload their file content to the cloud. + describe("cloud capture of change cards", () => { + const flushMicrotasks = () => new Promise((resolve) => setImmediate(resolve)) + + it("keeps change_card messages out of cloud task-message capture", async () => { + const { CloudService } = await import("@roo-code/cloud") + const enabledSpy = vi.spyOn(CloudService, "isEnabled").mockReturnValue(true) + const captureSpy = vi.fn() + // Minimal double for the singleton getter: the capture path only + // calls captureEvent on the instance, and the real singleton is + // not initialized in this harness (the getter would throw). + const cloudDouble = { captureEvent: captureSpy } as unknown as import("@roo-code/cloud").CloudService + const instanceSpy = vi.spyOn(CloudService, "instance", "get").mockReturnValue(cloudDouble) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await flushMicrotasks() + captureSpy.mockClear() + + const cardPayload = JSON.stringify({ + checkpointId: "abc123", + files: [{ path: ".env", additions: 1, deletions: 0, diff: "+SECRET_KEY=hunter2" }], + }) + await task.say("change_card", cardPayload, undefined, undefined, undefined, undefined, { + isNonInteractive: true, + }) + await flushMicrotasks() + + const cardCaptures = captureSpy.mock.calls.filter( + (call) => + (call[0] as { properties?: { message?: { say?: string } } }).properties?.message?.say === + "change_card", + ) + expect(cardCaptures).toHaveLength(0) + + // Control: an ordinary non-partial say still syncs while task + // capture is enabled, so the exclusion is change_card-specific. + await task.say("text", "ordinary message") + await flushMicrotasks() + const textCaptures = captureSpy.mock.calls.filter( + (call) => + (call[0] as { properties?: { message?: { say?: string } } }).properties?.message?.say === "text", + ) + expect(textCaptures.length).toBeGreaterThan(0) + + enabledSpy.mockRestore() + instanceSpy.mockRestore() + }) + + it("keeps change_card messages out of the updateClineMessage capture path", async () => { + const { CloudService } = await import("@roo-code/cloud") + const enabledSpy = vi.spyOn(CloudService, "isEnabled").mockReturnValue(true) + const captureSpy = vi.fn() + const cloudDouble = { captureEvent: captureSpy } as unknown as import("@roo-code/cloud").CloudService + const instanceSpy = vi.spyOn(CloudService, "instance", "get").mockReturnValue(cloudDouble) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await flushMicrotasks() + captureSpy.mockClear() + + const cardMessage = { + ts: Date.now(), + type: "say" as const, + say: "change_card" as const, + text: JSON.stringify({ checkpointId: "abc", files: [{ path: ".env", diff: "+SECRET=x" }] }), + } + await task["updateClineMessage"](cardMessage) + expect(captureSpy).not.toHaveBeenCalled() + + enabledSpy.mockRestore() + instanceSpy.mockRestore() + }) + }) }) describe("Queued message processing after condense", () => { From e935afc67afe2fad7d2607169dde955a2f9dbd43 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 17 Oct 2026 07:09:32 +0800 Subject: [PATCH 38/48] fix(checkpoints): reject an unverified final journal record on read loadChanges discarded a parse failure on the final line as a torn tail without evidence. A corrupted (not merely torn) final record then made rollback report an incomplete history as valid: restore-latest no-oped over the lost write and returned success. Torn-tail repair is verified truncation owned by the append path (it re-terminates or truncates the tail before writing); a read now rejects instead, so rollbackFile, rollbackStep, and restoreLatestFile surface an explicit failure without restoring. Regression tests: a torn final line and an entirely torn journal reject at load; corrupt final records fail restore-latest, rollbackFile, and rollbackStep without a restore call. Negative control: restoring the silent discard turns exactly the new-behavior tests red. --- .../__tests__/changeJournal.spec.ts | 19 ++++++----- .../checkpoints/__tests__/rollback.spec.ts | 32 +++++++++++++++++++ src/core/checkpoints/changeJournal.ts | 12 +++++-- 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/src/core/checkpoints/__tests__/changeJournal.spec.ts b/src/core/checkpoints/__tests__/changeJournal.spec.ts index 79ab3c5386..e5ac58aed9 100644 --- a/src/core/checkpoints/__tests__/changeJournal.spec.ts +++ b/src/core/checkpoints/__tests__/changeJournal.spec.ts @@ -410,15 +410,16 @@ describe("changeJournal", () => { expect(result[0].checkpointId).toBe("ok") }) - it("discards a torn final line and returns the complete entries", async () => { + it("rejects a torn final line instead of silently dropping it", async () => { await appendChange(tmpRoot, taskId, entry({ checkpointId: "ok" })) - // Append a second line truncated mid-content, with no trailing newline. + // Append a second line truncated mid-content, with no trailing + // newline. Without verified truncation there is no evidence this + // is a crash tail rather than a corrupted record, and silently + // dropping it would report an incomplete history as valid. await fs.appendFile(journalPath(tmpRoot, taskId), '{"path":"src/half.ts","operation":"upd') - const result = await loadChanges(tmpRoot, taskId) - expect(result).toHaveLength(1) - expect(result[0].checkpointId).toBe("ok") + await expect(loadChanges(tmpRoot, taskId)).rejects.toThrow(/Corrupt change journal entry at line 2/) }) it("skips a corrupt middle line and still loads the later valid entries", async () => { @@ -437,13 +438,15 @@ describe("changeJournal", () => { expect(result[0].checkpointId).toBe("after") }) - it("does not throw when the entire journal is torn", async () => { + it("rejects a journal whose only record is torn", async () => { await appendChange(tmpRoot, taskId, entry({ checkpointId: "first" })) - // Truncate to a single character — definitely invalid JSON. + // Truncate to a single character — definitely invalid JSON. The + // append path repairs such a tail on the next write; a read must + // not report the lost record as a complete (empty) history. await fs.writeFile(journalPath(tmpRoot, taskId), "{") - expect(await loadChanges(tmpRoot, taskId)).toEqual([]) + await expect(loadChanges(tmpRoot, taskId)).rejects.toThrow(/Corrupt change journal entry at line 1/) }) it("includes diffStats when present", async () => { diff --git a/src/core/checkpoints/__tests__/rollback.spec.ts b/src/core/checkpoints/__tests__/rollback.spec.ts index 0af6384113..acfc6c3ec9 100644 --- a/src/core/checkpoints/__tests__/rollback.spec.ts +++ b/src/core/checkpoints/__tests__/rollback.spec.ts @@ -429,6 +429,38 @@ describe("restoreLatestFile (B3c: forward direction)", () => { expect(service.restoreFile).not.toHaveBeenCalled() }) + it("fails restore-latest on a corrupt final journal record without restoring", async () => { + // A committed write whose final journal record was truncated or + // corrupted must surface as an explicit failure: reporting a + // successful no-op would hide that rollback metadata is incomplete. + await seedJournal([{ path: "src/a.ts", operation: "update", checkpointId: "sha-1" }]) + await fs.appendFile(journalPath(globalStorageDir, "task-rollback"), '{"path":"src/a.ts","ope') + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await restoreLatestFile(makeTask(), "src/a.ts") + + expect(outcome.success).toBe(false) + expect(outcome.error).toContain("Corrupt change journal entry at line 2") + expect(service.restoreFile).not.toHaveBeenCalled() + }) + + it("fails rollbackFile and rollbackStep on a corrupt final journal record without restoring", async () => { + await seedJournal([{ path: "src/a.ts", operation: "update", checkpointId: "sha-1" }]) + await fs.appendFile(journalPath(globalStorageDir, "task-rollback"), '{"path":"src/a.ts","ope') + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const single = await rollbackFile(makeTask(), "sha-1", "src/a.ts") + expect(single.success).toBe(false) + expect(single.error).toContain("Corrupt change journal entry at line 2") + + const step = await rollbackStep(makeTask(), ["src/a.ts"], "sha-1") + expect(step.files[0].success).toBe(false) + expect(step.files[0].error).toContain("Corrupt change journal entry at line 2") + expect(service.restoreFile).not.toHaveBeenCalled() + }) + it("fails cleanly when checkpoints are not enabled", async () => { mockedGetCheckpointService.mockResolvedValue(undefined) diff --git a/src/core/checkpoints/changeJournal.ts b/src/core/checkpoints/changeJournal.ts index 34b0166337..7b8ec3e42a 100644 --- a/src/core/checkpoints/changeJournal.ts +++ b/src/core/checkpoints/changeJournal.ts @@ -268,9 +268,15 @@ export async function loadChanges(globalStorageDir: string, taskId: string): Pro } } catch { // A corrupt line before the final line (e.g. a partially flushed - // append) must not hide the valid entries after it. The final line - // is still discarded as a torn tail — `continue` at the last index - // ends the loop either way. + // append) must not hide the valid entries after it. The final + // line is different: dropping an unverified record there can + // report an incomplete rollback history as valid (restore-latest + // would no-op over the lost write), and torn-tail repair is + // verified truncation owned by the append path, so a read + // rejects instead. + if (i === lines.length - 1) { + throw new Error(`Corrupt change journal entry at line ${i + 1} of ${filePath}`) + } continue } } From c872d4db19a835b9399926d810cb499c7f422ce2 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 17 Oct 2026 12:27:09 +0800 Subject: [PATCH 39/48] fix(checkpoints): re-validate restore containment before mutation; fail write saves lacking rollback metadata Row-9 review claims: - Security Boundaries: restoreFile validated real-path containment, then awaited Git lookups before rm/checkout, so a concurrent ancestor -> symlink swap (TOCTOU) could redirect the delete or the checkout outside the workspace. Containment now runs through assertRestoreContainment and is re-checked immediately before each mutation: re-validation after the awaits and before the mutation closes the window the Git lookups open (the residual microsecond race is inherent to path-based mutation and documented at the call sites). - Persistence Integrity: a write-metadata save now fails explicitly when the checkpoint service is unavailable or resolves without a commit (e.g. Git-ignored writes), instead of reporting tool success over a write with no journal entry and no change card. Non-write saves keep the historical no-op behavior. - Persistence Integrity (append): appendChanges no longer silently truncates an unparseable unterminated final record - it may be the truncated journal record of a committed write, and dropping it would let a later rollback resolve the wrong checkpoint. Verified re-termination of complete records is kept; unverified tails reject and surface through the existing CheckpointPartialPersistenceError path. Tests: 2 TOCTOU race tests (junction swap injected mid-lookup), 4 checkpointSave persistence tests, 2 changeJournal append tests retargeted plus a clean-tail orphan variant, 2 card-emission tests retargeted. Negative controls: removing either guard or either save check turns exactly one test red; reverting the append rejection turns exactly the two new-behavior tests red. --- .../__tests__/changeJournal.spec.ts | 40 ++++--- .../__tests__/checkpointJournal.test.ts | 37 ++++++- .../__tests__/checkpointSave.spec.ts | 12 ++- src/core/checkpoints/changeJournal.ts | 24 +++-- src/core/checkpoints/index.ts | 24 ++++- .../checkpoints/ShadowCheckpointService.ts | 100 +++++++++++------- .../__tests__/ShadowCheckpointService.spec.ts | 83 +++++++++++++++ 7 files changed, 252 insertions(+), 68 deletions(-) diff --git a/src/core/checkpoints/__tests__/changeJournal.spec.ts b/src/core/checkpoints/__tests__/changeJournal.spec.ts index e5ac58aed9..3d84823afe 100644 --- a/src/core/checkpoints/__tests__/changeJournal.spec.ts +++ b/src/core/checkpoints/__tests__/changeJournal.spec.ts @@ -49,24 +49,25 @@ describe("changeJournal", () => { expect(raw.split("\n").filter((line) => line !== "")).toHaveLength(2) }) - it("repairs a torn tail before appending so both entries load", async () => { + it("rejects an append when the final record is torn instead of truncating it", async () => { await appendChange(tmpRoot, taskId, entry({ checkpointId: "good" })) // Simulate a crash that tore the last append mid-write: the file now - // ends with an unterminated JSON fragment. + // ends with an unterminated JSON fragment. That fragment may be the + // truncated journal record of a committed write, so the append must + // not silently truncate it away and continue as if the history + // were complete. const filePath = journalPath(tmpRoot, taskId) const raw = await fs.readFile(filePath, "utf8") await fs.writeFile(filePath, raw + '{"path":"src/half.ts","operation":"upd') - await appendChange(tmpRoot, taskId, entry({ checkpointId: "after" })) - - const result = await loadChanges(tmpRoot, taskId) - expect(result.map((e) => e.checkpointId)).toEqual(["good", "after"]) + await expect(appendChange(tmpRoot, taskId, entry({ checkpointId: "after" }))).rejects.toThrow( + /torn-tail recovery requires explicit repair/, + ) - // The lost fragment is truncated away, not preserved (a later - // strict reader must never see it. - const repaired = await fs.readFile(filePath, "utf8") - expect(repaired).not.toContain("half.ts") - expect(repaired).not.toContain("half.ts") + // The journal is left untouched for an explicit recovery: neither + // the fragment was dropped nor the new entry added. + const after = await fs.readFile(filePath, "utf8") + expect(after).toBe(raw + '{"path":"src/half.ts","operation":"upd') }) it("does not insert a blank line when the journal ends cleanly", async () => { @@ -324,15 +325,28 @@ describe("changeJournal", () => { expect(leftovers).toEqual([]) }) - it("recovers after a crash left a stale temp file and a torn tail", async () => { + it("rejects the append when a crash left a stale temp file and a torn tail", async () => { await appendChange(tmpRoot, taskId, entry({ checkpointId: "before" })) const dir = path.dirname(journalPath(tmpRoot, taskId)) // A crash between the temp write and the rename leaves an orphan temp - // file; a crash mid-write can also leave the tail unterminated. + // file; a crash mid-write can also leave the tail unterminated. The + // torn tail must reject the append - verified recovery is explicit. await fs.writeFile(path.join(dir, "changes.jsonl.4242.orphan.tmp"), "partial") const raw = await fs.readFile(journalPath(tmpRoot, taskId), "utf8") await fs.writeFile(journalPath(tmpRoot, taskId), raw + '{"path":"src/half.ts","operation":"upd') + await expect(appendChange(tmpRoot, taskId, entry({ checkpointId: "after" }))).rejects.toThrow( + /torn-tail recovery requires explicit repair/, + ) + }) + + it("recovers after a crash left a stale temp file with a clean tail", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "before" })) + const dir = path.dirname(journalPath(tmpRoot, taskId)) + // An orphan temp file alone is harmless: the rename never happened, + // and the journal tail is complete, so the append proceeds. + await fs.writeFile(path.join(dir, "changes.jsonl.4242.orphan.tmp"), "partial") + await appendChange(tmpRoot, taskId, entry({ checkpointId: "after" })) const result = await loadChanges(tmpRoot, taskId) diff --git a/src/core/checkpoints/__tests__/checkpointJournal.test.ts b/src/core/checkpoints/__tests__/checkpointJournal.test.ts index a48d9eea69..feb26fa3d3 100644 --- a/src/core/checkpoints/__tests__/checkpointJournal.test.ts +++ b/src/core/checkpoints/__tests__/checkpointJournal.test.ts @@ -205,10 +205,43 @@ describe("checkpointSave change-journal wiring (B2)", () => { consoleErrorSpy.mockRestore() }) - it("does not write a journal entry when the checkpoint save is a no-op (empty commit)", async () => { + it("fails a write-metadata save when the checkpoint service is unavailable", async () => { + // A checkpoint initialization failure (missing Git or global storage) + // leaves the service unavailable; a write-metadata save must surface + // an explicit failure instead of a tool success over an orphaned + // write with no journal entry and no change card. + mockTask.enableCheckpoints = false + + await expect(checkpointSave(mockTask as Task, false, true, write)).rejects.toThrow(/checkpoint service/) + expect(saveCheckpointSpy).not.toHaveBeenCalled() + expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) + }) + + it("keeps non-write saves a no-op when the checkpoint service is unavailable", async () => { + // The task-start baseline tolerates an unavailable service: no write + // metadata, no rollback expectation, historical behavior preserved. + mockTask.enableCheckpoints = false + + await expect(checkpointSave(mockTask as Task)).resolves.toBeUndefined() + expect(saveCheckpointSpy).not.toHaveBeenCalled() + }) + + it("keeps non-write saves succeeding when the checkpoint produces no commit", async () => { + // The task-start baseline tolerates an empty shadow repository: no + // write metadata means no rollback expectation. saveCheckpointSpy.mockResolvedValueOnce(undefined) - await checkpointSave(mockTask as Task, false, true, write) + await expect(checkpointSave(mockTask as Task)).resolves.toBeUndefined() + }) + + it("fails a write-metadata save on an empty checkpoint without disabling checkpoints", async () => { + // A no-op checkpoint resolves without a commit: with write metadata + // there is no rollback point, so the save fails explicitly - but an + // empty checkpoint is not a service failure, so checkpoints stay + // enabled for later writes. + saveCheckpointSpy.mockResolvedValueOnce(undefined) + + await expect(checkpointSave(mockTask as Task, false, true, write)).rejects.toThrow(/did not produce a commit/) expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) expect(mockTask.enableCheckpoints).toBe(true) diff --git a/src/core/checkpoints/__tests__/checkpointSave.spec.ts b/src/core/checkpoints/__tests__/checkpointSave.spec.ts index 291bc4cdea..dabcd6e007 100644 --- a/src/core/checkpoints/__tests__/checkpointSave.spec.ts +++ b/src/core/checkpoints/__tests__/checkpointSave.spec.ts @@ -184,10 +184,12 @@ describe("checkpointSave change-card emission (B3a)", () => { expect(cardCalls).toHaveLength(0) }) - it("emits no change card when the checkpoint produced no commit", async () => { + it("fails a write-metadata save when the checkpoint produced no commit and emits no card", async () => { const { task, say } = makeTask({ saveCheckpoint: undefined }) - await checkpointSave(task, false, true, { path: "src/a.ts", operation: "create" }) + await expect(checkpointSave(task, false, true, { path: "src/a.ts", operation: "create" })).rejects.toThrow( + /did not produce a commit/, + ) const cardCalls = say.mock.calls.filter(([type]) => type === "change_card") expect(cardCalls).toHaveLength(0) @@ -206,10 +208,12 @@ describe("checkpointSave change-card emission (B3a)", () => { expect(cardCalls).toHaveLength(0) }) - it("emits no change card when checkpoints are disabled for the task", async () => { + it("fails a write-metadata save when checkpoints are disabled for the task and emits no card", async () => { const { task, say } = makeTask({ enableCheckpoints: false }) - await checkpointSave(task, false, true, { path: "src/a.ts", operation: "create", diff: "+x" }) + await expect( + checkpointSave(task, false, true, { path: "src/a.ts", operation: "create", diff: "+x" }), + ).rejects.toThrow(/could not initialize the checkpoint service/) expect(say).not.toHaveBeenCalled() }) diff --git a/src/core/checkpoints/changeJournal.ts b/src/core/checkpoints/changeJournal.ts index 7b8ec3e42a..fe870fb736 100644 --- a/src/core/checkpoints/changeJournal.ts +++ b/src/core/checkpoints/changeJournal.ts @@ -122,12 +122,17 @@ export async function appendChanges( throw error } } - // Torn-tail repair: a crash can leave the final line unterminated. If - // that final line is a complete, entry-shaped JSON record it was fully - // written and only its newline was lost - keep it and re-terminate. - // Otherwise it is a fragment of a lost write and the replacement - // truncates it, so the journal only ever holds complete records and a - // later entry can never mask the lost one. + // Torn-tail handling: a crash can leave the final line unterminated. + // If that final line is a complete, entry-shaped JSON record it was + // fully written and only its newline was lost - verified recovery: + // keep it and re-terminate. An unparseable or non-entry-shaped tail + // is NOT verified recovery material: it may be the truncated journal + // record of a committed write, and silently truncating it would let + // this append succeed while the rollback history is already + // incomplete (a later rollback could then resolve the file to the + // wrong checkpoint or lose the lost write's metadata). Reject the + // append instead; torn-tail repair is a separately verified recovery + // operation, not a side effect of the next write. if (content && !content.endsWith("\n")) { const cut = content.lastIndexOf("\n") + 1 let tail: unknown @@ -136,7 +141,12 @@ export async function appendChanges( } catch { tail = undefined } - content = isEntryShaped(tail) ? content + "\n" : content.slice(0, cut) + if (!isEntryShaped(tail)) { + throw new Error( + `Cannot append to change journal ${filePath}: unterminated final record is not a complete entry (torn-tail recovery requires explicit repair)`, + ) + } + content = content + "\n" } const payload = entries.map((entry) => JSON.stringify(entry) + "\n").join("") const tmpPath = path.join(dir, `${JOURNAL_FILENAME}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`) diff --git a/src/core/checkpoints/index.ts b/src/core/checkpoints/index.ts index 8bd83af672..ae850542e7 100644 --- a/src/core/checkpoints/index.ts +++ b/src/core/checkpoints/index.ts @@ -260,9 +260,21 @@ export async function checkpointSave( suppressMessage = false, write?: CheckpointWriteInfo | CheckpointWriteInfo[], ) { + // A save that carries write metadata must end with rollback metadata + // existing: every failure below (service unavailable, no commit, no + // journal location, journal write failure) surfaces explicitly instead + // of resolving to a tool success over an orphaned write. + const journalWrites = write === undefined ? undefined : Array.isArray(write) ? write : [write] + const hasWriteMetadata = !!journalWrites && journalWrites.length > 0 + const service = await getCheckpointService(task) if (!service) { + if (hasWriteMetadata) { + throw new Error( + "Checkpoint save with write metadata could not initialize the checkpoint service; the write has no rollback metadata.", + ) + } return } @@ -273,8 +285,6 @@ export async function checkpointSave( // leaves rollback metadata incomplete with no error surfaced. Without a // location at all, fail before committing so the tool reports an // explicit failure instead of a checkpoint nobody can roll back. - const journalWrites = write === undefined ? undefined : Array.isArray(write) ? write : [write] - const hasWriteMetadata = !!journalWrites && journalWrites.length > 0 const journalDir = task.providerRef.deref()?.context.globalStorageUri.fsPath if (hasWriteMetadata && !journalDir) { throw new Error( @@ -315,6 +325,16 @@ export async function checkpointSave( task.enableCheckpoints = false }) } + + // A save that resolves without a commit (e.g. a write to a Git-ignored + // file the shadow repository never stages) produced no rollback point: + // journaling is skipped and a later restore reports no entry. With + // write metadata that is a partial persistence, not a success. + if (hasWriteMetadata && !result?.commit) { + throw new Error( + "Checkpoint save with write metadata did not produce a commit; the write has no rollback metadata.", + ) + } // B2: record successful file writes in the per-task change journal. // Only a real commit produces an entry (an empty or failed save // resolves to undefined / rejects), and non-write checkpoint calls diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index 7ebf4bbf8d..f389dc4269 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -415,6 +415,53 @@ export abstract class ShadowCheckpointService extends EventEmitter { * If the file did not exist at `commitHash`, it is removed from the * working tree instead — rolling a file back to before it was created. */ + // Real-path containment for restoreFile targets. The lexical prefix check + // cannot see through a symlinked ancestor: a link inside the workspace + // pointing outside it passes the prefix check while the real target + // resolves elsewhere. Validate containment on real paths for every + // target, not only existing ones: the checkout branch writes through a + // symlinked ancestor even when the target itself does not exist. Walk up + // to the deepest ancestor that has a directory entry (lstat, so a + // dangling symlink counts as present and is then rejected by realpath + // instead of being skipped by an exists()-style check, which would + // validate the root and let the checkout report a false success). + // Resolving both sides keeps a legitimate symlinked workspace root + // working. Called before the Git lookups AND again immediately before + // each filesystem mutation: a concurrent process can replace an existing + // ancestor with a symlink while the Git calls are awaited (TOCTOU), + // redirecting the delete or the checkout outside the workspace. + private async assertRestoreContainment(resolvedTarget: string, filePath: string): Promise { + const realWorkspaceRoot = await fs.realpath(this.workspaceDir) + const realRoot = realWorkspaceRoot.endsWith(path.sep) ? realWorkspaceRoot : realWorkspaceRoot + path.sep + let realCandidate = resolvedTarget + while (true) { + try { + await fs.lstat(realCandidate) + break + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + if (code !== "ENOENT" && code !== "ENOTDIR") throw err + } + const parent = path.dirname(realCandidate) + if (parent === realCandidate) { + throw new Error(`restoreFile target resolves outside the workspace: ${filePath}`) + } + realCandidate = parent + } + let realChecked: string + try { + realChecked = await fs.realpath(realCandidate) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(`restoreFile target resolves through a dangling link: ${filePath}`) + } + throw err + } + if (realChecked !== realWorkspaceRoot && !realChecked.startsWith(realRoot)) { + throw new Error(`restoreFile target resolves outside the workspace: ${filePath}`) + } + } + public async restoreFile(commitHash: string, filePath: string): Promise { try { this.log(`[${this.constructor.name}#restoreFile] restoring ${filePath} from ${commitHash}`) @@ -436,46 +483,10 @@ export abstract class ShadowCheckpointService extends EventEmitter { throw new Error(`restoreFile target is outside the workspace: ${filePath}`) } - // The lexical check cannot see through a symlinked ancestor: a link - // inside the workspace pointing outside it passes the prefix check - // while the real target resolves elsewhere. Validate containment on - // real paths for every target, not only existing ones: the checkout - // branch writes through a symlinked ancestor even when the target - // itself does not exist. Walk up to the deepest ancestor that has a - // directory entry (lstat, so a dangling symlink counts as present and - // is then rejected by realpath instead of being skipped by an - // exists()-style check, which would validate the root and let the - // checkout report a false success). Resolving both sides keeps a - // legitimate symlinked workspace root working. - const realWorkspaceRoot = await fs.realpath(this.workspaceDir) - const realRoot = realWorkspaceRoot.endsWith(path.sep) ? realWorkspaceRoot : realWorkspaceRoot + path.sep - let realCandidate = resolvedTarget - while (true) { - try { - await fs.lstat(realCandidate) - break - } catch (err) { - const code = (err as NodeJS.ErrnoException).code - if (code !== "ENOENT" && code !== "ENOTDIR") throw err - } - const parent = path.dirname(realCandidate) - if (parent === realCandidate) { - throw new Error(`restoreFile target resolves outside the workspace: ${filePath}`) - } - realCandidate = parent - } - let realChecked: string - try { - realChecked = await fs.realpath(realCandidate) - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - throw new Error(`restoreFile target resolves through a dangling link: ${filePath}`) - } - throw err - } - if (realChecked !== realWorkspaceRoot && !realChecked.startsWith(realRoot)) { - throw new Error(`restoreFile target resolves outside the workspace: ${filePath}`) - } + // Real-path containment (symlinked ancestors, dangling links); see + // assertRestoreContainment. Re-checked below, immediately before + // each filesystem mutation. + await this.assertRestoreContainment(resolvedTarget, filePath) // Git pathspecs are always POSIX, but the path handed to git must be // the same path containment just validated. Deriving it from the raw @@ -513,12 +524,21 @@ export abstract class ShadowCheckpointService extends EventEmitter { if (mode !== "100644" && mode !== "100755") { throw new Error(`restoreFile target is not a regular file in the checkpoint: ${filePath}`) } + // TOCTOU guard: a concurrent process can replace an ancestor with + // a symlink while the Git lookups above are awaited, and the + // checkout would then write through the swapped link. Re-check + // containment immediately before the mutation. + await this.assertRestoreContainment(resolvedTarget, filePath) // The checkout pathspec must be literal too: a filename containing // glob characters ([a].ts, foo?bar.ts) would otherwise match - or // miss - other files, restoring more (or the wrong file) than the // single requested path. await this.git.checkout([commitHash, "--", `:(literal)${gitPath}`]) } else { + // TOCTOU guard: same race as above - without this re-check the + // delete could remove a file the swapped link now points at + // outside the workspace. + await this.assertRestoreContainment(resolvedTarget, filePath) // Use the validated path, not a re-join of the raw request: the // delete branch must remove exactly what containment approved. await fs.rm(resolvedTarget, { force: true }) diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index 2d8d0532de..480d9fb24e 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -431,6 +431,89 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( expect(await fileExistsAtPath(path.join(innerDir, "newfile.txt"))).toBe(false) }) + it("re-validates containment before the delete when an ancestor link is swapped mid-lookup", async () => { + // TOCTOU: containment passes while `sub` is a real directory, but + // a concurrent process replaces it with a link into an outside + // dir while the Git existence lookup is awaited. Without a + // re-check immediately before the mutation, fs.rm would delete + // the outside file the swapped link now points at. + await fs.writeFile(testFile, "Ahoy, world!") + const innerDir = path.join(service.workspaceDir, "sub") + await fs.mkdir(innerDir) + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + // Untracked after the commit: the delete branch applies. + await fs.writeFile(path.join(innerDir, "victim.txt"), "v1") + + const outsideDir = path.join(tmpDir, `outside-${Date.now()}`) + await fs.mkdir(outsideDir, { recursive: true }) + const outsideFile = path.join(outsideDir, "victim.txt") + await fs.writeFile(outsideFile, "outside") + + // Structural view of the private lookup so the race can be injected + // mid-call without a double assertion (AGENTS.md). + const proto = Object.getPrototypeOf(service) as { + fileExistsInCommit: (...args: unknown[]) => Promise + } + const original = proto.fileExistsInCommit + const swapSpy = vi.spyOn(proto, "fileExistsInCommit").mockImplementation(async function ( + this: unknown, + ...args: unknown[] + ) { + // The concurrent ancestor replacement lands mid-lookup. + await fs.rm(innerDir, { recursive: true, force: true }) + await fs.symlink(outsideDir, innerDir, "junction") + return original.apply(this, args) + }) + + await expect(service.restoreFile(commit1!.commit, path.join("sub", "victim.txt"))).rejects.toThrow( + /resolves outside the workspace/, + ) + swapSpy.mockRestore() + + // The outside file survives: the delete never followed the link. + expect(await fileExistsAtPath(outsideFile)).toBe(true) + expect(await fs.readFile(outsideFile, "utf-8")).toBe("outside") + }) + + it("re-validates containment before the checkout when an ancestor link is swapped mid-lookup", async () => { + // Same race on the checkout branch: the swap lands while the + // lookups are awaited, so a checkout without a pre-mutation + // re-check would write the committed file through the link into + // the outside dir. + const innerDir = path.join(service.workspaceDir, "sub") + await fs.mkdir(innerDir) + await fs.writeFile(path.join(innerDir, "keep.txt"), "v1") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + const outsideDir = path.join(tmpDir, `outside-${Date.now()}`) + await fs.mkdir(outsideDir, { recursive: true }) + + // Structural view of the private lookup so the race can be injected + // mid-call without a double assertion (AGENTS.md). + const proto = Object.getPrototypeOf(service) as { + fileExistsInCommit: (...args: unknown[]) => Promise + } + const original = proto.fileExistsInCommit + const swapSpy = vi.spyOn(proto, "fileExistsInCommit").mockImplementation(async function ( + this: unknown, + ...args: unknown[] + ) { + await fs.rm(innerDir, { recursive: true, force: true }) + await fs.symlink(outsideDir, innerDir, "junction") + return original.apply(this, args) + }) + + await expect(service.restoreFile(commit1!.commit, path.join("sub", "keep.txt"))).rejects.toThrow( + /resolves outside the workspace/, + ) + swapSpy.mockRestore() + + // The checkout never wrote through the swapped link. + expect(await fileExistsAtPath(path.join(outsideDir, "keep.txt"))).toBe(false) + }) + it("rejects restoring the workspace root as a single file", async () => { // "." passes the lexical and realpath guards (it IS the workspace) // but as a pathspec it would recursively restore the entire tree From 635f41552d755b11d21c59d3e0133f725b1b1043 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 17 Oct 2026 13:51:48 +0800 Subject: [PATCH 40/48] fix(checkpoints): reject a wrong-shaped final journal record on read A final line of valid JSON that is not entry-shaped (a bare null, a bare number, a differently-shaped object) was silently skipped by loadChanges, reporting an incomplete rollback history as valid: restore-latest could then pick an older entry for the same file and restore the wrong checkpoint. The final line now rejects like the unparseable case; middle lines keep the historical skip. Addresses the CodeRabbit review threads on the changeJournal read path. --- .../__tests__/changeJournal.spec.ts | 31 +++++++++++++++++++ src/core/checkpoints/changeJournal.ts | 24 +++++++++----- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/core/checkpoints/__tests__/changeJournal.spec.ts b/src/core/checkpoints/__tests__/changeJournal.spec.ts index 3d84823afe..e3e8658447 100644 --- a/src/core/checkpoints/__tests__/changeJournal.spec.ts +++ b/src/core/checkpoints/__tests__/changeJournal.spec.ts @@ -451,6 +451,37 @@ describe("changeJournal", () => { expect(result).toHaveLength(1) expect(result[0].checkpointId).toBe("after") }) + it("rejects a final line that parses but is not entry-shaped", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "good" })) + // A final line of valid JSON that is not an entry (a bare null, a + // bare number, a differently-shaped object) is the same + // unverified-tail hazard as an unparseable one: silently dropping + // it reports an incomplete rollback history as valid and lets + // restore-latest pick an older entry for the same file. + const filePath = journalPath(tmpRoot, taskId) + const raw = await fs.readFile(filePath, "utf8") + await fs.writeFile(filePath, raw + "null\n") + + await expect(loadChanges(tmpRoot, taskId)).rejects.toThrow(/Malformed change journal entry at line 2/) + }) + + it("skips a middle line that parses but is not entry-shaped", async () => { + // Lineage: a wrong-shaped line before the final line is skipped so + // later valid entries stay readable; only the final line rejects. + const filePath = journalPath(tmpRoot, taskId) + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile( + filePath, + JSON.stringify(entry({ checkpointId: "a" })) + + "\n" + + "null\n" + + JSON.stringify(entry({ checkpointId: "b" })) + + "\n", + ) + + const result = await loadChanges(tmpRoot, taskId) + expect(result.map((e) => e.checkpointId)).toEqual(["a", "b"]) + }) it("rejects a journal whose only record is torn", async () => { await appendChange(tmpRoot, taskId, entry({ checkpointId: "first" })) diff --git a/src/core/checkpoints/changeJournal.ts b/src/core/checkpoints/changeJournal.ts index fe870fb736..338d379223 100644 --- a/src/core/checkpoints/changeJournal.ts +++ b/src/core/checkpoints/changeJournal.ts @@ -267,15 +267,9 @@ export async function loadChanges(globalStorageDir: string, taskId: string): Pro const entries: ChangeJournalEntry[] = [] for (let i = 0; i < lines.length; i++) { + let parsed: unknown try { - const parsed: unknown = JSON.parse(lines[i]) - // A torn append or manual edit can leave valid JSON that is not an - // entry (a bare number, null, or a differently-shaped object). - // Consumers read path/checkpointId unguarded, so skip any line that - // is not entry-shaped instead of returning it as an entry. - if (isEntryShaped(parsed)) { - entries.push(parsed) - } + parsed = JSON.parse(lines[i]) } catch { // A corrupt line before the final line (e.g. a partially flushed // append) must not hide the valid entries after it. The final @@ -289,6 +283,20 @@ export async function loadChanges(globalStorageDir: string, taskId: string): Pro } continue } + // A torn append or manual edit can leave valid JSON that is not an + // entry (a bare number, null, or a differently-shaped object). + // Consumers read path/checkpointId unguarded, so skip any line that + // is not entry-shaped instead of returning it as an entry. A final + // line that parses but is not entry-shaped is the same + // unverified-tail hazard as an unparseable one: silently dropping it + // reports an incomplete rollback history as valid and lets + // restore-latest pick an older entry for the same file, so it + // rejects like the corrupt case; middle lines keep the skip. + if (isEntryShaped(parsed)) { + entries.push(parsed) + } else if (i === lines.length - 1) { + throw new Error(`Malformed change journal entry at line ${i + 1} of ${filePath}`) + } } return entries From 305ef64f739f8c898350acb80513ddec2fa339c4 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 17 Oct 2026 17:21:26 +0800 Subject: [PATCH 41/48] chore: retrigger CI (e2e-mock flake window retest) From 70afcfaef4acd0ab37e517dcf70f8d71337f2089 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 17 Oct 2026 18:43:55 +0800 Subject: [PATCH 42/48] fix(checkpoints): user-disabled checkpoint saves resolve silently for write-metadata saves Write-metadata saves now throw only when the task still expects checkpoints (enableCheckpoints true at save start = genuine init-failure). When the user disabled checkpoints, the absence of rollback metadata is the user's choice and the save resolves silently, matching pre-B3 tool behavior. Documented in the PR description. --- .../__tests__/checkpointJournal.test.ts | 12 +++--- .../__tests__/checkpointSave.spec.ts | 41 +++++++++++++++---- src/core/checkpoints/index.ts | 15 +++++-- 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/src/core/checkpoints/__tests__/checkpointJournal.test.ts b/src/core/checkpoints/__tests__/checkpointJournal.test.ts index feb26fa3d3..8838edc7ca 100644 --- a/src/core/checkpoints/__tests__/checkpointJournal.test.ts +++ b/src/core/checkpoints/__tests__/checkpointJournal.test.ts @@ -205,14 +205,14 @@ describe("checkpointSave change-journal wiring (B2)", () => { consoleErrorSpy.mockRestore() }) - it("fails a write-metadata save when the checkpoint service is unavailable", async () => { - // A checkpoint initialization failure (missing Git or global storage) - // leaves the service unavailable; a write-metadata save must surface - // an explicit failure instead of a tool success over an orphaned - // write with no journal entry and no change card. + it("resolves a write-metadata save when the user disabled checkpoints", async () => { + // Documented contract: user-disabled checkpoints means the absence of + // rollback metadata is the user's choice, so the save must not fail the + // write tool (no journal entry, no change card, no throw). Genuine + // init-failure coverage lives in checkpointSave.spec.ts. mockTask.enableCheckpoints = false - await expect(checkpointSave(mockTask as Task, false, true, write)).rejects.toThrow(/checkpoint service/) + await expect(checkpointSave(mockTask as Task, false, true, write)).resolves.toBeUndefined() expect(saveCheckpointSpy).not.toHaveBeenCalled() expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) }) diff --git a/src/core/checkpoints/__tests__/checkpointSave.spec.ts b/src/core/checkpoints/__tests__/checkpointSave.spec.ts index dabcd6e007..e452ad109a 100644 --- a/src/core/checkpoints/__tests__/checkpointSave.spec.ts +++ b/src/core/checkpoints/__tests__/checkpointSave.spec.ts @@ -25,7 +25,12 @@ vi.mock("@roo-code/telemetry", () => ({ * (journal dir + setting state), and say. Structural cast at the boundary, * matching the documented test-double style of the tool specs. */ -function makeTask(options: { saveCheckpoint?: unknown; state?: Record; enableCheckpoints?: boolean }) { +function makeTask(options: { + saveCheckpoint?: unknown + state?: Record + enableCheckpoints?: boolean + storageDir?: string | null +}) { const say = vi.fn().mockResolvedValue(undefined) // An explicit `saveCheckpoint: undefined` (a checkpoint that produced no // commit) must be preserved as-is; only an omitted option falls back to @@ -33,8 +38,12 @@ function makeTask(options: { saveCheckpoint?: unknown; state?: Record { expect(cardCalls).toHaveLength(0) }) - it("fails a write-metadata save when checkpoints are disabled for the task and emits no card", async () => { - const { task, say } = makeTask({ enableCheckpoints: false }) + it("resolves a write-metadata save when checkpoints are disabled for the task and emits no card", async () => { + // Documented contract: user-disabled checkpoints means the absence of + // rollback metadata is the user's choice, so the save must not fail the + // write tool. + const { task, say, saveCheckpoint } = makeTask({ enableCheckpoints: false }) + + await expect( + checkpointSave(task, false, true, { path: "src/a.ts", operation: "create", diff: "+x" }), + ).resolves.toBeUndefined() + + expect(saveCheckpoint).not.toHaveBeenCalled() + expect(say).not.toHaveBeenCalled() + }) + + it("fails a write-metadata save when the checkpoint-service lookup fails and emits no card", async () => { + // Documented contract: with checkpoints requested (enableCheckpoints + // true) a failed service lookup is a genuine init-failure - the write + // would be orphaned, so the save must surface it. + const { task, say } = makeTask({ storageDir: null }) await expect( checkpointSave(task, false, true, { path: "src/a.ts", operation: "create", diff: "+x" }), diff --git a/src/core/checkpoints/index.ts b/src/core/checkpoints/index.ts index ae850542e7..134839cbf0 100644 --- a/src/core/checkpoints/index.ts +++ b/src/core/checkpoints/index.ts @@ -261,16 +261,25 @@ export async function checkpointSave( write?: CheckpointWriteInfo | CheckpointWriteInfo[], ) { // A save that carries write metadata must end with rollback metadata - // existing: every failure below (service unavailable, no commit, no + // existing: every failure below (service init-failure, no commit, no // journal location, journal write failure) surfaces explicitly instead - // of resolving to a tool success over an orphaned write. + // of resolving to a tool success over an orphaned write. A user-disabled + // checkpoint setting is NOT such a failure: absence of rollback metadata + // is then the user's explicit choice, so the save resolves silently + // (documented contract, see PR description). const journalWrites = write === undefined ? undefined : Array.isArray(write) ? write : [write] const hasWriteMetadata = !!journalWrites && journalWrites.length > 0 + // Captured before the lookup: getCheckpointService flips the flag off when + // initialization fails, so reading it after the call cannot distinguish + // "user disabled checkpoints" (resolve silently) from "init failed while + // the user expects checkpoints" (throw). + const checkpointsRequested = task.enableCheckpoints + const service = await getCheckpointService(task) if (!service) { - if (hasWriteMetadata) { + if (hasWriteMetadata && checkpointsRequested) { throw new Error( "Checkpoint save with write metadata could not initialize the checkpoint service; the write has no rollback metadata.", ) From 075adc786c2feb159702bbaf8d8c893dc51b8762 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 17 Oct 2026 19:57:56 +0800 Subject: [PATCH 43/48] fix(checkpoints): review follow-ups T1-T3 (journal tail, baseline guard, ignored-path rollback) T1: appendChanges now rejects a malformed final record even when it kept its newline (same hazard as the unterminated tail: accepting it demotes it to a skipped middle line and reports an incomplete history as valid). T2: the task-start baseline block is wrapped in try/catch - a rejected state lookup or checkpoint error is logged and no longer aborts task startup. T3: shadow-repo-excluded paths never enter the change journal (skip-when- journaling) and restoreFile's delete branch refuses an excluded target with an explicit error instead of fs.rm (covers journals written before the fix). New ShadowCheckpointService#isPathIgnored uses check-ignore --no-index -v and judges by output, not by promise settlement (simple-git raw() resolves on a silent non-zero exit). --- .../__tests__/changeJournal.spec.ts | 20 +++++++ .../__tests__/checkpointJournal.test.ts | 41 ++++++++++++- .../__tests__/checkpointSave.spec.ts | 5 +- src/core/checkpoints/changeJournal.ts | 58 +++++++++++-------- src/core/checkpoints/index.ts | 20 ++++++- src/core/task/Task.ts | 22 ++++--- src/core/task/__tests__/Task.spec.ts | 25 ++++++++ .../checkpoints/ShadowCheckpointService.ts | 43 ++++++++++++++ .../__tests__/ShadowCheckpointService.spec.ts | 20 +++++++ 9 files changed, 221 insertions(+), 33 deletions(-) diff --git a/src/core/checkpoints/__tests__/changeJournal.spec.ts b/src/core/checkpoints/__tests__/changeJournal.spec.ts index e3e8658447..b945d3f176 100644 --- a/src/core/checkpoints/__tests__/changeJournal.spec.ts +++ b/src/core/checkpoints/__tests__/changeJournal.spec.ts @@ -70,6 +70,26 @@ describe("changeJournal", () => { expect(after).toBe(raw + '{"path":"src/half.ts","operation":"upd') }) + it("rejects an append when a malformed final record kept its newline", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "good" })) + // A final line of valid JSON that is not an entry (a bare null) is the + // same hazard with or without its newline: accepting it here would + // demote it to a skipped middle line after the append, so loadChanges + // would report an incomplete rollback history as valid and + // restore-latest could pick an older entry for the same file. + const filePath = journalPath(tmpRoot, taskId) + const raw = await fs.readFile(filePath, "utf8") + await fs.writeFile(filePath, raw + "null\n") + + await expect(appendChange(tmpRoot, taskId, entry({ checkpointId: "after" }))).rejects.toThrow( + /final record is not a complete entry/, + ) + + // The journal is left untouched for an explicit recovery. + const after = await fs.readFile(filePath, "utf8") + expect(after).toBe(raw + "null\n") + }) + it("does not insert a blank line when the journal ends cleanly", async () => { await appendChange(tmpRoot, taskId, entry({ checkpointId: "a" })) await appendChange(tmpRoot, taskId, entry({ checkpointId: "b" })) diff --git a/src/core/checkpoints/__tests__/checkpointJournal.test.ts b/src/core/checkpoints/__tests__/checkpointJournal.test.ts index 8838edc7ca..f0517feac4 100644 --- a/src/core/checkpoints/__tests__/checkpointJournal.test.ts +++ b/src/core/checkpoints/__tests__/checkpointJournal.test.ts @@ -60,6 +60,7 @@ const COMMIT = "test-commit-hash" interface ServiceLike { isInitialized: boolean saveCheckpoint: (...args: unknown[]) => Promise + isPathIgnored: (filePath: string) => Promise } interface ProviderLike { @@ -105,7 +106,13 @@ describe("checkpointSave change-journal wiring (B2)", () => { mockTask = { taskId: TASK_ID, enableCheckpoints: true, - checkpointService: { isInitialized: true, saveCheckpoint: saveCheckpointSpy }, + checkpointService: { + isInitialized: true, + saveCheckpoint: saveCheckpointSpy, + // Default: nothing is excluded from the shadow repo, so journaling + // behavior is unchanged unless a test overrides this. + isPathIgnored: vi.fn().mockResolvedValue(false), + }, checkpointServiceInitializing: false, providerRef: { deref: () => mockProvider }, // B3a: the card emission calls task.say; a resolved double keeps the @@ -138,6 +145,38 @@ describe("checkpointSave change-journal wiring (B2)", () => { expect(raw.split("\n").filter((line) => line !== "")).toHaveLength(1) }) + it("keeps shadow-repo-excluded paths out of the journal and the card", async () => { + // Excluded paths are absent from the shadow commit by design; journaling + // them would route a later rollback into restoreFile's delete branch and + // remove the user's live file. They must not appear in the journal, must + // not be advertised by the card, and a step whose writes are ALL excluded + // must produce neither a journal update nor a card. + mockTask.checkpointService.isPathIgnored = vi + .fn() + .mockImplementation(async (p: string) => p.startsWith("node_modules/")) + + await checkpointSave(mockTask as Task, false, true, [ + { path: "src/kept.ts", operation: "create", diffStats: { additions: 1, deletions: 0 } }, + { path: "node_modules/pkg/index.js", operation: "create", diffStats: { additions: 1, deletions: 0 } }, + ]) + + let entries = await loadChanges(tmpStorageDir, TASK_ID) + expect(entries.map((e) => e.path)).toEqual(["src/kept.ts"]) + const sayMock = mockTask.say as Mock + expect(sayMock).toHaveBeenCalledTimes(1) + expect(String(sayMock.mock.calls[0][1])).not.toContain("node_modules") + + // A step whose every write is excluded journals nothing and emits no card. + await checkpointSave(mockTask as Task, false, true, { + path: "node_modules/pkg/other.js", + operation: "create", + diffStats: { additions: 1, deletions: 0 }, + }) + entries = await loadChanges(tmpStorageDir, TASK_ID) + expect(entries.map((e) => e.path)).toEqual(["src/kept.ts"]) + expect(sayMock).toHaveBeenCalledTimes(1) + }) + it("omits diffStats in the journal entry when not provided", async () => { await checkpointSave(mockTask as Task, false, true, { path: "src/bar.ts", operation: "update" }) diff --git a/src/core/checkpoints/__tests__/checkpointSave.spec.ts b/src/core/checkpoints/__tests__/checkpointSave.spec.ts index e452ad109a..21d4f01136 100644 --- a/src/core/checkpoints/__tests__/checkpointSave.spec.ts +++ b/src/core/checkpoints/__tests__/checkpointSave.spec.ts @@ -53,7 +53,10 @@ function makeTask(options: { enableCheckpoints: options.enableCheckpoints ?? true, // With `storageDir: null` there is no preset service either, so the // lookup runs for real and fails on the missing storage dir. - checkpointService: options.storageDir === null ? undefined : { saveCheckpoint, isInitialized: true }, + checkpointService: + options.storageDir === null + ? undefined + : { saveCheckpoint, isInitialized: true, isPathIgnored: vi.fn().mockResolvedValue(false) }, providerRef: { deref: vi.fn().mockReturnValue(providerDeref) }, say, } as unknown as Task diff --git a/src/core/checkpoints/changeJournal.ts b/src/core/checkpoints/changeJournal.ts index 338d379223..f1fff767fd 100644 --- a/src/core/checkpoints/changeJournal.ts +++ b/src/core/checkpoints/changeJournal.ts @@ -122,31 +122,43 @@ export async function appendChanges( throw error } } - // Torn-tail handling: a crash can leave the final line unterminated. - // If that final line is a complete, entry-shaped JSON record it was - // fully written and only its newline was lost - verified recovery: - // keep it and re-terminate. An unparseable or non-entry-shaped tail - // is NOT verified recovery material: it may be the truncated journal - // record of a committed write, and silently truncating it would let - // this append succeed while the rollback history is already - // incomplete (a later rollback could then resolve the file to the - // wrong checkpoint or lose the lost write's metadata). Reject the - // append instead; torn-tail repair is a separately verified recovery - // operation, not a side effect of the next write. - if (content && !content.endsWith("\n")) { - const cut = content.lastIndexOf("\n") + 1 - let tail: unknown - try { - tail = JSON.parse(content.slice(cut)) - } catch { - tail = undefined + // Torn-tail handling: the FINAL record must be complete and + // entry-shaped whether or not it ends with a newline. A crash can + // leave the final line unterminated; if that line is a complete, + // entry-shaped JSON record it was fully written and only its newline + // was lost - verified recovery: keep it and re-terminate. An + // unparseable or non-entry-shaped final record is NOT verified + // recovery material: it may be the truncated journal record of a + // committed write, and silently truncating it would let this append + // succeed while the rollback history is already incomplete (a later + // rollback could then resolve the file to the wrong checkpoint or + // lose the lost write's metadata). A malformed final record that + // still ends with its newline (e.g. "null\n") is the same hazard: + // accepting it here would demote it to a skipped middle line after + // the append, reporting an incomplete history as valid. Reject the + // append in both cases; torn-tail repair is a separately verified + // recovery operation, not a side effect of the next write. + if (content) { + const unterminated = !content.endsWith("\n") + const body = unterminated ? content : content.slice(0, -1) + const cut = body.lastIndexOf("\n") + 1 + const tailText = body.slice(cut) + if (tailText !== "") { + let tail: unknown + try { + tail = JSON.parse(tailText) + } catch { + tail = undefined + } + if (!isEntryShaped(tail)) { + throw new Error( + `Cannot append to change journal ${filePath}: ${unterminated ? "unterminated " : ""}final record is not a complete entry (torn-tail recovery requires explicit repair)`, + ) + } } - if (!isEntryShaped(tail)) { - throw new Error( - `Cannot append to change journal ${filePath}: unterminated final record is not a complete entry (torn-tail recovery requires explicit repair)`, - ) + if (unterminated) { + content = content + "\n" } - content = content + "\n" } const payload = entries.map((entry) => JSON.stringify(entry) + "\n").join("") const tmpPath = path.join(dir, `${JOURNAL_FILENAME}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`) diff --git a/src/core/checkpoints/index.ts b/src/core/checkpoints/index.ts index 134839cbf0..8e5505d68e 100644 --- a/src/core/checkpoints/index.ts +++ b/src/core/checkpoints/index.ts @@ -352,7 +352,25 @@ export async function checkpointSave( // changes can still produce a commit, and emitting a card for it would // show a change card with zero files. The checkpoint itself still runs. if (result?.commit && hasWriteMetadata) { - const writes = journalWrites ?? [] + // Paths excluded from the shadow repository (node_modules, build + // output, .gitignore'd files) never enter the journal: the shadow + // commit cannot contain them, so a journal entry would route a later + // rollback into restoreFile's delete branch, where absence from the + // commit would delete the user's live file. Skipping excluded paths + // at journaling time keeps rollback metadata honest and removes + // excluded-path noise from the journal and the change card. + const writes: CheckpointWriteInfo[] = [] + for (const w of journalWrites ?? []) { + if (await service.isPathIgnored(w.path)) { + continue + } + writes.push(w) + } + if (writes.length === 0) { + // Every write landed on an excluded path: nothing to journal and + // nothing to show; the checkpoint commit itself still ran. + return result + } if (!journalDir) { // Defensive: the pre-commit guard above normally makes this // unreachable. If the location is nonetheless gone while the commit diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index a4b9046285..acf806523c 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3863,13 +3863,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 649e4a7956..322a6a84e2 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()", () => { diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index f389dc4269..1d259a6a70 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -535,6 +535,16 @@ export abstract class ShadowCheckpointService extends EventEmitter { // single requested path. await this.git.checkout([commitHash, "--", `:(literal)${gitPath}`]) } else { + // Belt-and-suspenders: a path the shadow repository excludes was + // never tracked, so its absence from the commit is not evidence of + // a deletion - removing it here could rm a live user file (e.g. a + // log under an excluded directory) that no checkpoint ever + // contained. Journaling now skips excluded paths, but journals + // written before that fix can still route an excluded path here; + // fail loudly instead of deleting. + if (await this.isPathIgnored(filePath)) { + throw new Error(`restoreFile target is excluded from the shadow repository: ${filePath}`) + } // TOCTOU guard: same race as above - without this re-check the // delete could remove a file the swapped link now points at // outside the workspace. @@ -555,6 +565,39 @@ export abstract class ShadowCheckpointService extends EventEmitter { } } + /** + * Whether `filePath` (workspace-relative or absolute) is excluded from the + * shadow repository by its local exclude rules (.git/info/exclude, which + * mirrors the workspace ignore configuration). Excluded paths are never + * tracked by the shadow repo, so they must not enter the change journal + * and must not be deleted by restoreFile's delete branch. + */ + public async isPathIgnored(filePath: string): Promise { + if (!this.git) { + return false + } + const resolved = path.resolve(this.workspaceDir, filePath) + const gitPath = path.relative(this.workspaceDir, resolved).split(path.sep).join("/") + if (!gitPath || gitPath.startsWith("..")) { + return false + } + try { + // --no-index evaluates the exclude rules alone: index or commit + // membership is irrelevant to "the shadow repo never tracks this". + // The verdict comes from the -v output, not from the promise + // settling: simple-git's raw() resolves (rather than rejects) on a + // silent non-zero exit, so "not ignored" (exit 1, no output) arrives + // as an empty success here - the same defect documented in + // fileExistsInCommit. An ignored path prints its matching rule line. + const match = await this.git.raw(["check-ignore", "--no-index", "-v", "--", gitPath]) + return match.trim().length > 0 + } catch { + // A git fatal (corrupt repo) leaves the path unproven-ignored; + // callers keep their historical behavior for that case. + return false + } + } + /** Whether `filePath` exists in the tree of `commitHash`. */ private async fileExistsInCommit(commitHash: string, filePath: string): Promise { // A failed lookup is not evidence the file is absent. If the commit object diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index 480d9fb24e..c549dbe545 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -202,6 +202,26 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!") }) + it("rejects deleting an ignored live file that the checkpoint never tracked", async () => { + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + // An ignored path (.log is excluded by the default shadow-repo + // rules) reaches restoreFile only from a journal written before the + // skip-when-journaling fix. Its absence from the commit is not + // evidence of a deletion: the delete branch must fail loudly + // instead of removing a live file the shadow repo never tracked. + const ignoredFile = path.join(service.workspaceDir, "live.log") + await fs.writeFile(ignoredFile, "live log content") + + await expect(service.restoreFile(commit1!.commit, "live.log")).rejects.toThrow( + /excluded from the shadow repository/, + ) + + expect(await fileExistsAtPath(ignoredFile)).toBe(true) + }) + it("accepts a native backslashed path on Windows (git pathspecs are POSIX)", async () => { if (process.platform !== "win32") { // Off-Windows the journal paths are already POSIX, so there is From 548231c77a9facbe72109826c0a0d5ed8ec16e4e Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 17 Oct 2026 22:20:50 +0800 Subject: [PATCH 44/48] fix(checkpoints): review follow-ups row 11 (excluded-path ordering, negation rules, flipped flag) A: the excluded-path filter now runs BEFORE the no-commit rejection - an all-excluded step stages nothing, so it resolves instead of failing when unrelated workspace cleanliness decides the tool outcome. B: isPathIgnored drops check-ignore -v: under -v a negation rule (e.g. '!.env.example' after '.env*') prints a match and misclassifies a re-included path as ignored; without -v only genuinely ignored paths print, and the verdict still comes from the output, not from promise settlement (simple-git raw() resolves on a silent non-zero exit). C: when the task flag is off but the persisted setting still requests checkpoints (an earlier init/save failure flipped the flag), a write-metadata save throws instead of silently losing rollback metadata; only a user-disabled setting resolves silently. --- .../__tests__/checkpointJournal.test.ts | 25 ++++++- .../__tests__/checkpointSave.spec.ts | 21 +++++- src/core/checkpoints/index.ts | 67 ++++++++++++------- .../checkpoints/ShadowCheckpointService.ts | 16 +++-- .../__tests__/ShadowCheckpointService.spec.ts | 13 ++++ 5 files changed, 110 insertions(+), 32 deletions(-) diff --git a/src/core/checkpoints/__tests__/checkpointJournal.test.ts b/src/core/checkpoints/__tests__/checkpointJournal.test.ts index f0517feac4..b06f12b87b 100644 --- a/src/core/checkpoints/__tests__/checkpointJournal.test.ts +++ b/src/core/checkpoints/__tests__/checkpointJournal.test.ts @@ -177,6 +177,26 @@ describe("checkpointSave change-journal wiring (B2)", () => { expect(sayMock).toHaveBeenCalledTimes(1) }) + it("resolves an all-excluded write when the checkpoint produces no commit", async () => { + // An excluded path is never staged, so an all-excluded step has nothing + // to commit: the save must resolve (the write succeeded and no rollback + // metadata is expected) instead of failing on the no-commit rejection, + // whose outcome would otherwise depend on unrelated workspace changes. + saveCheckpointSpy.mockResolvedValueOnce(undefined) + mockTask.checkpointService.isPathIgnored = vi.fn().mockResolvedValue(true) + + await expect( + checkpointSave(mockTask as Task, false, true, { + path: "node_modules/pkg/index.js", + operation: "create", + diffStats: { additions: 1, deletions: 0 }, + }), + ).resolves.toBeUndefined() + + expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) + expect(mockTask.say as Mock).not.toHaveBeenCalled() + }) + it("omits diffStats in the journal entry when not provided", async () => { await checkpointSave(mockTask as Task, false, true, { path: "src/bar.ts", operation: "update" }) @@ -248,8 +268,11 @@ describe("checkpointSave change-journal wiring (B2)", () => { // Documented contract: user-disabled checkpoints means the absence of // rollback metadata is the user's choice, so the save must not fail the // write tool (no journal entry, no change card, no throw). Genuine - // init-failure coverage lives in checkpointSave.spec.ts. + // init-failure coverage lives in checkpointSave.spec.ts. The + // persisted setting agrees the user disabled checkpoints (an unset + // setting would mean an earlier failure flipped the task flag). mockTask.enableCheckpoints = false + mockProvider.getState = vi.fn().mockResolvedValue({ enableCheckpoints: false }) await expect(checkpointSave(mockTask as Task, false, true, write)).resolves.toBeUndefined() expect(saveCheckpointSpy).not.toHaveBeenCalled() diff --git a/src/core/checkpoints/__tests__/checkpointSave.spec.ts b/src/core/checkpoints/__tests__/checkpointSave.spec.ts index 21d4f01136..72c1289f04 100644 --- a/src/core/checkpoints/__tests__/checkpointSave.spec.ts +++ b/src/core/checkpoints/__tests__/checkpointSave.spec.ts @@ -223,7 +223,12 @@ describe("checkpointSave change-card emission (B3a)", () => { // Documented contract: user-disabled checkpoints means the absence of // rollback metadata is the user's choice, so the save must not fail the // write tool. - const { task, say, saveCheckpoint } = makeTask({ enableCheckpoints: false }) + const { task, say, saveCheckpoint } = makeTask({ + enableCheckpoints: false, + // The persisted setting agrees the user disabled checkpoints; an unset or + // true setting would mean an earlier failure flipped the task flag. + state: { enableCheckpoints: false }, + }) await expect( checkpointSave(task, false, true, { path: "src/a.ts", operation: "create", diff: "+x" }), @@ -246,6 +251,20 @@ describe("checkpointSave change-card emission (B3a)", () => { expect(say).not.toHaveBeenCalled() }) + it("fails a write-metadata save when an earlier failure flipped the task flag", async () => { + // getCheckpointService and the save-failure handlers flip + // task.enableCheckpoints off; the persisted setting still says the user + // wants checkpoints, so a later write-metadata save must throw too + // instead of silently proceeding with no rollback metadata. + const { task, say } = makeTask({ enableCheckpoints: false, state: { enableCheckpoints: true } }) + + await expect( + checkpointSave(task, false, true, { path: "src/a.ts", operation: "create", diff: "+x" }), + ).rejects.toThrow(/could not initialize the checkpoint service/) + + expect(say).not.toHaveBeenCalled() + }) + it("keeps the journal append when a card emission failure occurs", async () => { const { task, say } = makeTask({}) say.mockImplementation(async (type: string) => { diff --git a/src/core/checkpoints/index.ts b/src/core/checkpoints/index.ts index 8e5505d68e..79ac5b1bc8 100644 --- a/src/core/checkpoints/index.ts +++ b/src/core/checkpoints/index.ts @@ -279,7 +279,19 @@ export async function checkpointSave( const service = await getCheckpointService(task) if (!service) { - if (hasWriteMetadata && checkpointsRequested) { + let requested = checkpointsRequested + if (hasWriteMetadata && !requested) { + // The task flag also flips off when an EARLIER failure disabled + // checkpoints (a failed service init, or the save-failure handlers + // below). The persisted setting still carries the user's intent + // (unset defaults to enabled, matching Task construction), so a + // write-metadata save after such a failure must throw too instead + // of silently proceeding with no rollback metadata. Only a user who + // disabled checkpoints in settings resolves silently. + const state = await task.providerRef.deref()?.getState() + requested = state?.enableCheckpoints ?? true + } + if (hasWriteMetadata && requested) { throw new Error( "Checkpoint save with write metadata could not initialize the checkpoint service; the write has no rollback metadata.", ) @@ -335,10 +347,36 @@ export async function checkpointSave( }) } - // A save that resolves without a commit (e.g. a write to a Git-ignored - // file the shadow repository never stages) produced no rollback point: - // journaling is skipped and a later restore reports no entry. With - // write metadata that is a partial persistence, not a success. + // Paths excluded from the shadow repository (node_modules, build + // output, .gitignore'd files) never enter the journal: the shadow + // commit cannot contain them, so a journal entry would route a later + // rollback into restoreFile's delete branch, where absence from the + // commit would delete the user's live file. Skipping excluded paths + // at journaling time keeps rollback metadata honest and removes + // excluded-path noise from the journal and the change card. The + // filter runs BEFORE the no-commit rejection below: an all-excluded + // step stages nothing, so requiring a commit would make the tool's + // outcome depend on unrelated workspace changes. + const writes: CheckpointWriteInfo[] = [] + if (hasWriteMetadata) { + for (const w of journalWrites ?? []) { + if (await service.isPathIgnored(w.path)) { + continue + } + writes.push(w) + } + if (writes.length === 0) { + // Every write landed on an excluded path: nothing to journal and + // nothing to show, and no rollback metadata is expected - the save + // resolves whether or not unrelated changes produced a commit. + return result + } + } + + // A save that resolves without a commit despite non-excluded writes + // produced no rollback point: journaling is skipped and a later + // restore reports no entry. With write metadata that is a partial + // persistence, not a success. if (hasWriteMetadata && !result?.commit) { throw new Error( "Checkpoint save with write metadata did not produce a commit; the write has no rollback metadata.", @@ -352,25 +390,6 @@ export async function checkpointSave( // changes can still produce a commit, and emitting a card for it would // show a change card with zero files. The checkpoint itself still runs. if (result?.commit && hasWriteMetadata) { - // Paths excluded from the shadow repository (node_modules, build - // output, .gitignore'd files) never enter the journal: the shadow - // commit cannot contain them, so a journal entry would route a later - // rollback into restoreFile's delete branch, where absence from the - // commit would delete the user's live file. Skipping excluded paths - // at journaling time keeps rollback metadata honest and removes - // excluded-path noise from the journal and the change card. - const writes: CheckpointWriteInfo[] = [] - for (const w of journalWrites ?? []) { - if (await service.isPathIgnored(w.path)) { - continue - } - writes.push(w) - } - if (writes.length === 0) { - // Every write landed on an excluded path: nothing to journal and - // nothing to show; the checkpoint commit itself still ran. - return result - } if (!journalDir) { // Defensive: the pre-commit guard above normally makes this // unreachable. If the location is nonetheless gone while the commit diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index 1d259a6a70..35d403bf98 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -584,12 +584,16 @@ export abstract class ShadowCheckpointService extends EventEmitter { try { // --no-index evaluates the exclude rules alone: index or commit // membership is irrelevant to "the shadow repo never tracks this". - // The verdict comes from the -v output, not from the promise - // settling: simple-git's raw() resolves (rather than rejects) on a - // silent non-zero exit, so "not ignored" (exit 1, no output) arrives - // as an empty success here - the same defect documented in - // fileExistsInCommit. An ignored path prints its matching rule line. - const match = await this.git.raw(["check-ignore", "--no-index", "-v", "--", gitPath]) + // No -v: verbose mode prints a line for ANY matching rule, and a + // negation rule (e.g. "!.env.example" after ".env*") means the path + // is NOT ignored - under -v it would be misclassified as ignored. + // Without -v only genuinely ignored paths print a line. The verdict + // still comes from the output, not from the promise settling: + // simple-git's raw() resolves (rather than rejects) on a silent + // non-zero exit, so "not ignored" (exit 1, no output) arrives as an + // empty success here - the same defect documented in + // fileExistsInCommit. + const match = await this.git.raw(["check-ignore", "--no-index", "--", gitPath]) return match.trim().length > 0 } catch { // A git fatal (corrupt repo) leaves the path unproven-ignored; diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index c549dbe545..e1571253af 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -222,6 +222,19 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( expect(await fileExistsAtPath(ignoredFile)).toBe(true) }) + it("treats a negation rule as not-ignored in isPathIgnored", async () => { + // A negation rule re-includes a path matched by an earlier rule + // (".env*" then "!.env.example"). check-ignore -v prints the negation + // match and would misclassify the file as ignored; without -v only + // genuinely ignored paths print, so isPathIgnored must agree with + // what the shadow repo actually stages. + const excludesPath = path.join(service.checkpointsDir, ".git", "info", "exclude") + await fs.appendFile(excludesPath, "\n.env*\n!.env.example\n") + + expect(await service.isPathIgnored(".env")).toBe(true) + expect(await service.isPathIgnored(".env.example")).toBe(false) + }) + it("accepts a native backslashed path on Windows (git pathspecs are POSIX)", async () => { if (process.platform !== "win32") { // Off-Windows the journal paths are already POSIX, so there is From a71cf5b846b70fdcbfa97356d40079d1de54da09 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 18 Oct 2026 06:35:49 +0800 Subject: [PATCH 45/48] fix(checkpoints): recover later writes after a transient checkpoint failure Port of the row-12 fixes for the shared checkpointSave block: a failure-driven disable now sets checkpointsDisabledByFailure, and the next write-metadata save attempts one recovery (reuse a live service handle, else retry init) instead of failing every later write in the task. Enabling checkpoints mid-task no longer retroactively fails writes: the throw is keyed on the failure flag, not live settings. WriteToFileTool skips the per-write checkpoint when an existing file's approval diff is empty (nothing to stage, no commit, no false failure). --- .../__tests__/checkpointJournal.test.ts | 6 +- .../__tests__/checkpointSave.spec.ts | 57 +++++++++++++-- src/core/checkpoints/index.ts | 72 +++++++++++-------- src/core/task/Task.ts | 5 ++ src/core/tools/WriteToFileTool.ts | 10 ++- .../tools/__tests__/writeToFileTool.spec.ts | 14 ++-- 6 files changed, 116 insertions(+), 48 deletions(-) diff --git a/src/core/checkpoints/__tests__/checkpointJournal.test.ts b/src/core/checkpoints/__tests__/checkpointJournal.test.ts index b06f12b87b..8d539a170d 100644 --- a/src/core/checkpoints/__tests__/checkpointJournal.test.ts +++ b/src/core/checkpoints/__tests__/checkpointJournal.test.ts @@ -268,11 +268,9 @@ describe("checkpointSave change-journal wiring (B2)", () => { // Documented contract: user-disabled checkpoints means the absence of // rollback metadata is the user's choice, so the save must not fail the // write tool (no journal entry, no change card, no throw). Genuine - // init-failure coverage lives in checkpointSave.spec.ts. The - // persisted setting agrees the user disabled checkpoints (an unset - // setting would mean an earlier failure flipped the task flag). + // init-failure coverage lives in checkpointSave.spec.ts. No + // checkpointsDisabledByFailure flag is set: the flip is a user choice. mockTask.enableCheckpoints = false - mockProvider.getState = vi.fn().mockResolvedValue({ enableCheckpoints: false }) await expect(checkpointSave(mockTask as Task, false, true, write)).resolves.toBeUndefined() expect(saveCheckpointSpy).not.toHaveBeenCalled() diff --git a/src/core/checkpoints/__tests__/checkpointSave.spec.ts b/src/core/checkpoints/__tests__/checkpointSave.spec.ts index 72c1289f04..0ba32ac090 100644 --- a/src/core/checkpoints/__tests__/checkpointSave.spec.ts +++ b/src/core/checkpoints/__tests__/checkpointSave.spec.ts @@ -2,7 +2,7 @@ import fs from "fs/promises" import os from "os" import path from "path" -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest" import type { Task } from "../../task/Task" import { journalPath } from "../changeJournal" @@ -251,12 +251,37 @@ describe("checkpointSave change-card emission (B3a)", () => { expect(say).not.toHaveBeenCalled() }) - it("fails a write-metadata save when an earlier failure flipped the task flag", async () => { - // getCheckpointService and the save-failure handlers flip - // task.enableCheckpoints off; the persisted setting still says the user - // wants checkpoints, so a later write-metadata save must throw too - // instead of silently proceeding with no rollback metadata. - const { task, say } = makeTask({ enableCheckpoints: false, state: { enableCheckpoints: true } }) + it("recovers the next write-metadata save after a transient save failure", async () => { + // A failed save flips the task flag (checkpointsDisabledByFailure). + // The next write-metadata save must attempt recovery - reusing the + // live service handle - instead of failing every later write in the + // task with a misleading init-failure error. + const { task } = makeTask({}) + const svc = task.checkpointService as unknown as { saveCheckpoint: Mock } + svc.saveCheckpoint = vi + .fn() + .mockRejectedValueOnce(new Error("index.lock contention")) + .mockResolvedValue({ commit: "sha-recovered-1" }) + + await expect( + checkpointSave(task, false, true, { path: "src/a.ts", operation: "create", diff: "+x" }), + ).rejects.toThrow(/Checkpoint save failed after a file write/) + expect(task.checkpointsDisabledByFailure).toBe(true) + + await expect( + checkpointSave(task, false, true, { path: "src/b.ts", operation: "create", diff: "+y" }), + ).resolves.toBeDefined() + expect(task.enableCheckpoints).toBe(true) + const journalRaw = await fs.readFile(journalPath(globalStorageDir, "task-card"), "utf8") + expect(journalRaw).toContain("src/b.ts") + }) + + it("fails a write-metadata save when recovery after an earlier failure cannot re-initialize", async () => { + // No live handle to reuse and initialization fails again: the write + // has no rollback metadata and must surface the failure. + const { task, say } = makeTask({ storageDir: null }) + task.enableCheckpoints = false + task.checkpointsDisabledByFailure = true await expect( checkpointSave(task, false, true, { path: "src/a.ts", operation: "create", diff: "+x" }), @@ -265,6 +290,24 @@ describe("checkpointSave change-card emission (B3a)", () => { expect(say).not.toHaveBeenCalled() }) + it("resolves silently when checkpoints were off for the task and no failure occurred", async () => { + // Enabling checkpoints in settings mid-task does not retroactively + // fail writes: the task was constructed without checkpoints and no + // failure flipped the flag, so the absence of rollback metadata is + // the construction-time user choice. + const { task, say, saveCheckpoint } = makeTask({ + enableCheckpoints: false, + state: { enableCheckpoints: true }, + }) + + await expect( + checkpointSave(task, false, true, { path: "src/a.ts", operation: "create", diff: "+x" }), + ).resolves.toBeUndefined() + + expect(saveCheckpoint).not.toHaveBeenCalled() + expect(say).not.toHaveBeenCalled() + }) + it("keeps the journal append when a card emission failure occurs", async () => { const { task, say } = makeTask({}) say.mockImplementation(async (type: string) => { diff --git a/src/core/checkpoints/index.ts b/src/core/checkpoints/index.ts index 79ac5b1bc8..7d29a8cfba 100644 --- a/src/core/checkpoints/index.ts +++ b/src/core/checkpoints/index.ts @@ -28,6 +28,16 @@ function sendCheckpointInitWarn(task: Task, type?: "WAIT_TIMEOUT" | "INIT_TIMEOU }) } +// Every checkpoint disable in this module is failure-driven (init, save, +// restore, or diff). Route the flip through this helper so checkpointSave +// can tell an earlier failure apart from a user who never enabled +// checkpoints: the persisted setting cannot make that distinction (it can +// change after the task started). +function disableCheckpointsAfterFailure(task: Task): void { + task.enableCheckpoints = false + task.checkpointsDisabledByFailure = true +} + export async function getCheckpointService(task: Task, { interval = 250 }: { interval?: number } = {}) { if (!task.enableCheckpoints) { return undefined @@ -59,7 +69,7 @@ export async function getCheckpointService(task: Task, { interval = 250 }: { int if (!workspaceDir) { log("[Task#getCheckpointService] workspace folder not found, disabling checkpoints") - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) return undefined } @@ -67,7 +77,7 @@ export async function getCheckpointService(task: Task, { interval = 250 }: { int if (!globalStorageDir) { log("[Task#getCheckpointService] globalStorageDir not found, disabling checkpoints") - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) return undefined } @@ -101,7 +111,7 @@ export async function getCheckpointService(task: Task, { interval = 250 }: { int ) if (!task?.checkpointService) { sendCheckpointInitWarn(task, "INIT_TIMEOUT", task.checkpointTimeout) - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) return undefined } else { sendCheckpointInitWarn(task) @@ -126,7 +136,7 @@ export async function getCheckpointService(task: Task, { interval = 250 }: { int sendCheckpointInitWarn(task, "INIT_TIMEOUT", task.checkpointTimeout) } log(`[Task#getCheckpointService] ${err.message}`) - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) task.checkpointServiceInitializing = false return undefined } @@ -143,7 +153,7 @@ async function checkGitInstallation( if (!gitInstalled) { log("[Task#getCheckpointService] Git is not installed, disabling checkpoints") - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) task.checkpointServiceInitializing = false // Show user-friendly notification @@ -192,7 +202,7 @@ async function checkGitInstallation( } catch (err) { log("[Task#getCheckpointService] caught unexpected error in on('checkpoint'), disabling checkpoints") console.error(err) - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) } }) @@ -202,12 +212,12 @@ async function checkGitInstallation( await service.initShadowGit() } catch (err) { log(`[Task#getCheckpointService] initShadowGit -> ${err.message}`) - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) } } catch (err) { log(`[Task#getCheckpointService] Unexpected error during Git check: ${err.message}`) console.error("Git check error:", err) - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) task.checkpointServiceInitializing = false } } @@ -276,27 +286,31 @@ export async function checkpointSave( // the user expects checkpoints" (throw). const checkpointsRequested = task.enableCheckpoints - const service = await getCheckpointService(task) + let service = await getCheckpointService(task) if (!service) { - let requested = checkpointsRequested - if (hasWriteMetadata && !requested) { - // The task flag also flips off when an EARLIER failure disabled - // checkpoints (a failed service init, or the save-failure handlers - // below). The persisted setting still carries the user's intent - // (unset defaults to enabled, matching Task construction), so a - // write-metadata save after such a failure must throw too instead - // of silently proceeding with no rollback metadata. Only a user who - // disabled checkpoints in settings resolves silently. - const state = await task.providerRef.deref()?.getState() - requested = state?.enableCheckpoints ?? true + if (hasWriteMetadata && !checkpointsRequested && task.checkpointsDisabledByFailure) { + // An earlier failure (init or save) disabled checkpoints while the + // user never turned them off. Attempt one recovery so a single + // transient failure (e.g. index.lock contention) does not + // permanently fail every later write in the task: reuse a live + // service handle when one exists (a save failure does not + // invalidate the handle), otherwise retry initialization once. The + // persisted setting cannot make this decision - it can change + // after the task started, and enabling checkpoints mid-task must + // not retroactively fail writes that were never promised a + // checkpoint. + task.enableCheckpoints = true + service = task.checkpointService ?? (await getCheckpointService(task)) } - if (hasWriteMetadata && requested) { - throw new Error( - "Checkpoint save with write metadata could not initialize the checkpoint service; the write has no rollback metadata.", - ) + if (!service) { + if (hasWriteMetadata && (checkpointsRequested || task.checkpointsDisabledByFailure)) { + throw new Error( + "Checkpoint save with write metadata could not initialize the checkpoint service; the write has no rollback metadata.", + ) + } + return } - return } // A save that carries write metadata needs a stable journal location for @@ -334,7 +348,7 @@ export async function checkpointSave( result = await savePromise } catch (err) { console.error("[Task#checkpointSave] caught unexpected error, disabling checkpoints", err) - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) throw new Error( `Checkpoint save failed after a file write; the write has no rollback metadata: ${err instanceof Error ? err.message : String(err)}`, { cause: err }, @@ -343,7 +357,7 @@ export async function checkpointSave( } else { result = await savePromise.catch((err) => { console.error("[Task#checkpointSave] caught unexpected error, disabling checkpoints", err) - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) }) } @@ -518,7 +532,7 @@ export async function checkpointRestore( provider?.cancelTask() } catch { provider?.log("[checkpointRestore] disabling checkpoints for this task") - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) } } @@ -608,6 +622,6 @@ export async function checkpointDiff(task: Task, { commitHash, mode }: Checkpoin } catch { const provider = task.providerRef.deref() provider?.log("[checkpointDiff] disabling checkpoints for this task") - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) } } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index acf806523c..58b528a3e6 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -511,6 +511,11 @@ export class Task extends EventEmitter implements TaskLike { checkpointTimeout: number checkpointService?: RepoPerTaskCheckpointService checkpointServiceInitializing = false + // Set when checkpoints were disabled by a failure (init, save, restore, + // or diff), so checkpointSave can distinguish an earlier failure from a + // user who never enabled checkpoints, without inferring it from live + // settings (which can change after the task started). + checkpointsDisabledByFailure = false // Message Queue Service public readonly messageQueueService: MessageQueueService diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 42bc94f411..e8f8648a76 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -191,7 +191,15 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { task.didEditFile = true didWrite = true - if (perWriteCheckpoints) { + // A write whose approval diff is empty leaves the shadow repository + // nothing to stage: the per-write checkpoint would resolve without + // a commit and the write-metadata rejection would report failure + // for a write that succeeded. Skip the checkpoint for that no-op + // (the write result is still pushed), mirroring EditFileTool's + // unchanged-content early return. + const contentUnchanged = fileExists && approvalDiff === "" + + if (perWriteCheckpoints && !contentUnchanged) { // B2: the change-journal entry for this write is appended inside // checkpointSave (the hook stays a single call site), keyed by the // checkpoint commit that call produces. Await so the checkpoint diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index e2fa2bca14..79b7568f3b 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -619,18 +619,18 @@ describe("writeToFileTool", () => { }) }) - it("omits diff stats from the checkpoint write when the approval diff is empty", async () => { + it("skips the checkpoint when an existing file's content is unchanged", async () => { // Writing identical content to an existing file produces an empty - // approval diff, so the checkpoint write carries no diffStats. + // approval diff: the shadow repository has nothing to stage, so a + // checkpoint would resolve without a commit and the write-metadata + // rejection would report failure for a write that succeeded. The + // write result is still pushed. vi.mocked(formatResponse.createPrettyPatch).mockReturnValueOnce("") await executeWriteFileTool({}, { fileExists: true }) - expect(mockedCheckpointSave).toHaveBeenCalledOnce() - expect(mockedCheckpointSave).toHaveBeenCalledWith(mockCline, false, true, { - path: testFilePath, - operation: "update", - }) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalled() }) it("threads autoApproved into the checkpoint write for auto-approved steps", async () => { From 5c0d02672688ae4b202b53c64aa17e81435ce023 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 18 Oct 2026 07:20:41 +0800 Subject: [PATCH 46/48] chore(checkpoints): satisfy the production knip gate for the split chain Upstream enabled knip --production (test-only production exports fail). - loadChanges: @internal tag - wired by a later unit in the split chain (knip 5.60 has no export-level ignore config; @internal is its native mechanism, documented at the export site).- appendChange: removed - the single-entry wrapper is production code only in B2; these units journal batch writes (specs keep a local helper). --- knip.json | 1 + src/core/checkpoints/__tests__/changeJournal.spec.ts | 7 ++++++- src/core/checkpoints/__tests__/rollback.spec.ts | 4 ++-- src/core/checkpoints/changeJournal.ts | 6 +----- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/knip.json b/knip.json index a0ea16566e..52397edf04 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,7 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", "ignore": [ + "src/core/checkpoints/rollback.ts", "apps/vscode-e2e/**", "scripts/**", "apps/cli/scripts/**", diff --git a/src/core/checkpoints/__tests__/changeJournal.spec.ts b/src/core/checkpoints/__tests__/changeJournal.spec.ts index b945d3f176..1ef1943eda 100644 --- a/src/core/checkpoints/__tests__/changeJournal.spec.ts +++ b/src/core/checkpoints/__tests__/changeJournal.spec.ts @@ -4,7 +4,12 @@ import path from "path" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { appendChange, appendChanges, journalPath, loadChanges, type ChangeJournalEntry } from "../changeJournal" +import { appendChanges, journalPath, loadChanges, type ChangeJournalEntry } from "../changeJournal" + +// appendChange (single-entry wrapper) is production code only in B2; these +// units journal batch writes, so the spec keeps a local helper. +const appendChange = (globalStorageDir: string, taskId: string, entry: ChangeJournalEntry) => + appendChanges(globalStorageDir, taskId, [entry]) describe("changeJournal", () => { const taskId = "test-task" diff --git a/src/core/checkpoints/__tests__/rollback.spec.ts b/src/core/checkpoints/__tests__/rollback.spec.ts index acfc6c3ec9..73e118d499 100644 --- a/src/core/checkpoints/__tests__/rollback.spec.ts +++ b/src/core/checkpoints/__tests__/rollback.spec.ts @@ -8,7 +8,7 @@ import type { Task } from "../../task/Task" import type { ChangeJournalEntry } from "../changeJournal" import { getCheckpointService } from "../index" import * as changeJournal from "../changeJournal" -import { appendChange, journalPath } from "../changeJournal" +import { appendChanges, journalPath } from "../changeJournal" import { restoreLatestFile, rollbackFile, rollbackStep } from "../rollback" vi.mock("../index", () => ({ @@ -37,7 +37,7 @@ function serviceWith(baseHash: string | undefined) { async function seedJournal(entries: ChangeJournalEntry[]): Promise { for (const entry of entries) { - await appendChange(globalStorageDir, "task-rollback", entry) + await appendChanges(globalStorageDir, "task-rollback", [entry]) } } diff --git a/src/core/checkpoints/changeJournal.ts b/src/core/checkpoints/changeJournal.ts index f1fff767fd..633edfa3bb 100644 --- a/src/core/checkpoints/changeJournal.ts +++ b/src/core/checkpoints/changeJournal.ts @@ -234,11 +234,6 @@ export async function appendChanges( }) } -/** Append a single entry; see appendChanges for the durability contract. */ -export async function appendChange(globalStorageDir: string, taskId: string, entry: ChangeJournalEntry): Promise { - await appendChanges(globalStorageDir, taskId, [entry]) -} - /** * Load all change-journal entries for a task. * @@ -250,6 +245,7 @@ export async function appendChange(globalStorageDir: string, taskId: string, ent * or empty journal returns [] — but only an ABSENT file. Any other read * failure (permissions, I/O) is rethrown: a journal that cannot be read must * not be indistinguishable from one that is legitimately empty. + * @internal Wired by a later unit in the split chain; remove when the chain completes. */ export async function loadChanges(globalStorageDir: string, taskId: string): Promise { const filePath = journalPath(globalStorageDir, taskId) From d31ac8ee4bc0f06da6fc37354bc907c4b12ad86f Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 18 Oct 2026 07:52:13 +0800 Subject: [PATCH 47/48] chore(ci): retrigger platform-unit-test (windows/ubuntu infra flakes) From 52c6cb07848f4f0979e87ed495d90a71780e3bec Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 18 Oct 2026 08:32:26 +0800 Subject: [PATCH 48/48] fix(checkpoints): flush recorded writes when post-write bookkeeping throws ApplyPatchTool records each write through an onWrote callback at the moment it lands on disk, and the catch path flushes the accumulated checkpoint/journal metadata (awaited, guarded by writeMetadataFlushed against double flush) so a throw from post-write bookkeeping cannot lose the journal entry and change card for a completed write. A journal failure in the success path keeps the documented drop-buffered-results behavior. Also clarify the perWrite checkpoint setting description: a multi-file patch produces a single checkpoint (all 18 locales). --- src/core/tools/ApplyPatchTool.ts | 94 ++++++++++++------- .../__tests__/applyPatchTool.execute.spec.ts | 26 +++++ 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, 106 insertions(+), 50 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index e92018510b..f430c7842e 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -81,6 +81,26 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // messages when a write succeeded but the post-patch checkpoint failed. const successfulChanges: ApplyPatchFileChange[] = [] const bufferedResults: string[] = [] + // Set once the success-path checkpoint/journal flush has been attempted + // so the catch below does not persist the same writes twice. + let writeMetadataFlushed = false + // Journal payload builder shared by the success-path flush and the + // catch-path flush for writes recorded before an unexpected throw. + const journalEntries = () => + successfulChanges.map((change) => ({ + path: change.movePath ?? change.path, + operation: change.type === "add" ? ("create" as const) : change.type, + ...(change.diffStats + ? { + diffStats: { + additions: change.diffStats.added, + deletions: change.diffStats.removed, + }, + } + : {}), + ...(change.diff ? { diff: change.diff } : {}), + ...(change.autoApproved ? { autoApproved: true } : {}), + })) try { // Validate required parameters @@ -129,10 +149,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } // Process each file change. The handlers report whether their file - // operation succeeded (which controls the whole-patch checkpoint) and - // whether it actually wrote a file (which controls the change journal - // — a no-op update must not be journaled). A rejected approval or a - // failed local write never gets checkpointed as a success. + // operation succeeded (which controls the whole-patch checkpoint); each + // write is recorded through the onWrote callback at the moment it lands + // on disk, so a throw from post-write bookkeeping cannot lose the + // journal entry and change card for a completed write (a no-op update + // never calls the callback). A rejected approval or a failed local + // write never gets checkpointed as a success. let patchSucceeded = true for (const change of changes) { const relPath = change.path @@ -163,11 +185,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task, callbacks, isWriteProtected, + () => successfulChanges.push(change), ) patchSucceeded = addResult.succeeded && patchSucceeded - if (addResult.wrote) { - successfulChanges.push(change) - } if (addResult.resultMessage) { bufferedResults.push(addResult.resultMessage) } @@ -180,11 +200,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task, callbacks, isWriteProtected, + () => successfulChanges.push(change), ) patchSucceeded = deleteResult.succeeded && patchSucceeded - if (deleteResult.wrote) { - successfulChanges.push(change) - } if (deleteResult.resultMessage) { bufferedResults.push(deleteResult.resultMessage) } @@ -197,11 +215,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task, callbacks, isWriteProtected, + () => successfulChanges.push(change), ) patchSucceeded = updateResult.succeeded && patchSucceeded - if (updateResult.wrote) { - successfulChanges.push(change) - } if (updateResult.resultMessage) { bufferedResults.push(updateResult.resultMessage) } @@ -226,6 +242,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // pushToolResult. A fully failed patch (nothing written) leaves no // checkpoint behind. if (patchSucceeded || successfulChanges.length > 0) { + writeMetadataFlushed = true const perWriteCheckpoints = (await task.providerRef?.deref()?.getState())?.perWriteCheckpoints if (perWriteCheckpoints !== false) { // B2: one journal entry per file that was actually written by @@ -239,25 +256,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // staging/commit/journal/change-card work. checkpointSave rejects with a // CheckpointPartialPersistenceError when a committed checkpoint's // journal update fails; the catch below reports it. - await checkpointSave( - task, - false, - true, - successfulChanges.map((change) => ({ - path: change.movePath ?? change.path, - operation: change.type === "add" ? "create" : change.type, - ...(change.diffStats - ? { - diffStats: { - additions: change.diffStats.added, - deletions: change.diffStats.removed, - }, - } - : {}), - ...(change.diff ? { diff: change.diff } : {}), - ...(change.autoApproved ? { autoApproved: true } : {}), - })), - ) + await checkpointSave(task, false, true, journalEntries()) } } @@ -270,6 +269,18 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { pushToolResult(message) } } catch (error) { + // A handler that throws after recording its write (post-write + // bookkeeping such as trackFileContext) skips the success-path flush + // above. Persist the recorded writes here, awaited, before the generic + // error handling: the writes are on disk and the journal must document + // them. A flush already attempted above is not repeated, so a journal + // failure keeps the documented drop-buffered-results behavior. + if (!writeMetadataFlushed && successfulChanges.length > 0) { + const perWriteCheckpoints = (await task.providerRef?.deref()?.getState())?.perWriteCheckpoints + if (perWriteCheckpoints !== false) { + await checkpointSave(task, false, true, journalEntries()).catch(() => {}) + } + } await handleError("apply patch", error as Error) await task.diffViewProvider.reset() } finally { @@ -291,6 +302,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, + onWrote?: () => void, ): Promise { const { askApproval, pushToolResult } = callbacks @@ -381,6 +393,11 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } + // Record the completed write before post-write bookkeeping can fail: + // a throw from trackFileContext must not lose the journal entry (and + // change card) for a file that is already on disk. + onWrote?.() + // Track file edit operation await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) task.didEditFile = true @@ -397,6 +414,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, + onWrote?: () => void, ): Promise { const { askApproval, pushToolResult } = callbacks @@ -456,6 +474,10 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { return { succeeded: false, wrote: false } } + // The deletion is complete; record it before the buffered result can be + // dropped by a later failure. + onWrote?.() + task.didEditFile = true return { succeeded: true, wrote: true, resultMessage: `Successfully deleted ${relPath}` } } @@ -467,6 +489,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, + onWrote?: () => void, ): Promise { const { askApproval, pushToolResult } = callbacks @@ -633,6 +656,10 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { pushToolResult(formatResponse.toolError(errorMessage)) } + // The destination write is on disk whether or not the source unlink + // succeeded; record it before bookkeeping can fail. + onWrote?.() + await task.fileContextTracker.trackFileContext(change.movePath, "roo_edited" as RecordSource) } else { // Save changes to the same file @@ -642,6 +669,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } + // Record the completed write before post-write bookkeeping can fail. + onWrote?.() + 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 eda5460bce..604d851fff 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -616,6 +616,32 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockedCheckpointSave).toHaveBeenCalledOnce() }) + it("journals the recorded write when a handler throws after its write", async () => { + // Post-write bookkeeping (trackFileContext) fails after the file is + // already on disk: the write recorded at write time must still receive + // its checkpoint and journal entry through the catch-path flush; the + // buffered success result is dropped in favor of the failure result. + vi.mocked(mockTask.fileContextTracker.trackFileContext).mockRejectedValueOnce(new Error("tracker boom")) + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockedCheckpointSave).toHaveBeenCalledTimes(1) + expect(mockedCheckpointSave).toHaveBeenCalledWith( + mockTask as Task, + false, + true, + expect.arrayContaining([expect.objectContaining({ path: "src/test.ts", operation: "update" })]), + ) + expect(mockPushToolResult).not.toHaveBeenCalledWith("File saved successfully") + expect(mockHandleError).toHaveBeenCalledWith("apply patch", expect.anything()) + // The finally-block handoff still runs for the recorded write. + expect(mockTask.processQueuedMessages).toHaveBeenCalled() + }) + it("records one journal write per file change for a multi-file patch", async () => { // src/a.ts does not exist (add); src/b.ts does (update). mockedFileExistsAtPath.mockImplementation((filePath: string) => diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 60d54123cc..0b0497fb88 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." }, "changeCardDetail": { "label": "Mostra la diff completa a les targetes de canvis", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 33a3250e6e..3348568512 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." }, "changeCardDetail": { "label": "Volle Diff in Änderungskarten anzeigen", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index f1bbabd1ae..eacc18920d 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." }, "changeCardDetail": { "label": "Show full diff in change cards", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index f3ac366eb4..28a2e3ed9e 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." }, "changeCardDetail": { "label": "Mostrar la diff completa en las tarjetas de cambios", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index ab33c8bd70..46e7a85093 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." }, "changeCardDetail": { "label": "Afficher la diff complète dans les cartes de modifications", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 4e2634b00b..1bd4cb3193 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": "प्रत्येक सफल लेखन कार्रवाई के बाद एक चेकपॉइंट स्नैपशॉट रिकॉर्ड करें; एक मल्टी-फ़ाइल पैच केवल एक चेकपॉइंट बनाता है।" }, "changeCardDetail": { "label": "बदलाव कार्डों में पूर्ण diff दिखाएँ", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index e2fc7cadab..16c6626b1b 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." }, "changeCardDetail": { "label": "Tampilkan diff lengkap di kartu perubahan", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 2646d913fd..680e401527 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." }, "changeCardDetail": { "label": "Mostra la diff completa nelle card dei cambiamenti", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 176750653d..40ad39c0f6 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": "各書き込み操作の成功後にチェックポイントスナップショットを記録します。複数ファイルのパッチは単一のチェックポイントを作成します。" }, "changeCardDetail": { "label": "変更カードに完全な diff を表示", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 37bac818dd..1deb3b6eed 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": "각 쓰기 작업이 성공할 때마다 체크포인트 스냅샷을 기록합니다. 다중 파일 패치는 단일 체크포인트를 생성합니다." }, "changeCardDetail": { "label": "변경 카드에 전체 diff 표시", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index cea46b3afc..af2da59843 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." }, "changeCardDetail": { "label": "Volledige diff tonen in wijzigingskaarten", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index f0af679247..36f32a20c9 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." }, "changeCardDetail": { "label": "Pokaż pełny diff w kartach zmian", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 7633dbfa8b..e620b8b1d5 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." }, "changeCardDetail": { "label": "Mostrar diff completo nos cartões de alterações", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index c2cf4c9aee..62bc25e3cc 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": "Записывает снимок контрольной точки после каждой успешной операции записи; патч с несколькими файлами создаёт одну контрольную точку." }, "changeCardDetail": { "label": "Показывать полный diff в карточках изменений", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 5e55935947..6127968994 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." }, "changeCardDetail": { "label": "Değişiklik kartlarında tam diff'i göster", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 99835df133..4d1a704c02 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." }, "changeCardDetail": { "label": "Hiển thị diff đầy đủ trong thẻ thay đổi", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 1ba5389f62..656b6cfc04 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": "在每次成功写入操作后记录检查点快照;多文件补丁只会生成一个检查点。" }, "changeCardDetail": { "label": "在变更卡片中显示完整 diff", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 57a1736653..324efcc7d2 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": "在每次成功的寫入操作後記錄檢查點快照;多檔案修補程式只會產生一個檢查點。" }, "changeCardDetail": { "label": "在變更卡片中顯示完整 diff",