Note from the reporter: This is an issue I've experienced quite a bit - I haven't personally investigated it but Opus4.6 did a pretty deep dive on it; essentially what happens is at some point in the session the review panel stops showing updates - I still see all changes in the session history, but the review and changes panels stop showing updates. Full details from the agent are below - I have experienced the issue several times in several sessions over several opencode versions using several different models and providers. I truly hope the information below is as accurate as possible, I'm unfortunately unfamiliar with the opencode codebase and otherwise I wouldn't be able to provide as much detail or research as the model has; even if the research is off, the description of the issue and when/how/where it appears should 100% be correct; hope you don't hate me for submitting extra detail in a report from a model
Versions affected
- Current version: 1.3.0 (updated during session)
- Session originally created on: 1.2.26
- Also reproduced on: 1.2.21 (separate project, claude-opus-4.5, 4,591 steps — same failure)
- Previously used versions with the issue: 1.1.28, 1.1.33, 1.1.34, 1.1.42, 1.2.21, 1.2.26, 1.2.27, 1.3.0
Description
The review panel shows no diffs (empty) for sessions that have been active for more than ~7 days, or for sessions with high step counts. The session history panel still shows individual changes correctly — the issue is specifically with the aggregated review/changes view.
Root cause
snapshot/index.ts uses git write-tree to capture state but never creates commit objects, leaving all tree objects unreachable in git's object model. The cleanup() function runs git gc --prune=7.days hourly, which prunes these unreachable trees. When summary.ts's computeDiff() later tries to diff the first step-start hash against the last step-finish hash, the from hash no longer exists in the object store, git diff-tree fails with fatal: bad object, and the error is silently swallowed — producing an empty diff.
Steps to Reproduce
- Start a session in a git-tracked project
- Make file changes across multiple assistant turns (generating step-start/step-finish parts with snapshot hashes)
- Either:
- Continue the session for 7+ days, OR
- Generate hundreds of steps (Claude models via github-copilot provider produce 3-15x more steps per turn than other models due to parallel tool calls)
- Wait for
cleanup() to run git gc --prune=7.days (runs hourly with 1-minute initial delay)
- Open the review panel
Expected: Review panel shows diffs of all changes made during the session
Actual: Review panel is empty — no diffs shown. Session history still shows individual changes.
Detailed Root Cause Analysis
The bug chain
Step 1: snapshot/index.ts — track() creates unreachable trees
const track = Effect.fnUntraced(function* () {
// ...
yield* add() // git add .
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
return result.text.trim() // Returns a TREE hash — no commit is ever created
})
git write-tree creates a tree object from the current index. This tree is not referenced by any commit, tag, or ref, making it an unreachable object that is eligible for garbage collection.
Step 2: processor.ts — stores hashes in step parts
On start-step: calls Snapshot.track(), stores tree hash in step-start part.
On finish-step: calls Snapshot.track() again, stores tree hash in step-finish part.
Step 3: snapshot/index.ts — cleanup() prunes unreachable objects
const cleanup = Effect.fnUntraced(function* () {
yield* git(args(["gc", "--prune=7.days"]), { cwd: state.directory })
})
Runs every hour. --prune=7.days destroys any unreachable object older than 7 days. Since the tree objects from write-tree are never anchored by commits, they are always unreachable.
Step 4: summary.ts — computeDiff() fails silently
export async function computeDiff(input: { messages: MessageV2.WithParts[] }) {
let from: string | undefined // earliest step-start snapshot hash
let to: string | undefined // latest step-finish snapshot hash
// ... scans all messages for first step-start and last step-finish ...
if (from && to) return Snapshot.diffFull(from, to)
return []
}
When diffFull(from, to) calls git diff-tree with a pruned from hash, git returns fatal: bad object. The error is silently swallowed (likely via .nothrow()), producing an empty result.
Evidence from database forensics
In a 6-day session (session ses_30601aff5ffe0sU89p1Q7fZvjy) with 1,200 steps:
| Metric |
Value |
| Distinct tree hashes in DB |
185 |
| Still alive in object store |
179 |
| Garbage collected |
6 |
First step-start hash |
9841247c... (DEAD — created Mar 17) |
Last step-finish hash |
106a1529... (alive — created Mar 23) |
computeDiff() result |
Empty (fatal: bad object on from) |
The 6 dead hashes are all from the first day of the session (Mar 17) — exactly matching the 7-day prune window.
A second session on a different project (ses_32a2d666, claude-opus-4.5, v1.2.21, 4,591 steps, "PREFLIGHT_OK") shows the identical failure pattern: first hash gone, summary_files=0.
Per-step snapshots DO work
Individual step pairs correctly capture changes. For example, when editing faq.md:
step-start at 07:06:04 → hash d4333c...
step-finish at 07:06:11 → hash 857b11... (different — change captured!)
patch part correctly lists faq.md in changed files
The per-step mechanism works. The problem is only with the session-level computeDiff() which needs the very first hash from the session, and that's the one most likely to be gc'd.
Why this disproportionately affects Claude/Opus models
Claude models produce dramatically more steps per session than other models:
| Model |
Provider |
Steps |
Review works? |
| claude-opus-4.5 |
github-copilot |
4,591 |
No |
| claude-opus-4.6 |
github-copilot |
1,200 |
No |
| minimax-m2.5-free |
opencode |
316 |
Yes |
| glm-4.7-free |
opencode |
60–130 |
Yes |
Each step calls track() → git add . && write-tree, creating more unreachable objects. More steps = more object churn = higher chance that git gc --auto triggers sooner (git auto-gc triggers based on loose object count). Combined with longer session lifetimes typical of complex multi-day work, these sessions reliably hit the prune window.
Related Issues
| Issue |
Status |
Relevance |
| #15977 |
Closed (not planned) |
Directly identifies that write-tree creates unreachable tree objects |
| #12719 |
Open |
Same symptom (stale hash), different trigger (git add failure) |
| #10034 |
Open |
Root cause analysis noting .nothrow() silences errors |
| #17397 |
Open (has PR #17423) |
Notes prune=7.days hardcoding |
| #13065 |
Open |
Same symptom (empty review panel), undiagnosed |
| #10716 |
Open |
session_diff/*.json all empty |
Proposed Fixes
Fix 1: Anchor trees with commits (recommended)
After write-tree, create a commit object to make the tree reachable:
const track = Effect.fnUntraced(function* () {
if (!(yield* enabled())) return
yield* init()
yield* add()
const treeResult = yield* git(args(["write-tree"]), { cwd: state.directory })
const hash = treeResult.text.trim()
// Anchor the tree with a commit to prevent gc
const parentRef = yield* git(
args(["rev-parse", "--verify", "refs/snapshots/latest"]),
{ cwd: state.directory }
).pipe(Effect.option)
const commitArgs = parentRef._tag === "Some"
? ["commit-tree", hash, "-p", parentRef.value.text.trim(), "-m", `snapshot`]
: ["commit-tree", hash, "-m", `snapshot`]
const commitResult = yield* git(args(commitArgs), { cwd: state.directory })
yield* git(
args(["update-ref", "refs/snapshots/latest", commitResult.text.trim()]),
{ cwd: state.directory }
)
return hash // Still return tree hash — git diff-tree works with both tree and commit hashes
})
Fix 2: Disable gc or use --prune=never
Simplest change — modify cleanup():
yield* git(args(["gc", "--prune=never"]), { cwd: state.directory })
This prevents pruning at the cost of unbounded disk growth in the snapshot repo.
Fix 3: Pin individual tree hashes with refs
yield* git(args(["update-ref", `refs/snapshots/t-${hash.slice(0, 12)}`, hash]), { cwd: state.directory })
Lighter than full commits but accumulates refs.
User Workaround
Starting a new session resets the baseline. The new session's first step-start hash will be fresh and won't be gc'd for at least 7 days.
Alternatively, users can manually anchor trees:
SNAP_DIR=~/.local/share/opencode/snapshot/<project_id>
TREE=$(git --git-dir="$SNAP_DIR" --work-tree="$(pwd)" write-tree)
COMMIT=$(git --git-dir="$SNAP_DIR" commit-tree "$TREE" -m "anchor")
git --git-dir="$SNAP_DIR" update-ref refs/snapshots/anchor "$COMMIT"
Versions affected
Description
The review panel shows no diffs (empty) for sessions that have been active for more than ~7 days, or for sessions with high step counts. The session history panel still shows individual changes correctly — the issue is specifically with the aggregated review/changes view.
Root cause
snapshot/index.tsusesgit write-treeto capture state but never creates commit objects, leaving all tree objects unreachable in git's object model. Thecleanup()function runsgit gc --prune=7.dayshourly, which prunes these unreachable trees. Whensummary.ts'scomputeDiff()later tries to diff the firststep-starthash against the laststep-finishhash, thefromhash no longer exists in the object store,git diff-treefails withfatal: bad object, and the error is silently swallowed — producing an empty diff.Steps to Reproduce
cleanup()to rungit gc --prune=7.days(runs hourly with 1-minute initial delay)Expected: Review panel shows diffs of all changes made during the session
Actual: Review panel is empty — no diffs shown. Session history still shows individual changes.
Detailed Root Cause Analysis
The bug chain
Step 1:
snapshot/index.ts—track()creates unreachable treesgit write-treecreates a tree object from the current index. This tree is not referenced by any commit, tag, or ref, making it an unreachable object that is eligible for garbage collection.Step 2:
processor.ts— stores hashes in step partsOn
start-step: callsSnapshot.track(), stores tree hash instep-startpart.On
finish-step: callsSnapshot.track()again, stores tree hash instep-finishpart.Step 3:
snapshot/index.ts—cleanup()prunes unreachable objectsRuns every hour.
--prune=7.daysdestroys any unreachable object older than 7 days. Since the tree objects fromwrite-treeare never anchored by commits, they are always unreachable.Step 4:
summary.ts—computeDiff()fails silentlyWhen
diffFull(from, to)callsgit diff-treewith a prunedfromhash, git returnsfatal: bad object. The error is silently swallowed (likely via.nothrow()), producing an empty result.Evidence from database forensics
In a 6-day session (session
ses_30601aff5ffe0sU89p1Q7fZvjy) with 1,200 steps:step-starthash9841247c...(DEAD — created Mar 17)step-finishhash106a1529...(alive — created Mar 23)computeDiff()resultfrom)The 6 dead hashes are all from the first day of the session (Mar 17) — exactly matching the 7-day prune window.
A second session on a different project (
ses_32a2d666, claude-opus-4.5, v1.2.21, 4,591 steps, "PREFLIGHT_OK") shows the identical failure pattern: first hash gone,summary_files=0.Per-step snapshots DO work
Individual step pairs correctly capture changes. For example, when editing
faq.md:step-startat 07:06:04 → hashd4333c...step-finishat 07:06:11 → hash857b11...(different — change captured!)patchpart correctly listsfaq.mdin changed filesThe per-step mechanism works. The problem is only with the session-level
computeDiff()which needs the very first hash from the session, and that's the one most likely to be gc'd.Why this disproportionately affects Claude/Opus models
Claude models produce dramatically more steps per session than other models:
Each step calls
track()→git add . && write-tree, creating more unreachable objects. More steps = more object churn = higher chance thatgit gc --autotriggers sooner (git auto-gc triggers based on loose object count). Combined with longer session lifetimes typical of complex multi-day work, these sessions reliably hit the prune window.Related Issues
write-treecreates unreachable tree objectsgit addfailure).nothrow()silences errorsprune=7.dayshardcodingsession_diff/*.jsonall emptyProposed Fixes
Fix 1: Anchor trees with commits (recommended)
After
write-tree, create a commit object to make the tree reachable:Fix 2: Disable gc or use
--prune=neverSimplest change — modify
cleanup():This prevents pruning at the cost of unbounded disk growth in the snapshot repo.
Fix 3: Pin individual tree hashes with refs
Lighter than full commits but accumulates refs.
User Workaround
Starting a new session resets the baseline. The new session's first
step-starthash will be fresh and won't be gc'd for at least 7 days.Alternatively, users can manually anchor trees: