refactor(native-chat): unify host snapshot versioning

This commit is contained in:
Merge Sim
2026-09-02 17:44:39 -07:00
parent cf6088917b
commit bbb934ade4
22 changed files with 108 additions and 48 deletions
@@ -142,7 +142,7 @@ export class OrcaRuntimeWithApplyMobileSessionTabNavigation extends OrcaRuntimeW
tabs
}
this.persistHeadlessTerminalActiveLeaf(worktreeId, activeTab)
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
this.storeMobileSessionSnapshot(worktreeId, nextSnapshot)
this.emitMobileSessionTabsSnapshot(nextSnapshot)
}
@@ -107,7 +107,7 @@ export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWi
: {}),
tabs: nextTabs
}
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
this.storeMobileSessionSnapshot(worktreeId, nextSnapshot)
this.emitMobileSessionTabsSnapshot(nextSnapshot)
}
@@ -39,7 +39,7 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi
})),
tabs: nextTabs
}
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
this.storeMobileSessionSnapshot(worktreeId, nextSnapshot)
this.emitMobileSessionTabsSnapshot(nextSnapshot)
}
@@ -49,7 +49,7 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi
protected republishMobileSessionTabsSnapshot(worktreeId: string): void {
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
if (snapshot) {
this.mobileSessionTabsByWorktree.set(worktreeId, {
this.storeMobileSessionSnapshot(worktreeId, {
...snapshot,
snapshotVersion: snapshot.snapshotVersion + 1
})
@@ -161,7 +161,7 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi
})),
tabs: nextTabs
}
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
this.storeMobileSessionSnapshot(worktreeId, nextSnapshot)
this.emitMobileSessionTabsSnapshot(nextSnapshot)
return true
}
@@ -203,7 +203,7 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi
focusesHost,
publicationEpoch: `headless:${Date.now().toString(36)}`
})
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
this.storeMobileSessionSnapshot(worktreeId, nextSnapshot)
// Why: browser group membership is otherwise live-only; persist it so a
// later rebuild keeps the browser in its group instead of coalescing left.
if (placedInTargetGroup && nextSnapshot.tabGroupLayout) {
@@ -139,7 +139,7 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca
...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}),
tabs
}
this.mobileSessionTabsByWorktree.set(worktreeId, next)
this.storeMobileSessionSnapshot(worktreeId, next)
const result = this.toMobileSessionTabsResult(next)
const changeSequence = ++this.mobileSessionTabsChangeSequence
for (const subscription of this.mobileSessionTabListeners) {
@@ -65,7 +65,7 @@ export class OrcaRuntimeWithHasExactPersistedTerminalSurfaceIdentity extends Orc
exactOnly: true
})
if (retired) {
this.mobileSessionTabsByWorktree.set(candidate.worktreeId, retired.snapshot)
this.storeMobileSessionSnapshot(candidate.worktreeId, retired.snapshot)
this.notifyMobileSessionTabsChanged(candidate.worktreeId)
}
}
@@ -236,7 +236,7 @@ export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession
if (existing && headlessMobileSnapshotContentUnchanged(existing, nextSnapshot)) {
continue
}
this.mobileSessionTabsByWorktree.set(entryWorktreeId, nextSnapshot)
this.storeMobileSessionSnapshot(entryWorktreeId, nextSnapshot)
}
return reconciledWorktreeIds
}
@@ -72,7 +72,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith
if (nextGroups.length > 1 && snapshot.tabGroupLayout) {
this.persistHeadlessTabGroups(worktreeId, nextGroups, snapshot.tabGroupLayout)
}
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
this.storeMobileSessionSnapshot(worktreeId, nextSnapshot)
this.emitMobileSessionTabsSnapshot(nextSnapshot)
return { moved: true }
}
@@ -111,7 +111,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith
tabGroupLayout: split.layout
}
this.persistHeadlessTabGroups(worktreeId, split.groups, split.layout)
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
this.storeMobileSessionSnapshot(worktreeId, nextSnapshot)
this.emitMobileSessionTabsSnapshot(nextSnapshot)
return { moved: true }
}
@@ -147,7 +147,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith
tabGroupLayout: layout
}
this.persistHeadlessTabGroups(worktreeId, moved.groups, layout)
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
this.storeMobileSessionSnapshot(worktreeId, nextSnapshot)
this.emitMobileSessionTabsSnapshot(nextSnapshot)
return { moved: true }
}
@@ -95,7 +95,7 @@ export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWi
snapshotVersion: snapshot.snapshotVersion + 1,
tabs
}
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
this.storeMobileSessionSnapshot(worktreeId, nextSnapshot)
this.emitMobileSessionTabsSnapshot(nextSnapshot)
}
@@ -181,7 +181,7 @@ export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWi
snapshotVersion: snapshot.snapshotVersion + 1,
tabs
}
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
this.storeMobileSessionSnapshot(worktreeId, nextSnapshot)
this.emitMobileSessionTabsSnapshot(nextSnapshot)
}
}
@@ -173,7 +173,7 @@ export class OrcaRuntimeWithPersistTerminalSurfaceRetirements extends OrcaRuntim
: {})
})
if (retired) {
this.mobileSessionTabsByWorktree.set(worktreeId, retired.snapshot)
this.storeMobileSessionSnapshot(worktreeId, retired.snapshot)
this.notifyMobileSessionTabsChanged(worktreeId)
}
}
@@ -142,7 +142,7 @@ export class OrcaRuntimeWithPublishPtyBackedMobileSessionTerminal extends OrcaRu
...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}),
tabs
}
this.mobileSessionTabsByWorktree.set(worktreeId, next)
this.storeMobileSessionSnapshot(worktreeId, next)
if (args.notify !== false) {
this.notifyMobileSessionTabsChanged(worktreeId)
}
@@ -47,7 +47,7 @@ export class OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs extends Or
const active = activeStillPresent
? null
: (nextTabs.find((tab) => tab.isActive) ?? nextTabs[0] ?? null)
this.mobileSessionTabsByWorktree.set(worktreeId, {
this.storeMobileSessionSnapshot(worktreeId, {
...existing,
publicationEpoch: `headless-hydrated:${Date.now().toString(36)}`,
snapshotVersion: existing.snapshotVersion + 1,
@@ -39,7 +39,7 @@ export class OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals
continue
}
if (!existing) {
this.mobileSessionTabsByWorktree.set(targetWorktreeId, {
this.storeMobileSessionSnapshot(targetWorktreeId, {
worktree: targetWorktreeId,
publicationEpoch: `renderer-rescue:${Date.now().toString(36)}`,
snapshotVersion: 0,
@@ -94,7 +94,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu
),
tabs: existing.tabs.map((tab) => ({ ...tab, isActive: tab.id === id }))
}
this.mobileSessionTabsByWorktree.set(input.workspaceId, snapshot)
this.storeMobileSessionSnapshot(input.workspaceId, snapshot)
if (input.notify !== false) {
this.emitMobileSessionTabsSnapshot(snapshot)
}
@@ -143,7 +143,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu
...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}),
tabs
}
this.mobileSessionTabsByWorktree.set(input.workspaceId, snapshot)
this.storeMobileSessionSnapshot(input.workspaceId, snapshot)
if (input.notify !== false) {
this.emitMobileSessionTabsSnapshot(snapshot)
}
@@ -96,6 +96,21 @@ export class OrcaRuntimeWithRuntimeId {
protected mobileSessionTabsByWorktree = new Map<string, RuntimeMobileSessionTabsSnapshot>()
/** Single host writer for mobile session snapshots; versions are total-order stamps. */
protected storeMobileSessionSnapshot(
worktreeId: string,
snapshot: RuntimeMobileSessionTabsSnapshot
): RuntimeMobileSessionTabsSnapshot {
const existing = this.mobileSessionTabsByWorktree.get(worktreeId)
const snapshotVersion = existing
? Math.max(snapshot.snapshotVersion, existing.snapshotVersion + 1)
: snapshot.snapshotVersion
const stamped =
snapshotVersion === snapshot.snapshotVersion ? snapshot : { ...snapshot, snapshotVersion }
this.mobileSessionTabsByWorktree.set(worktreeId, stamped)
return stamped
}
protected structuredAgentSessionTabRestorePromise: Promise<void> | null = null
protected structuredAgentSessionStartupRestorePromise: Promise<void> | null = null
@@ -57,7 +57,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId
resolveOwner: (handle) => this.resolveNativeChatLaunchDraftOwner(handle),
listMobileSnapshots: () => this.mobileSessionTabsByWorktree,
setMobileSnapshot: (worktreeId, snapshot) =>
this.mobileSessionTabsByWorktree.set(worktreeId, snapshot),
this.storeMobileSessionSnapshot(worktreeId, snapshot),
scheduleMobileSnapshot: (worktreeId) => this.scheduleMobileSessionTabsChanged(worktreeId),
notifyResolved: (tabId, resolution, event) => {
this.notifier?.nativeChatLaunchDraftResolved?.(tabId, resolution)
@@ -8,7 +8,6 @@ import type {
import { getMobileSessionSnapshotTabIdentityKeys } from './mobile-session-tab-merge'
import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
import { sameRuntimeBrowserPlacement } from '../../shared/runtime-browser-placement'
import { createHash } from 'node:crypto'
import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows'
export class OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab extends OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs {
@@ -115,23 +114,14 @@ export class OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab extends Orc
protected getMergedMobileSessionPublicationEpoch(
snapshot: RuntimeMobileSessionTabsSnapshot,
preservedTabs: readonly RuntimeMobileSessionSnapshotTab[]
_preservedTabs: readonly RuntimeMobileSessionSnapshotTab[]
): string {
// Why: preserved snapshots can merge repeatedly; strip the prior merge suffix first so the publication epoch stays idempotent.
const normalizedPublicationEpoch = snapshot.publicationEpoch.split(':headless-merge:')[0]
const signature = createHash('sha1')
.update(
preservedTabs
.map((tab) =>
tab.type === 'terminal'
? `${tab.id}:${tab.parentTabId}:${tab.ptyId ?? ''}:${tab.leafId}`
: tab.id
)
.join('|')
)
.digest('hex')
.slice(0, 12)
return `${normalizedPublicationEpoch}:headless-merge:${signature}`
// The epoch identifies the publisher generation, not the merged content.
// Content changes are ordered by snapshotVersion, so encoding a merge hash
// here would make the identity oscillate and permanently fence later rows.
return normalizedPublicationEpoch
}
/** Serves a hydrating host renderer; the publisher counts this as a delivery, not a read. */
@@ -167,7 +167,7 @@ export class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWithWriteOr
const storedVersion = existing
? Math.max(nextSnapshot.snapshotVersion, existing.snapshotVersion + 1)
: nextSnapshot.snapshotVersion
this.mobileSessionTabsByWorktree.set(
this.storeMobileSessionSnapshot(
snapshot.worktree,
storedVersion === nextSnapshot.snapshotVersion
? nextSnapshot
@@ -195,7 +195,7 @@ export class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWithWriteOr
preserved.tabs.length === existing.tabs.length &&
preserved.tabs.every((tab, index) => tab === existing.tabs[index])
if (!preservedIsNoOp) {
this.mobileSessionTabsByWorktree.set(worktreeId, preserved)
this.storeMobileSessionSnapshot(worktreeId, preserved)
}
// Why: the stored entry is no longer the renderer's publication, so a
// future renderer frame must be re-merged even if it reuses the pair.
@@ -238,7 +238,7 @@ export class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow
// the PTY touch path does) or the re-emitted payload — e.g. the
// pending-handle → ready flip — is discarded and the client stays stale.
// The accepted-renderer tracking is untouched: this is a main-local bump.
this.mobileSessionTabsByWorktree.set(worktreeId, {
this.storeMobileSessionSnapshot(worktreeId, {
...stored,
snapshotVersion: stored.snapshotVersion + 1
})
@@ -595,7 +595,7 @@ describe('OrcaRuntimeService', () => {
const secondMerge = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(secondMerge.publicationEpoch).toBe(firstMerge.publicationEpoch)
expect(secondMerge.publicationEpoch.match(/:headless-merge:/g) ?? []).toHaveLength(1)
expect(secondMerge.publicationEpoch).toBe('headless:stable-epoch')
})
it('keeps the graph ready when a mobile snapshot references a removed folder workspace', () => {
@@ -21,7 +21,7 @@ export class OrcaRuntimeWithTouchMobileSessionTabsForWorktree extends OrcaRuntim
if (!snapshot) {
return
}
this.mobileSessionTabsByWorktree.set(worktreeId, {
this.storeMobileSessionSnapshot(worktreeId, {
...snapshot,
snapshotVersion: snapshot.snapshotVersion + 1
})
@@ -6,7 +6,8 @@ import { buildPersistedUnifiedTabSessionData } from '../lib/workspace-session-un
import { buildHydratedTabState } from '../store/slices/tabs-hydration'
import {
applyLocalStructuredSessionTabSnapshots,
projectLocalStructuredSessionTabs
projectLocalStructuredSessionTabs,
resetLocalStructuredSessionVersionForTests
} from './local-structured-session-tabs-sync'
import {
applyWebSessionTabsSnapshot,
@@ -25,6 +26,7 @@ const PRIMARY_GROUP = 'primary-group'
const SECONDARY_GROUP = 'secondary-group'
afterEach(() => {
resetLocalStructuredSessionVersionForTests()
resetWebSessionFocusIntentForTests()
resetWebSessionTabsSnapshotFreshnessForTests()
})
@@ -148,6 +150,49 @@ function expectExactSplit(state: {
}
describe('local structured session tab projection', () => {
it('accepts a newer session after merged content returns to the base epoch', () => {
const state = createSnapshot()
const base = {
...({
worktree: WORKTREE_ID,
publicationEpoch: 'renderer:generation-1',
snapshotVersion: 4,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
} satisfies RuntimeMobileSessionTabsResult)
}
const withChat = {
...base,
snapshotVersion: 5,
tabs: [
{
type: 'agent-session' as const,
id: STRUCTURED_ID,
title: 'Codex Chat',
sessionId: 'codex-1',
agent: 'codex' as const,
isActive: true
}
]
}
const afterClose = { ...base, snapshotVersion: 6 }
const next = applyLocalStructuredSessionTabSnapshots(
state,
[
base,
withChat,
afterClose,
{ ...base, snapshotVersion: 7, tabs: [{ ...withChat.tabs[0], isActive: true }] }
],
'local-structured-session'
)
expect(next.unifiedTabsByWorktree[WORKTREE_ID]).toEqual(
expect.arrayContaining([expect.objectContaining({ contentType: 'agent-session' })])
)
})
it('drops terminal topology while retaining structured tabs', () => {
const snapshot = {
worktree: 'workspace-1',
@@ -4,15 +4,19 @@ import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-typ
import { useAppStore } from '../store'
import type { WorktreeRuntimeOwnerState } from '../lib/worktree-runtime-owner'
import { getExecutionHostIdForWorktree } from '../lib/worktree-runtime-owner'
import {
applyWebSessionTabsSnapshot,
applyWebSessionTabsStorePatch,
decideWebSessionTabsSnapshot
} from './web-session-tabs-sync'
import { applyWebSessionTabsSnapshot, applyWebSessionTabsStorePatch } from './web-session-tabs-sync'
import type { WebSessionTabsSyncState } from './web-session-tabs-sync'
export const LOCAL_STRUCTURED_SESSION_OWNER = 'local-structured-session'
let localStructuredSessionTabsRestorePromise: Promise<void> | null = null
const localStructuredSessionVersionByWorktree = new Map<
string,
{ publicationEpoch: string; snapshotVersion: number }
>()
export function resetLocalStructuredSessionVersionForTests(): void {
localStructuredSessionVersionByWorktree.clear()
}
type SessionTabsEvent =
| (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' })
@@ -79,7 +83,9 @@ export function applyLocalStructuredSessionTabSnapshots<
if (getExecutionHostIdForWorktree(next, snapshot.worktree) !== 'local') {
continue
}
if (!decideWebSessionTabsSnapshot(snapshot, owner).apply) {
const prior = localStructuredSessionVersionByWorktree.get(snapshot.worktree)
const isNewerPublisher = prior?.publicationEpoch !== snapshot.publicationEpoch
if (prior && !isNewerPublisher && snapshot.snapshotVersion <= prior.snapshotVersion) {
continue
}
const patch = applyWebSessionTabsSnapshot(
@@ -94,6 +100,10 @@ export function applyLocalStructuredSessionTabSnapshots<
}
)
next = patch === next ? next : ({ ...next, ...patch } as State)
localStructuredSessionVersionByWorktree.set(snapshot.worktree, {
publicationEpoch: snapshot.publicationEpoch,
snapshotVersion: snapshot.snapshotVersion
})
}
return next
}