fix(mobile): keep cached workspace counts across a transient RPC failure (#12408)

* fix(mobile): keep cached workspace counts across a transient RPC failure

The Home host card showed "12 worktrees · 2 active" until any worktree.ps
failed — a backgrounded app, a Wi-Fi→cellular handoff, or a sleep/resume
that kills the socket mid-request. Two things then went wrong:

- render dropped the counts: `markHomeWorktreeCatalogUnavailable` kept the
  proven numbers in state, but the card only rendered them when
  `catalogUnavailable` was unset, so the line collapsed to "Worktree list
  unavailable" even though the last successful counts were right there.
- nothing re-drove the fetch: the per-host wiring latched a `statsFetched`
  boolean on the first connect, and the logical client survives socket
  drops, so its reconnect never re-read the catalog. The card stayed wrong
  until the user navigated away and back.

Keep the proven counts and flag them stale (`staleCounts`), rendered as
"Last known: 12 worktrees · 2 active"; a host whose catalog never loaded
still reads "Worktree list unavailable" (STA-3123). Replace the one-shot
latch with createHostConnectRefetchGate, which fires on each transition
INTO 'connected' — one refetch per reconnect, no polling timer — mirroring
useWorktreeResync on the host screen. fetchHomeHostWorktreeInfo moves out
of app/index.tsx so its rejection path is covered by tests.

* fix(mobile): bound "Last known" counts and survive a path cutover

Review found two ways the home host card's stale-count fix misbehaves.

1. A migrateTo cutover (relay->direct probe, forced replacement) rejects
   in-flight requests with LogicalClientCutoverError and republishes
   'connected' from 'connected', so the connect gate never re-arms and the
   card latched on "Last known: ..." with nothing left to clear it.
   worktree.ps now re-issues on the authenticated replacement, bounded,
   like runtime-capability-probe and worktree-create-retry already do.

2. "Last known: N worktrees" had no age bound. The home snapshot is
   persisted, so a cold start whose first worktree.ps failed rendered
   counts proven days ago exactly like counts proven seconds ago - the case
   STA-3123 deliberately rendered as "Worktree list unavailable". Counts now
   carry countsProvenAt and expire out of the "last known" wording after
   10 minutes; counts persisted by an older build count as expired.

Also, per review: the card derives its own worktree line from
HostWorktreeInfo, so a caller can no longer re-gate the counts away (that
was the original defect), and the derivation is covered by a render test -
mobile/vitest.config.ts never collected *.test.tsx, so component tests
were silently dead. Home stats are keyed by host and summed instead of
letting whichever desktop replied last overwrite the shared header row,
which the per-reconnect refetch made churn on flaky links.

* fix(mobile): age bounds liveness, not the counts; scope the header total to paired hosts

Round-2 review follow-up.

Age bound was anchored on proof time inside the failure branch only, so a
session connected past the window that then hit one failed refresh rendered
the pre-fix "Worktree list unavailable" — the exact case this PR exists for —
while identically aged counts still rendered unlabeled as live whenever the
refresh was merely pending. Age now decides live vs "Last known" and the
failure branch keeps whatever the host last proved; "Worktree list unavailable"
is reserved for a catalog that never loaded.

Header stats summed every entry ever cached, so removing a desktop left its
lifetime numbers in the total for the rest of the session. totalHomeStats now
sums the hosts still paired, which also covers removal from the host screen.

wireHostSubscriptions is the effect body moved verbatim out of useEffect;
react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one
and the changed-code gate has no working suppression path (an inline directive
reads as unused to the plugin-less scan).

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin 2026-08-03 23:26:02 -07:00 committed by GitHub
parent 401f66939d
commit fb1259a09d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 791 additions and 154 deletions

View File

@ -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<string, HomeStatsSummary>) => Record<string, HomeStatsSummary>
) => 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<string, HostWorktreeInfo>) => Record<string, HostWorktreeInfo>
) => 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<Record<string, ConnectionState>>({})
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
const [hostLastConnected, setHostLastConnected] = useState<Record<string, number | null>>({})
const [stats, setStats] = useState<StatsSummary | null>(null)
const [statsByHost, setStatsByHost] = useState<Record<string, HomeStatsSummary>>({})
const [worktreeInfo, setWorktreeInfo] = useState<Record<string, HostWorktreeInfo>>({})
const [accountsByHost, setAccountsByHost] = useState<Record<string, AccountsSnapshot>>({})
const [taskProvidersByHost, setTaskProvidersByHost] = useState<Record<string, TaskProvider[]>>({})
@ -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()

View File

@ -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<string, HostWorktreeInfo>
accountsByHost: Record<string, AccountsSnapshot>

View File

@ -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: <T,>(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<string[]> {
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')
})
})

View File

@ -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 (
<Pressable
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}

View File

@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import { totalHomeStats, type HomeStatsSummary } from './home-stats-total'
function stats(overrides: Partial<HomeStatsSummary> = {}): 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())
})
})

View File

@ -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<string, HomeStatsSummary>,
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
}

View File

@ -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)
})
})

View File

@ -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
}
}
}

View File

@ -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<RpcResponse>((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<string, HostWorktreeInfo> = {}
return {
get current() {
return state
},
setInfo(updater: (prev: Record<string, HostWorktreeInfo>) => Record<string, HostWorktreeInfo>) {
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()
})
})

View File

@ -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<string, HostWorktreeInfo>) => Record<string, HostWorktreeInfo>
) => 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<void> {
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<void> =>
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)
}

View File

@ -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()
})
})

View File

@ -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
)
}

View File

@ -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']
}
})