fix(desktop): every gateway stays connected; picking one is instant, with no switch prompt

Desktop treated registered gateways as one-at-a-time: picking another gateway
ran a "switch" (barrier, session-list wipe, a "Switch to This device? This
opens a fresh session on this computer..." confirm), and a gateway that was
unreachable when Desktop started never showed up in the All profiles session
list after it came back.

- store/gateway-persistence: every registered gateway's home socket is dialed
  at launch, pinned against the idle pruner (retainGatewayForRelay now takes
  includeLocal so This device is kept too when a remote is primary), redialed
  every 30 s while down, and the session list is re-pulled when one connects.
- selectConnection is a live activation like a profile pick: the target is
  opened first (a dead gateway fails there and touches nothing), then the old
  runtime is unbound and a fresh draft started synchronously right before the
  target is published (#93937 invariant kept). No barrier, no list wipe, no
  machine-context reset; an in-flight Settings apply is superseded.
- This device prompts only when picking it would start the installer; the
  switchDevice* copy is gone and "Switch to" reads "Open". Other gateways'
  rail squares use the normal unselected weight instead of an "at rest" dim.
- Docs: multi-connection-desktop.md describes always-connected gateways.
This commit is contained in:
Teknium committed 2026-11-01 14:27:53 -08:00
1 parent 33920f4b2f
commit 13bd44e4a4
27 files changed
+537 -873

No files matched your search

@@ -98,11 +98,7 @@ export function ConnectionSwitcher({ compact = false, onConnect }: { compact?: b
void (async () => {
if (connection?.kind === 'local' && connectionId !== activeConnectionId) {
const accepted = await requestLocalDevice({
connectionId,
label: connection.label,
replaceCenter: true
})
const accepted = await requestLocalDevice({ connectionId, label: connection.label })
if (!accepted) {
return
@@ -23,25 +23,17 @@ async function probeLocalBackend(): Promise<boolean> {
export interface LocalDeviceTarget {
connectionId: string
label: string
profile?: string
/** The switch replaces the center with a fresh session, even when Hermes is already installed. */
replaceCenter?: boolean
}
interface LocalDevicePrompt extends LocalDeviceTarget {
kind: 'install' | 'switch'
}
/**
* Gate a re-home onto This device. Bootstrap-needed always confirms, and offers
* Connect to existing inline so the click cannot fall through into runBootstrap.
* An already-installed runtime still confirms before a fresh session replaces
* the center. Named profiles on an installed local runtime keep the existing
* spinner path — they are not the Home-looking misclick.
* Gate picking This device on Hermes actually being installed here. Only a
* click that would start the installer confirms, and it offers Connect to
* existing inline so the click cannot fall through into runBootstrap. An
* installed runtime is just another connected gateway: no prompt.
*/
export function useLocalDeviceSwitch() {
const { t } = useI18n()
const [prompt, setPrompt] = useState<LocalDevicePrompt | null>(null)
const [prompt, setPrompt] = useState<LocalDeviceTarget | null>(null)
const [connectOpen, setConnectOpen] = useState(false)
const waiter = useRef<((accepted: boolean) => void) | null>(null)
@@ -71,43 +63,36 @@ export function useLocalDeviceSwitch() {
return false
}
const freshSession = target.replaceCenter === true || target.profile == null || target.profile === 'default'
if (!bootstrapNeeded && !freshSession) {
if (!bootstrapNeeded) {
return true
}
return new Promise(resolve => {
waiter.current = resolve
setPrompt({ ...target, kind: bootstrapNeeded ? 'install' : 'switch' })
setPrompt(target)
})
}
const fleet = t.profiles.fleet
const install = prompt?.kind === 'install'
const dialog = (
<>
{prompt ? (
<ConfirmDialog
confirmLabel={install ? fleet.installDeviceConfirm : fleet.switchDeviceConfirm}
description={install ? fleet.installDeviceDesc : fleet.switchDeviceDesc}
confirmLabel={fleet.installDeviceConfirm}
description={fleet.installDeviceDesc}
dismissOnConfirm
onClose={() => settle(false)}
onConfirm={() => settle(true)}
open
secondaryAction={
install
? {
label: fleet.connectExistingInstead,
onClick: () => {
settle(false)
setConnectOpen(true)
}
}
: undefined
}
title={install ? fleet.installDeviceTitle : fleet.switchDeviceTitle}
secondaryAction={{
label: fleet.connectExistingInstead,
onClick: () => {
settle(false)
setConnectOpen(true)
}
}}
title={fleet.installDeviceTitle}
/>
) : null}
{connectOpen ? <FirstRunRemoteSetup onBack={() => setConnectOpen(false)} /> : null}
@@ -163,9 +163,7 @@ export function ProfileSwitcher({ compact = false }: { compact?: boolean }) {
void requestLocalDevice({
connectionId: agent.connectionId,
label: agent.connectionLabel,
profile: agent.profile,
replaceCenter: agent.profile === 'default'
label: agent.connectionLabel
}).then(accepted => {
setPendingRoute(current => (current === key ? null : current))
@@ -49,14 +49,10 @@ vi.mock('@/i18n', () => ({
installDeviceConfirm: 'Install locally',
installDeviceDesc:
'This will install Hermes locally, then open a fresh session on this computer. Nothing is installed until you confirm.',
installDeviceTitle: 'Switch to This device?',
installDeviceTitle: 'Install Hermes on This device?',
localDevice: 'This device (local backend — installs Hermes if missing, otherwise opens a fresh session)',
onGateway: (name: string, gateway: string) => `${name} · ${gateway}`,
switchDeviceConfirm: 'Switch',
switchDeviceDesc:
'This opens a fresh session on this computer. The conversation you are in stays on the other gateway.',
switchDeviceTitle: 'Switch to This device?',
switchTo: (name: string, gateway: string) => `Switch to ${name} on ${gateway}`
switchTo: (name: string, gateway: string) => `Open ${name} on ${gateway}`
},
importProfile: 'Import profile…',
manageProfiles: 'Manage profiles…',
@@ -531,7 +527,7 @@ describe('ProfileRail fleet mode', () => {
expect(builder.getAttribute('aria-busy')).toBeNull()
})
it('asks before a fresh session when This device is already installed', async () => {
it('opens an installed This device with no prompt', async () => {
armFleet()
await renderFleet()
@@ -541,21 +537,8 @@ describe('ProfileRail fleet mode', () => {
})
)
expect(selectConnection).not.toHaveBeenCalled()
expect(await screen.findByRole('dialog', { name: 'Switch to This device?' })).toBeTruthy()
expect(
screen.getByText(
'This opens a fresh session on this computer. The conversation you are in stays on the other gateway.'
)
).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Switch' }))
await act(async () => {
await Promise.resolve()
})
expect(selectConnection).toHaveBeenCalledWith('local', { profile: 'default' })
await vi.waitFor(() => expect(selectConnection).toHaveBeenCalledWith('local', { profile: 'default' }))
expect(screen.queryByRole('dialog')).toBeNull()
})
it('confirms before installing and offers connect-to-existing inline', async () => {
@@ -582,24 +565,6 @@ describe('ProfileRail fleet mode', () => {
expect(await screen.findByText('Connect to existing Hermes')).toBeTruthy()
})
it('does not switch when the fresh-session cue is cancelled', async () => {
armFleet()
await renderFleet()
fireEvent.click(
screen.getByRole('button', {
name: 'This device (local backend — installs Hermes if missing, otherwise opens a fresh session)'
})
)
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }))
await act(async () => {
await Promise.resolve()
})
expect(selectConnection).not.toHaveBeenCalled()
})
it('keeps the active gateway click on the plain profile path', async () => {
armFleet()
await renderFleet()
@@ -206,13 +206,12 @@ const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, trans
//
// With one registered gateway this is the whole story. With several, the rail
// becomes the FLEET rail: the active gateway's profiles stay exactly as they
// are, and every other registered gateway follows on the same strip as an
// at-rest group — a hairline, that gateway's kind glyph, its default home
// square and its named squares, dimmed. Clicking an at-rest square performs
// the same re-home the statusbar switcher does, landing on that exact
// (gateway, profile); the workspace still lives on one gateway at a time, only
// the picker spans the fleet. Groups keep registry order regardless of which
// one is active, so a square never moves under the pointer that clicked it.
// are, and every other registered gateway follows on the same strip as its own
// group — a hairline, that gateway's kind glyph, its default home square and
// its named squares. Every gateway is connected all the time, so clicking one
// of those squares is a profile pick on that gateway, landing on that exact
// (gateway, profile) with no dial. Groups keep registry order regardless of
// which one is active, so a square never moves under the pointer that clicked it.
export function ProfileRail() {
const { t, locale } = useI18n()
const p = t.profiles
@@ -349,16 +348,14 @@ export function ProfileRail() {
return
}
// Probe spinner only. The dialog — install confirm or fresh-session cue —
// must be on screen before selectConnection replaces the center.
// Probe spinner only: This device prompts only when picking it would
// start the installer.
const key = fleetRouteKey(agent.connectionId, agent.profile)
setPendingRoute(key)
void requestLocalDevice({
connectionId: agent.connectionId,
label: agent.connectionLabel,
profile: agent.profile,
replaceCenter: agent.profile === 'default'
label: agent.connectionLabel
}).then(accepted => {
setPendingRoute(current => (current === key ? null : current))
@@ -1094,7 +1091,7 @@ interface ProfilePillProps {
glyph: string
label: string
onSelect: () => void
// Fleet at-rest: dimmed until hovered, like the at-rest squares beside it.
// Another gateway's pill: the same unselected weight as the squares beside it.
muted?: boolean
pending?: boolean
slot?: string
@@ -1131,7 +1128,7 @@ function ProfilePill({
className={cn(
'bg-transparent text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
active && 'bg-(--ui-control-active-background) text-foreground',
muted && 'opacity-40 hover:opacity-100',
muted && 'opacity-55 hover:opacity-100',
summary && !active && 'relative'
)}
data-connection-id={connectionId}
@@ -1231,8 +1228,8 @@ function FleetRestGroup({
: `${group.needsSignIn ? `${p.fleet.gateway(group.label)} · ${t.settings.toolsets.needsSignIn}` : p.fleet.gatewayUnreachable(group.label)}${group.error ? `\n${group.error}` : ''}`
const defaultKey = fleetRouteKey(group.connectionId, group.defaultAgent.profile)
// At rest, This device is a backend switch, not Home. The house glyph stays
// on the active gateway's default profile.
// Off the active gateway, This device wears its device glyph, not Home. The
// house glyph stays on the active gateway's default profile.
const localDefault = group.kind === 'local'
return (
@@ -1329,7 +1326,7 @@ function RestSquare({
<button
aria-busy={pending || undefined}
aria-label={statusText ? `${label}, ${statusText}` : label}
className="relative grid size-5 shrink-0 select-none place-items-center rounded-[3px] text-[0.5625rem] font-semibold uppercase leading-none opacity-35 transition-opacity hover:opacity-100 aria-busy:opacity-100"
className="relative grid size-5 shrink-0 select-none place-items-center rounded-[3px] text-[0.5625rem] font-semibold uppercase leading-none opacity-55 transition-opacity hover:opacity-100 aria-busy:opacity-100"
data-connection-id={agent.connectionId}
data-profile={agent.profile}
data-slot="profile-rail-rest-square"
@@ -14,6 +14,7 @@ import { pathFromHermesDeepLink, resolveHermesOpenPath } from '@/lib/hermes-open
import { storedSessionIdForNotification } from '@/lib/session-ids'
import { announceNewSessionDraftKey } from '@/store/composer'
import { recordAction } from '@/store/desktop-metrics'
import { onKeptGatewayConnected } from '@/store/gateway-persistence'
import { requestMcpInstallFromDeepLink } from '@/store/mcp-deeplink-install'
import { startMcpHealthChecker, stopMcpHealthChecker } from '@/store/mcp-health'
import {
@@ -561,4 +562,7 @@ export function useDesktopIntegrations({
return onSessionsChanged(() => void refreshSessions())
}, [refreshSessions])
// A gateway that connects after the list was drawn adds its rows to it.
useEffect(() => onKeptGatewayConnected(() => void refreshSessions()), [refreshSessions])
}
@@ -3,6 +3,7 @@ import { useEffect } from 'react'
import { $desktopBoot } from '@/store/boot'
import { initializeConnectionsRegistry, refreshConnectionsRegistry } from '@/store/connections'
import { keepGatewaysConnected } from '@/store/gateway-persistence'
import { isAuxiliaryWindow, isPeerInstanceWindow } from '@/store/windows'
const RETRY_DELAYS_MS = [1_000, 2_000]
@@ -79,6 +80,9 @@ export function useConnectionsRegistry(): void {
// auxiliary windows already have an explicit destination of their own.
if (!boot.running && !isAuxiliaryWindow() && !isPeerInstanceWindow()) {
void initializeConnectionsRegistry().catch(() => undefined)
// Idempotent: a later re-boot (a connection-mode apply) keeps the other
// gateways' sockets instead of dropping and redialing them.
keepGatewaysConnected()
}
}, [boot.running])
}
@@ -1021,11 +1021,10 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
it("#93937: the Sessions switcher never publishes the new source while the previous backend's runtime id is still bound", async () => {
// Real stores end to end: real useGatewayBoot, real gateway registry, real
// selectConnection, fake sockets. Boot on the primary VPS with a transcript
// open (its runtime id was minted by THAT backend), then switch sources
// through the sidebar door. Before the fix that door activated the new
// socket first and wiped the bindings after an IPC round-trip, so the
// renderer sat on "gateway B + runtime id from A" and B answered every
// session RPC with "session not found".
// open (its runtime id was minted by THAT backend), then pick another
// gateway in the sidebar. Before #93937 that door activated the new socket
// and unbound the old runtime after an IPC round-trip, so the renderer sat
// on "gateway B + runtime id from A" and B answered "session not found".
const registryConnections: DesktopConnectionsRegistry = {
connections: [
{ id: 'primary-vps', kind: 'remote', label: 'VPS', tokenPreview: '...t', tokenSet: true },
@@ -1038,7 +1037,7 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
const desktop = fakeDesktop() as ReturnType<typeof fakeDesktop> & Record<string, unknown>
const setLastUsed = vi.fn(async (id: string) => ({ ok: true, registry: { ...registryConnections, lastUsed: id } }))
let bindingAtDial: null | string = null
let dials = 0
desktop.api = vi.fn(async ({ path }: { path: string }) =>
path === '/api/profiles/active' ? { active: 'default', current: 'default' } : { profiles: [] }
@@ -1050,8 +1049,7 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
registryScoped: true
}))
desktop.getGatewayWsUrlFor = vi.fn(async () => {
// Phase 1 (the dial) runs with the previous source still fully bound.
bindingAtDial = $activeSessionId.get()
dials += 1
return coderConn.wsUrl
})
@@ -1078,6 +1076,8 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
}
})
await vi.waitFor(() => expect(dials).toBeGreaterThan(0)) // connected before any pick
const dialsBeforePick = dials
const switching = selectConnection('coder-remote')
await flushAsync()
await flushAsync()
@@ -1085,13 +1085,13 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
await switching
off()
// The previous backend's runtime id was already gone — and the barrier up —
// at every publication of the new source. (Pre-fix: the first publication
// carried activeSessionId 'a93bb39d' with the barrier down.)
// The previous backend's runtime id was already gone at every publication
// of the new source (pre-fix the first one carried 'a93bb39d'). A gateway
// pick is a live activation: no switch barrier, no machine-context reset.
expect(published.length).toBeGreaterThan(0)
expect(published).toEqual(published.map(() => ({ activeSessionId: null, switching: true })))
expect(bindingAtDial).toBe('a93bb39d')
expect(beforeConnectionSwitch).toHaveBeenCalledTimes(1)
expect(published).toEqual(published.map(() => ({ activeSessionId: null, switching: false })))
expect(dials).toBe(dialsBeforePick) // the pick dials nothing
expect(beforeConnectionSwitch).not.toHaveBeenCalled()
expect($connection.get()?.connectionId).toBe('coder-remote')
expect(isActivePrimary()).toBe(false)
expect($activeSessionId.get()).toBeNull()
@@ -337,10 +337,8 @@ export function useGatewayBoot({
return () => void (cancelled = true)
}
// Store-driven switches (Sessions switcher → selectConnection) commit
// through beginGatewaySwitch(), which runs this window's machine-context
// reset — the same one a Settings apply (softSwitch below) runs. One owner,
// one reset, so the two doors can't drift apart again (#93937).
// A Settings apply (softSwitch below) commits through beginGatewaySwitch(),
// which runs this window's machine-context reset (#93937).
const offSwitchLifecycle = registerGatewaySwitchLifecycle({
beforeConnectionSwitch: () => callbacksRef.current.beforeConnectionSwitch(),
refreshSessions: shouldPublish => callbacksRef.current.refreshSessions(shouldPublish)
+1 -4
View File
@@ -408,10 +408,7 @@ export const arCommandCenter = {
manageProfiles: 'إدارة الملفات الشخصية',
fleet: {
localDevice: 'هذا الجهاز (خلفية محلية — تثبّت Hermes إن كان مفقودًا، وإلا تفتح جلسة جديدة)',
switchDeviceTitle: 'التبديل إلى هذا الجهاز؟',
switchDeviceDesc: 'يفتح هذا جلسة جديدة على هذا الحاسوب. تبقى المحادثة الحالية على البوابة الأخرى.',
switchDeviceConfirm: 'تبديل',
installDeviceTitle: 'التبديل إلى هذا الجهاز؟',
installDeviceTitle: 'تثبيت Hermes على هذا الجهاز؟',
installDeviceDesc: 'سيُثبَّت Hermes محليًا ثم تُفتح جلسة جديدة على هذا الحاسوب. لا يبدأ التثبيت قبل التأكيد.',
installDeviceConfirm: 'تثبيت محليًا',
connectExistingInstead: 'الاتصال بموجود بدلًا من ذلك'
+3 -7
View File
@@ -1618,7 +1618,7 @@ export const deOverrides = {
intro:
'Verwalten Sie dieses Gerät und jedes Hermes Gateway, das es über Remote-, SSH- oder Cloud-Verbindungen erreichen kann.',
stagedNote:
'Wechseln Sie Gateways über Sessions. Profile, Chats, Nachrichten und Cron-Jobs bleiben bei ihrem Gateway; Arbeit auf anderen Gateways läuft weiter.',
'Jedes Gateway bleibt verbunden. Profile, Chats, Nachrichten und Cron-Jobs bleiben bei ihrem Gateway, und Sessions kann alle gleichzeitig anzeigen.',
launchModeTitle: 'Beim Start zu Sessions auf dem zuletzt verwendeten Gateway zurückkehren',
launchModeDesc: 'Wenn deaktiviert, öffnet Sessions auf dem primären Gateway.',
searchPlaceholder: 'Gateways durchsuchen…',
@@ -3232,15 +3232,11 @@ export const deOverrides = {
gateway: gateway => `Profile auf ${gateway}`,
gatewayUnreachable: gateway => `${gateway} · nicht erreichbar`,
onGateway: (name, gateway) => `${name} · ${gateway}`,
switchTo: (name, gateway) => `Zu ${name} auf ${gateway} wechseln`,
switchTo: (name, gateway) => `${name} auf ${gateway} öffnen`,
deleteOn: gateway => ` auf ${gateway}`,
localDevice:
'Dieses Gerät (lokales Backend — installiert Hermes, falls es fehlt, sonst öffnet es eine neue Sitzung)',
switchDeviceTitle: 'Zu diesem Gerät wechseln?',
switchDeviceDesc:
'Das öffnet eine neue Sitzung auf diesem Computer. Das aktuelle Gespräch bleibt auf dem anderen Gateway.',
switchDeviceConfirm: 'Wechseln',
installDeviceTitle: 'Zu diesem Gerät wechseln?',
installDeviceTitle: 'Hermes auf diesem Gerät installieren?',
installDeviceDesc:
'Hermes wird lokal installiert und danach eine neue Sitzung auf diesem Computer geöffnet. Ohne Bestätigung startet keine Installation.',
installDeviceConfirm: 'Lokal installieren',
+3 -7
View File
@@ -1280,7 +1280,7 @@ export const en: Translations = {
title: 'Registered gateways',
intro: 'Manage this device and every Hermes gateway it can reach through remote, SSH, or Cloud connections.',
stagedNote:
'Switch gateways from Sessions. Profiles, chats, messaging, and cron jobs stay with their gateway; work on other gateways keeps running.',
'Every gateway stays connected. Profiles, chats, messaging, and cron jobs stay with their gateway, and Sessions can show all of them at once.',
launchModeTitle: 'At startup, return to Sessions on the last-used gateway',
launchModeDesc: 'When off, Sessions opens on the Primary gateway.',
searchPlaceholder: 'Search gateways…',
@@ -2797,14 +2797,10 @@ export const en: Translations = {
gateway: gateway => `Profiles on ${gateway}`,
gatewayUnreachable: gateway => `${gateway} · unreachable`,
onGateway: (name, gateway) => `${name} · ${gateway}`,
switchTo: (name, gateway) => `Switch to ${name} on ${gateway}`,
switchTo: (name, gateway) => `Open ${name} on ${gateway}`,
deleteOn: gateway => ` on ${gateway}`,
localDevice: 'This device (local backend — installs Hermes if missing, otherwise opens a fresh session)',
switchDeviceTitle: 'Switch to This device?',
switchDeviceDesc:
'This opens a fresh session on this computer. The conversation you are in stays on the other gateway.',
switchDeviceConfirm: 'Switch',
installDeviceTitle: 'Switch to This device?',
installDeviceTitle: 'Install Hermes on This device?',
installDeviceDesc:
'This will install Hermes locally, then open a fresh session on this computer. Nothing is installed until you confirm.',
installDeviceConfirm: 'Install locally',
+3 -7
View File
@@ -1617,7 +1617,7 @@ export const esOverrides = {
intro:
'Gestiona este dispositivo y cada gateway de Hermes al que puede llegar mediante conexiones remotas, SSH o Cloud.',
stagedNote:
'Cambia de gateway desde Sesiones. Los perfiles, chats, mensajería y tareas cron se quedan con su gateway; el trabajo en otros gateways sigue ejecutándose.',
'Todos los gateways permanecen conectados. Los perfiles, chats, mensajería y tareas cron se quedan con su gateway, y Sesiones puede mostrarlos todos a la vez.',
launchModeTitle: 'Al iniciar, volver a Sesiones en el último gateway usado',
launchModeDesc: 'Si está desactivado, Sesiones se abre en el gateway principal.',
searchPlaceholder: 'Buscar gateways…',
@@ -3221,14 +3221,10 @@ export const esOverrides = {
gateway: (gateway: string) => `Perfiles en ${gateway}`,
gatewayUnreachable: (gateway: string) => `${gateway} · inaccesible`,
onGateway: (name: string, gateway: string) => `${name} · ${gateway}`,
switchTo: (name: string, gateway: string) => `Cambiar a ${name} en ${gateway}`,
switchTo: (name: string, gateway: string) => `Abrir ${name} en ${gateway}`,
deleteOn: (gateway: string) => ` en ${gateway}`,
localDevice: 'Este dispositivo (backend local: instala Hermes si falta; si no, abre una sesión nueva)',
switchDeviceTitle: '¿Cambiar a este dispositivo?',
switchDeviceDesc:
'Esto abre una sesión nueva en este equipo. La conversación actual permanece en el otro gateway.',
switchDeviceConfirm: 'Cambiar',
installDeviceTitle: '¿Cambiar a este dispositivo?',
installDeviceTitle: '¿Instalar Hermes en este dispositivo?',
installDeviceDesc:
'Esto instalará Hermes localmente y luego abrirá una sesión nueva en este equipo. No se instala nada hasta que confirmes.',
installDeviceConfirm: 'Instalar localmente',
+3 -7
View File
@@ -1622,7 +1622,7 @@ export const frOverrides = {
intro:
'Enregistrez tous les emplacements où vivent vos agents : cet appareil, les gateways distantes de votre réseau et les instances Hermes Cloud. Ils sont tous conservés ici.',
stagedNote:
"Les conversations et la liste des agents suivent la source choisie ; le backend de fenêtre géré par l'application reste sélectionné dans Paramètres → Gateway.",
'Chaque gateway reste connectée. Les profils, conversations, messageries et tâches cron restent sur leur gateway, et Sessions peut toutes les afficher à la fois.',
launchModeTitle: 'Au démarrage, revenir aux sessions de la dernière gateway utilisée',
launchModeDesc: "Si cette option est désactivée, les sessions s'ouvrent sur la gateway principale.",
searchPlaceholder: 'Rechercher des gateways…',
@@ -3237,14 +3237,10 @@ export const frOverrides = {
gateway: gateway => `Profils sur ${gateway}`,
gatewayUnreachable: gateway => `${gateway} · inaccessible`,
onGateway: (name, gateway) => `${name} · ${gateway}`,
switchTo: (name, gateway) => `Basculer vers ${name} sur ${gateway}`,
switchTo: (name, gateway) => `Ouvrir ${name} sur ${gateway}`,
deleteOn: gateway => ` sur ${gateway}`,
localDevice: 'Cet appareil (backend local — installe Hermes s’il manque, sinon ouvre une nouvelle session)',
switchDeviceTitle: 'Basculer vers cet appareil ?',
switchDeviceDesc:
'Cela ouvre une nouvelle session sur cet ordinateur. La conversation en cours reste sur l’autre gateway.',
switchDeviceConfirm: 'Basculer',
installDeviceTitle: 'Basculer vers cet appareil ?',
installDeviceTitle: 'Installer Hermes sur cet appareil ?',
installDeviceDesc:
'Hermes sera installé localement, puis une nouvelle session s’ouvrira sur cet ordinateur. Rien n’est installé tant que vous ne confirmez pas.',
installDeviceConfirm: 'Installer localement',
+1 -4
View File
@@ -1795,10 +1795,7 @@ export const jaOverrides = {
fleet: {
localDevice:
'このデバイス(ローカルバックエンド — Hermes が無ければインストールし、あれば新しいセッションを開きます)',
switchDeviceTitle: 'このデバイスに切り替えますか?',
switchDeviceDesc: 'このコンピュータで新しいセッションを開きます。今の会話は別のゲートウェイに残ります。',
switchDeviceConfirm: '切り替える',
installDeviceTitle: 'このデバイスに切り替えますか?',
installDeviceTitle: 'このデバイスに Hermes をインストールしますか?',
installDeviceDesc:
'Hermes をローカルにインストールしてから、このコンピュータで新しいセッションを開きます。確認するまでインストールは始まりません。',
installDeviceConfirm: 'ローカルにインストール',
+2 -5
View File
@@ -1005,7 +1005,7 @@ export const ruOverrides = {
intro:
'Управляйте этим устройством и всеми шлюзами Hermes, до которых можно дотянуться через удалённые, SSH или Cloud-соединения.',
stagedNote:
'Переключайтесь между шлюзами из раздела «Сеансы». Профили, чаты, мессенджеры и cron-задачи остаются за своим шлюзом; работа на других шлюзах продолжается.',
'Все шлюзы остаются подключёнными. Профили, чаты, мессенджеры и cron-задачи остаются за своим шлюзом, а «Сеансы» могут показывать их все сразу.',
launchModeTitle: 'При запуске возвращаться к «Сеансам» на последнем шлюзе',
launchModeDesc: 'Если выключено, «Сеансы» открываются на основном шлюзе.',
searchPlaceholder: 'Поиск шлюзов…',
@@ -2084,10 +2084,7 @@ export const ruOverrides = {
connectGateway: 'Управлять шлюзами…',
fleet: {
localDevice: 'Это устройство (локальный бэкенд — установит Hermes, если его нет, иначе откроет новую сессию)',
switchDeviceTitle: 'Переключиться на это устройство?',
switchDeviceDesc: 'Откроется новая сессия на этом компьютере. Текущий разговор останется на другом шлюзе.',
switchDeviceConfirm: 'Переключить',
installDeviceTitle: 'Переключиться на это устройство?',
installDeviceTitle: 'Установить Hermes на это устройство?',
installDeviceDesc:
'Hermes будет установлен локально, затем откроется новая сессия на этом компьютере. Установка не начнётся, пока вы не подтвердите.',
installDeviceConfirm: 'Установить локально',
-3
View File
@@ -2464,9 +2464,6 @@ export interface Translations extends NoticeTranslations {
deleteOn: (gateway: string) => string
/** At-rest local default pill: device, not Home, and the click's consequence. */
localDevice: string
switchDeviceTitle: string
switchDeviceDesc: string
switchDeviceConfirm: string
installDeviceTitle: string
installDeviceDesc: string
installDeviceConfirm: string
@@ -331,10 +331,7 @@ export const zhHantCommandCenter = {
manageProfiles: '管理設定檔…',
fleet: {
localDevice: '此裝置(本機後端——若未安裝 Hermes 則會安裝,否則開啟新的工作階段)',
switchDeviceTitle: '切換到此裝置?',
switchDeviceDesc: '這會在這台電腦上開啟新的工作階段。目前的對話仍留在另一個閘道。',
switchDeviceConfirm: '切換',
installDeviceTitle: '切換到此裝置?',
installDeviceTitle: '在此裝置上安裝 Hermes?',
installDeviceDesc: '這會在本機安裝 Hermes,然後在這台電腦上開啟新的工作階段。確認之前不會開始安裝。',
installDeviceConfirm: '本機安裝',
connectExistingInstead: '改為連線現有環境'
+3 -7
View File
@@ -1322,8 +1322,7 @@ export const zhOverrides = {
connections: {
title: '已注册网关',
intro: '管理本机以及通过远程、SSH 或 Hermes Cloud 连接可访问的每个 Hermes 网关。',
stagedNote:
'可在“会话”侧边栏切换网关。配置档案、聊天、消息和定时任务归属于各自网关;其他网关上的工作会继续运行。',
stagedNote: '所有网关都保持连接。配置档案、聊天、消息和定时任务归属于各自网关,“会话”可以同时显示全部网关。',
launchModeTitle: '启动时返回上次使用网关的会话视图',
launchModeDesc: '关闭时,会话将在主网关上打开。',
searchPlaceholder: '搜索网关…',
@@ -2712,13 +2711,10 @@ export const zhOverrides = {
gateway: gateway => `${gateway} 上的配置档案`,
gatewayUnreachable: gateway => `${gateway} · 无法连接`,
onGateway: (name, gateway) => `${name} · ${gateway}`,
switchTo: (name, gateway) => `切换到 ${gateway} 上的 ${name}`,
switchTo: (name, gateway) => `打开 ${gateway} 上的 ${name}`,
deleteOn: gateway => `(位于 ${gateway})`,
localDevice: '此设备(本地后端——若未安装 Hermes 则会安装,否则打开一个新会话)',
switchDeviceTitle: '切换到此设备?',
switchDeviceDesc: '这会在这台电脑上打开一个新会话。当前对话仍留在另一个网关上。',
switchDeviceConfirm: '切换',
installDeviceTitle: '切换到此设备?',
installDeviceTitle: '在此设备上安装 Hermes?',
installDeviceDesc: '这将在本地安装 Hermes,然后在这台电脑上打开一个新会话。确认之前不会开始安装。',
installDeviceConfirm: '本地安装',
connectExistingInstead: '改为连接现有环境'
+94 -466
View File
@@ -19,10 +19,9 @@ const $connection = atom<null | {
registryScoped?: boolean
}>(null)
// The runtime session id minted by the CURRENT backend — the binding a switch
// must sever before the next source is published (#93937).
// The runtime session id minted by the CURRENT backend — the binding a pick
// must drop before the next gateway is published (#93937).
const $activeSessionId = atom<null | string>(null)
const $gatewaySwitching = atom(false)
interface ActivationOptions {
beforeActivate?: () => boolean
@@ -36,39 +35,17 @@ const ensureGatewayAgent = vi.fn(
const openGatewayAgent = vi.fn(async (_connectionId: string, _profile: string): Promise<void> => undefined)
const refreshActiveProfile = vi.fn(async () => undefined)
const requestFreshSession = vi.fn()
const beforeConnectionSwitch = vi.fn()
const wipeSessionListsForGatewaySwitch = vi.fn(() => $activeSessionId.set(null))
const exitProjectScope = vi.fn()
// Test double for the store's commit point with the real one's contract
// (barrier → machine-context reset → wipe, synchronously; the barrier is
// owned by the latest token); the real implementation is covered by
// gateway-switch.test.ts.
let latestSwitchToken = 0
const beginGatewaySwitch = vi.fn(() => {
$gatewaySwitching.set(true)
beforeConnectionSwitch()
wipeSessionListsForGatewaySwitch()
return ++latestSwitchToken
})
const endGatewaySwitch = vi.fn((token?: number) => {
if (token === undefined || token === latestSwitchToken) {
$gatewaySwitching.set(false)
}
})
const recoverActiveSourceAfterFailedGatewaySwitch = vi.fn()
vi.mock('@/store/session', () => ({ $connection, $activeSessionId, $selectedStoredSessionId: atom(null) }))
vi.mock('@/store/gateway-switch', () => ({
$gatewaySwitching,
beginGatewaySwitch,
endGatewaySwitch,
recoverActiveSourceAfterFailedGatewaySwitch,
wipeSessionListsForGatewaySwitch
vi.mock('@/store/session', () => ({
$connection,
$activeSessionId,
$selectedStoredSessionId: atom(null),
setActiveSessionId: (id: null | string) => $activeSessionId.set(id),
setSelectedStoredSessionId: vi.fn()
}))
vi.mock('@/store/project-scope', () => ({ exitProjectScope }))
vi.mock('@/store/gateway-switch', () => ({ supersedeGatewaySwitch: vi.fn() }))
vi.mock('@/store/profile', () => ({
$activeGatewayProfile,
$freshSessionRequest,
@@ -119,7 +96,7 @@ beforeEach(() => {
$newChatProfile.set(null)
$showAllProfiles.set(false)
ensureGatewayAgent.mockReset()
// Mirrors the real door: the commit hook runs right before the activation
// Mirrors the real door: beforeActivate runs right before the activation
// publishes, and a declined hook publishes nothing.
ensureGatewayAgent.mockImplementation(async (connectionId, profile, options) => {
if (options?.beforeActivate && !options.beforeActivate()) {
@@ -140,13 +117,8 @@ beforeEach(() => {
openGatewayAgent.mockResolvedValue(undefined)
refreshActiveProfile.mockClear()
requestFreshSession.mockClear()
beforeConnectionSwitch.mockClear()
beginGatewaySwitch.mockClear()
endGatewaySwitch.mockClear()
recoverActiveSourceAfterFailedGatewaySwitch.mockClear()
wipeSessionListsForGatewaySwitch.mockClear()
exitProjectScope.mockClear()
$activeSessionId.set(null)
$gatewaySwitching.set(false)
list.mockClear()
setLastUsed.mockClear()
api.mockReset()
@@ -271,18 +243,17 @@ describe('connection registry cache', () => {
})
describe('selectConnection', () => {
it('dials a secondary source and starts a fresh source-scoped draft', async () => {
it('activates the picked gateway in place and starts a fresh draft there, with no wipe', async () => {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
await selectConnection('homelab')
expect(openGatewayAgent).toHaveBeenCalledWith('homelab', 'default')
expect(ensureGatewayAgent).toHaveBeenCalledTimes(1)
expect(ensureGatewayAgent).toHaveBeenCalledWith('homelab', 'default', expect.anything())
expect(api).not.toHaveBeenCalled()
expect(beforeConnectionSwitch).toHaveBeenCalledTimes(1)
expect(requestFreshSession).toHaveBeenCalledTimes(1)
expect(wipeSessionListsForGatewaySwitch).toHaveBeenCalledTimes(1)
expect(exitProjectScope).toHaveBeenCalledTimes(1)
expect($newChatProfile.get()).toBe('default')
expect(refreshActiveProfile).toHaveBeenCalledTimes(1)
expect(setLastUsed).toHaveBeenCalledWith('homelab')
@@ -308,41 +279,6 @@ describe('selectConnection', () => {
expect(api).not.toHaveBeenCalled()
})
it('lets a later source choice win while an earlier dial is still pending', async () => {
let releaseDials!: () => void
const dialGate = new Promise<void>(resolve => {
releaseDials = resolve
})
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
openGatewayAgent.mockImplementation(async () => {
await dialGate
})
const openHomelab = selectConnection('homelab')
await Promise.resolve()
const stayLocal = selectConnection('local')
releaseDials()
await Promise.all([openHomelab, stayLocal])
expect(openGatewayAgent.mock.calls).toEqual([
['homelab', 'default'],
['local', 'default']
])
// The superseded dial never activates: the user doesn't flip through
// homelab on the way back to local, and only the winner commits.
expect(ensureGatewayAgent.mock.calls.map(call => [call[0], call[1]])).toEqual([['local', 'default']])
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
expect(wipeSessionListsForGatewaySwitch).toHaveBeenCalledTimes(1)
// Only the latest intent repaints the profile list.
expect(refreshActiveProfile).toHaveBeenCalledTimes(1)
expect($connection.get()?.connectionId).toBe('local')
expect($gatewaySwitching.get()).toBe(false)
})
it('lands on an explicit profile instead of the one last used on that source', async () => {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
@@ -394,7 +330,7 @@ describe('selectConnection', () => {
expect(ensureGatewayAgent).toHaveBeenCalledWith('local', 'default', expect.anything())
})
it('keeps the current source usable when a dial fails: nothing is severed before the target is reachable', async () => {
it('a gateway that is down fails at the open and touches nothing in front', async () => {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
$activeSessionId.set('a93bb39d')
@@ -402,14 +338,8 @@ describe('selectConnection', () => {
await expect(selectConnection('homelab')).rejects.toThrow('offline')
// The dial failed in phase 1 — the switch never committed, so the open
// transcript, its runtime binding and the session lists are all intact.
expect(ensureGatewayAgent).not.toHaveBeenCalled()
expect(beginGatewaySwitch).not.toHaveBeenCalled()
expect(beforeConnectionSwitch).not.toHaveBeenCalled()
expect(wipeSessionListsForGatewaySwitch).not.toHaveBeenCalled()
expect($activeSessionId.get()).toBe('a93bb39d')
expect($gatewaySwitching.get()).toBe(false)
expect(requestFreshSession).not.toHaveBeenCalled()
expect($newChatProfile.get()).toBeNull()
expect($pendingConnectionId.get()).toBeNull()
@@ -417,142 +347,50 @@ describe('selectConnection', () => {
expect($connection.get()?.connectionId).toBe('local')
})
it.each(['remote', 'cloud'] as const)(
'requires protected REST auth on a warm %s socket before discarding the current workspace',
async kind => {
const oauthRegistry: DesktopConnectionsRegistry = {
...registry,
connections: registry.connections.map(connection =>
connection.id === 'homelab' ? { ...connection, kind, authMode: 'oauth' } : connection
)
}
setConnectionsRegistry(oauthRegistry)
const source = { connectionId: 'work-vps', mode: 'remote' as const, profile: 'research', registryScoped: true }
$connection.set(source)
$activeGatewayProfile.set('research')
$activeSessionId.set('source-runtime')
$newChatProfile.set('research')
$showAllProfiles.set(true)
setApiRequestConnection('work-vps')
setApiRequestProfile('research')
setLastUsed.mockImplementationOnce(async id => ({ ok: true, registry: { ...oauthRegistry, lastUsed: id } }))
// A retained socket already passed its handshake; opening it succeeds
// even though fresh REST requests no longer authenticate. Connectivity
// errors must also preserve the workspace, without being called sign-in.
const expired = new Error(kind === 'remote' ? '401 Unauthorized: no_cookie' : '403 Forbidden: session expired')
const offline = new Error('net::ERR_CONNECTION_RESET')
api.mockRejectedValueOnce(expired).mockRejectedValueOnce(offline)
for (const error of [expired, offline]) {
await expect(selectConnection('homelab', { profile: 'scout' })).rejects.toBe(error)
expect($connection.get()).toBe(source)
expect($activeConnectionId.get()).toBe('work-vps')
expect($activeGatewayProfile.get()).toBe('research')
expect($activeSessionId.get()).toBe('source-runtime')
expect($newChatProfile.get()).toBe('research')
expect($showAllProfiles.get()).toBe(true)
expect($pendingConnectionId.get()).toBeNull()
expect($gatewaySwitching.get()).toBe(false)
expect(ensureGatewayAgent).not.toHaveBeenCalled()
expect(beginGatewaySwitch).not.toHaveBeenCalled()
expect(wipeSessionListsForGatewaySwitch).not.toHaveBeenCalled()
expect(requestFreshSession).not.toHaveBeenCalled()
expect(refreshActiveProfile).not.toHaveBeenCalled()
expect(setLastUsed).not.toHaveBeenCalled()
}
// Auth is refreshed externally; the very next click must work with the
// same module and warm socket, without a cached failed readiness result.
await selectConnection('homelab', { profile: 'scout' })
expect(api.mock.calls.map(call => (call as unknown[])[0])).toEqual(
Array.from({ length: 3 }, () =>
expect.objectContaining({ connectionId: 'homelab', profile: 'scout', path: '/api/profiles' })
)
)
expect(openGatewayAgent.mock.calls).toEqual(Array.from({ length: 3 }, () => ['homelab', 'scout']))
expect(ensureGatewayAgent).toHaveBeenCalledTimes(1)
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
expect(api.mock.invocationCallOrder[2]).toBeLessThan(beginGatewaySwitch.mock.invocationCallOrder[0])
expect($activeConnectionId.get()).toBe('homelab')
expect($newChatProfile.get()).toBe('scout')
expect($showAllProfiles.get()).toBe(false)
expect($activeSessionId.get()).toBeNull()
expect(setLastUsed).toHaveBeenCalledWith('homelab')
}
)
it('an activation that does not land after the wipe lowers the barrier and repaints the still-active source', async () => {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
// The dial opened the socket but the activation was declined (source
// edited/removed mid-switch): the commit hook ran (wipe done), yet
// $connection never moves to homelab.
ensureGatewayAgent.mockImplementationOnce(async (_connectionId, _profile, options) => {
options?.beforeActivate?.()
})
await expect(selectConnection('homelab')).rejects.toThrow('did not become active')
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
expect(endGatewaySwitch).toHaveBeenCalledTimes(1)
expect($gatewaySwitching.get()).toBe(false)
// The lists were wiped for a commit that never happened; the source that
// is still active gets repainted and the user lands on a fresh draft there.
expect(recoverActiveSourceAfterFailedGatewaySwitch).toHaveBeenCalledTimes(1)
expect(requestFreshSession).toHaveBeenCalledTimes(1)
expect(setLastUsed).not.toHaveBeenCalled()
expect($newChatProfile.get()).toBeNull()
expect($pendingConnectionId.get()).toBeNull()
expect($connection.get()?.connectionId).toBe('local')
})
it("#93937: severs the previous source's runtime session binding BEFORE the new source is published, behind the barrier", async () => {
it("#93937: the previous gateway's runtime is unbound BEFORE the picked gateway is published", async () => {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
// Runtime id minted by the local backend; only local has ever heard of it.
$activeSessionId.set('a93bb39d')
// Phase 1 (the dial) must not touch the current workspace at all.
openGatewayAgent.mockImplementationOnce(async () => {
expect($activeSessionId.get()).toBe('a93bb39d')
expect($gatewaySwitching.get()).toBe(false)
expect(wipeSessionListsForGatewaySwitch).not.toHaveBeenCalled()
})
// Every publication of the new source, with what a session-scoped effect
// would read at that instant.
const published: Array<{ activeSessionId: null | string; connectionId?: string; switching: boolean }> = []
const published: Array<{ activeSessionId: null | string; connectionId?: string; drafted: number }> = []
const off = $connection.listen(next => {
published.push({
activeSessionId: $activeSessionId.get(),
connectionId: next?.connectionId,
switching: $gatewaySwitching.get()
drafted: requestFreshSession.mock.calls.length
})
})
await selectConnection('homelab')
off()
// The old runtime id was already gone when homelab became visible, and
// the barrier was up — nothing could pair 'a93bb39d' with the new backend.
expect(published).toEqual([{ activeSessionId: null, connectionId: 'homelab', switching: true }])
// dial → commit (barrier + reset + wipe, inside the activation's commit
// hook) → publish, in that order.
expect(openGatewayAgent).toHaveBeenCalledWith('homelab', 'default')
expect(openGatewayAgent.mock.invocationCallOrder[0]).toBeLessThan(ensureGatewayAgent.mock.invocationCallOrder[0])
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
expect(beforeConnectionSwitch).toHaveBeenCalledTimes(1)
expect(endGatewaySwitch).toHaveBeenCalledTimes(1)
expect($gatewaySwitching.get()).toBe(false)
expect($activeSessionId.get()).toBeNull()
expect(published).toEqual([{ activeSessionId: null, connectionId: 'homelab', drafted: 1 }])
})
it('a click that supersedes a QUEUED commit wins: the superseded switch neither wipes nor activates', async () => {
// Both activations sit behind an in-flight profile/agent switch (the
// profile store's mutex); their commit hooks only run once it settles.
it('a failed activation leaves the gateway in front usable and points the draft back at it', async () => {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
// The pick reached its turn (draft rehomed), then the dial failed.
ensureGatewayAgent.mockImplementationOnce(async (_connectionId, _profile, options) => {
options?.beforeActivate?.()
throw new Error('offline')
})
await expect(selectConnection('homelab')).rejects.toThrow('offline')
expect($connection.get()?.connectionId).toBe('local')
expect($newChatProfile.get()).toBe('default')
expect(requestFreshSession).toHaveBeenCalledTimes(2)
expect(setLastUsed).not.toHaveBeenCalled()
expect($pendingConnectionId.get()).toBeNull()
})
it('a pick superseded while queued neither drafts nor activates', async () => {
// Both activations sit behind an in-flight profile/agent activation (the
// profile store's mutex); their beforeActivate hooks only run once it settles.
const mutex = deferred()
setConnectionsRegistry(registry)
@@ -581,71 +419,23 @@ describe('selectConnection', () => {
const second = selectConnection('work-vps')
await vi.waitFor(() => expect(ensureGatewayAgent).toHaveBeenCalledTimes(2))
// Nothing is severed while both are still queued.
expect(beginGatewaySwitch).not.toHaveBeenCalled()
// Nothing is touched while both are still queued.
expect($activeSessionId.get()).toBe('a93bb39d')
expect(requestFreshSession).not.toHaveBeenCalled()
mutex.resolve()
await Promise.all([first, second])
off()
// homelab stepped aside at its turn: no wipe, no publication, no error
// UI; work-vps wiped once and is the only source ever published.
expect(ensureGatewayAgent.mock.calls.map(call => call[0])).toEqual(['homelab', 'work-vps'])
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
// homelab stepped aside at its turn: no draft, no publication, no error.
expect(published).toEqual(['work-vps:active=null'])
expect($connection.get()?.connectionId).toBe('work-vps')
expect(recoverActiveSourceAfterFailedGatewaySwitch).not.toHaveBeenCalled()
expect(requestFreshSession).toHaveBeenCalledTimes(1)
expect(setLastUsed).toHaveBeenCalledTimes(1)
expect(setLastUsed).toHaveBeenCalledWith('work-vps')
expect($gatewaySwitching.get()).toBe(false)
expect($pendingConnectionId.get()).toBeNull()
})
it('does not spend the activation timeout while waiting for the serialized commit turn', async () => {
vi.useFakeTimers()
try {
const mutex = deferred()
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
ensureGatewayAgent.mockImplementationOnce(async (connectionId, _profile, options) => {
await mutex.promise
if (options?.beforeActivate && !options.beforeActivate()) {
return
}
$connection.set({
connectionId: connectionId ?? undefined,
mode: 'remote',
profile: 'default',
registryScoped: true
})
})
const attempt = selectConnection('homelab')
await vi.waitFor(() => expect(ensureGatewayAgent).toHaveBeenCalledTimes(1))
// Queue ownership belongs to the shared mutex, not the actual activation
// attempt, so waiting here must not consume its 20-second commit budget.
await vi.advanceTimersByTimeAsync(20_000)
expect(beginGatewaySwitch).not.toHaveBeenCalled()
mutex.resolve()
await attempt
expect($connection.get()?.connectionId).toBe('homelab')
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
expect($gatewaySwitching.get()).toBe(false)
} finally {
vi.useRealTimers()
}
})
it('a slow-but-healthy dial is not a failed switch: the source caps only past the ssh layer budget, and the click lands', async () => {
it('a slow-but-healthy cold dial is not a failed pick: it caps only past the ssh layer budget', async () => {
vi.useFakeTimers()
try {
@@ -653,7 +443,10 @@ describe('selectConnection', () => {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
openGatewayAgent.mockImplementationOnce(() => dial.promise)
ensureGatewayAgent.mockImplementationOnce(async (connectionId, profile) => {
await dial.promise
$connection.set({ connectionId: connectionId ?? undefined, mode: 'remote', profile, registryScoped: true })
})
let outcome = 'pending'
@@ -666,11 +459,8 @@ describe('selectConnection', () => {
}
)
// A cold remote dial is a chain of main-process stages (ssh connect, the
// platform/locate/version execs, the spawned backend's ready sentinel, the
// forward) that routinely outruns the 20 s reconnect-class budget: measured
// 37-64 s against a small VPS. The switch must keep waiting instead of
// toasting "Could not connect to <source>" for a source that is arriving.
// A cold remote dial (ssh connect, locate/version execs, the spawned
// backend's ready sentinel, the forward) routinely runs 37-64 s.
await vi.advanceTimersByTimeAsync(60_000)
expect(outcome).toBe('pending')
expect($pendingConnectionId.get()).toBe('homelab')
@@ -680,197 +470,12 @@ describe('selectConnection', () => {
expect(outcome).toBe('resolved')
expect($connection.get()?.connectionId).toBe('homelab')
expect($gatewaySwitching.get()).toBe(false)
} finally {
vi.useRealTimers()
}
})
it('a dial that never answers times out: nothing severed, the click fails visibly, and the source can be retried', async () => {
vi.useFakeTimers()
try {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
$activeSessionId.set('a93bb39d')
openGatewayAgent.mockImplementationOnce(() => new Promise<void>(() => undefined))
const outcome = selectConnection('homelab').then(
() => 'resolved',
(error: Error) => error.message
)
await vi.advanceTimersByTimeAsync(SOURCE_SWITCH_DIAL_TIMEOUT_MS)
expect(await outcome).toMatch(/Timed out connecting to "Homelab"/)
expect(ensureGatewayAgent).not.toHaveBeenCalled()
expect(beginGatewaySwitch).not.toHaveBeenCalled()
expect($activeSessionId.get()).toBe('a93bb39d')
expect($gatewaySwitching.get()).toBe(false)
expect($pendingConnectionId.get()).toBeNull()
// The stalled click does not poison the source: a retry is a real switch,
// not a duplicate of the pending one.
await selectConnection('homelab')
expect(ensureGatewayAgent).toHaveBeenCalledWith('homelab', 'default', expect.anything())
expect($connection.get()?.connectionId).toBe('homelab')
} finally {
vi.useRealTimers()
}
})
it('an activation that published the new source but never settled counts as committed', async () => {
vi.useFakeTimers()
try {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
// The socket activates and publishes synchronously; only the trailing
// descriptor resync (an IPC) stalls.
ensureGatewayAgent.mockImplementationOnce((connectionId, _profile, options) => {
options?.beforeActivate?.()
$connection.set({
connectionId: connectionId ?? undefined,
mode: 'remote',
profile: 'default',
registryScoped: true
})
return new Promise<void>(() => undefined)
})
const attempt = selectConnection('homelab')
await vi.advanceTimersByTimeAsync(20_000)
await attempt
expect($connection.get()?.connectionId).toBe('homelab')
expect($gatewaySwitching.get()).toBe(false)
expect(setLastUsed).toHaveBeenCalledWith('homelab')
expect(requestFreshSession).toHaveBeenCalledTimes(1)
expect(recoverActiveSourceAfterFailedGatewaySwitch).not.toHaveBeenCalled()
expect($pendingConnectionId.get()).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('a timed-out activation that already published cannot republish after a newer source wins', async () => {
vi.useFakeTimers()
try {
let releaseDescriptor: () => void = () => undefined
setConnectionsRegistry(registry)
// Seed A's remembered profile through the real source/profile observer,
// then restore the currently active local source.
$activeGatewayProfile.set('research')
$connection.set({ connectionId: 'homelab', mode: 'remote', profile: 'research', registryScoped: true })
$activeGatewayProfile.set('default')
$connection.set({ connectionId: 'local', mode: 'local', profile: 'default', registryScoped: true })
ensureGatewayAgent
.mockImplementationOnce((connectionId, profile, options) => {
options?.beforeActivate?.()
// Low-level activation publishes synchronously. The trailing descriptor
// promise remains alive beyond selectConnection's commit timeout.
$activeGatewayProfile.set(profile)
$connection.set({
connectionId: connectionId ?? undefined,
mode: 'remote',
profile,
registryScoped: true
})
return new Promise<void>(resolve => {
releaseDescriptor = () => {
// Mirrors ensureGatewayAgent's publication seam: a revoked owner
// observes its signal and must not publish its late descriptor.
if (!options?.signal?.aborted) {
$activeGatewayProfile.set(profile)
$connection.set({
connectionId: connectionId ?? undefined,
mode: 'remote',
profile,
registryScoped: true
})
}
resolve()
}
})
})
.mockImplementationOnce(async (connectionId, profile, options) => {
if (options?.beforeActivate && !options.beforeActivate()) {
return
}
$activeGatewayProfile.set(profile)
$connection.set({
connectionId: connectionId ?? undefined,
mode: 'remote',
profile,
registryScoped: true
})
})
const timedOutOwner = selectConnection('homelab')
await vi.advanceTimersByTimeAsync(20_000)
await timedOutOwner
// Fail open: A really did become active before its trailing descriptor
// work timed out, so the commit remains successful.
expect($activeGatewayProfile.get()).toBe('research')
expect($connection.get()?.connectionId).toBe('homelab')
await selectConnection('work-vps')
expect($activeGatewayProfile.get()).toBe('default')
expect($connection.get()?.connectionId).toBe('work-vps')
releaseDescriptor()
await vi.advanceTimersByTimeAsync(0)
expect($activeGatewayProfile.get()).toBe('default')
expect($connection.get()?.connectionId).toBe('work-vps')
} finally {
vi.useRealTimers()
}
})
it('an activation that stalls AFTER the wipe times out: barrier down, still-active source repainted', async () => {
vi.useFakeTimers()
try {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
ensureGatewayAgent.mockImplementationOnce((_connectionId, _profile, options) => {
options?.beforeActivate?.()
return new Promise<void>(() => undefined)
})
const outcome = selectConnection('homelab').then(
() => 'resolved',
(error: Error) => error.message
)
await vi.advanceTimersByTimeAsync(20_000)
expect(await outcome).toMatch(/Timed out activating "Homelab"/)
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
expect($gatewaySwitching.get()).toBe(false)
expect(recoverActiveSourceAfterFailedGatewaySwitch).toHaveBeenCalledTimes(1)
expect(requestFreshSession).toHaveBeenCalledTimes(1)
expect(setLastUsed).not.toHaveBeenCalled()
expect($connection.get()?.connectionId).toBe('local')
expect($pendingConnectionId.get()).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('a timed-out activation cannot publish the target after it eventually settles', async () => {
it('a dial that never answers times out visibly, cannot publish later, and the gateway can be retried', async () => {
vi.useFakeTimers()
try {
@@ -879,11 +484,9 @@ describe('selectConnection', () => {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
ensureGatewayAgent.mockImplementationOnce(async (connectionId, _profile, options) => {
options?.beforeActivate?.()
await activation.promise
// Mirrors the real activation door: cancellation ownership is checked
// immediately before publishing after the async activation work.
// Mirrors the real door: ownership is checked right before publishing.
if (options?.signal?.aborted) {
return
}
@@ -901,19 +504,48 @@ describe('selectConnection', () => {
(error: Error) => error.message
)
await vi.advanceTimersByTimeAsync(20_000)
await vi.advanceTimersByTimeAsync(SOURCE_SWITCH_DIAL_TIMEOUT_MS)
expect(await outcome).toMatch(/Timed out activating "Homelab"/)
expect($connection.get()?.connectionId).toBe('local')
expect($gatewaySwitching.get()).toBe(false)
expect(await outcome).toMatch(/Timed out connecting to "Homelab"/)
expect($pendingConnectionId.get()).toBeNull()
activation.resolve()
await vi.advanceTimersByTimeAsync(0)
expect($connection.get()?.connectionId).toBe('local')
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
expect(endGatewaySwitch).toHaveBeenCalledTimes(1)
expect($gatewaySwitching.get()).toBe(false)
await selectConnection('homelab')
expect($connection.get()?.connectionId).toBe('homelab')
} finally {
vi.useRealTimers()
}
})
it('an activation that published but never settled counts as landed', async () => {
vi.useFakeTimers()
try {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
// The socket activates and publishes; only a trailing descriptor resync stalls.
ensureGatewayAgent.mockImplementationOnce((connectionId, _profile, options) => {
options?.beforeActivate?.()
$connection.set({
connectionId: connectionId ?? undefined,
mode: 'remote',
profile: 'default',
registryScoped: true
})
return new Promise<void>(() => undefined)
})
const attempt = selectConnection('homelab')
await vi.advanceTimersByTimeAsync(SOURCE_SWITCH_DIAL_TIMEOUT_MS)
await attempt
expect($connection.get()?.connectionId).toBe('homelab')
expect(setLastUsed).toHaveBeenCalledWith('homelab')
expect(requestFreshSession).toHaveBeenCalledTimes(1)
expect($pendingConnectionId.get()).toBeNull()
} finally {
vi.useRealTimers()
@@ -930,13 +562,11 @@ describe('selectConnection', () => {
const restoring = initializeConnectionsRegistry()
await new Promise(resolve => setTimeout(resolve, 0))
expect(openGatewayAgent).not.toHaveBeenCalled()
expect(ensureGatewayAgent).not.toHaveBeenCalled()
$connection.set({ connectionId: 'homelab', mode: 'remote' })
await restoring
expect(openGatewayAgent).not.toHaveBeenCalled()
expect(ensureGatewayAgent).not.toHaveBeenCalled()
expect(setLastUsed).toHaveBeenCalledWith('homelab')
expect($showAllProfiles.get()).toBe(true)
@@ -1004,14 +634,13 @@ describe('selectConnection', () => {
// A later resync republishes a profile-less primary descriptor (the
// startHermes shape). The remembered pair is the authority for "what was
// last used here" — switching away and back must still restore 'mac',
// and the commit must not die in targetIsActive() on the descriptor gap.
// last used here" — picking away and back must still restore 'mac', and
// the pick must not die in targetIsActive() on the descriptor gap.
await selectConnection('homelab')
expect(ensureGatewayAgent).toHaveBeenLastCalledWith('homelab', 'default', expect.anything())
await selectConnection('local')
expect(openGatewayAgent).toHaveBeenLastCalledWith('local', 'mac')
expect(ensureGatewayAgent).toHaveBeenLastCalledWith('local', 'mac', expect.anything())
expect($newChatProfile.get()).toBe('mac')
})
@@ -1027,7 +656,6 @@ describe('selectConnection', () => {
await initializeConnectionsRegistry()
expect(ensureGatewayAgent).not.toHaveBeenCalled()
expect(wipeSessionListsForGatewaySwitch).not.toHaveBeenCalled()
expect($connection.get()?.mode).toBe('remote')
})
})
+100 -174
View File
@@ -1,6 +1,5 @@
import { atom, computed } from 'nanostores'
import { getProfiles } from '@/api/profiles'
import type { DesktopConnectionsRegistry } from '@/global'
import { traceIdentityChange } from '@/lib/identity-trace'
import { invalidateProfileScopedQueries } from '@/lib/query-client'
@@ -13,12 +12,7 @@ import {
} from '@/lib/with-timeout'
import { $connectionsRegistry } from '@/store/connection-registry-state'
import { $defaultProfileRoute, refreshDefaultProfile } from '@/store/default-profile'
import {
beginGatewaySwitch,
endGatewaySwitch,
type GatewaySwitchToken,
recoverActiveSourceAfterFailedGatewaySwitch
} from '@/store/gateway-switch'
import { supersedeGatewaySwitch } from '@/store/gateway-switch'
import {
$activeGatewayProfile,
$freshSessionRequest,
@@ -33,22 +27,23 @@ import {
refreshActiveProfile,
requestFreshSession
} from '@/store/profile'
import { $activeSessionId, $connection, $selectedStoredSessionId } from '@/store/session'
import { exitProjectScope } from '@/store/project-scope'
import {
$activeSessionId,
$connection,
$selectedStoredSessionId,
setActiveSessionId,
setSelectedStoredSessionId
} from '@/store/session'
import { isPeerInstanceWindow, windowProfileOverride } from '@/store/windows'
const LAST_PROFILE_STORAGE_KEY = 'hermes.desktop.lastProfileByConnection'
// Every await of a source switch is bounded. A wedged spawn, ticket mint,
// Every await of a gateway pick is bounded. A wedged spawn, ticket mint,
// handshake or IPC (the #93454 class) must surface as a failed click — not a
// spinner that also swallows every later click on the same source, and never
// a barrier left up or a wipe left unpainted.
//
// The dial and the commit are different work and so carry different budgets.
// The commit is local (sever the old bindings, activate, publish) and stays at
// the reconnect-class 20 s. The dial is the main process's whole remote
// bring-up chain, which the renderer cannot time from here — see
// spinner that also swallows every later click on the same gateway. A cold
// dial is the main process's whole remote bring-up chain; see
// SOURCE_SWITCH_DIAL_TIMEOUT_MS in lib/with-timeout.ts for its composition.
const SWITCH_COMMIT_TIMEOUT_MS = 20_000
const SWITCH_REMEMBER_TIMEOUT_MS = 5_000
// Matches the primary spawn budget: a healthy cold boot publishes well within
// this; anything longer means the primary is not coming and the registry
@@ -133,19 +128,19 @@ export function _resetConnectionsForTests(): void {
$pendingConnectionId.set(null)
}
// A connection switch is a new-chat intent on THAT source: keep the registry
// A gateway pick is a new-chat intent on THAT gateway: keep the registry
// identity with the profile so the next create names local::x / <source>::x
// exactly, never a bare profile string. An older explicit agent route must not
// override the selected source. The silent boot restore is not a user choice,
// so it keeps a route the user pinned (gateway-group +) while it was in flight.
function rehomeNewChatDraft(profile: string, keepExplicitRoute = false): void {
function rehomeNewChatDraft(profile: string, keepExplicitRoute = false, connectionId?: string): void {
$newChatProfile.set(profile)
if (!keepExplicitRoute) {
$newChatRoute.set(null)
}
captureNewChatSource()
captureNewChatSource(connectionId)
requestFreshSession()
}
@@ -330,29 +325,21 @@ export async function initializeConnectionsRegistry(): Promise<DesktopConnection
}
/**
* Re-home Sessions to one registered source, restoring that source's last
* profile. Only the selected source is dialed; merely rendering the switcher
* never probes or opens remote gateways.
* Re-scope Sessions to one registered gateway, landing on the profile the
* caller names or the one last used there.
*
* Two phases, same commit contract as a Settings → Gateway apply (softSwitch):
* 1. Dial the target — and for OAuth remotes prove a protected REST read —
* WITHOUT activating it. The previous source stays fully bound and
* painted, so a dead target loses nothing.
* 2. Commit: beginGatewaySwitch() — barrier up, machine-context reset,
* session bindings wiped — then activate the already-open socket. The
* wipe runs inside the activation's serialized section, synchronously
* before the publication, so no route/session effect can observe the new
* source while $activeSessionId still names the previous backend's
* runtime. Activating first and wiping after (across an IPC round-trip)
* is exactly how that id leaked to the new backend and came back as
* "session not found" (#93937).
* Every registered gateway is already connected (store/gateway-persistence),
* so this is the same live activation a profile pick or a cross-gateway resume
* makes: no session wipe, no barrier, no confirm. A gateway that is down fails
* at the open, before anything in front is touched. Sessions on the gateway left
* behind keep streaming on their own sockets, and the lists re-read for the
* new scope because the active connection id moved. Session RPCs route by
* their owner (connection, profile), never by the gateway in front, so nothing
* bound to the previous gateway has to be severed first.
*
* Overlap and stalls: clicks can supersede a switch at any point. A switch
* superseded while queued behind another activation declines its commit —
* no wipe, no activation — so the winner's wipe is always the one that
* precedes the final publication, and the barrier is owned by the latest
* switch. Every await is bounded; a commit that stalls after the wipe lowers
* the barrier and repaints the source that is still active.
* Overlap: a newer click supersedes an older one at any point. A superseded
* activation still queued behind another declines (beforeActivate), so the
* user never flips through it; one that times out loses its right to publish.
*/
export interface SelectConnectionOptions {
/** Land on this profile of the target source instead of the one last used
@@ -452,158 +439,97 @@ export async function selectConnection(connectionId: string, options: SelectConn
}
const revision = ++switchRevision
// A draft the user starts while this switch is in flight (a gateway group's
// "+", a profile pick) is newer than the switch; the late re-home must not
// take it over. switchRevision only sees other switches.
// A draft the user starts while this pick is in flight (a gateway group's
// "+", a profile pick) is newer than it; the late re-home must not take it over.
const draftIntent = currentNewChatIntent()
const activation = new AbortController()
let rehomed = false
pendingTarget = targetKey
$pendingConnectionId.set(connectionId)
// Set by the commit hook once THIS switch has wiped — i.e. it owns the
// barrier and, if the commit then fails, owes the still-active source a
// repaint. Null while queued, or if it stepped aside before its turn.
let token = null as GatewaySwitchToken | null
// Runs inside the activation's serialized section, synchronously right
// before the target is published, so the center is already a fresh draft on
// the target when route effects first see it (#93937). A superseded pick
// declines here: no draft, no activation, so the user never flips through it.
const beforeActivate = () => {
if (revision !== switchRevision) {
return false
}
supersedeGatewaySwitch()
if (!restoreOnBoot) {
$showAllProfiles.set(false)
}
if (currentNewChatIntent() === draftIntent) {
// The open runtime belongs to the gateway being left; unbind it now, not
// when the fresh draft renders, so nothing pairs it with the target.
setActiveSessionId(null)
setSelectedStoredSessionId(null)
rehomeNewChatDraft(targetProfile, restoreOnBoot, connectionId)
rehomed = true
}
return true
}
try {
// Phase 1 — open the target's socket; the active route is untouched.
// Always use the explicit registry route. `local` must mean This device,
// and a registry primary can differ from a legacy per-profile override.
await withTimeout(
openGatewayAgent(connectionId, targetProfile),
SOURCE_SWITCH_DIAL_TIMEOUT_MS,
`Timed out connecting to "${targetConnection.label}".`
)
const timeoutMessage = `Timed out connecting to "${targetConnection.label}".`
// Open the target WITHOUT activating it, so a dead gateway fails here and
// the one in front loses nothing — not even the draft. Every registered
// gateway is normally connected already, so this resolves at once.
await withTimeout(openGatewayAgent(connectionId, targetProfile), SOURCE_SWITCH_DIAL_TIMEOUT_MS, timeoutMessage)
// A newer click owns the switch from here on. The superseded dial never
// activates, so the user doesn't flip through it on the way to the source
// they picked last; its socket stays warm for that click or idles out.
if (revision !== switchRevision) {
return
}
if (
targetConnection.authMode === 'oauth' &&
(targetConnection.kind === 'remote' || targetConnection.kind === 'cloud')
) {
// Retained sockets can outlive cookie/native OAuth REST auth. Prove the
// cheapest protected read the target always serves before wiping. Keep
// the exact failure for caller UX (network failures are not sign-in errors).
await withTimeout(
getProfiles({ connectionId, profile: targetProfile }),
SOURCE_SWITCH_DIAL_TIMEOUT_MS,
`Timed out connecting to "${targetConnection.label}".`
)
if (revision !== switchRevision) {
return
}
}
// Phase 2 — commit. The hook runs inside the activation's serialized
// section, right before the socket is activated: sever the previous
// backend's bindings, then publish, with nothing in between. A click that
// superseded this switch while it was queued makes the hook decline —
// neither wipe nor activation — so the user never flips through it.
const activationController = new AbortController()
let markActivationStarted: () => void = () => undefined
const activationStarted = new Promise<void>(resolve => {
markActivationStarted = resolve
})
try {
try {
const activation = ensureGatewayAgent(connectionId, targetProfile, {
signal: activationController.signal,
beforeActivate: () => {
if (revision !== switchRevision) {
return false
}
token = beginGatewaySwitch()
markActivationStarted()
return true
}
})
const timedActivation = activationStarted.then(() =>
withTimeout(
activation,
SWITCH_COMMIT_TIMEOUT_MS,
`Timed out activating "${targetConnection.label}".`,
error => {
// withTimeout does not cancel its input. Every timed-out owner
// loses future activation/publication rights, even when low-level
// activation already published the target and the commit remains
// fail-open. The shared activation signal suppresses any trailing
// descriptor/profile publication when stale work later settles.
activationController.abort(error)
}
)
)
// Queue time belongs to the profile-store mutex. Start the bounded
// commit window only once beforeActivate grants this request its turn.
await Promise.race([activation, timedActivation])
} catch (error) {
// The socket is activated and its descriptor published synchronously;
// only best-effort descriptor resync trails it. A commit that timed out
// AFTER the new source became active has landed, so keep it fail-open;
// the timeout signal still revokes all trailing publication rights.
if (!isTimeoutError(error) || !targetIsActive()) {
throw error
}
}
if (revision !== switchRevision) {
return
}
if (!targetIsActive()) {
throw new Error(`Connection "${targetConnection.label}" did not become active.`)
}
} finally {
// Lower the barrier the moment the commit settles — before the
// bookkeeping awaits below — but only if this switch still owns it.
if (token !== null) {
endGatewaySwitch(token)
await withTimeout(
ensureGatewayAgent(connectionId, targetProfile, { beforeActivate, signal: activation.signal }),
SOURCE_SWITCH_DIAL_TIMEOUT_MS,
timeoutMessage,
error => activation.abort(error)
)
} catch (error) {
// A timeout after the target already became active has landed.
if (!isTimeoutError(error) || !targetIsActive()) {
throw error
}
}
// A newer click owns the final refresh. Serialized gateway activation
// already makes the latest source win; this guard also prevents an older
// request from repainting its profile list after that newer activation.
if (revision !== switchRevision) {
return
}
if (!targetIsActive()) {
throw new Error(`Connection "${targetConnection.label}" did not become active.`)
}
// Project ids name rows in one backend's projects.db.
if (currentConnectionId !== connectionId) {
exitProjectScope()
}
await rememberConnection(connectionId)
if (revision === switchRevision) {
await rememberConnection(connectionId)
// Remembering crosses IPC too; a newer click may now own the draft.
if (revision !== switchRevision) {
return
}
if (!restoreOnBoot) {
$showAllProfiles.set(false)
}
if (currentNewChatIntent() === draftIntent) {
rehomeNewChatDraft(targetProfile, restoreOnBoot)
}
await refreshActiveProfile()
}
} catch (error) {
if (revision === switchRevision) {
if (token !== null) {
// This switch wiped for a commit that never landed. The previous
// source is still the active one, and nothing reactive re-pulls its
// lists (no scope moved): repaint it and land on a fresh draft there,
// matching what a failed Settings apply leaves behind.
recoverActiveSourceAfterFailedGatewaySwitch(token)
requestFreshSession()
}
throw error
// A newer pick owns the outcome; this one's failure is moot.
if (revision !== switchRevision) {
return
}
// The target never became active: point the draft back at the gateway
// still in front so the next message cannot go to the one that failed.
if (rehomed) {
rehomeNewChatDraft(currentProfile)
}
throw error
} finally {
if (revision === switchRevision) {
pendingTarget = null
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { DesktopConnectionsRegistry } from '@/global'
const released: string[] = []
const retainGatewayForRelay = vi.fn((connectionId: string) => () => released.push(connectionId))
const openGatewayForAgent = vi.fn(async (_connectionId: string, _profile: string) => undefined)
const live = new Set<string>()
vi.mock('@/store/gateway', () => ({
liveSecondaryConnectionIds: () => new Set(live),
openGatewayForAgent,
retainGatewayForRelay
}))
const { $connectionsRegistry } = await import('@/store/connection-registry-state')
const { $primaryGatewayRoute } = await import('@/store/gateway-primary-route')
const { startPersistentGateways } = await import('./gateway-persistence')
const registry = (ids: string[]): DesktopConnectionsRegistry => ({
connections: ids.map(id => ({
id,
kind: id === 'local' ? 'local' : 'remote',
label: id,
tokenPreview: null,
tokenSet: false
})),
primary: 'local',
secureTokenStorage: true,
version: 2
})
let stop: () => void = () => undefined
beforeEach(() => {
vi.useFakeTimers()
released.length = 0
live.clear()
retainGatewayForRelay.mockClear()
openGatewayForAgent.mockClear()
$primaryGatewayRoute.set({ connectionId: 'local', mode: 'local' })
$connectionsRegistry.set(registry(['local', 'homelab', 'vps']))
})
afterEach(() => {
stop()
vi.useRealTimers()
})
it('keeps every registered gateway the primary socket does not serve connected, and lets a removed one go', () => {
stop = startPersistentGateways()
expect(retainGatewayForRelay.mock.calls.map(call => call[0])).toEqual(['homelab', 'vps'])
expect(openGatewayForAgent.mock.calls).toEqual([
['homelab', 'default'],
['vps', 'default']
])
// A dropped socket is redialed on the next tick; a live one is left alone.
live.add('homelab')
openGatewayForAgent.mockClear()
vi.advanceTimersByTime(30_000)
expect(openGatewayForAgent.mock.calls).toEqual([['vps', 'default']])
$connectionsRegistry.set(registry(['local', 'homelab']))
expect(released).toEqual(['vps'])
expect(retainGatewayForRelay).toHaveBeenCalledTimes(2)
})
it('pins This device on its own socket when a remote gateway is the primary', () => {
$primaryGatewayRoute.set({ connectionId: 'homelab', mode: 'remote' })
stop = startPersistentGateways()
expect(retainGatewayForRelay.mock.calls).toEqual([
['local', 'default', { includeLocal: true }],
['vps', 'default', { includeLocal: true }]
])
})
@@ -0,0 +1,107 @@
import { LOCAL_CONNECTION_ID } from '@hermes/shared'
import type { DesktopConnectionsRegistry, DesktopRegistryConnection } from '@/global'
import { $connectionsRegistry } from '@/store/connection-registry-state'
import { liveSecondaryConnectionIds, openGatewayForAgent, retainGatewayForRelay } from '@/store/gateway'
import { $primaryGatewayRoute, type PrimaryGatewayRoute } from '@/store/gateway-primary-route'
// Every registered gateway stays connected for as long as the window lives: its
// home socket is dialed at startup, pinned against the idle pruner, and redialed
// when it drops. Picking a gateway or opening one of its sessions then costs no
// dial, and the unified Sessions list (Electron splices every pooled backend's
// rows) shows every gateway at once instead of only the one in front.
const HOME_PROFILE = 'default'
const REDIAL_INTERVAL_MS = 30_000
/** The window's primary socket already holds this gateway open; a second socket
* to the same backend would duplicate its event stream. */
function servedByPrimary(connection: DesktopRegistryConnection, primary: PrimaryGatewayRoute): boolean {
if (primary.connectionId) {
return primary.connectionId === connection.id
}
return connection.id === LOCAL_CONNECTION_ID && primary.mode !== 'remote'
}
/** The registry connections that need their own pinned socket. */
export function persistentGatewayIds(
registry: DesktopConnectionsRegistry | null,
primary: PrimaryGatewayRoute
): string[] {
return (registry?.connections ?? []).filter(connection => !servedByPrimary(connection, primary)).map(c => c.id)
}
const connectedListeners = new Set<() => void>()
/** Fires each time a kept gateway (re)connects, so lists drawn before it was
* reachable re-pull its rows. Returns the unsubscribe. */
export function onKeptGatewayConnected(handler: () => void): () => void {
connectedListeners.add(handler)
return () => connectedListeners.delete(handler)
}
function dial(connectionId: string): void {
// Background priority: a gateway that is down or needs sign-in fails fast and
// stays throttled; the next tick (or a focus/online nudge) tries again.
void openGatewayForAgent(connectionId, HOME_PROFILE).then(
() => connectedListeners.forEach(handler => handler()),
() => undefined
)
}
/** Start keeping every registered gateway connected. Returns the teardown. */
export function startPersistentGateways(): () => void {
const pins = new Map<string, () => void>()
const reconcile = () => {
const wanted = new Set(persistentGatewayIds($connectionsRegistry.get(), $primaryGatewayRoute.get()))
for (const [connectionId, release] of pins) {
if (!wanted.has(connectionId)) {
pins.delete(connectionId)
release()
}
}
const live = liveSecondaryConnectionIds()
for (const connectionId of wanted) {
if (!pins.has(connectionId)) {
pins.set(connectionId, retainGatewayForRelay(connectionId, HOME_PROFILE, { includeLocal: true }))
}
if (!live.has(connectionId)) {
dial(connectionId)
}
}
}
const offRegistry = $connectionsRegistry.subscribe(reconcile)
const offPrimary = $primaryGatewayRoute.listen(reconcile)
const timer = setInterval(reconcile, REDIAL_INTERVAL_MS)
return () => {
clearInterval(timer)
offRegistry()
offPrimary()
for (const release of pins.values()) {
release()
}
pins.clear()
}
}
let stopKeeping: (() => void) | null = null
/** The window-lifetime keeper: starts once, later calls are no-ops. */
export function keepGatewaysConnected(): void {
stopKeeping ??= startPersistentGateways()
}
import.meta.hot?.dispose(() => {
stopKeeping?.()
stopKeeping = null
})
+21 -11
View File
@@ -39,9 +39,10 @@ import { clearAllSessionTodos } from '@/store/todos'
import { clearTranscriptTailPaging } from '@/store/transcript-tail'
import { clearTranscriptTails } from '@/store/transcript-tail-cache'
// True while a connection switch is mid-flight — a Settings → Gateway apply
// (wipe → re-dial, use-gateway-boot softSwitch) or a Sessions-switcher source
// change (store/connections selectConnection). Lets the boot hook suppress the
// True while a Settings → Gateway connection-mode apply is mid-flight (wipe →
// re-dial, use-gateway-boot softSwitch). Picking a registered gateway in
// Sessions is not a switch: every gateway stays connected and the pick is a
// live activation (store/connections selectConnection). Lets the boot hook suppress the
// backend-exit toast, keeps the cold-boot CONNECTING overlay from resurrecting
// when startHermes re-emits boot progress, and tells the resume path that a
// "session not found" mid-switch means "retry once things settle", not "gone".
@@ -84,14 +85,12 @@ export function registerGatewaySwitchLifecycle(lifecycle: GatewaySwitchLifecycle
}
/**
* Commit point of every connection switch: raise the barrier and sever every
* binding to the outgoing backend in ONE synchronous step. Both switch doors —
* Settings apply (softSwitch) and the Sessions switcher (selectConnection) —
* must call this BEFORE the next gateway is activated or its descriptor
* published. The sidebar door used to activate first and wipe afterwards
* (across an IPC round-trip), so route/session effects saw the new source while
* $activeSessionId still named the previous backend's runtime and sent that id
* to a backend that had never minted it — "session not found" (#93937).
* Commit point of a connection-mode apply: raise the barrier and sever every
* binding to the outgoing backend in ONE synchronous step, BEFORE the next
* gateway is activated or its descriptor published. Activating first and
* wiping after (across an IPC round-trip) let route/session effects see the new
* source while $activeSessionId still named the previous backend's runtime —
* "session not found" (#93937).
*/
export function beginGatewaySwitch(): GatewaySwitchToken {
const token = ++latestSwitchToken
@@ -128,6 +127,17 @@ export function beginGatewaySwitch(): GatewaySwitchToken {
}
}
/**
* A gateway pick made while a Settings apply is mid-flight wins: the apply's
* token stops owning any later publication, and its barrier comes down.
*/
export function supersedeGatewaySwitch(): void {
if ($gatewaySwitching.get()) {
latestSwitchToken += 1
endGatewaySwitch()
}
}
/**
* Lower the barrier once the switch that owns it has committed (or failed).
* Switches overlap — a click can supersede one that is mid-commit — and the
+9 -9
View File
@@ -1424,19 +1424,19 @@ function reopenAfterRedial(entry: Secondary, wasActive: boolean): void {
}
/**
* Pin the pooled socket for one relay route open across drain ticks. Returns
* a once-only release. Local routes (null/empty or explicit `local` source)
* are deliberately EXEMPT and get a no-op release: a relay pin would keep a
* local socket open past the point where retireLocalProfileGateways means to
* drop it (see that note). Local relay traffic is
* either the primary socket (no churn) or a short-lived local dial — never
* the remote reconnect flood this retention exists to stop.
* Pin one route's socket open across drain ticks (bot relay; each gateway's home socket, gateway-persistence).
* Returns a once-only release. Null, and `local` unless `includeLocal`, get a no-op: a relay pin would outlive
* retireLocalProfileGateways' drop, and local relay traffic never floods reconnects.
*/
export function retainGatewayForRelay(connectionId: null | string, profile: string): () => void {
export function retainGatewayForRelay(
connectionId: null | string,
profile: string,
{ includeLocal = false }: { includeLocal?: boolean } = {}
): () => void {
const key = normKey(profile)
const connection = String(connectionId ?? '').trim()
if (!connection || connection === 'local') {
if (!connection || (connection === 'local' && !includeLocal)) {
return () => undefined
}
-3
View File
@@ -2499,9 +2499,6 @@
"profiles.fleet.installDeviceTitle",
"profiles.fleet.localDevice",
"profiles.fleet.onGateway",
"profiles.fleet.switchDeviceConfirm",
"profiles.fleet.switchDeviceDesc",
"profiles.fleet.switchDeviceTitle",
"profiles.fleet.switchTo",
"profiles.importProfile",
"profiles.imported",
@@ -6,9 +6,10 @@ sidebar_position: 5
Register every Hermes backend you own — the local runtime, remote gateways on
your LAN or VPS, SSH hosts, and Hermes Cloud instances — in one desktop app,
and use the agents on all of them side by side. Connections are persistent:
each registered gateway dials its own backends and WebSockets on demand, and
background agents keep streaming while you look at another gateway.
and use the agents on all of them side by side. Every registered gateway is
connected all the time: Desktop dials each one when it starts, keeps its
connection open, and redials it if it drops, so there is no switching cost and
background agents keep streaming whichever gateway you are looking at.
This is the desktop-side complement to
[Running Many Gateways at Once](./multi-profile-gateways.md): that page is
@@ -76,10 +77,11 @@ Rules worth knowing:
the top of the Gateways page — the **Hermes Cloud** kind in the add-connection
editor points you there.
Switch gateways from the **Sessions** sidebar. Profiles, chats, messaging, and
cron stay scoped to that gateway; the app-managed window backend is still chosen
by the connection-mode controls above. **Primary** is the registry fallback and
does not switch the current workspace.
Every registered gateway stays connected, and the **Sessions** sidebar can
show all of them at once. Profiles, chats, messaging, and cron stay with their
own gateway; the app-managed window backend is still chosen by the
connection-mode controls above. **Primary** is the registry fallback and does
not change the current workspace.
## Organizing session groups
@@ -179,10 +181,10 @@ The union roster is what multi-gateway surfaces (and the built-in
as **`@name-device`** — `research` on your Homelab renders as
`@research-homelab`, while a profile unique across all gateways keeps its
bare name.
- Enumeration is eager but sockets are lazy: the app lists agents over REST
without dialing every gateway's WebSocket. An unreachable gateway reports
per-row instead of breaking the roster; SSH connections stay connect-on-demand
until you first open an agent on them (no surprise tunnels).
- Every registered gateway, SSH included, connects when Desktop starts and
stays connected: its default profile holds one open connection for the life
of the window and is redialed if it drops. An unreachable gateway reports
per-row instead of breaking the roster, and Desktop keeps retrying it.
- Opening an agent dials **its own gateway** — chats, sessions, and memory
live on the machine that owns the profile, exactly as if you were using
that instance directly.
@@ -204,7 +206,7 @@ missing. Saved session ownership still takes precedence, and deleting or
renaming that local profile clears this temporary route rather than reconnecting
an obsolete backend.
### Switching and scoping
### Gateways, profiles and scope
The sidebar foot follows one hierarchy: **gateway → profile → sessions**.
Gateways are machines or hosted backends; profiles are isolated Hermes agents
@@ -216,7 +218,9 @@ that live on one gateway.
cloud, network, or terminal icon identifies the connection type; profile
avatars remain a separate control after the divider. The same selector scales
from two gateways to a larger fleet without turning backends into profile-like
glyphs or crowding profile actions out of the rail.
glyphs or crowding profile actions out of the rail. Picking a gateway is
instant: it is already connected, nothing is wiped, there is no confirmation,
and sessions on the gateway you leave keep running.
- Selecting a gateway restores the last profile used there. The home pill
returns to its default profile and the layers pill shows **All profiles on
this gateway**. **Cmd/Ctrl+1–9** continue to switch profiles within the
@@ -224,19 +228,20 @@ that live on one gateway.
- With several gateways the profile rail is a **fleet rail**: every registered
gateway's profiles sit on the one strip, each group headed by that gateway's
kind glyph (device, network, terminal, cloud) — the same glyph the gateway
selector uses. The active gateway's squares look exactly as they do on a
single-gateway Desktop; the other gateways' squares are dimmed ("at rest").
Hovering an at-rest square names its machine (`omer · This device`), so two
same-named profiles on different machines never read alike.
- Clicking an at-rest square performs the same switch as the gateway selector,
landing on that exact `(gateway, profile)`: the square spins while the
target is dialed, the previous gateway stays painted until the target
answers, and a dead target fails the click with a message rather than
leaving the window half-switched. Groups keep registry order whichever
gateway is active, so a square never moves under the pointer that clicked
it. Right-click on an at-rest square offers **Switch to**, **Color**,
**Rename**, **Edit SOUL.md** and **Delete**, all executed on the square's
own gateway; the delete confirmation names the machine.
selector uses. Only the selected profile is highlighted; every other square,
on any gateway, has the same unselected look. Hovering another gateway's
square names its machine (`omer · This device`), so two same-named profiles
on different machines never read alike.
- Clicking another gateway's square is a profile pick on that gateway, landing
on that exact `(gateway, profile)` with a fresh draft there. A profile whose
backend is not open yet spins while it is dialed; a dead target fails the
click with a message and leaves everything you had in front untouched.
Groups keep registry order whichever gateway is active, so a square never
moves under the pointer that clicked it. Right-click on another gateway's
square offers **Open**, **Color**, **Rename**, **Edit SOUL.md** and
**Delete**, all executed on the square's own gateway; the delete
confirmation names the machine. Picking **This device** asks for
confirmation only when Hermes is not installed on this computer yet.
- A gateway the last enumeration could not reach keeps its squares, marked
with an amber dot on its glyph — a sleeping box is still yours. Two
registrations of one backend collapse to a single group. Past thirteen
@@ -261,11 +266,11 @@ that live on one gateway.
bar hidden, and keeps it current when gateways are saved or removed. Opening
Settings is not required. In Simple mode, the profile rail stays available
when more than one gateway is registered, even with only a default profile.
Loading the registry does not connect every gateway; there is no periodic
fleet polling.
- Hovering an agent pre-warms its backend so the switch doesn't pay a cold
boot. SSH agents are the exception: hovering never dials the tunnel or
starts a remote backend — only opening one does.
Every gateway in the registry is connected from startup; adding one connects
it, removing one disconnects it.
- Hovering a profile pre-warms its backend so opening it doesn't pay a cold
boot. On SSH gateways hovering never starts an extra profile backend on the
remote — only opening one does.
- The **Capabilities** page (Skills / Tools / MCP) has a matching scope: its
**Configuring** selector lists every `(profile, device)` agent from the
union roster, and picking one reads and writes **that machine's** skills,
@@ -281,13 +286,14 @@ home, not a second add flow.
### Sessions and Bot Mode
Sessions intentionally show one active gateway at a time: this keeps files,
tools, channels, cron, and session history in one understandable execution
context. The fleet profile rail widens only the *picker* — the workspace still
lives on exactly one `(gateway, profile)` after every click. Bot Mode serves a
different job and may present the union roster, grouped by gateway, so a user
can open one agent on a NAS and another on a VPS from one surface. Opening a
bot still activates its exact `(gateway, profile)` route.
With **All profiles** selected, the Sessions list shows the sessions of every
registered gateway at once; group it by **Gateway & profile** to give each
gateway its own section. Opening any of those sessions talks to its own
gateway directly — there is no gateway to switch to first. A new chat still
runs on exactly one `(gateway, profile)`: the one you picked, so files, tools,
channels and cron for that chat stay in one execution context. Bot Mode
presents the same union roster, grouped by gateway, so you can open one agent
on a NAS and another on a VPS from one surface.
Direct bot mentions and delegation remain gateway-local by default. Crossing a
backend boundary changes filesystem, credentials, tools, and trust context, so
@@ -366,8 +372,8 @@ multi-gateway roster is the reference consumer.
WebSocket leg failing while HTTP passes usually means a proxy, firewall, or
gateway auth/origin guard is blocking `/api/ws`.
- **A remote gateway is missing from the roster** — its backend is down or
unreachable; the roster lists it under gateways with the error. SSH connections
show *connect-on-demand* until first use — that's by design, not a failure.
unreachable; the roster lists it under gateways with the error, and Desktop
keeps redialing it in the background.
- **"Update Hermes Desktop to chat with agents on other connections"** — the
app predates the multi-connection stack; update the desktop app itself.
- **Duplicate device names** — not possible; names are enforced unique at