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:
finn763
2026-09-17 02:17:56 +08:00
committed by Teknium
parent 693430d010
commit 02b6802c8d
6 changed files with 177 additions and 20 deletions

View File

@@ -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' })])

View File

@@ -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),

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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()

View File

@@ -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
}
/**