From cced97d739361bcd85b02c899f94b76d134eab40 Mon Sep 17 00:00:00 2001 From: 4pmtong Date: Fri, 17 Jul 2026 23:56:53 +0800 Subject: [PATCH] fix: retry pending cloud key budget sync --- src/store/chatStore.ts | 77 +++++++++++++++++++++++- test/unit/store/chatStore.test.ts | 98 +++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 1 deletion(-) diff --git a/src/store/chatStore.ts b/src/store/chatStore.ts index 48de9ba13..8600b03cf 100644 --- a/src/store/chatStore.ts +++ b/src/store/chatStore.ts @@ -74,6 +74,9 @@ const clampHistorySummary = ( typeof value === 'string' ? value.slice(0, MAX_CHAT_HISTORY_SUMMARY_LENGTH) : undefined; +const CLOUD_KEY_PENDING_RETRY_DELAYS_MS = [1000, 1500, 2500, 5000]; +const BILLING_BUDGET_SYNC_PENDING_PATTERN = + /billing budget sync is still pending|budget sync.*pending/i; type ConfirmedUserPromptSources = { lastMessageContent?: unknown; @@ -109,6 +112,78 @@ const hasApiCode = (value: unknown, code: string) => value !== null && String((value as { code?: unknown }).code) === code; +const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +function getErrorText(value: unknown): string { + if (typeof value === 'string') { + return value; + } + if (Array.isArray(value)) { + return value.map(getErrorText).filter(Boolean).join(' '); + } + if (typeof value !== 'object' || value === null) { + return ''; + } + + const record = value as Record; + return [ + record.text, + record.message, + record.error, + record.detail, + record.response?.data?.text, + record.response?.data?.message, + record.response?.data?.detail, + ] + .map(getErrorText) + .filter(Boolean) + .join(' '); +} + +function isBillingBudgetSyncPending(value: unknown): boolean { + return BILLING_BUDGET_SYNC_PENDING_PATTERN.test(getErrorText(value)); +} + +async function fetchCloudUserKeyWithRetry(): Promise { + let lastPendingResponse: any = null; + + for ( + let attempt = 0; + attempt <= CLOUD_KEY_PENDING_RETRY_DELAYS_MS.length; + attempt += 1 + ) { + try { + const res = await proxyFetchGet('/api/v1/user/key'); + if (res?.value || !isBillingBudgetSyncPending(res)) { + return res; + } + lastPendingResponse = res; + } catch (error: any) { + if (!isBillingBudgetSyncPending(error)) { + throw error; + } + lastPendingResponse = error; + if (attempt >= CLOUD_KEY_PENDING_RETRY_DELAYS_MS.length) { + throw error; + } + } + + if (attempt >= CLOUD_KEY_PENDING_RETRY_DELAYS_MS.length) { + return lastPendingResponse; + } + + const delayMs = CLOUD_KEY_PENDING_RETRY_DELAYS_MS[attempt]; + console.info('[startTask] Cloud user key pending billing sync; retrying', { + attempt: attempt + 1, + delayMs, + }); + await delay(delayMs); + } + + return lastPendingResponse; +} + let _host: AppHost | null = null; // Per-step request_usage tokens keyed by `${taskId}:${agentId}`; needed @@ -1656,7 +1731,7 @@ const chatStore = (initial?: Partial) => let res: any; try { - res = await proxyFetchGet('/api/v1/user/key'); + res = await fetchCloudUserKeyWithRetry(); } catch (error: any) { finishStartupFailure(); const responseData = error?.response?.data; diff --git a/test/unit/store/chatStore.test.ts b/test/unit/store/chatStore.test.ts index 24f265fcc..d4f632861 100644 --- a/test/unit/store/chatStore.test.ts +++ b/test/unit/store/chatStore.test.ts @@ -999,6 +999,104 @@ describe('ChatStore - Core Functionality', () => { }); }); + describe('Cloud key fetching', () => { + it('retries the cloud user key while billing budget sync is pending', async () => { + vi.useFakeTimers(); + const { result } = renderHook(() => useChatStore()); + const appendInitChatStore = vi.fn( + (_projectId: string, customTaskId?: string) => { + const optimisticTaskId = result.current + .getState() + .create(customTaskId || 'cloud-key-task'); + result.current.getState().setActiveTaskId(optimisticTaskId); + return { + taskId: optimisticTaskId, + chatStore: result.current, + }; + } + ); + const setHistoryId = vi.fn(); + const getProjectStoreState = vi.mocked(useProjectStore.getState); + const previousProjectStoreImplementation = + getProjectStoreState.getMockImplementation(); + + try { + getProjectStoreState.mockReturnValue({ + activeProjectId: 'project-1', + appendInitChatStore, + getProjectById: () => ({ + id: 'project-1', + mode: 'single-agent', + }), + getProjectModel: () => null, + setProjectModel: vi.fn(), + getHistoryId: () => null, + getAllChatStores: () => [{ chatStore: result.current }], + setHistoryId, + } as any); + + let userKeyCalls = 0; + vi.mocked(proxyFetchGet).mockImplementation((url: string) => { + if (url === '/api/v1/user/key') { + userKeyCalls += 1; + if (userKeyCalls < 3) { + return Promise.resolve({ + value: '', + text: 'Billing budget sync is still pending. Please retry in a moment.', + }); + } + return Promise.resolve({ + value: 'cloud-key', + api_url: 'https://cloud.example', + }); + } + if (url === '/api/v1/remote-sub-agent-providers') { + return Promise.resolve({ items: [] }); + } + return Promise.resolve({ items: [] }); + }); + + const initialTaskId = result.current + .getState() + .create('initial-cloud-key-task'); + let startPromise!: Promise; + act(() => { + startPromise = result.current + .getState() + .startTask( + initialTaskId, + undefined, + undefined, + undefined, + 'Start from remote control', + [], + undefined, + 'project-1', + 'single-agent' as any + ); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + await startPromise; + }); + + expect(userKeyCalls).toBe(3); + expect(fetchEventSource).toHaveBeenCalled(); + expect( + String(vi.mocked(fetchEventSource).mock.calls[0]?.[1]?.body) + ).toContain('"api_key":"cloud-key"'); + } finally { + vi.useRealTimers(); + if (previousProjectStoreImplementation) { + getProjectStoreState.mockImplementation( + previousProjectStoreImplementation + ); + } + } + }); + }); + describe('Cross-store task safety', () => { it('does not create phantom tasks through task-scoped setters', () => { const { result } = renderHook(() => useChatStore());