fix: parallel edits sometimes would override each other (#23483)

This commit is contained in:
Aiden Cline
2026-04-20 00:14:21 -05:00
committed by GitHub
parent 93e633fb7d
commit 8bc4f91fd9
2 changed files with 97 additions and 73 deletions
+71 -57
View File
@@ -5,7 +5,7 @@
import z from "zod" import z from "zod"
import * as path from "path" import * as path from "path"
import { Effect } from "effect" import { Effect, Semaphore } from "effect"
import * as Tool from "./tool" import * as Tool from "./tool"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { createTwoFilesPatch, diffLines } from "diff" import { createTwoFilesPatch, diffLines } from "diff"
@@ -32,6 +32,18 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
return text.replaceAll("\n", "\r\n") return text.replaceAll("\n", "\r\n")
} }
const locks = new Map<string, Semaphore.Semaphore>()
function lock(filePath: string) {
const resolvedFilePath = AppFileSystem.resolve(filePath)
const hit = locks.get(resolvedFilePath)
if (hit) return hit
const next = Semaphore.makeUnsafe(1)
locks.set(resolvedFilePath, next)
return next
}
const Parameters = z.object({ const Parameters = z.object({
filePath: z.string().describe("The absolute path to the file to modify"), filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"), oldString: z.string().describe("The text to replace"),
@@ -68,11 +80,50 @@ export const EditTool = Tool.define(
let diff = "" let diff = ""
let contentOld = "" let contentOld = ""
let contentNew = "" let contentNew = ""
yield* Effect.gen(function* () { yield* lock(filePath).withPermits(1)(
if (params.oldString === "") { Effect.gen(function* () {
const existed = yield* afs.existsSafe(filePath) if (params.oldString === "") {
contentNew = params.newString const existed = yield* afs.existsSafe(filePath)
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) contentNew = params.newString
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
yield* ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
yield* afs.writeWithDirs(filePath, params.newString)
yield* format.file(filePath)
yield* bus.publish(File.Event.Edited, { file: filePath })
yield* bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: existed ? "change" : "add",
})
return
}
const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info) throw new Error(`File ${filePath} not found`)
if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`)
contentOld = yield* afs.readFileString(filePath)
const ending = detectLineEnding(contentOld)
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
contentNew = replace(contentOld, old, next, params.replaceAll)
diff = trimDiff(
createTwoFilesPatch(
filePath,
filePath,
normalizeLineEndings(contentOld),
normalizeLineEndings(contentNew),
),
)
yield* ctx.ask({ yield* ctx.ask({
permission: "edit", permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)], patterns: [path.relative(Instance.worktree, filePath)],
@@ -82,62 +133,25 @@ export const EditTool = Tool.define(
diff, diff,
}, },
}) })
yield* afs.writeWithDirs(filePath, params.newString)
yield* afs.writeWithDirs(filePath, contentNew)
yield* format.file(filePath) yield* format.file(filePath)
yield* bus.publish(File.Event.Edited, { file: filePath }) yield* bus.publish(File.Event.Edited, { file: filePath })
yield* bus.publish(FileWatcher.Event.Updated, { yield* bus.publish(FileWatcher.Event.Updated, {
file: filePath, file: filePath,
event: existed ? "change" : "add", event: "change",
}) })
return contentNew = yield* afs.readFileString(filePath)
} diff = trimDiff(
createTwoFilesPatch(
const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined))) filePath,
if (!info) throw new Error(`File ${filePath} not found`) filePath,
if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`) normalizeLineEndings(contentOld),
contentOld = yield* afs.readFileString(filePath) normalizeLineEndings(contentNew),
),
const ending = detectLineEnding(contentOld) )
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending) }).pipe(Effect.orDie),
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending) )
contentNew = replace(contentOld, old, next, params.replaceAll)
diff = trimDiff(
createTwoFilesPatch(
filePath,
filePath,
normalizeLineEndings(contentOld),
normalizeLineEndings(contentNew),
),
)
yield* ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
yield* afs.writeWithDirs(filePath, contentNew)
yield* format.file(filePath)
yield* bus.publish(File.Event.Edited, { file: filePath })
yield* bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: "change",
})
contentNew = yield* afs.readFileString(filePath)
diff = trimDiff(
createTwoFilesPatch(
filePath,
filePath,
normalizeLineEndings(contentOld),
normalizeLineEndings(contentNew),
),
)
}).pipe(Effect.orDie)
const filediff: Snapshot.FileDiff = { const filediff: Snapshot.FileDiff = {
file: filePath, file: filePath,
+26 -16
View File
@@ -29,11 +29,6 @@ afterEach(async () => {
await Instance.disposeAll() await Instance.disposeAll()
}) })
async function touch(file: string, time: number) {
const date = new Date(time)
await fs.utimes(file, date, date)
}
const runtime = ManagedRuntime.make( const runtime = ManagedRuntime.make(
Layer.mergeAll( Layer.mergeAll(
LSP.defaultLayer, LSP.defaultLayer,
@@ -639,44 +634,59 @@ describe("tool.edit", () => {
}) })
describe("concurrent editing", () => { describe("concurrent editing", () => {
test("serializes concurrent edits to same file", async () => { test("preserves concurrent edits to different sections of the same file", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt") const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "0", "utf-8") await fs.writeFile(filepath, "top = 0\nmiddle = keep\nbottom = 0\n", "utf-8")
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const edit = await resolve() const edit = await resolve()
let asks = 0
const firstAsk = Promise.withResolvers<void>()
const delayedCtx = {
...ctx,
ask: () =>
Effect.gen(function* () {
asks++
if (asks !== 1) return
firstAsk.resolve()
yield* Effect.promise(() => Bun.sleep(50))
}),
}
// Two concurrent edits
const promise1 = Effect.runPromise( const promise1 = Effect.runPromise(
edit.execute( edit.execute(
{ {
filePath: filepath, filePath: filepath,
oldString: "0", oldString: "top = 0",
newString: "1", newString: "top = 1",
}, },
ctx, delayedCtx,
), ),
) )
await firstAsk.promise
const promise2 = Effect.runPromise( const promise2 = Effect.runPromise(
edit.execute( edit.execute(
{ {
filePath: filepath, filePath: filepath,
oldString: "0", oldString: "bottom = 0",
newString: "2", newString: "bottom = 2",
}, },
ctx, delayedCtx,
), ),
) )
// Both should complete without error (though one might fail due to content mismatch)
const results = await Promise.allSettled([promise1, promise2]) const results = await Promise.allSettled([promise1, promise2])
expect(results.some((r) => r.status === "fulfilled")).toBe(true) expect(results[0]?.status).toBe("fulfilled")
expect(results[1]?.status).toBe("fulfilled")
expect(await fs.readFile(filepath, "utf-8")).toBe("top = 1\nmiddle = keep\nbottom = 2\n")
}, },
}) })
}) })
}) })
}) })