Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Worktree reconciliation performance

Status: implemented; PR/CI review pending. Issue: #806. Base: origin/main 5d641845.

## Scope and invariants

Preserve canonical projection identity on no-op and avoid replaying retained
evidence when its inputs are unchanged. Do not alter lifecycle/readiness,
provider evidence precedence, or stale-hydration contribution reversal.
Keep catalog-ready notification even for unchanged catalogs: initial history
can replace a projection between refreshes, and that external replacement must
still trigger correction. Cache content identity, not freshness timestamps.

## Implementation sequence

1. Add reference/no-replay regression tests and a sanitized-fixture benchmark;
run against the unchanged implementation to record failures/baseline.
2. Make canonicalization reference-stable and cache replay by cwd, baseline,
retained-evidence generation and catalog content identity. Keep the existing
bounded replay algorithm for invalidations rather than change attribution.
3. Cover irrelevant/empty batches, identical successful refresh, changed
catalogs, hydration replacement, evidence eviction and session teardown.
4. Run focused tests, typing/lint checks and before/after benchmark. Review the
diff, update issue evidence, open a complete PR and obtain current CI.
Do not merge without explicit user confirmation.

## Later independent increments

- #763: per-session React subscriptions and stable action/context boundaries.
- #762: renderer screen interest with fresh view-switch snapshots; backend
parsing and lifecycle/readiness remain ungated.
- #767: only remaining verified diagnostic costs. Disabled memory sampling
already exits early. Main worktree-index no-op persistence is functional
metadata, not something to disable as a diagnostic.
- A6 owns the separate #802–805 subagent/remote findings; do not overlap.

## Verification evidence

Prior isolated audit: 1000/1000 value-identical canonical projections changed
identity; retained 500-record replay cost about 2 ms per unrelated batch. These
are not app-wide typing latency claims. Record reproducible measurements below
before proposing completion.

The standalone `scripts/benchmark-worktree-reconciliation.mts` measured 200
irrelevant batches per window size on this Mac (same process setup per run):

| Retained records | Before median / p95 ms | After median / p95 ms | Identity changes before → after |
| --- | --- | --- | --- |
| 0 | 0.001959 / 0.008416 | 0.000667 / 0.002292 | 200 → 0 |
| 100 | 0.312375 / 0.442167 | 0.000458 / 0.001375 | 200 → 0 |
| 500 | 2.118166 / 2.476792 | 0.000333 / 0.000375 | 200 → 0 |

The first regression run on unchanged production code failed four of five
new tests at identity assertions; the stale-hydration control passed. With the
fix, the focused shared/renderer fixture suite passed 20 tests before adding
explicit cwd-change and empty-catalog invalidation controls. No wall-clock
threshold is enforced in tests; a provider-record getter proves no replay.

