From 4a722238218156bc17b8c9064c2972a0d1b1ec7b Mon Sep 17 00:00:00 2001 From: obchain Date: Tue, 19 May 2026 12:17:24 +0530 Subject: [PATCH 1/7] feat(rpc): add per-call timeoutMs override to callCoreRpc The global CORE_RPC_TIMEOUT_MS (30s) treats every RPC as equally fast/slow, but slow-but-alive paths such as first-launch openhuman.app_state_snapshot legitimately run for 35-40s on M-series Macs while memory tree init, Composio warmup, and other boot work compete for the snapshot critical path. Capping every call at 30s forces those users to a failure-style fallback even though the call would have succeeded a few seconds later. Add an optional timeoutMs override, clamped to the same [1s, 10min] window as the global default, so callers like fetchCoreAppSnapshot can opt into a longer-but-still-bounded budget without changing the default for fast RPCs. Refs #2156. --- .../services/__tests__/coreRpcClient.test.ts | 107 ++++++++++++++++++ app/src/services/coreRpcClient.ts | 37 +++++- 2 files changed, 138 insertions(+), 6 deletions(-) diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts index 33faf8aee2..e1a394ffbe 100644 --- a/app/src/services/__tests__/coreRpcClient.test.ts +++ b/app/src/services/__tests__/coreRpcClient.test.ts @@ -335,6 +335,113 @@ describe('coreRpcClient', () => { } }); + test('honors per-call timeoutMs override instead of the global default (#2156)', async () => { + vi.useFakeTimers(); + try { + const fetchMock = vi.mocked(fetch); + fetchMock.mockImplementationOnce( + (_url, init) => + new Promise((_resolve, reject) => { + const signal = (init as RequestInit).signal as AbortSignal | undefined; + if (!signal) return; + const onAbort = () => { + const err = new Error('The operation was aborted'); + err.name = 'AbortError'; + reject(err); + }; + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + }) + ); + + const pending = callCoreRpc({ + method: 'openhuman.app_state_snapshot', + timeoutMs: 60_000, + }); + pending.catch(() => {}); + + // 30s passes — global default would have aborted by now, but the + // per-call 60s override keeps the request alive. + await vi.advanceTimersByTimeAsync(31_000); + // Not yet rejected. Advance to the override boundary. + await vi.advanceTimersByTimeAsync(30_000); + + await expect(pending).rejects.toThrow( + 'Core RPC openhuman.app_state_snapshot timed out after 60000ms' + ); + } finally { + vi.useRealTimers(); + } + }); + + test('clamps an oversize timeoutMs to the MAX bound (10 minutes)', async () => { + vi.useFakeTimers(); + try { + const fetchMock = vi.mocked(fetch); + fetchMock.mockImplementationOnce( + (_url, init) => + new Promise((_resolve, reject) => { + const signal = (init as RequestInit).signal as AbortSignal | undefined; + if (!signal) return; + const onAbort = () => { + const err = new Error('The operation was aborted'); + err.name = 'AbortError'; + reject(err); + }; + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + }) + ); + + const pending = callCoreRpc({ + method: 'openhuman.app_state_snapshot', + // 2 hours — far beyond the 10 minute clamp; should be reduced. + timeoutMs: 2 * 60 * 60 * 1_000, + }); + pending.catch(() => {}); + + const MAX_MS = 10 * 60 * 1_000; + await vi.advanceTimersByTimeAsync(MAX_MS + 1); + + await expect(pending).rejects.toThrow( + `Core RPC openhuman.app_state_snapshot timed out after ${MAX_MS}ms` + ); + } finally { + vi.useRealTimers(); + } + }); + + test('falls back to the global default when timeoutMs is undefined', async () => { + vi.useFakeTimers(); + try { + const fetchMock = vi.mocked(fetch); + fetchMock.mockImplementationOnce( + (_url, init) => + new Promise((_resolve, reject) => { + const signal = (init as RequestInit).signal as AbortSignal | undefined; + if (!signal) return; + const onAbort = () => { + const err = new Error('The operation was aborted'); + err.name = 'AbortError'; + reject(err); + }; + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + }) + ); + + const pending = callCoreRpc({ method: 'openhuman.threads_list' }); + pending.catch(() => {}); + + await vi.advanceTimersByTimeAsync(CORE_RPC_TIMEOUT_MS + 1); + await expect(pending).rejects.toThrow( + `Core RPC openhuman.threads_list timed out after ${CORE_RPC_TIMEOUT_MS}ms` + ); + } finally { + vi.useRealTimers(); + } + }); + test('does not trigger the timeout path when fetch resolves promptly', async () => { const fetchMock = vi.mocked(fetch); fetchMock.mockResolvedValueOnce({ diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index 5870fc9ba0..9b840abb85 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -12,6 +12,27 @@ interface CoreRpcRelayRequest { method: string; params?: unknown; serviceManaged?: boolean; + /** + * Per-call timeout override in milliseconds. When omitted, defaults to the + * global `CORE_RPC_TIMEOUT_MS` (30s). Use for slow-but-alive RPCs such as + * first-launch `openhuman.app_state_snapshot` (#2156). Clamped to the same + * [MIN, MAX] window as the global default. + */ + timeoutMs?: number; +} + +/** Mirror of `parseCoreRpcTimeoutMs` bounds in `utils/config.ts`. */ +const PER_CALL_TIMEOUT_MIN_MS = 1_000; +const PER_CALL_TIMEOUT_MAX_MS = 10 * 60 * 1_000; + +function resolvePerCallTimeoutMs(override: number | undefined): number { + if (override === undefined) return CORE_RPC_TIMEOUT_MS; + if (!Number.isFinite(override)) return CORE_RPC_TIMEOUT_MS; + const clamped = Math.min( + Math.max(Math.round(override), PER_CALL_TIMEOUT_MIN_MS), + PER_CALL_TIMEOUT_MAX_MS + ); + return clamped; } interface JsonRpcRequestBody { @@ -336,6 +357,7 @@ export async function callCoreRpc({ method, params, serviceManaged = false, // kept for compatibility; direct frontend RPC does not use relay-level routing. + timeoutMs, }: CoreRpcRelayRequest): Promise { void serviceManaged; @@ -344,6 +366,7 @@ export async function callCoreRpc({ } const normalizedMethod = normalizeRpcMethod(method); + const effectiveTimeoutMs = resolvePerCallTimeoutMs(timeoutMs); const payload: JsonRpcRequestBody = { jsonrpc: '2.0', id: nextJsonRpcId++, @@ -362,12 +385,14 @@ export async function callCoreRpc({ if (token) { headers['Authorization'] = `Bearer ${token}`; } - // Bound the fetch to CORE_RPC_TIMEOUT_MS. Without this a hung core - // sidecar will block every caller (and the UI) forever. We use a - // manual AbortController + setTimeout rather than AbortSignal.timeout() - // so test fake timers can drive the abort deterministically. + // Bound the fetch. Without this a hung core sidecar would block every + // caller (and the UI) forever. We use a manual AbortController + + // setTimeout rather than AbortSignal.timeout() so test fake timers can + // drive the abort deterministically. Per-call `timeoutMs` (clamped) lets + // legitimately-slow RPCs such as first-launch `app_state_snapshot` + // (#2156) opt into a longer-but-still-bounded budget. const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), CORE_RPC_TIMEOUT_MS); + const timeoutId = setTimeout(() => controller.abort(), effectiveTimeoutMs); let response: Response; try { response = await fetch(rpcUrl, { @@ -378,7 +403,7 @@ export async function callCoreRpc({ }); } catch (fetchErr) { if (controller.signal.aborted) { - throw new Error(`Core RPC ${payload.method} timed out after ${CORE_RPC_TIMEOUT_MS}ms`); + throw new Error(`Core RPC ${payload.method} timed out after ${effectiveTimeoutMs}ms`); } throw fetchErr; } finally { From 3cc2609a4d673cc856e2b186f7eec6eca1f5bbb7 Mon Sep 17 00:00:00 2001 From: obchain Date: Tue, 19 May 2026 12:17:32 +0530 Subject: [PATCH 2/7] fix(core-state): raise app_state_snapshot RPC timeout to 90s (#2156) First-launch openhuman.app_state_snapshot can take 30-40s on slow hardware while memory tree init and Composio registry warmup compete for the snapshot critical path. The previous global 30s timeout killed those calls and parked users on the post-login fallback even though the backend would have answered moments later. Pass SNAPSHOT_TIMEOUT_MS=90s via the new callCoreRpc per-call timeoutMs option so slow-but-alive cores complete inline. Real failures still abort within 90s rather than hanging forever. Refs #2156. --- app/src/services/coreStateApi.test.ts | 14 +++++++++++--- app/src/services/coreStateApi.ts | 12 ++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/app/src/services/coreStateApi.test.ts b/app/src/services/coreStateApi.test.ts index 10a34b78c0..92f2f2350f 100644 --- a/app/src/services/coreStateApi.test.ts +++ b/app/src/services/coreStateApi.test.ts @@ -67,13 +67,21 @@ describe('coreStateApi.fetchCoreAppSnapshot', () => { mockCallCoreRpc.mockReset(); }); - it('calls the correct RPC method', async () => { + it('calls the correct RPC method with the slow-snapshot timeout override (#2156)', async () => { mockCallCoreRpc.mockResolvedValueOnce({ result: makeSnapshotResult() }); - const { fetchCoreAppSnapshot } = await import('./coreStateApi'); + const { fetchCoreAppSnapshot, SNAPSHOT_TIMEOUT_MS } = await import('./coreStateApi'); await fetchCoreAppSnapshot(); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.app_state_snapshot' }); + // The first-launch snapshot legitimately runs past the global 30s default + // on slow M-series machines; verify the longer-but-still-bounded budget + // is threaded through to callCoreRpc instead of relying on the default. + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.app_state_snapshot', + timeoutMs: SNAPSHOT_TIMEOUT_MS, + }); + expect(SNAPSHOT_TIMEOUT_MS).toBeGreaterThan(30_000); + expect(SNAPSHOT_TIMEOUT_MS).toBeLessThanOrEqual(10 * 60 * 1_000); }); it('returns the inner result from the RPC envelope', async () => { diff --git a/app/src/services/coreStateApi.ts b/app/src/services/coreStateApi.ts index a4c6296b6d..b65dd823d1 100644 --- a/app/src/services/coreStateApi.ts +++ b/app/src/services/coreStateApi.ts @@ -48,9 +48,21 @@ interface AppStateSnapshotResult { }; } +/** + * First-launch `app_state_snapshot` can take 30–40s on M-series Macs while + * memory tree init, Composio registry warmup, and other boot work compete + * for the snapshot critical path (#2156). The global `CORE_RPC_TIMEOUT_MS` + * default of 30s caused users with merely slow-but-alive cores to be parked + * on the post-login fallback. Use a longer-but-still-bounded budget here so + * legitimate slow-success completes inline, while real failures still abort + * within `SNAPSHOT_TIMEOUT_MS` rather than hanging forever. + */ +export const SNAPSHOT_TIMEOUT_MS = 90_000; + export const fetchCoreAppSnapshot = async (): Promise => { const response = await callCoreRpc<{ result: AppStateSnapshotResult }>({ method: 'openhuman.app_state_snapshot', + timeoutMs: SNAPSHOT_TIMEOUT_MS, }); // Normalise the optional #1299 field at the API boundary so older core // builds without `meetAutoOrchestratorHandoff` still surface the From bc28f71bdd189a415c0754501ff6565c48817b86 Mon Sep 17 00:00:00 2001 From: obchain Date: Tue, 19 May 2026 12:17:45 +0530 Subject: [PATCH 3/7] fix(onboarding): staged still-working UI + alive probe for slow snapshot (#2156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the post-login profile build runs past the 30s hardcoded threshold, users today see no signal that the system is still making progress — the existing build animation looks identical to a hang. On a slow-but-alive core that legitimately needs ~40s to complete app_state_snapshot or learning_save_profile, this reads as broken login. Add a staged transition: after STILL_WORKING_THRESHOLD_MS the copy swaps to a calmer 'Still working on your profile…' message, and a lightweight core.ping probe runs on a 5s interval so the indicator can distinguish slow-but-alive vs truly unreachable cores. Continue to chat stays available throughout. Also pass SAVE_PROFILE_TIMEOUT_MS=90s on learning_save_profile so the legitimate slow-success completes inline instead of falling to the error path. i18n strings shipped for 12 locales. Refs #2156. --- app/src/lib/i18n/chunks/ar-4.ts | 5 + app/src/lib/i18n/chunks/bn-4.ts | 5 + app/src/lib/i18n/chunks/en-4.ts | 5 + app/src/lib/i18n/chunks/es-4.ts | 5 + app/src/lib/i18n/chunks/fr-4.ts | 5 + app/src/lib/i18n/chunks/hi-4.ts | 5 + app/src/lib/i18n/chunks/id-4.ts | 5 + app/src/lib/i18n/chunks/it-4.ts | 5 + app/src/lib/i18n/chunks/pt-4.ts | 5 + app/src/lib/i18n/chunks/ru-4.ts | 5 + app/src/lib/i18n/chunks/zh-CN-4.ts | 5 + app/src/lib/i18n/en.ts | 7 + .../onboarding/steps/ContextGatheringStep.tsx | 123 +++++++++++++- .../__tests__/ContextGatheringStep.test.tsx | 156 +++++++++++++++++- 14 files changed, 335 insertions(+), 6 deletions(-) diff --git a/app/src/lib/i18n/chunks/ar-4.ts b/app/src/lib/i18n/chunks/ar-4.ts index d72f81ceb6..cb42c02d68 100644 --- a/app/src/lib/i18n/chunks/ar-4.ts +++ b/app/src/lib/i18n/chunks/ar-4.ts @@ -143,6 +143,11 @@ const ar4: TranslationMap = { 'onboarding.contextGathering.continueToChat': 'المتابعة إلى المحادثة', 'onboarding.contextGathering.errorDesc': 'تعذّر إنشاء ملفك الكامل الآن، لكن لا بأس — يمكنك المتابعة وسيُبنى ملفك مع الوقت.', + 'onboarding.contextGathering.coreAlive': 'النواة متاحة — قد يستغرق التشغيل الأول دقيقة.', + 'onboarding.contextGathering.coreAliveProbing': 'يجري التحقق من اتصال النواة…', + 'onboarding.contextGathering.coreUnreachable': 'النواة لا تستجيب. يمكنك المتابعة والمحاولة لاحقًا.', + 'onboarding.contextGathering.stillWorkingDesc': 'قد يستغرق التشغيل الأول 30–60 ثانية أثناء تهيئة نموذجك المحلي والأدوات. يمكنك المتابعة إلى المحادثة في أي وقت — يستمر بناء الملف الشخصي في الخلفية.', + 'onboarding.contextGathering.stillWorkingTitle': 'لا يزال العمل جاريًا على ملفك الشخصي…', 'onboarding.contextGathering.title': 'جمع السياق', 'openhuman.team_list_teams': 'قائمة الفرق', 'overlay.ariaAttention': 'رسالة انتباه', diff --git a/app/src/lib/i18n/chunks/bn-4.ts b/app/src/lib/i18n/chunks/bn-4.ts index 5d6fd844a2..9e6268cc04 100644 --- a/app/src/lib/i18n/chunks/bn-4.ts +++ b/app/src/lib/i18n/chunks/bn-4.ts @@ -144,6 +144,11 @@ const bn4: TranslationMap = { 'onboarding.contextGathering.continueToChat': 'চ্যাটে চালিয়ে যান', 'onboarding.contextGathering.errorDesc': 'আমরা এখনই আপনার পূর্ণ প্রোফাইল তৈরি করতে পারিনি, কিন্তু সমস্যা নেই — আপনি চালিয়ে যেতে পারেন এবং আপনার প্রোফাইল সময়ের সাথে তৈরি হবে।', + 'onboarding.contextGathering.coreAlive': 'কোর সংযোগযোগ্য — প্রথম লঞ্চ এক মিনিট সময় নিতে পারে।', + 'onboarding.contextGathering.coreAliveProbing': 'কোর সংযোগ যাচাই করা হচ্ছে…', + 'onboarding.contextGathering.coreUnreachable': 'কোর সাড়া দিচ্ছে না। আপনি চালিয়ে যেতে পারেন এবং পরে আবার চেষ্টা করতে পারেন।', + 'onboarding.contextGathering.stillWorkingDesc': 'আমরা আপনার লোকাল মডেল এবং টুলস প্রস্তুত করছি, প্রথম লঞ্চ ৩০–৬০ সেকেন্ড সময় নিতে পারে। আপনি যেকোনো সময় চ্যাটে যেতে পারেন — প্রোফাইল তৈরি ব্যাকগ্রাউন্ডে চলতে থাকবে।', + 'onboarding.contextGathering.stillWorkingTitle': 'এখনও আপনার প্রোফাইলে কাজ চলছে…', 'onboarding.contextGathering.title': 'কন্টেক্সট সংগ্রহ', 'openhuman.team_list_teams': 'টিম তালিকা', 'overlay.ariaAttention': 'মনোযোগের বার্তা', diff --git a/app/src/lib/i18n/chunks/en-4.ts b/app/src/lib/i18n/chunks/en-4.ts index f55ca85ae5..181f3e1a54 100644 --- a/app/src/lib/i18n/chunks/en-4.ts +++ b/app/src/lib/i18n/chunks/en-4.ts @@ -144,6 +144,11 @@ const en4: TranslationMap = { 'onboarding.contextGathering.continueToChat': 'Continue to chat', 'onboarding.contextGathering.errorDesc': "We couldn't build your full profile right now, but that's okay — you can continue and your profile will build over time.", + 'onboarding.contextGathering.coreAlive': 'Core is reachable — first launch can take a minute.', + 'onboarding.contextGathering.coreAliveProbing': 'Checking core connection…', + 'onboarding.contextGathering.coreUnreachable': 'Core is not responding. You can continue and try again later.', + 'onboarding.contextGathering.stillWorkingDesc': 'First launch can take 30–60 seconds while we warm up your local model and tools. You can continue to chat at any time — profile build keeps running in the background.', + 'onboarding.contextGathering.stillWorkingTitle': 'Still working on your profile…', 'onboarding.contextGathering.title': 'Context Gathering', 'openhuman.team_list_teams': 'Team list teams', 'overlay.ariaAttention': 'Attention message', diff --git a/app/src/lib/i18n/chunks/es-4.ts b/app/src/lib/i18n/chunks/es-4.ts index 81353fc2ee..20df3a821f 100644 --- a/app/src/lib/i18n/chunks/es-4.ts +++ b/app/src/lib/i18n/chunks/es-4.ts @@ -144,6 +144,11 @@ const es4: TranslationMap = { 'onboarding.contextGathering.continueToChat': 'Continuar al chat', 'onboarding.contextGathering.errorDesc': 'No pudimos construir tu perfil completo ahora mismo, pero no pasa nada — puedes continuar y tu perfil se construirá con el tiempo.', + 'onboarding.contextGathering.coreAlive': 'El núcleo está disponible — el primer arranque puede tardar un minuto.', + 'onboarding.contextGathering.coreAliveProbing': 'Comprobando la conexión con el núcleo…', + 'onboarding.contextGathering.coreUnreachable': 'El núcleo no responde. Puedes continuar e intentarlo más tarde.', + 'onboarding.contextGathering.stillWorkingDesc': 'El primer arranque puede tardar 30–60 segundos mientras preparamos tu modelo local y tus herramientas. Puedes continuar al chat en cualquier momento — la construcción del perfil sigue en segundo plano.', + 'onboarding.contextGathering.stillWorkingTitle': 'Seguimos trabajando en tu perfil…', 'onboarding.contextGathering.title': 'Recopilación de contexto', 'openhuman.team_list_teams': 'Lista de equipos', 'overlay.ariaAttention': 'Mensaje de atención', diff --git a/app/src/lib/i18n/chunks/fr-4.ts b/app/src/lib/i18n/chunks/fr-4.ts index acddf8cdcf..397d6e09e9 100644 --- a/app/src/lib/i18n/chunks/fr-4.ts +++ b/app/src/lib/i18n/chunks/fr-4.ts @@ -144,6 +144,11 @@ const fr4: TranslationMap = { 'onboarding.contextGathering.continueToChat': 'Accéder au chat', 'onboarding.contextGathering.errorDesc': "Nous n'avons pas pu créer votre profil complet pour l'instant, mais ce n'est pas grave — vous pouvez continuer et votre profil se construira au fil du temps.", + 'onboarding.contextGathering.coreAlive': 'Le cœur est accessible — le premier lancement peut prendre une minute.', + 'onboarding.contextGathering.coreAliveProbing': 'Vérification de la connexion au cœur…', + 'onboarding.contextGathering.coreUnreachable': 'Le cœur ne répond pas. Tu peux continuer et réessayer plus tard.', + 'onboarding.contextGathering.stillWorkingDesc': 'Le premier lancement peut prendre 30 à 60 secondes pendant que nous préparons ton modèle local et tes outils. Tu peux accéder au chat à tout moment — la construction du profil continue en arrière-plan.', + 'onboarding.contextGathering.stillWorkingTitle': 'Construction de ton profil en cours…', 'onboarding.contextGathering.title': 'Collecte de contexte', 'openhuman.team_list_teams': 'Liste des équipes', 'overlay.ariaAttention': "Message d'attention", diff --git a/app/src/lib/i18n/chunks/hi-4.ts b/app/src/lib/i18n/chunks/hi-4.ts index 713bda55ed..13161901d6 100644 --- a/app/src/lib/i18n/chunks/hi-4.ts +++ b/app/src/lib/i18n/chunks/hi-4.ts @@ -144,6 +144,11 @@ const hi4: TranslationMap = { 'onboarding.contextGathering.continueToChat': 'चैट पर जाएं', 'onboarding.contextGathering.errorDesc': 'हम अभी आपकी पूरी प्रोफ़ाइल नहीं बना सके, लेकिन कोई बात नहीं — आप जारी रख सकते हैं और आपकी प्रोफ़ाइल समय के साथ बनती जाएगी।', + 'onboarding.contextGathering.coreAlive': 'कोर पहुँच योग्य है — पहली बार लॉन्च करने में एक मिनट लग सकता है।', + 'onboarding.contextGathering.coreAliveProbing': 'कोर कनेक्शन की जाँच की जा रही है…', + 'onboarding.contextGathering.coreUnreachable': 'कोर प्रतिक्रिया नहीं दे रहा है। आप जारी रख सकते हैं और बाद में पुनः प्रयास कर सकते हैं।', + 'onboarding.contextGathering.stillWorkingDesc': 'हम आपके स्थानीय मॉडल और टूल्स को तैयार कर रहे हैं, पहली बार लॉन्च करने में 30–60 सेकंड लग सकते हैं। आप कभी भी चैट पर जा सकते हैं — प्रोफ़ाइल पृष्ठभूमि में बनती रहेगी।', + 'onboarding.contextGathering.stillWorkingTitle': 'आपकी प्रोफ़ाइल पर अब भी काम चल रहा है…', 'onboarding.contextGathering.title': 'कॉन्टेक्स्ट गैदरिंग', 'openhuman.team_list_teams': 'टीम सूची टीमें', 'overlay.ariaAttention': 'ध्यान संदेश', diff --git a/app/src/lib/i18n/chunks/id-4.ts b/app/src/lib/i18n/chunks/id-4.ts index 927b8f0f46..cf42684235 100644 --- a/app/src/lib/i18n/chunks/id-4.ts +++ b/app/src/lib/i18n/chunks/id-4.ts @@ -144,6 +144,11 @@ const id4: TranslationMap = { 'onboarding.contextGathering.continueToChat': 'Lanjutkan ke chat', 'onboarding.contextGathering.errorDesc': 'Kami tidak bisa membangun profil lengkap Anda sekarang, tapi tidak apa-apa — Anda bisa lanjut dan profil Anda akan terbentuk seiring waktu.', + 'onboarding.contextGathering.coreAlive': 'Core dapat diakses — peluncuran pertama bisa memakan waktu satu menit.', + 'onboarding.contextGathering.coreAliveProbing': 'Memeriksa koneksi core…', + 'onboarding.contextGathering.coreUnreachable': 'Core tidak merespons. Anda bisa melanjutkan dan coba lagi nanti.', + 'onboarding.contextGathering.stillWorkingDesc': 'Peluncuran pertama bisa memakan waktu 30–60 detik sementara kami menyiapkan model dan alat lokal Anda. Anda bisa melanjutkan ke chat kapan saja — pembuatan profil tetap berjalan di latar belakang.', + 'onboarding.contextGathering.stillWorkingTitle': 'Masih membangun profil Anda…', 'onboarding.contextGathering.title': 'Pengumpulan Konteks', 'openhuman.team_list_teams': 'Daftar tim', 'overlay.ariaAttention': 'Pesan perhatian', diff --git a/app/src/lib/i18n/chunks/it-4.ts b/app/src/lib/i18n/chunks/it-4.ts index 164f67f2e8..388902752a 100644 --- a/app/src/lib/i18n/chunks/it-4.ts +++ b/app/src/lib/i18n/chunks/it-4.ts @@ -144,6 +144,11 @@ const it4: TranslationMap = { 'onboarding.contextGathering.continueToChat': 'Continua alla chat', 'onboarding.contextGathering.errorDesc': 'Non siamo riusciti a costruire il tuo profilo completo ora, ma va bene — puoi continuare e il tuo profilo si svilupperà nel tempo.', + 'onboarding.contextGathering.coreAlive': 'Il core è raggiungibile — il primo avvio può richiedere un minuto.', + 'onboarding.contextGathering.coreAliveProbing': 'Verifica della connessione al core…', + 'onboarding.contextGathering.coreUnreachable': 'Il core non risponde. Puoi continuare e riprovare più tardi.', + 'onboarding.contextGathering.stillWorkingDesc': 'Il primo avvio può richiedere 30–60 secondi mentre prepariamo il tuo modello locale e gli strumenti. Puoi continuare la chat in qualsiasi momento — la costruzione del profilo continua in background.', + 'onboarding.contextGathering.stillWorkingTitle': 'Stiamo ancora preparando il tuo profilo…', 'onboarding.contextGathering.title': 'Raccolta del contesto', 'openhuman.team_list_teams': 'Elenco team', 'overlay.ariaAttention': 'Messaggio di attenzione', diff --git a/app/src/lib/i18n/chunks/pt-4.ts b/app/src/lib/i18n/chunks/pt-4.ts index 94e27abf06..d12286aff7 100644 --- a/app/src/lib/i18n/chunks/pt-4.ts +++ b/app/src/lib/i18n/chunks/pt-4.ts @@ -145,6 +145,11 @@ const pt4: TranslationMap = { 'onboarding.contextGathering.continueToChat': 'Continuar para o chat', 'onboarding.contextGathering.errorDesc': 'Não conseguimos montar seu perfil completo agora, mas tudo bem — você pode continuar e seu perfil será construído ao longo do tempo.', + 'onboarding.contextGathering.coreAlive': 'Núcleo acessível — a primeira inicialização pode demorar um minuto.', + 'onboarding.contextGathering.coreAliveProbing': 'Verificando a conexão com o núcleo…', + 'onboarding.contextGathering.coreUnreachable': 'O núcleo não está respondendo. Você pode continuar e tentar novamente mais tarde.', + 'onboarding.contextGathering.stillWorkingDesc': 'A primeira inicialização pode levar 30–60 segundos enquanto preparamos seu modelo local e ferramentas. Você pode continuar para o chat a qualquer momento — a construção do perfil continua em segundo plano.', + 'onboarding.contextGathering.stillWorkingTitle': 'Ainda construindo seu perfil…', 'onboarding.contextGathering.title': 'Coleta de Contexto', 'openhuman.team_list_teams': 'Listar equipes', 'overlay.ariaAttention': 'Mensagem de atenção', diff --git a/app/src/lib/i18n/chunks/ru-4.ts b/app/src/lib/i18n/chunks/ru-4.ts index 19b1cbb5a2..782ab82e8d 100644 --- a/app/src/lib/i18n/chunks/ru-4.ts +++ b/app/src/lib/i18n/chunks/ru-4.ts @@ -144,6 +144,11 @@ const ru4: TranslationMap = { 'onboarding.contextGathering.continueToChat': 'Перейти в чат', 'onboarding.contextGathering.errorDesc': 'Мы не смогли построить ваш полный профиль прямо сейчас, но это нормально — вы можете продолжить, и профиль будет дополняться со временем.', + 'onboarding.contextGathering.coreAlive': 'Ядро доступно — первый запуск может занять минуту.', + 'onboarding.contextGathering.coreAliveProbing': 'Проверка соединения с ядром…', + 'onboarding.contextGathering.coreUnreachable': 'Ядро не отвечает. Можно продолжить и попробовать позже.', + 'onboarding.contextGathering.stillWorkingDesc': 'Первый запуск может занять 30–60 секунд, пока мы прогреваем локальную модель и инструменты. Вы можете перейти в чат в любой момент — построение профиля продолжится в фоне.', + 'onboarding.contextGathering.stillWorkingTitle': 'Профиль ещё составляется…', 'onboarding.contextGathering.title': 'Сбор контекста', 'openhuman.team_list_teams': 'Список команд', 'overlay.ariaAttention': 'Сообщение', diff --git a/app/src/lib/i18n/chunks/zh-CN-4.ts b/app/src/lib/i18n/chunks/zh-CN-4.ts index 16ae7501bf..21fd977bca 100644 --- a/app/src/lib/i18n/chunks/zh-CN-4.ts +++ b/app/src/lib/i18n/chunks/zh-CN-4.ts @@ -142,6 +142,11 @@ const zhCN4: TranslationMap = { 'onboarding.contextGathering.continueToChat': '前往对话', 'onboarding.contextGathering.errorDesc': '我们暂时无法构建你的完整资料,但没关系——你可以继续,资料会随时间逐步完善。', + 'onboarding.contextGathering.coreAlive': '核心可访问 — 首次启动可能需要一分钟。', + 'onboarding.contextGathering.coreAliveProbing': '正在检查核心连接…', + 'onboarding.contextGathering.coreUnreachable': '核心未响应。你可以继续,稍后再试。', + 'onboarding.contextGathering.stillWorkingDesc': '我们正在预热本地模型与工具,首次启动可能需要 30–60 秒。你可以随时进入对话 — 档案构建会在后台继续进行。', + 'onboarding.contextGathering.stillWorkingTitle': '仍在生成你的档案…', 'onboarding.contextGathering.title': '上下文收集', 'openhuman.team_list_teams': '团队列表', 'overlay.ariaAttention': '注意消息', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index dc93283e04..22f52df3ff 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1448,8 +1448,15 @@ const en: TranslationMap = { 'onboarding.contextGathering.buildingDesc': 'Gathering context from your connected accounts…', 'onboarding.contextGathering.buildingProfile': 'Building your profile...', 'onboarding.contextGathering.continueToChat': 'Continue to chat', + 'onboarding.contextGathering.coreAlive': 'Core is reachable — first launch can take a minute.', + 'onboarding.contextGathering.coreAliveProbing': 'Checking core connection…', + 'onboarding.contextGathering.coreUnreachable': + 'Core is not responding. You can continue and try again later.', 'onboarding.contextGathering.errorDesc': "We couldn't build your full profile right now, but that's okay — you can continue and your profile will build over time.", + 'onboarding.contextGathering.stillWorkingDesc': + 'First launch can take 30–60 seconds while we warm up your local model and tools. You can continue to chat at any time — profile build keeps running in the background.', + 'onboarding.contextGathering.stillWorkingTitle': 'Still working on your profile…', 'onboarding.contextGathering.title': 'Context Gathering', 'openhuman.team_list_teams': 'Team list teams', 'overlay.ariaAttention': 'Attention message', diff --git a/app/src/pages/onboarding/steps/ContextGatheringStep.tsx b/app/src/pages/onboarding/steps/ContextGatheringStep.tsx index 6593833733..394678fbc6 100644 --- a/app/src/pages/onboarding/steps/ContextGatheringStep.tsx +++ b/app/src/pages/onboarding/steps/ContextGatheringStep.tsx @@ -15,9 +15,26 @@ import { useEffect, useRef, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; -import { callCoreRpc } from '../../../services/coreRpcClient'; +import { + callCoreRpc, + getCoreRpcUrl, + testCoreRpcConnection, +} from '../../../services/coreRpcClient'; import OnboardingNextButton from '../components/OnboardingNextButton'; +/** + * Threshold after which the building-profile UI swaps to the "still working" + * staged copy (#2156). Real first-launch completions on slow M-series Macs + * can take 30–40s, so users would otherwise see what looks like a stall at + * the global RPC timeout boundary. + */ +const STILL_WORKING_THRESHOLD_MS = 30_000; + +/** How often we probe `core.ping` after entering the still-working state. */ +const ALIVE_PROBE_INTERVAL_MS = 5_000; + +type AliveState = 'unknown' | 'probing' | 'alive' | 'unreachable'; + interface ContextGatheringStepProps { connectedSources: string[]; onNext: () => void | Promise; @@ -138,10 +155,20 @@ async function findLinkedInUrlViaComposio(): Promise { return null; } +/** + * First-launch profile compression on slow hardware (#2156) can run past the + * global 30s RPC timeout while the core LLM compressor finishes. Use a + * longer-but-still-bounded budget so users with a slow-but-alive core are + * not parked on the post-login fallback when the call would otherwise + * succeed. Real failures still abort within `SAVE_PROFILE_TIMEOUT_MS`. + */ +const SAVE_PROFILE_TIMEOUT_MS = 90_000; + async function saveProfile(markdown: string): Promise { await callCoreRpc({ method: 'openhuman.learning_save_profile', params: { markdown, summarize: true }, + timeoutMs: SAVE_PROFILE_TIMEOUT_MS, }); } @@ -158,6 +185,10 @@ const ContextGatheringStep = ({ ); const [finished, setFinished] = useState(false); const [hasError, setHasError] = useState(false); + // Staged "still working" mode kicks in after STILL_WORKING_THRESHOLD_MS so + // a slow-but-alive first launch no longer looks like a stall (#2156). + const [stillWorking, setStillWorking] = useState(false); + const [aliveState, setAliveState] = useState('unknown'); const backgroundClickedRef = useRef(false); const ranRef = useRef(false); @@ -271,6 +302,49 @@ const ContextGatheringStep = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Staged "still working" trigger (#2156). After STILL_WORKING_THRESHOLD_MS + // of pipeline runtime, swap to the calmer copy and start probing + // `core.ping` so we can tell users whether the core is slow-but-alive vs + // truly unreachable. Cleared on finish/error so completed runs never flip + // to the slow-path UI retroactively. + useEffect(() => { + if (finished || !hasGmail || hasError) return; + const timer = window.setTimeout(() => { + setStillWorking(true); + }, STILL_WORKING_THRESHOLD_MS); + return () => window.clearTimeout(timer); + }, [finished, hasGmail, hasError]); + + // Periodic alive probe while in still-working state. `core.ping` bypasses + // bearer auth and resolves quickly even when the busy snapshot RPC is + // holding the worker, so a green ping during a slow snapshot is exactly + // the alive-but-slow signal users need to see. + useEffect(() => { + if (!stillWorking || finished || hasError) return; + let cancelled = false; + + const probe = async () => { + if (cancelled) return; + setAliveState(prev => (prev === 'unknown' ? 'probing' : prev)); + try { + const url = await getCoreRpcUrl(); + const response = await testCoreRpcConnection(url); + if (!cancelled) { + setAliveState(response.ok ? 'alive' : 'unreachable'); + } + } catch { + if (!cancelled) setAliveState('unreachable'); + } + }; + + void probe(); + const interval = window.setInterval(probe, ALIVE_PROBE_INTERVAL_MS); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [stillWorking, finished, hasError]); + // Auto-navigate on successful completion (skip if user already clicked background link) useEffect(() => { if (finished && !hasError && !backgroundClickedRef.current) { @@ -303,6 +377,20 @@ const ContextGatheringStep = ({ ); } + const titleKey = stillWorking + ? 'onboarding.contextGathering.stillWorkingTitle' + : 'onboarding.contextGathering.buildingProfile'; + const descKey = stillWorking + ? 'onboarding.contextGathering.stillWorkingDesc' + : 'onboarding.contextGathering.buildingDesc'; + + const aliveLabelKey: Record = { + unknown: 'onboarding.contextGathering.coreAliveProbing', + probing: 'onboarding.contextGathering.coreAliveProbing', + alive: 'onboarding.contextGathering.coreAlive', + unreachable: 'onboarding.contextGathering.coreUnreachable', + }; + return (
@@ -310,11 +398,15 @@ const ContextGatheringStep = ({
{/* Title */} -

- {t('onboarding.contextGathering.buildingProfile')} +

+ {t(titleKey)}

-

- {t('onboarding.contextGathering.buildingDesc')} +

+ {t(descKey)}

{/* Skeleton bars */} @@ -324,6 +416,27 @@ const ContextGatheringStep = ({
+ {/* Alive indicator — only after we entered still-working state so we + don't show a probe state during the normal sub-30s happy path. */} + {stillWorking && ( +
+
+ )} + {hasGmail && !finished && ( vi.fn()); -vi.mock('../../../../services/coreRpcClient', () => ({ callCoreRpc })); +const getCoreRpcUrl = vi.hoisted(() => vi.fn(async () => 'http://127.0.0.1:7788/rpc')); +const testCoreRpcConnection = vi.hoisted(() => + vi.fn(async () => ({ ok: true, status: 200 }) as Response) +); +vi.mock('../../../../services/coreRpcClient', () => ({ + callCoreRpc, + getCoreRpcUrl, + testCoreRpcConnection, +})); describe('ContextGatheringStep', () => { beforeEach(() => { callCoreRpc.mockReset(); + getCoreRpcUrl.mockClear(); + testCoreRpcConnection.mockClear(); + testCoreRpcConnection.mockResolvedValue({ ok: true, status: 200 } as Response); }); it('no-Gmail branch: auto-navigates without any RPC', async () => { @@ -281,4 +292,147 @@ describe('ContextGatheringStep', () => { // fireEvent not needed — onNext is available via the button but user can also // just verify the friendly message is shown }); + + // -------------------------------------------------------------------------- + // #2156 — slow-but-alive snapshot / save_profile path + // -------------------------------------------------------------------------- + describe('staged still-working UI (#2156)', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('passes a 90s timeoutMs override on learning_save_profile so slow first launches finish', async () => { + const onNext = vi.fn().mockResolvedValue(undefined); + callCoreRpc.mockImplementation(async (req: { method: string }) => { + if (req.method === 'openhuman.tools_composio_execute') { + return { + successful: true, + data: { messages: [{ messageText: 'https://www.linkedin.com/in/jane-doe' }] }, + }; + } + if (req.method === 'openhuman.learning_save_profile') { + return { path: '/tmp/PROFILE.md', bytes: 1 }; + } + throw new Error(`unexpected RPC ${req.method}`); + }); + + renderWithProviders( + + ); + + await waitFor(() => expect(onNext).toHaveBeenCalled(), { timeout: 5000 }); + + const saveCall = callCoreRpc.mock.calls.find( + (c: Array<{ method: string; timeoutMs?: number }>) => + c[0].method === 'openhuman.learning_save_profile' + ); + expect(saveCall![0].timeoutMs).toBeGreaterThan(30_000); + expect(saveCall![0].timeoutMs).toBeLessThanOrEqual(10 * 60 * 1_000); + }); + + it('swaps to the still-working copy after 30s while the pipeline is still pending', async () => { + vi.useFakeTimers(); + let resolveGmail!: (v: unknown) => void; + callCoreRpc.mockImplementation( + () => + new Promise(res => { + resolveGmail = res; + }) + ); + + renderWithProviders( + + ); + + // Initial copy — fast happy path. + expect(screen.getByTestId('context-gathering-title').textContent).toMatch( + /building your profile/i + ); + expect(screen.queryByTestId('core-alive-indicator')).not.toBeInTheDocument(); + + // Cross the 30s threshold. + await act(async () => { + await vi.advanceTimersByTimeAsync(30_500); + }); + + expect(screen.getByTestId('context-gathering-title').textContent).toMatch( + /still working/i + ); + // Indicator appears so users see whether core is alive or unreachable. + expect(screen.getByTestId('core-alive-indicator')).toBeInTheDocument(); + + await act(async () => { + resolveGmail({ successful: true, data: { messages: [] } }); + }); + }); + + it('reports the core as alive when core.ping returns ok', async () => { + // Fake timers must be active before render so the 30s still-working + // setTimeout registers against the fake clock. + vi.useFakeTimers(); + let resolveGmail!: (v: unknown) => void; + callCoreRpc.mockImplementation( + () => + new Promise(res => { + resolveGmail = res; + }) + ); + + renderWithProviders( + + ); + + // Drive the still-working transition under fake timers, then flip + // back to real timers so waitFor can poll the React commit for the + // resolved indicator state (waitFor doesn't observe fake-time + // microtasks reliably). + await act(async () => { + await vi.advanceTimersByTimeAsync(30_500); + }); + vi.useRealTimers(); + + const indicator = await screen.findByTestId('core-alive-indicator'); + await waitFor(() => { + expect(indicator.getAttribute('data-alive-state')).toBe('alive'); + }); + expect(testCoreRpcConnection).toHaveBeenCalled(); + expect(indicator.textContent ?? '').toMatch(/core is reachable/i); + + await act(async () => { + resolveGmail({ successful: true, data: { messages: [] } }); + }); + }); + + it('reports unreachable when core.ping rejects', async () => { + vi.useFakeTimers(); + testCoreRpcConnection.mockRejectedValue(new Error('ECONNREFUSED')); + + let resolveGmail!: (v: unknown) => void; + callCoreRpc.mockImplementation( + () => + new Promise(res => { + resolveGmail = res; + }) + ); + + renderWithProviders( + + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(30_500); + }); + vi.useRealTimers(); + + const indicator = await screen.findByTestId('core-alive-indicator'); + await waitFor(() => { + expect(indicator.getAttribute('data-alive-state')).toBe('unreachable'); + }); + expect(indicator.textContent ?? '').toMatch(/core is not responding/i); + + await act(async () => { + resolveGmail({ successful: true, data: { messages: [] } }); + }); + }); + }); }); From c6b910b7f7a1a12969d271226dadc9946acf6690 Mon Sep 17 00:00:00 2001 From: obchain Date: Tue, 19 May 2026 12:21:07 +0530 Subject: [PATCH 4/7] chore: prettier format fixes from pre-push hook --- app/src/lib/i18n/chunks/ar-4.ts | 6 ++++-- app/src/lib/i18n/chunks/bn-4.ts | 6 ++++-- app/src/lib/i18n/chunks/en-4.ts | 6 ++++-- app/src/lib/i18n/chunks/es-4.ts | 9 ++++++--- app/src/lib/i18n/chunks/fr-4.ts | 9 ++++++--- app/src/lib/i18n/chunks/hi-4.ts | 9 ++++++--- app/src/lib/i18n/chunks/id-4.ts | 9 ++++++--- app/src/lib/i18n/chunks/it-4.ts | 9 ++++++--- app/src/lib/i18n/chunks/pt-4.ts | 9 ++++++--- app/src/lib/i18n/chunks/ru-4.ts | 6 ++++-- app/src/lib/i18n/chunks/zh-CN-4.ts | 3 ++- app/src/pages/onboarding/steps/ContextGatheringStep.tsx | 6 +----- .../steps/__tests__/ContextGatheringStep.test.tsx | 4 +--- app/src/services/__tests__/coreRpcClient.test.ts | 5 +---- 14 files changed, 57 insertions(+), 39 deletions(-) diff --git a/app/src/lib/i18n/chunks/ar-4.ts b/app/src/lib/i18n/chunks/ar-4.ts index cb42c02d68..c0f1b33f2f 100644 --- a/app/src/lib/i18n/chunks/ar-4.ts +++ b/app/src/lib/i18n/chunks/ar-4.ts @@ -145,8 +145,10 @@ const ar4: TranslationMap = { 'تعذّر إنشاء ملفك الكامل الآن، لكن لا بأس — يمكنك المتابعة وسيُبنى ملفك مع الوقت.', 'onboarding.contextGathering.coreAlive': 'النواة متاحة — قد يستغرق التشغيل الأول دقيقة.', 'onboarding.contextGathering.coreAliveProbing': 'يجري التحقق من اتصال النواة…', - 'onboarding.contextGathering.coreUnreachable': 'النواة لا تستجيب. يمكنك المتابعة والمحاولة لاحقًا.', - 'onboarding.contextGathering.stillWorkingDesc': 'قد يستغرق التشغيل الأول 30–60 ثانية أثناء تهيئة نموذجك المحلي والأدوات. يمكنك المتابعة إلى المحادثة في أي وقت — يستمر بناء الملف الشخصي في الخلفية.', + 'onboarding.contextGathering.coreUnreachable': + 'النواة لا تستجيب. يمكنك المتابعة والمحاولة لاحقًا.', + 'onboarding.contextGathering.stillWorkingDesc': + 'قد يستغرق التشغيل الأول 30–60 ثانية أثناء تهيئة نموذجك المحلي والأدوات. يمكنك المتابعة إلى المحادثة في أي وقت — يستمر بناء الملف الشخصي في الخلفية.', 'onboarding.contextGathering.stillWorkingTitle': 'لا يزال العمل جاريًا على ملفك الشخصي…', 'onboarding.contextGathering.title': 'جمع السياق', 'openhuman.team_list_teams': 'قائمة الفرق', diff --git a/app/src/lib/i18n/chunks/bn-4.ts b/app/src/lib/i18n/chunks/bn-4.ts index 9e6268cc04..7f33d6c24f 100644 --- a/app/src/lib/i18n/chunks/bn-4.ts +++ b/app/src/lib/i18n/chunks/bn-4.ts @@ -146,8 +146,10 @@ const bn4: TranslationMap = { 'আমরা এখনই আপনার পূর্ণ প্রোফাইল তৈরি করতে পারিনি, কিন্তু সমস্যা নেই — আপনি চালিয়ে যেতে পারেন এবং আপনার প্রোফাইল সময়ের সাথে তৈরি হবে।', 'onboarding.contextGathering.coreAlive': 'কোর সংযোগযোগ্য — প্রথম লঞ্চ এক মিনিট সময় নিতে পারে।', 'onboarding.contextGathering.coreAliveProbing': 'কোর সংযোগ যাচাই করা হচ্ছে…', - 'onboarding.contextGathering.coreUnreachable': 'কোর সাড়া দিচ্ছে না। আপনি চালিয়ে যেতে পারেন এবং পরে আবার চেষ্টা করতে পারেন।', - 'onboarding.contextGathering.stillWorkingDesc': 'আমরা আপনার লোকাল মডেল এবং টুলস প্রস্তুত করছি, প্রথম লঞ্চ ৩০–৬০ সেকেন্ড সময় নিতে পারে। আপনি যেকোনো সময় চ্যাটে যেতে পারেন — প্রোফাইল তৈরি ব্যাকগ্রাউন্ডে চলতে থাকবে।', + 'onboarding.contextGathering.coreUnreachable': + 'কোর সাড়া দিচ্ছে না। আপনি চালিয়ে যেতে পারেন এবং পরে আবার চেষ্টা করতে পারেন।', + 'onboarding.contextGathering.stillWorkingDesc': + 'আমরা আপনার লোকাল মডেল এবং টুলস প্রস্তুত করছি, প্রথম লঞ্চ ৩০–৬০ সেকেন্ড সময় নিতে পারে। আপনি যেকোনো সময় চ্যাটে যেতে পারেন — প্রোফাইল তৈরি ব্যাকগ্রাউন্ডে চলতে থাকবে।', 'onboarding.contextGathering.stillWorkingTitle': 'এখনও আপনার প্রোফাইলে কাজ চলছে…', 'onboarding.contextGathering.title': 'কন্টেক্সট সংগ্রহ', 'openhuman.team_list_teams': 'টিম তালিকা', diff --git a/app/src/lib/i18n/chunks/en-4.ts b/app/src/lib/i18n/chunks/en-4.ts index 181f3e1a54..33a9bb8736 100644 --- a/app/src/lib/i18n/chunks/en-4.ts +++ b/app/src/lib/i18n/chunks/en-4.ts @@ -146,8 +146,10 @@ const en4: TranslationMap = { "We couldn't build your full profile right now, but that's okay — you can continue and your profile will build over time.", 'onboarding.contextGathering.coreAlive': 'Core is reachable — first launch can take a minute.', 'onboarding.contextGathering.coreAliveProbing': 'Checking core connection…', - 'onboarding.contextGathering.coreUnreachable': 'Core is not responding. You can continue and try again later.', - 'onboarding.contextGathering.stillWorkingDesc': 'First launch can take 30–60 seconds while we warm up your local model and tools. You can continue to chat at any time — profile build keeps running in the background.', + 'onboarding.contextGathering.coreUnreachable': + 'Core is not responding. You can continue and try again later.', + 'onboarding.contextGathering.stillWorkingDesc': + 'First launch can take 30–60 seconds while we warm up your local model and tools. You can continue to chat at any time — profile build keeps running in the background.', 'onboarding.contextGathering.stillWorkingTitle': 'Still working on your profile…', 'onboarding.contextGathering.title': 'Context Gathering', 'openhuman.team_list_teams': 'Team list teams', diff --git a/app/src/lib/i18n/chunks/es-4.ts b/app/src/lib/i18n/chunks/es-4.ts index 20df3a821f..d7b0db9bf6 100644 --- a/app/src/lib/i18n/chunks/es-4.ts +++ b/app/src/lib/i18n/chunks/es-4.ts @@ -144,10 +144,13 @@ const es4: TranslationMap = { 'onboarding.contextGathering.continueToChat': 'Continuar al chat', 'onboarding.contextGathering.errorDesc': 'No pudimos construir tu perfil completo ahora mismo, pero no pasa nada — puedes continuar y tu perfil se construirá con el tiempo.', - 'onboarding.contextGathering.coreAlive': 'El núcleo está disponible — el primer arranque puede tardar un minuto.', + 'onboarding.contextGathering.coreAlive': + 'El núcleo está disponible — el primer arranque puede tardar un minuto.', 'onboarding.contextGathering.coreAliveProbing': 'Comprobando la conexión con el núcleo…', - 'onboarding.contextGathering.coreUnreachable': 'El núcleo no responde. Puedes continuar e intentarlo más tarde.', - 'onboarding.contextGathering.stillWorkingDesc': 'El primer arranque puede tardar 30–60 segundos mientras preparamos tu modelo local y tus herramientas. Puedes continuar al chat en cualquier momento — la construcción del perfil sigue en segundo plano.', + 'onboarding.contextGathering.coreUnreachable': + 'El núcleo no responde. Puedes continuar e intentarlo más tarde.', + 'onboarding.contextGathering.stillWorkingDesc': + 'El primer arranque puede tardar 30–60 segundos mientras preparamos tu modelo local y tus herramientas. Puedes continuar al chat en cualquier momento — la construcción del perfil sigue en segundo plano.', 'onboarding.contextGathering.stillWorkingTitle': 'Seguimos trabajando en tu perfil…', 'onboarding.contextGathering.title': 'Recopilación de contexto', 'openhuman.team_list_teams': 'Lista de equipos', diff --git a/app/src/lib/i18n/chunks/fr-4.ts b/app/src/lib/i18n/chunks/fr-4.ts index 397d6e09e9..925c84d937 100644 --- a/app/src/lib/i18n/chunks/fr-4.ts +++ b/app/src/lib/i18n/chunks/fr-4.ts @@ -144,10 +144,13 @@ const fr4: TranslationMap = { 'onboarding.contextGathering.continueToChat': 'Accéder au chat', 'onboarding.contextGathering.errorDesc': "Nous n'avons pas pu créer votre profil complet pour l'instant, mais ce n'est pas grave — vous pouvez continuer et votre profil se construira au fil du temps.", - 'onboarding.contextGathering.coreAlive': 'Le cœur est accessible — le premier lancement peut prendre une minute.', + 'onboarding.contextGathering.coreAlive': + 'Le cœur est accessible — le premier lancement peut prendre une minute.', 'onboarding.contextGathering.coreAliveProbing': 'Vérification de la connexion au cœur…', - 'onboarding.contextGathering.coreUnreachable': 'Le cœur ne répond pas. Tu peux continuer et réessayer plus tard.', - 'onboarding.contextGathering.stillWorkingDesc': 'Le premier lancement peut prendre 30 à 60 secondes pendant que nous préparons ton modèle local et tes outils. Tu peux accéder au chat à tout moment — la construction du profil continue en arrière-plan.', + 'onboarding.contextGathering.coreUnreachable': + 'Le cœur ne répond pas. Tu peux continuer et réessayer plus tard.', + 'onboarding.contextGathering.stillWorkingDesc': + 'Le premier lancement peut prendre 30 à 60 secondes pendant que nous préparons ton modèle local et tes outils. Tu peux accéder au chat à tout moment — la construction du profil continue en arrière-plan.', 'onboarding.contextGathering.stillWorkingTitle': 'Construction de ton profil en cours…', 'onboarding.contextGathering.title': 'Collecte de contexte', 'openhuman.team_list_teams': 'Liste des équipes', diff --git a/app/src/lib/i18n/chunks/hi-4.ts b/app/src/lib/i18n/chunks/hi-4.ts index 13161901d6..240def1382 100644 --- a/app/src/lib/i18n/chunks/hi-4.ts +++ b/app/src/lib/i18n/chunks/hi-4.ts @@ -144,10 +144,13 @@ const hi4: TranslationMap = { 'onboarding.contextGathering.continueToChat': 'चैट पर जाएं', 'onboarding.contextGathering.errorDesc': 'हम अभी आपकी पूरी प्रोफ़ाइल नहीं बना सके, लेकिन कोई बात नहीं — आप जारी रख सकते हैं और आपकी प्रोफ़ाइल समय के साथ बनती जाएगी।', - 'onboarding.contextGathering.coreAlive': 'कोर पहुँच योग्य है — पहली बार लॉन्च करने में एक मिनट लग सकता है।', + 'onboarding.contextGathering.coreAlive': + 'कोर पहुँच योग्य है — पहली बार लॉन्च करने में एक मिनट लग सकता है।', 'onboarding.contextGathering.coreAliveProbing': 'कोर कनेक्शन की जाँच की जा रही है…', - 'onboarding.contextGathering.coreUnreachable': 'कोर प्रतिक्रिया नहीं दे रहा है। आप जारी रख सकते हैं और बाद में पुनः प्रयास कर सकते हैं।', - 'onboarding.contextGathering.stillWorkingDesc': 'हम आपके स्थानीय मॉडल और टूल्स को तैयार कर रहे हैं, पहली बार लॉन्च करने में 30–60 सेकंड लग सकते हैं। आप कभी भी चैट पर जा सकते हैं — प्रोफ़ाइल पृष्ठभूमि में बनती रहेगी।', + 'onboarding.contextGathering.coreUnreachable': + 'कोर प्रतिक्रिया नहीं दे रहा है। आप जारी रख सकते हैं और बाद में पुनः प्रयास कर सकते हैं।', + 'onboarding.contextGathering.stillWorkingDesc': + 'हम आपके स्थानीय मॉडल और टूल्स को तैयार कर रहे हैं, पहली बार लॉन्च करने में 30–60 सेकंड लग सकते हैं। आप कभी भी चैट पर जा सकते हैं — प्रोफ़ाइल पृष्ठभूमि में बनती रहेगी।', 'onboarding.contextGathering.stillWorkingTitle': 'आपकी प्रोफ़ाइल पर अब भी काम चल रहा है…', 'onboarding.contextGathering.title': 'कॉन्टेक्स्ट गैदरिंग', 'openhuman.team_list_teams': 'टीम सूची टीमें', diff --git a/app/src/lib/i18n/chunks/id-4.ts b/app/src/lib/i18n/chunks/id-4.ts index cf42684235..ac144682e1 100644 --- a/app/src/lib/i18n/chunks/id-4.ts +++ b/app/src/lib/i18n/chunks/id-4.ts @@ -144,10 +144,13 @@ const id4: TranslationMap = { 'onboarding.contextGathering.continueToChat': 'Lanjutkan ke chat', 'onboarding.contextGathering.errorDesc': 'Kami tidak bisa membangun profil lengkap Anda sekarang, tapi tidak apa-apa — Anda bisa lanjut dan profil Anda akan terbentuk seiring waktu.', - 'onboarding.contextGathering.coreAlive': 'Core dapat diakses — peluncuran pertama bisa memakan waktu satu menit.', + 'onboarding.contextGathering.coreAlive': + 'Core dapat diakses — peluncuran pertama bisa memakan waktu satu menit.', 'onboarding.contextGathering.coreAliveProbing': 'Memeriksa koneksi core…', - 'onboarding.contextGathering.coreUnreachable': 'Core tidak merespons. Anda bisa melanjutkan dan coba lagi nanti.', - 'onboarding.contextGathering.stillWorkingDesc': 'Peluncuran pertama bisa memakan waktu 30–60 detik sementara kami menyiapkan model dan alat lokal Anda. Anda bisa melanjutkan ke chat kapan saja — pembuatan profil tetap berjalan di latar belakang.', + 'onboarding.contextGathering.coreUnreachable': + 'Core tidak merespons. Anda bisa melanjutkan dan coba lagi nanti.', + 'onboarding.contextGathering.stillWorkingDesc': + 'Peluncuran pertama bisa memakan waktu 30–60 detik sementara kami menyiapkan model dan alat lokal Anda. Anda bisa melanjutkan ke chat kapan saja — pembuatan profil tetap berjalan di latar belakang.', 'onboarding.contextGathering.stillWorkingTitle': 'Masih membangun profil Anda…', 'onboarding.contextGathering.title': 'Pengumpulan Konteks', 'openhuman.team_list_teams': 'Daftar tim', diff --git a/app/src/lib/i18n/chunks/it-4.ts b/app/src/lib/i18n/chunks/it-4.ts index 388902752a..d80b56c198 100644 --- a/app/src/lib/i18n/chunks/it-4.ts +++ b/app/src/lib/i18n/chunks/it-4.ts @@ -144,10 +144,13 @@ const it4: TranslationMap = { 'onboarding.contextGathering.continueToChat': 'Continua alla chat', 'onboarding.contextGathering.errorDesc': 'Non siamo riusciti a costruire il tuo profilo completo ora, ma va bene — puoi continuare e il tuo profilo si svilupperà nel tempo.', - 'onboarding.contextGathering.coreAlive': 'Il core è raggiungibile — il primo avvio può richiedere un minuto.', + 'onboarding.contextGathering.coreAlive': + 'Il core è raggiungibile — il primo avvio può richiedere un minuto.', 'onboarding.contextGathering.coreAliveProbing': 'Verifica della connessione al core…', - 'onboarding.contextGathering.coreUnreachable': 'Il core non risponde. Puoi continuare e riprovare più tardi.', - 'onboarding.contextGathering.stillWorkingDesc': 'Il primo avvio può richiedere 30–60 secondi mentre prepariamo il tuo modello locale e gli strumenti. Puoi continuare la chat in qualsiasi momento — la costruzione del profilo continua in background.', + 'onboarding.contextGathering.coreUnreachable': + 'Il core non risponde. Puoi continuare e riprovare più tardi.', + 'onboarding.contextGathering.stillWorkingDesc': + 'Il primo avvio può richiedere 30–60 secondi mentre prepariamo il tuo modello locale e gli strumenti. Puoi continuare la chat in qualsiasi momento — la costruzione del profilo continua in background.', 'onboarding.contextGathering.stillWorkingTitle': 'Stiamo ancora preparando il tuo profilo…', 'onboarding.contextGathering.title': 'Raccolta del contesto', 'openhuman.team_list_teams': 'Elenco team', diff --git a/app/src/lib/i18n/chunks/pt-4.ts b/app/src/lib/i18n/chunks/pt-4.ts index d12286aff7..00e004b568 100644 --- a/app/src/lib/i18n/chunks/pt-4.ts +++ b/app/src/lib/i18n/chunks/pt-4.ts @@ -145,10 +145,13 @@ const pt4: TranslationMap = { 'onboarding.contextGathering.continueToChat': 'Continuar para o chat', 'onboarding.contextGathering.errorDesc': 'Não conseguimos montar seu perfil completo agora, mas tudo bem — você pode continuar e seu perfil será construído ao longo do tempo.', - 'onboarding.contextGathering.coreAlive': 'Núcleo acessível — a primeira inicialização pode demorar um minuto.', + 'onboarding.contextGathering.coreAlive': + 'Núcleo acessível — a primeira inicialização pode demorar um minuto.', 'onboarding.contextGathering.coreAliveProbing': 'Verificando a conexão com o núcleo…', - 'onboarding.contextGathering.coreUnreachable': 'O núcleo não está respondendo. Você pode continuar e tentar novamente mais tarde.', - 'onboarding.contextGathering.stillWorkingDesc': 'A primeira inicialização pode levar 30–60 segundos enquanto preparamos seu modelo local e ferramentas. Você pode continuar para o chat a qualquer momento — a construção do perfil continua em segundo plano.', + 'onboarding.contextGathering.coreUnreachable': + 'O núcleo não está respondendo. Você pode continuar e tentar novamente mais tarde.', + 'onboarding.contextGathering.stillWorkingDesc': + 'A primeira inicialização pode levar 30–60 segundos enquanto preparamos seu modelo local e ferramentas. Você pode continuar para o chat a qualquer momento — a construção do perfil continua em segundo plano.', 'onboarding.contextGathering.stillWorkingTitle': 'Ainda construindo seu perfil…', 'onboarding.contextGathering.title': 'Coleta de Contexto', 'openhuman.team_list_teams': 'Listar equipes', diff --git a/app/src/lib/i18n/chunks/ru-4.ts b/app/src/lib/i18n/chunks/ru-4.ts index 782ab82e8d..83db63ac99 100644 --- a/app/src/lib/i18n/chunks/ru-4.ts +++ b/app/src/lib/i18n/chunks/ru-4.ts @@ -146,8 +146,10 @@ const ru4: TranslationMap = { 'Мы не смогли построить ваш полный профиль прямо сейчас, но это нормально — вы можете продолжить, и профиль будет дополняться со временем.', 'onboarding.contextGathering.coreAlive': 'Ядро доступно — первый запуск может занять минуту.', 'onboarding.contextGathering.coreAliveProbing': 'Проверка соединения с ядром…', - 'onboarding.contextGathering.coreUnreachable': 'Ядро не отвечает. Можно продолжить и попробовать позже.', - 'onboarding.contextGathering.stillWorkingDesc': 'Первый запуск может занять 30–60 секунд, пока мы прогреваем локальную модель и инструменты. Вы можете перейти в чат в любой момент — построение профиля продолжится в фоне.', + 'onboarding.contextGathering.coreUnreachable': + 'Ядро не отвечает. Можно продолжить и попробовать позже.', + 'onboarding.contextGathering.stillWorkingDesc': + 'Первый запуск может занять 30–60 секунд, пока мы прогреваем локальную модель и инструменты. Вы можете перейти в чат в любой момент — построение профиля продолжится в фоне.', 'onboarding.contextGathering.stillWorkingTitle': 'Профиль ещё составляется…', 'onboarding.contextGathering.title': 'Сбор контекста', 'openhuman.team_list_teams': 'Список команд', diff --git a/app/src/lib/i18n/chunks/zh-CN-4.ts b/app/src/lib/i18n/chunks/zh-CN-4.ts index 21fd977bca..88cf2efb1b 100644 --- a/app/src/lib/i18n/chunks/zh-CN-4.ts +++ b/app/src/lib/i18n/chunks/zh-CN-4.ts @@ -145,7 +145,8 @@ const zhCN4: TranslationMap = { 'onboarding.contextGathering.coreAlive': '核心可访问 — 首次启动可能需要一分钟。', 'onboarding.contextGathering.coreAliveProbing': '正在检查核心连接…', 'onboarding.contextGathering.coreUnreachable': '核心未响应。你可以继续,稍后再试。', - 'onboarding.contextGathering.stillWorkingDesc': '我们正在预热本地模型与工具,首次启动可能需要 30–60 秒。你可以随时进入对话 — 档案构建会在后台继续进行。', + 'onboarding.contextGathering.stillWorkingDesc': + '我们正在预热本地模型与工具,首次启动可能需要 30–60 秒。你可以随时进入对话 — 档案构建会在后台继续进行。', 'onboarding.contextGathering.stillWorkingTitle': '仍在生成你的档案…', 'onboarding.contextGathering.title': '上下文收集', 'openhuman.team_list_teams': '团队列表', diff --git a/app/src/pages/onboarding/steps/ContextGatheringStep.tsx b/app/src/pages/onboarding/steps/ContextGatheringStep.tsx index 394678fbc6..1ea73014d9 100644 --- a/app/src/pages/onboarding/steps/ContextGatheringStep.tsx +++ b/app/src/pages/onboarding/steps/ContextGatheringStep.tsx @@ -15,11 +15,7 @@ import { useEffect, useRef, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; -import { - callCoreRpc, - getCoreRpcUrl, - testCoreRpcConnection, -} from '../../../services/coreRpcClient'; +import { callCoreRpc, getCoreRpcUrl, testCoreRpcConnection } from '../../../services/coreRpcClient'; import OnboardingNextButton from '../components/OnboardingNextButton'; /** diff --git a/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx b/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx index 4505be557d..ec481e9824 100644 --- a/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx +++ b/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx @@ -355,9 +355,7 @@ describe('ContextGatheringStep', () => { await vi.advanceTimersByTimeAsync(30_500); }); - expect(screen.getByTestId('context-gathering-title').textContent).toMatch( - /still working/i - ); + expect(screen.getByTestId('context-gathering-title').textContent).toMatch(/still working/i); // Indicator appears so users see whether core is alive or unreachable. expect(screen.getByTestId('core-alive-indicator')).toBeInTheDocument(); diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts index e1a394ffbe..c721dd7ea1 100644 --- a/app/src/services/__tests__/coreRpcClient.test.ts +++ b/app/src/services/__tests__/coreRpcClient.test.ts @@ -354,10 +354,7 @@ describe('coreRpcClient', () => { }) ); - const pending = callCoreRpc({ - method: 'openhuman.app_state_snapshot', - timeoutMs: 60_000, - }); + const pending = callCoreRpc({ method: 'openhuman.app_state_snapshot', timeoutMs: 60_000 }); pending.catch(() => {}); // 30s passes — global default would have aborted by now, but the From 4318b84a84363f9e48a41f73f42d9d42166b7368 Mon Sep 17 00:00:00 2001 From: obchain Date: Tue, 19 May 2026 12:58:52 +0530 Subject: [PATCH 5/7] fix(onboarding): address CodeRabbit review on #2179 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ContextGatheringStep alive probe: add inFlight single-flight guard so overlapping core.ping calls cannot stack when the previous probe is still pending — prevents stale responses racing aliveState on the unreachable path. - Hide the still-working title/desc/alive-indicator once finished or hasError flips true. Without this, slow-success users see the still-working copy during the 800ms auto-advance window after the pipeline actually completes. - coreRpcClient timeout-override + clamp tests: assert the pending promise is still in flight before the expected cutoff so an early-abort timing regression cannot slip through with only the final timeout message matching. --- .../onboarding/steps/ContextGatheringStep.tsx | 25 ++++++++++++----- .../services/__tests__/coreRpcClient.test.ts | 27 +++++++++++++++---- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/app/src/pages/onboarding/steps/ContextGatheringStep.tsx b/app/src/pages/onboarding/steps/ContextGatheringStep.tsx index 1ea73014d9..fcf36cd4f6 100644 --- a/app/src/pages/onboarding/steps/ContextGatheringStep.tsx +++ b/app/src/pages/onboarding/steps/ContextGatheringStep.tsx @@ -318,9 +318,15 @@ const ContextGatheringStep = ({ useEffect(() => { if (!stillWorking || finished || hasError) return; let cancelled = false; + // Single-flight guard so a slow probe never gets shadowed by the next + // 5s tick. Without it, an unreachable core (each probe times out after + // the global fetch budget) would stack overlapping in-flight promises + // and the last-to-resolve would race `aliveState`. + let inFlight = false; const probe = async () => { - if (cancelled) return; + if (cancelled || inFlight) return; + inFlight = true; setAliveState(prev => (prev === 'unknown' ? 'probing' : prev)); try { const url = await getCoreRpcUrl(); @@ -330,6 +336,8 @@ const ContextGatheringStep = ({ } } catch { if (!cancelled) setAliveState('unreachable'); + } finally { + inFlight = false; } }; @@ -373,10 +381,14 @@ const ContextGatheringStep = ({ ); } - const titleKey = stillWorking + // The slow-path UI must vanish as soon as the pipeline resolves, even + // during the 800ms auto-advance window — otherwise a slow-success user + // sees "still working…" copy after the work has actually finished. + const showStillWorking = stillWorking && !finished && !hasError; + const titleKey = showStillWorking ? 'onboarding.contextGathering.stillWorkingTitle' : 'onboarding.contextGathering.buildingProfile'; - const descKey = stillWorking + const descKey = showStillWorking ? 'onboarding.contextGathering.stillWorkingDesc' : 'onboarding.contextGathering.buildingDesc'; @@ -412,9 +424,10 @@ const ContextGatheringStep = ({
- {/* Alive indicator — only after we entered still-working state so we - don't show a probe state during the normal sub-30s happy path. */} - {stillWorking && ( + {/* Alive indicator — only while still-working state is active AND + the pipeline hasn't finished/errored. Avoids flashing the probe + during the 800ms auto-advance window after a slow success. */} + {showStillWorking && (
{ ); const pending = callCoreRpc({ method: 'openhuman.app_state_snapshot', timeoutMs: 60_000 }); - pending.catch(() => {}); + let settled = false; + pending.catch(() => {}).finally(() => { + settled = true; + }); // 30s passes — global default would have aborted by now, but the - // per-call 60s override keeps the request alive. + // per-call 60s override keeps the request alive. Assert the pending + // promise is still in flight so an early-abort regression on the + // override path cannot slip through (CodeRabbit #2179 review). await vi.advanceTimersByTimeAsync(31_000); - // Not yet rejected. Advance to the override boundary. + expect(settled).toBe(false); + + // Advance to the override boundary — now the abort fires. await vi.advanceTimersByTimeAsync(30_000); await expect(pending).rejects.toThrow( @@ -395,10 +402,20 @@ describe('coreRpcClient', () => { // 2 hours — far beyond the 10 minute clamp; should be reduced. timeoutMs: 2 * 60 * 60 * 1_000, }); - pending.catch(() => {}); + let settled = false; + pending.catch(() => {}).finally(() => { + settled = true; + }); const MAX_MS = 10 * 60 * 1_000; - await vi.advanceTimersByTimeAsync(MAX_MS + 1); + // 1ms before the clamp boundary: still pending. Guards against an + // off-by-one where the clamp accidentally lowers the budget further + // (CodeRabbit #2179 review). + await vi.advanceTimersByTimeAsync(MAX_MS - 1); + expect(settled).toBe(false); + + // Cross the clamp boundary — abort fires. + await vi.advanceTimersByTimeAsync(2); await expect(pending).rejects.toThrow( `Core RPC openhuman.app_state_snapshot timed out after ${MAX_MS}ms` From 080c04c70e05e3bd4daff25e2e5dd13cbecfa2ad Mon Sep 17 00:00:00 2001 From: obchain Date: Tue, 19 May 2026 13:00:07 +0530 Subject: [PATCH 6/7] chore: prettier auto-fixes from pre-push hook --- app/src/services/__tests__/coreRpcClient.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts index 5879a4cb73..06891a122f 100644 --- a/app/src/services/__tests__/coreRpcClient.test.ts +++ b/app/src/services/__tests__/coreRpcClient.test.ts @@ -356,9 +356,11 @@ describe('coreRpcClient', () => { const pending = callCoreRpc({ method: 'openhuman.app_state_snapshot', timeoutMs: 60_000 }); let settled = false; - pending.catch(() => {}).finally(() => { - settled = true; - }); + pending + .catch(() => {}) + .finally(() => { + settled = true; + }); // 30s passes — global default would have aborted by now, but the // per-call 60s override keeps the request alive. Assert the pending @@ -403,9 +405,11 @@ describe('coreRpcClient', () => { timeoutMs: 2 * 60 * 60 * 1_000, }); let settled = false; - pending.catch(() => {}).finally(() => { - settled = true; - }); + pending + .catch(() => {}) + .finally(() => { + settled = true; + }); const MAX_MS = 10 * 60 * 1_000; // 1ms before the clamp boundary: still pending. Guards against an From e9451a628b7b8d1a64a4aa00e380f1e0f6c152a8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 19 May 2026 20:09:48 -0700 Subject: [PATCH 7/7] fix(onboarding): bound core.ping probe + treat 401 as alive (CodeGhost21 review on #2179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - testCoreRpcConnection: accept optional { signal } so callers can bound the raw fetch(). Without this, an unreachable core (TCP black-hole, suspended laptop) hangs the probe forever — the single-flight guard then blocks every 5s tick and the user is parked on "Checking core connection…" indefinitely, the exact failure mode #2156 fixes one layer up. - ContextGatheringStep alive probe: wrap each probe with its own AbortController + 3s timeout (PROBE_TIMEOUT_MS). - Treat HTTP 401 as 'alive': on cold start the bearer token resolution can race the first probe; the response is 401 even though the core is fine. Auth-not-ready ≠ core-down. - Rewrite the misleading comment that claimed core.ping bypasses bearer auth — it does not (see src/core/auth.rs PUBLIC_PATHS). - Align en/en-4 errorDesc with the staged-fallback copy the PR tests already assert ("Your chat is ready…"). Other locale chunks left untouched — re-translating is out of scope for this fix. - Add tests: 401 → alive, AbortSignal forwarded → probe abort → unreachable. --- app/src/lib/i18n/chunks/en-4.ts | 2 +- app/src/lib/i18n/en.ts | 2 +- .../onboarding/steps/ContextGatheringStep.tsx | 35 ++++++-- .../__tests__/ContextGatheringStep.test.tsx | 83 +++++++++++++++++++ app/src/services/coreRpcClient.ts | 4 +- 5 files changed, 114 insertions(+), 12 deletions(-) diff --git a/app/src/lib/i18n/chunks/en-4.ts b/app/src/lib/i18n/chunks/en-4.ts index 4389ed8a0d..bb3230a46a 100644 --- a/app/src/lib/i18n/chunks/en-4.ts +++ b/app/src/lib/i18n/chunks/en-4.ts @@ -143,7 +143,7 @@ const en4: TranslationMap = { 'onboarding.contextGathering.buildingProfile': 'Building your profile...', 'onboarding.contextGathering.continueToChat': 'Continue to chat', 'onboarding.contextGathering.errorDesc': - "We couldn't build your full profile right now, but that's okay — you can continue and your profile will build over time.", + "Your chat is ready. We'll keep building your full profile in the background, so you can continue now and refine it over time.", 'onboarding.contextGathering.coreAlive': 'Core is reachable — first launch can take a minute.', 'onboarding.contextGathering.coreAliveProbing': 'Checking core connection…', 'onboarding.contextGathering.coreUnreachable': diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 0228152170..2740c8a22a 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1489,7 +1489,7 @@ const en: TranslationMap = { 'onboarding.contextGathering.coreUnreachable': 'Core is not responding. You can continue and try again later.', 'onboarding.contextGathering.errorDesc': - "We couldn't build your full profile right now, but that's okay — you can continue and your profile will build over time.", + "Your chat is ready. We'll keep building your full profile in the background, so you can continue now and refine it over time.", 'onboarding.contextGathering.stillWorkingDesc': 'First launch can take 30–60 seconds while we warm up your local model and tools. You can continue to chat at any time — profile build keeps running in the background.', 'onboarding.contextGathering.stillWorkingTitle': 'Still working on your profile…', diff --git a/app/src/pages/onboarding/steps/ContextGatheringStep.tsx b/app/src/pages/onboarding/steps/ContextGatheringStep.tsx index 6803798b7c..06a14b6669 100644 --- a/app/src/pages/onboarding/steps/ContextGatheringStep.tsx +++ b/app/src/pages/onboarding/steps/ContextGatheringStep.tsx @@ -29,6 +29,14 @@ const STILL_WORKING_THRESHOLD_MS = 30_000; /** How often we probe `core.ping` after entering the still-working state. */ const ALIVE_PROBE_INTERVAL_MS = 5_000; +/** + * Per-probe network budget. `testCoreRpcConnection` uses raw `fetch()` with + * no built-in timeout, so we have to bound the probe ourselves — otherwise a + * TCP black-hole (firewall drop, suspended laptop wake) lets the first probe + * hang forever and the single-flight guard blocks every subsequent tick. + */ +const PROBE_TIMEOUT_MS = 3_000; + type AliveState = 'unknown' | 'probing' | 'alive' | 'unreachable'; interface ContextGatheringStepProps { @@ -311,32 +319,41 @@ const ContextGatheringStep = ({ return () => window.clearTimeout(timer); }, [finished, hasGmail, hasError]); - // Periodic alive probe while in still-working state. `core.ping` bypasses - // bearer auth and resolves quickly even when the busy snapshot RPC is - // holding the worker, so a green ping during a slow snapshot is exactly - // the alive-but-slow signal users need to see. + // Periodic alive probe while in still-working state. `core.ping` resolves + // quickly even when the busy snapshot RPC is holding the worker, so a + // green ping during a slow snapshot is exactly the alive-but-slow signal + // users need to see. On cold start the bearer token may not be resolved + // yet (IPC race) and the probe will come back 401 — that means auth is + // still warming up, not that the core is down, so we treat 401 as `alive`. useEffect(() => { if (!stillWorking || finished || hasError) return; let cancelled = false; // Single-flight guard so a slow probe never gets shadowed by the next - // 5s tick. Without it, an unreachable core (each probe times out after - // the global fetch budget) would stack overlapping in-flight promises - // and the last-to-resolve would race `aliveState`. + // 5s tick. Each probe also bounds itself with its own AbortController + // (PROBE_TIMEOUT_MS) so a TCP-black-hole core (firewall, suspended + // laptop) cannot leave us with a permanently in-flight fetch and a + // permanently blocked guard — that would re-create exactly the + // "Checking core connection…" forever failure mode this UI exists to + // fix. let inFlight = false; const probe = async () => { if (cancelled || inFlight) return; inFlight = true; setAliveState(prev => (prev === 'unknown' ? 'probing' : prev)); + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); try { const url = await getCoreRpcUrl(); - const response = await testCoreRpcConnection(url); + const response = await testCoreRpcConnection(url, undefined, { signal: controller.signal }); if (!cancelled) { - setAliveState(response.ok ? 'alive' : 'unreachable'); + // 401 = auth not ready yet (cold-start IPC race), not a dead core. + setAliveState(response.ok || response.status === 401 ? 'alive' : 'unreachable'); } } catch { if (!cancelled) setAliveState('unreachable'); } finally { + window.clearTimeout(timer); inFlight = false; } }; diff --git a/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx b/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx index 3b18f29834..e71d274ba8 100644 --- a/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx +++ b/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx @@ -397,6 +397,89 @@ describe('ContextGatheringStep', () => { }); }); + it('treats HTTP 401 as alive (auth not ready yet, core is up)', async () => { + vi.useFakeTimers(); + testCoreRpcConnection.mockResolvedValue({ ok: false, status: 401 } as Response); + + let resolveGmail!: (v: unknown) => void; + callCoreRpc.mockImplementation( + () => + new Promise(res => { + resolveGmail = res; + }) + ); + + renderWithProviders( + + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(30_500); + }); + vi.useRealTimers(); + + const indicator = await screen.findByTestId('core-alive-indicator'); + await waitFor(() => { + expect(indicator.getAttribute('data-alive-state')).toBe('alive'); + }); + + await act(async () => { + resolveGmail({ successful: true, data: { messages: [] } }); + }); + }); + + it('passes an AbortSignal so a TCP black-hole probe cannot hang forever', async () => { + vi.useFakeTimers(); + // Hang the probe indefinitely so the only way it can resolve is via + // the controller-driven abort path. Capture whether the signal was + // forwarded. + testCoreRpcConnection.mockImplementation( + ((_url: string, _token?: string, init?: { signal?: AbortSignal }) => + new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')) + ); + })) as never + ); + + let resolveGmail!: (v: unknown) => void; + callCoreRpc.mockImplementation( + () => + new Promise(res => { + resolveGmail = res; + }) + ); + + renderWithProviders( + + ); + + // Enter still-working state. + await act(async () => { + await vi.advanceTimersByTimeAsync(30_500); + }); + // Drive the per-probe 3s timeout to fire the abort. + await act(async () => { + await vi.advanceTimersByTimeAsync(3_500); + }); + vi.useRealTimers(); + + expect(testCoreRpcConnection).toHaveBeenCalled(); + const lastCall = testCoreRpcConnection.mock.calls[ + testCoreRpcConnection.mock.calls.length - 1 + ] as unknown as [string, string | undefined, { signal?: AbortSignal } | undefined]; + expect(lastCall[2]?.signal).toBeInstanceOf(AbortSignal); + + const indicator = await screen.findByTestId('core-alive-indicator'); + await waitFor(() => { + expect(indicator.getAttribute('data-alive-state')).toBe('unreachable'); + }); + + await act(async () => { + resolveGmail({ successful: true, data: { messages: [] } }); + }); + }); + it('reports unreachable when core.ping rejects', async () => { vi.useFakeTimers(); testCoreRpcConnection.mockRejectedValue(new Error('ECONNREFUSED')); diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index aed679a76c..6295ef6d03 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -360,7 +360,8 @@ export async function getCoreRpcToken(): Promise { */ export async function testCoreRpcConnection( url: string, - tokenOverride?: string + tokenOverride?: string, + init?: { signal?: AbortSignal } ): Promise { const token = tokenOverride?.trim() || (await getCoreRpcToken()); const headers: Record = { 'Content-Type': 'application/json' }; @@ -371,6 +372,7 @@ export async function testCoreRpcConnection( method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'core.ping', params: {} }), + signal: init?.signal, }); }