Compare commits

...

2 Commits

Author SHA1 Message Date
Aiden Cline aa504c7e85 fix(core): key mutation locks by file 2026-08-05 15:24:49 +00:00
Aiden Cline 602f5ef465 fix(core): serialize edit and patch transactions 2026-08-05 12:25:18 +00:00
6 changed files with 174 additions and 9 deletions
+12 -1
View File
@@ -53,6 +53,10 @@ export interface RemoveResult {
}
export interface Interface {
/** Serialize a complete read/prepare/write mutation transaction by canonical target. */
readonly withLock: (
targets: ReadonlyArray<Target>,
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
/** Create without replacing an existing target. */
readonly create: (input: WriteInput) => Effect.Effect<WriteResult, TargetExistsError | FSUtil.Error>
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
@@ -67,6 +71,9 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
/** Share transaction locks across Location graphs that address the same file. */
const transactionLocks = KeyedMutex.makeUnsafe<string>()
/**
* Serialize file changes by canonical target. Conditional writes compare and
* write under the same process-local lock so cooperating OpenCode mutations do
@@ -77,6 +84,10 @@ const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const locks = KeyedMutex.makeUnsafe<string>()
const withLock: Interface["withLock"] = (targets) => (effect) =>
[...new Set(targets.map((target) => target.canonical))]
.sort()
.reduceRight((result, target) => transactionLocks.withLock(target)(result), effect)
const withTargetLock =
(target: Target) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
@@ -169,7 +180,7 @@ const layer = Layer.effect(
),
)
return Service.of({ create, write, writeTextPreservingBom, writeIfUnchanged, remove })
return Service.of({ withLock, create, write, writeTextPreservingBom, writeIfUnchanged, remove })
}),
)
+7 -2
View File
@@ -11,9 +11,11 @@ import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Bom } from "@opencode-ai/util/bom"
import { Effect, Schema } from "effect"
import path from "path"
import { FileMutation } from "../../file-mutation"
import { Formatter } from "../../formatter"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../../location"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { fileDiff } from "./file-diff"
@@ -112,6 +114,7 @@ export const Plugin = {
const files = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const permission = yield* Permission.Service
yield* ctx.tool
@@ -125,7 +128,9 @@ export const Plugin = {
input: Input,
output: Output,
execute: (input, context) => {
return Effect.gen(function* () {
return files.withLock([
{ canonical: FSUtil.resolve(path.resolve(location.directory, input.path)), resource: input.path },
])(Effect.gen(function* () {
const permissionSource = {
type: "tool" as const,
messageID: context.messageID,
@@ -219,7 +224,7 @@ export const Plugin = {
files: [fileDiff(result.resource, source, formatted)],
replacements,
} satisfies Output
}).pipe(
})).pipe(
Effect.map((output) => ({
output,
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
+22 -3
View File
@@ -4,12 +4,13 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Schema } from "effect"
import { Effect, Result, Schema } from "effect"
import { PlatformError } from "effect/PlatformError"
import path from "path"
import { Bom } from "@opencode-ai/util/bom"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "../../formatter"
import { FileMutation } from "../../file-mutation"
import { Location } from "../../location"
import { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission"
@@ -70,6 +71,7 @@ export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
const fs = yield* FSUtil.Service
const mutation = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const location = yield* Location.Service
const permission = yield* Permission.Service
@@ -85,13 +87,30 @@ export const Plugin = {
output: Output,
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
const parsed = Patch.parse(input.patchText)
const lockTargets = Result.isSuccess(parsed)
? parsed.success.flatMap((hunk) => [
{
...resolveTarget(location, hunk.path),
canonical: FSUtil.resolve(path.resolve(location.directory, hunk.path)),
},
...(hunk.type === "update" && hunk.movePath
? [
{
...resolveTarget(location, hunk.movePath),
canonical: FSUtil.resolve(path.resolve(location.directory, hunk.movePath)),
},
]
: []),
])
: []
const fail = (operation: string, error: unknown) => {
const completed = applied.map((item) => item.resource).join(", ")
return new ToolFailure({
message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`,
})
}
return Effect.gen(function* () {
return mutation.withLock(lockTargets)(Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.messageID,
@@ -322,7 +341,7 @@ export const Plugin = {
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
})
return { applied, files }
}).pipe(
})).pipe(
Effect.map((output) => ({
output,
content: toModelOutput(output),
+53
View File
@@ -257,6 +257,59 @@ describe("FileMutation", () => {
),
)
it.live("shares transaction locks across Location service instances", () =>
withTmp((directory) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const target = { canonical: path.join(directory, "shared.txt"), resource: "shared.txt" }
const first = yield* Effect.gen(function* () {
const files = yield* FileMutation.Service
yield* files.withLock([target])(
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
)
}).pipe(provide(directory), Effect.forkChild)
yield* Deferred.await(firstStarted)
const second = yield* Effect.gen(function* () {
const files = yield* FileMutation.Service
yield* files.withLock([target])(Deferred.succeed(secondStarted, undefined))
}).pipe(provide(directory), Effect.forkChild)
yield* Effect.yieldNow
expect(yield* Deferred.isDone(secondStarted)).toBe(false)
yield* Deferred.succeed(releaseFirst, undefined)
yield* Deferred.await(secondStarted)
yield* Fiber.join(first)
yield* Fiber.join(second)
}),
),
)
it.live("allows transaction locks for distinct canonical targets to proceed independently", () =>
withTmp((directory) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondFinished = yield* Deferred.make<void>()
const files = yield* FileMutation.Service
const first = yield* files
.withLock([{ canonical: path.join(directory, "first.txt"), resource: "first.txt" }])(
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
)
.pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
yield* files.withLock([{ canonical: path.join(directory, "second.txt"), resource: "second.txt" }])(
Deferred.succeed(secondFinished, undefined),
)
expect(yield* Deferred.isDone(secondFinished)).toBe(true)
yield* Deferred.succeed(releaseFirst, undefined)
yield* Fiber.join(first)
}).pipe(provide(directory)),
),
)
it.live("allows only one concurrent conditional write based on the same bytes", () =>
withTmp((directory) =>
Effect.gen(function* () {
+38 -1
View File
@@ -23,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const editToolNode = makeLocationNode({
name: "test/edit-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
})
const sessionID = Session.ID.make("ses_edit_tool_test")
@@ -645,6 +645,43 @@ describe("EditTool", () => {
),
)
it.live("serializes concurrent edit transactions", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "concurrent.txt")
afterRead = () => (reads === 1 ? Effect.sleep("50 millis") : Effect.void)
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.all(
[
executeTool(
registry,
call({ path: "concurrent.txt", oldString: "one", newString: "ONE" }, "call-edit-one"),
),
executeTool(
registry,
call({ path: "concurrent.txt", oldString: "two", newString: "TWO" }, "call-edit-two"),
),
],
{ concurrency: "unbounded" },
),
),
),
Effect.andThen((results) =>
Effect.gen(function* () {
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("applies the edit when content changes after matching", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
+42 -2
View File
@@ -7,6 +7,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "@opencode-ai/core/formatter"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -22,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const patchToolNode = makeLocationNode({
name: "test/patch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
deps: [Tool.node, FileMutation.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
})
const sessionID = Session.ID.make("ses_patch_tool_test")
@@ -139,7 +140,7 @@ const withTool = <A, E, R>(
return yield* body(yield* Tool.Service)
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
AppNodeBuilder.build(LayerNode.group([Tool.node, FileMutation.node, patchToolNode]), [
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[Formatter.node, formatter],
@@ -262,6 +263,45 @@ describe("PatchTool", () => {
),
)
it.live("serializes concurrent patch transactions", () =>
withTempTool((directory, registry) => {
const target = path.join(directory, "concurrent.txt")
afterEditApproval = () =>
assertions.filter((input) => input.action === "edit").length === 1
? Effect.sleep("50 millis")
: Effect.void
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
Effect.andThen(
Effect.all(
[
executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-one\n+ONE\n*** End Patch",
"call-patch-one",
),
),
executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-two\n+TWO\n*** End Patch",
"call-patch-two",
),
),
],
{ concurrency: "unbounded" },
),
),
Effect.andThen((results) =>
Effect.gen(function* () {
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
}),
),
)
}),
)
it.live("returns file diffs for final formatted content", () =>
withTempTool((directory, registry) => {
const target = path.join(directory, "formatted.txt")