From 523b431a8195f5a54d0ef4fd1aac546a8b6c76cf Mon Sep 17 00:00:00 2001 From: Jackson Cummings <126146472+thelabcorner@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:56:59 -0500 Subject: [PATCH 01/12] fix(snapshot): serialize shadow indexes across processes and fail closed on Git errors --- packages/opencode/src/snapshot/index.ts | 124 ++++++++++++++++-------- 1 file changed, 83 insertions(+), 41 deletions(-) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 2dbc26c12..cd2496cc5 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -19,6 +19,7 @@ import { GitRuntime } from "@opencode-ai/core/git-runtime" import { InstanceState } from "@/effect/instance-state" import { FSUtil } from "@opencode-ai/core/fs-util" import { Hash } from "@opencode-ai/core/util/hash" +import { Flock } from "@opencode-ai/core/util/flock" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" import { Info } from "@opencode-ai/schema/file-diff" @@ -56,6 +57,7 @@ const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"] const cfg = [...GitRuntime.args([]), ...core] const quote = [...cfg, "-c", "core.quotepath=false"] const CAPTURE_TIMEOUT = Duration.minutes(5) +const TREE_OID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i interface GitResult { readonly code: ChildProcessSpawner.ExitCode readonly text: string @@ -121,8 +123,9 @@ export class Service extends Context.Service()("@opencode/Sn // Process-wide shadow-repo lock. Every Snapshot layer instance in this process // must serialize mutations to one shadow gitdir: the earlier per-layer map let // two instances write a single index concurrently, which is how a non-atomic -// seed copy could leave a zeroed index. Cross-process writers (separate server -// runs) are additionally protected by Git's own `index.lock`. +// seed copy could leave a zeroed index. The filesystem Flock below serializes +// the entire shadow-index transaction across independent server processes. +// Git's index.lock remains the last defense against legacy/noncooperating writers. const shadowLocks = new Map() const shadowLock = (key: string) => { const hit = shadowLocks.get(key) @@ -302,7 +305,7 @@ const layer: Layer.Layer< stdin: encodeNulTerminatedPaths(checkIgnorePaths), }, ) - if (check.code !== 0 && check.code !== 1) return new Set() + if (check.code !== 0 && check.code !== 1) return yield* failGit("check-ignore", check) return new Set( check.text .split("\0") @@ -311,9 +314,12 @@ const layer: Layer.Layer< ) }) + const failGit = (command: string, result: GitResult) => + Effect.die(new Error(`snapshot ${command} failed (exit ${result.code}): ${result.stderr.trim() || "no stderr"}`)) + const drop = Effect.fnUntraced(function* (files: string[]) { if (!files.length) return - yield* git( + const result = yield* git( [ ...cfg, ...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]), @@ -323,28 +329,57 @@ const layer: Layer.Layer< stdin: encodeTopLevelLiteralPathspecs(files), }, ) + if (result.code !== 0) return yield* failGit("rm --cached", result) }) const stage = Effect.fnUntraced(function* (files: string[]) { if (!files.length) return - const result = yield* git( - [...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])], - { - cwd: state.worktree, - stdin: encodeTopLevelLiteralPathspecs(files), - }, - ) - if (result.code === 0) return - yield* Effect.logWarning("failed to add snapshot files", { - exitCode: result.code, - stderr: result.stderr, - }) + // A legacy server that predates our cross-process lease may still + // hold Git's index.lock. Retry only that transient contention. + // Never write-tree after an unsuccessful stage. + for (let attempt = 0; attempt < 4; attempt++) { + const result = yield* git( + [...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])], + { + cwd: state.worktree, + stdin: encodeTopLevelLiteralPathspecs(files), + }, + ) + if (result.code === 0) return + if (attempt < 3 && result.stderr.includes("index.lock") && /file exists/i.test(result.stderr)) { + yield* Effect.sleep(Duration.millis(100 * (attempt + 1))) + continue + } + yield* Effect.logWarning("failed to add snapshot files", { + exitCode: result.code, + stderr: result.stderr, + attempt: attempt + 1, + }) + return yield* failGit("add", result) + } }) const exists = (file: string) => fs.exists(file).pipe(Effect.orDie) const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(""))) const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void)) - const locked = (fx: Effect.Effect) => shadowLock(state.gitdir).withPermits(1)(fx) + const locked = (fx: Effect.Effect) => + shadowLock(state.gitdir).withPermits(1)( + Effect.scoped( + Effect.gen(function* () { + // Index writes must be atomic across *processes*, not merely + // Snapshot layer instances. Hold the lease across sync, stage, + // write-tree, repair, checkout and GC; release on interruption. + yield* Flock.effect(`snapshot-index:${state.gitdir}`, { + dir: path.join(Global.Path.data, "snapshot-locks"), + staleMs: 180_000, + timeoutMs: 45_000, + baseDelayMs: 60, + maxDelayMs: 750, + }) + return yield* fx + }), + ), + ) const enabled = Effect.fnUntraced(function* () { if (state.vcs !== "git") return false @@ -465,6 +500,7 @@ const layer: Layer.Layer< exitCode: result.code, stderr: result.stderr, }) + return yield* failGit("rebuild index", result) } }) @@ -510,7 +546,7 @@ const layer: Layer.Layer< otherCode: other.code, otherStderr: other.stderr, }) - return + return yield* failGit("list files", diff.code !== 0 ? diff : other) } const tracked = diff.text.split("\0").filter(Boolean) @@ -600,6 +636,16 @@ const layer: Layer.Layer< yield* add() const result = yield* git(args(["write-tree"]), { cwd: state.directory }) const hash = result.text.trim() + // An empty or malformed hash is never a snapshot. In particular, + // a failed git add must not silently reuse the previous index tree. + if (result.code !== 0 || !TREE_OID.test(hash)) { + yield* Effect.logWarning("snapshot write-tree failed", { + exitCode: result.code, + stderr: result.stderr, + hash, + }) + return yield* failGit("write-tree", result) + } yield* Effect.logInfo("tracking", { hash, cwd: state.directory, git: state.gitdir }) return hash }) @@ -648,6 +694,19 @@ const layer: Layer.Layer< }), ), ).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logWarning("snapshot capture failed; no tree published", { + directory: state.directory, + git: state.gitdir, + cause: Cause.pretty(cause), + }) + yield* SynchronizedRef.update(materialization, (snapshot) => + snapshot.cached?.token === token ? { ...snapshot, cached: undefined } : snapshot, + ) + return undefined as string | undefined + }), + ), Effect.timeoutOrElse({ duration: CAPTURE_TIMEOUT, orElse: () => @@ -748,23 +807,11 @@ const layer: Layer.Layer< Effect.gen(function* () { yield* Effect.logInfo("restore", { commit: snapshot }) const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree }) - if (result.code === 0) { - const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { - cwd: state.worktree, - }) - if (checkout.code === 0) return - yield* Effect.logError("failed to restore snapshot", { - snapshot, - exitCode: checkout.code, - stderr: checkout.stderr, - }) - return - } - yield* Effect.logError("failed to restore snapshot", { - snapshot, - exitCode: result.code, - stderr: result.stderr, + if (result.code !== 0) return yield* failGit("read-tree (restore)", result) + const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { + cwd: state.worktree, }) + if (checkout.code !== 0) return yield* failGit("checkout-index (restore)", checkout) }), ), "snapshot.restore") }) @@ -795,13 +842,8 @@ const layer: Layer.Layer< const tree = yield* git([...core, ...args(["ls-tree", op.hash, "--", op.rel])], { cwd: state.worktree, }) - if (tree.code === 0 && tree.text.trim()) { - yield* Effect.logInfo("file existed in snapshot but checkout failed, keeping", { - file: op.file, - hash: op.hash, - }) - return - } + if (tree.code !== 0) return yield* failGit("ls-tree (revert)", tree) + if (tree.text.trim()) return yield* failGit("checkout (revert)", result) yield* Effect.logInfo("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash }) yield* remove(op.file) }) From 3cc7595341db7a9886dd11e40ba2eb2ce28ccc60 Mon Sep 17 00:00:00 2001 From: Jackson Cummings <126146472+thelabcorner@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:57:31 -0500 Subject: [PATCH 02/12] test(snapshot): regression coverage for index contention, lease ownership, and fail-safe revert --- .../opencode/test/snapshot/snapshot.test.ts | 81 ++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index f907b494f..b4729006a 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -7,7 +7,8 @@ import { Global } from "@opencode-ai/core/global" import { Hash } from "@opencode-ai/core/util/hash" import fs from "fs/promises" import path from "path" -import { Deferred, Effect, Fiber, Layer } from "effect" +import { Deferred, Effect, Exit, Fiber, Layer, Option } from "effect" +import { Flock } from "@opencode-ai/core/util/flock" import { Snapshot } from "../../src/snapshot" import { InstanceState } from "@/effect/instance-state" import { @@ -934,6 +935,84 @@ it.instance( ) +it.instance( + "does not publish an old tree or an empty hash when git add encounters index.lock", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toMatch(/^[a-f0-9]{40,64}$/) + + const ctx = yield* InstanceState.context + const gitdir = path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)) + const lock = path.join(gitdir, "index.lock") + yield* write(path.join(tmp.path, "new.txt"), "new snapshot content") + yield* write(lock, "simulated legacy git index writer") + + // Staging is required here, so an existing Git lock must make the whole + // capture fail closed. The invalid capture must not poison single-flight. + const failed = yield* snapshot.track() + expect(failed).toBeUndefined() + expect(yield* exists(lock)).toBe(true) + yield* rm(lock) + + const after = yield* snapshot.track() + expect(after).toMatch(/^[a-f0-9]{40,64}$/) + expect(after).not.toBe(before) + expect((yield* snapshot.patch(before!, after!)).files).toContain(fwd(tmp.path, "new.txt")) + }), + { git: true }, + { timeout: 30_000 }, +) + +it.instance( + "holds the filesystem lease across a complete snapshot capture", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + expect(yield* snapshot.track()).toBeTruthy() + const ctx = yield* InstanceState.context + const gitdir = path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)) + const options = { + dir: path.join(Global.Path.data, "snapshot-locks"), + timeoutMs: 1_000, + } + yield* write(path.join(tmp.path, "new.txt"), "new snapshot content") + const fiber = yield* Effect.acquireUseRelease( + Effect.promise(() => Flock.acquire(`snapshot-index:${gitdir}`, options)), + () => + Effect.gen(function* () { + const pending = yield* snapshot.track().pipe(Effect.forkScoped) + yield* Effect.sleep("200 millis") + // The competing capture cannot advance until the external owner + // releases its lease, even though the Git index is not itself locked. + expect(Option.isNone(yield* Fiber.poll(pending))).toBe(true) + return pending + }), + (lease) => Effect.promise(() => lease.release()), + ) + expect(yield* Fiber.join(fiber)).toMatch(/^[a-f0-9]{40,64}$/) + }), + { git: true }, + { timeout: 30_000 }, +) + +it.instance( + "invalid revert refs never cause worktree files to be deleted", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + expect(yield* snapshot.track()).toBeTruthy() + const target = path.join(tmp.path, "precious.txt") + yield* write(target, "retain me") + const result = yield* Effect.exit(snapshot.revert([{ hash: "invalid-tree", files: [target] }])) + expect(Exit.isFailure(result)).toBe(true) + expect(yield* readText(target)).toBe("retain me") + }), + { git: true }, + { timeout: 30_000 }, +) + it.instance( "reconstructs a corrupt shadow index instead of failing every capture", () => From 635e8b752989de05d8f27b01889c42368c64bec1 Mon Sep 17 00:00:00 2001 From: Jackson Cummings <126146472+thelabcorner@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:59:18 -0500 Subject: [PATCH 03/12] fix(snapshot): coordinate V2 shadow index with V1 across processes --- packages/core/src/snapshot.ts | 115 ++++++++++++++++++++++------------ 1 file changed, 75 insertions(+), 40 deletions(-) diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts index aff038622..e59de89fe 100644 --- a/packages/core/src/snapshot.ts +++ b/packages/core/src/snapshot.ts @@ -13,6 +13,7 @@ import { Location } from "./location" import { AppProcess } from "./process" import { AbsolutePath, RelativePath } from "./schema" import { Hash } from "./util/hash" +import { Flock } from "./util/flock" import { GitRuntime } from "./git-runtime" export const ID = Schema.String.pipe(Schema.brand("Snapshot.ID")) @@ -153,6 +154,24 @@ const layer = Layer.effect( : location.project.directory const gitDirectory = AbsolutePath.make(path.join(global.data, "snapshot", location.project.id, Hash.fast(worktree))) + // V1 Snapshot and this V2/core service use the *same* shadow gitdir. + // Git.tree has an in-process lock, but that cannot coordinate V1 or + // independent OpenFork server processes. Reuse the exact V1 filesystem + // lease key and hold it across initialization, refresh and write-tree. + const withShadowIndex = (body: Effect.Effect) => + Effect.scoped( + Effect.gen(function* () { + yield* Flock.effect(`snapshot-index:${gitDirectory}`, { + dir: path.join(global.data, "snapshot-locks"), + staleMs: 180_000, + timeoutMs: 45_000, + baseDelayMs: 60, + maxDelayMs: 750, + }) + return yield* body + }), + ) + const scope = Effect.fnUntraced(function* () { const relative = path.relative(worktree, location.directory) if (relative.startsWith("..") || path.isAbsolute(relative)) @@ -218,19 +237,23 @@ const layer = Layer.effect( const capture = Effect.fn("Snapshot.capture")(function* () { if (!(yield* enabled())) return undefined - return yield* Effect.gen(function* () { - const repo = yield* repository() - yield* healIndex(repo) - return ID.make( - yield* git.tree.capture({ - repository: repo, - scopes: [yield* scope()], - ignores: source, - maximumUntrackedFileBytes: 2 * 1024 * 1024, - }), - ) - }).pipe( - Effect.catch((cause) => Effect.logWarning("failed to capture snapshot", { cause }).pipe(Effect.as(undefined))), + return yield* withShadowIndex( + Effect.gen(function* () { + const repo = yield* repository() + yield* healIndex(repo) + return ID.make( + yield* git.tree.capture({ + repository: repo, + scopes: [yield* scope()], + ignores: source, + maximumUntrackedFileBytes: 2 * 1024 * 1024, + }), + ) + }), + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to capture snapshot; no tree published", { cause }).pipe(Effect.as(undefined)), + ), ) }) @@ -279,40 +302,52 @@ const layer = Layer.effect( const preview = Effect.fn("Snapshot.preview")(function* (input: PreviewInput) { if (!(yield* enabled())) return yield* new Error({ operation: "preview", message: "Snapshots are disabled" }) - const repo = yield* repository().pipe(Effect.mapError((cause) => failure("preview", cause))) - yield* healIndex(repo).pipe(Effect.catch(() => Effect.void)) - const files = yield* plan("preview", input) - const current = yield* git.tree - .capture({ - repository: repo, - scopes: Array.from(files.keys()), - ignores: source, - maximumUntrackedFileBytes: 2 * 1024 * 1024, - }) - .pipe(Effect.mapError((cause) => failure("preview", cause))) - return yield* git.tree - .preview({ - repository: repo, - current, - files, - context: input.context, - }) - .pipe(Effect.mapError((cause) => failure("preview", cause))) + return yield* withShadowIndex( + Effect.gen(function* () { + const repo = yield* repository().pipe(Effect.mapError((cause) => failure("preview", cause))) + yield* healIndex(repo) + const files = yield* plan("preview", input) + const current = yield* git.tree + .capture({ + repository: repo, + scopes: Array.from(files.keys()), + ignores: source, + maximumUntrackedFileBytes: 2 * 1024 * 1024, + }) + .pipe(Effect.mapError((cause) => failure("preview", cause))) + return yield* git.tree + .preview({ + repository: repo, + current, + files, + context: input.context, + }) + .pipe(Effect.mapError((cause) => failure("preview", cause))) + }), + ) }) const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) { if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" }) - const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause))) - yield* git.tree - .restore({ repository: repo, files: yield* plan("restore", input) }) - .pipe(Effect.mapError((cause) => failure("restore", cause))) + return yield* withShadowIndex( + Effect.gen(function* () { + const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause))) + yield* git.tree + .restore({ repository: repo, files: yield* plan("restore", input) }) + .pipe(Effect.mapError((cause) => failure("restore", cause))) + }), + ) }) const checkout = Effect.fn("Snapshot.checkout")(function* (snapshot: ID) { - const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause))) - yield* git.tree - .checkout({ repository: repo, tree: Git.TreeID.make(snapshot) }) - .pipe(Effect.mapError((cause) => failure("restore", cause))) + return yield* withShadowIndex( + Effect.gen(function* () { + const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause))) + yield* git.tree + .checkout({ repository: repo, tree: Git.TreeID.make(snapshot) }) + .pipe(Effect.mapError((cause) => failure("restore", cause))) + }), + ) }) const epoch = Effect.fnUntraced(function* () { From c2cd9f6c3d29477852283517c8226c86eefd9e0c Mon Sep 17 00:00:00 2001 From: Jackson Cummings <126146472+thelabcorner@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:00:18 -0500 Subject: [PATCH 04/12] fix(snapshot): reject invalid revert trees and off-worktree paths before mutation --- packages/opencode/src/snapshot/index.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index cd2496cc5..aae420dbc 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -822,13 +822,22 @@ const layer: Layer.Layer< const ops: { hash: string; file: string; rel: string }[] = [] const seen = new Set() for (const item of patches) { + // Reject invalid source trees *before* touching any worktree file. + // A failed lookup must never be interpreted as an absent file. + if (item.files.length && !TREE_OID.test(item.hash)) { + return yield* Effect.die(new Error(`invalid snapshot revert tree: ${item.hash}`)) + } for (const file of item.files) { if (seen.has(file)) continue + const relative = path.relative(state.worktree, file) + if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + return yield* Effect.die(new Error(`snapshot revert path escapes worktree: ${file}`)) + } seen.add(file) ops.push({ hash: item.hash, file, - rel: path.relative(state.worktree, file).replaceAll("\\", "/"), + rel: relative.replaceAll("\\", "/"), }) } } From 9a8aa22d9f5d356da1f6d27166f20f6196dbdd4b Mon Sep 17 00:00:00 2001 From: Jackson Cummings <126146472+thelabcorner@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:00:55 -0500 Subject: [PATCH 05/12] test(snapshot): prove revert cannot mutate files outside the worktree --- .../opencode/test/snapshot/snapshot.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index b4729006a..470edff69 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -997,6 +997,24 @@ it.instance( { timeout: 30_000 }, ) +it.instance( + "revert rejects paths outside the captured worktree", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + const tree = yield* snapshot.track() + expect(tree).toBeTruthy() + const outside = path.join(path.dirname(tmp.path), `${path.basename(tmp.path)}-outside.txt`) + yield* Effect.addFinalizer(() => rm(outside)) + yield* write(outside, "preserve outside file") + const result = yield* Effect.exit(snapshot.revert([{ hash: tree!, files: [outside] }])) + expect(Exit.isFailure(result)).toBe(true) + expect(yield* readText(outside)).toBe("preserve outside file") + }), + { git: true }, + { timeout: 30_000 }, +) + it.instance( "invalid revert refs never cause worktree files to be deleted", Effect.gen(function* () { From e0327bed6b742ed119437a7c60d848c7118eb199 Mon Sep 17 00:00:00 2001 From: Jackson Cummings <126146472+thelabcorner@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:01:43 -0500 Subject: [PATCH 06/12] fix(core/git): reject empty or malformed write-tree objects --- packages/core/src/git.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index db4fa9d12..3488ce7b3 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -573,7 +573,16 @@ const layer = Layer.effect( }) const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) { - return TreeID.make((yield* repositoryOperation("write_tree", repository, ["write-tree"])).text.trim()) + const result = yield* repositoryOperation("write_tree", repository, ["write-tree"]) + const oid = result.text.trim() + if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(oid)) { + return yield* new OperationError({ + operation: "write_tree", + directory: repository.worktree, + message: "Git write-tree did not return a valid tree object ID", + }) + } + return TreeID.make(oid) }) type CaptureInput = { From 92afda55fc8303a4dec6f1403e1753c92b3f5336 Mon Sep 17 00:00:00 2001 From: Jackson Cummings <126146472+thelabcorner@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:02:15 -0500 Subject: [PATCH 07/12] refactor(snapshot): define one cross-process shadow-index lease contract --- packages/core/src/snapshot-index-lock.ts | 37 ++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 packages/core/src/snapshot-index-lock.ts diff --git a/packages/core/src/snapshot-index-lock.ts b/packages/core/src/snapshot-index-lock.ts new file mode 100644 index 000000000..ba3ed896c --- /dev/null +++ b/packages/core/src/snapshot-index-lock.ts @@ -0,0 +1,37 @@ +import path from "path" +import { Effect } from "effect" +import { Flock } from "./util/flock" + +/** + * Shared writer lease for the V1 and core/V2 snapshot implementations. + * + * Both services intentionally use data/snapshot// as their + * shadow gitdir. Their separate in-process semaphores cannot protect that + * Git index from each other or from another OpenFork server process. + * + * Use a different lock directory from Git's index.lock: Flock provides atomic + * mkdir acquisition, heartbeat, owner token validation and stale lease recovery. + * Keep this lock across the entire index transaction, not merely git add. + */ +export namespace SnapshotIndexLock { + export const key = (gitDirectory: string) => `snapshot-index:${path.normalize(gitDirectory)}` + export const root = (dataDirectory: string) => path.join(dataDirectory, "snapshot-locks") + + export const withLock = ( + gitDirectory: string, + dataDirectory: string, + body: Effect.Effect, + ) => + Effect.scoped( + Effect.gen(function* () { + yield* Flock.effect(key(gitDirectory), { + dir: root(dataDirectory), + staleMs: 180_000, + timeoutMs: 45_000, + baseDelayMs: 60, + maxDelayMs: 750, + }) + return yield* body + }), + ) +} From 3cd9865b65bd9d3691385093d809f4b89df4cb5f Mon Sep 17 00:00:00 2001 From: Jackson Cummings <126146472+thelabcorner@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:02:25 -0500 Subject: [PATCH 08/12] refactor(snapshot): use canonical shared V1/V2 index-lock helper --- packages/opencode/src/snapshot/index.ts | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index aae420dbc..1401f0440 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -19,7 +19,7 @@ import { GitRuntime } from "@opencode-ai/core/git-runtime" import { InstanceState } from "@/effect/instance-state" import { FSUtil } from "@opencode-ai/core/fs-util" import { Hash } from "@opencode-ai/core/util/hash" -import { Flock } from "@opencode-ai/core/util/flock" +import { SnapshotIndexLock } from "@opencode-ai/core/snapshot-index-lock" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" import { Info } from "@opencode-ai/schema/file-diff" @@ -364,21 +364,8 @@ const layer: Layer.Layer< const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void)) const locked = (fx: Effect.Effect) => shadowLock(state.gitdir).withPermits(1)( - Effect.scoped( - Effect.gen(function* () { - // Index writes must be atomic across *processes*, not merely - // Snapshot layer instances. Hold the lease across sync, stage, - // write-tree, repair, checkout and GC; release on interruption. - yield* Flock.effect(`snapshot-index:${state.gitdir}`, { - dir: path.join(Global.Path.data, "snapshot-locks"), - staleMs: 180_000, - timeoutMs: 45_000, - baseDelayMs: 60, - maxDelayMs: 750, - }) - return yield* fx - }), - ), + // Exclusive across V1, core/V2 and any number of server processes. + SnapshotIndexLock.withLock(state.gitdir, Global.Path.data, fx), ) const enabled = Effect.fnUntraced(function* () { From 94c48d1a5154eb6ff0ae8daf3ede157e9fe39320 Mon Sep 17 00:00:00 2001 From: Jackson Cummings <126146472+thelabcorner@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:02:27 -0500 Subject: [PATCH 09/12] refactor(snapshot): use canonical shared V1/V2 index-lock helper --- packages/core/src/snapshot.ts | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts index e59de89fe..155b8e45b 100644 --- a/packages/core/src/snapshot.ts +++ b/packages/core/src/snapshot.ts @@ -13,7 +13,7 @@ import { Location } from "./location" import { AppProcess } from "./process" import { AbsolutePath, RelativePath } from "./schema" import { Hash } from "./util/hash" -import { Flock } from "./util/flock" +import { SnapshotIndexLock } from "./snapshot-index-lock" import { GitRuntime } from "./git-runtime" export const ID = Schema.String.pipe(Schema.brand("Snapshot.ID")) @@ -159,18 +159,7 @@ const layer = Layer.effect( // independent OpenFork server processes. Reuse the exact V1 filesystem // lease key and hold it across initialization, refresh and write-tree. const withShadowIndex = (body: Effect.Effect) => - Effect.scoped( - Effect.gen(function* () { - yield* Flock.effect(`snapshot-index:${gitDirectory}`, { - dir: path.join(global.data, "snapshot-locks"), - staleMs: 180_000, - timeoutMs: 45_000, - baseDelayMs: 60, - maxDelayMs: 750, - }) - return yield* body - }), - ) + SnapshotIndexLock.withLock(gitDirectory, global.data, body) const scope = Effect.fnUntraced(function* () { const relative = path.relative(worktree, location.directory) From 7e0551abf6b0b9eb3ff7c03d4155d307e7af10fc Mon Sep 17 00:00:00 2001 From: Jackson Cummings <126146472+thelabcorner@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:02:30 -0500 Subject: [PATCH 10/12] refactor(snapshot): use canonical shared V1/V2 index-lock helper --- packages/opencode/test/snapshot/snapshot.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 470edff69..fdded62eb 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -9,6 +9,7 @@ import fs from "fs/promises" import path from "path" import { Deferred, Effect, Exit, Fiber, Layer, Option } from "effect" import { Flock } from "@opencode-ai/core/util/flock" +import { SnapshotIndexLock } from "@opencode-ai/core/snapshot-index-lock" import { Snapshot } from "../../src/snapshot" import { InstanceState } from "@/effect/instance-state" import { @@ -974,12 +975,12 @@ it.instance( const ctx = yield* InstanceState.context const gitdir = path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)) const options = { - dir: path.join(Global.Path.data, "snapshot-locks"), + dir: SnapshotIndexLock.root(Global.Path.data), timeoutMs: 1_000, } yield* write(path.join(tmp.path, "new.txt"), "new snapshot content") const fiber = yield* Effect.acquireUseRelease( - Effect.promise(() => Flock.acquire(`snapshot-index:${gitdir}`, options)), + Effect.promise(() => Flock.acquire(SnapshotIndexLock.key(gitdir), options)), () => Effect.gen(function* () { const pending = yield* snapshot.track().pipe(Effect.forkScoped) From 3c2b0066ee279a4711b4aa10102fbe356256af02 Mon Sep 17 00:00:00 2001 From: Jackson Cummings <126146472+thelabcorner@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:05:13 -0500 Subject: [PATCH 11/12] fix(core/git): distinguish no Git ignore matches from real capture failures --- packages/core/src/git.ts | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index 3488ce7b3..8c60257bc 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -459,15 +459,39 @@ const layer = Layer.effect( ) const candidates = Array.from(new Set([...tracked, ...untracked])) if (!candidates.length) return { skipped: [] } - const ignored = input.ignores - ? new Set( - (yield* repositoryOperation("refresh", input.ignores, ["check-ignore", "--no-index", "--stdin", "-z"], { - stdin: candidates.join("\0") + "\0", - }).pipe(Effect.catch(() => Effect.succeed({ text: "", stderr: "" })))).text - .split("\0") - .filter(Boolean), + const ignored = new Set() + if (input.ignores) { + // Git check-ignore exits 1 for "nothing ignored"; any other failure + // must abort the capture, rather than silently treating ignored files + // as safe to stage into a snapshot. + const check = yield* proc + .run( + ChildProcess.make( + "git", + repositoryArgs(input.ignores, ["check-ignore", "--no-index", "--stdin", "-z"]), + { cwd: input.ignores.worktree, extendEnv: true }, + ), + { stdin: candidates.join("\0") + "\0" }, + ) + .pipe( + Effect.mapError( + (cause) => + new OperationError({ + operation: "refresh", + directory: input.ignores!.worktree, + message: cause.message, + cause, + }), + ), ) - : new Set() + if (check.exitCode !== 0 && check.exitCode !== 1) + return yield* new OperationError({ + operation: "refresh", + directory: input.ignores.worktree, + message: check.stderr.toString("utf8").trim() || "Failed to check ignored paths", + }) + for (const file of check.stdout.toString("utf8").split("\0").filter(Boolean)) ignored.add(file) + } const allowed = candidates.filter((item) => !ignored.has(item)) const maximum = input.maximumUntrackedFileBytes const skipped = maximum From 6db0309e93f7cfb95c755c4fe109c0703c0b4b3f Mon Sep 17 00:00:00 2001 From: Jackson Cummings <126146472+thelabcorner@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:06:19 -0500 Subject: [PATCH 12/12] test(snapshot): use Effect v4 fiber pollUnsafe contract in lease regression --- packages/opencode/test/snapshot/snapshot.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index fdded62eb..10b89d6a3 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -7,7 +7,7 @@ import { Global } from "@opencode-ai/core/global" import { Hash } from "@opencode-ai/core/util/hash" import fs from "fs/promises" import path from "path" -import { Deferred, Effect, Exit, Fiber, Layer, Option } from "effect" +import { Deferred, Effect, Exit, Fiber, Layer } from "effect" import { Flock } from "@opencode-ai/core/util/flock" import { SnapshotIndexLock } from "@opencode-ai/core/snapshot-index-lock" import { Snapshot } from "../../src/snapshot" @@ -987,7 +987,7 @@ it.instance( yield* Effect.sleep("200 millis") // The competing capture cannot advance until the external owner // releases its lease, even though the Git index is not itself locked. - expect(Option.isNone(yield* Fiber.poll(pending))).toBe(true) + expect(pending.pollUnsafe()).toBeUndefined() return pending }), (lease) => Effect.promise(() => lease.release()),