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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect } from 'react'

import { getLatestSessionMessages, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
import { toChatMessages } from '@/lib/chat-messages'
import { gatewayRpcProfile } from '@/store/gateway'
import { publishSessionState, setSessionTileDelegate } from '@/store/session-states'
import type { SessionResumeResponse } from '@/types/hermes'

Expand Down Expand Up @@ -106,16 +107,19 @@ export function useSessionTileDelegate({
// session from any profile, not just the active one; resuming (or
// reading messages) without a profile lets the gateway fall back to the
// launch-profile DB and fork the conversation into the wrong profile —
// the same cross-profile bleed the recovery resumes had (#67603).
// the same cross-profile bleed the recovery resumes had (#67603). REST
// keeps the Desktop owner; the gateway helper translates that owner into
// backend-internal scope for the WebSocket RPC.
const profile = await resolveSessionProfile(storedSessionId)
const resumeProfile = await gatewayRpcProfile(profile)

const [prefetch, resumed] = await Promise.all([
getLatestSessionMessages(storedSessionId, profile).catch(() => null),
requestGateway<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
cols: 96,
omit_messages: true,
...(profile ? { profile } : {})
...(resumeProfile ? { profile: resumeProfile } : {})
})
])

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { gatewayRpcProfile } from '@/store/gateway'

import { resolveSessionProfile } from '../use-session-actions/utils'

import type { GatewayRequest } from './utils'
Expand Down Expand Up @@ -90,11 +92,12 @@ export async function resolveTargetSessionId(deps: ResolveTargetSessionDeps): Pr
if (storedTarget) {
try {
const profile = await resolveSessionProfile(storedTarget)
const resumeProfile = await gatewayRpcProfile(profile)

const resumed = await requestGateway<{ session_id?: string }>('session.resume', {
session_id: storedTarget,
source: 'desktop',
...(profile ? { profile } : {})
...(resumeProfile ? { profile: resumeProfile } : {})
})

return resumed?.session_id || null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type ComposerAttachment,
terminalContextBlocksFromDraft
} from '@/store/composer'
import { gatewayRpcProfile } from '@/store/gateway'
import { $hudMode } from '@/store/hud'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
Expand Down Expand Up @@ -479,15 +480,17 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
// background queue drain only has the durable id). Continue that target
// conversation; only a genuine new-chat draft may create a new session.
try {
// Re-register on the session's OWNING profile — resuming on whichever
// profile is live would fork the conversation into the wrong DB (#67603).
// Resolve the session's Desktop owner first; the gateway helper keeps
// that scope only for a shared backend and omits aliases for dedicated
// per-profile backends (#67603).
const resumeProfile = await resolveSessionProfile(targetStoredSessionId)
const gatewayProfile = await gatewayRpcProfile(resumeProfile)

const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: targetStoredSessionId,
source: 'desktop',
omit_messages: true,
...(resumeProfile ? { profile: resumeProfile } : {})
...(gatewayProfile ? { profile: gatewayProfile } : {})
})

const resumeDrift = sessionDriftReason()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export interface SessionRecoveryDeps {
* store and the REST layer: the default implementation reaches through
* `resolveStoredSession` → `getSession()`, a real fetch that makes any unit
* test of this helper depend on leftover `$sessions` / `$profiles` state.
* The returned value is backend RPC scope, not necessarily the Desktop alias.
*/
resolveProfile?: (storedSessionId: string) => Promise<string | undefined>
/**
Expand All @@ -100,8 +101,9 @@ export interface SessionRecoveryDeps {
async function defaultResolveProfile(storedSessionId: string): Promise<string | undefined> {
// Lazy so utils.ts has no init-time cycle with use-session-actions.
const { resolveSessionProfile } = await import('../use-session-actions/utils')
const { gatewayRpcProfile } = await import('@/store/gateway')

return resolveSessionProfile(storedSessionId)
return gatewayRpcProfile(await resolveSessionProfile(storedSessionId))
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { recoverInFlightTurnJournal } from '@/lib/inflight-turn-journal'
import { setSessionYolo } from '@/lib/yolo-session'
import { migrateSessionDraft } from '@/store/composer'
import { clearQueuedPrompts, migrateQueuedPrompts } from '@/store/composer-queue'
import { gatewayRpcProfile } from '@/store/gateway'
import { $pinnedSessionIds } from '@/store/layout'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
Expand Down Expand Up @@ -655,6 +656,7 @@ export function useSessionActions({
// id loads as fast as a sidebar click instead of hanging on a list scan.
const storedForProfile = await resolveStoredSession(storedSessionId)
const sessionProfile = storedForProfile?.profile
const resumeProfile = await gatewayRpcProfile(sessionProfile)

if (resumeRequestRef.current !== requestId) {
return
Expand Down Expand Up @@ -922,7 +924,7 @@ export function useSessionActions({
// (MCP discovery / prompt build), and the agent pre-warms in the
// background while the prefetch above paints the transcript.
...(watchWindow ? { lazy: true } : { omit_messages: true }),
...(sessionProfile ? { profile: sessionProfile } : {})
...(resumeProfile ? { profile: resumeProfile } : {})
})

// The rejection is consumed by the `await` below; this guard only
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,9 @@ export interface HermesConnection {
// Set for pool (non-primary) backends so the renderer knows which profile a
// connection belongs to.
profile?: string
// True only when a profile is scoped inside the window's shared primary
// backend. Profile-owned pool descriptors remain unmarked.
sharedPrimary?: boolean
windowButtonPosition: { x: number; y: number } | null
}

Expand Down
34 changes: 28 additions & 6 deletions apps/desktop/src/store/gateway-shared-remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ vi.mock('@/hermes', () => ({
vi.mock('@/store/session', () => ({ setGatewayState: vi.fn() }))
vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() }))

const { $gateway, configureGatewayRegistry, ensureGatewayForProfile, setPrimaryGateway } = await import('./gateway')
const { $gateway, configureGatewayRegistry, ensureGatewayForProfile, gatewayRpcProfile, setPrimaryGateway } =
await import('./gateway')

type DesktopStub = { getConnection: ReturnType<typeof vi.fn> }

Expand Down Expand Up @@ -51,21 +52,21 @@ describe('ensureGatewayForProfile under a shared global remote', () => {
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' }))
// Shared descriptor: primary connection explicitly tagged as shared.
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('still pools a socket for profile-owned descriptors that carry a profile', 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' }))
// Pool descriptors identify their Desktop owner but are not shared-primary.
getConnection: vi.fn(async () => ({ port: 5151, profile: 'worker', token: 't2' }))
})

await ensureGatewayForProfile('worker')
Expand All @@ -76,3 +77,24 @@ describe('ensureGatewayForProfile under a shared global remote', () => {
expect($gateway.get()).not.toBe(primary)
})
})

describe('gatewayRpcProfile', () => {
it('keeps the profile tag for a shared global-remote descriptor', async () => {
installDesktop({
getConnection: vi.fn(async () => ({ port: 4242, profile: 'venture', sharedPrimary: true, token: 't' }))
})

await expect(gatewayRpcProfile('venture')).resolves.toBe('venture')
})

it('omits a Desktop alias when its backend descriptor is already dedicated', async () => {
installDesktop({
// Per-profile URL override or local pooled backend: the descriptor itself
// is already scoped, so forwarding `profile: worker` would address a
// nonexistent profile inside that backend.
getConnection: vi.fn(async () => ({ port: 5151, profile: 'worker', token: 't2' }))
})

await expect(gatewayRpcProfile('worker')).resolves.toBeUndefined()
})
})
40 changes: 37 additions & 3 deletions apps/desktop/src/store/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,8 @@ 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
// back as the primary connection tagged with `sharedPrimary`. Own-remote-override
// and local pooled descriptors are never marked. 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.
Expand All @@ -264,12 +264,46 @@ 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
}
}

/**
* Profile scope to send inside a gateway RPC for a Desktop-owned profile.
*
* A `sharedPrimary` descriptor is the shared global-remote route: one backend
* serves several profiles, so the RPC must carry the descriptor's profile.
* Unmarked descriptors are already dedicated (local pool or per-profile URL override),
* and forwarding the Desktop alias would incorrectly address a profile inside
* that backend. On descriptor lookup failure, retain the caller's scope rather
* than risking a cross-profile write on a shared backend.
*/
export async function gatewayRpcProfile(profile: null | string | undefined): Promise<string | undefined> {
const key = normKey(profile)
const desktop = window.hermesDesktop

if (!profile?.trim() || !desktop) {
return profile?.trim() || undefined
}

try {
const conn = await desktop.getConnection(key)

const descriptorProfile =
conn && typeof conn === 'object' && (conn as { sharedPrimary?: boolean }).sharedPrimary === true
? String((conn as { profile?: string }).profile ?? '').trim()
: ''

return descriptorProfile || undefined
} catch {
const fallbackProfile = key

return fallbackProfile
}
}

// Open `profile`'s socket WITHOUT making it active — the hover-intent pre-warm
// (store/profile). Runs the same spawn + connect chain as a real switch, so by
// click time ensureGatewayForProfile finds an open socket and just activates
Expand Down
Loading