diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 584cd9a9c9a..2b5272f440d 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -6,7 +6,7 @@ import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-worksp import { useStartupActions } from './use-app-startup-actions' import { WORKTREE_REFRESH_CONCURRENCY } from '../store/slices/worktrees' import { sweepRestoredCodexPanesForStaleAccounts } from '../lib/codex-stale-pane-sweep' -import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-persistence' +import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-hydration' import { collectFolderWorkspaceKeysFromSession, collectWorktreeHydrationRepoIdsFromSession @@ -189,7 +189,9 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta timeRendererStartupSyncStep('hydrate-session-stores', () => { actions.hydrateWorkspaceSession(sessionRead.session, { ...sessionHydrationOptions, - runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey + runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey, + contestedHostWorkspaceSessions: sessionRead.contestedHostWorkspaceSessions, + contestedPrimaryHostBySessionKey: sessionRead.contestedPrimaryHostBySessionKey }) actions.hydrateTabsSession(sessionRead.session, sessionHydrationOptions) actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions) diff --git a/src/renderer/src/lib/workspace-session-host-contention.test.ts b/src/renderer/src/lib/workspace-session-host-contention.test.ts new file mode 100644 index 00000000000..d2da1da82df --- /dev/null +++ b/src/renderer/src/lib/workspace-session-host-contention.test.ts @@ -0,0 +1,432 @@ +/** + * A worktree id is `repoId::path` with no host component, so one repo registered on two hosts + * publishes the same id for two different workspaces (STA-4343). Persistence used to fold every + * such id into the 'local' partition, which gave both workspaces ONE `tabsByWorktree` bucket: + * whichever host wrote last erased the other's tabs permanently. + */ +import { describe, expect, it, vi, type Mock } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { + indexWorktreeHostClaims, + mergeWorkspaceSessionsWithHostShadow, + pickPrimaryHostForClaims +} from './workspace-session-host-contention' +import { fetchWorkspaceSessionWithRuntimeHostOwners } from './workspace-session-host-hydration' +import { + buildHostIdByWorktreeId, + patchWorkspaceSessionByHost, + persistWorkspaceSessionByHost, + type HostPersistenceState +} from './workspace-session-host-persistence' + +const SHARED_ID = 'repo-shared::/work/orca' +const SSH_HOST: ExecutionHostId = 'ssh:build-box' + +function tab(id: string, worktreeId = SHARED_ID): TerminalTab { + return { + id, + ptyId: null, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function sessionWithTabs(entries: Record): WorkspaceSessionState { + return { ...getDefaultWorkspaceSession(), tabsByWorktree: entries } +} + +function contestedState(overrides: Partial = {}): HostPersistenceState { + return { + repos: [ + { id: 'repo-shared', connectionId: null, executionHostId: 'local' }, + { + id: 'repo-shared', + connectionId: 'build-box', + executionHostId: SSH_HOST + } + ], + worktreesByRepo: { + 'repo-shared': [ + { id: SHARED_ID, repoId: 'repo-shared', hostId: 'local' }, + { id: SHARED_ID, repoId: 'repo-shared', hostId: SSH_HOST } + ] + }, + ...overrides + } +} + +describe('indexWorktreeHostClaims', () => { + it('records every host publishing the same workspace id', () => { + const claims = indexWorktreeHostClaims(contestedState().worktreesByRepo, new Map()) + + expect([...(claims.get(SHARED_ID) ?? [])].sort()).toEqual(['local', SSH_HOST]) + }) + + it('attributes an unqualified row through its repo when the repo names one host', () => { + const claims = indexWorktreeHostClaims( + { 'repo-a': [{ id: 'repo-a::/work/a', repoId: 'repo-a' }] }, + new Map([['repo-a', SSH_HOST]]) + ) + + expect([...(claims.get('repo-a::/work/a') ?? [])]).toEqual([SSH_HOST]) + }) + + it('leaves an unqualified row unattributed when its repo id is itself ambiguous', () => { + const claims = indexWorktreeHostClaims( + { 'repo-a': [{ id: 'repo-a::/work/a', repoId: 'repo-a' }] }, + new Map([['repo-a', null]]) + ) + + expect(claims.has('repo-a::/work/a')).toBe(false) + }) + + it('prefers local as primary, else the lowest host id', () => { + expect(pickPrimaryHostForClaims([SSH_HOST, 'local'])).toBe('local') + expect(pickPrimaryHostForClaims(['runtime:b', 'runtime:a'])).toBe('runtime:a') + }) +}) + +describe('buildHostIdByWorktreeId for a contested workspace id', () => { + it('routes a local/SSH collision to one deterministic primary', () => { + expect(buildHostIdByWorktreeId(contestedState())(SHARED_ID)).toBe('local') + }) + + it('gives two runtime claimants a runtime primary instead of folding them into local', () => { + const owner = buildHostIdByWorktreeId({ + repos: [], + worktreesByRepo: { + 'repo-shared': [ + { id: SHARED_ID, repoId: 'repo-shared', hostId: 'runtime:env-b' }, + { id: SHARED_ID, repoId: 'repo-shared', hostId: 'runtime:env-a' } + ] + } + }) + + expect(owner(SHARED_ID)).toBe('runtime:env-a') + }) +}) + +describe('mergeWorkspaceSessionsWithHostShadow', () => { + it('parks a co-claimant partition entry instead of letting it win the shared key', () => { + const merged = mergeWorkspaceSessionsWithHostShadow({ + local: sessionWithTabs({ [SHARED_ID]: [tab('local-tab')] }), + [SSH_HOST]: sessionWithTabs({ [SHARED_ID]: [tab('ssh-tab')] }) + }) + + expect(merged.session.tabsByWorktree[SHARED_ID]?.map((entry) => entry.id)).toEqual([ + 'local-tab' + ]) + expect( + (merged.shadow[SSH_HOST]?.tabsByWorktree?.[SHARED_ID] ?? []).map((entry) => entry.id) + ).toEqual(['ssh-tab']) + expect(merged.shadow.local).toBeUndefined() + }) + + it('leaves uncontested partitions untouched', () => { + const merged = mergeWorkspaceSessionsWithHostShadow({ + local: sessionWithTabs({ 'repo-a::/a': [tab('a', 'repo-a::/a')] }), + 'runtime:env-1': sessionWithTabs({ + 'repo-b::/b': [tab('b', 'repo-b::/b')] + }) + }) + + expect(Object.keys(merged.session.tabsByWorktree).sort()).toEqual(['repo-a::/a', 'repo-b::/b']) + expect(merged.shadow).toEqual({}) + }) +}) + +type SessionWriteMock = Mock< + (session: WorkspaceSessionState, hostId?: ExecutionHostId) => Promise +> +type SessionPatchMock = Mock< + (patch: Partial, hostId?: ExecutionHostId) => Promise +> + +describe('writing a contested workspace id back', () => { + const RUNTIME_HOST: ExecutionHostId = 'runtime:env-1' + const RUNTIME_ONLY_ID = 'repo-runtime::/srv/app' + const shadow = { + [RUNTIME_HOST]: sessionWithTabs({ [SHARED_ID]: [tab('runtime-tab')] }) + } + + /** The runtime host owns a second workspace, so its partition is rewritten by every persist — + * the write that used to take the contested row down with it. */ + function runtimeCoClaimantState( + overrides: Partial = {} + ): HostPersistenceState { + return { + repos: [], + worktreesByRepo: { + 'repo-shared': [ + { id: SHARED_ID, repoId: 'repo-shared', hostId: 'local' }, + { id: SHARED_ID, repoId: 'repo-shared', hostId: RUNTIME_HOST } + ], + 'repo-runtime': [{ id: RUNTIME_ONLY_ID, repoId: 'repo-runtime', hostId: RUNTIME_HOST }] + }, + contestedHostWorkspaceSessions: shadow, + ...overrides + } + } + + function livePayload(): WorkspaceSessionState { + return sessionWithTabs({ + [SHARED_ID]: [tab('local-tab')], + [RUNTIME_ONLY_ID]: [tab('runtime-only-tab', RUNTIME_ONLY_ID)] + }) + } + + async function persist(state: HostPersistenceState): Promise { + const set: SessionWriteMock = vi.fn(async () => {}) + await persistWorkspaceSessionByHost( + { + set, + get: vi.fn(), + patch: vi.fn(), + setSync: vi.fn(), + flush: vi.fn(async () => {}) + }, + livePayload(), + state + ) + return set + } + + function tabIds(session: Partial | undefined, key: string): string[] { + return (session?.tabsByWorktree?.[key] ?? []).map((entry) => entry.id) + } + + it('keeps the co-claimant rows in its own partition when that partition is rewritten', async () => { + const set = await persist(runtimeCoClaimantState()) + + const runtimeWrite = set.mock.calls.find(([, hostId]) => hostId === RUNTIME_HOST)?.[0] + expect(tabIds(runtimeWrite, SHARED_ID)).toEqual(['runtime-tab']) + expect(tabIds(runtimeWrite, RUNTIME_ONLY_ID)).toEqual(['runtime-only-tab']) + const localWrite = set.mock.calls.find(([, hostId]) => hostId === undefined)?.[0] + expect(tabIds(localWrite, SHARED_ID)).toEqual(['local-tab']) + }) + + it('carries a parked field the slice never seeded through a full partition replace', async () => { + // Why: api.set swaps the whole partition, so a field with no live entry routing to the + // co-claimant would otherwise be written without its parked rows and erased on disk. + const set: SessionWriteMock = vi.fn(async () => {}) + await persistWorkspaceSessionByHost( + { + set, + get: vi.fn(), + patch: vi.fn(), + setSync: vi.fn(), + flush: vi.fn(async () => {}) + }, + { + ...sessionWithTabs({ [SHARED_ID]: [tab('local-tab')] }), + // Seeds the runtime slice (so its partition IS rewritten) without seeding tabsByWorktree. + lastVisitedAtByWorktreeId: { [RUNTIME_ONLY_ID]: 1 } + }, + runtimeCoClaimantState() + ) + + const runtimeWrite = set.mock.calls.find(([, hostId]) => hostId === RUNTIME_HOST)?.[0] + expect(runtimeWrite).toBeDefined() + expect(tabIds(runtimeWrite, SHARED_ID)).toEqual(['runtime-tab']) + }) + + it('restores the parked rows on the debounced patch path too', () => { + const patch: SessionPatchMock = vi.fn(async () => {}) + patchWorkspaceSessionByHost( + { patch, get: vi.fn(), setSync: vi.fn() }, + { tabsByWorktree: livePayload().tabsByWorktree }, + runtimeCoClaimantState() + ) + + const runtimePatch = patch.mock.calls.find(([, hostId]) => hostId === RUNTIME_HOST)?.[0] + expect(tabIds(runtimePatch, SHARED_ID)).toEqual(['runtime-tab']) + }) + + it('omits a field the patch never touched so the partition keeps its own copy', () => { + const patch: SessionPatchMock = vi.fn(async () => {}) + patchWorkspaceSessionByHost( + { patch, get: vi.fn(), setSync: vi.fn() }, + { activeTabId: 'tab-1' }, + runtimeCoClaimantState() + ) + + const runtimePatch = patch.mock.calls.find(([, hostId]) => hostId === RUNTIME_HOST)?.[0] + expect(runtimePatch?.tabsByWorktree).toBeUndefined() + }) + + it('drops a parked row once the catalog says that host no longer publishes the id', async () => { + const set = await persist( + runtimeCoClaimantState({ + worktreesByRepo: { + 'repo-shared': [{ id: SHARED_ID, repoId: 'repo-shared', hostId: 'local' }], + 'repo-runtime': [ + { + id: RUNTIME_ONLY_ID, + repoId: 'repo-runtime', + hostId: RUNTIME_HOST + } + ] + } + }) + ) + + const runtimeWrite = set.mock.calls.find(([, hostId]) => hostId === RUNTIME_HOST)?.[0] + expect(runtimeWrite?.tabsByWorktree[SHARED_ID]).toBeUndefined() + }) + + it('keeps a parked row whose workspace the catalog cannot speak for yet', async () => { + const folderKey = folderWorkspaceKey('folder-1') + const set = await persist( + runtimeCoClaimantState({ + contestedHostWorkspaceSessions: { + [RUNTIME_HOST]: sessionWithTabs({ + [folderKey]: [tab('folder-tab', folderKey)] + }) + } + }) + ) + + const runtimeWrite = set.mock.calls.find(([, hostId]) => hostId === RUNTIME_HOST)?.[0] + expect(tabIds(runtimeWrite, folderKey)).toEqual(['folder-tab']) + }) + + it('leaves an untouched partition alone rather than rewriting it from the shadow', async () => { + const set = await persist(contestedState({ contestedHostWorkspaceSessions: shadow })) + + expect(set.mock.calls.some(([, hostId]) => hostId === RUNTIME_HOST)).toBe(false) + }) +}) + +/** + * The read decides which partition a row came from; the write must not re-decide it. When the two + * disagreed, a write copied one host's workspace into another host's partition — worse than the + * shared bucket this PR set out to fix. + */ +describe('read-time primary is the one the write path honours', () => { + const RUNTIME_HOST: ExecutionHostId = 'runtime:env-1' + const RUNTIME_ONLY_ID = 'repo-runtime::/srv/app' + + function sshVersusRuntimeState( + overrides: Partial = {} + ): HostPersistenceState { + return { + repos: [], + worktreesByRepo: { + 'repo-shared': [ + { id: SHARED_ID, repoId: 'repo-shared', hostId: SSH_HOST }, + { id: SHARED_ID, repoId: 'repo-shared', hostId: RUNTIME_HOST } + ], + 'repo-runtime': [{ id: RUNTIME_ONLY_ID, repoId: 'repo-runtime', hostId: RUNTIME_HOST }] + }, + ...overrides + } + } + + it('keeps an SSH claimant in the local partition it actually persists in', () => { + // Why this shape: the claims catalog sorts `runtime:` before `ssh:`, so picking a primary from + // claimants sent the SSH workspace's rows into the runtime partition. + expect(buildHostIdByWorktreeId(sshVersusRuntimeState())(SHARED_ID)).toBe('local') + }) + + it('does not strand the runtime co-claimant when the SSH row is written', async () => { + const set: SessionWriteMock = vi.fn(async () => {}) + await persistWorkspaceSessionByHost( + { set, get: vi.fn(), patch: vi.fn(), setSync: vi.fn(), flush: vi.fn(async () => {}) }, + sessionWithTabs({ + [SHARED_ID]: [tab('ssh-tab')], + [RUNTIME_ONLY_ID]: [tab('runtime-only-tab', RUNTIME_ONLY_ID)] + }), + sshVersusRuntimeState({ + contestedHostWorkspaceSessions: { + [RUNTIME_HOST]: sessionWithTabs({ [SHARED_ID]: [tab('runtime-tab')] }) + } + }) + ) + + const runtimeWrite = set.mock.calls.find(([, hostId]) => hostId === RUNTIME_HOST)?.[0] + expect(runtimeWrite?.tabsByWorktree[SHARED_ID]?.map((entry) => entry.id)).toEqual([ + 'runtime-tab' + ]) + const localWrite = set.mock.calls.find(([, hostId]) => hostId === undefined)?.[0] + expect(localWrite?.tabsByWorktree[SHARED_ID]?.map((entry) => entry.id)).toEqual(['ssh-tab']) + }) + + it('writes a row back to the only partition that had it instead of copying it', async () => { + const set: SessionWriteMock = vi.fn(async () => {}) + await persistWorkspaceSessionByHost( + { set, get: vi.fn(), patch: vi.fn(), setSync: vi.fn(), flush: vi.fn(async () => {}) }, + sessionWithTabs({ [SHARED_ID]: [tab('runtime-tab')] }), + { + repos: [], + worktreesByRepo: { + 'repo-shared': [ + { id: SHARED_ID, repoId: 'repo-shared', hostId: 'local' }, + { id: SHARED_ID, repoId: 'repo-shared', hostId: RUNTIME_HOST } + ] + }, + contestedPrimaryHostBySessionKey: { [SHARED_ID]: RUNTIME_HOST } + } + ) + + const runtimeWrite = set.mock.calls.find(([, hostId]) => hostId === RUNTIME_HOST)?.[0] + expect(runtimeWrite?.tabsByWorktree[SHARED_ID]?.map((entry) => entry.id)).toEqual([ + 'runtime-tab' + ]) + const localWrite = set.mock.calls.find(([, hostId]) => hostId === undefined)?.[0] + expect(localWrite?.tabsByWorktree[SHARED_ID]).toBeUndefined() + }) + + it('still migrates a workspace the catalog has re-attributed to another partition', () => { + const owner = buildHostIdByWorktreeId({ + repos: [], + worktreesByRepo: { + 'repo-shared': [{ id: SHARED_ID, repoId: 'repo-shared', hostId: RUNTIME_HOST }] + }, + contestedPrimaryHostBySessionKey: { [SHARED_ID]: 'local' } + }) + + expect(owner(SHARED_ID)).toBe(RUNTIME_HOST) + }) + + it('records the partition every restored key came from, contested or not', () => { + const merged = mergeWorkspaceSessionsWithHostShadow({ + local: sessionWithTabs({ [SHARED_ID]: [tab('local-tab')] }), + [RUNTIME_HOST]: sessionWithTabs({ + [SHARED_ID]: [tab('runtime-tab')], + [RUNTIME_ONLY_ID]: [tab('runtime-only-tab', RUNTIME_ONLY_ID)] + }) + }) + + expect(merged.primaryHostBySessionKey).toEqual({ + [SHARED_ID]: 'local', + [RUNTIME_ONLY_ID]: RUNTIME_HOST + }) + }) + + it('does not name a runtime owner for a key the local partition kept', async () => { + const read = await fetchWorkspaceSessionWithRuntimeHostOwners( + { + get: vi.fn(async (hostId?: ExecutionHostId) => + hostId === RUNTIME_HOST + ? sessionWithTabs({ [SHARED_ID]: [tab('runtime-tab')] }) + : sessionWithTabs({ [SHARED_ID]: [tab('local-tab')] }) + ) + }, + [], + [RUNTIME_HOST] + ) + + // Why it matters: a runtime owner here makes startup build runtime placeholders for the local + // workspace whose row the merge actually kept. + expect(read.runtimeHostIdByWorkspaceSessionKey[SHARED_ID]).toBeUndefined() + expect(read.contestedPrimaryHostBySessionKey[SHARED_ID]).toBe('local') + }) +}) diff --git a/src/renderer/src/lib/workspace-session-host-contention.ts b/src/renderer/src/lib/workspace-session-host-contention.ts new file mode 100644 index 00000000000..51d09027200 --- /dev/null +++ b/src/renderer/src/lib/workspace-session-host-contention.ts @@ -0,0 +1,302 @@ +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { + getWorktreeIdFromHostIdentity, + isWorktreeHostIdentity +} from '../../../shared/worktree/host-qualified-identity' +import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from './workspace-session-host-field-ownership' +import { + isWorkspaceSessionRecord, + type WorkspaceSessionRecord +} from './workspace-session-host-records' +import type { WorkspaceRuntimeOwnerProjection } from './workspace-runtime-host-ownership' +import { + mergeWorkspaceSessionsFromHosts, + type HostSessionSlices +} from './workspace-session-host-split' + +/** + * Which execution hosts publish each workspace id, and what persistence does when two of them + * publish the same one. + * + * A worktree id is `repoId::path` with no host component, so one repo registered on two hosts + * publishes the SAME id for two different workspaces (STA-4343). Session state is keyed by that + * bare id, so without this the two workspaces share one `tabsByWorktree` bucket and whichever host + * writes last erases the other's tabs for good. + * + * The contested id gets one primary host, whose entries keep the normal bare key in the unified + * renderer session. Every other claimant's entries are parked in a shadow that never reaches + * renderer state and is written straight back to that host's own partition, so no host's session is + * destroyed by another's write. + * + * The primary is decided ONCE, at read time, from the partition each row actually came from, and + * that decision is carried back to the write path. Re-deriving it from the catalog at write time + * would let the two disagree — the catalog names `ssh:*` hosts that own no partition — and the + * write would then copy one host's workspace into another host's partition. + * + * Known gaps: hosts that share a partition cannot be separated at all ('local' and every `ssh:*` + * host persist into the 'local' blob), and the unified renderer session still holds one bucket per + * bare id, so both workspaces display the primary's tabs. Closing either needs host-qualified keys + * through the whole tab store. + */ + +export type WorktreeHostClaims = ReadonlyMap> + +const WORKTREE_KEYED_FIELDS = ( + Object.keys(WORKSPACE_SESSION_FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[] +).filter((field) => WORKSPACE_SESSION_FIELD_OWNERSHIP[field] === 'worktreeKeyed') + +/** Bare worktree id behind a session key, which may be a WorkspaceKey or a host-qualified identity. */ +export function normalizeWorkspaceSessionKeyToWorktreeId(value: string): string { + if (isWorktreeHostIdentity(value)) { + return getWorktreeIdFromHostIdentity(value) + } + const scope = parseWorkspaceKey(value) + return scope?.type === 'worktree' ? scope.worktreeId : value +} + +function resolveClaimedHostId( + worktree: WorkspaceRuntimeOwnerProjection, + repoHostById: ReadonlyMap +): ExecutionHostId | null { + const runtimeOwner = worktree.runtimeOwnerEnvironmentId?.trim() + if (runtimeOwner) { + return toRuntimeExecutionHostId(runtimeOwner) + } + const parsed = parseExecutionHostId(worktree.hostId) + if (parsed) { + return parsed.id + } + // Why: an unqualified row is attributable only when its repo id names exactly one host — + // guessing would invent a contest that is not there, or hide one that is. + return repoHostById.get(worktree.repoId) ?? null +} + +export function indexWorktreeHostClaims( + worktreesByRepo: Record, + repoHostById: ReadonlyMap +): WorktreeHostClaims { + const claims = new Map>() + for (const worktrees of Object.values(worktreesByRepo)) { + for (const worktree of worktrees) { + const hostId = resolveClaimedHostId(worktree, repoHostById) + if (!hostId) { + continue + } + const existing = claims.get(worktree.id) + if (existing) { + existing.add(hostId) + } else { + claims.set(worktree.id, new Set([hostId])) + } + } + } + return claims +} + +/** The partition a host's session rows live in: a runtime host owns one, while 'local' and every + * `ssh:*` host share the 'local' blob. */ +export function sessionPartitionHostFor(hostId: ExecutionHostId): ExecutionHostId { + return parseExecutionHostId(hostId)?.kind === 'runtime' ? hostId : LOCAL_EXECUTION_HOST_ID +} + +/** Distinct partitions a set of claimants spans. Fewer than two means persistence cannot tell the + * claimants apart, so the id keeps its uncontested routing. */ +export function contestedPartitionHosts(claimed: Iterable): ExecutionHostId[] { + return [...new Set([...claimed].map(sessionPartitionHostFor))] +} + +/** Stable owner of a contested id: 'local' when it is a claimant, else the lowest host id. + * Deliberately not the active host — a primary that followed navigation would migrate the same + * rows between partitions on every workspace switch. */ +export function pickPrimaryHostForClaims(hostIds: Iterable): ExecutionHostId { + const sorted = [...hostIds].sort() + return sorted.includes(LOCAL_EXECUTION_HOST_ID) + ? LOCAL_EXECUTION_HOST_ID + : (sorted[0] ?? LOCAL_EXECUTION_HOST_ID) +} + +function definedHostIds(slices: HostSessionSlices): ExecutionHostId[] { + return (Object.keys(slices) as ExecutionHostId[]).filter((hostId) => slices[hostId]) +} + +function indexHostIdsBySessionKey( + slices: HostSessionSlices, + hostIds: readonly ExecutionHostId[] +): Map { + const hostIdsByKey = new Map() + for (const hostId of hostIds) { + for (const field of WORKTREE_KEYED_FIELDS) { + const record = slices[hostId]?.[field] + if (!isWorkspaceSessionRecord(record)) { + continue + } + for (const key of Object.keys(record)) { + const owners = hostIdsByKey.get(key) + if (!owners) { + hostIdsByKey.set(key, [hostId]) + } else if (!owners.includes(hostId)) { + owners.push(hostId) + } + } + } + } + return hostIdsByKey +} + +function shadowHostEntries( + slice: WorkspaceSessionState, + hostId: ExecutionHostId, + primaryByKey: ReadonlyMap +): { slice: WorkspaceSessionState; shadow: WorkspaceSessionState | null } { + let nextSlice: WorkspaceSessionState | null = null + let shadow: WorkspaceSessionState | null = null + for (const field of WORKTREE_KEYED_FIELDS) { + const record = slice[field] + if (!isWorkspaceSessionRecord(record)) { + continue + } + const kept: WorkspaceSessionRecord = {} + const parked: WorkspaceSessionRecord = {} + for (const [key, entry] of Object.entries(record)) { + const primary = primaryByKey.get(key) + if (primary && primary !== hostId) { + parked[key] = entry + } else { + kept[key] = entry + } + } + if (Object.keys(parked).length === 0) { + continue + } + nextSlice ??= { ...slice } + shadow ??= {} as WorkspaceSessionState + ;(nextSlice as WorkspaceSessionRecord)[field] = kept + ;(shadow as WorkspaceSessionRecord)[field] = parked + } + return { slice: nextSlice ?? slice, shadow } +} + +/** Split contested worktree-keyed entries out of the read partitions: the primary host's rows stay + * in the slices the renderer merges, every other claimant's rows move to the shadow. + * + * `primaryHostBySessionKey` records where each key's live row came from — including the + * uncontested single-partition case, so the write path can put every row back in its own + * partition instead of re-deriving an owner that may not match. */ +export function extractContestedHostSessionEntries(slices: HostSessionSlices): { + slices: HostSessionSlices + shadow: HostSessionSlices + primaryHostBySessionKey: Record +} { + const shadow: HostSessionSlices = {} + const hostIds = definedHostIds(slices) + const hostIdsByKey = indexHostIdsBySessionKey(slices, hostIds) + const primaryHostBySessionKey: Record = {} + for (const [key, owners] of hostIdsByKey) { + primaryHostBySessionKey[key] = pickPrimaryHostForClaims(owners) + } + if (hostIds.length < 2) { + return { slices, shadow, primaryHostBySessionKey } + } + const primaryByKey = new Map() + for (const [key, owners] of hostIdsByKey) { + if (owners.length > 1) { + primaryByKey.set(key, pickPrimaryHostForClaims(owners)) + } + } + if (primaryByKey.size === 0) { + return { slices, shadow, primaryHostBySessionKey } + } + const next: HostSessionSlices = { ...slices } + for (const hostId of hostIds) { + const slice = slices[hostId] + if (!slice) { + continue + } + const result = shadowHostEntries(slice, hostId, primaryByKey) + next[hostId] = result.slice + if (result.shadow) { + shadow[hostId] = result.shadow + } + } + return { slices: next, shadow, primaryHostBySessionKey } +} + +export function mergeWorkspaceSessionsWithHostShadow(slices: HostSessionSlices): { + session: WorkspaceSessionState + slices: HostSessionSlices + shadow: HostSessionSlices + primaryHostBySessionKey: Record +} { + const extracted = extractContestedHostSessionEntries(slices) + return { + session: mergeWorkspaceSessionsFromHosts(extracted.slices), + slices: extracted.slices, + shadow: extracted.shadow, + primaryHostBySessionKey: extracted.primaryHostBySessionKey + } +} + +function hostStillClaimsKey( + claims: WorktreeHostClaims, + key: string, + hostId: ExecutionHostId +): boolean { + const claimed = claims.get(normalizeWorkspaceSessionKeyToWorktreeId(key)) + // Why: a missing catalog row is not evidence the host lost the workspace — the catalog may not + // have hydrated, or the key may be a folder workspace. Only a positive re-attribution drops a row. + return !claimed || claimed.has(hostId) +} + +/** Whether the slices will be applied as a merge-by-field patch or a full partition replace. */ +export type HostSessionWriteMode = 'patch' | 'replace' + +/** Write parked entries back into their own host's slice so a write for the primary host cannot + * erase a co-claimant's persisted session. Mutates the slices produced by the split. */ +export function attachHostSessionShadow( + slices: HostSessionSlices, + shadow: HostSessionSlices | undefined, + claims: WorktreeHostClaims, + mode: HostSessionWriteMode +): void { + if (!shadow) { + return + } + for (const [hostId, shadowSlice] of Object.entries(shadow) as [ + ExecutionHostId, + WorkspaceSessionState | undefined + ][]) { + const slice = slices[hostId] + if (!slice || !shadowSlice) { + continue + } + for (const field of WORKTREE_KEYED_FIELDS) { + const parked = shadowSlice[field] + if (!isWorkspaceSessionRecord(parked)) { + continue + } + let target = slice[field] + if (!isWorkspaceSessionRecord(target)) { + // Why the mode split: a patch that omits the field leaves the partition's own copy + // untouched, but a full set erases omitted fields, so the parked rows must ride along. + if (mode === 'patch') { + continue + } + target = {} + ;(slice as WorkspaceSessionRecord)[field] = target + } + for (const [key, entry] of Object.entries(parked)) { + if (Object.hasOwn(target, key) || !hostStillClaimsKey(claims, key, hostId)) { + continue + } + target[key] = entry + } + } + } +} diff --git a/src/renderer/src/lib/workspace-session-host-hydration.ts b/src/renderer/src/lib/workspace-session-host-hydration.ts new file mode 100644 index 00000000000..05e96720851 --- /dev/null +++ b/src/renderer/src/lib/workspace-session-host-hydration.ts @@ -0,0 +1,161 @@ +import type { Repo } from '../../../shared/repo-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { + mergeWorkspaceSessionsWithHostShadow, + normalizeWorkspaceSessionKeyToWorktreeId +} from './workspace-session-host-contention' +import { nonLocalHostSessionEntries, type HostSessionSlices } from './workspace-session-host-split' + +type SessionReadApi = { + get: (hostId?: ExecutionHostId) => Promise +} + +export type WorkspaceSessionHostRead = { + session: WorkspaceSessionState + runtimeHostIdByWorkspaceSessionKey: Record + contestedHostWorkspaceSessions: HostSessionSlices + contestedPrimaryHostBySessionKey: Record +} + +const WORKSPACE_SESSION_KEYED_FIELDS = [ + 'tabsByWorktree', + 'openFilesByWorktree', + 'activeFileIdByWorktree', + 'activeBrowserTabIdByWorktree', + 'activeTabTypeByWorktree', + 'activeTabIdByWorktree', + 'browserTabsByWorktree', + 'unifiedTabs', + 'tabGroups', + 'tabGroupLayouts', + 'activeGroupIdByWorktree', + 'lastVisitedAtByWorktreeId', + 'defaultTerminalTabsAppliedByWorktreeId' +] as const satisfies readonly (keyof WorkspaceSessionState)[] + +function isPlainRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function addWorkspaceSessionKeyForOwnerMap(ids: Set, value: unknown): void { + if (typeof value === 'string') { + ids.add(normalizeWorkspaceSessionKeyToWorktreeId(value)) + } +} + +function collectWorkspaceSessionKeysFromHostSession(session: WorkspaceSessionState): string[] { + const ids = new Set() + for (const field of WORKSPACE_SESSION_KEYED_FIELDS) { + const value = session[field] + if (isPlainRecord(value)) { + for (const id of Object.keys(value)) { + addWorkspaceSessionKeyForOwnerMap(ids, id) + } + } + } + for (const id of session.activeWorktreeIdsOnShutdown ?? []) { + addWorkspaceSessionKeyForOwnerMap(ids, id) + } + for (const pages of Object.values(session.browserPagesByWorkspace ?? {})) { + if (!Array.isArray(pages)) { + continue + } + for (const page of pages) { + addWorkspaceSessionKeyForOwnerMap(ids, page.worktreeId) + } + } + for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { + // Why: a hibernated agent can be the only restored session evidence for a + // runtime worktree before its remote catalog answers. + addWorkspaceSessionKeyForOwnerMap(ids, record.worktreeId) + } + return [...ids] +} + +function buildRuntimeHostIdByWorkspaceSessionKey( + slices: HostSessionSlices +): Record { + const owners: Record = {} + const ambiguous = new Set() + for (const [hostId, slice] of nonLocalHostSessionEntries(slices)) { + for (const worktreeId of collectWorkspaceSessionKeysFromHostSession(slice)) { + if (owners[worktreeId] && owners[worktreeId] !== hostId) { + ambiguous.add(worktreeId) + delete owners[worktreeId] + } else if (!ambiguous.has(worktreeId)) { + owners[worktreeId] = hostId + } + } + } + return owners +} + +/** Collect the distinct runtime hosts owning any persisted repo. */ +export function listKnownRuntimeHostIds( + repos: readonly Pick[] +): ExecutionHostId[] { + const hostIds = new Set() + for (const repo of repos) { + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (parsed?.kind === 'runtime') { + hostIds.add(parsed.id) + } + } + return [...hostIds] +} + +/** Boot-time hydration: fetch the local partition plus one partition per known + * runtime host (from loaded repos and saved runtime ids), then merge them into + * the unified session the hydrators expect. + * + * Fail-soft: a partition whose fetch rejects is skipped — boot proceeds with + * the rest. Corrupt partitions never reach here; persistence zod-validates + * each one and falls back to defaults on the main side. */ +export async function fetchWorkspaceSessionFromHosts( + api: SessionReadApi, + repos: readonly Pick[], + additionalRuntimeHostIds: readonly ExecutionHostId[] = [] +): Promise { + return (await fetchWorkspaceSessionWithRuntimeHostOwners(api, repos, additionalRuntimeHostIds)) + .session +} + +export async function fetchWorkspaceSessionWithRuntimeHostOwners( + api: SessionReadApi, + repos: readonly Pick[], + additionalRuntimeHostIds: readonly ExecutionHostId[] = [] +): Promise { + const slices: HostSessionSlices = { + [LOCAL_EXECUTION_HOST_ID]: await api.get() + } + // Why: startup can know saved runtime session hosts before their repo + // catalogs hydrate, so include those partitions in the first read. + const runtimeHostIds = new Set([ + ...listKnownRuntimeHostIds(repos), + ...additionalRuntimeHostIds + ]) + await Promise.all( + [...runtimeHostIds].map(async (hostId) => { + try { + slices[hostId] = await api.get(hostId) + } catch (err) { + console.warn(`[session] skipping unreadable host partition ${hostId}:`, err) + } + }) + ) + const merged = mergeWorkspaceSessionsWithHostShadow(slices) + return { + session: merged.session, + // Why the merged slices and not the raw ones: a row parked out of the renderer session must not + // still name its host as the owner, or startup builds runtime placeholders for a local row. + runtimeHostIdByWorkspaceSessionKey: buildRuntimeHostIdByWorkspaceSessionKey(merged.slices), + contestedHostWorkspaceSessions: merged.shadow, + contestedPrimaryHostBySessionKey: merged.primaryHostBySessionKey + } +} diff --git a/src/renderer/src/lib/workspace-session-host-persistence.test.ts b/src/renderer/src/lib/workspace-session-host-persistence.test.ts index 29d166d0816..f23a3afe295 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.test.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.test.ts @@ -5,13 +5,15 @@ import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/worksp import { buildHostIdByWorktreeId, buildWorkspaceSessionHostSnapshots, - fetchWorkspaceSessionFromHosts, - fetchWorkspaceSessionWithRuntimeHostOwners, patchWorkspaceSessionByHost, persistWorkspaceSessionByHost, persistWorkspaceSessionByHostSync, type HostPersistenceState } from './workspace-session-host-persistence' +import { + fetchWorkspaceSessionFromHosts, + fetchWorkspaceSessionWithRuntimeHostOwners +} from './workspace-session-host-hydration' describe('fetchWorkspaceSessionFromHosts', () => { it('reads saved runtime host partitions before runtime repos are loaded', async () => { @@ -77,7 +79,9 @@ describe('fetchWorkspaceSessionFromHosts', () => { const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1']) expect(read.session.tabsByWorktree[worktreeId]).toHaveLength(1) - expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [worktreeId]: 'runtime:env-1' }) + expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ + [worktreeId]: 'runtime:env-1' + }) }) it('normalizes canonical worktree session keys in runtime owner maps', async () => { @@ -108,7 +112,9 @@ describe('fetchWorkspaceSessionFromHosts', () => { const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1']) - expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [worktreeId]: 'runtime:env-1' }) + expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ + [worktreeId]: 'runtime:env-1' + }) }) it('returns runtime owners for folder workspace session keys', async () => { @@ -140,7 +146,9 @@ describe('fetchWorkspaceSessionFromHosts', () => { const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1']) expect(read.session.tabsByWorktree[folderKey]).toHaveLength(1) - expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [folderKey]: 'runtime:env-1' }) + expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ + [folderKey]: 'runtime:env-1' + }) }) it('returns runtime owners for sleeping-agent-only runtime worktrees', async () => { @@ -172,7 +180,9 @@ describe('fetchWorkspaceSessionFromHosts', () => { expect(read.session.sleepingAgentSessionsByPaneKey?.['remote-tab:leaf-1']?.worktreeId).toBe( worktreeId ) - expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [worktreeId]: 'runtime:env-1' }) + expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ + [worktreeId]: 'runtime:env-1' + }) }) it('routes restored runtime folder workspace patches back to the runtime host', async () => { @@ -200,7 +210,9 @@ describe('fetchWorkspaceSessionFromHosts', () => { { repos: [], worktreesByRepo: {}, - restoredRuntimeHostIdByWorkspaceSessionKey: { [folderKey]: 'runtime:env-1' } + restoredRuntimeHostIdByWorkspaceSessionKey: { + [folderKey]: 'runtime:env-1' + } } ) @@ -242,7 +254,9 @@ describe('fetchWorkspaceSessionFromHosts', () => { folderWorkspaces: [{ id: 'folder-1', projectGroupId: 'group-1' }], projectGroups: [{ id: 'group-1', executionHostId: 'local' }], worktreesByRepo: {}, - restoredRuntimeHostIdByWorkspaceSessionKey: { [folderKey]: 'runtime:stale-env' } + restoredRuntimeHostIdByWorkspaceSessionKey: { + [folderKey]: 'runtime:stale-env' + } } ) @@ -280,8 +294,16 @@ describe('fetchWorkspaceSessionFromHosts', () => { } }, { - repos: [{ id: 'remote-repo', connectionId: null, executionHostId: 'runtime:env-1' }], - worktreesByRepo: { 'remote-repo': [{ id: worktreeId, repoId: 'remote-repo' }] } + repos: [ + { + id: 'remote-repo', + connectionId: null, + executionHostId: 'runtime:env-1' + } + ], + worktreesByRepo: { + 'remote-repo': [{ id: worktreeId, repoId: 'remote-repo' }] + } } ) @@ -334,12 +356,20 @@ describe('fetchWorkspaceSessionFromHosts', () => { { repos: [ { id: 'same-repo', connectionId: null, executionHostId: 'local' }, - { id: 'same-repo', connectionId: null, executionHostId: 'runtime:env-1' } + { + id: 'same-repo', + connectionId: null, + executionHostId: 'runtime:env-1' + } ], worktreesByRepo: { 'same-repo': [ { id: localWorktreeId, repoId: 'same-repo' }, - { id: remoteWorktreeId, repoId: 'same-repo', hostId: 'runtime:env-1' } + { + id: remoteWorktreeId, + repoId: 'same-repo', + hostId: 'runtime:env-1' + } ] } } @@ -366,7 +396,11 @@ describe('fetchWorkspaceSessionFromHosts', () => { const owner = buildHostIdByWorktreeId({ repos: [ { id: 'same-repo', connectionId: null, executionHostId: 'local' }, - { id: 'same-repo', connectionId: null, executionHostId: 'runtime:env-1' } + { + id: 'same-repo', + connectionId: null, + executionHostId: 'runtime:env-1' + } ], worktreesByRepo: { 'same-repo': [{ id: 'same-repo::/local-only', repoId: 'same-repo' }] @@ -411,11 +445,21 @@ describe('fetchWorkspaceSessionFromHosts', () => { const state = { repos: [ { id: 'local-repo', connectionId: null, executionHostId: 'local' }, - { id: 'remote-repo', connectionId: null, executionHostId: 'runtime:env-1' } + { + id: 'remote-repo', + connectionId: null, + executionHostId: 'runtime:env-1' + } ], worktreesByRepo: { 'local-repo': [{ id: localWorktreeId, repoId: 'local-repo' }], - 'remote-repo': [{ id: remoteWorktreeId, repoId: 'remote-repo', hostId: 'runtime:env-1' }] + 'remote-repo': [ + { + id: remoteWorktreeId, + repoId: 'remote-repo', + hostId: 'runtime:env-1' + } + ] } } satisfies HostPersistenceState @@ -507,18 +551,30 @@ describe('persistWorkspaceSessionByHost', () => { { repos: [ { id: 'local-repo', connectionId: null, executionHostId: 'local' }, - { id: 'remote-repo', connectionId: null, executionHostId: 'runtime:env-1' } + { + id: 'remote-repo', + connectionId: null, + executionHostId: 'runtime:env-1' + } ], worktreesByRepo: { 'local-repo': [{ id: localWorktreeId, repoId: 'local-repo' }], - 'remote-repo': [{ id: remoteWorktreeId, repoId: 'remote-repo', hostId: 'runtime:env-1' }] + 'remote-repo': [ + { + id: remoteWorktreeId, + repoId: 'remote-repo', + hostId: 'runtime:env-1' + } + ] } } ) expect(set).toHaveBeenCalledTimes(2) expect(set).toHaveBeenCalledWith( - expect.objectContaining({ tabsByWorktree: { [localWorktreeId]: expect.any(Array) } }) + expect.objectContaining({ + tabsByWorktree: { [localWorktreeId]: expect.any(Array) } + }) ) expect(set).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts index 9d8cdb34857..1ee386883d5 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -12,11 +12,16 @@ import { import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { - getWorktreeIdFromHostIdentity, - isWorktreeHostIdentity -} from '../../../shared/worktree/host-qualified-identity' + attachHostSessionShadow, + contestedPartitionHosts, + indexWorktreeHostClaims, + normalizeWorkspaceSessionKeyToWorktreeId, + pickPrimaryHostForClaims, + type HostSessionWriteMode, + type WorktreeHostClaims +} from './workspace-session-host-contention' import { - mergeWorkspaceSessionsFromHosts, + nonLocalHostSessionEntries, splitWorkspaceSessionByHost, type HostSessionSlices, type HostIdByWorktreeId @@ -36,6 +41,12 @@ export type HostPersistenceState = { }[] worktreesByRepo: Record restoredRuntimeHostIdByWorkspaceSessionKey?: Record + /** Entries a co-claimant host lost to the primary of a contested workspace id; written straight + * back to their own partition so the primary's write cannot erase them. */ + contestedHostWorkspaceSessions?: HostSessionSlices + /** Partition each restored session key was read from. Routing honours it so a write returns rows + * to their own partition instead of re-deriving an owner the read never agreed to. */ + contestedPrimaryHostBySessionKey?: Record } type SessionApi = { @@ -49,97 +60,11 @@ type DurableSessionApi = SessionApi & { flush: () => Promise } -export type WorkspaceSessionHostRead = { - session: WorkspaceSessionState - runtimeHostIdByWorkspaceSessionKey: Record -} - export type WorkspaceSessionHostSnapshot = { state: WorkspaceSessionState hostId?: ExecutionHostId } -const WORKSPACE_SESSION_KEYED_FIELDS = [ - 'tabsByWorktree', - 'openFilesByWorktree', - 'activeFileIdByWorktree', - 'activeBrowserTabIdByWorktree', - 'activeTabTypeByWorktree', - 'activeTabIdByWorktree', - 'browserTabsByWorktree', - 'unifiedTabs', - 'tabGroups', - 'tabGroupLayouts', - 'activeGroupIdByWorktree', - 'lastVisitedAtByWorktreeId', - 'defaultTerminalTabsAppliedByWorktreeId' -] as const satisfies readonly (keyof WorkspaceSessionState)[] - -function isPlainRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} - -function normalizeWorkspaceSessionKeyForOwnerMap(value: string): string { - if (isWorktreeHostIdentity(value)) { - return getWorktreeIdFromHostIdentity(value) - } - const scope = parseWorkspaceKey(value) - return scope?.type === 'worktree' ? scope.worktreeId : value -} - -function addWorkspaceSessionKeyForOwnerMap(ids: Set, value: unknown): void { - if (typeof value === 'string') { - ids.add(normalizeWorkspaceSessionKeyForOwnerMap(value)) - } -} - -function collectWorkspaceSessionKeysFromHostSession(session: WorkspaceSessionState): string[] { - const ids = new Set() - for (const field of WORKSPACE_SESSION_KEYED_FIELDS) { - const value = session[field] - if (isPlainRecord(value)) { - for (const id of Object.keys(value)) { - addWorkspaceSessionKeyForOwnerMap(ids, id) - } - } - } - for (const id of session.activeWorktreeIdsOnShutdown ?? []) { - addWorkspaceSessionKeyForOwnerMap(ids, id) - } - for (const pages of Object.values(session.browserPagesByWorkspace ?? {})) { - if (!Array.isArray(pages)) { - continue - } - for (const page of pages) { - addWorkspaceSessionKeyForOwnerMap(ids, page.worktreeId) - } - } - for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { - // Why: a hibernated agent can be the only restored session evidence for a - // runtime worktree before its remote catalog answers. - addWorkspaceSessionKeyForOwnerMap(ids, record.worktreeId) - } - return [...ids] -} - -function buildRuntimeHostIdByWorkspaceSessionKey( - slices: HostSessionSlices -): Record { - const owners: Record = {} - const ambiguous = new Set() - for (const [hostId, slice] of nonLocalEntries(slices)) { - for (const worktreeId of collectWorkspaceSessionKeysFromHostSession(slice)) { - if (owners[worktreeId] && owners[worktreeId] !== hostId) { - ambiguous.add(worktreeId) - delete owners[worktreeId] - } else if (!ambiguous.has(worktreeId)) { - owners[worktreeId] = hostId - } - } - } - return owners -} - function getRestoredRuntimeHostId( owners: Record | undefined, key: string @@ -176,35 +101,82 @@ function getFolderWorkspaceRuntimeHostId( return restoredHostId ?? LOCAL_EXECUTION_HOST_ID } -/** Map a worktree to the host partition it persists under. - * - * Why: only `runtime:*` worktrees are partitioned out. SSH-owned worktrees stay - * in the 'local' partition because the SSH flow already persists them there (in - * the unified blob) and separately mirrors them to each target's remote - * snapshot — partitioning them too would double-own that data. */ -export function buildHostIdByWorktreeId(state: HostPersistenceState): HostIdByWorktreeId { +export type HostSessionRouting = { + hostIdByWorktreeId: HostIdByWorktreeId + claims: WorktreeHostClaims +} + +function buildRepoHostById( + repos: HostPersistenceState['repos'] +): Map { const repoHostById = new Map() - for (const repo of state.repos) { + for (const repo of repos) { const hostId = getRepoExecutionHostId(repo) const existing = repoHostById.get(repo.id) // Why: repo ids can repeat across hosts; ambiguous repo-only ownership // must not let a runtime placeholder steal local session state. repoHostById.set(repo.id, existing === undefined ? hostId : existing === hostId ? hostId : null) } + return repoHostById +} + +/** Map a worktree to the host partition it persists under, plus the host claims behind it. + * + * Why: only `runtime:*` worktrees are partitioned out. SSH-owned worktrees stay + * in the 'local' partition because the SSH flow already persists them there (in + * the unified blob) and separately mirrors them to each target's remote + * snapshot — partitioning them too would double-own that data. The one exception is an id two + * hosts both publish: it gets a deterministic primary so the co-claimant's rows can be parked in + * the shadow instead of sharing one bucket with it. */ +/** True only when the catalog positively says `hostId` no longer holds the workspace. An id the + * catalog cannot speak for yet keeps its restored partition — the same rule the shadow uses. */ +function catalogReattributedAwayFrom( + claims: WorktreeHostClaims, + worktreeId: string, + hostId: ExecutionHostId +): boolean { + const claimed = claims.get(worktreeId) + return Boolean(claimed) && !contestedPartitionHosts(claimed ?? []).includes(hostId) +} + +export function buildHostSessionRouting(state: HostPersistenceState): HostSessionRouting { + const repoHostById = buildRepoHostById(state.repos) + const claims = indexWorktreeHostClaims(state.worktreesByRepo, repoHostById) + const restoredPrimaryByWorktreeId = new Map() + for (const [key, hostId] of Object.entries(state.contestedPrimaryHostBySessionKey ?? {})) { + restoredPrimaryByWorktreeId.set(normalizeWorkspaceSessionKeyToWorktreeId(key), hostId) + } const { repoIdByWorktreeId, runtimeHostIdByWorktreeId } = indexWorkspaceRuntimeHostOwnership( state.worktreesByRepo ) - return (worktreeId: string): ExecutionHostId => { + const hostIdByWorktreeId = (worktreeId: string): ExecutionHostId => { const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { return getFolderWorkspaceRuntimeHostId(state, worktreeId) } const rawWorktreeId = workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : worktreeId + const restoredPrimary = + state.contestedPrimaryHostBySessionKey?.[worktreeId] ?? + restoredPrimaryByWorktreeId.get(rawWorktreeId) + if (restoredPrimary && !catalogReattributedAwayFrom(claims, rawWorktreeId, restoredPrimary)) { + // Why first: the read already decided which partition each row came from. Re-deriving an + // owner here is what let a write copy one host's workspace into another host's partition. + return restoredPrimary + } + const claimed = claims.get(rawWorktreeId) + if (claimed && claimed.size > 1) { + // Why partitions, not claimants: 'local' and every ssh host share one blob, so a claimant set + // that collapses to a single partition is not separable and keeps its normal routing. + const partitions = contestedPartitionHosts(claimed) + if (partitions.length > 1) { + return pickPrimaryHostForClaims(partitions) + } + } const worktreeHostId = runtimeHostIdByWorktreeId.get(rawWorktreeId) if (runtimeHostIdByWorktreeId.has(rawWorktreeId) && !worktreeHostId) { - // Why: a bare worktree id cannot safely select between two HUB partitions. + // Why: a bare worktree id whose claimants the catalog cannot name apart stays local. return LOCAL_EXECUTION_HOST_ID } if (worktreeHostId) { @@ -218,12 +190,24 @@ export function buildHostIdByWorktreeId(state: HostPersistenceState): HostIdByWo const parsed = parseExecutionHostId(repoHostId) return parsed?.kind === 'runtime' ? parsed.id : LOCAL_EXECUTION_HOST_ID } + return { hostIdByWorktreeId, claims } } -function nonLocalEntries(slices: HostSessionSlices): [ExecutionHostId, WorkspaceSessionState][] { - return (Object.entries(slices) as [ExecutionHostId, WorkspaceSessionState][]).filter( - ([hostId, slice]) => hostId !== LOCAL_EXECUTION_HOST_ID && slice !== undefined - ) +export function buildHostIdByWorktreeId(state: HostPersistenceState): HostIdByWorktreeId { + return buildHostSessionRouting(state).hostIdByWorktreeId +} + +/** Partition a session for writing: route each entry to its owner host, then restore the parked + * rows of every host that lost a contested id so this write cannot erase them. */ +function splitWorkspaceSessionForWrite( + payload: WorkspaceSessionState, + state: HostPersistenceState, + mode: HostSessionWriteMode +): HostSessionSlices { + const routing = buildHostSessionRouting(state) + const slices = splitWorkspaceSessionByHost(payload, routing.hostIdByWorktreeId) + attachHostSessionShadow(slices, state.contestedHostWorkspaceSessions, routing.claims, mode) + return slices } /** Patch path of the debounced session writer: split the partial patch by owner @@ -234,13 +218,10 @@ export function patchWorkspaceSessionByHost( patch: WorkspaceSessionPatch, state: HostPersistenceState ): Promise { - const slices = splitWorkspaceSessionByHost( - patch as WorkspaceSessionState, - buildHostIdByWorktreeId(state) - ) + const slices = splitWorkspaceSessionForWrite(patch as WorkspaceSessionState, state, 'patch') const local = (slices[LOCAL_EXECUTION_HOST_ID] ?? patch) as WorkspaceSessionPatch const localWrite = api.patch(local) - for (const [hostId, slice] of nonLocalEntries(slices)) { + for (const [hostId, slice] of nonLocalHostSessionEntries(slices)) { // Why: a failed runtime-partition write must not reject the local chain. void api.patch(slice as WorkspaceSessionPatch, hostId).catch((err) => { console.warn(`[session] host partition patch failed for ${hostId}:`, err) @@ -257,9 +238,11 @@ export async function persistWorkspaceSessionByHost( payload: WorkspaceSessionState, state: HostPersistenceState ): Promise { - const slices = splitWorkspaceSessionByHost(payload, buildHostIdByWorktreeId(state)) + // Why 'replace': api.set swaps the whole partition, so parked rows must ride along even for + // fields nothing else routed to this host. + const slices = splitWorkspaceSessionForWrite(payload, state, 'replace') const writes: Promise[] = [api.set(slices[LOCAL_EXECUTION_HOST_ID] ?? payload)] - for (const [hostId, slice] of nonLocalEntries(slices)) { + for (const [hostId, slice] of nonLocalHostSessionEntries(slices)) { writes.push(api.set(slice, hostId)) } await Promise.all(writes) @@ -271,10 +254,14 @@ export function buildWorkspaceSessionHostSnapshots( payload: WorkspaceSessionState, state: HostPersistenceState ): WorkspaceSessionHostSnapshot[] { - const slices = splitWorkspaceSessionByHost(payload, buildHostIdByWorktreeId(state)) + // Why 'replace': quit snapshots are applied as full partition sets. + const slices = splitWorkspaceSessionForWrite(payload, state, 'replace') return [ { state: slices[LOCAL_EXECUTION_HOST_ID] ?? payload }, - ...nonLocalEntries(slices).map(([hostId, hostState]) => ({ state: hostState, hostId })) + ...nonLocalHostSessionEntries(slices).map(([hostId, hostState]) => ({ + state: hostState, + hostId + })) ] } @@ -288,62 +275,3 @@ export function persistWorkspaceSessionByHostSync( api.setSync(snapshot.state, snapshot.hostId) } } - -/** Collect the distinct runtime hosts owning any persisted repo. */ -export function listKnownRuntimeHostIds( - repos: readonly Pick[] -): ExecutionHostId[] { - const hostIds = new Set() - for (const repo of repos) { - const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) - if (parsed?.kind === 'runtime') { - hostIds.add(parsed.id) - } - } - return [...hostIds] -} - -/** Boot-time hydration: fetch the local partition plus one partition per known - * runtime host (from loaded repos and saved runtime ids), then merge them into - * the unified session the hydrators expect. - * - * Fail-soft: a partition whose fetch rejects is skipped — boot proceeds with - * the rest. Corrupt partitions never reach here; persistence zod-validates - * each one and falls back to defaults on the main side. */ -export async function fetchWorkspaceSessionFromHosts( - api: Pick, - repos: readonly Pick[], - additionalRuntimeHostIds: readonly ExecutionHostId[] = [] -): Promise { - return (await fetchWorkspaceSessionWithRuntimeHostOwners(api, repos, additionalRuntimeHostIds)) - .session -} - -export async function fetchWorkspaceSessionWithRuntimeHostOwners( - api: Pick, - repos: readonly Pick[], - additionalRuntimeHostIds: readonly ExecutionHostId[] = [] -): Promise { - const slices: HostSessionSlices = { - [LOCAL_EXECUTION_HOST_ID]: await api.get() - } - // Why: startup can know saved runtime session hosts before their repo - // catalogs hydrate, so include those partitions in the first read. - const runtimeHostIds = new Set([ - ...listKnownRuntimeHostIds(repos), - ...additionalRuntimeHostIds - ]) - await Promise.all( - [...runtimeHostIds].map(async (hostId) => { - try { - slices[hostId] = await api.get(hostId) - } catch (err) { - console.warn(`[session] skipping unreadable host partition ${hostId}:`, err) - } - }) - ) - return { - session: mergeWorkspaceSessionsFromHosts(slices), - runtimeHostIdByWorkspaceSessionKey: buildRuntimeHostIdByWorkspaceSessionKey(slices) - } -} diff --git a/src/renderer/src/lib/workspace-session-host-split.ts b/src/renderer/src/lib/workspace-session-host-split.ts index ab9325621f4..817b87eebeb 100644 --- a/src/renderer/src/lib/workspace-session-host-split.ts +++ b/src/renderer/src/lib/workspace-session-host-split.ts @@ -291,6 +291,15 @@ export function splitWorkspaceSessionByHost( return slices } +/** Every defined non-'local' partition; 'local' is handled by its own dedicated write. */ +export function nonLocalHostSessionEntries( + slices: HostSessionSlices +): [ExecutionHostId, WorkspaceSessionState][] { + return (Object.entries(slices) as [ExecutionHostId, WorkspaceSessionState][]).filter( + ([hostId, slice]) => hostId !== LOCAL_EXECUTION_HOST_ID && slice !== undefined + ) +} + /** Inverse of split: combine per-host slices into one unified session. Global * fields are taken from the 'local' slice (it owns them); worktree/tab-scoped * maps are unioned across all hosts. Tolerates missing or partial slices. */ diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index e4b2c793ec6..2a79a784b1c 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -55,6 +55,8 @@ export const createTerminalSlice: StateCreator set({ terminalStartupRestorationReady: value }) }, restoredRuntimeHostIdByWorkspaceSessionKey: {}, + contestedHostWorkspaceSessions: {}, + contestedPrimaryHostBySessionKey: {}, defaultTerminalTabsAppliedByWorktreeId: {}, closedTerminalTabTombstonesByTabId: {}, hydrationSucceeded: false, diff --git a/src/renderer/src/store/terminals/terminal-contracts.ts b/src/renderer/src/store/terminals/terminal-contracts.ts index 8ed44aa4899..f821e2a8764 100644 --- a/src/renderer/src/store/terminals/terminal-contracts.ts +++ b/src/renderer/src/store/terminals/terminal-contracts.ts @@ -3,6 +3,7 @@ import type { AgentProviderSessionMetadata } from '../../../../shared/agent-sess import type { DirectSshAuthority } from '../../../../shared/ssh-types' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { WorkspaceSessionHydrationOptions } from '@/lib/workspace-session-hydration-keys' +import type { HostSessionSlices } from '@/lib/workspace-session-host-split' /** In-memory recovery claim consumed only after the resumed terminal hook becomes live. */ export type AutomaticAgentResumeClaim = { @@ -29,6 +30,10 @@ export type CodexRestartNotice = { export type HydrateWorkspaceSessionOptions = { directSshAuthority?: DirectSshAuthority runtimeHostIdByWorkspaceSessionKey?: Record + /** Rows parked for hosts that lost a contested workspace id; omitted leaves the store's copy. */ + contestedHostWorkspaceSessions?: HostSessionSlices + /** Partition each restored session key was read from; omitted leaves the store's copy. */ + contestedPrimaryHostBySessionKey?: Record } & WorkspaceSessionHydrationOptions /** Scoped reconnect must still match this exact provider epoch and connection generation. */ diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index cf7193a8062..c6939fc0847 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -16,6 +16,7 @@ import type { DirectSshPaneRetryHistory } from '../slices/direct-ssh-terminal-recovery' import type { NativeChatLaunchDraft, NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' +import type { HostSessionSlices } from '@/lib/workspace-session-host-split' import type { AutomaticAgentResumeClaim, CodexRestartNotice } from './terminal-contracts' import type { StateCreator } from 'zustand' import type { AppState } from '../types' @@ -92,6 +93,14 @@ export type TerminalState = { /** True after main ownership restoration, renderer PTY adoption, and structured-tab projection settle. */ terminalStartupRestorationReady: boolean restoredRuntimeHostIdByWorkspaceSessionKey: Record + /** + * Worktree-keyed session rows belonging to hosts that co-publish a workspace id with the host + * that owns it here. Never read by the UI: it is the carrier that lets a write for the owning + * host round-trip the other hosts' partitions instead of erasing them. + */ + contestedHostWorkspaceSessions: HostSessionSlices + /** Partition each restored session key was read from, so a write returns its rows there. */ + contestedPrimaryHostBySessionKey: Record defaultTerminalTabsAppliedByWorktreeId: Record closedTerminalTabTombstonesByTabId: ClosedTerminalTabTombstonesByTabId hydrationSucceeded: boolean diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts index f7e665baec2..3ea374a5475 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts @@ -32,7 +32,10 @@ export type WorkspaceHydrationPatch = Pick< | 'worktreeNavHistoryIndex' | 'ptyIdsByTabId' | 'terminalLayoutsByTabId' -> +> & + // Why partial: only a cold read carries the contested-host shadow; a scoped re-hydration must + // leave the store's copy alone rather than replace it with an empty one. + Partial> export function replaceHydratedRecordKeys( current: Record, diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts index 974b1af65f1..39be3c94f0f 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts @@ -172,6 +172,14 @@ export function createWorkspaceTerminalHydrationActions( activeTabIdByWorktree, restoredRuntimeHostIdByWorkspaceSessionKey: options?.runtimeHostIdByWorkspaceSessionKey ?? {}, + // Why conditional: a mid-session re-hydration (the SSH pull merge) carries no shadow, and + // clearing it there would drop the co-claimant rows the next write has to put back. + ...(options?.contestedHostWorkspaceSessions + ? { contestedHostWorkspaceSessions: options.contestedHostWorkspaceSessions } + : {}), + ...(options?.contestedPrimaryHostBySessionKey + ? { contestedPrimaryHostBySessionKey: options.contestedPrimaryHostBySessionKey } + : {}), repos: runtimeSessionPlaceholders.repos, tabsByWorktree, worktreesByRepo,