From a863bbcc286da385a4578dde719c2d15e3ce43f2 Mon Sep 17 00:00:00 2001 From: joaomarcos Date: Mon, 7 Sep 2026 14:52:52 -0300 Subject: [PATCH] fix(desktop): make pool slot timeouts actionable --- .../electron/pool-spawn-coordinator.test.ts | 8 ++-- .../src/app/contrib/wiring-routing.test.ts | 11 ++++- .../desktop/src/app/contrib/wiring-routing.ts | 5 +++ apps/desktop/src/app/contrib/wiring.tsx | 20 +++++++++ .../src/components/notifications.test.tsx | 41 ++++++++++++++++++- apps/desktop/src/i18n/ar.ts | 3 ++ apps/desktop/src/i18n/en.ts | 3 ++ apps/desktop/src/i18n/ja.ts | 3 ++ apps/desktop/src/i18n/ru.ts | 3 ++ apps/desktop/src/i18n/types.ts | 2 + apps/desktop/src/i18n/zh-hant.ts | 3 ++ apps/desktop/src/i18n/zh.ts | 3 ++ apps/desktop/src/store/notifications.ts | 12 +++++- apps/desktop/src/store/pool-limits.ts | 24 +++++++++++ 14 files changed, 133 insertions(+), 8 deletions(-) diff --git a/apps/desktop/electron/pool-spawn-coordinator.test.ts b/apps/desktop/electron/pool-spawn-coordinator.test.ts index b7cf4ee30e..31240e8a60 100644 --- a/apps/desktop/electron/pool-spawn-coordinator.test.ts +++ b/apps/desktop/electron/pool-spawn-coordinator.test.ts @@ -114,7 +114,7 @@ test('100 real child processes never exceed twelve simultaneous local slots', as const limit = 12 const coordinator = new LocalBackendSpawnCoordinator(limit) const livePids = new Set() - const seenPids = new Set() + let completedChildren = 0 let maxLive = 0 await Promise.all( @@ -128,7 +128,6 @@ test('100 real child processes never exceed twelve simultaneous local slots', as assert.ok(child.pid) livePids.add(child.pid) - seenPids.add(child.pid) maxLive = Math.max(maxLive, livePids.size) await new Promise((resolve, reject) => { @@ -142,6 +141,7 @@ test('100 real child processes never exceed twelve simultaneous local slots', as }) }) + completedChildren += 1 livePids.delete(child.pid) } finally { release() @@ -149,7 +149,9 @@ test('100 real child processes never exceed twelve simultaneous local slots', as }) ) - assert.equal(seenPids.size, 100) + // Windows may recycle a PID after a short-lived child exits; completion + // count, not PID uniqueness, is the invariant this concurrency test owns. + assert.equal(completedChildren, 100) assert.equal(maxLive, limit) assert.equal(livePids.size, 0) assert.equal(coordinator.activeCount, 0) diff --git a/apps/desktop/src/app/contrib/wiring-routing.test.ts b/apps/desktop/src/app/contrib/wiring-routing.test.ts index cb981d40d0..2f5a8472d8 100644 --- a/apps/desktop/src/app/contrib/wiring-routing.test.ts +++ b/apps/desktop/src/app/contrib/wiring-routing.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from 'vitest' -import { findStoredIdForRuntimeId, resolveRoutingSessionId, resolveSessionRpcOwner } from './wiring-routing' +import { + findStoredIdForRuntimeId, + POOL_LIMITS_SETTINGS_ROUTE, + resolveRoutingSessionId, + resolveSessionRpcOwner +} from './wiring-routing' + +it('targets the Advanced settings surface for pool-cap recovery', () => { + expect(POOL_LIMITS_SETTINGS_ROUTE).toBe('/settings?tab=config:advanced') +}) describe('findStoredIdForRuntimeId', () => { it('reverse-resolves a runtime id to its stored id', () => { diff --git a/apps/desktop/src/app/contrib/wiring-routing.ts b/apps/desktop/src/app/contrib/wiring-routing.ts index 8ad82a5ef9..0c6fef02a5 100644 --- a/apps/desktop/src/app/contrib/wiring-routing.ts +++ b/apps/desktop/src/app/contrib/wiring-routing.ts @@ -7,6 +7,11 @@ import type { SessionOwnerRoute } from '@/store/session-request-router' +import { SETTINGS_ROUTE } from '../routes' + +/** The in-app destination for pool-cap recovery. */ +export const POOL_LIMITS_SETTINGS_ROUTE = `${SETTINGS_ROUTE}?tab=config:advanced` + /** * Resolve a runtime session id back to its stored id by reverse-scanning the * stored->runtime binding map — the same ladder use-session-tile-delegate's diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 5ae3804532..3e5fc7b3ef 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -51,6 +51,7 @@ import { $cronReviewRequest, setCronFocusJobId } from '@/store/cron' import { requestGatewayForProfile } from '@/store/gateway' import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout' import { notifyError } from '@/store/notifications' +import { $poolLimitsSettingsRequest } from '@/store/pool-limits' import { $previewTarget } from '@/store/preview' import { $activeGatewayProfile, @@ -163,6 +164,7 @@ import { $restartPreviewServer, useTitlebarToolContributions } from './panes' import { type AmbientGatewayRequest, createSessionRpcDispatcher } from './session-rpc-dispatcher' import { ChatRoutesSurface, SidebarSurface, StatusbarSurface, TerminalSurface } from './surfaces' import type { WiringActions, WiringApi } from './types' +import { POOL_LIMITS_SETTINGS_ROUTE } from './wiring-routing' // Overlay views the controller mounts over the shell — lazy, load on demand. // The workspace-route full-page views (skills/messaging/artifacts) are the @@ -194,6 +196,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { // context (the sticky toast). The shell owns `navigate`, so it consumes the // intent counter here; the ref skips the initial mount value. const billingSettingsSeenRef = useRef(0) + const poolLimitsSettingsSeenRef = useRef(0) const cronReviewSeenRef = useRef(0) const activeTranscriptSignatureRef = useRef(new Map()) const activeTranscriptRequestSequenceRef = useRef(0) @@ -204,6 +207,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { const gatewayState = useStore($gatewayState) const activeSessionId = useStore($activeSessionId) const billingSettingsRequest = useStore($billingSettingsRequest) + const poolLimitsSettingsRequest = useStore($poolLimitsSettingsRequest) const cronReviewRequest = useStore($cronReviewRequest) const currentCwd = useStore($currentCwd) @@ -220,6 +224,22 @@ export function ContribWiring({ children }: { children: ReactNode }) { } }, [billingSettingsRequest, navigate]) + // Pool-cap recovery is fired by the notification action, which has no router + // context. Keep navigation user-initiated: the counter changes only when the + // user clicks "Open Advanced Settings" on a pool-slot failure. + // eslint-disable-next-line no-restricted-syntax -- one-shot request-seen sentinel, not an atom mirror + useEffect(() => { + if (poolLimitsSettingsRequest === poolLimitsSettingsSeenRef.current) { + return + } + + poolLimitsSettingsSeenRef.current = poolLimitsSettingsRequest + + if (poolLimitsSettingsRequest > 0) { + navigate(POOL_LIMITS_SETTINGS_ROUTE) + } + }, [navigate, poolLimitsSettingsRequest]) + // eslint-disable-next-line no-restricted-syntax -- one-shot request-seen sentinel, not an atom mirror useEffect(() => { if (cronReviewRequest === cronReviewSeenRef.current) { diff --git a/apps/desktop/src/components/notifications.test.tsx b/apps/desktop/src/components/notifications.test.tsx index 5c345ce5ea..2608f41a98 100644 --- a/apps/desktop/src/components/notifications.test.tsx +++ b/apps/desktop/src/components/notifications.test.tsx @@ -1,8 +1,9 @@ -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { I18nProvider } from '@/i18n' -import { clearNotifications, notify } from '@/store/notifications' +import { $notifications, clearNotifications, notify, notifyError } from '@/store/notifications' +import { $poolLimitsSettingsRequest } from '@/store/pool-limits' import { NotificationStack, toastTitleClassName } from './notifications' @@ -12,11 +13,13 @@ const DETAIL = 'target user message is no longer in session history' describe('toast titles', () => { beforeEach(() => { clearNotifications() + $poolLimitsSettingsRequest.set(0) }) afterEach(() => { cleanup() clearNotifications() + $poolLimitsSettingsRequest.set(0) }) it('drops the one-line clamp so a long error title can wrap', () => { @@ -47,4 +50,38 @@ describe('toast titles', () => { expect(title.className).toMatch(/\boverflow-y-auto\b/) expect(screen.getByText(DETAIL)).toBeTruthy() }) + + it('makes a local pool-slot timeout actionable without changing ordinary errors', () => { + notifyError( + new Error( + `Error invoking remote method 'hermes:connection': Error: Local backend start for "research" timed out while waiting for a free slot.` + ), + 'Failed to switch to profile "research"' + ) + + render( + + + + ) + + expect(screen.getByText(/All local profile backend slots are busy/)).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Open Advanced Settings' })) + + expect($poolLimitsSettingsRequest.get()).toBe(1) + expect($notifications.get()).toHaveLength(0) + + notifyError(new Error('gateway unavailable'), 'Failed to switch profile') + expect($notifications.get()[0]?.action).toBeUndefined() + }) + + it('keeps background pool-slot timeouts quiet if they reach the renderer', () => { + notifyError( + new Error('Local backend start for "background" timed out while waiting for a free slot. (background)'), + 'Background profile warm-up failed' + ) + + expect($notifications.get()[0]?.action).toBeUndefined() + }) }) diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 065b20a2a5..fc7e18b410 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -3273,6 +3273,9 @@ export const ar = defineLocale({ resumeStrandedTitle: 'تعذّر تحميل هذه الجلسة', resumeStrandedBody: 'فشل الاتصال بهذه الجلسة وتوقفت إعادة المحاولة التلقائية. تأكد من تشغيل البوابة، ثم حاول مجددا.', + poolSlotTimeoutBody: + 'جميع خانات الواجهات الخلفية المحلية للملفات الشخصية مشغولة. زد عدد Warm Bot Backends من الإعدادات ← متقدم، أو أعد المحاولة بعد إزالة واجهة خلفية خاملة.', + poolSlotTimeoutOpenSettings: 'فتح الإعدادات المتقدمة', resumeRetry: 'إعادة المحاولة', nothingToBranch: 'لا يوجد ما يمكن تفريعه', branchNeedsChat: 'يحتاج التفريع إلى محادثة', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 6a71f41b91..8693ea4e5d 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -4240,6 +4240,9 @@ export const en: Translations = { resumeStrandedTitle: "Couldn't load this session", resumeStrandedBody: 'The connection to this session failed and automatic retries gave up. Check that the gateway is running, then try again.', + poolSlotTimeoutBody: + 'All local profile backend slots are busy. Increase Warm Bot Backends in Settings → Advanced, or retry after an idle backend is evicted.', + poolSlotTimeoutOpenSettings: 'Open Advanced Settings', resumeRetry: 'Retry', nothingToBranch: 'Nothing to branch', branchNeedsChat: 'Start or resume a chat before branching.', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index bdcd95d474..6a2ee3c016 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -3710,6 +3710,9 @@ export const ja = defineLocale({ resumeStrandedTitle: 'このセッションを読み込めませんでした', resumeStrandedBody: 'このセッションへの接続に失敗し、自動再試行も停止しました。ゲートウェイが実行中か確認してから、もう一度お試しください。', + poolSlotTimeoutBody: + 'すべてのローカルプロファイルバックエンドスロットが使用中です。「設定」→「詳細設定」で「Warm Bot Backends」を増やすか、アイドル状態のバックエンドが解放された後に再試行してください。', + poolSlotTimeoutOpenSettings: '詳細設定を開く', resumeRetry: '再試行', nothingToBranch: 'ブランチするものがありません', branchNeedsChat: 'ブランチする前にチャットを開始または再開してください。', diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts index 594f74ddfb..6a89fc189f 100644 --- a/apps/desktop/src/i18n/ru.ts +++ b/apps/desktop/src/i18n/ru.ts @@ -3825,6 +3825,9 @@ export const ru = defineLocale({ resumeStrandedTitle: 'Не удалось загрузить этот сеанс', resumeStrandedBody: 'Соединение с этим сеансом оборвалось, и автоматические повторные попытки исчерпаны. Проверьте, что шлюз работает, и попробуйте снова.', + poolSlotTimeoutBody: + 'Все слоты локальных бэкендов профилей заняты. Увеличьте Warm Bot Backends в «Настройки» → «Дополнительно» или повторите попытку после освобождения неактивного бэкенда.', + poolSlotTimeoutOpenSettings: 'Открыть расширенные настройки', resumeRetry: 'Повторить', nothingToBranch: 'Нечего ветвить', branchNeedsChat: 'Начните или возобновите чат перед ветвлением.', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index e8aed0763e..22e5acf29f 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -3700,6 +3700,8 @@ export interface Translations { readOnlyTranscriptSendBlocked: string resumeStrandedTitle: string resumeStrandedBody: string + poolSlotTimeoutBody: string + poolSlotTimeoutOpenSettings: string resumeRetry: string nothingToBranch: string branchNeedsChat: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 741de25d6c..de79268f32 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -3555,6 +3555,9 @@ export const zhHant = defineLocale({ readOnlyTranscriptSendBlocked: '此對話目前以唯讀逐字稿方式開啟——傳送已停用。', resumeStrandedTitle: '無法載入此工作階段', resumeStrandedBody: '與此工作階段的連線失敗,自動重試已停止。請確認閘道正在執行,然後重試。', + poolSlotTimeoutBody: + '所有本機設定檔後端插槽目前都在使用中。請在「設定」→「進階」中增加 Warm Bot Backends,或等待閒置後端被移除後重試。', + poolSlotTimeoutOpenSettings: '開啟進階設定', resumeRetry: '重試', nothingToBranch: '沒有可分支的內容', branchNeedsChat: '分支前請先開始或繼續一個聊天。', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index be5b3d6495..77475c7efe 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -4349,6 +4349,9 @@ export const zh: Translations = { readOnlyTranscriptSendBlocked: '该会话目前以只读记录方式打开——发送已禁用。', resumeStrandedTitle: '无法加载此会话', resumeStrandedBody: '与此会话的连接失败,自动重试已停止。请确认网关正在运行,然后重试。', + poolSlotTimeoutBody: + '所有本地配置后端槽位都在使用中。请在“设置”→“高级”中增加 Warm Bot Backends,或等待空闲后端被驱逐后重试。', + poolSlotTimeoutOpenSettings: '打开高级设置', resumeRetry: '重试', nothingToBranch: '没有可分支的内容', branchNeedsChat: '分支前请先开始或恢复一个对话。', diff --git a/apps/desktop/src/store/notifications.ts b/apps/desktop/src/store/notifications.ts index 919c5cade2..55ce13f047 100644 --- a/apps/desktop/src/store/notifications.ts +++ b/apps/desktop/src/store/notifications.ts @@ -1,6 +1,7 @@ import { atom } from 'nanostores' import { translateNow } from '@/i18n' +import { isLocalBackendSlotWaitTimeout, requestPoolLimitsSettings } from '@/store/pool-limits' export type NotificationKind = 'error' | 'warning' | 'info' | 'success' @@ -195,12 +196,19 @@ export function notify(input: NotificationInput): string { export function notifyError(error: unknown, fallback: string): string { const readable = readableError(error, fallback) + const poolSlotTimeout = isLocalBackendSlotWaitTimeout(error) return notify({ + action: poolSlotTimeout + ? { + label: translateNow('desktop.poolSlotTimeoutOpenSettings'), + onClick: requestPoolLimitsSettings + } + : undefined, kind: 'error', title: fallback, - message: readable.message, - detail: readable.detail + message: poolSlotTimeout ? translateNow('desktop.poolSlotTimeoutBody') : readable.message, + detail: poolSlotTimeout ? readable.message : readable.detail }) } diff --git a/apps/desktop/src/store/pool-limits.ts b/apps/desktop/src/store/pool-limits.ts index 71bd9e4ade..c78667ef9a 100644 --- a/apps/desktop/src/store/pool-limits.ts +++ b/apps/desktop/src/store/pool-limits.ts @@ -26,6 +26,30 @@ export const POOL_LIMITS_DEFAULTS: PoolLimits = { export const $poolLimits = atom({ ...POOL_LIMITS_DEFAULTS }) +// Electron IPC rejects with a plain Error in the renderer, so the stable +// coordinator phrase is the cross-process discriminator. Keep it narrow: other +// backend failures must retain their existing recovery path. +const LOCAL_BACKEND_SLOT_TIMEOUT_PREFIX = 'Local backend start for "' +const LOCAL_BACKEND_SLOT_TIMEOUT_SUFFIX = ' timed out while waiting for a free slot.' +const LOCAL_BACKEND_SLOT_TIMEOUT_BACKGROUND_SUFFIX = ' (background)' + +export function isLocalBackendSlotWaitTimeout(error: unknown): boolean { + const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '' + + return ( + message.includes(LOCAL_BACKEND_SLOT_TIMEOUT_PREFIX) && + message.includes(LOCAL_BACKEND_SLOT_TIMEOUT_SUFFIX) && + !message.endsWith(LOCAL_BACKEND_SLOT_TIMEOUT_BACKGROUND_SUFFIX) + ) +} + +/** Navigation intent consumed by the shell, which owns React Router. */ +export const $poolLimitsSettingsRequest = atom(0) + +export function requestPoolLimitsSettings(): void { + $poolLimitsSettingsRequest.set($poolLimitsSettingsRequest.get() + 1) +} + /** Seed from main's authoritative state once at startup; no-op without the * bridge (web/older builds just keep the defaults for the UI). */ export async function loadPoolLimits(): Promise {