Final local verification: full typecheck, test contract and five-fixture privacy
verification passed; 24 focused unit tests and two worktree-bar renderer tests
passed. Full unit suite: 2138 passed, one failed because the existing image
corpus check references a removed private session. The identical failure was
reproduced on unchanged main (tracked by #684/#669/#641); no test was skipped or
weakened. All seven new regression tests pass. Public CI remains the gate.

## Independent review resolution (cc cccae6e1)

Both orchestrated reviewers approving, no correctness defects in the invalidation
keys, identity retention, or accounting-reversal path. Adopted: renamed
`LiveWorktreeReconciler.performance.test.ts` to
`LiveWorktreeReconciler.invalidation.test.ts` — it asserts identity/invalidation
contracts, never timing, and "performance" implied a benchmark tier this repo
does not have. Recorded as boundaries rather than defects: `sameCatalog`
compares `head`, so any commit in any checkout invalidates catalog identity and
replays the bounded evidence window for that cwd at the next refresh (correct,
but worth knowing); the replay cache freezes fold-time timestamps for quiet
sessions, which no renderer reads. A pre-existing, unrelated evidence-loss edge —
>500 relevant records buffered before the first successful Git IPC reply get
folded against an empty catalog and dropped — was filed separately, not fixed
in this PR.
39 changes: 39 additions & 0 deletions scripts/benchmark-worktree-reconciliation.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Run from the repository root:
// TSX_TSCONFIG_PATH=tsconfig.web.json node --import tsx scripts/benchmark-worktree-reconciliation.mts
// WHY standalone, not a timing assertion in Vitest: CI host load changes the
// timings; the regression suite asserts identities and actual retained-record
// reads deterministically. This reports isolated costs, not typing latency.
import { readFileSync } from 'node:fs'
import { LiveWorktreeReconciler } from '../src/renderer/src/workspace/work-context/LiveWorktreeReconciler.js'

const fixtureRoot = 'testing/fixtures/worktree-live-attribution/'
const fixture = JSON.parse(readFileSync(`${fixtureRoot}codex-0151-worktree-window.json`, 'utf8'))
const catalog = JSON.parse(readFileSync(`${fixtureRoot}git-worktree-identities.json`, 'utf8')).worktrees
const original = fixture.records.find((record: { payload?: { item?: { type?: string } } }) =>
record.payload?.item?.type === 'CommandExecution')

for (const retained of [0, 100, 500]) {
const reconciler = new LiveWorktreeReconciler({
loadWorktrees: async () => ({ ok: true, worktrees: catalog }),
onCatalogReady: () => undefined,
})
await reconciler.refresh(fixture.git.main.path)
let projection = reconciler.observe('bench', fixture.git.main.path,
Array.from({ length: retained }, (_, index) => ({ entry: {
...original, timestamp: new Date(1700000000000 + index * 1000).toISOString(),
} })), { workActivity: null, workContext: null })
const samples: number[] = []
let identityChanges = 0
for (let index = 0; index < 200; index += 1) {
const started = performance.now()
const next = reconciler.observe('bench', fixture.git.main.path,
[{ entry: { type: 'irrelevant' } }], projection)
samples.push(performance.now() - started)
if (next.workActivity !== projection.workActivity || next.workContext !== projection.workContext) identityChanges += 1
projection = next
}
samples.sort((a, b) => a - b)
console.log(JSON.stringify({ retained, batches: samples.length, identityChanges,
medianMs: samples[100], p95Ms: samples[190] }))
reconciler.dispose()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { describe, expect, it, vi } from 'vitest'
import { canonicalizeWorktreeActivity, ingestWorktreeRawEvent } from '@shared/work-context/tracker'
import { LiveWorktreeReconciler, type WorktreeRuntimeProjection } from './LiveWorktreeReconciler'

const main = { path: '/repo', branch: 'main', head: null, detached: false }
const linked = { path: '/repo/linked', branch: 'feature', head: null, detached: false }
const write = {
type: 'assistant', timestamp: '2026-09-01T00:00:00Z', cwd: '/repo',
message: { content: [{ type: 'tool_use', name: 'Write', input: { file_path: '/repo/linked/file.ts' } }] },
}
const empty = (): WorktreeRuntimeProjection => ({ workActivity: null, workContext: null })

describe('worktree reconciliation invalidation boundaries', () => {
it('keeps canonical state and context identities unless Git changes their values', () => {
const state = ingestWorktreeRawEvent({ state: null, raw: write, sessionCwd: '/repo', worktrees: [main, linked] })
const canonical = canonicalizeWorktreeActivity(state, [main, linked])
expect(canonicalizeWorktreeActivity(canonical, [{ ...main }, { ...linked }])).toBe(canonical)
const detached = canonicalizeWorktreeActivity(canonical, [main, { ...linked, branch: null, detached: true }])
expect(detached).not.toBe(canonical)
expect(detached.primary?.branch).toBeNull()
expect(detached.timeline).toBe(canonical.timeline)
})

it('keeps quiet-session projection identity across identical successful refreshes', async () => {
const reconciler = new LiveWorktreeReconciler({
loadWorktrees: async () => ({ ok: true, worktrees: [{ ...main }] }),
onCatalogReady: () => undefined, cacheTtlMs: 0,
})
await reconciler.refresh('/repo')
const projection = reconciler.project({ sessionId: 'quiet', cwd: '/repo', projection: empty() })
await reconciler.refresh('/repo')
expect(reconciler.project({ sessionId: 'quiet', cwd: '/repo', projection })).toBe(projection)
})

it('does not re-read retained records for empty/irrelevant batches or identical catalogs', async () => {
// A getter counts actual provider-record reads without spying on private
// cache fields. A value-equality assertion alone would miss wasteful replay
// that eventually compares equal; wall-clock assertions would be flaky.
const readMessage = vi.fn(() => write.message)
const raw = { ...write, get message() { return readMessage() } }
const onCatalogReady = vi.fn()
const reconciler = new LiveWorktreeReconciler({
loadWorktrees: async () => ({ ok: true, worktrees: [{ ...main }, { ...linked }] }),
onCatalogReady, cacheTtlMs: 0,
})
await reconciler.refresh('/repo')
const projection = reconciler.observe('busy', '/repo', [{ entry: raw }], empty())
expect(readMessage).toHaveBeenCalled()
readMessage.mockClear()
expect(reconciler.observe('busy', '/repo', [], projection)).toBe(projection)
expect(reconciler.observe('busy', '/repo', [{ entry: { type: 'irrelevant' } }], projection)).toBe(projection)
await reconciler.refresh('/repo')
expect(reconciler.project({ sessionId: 'busy', cwd: '/repo', projection })).toBe(projection)
expect(readMessage).not.toHaveBeenCalled()
// Must still notify: history may have replaced the caller's projection.
expect(onCatalogReady).toHaveBeenCalledTimes(2)
})

it('corrects external stale hydration even when the catalog did not change', async () => {
let projection = empty()
const reconciler = new LiveWorktreeReconciler({
loadWorktrees: async () => ({ ok: true, worktrees: [{ ...main }, { ...linked }] }),
cacheTtlMs: 0,
onCatalogReady: cwd => { projection = reconciler.project({ sessionId: 'race', cwd, projection }) },
})
await reconciler.refresh('/repo')
projection = reconciler.observe('race', '/repo', [{ entry: write }], projection)
const expectedTouches = projection.workActivity?.touched
const stale = ingestWorktreeRawEvent({ state: null, raw: write, sessionCwd: '/repo', worktrees: [main] })
projection = { workActivity: stale, workContext: stale.primary }
await reconciler.refresh('/repo')
expect(projection.workContext?.worktreePath).toBe('/repo/linked')
expect(projection.workActivity?.touched['/repo/linked'].score).toBe(expectedTouches?.['/repo/linked'].score)
expect(projection.workActivity?.touched['/repo'].writeCount ?? 0).toBe(0)
})

it('invalidates on cwd change even when both directories share the same catalog', async () => {
const worktrees = [main, linked]
const reconciler = new LiveWorktreeReconciler({
loadWorktrees: async () => ({ ok: true, worktrees }),
onCatalogReady: () => undefined,
})
await reconciler.refresh('/repo')
await reconciler.refresh('/repo/linked')
const first = reconciler.observe('moved', '/repo', [], empty())
expect(first.workContext?.worktreePath).toBe('/repo')
const moved = reconciler.observe('moved', '/repo/linked', [], first)
expect(moved.workContext?.worktreePath).toBe('/repo/linked')
expect(reconciler.observe('moved', '/repo/linked', [], moved)).toBe(moved)
})

it('distinguishes a successfully loaded empty catalog from a pending catalog', async () => {
const reconciler = new LiveWorktreeReconciler({
loadWorktrees: async () => ({ ok: true, worktrees: [] }),
onCatalogReady: () => undefined,
})
let projection = reconciler.observe('empty-catalog', '/repo', [], empty())
expect(projection.workActivity).toBeNull()
await reconciler.refresh('/repo')
projection = reconciler.project({ sessionId: 'empty-catalog', cwd: '/repo', projection })
expect(projection.workActivity).not.toBeNull()
expect(reconciler.observe('empty-catalog', '/repo', [], projection)).toBe(projection)
})

it('invalidates replay on catalog expansion/removal, branch changes and evidence eviction', async () => {
let worktrees = [main]
const reconciler = new LiveWorktreeReconciler({
loadWorktrees: async () => ({ ok: true, worktrees }),
onCatalogReady: () => undefined, cacheTtlMs: 0, recentRawLimit: 1,
})
await reconciler.refresh('/repo')
let projection = reconciler.observe('change', '/repo', [{ entry: write }], empty())
expect(projection.workContext?.worktreePath).toBe('/repo')
worktrees = [main, linked]
await reconciler.refresh('/repo')
projection = reconciler.project({ sessionId: 'change', cwd: '/repo', projection })
expect(projection.workContext?.worktreePath).toBe('/repo/linked')
worktrees = [main, { ...linked, branch: 'renamed' }]
await reconciler.refresh('/repo')
projection = reconciler.project({ sessionId: 'change', cwd: '/repo', projection })
expect(projection.workContext?.branch).toBe('renamed')
worktrees = [main]
await reconciler.refresh('/repo')
projection = reconciler.project({ sessionId: 'change', cwd: '/repo', projection })
expect(projection.workContext?.worktreePath).toBe('/repo')
const another = { ...write, timestamp: '2026-09-01T00:01:00Z' }
projection = reconciler.observe('change', '/repo', [{ entry: another }], projection)
expect(projection.workActivity?.touched['/repo'].writeCount).toBe(2)
expect(reconciler.observe('change', '/repo', [], projection)).toBe(projection)
reconciler.forgetSession('change')
const replacement = reconciler.observe('change', '/repo', [], empty())
expect(replacement.workActivity?.touched['/repo']?.writeCount ?? 0).toBe(0)
})
})
Loading