fix(mobile): recover workspace capabilities after transient probe failures (#31480)

Recover workspace capabilities after transient status failures without treating an unknown answer as unsupported. Share probes, invalidate across reconnects, and bound active-sheet retries with backoff and cleanup fencing.

Fixes #16347. Replaces the earlier recovery work in #16850 (already closed).

Credit Brennan Benson (@brennanb2025) for the original report and recovery implementation. Verification includes 105 focused tests, deterministic retry/leak counts, native iOS evidence with disclosed fixture limits, and green CI at a1cf7b54d2.

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
Neil
2026-10-21 15:51:58 -07:00
committed by GitHub
co-authored by Brennan Benson
parent e29c353787
commit 90b86405bc
6 changed files with 473 additions and 84 deletions
@@ -252,6 +252,11 @@
}
}
},
"59a45eb98b67": {
"capabilities": {
"$rpc": "null"
}
},
"73bcd4456e97": {
"name": "status.get#1",
"ordinal": 1,
@@ -427,6 +432,14 @@
}
}
},
"ee20a1dc39e7": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"$rpc": "null"
}
},
"f793cb0dfb40": {
"capabilities": {
"agentLaunch": false,
@@ -459,9 +472,9 @@
"sender": ["0ccee4def5e0"],
"payloads": ["d08f74d65ee6"],
"settlements": {
"probe": "041e5e32563c"
"probe": "ee20a1dc39e7"
},
"state": "f793cb0dfb40",
"state": "59a45eb98b67",
"effects": []
}
},
@@ -471,9 +484,9 @@
"sender": ["73bcd4456e97"],
"payloads": ["d08f74d65ee6"],
"settlements": {
"probe": "041e5e32563c"
"probe": "ee20a1dc39e7"
},
"state": "f793cb0dfb40",
"state": "59a45eb98b67",
"effects": []
}
},
@@ -519,9 +532,9 @@
"sender": ["16a71e555cb2"],
"payloads": ["d08f74d65ee6"],
"settlements": {
"probe": "041e5e32563c"
"probe": "ee20a1dc39e7"
},
"state": "f793cb0dfb40",
"state": "59a45eb98b67",
"effects": []
}
},
@@ -531,9 +544,9 @@
"sender": ["82f760e2c99f"],
"payloads": ["d08f74d65ee6"],
"settlements": {
"probe": "041e5e32563c"
"probe": "ee20a1dc39e7"
},
"state": "f793cb0dfb40",
"state": "59a45eb98b67",
"effects": []
}
},
@@ -543,9 +556,9 @@
"sender": ["e90520ab92af"],
"payloads": ["d08f74d65ee6"],
"settlements": {
"probe": "041e5e32563c"
"probe": "ee20a1dc39e7"
},
"state": "f793cb0dfb40",
"state": "59a45eb98b67",
"effects": []
}
},
@@ -555,9 +568,9 @@
"sender": ["2b8c44a85130"],
"payloads": ["d08f74d65ee6"],
"settlements": {
"probe": "041e5e32563c"
"probe": "ee20a1dc39e7"
},
"state": "f793cb0dfb40",
"state": "59a45eb98b67",
"effects": []
}
},
@@ -567,9 +580,9 @@
"sender": ["b799ba7b0c33"],
"payloads": ["d08f74d65ee6"],
"settlements": {
"probe": "041e5e32563c"
"probe": "ee20a1dc39e7"
},
"state": "f793cb0dfb40",
"state": "59a45eb98b67",
"effects": []
}
}
+25 -30
View File
@@ -84,8 +84,11 @@ function sourceInputs(renderer: ReactTestRenderer) {
)
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the modal only sends requests.
const requestsOnly = (sendRequest: unknown) => ({ sendRequest }) as unknown as RpcClient
function requestsOnly(sendRequest: RpcClient['sendRequest']): RpcClient {
const client = new FakeSession('connected')
client.sendRequest.mockImplementation(sendRequest)
return client
}
function runTargetPicker(renderer: ReactTestRenderer) {
return renderer.root
@@ -122,7 +125,7 @@ describe('NewWorktreeModal project targets', () => {
}
return new Promise(() => {})
})
const client = { sendRequest } as unknown as RpcClient
const client = requestsOnly(sendRequest)
await act(async () => {
renderer = create(
@@ -161,8 +164,8 @@ describe('NewWorktreeModal project targets', () => {
upstream: { owner: 'stablyai', repo: 'orca' }
}
]
const client = {
sendRequest: vi.fn().mockImplementation((method: string) => {
const client = requestsOnly(
vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return Promise.resolve({ ok: true, result: { repos: listedRepos } })
}
@@ -171,7 +174,7 @@ describe('NewWorktreeModal project targets', () => {
}
return new Promise(() => {})
})
} as unknown as RpcClient
)
await act(async () => {
renderer = create(
@@ -248,7 +251,7 @@ describe('NewWorktreeModal project targets', () => {
}
return Promise.resolve({ ok: true, result: {} })
})
const client = { sendRequest } as unknown as RpcClient
const client = requestsOnly(sendRequest)
await act(async () => {
renderer = create(
@@ -304,7 +307,7 @@ describe('NewWorktreeModal project targets', () => {
}
return Promise.resolve({ ok: true, result: {} })
})
const client = { sendRequest } as unknown as RpcClient
const client = requestsOnly(sendRequest)
await act(async () => {
renderer = create(
@@ -556,16 +559,16 @@ describe('NewWorktreeModal project targets', () => {
resolveOldList = resolve
})
const freshRepo = { ...repos[0]!, id: 'repo-fresh', displayName: 'fresh' }
const oldClient = {
sendRequest: vi.fn().mockImplementation((method: string) => {
const oldClient = requestsOnly(
vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return oldList
}
return new Promise(() => {})
})
} as unknown as RpcClient
const freshClient = {
sendRequest: vi.fn().mockImplementation((method: string) => {
)
const freshClient = requestsOnly(
vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return Promise.resolve({ ok: true, result: { repos: [freshRepo] } })
}
@@ -574,7 +577,7 @@ describe('NewWorktreeModal project targets', () => {
}
return new Promise(() => {})
})
} as unknown as RpcClient
)
const modalProps = {
visible: true,
hostId: 'host-1',
@@ -605,7 +608,7 @@ describe('NewWorktreeModal project targets', () => {
it('remounts before the reopened session renders, so no stale instance sees visible', async () => {
const sendRequest = vi.fn().mockImplementation(() => new Promise(() => {}))
const client = { sendRequest } as unknown as RpcClient
const client = requestsOnly(sendRequest)
const modalProps = {
client,
hostId: 'host-1',
@@ -630,8 +633,8 @@ describe('NewWorktreeModal project targets', () => {
// remounted the modal mid-edit and silently threw away the picked source.
it('keeps the picked source when a reconnect swaps the client for the same host', async () => {
const makeClient = () =>
({
sendRequest: vi.fn().mockImplementation((method: string) => {
requestsOnly(
vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return Promise.resolve({ ok: true, result: { repos } })
}
@@ -640,7 +643,7 @@ describe('NewWorktreeModal project targets', () => {
}
return new Promise(() => {})
})
}) as unknown as RpcClient
)
const modalProps = {
visible: true,
hostId: 'host-1',
@@ -681,9 +684,7 @@ describe('NewWorktreeModal project targets', () => {
it('keeps the form when a host switch renders but never commits', async () => {
setCachedRepos('host-2', repos)
const client = {
sendRequest: vi.fn().mockImplementation(() => new Promise(() => {}))
} as unknown as RpcClient
const client = requestsOnly(vi.fn().mockImplementation(() => new Promise(() => {})))
const modalProps = { visible: true, client, onCreated: () => {}, onClose: () => {} }
let frozen = false
const tree = (hostId: string) =>
@@ -708,9 +709,7 @@ describe('NewWorktreeModal project targets', () => {
})
it('keeps the form when a close renders but never commits', async () => {
const client = {
sendRequest: vi.fn().mockImplementation(() => new Promise(() => {}))
} as unknown as RpcClient
const client = requestsOnly(vi.fn().mockImplementation(() => new Promise(() => {})))
const modalProps = {
client,
hostId: 'host-1',
@@ -744,9 +743,7 @@ describe('NewWorktreeModal project targets', () => {
it('starts a fresh form session when the modal switches hosts', async () => {
setCachedRepos('host-2', repos)
const client = {
sendRequest: vi.fn().mockImplementation(() => new Promise(() => {}))
} as unknown as RpcClient
const client = requestsOnly(vi.fn().mockImplementation(() => new Promise(() => {})))
const modalProps = { visible: true, client, onCreated: () => {}, onClose: () => {} }
await act(async () => {
@@ -762,9 +759,7 @@ describe('NewWorktreeModal project targets', () => {
})
it('starts with fresh form state after closing and reopening', async () => {
const client = {
sendRequest: vi.fn().mockImplementation(() => new Promise(() => {}))
} as unknown as RpcClient
const client = requestsOnly(vi.fn().mockImplementation(() => new Promise(() => {})))
const modalProps = {
client,
hostId: 'host-1',
@@ -0,0 +1,315 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
import { useNewWorktreeRuntimeCapabilities } from './worktree-create-capability'
function connection() {
let generation = 1
const client = new FakeSession('connected')
client.getLastConnectedAt = () => generation
client.sendRequest.mockResolvedValue({
id: 'status',
ok: true,
result: {
capabilities: ['mobile.tasks.v1', 'worktree.create-idempotency.v1'],
hostPlatform: 'linux'
},
_meta: { runtimeId: 'test' }
})
return {
client,
sendRequest: client.sendRequest,
reconnect() {
generation += 1
client.publishState('connected')
}
}
}
describe('capability probe recovery', () => {
let renderer: ReactTestRenderer | undefined
let current: ReturnType<typeof useNewWorktreeRuntimeCapabilities> | undefined
function Harness({ client, enabled }: { client: RpcClient; enabled: boolean }) {
current = useNewWorktreeRuntimeCapabilities(client, enabled)
return null
}
async function render(client: RpcClient, enabled = true) {
await act(async () => {
const element = createElement(Harness, { client, enabled })
if (renderer) {
renderer.update(element)
} else {
renderer = create(element)
}
})
}
afterEach(() => {
act(() => renderer?.unmount())
renderer = undefined
vi.useRealTimers()
})
it('retries a transient failure when the create sheet reopens on the same client', async () => {
const host = connection()
host.sendRequest.mockRejectedValueOnce(new Error('offline'))
await render(host.client)
expect(current?.tasksSupported).toBe(false)
await render(host.client, false)
await render(host.client)
expect(current?.tasksSupported).toBe(true)
await expect(current?.getWorktreeCreateCutoverSupport()).resolves.toBeTruthy()
})
it('recovers Tasks without requiring a sheet reopen', async () => {
vi.useFakeTimers()
const host = connection()
host.sendRequest.mockRejectedValueOnce(new Error('timeout'))
await render(host.client)
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000)
})
expect(current?.tasksSupported).toBe(true)
})
it('does not publish a retired client probe after switching to a disabled host', async () => {
const first = connection()
const second = connection()
let resolve: ((value: Awaited<ReturnType<typeof first.sendRequest>>) => void) | undefined
first.sendRequest.mockImplementationOnce(
() =>
new Promise((done) => {
resolve = done
})
)
await render(first.client)
await render(second.client, false)
await act(async () =>
resolve?.({
id: 'late',
ok: true,
result: { capabilities: ['mobile.tasks.v1'] },
_meta: { runtimeId: 'retired' }
})
)
expect(current?.tasksSupported).toBe(false)
expect(current?.hostPlatform).toBeNull()
expect(second.sendRequest).not.toHaveBeenCalled()
})
it('reprobes after reconnect while keeping confirmed old hosts conservative', async () => {
const host = connection()
host.sendRequest.mockResolvedValueOnce({
id: 'status',
ok: true,
result: { capabilities: [] },
_meta: { runtimeId: 'old' }
})
await render(host.client)
await expect(current?.getWorktreeCreateCutoverSupport()).resolves.toBe(false)
expect(host.sendRequest).toHaveBeenCalledTimes(1)
await act(async () => host.reconnect())
expect(current?.tasksSupported).toBe(true)
expect(host.sendRequest).toHaveBeenCalledTimes(2)
})
it.each(['before', 'after'])(
'ignores a prior connection reply resolved %s the current reply',
async (order) => {
const host = connection()
let resolveOld: ((value: Awaited<ReturnType<typeof host.sendRequest>>) => void) | undefined
let resolveNew: ((value: Awaited<ReturnType<typeof host.sendRequest>>) => void) | undefined
host.sendRequest.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveOld = resolve
})
)
host.sendRequest.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveNew = resolve
})
)
await render(host.client)
await act(async () => host.reconnect())
const oldReply: Awaited<ReturnType<typeof host.sendRequest>> = {
id: 'old',
ok: true,
result: { capabilities: ['mobile.tasks.v1', 'worktree.create-idempotency.v1'] },
_meta: { runtimeId: 'old' }
}
const newReply: Awaited<ReturnType<typeof host.sendRequest>> = {
id: 'new',
ok: true,
result: { capabilities: [] },
_meta: { runtimeId: 'new' }
}
await act(async () => {
if (order === 'before') {
resolveOld?.(oldReply)
}
resolveNew?.(newReply)
})
expect(current?.tasksSupported).toBe(false)
if (order === 'after') {
await act(async () => resolveOld?.(oldReply))
}
expect(current?.tasksSupported).toBe(false)
await expect(current?.getWorktreeCreateCutoverSupport()).resolves.toBe(false)
expect(host.sendRequest).toHaveBeenCalledTimes(2)
}
)
it('shares one pending status barrier between create callers and the sheet', async () => {
const host = connection()
let resolve: ((value: Awaited<ReturnType<typeof host.sendRequest>>) => void) | undefined
host.sendRequest.mockImplementationOnce(
() =>
new Promise((done) => {
resolve = done
})
)
await render(host.client)
const first = current?.getWorktreeCreateCutoverSupport()
const second = current?.getWorktreeCreateCutoverSupport()
const launch = current?.getAgentLaunchSupport()
expect(host.sendRequest).toHaveBeenCalledTimes(1)
await act(async () =>
resolve?.({
id: 'shared',
ok: true,
result: { capabilities: ['mobile.tasks.v1', 'worktree.create-idempotency.v1'] },
_meta: { runtimeId: 'test' }
})
)
await expect(first).resolves.toBeTruthy()
await expect(second).resolves.toBeTruthy()
await expect(launch).resolves.toBe(false)
expect(current?.tasksSupported).toBe(true)
})
it('cancels backoff while disabled and cannot rearm after unmount', async () => {
vi.useFakeTimers()
const host = connection()
host.sendRequest.mockRejectedValue(new Error('offline'))
await render(host.client)
await render(host.client, false)
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000)
})
expect(host.sendRequest).toHaveBeenCalledTimes(1)
await render(host.client)
expect(host.sendRequest).toHaveBeenCalledTimes(2)
act(() => renderer?.unmount())
renderer = undefined
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000)
})
expect(host.sendRequest).toHaveBeenCalledTimes(2)
})
it.each([true, false])(
'hides prior capabilities after reconnect with the sheet initially enabled=%s',
async (enabled) => {
const host = connection()
await render(host.client)
expect(current?.tasksSupported).toBe(true)
expect(current?.hostPlatform).toBe('linux')
await render(host.client, enabled)
let reject: ((reason: Error) => void) | undefined
host.sendRequest.mockImplementationOnce(
() =>
new Promise((_resolve, fail) => {
reject = fail
})
)
await act(async () => host.reconnect())
await render(host.client)
expect(current?.tasksSupported).toBe(false)
expect(current?.hostPlatform).toBeNull()
await act(async () => reject?.(new Error('replacement offline')))
expect(current?.tasksSupported).toBe(false)
}
)
it('bounds failed probes over ten minutes and stops all retries on close', async () => {
vi.useFakeTimers()
const host = connection()
host.sendRequest.mockRejectedValue(new Error('offline'))
await render(host.client)
await act(async () => {
await vi.advanceTimersByTimeAsync(600_000)
})
expect(host.sendRequest).toHaveBeenCalledTimes(24)
expect(vi.getTimerCount()).toBe(1)
await render(host.client, false)
expect(vi.getTimerCount()).toBe(0)
await act(async () => {
await vi.advanceTimersByTimeAsync(600_000)
})
expect(host.sendRequest).toHaveBeenCalledTimes(24)
})
it('does not poll a confirmed unsupported host across renders or sheet reopens', async () => {
vi.useFakeTimers()
const host = connection()
host.sendRequest.mockResolvedValue({
id: 'old',
ok: true,
result: { capabilities: [] },
_meta: { runtimeId: 'old' }
})
await render(host.client)
for (let reopen = 0; reopen < 20; reopen += 1) {
await render(host.client, false)
await render(host.client)
}
await act(async () => {
await vi.advanceTimersByTimeAsync(600_000)
})
expect(host.sendRequest).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
})
it.each(['old-first', 'new-first'])(
'leaves no retry or RPC after overlapping reconnect failures settle %s and unmount',
async (order) => {
vi.useFakeTimers()
const host = connection()
let failOld: ((reason: Error) => void) | undefined
let failNew: ((reason: Error) => void) | undefined
host.sendRequest.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
failOld = reject
})
)
host.sendRequest.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
failNew = reject
})
)
await render(host.client)
await act(async () => host.reconnect())
await act(async () => {
if (order === 'old-first') {
failOld?.(new Error('old offline'))
}
failNew?.(new Error('new offline'))
})
if (order === 'new-first') {
await act(async () => failOld?.(new Error('old offline')))
}
expect(vi.getTimerCount()).toBe(1)
act(() => renderer?.unmount())
renderer = undefined
expect(vi.getTimerCount()).toBe(0)
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000)
})
expect(host.sendRequest).toHaveBeenCalledTimes(2)
host.reconnect()
expect(host.sendRequest).toHaveBeenCalledTimes(2)
}
)
})
@@ -238,11 +238,6 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
})
it('fails closed when capability detection is unavailable', async () => {
await expect(readNewWorktreeRuntimeCapabilities(statusClient(['error']))).resolves.toEqual({
tasksSupported: false,
worktreeCreateIdempotency: false,
agentLaunch: false,
hostPlatform: null
})
await expect(readNewWorktreeRuntimeCapabilities(statusClient(['error']))).resolves.toBeNull()
})
})
+102 -31
View File
@@ -27,25 +27,18 @@ export type NewWorktreeRuntimeCapabilities = {
hostPlatform: NodeJS.Platform | null
}
const UNSUPPORTED_CAPABILITIES: NewWorktreeRuntimeCapabilities = {
tasksSupported: false,
worktreeCreateIdempotency: false,
agentLaunch: false,
hostPlatform: null
}
// Why: status.get is safe to replay and must settle before create, independently
// of slower provider probes, so ambiguous cutover retries are gated correctly.
export async function readNewWorktreeRuntimeCapabilities(
client: RpcClient
): Promise<NewWorktreeRuntimeCapabilities> {
): Promise<NewWorktreeRuntimeCapabilities | null> {
for (let migrationRetry = 0; ; migrationRetry += 1) {
try {
const status = worktreeCreateCapabilityRead.interpret(
await worktreeCreateCapabilityRead.request(client)
)
if (!status.accepted) {
return UNSUPPORTED_CAPABILITIES
return null
}
const result = status.value
const capabilities = result.capabilities ?? []
@@ -63,7 +56,9 @@ export async function readNewWorktreeRuntimeCapabilities(
typeof advertisedIdempotency === 'object' &&
!Array.isArray(advertisedIdempotency)
? resolveWorktreeCreateIdempotencySupport(
(advertisedIdempotency as { dedupeTtlMs?: unknown }).dedupeTtlMs
'dedupeTtlMs' in advertisedIdempotency
? advertisedIdempotency.dedupeTtlMs
: undefined
)
: { dedupeTtlMs: 0 }
: false,
@@ -71,7 +66,7 @@ export async function readNewWorktreeRuntimeCapabilities(
}
} catch (error) {
if (!isLogicalClientCutoverError(error) || migrationRetry >= STATUS_CUTOVER_MAX_RETRIES) {
return UNSUPPORTED_CAPABILITIES
return null
}
}
}
@@ -86,49 +81,125 @@ export function useNewWorktreeRuntimeCapabilities(
getWorktreeCreateCutoverSupport: () => Promise<WorktreeCreateIdempotencySupport | false>
getAgentLaunchSupport: () => Promise<AgentLaunchSupport | false>
} {
const [tasksSupported, setTasksSupported] = useState(false)
const [hostPlatform, setHostPlatform] = useState<NodeJS.Platform | null>(null)
const [capabilityState, setCapabilityState] = useState<{
client: RpcClient
generation: number | undefined
connectedAt: number | null | undefined
capabilities: NewWorktreeRuntimeCapabilities
} | null>(null)
const capabilityProbeRef = useRef<{
client: RpcClient | null
promise: Promise<NewWorktreeRuntimeCapabilities>
generation: number | undefined
connectedAt: number | null | undefined
promise: Promise<NewWorktreeRuntimeCapabilities | null>
} | null>(null)
const getCapabilities = useCallback((): Promise<NewWorktreeRuntimeCapabilities> => {
if (!capabilityProbeRef.current || capabilityProbeRef.current.client !== client) {
// Why: a queued tap can reach Create before passive effects run; lazily
// starting one shared probe keeps that path from failing open.
capabilityProbeRef.current = {
const getCapabilities = useCallback(async (): Promise<NewWorktreeRuntimeCapabilities | null> => {
const generation = client?.getGeneration?.()
const connectedAt = client?.getLastConnectedAt()
let probe = capabilityProbeRef.current
if (
!probe ||
probe.client !== client ||
probe.generation !== generation ||
probe.connectedAt !== connectedAt
) {
// A queued create can arrive before passive effects; share its status barrier.
probe = {
client,
promise: client
? readNewWorktreeRuntimeCapabilities(client)
: Promise.resolve(UNSUPPORTED_CAPABILITIES)
generation,
connectedAt,
promise: client ? readNewWorktreeRuntimeCapabilities(client) : Promise.resolve(null)
}
capabilityProbeRef.current = probe
}
return capabilityProbeRef.current.promise
const capabilities = await probe.promise
if (
capabilityProbeRef.current !== probe ||
client?.getGeneration?.() !== generation ||
client?.getLastConnectedAt() !== connectedAt
) {
return null
}
if (!capabilities && capabilityProbeRef.current === probe) {
// A failed request says nothing about what the host supports.
capabilityProbeRef.current = null
}
return capabilities
}, [client])
useEffect(() => {
if (capabilityProbeRef.current?.client !== client) {
capabilityProbeRef.current = null
}
}, [client])
// oxlint-disable-next-line react-doctor/effect-needs-cleanup -- Cleanup cancels the timer and fences async rearming.
useEffect(() => {
if (!enabled || !client) {
return
}
let stale = false
void getCapabilities().then((capabilities) => {
if (!stale) {
setTasksSupported(capabilities.tasksSupported)
setHostPlatform(capabilities.hostPlatform)
let refreshVersion = 0
let retry: ReturnType<typeof setTimeout> | undefined
let retryDelay = 1_000
const refresh = async () => {
if (stale) {
return
}
const version = ++refreshVersion
clearTimeout(retry)
const capabilities = await getCapabilities()
if (stale || version !== refreshVersion) {
return
}
if (capabilities) {
setCapabilityState({
client,
generation: client.getGeneration?.(),
connectedAt: client.getLastConnectedAt(),
capabilities
})
retryDelay = 1_000
}
if (!capabilityProbeRef.current && client.getState() === 'connected') {
retry = setTimeout(() => void refresh(), retryDelay)
retryDelay = Math.min(retryDelay * 2, 30_000)
}
}
const unsubscribe = client.onStateChange((state) => {
if (state === 'connected') {
setCapabilityState(null)
capabilityProbeRef.current = null
void refresh()
}
})
void refresh()
return () => {
stale = true
clearTimeout(retry)
unsubscribe()
}
}, [client, enabled, getCapabilities, setTasksSupported])
}, [client, enabled, getCapabilities])
const getWorktreeCreateCutoverSupport = useCallback(
() => getCapabilities().then((capabilities) => capabilities.worktreeCreateIdempotency),
() =>
getCapabilities().then((capabilities) => capabilities?.worktreeCreateIdempotency ?? false),
[getCapabilities]
)
const getAgentLaunchSupport = useCallback(
() => getCapabilities().then((capabilities) => capabilities.agentLaunch),
() => getCapabilities().then((capabilities) => capabilities?.agentLaunch ?? false),
[getCapabilities]
)
return { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport, getAgentLaunchSupport }
const capabilities =
capabilityState?.client === client &&
capabilityState?.generation === client?.getGeneration?.() &&
capabilityState?.connectedAt === client?.getLastConnectedAt()
? capabilityState?.capabilities
: null
return {
tasksSupported: capabilities?.tasksSupported ?? false,
hostPlatform: capabilities?.hostPlatform ?? null,
getWorktreeCreateCutoverSupport,
getAgentLaunchSupport
}
}
@@ -97,7 +97,7 @@ type ClientVisibleOutcome = {
probedCapabilities: readonly string[] | null
answeredProbe: boolean
quickCommandsSupported: boolean
worktreeCreateSupport: NewWorktreeRuntimeCapabilities
worktreeCreateSupport: NewWorktreeRuntimeCapabilities | null
}
/** Every released path that reads status.get, plus the states derived from what it published. */
@@ -160,8 +160,8 @@ describe('mobileWeb.bundle.v1 on a released client', () => {
expect(advertised.gateStatus).not.toBeNull()
expect(advertised.probedCapabilities).toContain(MOBILE_WEB_BUNDLE_CAPABILITY)
expect(advertised.quickCommandsSupported).toBe(true)
expect(advertised.worktreeCreateSupport.tasksSupported).toBe(true)
expect(advertised.worktreeCreateSupport.worktreeCreateIdempotency).not.toBe(false)
expect(advertised.worktreeCreateSupport?.tasksSupported).toBe(true)
expect(advertised.worktreeCreateSupport?.worktreeCreateIdempotency).not.toBe(false)
expect(oldDesktop.gates.hostCapabilities).not.toContain(MOBILE_WEB_BUNDLE_CAPABILITY)
expect(asIfNeverAdvertised(advertised)).toEqual(oldDesktop)