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) => {