diff --git a/mobile/src/transport/host-status-gates.test.ts b/mobile/src/transport/host-status-gates.test.ts index 47cf6741045..11ad61a7e22 100644 --- a/mobile/src/transport/host-status-gates.test.ts +++ b/mobile/src/transport/host-status-gates.test.ts @@ -123,6 +123,65 @@ describe('useHostStatusGates', () => { } }) + it('observes a cold recommendation with one bounded follow-up request', async () => { + vi.useFakeTimers() + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { + protocolVersion: 3, + minCompatibleMobileVersion: 2, + capabilities: ['mobile.tasks.v1'], + recommendedMobileAppVersionsPending: true + } + }) + .mockResolvedValueOnce({ + ok: true, + result: { + protocolVersion: 3, + minCompatibleMobileVersion: 2, + capabilities: ['mobile.tasks.v1'], + recommendedMobileAppVersions: { android: '0.0.40' } + } + }) + const client = { sendRequest } as unknown as RpcClient + let gates: HostStatusGates | null = null + let renderer: ReactTestRenderer | null = null + + function Probe(): null { + gates = useHostStatusGates({ hostId: 'host-1', client, connState: 'connected' }) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(Probe)) + await Promise.resolve() + }) + expect(gates).toMatchObject({ + hostCapabilities: ['mobile.tasks.v1'], + recommendedMobileAppVersions: null, + statusPending: false + }) + expect(sendRequest).toHaveBeenCalledOnce() + + await act(async () => { + await vi.advanceTimersByTimeAsync(6_000) + }) + expect(gates?.recommendedMobileAppVersions).toEqual({ android: '0.0.40' }) + expect(sendRequest).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(60_000) + expect(sendRequest).toHaveBeenCalledTimes(2) + } finally { + restore() + renderer?.unmount() + vi.useRealTimers() + } + }) + it('fails closed while the same client reconnects', async () => { let resolveReconnect: ((response: unknown) => void) | null = null const pendingReconnect = new Promise((resolve) => { diff --git a/mobile/src/transport/host-status-gates.ts b/mobile/src/transport/host-status-gates.ts index e1ad8a19f4d..382de3dadbd 100644 --- a/mobile/src/transport/host-status-gates.ts +++ b/mobile/src/transport/host-status-gates.ts @@ -19,6 +19,7 @@ type LoadedHostStatusGates = HostStatusGates & { } const EMPTY_HOST_CAPABILITIES: string[] = [] +const RECOMMENDATION_REFRESH_DELAY_MS = 6_000 // Reads status.get on connect for capabilities, protocol-compat verdict, and the // floating-workspace flag. Compat constants are wide-open today so this never blocks yet. @@ -37,23 +38,29 @@ export function useHostStatusGates(args: { return } let cancelled = false + let recommendationRefreshTimer: ReturnType | null = null const requestClient = client - void (async () => { + const setFallbackGates = () => { + setLoaded({ + hostId, + client: requestClient, + hostCapabilities: [], + floatingWorkspaceEnabled: false, + compatVerdict: { kind: 'ok' }, + statusPending: false, + recommendedMobileAppVersions: null + }) + } + const loadStatus = async (recommendationRefresh: boolean): Promise => { try { const response = await requestClient.sendRequest('status.get') if (cancelled) { return } if (!response.ok) { - setLoaded({ - hostId, - client: requestClient, - hostCapabilities: [], - floatingWorkspaceEnabled: false, - compatVerdict: { kind: 'ok' }, - statusPending: false, - recommendedMobileAppVersions: null - }) + if (!recommendationRefresh) { + setFallbackGates() + } return } const status = (response as RpcSuccess).result as DesktopStatus & { @@ -63,6 +70,7 @@ export function useHostStatusGates(args: { desktopProtocolVersion: status.protocolVersion, desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion }) + const recommendedMobileAppVersions = readRecommendedMobileAppVersions(status) setLoaded({ hostId, client: requestClient, @@ -70,7 +78,7 @@ export function useHostStatusGates(args: { floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true, compatVerdict: verdict, statusPending: false, - recommendedMobileAppVersions: readRecommendedMobileAppVersions(status) + recommendedMobileAppVersions }) if (verdict.kind === 'blocked') { // Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints. @@ -81,23 +89,32 @@ export function useHostStatusGates(args: { requiredDesktopVersion: verdict.requiredDesktopVersion }) } + if ( + !recommendationRefresh && + verdict.kind !== 'blocked' && + !recommendedMobileAppVersions && + status.recommendedMobileAppVersionsPending === true + ) { + // The desktop fetch has a 5s deadline; one delayed read observes its cache + // without polling or adding work for older hosts. + recommendationRefreshTimer = setTimeout(() => { + recommendationRefreshTimer = null + void loadStatus(true) + }, RECOMMENDATION_REFRESH_DELAY_MS) + } } catch { // Why: a transient status failure must not trap navigation; conservative feature gates remain disabled. - if (!cancelled) { - setLoaded({ - hostId, - client: requestClient, - hostCapabilities: [], - floatingWorkspaceEnabled: false, - compatVerdict: { kind: 'ok' }, - statusPending: false, - recommendedMobileAppVersions: null - }) + if (!cancelled && !recommendationRefresh) { + setFallbackGates() } } - })() + } + void loadStatus(false) return () => { cancelled = true + if (recommendationRefreshTimer) { + clearTimeout(recommendationRefreshTimer) + } } }, [client, connState, hostId]) diff --git a/mobile/src/worktree/host-worktree-rpc-types.ts b/mobile/src/worktree/host-worktree-rpc-types.ts index a5f007405b5..19f96005858 100644 --- a/mobile/src/worktree/host-worktree-rpc-types.ts +++ b/mobile/src/worktree/host-worktree-rpc-types.ts @@ -16,6 +16,8 @@ export type DesktopStatus = { ios?: string android?: string } + // Why: new hosts use this to request one post-refresh status update; old hosts omit it. + recommendedMobileAppVersionsPending?: boolean } export type RepoSummary = { diff --git a/src/main/runtime/mobile-android-release-feed.test.ts b/src/main/runtime/mobile-android-release-feed.test.ts index b01fbfcab07..1168c7efffe 100644 --- a/src/main/runtime/mobile-android-release-feed.test.ts +++ b/src/main/runtime/mobile-android-release-feed.test.ts @@ -1,5 +1,16 @@ -import { describe, expect, it } from 'vitest' -import { extractLatestAndroidVersion } from './mobile-android-release-feed' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + __resetAndroidReleaseFeedCacheForTests, + __setAndroidReleaseFeedFetcherForTests, + extractLatestAndroidVersion, + getRecommendedAndroidVersion, + isAndroidReleaseFeedRefreshPending +} from './mobile-android-release-feed' + +afterEach(() => { + __resetAndroidReleaseFeedCacheForTests() + vi.restoreAllMocks() +}) describe('extractLatestAndroidVersion', () => { it('picks the newest stable mobile-android tag', () => { @@ -14,13 +25,14 @@ describe('extractLatestAndroidVersion', () => { expect(version).toBe('0.0.31') }) - it('skips drafts and prereleases', () => { + it('accepts the published mobile channel while skipping drafts', () => { const version = extractLatestAndroidVersion([ { tag_name: 'mobile-android-v0.0.40', prerelease: true }, { tag_name: 'mobile-android-v0.0.41', draft: true }, { tag_name: 'mobile-android-v0.0.31' } ]) - expect(version).toBe('0.0.31') + // Android workflow releases use --prerelease to avoid replacing desktop latest. + expect(version).toBe('0.0.40') }) it('rejects non-semver and malformed tags, failing open to null', () => { @@ -34,4 +46,25 @@ describe('extractLatestAndroidVersion', () => { ).toBeNull() expect(extractLatestAndroidVersion([])).toBeNull() }) + + it('deduplicates a cold refresh and exposes its completion without blocking callers', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify([{ tag_name: 'mobile-android-v0.0.40', prerelease: true }]), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ) + __setAndroidReleaseFeedFetcherForTests(fetchMock) + + expect(getRecommendedAndroidVersion(1_000)).toBeNull() + expect(getRecommendedAndroidVersion(1_000)).toBeNull() + expect(isAndroidReleaseFeedRefreshPending()).toBe(true) + expect(fetchMock).toHaveBeenCalledOnce() + + await vi.waitFor(() => { + expect(isAndroidReleaseFeedRefreshPending()).toBe(false) + }) + expect(getRecommendedAndroidVersion(1_000)).toBe('0.0.40') + expect(fetchMock).toHaveBeenCalledOnce() + }) }) diff --git a/src/main/runtime/mobile-android-release-feed.ts b/src/main/runtime/mobile-android-release-feed.ts index cc214ec774d..fe6569d51f7 100644 --- a/src/main/runtime/mobile-android-release-feed.ts +++ b/src/main/runtime/mobile-android-release-feed.ts @@ -12,8 +12,7 @@ const FETCH_TIMEOUT_MS = 5000 // Why: a mobile build ships ~monthly, so a long TTL keeps this to a few // unauthenticated GitHub calls per day per host while staying fresh enough. const CACHE_TTL_MS = 6 * 60 * 60 * 1000 -// Why: only stable Android tags count as a recommendation; skip anything with a -// prerelease suffix so an rc APK never nudges stable users. +// Why: only exact Android release tags count, so suffixed RC builds never nudge stable users. const ANDROID_TAG_RE = /^mobile-android-v(\d+\.\d+\.\d+)$/ type ReleaseFeedEntry = { @@ -27,14 +26,26 @@ type CacheState = { fetchedAt: number } +type ReleaseFeedResponse = Pick +type ReleaseFeedFetcher = () => Promise + +const defaultReleaseFeedFetcher: ReleaseFeedFetcher = () => + net.fetch(RELEASES_API_URL, { + headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'orca-runtime' }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }) + // Module-level cache shared across every status.get on this host. let cache: CacheState | null = null let inFlight: Promise | null = null +let releaseFeedFetcher = defaultReleaseFeedFetcher export function extractLatestAndroidVersion(entries: ReleaseFeedEntry[]): string | null { let latest: string | null = null for (const entry of entries) { - if (entry.draft === true || entry.prerelease === true) { + // Mobile releases intentionally use GitHub's prerelease flag so they do not + // replace the desktop's latest release; the strict tag defines this channel. + if (entry.draft === true) { continue } if (typeof entry.tag_name !== 'string') { @@ -54,10 +65,7 @@ export function extractLatestAndroidVersion(entries: ReleaseFeedEntry[]): string async function fetchLatestAndroidVersion(): Promise { try { - const response = await net.fetch(RELEASES_API_URL, { - headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'orca-runtime' }, - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) - }) + const response = await releaseFeedFetcher() if (!response.ok) { return null } @@ -103,10 +111,15 @@ export function getRecommendedAndroidVersion(nowMs: number): string | null { return cache?.version ?? null } +export function isAndroidReleaseFeedRefreshPending(): boolean { + return inFlight !== null +} + // Test-only: reset module cache between cases. export function __resetAndroidReleaseFeedCacheForTests(): void { cache = null inFlight = null + releaseFeedFetcher = defaultReleaseFeedFetcher } // Test-only: seed the cache with a fresh value so status.get reads it without @@ -115,3 +128,7 @@ export function __setAndroidReleaseFeedCacheForTests(version: string | null): vo cache = { version, fetchedAt: Date.now() } inFlight = null } + +export function __setAndroidReleaseFeedFetcherForTests(fetcher: ReleaseFeedFetcher): void { + releaseFeedFetcher = fetcher +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index a23f74247e4..537430fac4b 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1724,20 +1724,22 @@ describe('OrcaRuntimeService', () => { } }) - it('omits the mobile recommendation until the release feed resolves, then reports Android', () => { - __resetAndroidReleaseFeedCacheForTests() - // Why: the feed is fetched lazily and fail-open, so a cold host advertises - // nothing (no banner) rather than a fabricated version. - expect(createRuntime().getStatus().recommendedMobileAppVersions).toBeUndefined() + it('omits an unknown mobile recommendation, then reports cached Android', () => { + __setAndroidReleaseFeedCacheForTests(null) + try { + expect(createRuntime().getStatus().recommendedMobileAppVersions).toBeUndefined() - // Once the feed has a value, status.get carries only Android — iOS defers to - // the App Store, so it is never populated. - __setAndroidReleaseFeedCacheForTests('0.0.42') - const status = createRuntime().getStatus() - expect(status.recommendedMobileAppVersions).toEqual({ android: '0.0.42' }) - expect(status.recommendedMobileAppVersions?.ios).toBeUndefined() - expect(status.recommendedMobileAppVersions?.android).toMatch(/^\d+\.\d+\.\d+$/) - __resetAndroidReleaseFeedCacheForTests() + // Once the feed has a value, status.get carries only Android — iOS defers to + // the App Store, so it is never populated. + __setAndroidReleaseFeedCacheForTests('0.0.42') + const status = createRuntime().getStatus() + expect(status.recommendedMobileAppVersions).toEqual({ android: '0.0.42' }) + expect(status.recommendedMobileAppVersionsPending).toBeUndefined() + expect(status.recommendedMobileAppVersions?.ios).toBeUndefined() + expect(status.recommendedMobileAppVersions?.android).toMatch(/^\d+\.\d+\.\d+$/) + } finally { + __resetAndroidReleaseFeedCacheForTests() + } }) it('reports the configured Windows terminal shell on status', () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5c225086738..d16743aa483 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -449,7 +449,10 @@ import { type ClaudeAgentTeamsMode } from '../../shared/claude-agent-teams-tmux-compat' import { joinWorktreeRelativePath } from './runtime-relative-paths' -import { getRecommendedAndroidVersion } from './mobile-android-release-feed' +import { + getRecommendedAndroidVersion, + isAndroidReleaseFeedRefreshPending +} from './mobile-android-release-feed' import { collectMemorySnapshot } from '../memory/collector' import { BrowserWindow, ipcMain } from 'electron' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' @@ -3373,6 +3376,7 @@ export class OrcaRuntimeService { if (canBrowse) { capabilities.push(BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY) } + const mobileRecommendation = buildMobileAppRecommendationStatus() return { runtimeId: this.runtimeId, rendererGraphEpoch: this.rendererGraphEpoch, @@ -3395,7 +3399,8 @@ export class OrcaRuntimeService { // Why: derived live from GitHub Releases (cached, fail-open) instead of a // hand-bumped constant, so the Android nudge can never go stale; omitted // entirely when unknown. iOS defers to the App Store (no soft nudge). - recommendedMobileAppVersions: buildRecommendedMobileAppVersions(), + recommendedMobileAppVersions: mobileRecommendation.versions, + recommendedMobileAppVersionsPending: mobileRecommendation.pending, protocolVersion: RUNTIME_PROTOCOL_VERSION, minCompatibleMobileVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION } @@ -31472,7 +31477,13 @@ function compareWorktreePs( // Why: only Android has a soft nudge (APK from GitHub Releases); iOS defers to // App Store auto-update. Returns undefined when the latest Android version is // unknown so status.get omits the field and mobile shows no banner. -function buildRecommendedMobileAppVersions(): { android: string } | undefined { +function buildMobileAppRecommendationStatus(): { + versions: { android: string } | undefined + pending: true | undefined +} { const android = getRecommendedAndroidVersion(Date.now()) - return android ? { android } : undefined + return { + versions: android ? { android } : undefined, + pending: !android && isAndroidReleaseFeedRefreshPending() ? true : undefined + } } diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index feeda3b54ee..edb0d57bf94 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -88,6 +88,8 @@ export type RuntimeStatus = { // Why: per-platform release trains can diverge; absent on older hosts and // mobile fails open (no update nudge). recommendedMobileAppVersions?: RecommendedMobileAppVersions + // Why: lets mobile re-read once after a cold background refresh without polling old hosts. + recommendedMobileAppVersionsPending?: boolean // COMPAT(runtimeStatusMobileAliases): added 2026-05-15 for mobile builds // that still read these names; new desktop/CLI code uses the fields above. protocolVersion?: number