Skip to content

snapshot: /session/:id/diff returns 500 "bad object" for turns captured before a project ID change #60857

Open

Description

@gaojunran

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 (rewrote project, session, workspace rows on ID change) and wrote .git/opencode on every directory resolve. v2.0.3 dropped migrateProjectId and never writes .git/opencode, so neither the DB rows nor the store follow the new ID. git log -S on it is empty for the 2.x line.
  • SessionDiff.turn returns [] only for "no steps" / "no snapshot IDs". A tree that the store no longer has raises Snapshot.Error and becomes a 500, so the client cannot distinguish "this turn changed nothing" from "this turn's snapshot is unreachable".

Suggested fix

  1. Key the store on a stable identity (vcs.store / worktree path) instead of the mutable project ID, or
  2. 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
  3. at minimum, map bad object to a defined outcome (e.g. empty list plus a snapshot_unavailable marker) so clients can render "snapshot no longer available" instead of a hard failure.

Environment

  • opencode version: 2.1.2 (mise npm package; server via opencode 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.4 and on dev at 6c24ec9 (2026-10-31): snapshot/<project.id>, no migrateProjectId.

Related: #57985 (project ID change hides sessions), #59371 (duplicate project rows), #36093 (snapshot retention/GC policy).

Activity

  1. opencodejr commented on Oct 31, 2026

    @opencodejr
    Contributor

    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):

    1. git init and a root commit.
    2. opencode run --standalone --auto ...: one turn that writes one.txt.
    3. opencode api session.diff --standalone --param sessionID=$SID returns 200 with the one.txt patch.
    4. git remote add origin [email protected]:example/repro.git.
    5. The same call now returns HTTP 500, and the server log has failed to diff session turn with Snapshot.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-file finds 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 project rows and the session-list fix, which doesn't cover snapshot stores), #36093 (snapshot retention).

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #60857: /session/:id/diff returns 500 "bad object" after `git remote add` changes the project ID.
    # Needs bun and git. Starts an inline OpenAI-compatible mock that makes the model call `write` once.
    # Usage: ./repro.sh                       (uses `opencode` from PATH)
    #   from source: BUN_OPTIONS="--conditions=browser --preload=<repo>/packages/tui/node_modules/@opentui/solid/scripts/preload.js" OC="bun <repo>/packages/cli/src/index.ts" ./repro.sh
    OC=${OC:-opencode}
    HERE=$(cd "$(dirname "$0")" && pwd)
    R=$(mktemp -d)
    cat > "$R/mock.ts" <<'MOCK'
    // Minimal OpenAI-compatible chat completions mock.
    // First request of a turn: call the `write` tool to create one.txt. After a tool result: reply "done".
    Bun.serve({
      port: Number(process.env.PORT ?? 4555),
      async fetch(req) {
        const url = new URL(req.url)
        if (url.pathname.endsWith("/models")) return Response.json({ object: "list", data: [{ id: "mock", object: "model" }] })
        const body: any = await req.json().catch(() => ({}))
        const last = body.messages?.[body.messages.length - 1]
        const tools = (body.tools ?? []).map((t: any) => t.function?.name)
        if (process.env.MOCK_DEBUG) console.error("tools:", tools.join(","), "last role:", last?.role)
        const chunk = (o: unknown) => `data: ${JSON.stringify(o)}\n\n`
        const base = { id: "c1", object: "chat.completion.chunk", created: 0, model: "mock" }
        const usage = { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }
        let out: string
        if (last?.role !== "tool" && tools.includes("write")) {
          out =
            chunk({ ...base, choices: [{ index: 0, delta: { role: "assistant", tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "write", arguments: JSON.stringify({ path: "one.txt", content: "one\n" }) } }] }, finish_reason: null }] }) +
            chunk({ ...base, choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], usage })
        } else {
          out =
            chunk({ ...base, choices: [{ index: 0, delta: { role: "assistant", content: "done" }, finish_reason: null }] }) +
            chunk({ ...base, choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage })
        }
        return new Response(out + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } })
      },
    })
    MOCK
    export XDG_DATA_HOME=$R/data XDG_STATE_HOME=$R/state XDG_CACHE_HOME=$R/cache XDG_CONFIG_HOME=$R/config
    PORT=4555 bun "$R/mock.ts" & MOCK=$!; trap 'kill $MOCK' EXIT; sleep 1
    export OPENCODE_CONFIG_CONTENT='{"providers":{"mock":{"name":"Mock","package":"@opencode/ai/providers/openai-compatible","settings":{"baseURL":"http://127.0.0.1:4555/v1","apiKey":"x"},"models":{"mock":{"name":"mock"}}}},"model":"mock/mock"}'
    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
    $OC run --standalone --auto -m mock/mock "Create one.txt containing exactly: one" ; echo "run exit=$?"
    SID=$($OC session list --standalone | head -1 | cut -f1); echo "SID=$SID"
    echo "--- diff before remote add"; $OC api session.diff --standalone --param sessionID=$SID; echo " exit=$?"
    git remote add origin [email protected]:example/repro.git
    echo "--- diff after remote add"; $OC api session.diff --standalone --param sessionID=$SID; echo " exit=$?"
    echo "--- snapshot stores"; ls "$XDG_DATA_HOME"/opencode/snapshot/
    grep -rh "bad object" "$XDG_DATA_HOME"/opencode/log "$XDG_STATE_HOME" 2>/dev/null | tail -2
    echo "--- the missing tree is still in the old store"
    TREE=$(grep -rhoE "bad object [0-9a-f]+" "$XDG_DATA_HOME"/opencode/log "$XDG_STATE_HOME" 2>/dev/null | tail -1 | awk '{print $3}')
    for s in "$XDG_DATA_HOME"/opencode/snapshot/*/*; do echo -n "$s: "; git --git-dir="$s" cat-file -t "$TREE" 2>&1; done

    Run it: bash repro.sh

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions