Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8020,7 +8020,9 @@ async function ensureBackend(profile) {

// A shared backend still owes the caller its profile scope, so renderer-side
// WebSocket, filesystem, and cache routing target the selected profile.
return route.descriptorProfile ? { ...connection, profile: route.descriptorProfile } : connection
return route.descriptorProfile
? { ...connection, profile: route.descriptorProfile, sharedPrimary: true }
: connection
}

const existing = backendPool.get(key)
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/app/contrib/wiring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,11 @@ export function ContribWiring({ children }: { children: ReactNode }) {
void refreshCurrentModel(true)
void refreshHermesConfig(true)
void refreshActiveProfile()
}, [activeGatewayProfile, refreshCurrentModel, refreshHermesConfig])
// Session rows are profile-scoped; a soft rail switch does not reopen the
// gateway, so the on-connect refresh never reruns — repull now.
void refreshSessions()
void refreshCronJobs()
}, [activeGatewayProfile, refreshCronJobs, refreshCurrentModel, refreshHermesConfig, refreshSessions])

// New session anchored to a workspace. Seeds cwd + branch from the clicked
// workspace; an explicit worktree path also drills the sidebar into that
Expand Down
46 changes: 32 additions & 14 deletions apps/desktop/src/store/gateway-shared-remote.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

// The global-remote share (backend routing case 3): every profile is served
// by the PRIMARY backend over one host, and getConnection() tags the shared
// descriptor with `profile`. Dialing a second WebSocket at that descriptor
// used to fail over SSH (per-backend tunnel/ticket) and poison the active
// gateway with a closed socket — "Hermes gateway is not connected" for every
// profile except the primary. These tests pin the fix: a profile routed to
// the shared primary activates the primary socket instead of dialing.
// by the PRIMARY backend over one host, and ensureBackend tags that
// descriptor with `sharedPrimary: true`. Dialing a second WebSocket at that
// descriptor used to fail over SSH (per-backend tunnel/ticket) and poison the
// active gateway with a closed socket — "Hermes gateway is not connected" for
// every profile except the primary. Pooled backends (local `--profile serve`
// or per-profile remote overrides) also carry a `profile` name but must dial
// their own socket — checking `.profile` alone misclassified them (#85745).

vi.mock('@/hermes', () => ({
HermesGateway: class {
Expand Down Expand Up @@ -47,32 +48,49 @@ afterEach(() => {
})

describe('ensureGatewayForProfile under a shared global remote', () => {
it('activates the primary socket for a profile tagged onto the shared descriptor', async () => {
it('activates the primary socket for a shared-primary descriptor', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
installDesktop({
// Shared descriptor: primary connection tagged with the profile.
getConnection: vi.fn(async () => ({ port: 4242, profile: 'venture', token: 't' }))
getConnection: vi.fn(async () => ({
port: 4242,
profile: 'venture',
sharedPrimary: true,
token: 't'
}))
})

await ensureGatewayForProfile('venture')

expect($gateway.get()).toBe(primary)
})

it('still pools a socket for profiles with their own descriptor (untagged)', async () => {
it('pools a socket for a local profile descriptor tagged with profile but not sharedPrimary', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
installDesktop({
getConnection: vi.fn(async () => ({
port: 5151,
profile: 'bubu',
token: 't2',
wsUrl: 'ws://127.0.0.1:5151/api/ws?token=t2'
}))
})

await ensureGatewayForProfile('bubu')

expect($gateway.get()).not.toBe(primary)
})

it('still pools a socket for profiles with their own untagged descriptor', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
installDesktop({
// Own descriptor: no profile tag → normal pooled path (dial attempted).
getConnection: vi.fn(async () => ({ port: 5151, token: 't2' }))
})

await ensureGatewayForProfile('worker')

// The pooled path dialed (our stub throws, so the socket stays closed and
// reconnect is scheduled) — the important part is it did NOT silently
// reuse the primary.
expect($gateway.get()).not.toBe(primary)
})
})
15 changes: 8 additions & 7 deletions apps/desktop/src/store/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,12 +248,11 @@ function createSecondary(profile: string): Secondary {
}

// True when `profile`'s backend route resolves to the SHARED primary backend
// (global-remote case 3 in resolveProfileBackendRoute): the descriptor comes
// back as the primary connection tagged with `profile`. Own-remote-override
// and local pooled descriptors are never tagged. Dialing a second socket at
// that descriptor is wrong — over SSH the second dial fails (tunnel/token are
// per-backend) and the closed socket poisons the active gateway with
// "not connected" even though the primary is open right next to it.
// (global-remote case 3 in resolveProfileBackendRoute): ensureBackend tags the
// primary descriptor with `sharedPrimary: true`. Pooled backends (local serve
// or per-profile remote override) also carry a `profile` name for REST routing
// but must still dial their own socket — checking `.profile` alone misclassified
// them and left profile tab switches stuck on the primary gateway (#85745).
async function sharedPrimaryRoute(profile: string): Promise<boolean> {
const desktop = window.hermesDesktop

Expand All @@ -264,7 +263,9 @@ async function sharedPrimaryRoute(profile: string): Promise<boolean> {
try {
const conn = await desktop.getConnection(profile)

return Boolean(conn && typeof conn === 'object' && (conn as { profile?: string }).profile)
return Boolean(
conn && typeof conn === 'object' && (conn as { sharedPrimary?: boolean }).sharedPrimary === true
)
} catch {
return false
}
Expand Down
Loading