mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
[perf-remote] perf(renderer): dedupe remote status hydration (#13705)
* perf(renderer): dedupe remote status hydration * fix(runtime-status): re-list host catalog when the listing goes stale Coverage matching cannot observe catalog edits made by another client or the orca CLI, so gate hydration on catalog listing age too. Co-authored-by: Orca <help@stably.ai> * test(runtime-status): reset the catalog listing clock between tests The staleness guard keeps its last-listed timestamp in module scope, so the TTL test left fake time behind and made every following test in the file depend on its position. Add a reset hook, call it in beforeEach, and pin the TTL invariant on an externally added host rather than the timer alone. Also extracts the coverage predicate in fetchSettings and restores the WHY comment about compat failures clearing on a reachable status. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
|
||||
|
||||
/** Matches the renderer's other catalog-style cache TTLs (checks, work items, Jira). */
|
||||
export const RUNTIME_CATALOG_STALE_MS = 60_000
|
||||
|
||||
let lastCatalogListedAt = 0
|
||||
|
||||
/** Why: status coverage cannot observe catalog edits made by another client or the
|
||||
* orca CLI, so an old-enough listing must be re-read even when coverage looks complete. */
|
||||
export function isRuntimeCatalogListingStale(): boolean {
|
||||
return Date.now() - lastCatalogListedAt > RUNTIME_CATALOG_STALE_MS
|
||||
}
|
||||
|
||||
/** Why: this timestamp is module-global, so without a reset one test's clock leaks
|
||||
* into every test after it and makes list-count assertions order-dependent. */
|
||||
export function resetRuntimeCatalogListingForTests(): void {
|
||||
lastCatalogListedAt = 0
|
||||
}
|
||||
|
||||
type RuntimeStatusHydrationDependencies = {
|
||||
listEnvironments: () => Promise<PublicKnownRuntimeEnvironment[]>
|
||||
getCurrentEnvironments: () => PublicKnownRuntimeEnvironment[]
|
||||
publishEnvironments: (environments: PublicKnownRuntimeEnvironment[]) => void
|
||||
refreshEnvironmentStatus: (environmentId: string) => Promise<boolean>
|
||||
markCatalogSettled: () => void
|
||||
}
|
||||
|
||||
function environmentRevisions(
|
||||
environments: PublicKnownRuntimeEnvironment[]
|
||||
): ReadonlyMap<string, number> {
|
||||
return new Map(
|
||||
environments.map((environment) => [
|
||||
environment.id,
|
||||
environment.pairingRevision ?? environment.createdAt
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
function revisionsMatch(
|
||||
environments: PublicKnownRuntimeEnvironment[],
|
||||
expected: ReadonlyMap<string, number>
|
||||
): boolean {
|
||||
const current = environmentRevisions(environments)
|
||||
if (current.size !== expected.size) {
|
||||
return false
|
||||
}
|
||||
for (const [environmentId, revision] of current) {
|
||||
if (expected.get(environmentId) !== revision) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function createRuntimeStatusHydration({
|
||||
listEnvironments,
|
||||
getCurrentEnvironments,
|
||||
publishEnvironments,
|
||||
refreshEnvironmentStatus,
|
||||
markCatalogSettled
|
||||
}: RuntimeStatusHydrationDependencies): () => Promise<void> {
|
||||
let inFlight: Promise<void> | null = null
|
||||
let expectedRevisions: ReadonlyMap<string, number> | null = null
|
||||
let rerunRequested = false
|
||||
|
||||
return () => {
|
||||
if (inFlight) {
|
||||
if (expectedRevisions && !revisionsMatch(getCurrentEnvironments(), expectedRevisions)) {
|
||||
rerunRequested = true
|
||||
}
|
||||
return inFlight
|
||||
}
|
||||
const hydration = (async (): Promise<void> => {
|
||||
// Catalog changes queue a current-catalog pass without duplicating stable overlaps.
|
||||
do {
|
||||
rerunRequested = false
|
||||
const revisionsAtListStart = environmentRevisions(getCurrentEnvironments())
|
||||
expectedRevisions = revisionsAtListStart
|
||||
let environments: PublicKnownRuntimeEnvironment[]
|
||||
try {
|
||||
environments = await listEnvironments()
|
||||
} catch (err) {
|
||||
console.error('Failed to list runtime environments for status hydration:', err)
|
||||
markCatalogSettled()
|
||||
return
|
||||
}
|
||||
lastCatalogListedAt = Date.now()
|
||||
if (!revisionsMatch(getCurrentEnvironments(), revisionsAtListStart)) {
|
||||
rerunRequested = true
|
||||
continue
|
||||
}
|
||||
expectedRevisions = environmentRevisions(environments)
|
||||
publishEnvironments(environments)
|
||||
await Promise.allSettled(
|
||||
environments.map((environment) => refreshEnvironmentStatus(environment.id))
|
||||
)
|
||||
} while (
|
||||
rerunRequested ||
|
||||
(expectedRevisions && !revisionsMatch(getCurrentEnvironments(), expectedRevisions))
|
||||
)
|
||||
})()
|
||||
inFlight = hydration.finally(() => {
|
||||
inFlight = null
|
||||
expectedRevisions = null
|
||||
rerunRequested = false
|
||||
})
|
||||
return inFlight
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { RuntimeStatus } from '../../../../shared/runtime-types'
|
||||
import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client'
|
||||
import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision'
|
||||
|
||||
export async function refreshRuntimeEnvironmentStatus(
|
||||
environmentId: string,
|
||||
timeoutMs: number,
|
||||
publish: (status: RuntimeStatus | null) => void
|
||||
): Promise<boolean> {
|
||||
const expectedEnvironmentRevision = getRuntimeEnvironmentRevision(environmentId)
|
||||
try {
|
||||
const response = await window.api.runtimeEnvironments.getStatus({
|
||||
selector: environmentId,
|
||||
timeoutMs
|
||||
})
|
||||
const status = unwrapRuntimeRpcResult<RuntimeStatus>(response)
|
||||
if (getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentRevision) {
|
||||
return false
|
||||
}
|
||||
publish(status)
|
||||
return true
|
||||
} catch {
|
||||
if (getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentRevision) {
|
||||
return false
|
||||
}
|
||||
publish(null)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,16 @@ function stubRuntimeEnvironmentApi({
|
||||
return { getStatus, list }
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve: (value: T) => void = () => {}
|
||||
let reject: (reason?: unknown) => void = () => {}
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve
|
||||
reject = promiseReject
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeEnvironmentConnectionGenerationsForTests()
|
||||
vi.mocked(toast.warning).mockReset()
|
||||
@@ -361,6 +371,28 @@ describe('runtime-status slice', () => {
|
||||
expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration).toBe(1)
|
||||
})
|
||||
|
||||
it.each(['success', 'failure'] as const)(
|
||||
'drops a stale refresh %s after the same environment id is re-paired',
|
||||
async (outcome) => {
|
||||
const probe = deferred<ReturnType<typeof createCompatibleRuntimeStatusResponse>>()
|
||||
const getStatus = vi.fn().mockReturnValue(probe.promise)
|
||||
stubRuntimeEnvironmentApi({ getStatus })
|
||||
const store = createSliceStore()
|
||||
store.getState().setRuntimeEnvironments([makeEnvironment({ pairingRevision: 1 })])
|
||||
|
||||
const refresh = store.getState().refreshRuntimeEnvironmentStatus('env-a')
|
||||
store.getState().setRuntimeEnvironments([makeEnvironment({ pairingRevision: 2 })])
|
||||
if (outcome === 'success') {
|
||||
probe.resolve(createCompatibleRuntimeStatusResponse('runtime-old'))
|
||||
} else {
|
||||
probe.reject(new Error('old connection closed'))
|
||||
}
|
||||
|
||||
await expect(refresh).resolves.toBe(false)
|
||||
expect(store.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('advances connection generation after recovery without churning stable status polls', () => {
|
||||
const store = createSliceStore()
|
||||
store.getState().setRuntimeEnvironmentStatus('env-a', {
|
||||
@@ -590,20 +622,169 @@ describe('runtime-status slice', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('shares one full catalog and status sweep across overlapping hydrations', async () => {
|
||||
const environments = [makeEnvironment(), makeEnvironment({ id: 'env-b', name: 'Build Box' })]
|
||||
const probeA = deferred<ReturnType<typeof createCompatibleRuntimeStatusResponse>>()
|
||||
const probeB = deferred<ReturnType<typeof createCompatibleRuntimeStatusResponse>>()
|
||||
const getStatus = vi.fn(({ selector }: { selector: string }) =>
|
||||
selector === 'env-a' ? probeA.promise : probeB.promise
|
||||
)
|
||||
const list = vi.fn().mockResolvedValue(environments)
|
||||
stubRuntimeEnvironmentApi({ getStatus, list })
|
||||
const store = createSliceStore()
|
||||
let publications = 0
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
publications += 1
|
||||
})
|
||||
|
||||
const first = store.getState().hydrateRuntimeEnvironmentStatuses()
|
||||
const second = store.getState().hydrateRuntimeEnvironmentStatuses()
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
expect(getStatus).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(getStatus).toHaveBeenCalledTimes(2))
|
||||
const third = store.getState().hydrateRuntimeEnvironmentStatuses()
|
||||
|
||||
probeA.resolve(createCompatibleRuntimeStatusResponse('runtime-a'))
|
||||
probeB.reject(new Error('offline'))
|
||||
await Promise.all([first, second, third])
|
||||
unsubscribe()
|
||||
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
expect(getStatus).toHaveBeenCalledTimes(2)
|
||||
expect(publications).toBe(3)
|
||||
expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.runtimeId).toBe(
|
||||
'runtime-a'
|
||||
)
|
||||
expect(store.getState().runtimeStatusByEnvironmentId.get('env-b')?.status).toBeNull()
|
||||
})
|
||||
|
||||
it('runs a fresh explicit hydration after the shared sweep settles', async () => {
|
||||
const getStatus = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(createCompatibleRuntimeStatusResponse('runtime-1'))
|
||||
.mockResolvedValueOnce(createCompatibleRuntimeStatusResponse('runtime-2'))
|
||||
const list = vi.fn().mockResolvedValue([makeEnvironment()])
|
||||
stubRuntimeEnvironmentApi({ getStatus, list })
|
||||
const store = createSliceStore()
|
||||
let publications = 0
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
publications += 1
|
||||
})
|
||||
|
||||
await store.getState().hydrateRuntimeEnvironmentStatuses()
|
||||
await store.getState().hydrateRuntimeEnvironmentStatuses()
|
||||
unsubscribe()
|
||||
|
||||
expect(list).toHaveBeenCalledTimes(2)
|
||||
expect(getStatus).toHaveBeenCalledTimes(2)
|
||||
expect(publications).toBe(4)
|
||||
expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.runtimeId).toBe(
|
||||
'runtime-2'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not share hydration work between stores', async () => {
|
||||
const list = vi.fn().mockResolvedValue([])
|
||||
stubRuntimeEnvironmentApi({ getStatus: vi.fn(), list })
|
||||
const firstStore = createSliceStore()
|
||||
const secondStore = createSliceStore()
|
||||
|
||||
await Promise.all([
|
||||
firstStore.getState().hydrateRuntimeEnvironmentStatuses(),
|
||||
secondStore.getState().hydrateRuntimeEnvironmentStatuses()
|
||||
])
|
||||
|
||||
expect(list).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('queues a current-catalog sweep when the catalog changes during listing', async () => {
|
||||
const environmentA = makeEnvironment({ pairingRevision: 1 })
|
||||
const repairedEnvironmentA = makeEnvironment({ pairingRevision: 2 })
|
||||
const firstCatalog = deferred<PublicKnownRuntimeEnvironment[]>()
|
||||
const secondCatalog = deferred<PublicKnownRuntimeEnvironment[]>()
|
||||
const getStatus = vi
|
||||
.fn()
|
||||
.mockResolvedValue(createCompatibleRuntimeStatusResponse('runtime-current'))
|
||||
const list = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstCatalog.promise)
|
||||
.mockReturnValueOnce(secondCatalog.promise)
|
||||
stubRuntimeEnvironmentApi({ getStatus, list })
|
||||
const store = createSliceStore()
|
||||
store.getState().setRuntimeEnvironments([environmentA])
|
||||
|
||||
const hydration = store.getState().hydrateRuntimeEnvironmentStatuses()
|
||||
store.getState().setRuntimeEnvironments([repairedEnvironmentA])
|
||||
firstCatalog.resolve([environmentA])
|
||||
await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(2))
|
||||
|
||||
expect(getStatus).not.toHaveBeenCalled()
|
||||
expect(store.getState().runtimeEnvironments).toEqual([repairedEnvironmentA])
|
||||
|
||||
secondCatalog.resolve([repairedEnvironmentA])
|
||||
await hydration
|
||||
|
||||
expect(list).toHaveBeenCalledTimes(2)
|
||||
expect(getStatus).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(true)
|
||||
})
|
||||
|
||||
it('queues one current-catalog sweep when a host is removed during probing', async () => {
|
||||
const environmentA = makeEnvironment()
|
||||
const environmentB = makeEnvironment({ id: 'env-b', name: 'Build Box' })
|
||||
const firstProbe = deferred<ReturnType<typeof createCompatibleRuntimeStatusResponse>>()
|
||||
const getStatus = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => firstProbe.promise)
|
||||
.mockResolvedValue(createCompatibleRuntimeStatusResponse('runtime-current'))
|
||||
const list = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([environmentA, environmentB])
|
||||
.mockResolvedValueOnce([environmentA])
|
||||
stubRuntimeEnvironmentApi({ getStatus, list })
|
||||
const store = createSliceStore()
|
||||
|
||||
const first = store.getState().hydrateRuntimeEnvironmentStatuses()
|
||||
await vi.waitFor(() => expect(getStatus).toHaveBeenCalledTimes(2))
|
||||
store.getState().setRuntimeEnvironments([environmentA])
|
||||
const joined = store.getState().hydrateRuntimeEnvironmentStatuses()
|
||||
firstProbe.resolve(createCompatibleRuntimeStatusResponse('runtime-old'))
|
||||
await Promise.all([first, joined])
|
||||
|
||||
expect(list).toHaveBeenCalledTimes(2)
|
||||
expect(getStatus).toHaveBeenCalledTimes(3)
|
||||
expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.runtimeId).toBe(
|
||||
'runtime-current'
|
||||
)
|
||||
expect(store.getState().runtimeStatusByEnvironmentId.has('env-b')).toBe(false)
|
||||
})
|
||||
|
||||
// Why: skill discovery waits for the catalog to settle. A rejected read must
|
||||
// release that wait without claiming the catalog is hydrated — host routing
|
||||
// uses `runtimeEnvironmentCatalogHydrated` to fail closed on an unknown
|
||||
// catalog, and an empty stale list must not be mistaken for "no runtimes".
|
||||
it('settles but does not hydrate the catalog when the read fails', async () => {
|
||||
const list = vi.fn().mockRejectedValue(new Error('unreadable environments.json'))
|
||||
it('settles failed catalog reads and allows a later hydration retry', async () => {
|
||||
const list = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('unreadable environments.json'))
|
||||
.mockResolvedValueOnce([])
|
||||
stubRuntimeEnvironmentApi({ getStatus: vi.fn(), list })
|
||||
const store = createSliceStore()
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
await store.getState().hydrateRuntimeEnvironmentStatuses()
|
||||
try {
|
||||
await store.getState().hydrateRuntimeEnvironmentStatuses()
|
||||
|
||||
expect(store.getState().runtimeEnvironmentCatalogSettled).toBe(true)
|
||||
expect(store.getState().runtimeEnvironmentCatalogHydrated).toBe(false)
|
||||
expect(store.getState().runtimeEnvironments).toEqual([])
|
||||
expect(store.getState().runtimeEnvironmentCatalogSettled).toBe(true)
|
||||
expect(store.getState().runtimeEnvironmentCatalogHydrated).toBe(false)
|
||||
expect(store.getState().runtimeEnvironments).toEqual([])
|
||||
|
||||
await store.getState().hydrateRuntimeEnvironmentStatuses()
|
||||
expect(list).toHaveBeenCalledTimes(2)
|
||||
expect(store.getState().runtimeEnvironmentCatalogHydrated).toBe(true)
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('both settles and hydrates the catalog on a successful read', async () => {
|
||||
|
||||
@@ -5,12 +5,13 @@ import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-e
|
||||
import type { RuntimeStatus } from '../../../../shared/runtime-types'
|
||||
import {
|
||||
clearRecentRuntimeCompatibilityFailure,
|
||||
clearRuntimeCompatibilityCache,
|
||||
unwrapRuntimeRpcResult
|
||||
clearRuntimeCompatibilityCache
|
||||
} from '@/runtime/runtime-rpc-client'
|
||||
import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { bumpProviderRuntimeSessionGeneration } from '@/lib/provider-runtime-context'
|
||||
import { createRuntimeStatusHydration } from './runtime-status-hydration'
|
||||
import { refreshRuntimeEnvironmentStatus } from './runtime-status-refresh'
|
||||
|
||||
/** Live status for one saved runtime environment, as last observed by the
|
||||
* renderer. `status === null` records a probe that failed or timed out so the
|
||||
@@ -323,43 +324,20 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
|
||||
})
|
||||
},
|
||||
|
||||
refreshRuntimeEnvironmentStatus: async (environmentId, timeoutMs = 10_000) => {
|
||||
try {
|
||||
const response = await window.api.runtimeEnvironments.getStatus({
|
||||
selector: environmentId,
|
||||
timeoutMs
|
||||
})
|
||||
const status = unwrapRuntimeRpcResult<RuntimeStatus>(response)
|
||||
// setRuntimeEnvironmentStatus drops any stale compat failure on a non-null
|
||||
refreshRuntimeEnvironmentStatus: (environmentId, timeoutMs = 10_000) =>
|
||||
refreshRuntimeEnvironmentStatus(environmentId, timeoutMs, (status) => {
|
||||
// Why: setRuntimeEnvironmentStatus drops any stale compat failure on a non-null
|
||||
// (reachable) status, so a recovered host's reuse-flagged refetches re-probe.
|
||||
get().setRuntimeEnvironmentStatus(environmentId, { status, checkedAt: Date.now() })
|
||||
return true
|
||||
} catch {
|
||||
get().setRuntimeEnvironmentStatus(environmentId, {
|
||||
status: null,
|
||||
checkedAt: Date.now()
|
||||
})
|
||||
return false
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
hydrateRuntimeEnvironmentStatuses: async () => {
|
||||
let environments: PublicKnownRuntimeEnvironment[]
|
||||
try {
|
||||
environments = await window.api.runtimeEnvironments.list()
|
||||
} catch (err) {
|
||||
console.error('Failed to list runtime environments for status hydration:', err)
|
||||
// Why: settled, not hydrated. Skill discovery must stop waiting and fall
|
||||
// back to the local host, but host routing keeps failing closed on an
|
||||
// unknown catalog rather than acting on a stale empty list.
|
||||
set({ runtimeEnvironmentCatalogSettled: true })
|
||||
return
|
||||
}
|
||||
get().setRuntimeEnvironments(environments)
|
||||
// Why: fire-and-forget per env; one unreachable server must not block the
|
||||
// others, and a failure records a null status rather than nothing.
|
||||
await Promise.allSettled(
|
||||
environments.map((environment) => get().refreshRuntimeEnvironmentStatus(environment.id))
|
||||
)
|
||||
}
|
||||
hydrateRuntimeEnvironmentStatuses: createRuntimeStatusHydration({
|
||||
listEnvironments: () => window.api.runtimeEnvironments.list(),
|
||||
getCurrentEnvironments: () => get().runtimeEnvironments,
|
||||
publishEnvironments: (environments) => get().setRuntimeEnvironments(environments),
|
||||
refreshEnvironmentStatus: (environmentId) =>
|
||||
get().refreshRuntimeEnvironmentStatus(environmentId),
|
||||
// Why: failed reads release catalog waiters without claiming routing is safe.
|
||||
markCatalogSettled: () => set({ runtimeEnvironmentCatalogSettled: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { createTestStore, makeWorktree } from './store-test-helpers'
|
||||
import type { AppState } from '../types'
|
||||
import type { WorktreeLineage } from '../../../../shared/types'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
|
||||
@@ -9,6 +10,10 @@ import {
|
||||
RUNTIME_PROTOCOL_VERSION
|
||||
} from '../../../../shared/protocol-version'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
|
||||
import {
|
||||
RUNTIME_CATALOG_STALE_MS,
|
||||
resetRuntimeCatalogListingForTests
|
||||
} from './runtime-status-hydration'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } }))
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
@@ -37,9 +42,34 @@ const env2Lineage: WorktreeLineage = {
|
||||
createdAt: 1
|
||||
}
|
||||
|
||||
function makeRuntimeEnvironment(id: string): PublicKnownRuntimeEnvironment {
|
||||
const endpointId = `ws-${id}`
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastUsedAt: null,
|
||||
runtimeId: null,
|
||||
endpoints: [{ id: endpointId, kind: 'websocket', label: 'WebSocket', endpoint: 'ws://x' }],
|
||||
preferredEndpointId: endpointId
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve: (value: T) => void = () => {}
|
||||
let reject: (reason?: unknown) => void = () => {}
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve
|
||||
reject = promiseReject
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
resetRuntimeCatalogListingForTests()
|
||||
vi.clearAllMocks()
|
||||
runtimeEnvironmentGetStatus.mockResolvedValue({
|
||||
id: 'status-rpc-1',
|
||||
@@ -698,4 +728,141 @@ describe('fetchSettings runtime catalog probe', () => {
|
||||
expect(store.getState().settings).not.toBeNull()
|
||||
expect(store.getState().runtimeEnvironmentCatalogSettled).toBe(true)
|
||||
})
|
||||
|
||||
it('coalesces concurrent settings refreshes into one all-host sweep', async () => {
|
||||
const environments = [makeRuntimeEnvironment('env-a'), makeRuntimeEnvironment('env-b')]
|
||||
const catalog = deferred<PublicKnownRuntimeEnvironment[]>()
|
||||
runtimeEnvironmentList.mockReturnValueOnce(catalog.promise)
|
||||
const store = createTestStore()
|
||||
|
||||
await Promise.all(Array.from({ length: 10 }, () => store.getState().fetchSettings()))
|
||||
|
||||
expect(settingsGet).toHaveBeenCalledTimes(10)
|
||||
expect(runtimeEnvironmentList).toHaveBeenCalledTimes(1)
|
||||
expect(runtimeEnvironmentGetStatus).not.toHaveBeenCalled()
|
||||
|
||||
catalog.resolve(environments)
|
||||
await vi.waitFor(() => expect(runtimeEnvironmentGetStatus).toHaveBeenCalledTimes(2))
|
||||
await vi.waitFor(() => expect(store.getState().runtimeStatusByEnvironmentId.size).toBe(2))
|
||||
|
||||
await store.getState().fetchSettings()
|
||||
expect(settingsGet).toHaveBeenCalledTimes(11)
|
||||
expect(runtimeEnvironmentList).toHaveBeenCalledTimes(1)
|
||||
expect(runtimeEnvironmentGetStatus).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('fills status coverage after another path publishes only part of the catalog', async () => {
|
||||
const environments = [makeRuntimeEnvironment('env-a'), makeRuntimeEnvironment('env-b')]
|
||||
runtimeEnvironmentList.mockResolvedValue(environments)
|
||||
const store = createTestStore()
|
||||
store.getState().setRuntimeEnvironments(environments)
|
||||
store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 1 })
|
||||
|
||||
await store.getState().fetchSettings()
|
||||
await vi.waitFor(() => expect(runtimeEnvironmentGetStatus).toHaveBeenCalledTimes(2))
|
||||
|
||||
expect(runtimeEnvironmentList).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().runtimeStatusByEnvironmentId.has('env-b')).toBe(true)
|
||||
})
|
||||
|
||||
it('treats an offline result as checked on later settings refreshes', async () => {
|
||||
const environments = [makeRuntimeEnvironment('env-a'), makeRuntimeEnvironment('env-b')]
|
||||
runtimeEnvironmentList.mockResolvedValue(environments)
|
||||
runtimeEnvironmentGetStatus.mockImplementation(({ selector }: { selector: string }) =>
|
||||
selector === 'env-b'
|
||||
? Promise.reject(new Error('offline'))
|
||||
: Promise.resolve({
|
||||
id: 'status-rpc-a',
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime-a',
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-a' }
|
||||
})
|
||||
)
|
||||
const store = createTestStore()
|
||||
|
||||
await store.getState().fetchSettings()
|
||||
await vi.waitFor(() =>
|
||||
expect(store.getState().runtimeStatusByEnvironmentId.get('env-b')?.status).toBeNull()
|
||||
)
|
||||
await store.getState().fetchSettings()
|
||||
|
||||
expect(settingsGet).toHaveBeenCalledTimes(2)
|
||||
expect(runtimeEnvironmentList).toHaveBeenCalledTimes(1)
|
||||
expect(runtimeEnvironmentGetStatus).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retries a failed catalog read on the next settings refresh', async () => {
|
||||
const firstCatalog = deferred<PublicKnownRuntimeEnvironment[]>()
|
||||
runtimeEnvironmentList
|
||||
.mockReturnValueOnce(firstCatalog.promise)
|
||||
.mockResolvedValueOnce([makeRuntimeEnvironment('env-a')])
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const store = createTestStore()
|
||||
|
||||
try {
|
||||
await store.getState().fetchSettings()
|
||||
firstCatalog.reject(new Error('unreadable environments.json'))
|
||||
await vi.waitFor(() => expect(store.getState().runtimeEnvironmentCatalogSettled).toBe(true))
|
||||
expect(store.getState().runtimeEnvironmentCatalogHydrated).toBe(false)
|
||||
|
||||
await store.getState().fetchSettings()
|
||||
await vi.waitFor(() => expect(store.getState().runtimeEnvironmentCatalogHydrated).toBe(true))
|
||||
await vi.waitFor(() => expect(runtimeEnvironmentGetStatus).toHaveBeenCalledTimes(1))
|
||||
await store.getState().fetchSettings()
|
||||
|
||||
expect(settingsGet).toHaveBeenCalledTimes(3)
|
||||
expect(runtimeEnvironmentList).toHaveBeenCalledTimes(2)
|
||||
expect(runtimeEnvironmentGetStatus).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses an authoritative sweep to remove ghost status entries', async () => {
|
||||
const store = createTestStore()
|
||||
store.getState().setRuntimeEnvironments([])
|
||||
store.getState().setRuntimeEnvironmentStatus('removed-env', { status: null, checkedAt: 1 })
|
||||
|
||||
await store.getState().fetchSettings()
|
||||
await vi.waitFor(() => expect(store.getState().runtimeStatusByEnvironmentId.size).toBe(0))
|
||||
|
||||
expect(runtimeEnvironmentList).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('picks up an externally added host once the listing goes stale', async () => {
|
||||
runtimeEnvironmentList.mockResolvedValue([makeRuntimeEnvironment('env-a')])
|
||||
const store = createTestStore()
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000)
|
||||
|
||||
try {
|
||||
await store.getState().fetchSettings()
|
||||
await vi.waitFor(() => expect(store.getState().runtimeStatusByEnvironmentId.size).toBe(1))
|
||||
|
||||
// Another client adds a host; coverage still matches, so only staleness can reveal it.
|
||||
runtimeEnvironmentList.mockResolvedValue([
|
||||
makeRuntimeEnvironment('env-a'),
|
||||
makeRuntimeEnvironment('env-b')
|
||||
])
|
||||
await store.getState().fetchSettings()
|
||||
expect(runtimeEnvironmentList).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().runtimeEnvironments.map(({ id }) => id)).toEqual(['env-a'])
|
||||
|
||||
now.mockReturnValue(1_000_000 + RUNTIME_CATALOG_STALE_MS + 1)
|
||||
await store.getState().fetchSettings()
|
||||
await vi.waitFor(() =>
|
||||
expect(store.getState().runtimeEnvironments.map(({ id }) => id)).toEqual(['env-a', 'env-b'])
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(store.getState().runtimeStatusByEnvironmentId.has('env-b')).toBe(true)
|
||||
)
|
||||
expect(runtimeEnvironmentList).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
now.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ import { normalizeTerminalCustomThemes } from '../../../../shared/terminal-custo
|
||||
import { normalizeTaskProviderSettings } from '../../../../shared/task-providers'
|
||||
import { normalizeOpenInApplications } from '../../../../shared/open-in-applications'
|
||||
import { createSettingsSearchState, type SettingsSearchState } from './settings-search-state'
|
||||
import { isRuntimeCatalogListingStale } from './runtime-status-hydration'
|
||||
import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection'
|
||||
import {
|
||||
normalizeTuiAgentArgsRecord,
|
||||
@@ -135,6 +136,17 @@ async function persistSettingsUpdates(
|
||||
}))
|
||||
}
|
||||
|
||||
/** Every known host has a recorded status entry, and no entry survives for a host that is gone. */
|
||||
function hasCompleteRuntimeStatusCoverage(
|
||||
runtimeEnvironments: AppState['runtimeEnvironments'],
|
||||
runtimeStatusByEnvironmentId: AppState['runtimeStatusByEnvironmentId']
|
||||
): boolean {
|
||||
return (
|
||||
new Set(runtimeEnvironments.map(({ id }) => id)).size === runtimeStatusByEnvironmentId.size &&
|
||||
runtimeEnvironments.every(({ id }) => runtimeStatusByEnvironmentId.has(id))
|
||||
)
|
||||
}
|
||||
|
||||
async function verifyRuntimeEnvironmentReachable(environmentId: string | null): Promise<void> {
|
||||
if (!environmentId) {
|
||||
return
|
||||
@@ -161,11 +173,18 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch settings:', err)
|
||||
}
|
||||
// Why: best-effort boot probe so sidebar host pickers show live runtime
|
||||
// health before the settings pane is ever opened. Fire-and-forget to keep
|
||||
// startup off the network round-trips. Runs even when settings fail to load,
|
||||
// so surfaces waiting on the catalog settling are never stranded pending.
|
||||
void get().hydrateRuntimeEnvironmentStatuses()
|
||||
const { runtimeEnvironmentCatalogHydrated, runtimeEnvironments, runtimeStatusByEnvironmentId } =
|
||||
get()
|
||||
// Why: settings refreshes are frequent, but only incomplete host coverage needs
|
||||
// the all-host boot probe. A recorded null still means the host was checked.
|
||||
if (
|
||||
!runtimeEnvironmentCatalogHydrated ||
|
||||
!hasCompleteRuntimeStatusCoverage(runtimeEnvironments, runtimeStatusByEnvironmentId) ||
|
||||
// Why: coverage is blind to catalog edits from another client or the orca CLI.
|
||||
isRuntimeCatalogListingStale()
|
||||
) {
|
||||
void get().hydrateRuntimeEnvironmentStatuses()
|
||||
}
|
||||
},
|
||||
|
||||
updateSettings: async (updates) => {
|
||||
|
||||
Reference in New Issue
Block a user