From 18fd0b99ead7f41155e894072c1624f4451b1c7b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:05:28 +0900 Subject: [PATCH 01/15] test: cover applyPatchDetailed accumulates per-hunk failures --- test/index.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/index.test.ts b/test/index.test.ts index 4c28a42..aa8c568 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -6,6 +6,7 @@ import { APPLY_PATCH_LARK_GRAMMAR, type ApplyPatchExtensionAPI, applyPatch, + applyPatchDetailed, createApplyPatchTool, extractPatchedPaths, type FreeformToolFormat, @@ -412,6 +413,31 @@ EOF`; await expect(applyPatch(directory, patch)).rejects.toThrow("Failed to find expected lines in modify.txt"); }); + it("#given partial patch failure #when applying detailed #then accumulates applied and failed files", async () => { + // given + const directory = await createTempDirectory(); + await writeFile(path.join(directory, "ok.txt"), "before\n", "utf-8"); + await writeFile(path.join(directory, "broken.txt"), "line\n", "utf-8"); + const patch = `*** Begin Patch +*** Update File: ok.txt +@@ +-before ++after +*** Update File: broken.txt +@@ +-missing ++changed +*** End Patch`; + + // when + const result = await applyPatchDetailed(directory, patch); + + // then + expect(result.appliedFiles).toEqual(["ok.txt"]); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.filePath).toBe("broken.txt"); + }); + it("#given patch text #when extracting paths #then returns touched files", () => { // given const patch = `*** Begin Patch From 8c4ba20258fa6a0f46aade491e1663ee0f383276 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:05:49 +0900 Subject: [PATCH 02/15] feat: introduce ApplyPatchResult, ApplyPatchFailure, ApplyPatchRecoveryInstructions types and ApplyPatchError class --- src/index.ts | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/index.ts b/src/index.ts index 1daef01..3845121 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,8 +66,47 @@ type ApplyPatchPreview = { type ApplyPatchToolDetails = { preview?: ApplyPatchPreview; + result?: ApplyPatchResult; }; +export type ApplyPatchFailure = { + filePath: string; + operation: ApplyPatchOperation; + message: string; +}; + +export type ApplyPatchRecoveryInstructions = { + mustReadFiles: string[]; + mustNotReadFiles: string[]; +}; + +export type ApplyPatchResult = { + summaries: string[]; + appliedFiles: string[]; + failures: ApplyPatchFailure[]; + hasPartialSuccess: boolean; + recoveryInstructions: ApplyPatchRecoveryInstructions; + details: { + fuzz: number; + }; +}; + +export class ApplyPatchError extends Error { + public readonly failures: ApplyPatchFailure[]; + public readonly result: ApplyPatchResult; + + constructor(message: string, result: ApplyPatchResult) { + super(message); + this.name = "ApplyPatchError"; + this.failures = result.failures; + this.result = result; + } + + hasPartialSuccess(): boolean { + return this.result.hasPartialSuccess; + } +} + type ApplyPatchThemeColor = | "accent" | "error" From b13ce155f1ea75b935a580bce8abbf3560e5d6e6 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:06:40 +0900 Subject: [PATCH 03/15] feat: implement applyPatchDetailed with failure accumulation --- src/index.ts | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/index.ts b/src/index.ts index 3845121..4cb7219 100644 --- a/src/index.ts +++ b/src/index.ts @@ -655,6 +655,80 @@ async function applyParsedPatch(cwd: string, hunks: ParsedPatch[]): Promise { + const absolutePath = await resolveWorkspacePath(cwd, hunk.filePath); + if (hunk.type === "add") { + await mkdir(path.dirname(absolutePath), { recursive: true }); + await assertWorkspacePath(cwd, absolutePath); + await writeFile(absolutePath, hunk.content, "utf-8"); + return { summary: `add: ${hunk.filePath}`, appliedFile: hunk.filePath, fuzz: 0 }; + } + + if (hunk.type === "delete") { + await stat(absolutePath); + await assertWorkspacePath(cwd, absolutePath); + await rm(absolutePath); + return { summary: `delete: ${hunk.filePath}`, appliedFile: hunk.filePath, fuzz: 0 }; + } + + const currentContent = await readFile(absolutePath, "utf-8"); + const nextContent = + hunk.chunks.length === 0 ? currentContent : replaceChunks(currentContent, hunk.filePath, hunk.chunks); + + if (hunk.movePath) { + const absoluteMovePath = await resolveWorkspacePath(cwd, hunk.movePath); + await mkdir(path.dirname(absoluteMovePath), { recursive: true }); + await assertWorkspacePath(cwd, absoluteMovePath); + await writeFile(absoluteMovePath, nextContent, "utf-8"); + if (absoluteMovePath !== absolutePath) { + await rm(absolutePath); + } + return { summary: `move: ${hunk.filePath} -> ${hunk.movePath}`, appliedFile: hunk.movePath, fuzz: 0 }; + } + + await assertWorkspacePath(cwd, absolutePath); + await writeFile(absolutePath, nextContent, "utf-8"); + return { summary: `update: ${hunk.filePath}`, appliedFile: hunk.filePath, fuzz: 0 }; +} + +export async function applyPatchDetailed(cwd: string, patchText: string): Promise { + const hunks = parsePatch(patchText); + if (hunks.length === 0) { + const normalized = normalizePatchText(patchText).trim(); + if (normalized === "*** Begin Patch\n*** End Patch") { + throw new Error("patch rejected: empty patch"); + } + throw new Error("apply_patch verification failed: no hunks found"); + } + + const summaries: string[] = []; + const appliedFiles: string[] = []; + const failures: ApplyPatchFailure[] = []; + + for (const hunk of hunks) { + try { + const { summary, appliedFile } = await applySingleHunk(cwd, hunk); + summaries.push(summary); + appliedFiles.push(appliedFile); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failures.push({ filePath: hunk.filePath, operation: hunk.type, message }); + } + } + + return { + summaries, + appliedFiles, + failures, + hasPartialSuccess: appliedFiles.length > 0 && failures.length > 0, + recoveryInstructions: { mustReadFiles: [], mustNotReadFiles: [] }, + details: { fuzz: 0 }, + }; +} + export async function applyPatch(cwd: string, patchText: string): Promise { const hunks = parsePatch(patchText); if (hunks.length === 0) { From e773710f51970127f676b97f3f4d80f765721a1f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:07:12 +0900 Subject: [PATCH 04/15] feat: build mustReadFiles/mustNotReadFiles in createRecoveryInstructions --- src/index.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 4cb7219..826874c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -719,7 +719,7 @@ export async function applyPatchDetailed(cwd: string, patchText: string): Promis } } - return { + const result: ApplyPatchResult = { summaries, appliedFiles, failures, @@ -727,6 +727,16 @@ export async function applyPatchDetailed(cwd: string, patchText: string): Promis recoveryInstructions: { mustReadFiles: [], mustNotReadFiles: [] }, details: { fuzz: 0 }, }; + result.recoveryInstructions = createRecoveryInstructions(result); + return result; +} + +function createRecoveryInstructions( + result: Pick, +): ApplyPatchRecoveryInstructions { + const mustReadFiles = [...new Set(result.failures.map((failure) => failure.filePath))]; + const mustNotReadFiles = [...new Set(result.appliedFiles.filter((filePath) => !mustReadFiles.includes(filePath)))]; + return { mustReadFiles, mustNotReadFiles }; } export async function applyPatch(cwd: string, patchText: string): Promise { From f378f0b468c557204284e48aec9e31afff141776 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:07:52 +0900 Subject: [PATCH 05/15] feat: route tool execute() through applyPatchDetailed for partial-failure recovery message --- src/index.ts | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 826874c..54027e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -895,10 +895,36 @@ export function createApplyPatchTool(): ApplyPatchToolDefinition { details: pendingUpdate.details, }); - const summaries = await applyPatch(ctx.cwd, normalizedParams.input); + const result = await applyPatchDetailed(ctx.cwd, normalizedParams.input); + if (result.failures.length > 0) { + const failed = result.recoveryInstructions.mustReadFiles.join(", "); + const mustReadText = failed.includes(",") ? failed.split(", ").join(" and ") : failed; + return { + content: [ + { + type: "text", + text: [ + "apply_patch partially failed.", + `Failed: ${failed}`, + `Recovery: MUST read ${mustReadText} before retrying.`, + result.appliedFiles.length > 0 + ? "Earlier file actions in this patch were already applied." + : "No file actions were applied.", + result.recoveryInstructions.mustNotReadFiles.length > 0 + ? "Recovery: MUST NOT reread other files from this patch unless a specific dependency requires it." + : "", + ] + .filter((line) => line.length > 0) + .join("\n"), + }, + ], + details: { result }, + }; + } + return { - content: [{ type: "text", text: summaries.join("\n") }], - details: {}, + content: [{ type: "text", text: result.summaries.join("\n") }], + details: { result }, }; }, renderCall(_args, theme) { From 21dfaedcc81830b1abc9ea62b9a474811f91623b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:08:19 +0900 Subject: [PATCH 06/15] test: cover applyPatch retains fail-fast on first hunk failure --- test/index.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/index.test.ts b/test/index.test.ts index aa8c568..6501e97 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -436,6 +436,29 @@ EOF`; expect(result.appliedFiles).toEqual(["ok.txt"]); expect(result.failures).toHaveLength(1); expect(result.failures[0]?.filePath).toBe("broken.txt"); + expect(result.recoveryInstructions.mustReadFiles).toEqual(["broken.txt"]); + expect(result.recoveryInstructions.mustNotReadFiles).toEqual(["ok.txt"]); + }); + + it("#given partial patch failure #when applying compat api #then fails fast after first error", async () => { + // given + const directory = await createTempDirectory(); + await writeFile(path.join(directory, "broken.txt"), "line\n", "utf-8"); + await writeFile(path.join(directory, "later.txt"), "before\n", "utf-8"); + const patch = `*** Begin Patch +*** Update File: broken.txt +@@ +-missing ++changed +*** Update File: later.txt +@@ +-before ++after +*** End Patch`; + + // when / then + await expect(applyPatch(directory, patch)).rejects.toThrow("Failed to find expected lines in broken.txt"); + expect(await readFile(path.join(directory, "later.txt"), "utf-8")).toBe("before\n"); }); it("#given patch text #when extracting paths #then returns touched files", () => { From 3304b89c794746670aa71878d3ad52f47218d5aa Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:09:23 +0900 Subject: [PATCH 07/15] refactor: keep legacy applyPatch fail-fast semantics --- src/index.ts | 71 ++++++++++++++++++---------------------------------- 1 file changed, 25 insertions(+), 46 deletions(-) diff --git a/src/index.ts b/src/index.ts index 54027e8..d5b95d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -610,51 +610,6 @@ function replaceChunks(content: string, filePath: string, chunks: PatchChunk[]): return nextLines.join("\n"); } -async function applyParsedPatch(cwd: string, hunks: ParsedPatch[]): Promise { - const summaries: string[] = []; - - for (const hunk of hunks) { - const absolutePath = await resolveWorkspacePath(cwd, hunk.filePath); - if (hunk.type === "add") { - await mkdir(path.dirname(absolutePath), { recursive: true }); - await assertWorkspacePath(cwd, absolutePath); - await writeFile(absolutePath, hunk.content, "utf-8"); - summaries.push(`add: ${hunk.filePath}`); - continue; - } - - if (hunk.type === "delete") { - await stat(absolutePath); - await assertWorkspacePath(cwd, absolutePath); - await rm(absolutePath); - summaries.push(`delete: ${hunk.filePath}`); - continue; - } - - const currentContent = await readFile(absolutePath, "utf-8"); - const nextContent = - hunk.chunks.length === 0 ? currentContent : replaceChunks(currentContent, hunk.filePath, hunk.chunks); - - if (hunk.movePath) { - const absoluteMovePath = await resolveWorkspacePath(cwd, hunk.movePath); - await mkdir(path.dirname(absoluteMovePath), { recursive: true }); - await assertWorkspacePath(cwd, absoluteMovePath); - await writeFile(absoluteMovePath, nextContent, "utf-8"); - if (absoluteMovePath !== absolutePath) { - await rm(absolutePath); - } - summaries.push(`move: ${hunk.filePath} -> ${hunk.movePath}`); - continue; - } - - await assertWorkspacePath(cwd, absolutePath); - await writeFile(absolutePath, nextContent, "utf-8"); - summaries.push(`update: ${hunk.filePath}`); - } - - return summaries; -} - async function applySingleHunk( cwd: string, hunk: ParsedPatch, @@ -749,7 +704,31 @@ export async function applyPatch(cwd: string, patchText: string): Promise 0, + recoveryInstructions: createRecoveryInstructions({ + appliedFiles, + failures: [{ filePath: hunk.filePath, operation: hunk.type, message }], + }), + details: { fuzz: 0 }, + }; + throw new ApplyPatchError(message, result); + } + } + + return summaries; } async function createPendingPatchUpdate( From bc2e43c7257d8de3b25a192f91e822345889cec1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:09:48 +0900 Subject: [PATCH 08/15] test: cover seekSequence returning fuzz tier 0/1/100/10000 --- test/index.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/index.test.ts b/test/index.test.ts index 6501e97..caaf6d3 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -461,6 +461,30 @@ EOF`; expect(await readFile(path.join(directory, "later.txt"), "utf-8")).toBe("before\n"); }); + it("#given fuzzy matches across hunks #when applying detailed #then aggregates fuzz score", async () => { + // given + const directory = await createTempDirectory(); + await writeFile(path.join(directory, "trim-end.txt"), "keep trailing \n", "utf-8"); + await writeFile(path.join(directory, "normalize.txt"), "name = “old”\n", "utf-8"); + const patch = `*** Begin Patch +*** Update File: trim-end.txt +@@ +-keep trailing ++keep trailing updated +*** Update File: normalize.txt +@@ +-name = "old" ++name = "new" +*** End Patch`; + + // when + const result = await applyPatchDetailed(directory, patch); + + // then + expect(result.failures).toEqual([]); + expect(result.details.fuzz).toBe(10001); + }); + it("#given patch text #when extracting paths #then returns touched files", () => { // given const patch = `*** Begin Patch From 2db695eaebcf336a6af4142f8abaa01f5b9fcf96 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:11:09 +0900 Subject: [PATCH 09/15] feat: extend seekSequence to return {index, fuzz} tuple --- src/index.ts | 52 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/src/index.ts b/src/index.ts index d5b95d7..fbab105 100644 --- a/src/index.ts +++ b/src/index.ts @@ -193,9 +193,14 @@ function normalizeSeekLine(line: string): string { .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " "); } -function seekSequence(lines: string[], pattern: string[], start: number, eof: boolean): number | undefined { +function seekSequence( + lines: string[], + pattern: string[], + start: number, + eof: boolean, +): { index: number; fuzz: 0 | 1 | 100 | 10000 } | undefined { if (pattern.length === 0) { - return start; + return { index: start, fuzz: 0 }; } if (pattern.length > lines.length) { return undefined; @@ -216,22 +221,22 @@ function seekSequence(lines: string[], pattern: string[], start: number, eof: bo for (let index = searchStart; index <= lastStart; index++) { if (matches(index, (line, expected) => line === expected)) { - return index; + return { index, fuzz: 0 }; } } for (let index = searchStart; index <= lastStart; index++) { if (matches(index, (line, expected) => line.trimEnd() === expected.trimEnd())) { - return index; + return { index, fuzz: 1 }; } } for (let index = searchStart; index <= lastStart; index++) { if (matches(index, (line, expected) => line.trim() === expected.trim())) { - return index; + return { index, fuzz: 100 }; } } for (let index = searchStart; index <= lastStart; index++) { if (matches(index, (line, expected) => normalizeSeekLine(line) === normalizeSeekLine(expected))) { - return index; + return { index, fuzz: 10000 }; } } @@ -389,7 +394,8 @@ async function createPatchPreview(cwd: string, hunks: ParsedPatch[]): Promise ${hunk.movePath}`, appliedFile: hunk.movePath, fuzz: 0 }; + return { + summary: `move: ${hunk.filePath} -> ${hunk.movePath}`, + appliedFile: hunk.movePath, + fuzz: chunkResult.fuzz, + }; } await assertWorkspacePath(cwd, absolutePath); await writeFile(absolutePath, nextContent, "utf-8"); - return { summary: `update: ${hunk.filePath}`, appliedFile: hunk.filePath, fuzz: 0 }; + return { summary: `update: ${hunk.filePath}`, appliedFile: hunk.filePath, fuzz: chunkResult.fuzz }; } export async function applyPatchDetailed(cwd: string, patchText: string): Promise { From 0ceb2b7f096e43c0d6f5a226fb4c570ab1c7ae2e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:11:28 +0900 Subject: [PATCH 10/15] feat: aggregate fuzz across hunks into result.details.fuzz --- src/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index fbab105..9fc740c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -678,12 +678,14 @@ export async function applyPatchDetailed(cwd: string, patchText: string): Promis const summaries: string[] = []; const appliedFiles: string[] = []; const failures: ApplyPatchFailure[] = []; + let fuzz = 0; for (const hunk of hunks) { try { - const { summary, appliedFile } = await applySingleHunk(cwd, hunk); + const { summary, appliedFile, fuzz: hunkFuzz } = await applySingleHunk(cwd, hunk); summaries.push(summary); appliedFiles.push(appliedFile); + fuzz += hunkFuzz; } catch (error) { const message = error instanceof Error ? error.message : String(error); failures.push({ filePath: hunk.filePath, operation: hunk.type, message }); @@ -696,7 +698,7 @@ export async function applyPatchDetailed(cwd: string, patchText: string): Promis failures, hasPartialSuccess: appliedFiles.length > 0 && failures.length > 0, recoveryInstructions: { mustReadFiles: [], mustNotReadFiles: [] }, - details: { fuzz: 0 }, + details: { fuzz }, }; result.recoveryInstructions = createRecoveryInstructions(result); return result; From fe29036a7a1fba9dac310f736f84652ed5131441 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:11:56 +0900 Subject: [PATCH 11/15] test: cover tool recovery message for partial applyPatchDetailed failure --- test/index.test.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/index.test.ts b/test/index.test.ts index caaf6d3..53bb873 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -485,6 +485,38 @@ EOF`; expect(result.details.fuzz).toBe(10001); }); + it("#given apply patch tool partial failure #when executed #then returns recovery instructions text", async () => { + // given + const directory = await createTempDirectory(); + await writeFile(path.join(directory, "ok.txt"), "before\n", "utf-8"); + await writeFile(path.join(directory, "broken.txt"), "line\n", "utf-8"); + const patch = `*** Begin Patch +*** Update File: ok.txt +@@ +-before ++after +*** Update File: broken.txt +@@ +-missing ++changed +*** End Patch`; + + // when + const result = await createApplyPatchTool().execute("apply-patch-test", { input: patch }, undefined, undefined, { + cwd: directory, + } as never); + + // then + const text = result.content.find((block) => block.type === "text")?.text ?? ""; + expect(text).toContain("apply_patch partially failed."); + expect(text).toContain("Failed: broken.txt"); + expect(text).toContain("Recovery: MUST read broken.txt before retrying."); + expect(text).toContain("Earlier file actions in this patch were already applied."); + expect(text).toContain( + "Recovery: MUST NOT reread other files from this patch unless a specific dependency requires it.", + ); + }); + it("#given patch text #when extracting paths #then returns touched files", () => { // given const patch = `*** Begin Patch From c8fec833bb159ea75e06e3172ac2aa61df0450f6 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:23:16 +0900 Subject: [PATCH 12/15] test: cover writeFileAtomic Windows EEXIST fallback --- test/index.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/index.test.ts b/test/index.test.ts index 53bb873..fb66ad7 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2,6 +2,7 @@ import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + __testWriteFileAtomic, APPLY_PATCH_FREEFORM_DESCRIPTION, APPLY_PATCH_LARK_GRAMMAR, type ApplyPatchExtensionAPI, @@ -517,6 +518,35 @@ EOF`; ); }); + it("#given eexist on rename #when writing atomically #then retries after unlink", async () => { + // given + const calls: string[] = []; + let renameCount = 0; + const operations = { + async writeFile() { + calls.push("writeFile"); + }, + async rename() { + renameCount += 1; + calls.push(`rename:${renameCount}`); + if (renameCount === 1) { + const error = new Error("exists") as Error & { code?: string }; + error.code = "EEXIST"; + throw error; + } + }, + async unlink() { + calls.push("unlink"); + }, + }; + + // when + await __testWriteFileAtomic("/tmp/target.txt", "content", operations); + + // then + expect(calls).toEqual(["writeFile", "rename:1", "unlink", "rename:2"]); + }); + it("#given patch text #when extracting paths #then returns touched files", () => { // given const patch = `*** Begin Patch From 8c5ae8c8f1e14b475ed2c0ac7e3d0e0cd0776007 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:23:48 +0900 Subject: [PATCH 13/15] feat: add writeFileAtomic helper with EEXIST fallback --- src/index.ts | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 9fc740c..1bb28ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; +import { mkdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { Model } from "@mariozechner/pi-ai"; @@ -124,6 +124,48 @@ type ApplyPatchTheme = { inverse: (text: string) => string; }; +type AtomicWriteOperations = { + writeFile: (filePath: string, content: string, encoding: "utf-8") => Promise; + rename: (fromPath: string, toPath: string) => Promise; + unlink: (filePath: string) => Promise; +}; + +const ATOMIC_WRITE_OPERATIONS: AtomicWriteOperations = { + writeFile, + rename, + unlink, +}; + +function hasErrorCode(error: unknown, code: string): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} + +async function writeFileAtomic( + absPath: string, + content: string, + operations: AtomicWriteOperations = ATOMIC_WRITE_OPERATIONS, +): Promise { + const tempPath = `${absPath}.tmp.${process.pid}.${Math.random().toString(16).slice(2)}`; + await operations.writeFile(tempPath, content, "utf-8"); + try { + await operations.rename(tempPath, absPath); + } catch (error) { + if (!hasErrorCode(error, "EEXIST")) { + throw error; + } + await operations.unlink(absPath); + await operations.rename(tempPath, absPath); + } +} + +export async function __testWriteFileAtomic( + absPath: string, + content: string, + operations: AtomicWriteOperations, +): Promise { + await writeFileAtomic(absPath, content, operations); +} + const GPT_APPLY_PATCH_PROVIDERS = new Set(["openai", "azure-openai-responses", "github-copilot"]); function normalizeApplyPatchArguments(args: unknown): ApplyPatchParams { From fa4622e09d245c7dfdf740a70065a50ce5c0c141 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:24:12 +0900 Subject: [PATCH 14/15] test: cover patch flow leaves no .tmp artifacts after success --- test/index.test.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/test/index.test.ts b/test/index.test.ts index fb66ad7..74481bb 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { @@ -518,6 +518,26 @@ EOF`; ); }); + it("#given successful patch write #when applying patch #then atomic temp files are cleaned", async () => { + // given + const directory = await createTempDirectory(); + await writeFile(path.join(directory, "atomic.txt"), "before\n", "utf-8"); + const patch = `*** Begin Patch +*** Update File: atomic.txt +@@ +-before ++after +*** End Patch`; + + // when + await applyPatch(directory, patch); + + // then + expect(await readFile(path.join(directory, "atomic.txt"), "utf-8")).toBe("after\n"); + const files = await readdir(directory); + expect(files.some((name) => name.includes(".tmp."))).toBe(false); + }); + it("#given eexist on rename #when writing atomically #then retries after unlink", async () => { // given const calls: string[] = []; From c9480e75d2f933635f1130783d245fc9d2cdb150 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:24:34 +0900 Subject: [PATCH 15/15] refactor: route patch writes through writeFileAtomic --- src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1bb28ff..9f2f012 100644 --- a/src/index.ts +++ b/src/index.ts @@ -669,7 +669,7 @@ async function applySingleHunk( if (hunk.type === "add") { await mkdir(path.dirname(absolutePath), { recursive: true }); await assertWorkspacePath(cwd, absolutePath); - await writeFile(absolutePath, hunk.content, "utf-8"); + await writeFileAtomic(absolutePath, hunk.content); return { summary: `add: ${hunk.filePath}`, appliedFile: hunk.filePath, fuzz: 0 }; } @@ -691,7 +691,7 @@ async function applySingleHunk( const absoluteMovePath = await resolveWorkspacePath(cwd, hunk.movePath); await mkdir(path.dirname(absoluteMovePath), { recursive: true }); await assertWorkspacePath(cwd, absoluteMovePath); - await writeFile(absoluteMovePath, nextContent, "utf-8"); + await writeFileAtomic(absoluteMovePath, nextContent); if (absoluteMovePath !== absolutePath) { await rm(absolutePath); } @@ -703,7 +703,7 @@ async function applySingleHunk( } await assertWorkspacePath(cwd, absolutePath); - await writeFile(absolutePath, nextContent, "utf-8"); + await writeFileAtomic(absolutePath, nextContent); return { summary: `update: ${hunk.filePath}`, appliedFile: hunk.filePath, fuzz: chunkResult.fuzz }; }