Skip to content

patch silently overwrites external edits made while approval is pending #60595

Open

Description

@HeZ2z

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

  1. opencodejr commented on Oct 30, 2026

    @opencodejr

    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 patch tool on a temp file with first=old\nsecond=old\n and holds it at the approval prompt. While it is held, it writes second=external-change to the file, then approves. The patch returns completed ("Success. Updated the following files: M file.txt"), but the file ends up as first=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:

    Reproduction script (patch-race.ts)
    // Run from packages/core of an opencode checkout:
    //   cd packages/core && bun -e "$(cat /path/to/patch-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 prompt: signal "pending", then wait until we approve
      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)
        // External editor saves a change while approval is pending
        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', JSON.stringify(yield* Fiber.join(patch)))
        console.log('after patch', JSON.stringify(yield* Effect.promise(() => Bun.file(dir + '/file.txt').text())))
      }).pipe(Effect.provide(layer)),
    )

    Run it: bun patch-race.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions