fix(mobile): deliver live Android update recommendation

This commit is contained in:
Brennan Benson
2026-07-22 13:58:38 -07:00
parent c9347ebeb5
commit dbe8115815
8 changed files with 193 additions and 50 deletions
@@ -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) => {
+39 -22
View File
@@ -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<typeof setTimeout> | 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<void> => {
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])
@@ -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 = {
@@ -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()
})
})
@@ -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<Response, 'ok' | 'json'>
type ReleaseFeedFetcher = () => Promise<ReleaseFeedResponse>
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<void> | 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<string | null> {
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
}
+15 -13
View File
@@ -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', () => {
+15 -4
View File
@@ -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
}
}
+2
View File
@@ -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