mirror of
https://github.com/stablyai/orca.git
synced 2026-10-04 08:02:09 +00:00
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.
This commit is contained in:
@@ -212,6 +212,31 @@ describe('writing a contested workspace id back', () => {
|
||||
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(
|
||||
|
||||
@@ -225,12 +225,16 @@ function hostStillClaimsKey(
|
||||
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
|
||||
claims: WorktreeHostClaims,
|
||||
mode: HostSessionWriteMode
|
||||
): void {
|
||||
if (!shadow) {
|
||||
return
|
||||
@@ -245,12 +249,19 @@ export function attachHostSessionShadow(
|
||||
}
|
||||
for (const field of WORKTREE_KEYED_FIELDS) {
|
||||
const parked = shadowSlice[field]
|
||||
const target = slice[field]
|
||||
// Why present-only: a patch that omits the field leaves the partition's own copy untouched,
|
||||
// so nothing needs restoring there.
|
||||
if (!isWorkspaceSessionRecord(parked) || !isWorkspaceSessionRecord(target)) {
|
||||
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
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
attachHostSessionShadow,
|
||||
indexWorktreeHostClaims,
|
||||
pickPrimaryHostForClaims,
|
||||
type HostSessionWriteMode,
|
||||
type WorktreeHostClaims
|
||||
} from './workspace-session-host-contention'
|
||||
import {
|
||||
@@ -169,11 +170,12 @@ export function buildHostIdByWorktreeId(state: HostPersistenceState): HostIdByWo
|
||||
* rows of every host that lost a contested id so this write cannot erase them. */
|
||||
function splitWorkspaceSessionForWrite(
|
||||
payload: WorkspaceSessionState,
|
||||
state: HostPersistenceState
|
||||
state: HostPersistenceState,
|
||||
mode: HostSessionWriteMode
|
||||
): HostSessionSlices {
|
||||
const routing = buildHostSessionRouting(state)
|
||||
const slices = splitWorkspaceSessionByHost(payload, routing.hostIdByWorktreeId)
|
||||
attachHostSessionShadow(slices, state.contestedHostWorkspaceSessions, routing.claims)
|
||||
attachHostSessionShadow(slices, state.contestedHostWorkspaceSessions, routing.claims, mode)
|
||||
return slices
|
||||
}
|
||||
|
||||
@@ -185,7 +187,7 @@ export function patchWorkspaceSessionByHost(
|
||||
patch: WorkspaceSessionPatch,
|
||||
state: HostPersistenceState
|
||||
): Promise<void> {
|
||||
const slices = splitWorkspaceSessionForWrite(patch as WorkspaceSessionState, 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 nonLocalHostSessionEntries(slices)) {
|
||||
@@ -205,7 +207,9 @@ export async function persistWorkspaceSessionByHost(
|
||||
payload: WorkspaceSessionState,
|
||||
state: HostPersistenceState
|
||||
): Promise<void> {
|
||||
const slices = splitWorkspaceSessionForWrite(payload, 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 nonLocalHostSessionEntries(slices)) {
|
||||
writes.push(api.set(slice, hostId))
|
||||
@@ -219,7 +223,8 @@ export function buildWorkspaceSessionHostSnapshots(
|
||||
payload: WorkspaceSessionState,
|
||||
state: HostPersistenceState
|
||||
): WorkspaceSessionHostSnapshot[] {
|
||||
const slices = splitWorkspaceSessionForWrite(payload, 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 },
|
||||
...nonLocalHostSessionEntries(slices).map(([hostId, hostState]) => ({
|
||||
|
||||
Reference in New Issue
Block a user