fix(desktop): session RPCs route by the owning connection, not a bare profile name
Two connections both exposing `default` (this device + a remote gateway) collapse onto the PRIMARY connection whenever a session-scoped RPC's owner resolves to a bare profile name: the profile door (requestForSessionProfile -> requestGatewayForProfile -> gatewayForProfile) short-circuits any key equal to the primary profile onto the primary socket, which for an ordinary session on the non-primary connection is another machine that answers 4001 session not found from the second prompt on. An owner an inbound runtime event already proved (the exact connectionId + profile of the socket that delivered the session's events) is a more specific fact than a connection-blind row profile, so it now outranks it in both owner ladders (resolveSessionRpcOwner and knownOwnerForSession). Durable EXACT routes — tile, hint, connection-tagged row — still win, and a session with no proven connection keeps the previous bare-profile answer, so single-source and legacy topologies are unchanged.
This commit is contained in:
@@ -44,7 +44,7 @@ const { _resetSessionOwnerHintsForTests, setCronSessions, setMessagingSessions,
|
||||
await import('@/store/session')
|
||||
|
||||
const { isSessionOwnerResolutionError } = await import('@/store/session-owner-resolution')
|
||||
const { $sessionTiles } = await import('@/store/session-states')
|
||||
const { $sessionTiles, recordSessionEventScope } = await import('@/store/session-states')
|
||||
const { makeSessionInfo } = await import('@/test/session-info')
|
||||
|
||||
function dispatcher(
|
||||
@@ -208,6 +208,88 @@ describe('createSessionRpcDispatcher: exact owner rungs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSessionRpcDispatcher: routes by the session OWNING connection when two connections share a profile name', () => {
|
||||
// Two registered connections, both exposing `default` (the default install:
|
||||
// this device + a remote gateway), and the window's primary is the OTHER
|
||||
// connection. A bare profile name carries no connection identity, and the
|
||||
// profile door (requestGatewayForProfile -> gatewayForProfile) resolves a
|
||||
// bare name equal to the primary profile against the PRIMARY socket — a
|
||||
// different machine than the one holding the session. The inbound event
|
||||
// already proved which socket owns the runtime, so that exact owner has to
|
||||
// outrank the connection-blind row profile: otherwise the 2nd prompt of a
|
||||
// `This device` chat is answered by the remote backend with
|
||||
// `4001 session not found`.
|
||||
function twoConnections(): void {
|
||||
$connectionsRegistry.set({ connections: [{ id: 'local' }, { id: 'homelab' }] } as never)
|
||||
}
|
||||
|
||||
it('routes a session that lives on the LOCAL connection back to that connection', async () => {
|
||||
gatewayMocks.activeConnectionId = 'homelab'
|
||||
twoConnections()
|
||||
recordSessionEventScope({ connectionId: 'local', profile: 'default', session_id: 'rt-local' })
|
||||
setSessions([makeSessionInfo({ id: 'rt-local', profile: 'default' })])
|
||||
const { ambientRequest, request } = dispatcher()
|
||||
|
||||
await expect(request('prompt.submit', { session_id: 'rt-local', text: 'again' })).resolves.toEqual({
|
||||
routed: true
|
||||
})
|
||||
|
||||
expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledWith('local', 'default', 'prompt.submit', {
|
||||
session_id: 'rt-local',
|
||||
text: 'again'
|
||||
})
|
||||
expect(gatewayMocks.requestGatewayForProfile).not.toHaveBeenCalled()
|
||||
expect(ambientRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes a session that lives on the REMOTE connection to that connection', async () => {
|
||||
gatewayMocks.activeConnectionId = 'local'
|
||||
twoConnections()
|
||||
recordSessionEventScope({ connectionId: 'homelab', profile: 'default', session_id: 'rt-remote' })
|
||||
setSessions([makeSessionInfo({ id: 'rt-remote', profile: 'default' })])
|
||||
const { ambientRequest, request } = dispatcher()
|
||||
|
||||
await expect(request('prompt.submit', { session_id: 'rt-remote', text: 'again' })).resolves.toEqual({
|
||||
routed: true
|
||||
})
|
||||
|
||||
expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledWith('homelab', 'default', 'prompt.submit', {
|
||||
session_id: 'rt-remote',
|
||||
text: 'again'
|
||||
})
|
||||
expect(gatewayMocks.requestGatewayForProfile).not.toHaveBeenCalled()
|
||||
expect(ambientRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a connection-tagged row owner over a conflicting event scope', async () => {
|
||||
gatewayMocks.activeConnectionId = 'homelab'
|
||||
twoConnections()
|
||||
setSessions([makeSessionInfo({ connection_id: 'source-b', id: 'stored-shared', profile: 'default' })])
|
||||
recordSessionEventScope({ connectionId: 'local', profile: 'default', session_id: 'stored-shared' })
|
||||
|
||||
await expect(dispatcher().request('session.resume', { session_id: 'stored-shared' })).resolves.toEqual({
|
||||
routed: true
|
||||
})
|
||||
|
||||
expect(gatewayMocks.requestGatewayForAgent).toHaveBeenLastCalledWith('source-b', 'default', 'session.resume', {
|
||||
session_id: 'stored-shared'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a request with NO session context on the ambient socket, ledger or not', async () => {
|
||||
gatewayMocks.activeConnectionId = 'homelab'
|
||||
twoConnections()
|
||||
recordSessionEventScope({ connectionId: 'local', profile: 'default', session_id: 'rt-local' })
|
||||
const { ambientRequest, request } = dispatcher()
|
||||
|
||||
await expect(request('config.get', {})).resolves.toEqual({ ambient: true })
|
||||
|
||||
expect(ambientRequest).toHaveBeenCalledWith('config.get', {})
|
||||
expect(gatewayMocks.requestGatewayForAgent).not.toHaveBeenCalled()
|
||||
expect(gatewayMocks.requestGatewayForProfile).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSessionRpcDispatcher: stale runtime recovery', () => {
|
||||
it('requests a durable rebind for the visible session after a structured 4001', async () => {
|
||||
setSessions([makeSessionInfo({ connection_id: 'local', id: 'stored-omar', profile: 'omar' })])
|
||||
|
||||
@@ -39,7 +39,12 @@ import { isSessionGoneForBackgroundPolling } from '@/store/runtime-gone'
|
||||
import { getSessionOwnerHint, knownSessionOwner, ownerLookupSessionRows, requestSessionResume } from '@/store/session'
|
||||
import { assertSessionOwnerResolved } from '@/store/session-owner-resolution'
|
||||
import { requestForSessionProfile, type SessionOwnerScope } from '@/store/session-request-router'
|
||||
import { $focusedStoredSessionId, sessionTileOwnerRoute, storedSessionIdForRuntimeId } from '@/store/session-states'
|
||||
import {
|
||||
$focusedStoredSessionId,
|
||||
runtimeSessionOwner,
|
||||
sessionTileOwnerRoute,
|
||||
storedSessionIdForRuntimeId
|
||||
} from '@/store/session-states'
|
||||
|
||||
import { findStoredIdForRuntimeId, resolveRoutingSessionId, resolveSessionRpcOwner } from './wiring-routing'
|
||||
|
||||
@@ -75,6 +80,14 @@ export function createSessionRpcDispatcher(deps: SessionRpcDispatcherDeps): Ambi
|
||||
})
|
||||
|
||||
let owner: SessionOwnerScope = resolveSessionRpcOwner({
|
||||
// An owner an inbound runtime event already proved for THIS session (#97511):
|
||||
// the exact (connectionId, profile) of the socket that delivered its
|
||||
// events. It outranks the connection-blind row/rung profile — the rung
|
||||
// that makes two connections sharing a profile name collapse onto the
|
||||
// primary socket (another machine) instead of the session's own.
|
||||
eventOwner: storedSessionId =>
|
||||
runtimeSessionOwner(storedSessionId) ??
|
||||
(paramSessionId && paramSessionId !== storedSessionId ? runtimeSessionOwner(paramSessionId) : undefined),
|
||||
routingSessionId,
|
||||
sessionOwnerHint: storedSessionId => getSessionOwnerHint(storedSessionId),
|
||||
sessionRowOwner: storedSessionId => knownSessionOwner(ownerLookupSessionRows(), storedSessionId),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* React/Electron controller module.
|
||||
*/
|
||||
|
||||
import type { SessionOwnerRoute } from '@/store/session-request-router'
|
||||
import type { SessionOwnerRoute, SessionOwnerScope } from '@/store/session-request-router'
|
||||
|
||||
import { SETTINGS_ROUTE } from '../routes'
|
||||
|
||||
@@ -61,6 +61,11 @@ export function resolveRoutingSessionId(args: {
|
||||
* "active"). The type-only import keeps this module runtime-import-free. */
|
||||
export type SessionRpcOwnerRoute = SessionOwnerRoute
|
||||
|
||||
/** A connection-bearing owner. Checked structurally so this module stays
|
||||
* runtime-import-free (the type guard lives in store/session-request-router). */
|
||||
const isRouteOwner = (owner: unknown): owner is SessionRpcOwnerRoute =>
|
||||
Boolean(owner && typeof owner === 'object' && 'connectionId' in owner)
|
||||
|
||||
/**
|
||||
* The SYNC owner a session-scoped RPC routes to, resolved in this order:
|
||||
*
|
||||
@@ -73,9 +78,21 @@ export type SessionRpcOwnerRoute = SessionOwnerRoute
|
||||
* list splice, or a tag carried across a refresh), else its bare
|
||||
* profile (the cross-profile aggregator tags rows, but a bare profile
|
||||
* loses the connection and can lag the create);
|
||||
* 4. undefined → the caller runs the cross-profile probe, and fails closed
|
||||
* 4. `eventOwner`, an exact route an inbound runtime event already proved
|
||||
* for this session (store/session-states sessionOwnerByRuntimeId). It is
|
||||
* consulted whenever every rung above named nothing better than a bare
|
||||
* profile name, because a bare profile has NO connection: the profile
|
||||
* door (requestGatewayForProfile → gatewayForProfile) resolves a bare
|
||||
* name equal to the primary profile against the PRIMARY socket, so an
|
||||
* ordinary session that runs on a non-primary connection — two
|
||||
* connections both exposing `default` is enough — has its RPCs answered
|
||||
* by another machine ("4001 session not found" from the 2nd prompt on);
|
||||
* 5. undefined → the caller runs the cross-profile probe, and fails closed
|
||||
* if that misses too.
|
||||
*
|
||||
* An EXACT durable route (1–3 tagged, or 4) always outranks a bare profile
|
||||
* name; only a genuinely unknown owner falls through.
|
||||
*
|
||||
* The hint outranks the row because the row is presentation state that can
|
||||
* be stamped from the AMBIENT profile (an optimistic row minted while
|
||||
* All-profiles / Bot routing left `default` active), and because it carries
|
||||
@@ -90,18 +107,22 @@ export function resolveSessionRpcOwner(args: {
|
||||
tileOwnerRoute: (storedSessionId: string) => SessionRpcOwnerRoute | undefined
|
||||
sessionOwnerHint: (storedSessionId: string) => SessionRpcOwnerRoute | undefined
|
||||
sessionRowOwner: (storedSessionId: string) => null | SessionRpcOwnerRoute | string | undefined
|
||||
eventOwner?: (storedSessionId: string) => SessionOwnerScope | undefined
|
||||
}): SessionRpcOwnerRoute | string | undefined {
|
||||
const { routingSessionId, sessionOwnerHint, sessionRowOwner, tileOwnerRoute } = args
|
||||
const { eventOwner, routingSessionId, sessionOwnerHint, sessionRowOwner, tileOwnerRoute } = args
|
||||
|
||||
if (!routingSessionId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const fromRow = sessionRowOwner(routingSessionId)
|
||||
const rowOwner = typeof fromRow === 'string' ? fromRow.trim() || undefined : (fromRow ?? undefined)
|
||||
|
||||
return (
|
||||
tileOwnerRoute(routingSessionId) ??
|
||||
sessionOwnerHint(routingSessionId) ??
|
||||
(typeof fromRow === 'string' ? fromRow.trim() || undefined : (fromRow ?? undefined))
|
||||
)
|
||||
const durable = tileOwnerRoute(routingSessionId) ?? sessionOwnerHint(routingSessionId) ?? rowOwner
|
||||
|
||||
if (isRouteOwner(durable)) {
|
||||
return durable
|
||||
}
|
||||
|
||||
return eventOwner?.(routingSessionId) ?? durable
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import type { SessionProfileRoute } from '@/store/session-request-router'
|
||||
import { sessionTileOwnerRoute } from '@/store/session-states'
|
||||
import { runtimeSessionOwner, sessionTileOwnerRoute } from '@/store/session-states'
|
||||
|
||||
// Re-exported for the many session-actions/tile call sites that already import
|
||||
// it from here; the canonical definition lives in @/store/session.
|
||||
@@ -1625,7 +1625,8 @@ export async function resolveSessionOwner(storedSessionId: null | string): Promi
|
||||
routingSessionId: storedSessionId,
|
||||
tileOwnerRoute: sessionTileOwnerRoute,
|
||||
sessionOwnerHint: getSessionOwnerHint,
|
||||
sessionRowOwner: id => knownSessionOwner(ownerLookupSessionRows(), id)
|
||||
sessionRowOwner: id => knownSessionOwner(ownerLookupSessionRows(), id),
|
||||
eventOwner: runtimeSessionOwner
|
||||
})
|
||||
|
||||
if (owner) {
|
||||
|
||||
@@ -1301,6 +1301,25 @@ describe('knownOwnerForSession / requestForOwnedSession (#91684 client half)', (
|
||||
expect(knownOwnerForSession('stored-shared')).toEqual({ connectionId: 'source-b', profile: 'default' })
|
||||
})
|
||||
|
||||
// Two connections both exposing `default`: the row only names the profile,
|
||||
// and a bare profile is resolved by the profile door against the PRIMARY
|
||||
// connection — another machine for a session that runs on a non-primary one.
|
||||
// The inbound event proved the exact owner, so it must outrank the
|
||||
// connection-blind row profile (this is what keeps the 2nd prompt of an
|
||||
// ordinary session from answering `4001 session not found`).
|
||||
it('prefers an event-proven connection over a bare row profile when two connections share the profile name', () => {
|
||||
recordSessionEventScope({ connectionId: 'local', profile: 'default', session_id: 'rt-local' })
|
||||
setSessions([{ id: 'rt-local', profile: 'default' } as never])
|
||||
|
||||
expect(knownOwnerForSession('rt-local')).toEqual({ connectionId: 'local', profile: 'default' })
|
||||
})
|
||||
|
||||
it('still answers with the bare row profile when no event ever named the connection', () => {
|
||||
setSessions([{ id: 'rt-unproven', profile: 'default' } as never])
|
||||
|
||||
expect(knownOwnerForSession('rt-unproven')).toBe('default')
|
||||
})
|
||||
|
||||
it('returns undefined (ambient) when no owner is known, and for null ids', () => {
|
||||
expect(knownOwnerForSession('unknown-session')).toBeUndefined()
|
||||
expect(knownOwnerForSession(null)).toBeUndefined()
|
||||
|
||||
@@ -59,6 +59,7 @@ import { secondaryProfileOwnerForEvent } from './session-event-provenance'
|
||||
import { $focusedTreePaneId } from './session-focus'
|
||||
import { assertSessionOwnerResolved } from './session-owner-resolution'
|
||||
import {
|
||||
isSessionOwnerRoute,
|
||||
requestForSessionProfile,
|
||||
type SessionOwnerRoute,
|
||||
type SessionOwnerScope,
|
||||
@@ -118,6 +119,17 @@ export function recordSessionEventScope(event: { connectionId?: string; profile?
|
||||
}
|
||||
}
|
||||
|
||||
/** The owner an inbound runtime EVENT proved for `sessionId` (#97511): the
|
||||
* exact (connectionId, profile) of the socket that delivered its events, or
|
||||
* the bare profile of a legacy profile-only pool. Exported so the session-scoped
|
||||
* RPC ladder can consult it WITHOUT the connection-blind profile rung
|
||||
* preempting it (see knownOwnerForSession). */
|
||||
export function runtimeSessionOwner(sessionId: null | string | undefined): SessionOwnerScope {
|
||||
const id = String(sessionId ?? '').trim()
|
||||
|
||||
return id ? sessionOwnerByRuntimeId.get(id) : undefined
|
||||
}
|
||||
|
||||
/** Forget only profile-pool runtime owners during permanent LOCAL profile
|
||||
* teardown. These string routes came exclusively from the legacy secondary
|
||||
* producer; exact connection descriptors must survive a same-named remote
|
||||
@@ -1038,10 +1050,15 @@ export function openTileGatewayScopes(): Set<string> {
|
||||
* Last rung: the owner recorded from the inbound runtime event itself
|
||||
* (sessionOwnerByRuntimeId, #97511) — an orphan runtime whose tile/hint/row
|
||||
* binding is absent or stale still routes through the exact
|
||||
* (connectionId, profile) or secondary socket's proven local profile. Every
|
||||
* durable rung above keeps outranking it, so a stored-id collision never
|
||||
* inherits a stale runtime ledger entry. Unproven profile fields record
|
||||
* nothing, so unknown owners in multi-profile topology still fail closed.
|
||||
* (connectionId, profile) or secondary socket's proven local profile. It sits
|
||||
* BELOW every durable EXACT route, so a stored-id collision never inherits a
|
||||
* stale runtime ledger entry, but it must sit ABOVE a bare profile name: a
|
||||
* bare profile carries no connection, and the profile door resolves it against
|
||||
* the PRIMARY connection (store/gateway gatewayForProfile), which for an
|
||||
* ordinary session that runs on a non-primary connection — two connections
|
||||
* both exposing `default` is enough — is another machine that answers
|
||||
* `4001 session not found`. Unproven profile fields record nothing, so unknown
|
||||
* owners in multi-profile topology still fail closed.
|
||||
* Returns undefined when no owner is known — the caller fails closed
|
||||
* (assertSessionOwnerResolved), never falls to "active".
|
||||
*/
|
||||
@@ -1052,12 +1069,16 @@ export function knownOwnerForSession(sessionId: null | string | undefined): Sess
|
||||
|
||||
const storedSessionId = storedSessionIdForRuntimeId(sessionId) ?? sessionId
|
||||
|
||||
return (
|
||||
const durable =
|
||||
sessionTileOwner(storedSessionId) ??
|
||||
getSessionOwnerHint(storedSessionId) ??
|
||||
knownSessionOwner(ownerLookupSessionRows(), storedSessionId) ??
|
||||
sessionOwnerByRuntimeId.get(sessionId)
|
||||
)
|
||||
knownSessionOwner(ownerLookupSessionRows(), storedSessionId)
|
||||
|
||||
if (isSessionOwnerRoute(durable)) {
|
||||
return durable
|
||||
}
|
||||
|
||||
return sessionOwnerByRuntimeId.get(sessionId) ?? durable
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user