fix(desktop): never replay a failed RPC on a different gateway

useGatewayRequest recovered a transport failure through the current active
route and retried the original payload there. A gateway switch between
dispatch and the delayed rejection replayed the request on the other source.
Retry only while the dispatch route is still active; otherwise surface the
original error.
This commit is contained in:
unsupportedpastels
2026-10-03 14:37:46 +00:00
committed by Teknium
parent ad4fed6b8d
commit 6cd5b5ca18
2 changed files with 93 additions and 2 deletions

View File

@@ -318,6 +318,76 @@ describe('useGatewayRequest', () => {
expect(gateway.connect).toHaveBeenLastCalledWith(expect.stringContaining('ticket=fresh-2'))
})
it.each(['default', 'research'])(
'does not replay a delayed local failure onto another source (%s)',
async profile => {
installRemoteDesktop()
const primary = makePrimaryGateway()
const failure = new Error('connection closed')
let rejectRequest!: (error: Error) => void
primary.request.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectRequest = reject
})
)
setPrimaryGateway(primary as unknown as HermesGateway, 'default')
$gateway.set(primary as unknown as HermesGateway)
$gatewayState.set('open')
const { result } = renderHook(() => useGatewayRequest())
const pending = result.current
.requestGateway('prompt.submit', { session_id: 'local-only', text: 'private' })
.catch(error => error)
await act(async () => {
await ensureGatewayForAgent('home', profile)
})
const destination = $gateway.get() as unknown as TestGateway
destination.request.mockResolvedValue({ sent: true })
await act(async () => {
rejectRequest(failure)
})
expect(await pending).toBe(failure)
expect(primary.request).toHaveBeenCalledTimes(1)
expect(destination.request).not.toHaveBeenCalled()
}
)
it('does not replay when the source changes while recovery is pending', async () => {
const desktop = installRemoteDesktop()
const primary = makePrimaryGateway()
const failure = new Error('connection closed')
primary.connectionState = 'closed'
primary.request.mockRejectedValueOnce(failure).mockResolvedValue({ sent: true })
let finishRecovery!: () => void
primary.connect.mockImplementationOnce(
() =>
new Promise<void>(resolve => {
finishRecovery = resolve
})
)
setPrimaryGateway(primary as unknown as HermesGateway, 'default')
$gateway.set(primary as unknown as HermesGateway)
$gatewayState.set('closed')
const { result } = renderHook(() => useGatewayRequest())
const pending = result.current.requestGateway('prompt.submit', { text: 'local-only' }).catch(error => error)
await vi.waitFor(() => expect(primary.connect).toHaveBeenCalledTimes(1))
await act(async () => {
await ensureGatewayForAgent('home', 'default')
})
const destination = $gateway.get() as unknown as TestGateway
await act(async () => {
finishRecovery()
})
expect(await pending).toBe(failure)
expect(primary.request).toHaveBeenCalledTimes(1)
expect(destination.request).not.toHaveBeenCalled()
expect(desktop.getConnection).toHaveBeenCalledTimes(1)
})
it('does not reconnect for a non-transport request failure', async () => {
const { desktop, gateway } = await activateRemoteGateway()
const failure = Object.assign(new Error('request rejected'), { code: 'EVALIDATION' })

View File

@@ -5,7 +5,14 @@ import { useCallback, useEffect, useRef } from 'react'
import type { HermesGateway } from '@/hermes'
import { resolveDesktopGatewayWsUrl } from '@/lib/gateway-ws-url'
import { RECONNECT_ATTEMPT_TIMEOUT_MS, withTimeout } from '@/lib/with-timeout'
import { $gateway, activeGateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gateway'
import {
$gateway,
activeGateway,
activeGatewayConnectionId,
activeGatewayProfileKey,
ensureActiveGatewayOpen,
isActivePrimary
} from '@/store/gateway'
import { $gatewayState, setConnection } from '@/store/session'
export function useGatewayRequest() {
@@ -138,10 +145,20 @@ export function useGatewayRequest() {
throw new Error('Hermes gateway unavailable')
}
// Bind retries to the dispatch owner, not whichever source is focused
// when a delayed transport failure (or its reconnect) finishes.
const connectionId = activeGatewayConnectionId()
const profile = activeGatewayProfileKey()
const isDispatchRouteActive = () =>
(gatewayRef.current ?? activeGateway()) === gateway &&
activeGatewayConnectionId() === connectionId &&
activeGatewayProfileKey() === profile
try {
return await gateway.request<T>(method, params, timeoutMs, signal)
} catch (error) {
if (!isGatewayTransportError(error)) {
if (!isGatewayTransportError(error) || !isDispatchRouteActive()) {
throw error
}
@@ -151,6 +168,10 @@ export function useGatewayRequest() {
// sources.
const recovered = isActivePrimary() ? await ensureGatewayOpen() : await ensureActiveGatewayOpen()
if (!isDispatchRouteActive() || (recovered && recovered !== gateway)) {
throw error
}
if (!recovered) {
// Prefer the reauth error from the failed reconnect (OAuth session
// expired) over the generic transport error that triggered the retry.