mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 00:03:37 +00:00
perf(renderer): patch sidebar live-agent index on within-state status pings instead of full rebuild
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -7,6 +7,7 @@ import type { TerminalTab } from '../../../../shared/types'
|
||||
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import {
|
||||
getLiveEntriesFullRebuildCountForTests,
|
||||
selectLiveAgentStatusEntriesForWorktree,
|
||||
selectMigrationUnsupportedEntriesForWorktree,
|
||||
selectRuntimeAgentOrchestrationForWorktree,
|
||||
@@ -151,6 +152,109 @@ describe('selectLiveAgentStatusEntriesForWorktree', () => {
|
||||
expect(selectLiveAgentStatusEntriesForWorktree(state, 'wt-1')).toEqual([childEntry])
|
||||
})
|
||||
|
||||
it('patches instead of full-rebuilding across within-state pings, and stays correct on transitions', () => {
|
||||
const wt1Entry = makeEntry(PANE_KEY_1, 1000, { state: 'working', prompt: 'wt1 prompt' })
|
||||
const wt2Entry = makeEntry(PANE_KEY_2, 1000, { state: 'working', prompt: 'wt2 prompt' })
|
||||
const baseState = {
|
||||
tabsByWorktree: {
|
||||
'wt-1': [makeTab('tab-1')],
|
||||
'wt-2': [makeTab('tab-2')]
|
||||
},
|
||||
agentStatusByPaneKey: {
|
||||
[PANE_KEY_1]: wt1Entry,
|
||||
[PANE_KEY_2]: wt2Entry
|
||||
},
|
||||
migrationUnsupportedByPtyId: {},
|
||||
retainedAgentsByPaneKey: {}
|
||||
}
|
||||
|
||||
// Prime the cache (one full rebuild allowed here).
|
||||
const primedWt1 = selectLiveAgentStatusEntriesForWorktree(baseState, 'wt-1')
|
||||
const primedWt2 = selectLiveAgentStatusEntriesForWorktree(baseState, 'wt-2')
|
||||
const rebuildsAfterPrime = getLiveEntriesFullRebuildCountForTests()
|
||||
|
||||
// Simulate a burst of same-state pings: setAgentStatus mints a new map and
|
||||
// a new entry object per ping, with only prompt/tool metadata changing.
|
||||
let state = baseState
|
||||
let latestWt2 = primedWt2
|
||||
for (let ping = 0; ping < 50; ping += 1) {
|
||||
state = {
|
||||
...state,
|
||||
agentStatusByPaneKey: {
|
||||
...state.agentStatusByPaneKey,
|
||||
[PANE_KEY_2]: {
|
||||
...wt2Entry,
|
||||
prompt: `wt2 prompt ${ping}`,
|
||||
toolName: 'Bash',
|
||||
toolInput: `cmd ${ping}`,
|
||||
updatedAt: 1000 + ping
|
||||
}
|
||||
}
|
||||
}
|
||||
const wt1 = selectLiveAgentStatusEntriesForWorktree(state, 'wt-1')
|
||||
latestWt2 = selectLiveAgentStatusEntriesForWorktree(state, 'wt-2')
|
||||
// Unaffected worktree keeps identity; owning worktree serves the fresh entry.
|
||||
expect(wt1).toBe(primedWt1)
|
||||
expect(latestWt2).toHaveLength(1)
|
||||
expect(latestWt2[0]?.prompt).toBe(`wt2 prompt ${ping}`)
|
||||
expect(latestWt2[0]?.toolInput).toBe(`cmd ${ping}`)
|
||||
}
|
||||
// The O(all live agents) rebuild body never ran for within-state pings.
|
||||
expect(getLiveEntriesFullRebuildCountForTests()).toBe(rebuildsAfterPrime)
|
||||
expect(latestWt2).not.toBe(primedWt2)
|
||||
|
||||
// A real transition that changes bucketing (working -> done with the tab
|
||||
// still present keeps the bucket; removing the entry must drop it).
|
||||
const doneState = {
|
||||
...state,
|
||||
agentStatusByPaneKey: {
|
||||
[PANE_KEY_1]: state.agentStatusByPaneKey[PANE_KEY_1]
|
||||
}
|
||||
}
|
||||
expect(selectLiveAgentStatusEntriesForWorktree(doneState, 'wt-2')).toEqual([])
|
||||
expect(selectLiveAgentStatusEntriesForWorktree(doneState, 'wt-1')).toEqual([wt1Entry])
|
||||
expect(getLiveEntriesFullRebuildCountForTests()).toBe(rebuildsAfterPrime + 1)
|
||||
})
|
||||
|
||||
it('falls back to a full rebuild when a within-map update changes worktree attribution', () => {
|
||||
const entry = makeEntry(PANE_KEY_1, 1000, { state: 'working', worktreeId: 'wt-1' })
|
||||
const state = {
|
||||
// No tab membership: bucketing comes from entry.worktreeId attribution.
|
||||
tabsByWorktree: { 'wt-1': [], 'wt-2': [] },
|
||||
agentStatusByPaneKey: { [PANE_KEY_1]: entry },
|
||||
migrationUnsupportedByPtyId: {},
|
||||
retainedAgentsByPaneKey: {}
|
||||
}
|
||||
expect(selectLiveAgentStatusEntriesForWorktree(state, 'wt-1')).toEqual([entry])
|
||||
|
||||
const moved = { ...entry, worktreeId: 'wt-2' }
|
||||
const nextState = {
|
||||
...state,
|
||||
agentStatusByPaneKey: { [PANE_KEY_1]: moved }
|
||||
}
|
||||
expect(selectLiveAgentStatusEntriesForWorktree(nextState, 'wt-1')).toEqual([])
|
||||
expect(selectLiveAgentStatusEntriesForWorktree(nextState, 'wt-2')).toEqual([moved])
|
||||
})
|
||||
|
||||
it('falls back to a full rebuild when a live entry completes with its tab gone', () => {
|
||||
const entry = makeEntry(PANE_KEY_1, 1000, { state: 'working', worktreeId: 'wt-1' })
|
||||
const state = {
|
||||
tabsByWorktree: { 'wt-1': [] },
|
||||
agentStatusByPaneKey: { [PANE_KEY_1]: entry },
|
||||
migrationUnsupportedByPtyId: {},
|
||||
retainedAgentsByPaneKey: {}
|
||||
}
|
||||
expect(selectLiveAgentStatusEntriesForWorktree(state, 'wt-1')).toEqual([entry])
|
||||
|
||||
// done + tab absent must drop the row (bucket rule), not be patched in place.
|
||||
const done = { ...entry, state: 'done' as const }
|
||||
const nextState = {
|
||||
...state,
|
||||
agentStatusByPaneKey: { [PANE_KEY_1]: done }
|
||||
}
|
||||
expect(selectLiveAgentStatusEntriesForWorktree(nextState, 'wt-1')).toEqual([])
|
||||
})
|
||||
|
||||
it('does not use worktree attribution for a completed row whose tab is gone', () => {
|
||||
const closedEntry = makeEntry(PANE_KEY_1, 1000, {
|
||||
state: 'done',
|
||||
|
||||
@@ -51,6 +51,13 @@ let liveEntriesByWorktreeCache: LiveEntriesByWorktreeCache | null = null
|
||||
let migrationUnsupportedByWorktreeCache: MigrationUnsupportedByWorktreeCache | null = null
|
||||
let retainedEntriesByWorktreeCache: RetainedEntriesByWorktreeCache | null = null
|
||||
|
||||
// Why: test-only observability — proves same-key entry updates (per-ping
|
||||
// setAgentStatus map churn) take the O(changed) patch path, not a full rebuild.
|
||||
let liveEntriesFullRebuildCount = 0
|
||||
export function getLiveEntriesFullRebuildCountForTests(): number {
|
||||
return liveEntriesFullRebuildCount
|
||||
}
|
||||
|
||||
function reuseArrayIfEqual<T>(previous: T[] | undefined, next: T[]): T[] {
|
||||
if (!previous || previous.length !== next.length) {
|
||||
return next
|
||||
@@ -79,6 +86,90 @@ function getTabIdToWorktreeId(
|
||||
return tabIdToWorktreeId
|
||||
}
|
||||
|
||||
// Why: keep early attributed child rows, but hide completed rows once their tab is gone.
|
||||
function liveEntryWorktreeId(
|
||||
paneKey: string,
|
||||
entry: AgentStatusEntry,
|
||||
tabIdToWorktreeId: Map<string, string>
|
||||
): string | undefined {
|
||||
const parsed = parsePaneKey(paneKey)
|
||||
if (!parsed) {
|
||||
return undefined
|
||||
}
|
||||
const tabWorktreeId = tabIdToWorktreeId.get(parsed.tabId)
|
||||
return tabWorktreeId ?? (entry.state === 'done' ? undefined : entry.worktreeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches the cached by-worktree index in place of a full rebuild when the
|
||||
* live map changed only by replacing entries under existing pane keys with
|
||||
* the same bucketing (worktree attribution and done-ness).
|
||||
*
|
||||
* Why: setAgentStatus mints a new agentStatusByPaneKey on EVERY status ping,
|
||||
* including same-state working prompt/tool updates. Rebuilding the whole
|
||||
* index (parsePaneKey + bucketing across all live agents) per ping is the
|
||||
* dominant selector cost under parallel agents; a within-state ping only
|
||||
* needs the owning worktree's bucket refreshed.
|
||||
*/
|
||||
function patchLiveEntriesByWorktree(
|
||||
cache: LiveEntriesByWorktreeCache,
|
||||
agentStatusByPaneKey: WorktreeAgentRowsState['agentStatusByPaneKey'],
|
||||
tabIdToWorktreeId: Map<string, string>
|
||||
): Map<string, AgentStatusEntry[]> | null {
|
||||
const previousMap = cache.agentStatusByPaneKey
|
||||
const changed: Array<{ paneKey: string; entry: AgentStatusEntry }> = []
|
||||
let keyCount = 0
|
||||
for (const paneKey in agentStatusByPaneKey) {
|
||||
keyCount += 1
|
||||
const entry = agentStatusByPaneKey[paneKey]
|
||||
const previous = previousMap[paneKey]
|
||||
if (previous === entry) {
|
||||
continue
|
||||
}
|
||||
// Why: bail on added keys or bucket-determinant changes — the bucket rule
|
||||
// depends only on paneKey, the (reference-equal) tab index, worktree
|
||||
// attribution, and done-ness, so equal determinants mean the same bucket.
|
||||
if (
|
||||
previous === undefined ||
|
||||
previous.worktreeId !== entry.worktreeId ||
|
||||
(previous.state === 'done') !== (entry.state === 'done')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
changed.push({ paneKey, entry })
|
||||
}
|
||||
if (keyCount !== Object.keys(previousMap).length) {
|
||||
// Why: removed keys need buckets dropped; leave that to the full rebuild.
|
||||
return null
|
||||
}
|
||||
if (changed.length === 0) {
|
||||
return cache.entriesByWorktree
|
||||
}
|
||||
|
||||
const entriesByWorktree = new Map(cache.entriesByWorktree)
|
||||
const clonedBuckets = new Set<string>()
|
||||
for (const { paneKey, entry } of changed) {
|
||||
const worktreeId = liveEntryWorktreeId(paneKey, entry, tabIdToWorktreeId)
|
||||
if (!worktreeId) {
|
||||
continue
|
||||
}
|
||||
const bucket = entriesByWorktree.get(worktreeId)
|
||||
const index = bucket?.indexOf(previousMap[paneKey]) ?? -1
|
||||
if (!bucket || index < 0) {
|
||||
return null
|
||||
}
|
||||
const nextBucket = clonedBuckets.has(worktreeId) ? bucket : bucket.slice()
|
||||
// Why: in-position replacement preserves iteration order, matching what a
|
||||
// full rebuild would produce (spread updates keep object insertion order).
|
||||
nextBucket[index] = entry
|
||||
if (!clonedBuckets.has(worktreeId)) {
|
||||
clonedBuckets.add(worktreeId)
|
||||
entriesByWorktree.set(worktreeId, nextBucket)
|
||||
}
|
||||
}
|
||||
return entriesByWorktree
|
||||
}
|
||||
|
||||
function getLiveEntriesByWorktree(state: WorktreeAgentRowsState): Map<string, AgentStatusEntry[]> {
|
||||
const agentStatusByPaneKey = state.agentStatusByPaneKey ?? EMPTY_RECORD
|
||||
const tabsByWorktree = state.tabsByWorktree ?? EMPTY_RECORD
|
||||
@@ -90,16 +181,26 @@ function getLiveEntriesByWorktree(state: WorktreeAgentRowsState): Map<string, Ag
|
||||
}
|
||||
|
||||
const tabIdToWorktreeId = getTabIdToWorktreeId(tabsByWorktree)
|
||||
if (liveEntriesByWorktreeCache?.tabsByWorktree === tabsByWorktree) {
|
||||
const patched = patchLiveEntriesByWorktree(
|
||||
liveEntriesByWorktreeCache,
|
||||
agentStatusByPaneKey,
|
||||
tabIdToWorktreeId
|
||||
)
|
||||
if (patched) {
|
||||
liveEntriesByWorktreeCache = {
|
||||
tabsByWorktree,
|
||||
agentStatusByPaneKey,
|
||||
entriesByWorktree: patched
|
||||
}
|
||||
return patched
|
||||
}
|
||||
}
|
||||
liveEntriesFullRebuildCount += 1
|
||||
const previous = liveEntriesByWorktreeCache?.entriesByWorktree
|
||||
const entriesByWorktree = new Map<string, AgentStatusEntry[]>()
|
||||
for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) {
|
||||
const parsed = parsePaneKey(paneKey)
|
||||
if (!parsed) {
|
||||
continue
|
||||
}
|
||||
const tabWorktreeId = tabIdToWorktreeId.get(parsed.tabId)
|
||||
// Why: keep early attributed child rows, but hide completed rows once their tab is gone.
|
||||
const worktreeId = tabWorktreeId ?? (entry.state === 'done' ? undefined : entry.worktreeId)
|
||||
const worktreeId = liveEntryWorktreeId(paneKey, entry, tabIdToWorktreeId)
|
||||
if (!worktreeId) {
|
||||
continue
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user