From 62158570e0117261a45d6f1195e58c7253e12784 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:31:55 -0700 Subject: [PATCH] perf(mobile-sync): memoize the agent-status projection per entry (#10787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(mobile-sync): memoize the agent-status projection per entry buildRuntimeMobileAgentStatusProjection re-serialized every live agent on every status ping. setAgentStatus replaces one entry and re-spreads the map, which defeats the reference-equality skip gate, so each ping paid for every other agent's prompt, 20-entry history, and 8 KB assistant message to discover they had not changed. Memoize each row's JSON by entry identity, the cachedTabsProjection pattern already used a few functions above. Per ping: 0.18 ms -> 0.014 ms at 8 agents, 0.88 ms -> 0.063 ms at 40. The output is byte-identical — joining pre-serialized rows matches whole-array stringify, which the new test pins against a verbatim copy of the old implementation. * test(perf): stop inflating the projection benchmark baseline The pre-fix arm stringified each row and parsed it back before stringifying the array, a per-row roundtrip the original never paid. That made the baseline artificially slow: the reported 5.9x-14.0x is really 2.1x-5.0x. Share one row builder between both arms, and check equivalence after a ping as well as on the cold call — a stale-row bug can only surface once the cache is actually exercised, which the cold-path check could never catch. --- ...bile-agent-status-projection-benchmark.mjs | 192 ++++++++++++++++++ ...time-graph-agent-status-projection.test.ts | 120 +++++++++++ .../src/runtime/sync-runtime-graph.ts | 103 +++++++--- 3 files changed, 389 insertions(+), 26 deletions(-) create mode 100644 config/scripts/mobile-agent-status-projection-benchmark.mjs create mode 100644 src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts diff --git a/config/scripts/mobile-agent-status-projection-benchmark.mjs b/config/scripts/mobile-agent-status-projection-benchmark.mjs new file mode 100644 index 000000000..8d5d58a2d --- /dev/null +++ b/config/scripts/mobile-agent-status-projection-benchmark.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +// Benchmark: cost of the mobile agent-status projection per store mutation. +// +// buildRuntimeMobileAgentStatusProjection runs on the App.tsx global store +// subscriber. setAgentStatus replaces one entry and re-spreads +// agentStatusByPaneKey, which defeats the reference-equality skip gate, so before +// the fix EVERY live agent was re-serialized on EVERY status ping — each carrying +// a prompt, a 20-entry stateHistory, toolInput, and an 8 KB-capped +// lastAssistantMessage. +// +// The fix memoizes each row's JSON by entry identity, mirroring the +// cachedTabsProjection pattern already in the same file, so a ping re-serializes +// only the agent that actually changed. +// +// The bucket width is re-read from the real module so a drifted constant fails +// loudly here instead of quietly changing what this benchmark measures. +import { readFileSync } from 'node:fs' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +const GRAPH_SOURCE = readFileSync( + fileURLToPath(new URL('../../src/renderer/src/runtime/sync-runtime-graph.ts', import.meta.url)), + 'utf8' +) + +const bucketMatch = GRAPH_SOURCE.match(/AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS = ([0-9_]+)/) +if (!bucketMatch) { + throw new Error( + 'sync-runtime-graph.ts no longer defines the updatedAt bucket; re-sync this benchmark.' + ) +} +const BUCKET_MS = Number(bucketMatch[1].replaceAll('_', '')) + +const ITERATIONS = Number.parseInt(process.env.ORCA_AGENT_PROJECTION_BENCH_ITERATIONS ?? '400', 10) +const WARMUP = Number.parseInt(process.env.ORCA_AGENT_PROJECTION_BENCH_WARMUP ?? '60', 10) + +for (const [name, value] of [ + ['ORCA_AGENT_PROJECTION_BENCH_ITERATIONS', ITERATIONS], + ['ORCA_AGENT_PROJECTION_BENCH_WARMUP', WARMUP] +]) { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} + +function toRow(paneKey, entry) { + return { + paneKey, + entryPaneKey: entry.paneKey, + state: entry.state, + prompt: entry.prompt, + updatedAtBucket: Math.floor(entry.updatedAt / BUCKET_MS), + stateStartedAt: entry.stateStartedAt, + agentType: entry.agentType ?? null, + terminalTitle: entry.terminalTitle ?? null, + stateHistory: entry.stateHistory.map((history) => ({ + state: history.state, + prompt: history.prompt, + startedAt: history.startedAt, + interrupted: history.interrupted ?? null + })), + toolName: entry.toolName ?? null, + toolInput: entry.toolInput ?? null, + interactivePrompt: entry.interactivePrompt ?? null, + lastAssistantMessage: entry.lastAssistantMessage ?? null, + interrupted: entry.interrupted ?? null + } +} + +function serializeEntry(paneKey, entry) { + return JSON.stringify(toRow(paneKey, entry)) +} + +// Pre-fix: build plain rows and stringify the array once — no per-row roundtrip, +// which the original never paid and which would inflate the reported speedup. +function buildFull(map) { + return JSON.stringify( + Object.entries(map) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([paneKey, entry]) => toRow(paneKey, entry)) + ) +} + +// Post-fix: reuse each row's JSON while its entry object is unchanged. +function makeCachedBuilder() { + let cache = null + return (map) => { + if (cache?.source === map) { + return cache.projection + } + const previous = cache?.entries + const entries = new Map() + const parts = [] + for (const [paneKey, entry] of Object.entries(map).sort(([a], [b]) => a.localeCompare(b))) { + const prior = previous?.get(paneKey) + const row = + prior?.entry === entry ? prior : { entry, projection: serializeEntry(paneKey, entry) } + entries.set(paneKey, row) + parts.push(row.projection) + } + const projection = `[${parts.join(',')}]` + cache = { source: map, entries, projection } + return projection + } +} + +// A live agent as the store actually holds it. +function makeEntry(index, updatedAt) { + return { + paneKey: `tab-${index}:leaf-0`, + state: 'working', + prompt: 'implement the feature and run the tests '.repeat(4), + updatedAt, + stateStartedAt: 1740000000000, + agentType: 'claude', + terminalTitle: `agent ${index}`, + stateHistory: Array.from({ length: 20 }, (_value, step) => ({ + state: 'working', + prompt: `step ${step} of the current turn`, + startedAt: 1740000000000 + step, + interrupted: null + })), + toolName: 'shell_command', + toolInput: 'rg --line-number "pattern" src/ '.repeat(8), + interactivePrompt: null, + // The cap the store applies to assistant text. + lastAssistantMessage: 'x'.repeat(8000), + interrupted: null + } +} + +function makeMap(agents) { + const map = {} + for (let index = 0; index < agents; index += 1) { + map[`tab-${index}:leaf-0`] = makeEntry(index, 1740000000000 + index * BUCKET_MS) + } + return map +} + +// One status ping: one entry replaced, the map re-spread, every other entry +// reference-identical — exactly what setAgentStatus produces. +function ping(map, round) { + return { + ...map, + 'tab-0:leaf-0': makeEntry(0, 1740000000000 + BUCKET_MS * (round + 1)) + } +} + +function measure(build, map) { + let current = map + for (let index = 0; index < WARMUP; index += 1) { + current = ping(current, index) + build(current) + } + const samples = [] + for (let round = 0; round < 5; round += 1) { + const start = performance.now() + for (let index = 0; index < ITERATIONS; index += 1) { + current = ping(current, index) + build(current) + } + samples.push((performance.now() - start) / ITERATIONS) + } + samples.sort((a, b) => a - b) + return samples[2] +} + +const pad = (value, width) => String(value).padStart(width) +console.log('Mobile agent-status projection, per status ping (one agent changed)') +console.log(`bucket=${BUCKET_MS}ms iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`) +console.log(`${pad('agents', 8)} ${pad('full', 11)} ${pad('cached', 11)} ${pad('speedup', 9)}`) +for (const agents of [3, 8, 20, 40]) { + const map = makeMap(agents) + const cachedBuilder = makeCachedBuilder() + if (buildFull(map) !== cachedBuilder(map)) { + throw new Error(`projection mismatch at ${agents} agents`) + } + // Why also after a ping: the cold call reuses nothing, so a stale-row bug would + // only surface once the cache is actually exercised. + const pinged = ping(map, 0) + if (buildFull(pinged) !== cachedBuilder(pinged)) { + throw new Error(`projection mismatch after a ping at ${agents} agents`) + } + const full = measure(buildFull, map) + const cached = measure(makeCachedBuilder(), map) + console.log( + `${pad(agents, 8)} ${pad(`${full.toFixed(4)} ms`, 11)} ${pad(`${cached.toFixed(4)} ms`, 11)} ${pad(`${(full / cached).toFixed(1)}x`, 9)}` + ) +} +console.log( + '\nThis runs on the global store subscriber, so the cost is paid per status ping\nand scales with the number of agents running in parallel — the workload this\napp exists for.' +) diff --git a/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts b/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts new file mode 100644 index 000000000..551e584de --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { + AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS, + buildRuntimeMobileAgentStatusProjectionForTests, + resetRuntimeMobileAgentStatusProjectionCacheForTests +} from './sync-runtime-graph' + +// Reference: the pre-change whole-array serialization, kept verbatim. The bucket +// width is read from the module under test so a drifted constant cannot make this +// reference silently disagree for a reason unrelated to the change. +const BUCKET_MS = AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS +function referenceProjection(map: AppState['agentStatusByPaneKey']): string { + return JSON.stringify( + Object.entries(map) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([paneKey, entry]) => ({ + paneKey, + entryPaneKey: entry.paneKey, + state: entry.state, + prompt: entry.prompt, + updatedAtBucket: Math.floor(entry.updatedAt / BUCKET_MS), + stateStartedAt: entry.stateStartedAt, + agentType: entry.agentType ?? null, + terminalTitle: entry.terminalTitle ?? null, + stateHistory: entry.stateHistory.map((history) => ({ + state: history.state, + prompt: history.prompt, + startedAt: history.startedAt, + interrupted: history.interrupted ?? null + })), + toolName: entry.toolName ?? null, + toolInput: entry.toolInput ?? null, + interactivePrompt: entry.interactivePrompt ?? null, + lastAssistantMessage: entry.lastAssistantMessage ?? null, + interrupted: entry.interrupted ?? null + })) + ) +} + +function makeEntry(index: number, overrides: Record = {}): never { + return { + paneKey: `tab-${index}:leaf-0`, + state: 'working', + prompt: `prompt ${index} with "quotes" and \\ backslash`, + updatedAt: 1740000000000 + index * 17, + stateStartedAt: 1740000000000, + agentType: 'claude', + terminalTitle: `agent ${index} 日本 \u{1f389}`, + stateHistory: Array.from({ length: 3 }, (_value, h) => ({ + state: 'working', + prompt: `step ${h}`, + startedAt: 1740000000000 + h + })), + toolName: 'shell_command', + toolInput: 'ls -la', + lastAssistantMessage: 'answer', + ...overrides + } as never +} + +describe('mobile agent-status projection equivalence', () => { + it('matches the whole-array serialization across shapes and cache reuse', () => { + resetRuntimeMobileAgentStatusProjectionCacheForTests() + const shapes: AppState['agentStatusByPaneKey'][] = [] + shapes.push({}) + shapes.push({ 'tab-0:leaf-0': makeEntry(0) }) + const many: AppState['agentStatusByPaneKey'] = {} + for (let index = 0; index < 12; index += 1) { + many[`tab-${index}:leaf-0`] = makeEntry(index) + } + shapes.push(many) + // Optional fields absent entirely, which the ?? null fallbacks must cover. + shapes.push({ + 'tab-9:leaf-1': makeEntry(9, { + agentType: undefined, + terminalTitle: undefined, + toolName: undefined, + toolInput: undefined, + interactivePrompt: undefined, + lastAssistantMessage: undefined, + interrupted: undefined + }) + }) + // Keys deliberately out of insertion order to pin the sort. + shapes.push({ + 'tab-z:leaf-0': makeEntry(2), + 'tab-a:leaf-0': makeEntry(1), + 'tab-m:leaf-0': makeEntry(3) + }) + + for (const [index, shape] of shapes.entries()) { + expect({ + index, + projection: buildRuntimeMobileAgentStatusProjectionForTests(shape) + }).toEqual({ index, projection: referenceProjection(shape) }) + } + + // Now exercise the cache: replace one entry the way setAgentStatus does and + // confirm the reused rows still produce the reference output. The changes must + // be observable in the projection — a sub-bucket updatedAt nudge is not, so a + // stale-entry reuse bug would slip through. + let current = many + for (let round = 0; round < 4; round += 1) { + current = { + ...current, + 'tab-0:leaf-0': makeEntry(0, { + updatedAt: 1740000000000 + BUCKET_MS * 3 * (round + 1), + state: round % 2 === 0 ? 'done' : 'working', + prompt: `changed prompt round ${round}`, + lastAssistantMessage: `answer round ${round}` + }) + } + expect({ + round, + projection: buildRuntimeMobileAgentStatusProjectionForTests(current) + }).toEqual({ round, projection: referenceProjection(current) }) + } + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 340af3c3d..7d90d25d1 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -68,6 +68,15 @@ type TabsProjectionCache = { entries: Map projection: string } +type AgentStatusProjectionCacheEntry = { + entry: AppState['agentStatusByPaneKey'][string] + projection: string +} +type AgentStatusProjectionCache = { + source: AppState['agentStatusByPaneKey'] + entries: Map + projection: string +} const registeredTabs = new Map() // Why: registration time suppresses the "no live transport" warning during the async PTY-connect window; after the grace period it's a real stuck state. @@ -129,6 +138,7 @@ function jsonContentEquals(a: unknown, b: unknown): boolean { return true } let cachedTabsProjection: TabsProjectionCache | null = null +let cachedAgentStatusProjection: AgentStatusProjectionCache | null = null let cachedOpenFileIndexesSource: AppState['openFiles'] | null = null let cachedOpenFileIndexes: OpenFileIndexes | null = null let cachedEditorDraftsSource: AppState['editorDrafts'] | null = null @@ -491,35 +501,76 @@ function buildRuntimeMobileEditorDraftsProjection(editorDrafts: AppState['editor ) } +function serializeRuntimeMobileAgentStatusEntry( + paneKey: string, + entry: AppState['agentStatusByPaneKey'][string] +): string { + return JSON.stringify({ + paneKey, + entryPaneKey: entry.paneKey, + state: entry.state, + prompt: entry.prompt, + updatedAtBucket: Math.floor(entry.updatedAt / AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS), + stateStartedAt: entry.stateStartedAt, + agentType: entry.agentType ?? null, + terminalTitle: entry.terminalTitle ?? null, + stateHistory: entry.stateHistory.map((history) => ({ + state: history.state, + prompt: history.prompt, + startedAt: history.startedAt, + interrupted: history.interrupted ?? null + })), + toolName: entry.toolName ?? null, + toolInput: entry.toolInput ?? null, + // Why: include so a newly-captured AskUserQuestion prompt re-fires the mobile republish even when no other field changed. + interactivePrompt: entry.interactivePrompt ?? null, + lastAssistantMessage: entry.lastAssistantMessage ?? null, + interrupted: entry.interrupted ?? null + }) +} + function buildRuntimeMobileAgentStatusProjection( agentStatusByPaneKey: AppState['agentStatusByPaneKey'] ): string { - return JSON.stringify( - Object.entries(agentStatusByPaneKey) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([paneKey, entry]) => ({ - paneKey, - entryPaneKey: entry.paneKey, - state: entry.state, - prompt: entry.prompt, - updatedAtBucket: Math.floor(entry.updatedAt / AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS), - stateStartedAt: entry.stateStartedAt, - agentType: entry.agentType ?? null, - terminalTitle: entry.terminalTitle ?? null, - stateHistory: entry.stateHistory.map((history) => ({ - state: history.state, - prompt: history.prompt, - startedAt: history.startedAt, - interrupted: history.interrupted ?? null - })), - toolName: entry.toolName ?? null, - toolInput: entry.toolInput ?? null, - // Why: include so a newly-captured AskUserQuestion prompt re-fires the mobile republish even when no other field changed. - interactivePrompt: entry.interactivePrompt ?? null, - lastAssistantMessage: entry.lastAssistantMessage ?? null, - interrupted: entry.interrupted ?? null - })) - ) + if (cachedAgentStatusProjection?.source === agentStatusByPaneKey) { + return cachedAgentStatusProjection.projection + } + + // Why per-entry: a status ping replaces one entry and re-spreads the map, so + // without this every other live agent — each carrying a 20-entry history and an + // 8 KB message — is re-serialized to discover it did not change. + const previousEntries = cachedAgentStatusProjection?.entries + const entries = new Map() + const parts: string[] = [] + + for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey).sort(([a], [b]) => + a.localeCompare(b) + )) { + const previous = previousEntries?.get(paneKey) + const cached = + previous?.entry === entry + ? previous + : { entry, projection: serializeRuntimeMobileAgentStatusEntry(paneKey, entry) } + entries.set(paneKey, cached) + parts.push(cached.projection) + } + + const projection = `[${parts.join(',')}]` + cachedAgentStatusProjection = { source: agentStatusByPaneKey, entries, projection } + return projection +} + +export function buildRuntimeMobileAgentStatusProjectionForTests( + agentStatusByPaneKey: AppState['agentStatusByPaneKey'] +): string { + return buildRuntimeMobileAgentStatusProjection(agentStatusByPaneKey) +} + +export const AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS = + AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS + +export function resetRuntimeMobileAgentStatusProjectionCacheForTests(): void { + cachedAgentStatusProjection = null } export function runtimeMobileSessionSyncKeysEqual(