fix(desktop): retain primary gateway registry identity after reload

This commit is contained in:
Deus
2026-08-25 18:07:06 +02:00
committed by Teknium
parent 6fdf873464
commit c662e1be7b
3 changed files with 68 additions and 5 deletions
@@ -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
@@ -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()
+10 -4
View File
@@ -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