fix(mobile): retain folder deletion fences safely

This commit is contained in:
Brennan Benson
2026-07-31 18:55:35 -07:00
parent 750fa4c425
commit d2e128bc55
6 changed files with 506 additions and 52 deletions
@@ -0,0 +1,71 @@
import type { ExecutionHostId } from '../shared/execution-host'
import type {
DeletedFolderWorkspaceSessionTombstone,
PersistedState,
WorkspaceKey
} 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
export function boundDeletedFolderTombstoneEvidence(
tombstone: DeletedFolderWorkspaceSessionTombstone
): DeletedFolderWorkspaceSessionTombstone {
const tabEntries = Object.entries(tombstone.tabConnectionIdsByHostId).flatMap(([hostId, tabs]) =>
Object.entries(tabs ?? {}).map(
([tabId, connectionId]) => [hostId as ExecutionHostId, tabId, connectionId] as const
)
)
const retainedTabEntries = tabEntries.slice(Math.max(0, tabEntries.length - MAX_TAB_OWNERS))
const retainedHostIds = [
...new Set([...tombstone.hostIds, ...retainedTabEntries.map(([hostId]) => hostId)])
].slice(-MAX_HOST_IDS)
const retainedHostIdSet = new Set(retainedHostIds)
const tabConnectionIdsByHostId: DeletedFolderWorkspaceSessionTombstone['tabConnectionIdsByHostId'] =
{}
for (const [hostId, tabId, connectionId] of retainedTabEntries) {
if (!retainedHostIdSet.has(hostId)) {
continue
}
tabConnectionIdsByHostId[hostId] = {
...tabConnectionIdsByHostId[hostId],
[tabId]: connectionId
}
}
return { ...tombstone, hostIds: retainedHostIds, tabConnectionIdsByHostId }
}
export function getDeletedFolderTombstoneEvictionKeys(
tombstones: NonNullable<PersistedState['deletedFolderWorkspaceSessionTombstones']>,
now: number
): WorkspaceKey[] {
const entries = Object.entries(tombstones).flatMap(([workspaceKey, tombstone], index) =>
tombstone ? [{ index, workspaceKey: workspaceKey as WorkspaceKey, tombstone }] : []
)
const expired = entries.filter(
({ tombstone }) => now - tombstone.deletedAt >= MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS
)
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
)
.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)
]
}
+184 -18
View File
@@ -5148,6 +5148,7 @@ describe('Store', () => {
state.deletedFolderWorkspaceSessionTombstones = {
'folder:partial-tombstone': {
connectionId: null,
deletedAt: undefined as unknown as number,
hostIds: ['local'],
tabConnectionIdsByHostId: {
local: { valid: null, invalid: 42 as unknown as string }
@@ -5171,6 +5172,7 @@ describe('Store', () => {
]
).toEqual({
connectionId: null,
deletedAt: expect.any(Number),
hostIds: ['local'],
tabConnectionIdsByHostId: { local: { valid: null } }
})
@@ -5183,10 +5185,16 @@ describe('Store', () => {
const workspaceKeys = Array.from({ length: 514 }, (_, index) =>
folderWorkspaceKey(`deleted-${index}`)
)
const deletedAt = Date.now() - 2 * 24 * 60 * 60 * 1000
state.deletedFolderWorkspaceSessionTombstones = Object.fromEntries(
workspaceKeys.map((workspaceKey) => [
workspaceKeys.map((workspaceKey, index) => [
workspaceKey,
{ connectionId: null, hostIds: ['local'], tabConnectionIdsByHostId: {} }
{
connectionId: null,
deletedAt: deletedAt + index,
hostIds: ['local'],
tabConnectionIdsByHostId: {}
}
])
) as PersistedState['deletedFolderWorkspaceSessionTombstones']
writeDataFile(state)
@@ -5207,30 +5215,68 @@ describe('Store', () => {
expect(tombstones?.[workspaceKeys.at(-1)!]).toBeDefined()
})
it('bounds deleted-folder tombstones during repeated workspace churn', async () => {
it('retains recent deletion fences before applying the tombstone soft cap', async () => {
const store = await createStore()
const group = store.createProjectGroup({
name: 'Tombstone churn',
parentPath: '/workspace/churn',
createdFrom: 'folder-scan'
})
const workspaceKeys: ReturnType<typeof folderWorkspaceKey>[] = []
for (let index = 0; index < 514; index += 1) {
const workspace = store.createFolderWorkspace({
projectGroupId: group.id,
name: `Deleted ${index}`
})
workspaceKeys.push(folderWorkspaceKey(workspace.id))
expect(store.removeFolderWorkspace(workspace.id)).toBe(true)
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
const workspaceKeys: ReturnType<typeof folderWorkspaceKey>[] = []
for (let index = 0; index < 514; index += 1) {
const workspace = store.createFolderWorkspace({
projectGroupId: group.id,
name: `Deleted ${index}`
})
workspaceKeys.push(folderWorkspaceKey(workspace.id))
expect(store.removeFolderWorkspace(workspace.id)).toBe(true)
}
store.flush()
let tombstones = (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones
expect(Object.keys(tombstones ?? {})).toHaveLength(514)
expect(tombstones?.[workspaceKeys[0]!]).toBeDefined()
vi.setSystemTime(new Date('2026-01-02T00:00:00.001Z'))
const latest = store.createFolderWorkspace({ projectGroupId: group.id, name: 'Latest' })
expect(store.removeFolderWorkspace(latest.id)).toBe(true)
store.flush()
tombstones = (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones
expect(Object.keys(tombstones ?? {})).toHaveLength(512)
expect(tombstones?.[workspaceKeys[0]!]).toBeUndefined()
expect(tombstones?.[folderWorkspaceKey(latest.id)]).toBeDefined()
} finally {
vi.useRealTimers()
}
})
it('removes deleted-folder tombstones after the stale-write retention window', async () => {
const initial = await createStore()
initial.flush()
const state = readDataFile() as PersistedState
state.deletedFolderWorkspaceSessionTombstones = {
'folder:expired-tombstone': {
connectionId: null,
deletedAt: Date.now() - 31 * 24 * 60 * 60 * 1000,
hostIds: ['local'],
tabConnectionIdsByHostId: {}
}
}
writeDataFile(state)
vi.useFakeTimers()
try {
const restored = await createStore()
await vi.advanceTimersByTimeAsync(1_000)
await restored.waitForPendingWrite()
} finally {
vi.useRealTimers()
}
store.flush()
const tombstones = (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones
expect(Object.keys(tombstones ?? {})).toHaveLength(512)
expect(tombstones?.[workspaceKeys[0]!]).toBeUndefined()
expect(tombstones?.[workspaceKeys[1]!]).toBeUndefined()
expect(tombstones?.[workspaceKeys.at(-1)!]).toBeDefined()
expect((readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones).toEqual({})
})
it('backfills folder-scope SSH provenance from unambiguous child repos on load', async () => {
@@ -6785,6 +6831,126 @@ describe('Store', () => {
).toThrow('folder_workspace_not_found')
})
it('keeps a recent remote stale-write fence through rapid folder deletion churn', async () => {
const store = await createStore()
const connectionId = 'rapid-churn-remote'
const hostId = toRuntimeExecutionHostId('rapid-churn-runtime')
const group = store.createProjectGroup({
name: 'Rapid churn',
parentPath: '/remote/rapid-churn',
connectionId,
createdFrom: 'folder-scan'
})
const workspace = store.createFolderWorkspace({ projectGroupId: group.id, connectionId })
const workspaceKey = folderWorkspaceKey(workspace.id)
const staleSession = {
...getDefaultWorkspaceSession(),
activeWorkspaceKey: workspaceKey,
tabsByWorktree: {
[workspaceKey]: [makeTerminalTab({ id: 'rapid-churn-stale-tab', worktreeId: workspaceKey })]
}
} satisfies WorkspaceSessionState
store.setWorkspaceSession(staleSession, hostId)
expect(store.removeFolderWorkspace(workspace.id)).toBe(true)
for (let index = 0; index < 512; index += 1) {
const churned = store.createFolderWorkspace({
projectGroupId: group.id,
name: `Churned ${index}`
})
expect(store.removeFolderWorkspace(churned.id)).toBe(true)
}
store.setWorkspaceSession(staleSession, hostId)
store.upsertSshRemotePtyLease({
targetId: connectionId,
ptyId: 'rapid-churn-stale-pty',
worktreeId: workspaceKey,
tabId: 'rapid-churn-stale-tab',
leafId: TEST_LEAF_1,
state: 'detached'
})
expect(store.getWorkspaceSession(hostId).tabsByWorktree[workspaceKey]).toBeUndefined()
expect(store.getSshRemotePtyLeases()).toEqual([])
})
it('bounds detailed tab-owner evidence for one deleted folder', async () => {
const store = await createStore()
const hostId = toRuntimeExecutionHostId('tab-evidence-runtime')
const group = store.createProjectGroup({
name: 'Tab evidence',
parentPath: '/remote/tab-evidence',
createdFrom: 'folder-scan'
})
const workspace = store.createFolderWorkspace({ projectGroupId: group.id })
const workspaceKey = folderWorkspaceKey(workspace.id)
expect(store.removeFolderWorkspace(workspace.id)).toBe(true)
for (let index = 0; index < 300; index += 1) {
const tabId = `delayed-tab-${index}`
store.setWorkspaceSession(
{
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[workspaceKey]: [makeTerminalTab({ id: tabId, worktreeId: workspaceKey })]
}
},
hostId
)
}
store.flush()
const tombstone = (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones?.[
workspaceKey
]
expect(Object.keys(tombstone?.tabConnectionIdsByHostId[hostId] ?? {})).toHaveLength(256)
const ownerIndex = (
store as unknown as {
deletedFolderOwnersByHostAndTabId: Map<string, Map<string, unknown>>
}
).deletedFolderOwnersByHostAndTabId
expect(ownerIndex.get(hostId)?.size).toBe(256)
})
it('bounds host-partition evidence for one deleted folder', async () => {
const store = await createStore()
const group = store.createProjectGroup({
name: 'Host evidence',
parentPath: '/remote/host-evidence',
createdFrom: 'folder-scan'
})
const workspace = store.createFolderWorkspace({ projectGroupId: group.id })
const workspaceKey = folderWorkspaceKey(workspace.id)
expect(store.removeFolderWorkspace(workspace.id)).toBe(true)
for (let index = 0; index < 40; index += 1) {
const hostId = toRuntimeExecutionHostId(`host-evidence-${index}`)
const tabId = `host-evidence-tab-${index}`
store.setWorkspaceSession(
{
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[workspaceKey]: [makeTerminalTab({ id: tabId, worktreeId: workspaceKey })]
}
},
hostId
)
}
store.flush()
const tombstone = (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones?.[
workspaceKey
]
expect(tombstone?.hostIds).toHaveLength(32)
expect(Object.keys(tombstone?.tabConnectionIdsByHostId ?? {})).toHaveLength(32)
const ownerIndex = (
store as unknown as {
deletedFolderOwnersByHostAndTabId: Map<string, Map<string, unknown>>
}
).deletedFolderOwnersByHostAndTabId
expect(ownerIndex.size).toBe(32)
})
it('durably fences a delayed unified-only folder tab before a layout-only write', async () => {
const store = await createStore()
const connectionId = 'unified-only-delayed-target'
+81 -34
View File
@@ -88,6 +88,12 @@ import {
import { isWorkspaceLinkedItemSourceContextMatch } from '../shared/workspace-linked-item-source-context'
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
} from './deleted-folder-session-tombstones'
import { normalizePersistedMobileClientTabSelections } from './runtime/client-session-tab-selection-persistence'
import { sanitizeWorkspaceSessionTerminalRetirements } from './runtime/mobile-session-terminal-persistence-retirement'
import {
@@ -567,8 +573,6 @@ const WORKSPACE_SESSION_PATCH_OWNER_KEYS = new Set<keyof WorkspaceSessionState>(
'terminalSurfaceTombstonesByPaneKey',
'activeWorkspaceExecutionHostId'
])
const MAX_DELETED_FOLDER_WORKSPACE_SESSION_TOMBSTONES = 512
function logPersistenceStartupMilestone(
event: string,
details: Record<string, unknown> = {}
@@ -2871,6 +2875,7 @@ function normalizeDeletedFolderWorkspaceSessionTombstones(
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { tombstones, changed }
}
const now = Date.now()
for (const [workspaceKey, rawTombstone] of Object.entries(value)) {
const scope = parseWorkspaceKey(workspaceKey)
if (
@@ -2885,6 +2890,17 @@ function normalizeDeletedFolderWorkspaceSessionTombstones(
}
const raw = rawTombstone as Partial<DeletedFolderWorkspaceSessionTombstone>
const connectionId = typeof raw.connectionId === 'string' ? raw.connectionId : null
const deletedAt =
typeof raw.deletedAt === 'number' &&
Number.isFinite(raw.deletedAt) &&
raw.deletedAt >= 0 &&
raw.deletedAt <= now
? raw.deletedAt
: now - MIN_DELETED_FOLDER_TOMBSTONE_RETENTION_MS
if (now - deletedAt >= MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS) {
changed = true
continue
}
const hostIds = new Set<ExecutionHostId>()
for (const rawHostId of Array.isArray(raw.hostIds) ? raw.hostIds : []) {
const hostId = normalizeExecutionHostId(rawHostId)
@@ -2917,22 +2933,19 @@ function normalizeDeletedFolderWorkspaceSessionTombstones(
)
}
}
const normalizedTombstone: DeletedFolderWorkspaceSessionTombstone = {
const normalizedTombstone = boundDeletedFolderTombstoneEvidence({
connectionId,
deletedAt,
hostIds: [...hostIds],
tabConnectionIdsByHostId
}
})
tombstones[workspaceKey as WorkspaceKey] = normalizedTombstone
if (JSON.stringify(rawTombstone) !== JSON.stringify(normalizedTombstone)) {
changed = true
}
}
const workspaceKeys = Object.keys(tombstones)
for (const workspaceKey of workspaceKeys.slice(
0,
Math.max(0, workspaceKeys.length - MAX_DELETED_FOLDER_WORKSPACE_SESSION_TOMBSTONES)
)) {
delete tombstones[workspaceKey as WorkspaceKey]
for (const workspaceKey of getDeletedFolderTombstoneEvictionKeys(tombstones, now)) {
delete tombstones[workspaceKey]
changed = true
}
return { tombstones, changed }
@@ -3032,18 +3045,29 @@ export class Store {
if (!tombstone) {
continue
}
if (tombstone.connectionId) {
this.deletedFolderConnectionIdByWorkspaceKey.set(workspaceKey, tombstone.connectionId)
this.indexDeletedFolderWorkspaceSessionTombstone(workspaceKey, tombstone)
}
}
private indexDeletedFolderWorkspaceSessionTombstone(
workspaceKey: string,
tombstone: DeletedFolderWorkspaceSessionTombstone
): void {
if (tombstone.connectionId) {
this.deletedFolderConnectionIdByWorkspaceKey.set(workspaceKey, tombstone.connectionId)
}
for (const [hostId, tabConnectionIds] of Object.entries(tombstone.tabConnectionIdsByHostId)) {
const resolvedHostId = normalizeExecutionHostId(hostId)
if (!resolvedHostId) {
continue
}
for (const [hostId, tabConnectionIds] of Object.entries(tombstone.tabConnectionIdsByHostId)) {
const resolvedHostId = normalizeExecutionHostId(hostId)
if (!resolvedHostId) {
continue
}
for (const [tabId, connectionId] of Object.entries(tabConnectionIds ?? {})) {
this.rememberDeletedFolderTabOwner(resolvedHostId, tabId, workspaceKey, connectionId)
}
const ownersByTabId = this.deletedFolderOwnersByHostAndTabId.get(resolvedHostId) ?? new Map()
for (const [tabId, connectionId] of Object.entries(tabConnectionIds ?? {})) {
const owners = ownersByTabId.get(tabId) ?? new Map<string, string | null>()
owners.set(workspaceKey, connectionId)
ownersByTabId.set(tabId, owners)
}
this.deletedFolderOwnersByHostAndTabId.set(resolvedHostId, ownersByTabId)
}
}
@@ -4558,6 +4582,7 @@ export class Store {
const prior = this.state.deletedFolderWorkspaceSessionTombstones?.[workspaceKey as WorkspaceKey]
this.setDeletedFolderWorkspaceSessionTombstone(workspaceKey, {
connectionId: connectionId ?? prior?.connectionId ?? null,
deletedAt: prior?.deletedAt ?? Date.now(),
hostIds: [...new Set([...(prior?.hostIds ?? []), ...hostIds])],
tabConnectionIdsByHostId: prior?.tabConnectionIdsByHostId ?? {}
})
@@ -4692,18 +4717,21 @@ export class Store {
this.deletedFolderOwnersByHostAndTabId.set(hostId, ownersByTabId)
const prior = this.state.deletedFolderWorkspaceSessionTombstones?.[workspaceKey as WorkspaceKey]
const tabConnectionIdsByHostId = { ...prior?.tabConnectionIdsByHostId }
tabConnectionIdsByHostId[hostId] = {
...tabConnectionIdsByHostId[hostId],
[tabId]: connectionId ?? tabConnectionIdsByHostId[hostId]?.[tabId] ?? null
}
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(),
hostIds: [
...new Set([
...(prior?.hostIds ?? []),
hostId,
...(connectionId ? [toSshExecutionHostId(connectionId)] : [])
])
...new Set([...retainedHostIds, hostId, ...(connectionHostId ? [connectionHostId] : [])])
],
tabConnectionIdsByHostId
})
@@ -4713,20 +4741,37 @@ export class Store {
workspaceKey: string,
tombstone: DeletedFolderWorkspaceSessionTombstone
): void {
tombstone = boundDeletedFolderTombstoneEvidence(tombstone)
const tombstones = { ...this.state.deletedFolderWorkspaceSessionTombstones }
delete tombstones[workspaceKey as WorkspaceKey]
tombstones[workspaceKey as WorkspaceKey] = tombstone
const workspaceKeys = Object.keys(tombstones)
for (const evictedWorkspaceKey of workspaceKeys.slice(
0,
Math.max(0, workspaceKeys.length - MAX_DELETED_FOLDER_WORKSPACE_SESSION_TOMBSTONES)
this.forgetDeletedFolderWorkspaceSessionTombstone(workspaceKey)
this.indexDeletedFolderWorkspaceSessionTombstone(workspaceKey, tombstone)
for (const evictedWorkspaceKey of getDeletedFolderTombstoneEvictionKeys(
tombstones,
Date.now()
)) {
delete tombstones[evictedWorkspaceKey as WorkspaceKey]
delete tombstones[evictedWorkspaceKey]
this.forgetDeletedFolderWorkspaceSessionTombstone(evictedWorkspaceKey)
}
this.state.deletedFolderWorkspaceSessionTombstones = tombstones
}
private pruneDeletedFolderWorkspaceSessionTombstones(): void {
const current = this.state.deletedFolderWorkspaceSessionTombstones ?? {}
const evictedWorkspaceKeys = getDeletedFolderTombstoneEvictionKeys(current, Date.now())
if (evictedWorkspaceKeys.length === 0) {
return
}
const tombstones = { ...current }
for (const workspaceKey of evictedWorkspaceKeys) {
delete tombstones[workspaceKey]
this.forgetDeletedFolderWorkspaceSessionTombstone(workspaceKey)
}
this.state.deletedFolderWorkspaceSessionTombstones = tombstones
this.scheduleSave()
}
private forgetDeletedFolderWorkspaceSessionTombstone(workspaceKey: string): void {
this.deletedFolderConnectionIdByWorkspaceKey.delete(workspaceKey)
for (const [hostId, ownersByTabId] of this.deletedFolderOwnersByHostAndTabId) {
@@ -4858,6 +4903,7 @@ export class Store {
}
private isDeletedFolderWorkspaceKey(worktreeId: string): boolean {
this.pruneDeletedFolderWorkspaceSessionTombstones()
const scope = parseWorkspaceKey(worktreeId)
return Boolean(
scope?.type === 'folder' &&
@@ -5057,6 +5103,7 @@ export class Store {
session: WorkspaceSessionState,
hostId: ExecutionHostId
): WorkspaceSessionState {
this.pruneDeletedFolderWorkspaceSessionTombstones()
const liveFolderIds = new Set(
(this.state.folderWorkspaces ?? []).map((workspace) => workspace.id)
)
@@ -0,0 +1,153 @@
import { describe, expect, it, vi } from 'vitest'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import { makePaneKey } from '../../shared/stable-pane-id'
import type { FolderWorkspace, ProjectGroup, WorkspaceSessionState } from '../../shared/types'
import { OrcaRuntimeService } from './orca-runtime'
const electronMocks = vi.hoisted(() => {
const ipcMain = {
on: vi.fn(() => ipcMain),
removeListener: vi.fn(() => ipcMain),
emit: vi.fn(() => true)
}
return {
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false },
BrowserWindow: { fromId: vi.fn(() => ({ isDestroyed: () => false })) },
ipcMain,
Notification: vi.fn(),
webContents: { fromId: vi.fn(() => null) }
}
})
vi.mock('electron', () => electronMocks)
const TAB_ID = '11111111-1111-4111-8111-111111111111'
const LEAF_ID = '22222222-2222-4222-8222-222222222222'
function makeSession(worktreeId: string): WorkspaceSessionState {
const ptyId = `${worktreeId}@@race-pty`
return {
...getDefaultWorkspaceSession(),
activeWorktreeId: worktreeId,
activeTabId: TAB_ID,
activeTabIdByWorktree: { [worktreeId]: TAB_ID },
tabsByWorktree: {
[worktreeId]: [
{
id: TAB_ID,
ptyId,
worktreeId,
title: 'Persisted terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
[TAB_ID]: {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: ptyId }
}
},
terminalPtyIncarnationsByPaneKey: {
[makePaneKey(TAB_ID, LEAF_ID)]: '33333333-3333-4333-8333-333333333333'
}
}
}
function createRuntime(folders: FolderWorkspace[], group: ProjectGroup): OrcaRuntimeService {
const session = makeSession(`folder:${folders[0]!.id}`)
return new OrcaRuntimeService({
getRepo: () => undefined,
getRepos: () => [],
getFolderWorkspaces: () => folders,
getProjectGroups: () => [group],
getWorkspaceSession: () => session,
getWorkspaceSessionHostIds: () => ['local'],
addRepo: () => {},
updateRepo: () => undefined as never,
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
getGitHubCache: () => ({ pr: {}, issue: {} }),
setWorktreeMeta: () => undefined as never,
removeWorktreeMeta: () => {},
getSettings: () => ({
workspaceDir: '/workspace',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
branchPrefix: 'none',
branchPrefixCustom: ''
})
} as never)
}
describe('mobile folder session mutation races', () => {
it.each(['activate', 'close'] as const)(
'rejects a folder tab %s when deletion races PTY inventory',
async (operation) => {
const folder: FolderWorkspace = {
id: `${operation}-race-folder`,
projectGroupId: `${operation}-race-group`,
name: `${operation} race`,
folderPath: `/workspace/${operation}-race`,
connectionId: null,
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
}
const group: ProjectGroup = {
id: folder.projectGroupId,
name: folder.projectGroupId,
parentPath: folder.folderPath,
connectionId: null,
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const folderKey = `folder:${folder.id}`
const folders = [folder]
const runtime = createRuntime(folders, group)
await runtime.listMobileSessionTabs(`id:${folderKey}`)
let releaseInventory!: (value: []) => void
const inventory = new Promise<[]>((resolve) => {
releaseInventory = resolve
})
const listProcesses = vi.fn(() => inventory)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses
})
const mutation =
operation === 'activate'
? runtime.activateMobileSessionTab(`id:${folderKey}`, TAB_ID)
: runtime.closeMobileSessionTab(`id:${folderKey}`, TAB_ID)
await vi.waitFor(() => expect(listProcesses).toHaveBeenCalledOnce())
folders.length = 0
releaseInventory([])
await expect(mutation).rejects.toThrow('tab_not_found')
await expect(runtime.listMobileSessionTabs(`id:${folderKey}`)).resolves.toMatchObject({
worktree: folderKey,
removed: true,
tabs: []
})
}
)
})
+16
View File
@@ -4920,6 +4920,20 @@ export class OrcaRuntimeService {
return recreatedFolderWorktreeIds
}
private assertMobileSessionFolderWorkspaceExists(worktreeId: string): void {
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type !== 'folder' || !this.store?.getFolderWorkspaces) {
return
}
const folderWorkspaces = this.store.getFolderWorkspaces()
this.pruneRemovedFolderMobileSessionTabs(folderWorkspaces)
if (folderWorkspaces.some((workspace) => workspace.id === scope.folderWorkspaceId)) {
return
}
this.rememberRemovedFolderMobileSessionTabs(worktreeId)
throw new Error('tab_not_found')
}
private createFolderWorkspaceInventoryFence(
folderWorkspaces: readonly FolderWorkspace[]
): () => boolean {
@@ -8006,6 +8020,7 @@ export class OrcaRuntimeService {
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
await this.refreshMobileSessionPtyRecords(worktreeId)
this.assertMobileSessionFolderWorkspaceExists(worktreeId)
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
const directTab = snapshot?.tabs.find((candidate) => candidate.id === tabId)
const tab = leafId
@@ -8350,6 +8365,7 @@ export class OrcaRuntimeService {
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
const observedPtyIds = await this.refreshMobileSessionPtyRecords()
this.assertMobileSessionFolderWorkspaceExists(worktreeId)
if (graphEpoch !== null) {
this.assertStableReadyGraph(graphEpoch)
}
+1
View File
@@ -3566,6 +3566,7 @@ export type PersistedMobileClientTabSelections = Record<
export type DeletedFolderWorkspaceSessionTombstone = {
connectionId: string | null
deletedAt: number
hostIds: ExecutionHostId[]
tabConnectionIdsByHostId: Partial<Record<ExecutionHostId, Record<string, string | null>>>
}