perf(agent-status): memoize pane routing, cache the freshness minimum, stage one clone per transaction (#18323)

This commit is contained in:
Neil
2026-09-02 23:19:38 -07:00
committed by GitHub
parent df420285b0
commit ddb13a10f7
12 changed files with 1124 additions and 84 deletions
@@ -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<T>(run: () => T): T {
class CountingMap<K, V> extends NativeMap<K, V> {
constructor(entries?: readonly (readonly [K, V])[] | null) {
super(entries)
counters.maps += 1
}
}
class CountingSet<V> extends NativeSet<V> {
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<T> extends Array<T> {
[Symbol.iterator](): IterableIterator<T> {
const inner = nativeArrayIterator.call(this) as IterableIterator<T>
const wrapped: IterableIterator<T> = {
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<AppState>)
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<typeof createTestStore>, paneKeys: string[]): void {
for (let event = 0; event < EVENTS; event += 1) {
if (event % BATCH_SIZE === 0) {
store.setState({ agentStatusEpoch: event } as Partial<AppState>)
}
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<void> =>
new Promise((resolve) => {
queueMicrotask(resolve)
})
async function runCommitPass(
store: ReturnType<typeof createTestStore>,
batches: AgentStatusBatchUpdate[][],
onBatch?: (elapsed: number) => void
): Promise<void> {
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<string, number> = {}
// 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)
})
})
@@ -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
@@ -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<typeof createTestStore>): {
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<AppState>)
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<AppState>)
}
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<AppState>)
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<AppState>)
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<AppState>)
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)
})
}
})
})
@@ -22,21 +22,48 @@ type AgentStatusWorktreeConnectionResolution = {
type IndexedAgentStatusTab = {
title: string | undefined
unifiedLabel: string | undefined
owningWorktreeId: string
}
export type AgentStatusPaneRoutingIndex = {
tabsById: Map<string, IndexedAgentStatusTab>
unifiedTabsByWorktree: AppState['unifiedTabsByWorktree']
unifiedLabelsByWorktreeId: Map<string, Map<string, string | undefined>>
layoutsByTabId: AppState['terminalLayoutsByTabId']
leafIdsByRoot: WeakMap<TerminalPaneLayoutNode, Set<string>>
worktreesById: ReturnType<typeof getWorktreeMapFromState>
reposById: ReturnType<typeof getRepoMapFromState>
}
/** 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<TerminalPaneLayoutNode, Set<string>>()
const tabsByIdCache = new WeakMap<AppState['tabsByWorktree'], Map<string, IndexedAgentStatusTab>>()
const unifiedLabelIndexCache = new WeakMap<object, Map<string, Map<string, string | undefined>>>()
const routingIndexCache = new WeakMap<AppState['tabsByWorktree'], AgentStatusPaneRoutingIndex>()
const NO_UNIFIED_TABS = {}
function createUnifiedTerminalLabelIndex(
entries: AppState['unifiedTabsByWorktree'][string] | undefined
): Map<string, string | undefined> {
agentStatusPaneRoutingIndexCounters.unifiedLabelIndexBuilds += 1
const labelsByTabId = new Map<string, string | undefined>()
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<string, IndexedAgentStatusTab> {
const cached = tabsByIdCache.get(tabsByWorktree)
if (cached) {
return cached
}
agentStatusPaneRoutingIndexCounters.tabIndexBuilds += 1
const tabsById = new Map<string, IndexedAgentStatusTab>()
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<string, Map<string, string | undefined>> {
const cacheKey = unifiedTabsByWorktree ?? NO_UNIFIED_TABS
const cached = unifiedLabelIndexCache.get(cacheKey)
if (cached) {
return cached
}
const labelsByWorktreeId = new Map<string, Map<string, string | undefined>>()
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,
@@ -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)
})
})
@@ -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<Record<string, AgentStatusEntry>, number>()
export function countLiveAgentStatuses(entries: Record<string, AgentStatusEntry>): 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<string, AgentStatusEntry>,
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<string, AgentStatusEntry>,
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 => {
@@ -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<string, AgentStatusEntry>
function entry(paneKey: string, overrides: Partial<AgentStatusEntry> = {}): 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()
}
})
})
@@ -20,9 +20,16 @@ function doneEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry
}
}
function statusEntries(entries: AgentStatusEntry[]): Record<string, AgentStatusEntry> {
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<typeof createFreshnessScheduler>
let firstRead = true
const getEntries = vi.fn(() => {
const getStatusEntries = vi.fn((): Record<string, AgentStatusEntry> => {
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()
})
})
@@ -6,10 +6,23 @@ import {
} from '../../../../shared/agent-status-types'
export type FreshnessSchedulerDeps = {
getEntries: () => AgentStatusEntry[]
getStatusEntries: () => Record<string, AgentStatusEntry>
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<string, AgentStatusEntry>
nextEntries: Record<string, AgentStatusEntry>
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<string, AgentStatusEntry>
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<typeof setTimeout> | 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<string, AgentStatusEntry>, 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 }
}
@@ -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<AgentStatusSlice, 'setAgentStatus' | 'setAgentStatuses' | 'transactAgentStatuses'> {
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) {
@@ -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<AppState>
/** 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<AppState> {
): 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<AppState> = {
agentStatusByPaneKey: nextLive,
retainedAgentsByPaneKey: nextRetainedAgents,
sleepingAgentSessionsByPaneKey: nextSleepingAgentSessions,
@@ -104,4 +125,5 @@ export function reduceAgentStatusLiveUpdate(
? state.sortEpoch + 1
: state.sortEpoch
}
return { patch, evictedEntries }
}
@@ -29,6 +29,10 @@ export function createAgentStatusRuntime(
getActions: () => Pick<AgentStatusSlice, 'setAgentStatus' | 'recordAgentProviderSession'>
): AgentStatusRuntime {
let batchedAgentStatusState: AppState | null = null
let batchedAgentStatusTouchedKeys: Set<keyof AppState> | 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<keyof AppState>()
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<keyof AppState>
): Partial<AppState> {
const patch: Record<string, unknown> = {}
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]
}