Files
orca/mobile/src/worktree/home-worktree-info.ts
T
OrcaWinandOrcaWin fb1259a09d 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>
2026-08-03 23:26:02 -07:00

78 lines
2.7 KiB
TypeScript

export type HomeWorktreeSummary = {
worktreeId: string
repo: string
branch: string
displayName: string
liveTerminalCount: number
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
isActive?: boolean
lastOutputAt?: number
}
export type HostWorktreeInfo = {
hostId: string
totalWorktrees: number
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
): HostWorktreeInfo {
if (current?.catalogUnavailable) {
return current
}
if (current) {
// 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,
totalWorktrees: 0,
activeCount: 0,
lastActiveWorktree: null,
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
)
}