perf(runtime): withhold unchanged mobile snapshots from the graph payload (#12245)

* perf(runtime): withhold unchanged mobile snapshots from the graph payload

Every graph sync structured-cloned all 222 worktree snapshots to main even when
none had changed: 374 KB and ~5 ms per clone, paid twice because Electron clones
on serialize and again on deserialize. That transport cost — not the renderer
rebuild — is the bulk of a publication.

The renderer now sends only the snapshots main has not acknowledged and names
the rest in unchangedMobileSessionWorktrees. Detection is object identity, not a
deep compare: an unchanged worktree already returns its cached snapshot object.
Main seeds nextWorktrees from that list so its prune keeps withheld worktrees
live instead of removing them.

The call itself is unconditional. syncWindowGraph is not a one-way publish — its
return value is the only channel carrying agentOrchestrationByPaneKey to the
renderer, and the handler adopts pre-allocated handles, merges detached leaves,
refreshes writable flags, and drains graph-sync callbacks on every sync. Skipping
it would starve all of that.

Two failure modes are closed explicitly. The memo advances only after main
acknowledges, so a publication that throws is resent in full rather than
silently withheld forever. And a worktree main dropped on its own — worktree
metadata removal — comes back in mobileSessionResyncWorktrees, which also clears
the accepted-revision record so the republish is not rejected as a no-op.

Unchanged republish at 222 worktrees / 787 tabs: 374 KB to 3.4 KB, 5.08 ms to
0.02 ms per clone. One changed worktree: 5.3 KB.

* fix(runtime): resync stale withheld mobile snapshots

* fix(runtime): align accepted mobile snapshot membership

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
(cherry picked from commit ce8b778d31)
This commit is contained in:
OrcaWin
2026-08-04 02:28:06 -04:00
committed by OrcaWin
parent f7567c0ff6
commit 4987178e7a
5 changed files with 597 additions and 17 deletions
@@ -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> = {}): 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<string, RuntimeMobileSessionTabsSnapshot>
}
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])
})
})
+65 -16
View File
@@ -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<string>
}
>()
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<string>()
const changedMobileWorktrees = this.syncMobileSessionTabs(
graph.mobileSessionTabs,
graph.unchangedMobileSessionWorktrees,
mobileSessionResyncWorktrees
)
const nextLeaves = new Map<string, RuntimeLeafRecord>()
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<string> = new Set()
): Set<string> {
const changedWorktreeIds = new Set<string>()
if (snapshots === undefined) {
@@ -28874,6 +28890,38 @@ export class OrcaRuntimeService {
})
}
const nextWorktrees = new Set<string>()
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<string>
): 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)
)
}
@@ -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<string, unknown[]> = {}
const terminalLayoutsByTabId: Record<string, unknown> = {}
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<void> {
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<typeof vi.fn>
): { 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'
])
})
})
+52 -1
View File
@@ -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<string, RuntimeMobileSessionTabsSnapshot>()
export function setRuntimeGraphStoreStateGetter(getter: (() => AppState) | null): void {
getStoreState = getter
@@ -698,10 +701,13 @@ async function syncRuntimeGraph(): Promise<void> {
.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<void> {
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<void> {
})
}
}
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<string>()
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<T>(
source: Record<string, T> | undefined,
keys: readonly string[]
+7
View File
@@ -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<string, AgentStatusOrchestrationContext>
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 = {