mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
fix(mobile): bound deletion overflow evidence
This commit is contained in:
@@ -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<PersistedState['deletedFolderWorkspaceSessionTombstones']>,
|
||||
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<number | undefined>(
|
||||
(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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
+145
-66
@@ -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<string>
|
||||
liveFolderIds: ReadonlySet<string>,
|
||||
rawOverflowExpiresAt: unknown
|
||||
): {
|
||||
tombstones: NonNullable<PersistedState['deletedFolderWorkspaceSessionTombstones']>
|
||||
overflowExpiresAt: number | undefined
|
||||
changed: boolean
|
||||
} {
|
||||
const now = Date.now()
|
||||
const tombstones: NonNullable<PersistedState['deletedFolderWorkspaceSessionTombstones']> = {}
|
||||
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<string>()
|
||||
const tabPtyIdByTabId = new Map<string, string>()
|
||||
@@ -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<string, string | null>()
|
||||
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<string, DeletedFolderWorkspaceSessionTombstone>()
|
||||
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<string>
|
||||
): 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<string>()
|
||||
const ownerKeysByTabId = new Map<string, Set<string>>()
|
||||
const directPtyIdsByOwnerAndTabId = new Map<string, Map<string, string>>()
|
||||
@@ -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)
|
||||
|
||||
@@ -3583,6 +3583,7 @@ export type PersistedState = {
|
||||
deletedFolderWorkspaceSessionTombstones?: Partial<
|
||||
Record<WorkspaceKey, DeletedFolderWorkspaceSessionTombstone>
|
||||
>
|
||||
deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt?: number
|
||||
/** Sparse-checkout presets keyed by repoId. */
|
||||
sparsePresetsByRepo: Record<string, SparsePreset[]>
|
||||
/** Per paired device last tab selection by worktree; keeps mobile navigation across host restarts. */
|
||||
|
||||
Reference in New Issue
Block a user