Repository navigation
Description
Description
A pending edit can silently overwrite a concurrent successful write to the same file. Both tools report success, but only the edit's stale snapshot remains on disk. This can lose work from another parallel tool call or session. The edit's permission preview also no longer describes the file it actually overwrites.
edit holds the process-wide FileMutation.withLock across reading, permission and writing; write does not acquire that transaction lock. The separate lock inside writeTextPreservingBom does not coordinate with the edit transaction, so it can complete while the edit waits for approval. This differs from #53512 (two apply_patch calls) and #48707 (an older stale-content error message).
Reproduction
On dev commit 82666b6612cdb2ba13a7a26e150b21e897a80373, save this script as /tmp/oc-write-edit-race.ts and run from packages/core:
bun -e "$(cat /tmp/oc-write-edit-race.ts)"import { Effect, Layer, Deferred, Fiber } from 'effect'
import { AppNodeBuilder } from '@opencode/core/effect/app-node-builder'
import { LayerNode } from '@opencode/util/effect/layer-node'
import { makeLocationNode } from '@opencode/util/effect/app-node'
import { Environment } from '@opencode/core/environment/index'
import { FileMutation } from '@opencode/core/file-mutation'
import { Formatter } from '@opencode/core/formatter'
import { Location } from '@opencode/core/location'
import { FileAccess } from '@opencode/core/file-access'
import { Permission } from '@opencode/core/permission'
import { AbsolutePath } from '@opencode/core/schema'
import { Session } from '@opencode/core/session'
import { Tool } from '@opencode/core/tool'
import { EditTool } from '@opencode/core/tool/plugin/edit'
import { WriteTool } from '@opencode/core/tool/plugin/write'
import { location } from './test/fixture/location'
import { permissionLayer } from './test/lib/permission'
import { toolIdentity, executeTool, registerToolPlugin } from './test/lib/tool'
import { mkdtemp } from 'node:fs/promises'
const dir=await mkdtemp('/tmp/oc-write-edit-race-'); await Bun.write(dir+'/file.txt','old\n')
const deps=[Tool.node,FileAccess.node,FileMutation.node,Environment.node,Formatter.node,Location.node,Permission.node]
const node=makeLocationNode({name:'race-tools',layer:Layer.effectDiscard(Effect.all([registerToolPlugin(EditTool.Plugin),registerToolPlugin(WriteTool.Plugin)])),deps})
const ready=Deferred.makeUnsafe(); const proceed=Deferred.makeUnsafe()
const loc=Layer.succeed(Location.Service,Location.Service.of(location({directory:AbsolutePath.make(dir)})))
const layer=AppNodeBuilder.build(LayerNode.group([Tool.node,FileAccess.node,FileMutation.node,node]),[Location.node.replace(loc),Formatter.node.replace(Layer.mock(Formatter.Service,{file:()=>Effect.succeed(false)})),Permission.node.replace(permissionLayer({assert:(input)=>input.source.id==='edit' ? Deferred.succeed(ready,undefined).pipe(Effect.andThen(Deferred.await(proceed))) : Effect.void}))])
const call=(id,name,input)=>({sessionID:Session.ID.make('ses_race'),...toolIdentity,call:{type:'tool-call',id,name,input}})
await Effect.runPromise(Effect.gen(function*(){const reg=yield* Tool.Service; const edit=yield* executeTool(reg,call('edit','edit',{path:'file.txt',oldString:'old',newString:'edited'})).pipe(Effect.forkChild); yield* Deferred.await(ready); console.log('write',yield* executeTool(reg,call('write','write',{path:'file.txt',content:'other session work\n'}))); console.log('after write',JSON.stringify(yield* Effect.promise(() => Bun.file(dir+'/file.txt').text()))); yield* Deferred.succeed(proceed,undefined); console.log('edit',yield* Fiber.join(edit)); console.log('after edit',JSON.stringify(yield* Effect.promise(() => Bun.file(dir+'/file.txt').text())));}).pipe(Effect.provide(layer)))The script uses the real registered tools and local filesystem. A permission fixture holds the edit at the approval point; formatting is disabled. Actual output shows write completed, after write is "other session work\n", then edit completed and after edit is "edited\n". Expected: the earlier successful write must not be silently lost; the edit could be serialized with the write or reject a changed file. Both relevant tool source blobs match current upstream dev.
Activity
opencodejr commentedon Oct 30, 2026
Thanks for the detailed report and the script. I reproduced this on the latest
dev(fa96f42). The files involved are unchanged since the commit you reported.With the
editheld at its permission prompt, awriteto the same file completes and the file contains"other session work\n". Once the edit is approved, it also reportscompleted, with a diff still showing-old +edited, and the file ends up as"edited\n". The write is silently lost.The code matches your analysis.
editholdsFileMutation.withLock, which uses the module-leveltransactionLocks, across reading, the permission prompt and writing.writeonly goes throughwriteTextPreservingBom, which uses a separate per-target lock, so the two never wait for each other.The script below is a lightly tidied version of yours. Run it from
packages/core.Related, but not the same problem:
apply_patchcalls, fixed by serializing those callsReproduction script (race.ts)
Run it:
bun race.ts