anomalyco / anomalyco/opencode

Session snapshot "restore/revert" bulk-overwrites the whole worktree with a stale baseline tree that is never rotated

Open
#49,732 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
209k
Forks
27.5k
PR merge metrics
PR metrics pending

Description

Description

Summary
The session snapshot subsystem pins a single project-level baseline tree (a git TREE object id) on first creation and never rotates it. Every later session's step.started/revert.snapshot reuses the same tree id. A restore/revert then runs a whole-tree checkout (git read-tree + git checkout-index --all --force) with no diff-size guard, reverting the entire worktree to the state of that stale baseline — bulk-overwriting tracked files with old content and resurrecting deleted files. Bookkeeping lives in a hidden shadow repo (~/.local/share/opencode/snapshot///), so the real repo's HEAD / reflog / index stay untouched, making the event look like a silent "workspace rollback".

Evidence (DB, read-only)
All from ~/.local/share/opencode/opencode.db (SQLite, 867MB) + the shadow git repo ~/.local/share/opencode/snapshot/e242e0621b65be4ad71f837f17c59b8baae12a77//.

Across ALL step-start/step-finish part JSON in 25 days, the snapshot field contains exactly 2 distinct TREE ids:
ff71af… (first seen 2026-08-21 21:12, last seen 2026-08-22 18:07)
9660d465… (first seen 2026-08-24 22:11, last seen 2026-09-18 17:03 — 100% of snapshots for 25 days)
One session had a non-empty revert record: session.revert = { messageID: "msg_…", snapshot: "9660d465…", diff: "" } — empty diff = whole-tree rollback, no file-level plan.
The shadow repo index (written today) git diff --cached <9660d465 tree> = empty → index tree == the 8-24 baseline. git diff --cached = 334 files touched / 68,439 deletions → index is the old slim worktree, not current state.
The shadow repo: worktree = /Users/…/codex-workplace (points at the live worktree), objects/info/alternates → real repo .git/objects, HEAD unborn master, empty refs, no reflog. git status --short shows the whole tree staged (A/AM).
Timeline: shadow repo created 08-17; lost-found/ created 08-24 13:20 (= first incident day; fsck ran); baseline switched from ff71af to 9660d465 at 08-24 22:11 (shadow repo re-created via git.repo.create which clones the main repo's index into the shadow index). Incidents observed ~08-24, 08-26, 09-11, 09-18 all restored files to the 08-24 snapshot state. Real repo git log/reflog show no reset/restore — HEAD intact, deleted files resurrected as untracked.
Evidence (source, engine bundle in Desktop app.asar)
Snapshot.capture → Git.tree.capture → per-scope Git.index.refresh (a real git add --all --sparse --pathspec-from-file=- + git rm --cached for ignored/oversized) then write-tree. So capture() is supposed to produce a fresh tree reflecting the current worktree each call.
Defect 1 — baseline never recaptured. SessionRevert.stage: original = session.revert?.snapshot ? reuseExistingTree : snapshot.capture(). Once a revert.snapshot is stored, every later stage/restore reuses the OLD tree; capture is short-circuited forever.
Defect 2 — no guard on whole-tree restore. The restore/checkout path ends in git read-tree + checkout-index --all --force (unconditional full-tree overwrite). No diff-size / file-count check.
Defect 3 — content-addressing self-stabilizing loop. Tree ids are content-addressed. After a restore writes the 8-24 content back into the worktree, the next capture produces the same tree id (identical content ⇒ identical SHA), so the recorded baseline never visibly changes — masking the bug for weeks.
Workaround for users: set "snapshots": false in opencode.json (this is the exact config key checked by the engine: config.latest(entries, "snapshots") !== false).

Suggested fix
Never reuse a stored revert baseline for a new session/step — always call capture() at session/step start (or at minimum cap the reuse to the same session).
Add a guard before any whole-tree checkout-index --all --force: compute the diff size between target tree and current worktree and abort (with a clear error) when it exceeds a sane threshold (e.g. >500 files or >50k lines), requiring explicit confirmation.
Derive the session-revert target from the latest captured tree, and prefer per-file git.tree.restore over whole-tree checkout for session-level reverts.
Consider validating that target tree is reachable/plausible relative to current state before writing.
Versions: Desktop App 1.18.21 / engine bundle app.asar node-CMcqrhBZ.js; TUI 1.18.30.

Plugins

No response

OpenCode version

1.18.21 (1.18.21)

Steps to reproduce

No response

Screenshot and/or share link

No response

Operating System

No response

Terminal

No response

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with SessionRevert.stage and the snapshot.capture, Git.tree.capture, and Git.index.refresh entry points in the engine bundle app.asar (node-CMcqrhBZ.js). Trace how the stored session.revert snapshot reaches the restore/checkout path and how the snapshots config key is checked. Done means new steps capture a current baseline and large whole-tree restores are rejected or require explicit confirmation, with regression coverage added where the project’s tests permit.

Written by the indexing model from the issue text.

Assessment

Tech stack
git, typescript
Domain
devtools
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.