anomalyco / anomalyco/opencode
Snapshot git transactions race across processes and a stale index.lock permanently wedges snapshots
@kitlangton is already working on this.
Since Sep 13, 2026.
- Dominant language
- TypeScript
- Stars
- 209k
- Forks
- 27.5k
- PR merge metrics
- PR metrics pending
Description
What version of opencode are you using?
Latest dev at time of report (base commit 95daf90670b7c039c436c85537da5fbfe2205b41), Linux x64, git 2.x.
Describe the bug
Snapshot git operations race across separate opencode processes working in the same project worktree, and a single stale index.lock permanently and silently disables snapshots.
Root cause
packages/opencode/src/snapshot/index.ts serializes snapshot git operations with an in-process-only semaphore:
locks = new Map<string, Semaphore>()keyed bystate.gitdirstate.gitdir = <global data>/snapshot/<project id>/<Hash.fast(worktree)>
Every opencode process (or any two sessions) working in the same worktree derives the same on-disk gitdir, but the semaphore only coordinates within one process. Independent processes therefore run git add, git rm --cached, and git write-tree concurrently against one shared index.
Measured reproduction (8 processes x 8 rounds, scratch repo): 56/64 git add failures and 56/64 git write-tree failures, all Unable to create '.../index.lock': File exists.
Git's own index.lock is a metadata-free zero-byte file and fails immediately when present - there is no wait/retry. If a git child is SIGKILLed (OOM, crash, terminal close) the lock is left behind forever (git intentionally never auto-removes it), and opencode has no detection or recovery: every later snapshot call fails with the same warning. In the wild this produced a 9-day silent snapshot outage - every session log filled with failed to add snapshot files ... index.lock: File exists and tracking hash="".
Related correctness issues found while fixing
stage()/drop()ignored failures, andwrite-treestdout was not validated, so an empty or stale hash could be returned.restore()failure was unobservable, and session revert persisted a revert that the filesystem never received.setup()re-rangit configon every track once the gitdir existed; a concurrentconfig.lock(exit 255) permanently wedged new repositories.
Fix (PR to follow)
- Keep the process-local semaphore as an outer gate and add the existing
EffectFlock(packages/core/src/util/effect-flock.ts) around the whole snapshot transaction, serializing across processes. - Strictly handle setup/stage/drop/write-tree failures; never return empty/stale tree IDs (validate SHA-1/SHA-256 object IDs).
- Retry only exact native
index.lockcontention with bounded jitter; never delete an ownership-unknown native lock; emit a deduplicated structured diagnostic (snapshot_index_lock_stuck) with lock path, age, and a manual remedy after bounded retries. - Reclaim an abandoned opencode-owned transaction token only when its owner is provably dead (same host +
ESRCH); live/legacy/foreign-host tokens are preserved. - Abort session revert when snapshot restore/revert fails instead of persisting a false revert.
- Run
setuponly for a new snapshot gitdir.
Verification
- Deterministic 8-process full-transaction race test (exclusive sentinel spanning
git add->git write-tree), plus failure-path, SHA-256, stale-native-lock, token-reclaim, and revert-abort regression tests. bun typecheckclean;bun test test/snapshot/snapshot.test.ts test/snapshot/snapshot-lock.test.ts-> 73 pass / 1 skip / 0 fail (780 assertions); session revert suite 8 pass / 0 fail.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.