From 4d62514ea5584859ab9525f603ea34b5523b2eeb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:31:04 -0700 Subject: [PATCH] fix(mobile): bound deletion overflow evidence --- src/main/deleted-folder-session-tombstones.ts | 29 +-- src/main/persistence.test.ts | 56 ++++- src/main/persistence.ts | 211 ++++++++++++------ src/shared/types.ts | 1 + 4 files changed, 213 insertions(+), 84 deletions(-) diff --git a/src/main/deleted-folder-session-tombstones.ts b/src/main/deleted-folder-session-tombstones.ts index 80c9771f714..3fd88d3fb98 100644 --- a/src/main/deleted-folder-session-tombstones.ts +++ b/src/main/deleted-folder-session-tombstones.ts @@ -6,7 +6,6 @@ import type { } from '../shared/types' const MAX_TOMBSTONES = 512 -export const MIN_DELETED_FOLDER_TOMBSTONE_RETENTION_MS = 24 * 60 * 60 * 1000 export const MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000 const MAX_HOST_IDS = 32 const MAX_TAB_OWNERS = 256 @@ -47,10 +46,10 @@ export function boundDeletedFolderTombstoneEvidence( } } -export function getDeletedFolderTombstoneEvictionKeys( +export function getDeletedFolderTombstoneEviction( tombstones: NonNullable, now: number -): WorkspaceKey[] { +): { workspaceKeys: WorkspaceKey[]; overflowExpiresAt: number | undefined } { const entries = Object.entries(tombstones).flatMap(([workspaceKey, tombstone], index) => tombstone ? [{ index, workspaceKey: workspaceKey as WorkspaceKey, tombstone }] : [] ) @@ -60,21 +59,23 @@ export function getDeletedFolderTombstoneEvictionKeys( const expiredKeys = new Set(expired.map(({ workspaceKey }) => workspaceKey)) const retained = entries.filter(({ workspaceKey }) => !expiredKeys.has(workspaceKey)) const excess = Math.max(0, retained.length - MAX_TOMBSTONES) - if (excess === 0) { - return expired.map(({ workspaceKey }) => workspaceKey) - } - const softEvictions = retained - .filter( - ({ tombstone }) => now - tombstone.deletedAt >= MIN_DELETED_FOLDER_TOMBSTONE_RETENTION_MS - ) + const capEvictions = retained .sort((left, right) => left.tombstone.deletedAt === right.tombstone.deletedAt ? left.index - right.index : left.tombstone.deletedAt - right.tombstone.deletedAt ) .slice(0, excess) - return [ - ...expired.map(({ workspaceKey }) => workspaceKey), - ...softEvictions.map(({ workspaceKey }) => workspaceKey) - ] + const overflowExpiresAt = capEvictions.reduce( + (latest, { tombstone }) => + Math.max(latest ?? 0, tombstone.deletedAt + MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS), + undefined + ) + return { + workspaceKeys: [ + ...expired.map(({ workspaceKey }) => workspaceKey), + ...capEvictions.map(({ workspaceKey }) => workspaceKey) + ], + overflowExpiresAt + } } diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 178dbfee6fd..274db2eb7ed 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -5216,9 +5216,12 @@ describe('Store', () => { expect(tombstones?.[workspaceKeys[0]!]).toBeUndefined() expect(tombstones?.[workspaceKeys[1]!]).toBeUndefined() expect(tombstones?.[workspaceKeys.at(-1)!]).toBeDefined() + expect( + (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt + ).toBe(deletedAt + 1 + 30 * 24 * 60 * 60 * 1000) }) - it('retains recent deletion fences before applying the tombstone soft cap', async () => { + it('hard-bounds recent deletion fences with conservative overflow evidence', async () => { const store = await createStore() const group = store.createProjectGroup({ name: 'Tombstone churn', @@ -5239,8 +5242,11 @@ describe('Store', () => { } store.flush() let tombstones = (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones - expect(Object.keys(tombstones ?? {})).toHaveLength(514) - expect(tombstones?.[workspaceKeys[0]!]).toBeDefined() + expect(Object.keys(tombstones ?? {})).toHaveLength(512) + expect(tombstones?.[workspaceKeys[0]!]).toBeUndefined() + expect( + (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt + ).toBeGreaterThan(Date.now()) vi.setSystemTime(new Date('2026-01-02T00:00:00.001Z')) const latest = store.createFolderWorkspace({ projectGroupId: group.id, name: 'Latest' }) @@ -5269,6 +5275,7 @@ describe('Store', () => { tabConnectionIdsByHostId: {} } } + state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt = Date.now() - 1 writeDataFile(state) vi.useFakeTimers() @@ -5280,7 +5287,9 @@ describe('Store', () => { vi.useRealTimers() } - expect((readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones).toEqual({}) + const persisted = readDataFile() as PersistedState + expect(persisted.deletedFolderWorkspaceSessionTombstones).toEqual({}) + expect(persisted.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt).toBeUndefined() }) it('backfills folder-scope SSH provenance from unambiguous child repos on load', async () => { @@ -6850,6 +6859,7 @@ describe('Store', () => { const staleSession = { ...getDefaultWorkspaceSession(), activeWorkspaceKey: workspaceKey, + activeConnectionIdsAtShutdown: [connectionId], tabsByWorktree: { [workspaceKey]: [makeTerminalTab({ id: 'rapid-churn-stale-tab', worktreeId: workspaceKey })] } @@ -6863,6 +6873,11 @@ describe('Store', () => { }) expect(store.removeFolderWorkspace(churned.id)).toBe(true) } + const persisted = (store as unknown as { state: PersistedState }).state + expect(persisted.deletedFolderWorkspaceSessionTombstones?.[workspaceKey]).toBeUndefined() + expect(persisted.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt).toBeGreaterThan( + Date.now() + ) store.setWorkspaceSession(staleSession, hostId) store.upsertSshRemotePtyLease({ @@ -6875,9 +6890,42 @@ describe('Store', () => { }) expect(store.getWorkspaceSession(hostId).tabsByWorktree[workspaceKey]).toBeUndefined() + expect(store.getWorkspaceSession(hostId).activeConnectionIdsAtShutdown).toBeUndefined() expect(store.getSshRemotePtyLeases()).toEqual([]) }) + it('batches deletion evidence commits for folders with many tabs', async () => { + const store = await createStore() + const group = store.createProjectGroup({ + name: 'Many tabs', + parentPath: '/workspace/many-tabs', + createdFrom: 'folder-scan' + }) + const workspace = store.createFolderWorkspace({ projectGroupId: group.id }) + const workspaceKey = folderWorkspaceKey(workspace.id) + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [workspaceKey]: Array.from({ length: 256 }, (_, index) => + makeTerminalTab({ id: `many-tabs-${index}`, worktreeId: workspaceKey }) + ) + } + }) + const tombstoneSetter = vi.spyOn( + store as unknown as { + setDeletedFolderWorkspaceSessionTombstone: ( + workspaceKey: string, + tombstone: unknown + ) => void + }, + 'setDeletedFolderWorkspaceSessionTombstone' + ) + + expect(store.removeFolderWorkspace(workspace.id)).toBe(true) + + expect(tombstoneSetter).toHaveBeenCalledTimes(2) + }) + it('bounds detailed tab-owner evidence for one deleted folder', async () => { const store = await createStore() const hostId = toRuntimeExecutionHostId('tab-evidence-runtime') diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 0f9dbb1fdb1..ae8ee163337 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -90,9 +90,8 @@ import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files' import { boundDeletedFolderTombstoneEvidence, - getDeletedFolderTombstoneEvictionKeys, - MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS, - MIN_DELETED_FOLDER_TOMBSTONE_RETENTION_MS + getDeletedFolderTombstoneEviction, + MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS } from './deleted-folder-session-tombstones' import { normalizePersistedMobileClientTabSelections } from './runtime/client-session-tab-selection-persistence' import { sanitizeWorkspaceSessionTerminalRetirements } from './runtime/mobile-session-terminal-persistence-retirement' @@ -2865,17 +2864,28 @@ function backfillFolderScopeConnectionIds(state: PersistedState): { function normalizeDeletedFolderWorkspaceSessionTombstones( value: unknown, - liveFolderIds: ReadonlySet + liveFolderIds: ReadonlySet, + rawOverflowExpiresAt: unknown ): { tombstones: NonNullable + overflowExpiresAt: number | undefined changed: boolean } { + const now = Date.now() const tombstones: NonNullable = {} let changed = value !== undefined && (!value || typeof value !== 'object' || Array.isArray(value)) + let overflowExpiresAt = + typeof rawOverflowExpiresAt === 'number' && + Number.isFinite(rawOverflowExpiresAt) && + rawOverflowExpiresAt > now + ? Math.min(rawOverflowExpiresAt, now + MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS) + : undefined + if (rawOverflowExpiresAt !== undefined && overflowExpiresAt !== rawOverflowExpiresAt) { + changed = true + } if (!value || typeof value !== 'object' || Array.isArray(value)) { - return { tombstones, changed } + return { tombstones, overflowExpiresAt, changed } } - const now = Date.now() for (const [workspaceKey, rawTombstone] of Object.entries(value)) { const scope = parseWorkspaceKey(workspaceKey) if ( @@ -2896,7 +2906,7 @@ function normalizeDeletedFolderWorkspaceSessionTombstones( raw.deletedAt >= 0 && raw.deletedAt <= now ? raw.deletedAt - : now - MIN_DELETED_FOLDER_TOMBSTONE_RETENTION_MS + : now if (now - deletedAt >= MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS) { changed = true continue @@ -2945,11 +2955,13 @@ function normalizeDeletedFolderWorkspaceSessionTombstones( changed = true } } - for (const workspaceKey of getDeletedFolderTombstoneEvictionKeys(tombstones, now)) { + const eviction = getDeletedFolderTombstoneEviction(tombstones, now) + for (const workspaceKey of eviction.workspaceKeys) { delete tombstones[workspaceKey] changed = true } - return { tombstones, changed } + overflowExpiresAt = Math.max(overflowExpiresAt ?? 0, eviction.overflowExpiresAt ?? 0) || undefined + return { tombstones, overflowExpiresAt, changed } } function deleteRemovedTerminalScrollbackSnapshots( @@ -3482,7 +3494,8 @@ export class Store { ) const normalizedDeletedFolderTombstones = normalizeDeletedFolderWorkspaceSessionTombstones( parsed.deletedFolderWorkspaceSessionTombstones, - new Set(normalizedFolderWorkspaces.map((workspace) => workspace.id)) + new Set(normalizedFolderWorkspaces.map((workspace) => workspace.id)), + parsed.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt ) if (normalizedDeletedFolderTombstones.changed) { this.loadNeedsSave = true @@ -3537,6 +3550,8 @@ export class Store { projectGroups: normalizedProjectGroups, folderWorkspaces: normalizedFolderWorkspaces, deletedFolderWorkspaceSessionTombstones: normalizedDeletedFolderTombstones.tombstones, + deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt: + normalizedDeletedFolderTombstones.overflowExpiresAt, worktreeLineageById: parsed.worktreeLineageById ?? {}, mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections( parsed.mobileClientTabSelectionsByDeviceId @@ -4588,6 +4603,12 @@ export class Store { hostIds: [...new Set([...(prior?.hostIds ?? []), ...hostIds])], tabConnectionIdsByHostId: prior?.tabConnectionIdsByHostId ?? {} }) + const tabOwners: { + hostId: ExecutionHostId + tabId: string + workspaceKey: string + connectionId: string | null + }[] = [] for (const { hostId, session } of partitions) { const tabIds = new Set() const tabPtyIdByTabId = new Map() @@ -4642,16 +4663,18 @@ export class Store { tabPtyIdByTabId.get(tabId) ?? layoutPtyId ?? '' - this.rememberDeletedFolderTabOwner( + tabOwners.push({ hostId, tabId, workspaceKey, - this.resolveWorkspaceSessionPtyConnectionId(hostId, workspaceKey, ptyId) ?? + connectionId: + this.resolveWorkspaceSessionPtyConnectionId(hostId, workspaceKey, ptyId) ?? tabConnectionIdByTabId.get(tabId) ?? connectionId - ) + }) } } + this.rememberDeletedFolderTabOwners(tabOwners) } private workspaceSessionReferencesOwner( @@ -4706,38 +4729,55 @@ export class Store { ) } - private rememberDeletedFolderTabOwner( - hostId: ExecutionHostId, - tabId: string, - workspaceKey: string, - connectionId: string | null + private rememberDeletedFolderTabOwners( + records: readonly { + hostId: ExecutionHostId + tabId: string + workspaceKey: string + connectionId: string | null + }[] ): void { - const ownersByTabId = this.deletedFolderOwnersByHostAndTabId.get(hostId) ?? new Map() - const owners = ownersByTabId.get(tabId) ?? new Map() - owners.set(workspaceKey, connectionId ?? owners.get(workspaceKey) ?? null) - ownersByTabId.set(tabId, owners) - this.deletedFolderOwnersByHostAndTabId.set(hostId, ownersByTabId) - const prior = this.state.deletedFolderWorkspaceSessionTombstones?.[workspaceKey as WorkspaceKey] - const tabConnectionIdsByHostId = { ...prior?.tabConnectionIdsByHostId } - const tabConnectionIds = { ...tabConnectionIdsByHostId[hostId] } - const retainedConnectionId = connectionId ?? tabConnectionIds[tabId] ?? null - delete tabConnectionIds[tabId] - tabConnectionIds[tabId] = retainedConnectionId - delete tabConnectionIdsByHostId[hostId] - tabConnectionIdsByHostId[hostId] = tabConnectionIds - const connectionHostId = connectionId ? toSshExecutionHostId(connectionId) : null - const retainedHostIds = (prior?.hostIds ?? []).filter( - (retainedHostId) => retainedHostId !== hostId && retainedHostId !== connectionHostId - ) - this.setDeletedFolderWorkspaceSessionTombstone(workspaceKey, { - connectionId: prior?.connectionId ?? connectionId, - deletedAt: prior?.deletedAt ?? Date.now(), - evidenceTruncated: prior?.evidenceTruncated ?? false, - hostIds: [ - ...new Set([...retainedHostIds, hostId, ...(connectionHostId ? [connectionHostId] : [])]) - ], - tabConnectionIdsByHostId - }) + const pendingByWorkspaceKey = new Map() + for (const { hostId, tabId, workspaceKey, connectionId } of records) { + let pending = pendingByWorkspaceKey.get(workspaceKey) + if (!pending) { + const prior = + this.state.deletedFolderWorkspaceSessionTombstones?.[workspaceKey as WorkspaceKey] + pending = { + connectionId: prior?.connectionId ?? connectionId, + deletedAt: prior?.deletedAt ?? Date.now(), + evidenceTruncated: prior?.evidenceTruncated ?? false, + hostIds: [...(prior?.hostIds ?? [])], + tabConnectionIdsByHostId: Object.fromEntries( + Object.entries(prior?.tabConnectionIdsByHostId ?? {}).map(([priorHostId, tabs]) => [ + priorHostId, + { ...tabs } + ]) + ) + } + pendingByWorkspaceKey.set(workspaceKey, pending) + } + pending.connectionId ??= connectionId + const tabConnectionIds = pending.tabConnectionIdsByHostId[hostId] ?? {} + const retainedConnectionId = connectionId ?? tabConnectionIds[tabId] ?? null + delete tabConnectionIds[tabId] + tabConnectionIds[tabId] = retainedConnectionId + delete pending.tabConnectionIdsByHostId[hostId] + pending.tabConnectionIdsByHostId[hostId] = tabConnectionIds + const connectionHostId = connectionId ? toSshExecutionHostId(connectionId) : null + pending.hostIds = [ + ...new Set([ + ...pending.hostIds.filter( + (retainedHostId) => retainedHostId !== hostId && retainedHostId !== connectionHostId + ), + hostId, + ...(connectionHostId ? [connectionHostId] : []) + ]) + ] + } + for (const [workspaceKey, tombstone] of pendingByWorkspaceKey) { + this.setDeletedFolderWorkspaceSessionTombstone(workspaceKey, tombstone) + } } private setDeletedFolderWorkspaceSessionTombstone( @@ -4750,31 +4790,51 @@ export class Store { tombstones[workspaceKey as WorkspaceKey] = tombstone this.forgetDeletedFolderWorkspaceSessionTombstone(workspaceKey) this.indexDeletedFolderWorkspaceSessionTombstone(workspaceKey, tombstone) - for (const evictedWorkspaceKey of getDeletedFolderTombstoneEvictionKeys( - tombstones, - Date.now() - )) { + const eviction = getDeletedFolderTombstoneEviction(tombstones, Date.now()) + for (const evictedWorkspaceKey of eviction.workspaceKeys) { delete tombstones[evictedWorkspaceKey] this.forgetDeletedFolderWorkspaceSessionTombstone(evictedWorkspaceKey) } + if (eviction.overflowExpiresAt) { + this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt = Math.max( + this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt ?? 0, + eviction.overflowExpiresAt + ) + } this.state.deletedFolderWorkspaceSessionTombstones = tombstones } private pruneDeletedFolderWorkspaceSessionTombstones(): void { + const now = Date.now() const current = this.state.deletedFolderWorkspaceSessionTombstones ?? {} - const evictedWorkspaceKeys = getDeletedFolderTombstoneEvictionKeys(current, Date.now()) - if (evictedWorkspaceKeys.length === 0) { + const eviction = getDeletedFolderTombstoneEviction(current, now) + const overflowExpiresAt = this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt + const overflowExpired = overflowExpiresAt !== undefined && overflowExpiresAt <= now + const overflowExtended = + eviction.overflowExpiresAt !== undefined && + eviction.overflowExpiresAt > (overflowExpiresAt ?? 0) + if (eviction.workspaceKeys.length === 0 && !overflowExpired && !overflowExtended) { return } const tombstones = { ...current } - for (const workspaceKey of evictedWorkspaceKeys) { + for (const workspaceKey of eviction.workspaceKeys) { delete tombstones[workspaceKey] this.forgetDeletedFolderWorkspaceSessionTombstone(workspaceKey) } + if (overflowExtended) { + this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt = + eviction.overflowExpiresAt + } else if (overflowExpired) { + delete this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt + } this.state.deletedFolderWorkspaceSessionTombstones = tombstones this.scheduleSave() } + private hasDeletedFolderWorkspaceSessionTombstoneOverflow(): boolean { + return (this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt ?? 0) > Date.now() + } + private forgetDeletedFolderWorkspaceSessionTombstone(workspaceKey: string): void { this.deletedFolderConnectionIdByWorkspaceKey.delete(workspaceKey) for (const [hostId, ownersByTabId] of this.deletedFolderOwnersByHostAndTabId) { @@ -4913,7 +4973,8 @@ export class Store { !(this.state.folderWorkspaces ?? []).some( (workspace) => workspace.id === scope.folderWorkspaceId ) && - this.state.deletedFolderWorkspaceSessionTombstones?.[worktreeId as WorkspaceKey] + (this.state.deletedFolderWorkspaceSessionTombstones?.[worktreeId as WorkspaceKey] || + this.hasDeletedFolderWorkspaceSessionTombstoneOverflow()) ) } @@ -4953,7 +5014,13 @@ export class Store { removedOwnerKeys: ReadonlySet ): WorkspaceSessionState { const originalConnectionIds = session.activeConnectionIdsAtShutdown - if (!originalConnectionIds || removedConnectionIds.size === 0) { + if (!originalConnectionIds) { + return session + } + const removalCandidates = this.hasDeletedFolderWorkspaceSessionTombstoneOverflow() + ? new Set(originalConnectionIds) + : removedConnectionIds + if (removalCandidates.size === 0) { return session } const retainedPartitions = [ @@ -4970,7 +5037,7 @@ export class Store { } const activeConnectionIdsAtShutdown = originalConnectionIds.filter( (connectionId) => - !removedConnectionIds.has(connectionId) || retainedConnectionIds.has(connectionId) + !removalCandidates.has(connectionId) || retainedConnectionIds.has(connectionId) ) if ( activeConnectionIdsAtShutdown.length === originalConnectionIds.length && @@ -5111,9 +5178,11 @@ export class Store { (this.state.folderWorkspaces ?? []).map((workspace) => workspace.id) ) const deletedFolderTombstones = this.state.deletedFolderWorkspaceSessionTombstones ?? {} - const hasTruncatedDeletedFolderEvidence = Object.values(deletedFolderTombstones).some( - (tombstone) => tombstone?.evidenceTruncated - ) + const hasDeletedFolderTombstoneOverflow = + this.hasDeletedFolderWorkspaceSessionTombstoneOverflow() + const hasTruncatedDeletedFolderEvidence = + hasDeletedFolderTombstoneOverflow || + Object.values(deletedFolderTombstones).some((tombstone) => tombstone?.evidenceTruncated) const ownerKeys = new Set() const ownerKeysByTabId = new Map>() const directPtyIdsByOwnerAndTabId = new Map>() @@ -5150,7 +5219,9 @@ export class Store { if ( scope?.type === 'folder' && !liveFolderIds.has(scope.folderWorkspaceId) && - (hostId === LOCAL_EXECUTION_HOST_ID || tombstone !== undefined) + (hostId === LOCAL_EXECUTION_HOST_ID || + tombstone !== undefined || + hasDeletedFolderTombstoneOverflow) ) { ownerKeys.add(worktreeId) } @@ -5263,6 +5334,12 @@ export class Store { ownerKeys ) } + const deletedTabOwners: { + hostId: ExecutionHostId + tabId: string + workspaceKey: string + connectionId: string | null + }[] = [] for (const [tabId, tabOwners] of ownerKeysByTabId) { for (const ownerKey of tabOwners) { if (!ownerKeys.has(ownerKey)) { @@ -5274,20 +5351,22 @@ export class Store { ? (session.remoteSessionIdsByTabId?.[tabId] ?? Object.values(session.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {})[0]) : undefined - this.rememberDeletedFolderTabOwner( + deletedTabOwners.push({ hostId, tabId, - ownerKey, - this.resolveWorkspaceSessionPtyConnectionId( - hostId, - ownerKey, - directPtyId ?? sharedPtyId ?? '' - ) ?? + workspaceKey: ownerKey, + connectionId: + this.resolveWorkspaceSessionPtyConnectionId( + hostId, + ownerKey, + directPtyId ?? sharedPtyId ?? '' + ) ?? directConnectionIdsByOwnerAndTabId.get(ownerKey)?.get(tabId) ?? null - ) + }) } } + this.rememberDeletedFolderTabOwners(deletedTabOwners) const removedConnectionIds = this.collectWorkspaceSessionRemoteConnectionIds( [{ hostId, session }], (worktreeId) => ownerKeys.has(worktreeId) diff --git a/src/shared/types.ts b/src/shared/types.ts index 8d2be14e5ad..5b99ae7eb00 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3583,6 +3583,7 @@ export type PersistedState = { deletedFolderWorkspaceSessionTombstones?: Partial< Record > + deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt?: number /** Sparse-checkout presets keyed by repoId. */ sparsePresetsByRepo: Record /** Per paired device last tab selection by worktree; keeps mobile navigation across host restarts. */