diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 59b598de1c..d770bce906 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -674,6 +674,10 @@ export function useGatewayBoot({ // (connectionId, profile) keep-set so two sources exposing the same // profile name (every source has a 'default') can't collide. configureGatewayRegistry({ + // The primary socket has no secondary entry to carry registry identity. + // Electron's published active descriptor is authoritative after boot; + // a true legacy primary has no connectionId and remains unqualified. + activeConnectionId: () => $connection.get()?.connectionId ?? null, // Every dispose path in the registry (live-work pruner AND the // refcount-0 request leases) spares a socket a mounted tile, the // primary thread or a just-created session's owner hold is bound to diff --git a/apps/desktop/src/app/session/hooks/profile-rail-fresh-chat-owner.test.tsx b/apps/desktop/src/app/session/hooks/profile-rail-fresh-chat-owner.test.tsx index 09b48b5c82..48aa678ccb 100644 --- a/apps/desktop/src/app/session/hooks/profile-rail-fresh-chat-owner.test.tsx +++ b/apps/desktop/src/app/session/hooks/profile-rail-fresh-chat-owner.test.tsx @@ -24,6 +24,7 @@ import { } from '@/store/profile' import { $activeSessionId, + $connection, $selectedStoredSessionId, $sessions, _resetSessionOwnerHintsForTests, @@ -33,6 +34,7 @@ import { setActiveSessionId, setAwaitingResponse, setBusy, + setConnection, setMessages, setSelectedStoredSessionId, setSessions @@ -336,13 +338,19 @@ describe('profile rail: a fresh Omar chat keeps its exact registry owner across mintedRuntimeId = RUNTIME_ID mintedStoredId = STORED_ID clearSingleFlightSessionResumeState() - configureGatewayRegistry({ onEvent: vi.fn() }) + // Wired exactly as useGatewayBoot: the published active descriptor carries + // a registry-backed primary's source identity across a renderer reload. + configureGatewayRegistry({ + activeConnectionId: () => $connection.get()?.connectionId ?? null, + onEvent: vi.fn() + }) closeSecondaryGateways() installDesktop() setSessions([]) setMessages([]) setActiveSessionId(null) setSelectedStoredSessionId(null) + setConnection(null) setBusy(false) setAwaitingResponse(false) $newChatProfile.set(null) @@ -357,6 +365,7 @@ describe('profile rail: a fresh Omar chat keeps its exact registry owner across setSessions([]) setActiveSessionId(null) setSelectedStoredSessionId(null) + setConnection(null) $newChatProfile.set(null) $newChatRoute.set(null) $newChatConnectionId.set(null) @@ -425,6 +434,50 @@ describe('profile rail: a fresh Omar chat keeps its exact registry owner across const calls = (socket: MockGateway) => socket.request.mock.calls.map(call => call[0] as string) + it('dials homelab::omar when boot published homelab on the active primary gateway', async () => { + const primary = makePrimary() + + setPrimaryGateway(primary as never, 'default') + expect(activeGateway()).toBe(primary as never) + // A true legacy primary has no published registry identity. + expect(activeGatewayConnectionId()).toBeNull() + + // Cold boot publishes the resolved primary descriptor before profile-rail + // interaction. No registry secondary has been opened in this scenario. + setConnection({ connectionId: SOURCE_ID, mode: 'remote', profile: 'default' } as never) + expect(activeGatewayConnectionId()).toBe(SOURCE_ID) + + selectProfile('omar') + + const desktop = window.hermesDesktop! + + await waitFor(() => + expect(desktop.getConnectionFor).toHaveBeenCalledWith({ connectionId: SOURCE_ID, profile: 'omar' }) + ) + expect(desktop.getConnection).not.toHaveBeenCalledWith('omar') + expect($newChatConnectionId.get()).toBe(SOURCE_ID) + }) + + it('keeps the legacy profile door when boot published `local` on the active primary gateway', async () => { + // The published identity survives the reload, but a pick on the explicit + // local source is still a legacy profile pick (per-profile remote + // overrides resolve through getConnection), so the draft's owner is the + // v1 profile socket, never the registry entry local::omar. + const primary = makePrimary() + + setPrimaryGateway(primary as never, 'default') + setConnection({ connectionId: 'local', mode: 'local', profile: 'default' } as never) + expect(activeGatewayConnectionId()).toBe('local') + + selectProfile('omar') + + const desktop = window.hermesDesktop! + + await waitFor(() => expect(desktop.getConnection).toHaveBeenCalledWith('omar')) + expect(desktop.getConnectionFor).not.toHaveBeenCalledWith({ connectionId: 'local', profile: 'omar' }) + expect($newChatConnectionId.get()).toBeNull() + }) + it('session.create and both prompt.submit calls ride the SAME conn:homelab::omar socket', async () => { const { handle, omarSocket, primary } = await bootProfileRailOmar() diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index f51c6ab99c..3aa53d2caa 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -24,6 +24,10 @@ const normKey = (profile: string | null | undefined): string => (profile ?? ''). const isOpen = (gateway: HermesGateway | null): boolean => gateway?.connectionState === 'open' interface RegistryConfig { + /** Electron's published descriptor is authoritative for a primary gateway's + * registry identity. Kept as a getter so gateway.ts does not own or duplicate + * the connection store. */ + activeConnectionId?: () => null | string onEvent: (event: GatewayEvent) => void onActiveConnectionInvalidated?: (fallbackProfile: string, activationEpoch: number) => void onActiveConnectionChanged?: (connection: HermesConnection) => void @@ -304,15 +308,17 @@ export function activeGateway(): HermesGateway | null { /** * The registry connection serving the gateway the user is currently looking - * at — null for the local/legacy primary path and for profile-keyed (local) - * secondaries. Event consumers pair this with the event's own `connectionId` - * tag so "from the active profile" really means "from the active SOURCE": + * at. A registry-backed primary takes its identity from the published primary + * connection, falling back to Electron's active descriptor until that is set; + * a true legacy primary (no resolved connectionId) and profile-keyed local + * secondaries remain null. Event consumers pair this with the event's own + * `connectionId` tag so "from the active profile" really means "from the active SOURCE": * two connected gateways can both expose a 'default' profile, and a bare * profile comparison attributed gateway B's 'default' activity to gateway A. */ export function activeGatewayConnectionId(): null | string { if (g.activeKey === g.primaryProfile) { - return g.primaryConnectionId + return g.primaryConnectionId ?? (g.config?.activeConnectionId?.()?.trim() || null) } return g.secondaries.get(g.activeKey)?.connectionId ?? null