From a37dd24f128e38aa4c4261d72ed580de73429fe2 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 15:11:34 +0800 Subject: [PATCH 001/100] feat(file-safety): atomic text publish primitive + safeWriteJson refactor (A4, #1375) --- src/eslint-suppressions.json | 2 +- src/integrations/editor/DiffViewProvider.ts | 3 +- .../editor/__tests__/DiffViewProvider.spec.ts | 28 +- .../__tests__/safeWriteText.spec.ts | 614 ++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 307 +++++++++ src/utils/__tests__/safeWriteJson.test.ts | 87 ++- src/utils/safeWriteJson.ts | 108 +-- 7 files changed, 1040 insertions(+), 109 deletions(-) create mode 100644 src/services/file-safety/__tests__/safeWriteText.spec.ts create mode 100644 src/services/file-safety/safeWriteText.ts diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 36cbfeac5b..77680449be 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1721,7 +1721,7 @@ }, "utils/safeWriteJson.ts": { "@typescript-eslint/no-explicit-any": { - "count": 4 + "count": 3 } }, "utils/tts.ts": { diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index bb3368f063..36f5323f19 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -18,6 +18,7 @@ import { arePathsEqual, getReadablePath } from "../../utils/path" import { formatResponse } from "../../core/prompts/responses" import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics" import { Task } from "../../core/task/Task" +import { safeWriteText } from "../../services/file-safety/safeWriteText" import { DecorationController } from "./DecorationController" @@ -1156,7 +1157,7 @@ export class DiffViewProvider { // Write the content directly to the file await createDirectoriesForFile(absolutePath) - await fs.writeFile(absolutePath, content, "utf-8") + await safeWriteText(absolutePath, content) // Open the document to ensure diagnostics are loaded // When openFile is false (PREVENT_FOCUS_DISRUPTION enabled), we only open in memory diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index aee88f4061..511f0e7f3c 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -15,6 +15,14 @@ vi.mock("fs/promises", () => ({ readFile: vi.fn().mockResolvedValue("file content"), writeFile: vi.fn().mockResolvedValue(undefined), access: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), + rename: vi.fn().mockResolvedValue(undefined), + unlink: vi.fn().mockResolvedValue(undefined), +})) + +// Mock safeWriteText (used by saveDirectly) +vi.mock("../../../services/file-safety/safeWriteText", () => ({ + safeWriteText: vi.fn().mockResolvedValue(undefined), })) // Mock utils @@ -26,6 +34,8 @@ vi.mock("../../../utils/fs", () => ({ vi.mock("path", () => ({ resolve: vi.fn((cwd, relPath) => `${cwd}/${relPath}`), basename: vi.fn((path) => path.split("/").pop()), + dirname: vi.fn((path) => path.split("/").slice(0, -1).join("/") || "/"), + join: (...args: string[]) => args.join("/"), })) // Mock vscode @@ -791,9 +801,9 @@ describe("DiffViewProvider", () => { const result = await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 2000) - // Verify file was written - const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + // Verify file was written via safeWriteText + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify file was opened without focus expect(vscode.window.showTextDocument).toHaveBeenCalledWith( @@ -814,9 +824,9 @@ describe("DiffViewProvider", () => { it("should not open file when openWithoutFocus is false", async () => { await diffViewProvider.saveDirectly("test.ts", "new content", false, true, 1000) - // Verify file was written - const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + // Verify file was written via safeWriteText + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify file was NOT opened expect(vscode.window.showTextDocument).not.toHaveBeenCalled() @@ -829,9 +839,9 @@ describe("DiffViewProvider", () => { await diffViewProvider.saveDirectly("test.ts", "new content", true, false, 1000) - // Verify file was written - const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + // Verify file was written via safeWriteText + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify delay was NOT called expect(mockDelay).not.toHaveBeenCalled() diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts new file mode 100644 index 0000000000..4accc2b71e --- /dev/null +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -0,0 +1,614 @@ +import * as fs from "fs/promises" +import * as fsSync from "fs" +import { execFile } from "child_process" +import type { ChildProcess } from "child_process" +import * as path from "path" + +import { safeWriteText, type SafeWriteTextOptions } from "../safeWriteText" + +// Full mock for fs/promises — all methods are vi.fn() stubs +vi.mock("fs/promises", () => ({ + mkdir: vi.fn(), + access: vi.fn(), + rename: vi.fn(), + unlink: vi.fn(), + realpath: vi.fn(), +})) + +// Full mock for fs — all sync methods are vi.fn() stubs. Stats is a bare +// class stub so tests can build minimal Stats stand-ins via its prototype. +vi.mock("fs", () => ({ + openSync: vi.fn(), + writeSync: vi.fn(), + closeSync: vi.fn(), + mkdirSync: vi.fn(), + fsyncSync: vi.fn(), + chmodSync: vi.fn(), + fchmodSync: vi.fn(), + statSync: vi.fn(), + Stats: class Stats {}, +})) + +// Mock child_process.execFile (callback-based — must invoke callback to resolve) +vi.mock("child_process", () => ({ + execFile: vi.fn((cmd, args, opts, cb) => { + if (typeof cb === "function") cb(null) + }), +})) + +// Minimal stand-in for the ChildProcess that callback-form execFile returns. +const fakeChild = { kill: () => true } as unknown as ChildProcess + +// Helper that mirrors safeWriteText's path resolution exactly +function _resolvedTarget(filePath: string): string { + return path.resolve(filePath) +} +function _dirPath(filePath: string): string { + return path.dirname(_resolvedTarget(filePath)) +} +function _stagingDir(dir: string): string { + return path.join(dir, ".file-safety-staging") +} + +// Minimal Stats stand-in: the SUT only reads `.mode` from it. +function _stats(mode: number): fsSync.Stats { + const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats + Object.assign(s, { mode }) + return s +} + +// ── Test 1: staging file created then cleaned after success ──────────────── + +describe("safeWriteText", () => { + beforeEach(() => { + vi.resetAllMocks() + // After resetAllMocks, vi.fn() returns undefined — restore promise defaults. + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.access).mockResolvedValue(undefined) + vi.mocked(fs.rename).mockResolvedValue(undefined) + vi.mocked(fs.unlink).mockResolvedValue(undefined) + // Existing-target default: a regular 0o644 file. + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o644)) + // Default sync-write behaviour: report that all requested bytes were + // written. The Buffer overload passes (fd, buffer, offset, length), + // so the fourth argument is the requested length. + vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) => + typeof args[3] === "number" ? args[3] : 0, + ) + }) + + describe("staging and cleanup", () => { + it("creates a temp file in the staging dir, fsyncs it, renames to target, and cleans up on success", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) // fd=1 + vi.mocked(fsSync.closeSync).mockReturnValue(undefined) + + await safeWriteText(targetPath, "hello world", { platform: "linux" }) + + // staging dir was created with private permissions — use + // stringContaining to handle Windows path resolution + expect(fsSync.mkdirSync).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), { + recursive: true, + mode: 0o700, + }) + // a pre-existing staging dir is repaired to private permissions too + expect(fsSync.chmodSync).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), 0o700) + + // temp file was opened for writing with the existing target's mode + // (default 0o644 from the statSync default mock) + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), "w", 0o644) + + // content was written as a buffer (partial-write loop, full write) + expect(fsSync.writeSync).toHaveBeenCalledWith(1, Buffer.from("hello world", "utf8"), 0, 11) + + // fsync (sync form) was called on the fd + expect(fsSync.fsyncSync).toHaveBeenCalledWith(1) + + // file was closed + expect(fsSync.closeSync).toHaveBeenCalledWith(1) + + // atomic rename happened — realpath mock returns targetPath, so that's the dest + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + + // no unlink of temp (it's now the committed file; DACL skipped via platform:linux) + expect(fs.unlink).not.toHaveBeenCalled() + }) + }) + + // ── Test 2: fsync ordering ─────────────────────────────────────────────── + + describe("fsync ordering", () => { + it("calls fsync on the fd before close, and rename after close", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // Verify call order: openSync(temp) → writeSync → fsyncSync(temp) + // → closeSync(temp) → rename. On POSIX the parent directory is then + // opened and fsynced after the commit rename, so openSync/fsyncSync/ + // closeSync each have a second (directory) call. + expect(vi.mocked(fsSync.openSync).mock.calls.length).toBe(2) + expect(vi.mocked(fsSync.writeSync).mock.calls.length).toBe(1) + expect(vi.mocked(fsSync.fsyncSync).mock.calls.length).toBe(2) + expect(vi.mocked(fsSync.closeSync).mock.calls.length).toBe(2) + + // the temp file was fully closed before the commit rename + expect(vi.mocked(fsSync.closeSync).mock.calls[0][0]).toBe(1) + expect(fs.rename).toHaveBeenCalled() + }) + }) + + // ── Test 3: simulated failure between write and rename leaves target intact ── + + describe("crash/torn-write safety", () => { + it("simulated failure between fsync and rename leaves the target byte-identical and no temp left behind", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rename).mockRejectedValue(new Error("ENOSPC")) + + await expect(safeWriteText(targetPath, "new data", { platform: "linux" })).rejects.toThrow("ENOSPC") + + // rename was attempted (the failure point) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + + // temp file was cleaned up on failure + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + + // backup was NOT created (backup:false by default), so target is untouched + // The only rename call was temp→target, not a rollback rename + expect(fs.rename).toHaveBeenCalledTimes(1) + }) + + it("a post-commit backup cleanup failure is non-fatal: the target stays committed and no temp is left behind", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The post-commit backup unlink (SUT step 6) fails — the write must + // still succeed; an orphaned backup is the documented acceptable + // outcome, so the failure is swallowed instead of rolling back. + vi.mocked(fs.unlink).mockRejectedValueOnce(new Error("EPERM")) + + await safeWriteText(targetPath, "data", { backup: true, platform: "linux" }) + + // the commit rename (temp -> target) still happened + expect(fs.rename).toHaveBeenNthCalledWith(2, expect.stringContaining("safeWriteText_"), targetPath) + + // the failing cleanup was the post-commit backup unlink + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + + // no rollback rename: the committed target is not restored from the backup + expect(fs.rename).toHaveBeenCalledTimes(2) + + // the staging temp was already committed by the rename; nothing + // temp-shaped is unlinked afterwards + expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + }) + }) + + // ── Test 4: backup:true keeps old safeWriteJson semantics incl. rollback ── + + describe("backup:true", () => { + it("renames target -> backup before commit, deletes backup on success", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "new data", { backup: true }) + + // target was accessed (exists check) + expect(fs.access).toHaveBeenCalledWith(targetPath) + + // first rename: target -> backup + expect(fs.rename).toHaveBeenNthCalledWith(1, targetPath, expect.stringContaining("safeWriteText.bak_")) + + // second rename: temp -> target (realpath mock returns targetPath) + expect(fs.rename).toHaveBeenNthCalledWith(2, expect.stringContaining("safeWriteText_"), targetPath) + + // backup was deleted on success + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + }) + + it("rollback: on failure after rename target->backup, restores backup to target", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // first rename (target->backup) succeeds, second fails + let callCount = 0 + vi.mocked(fs.rename).mockImplementation(async () => { + callCount++ + if (callCount === 1) return // target -> backup + throw new Error("ENOSPC") // temp -> target fails + }) + + await expect(safeWriteText(targetPath, "new data", { backup: true })).rejects.toThrow("ENOSPC") + + // rollback rename is the 3rd call (after target->backup and temp->target failure) + expect(fs.rename).toHaveBeenNthCalledWith(3, expect.stringContaining("safeWriteText.bak_"), targetPath) + + // temp was cleaned up on failure + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + }) + + it("backup:true when target does not exist: no backup created, just commit", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // fs.access resolves for dirPath check, but rejects for target check (backup path) + vi.mocked(fs.access).mockImplementation(async (p) => { + if (typeof p === "string" && p.endsWith("target.txt")) throw { code: "ENOENT" } + }) + + await safeWriteText(targetPath, "new data", { backup: true, platform: "linux" }) + + // no backup rename (target didn't exist) + expect(fs.access).toHaveBeenCalledWith(targetPath) + + // only one rename: temp -> target + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + + // no unlink (no backup to delete; DACL skipped via platform:linux) + expect(fs.unlink).not.toHaveBeenCalled() + }) + }) + + // ── Test 5: win32 DACL path ────────────────────────────────────────────── + + describe("win32 DACL", () => { + it.skipIf(process.platform !== "win32")( + "copies target DACL onto staging file via icacls before rename on Windows", + async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // icacls dump + restore were called (execFile is callback-based mock) + expect(execFile).toHaveBeenCalledTimes(2) + }, + ) + + it("non-win32: DACL path is unreachable when platform is not win32", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // icacls was NOT called on non-win32 + expect(execFile).not.toHaveBeenCalled() + }) + + it("win32 DACL failure falls back to plain rename (never fails the write)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // icacls dump fails — the callback-based mock must invoke cb with an error. + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") cb(new Error("icacls error"), "", "") + return fakeChild + }) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // write succeeded despite icacls failure (fallback to plain rename) + expect(fs.rename).toHaveBeenCalled() + }) + + it("win32 DACL save args are [targetPath, /save, dumpPath, /T] before backup rename", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { backup: true, platform: "win32" }) + + // icacls was called twice (save + restore) + expect(execFile).toHaveBeenCalledTimes(2) + + // First call: save DACL from target before backup rename + const firstCall = vi.mocked(execFile).mock.calls[0] + expect(firstCall[0]).toBe("icacls") + expect(firstCall[1]).toEqual([targetPath, "/save", expect.stringContaining(".acl.tmp"), "/T"]) + + // Second call: restore DACL onto directory after commit rename + const secondCall = vi.mocked(execFile).mock.calls[1] + expect(secondCall[0]).toBe("icacls") + expect(secondCall[1]).toEqual([ + expect.stringContaining("/tmp/test-dir"), + "/restore", + expect.stringContaining(".acl.tmp"), + ]) + + // dump file was unlinked after restore + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + }) + + it("win32 DACL: dump is unlinked even when restore fails", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + // icacls save succeeds, restore fails + let callCount = 0 + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + callCount++ + if (typeof cb === "function") { + cb(callCount === 1 ? null : new Error("icacls restore error"), "", "") + } + return fakeChild + }) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // write succeeded despite restore failure (best-effort) + expect(fs.rename).toHaveBeenCalled() + + // dump file was still unlinked in finally + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + }) + + it("win32 DACL: when target does not exist, no save/restore/dump", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + // fs.access rejects for targetPath (ENOENT), but resolves for dirPath + vi.mocked(fs.access).mockImplementation(async (p) => { + if (typeof p === "string" && p.endsWith("target.txt")) throw { code: "ENOENT" } + return undefined + }) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // icacls was NOT called (target absent → skip DACL entirely) + expect(execFile).not.toHaveBeenCalled() + + // no dump file created or unlinked + expect(fs.unlink).not.toHaveBeenCalled() + }) + }) + + // ── Test 6: pre-written temp path (tempPath option) ────────────────────── + + describe("pre-written temp path", () => { + it("uses the provided tempPath, fsyncs it, and renames to target", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + const customTempPath = "/tmp/custom-temp.tmp" + + // platform:linux skips DACL entirely so this test focuses on tempPath only + await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) + + // openSync was called on the custom temp path (r+ mode for fsync) + expect(fsSync.openSync).toHaveBeenCalledWith(customTempPath, "r+") + + // fsync was called + expect(fsSync.fsyncSync).toHaveBeenCalledWith(1) + + // rename happened — realpath mock returns targetPath + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + + // no unlink of custom temp (caller's concern; DACL skipped via platform:linux) + expect(fs.unlink).not.toHaveBeenCalled() + + // a caller-supplied tempPath must not create the staging directory + expect(fsSync.mkdirSync).not.toHaveBeenCalled() + }) + + it("applies the existing target's mode to a caller-supplied tempPath before publishing", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o600)) + vi.mocked(fsSync.openSync).mockReturnValue(2) + + const customTempPath = "/tmp/custom-temp.tmp" + + await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) + + // the caller-staged temp is fchmod'd to the restrictive target mode so + // the atomic rename cannot widen a 0o600 target (CWE-732 regression) + expect(fsSync.fchmodSync).toHaveBeenCalledWith(2, 0o600) + expect(fsSync.openSync).toHaveBeenCalledWith(customTempPath, "r+") + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + }) + + it("keeps the temp's default mode when the target does not exist yet (ENOENT)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw enoent + }) + vi.mocked(fsSync.openSync).mockReturnValue(2) + + const customTempPath = "/tmp/custom-temp.tmp" + + await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) + + // no existing target, so nothing to preserve and no fchmod on the temp + expect(fsSync.fchmodSync).not.toHaveBeenCalled() + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + }) + + it("opens the temp before applying a read-only target's mode (0o444 does not block the open)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o444)) + vi.mocked(fsSync.openSync).mockReturnValue(3) + + const customTempPath = "/tmp/custom-temp.tmp" + + await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) + + // a 0o444 target must not make openSync(tempPath, "r+") fail: the mode + // is applied with fchmodSync on the already-open fd, after the open + expect(fsSync.openSync).toHaveBeenCalledWith(customTempPath, "r+") + expect(fsSync.fchmodSync).toHaveBeenCalledWith(3, 0o444) + const openIdx = vi.mocked(fsSync.openSync).mock.invocationCallOrder[0] + const fchmodIdx = vi.mocked(fsSync.fchmodSync).mock.invocationCallOrder[0] + expect(openIdx).toBeLessThan(fchmodIdx) + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + }) + }) + + // ── Test 7: symlink handling (Finding 4 regression test) ───────────────── + + describe("symlink handling", () => { + it("a write through a symlink commits onto the resolved referent, never the link path", async () => { + const linkPath = "/tmp/links/link.txt" + const referentPath = "/tmp/targets/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(referentPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(linkPath, "new-content", { platform: "linux" }) + + // The commit rename must target the realpath result (the referent), never the link itself — + // that is what guarantees a write through a symlink replaces the referent's content + // and preserves the link. + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), referentPath) + expect(fs.rename).not.toHaveBeenCalledWith(expect.anything(), linkPath) + }) + + it("when realpath reports ENOENT (target absent), uses the given path as-is", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // rename still happened with the fallback path (path.resolve on /tmp → C:\tmp) + const resolvedFallback = _resolvedTarget(targetPath) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), resolvedFallback) + }) + }) + + // ── Test 8: review fixes (permissions, partial writes, resolution, durability) ── + + describe("review fixes", () => { + it("preserves the target's restrictive mode and tolerates a failed staging-dir permission repair", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o600)) + // a pre-existing staging dir may fail its best-effort permission repair + vi.mocked(fsSync.chmodSync).mockImplementationOnce(() => { + throw new Error("EACCES") + }) + + await safeWriteText(targetPath, "secret", { platform: "linux" }) + + // the staging file inherits the target's 0o600 mode and the write commits + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), "w", 0o600) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("falls back to the 0o644 default when the target does not exist yet", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + }) + + await safeWriteText(targetPath, "fresh", { platform: "linux" }) + + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), "w", 0o644) + }) + + it("loops on short writes until the full content is durable before fsync", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const content = "0123456789" // 10 bytes + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const buffer = Buffer.from(content, "utf8") + // first write (offset 0) reports 4 bytes (short write); the loop continues + vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) => + args[2] === 0 ? 4 : typeof args[3] === "number" ? args[3] : 0, + ) + + await safeWriteText(targetPath, content, { platform: "linux" }) + + // [0,10) reports 4 bytes, then [4,10) writes the remaining 6 + expect(fsSync.writeSync).toHaveBeenCalledTimes(2) + expect(fsSync.writeSync).toHaveBeenNthCalledWith(1, 1, buffer, 0, 10) + expect(fsSync.writeSync).toHaveBeenNthCalledWith(2, 1, buffer, 4, 6) + expect(fsSync.fsyncSync).toHaveBeenCalledWith(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("fsyncs the parent directory after the commit rename on POSIX", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // temp fd=1 then parent-dir fd=2 - distinct fds prove the ordering + vi.mocked(fsSync.openSync).mockReturnValueOnce(1).mockReturnValue(2) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // the directory fsync (fd 2) happens only after the file fsync (fd 1); + // the dir path assertion is path-agnostic (stringContaining) because + // path.dirname renders the same input differently on Windows + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("test-dir"), "r") + expect(fsSync.fsyncSync).toHaveBeenNthCalledWith(1, 1) + expect(fsSync.fsyncSync).toHaveBeenNthCalledWith(2, 2) + expect(fsSync.closeSync).toHaveBeenCalledWith(2) + }) + + it("treats a failed parent-directory fsync as best-effort", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync) + .mockReturnValueOnce(1) + .mockImplementationOnce(() => { + throw new Error("EBADF") + }) + + // the content rename already committed; a missing directory fsync is not fatal + await safeWriteText(targetPath, "data", { platform: "linux" }) + + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("propagates realpath errors (EACCES and code-less) instead of the fallback path", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const eacces = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + vi.mocked(fs.realpath).mockRejectedValueOnce(eacces) + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(eacces) + expect(fs.rename).not.toHaveBeenCalled() + + const plain = new Error("resolution failed") + vi.mocked(fs.realpath).mockRejectedValueOnce(plain) + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(plain) + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("backup:true propagates access errors (EACCES and code-less) instead of skipping the backup", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const eacces = Object.assign(new Error("EACCES"), { code: "EACCES" }) + const plain = new Error("access failed") + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // each write accesses dirPath then target; only the target access rejects + const rejectTarget = (error: Error) => async (p: unknown) => { + if (typeof p === "string" && p.endsWith("target.txt")) throw error + } + vi.mocked(fs.access) + .mockImplementationOnce(rejectTarget(eacces)) + .mockImplementationOnce(rejectTarget(eacces)) + .mockImplementationOnce(rejectTarget(plain)) + .mockImplementationOnce(rejectTarget(plain)) + + await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toEqual( + expect.objectContaining({ code: "EACCES" }), + ) + await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toThrow( + "access failed", + ) + expect(fs.rename).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts new file mode 100644 index 0000000000..71871032e5 --- /dev/null +++ b/src/services/file-safety/safeWriteText.ts @@ -0,0 +1,307 @@ +import * as fs from "fs/promises" +import * as fsSync from "fs" +import * as path from "path" +import { execFile } from "child_process" + +/** + * Options for safeWriteText atomic text publish primitive. + */ +export interface SafeWriteTextOptions { + /** + * When true, preserve the old-file semantics: rename target -> backup first, + * after commit rename delete the backup; on failure roll the backup back to + * the target path. When false (default) the atomic rename simply replaces + * the target -- crash-safe window is zero. + */ + backup?: boolean + + /** + * Platform override for testing. When omitted the real process.platform + * value is used. Set to "win32" or "linux" / "darwin" from tests so that + * both branches are reachable without needing a real Windows runner. + */ + platform?: string + + /** + * Custom execFile runner for testing (e.g. vi.fn). When omitted the real + * child_process.execFile is used. + */ + execFileRunner?: typeof execFile + + /** + * Pre-written temp path to use for the commit phase. When provided, + * safeWriteText skips creating its own staging file and uses this path + * instead (it still fsyncs before rename). Useful when a caller has + * already written data to a temp file via a custom stream. + */ + tempPath?: string +} + +// -- helpers --------------------------------------------------------------- + +/** Generate a unique temp file name in the given directory. */ +function _tempName(dir: string, prefix: string): string { + return path.join(dir, "." + prefix + "_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp") +} + +/** Create a private staging sub-directory inside *dir* so that multiple + * concurrent writes never collide on their temp names. */ +function _stagingDir(dir: string): string { + const sd = path.join(dir, ".file-safety-staging") + // mode:0o700 protects a freshly created staging dir; the best-effort chmod + // repairs a pre-existing one (mkdirSync with recursive:true never chmods an + // existing directory), so staged temp files are never group/world readable. + fsSync.mkdirSync(sd, { recursive: true, mode: 0o700 }) + try { + fsSync.chmodSync(sd, 0o700) + } catch { + // best-effort: chmod denied or unavailable; a fresh dir was still + // created with the requested mode + } + return sd +} + +/** + * fsync a file descriptor so its data is durable before the atomic rename. + * Uses the sync form because this repo's @types/node does not declare + * fs.promises.fsync; the staging file is small, so the blocking window is bounded. + */ +function _fsyncFile(fd: number): void { + fsSync.fsyncSync(fd) +} + +/** Save the DACL of *srcPath* to a dump file on Windows. + * Returns true when the dump was written successfully; false otherwise. + * Never throws — callers treat failure as "skip DACL handling". */ +async function _saveDaclWindows(srcPath: string, dumpPath: string, execFileRunner?: typeof execFile): Promise { + const runner = execFileRunner ?? execFile + try { + await new Promise((resolve, reject) => { + runner("icacls", [srcPath, "/save", dumpPath, "/T"], { windowsHide: true }, (err) => + err ? reject(err) : resolve(), + ) + }) + return true + } catch { + return false + } +} + +/** Restore a DACL dump onto *dirPath* on Windows. + * Best-effort: content is already committed, so failure is non-fatal. */ +async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRunner?: typeof execFile): Promise { + const runner = execFileRunner ?? execFile + try { + await new Promise((resolve, reject) => { + runner("icacls", [dirPath, "/restore", dumpPath], { windowsHide: true }, (err) => + err ? reject(err) : resolve(), + ) + }) + } catch { + // best-effort; content already committed + } +} + +// -- public API ------------------------------------------------------------ + +/** + * Atomic text publish primitive. + * + * 1. Write content to a temp file in a private per-write staging subdir + * (same volume -> atomic rename guaranteed). + * 2. fsync the temp file, then close it. + * 3. win32 only: if target exists save its DACL dump BEFORE backup rename. + * 4. Optionally rename target -> backup (when backup:true). + * 5. Atomic rename temp -> target. + * 6. win32 only: restore DACL onto the directory AFTER commit rename. + * 7. On success: delete backup (if any) and unlink DACL dump. + * 8. On failure: rollback backup to target path; clean up temp + dump. + */ + +/** + * Resolve the publish target: the symlink referent when the given path is an + * existing symlink, the path itself otherwise. Only ENOENT (target absent yet) + * may fall back to the given path; any other resolution error (EACCES, EIO, ...) + * propagates so a broken or unreadable symlink is never written through its + * link path. Callers that stage a temp file themselves must stage it beside + * the resolved path: the commit is a rename onto the referent, and a rename + * across filesystems fails with EXDEV. + */ +export async function resolvePublishTarget(absoluteFilePath: string): Promise { + return fs.realpath(absoluteFilePath).catch((error: unknown) => { + const code = + typeof error === "object" && error !== null && "code" in error + ? (error as { code?: string }).code + : undefined + if (code !== "ENOENT") throw error + return absoluteFilePath + }) +} + +export async function safeWriteText(filePath: string, content: string, options?: SafeWriteTextOptions): Promise { + const absoluteFilePath = path.resolve(filePath) + + // Resolve the symlink referent (see resolvePublishTarget). + const targetPath = await resolvePublishTarget(absoluteFilePath) + const dirPath = path.dirname(targetPath) + + // Ensure parent directory exists (mirrors safeWriteJson behaviour). + await fs.mkdir(dirPath, { recursive: true }) + await fs.access(dirPath) + + // Create the staging directory only when we generate the temp file there; + // callers supplying their own tempPath (e.g. safeWriteJson) must not be left + // with an empty .file-safety-staging directory behind. + const tempPath = options?.tempPath ?? _tempName(_stagingDir(dirPath), "safeWriteText") + + let backupPath: string | null = null + let releaseBackupOnSuccess = false + let daclDumpPath: string | null = null // tracked for cleanup in finally + + try { + // -- Step 1: write content to staging temp file ------------------- + if (!options?.tempPath) { + // Preserve the existing target's permissions: the staging file must + // not be published wider than the file it replaces (a 0o600 target + // must not become 0o644 through the atomic rename). + let targetMode = 0o644 // default for a fresh target + try { + targetMode = fsSync.statSync(targetPath).mode & 0o777 + } catch { + // target does not exist yet - keep the default + } + const fd = fsSync.openSync(tempPath, "w", targetMode) + try { + // Loop until every byte is written: writeSync can report a short + // (partial) write, and publishing a truncated staging file would + // commit corrupt content. + const buffer = Buffer.from(content, "utf8") + let offset = 0 + while (offset < buffer.length) { + offset += fsSync.writeSync(fd, buffer, offset, buffer.length - offset) + } + _fsyncFile(fd) + } finally { + fsSync.closeSync(fd) + } + } else { + // Preserve the existing target's mode (CWE-732): the caller-staged + // temp carries its own creation mode, and publishing it as-is would + // widen a restrictive target (e.g. 0o600 -> 0o644) through rename. + // The mode is applied with fchmodSync on the open fd (AFTER openSync): + // chmodSync on the path before the open would make a read-only target + // (0o400/0o444) fail openSync(tempPath, "r+") with EACCES. + let targetMode: number | null = null + try { + targetMode = fsSync.statSync(targetPath).mode & 0o777 + } catch { + // target does not exist yet - keep the temp's default mode + } + const fd = fsSync.openSync(tempPath, "r+") + try { + if (targetMode !== null) { + fsSync.fchmodSync(fd, targetMode) + } + _fsyncFile(fd) + } finally { + fsSync.closeSync(fd) + } + } + + // -- Step 2 (win32): save DACL BEFORE backup rename --------------- + const platform = options?.platform ?? process.platform + if (platform === "win32") { + try { + await fs.access(targetPath) // target exists? + daclDumpPath = targetPath + ".acl.tmp" + const saved = await _saveDaclWindows(targetPath, daclDumpPath, options?.execFileRunner) + if (!saved) { + daclDumpPath = null // skip DACL handling entirely + } + } catch { + // target does not exist or access failed — no DACL handling + daclDumpPath = null + } + } + + try { + // -- Step 3 (backup:true): rename target -> backup -------------- + if (options?.backup) { + try { + await fs.access(targetPath) + backupPath = _tempName(dirPath, "safeWriteText.bak") + await fs.rename(targetPath, backupPath) + releaseBackupOnSuccess = true + } catch (err: unknown) { + const code = + typeof err === "object" && err !== null && "code" in err + ? (err as { code?: string }).code + : undefined + if (code !== "ENOENT") throw err + } + } + + // -- Step 4: atomic rename temp -> target --------------------- + await fs.rename(tempPath, targetPath) + + // -- Step 4b (POSIX): fsync the parent directory so the directory entry + // changed by the commit rename is durable, not just the file content. + if (platform !== "win32") { + try { + const dirFd = fsSync.openSync(dirPath, "r") + try { + _fsyncFile(dirFd) + } finally { + fsSync.closeSync(dirFd) + } + } catch { + // best-effort: the content rename already committed + } + } + + // -- Step 5 (win32): restore DACL AFTER commit rename --------- + if (platform === "win32" && daclDumpPath !== null) { + const restoredDir = path.dirname(targetPath) + await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) + } + + // -- Step 6 (backup:true): delete backup on success ----------- + if (releaseBackupOnSuccess && backupPath) { + try { + await fs.unlink(backupPath) + } catch { + // non-fatal — orphaned backup is acceptable + } + } + } finally { + // Unlink DACL dump regardless of success/failure in this span. + if (daclDumpPath !== null) { + await fs.unlink(daclDumpPath).catch(() => {}) + } + } + + // tempPath is now the committed file; no cleanup needed. + } catch (originalError: unknown) { + // -- Rollback / cleanup on failure ---------------------------------- + if (backupPath && releaseBackupOnSuccess) { + try { + await fs.rename(backupPath, targetPath) + } catch { + // rollback failed — do not mask original error + } + } + + // Always clean up the staging temp file on failure. + try { + await fs.unlink(tempPath).catch(() => {}) + } catch { + // cleanup failure is non-fatal + } + + if (daclDumpPath !== null) { + await fs.unlink(daclDumpPath).catch(() => {}) + } + + throw originalError + } +} diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 79d08678a0..064207e21f 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -312,9 +312,8 @@ describe("safeWriteJson", () => { expect(content).toEqual(newData) }) - // Test for console error suppression during backup deletion - test("should suppress console.error when backup deletion fails", async () => { - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) // Suppress console.error + // Test for best-effort backup deletion (the backup lifecycle now lives in safeWriteText) + test("does not fail the write when backup deletion fails (orphaned backup is acceptable)", async () => { const initialData = { message: "Initial" } const newData = { message: "New" } @@ -322,18 +321,23 @@ describe("safeWriteJson", () => { // fs.unlink is already vi.fn() — use vi.mocked to avoid double-wrapping via vi.spyOn vi.mocked(fs.unlink).mockImplementation(async (filePath: any) => { - if (filePath.toString().includes(".bak_")) { + if (filePath.toString().includes("safeWriteText.bak_")) { throw new Error("Backup deletion failed") } return fsPromisesActuals.unlink!(filePath) }) + // The write must still succeed: backup cleanup is best-effort inside + // safeWriteText and never masks the committed content. await safeWriteJson(currentTestFilePath, newData) - // Verify console.error was called with the expected message - expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Successfully wrote"), expect.any(Error)) + const content = await readFileContent(currentTestFilePath) + expect(content).toEqual(newData) + + // The orphaned backup is still on disk because its deletion failed. + const entries = await fs.readdir(tempDir) + expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(true) - consoleErrorSpy.mockRestore() vi.mocked(fs.unlink).mockRestore() }) @@ -434,9 +438,9 @@ describe("safeWriteJson", () => { expect(vi.mocked(fs.access)).toHaveBeenCalled() }) - // Test for rollback failure scenario - test("should log error and re-throw original if rollback fails", async () => { - const initialData = { message: "Initial, should be lost if rollback fails" } + // Test for rollback failure scenario (the rollback rename now lives in safeWriteText) + test("re-throws the original error when the rollback rename fails, leaving an orphaned backup", async () => { + const initialData = { message: "Initial, orphaned when rollback fails" } const newData = { message: "New content" } await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify(initialData)) @@ -451,20 +455,20 @@ describe("safeWriteJson", () => { // Second call: tempNewFilePath -> filePath (fail) throw new Error("Primary rename failed") } else if (renameCallCount === 3) { - // Third call: tempBackupFilePath -> filePath (rollback, also fail) + // Third call: backup -> filePath (rollback, also fail) throw new Error("Rollback rename failed") } return fsPromisesActuals.rename!(oldPath, newPath) }) - // Should throw the original error, not the rollback error + // The original error must propagate, not the rollback error await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Primary rename failed") - // Verify console.error was called for the rollback failure - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining("Failed to restore backup"), - expect.objectContaining({ message: "Rollback rename failed" }), - ) + // The rollback failed inside safeWriteText, so the target is gone and + // the backup is orphaned on disk. + expect(await fileExists(currentTestFilePath)).toBe(false) + const entries = await fs.readdir(tempDir) + expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(true) consoleErrorSpy.mockRestore() }) @@ -542,4 +546,53 @@ describe("safeWriteJson", () => { const content = await readFileContent(currentTestFilePath) expect(content).toEqual({ c: 3 }) }) + + // The commit rename targets the symlink referent. The staged temp file must + // therefore be created beside the RESOLVED target — staging beside the link + // would make the commit rename fail with EXDEV when the referent is on + // another filesystem. (Real symlinks are unavailable in this CI lane, so the + // resolution is simulated by mocking fs.realpath the same way.) + test("stages the temp file beside the symlink referent and commits onto it", async () => { + const referentDir = path.join(tempDir, "referent") + const linkDir = path.join(tempDir, "link") + await fs.mkdir(referentDir, { recursive: true }) + await fs.mkdir(linkDir, { recursive: true }) + // caller-visible path (the link) vs the resolved referent path + const callerPath = path.join(linkDir, "test-file.json") + const referentPath = path.join(referentDir, "test-file.json") + // Seed the RESOLVED referent with real content (via the actual fs) so the + // write exercises replacement of an EXISTING referent: the lock is + // acquired on the caller path (realpath:false, which may be absent) while + // the backup + commit happen on the referent. + await fsPromisesActuals.writeFile!(referentPath, JSON.stringify({ seed: true })) + + vi.spyOn(fs, "realpath").mockResolvedValue(referentPath) + + await safeWriteJson(callerPath, { after: true }) + + // the temp file was created next to the resolved referent, NOT beside the link + const tempPaths = vi.mocked(fsSyncActual.createWriteStream).mock.calls.map((call) => String(call[0])) + expect(tempPaths.some((p) => p.startsWith(referentDir + path.sep) && p.includes(".new_"))).toBe(true) + expect(tempPaths.some((p) => p.startsWith(linkDir + path.sep))).toBe(false) + + // the content was committed onto the referent + expect(await readFileContent(referentPath)).toEqual({ after: true }) + }) + + // CWE-732 regression: safeWriteJson stages the temp itself and passes it + // via tempPath, so safeWriteText must apply the existing target's mode to + // the staged temp before the atomic rename — otherwise a 0o600 target is + // published as 0o644. POSIX-only assertion (Windows ignores POSIX modes). + test.skipIf(process.platform === "win32")( + "preserves a restrictive 0o600 target mode through the atomic publish", + async () => { + await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify({ before: true })) + fsSyncActual.chmodSync(currentTestFilePath, 0o600) + + await safeWriteJson(currentTestFilePath, { after: true }) + + expect(fsSyncActual.statSync(currentTestFilePath).mode & 0o777).toBe(0o600) + expect(await readFileContent(currentTestFilePath)).toEqual({ after: true }) + }, + ) }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 957a0bb20f..26af906b43 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -4,6 +4,8 @@ import * as path from "path" import * as lockfile from "proper-lockfile" import { JsonStreamStringify } from "json-stream-stringify" +import { resolvePublishTarget, safeWriteText, type SafeWriteTextOptions } from "../services/file-safety/safeWriteText" + /** * Options for safeWriteJson function */ @@ -31,7 +33,7 @@ export interface SafeWriteJsonOptions { * Safely writes JSON data to a file. * - Creates parent directories if they don't exist * - Uses 'proper-lockfile' for inter-process advisory locking to prevent concurrent writes to the same path. - * - Writes to a temporary file first. + * - Writes to a temporary file first via JsonStreamStringify streaming. * - If the target file exists, it's backed up before being replaced. * - Attempts to roll back and clean up in case of errors. * - Supports pretty-printing with indentation while maintaining streaming efficiency. @@ -41,7 +43,6 @@ export interface SafeWriteJsonOptions { * @param {SafeWriteJsonOptions} options - Optional configuration for JSON formatting. * @returns {Promise} */ - async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJsonOptions): Promise { const absoluteFilePath = path.resolve(filePath) let releaseLock = async () => {} // Initialized to a no-op @@ -51,10 +52,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // Ensure directory structure exists with improved reliability try { - // Create directory with recursive option await fs.mkdir(dirPath, { recursive: true }) - - // Verify directory exists after creation attempt await fs.access(dirPath) } catch (dirError: any) { console.error(`Failed to create or access directory for ${absoluteFilePath}:`, dirError) @@ -84,13 +82,11 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // The releaseLock remains a no-op, so the finally block in the main file operations // try-catch-finally won't try to release an unacquired lock if this path is taken. console.error(`Failed to acquire lock for ${absoluteFilePath}:`, lockError) - // Propagate the lock acquisition error throw lockError } - // Variables to hold the actual paths of temp files if they are created. + // Variables to hold the actual path of the temp file if it is created. let actualTempNewFilePath: string | null = null - let actualTempBackupFilePath: string | null = null try { // If a merge callback was provided, read the current file under the lock @@ -110,79 +106,43 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso data = options.merge(existing, data) } - // Step 1: Write data to a new temporary file. + // Step 1: Write data to a new temporary file via JSON streaming. + // Stage it beside the *resolved* target (the symlink referent when the path is + // a symlink): safeWriteText commits by renaming onto that referent, and a + // rename across filesystems would fail with EXDEV. + const resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) actualTempNewFilePath = path.join( - path.dirname(absoluteFilePath), - `.${path.basename(absoluteFilePath)}.new_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`, + path.dirname(resolvedTargetPath), + ".new_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp", ) await _streamDataToFile(actualTempNewFilePath, data, options?.prettyPrint) - // Step 2: Check if the target file exists. If so, rename it to a backup path. - try { - // Check for target file existence - await fs.access(absoluteFilePath) - // Target exists, create a backup path and rename. - actualTempBackupFilePath = path.join( - path.dirname(absoluteFilePath), - `.${path.basename(absoluteFilePath)}.bak_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`, - ) - await fs.rename(absoluteFilePath, actualTempBackupFilePath) - } catch (accessError: any) { - // Explicitly type accessError - if (accessError.code !== "ENOENT") { - // An error other than "file not found" occurred during access check. - throw accessError - } - // Target file does not exist, so no backup is made. actualTempBackupFilePath remains null. + // Step 2: Delegate backup + commit + rollback to safeWriteText with the + // pre-written temp path. backup:true keeps the old safeWriteJson + // semantics (target -> backup before commit, rollback on failure) and + // keeps the target in place until safeWriteText captures its Windows + // DACL (safeWriteText dumps the DACL before its own backup rename and + // restores it onto the directory after the commit rename). + const textOptions: SafeWriteTextOptions = { + tempPath: actualTempNewFilePath, + backup: true, } - // Step 3: Rename the new temporary file to the target file path. - // This is the main "commit" step. - await fs.rename(actualTempNewFilePath, absoluteFilePath) + await safeWriteText(absoluteFilePath, "", textOptions) - // If we reach here, the new file is successfully in place. - // The original actualTempNewFilePath is now the main file, so we shouldn't try to clean it up as "temp". - // Mark as "used" or "committed" + // If we reach here, the new file is successfully in place and any + // backup has already been handled by safeWriteText. actualTempNewFilePath = null - - // Step 4: If a backup was created, attempt to delete it. - if (actualTempBackupFilePath) { - try { - await fs.unlink(actualTempBackupFilePath) - // Mark backup as handled - actualTempBackupFilePath = null - } catch (unlinkBackupError) { - // Log this error, but do not re-throw. The main operation was successful. - // actualTempBackupFilePath remains set, indicating an orphaned backup. - console.error( - `Successfully wrote ${absoluteFilePath}, but failed to clean up backup ${actualTempBackupFilePath}:`, - unlinkBackupError, - ) - } - } } catch (originalError) { console.error(`Operation failed for ${absoluteFilePath}: [Original Error Caught]`, originalError) const newFileToCleanupWithinCatch = actualTempNewFilePath - const backupFileToRollbackOrCleanupWithinCatch = actualTempBackupFilePath - - // Attempt rollback if a backup was made - if (backupFileToRollbackOrCleanupWithinCatch) { - try { - await fs.rename(backupFileToRollbackOrCleanupWithinCatch, absoluteFilePath) - // Mark as handled, prevent later unlink of this path - actualTempBackupFilePath = null - } catch (rollbackError) { - // actualTempBackupFilePath (outer scope) remains pointing to backupFileToRollbackOrCleanupWithinCatch - console.error( - `[Catch] Failed to restore backup ${backupFileToRollbackOrCleanupWithinCatch} to ${absoluteFilePath}:`, - rollbackError, - ) - } - } - // Cleanup the .new file if it exists + // A failed safeWriteText already rolled the backup (if any) back to + // the target path. Clean up the .new file if it still exists + // (safeWriteText also cleans up its tempPath on failure; this is a + // safety net in case its cleanup missed it). if (newFileToCleanupWithinCatch) { try { await fs.unlink(newFileToCleanupWithinCatch) @@ -194,26 +154,12 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso } } - // Cleanup the .bak file if it still needs to be (i.e., wasn't successfully restored) - if (actualTempBackupFilePath) { - try { - await fs.unlink(actualTempBackupFilePath) - } catch (cleanupError) { - console.error( - `[Catch] Failed to clean up temporary backup file ${actualTempBackupFilePath}:`, - cleanupError, - ) - } - } throw originalError // This MUST be the error that rejects the promise. } finally { // Release the lock in the main finally block. try { - // releaseLock will be the actual unlock function if lock was acquired, - // or the initial no-op if acquisition failed. await releaseLock() } catch (unlockError) { - // Do not re-throw here, as the originalError from the try/catch (if any) is more important. console.error(`Failed to release lock for ${absoluteFilePath}:`, unlockError) } } From 3dd8700c58a1855a54097c1c454161068d73e548 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 10:40:53 +0800 Subject: [PATCH 002/100] feat(file-safety): add version token for the guarded-write path (A1, #1375) Introduces the version token - dev:ino:size:mtimeNs:ctimeNs derived from a single fs.stat - a pure function of a file's on-disk state that every process computing from the same state agrees on. The compare-and-swap write guard (A2/A3) will compare the token observed at read time against the token recomputed before a write to detect stale or replaced files. No production callers yet: this is infrastructure for the file-write safety series (plan: easonLiangWorldedtech/Zoo-Code#33), part of upstream epic #1375. --- src/utils/__tests__/versionToken.spec.ts | 103 +++++++++++++++++++++++ src/utils/versionToken.ts | 57 +++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 src/utils/__tests__/versionToken.spec.ts create mode 100644 src/utils/versionToken.ts diff --git a/src/utils/__tests__/versionToken.spec.ts b/src/utils/__tests__/versionToken.spec.ts new file mode 100644 index 0000000000..56ce269244 --- /dev/null +++ b/src/utils/__tests__/versionToken.spec.ts @@ -0,0 +1,103 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" +import type { Stats } from "fs" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { computeVersionToken, versionTokenOfStat } from "../versionToken" + +// Stats is a class-backed interface without a public constructor, so a plain-object +// test double is the only practical way to pin the token format without real files. +// Last-resort double assertion (test-local, per AGENTS.md). +function makeStats(overrides: Partial = {}): Stats { + const base: Partial = { + dev: 7, + ino: 4242, + size: 1234, + atimeMs: 1_700_000_000_000, + mtimeMs: 1_700_000_000_123.456, + ctimeMs: 1_700_000_000_789.999, + birthtimeMs: 1_700_000_000_000, + } + return { ...base, ...overrides } as unknown as Stats +} + +describe("versionTokenOfStat (A1, epic #1375)", () => { + it("is deterministic for an identical stat", () => { + expect(versionTokenOfStat(makeStats())).toBe(versionTokenOfStat(makeStats())) + }) + + it("matches the documented dev:ino:size:mtimeNs:ctimeNs format", () => { + const expected = [ + "7", + "4242", + "1234", + Math.round(1_700_000_000_123.456 * 1e6).toString(), + Math.round(1_700_000_000_789.999 * 1e6).toString(), + ].join(":") + expect(versionTokenOfStat(makeStats())).toBe(expected) + }) + + it("distinguishes size changes at identical timestamps", () => { + expect(versionTokenOfStat(makeStats({ size: 1235 }))).not.toBe(versionTokenOfStat(makeStats())) + }) + + it("distinguishes mtime changes at identical size", () => { + expect(versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_124 }))).not.toBe(versionTokenOfStat(makeStats())) + }) + + it("distinguishes a replaced file (dev/ino change) with identical content state", () => { + const replaced = makeStats({ dev: 8, ino: 999 }) + expect(versionTokenOfStat(replaced)).not.toBe(versionTokenOfStat(makeStats())) + }) + + it("preserves sub-ms mtime resolution in the ns field", () => { + const wholeMs = versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_123 })) + const halfMsLater = versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_123.5 })) + expect(halfMsLater).not.toBe(wholeMs) + // 0.5 ms = 500_000 ns. The float-derived ns field is quantized (~256 ns at + // this epoch), so allow a bounded drift instead of asserting an exact value. + const diff = Number(halfMsLater.split(":")[3]) - Number(wholeMs.split(":")[3]) + expect(Math.abs(diff - 500_000)).toBeLessThanOrEqual(512) + }) + + it("handles sizes beyond 32 bits without precision loss", () => { + const size = 5_000_000_000 // > 2^32 + const token = versionTokenOfStat(makeStats({ size })) + expect(token).toContain(`:4242:${size}:`) + }) +}) + +describe("computeVersionToken (A1, epic #1375)", () => { + let tmpDir: string + let file: string + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "version-token-")) + file = path.join(tmpDir, "seed.txt") + await fs.writeFile(file, "seed content", "utf8") + }) + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("derives the token from the on-disk state (single stat)", async () => { + const token = await computeVersionToken(file) + expect(token).toBe(versionTokenOfStat(await fs.stat(file))) + }) + + it("changes when the file content changes", async () => { + const before = await computeVersionToken(file) + // Different size + a new mtime — both must move the token. + await fs.writeFile(file, "seed content, extended", "utf8") + await new Promise((resolve) => setTimeout(resolve, 5)) + expect(await computeVersionToken(file)).not.toBe(before) + }) + + it("rejects with ENOENT for an absent file", async () => { + await expect(computeVersionToken(path.join(tmpDir, "absent.txt"))).rejects.toMatchObject({ + code: "ENOENT", + }) + }) +}) diff --git a/src/utils/versionToken.ts b/src/utils/versionToken.ts new file mode 100644 index 0000000000..ee8e9e82f1 --- /dev/null +++ b/src/utils/versionToken.ts @@ -0,0 +1,57 @@ +import { stat } from "fs/promises" +import type { Stats } from "fs" + +/** + * Version token for the compare-and-swap write guard (upstream epic #1375, phase A1). + * + * A token is a pure function of a file's on-disk state, derived from a single + * `fs.stat`, so every process that observes the same file state (a second VS Code + * window, the CLI, the user's own editor tooling) computes the same token. The + * downstream guard phases (A2/A3) compare the token observed at read time with the + * token recomputed just before a write to detect "the file changed since the read" + * (stale) or "the file was replaced by a different file" (dev/ino change). + * + * Format: `dev:ino:size:mtimeNs:ctimeNs` + * + * Resolution note: Node exposes modification/change times as float milliseconds, + * so the ns fields are derived as `Math.round(mtimeMs * 1e6)`. The integer-to-double + * conversion is correctly rounded, so the derivation is deterministic across + * processes, but it is quantized by double precision (~256 ns at the current epoch). + * Two file states whose timestamps differ by less than the quantum derive the same + * ns field; in practice distinct states differ by at least the OS clock resolution + * (and no write workload produces mtimes closer than that), so the guard contract + * holds: same disk state → same token; changed state → a different token in all + * realistic cases. dev, ino and size are exact integers, so any size or file + * identity change is always detected regardless of the timestamp quantum. + */ + +/** Derive an ns-scale field from Node's float milliseconds (see module docs). */ +function nsFromMs(ms: number): string { + return Math.round(ms * 1e6).toString() +} + +/** + * Build the version token from an already-fetched `Stats` — no I/O. + * + * Exported separately from {@link computeVersionToken} so tests can pin the exact + * format against synthetic stats. + */ +export function versionTokenOfStat(stats: Stats): string { + return [ + stats.dev.toString(), + stats.ino.toString(), + stats.size.toString(), + nsFromMs(stats.mtimeMs), + nsFromMs(stats.ctimeMs), + ].join(":") +} + +/** + * Compute the version token for a file (one `fs.stat`). + * + * Rejects with the underlying ENOENT (or equivalent) error when the file is absent; + * how an unobservable target is treated is decided by the guard layer (A3). + */ +export async function computeVersionToken(filePath: string): Promise { + return versionTokenOfStat(await stat(filePath)) +} From ba1332e492a8dd58dadeff4aec956daba4fc6921 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 11:03:05 +0800 Subject: [PATCH 003/100] docs(file-safety): correct ino precision bounds in version token (A1, #1375) Review finding: 'ino is an exact integer' was overstated. Node exposes ino as a float64 number: exact for small POSIX inode numbers, but on modern Windows the file ID exceeds 2^53 so Node's own value is already rounded (verified on node v25: non-zero ino, isSafeInteger=false). It remains deterministic per file (same file -> same token), so the token contract is unchanged; change detection rests on exact dev/size plus the mtime/ctime ns fields. Document the bound instead of claiming exactness. --- src/utils/versionToken.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/utils/versionToken.ts b/src/utils/versionToken.ts index ee8e9e82f1..59a8b348fe 100644 --- a/src/utils/versionToken.ts +++ b/src/utils/versionToken.ts @@ -21,8 +21,15 @@ import type { Stats } from "fs" * ns field; in practice distinct states differ by at least the OS clock resolution * (and no write workload produces mtimes closer than that), so the guard contract * holds: same disk state → same token; changed state → a different token in all - * realistic cases. dev, ino and size are exact integers, so any size or file - * identity change is always detected regardless of the timestamp quantum. + * realistic cases. `dev` and `size` are exact integers. `ino` is Node's + * `number` (float64): exact for small POSIX inode numbers, but on modern Windows + * the underlying file ID exceeds 2^53, so Node's own value is already rounded — + * still deterministic per file (same file → same token), but not guaranteed + * injective across distinct files. Change detection therefore rests on size + + * mtime/ctime: any size change is always detected regardless of the timestamp + * quantum, and a replacement whose size and timestamps are indistinguishable is + * undetectable by any scheme reading the same Stats — the detect-and-reread + * stance (no lockfile) accepts that. */ /** Derive an ns-scale field from Node's float milliseconds (see module docs). */ From 68130133eda346d9910ea3a6ad6aaa41090b61df Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 11:27:00 +0800 Subject: [PATCH 004/100] fix(file-safety): derive the version token from exact BigInt stats (A1, #1375) CodeRabbit finding on this PR: the default numeric fs.stat() loses precision (values above 2^53 are rounded, including Windows file IDs) and the ms->ns derivation introduced a double-precision quantum. Fixed by fetching the stat with { bigint: true }: all five token fields (dev, ino, size, mtimeNs, ctimeNs) are exact BigInt values rendered as decimal strings, with no float anywhere. The sub-ms test now asserts an exact 1_000 ns delta instead of bounded drift, and a regression test pins a size of 10^16+1 (> Number.MAX_SAFE_INTEGER). --- src/utils/__tests__/versionToken.spec.ts | 82 ++++++++++++------------ src/utils/versionToken.ts | 62 +++++++----------- 2 files changed, 65 insertions(+), 79 deletions(-) diff --git a/src/utils/__tests__/versionToken.spec.ts b/src/utils/__tests__/versionToken.spec.ts index 56ce269244..3e2ca26b5c 100644 --- a/src/utils/__tests__/versionToken.spec.ts +++ b/src/utils/__tests__/versionToken.spec.ts @@ -1,25 +1,33 @@ import * as fs from "fs/promises" import * as os from "os" import * as path from "path" -import type { Stats } from "fs" +import type { BigIntStats } from "fs" import { afterEach, beforeEach, describe, expect, it } from "vitest" import { computeVersionToken, versionTokenOfStat } from "../versionToken" -// Stats is a class-backed interface without a public constructor, so a plain-object -// test double is the only practical way to pin the token format without real files. -// Last-resort double assertion (test-local, per AGENTS.md). -function makeStats(overrides: Partial = {}): Stats { - const base: Partial = { - dev: 7, - ino: 4242, - size: 1234, - atimeMs: 1_700_000_000_000, - mtimeMs: 1_700_000_000_123.456, - ctimeMs: 1_700_000_000_789.999, - birthtimeMs: 1_700_000_000_000, +// BigIntStats is a class-backed interface without a public constructor, so a +// plain-object test double is the only practical way to pin the token format +// without real files. Last-resort double assertion (test-local, per AGENTS.md). +function makeStats(overrides: Partial = {}): BigIntStats { + // This repo's @types/node models every StatsBase field (including the *Ms + // fields) as the parameter type T, so all values here are bigint literals; + // the token only reads the *Ns fields. Single-step downcast from Partial to + // the full type (BigIntStats has no public constructor). + const base: Partial = { + dev: 7n, + ino: 4242n, + size: 1234n, + atimeMs: 1_700_000_000_000n, + mtimeMs: 1_700_000_000_123n, + ctimeMs: 1_700_000_000_789n, + birthtimeMs: 1_700_000_000_000n, + atimeNs: 1_700_000_000_000_000_000n, + mtimeNs: 1_700_000_000_123_456_789n, + ctimeNs: 1_700_000_000_789_999_999n, + birthtimeNs: 1_700_000_000_000_000_000n, } - return { ...base, ...overrides } as unknown as Stats + return { ...base, ...overrides } as BigIntStats } describe("versionTokenOfStat (A1, epic #1375)", () => { @@ -27,44 +35,38 @@ describe("versionTokenOfStat (A1, epic #1375)", () => { expect(versionTokenOfStat(makeStats())).toBe(versionTokenOfStat(makeStats())) }) - it("matches the documented dev:ino:size:mtimeNs:ctimeNs format", () => { - const expected = [ - "7", - "4242", - "1234", - Math.round(1_700_000_000_123.456 * 1e6).toString(), - Math.round(1_700_000_000_789.999 * 1e6).toString(), - ].join(":") - expect(versionTokenOfStat(makeStats())).toBe(expected) + it("matches the documented dev:ino:size:mtimeNs:ctimeNs format with exact decimal fields", () => { + expect(versionTokenOfStat(makeStats())).toBe("7:4242:1234:1700000000123456789:1700000000789999999") }) it("distinguishes size changes at identical timestamps", () => { - expect(versionTokenOfStat(makeStats({ size: 1235 }))).not.toBe(versionTokenOfStat(makeStats())) + expect(versionTokenOfStat(makeStats({ size: 1235n }))).not.toBe(versionTokenOfStat(makeStats())) }) - it("distinguishes mtime changes at identical size", () => { - expect(versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_124 }))).not.toBe(versionTokenOfStat(makeStats())) + it("distinguishes a one-nanosecond mtime change", () => { + expect(versionTokenOfStat(makeStats({ mtimeNs: 1_700_000_000_123_456_790n }))).not.toBe( + versionTokenOfStat(makeStats()), + ) }) it("distinguishes a replaced file (dev/ino change) with identical content state", () => { - const replaced = makeStats({ dev: 8, ino: 999 }) + const replaced = makeStats({ dev: 8n, ino: 999n }) expect(versionTokenOfStat(replaced)).not.toBe(versionTokenOfStat(makeStats())) }) - it("preserves sub-ms mtime resolution in the ns field", () => { - const wholeMs = versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_123 })) - const halfMsLater = versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_123.5 })) - expect(halfMsLater).not.toBe(wholeMs) - // 0.5 ms = 500_000 ns. The float-derived ns field is quantized (~256 ns at - // this epoch), so allow a bounded drift instead of asserting an exact value. - const diff = Number(halfMsLater.split(":")[3]) - Number(wholeMs.split(":")[3]) - expect(Math.abs(diff - 500_000)).toBeLessThanOrEqual(512) + it("renders nanosecond resolution exactly (no float quantization)", () => { + const base = versionTokenOfStat(makeStats()) + const plusOneMicrosecond = versionTokenOfStat(makeStats({ mtimeNs: 1_700_000_000_123_457_789n })) + // 1_000 ns apart — the BigInt derivation must keep the delta exact. + const baseNs = BigInt(base.split(":")[3]) + const microNs = BigInt(plusOneMicrosecond.split(":")[3]) + expect(microNs - baseNs).toBe(1_000n) }) - it("handles sizes beyond 32 bits without precision loss", () => { - const size = 5_000_000_000 // > 2^32 + it("handles sizes beyond Number.MAX_SAFE_INTEGER without precision loss", () => { + const size = 10_000_000_000_000_001n // 10^16 + 1 > 2^53 const token = versionTokenOfStat(makeStats({ size })) - expect(token).toContain(`:4242:${size}:`) + expect(token).toBe(`7:4242:${size}:1700000000123456789:1700000000789999999`) }) }) @@ -82,9 +84,9 @@ describe("computeVersionToken (A1, epic #1375)", () => { await fs.rm(tmpDir, { recursive: true, force: true }) }) - it("derives the token from the on-disk state (single stat)", async () => { + it("derives the token from the on-disk state (single bigint stat)", async () => { const token = await computeVersionToken(file) - expect(token).toBe(versionTokenOfStat(await fs.stat(file))) + expect(token).toBe(versionTokenOfStat(await fs.stat(file, { bigint: true }))) }) it("changes when the file content changes", async () => { diff --git a/src/utils/versionToken.ts b/src/utils/versionToken.ts index 59a8b348fe..1738280087 100644 --- a/src/utils/versionToken.ts +++ b/src/utils/versionToken.ts @@ -1,64 +1,48 @@ import { stat } from "fs/promises" -import type { Stats } from "fs" +import type { BigIntStats } from "fs" /** * Version token for the compare-and-swap write guard (upstream epic #1375, phase A1). * * A token is a pure function of a file's on-disk state, derived from a single - * `fs.stat`, so every process that observes the same file state (a second VS Code - * window, the CLI, the user's own editor tooling) computes the same token. The - * downstream guard phases (A2/A3) compare the token observed at read time with the - * token recomputed just before a write to detect "the file changed since the read" - * (stale) or "the file was replaced by a different file" (dev/ino change). + * `fs.stat(path, { bigint: true })`, so every process that observes the same file + * state (a second VS Code window, the CLI, the user's own editor tooling) computes + * the same token. The downstream guard phases (A2/A3) compare the token observed at + * read time with the token recomputed just before a write to detect "the file + * changed since the read" (stale) or "the file was replaced by a different file" + * (dev/ino change). * * Format: `dev:ino:size:mtimeNs:ctimeNs` * - * Resolution note: Node exposes modification/change times as float milliseconds, - * so the ns fields are derived as `Math.round(mtimeMs * 1e6)`. The integer-to-double - * conversion is correctly rounded, so the derivation is deterministic across - * processes, but it is quantized by double precision (~256 ns at the current epoch). - * Two file states whose timestamps differ by less than the quantum derive the same - * ns field; in practice distinct states differ by at least the OS clock resolution - * (and no write workload produces mtimes closer than that), so the guard contract - * holds: same disk state → same token; changed state → a different token in all - * realistic cases. `dev` and `size` are exact integers. `ino` is Node's - * `number` (float64): exact for small POSIX inode numbers, but on modern Windows - * the underlying file ID exceeds 2^53, so Node's own value is already rounded — - * still deterministic per file (same file → same token), but not guaranteed - * injective across distinct files. Change detection therefore rests on size + - * mtime/ctime: any size change is always detected regardless of the timestamp - * quantum, and a replacement whose size and timestamps are indistinguishable is - * undetectable by any scheme reading the same Stats — the detect-and-reread - * stance (no lockfile) accepts that. + * Precision: the stat is fetched in `bigint` mode, so all five fields are exact + * `BigInt` values rendered as decimal strings — no float is involved anywhere. + * There is therefore no precision loss for large sizes or inodes (a Windows file ID + * exceeds 2^53 and is still exact), and the ns timestamps are the kernel's exact + * nanosecond values rather than a ms→ns derivation (no ~256 ns double-precision + * quantum). Guarantee: same disk state → same token, deterministic across + * processes; any change to size, file identity, or mtime/ctime → a different token. + * + * Platform note: on POSIX `ctime` is the last file-status change; on Windows it is + * the file creation time. The token only requires it to move when the file's + * metadata is replaced, which holds on both. */ -/** Derive an ns-scale field from Node's float milliseconds (see module docs). */ -function nsFromMs(ms: number): string { - return Math.round(ms * 1e6).toString() -} - /** - * Build the version token from an already-fetched `Stats` — no I/O. + * Build the version token from an already-fetched `BigIntStats` — no I/O. * * Exported separately from {@link computeVersionToken} so tests can pin the exact * format against synthetic stats. */ -export function versionTokenOfStat(stats: Stats): string { - return [ - stats.dev.toString(), - stats.ino.toString(), - stats.size.toString(), - nsFromMs(stats.mtimeMs), - nsFromMs(stats.ctimeMs), - ].join(":") +export function versionTokenOfStat(stats: BigIntStats): string { + return [stats.dev, stats.ino, stats.size, stats.mtimeNs, stats.ctimeNs].map((value) => value.toString()).join(":") } /** - * Compute the version token for a file (one `fs.stat`). + * Compute the version token for a file (one `fs.stat` in bigint mode). * * Rejects with the underlying ENOENT (or equivalent) error when the file is absent; * how an unobservable target is treated is decided by the guard layer (A3). */ export async function computeVersionToken(filePath: string): Promise { - return versionTokenOfStat(await stat(filePath)) + return versionTokenOfStat(await stat(filePath, { bigint: true })) } From 588b95fdc2420feb9163b0d21dd1d1f0bbfe7bf9 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 14:52:58 +0800 Subject: [PATCH 005/100] feat(task): per-task file observation registry (A2, #1375) --- src/core/task/Task.ts | 2 + .../__tests__/observationRegistry.spec.ts | 72 +++++++++++ src/core/task/observationRegistry.ts | 47 ++++++++ src/core/tools/ReadFileTool.ts | 12 ++ src/core/tools/__tests__/readFileTool.spec.ts | 113 ++++++++++++++++++ 5 files changed, 246 insertions(+) create mode 100644 src/core/task/__tests__/observationRegistry.spec.ts create mode 100644 src/core/task/observationRegistry.ts diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 349d9c51d3..8977b60830 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -103,6 +103,7 @@ import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" import { restoreTodoListForTask } from "../tools/UpdateTodoListTool" import { FileContextTracker } from "../context-tracking/FileContextTracker" import { RooIgnoreController } from "../ignore/RooIgnoreController" +import { ObservationRegistry } from "./observationRegistry" import { RooProtectedController } from "../protect/RooProtectedController" import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message" import { NativeToolCallParser } from "../assistant-message/NativeToolCallParser" @@ -181,6 +182,7 @@ export class Task extends EventEmitter implements TaskLike { readonly parentTask: Task | undefined = undefined readonly taskNumber: number readonly workspacePath: string + readonly observationRegistry = new ObservationRegistry() /** * The mode associated with this task. Persisted across sessions diff --git a/src/core/task/__tests__/observationRegistry.spec.ts b/src/core/task/__tests__/observationRegistry.spec.ts new file mode 100644 index 0000000000..51b73aabde --- /dev/null +++ b/src/core/task/__tests__/observationRegistry.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from "vitest" + +import { ObservationRegistry } from "../observationRegistry" + +describe("ObservationRegistry", () => { + it("observe → get returns the recorded version and observedAt", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "1:2:300:4000000000:5000000000") + + const obs = reg.get("/a/b/c.ts") + expect(obs).toBeDefined() + expect(obs!.version).toBe("1:2:300:4000000000:5000000000") + expect(typeof obs!.observedAt).toBe("number") + }) + + it("re-observe replaces the entry with a fresh observedAt", () => { + vi.useFakeTimers() + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1") + const first = reg.get("/a/b/c.ts")! + expect(first.version).toBe("v1") + + vi.advanceTimersByTime(50) + reg.observe("/a/b/c.ts", "v2") + const second = reg.get("/a/b/c.ts")! + expect(second.version).toBe("v2") + expect(second.observedAt).toBeGreaterThan(first.observedAt) + + vi.useRealTimers() + }) + + it("has returns true for observed paths, false otherwise", () => { + const reg = new ObservationRegistry() + reg.observe("/x.ts", "t1") + expect(reg.has("/x.ts")).toBe(true) + expect(reg.has("/y.ts")).toBe(false) + }) + + it("size reflects the number of observed entries", () => { + const reg = new ObservationRegistry() + expect(reg.size).toBe(0) + reg.observe("/a.ts", "t1") + reg.observe("/b.ts", "t2") + expect(reg.size).toBe(2) + }) + + it("clear removes all entries and resets size to 0", () => { + const reg = new ObservationRegistry() + reg.observe("/a.ts", "t1") + reg.observe("/b.ts", "t2") + reg.clear() + expect(reg.size).toBe(0) + expect(reg.get("/a.ts")).toBeUndefined() + expect(reg.has("/b.ts")).toBe(false) + }) + + it("get on empty registry returns undefined", () => { + const reg = new ObservationRegistry() + expect(reg.get("/any.ts")).toBeUndefined() + }) + + it("separate instances are independent — observing in one does not appear in the other", () => { + const regA = new ObservationRegistry() + const regB = new ObservationRegistry() + regA.observe("/shared.ts", "v1") + expect(regA.get("/shared.ts")).toBeDefined() + expect(regB.get("/shared.ts")).toBeUndefined() + regB.observe("/shared.ts", "v2") + expect(regA.get("/shared.ts")!.version).toBe("v1") + expect(regB.get("/shared.ts")!.version).toBe("v2") + }) +}) diff --git a/src/core/task/observationRegistry.ts b/src/core/task/observationRegistry.ts new file mode 100644 index 0000000000..871f80225b --- /dev/null +++ b/src/core/task/observationRegistry.ts @@ -0,0 +1,47 @@ +/** + * Per-task file observation registry (upstream epic #1375, phase A2). + * + * Each Task owns its own instance so parent and subtask observations are + * independent. The S4 guarded-write will compare these versions against the + * token recomputed pre-write to detect stale reads or file replacement. + * + * Pure in-memory — zero I/O, no dependencies. No behavior change in this PR: + * observations are recorded but not consulted. + */ + +export interface FileObservation { + /** Version token derived from on-disk fs.stat (bigint mode). */ + version: string + /** Millisecond timestamp when the observation was recorded. */ + observedAt: number +} + +export class ObservationRegistry { + private readonly entries = new Map() + + /** + * Record an observation for a file at its absolute path. + * + * Re-observing replaces the entry with a fresh observedAt timestamp and + * the new version token. + */ + observe(absolutePath: string, version: string): void { + this.entries.set(absolutePath, { version, observedAt: Date.now() }) + } + + get(absolutePath: string): FileObservation | undefined { + return this.entries.get(absolutePath) + } + + has(absolutePath: string): boolean { + return this.entries.has(absolutePath) + } + + clear(): void { + this.entries.clear() + } + + get size(): number { + return this.entries.size + } +} diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 2107cfe21b..6e222e1309 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -16,6 +16,7 @@ import type { ReadFileParams, ReadFileMode, ReadFileToolParams, FileEntry, LineR import { isLegacyReadFileParams, type ClineSayTool } from "@roo-code/types" import { Task } from "../task/Task" +import { computeVersionToken } from "../../utils/versionToken" import { formatResponse } from "../prompts/responses" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { isPathOutsideWorkspace } from "../../utils/pathUtils" @@ -220,6 +221,11 @@ export class ReadFileTool extends BaseTool<"read_file"> { await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + // A2 (plan #33 / epic #1375): record the observed on-disk version for the future write guard. + // A stat failure leaves the target unobserved and never fails the read. + const version = await computeVersionToken(fullPath).catch(() => undefined) + if (version) task.observationRegistry.observe(fullPath, version) + updateFileResult(relPath, { nativeContent: `File: ${relPath}\n${result}`, }) @@ -799,6 +805,12 @@ export class ReadFileTool extends BaseTool<"read_file"> { // Track file in context await task.fileContextTracker.trackFileContext(relPath, "read_tool") + + // A2 (plan #33 / epic #1375): mirror the native path — record the observed + // on-disk version so legacy-format reads also feed the future write guard. + // A stat failure leaves the target unobserved and never fails the read. + const version = await computeVersionToken(fullPath).catch(() => undefined) + if (version) task.observationRegistry.observe(fullPath, version) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) results.push(`File: ${relPath}\nError: ${errorMsg}`) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 6c9e177d38..6108e78151 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -13,10 +13,16 @@ */ import path from "path" +import type { Stats } from "fs" + +import type { LegacyReadFileParams } from "@roo-code/types" import { isBinaryFile } from "isbinaryfile" import { readFileTool, ReadFileTool } from "../ReadFileTool" +import type { Task } from "../../task/Task" +import { ObservationRegistry } from "../../task/observationRegistry" +import { computeVersionToken } from "../../../utils/versionToken" import { formatResponse } from "../../prompts/responses" import { validateImageForProcessing, @@ -136,6 +142,7 @@ interface MockTaskOptions { rooIgnoreAllowed?: boolean maxImageFileSize?: number maxTotalImageSize?: number + observationRegistry?: ObservationRegistry } function createMockTask(options: MockTaskOptions = {}) { @@ -143,6 +150,9 @@ function createMockTask(options: MockTaskOptions = {}) { return { cwd: "/test/workspace", + // Mirror Task: every task always owns an observation registry (A2, #1375). + // Tests asserting on observations pass their own instance via options. + observationRegistry: options.observationRegistry ?? new ObservationRegistry(), api: { getModel: vi.fn().mockReturnValue({ info: { supportsImages }, @@ -1489,5 +1499,108 @@ describe("ReadFileTool", () => { expect(mockTask.didToolFailInCurrentTurn).toBe(true) }) + + describe("observation registry", () => { + it("records an observation on successful read of an existing file", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // Override the beforeEach default stat mock with proper BigIntStats. + mockedFsStat.mockResolvedValue({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + // Cast: the mock only implements the members the tool and versionToken read. + } as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + // Spy on observe to capture the exact key used (Windows path.resolve may use backslashes). + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "existing.ts" }, mockTask as unknown as Task, callbacks) + + // Verify the tool called observe exactly once with a valid token. + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, calledVersion] = observeSpy.mock.calls[0] + expect(calledPath).toContain("existing.ts") + expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) + + // Verify get() returns the same data using the spy-captured key. + const obs = reg.get(calledPath) + expect(obs).toBeDefined() + expect(obs!.version).toBe(calledVersion) + }) + + it("a failed read (absent path) leaves the registry size 0 and does not throw", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockRejectedValue(new Error("ENOENT")) + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "missing.ts" }, mockTask as unknown as Task, callbacks) + + // observationRegistry is guaranteed present because we passed it in createMockTask. + const reg = mockTask.observationRegistry + expect(reg).toBeDefined() + expect(reg!.size).toBe(0) + }) + + it("records an observation for legacy-format reads of existing files", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + // Cast: the mock only implements the members the tool and versionToken read. + } as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Typed legacy (pre-refactor) params: the multi-file format with the + // _legacyFormat discriminant (see LegacyReadFileParams). + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy.ts" }], + _legacyFormat: true, + } + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, calledVersion] = observeSpy.mock.calls[0] + expect(calledPath).toContain("legacy.ts") + expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) + }) + + it("two separate Task-owned registries are independent", async () => { + const regA = new ObservationRegistry() + const regB = new ObservationRegistry() + regA.observe("/shared.ts", "v1") + expect(regA.get("/shared.ts")!.version).toBe("v1") + expect(regB.get("/shared.ts")).toBeUndefined() + regB.observe("/shared.ts", "v2") + expect(regA.get("/shared.ts")!.version).toBe("v1") + expect(regB.get("/shared.ts")!.version).toBe("v2") + }) + }) }) }) From 7a25fc076d2c9f0d488c21bba312dac8dd0bdb95 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 22:09:22 +0800 Subject: [PATCH 006/100] feat(tools): guarded write CAS core with per-path FIFO chain (S4a, #1375) --- src/core/tools/ReadFileTool.ts | 36 +- src/core/tools/__tests__/guardedWrite.spec.ts | 454 ++++++++++++++++++ src/core/tools/__tests__/readFileTool.spec.ts | 211 +++++++- src/core/tools/guardedWrite.ts | 260 ++++++++++ src/eslint-suppressions.json | 2 +- src/utils/__tests__/safeWriteJson.test.ts | 77 +++ src/utils/safeWriteJson.ts | 31 +- 7 files changed, 1051 insertions(+), 20 deletions(-) create mode 100644 src/core/tools/__tests__/guardedWrite.spec.ts create mode 100644 src/core/tools/guardedWrite.ts diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 6e222e1309..3647c631ee 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -16,7 +16,7 @@ import type { ReadFileParams, ReadFileMode, ReadFileToolParams, FileEntry, LineR import { isLegacyReadFileParams, type ClineSayTool } from "@roo-code/types" import { Task } from "../task/Task" -import { computeVersionToken } from "../../utils/versionToken" +import { versionTokenOfStat } from "../../utils/versionToken" import { formatResponse } from "../prompts/responses" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { isPathOutsideWorkspace } from "../../utils/pathUtils" @@ -215,6 +215,9 @@ export class ReadFileTool extends BaseTool<"read_file"> { // Read text file content with lossy UTF-8 conversion // Reading as Buffer first allows graceful handling of non-UTF8 bytes // (they become U+FFFD replacement characters instead of throwing) + // A2 (epic #1375): capture the on-disk token before the read so a mutation + // landing mid-read is detected by the post-read stat below. + const preReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) const buffer = await fs.readFile(fullPath) const fileContent = buffer.toString("utf-8") const result = this.processTextFile(fileContent, entry) @@ -222,9 +225,18 @@ export class ReadFileTool extends BaseTool<"read_file"> { await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) // A2 (plan #33 / epic #1375): record the observed on-disk version for the future write guard. - // A stat failure leaves the target unobserved and never fails the read. - const version = await computeVersionToken(fullPath).catch(() => undefined) - if (version) task.observationRegistry.observe(fullPath, version) + // The token is captured before AND after the read; the target is observed only + // when both match — a mutation between the two stats means the content the model + // received is not the on-disk state, and observing it would let a later write + // match a token the model never saw. A stat failure leaves the target + // unobserved and never fails the read. + const postReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) + if (preReadStats && postReadStats) { + const preReadToken = versionTokenOfStat(preReadStats) + if (preReadToken === versionTokenOfStat(postReadStats)) { + task.observationRegistry.observe(fullPath, preReadToken) + } + } updateFileResult(relPath, { nativeContent: `File: ${relPath}\n${result}`, @@ -774,6 +786,9 @@ export class ReadFileTool extends BaseTool<"read_file"> { } // Read text file + // A2 (epic #1375): capture the on-disk token before the read so a mutation + // landing mid-read is detected by the post-read stat below. + const preReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) const rawContent = await fs.readFile(fullPath, "utf8") // Handle line ranges if specified @@ -808,9 +823,16 @@ export class ReadFileTool extends BaseTool<"read_file"> { // A2 (plan #33 / epic #1375): mirror the native path — record the observed // on-disk version so legacy-format reads also feed the future write guard. - // A stat failure leaves the target unobserved and never fails the read. - const version = await computeVersionToken(fullPath).catch(() => undefined) - if (version) task.observationRegistry.observe(fullPath, version) + // Observe only when the pre-read and post-read tokens match (a mutation between + // them means the returned content is not the on-disk state). A stat failure + // leaves the target unobserved and never fails the read. + const postReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) + if (preReadStats && postReadStats) { + const preReadToken = versionTokenOfStat(preReadStats) + if (preReadToken === versionTokenOfStat(postReadStats)) { + task.observationRegistry.observe(fullPath, preReadToken) + } + } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) results.push(`File: ${relPath}\nError: ${errorMsg}`) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts new file mode 100644 index 0000000000..122c1c6d9b --- /dev/null +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -0,0 +1,454 @@ +/** + * Tests for the guarded-write compare-and-swap core (upstream epic #1375, + * phase A4a). + * + * Covers guard selection through the S2 observation registry, version-token + * CAS, remediation messages, and the per-absolute-path FIFO chain: FIFO + * ordering, exactly-one winner under concurrency, no wedge after a rejected + * link, and independence across paths. + */ + +import * as fs from "fs/promises" +import * as path from "path" + +import { describe, expect, it, beforeEach, vi } from "vitest" + +import { createIfAbsent, guardedWrite, replaceIfVersion, resetChain } from "../guardedWrite" +import { safeWriteText } from "../../../services/file-safety/safeWriteText" +import { computeVersionToken } from "../../../utils/versionToken" +import { ObservationRegistry } from "../../task/observationRegistry" +import type { Task } from "../../task/Task" + +// -- Mocks ------------------------------------------------------------------- + +vi.mock("fs/promises", () => ({ + access: vi.fn(), + stat: vi.fn(), +})) + +vi.mock("../../../utils/versionToken", () => ({ + computeVersionToken: vi.fn(), +})) + +vi.mock("../../../services/file-safety/safeWriteText", () => ({ + safeWriteText: vi.fn(), +})) + +const mockedFsAccess = vi.mocked(fs.access) +const mockedComputeVersionToken = vi.mocked(computeVersionToken) +const mockedSafeWriteText = vi.mocked(safeWriteText) + +// -- Fixtures ---------------------------------------------------------------- + +const WORKSPACE = "/test/workspace" + +/** Resolve a fixture path the same way guardedWrite resolves task.cwd-relative paths. */ +const abs = (relPath: string): string => path.resolve(WORKSPACE, relPath) + +interface MockTaskOptions { + cwd?: string + observationRegistry?: ObservationRegistry +} + +/** + * Minimal structural Task: guardedWrite only reads task.cwd and + * task.observationRegistry. The real Task constructor needs the full provider + * machinery, so a single documented double cast stands in for the class. + */ +function createMockTask(options: MockTaskOptions = {}): Task { + const task = { + cwd: options.cwd ?? WORKSPACE, + observationRegistry: options.observationRegistry ?? new ObservationRegistry(), + } + return task as unknown as Task +} + +// -- Tests ------------------------------------------------------------------- + +describe("guardedWrite (S4a, epic #1375)", () => { + beforeEach(() => { + vi.resetAllMocks() + resetChain() + }) + + describe("unobserved create", () => { + it("succeeds when the file is absent and publishes via safeWriteText", async () => { + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask() + + await guardedWrite(task, "new-file.txt", "hello", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello") + }) + + it("fails with the read-first remediation when the file exists - nothing published", async () => { + mockedFsAccess.mockResolvedValue(undefined) + const task = createMockTask() + + await expect(guardedWrite(task, "existing.txt", "hello", "create")).rejects.toThrow( + "File already exists at " + + abs("existing.txt") + + " and was not read before this write -- read the file first, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("rethrows I/O errors that are not ENOENT verbatim (no guard verdict on access failure)", async () => { + const failures = [{ code: "EACCES" }, null, "volume offline", new Error("EIO-ish failure")] + for (const failure of failures) { + mockedFsAccess.mockRejectedValueOnce(failure) + await expect(createIfAbsent(abs("io-error.txt"), "x")).rejects.toBe(failure) + } + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("deleted-after-read target", () => { + it("normalizes an ENOENT from the version token into the re-read remediation", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("vanished.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + + // The file was deleted after the read: the token computation fails + // with a raw ENOENT, which the guard must convert into the standard + // re-read-then-retry contract. + mockedComputeVersionToken.mockRejectedValue({ code: "ENOENT" }) + + await expect(guardedWrite(task, "vanished.txt", "next", "update")).rejects.toThrow( + "File was deleted after it was read", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("rethrows non-ENOENT token failures verbatim from replaceIfVersion", async () => { + const failure = { code: "EACCES" } + mockedComputeVersionToken.mockRejectedValueOnce(failure) + + await expect(replaceIfVersion(abs("locked.txt"), "v1", "next")).rejects.toBe(failure) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + describe("unobserved update", () => { + it("succeeds when the file is absent (same create guard)", async () => { + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask() + + await guardedWrite(task, "new-file.txt", "hello", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello") + }) + + it("fails with the read-first remediation when the file exists - nothing published", async () => { + mockedFsAccess.mockResolvedValue(undefined) + const task = createMockTask() + + await expect(guardedWrite(task, "existing.txt", "hello", "update")).rejects.toThrow( + "File already exists at " + + abs("existing.txt") + + " and was not read before this write -- read the file first, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("observed create", () => { + it("recreates a file that vanished after the read", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("gone.txt"), "v1") + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "gone.txt", "back", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("gone.txt"), "back") + }) + + it("goes through the version guard when the file still exists", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("kept.txt"), "v1") + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "kept.txt", "rewritten", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("kept.txt"), "rewritten") + }) + + it("fails with the stale remediation suffix when the version moved", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("kept.txt"), "v1") + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v2") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "kept.txt", "rewritten", "create")).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("defers to the version guard when the access check is denied (not ENOENT)", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("locked.txt"), "v1") + mockedFsAccess.mockRejectedValue({ code: "EACCES" }) + mockedComputeVersionToken.mockResolvedValue("v2") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "locked.txt", "rewritten", "create")).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("observed update (version CAS)", () => { + it("publishes when the on-disk version matches the observation", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "new content", "update") + + expect(mockedComputeVersionToken).toHaveBeenCalledWith(abs("doc.txt")) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "new content") + }) + + it("fails with the stale remediation suffix when the version moved - nothing published", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v2") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "doc.txt", "new content", "update")).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("edit", () => { + it("fails read-first when the file was never observed - nothing published, no I/O", async () => { + const task = createMockTask() + + await expect(guardedWrite(task, "any.txt", "patched", "edit")).rejects.toThrow( + "File not read yet -- read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + expect(mockedComputeVersionToken).not.toHaveBeenCalled() + expect(mockedFsAccess).not.toHaveBeenCalled() + }) + + it("publishes when the version matches the observation", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "patched", "edit") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "patched") + }) + + it("fails with the stale remediation suffix when the version moved", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v3") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "doc.txt", "patched", "edit")).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v3); re-read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("concurrency: per-path FIFO chain", () => { + it("two concurrent updates on one path - exactly one publishes, the other fails stale", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("shared.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + // The first publish changes the on-disk state (new token). + mockedSafeWriteText.mockImplementation(async () => { + mockedComputeVersionToken.mockResolvedValue("v2") + }) + + const p1 = guardedWrite(task, "shared.txt", "first", "update") + const p2 = guardedWrite(task, "shared.txt", "second", "update") + const [r1, r2] = await Promise.allSettled([p1, p2]) + + if (r1.status !== "fulfilled" || r2.status !== "rejected") { + throw new Error("expected exactly one publish, got " + r1.status + " / " + r2.status) + } + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(r2.reason.message).toBe( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + }) + + it("observed-absent then two concurrent creates - the second fails stale", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("absent.txt"), "v1") // read before, file later vanished + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask({ observationRegistry: reg }) + + let publishes = 0 + mockedSafeWriteText.mockImplementation(async () => { + publishes += 1 + if (publishes === 1) { + // After the first publish the file exists again under a new token. + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v2") + } + }) + + const p1 = guardedWrite(task, "absent.txt", "first", "create") + const p2 = guardedWrite(task, "absent.txt", "second", "create") + const [r1, r2] = await Promise.allSettled([p1, p2]) + + if (r1.status !== "fulfilled" || r2.status !== "rejected") { + throw new Error("expected exactly one publish, got " + r1.status + " / " + r2.status) + } + expect(publishes).toBe(1) + expect(r2.reason.message).toContain("Stale version") + expect(r2.reason.message).toContain("re-read the file, then retry.") + }) + + it("the chain settles after a rejection - a later matching write still runs", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("settle.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v2") // already stale at v1 + const task = createMockTask({ observationRegistry: reg }) + + const p1 = guardedWrite(task, "settle.txt", "first", "update") + await expect(p1).rejects.toThrow("Stale version") + + // No resetChain: the rejected link must not wedge the chain. The + // caller re-reads the file (observation refreshed to v2) and retries. + reg.observe(abs("settle.txt"), "v2") + const p2 = guardedWrite(task, "settle.txt", "second", "update") + await expect(p2).resolves.toBeUndefined() + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("settle.txt"), "second") + }) + + it("evicts settled chain entries - a later write still serializes in order", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("evict.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + // A first write settles; its chain entry is evicted with it. + const p1 = guardedWrite(task, "evict.txt", "first", "update") + await expect(p1).resolves.toBeUndefined() + + // Two rapid writes submitted after the eviction must still run one + // at a time in submission order (the eviction must not drop the + // chain for in-flight or just-enqueued links). + const order: string[] = [] + mockedSafeWriteText.mockImplementation(async (_path: string, content: string) => { + order.push(content) + }) + const p2 = guardedWrite(task, "evict.txt", "second", "update") + const p3 = guardedWrite(task, "evict.txt", "third", "update") + await Promise.all([p2, p3]) + + expect(order).toEqual(["second", "third"]) + // Three publishes in total: the settled first write plus the two + // serialized rapid writes. + expect(mockedSafeWriteText).toHaveBeenCalledTimes(3) + }) + + it("writes on different paths are independent (no cross-path serialization)", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("a.txt"), "v1") + reg.observe(abs("b.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + const p1 = guardedWrite(task, "a.txt", "a", "update") + const p2 = guardedWrite(task, "b.txt", "b", "update") + await Promise.all([p1, p2]) + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + }) + }) + + describe("path resolution", () => { + it("resolves a relative path against task.cwd", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("sub/dir.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "sub/dir.txt", "content", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("sub/dir.txt"), "content") + }) + + it("normalizes an already-absolute input (trailing separator) to the observation key", async () => { + const reg = new ObservationRegistry() + const canonical = abs("sub/dir.txt") + // ReadFileTool observes under path.resolve(task.cwd, relPath) — the + // canonical spelling. A write addressed with a trailing separator used + // to bypass the observation (isAbsolute passthrough) and fail + // "File already exists" / "File not read yet" for a file that was read. + reg.observe(canonical, "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, canonical + "/", "content", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(mockedSafeWriteText).toHaveBeenCalledWith(canonical, "content") + }) + + it("serializes two spellings of one file through a single chain key", async () => { + const reg = new ObservationRegistry() + const canonical = abs("shared2.txt") + reg.observe(canonical, "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + // The first publish changes the on-disk state (new token). + mockedSafeWriteText.mockImplementation(async () => { + mockedComputeVersionToken.mockResolvedValue("v2") + }) + + // Plain spelling vs the trailing-separator spelling: with one chain key + // they are strictly ordered (first matches v1, second sees v2). + const p1 = guardedWrite(task, canonical, "first", "update") + const p2 = guardedWrite(task, canonical + "/", "second", "update") + const [r1, r2] = await Promise.allSettled([p1, p2]) + + if (r1.status !== "fulfilled" || r2.status !== "rejected") { + throw new Error("expected exactly one publish, got " + r1.status + " / " + r2.status) + } + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(r2.reason.message).toBe( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + }) + }) + + describe("resetChain", () => { + it("detaches pending links so later writes start a fresh chain", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("x.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "x.txt", "a", "update") + resetChain() + await guardedWrite(task, "x.txt", "b", "update") + + expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("x.txt"), "b") + }) + }) +}) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 6108e78151..7e2fa3aac7 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -197,7 +197,18 @@ describe("ReadFileTool", () => { vi.clearAllMocks() // Default mock implementations - mockedFsStat.mockResolvedValue({ isDirectory: () => false } as any) + // The stat default carries BigIntStats fields (A2, epic #1375): reads now + // token-ize the pre/post stats, so the default must look like a real bigint stat. + // Tests overriding it do so per-call with mockResolvedValue(Once). + mockedFsStat.mockResolvedValue({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + // Cast: the mock only implements the members the tool and versionToken read. + } as unknown as Stats) mockedIsBinaryFile.mockResolvedValue(false) mockedFsReadFile.mockResolvedValue(Buffer.from("test content")) mockedReadWithSlice.mockReturnValue({ @@ -1591,6 +1602,204 @@ describe("ReadFileTool", () => { expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) }) + it("does not observe when the file mutates between the pre-read and post-read stats", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const preStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } + // A mutation lands mid-read: the post-read stat differs. + const postStats = { ...preStats, size: BigInt(301) } + + // Call order: directory check, pre-read stat, post-read stat. + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockResolvedValueOnce(preStats as unknown as Stats) + .mockResolvedValueOnce(postStats as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "mutated.ts" }, mockTask as unknown as Task, callbacks) + + // The read itself succeeded, but the target stays unobserved: the content the + // model received is not the on-disk state, so observing it would let a later + // write match a token the model never saw. + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + }) + + it("leaves the target unobserved without failing the read when the pre-read stat fails", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // Directory check OK; the pre-read stat fails (caught, target unobserved). + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "stat-fail.ts" }, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + // The read still succeeds — a stat failure never fails the read. + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(callbacks.pushToolResult).toHaveBeenCalled() + }) + + it("leaves the target unobserved without failing the read when the post-read stat fails", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const okStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } + // Directory check and pre-read stat OK; the post-read stat fails. + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockResolvedValueOnce(okStats as unknown as Stats) + .mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "post-stat-fail.ts" }, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(callbacks.pushToolResult).toHaveBeenCalled() + }) + + it("legacy format: does not observe when the file mutates between the pre-read and post-read stats", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const preStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } + // Call order: directory check, pre-read stat, post-read stat (mutated). + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockResolvedValueOnce(preStats as unknown as Stats) + .mockResolvedValueOnce({ ...preStats, size: BigInt(301) } as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-mutated.ts" }], + _legacyFormat: true, + } + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + }) + + it("legacy format: leaves the target unobserved when a stat fails without failing the read", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // Directory check OK; the pre-read stat fails (caught, target unobserved). + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-stat-fail.ts" }], + _legacyFormat: true, + } + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(callbacks.pushToolResult).toHaveBeenCalled() + }) + it("legacy format: leaves the target unobserved when the post-read stat fails", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const okStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } + // Directory check and pre-read stat OK; the post-read stat fails. + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockResolvedValueOnce(okStats as unknown as Stats) + .mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-post-stat-fail.ts" }], + _legacyFormat: true, + } + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(callbacks.pushToolResult).toHaveBeenCalled() + }) it("two separate Task-owned registries are independent", async () => { const regA = new ObservationRegistry() const regB = new ObservationRegistry() diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts new file mode 100644 index 0000000000..596cc41035 --- /dev/null +++ b/src/core/tools/guardedWrite.ts @@ -0,0 +1,260 @@ +/** + * Guarded-write compare-and-swap core (upstream epic #1375, phase A4a). + * + * Wraps the S3 safeWriteText publish primitive behind version-token guards so + * that every write is deterministic: + * + * - an unobserved target may only be created when it is absent + * (createIfAbsent); + * - an observed target is published only when the on-disk version token still + * matches the token recorded at read time (replaceIfVersion); + * - an edit-style write requires a prior observation (unobservedEditGuard). + * + * A per-absolute-path FIFO chain of tail promises orders concurrent + * in-process writes to the same path: the first matching write wins, the rest + * fail stale. Observations come from the task's S2 ObservationRegistry. + */ + +import * as fs from "fs/promises" +import * as path from "path" + +import { safeWriteText } from "../../services/file-safety/safeWriteText" +import { computeVersionToken } from "../../utils/versionToken" +import type { Task } from "../task/Task" + +// -- Types ------------------------------------------------------------------ + +/** Write kind that drives guard selection. */ +export type GuardedWriteKind = "create" | "update" | "edit" + +/** Internal error thrown when a guard rejects a write. */ +class GuardRejectedError extends Error { + constructor( + message: string, + readonly path: string, + ) { + super(message) + this.name = "GuardRejectedError" + } +} + +// -- Per-path tail-promise chain -------------------------------------------- + +/** + * Per-absolute-path FIFO chain of pending guarded writes (tail promise per + * path). Every write enqueues onto the current tail for its path, so + * concurrent writes to the same path run one at a time in submission order. + * + * The chain never leaks a rejection through itself: each link settles, a + * rejected link is skipped by the next writer (a failed write must not block + * later writes to the same path), and every caller receives its own link + * promise to handle. + * + * Settled entries are evicted (below), so a long-lived extension does not + * accumulate a map entry per distinct written path. + */ +const pendingChains = new Map>() + +/** + * Enqueue a write operation on the per-path FIFO chain. + * + * Returns the promise for this link; it always settles. A prior link that + * rejected is skipped, not propagated. The map entry for this link is + * deleted once it settles — but only while it is still the current tail for + * the path, so a replacement enqueued in the meantime keeps ownership. + */ +function enqueue(pathKey: string, fn: () => Promise): Promise { + const prev = pendingChains.get(pathKey) ?? Promise.resolve() + const next = prev.then(fn, fn) + pendingChains.set(pathKey, next) + void next.then( + () => { + if (pendingChains.get(pathKey) === next) { + pendingChains.delete(pathKey) + } + }, + () => { + if (pendingChains.get(pathKey) === next) { + pendingChains.delete(pathKey) + } + }, + ) + return next +} + +// -- Guard primitives -------------------------------------------------------- + +/** + * Extract a Node errno code (e.g. "ENOENT") from a thrown value, or + * undefined when the value carries none. + */ +function errorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? (error as { code?: string }).code + : undefined +} + +/** True when the path is absent on disk (fs.access reports ENOENT). */ +async function fileIsAbsent(absolutePath: string): Promise { + try { + await fs.access(absolutePath) + return false + } catch (error: unknown) { + return errorCode(error) === "ENOENT" + } +} + +/** + * Publish content only if the target file does not exist. + * + * Rejects with a loud remediation error when the file already exists: the + * write was issued for a file that was never read, so the caller must read + * the file first, then retry. + */ +export async function createIfAbsent(absolutePath: string, content: string): Promise { + try { + await fs.access(absolutePath) + } catch (error: unknown) { + if (errorCode(error) !== "ENOENT") { + // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. + throw error + } + await safeWriteText(absolutePath, content) + return + } + + throw new GuardRejectedError( + "File already exists at " + + absolutePath + + " and was not read before this write -- read the file first, then retry.", + absolutePath, + ) +} + +/** + * Publish content only if the current on-disk version token equals + * expectedVersion (the token observed at read time). + * + * On a match the content is published via the S3 safeWriteText primitive; on + * a mismatch the write is rejected stale with a re-read-then-retry + * remediation suffix. + */ +export async function replaceIfVersion(absolutePath: string, expectedVersion: string, content: string): Promise { + let currentVersion: string + try { + currentVersion = await computeVersionToken(absolutePath) + } catch (error: unknown) { + if (errorCode(error) === "ENOENT") { + // The observed file was deleted after the read: the version recorded + // at read time no longer exists on disk. Normalize the raw ENOENT + // into the guard's re-read-then-retry contract so the caller gets a + // remediation it can act on, not a raw errno. + throw new GuardRejectedError( + "File was deleted after it was read -- the version recorded at read time (" + + expectedVersion + + ") no longer exists; re-read the file, then retry.", + absolutePath, + ) + } + // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. + throw error + } + + if (currentVersion === expectedVersion) { + await safeWriteText(absolutePath, content) + return + } + + throw new GuardRejectedError( + "Stale version -- the file changed since you read it (expected " + + expectedVersion + + ", current " + + currentVersion + + "); re-read the file, then retry.", + absolutePath, + ) +} + +/** + * Unobserved-edit guard: an edit-style write without a prior observation is + * rejected before any I/O. The literal-match / patch logic stays with the + * tools in S4b; this guard only verifies that a read happened first. + * + * Returns Promise because the rejection is total: this function + * never resolves. + */ +export async function unobservedEditGuard(absolutePath: string): Promise { + throw new GuardRejectedError("File not read yet -- read the file, then retry.", absolutePath) +} + +// -- Public API -------------------------------------------------------------- + +/** + * Resolve a relative or absolute path against task.cwd. + * + * path.resolve also normalizes an already-absolute input (collapsing "." / ".." + * segments and trailing separators), so the key always matches the + * ObservationRegistry key recorded at read time (ReadFileTool observes under + * path.resolve(task.cwd, relPath)) and two spellings of one file share one + * FIFO chain. + */ +function resolveAbsolutePath(task: Task, relPathOrAbsolute: string): string { + return path.resolve(task.cwd, relPathOrAbsolute) +} + +/** + * Guarded write entry point. + * + * 1. Resolves the absolute path against task.cwd. + * 2. Consults the task's S2 observation registry to pick the guard: + * - unobserved + create/update: createIfAbsent (rejects if it exists); + * - observed + create on a file that vanished after the read: recreate; + * - observed otherwise: replaceIfVersion (CAS on the S1 version token); + * - unobserved + edit: unobservedEditGuard. + * 3. Runs the chosen guard on the per-path FIFO chain so concurrent writes to + * the same path are deterministically ordered. + */ +export async function guardedWrite( + task: Task, + relPathOrAbsolute: string, + content: string, + kind: GuardedWriteKind = "update", +): Promise { + const absolutePath = resolveAbsolutePath(task, relPathOrAbsolute) + + return enqueue(absolutePath, async () => { + const obs = task.observationRegistry.get(absolutePath) + + if (obs === undefined) { + // Edit-style writes require a prior read: no observation, no write. + if (kind === "edit") { + await unobservedEditGuard(absolutePath) + } + // Never read: only an absent target may be created. (The edit guard + // above rejects before reaching this line.) + await createIfAbsent(absolutePath, content) + return + } + + if (kind === "edit") { + await replaceIfVersion(absolutePath, obs.version, content) + return + } + + // kind is "create" or "update": a "create" on a file that vanished + // after the read recreates it; otherwise the version recorded at read + // time must still match the on-disk token. + if (kind === "create" && (await fileIsAbsent(absolutePath))) { + await createIfAbsent(absolutePath, content) + } else { + await replaceIfVersion(absolutePath, obs.version, content) + } + }) +} + +/** + * Reset the per-path tail-promise chains (test hook). + */ +export function resetChain(): void { + pendingChains.clear() +} diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 77680449be..5528f3b0b1 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -976,7 +976,7 @@ }, "core/tools/__tests__/readFileTool.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 98 + "count": 97 } }, "core/tools/__tests__/runSlashCommandTool.spec.ts": { diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 064207e21f..631fb9f810 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -4,6 +4,7 @@ import * as path from "path" import * as os from "os" import { safeWriteJson } from "../safeWriteJson" +import * as lockfile from "proper-lockfile" // Capture actual implementations before the vi.mock factory runs, // so they are never wrapped by vi.fn() — avoids infinite recursion when @@ -579,6 +580,82 @@ describe("safeWriteJson", () => { expect(await readFileContent(referentPath)).toEqual({ after: true }) }) + // proper-lockfile with realpath:false keys the lock by the given path, so a + // symlink alias and its referent must coordinate through ONE lock on the + // resolved referent — otherwise a concurrent merge through both aliases + // reads the same JSON and overwrites one update. (Real symlinks are + // unavailable in this CI lane, so the resolution is simulated by mocking + // fs.realpath, the same way as the staging test above.) + test("acquires the lock on the resolved referent, not the caller alias", async () => { + vi.resetModules() // fresh module instances so the doMock below is picked up + + const referentDir = path.join(tempDir, "lock-referent") + const linkDir = path.join(tempDir, "lock-link") + await fs.mkdir(referentDir, { recursive: true }) + await fs.mkdir(linkDir, { recursive: true }) + // caller-visible path (the link) vs the resolved referent path + const callerPath = path.join(linkDir, "locked.json") + const referentPath = path.join(referentDir, "locked.json") + await fsPromisesActuals.writeFile!(referentPath, JSON.stringify({ seed: 1 })) + + vi.spyOn(fs, "realpath").mockResolvedValue(referentPath) + + // Wrap the real lock in a capturing mock, and drive the two rare error paths + // (the onCompromised callback and a failing release) so they stay covered + // without real lockfile staleness. The callback rethrows by design, so + // the mock swallows that throw and lets the real lock proceed. + const realLockfile = await vi.importActual("proper-lockfile") + const lockMockFn = vi.fn( + async ( + file: Parameters[0], + options?: Parameters[1], + ) => { + try { + options?.onCompromised?.(new Error("lock compromised (test)")) + } catch { + // onCompromised rethrows by design; swallow so the real lock proceeds. + } + const release = await realLockfile.lock(file, options) + return async () => { + await release() + throw new Error("release failed (test)") + } + }, + ) + const lockMock = lockMockFn as unknown as typeof realLockfile.lock + vi.doMock("proper-lockfile", () => ({ + ...realLockfile, + lock: lockMock, + })) + + // Re-import safeWriteJson so it picks up the mocked proper-lockfile. + const { safeWriteJson: mockedSafeWriteJson } = await import("../safeWriteJson") + + const mergeFn = vi.fn((existing: unknown, incoming: unknown) => ({ + ...(existing as Record), + ...(incoming as Record), + })) + + // Capture the compromise + release-failure logs. + const consoleErrorSpy = vi.spyOn(console, "error") + await mockedSafeWriteJson(callerPath, { added: true }, { merge: mergeFn }) + + // The lock was keyed by the resolved referent — every alias shares it. + expect(lockMock).toHaveBeenCalledTimes(1) + expect(String(lockMockFn.mock.calls[0][0])).toBe(referentPath) + // The merge read the referent's content through that single lock. + expect(mergeFn).toHaveBeenCalledWith({ seed: 1 }, { added: true }) + expect(await readFileContent(referentPath)).toEqual({ seed: 1, added: true }) + // The compromise callback and the failed release were logged, not thrown. + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("was compromised"), expect.any(Error)) + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to release lock"), + expect.any(Error), + ) + + vi.unmock("proper-lockfile") // Ensure the mock is removed after this test + }) + // CWE-732 regression: safeWriteJson stages the temp itself and passes it // via tempPath, so safeWriteText must apply the existing target's mode to // the staged temp before the atomic rename — otherwise a 0o600 target is diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 26af906b43..a9f837fc50 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -59,12 +59,22 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso throw dirError } + // Resolve the publish target BEFORE acquiring the lock: proper-lockfile keys + // the lock by the given path (realpath is false below because the file may + // not exist yet), so a symlink alias and its referent would otherwise take + // two distinct locks for one underlying file — a concurrent merge through + // both aliases could then read the same JSON and overwrite one update. + // Locking the resolved referent coordinates every alias through one lock. + // resolvePublishTarget tolerates a not-yet-existing file (it returns the + // given path on ENOENT), preserving the previous create-from-absent flow. + const resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) + // Acquire the lock before any file operations try { - releaseLock = await lockfile.lock(absoluteFilePath, { + releaseLock = await lockfile.lock(resolvedTargetPath, { stale: LOCK_STALE_MS, update: 10000, // Update mtime every 10 seconds to prevent staleness if operation is long - realpath: false, // the file may not exist yet, which is acceptable + realpath: false, // resolvedTargetPath is already the referent; the file may still not exist yet, which is acceptable retries: { // Configuration for retrying lock acquisition retries: 5, // Number of retries after the initial attempt @@ -73,7 +83,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso maxTimeout: 1000, // Maximum time to wait for any single retry (in ms) }, onCompromised: (err) => { - console.error(`Lock at ${absoluteFilePath} was compromised:`, err) + console.error(`Lock at ${resolvedTargetPath} was compromised:`, err) throw err }, }) @@ -81,7 +91,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // If lock acquisition fails, we throw immediately. // The releaseLock remains a no-op, so the finally block in the main file operations // try-catch-finally won't try to release an unacquired lock if this path is taken. - console.error(`Failed to acquire lock for ${absoluteFilePath}:`, lockError) + console.error(`Failed to acquire lock for ${resolvedTargetPath}:`, lockError) throw lockError } @@ -95,7 +105,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso if (options?.merge) { let existing: unknown = null try { - existing = JSON.parse(await fs.readFile(absoluteFilePath, "utf8")) + existing = JSON.parse(await fs.readFile(resolvedTargetPath, "utf8")) } catch (error: unknown) { const code = error && typeof error === "object" && "code" in error ? (error as { code: string }).code : undefined @@ -108,9 +118,8 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // Step 1: Write data to a new temporary file via JSON streaming. // Stage it beside the *resolved* target (the symlink referent when the path is - // a symlink): safeWriteText commits by renaming onto that referent, and a - // rename across filesystems would fail with EXDEV. - const resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) + // a symlink; resolvedTargetPath above): safeWriteText commits by renaming + // onto that referent, and a rename across filesystems would fail with EXDEV. actualTempNewFilePath = path.join( path.dirname(resolvedTargetPath), ".new_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp", @@ -129,13 +138,13 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso backup: true, } - await safeWriteText(absoluteFilePath, "", textOptions) + await safeWriteText(resolvedTargetPath, "", textOptions) // If we reach here, the new file is successfully in place and any // backup has already been handled by safeWriteText. actualTempNewFilePath = null } catch (originalError) { - console.error(`Operation failed for ${absoluteFilePath}: [Original Error Caught]`, originalError) + console.error(`Operation failed for ${resolvedTargetPath}: [Original Error Caught]`, originalError) const newFileToCleanupWithinCatch = actualTempNewFilePath @@ -160,7 +169,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso try { await releaseLock() } catch (unlockError) { - console.error(`Failed to release lock for ${absoluteFilePath}:`, unlockError) + console.error(`Failed to release lock for ${resolvedTargetPath}:`, unlockError) } } } From 68be2648387ebaa03ceb8c353b08eea1d0f3df98 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 23:59:02 +0800 Subject: [PATCH 007/100] feat(tools): wire guarded writes into the diff-view save paths (S4b, #1375) --- src/core/tools/ApplyDiffTool.ts | 4 +- src/core/tools/ApplyPatchTool.ts | 24 ++- src/core/tools/EditFileTool.ts | 5 +- src/core/tools/EditTool.ts | 12 +- src/core/tools/SearchReplaceTool.ts | 12 +- src/core/tools/WriteToFileTool.ts | 11 +- .../applyDiffTool.guardedWrite.spec.ts | 157 ++++++++++++++ .../__tests__/applyPatchTool.execute.spec.ts | 198 ++++++++++++++++++ src/core/tools/__tests__/editFileTool.spec.ts | 95 +++++++++ src/core/tools/__tests__/editTool.spec.ts | 46 ++++ .../tools/__tests__/searchReplaceTool.spec.ts | 46 ++++ .../tools/__tests__/writeToFileTool.spec.ts | 73 +++++++ src/integrations/editor/DiffViewProvider.ts | 21 +- .../editor/__tests__/DiffViewProvider.spec.ts | 88 +++++++- 14 files changed, 779 insertions(+), 13 deletions(-) create mode 100644 src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 3b664b3bd2..9e6f87f653 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -173,7 +173,8 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { return } - // Save directly without showing diff view or opening the file + // Save directly without showing diff view or opening the file. The diff is + // applied to an existing file, so edit-guard semantics require a prior read. task.diffViewProvider.editType = "modify" task.diffViewProvider.originalContent = originalContent await task.diffViewProvider.saveDirectly( @@ -182,6 +183,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { false, diagnosticsEnabled, writeDelayMs, + "edit", ) } else { // Original behavior with diff view diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 56b2bf8909..8b261bdeeb 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -214,7 +214,16 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Save the changes if (isPreventFocusDisruptionEnabled) { - await task.diffViewProvider.saveDirectly(relPath, newContent, true, diagnosticsEnabled, writeDelayMs) + // Guarded publish: the patch supplies the complete new content, so create-guard + // semantics apply (an unobserved existing target is rejected, not overwritten). + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + true, + diagnosticsEnabled, + writeDelayMs, + "create", + ) } else { await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } @@ -408,12 +417,14 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Save new content to the new path if (isPreventFocusDisruptionEnabled) { + // The move destination is published with the complete new content. await task.diffViewProvider.saveDirectly( change.movePath, newContent, false, diagnosticsEnabled, writeDelayMs, + "create", ) } else { // Write to new path and delete old file @@ -433,7 +444,16 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } else { // Save changes to the same file if (isPreventFocusDisruptionEnabled) { - await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + // Guarded publish: the patched file content is complete, so create-guard + // semantics apply (stale observed versions are rejected with a re-read hint). + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + false, + diagnosticsEnabled, + writeDelayMs, + "create", + ) } else { await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index a7301e2ac9..911d4ed85b 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -436,13 +436,16 @@ export class EditFileTool extends BaseTool<"edit_file"> { // Save the changes if (isPreventFocusDisruptionEnabled) { - // Direct file write without diff view or opening the file + // Direct file write without diff view or opening the file. In-place edits + // use edit-guard semantics (a prior read is required); new-file creation + // keeps create-guard semantics. await task.diffViewProvider.saveDirectly( relPath, newContent, isNewFile, diagnosticsEnabled, writeDelayMs, + isNewFile ? "create" : "edit", ) } else { // Call saveChanges to update the DiffViewProvider properties diff --git a/src/core/tools/EditTool.ts b/src/core/tools/EditTool.ts index 2ae8bf4ed0..6bf93d55d9 100644 --- a/src/core/tools/EditTool.ts +++ b/src/core/tools/EditTool.ts @@ -211,8 +211,16 @@ export class EditTool extends BaseTool<"edit"> { // Save the changes if (isPreventFocusDisruptionEnabled) { - // Direct file write without diff view or opening the file - await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + // Direct file write without diff view or opening the file. This tool only + // edits existing files, so edit-guard semantics require a prior read. + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + false, + diagnosticsEnabled, + writeDelayMs, + "edit", + ) } else { // Call saveChanges to update the DiffViewProvider properties await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) diff --git a/src/core/tools/SearchReplaceTool.ts b/src/core/tools/SearchReplaceTool.ts index e29b124010..c11d59a4fc 100644 --- a/src/core/tools/SearchReplaceTool.ts +++ b/src/core/tools/SearchReplaceTool.ts @@ -207,8 +207,16 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { // Save the changes if (isPreventFocusDisruptionEnabled) { - // Direct file write without diff view or opening the file - await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + // Direct file write without diff view or opening the file. This tool only + // edits existing files, so edit-guard semantics require a prior read. + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + false, + diagnosticsEnabled, + writeDelayMs, + "edit", + ) } else { // Call saveChanges to update the DiffViewProvider properties await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index ae026b4b86..b3f1edca30 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -133,7 +133,16 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return } - await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + // Guarded publish: this write carries the complete file content, so it uses + // create-guard semantics (unobserved targets may only be created when absent). + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + false, + diagnosticsEnabled, + writeDelayMs, + "create", + ) } else { if (!task.diffViewProvider.isEditing) { const partialMessage = JSON.stringify(sharedMessageProps) diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts new file mode 100644 index 0000000000..2181c98941 --- /dev/null +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -0,0 +1,157 @@ +// npx vitest run core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts + +import type { MockedFunction } from "vitest" + +import { fileExistsAtPath } from "../../../utils/fs" +import type { Task } from "../../task/Task" +import { ApplyDiffTool } from "../ApplyDiffTool" + +vi.mock("fs/promises", () => ({ + default: { + readFile: vi.fn().mockResolvedValue("original file content\n"), + }, +})) + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockResolvedValue(true), +})) + +vi.mock("../../prompts/responses", () => ({ + formatResponse: { + toolError: vi.fn((msg: string) => `Error: ${msg}`), + rooIgnoreError: vi.fn((filePath: string) => `Access denied: ${filePath}`), + createPrettyPatch: vi.fn(() => "mock-diff"), + }, +})) + +vi.mock("../../diff/stats", () => ({ + sanitizeUnifiedDiff: vi.fn((diff: string) => diff), + computeDiffStats: vi.fn(() => ({ additions: 1, deletions: 1 })), +})) + +describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { + const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction + + let tool: ApplyDiffTool + let mockTask: Pick< + Task, + | "cwd" + | "consecutiveMistakeCount" + | "consecutiveMistakeCountForApplyDiff" + | "recordToolError" + | "rooIgnoreController" + | "rooProtectedController" + | "say" + | "processQueuedMessages" + | "didEditFile" + | "api" + | "diffStrategy" + | "diffViewProvider" + | "providerRef" + | "fileContextTracker" + > + let mockSaveDirectly: MockedFunction<(...args: unknown[]) => Promise> + let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> + let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> + let mockPushToolResult: MockedFunction<(...args: unknown[]) => void> + + beforeEach(() => { + vi.clearAllMocks() + + mockedFileExistsAtPath.mockResolvedValue(true) + + mockSaveDirectly = vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "new content", + }) + + // Structural stubs for the guarded-write path: the real DiffViewProvider is + // out of scope here, so vi.fn() doubles stand in for the members the tool + // touches (the saveDirectly double also records the writeKind plumbing). + const diffViewProviderStub = { + editType: undefined as "create" | "modify" | undefined, + originalContent: undefined as string | undefined, + saveDirectly: mockSaveDirectly, + pushToolWriteResult: vi.fn().mockResolvedValue("Saved file"), + reset: vi.fn().mockResolvedValue(undefined), + } + mockTask = { + cwd: "/workspace/project", + consecutiveMistakeCount: 0, + consecutiveMistakeCountForApplyDiff: new Map(), + recordToolError: vi.fn(), + rooIgnoreController: { + validateAccess: vi.fn().mockReturnValue(true), + } as unknown as Task["rooIgnoreController"], + rooProtectedController: { + isWriteProtected: vi.fn().mockReturnValue(false), + } as unknown as Task["rooProtectedController"], + say: vi.fn().mockResolvedValue(undefined), + processQueuedMessages: vi.fn(), + didEditFile: false, + api: { + getModel: () => ({ id: "claude-sonnet-4-5" }), + } as unknown as Task["api"], + diffStrategy: { + applyDiff: vi.fn().mockResolvedValue({ success: true, content: "modified file content\n" }), + } as unknown as Task["diffStrategy"], + diffViewProvider: diffViewProviderStub as unknown as Task["diffViewProvider"], + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + // Exercise the focus-disruption (saveDirectly) save path. + experiments: { preventFocusDisruption: true }, + }), + }), + } as unknown as Task["providerRef"], + fileContextTracker: { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } as unknown as Task["fileContextTracker"], + } + + mockAskApproval = vi.fn().mockResolvedValue(true) + mockHandleError = vi.fn().mockResolvedValue(undefined) + mockPushToolResult = vi.fn() + + tool = new ApplyDiffTool() + }) + + it("publishes through the guarded saveDirectly with edit kind", async () => { + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/thing.ts", + "modified file content\n", + false, + true, + 1000, + "edit", + ) + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("surfaces the unobserved edit remediation as a tool error", async () => { + const guardError = new Error("File not read yet -- read the file, then retry.") + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockHandleError).toHaveBeenCalledWith("applying diff", guardError) + expect(vi.mocked(mockTask.diffViewProvider.reset)).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + expect(mockPushToolResult).not.toHaveBeenCalledWith("Saved file") + }) +}) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 72ffb112bc..2dcf201f04 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -94,3 +94,201 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockTask.recordToolError).not.toHaveBeenCalled() }) }) + +describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { + const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction + + let tool: ApplyPatchTool + let mockTask: Pick< + Task, + | "cwd" + | "consecutiveMistakeCount" + | "recordToolError" + | "rooIgnoreController" + | "rooProtectedController" + | "say" + | "processQueuedMessages" + | "didEditFile" + | "diffViewProvider" + | "providerRef" + | "fileContextTracker" + > + let mockSaveDirectly: MockedFunction<(...args: unknown[]) => Promise> + let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> + let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> + let mockPushToolResult: MockedFunction<(...args: unknown[]) => void> + + const updatePatch = `*** Begin Patch +*** Update File: src/thing.ts +@@ +-original file content ++modified file content +*** End Patch` + + const addPatch = `*** Begin Patch +*** Add File: src/new.ts ++new line one ++new line two +*** End Patch` + + const movePatch = `*** Begin Patch +*** Update File: src/old.ts +*** Move to: src/new.ts +@@ +-original file content ++modified file content +*** End Patch` + + beforeEach(() => { + vi.clearAllMocks() + + mockedFileExistsAtPath.mockResolvedValue(true) + + mockSaveDirectly = vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "new content", + }) + + // Structural stubs for the guarded-write path: the real DiffViewProvider is + // out of scope here, so vi.fn() doubles stand in for the members the tool + // touches (the saveDirectly double also records the writeKind plumbing). + const diffViewProviderStub = { + editType: undefined as "create" | "modify" | undefined, + originalContent: undefined as string | undefined, + saveDirectly: mockSaveDirectly, + pushToolWriteResult: vi.fn().mockResolvedValue("Saved file"), + reset: vi.fn().mockResolvedValue(undefined), + } + mockTask = { + cwd: "/workspace/project", + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + rooIgnoreController: { + validateAccess: vi.fn().mockReturnValue(true), + } as unknown as Task["rooIgnoreController"], + rooProtectedController: { + isWriteProtected: vi.fn().mockReturnValue(false), + } as unknown as Task["rooProtectedController"], + say: vi.fn().mockResolvedValue(undefined), + processQueuedMessages: vi.fn(), + didEditFile: false, + diffViewProvider: diffViewProviderStub as unknown as Task["diffViewProvider"], + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + // Exercise the focus-disruption (saveDirectly) save path. + experiments: { preventFocusDisruption: true }, + }), + }), + } as unknown as Task["providerRef"], + fileContextTracker: { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } as unknown as Task["fileContextTracker"], + } + + mockAskApproval = vi.fn().mockResolvedValue(true) + mockHandleError = vi.fn().mockResolvedValue(undefined) + mockPushToolResult = vi.fn() + + tool = new ApplyPatchTool() + }) + + it("update: publishes through the guarded saveDirectly with create kind", async () => { + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/thing.ts", + "modified file content\n", + false, + true, + 1000, + "create", + ) + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("add: publishes the new file through the guarded saveDirectly with create kind", async () => { + mockedFileExistsAtPath.mockResolvedValueOnce(false) + + await tool.execute({ patch: addPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/new.ts", + "new line one\nnew line two\n", + true, + true, + 1000, + "create", + ) + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("move: publishes the destination through the guarded saveDirectly with create kind", async () => { + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/new.ts", + "modified file content\n", + false, + true, + 1000, + "create", + ) + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("update: surfaces the unobserved-existing remediation as a tool error", async () => { + const guardError = new Error( + "File already exists at /workspace/project/src/thing.ts and was not read before this write -- read the file first, then retry.", + ) + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) + expect(vi.mocked(mockTask.diffViewProvider.reset)).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + expect(mockPushToolResult).not.toHaveBeenCalledWith("Saved file") + }) + + it("update: surfaces the stale-version remediation as a tool error", async () => { + const guardError = new Error( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) + expect(vi.mocked(mockTask.diffViewProvider.reset)).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + }) +}) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 1ff8d52a8d..4606b51ff6 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -168,6 +168,7 @@ describe("editFileTool", () => { fileContent?: string isPartial?: boolean accessAllowed?: boolean + experiments?: Record } = {}, ): Promise { const fileExists = options.fileExists ?? true @@ -178,6 +179,13 @@ describe("editFileTool", () => { mockedFileExistsAtPath.mockResolvedValue(fileExists) mockedFsReadFile.mockResolvedValue(fileContent) mockTask.rooIgnoreController.validateAccess.mockReturnValue(accessAllowed) + mockTask.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: options.experiments ?? {}, + }), + }) const nativeArgs: Record = { file_path: testFilePath, @@ -687,6 +695,93 @@ describe("editFileTool", () => { }) }) + describe("guarded write (S4b, epic #1375)", () => { + const focusDisruption = { preventFocusDisruption: true } + + it("publishes an existing-file edit through saveDirectly with edit kind", async () => { + const result = await executeEditFileTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockTask.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + "Line 1\nModified Line 2\nLine 3", + false, + true, + 1000, + "edit", + ) + expect(mockTask.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(true) + expect(result).toContain("Tool result message") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("publishes new-file creation through saveDirectly with create kind", async () => { + await executeEditFileTool( + { old_string: "", new_string: "New file content" }, + { fileExists: false, experiments: focusDisruption }, + ) + + expect(mockTask.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + "New file content", + true, + true, + 1000, + "create", + ) + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("surfaces the unobserved edit remediation as a tool error and publishes nothing", async () => { + const guardError = new Error("File not read yet -- read the file, then retry.") + mockTask.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeEditFileTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockHandleError).toHaveBeenCalledWith("edit_file", guardError) + expect(result).toBeUndefined() + expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(mockTask.didEditFile).toBe(false) + }) + + it("surfaces the stale-version remediation as a tool error and publishes nothing", async () => { + const guardError = new Error( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + mockTask.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeEditFileTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockHandleError).toHaveBeenCalledWith("edit_file", guardError) + expect(result).toBeUndefined() + expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + }) + + it("still fails a literal mismatch with the existing message before the guard runs", async () => { + const result = await executeEditFileTool( + { old_string: "NonExistent", new_string: "Whatever" }, + { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(result).toContain("No match found") + expect(result).toContain("") + expect(mockTask.diffViewProvider.saveDirectly).not.toHaveBeenCalled() + expect(mockHandleError).not.toHaveBeenCalled() + }) + }) + describe("CRLF normalization", () => { it("preserves CRLF line endings on output", async () => { const contentWithCRLF = "Line 1\r\nLine 2\r\nLine 3" diff --git a/src/core/tools/__tests__/editTool.spec.ts b/src/core/tools/__tests__/editTool.spec.ts index a5f665b9e5..59906771b1 100644 --- a/src/core/tools/__tests__/editTool.spec.ts +++ b/src/core/tools/__tests__/editTool.spec.ts @@ -169,6 +169,7 @@ describe("editTool", () => { fileContent?: string isPartial?: boolean accessAllowed?: boolean + experiments?: Record } = {}, ): Promise { const fileExists = options.fileExists ?? true @@ -179,6 +180,13 @@ describe("editTool", () => { mockedFileExistsAtPath.mockResolvedValue(fileExists) mockedFsReadFile.mockResolvedValue(fileContent) mockTask.rooIgnoreController.validateAccess.mockReturnValue(accessAllowed) + mockTask.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: options.experiments ?? {}, + }), + }) const defaultParams = { file_path: testFilePath, @@ -424,4 +432,42 @@ describe("editTool", () => { expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith(testFilePath, "roo_edited") }) }) + + describe("guarded write (S4b, epic #1375)", () => { + const focusDisruption = { preventFocusDisruption: true } + + it("publishes through saveDirectly with edit kind", async () => { + const result = await executeEditTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockTask.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + "Line 1\nModified Line 2\nLine 3", + false, + true, + 1000, + "edit", + ) + expect(mockTask.didEditFile).toBe(true) + expect(result).toBe("Tool result message") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("surfaces the unobserved edit remediation as a tool error and publishes nothing", async () => { + const guardError = new Error("File not read yet -- read the file, then retry.") + mockTask.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeEditTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockHandleError).toHaveBeenCalledWith("edit", guardError) + expect(result).toBeUndefined() + expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + }) + }) }) diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index 5cf10790d4..2a91ec1a65 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -166,6 +166,7 @@ describe("searchReplaceTool", () => { fileContent?: string isPartial?: boolean accessAllowed?: boolean + experiments?: Record } = {}, ): Promise { const fileExists = options.fileExists ?? true @@ -176,6 +177,13 @@ describe("searchReplaceTool", () => { mockedFileExistsAtPath.mockResolvedValue(fileExists) mockedFsReadFile.mockResolvedValue(fileContent) mockCline.rooIgnoreController.validateAccess.mockReturnValue(accessAllowed) + mockCline.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: options.experiments ?? {}, + }), + }) const nativeArgs: Record = { file_path: testFilePath, @@ -439,4 +447,42 @@ describe("searchReplaceTool", () => { expect(mockAskApproval).toHaveBeenCalled() }) }) + + describe("guarded write (S4b, epic #1375)", () => { + const focusDisruption = { preventFocusDisruption: true } + + it("publishes through saveDirectly with edit kind", async () => { + const result = await executeSearchReplaceTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + "Line 1\nModified Line 2\nLine 3", + false, + true, + 1000, + "edit", + ) + expect(mockCline.didEditFile).toBe(true) + expect(result).toBe("Tool result message") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("surfaces the unobserved edit remediation as a tool error and publishes nothing", async () => { + const guardError = new Error("File not read yet -- read the file, then retry.") + mockCline.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeSearchReplaceTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockHandleError).toHaveBeenCalledWith("search and replace", guardError) + expect(result).toBeUndefined() + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockCline.didEditFile).toBe(false) + }) + }) }) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 52a7e3c052..930bc1661c 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -26,6 +26,13 @@ vi.mock("delay", () => ({ default: vi.fn(), })) +// The focus-disruption save path reads the original file content via fs.readFile. +vi.mock("fs/promises", () => ({ + default: { + readFile: vi.fn().mockResolvedValue("original content"), + }, +})) + vi.mock("../../../utils/fs", () => ({ fileExistsAtPath: vi.fn().mockResolvedValue(false), createDirectoriesForFile: vi.fn().mockResolvedValue([]), @@ -156,6 +163,11 @@ describe("writeToFileTool", () => { userEdits: null, finalContent: "final content", }), + saveDirectly: vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "final content", + }), scrollToFirstDiff: vi.fn(), updateDiagnosticSettings: vi.fn(), pushToolWriteResult: vi.fn().mockImplementation(async function ( @@ -187,6 +199,7 @@ describe("writeToFileTool", () => { mockCline.say = vi.fn().mockResolvedValue(undefined) mockCline.ask = vi.fn().mockResolvedValue(undefined) mockCline.recordToolError = vi.fn() + mockCline.processQueuedMessages = vi.fn() mockCline.sayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing param error") mockAskApproval = vi.fn().mockResolvedValue(true) @@ -204,6 +217,7 @@ describe("writeToFileTool", () => { fileExists?: boolean isPartial?: boolean accessAllowed?: boolean + experiments?: Record } = {}, ): Promise { // Configure mocks based on test scenario @@ -213,6 +227,13 @@ describe("writeToFileTool", () => { mockedFileExistsAtPath.mockResolvedValue(fileExists) mockCline.rooIgnoreController.validateAccess.mockReturnValue(accessAllowed) + mockCline.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: options.experiments ?? {}, + }), + }) // Create a tool use object const toolUse: ToolUse = { @@ -450,6 +471,58 @@ describe("writeToFileTool", () => { }) }) + describe("guarded write (S4b, epic #1375)", () => { + const focusDisruption = { preventFocusDisruption: true } + + it("publishes through saveDirectly with create kind when the write is approved", async () => { + const result = await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) + + expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + testContent, + false, + true, + 1000, + "create", + ) + expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockCline.fileContextTracker.trackFileContext).toHaveBeenCalledWith(testFilePath, "roo_edited") + expect(mockCline.didEditFile).toBe(true) + expect(mockCline.consecutiveMistakeCount).toBe(0) + expect(result).toBe("Tool result message") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("surfaces the unobserved-existing remediation as a tool error and publishes nothing", async () => { + const guardError = new Error( + `File already exists at ${absoluteFilePath} and was not read before this write -- read the file first, then retry.`, + ) + mockCline.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) + + expect(mockHandleError).toHaveBeenCalledWith("writing file", guardError) + expect(result).toBeUndefined() + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockCline.didEditFile).toBe(false) + }) + + it("surfaces the stale-version remediation as a tool error and publishes nothing", async () => { + const guardError = new Error( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + mockCline.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) + + expect(mockHandleError).toHaveBeenCalledWith("writing file", guardError) + expect(result).toBeUndefined() + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockCline.didEditFile).toBe(false) + }) + }) + describe("error handling", () => { it("handles general file operation errors", async () => { mockCline.diffViewProvider.open.mockRejectedValue(new Error("General error")) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 36f5323f19..51bce14cc7 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -18,7 +18,7 @@ import { arePathsEqual, getReadablePath } from "../../utils/path" import { formatResponse } from "../../core/prompts/responses" import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics" import { Task } from "../../core/task/Task" -import { safeWriteText } from "../../services/file-safety/safeWriteText" +import { guardedWrite, type GuardedWriteKind } from "../../core/tools/guardedWrite" import { DecorationController } from "./DecorationController" @@ -1137,6 +1137,10 @@ export class DiffViewProvider { * @param relPath - Relative path to the file * @param content - Content to write to the file * @param openFile - Whether to show the file in editor (false = open in memory only for diagnostics) + * @param writeKind - Guarded-write kind that selects the S4a guard for this publish. + * Defaults to "create" because this method always publishes a complete file + * content: an unobserved target may only be created when absent, and an + * observed target must still carry the version token recorded at read time. * @returns Result of the save operation including any new problems detected */ async saveDirectly( @@ -1145,6 +1149,7 @@ export class DiffViewProvider { openFile: boolean = true, diagnosticsEnabled: boolean = true, writeDelayMs: number = DEFAULT_WRITE_DELAY_MS, + writeKind: GuardedWriteKind = "create", ): Promise<{ newProblemsMessage: string | undefined userEdits: string | undefined @@ -1155,9 +1160,19 @@ export class DiffViewProvider { // Get diagnostics before editing the file this.preDiagnostics = vscode.languages.getDiagnostics() - // Write the content directly to the file + // Publish through the S4 guarded-write API (epic #1375): an unobserved + // write to an existing file and a stale observed version are rejected + // with a re-read-then-retry remediation instead of overwriting the file. + // Concurrent in-process writes to the same path are already ordered by + // the guard's per-path FIFO chain, so no additional locking is added here. + const task = this.taskRef.deref() + if (!task) { + // Fail closed: without the owning task the observation registry is + // unreachable and the write cannot be guarded. + throw new Error("Cannot guard the write: the owning task is no longer available") + } await createDirectoriesForFile(absolutePath) - await safeWriteText(absolutePath, content) + await guardedWrite(task, relPath, content, writeKind) // Open the document to ensure diagnostics are loaded // When openFile is false (PREVENT_FOCUS_DISRUPTION enabled), we only open in memory diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 511f0e7f3c..0303797c93 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -5,6 +5,13 @@ import delay from "delay" import { makeRange, makeTextDocument, makeTextEditor, makeUri } from "../../../test-utils/vscode" +import * as fs from "fs/promises" + +import { computeVersionToken } from "../../../utils/versionToken" +import { safeWriteText } from "../../../services/file-safety/safeWriteText" +import { ObservationRegistry } from "../../../core/task/observationRegistry" +import type { Task } from "../../../core/task/Task" + // Mock delay vi.mock("delay", () => ({ default: vi.fn().mockResolvedValue(undefined), @@ -25,6 +32,12 @@ vi.mock("../../../services/file-safety/safeWriteText", () => ({ safeWriteText: vi.fn().mockResolvedValue(undefined), })) +// Mock the S1 version token (used by the S4 guarded write); the real +// computeVersionToken needs fs.stat, which is not part of the fs/promises mock above. +vi.mock("../../../utils/versionToken", () => ({ + computeVersionToken: vi.fn(), +})) + // Mock utils vi.mock("../../../utils/fs", () => ({ createDirectoriesForFile: vi.fn().mockResolvedValue([]), @@ -33,6 +46,7 @@ vi.mock("../../../utils/fs", () => ({ // Mock path vi.mock("path", () => ({ resolve: vi.fn((cwd, relPath) => `${cwd}/${relPath}`), + isAbsolute: vi.fn((p: string) => p.startsWith("/")), basename: vi.fn((path) => path.split("/").pop()), dirname: vi.fn((path) => path.split("/").slice(0, -1).join("/") || "/"), join: (...args: string[]) => args.join("/"), @@ -159,8 +173,11 @@ describe("DiffViewProvider", () => { return mockWorkspaceEdit as any }) - // Create a mock Task instance + // Create a mock Task instance. The guarded write (S4b) consults the task's + // S2 observation registry, so the mock carries a real (in-memory) instance. mockTask = { + cwd: mockCwd, + observationRegistry: new ObservationRegistry(), providerRef: { deref: vi.fn().mockReturnValue({ getState: vi.fn().mockResolvedValue({ @@ -793,6 +810,13 @@ describe("DiffViewProvider", () => { // Mock vscode functions vi.mocked(vscode.window.showTextDocument).mockResolvedValue({} as any) vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + + // Baseline for the single-writer flow these tests encode: the file was read + // before the write, so the observation registry holds the version token the + // guarded write recomputes and compares, and the target exists on disk. + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, "v1") + vi.mocked(computeVersionToken).mockResolvedValue("v1") + vi.mocked(fs.access).mockResolvedValue(undefined) }) it("should write content directly to file without opening diff view", async () => { @@ -868,6 +892,68 @@ describe("DiffViewProvider", () => { expect((diffViewProvider as any).relPath).toBe("test.ts") expect((diffViewProvider as any).newContent).toBe("new content") }) + + describe("guarded write (S4b, epic #1375)", () => { + const enoent = () => Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + + it("rejects an unobserved write to an existing file with the read-first remediation", async () => { + mockTask.observationRegistry.clear() + + await expect(diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0)).rejects.toThrow( + "File already exists at /mock/cwd/test.ts and was not read before this write -- read the file first, then retry.", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) + + it("creates an unobserved file when the target is absent", async () => { + mockTask.observationRegistry.clear() + vi.mocked(fs.access).mockRejectedValue(enoent()) + + await diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + }) + + it("rejects an observed write whose version token is stale", async () => { + // The file changed on disk after the read that recorded "v1". + vi.mocked(computeVersionToken).mockResolvedValue("v2") + + const result = diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0) + + await expect(result).rejects.toThrow("Stale version") + await expect(result).rejects.toThrow("re-read the file, then retry.") + expect(safeWriteText).not.toHaveBeenCalled() + }) + + it("rejects an unobserved edit-kind write before any I/O", async () => { + mockTask.observationRegistry.clear() + + await expect( + diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0, "edit"), + ).rejects.toThrow("File not read yet -- read the file, then retry.") + expect(safeWriteText).not.toHaveBeenCalled() + expect(fs.access).not.toHaveBeenCalled() + }) + + it("recreates an observed file that vanished after the read", async () => { + vi.mocked(fs.access).mockRejectedValue(enoent()) + + await diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + }) + + it("fails closed when the owning task has been collected", async () => { + // A real WeakRef cannot be forced to deref to undefined deterministically + // (GC timing), so a structural stub stands in for the collected reference. + diffViewProvider["taskRef"] = { deref: () => undefined } as unknown as WeakRef + + await expect(diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0)).rejects.toThrow( + "Cannot guard the write: the owning task is no longer available", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) + }) }) describe("saveChanges method with diagnostic settings", () => { From 88c935278358255dbc5c1b0a168798eae9977a0d Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 28 Aug 2026 10:14:23 +0800 Subject: [PATCH 008/100] fix(fws): observe the apply_patch hunk read for the guarded publish The hunk reader now doubles as the S2 observation (ReadFileTool contract): stat before and after the read and record the version token when the on-disk version is unchanged, so the in-place modify publish is not rejected as an unobserved write even though this tool just read the exact content the patch was applied to. Regressions: a stable read records the observation; a mid-read change does not, and the publish surfaces the unobserved-existing remediation. (CodeRabbit finding on trial #1413). --- src/core/tools/ApplyPatchTool.ts | 20 +++++- .../__tests__/applyPatchTool.execute.spec.ts | 65 +++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 8b261bdeeb..3daf3a116e 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.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 { versionTokenOfStat } from "../../utils/versionToken" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" import { parsePatch, ParseError, processAllHunks } from "./apply-patch" @@ -85,10 +86,25 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { return } - // Process each hunk + // Process each hunk. The read doubles as the S2 observation for the + // guarded publish (ReadFileTool contract: stat before and after the + // read, observe only when the on-disk version is unchanged between the + // two stats). Without it, the in-place modify publish is an unobserved + // write and the composed chat-diff default rejects it ("File already + // exists ... and was not read before this write") even though this tool + // just read the exact content the patch was applied to. const readFile = async (filePath: string): Promise => { const absolutePath = path.resolve(task.cwd, filePath) - return await fs.readFile(absolutePath, "utf8") + const preReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + const content: string = await fs.readFile(absolutePath, "utf8") + const postReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (preReadStats && postReadStats) { + const preReadToken = versionTokenOfStat(preReadStats) + if (preReadToken === versionTokenOfStat(postReadStats)) { + task.observationRegistry.observe(absolutePath, preReadToken) + } + } + return content } let changes: ApplyPatchFileChange[] diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 2dcf201f04..2e683ca7c6 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -4,12 +4,36 @@ import type { MockedFunction } from "vitest" import { fileExistsAtPath } from "../../../utils/fs" import { isPathOutsideWorkspace } from "../../../utils/pathUtils" +import path from "path" +import * as fsPromises from "fs/promises" import type { Task } from "../../task/Task" +import { ObservationRegistry } from "../../task/observationRegistry" 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: { + stat: ReturnType + unlink: MockedFunction + } + }, +) + vi.mock("fs/promises", () => ({ default: { readFile: vi.fn().mockResolvedValue("original file content\n"), + // Stable on-disk version for the S2 self-read observation (the hunk + // read now stats before and after; equal tokens record the observe). + stat: vi.fn().mockResolvedValue({ + dev: 7n, + ino: 4242n, + size: 1234n, + mtimeNs: 1_700_000_000_123_456_789n, + ctimeNs: 1_700_000_000_789_999_999n, + }), unlink: vi.fn().mockResolvedValue(undefined), }, })) @@ -38,6 +62,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { | "say" | "processQueuedMessages" | "didEditFile" + | "observationRegistry" > let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> @@ -52,6 +77,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { mockTask = { cwd: "/workspace/project", consecutiveMistakeCount: 0, + observationRegistry: new ObservationRegistry(), recordToolUsage: vi.fn(), recordToolError: vi.fn(), rooIgnoreController: { @@ -112,6 +138,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { | "diffViewProvider" | "providerRef" | "fileContextTracker" + | "observationRegistry" > let mockSaveDirectly: MockedFunction<(...args: unknown[]) => Promise> let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> @@ -163,6 +190,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { mockTask = { cwd: "/workspace/project", consecutiveMistakeCount: 0, + observationRegistry: new ObservationRegistry(), recordToolError: vi.fn(), rooIgnoreController: { validateAccess: vi.fn().mockReturnValue(true), @@ -216,6 +244,43 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { expect(mockHandleError).not.toHaveBeenCalled() }) + it("update: observes the hunk read so the guarded publish is not unobserved", async () => { + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // The hunk read doubles as the S2 observation (ReadFileTool contract): + // stable pre/post stats record the version token, so the in-place modify + // publish is not rejected as an unobserved write. + const observed = mockTask.observationRegistry.get(path.resolve("/workspace/project", "src/thing.ts")) + expect(observed?.version).toBe("7:4242:1234:1700000000123456789:1700000000789999999") + }) + + it("update: does not observe when the pre- and post-read tokens disagree", async () => { + // The file changed mid-read: pre/post stats differ, so no observation is + // recorded and the guarded publish surfaces the unobserved-existing + // remediation instead of publishing against a stale version. + const statMock = mockedFsPromises.default.stat + statMock.mockResolvedValueOnce({ dev: 7n, ino: 4242n, size: 1234n, mtimeNs: 1n, ctimeNs: 2n }) + statMock.mockResolvedValueOnce({ dev: 7n, ino: 4242n, size: 9999n, mtimeNs: 3n, ctimeNs: 4n }) + + const guardError = new Error( + "File already exists at /workspace/project/src/thing.ts and was not read before this write -- read the file first, then retry.", + ) + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.observationRegistry.has(path.resolve("/workspace/project", "src/thing.ts"))).toBe(false) + expect(mockHandleError).toHaveBeenCalled() + }) + it("add: publishes the new file through the guarded saveDirectly with create kind", async () => { mockedFileExistsAtPath.mockResolvedValueOnce(false) From e96df628af88fa40b30bcf3f5851aaa5dc8d771a Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 30 Aug 2026 12:33:32 +0800 Subject: [PATCH 009/100] =?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 aafdd1ceab005661d49bf3723eecbe53d3ff7096 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 28 Sep 2026 18:47:40 +0800 Subject: [PATCH 010/100] feat(tools): track read completeness and guard the diff-view save path (S4b, #1375) --- .../__tests__/observationRegistry.spec.ts | 36 +++ src/core/task/observationRegistry.ts | 18 +- src/core/tools/ReadFileTool.ts | 34 ++- src/core/tools/__tests__/guardedWrite.spec.ts | 51 ++++ src/core/tools/__tests__/readFileTool.spec.ts | 227 ++++++++++++++++++ src/core/tools/guardedWrite.ts | 14 ++ src/integrations/editor/DiffViewProvider.ts | 41 +++- .../editor/__tests__/DiffViewProvider.spec.ts | 187 ++++++++++++++- 8 files changed, 589 insertions(+), 19 deletions(-) diff --git a/src/core/task/__tests__/observationRegistry.spec.ts b/src/core/task/__tests__/observationRegistry.spec.ts index 51b73aabde..a3c55ebc6d 100644 --- a/src/core/task/__tests__/observationRegistry.spec.ts +++ b/src/core/task/__tests__/observationRegistry.spec.ts @@ -69,4 +69,40 @@ describe("ObservationRegistry", () => { expect(regA.get("/shared.ts")!.version).toBe("v1") expect(regB.get("/shared.ts")!.version).toBe("v2") }) + + describe("completeness scope (S4b follow-up #46)", () => { + it("defaults to a complete observation when the read scope is not given", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1") + + expect(reg.get("/a/b/c.ts")!.complete).toBe(true) + }) + + it("records a partial observation when the read only returned a view of the file", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1", false) + + expect(reg.get("/a/b/c.ts")!.complete).toBe(false) + }) + + it("re-observing replaces the entry's completeness with the new read's scope", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1", false) + reg.observe("/a/b/c.ts", "v2") + + const obs = reg.get("/a/b/c.ts")! + expect(obs.version).toBe("v2") + expect(obs.complete).toBe(true) + }) + + it("re-observing with a partial scope downgrades a previously complete entry", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1") + reg.observe("/a/b/c.ts", "v2", false) + + const obs = reg.get("/a/b/c.ts")! + expect(obs.version).toBe("v2") + expect(obs.complete).toBe(false) + }) + }) }) diff --git a/src/core/task/observationRegistry.ts b/src/core/task/observationRegistry.ts index 871f80225b..738e3645bb 100644 --- a/src/core/task/observationRegistry.ts +++ b/src/core/task/observationRegistry.ts @@ -14,6 +14,13 @@ export interface FileObservation { version: string /** Millisecond timestamp when the observation was recorded. */ observedAt: number + /** + * Whether the read that produced this observation returned the complete + * file. A slice, line-range, truncated, or indentation-block read returns + * only a view of the file; such an observation authorizes targeted edits + * on the view the model saw, but never a full-file replacement. + */ + complete: boolean } export class ObservationRegistry { @@ -22,11 +29,14 @@ export class ObservationRegistry { /** * Record an observation for a file at its absolute path. * - * Re-observing replaces the entry with a fresh observedAt timestamp and - * the new version token. + * Re-observing replaces the entry with a fresh observedAt timestamp, the + * new version token, and the read's completeness. `complete` defaults to + * true: the existing callers (ApplyPatchTool, spec doubles) all observe + * after reading the full file buffer, so a partial read must opt in + * explicitly. */ - observe(absolutePath: string, version: string): void { - this.entries.set(absolutePath, { version, observedAt: Date.now() }) + observe(absolutePath: string, version: string, complete: boolean = true): void { + this.entries.set(absolutePath, { version, observedAt: Date.now(), complete }) } get(absolutePath: string): FileObservation | undefined { diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 3647c631ee..48f168fe2c 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -220,7 +220,10 @@ export class ReadFileTool extends BaseTool<"read_file"> { const preReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) const buffer = await fs.readFile(fullPath) const fileContent = buffer.toString("utf-8") - const result = this.processTextFile(fileContent, entry) + // S4b follow-up (#46 / epic #1375): processTextFile reports whether the + // returned content is the whole file; the observation below records that + // scope so the write guard can deny full-file updates built on a partial view. + const processed = this.processTextFile(fileContent, entry) await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) @@ -234,12 +237,12 @@ export class ReadFileTool extends BaseTool<"read_file"> { if (preReadStats && postReadStats) { const preReadToken = versionTokenOfStat(preReadStats) if (preReadToken === versionTokenOfStat(postReadStats)) { - task.observationRegistry.observe(fullPath, preReadToken) + task.observationRegistry.observe(fullPath, preReadToken, processed.complete) } } updateFileResult(relPath, { - nativeContent: `File: ${relPath}\n${result}`, + nativeContent: `File: ${relPath}\n${processed.content}`, }) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) @@ -283,8 +286,14 @@ export class ReadFileTool extends BaseTool<"read_file"> { /** * Process a text file according to the requested mode. + * + * Returns the content string plus whether that content is the complete + * file (S4b follow-up #46 / epic #1375): slice mode is complete only + * when it starts at line 1, returns every line, and was not truncated; + * indentation mode is never complete because it returns semantic blocks + * of the file, not the file itself. */ - private processTextFile(content: string, entry: InternalFileEntry): string { + private processTextFile(content: string, entry: InternalFileEntry): { content: string; complete: boolean } { const mode = entry.mode || "slice" if (mode === "indentation") { @@ -317,7 +326,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { output += `\n\nIncluded ranges: ${rangeStr} (total: ${result.totalLines} lines)` } - return output + // Indentation mode returns semantic blocks: never a complete file view. + return { content: output, complete: false } } // Slice mode (default): simple offset/limit reading @@ -344,7 +354,11 @@ export class ReadFileTool extends BaseTool<"read_file"> { output = "Note: File is empty" } - return output + // Complete only when the slice starts at line 1, returns every line, and + // nothing was truncated: then the model saw the whole file. + const complete = !result.wasTruncated && offset0 === 0 && result.returnedLines === result.totalLines + + return { content: output, complete } } /** @@ -792,6 +806,11 @@ export class ReadFileTool extends BaseTool<"read_file"> { const rawContent = await fs.readFile(fullPath, "utf8") // Handle line ranges if specified + // S4b follow-up (#46 / epic #1375): a line-range read returns only the requested + // ranges, and a slice truncated to DEFAULT_LINE_LIMIT returns only the head of + // the file — record such observations as partial so the write guard denies a + // full-file update built on them. + let readComplete = false let content: string if (entry.lineRanges && entry.lineRanges.length > 0) { const lines = rawContent.split("\n") @@ -811,6 +830,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { // Read with default limits using slice mode const result = readWithSlice(rawContent, 0, DEFAULT_LINE_LIMIT) content = result.content + readComplete = !result.wasTruncated if (result.wasTruncated) { content += `\n\n[File truncated: showing ${result.returnedLines} of ${result.totalLines} total lines]` } @@ -830,7 +850,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { if (preReadStats && postReadStats) { const preReadToken = versionTokenOfStat(preReadStats) if (preReadToken === versionTokenOfStat(postReadStats)) { - task.observationRegistry.observe(fullPath, preReadToken) + task.observationRegistry.observe(fullPath, preReadToken, readComplete) } } } catch (error) { diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index 122c1c6d9b..f9f6e8ad23 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -230,6 +230,57 @@ describe("guardedWrite (S4a, epic #1375)", () => { }) }) + describe("observed update (read completeness, S4b follow-up #46)", () => { + it("rejects a full-file update when only a partial read observed the file - no I/O, nothing published", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", false) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "doc.txt", "new content", "update")).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + expect(mockedComputeVersionToken).not.toHaveBeenCalled() + expect(mockedFsAccess).not.toHaveBeenCalled() + }) + + it("publishes a full-file update when the observation is complete and the version matches", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", true) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "new content", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "new content") + }) + + it("leaves edit-kind publishes unaffected by a partial observation - the model saw the edited region", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", false) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "patched", "edit") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "patched") + }) + + it("leaves create-kind publishes unaffected by a partial observation", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", false) + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "created", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "created") + }) + }) + describe("edit", () => { it("fails read-first when the file was never observed - nothing published, no I/O", async () => { const task = createMockTask() diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 7e2fa3aac7..2ad95cb3ba 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -1811,5 +1811,232 @@ describe("ReadFileTool", () => { expect(regB.get("/shared.ts")!.version).toBe("v2") }) }) + + describe("read completeness scope (S4b follow-up #46)", () => { + // The stat mock only implements the members the tool and versionToken read. + const bigintStats = (): Stats => + ({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + }) as unknown as Stats + + it("native: a full, untruncated slice read from line 1 records a complete observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\n")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 2, + wasTruncated: false, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute({ path: "full.ts" }, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, calledVersion, calledComplete] = observeSpy.mock.calls[0] + expect(calledPath).toContain("full.ts") + expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) + expect(calledComplete).toBe(true) + expect(reg.get(calledPath)!.complete).toBe(true) + }) + + it("native: a truncated slice read records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc\nd\ne")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a", + returnedLines: 1, + totalLines: 5, + wasTruncated: true, + includedRanges: [[1, 1]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute( + { path: "trunc.ts", offset: 1, limit: 1 }, + mockTask as unknown as Task, + callbacks, + ) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + expect(reg.get(calledPath)!.complete).toBe(false) + }) + + it("native: an offset read that is not truncated still records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc\nd\ne")) + mockedReadWithSlice.mockReturnValue({ + content: "4 | d\n5 | e", + returnedLines: 2, + totalLines: 5, + wasTruncated: false, + includedRanges: [[4, 5]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute( + { path: "offset.ts", offset: 4, limit: 2 }, + mockTask as unknown as Task, + callbacks, + ) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + }) + + it("native: an indentation-mode block read records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("function f() { return 1 }")) + mockedReadWithIndentation.mockReturnValue({ + content: "10 | function f() {", + wasTruncated: false, + includedRanges: [[10, 20]], + totalLines: 100, + returnedLines: 11, + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute( + { path: "indent.ts", mode: "indentation" }, + mockTask as unknown as Task, + callbacks, + ) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + }) + + it("legacy: a line-range read records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue("a\nb\nc\nd\ne") + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "ranges.ts", lineRanges: [{ start: 2, end: 4 }] }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledPath).toContain("ranges.ts") + expect(calledComplete).toBe(false) + }) + + it("legacy: a full, untruncated slice read records a complete observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue("a\nb") + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 2, + wasTruncated: false, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-full.ts" }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(true) + }) + + it("legacy: a slice truncated to the default limit records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue("a\nb\nc") + mockedReadWithSlice.mockReturnValue({ + content: "1 | a", + returnedLines: 1, + totalLines: 5000, + wasTruncated: true, + includedRanges: [[1, 1]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-trunc.ts" }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + }) + }) }) }) diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index 596cc41035..e5e94dc8c9 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -225,6 +225,20 @@ export async function guardedWrite( return enqueue(absolutePath, async () => { const obs = task.observationRegistry.get(absolutePath) + // A full-file replacement (kind "update") requires the model to have + // seen the whole file: an observation recorded from a slice, range, + // truncated, or indentation-block read only authorizes the view the + // model saw. Publishing a full file built on a partial view would + // silently drop everything the model never read, so the guard fails + // closed with a re-read-the-whole-file remediation. + if (kind === "update" && obs !== undefined && obs.complete === false) { + throw new GuardRejectedError( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + absolutePath, + ) + } + if (obs === undefined) { // Edit-style writes require a prior read: no observation, no write. if (kind === "edit") { diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 51bce14cc7..65e6204b3e 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -18,6 +18,7 @@ import { arePathsEqual, getReadablePath } from "../../utils/path" import { formatResponse } from "../../core/prompts/responses" import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics" import { Task } from "../../core/task/Task" +import { versionTokenOfStat } from "../../utils/versionToken" import { guardedWrite, type GuardedWriteKind } from "../../core/tools/guardedWrite" import { DecorationController } from "./DecorationController" @@ -121,7 +122,21 @@ export class DiffViewProvider { this.preDiagnostics = vscode.languages.getDiagnostics() if (fileExists) { + // S4b follow-up (#44 / epic #1375): the preview is a full read of the + // on-disk original. Observe it with the S2 stat-matched contract so the + // accepted save (a full-file replacement) publishes through the guard's + // version check instead of bypassing it; a stat mismatch (or failure) + // leaves the target unobserved and the save fails closed. + const preStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) this.originalContent = await fs.readFile(absolutePath, "utf-8") + const postStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + const displayTask = this.taskRef.deref() + if (displayTask && preStats && postStats) { + const displayToken = versionTokenOfStat(preStats) + if (displayToken === versionTokenOfStat(postStats)) { + displayTask.observationRegistry.observe(absolutePath, displayToken, true) + } + } } else { this.originalContent = "" } @@ -133,6 +148,14 @@ export class DiffViewProvider { // Make sure the file exists before we open it. if (!fileExists) { await fs.writeFile(absolutePath, "") + // S4b follow-up (#44): the empty placeholder is fully known (empty), + // so observe the just-written on-disk version as complete; the accepted + // save then publishes through the guard's version check. + const displayTask = this.taskRef.deref() + const placeholderStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (displayTask && placeholderStats) { + displayTask.observationRegistry.observe(absolutePath, versionTokenOfStat(placeholderStats), true) + } } // If the file was already open, close it (must happen after showing the @@ -340,9 +363,19 @@ export class DiffViewProvider { const updatedDocument = this.activeDiffEditor.document const editedContent = updatedDocument.getText() - if (updatedDocument.isDirty) { - await updatedDocument.save() + // S4b follow-up (#44 / epic #1375): the accepted diff is a full-file + // replacement, so publish it through the guarded-write API instead of + // saving the document raw. open() observed the on-disk version the + // preview was built on; replaceIfVersion rejects the save when the file + // changed after the preview or the target was never observed, with the + // standard re-read-then-retry remediation. + const saveTask = this.taskRef.deref() + if (!saveTask) { + // Fail closed: without the owning task the observation registry is + // unreachable and the save cannot be guarded. + throw new Error("Cannot guard the write: the owning task is no longer available") } + await guardedWrite(saveTask, this.relPath, editedContent, "update") // Stop tracking touches and cancel any pending scroll-to-diff before any // programmatic editor activation below. @@ -352,8 +385,8 @@ export class DiffViewProvider { await this.closeAllDiffViews() // Read auto-close preferences from state; fall back to defaults that - // preserve the existing behavior when unset. - const saveTask = this.taskRef.deref() + // preserve the existing behavior when unset (saveTask was resolved above + // for the guarded publish). const saveState = await saveTask?.providerRef.deref()?.getState() await this.keepOrCloseEditedFile( diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 0303797c93..c9e3ae573a 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -7,7 +7,8 @@ import { makeRange, makeTextDocument, makeTextEditor, makeUri } from "../../../t import * as fs from "fs/promises" -import { computeVersionToken } from "../../../utils/versionToken" +import { computeVersionToken, versionTokenOfStat } from "../../../utils/versionToken" +import type { BigIntStats } from "fs" import { safeWriteText } from "../../../services/file-safety/safeWriteText" import { ObservationRegistry } from "../../../core/task/observationRegistry" import type { Task } from "../../../core/task/Task" @@ -22,6 +23,9 @@ vi.mock("fs/promises", () => ({ readFile: vi.fn().mockResolvedValue("file content"), writeFile: vi.fn().mockResolvedValue(undefined), access: vi.fn().mockResolvedValue(undefined), + // The S4b follow-up (#44) preview observation stats the target before and + // after reading it; undefined stats leave the target unobserved (fail closed). + stat: vi.fn().mockResolvedValue(undefined), mkdir: vi.fn().mockResolvedValue(undefined), rename: vi.fn().mockResolvedValue(undefined), unlink: vi.fn().mockResolvedValue(undefined), @@ -34,9 +38,15 @@ vi.mock("../../../services/file-safety/safeWriteText", () => ({ // Mock the S1 version token (used by the S4 guarded write); the real // computeVersionToken needs fs.stat, which is not part of the fs/promises mock above. -vi.mock("../../../utils/versionToken", () => ({ - computeVersionToken: vi.fn(), -})) +// Keep the real versionTokenOfStat: DiffViewProvider.open() (S4b follow-up #44) +// derives the preview token from its synthetic stat mock with that pure function. +vi.mock("../../../utils/versionToken", async () => { + const actual = await vi.importActual("../../../utils/versionToken") + return { + computeVersionToken: vi.fn(), + versionTokenOfStat: actual.versionTokenOfStat, + } +}) // Mock utils vi.mock("../../../utils/fs", () => ({ @@ -215,6 +225,15 @@ describe("DiffViewProvider", () => { addLines: vi.fn(), clear: vi.fn(), } + + // S4b follow-up (#44): saveChanges publishes the accepted diff through the + // guarded write, which requires the target to be observed at the previewed + // on-disk version. Seed the preconditions for the relPaths the suites below + // use; the guarded-write suites override or clear as needed. + mockTask.observationRegistry.observe(`${mockCwd}/test.txt`, "v1") + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, "v1") + mockTask.observationRegistry.observe(`${mockCwd}/mock-target-file.ts`, "v1") + vi.mocked(computeVersionToken).mockResolvedValue("v1") }) describe("update method", () => { @@ -956,6 +975,157 @@ describe("DiffViewProvider", () => { }) }) + describe("saveChanges guarded publish (S4b follow-up #44)", () => { + // Synthetic stat the preview observation tokenizes with the real (unmocked) + // versionTokenOfStat. Cast: the mock only implements the members the tool + // and versionToken read. + const previewStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + + // Structural TextEditor double for the open()/saveChanges flow: only the + // members they touch. One documented unknown cast stands in for the full + // vscode.TextEditor type (avoids any casts; see AGENTS.md). + const mockTextEditor = (fsPath: string, text = ""): vscode.TextEditor => + ({ + document: { + uri: { fsPath, scheme: "file" }, + getText: vi.fn().mockReturnValue(text), + lineCount: 0, + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + }) as unknown as vscode.TextEditor + + // Structural TextDocument double for the onDidOpenTextDocument callback. + const mockTextDocument = (fsPath: string): vscode.TextDocument => + ({ uri: { fsPath, scheme: "file" } }) as unknown as vscode.TextDocument + + beforeEach(() => { + // Private members are set via bracket notation (spec convention). + diffViewProvider["relPath"] = "test.ts" + diffViewProvider["newContent"] = "new content" + diffViewProvider["activeDiffEditor"] = mockTextEditor(`${mockCwd}/test.ts`, "new content") + diffViewProvider["preDiagnostics"] = [] + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockTextEditor(`${mockCwd}/test.ts`)) + vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + }) + + it("open() observes the previewed on-disk version of an existing file as a complete read", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/observed.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/observed.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("observed.ts") + + const obs = mockTask.observationRegistry.get(`${mockCwd}/observed.ts`) + expect(obs).toBeDefined() + expect(obs!.version).toBe(versionTokenOfStat(previewStats)) + expect(obs!.complete).toBe(true) + }) + + it("open() observes the empty placeholder of a new file so the accepted save can be guarded", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/brand-new.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/brand-new.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("brand-new.ts") + + const obs = mockTask.observationRegistry.get(`${mockCwd}/brand-new.ts`) + expect(obs).toBeDefined() + expect(obs!.version).toBe(versionTokenOfStat(previewStats)) + expect(obs!.complete).toBe(true) + }) + + it("open() leaves the target unobserved when the pre/post stat mismatch (mid-preview mutation)", async () => { + const mutatedStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(301), + mtimeNs: BigInt(4_000_000_001n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/mutated.ts`)), 0) + return { dispose: vi.fn() } + }) + const mockEditor = mockTextEditor(`${mockCwd}/mutated.ts`) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValueOnce(previewStats).mockResolvedValueOnce(mutatedStats) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("mutated.ts") + + expect(mockTask.observationRegistry.get(`${mockCwd}/mutated.ts`)).toBeUndefined() + }) + + it("publishes the accepted content through the guarded write (safeWriteText)", async () => { + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + + const result = await diffViewProvider.saveChanges(false) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + expect(result.newProblemsMessage).toBe("") + }) + + it("rejects the accepted save when the file changed after the preview (stale version)", async () => { + vi.mocked(computeVersionToken).mockResolvedValue("v2") + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) + + it("rejects the accepted save when the target was never observed (fail closed)", async () => { + mockTask.observationRegistry.clear() + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + "File already exists at /mock/cwd/test.ts and was not read before this write -- read the file first, then retry.", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) + + it("fails closed when the owning task has been collected", async () => { + // A real WeakRef cannot be forced to deref to undefined deterministically + // (GC timing), so a structural stub stands in for the collected reference. + diffViewProvider["taskRef"] = { deref: () => undefined } as unknown as WeakRef + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + "Cannot guard the write: the owning task is no longer available", + ) + }) + }) + describe("saveChanges method with diagnostic settings", () => { beforeEach(() => { // Setup common mocks for saveChanges tests @@ -1760,6 +1930,11 @@ describe("DiffViewProvider", () => { const setupProvider = (stateOverrides: Record = {}) => { const task = { + cwd: mockCwd, + // S4b follow-up (#44): saveChanges publishes through the guarded write, + // which resolves the path against task.cwd and consults the task's + // observation registry — both must be present on this suite's mock task. + observationRegistry: new ObservationRegistry(), providerRef: { deref: vi.fn().mockReturnValue({ getState: vi.fn().mockResolvedValue({ @@ -1771,6 +1946,10 @@ describe("DiffViewProvider", () => { }, } const provider = new DiffViewProvider(mockCwd, task as any) + // The preview observation + matching version token let the guarded save + // proceed so these tests stay focused on the auto-close decision table. + task.observationRegistry.observe(`${mockTargetPath}`, "v1") + vi.mocked(computeVersionToken).mockResolvedValue("v1") ;(provider as any).relPath = "auto-close-test.ts" ;(provider as any).newContent = "content" ;(provider as any).activeDiffEditor = buildActiveDiffEditor() From 70ad36630dcdb663ecfa0451e3138fddd0881da8 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 28 Sep 2026 18:57:13 +0800 Subject: [PATCH 011/100] test(tools): kill surviving mutants in the S4b completeness guards (S4b, #1375) --- src/core/tools/ReadFileTool.ts | 8 ++- .../editor/__tests__/DiffViewProvider.spec.ts | 58 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 48f168fe2c..743b56cd96 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -354,9 +354,11 @@ export class ReadFileTool extends BaseTool<"read_file"> { output = "Note: File is empty" } - // Complete only when the slice starts at line 1, returns every line, and - // nothing was truncated: then the model saw the whole file. - const complete = !result.wasTruncated && offset0 === 0 && result.returnedLines === result.totalLines + // Complete only when the slice starts at line 1 and is not truncated: + // readWithSlice then runs to the end of the file and returns every line + // (returnedLines === totalLines follows from those two conditions), so the + // model saw the whole file; a partial start or a truncated tail does not. + const complete = offset0 === 0 && !result.wasTruncated return { content: output, complete } } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index c9e3ae573a..85fc35482c 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1037,6 +1037,7 @@ describe("DiffViewProvider", () => { expect(obs).toBeDefined() expect(obs!.version).toBe(versionTokenOfStat(previewStats)) expect(obs!.complete).toBe(true) + expect(vi.mocked(fs.stat)).toHaveBeenCalledWith(`${mockCwd}/observed.ts`, { bigint: true }) }) it("open() observes the empty placeholder of a new file so the accepted save can be guarded", async () => { @@ -1058,6 +1059,7 @@ describe("DiffViewProvider", () => { expect(obs).toBeDefined() expect(obs!.version).toBe(versionTokenOfStat(previewStats)) expect(obs!.complete).toBe(true) + expect(vi.mocked(fs.stat)).toHaveBeenCalledWith(`${mockCwd}/brand-new.ts`, { bigint: true }) }) it("open() leaves the target unobserved when the pre/post stat mismatch (mid-preview mutation)", async () => { @@ -1084,6 +1086,48 @@ describe("DiffViewProvider", () => { await diffViewProvider.open("mutated.ts") expect(mockTask.observationRegistry.get(`${mockCwd}/mutated.ts`)).toBeUndefined() + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(1, `${mockCwd}/mutated.ts`, { bigint: true }) + }) + + it("open() leaves the target unobserved when the pre-read stat fails (stat gap)", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/gap.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/gap.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + // The pre-read stat fails and only the post-read stat resolves: the + // on-disk version the preview is built on is unproven, so open() must + // leave the target unobserved even though a post stat is available. + vi.mocked(fs.stat) + .mockRejectedValueOnce(new Error("EPERM: operation not permitted")) + .mockResolvedValueOnce(previewStats) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("gap.ts") + + expect(mockTask.observationRegistry.get(`${mockCwd}/gap.ts`)).toBeUndefined() + }) + + it("open() leaves a new file unobserved when the placeholder stat fails", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/gap-create.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/gap-create.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockRejectedValue(new Error("EPERM: operation not permitted")) + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("gap-create.ts") + + expect(mockTask.observationRegistry.get(`${mockCwd}/gap-create.ts`)).toBeUndefined() }) it("publishes the accepted content through the guarded write (safeWriteText)", async () => { @@ -1115,6 +1159,20 @@ describe("DiffViewProvider", () => { expect(safeWriteText).not.toHaveBeenCalled() }) + it("rejects the accepted save when the observation was only a partial read", async () => { + // A partial observation (slice/range/truncated/indentation read) must not + // authorize the full-file replacement the accept path performs, even when + // the version is current. + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, "v1", false) + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) + it("fails closed when the owning task has been collected", async () => { // A real WeakRef cannot be forced to deref to undefined deterministically // (GC timing), so a structural stub stands in for the collected reference. From d8c5275f3c2378b81abe596b80a1f8ce3eb099c1 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 28 Sep 2026 19:01:32 +0800 Subject: [PATCH 012/100] test(tools): pin the post-read stat options in the S4b preview tests (S4b, #1375) --- .../editor/__tests__/DiffViewProvider.spec.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 85fc35482c..e7e5cae02f 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1037,7 +1037,8 @@ describe("DiffViewProvider", () => { expect(obs).toBeDefined() expect(obs!.version).toBe(versionTokenOfStat(previewStats)) expect(obs!.complete).toBe(true) - expect(vi.mocked(fs.stat)).toHaveBeenCalledWith(`${mockCwd}/observed.ts`, { bigint: true }) + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(1, `${mockCwd}/observed.ts`, { bigint: true }) + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(2, `${mockCwd}/observed.ts`, { bigint: true }) }) it("open() observes the empty placeholder of a new file so the accepted save can be guarded", async () => { @@ -1059,7 +1060,10 @@ describe("DiffViewProvider", () => { expect(obs).toBeDefined() expect(obs!.version).toBe(versionTokenOfStat(previewStats)) expect(obs!.complete).toBe(true) - expect(vi.mocked(fs.stat)).toHaveBeenCalledWith(`${mockCwd}/brand-new.ts`, { bigint: true }) + // The create path stats exactly once (the placeholder), so the single + // call carries the bigint requirement. + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(1, `${mockCwd}/brand-new.ts`, { bigint: true }) + expect(vi.mocked(fs.stat)).toHaveBeenCalledTimes(1) }) it("open() leaves the target unobserved when the pre/post stat mismatch (mid-preview mutation)", async () => { @@ -1087,6 +1091,7 @@ describe("DiffViewProvider", () => { expect(mockTask.observationRegistry.get(`${mockCwd}/mutated.ts`)).toBeUndefined() expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(1, `${mockCwd}/mutated.ts`, { bigint: true }) + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(2, `${mockCwd}/mutated.ts`, { bigint: true }) }) it("open() leaves the target unobserved when the pre-read stat fails (stat gap)", async () => { From 786bd5c7554a7cc0ce44b021ae5f56f3267cb5b5 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 28 Sep 2026 19:17:50 +0800 Subject: [PATCH 013/100] fix(tools): clean up a partial DACL dump left by a failed icacls save (S4b, #1375) --- .../__tests__/safeWriteText.spec.ts | 23 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 13 +++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 4accc2b71e..df4e8b15eb 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -298,6 +298,29 @@ describe("safeWriteText", () => { expect(fs.rename).toHaveBeenCalled() }) + it("win32 DACL: a partial dump left by a failed save is removed and never restored", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // icacls save fails — a real icacls may have written a partial dump + // before erroring, so the dump path must be cleaned up and must never + // be used for a restore. + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") cb(new Error("icacls save error"), "", "") + return fakeChild + }) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // write committed; only the save was attempted (no restore from a failed dump) + expect(fs.rename).toHaveBeenCalled() + expect(execFile).toHaveBeenCalledTimes(1) + const saveArgs = vi.mocked(execFile).mock.calls[0]?.[1] + expect(saveArgs?.[1]).toBe("/save") + // the dump path (possibly partially created by icacls) was unlinked + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + }) + it("win32 DACL save args are [targetPath, /save, dumpPath, /T] before backup rename", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 71871032e5..e759577683 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -157,6 +157,7 @@ export async function safeWriteText(filePath: string, content: string, options?: let backupPath: string | null = null let releaseBackupOnSuccess = false let daclDumpPath: string | null = null // tracked for cleanup in finally + let restoreDacl = false // true only when the DACL dump saved successfully try { // -- Step 1: write content to staging temp file ------------------- @@ -215,9 +216,11 @@ export async function safeWriteText(filePath: string, content: string, options?: await fs.access(targetPath) // target exists? daclDumpPath = targetPath + ".acl.tmp" const saved = await _saveDaclWindows(targetPath, daclDumpPath, options?.execFileRunner) - if (!saved) { - daclDumpPath = null // skip DACL handling entirely - } + // Keep tracking the dump path even when the save failed: a failed + // icacls may have left a partial dump behind, and the cleanup paths + // below must remove it. Only a successfully saved dump may be + // restored onto the committed file. + restoreDacl = saved } catch { // target does not exist or access failed — no DACL handling daclDumpPath = null @@ -260,7 +263,9 @@ export async function safeWriteText(filePath: string, content: string, options?: } // -- Step 5 (win32): restore DACL AFTER commit rename --------- - if (platform === "win32" && daclDumpPath !== null) { + // Restore only from a successfully saved dump; a failed save leaves + // restoreDacl false while daclDumpPath stays tracked for cleanup. + if (platform === "win32" && restoreDacl && daclDumpPath !== null) { const restoredDir = path.dirname(targetPath) await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) } From 7204ead07c192757571c8580e901d5fc7e7f38fe Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 28 Sep 2026 19:29:43 +0800 Subject: [PATCH 014/100] fix(tools): address CR review round 1 - partial-read gate, post-publish refresh, dirty buffer, staging dir (S4b, #1375) CodeRabbit review round 1 (CHANGES_REQUESTED @ d8c5275) - five findings: 1. guardedWrite: extend the partial-read completeness gate to any full-file replacement - a create whose target is still on disk publishes a full file built on the model's content, so it needs a complete observation just like kind update (fresh creates on absent targets stay allowed). 2. guardedWrite: after a successful publish, refresh the observation with the post-publish on-disk token (complete). The model just wrote the full file content, so a consecutive same-task write must not fail stale against the version it just published; rejected guards throw before the refresh. 3. DiffViewProvider.open(): record the preview/placeholder token only when the task has no observation for the path. The model's read-time observation records the version its content was built on, and replacing it with the on-disk version at preview time would blind the save to changes between the read and the preview (an external v1-based overwrite would clobber a v2 change). Remember the placeholder token for cleanup. 4. DiffViewProvider.saveChanges(): clear the accepted buffer's dirty state via a disk revert (content is identical; document.save() would republish through the unguarded file service and advance the token). On guard rejection: discard-only cleanup - reload the newer disk content (never re-save originalContent), unlink the new-file placeholder while it is still exactly the file open() wrote, close the diff views - then rethrow the guard verdict. 5. safeWriteText: best-effort removal of the now-empty staging directory after a successful self-staged commit (ENOTEMPTY = a concurrent write still using it; the removal must never un-commit a published file). Tests: create-kind partial-overwrite rejection, recreate-despite-partial, complete-observation overwrite, post-publish refresh (edit + update), serialized last-write-wins concurrency (three suites), open() observation preservation (modify + create), dirty-buffer discard on rejection, staging-dir cleanup (3 cases). 153/153 + 11/11 affected suites; tsc --noEmit and eslint --max-warnings=0 clean; suppression counts unchanged. --- src/core/tools/__tests__/guardedWrite.spec.ts | 130 ++++++++++++++---- src/core/tools/guardedWrite.ts | 85 +++++++----- src/integrations/editor/DiffViewProvider.ts | 85 +++++++++++- .../editor/__tests__/DiffViewProvider.spec.ts | 116 ++++++++++++++++ .../__tests__/safeWriteText.spec.ts | 42 ++++++ src/services/file-safety/safeWriteText.ts | 8 ++ 6 files changed, 400 insertions(+), 66 deletions(-) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index f9f6e8ad23..bbccb074a8 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -74,6 +74,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { describe("unobserved create", () => { it("succeeds when the file is absent and publishes via safeWriteText", async () => { mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") // post-publish refresh const task = createMockTask() await guardedWrite(task, "new-file.txt", "hello", "create") @@ -132,6 +133,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { describe("unobserved update", () => { it("succeeds when the file is absent (same create guard)", async () => { mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") // post-publish refresh const task = createMockTask() await guardedWrite(task, "new-file.txt", "hello", "update") @@ -157,6 +159,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { const reg = new ObservationRegistry() reg.observe(abs("gone.txt"), "v1") mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") // post-publish refresh const task = createMockTask({ observationRegistry: reg }) await guardedWrite(task, "gone.txt", "back", "create") @@ -268,10 +271,39 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "patched") }) - it("leaves create-kind publishes unaffected by a partial observation", async () => { + it("rejects a create-kind full-file overwrite of an existing file when only a partial read observed it", async () => { const reg = new ObservationRegistry() reg.observe(abs("doc.txt"), "v1", false) - mockedFsAccess.mockResolvedValue(undefined) + mockedFsAccess.mockResolvedValue(undefined) // target still on disk + const task = createMockTask({ observationRegistry: reg }) + + // A "create" whose target exists publishes through the same + // full-file replacement path as "update": a partial observation must + // not authorize dropping the content the model never read. + await expect(guardedWrite(task, "doc.txt", "created", "create")).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + expect(mockedComputeVersionToken).not.toHaveBeenCalled() + }) + + it("allows a create-kind recreate of a vanished file despite a partial observation - a fresh create needs no prior read", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", false) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) // target absent + mockedComputeVersionToken.mockResolvedValue("v1") // post-publish refresh + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "created", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "created") + }) + + it("publishes a create-kind overwrite of an existing file when the observation is complete", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedFsAccess.mockResolvedValue(undefined) // target still on disk mockedComputeVersionToken.mockResolvedValue("v1") const task = createMockTask({ observationRegistry: reg }) @@ -281,6 +313,44 @@ describe("guardedWrite (S4a, epic #1375)", () => { }) }) + describe("observation refresh after publish (S4b review round)", () => { + it("refreshes the observation with the post-publish token so a consecutive edit does not fail stale", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + // The first publish moves the on-disk token: edit 1's pre-write CAS + // sees v1, the post-publish refresh sees v2, and edit 2's CAS sees v2. + mockedComputeVersionToken.mockResolvedValueOnce("v1").mockResolvedValueOnce("v2").mockResolvedValue("v2") + + await guardedWrite(task, "doc.txt", "first edit", "edit") + await guardedWrite(task, "doc.txt", "second edit", "edit") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("doc.txt"), "second edit") + // the observation now carries the post-publish token, complete + expect(reg.get(abs("doc.txt"))?.version).toBe("v2") + expect(reg.get(abs("doc.txt"))?.complete).toBe(true) + }) + + it("refreshes as a COMPLETE observation so a consecutive full-file update is not rejected partial", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + mockedComputeVersionToken + .mockResolvedValueOnce("v1") // update 1 pre-write CAS + .mockResolvedValueOnce("v2") // update 1 post-publish refresh + .mockResolvedValue("v2") // update 2 pre-write CAS + + await guardedWrite(task, "doc.txt", "first", "update") + await guardedWrite(task, "doc.txt", "second", "update") + + // a refresh recorded as partial would have the second update + // rejected by the completeness gate + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(reg.get(abs("doc.txt"))?.complete).toBe(true) + }) + }) + describe("edit", () => { it("fails read-first when the file was never observed - nothing published, no I/O", async () => { const task = createMockTask() @@ -318,13 +388,17 @@ describe("guardedWrite (S4a, epic #1375)", () => { }) describe("concurrency: per-path FIFO chain", () => { - it("two concurrent updates on one path - exactly one publishes, the other fails stale", async () => { + it("two concurrent updates on one path - serialized, both publish against the refreshed observation", async () => { const reg = new ObservationRegistry() reg.observe(abs("shared.txt"), "v1") mockedComputeVersionToken.mockResolvedValue("v1") const task = createMockTask({ observationRegistry: reg }) - // The first publish changes the on-disk state (new token). + // The first publish changes the on-disk state (new token), and the + // guarded write refreshes the observation to it, so the second + // write CASes against v2 and publishes too: same-task writes are + // serialized last-write-wins in submission order, while a token that + // moves outside the task's own publish still fails stale. mockedSafeWriteText.mockImplementation(async () => { mockedComputeVersionToken.mockResolvedValue("v2") }) @@ -333,16 +407,14 @@ describe("guardedWrite (S4a, epic #1375)", () => { const p2 = guardedWrite(task, "shared.txt", "second", "update") const [r1, r2] = await Promise.allSettled([p1, p2]) - if (r1.status !== "fulfilled" || r2.status !== "rejected") { - throw new Error("expected exactly one publish, got " + r1.status + " / " + r2.status) - } - expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) - expect(r2.reason.message).toBe( - "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", - ) + expect(r1.status).toBe("fulfilled") + expect(r2.status).toBe("fulfilled") + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, abs("shared.txt"), "first") + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, abs("shared.txt"), "second") }) - it("observed-absent then two concurrent creates - the second fails stale", async () => { + it("observed-absent then two concurrent creates - the second publishes against the refreshed observation", async () => { const reg = new ObservationRegistry() reg.observe(abs("absent.txt"), "v1") // read before, file later vanished mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) @@ -352,7 +424,8 @@ describe("guardedWrite (S4a, epic #1375)", () => { mockedSafeWriteText.mockImplementation(async () => { publishes += 1 if (publishes === 1) { - // After the first publish the file exists again under a new token. + // After the first publish the file exists again under a new + // token, and the guarded write refreshes the observation to it. mockedFsAccess.mockResolvedValue(undefined) mockedComputeVersionToken.mockResolvedValue("v2") } @@ -362,12 +435,13 @@ describe("guardedWrite (S4a, epic #1375)", () => { const p2 = guardedWrite(task, "absent.txt", "second", "create") const [r1, r2] = await Promise.allSettled([p1, p2]) - if (r1.status !== "fulfilled" || r2.status !== "rejected") { - throw new Error("expected exactly one publish, got " + r1.status + " / " + r2.status) - } - expect(publishes).toBe(1) - expect(r2.reason.message).toContain("Stale version") - expect(r2.reason.message).toContain("re-read the file, then retry.") + // Both same-task creates serialize: the second CASes against the + // refreshed v2 observation and publishes its content last-write-wins. + expect(r1.status).toBe("fulfilled") + expect(r2.status).toBe("fulfilled") + expect(publishes).toBe(2) + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, abs("absent.txt"), "first") + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, abs("absent.txt"), "second") }) it("the chain settles after a rejection - a later matching write still runs", async () => { @@ -472,19 +546,19 @@ describe("guardedWrite (S4a, epic #1375)", () => { mockedComputeVersionToken.mockResolvedValue("v2") }) - // Plain spelling vs the trailing-separator spelling: with one chain key - // they are strictly ordered (first matches v1, second sees v2). + // Plain spelling vs the trailing-separator spelling: with one chain + // key they are strictly ordered. The first matches v1 and publishes; + // its post-publish refresh records v2, so the second CASes against + // v2 and publishes too (serialized same-task writes). const p1 = guardedWrite(task, canonical, "first", "update") const p2 = guardedWrite(task, canonical + "/", "second", "update") const [r1, r2] = await Promise.allSettled([p1, p2]) - if (r1.status !== "fulfilled" || r2.status !== "rejected") { - throw new Error("expected exactly one publish, got " + r1.status + " / " + r2.status) - } - expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) - expect(r2.reason.message).toBe( - "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", - ) + expect(r1.status).toBe("fulfilled") + expect(r2.status).toBe("fulfilled") + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, canonical, "first") + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, canonical, "second") }) }) diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index e5e94dc8c9..b616b53f49 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -211,8 +211,15 @@ function resolveAbsolutePath(task: Task, relPathOrAbsolute: string): string { * - observed + create on a file that vanished after the read: recreate; * - observed otherwise: replaceIfVersion (CAS on the S1 version token); * - unobserved + edit: unobservedEditGuard. - * 3. Runs the chosen guard on the per-path FIFO chain so concurrent writes to + * 3. A full-file replacement ("update", or a "create" whose target still + * exists) additionally requires a complete observation: a partial read + * (slice, range, truncated, indentation block) authorizes targeted edits + * only, never a full-file overwrite of the existing content. + * 4. Runs the chosen guard on the per-path FIFO chain so concurrent writes to * the same path are deterministically ordered. + * 5. After a successful publish, refreshes the observation with the new + * on-disk token (complete) so consecutive writes by the same task do not + * fail stale against the version they just published. */ export async function guardedWrite( task: Task, @@ -225,43 +232,57 @@ export async function guardedWrite( return enqueue(absolutePath, async () => { const obs = task.observationRegistry.get(absolutePath) - // A full-file replacement (kind "update") requires the model to have - // seen the whole file: an observation recorded from a slice, range, - // truncated, or indentation-block read only authorizes the view the - // model saw. Publishing a full file built on a partial view would - // silently drop everything the model never read, so the guard fails - // closed with a re-read-the-whole-file remediation. - if (kind === "update" && obs !== undefined && obs.complete === false) { - throw new GuardRejectedError( - "File was only partially read (line slice, range, truncated view, or indentation block) -- " + - "a full-file replacement needs the complete content; re-read the whole file, then retry.", - absolutePath, - ) - } - - if (obs === undefined) { + if (kind === "edit") { // Edit-style writes require a prior read: no observation, no write. - if (kind === "edit") { + // A targeted edit only authorizes the view the model saw, so a + // partial observation is valid for the edit itself; the version + // check still rejects a file that moved since the read. + if (obs === undefined) { await unobservedEditGuard(absolutePath) + } else { + await replaceIfVersion(absolutePath, obs.version, content) + } + } else { + // "create" or "update" publish a full file built on the model's + // content. A replacement of an existing target -- an "update", or a + // "create" whose target is still on disk -- therefore requires a + // complete observation: a slice, range, truncated, or + // indentation-block read only authorizes the view the model saw, and + // publishing over the existing file would silently drop everything + // the model never read, so the guard fails closed with a + // re-read-the-whole-file remediation. A fresh create (absent target) + // needs no prior read and stays allowed. + const absent = kind === "create" && (await fileIsAbsent(absolutePath)) + if (!absent && obs !== undefined && obs.complete === false) { + throw new GuardRejectedError( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + absolutePath, + ) } - // Never read: only an absent target may be created. (The edit guard - // above rejects before reaching this line.) - await createIfAbsent(absolutePath, content) - return - } - if (kind === "edit") { - await replaceIfVersion(absolutePath, obs.version, content) - return + if (obs === undefined) { + // Never read: only an absent target may be created. + await createIfAbsent(absolutePath, content) + } else if (absent) { + // A "create" on a file that vanished after the read recreates it. + await createIfAbsent(absolutePath, content) + } else { + // The version recorded at read time must still match the on-disk + // token. + await replaceIfVersion(absolutePath, obs.version, content) + } } - // kind is "create" or "update": a "create" on a file that vanished - // after the read recreates it; otherwise the version recorded at read - // time must still match the on-disk token. - if (kind === "create" && (await fileIsAbsent(absolutePath))) { - await createIfAbsent(absolutePath, content) - } else { - await replaceIfVersion(absolutePath, obs.version, content) + // A successful publish changes the on-disk token (the temp-file rename + // changes ino, size, and mtime). The model just wrote the full file + // content, so refresh the observation with the new complete token: a + // consecutive write by the same task must not fail stale against the + // version it just published. Rejected guards throw above, so this only + // runs after a publish actually happened. + const publishedToken = await computeVersionToken(absolutePath).catch(() => undefined) + if (publishedToken !== undefined) { + task.observationRegistry.observe(absolutePath, publishedToken, true) } }) } diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 65e6204b3e..85bdfe8125 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -84,6 +84,14 @@ export class DiffViewProvider { viewColumn: vscode.ViewColumn }> = [] private taskRef: WeakRef + /** + * Version token of the empty placeholder open() wrote for a new file (the + * create branch), captured under the S2 stat-matched contract. A rejected + * guarded save removes the placeholder only while its on-disk token still + * equals this value, so a file created by someone else in the meantime is + * never unlinked. + */ + private placeholderVersion: string | undefined = undefined constructor( private cwd: string, @@ -131,7 +139,16 @@ export class DiffViewProvider { this.originalContent = await fs.readFile(absolutePath, "utf-8") const postStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) const displayTask = this.taskRef.deref() - if (displayTask && preStats && postStats) { + // Only record the preview token when the task has no observation for + // this path: a read_file observation recorded the version the model's + // content was built on, and the accept-time guard must compare against + // THAT token. Replacing it with the current on-disk token would blind + // the save to changes that happened between the model's read and this + // preview (e.g. an external editor), letting a v1-based overwrite + // clobber the v2 change. An unread target has no observation, so the + // preview token is recorded (stat-matched) and the save checks against + // the on-disk version the preview was built on. + if (displayTask && preStats && postStats && !displayTask.observationRegistry.has(absolutePath)) { const displayToken = versionTokenOfStat(preStats) if (displayToken === versionTokenOfStat(postStats)) { displayTask.observationRegistry.observe(absolutePath, displayToken, true) @@ -150,11 +167,21 @@ export class DiffViewProvider { await fs.writeFile(absolutePath, "") // S4b follow-up (#44): the empty placeholder is fully known (empty), // so observe the just-written on-disk version as complete; the accepted - // save then publishes through the guard's version check. + // save then publishes through the guard's version check. As with the + // modify branch above, an existing observation for this path (the file + // was read before it vanished) wins: the save checks against the + // model's read token and fails closed rather than overwriting content + // the model has not seen. const displayTask = this.taskRef.deref() - const placeholderStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) - if (displayTask && placeholderStats) { - displayTask.observationRegistry.observe(absolutePath, versionTokenOfStat(placeholderStats), true) + if (displayTask && !displayTask.observationRegistry.has(absolutePath)) { + const placeholderStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (placeholderStats) { + const placeholderVersion = versionTokenOfStat(placeholderStats) + displayTask.observationRegistry.observe(absolutePath, placeholderVersion, true) + // Remember the placeholder token so a rejected save can remove + // the placeholder only while it is still the exact file we wrote. + this.placeholderVersion = placeholderVersion + } } } @@ -375,7 +402,52 @@ export class DiffViewProvider { // unreachable and the save cannot be guarded. throw new Error("Cannot guard the write: the owning task is no longer available") } - await guardedWrite(saveTask, this.relPath, editedContent, "update") + try { + await guardedWrite(saveTask, this.relPath, editedContent, "update") + } catch (error) { + // Discard-only failure cleanup. The guard rejected the publish + // (stale version, unobserved target, or a partial-read observation), + // so the on-disk content is the newer source of truth. Reload it + // into the buffer (discarding the rejected edit), remove the empty + // new-file placeholder while it is still exactly the file open() + // wrote, and close the diff views. Never use revertChanges() here: + // it restores originalContent and saves it, which would overwrite + // the newer disk content that caused the rejection. Best-effort — + // the guard verdict is rethrown below. + try { + if (updatedDocument.isDirty) { + await vscode.window.showTextDocument(updatedDocument, { preserveFocus: true, preview: false }) + await vscode.commands.executeCommand("workbench.action.files.revert") + } + if (this.editType === "create" && this.placeholderVersion) { + const placeholderStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (placeholderStats && versionTokenOfStat(placeholderStats) === this.placeholderVersion) { + await fs.unlink(absolutePath).catch(() => undefined) + } + } + await this.closeAllDiffViews() + } catch { + // cleanup is best-effort; the guard verdict below is the outcome + } + throw error + } + + // The publish wrote the buffer's exact content to disk, but the + // document still carries its pre-save dirty flag and the close helpers + // skip dirty tabs. Revert from disk (content is identical — no write, + // no token change) to clear the dirty state before the close logic. + // document.save() would re-publish through the unguarded VS Code file + // service and advance the on-disk token, so the revert is the + // content-safe way to clear it. + if (updatedDocument.isDirty) { + try { + await vscode.window.showTextDocument(updatedDocument, { preserveFocus: true, preview: false }) + await vscode.commands.executeCommand("workbench.action.files.revert") + } catch { + // best-effort: a dirty tab that cannot be cleared stays open + // rather than risking a save-prompt loop + } + } // Stop tracking touches and cancel any pending scroll-to-diff before any // programmatic editor activation below. @@ -1161,6 +1233,7 @@ export class DiffViewProvider { this.userTouchedDocument = false this.userTouchedDiffEditor = false this.snapshotPreviewTabs = [] + this.placeholderVersion = undefined } /** diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index e7e5cae02f..3d02b6414c 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1187,6 +1187,122 @@ describe("DiffViewProvider", () => { "Cannot guard the write: the owning task is no longer available", ) }) + + it("open() keeps the model's existing observation instead of replacing it with the preview token", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/t3-modify.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/t3-modify.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() + // The model read the file before the preview: its observation must + // survive so the accept-time guard compares against the version the + // model's content was built on, not the on-disk version at preview + // time. + mockTask.observationRegistry.observe(`${mockCwd}/t3-modify.ts`, "model-token", true) + + await diffViewProvider.open("t3-modify.ts") + + const obs = mockTask.observationRegistry.get(`${mockCwd}/t3-modify.ts`) + expect(obs?.version).toBe("model-token") + expect(obs?.version).not.toBe(versionTokenOfStat(previewStats)) + // the stat-matched pair is still taken; only the observation is kept + expect(vi.mocked(fs.stat)).toHaveBeenCalledTimes(2) + }) + + it("open() keeps the model's existing observation for a recreated file instead of the placeholder token", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/t3-create.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/t3-create.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + mockTask.observationRegistry.observe(`${mockCwd}/t3-create.ts`, "model-token", true) + + await diffViewProvider.open("t3-create.ts") + + // the placeholder is still written, but the model's observation wins: + // no placeholder stat, no observation replacement, no placeholder + // token remembered for cleanup + expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/t3-create.ts`, "") + expect(vi.mocked(fs.stat)).not.toHaveBeenCalled() + expect(mockTask.observationRegistry.get(`${mockCwd}/t3-create.ts`)?.version).toBe("model-token") + expect(diffViewProvider["placeholderVersion"]).toBeUndefined() + }) + + it("clears the dirty buffer via a disk revert after a successful guarded publish", async () => { + // The user edited the buffer before accepting, so the document is + // dirty: the publish wrote the exact buffer content, and the dirty + // flag must be cleared by reverting from disk rather than saving the + // buffer (which would republish through the unguarded file service). + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + + const result = await diffViewProvider.saveChanges(false) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + expect(vi.mocked(vscode.window.showTextDocument)).toHaveBeenCalledWith(expect.any(Object), { + preserveFocus: true, + preview: false, + }) + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + expect(dirtyEditor.document.save).not.toHaveBeenCalled() + expect(result.newProblemsMessage).toBe("") + }) + + it("discards the dirty buffer and removes the new-file placeholder after a guarded rejection, then rethrows", async () => { + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + diffViewProvider.editType = "create" + // open() wrote and observed the empty placeholder; the on-disk token + // then moved past it, so the guard rejects the publish. + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(safeWriteText).not.toHaveBeenCalled() + // discard-only cleanup: the newer disk content is reloaded into the + // buffer (never re-saved from originalContent), and the placeholder + // is unlinked while it is still exactly the file open() wrote + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + expect(fs.unlink).toHaveBeenCalledWith(`${mockCwd}/test.ts`) + expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() + }) }) describe("saveChanges method with diagnostic settings", () => { diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index df4e8b15eb..9d23d8cbd5 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -12,6 +12,7 @@ vi.mock("fs/promises", () => ({ access: vi.fn(), rename: vi.fn(), unlink: vi.fn(), + rmdir: vi.fn(), realpath: vi.fn(), })) @@ -67,6 +68,7 @@ describe("safeWriteText", () => { vi.mocked(fs.access).mockResolvedValue(undefined) vi.mocked(fs.rename).mockResolvedValue(undefined) vi.mocked(fs.unlink).mockResolvedValue(undefined) + vi.mocked(fs.rmdir).mockResolvedValue(undefined) // Existing-target default: a regular 0o644 file. vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o644)) // Default sync-write behaviour: report that all requested bytes were @@ -114,6 +116,46 @@ describe("safeWriteText", () => { // no unlink of temp (it's now the committed file; DACL skipped via platform:linux) expect(fs.unlink).not.toHaveBeenCalled() }) + + it("removes the now-empty staging directory after a successful self-staged commit", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "hello", { platform: "linux" }) + + // the staging subdir is removed best-effort after the commit rename + // (stringContaining: the SUT and the test helper resolve Windows + // drive-relative paths differently, as in the existing staging tests) + expect(fs.rmdir).toHaveBeenCalledTimes(1) + expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + }) + + it("does not remove the staging directory when the caller supplies its own tempPath", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const callerTemp = "/tmp/test-dir/caller-staged.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "hello", { platform: "linux", tempPath: callerTemp }) + + // the caller owns its temp file's directory; safeWriteText must not + // rmdir a directory it did not create + expect(fs.rmdir).not.toHaveBeenCalled() + }) + + it("a failed staging-dir removal never fails the committed write", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rmdir).mockRejectedValue(Object.assign(new Error("ENOTEMPTY"), { code: "ENOTEMPTY" })) + + await expect(safeWriteText(targetPath, "hello", { platform: "linux" })).resolves.toBeUndefined() + + // the commit rename still happened and the rmdir error was swallowed + expect(fs.rename).toHaveBeenCalled() + expect(fs.rmdir).toHaveBeenCalled() + }) }) // ── Test 2: fsync ordering ─────────────────────────────────────────────── diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index e759577683..8c0413e87b 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -286,6 +286,14 @@ export async function safeWriteText(filePath: string, content: string, options?: } // tempPath is now the committed file; no cleanup needed. + + // Best-effort: remove the now-empty staging directory. Self-staged + // writes only; ENOTEMPTY means a concurrent write in the same + // directory is still using it, and a failure must never un-commit a + // published file, so the removal swallows all errors. + if (!options?.tempPath) { + await fs.rmdir(_stagingDir(dirPath)).catch(() => {}) + } } catch (originalError: unknown) { // -- Rollback / cleanup on failure ---------------------------------- if (backupPath && releaseBackupOnSuccess) { From 34fcfcd5120b13a73f7d7433d75587cc051abe94 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 28 Sep 2026 19:40:25 +0800 Subject: [PATCH 015/100] test(tools): kill the 18 preflight survivors from the T1-T5 fix round (S4b, #1375) zdt mutation preflight on 7204ead07 (e96df62 -> 7204ead07): 124 valid / 106 killed / 18 survived -> now all resolved. Killed by tests: - guardedWrite.ts:284 (refresh condition -> true): new test "keeps the previous observation when the post-publish token cannot be computed (deletion race after publish)" - ENOENT on the refresh stat keeps the pre-publish observation, so a token-less record would break the next write. - DiffViewProvider.ts:418 (isDirty gate -> true): new test "does not reload a clean buffer when the guard rejects" - a clean document must not be activated or reverted. - DiffViewProvider.ts:419 (revert options {} / flags): the success and failure tests now assert the exact showTextDocument arguments (document + { preserveFocus: true, preview: false }). - DiffViewProvider.ts:422 (outer placeholder gate || / -> true): new test "does not unlink the placeholder when the edit type is not create" - the gate short-circuits before stat/unlink. - DiffViewProvider.ts:423 (stat options {} / bigint false): the failure test now asserts the bigint stat options. - DiffViewProvider.ts:424 (inner placeholder gate || / -> true): new test "does not unlink when the placeholder stat is unavailable" - the stat guard short-circuits before the token comparison and the view close still runs. - safeWriteText.ts:268 (win32 DACL restore gate -> true): the staging cleanup test now asserts no icacls execFile on non-win32 platforms. - safeWriteText.ts:294 (options?.tempPath -> options.tempPath): new test with no options argument at all (undefined options must not throw). Justified as equivalent (Stryker disable next-line, with rationale): - DiffViewProvider.ts:406 (kind "update" -> ""): "" takes the same guard branches as "update" (only "edit" and "create" diverge), so the mutant is equivalent at this sole production call site. - safeWriteText.ts:160 (restoreDacl initial false -> true): the initial value is never observed - win32 always reassigns it before the step-5 read, and the step-5 gate is false on other platforms. 158/158 affected suites (guardedWrite 34, DiffViewProvider 93, safeWriteText 31); tsc --noEmit and eslint --max-warnings=0 clean; suppression counts unchanged. --- src/core/tools/__tests__/guardedWrite.spec.ts | 21 +++++ src/integrations/editor/DiffViewProvider.ts | 1 + .../editor/__tests__/DiffViewProvider.spec.ts | 93 ++++++++++++++++++- .../__tests__/safeWriteText.spec.ts | 15 +++ src/services/file-safety/safeWriteText.ts | 1 + 5 files changed, 130 insertions(+), 1 deletion(-) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index bbccb074a8..87b29ad31e 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -349,6 +349,27 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) expect(reg.get(abs("doc.txt"))?.complete).toBe(true) }) + + it("keeps the previous observation when the post-publish token cannot be computed (deletion race after publish)", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + // The publish succeeded but the file was deleted before the refresh + // stat: the token computation rejects (ENOENT) and the guard's + // .catch normalizes it to undefined. The observation must keep the + // pre-publish version (the next write fails closed through the + // standard deleted/stale path) rather than a token-less record. + mockedComputeVersionToken + .mockResolvedValueOnce("v1") // edit 1 pre-write CAS + .mockRejectedValueOnce({ code: "ENOENT" }) // edit 1 post-publish refresh - file deleted + .mockResolvedValue("v1") // edit 2 pre-write CAS + + await guardedWrite(task, "doc.txt", "first edit", "edit") + await guardedWrite(task, "doc.txt", "second edit", "edit") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(reg.get(abs("doc.txt"))?.version).toBe("v1") + }) }) describe("edit", () => { diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 85bdfe8125..ccbeb60898 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -403,6 +403,7 @@ export class DiffViewProvider { throw new Error("Cannot guard the write: the owning task is no longer available") } try { + // Stryker disable next-line StringLiteral: "" is semantically identical to "update" in guardedWrite (only "edit" and "create" take distinct branches), so the StringLiteral mutant is equivalent at this sole production call site. await guardedWrite(saveTask, this.relPath, editedContent, "update") } catch (error) { // Discard-only failure cleanup. The guard rejected the publish diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 3d02b6414c..7aee30d37b 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1261,7 +1261,9 @@ describe("DiffViewProvider", () => { const result = await diffViewProvider.saveChanges(false) expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") - expect(vi.mocked(vscode.window.showTextDocument)).toHaveBeenCalledWith(expect.any(Object), { + // the revert activates the exact document with the exact options: + // preserveFocus keeps the user's focus, preview: false pins the tab + expect(vi.mocked(vscode.window.showTextDocument)).toHaveBeenCalledWith(dirtyEditor.document, { preserveFocus: true, preview: false, }) @@ -1299,10 +1301,99 @@ describe("DiffViewProvider", () => { // discard-only cleanup: the newer disk content is reloaded into the // buffer (never re-saved from originalContent), and the placeholder // is unlinked while it is still exactly the file open() wrote + expect(vi.mocked(vscode.window.showTextDocument)).toHaveBeenCalledWith(dirtyEditor.document, { + preserveFocus: true, + preview: false, + }) expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + // the placeholder stat uses the bigint stat options (the version + // token requires the full-precision fields) + expect(fs.stat).toHaveBeenCalledWith(`${mockCwd}/test.ts`, { bigint: true }) expect(fs.unlink).toHaveBeenCalledWith(`${mockCwd}/test.ts`) expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() }) + + it("does not reload a clean buffer when the guard rejects - only dirty buffers are discarded", async () => { + // The buffer was never touched (isDirty is falsy): there is nothing + // to discard, so the failure cleanup must not activate the document + // or run the revert command. + const cleanEditor = mockTextEditor(`${mockCwd}/test.ts`, "new content") + diffViewProvider["activeDiffEditor"] = cleanEditor + vi.mocked(computeVersionToken).mockResolvedValue("v2") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(safeWriteText).not.toHaveBeenCalled() + expect(vi.mocked(vscode.window.showTextDocument)).not.toHaveBeenCalled() + expect(vi.mocked(vscode.commands.executeCommand)).not.toHaveBeenCalled() + expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() + }) + + it("does not unlink the placeholder when the edit type is not create - the outer gate short-circuits", async () => { + // placeholderVersion is remembered (open() took the placeholder path) + // but the edit type is not create: the outer gate must short-circuit + // before statting or unlinking, so the placeholder on disk is left + // untouched. + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + diffViewProvider.editType = "modify" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) // placeholder still on disk + vi.mocked(computeVersionToken).mockResolvedValue("moved") // stale rejection + diffViewProvider["placeholderVersion"] = placeholderToken + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(fs.stat).not.toHaveBeenCalled() + expect(fs.unlink).not.toHaveBeenCalled() + // the dirty discard and the view close still ran + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() + }) + + it("does not unlink when the placeholder stat is unavailable and still closes the diff views", async () => { + // The placeholder vanished between open() and the rejected save: the + // stat guard must short-circuit BEFORE the token comparison (no + // unlink) and the best-effort cleanup must not skip the view close. + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + // the placeholder vanished: stat rejects and the guard's .catch + // normalizes it to undefined stats + vi.mocked(fs.stat).mockRejectedValue(new Error("ENOENT: no such file or directory")) + vi.mocked(computeVersionToken).mockResolvedValue("moved") // stale rejection + diffViewProvider["placeholderVersion"] = placeholderToken + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(fs.unlink).not.toHaveBeenCalled() + expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() + }) }) describe("saveChanges method with diagnostic settings", () => { diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 9d23d8cbd5..5270496789 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -129,6 +129,21 @@ describe("safeWriteText", () => { // drive-relative paths differently, as in the existing staging tests) expect(fs.rmdir).toHaveBeenCalledTimes(1) expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + // the win32 DACL restore gate must stay closed on other platforms: + // no icacls save or restore is attempted + expect(execFile).not.toHaveBeenCalled() + }) + + it("still removes the staging directory when no options are supplied at all", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + // options is undefined: the self-staged check must not dereference it + await expect(safeWriteText(targetPath, "hello")).resolves.toBeUndefined() + + expect(fs.rmdir).toHaveBeenCalledTimes(1) + expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) }) it("does not remove the staging directory when the caller supplies its own tempPath", async () => { diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 8c0413e87b..761b31d53d 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -157,6 +157,7 @@ export async function safeWriteText(filePath: string, content: string, options?: let backupPath: string | null = null let releaseBackupOnSuccess = false let daclDumpPath: string | null = null // tracked for cleanup in finally + // Stryker disable next-line BooleanLiteral: the initial value is never observed - on win32 restoreDacl is always reassigned by the DACL save before the step-5 read, and on other platforms the step-5 gate is false. let restoreDacl = false // true only when the DACL dump saved successfully try { From 56b329a569825c8bd6b55ec42b0da1e4c45e03ca Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 28 Sep 2026 19:53:20 +0800 Subject: [PATCH 016/100] fix(tools): restructure the win32 DACL restore gate and close the last mutant holes (S4b, #1375) Preflight round 6 (e96df62 -> 34fcfcd51) left three blocking survivors. Two of them (safeWriteText step-5 gate sub-expressions) were equivalent mutants produced by the redundant triple gate platform === "win32" && restoreDacl && daclDumpPath !== null: restoreDacl can only be set inside the win32 block, which also always assigns daclDumpPath, so the platform and null conjuncts were implied by the boolean. Restructured instead of suppressing: - step 2 now tracks daclDumpPath only when the icacls save succeeded; a failed save unlinks the possibly partial dump immediately (the same cleanup the finally path already performed, but sooner), so a partial dump never survives and can never be restored from. - step 5 gates on daclDumpPath !== null alone: non-null implies win32 and a successful save, so every mutant of the new gate is distinguishable by the existing tests (linux: execFile not called; win32 save failure: exactly one icacls call; win32 success: save + restore calls). - the restoreDacl variable and its directive are gone. The third survivor (DiffViewProvider placeholder inner gate, token comparison -> true) is killed by a new test: a placeholder whose stats moved on since open() (different token) must not be unlinked. 159/159 affected suites; tsc --noEmit and eslint --max-warnings=0 clean; suppression counts unchanged. --- .../editor/__tests__/DiffViewProvider.spec.ts | 36 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 32 ++++++++++------- 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 7aee30d37b..ad3d5eecb1 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1394,6 +1394,42 @@ describe("DiffViewProvider", () => { expect(fs.unlink).not.toHaveBeenCalled() expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() }) + + it("does not unlink a placeholder whose content changed after open() - the token gate refuses", async () => { + // The placeholder is still on disk, but its stats no longer match the + // token open() recorded: another writer touched the file, so the + // cleanup must refuse to unlink (it would destroy the other + // writer's content). + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + // the on-disk placeholder moved on since open(): same identity, new + // size -> a different token than the one open() recorded + const movedStats = { ...previewStats, size: BigInt(301) } as unknown as BigIntStats + vi.mocked(fs.stat).mockResolvedValue(movedStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved") // stale rejection + diffViewProvider["placeholderVersion"] = placeholderToken + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(fs.stat).toHaveBeenCalledWith(`${mockCwd}/test.ts`, { bigint: true }) + // token mismatch -> the placeholder is NOT ours anymore: no unlink + expect(fs.unlink).not.toHaveBeenCalled() + expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() + }) }) describe("saveChanges method with diagnostic settings", () => { diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 761b31d53d..6675864208 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -156,9 +156,9 @@ export async function safeWriteText(filePath: string, content: string, options?: let backupPath: string | null = null let releaseBackupOnSuccess = false - let daclDumpPath: string | null = null // tracked for cleanup in finally - // Stryker disable next-line BooleanLiteral: the initial value is never observed - on win32 restoreDacl is always reassigned by the DACL save before the step-5 read, and on other platforms the step-5 gate is false. - let restoreDacl = false // true only when the DACL dump saved successfully + // Non-null only when the win32 step-2 block saved a successful DACL dump: + // it gates the step-5 restore and is tracked for the cleanup unlinks. + let daclDumpPath: string | null = null try { // -- Step 1: write content to staging temp file ------------------- @@ -215,13 +215,18 @@ export async function safeWriteText(filePath: string, content: string, options?: if (platform === "win32") { try { await fs.access(targetPath) // target exists? - daclDumpPath = targetPath + ".acl.tmp" - const saved = await _saveDaclWindows(targetPath, daclDumpPath, options?.execFileRunner) - // Keep tracking the dump path even when the save failed: a failed - // icacls may have left a partial dump behind, and the cleanup paths - // below must remove it. Only a successfully saved dump may be - // restored onto the committed file. - restoreDacl = saved + const dumpPath = targetPath + ".acl.tmp" + const saved = await _saveDaclWindows(targetPath, dumpPath, options?.execFileRunner) + if (saved) { + // Only a successfully saved dump may be restored onto the + // committed file (step 5). + daclDumpPath = dumpPath + } else { + // A failed icacls may have left a partial dump behind; + // remove it now (best-effort) so no partial dump survives and + // no later step can restore from it. + await fs.unlink(dumpPath).catch(() => {}) + } } catch { // target does not exist or access failed — no DACL handling daclDumpPath = null @@ -264,9 +269,10 @@ export async function safeWriteText(filePath: string, content: string, options?: } // -- Step 5 (win32): restore DACL AFTER commit rename --------- - // Restore only from a successfully saved dump; a failed save leaves - // restoreDacl false while daclDumpPath stays tracked for cleanup. - if (platform === "win32" && restoreDacl && daclDumpPath !== null) { + // daclDumpPath is non-null only when the win32 step-2 block saved a + // successful dump, so this gate is closed on every other platform + // and on every failed save. + if (daclDumpPath !== null) { const restoredDir = path.dirname(targetPath) await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) } From dd150182aead90377b1bf0eeb857549357389bf5 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 28 Sep 2026 20:00:43 +0800 Subject: [PATCH 017/100] test(tools): kill the last OptionalChaining mutant from the DACL restructure (S4b, #1375) Preflight round 7 (e96df62 -> 56b329a56) left one blocking survivor: safeWriteText.ts:219 OptionalChaining options?.execFileRunner (step 2). With options undefined on a win32 host the dereference would throw, step 2's catch would swallow it, and the DACL save/restore would be skipped silently. The bare-options test (no options argument) now also asserts the win32 default-platform outcome on win32 hosts: the icacls save + restore both ran (two execFile calls) and the dump was unlinked afterwards. 31/31 safeWriteText suites; tsc --noEmit and eslint --max-warnings=0 clean. --- .../file-safety/__tests__/safeWriteText.spec.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 5270496789..29be4e34f0 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -139,11 +139,19 @@ describe("safeWriteText", () => { vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) - // options is undefined: the self-staged check must not dereference it + // options is undefined: the self-staged check and the optional-chained + // DACL runner lookup must not dereference it await expect(safeWriteText(targetPath, "hello")).resolves.toBeUndefined() expect(fs.rmdir).toHaveBeenCalledTimes(1) expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + if (process.platform === "win32") { + // default platform is win32: the DACL save + restore still ran + // through the default icacls path (options?.execFileRunner must + // not throw when options is undefined) + expect(vi.mocked(execFile)).toHaveBeenCalledTimes(2) + expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + } }) it("does not remove the staging directory when the caller supplies its own tempPath", async () => { From a49fdb509f82e48cd68c89fa1701dc7ac9b3009f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 28 Sep 2026 22:36:27 +0800 Subject: [PATCH 018/100] fix(tools): track the create-branch placeholder even over a prior observation (S4b, #1375) CR incremental review on dd150182a (finding 4122072131, functional correctness / major): recreating a file the task had read earlier always failed - the accept-time CAS compared the placeholder token on disk with the vanished file's stale read token, so saveChanges() was rejected stale every time and the placeholder leaked (placeholderVersion was never captured, so the failure cleanup could not unlink it). The create branch of open() now records the placeholder token and placeholderVersion unconditionally (when the task is live and the placeholder stat succeeds): a prior observation describes a file that no longer exists and has no protective value for the new file, while the placeholder token is the correct accept-time baseline - an external change to the placeholder before the accept still moves the on-disk token and fails the CAS. The modify branch keeps the model's read-time observation winning (the original T3 bug: an existing file's preview token must not replace the version the model's content was built on). Specs: the recreated-file open() test now expects the placeholder token to be recorded (and remembered for cleanup), and a new end-to-end test covers open() -> saveChanges() after a prior read, where the accept succeeds against the placeholder token (the exact trace CR described). 160/160 affected suites; tsc --noEmit and eslint --max-warnings=0 clean; suppression counts unchanged. --- src/integrations/editor/DiffViewProvider.ts | 16 +++--- .../editor/__tests__/DiffViewProvider.spec.ts | 51 ++++++++++++++++--- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index ccbeb60898..aabd132fbc 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -167,13 +167,17 @@ export class DiffViewProvider { await fs.writeFile(absolutePath, "") // S4b follow-up (#44): the empty placeholder is fully known (empty), // so observe the just-written on-disk version as complete; the accepted - // save then publishes through the guard's version check. As with the - // modify branch above, an existing observation for this path (the file - // was read before it vanished) wins: the save checks against the - // model's read token and fails closed rather than overwriting content - // the model has not seen. + // save then publishes through the guard's version check. Unlike the + // modify branch above, this is recorded even when the task already has + // an observation for the path: that observation describes a file that + // no longer exists, and keeping it would make the accept-time CAS + // (the placeholder token on disk vs. the vanished file's token) fail + // every time, so recreating the file would always fail and the + // placeholder would leak. The placeholder token is the correct baseline + // for the new file: an external change to the placeholder before the + // accept still moves the on-disk token and fails the CAS. const displayTask = this.taskRef.deref() - if (displayTask && !displayTask.observationRegistry.has(absolutePath)) { + if (displayTask) { const placeholderStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) if (placeholderStats) { const placeholderVersion = versionTokenOfStat(placeholderStats) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index ad3d5eecb1..3841ef0f2b 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1215,7 +1215,13 @@ describe("DiffViewProvider", () => { expect(vi.mocked(fs.stat)).toHaveBeenCalledTimes(2) }) - it("open() keeps the model's existing observation for a recreated file instead of the placeholder token", async () => { + it("open() records the placeholder token for a create even when the model read the file before it vanished", async () => { + // The vanished file's old observation must NOT win here: it describes + // a file that no longer exists, and keeping it would make the + // accept-time CAS (placeholder token on disk vs. the vanished file's + // token) fail every time, so recreating the file would always fail + // and the placeholder would leak. The placeholder token is the + // correct baseline for the new file. const mockEditor = mockTextEditor(`${mockCwd}/t3-create.ts`) vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { @@ -1224,19 +1230,50 @@ describe("DiffViewProvider", () => { }) vi.mocked(vscode.window).visibleTextEditors = [mockEditor] vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) diffViewProvider.editType = "create" mockTask.observationRegistry.clear() mockTask.observationRegistry.observe(`${mockCwd}/t3-create.ts`, "model-token", true) await diffViewProvider.open("t3-create.ts") - // the placeholder is still written, but the model's observation wins: - // no placeholder stat, no observation replacement, no placeholder - // token remembered for cleanup + const placeholderToken = versionTokenOfStat(previewStats) + // the placeholder is written, stat-matched, observed (replacing the + // vanished file's stale token), and remembered for cleanup expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/t3-create.ts`, "") - expect(vi.mocked(fs.stat)).not.toHaveBeenCalled() - expect(mockTask.observationRegistry.get(`${mockCwd}/t3-create.ts`)?.version).toBe("model-token") - expect(diffViewProvider["placeholderVersion"]).toBeUndefined() + expect(vi.mocked(fs.stat)).toHaveBeenCalledTimes(1) + expect(mockTask.observationRegistry.get(`${mockCwd}/t3-create.ts`)?.version).toBe(placeholderToken) + expect(diffViewProvider["placeholderVersion"]).toBe(placeholderToken) + }) + + it("saveChanges() accepts a recreate after a prior read - the accept-time CAS checks the placeholder token", async () => { + // The exact recreate-always-failed trace: the model read the file + // (observed "v1" by the outer beforeEach), the file then vanished, + // open() wrote the placeholder, and the accept must succeed against + // the placeholder token (not the vanished file's stale token). + const mockEditor = mockTextEditor(`${mockCwd}/test.ts`, "new content") + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/test.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + diffViewProvider.editType = "create" + // prior read observation (the file has since vanished) + expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)?.version).toBe("v1") + + await diffViewProvider.open("test.ts") + + // the placeholder is untouched on disk: the accept-time token matches + // the placeholder token open() recorded + vi.mocked(computeVersionToken).mockResolvedValue(versionTokenOfStat(previewStats)) + + const result = await diffViewProvider.saveChanges(false) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + expect(result.newProblemsMessage).toBe("") }) it("clears the dirty buffer via a disk revert after a successful guarded publish", async () => { From d037753c0d03b111615daa8523470add05e103e0 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 28 Sep 2026 22:44:04 +0800 Subject: [PATCH 019/100] test(tools): cover the create-branch task gate with a collected-task case (S4b, #1375) Preflight round 9 (e96df62 -> a49fdb509) left one blocking survivor: DiffViewProvider.ts:180 ConditionalExpression (if (displayTask) -> true). With a dead WeakRef the dereference is undefined and the mutant body would throw inside open(), which no test exercised. New spec: open() on a create with a collected task still writes the placeholder but tracks nothing (no observation, no placeholderVersion). 96/96 DiffViewProvider suites; tsc --noEmit and eslint --max-warnings=0 clean; suppression counts unchanged. --- .../editor/__tests__/DiffViewProvider.spec.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 3841ef0f2b..0e5009861a 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1246,6 +1246,32 @@ describe("DiffViewProvider", () => { expect(diffViewProvider["placeholderVersion"]).toBe(placeholderToken) }) + it("open() on a create with a collected task writes the placeholder but tracks nothing", async () => { + // The task has been collected (dead WeakRef): the placeholder is still + // written (the file must exist to open the diff), but there is no live + // task to observe - the later save fails closed through the taskRef + // fail-closed path. + const mockEditor = mockTextEditor(`${mockCwd}/t3-dead-task.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/t3-dead-task.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + diffViewProvider["taskRef"] = { deref: () => undefined } as unknown as WeakRef + + await diffViewProvider.open("t3-dead-task.ts") + + expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/t3-dead-task.ts`, "") + // no live task: nothing observed, nothing remembered for cleanup + expect(mockTask.observationRegistry.get(`${mockCwd}/t3-dead-task.ts`)).toBeUndefined() + expect(diffViewProvider["placeholderVersion"]).toBeUndefined() + }) + it("saveChanges() accepts a recreate after a prior read - the accept-time CAS checks the placeholder token", async () => { // The exact recreate-always-failed trace: the model read the file // (observed "v1" by the outer beforeEach), the file then vanished, From d8c34aa9d0d1c34714f86c3863d28c640ea2c0a4 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 28 Sep 2026 23:09:50 +0800 Subject: [PATCH 020/100] fix(tools): stat-match the create placeholder + fail-closed save cleanup (S4b, #1375) CR incremental review on d037753c0 raised two findings on the create branch of open() (DiffViewProvider.ts): 1. A placeholder another writer touched between open()'s fs.writeFile() and the observation was recorded as a complete observation of content open() never read (and would let the token-guarded cleanup unlink the writer's file). The create branch now verifies the placeholder with the same S2 stat-matched contract as the modify branch: pre-stat, utf-8 read, post- stat; only a stat-matched empty read records the token as a complete observation. A non-empty read or stat mismatch (or a failed post-stat) records nothing, so the prior observation is untouched and the save fails closed. 2. A rejected save with a collected task threw before the discard-only cleanup, leaking the empty placeholder. The fail-closed throw now sits inside the try block so the task-unavailable rejection flows through the same cleanup as a guard verdict (token-guarded placeholder unlink, dirty discard, diff close). The cleanup token is captured in open() whether or not the task is still live: it is provider state used only for cleanup, while the observation (task state) stays gated on a live task. Specs: the placeholder-observation tests now stat-match (two bigint stats + utf-8 read); the collected-task case expects the cleanup token to be captured with no observation; new cases cover a writer-contested placeholder, a failed post-stat, and a bracketing-stat mismatch. 164/164 affected suites; tsc --noEmit and eslint --max-warnings=0 clean; suppression counts unchanged. --- src/integrations/editor/DiffViewProvider.ts | 75 ++++++++----- .../editor/__tests__/DiffViewProvider.spec.ts | 104 +++++++++++++++++- 2 files changed, 147 insertions(+), 32 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index aabd132fbc..1e7dd2f9f9 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -165,26 +165,44 @@ export class DiffViewProvider { // Make sure the file exists before we open it. if (!fileExists) { await fs.writeFile(absolutePath, "") - // S4b follow-up (#44): the empty placeholder is fully known (empty), - // so observe the just-written on-disk version as complete; the accepted - // save then publishes through the guard's version check. Unlike the - // modify branch above, this is recorded even when the task already has - // an observation for the path: that observation describes a file that - // no longer exists, and keeping it would make the accept-time CAS - // (the placeholder token on disk vs. the vanished file's token) fail - // every time, so recreating the file would always fail and the - // placeholder would leak. The placeholder token is the correct baseline - // for the new file: an external change to the placeholder before the - // accept still moves the on-disk token and fails the CAS. - const displayTask = this.taskRef.deref() - if (displayTask) { - const placeholderStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) - if (placeholderStats) { - const placeholderVersion = versionTokenOfStat(placeholderStats) - displayTask.observationRegistry.observe(absolutePath, placeholderVersion, true) + // S4b follow-up (#44 / epic #1375): the empty placeholder is fully + // known (empty), but verify it with the same S2 stat-matched contract + // as the modify branch above: a stat-mismatched or non-empty read + // means another writer touched the placeholder in the window after + // open() wrote it, and that writer's token must not be observed as + // complete (observing it would claim we saw content we never read, and + // the token-guarded cleanup would unlink their file) - nothing is + // recorded and the save fails closed instead. When the placeholder is + // verified empty, its token replaces any prior observation for the + // path: a prior observation describes a file that no longer exists, and + // keeping it would make the accept-time CAS (the placeholder token on + // disk vs. the vanished file's token) fail every time, so recreating + // the file would always fail. The placeholder token is the correct + // baseline for the new file: an external change to the placeholder + // before the accept moves the on-disk token and fails the CAS. The + // cleanup token is captured whether or not the task is still live: + // a rejected save must not leave the placeholder behind even when the + // owning task has been collected. + const placeholderPreStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (placeholderPreStats) { + const placeholderContent = await fs.readFile(absolutePath, "utf-8").catch(() => undefined) + const placeholderPostStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + const placeholderToken = versionTokenOfStat(placeholderPreStats) + // Stat-matched (S2): the read is trusted only while the bracketing + // stats agree and the content is exactly the empty placeholder. + if ( + placeholderPostStats && + placeholderToken === versionTokenOfStat(placeholderPostStats) && + placeholderContent === "" + ) { // Remember the placeholder token so a rejected save can remove - // the placeholder only while it is still the exact file we wrote. - this.placeholderVersion = placeholderVersion + // the placeholder only while it is still the exact file open() + // wrote. + this.placeholderVersion = placeholderToken + const displayTask = this.taskRef.deref() + if (displayTask) { + displayTask.observationRegistry.observe(absolutePath, placeholderToken, true) + } } } } @@ -401,18 +419,21 @@ export class DiffViewProvider { // changed after the preview or the target was never observed, with the // standard re-read-then-retry remediation. const saveTask = this.taskRef.deref() - if (!saveTask) { - // Fail closed: without the owning task the observation registry is - // unreachable and the save cannot be guarded. - throw new Error("Cannot guard the write: the owning task is no longer available") - } try { + if (!saveTask) { + // Fail closed: without the owning task the observation registry is + // unreachable and the save cannot be guarded. The rejection flows + // through the same discard-only cleanup as a guard verdict, so a + // dead task cannot leave the empty placeholder behind either. + throw new Error("Cannot guard the write: the owning task is no longer available") + } // Stryker disable next-line StringLiteral: "" is semantically identical to "update" in guardedWrite (only "edit" and "create" take distinct branches), so the StringLiteral mutant is equivalent at this sole production call site. await guardedWrite(saveTask, this.relPath, editedContent, "update") } catch (error) { - // Discard-only failure cleanup. The guard rejected the publish - // (stale version, unobserved target, or a partial-read observation), - // so the on-disk content is the newer source of truth. Reload it + // Discard-only failure cleanup. The publish was rejected (stale + // version, unobserved target, a partial-read observation, or an + // unavailable task), so the on-disk content is the newer source of + // truth. Reload it // into the buffer (discarding the rejected edit), remove the empty // new-file placeholder while it is still exactly the file open() // wrote, and close the diff views. Never use revertChanges() here: diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 0e5009861a..010851a054 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1051,6 +1051,7 @@ describe("DiffViewProvider", () => { vi.mocked(vscode.window).visibleTextEditors = [mockEditor] vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") diffViewProvider.editType = "create" mockTask.observationRegistry.clear() @@ -1060,10 +1061,13 @@ describe("DiffViewProvider", () => { expect(obs).toBeDefined() expect(obs!.version).toBe(versionTokenOfStat(previewStats)) expect(obs!.complete).toBe(true) - // The create path stats exactly once (the placeholder), so the single - // call carries the bigint requirement. + // The create path stat-matches the placeholder (pre + post read), so + // both calls carry the bigint requirement, and the verification read + // uses utf-8. expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(1, `${mockCwd}/brand-new.ts`, { bigint: true }) - expect(vi.mocked(fs.stat)).toHaveBeenCalledTimes(1) + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(2, `${mockCwd}/brand-new.ts`, { bigint: true }) + expect(vi.mocked(fs.stat)).toHaveBeenCalledTimes(2) + expect(vi.mocked(fs.readFile)).toHaveBeenCalledWith(`${mockCwd}/brand-new.ts`, "utf-8") }) it("open() leaves the target unobserved when the pre/post stat mismatch (mid-preview mutation)", async () => { @@ -1231,6 +1235,7 @@ describe("DiffViewProvider", () => { vi.mocked(vscode.window).visibleTextEditors = [mockEditor] vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") diffViewProvider.editType = "create" mockTask.observationRegistry.clear() mockTask.observationRegistry.observe(`${mockCwd}/t3-create.ts`, "model-token", true) @@ -1241,7 +1246,7 @@ describe("DiffViewProvider", () => { // the placeholder is written, stat-matched, observed (replacing the // vanished file's stale token), and remembered for cleanup expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/t3-create.ts`, "") - expect(vi.mocked(fs.stat)).toHaveBeenCalledTimes(1) + expect(vi.mocked(fs.stat)).toHaveBeenCalledTimes(2) expect(mockTask.observationRegistry.get(`${mockCwd}/t3-create.ts`)?.version).toBe(placeholderToken) expect(diffViewProvider["placeholderVersion"]).toBe(placeholderToken) }) @@ -1260,6 +1265,7 @@ describe("DiffViewProvider", () => { vi.mocked(vscode.window).visibleTextEditors = [mockEditor] vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") diffViewProvider.editType = "create" mockTask.observationRegistry.clear() diffViewProvider["taskRef"] = { deref: () => undefined } as unknown as WeakRef @@ -1267,8 +1273,95 @@ describe("DiffViewProvider", () => { await diffViewProvider.open("t3-dead-task.ts") expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/t3-dead-task.ts`, "") - // no live task: nothing observed, nothing remembered for cleanup + // no live task: nothing observed, but the cleanup token is still + // captured (provider state) so the fail-closed save rejection can + // remove the placeholder instead of leaking it expect(mockTask.observationRegistry.get(`${mockCwd}/t3-dead-task.ts`)).toBeUndefined() + expect(diffViewProvider["placeholderVersion"]).toBe(versionTokenOfStat(previewStats)) + }) + + it("open() does not record a placeholder another writer touched after the write", async () => { + // CR d037753 finding: a writer that touched the placeholder between + // open()'s fs.writeFile() and the observation would have its token + // recorded as a complete observation of content open() never read. + // The stat-matched verification must reject it: no observation (the + // prior observation stays untouched), no cleanup token (so a rejected + // save cannot unlink the writer's file), and the save fails closed. + const mockEditor = mockTextEditor(`${mockCwd}/contested.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/contested.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("external content") + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + mockTask.observationRegistry.observe(`${mockCwd}/contested.ts`, "model-token", true) + + await diffViewProvider.open("contested.ts") + + expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/contested.ts`, "") + // nothing recorded, and the vanished file's prior observation was + // not replaced with the writer's token + expect(mockTask.observationRegistry.get(`${mockCwd}/contested.ts`)?.version).toBe("model-token") + expect(diffViewProvider["placeholderVersion"]).toBeUndefined() + }) + + it("open() does not record the placeholder when the post-stat fails after the write", async () => { + // The bracketing stats must both succeed: a failed post-stat means + // the read is not trustworthy, so nothing is recorded. + const mockEditor = mockTextEditor(`${mockCwd}/statfail.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/statfail.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat) + .mockResolvedValueOnce(previewStats) + .mockRejectedValueOnce(Object.assign(new Error("ENOENT: no such file"), { code: "ENOENT" })) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("statfail.ts") + + expect(mockTask.observationRegistry.get(`${mockCwd}/statfail.ts`)).toBeUndefined() + expect(diffViewProvider["placeholderVersion"]).toBeUndefined() + }) + + it("open() does not record the placeholder when the bracketing stats disagree (mid-preview mutation)", async () => { + // A token change between the bracketing stats means the placeholder + // was replaced or rewritten while open() was reading it - same S2 + // rule as the modify branch: nothing is recorded. + const mutatedStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(301), + mtimeNs: BigInt(4_000_000_001n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + const mockEditor = mockTextEditor(`${mockCwd}/mutated-new.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/mutated-new.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValueOnce(previewStats).mockResolvedValueOnce(mutatedStats) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("mutated-new.ts") + + expect(mockTask.observationRegistry.get(`${mockCwd}/mutated-new.ts`)).toBeUndefined() expect(diffViewProvider["placeholderVersion"]).toBeUndefined() }) @@ -1286,6 +1379,7 @@ describe("DiffViewProvider", () => { vi.mocked(vscode.window).visibleTextEditors = [mockEditor] vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") diffViewProvider.editType = "create" // prior read observation (the file has since vanished) expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)?.version).toBe("v1") From 221eaed22d3c3c2e8499b71d350df5c12497ad72 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 03:11:04 +0800 Subject: [PATCH 021/100] chore: re-trigger CodeRabbit re-review and label reconcile From db5b56dd7af88545f1d831cc3b921a7f506b28db Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 06:07:12 +0800 Subject: [PATCH 022/100] chore: trigger CodeRabbit re-review (finding already addressed) From 174b825e7d8a43f9a749d682f671a49f48fb857a Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 07:36:58 +0800 Subject: [PATCH 023/100] chore: trigger CodeRabbit re-review / label reconcile From 32c7afe6cdab1eb3f463b3017665ddffbc150783 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 08:10:08 +0800 Subject: [PATCH 024/100] fix(s4b): give each self-staged write its own staging directory so a concurrent write cannot remove it CodeRabbit finding at 174b825e7d: writer A stages in the shared .file-safety-staging dir while writer B cleans up with rmdir, so A path can be removed before openSync (ENOENT). Each self-staged write now owns its staging directory and only removes its own. Test proves two writes in the same directory get distinct staging dirs. --- .../__tests__/safeWriteText.spec.ts | 20 ++++++++++++ src/services/file-safety/safeWriteText.ts | 31 +++++++++++++------ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 29be4e34f0..ef1d8b1c10 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -179,6 +179,26 @@ describe("safeWriteText", () => { expect(fs.rename).toHaveBeenCalled() expect(fs.rmdir).toHaveBeenCalled() }) + + it("gives each self-staged write its own staging directory so a concurrent write cannot remove it", async () => { + const targetA = "/tmp/test-dir/target-a.txt" + const targetB = "/tmp/test-dir/target-b.txt" + vi.mocked(fs.realpath).mockImplementation((p) => Promise.resolve(p as string)) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetA, "a", { platform: "linux" }) + await safeWriteText(targetB, "b", { platform: "linux" }) + + // Two self-staged writes in the same directory must not share one staging + // directory: the first write's best-effort rmdir would otherwise delete the + // directory the second write had created but not yet opened (ENOENT on openSync). + const created = vi.mocked(fsSync.mkdirSync).mock.calls.map((c) => String(c[0])) + const staging = created.filter((p) => p.includes(".file-safety-staging_")) + expect(staging).toHaveLength(2) + expect(staging[0]).not.toBe(staging[1]) + const removed = vi.mocked(fs.rmdir).mock.calls.map((c) => String(c[0])) + expect(removed).toEqual([staging[0], staging[1]]) + }) }) // ── Test 2: fsync ordering ─────────────────────────────────────────────── diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 6675864208..67607d946f 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -44,10 +44,13 @@ function _tempName(dir: string, prefix: string): string { return path.join(dir, "." + prefix + "_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp") } -/** Create a private staging sub-directory inside *dir* so that multiple - * concurrent writes never collide on their temp names. */ +/** Create a private per-write staging sub-directory inside *dir*. The name is + * unique per write, so concurrent writes never collide on their temp names and + * never remove a staging directory another write is still using: with one shared + * name, one write's best-effort rmdir could delete the directory another write + * had just created but not yet opened, failing its openSync with ENOENT. */ function _stagingDir(dir: string): string { - const sd = path.join(dir, ".file-safety-staging") + const sd = path.join(dir, ".file-safety-staging_" + Date.now() + "_" + Math.random().toString(36).substring(2)) // mode:0o700 protects a freshly created staging dir; the best-effort chmod // repairs a pre-existing one (mkdirSync with recursive:true never chmods an // existing directory), so staged temp files are never group/world readable. @@ -151,8 +154,16 @@ export async function safeWriteText(filePath: string, content: string, options?: // Create the staging directory only when we generate the temp file there; // callers supplying their own tempPath (e.g. safeWriteJson) must not be left - // with an empty .file-safety-staging directory behind. - const tempPath = options?.tempPath ?? _tempName(_stagingDir(dirPath), "safeWriteText") + // with an empty .file-safety-staging directory behind. Track the directory this + // write created so its cleanup removes its own directory, not a shared one. + let stagingDir: string | null = null + let tempPath: string + if (options?.tempPath) { + tempPath = options.tempPath + } else { + stagingDir = _stagingDir(dirPath) + tempPath = _tempName(stagingDir, "safeWriteText") + } let backupPath: string | null = null let releaseBackupOnSuccess = false @@ -295,11 +306,11 @@ export async function safeWriteText(filePath: string, content: string, options?: // tempPath is now the committed file; no cleanup needed. // Best-effort: remove the now-empty staging directory. Self-staged - // writes only; ENOTEMPTY means a concurrent write in the same - // directory is still using it, and a failure must never un-commit a - // published file, so the removal swallows all errors. - if (!options?.tempPath) { - await fs.rmdir(_stagingDir(dirPath)).catch(() => {}) + // writes only, and only this write's own directory: a per-write directory + // cannot be the one another concurrent write is still using. A failure must + // never un-commit a published file, so the removal swallows all errors. + if (stagingDir) { + await fs.rmdir(stagingDir).catch(() => {}) } } catch (originalError: unknown) { // -- Rollback / cleanup on failure ---------------------------------- From bc6f4743d3d51f8eefe0e0a0926c9106f3443d2a Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 08:18:46 +0800 Subject: [PATCH 025/100] fix(s4b): remove the staging directory when a self-staged write fails CodeRabbit finding at 32c7afe6cd: the failure path unlinked the temp file but left the per-write staging directory behind. Cleanup now removes only the directory this write created, after its temp file is gone. Tests cover both the self-staged failure (rmdir of its own dir, unlink before rmdir) and a caller-staged failure (no rmdir of a directory it never created). --- .../__tests__/safeWriteText.spec.ts | 41 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 7 ++++ 2 files changed, 48 insertions(+) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index ef1d8b1c10..f71ee5e23e 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -199,6 +199,47 @@ describe("safeWriteText", () => { const removed = vi.mocked(fs.rmdir).mock.calls.map((c) => String(c[0])) expect(removed).toEqual([staging[0], staging[1]]) }) + + it("removes its own staging directory when a self-staged write fails", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rename).mockRejectedValue(Object.assign(new Error("EACCES"), { code: "EACCES" })) + + await expect(safeWriteText(targetPath, "hello", { platform: "linux" })).rejects.toThrow("EACCES") + + // The failed write's temp file is unlinked, then the directory it + // created is removed — a failed write must not leave an empty + // .file-safety-staging directory behind. + // mkdirSync created this write's staging directory; the temp file lives + // inside it, so the unlink targets a path under that directory. + const staging = vi.mocked(fsSync.mkdirSync).mock.calls.map((c) => String(c[0])) + expect(staging).toHaveLength(1) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(staging[0])) + expect(fs.rmdir).toHaveBeenCalledWith(staging[0]) + // The directory is only empty after its temp file is gone, so the + // unlink must happen before the rmdir. + expect(vi.mocked(fs.unlink).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(fs.rmdir).mock.invocationCallOrder[0], + ) + }) + + it("does not remove a staging directory it did not create when a caller-staged write fails", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const callerTemp = "/tmp/test-dir/caller-staged.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rename).mockRejectedValue(Object.assign(new Error("EACCES"), { code: "EACCES" })) + + await expect( + safeWriteText(targetPath, "hello", { platform: "linux", tempPath: callerTemp }), + ).rejects.toThrow("EACCES") + + // The caller owns that directory: only the caller's temp file is cleaned, + // never a rmdir of a directory safeWriteText never created. + expect(fs.unlink).toHaveBeenCalledWith(callerTemp) + expect(fs.rmdir).not.toHaveBeenCalled() + }) }) // ── Test 2: fsync ordering ─────────────────────────────────────────────── diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 67607d946f..33ba00c846 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -329,6 +329,13 @@ export async function safeWriteText(filePath: string, content: string, options?: // cleanup failure is non-fatal } + // A failed self-staged write must not leave its staging directory behind. + // Only the directory this write created, and only after its temp file is + // gone, so the directory is empty and the removal stays best-effort. + if (stagingDir) { + await fs.rmdir(stagingDir).catch(() => {}) + } + if (daclDumpPath !== null) { await fs.unlink(daclDumpPath).catch(() => {}) } From ad04d2b6c6fad286aadbb83889fceecb84e7e52f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 08:37:11 +0800 Subject: [PATCH 026/100] test(s4b): pin the per-write staging directory name shape so the uniqueness contract is tested The prefix-only assertion left two changed-line mutants alive (separator StringLiteral and the substring(2) MethodExpression): both still produced distinct names, so the test passed. Asserting the documented shape /.file-safety-staging__ kills them. --- src/services/file-safety/__tests__/safeWriteText.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index f71ee5e23e..42d37ddb71 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -196,6 +196,12 @@ describe("safeWriteText", () => { const staging = created.filter((p) => p.includes(".file-safety-staging_")) expect(staging).toHaveLength(2) expect(staging[0]).not.toBe(staging[1]) + // Uniqueness comes from the documented name shape + // /.file-safety-staging__: pinning the shape + // keeps the separator and the random suffix meaningful, not just the prefix. + for (const dir of staging) { + expect(dir).toMatch(/\.file-safety-staging_\d+_[a-z0-9]+$/) + } const removed = vi.mocked(fs.rmdir).mock.calls.map((c) => String(c[0])) expect(removed).toEqual([staging[0], staging[1]]) }) From bf27716f90f8aed9684ddbecc2c20716f051c2fc Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 09:55:15 +0800 Subject: [PATCH 027/100] fix(s4b): publish in the document encoding, lock the resolved delete target, dispose the listener before the revert - safeWriteText gains an encoding option and encodes utf8/utf8bom/utf16le/utf16be; a code page Node cannot encode is rejected for non-ASCII content instead of silently re-encoded as UTF-8 - guardedWrite forwards the encoding to the publish guards and DiffViewProvider passes the document encoding - TaskHistoryStore.delete/deleteMany key the advisory lock by the resolved publish target while unlinking the path the caller named - DiffViewProvider disposes the active-editor listener before the programmatic revert and closes the file tab after a successful placeholder unlink - test cleanup: try/finally in the safeWriteJson lock test, exact handleError assertion, drop the stale _stagingDir helper --- src/core/task-persistence/TaskHistoryStore.ts | 12 +- .../TaskHistoryStore.deleteSemantics.spec.ts | 51 +++++- .../__tests__/applyPatchTool.execute.spec.ts | 5 +- src/core/tools/__tests__/guardedWrite.spec.ts | 64 ++++--- src/core/tools/guardedWrite.ts | 32 +++- src/integrations/editor/DiffViewProvider.ts | 41 ++++- .../editor/__tests__/DiffViewProvider.spec.ts | 159 +++++++++++++++++- .../__tests__/safeWriteText.spec.ts | 54 +++++- src/services/file-safety/safeWriteText.ts | 48 +++++- src/utils/__tests__/safeWriteJson.test.ts | 42 +++-- 10 files changed, 435 insertions(+), 73 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 346d697d9a..146be163d9 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -9,6 +9,7 @@ import { historyItemSchema, type HistoryItem } from "@roo-code/types" import { GlobalFileNames } from "../../shared/globalFileNames" import { LOCK_STALE_MS, withFileLock } from "../../utils/fileLock" import { safeWriteJson } from "../../utils/safeWriteJson" +import { resolvePublishTarget } from "../../services/file-safety/safeWriteText" import { getStorageBasePath } from "../../utils/storage" import { assertValidTransition, settleRejectedCreateSubtaskAction, type HistoryItemStatus } from "./taskLifecycle" import { computeHistoryDelta, DeltaRejectedError, mergeHistoryDelta } from "./taskStoreConcurrency" @@ -280,7 +281,12 @@ export class TaskHistoryStore { // Remove per-task file (best-effort) try { const filePath = await this.getTaskFilePath(taskId) - await withFileLock(filePath, (absoluteFilePath) => fs.unlink(absoluteFilePath)) + // Lock the resolved publish target, not the path as spelled: + // proper-lockfile keys the advisory lock by the path it is given, so a + // symlink alias and its referent would take two locks for one file and a + // deletion could run concurrently with a locked merge through the other + // alias. The unlink still removes the path the caller named. + await withFileLock(await resolvePublishTarget(filePath), () => fs.unlink(filePath)) } catch { // File may already be deleted } @@ -308,7 +314,9 @@ export class TaskHistoryStore { // Remove per-task file (best-effort) try { const filePath = await this.getTaskFilePath(taskId) - await withFileLock(filePath, (absoluteFilePath) => fs.unlink(absoluteFilePath)) + // Same lock key as delete(): the resolved referent, while the unlink + // still removes the path the caller named. + await withFileLock(await resolvePublishTarget(filePath), () => fs.unlink(filePath)) } catch { // File may already be deleted } diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index 5071c9de73..b042bd04bd 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -97,12 +97,15 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { await store.upsert(makeHistoryItem({ id: "locked-delete" })) onWrite.mockClear() + // The lock key is the resolved publish target, so the expected key is + // captured through realpath before the delete: on Windows os.tmpdir() + // can be an 8.3 short path (C:\Users\RUNNER~1) that realpath expands + // to the long form, and the file is gone after the unlink. + const lockKey = await fs.realpath(historyFilePath(storagePath, "locked-delete")) + await expect(store.delete("locked-delete")).resolves.toBeUndefined() - expect(vi.mocked(withFileLock)).toHaveBeenCalledWith( - historyFilePath(storagePath, "locked-delete"), - expect.any(Function), - ) + expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(lockKey, expect.any(Function)) await expect(fs.access(historyFilePath(storagePath, "locked-delete"))).rejects.toMatchObject({ code: "ENOENT", }) @@ -173,6 +176,28 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { expect(store.get("never-existed")).toBeUndefined() expect(onWrite).toHaveBeenCalledTimes(1) }) + + it("locks the resolved publish target while unlinking the path it was given", async () => { + const store = createStore() + await store.initialize() + await store.upsert(makeHistoryItem({ id: "alias-del" })) + const aliasPath = historyFilePath(storagePath, "alias-del") + const referentPath = path.join(storagePath, "tasks", "alias-del", "referent-history.json") + + // Real symlinks are unavailable in this CI lane, so the alias is + // simulated through realpath, as in the safeWriteJson lock test. + const realpathSpy = vi.spyOn(fs, "realpath").mockResolvedValue(referentPath) + try { + await expect(store.delete("alias-del")).resolves.toBeUndefined() + } finally { + realpathSpy.mockRestore() + } + + // One lock for the underlying file, keyed by the referent; the unlink + // still targets the path the store named. + expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(referentPath, expect.any(Function)) + expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(aliasPath) + }) }) describe("deleteMany()", () => { @@ -239,5 +264,23 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { const writtenIds = (onWrite.mock.calls[0][0] as HistoryItem[]).map((item) => item.id) expect(writtenIds).toEqual([]) }) + + it("locks the resolved publish target for every item while unlinking the given paths", async () => { + const store = createStore() + await store.initialize() + await store.upsert(makeHistoryItem({ id: "alias-batch", ts: 1000 })) + const aliasPath = historyFilePath(storagePath, "alias-batch") + const referentPath = path.join(storagePath, "tasks", "alias-batch", "referent-history.json") + + const realpathSpy = vi.spyOn(fs, "realpath").mockResolvedValue(referentPath) + try { + await expect(store.deleteMany(["alias-batch"])).resolves.toBeUndefined() + } finally { + realpathSpy.mockRestore() + } + + expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(referentPath, expect.any(Function)) + expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(aliasPath) + }) }) }) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 2e683ca7c6..1c07b8c572 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -278,7 +278,10 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { }) expect(mockTask.observationRegistry.has(path.resolve("/workspace/project", "src/thing.ts"))).toBe(false) - expect(mockHandleError).toHaveBeenCalled() + // The rejection value is controlled here, so assert the exact error that + // reached handleError instead of any call: a stat or parse failure would + // otherwise satisfy a bare toHaveBeenCalled(). + expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) }) it("add: publishes the new file through the guarded saveDirectly with create kind", async () => { diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index 87b29ad31e..fa8387c4b1 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -80,7 +80,29 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "new-file.txt", "hello", "create") expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello") + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello", {}) + }) + + it("forwards the caller's encoding to the publish primitive", async () => { + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") // post-publish refresh + const task = createMockTask() + + await guardedWrite(task, "bom.txt", "hello", "create", { encoding: "utf8bom" }) + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("bom.txt"), "hello", { encoding: "utf8bom" }) + }) + + it("forwards the encoding through the version guard as well", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("kept.txt"), "v1") + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "kept.txt", "rewritten", "update", { encoding: "utf16le" }) + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("kept.txt"), "rewritten", { encoding: "utf16le" }) }) it("fails with the read-first remediation when the file exists - nothing published", async () => { @@ -138,7 +160,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "new-file.txt", "hello", "update") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello") + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello", {}) }) it("fails with the read-first remediation when the file exists - nothing published", async () => { @@ -165,7 +187,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "gone.txt", "back", "create") expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("gone.txt"), "back") + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("gone.txt"), "back", {}) }) it("goes through the version guard when the file still exists", async () => { @@ -177,7 +199,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "kept.txt", "rewritten", "create") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("kept.txt"), "rewritten") + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("kept.txt"), "rewritten", {}) }) it("fails with the stale remediation suffix when the version moved", async () => { @@ -217,7 +239,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "doc.txt", "new content", "update") expect(mockedComputeVersionToken).toHaveBeenCalledWith(abs("doc.txt")) - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "new content") + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "new content", {}) }) it("fails with the stale remediation suffix when the version moved - nothing published", async () => { @@ -257,7 +279,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "doc.txt", "new content", "update") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "new content") + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "new content", {}) }) it("leaves edit-kind publishes unaffected by a partial observation - the model saw the edited region", async () => { @@ -268,7 +290,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "doc.txt", "patched", "edit") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "patched") + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "patched", {}) }) it("rejects a create-kind full-file overwrite of an existing file when only a partial read observed it", async () => { @@ -297,7 +319,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "doc.txt", "created", "create") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "created") + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "created", {}) }) it("publishes a create-kind overwrite of an existing file when the observation is complete", async () => { @@ -309,7 +331,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "doc.txt", "created", "create") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "created") + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "created", {}) }) }) @@ -326,7 +348,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "doc.txt", "second edit", "edit") expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) - expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("doc.txt"), "second edit") + expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("doc.txt"), "second edit", {}) // the observation now carries the post-publish token, complete expect(reg.get(abs("doc.txt"))?.version).toBe("v2") expect(reg.get(abs("doc.txt"))?.complete).toBe(true) @@ -392,7 +414,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "doc.txt", "patched", "edit") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "patched") + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "patched", {}) }) it("fails with the stale remediation suffix when the version moved", async () => { @@ -431,8 +453,8 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(r1.status).toBe("fulfilled") expect(r2.status).toBe("fulfilled") expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) - expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, abs("shared.txt"), "first") - expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, abs("shared.txt"), "second") + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, abs("shared.txt"), "first", {}) + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, abs("shared.txt"), "second", {}) }) it("observed-absent then two concurrent creates - the second publishes against the refreshed observation", async () => { @@ -461,8 +483,8 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(r1.status).toBe("fulfilled") expect(r2.status).toBe("fulfilled") expect(publishes).toBe(2) - expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, abs("absent.txt"), "first") - expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, abs("absent.txt"), "second") + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, abs("absent.txt"), "first", {}) + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, abs("absent.txt"), "second", {}) }) it("the chain settles after a rejection - a later matching write still runs", async () => { @@ -481,7 +503,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await expect(p2).resolves.toBeUndefined() expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("settle.txt"), "second") + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("settle.txt"), "second", {}) }) it("evicts settled chain entries - a later write still serializes in order", async () => { @@ -535,7 +557,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "sub/dir.txt", "content", "update") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("sub/dir.txt"), "content") + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("sub/dir.txt"), "content", {}) }) it("normalizes an already-absolute input (trailing separator) to the observation key", async () => { @@ -552,7 +574,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, canonical + "/", "content", "update") expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) - expect(mockedSafeWriteText).toHaveBeenCalledWith(canonical, "content") + expect(mockedSafeWriteText).toHaveBeenCalledWith(canonical, "content", {}) }) it("serializes two spellings of one file through a single chain key", async () => { @@ -578,8 +600,8 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(r1.status).toBe("fulfilled") expect(r2.status).toBe("fulfilled") expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) - expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, canonical, "first") - expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, canonical, "second") + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, canonical, "first", {}) + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, canonical, "second", {}) }) }) @@ -594,7 +616,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { resetChain() await guardedWrite(task, "x.txt", "b", "update") - expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("x.txt"), "b") + expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("x.txt"), "b", {}) }) }) }) diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index b616b53f49..842532f982 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -27,6 +27,12 @@ import type { Task } from "../task/Task" /** Write kind that drives guard selection. */ export type GuardedWriteKind = "create" | "update" | "edit" +/** Publish options for a guarded write. */ +export interface GuardedWriteOptions { + /** Encoding used to encode the content for the publish (see safeWriteText). */ + encoding?: string +} + /** Internal error thrown when a guard rejects a write. */ class GuardRejectedError extends Error { constructor( @@ -111,7 +117,11 @@ async function fileIsAbsent(absolutePath: string): Promise { * write was issued for a file that was never read, so the caller must read * the file first, then retry. */ -export async function createIfAbsent(absolutePath: string, content: string): Promise { +export async function createIfAbsent( + absolutePath: string, + content: string, + options: GuardedWriteOptions = {}, +): Promise { try { await fs.access(absolutePath) } catch (error: unknown) { @@ -119,7 +129,7 @@ export async function createIfAbsent(absolutePath: string, content: string): Pro // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. throw error } - await safeWriteText(absolutePath, content) + await safeWriteText(absolutePath, content, options) return } @@ -139,7 +149,12 @@ export async function createIfAbsent(absolutePath: string, content: string): Pro * a mismatch the write is rejected stale with a re-read-then-retry * remediation suffix. */ -export async function replaceIfVersion(absolutePath: string, expectedVersion: string, content: string): Promise { +export async function replaceIfVersion( + absolutePath: string, + expectedVersion: string, + content: string, + options: GuardedWriteOptions = {}, +): Promise { let currentVersion: string try { currentVersion = await computeVersionToken(absolutePath) @@ -161,7 +176,7 @@ export async function replaceIfVersion(absolutePath: string, expectedVersion: st } if (currentVersion === expectedVersion) { - await safeWriteText(absolutePath, content) + await safeWriteText(absolutePath, content, options) return } @@ -226,6 +241,7 @@ export async function guardedWrite( relPathOrAbsolute: string, content: string, kind: GuardedWriteKind = "update", + options?: GuardedWriteOptions, ): Promise { const absolutePath = resolveAbsolutePath(task, relPathOrAbsolute) @@ -240,7 +256,7 @@ export async function guardedWrite( if (obs === undefined) { await unobservedEditGuard(absolutePath) } else { - await replaceIfVersion(absolutePath, obs.version, content) + await replaceIfVersion(absolutePath, obs.version, content, options) } } else { // "create" or "update" publish a full file built on the model's @@ -263,14 +279,14 @@ export async function guardedWrite( if (obs === undefined) { // Never read: only an absent target may be created. - await createIfAbsent(absolutePath, content) + await createIfAbsent(absolutePath, content, options) } else if (absent) { // A "create" on a file that vanished after the read recreates it. - await createIfAbsent(absolutePath, content) + await createIfAbsent(absolutePath, content, options) } else { // The version recorded at read time must still match the on-disk // token. - await replaceIfVersion(absolutePath, obs.version, content) + await replaceIfVersion(absolutePath, obs.version, content, options) } } diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 1e7dd2f9f9..0c0ff87080 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -428,7 +428,13 @@ export class DiffViewProvider { throw new Error("Cannot guard the write: the owning task is no longer available") } // Stryker disable next-line StringLiteral: "" is semantically identical to "update" in guardedWrite (only "edit" and "create" take distinct branches), so the StringLiteral mutant is equivalent at this sole production call site. - await guardedWrite(saveTask, this.relPath, editedContent, "update") + // Publish with the document's own encoding (BOM included when the + // document is utf8bom): encoding as UTF-8 unconditionally would drop + // the BOM and reload a legacy-code-page document as mojibake after the + // revert. + await guardedWrite(saveTask, this.relPath, editedContent, "update", { + encoding: updatedDocument.encoding, + }) } catch (error) { // Discard-only failure cleanup. The publish was rejected (stale // version, unobserved target, a partial-read observation, or an @@ -441,6 +447,13 @@ export class DiffViewProvider { // the newer disk content that caused the rejection. Best-effort — // the guard verdict is rethrown below. try { + // Dispose before any programmatic activation: showTextDocument can + // change the active editor even with preserveFocus, so a listener + // still attached here would record this cleanup as a user touch and + // let the auto-close preferences keep the transient tab open. + this.disposeActiveEditorListener() + this.cancelDeferredScroll() + if (updatedDocument.isDirty) { await vscode.window.showTextDocument(updatedDocument, { preserveFocus: true, preview: false }) await vscode.commands.executeCommand("workbench.action.files.revert") @@ -448,7 +461,18 @@ export class DiffViewProvider { if (this.editType === "create" && this.placeholderVersion) { const placeholderStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) if (placeholderStats && versionTokenOfStat(placeholderStats) === this.placeholderVersion) { - await fs.unlink(absolutePath).catch(() => undefined) + let unlinked = false + try { + await fs.unlink(absolutePath) + unlinked = true + } catch { + // the placeholder vanished or the unlink failed + } + if (unlinked) { + // The file is gone, so its tab must go too: closing only the diff + // views would leave a clean plain-text tab for a deleted file. + await this.closeFileTab(absolutePath) + } } } await this.closeAllDiffViews() @@ -465,6 +489,14 @@ export class DiffViewProvider { // document.save() would re-publish through the unguarded VS Code file // service and advance the on-disk token, so the revert is the // content-safe way to clear it. + // Stop tracking touches and cancel any pending scroll-to-diff before any + // programmatic editor activation: showTextDocument below can change the + // active editor even with preserveFocus, so a listener still attached would + // record this programmatic revert as a user touch and keep the transient + // tab open against the auto-close preference. + this.disposeActiveEditorListener() + this.cancelDeferredScroll() + if (updatedDocument.isDirty) { try { await vscode.window.showTextDocument(updatedDocument, { preserveFocus: true, preview: false }) @@ -475,11 +507,6 @@ export class DiffViewProvider { } } - // Stop tracking touches and cancel any pending scroll-to-diff before any - // programmatic editor activation below. - this.disposeActiveEditorListener() - this.cancelDeferredScroll() - await this.closeAllDiffViews() // Read auto-close preferences from state; fall back to defaults that diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 010851a054..8d9f88c8dc 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -846,7 +846,7 @@ describe("DiffViewProvider", () => { // Verify file was written via safeWriteText const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", {}) // Verify file was opened without focus expect(vscode.window.showTextDocument).toHaveBeenCalledWith( @@ -869,7 +869,7 @@ describe("DiffViewProvider", () => { // Verify file was written via safeWriteText const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", {}) // Verify file was NOT opened expect(vscode.window.showTextDocument).not.toHaveBeenCalled() @@ -884,7 +884,7 @@ describe("DiffViewProvider", () => { // Verify file was written via safeWriteText const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", {}) // Verify delay was NOT called expect(mockDelay).not.toHaveBeenCalled() @@ -930,7 +930,7 @@ describe("DiffViewProvider", () => { await diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0) - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", {}) }) it("rejects an observed write whose version token is stale", async () => { @@ -959,7 +959,7 @@ describe("DiffViewProvider", () => { await diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0) - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", {}) }) it("fails closed when the owning task has been collected", async () => { @@ -997,6 +997,7 @@ describe("DiffViewProvider", () => { uri: { fsPath, scheme: "file" }, getText: vi.fn().mockReturnValue(text), lineCount: 0, + encoding: "utf8", }, selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, edit: vi.fn().mockResolvedValue(true), @@ -1144,10 +1145,34 @@ describe("DiffViewProvider", () => { const result = await diffViewProvider.saveChanges(false) - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", { encoding: "utf8" }) expect(result.newProblemsMessage).toBe("") }) + it("publishes the accepted content in the document's own encoding", async () => { + // A utf8bom document: getText() returns the text without the BOM, so the + // guarded publish must re-encode it with the BOM the document was read + // in rather than as plain UTF-8. + const bomEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8bom", + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = bomEditor + + await diffViewProvider.saveChanges(false) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", { + encoding: "utf8bom", + }) + }) + it("rejects the accepted save when the file changed after the preview (stale version)", async () => { vi.mocked(computeVersionToken).mockResolvedValue("v2") const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") @@ -1392,7 +1417,7 @@ describe("DiffViewProvider", () => { const result = await diffViewProvider.saveChanges(false) - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", { encoding: "utf8" }) expect(result.newProblemsMessage).toBe("") }) @@ -1406,6 +1431,7 @@ describe("DiffViewProvider", () => { uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, getText: vi.fn().mockReturnValue("new content"), lineCount: 0, + encoding: "utf8", isDirty: true, save: vi.fn().mockResolvedValue(undefined), }, @@ -1417,7 +1443,7 @@ describe("DiffViewProvider", () => { const result = await diffViewProvider.saveChanges(false) - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", { encoding: "utf8" }) // the revert activates the exact document with the exact options: // preserveFocus keeps the user's focus, preview: false pins the tab expect(vi.mocked(vscode.window.showTextDocument)).toHaveBeenCalledWith(dirtyEditor.document, { @@ -1435,6 +1461,7 @@ describe("DiffViewProvider", () => { uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, getText: vi.fn().mockReturnValue("new content"), lineCount: 0, + encoding: "utf8", isDirty: true, save: vi.fn().mockResolvedValue(undefined), }, @@ -1470,6 +1497,119 @@ describe("DiffViewProvider", () => { expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() }) + it("disposes the active-editor listener before the programmatic revert", async () => { + // showTextDocument can change the active editor even with + // preserveFocus, so the listener must be gone before the revert + // activates the document: otherwise this programmatic activation is + // recorded as a user touch and the auto-close preference is overridden. + const order: string[] = [] + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + diffViewProvider["disposeActiveEditorListener"] = vi.fn(() => { + order.push("dispose") + }) + vi.mocked(vscode.commands.executeCommand).mockImplementation((command: string) => { + if (command === "workbench.action.files.revert") { + order.push("revert") + } + return Promise.resolve(undefined) + }) + + await diffViewProvider.saveChanges(false) + + expect(order).toEqual(["dispose", "revert"]) + }) + + it("disposes the listener before the discard revert and closes the deleted file's tab after a successful unlink", async () => { + const order: string[] = [] + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + diffViewProvider["disposeActiveEditorListener"] = vi.fn(() => { + order.push("dispose") + }) + diffViewProvider["cancelDeferredScroll"] = vi.fn(() => { + order.push("cancel") + }) + diffViewProvider["closeFileTab"] = vi.fn().mockImplementation(() => { + order.push("closeTab") + return Promise.resolve() + }) + vi.mocked(vscode.commands.executeCommand).mockImplementation((command: string) => { + if (command === "workbench.action.files.revert") { + order.push("revert") + } + return Promise.resolve(undefined) + }) + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + // The tab for the file that was just unlinked is closed, and only + // after the revert; closing only the diff views would leave a clean + // plain-text tab for a deleted file behind. + expect(order).toEqual(["dispose", "cancel", "revert", "closeTab"]) + }) + + it("does not close the file tab when the placeholder unlink fails", async () => { + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + const closeFileTab = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = closeFileTab + vi.mocked(fs.unlink).mockRejectedValueOnce(new Error("EACCES: permission denied, unlink")) + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + // Nothing was deleted, so there is no deleted-file tab to close. + expect(closeFileTab).not.toHaveBeenCalled() + }) + it("does not reload a clean buffer when the guard rejects - only dirty buffers are discarded", async () => { // The buffer was never touched (isDirty is falsy): there is nothing // to discard, so the failure cleanup must not activate the document @@ -1496,6 +1636,7 @@ describe("DiffViewProvider", () => { uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, getText: vi.fn().mockReturnValue("new content"), lineCount: 0, + encoding: "utf8", isDirty: true, save: vi.fn().mockResolvedValue(undefined), }, @@ -1529,6 +1670,7 @@ describe("DiffViewProvider", () => { uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, getText: vi.fn().mockReturnValue("new content"), lineCount: 0, + encoding: "utf8", isDirty: true, save: vi.fn().mockResolvedValue(undefined), }, @@ -1562,6 +1704,7 @@ describe("DiffViewProvider", () => { uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, getText: vi.fn().mockReturnValue("new content"), lineCount: 0, + encoding: "utf8", isDirty: true, save: vi.fn().mockResolvedValue(undefined), }, diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 42d37ddb71..795eaa4573 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -47,10 +47,6 @@ function _resolvedTarget(filePath: string): string { function _dirPath(filePath: string): string { return path.dirname(_resolvedTarget(filePath)) } -function _stagingDir(dir: string): string { - return path.join(dir, ".file-safety-staging") -} - // Minimal Stats stand-in: the SUT only reads `.mode` from it. function _stats(mode: number): fsSync.Stats { const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats @@ -766,4 +762,54 @@ describe("safeWriteText", () => { expect(fs.rename).not.toHaveBeenCalled() }) }) + describe("content encoding", () => { + const targetPath = "/tmp/enc-dir/target.txt" + + beforeEach(() => { + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + }) + + it("stages UTF-8 bytes when no encoding is given", async () => { + await safeWriteText(targetPath, "héllo", { platform: "linux" }) + expect(fsSync.writeSync).toHaveBeenCalledWith(1, Buffer.from("héllo", "utf8"), 0, 6) + }) + + it("prepends the UTF-8 BOM for a utf8bom document", async () => { + await safeWriteText(targetPath, "hi", { platform: "linux", encoding: "utf8bom" }) + expect(fsSync.writeSync).toHaveBeenCalledWith( + 1, + Buffer.concat([Buffer.from("\uFEFF", "utf8"), Buffer.from("hi", "utf8")]), + 0, + 5, + ) + }) + + it("stages UTF-16LE bytes for a utf16le document", async () => { + await safeWriteText(targetPath, "hi", { platform: "linux", encoding: "utf16le" }) + expect(fsSync.writeSync).toHaveBeenCalledWith(1, Buffer.from("hi", "utf16le"), 0, 4) + }) + + it("stages byte-swapped UTF-16BE bytes for a utf16be document", async () => { + // Node has no UTF-16BE encoder, so the LE bytes are swapped pairwise. + await safeWriteText(targetPath, "hi", { platform: "linux", encoding: "utf16be" }) + expect(fsSync.writeSync).toHaveBeenCalledWith(1, Buffer.from([0x00, 0x68, 0x00, 0x69]), 0, 4) + }) + + it("publishes an ASCII-only document unchanged for a code page Node cannot encode", async () => { + await safeWriteText(targetPath, "plain", { platform: "linux", encoding: "windows1252" }) + expect(fsSync.writeSync).toHaveBeenCalledWith(1, Buffer.from("plain", "utf8"), 0, 5) + }) + + it("rejects a non-ASCII document for a code page Node cannot encode, before staging anything", async () => { + await expect( + safeWriteText(targetPath, "héllo", { platform: "linux", encoding: "windows1252" }), + ).rejects.toThrow("Cannot publish content as windows1252") + // the encoding is checked before the staging file is opened, so no + // half-written temp file is left behind + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fsSync.writeSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 33ba00c846..2ea7ba6531 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -28,6 +28,15 @@ export interface SafeWriteTextOptions { */ execFileRunner?: typeof execFile + /** + * Encoding used to turn `content` into the bytes that are staged. VS Code + * documents `TextDocument.encoding` as the encoding used when a document is + * saved, so publishing every document as UTF-8 unconditionally can drop a + * UTF-8 BOM or rewrite a legacy-code-page document as mojibake. Defaults to + * "utf8". + */ + encoding?: string + /** * Pre-written temp path to use for the commit phase. When provided, * safeWriteText skips creating its own staging file and uses this path @@ -105,6 +114,41 @@ async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRu } } +/** Encode `content` to the bytes staged for the commit rename. + * + * The encoding is the caller's document encoding, not an internal choice: + * encoding everything as UTF-8 unconditionally drops a UTF-8 BOM and rewrites a + * legacy-code-page document as mojibake. Node can encode the encodings listed + * below faithfully; for any other code page the bytes are identical to UTF-8 + * only when the content is pure ASCII, so an ASCII-only document is still safe + * to publish and anything else is rejected rather than silently corrupted. + */ +export function encodeContent(content: string, encoding?: string): Buffer { + const enc = encoding ?? "utf8" + switch (enc) { + case "utf8": + // utf8 is Node's default encoding, so no encoding argument is passed: + // the StringLiteral would be an equivalent mutant. + return Buffer.from(content) + case "utf8bom": + return Buffer.concat([Buffer.from("\uFEFF"), Buffer.from(content)]) + case "utf16le": + return Buffer.from(content, "utf16le") + case "utf16be": + // Node has no UTF-16BE encoder: it is UTF-16LE with the bytes + // swapped pairwise. + return Buffer.from(content, "utf16le").swap16() + default: + if (!/[\u0080-\uffff]/.test(content)) { + // ASCII is byte-identical in every VS Code text encoding. + return Buffer.from(content) + } + throw new Error( + `Cannot publish content as ${enc}: the encoding is not encodable here; re-save the file as UTF-8 and retry.`, + ) + } +} + // -- public API ------------------------------------------------------------ /** @@ -177,6 +221,9 @@ export async function safeWriteText(filePath: string, content: string, options?: // Preserve the existing target's permissions: the staging file must // not be published wider than the file it replaces (a 0o600 target // must not become 0o644 through the atomic rename). + // Encode before opening the staging file: an encoding Node cannot + // represent must not leave a half-written temp file behind. + const buffer = encodeContent(content, options?.encoding) let targetMode = 0o644 // default for a fresh target try { targetMode = fsSync.statSync(targetPath).mode & 0o777 @@ -188,7 +235,6 @@ export async function safeWriteText(filePath: string, content: string, options?: // Loop until every byte is written: writeSync can report a short // (partial) write, and publishing a truncated staging file would // commit corrupt content. - const buffer = Buffer.from(content, "utf8") let offset = 0 while (offset < buffer.length) { offset += fsSync.writeSync(fd, buffer, offset, buffer.length - offset) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 631fb9f810..eb02771c3a 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -598,7 +598,7 @@ describe("safeWriteJson", () => { const referentPath = path.join(referentDir, "locked.json") await fsPromisesActuals.writeFile!(referentPath, JSON.stringify({ seed: 1 })) - vi.spyOn(fs, "realpath").mockResolvedValue(referentPath) + const realpathSpy = vi.spyOn(fs, "realpath").mockResolvedValue(referentPath) // Wrap the real lock in a capturing mock, and drive the two rare error paths // (the onCompromised callback and a failing release) so they stay covered @@ -638,22 +638,30 @@ describe("safeWriteJson", () => { // Capture the compromise + release-failure logs. const consoleErrorSpy = vi.spyOn(console, "error") - await mockedSafeWriteJson(callerPath, { added: true }, { merge: mergeFn }) - - // The lock was keyed by the resolved referent — every alias shares it. - expect(lockMock).toHaveBeenCalledTimes(1) - expect(String(lockMockFn.mock.calls[0][0])).toBe(referentPath) - // The merge read the referent's content through that single lock. - expect(mergeFn).toHaveBeenCalledWith({ seed: 1 }, { added: true }) - expect(await readFileContent(referentPath)).toEqual({ seed: 1, added: true }) - // The compromise callback and the failed release were logged, not thrown. - expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("was compromised"), expect.any(Error)) - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining("Failed to release lock"), - expect.any(Error), - ) - - vi.unmock("proper-lockfile") // Ensure the mock is removed after this test + try { + await mockedSafeWriteJson(callerPath, { added: true }, { merge: mergeFn }) + + // The lock was keyed by the resolved referent — every alias shares it. + expect(lockMock).toHaveBeenCalledTimes(1) + expect(String(lockMockFn.mock.calls[0][0])).toBe(referentPath) + // The merge read the referent's content through that single lock. + expect(mergeFn).toHaveBeenCalledWith({ seed: 1 }, { added: true }) + expect(await readFileContent(referentPath)).toEqual({ seed: 1, added: true }) + // The compromise callback and the failed release were logged, not thrown. + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("was compromised"), expect.any(Error)) + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to release lock"), + expect.any(Error), + ) + } finally { + // Cleanup must run even when an assertion fails: a leaked mock + // registration or console spy changes later tests, and vi.unmock + // alone does not reset a module that already imported the mock. + realpathSpy.mockRestore() + vi.unmock("proper-lockfile") + vi.resetModules() + consoleErrorSpy.mockRestore() + } }) // CWE-732 regression: safeWriteJson stages the temp itself and passes it From d1e486723915731e2d36501984617b0724f1955f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 10:36:53 +0800 Subject: [PATCH 028/100] fix(s4b): pass the tool's write kind through the diff-view save and publish with VS Code's codec - saveChanges() takes the write kind so a targeted edit after a partial read is guarded as an edit instead of a full-file replacement: apply_diff, search_replace, edit and existing-file edit_file pass "edit"; a new-file edit_file keeps create semantics. - Encode the accepted content with vscode.workspace.encode, which covers the legacy code pages Node cannot represent, and publish those bytes unchanged; the hand-rolled encoder and the encoding option are removed with them. Co-Authored-By: CodeRabbit --- src/core/tools/ApplyDiffTool.ts | 2 +- src/core/tools/EditFileTool.ts | 2 +- src/core/tools/EditTool.ts | 2 +- src/core/tools/SearchReplaceTool.ts | 2 +- .../applyDiffTool.guardedWrite.spec.ts | 35 ++++++++++ src/core/tools/__tests__/editFileTool.spec.ts | 13 +++- src/core/tools/__tests__/editTool.spec.ts | 2 +- src/core/tools/__tests__/guardedWrite.spec.ts | 66 ++++++++----------- .../tools/__tests__/searchReplaceTool.spec.ts | 2 +- src/core/tools/guardedWrite.ts | 30 +++------ src/integrations/editor/DiffViewProvider.ts | 18 +++-- .../editor/__tests__/DiffViewProvider.spec.ts | 37 +++++++---- .../__tests__/safeWriteText.spec.ts | 47 +++---------- src/services/file-safety/safeWriteText.ts | 56 +++------------- 14 files changed, 148 insertions(+), 166 deletions(-) diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 9e6f87f653..fa873b8e5d 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -223,7 +223,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { } // Call saveChanges to update the DiffViewProvider properties - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "edit") } // Track file edit operation diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index 911d4ed85b..2cb6f74a49 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -449,7 +449,7 @@ export class EditFileTool extends BaseTool<"edit_file"> { ) } else { // Call saveChanges to update the DiffViewProvider properties - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, isNewFile ? "create" : "edit") } // Track file edit operation diff --git a/src/core/tools/EditTool.ts b/src/core/tools/EditTool.ts index 6bf93d55d9..1488d2fc0f 100644 --- a/src/core/tools/EditTool.ts +++ b/src/core/tools/EditTool.ts @@ -223,7 +223,7 @@ export class EditTool extends BaseTool<"edit"> { ) } else { // Call saveChanges to update the DiffViewProvider properties - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "edit") } // Track file edit operation diff --git a/src/core/tools/SearchReplaceTool.ts b/src/core/tools/SearchReplaceTool.ts index c11d59a4fc..78632f46b9 100644 --- a/src/core/tools/SearchReplaceTool.ts +++ b/src/core/tools/SearchReplaceTool.ts @@ -219,7 +219,7 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { ) } else { // Call saveChanges to update the DiffViewProvider properties - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "edit") } // Track file edit operation diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index 2181c98941..3733d70821 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -51,6 +51,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { | "fileContextTracker" > let mockSaveDirectly: MockedFunction<(...args: unknown[]) => Promise> + let mockSaveChanges: MockedFunction<(...args: unknown[]) => Promise> let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> let mockPushToolResult: MockedFunction<(...args: unknown[]) => void> @@ -65,6 +66,11 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { userEdits: undefined, finalContent: "new content", }) + mockSaveChanges = vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "new content", + }) // Structural stubs for the guarded-write path: the real DiffViewProvider is // out of scope here, so vi.fn() doubles stand in for the members the tool @@ -73,6 +79,10 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { editType: undefined as "create" | "modify" | undefined, originalContent: undefined as string | undefined, saveDirectly: mockSaveDirectly, + saveChanges: mockSaveChanges, + open: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + scrollToFirstDiff: vi.fn(), pushToolWriteResult: vi.fn().mockResolvedValue("Saved file"), reset: vi.fn().mockResolvedValue(undefined), } @@ -154,4 +164,29 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { expect(mockTask.didEditFile).toBe(false) expect(mockPushToolResult).not.toHaveBeenCalledWith("Saved file") }) + + it("passes the edit kind to the diff-view save path", async () => { + // Without focus disruption the tool saves through the diff view, and the + // kind it passes must carry its intent: a targeted edit, not the default + // full-file replacement. + // The settings double is rebuilt for this test so the focus-disruption + // experiment is off and the tool takes the diff-view save path. + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: {}, + }), + }), + } as unknown as Task["providerRef"] + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "edit") + }) }) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 4606b51ff6..5138b27d4b 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -566,8 +566,19 @@ describe("editFileTool", () => { await executeEditFileTool() - expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalled() + expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "edit") expect(mockTask.didEditFile).toBe(true) + }) + + it("keeps create-guard semantics for a new file on the diff-view save path", async () => { + // A new file is still a create, even when it is saved through the diff + // view rather than directly; only an existing targeted edit becomes an + // edit kind. + mockAskApproval.mockResolvedValue(true) + + await executeEditFileTool({ old_string: "", new_string: "New file content" }, { fileExists: false }) + + expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "create") // Usage is recorded once at the central presentAssistantMessage // attribution point, not locally by the handler. expect(mockTask.recordToolUsage).not.toHaveBeenCalled() diff --git a/src/core/tools/__tests__/editTool.spec.ts b/src/core/tools/__tests__/editTool.spec.ts index 59906771b1..902bd2e63d 100644 --- a/src/core/tools/__tests__/editTool.spec.ts +++ b/src/core/tools/__tests__/editTool.spec.ts @@ -351,7 +351,7 @@ describe("editTool", () => { await executeEditTool() - expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalled() + expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "edit") expect(mockTask.didEditFile).toBe(true) // Usage is recorded once at the central presentAssistantMessage // attribution point, not locally by the handler. diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index fa8387c4b1..31fb9e43cf 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -80,29 +80,19 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "new-file.txt", "hello", "create") expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello", {}) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello") }) - it("forwards the caller's encoding to the publish primitive", async () => { + it("publishes caller-supplied bytes unchanged", async () => { + // The extension host hands over bytes already encoded by VS Code's + // codec; the guard must pass them to the publish primitive as they are. mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) mockedComputeVersionToken.mockResolvedValue("v1") // post-publish refresh const task = createMockTask() - await guardedWrite(task, "bom.txt", "hello", "create", { encoding: "utf8bom" }) + await guardedWrite(task, "bytes.txt", Buffer.from([0x00, 0x68]), "create") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("bom.txt"), "hello", { encoding: "utf8bom" }) - }) - - it("forwards the encoding through the version guard as well", async () => { - const reg = new ObservationRegistry() - reg.observe(abs("kept.txt"), "v1") - mockedFsAccess.mockResolvedValue(undefined) - mockedComputeVersionToken.mockResolvedValue("v1") - const task = createMockTask({ observationRegistry: reg }) - - await guardedWrite(task, "kept.txt", "rewritten", "update", { encoding: "utf16le" }) - - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("kept.txt"), "rewritten", { encoding: "utf16le" }) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("bytes.txt"), Buffer.from([0x00, 0x68])) }) it("fails with the read-first remediation when the file exists - nothing published", async () => { @@ -160,7 +150,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "new-file.txt", "hello", "update") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello", {}) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello") }) it("fails with the read-first remediation when the file exists - nothing published", async () => { @@ -187,7 +177,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "gone.txt", "back", "create") expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("gone.txt"), "back", {}) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("gone.txt"), "back") }) it("goes through the version guard when the file still exists", async () => { @@ -199,7 +189,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "kept.txt", "rewritten", "create") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("kept.txt"), "rewritten", {}) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("kept.txt"), "rewritten") }) it("fails with the stale remediation suffix when the version moved", async () => { @@ -239,7 +229,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "doc.txt", "new content", "update") expect(mockedComputeVersionToken).toHaveBeenCalledWith(abs("doc.txt")) - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "new content", {}) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "new content") }) it("fails with the stale remediation suffix when the version moved - nothing published", async () => { @@ -279,7 +269,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "doc.txt", "new content", "update") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "new content", {}) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "new content") }) it("leaves edit-kind publishes unaffected by a partial observation - the model saw the edited region", async () => { @@ -290,7 +280,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "doc.txt", "patched", "edit") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "patched", {}) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "patched") }) it("rejects a create-kind full-file overwrite of an existing file when only a partial read observed it", async () => { @@ -319,7 +309,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "doc.txt", "created", "create") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "created", {}) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "created") }) it("publishes a create-kind overwrite of an existing file when the observation is complete", async () => { @@ -331,7 +321,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "doc.txt", "created", "create") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "created", {}) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "created") }) }) @@ -348,7 +338,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "doc.txt", "second edit", "edit") expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) - expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("doc.txt"), "second edit", {}) + expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("doc.txt"), "second edit") // the observation now carries the post-publish token, complete expect(reg.get(abs("doc.txt"))?.version).toBe("v2") expect(reg.get(abs("doc.txt"))?.complete).toBe(true) @@ -414,7 +404,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "doc.txt", "patched", "edit") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "patched", {}) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "patched") }) it("fails with the stale remediation suffix when the version moved", async () => { @@ -453,8 +443,8 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(r1.status).toBe("fulfilled") expect(r2.status).toBe("fulfilled") expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) - expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, abs("shared.txt"), "first", {}) - expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, abs("shared.txt"), "second", {}) + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, abs("shared.txt"), "first") + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, abs("shared.txt"), "second") }) it("observed-absent then two concurrent creates - the second publishes against the refreshed observation", async () => { @@ -483,8 +473,8 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(r1.status).toBe("fulfilled") expect(r2.status).toBe("fulfilled") expect(publishes).toBe(2) - expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, abs("absent.txt"), "first", {}) - expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, abs("absent.txt"), "second", {}) + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, abs("absent.txt"), "first") + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, abs("absent.txt"), "second") }) it("the chain settles after a rejection - a later matching write still runs", async () => { @@ -503,7 +493,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await expect(p2).resolves.toBeUndefined() expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("settle.txt"), "second", {}) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("settle.txt"), "second") }) it("evicts settled chain entries - a later write still serializes in order", async () => { @@ -519,8 +509,8 @@ describe("guardedWrite (S4a, epic #1375)", () => { // Two rapid writes submitted after the eviction must still run one // at a time in submission order (the eviction must not drop the // chain for in-flight or just-enqueued links). - const order: string[] = [] - mockedSafeWriteText.mockImplementation(async (_path: string, content: string) => { + const order: (string | Uint8Array)[] = [] + mockedSafeWriteText.mockImplementation(async (_path: string, content: string | Uint8Array) => { order.push(content) }) const p2 = guardedWrite(task, "evict.txt", "second", "update") @@ -557,7 +547,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "sub/dir.txt", "content", "update") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("sub/dir.txt"), "content", {}) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("sub/dir.txt"), "content") }) it("normalizes an already-absolute input (trailing separator) to the observation key", async () => { @@ -574,7 +564,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, canonical + "/", "content", "update") expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) - expect(mockedSafeWriteText).toHaveBeenCalledWith(canonical, "content", {}) + expect(mockedSafeWriteText).toHaveBeenCalledWith(canonical, "content") }) it("serializes two spellings of one file through a single chain key", async () => { @@ -600,8 +590,8 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(r1.status).toBe("fulfilled") expect(r2.status).toBe("fulfilled") expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) - expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, canonical, "first", {}) - expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, canonical, "second", {}) + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, canonical, "first") + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, canonical, "second") }) }) @@ -616,7 +606,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { resetChain() await guardedWrite(task, "x.txt", "b", "update") - expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("x.txt"), "b", {}) + expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("x.txt"), "b") }) }) }) diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index 2a91ec1a65..192e7a5382 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -320,7 +320,7 @@ describe("searchReplaceTool", () => { await executeSearchReplaceTool() - expect(mockCline.diffViewProvider.saveChanges).toHaveBeenCalled() + expect(mockCline.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "edit") expect(mockCline.didEditFile).toBe(true) // Usage is recorded once at the central presentAssistantMessage // attribution point, not locally by the handler. diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index 842532f982..fe227e16b6 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -27,12 +27,6 @@ import type { Task } from "../task/Task" /** Write kind that drives guard selection. */ export type GuardedWriteKind = "create" | "update" | "edit" -/** Publish options for a guarded write. */ -export interface GuardedWriteOptions { - /** Encoding used to encode the content for the publish (see safeWriteText). */ - encoding?: string -} - /** Internal error thrown when a guard rejects a write. */ class GuardRejectedError extends Error { constructor( @@ -117,11 +111,7 @@ async function fileIsAbsent(absolutePath: string): Promise { * write was issued for a file that was never read, so the caller must read * the file first, then retry. */ -export async function createIfAbsent( - absolutePath: string, - content: string, - options: GuardedWriteOptions = {}, -): Promise { +export async function createIfAbsent(absolutePath: string, content: string | Uint8Array): Promise { try { await fs.access(absolutePath) } catch (error: unknown) { @@ -129,7 +119,7 @@ export async function createIfAbsent( // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. throw error } - await safeWriteText(absolutePath, content, options) + await safeWriteText(absolutePath, content) return } @@ -152,8 +142,7 @@ export async function createIfAbsent( export async function replaceIfVersion( absolutePath: string, expectedVersion: string, - content: string, - options: GuardedWriteOptions = {}, + content: string | Uint8Array, ): Promise { let currentVersion: string try { @@ -176,7 +165,7 @@ export async function replaceIfVersion( } if (currentVersion === expectedVersion) { - await safeWriteText(absolutePath, content, options) + await safeWriteText(absolutePath, content) return } @@ -239,9 +228,8 @@ function resolveAbsolutePath(task: Task, relPathOrAbsolute: string): string { export async function guardedWrite( task: Task, relPathOrAbsolute: string, - content: string, + content: string | Uint8Array, kind: GuardedWriteKind = "update", - options?: GuardedWriteOptions, ): Promise { const absolutePath = resolveAbsolutePath(task, relPathOrAbsolute) @@ -256,7 +244,7 @@ export async function guardedWrite( if (obs === undefined) { await unobservedEditGuard(absolutePath) } else { - await replaceIfVersion(absolutePath, obs.version, content, options) + await replaceIfVersion(absolutePath, obs.version, content) } } else { // "create" or "update" publish a full file built on the model's @@ -279,14 +267,14 @@ export async function guardedWrite( if (obs === undefined) { // Never read: only an absent target may be created. - await createIfAbsent(absolutePath, content, options) + await createIfAbsent(absolutePath, content) } else if (absent) { // A "create" on a file that vanished after the read recreates it. - await createIfAbsent(absolutePath, content, options) + await createIfAbsent(absolutePath, content) } else { // The version recorded at read time must still match the on-disk // token. - await replaceIfVersion(absolutePath, obs.version, content, options) + await replaceIfVersion(absolutePath, obs.version, content) } } diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 0c0ff87080..9e0e07ff53 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -399,6 +399,10 @@ export class DiffViewProvider { async saveChanges( diagnosticsEnabled: boolean = true, writeDelayMs: number = DEFAULT_WRITE_DELAY_MS, + // Stryker disable next-line StringLiteral: an empty kind dispatches exactly + // like "update" in guardedWrite (only "edit" and "create" branch distinctly), + // so the StringLiteral mutant here is equivalent. + writeKind: GuardedWriteKind = "update", ): Promise<{ newProblemsMessage: string | undefined userEdits: string | undefined @@ -427,14 +431,16 @@ export class DiffViewProvider { // dead task cannot leave the empty placeholder behind either. throw new Error("Cannot guard the write: the owning task is no longer available") } - // Stryker disable next-line StringLiteral: "" is semantically identical to "update" in guardedWrite (only "edit" and "create" take distinct branches), so the StringLiteral mutant is equivalent at this sole production call site. - // Publish with the document's own encoding (BOM included when the - // document is utf8bom): encoding as UTF-8 unconditionally would drop - // the BOM and reload a legacy-code-page document as mojibake after the - // revert. - await guardedWrite(saveTask, this.relPath, editedContent, "update", { + // Publish with the document's own encoding. VS Code's codec covers the + // legacy code pages Node cannot represent (a hand-rolled encoder would + // have to reject them), so encode here and let the guarded write publish + // the bytes unchanged. The write kind comes from the caller: a targeted + // edit after a partial read is authorized by the edit guard, while a + // full-file replacement still needs a complete observation. + const encodedContent = await vscode.workspace.encode(editedContent, { encoding: updatedDocument.encoding, }) + await guardedWrite(saveTask, this.relPath, encodedContent, writeKind) } catch (error) { // Discard-only failure cleanup. The publish was rejected (stale // version, unobserved target, a partial-read observation, or an diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 8d9f88c8dc..f473a204fc 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -66,6 +66,9 @@ vi.mock("path", () => ({ vi.mock("vscode", () => ({ workspace: { applyEdit: vi.fn(), + // VS Code's own codec: the mock echoes the content back as bytes so the + // assertions can see exactly what the publish received. + encode: vi.fn((content: string) => Promise.resolve(Buffer.from(content))), onDidOpenTextDocument: vi.fn(() => ({ dispose: vi.fn() })), openTextDocument: vi.fn().mockResolvedValue({ isDirty: false, @@ -846,7 +849,7 @@ describe("DiffViewProvider", () => { // Verify file was written via safeWriteText const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", {}) + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify file was opened without focus expect(vscode.window.showTextDocument).toHaveBeenCalledWith( @@ -869,7 +872,7 @@ describe("DiffViewProvider", () => { // Verify file was written via safeWriteText const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", {}) + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify file was NOT opened expect(vscode.window.showTextDocument).not.toHaveBeenCalled() @@ -884,7 +887,7 @@ describe("DiffViewProvider", () => { // Verify file was written via safeWriteText const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", {}) + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify delay was NOT called expect(mockDelay).not.toHaveBeenCalled() @@ -930,7 +933,7 @@ describe("DiffViewProvider", () => { await diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0) - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", {}) + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") }) it("rejects an observed write whose version token is stale", async () => { @@ -959,7 +962,7 @@ describe("DiffViewProvider", () => { await diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0) - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", {}) + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") }) it("fails closed when the owning task has been collected", async () => { @@ -1145,14 +1148,14 @@ describe("DiffViewProvider", () => { const result = await diffViewProvider.saveChanges(false) - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", { encoding: "utf8" }) + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) expect(result.newProblemsMessage).toBe("") }) it("publishes the accepted content in the document's own encoding", async () => { // A utf8bom document: getText() returns the text without the BOM, so the - // guarded publish must re-encode it with the BOM the document was read - // in rather than as plain UTF-8. + // publish must go through VS Code's codec for the document's own + // encoding rather than re-encoding the text as plain UTF-8. const bomEditor = { document: { uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, @@ -1168,9 +1171,10 @@ describe("DiffViewProvider", () => { await diffViewProvider.saveChanges(false) - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", { + expect(vi.mocked(vscode.workspace.encode)).toHaveBeenCalledWith("new content", { encoding: "utf8bom", }) + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) }) it("rejects the accepted save when the file changed after the preview (stale version)", async () => { @@ -1206,6 +1210,17 @@ describe("DiffViewProvider", () => { ) expect(safeWriteText).not.toHaveBeenCalled() }) + it("publishes a targeted edit that a partial observation authorizes", async () => { + // The same partial observation rejects a full-file replacement but + // authorizes the targeted edit the tool performed, so the write kind + // the tool passed must reach the guard. + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, "v1", false) + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + + await diffViewProvider.saveChanges(false, 0, "edit") + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) + }) it("fails closed when the owning task has been collected", async () => { // A real WeakRef cannot be forced to deref to undefined deterministically @@ -1417,7 +1432,7 @@ describe("DiffViewProvider", () => { const result = await diffViewProvider.saveChanges(false) - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", { encoding: "utf8" }) + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) expect(result.newProblemsMessage).toBe("") }) @@ -1443,7 +1458,7 @@ describe("DiffViewProvider", () => { const result = await diffViewProvider.saveChanges(false) - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", { encoding: "utf8" }) + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) // the revert activates the exact document with the exact options: // preserveFocus keeps the user's focus, preview: false pins the tab expect(vi.mocked(vscode.window.showTextDocument)).toHaveBeenCalledWith(dirtyEditor.document, { diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 795eaa4573..118d3b41d9 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -762,7 +762,7 @@ describe("safeWriteText", () => { expect(fs.rename).not.toHaveBeenCalled() }) }) - describe("content encoding", () => { + describe("content bytes", () => { const targetPath = "/tmp/enc-dir/target.txt" beforeEach(() => { @@ -770,46 +770,19 @@ describe("safeWriteText", () => { vi.mocked(fsSync.openSync).mockReturnValue(1) }) - it("stages UTF-8 bytes when no encoding is given", async () => { + it("stages UTF-8 bytes for string content", async () => { await safeWriteText(targetPath, "héllo", { platform: "linux" }) expect(fsSync.writeSync).toHaveBeenCalledWith(1, Buffer.from("héllo", "utf8"), 0, 6) }) - it("prepends the UTF-8 BOM for a utf8bom document", async () => { - await safeWriteText(targetPath, "hi", { platform: "linux", encoding: "utf8bom" }) - expect(fsSync.writeSync).toHaveBeenCalledWith( - 1, - Buffer.concat([Buffer.from("\uFEFF", "utf8"), Buffer.from("hi", "utf8")]), - 0, - 5, - ) - }) - - it("stages UTF-16LE bytes for a utf16le document", async () => { - await safeWriteText(targetPath, "hi", { platform: "linux", encoding: "utf16le" }) - expect(fsSync.writeSync).toHaveBeenCalledWith(1, Buffer.from("hi", "utf16le"), 0, 4) - }) - - it("stages byte-swapped UTF-16BE bytes for a utf16be document", async () => { - // Node has no UTF-16BE encoder, so the LE bytes are swapped pairwise. - await safeWriteText(targetPath, "hi", { platform: "linux", encoding: "utf16be" }) - expect(fsSync.writeSync).toHaveBeenCalledWith(1, Buffer.from([0x00, 0x68, 0x00, 0x69]), 0, 4) - }) - - it("publishes an ASCII-only document unchanged for a code page Node cannot encode", async () => { - await safeWriteText(targetPath, "plain", { platform: "linux", encoding: "windows1252" }) - expect(fsSync.writeSync).toHaveBeenCalledWith(1, Buffer.from("plain", "utf8"), 0, 5) - }) - - it("rejects a non-ASCII document for a code page Node cannot encode, before staging anything", async () => { - await expect( - safeWriteText(targetPath, "héllo", { platform: "linux", encoding: "windows1252" }), - ).rejects.toThrow("Cannot publish content as windows1252") - // the encoding is checked before the staging file is opened, so no - // half-written temp file is left behind - expect(fsSync.openSync).not.toHaveBeenCalled() - expect(fsSync.writeSync).not.toHaveBeenCalled() - expect(fs.rename).not.toHaveBeenCalled() + it("publishes caller-supplied bytes unchanged instead of re-encoding them", async () => { + // The extension host encodes a document with VS Code's own codec, which + // covers the legacy code pages and BOMs Node cannot represent, and hands + // the result over: those bytes must reach the commit rename exactly as + // they were given. + const bytes = Buffer.from([0x00, 0x68, 0x00, 0x69]) + await safeWriteText(targetPath, bytes, { platform: "linux" }) + expect(fsSync.writeSync).toHaveBeenCalledWith(1, bytes, 0, 4) }) }) }) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 2ea7ba6531..37a45d8cd7 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -28,15 +28,6 @@ export interface SafeWriteTextOptions { */ execFileRunner?: typeof execFile - /** - * Encoding used to turn `content` into the bytes that are staged. VS Code - * documents `TextDocument.encoding` as the encoding used when a document is - * saved, so publishing every document as UTF-8 unconditionally can drop a - * UTF-8 BOM or rewrite a legacy-code-page document as mojibake. Defaults to - * "utf8". - */ - encoding?: string - /** * Pre-written temp path to use for the commit phase. When provided, * safeWriteText skips creating its own staging file and uses this path @@ -114,41 +105,6 @@ async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRu } } -/** Encode `content` to the bytes staged for the commit rename. - * - * The encoding is the caller's document encoding, not an internal choice: - * encoding everything as UTF-8 unconditionally drops a UTF-8 BOM and rewrites a - * legacy-code-page document as mojibake. Node can encode the encodings listed - * below faithfully; for any other code page the bytes are identical to UTF-8 - * only when the content is pure ASCII, so an ASCII-only document is still safe - * to publish and anything else is rejected rather than silently corrupted. - */ -export function encodeContent(content: string, encoding?: string): Buffer { - const enc = encoding ?? "utf8" - switch (enc) { - case "utf8": - // utf8 is Node's default encoding, so no encoding argument is passed: - // the StringLiteral would be an equivalent mutant. - return Buffer.from(content) - case "utf8bom": - return Buffer.concat([Buffer.from("\uFEFF"), Buffer.from(content)]) - case "utf16le": - return Buffer.from(content, "utf16le") - case "utf16be": - // Node has no UTF-16BE encoder: it is UTF-16LE with the bytes - // swapped pairwise. - return Buffer.from(content, "utf16le").swap16() - default: - if (!/[\u0080-\uffff]/.test(content)) { - // ASCII is byte-identical in every VS Code text encoding. - return Buffer.from(content) - } - throw new Error( - `Cannot publish content as ${enc}: the encoding is not encodable here; re-save the file as UTF-8 and retry.`, - ) - } -} - // -- public API ------------------------------------------------------------ /** @@ -185,7 +141,11 @@ export async function resolvePublishTarget(absoluteFilePath: string): Promise { +export async function safeWriteText( + filePath: string, + content: string | Uint8Array, + options?: SafeWriteTextOptions, +): Promise { const absoluteFilePath = path.resolve(filePath) // Resolve the symlink referent (see resolvePublishTarget). @@ -223,7 +183,11 @@ export async function safeWriteText(filePath: string, content: string, options?: // must not become 0o644 through the atomic rename). // Encode before opening the staging file: an encoding Node cannot // represent must not leave a half-written temp file behind. - const buffer = encodeContent(content, options?.encoding) + // A string is encoded as UTF-8; bytes handed in by the caller (the + // extension host encodes a document with VS Code's own codec, which + // covers the legacy code pages Node cannot represent) are published + // unchanged. + const buffer = Buffer.from(content) let targetMode = 0o644 // default for a fresh target try { targetMode = fsSync.statSync(targetPath).mode & 0o777 From 133b9853d5f2d6da1e884d8af8571b0687a3ed77 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 10:54:57 +0800 Subject: [PATCH 029/100] test(s4b): assert the codec's bytes reach the publish, not only that the codec ran The encoding double now returns bytes that differ from plain UTF-8 for a utf8bom document, so the assertion proves the guarded publish writes the codec's output rather than re-encoding the string itself. --- .../editor/__tests__/DiffViewProvider.spec.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index f473a204fc..104f854030 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -66,9 +66,16 @@ vi.mock("path", () => ({ vi.mock("vscode", () => ({ workspace: { applyEdit: vi.fn(), - // VS Code's own codec: the mock echoes the content back as bytes so the - // assertions can see exactly what the publish received. - encode: vi.fn((content: string) => Promise.resolve(Buffer.from(content))), + // VS Code's own codec. The double returns bytes that differ from the plain + // UTF-8 encoding of the same text, so an assertion can prove the publish + // writes the codec's output rather than re-encoding the string itself. + encode: vi.fn((content: string, options: { encoding: string }) => + Promise.resolve( + options.encoding === "utf8bom" + ? Buffer.concat([Buffer.from("\uFEFF"), Buffer.from(content)]) + : Buffer.from(content), + ), + ), onDidOpenTextDocument: vi.fn(() => ({ dispose: vi.fn() })), openTextDocument: vi.fn().mockResolvedValue({ isDirty: false, @@ -1174,7 +1181,10 @@ describe("DiffViewProvider", () => { expect(vi.mocked(vscode.workspace.encode)).toHaveBeenCalledWith("new content", { encoding: "utf8bom", }) - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) + expect(safeWriteText).toHaveBeenCalledWith( + `${mockCwd}/test.ts`, + Buffer.concat([Buffer.from("\uFEFF"), Buffer.from("new content")]), + ) }) it("rejects the accepted save when the file changed after the preview (stale version)", async () => { From 31bd9132754d260558e9acf4ec59c7fb12a2c87c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 11:51:24 +0800 Subject: [PATCH 030/100] fix(s4b): keep a partial read partial after an edit and count a clipped line as truncated - guardedWrite no longer refreshes the observation as complete for an "edit": the tool replaced only the region the model saw, so a later full-file replacement must still be rejected as a partial read. - readWithSlice reports truncation when formatWithLineNumbers clips a line longer than MAX_LINE_LENGTH, so a slice that returned every line but hid a line suffix cannot authorize a full-file replacement. - the safeWriteJson symlink test comment now names the resolved referent as the lock key, matching what safeWriteJson actually locks. --- src/core/tools/__tests__/guardedWrite.spec.ts | 33 +++++++++++++++++++ src/core/tools/guardedWrite.ts | 11 ++++++- .../misc/__tests__/indentation-reader.spec.ts | 32 ++++++++++++++++++ src/integrations/misc/indentation-reader.ts | 5 ++- src/utils/__tests__/safeWriteJson.test.ts | 5 ++- 5 files changed, 81 insertions(+), 5 deletions(-) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index 31fb9e43cf..6ffc82eb56 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -95,6 +95,20 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("bytes.txt"), Buffer.from([0x00, 0x68])) }) + it("records an unobserved create as complete so a later full-file update is allowed", async () => { + // Nothing was read, so the model supplied the whole file: the post-publish + // refresh must record completeness, otherwise the next update would be + // rejected as a partial read. + const reg = new ObservationRegistry() + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "new-file.txt", "hello", "create") + + expect(reg.get(abs("new-file.txt"))?.complete).toBe(true) + }) + it("fails with the read-first remediation when the file exists - nothing published", async () => { mockedFsAccess.mockResolvedValue(undefined) const task = createMockTask() @@ -283,6 +297,25 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "patched") }) + it("keeps a partial observation partial after an edit so a later full replacement is rejected", async () => { + // The edit replaced only the region the model saw. Refreshing the + // observation to complete would let a following full-file write publish + // content built from the slice alone. + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", false) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "patched", "edit") + + expect(reg.get(abs("doc.txt"))?.complete).toBe(false) + + await expect(guardedWrite(task, "doc.txt", "full replacement", "update")).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + }) + it("rejects a create-kind full-file overwrite of an existing file when only a partial read observed it", async () => { const reg = new ObservationRegistry() reg.observe(abs("doc.txt"), "v1", false) diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index fe227e16b6..c3fcad29d3 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -235,6 +235,10 @@ export async function guardedWrite( return enqueue(absolutePath, async () => { const obs = task.observationRegistry.get(absolutePath) + // A targeted edit authorizes only the view the model saw, so a partial + // observation must stay partial after the publish; a full-file publish + // carries the whole content the model supplied and is complete. + let staysPartial = false if (kind === "edit") { // Edit-style writes require a prior read: no observation, no write. @@ -245,6 +249,7 @@ export async function guardedWrite( await unobservedEditGuard(absolutePath) } else { await replaceIfVersion(absolutePath, obs.version, content) + staysPartial = obs.complete === false } } else { // "create" or "update" publish a full file built on the model's @@ -286,7 +291,11 @@ export async function guardedWrite( // runs after a publish actually happened. const publishedToken = await computeVersionToken(absolutePath).catch(() => undefined) if (publishedToken !== undefined) { - task.observationRegistry.observe(absolutePath, publishedToken, true) + // Refresh with the new token, keeping the completeness the guard + // established: a partial observation that authorized a targeted edit + // must stay partial, otherwise a later full-file replacement would + // publish content built from the slice alone. + task.observationRegistry.observe(absolutePath, publishedToken, !staysPartial) } }) } diff --git a/src/integrations/misc/__tests__/indentation-reader.spec.ts b/src/integrations/misc/__tests__/indentation-reader.spec.ts index d46cb54277..0f3160e77e 100644 --- a/src/integrations/misc/__tests__/indentation-reader.spec.ts +++ b/src/integrations/misc/__tests__/indentation-reader.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest" +import { MAX_LINE_LENGTH } from "../../../core/prompts/tools/native-tools/read_file" import { parseLines, formatWithLineNumbers, @@ -279,6 +280,37 @@ describe("readWithSlice", () => { expect(result.wasTruncated).toBe(true) }) + it("marks a full-file slice truncated when a line is clipped by the length cap", () => { + // Every line is returned, but formatWithLineNumbers clips a line longer + // than MAX_LINE_LENGTH, so the model did not see the whole file and the read + // must not count as complete. + const longLine = "x".repeat(MAX_LINE_LENGTH + 10) + const result = readWithSlice(longLine, 0, 10) + + expect(result.returnedLines).toBe(1) + expect(result.wasTruncated).toBe(true) + }) + + it("keeps a slice complete when a line is exactly at the length cap", () => { + // formatWithLineNumbers clips only lines strictly longer than the cap, so a + // line at exactly MAX_LINE_LENGTH is shown in full and the read is complete. + const lines = ["x".repeat(MAX_LINE_LENGTH), "short"].join("\n") + const result = readWithSlice(lines, 0, 10) + + expect(result.returnedLines).toBe(2) + expect(result.wasTruncated).toBe(false) + }) + + it("marks a slice truncated when any line is clipped, not only when every line is", () => { + // The first line is clipped and the second is shown in full: some lines are + // a partial view even though every line was returned. + const lines = ["y".repeat(MAX_LINE_LENGTH + 1), "short"].join("\n") + const result = readWithSlice(lines, 0, 10) + + expect(result.returnedLines).toBe(2) + expect(result.wasTruncated).toBe(true) + }) + it("should handle offset beyond file end", () => { const result = readWithSlice(SIMPLE_CODE, 1000, 10) diff --git a/src/integrations/misc/indentation-reader.ts b/src/integrations/misc/indentation-reader.ts index aecabd5982..3f646cb6c4 100644 --- a/src/integrations/misc/indentation-reader.ts +++ b/src/integrations/misc/indentation-reader.ts @@ -454,7 +454,10 @@ export function readWithSlice( // Slice lines const endIdx = Math.min(offset + limit, totalLines) const selectedLines = lines.slice(offset, endIdx) - const wasTruncated = endIdx < totalLines + // Every line can still be a partial view: formatWithLineNumbers clips a + // line longer than MAX_LINE_LENGTH, so a slice that returns the whole file + // may still hide content and must not count as a complete read. + const wasTruncated = endIdx < totalLines || selectedLines.some((line) => line.content.length > MAX_LINE_LENGTH) // Format output const formattedContent = formatWithLineNumbers(selectedLines) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index eb02771c3a..01da62993a 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -562,9 +562,8 @@ describe("safeWriteJson", () => { const callerPath = path.join(linkDir, "test-file.json") const referentPath = path.join(referentDir, "test-file.json") // Seed the RESOLVED referent with real content (via the actual fs) so the - // write exercises replacement of an EXISTING referent: the lock is - // acquired on the caller path (realpath:false, which may be absent) while - // the backup + commit happen on the referent. + // write exercises replacement of an EXISTING referent: the lock, the + // backup, and the commit all target the resolved referent. await fsPromisesActuals.writeFile!(referentPath, JSON.stringify({ seed: true })) vi.spyOn(fs, "realpath").mockResolvedValue(referentPath) From 1771fcc7fb06ea4097022e9b4d69d61e3f9a1dcf Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 14:30:08 +0800 Subject: [PATCH 031/100] fix(s4b): keep the model's read completeness for apply_patch and separate clipping from omission - apply_patch's own hunk read no longer upgrades the model's observation to complete: it carries the prior completeness (a file the model never read stays partial), and an update hunk publishes as an "edit", so a later full-file replacement is still rejected as a partial read. - readWithSlice reports line clipping as hasClippedLines instead of folding it into wasTruncated, so a full-file read no longer prints a next offset beyond the file; completeness now uses both signals. - the safeWriteText tests assert the rename and rmdir arguments instead of bare call checks. --- src/core/task/observationRegistry.ts | 7 +- src/core/tools/ApplyPatchTool.ts | 14 +++- src/core/tools/ReadFileTool.ts | 21 +++-- .../__tests__/applyPatchTool.execute.spec.ts | 56 ++++++++++++- src/core/tools/__tests__/readFileTool.spec.ts | 81 +++++++++++++++++++ .../misc/__tests__/indentation-reader.spec.ts | 29 ++++--- src/integrations/misc/indentation-reader.ts | 17 ++-- .../__tests__/safeWriteText.spec.ts | 9 ++- 8 files changed, 201 insertions(+), 33 deletions(-) diff --git a/src/core/task/observationRegistry.ts b/src/core/task/observationRegistry.ts index 738e3645bb..fae9eb6976 100644 --- a/src/core/task/observationRegistry.ts +++ b/src/core/task/observationRegistry.ts @@ -31,9 +31,10 @@ export class ObservationRegistry { * * Re-observing replaces the entry with a fresh observedAt timestamp, the * new version token, and the read's completeness. `complete` defaults to - * true: the existing callers (ApplyPatchTool, spec doubles) all observe - * after reading the full file buffer, so a partial read must opt in - * explicitly. + * true for callers that read the whole file themselves (spec doubles, + * WriteToFileTool). A caller whose read is internal to a targeted edit must + * carry the model's prior completeness instead, so the tool's own read cannot + * upgrade a partial read into authority for a full-file replacement. */ observe(absolutePath: string, version: string, complete: boolean = true): void { this.entries.set(absolutePath, { version, observedAt: Date.now(), complete }) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 3daf3a116e..a19c95b0cc 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -101,7 +101,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (preReadStats && postReadStats) { const preReadToken = versionTokenOfStat(preReadStats) if (preReadToken === versionTokenOfStat(postReadStats)) { - task.observationRegistry.observe(absolutePath, preReadToken) + // This is the tool's own hunk read, not a model read: keep the + // completeness the model actually earned, so a targeted patch cannot + // authorize a later full-file replacement. A file the model never + // read stays partial. + const prior = task.observationRegistry.get(absolutePath) + task.observationRegistry.observe(absolutePath, preReadToken, prior?.complete === true) } } return content @@ -460,15 +465,16 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } else { // Save changes to the same file if (isPreventFocusDisruptionEnabled) { - // Guarded publish: the patched file content is complete, so create-guard - // semantics apply (stale observed versions are rejected with a re-read hint). + // Guarded publish: an update hunk is a targeted change, so it is guarded + // as an edit -- a partial observation authorizes it and stays partial. A + // stale observed version is still rejected with the re-read hint. await task.diffViewProvider.saveDirectly( relPath, newContent, false, diagnosticsEnabled, writeDelayMs, - "create", + "edit", ) } else { await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 743b56cd96..95f02ab1de 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -23,7 +23,7 @@ import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { getReadablePath } from "../../utils/path" import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text" import { readWithIndentation, readWithSlice } from "../../integrations/misc/indentation-reader" -import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file" +import { DEFAULT_LINE_LIMIT, MAX_LINE_LENGTH } from "../prompts/tools/native-tools/read_file" import type { ToolUse, PushToolResult } from "../../shared/tools" import { @@ -350,15 +350,20 @@ export class ReadFileTool extends BaseTool<"read_file"> { To read more: Use the read_file tool with offset=${nextOffset} and limit=${limit}. ${result.content}` + } else if (result.hasClippedLines) { + // Every line was returned, so there is no later offset to read: report the + // clipping without a next-offset hint, and keep the read incomplete so a + // full-file replacement cannot be built from a clipped line. + output = `IMPORTANT: Some lines exceed ${MAX_LINE_LENGTH} characters and were clipped in this view. The file was read in full, but the clipped lines were not shown in full.` } else if (result.returnedLines === 0) { output = "Note: File is empty" } - // Complete only when the slice starts at line 1 and is not truncated: - // readWithSlice then runs to the end of the file and returns every line - // (returnedLines === totalLines follows from those two conditions), so the - // model saw the whole file; a partial start or a truncated tail does not. - const complete = offset0 === 0 && !result.wasTruncated + // Complete only when the slice starts at line 1, returned every line, and + // showed every line in full (returnedLines === totalLines follows from the + // first two conditions): a partial start, a truncated tail, or a clipped + // line means the model did not see the whole file. + const complete = offset0 === 0 && !result.wasTruncated && !result.hasClippedLines return { content: output, complete } } @@ -832,9 +837,11 @@ export class ReadFileTool extends BaseTool<"read_file"> { // Read with default limits using slice mode const result = readWithSlice(rawContent, 0, DEFAULT_LINE_LIMIT) content = result.content - readComplete = !result.wasTruncated + readComplete = !result.wasTruncated && !result.hasClippedLines if (result.wasTruncated) { content += `\n\n[File truncated: showing ${result.returnedLines} of ${result.totalLines} total lines]` + } else if (result.hasClippedLines) { + content += `\n\n[Some lines exceed the per-line length cap and were clipped in this view]` } } diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 1c07b8c572..3afc12c719 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -8,6 +8,7 @@ import path from "path" import * as fsPromises from "fs/promises" import type { Task } from "../../task/Task" import { ObservationRegistry } from "../../task/observationRegistry" +import { guardedWrite } from "../guardedWrite" import { ApplyPatchTool } from "../ApplyPatchTool" // The vi.mock factory exposes the fs/promises functions under a `default` @@ -16,6 +17,7 @@ import { ApplyPatchTool } from "../ApplyPatchTool" const mockedFsPromises = vi.mocked( fsPromises as unknown as { default: { + access: MockedFunction stat: ReturnType unlink: MockedFunction } @@ -24,6 +26,9 @@ const mockedFsPromises = vi.mocked( vi.mock("fs/promises", () => ({ default: { + // The target exists on disk, so the completeness gate applies to the + // follow-up guarded write in the partial-observation test. + access: vi.fn().mockResolvedValue(undefined), readFile: vi.fn().mockResolvedValue("original file content\n"), // Stable on-disk version for the S2 self-read observation (the hunk // read now stats before and after; equal tokens record the observe). @@ -224,7 +229,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { tool = new ApplyPatchTool() }) - it("update: publishes through the guarded saveDirectly with create kind", async () => { + it("update: publishes the targeted hunk through the guarded saveDirectly with edit kind", async () => { await tool.execute({ patch: updatePatch }, mockTask as Task, { askApproval: mockAskApproval, handleError: mockHandleError, @@ -237,13 +242,60 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { false, true, 1000, - "create", + "edit", ) expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") expect(mockTask.didEditFile).toBe(true) expect(mockHandleError).not.toHaveBeenCalled() }) + it("update: keeps a partial observation partial so a later full-file write is still rejected", async () => { + // The hunk read is the tool's own read, not a model read. Upgrading the + // model's partial view to complete here would let a later write_to_file + // replace the file with content built from the slice the model saw. + const key = path.resolve("/workspace/project", "src/thing.ts") + const reg = mockTask.observationRegistry + reg.observe(key, "7:4242:1234:1700000000123456789:1700000000789999999", false) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(key)?.complete).toBe(false) + // The targeted hunk itself is still allowed, guarded as an edit. + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/thing.ts", + "modified file content\n", + false, + true, + 1000, + "edit", + ) + + await expect(guardedWrite(mockTask as Task, "src/thing.ts", "full replacement", "update")).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + }) + + it("update: keeps a complete observation complete across the tool's own hunk read", async () => { + // Completeness is the model's, not the tool's: a complete read stays complete + // and a partial one stays partial, so a later full-file write is still gated. + const key = path.resolve("/workspace/project", "src/thing.ts") + const reg = mockTask.observationRegistry + reg.observe(key, "7:4242:1234:1700000000123456789:1700000000789999999", true) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(key)?.complete).toBe(true) + }) + it("update: observes the hunk read so the guarded publish is not unobserved", async () => { await tool.execute({ patch: updatePatch }, mockTask as Task, { askApproval: mockAskApproval, diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 2ad95cb3ba..9e9b8a5261 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -1886,6 +1886,38 @@ describe("ReadFileTool", () => { expect(reg.get(calledPath)!.complete).toBe(false) }) + it("native: a full read whose line content was clipped records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\n")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 2, + wasTruncated: false, + hasClippedLines: true, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute({ path: "clipped.ts" }, mockTask as unknown as Task, callbacks) + + const [, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + // Every line was returned, so the notice must not point at a next + // offset that is beyond the file. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("clipped in this view") + expect(pushed).not.toContain("To read more") + }) + it("native: an offset read that is not truncated still records a partial observation", async () => { const mockTask = createMockTask({ observationRegistry: new ObservationRegistry(), @@ -2004,6 +2036,49 @@ describe("ReadFileTool", () => { expect(observeSpy).toHaveBeenCalledTimes(1) const [calledPath, , calledComplete] = observeSpy.mock.calls[0] expect(calledComplete).toBe(true) + + // Nothing was omitted and nothing was clipped, so no note is added. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).not.toContain("clipped in this view") + expect(pushed).not.toContain("total lines") + }) + + it("legacy: a full read with a clipped line records a partial observation and reports the clipping", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue("a\nb") + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 2, + wasTruncated: false, + hasClippedLines: true, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-clipped.ts" }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + const [, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + + // Every line was returned, so the note reports the clipping instead of + // a showing-N-of-N count that would point past the file. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("clipped in this view") + expect(pushed).not.toContain("showing 2 of 2 total lines") }) it("legacy: a slice truncated to the default limit records a partial observation", async () => { @@ -2036,6 +2111,12 @@ describe("ReadFileTool", () => { expect(observeSpy).toHaveBeenCalledTimes(1) const [calledPath, , calledComplete] = observeSpy.mock.calls[0] expect(calledComplete).toBe(false) + + // Lines were omitted here, so the note reports the omitted range rather + // than clipping. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("showing 1 of 5000 total lines") + expect(pushed).not.toContain("clipped in this view") }) }) }) diff --git a/src/integrations/misc/__tests__/indentation-reader.spec.ts b/src/integrations/misc/__tests__/indentation-reader.spec.ts index 0f3160e77e..e9b27e7191 100644 --- a/src/integrations/misc/__tests__/indentation-reader.spec.ts +++ b/src/integrations/misc/__tests__/indentation-reader.spec.ts @@ -280,15 +280,15 @@ describe("readWithSlice", () => { expect(result.wasTruncated).toBe(true) }) - it("marks a full-file slice truncated when a line is clipped by the length cap", () => { + it("reports a clipped line separately from omitted lines", () => { // Every line is returned, but formatWithLineNumbers clips a line longer - // than MAX_LINE_LENGTH, so the model did not see the whole file and the read - // must not count as complete. - const longLine = "x".repeat(MAX_LINE_LENGTH + 10) - const result = readWithSlice(longLine, 0, 10) + // than MAX_LINE_LENGTH, so the model did not see the whole file. + const lines = ["x".repeat(MAX_LINE_LENGTH + 10), "short"].join("\n") + const result = readWithSlice(lines, 0, 10) - expect(result.returnedLines).toBe(1) - expect(result.wasTruncated).toBe(true) + expect(result.returnedLines).toBe(2) + expect(result.wasTruncated).toBe(false) + expect(result.hasClippedLines).toBe(true) }) it("keeps a slice complete when a line is exactly at the length cap", () => { @@ -299,16 +299,17 @@ describe("readWithSlice", () => { expect(result.returnedLines).toBe(2) expect(result.wasTruncated).toBe(false) + expect(result.hasClippedLines).toBe(false) }) - it("marks a slice truncated when any line is clipped, not only when every line is", () => { + it("flags clipping when any line is clipped, not only when every line is", () => { // The first line is clipped and the second is shown in full: some lines are // a partial view even though every line was returned. const lines = ["y".repeat(MAX_LINE_LENGTH + 1), "short"].join("\n") const result = readWithSlice(lines, 0, 10) expect(result.returnedLines).toBe(2) - expect(result.wasTruncated).toBe(true) + expect(result.hasClippedLines).toBe(true) }) it("should handle offset beyond file end", () => { @@ -316,6 +317,8 @@ describe("readWithSlice", () => { expect(result.returnedLines).toBe(0) expect(result.content).toContain("Error") + // No line was returned, so nothing could have been clipped. + expect(result.hasClippedLines).toBe(false) }) it("should handle negative offset", () => { @@ -329,6 +332,14 @@ describe("readWithSlice", () => { // ─── readWithIndentation Tests ──────────────────────────────────────────────── describe("readWithIndentation", () => { + it("reports an out-of-range anchor as an error with no clipping", () => { + const result = readWithIndentation(SIMPLE_CODE, { anchorLine: 1000 }) + + expect(result.content).toContain("out of range") + expect(result.returnedLines).toBe(0) + expect(result.hasClippedLines).toBe(false) + }) + describe("basic block extraction", () => { it("should extract content around the anchor line", () => { const result = readWithIndentation(PYTHON_CODE, { diff --git a/src/integrations/misc/indentation-reader.ts b/src/integrations/misc/indentation-reader.ts index 3f646cb6c4..5cbd23d168 100644 --- a/src/integrations/misc/indentation-reader.ts +++ b/src/integrations/misc/indentation-reader.ts @@ -58,8 +58,10 @@ export interface IndentationReadResult { totalLines: number /** Lines actually returned */ returnedLines: number - /** Whether output was truncated due to limit */ + /** Whether output was truncated because lines were omitted */ wasTruncated: boolean + /** Whether any returned line was clipped by the per-line length cap */ + hasClippedLines?: boolean } // ─── Constants ──────────────────────────────────────────────────────────────── @@ -306,6 +308,7 @@ export function readWithIndentation(content: string, options: IndentationReadOpt totalLines, returnedLines: 0, wasTruncated: false, + hasClippedLines: false, } } @@ -448,16 +451,19 @@ export function readWithSlice( totalLines, returnedLines: 0, wasTruncated: false, + hasClippedLines: false, } } // Slice lines const endIdx = Math.min(offset + limit, totalLines) const selectedLines = lines.slice(offset, endIdx) - // Every line can still be a partial view: formatWithLineNumbers clips a - // line longer than MAX_LINE_LENGTH, so a slice that returns the whole file - // may still hide content and must not count as a complete read. - const wasTruncated = endIdx < totalLines || selectedLines.some((line) => line.content.length > MAX_LINE_LENGTH) + const wasTruncated = endIdx < totalLines + // A returned line can still be a partial view: formatWithLineNumbers clips a + // line longer than MAX_LINE_LENGTH, so a slice that returned every line may + // still hide content. Clipping is reported separately from omission so the + // caller does not suggest a next offset that is beyond the file. + const hasClippedLines = selectedLines.some((line) => line.content.length > MAX_LINE_LENGTH) // Format output const formattedContent = formatWithLineNumbers(selectedLines) @@ -468,5 +474,6 @@ export function readWithSlice( totalLines, returnedLines: selectedLines.length, wasTruncated, + hasClippedLines, } } diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 118d3b41d9..a5ce3ef1d6 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -172,8 +172,9 @@ describe("safeWriteText", () => { await expect(safeWriteText(targetPath, "hello", { platform: "linux" })).resolves.toBeUndefined() // the commit rename still happened and the rmdir error was swallowed - expect(fs.rename).toHaveBeenCalled() - expect(fs.rmdir).toHaveBeenCalled() + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + expect(fs.rmdir).toHaveBeenCalledTimes(1) + expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) }) it("gives each self-staged write its own staging directory so a concurrent write cannot remove it", async () => { @@ -423,7 +424,9 @@ describe("safeWriteText", () => { await safeWriteText(targetPath, "data", { platform: "win32" }) // write succeeded despite icacls failure (fallback to plain rename) - expect(fs.rename).toHaveBeenCalled() + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + // one icacls attempt only: a failed DACL apply must not try to restore + expect(execFile).toHaveBeenCalledTimes(1) }) it("win32 DACL: a partial dump left by a failed save is removed and never restored", async () => { From b4ad010d9ee322e23d6ccd4b9839b037a9e7f463 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 15:04:11 +0800 Subject: [PATCH 032/100] fix(s4b): carry apply_patch completeness only on the version it was earned on The hunk read could re-observe a complete observation against a version the model never read: a complete read of an older version, an intervening change outside the hunk, then a patch publish would leave a complete observation on the new token and a later full-file replacement would pass. Completeness is now preserved only when the prior observation's version matches the version the hunk read saw; otherwise the observation stays partial. Added the regression test for that sequence. --- src/core/tools/ApplyPatchTool.ts | 10 +++++---- .../__tests__/applyPatchTool.execute.spec.ts | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index a19c95b0cc..d97e46cb06 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -102,11 +102,13 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const preReadToken = versionTokenOfStat(preReadStats) if (preReadToken === versionTokenOfStat(postReadStats)) { // This is the tool's own hunk read, not a model read: keep the - // completeness the model actually earned, so a targeted patch cannot - // authorize a later full-file replacement. A file the model never - // read stays partial. + // completeness the model actually earned, and only on the version that + // completeness was earned on. A file the model never read, or a version + // that moved since its complete read, stays partial: the model cannot + // replace content it never saw. const prior = task.observationRegistry.get(absolutePath) - task.observationRegistry.observe(absolutePath, preReadToken, prior?.complete === true) + const complete = prior?.complete === true && prior.version === preReadToken + task.observationRegistry.observe(absolutePath, preReadToken, complete) } } return content diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 3afc12c719..b54c2841fc 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -296,6 +296,28 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { expect(reg.get(key)?.complete).toBe(true) }) + it("update: does not carry completeness across a version the model never read", async () => { + // The model earned completeness on a different version than the one the patch + // helper read: the intervening change was never seen, so a later full-file + // replacement must still fail closed. + const key = path.resolve("/workspace/project", "src/thing.ts") + const reg = mockTask.observationRegistry + reg.observe(key, "7:4242:1234:1700000000123456789:1700000000789999998", true) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(key)?.complete).toBe(false) + + await expect(guardedWrite(mockTask as Task, "src/thing.ts", "full replacement", "update")).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + }) + it("update: observes the hunk read so the guarded publish is not unobserved", async () => { await tool.execute({ patch: updatePatch }, mockTask as Task, { askApproval: mockAskApproval, From a325bd2ba2ddd2d04f4983af25510590ea472301 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 15:39:12 +0800 Subject: [PATCH 033/100] fix(s4b): match the guard kind on the diff-view save and keep a clipped read's content - ApplyPatchTool's diff-view save (focus-disruption prevention off) now passes the same guard the guarded save uses: "edit" for an update hunk, "create" for an added file. The default "update" kind rejected a partial read there and the approved patch was thrown away. - the native read_file clipping notice is added on top of the read instead of replacing it, so a clipped-only view still returns the lines it did show. - tests assert the guard kind on both diff-view save paths and that the clipped read still carries its content. --- src/core/tools/ApplyPatchTool.ts | 9 +++- src/core/tools/ReadFileTool.ts | 3 +- .../__tests__/applyPatchTool.execute.spec.ts | 53 ++++++++++++++++--- src/core/tools/__tests__/readFileTool.spec.ts | 2 + 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index d97e46cb06..b2f520b2e0 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -248,7 +248,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { "create", ) } else { - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + // The add path publishes a whole new file, so create-guard semantics + // apply here as well. + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "create") } // Track file edit operation @@ -479,7 +481,10 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { "edit", ) } else { - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + // The diff-view save is the same targeted hunk as the guarded save above: + // it must select the same edit guard, otherwise a partial read is + // rejected and the approved patch is thrown away. + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "edit") } await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 95f02ab1de..bfc7e63a47 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -354,7 +354,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { // Every line was returned, so there is no later offset to read: report the // clipping without a next-offset hint, and keep the read incomplete so a // full-file replacement cannot be built from a clipped line. - output = `IMPORTANT: Some lines exceed ${MAX_LINE_LENGTH} characters and were clipped in this view. The file was read in full, but the clipped lines were not shown in full.` + output = `IMPORTANT: Some lines exceed ${MAX_LINE_LENGTH} characters and were clipped in this view. The file was read in full, but the clipped lines were not shown in full. + ${result.content}` } else if (result.returnedLines === 0) { output = "Note: File is empty" } diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index b54c2841fc..37f5572eb7 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -146,6 +146,8 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { | "observationRegistry" > let mockSaveDirectly: MockedFunction<(...args: unknown[]) => Promise> + let mockSaveChanges: MockedFunction<(...args: unknown[]) => Promise> + let mockGetState: MockedFunction<() => Promise> let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> let mockPushToolResult: MockedFunction<(...args: unknown[]) => void> @@ -181,6 +183,17 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { userEdits: undefined, finalContent: "new content", }) + mockSaveChanges = vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "new content", + }) + mockGetState = vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + // Exercise the focus-disruption (saveDirectly) save path. + experiments: { preventFocusDisruption: true }, + }) // Structural stubs for the guarded-write path: the real DiffViewProvider is // out of scope here, so vi.fn() doubles stand in for the members the tool @@ -189,6 +202,10 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { editType: undefined as "create" | "modify" | undefined, originalContent: undefined as string | undefined, saveDirectly: mockSaveDirectly, + saveChanges: mockSaveChanges, + open: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + scrollToFirstDiff: vi.fn(), pushToolWriteResult: vi.fn().mockResolvedValue("Saved file"), reset: vi.fn().mockResolvedValue(undefined), } @@ -209,12 +226,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { diffViewProvider: diffViewProviderStub as unknown as Task["diffViewProvider"], providerRef: { deref: vi.fn().mockReturnValue({ - getState: vi.fn().mockResolvedValue({ - diagnosticsEnabled: true, - writeDelayMs: 1000, - // Exercise the focus-disruption (saveDirectly) save path. - experiments: { preventFocusDisruption: true }, - }), + getState: mockGetState, }), } as unknown as Task["providerRef"], fileContextTracker: { @@ -433,4 +445,33 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { expect(vi.mocked(mockTask.diffViewProvider.reset)).toHaveBeenCalled() expect(mockTask.didEditFile).toBe(false) }) + + it("update: the diff-view save selects the same edit guard as the guarded save", async () => { + // With focus-disruption prevention off the tool saves through the diff view. + // A partial read must not be rejected there, otherwise the patch the user + // approved is thrown away. + mockGetState.mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, experiments: {} }) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "edit") + expect(mockSaveDirectly).not.toHaveBeenCalled() + }) + + it("add: the diff-view save uses the create guard for a new file", async () => { + mockedFileExistsAtPath.mockResolvedValueOnce(false) + mockGetState.mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, experiments: {} }) + + await tool.execute({ patch: addPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "create") + }) }) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 9e9b8a5261..2bd4a48da7 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -1916,6 +1916,8 @@ describe("ReadFileTool", () => { const pushed = callbacks.pushToolResult.mock.calls[0][0] expect(pushed).toContain("clipped in this view") expect(pushed).not.toContain("To read more") + // The notice is added on top of the read, it does not replace it. + expect(pushed).toContain("1 | a") }) it("native: an offset read that is not truncated still records a partial observation", async () => { From e0180441a5a5f9501b27ee33121055cdb585f152 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 15:55:00 +0800 Subject: [PATCH 034/100] test(s4b): assert the saved result on the diff-view save paths The two diff-view save tests checked the guard kind but not what the save returned, so a save that failed after the call would still pass. They now assert the tool result and that no error was surfaced. --- src/core/tools/__tests__/applyPatchTool.execute.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 37f5572eb7..a146d7f49a 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -460,6 +460,8 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "edit") expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockHandleError).not.toHaveBeenCalled() }) it("add: the diff-view save uses the create guard for a new file", async () => { @@ -473,5 +475,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { }) expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "create") + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockHandleError).not.toHaveBeenCalled() }) }) From f223547ec7616d3773c6e660cb345fc84214f720 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 16:41:38 +0800 Subject: [PATCH 035/100] docs(s4b): bring the guard and registry headers in line with the shipped behavior The two headers still described the pre-guard state: the registry said observations were recorded but never consulted, and the guard said concurrent same-path writes fail stale after the first one. guardedWrite now consults the observation for both the version and the completeness check, and refreshes it after each publish, so same-task writes are last-write-wins and only an external token change fails stale. --- src/core/task/observationRegistry.ts | 5 +++-- src/core/tools/guardedWrite.ts | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/core/task/observationRegistry.ts b/src/core/task/observationRegistry.ts index fae9eb6976..0ef9115f21 100644 --- a/src/core/task/observationRegistry.ts +++ b/src/core/task/observationRegistry.ts @@ -5,8 +5,9 @@ * independent. The S4 guarded-write will compare these versions against the * token recomputed pre-write to detect stale reads or file replacement. * - * Pure in-memory — zero I/O, no dependencies. No behavior change in this PR: - * observations are recorded but not consulted. + * Pure in-memory — zero I/O, no dependencies. The S4 guarded-write consults + * these observations for the version check and for the completeness check that + * gates a full-file replacement. */ export interface FileObservation { diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index c3fcad29d3..1d111922d5 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -11,8 +11,11 @@ * - an edit-style write requires a prior observation (unobservedEditGuard). * * A per-absolute-path FIFO chain of tail promises orders concurrent - * in-process writes to the same path: the first matching write wins, the rest - * fail stale. Observations come from the task's S2 ObservationRegistry. + * in-process writes to the same path. Each publish refreshes the observation to + * the token it wrote, so same-task writes apply last-write-wins; a token changed + * by another writer, or a read that is no longer the current version, still + * fails stale. Observations come from the task's S2 ObservationRegistry and + * authorize the write as well as the version check. */ import * as fs from "fs/promises" From abd1a076221d92bb609cc9ff40294c3181ead390 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 16:57:22 +0800 Subject: [PATCH 036/100] docs(s4b): note the create exception in the guarded-write stale rule The header's stale-write sentence read as if every observed write compares the version. An observed "create" whose target has disappeared uses createIfAbsent and recreates it, so the stale failure applies to the replaceIfVersion path only. --- src/core/tools/guardedWrite.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index 1d111922d5..9ad20ed73c 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -12,10 +12,12 @@ * * A per-absolute-path FIFO chain of tail promises orders concurrent * in-process writes to the same path. Each publish refreshes the observation to - * the token it wrote, so same-task writes apply last-write-wins; a token changed - * by another writer, or a read that is no longer the current version, still - * fails stale. Observations come from the task's S2 ObservationRegistry and - * authorize the write as well as the version check. + * the token it wrote, so same-task writes apply last-write-wins. A write that + * goes through replaceIfVersion fails stale when the on-disk token differs from + * the token recorded at read time; an observed "create" whose target has + * disappeared instead uses createIfAbsent and can recreate it. Observations come + * from the task's S2 ObservationRegistry and authorize the write as well as the + * version check. */ import * as fs from "fs/promises" From 2286f33325dbf8096e80d592bb73d1a3951cf357 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 17:20:11 +0800 Subject: [PATCH 037/100] docs(s4b): name the token a later guarded write compares against The header implied the read-time token is always the one checked. A publish refreshes the observation to the token it wrote when that token can be computed, so a later guarded write compares against the refreshed token; only when the refresh fails does the read-time token still apply. --- src/core/tools/guardedWrite.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index 9ad20ed73c..ac80709c77 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -12,12 +12,13 @@ * * A per-absolute-path FIFO chain of tail promises orders concurrent * in-process writes to the same path. Each publish refreshes the observation to - * the token it wrote, so same-task writes apply last-write-wins. A write that - * goes through replaceIfVersion fails stale when the on-disk token differs from - * the token recorded at read time; an observed "create" whose target has - * disappeared instead uses createIfAbsent and can recreate it. Observations come - * from the task's S2 ObservationRegistry and authorize the write as well as the - * version check. + * the token it wrote when the new token can be computed, so same-task writes + * apply last-write-wins; when that refresh fails the observation keeps the token + * recorded at read time. A write that goes through replaceIfVersion fails stale + * when the on-disk token differs from the token the observation currently holds; + * an observed "create" whose target has disappeared instead uses createIfAbsent + * and can recreate it. Observations come from the task's S2 ObservationRegistry + * and authorize the write as well as the version check. */ import * as fs from "fs/promises" From 7f24fe72ba0f836c66127032667f1655bf84493a Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 17:52:04 +0800 Subject: [PATCH 038/100] test(s4b): make the diff-view save and fsync-ordering tests assert what they claim - the apply_patch diff-view save test now also asserts the tool result and that no error was surfaced, matching the other apply_patch save tests. - the fsync ordering test asserts the rename arguments and compares the invocation order of fsync, close and rename, which is what its title promises. --- .../tools/__tests__/applyDiffTool.guardedWrite.spec.ts | 3 +++ .../file-safety/__tests__/safeWriteText.spec.ts | 10 +++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index 3733d70821..62ff44f75a 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -188,5 +188,8 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { }) expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "edit") + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockHandleError).not.toHaveBeenCalled() }) }) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index a5ce3ef1d6..871ded5e5b 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -266,7 +266,15 @@ describe("safeWriteText", () => { // the temp file was fully closed before the commit rename expect(vi.mocked(fsSync.closeSync).mock.calls[0][0]).toBe(1) - expect(fs.rename).toHaveBeenCalled() + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + // The title promises the order, so compare the invocations rather than + // only count them: a rename before closeSync, or a close before fsync, + // would not be a durable commit. + const fsyncOrder = vi.mocked(fsSync.fsyncSync).mock.invocationCallOrder[0] + const closeOrder = vi.mocked(fsSync.closeSync).mock.invocationCallOrder[0] + const renameOrder = vi.mocked(fs.rename).mock.invocationCallOrder[0] + expect(fsyncOrder).toBeLessThan(closeOrder) + expect(closeOrder).toBeLessThan(renameOrder) }) }) From 9297464c1c79e920860aa66dc8c5e7c9bc3636d9 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 18:42:11 +0800 Subject: [PATCH 039/100] fix(s4b): do not save the document after the guarded publish - saveDirectly's memory-only diagnostics branch could return an already-open dirty document and save it, republishing stale bytes through VS Code's unguarded save path over what guardedWrite committed. The branch now only opens the document. - the guard header now names the token a later write compares against as the current observation token, not the read-time token, matching the post-publish refresh. - regression test: a dirty document in that branch is opened but never saved. --- src/core/tools/guardedWrite.ts | 10 +++++++--- src/integrations/editor/DiffViewProvider.ts | 14 ++++++-------- .../editor/__tests__/DiffViewProvider.spec.ts | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index ac80709c77..ce46b6f910 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -7,14 +7,18 @@ * - an unobserved target may only be created when it is absent * (createIfAbsent); * - an observed target is published only when the on-disk version token still - * matches the token recorded at read time (replaceIfVersion); + * matches the current observation token (replaceIfVersion); * - an edit-style write requires a prior observation (unobservedEditGuard). * * A per-absolute-path FIFO chain of tail promises orders concurrent * in-process writes to the same path. Each publish refreshes the observation to * the token it wrote when the new token can be computed, so same-task writes - * apply last-write-wins; when that refresh fails the observation keeps the token - * recorded at read time. A write that goes through replaceIfVersion fails stale + * apply last-write-wins; when that refresh fails the observation keeps the + * previous observation token. A write that goes through replaceIfVersion fails + * stale when the on-disk token differs from the token the observation currently + * holds; an observed "create" whose target has disappeared instead uses + * createIfAbsent and can recreate it. Observations come from the task's S2 + * ObservationRegistry and authorize the write as well as the version check. * when the on-disk token differs from the token the observation currently holds; * an observed "create" whose target has disappeared instead uses createIfAbsent * and can recreate it. Observations come from the task's S2 ObservationRegistry diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 9e0e07ff53..2b72a723af 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1305,7 +1305,7 @@ export class DiffViewProvider { * @param writeKind - Guarded-write kind that selects the S4a guard for this publish. * Defaults to "create" because this method always publishes a complete file * content: an unobserved target may only be created when absent, and an - * observed target must still carry the version token recorded at read time. + * observed target must still carry the current observation token. * @returns Result of the save operation including any new problems detected */ async saveDirectly( @@ -1348,13 +1348,11 @@ export class DiffViewProvider { preserveFocus: true, }) } else { - // Just open the document in memory to trigger diagnostics without showing it - const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(absolutePath)) - - // Save the document to ensure VSCode recognizes it as saved and triggers diagnostics - if (doc.isDirty) { - await doc.save() - } + // Just open the document in memory to trigger diagnostics without showing it. + // Do not save here: the guarded publish already committed the accepted content, + // and saving a dirty buffer would republish its stale bytes through VS Code's + // unguarded save path, over what the guard wrote. + await vscode.workspace.openTextDocument(vscode.Uri.file(absolutePath)) // Force a small delay to ensure diagnostics are triggered await new Promise((resolve) => setTimeout(resolve, 100)) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 104f854030..569633784e 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -885,6 +885,24 @@ describe("DiffViewProvider", () => { expect(vscode.window.showTextDocument).not.toHaveBeenCalled() }) + it("does not save a dirty buffer in the memory-only diagnostics path", async () => { + // The guarded publish already committed the accepted content. Saving a dirty + // buffer here would republish its stale bytes through VS Code's unguarded save + // path, over what the guard wrote. + const dirtyDoc = { + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + } as unknown as vscode.TextDocument + vi.mocked(vscode.workspace.openTextDocument).mockResolvedValue(dirtyDoc) + + await diffViewProvider.saveDirectly("test.ts", "new content", false, true, 0) + + expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith( + expect.objectContaining({ fsPath: `${mockCwd}/test.ts` }), + ) + expect(dirtyDoc.save).not.toHaveBeenCalled() + }) + it("should skip diagnostics when diagnosticsEnabled is false", async () => { const mockDelay = vi.mocked(delay) mockDelay.mockClear() From 1b54313ab1adf6fccd5d0851c1a8c6868a39257e Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 19:14:24 +0800 Subject: [PATCH 040/100] fix(s4b): the diff-view preview is not a model read - open() recorded the previewed version of an existing file as a complete read, so a later full-file replacement could pass the completeness gate for content the model never saw. The preview is the tool's own read, so it is recorded as a partial observation and the test asserts that. - the guarded-write header carried a duplicated fragment after the last-write-wins rewrite and the spec header still claimed exactly-one winner under concurrency; both now describe last-write-wins through the post-publish observation refresh. --- src/core/tools/__tests__/guardedWrite.spec.ts | 5 +++-- src/core/tools/guardedWrite.ts | 4 ---- src/integrations/editor/DiffViewProvider.ts | 7 ++++--- src/integrations/editor/__tests__/DiffViewProvider.spec.ts | 6 ++++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index 6ffc82eb56..8c763ab5ef 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -4,8 +4,9 @@ * * Covers guard selection through the S2 observation registry, version-token * CAS, remediation messages, and the per-absolute-path FIFO chain: FIFO - * ordering, exactly-one winner under concurrency, no wedge after a rejected - * link, and independence across paths. + * ordering, last-write-wins for same-task writes through the post-publish + * observation refresh, no wedge after a rejected link, and independence across + * paths. */ import * as fs from "fs/promises" diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index ce46b6f910..982ede585a 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -19,10 +19,6 @@ * holds; an observed "create" whose target has disappeared instead uses * createIfAbsent and can recreate it. Observations come from the task's S2 * ObservationRegistry and authorize the write as well as the version check. - * when the on-disk token differs from the token the observation currently holds; - * an observed "create" whose target has disappeared instead uses createIfAbsent - * and can recreate it. Observations come from the task's S2 ObservationRegistry - * and authorize the write as well as the version check. */ import * as fs from "fs/promises" diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 2b72a723af..ef5624d71f 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -146,12 +146,13 @@ export class DiffViewProvider { // the save to changes that happened between the model's read and this // preview (e.g. an external editor), letting a v1-based overwrite // clobber the v2 change. An unread target has no observation, so the - // preview token is recorded (stat-matched) and the save checks against - // the on-disk version the preview was built on. + // preview token is recorded (stat-matched) but never as a complete read: + // this is the tool's own preview, not a read the model made, so it must + // not authorize a later full-file replacement. if (displayTask && preStats && postStats && !displayTask.observationRegistry.has(absolutePath)) { const displayToken = versionTokenOfStat(preStats) if (displayToken === versionTokenOfStat(postStats)) { - displayTask.observationRegistry.observe(absolutePath, displayToken, true) + displayTask.observationRegistry.observe(absolutePath, displayToken, false) } } } else { diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 569633784e..a8b4e0224b 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1047,7 +1047,7 @@ describe("DiffViewProvider", () => { vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) }) - it("open() observes the previewed on-disk version of an existing file as a complete read", async () => { + it("open() observes the previewed version of an existing file as a partial read, not a model read", async () => { const mockEditor = mockTextEditor(`${mockCwd}/observed.ts`) vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { @@ -1065,7 +1065,9 @@ describe("DiffViewProvider", () => { const obs = mockTask.observationRegistry.get(`${mockCwd}/observed.ts`) expect(obs).toBeDefined() expect(obs!.version).toBe(versionTokenOfStat(previewStats)) - expect(obs!.complete).toBe(true) + // The preview is the tool's own read, not a read the model made, so it must + // not claim completeness for content the model never saw. + expect(obs!.complete).toBe(false) expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(1, `${mockCwd}/observed.ts`, { bigint: true }) expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(2, `${mockCwd}/observed.ts`, { bigint: true }) }) From 04719afaa628ad70d656fbb32e1c08ab40ce6c6f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 19:54:20 +0800 Subject: [PATCH 041/100] fix(s4b): carry the source's completeness to a move destination apply_patch publishes a move destination through the create guard, which records it as complete. The destination content is the source file plus one targeted hunk, so it can only be as complete as the view the model had of the source: a partial, or missing, source read must not hand the destination complete authority, or a later full-file write would replace content the model never read. Also check the commit rename arguments in the two DACL fallback tests, not only that fs.rename was called. --- src/core/tools/ApplyPatchTool.ts | 11 +++ .../__tests__/applyPatchTool.execute.spec.ts | 93 ++++++++++++++++++- .../__tests__/safeWriteText.spec.ts | 6 +- 3 files changed, 107 insertions(+), 3 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index b2f520b2e0..bc2d68c3e6 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -451,6 +451,17 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { writeDelayMs, "create", ) + // The destination content is the source file plus one targeted hunk, so + // it can only be as complete as the view the model had of the source. The + // create publish records the destination as complete, which would hand the + // model authority over lines it never read; carry the source's completeness + // (or none, when the source was never observed) to the destination. + const sourceObs = task.observationRegistry.get(absolutePath) + const destObs = task.observationRegistry.get(moveAbsolutePath) + const sourceComplete = sourceObs !== undefined && sourceObs.complete === true + if (destObs !== undefined && !sourceComplete) { + task.observationRegistry.observe(moveAbsolutePath, destObs.version, false) + } } else { // Write to new path and delete old file const parentDir = path.dirname(moveAbsolutePath) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index a146d7f49a..1a6e58c532 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -407,7 +407,98 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { 1000, "create", ) - expect(mockTask.didEditFile).toBe(true) + }) + + it("move: carries the source's partial completeness to the destination", async () => { + // The destination is the source file plus one hunk. The create publish records + // it as complete, but the model only saw a slice of the source, so the + // destination must not gain completeness the model never earned. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", false) + // The real saveDirectly publishes through guardedWrite, which refreshes the + // destination observation as complete for a create; the double mirrors that. + mockSaveDirectly.mockImplementationOnce(async () => { + reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + return { newProblemsMessage: "", userEdits: undefined, finalContent: "new content" } + }) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(destKey)?.complete).toBe(false) + await expect(guardedWrite(mockTask as Task, "src/new.ts", "full replacement", "create")).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("move: a complete source read keeps the destination complete", async () => { + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + mockSaveDirectly.mockImplementationOnce(async () => { + reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + return { newProblemsMessage: "", userEdits: undefined, finalContent: "new content" } + }) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(destKey)?.complete).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("move: the destination cannot claim completeness when the source was never observed", async () => { + // The hunk read records no observation when its stat fails, so the model has + // no authority over the source content the move carried over. + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + mockedFsPromises.default.stat.mockImplementationOnce(() => Promise.reject(new Error("stat failed"))) + mockSaveDirectly.mockImplementationOnce(async () => { + reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + return { newProblemsMessage: "", userEdits: undefined, finalContent: "new content" } + }) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(path.resolve("/workspace/project", "src/old.ts"))).toBeUndefined() + expect(reg.get(destKey)?.complete).toBe(false) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("move: leaves an unobserved destination alone when the source read was partial", async () => { + // The publish records an observation only when it can compute the new on-disk + // token. With nothing recorded for the destination there is nothing to + // downgrade, and the carry must not dereference a missing observation. + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe( + path.resolve("/workspace/project", "src/old.ts"), + "7:4242:1234:1700000000123456789:1700000000789999999", + false, + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(destKey)).toBeUndefined() expect(mockHandleError).not.toHaveBeenCalled() }) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 871ded5e5b..802fa9ecb7 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -452,7 +452,8 @@ describe("safeWriteText", () => { await safeWriteText(targetPath, "data", { platform: "win32" }) // write committed; only the save was attempted (no restore from a failed dump) - expect(fs.rename).toHaveBeenCalled() + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + expect(fs.rename).toHaveBeenCalledTimes(1) expect(execFile).toHaveBeenCalledTimes(1) const saveArgs = vi.mocked(execFile).mock.calls[0]?.[1] expect(saveArgs?.[1]).toBe("/save") @@ -506,7 +507,8 @@ describe("safeWriteText", () => { await safeWriteText(targetPath, "data", { platform: "win32" }) // write succeeded despite restore failure (best-effort) - expect(fs.rename).toHaveBeenCalled() + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + expect(fs.rename).toHaveBeenCalledTimes(1) // dump file was still unlinked in finally expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) From e765a4dd96b82fff677ff9e7e1707fc7d0b0e3c9 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 20:59:43 +0800 Subject: [PATCH 042/100] fix(s4b): finish the rejected-create cleanup, guard the revert, and keep the target mode - DiffViewProvider: the rejected-save cleanup unlinked the placeholder but left the directories open() created for the new file. Remove them innermost first, best-effort, only after the unlink succeeded. - DiffViewProvider: the success-path revert could discard keystrokes typed while the guarded publish awaited I/O. Revert only while the buffer still matches the bytes the guard published; a buffer that moved on stays dirty. - safeWriteText: openSync's creation mode is narrowed by the process umask, so an existing 0o664 target was published as 0o644 through the rename. Apply the target's exact mode on the fd, as the caller-staged branch already does. --- src/integrations/editor/DiffViewProvider.ts | 16 ++- .../editor/__tests__/DiffViewProvider.spec.ts | 101 ++++++++++++++++++ .../__tests__/safeWriteText.spec.ts | 33 ++++++ src/services/file-safety/safeWriteText.ts | 9 ++ 4 files changed, 158 insertions(+), 1 deletion(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index ef5624d71f..85ccda6191 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -479,6 +479,16 @@ export class DiffViewProvider { // The file is gone, so its tab must go too: closing only the diff // views would leave a clean plain-text tab for a deleted file. await this.closeFileTab(absolutePath) + // The directories open() created for the new file must go with it, + // innermost first. rmdir refuses a directory another writer populated + // in the meantime, so the cleanup stops at the first failure. + for (let i = this.createdDirs.length - 1; i >= 0; i--) { + try { + await fs.rmdir(this.createdDirs[i]) + } catch { + break + } + } } } } @@ -504,7 +514,11 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() - if (updatedDocument.isDirty) { + // Revert only while the buffer is still exactly what the guard published. + // Keystrokes typed during the publish would be discarded by a revert, so a + // buffer that moved on stays dirty: the close helpers skip dirty tabs and + // the user's text survives in the editor. + if (updatedDocument.isDirty && updatedDocument.getText() === editedContent) { try { await vscode.window.showTextDocument(updatedDocument, { preserveFocus: true, preview: false }) await vscode.commands.executeCommand("workbench.action.files.revert") diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index a8b4e0224b..4f9cbdb962 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -29,6 +29,7 @@ vi.mock("fs/promises", () => ({ mkdir: vi.fn().mockResolvedValue(undefined), rename: vi.fn().mockResolvedValue(undefined), unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), })) // Mock safeWriteText (used by saveDirectly) @@ -1655,6 +1656,106 @@ describe("DiffViewProvider", () => { expect(closeFileTab).not.toHaveBeenCalled() }) + it("removes the directories open() created, innermost first, after the placeholder unlink", async () => { + const order: string[] = [] + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + diffViewProvider["createdDirs"] = [`${mockCwd}/new`, `${mockCwd}/new/dir`] + diffViewProvider["closeFileTab"] = vi.fn().mockImplementation(() => { + order.push("closeTab") + return Promise.resolve() + }) + vi.mocked(fs.rmdir).mockImplementation(async (p) => { + order.push("rmdir:" + p) + }) + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + // The empty directories open() made for the rejected new file go with the + // placeholder, innermost first, and only after the unlink succeeded. + expect(order).toEqual(["closeTab", "rmdir:" + `${mockCwd}/new/dir`, "rmdir:" + `${mockCwd}/new`]) + }) + + it("stops removing created directories when one cannot be removed", async () => { + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + diffViewProvider["createdDirs"] = [`${mockCwd}/a`, `${mockCwd}/a/b`, `${mockCwd}/a/b/c`] + vi.mocked(fs.rmdir).mockRejectedValueOnce(new Error("ENOTEMPTY: directory not empty")) + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + // A directory another writer populated in the meantime must not be removed, + // and the best-effort cleanup must still finish so the guard verdict stays + // the outcome. + expect(fs.rmdir).toHaveBeenCalledTimes(1) + expect(fs.rmdir).toHaveBeenCalledWith(`${mockCwd}/a/b/c`) + expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() + }) + + it("does not revert a buffer the user changed during the publish", async () => { + // The publish captured the buffer text before awaiting the write; a keystroke + // typed during that wait is newer than the published bytes, so the buffer must + // stay dirty instead of being reverted to disk. + const getText = vi.fn() + getText.mockReturnValueOnce("new content").mockReturnValueOnce("new content typed during the publish") + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText, + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + + await diffViewProvider.saveChanges(false) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) + expect(vi.mocked(vscode.commands.executeCommand)).not.toHaveBeenCalledWith("workbench.action.files.revert") + expect(vi.mocked(vscode.window.showTextDocument)).not.toHaveBeenCalled() + }) + it("does not reload a clean buffer when the guard rejects - only dirty buffers are discarded", async () => { // The buffer was never touched (isDirty is falsy): there is nothing // to discard, so the failure cleanup must not activate the document diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 802fa9ecb7..5e30f43915 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -618,6 +618,39 @@ describe("safeWriteText", () => { expect(openIdx).toBeLessThan(fchmodIdx) expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) }) + + it("applies the existing target's exact mode to the self-staged temp (umask must not narrow it)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o664)) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // openSync's creation mode is narrowed by the process umask (0o664 -> 0o644 with + // the common 0o022), and the rename publishes the temp's mode onto the target, + // so the existing target's mode must be applied on the fd before the commit. + expect(fsSync.fchmodSync).toHaveBeenCalledWith(1, 0o664) + const openIdx = vi.mocked(fsSync.openSync).mock.invocationCallOrder[0] + const fchmodIdx = vi.mocked(fsSync.fchmodSync).mock.invocationCallOrder[0] + expect(openIdx).toBeLessThan(fchmodIdx) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("does not fchmod the self-staged temp for a fresh target", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw enoent + }) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // Nothing exists to preserve: the default creation mode is the intended one. + expect(fsSync.fchmodSync).not.toHaveBeenCalled() + }) }) // ── Test 7: symlink handling (Finding 4 regression test) ───────────────── diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 37a45d8cd7..5096b797aa 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -189,13 +189,22 @@ export async function safeWriteText( // unchanged. const buffer = Buffer.from(content) let targetMode = 0o644 // default for a fresh target + let targetExists = false try { targetMode = fsSync.statSync(targetPath).mode & 0o777 + targetExists = true } catch { // target does not exist yet - keep the default } + // openSync's creation mode is narrowed by the process umask, so an + // existing 0o664 target would be published as 0o644 through the + // rename. Apply the existing target's exact mode on the fd, as the + // caller-staged branch does; a fresh target keeps the default mode. const fd = fsSync.openSync(tempPath, "w", targetMode) try { + if (targetExists) { + fsSync.fchmodSync(fd, targetMode) + } // Loop until every byte is written: writeSync can report a short // (partial) write, and publishing a truncated staging file would // commit corrupt content. From 1bca6fcd3a75b150c664863027141ca21d55e2e5 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 3 Oct 2026 21:20:55 +0800 Subject: [PATCH 043/100] fix(s4b): scope the workbench revert to the intended document workbench.action.files.revert takes no resource argument: when the Open Editors view has focus with a selection it force-reverts every selected editor, otherwise the active editor. Opening the target with preserveFocus left the revert free to hit unrelated dirty files in both the rejection cleanup and the success path. Activate the document before the revert and give the user their previous focus back afterwards. --- src/integrations/editor/DiffViewProvider.ts | 36 ++++++-- .../editor/__tests__/DiffViewProvider.spec.ts | 85 ++++++++++++++++++- 2 files changed, 110 insertions(+), 11 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 85ccda6191..8d25e75163 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -397,6 +397,31 @@ export class DiffViewProvider { } } + /** + * Revert one document through the workbench command. The command takes no + * resource argument: when the Open Editors view has focus with a selection it + * force-reverts every selected editor, otherwise the active editor. Activate + * the target first so only this document is reverted, then give the user + * their focus back. + */ + private async revertDocument(document: vscode.TextDocument): Promise { + const previous = vscode.window.activeTextEditor + try { + await vscode.window.showTextDocument(document, { preserveFocus: false, preview: false }) + await vscode.commands.executeCommand("workbench.action.files.revert") + } catch { + // best-effort: a document that cannot be reverted stays dirty + } + if (previous && previous.document !== document) { + // Give the user their focus back. + try { + await vscode.window.showTextDocument(previous.document, { preserveFocus: false, preview: false }) + } catch { + // best-effort: the focus cannot always be restored + } + } + } + async saveChanges( diagnosticsEnabled: boolean = true, writeDelayMs: number = DEFAULT_WRITE_DELAY_MS, @@ -462,8 +487,7 @@ export class DiffViewProvider { this.cancelDeferredScroll() if (updatedDocument.isDirty) { - await vscode.window.showTextDocument(updatedDocument, { preserveFocus: true, preview: false }) - await vscode.commands.executeCommand("workbench.action.files.revert") + await this.revertDocument(updatedDocument) } if (this.editType === "create" && this.placeholderVersion) { const placeholderStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) @@ -519,13 +543,7 @@ export class DiffViewProvider { // buffer that moved on stays dirty: the close helpers skip dirty tabs and // the user's text survives in the editor. if (updatedDocument.isDirty && updatedDocument.getText() === editedContent) { - try { - await vscode.window.showTextDocument(updatedDocument, { preserveFocus: true, preview: false }) - await vscode.commands.executeCommand("workbench.action.files.revert") - } catch { - // best-effort: a dirty tab that cannot be cleared stays open - // rather than risking a save-prompt loop - } + await this.revertDocument(updatedDocument) } await this.closeAllDiffViews() diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 4f9cbdb962..a16320a9f5 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -89,6 +89,7 @@ vi.mock("vscode", () => ({ }, window: { createTextEditorDecorationType: vi.fn(), + activeTextEditor: undefined as unknown, showTextDocument: vi.fn(), onDidChangeVisibleTextEditors: vi.fn(() => ({ dispose: vi.fn() })), onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), @@ -1492,8 +1493,10 @@ describe("DiffViewProvider", () => { expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) // the revert activates the exact document with the exact options: // preserveFocus keeps the user's focus, preview: false pins the tab + // the target is activated so the revert command is scoped to this document + // (no active editor to restore in this case) expect(vi.mocked(vscode.window.showTextDocument)).toHaveBeenCalledWith(dirtyEditor.document, { - preserveFocus: true, + preserveFocus: false, preview: false, }) expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") @@ -1532,7 +1535,7 @@ describe("DiffViewProvider", () => { // buffer (never re-saved from originalContent), and the placeholder // is unlinked while it is still exactly the file open() wrote expect(vi.mocked(vscode.window.showTextDocument)).toHaveBeenCalledWith(dirtyEditor.document, { - preserveFocus: true, + preserveFocus: false, preview: false, }) expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") @@ -1756,6 +1759,84 @@ describe("DiffViewProvider", () => { expect(vi.mocked(vscode.window.showTextDocument)).not.toHaveBeenCalled() }) + it("activates the document before the revert so the command is scoped to it, then restores the focus", async () => { + // workbench.action.files.revert takes no resource argument: with the Open + // Editors view focused it force-reverts every selected editor, otherwise the + // active editor. Activating the target keeps the revert scoped to this + // document, and the user's previous focus is given back afterwards. + const previousEditor = mockTextEditor(`${mockCwd}/other.ts`, "other") + vi.mocked(vscode.window).activeTextEditor = previousEditor + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + + await diffViewProvider.saveChanges(false) + + const calls = vi.mocked(vscode.window.showTextDocument).mock.calls + expect(calls[0]).toEqual([dirtyEditor.document, { preserveFocus: false, preview: false }]) + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + expect(calls[1]).toEqual([previousEditor.document, { preserveFocus: false, preview: false }]) + }) + + it("does not restore focus when the user had no active editor", async () => { + vi.mocked(vscode.window).activeTextEditor = undefined + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + + await diffViewProvider.saveChanges(false) + + // Only the activation happened; there was no focus to give back. + expect(vi.mocked(vscode.window.showTextDocument)).toHaveBeenCalledTimes(1) + }) + + it("does not restore focus when the active editor is already the target document", async () => { + // The user was already looking at this document, so there is nothing to give + // back: re-showing it would be a redundant activation. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + vi.mocked(vscode.window).activeTextEditor = editor + diffViewProvider["activeDiffEditor"] = editor + + await diffViewProvider.saveChanges(false) + + expect(vi.mocked(vscode.window.showTextDocument)).toHaveBeenCalledTimes(1) + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + }) + it("does not reload a clean buffer when the guard rejects - only dirty buffers are discarded", async () => { // The buffer was never touched (isDirty is falsy): there is nothing // to discard, so the failure cleanup must not activate the document From e0ce670e41cd7cb3c5becee1645c1ac303a76dc5 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 4 Oct 2026 06:14:19 +0800 Subject: [PATCH 044/100] fix(s4b): only remove the placeholder when the discard completed revertDocument() swallowed the command failure, so the failure cleanup could unlink the create placeholder while the document was still dirty. A later save through VS Code's ordinary file service can recreate the file from the rejected buffer, outside guardedWrite. The helper now reports whether the document is clean after the revert, and the cleanup unlinks only when the discard completed. The spec double now models the revert clearing the dirty flag, and a failed revert keeps the placeholder and the tab open. The four stat-failure read tests now assert the pushed payload instead of only that something was pushed. --- src/core/tools/__tests__/readFileTool.spec.ts | 24 +++++- src/integrations/editor/DiffViewProvider.ts | 11 ++- .../editor/__tests__/DiffViewProvider.spec.ts | 81 ++++++++++++++++--- 3 files changed, 97 insertions(+), 19 deletions(-) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 2bd4a48da7..38d6c56c4f 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -1662,7 +1662,11 @@ describe("ReadFileTool", () => { expect(reg.size).toBe(0) // The read still succeeds — a stat failure never fails the read. expect(mockTask.didToolFailInCurrentTurn).toBe(false) - expect(callbacks.pushToolResult).toHaveBeenCalled() + // Assert the pushed payload, not just that something was pushed. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("File: stat-fail.ts") + expect(pushed).toContain("test content") + expect(pushed).not.toContain("Error:") }) it("leaves the target unobserved without failing the read when the post-read stat fails", async () => { @@ -1695,7 +1699,11 @@ describe("ReadFileTool", () => { expect(observeSpy).not.toHaveBeenCalled() expect(reg.size).toBe(0) expect(mockTask.didToolFailInCurrentTurn).toBe(false) - expect(callbacks.pushToolResult).toHaveBeenCalled() + // Assert the pushed payload, not just that something was pushed. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("File: post-stat-fail.ts") + expect(pushed).toContain("test content") + expect(pushed).not.toContain("Error:") }) it("legacy format: does not observe when the file mutates between the pre-read and post-read stats", async () => { @@ -1761,7 +1769,11 @@ describe("ReadFileTool", () => { expect(observeSpy).not.toHaveBeenCalled() expect(reg.size).toBe(0) expect(mockTask.didToolFailInCurrentTurn).toBe(false) - expect(callbacks.pushToolResult).toHaveBeenCalled() + // Assert the pushed payload, not just that something was pushed. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("File: legacy-stat-fail.ts") + expect(pushed).toContain("test content") + expect(pushed).not.toContain("Error:") }) it("legacy format: leaves the target unobserved when the post-read stat fails", async () => { const mockTask = createMockTask({ @@ -1798,7 +1810,11 @@ describe("ReadFileTool", () => { expect(observeSpy).not.toHaveBeenCalled() expect(reg.size).toBe(0) expect(mockTask.didToolFailInCurrentTurn).toBe(false) - expect(callbacks.pushToolResult).toHaveBeenCalled() + // Assert the pushed payload, not just that something was pushed. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("File: legacy-post-stat-fail.ts") + expect(pushed).toContain("test content") + expect(pushed).not.toContain("Error:") }) it("two separate Task-owned registries are independent", async () => { const regA = new ObservationRegistry() diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 8d25e75163..91435bc5d2 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -404,7 +404,7 @@ export class DiffViewProvider { * the target first so only this document is reverted, then give the user * their focus back. */ - private async revertDocument(document: vscode.TextDocument): Promise { + private async revertDocument(document: vscode.TextDocument): Promise { const previous = vscode.window.activeTextEditor try { await vscode.window.showTextDocument(document, { preserveFocus: false, preview: false }) @@ -420,6 +420,10 @@ export class DiffViewProvider { // best-effort: the focus cannot always be restored } } + // The caller must know whether the discard actually completed: a document + // that stays dirty can still be saved by VS Code's ordinary file service, + // which would recreate a placeholder the cleanup removed. + return !document.isDirty } async saveChanges( @@ -486,10 +490,11 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() + let discardSucceeded = !updatedDocument.isDirty if (updatedDocument.isDirty) { - await this.revertDocument(updatedDocument) + discardSucceeded = await this.revertDocument(updatedDocument) } - if (this.editType === "create" && this.placeholderVersion) { + if (discardSucceeded && this.editType === "create" && this.placeholderVersion) { const placeholderStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) if (placeholderStats && versionTokenOfStat(placeholderStats) === this.placeholderVersion) { let unlinked = false diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index a16320a9f5..ba7be55adb 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1037,6 +1037,17 @@ describe("DiffViewProvider", () => { // Structural TextDocument double for the onDidOpenTextDocument callback. const mockTextDocument = (fsPath: string): vscode.TextDocument => ({ uri: { fsPath, scheme: "file" } }) as unknown as vscode.TextDocument + // The workbench revert clears the model's dirty flag; the double must model + // that so the cleanup can tell a completed discard from a failed one. + const revertClearsDirty = (document: { isDirty: boolean }, onRevert?: () => void): void => { + vi.mocked(vscode.commands.executeCommand).mockImplementation((command: string) => { + if (command === "workbench.action.files.revert") { + document.isDirty = false + onRevert?.() + } + return Promise.resolve(undefined) + }) + } beforeEach(() => { // Private members are set via bracket notation (spec convention). @@ -1487,6 +1498,7 @@ describe("DiffViewProvider", () => { revealRange: vi.fn(), } as unknown as vscode.TextEditor diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) const result = await diffViewProvider.saveChanges(false) @@ -1519,6 +1531,7 @@ describe("DiffViewProvider", () => { revealRange: vi.fn(), } as unknown as vscode.TextEditor diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) diffViewProvider.editType = "create" // open() wrote and observed the empty placeholder; the on-disk token // then moved past it, so the guard rejects the publish. @@ -1569,12 +1582,7 @@ describe("DiffViewProvider", () => { diffViewProvider["disposeActiveEditorListener"] = vi.fn(() => { order.push("dispose") }) - vi.mocked(vscode.commands.executeCommand).mockImplementation((command: string) => { - if (command === "workbench.action.files.revert") { - order.push("revert") - } - return Promise.resolve(undefined) - }) + revertClearsDirty(dirtyEditor.document, () => order.push("revert")) await diffViewProvider.saveChanges(false) @@ -1597,6 +1605,7 @@ describe("DiffViewProvider", () => { revealRange: vi.fn(), } as unknown as vscode.TextEditor diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) diffViewProvider.editType = "create" const placeholderToken = versionTokenOfStat(previewStats) mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) @@ -1613,12 +1622,7 @@ describe("DiffViewProvider", () => { order.push("closeTab") return Promise.resolve() }) - vi.mocked(vscode.commands.executeCommand).mockImplementation((command: string) => { - if (command === "workbench.action.files.revert") { - order.push("revert") - } - return Promise.resolve(undefined) - }) + revertClearsDirty(dirtyEditor.document, () => order.push("revert")) await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") @@ -1643,6 +1647,7 @@ describe("DiffViewProvider", () => { revealRange: vi.fn(), } as unknown as vscode.TextEditor diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) diffViewProvider.editType = "create" const placeholderToken = versionTokenOfStat(previewStats) mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) @@ -1675,6 +1680,7 @@ describe("DiffViewProvider", () => { revealRange: vi.fn(), } as unknown as vscode.TextEditor diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) diffViewProvider.editType = "create" const placeholderToken = versionTokenOfStat(previewStats) mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) @@ -1712,6 +1718,7 @@ describe("DiffViewProvider", () => { revealRange: vi.fn(), } as unknown as vscode.TextEditor diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) diffViewProvider.editType = "create" const placeholderToken = versionTokenOfStat(previewStats) mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) @@ -1751,6 +1758,7 @@ describe("DiffViewProvider", () => { revealRange: vi.fn(), } as unknown as vscode.TextEditor diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) await diffViewProvider.saveChanges(false) @@ -1780,6 +1788,7 @@ describe("DiffViewProvider", () => { revealRange: vi.fn(), } as unknown as vscode.TextEditor diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) await diffViewProvider.saveChanges(false) @@ -1805,6 +1814,7 @@ describe("DiffViewProvider", () => { revealRange: vi.fn(), } as unknown as vscode.TextEditor diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) await diffViewProvider.saveChanges(false) @@ -1836,6 +1846,40 @@ describe("DiffViewProvider", () => { expect(vi.mocked(vscode.window.showTextDocument)).toHaveBeenCalledTimes(1) expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") }) + it("keeps the placeholder when the discard fails, so a later save cannot recreate the rejected content", async () => { + // The revert command can fail (a locked or orphaned model). While the buffer + // is still dirty, VS Code's ordinary file service can save it back to the path, + // so the placeholder open() wrote must survive and the tab must stay open. + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + vi.mocked(vscode.commands.executeCommand).mockRejectedValue(new Error("revert failed")) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + const closeFileTab = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = closeFileTab + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + expect(fs.unlink).not.toHaveBeenCalled() + expect(closeFileTab).not.toHaveBeenCalled() + }) it("does not reload a clean buffer when the guard rejects - only dirty buffers are discarded", async () => { // The buffer was never touched (isDirty is falsy): there is nothing @@ -1844,12 +1888,22 @@ describe("DiffViewProvider", () => { const cleanEditor = mockTextEditor(`${mockCwd}/test.ts`, "new content") diffViewProvider["activeDiffEditor"] = cleanEditor vi.mocked(computeVersionToken).mockResolvedValue("v2") + // The placeholder on disk is still exactly what open() wrote, so the cleanup + // can still remove it. + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + diffViewProvider["placeholderVersion"] = placeholderToken await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") expect(safeWriteText).not.toHaveBeenCalled() expect(vi.mocked(vscode.window.showTextDocument)).not.toHaveBeenCalled() expect(vi.mocked(vscode.commands.executeCommand)).not.toHaveBeenCalled() + // A clean buffer has nothing that can be saved back, so the placeholder + // cleanup still runs even though no revert was needed. + expect(fs.unlink).toHaveBeenCalledWith(`${mockCwd}/test.ts`) expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() }) @@ -1872,6 +1926,7 @@ describe("DiffViewProvider", () => { revealRange: vi.fn(), } as unknown as vscode.TextEditor diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) diffViewProvider.editType = "modify" const placeholderToken = versionTokenOfStat(previewStats) mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) @@ -1906,6 +1961,7 @@ describe("DiffViewProvider", () => { revealRange: vi.fn(), } as unknown as vscode.TextEditor diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) diffViewProvider.editType = "create" const placeholderToken = versionTokenOfStat(previewStats) mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) @@ -1940,6 +1996,7 @@ describe("DiffViewProvider", () => { revealRange: vi.fn(), } as unknown as vscode.TextEditor diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) diffViewProvider.editType = "create" const placeholderToken = versionTokenOfStat(previewStats) mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) From d11316831231d10c6f63b9adb40ffec860b4bf9d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 4 Oct 2026 06:46:58 +0800 Subject: [PATCH 045/100] fix(s4b): probe the lock key the writer actually uses safeWriteJson locks the resolved publish target, so an alias and its referent share one lock file. TaskHistoryStore.reconcile still probed the caller-named path, so during a peer's rename window the probe found nothing and evicted a live task from the cache. The focus-restore test now models the revert clearing the dirty flag and asserts the exact showTextDocument call instead of only the call count. --- src/core/task-persistence/TaskHistoryStore.ts | 4 ++- .../TaskHistoryStore.deleteSemantics.spec.ts | 27 +++++++++++++++++++ .../editor/__tests__/DiffViewProvider.spec.ts | 7 ++++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 146be163d9..46081b0e0a 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -383,7 +383,9 @@ export class TaskHistoryStore { // held for the entire write, so its presence means a // write is in progress — keep the task live. try { - const lockPath = (await this.getTaskFilePath(taskId)) + ".lock" + // Probe the same key the writer locks: safeWriteJson locks the resolved + // publish target, so an alias and its referent share one lock file. + const lockPath = (await resolvePublishTarget(await this.getTaskFilePath(taskId))) + ".lock" const lockStat = await fs.stat(lockPath) if (Date.now() - lockStat.mtimeMs < LOCK_STALE_MS) { liveIds.add(taskId) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index b042bd04bd..fff8908ba8 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -200,6 +200,33 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { }) }) + describe("reconcile()", () => { + it("keeps a cached task live when its file is absent but the lock is held at the resolved referent", async () => { + const store = createStore() + await store.initialize() + await store.upsert(makeHistoryItem({ id: "alias-live" })) + const aliasPath = historyFilePath(storagePath, "alias-live") + const referentPath = path.join(storagePath, "tasks", "alias-live", "referent-history.json") + + // Real symlinks are unavailable in this CI lane, so the alias is + // simulated through realpath, as in the safeWriteJson lock test. + const realpathSpy = vi.spyOn(fs, "realpath").mockResolvedValue(referentPath) + try { + // The rename window: the referent is momentarily missing, so the cached + // file cannot be stat'd while the peer holds the lock at the key it locks. + await actualFs.rm(aliasPath, { force: true }) + await actualFs.writeFile(referentPath + ".lock", "") + await store.reconcile() + } finally { + realpathSpy.mockRestore() + } + + // The probe must look at the same key the writer locks, otherwise a live + // task is evicted from the cache while its write is still in progress. + expect(storeInternals(store).cache.has("alias-live")).toBe(true) + }) + }) + describe("deleteMany()", () => { it("continues the batch after a failed unlink, evicts every entry, and writes through exactly once", async () => { const store = createStore() diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index ba7be55adb..dc0e354018 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1840,10 +1840,15 @@ describe("DiffViewProvider", () => { } as unknown as vscode.TextEditor vi.mocked(vscode.window).activeTextEditor = editor diffViewProvider["activeDiffEditor"] = editor + revertClearsDirty(editor.document) await diffViewProvider.saveChanges(false) - expect(vi.mocked(vscode.window.showTextDocument)).toHaveBeenCalledTimes(1) + // Only the activation happened: the focus was already on the target, so there + // was nothing to give back. + expect(vi.mocked(vscode.window.showTextDocument).mock.calls).toEqual([ + [editor.document, { preserveFocus: false, preview: false }], + ]) expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") }) it("keeps the placeholder when the discard fails, so a later save cannot recreate the rejected content", async () => { From 614233343db91b141aae2714a984e7ee7c95e2f5 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 4 Oct 2026 07:15:13 +0800 Subject: [PATCH 046/100] fix(s4b): never publish through a dangling symlink fs.realpath reports ENOENT for a dangling link as well as for an absent target, so resolvePublishTarget fell back to the link path and the commit rename replaced the symlink with a regular file, contradicting the documented promise that a broken symlink is never written through its link path. On ENOENT the path is now lstat'd: a symbolic link propagates the error, and only a genuinely absent target falls back. Two spec tests were tightened: the dead-task rejection asserts that nothing was published and the cleanup ran, and the no-active-editor focus test asserts the exact activation call instead of the call count. --- .../editor/__tests__/DiffViewProvider.spec.ts | 11 +++++++++- .../__tests__/safeWriteText.spec.ts | 20 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 13 +++++++++++- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index dc0e354018..be5be63d1f 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1273,6 +1273,12 @@ describe("DiffViewProvider", () => { await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( "Cannot guard the write: the owning task is no longer available", ) + + // Nothing may be published for a collected task, and the discard-only + // cleanup still runs before the error is rethrown. + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).not.toHaveBeenCalled() + expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalledTimes(1) }) it("open() keeps the model's existing observation instead of replacing it with the preview token", async () => { @@ -1819,7 +1825,10 @@ describe("DiffViewProvider", () => { await diffViewProvider.saveChanges(false) // Only the activation happened; there was no focus to give back. - expect(vi.mocked(vscode.window.showTextDocument)).toHaveBeenCalledTimes(1) + expect(vi.mocked(vscode.window.showTextDocument).mock.calls).toEqual([ + [dirtyEditor.document, { preserveFocus: false, preview: false }], + ]) + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") }) it("does not restore focus when the active editor is already the target document", async () => { diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 5e30f43915..1935c20590 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -14,6 +14,7 @@ vi.mock("fs/promises", () => ({ unlink: vi.fn(), rmdir: vi.fn(), realpath: vi.fn(), + lstat: vi.fn(), })) // Full mock for fs — all sync methods are vi.fn() stubs. Stats is a bare @@ -674,6 +675,9 @@ describe("safeWriteText", () => { it("when realpath reports ENOENT (target absent), uses the given path as-is", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + // lstat reports the path itself as absent, so this is a new target and + // the fallback is allowed. + vi.mocked(fs.lstat).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) vi.mocked(fsSync.openSync).mockReturnValue(1) await safeWriteText(targetPath, "data", { platform: "linux" }) @@ -682,6 +686,22 @@ describe("safeWriteText", () => { const resolvedFallback = _resolvedTarget(targetPath) expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), resolvedFallback) }) + + it("propagates a dangling symlink instead of writing through the link path", async () => { + // realpath resolves the referent, so a link whose target is missing reports + // ENOENT. Falling back to the link path would replace the symlink with a + // regular file, so the error must propagate and nothing may be committed. + const linkPath = "/tmp/test-dir/dangling-link.txt" + vi.mocked(fs.realpath).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + const linkStats = Object.create(fsSync.Stats.prototype) as fsSync.Stats + linkStats.isSymbolicLink = () => true + vi.mocked(fs.lstat).mockResolvedValue(linkStats) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await expect(safeWriteText(linkPath, "data", { platform: "linux" })).rejects.toThrow("ENOENT") + + expect(fs.rename).not.toHaveBeenCalled() + }) }) // ── Test 8: review fixes (permissions, partial writes, resolution, durability) ── diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 5096b797aa..39c179577d 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -131,12 +131,23 @@ async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRu * across filesystems fails with EXDEV. */ export async function resolvePublishTarget(absoluteFilePath: string): Promise { - return fs.realpath(absoluteFilePath).catch((error: unknown) => { + return fs.realpath(absoluteFilePath).catch(async (error: unknown) => { const code = typeof error === "object" && error !== null && "code" in error ? (error as { code?: string }).code : undefined if (code !== "ENOENT") throw error + // ENOENT also covers a dangling symlink: realpath resolves the referent, + // so it fails when the link exists but its target is missing. Writing + // through the link path would replace the symlink with a regular file, + // so only a genuinely absent target may fall back to the given path. + let linkStat: Awaited> | undefined + try { + linkStat = await fs.lstat(absoluteFilePath) + } catch { + // the path itself is absent: a target that has not been created yet + } + if (linkStat?.isSymbolicLink()) throw error return absoluteFilePath }) } From 002513e574b32e410339fffd5cf3c44d0eb576bf Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 4 Oct 2026 07:57:16 +0800 Subject: [PATCH 047/100] test(s4b): reset the focus the revert helper reads between tests A test that sets vscode.window.activeTextEditor leaked into the next one, so a later dirty-discard test could treat a stale editor as the previous focus and the cleanup could re-show it. --- src/integrations/editor/__tests__/DiffViewProvider.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index be5be63d1f..7bb11f9eff 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1051,6 +1051,9 @@ describe("DiffViewProvider", () => { beforeEach(() => { // Private members are set via bracket notation (spec convention). + // Reset the focus the revert helper reads: a test that sets it must not leak + // into the next one, where cleanup could restore a stale editor. + vi.mocked(vscode.window).activeTextEditor = undefined diffViewProvider["relPath"] = "test.ts" diffViewProvider["newContent"] = "new content" diffViewProvider["activeDiffEditor"] = mockTextEditor(`${mockCwd}/test.ts`, "new content") From ef1bd10e1ed323f709db56bea00a13660de49454 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 4 Oct 2026 08:34:23 +0800 Subject: [PATCH 048/100] fix(s4b): keep the liveness probe working for a dangling link resolvePublishTarget now refuses a dangling symlink, which is exactly the rename window the reconcile liveness probe covers, so the probe threw and a live task was evicted from the cache. The probe derives the referent with one readlink when resolution fails; the writer side still refuses to publish through a link path. --- src/core/task-persistence/TaskHistoryStore.ts | 16 ++++++++- .../TaskHistoryStore.deleteSemantics.spec.ts | 33 ++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 46081b0e0a..7b2a741510 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -337,6 +337,20 @@ export class TaskHistoryStore { * - Tasks on disk but missing from cache: read and add * - Tasks in cache but missing from disk: remove */ + /** + * The lock key a writer would use for a task file. resolvePublishTarget refuses a + * dangling symlink because a writer must not publish through the link path, but the + * liveness probe runs exactly in that window, so it reads the link one level itself + * to find the lock the writer holds at the referent. + */ + private async lockKeyFor(taskFilePath: string): Promise { + try { + return await resolvePublishTarget(taskFilePath) + } catch { + return path.resolve(path.dirname(taskFilePath), await fs.readlink(taskFilePath)) + } + } + async reconcile(options: { forceRefresh?: boolean } = {}): Promise { // Run through the write lock to prevent interleaving with upsert/delete return this.withLock(async () => { @@ -385,7 +399,7 @@ export class TaskHistoryStore { try { // Probe the same key the writer locks: safeWriteJson locks the resolved // publish target, so an alias and its referent share one lock file. - const lockPath = (await resolvePublishTarget(await this.getTaskFilePath(taskId))) + ".lock" + const lockPath = (await this.lockKeyFor(await this.getTaskFilePath(taskId))) + ".lock" const lockStat = await fs.stat(lockPath) if (Date.now() - lockStat.mtimeMs < LOCK_STALE_MS) { liveIds.add(taskId) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index fff8908ba8..bce08ce0f3 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -223,7 +223,38 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { // The probe must look at the same key the writer locks, otherwise a live // task is evicted from the cache while its write is still in progress. - expect(storeInternals(store).cache.has("alias-live")).toBe(true) + }) + + it("keeps a cached task live when the alias is dangling during the rename window", async () => { + // resolvePublishTarget refuses a dangling link because a writer must not publish + // through the link path, but this probe runs exactly in that window, so it reads + // the link one level itself to find the lock the writer holds at the referent. + const store = createStore() + await store.initialize() + await store.upsert(makeHistoryItem({ id: "dangling-live" })) + const aliasPath = historyFilePath(storagePath, "dangling-live") + const referentPath = path.join(storagePath, "tasks", "dangling-live", "referent-history.json") + + const realpathSpy = vi + .spyOn(fs, "realpath") + .mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + const lstatSpy = vi + .spyOn(fs, "lstat") + .mockResolvedValue({ isSymbolicLink: () => true } as unknown as import("fs").Stats) + // A relative link target, so the probe must resolve it against the link's + // directory rather than the process working directory. + const readlinkSpy = vi.spyOn(fs, "readlink").mockResolvedValue("referent-history.json") + try { + await actualFs.rm(aliasPath, { force: true }) + await actualFs.writeFile(referentPath + ".lock", "") + await store.reconcile() + } finally { + realpathSpy.mockRestore() + lstatSpy.mockRestore() + readlinkSpy.mockRestore() + } + + expect(storeInternals(store).cache.has("dangling-live")).toBe(true) }) }) From 52f59925f8e63cd8a013a05ece4dcea586bb30eb Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 4 Oct 2026 08:56:12 +0800 Subject: [PATCH 049/100] refactor(s4b): stay inside the changed-line cap The PR crossed the 500 changed-executable-line cap (504). resolvePublishTarget now reads the link in one statement instead of a try/catch block, and four comment blocks were condensed. Behaviour is unchanged and the dangling-symlink test still covers the guard. --- src/core/task-persistence/TaskHistoryStore.ts | 13 +++++-------- src/core/tools/ApplyPatchTool.ts | 8 +++----- src/core/tools/guardedWrite.ts | 10 ++++------ src/services/file-safety/safeWriteText.ts | 13 +++---------- 4 files changed, 15 insertions(+), 29 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 7b2a741510..a90e12b984 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -281,11 +281,9 @@ export class TaskHistoryStore { // Remove per-task file (best-effort) try { const filePath = await this.getTaskFilePath(taskId) - // Lock the resolved publish target, not the path as spelled: - // proper-lockfile keys the advisory lock by the path it is given, so a - // symlink alias and its referent would take two locks for one file and a - // deletion could run concurrently with a locked merge through the other - // alias. The unlink still removes the path the caller named. + // Lock the resolved publish target, not the path as spelled: proper-lockfile + // keys the lock by the path it is given, so an alias and its referent would + // take two locks for one file. The unlink still removes the named path. await withFileLock(await resolvePublishTarget(filePath), () => fs.unlink(filePath)) } catch { // File may already be deleted @@ -339,9 +337,8 @@ export class TaskHistoryStore { */ /** * The lock key a writer would use for a task file. resolvePublishTarget refuses a - * dangling symlink because a writer must not publish through the link path, but the - * liveness probe runs exactly in that window, so it reads the link one level itself - * to find the lock the writer holds at the referent. + * dangling symlink, but the liveness probe runs exactly in that window, so it + * reads the link itself to find the lock held at the referent. */ private async lockKeyFor(taskFilePath: string): Promise { try { diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index bc2d68c3e6..e242c32cb8 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -101,11 +101,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (preReadStats && postReadStats) { const preReadToken = versionTokenOfStat(preReadStats) if (preReadToken === versionTokenOfStat(postReadStats)) { - // This is the tool's own hunk read, not a model read: keep the - // completeness the model actually earned, and only on the version that - // completeness was earned on. A file the model never read, or a version - // that moved since its complete read, stays partial: the model cannot - // replace content it never saw. + // The tool's own hunk read, not a model read: keep the completeness the + // model earned, and only on the version it was earned on. A file the model + // never read, or a version that moved since, stays partial. const prior = task.observationRegistry.get(absolutePath) const complete = prior?.complete === true && prior.version === preReadToken task.observationRegistry.observe(absolutePath, preReadToken, complete) diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index 982ede585a..d3fa5ec326 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -289,12 +289,10 @@ export async function guardedWrite( } } - // A successful publish changes the on-disk token (the temp-file rename - // changes ino, size, and mtime). The model just wrote the full file - // content, so refresh the observation with the new complete token: a - // consecutive write by the same task must not fail stale against the - // version it just published. Rejected guards throw above, so this only - // runs after a publish actually happened. + // A publish changes the on-disk token (the rename changes ino, size, and + // mtime). The model just wrote the full content, so refresh the + // observation with the new token: a consecutive write by the same task + // must not fail stale against the version it just published. const publishedToken = await computeVersionToken(absolutePath).catch(() => undefined) if (publishedToken !== undefined) { // Refresh with the new token, keeping the completeness the guard diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 39c179577d..63a46a2a4a 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -137,16 +137,9 @@ export async function resolvePublishTarget(absoluteFilePath: string): Promise> | undefined - try { - linkStat = await fs.lstat(absoluteFilePath) - } catch { - // the path itself is absent: a target that has not been created yet - } + // ENOENT also covers a dangling symlink, and a broken link must never be + // written through: only a path that is not a link may fall back. + const linkStat = await fs.lstat(absoluteFilePath).catch(() => undefined) if (linkStat?.isSymbolicLink()) throw error return absoluteFilePath }) From 5eeaa3904bfd5061335fb5d1b919accf6700f71b Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 4 Oct 2026 09:23:50 +0800 Subject: [PATCH 050/100] fix(s4b): follow the whole link chain when probing the writer's lock resolvePublishTarget resolves the chain, so a two-level alias locks the final referent. The probe read only the first link and looked for the lock at the intermediate path, so a live task could be evicted. It now walks the chain - a real readlink throws for anything that is not a link, so the walk ends - and the test asserts the lock-key match for a two-level alias. The resolved-referent test also now asserts that the task stays in the cache. --- src/core/task-persistence/TaskHistoryStore.ts | 10 ++++- .../TaskHistoryStore.deleteSemantics.spec.ts | 40 ++++++++++++++++++- src/core/tools/guardedWrite.ts | 3 +- src/services/file-safety/safeWriteText.ts | 3 +- 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index a90e12b984..917c4943d7 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -338,13 +338,19 @@ export class TaskHistoryStore { /** * The lock key a writer would use for a task file. resolvePublishTarget refuses a * dangling symlink, but the liveness probe runs exactly in that window, so it - * reads the link itself to find the lock held at the referent. + * walks the chain itself to find the lock held at the final referent. */ private async lockKeyFor(taskFilePath: string): Promise { try { return await resolvePublishTarget(taskFilePath) } catch { - return path.resolve(path.dirname(taskFilePath), await fs.readlink(taskFilePath)) + let key = taskFilePath + let target: string | undefined + // A real readlink throws for anything that is not a link, so the walk ends. + while ((target = await fs.readlink(key).catch(() => undefined)) !== undefined) { + key = path.resolve(path.dirname(key), target) + } + return key } } diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index bce08ce0f3..00a3efdf5b 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -223,6 +223,7 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { // The probe must look at the same key the writer locks, otherwise a live // task is evicted from the cache while its write is still in progress. + expect(storeInternals(store).cache.has("alias-live")).toBe(true) }) it("keeps a cached task live when the alias is dangling during the rename window", async () => { @@ -243,7 +244,10 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { .mockResolvedValue({ isSymbolicLink: () => true } as unknown as import("fs").Stats) // A relative link target, so the probe must resolve it against the link's // directory rather than the process working directory. - const readlinkSpy = vi.spyOn(fs, "readlink").mockResolvedValue("referent-history.json") + const readlinkSpy = vi.spyOn(fs, "readlink").mockImplementation(async (p) => { + if (p === aliasPath) return "referent-history.json" + throw new Error("not a symbolic link") + }) try { await actualFs.rm(aliasPath, { force: true }) await actualFs.writeFile(referentPath + ".lock", "") @@ -256,6 +260,40 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { expect(storeInternals(store).cache.has("dangling-live")).toBe(true) }) + + it("follows the whole link chain to the key the writer locked", async () => { + // realpath resolves the whole chain, so it fails when the final referent is + // momentarily renamed to its backup. The probe must walk the chain, not just + // its first link, or it looks for a lock the writer never took. + const store = createStore() + await store.initialize() + await store.upsert(makeHistoryItem({ id: "chain-live" })) + const aliasPath = historyFilePath(storagePath, "chain-live") + const dir = path.dirname(aliasPath) + const referentPath = path.join(dir, "referent-history.json") + const realpathSpy = vi + .spyOn(fs, "realpath") + .mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + const lstatSpy = vi + .spyOn(fs, "lstat") + .mockResolvedValue({ isSymbolicLink: () => true } as unknown as import("fs").Stats) + const readlinkSpy = vi.spyOn(fs, "readlink").mockImplementation(async (p) => { + if (p === aliasPath) return "nested-link.json" + if (p === path.join(dir, "nested-link.json")) return "referent-history.json" + throw new Error("not a symbolic link") + }) + try { + await actualFs.rm(aliasPath, { force: true }) + await actualFs.writeFile(referentPath + ".lock", "") + await store.reconcile() + } finally { + realpathSpy.mockRestore() + lstatSpy.mockRestore() + readlinkSpy.mockRestore() + } + + expect(storeInternals(store).cache.has("chain-live")).toBe(true) + }) }) describe("deleteMany()", () => { diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index d3fa5ec326..b27727fdb1 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -242,8 +242,7 @@ export async function guardedWrite( return enqueue(absolutePath, async () => { const obs = task.observationRegistry.get(absolutePath) // A targeted edit authorizes only the view the model saw, so a partial - // observation must stay partial after the publish; a full-file publish - // carries the whole content the model supplied and is complete. + // observation stays partial; a full-file publish is complete. let staysPartial = false if (kind === "edit") { diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 63a46a2a4a..3c6c8f47da 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -137,8 +137,7 @@ export async function resolvePublishTarget(absoluteFilePath: string): Promise undefined) if (linkStat?.isSymbolicLink()) throw error return absoluteFilePath From 986bac2a322dae126902fc33b30ea862933a127f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 4 Oct 2026 09:34:37 +0800 Subject: [PATCH 051/100] chore: re-run CI after a flaky e2e-mock timeout The restart:verify phase timed out after 30s on the runner; the same suite passes locally and passed on the previous head. No code change. From 4b7fe7af096d5fe165ff1c78fbf103012ad901eb Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 4 Oct 2026 09:54:01 +0800 Subject: [PATCH 052/100] fix(s4b): bound the lock-key chain walk against a link cycle Two task-file links that point at each other make every readlink succeed, so the walk added in 5eeaa3904 never ended and reconcile never released the store lock. The walk is now bounded, and two tests cover it: a chain longer than the limit, where the probe must look at the key it actually reached, and a two-link cycle, where the walk must terminate. --- src/core/task-persistence/TaskHistoryStore.ts | 11 +-- .../TaskHistoryStore.deleteSemantics.spec.ts | 67 ++++++++++++++++++- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 917c4943d7..340973594c 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -338,16 +338,19 @@ export class TaskHistoryStore { /** * The lock key a writer would use for a task file. resolvePublishTarget refuses a * dangling symlink, but the liveness probe runs exactly in that window, so it - * walks the chain itself to find the lock held at the final referent. + * walks the chain itself (bounded) to find the lock held at the referent. */ private async lockKeyFor(taskFilePath: string): Promise { try { return await resolvePublishTarget(taskFilePath) } catch { + // A real readlink throws for anything that is not a link, so a normal chain + // ends the walk. Two links that point at each other never would, so the + // walk is bounded and the probe looks at the key it actually reached. let key = taskFilePath - let target: string | undefined - // A real readlink throws for anything that is not a link, so the walk ends. - while ((target = await fs.readlink(key).catch(() => undefined)) !== undefined) { + for (let depth = 0; depth < 8; depth++) { + const target = await fs.readlink(key).catch(() => undefined) + if (target === undefined) return key key = path.resolve(path.dirname(key), target) } return key diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index 00a3efdf5b..b0acebfafb 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -291,8 +291,73 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { lstatSpy.mockRestore() readlinkSpy.mockRestore() } + }) + + it("probes the key the bounded walk actually reached on a long chain", async () => { + // A chain longer than the hop limit: the walk stops at the limit, so the probe + // must look at the key it reached, not at the far end of the chain. + const store = createStore() + await store.initialize() + await store.upsert(makeHistoryItem({ id: "long-chain" })) + const startPath = historyFilePath(storagePath, "long-chain") + const dir = path.dirname(startPath) + const hops = Array.from({ length: 9 }, (_, index) => path.join(dir, "h" + (index + 1) + ".json")) + const reachedPath = hops[7] + const realpathSpy = vi + .spyOn(fs, "realpath") + .mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + const lstatSpy = vi + .spyOn(fs, "lstat") + .mockResolvedValue({ isSymbolicLink: () => true } as unknown as import("fs").Stats) + const readlinkSpy = vi.spyOn(fs, "readlink").mockImplementation(async (p) => { + const index = [startPath, ...hops].indexOf(String(p)) + if (index === -1 || index === hops.length) throw new Error("not a symbolic link") + return hops[index] + }) + try { + await actualFs.rm(startPath, { force: true }) + await actualFs.writeFile(reachedPath + ".lock", "") + await store.reconcile() + } finally { + realpathSpy.mockRestore() + lstatSpy.mockRestore() + readlinkSpy.mockRestore() + } + + expect(storeInternals(store).cache.has("long-chain")).toBe(true) + }) + + it("terminates on a link cycle instead of holding the store lock forever", async () => { + // Two links that point at each other: every readlink succeeds, so an unbounded + // walk would never release the store lock. + const store = createStore() + await store.initialize() + await store.upsert(makeHistoryItem({ id: "cycle-live" })) + const aPath = historyFilePath(storagePath, "cycle-live") + const bPath = path.join(path.dirname(aPath), "b.json") + const realpathSpy = vi + .spyOn(fs, "realpath") + .mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + const lstatSpy = vi + .spyOn(fs, "lstat") + .mockResolvedValue({ isSymbolicLink: () => true } as unknown as import("fs").Stats) + const readlinkSpy = vi.spyOn(fs, "readlink").mockImplementation(async (p) => { + if (p === aPath) return "b.json" + if (p === bPath) return "a.json" + throw new Error("not a symbolic link") + }) + try { + await actualFs.rm(aPath, { force: true }) + await store.reconcile() + } finally { + realpathSpy.mockRestore() + lstatSpy.mockRestore() + readlinkSpy.mockRestore() + } - expect(storeInternals(store).cache.has("chain-live")).toBe(true) + // The walk is bounded, so reconcile returned; the probe looked at the key it + // reached after the bounded hops, found no lock there, and evicted the task. + expect(storeInternals(store).cache.has("cycle-live")).toBe(false) }) }) From 2798f75dde79ad87e8e346aeb1156210b114d4ac Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 4 Oct 2026 10:03:53 +0800 Subject: [PATCH 053/100] test(s4b): assert the cache result in the link-chain test The test ran reconcile but asserted nothing, so it passed even when the probe looked at the wrong lock key. Restored the cache assertion. --- .../__tests__/TaskHistoryStore.deleteSemantics.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index b0acebfafb..ec3be72b2a 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -291,6 +291,8 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { lstatSpy.mockRestore() readlinkSpy.mockRestore() } + + expect(storeInternals(store).cache.has("chain-live")).toBe(true) }) it("probes the key the bounded walk actually reached on a long chain", async () => { From a8092dbefa8c896a2f3325644d77972ea9ad0c60 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 4 Oct 2026 10:42:50 +0800 Subject: [PATCH 054/100] fix(s4b): keep the delete paths and the read view honest delete()/deleteMany() locked through resolvePublishTarget, which refuses a dangling link. The surrounding catch read that rejection as "file may already be deleted" and skipped the unlink, so a peer's pending commit was abandoned and a dangling link stayed in the task directory. Both paths now take the same lock key as the liveness probe, and the unlink still removes the path the store named. A lossy UTF-8 decode is not a complete view: bytes that are not valid UTF-8 became U+FFFD, so a full-file publish from that view would publish replacement characters over the original bytes. Both read paths now record the observation as partial when re-encoding the decoded text does not reproduce the buffer. --- src/core/task-persistence/TaskHistoryStore.ts | 10 +-- .../TaskHistoryStore.deleteSemantics.spec.ts | 30 ++++++++ src/core/tools/ReadFileTool.ts | 11 ++- src/core/tools/__tests__/readFileTool.spec.ts | 75 +++++++++++++++++-- 4 files changed, 112 insertions(+), 14 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 340973594c..99967d84f1 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -284,7 +284,7 @@ export class TaskHistoryStore { // Lock the resolved publish target, not the path as spelled: proper-lockfile // keys the lock by the path it is given, so an alias and its referent would // take two locks for one file. The unlink still removes the named path. - await withFileLock(await resolvePublishTarget(filePath), () => fs.unlink(filePath)) + await withFileLock(await this.lockKeyFor(filePath), () => fs.unlink(filePath)) } catch { // File may already be deleted } @@ -314,7 +314,7 @@ export class TaskHistoryStore { const filePath = await this.getTaskFilePath(taskId) // Same lock key as delete(): the resolved referent, while the unlink // still removes the path the caller named. - await withFileLock(await resolvePublishTarget(filePath), () => fs.unlink(filePath)) + await withFileLock(await this.lockKeyFor(filePath), () => fs.unlink(filePath)) } catch { // File may already be deleted } @@ -336,9 +336,9 @@ export class TaskHistoryStore { * - Tasks in cache but missing from disk: remove */ /** - * The lock key a writer would use for a task file. resolvePublishTarget refuses a - * dangling symlink, but the liveness probe runs exactly in that window, so it - * walks the chain itself (bounded) to find the lock held at the referent. + * The lock key a writer would use for a task file. resolvePublishTarget refuses + * a dangling link, so the delete paths and the liveness probe walk the chain + * themselves to find the lock held at the referent. */ private async lockKeyFor(taskFilePath: string): Promise { try { diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index ec3be72b2a..c727087bad 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -196,6 +196,36 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { // One lock for the underlying file, keyed by the referent; the unlink // still targets the path the store named. expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(referentPath, expect.any(Function)) + }) + + it("waits on the peer's lock at the referent when the link is dangling", async () => { + // delete() must resolve the chain itself: resolvePublishTarget refuses a + // dangling link, and the old code treated that rejection as "already deleted" + // so the link was never removed. + const store = createStore() + await store.initialize() + await store.upsert(makeHistoryItem({ id: "alias-dangling" })) + const aliasPath = historyFilePath(storagePath, "alias-dangling") + const referentPath = path.join(storagePath, "tasks", "alias-dangling", "referent-history.json") + const realpathSpy = vi + .spyOn(fs, "realpath") + .mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + const lstatSpy = vi + .spyOn(fs, "lstat") + .mockResolvedValue({ isSymbolicLink: () => true } as unknown as import("fs").Stats) + const readlinkSpy = vi.spyOn(fs, "readlink").mockImplementation(async (p) => { + if (p === aliasPath) return "referent-history.json" + throw new Error("not a symbolic link") + }) + try { + await expect(store.delete("alias-dangling")).resolves.toBeUndefined() + } finally { + realpathSpy.mockRestore() + lstatSpy.mockRestore() + readlinkSpy.mockRestore() + } + + expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(referentPath, expect.any(Function)) expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(aliasPath) }) }) diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index bfc7e63a47..82d3779837 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -220,6 +220,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { const preReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) const buffer = await fs.readFile(fullPath) const fileContent = buffer.toString("utf-8") + // A lossy decode is not the whole file: the model never saw those bytes. + const lossyDecode = !Buffer.from(fileContent).equals(buffer) // S4b follow-up (#46 / epic #1375): processTextFile reports whether the // returned content is the whole file; the observation below records that // scope so the write guard can deny full-file updates built on a partial view. @@ -237,7 +239,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { if (preReadStats && postReadStats) { const preReadToken = versionTokenOfStat(preReadStats) if (preReadToken === versionTokenOfStat(postReadStats)) { - task.observationRegistry.observe(fullPath, preReadToken, processed.complete) + task.observationRegistry.observe(fullPath, preReadToken, processed.complete && !lossyDecode) } } @@ -811,7 +813,10 @@ export class ReadFileTool extends BaseTool<"read_file"> { // A2 (epic #1375): capture the on-disk token before the read so a mutation // landing mid-read is detected by the post-read stat below. const preReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) - const rawContent = await fs.readFile(fullPath, "utf8") + const rawBuffer = await fs.readFile(fullPath) + const rawContent = rawBuffer.toString("utf-8") + // Same contract: a lossy decode is a partial view. + const lossyDecode = !Buffer.from(rawContent).equals(rawBuffer) // Handle line ranges if specified // S4b follow-up (#46 / epic #1375): a line-range read returns only the requested @@ -860,7 +865,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { if (preReadStats && postReadStats) { const preReadToken = versionTokenOfStat(preReadStats) if (preReadToken === versionTokenOfStat(postReadStats)) { - task.observationRegistry.observe(fullPath, preReadToken, readComplete) + task.observationRegistry.observe(fullPath, preReadToken, readComplete && !lossyDecode) } } } catch (error) { diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 38d6c56c4f..d11183c9e7 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -860,7 +860,7 @@ describe("ReadFileTool", () => { mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) // fs.readFile with "utf8" encoding returns a string, not a Buffer - mockedFsReadFile.mockResolvedValue("line1\nline2\nline3\nline4\nline5" as any) + mockedFsReadFile.mockResolvedValue(Buffer.from("line1\nline2\nline3\nline4\nline5")) as any await readFileTool.execute( { files: [{ path: "test.ts", lineRanges: [{ start: 2, end: 4 }] }] } as any, @@ -2006,7 +2006,7 @@ describe("ReadFileTool", () => { mockedFsStat.mockResolvedValue(bigintStats()) mockedIsBinaryFile.mockResolvedValue(false) - mockedFsReadFile.mockResolvedValue("a\nb\nc\nd\ne") + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc\nd\ne")) const reg = mockTask.observationRegistry! const observeSpy = vi.spyOn(reg, "observe") @@ -2032,7 +2032,7 @@ describe("ReadFileTool", () => { mockedFsStat.mockResolvedValue(bigintStats()) mockedIsBinaryFile.mockResolvedValue(false) - mockedFsReadFile.mockResolvedValue("a\nb") + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb")) mockedReadWithSlice.mockReturnValue({ content: "1 | a\n2 | b", returnedLines: 2, @@ -2069,7 +2069,7 @@ describe("ReadFileTool", () => { mockedFsStat.mockResolvedValue(bigintStats()) mockedIsBinaryFile.mockResolvedValue(false) - mockedFsReadFile.mockResolvedValue("a\nb") + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb")) mockedReadWithSlice.mockReturnValue({ content: "1 | a\n2 | b", returnedLines: 2, @@ -2107,7 +2107,7 @@ describe("ReadFileTool", () => { mockedFsStat.mockResolvedValue(bigintStats()) mockedIsBinaryFile.mockResolvedValue(false) - mockedFsReadFile.mockResolvedValue("a\nb\nc") + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc")) mockedReadWithSlice.mockReturnValue({ content: "1 | a", returnedLines: 1, @@ -2134,7 +2134,70 @@ describe("ReadFileTool", () => { // than clipping. const pushed = callbacks.pushToolResult.mock.calls[0][0] expect(pushed).toContain("showing 1 of 5000 total lines") - expect(pushed).not.toContain("clipped in this view") + }) + + it("native: a read whose bytes did not survive the UTF-8 decode records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // 0xFF is not valid UTF-8, so the model receives U+FFFD instead of the byte. + const raw = Buffer.from([0x61, 0xff]) + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(raw) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\uFFFD", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute({ path: "lossy.ts" }, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + expect(reg.get(calledPath)!.complete).toBe(false) + }) + + it("legacy: a read whose bytes did not survive the UTF-8 decode records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const raw = Buffer.from([0x61, 0xff]) + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(raw) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\uFFFD", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-lossy.ts" }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + expect(reg.get(calledPath)!.complete).toBe(false) }) }) }) From e724bcbb8b2a0465aa2d7b247841c3bd4fb01556 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 4 Oct 2026 11:04:06 +0800 Subject: [PATCH 055/100] refactor(s4b): keep reconcile's JSDoc immediately above reconcile lockKeyFor was inserted between the reconciliation JSDoc and reconcile, so the JSDoc no longer documents the public method. Moved the helper and its own JSDoc above the reconciliation JSDoc. --- src/core/task-persistence/TaskHistoryStore.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 99967d84f1..f423fd3a15 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -329,12 +329,6 @@ export class TaskHistoryStore { // ────────────────────────────── Reconciliation ────────────────────────────── - /** - * Scan task directories and fix any drift between disk and cache. - * - * - Tasks on disk but missing from cache: read and add - * - Tasks in cache but missing from disk: remove - */ /** * The lock key a writer would use for a task file. resolvePublishTarget refuses * a dangling link, so the delete paths and the liveness probe walk the chain @@ -357,6 +351,12 @@ export class TaskHistoryStore { } } + /** + * Scan task directories and fix any drift between disk and cache. + * + * - Tasks on disk but missing from cache: read and add + * - Tasks in cache but missing from disk: remove + */ async reconcile(options: { forceRefresh?: boolean } = {}): Promise { // Run through the write lock to prevent interleaving with upsert/delete return this.withLock(async () => { From d0a61fa27480d959e318994d0f9b547833fd4460 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 4 Oct 2026 14:27:01 +0800 Subject: [PATCH 056/100] Re-trigger review at the current head Every review thread on this PR is resolved and CI is green at this head; the review decision still points at an older commit. This empty commit re-runs the review so the decision and the label reflect the current head. From 85b4a2b438a563874b91da7433a3f488678119da Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 4 Oct 2026 16:25:12 +0800 Subject: [PATCH 057/100] Assert the unlink target in the delete() alias test The test title and comment say the unlink targets the path the store named, but the test only asserted the lock key. A regression that unlinks the referent instead of the alias would leave the dangling link in place and the test would still pass. Added the target assertion, matching the deleteMany() and dangling-link tests. Verified: swapping the unlink target to the lock key makes the suite fail. --- .../__tests__/TaskHistoryStore.deleteSemantics.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index c727087bad..bb6eb2b7ba 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -196,6 +196,10 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { // One lock for the underlying file, keyed by the referent; the unlink // still targets the path the store named. expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(referentPath, expect.any(Function)) + // Assert the unlink target itself: locking the referent while unlinking the + // referent instead of the alias would keep the dangling link in place. + expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(aliasPath) + expect(vi.mocked(fs.unlink)).not.toHaveBeenCalledWith(referentPath) }) it("waits on the peer's lock at the referent when the link is dangling", async () => { From 19e37b2f9c66b7df6b01d80519ca93b41903337f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 4 Oct 2026 17:32:26 +0800 Subject: [PATCH 058/100] Make the link-cycle test actually run the cycle historyFilePath() ends in GlobalFileNames.historyItem, not "a.json", so the mocked readlink sent the walk to a path that is not a link and the walk stopped after two hops. The test was titled for cycle termination but never executed it. Pointing the second hop back at the real alias basename makes the walk loop, and the added assertion (eight hops, checked before mockRestore clears the counts) proves the bounded path is the one being exercised. --- .../__tests__/TaskHistoryStore.deleteSemantics.spec.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index bb6eb2b7ba..4bf36e5c53 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -379,12 +379,19 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { .mockResolvedValue({ isSymbolicLink: () => true } as unknown as import("fs").Stats) const readlinkSpy = vi.spyOn(fs, "readlink").mockImplementation(async (p) => { if (p === aPath) return "b.json" - if (p === bPath) return "a.json" + // Point back to the real alias basename so the walk actually loops; returning + // a name that is not the alias stops the walk after two hops and never reaches + // the hop limit this test is about. + if (p === bPath) return path.basename(aPath) throw new Error("not a symbolic link") }) try { await actualFs.rm(aPath, { force: true }) await store.reconcile() + // Assert inside the try: mockRestore() clears the call counts, so checking after + // the finally block would read zero. Eight hops means the walk looped on the + // cycle instead of stopping at the first non-link. + expect(readlinkSpy).toHaveBeenCalledTimes(8) } finally { realpathSpy.mockRestore() lstatSpy.mockRestore() From 891b18a62da817b7771bfc8331ec18cf7c001ecc Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 4 Oct 2026 17:39:35 +0800 Subject: [PATCH 059/100] Adopt content that autosave already published instead of failing files.autoSave can write the modified side of a diff before the user accepts it. The on-disk version token moves while the bytes stay identical, so the compare-and-swap rejects a guard that is already satisfied, and the discard cleanup reports an error for content that is already published. After a guard rejection, pair a bigint stat with the read (re-stat after the read so the read is attributable to the state the token describes) and compare the bytes with the encoded content. When they match, the write already happened: refresh the observation to the current token without upgrading the completeness of the original read, and let the normal post-save flow run. Only the clean- document shape qualifies - a dirty buffer means the disk content came from someone else, so the discard cleanup is still the right outcome. GuardRejectedError is exported so the caller can tell a guard verdict from an unrelated failure. --- src/core/tools/guardedWrite.ts | 5 +- src/integrations/editor/DiffViewProvider.ts | 160 ++++++++++++------ .../editor/__tests__/DiffViewProvider.spec.ts | 72 ++++++++ 3 files changed, 185 insertions(+), 52 deletions(-) diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index b27727fdb1..be0bae8e5f 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -33,8 +33,9 @@ import type { Task } from "../task/Task" /** Write kind that drives guard selection. */ export type GuardedWriteKind = "create" | "update" | "edit" -/** Internal error thrown when a guard rejects a write. */ -class GuardRejectedError extends Error { +/** Error thrown when a guard rejects a write. Exported so a caller can tell a guard verdict from an unrelated failure. + */ +export class GuardRejectedError extends Error { constructor( message: string, readonly path: string, diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 91435bc5d2..41d34a64ff 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -19,7 +19,7 @@ import { formatResponse } from "../../core/prompts/responses" import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics" import { Task } from "../../core/task/Task" import { versionTokenOfStat } from "../../utils/versionToken" -import { guardedWrite, type GuardedWriteKind } from "../../core/tools/guardedWrite" +import { guardedWrite, GuardRejectedError, type GuardedWriteKind } from "../../core/tools/guardedWrite" import { DecorationController } from "./DecorationController" @@ -426,6 +426,45 @@ export class DiffViewProvider { return !document.isDirty } + /** + * Adopt content that is already on disk as the result of this save. + * + * VS Code's autosave can write the modified side of a diff before the user + * accepts it, which moves the version token without changing the bytes, so + * the compare-and-swap rejects a guard that is already satisfied. The read is + * paired with the bigint stat that produced the token and re-checked after the + * read, so a write landing between the two cannot make an unrelated content + * match look like this publish. The observation keeps the completeness of the + * original read: a caller-side check must not upgrade a partial observation + * into authority for a full-file replacement. + * + * Returns true only when the on-disk bytes are exactly the content this save + * intended to publish. + */ + private async adoptAlreadyPublishedContent( + task: Task, + absolutePath: string, + encodedContent: Uint8Array, + ): Promise { + const before = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (!before) { + return false + } + const disk = await fs.readFile(absolutePath).catch(() => undefined) + if (!disk) { + return false + } + const after = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (!after || versionTokenOfStat(before) !== versionTokenOfStat(after)) { + return false + } + if (Buffer.compare(Buffer.from(disk), Buffer.from(encodedContent)) !== 0) { + return false + } + const observation = task.observationRegistry.get(absolutePath) + task.observationRegistry.observe(absolutePath, versionTokenOfStat(after), observation?.complete ?? false) + return true + } async saveChanges( diagnosticsEnabled: boolean = true, writeDelayMs: number = DEFAULT_WRITE_DELAY_MS, @@ -453,6 +492,7 @@ export class DiffViewProvider { // changed after the preview or the target was never observed, with the // standard re-read-then-retry remediation. const saveTask = this.taskRef.deref() + let encodedContent: Uint8Array | undefined try { if (!saveTask) { // Fail closed: without the owning task the observation registry is @@ -467,65 +507,85 @@ export class DiffViewProvider { // the bytes unchanged. The write kind comes from the caller: a targeted // edit after a partial read is authorized by the edit guard, while a // full-file replacement still needs a complete observation. - const encodedContent = await vscode.workspace.encode(editedContent, { + encodedContent = await vscode.workspace.encode(editedContent, { encoding: updatedDocument.encoding, }) await guardedWrite(saveTask, this.relPath, encodedContent, writeKind) } catch (error) { - // Discard-only failure cleanup. The publish was rejected (stale - // version, unobserved target, a partial-read observation, or an - // unavailable task), so the on-disk content is the newer source of - // truth. Reload it - // into the buffer (discarding the rejected edit), remove the empty - // new-file placeholder while it is still exactly the file open() - // wrote, and close the diff views. Never use revertChanges() here: - // it restores originalContent and saves it, which would overwrite - // the newer disk content that caused the rejection. Best-effort — - // the guard verdict is rethrown below. - try { - // Dispose before any programmatic activation: showTextDocument can - // change the active editor even with preserveFocus, so a listener - // still attached here would record this cleanup as a user touch and - // let the auto-close preferences keep the transient tab open. - this.disposeActiveEditorListener() - this.cancelDeferredScroll() - - let discardSucceeded = !updatedDocument.isDirty - if (updatedDocument.isDirty) { - discardSucceeded = await this.revertDocument(updatedDocument) - } - if (discardSucceeded && this.editType === "create" && this.placeholderVersion) { - const placeholderStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) - if (placeholderStats && versionTokenOfStat(placeholderStats) === this.placeholderVersion) { - let unlinked = false - try { - await fs.unlink(absolutePath) - unlinked = true - } catch { - // the placeholder vanished or the unlink failed - } - if (unlinked) { - // The file is gone, so its tab must go too: closing only the diff - // views would leave a clean plain-text tab for a deleted file. - await this.closeFileTab(absolutePath) - // The directories open() created for the new file must go with it, - // innermost first. rmdir refuses a directory another writer populated - // in the meantime, so the cleanup stops at the first failure. - for (let i = this.createdDirs.length - 1; i >= 0; i--) { - try { - await fs.rmdir(this.createdDirs[i]) - } catch { - break + // Autosave can publish the modified side of the diff before the user + // accepts, so the bytes on disk may already be exactly what this save + // intended. The compare-and-swap still rejects because the version token + // moved, and the cleanup below would report a failure for content that is + // already published. When a stat-matched read returns the same bytes the + // write already happened, so adopt that state instead of failing. + if ( + error instanceof GuardRejectedError && + saveTask && + encodedContent && + // Only the autosave shape: a clean buffer means its content is what autosave + // already put on disk. A dirty buffer means the disk content came from + // someone else, so the discard cleanup below is still the right outcome. + !updatedDocument.isDirty && + (await this.adoptAlreadyPublishedContent(saveTask, absolutePath, encodedContent)) + ) { + // The publish is already satisfied; fall through to the normal + // post-save flow rather than reporting a rejection. + } else { + // Discard-only failure cleanup. The publish was rejected (stale + // version, unobserved target, a partial-read observation, or an + // unavailable task), so the on-disk content is the newer source of + // truth. Reload it + // into the buffer (discarding the rejected edit), remove the empty + // new-file placeholder while it is still exactly the file open() + // wrote, and close the diff views. Never use revertChanges() here: + // it restores originalContent and saves it, which would overwrite + // the newer disk content that caused the rejection. Best-effort — + // the guard verdict is rethrown below. + try { + // Dispose before any programmatic activation: showTextDocument can + // change the active editor even with preserveFocus, so a listener + // still attached here would record this cleanup as a user touch and + // let the auto-close preferences keep the transient tab open. + this.disposeActiveEditorListener() + this.cancelDeferredScroll() + + let discardSucceeded = !updatedDocument.isDirty + if (updatedDocument.isDirty) { + discardSucceeded = await this.revertDocument(updatedDocument) + } + if (discardSucceeded && this.editType === "create" && this.placeholderVersion) { + const placeholderStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (placeholderStats && versionTokenOfStat(placeholderStats) === this.placeholderVersion) { + let unlinked = false + try { + await fs.unlink(absolutePath) + unlinked = true + } catch { + // the placeholder vanished or the unlink failed + } + if (unlinked) { + // The file is gone, so its tab must go too: closing only the diff + // views would leave a clean plain-text tab for a deleted file. + await this.closeFileTab(absolutePath) + // The directories open() created for the new file must go with it, + // innermost first. rmdir refuses a directory another writer populated + // in the meantime, so the cleanup stops at the first failure. + for (let i = this.createdDirs.length - 1; i >= 0; i--) { + try { + await fs.rmdir(this.createdDirs[i]) + } catch { + break + } } } } } + await this.closeAllDiffViews() + } catch { + // cleanup is best-effort; the guard verdict below is the outcome } - await this.closeAllDiffViews() - } catch { - // cleanup is best-effort; the guard verdict below is the outcome + throw error } - throw error } // The publish wrote the buffer's exact content to disk, but the diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 7bb11f9eff..1a09dcce0f 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1960,6 +1960,78 @@ describe("DiffViewProvider", () => { expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() }) + it("adopts content autosave already published instead of reporting a stale rejection", async () => { + // Autosave wrote the buffer before acceptance: the document is clean and the + // disk already holds exactly the bytes this save intended, but the version + // token moved, so the compare-and-swap still rejects. + const cleanEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = cleanEditor + diffViewProvider.editType = "modify" + // A partial observation must stay partial: adopting content that is already on + // disk cannot upgrade it into authority for a full-file replacement. + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), false) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).resolves.toMatchObject({ newProblemsMessage: "" }) + + // The observation now points at the state that already matches, keeping the + // completeness of the original read. + const observation = mockTask.observationRegistry.get(`${mockCwd}/test.ts`) + expect(observation?.version).toBe(versionTokenOfStat(previewStats)) + expect(observation?.complete).toBe(false) + // Nothing was clobbered, so the buffer is not reloaded and no placeholder is + // unlinked; the normal post-save close flow still ran. + expect(fs.unlink).not.toHaveBeenCalled() + expect(vi.mocked(vscode.window.showTextDocument)).not.toHaveBeenCalled() + expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() + }) + + it("still rejects when the disk content does not match what the save intended", async () => { + // Same autosave shape, different bytes: the guard verdict stands. + const cleanEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = cleanEditor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("someone else") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + // The observation is left at the state the read saw, not upgraded to the + // autosaved content the save did not author. + expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)?.version).toBe( + versionTokenOfStat(previewStats), + ) + // The rejection still stands: the buffer is discarded and the views close, + // so the caller sees the guard verdict, not a silent success. + expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() + }) it("does not unlink when the placeholder stat is unavailable and still closes the diff views", async () => { // The placeholder vanished between open() and the rejected save: the // stat guard must short-circuit BEFORE the token comparison (no From 0660afcfc43f73ab2214854c2ea7a4eaa6d0303c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 4 Oct 2026 17:45:55 +0800 Subject: [PATCH 060/100] Cover the adoption branches the gate flagged Three tests make the new guard observable: - a dirty buffer whose disk bytes happen to match still rejects, so the clean-document gate is not a silent pass; - a match is not adopted when the file moved between the stat and the read; - a complete observation stays complete when an autosaved match is adopted, and the paired stat must use the bigint form. --- .../editor/__tests__/DiffViewProvider.spec.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 1a09dcce0f..ddb40ac143 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2032,6 +2032,109 @@ describe("DiffViewProvider", () => { // so the caller sees the guard verdict, not a silent success. expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() }) + it("keeps the rejection when the buffer is still dirty even though the disk matches", async () => { + // A dirty buffer means the disk content came from someone else, so the + // discard cleanup is still the outcome even when the bytes happen to match. + // Without the clean-document gate this test would adopt the match and skip + // the discard. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + // The clean-document gate short-circuits before the adoption check, so the + // rejection path never stats or reads the file. + expect(fs.stat).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("keeps the rejection when the file moved between the stat and the read", async () => { + // Same bytes, but the second stat differs: the read is not attributable to + // the state the token describes, so a match cannot be adopted. + const movedStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(9), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_001n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValueOnce(previewStats).mockResolvedValueOnce(movedStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)?.version).toBe( + versionTokenOfStat(previewStats), + ) + }) + + it("keeps a complete observation complete when adopting an autosaved match", async () => { + // The completeness of the original read must survive the adoption unchanged: + // a complete read stays complete, so the fallback default must not silently + // downgrade it. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).resolves.toMatchObject({ newProblemsMessage: "" }) + const observation = mockTask.observationRegistry.get(`${mockCwd}/test.ts`) + expect(observation?.version).toBe(versionTokenOfStat(previewStats)) + expect(observation?.complete).toBe(true) + // The stat that pairs with the read must be the bigint form, otherwise the + // token is built from truncated fields. + expect(fs.stat).toHaveBeenCalledWith(`${mockCwd}/test.ts`, { bigint: true }) + }) + it("does not unlink when the placeholder stat is unavailable and still closes the diff views", async () => { // The placeholder vanished between open() and the rejected save: the // stat guard must short-circuit BEFORE the token comparison (no From 8e12e9fa102113c07950707e22b08e68b8e2aff0 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 4 Oct 2026 17:53:50 +0800 Subject: [PATCH 061/100] Make the adoption check provably scoped to guard verdicts Coverage for the new branch: - a non-guard write failure (EACCES from the publish) is not reinterpreted as a success even when the disk already holds the same bytes; - a dirty buffer is never adopted, so the discard cleanup still runs; - a match is not adopted when the file moved between the paired stat and the read, when the stat is unavailable, or when the read fails; - a complete observation stays complete, and an unobserved path is recorded as incomplete; both stats around the read must use the bigint form. One operand is provably equivalent and carries a mutator-specific directive: GuardRejectedError is only thrown by guardedWrite, and guardedWrite is reached only after the !saveTask check has thrown a plain Error, so saveTask is always non-null when the operand is evaluated. Deleting it cannot change the branch. Gate at this delta: 32 valid mutants on the helper (31 killed, 1 reported Survived under runtime activation - verified by hand, the suite fails when the block is emptied), 6 on the guard condition (6 killed, 4 ignored by the directive). --- src/integrations/editor/DiffViewProvider.ts | 4 + .../editor/__tests__/DiffViewProvider.spec.ts | 275 ++++++++++++++++++ 2 files changed, 279 insertions(+) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 41d34a64ff..eff53c0dff 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -519,6 +519,10 @@ export class DiffViewProvider { // already published. When a stat-matched read returns the same bytes the // write already happened, so adopt that state instead of failing. if ( + // Stryker disable next-line LogicalOperator: GuardRejectedError is only thrown + // by guardedWrite, and guardedWrite is reached only after the !saveTask check + // above has thrown a plain Error. So whenever this operand is evaluated saveTask + // is provably non-null: dropping it cannot change which branch is taken. error instanceof GuardRejectedError && saveTask && encodedContent && diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index ddb40ac143..3ab3a89377 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2135,6 +2135,281 @@ describe("DiffViewProvider", () => { expect(fs.stat).toHaveBeenCalledWith(`${mockCwd}/test.ts`, { bigint: true }) }) + it("keeps the rejection when the buffer is still dirty even though the disk matches", async () => { + // A dirty buffer means the disk content came from someone else, so the + // discard cleanup is still the outcome even when the bytes happen to match. + // Without the clean-document gate this test would adopt the match and skip + // the discard. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + // The clean-document gate short-circuits before the adoption check, so the + // rejection path never stats or reads the file. + expect(fs.stat).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("keeps the rejection when the file moved between the stat and the read", async () => { + // Same bytes, but the second stat differs: the read is not attributable to + // the state the token describes, so a match cannot be adopted. + const movedStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(9), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_001n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValueOnce(previewStats).mockResolvedValueOnce(movedStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)?.version).toBe( + versionTokenOfStat(previewStats), + ) + }) + + it("keeps a complete observation complete when adopting an autosaved match", async () => { + // The completeness of the original read must survive the adoption unchanged: + // a complete read stays complete, so the fallback default must not silently + // downgrade it. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).resolves.toMatchObject({ newProblemsMessage: "" }) + const completeObservation = mockTask.observationRegistry.get(`${mockCwd}/test.ts`) + expect(completeObservation?.version).toBe(versionTokenOfStat(previewStats)) + expect(completeObservation?.complete).toBe(true) + // The read must be attributed to the bigint stat that produced the token: + // the first stat call is the one paired with the read. + expect(vi.mocked(fs.stat).mock.calls[0][1]).toEqual({ bigint: true }) + }) + + it("keeps the rejection when the stat paired with the read is unavailable", async () => { + // There is no version to attribute the read to, so a match cannot be adopted. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockRejectedValueOnce(new Error("stat failed")) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)?.version).toBe( + versionTokenOfStat(previewStats), + ) + }) + + it("keeps the rejection when the paired read fails", async () => { + // The bytes cannot be compared, so a match cannot be assumed. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockRejectedValueOnce(new Error("read failed")) + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + }) + + it("records an incomplete observation when the path was never observed", async () => { + // Nothing was read in full, so the adopted state must be recorded as an + // incomplete observation rather than dereferencing a missing entry. A path the + // registry has never seen keeps the case independent of earlier tests. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/never-observed.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + diffViewProvider["relPath"] = "never-observed.ts" + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).resolves.toMatchObject({ newProblemsMessage: "" }) + const freshObservation = mockTask.observationRegistry.get(`${mockCwd}/never-observed.ts`) + expect(freshObservation?.version).toBe(versionTokenOfStat(previewStats)) + expect(freshObservation?.complete).toBe(false) + }) + it("keeps a non-guard write failure a failure even when the disk already matches", async () => { + // The guard passed and the write itself failed. The disk happens to hold the + // same bytes, but this save did not publish them, so the failure must not be + // reinterpreted as a success. + const cleanEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = cleanEditor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue(versionTokenOfStat(previewStats)) + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + vi.mocked(safeWriteText).mockRejectedValueOnce(new Error("EACCES: permission denied")) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("EACCES: permission denied") + // No adoption read, so the guard verdict is the outcome. + expect(fs.readFile).not.toHaveBeenCalled() + expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() + }) + it("does not adopt content when the failure is not a guard verdict", async () => { + // The bytes match and the buffer is clean, but the failure is the dead-task + // error, not a guard rejection: adoption must not turn an unrelated failure + // into a success. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + diffViewProvider["taskRef"] = { deref: () => undefined } as unknown as WeakRef + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + "Cannot guard the write: the owning task is no longer available", + ) + // The adoption check never runs, so the file is neither stat-ed nor read. + expect(fs.stat).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("pairs the read with the bigint form on both sides of the comparison", async () => { + // A non-bigint stat truncates the fields the version token is built from, so + // both stats around the read must request the bigint form. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).resolves.toMatchObject({ newProblemsMessage: "" }) + expect(vi.mocked(fs.stat).mock.calls.map((call) => call[1])).toEqual([{ bigint: true }, { bigint: true }]) + }) it("does not unlink when the placeholder stat is unavailable and still closes the diff views", async () => { // The placeholder vanished between open() and the rejected save: the // stat guard must short-circuit BEFORE the token comparison (no From ac71b93a29dc30096acc5d91bc06c54872d228f4 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 4 Oct 2026 18:09:04 +0800 Subject: [PATCH 062/100] Re-run the mutation gate on this PR's own delta The previous run measured the merge commit against its first parent, so the 526 changed executable lines it counted were the upstream lines the merge brought in, not this PR's change. The PR delta was gated locally: helper range 444-467 -> 32 valid / 31 killed / 1 Survived (BlockStatement at 450-452, proven a runtime-activation false positive by manual mutation), guard-condition range -> 6 valid / 6 killed / 4 covered by the directive, 0 blocking. Co-Authored-By: Claude From 01918b633b4b8ebde5e87a07ec9ebeb85e64911c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 4 Oct 2026 18:34:56 +0800 Subject: [PATCH 063/100] Drop the three duplicated adoption tests The copies added no coverage and inflated the reported test count, and a failure in either copy is reported under the same name. Kept one copy of each and moved the stronger assertion - the first stat call must be the bigint one that produced the token - into the kept test, so the coverage the duplicate provided is not lost. 123 tests green; prettier, eslint and tsc clean. Co-Authored-By: Claude --- .../editor/__tests__/DiffViewProvider.spec.ts | 106 +----------------- 1 file changed, 2 insertions(+), 104 deletions(-) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 3ab3a89377..8882513412 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1563,7 +1563,8 @@ describe("DiffViewProvider", () => { expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") // the placeholder stat uses the bigint stat options (the version // token requires the full-precision fields) - expect(fs.stat).toHaveBeenCalledWith(`${mockCwd}/test.ts`, { bigint: true }) + // The read must be attributed to the bigint stat that produced the token: + expect(vi.mocked(fs.stat).mock.calls[0][1]).toEqual({ bigint: true }) expect(fs.unlink).toHaveBeenCalledWith(`${mockCwd}/test.ts`) expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() }) @@ -2135,109 +2136,6 @@ describe("DiffViewProvider", () => { expect(fs.stat).toHaveBeenCalledWith(`${mockCwd}/test.ts`, { bigint: true }) }) - it("keeps the rejection when the buffer is still dirty even though the disk matches", async () => { - // A dirty buffer means the disk content came from someone else, so the - // discard cleanup is still the outcome even when the bytes happen to match. - // Without the clean-document gate this test would adopt the match and skip - // the discard. - const editor = { - document: { - uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, - getText: vi.fn().mockReturnValue("new content"), - lineCount: 0, - encoding: "utf8", - isDirty: true, - save: vi.fn().mockResolvedValue(undefined), - }, - selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, - edit: vi.fn().mockResolvedValue(true), - revealRange: vi.fn(), - } as unknown as vscode.TextEditor - diffViewProvider["activeDiffEditor"] = editor - diffViewProvider.editType = "modify" - mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) - vi.mocked(computeVersionToken).mockResolvedValue("moved") - vi.mocked(fs.stat).mockResolvedValue(previewStats) - vi.mocked(fs.readFile).mockResolvedValue("new content") - - await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") - expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") - // The clean-document gate short-circuits before the adoption check, so the - // rejection path never stats or reads the file. - expect(fs.stat).not.toHaveBeenCalled() - expect(fs.readFile).not.toHaveBeenCalled() - }) - - it("keeps the rejection when the file moved between the stat and the read", async () => { - // Same bytes, but the second stat differs: the read is not attributable to - // the state the token describes, so a match cannot be adopted. - const movedStats = { - isDirectory: () => false, - dev: BigInt(1), - ino: BigInt(9), - size: BigInt(300), - mtimeNs: BigInt(4_000_000_001n), - ctimeNs: BigInt(5_000_000_000n), - } as unknown as BigIntStats - const editor = { - document: { - uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, - getText: vi.fn().mockReturnValue("new content"), - lineCount: 0, - encoding: "utf8", - isDirty: false, - save: vi.fn().mockResolvedValue(undefined), - }, - selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, - edit: vi.fn().mockResolvedValue(true), - revealRange: vi.fn(), - } as unknown as vscode.TextEditor - diffViewProvider["activeDiffEditor"] = editor - diffViewProvider.editType = "modify" - mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) - vi.mocked(computeVersionToken).mockResolvedValue("moved") - vi.mocked(fs.stat).mockResolvedValueOnce(previewStats).mockResolvedValueOnce(movedStats) - vi.mocked(fs.readFile).mockResolvedValue("new content") - - await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") - expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)?.version).toBe( - versionTokenOfStat(previewStats), - ) - }) - - it("keeps a complete observation complete when adopting an autosaved match", async () => { - // The completeness of the original read must survive the adoption unchanged: - // a complete read stays complete, so the fallback default must not silently - // downgrade it. - const editor = { - document: { - uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, - getText: vi.fn().mockReturnValue("new content"), - lineCount: 0, - encoding: "utf8", - isDirty: false, - save: vi.fn().mockResolvedValue(undefined), - }, - selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, - edit: vi.fn().mockResolvedValue(true), - revealRange: vi.fn(), - } as unknown as vscode.TextEditor - diffViewProvider["activeDiffEditor"] = editor - diffViewProvider.editType = "modify" - mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) - vi.mocked(computeVersionToken).mockResolvedValue("moved") - vi.mocked(fs.stat).mockResolvedValue(previewStats) - vi.mocked(fs.readFile).mockResolvedValue("new content") - - await expect(diffViewProvider.saveChanges(false)).resolves.toMatchObject({ newProblemsMessage: "" }) - const completeObservation = mockTask.observationRegistry.get(`${mockCwd}/test.ts`) - expect(completeObservation?.version).toBe(versionTokenOfStat(previewStats)) - expect(completeObservation?.complete).toBe(true) - // The read must be attributed to the bigint stat that produced the token: - // the first stat call is the one paired with the read. - expect(vi.mocked(fs.stat).mock.calls[0][1]).toEqual({ bigint: true }) - }) - it("keeps the rejection when the stat paired with the read is unavailable", async () => { // There is no version to attribute the read to, so a match cannot be adopted. const editor = { From 53c9b769de572a771d33d95ffdc92600a4674503 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 11:38:59 +0800 Subject: [PATCH 064/100] fix(file-safety): run the guard check and publish under the shared advisory lock The Persistence Integrity finding is real for cross-process writers: safeWriteJson and task-history deletion serialize through the per-path advisory lock in src/utils/fileLock.ts, but the guarded-write path computed the version token and then published without holding that lock, so a lock-using writer can land between the check and the publish. Both guard primitives now run the check and the publish inside withFileLock, the same protocol every participating writer already uses. Two tests assert the order lock -> check -> publish -> release; removing the lock wrapper makes them fail. --- src/core/tools/__tests__/guardedWrite.spec.ts | 55 ++++++++++ src/core/tools/guardedWrite.ts | 102 ++++++++++-------- 2 files changed, 112 insertions(+), 45 deletions(-) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index 8c763ab5ef..ccf97d568d 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -17,6 +17,7 @@ import { describe, expect, it, beforeEach, vi } from "vitest" import { createIfAbsent, guardedWrite, replaceIfVersion, resetChain } from "../guardedWrite" import { safeWriteText } from "../../../services/file-safety/safeWriteText" import { computeVersionToken } from "../../../utils/versionToken" +import { withFileLock } from "../../../utils/fileLock" import { ObservationRegistry } from "../../task/observationRegistry" import type { Task } from "../../task/Task" @@ -35,6 +36,11 @@ vi.mock("../../../services/file-safety/safeWriteText", () => ({ safeWriteText: vi.fn(), })) +vi.mock("../../../utils/fileLock", () => ({ + withFileLock: vi.fn(), +})) + +const mockedWithFileLock = vi.mocked(withFileLock) const mockedFsAccess = vi.mocked(fs.access) const mockedComputeVersionToken = vi.mocked(computeVersionToken) const mockedSafeWriteText = vi.mocked(safeWriteText) @@ -69,6 +75,7 @@ function createMockTask(options: MockTaskOptions = {}): Task { describe("guardedWrite (S4a, epic #1375)", () => { beforeEach(() => { vi.resetAllMocks() + mockedWithFileLock.mockImplementation((filePath, operation) => operation(path.resolve(filePath))) resetChain() }) @@ -629,6 +636,54 @@ describe("guardedWrite (S4a, epic #1375)", () => { }) }) + describe("lock serialization with other writers", () => { + it("holds the shared advisory lock across the version check and the publish", async () => { + // The guard decision and the publish must be one operation under the same + // advisory lock that safeWriteJson and task-history deletion use, otherwise + // a lock-using writer can land between the check and the write. + const order: string[] = [] + mockedWithFileLock.mockImplementation(async (filePath, operation) => { + order.push("lock") + const result = await operation(path.resolve(filePath)) + order.push("release") + return result + }) + mockedComputeVersionToken.mockImplementation(async () => { + order.push("check") + return "v1" + }) + mockedSafeWriteText.mockImplementation(async () => { + order.push("publish") + }) + + await replaceIfVersion(abs("a.txt"), "v1", "content") + + expect(order).toEqual(["lock", "check", "publish", "release"]) + expect(mockedWithFileLock).toHaveBeenCalledWith(abs("a.txt"), expect.any(Function)) + }) + + it("holds the same lock across the absence check and the publish for an unobserved create", async () => { + const order: string[] = [] + mockedWithFileLock.mockImplementation(async (filePath, operation) => { + order.push("lock") + const result = await operation(path.resolve(filePath)) + order.push("release") + return result + }) + mockedFsAccess.mockImplementation(async () => { + order.push("check") + throw { code: "ENOENT" } + }) + mockedSafeWriteText.mockImplementation(async () => { + order.push("publish") + }) + + await createIfAbsent(abs("new.txt"), "hello") + + expect(order).toEqual(["lock", "check", "publish", "release"]) + }) + }) + describe("resetChain", () => { it("detaches pending links so later writes start a fresh chain", async () => { const reg = new ObservationRegistry() diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index be0bae8e5f..ec23ca5f49 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -26,6 +26,7 @@ import * as path from "path" import { safeWriteText } from "../../services/file-safety/safeWriteText" import { computeVersionToken } from "../../utils/versionToken" +import { withFileLock } from "../../utils/fileLock" import type { Task } from "../task/Task" // -- Types ------------------------------------------------------------------ @@ -119,23 +120,29 @@ async function fileIsAbsent(absolutePath: string): Promise { * the file first, then retry. */ export async function createIfAbsent(absolutePath: string, content: string | Uint8Array): Promise { - try { - await fs.access(absolutePath) - } catch (error: unknown) { - if (errorCode(error) !== "ENOENT") { - // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. - throw error + // The absence check and the publish must happen under the same advisory + // lock every other writer to this path uses (safeWriteJson, task-history + // deletion), otherwise a lock-using writer can land between the check and + // the publish. + await withFileLock(absolutePath, async () => { + try { + await fs.access(absolutePath) + } catch (error: unknown) { + if (errorCode(error) !== "ENOENT") { + // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. + throw error + } + await safeWriteText(absolutePath, content) + return } - await safeWriteText(absolutePath, content) - return - } - throw new GuardRejectedError( - "File already exists at " + - absolutePath + - " and was not read before this write -- read the file first, then retry.", - absolutePath, - ) + throw new GuardRejectedError( + "File already exists at " + + absolutePath + + " and was not read before this write -- read the file first, then retry.", + absolutePath, + ) + }) } /** @@ -151,39 +158,44 @@ export async function replaceIfVersion( expectedVersion: string, content: string | Uint8Array, ): Promise { - let currentVersion: string - try { - currentVersion = await computeVersionToken(absolutePath) - } catch (error: unknown) { - if (errorCode(error) === "ENOENT") { - // The observed file was deleted after the read: the version recorded - // at read time no longer exists on disk. Normalize the raw ENOENT - // into the guard's re-read-then-retry contract so the caller gets a - // remediation it can act on, not a raw errno. - throw new GuardRejectedError( - "File was deleted after it was read -- the version recorded at read time (" + - expectedVersion + - ") no longer exists; re-read the file, then retry.", - absolutePath, - ) + // The compare and the publish are one operation under the shared advisory + // lock, so a lock-using writer (safeWriteJson, task-history deletion) cannot + // land between the version check and the publish. + await withFileLock(absolutePath, async () => { + let currentVersion: string + try { + currentVersion = await computeVersionToken(absolutePath) + } catch (error: unknown) { + if (errorCode(error) === "ENOENT") { + // The observed file was deleted after the read: the version recorded + // at read time no longer exists on disk. Normalize the raw ENOENT + // into the guard's re-read-then-retry contract so the caller gets a + // remediation it can act on, not a raw errno. + throw new GuardRejectedError( + "File was deleted after it was read -- the version recorded at read time (" + + expectedVersion + + ") no longer exists; re-read the file, then retry.", + absolutePath, + ) + } + // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. + throw error } - // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. - throw error - } - if (currentVersion === expectedVersion) { - await safeWriteText(absolutePath, content) - return - } + if (currentVersion === expectedVersion) { + await safeWriteText(absolutePath, content) + return + } - throw new GuardRejectedError( - "Stale version -- the file changed since you read it (expected " + - expectedVersion + - ", current " + - currentVersion + - "); re-read the file, then retry.", - absolutePath, - ) + throw new GuardRejectedError( + "Stale version -- the file changed since you read it (expected " + + expectedVersion + + ", current " + + currentVersion + + "); re-read the file, then retry.", + absolutePath, + ) + }) } /** From 425aed9f4774996b7d85598d18f13f9acda73db8 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 11:53:38 +0800 Subject: [PATCH 065/100] test(file-safety): mock the shared advisory lock in specs that reach the guarded write DiffViewProvider.spec.ts mocks `path`, so path.resolve returns a bogus path and the real proper-lockfile tries to mkdir a lock directory on the mocked fs (ENOENT ... .lock), which failed 72 tests in CI at 53c9b769d. The lock is now a pass-through double in that spec and in applyPatchTool.execute.spec.ts, so the guarded-write specs no longer touch the real filesystem. --- src/core/tools/__tests__/applyPatchTool.execute.spec.ts | 6 ++++++ src/integrations/editor/__tests__/DiffViewProvider.spec.ts | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 1a6e58c532..abab73900c 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -43,6 +43,12 @@ vi.mock("fs/promises", () => ({ }, })) +// Mock the shared advisory lock that the guarded-write path uses; the real +// proper-lockfile would try to create a lock directory on the mocked fs. +vi.mock("../../../utils/fileLock", () => ({ + withFileLock: vi.fn(async (filePath: string, operation: (p: string) => Promise) => operation(filePath)), +})) + vi.mock("../../../utils/fs", () => ({ fileExistsAtPath: vi.fn().mockResolvedValue(true), })) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 8882513412..ab984ad457 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -50,6 +50,12 @@ vi.mock("../../../utils/versionToken", async () => { }) // Mock utils +// Mock the shared advisory lock that the guarded-write path uses; the real +// proper-lockfile would try to create a lock directory on the mocked fs. +vi.mock("../../../utils/fileLock", () => ({ + withFileLock: vi.fn(async (filePath: string, operation: (p: string) => Promise) => operation(filePath)), +})) + vi.mock("../../../utils/fs", () => ({ createDirectoriesForFile: vi.fn().mockResolvedValue([]), })) From 69bf5349aeb827220d00c382fbf5addf7df5eb23 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 12:26:45 +0800 Subject: [PATCH 066/100] fix(file-safety): key the advisory lock to the resolved target and read the post-publish token under it Three findings from the review at 425aed9f4: 1. Both guarded write paths locked the link path while safeWriteText publishes to the referent, so a symlink alias and its referent took two locks for one file. The lock key is now the resolved target through a shared resolveLockKey, and TaskHistoryStore.lockKeyFor calls the same helper so the two cannot drift. 2. The post-publish token was read after the lock was released, so a peer lock-using writer could publish in the gap and the task recorded that peer's token as its own complete observation. Each guard now reads the token under the lock and returns it; a failed stat only skips the refresh. 3. safeWriteJson resolved the publish target before acquiring the lock, so a dangling link during a peer commit rejected the caller before it could queue. The lock key walk tolerates a dangling link and the strict resolution now happens under the lock. --- src/core/task-persistence/TaskHistoryStore.ts | 17 +--- src/core/tools/__tests__/guardedWrite.spec.ts | 53 ++++++++++++- src/core/tools/guardedWrite.ts | 53 ++++++++----- .../editor/__tests__/DiffViewProvider.spec.ts | 1 + src/services/file-safety/safeWriteText.ts | 24 ++++++ .../__tests__/safeWriteJson.lockKey.spec.ts | 78 +++++++++++++++++++ src/utils/safeWriteJson.ts | 29 +++---- 7 files changed, 207 insertions(+), 48 deletions(-) create mode 100644 src/utils/__tests__/safeWriteJson.lockKey.spec.ts diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index f423fd3a15..f1b0f09614 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -9,7 +9,7 @@ import { historyItemSchema, type HistoryItem } from "@roo-code/types" import { GlobalFileNames } from "../../shared/globalFileNames" import { LOCK_STALE_MS, withFileLock } from "../../utils/fileLock" import { safeWriteJson } from "../../utils/safeWriteJson" -import { resolvePublishTarget } from "../../services/file-safety/safeWriteText" +import { resolveLockKey } from "../../services/file-safety/safeWriteText" import { getStorageBasePath } from "../../utils/storage" import { assertValidTransition, settleRejectedCreateSubtaskAction, type HistoryItemStatus } from "./taskLifecycle" import { computeHistoryDelta, DeltaRejectedError, mergeHistoryDelta } from "./taskStoreConcurrency" @@ -335,20 +335,7 @@ export class TaskHistoryStore { * themselves to find the lock held at the referent. */ private async lockKeyFor(taskFilePath: string): Promise { - try { - return await resolvePublishTarget(taskFilePath) - } catch { - // A real readlink throws for anything that is not a link, so a normal chain - // ends the walk. Two links that point at each other never would, so the - // walk is bounded and the probe looks at the key it actually reached. - let key = taskFilePath - for (let depth = 0; depth < 8; depth++) { - const target = await fs.readlink(key).catch(() => undefined) - if (target === undefined) return key - key = path.resolve(path.dirname(key), target) - } - return key - } + return resolveLockKey(taskFilePath) } /** diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index ccf97d568d..ba2bdaebb5 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -18,6 +18,7 @@ import { createIfAbsent, guardedWrite, replaceIfVersion, resetChain } from "../g import { safeWriteText } from "../../../services/file-safety/safeWriteText" import { computeVersionToken } from "../../../utils/versionToken" import { withFileLock } from "../../../utils/fileLock" +import { resolveLockKey } from "../../../services/file-safety/safeWriteText" import { ObservationRegistry } from "../../task/observationRegistry" import type { Task } from "../../task/Task" @@ -34,6 +35,7 @@ vi.mock("../../../utils/versionToken", () => ({ vi.mock("../../../services/file-safety/safeWriteText", () => ({ safeWriteText: vi.fn(), + resolveLockKey: vi.fn(async (p: string) => p), })) vi.mock("../../../utils/fileLock", () => ({ @@ -41,6 +43,7 @@ vi.mock("../../../utils/fileLock", () => ({ })) const mockedWithFileLock = vi.mocked(withFileLock) +const mockedResolveLockKey = vi.mocked(resolveLockKey) const mockedFsAccess = vi.mocked(fs.access) const mockedComputeVersionToken = vi.mocked(computeVersionToken) const mockedSafeWriteText = vi.mocked(safeWriteText) @@ -658,7 +661,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await replaceIfVersion(abs("a.txt"), "v1", "content") - expect(order).toEqual(["lock", "check", "publish", "release"]) + expect(order).toEqual(["lock", "check", "publish", "check", "release"]) expect(mockedWithFileLock).toHaveBeenCalledWith(abs("a.txt"), expect.any(Function)) }) @@ -678,9 +681,55 @@ describe("guardedWrite (S4a, epic #1375)", () => { order.push("publish") }) + mockedComputeVersionToken.mockImplementation(async () => { + order.push("token") + return "v1" + }) + await createIfAbsent(abs("new.txt"), "hello") - expect(order).toEqual(["lock", "check", "publish", "release"]) + expect(order).toEqual(["lock", "check", "publish", "token", "release"]) + }) + + it("locks the resolved publish target instead of the link path", async () => { + // proper-lockfile keys by the path it is given, so a symlink alias and its + // referent would take two locks for one file. The guard must lock the key + // every other writer to that file uses. + const referent = abs("real/file.txt") + mockedResolveLockKey.mockResolvedValue(referent) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + + await createIfAbsent(abs("link.txt"), "hello") + + expect(mockedResolveLockKey).toHaveBeenCalledWith(abs("link.txt")) + expect(mockedWithFileLock).toHaveBeenCalledWith(referent, expect.any(Function)) + }) + + it("reads the post-publish token inside the lock, before releasing it", async () => { + // A peer lock-using writer can publish in the gap between the publish and + // a post-publish stat made after the lock is released, and the task would + // then record that peer's token as its own observation. + const order: string[] = [] + mockedWithFileLock.mockImplementation(async (filePath, operation) => { + order.push("lock") + const result = await operation(path.resolve(filePath)) + order.push("release") + return result + }) + mockedComputeVersionToken.mockImplementation(async () => { + order.push("check") + return "v1" + }) + mockedSafeWriteText.mockImplementation(async () => { + order.push("publish") + return undefined + }) + + const token = await replaceIfVersion(abs("a.txt"), "v1", "content") + + expect(order).toEqual(["lock", "check", "publish", "check", "release"]) + expect(token).toBe("v1") }) }) diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index ec23ca5f49..d1a0da5922 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -27,6 +27,7 @@ import * as path from "path" import { safeWriteText } from "../../services/file-safety/safeWriteText" import { computeVersionToken } from "../../utils/versionToken" import { withFileLock } from "../../utils/fileLock" +import { resolveLockKey } from "../../services/file-safety/safeWriteText" import type { Task } from "../task/Task" // -- Types ------------------------------------------------------------------ @@ -119,12 +120,19 @@ async function fileIsAbsent(absolutePath: string): Promise { * write was issued for a file that was never read, so the caller must read * the file first, then retry. */ -export async function createIfAbsent(absolutePath: string, content: string | Uint8Array): Promise { - // The absence check and the publish must happen under the same advisory - // lock every other writer to this path uses (safeWriteJson, task-history - // deletion), otherwise a lock-using writer can land between the check and - // the publish. - await withFileLock(absolutePath, async () => { +/** + * Read the on-disk token after a publish, best-effort: a publish that + * succeeded is not undone by a failed stat, so the caller keeps the publish + * and only skips the observation refresh. + */ +async function tokenAfterPublish(absolutePath: string): Promise { + return computeVersionToken(absolutePath).catch(() => undefined) +} + +export async function createIfAbsent(absolutePath: string, content: string | Uint8Array): Promise { + // Lock the key every other writer to this file uses: the resolved publish + // target, so a symlink alias and its referent share one lock. + return withFileLock(await resolveLockKey(absolutePath), async () => { try { await fs.access(absolutePath) } catch (error: unknown) { @@ -133,7 +141,10 @@ export async function createIfAbsent(absolutePath: string, content: string | Uin throw error } await safeWriteText(absolutePath, content) - return + // Read the new token under the same lock, otherwise a peer lock-using + // writer can publish in the gap and the caller records that writer's + // token as its own observation. + return tokenAfterPublish(absolutePath) } throw new GuardRejectedError( @@ -157,11 +168,10 @@ export async function replaceIfVersion( absolutePath: string, expectedVersion: string, content: string | Uint8Array, -): Promise { - // The compare and the publish are one operation under the shared advisory - // lock, so a lock-using writer (safeWriteJson, task-history deletion) cannot - // land between the version check and the publish. - await withFileLock(absolutePath, async () => { +): Promise { + // Lock the key every other writer to this file uses: the resolved publish + // target, so a symlink alias and its referent share one lock. + return withFileLock(await resolveLockKey(absolutePath), async () => { let currentVersion: string try { currentVersion = await computeVersionToken(absolutePath) @@ -184,7 +194,10 @@ export async function replaceIfVersion( if (currentVersion === expectedVersion) { await safeWriteText(absolutePath, content) - return + // Read the new token under the same lock, otherwise a peer lock-using + // writer can publish in the gap and the caller records that writer's + // token as its own observation. + return tokenAfterPublish(absolutePath) } throw new GuardRejectedError( @@ -257,6 +270,9 @@ export async function guardedWrite( // A targeted edit authorizes only the view the model saw, so a partial // observation stays partial; a full-file publish is complete. let staysPartial = false + // Token the guard read under the lock, so the refresh records the token + // this write published rather than a peer writer's. + let publishedToken: string | undefined if (kind === "edit") { // Edit-style writes require a prior read: no observation, no write. @@ -266,7 +282,7 @@ export async function guardedWrite( if (obs === undefined) { await unobservedEditGuard(absolutePath) } else { - await replaceIfVersion(absolutePath, obs.version, content) + publishedToken = await replaceIfVersion(absolutePath, obs.version, content) staysPartial = obs.complete === false } } else { @@ -290,14 +306,14 @@ export async function guardedWrite( if (obs === undefined) { // Never read: only an absent target may be created. - await createIfAbsent(absolutePath, content) + publishedToken = await createIfAbsent(absolutePath, content) } else if (absent) { // A "create" on a file that vanished after the read recreates it. - await createIfAbsent(absolutePath, content) + publishedToken = await createIfAbsent(absolutePath, content) } else { // The version recorded at read time must still match the on-disk // token. - await replaceIfVersion(absolutePath, obs.version, content) + publishedToken = await replaceIfVersion(absolutePath, obs.version, content) } } @@ -305,7 +321,8 @@ export async function guardedWrite( // mtime). The model just wrote the full content, so refresh the // observation with the new token: a consecutive write by the same task // must not fail stale against the version it just published. - const publishedToken = await computeVersionToken(absolutePath).catch(() => undefined) + // The guard already read the token under the lock; a failed stat after a + // successful publish only skips the refresh, it does not undo the publish. if (publishedToken !== undefined) { // Refresh with the new token, keeping the completeness the guard // established: a partial observation that authorized a targeted edit diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index ab984ad457..b77ea184ed 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -35,6 +35,7 @@ vi.mock("fs/promises", () => ({ // Mock safeWriteText (used by saveDirectly) vi.mock("../../../services/file-safety/safeWriteText", () => ({ safeWriteText: vi.fn().mockResolvedValue(undefined), + resolveLockKey: vi.fn(async (p: string) => p), })) // Mock the S1 version token (used by the S4 guarded write); the real diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 3c6c8f47da..8648f586d6 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -144,6 +144,30 @@ export async function resolvePublishTarget(absoluteFilePath: string): Promise { + try { + return await resolvePublishTarget(absoluteFilePath) + } catch { + // A real readlink throws for anything that is not a link, so a normal chain + // ends the walk. Two links that point at each other never would, so the + // walk is bounded and callers use the key they actually reached. + let key = absoluteFilePath + for (let depth = 0; depth < 8; depth++) { + const target = await fs.readlink(key).catch(() => undefined) + if (target === undefined) return key + key = path.resolve(path.dirname(key), target) + } + return key + } +} + export async function safeWriteText( filePath: string, content: string | Uint8Array, diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts new file mode 100644 index 0000000000..e823b39fd0 --- /dev/null +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -0,0 +1,78 @@ +// npx vitest run utils/__tests__/safeWriteJson.lockKey.spec.ts + +import * as os from "os" +import path from "path" +import * as fs from "fs/promises" +import { acquireFileLock } from "../fileLock" +import { safeWriteJson } from "../safeWriteJson" + +vi.mock("../fileLock", () => ({ + acquireFileLock: vi.fn(async () => async () => {}), +})) + +vi.mock("fs/promises", async () => { + const actual = await vi.importActual("fs/promises") + return { + ...actual, + realpath: vi.fn(), + lstat: vi.fn(), + readlink: vi.fn(), + } +}) + +const mockedRealpath = vi.mocked(fs.realpath) +const mockedLstat = vi.mocked(fs.lstat) +const mockedReadlink = vi.mocked(fs.readlink) +const mockedAcquireFileLock = vi.mocked(acquireFileLock) + +beforeEach(() => { + vi.mocked(acquireFileLock).mockImplementation(async () => async () => {}) + mockedRealpath.mockImplementation((p) => actualRealpath(p as string)) + mockedLstat.mockImplementation((p) => actualLstat(p as string)) + mockedReadlink.mockImplementation(async (p) => (p === link ? referent : Promise.reject(new Error("not a link")))) +}) + +const actualRealpath = (p: string) => fs.realpath(p) +const actualLstat = (p: string) => fs.lstat(p) +const actualReadlink = (p: string) => fs.readlink(p) + +describe("safeWriteJson lock key under a peer commit", () => { + it("waits for the peer instead of rejecting, and locks the referent", async () => { + const order: string[] = [] + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lockkey-")) + const referent = path.join(dir, "history_item.json") + const link = path.join(dir, "link.json") + + // A dangling link: the peer writer has renamed the referent away and has + // not committed yet, so the first resolution throws ENOENT while lstat + // still reports a symbolic link. A strict resolve here rejects the caller + // before it can ever take the lock, so the delta write is lost. + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + mockedRealpath + .mockImplementationOnce(() => { + order.push("resolve-failed") + throw enoent + }) + .mockImplementation(async (p) => { + order.push("resolve") + return p === link ? referent : (p as string) + }) + mockedLstat.mockImplementation(async (p) => ({ + isSymbolicLink: () => p === link, + })) + mockedReadlink.mockImplementation(async (p) => (p === link ? referent : actualReadlink(p as string))) + mockedAcquireFileLock.mockImplementation(async (lockPath) => { + order.push("lock") + expect(lockPath).toBe(referent) + return async () => {} + }) + + await safeWriteJson(link, { id: "task-1" }) + + // The lock key is the key every other writer to this file uses, so the + // caller queued behind the peer instead of failing before the lock. + expect(order).toEqual(["resolve-failed", "lock", "resolve", "resolve"]) + const committed = JSON.parse(await fs.readFile(referent, "utf8")) + expect(committed).toEqual({ id: "task-1" }) + }) +}) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index dd57dbf3db..8b4fa2d416 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -4,7 +4,12 @@ import * as path from "path" import { JsonStreamStringify } from "json-stream-stringify" import { acquireFileLock } from "./fileLock" -import { resolvePublishTarget, safeWriteText, type SafeWriteTextOptions } from "../services/file-safety/safeWriteText" +import { + resolveLockKey, + resolvePublishTarget, + safeWriteText, + type SafeWriteTextOptions, +} from "../services/file-safety/safeWriteText" /** * Options for safeWriteJson function @@ -59,22 +64,20 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso throw dirError } - // Resolve the publish target BEFORE acquiring the lock: proper-lockfile keys - // the lock by the given path (realpath is false in acquireFileLock because the - // file may not exist yet), so a symlink alias and its referent would otherwise - // take two distinct locks for one underlying file — a concurrent merge through - // both aliases could then read the same JSON and overwrite one update. - // Locking the resolved referent coordinates every alias through one lock, and - // acquireFileLock keeps the staleness/retry protocol identical to every other - // holder of the same advisory lock (for example task-history deletion). - // resolvePublishTarget tolerates a not-yet-existing file (it returns the - // given path on ENOENT), preserving the previous create-from-absent flow. - const resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) + // Lock key: the symlink referent when the path is an existing symlink, so a + // symlink alias and its referent share one lock. The key must be computable + // while a peer writer is mid-commit (backup mode renames the referent away and + // back), so the walk tolerates a dangling link instead of rejecting it here. + const lockKey = await resolveLockKey(absoluteFilePath) // Acquire the lock before any file operations. If acquisition fails it throws // immediately, and releaseLock stays a no-op so the finally block does not try // to release an unacquired lock. - releaseLock = await acquireFileLock(resolvedTargetPath) + releaseLock = await acquireFileLock(lockKey) + + // Resolve the publish target under the lock: the peer has committed by now, so + // the strict dangling-link rejection still applies to a real dangling link. + const resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) // Variables to hold the actual path of the temp file if it is created. let actualTempNewFilePath: string | null = null From a55720efd214cc004261ac6fa23b4e0c4bd4883f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 12:33:34 +0800 Subject: [PATCH 067/100] test(file-safety): keep the lock-key spec portable and type-clean The first version created a real symlink, which fails on the Windows runner (EPERM), and its lstat double did not satisfy the Stats shape, which failed check-types. The doubles are now in scope for the test and the symlink shape is asserted through the mock, so the same test runs on both runners. --- .../__tests__/safeWriteJson.lockKey.spec.ts | 51 +++++++------------ 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index e823b39fd0..4ce08ecf1e 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -2,6 +2,7 @@ import * as os from "os" import path from "path" +import type { BigIntStats } from "fs" import * as fs from "fs/promises" import { acquireFileLock } from "../fileLock" import { safeWriteJson } from "../safeWriteJson" @@ -12,12 +13,7 @@ vi.mock("../fileLock", () => ({ vi.mock("fs/promises", async () => { const actual = await vi.importActual("fs/promises") - return { - ...actual, - realpath: vi.fn(), - lstat: vi.fn(), - readlink: vi.fn(), - } + return { ...actual, realpath: vi.fn(), lstat: vi.fn(), readlink: vi.fn() } }) const mockedRealpath = vi.mocked(fs.realpath) @@ -25,17 +21,6 @@ const mockedLstat = vi.mocked(fs.lstat) const mockedReadlink = vi.mocked(fs.readlink) const mockedAcquireFileLock = vi.mocked(acquireFileLock) -beforeEach(() => { - vi.mocked(acquireFileLock).mockImplementation(async () => async () => {}) - mockedRealpath.mockImplementation((p) => actualRealpath(p as string)) - mockedLstat.mockImplementation((p) => actualLstat(p as string)) - mockedReadlink.mockImplementation(async (p) => (p === link ? referent : Promise.reject(new Error("not a link")))) -}) - -const actualRealpath = (p: string) => fs.realpath(p) -const actualLstat = (p: string) => fs.lstat(p) -const actualReadlink = (p: string) => fs.readlink(p) - describe("safeWriteJson lock key under a peer commit", () => { it("waits for the peer instead of rejecting, and locks the referent", async () => { const order: string[] = [] @@ -43,36 +28,36 @@ describe("safeWriteJson lock key under a peer commit", () => { const referent = path.join(dir, "history_item.json") const link = path.join(dir, "link.json") - // A dangling link: the peer writer has renamed the referent away and has - // not committed yet, so the first resolution throws ENOENT while lstat - // still reports a symbolic link. A strict resolve here rejects the caller - // before it can ever take the lock, so the delta write is lost. + // The peer writer has renamed the referent away and has not committed yet, + // so the first resolution fails with ENOENT while lstat still reports a + // symbolic link. A strict resolve here rejects the caller before it can ever + // queue behind the peer, and the caller's delta write is lost. const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) mockedRealpath .mockImplementationOnce(() => { order.push("resolve-failed") throw enoent }) - .mockImplementation(async (p) => { + .mockImplementation(async (target) => { order.push("resolve") - return p === link ? referent : (p as string) + // The second call happens under the lock, where the peer has committed. + return target === link ? referent : String(target) }) - mockedLstat.mockImplementation(async (p) => ({ - isSymbolicLink: () => p === link, - })) - mockedReadlink.mockImplementation(async (p) => (p === link ? referent : actualReadlink(p as string))) - mockedAcquireFileLock.mockImplementation(async (lockPath) => { + // Only isSymbolicLink() is consulted by the guard, so the double carries + // just that method. + mockedLstat.mockImplementation(async (target) => ({ isSymbolicLink: () => target === link } as unknown as BigIntStats)) + mockedReadlink.mockImplementation(async (target) => (target === link ? referent : Promise.reject(new Error("not a link")))) + mockedAcquireFileLock.mockImplementation(async () => { order.push("lock") - expect(lockPath).toBe(referent) return async () => {} }) await safeWriteJson(link, { id: "task-1" }) - // The lock key is the key every other writer to this file uses, so the - // caller queued behind the peer instead of failing before the lock. + // The lock key is the key every other writer to this file uses, so the caller + // queued behind the peer instead of failing before the lock. + expect(mockedAcquireFileLock).toHaveBeenCalledWith(referent) expect(order).toEqual(["resolve-failed", "lock", "resolve", "resolve"]) - const committed = JSON.parse(await fs.readFile(referent, "utf8")) - expect(committed).toEqual({ id: "task-1" }) + expect(JSON.parse(await fs.readFile(referent, "utf8"))).toEqual({ id: "task-1" }) }) }) From e8d6e6684bd77e9c36b222cc64b36666a2be7fd7 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 12:33:43 +0800 Subject: [PATCH 068/100] style(file-safety): format the lock-key spec --- src/utils/__tests__/safeWriteJson.lockKey.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 4ce08ecf1e..2696bf3229 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -45,8 +45,12 @@ describe("safeWriteJson lock key under a peer commit", () => { }) // Only isSymbolicLink() is consulted by the guard, so the double carries // just that method. - mockedLstat.mockImplementation(async (target) => ({ isSymbolicLink: () => target === link } as unknown as BigIntStats)) - mockedReadlink.mockImplementation(async (target) => (target === link ? referent : Promise.reject(new Error("not a link")))) + mockedLstat.mockImplementation( + async (target) => ({ isSymbolicLink: () => target === link }) as unknown as BigIntStats, + ) + mockedReadlink.mockImplementation(async (target) => + target === link ? referent : Promise.reject(new Error("not a link")), + ) mockedAcquireFileLock.mockImplementation(async () => { order.push("lock") return async () => {} From 762451956387c6e36f7cf3640f040add5166fa64 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 12:55:08 +0800 Subject: [PATCH 069/100] fix(file-safety): keep the resolution inside the protected block so the lock is released resolvePublishTarget ran after acquireFileLock but before the try/finally, so a real dangling link rejected the caller while the advisory lock stayed held until the stale timeout for every other writer to the same file. The resolution now runs inside the protected block; resolvedTargetPath is declared outside it so the catch and finally can still name the target when the resolution itself rejects. New test asserts the lock is released on that rejection (order lock -> release). Binary check: moving the resolution back outside the try fails it. --- .../__tests__/safeWriteJson.lockKey.spec.ts | 36 ++++++++++++++++++- src/utils/safeWriteJson.ts | 21 +++++++---- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 2696bf3229..d5c7386bf2 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -21,6 +21,8 @@ const mockedLstat = vi.mocked(fs.lstat) const mockedReadlink = vi.mocked(fs.readlink) const mockedAcquireFileLock = vi.mocked(acquireFileLock) +const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + describe("safeWriteJson lock key under a peer commit", () => { it("waits for the peer instead of rejecting, and locks the referent", async () => { const order: string[] = [] @@ -32,7 +34,6 @@ describe("safeWriteJson lock key under a peer commit", () => { // so the first resolution fails with ENOENT while lstat still reports a // symbolic link. A strict resolve here rejects the caller before it can ever // queue behind the peer, and the caller's delta write is lost. - const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) mockedRealpath .mockImplementationOnce(() => { order.push("resolve-failed") @@ -64,4 +65,37 @@ describe("safeWriteJson lock key under a peer commit", () => { expect(order).toEqual(["resolve-failed", "lock", "resolve", "resolve"]) expect(JSON.parse(await fs.readFile(referent, "utf8"))).toEqual({ id: "task-1" }) }) + + it("releases the lock when the resolution under the lock rejects", async () => { + const order: string[] = [] + let released = false + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lockkey-")) + const referent = path.join(dir, "history_item.json") + const link = path.join(dir, "link.json") + + // A real dangling link: the walk tolerates it so the caller can queue behind + // the peer, but once the lock is held the strict rejection still applies. A + // rejection outside the protected block would leave the lock held until the + // stale timeout for every other writer to the same file. + mockedRealpath.mockImplementation(() => { + throw enoent + }) + mockedLstat.mockImplementation( + async (target) => ({ isSymbolicLink: () => target === link }) as unknown as BigIntStats, + ) + mockedReadlink.mockImplementation(async (target) => + target === link ? referent : Promise.reject(new Error("not a link")), + ) + mockedAcquireFileLock.mockImplementation(async () => { + order.push("lock") + return async () => { + order.push("release") + released = true + } + }) + + await expect(safeWriteJson(link, { id: "task-1" })).rejects.toThrow(enoent) + expect(released).toBe(true) + expect(order).toEqual(["lock", "release"]) + }) }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 8b4fa2d416..753b501852 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -56,6 +56,10 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso const dirPath = path.dirname(absoluteFilePath) // Ensure directory structure exists with improved reliability + // Declared outside the protected block so the catch and finally can still name + // the target when the resolution itself rejects. + let resolvedTargetPath: string | undefined + try { await fs.mkdir(dirPath, { recursive: true }) await fs.access(dirPath) @@ -75,14 +79,16 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // to release an unacquired lock. releaseLock = await acquireFileLock(lockKey) - // Resolve the publish target under the lock: the peer has committed by now, so - // the strict dangling-link rejection still applies to a real dangling link. - const resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) - // Variables to hold the actual path of the temp file if it is created. let actualTempNewFilePath: string | null = null try { + // Resolve the publish target under the lock: the peer has committed by now, so + // the strict dangling-link rejection still applies to a real dangling link. It + // must stay inside the protected block, otherwise a rejection here leaves the + // advisory lock held until the stale timeout for every other writer. + resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) + // If a merge callback was provided, read the current file under the lock // and let the caller merge before we write. Must be inside try/finally // so a throwing merge still releases the lock. @@ -128,7 +134,10 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // backup has already been handled by safeWriteText. actualTempNewFilePath = null } catch (originalError) { - console.error(`Operation failed for ${resolvedTargetPath}: [Original Error Caught]`, originalError) + console.error( + `Operation failed for ${resolvedTargetPath ?? absoluteFilePath}: [Original Error Caught]`, + originalError, + ) const newFileToCleanupWithinCatch = actualTempNewFilePath @@ -153,7 +162,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso try { await releaseLock() } catch (unlockError) { - console.error(`Failed to release lock for ${resolvedTargetPath}:`, unlockError) + console.error(`Failed to release lock for ${resolvedTargetPath ?? absoluteFilePath}:`, unlockError) } } } From a5ec999e38033b962805d87309cafa1c0cf4dfda Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 12:55:31 +0800 Subject: [PATCH 070/100] test(file-safety): exercise the ENOENT+symlink branch in the lock-key spec A synchronous throw in the first realpath mock bypassed resolvePublishTarget's catch, so the walk went straight to readlink without the symlink check. The mock now rejects asynchronously and the test asserts lstat was consulted, so the branch that decides the lock key is actually under test. --- src/utils/__tests__/safeWriteJson.lockKey.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index d5c7386bf2..4fc76e0548 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -35,7 +35,7 @@ describe("safeWriteJson lock key under a peer commit", () => { // symbolic link. A strict resolve here rejects the caller before it can ever // queue behind the peer, and the caller's delta write is lost. mockedRealpath - .mockImplementationOnce(() => { + .mockImplementationOnce(async () => { order.push("resolve-failed") throw enoent }) From e14d72f4261649890ee1468b6cea36c86aa4d048 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 12:55:45 +0800 Subject: [PATCH 071/100] test(file-safety): assert the symlink branch the lock key depends on --- src/utils/__tests__/safeWriteJson.lockKey.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 4fc76e0548..9620c23e89 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -61,6 +61,9 @@ describe("safeWriteJson lock key under a peer commit", () => { // The lock key is the key every other writer to this file uses, so the caller // queued behind the peer instead of failing before the lock. + // The async rejection is what lets the ENOENT branch run, so the symlink + // check is part of the path under test rather than skipped by a sync throw. + expect(mockedLstat).toHaveBeenCalledWith(link) expect(mockedAcquireFileLock).toHaveBeenCalledWith(referent) expect(order).toEqual(["resolve-failed", "lock", "resolve", "resolve"]) expect(JSON.parse(await fs.readFile(referent, "utf8"))).toEqual({ id: "task-1" }) From 206703d0e6549087f990ae4b71c6a9de2c18d7f8 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 13:12:17 +0800 Subject: [PATCH 072/100] test(file-safety): reach the strict rejection through the ENOENT+symlink branch A synchronous throw in the realpath mock bypasses resolvePublishTarget's catch, so the release assertion could pass on that error alone without the symlink check. Both mocks now reject asynchronously and the order assertion includes the lstat calls, so the branch that decides the outcome is the one under test. --- .../__tests__/safeWriteJson.lockKey.spec.ts | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 9620c23e89..f66be988b1 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -23,12 +23,18 @@ const mockedAcquireFileLock = vi.mocked(acquireFileLock) const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) +// Only isSymbolicLink() is consulted by the guard, so the double carries just +// that method. The mocks reject asynchronously: a synchronous throw would bypass +// resolvePublishTarget's catch and skip the ENOENT/symlink branch under test. +const symlinkStat = (target: unknown) => ({ isSymbolicLink: () => target === currentLink }) as unknown as BigIntStats +let currentLink = "" + describe("safeWriteJson lock key under a peer commit", () => { it("waits for the peer instead of rejecting, and locks the referent", async () => { const order: string[] = [] const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lockkey-")) const referent = path.join(dir, "history_item.json") - const link = path.join(dir, "link.json") + currentLink = path.join(dir, "link.json") // The peer writer has renamed the referent away and has not committed yet, // so the first resolution fails with ENOENT while lstat still reports a @@ -42,30 +48,26 @@ describe("safeWriteJson lock key under a peer commit", () => { .mockImplementation(async (target) => { order.push("resolve") // The second call happens under the lock, where the peer has committed. - return target === link ? referent : String(target) + return target === currentLink ? referent : String(target) }) - // Only isSymbolicLink() is consulted by the guard, so the double carries - // just that method. - mockedLstat.mockImplementation( - async (target) => ({ isSymbolicLink: () => target === link }) as unknown as BigIntStats, - ) + mockedLstat.mockImplementation(async (target) => { + order.push("lstat") + return symlinkStat(target) + }) mockedReadlink.mockImplementation(async (target) => - target === link ? referent : Promise.reject(new Error("not a link")), + target === currentLink ? referent : Promise.reject(new Error("not a link")), ) mockedAcquireFileLock.mockImplementation(async () => { order.push("lock") return async () => {} }) - await safeWriteJson(link, { id: "task-1" }) + await safeWriteJson(currentLink, { id: "task-1" }) // The lock key is the key every other writer to this file uses, so the caller // queued behind the peer instead of failing before the lock. - // The async rejection is what lets the ENOENT branch run, so the symlink - // check is part of the path under test rather than skipped by a sync throw. - expect(mockedLstat).toHaveBeenCalledWith(link) expect(mockedAcquireFileLock).toHaveBeenCalledWith(referent) - expect(order).toEqual(["resolve-failed", "lock", "resolve", "resolve"]) + expect(order).toEqual(["resolve-failed", "lstat", "lock", "resolve", "resolve"]) expect(JSON.parse(await fs.readFile(referent, "utf8"))).toEqual({ id: "task-1" }) }) @@ -74,20 +76,21 @@ describe("safeWriteJson lock key under a peer commit", () => { let released = false const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lockkey-")) const referent = path.join(dir, "history_item.json") - const link = path.join(dir, "link.json") + currentLink = path.join(dir, "link.json") // A real dangling link: the walk tolerates it so the caller can queue behind // the peer, but once the lock is held the strict rejection still applies. A // rejection outside the protected block would leave the lock held until the // stale timeout for every other writer to the same file. - mockedRealpath.mockImplementation(() => { + mockedRealpath.mockImplementation(async () => { throw enoent }) - mockedLstat.mockImplementation( - async (target) => ({ isSymbolicLink: () => target === link }) as unknown as BigIntStats, - ) + mockedLstat.mockImplementation(async (target) => { + order.push("lstat") + return symlinkStat(target) + }) mockedReadlink.mockImplementation(async (target) => - target === link ? referent : Promise.reject(new Error("not a link")), + target === currentLink ? referent : Promise.reject(new Error("not a link")), ) mockedAcquireFileLock.mockImplementation(async () => { order.push("lock") @@ -97,8 +100,10 @@ describe("safeWriteJson lock key under a peer commit", () => { } }) - await expect(safeWriteJson(link, { id: "task-1" })).rejects.toThrow(enoent) + await expect(safeWriteJson(currentLink, { id: "task-1" })).rejects.toThrow(enoent) expect(released).toBe(true) - expect(order).toEqual(["lock", "release"]) + // The strict rejection is reached through the ENOENT + symlink branch, not + // through a synchronous throw that skips it. + expect(order).toEqual(["lstat", "lock", "lstat", "release"]) }) }) From 35d94be676fedaff8b3f21340bef3d14898b3b1f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 13:31:56 +0800 Subject: [PATCH 073/100] fix(file-safety): canonicalize the lock key when the target itself is not there yet fs.realpath canonicalizes every component, including a symlinked ancestor directory or a Windows 8.3 short name. The ENOENT fallback returned the path unchanged, so the lock key for one file depended on whether the file existed when the key was computed: a writer that resolved the canonical directory and a writer that took the fallback locked two different keys for the same file, and reconcile() probed the wrong lock. resolveLockKey now canonicalizes the parent directory on every path it returns, and TaskHistoryStore.lockKeyFor goes through the same helper. The realpath doubles in the two affected suites resolve the file only, so the directory stays canonical. New test asserts the key is / when realpath rejects the file with ENOENT and lstat reports no symlink. --- .../TaskHistoryStore.deleteSemantics.spec.ts | 28 +++++++--- src/services/file-safety/safeWriteText.ts | 51 +++++++++---------- .../__tests__/safeWriteJson.lockKey.spec.ts | 21 +++++++- src/utils/__tests__/safeWriteJson.test.ts | 12 ++++- 4 files changed, 77 insertions(+), 35 deletions(-) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index 4bf36e5c53..6fb938e06f 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -186,7 +186,11 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { // Real symlinks are unavailable in this CI lane, so the alias is // simulated through realpath, as in the safeWriteJson lock test. - const realpathSpy = vi.spyOn(fs, "realpath").mockResolvedValue(referentPath) + // Only the file resolves through the alias; the directory is already canonical, + // so canonicalDirKey leaves it unchanged and the key stays the referent. + const realpathSpy = vi + .spyOn(fs, "realpath") + .mockImplementation(async (target) => (target === aliasPath ? referentPath : String(target))) try { await expect(store.delete("alias-del")).resolves.toBeUndefined() } finally { @@ -211,9 +215,13 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { await store.upsert(makeHistoryItem({ id: "alias-dangling" })) const aliasPath = historyFilePath(storagePath, "alias-dangling") const referentPath = path.join(storagePath, "tasks", "alias-dangling", "referent-history.json") - const realpathSpy = vi - .spyOn(fs, "realpath") - .mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + // Only the file resolves through the alias; the directory is already canonical, + // so canonicalDirKey leaves it unchanged and the key stays the referent. + const enoent = Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + const realpathSpy = vi.spyOn(fs, "realpath").mockImplementation(async (target) => { + if (target === aliasPath) throw enoent + return String(target) + }) const lstatSpy = vi .spyOn(fs, "lstat") .mockResolvedValue({ isSymbolicLink: () => true } as unknown as import("fs").Stats) @@ -244,7 +252,11 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { // Real symlinks are unavailable in this CI lane, so the alias is // simulated through realpath, as in the safeWriteJson lock test. - const realpathSpy = vi.spyOn(fs, "realpath").mockResolvedValue(referentPath) + // Only the file resolves through the alias; the directory is already canonical, + // so canonicalDirKey leaves it unchanged and the key stays the referent. + const realpathSpy = vi + .spyOn(fs, "realpath") + .mockImplementation(async (target) => (target === aliasPath ? referentPath : String(target))) try { // The rename window: the referent is momentarily missing, so the cached // file cannot be stat'd while the peer holds the lock at the key it locks. @@ -476,7 +488,11 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { const aliasPath = historyFilePath(storagePath, "alias-batch") const referentPath = path.join(storagePath, "tasks", "alias-batch", "referent-history.json") - const realpathSpy = vi.spyOn(fs, "realpath").mockResolvedValue(referentPath) + // Only the file resolves through the alias; the directory is already canonical, + // so canonicalDirKey leaves it unchanged and the key stays the referent. + const realpathSpy = vi + .spyOn(fs, "realpath") + .mockImplementation(async (target) => (target === aliasPath ? referentPath : String(target))) try { await expect(store.deleteMany(["alias-batch"])).resolves.toBeUndefined() } finally { diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 8648f586d6..22318b8c9b 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -3,9 +3,6 @@ import * as fsSync from "fs" import * as path from "path" import { execFile } from "child_process" -/** - * Options for safeWriteText atomic text publish primitive. - */ export interface SafeWriteTextOptions { /** * When true, preserve the old-file semantics: rename target -> backup first, @@ -64,11 +61,6 @@ function _stagingDir(dir: string): string { return sd } -/** - * fsync a file descriptor so its data is durable before the atomic rename. - * Uses the sync form because this repo's @types/node does not declare - * fs.promises.fsync; the staging file is small, so the blocking window is bounded. - */ function _fsyncFile(fd: number): void { fsSync.fsyncSync(fd) } @@ -107,20 +99,6 @@ async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRu // -- public API ------------------------------------------------------------ -/** - * Atomic text publish primitive. - * - * 1. Write content to a temp file in a private per-write staging subdir - * (same volume -> atomic rename guaranteed). - * 2. fsync the temp file, then close it. - * 3. win32 only: if target exists save its DACL dump BEFORE backup rename. - * 4. Optionally rename target -> backup (when backup:true). - * 5. Atomic rename temp -> target. - * 6. win32 only: restore DACL onto the directory AFTER commit rename. - * 7. On success: delete backup (if any) and unlink DACL dump. - * 8. On failure: rollback backup to target path; clean up temp + dump. - */ - /** * Resolve the publish target: the symlink referent when the given path is an * existing symlink, the path itself otherwise. Only ENOENT (target absent yet) @@ -151,9 +129,30 @@ export async function resolvePublishTarget(absoluteFilePath: string): Promise { + const dirPath = path.dirname(absoluteFilePath) + const canonicalDir = await fs.realpath(dirPath).catch(() => dirPath) + return path.join(canonicalDir, path.basename(absoluteFilePath)) +} + +/** + * Lock key for a publish target: the symlink referent when the path is an + * existing symlink, the path itself otherwise. Unlike resolvePublishTarget this + * tolerates a dangling link, because the lock key has to be computable while a + * peer writer is mid-commit (backup mode renames the referent away and back). + * The walk is bounded so a two-link cycle terminates, and every key it returns is + * canonicalized through canonicalDirKey. + */ export async function resolveLockKey(absoluteFilePath: string): Promise { try { - return await resolvePublishTarget(absoluteFilePath) + return await canonicalDirKey(await resolvePublishTarget(absoluteFilePath)) } catch { // A real readlink throws for anything that is not a link, so a normal chain // ends the walk. Two links that point at each other never would, so the @@ -161,10 +160,10 @@ export async function resolveLockKey(absoluteFilePath: string): Promise let key = absoluteFilePath for (let depth = 0; depth < 8; depth++) { const target = await fs.readlink(key).catch(() => undefined) - if (target === undefined) return key - key = path.resolve(path.dirname(key), target) + if (target === undefined) return await canonicalDirKey(key) + key = await canonicalDirKey(path.resolve(path.dirname(key), target)) } - return key + return await canonicalDirKey(key) } } diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index f66be988b1..3d02c3c7a9 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -6,6 +6,7 @@ import type { BigIntStats } from "fs" import * as fs from "fs/promises" import { acquireFileLock } from "../fileLock" import { safeWriteJson } from "../safeWriteJson" +import { resolveLockKey } from "../../services/file-safety/safeWriteText" vi.mock("../fileLock", () => ({ acquireFileLock: vi.fn(async () => async () => {}), @@ -67,7 +68,7 @@ describe("safeWriteJson lock key under a peer commit", () => { // The lock key is the key every other writer to this file uses, so the caller // queued behind the peer instead of failing before the lock. expect(mockedAcquireFileLock).toHaveBeenCalledWith(referent) - expect(order).toEqual(["resolve-failed", "lstat", "lock", "resolve", "resolve"]) + expect(order).toEqual(["resolve-failed", "lstat", "resolve", "resolve", "lock", "resolve", "resolve"]) expect(JSON.parse(await fs.readFile(referent, "utf8"))).toEqual({ id: "task-1" }) }) @@ -106,4 +107,22 @@ describe("safeWriteJson lock key under a peer commit", () => { // through a synchronous throw that skips it. expect(order).toEqual(["lstat", "lock", "lstat", "release"]) }) + + it("canonicalizes the parent directory when the file itself is not there yet", async () => { + // fs.realpath canonicalizes every component, including a symlinked ancestor + // directory or a Windows 8.3 short name. If the fallback returns the alias + // directory, the key depends on whether the file exists at the moment the key + // is computed, and a writer that resolved the canonical directory takes a + // different lock for the same file. + const aliasDir = path.join(os.tmpdir(), "alias-dir") + const canonicalDir = path.join(os.tmpdir(), "canonical-dir") + const file = path.join(aliasDir, "history_item.json") + mockedRealpath.mockImplementation(async (target) => { + if (target === file) throw enoent + return canonicalDir + }) + mockedLstat.mockImplementation(async () => ({ isSymbolicLink: () => false }) as unknown as BigIntStats) + + expect(await resolveLockKey(file)).toBe(path.join(canonicalDir, "history_item.json")) + }) }) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 01da62993a..868b1a7cf1 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -566,7 +566,11 @@ describe("safeWriteJson", () => { // backup, and the commit all target the resolved referent. await fsPromisesActuals.writeFile!(referentPath, JSON.stringify({ seed: true })) - vi.spyOn(fs, "realpath").mockResolvedValue(referentPath) + // Only the file resolves through the link; the directory is already canonical, + // so the lock key is the referent rather than the alias directory + basename. + vi.spyOn(fs, "realpath").mockImplementation(async (target) => + target === callerPath ? referentPath : String(target), + ) await safeWriteJson(callerPath, { after: true }) @@ -597,7 +601,11 @@ describe("safeWriteJson", () => { const referentPath = path.join(referentDir, "locked.json") await fsPromisesActuals.writeFile!(referentPath, JSON.stringify({ seed: 1 })) - const realpathSpy = vi.spyOn(fs, "realpath").mockResolvedValue(referentPath) + // Only the file resolves through the link; the directory is already canonical, + // so the lock key is the referent rather than the alias directory + basename. + const realpathSpy = vi + .spyOn(fs, "realpath") + .mockImplementation(async (target) => (target === callerPath ? referentPath : String(target))) // Wrap the real lock in a capturing mock, and drive the two rare error paths // (the onCompromised callback and a failing release) so they stay covered From 945460bb072ab451bdbf783ee3d8ed14575f91c7 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 13:50:25 +0800 Subject: [PATCH 074/100] test(read-file): drop a no-op any cast and prune its suppression The cast was applied to the result of mockResolvedValue, which is never used, so it did nothing except add a new any to the spec. Removing it lowers the file's no-explicit-any count from 97 to 96. --- src/core/tools/__tests__/readFileTool.spec.ts | 2 +- src/eslint-suppressions.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index d11183c9e7..ef96ff6da4 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -860,7 +860,7 @@ describe("ReadFileTool", () => { mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) // fs.readFile with "utf8" encoding returns a string, not a Buffer - mockedFsReadFile.mockResolvedValue(Buffer.from("line1\nline2\nline3\nline4\nline5")) as any + mockedFsReadFile.mockResolvedValue(Buffer.from("line1\nline2\nline3\nline4\nline5")) await readFileTool.execute( { files: [{ path: "test.ts", lineRanges: [{ start: 2, end: 4 }] }] } as any, diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index d0a6f2b41e..0f1a1f6dc2 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -976,7 +976,7 @@ }, "core/tools/__tests__/readFileTool.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 97 + "count": 96 } }, "core/tools/__tests__/runSlashCommandTool.spec.ts": { From b0a78f485ea37cdd6ba43016fc8a3cb87bbab117 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 14:08:47 +0800 Subject: [PATCH 075/100] fix(editor): a rejected save closes only its own diff tab closeAllDiffViews() closes every clean diff tab in the workbench. A rejected guarded save belongs to one task, so the cleanup was closing other tasks' diff views while those tasks' providers still held their activation listener and deferred scroll timer against a tab that no longer exists. The rejection cleanup now closes only the tab whose modified side is this provider's file. New test puts two diff tabs in the workbench - this provider's and another task's - and asserts only the first is closed. --- src/integrations/editor/DiffViewProvider.ts | 29 ++++++++- .../editor/__tests__/DiffViewProvider.spec.ts | 60 ++++++++++++++++--- 2 files changed, 79 insertions(+), 10 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index eff53c0dff..6db3dbf44c 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -584,7 +584,7 @@ export class DiffViewProvider { } } } - await this.closeAllDiffViews() + await this.closeOwnDiffView(absolutePath) } catch { // cleanup is best-effort; the guard verdict below is the outcome } @@ -885,6 +885,33 @@ export class DiffViewProvider { await Promise.all(closeOps) } + /** + * Close only this provider's diff tab. closeAllDiffViews() closes every clean + * diff tab in the workbench, so a rejected save would also close another task's + * diff view while that task's provider still holds its activation listener and + * deferred scroll timer against a tab that is gone. A rejection belongs to one + * task, so the cleanup must stay inside that task's view. + */ + private async closeOwnDiffView(absolutePath: string): Promise { + const target = path.resolve(absolutePath) + const tabs = vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .filter( + (tab) => + tab.input instanceof vscode.TabInputTextDiff && + tab.input.original.scheme === DIFF_VIEW_URI_SCHEME && + path.resolve(tab.input.modified.fsPath) === target && + !tab.isDirty, + ) + for (const tab of tabs) { + try { + await vscode.window.tabGroups.close(tab) + } catch { + // best-effort: the tab stays open + } + } + } + // Stop tracking user activation of the target file. Called before any // programmatic showTextDocument so our own re-show never counts as a "touch". private disposeActiveEditorListener(): void { diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index b77ea184ed..d84d526f87 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -843,6 +843,47 @@ describe("DiffViewProvider", () => { }) }) + // A rejected save belongs to one task. closeAllDiffViews() closes every clean + // diff tab in the workbench, so it would close another task's diff view while + // that task's provider still holds its activation listener and deferred scroll + // timer against a tab that is gone. + describe("closeOwnDiffView method", () => { + it("closes only this provider's tab and leaves another task's diff view open", async () => { + const ownTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + isDirty: false, + } + const otherTaskTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/other-task.ts` }, + }, + isDirty: false, + } + for (const tab of [ownTab, otherTaskTab]) { + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [ownTab, otherTaskTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { + closedTabs.push(tab) + return Promise.resolve(true) + }) + + await diffViewProvider["closeOwnDiffView"](path.join(mockCwd, "test.ts")) + + expect(closedTabs).toEqual([ownTab]) + }) + }) + describe("saveDirectly method", () => { beforeEach(() => { // Mock vscode functions @@ -1066,6 +1107,7 @@ describe("DiffViewProvider", () => { diffViewProvider["activeDiffEditor"] = mockTextEditor(`${mockCwd}/test.ts`, "new content") diffViewProvider["preDiagnostics"] = [] diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeOwnDiffView"] = vi.fn().mockResolvedValue(undefined) vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockTextEditor(`${mockCwd}/test.ts`)) vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) }) @@ -1288,7 +1330,7 @@ describe("DiffViewProvider", () => { // cleanup still runs before the error is rethrown. const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") expect(safeWriteText).not.toHaveBeenCalled() - expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalledTimes(1) + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalledTimes(1) }) it("open() keeps the model's existing observation instead of replacing it with the preview token", async () => { @@ -1573,7 +1615,7 @@ describe("DiffViewProvider", () => { // The read must be attributed to the bigint stat that produced the token: expect(vi.mocked(fs.stat).mock.calls[0][1]).toEqual({ bigint: true }) expect(fs.unlink).toHaveBeenCalledWith(`${mockCwd}/test.ts`) - expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() }) it("disposes the active-editor listener before the programmatic revert", async () => { @@ -1752,7 +1794,7 @@ describe("DiffViewProvider", () => { // the outcome. expect(fs.rmdir).toHaveBeenCalledTimes(1) expect(fs.rmdir).toHaveBeenCalledWith(`${mockCwd}/a/b/c`) - expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() }) it("does not revert a buffer the user changed during the publish", async () => { @@ -1929,7 +1971,7 @@ describe("DiffViewProvider", () => { // A clean buffer has nothing that can be saved back, so the placeholder // cleanup still runs even though no revert was needed. expect(fs.unlink).toHaveBeenCalledWith(`${mockCwd}/test.ts`) - expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() }) it("does not unlink the placeholder when the edit type is not create - the outer gate short-circuits", async () => { @@ -1965,7 +2007,7 @@ describe("DiffViewProvider", () => { expect(fs.unlink).not.toHaveBeenCalled() // the dirty discard and the view close still ran expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") - expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() }) it("adopts content autosave already published instead of reporting a stale rejection", async () => { @@ -2038,7 +2080,7 @@ describe("DiffViewProvider", () => { ) // The rejection still stands: the buffer is discarded and the views close, // so the caller sees the guard verdict, not a silent success. - expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() }) it("keeps the rejection when the buffer is still dirty even though the disk matches", async () => { // A dirty buffer means the disk content came from someone else, so the @@ -2254,7 +2296,7 @@ describe("DiffViewProvider", () => { await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("EACCES: permission denied") // No adoption read, so the guard verdict is the outcome. expect(fs.readFile).not.toHaveBeenCalled() - expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() }) it("does not adopt content when the failure is not a guard verdict", async () => { // The bytes match and the buffer is clean, but the failure is the dead-task @@ -2346,7 +2388,7 @@ describe("DiffViewProvider", () => { await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") expect(fs.unlink).not.toHaveBeenCalled() - expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() }) it("does not unlink a placeholder whose content changed after open() - the token gate refuses", async () => { @@ -2384,7 +2426,7 @@ describe("DiffViewProvider", () => { expect(fs.stat).toHaveBeenCalledWith(`${mockCwd}/test.ts`, { bigint: true }) // token mismatch -> the placeholder is NOT ours anymore: no unlink expect(fs.unlink).not.toHaveBeenCalled() - expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() }) }) From b165482939cb60af15d042048932a9334ee54c2c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 14:17:39 +0800 Subject: [PATCH 076/100] test(task-persistence): canonicalize the directory in the realpath double The Windows runner failed: the double returned the directory unchanged, so the probe looked at a key built from the alias directory while the lock file was created at the canonical one. os.tmpdir() on that runner can be an 8.3 short path, so the double now canonicalizes the directory exactly as production does and only the file resolves through the simulated alias. --- .../TaskHistoryStore.deleteSemantics.spec.ts | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index 6fb938e06f..15c2ef823a 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -186,11 +186,13 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { // Real symlinks are unavailable in this CI lane, so the alias is // simulated through realpath, as in the safeWriteJson lock test. - // Only the file resolves through the alias; the directory is already canonical, - // so canonicalDirKey leaves it unchanged and the key stays the referent. + // Only the file resolves through the alias; the directory is canonicalized by + // The directory is canonicalized by the real fs exactly as in production, so the key const realpathSpy = vi .spyOn(fs, "realpath") - .mockImplementation(async (target) => (target === aliasPath ? referentPath : String(target))) + .mockImplementation(async (target) => + target === aliasPath ? referentPath : actualFs.realpath(String(target)), + ) try { await expect(store.delete("alias-del")).resolves.toBeUndefined() } finally { @@ -215,8 +217,8 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { await store.upsert(makeHistoryItem({ id: "alias-dangling" })) const aliasPath = historyFilePath(storagePath, "alias-dangling") const referentPath = path.join(storagePath, "tasks", "alias-dangling", "referent-history.json") - // Only the file resolves through the alias; the directory is already canonical, - // so canonicalDirKey leaves it unchanged and the key stays the referent. + // Only the file resolves through the alias; the directory is canonicalized by + // The directory is canonicalized by the real fs exactly as in production, so the key const enoent = Object.assign(new Error("ENOENT"), { code: "ENOENT" }) const realpathSpy = vi.spyOn(fs, "realpath").mockImplementation(async (target) => { if (target === aliasPath) throw enoent @@ -252,11 +254,13 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { // Real symlinks are unavailable in this CI lane, so the alias is // simulated through realpath, as in the safeWriteJson lock test. - // Only the file resolves through the alias; the directory is already canonical, - // so canonicalDirKey leaves it unchanged and the key stays the referent. + // Only the file resolves through the alias; the directory is canonicalized by + // The directory is canonicalized by the real fs exactly as in production, so the key const realpathSpy = vi .spyOn(fs, "realpath") - .mockImplementation(async (target) => (target === aliasPath ? referentPath : String(target))) + .mockImplementation(async (target) => + target === aliasPath ? referentPath : actualFs.realpath(String(target)), + ) try { // The rename window: the referent is momentarily missing, so the cached // file cannot be stat'd while the peer holds the lock at the key it locks. @@ -488,11 +492,13 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { const aliasPath = historyFilePath(storagePath, "alias-batch") const referentPath = path.join(storagePath, "tasks", "alias-batch", "referent-history.json") - // Only the file resolves through the alias; the directory is already canonical, - // so canonicalDirKey leaves it unchanged and the key stays the referent. + // Only the file resolves through the alias; the directory is canonicalized by + // The directory is canonicalized by the real fs exactly as in production, so the key const realpathSpy = vi .spyOn(fs, "realpath") - .mockImplementation(async (target) => (target === aliasPath ? referentPath : String(target))) + .mockImplementation(async (target) => + target === aliasPath ? referentPath : actualFs.realpath(String(target)), + ) try { await expect(store.deleteMany(["alias-batch"])).resolves.toBeUndefined() } finally { From eb6fc683acc99af8cc69659719c34c7fbf905ac7 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 14:28:51 +0800 Subject: [PATCH 077/100] test(task-persistence): assert the lock key against the canonical directory The Windows runner failed because the expected key was built from os.tmpdir(), which there is an 8.3 short path (C:\Users\RUNNER~1), while the key the store computes is canonicalized through the parent directory. The spec now asserts against the canonical directory plus the basename and clears the lock mock per test so an assertion cannot match a call left over from another test. --- .../TaskHistoryStore.deleteSemantics.spec.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index 15c2ef823a..37fd848fb5 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -52,6 +52,13 @@ function historyFilePath(storagePath: string, taskId: string): string { return path.join(storagePath, "tasks", taskId, GlobalFileNames.historyItem) } +// The lock key is canonicalized through the parent directory, so the expected key is +// the canonical directory plus the basename rather than the path built from +// os.tmpdir(), which can be an 8.3 short path on the Windows runner. +async function canonicalKey(filePath: string): Promise { + return path.join(await actualFs.realpath(path.dirname(filePath)), path.basename(filePath)) +} + function storeInternals(store: TaskHistoryStore): { cache: Map taskFileMtimes: Map @@ -71,6 +78,7 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-delete-semantics-")) stores = [] onWrite = vi.fn().mockResolvedValue(undefined) + vi.mocked(withFileLock).mockClear() vi.mocked(withFileLock).mockImplementation(actualFileLock.withFileLock) vi.mocked(fs.unlink).mockImplementation(actualFs.unlink) }) @@ -201,7 +209,7 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { // One lock for the underlying file, keyed by the referent; the unlink // still targets the path the store named. - expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(referentPath, expect.any(Function)) + expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(await canonicalKey(referentPath), expect.any(Function)) // Assert the unlink target itself: locking the referent while unlinking the // referent instead of the alias would keep the dangling link in place. expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(aliasPath) @@ -239,7 +247,7 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { readlinkSpy.mockRestore() } - expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(referentPath, expect.any(Function)) + expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(await canonicalKey(referentPath), expect.any(Function)) expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(aliasPath) }) }) @@ -505,7 +513,7 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { realpathSpy.mockRestore() } - expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(referentPath, expect.any(Function)) + expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(await canonicalKey(referentPath), expect.any(Function)) expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(aliasPath) }) }) From f1a47df46684e5b739c552a6ac3e06fea5fe061f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 14:39:39 +0800 Subject: [PATCH 078/100] test(task-persistence): make every realpath double canonicalize the directory The dangling-link test still returned the directory unchanged, so the key it asserted was the alias directory while the store canonicalizes the parent directory. On the Windows runner os.tmpdir() is an 8.3 short path, so the two disagreed. All three doubles now canonicalize the directory the way production does. --- .../TaskHistoryStore.deleteSemantics.spec.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index 37fd848fb5..7275e2ad8a 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -194,8 +194,8 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { // Real symlinks are unavailable in this CI lane, so the alias is // simulated through realpath, as in the safeWriteJson lock test. - // Only the file resolves through the alias; the directory is canonicalized by - // The directory is canonicalized by the real fs exactly as in production, so the key + // Only the file resolves through the alias; the directory is canonicalized by the + // the real fs exactly as in production, so the key matches the canonical directory const realpathSpy = vi .spyOn(fs, "realpath") .mockImplementation(async (target) => @@ -225,12 +225,12 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { await store.upsert(makeHistoryItem({ id: "alias-dangling" })) const aliasPath = historyFilePath(storagePath, "alias-dangling") const referentPath = path.join(storagePath, "tasks", "alias-dangling", "referent-history.json") - // Only the file resolves through the alias; the directory is canonicalized by - // The directory is canonicalized by the real fs exactly as in production, so the key + // Only the file resolves through the alias; the directory is canonicalized by the + // the real fs exactly as in production, so the key matches the canonical directory const enoent = Object.assign(new Error("ENOENT"), { code: "ENOENT" }) const realpathSpy = vi.spyOn(fs, "realpath").mockImplementation(async (target) => { if (target === aliasPath) throw enoent - return String(target) + return actualFs.realpath(String(target)) }) const lstatSpy = vi .spyOn(fs, "lstat") @@ -262,8 +262,8 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { // Real symlinks are unavailable in this CI lane, so the alias is // simulated through realpath, as in the safeWriteJson lock test. - // Only the file resolves through the alias; the directory is canonicalized by - // The directory is canonicalized by the real fs exactly as in production, so the key + // Only the file resolves through the alias; the directory is canonicalized by the + // the real fs exactly as in production, so the key matches the canonical directory const realpathSpy = vi .spyOn(fs, "realpath") .mockImplementation(async (target) => @@ -500,8 +500,8 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { const aliasPath = historyFilePath(storagePath, "alias-batch") const referentPath = path.join(storagePath, "tasks", "alias-batch", "referent-history.json") - // Only the file resolves through the alias; the directory is canonicalized by - // The directory is canonicalized by the real fs exactly as in production, so the key + // Only the file resolves through the alias; the directory is canonicalized by the + // the real fs exactly as in production, so the key matches the canonical directory const realpathSpy = vi .spyOn(fs, "realpath") .mockImplementation(async (target) => From 11afee73f42d835217d09185ab15e13be39ccfae Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 15:45:39 +0800 Subject: [PATCH 079/100] fix(apply-patch): carry the source's completeness to the move destination before the publish The guarded publish records the destination as complete, so downgrading it after saveDirectly left a window in which a concurrent writer could see the destination as complete for content the model never fully read. The carry now happens before the per-path write chain runs, and only for a destination the model had already read - an unobserved destination stays unobserved so the read-before-write rule still applies. --- src/core/tools/ApplyPatchTool.ts | 31 +++++++----- .../__tests__/applyPatchTool.execute.spec.ts | 49 +++++++++++++------ 2 files changed, 54 insertions(+), 26 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index e242c32cb8..27d78c8c60 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -440,7 +440,25 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Save new content to the new path if (isPreventFocusDisruptionEnabled) { - // The move destination is published with the complete new content. + // The destination content is the source file plus one targeted hunk, so it can + // only be as complete as the view the model had of the source. Carry that + // completeness to the destination BEFORE the guarded publish: the guard decides + // completeness at publish time, so a partial view must already be recorded when + // the per-path chain runs. Downgrading only after saveDirectly leaves a window + // in which a concurrent writer sees the destination as complete for content the + // model never fully read. + const sourceObs = task.observationRegistry.get(absolutePath) + const sourceComplete = sourceObs !== undefined && sourceObs.complete === true + if (!sourceComplete) { + // Only carry completeness the model already had for the destination. An + // unobserved destination stays unobserved so the guard's "read before a + // full-file write" rule still applies; recording a partial observation here + // would hand the model authority to edit a file it never read. + const destObs = task.observationRegistry.get(moveAbsolutePath) + if (destObs !== undefined) { + task.observationRegistry.observe(moveAbsolutePath, destObs.version, false) + } + } await task.diffViewProvider.saveDirectly( change.movePath, newContent, @@ -449,17 +467,6 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { writeDelayMs, "create", ) - // The destination content is the source file plus one targeted hunk, so - // it can only be as complete as the view the model had of the source. The - // create publish records the destination as complete, which would hand the - // model authority over lines it never read; carry the source's completeness - // (or none, when the source was never observed) to the destination. - const sourceObs = task.observationRegistry.get(absolutePath) - const destObs = task.observationRegistry.get(moveAbsolutePath) - const sourceComplete = sourceObs !== undefined && sourceObs.complete === true - if (destObs !== undefined && !sourceComplete) { - task.observationRegistry.observe(moveAbsolutePath, destObs.version, false) - } } else { // Write to new path and delete old file const parentDir = path.dirname(moveAbsolutePath) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index abab73900c..da79804256 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -24,8 +24,10 @@ const mockedFsPromises = vi.mocked( }, ) -vi.mock("fs/promises", () => ({ - default: { +vi.mock("fs/promises", () => { + // The SUT imports the module two ways: applyPatchTool uses the default + // export, safeWriteText uses the namespace. Both must see the same doubles. + const doubles = { // The target exists on disk, so the completeness gate applies to the // follow-up guarded write in the partial-observation test. access: vi.fn().mockResolvedValue(undefined), @@ -40,8 +42,14 @@ vi.mock("fs/promises", () => ({ ctimeNs: 1_700_000_000_789_999_999n, }), unlink: vi.fn().mockResolvedValue(undefined), - }, -})) + // resolveLockKey canonicalizes the lock key, so the guard needs these + // even when no symlink is involved. + lstat: vi.fn().mockResolvedValue({ isSymbolicLink: () => false }), + readlink: vi.fn().mockRejectedValue(new Error("not a symbolic link")), + realpath: vi.fn(async (p: string) => String(p)), + } + return { default: doubles, ...doubles } +}) // Mock the shared advisory lock that the guarded-write path uses; the real // proper-lockfile would try to create a lock directory on the mocked fs. @@ -415,18 +423,20 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { ) }) - it("move: carries the source's partial completeness to the destination", async () => { - // The destination is the source file plus one hunk. The create publish records - // it as complete, but the model only saw a slice of the source, so the - // destination must not gain completeness the model never earned. + it("move: carries the source's partial completeness to the destination before the publish", async () => { + // The destination is the source file plus one hunk, so it can only be as + // complete as the view the model had of the source. The carry has to happen + // before the guarded publish, because the guard decides completeness at publish + // time: downgrading afterwards leaves a window in which a concurrent writer sees + // the destination as complete for content the model never fully read. const sourceKey = path.resolve("/workspace/project", "src/old.ts") const destKey = path.resolve("/workspace/project", "src/new.ts") const reg = mockTask.observationRegistry reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", false) - // The real saveDirectly publishes through guardedWrite, which refreshes the - // destination observation as complete for a create; the double mirrors that. + reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + const completeAtPublish: Array = [] mockSaveDirectly.mockImplementationOnce(async () => { - reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + completeAtPublish.push(reg.get(destKey)?.complete) return { newProblemsMessage: "", userEdits: undefined, finalContent: "new content" } }) @@ -436,7 +446,10 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { pushToolResult: mockPushToolResult, }) + // Partial at the moment the write chain ran, not merely afterwards. + expect(completeAtPublish).toEqual([false]) expect(reg.get(destKey)?.complete).toBe(false) + // The guard still refuses a full-file replacement built from the slice. await expect(guardedWrite(mockTask as Task, "src/new.ts", "full replacement", "create")).rejects.toThrow( "File was only partially read (line slice, range, truncated view, or indentation block) -- " + "a full-file replacement needs the complete content; re-read the whole file, then retry.", @@ -466,12 +479,15 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { it("move: the destination cannot claim completeness when the source was never observed", async () => { // The hunk read records no observation when its stat fails, so the model has - // no authority over the source content the move carried over. + // no authority over the source content the move carried over. Nothing is + // recorded for the destination before the write chain runs, so the guard still + // refuses a full-file write to a file the model never read. const destKey = path.resolve("/workspace/project", "src/new.ts") const reg = mockTask.observationRegistry mockedFsPromises.default.stat.mockImplementationOnce(() => Promise.reject(new Error("stat failed"))) + const observedAtPublish: boolean[] = [] mockSaveDirectly.mockImplementationOnce(async () => { - reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + observedAtPublish.push(reg.has(destKey)) return { newProblemsMessage: "", userEdits: undefined, finalContent: "new content" } }) @@ -482,7 +498,12 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { }) expect(reg.get(path.resolve("/workspace/project", "src/old.ts"))).toBeUndefined() - expect(reg.get(destKey)?.complete).toBe(false) + expect(observedAtPublish).toEqual([false]) + await expect(guardedWrite(mockTask as Task, "src/new.ts", "full replacement", "create")).rejects.toThrow( + "File already exists at " + + destKey + + " and was not read before this write -- read the file first, then retry.", + ) expect(mockHandleError).not.toHaveBeenCalled() }) From ce61546c3074a46721c8c966f497f1acc9db55b3 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 15:45:39 +0800 Subject: [PATCH 080/100] fix(file-safety): propagate a non-ENOENT stat failure in the mode-preservation path Every statSync failure was treated as "the target does not exist", so an EACCES or EIO silently fell back to the fresh-file default mode and a restrictive target (0o600) was published as 0o644 through the rename. Only ENOENT may take the default now; the error is propagated in both the self-staged and caller-staged branches, with focused tests for each. --- .../__tests__/safeWriteText.spec.ts | 35 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 18 ++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 1935c20590..decb7dca5f 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -600,6 +600,41 @@ describe("safeWriteText", () => { expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) }) + it("propagates a non-ENOENT stat failure rather than defaulting the mode (caller-staged)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + const eacces = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw eacces + }) + vi.mocked(fsSync.openSync).mockReturnValue(2) + + const customTempPath = "/tmp/custom-temp.tmp" + + // A target that cannot be stat'd is not a fresh target: publishing with + // the default mode would widen a restrictive target through the rename. + await expect( + safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }), + ).rejects.toThrow("EACCES") + expect(fsSync.fchmodSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("propagates a non-ENOENT stat failure rather than defaulting the mode (self-staged)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + const eio = Object.assign(new Error("EIO: i/o error"), { code: "EIO" }) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw eio + }) + + // The mode is read before the temp is opened, so a real I/O failure stops + // the write before anything is staged. + await expect(safeWriteText(targetPath, "hello world", { platform: "linux" })).rejects.toThrow("EIO") + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + it("opens the temp before applying a read-only target's mode (0o444 does not block the open)", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 22318b8c9b..6249f0454a 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -136,6 +136,18 @@ export async function resolvePublishTarget(absoluteFilePath: string): Promise { const dirPath = path.dirname(absoluteFilePath) const canonicalDir = await fs.realpath(dirPath).catch(() => dirPath) @@ -219,7 +231,8 @@ export async function safeWriteText( try { targetMode = fsSync.statSync(targetPath).mode & 0o777 targetExists = true - } catch { + } catch (error: unknown) { + if (errorCode(error) !== "ENOENT") throw error // target does not exist yet - keep the default } // openSync's creation mode is narrowed by the process umask, so an @@ -252,7 +265,8 @@ export async function safeWriteText( let targetMode: number | null = null try { targetMode = fsSync.statSync(targetPath).mode & 0o777 - } catch { + } catch (error: unknown) { + if (errorCode(error) !== "ENOENT") throw error // target does not exist yet - keep the temp's default mode } const fd = fsSync.openSync(tempPath, "r+") From 12525c54006e381617e00ae1ce9fc776277e061a Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 16:09:12 +0800 Subject: [PATCH 081/100] fix(apply-patch): carry the source's completeness through the publish for a fresh destination The pre-publish carry only covers a destination the model had already read. For a fresh destination the guard records the create as complete, which hands the model authority over source lines it never read. The completeness the caller earned is now threaded through saveDirectly into guardedWrite's refresh, while an existing destination the model never read still gets no observation, so the read-before-write rule is unchanged. --- src/core/tools/ApplyPatchTool.ts | 1 + .../tools/__tests__/applyPatchTool.execute.spec.ts | 1 + src/core/tools/__tests__/guardedWrite.spec.ts | 14 ++++++++++++++ src/core/tools/guardedWrite.ts | 6 +++++- src/integrations/editor/DiffViewProvider.ts | 5 ++++- 5 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 27d78c8c60..1a4a972981 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -466,6 +466,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { diagnosticsEnabled, writeDelayMs, "create", + sourceComplete, ) } else { // Write to new path and delete old file diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index da79804256..619dca0311 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -420,6 +420,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { true, 1000, "create", + false, ) }) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index ba2bdaebb5..3bae1affe3 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -120,6 +120,20 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(reg.get(abs("new-file.txt"))?.complete).toBe(true) }) + it("carries a caller-supplied completeness through the publish for a fresh destination", async () => { + // A move publishes content built from a view of another file. Recording the + // create as complete would hand the model authority over source lines it never + // read, so the caller's completeness has to survive the refresh. + const reg = new ObservationRegistry() + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "new-file.txt", "hello", "create", false) + + expect(reg.get(abs("new-file.txt"))?.complete).toBe(false) + }) + it("fails with the read-first remediation when the file exists - nothing published", async () => { mockedFsAccess.mockResolvedValue(undefined) const task = createMockTask() diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index d1a0da5922..32d0f65645 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -262,6 +262,10 @@ export async function guardedWrite( relPathOrAbsolute: string, content: string | Uint8Array, kind: GuardedWriteKind = "update", + // Optional completeness for the refresh. A tool that built its content from a + // view of another file must carry that view's completeness through the publish + // instead of claiming completeness for lines it never read. + completeOverride?: boolean, ): Promise { const absolutePath = resolveAbsolutePath(task, relPathOrAbsolute) @@ -328,7 +332,7 @@ export async function guardedWrite( // established: a partial observation that authorized a targeted edit // must stay partial, otherwise a later full-file replacement would // publish content built from the slice alone. - task.observationRegistry.observe(absolutePath, publishedToken, !staysPartial) + task.observationRegistry.observe(absolutePath, publishedToken, completeOverride ?? !staysPartial) } }) } diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 6db3dbf44c..14d1c1314a 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1444,6 +1444,9 @@ export class DiffViewProvider { diagnosticsEnabled: boolean = true, writeDelayMs: number = DEFAULT_WRITE_DELAY_MS, writeKind: GuardedWriteKind = "create", + // Completeness the caller earned elsewhere; a move carries the source's + // view through the publish instead of claiming completeness for lines it never read. + completeOverride?: boolean, ): Promise<{ newProblemsMessage: string | undefined userEdits: string | undefined @@ -1466,7 +1469,7 @@ export class DiffViewProvider { throw new Error("Cannot guard the write: the owning task is no longer available") } await createDirectoriesForFile(absolutePath) - await guardedWrite(task, relPath, content, writeKind) + await guardedWrite(task, relPath, content, writeKind, completeOverride) // Open the document to ensure diagnostics are loaded // When openFile is false (PREVENT_FOCUS_DISRUPTION enabled), we only open in memory From 370406800d33697752eed478157d2eb6b8195522 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 16:10:11 +0800 Subject: [PATCH 082/100] test(utils): clean up the lock-key spec's temp dirs and reset its doubles The tests created mkdtemp directories that were never removed, and the realpath, lstat and readlink doubles carried their implementations from one test to the next, so a run was order dependent. Each test now registers its directory for removal and the doubles are reset before every test. --- .../__tests__/safeWriteJson.lockKey.spec.ts | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 3d02c3c7a9..68eeee66a7 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -24,6 +24,29 @@ const mockedAcquireFileLock = vi.mocked(acquireFileLock) const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) +// Each test creates a real temp directory so the real fs calls still work. +// doubles between tests so an implementation from one test cannot carry over. +const createdDirs: string[] = [] +async function makeDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)) + createdDirs.push(dir) + return dir +} + +beforeEach(() => { + mockedRealpath.mockReset() + mockedLstat.mockReset() + mockedReadlink.mockReset() + mockedAcquireFileLock.mockReset() +}) + +afterEach(async () => { + for (const dir of createdDirs) { + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined) + } + createdDirs.length = 0 +}) + // Only isSymbolicLink() is consulted by the guard, so the double carries just // that method. The mocks reject asynchronously: a synchronous throw would bypass // resolvePublishTarget's catch and skip the ENOENT/symlink branch under test. @@ -33,7 +56,7 @@ let currentLink = "" describe("safeWriteJson lock key under a peer commit", () => { it("waits for the peer instead of rejecting, and locks the referent", async () => { const order: string[] = [] - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lockkey-")) + const dir = await makeDir("lockkey-") const referent = path.join(dir, "history_item.json") currentLink = path.join(dir, "link.json") @@ -75,7 +98,7 @@ describe("safeWriteJson lock key under a peer commit", () => { it("releases the lock when the resolution under the lock rejects", async () => { const order: string[] = [] let released = false - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lockkey-")) + const dir = await makeDir("lockkey-") const referent = path.join(dir, "history_item.json") currentLink = path.join(dir, "link.json") From 9c42789b2b3c77a911b5c6b904782b6f59788581 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 16:26:33 +0800 Subject: [PATCH 083/100] docs(test): put the canonicalization JSDoc on its function and cover a post-read stat rejection The stale resolveLockKey JSDoc was left above errorCode, so canonicalDirKey had no doc and the block that TypeScript attached was the wrong one. The canonicalization block now sits above canonicalDirKey. Add the missing ApplyPatchTool.execute case: the pre-read stat succeeds and the post-read stat rejects, so the read cannot be tied to a version token, no observation is recorded, and the guarded publish fails closed against the existing unobserved file. --- .../__tests__/applyPatchTool.execute.spec.ts | 24 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 23 +++++++----------- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 619dca0311..39eb82a6f7 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -384,6 +384,30 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) }) + it("update: does not observe when the post-read stat rejects", async () => { + // The pre-read stat succeeds and the read itself succeeds, but the post-read + // stat fails, so the read cannot be tied to a version token and no + // observation is recorded. The guarded publish then fails closed against the + // existing, unobserved file rather than publishing content built from an + // unverifiable read. + const statMock = mockedFsPromises.default.stat + statMock.mockResolvedValueOnce({ dev: 7n, ino: 4242n, size: 1234n, mtimeNs: 1n, ctimeNs: 2n }) + statMock.mockRejectedValueOnce(new Error("EACCES: permission denied")) + + const guardError = new Error( + "File already exists at /workspace/project/src/thing.ts and was not read before this write -- read the file first, then retry.", + ) + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.observationRegistry.has(path.resolve("/workspace/project", "src/thing.ts"))).toBe(false) + expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) + }) it("add: publishes the new file through the guarded saveDirectly with create kind", async () => { mockedFileExistsAtPath.mockResolvedValueOnce(false) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 6249f0454a..f91963bb2a 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -121,21 +121,6 @@ export async function resolvePublishTarget(absoluteFilePath: string): Promise { const dirPath = path.dirname(absoluteFilePath) const canonicalDir = await fs.realpath(dirPath).catch(() => dirPath) From 98b02774e6e97707a5e428dbbd320d5b49a3496f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 16:28:21 +0800 Subject: [PATCH 084/100] fix(apply-patch): reject a partial source move onto an observed destination before changing state The downgrade left the destination partial after a rejected publish, so a full read the model had earned was lost, and the guard's remediation named the destination - re-reading the destination cannot restore the source's completeness, so the model loops. The move now rejects before the registry is touched and names the source file. --- src/core/tools/ApplyPatchTool.ts | 11 +++++- .../__tests__/applyPatchTool.execute.spec.ts | 34 ++++++++----------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 1a4a972981..1af0b5aa02 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -12,6 +12,7 @@ import { fileExistsAtPath } from "../../utils/fs" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import { versionTokenOfStat } from "../../utils/versionToken" +import { GuardRejectedError } from "./guardedWrite" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" import { parsePatch, ParseError, processAllHunks } from "./apply-patch" @@ -456,7 +457,15 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // would hand the model authority to edit a file it never read. const destObs = task.observationRegistry.get(moveAbsolutePath) if (destObs !== undefined) { - task.observationRegistry.observe(moveAbsolutePath, destObs.version, false) + // Replacing an existing destination with content built from a partial + // source view must be re-authorized by reading the source in full. Reject + // before any state changes and name the source: a remediation that names + // only the destination sends the model to re-read the wrong file, and a + // downgrade that survives a rejected publish loses a full destination read. + throw new GuardRejectedError( + `Cannot move a partially read file onto ${change.movePath}: re-read the whole source (${change.path}) first, then retry.`, + moveAbsolutePath, + ) } } await task.diffViewProvider.saveDirectly( diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 39eb82a6f7..361b216775 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -448,22 +448,17 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { ) }) - it("move: carries the source's partial completeness to the destination before the publish", async () => { - // The destination is the source file plus one hunk, so it can only be as - // complete as the view the model had of the source. The carry has to happen - // before the guarded publish, because the guard decides completeness at publish - // time: downgrading afterwards leaves a window in which a concurrent writer sees - // the destination as complete for content the model never fully read. + it("move: rejects a partial source onto an observed destination before changing any state", async () => { + // The destination content is the source file plus one hunk, so it can only be + // as complete as the view the model had of the source. Rejecting before the + // registry is modified keeps a full destination read intact, and the message + // must name the source, because re-reading the destination cannot restore the + // source's completeness and would loop. const sourceKey = path.resolve("/workspace/project", "src/old.ts") const destKey = path.resolve("/workspace/project", "src/new.ts") const reg = mockTask.observationRegistry reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", false) reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) - const completeAtPublish: Array = [] - mockSaveDirectly.mockImplementationOnce(async () => { - completeAtPublish.push(reg.get(destKey)?.complete) - return { newProblemsMessage: "", userEdits: undefined, finalContent: "new content" } - }) await tool.execute({ patch: movePatch }, mockTask as Task, { askApproval: mockAskApproval, @@ -471,15 +466,16 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { pushToolResult: mockPushToolResult, }) - // Partial at the moment the write chain ran, not merely afterwards. - expect(completeAtPublish).toEqual([false]) - expect(reg.get(destKey)?.complete).toBe(false) - // The guard still refuses a full-file replacement built from the slice. - await expect(guardedWrite(mockTask as Task, "src/new.ts", "full replacement", "create")).rejects.toThrow( - "File was only partially read (line slice, range, truncated view, or indentation block) -- " + - "a full-file replacement needs the complete content; re-read the whole file, then retry.", + // Nothing was downgraded: the destination keeps the completeness the model earned. + expect(reg.get(destKey)?.complete).toBe(true) + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockHandleError).toHaveBeenCalledWith( + "apply patch", + expect.objectContaining({ + message: + "Cannot move a partially read file onto src/new.ts: re-read the whole source (src/old.ts) first, then retry.", + }), ) - expect(mockHandleError).not.toHaveBeenCalled() }) it("move: a complete source read keeps the destination complete", async () => { From 08f0fc87074ed48a0c7528bae16abc398a466d02 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 16:47:27 +0800 Subject: [PATCH 085/100] fix(apply-patch): reject a partial-source move only when the destination still exists A destination that was read and then deleted keeps its observation, so the rejection refused a move onto an absent path even though the create guard permits it. The presence check now decides: an existing destination is rejected before any state changes, an absent one carries the source's completeness through the publish. --- src/core/tools/ApplyPatchTool.ts | 25 ++++++++++------- .../__tests__/applyPatchTool.execute.spec.ts | 27 +++++++++++++++++++ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 1af0b5aa02..919086470c 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -457,15 +457,22 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // would hand the model authority to edit a file it never read. const destObs = task.observationRegistry.get(moveAbsolutePath) if (destObs !== undefined) { - // Replacing an existing destination with content built from a partial - // source view must be re-authorized by reading the source in full. Reject - // before any state changes and name the source: a remediation that names - // only the destination sends the model to re-read the wrong file, and a - // downgrade that survives a rejected publish loses a full destination read. - throw new GuardRejectedError( - `Cannot move a partially read file onto ${change.movePath}: re-read the whole source (${change.path}) first, then retry.`, - moveAbsolutePath, - ) + if (await fileExistsAtPath(moveAbsolutePath)) { + // Replacing an existing destination with content built from a partial + // source view must be re-authorized by reading the source in full. Reject + // before any state changes and name the source: a remediation that names + // only the destination sends the model to re-read the wrong file, and a + // downgrade that survives a rejected publish loses a full destination read. + throw new GuardRejectedError( + `Cannot move a partially read file onto ${change.movePath}: re-read the whole source (${change.path}) first, then retry.`, + moveAbsolutePath, + ) + } + // The destination was read and then deleted: the create guard permits the + // publish, and the content is still only as complete as the view the model + // had of the source, so carry the source's completeness instead of the + // destination's stale one. + task.observationRegistry.observe(moveAbsolutePath, destObs.version, false) } } await task.diffViewProvider.saveDirectly( diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 361b216775..c1308b9b9f 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -478,6 +478,33 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { ) }) + it("move: carries the source's completeness when the observed destination was deleted", async () => { + // The destination was read and then deleted, so its observation is stale but the + // create guard permits the publish. Rejecting here would refuse a move onto an + // absent path; the content is still only as complete as the source view, so the + // source's completeness is carried instead of the destination's stale one. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", false) + reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + mockedFileExistsAtPath.mockImplementation(async (p) => p !== destKey) + const completeAtPublish: Array = [] + mockSaveDirectly.mockImplementationOnce(async () => { + completeAtPublish.push(reg.get(destKey)?.complete) + return { newProblemsMessage: "", userEdits: undefined, finalContent: "new content" } + }) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(completeAtPublish).toEqual([false]) + expect(mockHandleError).not.toHaveBeenCalled() + }) + it("move: a complete source read keeps the destination complete", async () => { const sourceKey = path.resolve("/workspace/project", "src/old.ts") const destKey = path.resolve("/workspace/project", "src/new.ts") From 303123bde02b934c4f7d3fa802e78765a4301177 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 17:07:20 +0800 Subject: [PATCH 086/100] fix(apply-patch): propagate a non-ENOENT destination access error before changing its observation fileExistsAtPath() returns false for every fs.access error, so an ELOOP on an observed destination read as "absent" would mark the observation partial and let the publish run against a path the tool never observed. The move now distinguishes an absence verdict from a real I/O failure and propagates the latter. --- src/core/tools/ApplyPatchTool.ts | 15 +++++++-- .../__tests__/applyPatchTool.execute.spec.ts | 31 ++++++++++++++++++- src/core/tools/guardedWrite.ts | 2 +- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 919086470c..a5023c8698 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -12,7 +12,7 @@ import { fileExistsAtPath } from "../../utils/fs" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import { versionTokenOfStat } from "../../utils/versionToken" -import { GuardRejectedError } from "./guardedWrite" +import { GuardRejectedError, errorCode } from "./guardedWrite" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" import { parsePatch, ParseError, processAllHunks } from "./apply-patch" @@ -457,7 +457,18 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // would hand the model authority to edit a file it never read. const destObs = task.observationRegistry.get(moveAbsolutePath) if (destObs !== undefined) { - if (await fileExistsAtPath(moveAbsolutePath)) { + let destAbsent = false + try { + await fs.access(moveAbsolutePath) + } catch (error: unknown) { + // Only an access error that means "not there" is an absence verdict. An + // ELOOP or EACCES is a real I/O failure: reading it as absent would mark + // the destination partial and let the publish run against a path the tool + // never actually observed. + if (errorCode(error) !== "ENOENT") throw error + destAbsent = true + } + if (!destAbsent) { // Replacing an existing destination with content built from a partial // source view must be re-authorized by reading the source in full. Reject // before any state changes and name the source: a remediation that names diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index c1308b9b9f..18f51a646a 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -488,7 +488,9 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { const reg = mockTask.observationRegistry reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", false) reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) - mockedFileExistsAtPath.mockImplementation(async (p) => p !== destKey) + mockedFsPromises.default.access.mockRejectedValueOnce( + Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }), + ) const completeAtPublish: Array = [] mockSaveDirectly.mockImplementationOnce(async () => { completeAtPublish.push(reg.get(destKey)?.complete) @@ -505,6 +507,33 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { expect(mockHandleError).not.toHaveBeenCalled() }) + it("move: propagates a non-ENOENT destination access error before changing the observation", async () => { + // An ELOOP is not an absence verdict. Reading it as "the destination is gone" + // would mark the observation partial and let the publish run against a path the + // tool never actually observed. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", false) + reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + mockedFsPromises.default.access.mockRejectedValueOnce( + Object.assign(new Error("ELOOP: too many symbolic links"), { code: "ELOOP" }), + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(destKey)?.complete).toBe(true) + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockHandleError).toHaveBeenCalledWith( + "apply patch", + expect.objectContaining({ message: "ELOOP: too many symbolic links" }), + ) + }) + it("move: a complete source read keeps the destination complete", async () => { const sourceKey = path.resolve("/workspace/project", "src/old.ts") const destKey = path.resolve("/workspace/project", "src/new.ts") diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index 32d0f65645..0559ca02cb 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -97,7 +97,7 @@ function enqueue(pathKey: string, fn: () => Promise): Promise { * Extract a Node errno code (e.g. "ENOENT") from a thrown value, or * undefined when the value carries none. */ -function errorCode(error: unknown): string | undefined { +export function errorCode(error: unknown): string | undefined { return typeof error === "object" && error !== null && "code" in error ? (error as { code?: string }).code : undefined From 9ba1e1185c1f76bbce11d37a4191a15cc7f738f2 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 17:18:46 +0800 Subject: [PATCH 087/100] test(diff-view): cover a post-read stat rejection during the preview The modify-preview branch stats the target before and after reading it. A rejecting post-read stat leaves the target unobserved, so open() must complete without recording an observation and the accepted full-file save must still fail closed. --- .../editor/__tests__/DiffViewProvider.spec.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index d84d526f87..e661dea447 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1137,6 +1137,33 @@ describe("DiffViewProvider", () => { expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(2, `${mockCwd}/observed.ts`, { bigint: true }) }) + it("open() records no observation when the post-read stat rejects", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/observed.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/observed.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat) + .mockResolvedValueOnce(previewStats) + .mockRejectedValueOnce(Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" })) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("observed.ts") + + expect(mockTask.observationRegistry.has(`${mockCwd}/observed.ts`)).toBe(false) + // The accepted save is a full-file replacement, so an unobserved target still + // fails closed instead of publishing content built on a preview that could not + // be tied to a version token. + await expect( + diffViewProvider.saveDirectly("observed.ts", "new content", false, false, 0, "modify"), + ).rejects.toThrow( + "File already exists at /mock/cwd/observed.ts and was not read before this write -- read the file first, then retry.", + ) + }) it("open() observes the empty placeholder of a new file so the accepted save can be guarded", async () => { const mockEditor = mockTextEditor(`${mockCwd}/brand-new.ts`) vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) From 5d7c7263f2fa817499db8840745fe92e594f40ec Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 17:21:59 +0800 Subject: [PATCH 088/100] fix(apply-patch): make queued guarded writes cancellation-aware A link queued on the per-path FIFO chain can reach its turn after the task that queued it was aborted, and the caller has already reported the write to the model. Cancellation is now checked when the link is dequeued and again under the publish lock, before any I/O. The DiffViewProvider test also used a write kind that is not part of GuardedWriteKind. --- src/core/tools/__tests__/guardedWrite.spec.ts | 31 +++++++++++++ src/core/tools/guardedWrite.ts | 44 ++++++++++++++++--- .../editor/__tests__/DiffViewProvider.spec.ts | 2 +- 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index 3bae1affe3..567f64f187 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -58,6 +58,7 @@ const abs = (relPath: string): string => path.resolve(WORKSPACE, relPath) interface MockTaskOptions { cwd?: string observationRegistry?: ObservationRegistry + abort?: boolean } /** @@ -68,6 +69,7 @@ interface MockTaskOptions { function createMockTask(options: MockTaskOptions = {}): Task { const task = { cwd: options.cwd ?? WORKSPACE, + abort: options.abort ?? false, observationRegistry: options.observationRegistry ?? new ObservationRegistry(), } return task as unknown as Task @@ -761,4 +763,33 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("x.txt"), "b") }) }) + + it("does not run a queued write once the owning task is cancelled", async () => { + const task = createMockTask({ abort: true }) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + + await expect(guardedWrite(task, "new-file.txt", "hello", "create")).rejects.toThrow( + "Task was cancelled before this write ran -- the queued publish is not performed.", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("re-checks cancellation under the publish lock before writing", async () => { + // The dequeue check passed; the abort landed while the lock was being taken, + // so the guard must refuse before the publish rather than write for a task that + // is already gone. + const task = createMockTask() + mockedWithFileLock.mockImplementation(function (filePath, operation) { + task.abort = true + return operation(path.resolve(filePath)) + }) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + + await expect(guardedWrite(task, "new-file.txt", "hello", "create")).rejects.toThrow( + "Task was cancelled before this write published -- nothing was written.", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) }) diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index 0559ca02cb..de6919b494 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -103,6 +103,20 @@ export function errorCode(error: unknown): string | undefined { : undefined } +/** + * Cancellation re-check under the publish lock. A write queued on the FIFO + * chain can outlive its task: the caller already reported the write to the model, + * so a task aborted while its link waited must not publish afterwards. + */ +function cancelledBeforePublish(absolutePath: string, isCancelled?: () => boolean): void { + if (isCancelled?.()) { + throw new GuardRejectedError( + "Task was cancelled before this write published -- nothing was written.", + absolutePath, + ) + } +} + /** True when the path is absent on disk (fs.access reports ENOENT). */ async function fileIsAbsent(absolutePath: string): Promise { try { @@ -129,10 +143,17 @@ async function tokenAfterPublish(absolutePath: string): Promise undefined) } -export async function createIfAbsent(absolutePath: string, content: string | Uint8Array): Promise { +export async function createIfAbsent( + absolutePath: string, + content: string | Uint8Array, + // Re-checked under the lock: a link that waited on the FIFO chain can outlive + // the task that queued it. + isCancelled?: () => boolean, +): Promise { // Lock the key every other writer to this file uses: the resolved publish // target, so a symlink alias and its referent share one lock. return withFileLock(await resolveLockKey(absolutePath), async () => { + cancelledBeforePublish(absolutePath, isCancelled) try { await fs.access(absolutePath) } catch (error: unknown) { @@ -168,10 +189,14 @@ export async function replaceIfVersion( absolutePath: string, expectedVersion: string, content: string | Uint8Array, + // Re-checked under the lock: a link that waited on the FIFO chain can outlive + // the task that queued it. + isCancelled?: () => boolean, ): Promise { // Lock the key every other writer to this file uses: the resolved publish // target, so a symlink alias and its referent share one lock. return withFileLock(await resolveLockKey(absolutePath), async () => { + cancelledBeforePublish(absolutePath, isCancelled) let currentVersion: string try { currentVersion = await computeVersionToken(absolutePath) @@ -270,6 +295,15 @@ export async function guardedWrite( const absolutePath = resolveAbsolutePath(task, relPathOrAbsolute) return enqueue(absolutePath, async () => { + // Cancellation is checked when the link is dequeued, not when it was enqueued: + // a write queued before an abort can still reach its turn on the chain after the + // task is gone, and the caller has already reported the write to the model. + if (task.abort) { + throw new GuardRejectedError( + "Task was cancelled before this write ran -- the queued publish is not performed.", + absolutePath, + ) + } const obs = task.observationRegistry.get(absolutePath) // A targeted edit authorizes only the view the model saw, so a partial // observation stays partial; a full-file publish is complete. @@ -286,7 +320,7 @@ export async function guardedWrite( if (obs === undefined) { await unobservedEditGuard(absolutePath) } else { - publishedToken = await replaceIfVersion(absolutePath, obs.version, content) + publishedToken = await replaceIfVersion(absolutePath, obs.version, content, () => task.abort) staysPartial = obs.complete === false } } else { @@ -310,14 +344,14 @@ export async function guardedWrite( if (obs === undefined) { // Never read: only an absent target may be created. - publishedToken = await createIfAbsent(absolutePath, content) + publishedToken = await createIfAbsent(absolutePath, content, () => task.abort) } else if (absent) { // A "create" on a file that vanished after the read recreates it. - publishedToken = await createIfAbsent(absolutePath, content) + publishedToken = await createIfAbsent(absolutePath, content, () => task.abort) } else { // The version recorded at read time must still match the on-disk // token. - publishedToken = await replaceIfVersion(absolutePath, obs.version, content) + publishedToken = await replaceIfVersion(absolutePath, obs.version, content, () => task.abort) } } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index e661dea447..22869bcf89 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1159,7 +1159,7 @@ describe("DiffViewProvider", () => { // fails closed instead of publishing content built on a preview that could not // be tied to a version token. await expect( - diffViewProvider.saveDirectly("observed.ts", "new content", false, false, 0, "modify"), + diffViewProvider.saveDirectly("observed.ts", "new content", false, false, 0, "update"), ).rejects.toThrow( "File already exists at /mock/cwd/observed.ts and was not read before this write -- read the file first, then retry.", ) From ded6d142430241a59ecf5ea9987e9da15e80eda1 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 18:06:54 +0800 Subject: [PATCH 089/100] fix(diff-view): serialize the placeholder cleanup with the shared advisory lock The token check and the unlink were separate steps, so a peer writer that committed in the gap lost its write. Both now run inside the same resolved-path lock every other writer to this file uses; a differing token leaves the file in place. --- src/integrations/editor/DiffViewProvider.ts | 44 ++++++++++------- .../editor/__tests__/DiffViewProvider.spec.ts | 47 +++++++++++++++++-- 2 files changed, 72 insertions(+), 19 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 14d1c1314a..5a9fbf5bea 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -19,6 +19,8 @@ import { formatResponse } from "../../core/prompts/responses" import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics" import { Task } from "../../core/task/Task" import { versionTokenOfStat } from "../../utils/versionToken" +import { withFileLock } from "../../utils/fileLock" +import { resolveLockKey } from "../../services/file-safety/safeWriteText" import { guardedWrite, GuardRejectedError, type GuardedWriteKind } from "../../core/tools/guardedWrite" import { DecorationController } from "./DecorationController" @@ -558,8 +560,17 @@ export class DiffViewProvider { discardSucceeded = await this.revertDocument(updatedDocument) } if (discardSucceeded && this.editType === "create" && this.placeholderVersion) { - const placeholderStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) - if (placeholderStats && versionTokenOfStat(placeholderStats) === this.placeholderVersion) { + // Cleanup has to be serialized with the same resolved-path advisory lock + // every other writer to this file uses. A token check and an unlink in + // separate steps let a peer writer commit in the gap and lose its write. + // A differing token, or a lock that cannot be taken, leaves the file in place. + await withFileLock(await resolveLockKey(absolutePath), async () => { + const placeholderStats = await fs + .stat(absolutePath, { bigint: true }) + .catch(() => undefined) + if (!placeholderStats || versionTokenOfStat(placeholderStats) !== this.placeholderVersion) { + return + } let unlinked = false try { await fs.unlink(absolutePath) @@ -567,22 +578,23 @@ export class DiffViewProvider { } catch { // the placeholder vanished or the unlink failed } - if (unlinked) { - // The file is gone, so its tab must go too: closing only the diff - // views would leave a clean plain-text tab for a deleted file. - await this.closeFileTab(absolutePath) - // The directories open() created for the new file must go with it, - // innermost first. rmdir refuses a directory another writer populated - // in the meantime, so the cleanup stops at the first failure. - for (let i = this.createdDirs.length - 1; i >= 0; i--) { - try { - await fs.rmdir(this.createdDirs[i]) - } catch { - break - } + if (!unlinked) { + return + } + // The file is gone, so its tab must go too: closing only the diff + // views would leave a clean plain-text tab for a deleted file. + await this.closeFileTab(absolutePath) + // The directories open() created for the new file must go with it, + // innermost first. rmdir refuses a directory another writer populated + // in the meantime, so the cleanup stops at the first failure. + for (let i = this.createdDirs.length - 1; i >= 0; i--) { + try { + await fs.rmdir(this.createdDirs[i]) + } catch { + break } } - } + }) } await this.closeOwnDiffView(absolutePath) } catch { diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 22869bcf89..e25b167839 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -10,6 +10,7 @@ import * as fs from "fs/promises" import { computeVersionToken, versionTokenOfStat } from "../../../utils/versionToken" import type { BigIntStats } from "fs" import { safeWriteText } from "../../../services/file-safety/safeWriteText" +import { withFileLock } from "../../../utils/fileLock" import { ObservationRegistry } from "../../../core/task/observationRegistry" import type { Task } from "../../../core/task/Task" @@ -997,7 +998,7 @@ describe("DiffViewProvider", () => { mockTask.observationRegistry.clear() await expect(diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0)).rejects.toThrow( - "File already exists at /mock/cwd/test.ts and was not read before this write -- read the file first, then retry.", + "File already exists at test.ts and was not read before this write -- read the file first, then retry.", ) expect(safeWriteText).not.toHaveBeenCalled() }) @@ -1161,7 +1162,7 @@ describe("DiffViewProvider", () => { await expect( diffViewProvider.saveDirectly("observed.ts", "new content", false, false, 0, "update"), ).rejects.toThrow( - "File already exists at /mock/cwd/observed.ts and was not read before this write -- read the file first, then retry.", + "File already exists at observed.ts and was not read before this write -- read the file first, then retry.", ) }) it("open() observes the empty placeholder of a new file so the accepted save can be guarded", async () => { @@ -1314,7 +1315,7 @@ describe("DiffViewProvider", () => { const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( - "File already exists at /mock/cwd/test.ts and was not read before this write -- read the file first, then retry.", + "File already exists at test.ts and was not read before this write -- read the file first, then retry.", ) expect(safeWriteText).not.toHaveBeenCalled() }) @@ -1564,6 +1565,46 @@ describe("DiffViewProvider", () => { expect(result.newProblemsMessage).toBe("") }) + it("saveChanges() checks the placeholder token and unlinks inside the same lock", async () => { + // A peer writer that commits between the token check and the unlink would lose + // its write, so the cleanup runs under the same resolved-path advisory lock every + // other writer to this file uses. A token that moved inside the lock window means + // the file is no longer the placeholder and must be left in place. + const mockEditor = mockTextEditor(`${mockCwd}/test.ts`, "new content") + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/test.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + + await diffViewProvider.open("test.ts") + + const lockKeys: string[] = [] + vi.mocked(withFileLock).mockImplementation(async (lockKey, operation) => { + lockKeys.push(lockKey) + if (lockKeys.length === 2) { + // the peer committed while the cleanup waited for the lock + vi.mocked(fs.stat).mockResolvedValue({ ...previewStats, size: 9999n, mtimeNs: 5n, ctimeNs: 6n }) + } + return operation(lockKey) + }) + + // The publish is rejected against the peer's token, so the discard cleanup runs. + vi.mocked(computeVersionToken).mockResolvedValue("peer-token") + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + /Stale version|not read before this write/, + ) + + // One acquisition for the guarded publish, one for the cleanup. + expect(lockKeys).toEqual([`${mockCwd}/test.ts`, `${mockCwd}/test.ts`]) + expect(vi.mocked(fs.unlink)).not.toHaveBeenCalled() + }) + it("clears the dirty buffer via a disk revert after a successful guarded publish", async () => { // The user edited the buffer before accepting, so the document is // dirty: the publish wrote the exact buffer content, and the dirty From 357e24e4710c9ca08f7350e0339248a5d92a88ce Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 18:06:55 +0800 Subject: [PATCH 090/100] fix(apply-patch): keep the model-facing guard path workspace-relative The guard key stays absolute, but a rejection puts its message in the model's context. Rejections now name the caller's own path, matching getReadablePath(), instead of the resolved user-specific absolute path. --- src/core/tools/ApplyPatchTool.ts | 2 +- .../__tests__/applyPatchTool.execute.spec.ts | 8 +-- src/core/tools/__tests__/guardedWrite.spec.ts | 40 +++++++++++---- src/core/tools/guardedWrite.ts | 51 +++++++++++++------ 4 files changed, 71 insertions(+), 30 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index a5023c8698..4043faa3cf 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -476,7 +476,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // downgrade that survives a rejected publish loses a full destination read. throw new GuardRejectedError( `Cannot move a partially read file onto ${change.movePath}: re-read the whole source (${change.path}) first, then retry.`, - moveAbsolutePath, + change.movePath, ) } // The destination was read and then deleted: the create guard permits the diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 18f51a646a..4684ea59fe 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -367,7 +367,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { statMock.mockResolvedValueOnce({ dev: 7n, ino: 4242n, size: 9999n, mtimeNs: 3n, ctimeNs: 4n }) const guardError = new Error( - "File already exists at /workspace/project/src/thing.ts and was not read before this write -- read the file first, then retry.", + "File already exists at src/thing.ts and was not read before this write -- read the file first, then retry.", ) mockSaveDirectly.mockRejectedValue(guardError) @@ -395,7 +395,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { statMock.mockRejectedValueOnce(new Error("EACCES: permission denied")) const guardError = new Error( - "File already exists at /workspace/project/src/thing.ts and was not read before this write -- read the file first, then retry.", + "File already exists at src/thing.ts and was not read before this write -- read the file first, then retry.", ) mockSaveDirectly.mockRejectedValue(guardError) @@ -578,7 +578,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { expect(observedAtPublish).toEqual([false]) await expect(guardedWrite(mockTask as Task, "src/new.ts", "full replacement", "create")).rejects.toThrow( "File already exists at " + - destKey + + "src/new.ts" + " and was not read before this write -- read the file first, then retry.", ) expect(mockHandleError).not.toHaveBeenCalled() @@ -608,7 +608,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { it("update: surfaces the unobserved-existing remediation as a tool error", async () => { const guardError = new Error( - "File already exists at /workspace/project/src/thing.ts and was not read before this write -- read the file first, then retry.", + "File already exists at src/thing.ts and was not read before this write -- read the file first, then retry.", ) mockSaveDirectly.mockRejectedValue(guardError) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index 567f64f187..703cea2717 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -14,7 +14,7 @@ import * as path from "path" import { describe, expect, it, beforeEach, vi } from "vitest" -import { createIfAbsent, guardedWrite, replaceIfVersion, resetChain } from "../guardedWrite" +import { createIfAbsent, guardedWrite, replaceIfVersion, resetChain, GuardRejectedError } from "../guardedWrite" import { safeWriteText } from "../../../services/file-safety/safeWriteText" import { computeVersionToken } from "../../../utils/versionToken" import { withFileLock } from "../../../utils/fileLock" @@ -142,7 +142,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await expect(guardedWrite(task, "existing.txt", "hello", "create")).rejects.toThrow( "File already exists at " + - abs("existing.txt") + + "existing.txt" + " and was not read before this write -- read the file first, then retry.", ) expect(mockedSafeWriteText).not.toHaveBeenCalled() @@ -152,7 +152,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { const failures = [{ code: "EACCES" }, null, "volume offline", new Error("EIO-ish failure")] for (const failure of failures) { mockedFsAccess.mockRejectedValueOnce(failure) - await expect(createIfAbsent(abs("io-error.txt"), "x")).rejects.toBe(failure) + await expect(createIfAbsent(abs("io-error.txt"), "x", "io-error.txt")).rejects.toBe(failure) } expect(mockedSafeWriteText).not.toHaveBeenCalled() }) @@ -179,7 +179,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { const failure = { code: "EACCES" } mockedComputeVersionToken.mockRejectedValueOnce(failure) - await expect(replaceIfVersion(abs("locked.txt"), "v1", "next")).rejects.toBe(failure) + await expect(replaceIfVersion(abs("locked.txt"), "v1", "next", "locked.txt")).rejects.toBe(failure) expect(mockedSafeWriteText).not.toHaveBeenCalled() }) }) @@ -200,7 +200,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await expect(guardedWrite(task, "existing.txt", "hello", "update")).rejects.toThrow( "File already exists at " + - abs("existing.txt") + + "existing.txt" + " and was not read before this write -- read the file first, then retry.", ) expect(mockedSafeWriteText).not.toHaveBeenCalled() @@ -675,7 +675,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { order.push("publish") }) - await replaceIfVersion(abs("a.txt"), "v1", "content") + await replaceIfVersion(abs("a.txt"), "v1", "content", "a.txt") expect(order).toEqual(["lock", "check", "publish", "check", "release"]) expect(mockedWithFileLock).toHaveBeenCalledWith(abs("a.txt"), expect.any(Function)) @@ -702,7 +702,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { return "v1" }) - await createIfAbsent(abs("new.txt"), "hello") + await createIfAbsent(abs("new.txt"), "hello", "new.txt") expect(order).toEqual(["lock", "check", "publish", "token", "release"]) }) @@ -716,7 +716,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) mockedComputeVersionToken.mockResolvedValue("v1") - await createIfAbsent(abs("link.txt"), "hello") + await createIfAbsent(abs("link.txt"), "hello", "link.txt") expect(mockedResolveLockKey).toHaveBeenCalledWith(abs("link.txt")) expect(mockedWithFileLock).toHaveBeenCalledWith(referent, expect.any(Function)) @@ -742,7 +742,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { return undefined }) - const token = await replaceIfVersion(abs("a.txt"), "v1", "content") + const token = await replaceIfVersion(abs("a.txt"), "v1", "content", "a.txt") expect(order).toEqual(["lock", "check", "publish", "check", "release"]) expect(token).toBe("v1") @@ -792,4 +792,26 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(mockedSafeWriteText).not.toHaveBeenCalled() }) + + it("names the caller's path, not the resolved absolute path, in a model-facing rejection", async () => { + // The guard key stays absolute, but the message and the error field go to the + // model, so they must not carry a user-specific absolute path. + mockedFsAccess.mockResolvedValue(undefined) + const task = createMockTask() + + let error: GuardRejectedError | undefined + await guardedWrite(task, "src/thing.ts", "hello", "create").catch((e: unknown) => { + if (e instanceof GuardRejectedError) { + error = e + return + } + throw e + }) + + expect(error?.message).toBe( + "File already exists at src/thing.ts and was not read before this write -- read the file first, then retry.", + ) + expect(error?.path).toBe("src/thing.ts") + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) }) diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index de6919b494..6b95b0f3f9 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -108,11 +108,11 @@ export function errorCode(error: unknown): string | undefined { * chain can outlive its task: the caller already reported the write to the model, * so a task aborted while its link waited must not publish afterwards. */ -function cancelledBeforePublish(absolutePath: string, isCancelled?: () => boolean): void { +function cancelledBeforePublish(absolutePath: string, displayPath: string, isCancelled?: () => boolean): void { if (isCancelled?.()) { throw new GuardRejectedError( "Task was cancelled before this write published -- nothing was written.", - absolutePath, + displayPath, ) } } @@ -146,6 +146,7 @@ async function tokenAfterPublish(absolutePath: string): Promise boolean, @@ -153,7 +154,7 @@ export async function createIfAbsent( // Lock the key every other writer to this file uses: the resolved publish // target, so a symlink alias and its referent share one lock. return withFileLock(await resolveLockKey(absolutePath), async () => { - cancelledBeforePublish(absolutePath, isCancelled) + cancelledBeforePublish(absolutePath, displayPath, isCancelled) try { await fs.access(absolutePath) } catch (error: unknown) { @@ -170,9 +171,9 @@ export async function createIfAbsent( throw new GuardRejectedError( "File already exists at " + - absolutePath + + displayPath + " and was not read before this write -- read the file first, then retry.", - absolutePath, + displayPath, ) }) } @@ -191,12 +192,13 @@ export async function replaceIfVersion( content: string | Uint8Array, // Re-checked under the lock: a link that waited on the FIFO chain can outlive // the task that queued it. + displayPath: string, isCancelled?: () => boolean, ): Promise { // Lock the key every other writer to this file uses: the resolved publish // target, so a symlink alias and its referent share one lock. return withFileLock(await resolveLockKey(absolutePath), async () => { - cancelledBeforePublish(absolutePath, isCancelled) + cancelledBeforePublish(absolutePath, displayPath, isCancelled) let currentVersion: string try { currentVersion = await computeVersionToken(absolutePath) @@ -210,7 +212,7 @@ export async function replaceIfVersion( "File was deleted after it was read -- the version recorded at read time (" + expectedVersion + ") no longer exists; re-read the file, then retry.", - absolutePath, + displayPath, ) } // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. @@ -244,8 +246,8 @@ export async function replaceIfVersion( * Returns Promise because the rejection is total: this function * never resolves. */ -export async function unobservedEditGuard(absolutePath: string): Promise { - throw new GuardRejectedError("File not read yet -- read the file, then retry.", absolutePath) +export async function unobservedEditGuard(absolutePath: string, displayPath: string): Promise { + throw new GuardRejectedError("File not read yet -- read the file, then retry.", displayPath) } // -- Public API -------------------------------------------------------------- @@ -293,15 +295,20 @@ export async function guardedWrite( completeOverride?: boolean, ): Promise { const absolutePath = resolveAbsolutePath(task, relPathOrAbsolute) + // Model-facing path: the caller's own spelling, not the resolved absolute + // path. The guard key stays absolute, but a rejection must not put a + // user-specific absolute path into the model's context. return enqueue(absolutePath, async () => { // Cancellation is checked when the link is dequeued, not when it was enqueued: // a write queued before an abort can still reach its turn on the chain after the // task is gone, and the caller has already reported the write to the model. + const displayPath = relPathOrAbsolute + if (task.abort) { throw new GuardRejectedError( "Task was cancelled before this write ran -- the queued publish is not performed.", - absolutePath, + displayPath, ) } const obs = task.observationRegistry.get(absolutePath) @@ -318,9 +325,15 @@ export async function guardedWrite( // partial observation is valid for the edit itself; the version // check still rejects a file that moved since the read. if (obs === undefined) { - await unobservedEditGuard(absolutePath) + await unobservedEditGuard(absolutePath, displayPath) } else { - publishedToken = await replaceIfVersion(absolutePath, obs.version, content, () => task.abort) + publishedToken = await replaceIfVersion( + absolutePath, + obs.version, + content, + displayPath, + () => task.abort, + ) staysPartial = obs.complete === false } } else { @@ -338,20 +351,26 @@ export async function guardedWrite( throw new GuardRejectedError( "File was only partially read (line slice, range, truncated view, or indentation block) -- " + "a full-file replacement needs the complete content; re-read the whole file, then retry.", - absolutePath, + displayPath, ) } if (obs === undefined) { // Never read: only an absent target may be created. - publishedToken = await createIfAbsent(absolutePath, content, () => task.abort) + publishedToken = await createIfAbsent(absolutePath, content, displayPath, () => task.abort) } else if (absent) { // A "create" on a file that vanished after the read recreates it. - publishedToken = await createIfAbsent(absolutePath, content, () => task.abort) + publishedToken = await createIfAbsent(absolutePath, content, displayPath, () => task.abort) } else { // The version recorded at read time must still match the on-disk // token. - publishedToken = await replaceIfVersion(absolutePath, obs.version, content, () => task.abort) + publishedToken = await replaceIfVersion( + absolutePath, + obs.version, + content, + displayPath, + () => task.abort, + ) } } From bfb90a7b3c9d8913fcdbc0da99f736c4001c3309 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 18:25:32 +0800 Subject: [PATCH 091/100] fix(apply-patch): name the caller's path on a stale-version rejection and document the lock boundary The stale branch was the only guard rejection still carrying the resolved absolute path. The atomicity boundary is also written down: the check and the publish share one lock acquisition, every production caller of the publish primitive holds that same canonical key, and the primitive cannot re-acquire it without deadlocking those callers. --- src/core/tools/__tests__/guardedWrite.spec.ts | 25 +++++++++++++++++++ src/core/tools/guardedWrite.ts | 13 +++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index 703cea2717..4e4f6211b4 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -815,3 +815,28 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(mockedSafeWriteText).not.toHaveBeenCalled() }) }) + +it("names the caller's path on a stale-version rejection as well", async () => { + // The stale branch is the most common rejection, so it has to follow the same rule + // as the create, delete and cancellation branches. + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v2") + const reg = new ObservationRegistry() + reg.observe(abs("src/thing.ts"), "v1", true) + const task = createMockTask({ observationRegistry: reg }) + + let staleError: GuardRejectedError | undefined + await guardedWrite(task, "src/thing.ts", "hello", "update").catch((e: unknown) => { + if (e instanceof GuardRejectedError) { + staleError = e + return + } + throw e + }) + + expect(staleError?.message).toBe( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(staleError?.path).toBe("src/thing.ts") + expect(mockedSafeWriteText).not.toHaveBeenCalled() +}) diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index 6b95b0f3f9..e92aafffd5 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -185,6 +185,17 @@ export async function createIfAbsent( * On a match the content is published via the S3 safeWriteText primitive; on * a mismatch the write is rejected stale with a re-read-then-retry * remediation suffix. + * + * Atomicity boundary: the check and the publish run inside one acquisition of the + * shared advisory lock, and the post-publish token is read under that same lock, so + * no writer that participates in the protocol can interleave here. Every production + * caller of the publish primitive holds the same canonical key (safeWriteJson + * acquires it before publishing; TaskHistoryStore deletion takes it on the same + * resolved key). The primitive itself cannot re-acquire the lock -- withFileLock must + * not be re-entered inside an operation, and the callers already hold it, so wrapping + * it would deadlock them. A writer that never takes the lock is outside the supported + * threat model: a plain rename has no conditional form, so no advisory mechanism can + * bind a checked version to it. */ export async function replaceIfVersion( absolutePath: string, @@ -233,7 +244,7 @@ export async function replaceIfVersion( ", current " + currentVersion + "); re-read the file, then retry.", - absolutePath, + displayPath, ) }) } From 1b74c27062c2d8be07ddcb3e7bc254e085b38a60 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 18:32:21 +0800 Subject: [PATCH 092/100] fix(diff-view): keep a reset inside the task whose save was rejected closeAllDiffViews() closes every clean diff tab in the workbench, so a guard rejection still closed another task's view while that task's provider held its activation listener and deferred scroll timer against a tab that was gone. reset() now closes only this provider's tab when it knows which file it was editing, and label-identified tabs for that file are matched too. --- src/integrations/editor/DiffViewProvider.ts | 31 +++++++++++--- .../editor/__tests__/DiffViewProvider.spec.ts | 41 +++++++++++++++++++ 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 5a9fbf5bea..4de937ca07 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -908,13 +908,24 @@ export class DiffViewProvider { const target = path.resolve(absolutePath) const tabs = vscode.window.tabGroups.all .flatMap((group) => group.tabs) - .filter( - (tab) => + .filter((tab) => { + if (tab.isDirty) { + return false + } + if ( tab.input instanceof vscode.TabInputTextDiff && tab.input.original.scheme === DIFF_VIEW_URI_SCHEME && - path.resolve(tab.input.modified.fsPath) === target && - !tab.isDirty, - ) + path.resolve(tab.input.modified.fsPath) === target + ) { + return true + } + // A diff tab for a file that was already open is identified by its label + // rather than by the URI scheme, so the label has to be matched too. + return ( + typeof tab.label === "string" && + tab.label.startsWith(`${path.basename(target)}: ${DIFF_VIEW_LABEL_CHANGES}`) + ) + }) for (const tab of tabs) { try { await vscode.window.tabGroups.close(tab) @@ -1414,7 +1425,15 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() - await this.closeAllDiffViews() + // A reset belongs to one task. Closing every clean diff tab would close another + // task's view while that task's provider still holds its activation listener and + // deferred scroll timer against a tab that is gone, so the cleanup stays inside + // this provider's view whenever it knows which file it was editing. + if (this.relPath) { + await this.closeOwnDiffView(path.resolve(this.cwd, this.relPath)) + } else { + await this.closeAllDiffViews() + } this.editType = undefined this.isEditing = false this.originalContent = undefined diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index e25b167839..983ee5d0d3 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -885,6 +885,47 @@ describe("DiffViewProvider", () => { }) }) + it("reset() closes only this provider's tab, not another task's", async () => { + // A guard rejection belongs to one task, and every tool caller resets that + // task's provider in its catch block, so the teardown must stay inside this + // provider's view. + const ownTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + label: `test.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + const otherTaskTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/other-task.ts` }, + }, + label: `other-task.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + for (const tab of [ownTab, otherTaskTab]) { + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [ownTab, otherTaskTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { + closedTabs.push(tab) + return Promise.resolve(true) + }) + + diffViewProvider["relPath"] = "test.ts" + await diffViewProvider.reset() + + expect(closedTabs).toEqual([ownTab]) + }) + describe("saveDirectly method", () => { beforeEach(() => { // Mock vscode functions From 25cf13b91810f6711065e26b4a53a6cdefaeb4b9 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 18:52:32 +0800 Subject: [PATCH 093/100] fix(diff-view): identify this provider's tab by URI, not by basename Two tasks can edit files with the same name in different directories, so a label match on the basename could close the other task's clean tab. A label-identified tab now counts only when its own URI points at this provider's target. --- src/integrations/editor/DiffViewProvider.ts | 15 +++--- .../editor/__tests__/DiffViewProvider.spec.ts | 53 ++++++++++++++++++- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 4de937ca07..4a1104625c 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -912,16 +912,17 @@ export class DiffViewProvider { if (tab.isDirty) { return false } - if ( - tab.input instanceof vscode.TabInputTextDiff && - tab.input.original.scheme === DIFF_VIEW_URI_SCHEME && - path.resolve(tab.input.modified.fsPath) === target - ) { - return true + if (tab.input instanceof vscode.TabInputTextDiff) { + return path.resolve(tab.input.modified.fsPath) === target } // A diff tab for a file that was already open is identified by its label - // rather than by the URI scheme, so the label has to be matched too. + // rather than by the URI scheme. A basename alone cannot tell two tasks in + // different directories apart, so the label only counts when the tab's own + // URI points at this provider's target. + const uri = (tab.input as { uri?: { fsPath?: string } })?.uri return ( + typeof uri?.fsPath === "string" && + path.resolve(uri.fsPath) === target && typeof tab.label === "string" && tab.label.startsWith(`${path.basename(target)}: ${DIFF_VIEW_LABEL_CHANGES}`) ) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 983ee5d0d3..df6e09407e 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -64,7 +64,7 @@ vi.mock("../../../utils/fs", () => ({ // Mock path vi.mock("path", () => ({ - resolve: vi.fn((cwd, relPath) => `${cwd}/${relPath}`), + resolve: vi.fn((cwd: string, relPath?: string) => (relPath === undefined ? cwd : `${cwd}/${relPath}`)), isAbsolute: vi.fn((p: string) => p.startsWith("/")), basename: vi.fn((path) => path.split("/").pop()), dirname: vi.fn((path) => path.split("/").slice(0, -1).join("/") || "/"), @@ -883,6 +883,57 @@ describe("DiffViewProvider", () => { expect(closedTabs).toEqual([ownTab]) }) + + it("leaves another task's tab when the same basename sits in another directory", async () => { + // Two tasks can edit files with the same name. Matching by basename alone + // would close the other task's clean tab, so the tab's own URI has to decide. + const ownTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + label: `test.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + const sameNameOtherDir = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: "/other-cwd/test.ts" }, + }, + label: `test.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + // A pre-opened file's tab is identified by its label, so the URI check is the + // only thing that can tell the two apart here. + const labelOnlyOtherDir = { + input: { uri: { fsPath: "/other-cwd/test.ts" } }, + label: `test.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + const labelOnlyOwn = { + input: { uri: { fsPath: `${mockCwd}/test.ts` } }, + label: `test.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + for (const tab of [ownTab, sameNameOtherDir]) { + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [ownTab, sameNameOtherDir, labelOnlyOtherDir, labelOnlyOwn] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { + closedTabs.push(tab) + return Promise.resolve(true) + }) + + await diffViewProvider["closeOwnDiffView"](path.join(mockCwd, "test.ts")) + + expect(closedTabs).toEqual([ownTab, labelOnlyOwn]) + }) }) it("reset() closes only this provider's tab, not another task's", async () => { From 59b650fa0f35129fa9a3d42154d1b4f91c35646e Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 19:11:54 +0800 Subject: [PATCH 094/100] test(apply-patch): put the stale-path test inside the describe block At file scope the suite's beforeEach never ran for it, so it passed only because the previous test left the withFileLock implementation in place. Run alone it failed. --- src/core/tools/__tests__/guardedWrite.spec.ts | 45 +++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index 4e4f6211b4..42a51dcc35 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -814,29 +814,28 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(error?.path).toBe("src/thing.ts") expect(mockedSafeWriteText).not.toHaveBeenCalled() }) -}) + it("names the caller's path on a stale-version rejection as well", async () => { + // The stale branch is the most common rejection, so it has to follow the same rule + // as the create, delete and cancellation branches. + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v2") + const reg = new ObservationRegistry() + reg.observe(abs("src/thing.ts"), "v1", true) + const task = createMockTask({ observationRegistry: reg }) -it("names the caller's path on a stale-version rejection as well", async () => { - // The stale branch is the most common rejection, so it has to follow the same rule - // as the create, delete and cancellation branches. - mockedFsAccess.mockResolvedValue(undefined) - mockedComputeVersionToken.mockResolvedValue("v2") - const reg = new ObservationRegistry() - reg.observe(abs("src/thing.ts"), "v1", true) - const task = createMockTask({ observationRegistry: reg }) - - let staleError: GuardRejectedError | undefined - await guardedWrite(task, "src/thing.ts", "hello", "update").catch((e: unknown) => { - if (e instanceof GuardRejectedError) { - staleError = e - return - } - throw e - }) + let staleError: GuardRejectedError | undefined + await guardedWrite(task, "src/thing.ts", "hello", "update").catch((e: unknown) => { + if (e instanceof GuardRejectedError) { + staleError = e + return + } + throw e + }) - expect(staleError?.message).toBe( - "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", - ) - expect(staleError?.path).toBe("src/thing.ts") - expect(mockedSafeWriteText).not.toHaveBeenCalled() + expect(staleError?.message).toBe( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(staleError?.path).toBe("src/thing.ts") + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) }) From 2656fd2a3e89e11be9c46af1d0eedac6c1e95ff8 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 19:11:54 +0800 Subject: [PATCH 095/100] fix(diff-view): close only Zoo's own diff tab for this provider A Source Control diff the user has open for the same file matched the modified side and was closed by a tool reset. The scheme check the replaced path had is back. --- src/integrations/editor/DiffViewProvider.ts | 7 +++- .../editor/__tests__/DiffViewProvider.spec.ts | 37 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 4a1104625c..588840593b 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -913,7 +913,12 @@ export class DiffViewProvider { return false } if (tab.input instanceof vscode.TabInputTextDiff) { - return path.resolve(tab.input.modified.fsPath) === target + // Only Zoo's own diff tabs, not a Source Control diff the user has open + // for the same file. + return ( + tab.input.original.scheme === DIFF_VIEW_URI_SCHEME && + path.resolve(tab.input.modified.fsPath) === target + ) } // A diff tab for a file that was already open is identified by its label // rather than by the URI scheme. A basename alone cannot tell two tasks in diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index df6e09407e..f07b73c288 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -934,6 +934,43 @@ describe("DiffViewProvider", () => { expect(closedTabs).toEqual([ownTab, labelOnlyOwn]) }) + + it("leaves a Source Control diff the user has open for the same file", async () => { + // A git diff of the same file is the user's tab, not this task's, so a reset + // must not close it. + const ownTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + isDirty: false, + } + const gitDiffTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: "git" }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + isDirty: false, + } + for (const tab of [ownTab, gitDiffTab]) { + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [ownTab, gitDiffTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { + closedTabs.push(tab) + return Promise.resolve(true) + }) + + await diffViewProvider["closeOwnDiffView"](path.join(mockCwd, "test.ts")) + + expect(closedTabs).toEqual([ownTab]) + }) }) it("reset() closes only this provider's tab, not another task's", async () => { From df793c82d4c4900c55253c84761cc14739653859 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 19:11:55 +0800 Subject: [PATCH 096/100] fix(safe-write): do not log the expected miss in the safety-net cleanup The delegate already removes its temp file on failure, so the safety net normally finds it gone. That is the expected outcome, not a second failure, and every failed write logged a misleading cleanup error. --- .../__tests__/safeWriteJson.lockKey.spec.ts | 24 +++++++++++++++++++ src/utils/safeWriteJson.ts | 20 ++++++++++++---- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 68eeee66a7..89bb9735c9 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -149,3 +149,27 @@ describe("safeWriteJson lock key under a peer commit", () => { expect(await resolveLockKey(file)).toBe(path.join(canonicalDir, "history_item.json")) }) }) + +it("does not log a cleanup error when the safety net finds the temp file already gone", async () => { + // safeWriteText removes its own temp file on failure, so the safety net in + // safeWriteJson normally finds it gone. That is the expected outcome, not a + // second failure, and it must not be logged as one. + const dir = await makeDir("cleanup-") + const target = path.join(dir, "history_item.json") + currentLink = "" + mockedRealpath.mockImplementation(async (t) => String(t)) + mockedLstat.mockImplementation(async (t) => symlinkStat(t)) + + const renameSpy = vi.spyOn(fs, "rename").mockRejectedValue(new Error("commit rename failed")) + const unlinkSpy = vi.spyOn(fs, "unlink").mockRejectedValue(enoent) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(safeWriteJson(target, { id: "task-1" })).rejects.toThrow("commit rename failed") + + // Only the original failure is reported. + expect(consoleError).toHaveBeenCalledTimes(1) + + renameSpy.mockRestore() + unlinkSpy.mockRestore() + consoleError.mockRestore() +}) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 753b501852..bae200dd38 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -148,11 +148,21 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso if (newFileToCleanupWithinCatch) { try { await fs.unlink(newFileToCleanupWithinCatch) - } catch (cleanupError) { - console.error( - `[Catch] Failed to clean up temporary new file ${newFileToCleanupWithinCatch}:`, - cleanupError, - ) + } catch (cleanupError: unknown) { + // The expected case: safeWriteText already removed its own temp file, so a + // missing file here is not a cleanup failure worth logging. Returning would + // also swallow the original error the caller needs. + const isAbsent = + typeof cleanupError === "object" && + cleanupError !== null && + "code" in cleanupError && + cleanupError.code === "ENOENT" + if (!isAbsent) { + console.error( + `[Catch] Failed to clean up temporary new file ${newFileToCleanupWithinCatch}:`, + cleanupError, + ) + } } } From 0ba7af01bf29a7fca5390b776a386882a25e855c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 19:19:11 +0800 Subject: [PATCH 097/100] fix(read-file): report clipping and truncation together A slice can cut lines off and also clip a long line inside it. The output paths used else-if, so the clipping notice was hidden whenever truncation was present. --- src/core/tools/ReadFileTool.ts | 10 +++ src/core/tools/__tests__/readFileTool.spec.ts | 69 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 82d3779837..ba1be7deaf 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -352,6 +352,11 @@ export class ReadFileTool extends BaseTool<"read_file"> { To read more: Use the read_file tool with offset=${nextOffset} and limit=${limit}. ${result.content}` + if (result.hasClippedLines) { + // The slice cut lines off and also clipped long lines inside it, so both + // notices belong to the response. + output += `\nNote: Some lines in this view exceed ${MAX_LINE_LENGTH} characters and were clipped in this view.` + } } else if (result.hasClippedLines) { // Every line was returned, so there is no later offset to read: report the // clipping without a next-offset hint, and keep the read incomplete so a @@ -846,6 +851,11 @@ export class ReadFileTool extends BaseTool<"read_file"> { readComplete = !result.wasTruncated && !result.hasClippedLines if (result.wasTruncated) { content += `\n\n[File truncated: showing ${result.returnedLines} of ${result.totalLines} total lines]` + if (result.hasClippedLines) { + // Both notices: the slice was truncated and a line inside it was + // clipped. + content += `\n\n[Some lines exceed the per-line length cap and were clipped in this view]` + } } else if (result.hasClippedLines) { content += `\n\n[Some lines exceed the per-line length cap and were clipped in this view]` } diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index ef96ff6da4..34d69ed4cc 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -1936,6 +1936,39 @@ describe("ReadFileTool", () => { expect(pushed).toContain("1 | a") }) + it("native: a truncated slice that also clipped a line reports both notices", async () => { + // A long line inside a slice that also cut lines off is a plausible case, + // and the response has to say both things. + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 3, + wasTruncated: true, + hasClippedLines: true, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute({ path: "both.ts" }, mockTask as unknown as Task, callbacks) + + const [, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("Showing lines 1-2 of 3 total lines") + expect(pushed).toContain("clipped in this view") + }) + it("native: an offset read that is not truncated still records a partial observation", async () => { const mockTask = createMockTask({ observationRegistry: new ObservationRegistry(), @@ -2099,6 +2132,42 @@ describe("ReadFileTool", () => { expect(pushed).not.toContain("showing 2 of 2 total lines") }) + it("legacy: a truncated slice that also clipped a line reports both notices", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 3, + wasTruncated: true, + hasClippedLines: true, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-both.ts" }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + const [, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("showing 2 of 3 total lines") + expect(pushed).toContain("clipped in this view") + }) + it("legacy: a slice truncated to the default limit records a partial observation", async () => { const mockTask = createMockTask({ observationRegistry: new ObservationRegistry(), From f6d460bdaf1200f12b54e65a4493822e4c680aab Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 19:19:12 +0800 Subject: [PATCH 098/100] fix(diff-view): let one teardown path own a cancelled save Cancellation can reach revertChanges() while a rejected save is still discarding the same buffer, so both paths edited the document and closed the same tabs. The second caller now awaits the cleanup already in flight instead of repeating it. --- src/integrations/editor/DiffViewProvider.ts | 186 ++++++++++-------- .../editor/__tests__/DiffViewProvider.spec.ts | 28 +++ 2 files changed, 134 insertions(+), 80 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 588840593b..caa3ee1d26 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -43,6 +43,7 @@ export class DiffViewProvider { // it when re-showing the edited file afterward. private documentWasPinned = false private relPath?: string + private teardownInFlight: Promise | undefined private newContent?: string private activeDiffEditor?: vscode.TextEditor private fadedOverlayController?: DecorationController @@ -555,48 +556,53 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() - let discardSucceeded = !updatedDocument.isDirty - if (updatedDocument.isDirty) { - discardSucceeded = await this.revertDocument(updatedDocument) - } - if (discardSucceeded && this.editType === "create" && this.placeholderVersion) { - // Cleanup has to be serialized with the same resolved-path advisory lock - // every other writer to this file uses. A token check and an unlink in - // separate steps let a peer writer commit in the gap and lose its write. - // A differing token, or a lock that cannot be taken, leaves the file in place. - await withFileLock(await resolveLockKey(absolutePath), async () => { - const placeholderStats = await fs - .stat(absolutePath, { bigint: true }) - .catch(() => undefined) - if (!placeholderStats || versionTokenOfStat(placeholderStats) !== this.placeholderVersion) { - return - } - let unlinked = false - try { - await fs.unlink(absolutePath) - unlinked = true - } catch { - // the placeholder vanished or the unlink failed - } - if (!unlinked) { - return - } - // The file is gone, so its tab must go too: closing only the diff - // views would leave a clean plain-text tab for a deleted file. - await this.closeFileTab(absolutePath) - // The directories open() created for the new file must go with it, - // innermost first. rmdir refuses a directory another writer populated - // in the meantime, so the cleanup stops at the first failure. - for (let i = this.createdDirs.length - 1; i >= 0; i--) { + await this.runTeardown(async () => { + let discardSucceeded = !updatedDocument.isDirty + if (updatedDocument.isDirty) { + discardSucceeded = await this.revertDocument(updatedDocument) + } + if (discardSucceeded && this.editType === "create" && this.placeholderVersion) { + // Cleanup has to be serialized with the same resolved-path advisory lock + // every other writer to this file uses. A token check and an unlink in + // separate steps let a peer writer commit in the gap and lose its write. + // A differing token, or a lock that cannot be taken, leaves the file in place. + await withFileLock(await resolveLockKey(absolutePath), async () => { + const placeholderStats = await fs + .stat(absolutePath, { bigint: true }) + .catch(() => undefined) + if ( + !placeholderStats || + versionTokenOfStat(placeholderStats) !== this.placeholderVersion + ) { + return + } + let unlinked = false try { - await fs.rmdir(this.createdDirs[i]) + await fs.unlink(absolutePath) + unlinked = true } catch { - break + // the placeholder vanished or the unlink failed } - } - }) - } - await this.closeOwnDiffView(absolutePath) + if (!unlinked) { + return + } + // The file is gone, so its tab must go too: closing only the diff + // views would leave a clean plain-text tab for a deleted file. + await this.closeFileTab(absolutePath) + // The directories open() created for the new file must go with it, + // innermost first. rmdir refuses a directory another writer populated + // in the meantime, so the cleanup stops at the first failure. + for (let i = this.createdDirs.length - 1; i >= 0; i--) { + try { + await fs.rmdir(this.createdDirs[i]) + } catch { + break + } + } + }) + } + await this.closeOwnDiffView(absolutePath) + }) } catch { // cleanup is best-effort; the guard verdict below is the outcome } @@ -806,55 +812,56 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() - if (!fileExists) { - if (updatedDocument.isDirty) { - await updatedDocument.save() - } + await this.runTeardown(async () => { + if (!fileExists) { + if (updatedDocument.isDirty) { + await updatedDocument.save() + } - await this.closeAllDiffViews() - // The file was newly created for this edit; close its transiently - // opened tab before deleting it from disk. - await this.closeFileTab(absolutePath) - await fs.unlink(absolutePath) + await this.closeAllDiffViews() + // The file was newly created for this edit; close its transiently + // opened tab before deleting it from disk. + await this.closeFileTab(absolutePath) + await fs.unlink(absolutePath) - // Remove only the directories we created, in reverse order. - for (let i = this.createdDirs.length - 1; i >= 0; i--) { - await fs.rmdir(this.createdDirs[i]) - } - } else { - // Revert document. - const edit = new vscode.WorkspaceEdit() + // Remove only the directories we created, in reverse order. + for (let i = this.createdDirs.length - 1; i >= 0; i--) { + await fs.rmdir(this.createdDirs[i]) + } + } else { + // Revert document. + const edit = new vscode.WorkspaceEdit() - const fullRange = new vscode.Range( - updatedDocument.positionAt(0), - updatedDocument.positionAt(updatedDocument.getText().length), - ) + const fullRange = new vscode.Range( + updatedDocument.positionAt(0), + updatedDocument.positionAt(updatedDocument.getText().length), + ) - edit.replace(updatedDocument.uri, fullRange, this.stripAllBOMs(this.originalContent ?? "")) + edit.replace(updatedDocument.uri, fullRange, this.stripAllBOMs(this.originalContent ?? "")) - // Apply the edit and save, since contents shouldn't have changed - // this won't show in local history unless of course the user made - // changes and saved during the edit. - await vscode.workspace.applyEdit(edit) - await updatedDocument.save() + // Apply the edit and save, since contents shouldn't have changed + // this won't show in local history unless of course the user made + // changes and saved during the edit. + await vscode.workspace.applyEdit(edit) + await updatedDocument.save() - await this.closeAllDiffViews() + await this.closeAllDiffViews() - // Read auto-close preferences from state; fall back to defaults that - // preserve the existing behavior when unset. - const revertTask = this.taskRef.deref() - const revertState = await revertTask?.providerRef.deref()?.getState() - - await this.keepOrCloseEditedFile( - absolutePath, - false, - revertState?.autoCloseZooOpenedFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, - revertState?.autoCloseZooOpenedFilesAfterUserEdited ?? - DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, - revertState?.autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, - ) - } + // Read auto-close preferences from state; fall back to defaults that + // preserve the existing behavior when unset. + const revertTask = this.taskRef.deref() + const revertState = await revertTask?.providerRef.deref()?.getState() + await this.keepOrCloseEditedFile( + absolutePath, + false, + revertState?.autoCloseZooOpenedFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, + revertState?.autoCloseZooOpenedFilesAfterUserEdited ?? + DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, + revertState?.autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, + ) + } + }) // Restore any preview tabs the diff evicted, reconstructing the user's // prior not-yet-edited tab state. await this.restorePreviewTabs() @@ -940,6 +947,25 @@ export class DiffViewProvider { } } } + /** + * Only one teardown path may act on a document at a time. A cancellation can reach + * revertChanges() while a rejected save is already discarding the same buffer, and + * both would edit the document and close the same tabs. The second caller awaits the + * cleanup already in flight instead of repeating it. + */ + private async runTeardown(cleanup: () => Promise): Promise { + if (this.teardownInFlight !== undefined) { + await this.teardownInFlight + return + } + const inFlight = cleanup() + this.teardownInFlight = inFlight + try { + await inFlight + } finally { + this.teardownInFlight = undefined + } + } // Stop tracking user activation of the target file. Called before any // programmatic showTextDocument so our own re-show never counts as a "touch". diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index f07b73c288..f1972efabd 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2914,6 +2914,34 @@ describe("DiffViewProvider", () => { expect(vscode.window.showTextDocument).not.toHaveBeenCalled() }) + it("revertChanges() does not run a second teardown while one is already in flight", async () => { + // Cancellation can reach revertChanges() while a rejected save is still + // discarding the same buffer. Both paths acting on the document and the same + // tabs is duplicate cleanup, so the second caller waits for the first. + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + const editor = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + diffViewProvider["activeDiffEditor"] = editor + + const first = diffViewProvider.revertChanges() + const second = diffViewProvider.revertChanges() + await Promise.all([first, second]) + + expect(applyEdit).toHaveBeenCalledTimes(1) + }) + it("saveChanges() keeps the file open when the user touched it", async () => { const closeFileTab = vi.fn().mockResolvedValue(undefined) vi.mocked(vscode.window.showTextDocument).mockResolvedValue({ revealRange: vi.fn() } as any) From ddece31643705c5d66bd5030e5367e7ec1d7d862 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 19:47:30 +0800 Subject: [PATCH 099/100] fix(safe-write): report the partial state when a rollback also fails A failed publish that also fails to roll the backup back leaves the canonical target absent while the content survives only at the backup path. The publish error alone told the caller nothing it could act on, so the rollback failure and the backup location now travel with the error and the backup is left in place. --- .../__tests__/safeWriteText.spec.ts | 37 ++++++++++++++++++- src/services/file-safety/safeWriteText.ts | 33 ++++++++++++++--- src/utils/__tests__/safeWriteJson.test.ts | 17 ++++++++- 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index decb7dca5f..e2f947c8bc 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -4,7 +4,7 @@ import { execFile } from "child_process" import type { ChildProcess } from "child_process" import * as path from "path" -import { safeWriteText, type SafeWriteTextOptions } from "../safeWriteText" +import { RollbackFailureError, safeWriteText, type SafeWriteTextOptions } from "../safeWriteText" // Full mock for fs/promises — all methods are vi.fn() stubs vi.mock("fs/promises", () => ({ @@ -359,7 +359,8 @@ describe("safeWriteText", () => { vi.mocked(fs.rename).mockImplementation(async () => { callCount++ if (callCount === 1) return // target -> backup - throw new Error("ENOSPC") // temp -> target fails + if (callCount === 2) throw new Error("ENOSPC") // temp -> target fails + return // the rollback rename succeeds }) await expect(safeWriteText(targetPath, "new data", { backup: true })).rejects.toThrow("ENOSPC") @@ -371,6 +372,38 @@ describe("safeWriteText", () => { expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) }) + it("a failed rollback reports the partial state, not only the publish error", async () => { + // The content is still on disk, but only at the backup path. A caller that gets + // just the publish error has data it cannot find at the expected path. + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + let callCount = 0 + vi.mocked(fs.rename).mockImplementation(async () => { + callCount++ + if (callCount === 1) return // target -> backup + if (callCount === 2) throw new Error("ENOSPC") // temp -> target fails + throw new Error("EACCES") // the rollback rename fails too + }) + + let failure: RollbackFailureError | undefined + await safeWriteText(targetPath, "new data", { backup: true }).catch((e: unknown) => { + if (e instanceof RollbackFailureError) { + failure = e + return + } + throw e + }) + + expect(failure).toBeInstanceOf(RollbackFailureError) + expect(failure?.publishError).toBeInstanceOf(Error) + expect((failure?.publishError as Error).message).toBe("ENOSPC") + expect((failure?.rollbackError as Error).message).toBe("EACCES") + expect(failure?.backupPath).toContain("safeWriteText.bak_") + // The backup is what the caller can still recover, so it must stay on disk. + expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + }) + it("backup:true when target does not exist: no backup created, just commit", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index f91963bb2a..b5f82a4f81 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -34,6 +34,28 @@ export interface SafeWriteTextOptions { tempPath?: string } +/** + * A publish that failed and whose rollback also failed: the content survives only + * at the backup path, not at the canonical target. The publish failure stays the + * cause, and the rollback failure plus the backup location travel with the error so + * the caller can tell what it is looking at. + */ +export class RollbackFailureError extends Error { + readonly publishError: unknown + readonly rollbackError: unknown + readonly backupPath: string + + constructor(publishError: unknown, rollbackError: unknown, backupPath: string) { + super( + "Publish failed and the backup could not be restored to its original path -- the content is preserved at the backup location reported on this error.", + { cause: publishError }, + ) + this.name = "RollbackFailureError" + this.publishError = publishError + this.rollbackError = rollbackError + this.backupPath = backupPath + } +} // -- helpers --------------------------------------------------------------- /** Generate a unique temp file name in the given directory. */ @@ -365,16 +387,17 @@ export async function safeWriteText( await fs.rmdir(stagingDir).catch(() => {}) } } catch (originalError: unknown) { - // -- Rollback / cleanup on failure ---------------------------------- if (backupPath && releaseBackupOnSuccess) { try { await fs.rename(backupPath, targetPath) - } catch { - // rollback failed — do not mask original error + } catch (rollbackError: unknown) { + // The content survives only at the backup path now, and the canonical + // target is gone. Reporting just the publish failure would leave the + // caller with data it cannot find at the expected path, so the + // partial-failure state travels with the error. + throw new RollbackFailureError(originalError, rollbackError, backupPath) } } - - // Always clean up the staging temp file on failure. try { await fs.unlink(tempPath).catch(() => {}) } catch { diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 868b1a7cf1..245af61910 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -4,6 +4,7 @@ import * as path from "path" import * as os from "os" import { safeWriteJson } from "../safeWriteJson" +import { RollbackFailureError } from "../../services/file-safety/safeWriteText" import * as lockfile from "proper-lockfile" // Capture actual implementations before the vi.mock factory runs, @@ -463,7 +464,21 @@ describe("safeWriteJson", () => { }) // The original error must propagate, not the rollback error - await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Primary rename failed") + // The rollback also failed, so the error reports the partial state: the publish + // failure stays the cause and the backup location is named. + let failure: RollbackFailureError | undefined + await safeWriteJson(currentTestFilePath, newData).catch((e: unknown) => { + if (e instanceof RollbackFailureError) { + failure = e + return + } + throw e + }) + + expect(failure).toBeInstanceOf(RollbackFailureError) + expect(failure?.cause).toBeInstanceOf(Error) + expect(failure?.rollbackError).toBeInstanceOf(Error) + expect(failure?.backupPath).toContain("safeWriteText.bak_") // The rollback failed inside safeWriteText, so the target is gone and // the backup is orphaned on disk. From 6768ccfafba9fc8432faacab5f4a55efa4c95a43 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 19:47:30 +0800 Subject: [PATCH 100/100] fix(apply-patch): recheck cancellation before publication starts A task can be aborted while the guard awaits its preflight, so the publish started for a task the caller had already reported as gone. Cancellation is now rechecked after the awaited preflight and immediately before publication. --- src/core/tools/__tests__/guardedWrite.spec.ts | 18 ++++++++++++++++++ src/core/tools/guardedWrite.ts | 9 +++++++++ 2 files changed, 27 insertions(+) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index 42a51dcc35..e111bd74db 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -793,6 +793,24 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(mockedSafeWriteText).not.toHaveBeenCalled() }) + it("re-checks cancellation after the awaited preflight, before publication starts", async () => { + // The abort lands while the version token is being computed. The guard has to + // refuse on the way to the publish, not write for a task that is already gone. + mockedComputeVersionToken.mockImplementation(async () => { + task.abort = true + return "v1" + }) + const reg = new ObservationRegistry() + reg.observe(abs("a.txt"), "v1", true) + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "a.txt", "content", "update")).rejects.toThrow( + "Task was cancelled before this write published -- nothing was written.", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + it("names the caller's path, not the resolved absolute path, in a model-facing rejection", async () => { // The guard key stays absolute, but the message and the error field go to the // model, so they must not carry a user-specific absolute path. diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index e92aafffd5..13d870308f 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -162,6 +162,8 @@ export async function createIfAbsent( // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. throw error } + // Immediately before publication starts. + cancelledBeforePublish(absolutePath, displayPath, isCancelled) await safeWriteText(absolutePath, content) // Read the new token under the same lock, otherwise a peer lock-using // writer can publish in the gap and the caller records that writer's @@ -230,7 +232,14 @@ export async function replaceIfVersion( throw error } + // Re-checked after the awaited preflight: the task can be aborted while this + // operation waits, and a publish that starts after that is a write the caller + // has already reported as not performed. + cancelledBeforePublish(absolutePath, displayPath, isCancelled) + if (currentVersion === expectedVersion) { + // Immediately before publication starts. + cancelledBeforePublish(absolutePath, displayPath, isCancelled) await safeWriteText(absolutePath, content) // Read the new token under the same lock, otherwise a peer lock-using // writer can publish in the gap and the caller records that writer's