diff --git a/apps/desktop/src/app/contrib/session-rpc-dispatcher.test.ts b/apps/desktop/src/app/contrib/session-rpc-dispatcher.test.ts index cee4203f44..2a5517e3bc 100644 --- a/apps/desktop/src/app/contrib/session-rpc-dispatcher.test.ts +++ b/apps/desktop/src/app/contrib/session-rpc-dispatcher.test.ts @@ -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' })]) diff --git a/apps/desktop/src/app/contrib/session-rpc-dispatcher.ts b/apps/desktop/src/app/contrib/session-rpc-dispatcher.ts index d57bcbb115..b9f61b2445 100644 --- a/apps/desktop/src/app/contrib/session-rpc-dispatcher.ts +++ b/apps/desktop/src/app/contrib/session-rpc-dispatcher.ts @@ -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), diff --git a/apps/desktop/src/app/contrib/wiring-routing.ts b/apps/desktop/src/app/contrib/wiring-routing.ts index 0c6fef02a5..c0d8835b8a 100644 --- a/apps/desktop/src/app/contrib/wiring-routing.ts +++ b/apps/desktop/src/app/contrib/wiring-routing.ts @@ -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 } diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 47119752c7..7ae23f45d8 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -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) { diff --git a/apps/desktop/src/store/session-states.test.ts b/apps/desktop/src/store/session-states.test.ts index 522cd2c1c1..2d522bf810 100644 --- a/apps/desktop/src/store/session-states.test.ts +++ b/apps/desktop/src/store/session-states.test.ts @@ -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() diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index b663c9e6fa..5c6ff6e9fd 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -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 { * 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 } /**