fix(runtime): bound persisted graph hydration (#11832)

This commit is contained in:
OrcaWin 2026-07-31 17:56:40 -07:00 committed by GitHub
parent 377b580bab
commit 33ad64b1c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 435 additions and 56 deletions

View File

@ -26944,6 +26944,86 @@ describe('OrcaRuntimeService', () => {
expect(secondMerge.publicationEpoch.match(/:headless-merge:/g) ?? []).toHaveLength(1)
})
it('keeps the graph ready when a mobile snapshot references a removed folder workspace', () => {
const runtime = new OrcaRuntimeService({
...store,
getFolderWorkspaces: () => []
} as never)
expect(() =>
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: [
{
worktree: 'folder:removed-folder',
publicationEpoch: 'stale-folder-publication',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
]
})
).not.toThrow()
expect(runtime.getStatus().graphStatus).toBe('ready')
})
it('scans ordinary persisted sessions once instead of once per graph workspace', () => {
const tabsByWorktree = Object.fromEntries(
Array.from({ length: 100 }, (_, index) => [
`${TEST_REPO_ID}::/tmp/worktree-${index}`,
[{ id: `tab-${index}`, ptyId: `${TEST_REPO_ID}::/tmp/worktree-${index}@@pty` }]
])
)
const session = { tabsByWorktree, terminalLayoutsByTabId: {} }
const getWorkspaceSession = vi.fn(() => session)
const runtime = new OrcaRuntimeService({
...store,
getWorkspaceSession,
getWorkspaceSessionHostIds: () => ['local']
} as never)
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: Object.keys(tabsByWorktree).map((worktree) => ({
worktree,
publicationEpoch: 'large-profile',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}))
})
expect(getWorkspaceSession).toHaveBeenCalledTimes(1)
expect(runtime.getStatus().graphStatus).toBe('ready')
})
it('hydrates runtime-owned candidates from one host-session read', () => {
const tabsByWorktree = Object.fromEntries(
Array.from({ length: 100 }, (_, index) => [
`${TEST_REPO_ID}::/tmp/runtime-worktree-${index}`,
[{ id: `runtime-tab-${index}`, ptyId: `serve-runtime-${index}` }]
])
)
const session = { tabsByWorktree, terminalLayoutsByTabId: {} }
const getWorkspaceSession = vi.fn(() => session)
const runtime = new OrcaRuntimeService({
...store,
getWorkspaceSession,
getWorkspaceSessionHostIds: () => ['local']
} as never)
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] })
expect(getWorkspaceSession).toHaveBeenCalledTimes(1)
expect(runtime.getStatus().graphStatus).toBe('ready')
})
it('briefly preserves abnormal SSH exits for paired pane recovery', async () => {
vi.useFakeTimers()
try {
@ -27180,7 +27260,8 @@ describe('OrcaRuntimeService', () => {
getWorkspaceSession
} as never)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] })
expect(getWorkspaceSession).toHaveBeenCalledTimes(2)
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([
expect.objectContaining({

View File

@ -4561,14 +4561,14 @@ export class OrcaRuntimeService {
return this.startedAt
}
private getWorkspaceSessionHostIdForWorktree(worktreeId: string): ExecutionHostId {
private tryGetWorkspaceSessionHostIdForWorktree(worktreeId: string): ExecutionHostId | null {
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
const workspace = this.store
?.getFolderWorkspaces?.()
.find((entry) => entry.id === scope.folderWorkspaceId)
if (!workspace) {
throw new Error('folder_workspace_not_found')
return null
}
const connectionId = this.resolveFolderWorkspaceConnectionId(workspace)
return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
@ -4578,11 +4578,17 @@ export class OrcaRuntimeService {
return repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID
}
private getWorkspaceSessionHostIdForWorktree(worktreeId: string): ExecutionHostId {
const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId)
if (!hostId) {
throw new Error('folder_workspace_not_found')
}
return hostId
}
private getWorkspaceSessionForWorktree(worktreeId: string): WorkspaceSessionState | null {
return (
this.store?.getWorkspaceSession?.(this.getWorkspaceSessionHostIdForWorktree(worktreeId)) ??
null
)
const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId)
return hostId ? (this.store?.getWorkspaceSession?.(hostId) ?? null) : null
}
private setWorkspaceSessionForWorktree(worktreeId: string, session: WorkspaceSessionState): void {
@ -4611,6 +4617,56 @@ export class OrcaRuntimeService {
return worktreeIds
}
private getWorkspaceSessionHydrationTargets(
includeAllPersistedWorktrees: boolean
): Map<string, WorkspaceSessionState> {
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<ExecutionHostId>(['local'])
for (const repo of repos) {
hostIds.add(getRepoExecutionHostId(repo))
}
for (const hostId of this.store?.getWorkspaceSessionHostIds?.() ?? []) {
hostIds.add(hostId)
}
const targets = new Map<string, WorkspaceSessionState>()
for (const hostId of hostIds) {
const session = this.store?.getWorkspaceSession?.(hostId)
if (!session) {
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)
if (
ownerHostId === hostId &&
(includeAllPersistedWorktrees ||
this.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate(session, worktreeId, tabs))
) {
targets.set(worktreeId, session)
}
}
}
return targets
}
getStatus(): RuntimeStatus {
// Why: browser panes need a backend that can create and stream a page. A
// desktop renderer provides one via <webview>; a headless serve provides one
@ -5475,6 +5531,8 @@ export class OrcaRuntimeService {
force?: boolean
allowAttachedWindow?: boolean
onlyRuntimeOwnedTerminals?: boolean
runtimeOwnedTerminalCandidateKnown?: boolean
workspaceSession?: WorkspaceSessionState
} = {}
): Set<string> {
// Why: report which worktrees were reconciled in place so callers don't
@ -5483,9 +5541,11 @@ export class OrcaRuntimeService {
if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) {
return reconciledWorktreeIds
}
const session = worktreeId
? this.getWorkspaceSessionForWorktree(worktreeId)
: this.store?.getWorkspaceSession?.()
const session =
options.workspaceSession ??
(worktreeId
? this.getWorkspaceSessionForWorktree(worktreeId)
: this.store?.getWorkspaceSession?.())
if (!session) {
return reconciledWorktreeIds
}
@ -5497,7 +5557,14 @@ export class OrcaRuntimeService {
if (
options.onlyRuntimeOwnedTerminals === true &&
!this.offscreenBrowserBackend &&
!this.workspaceSessionHasRuntimeOwnedPtyCandidate(session)
options.runtimeOwnedTerminalCandidateKnown !== true &&
!(worktreeId
? this.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate(
session,
worktreeId,
session.tabsByWorktree[worktreeId] ?? []
)
: this.workspaceSessionHasRuntimeOwnedPtyCandidate(session))
) {
return reconciledWorktreeIds
}
@ -5542,7 +5609,8 @@ export class OrcaRuntimeService {
}
const terminalTabs = this.buildHeadlessMobileSessionTerminalTabs(
entryWorktreeId,
persistedTabs
persistedTabs,
session
).filter(
(tab) =>
options.onlyRuntimeOwnedTerminals !== true ||
@ -5854,26 +5922,30 @@ export class OrcaRuntimeService {
}
private workspaceSessionHasRuntimeOwnedPtyCandidate(session: WorkspaceSessionState): boolean {
for (const tabs of Object.values(session.tabsByWorktree ?? {})) {
for (const tab of tabs) {
if (this.isServeOrSshOwnedPtyId(tab.ptyId)) {
return true
}
const leafPtyIds = session.terminalLayoutsByTabId?.[tab.id]?.ptyIdsByLeafId
if (
leafPtyIds &&
Object.values(leafPtyIds).some((ptyId) => this.isServeOrSshOwnedPtyId(ptyId))
) {
return true
}
}
}
// Why: expiry clears the stale PTY id but retains pane coordinates so paired viewers can ask the HUB for a fresh shell.
return Object.entries(session.tabsByWorktree ?? {}).some(([worktreeId, tabs]) =>
tabs.some((tab) => this.getRecentExpiredSshLease(worktreeId, tab.id, undefined) !== null)
this.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate(session, worktreeId, tabs)
)
}
private workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate(
session: WorkspaceSessionState,
worktreeId: string,
tabs: WorkspaceSessionState['tabsByWorktree'][string]
): boolean {
return tabs.some((tab) => {
if (this.isServeOrSshOwnedPtyId(tab.ptyId)) {
return true
}
const leafPtyIds = session.terminalLayoutsByTabId?.[tab.id]?.ptyIdsByLeafId
return (
(leafPtyIds &&
Object.values(leafPtyIds).some((ptyId) => this.isServeOrSshOwnedPtyId(ptyId))) ||
// Why: expiry keeps pane coordinates so paired viewers can request a fresh shell.
this.getRecentExpiredSshLease(worktreeId, tab.id, undefined) !== null
)
})
}
private getRecentExpiredSshLease(
worktreeId: string,
tabId: string,
@ -6482,12 +6554,9 @@ export class OrcaRuntimeService {
private buildHeadlessMobileSessionTerminalTabs(
worktreeId: string,
persistedTabs: readonly TerminalTab[]
persistedTabs: readonly TerminalTab[],
session: WorkspaceSessionState
): RuntimeMobileSessionTerminalTab[] {
const session = this.getWorkspaceSessionForWorktree(worktreeId)
if (!session) {
return []
}
return [...persistedTabs]
.sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt)
.flatMap((tab, index) => {
@ -6519,7 +6588,13 @@ export class OrcaRuntimeService {
...(tab.color != null ? { color: tab.color } : {}),
...(tab.isPinned ? { isPinned: true } : {}),
...(tab.viewMode ? { viewMode: tab.viewMode } : {}),
isActive: this.isPersistedTerminalLeafActive(worktreeId, tab.id, leafId, layout)
isActive: this.isPersistedTerminalLeafActive(
session,
worktreeId,
tab.id,
leafId,
layout
)
}
]
})
@ -6708,13 +6783,13 @@ export class OrcaRuntimeService {
}
private isPersistedTerminalLeafActive(
session: WorkspaceSessionState,
worktreeId: string,
tabId: string,
leafId: string,
layout: TerminalLayoutSnapshot | undefined
): boolean {
const session = this.getWorkspaceSessionForWorktree(worktreeId)
const activeTabId = session?.activeTabIdByWorktree?.[worktreeId] ?? session?.activeTabId
const activeTabId = session.activeTabIdByWorktree?.[worktreeId] ?? session.activeTabId
return activeTabId === tabId && (!layout?.activeLeafId || layout.activeLeafId === leafId)
}
@ -28248,15 +28323,23 @@ export class OrcaRuntimeService {
missingSnapshotOnly: true,
notify: false
})
const worktreeIdsToHydrate = this.getKnownWorkspaceSessionWorktreeIds()
for (const snapshot of snapshots) {
worktreeIdsToHydrate.add(snapshot.worktree)
// Why: graph sync must scan each persisted host session once, not once per workspace.
const worktreeSessionsToHydrate = new Map<string, WorkspaceSessionState | null>(
this.getWorkspaceSessionHydrationTargets(Boolean(this.offscreenBrowserBackend))
)
if (this.offscreenBrowserBackend) {
for (const snapshot of snapshots) {
if (!worktreeSessionsToHydrate.has(snapshot.worktree)) {
worktreeSessionsToHydrate.set(snapshot.worktree, null)
}
}
}
// Why: an empty renderer publication after HUB restart must not hide SSH panes persisted in this HUB's host partition.
for (const worktreeId of worktreeIdsToHydrate) {
for (const [worktreeId, workspaceSession] of worktreeSessionsToHydrate) {
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, {
allowAttachedWindow: true,
onlyRuntimeOwnedTerminals: true
onlyRuntimeOwnedTerminals: true,
...(workspaceSession ? { runtimeOwnedTerminalCandidateKnown: true, workspaceSession } : {})
})
}
const nextWorktrees = new Set<string>()

View File

@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import { getReachableRuntimeSessionMirrorTargets } from './runtime-session-mirror-targets'
const environments = [
{
id: 'online-env',
createdAt: 100,
pairingRevision: 101
},
{
id: 'offline-env',
createdAt: 200
}
]
describe('getReachableRuntimeSessionMirrorTargets', () => {
it('subscribes only after a runtime health probe succeeds', () => {
expect(
getReachableRuntimeSessionMirrorTargets({
settings: { activeRuntimeEnvironmentId: 'online-env' },
repos: [{ id: 'offline-repo', connectionId: null, executionHostId: 'runtime:offline-env' }],
runtimeEnvironments: environments,
runtimeStatusByEnvironmentId: new Map([
[
'online-env',
{
status: { runtimeId: 'runtime-online' },
connectionGeneration: 3
}
],
['offline-env', { status: null, connectionGeneration: 7 }]
])
})
).toEqual([
{
environmentId: 'online-env',
runtimeId: 'runtime-online',
connectionGeneration: 3,
pairingRevision: 101
}
])
})
it('waits for both the saved environment and its first successful status', () => {
expect(
getReachableRuntimeSessionMirrorTargets({
settings: { activeRuntimeEnvironmentId: 'online-env' },
runtimeEnvironments: environments,
runtimeStatusByEnvironmentId: new Map()
})
).toEqual([])
expect(
getReachableRuntimeSessionMirrorTargets({
settings: { activeRuntimeEnvironmentId: 'missing-env' },
runtimeEnvironments: environments,
runtimeStatusByEnvironmentId: new Map([
['missing-env', { status: { runtimeId: 'runtime-missing' } }]
])
})
).toEqual([])
})
it('uses creation time for environments paired before pairing revisions existed', () => {
expect(
getReachableRuntimeSessionMirrorTargets({
settings: { activeRuntimeEnvironmentId: 'offline-env' },
runtimeEnvironments: environments,
runtimeStatusByEnvironmentId: new Map([
['offline-env', { status: { runtimeId: 'runtime-recovered' } }]
])
})
).toEqual([
{
environmentId: 'offline-env',
runtimeId: 'runtime-recovered',
connectionGeneration: 0,
pairingRevision: 200
}
])
})
})

View File

@ -0,0 +1,54 @@
import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state'
import { getRuntimeSessionMirrorEnvironmentIds } from './runtime-session-mirror-owners'
type RuntimeMirrorStatus = {
status: { runtimeId: string } | null
connectionGeneration?: number
}
type RuntimeMirrorEnvironment = {
id: string
createdAt: number
pairingRevision?: number
}
export type RuntimeSessionMirrorTarget = {
environmentId: string
runtimeId: string
connectionGeneration: number
pairingRevision: number
}
export type RuntimeSessionMirrorTargetState = Omit<
WorktreeRuntimeOwnerState,
'runtimeEnvironments'
> & {
runtimeEnvironments?: readonly RuntimeMirrorEnvironment[]
runtimeStatusByEnvironmentId?: ReadonlyMap<string, RuntimeMirrorStatus>
}
export function getReachableRuntimeSessionMirrorTargets(
state: RuntimeSessionMirrorTargetState
): RuntimeSessionMirrorTarget[] {
const environmentById = new Map(
(state.runtimeEnvironments ?? []).map((environment) => [environment.id, environment])
)
const targets: RuntimeSessionMirrorTarget[] = []
for (const environmentId of getRuntimeSessionMirrorEnvironmentIds(state)) {
const status = state.runtimeStatusByEnvironmentId?.get(environmentId)
if (!status?.status) {
continue
}
const environment = environmentById.get(environmentId)
if (!environment) {
continue
}
targets.push({
environmentId,
runtimeId: status.status.runtimeId,
connectionGeneration: status.connectionGeneration ?? 0,
pairingRevision: environment.pairingRevision ?? environment.createdAt
})
}
return targets
}

View File

@ -428,6 +428,29 @@ describe('getRuntimeMobileSessionSyncKey', () => {
expect(canSkipRuntimeMobileSessionSyncKeyBuild(after, before)).toBe(false)
})
it('changes and does not skip when a folder workspace is removed', () => {
const sharedOverrides = makeSharedOverrides()
const folderWorkspace = {
id: 'folder-1'
} as AppState['folderWorkspaces'][number]
const before = makeState({
...sharedOverrides,
folderWorkspaces: [folderWorkspace]
})
const after = makeState({
...sharedOverrides,
folderWorkspaces: []
})
expect(canSkipRuntimeMobileSessionSyncKeyBuild(after, before)).toBe(false)
expect(
runtimeMobileSessionSyncKeysEqual(
getRuntimeMobileSessionSyncKey(before),
getRuntimeMobileSessionSyncKey(after)
)
).toBe(false)
})
it('changes when explicit agent status epoch changes', () => {
const sharedOverrides = makeSharedOverrides()
const before = getRuntimeMobileSessionSyncKey(
@ -596,6 +619,54 @@ describe('getRuntimeMobileSessionSyncKey', () => {
})
describe('buildMobileSessionTabSnapshots', () => {
it('does not publish state for a removed folder workspace', () => {
const staleFolderKey = 'folder:removed-folder'
const state = makeState({
folderWorkspaces: [],
tabsByWorktree: {
[staleFolderKey]: [{ id: 'term-1', title: 'Terminal 1' }]
} as unknown as AppState['tabsByWorktree']
})
expect(buildMobileSessionTabSnapshots(state)).toEqual([])
})
it('publishes state for a live folder workspace', () => {
const folderWorkspaceId = 'live-folder'
const folderKey = `folder:${folderWorkspaceId}`
const state = makeState({
folderWorkspaces: [{ id: folderWorkspaceId } as AppState['folderWorkspaces'][number]],
tabsByWorktree: {
[folderKey]: [{ id: 'term-1', title: 'Terminal 1' }]
} as unknown as AppState['tabsByWorktree']
})
expect(buildMobileSessionTabSnapshots(state)).toEqual([
expect.objectContaining({ worktree: folderKey })
])
})
it('evicts a removed folder workspace from the snapshot cache', () => {
const folderWorkspaceId = 'cache-eviction-folder'
const folderKey = `folder:${folderWorkspaceId}`
const liveState = makeState({
folderWorkspaces: [{ id: folderWorkspaceId } as AppState['folderWorkspaces'][number]],
tabsByWorktree: {
[folderKey]: [{ id: 'term-1', title: 'Terminal 1' }]
} as unknown as AppState['tabsByWorktree']
})
const removedState = makeState({
folderWorkspaces: [],
tabsByWorktree: liveState.tabsByWorktree
})
const initial = buildMobileSessionTabSnapshots(liveState)[0]!
expect(buildMobileSessionTabSnapshots(removedState)).toEqual([])
const restored = buildMobileSessionTabSnapshots(liveState)[0]!
expect(restored.snapshotVersion).toBeGreaterThan(initial.snapshotVersion)
})
it('publishes browser and editor color + pin state from unified tabs', () => {
const fileId = '/repo/README.md'
const state = makeState({

View File

@ -25,6 +25,7 @@ import type {
import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id'
import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
import { isClaudeManagementTitle } from '../../../shared/agent-detection'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import type {
Tab,
TabGroup,
@ -247,6 +248,7 @@ export type RuntimeMobileSessionSyncKey = {
terminalLayoutsByTabId: AppState['terminalLayoutsByTabId']
runtimePaneTitlesByTabId: AppState['runtimePaneTitlesByTabId']
nativeChatLaunchDraftByTabId: AppState['nativeChatLaunchDraftByTabId']
folderWorkspaces: AppState['folderWorkspaces']
groupsByWorktree: AppState['groupsByWorktree']
activeGroupIdByWorktree: AppState['activeGroupIdByWorktree']
layoutByWorktree: AppState['layoutByWorktree']
@ -303,6 +305,7 @@ export function canSkipRuntimeMobileSessionSyncKeyBuild(
state.terminalLayoutsByTabId === previousState.terminalLayoutsByTabId &&
state.runtimePaneTitlesByTabId === previousState.runtimePaneTitlesByTabId &&
state.nativeChatLaunchDraftByTabId === previousState.nativeChatLaunchDraftByTabId &&
state.folderWorkspaces === previousState.folderWorkspaces &&
state.agentStatusEpoch === previousState.agentStatusEpoch &&
state.agentStatusByPaneKey === previousState.agentStatusByPaneKey
)
@ -340,6 +343,7 @@ export function getRuntimeMobileSessionSyncKey(
terminalLayoutsByTabId: state.terminalLayoutsByTabId,
runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId,
nativeChatLaunchDraftByTabId: state.nativeChatLaunchDraftByTabId,
folderWorkspaces: state.folderWorkspaces,
groupsByWorktree: state.groupsByWorktree,
activeGroupIdByWorktree: state.activeGroupIdByWorktree,
layoutByWorktree: state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE,
@ -585,6 +589,7 @@ export function runtimeMobileSessionSyncKeysEqual(
a.terminalLayoutsByTabId === b.terminalLayoutsByTabId &&
a.runtimePaneTitlesByTabId === b.runtimePaneTitlesByTabId &&
a.nativeChatLaunchDraftByTabId === b.nativeChatLaunchDraftByTabId &&
a.folderWorkspaces === b.folderWorkspaces &&
a.groupsByWorktree === b.groupsByWorktree &&
a.activeGroupIdByWorktree === b.activeGroupIdByWorktree &&
a.layoutByWorktree === b.layoutByWorktree &&
@ -757,6 +762,9 @@ export function buildMobileSessionTabSnapshots(
// Why: high-frequency title ticks fire mobile sync; cache indexes/hashes by store-slice ref to skip rescanning editor state.
const openFileIndexes = getOpenFileIndexes(state.openFiles)
const editorDraftVersionByFileId = getEditorDraftVersionByFileId(state.editorDrafts)
const liveFolderWorkspaceIds = new Set(
(state.folderWorkspaces ?? []).map((workspace) => workspace.id)
)
const worktreeIds = new Set<string>([
...Object.keys(state.tabsByWorktree),
...Object.keys(state.groupsByWorktree),
@ -767,6 +775,14 @@ export function buildMobileSessionTabSnapshots(
const snapshots: RuntimeMobileSessionTabsSnapshot[] = []
for (const worktreeId of worktreeIds) {
const workspaceScope = parseWorkspaceKey(worktreeId)
if (
workspaceScope?.type === 'folder' &&
!liveFolderWorkspaceIds.has(workspaceScope.folderWorkspaceId)
) {
mobileSessionSnapshotCacheByWorktree.delete(worktreeId)
continue
}
const activeGroupId = state.activeGroupIdByWorktree[worktreeId] ?? null
const terminalTabByIdForWorktree = new Map(
(state.tabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab])

View File

@ -33,10 +33,8 @@ import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/sta
import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from './runtime-terminal-stream'
import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization'
import { normalizeTerminalLayoutPtyOwnership } from '@/components/terminal-pane/terminal-layout-pty-ownership'
import {
getExplicitRuntimeEnvironmentIdForWorktree,
getRuntimeSessionMirrorEnvironmentIds
} from '@/lib/worktree-runtime-owner'
import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { getReachableRuntimeSessionMirrorTargets } from '@/lib/runtime-session-mirror-targets'
import {
createWebRuntimeSessionTerminal,
HOST_TERMINAL_SURFACE_SEPARATOR,
@ -2694,17 +2692,11 @@ export function applyWebSessionTabsStorePatch(
export function useWebSessionTabsSync(): void {
const activeWorktreeId = useAppStore((state) => state.activeWorktreeId)
const runtimeSessionMirrorEnvironmentKey = useAppStore((state) =>
getRuntimeSessionMirrorEnvironmentIds(state)
.map((environmentId) => {
const status = state.runtimeStatusByEnvironmentId.get(environmentId)
const environment = state.runtimeEnvironments.find(
(candidate) => candidate.id === environmentId
)
const pairingRevision = environment
? (environment.pairingRevision ?? environment.createdAt)
: ''
return `${environmentId}\u0001${status?.status?.runtimeId ?? ''}\u0001${status?.connectionGeneration ?? 0}\u0001${pairingRevision}`
})
getReachableRuntimeSessionMirrorTargets(state)
.map(
({ environmentId, runtimeId, connectionGeneration, pairingRevision }) =>
`${environmentId}\u0001${runtimeId}\u0001${connectionGeneration}\u0001${pairingRevision}`
)
.join('\u0000')
)
const activeWorktreeRuntimeEnvironmentId = useAppStore((state) =>