From 8cc423b4065bd5a3cecf46ec63e106e3eebd2e01 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:46:03 -0700 Subject: [PATCH] perf(runtime): scope PTY inventory by workspace owner --- src/main/runtime/orca-runtime.ts | 1012 +++++++++++++---- .../pty-inventory-liveness-verdict.test.ts | 1 + ...pty-inventory-runtime-folder-guard.test.ts | 47 + ...-inventory-scoped-routing-fixture-types.ts | 211 ++++ .../pty-inventory-scoped-routing-fixtures.ts | 209 ++++ .../pty-inventory-scoped-routing.test.ts | 775 +++++++++++++ 6 files changed, 2049 insertions(+), 206 deletions(-) create mode 100644 src/main/runtime/pty-inventory-runtime-folder-guard.test.ts create mode 100644 src/main/runtime/pty-inventory-scoped-routing-fixture-types.ts create mode 100644 src/main/runtime/pty-inventory-scoped-routing-fixtures.ts create mode 100644 src/main/runtime/pty-inventory-scoped-routing.test.ts diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index aac73f5e7f4..181d83e1b52 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1986,6 +1986,23 @@ type PtyControllerInventory = Readonly<{ queriedHostIds: ReadonlySet }> +type ScopedPtyControllerInventoryTarget = Readonly<{ + worktree: ResolvedWorktree + provider: Readonly<{ connectionId: string | null }> | null +}> + +type FolderWorkspaceOwnerHostIndex = Readonly<{ + hostIdsById: Map> + unresolvedIds: Set +}> + +type WorkspaceExecutionOwnerIndex = Readonly<{ + repoOwnersById: Map + exactHostIdsByWorktreeId: Map> + folderHostIdsById: Map> + unresolvedFolderIds: Set +}> + type WorktreeStartupDraftPaste = { agent: TuiAgent content: string @@ -3123,7 +3140,9 @@ export class OrcaRuntimeService { // reconnect-scan bounding for sequential soft freezes. private readonly terminalFocusNavigationCoalescer = new TerminalFocusNavigationCoalescer() - private pendingMobileSessionPtyInventoryRefresh: Promise | null> | null = null + // Why: inventories for different execution hosts are not interchangeable. Coalesce + // only callers targeting the same workspace; an all-host refresh remains its own key. + private pendingMobileSessionPtyInventoryRefreshes = new Map | null>>() private leaves = new Map() // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by // ptyId keeps active TUI redraws independent of the total open terminal count. @@ -5940,9 +5959,19 @@ export class OrcaRuntimeService { return this.startedAt } - private tryGetWorkspaceSessionHostIdForWorktree(worktreeId: string): ExecutionHostId | null { + private tryGetWorkspaceSessionHostIdForWorktree( + worktreeId: string, + ownerIndex?: WorkspaceExecutionOwnerIndex + ): ExecutionHostId | null { const scope = parseWorkspaceKey(worktreeId) if (scope?.type === 'folder') { + if (ownerIndex) { + const folderId = scope.folderWorkspaceId + const hostIds = ownerIndex.folderHostIdsById.get(folderId) + return !ownerIndex.unresolvedFolderIds.has(folderId) && hostIds?.size === 1 + ? hostIds.values().next().value! + : null + } const workspace = this.store ?.getFolderWorkspaces?.() .find((entry) => entry.id === scope.folderWorkspaceId) @@ -5952,12 +5981,38 @@ export class OrcaRuntimeService { if (workspace.executionHostId != null) { return parseExecutionHostId(workspace.executionHostId)?.id ?? null } - const connectionId = this.resolveFolderWorkspaceConnectionId(workspace) - return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID + try { + const connectionId = this.resolveFolderWorkspaceConnectionId(workspace) + return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID + } catch { + return null + } } const resolvedWorktreeId = scope?.type === 'worktree' ? scope.worktreeId : worktreeId - const repo = this.store?.getRepo?.(getRepoIdFromWorktreeId(resolvedWorktreeId)) - return repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID + const parsed = splitWorktreeIdForFilesystem(resolvedWorktreeId) + if (!parsed) { + return null + } + const resolvedOwnerIndex = ownerIndex ?? this.buildWorkspaceExecutionOwnerIndex() + const ownerHostIds = new Set( + resolvedOwnerIndex.exactHostIdsByWorktreeId.get(resolvedWorktreeId) ?? [] + ) + const repoOwners = resolvedOwnerIndex.repoOwnersById.get(parsed.repoId) ?? [] + const pathOwners = repoOwners.filter((repo) => + isPathInsideOrEqual(repo.path, parsed.worktreePath) + ) + for (const repo of pathOwners.length > 0 + ? pathOwners + : ownerHostIds.size === 0 + ? repoOwners + : []) { + ownerHostIds.add(getRepoExecutionHostId(repo)) + } + return ownerHostIds.size === 0 + ? LOCAL_EXECUTION_HOST_ID + : ownerHostIds.size === 1 + ? ownerHostIds.values().next().value! + : null } private getWorkspaceSessionHostIdForWorktree(worktreeId: string): ExecutionHostId { @@ -5967,9 +6022,205 @@ export class OrcaRuntimeService { } return hostId } + private buildFolderWorkspaceOwnerHostIndex( + repos: readonly Repo[], + projectGroups: readonly ProjectGroup[], + folderWorkspaces: readonly FolderWorkspace[] + ): FolderWorkspaceOwnerHostIndex { + type FolderPathTrieNode = { + children: Map + folderIds: string[] + } + const folderPathRoot: FolderPathTrieNode = { children: new Map(), folderIds: [] } + const pathConnectionIdsByFolderId = new Map>() + for (const folderWorkspace of folderWorkspaces) { + let node = folderPathRoot + for (const segment of normalizeRuntimePathForComparison(folderWorkspace.folderPath) + .split('/') + .filter(Boolean)) { + const child = node.children.get(segment) ?? { children: new Map(), folderIds: [] } + node.children.set(segment, child) + node = child + } + node.folderIds.push(folderWorkspace.id) + if (!pathConnectionIdsByFolderId.has(folderWorkspace.id)) { + pathConnectionIdsByFolderId.set(folderWorkspace.id, new Set()) + } + } + const projectGroupById = new Map(projectGroups.map((group) => [group.id, group])) + const groupConnectionIdsByAncestorId = new Map>() + for (const repo of repos) { + const connectionId = repo.connectionId?.trim() || null + let node: FolderPathTrieNode | undefined = folderPathRoot + for (const folderId of node.folderIds) { + pathConnectionIdsByFolderId.get(folderId)?.add(connectionId) + } + for (const segment of normalizeRuntimePathForComparison(repo.path) + .split('/') + .filter(Boolean)) { + node = node.children.get(segment) + if (!node) { + break + } + for (const folderId of node.folderIds) { + pathConnectionIdsByFolderId.get(folderId)?.add(connectionId) + } + } + let groupId = repo.projectGroupId ?? null + const visitedGroupIds = new Set() + while (groupId && !visitedGroupIds.has(groupId)) { + visitedGroupIds.add(groupId) + const connectionIds = + groupConnectionIdsByAncestorId.get(groupId) ?? new Set() + connectionIds.add(connectionId) + groupConnectionIdsByAncestorId.set(groupId, connectionIds) + groupId = projectGroupById.get(groupId)?.parentGroupId ?? null + } + } + const hostIdsById = new Map>() + const unresolvedIds = new Set() + for (const folderWorkspace of folderWorkspaces) { + const explicitHost = folderWorkspace.executionHostId + ? parseExecutionHostId(folderWorkspace.executionHostId) + : null + if (folderWorkspace.executionHostId != null && !explicitHost) { + unresolvedIds.add(folderWorkspace.id) + continue + } + if (explicitHost?.kind === 'runtime') { + const hostIds = hostIdsById.get(folderWorkspace.id) ?? new Set() + hostIds.add(explicitHost.id) + hostIdsById.set(folderWorkspace.id, hostIds) + continue + } + const groupConnectionIds = + groupConnectionIdsByAncestorId.get(folderWorkspace.projectGroupId) ?? + new Set() + let connectionId: string | null | undefined + if (folderWorkspace.connectionId) { + const explicitConnectionId = folderWorkspace.connectionId + connectionId = + groupConnectionIds.has(null) || + [...groupConnectionIds].some( + (candidate) => candidate !== null && candidate !== explicitConnectionId + ) + ? undefined + : explicitConnectionId + } else { + const effectiveConnectionIds = + groupConnectionIds.size > 0 + ? groupConnectionIds + : (pathConnectionIdsByFolderId.get(folderWorkspace.id) ?? new Set()) + const hasLocal = effectiveConnectionIds.has(null) + const sshConnectionIds = [...effectiveConnectionIds].filter( + (candidate): candidate is string => candidate !== null + ) + connectionId = + (hasLocal && sshConnectionIds.length > 0) || sshConnectionIds.length > 1 + ? undefined + : (sshConnectionIds[0] ?? null) + } + if (connectionId === undefined) { + unresolvedIds.add(folderWorkspace.id) + continue + } + const inferredHostId = connectionId + ? toSshExecutionHostId(connectionId) + : LOCAL_EXECUTION_HOST_ID + if (explicitHost && explicitHost.id !== inferredHostId) { + unresolvedIds.add(folderWorkspace.id) + continue + } + const hostIds = hostIdsById.get(folderWorkspace.id) ?? new Set() + hostIds.add(inferredHostId) + hostIdsById.set(folderWorkspace.id, hostIds) + } + return { hostIdsById, unresolvedIds } + } - private getWorkspaceSessionForWorktree(worktreeId: string): WorkspaceSessionState | null { - const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) + private buildWorkspaceExecutionOwnerIndex(): WorkspaceExecutionOwnerIndex { + const repos = this.store?.getRepos?.() ?? [] + const repoOwnersById = new Map() + for (const repo of repos) { + const owners = repoOwnersById.get(repo.id) ?? [] + owners.push(repo) + repoOwnersById.set(repo.id, owners) + } + const exactHostIdsByWorktreeId = new Map>() + const addExactOwner = (worktreeId: string, hostId: string | undefined): void => { + const parsedHost = hostId ? parseExecutionHostId(hostId) : null + if (!parsedHost) { + return + } + const hostIds = exactHostIdsByWorktreeId.get(worktreeId) ?? new Set() + hostIds.add(parsedHost.id) + exactHostIdsByWorktreeId.set(worktreeId, hostIds) + } + for (const worktree of this.resolvedWorktreeCache?.worktrees ?? []) { + addExactOwner(worktree.id, worktree.hostId) + } + for (const [worktreeId, meta] of Object.entries(this.store?.getAllWorktreeMeta?.() ?? {})) { + addExactOwner(worktreeId, typeof meta.hostId === 'string' ? meta.hostId : undefined) + } + const folderOwnerIndex = this.buildFolderWorkspaceOwnerHostIndex( + repos, + this.store?.getProjectGroups?.() ?? [], + this.store?.getFolderWorkspaces?.() ?? [] + ) + return { + repoOwnersById, + exactHostIdsByWorktreeId, + folderHostIdsById: folderOwnerIndex.hostIdsById, + unresolvedFolderIds: folderOwnerIndex.unresolvedIds + } + } + + private hasAmbiguousWorkspaceExecutionOwner( + worktreeId: string, + ownerIndex = this.buildWorkspaceExecutionOwnerIndex() + ): boolean { + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + const folderId = workspaceScope.folderWorkspaceId + return ( + ownerIndex.unresolvedFolderIds.has(folderId) || + ownerIndex.folderHostIdsById.get(folderId)?.size !== 1 + ) + } + const parsed = splitWorktreeIdForFilesystem(worktreeId) + if (!parsed) { + return true + } + const ownerHostIds = new Set(ownerIndex.exactHostIdsByWorktreeId.get(worktreeId) ?? []) + const repoOwners = ownerIndex.repoOwnersById.get(parsed.repoId) ?? [] + const pathOwners = repoOwners.filter((repo) => + isPathInsideOrEqual(repo.path, parsed.worktreePath) + ) + for (const repo of pathOwners.length > 0 + ? pathOwners + : ownerHostIds.size === 0 + ? repoOwners + : []) { + ownerHostIds.add(getRepoExecutionHostId(repo)) + } + return ownerHostIds.size > 1 + } + + private assertUnambiguousWorkspaceExecutionOwner( + worktreeId: string, + ownerIndex?: WorkspaceExecutionOwnerIndex + ): void { + if (this.hasAmbiguousWorkspaceExecutionOwner(worktreeId, ownerIndex)) { + this.mobileSessionTabsByWorktree.delete(worktreeId) + throw new Error('selector_ambiguous') + } + } + + private getWorkspaceSessionForWorktree( + worktreeId: string, + ownerIndex?: WorkspaceExecutionOwnerIndex + ): WorkspaceSessionState | null { + const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId, ownerIndex) return hostId ? (this.store?.getWorkspaceSession?.(hostId) ?? null) : null } @@ -6042,24 +6293,19 @@ export class OrcaRuntimeService { } private getWorkspaceSessionHydrationTargets( - includeAllPersistedWorktrees: boolean + includeAllPersistedWorktrees: boolean, + ownerIndex = this.buildWorkspaceExecutionOwnerIndex() ): Map { - const repos = this.store?.getRepos?.() ?? [] - const repoHostIdByRepoId = new Map( - repos.map((repo) => [repo.id, getRepoExecutionHostId(repo)] as const) - ) - const folderHostIdByWorkspaceId = new Map( - (this.store?.getFolderWorkspaces?.() ?? []).map((workspace) => { - const connectionId = this.resolveFolderWorkspaceConnectionId(workspace) - return [ - workspace.id, - connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID - ] as const - }) - ) const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) - for (const repo of repos) { - hostIds.add(getRepoExecutionHostId(repo)) + for (const repos of ownerIndex.repoOwnersById.values()) { + for (const repo of repos) { + hostIds.add(getRepoExecutionHostId(repo)) + } + } + for (const folderHostIds of ownerIndex.folderHostIdsById.values()) { + for (const hostId of folderHostIds) { + hostIds.add(hostId) + } } for (const hostId of this.store?.getWorkspaceSessionHostIds?.() ?? []) { hostIds.add(hostId) @@ -6072,13 +6318,7 @@ export class OrcaRuntimeService { continue } for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) { - const scope = parseWorkspaceKey(worktreeId) - const ownerHostId = - scope?.type === 'folder' - ? (folderHostIdByWorkspaceId.get(scope.folderWorkspaceId) ?? null) - : (repoHostIdByRepoId.get( - getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : worktreeId) - ) ?? LOCAL_EXECUTION_HOST_ID) + const ownerHostId = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId, ownerIndex) if ( ownerHostId === hostId && (includeAllPersistedWorktrees || @@ -6518,6 +6758,7 @@ export class OrcaRuntimeService { targetId: string, generation: number ): Promise { + const providerHostId = toSshExecutionHostId(targetId) const repoIds = new Set( (this.store?.getRepos() ?? []) .filter((repo) => repo.connectionId === targetId) @@ -6526,32 +6767,53 @@ export class OrcaRuntimeService { if (repoIds.size === 0) { return } + const workspaceSession = this.store?.getWorkspaceSession?.(providerHostId) + const ownerIndex = this.buildWorkspaceExecutionOwnerIndex() const worktreeIds = new Set() + const publishableWorktreeIds = new Set() for (const worktreeId of [ - ...this.getKnownWorkspaceSessionWorktreeIds(), + ...Object.keys(workspaceSession?.tabsByWorktree ?? {}), ...this.mobileSessionTabsByWorktree.keys() ]) { const parsed = splitWorktreeId(worktreeId) - if (parsed && repoIds.has(parsed.repoId)) { - worktreeIds.add(worktreeId) + if (!parsed || !repoIds.has(parsed.repoId)) { + continue } + worktreeIds.add(worktreeId) + if (this.hasAmbiguousWorkspaceExecutionOwner(worktreeId, ownerIndex)) { + // No public mobile operation carries execution-host authority. Keep the + // provider-scoped liveness query, but never publish a mixed snapshot. + this.mobileSessionTabsByWorktree.delete(worktreeId) + continue + } + publishableWorktreeIds.add(worktreeId) } if (worktreeIds.size === 0) { return } - - // Why: relay readiness follows PTY reattach; rebuild the HUB-owned panes before paired clients consume the connected event. - for (const worktreeId of worktreeIds) { - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { - allowAttachedWindow: true, - onlyRuntimeOwnedTerminals: true - }) + const resolvedWorktrees = [...worktreeIds].flatMap((worktreeId) => { + const target = this.resolveScopedPtyControllerInventoryTarget(worktreeId, null, targetId) + return target?.provider?.connectionId === targetId ? [target.worktree] : [] + }) + if (resolvedWorktrees.length === 0) { + return } - await this.refreshMobileSessionPtyRecords() + await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees, null, undefined, targetId) if (this.sshRelayRecoveryGenerationByTargetId.get(targetId) !== generation) { return } - for (const worktreeId of worktreeIds) { + const refreshedOwnerIndex = this.buildWorkspaceExecutionOwnerIndex() + for (const worktreeId of publishableWorktreeIds) { + if (this.hasAmbiguousWorkspaceExecutionOwner(worktreeId, refreshedOwnerIndex)) { + this.mobileSessionTabsByWorktree.delete(worktreeId) + continue + } + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true, + ownerIndex: refreshedOwnerIndex, + ...(workspaceSession ? { workspaceSession } : {}) + }) this.notifyMobileSessionTabsChangedNow(worktreeId) } } @@ -7040,23 +7302,36 @@ export class OrcaRuntimeService { clientNavigationId?: string ): Promise { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) + const ownerIndex = this.buildWorkspaceExecutionOwnerIndex() if (explicitWorktreeId) { + this.assertUnambiguousWorkspaceExecutionOwner(explicitWorktreeId, ownerIndex) this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId, { allowAttachedWindow: true, - onlyRuntimeOwnedTerminals: true + onlyRuntimeOwnedTerminals: true, + ownerIndex }) - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId) + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId, { ownerIndex }) await this.refreshMobileSessionPtyRecords(explicitWorktreeId) + this.assertUnambiguousWorkspaceExecutionOwner( + explicitWorktreeId, + this.buildWorkspaceExecutionOwnerIndex() + ) this.restoreLivePairedRendererSessionOwnedMobileTerminals(explicitWorktreeId) return this.getMobileSessionTabsForWorktree(explicitWorktreeId, clientNavigationId) } const worktree = await this.resolveWorktreeSelector(worktreeSelector) + this.assertUnambiguousWorkspaceExecutionOwner(worktree.id, ownerIndex) this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, { allowAttachedWindow: true, - onlyRuntimeOwnedTerminals: true + onlyRuntimeOwnedTerminals: true, + ownerIndex }) - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id) - await this.refreshMobileSessionPtyRecords() + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, { ownerIndex }) + await this.refreshMobileSessionPtyRecords(worktree.id) + this.assertUnambiguousWorkspaceExecutionOwner( + worktree.id, + this.buildWorkspaceExecutionOwnerIndex() + ) this.restoreLivePairedRendererSessionOwnedMobileTerminals(worktree.id) return this.getMobileSessionTabsForWorktree(worktree.id, clientNavigationId) } @@ -7064,14 +7339,22 @@ export class OrcaRuntimeService { async listAllMobileSessionTabs( clientNavigationId?: string ): Promise { + const ownerIndex = this.buildWorkspaceExecutionOwnerIndex() for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, - onlyRuntimeOwnedTerminals: true + onlyRuntimeOwnedTerminals: true, + ownerIndex }) } - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(undefined, { ownerIndex }) await this.refreshMobileSessionPtyRecords() + const refreshedOwnerIndex = this.buildWorkspaceExecutionOwnerIndex() + for (const worktreeId of this.mobileSessionTabsByWorktree.keys()) { + if (this.hasAmbiguousWorkspaceExecutionOwner(worktreeId, refreshedOwnerIndex)) { + this.mobileSessionTabsByWorktree.delete(worktreeId) + } + } return [...this.mobileSessionTabsByWorktree.values()].map((snapshot) => this.clientSessionTabSelections.project( this.toMobileSessionTabsResult(snapshot), @@ -7088,6 +7371,7 @@ export class OrcaRuntimeService { onlyRuntimeOwnedTerminals?: boolean runtimeOwnedTerminalCandidateKnown?: boolean workspaceSession?: WorkspaceSessionState + ownerIndex?: WorkspaceExecutionOwnerIndex } = {} ): Set { // Why: report which worktrees were reconciled in place so callers don't @@ -7096,10 +7380,11 @@ export class OrcaRuntimeService { if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) { return reconciledWorktreeIds } + const ownerIndex = options.ownerIndex ?? this.buildWorkspaceExecutionOwnerIndex() const session = options.workspaceSession ?? (worktreeId - ? this.getWorkspaceSessionForWorktree(worktreeId) + ? this.getWorkspaceSessionForWorktree(worktreeId, ownerIndex) : this.store?.getWorkspaceSession?.()) if (!session) { return reconciledWorktreeIds @@ -7137,6 +7422,12 @@ export class OrcaRuntimeService { // report repos — an unavailable list must not read as "every repo is gone". let liveRepoIds: Set | null | undefined for (const [entryWorktreeId, persistedTabs] of entries) { + if (this.hasAmbiguousWorkspaceExecutionOwner(entryWorktreeId, ownerIndex)) { + // A hostless Map key cannot safely merge two execution owners. Drop any + // stale mixed snapshot and wait for an unambiguous owner. + this.mobileSessionTabsByWorktree.delete(entryWorktreeId) + continue + } const ownerRepoId = splitWorktreeIdForFilesystem(entryWorktreeId)?.repoId if (ownerRepoId) { if (liveRepoIds === undefined) { @@ -8760,22 +9051,23 @@ export class OrcaRuntimeService { private async refreshMobileSessionPtyRecords( targetWorktreeId: string | null = null ): Promise | null> { - if (targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { - const pending = this.pendingMobileSessionPtyInventoryRefresh - if (pending) { - return pending - } - // Why: reconnect exit bursts share one authoritative daemon inventory - // instead of multiplying a full cross-generation list RPC per stale tab. - const refresh = this.performMobileSessionPtyRecordsRefresh(targetWorktreeId).finally(() => { - if (this.pendingMobileSessionPtyInventoryRefresh === refresh) { - this.pendingMobileSessionPtyInventoryRefresh = null - } - }) - this.pendingMobileSessionPtyInventoryRefresh = refresh - return refresh + if (targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + return await this.performMobileSessionPtyRecordsRefresh(targetWorktreeId) } - return await this.performMobileSessionPtyRecordsRefresh(targetWorktreeId) + const refreshKey = targetWorktreeId ?? '\0all' + const pending = this.pendingMobileSessionPtyInventoryRefreshes.get(refreshKey) + if (pending) { + return pending + } + // Why: reconnect exit bursts for one workspace share one authoritative provider + // inventory without letting another provider's in-flight result stand in for it. + const refresh = this.performMobileSessionPtyRecordsRefresh(targetWorktreeId).finally(() => { + if (this.pendingMobileSessionPtyInventoryRefreshes.get(refreshKey) === refresh) { + this.pendingMobileSessionPtyInventoryRefreshes.delete(refreshKey) + } + }) + this.pendingMobileSessionPtyInventoryRefreshes.set(refreshKey, refresh) + return refresh } private async performMobileSessionPtyRecordsRefresh( @@ -8784,13 +9076,24 @@ export class OrcaRuntimeService { if (!this.ptyController?.listProcesses && !this.ptyController?.hasPty) { return null } - // 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() - return await this.refreshPtyWorktreeRecordsFromController( - resolvedWorktrees, - isFloatingWorkspace ? targetWorktreeId : null - ) + // Why: floating PTY identity is explicit, so polling must not resolve any Git/SSH worktree. + if (targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + return await this.refreshPtyWorktreeRecordsFromController([], targetWorktreeId) + } + if (targetWorktreeId) { + const target = this.resolveScopedPtyControllerInventoryTarget(targetWorktreeId) + if (!target?.provider) { + // Ambiguous, paired-runtime, or unavailable ownership proves no PTY absent. + return null + } + return await this.refreshPtyWorktreeRecordsFromController( + [target.worktree], + targetWorktreeId, + undefined, + target.provider.connectionId + ) + } + return await this.refreshPtyWorktreeRecordsFromController(await this.listResolvedWorktrees()) } async activateMobileSessionTab( @@ -8809,8 +9112,14 @@ export class OrcaRuntimeService { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) + const ownerIndex = this.buildWorkspaceExecutionOwnerIndex() + this.assertUnambiguousWorkspaceExecutionOwner(worktreeId, ownerIndex) + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { ownerIndex }) await this.refreshMobileSessionPtyRecords(worktreeId) + this.assertUnambiguousWorkspaceExecutionOwner( + worktreeId, + this.buildWorkspaceExecutionOwnerIndex() + ) const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) const directTab = snapshot?.tabs.find((candidate) => candidate.id === tabId) const tab = leafId @@ -8874,6 +9183,18 @@ export class OrcaRuntimeService { // untappable; fall back to the plain-shell materialize. } } + const materializationOwnerIndex = this.buildWorkspaceExecutionOwnerIndex() + this.assertUnambiguousWorkspaceExecutionOwner(worktreeId, materializationOwnerIndex) + const materializationHostId = this.tryGetWorkspaceSessionHostIdForWorktree( + worktreeId, + materializationOwnerIndex + ) + if (materializationHostId) { + const materializationHost = parseExecutionHostId(materializationHostId) + if (materializationHost?.kind === 'runtime') { + throw new Error('terminal_unavailable') + } + } try { await this.createRuntimeOwnedMobileSessionTerminal(worktreeId, targetsHost, undefined, { identity: { @@ -9157,6 +9478,38 @@ export class OrcaRuntimeService { }) } + private assertMobileTerminalTabExecutionOwner( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): void { + const ownerHostId = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) + if (!ownerHostId) { + throw new Error('selector_ambiguous') + } + const ptyIds = new Set( + [tab.ptyId, ...Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {})].filter( + (ptyId): ptyId is string => Boolean(ptyId) + ) + ) + for (const ptyId of ptyIds) { + const fromId = getPtyExecutionHost(ptyId) + if (fromId === 'foreign') { + throw new Error('terminal_host_scope_mismatch') + } + const record = this.ptysById.get(ptyId) + const ptyHostId = + fromId ?? + (record?.connectionId + ? toSshExecutionHostId(record.connectionId) + : record + ? LOCAL_EXECUTION_HOST_ID + : null) + if (ptyHostId && ptyHostId !== ownerHostId) { + throw new Error('terminal_host_scope_mismatch') + } + } + } + async closeMobileSessionTab( worktreeSelector: string, tabId: string, @@ -9172,8 +9525,14 @@ export class OrcaRuntimeService { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) - const observedPtyIds = await this.refreshMobileSessionPtyRecords() + const ownerIndex = this.buildWorkspaceExecutionOwnerIndex() + this.assertUnambiguousWorkspaceExecutionOwner(worktreeId, ownerIndex) + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { ownerIndex }) + const observedPtyIds = await this.refreshMobileSessionPtyRecords(worktreeId) + this.assertUnambiguousWorkspaceExecutionOwner( + worktreeId, + this.buildWorkspaceExecutionOwnerIndex() + ) if (graphEpoch !== null) { this.assertStableReadyGraph(graphEpoch) } @@ -9206,6 +9565,9 @@ export class OrcaRuntimeService { if (!snapshot || !tab) { throw new Error('tab_not_found') } + if (tab.type === 'terminal') { + this.assertMobileTerminalTabExecutionOwner(worktreeId, tab) + } if (options.expectedTerminalHandle !== undefined) { const terminalIncarnationMatches = tab.type === 'terminal' && @@ -17177,40 +17539,28 @@ export class OrcaRuntimeService { const explicitTargetWorktreeId = worktreeSelector ? this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) : null - const initialResolvedWorktreeCache = this.resolvedWorktreeCache - const cachedResolvedWorktrees = - initialResolvedWorktreeCache && initialResolvedWorktreeCache.expiresAt > Date.now() - ? initialResolvedWorktreeCache.worktrees - : null + const cachedExplicitTargetWorktrees = + explicitTargetWorktreeId && + this.resolvedWorktreeCache && + this.resolvedWorktreeCache.expiresAt > Date.now() + ? this.resolvedWorktreeCache.worktrees.filter( + (worktree) => worktree.id === explicitTargetWorktreeId + ) + : [] const cachedExplicitTargetWorktree = - explicitTargetWorktreeId && cachedResolvedWorktrees - ? (cachedResolvedWorktrees.find((worktree) => worktree.id === explicitTargetWorktreeId) ?? - null) - : null - const parsedExplicitTargetWorktree = - explicitTargetWorktreeId && !cachedExplicitTargetWorktree - ? this.buildResolvedWorktreeFromId(explicitTargetWorktreeId) - : null + cachedExplicitTargetWorktrees.length === 1 ? cachedExplicitTargetWorktrees[0]! : null const targetWorktree = worktreeSelector && !explicitTargetWorktreeId ? await this.resolveWorktreeSelector(worktreeSelector) - : (cachedExplicitTargetWorktree ?? parsedExplicitTargetWorktree) + : cachedExplicitTargetWorktree const targetWorktreeId = explicitTargetWorktreeId ?? targetWorktree?.id ?? null - const classificationResolvedWorktreeCache = this.resolvedWorktreeCache - const classificationResolvedWorktrees = - targetWorktreeId && - classificationResolvedWorktreeCache && - classificationResolvedWorktreeCache.expiresAt > Date.now() - ? includeTargetResolvedWorktree( - classificationResolvedWorktreeCache.worktrees, - targetWorktree - ) - : targetWorktreeId && explicitTargetWorktreeId - ? this.listKnownResolvedWorktreesForExplicitTarget(targetWorktreeId, targetWorktree) - : null + const scopedInventoryTarget = targetWorktreeId + ? this.resolveScopedPtyControllerInventoryTarget(targetWorktreeId, targetWorktree) + : null + const resolvedTargetWorktree = scopedInventoryTarget?.worktree ?? targetWorktree const worktreesById = - targetWorktreeId && targetWorktree - ? new Map([[targetWorktree.id, targetWorktree]]) + targetWorktreeId && resolvedTargetWorktree + ? new Map([[resolvedTargetWorktree.id, resolvedTargetWorktree]]) : targetWorktreeId ? new Map() : await this.getResolvedWorktreeMap() @@ -17218,18 +17568,21 @@ export class OrcaRuntimeService { this.assertStableReadyGraph(graphEpoch) } - const resolvedWorktrees = - targetWorktreeId && classificationResolvedWorktrees - ? classificationResolvedWorktrees - : targetWorktreeId && targetWorktree - ? [targetWorktree] - : targetWorktreeId - ? [] - : [...worktreesById.values()] - const controllerInventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( - resolvedWorktrees, - targetWorktreeId - ) + // An exact workspace has exactly one execution owner. Query only that provider, + // while an unscoped list intentionally preserves the fleet-wide inventory. + const controllerInventory = + targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID + ? await this.refreshPtyWorktreeRecordsWithControllerInventory([], targetWorktreeId) + : targetWorktreeId + ? scopedInventoryTarget?.provider + ? await this.refreshPtyWorktreeRecordsWithControllerInventory( + [scopedInventoryTarget.worktree], + targetWorktreeId, + undefined, + scopedInventoryTarget.provider.connectionId + ) + : null + : await this.refreshPtyWorktreeRecordsWithControllerInventory([...worktreesById.values()]) const refreshedPtyLiveness = controllerInventory ? new Set(controllerInventory.livePtyIds) : null @@ -23653,7 +24006,7 @@ export class OrcaRuntimeService { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, { allowAttachedWindow: true }) - await this.refreshMobileSessionPtyRecords() + await this.refreshMobileSessionPtyRecords(worktree.id) this.notifyMobileSessionTabsChanged(worktree.id) // Why: a phone open must also wake the worktree's slept agents (experimental // agent sleep). Only the host renderer holds the sleeping records + wake @@ -30528,19 +30881,23 @@ export class OrcaRuntimeService { worktree: ResolvedWorktree ): Promise { const sleepDeadline = Date.now() + WORKTREE_TERMINAL_SLEEP_TIMEOUT_MS + const inventoryProvider = this.resolveScopedPtyControllerInventoryTarget( + worktree.id, + worktree + )?.provider + if (!inventoryProvider) { + throw new Error('terminal_liveness_unavailable') + } const releaseMutation = await this.acquireWorktreeTerminalMutation(worktree.id, sleepDeadline) const key = runtimeWorktreeIdentityKey(worktree.id) const existingSleepState = this.terminalSleepStateByWorktreeId.get(key) if (existingSleepState?.phase === 'sleeping') { try { - const resolvedWorktrees = includeTargetResolvedWorktree( - [...(await this.getResolvedWorktreeMap()).values()], - worktree - ) const refreshedPtyLiveness = await this.refreshPtyWorktreeRecordsFromController( - resolvedWorktrees, + [worktree], worktree.id, - sleepDeadline + sleepDeadline, + inventoryProvider.connectionId ) if (!refreshedPtyLiveness) { throw new Error('terminal_liveness_unavailable') @@ -30576,14 +30933,11 @@ export class OrcaRuntimeService { let fullyCommitted = false let releaseReversibleRendererStops = (): void => {} try { - const resolvedWorktrees = includeTargetResolvedWorktree( - [...(await this.getResolvedWorktreeMap()).values()], - worktree - ) const refreshedPtyLiveness = await this.refreshPtyWorktreeRecordsFromController( - resolvedWorktrees, + [worktree], worktree.id, - sleepDeadline + sleepDeadline, + inventoryProvider.connectionId ) if (!refreshedPtyLiveness) { throw new Error('terminal_liveness_unavailable') @@ -30678,9 +31032,10 @@ export class OrcaRuntimeService { ) const postStopLiveness = await this.refreshPtyWorktreeRecordsFromController( - resolvedWorktrees, + [worktree], worktree.id, - sleepDeadline + sleepDeadline, + inventoryProvider.connectionId ) if (!postStopLiveness) { this.commitWorktreeTerminalSleepPtys({ @@ -30818,9 +31173,19 @@ export class OrcaRuntimeService { if (expected.size !== 1) { throw new Error('terminal_exact_stop_requires_single_pty') } - const resolvedWorktrees = [...(await this.getResolvedWorktreeMap()).values()] - const refreshedPtyLiveness = - await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) + const inventoryProvider = this.resolveScopedPtyControllerInventoryTarget( + worktree.id, + worktree + )?.provider + if (!inventoryProvider) { + throw new Error('terminal_liveness_unavailable') + } + const refreshedPtyLiveness = await this.refreshPtyWorktreeRecordsFromController( + [worktree], + worktree.id, + undefined, + inventoryProvider.connectionId + ) if (!refreshedPtyLiveness) { throw new Error('terminal_liveness_unavailable') } @@ -30856,7 +31221,12 @@ export class OrcaRuntimeService { } stoppedPtyIds.push(ptyId) } - const postStopLiveness = await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) + const postStopLiveness = await this.refreshPtyWorktreeRecordsFromController( + [worktree], + worktree.id, + undefined, + inventoryProvider.connectionId + ) if (!postStopLiveness) { return { stopped: stoppedPtyIds.length, @@ -31191,9 +31561,12 @@ export class OrcaRuntimeService { } } - private resolveFolderWorkspaceConnectionId(workspace: FolderWorkspace): string | null { - const repos = this.store?.getRepos() ?? [] - const projectGroups = this.store?.getProjectGroups?.() ?? [] + private resolveFolderWorkspaceConnectionId( + workspace: FolderWorkspace, + catalog?: { repos: readonly Repo[]; projectGroups: readonly ProjectGroup[] } + ): string | null { + const repos = catalog?.repos ?? this.store?.getRepos() ?? [] + const projectGroups = catalog?.projectGroups ?? this.store?.getProjectGroups?.() ?? [] const connection = inferFolderWorkspacePathConnection({ folderPath: workspace.folderPath, projectGroupId: workspace.projectGroupId, @@ -31912,12 +32285,18 @@ export class OrcaRuntimeService { return this.store as unknown as Store } - private buildResolvedWorktreeFromId(worktreeId: string): ResolvedWorktree | null { + private buildResolvedWorktreeFromId( + worktreeId: string, + knownOwner?: Repo + ): ResolvedWorktree | null { const parsed = splitWorktreeIdForFilesystem(worktreeId) if (!parsed?.repoId || !parsed.worktreePath) { return null } - const repo = this.store?.getRepos().find((entry) => entry.id === parsed.repoId) + const repo = + knownOwner?.id === parsed.repoId + ? knownOwner + : this.store?.getRepos().find((entry) => entry.id === parsed.repoId) const git = { path: parsed.worktreePath, head: '', @@ -31942,43 +32321,6 @@ export class OrcaRuntimeService { } } - private listKnownResolvedWorktreesForExplicitTarget( - targetWorktreeId: string, - targetWorktree: ResolvedWorktree | null - ): ResolvedWorktree[] { - if (!this.store || !targetWorktree) { - return [] - } - const target = splitWorktreeIdForFilesystem(targetWorktreeId) - if (!target?.repoId || !target.worktreePath) { - return [] - } - const worktreeIds = new Set( - Object.keys(this.store.getAllWorktreeMeta()).filter((worktreeId) => { - const parsed = splitWorktreeIdForFilesystem(worktreeId) - return ( - parsed?.repoId === target.repoId && - Boolean(parsed.worktreePath) && - (isPathInsideOrEqual(target.worktreePath, parsed.worktreePath) || - isPathInsideOrEqual(parsed.worktreePath, target.worktreePath)) - ) - }) - ) - worktreeIds.add(targetWorktreeId) - - const resolved: ResolvedWorktree[] = [] - for (const worktreeId of worktreeIds) { - const worktree = - worktreeId === targetWorktreeId - ? targetWorktree - : this.buildResolvedWorktreeFromId(worktreeId) - if (worktree) { - resolved.push(worktree) - } - } - return resolved - } - /** A warm fleet snapshot already answers any selector for free, so scoped scanning must yield to it. */ private hasFreshResolvedWorktreeCache(): boolean { return Boolean(this.resolvedWorktreeCache && this.resolvedWorktreeCache.expiresAt > Date.now()) @@ -32483,16 +32825,248 @@ export class OrcaRuntimeService { return this.recordPtyWorktree(ptyId, inferredWorktreeId) } + /** + * Resolves one workspace's authoritative PTY provider without enumerating the fleet. + * A null provider means the row is known but this runtime cannot prove PTY absence for it. + */ + private resolveScopedPtyControllerInventoryTarget( + targetWorktreeId: string, + resolvedWorktree: ResolvedWorktree | null = null, + requiredConnectionId?: string | null + ): ScopedPtyControllerInventoryTarget | null { + const requiredHostId = + requiredConnectionId === undefined + ? null + : requiredConnectionId === null + ? LOCAL_EXECUTION_HOST_ID + : toSshExecutionHostId(requiredConnectionId) + const workspaceScope = parseWorkspaceKey(targetWorktreeId) + if (workspaceScope?.type === 'folder') { + const folderWorkspace = this.store + ?.getFolderWorkspaces?.() + .find((workspace) => workspace.id === workspaceScope.folderWorkspaceId) + if (!folderWorkspace) { + return null + } + const worktree = resolvedWorktree ?? this.folderWorkspaceToResolvedWorktree(folderWorkspace) + const explicitHost = + folderWorkspace.executionHostId == null + ? null + : parseExecutionHostId(folderWorkspace.executionHostId) + if ( + (folderWorkspace.executionHostId != null && !explicitHost) || + explicitHost?.kind === 'runtime' + ) { + return { worktree, provider: null } + } + let connectionId: string | null + try { + connectionId = this.resolveFolderWorkspaceConnectionId(folderWorkspace) + } catch { + // Mixed child-repo ownership has no single provider whose absence is authoritative. + return { worktree, provider: null } + } + const inferredHostId = connectionId + ? toSshExecutionHostId(connectionId) + : LOCAL_EXECUTION_HOST_ID + if ( + (explicitHost && explicitHost.id !== inferredHostId) || + (requiredHostId && requiredHostId !== inferredHostId) + ) { + return { worktree, provider: null } + } + return { + worktree: + worktree.hostId === inferredHostId ? worktree : { ...worktree, hostId: inferredHostId }, + provider: { connectionId } + } + } + + const parsed = splitWorktreeIdForFilesystem(targetWorktreeId) + if (!parsed?.repoId || !parsed.worktreePath) { + return null + } + const repoOwners = (this.store?.getRepos() ?? []).filter((repo) => repo.id === parsed.repoId) + const requiredOwners = requiredHostId + ? repoOwners.filter((repo) => getRepoExecutionHostId(repo) === requiredHostId) + : repoOwners + let worktree = resolvedWorktree + if (!worktree) { + const cachedMatches = + this.resolvedWorktreeCache && this.resolvedWorktreeCache.expiresAt > Date.now() + ? this.resolvedWorktreeCache.worktrees.filter( + (candidate) => + candidate.id === targetWorktreeId && + (!requiredHostId || candidate.hostId === requiredHostId) + ) + : [] + const cachedHostIds = new Set(cachedMatches.map((candidate) => candidate.hostId)) + if (cachedHostIds.size > 1) { + return null + } + worktree = cachedMatches[0] ?? null + } + const requiredOwnerHostIds = new Set(requiredOwners.map((repo) => getRepoExecutionHostId(repo))) + const repoOwnerHostIds = new Set(repoOwners.map((repo) => getRepoExecutionHostId(repo))) + if (!worktree && requiredOwners.length > 0 && requiredOwnerHostIds.size === 1) { + worktree = this.buildResolvedWorktreeFromId(targetWorktreeId, requiredOwners[0]) + } else if (!worktree && requiredHostId === null && repoOwnerHostIds.size === 1) { + worktree = this.buildResolvedWorktreeFromId(targetWorktreeId, repoOwners[0]) + } + if (!worktree || worktree.id !== targetWorktreeId) { + return null + } + const host = + parseExecutionHostId(worktree.hostId) ?? + (requiredOwnerHostIds.size === 1 + ? parseExecutionHostId(requiredOwnerHostIds.values().next().value!) + : null) + if (!host || host.kind === 'runtime' || (requiredHostId && host.id !== requiredHostId)) { + return { worktree, provider: null } + } + const matchingOwners = repoOwners.filter((repo) => getRepoExecutionHostId(repo) === host.id) + const matchingConnectionIds = new Set( + matchingOwners.map((owner) => owner.connectionId?.trim() || null) + ) + if (matchingConnectionIds.size !== 1) { + return { worktree, provider: null } + } + const connectionId = matchingConnectionIds.values().next().value! + if ( + (host.kind === 'local' && connectionId !== null) || + (host.kind === 'ssh' && connectionId !== host.targetId) || + (requiredConnectionId !== undefined && connectionId !== requiredConnectionId) + ) { + return { worktree, provider: null } + } + return { worktree, provider: { connectionId } } + } + + /** + * Supplies cwd-only legacy sessions with longest-path candidates while keeping + * provider I/O scoped to the exact owner. Every candidate comes from memory or + * persisted identity; this never scans Git or contacts another provider. + */ + private listScopedPtyClassificationWorktrees( + target: ScopedPtyControllerInventoryTarget + ): ResolvedWorktree[] { + if (!target.provider) { + return [target.worktree] + } + const connectionId = target.provider.connectionId + const providerHostId = connectionId + ? toSshExecutionHostId(connectionId) + : LOCAL_EXECUTION_HOST_ID + const repos = this.store?.getRepos() ?? [] + const projectGroups = this.store?.getProjectGroups?.() ?? [] + const repoOwnerById = new Map() + for (const repo of repos) { + if (getRepoExecutionHostId(repo) !== providerHostId || repoOwnerById.has(repo.id)) { + continue + } + repoOwnerById.set(repo.id, repo) + } + + const worktreesById = new Map([[target.worktree.id, target.worktree]]) + const addCandidate = (candidate: ResolvedWorktree | null): void => { + if ( + !candidate || + (!isPathInsideOrEqual(target.worktree.path, candidate.path) && + !isPathInsideOrEqual(candidate.path, target.worktree.path)) + ) { + return + } + const folderScope = parseWorkspaceKey(candidate.id) + if (folderScope?.type === 'folder') { + if (candidate.hostId === providerHostId) { + worktreesById.set(candidate.id, candidate) + } + return + } + const parsed = splitWorktreeIdForFilesystem(candidate.id) + const owner = parsed ? repoOwnerById.get(parsed.repoId) : undefined + if (!owner || (candidate.hostId !== undefined && candidate.hostId !== providerHostId)) { + return + } + worktreesById.set(candidate.id, candidate) + } + + for (const candidate of this.resolvedWorktreeCache?.worktrees ?? []) { + addCandidate(candidate) + } + const providerSession = this.store?.getWorkspaceSession?.(providerHostId) + const knownWorktreeIds = new Set([ + ...Object.keys(this.store?.getAllWorktreeMeta?.() ?? {}), + ...Object.keys(providerSession?.tabsByWorktree ?? {}), + ...this.mobileSessionTabsByWorktree.keys(), + ...[...this.ptysById.values()].map((pty) => pty.worktreeId), + ...[...this.leaves.values()].map((leaf) => leaf.worktreeId) + ]) + for (const owner of repoOwnerById.values()) { + if (owner) { + knownWorktreeIds.add(`${owner.id}${WORKTREE_ID_SEPARATOR}${owner.path}`) + } + } + const folderWorkspaces = this.store?.getFolderWorkspaces?.() ?? [] + const folderOwnerIndex = this.buildFolderWorkspaceOwnerHostIndex( + repos, + projectGroups, + folderWorkspaces + ) + for (const folderWorkspace of folderWorkspaces) { + if ( + (!isPathInsideOrEqual(target.worktree.path, folderWorkspace.folderPath) && + !isPathInsideOrEqual(folderWorkspace.folderPath, target.worktree.path)) || + folderOwnerIndex.unresolvedIds.has(folderWorkspace.id) + ) { + continue + } + const hostIds = folderOwnerIndex.hostIdsById.get(folderWorkspace.id) + if (hostIds?.size !== 1) { + continue + } + const folderHostId = hostIds.values().next().value! + const folderHost = parseExecutionHostId(folderHostId) + if (!folderHost || folderHost.kind === 'runtime') { + continue + } + const folderConnectionId = folderHost.kind === 'ssh' ? folderHost.targetId : null + if (folderConnectionId !== connectionId) { + continue + } + addCandidate({ + ...this.folderWorkspaceToResolvedWorktree(folderWorkspace), + hostId: folderHostId + }) + } + for (const worktreeId of knownWorktreeIds) { + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + // Registered folders were partitioned once above, including folders known + // only through connection/project-group inference. + continue + } + const parsed = splitWorktreeIdForFilesystem(worktreeId) + const owner = parsed ? repoOwnerById.get(parsed.repoId) : undefined + if (owner) { + addCandidate(this.buildResolvedWorktreeFromId(worktreeId, owner)) + } + } + return [...worktreesById.values()] + } + /** Synchronizes PTY tracking records with running daemon sessions, querying their foreground agent states. */ private async refreshPtyWorktreeRecordsFromController( resolvedWorktrees: ResolvedWorktree[], targetWorktreeId: string | null = null, - deadline?: number + deadline?: number, + connectionId?: string | null ): Promise | null> { const inventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( resolvedWorktrees, targetWorktreeId, - deadline + deadline, + connectionId ) return inventory ? new Set(inventory.livePtyIds) : null } @@ -32610,7 +33184,21 @@ export class OrcaRuntimeService { for (const ptyId of ambiguousControllerPtyIds) { controllerIdentityByPtyId.delete(ptyId) } - const findResolvedWorktree = createIncrementalResolvedWorktreeLookup(resolvedWorktrees) + const targetResolvedWorktree = targetWorktreeId + ? resolvedWorktrees.find((worktree) => runtimeWorktreeIdsEqual(worktree.id, targetWorktreeId)) + : undefined + const scopedTarget = + targetWorktreeId && targetResolvedWorktree + ? this.resolveScopedPtyControllerInventoryTarget( + targetWorktreeId, + targetResolvedWorktree, + connectionId + ) + : null + const classificationWorktrees = scopedTarget?.provider + ? this.listScopedPtyClassificationWorktrees(scopedTarget) + : resolvedWorktrees + const findResolvedWorktree = createIncrementalResolvedWorktreeLookup(classificationWorktrees) const persistedIndexesByHostId = new Map< ExecutionHostId, { @@ -32665,7 +33253,7 @@ export class OrcaRuntimeService { : (session.worktreeId ?? persistedWorktree?.id ?? inferredWorktreeId ?? - findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd, targetWorktreeId)) + findResolvedWorktreeIdForPath(classificationWorktrees, session.cwd, targetWorktreeId)) const persistedSurface = persistedIndexes.surfaceByPtyId.get(session.id) const restoresExactSurface = persistedSurface && @@ -32726,6 +33314,21 @@ export class OrcaRuntimeService { if (connectionId !== undefined && pty.connectionId !== connectionId) { continue } + const ptyHostFromId = getPtyExecutionHost(pty.ptyId) + if (ptyHostFromId === 'foreign') { + continue + } + const ptyHostId = + ptyHostFromId ?? + (pty.connectionId + ? toSshExecutionHostId(pty.connectionId) + : (this.tryGetWorkspaceSessionHostIdForWorktree(pty.worktreeId) ?? + LOCAL_EXECUTION_HOST_ID)) + if (!queriedHostIds.has(ptyHostId)) { + // A partial aggregate names only providers that answered. Silence from any + // other provider is unknown, never an authoritative absence. + continue + } if (!allLivePtyIds.has(pty.ptyId) && !this.leafExistsForPty(pty.ptyId)) { const currentVerdict = this.ptyLivenessVerdictByPtyId.get(pty.ptyId) if ( @@ -32733,8 +33336,8 @@ export class OrcaRuntimeService { currentVerdict.observedAt > livenessObservationAtStart && currentVerdict.verdict.status === 'unverifiable' ) { - pty.connected = false - pty.disconnectedAt ??= Date.now() + // A newer provider failure outranks this older list response. Keep the + // prior lifecycle state until the owning provider answers again. continue } const observed = this.ptyController.hasPty?.(pty.ptyId) @@ -32752,28 +33355,33 @@ export class OrcaRuntimeService { this.forgetPtyLivenessVerdict(pty.ptyId) continue } + if (observed === null) { + // The exact provider inventory already answered. A missing per-ID rescue + // cannot revoke that authoritative absence; retain the diagnostic only. + this.markPtyLivenessUnverifiable(pty.ptyId, NO_OBSERVING_PROVIDER_REASON) + } pty.connected = false pty.disconnectedAt ??= Date.now() - // Why: this list only enumerates registered providers, so a dropped relay - // clears `connected` for every one of its PTYs at once. Only `false` here - // is an observed absence; `null` means no provider could be asked. if (observed === false) { this.forgetPtyLivenessVerdict(pty.ptyId) - } else if (observed === null) { - this.markPtyLivenessUnverifiable(pty.ptyId, NO_OBSERVING_PROVIDER_REASON) } } } // Why: runs after the hasPty rescue so a still-addressable pane keeps its receipt. - // A provider that failed to list is absent from `sessions`, and dropping authority on - // that silence would retire an orchestration handle the relay can still reach. + // A provider that failed to list is absent from queriedHostIds, and dropping + // authority on that silence would retire a handle its relay can still reach. for (const [ptyId, receipt] of this.restoredOrchestrationAuthorityByPtyId) { + const receiptHostId = + receipt.hostScope.kind === 'ssh' + ? toSshExecutionHostId(receipt.hostScope.targetId) + : LOCAL_EXECUTION_HOST_ID const inScope = - connectionId === undefined || - (connectionId === null && receipt.hostScope.kind !== 'ssh') || - (typeof connectionId === 'string' && - receipt.hostScope.kind === 'ssh' && - receipt.hostScope.targetId === connectionId) + queriedHostIds.has(receiptHostId) && + (connectionId === undefined || + (connectionId === null && receipt.hostScope.kind !== 'ssh') || + (typeof connectionId === 'string' && + receipt.hostScope.kind === 'ssh' && + receipt.hostScope.targetId === connectionId)) if (inScope && !allLivePtyIds.has(ptyId)) { this.restoredOrchestrationAuthorityByPtyId.delete(ptyId) } @@ -33075,8 +33683,9 @@ export class OrcaRuntimeService { notify: false }) // Why: graph sync must scan each persisted host session once, not once per workspace. + const ownerIndex = this.buildWorkspaceExecutionOwnerIndex() const worktreeSessionsToHydrate = new Map( - this.getWorkspaceSessionHydrationTargets(Boolean(this.offscreenBrowserBackend)) + this.getWorkspaceSessionHydrationTargets(Boolean(this.offscreenBrowserBackend), ownerIndex) ) if (this.offscreenBrowserBackend) { for (const snapshot of snapshots) { @@ -33090,6 +33699,7 @@ export class OrcaRuntimeService { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, onlyRuntimeOwnedTerminals: true, + ownerIndex, ...(workspaceSession ? { runtimeOwnedTerminalCandidateKnown: true, workspaceSession } : {}) }) } @@ -40717,16 +41327,6 @@ function runtimeWorktreeSummaryPathKey( return `${repoId}\0${worktreePathComparisonKey(worktreePath, platform)}` } -function includeTargetResolvedWorktree( - resolvedWorktrees: ResolvedWorktree[], - targetWorktree: ResolvedWorktree | null -): ResolvedWorktree[] { - if (!targetWorktree || resolvedWorktrees.some((worktree) => worktree.id === targetWorktree.id)) { - return resolvedWorktrees - } - return [...resolvedWorktrees, targetWorktree] -} - function findResolvedWorktreeIdForPath( resolvedWorktrees: ResolvedWorktree[], cwd: string, diff --git a/src/main/runtime/pty-inventory-liveness-verdict.test.ts b/src/main/runtime/pty-inventory-liveness-verdict.test.ts index 38e3dda20eb..f7445ba14ca 100644 --- a/src/main/runtime/pty-inventory-liveness-verdict.test.ts +++ b/src/main/runtime/pty-inventory-liveness-verdict.test.ts @@ -25,6 +25,7 @@ function makeStore() { setWorkspaceSession: vi.fn(), getRepos: vi.fn(() => [ { + connectionId: 'conn-1', id: 'repo-1', path: '/tmp/inventory-verdict', displayName: 'inventory-verdict', diff --git a/src/main/runtime/pty-inventory-runtime-folder-guard.test.ts b/src/main/runtime/pty-inventory-runtime-folder-guard.test.ts new file mode 100644 index 00000000000..2dfe729210b --- /dev/null +++ b/src/main/runtime/pty-inventory-runtime-folder-guard.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { folderWorkspaceKey } from '../../shared/workspace-scope' +import { SSH_FOLDER, makeHarness } from './pty-inventory-scoped-routing-fixtures' + +describe('runtime-owned folder mobile authority', () => { + it('never materializes a pending runtime folder tab through local or SSH PTY providers', async () => { + const harness = makeHarness() + const runtimeFolder = { + ...SSH_FOLDER, + id: 'folder-runtime-pending', + name: 'Runtime pending folder', + folderPath: '/runtime/pending-folder', + connectionId: null, + executionHostId: 'runtime:environment-1' + } + harness.folderWorkspaces.push(runtimeFolder) + const worktreeId = folderWorkspaceKey(runtimeFolder.id) + const ptyId = 'runtime-pending-pty' + harness.session.tabsByWorktree[worktreeId] = [ + { + id: 'runtime-pending-tab', + ptyId, + worktreeId, + title: 'Runtime pending', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + + const listed = await harness.runtime.listMobileSessionTabs(`id:${worktreeId}`) + expect(listed.tabs.some((tab) => tab.type === 'terminal' && tab.ptyId === ptyId)).toBe(true) + await expect( + harness.runtime.activateMobileSessionTab(`id:${worktreeId}`, 'runtime-pending-tab') + ).rejects.toThrow('terminal_unavailable') + + expect(harness.spawn).not.toHaveBeenCalled() + expect(harness.listProcesses).not.toHaveBeenCalled() + expect(harness.listProcessesWithHostScope).not.toHaveBeenCalled() + expect(harness.providers.local).not.toHaveBeenCalled() + expect(harness.providers['box-a']).not.toHaveBeenCalled() + expect(harness.providers['box-b']).not.toHaveBeenCalled() + expect(harness.providers['box-c']).not.toHaveBeenCalled() + expect(harness.internals.mobileSessionTabsByWorktree.has(worktreeId)).toBe(true) + }) +}) diff --git a/src/main/runtime/pty-inventory-scoped-routing-fixture-types.ts b/src/main/runtime/pty-inventory-scoped-routing-fixture-types.ts new file mode 100644 index 00000000000..b6ee6b66c0b --- /dev/null +++ b/src/main/runtime/pty-inventory-scoped-routing-fixture-types.ts @@ -0,0 +1,211 @@ +export const LOCAL_WORKTREE_ID = 'repo-local::/local/project' +export const WSL_WORKTREE_ID = 'repo-wsl::C:\\work\\project' +export const SSH_A_WORKTREE_ID = 'repo-ssh-a::/srv/a/project' +export const SSH_B_WORKTREE_ID = 'repo-ssh-b::/srv/b/project' +export const SSH_C_WORKTREE_ID = 'repo-ssh-c::/srv/c/project' +export const RUNTIME_WORKTREE_ID = 'repo-runtime::/srv/runtime/project' + +export type FolderFixture = { + id: string + projectGroupId: string + name: string + folderPath: string + connectionId?: string | null + executionHostId?: string | null + linkedTask: null + comment: string + isArchived: boolean + isUnread: boolean + isPinned: boolean + sortOrder: number + lastActivityAt: number + createdAt: number + updatedAt: number +} + +export type ProjectGroupFixture = { + id: string + name: string + parentPath: string | null + parentGroupId: string | null + createdFrom: 'manual' + tabOrder: number + isCollapsed: boolean + color: string | null + createdAt: number + updatedAt: number +} + +export const SSH_FOLDER: FolderFixture = { + id: 'folder-ssh-b', + projectGroupId: 'group-1', + name: 'SSH B folder', + folderPath: '/srv/b/folder', + connectionId: 'box-b', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 +} + +export type RepoFixture = { + id: string + path: string + displayName: string + badgeColor: string + addedAt: number + connectionId?: string + executionHostId?: 'local' | `runtime:${string}` + projectGroupId?: string +} + +export type ResolvedWorktreeFixture = { + id: string + repoId: string + hostId: string + path: string + head: string + branch: string + isBare: boolean + isMainWorktree: boolean + displayName: string + comment: string + linkedIssue: null + linkedPR: null + linkedLinearIssue: null + linkedLinearIssueWorkspaceId: null + linkedLinearIssueOrganizationUrlKey: null + linkedGitLabMR: null + linkedGitLabIssue: null + linkedBitbucketPR: null + linkedAzureDevOpsPR: null + linkedGiteaPR: null + linkedWorkItem: null + linkedTaskSourceContext: null + isArchived: boolean + isUnread: boolean + isPinned: boolean + sortOrder: number + lastActivityAt: number + parentWorktreeId: null + childWorktreeIds: string[] + lineage: null + git: { + path: string + head: string + branch: string + isBare: boolean + isMainWorktree: boolean + } +} + +export const REPOS: RepoFixture[] = [ + { + id: 'repo-local', + path: '/local/project', + displayName: 'local', + badgeColor: '#000000', + addedAt: 0 + }, + { + id: 'repo-wsl', + path: 'C:\\work\\project', + displayName: 'wsl', + badgeColor: '#000000', + addedAt: 0, + executionHostId: 'local' as const + }, + ...(['a', 'b', 'c'] as const).map((suffix) => ({ + id: `repo-ssh-${suffix}`, + path: `/srv/${suffix}/project`, + displayName: `ssh-${suffix}`, + badgeColor: '#000000', + addedAt: 0, + connectionId: `box-${suffix}` + })), + { + id: 'repo-runtime', + path: '/srv/runtime/project', + displayName: 'runtime', + badgeColor: '#000000', + addedAt: 0, + executionHostId: 'runtime:environment-1' as const + } +] + +export function resolvedWorktree( + id: string, + repoId: string, + path: string, + hostId: string +): ResolvedWorktreeFixture { + const git = { + path, + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + return { + id, + repoId, + hostId, + ...git, + displayName: repoId, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, + linkedGiteaPR: null, + linkedWorkItem: null, + linkedTaskSourceContext: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + git + } +} + +export type ProviderKey = 'local' | 'box-a' | 'box-b' | 'box-c' +export type ListedSession = { id: string; cwd: string; worktreeId?: string; wslDistro?: string } + +export type ScopedTargetFixture = { + worktree: ResolvedWorktreeFixture + provider: { connectionId: string | null } | null +} + +export type RuntimeInternals = { + resolvedWorktreeCache: { + worktrees: ResolvedWorktreeFixture[] + platformByRepoId: Map + expiresAt: number + } | null + ptysById: Map + mobileSessionTabsByWorktree: Map + resolveScopedPtyControllerInventoryTarget: ( + worktreeId: string, + worktree?: ResolvedWorktreeFixture | null, + connectionId?: string | null + ) => ScopedTargetFixture | null + listScopedPtyClassificationWorktrees: (target: ScopedTargetFixture) => ResolvedWorktreeFixture[] + publishRecoveredSshMobileSessionTabs: (targetId: string, generation: number) => Promise + sshRelayRecoveryGenerationByTargetId: Map + resolveFolderWorkspaceConnectionId: (folderWorkspace: FolderFixture) => string | null + notifyMobileSessionTabsChangedNow: (worktreeId: string) => void +} diff --git a/src/main/runtime/pty-inventory-scoped-routing-fixtures.ts b/src/main/runtime/pty-inventory-scoped-routing-fixtures.ts new file mode 100644 index 00000000000..5acd81d36e2 --- /dev/null +++ b/src/main/runtime/pty-inventory-scoped-routing-fixtures.ts @@ -0,0 +1,209 @@ +import { vi, type Mock } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { OrcaRuntimeService } from './orca-runtime' +import { + LOCAL_WORKTREE_ID, + REPOS, + RUNTIME_WORKTREE_ID, + SSH_A_WORKTREE_ID, + SSH_B_WORKTREE_ID, + SSH_C_WORKTREE_ID, + SSH_FOLDER, + WSL_WORKTREE_ID, + resolvedWorktree, + type FolderFixture, + type ListedSession, + type ProjectGroupFixture, + type ProviderKey, + type RepoFixture, + type ResolvedWorktreeFixture, + type RuntimeInternals +} from './pty-inventory-scoped-routing-fixture-types' +export { + LOCAL_WORKTREE_ID, + RUNTIME_WORKTREE_ID, + SSH_A_WORKTREE_ID, + SSH_B_WORKTREE_ID, + SSH_C_WORKTREE_ID, + SSH_FOLDER, + WSL_WORKTREE_ID, + resolvedWorktree, + type ProviderKey +} from './pty-inventory-scoped-routing-fixture-types' + +type RoutingHarness = { + runtime: OrcaRuntimeService + internals: RuntimeInternals + store: { + getWorkspaceSession: Mock<() => ReturnType> + getWorkspaceSessionHostIds: Mock<() => string[]> + getFolderWorkspaces: Mock<() => FolderFixture[]> + getProjectGroups: Mock<() => ProjectGroupFixture[]> + getRepos: Mock<() => RepoFixture[]> + getRepo: Mock<(id: string) => RepoFixture | undefined> + getAllWorktreeMeta: Mock<() => Record>> + } + repos: RepoFixture[] + session: ReturnType + meta: Record> + worktrees: ResolvedWorktreeFixture[] + folderWorkspaces: FolderFixture[] + projectGroups: ProjectGroupFixture[] + kill: Mock<(ptyId: string) => boolean> + spawn: Mock<() => Promise<{ id: string }>> + sessions: Record + failures: Set + providers: Record Promise>> + listProcesses: Mock<(connectionId?: string | null, options?: unknown) => Promise> + listProcessesWithHostScope: Mock<() => Promise<{ processes: ListedSession[]; hostIds: string[] }>> + clearProviderCalls: () => void +} + +function createHarness(): RoutingHarness { + const session = getDefaultWorkspaceSession() + const repos = [...REPOS] + const meta: Record> = {} + const folderWorkspaces: FolderFixture[] = [SSH_FOLDER] + const projectGroups: ProjectGroupFixture[] = [] + const worktrees = [ + resolvedWorktree(LOCAL_WORKTREE_ID, 'repo-local', '/local/project', 'local'), + resolvedWorktree(WSL_WORKTREE_ID, 'repo-wsl', 'C:\\work\\project', 'local'), + resolvedWorktree(SSH_A_WORKTREE_ID, 'repo-ssh-a', '/srv/a/project', 'ssh:box-a'), + resolvedWorktree(SSH_B_WORKTREE_ID, 'repo-ssh-b', '/srv/b/project', 'ssh:box-b'), + resolvedWorktree(SSH_C_WORKTREE_ID, 'repo-ssh-c', '/srv/c/project', 'ssh:box-c'), + resolvedWorktree( + RUNTIME_WORKTREE_ID, + 'repo-runtime', + '/srv/runtime/project', + 'runtime:environment-1' + ) + ] + const store = { + getWorkspaceSession: vi.fn(() => session), + getWorkspaceSessionHostIds: vi.fn(() => [ + 'local', + 'ssh:box-a', + 'ssh:box-b', + 'ssh:box-c', + 'runtime:environment-1' + ]), + getFolderWorkspaces: vi.fn(() => folderWorkspaces), + getProjectGroups: vi.fn(() => projectGroups), + getRepos: vi.fn(() => repos), + getRepo: vi.fn((id: string) => repos.find((repo) => repo.id === id)), + getAllWorktreeMeta: vi.fn(() => meta), + getWorktreeMeta: vi.fn((worktreeId: string) => meta[worktreeId]), + setWorktreeMeta: vi.fn((worktreeId: string, patch: Record) => { + meta[worktreeId] = { ...meta[worktreeId], ...patch } + return meta[worktreeId] + }), + removeWorktreeMeta: vi.fn(), + setWorkspaceSession: vi.fn(), + getSettings: vi.fn(() => ({ workspaceDir: '/tmp/workspaces' })), + getProjects: vi.fn(() => []) + } + const sessions: Record = { + local: [], + 'box-a': [], + 'box-b': [], + 'box-c': [] + } + const failures = new Set() + const providers = { + local: vi.fn(async () => sessions.local), + 'box-a': vi.fn(async () => { + if (failures.has('box-a')) { + throw new Error('box-a unavailable') + } + return sessions['box-a'] + }), + 'box-b': vi.fn(async () => { + if (failures.has('box-b')) { + throw new Error('box-b unavailable') + } + return sessions['box-b'] + }), + 'box-c': vi.fn(async () => { + if (failures.has('box-c')) { + throw new Error('box-c unavailable') + } + return sessions['box-c'] + }) + } + const listProcesses = vi.fn(async (connectionId?: string | null) => { + if (connectionId === null) { + return await providers.local() + } + if (connectionId === 'box-a' || connectionId === 'box-b' || connectionId === 'box-c') { + return await providers[connectionId]() + } + throw new Error(`unexpected aggregate fallback: ${String(connectionId)}`) + }) + const kill = vi.fn(() => true) + const listProcessesWithHostScope = vi.fn(async () => { + const local = await providers.local() + const remoteResults = await Promise.all( + (['box-a', 'box-b', 'box-c'] as const).map(async (connectionId) => { + try { + return { connectionId, sessions: await providers[connectionId]() } + } catch { + return null + } + }) + ) + const available = remoteResults.filter((result) => result !== null) + return { + processes: [local, ...available.map((result) => result.sessions)].flat(), + hostIds: ['local', ...available.map((result) => `ssh:${result.connectionId}`)] + } + }) + const spawn = vi.fn(async () => ({ id: 'never' })) + const runtime = new OrcaRuntimeService(store as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill, + hasPty: () => null, + getForegroundProcess: async () => null, + listProcesses, + listProcessesWithHostScope + } as never) + const internals = runtime as unknown as RuntimeInternals + internals.resolvedWorktreeCache = { + worktrees, + platformByRepoId: new Map(), + expiresAt: Number.POSITIVE_INFINITY + } + + const clearProviderCalls = () => { + for (const provider of Object.values(providers)) { + provider.mockClear() + } + listProcesses.mockClear() + listProcessesWithHostScope.mockClear() + } + + return { + runtime, + internals: runtime as unknown as RuntimeInternals, + store, + repos, + session, + meta, + worktrees, + folderWorkspaces, + projectGroups, + kill, + spawn, + sessions, + failures, + providers, + listProcesses, + listProcessesWithHostScope, + clearProviderCalls + } +} + +export function makeHarness(): ReturnType { + return createHarness() +} diff --git a/src/main/runtime/pty-inventory-scoped-routing.test.ts b/src/main/runtime/pty-inventory-scoped-routing.test.ts new file mode 100644 index 00000000000..df4cfda3eaa --- /dev/null +++ b/src/main/runtime/pty-inventory-scoped-routing.test.ts @@ -0,0 +1,775 @@ +import { describe, expect, it, vi } from 'vitest' +import { folderWorkspaceKey } from '../../shared/workspace-scope' +import { + LOCAL_WORKTREE_ID, + RUNTIME_WORKTREE_ID, + SSH_A_WORKTREE_ID, + SSH_B_WORKTREE_ID, + SSH_C_WORKTREE_ID, + SSH_FOLDER, + WSL_WORKTREE_ID, + makeHarness, + resolvedWorktree, + type ProviderKey +} from './pty-inventory-scoped-routing-fixtures' + +describe('scoped PTY inventory routing', () => { + it('queries only the exact local, WSL-local, SSH, or folder owner and fans out only when unscoped', async () => { + const harness = makeHarness() + harness.sessions.local.push( + { id: 'pty-local', cwd: '/local/project', worktreeId: LOCAL_WORKTREE_ID }, + { + id: 'pty-wsl', + cwd: '/mnt/c/work/project', + worktreeId: WSL_WORKTREE_ID, + wslDistro: 'Ubuntu' + } + ) + + const local = await harness.runtime.listTerminals(`id:${LOCAL_WORKTREE_ID}`) + expect(harness.providers.local).toHaveBeenCalledOnce() + expect(harness.providers['box-a']).not.toHaveBeenCalled() + expect(harness.providers['box-b']).not.toHaveBeenCalled() + expect(harness.providers['box-c']).not.toHaveBeenCalled() + expect(local.terminals.map((terminal) => terminal.ptyId)).toEqual(['pty-local']) + + harness.clearProviderCalls() + const wsl = await harness.runtime.listTerminals(`id:${WSL_WORKTREE_ID}`) + expect(harness.providers.local).toHaveBeenCalledOnce() + expect(harness.listProcesses).toHaveBeenCalledWith(null, expect.any(Object)) + expect(wsl.terminals.map((terminal) => terminal.ptyId)).toEqual(['pty-wsl']) + + harness.clearProviderCalls() + await harness.runtime.listTerminals(`id:${SSH_A_WORKTREE_ID}`) + expect(harness.providers['box-a']).toHaveBeenCalledOnce() + expect(harness.providers.local).not.toHaveBeenCalled() + expect(harness.providers['box-b']).not.toHaveBeenCalled() + expect(harness.providers['box-c']).not.toHaveBeenCalled() + + harness.clearProviderCalls() + await harness.runtime.listTerminals(`id:${folderWorkspaceKey(SSH_FOLDER.id)}`) + expect(harness.providers['box-b']).toHaveBeenCalledOnce() + expect(harness.providers.local).not.toHaveBeenCalled() + expect(harness.providers['box-a']).not.toHaveBeenCalled() + expect(harness.providers['box-c']).not.toHaveBeenCalled() + + harness.clearProviderCalls() + await harness.runtime.listMobileSessionTabs(`id:${SSH_C_WORKTREE_ID}`) + expect(harness.providers['box-c']).toHaveBeenCalledOnce() + expect(harness.providers.local).not.toHaveBeenCalled() + expect(harness.providers['box-a']).not.toHaveBeenCalled() + expect(harness.providers['box-b']).not.toHaveBeenCalled() + + harness.clearProviderCalls() + await harness.runtime.listTerminals() + expect(harness.listProcessesWithHostScope).toHaveBeenCalledOnce() + expect(harness.providers.local).toHaveBeenCalledOnce() + expect(harness.providers['box-a']).toHaveBeenCalledOnce() + expect(harness.providers['box-b']).toHaveBeenCalledOnce() + expect(harness.providers['box-c']).toHaveBeenCalledOnce() + }) + + it('keeps runtime-owned targets omitted without querying a local or SSH provider', async () => { + const harness = makeHarness() + + const result = await harness.runtime.listTerminals(`id:${RUNTIME_WORKTREE_ID}`) + + expect(harness.listProcesses).not.toHaveBeenCalled() + expect(harness.listProcessesWithHostScope).not.toHaveBeenCalled() + expect(result.hostScope?.hostIds).toEqual([]) + expect(result.hostScope?.omittedHostIds).toContain('runtime:environment-1') + }) + + it('isolates provider failures and never retires a PTY from an unqueried or unavailable owner', async () => { + const harness = makeHarness() + const sshAPty = 'ssh:box-a@@pty-a' + const sshBPty = 'ssh:box-b@@pty-b' + const sshCPty = 'ssh:box-c@@pty-c' + harness.sessions['box-a'].push({ + id: sshAPty, + cwd: '/srv/a/project', + worktreeId: SSH_A_WORKTREE_ID + }) + harness.runtime.registerPty(sshAPty, SSH_A_WORKTREE_ID, 'box-a') + harness.runtime.registerPty(sshCPty, SSH_C_WORKTREE_ID, 'box-c') + harness.failures.add('box-c') + + const scoped = await harness.runtime.listTerminals(`id:${SSH_A_WORKTREE_ID}`) + expect(scoped.terminals.map((terminal) => terminal.ptyId)).toContain(sshAPty) + expect(harness.providers['box-a']).toHaveBeenCalledOnce() + expect(harness.providers['box-c']).not.toHaveBeenCalled() + + harness.clearProviderCalls() + await harness.runtime.listTerminals(`id:${LOCAL_WORKTREE_ID}`) + expect(harness.internals.ptysById.get(sshCPty)?.connected).toBe(true) + expect(harness.providers['box-c']).not.toHaveBeenCalled() + + harness.clearProviderCalls() + await harness.runtime.listTerminals() + expect(harness.providers['box-c']).toHaveBeenCalledOnce() + expect(harness.internals.ptysById.get(sshCPty)?.connected).toBe(true) + + harness.clearProviderCalls() + harness.runtime.registerPty(sshBPty, SSH_B_WORKTREE_ID, 'box-b') + harness.failures.add('box-b') + const unavailableOwner = await harness.runtime.listTerminals(`id:${SSH_B_WORKTREE_ID}`) + expect(unavailableOwner.terminals.map((terminal) => terminal.ptyId)).toContain(sshBPty) + expect(harness.providers['box-b']).toHaveBeenCalledOnce() + expect(harness.internals.ptysById.get(sshBPty)?.connected).toBe(true) + }) + + it('classifies cwd-only nested PTYs against cold-cache same-provider paths only', async () => { + const harness = makeHarness() + harness.internals.resolvedWorktreeCache = null + const localChildId = 'repo-local-child::/local/project/nested' + const wslChildId = 'repo-wsl-child::C:\\work\\project\\nested' + const sshChildId = 'repo-ssh-a-child::/srv/a/project/nested' + const folderChildId = 'repo-folder-child::/srv/b/folder/nested' + harness.repos.push( + { + id: 'repo-shadow-other-host', + path: '/srv/a/project/nested', + displayName: 'other-host-shadow', + badgeColor: '#000000', + addedAt: 0, + connectionId: 'box-c' + }, + { + id: 'repo-local-child', + path: '/local/project/nested', + displayName: 'local-child', + badgeColor: '#000000', + addedAt: 0 + }, + { + id: 'repo-wsl-child', + path: 'C:\\work\\project\\nested', + displayName: 'wsl-child', + badgeColor: '#000000', + addedAt: 0, + executionHostId: 'local' + }, + { + id: 'repo-ssh-a-child', + path: '/srv/a/project/nested', + displayName: 'ssh-a-child', + badgeColor: '#000000', + addedAt: 0, + connectionId: 'box-a' + }, + { + id: 'repo-folder-child', + path: '/srv/b/folder/nested', + displayName: 'folder-child', + badgeColor: '#000000', + addedAt: 0, + connectionId: 'box-b' + } + ) + const cases: { + label: string + provider: ProviderKey + parentId: string + childId: string + cwd: string + ptyId: string + }[] = [ + { + label: 'local', + provider: 'local', + parentId: LOCAL_WORKTREE_ID, + childId: localChildId, + cwd: '/local/project/nested/src', + ptyId: 'pty-local-nested' + }, + { + label: 'WSL-local', + provider: 'local', + parentId: WSL_WORKTREE_ID, + childId: wslChildId, + cwd: 'C:\\work\\project\\nested\\src', + ptyId: 'pty-wsl-nested' + }, + { + label: 'SSH', + provider: 'box-a', + parentId: SSH_A_WORKTREE_ID, + childId: sshChildId, + cwd: '/srv/a/project/nested/src', + ptyId: 'ssh:box-a@@nested' + }, + { + label: 'folder SSH', + provider: 'box-b', + parentId: folderWorkspaceKey(SSH_FOLDER.id), + childId: folderChildId, + cwd: '/srv/b/folder/nested/src', + ptyId: 'ssh:box-b@@folder-nested' + } + ] + + for (const nested of cases) { + harness.sessions[nested.provider].splice(0, Number.POSITIVE_INFINITY, { + id: nested.ptyId, + cwd: nested.cwd + }) + const parent = await harness.runtime.listTerminals(`id:${nested.parentId}`) + expect( + parent.terminals.map((terminal) => terminal.ptyId), + `${nested.label} parent must not adopt its nested child's PTY` + ).not.toContain(nested.ptyId) + + const child = await harness.runtime.listTerminals(`id:${nested.childId}`) + expect( + child.terminals.map((terminal) => terminal.ptyId), + `${nested.label} child must retain its cwd-only PTY` + ).toContain(nested.ptyId) + } + + const sshTarget = harness.internals.resolveScopedPtyControllerInventoryTarget( + SSH_A_WORKTREE_ID, + null, + 'box-a' + ) + expect(sshTarget).not.toBeNull() + const sshCandidates = harness.internals.listScopedPtyClassificationWorktrees(sshTarget!) + expect(sshCandidates.map((candidate) => candidate.id)).toContain(sshChildId) + expect(sshCandidates.map((candidate) => candidate.id)).not.toContain( + 'repo-shadow-other-host::/srv/a/project/nested' + ) + }) + + it('partitions duplicate-path and nested project-group folders by owning host', () => { + const harness = makeHarness() + harness.internals.resolvedWorktreeCache = null + harness.projectGroups.push( + { + id: 'group-root', + name: 'Root', + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 0, + updatedAt: 0 + }, + { + id: 'group-child', + name: 'Child', + parentPath: null, + parentGroupId: 'group-root', + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 0, + updatedAt: 0 + } + ) + harness.repos.push( + { + id: 'repo-local-folder-shadow', + path: '/srv/a/project/duplicate-folder', + displayName: 'local folder shadow', + badgeColor: '#000000', + addedAt: 0 + }, + { + id: 'repo-group-descendant', + path: '/outside/group-repo', + displayName: 'group repo', + badgeColor: '#000000', + addedAt: 0, + connectionId: 'box-a', + projectGroupId: 'group-child' + } + ) + const folderBase = { + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + } as const + harness.folderWorkspaces.push( + { + ...folderBase, + id: 'folder-local-shadow', + projectGroupId: 'unused-local', + name: 'Local shadow', + folderPath: '/srv/a/project/duplicate-folder' + }, + { + ...folderBase, + id: 'folder-ssh-owner', + projectGroupId: 'unused-ssh', + name: 'SSH owner', + folderPath: '/srv/a/project/duplicate-folder', + connectionId: 'box-a' + }, + { + ...folderBase, + id: 'folder-group-owner', + projectGroupId: 'group-root', + name: 'Group owner', + folderPath: '/srv/a/project/group-folder' + } + ) + const target = harness.internals.resolveScopedPtyControllerInventoryTarget( + SSH_A_WORKTREE_ID, + null, + 'box-a' + ) + expect(target).not.toBeNull() + + const candidateIds = harness.internals + .listScopedPtyClassificationWorktrees(target!) + .map((candidate) => candidate.id) + + expect(candidateIds).toContain(folderWorkspaceKey('folder-ssh-owner')) + expect(candidateIds).toContain(folderWorkspaceKey('folder-group-owner')) + expect(candidateIds).not.toContain(folderWorkspaceKey('folder-local-shadow')) + }) + + it('builds large classifier and mobile owner indexes with linear catalog passes', async () => { + const harness = makeHarness() + harness.internals.resolvedWorktreeCache = null + const scale = 250 + for (let index = 0; index < scale; index += 1) { + const repoId = `repo-scale-${index}` + const path = `/local/project/scale-${index}` + const worktreeId = `${repoId}::${path}` + harness.repos.push({ + id: repoId, + path, + displayName: repoId, + badgeColor: '#000000', + addedAt: 0 + }) + harness.meta[worktreeId] = { hostId: 'local' } + harness.folderWorkspaces.push({ + id: `folder-scale-${index}`, + projectGroupId: `unused-group-${index}`, + name: `Folder ${index}`, + folderPath: path, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + }) + } + const target = harness.internals.resolveScopedPtyControllerInventoryTarget( + LOCAL_WORKTREE_ID, + null, + null + ) + expect(target).not.toBeNull() + const resolver = vi.spyOn(harness.internals, 'resolveScopedPtyControllerInventoryTarget') + harness.store.getRepos.mockClear() + harness.store.getAllWorktreeMeta.mockClear() + harness.store.getFolderWorkspaces.mockClear() + + const candidates = harness.internals.listScopedPtyClassificationWorktrees(target!) + + expect(candidates).toHaveLength(scale * 2 + 1) + expect(resolver).not.toHaveBeenCalled() + expect(harness.store.getRepos).toHaveBeenCalledOnce() + expect(harness.store.getAllWorktreeMeta).toHaveBeenCalledOnce() + expect(harness.store.getFolderWorkspaces).toHaveBeenCalledOnce() + const folderResolver = vi.spyOn(harness.internals, 'resolveFolderWorkspaceConnectionId') + const ptyId = 'pty-scale-mobile' + harness.session.tabsByWorktree[LOCAL_WORKTREE_ID] = [ + { + id: 'scale-mobile-tab', + ptyId, + worktreeId: LOCAL_WORKTREE_ID, + title: 'Scale mobile', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + harness.sessions.local.push({ id: ptyId, cwd: '/local/project', worktreeId: LOCAL_WORKTREE_ID }) + harness.store.getRepos.mockClear() + harness.store.getProjectGroups.mockClear() + harness.store.getFolderWorkspaces.mockClear() + harness.internals.resolvedWorktreeCache = { + worktrees: harness.worktrees, + platformByRepoId: new Map(), + expiresAt: Number.POSITIVE_INFINITY + } + + await harness.runtime.listMobileSessionTabs(`id:${LOCAL_WORKTREE_ID}`) + await harness.runtime.activateMobileSessionTab(`id:${LOCAL_WORKTREE_ID}`, 'scale-mobile-tab') + await harness.runtime.listAllMobileSessionTabs() + harness.runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + + expect(folderResolver).not.toHaveBeenCalled() + expect(harness.store.getRepos.mock.calls.length).toBeLessThanOrEqual(32) + expect(harness.store.getProjectGroups.mock.calls.length).toBeLessThanOrEqual(16) + expect(harness.store.getFolderWorkspaces.mock.calls.length).toBeLessThanOrEqual(16) + }) + + it('allows distinct full worktree ids that share a repo id across hosts', async () => { + const harness = makeHarness() + const repoId = 'repo-split-owner' + const localWorktreeId = `${repoId}::/local/split-owner` + const sshWorktreeId = `${repoId}::/remote/split-owner` + const localPty = 'pty-split-local' + const sshPty = 'ssh:box-a@@split-remote' + harness.repos.push( + { + id: repoId, + path: '/local/split-owner', + displayName: 'split local', + badgeColor: '#000000', + addedAt: 0 + }, + { + id: repoId, + path: '/remote/split-owner', + displayName: 'split ssh', + badgeColor: '#000000', + addedAt: 0, + connectionId: 'box-a' + } + ) + harness.worktrees.push( + resolvedWorktree(localWorktreeId, repoId, '/local/split-owner', 'local'), + resolvedWorktree(sshWorktreeId, repoId, '/remote/split-owner', 'ssh:box-a') + ) + for (const [worktreeId, tabId, ptyId] of [ + [localWorktreeId, 'split-local-tab', localPty], + [sshWorktreeId, 'split-ssh-tab', sshPty] + ] as const) { + harness.session.tabsByWorktree[worktreeId] = [ + { + id: tabId, + ptyId, + worktreeId, + title: tabId, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + harness.sessions.local.push({ + id: localPty, + cwd: '/local/split-owner', + worktreeId: localWorktreeId + }) + harness.sessions['box-a'].push({ + id: sshPty, + cwd: '/remote/split-owner', + worktreeId: sshWorktreeId + }) + + const local = await harness.runtime.listMobileSessionTabs(`id:${localWorktreeId}`) + expect(local.tabs.some((tab) => tab.type === 'terminal' && tab.ptyId === localPty)).toBe(true) + await expect( + harness.runtime.activateMobileSessionTab(`id:${localWorktreeId}`, 'split-local-tab') + ).resolves.toMatchObject({ worktree: localWorktreeId }) + const remote = await harness.runtime.listMobileSessionTabs(`id:${sshWorktreeId}`) + expect(remote.tabs.some((tab) => tab.type === 'terminal' && tab.ptyId === sshPty)).toBe(true) + await expect( + harness.runtime.closeMobileSessionTab(`id:${sshWorktreeId}`, 'split-ssh-tab') + ).resolves.toEqual({ closed: true }) + }) + + it('deduplicates repeated registrations on the same local or SSH provider', async () => { + const harness = makeHarness() + harness.repos.push( + { ...harness.repos.find((repo) => repo.id === 'repo-local')! }, + { ...harness.repos.find((repo) => repo.id === 'repo-ssh-a')! } + ) + harness.worktrees.push( + resolvedWorktree(LOCAL_WORKTREE_ID, 'repo-local', '/local/project', 'local'), + resolvedWorktree(SSH_A_WORKTREE_ID, 'repo-ssh-a', '/srv/a/project', 'ssh:box-a') + ) + const localPty = 'pty-duplicate-local-registration' + const sshPty = 'ssh:box-a@@duplicate-registration' + harness.sessions.local.push({ + id: localPty, + cwd: '/local/project', + worktreeId: LOCAL_WORKTREE_ID + }) + harness.sessions['box-a'].push({ + id: sshPty, + cwd: '/srv/a/project', + worktreeId: SSH_A_WORKTREE_ID + }) + harness.session.tabsByWorktree[SSH_A_WORKTREE_ID] = [ + { + id: 'duplicate-registration-tab', + ptyId: sshPty, + worktreeId: SSH_A_WORKTREE_ID, + title: 'Duplicate registration', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + + const local = await harness.runtime.listTerminals(`id:${LOCAL_WORKTREE_ID}`) + expect(local.terminals.map((terminal) => terminal.ptyId)).toContain(localPty) + expect(harness.internals.ptysById.get(localPty)?.connected).toBe(true) + harness.clearProviderCalls() + const remote = await harness.runtime.listTerminals(`id:${SSH_A_WORKTREE_ID}`) + expect(remote.terminals.map((terminal) => terminal.ptyId)).toContain(sshPty) + expect(harness.providers['box-a']).toHaveBeenCalledOnce() + expect(harness.internals.ptysById.get(sshPty)?.connected).toBe(true) + await harness.runtime.listMobileSessionTabs(`id:${SSH_A_WORKTREE_ID}`) + await expect( + harness.runtime.closeMobileSessionTab(`id:${SSH_A_WORKTREE_ID}`, 'duplicate-registration-tab') + ).resolves.toEqual({ closed: true }) + }) + + it('rejects a folder id registered on local and SSH hosts', async () => { + const harness = makeHarness() + harness.folderWorkspaces.push({ + ...SSH_FOLDER, + name: 'Conflicting local folder', + folderPath: '/local/conflicting-folder', + connectionId: null + }) + + await expect( + harness.runtime.listMobileSessionTabs(`id:${folderWorkspaceKey(SSH_FOLDER.id)}`) + ).rejects.toThrow('selector_ambiguous') + }) + + it('accepts duplicate folder registrations on the same SSH host', async () => { + const harness = makeHarness() + harness.folderWorkspaces.push({ ...SSH_FOLDER }) + const folderWorktreeId = folderWorkspaceKey(SSH_FOLDER.id) + const ptyId = 'ssh:box-b@@same-folder-registration' + harness.session.tabsByWorktree[folderWorktreeId] = [ + { + id: 'same-folder-tab', + ptyId, + worktreeId: folderWorktreeId, + title: 'Same folder', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + harness.sessions['box-b'].push({ + id: ptyId, + cwd: SSH_FOLDER.folderPath, + worktreeId: folderWorktreeId + }) + + const terminals = await harness.runtime.listTerminals(`id:${folderWorktreeId}`) + expect(terminals.terminals.map((terminal) => terminal.ptyId)).toContain(ptyId) + await expect( + harness.runtime.listMobileSessionTabs(`id:${folderWorktreeId}`) + ).resolves.toMatchObject({ worktree: folderWorktreeId }) + }) + + it('keeps runtime-owned folder mobile tabs unambiguous without provider liveness', async () => { + const harness = makeHarness() + const runtimeFolder = { + ...SSH_FOLDER, + id: 'folder-runtime-owner', + name: 'Runtime folder', + folderPath: '/runtime/folder', + connectionId: null, + executionHostId: 'runtime:environment-1' + } + harness.folderWorkspaces.push(runtimeFolder) + const worktreeId = folderWorkspaceKey(runtimeFolder.id) + const ptyId = 'runtime-folder-pty' + harness.session.tabsByWorktree[worktreeId] = [ + { + id: 'runtime-folder-tab', + ptyId, + worktreeId, + title: 'Runtime folder', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + const leafId = '11111111-1111-4111-8111-111111111111' + harness.session.terminalLayoutsByTabId['runtime-folder-tab'] = { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } + harness.runtime.preAllocateHandleForPty(ptyId) + harness.runtime.registerPty(ptyId, worktreeId, null, { + tabId: 'runtime-folder-tab', + leafId + }) + + const listed = await harness.runtime.listMobileSessionTabs(`id:${worktreeId}`) + expect(listed.tabs.some((tab) => tab.type === 'terminal' && tab.ptyId === ptyId)).toBe(true) + expect(harness.internals.ptysById.get(ptyId)?.connected).toBe(true) + await expect( + harness.runtime.activateMobileSessionTab(`id:${worktreeId}`, 'runtime-folder-tab') + ).resolves.toMatchObject({ worktree: worktreeId }) + expect(harness.listProcesses).not.toHaveBeenCalled() + expect(harness.listProcessesWithHostScope).not.toHaveBeenCalled() + expect(harness.providers.local).not.toHaveBeenCalled() + expect(harness.providers['box-a']).not.toHaveBeenCalled() + expect(harness.providers['box-b']).not.toHaveBeenCalled() + expect(harness.providers['box-c']).not.toHaveBeenCalled() + await expect( + harness.runtime.closeMobileSessionTab(`id:${worktreeId}`, 'runtime-folder-tab') + ).rejects.toThrow('terminal_host_scope_mismatch') + expect(harness.kill).not.toHaveBeenCalled() + }) + it('queries but never exposes SSH mobile tabs for a duplicate-owner worktree id', async () => { + const harness = makeHarness() + harness.internals.resolvedWorktreeCache = null + const duplicateWorktreeId = 'repo-duplicate::/shared/project' + harness.repos.push( + { + id: 'repo-duplicate', + path: '/shared/project', + displayName: 'local-duplicate', + badgeColor: '#000000', + addedAt: 0 + }, + { + id: 'repo-duplicate', + path: '/shared/project', + displayName: 'ssh-duplicate', + badgeColor: '#000000', + addedAt: 0, + connectionId: 'box-a' + } + ) + const recoveredPty = 'ssh:box-a@@recovered-mobile' + harness.session.tabsByWorktree[duplicateWorktreeId] = [ + { + id: 'recovered-tab', + ptyId: recoveredPty, + worktreeId: duplicateWorktreeId, + title: 'Recovered', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + harness.sessions['box-a'].push({ + id: recoveredPty, + cwd: '/shared/project', + worktreeId: duplicateWorktreeId + }) + const unrelatedPty = 'ssh:box-c@@unrelated-live' + harness.runtime.registerPty(unrelatedPty, SSH_C_WORKTREE_ID, 'box-c') + harness.internals.sshRelayRecoveryGenerationByTargetId.set('box-a', 1) + const notify = vi.spyOn(harness.internals, 'notifyMobileSessionTabsChangedNow') + + await harness.internals.publishRecoveredSshMobileSessionTabs('box-a', 1) + + expect(harness.providers['box-a']).toHaveBeenCalledOnce() + expect(harness.providers.local).not.toHaveBeenCalled() + expect(harness.providers['box-b']).not.toHaveBeenCalled() + expect(harness.providers['box-c']).not.toHaveBeenCalled() + expect(harness.store.getWorkspaceSession).toHaveBeenCalledWith('ssh:box-a') + expect(notify).not.toHaveBeenCalledWith(duplicateWorktreeId) + expect(harness.internals.mobileSessionTabsByWorktree.has(duplicateWorktreeId)).toBe(false) + expect(harness.internals.ptysById.get(recoveredPty)?.connected).toBe(true) + await expect( + harness.runtime.listMobileSessionTabs(`id:${duplicateWorktreeId}`) + ).rejects.toThrow('selector_ambiguous') + await expect( + harness.runtime.closeMobileSessionTab(`id:${duplicateWorktreeId}`, 'recovered-tab') + ).rejects.toThrow('selector_ambiguous') + expect(harness.kill).not.toHaveBeenCalled() + expect(harness.internals.ptysById.get(unrelatedPty)?.connected).toBe(true) + }) + + it('rechecks exact ownership after deferred provider inventory before publishing or acting', async () => { + for (const mode of ['recovery', 'list', 'activate'] as const) { + const harness = makeHarness() + const ptyId = 'ssh:box-a@@owner-race' + harness.session.tabsByWorktree[SSH_A_WORKTREE_ID] = [ + { + id: 'owner-race-tab', + ptyId, + worktreeId: SSH_A_WORKTREE_ID, + title: 'Owner race', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + harness.sessions['box-a'].push({ + id: ptyId, + cwd: '/srv/a/project', + worktreeId: SSH_A_WORKTREE_ID + }) + let release!: (sessions: (typeof harness.sessions)['box-a']) => void + const deferred = new Promise<(typeof harness.sessions)['box-a']>((resolve) => { + release = resolve + }) + harness.providers['box-a'].mockImplementationOnce(() => deferred) + harness.internals.sshRelayRecoveryGenerationByTargetId.set('box-a', 1) + const notify = vi.spyOn(harness.internals, 'notifyMobileSessionTabsChangedNow') + const pending = + mode === 'recovery' + ? harness.internals.publishRecoveredSshMobileSessionTabs('box-a', 1) + : mode === 'list' + ? harness.runtime.listMobileSessionTabs(`id:${SSH_A_WORKTREE_ID}`) + : harness.runtime.activateMobileSessionTab(`id:${SSH_A_WORKTREE_ID}`, 'owner-race-tab') + await vi.waitFor(() => expect(harness.providers['box-a']).toHaveBeenCalledOnce()) + harness.repos.push({ + ...harness.repos.find((repo) => repo.id === 'repo-ssh-a')!, + connectionId: undefined + }) + release(harness.sessions['box-a']) + if (mode === 'recovery') { + await pending + expect(notify).not.toHaveBeenCalledWith(SSH_A_WORKTREE_ID) + } else { + await expect(pending).rejects.toThrow('selector_ambiguous') + } + expect(harness.internals.mobileSessionTabsByWorktree.has(SSH_A_WORKTREE_ID)).toBe(false) + expect(harness.kill).not.toHaveBeenCalled() + } + }) + + it('does not guess when the same exact worktree id has multiple execution owners', async () => { + const harness = makeHarness() + harness.repos.push({ + ...harness.repos[0]!, + connectionId: 'box-a' + }) + harness.worktrees.push( + resolvedWorktree(LOCAL_WORKTREE_ID, 'repo-local', '/local/project', 'ssh:box-a') + ) + const retainedPty = 'ssh:box-a@@ambiguous-owner' + harness.runtime.registerPty(retainedPty, LOCAL_WORKTREE_ID, 'box-a') + + const result = await harness.runtime.listTerminals(`id:${LOCAL_WORKTREE_ID}`) + + expect(harness.listProcesses).not.toHaveBeenCalled() + expect(harness.listProcessesWithHostScope).not.toHaveBeenCalled() + expect(result.terminals.map((terminal) => terminal.ptyId)).toContain(retainedPty) + expect(harness.internals.ptysById.get(retainedPty)?.connected).toBe(true) + }) +})