diff --git a/docs/superpowers/plans/2026-09-07-worktree-evidence-before-catalog.md b/docs/superpowers/plans/2026-09-07-worktree-evidence-before-catalog.md new file mode 100644 index 00000000..9277f0a2 --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-worktree-evidence-before-catalog.md @@ -0,0 +1,276 @@ +### Task 3: Fix #822, evidence evicted before the first Git catalog is lost + +**Files:** +- Modify: `src/renderer/src/workspace/work-context/LiveWorktreeReconciler.ts` +- Modify: `src/renderer/src/workspace/work-context/LiveWorktreeReconciler.test.ts` +- Create: `docs/superpowers/plans/2026-09-07-worktree-evidence-before-catalog.md` (this section) + +**Interfaces:** +- Consumes: the recorded fixture `testing/fixtures/worktree-live-attribution/claude-cwd-tool-branch-conflict.json` (its `records[0]` and `records[1]` are assistant `Write` tool uses into `/fixture/project-1/.worktrees/worktree-1/...`, both relevant evidence) and `git-worktree-identities.json`. +- Produces: `SessionEvidence.deferredRaw: unknown[]`, `SessionEvidence.droppedBeforeCatalog: number`; `WorktreeReconciliationDebug` gains `deferredEvidenceCount: number` and `droppedBeforeCatalog: number`. + +The defect, from the code on `origin/main`: `observe()` evicts records beyond `recentRawLimit` (500) into the baseline through `foldRaw()`. `foldRaw()` skips every record that is not a `worktree-state` record while the cwd has no catalog (`if (asRecord(raw)?.type !== 'worktree-state' && !hasCatalog) continue`). Records retained in `recentRaw` are safe because `rebuild()` re-folds them after the catalog arrives; records evicted before the catalog are gone. The fix keeps evicted-without-catalog records in a second bounded list and folds them into the baseline the first time `rebuild()` runs with a catalog. Overflow of that second list is counted, not silent. + +- [ ] **Step 1: Create the worktree and commit the plan** + +```bash +cd /Users/juliusolsson/Desktop/Development/agent-code +git worktree add .worktrees/worktree-evidence-before-catalog -b fix/worktree-evidence-before-catalog origin/main +cd .worktrees/worktree-evidence-before-catalog +git submodule update --init && ln -s ../../node_modules node_modules +# copy this Task 3 section into docs/superpowers/plans/2026-09-07-worktree-evidence-before-catalog.md +git add docs/superpowers/plans/2026-09-07-worktree-evidence-before-catalog.md +git commit -m "docs(worktrees): plan retaining evidence evicted before the first Git catalog + +Refs #822 + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd" +``` + +- [ ] **Step 2: Write the failing tests** + +Append inside the existing `describe('LiveWorktreeReconciler recorded cache ordering', ...)` block in `LiveWorktreeReconciler.test.ts`: + +```ts + it('folds evidence evicted before the first catalog once the catalog arrives (#822)', async () => { + const claude = fixture('claude-cwd-tool-branch-conflict.json') + let resolveCatalog!: (value: { ok: true; worktrees: ReturnType }) => void + const pendingCatalog = new Promise<{ ok: true; worktrees: ReturnType }>( + resolve => { resolveCatalog = resolve }, + ) + let projection = emptyProjection() + let reconciler!: LiveWorktreeReconciler + reconciler = new LiveWorktreeReconciler({ + loadWorktrees: () => pendingCatalog, + // A window of one guarantees the first write is evicted by the second + // while Git IPC is still pending, which is the #822 loss path. + recentRawLimit: 1, + onCatalogReady: cwd => { + projection = reconciler.project({ sessionId: 'evicted-early', cwd, projection }) + }, + }) + + const refreshing = reconciler.refresh(claude.git.main.path) + projection = reconciler.observe('evicted-early', claude.git.main.path, [{ entry: claude.records[0] }], projection) + projection = reconciler.observe('evicted-early', claude.git.main.path, [{ entry: claude.records[1] }], projection) + expect(reconciler.summarize({ sessionId: 'evicted-early', cwd: claude.git.main.path, projection })) + .toMatchObject({ recentEvidenceCount: 1, deferredEvidenceCount: 1, droppedBeforeCatalog: 0 }) + + resolveCatalog({ ok: true, worktrees: catalog() }) + expect(await refreshing).toBe('ready') + + const writes = projection.workActivity?.timeline.filter(event => event.kind === 'file-write') ?? [] + expect(writes).toHaveLength(2) + expect(writes.every(event => event.resolvedWorktreePath === claude.git.grid?.path)).toBe(true) + expect(reconciler.summarize({ sessionId: 'evicted-early', cwd: claude.git.main.path, projection })) + .toMatchObject({ deferredEvidenceCount: 0, droppedBeforeCatalog: 0 }) + }) + + it('bounds the deferred window and reports what it dropped', async () => { + const claude = fixture('claude-cwd-tool-branch-conflict.json') + const reconciler = new LiveWorktreeReconciler({ + loadWorktrees: () => new Promise(() => undefined), // catalog never arrives + recentRawLimit: 1, + onCatalogReady: () => undefined, + }) + let projection = emptyProjection() + void reconciler.refresh(claude.git.main.path) + for (let i = 0; i < 3; i += 1) { + projection = reconciler.observe('never-catalog', claude.git.main.path, [{ entry: claude.records[i % 2] }], projection) + } + // Three relevant records, window of one: one live, one deferred, one dropped. + expect(reconciler.summarize({ sessionId: 'never-catalog', cwd: claude.git.main.path, projection })) + .toMatchObject({ recentEvidenceCount: 1, deferredEvidenceCount: 1, droppedBeforeCatalog: 1 }) + }) +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +```bash +source /opt/homebrew/opt/nvm/nvm.sh && nvm use 24 +NODE_ENV=test npx vitest run --project unit src/renderer/src/workspace/work-context/LiveWorktreeReconciler.test.ts +``` +Expected: the two new tests FAIL (`deferredEvidenceCount` is undefined; the first test finds 1 write instead of 2). The existing six tests still pass. + +- [ ] **Step 4: Implement** + +In `LiveWorktreeReconciler.ts`: + +1. Extend the debug type: + +```ts +export type WorktreeReconciliationDebug = { + cacheState: 'missing' | 'loading' | 'ready' | 'stale' + catalogCount: number + recentEvidenceCount: number + /** Relevant records evicted from the live window before this cwd had a Git + * catalog. They are folded into the baseline on the first rebuild that + * has one (#822). Non-zero means Git IPC is still pending. */ + deferredEvidenceCount: number + /** Deferred records lost to the deferred window's own bound. Non-zero is + * an honest "attribution for this session is incomplete" signal. */ + droppedBeforeCatalog: number + activeSource: string | null + primarySource: string | null + projectedPath: string | null + projectedBranch: string | null +} +``` + +2. Extend `SessionEvidence`: + +```ts +type SessionEvidence = { + baseline: WorktreeRuntimeProjection + recentRaw: unknown[] + // WHY a second list instead of folding into the baseline immediately: + // foldRaw() cannot interpret a provider path without the Git catalog (it + // would guess against an empty worktree list and then dedupe the correct + // answer away later), and dropping the record was the #822 data loss. + // Holding it here until the catalog arrives keeps the eviction lossless + // for the first `recentRawLimit` records; beyond that the loss is counted. + deferredRaw: unknown[] + droppedBeforeCatalog: number + revision: number + lastEmitted: WorktreeRuntimeProjection + replay?: { /* unchanged */ } +} +``` + +3. In `observe()`, both evidence constructions (`evidence = { ... }` in the hydration branch and `evidence ??= { ... }`) carry the new fields: the hydration branch copies `deferredRaw: evidence.deferredRaw, droppedBeforeCatalog: evidence.droppedBeforeCatalog` and releases keys for deferred records too: + +```ts + evidence = { + baseline: this.releaseRetainedEvidenceKeys( + projection, + // Deferred records will be folded later exactly like the live + // window, so hydration must release their keys as well or they + // would double-count against the history loader's smaller catalog. + [...evidence.deferredRaw, ...evidence.recentRaw], + ), + recentRaw: evidence.recentRaw, + deferredRaw: evidence.deferredRaw, + droppedBeforeCatalog: evidence.droppedBeforeCatalog, + revision: evidence.revision, + lastEmitted: projection, + } +``` + +and the fresh construction adds `deferredRaw: [], droppedBeforeCatalog: 0,`. + +4. Replace the eviction block in `observe()`: + +```ts + if (evidence.recentRaw.length > this.recentRawLimit) { + const evicted = evidence.recentRaw.splice( + 0, + evidence.recentRaw.length - this.recentRawLimit, + ) + const cached = this.cache.get(cwd) + if (cached && cached.refreshedAt > 0) { + evidence.baseline = this.foldRaw(cwd, evidence.baseline, evicted) + } else { + // No catalog yet. worktree-state records fold safely without one + // (foldRaw applies them); every other provider path must wait. + const foldable = evicted.filter(raw => asRecord(raw)?.type === 'worktree-state') + const deferred = evicted.filter(raw => asRecord(raw)?.type !== 'worktree-state') + if (foldable.length > 0) { + evidence.baseline = this.foldRaw(cwd, evidence.baseline, foldable) + } + evidence.deferredRaw.push(...deferred) + if (evidence.deferredRaw.length > this.recentRawLimit) { + // Same bound as the live window so a session that never gets a + // catalog holds at most 2 × recentRawLimit records. Oldest go + // first because attribution weights recency. + const overflow = evidence.deferredRaw.length - this.recentRawLimit + evidence.deferredRaw.splice(0, overflow) + evidence.droppedBeforeCatalog += overflow + } + } + } +``` + +5. In `project()`, the rebuilt evidence object carries the two new fields and releases keys for `[...evidence.deferredRaw, ...evidence.recentRaw]`, same shape as step 3. + +6. In `rebuild()`, before the replay-cache check: + +```ts + if (catalog && evidence.deferredRaw.length > 0) { + // First rebuild with a catalog: the deferred records precede the live + // window in time, so they belong in the baseline, folded in arrival + // order under the real worktree list. A new baseline object also + // invalidates the replay cache below, which is what we want. + evidence.baseline = this.foldRaw(cwd, evidence.baseline, evidence.deferredRaw) + evidence.deferredRaw = [] + evidence.revision += 1 + } +``` + +7. In `summarize()`, add to the returned object: + +```ts + deferredEvidenceCount: evidence?.deferredRaw.length ?? 0, + droppedBeforeCatalog: evidence?.droppedBeforeCatalog ?? 0, +``` + +where `evidence` is `this.evidenceBySession.get(params.sessionId)` (read it at the top of `summarize()` if it is not already). + +- [ ] **Step 5: Run the tests and the type gate** + +```bash +NODE_ENV=test npx vitest run --project unit src/renderer/src/workspace/work-context/ +npm run typecheck +npm run check:worktree-live-fixtures +``` +Expected: all tests in the directory pass (existing six plus two new); typecheck clean; the fixture check still passes (no fixture was changed). + +If `npm run typecheck` reports a consumer of `WorktreeReconciliationDebug` that builds the object literally (the debug panel or a test), add the two fields there with `0`. + +- [ ] **Step 6: Commit, push, open the PR** + +```bash +git add src/renderer/src/workspace/work-context/LiveWorktreeReconciler.ts src/renderer/src/workspace/work-context/LiveWorktreeReconciler.test.ts +git commit -m "fix(worktrees): retain evidence evicted before the first Git catalog + +Records evicted from the live window while Git IPC was still pending were +folded through foldRaw, which skips every non-worktree-state record without +a catalog. They were dropped silently, so a fast-starting session lost +attribution for everything before its newest 500 relevant records. Hold +those records in a second bounded list and fold them on the first rebuild +that has a catalog; count, do not hide, what the bound drops. + +Fixes #822 + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd" +git push -u origin fix/worktree-evidence-before-catalog +gh pr create --title "fix(worktrees): retain evidence evicted before the first Git catalog" --body-file - <<'EOF' +## Problem +`LiveWorktreeReconciler.observe` folds records evicted from the 500-record live window into the baseline through `foldRaw`, and `foldRaw` skips every non-`worktree-state` record while the cwd has no Git catalog. A session that buffers more than 500 relevant records before the first catalog reply loses attribution for all but the newest 500. Surfaced by the independent review of #808 and filed as #822; pre-existing on main. + +## Change +- Records evicted before a catalog exists go to a second list, `deferredRaw`, bounded by the same `recentRawLimit`. +- The first `rebuild()` that has a catalog folds the deferred list into the baseline in arrival order. +- Hydration and history adoption release tracker keys for deferred records too, so they cannot double-count. +- `summarize()` reports `deferredEvidenceCount` and `droppedBeforeCatalog` so the debug panel can say when attribution is still incomplete. + +## Tests +- Recorded Claude fixture, window of one, catalog pending: the first write is evicted, deferred, and present in the timeline after the catalog arrives (fails on main with one write). +- Window of one, catalog never arrives, three records: one live, one deferred, one counted as dropped. + +## Verification +`npm run typecheck`, the work-context unit suite, `npm run check:worktree-live-fixtures`. + +Fixes #822 + +🤖 Generated with [Claude Code](https://claude.com/claude-code) + +https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd +EOF +``` + +CHECKPOINT: report the PR link; merge only on confirmation. + +--- + diff --git a/src/renderer/src/workspace/work-context/LiveWorktreeReconciler.test.ts b/src/renderer/src/workspace/work-context/LiveWorktreeReconciler.test.ts index 38728e7e..de7fab23 100644 --- a/src/renderer/src/workspace/work-context/LiveWorktreeReconciler.test.ts +++ b/src/renderer/src/workspace/work-context/LiveWorktreeReconciler.test.ts @@ -389,6 +389,12 @@ describe('LiveWorktreeReconciler recorded cache ordering', () => { cacheState: 'missing', catalogCount: 0, recentEvidenceCount: 2, + // These four recorded Codex records are relevant evidence and the window + // holds two, so the other two are exactly the records #822 used to drop + // on the floor: no catalog has arrived, so they wait in the deferred + // list. The bound is the same 2, so nothing is lost yet. + deferredEvidenceCount: 2, + droppedBeforeCatalog: 0, activeSource: null, primarySource: null, projectedPath: null, @@ -403,4 +409,55 @@ describe('LiveWorktreeReconciler recorded cache ordering', () => { expect(await refreshing).toBe('disposed') expect(onCatalogReady).not.toHaveBeenCalled() }) + + it('folds evidence evicted before the first catalog once the catalog arrives (#822)', async () => { + const claude = fixture('claude-cwd-tool-branch-conflict.json') + let resolveCatalog!: (value: { ok: true; worktrees: ReturnType }) => void + const pendingCatalog = new Promise<{ ok: true; worktrees: ReturnType }>( + resolve => { resolveCatalog = resolve }, + ) + let projection = emptyProjection() + let reconciler!: LiveWorktreeReconciler + reconciler = new LiveWorktreeReconciler({ + loadWorktrees: () => pendingCatalog, + // A window of one guarantees the first write is evicted by the second + // while Git IPC is still pending, which is the #822 loss path. + recentRawLimit: 1, + onCatalogReady: cwd => { + projection = reconciler.project({ sessionId: 'evicted-early', cwd, projection }) + }, + }) + + const refreshing = reconciler.refresh(claude.git.main.path) + projection = reconciler.observe('evicted-early', claude.git.main.path, [{ entry: claude.records[0] }], projection) + projection = reconciler.observe('evicted-early', claude.git.main.path, [{ entry: claude.records[1] }], projection) + expect(reconciler.summarize({ sessionId: 'evicted-early', cwd: claude.git.main.path, projection })) + .toMatchObject({ recentEvidenceCount: 1, deferredEvidenceCount: 1, droppedBeforeCatalog: 0 }) + + resolveCatalog({ ok: true, worktrees: catalog() }) + expect(await refreshing).toBe('ready') + + const writes = projection.workActivity?.timeline.filter(event => event.kind === 'file-write') ?? [] + expect(writes).toHaveLength(2) + expect(writes.every(event => event.resolvedWorktreePath === claude.git.grid?.path)).toBe(true) + expect(reconciler.summarize({ sessionId: 'evicted-early', cwd: claude.git.main.path, projection })) + .toMatchObject({ deferredEvidenceCount: 0, droppedBeforeCatalog: 0 }) + }) + + it('bounds the deferred window and reports what it dropped', async () => { + const claude = fixture('claude-cwd-tool-branch-conflict.json') + const reconciler = new LiveWorktreeReconciler({ + loadWorktrees: () => new Promise(() => undefined), // catalog never arrives + recentRawLimit: 1, + onCatalogReady: () => undefined, + }) + let projection = emptyProjection() + void reconciler.refresh(claude.git.main.path) + for (let i = 0; i < 3; i += 1) { + projection = reconciler.observe('never-catalog', claude.git.main.path, [{ entry: claude.records[i % 2] }], projection) + } + // Three relevant records, window of one: one live, one deferred, one dropped. + expect(reconciler.summarize({ sessionId: 'never-catalog', cwd: claude.git.main.path, projection })) + .toMatchObject({ recentEvidenceCount: 1, deferredEvidenceCount: 1, droppedBeforeCatalog: 1 }) + }) }) diff --git a/src/renderer/src/workspace/work-context/LiveWorktreeReconciler.ts b/src/renderer/src/workspace/work-context/LiveWorktreeReconciler.ts index 4665c1aa..bf5a7266 100644 --- a/src/renderer/src/workspace/work-context/LiveWorktreeReconciler.ts +++ b/src/renderer/src/workspace/work-context/LiveWorktreeReconciler.ts @@ -23,6 +23,13 @@ export type WorktreeReconciliationDebug = { cacheState: 'missing' | 'loading' | 'ready' | 'stale' catalogCount: number recentEvidenceCount: number + /** Relevant records evicted from the live window before this cwd had a Git + * catalog. They are folded into the baseline on the first rebuild that + * has one (#822). Non-zero means Git IPC is still pending. */ + deferredEvidenceCount: number + /** Deferred records lost to the deferred window's own bound. Non-zero is + * an honest "attribution for this session is incomplete" signal. */ + droppedBeforeCatalog: number activeSource: string | null primarySource: string | null projectedPath: string | null @@ -40,6 +47,14 @@ type RefreshOutcome = 'cached' | 'ready' | 'failed' | 'disposed' type SessionEvidence = { baseline: WorktreeRuntimeProjection recentRaw: unknown[] + // WHY a second list instead of folding into the baseline immediately: + // foldRaw() cannot interpret a provider path without the Git catalog (it + // would guess against an empty worktree list and then dedupe the correct + // answer away later), and dropping the record was the #822 data loss. + // Holding it here until the catalog arrives keeps the eviction lossless + // for the first `recentRawLimit` records; beyond that the loss is counted. + deferredRaw: unknown[] + droppedBeforeCatalog: number revision: number lastEmitted: WorktreeRuntimeProjection replay?: { @@ -109,9 +124,14 @@ export class LiveWorktreeReconciler { evidence = { baseline: this.releaseRetainedEvidenceKeys( projection, - evidence.recentRaw, + // Deferred records will be folded later exactly like the live + // window, so hydration must release their keys as well or they + // would double-count against the history loader's smaller catalog. + [...evidence.deferredRaw, ...evidence.recentRaw], ), recentRaw: evidence.recentRaw, + deferredRaw: evidence.deferredRaw, + droppedBeforeCatalog: evidence.droppedBeforeCatalog, revision: evidence.revision, lastEmitted: projection, } @@ -126,6 +146,8 @@ export class LiveWorktreeReconciler { // already-lossy projection. baseline: projection, recentRaw: [], + deferredRaw: [], + droppedBeforeCatalog: 0, revision: 0, lastEmitted: projection, } @@ -148,11 +170,27 @@ export class LiveWorktreeReconciler { 0, evidence.recentRaw.length - this.recentRawLimit, ) - evidence.baseline = this.foldRaw( - cwd, - evidence.baseline, - evicted, - ) + const cached = this.cache.get(cwd) + if (cached && cached.refreshedAt > 0) { + evidence.baseline = this.foldRaw(cwd, evidence.baseline, evicted) + } else { + // No catalog yet. worktree-state records fold safely without one + // (foldRaw applies them); every other provider path must wait. + const foldable = evicted.filter(raw => asRecord(raw)?.type === 'worktree-state') + const deferred = evicted.filter(raw => asRecord(raw)?.type !== 'worktree-state') + if (foldable.length > 0) { + evidence.baseline = this.foldRaw(cwd, evidence.baseline, foldable) + } + evidence.deferredRaw.push(...deferred) + if (evidence.deferredRaw.length > this.recentRawLimit) { + // Same bound as the live window so a session that never gets a + // catalog holds at most 2 × recentRawLimit records. Oldest go + // first because attribution weights recency. + const overflow = evidence.deferredRaw.length - this.recentRawLimit + evidence.deferredRaw.splice(0, overflow) + evidence.droppedBeforeCatalog += overflow + } + } } this.evidenceBySession.set(sessionId, evidence) const next = this.rebuild(cwd, evidence) @@ -240,9 +278,14 @@ export class LiveWorktreeReconciler { evidence = { baseline: this.releaseRetainedEvidenceKeys( params.projection, - evidence.recentRaw, + // Same reasoning as the hydration branch in observe(): records still + // waiting on the catalog are replayed later, so history adoption has + // to give their tracker keys back or the replay is deduped away. + [...evidence.deferredRaw, ...evidence.recentRaw], ), recentRaw: evidence.recentRaw, + deferredRaw: evidence.deferredRaw, + droppedBeforeCatalog: evidence.droppedBeforeCatalog, revision: evidence.revision, lastEmitted: params.projection, } @@ -259,6 +302,15 @@ export class LiveWorktreeReconciler { ): WorktreeRuntimeProjection { const cached = this.cache.get(cwd) const catalog = cached && cached.refreshedAt > 0 ? cached.worktrees : undefined + if (catalog && evidence.deferredRaw.length > 0) { + // First rebuild with a catalog: the deferred records precede the live + // window in time, so they belong in the baseline, folded in arrival + // order under the real worktree list. A new baseline object also + // invalidates the replay cache below, which is what we want. + evidence.baseline = this.foldRaw(cwd, evidence.baseline, evidence.deferredRaw) + evidence.deferredRaw = [] + evidence.revision += 1 + } const replay = evidence.replay // This is an input cache, not an output deep comparison. Replaying 500 // records to discover an identical result still blocks input on the renderer @@ -421,6 +473,7 @@ export class LiveWorktreeReconciler { projection: WorktreeRuntimeProjection }): WorktreeReconciliationDebug { const cached = this.cache.get(params.cwd) + const evidence = this.evidenceBySession.get(params.sessionId) const age = cached ? this.now() - cached.refreshedAt : null const cacheState = !cached ? 'missing' @@ -434,8 +487,9 @@ export class LiveWorktreeReconciler { return { cacheState, catalogCount: cached?.worktrees.length ?? 0, - recentEvidenceCount: - this.evidenceBySession.get(params.sessionId)?.recentRaw.length ?? 0, + recentEvidenceCount: evidence?.recentRaw.length ?? 0, + deferredEvidenceCount: evidence?.deferredRaw.length ?? 0, + droppedBeforeCatalog: evidence?.droppedBeforeCatalog ?? 0, activeSource: params.projection.workActivity?.active?.source ?? null, primarySource: params.projection.workActivity?.primary?.source ?? null, projectedPath: params.projection.workContext?.worktreePath ?? null,