diff --git a/config/scripts/agent-status-hot-path-benchmark.test.ts b/config/scripts/agent-status-hot-path-benchmark.test.ts new file mode 100644 index 00000000000..6c30b82ebf7 --- /dev/null +++ b/config/scripts/agent-status-hot-path-benchmark.test.ts @@ -0,0 +1,292 @@ +/** + * Deterministic benchmark for the renderer agent-status hot path. + * + * It is written so the SAME file can be checked out onto a baseline revision and re-run: it only + * touches API that exists on both sides of the memoization change. Run it on the baseline and on + * the candidate on one machine and diff the JSON artifact. + * + * Counting passes patch `Map`/`Set`/`Object.assign`/`Object.values`, which deoptimizes them, so + * counts and timings are taken in separate passes and never from the same run. + * + * Scale mirrors the reporting user rather than the 100-worktree fixture in + * docs/reference/renderer-agent-status-performance.md: 423 worktrees, 634 terminal tabs. + */ +import { writeFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import type { AgentStatusBatchUpdate } from '@/store/slices/agent-status' +import { + createTestStore, + makeTab, + makeUnifiedTab, + makeWorktree, + TEST_REPO +} from '@/store/slices/store-test-helpers' +import { makePaneKey } from '../../src/shared/stable-pane-id' +import { + createAgentStatusPaneRoutingIndex, + resolvePaneKeyFromRoutingIndex +} from '@/hooks/ipc-events/agent-status-pane-routing-index' +import { resolvePaneKey } from '@/hooks/ipc-events/agent-status-routing' + +const WORKTREES = 423 +const EVENTS = 1_000 +const BATCH_SIZE = 8 +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const BASE_TIME = 2_000_000_000 + +const counters = { maps: 0, sets: 0, tabComparisons: 0 } + +const NativeMap = globalThis.Map +const NativeSet = globalThis.Set +const nativeArrayIterator = Array.prototype[Symbol.iterator] + +function withAllocationCounting(run: () => T): T { + class CountingMap extends NativeMap { + constructor(entries?: readonly (readonly [K, V])[] | null) { + super(entries) + counters.maps += 1 + } + } + class CountingSet extends NativeSet { + constructor(values?: readonly V[] | null) { + super(values) + counters.sets += 1 + } + } + counters.maps = 0 + counters.sets = 0 + globalThis.Map = CountingMap as unknown as MapConstructor + globalThis.Set = CountingSet as unknown as SetConstructor + try { + return run() + } finally { + globalThis.Map = NativeMap + globalThis.Set = NativeSet + } +} + +/** Tab list whose iteration is observable, so the nested-loop resolver's comparisons are countable. */ +class CountingTabList extends Array { + [Symbol.iterator](): IterableIterator { + const inner = nativeArrayIterator.call(this) as IterableIterator + const wrapped: IterableIterator = { + next: () => { + const result = inner.next() + if (!result.done) { + counters.tabComparisons += 1 + } + return result + }, + [Symbol.iterator]: () => wrapped + } + return wrapped + } +} + +function buildFixture(countTabIteration: boolean) { + const store = createTestStore() + const tabsByWorktree: AppState['tabsByWorktree'] = {} + const unifiedTabsByWorktree: AppState['unifiedTabsByWorktree'] = {} + const worktrees = [] + const paneKeys: string[] = [] + const owners: { tabId: string; worktreeId: string }[] = [] + for (let index = 0; index < WORKTREES; index += 1) { + const worktreeId = `wt-${index}` + worktrees.push(makeWorktree({ id: worktreeId, repoId: TEST_REPO.id })) + const tabs = [] + const unified = [] + for (let tab = 0; tab < (index % 2 === 0 ? 1 : 2); tab += 1) { + const tabId = `tab-${index}-${tab}` + tabs.push(makeTab({ id: tabId, worktreeId, title: `Terminal ${index}-${tab}` })) + unified.push( + makeUnifiedTab({ + id: tabId, + worktreeId, + groupId: `group-${index}`, + label: `Label ${index}` + }) + ) + paneKeys.push(makePaneKey(tabId, LEAF_ID)) + owners.push({ tabId, worktreeId }) + } + tabsByWorktree[worktreeId] = countTabIteration + ? (CountingTabList.from(tabs) as unknown as typeof tabs) + : tabs + unifiedTabsByWorktree[worktreeId] = unified + } + store.setState({ + repos: [TEST_REPO], + worktreesByRepo: { [TEST_REPO.id]: worktrees }, + tabsByWorktree, + unifiedTabsByWorktree, + terminalLayoutsByTabId: {}, + setGeneratedTabTitlesFromAgentPrompts: () => {}, + settings: { ...store.getState().settings, tabAutoGenerateTitle: false } + } as Partial) + return { store, paneKeys, owners } +} + +function measure(run: () => void): number { + const start = performance.now() + run() + return performance.now() - start +} + +function per1k(value: number): number { + return Math.round((value / EVENTS) * 1000) +} + +/** One burst-shaped pass: a fresh index per batch, one pane resolution per event. */ +function runIndexedRouting(store: ReturnType, paneKeys: string[]): void { + for (let event = 0; event < EVENTS; event += 1) { + if (event % BATCH_SIZE === 0) { + store.setState({ agentStatusEpoch: event } as Partial) + } + const index = createAgentStatusPaneRoutingIndex(store.getState()) + resolvePaneKeyFromRoutingIndex(index, paneKeys[event % paneKeys.length]) + } +} + +function runStandaloneRouting(state: AppState, paneKeys: string[]): void { + for (let event = 0; event < EVENTS; event += 1) { + resolvePaneKey(state, paneKeys[event % paneKeys.length]) + } +} + +function buildBatches( + owners: { tabId: string; worktreeId: string }[], + paneKeys: string[] +): AgentStatusBatchUpdate[][] { + const batches: AgentStatusBatchUpdate[][] = [] + for (let event = 0; event < EVENTS; event += 1) { + const batchIndex = Math.floor(event / BATCH_SIZE) + const owner = owners[event % owners.length] + batches[batchIndex] ??= [] + batches[batchIndex].push({ + paneKey: paneKeys[event % paneKeys.length], + payload: { state: 'working', prompt: `turn ${event}`, agentType: 'claude' }, + timing: { updatedAt: BASE_TIME + event, stateStartedAt: BASE_TIME + event }, + routing: { tabId: owner.tabId, worktreeId: owner.worktreeId } + }) + } + return batches +} + +const nextTick = (): Promise => + new Promise((resolve) => { + queueMicrotask(resolve) + }) + +async function runCommitPass( + store: ReturnType, + batches: AgentStatusBatchUpdate[][], + onBatch?: (elapsed: number) => void +): Promise { + for (const batch of batches) { + const elapsed = measure(() => { + store.getState().setAgentStatuses(batch) + }) + onBatch?.(elapsed) + // Each 33 ms burst is its own tick in production; let the deferred freshness scan run. + await nextTick() + } +} + +describe('agent-status hot path benchmark', () => { + it('reports routing, resolution and commit cost at 423 worktrees', async () => { + const report: Record = {} + + // Routing allocations (counting pass) and routing time (clean pass), taken separately. + { + const alloc = buildFixture(false) + withAllocationCounting(() => runIndexedRouting(alloc.store, alloc.paneKeys)) + report['routing.indexed.mapAllocationsPer1kEvents'] = per1k(counters.maps) + report['routing.indexed.setAllocationsPer1kEvents'] = per1k(counters.sets) + + const warm = buildFixture(false) + runIndexedRouting(warm.store, warm.paneKeys) + const clean = buildFixture(false) + report['routing.indexed.ms'] = Number( + measure(() => runIndexedRouting(clean.store, clean.paneKeys)).toFixed(2) + ) + } + + // Standalone nested-loop resolution: what the leading edge used before this change. + { + const alloc = buildFixture(true) + counters.tabComparisons = 0 + withAllocationCounting(() => runStandaloneRouting(alloc.store.getState(), alloc.paneKeys)) + report['routing.standalone.tabComparisonsPer1kEvents'] = per1k(counters.tabComparisons) + report['routing.standalone.mapAllocationsPer1kEvents'] = per1k(counters.maps) + + const warm = buildFixture(false) + runStandaloneRouting(warm.store.getState(), warm.paneKeys) + const clean = buildFixture(false) + report['routing.standalone.ms'] = Number( + measure(() => runStandaloneRouting(clean.store.getState(), clean.paneKeys)).toFixed(2) + ) + } + + // Indexed resolution under the same tab-iteration counter, for a like-for-like comparison count. + { + const alloc = buildFixture(true) + counters.tabComparisons = 0 + runIndexedRouting(alloc.store, alloc.paneKeys) + report['routing.indexed.tabComparisonsPer1kEvents'] = per1k(counters.tabComparisons) + } + + // Store commits: 1,000 updates folded in 125 transactions, one microtask tick per transaction. + { + const counted = buildFixture(false) + const nativeObjectAssign = Object.assign + const nativeObjectValues = Object.values + let objectAssignCalls = 0 + let objectAssignPropertyCopies = 0 + let freshnessEntryVisits = 0 + Object.assign = ((target: object, ...sources: object[]) => { + objectAssignCalls += 1 + for (const source of sources) { + if (source && typeof source === 'object') { + objectAssignPropertyCopies += Object.keys(source).length + } + } + return nativeObjectAssign(target, ...sources) + }) as typeof Object.assign + Object.values = ((value: object) => { + const result = nativeObjectValues(value) + freshnessEntryVisits += result.length + return result + }) as typeof Object.values + const countedBatches = buildBatches(counted.owners, counted.paneKeys) + try { + await runCommitPass(counted.store, countedBatches) + } finally { + Object.assign = nativeObjectAssign + Object.values = nativeObjectValues + } + report['commit.objectAssignCallsPer1kUpdates'] = per1k(objectAssignCalls) + report['commit.stagedPropertyCopiesPer1kUpdates'] = per1k(objectAssignPropertyCopies) + report['commit.freshnessEntryVisitsPer1kUpdates'] = per1k(freshnessEntryVisits) + report['commit.transactions'] = countedBatches.length + expect(Object.keys(counted.store.getState().agentStatusByPaneKey).length).toBeGreaterThan(0) + + const warm = buildFixture(false) + await runCommitPass(warm.store, buildBatches(warm.owners, warm.paneKeys)) + const clean = buildFixture(false) + let elapsed = 0 + await runCommitPass(clean.store, buildBatches(clean.owners, clean.paneKeys), (batchMs) => { + elapsed += batchMs + }) + report['commit.ms'] = Number(elapsed.toFixed(2)) + } + + const outputPath = + process.env.ORCA_AGENT_STATUS_BENCH_OUTPUT ?? '/tmp/agent-status-hot-path-benchmark.json' + writeFileSync( + outputPath, + `${JSON.stringify({ worktrees: WORKTREES, events: EVENTS, report }, null, 2)}\n` + ) + expect(report['routing.indexed.ms']).toBeGreaterThan(0) + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts index c0ce037a0dc..65a5718f3ac 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts @@ -18,11 +18,10 @@ import { hasRuntimeBackedWorktreeAttribution, isAgentStatusForRecentlyClosedTab, resolveHookPayloadAgentType, - resolvePaneKey, - resolveWorktreeConnection, shouldApplyResolvedAgentTerminalTitleToTab } from './agent-status-routing' import { + createAgentStatusPaneRoutingIndex, resolvePaneKeyFromRoutingIndex, resolveWorktreeConnectionFromRoutingIndex } from './agent-status-pane-routing-index' @@ -60,6 +59,9 @@ export function createAgentStatusEventApplicator(args: { if (!payload) { return 'dropped' } + // Why: the memoized index answers the leading edge with the same first-match ownership the + // standalone resolver produced, without its worktree x tab rescan per event. + const routingIndex = options?.batch?.routingIndex ?? createAgentStatusPaneRoutingIndex(store) let { exists, title, @@ -68,9 +70,7 @@ export function createAgentStatusEventApplicator(args: { repoConnectionResolved, owningWorktreeId, titleUsesTabTitle - } = options?.batch - ? resolvePaneKeyFromRoutingIndex(options.batch.routingIndex, paneKey) - : resolvePaneKey(store, paneKey) + } = resolvePaneKeyFromRoutingIndex(routingIndex, paneKey) const projectedTitles = titleUsesTabTitle && ownerTabId ? options?.batch?.projectedTitlesByTabId.get(ownerTabId) @@ -80,9 +80,10 @@ export function createAgentStatusEventApplicator(args: { identityTitle = projectedTitles.identityTitle } if (!exists && data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) { - const fallbackOwnership = options?.batch - ? resolveWorktreeConnectionFromRoutingIndex(options.batch.routingIndex, data.worktreeId) - : resolveWorktreeConnection(store, data.worktreeId) + const fallbackOwnership = resolveWorktreeConnectionFromRoutingIndex( + routingIndex, + data.worktreeId + ) if (fallbackOwnership.worktreeExists) { owningWorktreeId = data.worktreeId repoConnectionId = fallbackOwnership.repoConnectionId diff --git a/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.test.ts b/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.test.ts new file mode 100644 index 00000000000..f016fe40320 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.test.ts @@ -0,0 +1,207 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { Tab } from '../../../../shared/tab-types' +import type { AppState } from '../../store/types' +import { + createTestStore, + makeLayout, + makeTab, + makeUnifiedTab, + makeWorktree, + TEST_REPO +} from '../../store/slices/store-test-helpers' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { + agentStatusPaneRoutingIndexCounters, + createAgentStatusPaneRoutingIndex, + resetAgentStatusPaneRoutingIndexCounters, + resolvePaneKeyFromRoutingIndex +} from './agent-status-pane-routing-index' +import { resolvePaneKey } from './agent-status-routing' + +const WORKTREE_COUNT = 100 +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const OTHER_LEAF_ID = '22222222-2222-4222-8222-222222222222' + +function seedLineage(store: ReturnType): { + tabsByWorktree: AppState['tabsByWorktree'] + paneKeys: string[] +} { + const tabsByWorktree: AppState['tabsByWorktree'] = {} + const unifiedTabsByWorktree: AppState['unifiedTabsByWorktree'] = {} + const paneKeys: string[] = [] + for (let index = 0; index < WORKTREE_COUNT; index += 1) { + const worktreeId = `wt-${index}` + const tabId = `tab-${index}` + tabsByWorktree[worktreeId] = [makeTab({ id: tabId, worktreeId, title: `Terminal ${index}` })] + unifiedTabsByWorktree[worktreeId] = [ + makeUnifiedTab({ id: tabId, worktreeId, groupId: `group-${index}`, label: `Label ${index}` }) + ] + paneKeys.push(makePaneKey(tabId, LEAF_ID)) + } + store.setState({ + repos: [TEST_REPO], + worktreesByRepo: { + [TEST_REPO.id]: Array.from({ length: WORKTREE_COUNT }, (_, index) => + makeWorktree({ id: `wt-${index}`, repoId: TEST_REPO.id }) + ) + }, + tabsByWorktree, + unifiedTabsByWorktree, + terminalLayoutsByTabId: {} + } as Partial) + return { tabsByWorktree, paneKeys } +} + +describe('agent-status pane routing index memoization', () => { + beforeEach(() => { + resetAgentStatusPaneRoutingIndexCounters() + }) + + it('builds the ownership index once while the tab map stays identity-stable', () => { + const store = createTestStore() + const { paneKeys } = seedLineage(store) + resetAgentStatusPaneRoutingIndexCounters() + + // 100 status commits: each replaces the live status map, none replaces the tab map. + for (let commit = 0; commit < 100; commit += 1) { + const index = createAgentStatusPaneRoutingIndex(store.getState()) + expect(resolvePaneKeyFromRoutingIndex(index, paneKeys[commit % paneKeys.length]).exists).toBe( + true + ) + store.setState({ + agentStatusByPaneKey: { ...store.getState().agentStatusByPaneKey }, + agentStatusEpoch: commit + } as Partial) + } + + expect(agentStatusPaneRoutingIndexCounters.indexBuilds).toBe(1) + expect(agentStatusPaneRoutingIndexCounters.tabIndexBuilds).toBe(1) + expect(agentStatusPaneRoutingIndexCounters.tabVisits).toBe(WORKTREE_COUNT) + // Only the worktrees actually routed to pay for a unified-label map. + expect(agentStatusPaneRoutingIndexCounters.unifiedLabelIndexBuilds).toBeLessThanOrEqual( + paneKeys.length + ) + }) + + it('rebuilds the tab index when the tab map is replaced', () => { + const store = createTestStore() + seedLineage(store) + createAgentStatusPaneRoutingIndex(store.getState()) + resetAgentStatusPaneRoutingIndexCounters() + + store.setState({ + tabsByWorktree: { ...store.getState().tabsByWorktree } + } as Partial) + createAgentStatusPaneRoutingIndex(store.getState()) + + expect(agentStatusPaneRoutingIndexCounters.tabIndexBuilds).toBe(1) + }) + + it('reuses the index across a layout replacement without re-indexing tabs', () => { + const store = createTestStore() + seedLineage(store) + createAgentStatusPaneRoutingIndex(store.getState()) + resetAgentStatusPaneRoutingIndexCounters() + + store.setState({ + terminalLayoutsByTabId: { 'tab-0': makeLayout() } + } as Partial) + createAgentStatusPaneRoutingIndex(store.getState()) + + expect(agentStatusPaneRoutingIndexCounters.indexBuilds).toBe(1) + expect(agentStatusPaneRoutingIndexCounters.tabIndexBuilds).toBe(0) + }) +}) + +describe('agent-status leading-edge and batched pane resolution', () => { + it('agrees with the standalone resolver across duplicates, splits and missing owners', () => { + const store = createTestStore() + const duplicateTabId = 'tab-duplicate' + const splitTabId = 'tab-split' + const orphanTabId = 'tab-orphan' + const unifiedTabsByWorktree: AppState['unifiedTabsByWorktree'] = { + 'wt-a': [ + makeUnifiedTab({ + id: duplicateTabId, + worktreeId: 'wt-a', + groupId: 'group-a', + label: 'First label' + }), + makeUnifiedTab({ + id: duplicateTabId, + worktreeId: 'wt-a', + groupId: 'group-a', + label: 'Shadowed label' + }), + { + ...makeUnifiedTab({ + id: splitTabId, + worktreeId: 'wt-a', + groupId: 'group-a', + label: ' ' + }) + } as Tab + ], + 'wt-b': [ + makeUnifiedTab({ + id: duplicateTabId, + worktreeId: 'wt-b', + groupId: 'group-b', + label: 'Second worktree label' + }) + ] + } + store.setState({ + repos: [TEST_REPO], + worktreesByRepo: { + [TEST_REPO.id]: [ + makeWorktree({ id: 'wt-a', repoId: TEST_REPO.id }), + makeWorktree({ id: 'wt-b', repoId: TEST_REPO.id }) + ] + }, + tabsByWorktree: { + 'wt-a': [ + makeTab({ id: duplicateTabId, worktreeId: 'wt-a', title: 'Owner A' }), + makeTab({ id: splitTabId, worktreeId: 'wt-a', title: 'Split owner' }) + ], + // The same tab id under a second worktree: first worktree must keep ownership. + 'wt-b': [makeTab({ id: duplicateTabId, worktreeId: 'wt-b', title: 'Owner B' })], + 'wt-missing': [makeTab({ id: orphanTabId, worktreeId: 'wt-missing', title: 'Orphan' })] + }, + unifiedTabsByWorktree, + terminalLayoutsByTabId: { + [splitTabId]: { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: OTHER_LEAF_ID } + }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + titlesByLeafId: { [LEAF_ID]: 'Pane title', [OTHER_LEAF_ID]: '' } + } + } + } as Partial) + + const corpus = [ + makePaneKey(duplicateTabId, LEAF_ID), + makePaneKey(duplicateTabId, OTHER_LEAF_ID), + makePaneKey(splitTabId, LEAF_ID), + makePaneKey(splitTabId, OTHER_LEAF_ID), + makePaneKey(splitTabId, '33333333-3333-4333-8333-333333333333'), + makePaneKey(orphanTabId, LEAF_ID), + makePaneKey('tab-unknown', LEAF_ID), + 'not-a-pane-key' + ] + + const state = store.getState() + const index = createAgentStatusPaneRoutingIndex(state) + for (const paneKey of corpus) { + expect({ paneKey, ...resolvePaneKeyFromRoutingIndex(index, paneKey) }).toEqual({ + paneKey, + ...resolvePaneKey(state, paneKey) + }) + } + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts b/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts index b7f9467bfd2..156789caad1 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts @@ -22,21 +22,48 @@ type AgentStatusWorktreeConnectionResolution = { type IndexedAgentStatusTab = { title: string | undefined - unifiedLabel: string | undefined owningWorktreeId: string } export type AgentStatusPaneRoutingIndex = { tabsById: Map + unifiedTabsByWorktree: AppState['unifiedTabsByWorktree'] + unifiedLabelsByWorktreeId: Map> layoutsByTabId: AppState['terminalLayoutsByTabId'] leafIdsByRoot: WeakMap> worktreesById: ReturnType reposById: ReturnType } +/** Deterministic build accounting for the memoization ratchet test and the routing benchmark. */ +export const agentStatusPaneRoutingIndexCounters = { + indexBuilds: 0, + tabIndexBuilds: 0, + tabVisits: 0, + unifiedLabelIndexBuilds: 0, + leafSetBuilds: 0 +} + +export function resetAgentStatusPaneRoutingIndexCounters(): void { + agentStatusPaneRoutingIndexCounters.indexBuilds = 0 + agentStatusPaneRoutingIndexCounters.tabIndexBuilds = 0 + agentStatusPaneRoutingIndexCounters.tabVisits = 0 + agentStatusPaneRoutingIndexCounters.unifiedLabelIndexBuilds = 0 + agentStatusPaneRoutingIndexCounters.leafSetBuilds = 0 +} + +// Why: layout roots are immutable snapshots, so leaf membership keyed on the root node stays +// correct across commits and never has to be rewalked once seen. +const leafIdsByRoot = new WeakMap>() +const tabsByIdCache = new WeakMap>() +const unifiedLabelIndexCache = new WeakMap>>() +const routingIndexCache = new WeakMap() +const NO_UNIFIED_TABS = {} + function createUnifiedTerminalLabelIndex( entries: AppState['unifiedTabsByWorktree'][string] | undefined ): Map { + agentStatusPaneRoutingIndexCounters.unifiedLabelIndexBuilds += 1 const labelsByTabId = new Map() for (const entry of entries ?? []) { if (entry.contentType !== 'terminal' || labelsByTabId.has(entry.entityId)) { @@ -48,30 +75,86 @@ function createUnifiedTerminalLabelIndex( return labelsByTabId } -export function createAgentStatusPaneRoutingIndex(store: AppState): AgentStatusPaneRoutingIndex { +function getIndexedTabs( + tabsByWorktree: AppState['tabsByWorktree'] +): Map { + const cached = tabsByIdCache.get(tabsByWorktree) + if (cached) { + return cached + } + agentStatusPaneRoutingIndexCounters.tabIndexBuilds += 1 const tabsById = new Map() - for (const [worktreeId, tabs] of Object.entries(store.tabsByWorktree)) { - const unifiedLabelsByTabId = createUnifiedTerminalLabelIndex( - store.unifiedTabsByWorktree?.[worktreeId] - ) + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { for (const tab of tabs) { + agentStatusPaneRoutingIndexCounters.tabVisits += 1 + // Read the id once: retained selectors assert one read per row, and it is a getter on some snapshots. const tabId = tab.id + // First wins: the standalone resolver stops at the first worktree owning this tab id. if (!tabsById.has(tabId)) { - tabsById.set(tabId, { - title: tab.title, - unifiedLabel: unifiedLabelsByTabId.get(tabId), - owningWorktreeId: worktreeId - }) + tabsById.set(tabId, { title: tab.title, owningWorktreeId: worktreeId }) } } } - return { - tabsById, - layoutsByTabId: store.terminalLayoutsByTabId, - leafIdsByRoot: new WeakMap(), - worktreesById: getWorktreeMapFromState(store), - reposById: getRepoMapFromState(store) + tabsByIdCache.set(tabsByWorktree, tabsById) + return tabsById +} + +function getUnifiedLabelIndex( + unifiedTabsByWorktree: AppState['unifiedTabsByWorktree'] +): Map> { + const cacheKey = unifiedTabsByWorktree ?? NO_UNIFIED_TABS + const cached = unifiedLabelIndexCache.get(cacheKey) + if (cached) { + return cached } + const labelsByWorktreeId = new Map>() + unifiedLabelIndexCache.set(cacheKey, labelsByWorktreeId) + return labelsByWorktreeId +} + +function resolveUnifiedLabel( + index: AgentStatusPaneRoutingIndex, + worktreeId: string, + tabId: string +): string | undefined { + let labelsByTabId = index.unifiedLabelsByWorktreeId.get(worktreeId) + if (!labelsByTabId) { + labelsByTabId = createUnifiedTerminalLabelIndex(index.unifiedTabsByWorktree?.[worktreeId]) + index.unifiedLabelsByWorktreeId.set(worktreeId, labelsByTabId) + } + return labelsByTabId.get(tabId) +} + +/** + * Ownership index for agent-status routing, memoized on the identity of the slices it reads. + * A status commit replaces none of them, so a dense burst reuses one index instead of rebuilding + * a per-worktree tab and label map for every event. + */ +export function createAgentStatusPaneRoutingIndex(store: AppState): AgentStatusPaneRoutingIndex { + const worktreesById = getWorktreeMapFromState(store) + const reposById = getRepoMapFromState(store) + const cached = routingIndexCache.get(store.tabsByWorktree) + if ( + cached && + cached.unifiedTabsByWorktree === store.unifiedTabsByWorktree && + cached.layoutsByTabId === store.terminalLayoutsByTabId && + cached.worktreesById === worktreesById && + cached.reposById === reposById + ) { + return cached + } + agentStatusPaneRoutingIndexCounters.indexBuilds += 1 + const index: AgentStatusPaneRoutingIndex = { + tabsById: getIndexedTabs(store.tabsByWorktree), + unifiedTabsByWorktree: store.unifiedTabsByWorktree, + unifiedLabelsByWorktreeId: getUnifiedLabelIndex(store.unifiedTabsByWorktree), + layoutsByTabId: store.terminalLayoutsByTabId, + leafIdsByRoot, + worktreesById, + reposById + } + routingIndexCache.set(store.tabsByWorktree, index) + return index } export function resolveWorktreeConnectionFromRoutingIndex( @@ -124,6 +207,7 @@ export function resolvePaneKeyFromRoutingIndex( if (layout?.root) { let leafIds = index.leafIdsByRoot.get(layout.root) if (!leafIds) { + agentStatusPaneRoutingIndexCounters.leafSetBuilds += 1 leafIds = new Set(collectLeafIdsInOrder(layout.root)) index.leafIdsByRoot.set(layout.root, leafIds) } @@ -144,7 +228,8 @@ export function resolvePaneKeyFromRoutingIndex( return { exists: true, title: paneTitle ?? tab.title, - identityTitle: paneTitle ?? tab.unifiedLabel ?? tab.title, + identityTitle: + paneTitle ?? resolveUnifiedLabel(index, tab.owningWorktreeId, tabId) ?? tab.title, repoConnectionId: connection.repoConnectionId, repoConnectionResolved: connection.repoConnectionResolved, owningWorktreeId: tab.owningWorktreeId, diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-hydration.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-hydration.test.ts index 0705e323399..d2185e5dafb 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-hydration.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-hydration.test.ts @@ -651,11 +651,13 @@ describe('useIpcEvents agent status snapshot integration', () => { expect(tabIdLookupCount).toBe(paneCount) expect(getMigrationUnsupportedSnapshot).toHaveBeenCalledTimes(1) + // The routing index is memoized on the tab map, so a second snapshot against the same tabs + // reuses it instead of re-indexing every owner. tabIdLookupCount = 0 resolveUnsupportedSnapshot(unsupportedSnapshot) await vi.waitFor(() => { expect(Object.keys(store.getState().migrationUnsupportedByPtyId)).toHaveLength(paneCount) }) - expect(tabIdLookupCount).toBe(paneCount) + expect(tabIdLookupCount).toBe(0) }) }) diff --git a/src/renderer/src/store/slices/agent-status-capacity-eviction.ts b/src/renderer/src/store/slices/agent-status-capacity-eviction.ts index fa82528a169..b22941da07d 100644 --- a/src/renderer/src/store/slices/agent-status-capacity-eviction.ts +++ b/src/renderer/src/store/slices/agent-status-capacity-eviction.ts @@ -55,19 +55,41 @@ export function classifyPaneKeyLiveness(state: AppState): (paneKey: string) => P } } +// Why: a live map that is nowhere near the cap must not pay for a 500-string key array on every +// accepted update, so the count is threaded in and the keys are materialized only to evict. +const liveAgentStatusCounts = new WeakMap, number>() + +export function countLiveAgentStatuses(entries: Record): number { + const cached = liveAgentStatusCounts.get(entries) + if (cached !== undefined) { + return cached + } + const size = Object.keys(entries).length + liveAgentStatusCounts.set(entries, size) + return size +} + +export function noteLiveAgentStatusCount( + entries: Record, + size: number +): void { + liveAgentStatusCounts.set(entries, size) +} + // Why: mutate the caller-owned spread so eviction does not allocate another heavy-map copy. export function capLiveAgentStatusesInPlace( freshLive: Record, protectedPaneKey: string, buildClassifier: () => (paneKey: string) => PaneLiveness, now: number, - maxEntries = MAX_LIVE_AGENT_STATUSES + maxEntries = MAX_LIVE_AGENT_STATUSES, + entryCount = countLiveAgentStatuses(freshLive) ): string[] { - const keys = Object.keys(freshLive) - let overflow = keys.length - maxEntries + let overflow = entryCount - maxEntries if (overflow <= 0) { return [] } + const keys = Object.keys(freshLive) const classify = buildClassifier() const evictedPaneKeys: string[] = [] const sweep = (canEvict: (liveness: PaneLiveness, entry: AgentStatusEntry) => boolean): void => { diff --git a/src/renderer/src/store/slices/agent-status-freshness-cache.test.ts b/src/renderer/src/store/slices/agent-status-freshness-cache.test.ts new file mode 100644 index 00000000000..fca2f1fd3cb --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-freshness-cache.test.ts @@ -0,0 +1,227 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { + agentStatusFreshnessScanCounters, + createFreshnessScheduler, + resetAgentStatusFreshnessScanCounters +} from './agent-status-freshness-scheduler' + +const NOW = new Date('2026-04-09T12:00:00.000Z').getTime() +const MINUTE = 60_000 + +type StatusMap = Record + +function entry(paneKey: string, overrides: Partial = {}): AgentStatusEntry { + return { + paneKey, + state: 'done', + prompt: '', + updatedAt: NOW, + stateStartedAt: NOW, + stateHistory: [], + agentType: 'claude', + ...overrides + } +} + +type Step = { + advanceMs: number + nextEntry?: AgentStatusEntry + evictedPaneKeys?: string[] +} + +/** + * Insert / replace / evict sequence with a single unique minimum at every point, so a cache that + * failed to notice the minimum leaving would arm a different instant than the rescan reference. + */ +function buildScript(): Step[] { + const working = (paneKey: string, updatedAt: number): AgentStatusEntry => + entry(paneKey, { state: 'working', updatedAt, stateStartedAt: updatedAt }) + return [ + // Distinct hook expiries: A at +10m, B at +20m, C at +30m. + { advanceMs: 0, nextEntry: working('tab:a', NOW - 20 * MINUTE) }, + { advanceMs: 0, nextEntry: working('tab:b', NOW - 10 * MINUTE) }, + { advanceMs: 0, nextEntry: working('tab:c', NOW) }, + // Replacing a non-minimum pane must not move the wake. + { advanceMs: MINUTE, nextEntry: working('tab:c', NOW + MINUTE) }, + // A done row whose completion deadline already passed contributes only its hook expiry. + { + advanceMs: MINUTE, + nextEntry: entry('tab:d', { + stateStartedAt: NOW - 29 * MINUTE, + updatedAt: NOW + 2 * MINUTE + }) + }, + // Replacing the pane that HOLDS the minimum. + { advanceMs: MINUTE, nextEntry: working('tab:a', NOW + 3 * MINUTE) }, + // Evicting the pane that now holds the minimum. + { + advanceMs: MINUTE, + nextEntry: working('tab:f', NOW + 4 * MINUTE), + evictedPaneKeys: ['tab:b'] + }, + // A completion deadline that lands before every hook expiry, then crosses it. + { + advanceMs: 0, + nextEntry: entry('tab:g', { + stateStartedAt: NOW - 25 * MINUTE, + updatedAt: NOW + 4 * MINUTE + }) + }, + { advanceMs: 2 * MINUTE }, + // A hydrated row whose hook expiry is EARLIER than the standing minimum. + { advanceMs: 0, nextEntry: working('tab:i', NOW - 22 * MINUTE) }, + { advanceMs: 3 * MINUTE }, + // An interrupted row never contributes a completion deadline. + { + advanceMs: MINUTE, + nextEntry: entry('tab:h', { interrupted: true, updatedAt: NOW + 7 * MINUTE }) + }, + { advanceMs: 25 * MINUTE }, + { advanceMs: 10 * MINUTE } + ] +} + +type PassResult = { armedAt: number[]; bumpedAt: number[] } + +function runPass(mode: 'cached' | 'rescan', script: Step[]): PassResult { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const armedAt: number[] = [] + const bumpedAt: number[] = [] + const nativeSetTimeout = globalThis.setTimeout + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout').mockImplementation((( + handler: TimerHandler, + timeout?: number + ) => { + armedAt.push(Date.now() + (timeout ?? 0)) + return nativeSetTimeout(handler as () => void, timeout) + }) as unknown as typeof globalThis.setTimeout) + + let current: StatusMap = {} + const scheduler = createFreshnessScheduler({ + // The rescan reference hands back a fresh object every read, so the cache can never validate. + getStatusEntries: () => (mode === 'cached' ? current : { ...current }), + bumpEpochs: () => { + bumpedAt.push(Date.now()) + } + }) + + for (const step of script) { + if (step.advanceMs > 0) { + vi.advanceTimersByTime(step.advanceMs) + } + if (step.nextEntry) { + const previousEntries = current + const nextEntries: StatusMap = { + ...previousEntries, + [step.nextEntry.paneKey]: step.nextEntry + } + const evictedEntries: AgentStatusEntry[] = [] + for (const paneKey of step.evictedPaneKeys ?? []) { + const evicted = previousEntries[paneKey] + if (evicted) { + evictedEntries.push(evicted) + delete nextEntries[paneKey] + } + } + current = nextEntries + if (mode === 'cached') { + scheduler.noteLiveEntryDelta({ + previousEntries, + nextEntries, + nextEntry: step.nextEntry, + replacedEntry: previousEntries[step.nextEntry.paneKey], + evictedEntries + }) + } + } + scheduler.schedule() + } + + scheduler.dispose() + setTimeoutSpy.mockRestore() + vi.useRealTimers() + return { armedAt, bumpedAt } +} + +describe('freshness scheduler cached minimum', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('arms the same wake instants and crossings as a full rescan', () => { + const script = buildScript() + + const rescan = runPass('rescan', script) + const cached = runPass('cached', script) + + expect(cached.armedAt).toEqual(rescan.armedAt) + expect(cached.bumpedAt).toEqual(rescan.bumpedAt) + expect(cached.armedAt.length).toBeGreaterThan(0) + }) + + it('answers repeated commits from the cache instead of revisiting every entry', () => { + resetAgentStatusFreshnessScanCounters() + runPass('cached', buildScript()) + const cachedScans = agentStatusFreshnessScanCounters.cachedScans + const cachedVisits = agentStatusFreshnessScanCounters.entryVisits + + resetAgentStatusFreshnessScanCounters() + runPass('rescan', buildScript()) + + expect(cachedScans).toBeGreaterThan(0) + expect(cachedVisits).toBeLessThan(agentStatusFreshnessScanCounters.entryVisits) + }) + + it('falls back to a full rescan when a writer changes the map without reporting it', () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + let current: StatusMap = { 'tab:a': entry('tab:a', { state: 'working' }) } + const bumps: number[] = [] + const scheduler = createFreshnessScheduler({ + getStatusEntries: () => current, + bumpEpochs: () => bumps.push(Date.now()) + }) + scheduler.schedule() + resetAgentStatusFreshnessScanCounters() + + // An unreported replacement: identity no longer matches what the cache was built from. + current = { 'tab:a': entry('tab:a', { state: 'working', updatedAt: NOW + MINUTE }) } + scheduler.schedule() + + expect(agentStatusFreshnessScanCounters.fullScans).toBe(1) + expect(agentStatusFreshnessScanCounters.cachedScans).toBe(0) + scheduler.dispose() + }) +}) + +describe('freshness scheduler stale-boundary equivalence', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('bumps once at the stale boundary whether or not the cache answered', () => { + for (const mode of ['cached', 'rescan'] as const) { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const bumps: number[] = [] + let current: StatusMap = { 'tab:a': entry('tab:a', { state: 'working' }) } + const scheduler = createFreshnessScheduler({ + getStatusEntries: () => (mode === 'cached' ? current : { ...current }), + bumpEpochs: () => bumps.push(Date.now()) + }) + + scheduler.schedule() + scheduler.schedule() + vi.advanceTimersByTime(AGENT_STATUS_STALE_AFTER_MS + 1) + + expect(bumps).toEqual([NOW + AGENT_STATUS_STALE_AFTER_MS + 1]) + scheduler.dispose() + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/store/slices/agent-status-freshness-scheduler.test.ts b/src/renderer/src/store/slices/agent-status-freshness-scheduler.test.ts index 70cef8d055b..6e92db1493a 100644 --- a/src/renderer/src/store/slices/agent-status-freshness-scheduler.test.ts +++ b/src/renderer/src/store/slices/agent-status-freshness-scheduler.test.ts @@ -20,9 +20,16 @@ function doneEntry(overrides: Partial = {}): AgentStatusEntry } } +function statusEntries(entries: AgentStatusEntry[]): Record { + return Object.fromEntries(entries.map((entry, index) => [`${entry.paneKey}#${index}`, entry])) +} + function setup(entries: AgentStatusEntry[]) { const bumpEpochs = vi.fn() - const scheduler = createFreshnessScheduler({ getEntries: () => entries, bumpEpochs }) + const scheduler = createFreshnessScheduler({ + getStatusEntries: () => statusEntries(entries), + bumpEpochs + }) return { bumpEpochs, scheduler } } @@ -145,19 +152,19 @@ describe('freshness scheduler completion deadlines', () => { vi.useFakeTimers() vi.setSystemTime(NOW) const entries = [doneEntry()] - const getEntries = vi.fn(() => entries) + const getStatusEntries = vi.fn(() => statusEntries(entries)) const bumpEpochs = vi.fn() - const scheduler = createFreshnessScheduler({ getEntries, bumpEpochs }) + const scheduler = createFreshnessScheduler({ getStatusEntries, bumpEpochs }) const queueMicrotaskSpy = vi.spyOn(globalThis, 'queueMicrotask') scheduler.scheduleDeferred() scheduler.scheduleDeferred() expect(queueMicrotaskSpy).toHaveBeenCalledTimes(2) - expect(getEntries).not.toHaveBeenCalled() + expect(getStatusEntries).not.toHaveBeenCalled() await flushMicrotasks() - expect(getEntries).toHaveBeenCalledTimes(1) + expect(getStatusEntries).toHaveBeenCalledTimes(1) scheduler.dispose() }) @@ -166,19 +173,19 @@ describe('freshness scheduler completion deadlines', () => { vi.setSystemTime(NOW) let scheduler!: ReturnType let firstRead = true - const getEntries = vi.fn(() => { + const getStatusEntries = vi.fn((): Record => { if (firstRead) { firstRead = false scheduler.scheduleDeferred() } - return [] + return {} }) - scheduler = createFreshnessScheduler({ getEntries, bumpEpochs: vi.fn() }) + scheduler = createFreshnessScheduler({ getStatusEntries, bumpEpochs: vi.fn() }) scheduler.scheduleDeferred() await flushMicrotasks() - expect(getEntries).toHaveBeenCalledTimes(2) + expect(getStatusEntries).toHaveBeenCalledTimes(2) scheduler.dispose() }) }) diff --git a/src/renderer/src/store/slices/agent-status-freshness-scheduler.ts b/src/renderer/src/store/slices/agent-status-freshness-scheduler.ts index c2ed805c3f4..2c778a281f7 100644 --- a/src/renderer/src/store/slices/agent-status-freshness-scheduler.ts +++ b/src/renderer/src/store/slices/agent-status-freshness-scheduler.ts @@ -6,10 +6,23 @@ import { } from '../../../../shared/agent-status-types' export type FreshnessSchedulerDeps = { - getEntries: () => AgentStatusEntry[] + getStatusEntries: () => Record bumpEpochs: () => void } +/** + * One accepted live-map replacement, described well enough to move the cached freshness minimum + * without rescanning the map. `previousEntries` is the map the change was derived from, so a + * writer that never reports its change simply invalidates the cache instead of corrupting it. + */ +export type FreshnessLiveEntryDelta = { + previousEntries: Record + nextEntries: Record + nextEntry: AgentStatusEntry + replacedEntry: AgentStatusEntry | undefined + evictedEntries: readonly AgentStatusEntry[] +} + export type FreshnessScheduler = { schedule: () => void /** @@ -18,6 +31,7 @@ export type FreshnessScheduler = { * request performs the scan. */ scheduleDeferred: () => void + noteLiveEntryDelta: (delta: FreshnessLiveEntryDelta) => void /** * Cancel any pending freshness timer. Intended for tests that create a * fresh store per case — production callers do not need this because the @@ -26,6 +40,42 @@ export type FreshnessScheduler = { dispose: () => void } +/** Deterministic scan accounting for the freshness ratchet test and the freshness benchmark. */ +export const agentStatusFreshnessScanCounters = { + fullScans: 0, + cachedScans: 0, + entryVisits: 0 +} + +export function resetAgentStatusFreshnessScanCounters(): void { + agentStatusFreshnessScanCounters.fullScans = 0 + agentStatusFreshnessScanCounters.cachedScans = 0 + agentStatusFreshnessScanCounters.entryVisits = 0 +} + +type EntryExpiries = { hookExpiryAt: number; completionExpiryAt: number | null } + +function entryExpiries(entry: AgentStatusEntry): EntryExpiries { + const completedAt = agentEntryCompletionAt(entry) + return { + hookExpiryAt: agentStatusEvidenceObservedAt(entry) + AGENT_STATUS_STALE_AFTER_MS, + completionExpiryAt: completedAt === null ? null : completedAt + AGENT_STATUS_STALE_AFTER_MS + } +} + +/** + * Summary of the last full scan. `minExpiryAt` / `minCompletionExpiryAt` are the minima over the + * candidates that were still in the future at `scannedAt`; candidates already past then can never + * re-enter either answer, because time only moves forward and an entry's candidates are fixed. + */ +type FreshnessScanCache = { + entries: Record + scannedAt: number + minExpiryAt: number + minCompletionExpiryAt: number + size: number +} + export function createFreshnessScheduler(deps: FreshnessSchedulerDeps): FreshnessScheduler { // Why: tests that trigger scheduling must use vi.useFakeTimers() or call // `dispose()` in teardown — otherwise a real 30-minute setTimeout leaks @@ -33,6 +83,7 @@ export function createFreshnessScheduler(deps: FreshnessSchedulerDeps): Freshnes let timer: ReturnType | null = null let lastCheckedAt: number | null = null let deferredScheduleGeneration = 0 + let cache: FreshnessScanCache | null = null const clear = (): void => { if (timer !== null) { @@ -51,15 +102,76 @@ export function createFreshnessScheduler(deps: FreshnessSchedulerDeps): Freshnes }) } - const schedule = (): void => { - clear() - const entries = deps.getEntries() + const noteLiveEntryDelta = (delta: FreshnessLiveEntryDelta): void => { + if (cache === null || cache.entries !== delta.previousEntries) { + cache = null + return + } + const departing = + delta.replacedEntry === undefined + ? delta.evictedEntries + : [delta.replacedEntry, ...delta.evictedEntries] + for (const entry of departing) { + const { hookExpiryAt, completionExpiryAt } = entryExpiries(entry) + // A departing row that holds (or ties) a cached minimum leaves it unknowable without a scan. + if ( + hookExpiryAt === cache.minExpiryAt || + (completionExpiryAt !== null && + (completionExpiryAt === cache.minExpiryAt || + completionExpiryAt === cache.minCompletionExpiryAt)) + ) { + cache = null + return + } + } + const { hookExpiryAt, completionExpiryAt } = entryExpiries(delta.nextEntry) + if (hookExpiryAt >= cache.scannedAt) { + cache.minExpiryAt = Math.min(cache.minExpiryAt, hookExpiryAt) + } + if (completionExpiryAt !== null && completionExpiryAt >= cache.scannedAt) { + cache.minExpiryAt = Math.min(cache.minExpiryAt, completionExpiryAt) + cache.minCompletionExpiryAt = Math.min(cache.minCompletionExpiryAt, completionExpiryAt) + } + cache.size = + cache.size - delta.evictedEntries.length + (delta.replacedEntry === undefined ? 1 : 0) + cache.entries = delta.nextEntries + } + + const arm = (nextExpiryAt: number, now: number): void => { + if (!Number.isFinite(nextExpiryAt)) { + return + } + // Why: +1 ms ensures the timer fires strictly after the stale boundary, + // so isExplicitAgentStatusFresh (which uses `<=`) flips to stale when the + // timer runs. Without the +1, float/rounding could leave the entry "just + // fresh enough" at the tick, delaying the epoch bump by one tick. + timer = setTimeout( + () => { + timer = null + deps.bumpEpochs() + lastCheckedAt = Date.now() + schedule() + }, + nextExpiryAt - now + 1 + ) + } + + const scan = (statusEntries: Record, now: number): void => { + agentStatusFreshnessScanCounters.fullScans += 1 + const entries = Object.values(statusEntries) if (entries.length === 0) { + cache = { + entries: statusEntries, + scannedAt: now, + minExpiryAt: Infinity, + minCompletionExpiryAt: Infinity, + size: 0 + } lastCheckedAt = null return } - const now = Date.now() let nextExpiryAt = Number.POSITIVE_INFINITY + let nextCompletionExpiryAt = Number.POSITIVE_INFINITY let crossedCompletionDeadline = false // Why: skip entries already past the stale boundary — they each contribute // exactly one epoch bump at crossing, and rescheduling on them would spin @@ -70,14 +182,13 @@ export function createFreshnessScheduler(deps: FreshnessSchedulerDeps): Freshnes // future timer: the setAgentStatus write already bumped the epoch, so // freshness-aware selectors can decay them immediately on that render. for (const entry of entries) { - const expiryAt = agentStatusEvidenceObservedAt(entry) + AGENT_STATUS_STALE_AFTER_MS - if (expiryAt >= now) { - nextExpiryAt = Math.min(nextExpiryAt, expiryAt) + agentStatusFreshnessScanCounters.entryVisits += 1 + const { hookExpiryAt, completionExpiryAt } = entryExpiries(entry) + if (hookExpiryAt >= now) { + nextExpiryAt = Math.min(nextExpiryAt, hookExpiryAt) } // Completion and hook freshness have independent expiry times. - const completedAt = agentEntryCompletionAt(entry) - if (completedAt !== null) { - const completionExpiryAt = completedAt + AGENT_STATUS_STALE_AFTER_MS + if (completionExpiryAt !== null) { // Detect a missed completion expiry before a same-state update extends hook freshness. if ( lastCheckedAt !== null && @@ -88,34 +199,54 @@ export function createFreshnessScheduler(deps: FreshnessSchedulerDeps): Freshnes } if (completionExpiryAt >= now) { nextExpiryAt = Math.min(nextExpiryAt, completionExpiryAt) + nextCompletionExpiryAt = Math.min(nextCompletionExpiryAt, completionExpiryAt) } } } + cache = { + entries: statusEntries, + scannedAt: now, + minExpiryAt: nextExpiryAt, + minCompletionExpiryAt: nextCompletionExpiryAt, + size: entries.length + } lastCheckedAt = now if (crossedCompletionDeadline) { deps.bumpEpochs() } - if (!Number.isFinite(nextExpiryAt)) { + arm(nextExpiryAt, now) + } + + const schedule = (): void => { + clear() + const statusEntries = deps.getStatusEntries() + const now = Date.now() + // The cached minima answer only while they are still in the future: a minimum that has gone + // past is exactly the case where the surviving candidates — and any crossing — need a rescan. + if ( + cache !== null && + cache.entries === statusEntries && + cache.minExpiryAt >= now && + cache.minCompletionExpiryAt >= now + ) { + agentStatusFreshnessScanCounters.cachedScans += 1 + if (cache.size === 0) { + lastCheckedAt = null + return + } + lastCheckedAt = now + arm(cache.minExpiryAt, now) return } - // Why: +1 ms ensures the timer fires strictly after the stale boundary, - // so isExplicitAgentStatusFresh (which uses `<=`) flips to stale when the - // timer runs. Without the +1, float/rounding could leave the entry "just - // fresh enough" at the tick, delaying the epoch bump by one tick. - const delayMs = nextExpiryAt - now + 1 - timer = setTimeout(() => { - timer = null - deps.bumpEpochs() - lastCheckedAt = Date.now() - schedule() - }, delayMs) + scan(statusEntries, now) } const dispose = (): void => { clear() + cache = null // Invalidate callbacks already queued by scheduleDeferred. deferredScheduleGeneration += 1 } - return { schedule, scheduleDeferred, dispose } + return { schedule, scheduleDeferred, noteLiveEntryDelta, dispose } } diff --git a/src/renderer/src/store/slices/agent-status-live-actions.ts b/src/renderer/src/store/slices/agent-status-live-actions.ts index a9407da5c13..f825abc9d2c 100644 --- a/src/renderer/src/store/slices/agent-status-live-actions.ts +++ b/src/renderer/src/store/slices/agent-status-live-actions.ts @@ -13,6 +13,7 @@ import { type AgentStatusLiveEntryRejection } from './agent-status-live-entry-builder' import { reduceAgentStatusLiveUpdate } from './agent-status-live-reducer' +import type { FreshnessLiveEntryDelta } from './agent-status-freshness-scheduler' import { agentStatusTabAlreadyHasProtectedOrGeneratedTitle, getTabIdFromPaneKey, @@ -28,8 +29,14 @@ import { export function createAgentStatusLiveActions( runtime: AgentStatusRuntime ): Pick { - const { get, set, applyGeneratedTabTitleUpdate, requestFreshness, transactAgentStatuses } = - runtime + const { + get, + set, + applyGeneratedTabTitleUpdate, + freshness, + requestFreshness, + transactAgentStatuses + } = runtime const setAgentStatus = ( rawPaneKey: string, payload: AgentStatusPayload, @@ -51,6 +58,7 @@ export function createAgentStatusLiveActions( return } let built: AgentStatusLiveEntryBuild | AgentStatusLiveEntryRejection | null = null + let liveEntryDelta: FreshnessLiveEntryDelta | null = null set((state) => { built = buildAgentStatusLiveEntry({ state, @@ -62,8 +70,23 @@ export function createAgentStatusLiveActions( metadata, updatedAt }) - return built.entry ? reduceAgentStatusLiveUpdate(state, built, updatedAt) : state + if (!built.entry) { + return state + } + const previousEntries = state.agentStatusByPaneKey + const reduction = reduceAgentStatusLiveUpdate(state, built, updatedAt) + liveEntryDelta = { + previousEntries, + nextEntries: reduction.patch.agentStatusByPaneKey ?? previousEntries, + nextEntry: built.entry, + replacedEntry: previousEntries[built.entry.paneKey], + evictedEntries: reduction.evictedEntries + } + return reduction.patch }) + if (liveEntryDelta) { + freshness.noteLiveEntryDelta(liveEntryDelta) + } // Zustand's updater runs synchronously, but TypeScript cannot observe the closure assignment. const builtResult = built as AgentStatusLiveEntryBuild | AgentStatusLiveEntryRejection | null if (!builtResult?.entry) { diff --git a/src/renderer/src/store/slices/agent-status-live-reducer.ts b/src/renderer/src/store/slices/agent-status-live-reducer.ts index 96c8c0c97b2..1ce8de5f720 100644 --- a/src/renderer/src/store/slices/agent-status-live-reducer.ts +++ b/src/renderer/src/store/slices/agent-status-live-reducer.ts @@ -1,19 +1,28 @@ import type { AppState } from '../types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { capLiveAgentStatusesInPlace, - classifyPaneKeyLiveness + classifyPaneKeyLiveness, + countLiveAgentStatuses, + noteLiveAgentStatusCount } from './agent-status-capacity-eviction' import { removePaneKeys } from './agent-status-pane-keyed-records' import { recoveryRecordMatches } from './agent-status-recovery-equivalence' import type { AgentStatusLiveEntryBuild } from './agent-status-live-entry-builder' import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' +export type AgentStatusLiveUpdateReduction = { + patch: Partial + /** Rows the cap dropped, so freshness can move its cached minimum without a full rescan. */ + evictedEntries: AgentStatusEntry[] +} + /** Apply the map changes associated with an accepted live status row. */ export function reduceAgentStatusLiveUpdate( state: AppState, build: AgentStatusLiveEntryBuild, updatedAt: number -): Partial { +): AgentStatusLiveUpdateReduction { const { entry, existingSleepingRecord, @@ -75,20 +84,32 @@ export function reduceAgentStatusLiveUpdate( nextSleepingAgentSessions = { ...state.sleepingAgentSessionsByPaneKey } delete nextSleepingAgentSessions[paneKey] } - const nextLive = { ...state.agentStatusByPaneKey, [paneKey]: entry } + const previousLive = state.agentStatusByPaneKey + const nextLive = { ...previousLive, [paneKey]: entry } + const nextLiveCount = countLiveAgentStatuses(previousLive) + (paneKey in previousLive ? 0 : 1) const evictedPaneKeys = capLiveAgentStatusesInPlace( nextLive, paneKey, () => classifyPaneKeyLiveness(state), - updatedAt + updatedAt, + undefined, + nextLiveCount ) + noteLiveAgentStatusCount(nextLive, nextLiveCount - evictedPaneKeys.length) const evictedOrphans = evictedPaneKeys.length > 0 + const evictedEntries: AgentStatusEntry[] = [] if (evictedOrphans) { const evicted = new Set(evictedPaneKeys) + for (const evictedPaneKey of evictedPaneKeys) { + const evictedEntry = previousLive[evictedPaneKey] + if (evictedEntry) { + evictedEntries.push(evictedEntry) + } + } nextSleepingAgentSessions = removePaneKeys(nextSleepingAgentSessions, evicted) nextLaunchConfigs = removePaneKeys(nextLaunchConfigs, evicted) } - return { + const patch: Partial = { agentStatusByPaneKey: nextLive, retainedAgentsByPaneKey: nextRetainedAgents, sleepingAgentSessionsByPaneKey: nextSleepingAgentSessions, @@ -104,4 +125,5 @@ export function reduceAgentStatusLiveUpdate( ? state.sortEpoch + 1 : state.sortEpoch } + return { patch, evictedEntries } } diff --git a/src/renderer/src/store/slices/agent-status-runtime.ts b/src/renderer/src/store/slices/agent-status-runtime.ts index 64dc343f122..0716fa633aa 100644 --- a/src/renderer/src/store/slices/agent-status-runtime.ts +++ b/src/renderer/src/store/slices/agent-status-runtime.ts @@ -29,6 +29,10 @@ export function createAgentStatusRuntime( getActions: () => Pick ): AgentStatusRuntime { let batchedAgentStatusState: AppState | null = null + let batchedAgentStatusTouchedKeys: Set | null = null + // Identity can no longer report "this staged update changed something" once the staged object is + // mutated in place, so every accepted staged write advances this instead. + let batchedAgentStatusRevision = 0 let batchedAgentStatusEffects: (() => void)[] | null = null let batchedGeneratedTabTitleUpdates: GeneratedTabTitleUpdate[] | null = null let batchedAgentStatusFreshnessRequested = false @@ -37,15 +41,24 @@ export function createAgentStatusRuntime( // Deliberately narrower than zustand's `set`: no `replace` parameter, so no call site in // this slice can compile into a REPLACE the batch commit is unable to express. const set = (update: AgentStatusStateUpdate): void => { - if (batchedAgentStatusState === null) { + const staged = batchedAgentStatusState + if (staged === null) { storeSet(update, false) return } - const nextState = typeof update === 'function' ? update(batchedAgentStatusState) : update - if (Object.is(nextState, batchedAgentStatusState)) { + const nextState = typeof update === 'function' ? update(staged) : update + if (Object.is(nextState, staged)) { return } - batchedAgentStatusState = Object.assign({}, batchedAgentStatusState, nextState) + batchedAgentStatusRevision += 1 + const touched = batchedAgentStatusTouchedKeys + if (touched) { + for (const key of Object.keys(nextState)) { + touched.add(key as keyof AppState) + } + } + // The staged object is private until commit, so fold into it instead of cloning AppState per update. + Object.assign(staged, nextState) } const runAfterCommit = (effect: () => void): void => { @@ -77,10 +90,10 @@ export function createAgentStatusRuntime( } const applyBatchedAgentStatusUpdate = (update: AgentStatusBatchUpdate): boolean => { - const stateBeforeUpdate = batchedAgentStatusState - if (!stateBeforeUpdate) { + if (!batchedAgentStatusState) { return false } + const revisionBeforeUpdate = batchedAgentStatusRevision const actions = getActions() if (update.kind === 'providerSession') { actions.recordAgentProviderSession( @@ -101,7 +114,7 @@ export function createAgentStatusRuntime( update.metadata ) } - return batchedAgentStatusState !== stateBeforeUpdate + return batchedAgentStatusRevision !== revisionBeforeUpdate } const batchTransaction: AgentStatusBatchTransaction = { @@ -117,7 +130,10 @@ export function createAgentStatusRuntime( return operation(batchTransaction) } const initialState = storeGet() - batchedAgentStatusState = initialState + const touchedKeys = new Set() + const revisionAtStart = batchedAgentStatusRevision + batchedAgentStatusState = { ...initialState } + batchedAgentStatusTouchedKeys = touchedKeys batchedAgentStatusEffects = [] batchedGeneratedTabTitleUpdates = [] try { @@ -126,12 +142,14 @@ export function createAgentStatusRuntime( const effects = batchedAgentStatusEffects const generatedTabTitleUpdates = batchedGeneratedTabTitleUpdates const freshnessRequested = batchedAgentStatusFreshnessRequested + const hasStagedWrites = batchedAgentStatusRevision !== revisionAtStart batchedAgentStatusState = null + batchedAgentStatusTouchedKeys = null batchedAgentStatusEffects = null batchedGeneratedTabTitleUpdates = null batchedAgentStatusFreshnessRequested = false - if (nextState !== initialState) { - storeSet(buildAgentStatusBatchPatch(initialState, nextState), false) + if (hasStagedWrites) { + storeSet(buildAgentStatusBatchPatch(initialState, nextState, touchedKeys), false) } if (generatedTabTitleUpdates.length > 0) { storeGet().setGeneratedTabTitlesFromAgentPrompts(generatedTabTitleUpdates) @@ -145,6 +163,7 @@ export function createAgentStatusRuntime( return result } finally { batchedAgentStatusState = null + batchedAgentStatusTouchedKeys = null batchedAgentStatusEffects = null batchedGeneratedTabTitleUpdates = null batchedAgentStatusFreshnessRequested = false @@ -152,7 +171,7 @@ export function createAgentStatusRuntime( } const freshness = createFreshnessScheduler({ - getEntries: () => Object.values(get().agentStatusByPaneKey), + getStatusEntries: () => get().agentStatusByPaneKey, bumpEpochs: () => { // Why: freshness is time-based — bump both epochs at the stale boundary to force selector // recompute and re-sort even with no new output, since staleness can change worktree ordering. @@ -212,10 +231,12 @@ export function createAgentStatusRuntime( function buildAgentStatusBatchPatch( initialState: AppState, - nextState: AppState + nextState: AppState, + touchedKeys: ReadonlySet ): Partial { const patch: Record = {} - for (const key of Object.keys(nextState) as (keyof AppState)[]) { + // Untouched slices cannot differ, so the patch stays proportional to what the fold actually wrote. + for (const key of touchedKeys) { if (!Object.is(nextState[key], initialState[key])) { patch[key as string] = nextState[key] }