perf(mobile): route graph-sync snapshot fanout through the notify coalescer (#9421)
* perf(mobile): route graph-sync snapshot fanout through the notify coalescer syncWindowGraph no longer unconditionally re-serializes+fans every worktree's mobile snapshot to every client. Serve-hydrate fast path skips the build-then-drop loop when no serve-owned pty exists (desktop case); changed-worktree gating (strict superset of snapshot inputs) routes only real changes through the existing coalescer. No-op syncs 720->0 emits; single-worktree churn 720->120 (~83% fewer bytes to phone). Real changes still propagate. 11-round GPT-5.6-Sol xHIGH review converged 2-consecutive-clean; independently verified tsc+830 tests. Co-authored-by: Orca <help@stably.ai> * fix(mobile): bump snapshotVersion on preserved prune frames + carry split-group layout Pre-merge adversarial review found two stale-phone regressions: (1) preserved-headless snapshots did not bump snapshotVersion, so a pruned-but-serve-preserved worktree change was dropped by clients' same-epoch freshness gate (orca-runtime.ts:22316); (2) serve-only no-op hydrate dropped the phone's split-group layout (orca-runtime.ts:3731). Both fixed + mutation-verified regression tests. 3 typechecks + suites green. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
42bff5e598
commit
4a7bdf9177
|
|
@ -0,0 +1,861 @@
|
|||
/**
|
||||
* Graph-sync mobile snapshot gating: the serve-only hydrate fast-path must
|
||||
* never hide a serve-owned terminal or a headless browser tab, and the
|
||||
* changed-worktree coalesced fanout must emit every real change while
|
||||
* suppressing no-op syncs entirely.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
RuntimeMobileSessionTabsResult,
|
||||
RuntimeMobileSessionTabsSnapshot
|
||||
} from '../../shared/runtime-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/types'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
// Freshness predicate of shouldApplyWebSessionTabsSnapshot in
|
||||
// src/renderer/src/runtime/web-session-tabs-sync.ts, copied as a literal
|
||||
// because a main-process test must not import a renderer module: a same-epoch
|
||||
// frame whose version is not strictly newer is dropped by web clients.
|
||||
function makeWebClientFreshnessGate(): (frame: RuntimeMobileSessionTabsResult) => boolean {
|
||||
const latestByWorktree = new Map<string, { publicationEpoch: string; snapshotVersion: number }>()
|
||||
return (frame) => {
|
||||
const current = latestByWorktree.get(frame.worktree)
|
||||
if (
|
||||
current &&
|
||||
current.publicationEpoch === frame.publicationEpoch &&
|
||||
frame.snapshotVersion <= current.snapshotVersion
|
||||
) {
|
||||
return false
|
||||
}
|
||||
latestByWorktree.set(frame.worktree, {
|
||||
publicationEpoch: frame.publicationEpoch,
|
||||
snapshotVersion: frame.snapshotVersion
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const WT = 'repo-1::/tmp/worktree-a'
|
||||
const WT_B = 'repo-1::/tmp/worktree-b'
|
||||
|
||||
const storeBase = {
|
||||
getRepo: () => ({
|
||||
id: 'repo-1',
|
||||
path: '/tmp/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1
|
||||
}),
|
||||
getRepos: () => [storeBase.getRepo()],
|
||||
addRepo: () => {},
|
||||
updateRepo: () => undefined as never,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined,
|
||||
getGitHubCache: () => ({ pr: {}, issue: {} }),
|
||||
setWorktreeMeta: () => undefined as never,
|
||||
removeWorktreeMeta: () => {},
|
||||
getSettings: () => ({
|
||||
workspaceDir: '/tmp/workspaces',
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
branchPrefix: 'none',
|
||||
branchPrefixCustom: ''
|
||||
})
|
||||
}
|
||||
|
||||
function makeSession(overrides: Partial<WorkspaceSessionState> = {}): WorkspaceSessionState {
|
||||
return {
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: WT,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeTerminalTab(id: string, ptyId: string | null) {
|
||||
return {
|
||||
id,
|
||||
ptyId,
|
||||
worktreeId: WT,
|
||||
title: `Terminal ${id}`,
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function createRuntime(initialSession: WorkspaceSessionState) {
|
||||
let session = initialSession
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...storeBase,
|
||||
getWorkspaceSession: () => session,
|
||||
setWorkspaceSession: (next: WorkspaceSessionState) => {
|
||||
session = next
|
||||
}
|
||||
})
|
||||
const events: RuntimeMobileSessionTabsResult[] = []
|
||||
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
|
||||
const setSession = (next: WorkspaceSessionState): void => {
|
||||
session = next
|
||||
}
|
||||
const sync = (
|
||||
mobileSessionTabs?: RuntimeMobileSessionTabsSnapshot[],
|
||||
graph: { tabs?: unknown[]; leaves?: unknown[] } = {}
|
||||
): void => {
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: (graph.tabs ?? []) as never,
|
||||
leaves: (graph.leaves ?? []) as never,
|
||||
mobileSessionTabs
|
||||
})
|
||||
}
|
||||
return { runtime, events, sync, setSession, unsubscribe }
|
||||
}
|
||||
|
||||
function makeRendererSnapshot(args: {
|
||||
worktree?: string
|
||||
version: number
|
||||
epoch?: string
|
||||
title?: string
|
||||
ptyId?: string
|
||||
}): RuntimeMobileSessionTabsSnapshot {
|
||||
const worktree = args.worktree ?? WT
|
||||
return {
|
||||
worktree,
|
||||
publicationEpoch: args.epoch ?? 'renderer:test-epoch',
|
||||
snapshotVersion: args.version,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: 'tab-1::leaf-1',
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-1::leaf-1',
|
||||
parentTabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
title: args.title ?? 'Terminal 1',
|
||||
...(args.ptyId ? { ptyId: args.ptyId } : {}),
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
type RuntimeInternals = {
|
||||
buildHeadlessMobileSessionTerminalTabs: (...args: unknown[]) => unknown[]
|
||||
offscreenBrowserBackend: unknown
|
||||
agentBrowserBridge: unknown
|
||||
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
|
||||
}
|
||||
|
||||
describe('graph-sync mobile snapshot gating', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('hydrates a serve-owned terminal bound via tab.ptyId and emits it', () => {
|
||||
const { events, sync } = createRuntime(
|
||||
makeSession({
|
||||
tabsByWorktree: { [WT]: [makeTerminalTab('serve-tab', 'serve-pty-1')] }
|
||||
})
|
||||
)
|
||||
|
||||
sync([])
|
||||
vi.advanceTimersByTime(60)
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.worktree).toBe(WT)
|
||||
expect(events[0]?.tabs).toEqual([
|
||||
expect.objectContaining({ type: 'terminal', parentTabId: 'serve-tab' })
|
||||
])
|
||||
})
|
||||
|
||||
it('hydrates a serve-owned terminal bound ONLY via the layout leaf map (superset proof)', () => {
|
||||
const { events, sync } = createRuntime(
|
||||
makeSession({
|
||||
tabsByWorktree: { [WT]: [makeTerminalTab('split-tab', null)] },
|
||||
terminalLayoutsByTabId: {
|
||||
'split-tab': {
|
||||
root: { type: 'leaf', leafId: 'leaf-a' },
|
||||
activeLeafId: 'leaf-a',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { 'leaf-a': 'serve-pty-2' }
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
sync([])
|
||||
vi.advanceTimersByTime(60)
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.tabs).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
parentTabId: 'split-tab',
|
||||
parentLayout: expect.objectContaining({
|
||||
ptyIdsByLeafId: { 'leaf-a': 'serve-pty-2' }
|
||||
})
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('skips the serve-only hydrate rebuild and emits nothing when no serve ptys and no browser backend', () => {
|
||||
const { runtime, events, sync } = createRuntime(
|
||||
makeSession({
|
||||
tabsByWorktree: { [WT]: [makeTerminalTab('plain-tab', 'repo-1::wt@@abc')] }
|
||||
})
|
||||
)
|
||||
const buildSpy = vi.spyOn(
|
||||
runtime as unknown as RuntimeInternals,
|
||||
'buildHeadlessMobileSessionTerminalTabs'
|
||||
)
|
||||
|
||||
const snapshot = makeRendererSnapshot({ version: 1 })
|
||||
sync([structuredClone(snapshot)])
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(buildSpy).not.toHaveBeenCalled()
|
||||
|
||||
// Re-sync with fresh IPC-style structured clones of the same publication
|
||||
// (same epoch + version = the renderer's unchanged-content resend): zero
|
||||
// emits, even though object identity never survives IPC.
|
||||
events.length = 0
|
||||
for (let i = 0; i < 5; i++) {
|
||||
sync([structuredClone(snapshot)])
|
||||
}
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(events).toHaveLength(0)
|
||||
expect(buildSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still emits when the renderer publishes a new version with identical content', () => {
|
||||
const { events, sync } = createRuntime(makeSession())
|
||||
|
||||
sync([makeRendererSnapshot({ version: 1 })])
|
||||
vi.advanceTimersByTime(300)
|
||||
events.length = 0
|
||||
|
||||
// Byte-identical content, but a fresh version: a higher version is the
|
||||
// renderer saying "this worktree changed", so it must emit.
|
||||
sync([makeRendererSnapshot({ version: 2 })])
|
||||
vi.advanceTimersByTime(60)
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts a renderer-reload epoch with a reset snapshotVersion and emits', () => {
|
||||
const { events, sync } = createRuntime(makeSession())
|
||||
|
||||
sync([makeRendererSnapshot({ version: 5, epoch: 'renderer:epoch-1' })])
|
||||
vi.advanceTimersByTime(300)
|
||||
events.length = 0
|
||||
|
||||
sync([makeRendererSnapshot({ version: 1, epoch: 'renderer:epoch-2', title: 'Renamed' })])
|
||||
vi.advanceTimersByTime(60)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.publicationEpoch).toBe('renderer:epoch-2')
|
||||
})
|
||||
|
||||
it('hydrates headless browser tabs when an offscreen backend exists despite zero serve terminals', () => {
|
||||
const { runtime, events, sync } = createRuntime(
|
||||
makeSession({
|
||||
tabsByWorktree: { [WT]: [makeTerminalTab('plain-tab', 'repo-1::wt@@abc')] }
|
||||
})
|
||||
)
|
||||
const internals = runtime as unknown as RuntimeInternals
|
||||
internals.offscreenBrowserBackend = { closeTab: vi.fn() }
|
||||
internals.agentBrowserBridge = {
|
||||
tabList: vi.fn(() => ({
|
||||
tabs: [
|
||||
{ browserPageId: 'page-1', index: 0, url: 'https://x.test', title: 'X', active: true }
|
||||
]
|
||||
})),
|
||||
getRegisteredTabs: vi.fn(() => new Map([['page-1', 100]]))
|
||||
}
|
||||
|
||||
sync([])
|
||||
vi.advanceTimersByTime(60)
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.tabs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'browser', browserPageId: 'page-1' })
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('fans out only the changed worktree, never the unchanged sibling (across IPC clones)', () => {
|
||||
const { events, sync } = createRuntime(makeSession())
|
||||
const snapshotB = makeRendererSnapshot({ worktree: WT_B, version: 1 })
|
||||
|
||||
sync([makeRendererSnapshot({ version: 1 }), structuredClone(snapshotB)])
|
||||
vi.advanceTimersByTime(300)
|
||||
events.length = 0
|
||||
|
||||
// Worktree A republishes with a new version; unchanged B arrives as a fresh
|
||||
// structured clone with the same (epoch, version) pair, exactly as the
|
||||
// renderer's per-worktree snapshot cache produces over IPC.
|
||||
sync([makeRendererSnapshot({ version: 3, title: 'Renamed' }), structuredClone(snapshotB)])
|
||||
vi.advanceTimersByTime(60)
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.worktree).toBe(WT)
|
||||
})
|
||||
|
||||
it('emits a removed frame immediately and cancels the pending coalesced notify', () => {
|
||||
const { events, sync } = createRuntime(makeSession())
|
||||
|
||||
sync([makeRendererSnapshot({ version: 1 })])
|
||||
// Pending coalesced notify exists (no timer advance yet). Removing the
|
||||
// worktree must emit the removed frame NOW and cancel the pending notify.
|
||||
sync([])
|
||||
|
||||
const removed = events.filter((event) => 'removed' in event && event.removed === true)
|
||||
expect(removed).toHaveLength(1)
|
||||
expect(removed[0]?.worktree).toBe(WT)
|
||||
|
||||
events.length = 0
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('forces an emit by the starvation cap under sustained sync churn', () => {
|
||||
const { events, sync } = createRuntime(makeSession())
|
||||
|
||||
sync([makeRendererSnapshot({ version: 1 })])
|
||||
vi.advanceTimersByTime(300)
|
||||
events.length = 0
|
||||
|
||||
// Re-schedule every 20ms: the 50ms trailing edge never settles, so the
|
||||
// 250ms cap must force the emit (the schedule at t>=250 fires inline).
|
||||
let version = 2
|
||||
for (let elapsed = 0; elapsed <= 300; elapsed += 20) {
|
||||
sync([makeRendererSnapshot({ version: version++, title: `spin-${version}` })])
|
||||
vi.advanceTimersByTime(20)
|
||||
}
|
||||
expect(events.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('emits ready when a graph-only sync binds a restored leaf under an unchanged snapshot pair', () => {
|
||||
const { events, sync } = createRuntime(makeSession())
|
||||
const webClientAccepts = makeWebClientFreshnessGate()
|
||||
// Renderer restore: the saved layout ptyId is in the snapshot, but the
|
||||
// graph leaf has not re-bound yet (null ptyId) — client sees pending-handle.
|
||||
const snapshot = makeRendererSnapshot({ version: 1, ptyId: 'pty-restored' })
|
||||
const nullLeaf = {
|
||||
tabId: 'tab-1',
|
||||
worktreeId: WT,
|
||||
leafId: 'leaf-1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: null
|
||||
}
|
||||
sync([structuredClone(snapshot)], { leaves: [nullLeaf] })
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.tabs[0]).toMatchObject({ status: 'pending-handle', terminal: null })
|
||||
// The web client applies the pending frame and records its (epoch, version).
|
||||
expect(webClientAccepts(events[0]!)).toBe(true)
|
||||
|
||||
// A later graph-only sync binds the leaf while the renderer resends the
|
||||
// exact same (epoch, version) pair. The payload is a function of leaf state
|
||||
// too, so the subscriber must receive ready + a terminal handle.
|
||||
events.length = 0
|
||||
sync([structuredClone(snapshot)], { leaves: [{ ...nullLeaf, ptyId: 'pty-restored' }] })
|
||||
vi.advanceTimersByTime(60)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.tabs[0]).toMatchObject({
|
||||
status: 'ready',
|
||||
terminal: expect.stringMatching(/^term_/)
|
||||
})
|
||||
// Why: the raw listener receiving the frame is not enough — the web
|
||||
// freshness gate drops same-epoch versions that aren't strictly newer, so
|
||||
// the ready frame must carry a bumped version to actually reach the client.
|
||||
expect(webClientAccepts(events[0]!)).toBe(true)
|
||||
|
||||
// Once bound, further byte-identical no-op syncs stay fully suppressed.
|
||||
events.length = 0
|
||||
sync([structuredClone(snapshot)], { leaves: [{ ...nullLeaf, ptyId: 'pty-restored' }] })
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('accepts a newer renderer revision after main-local touches raised the stored version', () => {
|
||||
const { runtime, events, sync } = createRuntime(makeSession())
|
||||
sync([makeRendererSnapshot({ version: 1, ptyId: 'pty-live' })])
|
||||
vi.advanceTimersByTime(300)
|
||||
events.length = 0
|
||||
|
||||
// Several OSC/status chunks touch the stored snapshot: version 1 → 6,
|
||||
// while the renderer's counter is still at 1.
|
||||
const internals = runtime as unknown as RuntimeInternals & {
|
||||
touchMobileSessionSnapshotsForPty: (ptyId: string) => void
|
||||
}
|
||||
for (let i = 0; i < 5; i++) {
|
||||
internals.touchMobileSessionSnapshotsForPty('pty-live')
|
||||
}
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(internals.mobileSessionTabsByWorktree.get(WT)?.snapshotVersion).toBe(6)
|
||||
events.length = 0
|
||||
|
||||
// The renderer's next real change publishes version 2 — lower than main's 6.
|
||||
// It must merge (renderer ordering, not stored-version ordering) and the
|
||||
// emitted version must stay strictly monotonic so clients don't drop it.
|
||||
sync([makeRendererSnapshot({ version: 2, ptyId: 'pty-live', title: 'Renamed tab' })])
|
||||
vi.advanceTimersByTime(60)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.tabs[0]).toMatchObject({ title: 'Renamed tab' })
|
||||
expect(events[0]?.snapshotVersion).toBeGreaterThan(6)
|
||||
|
||||
// Byte-identical graph-sync resends of that revision are no-op suppressed
|
||||
// and never resurrect the pre-rename content.
|
||||
events.length = 0
|
||||
const emittedVersion = internals.mobileSessionTabsByWorktree.get(WT)?.snapshotVersion
|
||||
for (let i = 0; i < 3; i++) {
|
||||
sync([makeRendererSnapshot({ version: 2, ptyId: 'pty-live', title: 'Renamed tab' })])
|
||||
}
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(events).toHaveLength(0)
|
||||
expect(internals.mobileSessionTabsByWorktree.get(WT)?.snapshotVersion).toBe(emittedVersion)
|
||||
expect(internals.mobileSessionTabsByWorktree.get(WT)?.tabs[0]?.title).toBe('Renamed tab')
|
||||
})
|
||||
|
||||
it('suppresses repeated unchanged syncs with a serve-owned terminal present', () => {
|
||||
const { runtime, events, sync } = createRuntime(
|
||||
makeSession({
|
||||
tabsByWorktree: { [WT]: [makeTerminalTab('serve-tab', 'serve-pty-1')] }
|
||||
})
|
||||
)
|
||||
sync([])
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(events).toHaveLength(1)
|
||||
const internals = runtime as unknown as RuntimeInternals
|
||||
const stored = internals.mobileSessionTabsByWorktree.get(WT)
|
||||
|
||||
// Byte-identical re-syncs: the serve-only hydrate rebuilds the projection
|
||||
// but must retain the existing snapshot object/epoch/version, so the
|
||||
// identity-based no-op gating emits nothing.
|
||||
events.length = 0
|
||||
for (let i = 0; i < 5; i++) {
|
||||
sync([])
|
||||
}
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(events).toHaveLength(0)
|
||||
expect(internals.mobileSessionTabsByWorktree.get(WT)).toBe(stored)
|
||||
})
|
||||
|
||||
it('suppresses repeated unchanged syncs with an offscreen browser backend enabled', () => {
|
||||
const { runtime, events, sync } = createRuntime(
|
||||
makeSession({
|
||||
tabsByWorktree: { [WT]: [makeTerminalTab('plain-tab', 'repo-1::wt@@abc')] }
|
||||
})
|
||||
)
|
||||
const internals = runtime as unknown as RuntimeInternals
|
||||
internals.offscreenBrowserBackend = { closeTab: vi.fn() }
|
||||
internals.agentBrowserBridge = {
|
||||
tabList: vi.fn(() => ({
|
||||
tabs: [
|
||||
{ browserPageId: 'page-1', index: 0, url: 'https://x.test', title: 'X', active: true }
|
||||
]
|
||||
})),
|
||||
getRegisteredTabs: vi.fn(() => new Map([['page-1', 100]]))
|
||||
}
|
||||
sync([])
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(events).toHaveLength(1)
|
||||
const stored = internals.mobileSessionTabsByWorktree.get(WT)
|
||||
|
||||
events.length = 0
|
||||
for (let i = 0; i < 5; i++) {
|
||||
sync([])
|
||||
}
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(events).toHaveLength(0)
|
||||
expect(internals.mobileSessionTabsByWorktree.get(WT)).toBe(stored)
|
||||
|
||||
// A real browser change (a newly opened page) must still emit. Same-page
|
||||
// navigation is delivered via the browser-bridge notify path, not graph
|
||||
// sync, so a new tab is the graph-sync-visible browser change.
|
||||
internals.agentBrowserBridge = {
|
||||
tabList: vi.fn(() => ({
|
||||
tabs: [
|
||||
{ browserPageId: 'page-1', index: 0, url: 'https://x.test', title: 'X', active: false },
|
||||
{ browserPageId: 'page-2', index: 1, url: 'https://y.test', title: 'Y', active: true }
|
||||
]
|
||||
})),
|
||||
getRegisteredTabs: vi.fn(
|
||||
() =>
|
||||
new Map([
|
||||
['page-1', 100],
|
||||
['page-2', 101]
|
||||
])
|
||||
)
|
||||
}
|
||||
sync([])
|
||||
vi.advanceTimersByTime(60)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.tabs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'browser', browserPageId: 'page-2' })
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('drops a closed renderer tab from a mixed renderer+serve snapshot while keeping the serve tab', () => {
|
||||
const { runtime, events, sync, setSession } = createRuntime(makeSession())
|
||||
const internals = runtime as unknown as RuntimeInternals
|
||||
|
||||
// Renderer publishes desktop tab A with no serve terminals anywhere.
|
||||
sync([makeRendererSnapshot({ version: 1 })])
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(events).toHaveLength(1)
|
||||
|
||||
// A serve-owned terminal appears in the persisted session; the serve-only
|
||||
// hydrate merges it into the stored RENDERER publication (mixed snapshot).
|
||||
setSession(
|
||||
makeSession({
|
||||
tabsByWorktree: { [WT]: [makeTerminalTab('serve-tab', 'serve-pty-1')] }
|
||||
})
|
||||
)
|
||||
events.length = 0
|
||||
sync([makeRendererSnapshot({ version: 1 })])
|
||||
vi.advanceTimersByTime(60)
|
||||
expect(events).toHaveLength(1)
|
||||
const mixed = internals.mobileSessionTabsByWorktree.get(WT)
|
||||
expect(mixed?.tabs.map((tab) => (tab.type === 'terminal' ? tab.parentTabId : tab.id))).toEqual([
|
||||
'tab-1',
|
||||
'serve-tab'
|
||||
])
|
||||
|
||||
// Desktop closes tab A: the next renderer publication omits it. The merge
|
||||
// must NOT resurrect A from the mixed snapshot, but the serve tab (not
|
||||
// renderer-owned) must survive.
|
||||
events.length = 0
|
||||
sync([
|
||||
{
|
||||
worktree: WT,
|
||||
publicationEpoch: 'renderer:test-epoch',
|
||||
snapshotVersion: 2,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
])
|
||||
vi.advanceTimersByTime(60)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.tabs).toEqual([
|
||||
expect.objectContaining({ type: 'terminal', parentTabId: 'serve-tab' })
|
||||
])
|
||||
|
||||
// The serve binding itself disappears from persistence (and no live PTY
|
||||
// backs it): the next renderer publication must stop preserving it too.
|
||||
setSession(makeSession())
|
||||
events.length = 0
|
||||
sync([
|
||||
{
|
||||
worktree: WT,
|
||||
publicationEpoch: 'renderer:test-epoch',
|
||||
snapshotVersion: 3,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
])
|
||||
vi.advanceTimersByTime(60)
|
||||
expect(internals.mobileSessionTabsByWorktree.get(WT)?.tabs).toEqual([])
|
||||
})
|
||||
|
||||
it('delivers the prune frame when the renderer omits a worktree whose serve tab is preserved', () => {
|
||||
const { events, sync } = createRuntime(
|
||||
makeSession({
|
||||
tabsByWorktree: { [WT]: [makeTerminalTab('serve-tab', 'serve-pty-1')] }
|
||||
})
|
||||
)
|
||||
const webClientAccepts = makeWebClientFreshnessGate()
|
||||
|
||||
// Phone accepts the renderer+serve merged frame.
|
||||
sync([makeRendererSnapshot({ version: 1 })])
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(webClientAccepts(events[0]!)).toBe(true)
|
||||
expect(events[0]?.tabs.map((tab) => ('parentTabId' in tab ? tab.parentTabId : tab.id))).toEqual(
|
||||
['tab-1', 'serve-tab']
|
||||
)
|
||||
|
||||
// Desktop closes the renderer tab, so the next graph omits the worktree
|
||||
// while the serve binding persists. Preservation prunes the renderer tab,
|
||||
// but the preserved epoch hashes only the unchanged serve tab — without a
|
||||
// fresh snapshotVersion the phone's same-epoch freshness gate drops the
|
||||
// prune frame and keeps the closed tab forever.
|
||||
events.length = 0
|
||||
sync([])
|
||||
vi.advanceTimersByTime(60)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.tabs).toEqual([
|
||||
expect.objectContaining({ type: 'terminal', parentTabId: 'serve-tab' })
|
||||
])
|
||||
expect(webClientAccepts(events[0]!)).toBe(true)
|
||||
|
||||
// Recomputing the preservation on further omitted syncs is a genuine
|
||||
// no-op: the preservedIsNoOp identity gate keeps the entry, zero fanout.
|
||||
events.length = 0
|
||||
for (let i = 0; i < 3; i++) {
|
||||
sync([])
|
||||
}
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops a de-persisted serve tab when the renderer resends the unchanged accepted revision', () => {
|
||||
const { runtime, events, sync, setSession } = createRuntime(makeSession())
|
||||
const internals = runtime as unknown as RuntimeInternals
|
||||
const webClientAccepts = makeWebClientFreshnessGate()
|
||||
|
||||
// Renderer publishes (epoch, version 1) while a serve binding exists; the
|
||||
// serve-only hydrate merges the serve tab and version 1 becomes accepted.
|
||||
setSession(
|
||||
makeSession({
|
||||
tabsByWorktree: { [WT]: [makeTerminalTab('serve-tab', 'serve-pty-1')] }
|
||||
})
|
||||
)
|
||||
sync([makeRendererSnapshot({ version: 1 })])
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(webClientAccepts(events[0]!)).toBe(true)
|
||||
expect(
|
||||
internals.mobileSessionTabsByWorktree
|
||||
.get(WT)
|
||||
?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'serve-tab')
|
||||
).toBe(true)
|
||||
|
||||
// The serve binding disappears from persistence (no live PTY backs it)
|
||||
// while desktop tabs are unchanged, so the renderer correctly resends the
|
||||
// SAME (epoch, version 1). The accepted-revision no-op gate must not keep
|
||||
// the stale preserved serve tab published: the resend must re-merge, drop
|
||||
// the tab, and reach clients past their same-epoch freshness gate.
|
||||
setSession(makeSession())
|
||||
events.length = 0
|
||||
sync([makeRendererSnapshot({ version: 1 })])
|
||||
vi.advanceTimersByTime(60)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(
|
||||
events[0]?.tabs.some((tab) => 'parentTabId' in tab && tab.parentTabId === 'serve-tab')
|
||||
).toBe(false)
|
||||
expect(webClientAccepts(events[0]!)).toBe(true)
|
||||
expect(
|
||||
internals.mobileSessionTabsByWorktree
|
||||
.get(WT)
|
||||
?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'serve-tab')
|
||||
).toBe(false)
|
||||
|
||||
// Further byte-identical resends of the accepted revision stay suppressed.
|
||||
events.length = 0
|
||||
for (let i = 0; i < 3; i++) {
|
||||
sync([makeRendererSnapshot({ version: 1 })])
|
||||
}
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops a de-persisted SSH tab when the renderer resends the unchanged accepted revision', () => {
|
||||
const sshPtyId = 'ssh:conn-1@@pty-7'
|
||||
const { runtime, sync, setSession } = createRuntime(
|
||||
makeSession({
|
||||
tabsByWorktree: { [WT]: [makeTerminalTab('ssh-tab', sshPtyId)] }
|
||||
})
|
||||
)
|
||||
const internals = runtime as unknown as RuntimeInternals & {
|
||||
hydrateHeadlessMobileSessionTabsFromWorkspaceSession: (worktreeId?: string) => Set<string>
|
||||
}
|
||||
internals.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(WT)
|
||||
|
||||
// Renderer attaches: the SSH tab merges into the accepted renderer revision.
|
||||
sync([makeRendererSnapshot({ version: 1 })])
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(
|
||||
internals.mobileSessionTabsByWorktree
|
||||
.get(WT)
|
||||
?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'ssh-tab')
|
||||
).toBe(true)
|
||||
|
||||
// The persisted SSH binding is removed with no renderer-visible change, so
|
||||
// the renderer resends the unchanged version 1 — the tab must still drop.
|
||||
setSession(makeSession())
|
||||
sync([makeRendererSnapshot({ version: 1 })])
|
||||
vi.advanceTimersByTime(60)
|
||||
expect(
|
||||
internals.mobileSessionTabsByWorktree
|
||||
.get(WT)
|
||||
?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'ssh-tab')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('still preserves every tab across a renderer publication when the base snapshot is headless-built', () => {
|
||||
const { runtime, sync } = createRuntime(
|
||||
makeSession({
|
||||
tabsByWorktree: { [WT]: [makeTerminalTab('serve-tab', 'serve-pty-1')] }
|
||||
})
|
||||
)
|
||||
const internals = runtime as unknown as RuntimeInternals
|
||||
|
||||
// Headless-built snapshot (serve hydrate, no renderer publication yet).
|
||||
sync([])
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(
|
||||
internals.mobileSessionTabsByWorktree.get(WT)?.publicationEpoch.startsWith('headless')
|
||||
).toBe(true)
|
||||
|
||||
// A renderer attaches and publishes an unrelated tab: the headless-built
|
||||
// tab must be preserved into the merged renderer snapshot (broad rule).
|
||||
sync([makeRendererSnapshot({ version: 1 })])
|
||||
vi.advanceTimersByTime(60)
|
||||
const merged = internals.mobileSessionTabsByWorktree.get(WT)
|
||||
expect(
|
||||
merged?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'serve-tab')
|
||||
).toBe(true)
|
||||
expect(merged?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'tab-1')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves a runtime-owned SSH terminal across successive renderer revisions', () => {
|
||||
const sshPtyId = 'ssh:conn-1@@pty-7'
|
||||
const { runtime, sync, setSession } = createRuntime(
|
||||
makeSession({
|
||||
tabsByWorktree: { [WT]: [makeTerminalTab('ssh-tab', sshPtyId)] }
|
||||
})
|
||||
)
|
||||
const internals = runtime as unknown as RuntimeInternals & {
|
||||
hydrateHeadlessMobileSessionTabsFromWorkspaceSession: (worktreeId?: string) => Set<string>
|
||||
}
|
||||
// Full headless hydrate (SSH tabs never come from the serve-only path)
|
||||
// builds the SSH tab into a headless-built snapshot.
|
||||
internals.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(WT)
|
||||
expect(internals.mobileSessionTabsByWorktree.get(WT)?.tabs).toEqual([
|
||||
expect.objectContaining({ type: 'terminal', parentTabId: 'ssh-tab' })
|
||||
])
|
||||
|
||||
// A renderer attaches and publishes an unrelated tab: the SSH tab is merged
|
||||
// into the renderer epoch (broad headless-built preservation).
|
||||
sync([makeRendererSnapshot({ version: 1 })])
|
||||
vi.advanceTimersByTime(300)
|
||||
const merged = internals.mobileSessionTabsByWorktree.get(WT)
|
||||
expect(
|
||||
merged?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'ssh-tab')
|
||||
).toBe(true)
|
||||
|
||||
// A newer renderer revision still omits the still-runtime-owned SSH tab.
|
||||
// Its binding remains persisted, so it must remain published — this is the
|
||||
// regression: serve-only preservation dropped app-scoped ssh:@@ bindings.
|
||||
sync([makeRendererSnapshot({ version: 2, title: 'Renamed' })])
|
||||
vi.advanceTimersByTime(60)
|
||||
expect(
|
||||
internals.mobileSessionTabsByWorktree
|
||||
.get(WT)
|
||||
?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'ssh-tab')
|
||||
).toBe(true)
|
||||
|
||||
// Once the SSH binding disappears from persistence (and no live PTY backs
|
||||
// it), the next renderer revision must stop preserving it.
|
||||
setSession(makeSession())
|
||||
sync([makeRendererSnapshot({ version: 3, title: 'Renamed again' })])
|
||||
vi.advanceTimersByTime(60)
|
||||
expect(
|
||||
internals.mobileSessionTabsByWorktree
|
||||
.get(WT)
|
||||
?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'ssh-tab')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('retains the split-group layout across no-op syncs with a serve-owned terminal present', () => {
|
||||
const { runtime, events, sync } = createRuntime(
|
||||
makeSession({
|
||||
tabsByWorktree: { [WT]: [makeTerminalTab('serve-tab', 'serve-pty-1')] }
|
||||
})
|
||||
)
|
||||
const internals = runtime as unknown as RuntimeInternals
|
||||
const splitLayout = {
|
||||
type: 'split' as const,
|
||||
direction: 'horizontal' as const,
|
||||
first: { type: 'leaf' as const, groupId: 'group-1' },
|
||||
second: { type: 'leaf' as const, groupId: 'group-2' },
|
||||
ratio: 0.4
|
||||
}
|
||||
const makeSplitRendererSnapshot = (): RuntimeMobileSessionTabsSnapshot => ({
|
||||
worktree: WT,
|
||||
publicationEpoch: 'renderer:test-epoch',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: 'tab-1::leaf-1',
|
||||
activeTabType: 'terminal',
|
||||
tabGroups: [
|
||||
{ id: 'group-1', activeTabId: 'tab-1', tabOrder: ['tab-1'] },
|
||||
{ id: 'group-2', activeTabId: 'tab-2', tabOrder: ['tab-2'] }
|
||||
],
|
||||
tabGroupLayout: structuredClone(splitLayout),
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-1::leaf-1',
|
||||
parentTabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
title: 'Terminal 1',
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-2::leaf-1',
|
||||
parentTabId: 'tab-2',
|
||||
leafId: 'leaf-1',
|
||||
title: 'Terminal 2',
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Renderer publishes a two-group split while a serve binding exists; the
|
||||
// merged snapshot must carry the renderer's split layout.
|
||||
sync([makeSplitRendererSnapshot()])
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.tabGroupLayout).toEqual(splitLayout)
|
||||
expect(internals.mobileSessionTabsByWorktree.get(WT)?.tabGroupLayout).toEqual(splitLayout)
|
||||
|
||||
// A second identical renderer pair: the serve-only hydrate rebuild runs
|
||||
// (serve PTY present) and must carry the stored split layout forward, so
|
||||
// the sync is a full no-op — stored layout unchanged, zero fanout.
|
||||
events.length = 0
|
||||
sync([makeSplitRendererSnapshot()])
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(events).toHaveLength(0)
|
||||
expect(internals.mobileSessionTabsByWorktree.get(WT)?.tabGroupLayout).toEqual(splitLayout)
|
||||
})
|
||||
|
||||
it('hydrates a serve terminal that appears in the session after suppressed syncs', () => {
|
||||
const { events, sync, setSession } = createRuntime(makeSession())
|
||||
|
||||
sync([])
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(events).toHaveLength(0)
|
||||
|
||||
setSession(
|
||||
makeSession({
|
||||
tabsByWorktree: { [WT]: [makeTerminalTab('late-serve-tab', 'serve-pty-9')] }
|
||||
})
|
||||
)
|
||||
sync([])
|
||||
vi.advanceTimersByTime(60)
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.tabs).toEqual([
|
||||
expect.objectContaining({ type: 'terminal', parentTabId: 'late-serve-tab' })
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -2270,6 +2270,16 @@ export class OrcaRuntimeService {
|
|||
private authoritativeWindowId: number | null = null
|
||||
private tabs = new Map<string, RuntimeSyncedTab>()
|
||||
private mobileSessionTabsByWorktree = new Map<string, RuntimeMobileSessionTabsSnapshot>()
|
||||
// Why: renderer publication ordering must be judged against the renderer's
|
||||
// own last-accepted (epoch, version) — never against the stored snapshot's
|
||||
// version, which main-local touches bump independently and can push
|
||||
// permanently ahead of the renderer's counter. The renderer reuses one pair
|
||||
// for byte-identical content, so a same-epoch version <= this one is a no-op
|
||||
// resend (or stale) and is skipped without touching the stored entry.
|
||||
private acceptedRendererMobileSnapshotByWorktree = new Map<
|
||||
string,
|
||||
{ publicationEpoch: string; rendererVersion: number }
|
||||
>()
|
||||
private clientSessionTabSelections = new ClientSessionTabSelectionStore()
|
||||
// Why: idempotency map for mobile terminal creation — a retried create with the
|
||||
// same clientMutationId returns the in-flight operation instead of duplicating.
|
||||
|
|
@ -3455,8 +3465,10 @@ export class OrcaRuntimeService {
|
|||
throw new Error('Runtime graph publisher does not match the authoritative window')
|
||||
}
|
||||
|
||||
const previousTabs = this.tabs
|
||||
const previousLeaves = this.leaves
|
||||
this.tabs = new Map(graph.tabs.map((tab) => [tab.tabId, tab]))
|
||||
this.syncMobileSessionTabs(graph.mobileSessionTabs)
|
||||
const changedMobileWorktrees = this.syncMobileSessionTabs(graph.mobileSessionTabs)
|
||||
const nextLeaves = new Map<string, RuntimeLeafRecord>()
|
||||
const graphSyncedAt = this.nextTitleObservationSequence()
|
||||
|
||||
|
|
@ -3577,7 +3589,40 @@ export class OrcaRuntimeService {
|
|||
|
||||
this.leaves = nextLeaves
|
||||
this.rebuildLeafPtyIndex()
|
||||
this.notifyMobileSessionTabSnapshots()
|
||||
// Why: the emitted client payload is a function of the stored snapshot AND
|
||||
// the tab/leaf graph (handles/titles/connected resolve from leaf state), so
|
||||
// a graph-only change — e.g. a restored leaf binding its ptyId while the
|
||||
// snapshot pair is unchanged — must also fan out, or a paired client stays
|
||||
// on pending-handle forever. Schedule the union on the same 50ms trailing
|
||||
// edge as the OSC-title path; the coalescer emit reads the latest state at
|
||||
// fire time so no final version is ever lost.
|
||||
for (const worktreeId of this.collectMobileVisibleGraphChangedWorktrees(
|
||||
previousTabs,
|
||||
previousLeaves
|
||||
)) {
|
||||
if (changedMobileWorktrees.has(worktreeId)) {
|
||||
continue
|
||||
}
|
||||
const stored = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
if (!stored) {
|
||||
continue
|
||||
}
|
||||
// Why: web clients drop same-epoch frames whose version isn't strictly
|
||||
// newer, so a graph-only change must mint a fresh stored version (like
|
||||
// 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, {
|
||||
...stored,
|
||||
snapshotVersion: stored.snapshotVersion + 1
|
||||
})
|
||||
changedMobileWorktrees.add(worktreeId)
|
||||
}
|
||||
for (const worktreeId of changedMobileWorktrees) {
|
||||
if (this.mobileSessionTabsByWorktree.has(worktreeId)) {
|
||||
this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId)
|
||||
}
|
||||
}
|
||||
this.graphStatus = 'ready'
|
||||
this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID)
|
||||
this.refreshWritableFlags()
|
||||
|
|
@ -3598,6 +3643,46 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
}
|
||||
|
||||
// Why: toMobileSessionTabsResult resolves handles/titles from this.tabs and
|
||||
// this.leaves, so any tab/leaf delta a graph sync installs can flip the
|
||||
// client payload (pending-handle → ready, tab title) with zero change to the
|
||||
// stored snapshot. Compare exactly the projection-relevant fields and report
|
||||
// the affected worktrees; false positives only cost a coalesced no-op emit.
|
||||
private collectMobileVisibleGraphChangedWorktrees(
|
||||
previousTabs: Map<string, RuntimeSyncedTab>,
|
||||
previousLeaves: Map<string, RuntimeLeafRecord>
|
||||
): Set<string> {
|
||||
const changed = new Set<string>()
|
||||
for (const [tabId, tab] of this.tabs) {
|
||||
const prev = previousTabs.get(tabId)
|
||||
if (!prev || prev.title !== tab.title) {
|
||||
changed.add(tab.worktreeId)
|
||||
}
|
||||
}
|
||||
for (const [tabId, tab] of previousTabs) {
|
||||
if (!this.tabs.has(tabId)) {
|
||||
changed.add(tab.worktreeId)
|
||||
}
|
||||
}
|
||||
for (const [leafKey, leaf] of this.leaves) {
|
||||
const prev = previousLeaves.get(leafKey)
|
||||
if (
|
||||
!prev ||
|
||||
prev.ptyId !== leaf.ptyId ||
|
||||
prev.connected !== leaf.connected ||
|
||||
prev.paneTitle !== leaf.paneTitle
|
||||
) {
|
||||
changed.add(leaf.worktreeId)
|
||||
}
|
||||
}
|
||||
for (const [leafKey, leaf] of previousLeaves) {
|
||||
if (!this.leaves.has(leafKey)) {
|
||||
changed.add(leaf.worktreeId)
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
async listMobileSessionTabs(
|
||||
worktreeSelector: string,
|
||||
clientNavigationId?: string
|
||||
|
|
@ -3645,6 +3730,18 @@ export class OrcaRuntimeService {
|
|||
if (!session) {
|
||||
return reconciledWorktreeIds
|
||||
}
|
||||
// Why: with no serve-owned ptyId anywhere in the session and no offscreen
|
||||
// browser backend, the serve-only hydrate provably builds zero tabs for
|
||||
// every worktree — skip the per-worktree rebuild entirely (hot on every
|
||||
// graph sync). Scoped to onlyServeOwnedTerminals so full hydrates are
|
||||
// untouched.
|
||||
if (
|
||||
options.onlyServeOwnedTerminals === true &&
|
||||
!this.offscreenBrowserBackend &&
|
||||
!this.workspaceSessionHasServeOwnedPty(session)
|
||||
) {
|
||||
return reconciledWorktreeIds
|
||||
}
|
||||
const entries =
|
||||
worktreeId !== undefined
|
||||
? ([[worktreeId, session.tabsByWorktree[worktreeId] ?? []]] as const)
|
||||
|
|
@ -3714,57 +3811,146 @@ export class OrcaRuntimeService {
|
|||
? mergedActiveTab.parentTabId
|
||||
: mergedActiveTab.id
|
||||
: null
|
||||
this.mobileSessionTabsByWorktree.set(entryWorktreeId, {
|
||||
const nextTabGroups: RuntimeMobileSessionTabGroup[] = hasPersistedSplit
|
||||
? this.appendBrowserTabOrder(
|
||||
this.distributeHeadlessTabsAcrossGroups(
|
||||
persistedGroups.map((group) => ({
|
||||
id: group.id,
|
||||
activeTabId: group.activeTabId,
|
||||
tabOrder: [...group.tabOrder],
|
||||
...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {})
|
||||
})),
|
||||
this.collectHeadlessParentTabOrder(mergedTerminalTabs),
|
||||
activeTopLevelId
|
||||
),
|
||||
mergedBrowserOrder,
|
||||
undefined,
|
||||
// Why: distribute drops browser ids (terminal-only), so carry each
|
||||
// browser's persisted group forward instead of coalescing left.
|
||||
this.collectBrowserGroupAssignment(persistedGroups, mergedBrowserOrder)
|
||||
)
|
||||
: options.onlyServeOwnedTerminals === true && existing?.tabGroups
|
||||
? this.appendBrowserTabOrder(
|
||||
this.mergeMobileSessionTabGroups(
|
||||
entryWorktreeId,
|
||||
existing.tabGroups,
|
||||
mergedTerminalTabs,
|
||||
mergedActiveTab?.type === 'terminal' ? mergedActiveTab : null
|
||||
),
|
||||
mergedBrowserOrder
|
||||
)
|
||||
: [
|
||||
{
|
||||
id: groupId,
|
||||
activeTabId: mergedActiveTab?.id
|
||||
? (activeTab?.parentTabId ?? mergedActiveTab.id)
|
||||
: (tabOrder[0] ?? null),
|
||||
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.
|
||||
const mergedIntoRendererPublication =
|
||||
options.onlyServeOwnedTerminals === true &&
|
||||
existing !== undefined &&
|
||||
!this.isHeadlessBuiltMobileSessionPublicationBase(existing.publicationEpoch)
|
||||
const nextSnapshot: RuntimeMobileSessionTabsSnapshot = {
|
||||
worktree: existing?.worktree ?? entryWorktreeId,
|
||||
publicationEpoch: `headless-hydrated:${Date.now().toString(36)}`,
|
||||
publicationEpoch: mergedIntoRendererPublication
|
||||
? this.getMergedMobileSessionPublicationEpoch(existing, tabs)
|
||||
: `headless-hydrated:${Date.now().toString(36)}`,
|
||||
snapshotVersion: (existing?.snapshotVersion ?? 0) + 1,
|
||||
activeGroupId: existing?.activeGroupId ?? groupId,
|
||||
activeTabId: mergedActiveTab?.id ?? null,
|
||||
activeTabType: mergedActiveTab?.type ?? null,
|
||||
tabGroups: hasPersistedSplit
|
||||
? this.appendBrowserTabOrder(
|
||||
this.distributeHeadlessTabsAcrossGroups(
|
||||
persistedGroups.map((group) => ({
|
||||
id: group.id,
|
||||
activeTabId: group.activeTabId,
|
||||
tabOrder: [...group.tabOrder],
|
||||
...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {})
|
||||
})),
|
||||
this.collectHeadlessParentTabOrder(mergedTerminalTabs),
|
||||
activeTopLevelId
|
||||
),
|
||||
mergedBrowserOrder,
|
||||
undefined,
|
||||
// Why: distribute drops browser ids (terminal-only), so carry each
|
||||
// browser's persisted group forward instead of coalescing left.
|
||||
this.collectBrowserGroupAssignment(persistedGroups, mergedBrowserOrder)
|
||||
)
|
||||
: options.onlyServeOwnedTerminals === true && existing?.tabGroups
|
||||
? this.appendBrowserTabOrder(
|
||||
this.mergeMobileSessionTabGroups(
|
||||
entryWorktreeId,
|
||||
existing.tabGroups,
|
||||
mergedTerminalTabs,
|
||||
mergedActiveTab?.type === 'terminal' ? mergedActiveTab : null
|
||||
),
|
||||
mergedBrowserOrder
|
||||
)
|
||||
: [
|
||||
{
|
||||
id: groupId,
|
||||
activeTabId: mergedActiveTab?.id
|
||||
? (activeTab?.parentTabId ?? mergedActiveTab.id)
|
||||
: (tabOrder[0] ?? null),
|
||||
tabOrder
|
||||
}
|
||||
],
|
||||
...(hasPersistedSplit && persistedLayout ? { tabGroupLayout: persistedLayout } : {}),
|
||||
tabGroups: nextTabGroups,
|
||||
// Why: the serve-only rebuild runs on every graph sync — carry the
|
||||
// existing split layout forward or each sync drops it and fans out.
|
||||
...(hasPersistedSplit && persistedLayout
|
||||
? { tabGroupLayout: persistedLayout }
|
||||
: options.onlyServeOwnedTerminals === true && existing?.tabGroupLayout
|
||||
? { tabGroupLayout: existing.tabGroupLayout }
|
||||
: {}),
|
||||
tabs: mergedTabs
|
||||
})
|
||||
}
|
||||
// Why: the serve-only hydrate runs on EVERY graph sync; when the rebuilt
|
||||
// projection matches the existing snapshot, keep the existing object and
|
||||
// (epoch, version) untouched so identity-based change detection stays a
|
||||
// pure no-op and unchanged serve/browser worktrees never fan out.
|
||||
if (existing && this.headlessMobileSnapshotContentUnchanged(existing, nextSnapshot)) {
|
||||
continue
|
||||
}
|
||||
this.mobileSessionTabsByWorktree.set(entryWorktreeId, nextSnapshot)
|
||||
}
|
||||
return reconciledWorktreeIds
|
||||
}
|
||||
|
||||
// Why: content equality for the hydrate's idempotence check — compares every
|
||||
// client-visible field EXCEPT publicationEpoch/snapshotVersion (both are
|
||||
// freshly minted on each rebuild and would defeat the comparison). Tab and
|
||||
// group objects are rebuilt each hydrate, so compare by value, not identity.
|
||||
private headlessMobileSnapshotContentUnchanged(
|
||||
existing: RuntimeMobileSessionTabsSnapshot,
|
||||
next: RuntimeMobileSessionTabsSnapshot
|
||||
): boolean {
|
||||
if (
|
||||
existing.worktree !== next.worktree ||
|
||||
existing.activeGroupId !== next.activeGroupId ||
|
||||
existing.activeTabId !== next.activeTabId ||
|
||||
existing.activeTabType !== next.activeTabType
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// Why: this runs per persisted worktree on EVERY graph sync whenever a
|
||||
// serve PTY exists, so compare structurally instead of stable-stringifying
|
||||
// both sides (which allocated six full serialized trees per worktree).
|
||||
return (
|
||||
this.mobileSnapshotValueEqual(existing.tabs, next.tabs) &&
|
||||
this.mobileSnapshotValueEqual(existing.tabGroups ?? null, next.tabGroups ?? null) &&
|
||||
this.mobileSnapshotValueEqual(existing.tabGroupLayout ?? null, next.tabGroupLayout ?? null)
|
||||
)
|
||||
}
|
||||
|
||||
// Deep structural equality over plain snapshot JSON (objects/arrays/scalars).
|
||||
// Key order is irrelevant; a mismatch only costs a coalesced no-op emit.
|
||||
private mobileSnapshotValueEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
for (let index = 0; index < a.length; index++) {
|
||||
if (!this.mobileSnapshotValueEqual(a[index], b[index])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a !== null && b !== null && typeof a === 'object' && typeof b === 'object') {
|
||||
const aRecord = a as Record<string, unknown>
|
||||
const bRecord = b as Record<string, unknown>
|
||||
const aKeys = Object.keys(aRecord)
|
||||
if (aKeys.length !== Object.keys(bRecord).length) {
|
||||
return false
|
||||
}
|
||||
for (const key of aKeys) {
|
||||
if (
|
||||
!Object.hasOwn(bRecord, key) ||
|
||||
!this.mobileSnapshotValueEqual(aRecord[key], bRecord[key])
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Why: keep an existing snapshot's browser tabs in sync with the live bridge
|
||||
// without rebuilding stable terminal state. Replaces browser entries with the
|
||||
// current live set and rewrites the browser portion of the primary group order.
|
||||
|
|
@ -3882,6 +4068,27 @@ export class OrcaRuntimeService {
|
|||
return typeof ptyId === 'string' && ptyId.startsWith('serve-')
|
||||
}
|
||||
|
||||
// Why: strict superset of hasServeOwnedPtyBinding's inputs (tab.ptyId +
|
||||
// layout leaf ptyIds are what the built tabs' bindings are derived from), so
|
||||
// the serve-only hydrate fast-path can never hide a serve terminal.
|
||||
private workspaceSessionHasServeOwnedPty(session: WorkspaceSessionState): boolean {
|
||||
for (const tabs of Object.values(session.tabsByWorktree ?? {})) {
|
||||
for (const tab of tabs) {
|
||||
if (this.isServeOwnedPtyId(tab.ptyId)) {
|
||||
return true
|
||||
}
|
||||
const leafPtyIds = session.terminalLayoutsByTabId?.[tab.id]?.ptyIdsByLeafId
|
||||
if (
|
||||
leafPtyIds &&
|
||||
Object.values(leafPtyIds).some((ptyId) => this.isServeOwnedPtyId(ptyId))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private hasServeOwnedPtyBinding(tab: RuntimeMobileSessionTerminalTab): boolean {
|
||||
if (this.isServeOwnedPtyId(tab.ptyId)) {
|
||||
return true
|
||||
|
|
@ -3913,6 +4120,47 @@ export class OrcaRuntimeService {
|
|||
)
|
||||
}
|
||||
|
||||
// Why: a snapshot tab can keep a serve/SSH-owned ptyId after the runtime
|
||||
// terminal died and was de-persisted, so id shape alone must not preserve it
|
||||
// against a renderer publication. Require the binding to be backed by a live
|
||||
// PTY or by the persisted workspace session (a dormant persisted serve/SSH
|
||||
// binding is still re-hydratable, so it stays preserved).
|
||||
private hasLiveOrPersistedServeOrSshOwnedPtyBinding(
|
||||
worktreeId: string,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
): boolean {
|
||||
const boundPtyIds = [
|
||||
tab.ptyId,
|
||||
...Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {})
|
||||
].filter((ptyId): ptyId is string => this.isServeOrSshOwnedPtyId(ptyId))
|
||||
if (boundPtyIds.length === 0) {
|
||||
return false
|
||||
}
|
||||
// Why: exited PTY records are archived in ptysById, so require a connected
|
||||
// record — a dead serve shell whose persisted binding is also gone must
|
||||
// stop being preserved.
|
||||
if (boundPtyIds.some((ptyId) => this.ptysById.get(ptyId)?.connected === true)) {
|
||||
return true
|
||||
}
|
||||
const session = this.store?.getWorkspaceSession?.()
|
||||
if (!session) {
|
||||
return false
|
||||
}
|
||||
const persistedTab = (session.tabsByWorktree?.[worktreeId] ?? []).find(
|
||||
(candidate) => candidate.id === tab.parentTabId
|
||||
)
|
||||
if (!persistedTab) {
|
||||
return false
|
||||
}
|
||||
const persistedPtyIds = new Set(
|
||||
[
|
||||
persistedTab.ptyId,
|
||||
...Object.values(session.terminalLayoutsByTabId?.[persistedTab.id]?.ptyIdsByLeafId ?? {})
|
||||
].filter((ptyId): ptyId is string => typeof ptyId === 'string')
|
||||
)
|
||||
return boundPtyIds.some((ptyId) => persistedPtyIds.has(ptyId))
|
||||
}
|
||||
|
||||
// Why: a tab needs authoritative runtime teardown (kill + de-persist + prune)
|
||||
// only when the renderer can't durably tear it down: either it's serve/SSH
|
||||
// (preserved + re-hydrated, would resurrect) or the renderer graph never
|
||||
|
|
@ -22407,10 +22655,20 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
}
|
||||
|
||||
private syncMobileSessionTabs(snapshots: RuntimeMobileSessionTabsSnapshot[] | undefined): void {
|
||||
// Returns the worktrees whose stored snapshot object changed during this
|
||||
// sync, so the caller can fan out only actually-changed worktrees.
|
||||
private syncMobileSessionTabs(
|
||||
snapshots: RuntimeMobileSessionTabsSnapshot[] | undefined
|
||||
): Set<string> {
|
||||
const changedWorktreeIds = new Set<string>()
|
||||
if (snapshots === undefined) {
|
||||
return
|
||||
return changedWorktreeIds
|
||||
}
|
||||
// Why: snapshots are immutable — every writer replaces the map entry with a
|
||||
// new object, and the accept gate below drops semantically-unchanged
|
||||
// renderer resends before they replace an entry — so reference identity
|
||||
// before/after detects exactly the entries that actually changed.
|
||||
const before = new Map(this.mobileSessionTabsByWorktree)
|
||||
// Why: renderer graphs own renderer tabs, but headless serve terminals never enter that graph unless we preserve their bindings.
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(undefined, {
|
||||
allowAttachedWindow: true,
|
||||
|
|
@ -22420,29 +22678,85 @@ export class OrcaRuntimeService {
|
|||
for (const snapshot of snapshots) {
|
||||
nextWorktrees.add(snapshot.worktree)
|
||||
const existing = this.mobileSessionTabsByWorktree.get(snapshot.worktree)
|
||||
const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(snapshot, existing)
|
||||
// Why: judge renderer publication ordering against the renderer's own
|
||||
// last-accepted (epoch, version) — the renderer reuses one pair for
|
||||
// byte-identical content, so a same-epoch version <= the accepted one is
|
||||
// a no-op resend (or a stale frame) and must be skipped. Never compare
|
||||
// against the stored snapshot's version: main-local touches bump it
|
||||
// independently and would reject genuinely newer renderer revisions.
|
||||
const accepted = this.acceptedRendererMobileSnapshotByWorktree.get(snapshot.worktree)
|
||||
if (
|
||||
!existing ||
|
||||
nextSnapshot.publicationEpoch !== existing.publicationEpoch ||
|
||||
nextSnapshot.snapshotVersion >= existing.snapshotVersion
|
||||
accepted &&
|
||||
accepted.publicationEpoch === snapshot.publicationEpoch &&
|
||||
snapshot.snapshotVersion <= accepted.rendererVersion &&
|
||||
// Why: preservation is main-only state — a serve/SSH binding (or live
|
||||
// browser page) can disappear without the renderer bumping its version,
|
||||
// so a resend of the EXACT accepted revision (content-identical to the
|
||||
// accepted publication, safe to re-merge) must still fall through to
|
||||
// the merge, which prunes stale preserved tabs. Strictly-older frames
|
||||
// stay skipped: their content is outdated, and the next accepted-pair
|
||||
// resend performs the prune.
|
||||
!(
|
||||
existing &&
|
||||
snapshot.snapshotVersion === accepted.rendererVersion &&
|
||||
this.storedMobileSnapshotHasStalePreservedTab(existing, snapshot)
|
||||
)
|
||||
) {
|
||||
this.mobileSessionTabsByWorktree.set(snapshot.worktree, nextSnapshot)
|
||||
continue
|
||||
}
|
||||
const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(snapshot, existing)
|
||||
// Why: clients drop same-epoch frames whose version isn't strictly newer,
|
||||
// and main-local touches may already have emitted a higher version than
|
||||
// the renderer's counter — keep the stored version strictly monotonic so
|
||||
// the accepted content is never discarded as stale downstream.
|
||||
const storedVersion = existing
|
||||
? Math.max(nextSnapshot.snapshotVersion, existing.snapshotVersion + 1)
|
||||
: nextSnapshot.snapshotVersion
|
||||
this.mobileSessionTabsByWorktree.set(
|
||||
snapshot.worktree,
|
||||
storedVersion === nextSnapshot.snapshotVersion
|
||||
? nextSnapshot
|
||||
: { ...nextSnapshot, snapshotVersion: storedVersion }
|
||||
)
|
||||
this.acceptedRendererMobileSnapshotByWorktree.set(snapshot.worktree, {
|
||||
publicationEpoch: snapshot.publicationEpoch,
|
||||
rendererVersion: snapshot.snapshotVersion
|
||||
})
|
||||
}
|
||||
for (const [worktreeId, existing] of [...this.mobileSessionTabsByWorktree.entries()]) {
|
||||
if (!nextWorktrees.has(worktreeId)) {
|
||||
const preserved = this.buildPreservedHeadlessMobileSessionSnapshot(existing)
|
||||
if (preserved) {
|
||||
this.mobileSessionTabsByWorktree.set(worktreeId, preserved)
|
||||
// Why: preservation filters existing.tabs in place (same objects) and
|
||||
// the merge epoch hashes the preserved identities idempotently, so an
|
||||
// equal epoch with every tab object retained means the recomputation
|
||||
// was a no-op — keep the entry so no-op syncs don't fan out.
|
||||
const preservedIsNoOp =
|
||||
preserved.publicationEpoch === existing.publicationEpoch &&
|
||||
preserved.tabs.length === existing.tabs.length &&
|
||||
preserved.tabs.every((tab, index) => tab === existing.tabs[index])
|
||||
if (!preservedIsNoOp) {
|
||||
this.mobileSessionTabsByWorktree.set(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.
|
||||
this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId)
|
||||
nextWorktrees.add(worktreeId)
|
||||
} else {
|
||||
this.mobileSessionTabsByWorktree.delete(worktreeId)
|
||||
this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId)
|
||||
// Why: drop any pending coalesced notify so a stale snapshot can't land after the removed frame.
|
||||
this.mobileSessionTabsNotifyCoalescer.cancel(worktreeId)
|
||||
this.notifyMobileSessionTabsRemoved(worktreeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) {
|
||||
if (before.get(worktreeId) !== snapshot) {
|
||||
changedWorktreeIds.add(worktreeId)
|
||||
}
|
||||
}
|
||||
return changedWorktreeIds
|
||||
}
|
||||
|
||||
private mergePreservedHeadlessMobileSessionTabs(
|
||||
|
|
@ -22511,6 +22825,8 @@ export class OrcaRuntimeService {
|
|||
return {
|
||||
...existing,
|
||||
publicationEpoch: this.getMergedMobileSessionPublicationEpoch(existing, tabs),
|
||||
// Why: mint a fresh version or clients' same-epoch gate drops the prune frame.
|
||||
snapshotVersion: existing.snapshotVersion + 1,
|
||||
activeGroupId:
|
||||
existing.activeGroupId ?? this.getHeadlessMobileSessionGroupId(existing.worktree),
|
||||
activeTabId: activeTab?.id ?? null,
|
||||
|
|
@ -22525,6 +22841,26 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
}
|
||||
|
||||
// Why: the accepted-revision no-op gate must not fossilize preserved runtime
|
||||
// tabs. A stored merged snapshot's tabs that are absent from the incoming
|
||||
// renderer publication exist only via preservation; if any such tab no longer
|
||||
// passes the preservation predicate (binding removed from the live PTY table
|
||||
// and persisted session, or browser page closed), the stored snapshot is
|
||||
// stale even though the renderer revision is unchanged.
|
||||
private storedMobileSnapshotHasStalePreservedTab(
|
||||
existing: RuntimeMobileSessionTabsSnapshot,
|
||||
incoming: RuntimeMobileSessionTabsSnapshot
|
||||
): boolean {
|
||||
const incomingIds = new Set(
|
||||
incoming.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab))
|
||||
)
|
||||
return existing.tabs.some(
|
||||
(tab) =>
|
||||
!this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => incomingIds.has(id)) &&
|
||||
!this.shouldPreserveHeadlessMobileSessionTab(existing, tab)
|
||||
)
|
||||
}
|
||||
|
||||
private collectPreservedHeadlessMobileSessionTabs(
|
||||
existing: RuntimeMobileSessionTabsSnapshot,
|
||||
incoming?: RuntimeMobileSessionTabsSnapshot
|
||||
|
|
@ -22546,17 +22882,31 @@ export class OrcaRuntimeService {
|
|||
): boolean {
|
||||
// Why: headless offscreen browser tabs exist only server-side, so a renderer-graph merge must keep them, not prune as "not in the graph".
|
||||
if (tab.type === 'browser') {
|
||||
if (!this.offscreenBrowserBackend) {
|
||||
return false
|
||||
}
|
||||
// Why: in a renderer-based merged snapshot the browser entries can also
|
||||
// be renderer-owned, so only pages the offscreen bridge still lists are
|
||||
// runtime-owned and preservable; a pure renderer epoch preserves none.
|
||||
return (
|
||||
Boolean(this.offscreenBrowserBackend) &&
|
||||
this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch)
|
||||
this.isHeadlessBuiltMobileSessionPublicationBase(snapshot.publicationEpoch) ||
|
||||
(snapshot.publicationEpoch.includes(':headless-merge:') &&
|
||||
typeof tab.browserPageId === 'string' &&
|
||||
this.getLiveBrowserTabsByPageId(snapshot.worktree).has(tab.browserPageId))
|
||||
)
|
||||
}
|
||||
if (tab.type !== 'terminal') {
|
||||
return false
|
||||
}
|
||||
// Why: a merged renderer snapshot carries BOTH renderer-owned and
|
||||
// runtime-owned tabs, so the epoch alone must not preserve every terminal —
|
||||
// that resurrects renderer tabs the renderer already closed. Broad
|
||||
// preservation applies only to genuinely headless-built snapshots; in a
|
||||
// renderer-based one, only tabs with a live-or-persisted serve/SSH binding
|
||||
// are runtime-owned and preservable.
|
||||
return (
|
||||
this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) ||
|
||||
this.hasServeOwnedPtyBinding(tab)
|
||||
this.isHeadlessBuiltMobileSessionPublicationBase(snapshot.publicationEpoch) ||
|
||||
this.hasLiveOrPersistedServeOrSshOwnedPtyBinding(snapshot.worktree, tab)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -22568,6 +22918,15 @@ export class OrcaRuntimeService {
|
|||
)
|
||||
}
|
||||
|
||||
// Why: `:headless-merge:` only marks that runtime tabs were merged in — the
|
||||
// BASE epoch still says who published the snapshot. A renderer-based merged
|
||||
// snapshot must not be classified as headless-built, or its renderer tabs
|
||||
// read as runtime-owned.
|
||||
private isHeadlessBuiltMobileSessionPublicationBase(publicationEpoch: string): boolean {
|
||||
const base = publicationEpoch.split(':headless-merge:')[0]
|
||||
return base.startsWith('headless:') || base.startsWith('headless-hydrated:')
|
||||
}
|
||||
|
||||
private getMergedMobileSessionPublicationEpoch(
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
preservedTabs: readonly RuntimeMobileSessionSnapshotTab[]
|
||||
|
|
|
|||
|
|
@ -189,4 +189,69 @@ describe('mobile session snapshot reuse', () => {
|
|||
|
||||
expect(draftSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps (publicationEpoch, snapshotVersion) stable for byte-identical worktree content', () => {
|
||||
const state = makeState({
|
||||
tabsByWorktree: {
|
||||
'wt-stable': [{ id: 'term-1', title: 'Codex working', customTitle: null }]
|
||||
} as unknown as AppState['tabsByWorktree'],
|
||||
terminalLayoutsByTabId: {
|
||||
'term-1': {
|
||||
root: { type: 'leaf', leafId: '11111111-1111-4111-8111-111111111111' },
|
||||
activeLeafId: '11111111-1111-4111-8111-111111111111',
|
||||
expandedLeafId: null
|
||||
}
|
||||
} as unknown as AppState['terminalLayoutsByTabId']
|
||||
})
|
||||
|
||||
const first = buildMobileSessionTabSnapshots(state)[0]
|
||||
const second = buildMobileSessionTabSnapshots(state)[0]
|
||||
|
||||
// Why: main gates per-worktree mobile fanout on this pair; a no-op rebuild
|
||||
// must not mint a fresh version or every graph sync fans out every worktree.
|
||||
expect(second?.publicationEpoch).toBe(first?.publicationEpoch)
|
||||
expect(second?.snapshotVersion).toBe(first?.snapshotVersion)
|
||||
})
|
||||
|
||||
it('bumps only the changed worktree version when a sibling stays byte-identical', () => {
|
||||
const makeTwoWorktreeState = (title: string): AppState =>
|
||||
makeState({
|
||||
tabsByWorktree: {
|
||||
'wt-changed': [{ id: 'term-a', title, customTitle: null }],
|
||||
'wt-stable-sibling': [{ id: 'term-b', title: 'Idle agent', customTitle: null }]
|
||||
} as unknown as AppState['tabsByWorktree'],
|
||||
terminalLayoutsByTabId: {
|
||||
'term-a': {
|
||||
root: { type: 'leaf', leafId: '22222222-2222-4222-8222-222222222222' },
|
||||
activeLeafId: '22222222-2222-4222-8222-222222222222',
|
||||
expandedLeafId: null
|
||||
},
|
||||
'term-b': {
|
||||
root: { type: 'leaf', leafId: '33333333-3333-4333-8333-333333333333' },
|
||||
activeLeafId: '33333333-3333-4333-8333-333333333333',
|
||||
expandedLeafId: null
|
||||
}
|
||||
} as unknown as AppState['terminalLayoutsByTabId']
|
||||
})
|
||||
|
||||
const before = new Map(
|
||||
buildMobileSessionTabSnapshots(makeTwoWorktreeState('Codex working')).map((snapshot) => [
|
||||
snapshot.worktree,
|
||||
snapshot
|
||||
])
|
||||
)
|
||||
const after = new Map(
|
||||
buildMobileSessionTabSnapshots(makeTwoWorktreeState('Codex done')).map((snapshot) => [
|
||||
snapshot.worktree,
|
||||
snapshot
|
||||
])
|
||||
)
|
||||
|
||||
expect(after.get('wt-changed')?.snapshotVersion).toBeGreaterThan(
|
||||
before.get('wt-changed')?.snapshotVersion ?? Number.POSITIVE_INFINITY
|
||||
)
|
||||
expect(after.get('wt-stable-sibling')?.snapshotVersion).toBe(
|
||||
before.get('wt-stable-sibling')?.snapshotVersion
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -87,6 +87,47 @@ let syncEnabled = false
|
|||
let syncTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let getStoreState: (() => AppState) | null = null
|
||||
let mobileSessionSnapshotVersion = 0
|
||||
// Why: main gates per-worktree mobile fanout on (publicationEpoch,
|
||||
// snapshotVersion), so that pair must be a semantic revision: reuse the cached
|
||||
// snapshot (same version) whenever a worktree's mobile-visible content is
|
||||
// unchanged, and bump the version only for worktrees that actually changed.
|
||||
const mobileSessionSnapshotCacheByWorktree = new Map<
|
||||
string,
|
||||
{ content: unknown; snapshot: RuntimeMobileSessionTabsSnapshot }
|
||||
>()
|
||||
|
||||
// Structural equality under JSON-serialization semantics (undefined-valued
|
||||
// keys are absent), so version reuse matches a JSON fingerprint exactly
|
||||
// without allocating a serialized copy of the payload on every graph sync.
|
||||
// Any value strict-equality can't prove equal (e.g. NaN) reads as changed,
|
||||
// which only costs a redundant fanout — never a suppressed one.
|
||||
function jsonContentEquals(a: unknown, b: unknown): boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
return a.every((item, index) => jsonContentEquals(item, b[index]))
|
||||
}
|
||||
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
|
||||
return false
|
||||
}
|
||||
const aRecord = a as Record<string, unknown>
|
||||
const bRecord = b as Record<string, unknown>
|
||||
for (const key of Object.keys(aRecord)) {
|
||||
if (!jsonContentEquals(aRecord[key], bRecord[key])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(bRecord)) {
|
||||
if (bRecord[key] !== undefined && aRecord[key] === undefined) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
let cachedTabsProjection: TabsProjectionCache | null = null
|
||||
let cachedOpenFileIndexesSource: AppState['openFiles'] | null = null
|
||||
let cachedOpenFileIndexes: OpenFileIndexes | null = null
|
||||
|
|
@ -821,17 +862,38 @@ export function buildMobileSessionTabSnapshots(
|
|||
new Set(tabGroups.map((group) => group.id))
|
||||
)
|
||||
: groupProjection.tabGroupLayout
|
||||
snapshots.push({
|
||||
worktree: worktreeId,
|
||||
publicationEpoch: mobileSessionPublicationEpoch,
|
||||
snapshotVersion: ++mobileSessionSnapshotVersion,
|
||||
const content = {
|
||||
activeGroupId,
|
||||
activeTabId: active?.id ?? null,
|
||||
activeTabType: active?.type ?? null,
|
||||
...(tabGroups && tabGroups.length > 0 ? { tabGroups } : {}),
|
||||
...(tabGroupLayout ? { tabGroupLayout } : {}),
|
||||
tabs
|
||||
})
|
||||
}
|
||||
// Why: main suppresses per-worktree fanout on an unchanged (epoch, version)
|
||||
// pair, so reuse the cached version for structurally-identical content. The
|
||||
// global counter still advances per worktree per build (as before caching)
|
||||
// so a changed worktree's fresh version stays ahead of main's +1 bumps.
|
||||
const candidateVersion = ++mobileSessionSnapshotVersion
|
||||
const cached = mobileSessionSnapshotCacheByWorktree.get(worktreeId)
|
||||
if (cached && jsonContentEquals(cached.content, content)) {
|
||||
snapshots.push(cached.snapshot)
|
||||
continue
|
||||
}
|
||||
const snapshot: RuntimeMobileSessionTabsSnapshot = {
|
||||
worktree: worktreeId,
|
||||
publicationEpoch: mobileSessionPublicationEpoch,
|
||||
snapshotVersion: candidateVersion,
|
||||
...content
|
||||
}
|
||||
mobileSessionSnapshotCacheByWorktree.set(worktreeId, { content, snapshot })
|
||||
snapshots.push(snapshot)
|
||||
}
|
||||
|
||||
for (const worktreeId of mobileSessionSnapshotCacheByWorktree.keys()) {
|
||||
if (!worktreeIds.has(worktreeId)) {
|
||||
mobileSessionSnapshotCacheByWorktree.delete(worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
return snapshots
|
||||
|
|
|
|||
Loading…
Reference in New Issue