mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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:
+27
-14
@@ -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": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user