mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(session): stop two hosts sharing one workspace-session bucket (#17912)
* fix(session): stop two hosts sharing one workspace-session bucket A worktree id is `repoId::path` with no host component, so a repo registered on two execution hosts publishes the same id for two different workspaces (STA-4343). buildHostIdByWorktreeId folded every such id into the 'local' partition, so the two workspaces shared one tabsByWorktree bucket and the host that wrote last erased the other's rows for good. A contested id now resolves to one deterministic primary host (local when it is a claimant, else the lowest host id — stable, so the primary does not move as the user navigates). On hydration, entries other claimants hold in their own partitions are parked in a shadow that never reaches renderer state, and every write re-attaches them to their own partition, so a write for the primary can no longer take a co-claimant's session down with it. A parked row is dropped only when the catalog positively re-attributes the workspace. Known gap, documented in workspace-session-host-contention.ts: the unified renderer session still holds one bucket per bare id, so both workspaces display the primary's tabs. Closing that needs host-qualified keys through the tab store. * fix(session): carry parked contested rows through full partition replaces attachHostSessionShadow skipped a parked field when nothing else routed to the co-claimant's slice. That is correct for the patch path (an omitted field leaves the partition untouched) but wrong for persistWorkspaceSessionByHost and the quit snapshots: setHostWorkspaceSession replaces the whole partition, so the omitted field erased the very rows the shadow exists to protect. The attach now takes the write mode and, on a full replace, seeds the missing field with the parked rows. * fix(session): decide a contested id's partition once, at read time Review found the read and write paths deriving the primary from different domains. The read picked it from which partitions held the key (SSH rows live in the 'local' blob, so SSH reads as local); the write picked it from the claims catalog, where SSH is `ssh:*`. For an ssh+runtime contest the claims sort `runtime:` first, so the write sent the SSH workspace's rows into the runtime partition and attachHostSessionShadow then skipped restoring the runtime's own rows because the key was already present — a cross-host copy worse than the shared bucket this branch set out to fix. The same disagreement copied a row across partitions whenever only a co-claimant had it saved. The read now records the partition every restored key came from and the routing honours it, so rows go back where they live. A claims-derived owner is only a fallback for keys the read never saw, and it is computed over distinct PARTITIONS: 'local' and every ssh host share one blob, so a claimant set that collapses to a single partition keeps its normal routing. A stale record loses to a positive catalog re-attribution, so adoption still migrates a workspace. Also: build the runtime owner map from the post-extraction slices, so a row parked out of the renderer session no longer names its host as owner and startup stops building runtime placeholders for the local row that was kept. Drop the unused isContestedWorktreeId export.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<string, TerminalTab[]>): WorkspaceSessionState {
|
||||
return { ...getDefaultWorkspaceSession(), tabsByWorktree: entries }
|
||||
}
|
||||
|
||||
function contestedState(overrides: Partial<HostPersistenceState> = {}): 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<void>
|
||||
>
|
||||
type SessionPatchMock = Mock<
|
||||
(patch: Partial<WorkspaceSessionState>, hostId?: ExecutionHostId) => Promise<void>
|
||||
>
|
||||
|
||||
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> = {}
|
||||
): 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<SessionWriteMock> {
|
||||
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<WorkspaceSessionState> | 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> = {}
|
||||
): 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')
|
||||
})
|
||||
})
|
||||
@@ -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<string, ReadonlySet<ExecutionHostId>>
|
||||
|
||||
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<string, ExecutionHostId | null>
|
||||
): 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<string, readonly WorkspaceRuntimeOwnerProjection[]>,
|
||||
repoHostById: ReadonlyMap<string, ExecutionHostId | null>
|
||||
): WorktreeHostClaims {
|
||||
const claims = new Map<string, Set<ExecutionHostId>>()
|
||||
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>): 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>): 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<string, ExecutionHostId[]> {
|
||||
const hostIdsByKey = new Map<string, ExecutionHostId[]>()
|
||||
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<string, ExecutionHostId>
|
||||
): { 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<string, ExecutionHostId>
|
||||
} {
|
||||
const shadow: HostSessionSlices = {}
|
||||
const hostIds = definedHostIds(slices)
|
||||
const hostIdsByKey = indexHostIdsBySessionKey(slices, hostIds)
|
||||
const primaryHostBySessionKey: Record<string, ExecutionHostId> = {}
|
||||
for (const [key, owners] of hostIdsByKey) {
|
||||
primaryHostBySessionKey[key] = pickPrimaryHostForClaims(owners)
|
||||
}
|
||||
if (hostIds.length < 2) {
|
||||
return { slices, shadow, primaryHostBySessionKey }
|
||||
}
|
||||
const primaryByKey = new Map<string, ExecutionHostId>()
|
||||
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<string, ExecutionHostId>
|
||||
} {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<WorkspaceSessionState>
|
||||
}
|
||||
|
||||
export type WorkspaceSessionHostRead = {
|
||||
session: WorkspaceSessionState
|
||||
runtimeHostIdByWorkspaceSessionKey: Record<string, ExecutionHostId>
|
||||
contestedHostWorkspaceSessions: HostSessionSlices
|
||||
contestedPrimaryHostBySessionKey: Record<string, 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<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function addWorkspaceSessionKeyForOwnerMap(ids: Set<string>, value: unknown): void {
|
||||
if (typeof value === 'string') {
|
||||
ids.add(normalizeWorkspaceSessionKeyToWorktreeId(value))
|
||||
}
|
||||
}
|
||||
|
||||
function collectWorkspaceSessionKeysFromHostSession(session: WorkspaceSessionState): string[] {
|
||||
const ids = new Set<string>()
|
||||
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<string, ExecutionHostId> {
|
||||
const owners: Record<string, ExecutionHostId> = {}
|
||||
const ambiguous = new Set<string>()
|
||||
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<Repo, 'connectionId' | 'executionHostId'>[]
|
||||
): ExecutionHostId[] {
|
||||
const hostIds = new Set<ExecutionHostId>()
|
||||
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<Repo, 'connectionId' | 'executionHostId'>[],
|
||||
additionalRuntimeHostIds: readonly ExecutionHostId[] = []
|
||||
): Promise<WorkspaceSessionState> {
|
||||
return (await fetchWorkspaceSessionWithRuntimeHostOwners(api, repos, additionalRuntimeHostIds))
|
||||
.session
|
||||
}
|
||||
|
||||
export async function fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
api: SessionReadApi,
|
||||
repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[],
|
||||
additionalRuntimeHostIds: readonly ExecutionHostId[] = []
|
||||
): Promise<WorkspaceSessionHostRead> {
|
||||
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<ExecutionHostId>([
|
||||
...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
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, readonly WorkspaceRuntimeOwnerProjection[]>
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey?: Record<string, ExecutionHostId>
|
||||
/** 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<string, ExecutionHostId>
|
||||
}
|
||||
|
||||
type SessionApi = {
|
||||
@@ -49,97 +60,11 @@ type DurableSessionApi = SessionApi & {
|
||||
flush: () => Promise<void>
|
||||
}
|
||||
|
||||
export type WorkspaceSessionHostRead = {
|
||||
session: WorkspaceSessionState
|
||||
runtimeHostIdByWorkspaceSessionKey: Record<string, ExecutionHostId>
|
||||
}
|
||||
|
||||
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<string, unknown> {
|
||||
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<string>, value: unknown): void {
|
||||
if (typeof value === 'string') {
|
||||
ids.add(normalizeWorkspaceSessionKeyForOwnerMap(value))
|
||||
}
|
||||
}
|
||||
|
||||
function collectWorkspaceSessionKeysFromHostSession(session: WorkspaceSessionState): string[] {
|
||||
const ids = new Set<string>()
|
||||
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<string, ExecutionHostId> {
|
||||
const owners: Record<string, ExecutionHostId> = {}
|
||||
const ambiguous = new Set<string>()
|
||||
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<string, ExecutionHostId> | 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<string, ExecutionHostId | null> {
|
||||
const repoHostById = new Map<string, ExecutionHostId | null>()
|
||||
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<string, ExecutionHostId>()
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void>[] = [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<Repo, 'connectionId' | 'executionHostId'>[]
|
||||
): ExecutionHostId[] {
|
||||
const hostIds = new Set<ExecutionHostId>()
|
||||
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<SessionApi, 'get'>,
|
||||
repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[],
|
||||
additionalRuntimeHostIds: readonly ExecutionHostId[] = []
|
||||
): Promise<WorkspaceSessionState> {
|
||||
return (await fetchWorkspaceSessionWithRuntimeHostOwners(api, repos, additionalRuntimeHostIds))
|
||||
.session
|
||||
}
|
||||
|
||||
export async function fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
api: Pick<SessionApi, 'get'>,
|
||||
repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[],
|
||||
additionalRuntimeHostIds: readonly ExecutionHostId[] = []
|
||||
): Promise<WorkspaceSessionHostRead> {
|
||||
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<ExecutionHostId>([
|
||||
...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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -55,6 +55,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
set({ terminalStartupRestorationReady: value })
|
||||
},
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: {},
|
||||
contestedHostWorkspaceSessions: {},
|
||||
contestedPrimaryHostBySessionKey: {},
|
||||
defaultTerminalTabsAppliedByWorktreeId: {},
|
||||
closedTerminalTabTombstonesByTabId: {},
|
||||
hydrationSucceeded: false,
|
||||
|
||||
@@ -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<string, ExecutionHostId>
|
||||
/** 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<string, ExecutionHostId>
|
||||
} & WorkspaceSessionHydrationOptions
|
||||
|
||||
/** Scoped reconnect must still match this exact provider epoch and connection generation. */
|
||||
|
||||
@@ -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<string, ExecutionHostId>
|
||||
/**
|
||||
* 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<string, ExecutionHostId>
|
||||
defaultTerminalTabsAppliedByWorktreeId: Record<string, true>
|
||||
closedTerminalTabTombstonesByTabId: ClosedTerminalTabTombstonesByTabId
|
||||
hydrationSucceeded: boolean
|
||||
|
||||
@@ -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<Pick<AppState, 'contestedHostWorkspaceSessions' | 'contestedPrimaryHostBySessionKey'>>
|
||||
|
||||
export function replaceHydratedRecordKeys<T>(
|
||||
current: Record<string, T>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user