diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 661706e9316..e4c39a32583 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -2220,6 +2220,92 @@ describe('OrcaRuntimeService', () => { expect(getRepos).not.toHaveBeenCalled() }) + it('does not block a targeted mobile session tab list on an unrelated worktree scan', async () => { + const remoteWorktreeId = 'repo-ssh::/remote/worktree' + const remotePtyId = 'ssh:ssh-target@@remote-pty' + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeRepoId: 'repo-ssh', + activeWorktreeId: remoteWorktreeId, + activeTabIdByWorktree: { [remoteWorktreeId]: 'remote-tab' }, + tabsByWorktree: { + [remoteWorktreeId]: [ + { + id: 'remote-tab', + ptyId: remotePtyId, + worktreeId: remoteWorktreeId, + title: 'Remote terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'remote-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: remotePtyId }) + } + }), + 'ssh:ssh-target' + ) + const remoteRepo = { + ...store.getRepos()[0], + id: 'repo-ssh', + connectionId: 'ssh-target' + } + runtimeStore.getRepos = () => [remoteRepo] + runtimeStore.getRepo = (id: string) => (id === remoteRepo.id ? remoteRepo : undefined) + const runtime = new OrcaRuntimeService(runtimeStore as never) + const listProcesses = vi.fn(async () => [ + { + id: remotePtyId, + incarnationId: 'remote-incarnation', + terminalHandle: 'term_remote', + title: 'Remote terminal', + cwd: '/remote/worktree', + worktreeId: remoteWorktreeId + } + ]) + runtime.setPtyController({ + listProcesses, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const listWorktrees = vi.fn(() => new Promise(() => {})) + registerSshGitProvider('ssh-target', { listWorktrees } as never) + + vi.useFakeTimers() + try { + let timeoutId: ReturnType | undefined + const timeout = new Promise((resolve) => { + timeoutId = setTimeout(() => resolve(null), 1_000) + }) + const resultPromise = runtime.listMobileSessionTabs(`id:${remoteWorktreeId}`) + await Promise.resolve() + await vi.advanceTimersByTimeAsync(1_000) + const result = await Promise.race([resultPromise, timeout]) + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + } + + expect(result).not.toBeNull() + expect(listWorktrees).not.toHaveBeenCalled() + expect(listProcesses).toHaveBeenCalledOnce() + expect(listProcesses).toHaveBeenCalledWith( + 'ssh-target', + expect.objectContaining({ deadlineMs: expect.any(Number) }) + ) + expect(result).toMatchObject({ + worktree: remoteWorktreeId, + tabs: [expect.objectContaining({ type: 'terminal', parentTabId: 'remote-tab' })] + }) + } finally { + vi.useRealTimers() + unregisterSshGitProvider('ssh-target') + } + }) + it('hydrates persisted tabs when the store cannot report repos', async () => { // Why: #9343 read the repo gate as `getRepos?.() ?? []`, so a store that cannot // report its inventory looked like "every repo is gone" and hydrated nothing — diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 46c3c60565d..c8a9c7eb376 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9634,10 +9634,13 @@ export class OrcaRuntimeService { private async refreshMobileSessionPtyInventory( targetWorktreeId: string | null = null ): Promise { + // Targeted mobile polls must not queue behind an aggregate census that may + // be waiting on an unrelated SSH provider. + if (targetWorktreeId !== null && targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { + return this.performMobileSessionPtyRecordsRefresh(targetWorktreeId) + } if (targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { - // Non-floating refreshes all query the aggregate controller inventory; - // coalesce targeted and all-worktree callers so they cannot invalidate - // one another through the shared aggregate generation fence. + // Fleet-wide refreshes share one aggregate controller inventory. const pending = this.pendingMobileSessionPtyAggregateInventoryRefresh if (pending) { return pending @@ -9663,13 +9666,63 @@ export class OrcaRuntimeService { } // Why: floating PTY identity is explicit, so polling must not resolve every Git/SSH worktree. const isFloatingWorkspace = targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID - const resolvedWorktrees = isFloatingWorkspace ? [] : await this.listResolvedWorktrees() + const resolvedWorktrees = isFloatingWorkspace + ? [] + : targetWorktreeId + ? this.listResolvedWorktreesForExplicitTarget(targetWorktreeId) + : await this.listResolvedWorktrees() + // An explicit mobile worktree belongs to one execution host. Query only + // that provider; aggregate inventory would wait on unrelated SSH hosts. + const targetExecutionHost = targetWorktreeId + ? (resolvedWorktrees.find((worktree) => worktree.id === targetWorktreeId)?.hostId ?? + this.tryGetWorkspaceSessionHostIdForWorktree(targetWorktreeId)) + : null + const parsedTargetHost = targetExecutionHost ? parseExecutionHostId(targetExecutionHost) : null + // Paired/runtime-owned workspaces have a separate controller; this runtime + // cannot inspect them and must not silently query its local PTY provider. + if (parsedTargetHost?.kind === 'runtime') { + return null + } + const targetConnectionId = + parsedTargetHost?.kind === 'ssh' + ? parsedTargetHost.targetId + : targetWorktreeId + ? null + : undefined return await this.refreshPtyWorktreeRecordsWithControllerInventory( resolvedWorktrees, - isFloatingWorkspace ? targetWorktreeId : null + targetWorktreeId, + undefined, + targetConnectionId ) } + /** Targeted mobile opens must not wait for an unrelated SSH/Git worktree scan. */ + private listResolvedWorktreesForExplicitTarget(targetWorktreeId: string): ResolvedWorktree[] { + const cached = + this.resolvedWorktreeCache && this.resolvedWorktreeCache.expiresAt > Date.now() + ? this.resolvedWorktreeCache.worktrees + : null + const targetWorktree = + cached?.find((worktree) => worktree.id === targetWorktreeId) ?? + (() => { + const scope = parseWorkspaceKey(targetWorktreeId) + if (scope?.type === 'folder') { + const folder = this.store + ?.getFolderWorkspaces?.() + .find((workspace) => workspace.id === scope.folderWorkspaceId) + return folder ? this.folderWorkspaceToResolvedWorktree(folder) : null + } + return this.buildResolvedWorktreeFromId(targetWorktreeId) + })() + if (!targetWorktree) { + return [] + } + return cached + ? includeTargetResolvedWorktree(cached, targetWorktree) + : this.listKnownResolvedWorktreesForExplicitTarget(targetWorktreeId, targetWorktree) + } + async activateMobileSessionTab( worktreeSelector: string, tabId: string, @@ -34893,7 +34946,7 @@ export class OrcaRuntimeService { if (!parsed?.repoId || !parsed.worktreePath) { return null } - const repo = this.store?.getRepos().find((entry) => entry.id === parsed.repoId) + const repo = this.store?.getRepos?.()?.find((entry) => entry.id === parsed.repoId) const git = { path: parsed.worktreePath, head: '', @@ -34927,7 +34980,9 @@ export class OrcaRuntimeService { } const target = splitWorktreeIdForFilesystem(targetWorktreeId) if (!target?.repoId || !target.worktreePath) { - return [] + // Folder workspace keys have no repo/path tuple, but the converted row + // is already authoritative for this explicit target. + return [targetWorktree] } const worktreeIds = new Set( Object.keys(this.store.getAllWorktreeMeta()).filter((worktreeId) => { @@ -35496,7 +35551,8 @@ export class OrcaRuntimeService { resolvedWorktrees: ResolvedWorktree[], targetWorktreeId: string | null = null, deadline?: number, - connectionId?: string | null + connectionId?: string | null, + retryStale = false ): Promise { if (targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID) { const targetedLiveness = this.refreshFloatingWorkspacePtyLiveness() @@ -35570,6 +35626,18 @@ export class OrcaRuntimeService { inventoryGeneration && this.ptyControllerAggregateInventoryGeneration <= inventoryGeneration if (!isCurrentInventory) { + // A fleet census that began after this targeted poll must not turn a + // user-driven open into an empty result. Re-query the owning provider; + // the second generation is then fenced against both operations. + if (targetWorktreeId !== null && !retryStale) { + return this.refreshPtyWorktreeRecordsWithControllerInventory( + resolvedWorktrees, + targetWorktreeId, + deadline, + connectionId, + true + ) + } return null } const sessions = sessionsResult.value.processes diff --git a/src/main/runtime/session-tabs-inventory-publication.test.ts b/src/main/runtime/session-tabs-inventory-publication.test.ts index 761504c273f..854059958bd 100644 --- a/src/main/runtime/session-tabs-inventory-publication.test.ts +++ b/src/main/runtime/session-tabs-inventory-publication.test.ts @@ -133,7 +133,7 @@ describe('authoritative session tab inventory publication', () => { expect(collections).toBe(4) }) - it('coalesces targeted and all-host PTY refreshes behind one aggregate census', async () => { + it('keeps targeted PTY refreshes independent from an aggregate census', async () => { const runtime = createInventoryRuntime() runtime.attachWindow(1) runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) @@ -143,7 +143,7 @@ describe('authoritative session tab inventory publication', () => { terminalIdentityByPtyId: new Map(), queriedHostIds: new Set(['local']) } - let resolveRefresh: ((inventory: typeof emptyInventory) => void) | undefined + const pendingResolves: ((inventory: typeof emptyInventory) => void)[] = [] const internals = runtime as unknown as { refreshMobileSessionPtyInventory: (targetWorktreeId?: string | null) => Promise performMobileSessionPtyRecordsRefresh: (targetWorktreeId: string | null) => Promise @@ -151,7 +151,7 @@ describe('authoritative session tab inventory publication', () => { const perform = vi.spyOn(internals, 'performMobileSessionPtyRecordsRefresh').mockImplementation( () => new Promise((resolve) => { - resolveRefresh = resolve + pendingResolves.push(resolve) }) ) @@ -160,10 +160,13 @@ describe('authoritative session tab inventory publication', () => { const targeted = internals.refreshMobileSessionPtyInventory('repo::/target') await Promise.resolve() - expect(perform).toHaveBeenCalledOnce() - resolveRefresh?.(emptyInventory) + expect(perform).toHaveBeenCalledTimes(2) + expect(perform).toHaveBeenNthCalledWith(1, null) + expect(perform).toHaveBeenNthCalledWith(2, 'repo::/target') + pendingResolves[1]?.(emptyInventory) await targeted + pendingResolves[0]?.(emptyInventory) await expect(allHosts).resolves.toEqual({ snapshots: [], authoritative: true }) })