diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index d88fb6699aa..01ba9c7a963 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -94,6 +94,7 @@ import { import { useWorkspaceSections } from '../../../src/worktree/use-workspace-sections' import { getMobileWorkspaceLineageGroupKey } from '../../../src/worktree/mobile-workspace-lineage' import { areWorktreeListsEqual } from '../../../src/worktree/worktree-list-snapshot' +import { WorktreeCatalogSnapshotClient } from '../../../src/worktree/worktree-catalog-snapshot-client' import { repoColor } from '../../../src/worktree/repo-color' import { WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS, @@ -141,6 +142,7 @@ export function HostScreen({ const lastConnectedAt = useLastConnectedAt(hostId) const clientRef = useRef(null) const fetchWorktreesInFlightRef = useRef(false) + const worktreeCatalog = useMemo(() => new WorktreeCatalogSnapshotClient(), []) const fetchRepoMetadataInFlightRef = useRef(new WeakSet()) const fetchRepoMetadataPendingRef = useRef(new WeakSet()) const repoMetadataFetchedAtRef = useRef(0) @@ -423,27 +425,28 @@ export function HostScreen({ const requestHostId = hostId try { - // Why: worktree.ps silently truncates at 200; use a high cap so large hosts don't drop workspaces. - const response = await requestClient.sendRequest('worktree.ps', { limit: 10000 }) + const pendingCatalog = await worktreeCatalog.fetch(requestClient, requestHostId) if (clientRef.current !== requestClient || hostId !== requestHostId) { return } if (!options.allowDuringModal && newWorktreeModalVisibleRef.current) { return } - if (response.ok) { - const result = (response as RpcSuccess).result as { worktrees: Worktree[] } - // Why: reuse the existing array on identical snapshots to keep SectionList/sort rebuilds off the tap path. - setWorktrees((current) => - areWorktreeListsEqual(current, result.worktrees) ? current : result.worktrees - ) - setLastKnownWorktrees((current) => - areWorktreeListsEqual(current, result.worktrees) ? current : result.worktrees - ) - setWorktreesLoaded(true) - // Why (#8498): overwrite the home-written cache with the confirmed snapshot so a reconnect/remount can't serve a stale list. - if (hostId) { - setCachedWorktrees(hostId, result.worktrees) + const result = worktreeCatalog.admit(pendingCatalog) + if (result) { + if (result.changed) { + // Why: reuse the existing array on identical snapshots to keep SectionList/sort rebuilds off the tap path. + setWorktrees((current) => + areWorktreeListsEqual(current, result.worktrees) ? current : result.worktrees + ) + setLastKnownWorktrees((current) => + areWorktreeListsEqual(current, result.worktrees) ? current : result.worktrees + ) + setWorktreesLoaded(true) + // Why (#8498): overwrite the home-written cache with the confirmed snapshot so a reconnect/remount can't serve a stale list. + if (hostId) { + setCachedWorktrees(hostId, result.worktrees) + } } // Drop the optimistic active override once the host reports it active, so later desktop changes win. setOptimisticActiveWorktreeId((pending) => @@ -487,7 +490,7 @@ export function HostScreen({ fetchWorktreesInFlightRef.current = false } }, - [client, connState, hostId] + [client, connState, hostId, worktreeCatalog] ) useFocusEffect( diff --git a/mobile/src/worktree/worktree-catalog-snapshot-client.test.ts b/mobile/src/worktree/worktree-catalog-snapshot-client.test.ts new file mode 100644 index 00000000000..4565c605d66 --- /dev/null +++ b/mobile/src/worktree/worktree-catalog-snapshot-client.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { + admitWorktreeCatalogResponse, + WorktreeCatalogSnapshotClient +} from './worktree-catalog-snapshot-client' + +describe('admitWorktreeCatalogResponse', () => { + it('accepts new-host full and unchanged responses', () => { + const full = admitWorktreeCatalogResponse( + { worktrees: [{ id: 'worktree-1' }], snapshotId: 'snapshot-1' }, + null + ) + const unchanged = admitWorktreeCatalogResponse( + { unchanged: true, snapshotId: 'snapshot-1' }, + 'snapshot-1' + ) + + expect(full).toEqual({ + kind: 'full', + snapshotId: 'snapshot-1', + worktrees: [{ id: 'worktree-1' }] + }) + expect(unchanged).toEqual({ kind: 'unchanged', snapshotId: 'snapshot-1' }) + }) + + it('treats an old-host full response as authoritative and clears the token', () => { + expect( + admitWorktreeCatalogResponse({ worktrees: [{ id: 'worktree-1' }] }, 'stale-token') + ).toEqual({ + kind: 'full', + snapshotId: null, + worktrees: [{ id: 'worktree-1' }] + }) + }) + + it('rejects unchanged responses for a snapshot the client does not own', () => { + expect( + admitWorktreeCatalogResponse({ unchanged: true, snapshotId: 'snapshot-2' }, 'snapshot-1') + ).toEqual({ kind: 'invalid' }) + expect(admitWorktreeCatalogResponse({ unchanged: true }, 'snapshot-1')).toEqual({ + kind: 'invalid' + }) + }) + + it('rejects malformed success payloads', () => { + expect(admitWorktreeCatalogResponse(null, 'snapshot-1')).toEqual({ kind: 'invalid' }) + expect( + admitWorktreeCatalogResponse({ unchanged: false, snapshotId: 'snapshot-1' }, 'snapshot-1') + ).toEqual({ kind: 'invalid' }) + expect( + admitWorktreeCatalogResponse({ worktrees: [], snapshotId: 'x'.repeat(129) }, null) + ).toEqual({ kind: 'full', snapshotId: null, worktrees: [] }) + }) +}) + +function clientWithResults(...results: unknown[]): RpcClient { + return { + sendRequest: vi.fn( + async () => + ({ + id: 'request', + ok: true, + result: results.shift(), + _meta: { runtimeId: 'runtime' } + }) as const + ) + } as unknown as RpcClient +} + +describe('WorktreeCatalogSnapshotClient', () => { + it('retains confirmed rows while admitting unchanged responses', async () => { + const client = clientWithResults( + { worktrees: [{ worktreeId: 'worktree-1' }], snapshotId: 'snapshot-1' }, + { unchanged: true, snapshotId: 'snapshot-1' } + ) + const snapshots = new WorktreeCatalogSnapshotClient() + + const first = snapshots.admit(await snapshots.fetch(client, 'host-1')) + const second = snapshots.admit(await snapshots.fetch(client, 'host-1')) + + expect(first).toMatchObject({ changed: true }) + expect(second).toEqual({ changed: false, worktrees: first?.worktrees }) + }) + + it('does not advance the token until the caller admits a response', async () => { + const client = clientWithResults( + { worktrees: [], snapshotId: 'snapshot-1' }, + { worktrees: [], snapshotId: 'snapshot-1' } + ) + const snapshots = new WorktreeCatalogSnapshotClient() + + await snapshots.fetch(client, 'host-1') + snapshots.admit(await snapshots.fetch(client, 'host-1')) + + expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'worktree.ps', { + limit: 10_000, + afterSnapshotId: null + }) + }) + + it('preserves the last admitted token across transport failures', async () => { + const client = clientWithResults({ worktrees: [], snapshotId: 'snapshot-1' }) + const snapshots = new WorktreeCatalogSnapshotClient() + + snapshots.admit(await snapshots.fetch(client, 'host-1')) + snapshots.admit(null) + await snapshots.fetch(client, 'host-1') + + expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'worktree.ps', { + limit: 10_000, + afterSnapshotId: 'snapshot-1' + }) + }) + + it('clears snapshot ownership after a mismatched unchanged response', async () => { + const client = clientWithResults( + { worktrees: [], snapshotId: 'snapshot-1' }, + { unchanged: true, snapshotId: 'snapshot-2' }, + { worktrees: [], snapshotId: 'snapshot-3' } + ) + const snapshots = new WorktreeCatalogSnapshotClient() + + snapshots.admit(await snapshots.fetch(client, 'host-1')) + snapshots.admit(await snapshots.fetch(client, 'host-1')) + await snapshots.fetch(client, 'host-1') + + expect(client.sendRequest).toHaveBeenNthCalledWith(3, 'worktree.ps', { + limit: 10_000, + afterSnapshotId: null + }) + }) + + it('resets snapshot ownership when the client or host changes', async () => { + const firstClient = clientWithResults({ worktrees: [], snapshotId: 'snapshot-1' }) + const secondClient = clientWithResults({ worktrees: [], snapshotId: 'snapshot-2' }) + const snapshots = new WorktreeCatalogSnapshotClient() + + snapshots.admit(await snapshots.fetch(firstClient, 'host-1')) + await snapshots.fetch(secondClient, 'host-2') + + expect(secondClient.sendRequest).toHaveBeenCalledWith('worktree.ps', { + limit: 10_000, + afterSnapshotId: null + }) + }) +}) diff --git a/mobile/src/worktree/worktree-catalog-snapshot-client.ts b/mobile/src/worktree/worktree-catalog-snapshot-client.ts new file mode 100644 index 00000000000..547205b1944 --- /dev/null +++ b/mobile/src/worktree/worktree-catalog-snapshot-client.ts @@ -0,0 +1,108 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import type { Worktree } from './workspace-list-sections' + +export type WorktreeCatalogAdmission = + | { kind: 'full'; snapshotId: string | null; worktrees: T[] } + | { kind: 'unchanged'; snapshotId: string } + | { kind: 'invalid' } + +type PendingWorktreeCatalog = { + admission: WorktreeCatalogAdmission + client: RpcClient + hostId: string +} + +export type AdmittedWorktreeCatalog = { + changed: boolean + worktrees: Worktree[] +} + +function validSnapshotId(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 && value.length <= 128 ? value : null +} + +export function admitWorktreeCatalogResponse( + result: unknown, + requestedSnapshotId: string | null +): WorktreeCatalogAdmission { + if (!result || typeof result !== 'object') { + return { kind: 'invalid' } + } + const response = result as { + snapshotId?: unknown + unchanged?: unknown + worktrees?: unknown + } + if ('unchanged' in response) { + const snapshotId = validSnapshotId(response.snapshotId) + if (response.unchanged === true && snapshotId && snapshotId === requestedSnapshotId) { + return { kind: 'unchanged', snapshotId } + } + return { kind: 'invalid' } + } + + if (!Array.isArray(response.worktrees)) { + return { kind: 'invalid' } + } + return { + kind: 'full', + snapshotId: validSnapshotId(response.snapshotId), + worktrees: response.worktrees as T[] + } +} + +export class WorktreeCatalogSnapshotClient { + private client: RpcClient | null = null + private hostId: string | null = null + private snapshotId: string | null = null + private confirmedWorktrees: Worktree[] | null = null + + async fetch(client: RpcClient, hostId: string): Promise { + if (this.client !== client || this.hostId !== hostId) { + this.client = client + this.hostId = hostId + this.snapshotId = null + this.confirmedWorktrees = null + } + const requestedSnapshotId = this.snapshotId + const response = await client.sendRequest('worktree.ps', { + limit: 10_000, + afterSnapshotId: requestedSnapshotId + }) + if (!response.ok) { + return null + } + return { + admission: admitWorktreeCatalogResponse( + (response as RpcSuccess).result, + requestedSnapshotId + ), + client, + hostId + } + } + + admit(pending: PendingWorktreeCatalog | null): AdmittedWorktreeCatalog | null { + if (!pending) { + return null + } + if ( + pending.client !== this.client || + pending.hostId !== this.hostId || + pending.admission.kind === 'invalid' + ) { + this.snapshotId = null + return null + } + + const admission = pending.admission + this.snapshotId = admission.snapshotId + if (admission.kind === 'full') { + this.confirmedWorktrees = admission.worktrees + } + return this.confirmedWorktrees + ? { changed: admission.kind === 'full', worktrees: this.confirmedWorktrees } + : null + } +} diff --git a/src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts b/src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts new file mode 100644 index 00000000000..c7efa0bbe43 --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { RpcDispatcher } from '../dispatcher' +import { WORKTREE_METHODS } from './worktree' + +function makeRuntime() { + return { + getRuntimeId: () => 'test-runtime', + getWorktreePs: vi.fn().mockResolvedValue({ + worktrees: [], + totalCount: 0, + truncated: false + }) + } as unknown as OrcaRuntimeService +} + +describe('worktree.ps catalog snapshots', () => { + it('preserves the exact legacy response when no snapshot field is sent', async () => { + const runtime = makeRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + const response = await dispatcher.dispatch({ + id: 'legacy', + authToken: 'token', + method: 'worktree.ps', + params: { limit: 10_000 } + }) + + expect(response).toMatchObject({ + ok: true, + result: { worktrees: [], totalCount: 0, truncated: false } + }) + expect((response as { result: unknown }).result).not.toHaveProperty('snapshotId') + }) + + it('returns a full snapshot followed by a tiny unchanged response', async () => { + const runtime = makeRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + const first = await dispatcher.dispatch({ + id: 'first', + authToken: 'token', + method: 'worktree.ps', + params: { limit: 10_000, afterSnapshotId: null } + }) + const snapshotId = (first as { result: { snapshotId: string } }).result.snapshotId + + const second = await dispatcher.dispatch({ + id: 'second', + authToken: 'token', + method: 'worktree.ps', + params: { limit: 10_000, afterSnapshotId: snapshotId } + }) + + expect(snapshotId).toEqual(expect.any(String)) + expect(second).toMatchObject({ + ok: true, + result: { unchanged: true, snapshotId } + }) + expect(runtime.getWorktreePs).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index caf8dd025ff..069a8499d18 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -57,7 +57,8 @@ export const WorktreeTeardownMissingTerminalsParams = WorktreeDetectedListParams }) export const WorktreePsParams = z.object({ - limit: OptionalFiniteNumber + limit: OptionalFiniteNumber, + afterSnapshotId: z.string().min(1).max(128).nullable().optional() }) export const WorktreeSortOrder = z.object({ diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 9c6205ed86d..e3e8bd7ffa0 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -5,6 +5,7 @@ import { } from '../../../automations/workspace-provenance' import { buildCliWorkspaceProvenance } from '../../../../shared/cli-workspace-provenance' import { defineMethod, type RpcMethod } from '../core' +import { resolveWorktreeCatalogSnapshot } from '../worktree-catalog-snapshot' import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' import { WorktreeCreate, @@ -27,7 +28,13 @@ export const WORKTREE_METHODS: RpcMethod[] = [ defineMethod({ name: 'worktree.ps', params: WorktreePsParams, - handler: async (params, { runtime }) => runtime.getWorktreePs(params.limit) + handler: async (params, { runtime }) => { + const result = await runtime.getWorktreePs(params.limit) + if (params.afterSnapshotId === undefined) { + return result + } + return resolveWorktreeCatalogSnapshot(runtime, params.limit, result, params.afterSnapshotId) + } }), defineMethod({ name: 'worktree.list', diff --git a/src/main/runtime/rpc/worktree-catalog-snapshot.test.ts b/src/main/runtime/rpc/worktree-catalog-snapshot.test.ts new file mode 100644 index 00000000000..f50f5c1db2e --- /dev/null +++ b/src/main/runtime/rpc/worktree-catalog-snapshot.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeWorktreePsResult } from '../../../shared/runtime-types' +import { WorktreeCatalogSnapshotCache } from './worktree-catalog-snapshot' + +function result(totalCount: number): RuntimeWorktreePsResult { + return { worktrees: [], totalCount, truncated: false } +} + +describe('WorktreeCatalogSnapshotCache', () => { + it('returns unchanged only when the caller owns the exact completed snapshot', () => { + const snapshots = new WorktreeCatalogSnapshotCache() + const first = snapshots.resolve(10_000, result(1), null) + expect(first).toMatchObject({ totalCount: 1 }) + expect('snapshotId' in first).toBe(true) + + const snapshotId = first.snapshotId + expect(snapshots.resolve(10_000, result(1), snapshotId)).toEqual({ + unchanged: true, + snapshotId + }) + expect(snapshots.resolve(10_000, result(1), 'unknown')).toEqual({ + ...result(1), + snapshotId + }) + }) + + it('issues a new snapshot for changed content or a different limit', () => { + const snapshots = new WorktreeCatalogSnapshotCache() + const first = snapshots.resolve(10_000, result(1), null) + const changed = snapshots.resolve(10_000, result(2), first.snapshotId) + const differentLimit = snapshots.resolve(200, result(2), changed.snapshotId) + + expect(changed.snapshotId).not.toBe(first.snapshotId) + expect(differentLimit.snapshotId).not.toBe(changed.snapshotId) + }) + + it('repairs completion-order cache replacement with a full response', () => { + const snapshots = new WorktreeCatalogSnapshotCache() + const older = snapshots.resolve(10_000, result(1), null) + const newer = snapshots.resolve(10_000, result(2), older.snapshotId) + snapshots.resolve(10_000, result(1), older.snapshotId) + + const repaired = snapshots.resolve(10_000, result(2), newer.snapshotId) + expect(repaired).toMatchObject({ totalCount: 2 }) + expect(repaired).not.toHaveProperty('unchanged') + }) + + it('scopes snapshot ids to one runtime cache', () => { + const left = new WorktreeCatalogSnapshotCache().resolve(10_000, result(1), null) + const right = new WorktreeCatalogSnapshotCache().resolve(10_000, result(1), null) + + expect(left.snapshotId).not.toBe(right.snapshotId) + }) +}) diff --git a/src/main/runtime/rpc/worktree-catalog-snapshot.ts b/src/main/runtime/rpc/worktree-catalog-snapshot.ts new file mode 100644 index 00000000000..2e72ee2dd88 --- /dev/null +++ b/src/main/runtime/rpc/worktree-catalog-snapshot.ts @@ -0,0 +1,52 @@ +import { randomUUID } from 'node:crypto' +import { isDeepStrictEqual } from 'node:util' +import type { + RuntimeWorktreePsConditionalResult, + RuntimeWorktreePsResult +} from '../../../shared/runtime-types' + +type CachedWorktreeCatalog = { + limit: number | undefined + result: RuntimeWorktreePsResult + snapshotId: string +} + +export class WorktreeCatalogSnapshotCache { + private readonly runtimeScope = randomUUID() + private sequence = 0 + private cached: CachedWorktreeCatalog | null = null + + resolve( + limit: number | undefined, + result: RuntimeWorktreePsResult, + afterSnapshotId: string | null + ): RuntimeWorktreePsConditionalResult { + const cached = this.cached + if (cached !== null && cached.limit === limit && isDeepStrictEqual(cached.result, result)) { + if (afterSnapshotId === cached.snapshotId) { + return { unchanged: true, snapshotId: cached.snapshotId } + } + return { ...result, snapshotId: cached.snapshotId } + } + + const snapshotId = `${this.runtimeScope}:${++this.sequence}` + this.cached = { limit, result, snapshotId } + return { ...result, snapshotId } + } +} + +const snapshotsByRuntime = new WeakMap() + +export function resolveWorktreeCatalogSnapshot( + runtime: object, + limit: number | undefined, + result: RuntimeWorktreePsResult, + afterSnapshotId: string | null +): RuntimeWorktreePsConditionalResult { + let snapshots = snapshotsByRuntime.get(runtime) + if (!snapshots) { + snapshots = new WorktreeCatalogSnapshotCache() + snapshotsByRuntime.set(runtime, snapshots) + } + return snapshots.resolve(limit, result, afterSnapshotId) +} diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 5eca1063ab8..d0f98e68360 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -843,6 +843,19 @@ export type RuntimeWorktreePsResult = { truncated: boolean } +export type RuntimeWorktreePsSnapshotResult = RuntimeWorktreePsResult & { + snapshotId: string +} + +export type RuntimeWorktreePsUnchangedResult = { + unchanged: true + snapshotId: string +} + +export type RuntimeWorktreePsConditionalResult = + | RuntimeWorktreePsSnapshotResult + | RuntimeWorktreePsUnchangedResult + export type RuntimeRepoList = { repos: Repo[] }