diff --git a/src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts b/src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts new file mode 100644 index 000000000..3efcbcc80 --- /dev/null +++ b/src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts @@ -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() + 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 { + 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 +} + +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 + } + 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 + } + // 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' }) + ]) + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 316d89f1e..587fd80b7 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2270,6 +2270,16 @@ export class OrcaRuntimeService { private authoritativeWindowId: number | null = null private tabs = new Map() private mobileSessionTabsByWorktree = new Map() + // 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() 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, + previousLeaves: Map + ): Set { + const changed = new Set() + 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 + const bRecord = b as Record + 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 { + const changedWorktreeIds = new Set() 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[] diff --git a/src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts b/src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts index 388ea40ea..2f895784d 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts @@ -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 + ) + }) }) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 4a4e12fdd..340af3c3d 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -87,6 +87,47 @@ let syncEnabled = false let syncTimer: ReturnType | 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 + const bRecord = b as Record + 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