Repository navigation
Description
Description
GET /session/:id/diff (SessionDiff.turn) computes a turn's diff by comparing two snapshot trees, and the snapshot store is keyed by project ID:
packages/core/src/snapshot.ts:96
path.join(global.data, "snapshot", location.project.id, Hash.fast(worktree))Project IDs are derived, not stored (remote(origin) ?? .git/opencode ?? root commit ?? sha1(directory), packages/core/src/project.ts), so adding or switching a git remote changes the ID. When that happens the location resolves to a new, empty snapshot store while every tree captured before the change stays in the old one. Any diff for a turn captured before the switch then fails with
Snapshot.Error: fatal: bad object <tree>
and /session/:id/diff returns HTTP 500 instead of a diff or a degraded result.
Two ways this surfaces:
- Hidden session (the case tracked in project: adding a git remote changes the project ID and hides every earlier session #57985): the session keeps the old project ID, so it also disappears from
session list, and its diff 500s. - Visible session: sessions migrated from v1 are re-projected with
session_v2.project_id= the current project ID, so they stay listed and open fine — but every turn captured under the old ID still 500s. That is how I found this: a v1-era session (2026-09-23) is listed normally, its pre-2026-09-29 turns return 500, and its turns from 2026-10-03 onward (captured under the new ID) work.
The bad object is not data loss: the old store is intact, it is just never read again. Snapshots are also never pruned (see #36093), so the orphaned store sits on disk indefinitely.
Steps to reproduce
R=$(mktemp -d)
export XDG_DATA_HOME=$R/data XDG_STATE_HOME=$R/state XDG_CACHE_HOME=$R/cache
mkdir -p "$XDG_DATA_HOME/opencode"
cp ~/.local/share/opencode/auth.json "$XDG_DATA_HOME/opencode/" # or configure a provider in the isolated config
DIR=$R/repro && mkdir -p "$DIR" && cd "$DIR"
git init -q && printf 'seed\n' > seed.txt && git add . \
&& git -c user.email=a@b -c user.name=a commit -qm root
# one turn, so at least one snapshot tree exists
opencode run --standalone --auto -m <provider/model> "Create one.txt containing exactly: one"
SID=$(opencode session list --standalone | head -1 | cut -f1)
opencode api session.diff --standalone --param sessionID=$SID
# -> 200, {"data":[{"file":"one.txt", ...}]}
git remote add origin [email protected]:example/repro.git
opencode api session.diff --standalone --param sessionID=$SID
# -> HTTP 500 {"_tag":"UnknownError","message":"Unexpected server error..."}
# server log: message="failed to diff session turn"
# cause="Snapshot.Error: fatal: bad object 76bf8df7..."Verified output from that run (opencode 2.1.2):
project rows snapshot stores (same worktree hash)
7779fcf2… (root commit) <- snapshot/7779fcf2…/db12f051…/ git cat-file 76bf8df7… -> tree
1a3e8f47… (remote hash) <- snapshot/1a3e8f47…/db12f051…/ git cat-file 76bf8df7… -> fatal: bad object
$ printf '%s' 'git-remote:github.com/example/repro' | sha1sum
1a3e8f47109a71b8a0dfde6212fc93f5989c9f3b # equals the new project ID
session_v2.project_id for the session = 7779fcf2… (old, so it is also hidden from `session list`)
log:
timestamp=... level=ERROR message="failed to diff session turn"
cause="Snapshot.Error: fatal: bad object 76bf8df7075573a2d858002fdbd07a661fafdf7f" sessionID=ses_...
Root cause
- The snapshot store is keyed by
location.project.id, which is a derived value; nothing moves or adopts the store when the ID changes. - v1 had
migrateProjectId(rewroteproject,session,workspacerows on ID change) and wrote.git/opencodeon every directory resolve. v2.0.3 droppedmigrateProjectIdand never writes.git/opencode, so neither the DB rows nor the store follow the new ID.git log -Son it is empty for the 2.x line. SessionDiff.turnreturns[]only for "no steps" / "no snapshot IDs". A tree that the store no longer has raisesSnapshot.Errorand becomes a 500, so the client cannot distinguish "this turn changed nothing" from "this turn's snapshot is unreachable".
Suggested fix
- Key the store on a stable identity (
vcs.store/ worktree path) instead of the mutable project ID, or - record which store belongs to which ID and fall back to the previous store when the current one misses the tree (the old store is still there — a rename/migrate on ID change would also work), or
- at minimum, map
bad objectto a defined outcome (e.g. empty list plus asnapshot_unavailablemarker) so clients can render "snapshot no longer available" instead of a hard failure.
Environment
- opencode version: 2.1.2 (
misenpm package; server viaopencode serve --service, repro via--standalone) - OS: Linux (headless)
- Plugins active in the original profile:
oh-my-opencode-slim,@cortexkit/aft-opencode,@cortexkit/opencode-magic-context(unrelated — reproduces with an isolated data dir) - Also checked: the same code path is still present on
v2.1.4and ondevat6c24ec9(2026-10-31):snapshot/<project.id>, nomigrateProjectId.
Related: #57985 (project ID change hides sessions), #59371 (duplicate project rows), #36093 (snapshot retention/GC policy).
Activity
opencodejr commentedon Oct 31, 2026
Thanks for the detailed report, I could reproduce this.
Steps (opencode 2.1.2, isolated data dirs, a local OpenAI-compatible mock as the model):
git initand a root commit.opencode run --standalone --auto ...: one turn that writesone.txt.opencode api session.diff --standalone --param sessionID=$SIDreturns 200 with theone.txtpatch.git remote add origin [email protected]:example/repro.git.failed to diff session turnwithSnapshot.Error: fatal: bad object <tree>.As you described,
snapshot/now has a directory for the old project ID and one for the new ID.git cat-filefinds the tree in the old store but not in the new one, so the data is still there; it just isn't read any more.It still happens on the latest
dev(6c24ec9), run from source. The script below reproduces it end to end, including the mock model.Related: #57985 (same project ID change hides sessions), #59371 / #59946 (duplicate
projectrows and the session-list fix, which doesn't cover snapshot stores), #36093 (snapshot retention).Reproduction script (repro.sh)
Run it:
bash repro.sh