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:
Neil 2026-07-20 23:59:05 -07:00 committed by GitHub
parent 42bff5e598
commit 4a7bdf9177
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 1407 additions and 60 deletions

View File

@ -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' })
])
})
})

View File

@ -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[]

View File

@ -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
)
})
})

View File

@ -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