diff --git a/apps/desktop/electron/backend-dial-claim.test.ts b/apps/desktop/electron/backend-dial-claim.test.ts index 82694624ab..03bbf4198b 100644 --- a/apps/desktop/electron/backend-dial-claim.test.ts +++ b/apps/desktop/electron/backend-dial-claim.test.ts @@ -135,7 +135,7 @@ describe('main.ts wiring for #90812', () => { it('routes the registry-scoped dial IPC through the claim keyed by backendScopeKey(connectionId, profile)', () => { const handlerStart = mainSource.indexOf("ipcMain.handle('hermes:connection:for', ") expect(handlerStart).toBeGreaterThan(-1) - const body = mainSource.slice(handlerStart, handlerStart + 1_600) + const body = mainSource.slice(handlerStart, handlerStart + 1_200) expect(body).toContain('const scopeKey = backendScopeKey(id, profile)') expect(body).toContain('backendDialClaims.run(scopeKey, ') @@ -162,7 +162,7 @@ describe('main.ts wiring for #90812', () => { it('routes a terminal-pane backend resolve through the single-owner claim on both the registry and local branches', () => { const handlerStart = mainSource.indexOf('async function ensureTerminalBackend(webContentsId: number) {') expect(handlerStart).toBeGreaterThan(-1) - const body = mainSource.slice(handlerStart, handlerStart + 1_200) + const body = mainSource.slice(handlerStart, handlerStart + 900) expect(body).toContain('backendDialClaims.run(backendScopeKey(windowRoute.connectionId, windowRoute.profile)') expect(body).toContain('ensureRegistryBackend(windowRoute.connectionId, windowRoute.profile)') @@ -195,21 +195,9 @@ describe('main.ts wiring for #90812', () => { it('routes every registry-scoped REST dispatch (hermes:api) through the single-owner claim', () => { const handlerStart = mainSource.indexOf('async function dispatchRegistryApiRequest(') expect(handlerStart).toBeGreaterThan(-1) - const body = mainSource.slice(handlerStart, handlerStart + 1_600) + const body = mainSource.slice(handlerStart, handlerStart + 1_000) expect(body).toContain('backendDialClaims.run(backendScopeKey(registryConnectionId, routeProfile)') expect(body).toContain("ensureRegistryBackend(registryConnectionId, routeProfile, '', { spawnPriority })") }) - - it('forwards foreground API intent through both local and registry backend resolution', () => { - const registryStart = mainSource.indexOf('async function dispatchRegistryApiRequest(') - const registryBody = mainSource.slice(registryStart, registryStart + 1_300) - const apiStart = mainSource.indexOf('async function handleHermesApiRequest(request)') - const apiBody = mainSource.slice(apiStart, apiStart + 3_500) - - expect(registryBody).toContain('const spawnPriority = spawnPriorityFrom(request?.priority)') - expect(registryBody).toContain("ensureRegistryBackend(registryConnectionId, routeProfile, '', { spawnPriority })") - expect(apiBody).toContain('const spawnPriority = spawnPriorityFrom(request?.priority)') - expect(apiBody).toContain('ensureBackend(routeProfile, { passive: request?.passive, spawnPriority })') - }) }) diff --git a/apps/desktop/src/api/client.ts b/apps/desktop/src/api/client.ts index b61f0d33b4..db0d0e7e05 100644 --- a/apps/desktop/src/api/client.ts +++ b/apps/desktop/src/api/client.ts @@ -155,6 +155,15 @@ export function capabilityScoped(scope?: ProfileScope): { connectionId?: string; return { ...profileScoped(scope), ...connectionScoped() } } +/** Spawn priority for a REST call that may cold-start a pooled backend. An + * explicit scope is a user pointing a scope selector (Settings "Applies to", + * Capabilities) at another profile — a visible action that may take the + * pool's reserved foreground slot (#111651). The ambient path stays untagged, + * main's background default, so hydration cannot consume that slot. */ +export function scopedDialPriority(scope?: ProfileScope): { priority?: 'foreground' } { + return scope == null ? {} : { priority: 'foreground' } +} + /** Stable cache-key for a capability scope: `profile` for the ambient/legacy * path, `connectionId::profile` for ANY explicit pin — `local` included. An * explicit "This device" pick and the ambient path are no longer guaranteed diff --git a/apps/desktop/src/api/config.ts b/apps/desktop/src/api/config.ts index e4d30979c9..64818f58c2 100644 --- a/apps/desktop/src/api/config.ts +++ b/apps/desktop/src/api/config.ts @@ -14,14 +14,14 @@ import type { StatusResponse } from '@/types/hermes' -import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client' - -// Settings' explicit "Applies to" selection is a visible user action. Only -// tag concrete profile scopes: ambient reads remain background so hydration -// and unrelated config consumers cannot consume foreground pool capacity. -function settingsScopePriority(profile?: ProfileScope): { priority?: 'foreground' } { - return profile == null ? {} : { priority: 'foreground' } -} +import { + capabilityScoped, + hermesApi, + type ProfileScope, + profileScoped, + scopedDialPriority, + STARTUP_REQUEST_TIMEOUT_MS +} from './client' export function getStatus(): Promise { return hermesApi({ @@ -81,7 +81,7 @@ export function getHermesConfigRecord( ): Promise { return window.hermesDesktop.api({ ...capabilityScoped(profile), - ...settingsScopePriority(profile), + ...scopedDialPriority(profile), path: includeDefaults ? '/api/config' : '/api/config?include_defaults=false' }) } @@ -97,7 +97,7 @@ export function getHermesConfigDefaults(): Promise { export function getHermesConfigSchema(profile?: null | string): Promise { return hermesApi({ ...profileScoped(profile), - ...(profile == null ? {} : { priority: 'foreground' as const }), + ...scopedDialPriority(profile), path: '/api/config/schema' }) } @@ -109,7 +109,7 @@ export function saveHermesConfig( ): Promise<{ ok: boolean }> { return hermesApi<{ ok: boolean }>({ ...profileScoped(profile), - ...(profile == null ? {} : { priority: 'foreground' as const }), + ...scopedDialPriority(profile), path: preserveLanguage ? '/api/config?preserve_language=true' : '/api/config', method: 'PUT', body: { config } @@ -122,7 +122,7 @@ export function saveHermesConfig( export function saveHermesConfigRecord(config: HermesConfigRecord, profile?: ProfileScope): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ ...capabilityScoped(profile), - ...settingsScopePriority(profile), + ...scopedDialPriority(profile), path: '/api/config', method: 'PUT', body: { config } @@ -132,7 +132,7 @@ export function saveHermesConfigRecord(config: HermesConfigRecord, profile?: Pro export function getEnvVars(profile?: null | string): Promise> { return hermesApi>({ ...profileScoped(profile), - ...(profile == null ? {} : { priority: 'foreground' as const }), + ...scopedDialPriority(profile), path: '/api/env' }) } @@ -140,7 +140,7 @@ export function getEnvVars(profile?: null | string): Promise { return window.hermesDesktop.api<{ ok: boolean }>({ ...capabilityScoped(profile), - ...settingsScopePriority(profile), + ...scopedDialPriority(profile), path: '/api/env', method: 'PUT', body: { key, value } @@ -150,7 +150,7 @@ export function setEnvVar(key: string, value: string, profile?: ProfileScope): P export function deleteEnvVar(key: string, profile?: ProfileScope): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ ...capabilityScoped(profile), - ...settingsScopePriority(profile), + ...scopedDialPriority(profile), path: '/api/env', method: 'DELETE', body: { key } @@ -160,7 +160,7 @@ export function deleteEnvVar(key: string, profile?: ProfileScope): Promise<{ ok: export function revealEnvVar(key: string, profile?: ProfileScope): Promise<{ key: string; value: string }> { return window.hermesDesktop.api<{ key: string; value: string }>({ ...capabilityScoped(profile), - ...settingsScopePriority(profile), + ...scopedDialPriority(profile), path: '/api/env/reveal', method: 'POST', body: { key } @@ -223,7 +223,7 @@ export function deleteCustomEndpoint(id: string): Promise { return hermesApi({ ...profileScoped(profile), - ...(profile == null ? {} : { priority: 'foreground' as const }), + ...scopedDialPriority(profile), path: '/api/providers/oauth' }) } @@ -234,7 +234,7 @@ export function disconnectOAuthProvider( ): Promise<{ ok: boolean; provider: string }> { return hermesApi<{ ok: boolean; provider: string }>({ ...profileScoped(profile), - ...(profile == null ? {} : { priority: 'foreground' as const }), + ...scopedDialPriority(profile), path: `/api/providers/oauth/${encodeURIComponent(providerId)}`, method: 'DELETE' }) @@ -243,7 +243,7 @@ export function disconnectOAuthProvider( export function startOAuthLogin(providerId: string, profile?: ProfileScope): Promise { return window.hermesDesktop.api({ ...capabilityScoped(profile), - ...settingsScopePriority(profile), + ...scopedDialPriority(profile), path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`, method: 'POST', body: {} @@ -258,7 +258,7 @@ export function submitOAuthCode( ): Promise { return hermesApi({ ...profileScoped(profile), - ...(profile == null ? {} : { priority: 'foreground' as const }), + ...scopedDialPriority(profile), path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`, method: 'POST', body: { session_id: sessionId, code } @@ -272,7 +272,7 @@ export function pollOAuthSession( ): Promise { return window.hermesDesktop.api({ ...capabilityScoped(profile), - ...settingsScopePriority(profile), + ...scopedDialPriority(profile), path: `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}` }) } @@ -280,7 +280,7 @@ export function pollOAuthSession( export function cancelOAuthSession(sessionId: string, profile?: null | string): Promise<{ ok: boolean }> { return hermesApi<{ ok: boolean }>({ ...profileScoped(profile), - ...(profile == null ? {} : { priority: 'foreground' as const }), + ...scopedDialPriority(profile), path: `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`, method: 'DELETE' }) diff --git a/apps/desktop/src/api/mcp.ts b/apps/desktop/src/api/mcp.ts index 94a22dc627..9507e8ea9f 100644 --- a/apps/desktop/src/api/mcp.ts +++ b/apps/desktop/src/api/mcp.ts @@ -1,6 +1,6 @@ import type { McpCatalogResponse, McpServerSummary } from '@/types/hermes' -import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client' +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, scopedDialPriority } from './client' export interface McpTestResult { ok: boolean @@ -117,6 +117,7 @@ export function setMcpServerEnabled(name: string, enabled: boolean): Promise<{ o export function getMcpCatalog(profile?: ProfileScope): Promise { return window.hermesDesktop.api({ ...capabilityScoped(profile), + ...scopedDialPriority(profile), path: '/api/mcp/catalog' }) } diff --git a/apps/desktop/src/api/skills.ts b/apps/desktop/src/api/skills.ts index f4d32f4dcc..656c073ca1 100644 --- a/apps/desktop/src/api/skills.ts +++ b/apps/desktop/src/api/skills.ts @@ -9,11 +9,12 @@ import type { } from '@/types/hermes' import type { ActionResponse } from '@/types/hermes' -import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client' +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, scopedDialPriority } from './client' export function getSkills(profile?: ProfileScope): Promise { return window.hermesDesktop.api({ ...capabilityScoped(profile), + ...scopedDialPriority(profile), path: '/api/skills' }) } diff --git a/apps/desktop/src/api/toolsets.ts b/apps/desktop/src/api/toolsets.ts index 862c0fc521..af0d27b2aa 100644 --- a/apps/desktop/src/api/toolsets.ts +++ b/apps/desktop/src/api/toolsets.ts @@ -7,7 +7,7 @@ import type { ToolsetModelsResponse } from '@/types/hermes' -import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client' +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, scopedDialPriority } from './client' // The optional trailing `profile` on every capability fetcher below is the // Capabilities view's profile-scope override: it lets the Skills/Tools/MCP @@ -17,6 +17,7 @@ import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from '. export function getToolsets(profile?: ProfileScope): Promise { return window.hermesDesktop.api({ ...capabilityScoped(profile), + ...scopedDialPriority(profile), path: '/api/tools/toolsets' }) } diff --git a/apps/desktop/src/hermes-capability-scope.test.ts b/apps/desktop/src/hermes-capability-scope.test.ts index e0a3718fc8..bba6745f6d 100644 --- a/apps/desktop/src/hermes-capability-scope.test.ts +++ b/apps/desktop/src/hermes-capability-scope.test.ts @@ -74,9 +74,13 @@ describe('capability helpers are connection-scoped', () => { expect(last().connectionId).toBe('gw-tailscale') }) - it('marks an explicitly scoped Settings config read as foreground', () => { + it('marks an explicitly scoped Settings / Capabilities read as foreground (#111651)', () => { + // A scope-selector pick is a visible user action: its cold dial must take + // the pool's reserved foreground slot instead of queueing behind hydration. getHermesConfigRecord('coder') + expect(last()).toMatchObject({ profile: 'coder', priority: 'foreground' }) + void getSkills('coder') expect(last()).toMatchObject({ profile: 'coder', priority: 'foreground' }) })