fix(session): clear an emptied field on a runtime partition this window loaded (#33972)

* fix(session): clear an emptied field on a runtime partition this window loaded

* fix(session): leave hosts with parked contested rows to the shadow

Also replaces the new type assertions with checked types.
This commit is contained in:
Neil
2026-10-26 07:50:02 -07:00
committed by GitHub
parent 625d284e4d
commit c3bbd6fd67
11 changed files with 113 additions and 9 deletions
@@ -84,7 +84,8 @@ export async function bootWindowFromPartitions(
...options,
runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey,
contestedHostWorkspaceSessions: sessionRead.contestedHostWorkspaceSessions,
contestedPrimaryHostBySessionKey: sessionRead.contestedPrimaryHostBySessionKey
contestedPrimaryHostBySessionKey: sessionRead.contestedPrimaryHostBySessionKey,
hydratedRuntimeSessionHostIds: sessionRead.hydratedRuntimeSessionHostIds
})
actions.hydrateTabsSession(sessionRead.session, options)
actions.hydrateEditorSession(sessionRead.session, options)
@@ -181,11 +181,10 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
additionalValidWorkspaceKeys: collectFolderWorkspaceKeysFromSession(sessionRead.session)
}
timeRendererStartupSyncStep('hydrate-session-stores', () => {
// The read's host-ownership fields are named as the hydration options it feeds.
actions.hydrateWorkspaceSession(sessionRead.session, {
...sessionHydrationOptions,
runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey,
contestedHostWorkspaceSessions: sessionRead.contestedHostWorkspaceSessions,
contestedPrimaryHostBySessionKey: sessionRead.contestedPrimaryHostBySessionKey
...sessionRead,
...sessionHydrationOptions
})
actions.hydrateTabsSession(sessionRead.session, sessionHydrationOptions)
actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions)
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
import type { WorkspaceSessionPatch } from '../../../shared/workspace-session-state-types'
import {
patchWorkspaceSessionByHost,
type HostPersistenceState
} from './workspace-session-host-persistence'
const RUNTIME = toRuntimeExecutionHostId('env-1')
const WT = 'repo-remote::/srv/project'
// #23914: main merges a patch fieldwise, so a runtime partition the patch omits keeps a closed tab.
async function patchAfterLastBrowserTabCloses(
state: Partial<HostPersistenceState>
): Promise<Record<string, Record<string, unknown>>> {
const partitions: Record<string, Record<string, unknown>> = {
local: { browserTabsByWorktree: {}, browserPagesByWorkspace: {} },
[RUNTIME]: {
browserTabsByWorktree: { [WT]: [{ id: 'bw-1', worktreeId: WT }] },
browserPagesByWorkspace: { 'bw-1': [{ id: 'p-1', workspaceId: 'bw-1' }] }
}
}
await patchWorkspaceSessionByHost(
{
get: async () => getDefaultWorkspaceSession(),
patch: async (patch: WorkspaceSessionPatch, hostId?: ExecutionHostId) => {
const key = hostId ?? 'local'
partitions[key] = { ...partitions[key], ...patch }
}
},
{ browserTabsByWorktree: {}, browserPagesByWorkspace: {} },
{
repos: [{ id: 'repo-remote', connectionId: null, executionHostId: RUNTIME }],
worktreesByRepo: {},
tabsByWorktree: {},
...state
}
).written
return partitions
}
describe('a patch that empties a field on a runtime host', () => {
it('clears the closed tab from a runtime partition this window loaded', async () => {
const partitions = await patchAfterLastBrowserTabCloses({
hydratedRuntimeSessionHostIds: [RUNTIME]
})
expect(partitions[RUNTIME]?.browserTabsByWorktree).toEqual({})
expect(partitions[RUNTIME]?.browserPagesByWorkspace).toEqual({})
})
it('leaves a runtime partition this window never loaded, or holds parked rows for', async () => {
const unread = await patchAfterLastBrowserTabCloses({})
expect(unread[RUNTIME]?.browserTabsByWorktree).toEqual({ [WT]: [expect.anything()] })
const parked = await patchAfterLastBrowserTabCloses({
hydratedRuntimeSessionHostIds: [RUNTIME],
contestedHostWorkspaceSessions: { [RUNTIME]: getDefaultWorkspaceSession() }
})
expect(parked[RUNTIME]?.browserPagesByWorkspace).toEqual({
'bw-1': [expect.anything()]
})
})
})
@@ -34,6 +34,8 @@ export type WorkspaceSessionHostRead = {
runtimeHostIdByWorkspaceSessionKey: Record<string, ExecutionHostId>
contestedHostWorkspaceSessions: HostSessionSlices
contestedPrimaryHostBySessionKey: Record<string, ExecutionHostId>
/** Runtime partitions this read actually loaded, so a later write may speak for all of their rows. */
hydratedRuntimeSessionHostIds: ExecutionHostId[]
}
const WORKSPACE_SESSION_KEYED_FIELDS = [
@@ -223,7 +225,8 @@ export async function fetchWorkspaceSessionWithRuntimeHostOwners(
// Derive owners from surviving merged rows, never parked raw rows, or startup creates runtime placeholders for local winners.
runtimeHostIdByWorkspaceSessionKey: buildRuntimeHostIdByWorkspaceSessionKey(merged.slices),
contestedHostWorkspaceSessions: shadow,
contestedPrimaryHostBySessionKey: primaryHostBySessionKey
contestedPrimaryHostBySessionKey: primaryHostBySessionKey,
hydratedRuntimeSessionHostIds: [...runtimeHostIds].filter((hostId) => slices[hostId])
}
}
@@ -49,6 +49,7 @@ export type HostPersistenceState = WorkspaceTabOwnerCatalog & {
}[]
worktreesByRepo: Record<string, readonly WorkspaceRuntimeOwnerProjection[]>
restoredRuntimeHostIdByWorkspaceSessionKey?: Record<string, ExecutionHostId>
hydratedRuntimeSessionHostIds?: readonly 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
@@ -201,10 +202,22 @@ function splitWorkspaceSessionForWrite(
mode: HostSessionWriteMode
): HostSessionSlices {
const routing = buildHostSessionRouting(state)
const catalogRuntimeHostIds = new Set(state.repos.map((repo) => getRepoExecutionHostId(repo)))
// A patch replaces fields fieldwise, so a host whose last entry went needs the emptied field. Only a
// partition this window loaded and still catalogs is one its state speaks for in full.
// The contested shadow restores only worktree-keyed rows, so a host holding parked rows keeps today's rule.
const emptyContainerHostIds =
mode === 'patch'
? (state.hydratedRuntimeSessionHostIds ?? []).filter(
(hostId) =>
catalogRuntimeHostIds.has(hostId) && !state.contestedHostWorkspaceSessions?.[hostId]
)
: []
// Use live tab catalogs for layouts-only patches; payload-only routing loses remote park scrollback in local pruning (#21295).
const worktreeIdByTabId = extendWorktreeIdByTabId(buildWorktreeIdByTabId(payload), state)
const slices = splitWorkspaceSessionByHost(payload, routing.hostIdByWorktreeId, {
worktreeIdByTabId
worktreeIdByTabId,
emptyContainerHostIds
})
attachHostSessionShadow(slices, state.contestedHostWorkspaceSessions, routing.claims, mode)
return slices
@@ -129,7 +129,12 @@ function assignKeyedByResolvedWorktree(
export function splitWorkspaceSessionByHost(
state: WorkspaceSessionState,
hostIdByWorktreeId: HostIdByWorktreeId,
options: { worktreeIdByTabId?: Map<string, string> } = {}
options: {
worktreeIdByTabId?: Map<string, string>
/** Hosts whose rows this write speaks for in full: a field it carries replaces theirs even when
* none of their workspaces has an entry left, or main's fieldwise patch keeps the stale rows (#23914). */
emptyContainerHostIds?: readonly ExecutionHostId[]
} = {}
): HostSessionSlices {
// Copy only present global own-keys: partial patches must not inject undefined over absent fields; explicit undefined remains intentional.
const template = {} as WorkspaceSessionState
@@ -167,6 +172,10 @@ export function splitWorkspaceSessionByHost(
// Seed explicit empty containers locally so merge preserves their presence.
if (ownership !== 'global' && ownership !== 'hostPrivate') {
localSlice[field] ??= Array.isArray(value) ? [] : {}
for (const hostId of options.emptyContainerHostIds ?? []) {
const slice = ensureSlice(slices, hostId, templates)
Object.assign(slice, { [field]: slice[field] ?? (Array.isArray(value) ? [] : {}) })
}
}
switch (ownership) {
case 'global':
@@ -61,6 +61,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
restoredRuntimeHostIdByWorkspaceSessionKey: {},
contestedHostWorkspaceSessions: {},
contestedPrimaryHostBySessionKey: {},
hydratedRuntimeSessionHostIds: [],
defaultTerminalTabsAppliedByWorktreeId: {},
closedTerminalTabTombstonesByTabId: {},
hydrationSucceeded: false,
@@ -32,4 +32,6 @@ export type HydrateWorkspaceSessionOptions = {
contestedHostWorkspaceSessions?: HostSessionSlices
/** Partition each restored session key was read from; omitted leaves the store's copy. */
contestedPrimaryHostBySessionKey?: Record<string, ExecutionHostId>
/** Runtime partitions the cold read loaded; omitted leaves the store's copy. */
hydratedRuntimeSessionHostIds?: ExecutionHostId[]
} & WorkspaceSessionHydrationOptions
@@ -99,6 +99,8 @@ export type TerminalState = {
contestedHostWorkspaceSessions: HostSessionSlices
/** Partition each restored session key was read from, so a write returns its rows there. */
contestedPrimaryHostBySessionKey: Record<string, ExecutionHostId>
/** Runtime partitions the startup read loaded; only these may receive emptied host-keyed fields. */
hydratedRuntimeSessionHostIds: ExecutionHostId[]
defaultTerminalTabsAppliedByWorktreeId: Record<string, true>
closedTerminalTabTombstonesByTabId: ClosedTerminalTabTombstonesByTabId
hydrationSucceeded: boolean
@@ -35,7 +35,14 @@ export type WorkspaceHydrationPatch = Pick<
| 'localOnlyScrollbackByTabId'
> &
// Only cold reads carry contested-host shadows; scoped hydration must preserve the store’s copy.
Partial<Pick<AppState, 'contestedHostWorkspaceSessions' | 'contestedPrimaryHostBySessionKey'>>
Partial<
Pick<
AppState,
| 'contestedHostWorkspaceSessions'
| 'contestedPrimaryHostBySessionKey'
| 'hydratedRuntimeSessionHostIds'
>
>
export function replaceHydratedRecordKeys<T>(
current: Record<string, T>,
@@ -161,6 +161,9 @@ export function createWorkspaceTerminalHydrationActions(
...(options?.contestedPrimaryHostBySessionKey
? { contestedPrimaryHostBySessionKey: options.contestedPrimaryHostBySessionKey }
: {}),
...(options?.hydratedRuntimeSessionHostIds
? { hydratedRuntimeSessionHostIds: options.hydratedRuntimeSessionHostIds }
: {}),
repos: runtimeSessionPlaceholders.repos,
tabsByWorktree,
worktreesByRepo,