From 21d5ca6db3efe6199ea524068aab203386897eff Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:33:55 -0700 Subject: [PATCH] [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 * 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 --------- Co-authored-by: Orca --- .../store/slices/runtime-status-hydration.ts | 109 ++++++++++ .../store/slices/runtime-status-refresh.ts | 29 +++ .../src/store/slices/runtime-status.test.ts | 193 +++++++++++++++++- .../src/store/slices/runtime-status.ts | 54 ++--- .../src/store/slices/settings.test.ts | 167 +++++++++++++++ src/renderer/src/store/slices/settings.ts | 29 ++- 6 files changed, 532 insertions(+), 49 deletions(-) create mode 100644 src/renderer/src/store/slices/runtime-status-hydration.ts create mode 100644 src/renderer/src/store/slices/runtime-status-refresh.ts diff --git a/src/renderer/src/store/slices/runtime-status-hydration.ts b/src/renderer/src/store/slices/runtime-status-hydration.ts new file mode 100644 index 00000000000..b8555a38d41 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-hydration.ts @@ -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 + getCurrentEnvironments: () => PublicKnownRuntimeEnvironment[] + publishEnvironments: (environments: PublicKnownRuntimeEnvironment[]) => void + refreshEnvironmentStatus: (environmentId: string) => Promise + markCatalogSettled: () => void +} + +function environmentRevisions( + environments: PublicKnownRuntimeEnvironment[] +): ReadonlyMap { + return new Map( + environments.map((environment) => [ + environment.id, + environment.pairingRevision ?? environment.createdAt + ]) + ) +} + +function revisionsMatch( + environments: PublicKnownRuntimeEnvironment[], + expected: ReadonlyMap +): 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 { + let inFlight: Promise | null = null + let expectedRevisions: ReadonlyMap | null = null + let rerunRequested = false + + return () => { + if (inFlight) { + if (expectedRevisions && !revisionsMatch(getCurrentEnvironments(), expectedRevisions)) { + rerunRequested = true + } + return inFlight + } + const hydration = (async (): Promise => { + // 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 + } +} diff --git a/src/renderer/src/store/slices/runtime-status-refresh.ts b/src/renderer/src/store/slices/runtime-status-refresh.ts new file mode 100644 index 00000000000..5609cb4c5a7 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-refresh.ts @@ -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 { + const expectedEnvironmentRevision = getRuntimeEnvironmentRevision(environmentId) + try { + const response = await window.api.runtimeEnvironments.getStatus({ + selector: environmentId, + timeoutMs + }) + const status = unwrapRuntimeRpcResult(response) + if (getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentRevision) { + return false + } + publish(status) + return true + } catch { + if (getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentRevision) { + return false + } + publish(null) + return false + } +} diff --git a/src/renderer/src/store/slices/runtime-status.test.ts b/src/renderer/src/store/slices/runtime-status.test.ts index 1462d5ea702..d7aacc3bb96 100644 --- a/src/renderer/src/store/slices/runtime-status.test.ts +++ b/src/renderer/src/store/slices/runtime-status.test.ts @@ -74,6 +74,16 @@ function stubRuntimeEnvironmentApi({ return { getStatus, list } } +function deferred() { + let resolve: (value: T) => void = () => {} + let reject: (reason?: unknown) => void = () => {} + const promise = new Promise((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>() + 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>() + const probeB = deferred>() + 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() + const secondCatalog = deferred() + 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>() + 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 () => { diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index f1eab57213c..29c3e556d2a 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -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 { - try { - const response = await window.api.runtimeEnvironments.getStatus({ - selector: environmentId, - timeoutMs - }) - const status = unwrapRuntimeRpcResult(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 }) + }) }) diff --git a/src/renderer/src/store/slices/settings.test.ts b/src/renderer/src/store/slices/settings.test.ts index 612caab0be4..756e6d9baa8 100644 --- a/src/renderer/src/store/slices/settings.test.ts +++ b/src/renderer/src/store/slices/settings.test.ts @@ -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() { + let resolve: (value: T) => void = () => {} + let reject: (reason?: unknown) => void = () => {} + const promise = new Promise((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() + 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() + 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() + } + }) }) diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 670a2556e10..593d1650e1e 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -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 { if (!environmentId) { return @@ -161,11 +173,18 @@ export const createSettingsSlice: StateCreator } 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) => {