Repository navigation
Description
Description
The patch tool reads a file and prepares its replacement before requesting permission. If the user saves changes in an editor while the approval is pending, approving the patch silently overwrites those changes with the old snapshot. The tool reports success, even when the external change affects a different line from the patch. This is a current V2 recurrence of the V1 behavior described in #31776. It is distinct from #60509, which involves concurrent OpenCode edit and write calls.
Reproduction
On dev commit 82666b6612cdb2ba13a7a26e150b21e897a80373, save the following as /tmp/oc-patch-external-race.ts and run from packages/core:
bun -e "$(cat /tmp/oc-patch-external-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 { PatchTool } from '@opencode/core/tool/plugin/patch'
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-patch-external-'); await Bun.write(dir+'/file.txt','first=old\nsecond=old\n')
const deps=[Tool.node,FileAccess.node,FileMutation.node,Environment.node,Formatter.node,Location.node,Permission.node]
const node=makeLocationNode({name:'race-patch',layer:Layer.effectDiscard(registerToolPlugin(PatchTool.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:()=>Deferred.succeed(ready,undefined).pipe(Effect.andThen(Deferred.await(proceed)))}))])
await Effect.runPromise(Effect.gen(function*(){const reg=yield* Tool.Service; const patch=yield* executeTool(reg,{sessionID:Session.ID.make('ses_race'),...toolIdentity,call:{type:'tool-call',id:'patch',name:'patch',input:{patchText:'*** Begin Patch\n*** Update File: file.txt\n@@\n-first=old\n+first=new\n second=old\n*** End Patch'}}}).pipe(Effect.forkChild); yield* Deferred.await(ready); yield* Effect.promise(()=>Bun.write(dir+'/file.txt','first=old\nsecond=external-change\n')); console.log('after external write',JSON.stringify(yield* Effect.promise(()=>Bun.file(dir+'/file.txt').text()))); yield* Deferred.succeed(proceed,undefined); console.log('patch',yield* Fiber.join(patch)); console.log('after patch',JSON.stringify(yield* Effect.promise(()=>Bun.file(dir+'/file.txt').text())));}).pipe(Effect.provide(layer)))Actual: after the external write, the file contains first=old\nsecond=external-change\n; patch returns completed, and the final file is first=new\nsecond=old\n. Expected: the user's change must not be silently lost; the patch should detect the changed file or otherwise preserve the concurrent update.
The script uses the actual registered PatchTool and local filesystem. A permission fixture holds the patch at approval and formatting is disabled. The patch source blob matches current upstream dev.
Activity
opencodejr commentedon Oct 30, 2026
Thanks for the detailed report and the script. I can reproduce this on the latest
dev(95fc3c87), run from source.The script below does what yours does. It runs the real
patchtool on a temp file withfirst=old\nsecond=old\nand holds it at the approval prompt. While it is held, it writessecond=external-changeto the file, then approves. The patch returnscompleted("Success. Updated the following files: M file.txt"), but the file ends up asfirst=new\nsecond=old\n. The outside change is silently lost.In
packages/core/src/tool/plugin/patch.ts, the patch tool reads the file and builds the new content before asking for permission. After approval it writes that content without checking whether the file changed in between. The per-file lock only covers writes made by OpenCode itself, so it doesn't catch an editor saving the file.Related issues:
apply_patcheditandwriteracing each other inside OpenCodeapply_patchcalls on the same fileReproduction script (patch-race.ts)
Run it:
bun patch-race.ts