fix(runtime): keep the renderer epoch and sanitise persisted groups on the chat-only fall-through

This commit is contained in:
Jinwoo-H
2026-09-07 21:44:06 -04:00
parent a14f586bf1
commit e57d2ea209
2 changed files with 161 additions and 34 deletions
@@ -1,6 +1,7 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithWaitForSessionTabsInventoryPublication } from './orca-runtime-wait-for-session-tabs-inventory-publication'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import type { TabGroup } from '../../shared/tab-types'
import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
import { buildHeadlessMobileSessionTerminalTabs } from './mobile-session-terminal-projection'
@@ -28,6 +29,17 @@ import {
} from './mobile-session-browser-group-projection'
import { headlessMobileSnapshotContentUnchanged } from './mobile-session-snapshot-equality'
// Why: a persisted TabGroup carries `worktreeId`, which the published group
// shape does not — spreading one onto the wire leaks it to every client.
function toRuntimeMobileSessionTabGroup(group: TabGroup): RuntimeMobileSessionTabGroup {
return {
id: group.id,
activeTabId: group.activeTabId,
tabOrder: [...group.tabOrder],
...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {})
}
}
export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession extends OrcaRuntimeWithWaitForSessionTabsInventoryPublication {
protected hydrateHeadlessMobileSessionTabsFromWorkspaceSession(
worktreeId?: string,
@@ -142,12 +154,18 @@ export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession
...browserTabs.map((tab) => tab.id)
]
const groupId = getHeadlessMobileSessionGroupId(entryWorktreeId)
// Why: `tabs` already carries every live browser, so merging the existing
// browser entries back in resurrects a page that closed while the
// snapshot was chat-only (the skip branch's reconcile used to prune it).
const existingMergeBaseTabs = preserveNonTerminalSnapshot
? existing.tabs.filter((tab) => tab.type !== 'browser')
: existing?.tabs
const mergedTabs =
(options.onlyRuntimeOwnedTerminals === true || preserveNonTerminalSnapshot) && existing
? mergeMobileSessionSnapshotTabs(existing.tabs, tabs)
? mergeMobileSessionSnapshotTabs(existingMergeBaseTabs, tabs)
: tabs
const mergedActiveTab =
existing?.tabs.find((tab) => tab.id === existing.activeTabId) ??
existingMergeBaseTabs?.find((tab) => tab.id === existing?.activeTabId) ??
activeTab ??
mergedTabs[0] ??
null
@@ -177,17 +195,12 @@ export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession
entryWorktreeId,
mergedTabs,
mergedActiveTab,
existing.tabGroups ?? persistedGroups
existing.tabGroups ?? persistedGroups?.map(toRuntimeMobileSessionTabGroup)
)
: hasPersistedSplit
? appendBrowserTabOrder(
distributeHeadlessTabsAcrossGroups(
persistedGroups.map((group) => ({
id: group.id,
activeTabId: group.activeTabId,
tabOrder: [...group.tabOrder],
...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {})
})),
persistedGroups.map(toRuntimeMobileSessionTabGroup),
collectHeadlessParentTabOrder(mergedTerminalTabs),
activeTopLevelId
),
@@ -216,22 +229,31 @@ export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession
tabOrder
}
]
// Why: merging runtime tabs INTO a renderer publication must not reclass
// the snapshot as headless-built — the preservation predicate would then
// treat the renderer's own tabs as runtime-owned and resurrect tabs the
// renderer later closes. Keep the renderer base epoch with a merge suffix
// (idempotent) so ownership stays derivable from the epoch.
// Why: merging INTO a non-headless publication must not reclass the
// snapshot as headless-built — the preservation predicate would then treat
// that publisher's own tabs as runtime-owned and resurrect tabs it later
// closes. Applies to both merge paths: the chat-only fall-through also
// merges into an existing snapshot, whose base epoch can be a renderer's.
const mergedIntoRendererPublication =
options.onlyRuntimeOwnedTerminals === true &&
(options.onlyRuntimeOwnedTerminals === true || preserveNonTerminalSnapshot) &&
existing !== undefined &&
!this.isHeadlessBuiltMobileSessionPublicationBase(existing.publicationEpoch)
// Why: a group whose last tab is gone is dropped above, so a carried-over
// activeGroupId can name a group that no longer exists — reseat it on the
// group holding the active tab rather than publishing a dangling id.
const preservedActiveGroupId = existing?.activeGroupId ?? groupId
const nextActiveGroupId = nextTabGroups.some((group) => group.id === preservedActiveGroupId)
? preservedActiveGroupId
: (nextTabGroups.find((group) => group.tabOrder.includes(activeTopLevelId))?.id ??
nextTabGroups[0]?.id ??
preservedActiveGroupId)
const nextSnapshot: RuntimeMobileSessionTabsSnapshot = {
worktree: existing?.worktree ?? entryWorktreeId,
publicationEpoch: mergedIntoRendererPublication
? this.getMergedMobileSessionPublicationEpoch(existing, tabs)
: `headless-hydrated:${Date.now().toString(36)}`,
snapshotVersion: (existing?.snapshotVersion ?? 0) + 1,
activeGroupId: existing?.activeGroupId ?? groupId,
activeGroupId: nextActiveGroupId,
activeTabId: mergedActiveTab?.id ?? null,
activeTabType: mergedActiveTab?.type ?? null,
tabGroups: nextTabGroups,
@@ -1,6 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types'
import type {
RuntimeMobileSessionSnapshotTab,
RuntimeMobileSessionTabsSnapshot
} from '../../shared/runtime-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import * as terminalProjection from './mobile-session-terminal-projection'
import { OrcaRuntimeService } from './orca-runtime'
@@ -12,10 +15,15 @@ type RuntimeInternals = {
getAvailableAuthoritativeWindow(): unknown
getWorkspaceSessionForWorktree(worktreeId: string): WorkspaceSessionState
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
buildHeadlessMobileSessionBrowserTabs: () => never[]
buildHeadlessMobileSessionBrowserTabs: () => RuntimeMobileSessionSnapshotTab[]
reconcileHeadlessMobileSessionBrowserTabs: () => void
hasServeOrSshOwnedBinding(tab: { ptyId?: string }): boolean
hasRecentExpiredSshLeasePane(): boolean
isHeadlessBuiltMobileSessionPublicationBase(publicationEpoch: string): boolean
shouldPreserveHeadlessMobileSessionTab(
snapshot: RuntimeMobileSessionTabsSnapshot,
tab: RuntimeMobileSessionSnapshotTab
): boolean
hydrateHeadlessMobileSessionTabsFromWorkspaceSession(
worktreeId: string,
options: {
@@ -27,6 +35,21 @@ type RuntimeInternals = {
): Set<string>
}
function browserTab(): RuntimeMobileSessionSnapshotTab {
return {
type: 'browser',
id: 'browser',
browserWorkspaceId: 'page',
browserPageId: 'browser',
loading: false,
canGoBack: false,
canGoForward: false,
title: 'Page',
url: 'about:blank',
isActive: false
}
}
function setup() {
const runtime = new OrcaRuntimeService() as unknown as RuntimeInternals
const session: WorkspaceSessionState = {
@@ -81,18 +104,10 @@ describe('persisted terminal hydration behind non-terminal snapshots', () => {
it.each([false, true])('preserves the active chat and its group (browser split: %s)', (split) => {
const { runtime, session, snapshot } = setup()
if (split) {
snapshot.tabs.push({
type: 'browser',
id: 'browser',
browserWorkspaceId: 'page',
browserPageId: 'browser',
loading: false,
canGoBack: false,
canGoForward: false,
title: 'Page',
url: 'about:blank',
isActive: false
})
const browser = browserTab()
snapshot.tabs.push(browser)
// The page is still live, so the rebuild republishes it.
runtime.buildHeadlessMobileSessionBrowserTabs = vi.fn(() => [browser])
snapshot.tabGroups!.unshift({
id: 'browser-group',
activeTabId: 'browser',
@@ -213,9 +228,99 @@ describe('persisted terminal hydration behind non-terminal snapshots', () => {
allowAttachedWindow: true,
force: true
})
expect(runtime.mobileSessionTabsByWorktree.get(WORKTREE)!.tabs.map((tab) => tab.type)).toEqual([
'terminal',
'terminal'
])
const result = runtime.mobileSessionTabsByWorktree.get(WORKTREE)!
expect(result.tabs.map((tab) => tab.type)).toEqual(['terminal', 'terminal'])
// Guards the epoch fix below from over-correcting: a plain rebuild that
// merges into nothing is headless-built and must say so.
expect(result.publicationEpoch.startsWith('headless-hydrated:')).toBe(true)
})
})
describe('chat-only fall-through hygiene', () => {
const RENDERER_EPOCH = 'renderer:6b1f0f5c-0b6a-4d31-9d1f-6a0f1d2c3b4e'
it('carries a renderer base epoch forward instead of reclassing as headless-built', () => {
const { runtime, snapshot } = setup()
snapshot.publicationEpoch = RENDERER_EPOCH
runtime.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(WORKTREE, {
allowAttachedWindow: true
})
const result = runtime.mobileSessionTabsByWorktree.get(WORKTREE)!
expect(result.publicationEpoch).toBe(RENDERER_EPOCH)
expect(runtime.isHeadlessBuiltMobileSessionPublicationBase(result.publicationEpoch)).toBe(false)
const hydrated = result.tabs.find((tab) => tab.type === 'terminal')!
expect(runtime.shouldPreserveHeadlessMobileSessionTab(result, hydrated)).toBe(false)
})
it('keeps a headless base epoch headless-built', () => {
const { runtime, snapshot } = setup()
snapshot.publicationEpoch = 'headless:seed'
runtime.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(WORKTREE, {
allowAttachedWindow: true
})
const result = runtime.mobileSessionTabsByWorktree.get(WORKTREE)!
expect(result.publicationEpoch.startsWith('headless-hydrated:')).toBe(true)
expect(runtime.isHeadlessBuiltMobileSessionPublicationBase(result.publicationEpoch)).toBe(true)
})
it('publishes persisted groups in the wire shape, without worktreeId', () => {
const { runtime, session, snapshot } = setup()
delete snapshot.tabGroups
session.tabGroups = {
[WORKTREE]: ['left', 'right'].map((id) => ({
id,
worktreeId: WORKTREE,
activeTabId: null,
tabOrder: [],
recentTabIds: []
}))
}
runtime.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(WORKTREE, {
allowAttachedWindow: true
})
const result = runtime.mobileSessionTabsByWorktree.get(WORKTREE)!
expect(result.tabGroups!.length).toBeGreaterThan(0)
for (const group of result.tabGroups!) {
expect(Object.keys(group)).not.toContain('worktreeId')
}
})
it('drops a browser tab whose page is gone', () => {
const { runtime, snapshot } = setup()
snapshot.tabs.push(browserTab())
snapshot.tabGroups![0]!.tabOrder.push('browser')
runtime.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(WORKTREE, {
allowAttachedWindow: true
})
const result = runtime.mobileSessionTabsByWorktree.get(WORKTREE)!
expect(result.tabs.map((tab) => tab.id)).not.toContain('browser')
expect(result.tabs.some((tab) => tab.id === CHAT)).toBe(true)
expect(result.tabGroups!.flatMap((group) => group.tabOrder)).not.toContain('browser')
})
it('reseats the active group when the stale browser emptied it', () => {
const { runtime, snapshot } = setup()
snapshot.tabs.push(browserTab())
snapshot.tabGroups!.push({ id: 'browser-group', activeTabId: 'browser', tabOrder: ['browser'] })
snapshot.activeGroupId = 'browser-group'
snapshot.activeTabId = 'browser'
snapshot.activeTabType = 'browser'
runtime.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(WORKTREE, {
allowAttachedWindow: true
})
const result = runtime.mobileSessionTabsByWorktree.get(WORKTREE)!
expect(result.tabGroups!.map((group) => group.id)).not.toContain('browser-group')
expect(result.tabGroups!.some((group) => group.id === result.activeGroupId)).toBe(true)
expect(result.tabs.some((tab) => tab.id === result.activeTabId)).toBe(true)
})
})