From 49ffbe516928072758f41879e01604b5f926e1ec Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:32:58 -0700 Subject: [PATCH] perf(renderer): keep worktree lineage map identity on no-op refresh A host-scoped lineage refresh always spread IPC clones into new maps, so WorktreeList Object.is subscriptions missed every time even when capture, taskId, and coordinatorHandle were unchanged. Reuse structurally equal records, return the previous maps when nothing moved, and skip set() when both maps are still current so a no-op refresh is actually a no-op. Co-authored-by: Orca --- .../hooks/direct-ssh-host-hydration.test.ts | 103 +++++++++- .../src/hooks/direct-ssh-host-hydration.ts | 63 +++--- .../store/slices/repo-identity-reconcile.ts | 32 +++ .../src/store/slices/worktrees.test.ts | 194 ++++++++++++++++++ src/renderer/src/store/slices/worktrees.ts | 56 +++-- 5 files changed, 399 insertions(+), 49 deletions(-) diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts b/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts index 71d29e256e6..3f8a00813eb 100644 --- a/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts +++ b/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { HostLineageSnapshot } from '../../../shared/host-lineage-contract' import type { HostRepoCatalogSnapshot } from '../../../shared/host-repo-catalog-contract' import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import type { Repo } from '../../../shared/types' +import type { Repo, WorktreeLineage, WorkspaceLineage } from '../../../shared/types' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' import type { AppState } from '../store/types' import { createDirectSshHostHydration } from './direct-ssh-host-hydration' @@ -40,6 +40,34 @@ function state(overrides: Record = {}): AppState { } as unknown as AppState } +function productionLineage(worktreeId: string, parentWorktreeId: string): WorktreeLineage { + return { + worktreeId, + worktreeInstanceId: `${worktreeId}-instance`, + parentWorktreeId, + parentWorktreeInstanceId: `${parentWorktreeId}-instance`, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'explicit' }, + taskId: 'task-1', + coordinatorHandle: 'coord-1', + createdAt: 1 + } +} + +function productionWorkspaceLineage(childId: string, parentId: string): WorkspaceLineage { + return { + childWorkspaceKey: worktreeWorkspaceKey(childId), + childInstanceId: `${childId}-instance`, + parentWorkspaceKey: worktreeWorkspaceKey(parentId), + parentInstanceId: `${parentId}-instance`, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'explicit' }, + taskId: 'task-1', + coordinatorHandle: 'coord-1', + createdAt: 1 + } +} + function hostSnapshot( owner: DirectSshAuthority, repos: ReturnType[] @@ -341,6 +369,79 @@ describe('createDirectSshHostHydration', () => { }) }) + it('keeps lineage map identity for a cloned no-op host snapshot', async () => { + const owner = authority() + const hostLineage = productionLineage('a::/work', 'a::/parent') + const foreignLineage = productionLineage('b::/work', 'b::/parent') + const hostWorkspace = productionWorkspaceLineage('a::/work', 'a::/parent') + const foreignWorkspace = productionWorkspaceLineage('b::/work', 'b::/parent') + const store = createStore(() => + state({ + repos: [repo('a', 'target-a'), repo('b', 'target-b')], + worktreesByRepo: { + a: [{ id: 'a::/work', repoId: 'a', hostId: 'ssh:target-a' }], + b: [{ id: 'b::/work', repoId: 'b', hostId: 'ssh:target-b' }] + }, + worktreeLineageById: { + 'a::/work': hostLineage, + 'b::/work': foreignLineage + }, + workspaceLineageByChildKey: { + [worktreeWorkspaceKey('a::/work')]: hostWorkspace, + [worktreeWorkspaceKey('b::/work')]: foreignWorkspace + } + }) + ) + const snapshot: HostLineageSnapshot = { + authoritative: true, + authority: { + kind: 'direct-ssh', + executionHostId: 'ssh:target-a', + ...owner + }, + worktreeLineageById: { + 'a::/work': hostLineage + }, + workspaceLineageByChildKey: { + [worktreeWorkspaceKey('a::/work')]: hostWorkspace + } + } + let publications = 0 + store.subscribe(() => { + publications += 1 + }) + const hydration = createDirectSshHostHydration({ + store, + listRepos: vi.fn(), + listLineage: vi.fn(async () => structuredClone(snapshot)), + isCurrentAuthority: () => true + }) + const beforeLineage = store.getState().worktreeLineageById + const beforeWorkspace = store.getState().workspaceLineageByChildKey + + await expect( + hydration.readHostScopedLineage({ + ...owner, + catalogRevision: 0, + repoRefs: [{ repoId: 'a', executionHostId: 'ssh:target-a' }], + authorityRequirement: 'required', + reason: 'reconnect' + }) + ).resolves.toBe('complete') + + expect(store.getState().worktreeLineageById).toBe(beforeLineage) + expect(store.getState().workspaceLineageByChildKey).toBe(beforeWorkspace) + expect(store.getState().worktreeLineageById['a::/work']).toBe(hostLineage) + expect(store.getState().worktreeLineageById['b::/work']).toBe(foreignLineage) + expect(store.getState().workspaceLineageByChildKey[worktreeWorkspaceKey('a::/work')]).toBe( + hostWorkspace + ) + expect(store.getState().workspaceLineageByChildKey[worktreeWorkspaceKey('b::/work')]).toBe( + foreignWorkspace + ) + expect(publications).toBe(0) + }) + it('rejects lineage captured before a newer same-authority catalog revision', async () => { const owner = authority() const store = createStore(() => diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration.ts b/src/renderer/src/hooks/direct-ssh-host-hydration.ts index d3a5a2bc366..0ce26ac7476 100644 --- a/src/renderer/src/hooks/direct-ssh-host-hydration.ts +++ b/src/renderer/src/hooks/direct-ssh-host-hydration.ts @@ -5,6 +5,7 @@ import type { HostRepoCatalogSnapshot } from '../../../shared/host-repo-catalog- import type { DirectSshAuthority } from '../../../shared/ssh-types' import { isWorkspaceKey } from '../../../shared/workspace-scope' import type { AppState } from '../store/types' +import { reuseEqualRecordMap } from '../store/slices/repo-identity-reconcile' import type { DirectSshLineageOutcome, DirectSshPreparationInput, @@ -78,37 +79,43 @@ function mergeExactHostLineage( catalogRevision: number ): AppState { const scope = directSshHostHydrationScope(state, authority, catalogRevision) - const worktreeLineageById = Object.fromEntries( - Object.entries(state.worktreeLineageById).filter( - ([worktreeId]) => !scope.gitWorktreeIds.has(worktreeId) - ) - ) - const workspaceLineageByChildKey = Object.fromEntries( - Object.entries(state.workspaceLineageByChildKey).filter( - ([childKey]) => !isWorkspaceKey(childKey) || !scope.lineageWorkspaceKeys.has(childKey) - ) - ) - const incomingWorktreeLineage = Object.fromEntries( - Object.entries(snapshot.worktreeLineageById).filter(([worktreeId]) => - scope.gitWorktreeIds.has(worktreeId) - ) - ) - const incomingWorkspaceLineage = Object.fromEntries( - Object.entries(snapshot.workspaceLineageByChildKey).filter( - ([workspaceKey]) => - isWorkspaceKey(workspaceKey) && scope.lineageWorkspaceKeys.has(workspaceKey) - ) + const nextWorktreeLineageById: AppState['worktreeLineageById'] = {} + for (const [worktreeId, existing] of Object.entries(state.worktreeLineageById)) { + if (!scope.gitWorktreeIds.has(worktreeId)) { + nextWorktreeLineageById[worktreeId] = existing + } + } + for (const [worktreeId, incoming] of Object.entries(snapshot.worktreeLineageById)) { + if (scope.gitWorktreeIds.has(worktreeId)) { + nextWorktreeLineageById[worktreeId] = incoming + } + } + const nextWorkspaceLineageByChildKey: AppState['workspaceLineageByChildKey'] = {} + for (const [childKey, existing] of Object.entries(state.workspaceLineageByChildKey)) { + if (!isWorkspaceKey(childKey) || !scope.lineageWorkspaceKeys.has(childKey)) { + nextWorkspaceLineageByChildKey[childKey] = existing + } + } + for (const [workspaceKey, incoming] of Object.entries(snapshot.workspaceLineageByChildKey)) { + if (isWorkspaceKey(workspaceKey) && scope.lineageWorkspaceKeys.has(workspaceKey)) { + nextWorkspaceLineageByChildKey[workspaceKey] = incoming + } + } + const worktreeLineageById = reuseEqualRecordMap(state.worktreeLineageById, nextWorktreeLineageById) + const workspaceLineageByChildKey = reuseEqualRecordMap( + state.workspaceLineageByChildKey, + nextWorkspaceLineageByChildKey ) + if ( + worktreeLineageById === state.worktreeLineageById && + workspaceLineageByChildKey === state.workspaceLineageByChildKey + ) { + return state + } return { ...state, - worktreeLineageById: { - ...worktreeLineageById, - ...incomingWorktreeLineage - }, - workspaceLineageByChildKey: { - ...workspaceLineageByChildKey, - ...incomingWorkspaceLineage - } + worktreeLineageById, + workspaceLineageByChildKey } } diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.ts b/src/renderer/src/store/slices/repo-identity-reconcile.ts index f61107c2673..4809b23adda 100644 --- a/src/renderer/src/store/slices/repo-identity-reconcile.ts +++ b/src/renderer/src/store/slices/repo-identity-reconcile.ts @@ -79,3 +79,35 @@ export function reconcileFetchedRepos( ): readonly Repo[] { return reconcileCatalogRows(previous, next, getRepoHostIdentity) } + +/** + * Reuses equal record values from `previous` — and the whole map when nothing + * changed — so a cloned no-op refresh leaves Object.is subscribers untouched. + */ +export function reuseEqualRecordMap( + previous: Record, + next: Record +): Record { + const nextKeys = Object.keys(next) + const previousKeys = Object.keys(previous) + let identical = nextKeys.length === previousKeys.length + const reconciled: Record = {} + for (const key of nextKeys) { + const existing = Object.hasOwn(previous, key) ? previous[key] : undefined + if (existing !== undefined && areValuesEqual(existing, next[key])) { + reconciled[key] = existing + continue + } + identical = false + reconciled[key] = next[key] + } + if (identical) { + for (const key of previousKeys) { + if (!Object.hasOwn(next, key)) { + identical = false + break + } + } + } + return identical ? previous : reconciled +} diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 5ebe9a06116..6077204ba51 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -3187,6 +3187,71 @@ describe('fetchWorktrees', () => { expect(store.getState().sortEpoch).toBe(7) }) + it('keeps remote lineage map identity when a cloned payload is unchanged', async () => { + const store = createTestStore() + const worktree = makeWorktree({ + id: 'repo1::/remote/wt1', + repoId: 'repo1', + path: '/remote/wt1', + branch: 'refs/heads/remote', + hostId: 'runtime:env-1' + }) + const lineage = makeLineage({ + worktreeId: worktree.id, + parentWorktreeId: 'repo1::/remote/parent', + capture: { source: 'orchestration-context', confidence: 'explicit' }, + taskId: 'task-42', + coordinatorHandle: 'coord-1' + }) + const workspaceLineage = makeWorkspaceLineage({ + childWorkspaceKey: worktreeWorkspaceKey(worktree.id), + parentWorkspaceKey: worktreeWorkspaceKey(lineage.parentWorktreeId), + childInstanceId: lineage.worktreeInstanceId, + parentInstanceId: lineage.parentWorktreeInstanceId, + origin: lineage.origin, + capture: lineage.capture, + taskId: lineage.taskId, + coordinatorHandle: lineage.coordinatorHandle, + createdAt: lineage.createdAt + }) + const detected = makeDetectedResult('repo1', [worktree]) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + worktreesByRepo: { repo1: [worktree] }, + detectedWorktreesByRepo: { repo1: detected }, + worktreeLineageById: { [lineage.worktreeId]: lineage }, + workspaceLineageByChildKey: { + [workspaceLineage.childWorkspaceKey]: workspaceLineage + }, + sortEpoch: 7 + } as Partial) + runtimeEnvironmentCall.mockImplementation(({ method }: RuntimeEnvironmentCallRequest) => { + const result = + method === 'worktree.lineageList' + ? structuredClone({ + lineage: { [lineage.worktreeId]: lineage }, + workspaceLineage: { [workspaceLineage.childWorkspaceKey]: workspaceLineage } + }) + : structuredClone(detected) + return Promise.resolve({ + id: 'rpc-1', + ok: true, + result, + _meta: { runtimeId: 'runtime-remote' } + }) + }) + const before = store.getState() + + await store.getState().fetchWorktrees('repo1') + + expect(store.getState().worktreeLineageById).toBe(before.worktreeLineageById) + expect(store.getState().workspaceLineageByChildKey).toBe(before.workspaceLineageByChildKey) + expect(store.getState().worktreeLineageById[lineage.worktreeId]).toBe(lineage) + expect(store.getState().workspaceLineageByChildKey[workspaceLineage.childWorkspaceKey]).toBe( + workspaceLineage + ) + }) + it('defers remote lineage when a caller owns the final host refresh', async () => { const store = createTestStore() const worktree = makeWorktree({ @@ -3319,6 +3384,135 @@ describe('worktree lineage state', () => { }) }) + it('keeps lineage map and entry identity across a cloned no-op refresh', async () => { + const lineage = makeLineage({ + capture: { source: 'orchestration-context', confidence: 'explicit' }, + taskId: 'task-42', + coordinatorHandle: 'coord-1' + }) + const workspaceLineage = makeWorkspaceLineage({ + childWorkspaceKey: worktreeWorkspaceKey(lineage.worktreeId), + parentWorkspaceKey: worktreeWorkspaceKey(lineage.parentWorktreeId), + childInstanceId: lineage.worktreeInstanceId, + parentInstanceId: lineage.parentWorktreeInstanceId, + origin: lineage.origin, + capture: lineage.capture, + taskId: lineage.taskId, + coordinatorHandle: lineage.coordinatorHandle, + createdAt: lineage.createdAt + }) + const store = createLocalLineageTestStore(lineage) + const payload = { + lineage: { [lineage.worktreeId]: lineage }, + workspaceLineage: { [workspaceLineage.childWorkspaceKey]: workspaceLineage } + } + mockApi.worktrees.listLineage.mockImplementation(async () => structuredClone(payload)) + + await store.getState().fetchWorktreeLineage() + const afterFirst = store.getState() + const lineageById = afterFirst.worktreeLineageById + const workspaceByKey = afterFirst.workspaceLineageByChildKey + const lineageEntry = lineageById[lineage.worktreeId] + const workspaceEntry = workspaceByKey[workspaceLineage.childWorkspaceKey] + expect(lineageEntry).toEqual(lineage) + expect(lineageEntry).not.toBe(lineage) + const subscriber = vi.fn() + const unsubscribe = store.subscribe(subscriber) + + await store.getState().fetchWorktreeLineage() + unsubscribe() + + expect(store.getState().worktreeLineageById).toBe(lineageById) + expect(store.getState().workspaceLineageByChildKey).toBe(workspaceByKey) + expect(store.getState().worktreeLineageById[lineage.worktreeId]).toBe(lineageEntry) + expect(store.getState().workspaceLineageByChildKey[workspaceLineage.childWorkspaceKey]).toBe( + workspaceEntry + ) + expect(subscriber).not.toHaveBeenCalled() + expect(mockApi.worktrees.listLineage).toHaveBeenCalledTimes(2) + }) + + it('preserves other-host lineage row identity across a cloned same-host refresh', async () => { + const store = createTestStore() + const localWorktree = makeWorktree({ + id: 'repo1::/local/child', + repoId: 'repo1', + hostId: LOCAL_EXECUTION_HOST_ID + }) + const sshWorktree = makeWorktree({ + id: 'repo2::/ssh/child', + repoId: 'repo2', + hostId: 'ssh:ssh-1' + }) + const localLineage = makeLineage({ + worktreeId: localWorktree.id, + parentWorktreeId: 'repo1::/local/parent', + capture: { source: 'orchestration-context', confidence: 'explicit' }, + taskId: 'task-local', + coordinatorHandle: 'coord-local' + }) + const sshLineage = makeLineage({ + worktreeId: sshWorktree.id, + parentWorktreeId: 'repo2::/ssh/parent', + capture: { source: 'cwd-context', confidence: 'inferred' }, + taskId: 'task-ssh', + coordinatorHandle: 'coord-ssh' + }) + const localWorkspace = makeWorkspaceLineage({ + childWorkspaceKey: worktreeWorkspaceKey(localWorktree.id), + parentWorkspaceKey: worktreeWorkspaceKey(localLineage.parentWorktreeId), + childInstanceId: localLineage.worktreeInstanceId, + parentInstanceId: localLineage.parentWorktreeInstanceId, + origin: localLineage.origin, + capture: localLineage.capture, + taskId: localLineage.taskId, + coordinatorHandle: localLineage.coordinatorHandle + }) + const sshWorkspace = makeWorkspaceLineage({ + childWorkspaceKey: worktreeWorkspaceKey(sshWorktree.id), + parentWorkspaceKey: worktreeWorkspaceKey(sshLineage.parentWorktreeId), + childInstanceId: sshLineage.worktreeInstanceId, + parentInstanceId: sshLineage.parentWorktreeInstanceId, + origin: sshLineage.origin, + capture: sshLineage.capture, + taskId: sshLineage.taskId, + coordinatorHandle: sshLineage.coordinatorHandle + }) + store.setState({ + worktreesByRepo: { + repo1: [localWorktree], + repo2: [sshWorktree] + }, + worktreeLineageById: { + [localWorktree.id]: localLineage, + [sshWorktree.id]: sshLineage + }, + workspaceLineageByChildKey: { + [localWorkspace.childWorkspaceKey]: localWorkspace, + [sshWorkspace.childWorkspaceKey]: sshWorkspace + } + } as Partial) + const payload = { + lineage: { [localWorktree.id]: localLineage }, + workspaceLineage: { [localWorkspace.childWorkspaceKey]: localWorkspace } + } + mockApi.worktrees.listLineage.mockImplementation(async () => structuredClone(payload)) + const before = store.getState() + + await store.getState().fetchWorktreeLineage() + + expect(store.getState().worktreeLineageById).toBe(before.worktreeLineageById) + expect(store.getState().workspaceLineageByChildKey).toBe(before.workspaceLineageByChildKey) + expect(store.getState().worktreeLineageById[localWorktree.id]).toBe(localLineage) + expect(store.getState().worktreeLineageById[sshWorktree.id]).toBe(sshLineage) + expect(store.getState().workspaceLineageByChildKey[localWorkspace.childWorkspaceKey]).toBe( + localWorkspace + ) + expect(store.getState().workspaceLineageByChildKey[sshWorkspace.childWorkspaceKey]).toBe( + sshWorkspace + ) + }) + it('clears workspace lineage on successful old-shape lineage refresh', async () => { const store = createTestStore() const lineage = makeLineage() diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 9c1fc6691ec..01e76aacf93 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -133,6 +133,7 @@ import type { import type { DirectSshAuthority } from '../../../../shared/ssh-types' import { findIndexedWorktreeOwnerForHost } from '@/lib/worktree-runtime-owner-index' import { catalogRowsEqual, reuseEqualCatalogRows } from './worktree-catalog-reconciliation' +import { reuseEqualRecordMap } from './repo-identity-reconcile' export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers' // Why: old runtime servers only have `worktree.list`; preserve the large-list UI hydration parity used before `worktree.detectedList` existed. @@ -1570,21 +1571,38 @@ function applyWorktreeLineageUpdate( }) } +function applyHostLineageRefresh( + set: Parameters>[0], + hostId: ExecutionHostId, + lineage: { + worktreeLineageById: Record + workspaceLineageByChildKey: Record + } +): void { + set((s) => { + const worktreeLineageById = mergeLineageForHost(s, hostId, lineage.worktreeLineageById) + const workspaceLineageByChildKey = mergeWorkspaceLineageForHost( + s, + hostId, + lineage.workspaceLineageByChildKey + ) + if ( + worktreeLineageById === s.worktreeLineageById && + workspaceLineageByChildKey === s.workspaceLineageByChildKey + ) { + return s + } + return { worktreeLineageById, workspaceLineageByChildKey } + }) +} + async function refreshWorktreeLineageForSettings( settings: AppState['settings'], set: Parameters>[0], options: BackgroundRuntimeRefreshOptions = {} ): Promise { const lineage = await listWorktreeLineageForRuntime(settings, options) - const hostId = getSettingsFocusedExecutionHostId(settings) - set((s) => ({ - worktreeLineageById: mergeLineageForHost(s, hostId, lineage.worktreeLineageById), - workspaceLineageByChildKey: mergeWorkspaceLineageForHost( - s, - hostId, - lineage.workspaceLineageByChildKey - ) - })) + applyHostLineageRefresh(set, getSettingsFocusedExecutionHostId(settings), lineage) } async function refreshRemoteWorktreeLineageBestEffort( @@ -1598,15 +1616,7 @@ async function refreshRemoteWorktreeLineageBestEffort( const lineage = await listWorktreeLineageForRuntime(settings, { reuseRecentCompatibilityFailure: true }) - const hostId = getSettingsFocusedExecutionHostId(settings) - set((s) => ({ - worktreeLineageById: mergeLineageForHost(s, hostId, lineage.worktreeLineageById), - workspaceLineageByChildKey: mergeWorkspaceLineageForHost( - s, - hostId, - lineage.workspaceLineageByChildKey - ) - })) + applyHostLineageRefresh(set, getSettingsFocusedExecutionHostId(settings), lineage) } catch (err) { // Why: lineage is supplemental, so a remote timeout here must not discard a successful worktree refresh. console.error('Failed to fetch worktree lineage:', err) @@ -1646,7 +1656,10 @@ function mergeLineageForHost( next[worktreeId] = existing } } - return { ...next, ...lineage } + for (const [worktreeId, incoming] of Object.entries(lineage)) { + next[worktreeId] = incoming + } + return reuseEqualRecordMap(state.worktreeLineageById, next) } function mergeWorkspaceLineageForHost( @@ -1671,7 +1684,10 @@ function mergeWorkspaceLineageForHost( next[childKey] = existing } } - return { ...next, ...lineage } + for (const [childKey, incoming] of Object.entries(lineage)) { + next[childKey] = incoming + } + return reuseEqualRecordMap(state.workspaceLineageByChildKey, next) } async function persistWorktreeMeta(