diff --git a/src/main/runtime/graph-sync-payload-partition.test.ts b/src/main/runtime/graph-sync-payload-partition.test.ts new file mode 100644 index 00000000000..6533a53f9fb --- /dev/null +++ b/src/main/runtime/graph-sync-payload-partition.test.ts @@ -0,0 +1,264 @@ +/** + * The renderer withholds unchanged mobile-session snapshots from the graph + * payload. Main must treat those worktrees as live rather than pruning them as + * removed, and must ask for a republish of any it no longer holds. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTabsSnapshot, + RuntimeSyncWindowGraphResult +} from '../../shared/runtime-types' +import type { WorkspaceSessionState } from '../../shared/types' +import { OrcaRuntimeService } from './orca-runtime' + +const WT_A = '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_A, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ...overrides + } +} + +function makeTerminalTab(id: string, ptyId: string) { + return { + id, + ptyId, + worktreeId: WT_A, + title: `Terminal ${id}`, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +type RuntimeInternals = { + mobileSessionTabsByWorktree: Map +} + +function createRuntime() { + let session = makeSession() + const runtime = new OrcaRuntimeService({ + ...storeBase, + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + } + }) + const events: RuntimeMobileSessionTabsResult[] = [] + runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + const setSession = (next: WorkspaceSessionState): void => { + session = next + } + const sync = ( + mobileSessionTabs: RuntimeMobileSessionTabsSnapshot[], + unchangedMobileSessionWorktrees?: string[] + ): RuntimeSyncWindowGraphResult => + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs, + ...(unchangedMobileSessionWorktrees ? { unchangedMobileSessionWorktrees } : {}) + }) + return { runtime, events, sync, setSession, internals: runtime as unknown as RuntimeInternals } +} + +function makeSnapshot(worktree: string, version: number): RuntimeMobileSessionTabsSnapshot { + return { + worktree, + publicationEpoch: 'renderer:test-epoch', + snapshotVersion: 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: `Terminal ${worktree}`, + isActive: true + } + ] + } +} + +describe('graph-sync mobile payload partition', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('keeps a withheld worktree alive instead of pruning it as removed', () => { + const { events, sync, internals } = createRuntime() + sync([makeSnapshot(WT_A, 1), makeSnapshot(WT_B, 2)]) + vi.advanceTimersByTime(300) + const storedB = internals.mobileSessionTabsByWorktree.get(WT_B) + expect(storedB).toBeDefined() + events.length = 0 + + // A publishes a change; B is withheld as unchanged rather than omitted. + sync([makeSnapshot(WT_A, 3)], [WT_B]) + vi.advanceTimersByTime(300) + + expect(internals.mobileSessionTabsByWorktree.get(WT_B)).toBe(storedB) + expect(events.filter((event) => 'removed' in event && event.removed === true)).toEqual([]) + expect(events.map((event) => event.worktree)).toEqual([WT_A]) + }) + + it('still prunes a worktree the renderer omits from both lists', () => { + const { events, sync, internals } = createRuntime() + sync([makeSnapshot(WT_A, 1), makeSnapshot(WT_B, 2)]) + vi.advanceTimersByTime(300) + events.length = 0 + + sync([makeSnapshot(WT_A, 3)], []) + vi.advanceTimersByTime(300) + + expect(internals.mobileSessionTabsByWorktree.has(WT_B)).toBe(false) + const removed = events.filter((event) => 'removed' in event && event.removed === true) + expect(removed.map((event) => event.worktree)).toEqual([WT_B]) + }) + + it('asks for a republish of a withheld worktree it dropped, and accepts the resend', () => { + const { sync, internals } = createRuntime() + sync([makeSnapshot(WT_A, 1), makeSnapshot(WT_B, 2)]) + vi.advanceTimersByTime(300) + + // Main drops B on its own (worktree metadata removal) while the renderer + // still believes B is delivered. + internals.mobileSessionTabsByWorktree.delete(WT_B) + + const result = sync([], [WT_A, WT_B]) + vi.advanceTimersByTime(300) + expect(result.mobileSessionResyncWorktrees).toEqual([WT_B]) + expect(internals.mobileSessionTabsByWorktree.has(WT_A)).toBe(true) + + // The renderer republishes B at the SAME (epoch, version) it last sent. The + // accept gate must no longer treat that as an already-accepted no-op. + const resend = sync([makeSnapshot(WT_B, 2)], [WT_A]) + vi.advanceTimersByTime(300) + expect(resend.mobileSessionResyncWorktrees).toBeUndefined() + expect(internals.mobileSessionTabsByWorktree.get(WT_B)?.tabs).toEqual([ + expect.objectContaining({ parentTabId: 'tab-1' }) + ]) + }) + + it('asks for a republish when headless hydration masks a dropped renderer snapshot', () => { + const { sync, setSession, internals } = createRuntime() + setSession( + makeSession({ + tabsByWorktree: { [WT_A]: [makeTerminalTab('serve-tab', 'serve-pty-1')] } + }) + ) + sync([makeSnapshot(WT_A, 1)]) + internals.mobileSessionTabsByWorktree.delete(WT_A) + + const result = sync([], [WT_A]) + expect(result.mobileSessionResyncWorktrees).toEqual([WT_A]) + expect(internals.mobileSessionTabsByWorktree.get(WT_A)?.publicationEpoch).toMatch( + /^headless-hydrated:/ + ) + + const resend = sync([makeSnapshot(WT_A, 1)], []) + expect(resend.mobileSessionResyncWorktrees).toBeUndefined() + expect( + internals.mobileSessionTabsByWorktree + .get(WT_A) + ?.tabs.map((tab) => (tab.type === 'terminal' ? tab.parentTabId : tab.id)) + ).toEqual(['tab-1', 'serve-tab']) + }) + + it('requests the accepted revision again to prune a stale preserved tab', () => { + const { sync, setSession, internals } = createRuntime() + setSession( + makeSession({ + tabsByWorktree: { [WT_A]: [makeTerminalTab('serve-tab', 'serve-pty-1')] } + }) + ) + sync([makeSnapshot(WT_A, 1)]) + expect( + internals.mobileSessionTabsByWorktree + .get(WT_A) + ?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'serve-tab') + ).toBe(true) + + setSession(makeSession()) + const result = sync([], [WT_A]) + expect(result.mobileSessionResyncWorktrees).toEqual([WT_A]) + + sync([makeSnapshot(WT_A, 1)], []) + expect( + internals.mobileSessionTabsByWorktree + .get(WT_A) + ?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'serve-tab') + ).toBe(false) + }) + + it('does not request a renderer tab rejected by the retirement fence', () => { + const { sync, setSession, internals } = createRuntime() + setSession( + makeSession({ + terminalTopologyRevisionByRepoId: { 'repo-1': 1 } + }) + ) + + sync([makeSnapshot(WT_A, 1)]) + expect(internals.mobileSessionTabsByWorktree.get(WT_A)?.tabs).toEqual([]) + + expect(sync([], [WT_A]).mobileSessionResyncWorktrees).toBeUndefined() + expect(sync([], [WT_A]).mobileSessionResyncWorktrees).toBeUndefined() + }) + + it('leaves the legacy full-payload contract untouched when no list is sent', () => { + const { events, sync, internals } = createRuntime() + sync([makeSnapshot(WT_A, 1), makeSnapshot(WT_B, 2)]) + vi.advanceTimersByTime(300) + events.length = 0 + + // No unchangedMobileSessionWorktrees field at all: omission still prunes. + sync([makeSnapshot(WT_A, 3)]) + vi.advanceTimersByTime(300) + + expect(internals.mobileSessionTabsByWorktree.has(WT_B)).toBe(false) + expect( + events.filter((event) => 'removed' in event && event.removed === true).map((e) => e.worktree) + ).toEqual([WT_B]) + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 15ed6e170af..1fdabae84f2 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2723,7 +2723,12 @@ export class OrcaRuntimeService { // resend (or stale) and is skipped without touching the stored entry. private acceptedRendererMobileSnapshotByWorktree = new Map< string, - { publicationEpoch: string; rendererVersion: number } + { + publicationEpoch: string + rendererVersion: number + rendererTabCount: number + rendererTabIdentityKeys: ReadonlySet + } >() private clientSessionTabSelections = new ClientSessionTabSelectionStore() // Why: idempotency map for mobile terminal creation — a retried create with the @@ -5375,7 +5380,12 @@ export class OrcaRuntimeService { const previousLeaves = this.leaves this.tabs = new Map(graph.tabs.map((tab) => [tab.tabId, tab])) const lifecycleLeaves = this.reconcileMobileSessionRetirementFences(graph.leaves) - const changedMobileWorktrees = this.syncMobileSessionTabs(graph.mobileSessionTabs) + const mobileSessionResyncWorktrees = new Set() + const changedMobileWorktrees = this.syncMobileSessionTabs( + graph.mobileSessionTabs, + graph.unchangedMobileSessionWorktrees, + mobileSessionResyncWorktrees + ) const nextLeaves = new Map() const graphSyncedAt = this.nextTitleObservationSequence() @@ -5551,7 +5561,10 @@ export class OrcaRuntimeService { return { ...this.getStatus(), ...(agentOrchestrationByPaneKey ? { agentOrchestrationByPaneKey } : {}), - ...(nativeChatLaunchDraftResolutions.length > 0 ? { nativeChatLaunchDraftResolutions } : {}) + ...(nativeChatLaunchDraftResolutions.length > 0 ? { nativeChatLaunchDraftResolutions } : {}), + ...(mobileSessionResyncWorktrees.size > 0 + ? { mobileSessionResyncWorktrees: [...mobileSessionResyncWorktrees] } + : {}) } } @@ -23197,6 +23210,7 @@ export class OrcaRuntimeService { store.removeWorktreeMeta(worktreeId) } this.mobileSessionTabsByWorktree.delete(worktreeId) + this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) advertisedUrlWatcher.forgetWorktree(worktreeId) deleteWorktreeHistoryDir(worktreeId) this.closeHeadlessBrowserPagesForWorktree(worktreeId) @@ -28839,7 +28853,9 @@ export class OrcaRuntimeService { // 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 + snapshots: RuntimeMobileSessionTabsSnapshot[] | undefined, + unchangedWorktreeIds?: string[], + resyncWorktreeIds: Set = new Set() ): Set { const changedWorktreeIds = new Set() if (snapshots === undefined) { @@ -28874,6 +28890,38 @@ export class OrcaRuntimeService { }) } const nextWorktrees = new Set() + const incomingWorktreeIds = new Set(snapshots.map((snapshot) => snapshot.worktree)) + // Why: the renderer withholds unchanged snapshots to keep the graph payload + // small, so these worktrees are still live and must not fall into the prune + // below. Ask for a republish when main no longer holds that accepted renderer + // publication or a formerly-preserved runtime tab has gone stale. + for (const worktreeId of unchangedWorktreeIds ?? []) { + const existing = this.mobileSessionTabsByWorktree.get(worktreeId) + const accepted = this.acceptedRendererMobileSnapshotByWorktree.get(worktreeId) + if (existing) { + nextWorktrees.add(worktreeId) + } + if ( + existing && + accepted && + (existing.publicationEpoch === accepted.publicationEpoch || + existing.publicationEpoch.startsWith(`${accepted.publicationEpoch}:headless-merge:`)) && + existing.tabs.length >= accepted.rendererTabCount && + (existing.tabs.length === accepted.rendererTabCount || + !this.storedMobileSnapshotHasStalePreservedTab( + existing, + accepted.rendererTabIdentityKeys + )) + ) { + continue + } + if (!incomingWorktreeIds.has(worktreeId)) { + resyncWorktreeIds.add(worktreeId) + } + // Why: the accept gate compares against the renderer's last accepted pair, + // which outlives the dropped snapshot and would reject the republish. + this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) + } for (const snapshot of snapshots) { nextWorktrees.add(snapshot.worktree) const existing = this.mobileSessionTabsByWorktree.get(snapshot.worktree) @@ -28898,7 +28946,7 @@ export class OrcaRuntimeService { !( existing && snapshot.snapshotVersion === accepted.rendererVersion && - this.storedMobileSnapshotHasStalePreservedTab(existing, snapshot) + this.storedMobileSnapshotHasStalePreservedTab(existing, accepted.rendererTabIdentityKeys) ) ) { continue @@ -28922,7 +28970,11 @@ export class OrcaRuntimeService { ) this.acceptedRendererMobileSnapshotByWorktree.set(snapshot.worktree, { publicationEpoch: snapshot.publicationEpoch, - rendererVersion: snapshot.snapshotVersion + rendererVersion: snapshot.snapshotVersion, + rendererTabCount: fencedSnapshot.tabs.length, + rendererTabIdentityKeys: new Set( + fencedSnapshot.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) + ) }) } for (const [worktreeId, existing] of [...this.mobileSessionTabsByWorktree.entries()]) { @@ -29044,22 +29096,19 @@ 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 + // tabs. A stored merged snapshot's tabs absent from the accepted 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. + // and persisted session, or browser page closed), the stored snapshot is stale. private storedMobileSnapshotHasStalePreservedTab( existing: RuntimeMobileSessionTabsSnapshot, - incoming: RuntimeMobileSessionTabsSnapshot + rendererTabIdentityKeys: ReadonlySet ): 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) + !this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => + rendererTabIdentityKeys.has(id) + ) && !this.shouldPreserveHeadlessMobileSessionTab(existing, tab) ) } diff --git a/src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts b/src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts new file mode 100644 index 00000000000..aac5de9b2b7 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts @@ -0,0 +1,209 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + scheduleRuntimeGraphSync, + setRuntimeGraphStoreStateGetter, + setRuntimeGraphSyncEnabled +} from './sync-runtime-graph' +import type { AppState } from '../store/types' +import type { RuntimeSyncWindowGraph } from '../../../shared/runtime-types' + +function leafIdFor(index: number): string { + return `${String(index).padStart(8, '0')}-1111-4111-8111-111111111111` +} + +/** Distinct ids per test: the publication memo is module state shared in this file. */ +function makeState(prefix: string, worktreeCount: number, titles: string[] = []): AppState { + const tabsByWorktree: Record = {} + const terminalLayoutsByTabId: Record = {} + for (let i = 0; i < worktreeCount; i++) { + const tabId = `${prefix}-term-${i}` + tabsByWorktree[`repo::/${prefix}-wt-${i}`] = [ + { id: tabId, title: titles[i] ?? `Agent ${i}`, customTitle: null, ptyId: null } + ] + terminalLayoutsByTabId[tabId] = { + root: { type: 'leaf', leafId: leafIdFor(i) }, + activeLeafId: leafIdFor(i), + expandedLeafId: null + } + } + return { + tabsByWorktree, + terminalLayoutsByTabId, + runtimePaneTitlesByTabId: {}, + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + layoutByWorktree: {}, + unifiedTabsByWorktree: {}, + tabBarOrderByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + openFiles: [], + editorDrafts: {}, + activeTabId: null, + agentStatusByPaneKey: {}, + browserTabsByWorktree: {} + } as unknown as AppState +} + +function graphOf(call: unknown[] | undefined): RuntimeSyncWindowGraph { + return call?.[0] as RuntimeSyncWindowGraph +} + +function publishedWorktrees(call: unknown[] | undefined): string[] { + return (graphOf(call).mobileSessionTabs ?? []).map((snapshot) => snapshot.worktree).sort() +} + +function withheldWorktrees(call: unknown[] | undefined): string[] { + return [...(graphOf(call).unchangedMobileSessionWorktrees ?? [])].sort() +} + +async function flushSync(): Promise { + await vi.advanceTimersByTimeAsync(20) + await Promise.resolve() + await Promise.resolve() +} + +afterEach(() => { + setRuntimeGraphSyncEnabled(false) + setRuntimeGraphStoreStateGetter(null) + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +function startSync( + state: AppState, + syncWindowGraph: ReturnType +): { setState: (next: AppState) => void } { + let current = state + vi.useFakeTimers() + vi.stubGlobal('window', { api: { runtime: { syncWindowGraph } } }) + vi.stubGlobal('HTMLElement', class HTMLElement {}) + setRuntimeGraphStoreStateGetter(() => current) + setRuntimeGraphSyncEnabled(true) + return { + setState: (next: AppState) => { + current = next + } + } +} + +describe('mobile session graph payload partition', () => { + it('withholds every unchanged worktree from the republished payload', async () => { + const syncWindowGraph = vi.fn().mockResolvedValue({}) + startSync(makeState('a', 3), syncWindowGraph) + + await flushSync() + expect(publishedWorktrees(syncWindowGraph.mock.calls[0])).toEqual([ + 'repo::/a-wt-0', + 'repo::/a-wt-1', + 'repo::/a-wt-2' + ]) + expect(withheldWorktrees(syncWindowGraph.mock.calls[0])).toEqual([]) + + scheduleRuntimeGraphSync() + await flushSync() + + expect(syncWindowGraph).toHaveBeenCalledTimes(2) + expect(publishedWorktrees(syncWindowGraph.mock.calls[1])).toEqual([]) + expect(withheldWorktrees(syncWindowGraph.mock.calls[1])).toEqual([ + 'repo::/a-wt-0', + 'repo::/a-wt-1', + 'repo::/a-wt-2' + ]) + }) + + it('publishes only the worktree whose snapshot changed', async () => { + const syncWindowGraph = vi.fn().mockResolvedValue({}) + const { setState } = startSync(makeState('b', 3), syncWindowGraph) + await flushSync() + + setState(makeState('b', 3, ['Agent 0', 'Agent 1 (done)', 'Agent 2'])) + scheduleRuntimeGraphSync() + await flushSync() + + expect(publishedWorktrees(syncWindowGraph.mock.calls[1])).toEqual(['repo::/b-wt-1']) + expect(withheldWorktrees(syncWindowGraph.mock.calls[1])).toEqual([ + 'repo::/b-wt-0', + 'repo::/b-wt-2' + ]) + }) + + it('republishes a changed worktree whose publication threw before it was acknowledged', async () => { + const syncWindowGraph = vi.fn().mockResolvedValue({}) + const { setState } = startSync(makeState('c', 3), syncWindowGraph) + await flushSync() + + // The publication carrying c-wt-1's new snapshot fails in flight. + setState(makeState('c', 3, ['Agent 0', 'Agent 1 (done)', 'Agent 2'])) + syncWindowGraph.mockRejectedValueOnce(new Error('ipc closed')) + scheduleRuntimeGraphSync() + await flushSync() + expect(publishedWorktrees(syncWindowGraph.mock.calls[1])).toEqual(['repo::/c-wt-1']) + + // Nothing changed since, but main never acknowledged it: resend, don't withhold. + scheduleRuntimeGraphSync() + await flushSync() + + expect(syncWindowGraph).toHaveBeenCalledTimes(3) + expect(publishedWorktrees(syncWindowGraph.mock.calls[2])).toEqual(['repo::/c-wt-1']) + expect(withheldWorktrees(syncWindowGraph.mock.calls[2])).toEqual([ + 'repo::/c-wt-0', + 'repo::/c-wt-2' + ]) + }) + + it('resends everything when the first publication throws', async () => { + const syncWindowGraph = vi.fn().mockRejectedValueOnce(new Error('ipc closed')) + syncWindowGraph.mockResolvedValue({}) + startSync(makeState('d', 3), syncWindowGraph) + await flushSync() + expect(publishedWorktrees(syncWindowGraph.mock.calls[0])).toHaveLength(3) + + scheduleRuntimeGraphSync() + await flushSync() + + expect(publishedWorktrees(syncWindowGraph.mock.calls[1])).toEqual([ + 'repo::/d-wt-0', + 'repo::/d-wt-1', + 'repo::/d-wt-2' + ]) + expect(withheldWorktrees(syncWindowGraph.mock.calls[1])).toEqual([]) + }) + + it('republishes worktrees main reports it dropped after acknowledging them', async () => { + const syncWindowGraph = vi.fn().mockResolvedValue({}) + startSync(makeState('e', 3), syncWindowGraph) + await flushSync() + + syncWindowGraph.mockResolvedValueOnce({ mobileSessionResyncWorktrees: ['repo::/e-wt-2'] }) + scheduleRuntimeGraphSync() + await flushSync() + expect(withheldWorktrees(syncWindowGraph.mock.calls[1])).toHaveLength(3) + + // The resync reply schedules its own republish; no store change is needed. + await flushSync() + + expect(syncWindowGraph).toHaveBeenCalledTimes(3) + expect(publishedWorktrees(syncWindowGraph.mock.calls[2])).toEqual(['repo::/e-wt-2']) + expect(withheldWorktrees(syncWindowGraph.mock.calls[2])).toEqual([ + 'repo::/e-wt-0', + 'repo::/e-wt-1' + ]) + }) + + it('drops a removed worktree from both the payload and the withheld list', async () => { + const syncWindowGraph = vi.fn().mockResolvedValue({}) + const { setState } = startSync(makeState('f', 3), syncWindowGraph) + await flushSync() + + setState(makeState('f', 2)) + scheduleRuntimeGraphSync() + await flushSync() + + expect(publishedWorktrees(syncWindowGraph.mock.calls[1])).toEqual([]) + expect(withheldWorktrees(syncWindowGraph.mock.calls[1])).toEqual([ + 'repo::/f-wt-0', + 'repo::/f-wt-1' + ]) + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 63f578c7dfb..0f8e28b7fe6 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -217,6 +217,9 @@ const EMPTY_WORKTREE_UNIFIED_TABS: AppState['unifiedTabsByWorktree'][string] = [ const EMPTY_WORKTREE_TAB_GROUPS: AppState['groupsByWorktree'][string] = [] const EMPTY_WORKTREE_OPEN_FILE_IDS: readonly string[] = [] const mobileSessionPublicationEpoch = `renderer:${createBrowserUuid()}` +// Why: the snapshot object main last acknowledged per worktree; anything still +// identical is withheld from the graph payload instead of re-cloned across IPC. +const publishedMobileSessionSnapshotByWorktree = new Map() export function setRuntimeGraphStoreStateGetter(getter: (() => AppState) | null): void { getStoreState = getter @@ -698,10 +701,13 @@ async function syncRuntimeGraph(): Promise { .map((tab) => [tab.id, tab]) ) const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true + const mobileSessionTabs = buildMobileSessionTabSnapshots(state, systemPrefersDark) + const publication = partitionMobileSessionPublication(mobileSessionTabs) const graph: RuntimeSyncWindowGraph = { tabs: [], leaves: [], - mobileSessionTabs: buildMobileSessionTabSnapshots(state, systemPrefersDark) + mobileSessionTabs: publication.changed, + unchangedMobileSessionWorktrees: publication.unchangedWorktrees } for (const [tabId, registeredTab] of registeredTabs) { @@ -811,6 +817,9 @@ async function syncRuntimeGraph(): Promise { try { const result = await window.api.runtime.syncWindowGraph(graph) + // Why: only an acknowledged publication may be treated as delivered. A throw + // leaves the memo behind, so the retry resends every worktree in full. + commitMobileSessionPublication(mobileSessionTabs, result?.mobileSessionResyncWorktrees) const currentState = getStoreState() currentState?.setRuntimeAgentOrchestrationByPaneKey?.(result?.agentOrchestrationByPaneKey ?? {}) for (const resolution of result?.nativeChatLaunchDraftResolutions ?? []) { @@ -821,11 +830,53 @@ async function syncRuntimeGraph(): Promise { }) } } + if (result?.mobileSessionResyncWorktrees?.length) { + scheduleRuntimeGraphSync() + } } catch (error) { console.error('[runtime] Failed to sync renderer graph:', error) } } +function partitionMobileSessionPublication(snapshots: RuntimeMobileSessionTabsSnapshot[]): { + changed: RuntimeMobileSessionTabsSnapshot[] + unchangedWorktrees: string[] +} { + const changed: RuntimeMobileSessionTabsSnapshot[] = [] + const unchangedWorktrees: string[] = [] + for (const snapshot of snapshots) { + // Why: buildMobileSessionTabSnapshots returns the cached object for a worktree + // it did not rebuild, so identity — not a deep compare — settles this. + if (publishedMobileSessionSnapshotByWorktree.get(snapshot.worktree) === snapshot) { + unchangedWorktrees.push(snapshot.worktree) + } else { + changed.push(snapshot) + } + } + return { changed, unchangedWorktrees } +} + +function commitMobileSessionPublication( + snapshots: RuntimeMobileSessionTabsSnapshot[], + resyncWorktrees: string[] | undefined +): void { + const published = new Set() + for (const snapshot of snapshots) { + published.add(snapshot.worktree) + publishedMobileSessionSnapshotByWorktree.set(snapshot.worktree, snapshot) + } + for (const worktreeId of publishedMobileSessionSnapshotByWorktree.keys()) { + if (!published.has(worktreeId)) { + publishedMobileSessionSnapshotByWorktree.delete(worktreeId) + } + } + // Why: main dropped these after acknowledging them, so forget the delivery and + // let the scheduled resync republish them in full. + for (const worktreeId of resyncWorktrees ?? []) { + publishedMobileSessionSnapshotByWorktree.delete(worktreeId) + } +} + function narrowRecordByKeys( source: Record | undefined, keys: readonly string[] diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index c8291e35fb1..06821f7ff7f 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -143,7 +143,11 @@ export type RuntimeSyncedLeaf = { export type RuntimeSyncWindowGraph = { tabs: RuntimeSyncedTab[] leaves: RuntimeSyncedLeaf[] + /** Only worktrees whose snapshot changed since the last acknowledged publication. */ mobileSessionTabs?: RuntimeMobileSessionTabsSnapshot[] + /** Worktrees the renderer is still publishing unchanged; main must keep their + * stored snapshots alive instead of pruning them as removed. */ + unchangedMobileSessionWorktrees?: string[] } export type RuntimeNativeChatLaunchDraftResolution = { @@ -157,6 +161,9 @@ export type RuntimeSyncWindowGraphResult = RuntimeStatus & { * parent metadata needed by title-derived agent rows without name guessing. */ agentOrchestrationByPaneKey?: Record nativeChatLaunchDraftResolutions?: RuntimeNativeChatLaunchDraftResolution[] + /** Worktrees the renderer withheld as unchanged that main holds no snapshot + * for — it dropped them independently, so the renderer must republish them. */ + mobileSessionResyncWorktrees?: string[] } export type RuntimeMobileSessionTerminalTab = {