diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 907f50ed9..57532a4a8 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -18,14 +18,11 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import { loadHosts } from '../src/transport/host-store' import { navigateToMobileHostEdit } from '../src/transport/host-edit-navigation' import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle' -import { pickResumeWorktree } from '../src/worktree/resume-worktree' -import { WORKTREE_PS_FULL_LIMIT } from '../src/worktree/worktree-catalog-snapshot-client' -import { - markHomeWorktreeCatalogUnavailable, - type HomeWorktreeSummary, - type HostWorktreeInfo -} from '../src/worktree/home-worktree-info' +import { fetchHomeHostWorktreeInfo } from '../src/worktree/home-host-worktree-fetch' +import { totalHomeStats, type HomeStatsSummary } from '../src/stats/home-stats-total' +import type { HomeWorktreeSummary, HostWorktreeInfo } from '../src/worktree/home-worktree-info' import type { RpcClient } from '../src/transport/rpc-client' +import { createHostConnectRefetchGate } from '../src/transport/host-connect-refetch-gate' import { sendSingleFlightRequest } from '../src/transport/request-single-flight' import { useAllHostClients, @@ -68,13 +65,6 @@ function endpointLabel(endpoint: string): string { } } -type StatsSummary = { - totalAgentsSpawned: number - totalPRsCreated: number - totalAgentTimeMs: number - firstEventAt: number | null -} - type HomeTaskSettings = { visibleTaskProviders?: unknown } @@ -123,7 +113,9 @@ function clientKey(client: RpcClient): number { function fetchStats( client: RpcClient, hostId: string, - setStats: (s: StatsSummary) => void, + setStats: ( + updater: (prev: Record) => Record + ) => void, disposed: () => boolean ) { sendSingleFlightRequest(client, hostId, 'stats.summary') @@ -132,61 +124,13 @@ function fetchStats( return } if (response.ok) { - setStats(response.result as StatsSummary) + // Keyed by host: the header totals every desktop instead of showing whoever replied last. + setStats((prev) => ({ ...prev, [hostId]: response.result as HomeStatsSummary })) } }) .catch(() => {}) } -function fetchWorktreeInfo( - client: RpcClient, - hostId: string, - setInfo: ( - updater: (prev: Record) => Record - ) => void, - disposed: () => boolean -) { - const markUnavailable = () => { - setInfo((prev) => { - const current = prev[hostId] - const next = markHomeWorktreeCatalogUnavailable(current, hostId) - return next === current ? prev : { ...prev, [hostId]: next } - }) - } - - sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: WORKTREE_PS_FULL_LIMIT }) - .then((response) => { - if (disposed()) { - return - } - if (response.ok) { - const result = response.result as { worktrees: HomeWorktreeSummary[] } - const worktrees = result.worktrees ?? [] - setCachedWorktrees(hostId, worktrees) - const activeStatuses = new Set(['working', 'active', 'permission']) - const active = worktrees.filter((w) => w.status && activeStatuses.has(w.status)) - // Mirror the desktop's focused workspace (see pickResumeWorktree). - const lastActive = pickResumeWorktree(worktrees) - setInfo((prev) => ({ - ...prev, - [hostId]: { - hostId, - totalWorktrees: worktrees.length, - activeCount: active.length, - lastActiveWorktree: lastActive - } - })) - } else { - markUnavailable() - } - }) - .catch(() => { - if (!disposed()) { - markUnavailable() - } - }) -} - function fetchAccountsSnapshot( client: RpcClient, hostId: string, @@ -272,7 +216,7 @@ export default function HomeScreen() { const [hostStates, setHostStates] = useState>({}) const [hostAttempts, setHostAttempts] = useState>({}) const [hostLastConnected, setHostLastConnected] = useState>({}) - const [stats, setStats] = useState(null) + const [statsByHost, setStatsByHost] = useState>({}) const [worktreeInfo, setWorktreeInfo] = useState>({}) const [accountsByHost, setAccountsByHost] = useState>({}) const [taskProvidersByHost, setTaskProvidersByHost] = useState>({}) @@ -285,6 +229,8 @@ export default function HomeScreen() { // Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md. const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) + // Why: scoped to the paired hosts so an unpaired desktop's cached reply leaves the header total. + const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds]) const allClients = useAllHostClients(hostIds) const hostPaths = useMemo( () => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])), @@ -375,8 +321,8 @@ export default function HomeScreen() { }) for (const entry of allClientsRef.current) { if (entry.client.getState() === 'connected') { - fetchStats(entry.client, entry.hostId, setStats, () => stale) - fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale) + fetchStats(entry.client, entry.hostId, setStatsByHost, () => stale) + void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale) fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale) fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => stale) } @@ -457,60 +403,70 @@ export default function HomeScreen() { }) }, [allClients, hosts]) - // Per-host notif/accounts subs + one-shot stats on 'connected'; re-runs per (hostId, client) pair, socket stays open so it's cheap. - useEffect(() => { - const cleanups: Array<() => void> = [] - for (const entry of allClients) { - let unsubNotif: (() => void) | null = null - let unsubAccounts: (() => void) | null = null - let statsFetched = false - const wireUp = (state: ConnectionState) => { - if (state === 'connected') { - if (!unsubNotif) { - unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId) - } - if (!unsubAccounts) { - unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => { - if (!payload || typeof payload !== 'object') { - return + // Notif/accounts subs + a snapshot read per connect for one host. Lives outside the effect body + // because react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one; the + // returned disposer owns every handle allocated here. + const wireHostSubscriptions = (entry: { + hostId: string + client: RpcClient + state: ConnectionState + }) => { + let unsubNotif: (() => void) | null = null + let unsubAccounts: (() => void) | null = null + const refetchGate = createHostConnectRefetchGate() + const wireUp = (state: ConnectionState) => { + const reconnected = refetchGate.observe(state) + if (state === 'connected') { + if (!unsubNotif) { + unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId) + } + if (!unsubAccounts) { + unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => { + if (!payload || typeof payload !== 'object') { + return + } + const evt = payload as { type?: string; snapshot?: unknown } + if (evt.type === 'ready' || evt.type === 'snapshot') { + try { + const snapshot = decodeAccountsSnapshot(evt.snapshot) + setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot })) + } catch { + // Keep the last proven snapshot; malformed remote data must + // not enter render state or crash the home host cards. } - const evt = payload as { type?: string; snapshot?: unknown } - if (evt.type === 'ready' || evt.type === 'snapshot') { - try { - const snapshot = decodeAccountsSnapshot(evt.snapshot) - setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot })) - } catch { - // Keep the last proven snapshot; malformed remote data must - // not enter render state or crash the home host cards. - } - } - }) - } - if (!statsFetched) { - statsFetched = true - fetchStats(entry.client, entry.hostId, setStats, () => false) - fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false) - fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false) - } - } else { - if (unsubNotif) { - unsubNotif() - unsubNotif = null - } - if (unsubAccounts) { - unsubAccounts() - unsubAccounts = null - } + } + }) + } + // Why: the socket survives backgrounding/handoffs by reconnecting, so re-read the host + // snapshot on every reconnect — a one-shot latch left the card on stale data forever. + if (reconnected) { + fetchStats(entry.client, entry.hostId, setStatsByHost, () => false) + void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false) + fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false) + } + } else { + if (unsubNotif) { + unsubNotif() + unsubNotif = null + } + if (unsubAccounts) { + unsubAccounts() + unsubAccounts = null } } - wireUp(entry.state) - const unsubState = entry.client.onStateChange(wireUp) - cleanups.push(() => { - unsubState() - unsubNotif?.() - unsubAccounts?.() - }) } + wireUp(entry.state) + const unsubState = entry.client.onStateChange(wireUp) + return () => { + unsubState() + unsubNotif?.() + unsubAccounts?.() + } + } + + // Re-runs per (hostId, client) pair; the socket stays open so it's cheap. + useEffect(() => { + const cleanups = allClients.map((entry) => wireHostSubscriptions(entry)) return () => { for (const c of cleanups) { c() @@ -750,7 +706,6 @@ export default function HomeScreen() { const state = hostStates[item.id] ?? 'connecting' const attempts = hostAttempts[item.id] ?? 0 const lastConnectedAt = hostLastConnected[item.id] ?? null - const info = worktreeInfo[item.id] const verdict = classifyConnection({ state, reconnectAttempts: attempts, @@ -763,12 +718,7 @@ export default function HomeScreen() { state={state} verdict={verdict} path={hostPaths[item.id] ?? 'lan'} - worktreeCounts={ - info && !info.catalogUnavailable - ? { total: info.totalWorktrees, active: info.activeCount } - : undefined - } - worktreeCountsUnavailable={info?.catalogUnavailable === true} + worktreeInfo={worktreeInfo[item.id]} onPress={() => router.push(`/h/${item.id}`)} onLongPress={() => { triggerMediumImpact() diff --git a/mobile/src/cache/home-snapshot-cache.ts b/mobile/src/cache/home-snapshot-cache.ts index a591bb01d..bbda03373 100644 --- a/mobile/src/cache/home-snapshot-cache.ts +++ b/mobile/src/cache/home-snapshot-cache.ts @@ -5,25 +5,12 @@ // WebSocket reconnects and the first responses come back. import AsyncStorage from '@react-native-async-storage/async-storage' import type { AccountsSnapshot } from '../components/AccountUsage' +// Why: the canonical shape, so persisted counts keep carrying countsProvenAt — the home card +// needs it to know whether a rehydrated count is minutes or days old. +import type { HostWorktreeInfo } from '../worktree/home-worktree-info' const STORAGE_KEY = 'orca:home-snapshot:v1' -type WorktreeSummary = { - worktreeId: string - repo: string - branch: string - displayName: string - liveTerminalCount: number - status?: 'working' | 'active' | 'permission' | 'done' | 'inactive' -} - -type HostWorktreeInfo = { - hostId: string - totalWorktrees: number - activeCount: number - lastActiveWorktree: WorktreeSummary | null -} - export type HomeSnapshot = { worktreeInfo: Record accountsByHost: Record diff --git a/mobile/src/components/MobileHostCard.test.tsx b/mobile/src/components/MobileHostCard.test.tsx new file mode 100644 index 000000000..a18897939 --- /dev/null +++ b/mobile/src/components/MobileHostCard.test.tsx @@ -0,0 +1,93 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ConnectionVerdict } from '../transport/connection-health' +import type { HostProfile } from '../transport/types' +import { + markHomeWorktreeCatalogUnavailable, + type HostWorktreeInfo +} from '../worktree/home-worktree-info' +import { MobileHostCard } from './MobileHostCard' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: T) => styles }, + Text: 'Text', + View: 'View' +})) +vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight', Monitor: 'Monitor' })) +vi.mock('./StatusDot', () => ({ StatusDot: 'StatusDot' })) + +const host: HostProfile = { + id: 'host-1', + name: 'Studio', + endpoint: 'ws://studio.local:8765', + deviceToken: 'token', + publicKeyB64: 'key', + lastConnected: 0 +} +const verdict: ConnectionVerdict = { kind: 'normal', label: 'Connected' } +const loaded: HostWorktreeInfo = { + hostId: 'host-1', + totalWorktrees: 12, + activeCount: 2, + lastActiveWorktree: null, + countsProvenAt: Date.now() +} + +describe('MobileHostCard', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function renderCard(worktreeInfo: HostWorktreeInfo | undefined): Promise { + await act(async () => { + renderer = create( + createElement(MobileHostCard, { + host, + state: 'connected', + verdict, + path: 'lan', + worktreeInfo, + onPress: () => {}, + onLongPress: () => {} + }) + ) + }) + return renderer!.root + .findAllByType('Text') + .flatMap((node) => node.children.filter((child) => typeof child === 'string')) + } + + it('renders the counts the host proved', async () => { + expect(await renderCard(loaded)).toContain('12 worktrees · 2 active') + }) + + it('keeps rendering the last proven counts after a failed refresh', async () => { + // The regression this card shipped once: the caller dropped the counts the + // failure path deliberately preserved. + expect(await renderCard(markHomeWorktreeCatalogUnavailable(loaded, 'host-1'))).toContain( + 'Last known: 12 worktrees · 2 active' + ) + }) + + it('never asserts a count for a catalog that failed with nothing proven', async () => { + expect(await renderCard(markHomeWorktreeCatalogUnavailable(undefined, 'host-1'))).toContain( + 'Worktree list unavailable' + ) + }) + + it('shows no worktree line before the first read lands', async () => { + const lines = await renderCard(undefined) + + expect(lines).not.toContain('0 worktrees') + expect(lines).not.toContain('Worktree list unavailable') + }) +}) diff --git a/mobile/src/components/MobileHostCard.tsx b/mobile/src/components/MobileHostCard.tsx index 4e466922f..79a58bf76 100644 --- a/mobile/src/components/MobileHostCard.tsx +++ b/mobile/src/components/MobileHostCard.tsx @@ -6,6 +6,7 @@ import { mobileConnectionPathLabel } from '../transport/mobile-connection-path-l import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client' import type { ConnectionState, HostProfile } from '../transport/types' import { colors, radii, spacing } from '../theme/mobile-theme' +import { homeHostWorktreeSummary, type HostWorktreeInfo } from '../worktree/home-worktree-info' import { StatusDot } from './StatusDot' export function MobileHostCard(props: { @@ -13,20 +14,15 @@ export function MobileHostCard(props: { state: ConnectionState verdict: ConnectionVerdict path: MobileConnectionPath - worktreeCounts?: { total: number; active: number } - // Why (STA-3123): the host is connected but its worktree catalog request failed, - // so the card must say "unavailable" instead of asserting a count of zero. - worktreeCountsUnavailable?: boolean + // Why: the card owns the fresh/stale/unavailable wording so no caller can re-gate the counts + // away (STA-3123 shipped that bug once already). + worktreeInfo?: HostWorktreeInfo onPress: () => void onLongPress: () => void }) { const connected = props.state === 'connected' const isError = ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind) - const worktreeSummary = props.worktreeCounts - ? `${props.worktreeCounts.total} worktree${props.worktreeCounts.total === 1 ? '' : 's'}${props.worktreeCounts.active > 0 ? ` · ${props.worktreeCounts.active} active` : ''}` - : props.worktreeCountsUnavailable - ? 'Worktree list unavailable' - : null + const worktreeSummary = homeHostWorktreeSummary(props.worktreeInfo) return ( [styles.card, pressed && styles.cardPressed]} diff --git a/mobile/src/stats/home-stats-total.test.ts b/mobile/src/stats/home-stats-total.test.ts new file mode 100644 index 000000000..035f6c747 --- /dev/null +++ b/mobile/src/stats/home-stats-total.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { totalHomeStats, type HomeStatsSummary } from './home-stats-total' + +function stats(overrides: Partial = {}): HomeStatsSummary { + return { + totalAgentsSpawned: 10, + totalPRsCreated: 2, + totalAgentTimeMs: 60_000, + firstEventAt: 1_700_000_000_000, + ...overrides + } +} + +describe('totalHomeStats', () => { + it('has nothing to show before any host answers', () => { + expect(totalHomeStats({}, ['host-1'])).toBeNull() + }) + + it('passes a single host through unchanged', () => { + expect(totalHomeStats({ 'host-1': stats() }, ['host-1'])).toEqual(stats()) + }) + + it('sums every desktop instead of letting the last reply win', () => { + const total = totalHomeStats( + { + 'host-1': stats(), + 'host-2': stats({ totalAgentsSpawned: 5, totalPRsCreated: 1, totalAgentTimeMs: 30_000 }) + }, + ['host-1', 'host-2'] + ) + + expect(total).toMatchObject({ + totalAgentsSpawned: 15, + totalPRsCreated: 3, + totalAgentTimeMs: 90_000 + }) + }) + + it('drops a removed desktop from the total', () => { + // Replies are cached for the life of the process, so the entry outlives the pairing. + const byHost = { + 'host-1': stats(), + 'host-2': stats({ totalAgentsSpawned: 5, totalPRsCreated: 1, totalAgentTimeMs: 30_000 }) + } + + expect(totalHomeStats(byHost, ['host-1'])).toEqual(stats()) + expect(totalHomeStats(byHost, [])).toBeNull() + }) + + it('keeps the earliest known first event', () => { + const total = totalHomeStats( + { + 'host-1': stats({ firstEventAt: 2_000 }), + 'host-2': stats({ firstEventAt: null }), + 'host-3': stats({ firstEventAt: 1_000 }) + }, + ['host-1', 'host-2', 'host-3'] + ) + + expect(total?.firstEventAt).toBe(1_000) + }) + + it('reports no first event when no host has one', () => { + expect( + totalHomeStats({ 'host-1': stats({ firstEventAt: null }) }, ['host-1'])?.firstEventAt + ).toBeNull() + }) + + it('ignores a malformed reply instead of poisoning the header', () => { + const total = totalHomeStats( + { + 'host-1': stats(), + 'host-2': null as unknown as HomeStatsSummary, + 'host-3': { totalAgentsSpawned: 'lots' } as unknown as HomeStatsSummary + }, + ['host-1', 'host-2', 'host-3'] + ) + + expect(total).toEqual(stats()) + }) +}) diff --git a/mobile/src/stats/home-stats-total.ts b/mobile/src/stats/home-stats-total.ts new file mode 100644 index 000000000..722648725 --- /dev/null +++ b/mobile/src/stats/home-stats-total.ts @@ -0,0 +1,51 @@ +export type HomeStatsSummary = { + totalAgentsSpawned: number + totalPRsCreated: number + totalAgentTimeMs: number + firstEventAt: number | null +} + +/** + * Why: the home header shows one lifetime-usage row for every paired desktop. Each host answers + * stats.summary for itself, so a single shared slot made the row flip to whichever host replied + * last — visible churn now that every reconnect re-reads. Sum instead; one host still totals itself. + * + * Summing only `hostIds` keeps an unpaired desktop out of the total: replies are cached per host + * for the life of the process, so an entry outlives the host it describes. + */ +export function totalHomeStats( + byHost: Record, + hostIds: readonly string[] +): HomeStatsSummary | null { + const hosts = hostIds.filter((id) => id in byHost).map((id) => byHost[id]) + if (hosts.length === 0) { + return null + } + const total: HomeStatsSummary = { + totalAgentsSpawned: 0, + totalPRsCreated: 0, + totalAgentTimeMs: 0, + firstEventAt: null + } + for (const host of hosts) { + // The rows come straight off the wire unvalidated; a malformed desktop reply must not + // NaN out or crash the header for every other host. + if (!host || typeof host !== 'object') { + continue + } + total.totalAgentsSpawned += finiteOrZero(host.totalAgentsSpawned) + total.totalPRsCreated += finiteOrZero(host.totalPRsCreated) + total.totalAgentTimeMs += finiteOrZero(host.totalAgentTimeMs) + if (typeof host.firstEventAt === 'number' && Number.isFinite(host.firstEventAt)) { + total.firstEventAt = + total.firstEventAt === null + ? host.firstEventAt + : Math.min(total.firstEventAt, host.firstEventAt) + } + } + return total +} + +function finiteOrZero(value: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} diff --git a/mobile/src/transport/host-connect-refetch-gate.test.ts b/mobile/src/transport/host-connect-refetch-gate.test.ts new file mode 100644 index 000000000..17b3d68ae --- /dev/null +++ b/mobile/src/transport/host-connect-refetch-gate.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { createHostConnectRefetchGate } from './host-connect-refetch-gate' +import type { ConnectionState } from './types' + +function refetchesFor(states: ConnectionState[]): number { + const gate = createHostConnectRefetchGate() + return states.filter((state) => gate.observe(state)).length +} + +describe('createHostConnectRefetchGate', () => { + it('fetches once on the first connect', () => { + expect(refetchesFor(['connecting', 'handshaking', 'connected'])).toBe(1) + }) + + it('fetches again after a dropped socket comes back', () => { + expect( + refetchesFor(['connected', 'reconnecting', 'connecting', 'handshaking', 'connected']) + ).toBe(2) + }) + + it('does not storm while the connection holds', () => { + expect(refetchesFor(['connected', 'connected', 'connected'])).toBe(1) + }) + + it('never fetches while the host stays down', () => { + expect(refetchesFor(['connecting', 'disconnected', 'reconnecting', 'auth-failed'])).toBe(0) + }) +}) diff --git a/mobile/src/transport/host-connect-refetch-gate.ts b/mobile/src/transport/host-connect-refetch-gate.ts new file mode 100644 index 000000000..4ce2ba885 --- /dev/null +++ b/mobile/src/transport/host-connect-refetch-gate.ts @@ -0,0 +1,18 @@ +import type { ConnectionState } from './types' + +// Why: a logical client survives socket drops, so screens that fetch once per client latched +// after the first connect and never refreshed after a background/handoff reconnect. Gate the +// refetch on the transition INTO 'connected' — one read per reconnect, no polling timer. +export function createHostConnectRefetchGate(): { + observe: (state: ConnectionState) => boolean +} { + let connected = false + return { + observe(state) { + const nowConnected = state === 'connected' + const crossedIntoConnected = nowConnected && !connected + connected = nowConnected + return crossedIntoConnected + } + } +} diff --git a/mobile/src/worktree/home-host-worktree-fetch.test.ts b/mobile/src/worktree/home-host-worktree-fetch.test.ts new file mode 100644 index 000000000..5799de963 --- /dev/null +++ b/mobile/src/worktree/home-host-worktree-fetch.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from 'vitest' +import { createHostConnectRefetchGate } from '../transport/host-connect-refetch-gate' +import type { RpcClient } from '../transport/rpc-client' +import { + createStableLogicalRpcClient, + LogicalClientCutoverError +} from '../transport/stable-logical-rpc-client' +import type { ConnectionState, RpcResponse } from '../transport/types' +import { fetchHomeHostWorktreeInfo } from './home-host-worktree-fetch' +import type { HostWorktreeInfo } from './home-worktree-info' + +type FakeSession = { + client: RpcClient + calls: number + settle: (response: RpcResponse | Error) => void +} + +function fakeSession(): FakeSession { + const pending: Array<(response: RpcResponse | Error) => void> = [] + const fake: FakeSession = { + calls: 0, + settle(response: RpcResponse | Error) { + const next = pending.shift() + next?.(response) + }, + client: { + sendRequest: () => { + fake.calls += 1 + return new Promise((resolve, reject) => { + pending.push((response) => + response instanceof Error ? reject(response) : resolve(response) + ) + }) + }, + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: (): ConnectionState => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + } + return fake +} + +// Drains the promise chain so a retry queued in a .catch has reached the transport. +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +function catalogResponse(count: number, active: number): RpcResponse { + const worktrees = Array.from({ length: count }, (_, index) => ({ + worktreeId: `wt-${index}`, + repo: 'orca', + branch: `branch-${index}`, + displayName: `Workspace ${index}`, + liveTerminalCount: 0, + status: index < active ? ('working' as const) : ('done' as const) + })) + return { ok: true, result: { worktrees } } as RpcResponse +} + +function infoStore() { + let state: Record = {} + return { + get current() { + return state + }, + setInfo(updater: (prev: Record) => Record) { + state = updater(state) + } + } +} + +const notDisposed = () => false + +describe('fetchHomeHostWorktreeInfo', () => { + it('keeps the last proven counts when the in-flight read rejects', async () => { + const store = infoStore() + const host = fakeSession() + + const loaded = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(catalogResponse(12, 2)) + await loaded + + const failed = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(new Error('socket closed mid-request')) + await failed + + expect(store.current['host-1']).toEqual({ + hostId: 'host-1', + totalWorktrees: 12, + activeCount: 2, + lastActiveWorktree: expect.objectContaining({ worktreeId: 'wt-0' }), + catalogUnavailable: true, + staleCounts: true, + // Age-stamped so the card can stop calling day-old counts "last known". + countsProvenAt: expect.any(Number) + }) + }) + + it('stamps when the host proved the counts', async () => { + const store = infoStore() + const host = fakeSession() + const before = Date.now() + + const loaded = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(catalogResponse(3, 1)) + await loaded + + const provenAt = store.current['host-1'].countsProvenAt + expect(provenAt).toBeGreaterThanOrEqual(before) + expect(provenAt).toBeLessThanOrEqual(Date.now()) + }) + + it('marks a host whose catalog never loaded as unavailable, not empty', async () => { + const store = infoStore() + const host = fakeSession() + + const failed = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle({ ok: false, error: { code: 'internal' } } as RpcResponse) + await failed + + expect(store.current['host-1']).toMatchObject({ + totalWorktrees: 0, + catalogUnavailable: true + }) + expect(store.current['host-1'].staleCounts).toBeUndefined() + }) + + it('re-reads the catalog on reconnect and clears the stale flag', async () => { + const store = infoStore() + const host = fakeSession() + const gate = createHostConnectRefetchGate() + + const connect = async (response: RpcResponse | Error) => { + if (!gate.observe('connected')) { + return + } + const done = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(response) + await done + } + + await connect(catalogResponse(12, 2)) + // Socket dies mid-poll: counts survive, flagged stale. + const dropped = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(new Error('socket closed mid-request')) + await dropped + gate.observe('reconnecting') + expect(store.current['host-1'].staleCounts).toBe(true) + + await connect(catalogResponse(13, 3)) + + expect(store.current['host-1']).toMatchObject({ + totalWorktrees: 13, + activeCount: 3 + }) + expect(store.current['host-1'].staleCounts).toBeUndefined() + expect(store.current['host-1'].catalogUnavailable).toBeUndefined() + // Two connects + the dropped poll: the gate must not re-read while the link holds. + expect(host.calls).toBe(3) + await connect(catalogResponse(13, 3)) + expect(host.calls).toBe(3) + }) + + it('re-reads through a relay→direct cutover, which never leaves connected', async () => { + const store = infoStore() + const relay = fakeSession() + const direct = fakeSession() + const logical = createStableLogicalRpcClient(relay.client, 'relay') + const gate = createHostConnectRefetchGate() + const gateFires: ConnectionState[] = [] + logical.onStateChange((state) => { + if (gate.observe(state)) { + gateFires.push(state) + } + }) + gate.observe(logical.getState()) + + const loaded = fetchHomeHostWorktreeInfo(logical, 'host-1', store.setInfo, notDisposed) + relay.settle(catalogResponse(12, 2)) + await loaded + + // The supervisor's direct probe migrates while a refresh is on the wire. + const interrupted = fetchHomeHostWorktreeInfo(logical, 'host-1', store.setInfo, notDisposed) + await logical.migrateTo(direct.client, 'lan') + await flush() + + expect(direct.calls).toBe(1) + direct.settle(catalogResponse(13, 3)) + await interrupted + + // The gate can't cover this: migrateTo republishes 'connected' from 'connected'. + expect(gateFires).toEqual([]) + expect(store.current['host-1']).toMatchObject({ totalWorktrees: 13, activeCount: 3 }) + expect(store.current['host-1'].staleCounts).toBeUndefined() + expect(store.current['host-1'].catalogUnavailable).toBeUndefined() + }) + + it('gives up on a host that keeps cutting over so the card still reports the failure', async () => { + const store = infoStore() + const host = fakeSession() + + const loaded = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(catalogResponse(12, 2)) + await loaded + + const cutoverStorm = fetchHomeHostWorktreeInfo( + host.client, + 'host-1', + store.setInfo, + notDisposed + ) + for (let attempt = 0; attempt < 6; attempt += 1) { + host.settle(new LogicalClientCutoverError()) + await flush() + } + await cutoverStorm + + // 1 original + CUTOVER_RETRY_LIMIT retries, then the failure is surfaced. + expect(host.calls).toBe(4) + expect(store.current['host-1'].staleCounts).toBe(true) + }) + + it('ignores a response that lands after the screen is disposed', async () => { + const store = infoStore() + const host = fakeSession() + + const done = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, () => true) + host.settle(new Error('socket closed mid-request')) + await done + + expect(store.current['host-1']).toBeUndefined() + }) +}) diff --git a/mobile/src/worktree/home-host-worktree-fetch.ts b/mobile/src/worktree/home-host-worktree-fetch.ts new file mode 100644 index 000000000..7c6dafe6b --- /dev/null +++ b/mobile/src/worktree/home-host-worktree-fetch.ts @@ -0,0 +1,79 @@ +import { setCachedWorktrees } from '../cache/worktree-cache' +import { sendSingleFlightRequest } from '../transport/request-single-flight' +import type { RpcClient } from '../transport/rpc-client' +import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { + markHomeWorktreeCatalogUnavailable, + type HomeWorktreeSummary, + type HostWorktreeInfo +} from './home-worktree-info' +import { pickResumeWorktree } from './resume-worktree' +import { WORKTREE_PS_FULL_LIMIT } from './worktree-catalog-snapshot-client' + +const ACTIVE_STATUSES = new Set(['working', 'active', 'permission']) +// Why: a relay↔direct cutover rejects in-flight reads without ever leaving 'connected', so the +// connect gate never re-arms. Re-issue on the replacement session; cap it so a migration loop +// can't spin. See runtime-capability-probe.ts for the same hazard on status.get. +const CUTOVER_RETRY_LIMIT = 2 + +export type HostWorktreeInfoSetter = ( + updater: (prev: Record) => Record +) => void + +/** Reads one host's worktree catalog for the Home card, preserving proven counts on failure. */ +export function fetchHomeHostWorktreeInfo( + client: RpcClient, + hostId: string, + setInfo: HostWorktreeInfoSetter, + disposed: () => boolean +): Promise { + const markUnavailable = (): void => { + setInfo((prev) => { + const current = prev[hostId] + const next = markHomeWorktreeCatalogUnavailable(current, hostId) + return next === current ? prev : { ...prev, [hostId]: next } + }) + } + + const attempt = (cutoverRetriesLeft: number): Promise => + sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: WORKTREE_PS_FULL_LIMIT }) + .then((response) => { + if (disposed()) { + return + } + if (!response.ok) { + markUnavailable() + return + } + const result = response.result as { worktrees?: HomeWorktreeSummary[] } + const worktrees = result.worktrees ?? [] + setCachedWorktrees(hostId, worktrees) + const active = worktrees.filter((w) => w.status && ACTIVE_STATUSES.has(w.status)) + // Mirror the desktop's focused workspace (see pickResumeWorktree). + const lastActive = pickResumeWorktree(worktrees) + setInfo((prev) => ({ + ...prev, + [hostId]: { + hostId, + totalWorktrees: worktrees.length, + activeCount: active.length, + lastActiveWorktree: lastActive, + countsProvenAt: Date.now() + } + })) + }) + .catch((error: unknown) => { + if (disposed()) { + return + } + // A cutover raises only after migrateTo installed an authenticated replacement, so the + // read was interrupted, not answered — ask again instead of latching "Last known". + if (cutoverRetriesLeft > 0 && isLogicalClientCutoverError(error)) { + return attempt(cutoverRetriesLeft - 1) + } + // Any other rejection (socket died mid-request) is a failed refresh, not an empty host. + markUnavailable() + }) + + return attempt(CUTOVER_RETRY_LIMIT) +} diff --git a/mobile/src/worktree/home-worktree-info.test.ts b/mobile/src/worktree/home-worktree-info.test.ts index ebbfe379e..5cb80d007 100644 --- a/mobile/src/worktree/home-worktree-info.test.ts +++ b/mobile/src/worktree/home-worktree-info.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { markHomeWorktreeCatalogUnavailable, type HostWorktreeInfo } from './home-worktree-info' +import { + HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS, + homeHostWorktreeSummary, + markHomeWorktreeCatalogUnavailable, + type HostWorktreeInfo +} from './home-worktree-info' describe('markHomeWorktreeCatalogUnavailable', () => { it('marks a host unavailable when no catalog has loaded', () => { @@ -28,7 +33,8 @@ describe('markHomeWorktreeCatalogUnavailable', () => { expect(markHomeWorktreeCatalogUnavailable(current, 'host-1')).toEqual({ ...current, - catalogUnavailable: true + catalogUnavailable: true, + staleCounts: true }) }) @@ -44,3 +50,73 @@ describe('markHomeWorktreeCatalogUnavailable', () => { expect(markHomeWorktreeCatalogUnavailable(current, 'host-1')).toBe(current) }) }) + +describe('homeHostWorktreeSummary', () => { + const provenAt = 1_700_000_000_000 + const loaded: HostWorktreeInfo = { + hostId: 'host-1', + totalWorktrees: 12, + activeCount: 2, + lastActiveWorktree: null, + countsProvenAt: provenAt + } + + it('summarizes a freshly loaded catalog', () => { + expect(homeHostWorktreeSummary(loaded, provenAt)).toBe('12 worktrees · 2 active') + expect( + homeHostWorktreeSummary({ ...loaded, totalWorktrees: 1, activeCount: 0 }, provenAt) + ).toBe('1 worktree') + }) + + it('keeps showing the last proven counts after a failed refresh', () => { + const afterFailure = markHomeWorktreeCatalogUnavailable(loaded, 'host-1') + + expect(homeHostWorktreeSummary(afterFailure, provenAt + 30_000)).toBe( + 'Last known: 12 worktrees · 2 active' + ) + }) + + it('keeps the last proven counts however long ago the refresh failed', () => { + const afterFailure = markHomeWorktreeCatalogUnavailable(loaded, 'host-1') + + // A session that stays connected past the live window then hits one failed refresh is the + // headline case: the counts are history, not nothing. + expect( + homeHostWorktreeSummary(afterFailure, provenAt + HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS + 1) + ).toBe('Last known: 12 worktrees · 2 active') + expect(homeHostWorktreeSummary(afterFailure, provenAt + 3 * 86_400_000)).toBe( + 'Last known: 12 worktrees · 2 active' + ) + }) + + it('stops calling aged-out counts live even while a refresh is only pending', () => { + // A snapshot rehydrated from a previous app session describes a host we have not reached yet. + expect(homeHostWorktreeSummary(loaded, provenAt + HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS)).toBe( + '12 worktrees · 2 active' + ) + expect( + homeHostWorktreeSummary(loaded, provenAt + HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS + 1) + ).toBe('Last known: 12 worktrees · 2 active') + expect(homeHostWorktreeSummary(loaded, provenAt + 3 * 86_400_000)).toBe( + 'Last known: 12 worktrees · 2 active' + ) + }) + + it('treats counts persisted before age stamping as no longer live', () => { + const preUpgrade: HostWorktreeInfo = { ...loaded, countsProvenAt: undefined } + + expect(homeHostWorktreeSummary(preUpgrade, provenAt)).toBe( + 'Last known: 12 worktrees · 2 active' + ) + expect( + homeHostWorktreeSummary(markHomeWorktreeCatalogUnavailable(preUpgrade, 'host-1'), provenAt) + ).toBe('Last known: 12 worktrees · 2 active') + }) + + it('reports unavailable only when no catalog ever loaded', () => { + expect(homeHostWorktreeSummary(markHomeWorktreeCatalogUnavailable(undefined, 'host-1'))).toBe( + 'Worktree list unavailable' + ) + expect(homeHostWorktreeSummary(undefined)).toBeNull() + }) +}) diff --git a/mobile/src/worktree/home-worktree-info.ts b/mobile/src/worktree/home-worktree-info.ts index 867e266e8..a417636dc 100644 --- a/mobile/src/worktree/home-worktree-info.ts +++ b/mobile/src/worktree/home-worktree-info.ts @@ -15,8 +15,17 @@ export type HostWorktreeInfo = { activeCount: number lastActiveWorktree: HomeWorktreeSummary | null catalogUnavailable?: boolean + // The counts are the last proven ones, kept across a failed refresh. + staleCounts?: boolean + // When the host last answered with these counts (ms epoch); absent in snapshots an older + // build persisted, which is why an unstamped count counts as expired rather than fresh. + countsProvenAt?: number } +// Why: the home snapshot is persisted across launches, so counts could otherwise be rendered as +// live while describing a workspace set proven days ago. Past this window they are only history. +export const HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS = 10 * 60_000 + export function markHomeWorktreeCatalogUnavailable( current: HostWorktreeInfo | undefined, hostId: string @@ -25,7 +34,9 @@ export function markHomeWorktreeCatalogUnavailable( return current } if (current) { - return { ...current, catalogUnavailable: true } + // Why: `current` predates this failure, so its counts are proven host truth — a dropped + // socket must not erase them, only flag them as no longer live. + return { ...current, catalogUnavailable: true, staleCounts: true } } return { hostId, @@ -35,3 +46,32 @@ export function markHomeWorktreeCatalogUnavailable( catalogUnavailable: true } } + +/** The host card's worktree line, or null when nothing is known yet. */ +export function homeHostWorktreeSummary( + info: HostWorktreeInfo | undefined, + now: number = Date.now() +): string | null { + if (!info) { + return null + } + // Why (STA-3123): a catalog that never loaded must not assert a count the host has not proven. + if (info.catalogUnavailable && !info.staleCounts) { + return 'Worktree list unavailable' + } + const counts = `${info.totalWorktrees} worktree${info.totalWorktrees === 1 ? '' : 's'}${ + info.activeCount > 0 ? ` · ${info.activeCount} active` : '' + }` + // Age bounds liveness, not the counts themselves: a failed refresh — or a rehydrated snapshot the + // host has not re-confirmed — is still the last thing it told us, so keep it and drop the claim + // that it is current. + return info.staleCounts || !provenRecently(info, now) ? `Last known: ${counts}` : counts +} + +function provenRecently(info: HostWorktreeInfo, now: number): boolean { + // A snapshot written before countsProvenAt existed has an unknowable age; treat it as expired. + return ( + typeof info.countsProvenAt === 'number' && + now - info.countsProvenAt <= HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS + ) +} diff --git a/mobile/vitest.config.ts b/mobile/vitest.config.ts index 9e7e3ecc5..e39f90eed 100644 --- a/mobile/vitest.config.ts +++ b/mobile/vitest.config.ts @@ -9,6 +9,8 @@ export default defineConfig({ oxc: vitestOxcConfig, test: { environment: 'node', - include: ['src/**/*.test.ts'] + // .tsx too: component tests exist (react-test-renderer + mocked react-native) and were + // silently never collected, so render-level regressions shipped untested. + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'] } })