From 6cd5b5ca186a2ef766f5828243400a6b0b4741f3 Mon Sep 17 00:00:00 2001 From: unsupportedpastels Date: Sat, 3 Oct 2026 14:37:46 +0000 Subject: [PATCH] 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. --- .../gateway/hooks/use-gateway-request.test.ts | 70 +++++++++++++++++++ .../app/gateway/hooks/use-gateway-request.ts | 25 ++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-request.test.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-request.test.ts index f44505d714..f2dc308b8b 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-request.test.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-request.test.ts @@ -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(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' }) diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts index 54fdc79dbe..c7442aaf24 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts @@ -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(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.