mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
perf(agent-status): memoize pane routing, cache the freshness minimum, stage one clone per transaction (#18323)
This commit is contained in:
@@ -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]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user