Skip to content

Commit 8bc4f91

Browse files
authored
fix: parallel edits sometimes would override each other (#23483)
1 parent 93e633f commit 8bc4f91

File tree

2 files changed

+97
-73
lines changed

2 files changed

+97
-73
lines changed

packages/opencode/src/tool/edit.ts

Lines changed: 71 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import z from "zod"
77
import * as path from "path"
8-
import { Effect } from "effect"
8+
import { Effect, Semaphore } from "effect"
99
import * as Tool from "./tool"
1010
import { LSP } from "../lsp"
1111
import { createTwoFilesPatch, diffLines } from "diff"
@@ -32,6 +32,18 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
3232
return text.replaceAll("\n", "\r\n")
3333
}
3434

35+
const locks = new Map<string, Semaphore.Semaphore>()
36+
37+
function lock(filePath: string) {
38+
const resolvedFilePath = AppFileSystem.resolve(filePath)
39+
const hit = locks.get(resolvedFilePath)
40+
if (hit) return hit
41+
42+
const next = Semaphore.makeUnsafe(1)
43+
locks.set(resolvedFilePath, next)
44+
return next
45+
}
46+
3547
const Parameters = z.object({
3648
filePath: z.string().describe("The absolute path to the file to modify"),
3749
oldString: z.string().describe("The text to replace"),
@@ -68,11 +80,50 @@ export const EditTool = Tool.define(
6880
let diff = ""
6981
let contentOld = ""
7082
let contentNew = ""
71-
yield* Effect.gen(function* () {
72-
if (params.oldString === "") {
73-
const existed = yield* afs.existsSafe(filePath)
74-
contentNew = params.newString
75-
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
83+
yield* lock(filePath).withPermits(1)(
84+
Effect.gen(function* () {
85+
if (params.oldString === "") {
86+
const existed = yield* afs.existsSafe(filePath)
87+
contentNew = params.newString
88+
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
89+
yield* ctx.ask({
90+
permission: "edit",
91+
patterns: [path.relative(Instance.worktree, filePath)],
92+
always: ["*"],
93+
metadata: {
94+
filepath: filePath,
95+
diff,
96+
},
97+
})
98+
yield* afs.writeWithDirs(filePath, params.newString)
99+
yield* format.file(filePath)
100+
yield* bus.publish(File.Event.Edited, { file: filePath })
101+
yield* bus.publish(FileWatcher.Event.Updated, {
102+
file: filePath,
103+
event: existed ? "change" : "add",
104+
})
105+
return
106+
}
107+
108+
const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
109+
if (!info) throw new Error(`File ${filePath} not found`)
110+
if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`)
111+
contentOld = yield* afs.readFileString(filePath)
112+
113+
const ending = detectLineEnding(contentOld)
114+
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
115+
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
116+
117+
contentNew = replace(contentOld, old, next, params.replaceAll)
118+
119+
diff = trimDiff(
120+
createTwoFilesPatch(
121+
filePath,
122+
filePath,
123+
normalizeLineEndings(contentOld),
124+
normalizeLineEndings(contentNew),
125+
),
126+
)
76127
yield* ctx.ask({
77128
permission: "edit",
78129
patterns: [path.relative(Instance.worktree, filePath)],
@@ -82,62 +133,25 @@ export const EditTool = Tool.define(
82133
diff,
83134
},
84135
})
85-
yield* afs.writeWithDirs(filePath, params.newString)
136+
137+
yield* afs.writeWithDirs(filePath, contentNew)
86138
yield* format.file(filePath)
87139
yield* bus.publish(File.Event.Edited, { file: filePath })
88140
yield* bus.publish(FileWatcher.Event.Updated, {
89141
file: filePath,
90-
event: existed ? "change" : "add",
142+
event: "change",
91143
})
92-
return
93-
}
94-
95-
const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
96-
if (!info) throw new Error(`File ${filePath} not found`)
97-
if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`)
98-
contentOld = yield* afs.readFileString(filePath)
99-
100-
const ending = detectLineEnding(contentOld)
101-
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
102-
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
103-
104-
contentNew = replace(contentOld, old, next, params.replaceAll)
105-
106-
diff = trimDiff(
107-
createTwoFilesPatch(
108-
filePath,
109-
filePath,
110-
normalizeLineEndings(contentOld),
111-
normalizeLineEndings(contentNew),
112-
),
113-
)
114-
yield* ctx.ask({
115-
permission: "edit",
116-
patterns: [path.relative(Instance.worktree, filePath)],
117-
always: ["*"],
118-
metadata: {
119-
filepath: filePath,
120-
diff,
121-
},
122-
})
123-
124-
yield* afs.writeWithDirs(filePath, contentNew)
125-
yield* format.file(filePath)
126-
yield* bus.publish(File.Event.Edited, { file: filePath })
127-
yield* bus.publish(FileWatcher.Event.Updated, {
128-
file: filePath,
129-
event: "change",
130-
})
131-
contentNew = yield* afs.readFileString(filePath)
132-
diff = trimDiff(
133-
createTwoFilesPatch(
134-
filePath,
135-
filePath,
136-
normalizeLineEndings(contentOld),
137-
normalizeLineEndings(contentNew),
138-
),
139-
)
140-
}).pipe(Effect.orDie)
144+
contentNew = yield* afs.readFileString(filePath)
145+
diff = trimDiff(
146+
createTwoFilesPatch(
147+
filePath,
148+
filePath,
149+
normalizeLineEndings(contentOld),
150+
normalizeLineEndings(contentNew),
151+
),
152+
)
153+
}).pipe(Effect.orDie),
154+
)
141155

142156
const filediff: Snapshot.FileDiff = {
143157
file: filePath,

packages/opencode/test/tool/edit.test.ts

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,6 @@ afterEach(async () => {
2929
await Instance.disposeAll()
3030
})
3131

32-
async function touch(file: string, time: number) {
33-
const date = new Date(time)
34-
await fs.utimes(file, date, date)
35-
}
36-
3732
const runtime = ManagedRuntime.make(
3833
Layer.mergeAll(
3934
LSP.defaultLayer,
@@ -639,44 +634,59 @@ describe("tool.edit", () => {
639634
})
640635

641636
describe("concurrent editing", () => {
642-
test("serializes concurrent edits to same file", async () => {
637+
test("preserves concurrent edits to different sections of the same file", async () => {
643638
await using tmp = await tmpdir()
644639
const filepath = path.join(tmp.path, "file.txt")
645-
await fs.writeFile(filepath, "0", "utf-8")
640+
await fs.writeFile(filepath, "top = 0\nmiddle = keep\nbottom = 0\n", "utf-8")
646641

647642
await Instance.provide({
648643
directory: tmp.path,
649644
fn: async () => {
650645
const edit = await resolve()
646+
let asks = 0
647+
const firstAsk = Promise.withResolvers<void>()
648+
const delayedCtx = {
649+
...ctx,
650+
ask: () =>
651+
Effect.gen(function* () {
652+
asks++
653+
if (asks !== 1) return
654+
firstAsk.resolve()
655+
yield* Effect.promise(() => Bun.sleep(50))
656+
}),
657+
}
651658

652-
// Two concurrent edits
653659
const promise1 = Effect.runPromise(
654660
edit.execute(
655661
{
656662
filePath: filepath,
657-
oldString: "0",
658-
newString: "1",
663+
oldString: "top = 0",
664+
newString: "top = 1",
659665
},
660-
ctx,
666+
delayedCtx,
661667
),
662668
)
663669

670+
await firstAsk.promise
671+
664672
const promise2 = Effect.runPromise(
665673
edit.execute(
666674
{
667675
filePath: filepath,
668-
oldString: "0",
669-
newString: "2",
676+
oldString: "bottom = 0",
677+
newString: "bottom = 2",
670678
},
671-
ctx,
679+
delayedCtx,
672680
),
673681
)
674682

675-
// Both should complete without error (though one might fail due to content mismatch)
676683
const results = await Promise.allSettled([promise1, promise2])
677-
expect(results.some((r) => r.status === "fulfilled")).toBe(true)
684+
expect(results[0]?.status).toBe("fulfilled")
685+
expect(results[1]?.status).toBe("fulfilled")
686+
expect(await fs.readFile(filepath, "utf-8")).toBe("top = 1\nmiddle = keep\nbottom = 2\n")
678687
},
679688
})
680689
})
690+
681691
})
682692
})

0 commit comments

Comments
 (0)