fix(desktop): stamp remote list rows with their owning connection; retry one transient projects.tree loss

Partial cherry-pick of PR #94901 (joe-rodgers). Surviving scope:
- api/sessions: stampActiveConnectionOwner — rows returned by the
  active non-local gateway are stamped with its registry connection_id
  (explicit owners from multi-source responses preserved), so a later
  resume cannot fall back to a same-named local profile.
- store/projects: one-shot projects.tree retry when a remote source
  switch leaves the first read RPC on a newly-opened socket without a
  response (request timed out / gateway connection closed), only while
  the same gateway/profile is still foreground. Component fix for the
  live-confirmed #92352 sidebar-never-paints gap.

Dropped scope (superseded on main / by the #94656 anchor landed just
below): knownSessionOwner+SessionOwnerScope rewiring in session.ts,
session-states.ts, wiring.tsx (main and #94656 carry richer variants),
and the $connection-derived optimistic-row stamp in
use-session-actions/utils.ts (#94656 stamps the optimistic row from
the captured exact owner route instead of ambient state).

Original-PR: #94901
Dropped-scope: routing half of 2cb5bdbf1 (session.ts, session-states.ts, wiring.tsx, use-session-actions/utils.ts hunks)
This commit is contained in:
joe-rodgers
2026-08-26 01:45:41 -07:00
committed by Teknium
parent b2a58dbb39
commit fb393ee08b
4 changed files with 114 additions and 11 deletions

View File

@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/gateway-rpc', () => ({ isMissingRestEndpoint: () => false }))
vi.mock('@/store/transcript-tail', () => ({ recordTranscriptTail: vi.fn() }))
vi.mock('./client', () => ({
capabilityScoped: vi.fn(),
getApiRequestConnection: vi.fn(() => 'prometheus'),
hermesApi: vi.fn(),
profileScoped: vi.fn(() => ({}))
}))
const client = await import('./client')
const { listSidebarSessions } = await import('./sessions')
const hermesApi = vi.mocked(client.hermesApi)
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(client.getApiRequestConnection).mockReturnValue('prometheus')
})
describe('listSidebarSessions remote ownership', () => {
it('stamps active remote rows so a later resume stays on their gateway', async () => {
hermesApi.mockResolvedValue({
cron: { sessions: [] },
messaging: { sessions: [] },
recents: {
sessions: [{ id: 'remote-session', profile: 'default', source: 'desktop', title: 'Remote chat' }]
}
} as never)
const result = await listSidebarSessions({
recentsProfile: 'default',
recentsLimit: 40,
recentsExclude: [],
cronLimit: 20,
messagingLimit: 40,
messagingExclude: []
})
expect(result.recents.sessions[0]).toMatchObject({ connection_id: 'prometheus', id: 'remote-session' })
})
})

View File

@@ -8,7 +8,7 @@ import type {
SessionSearchResponse
} from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client'
import { capabilityScoped, getApiRequestConnection, hermesApi, type ProfileScope, profileScoped } from './client'
const SESSION_LIST_REQUEST_TIMEOUT_MS = 60_000
@@ -32,6 +32,23 @@ function sessionScopeQuery(scope?: ProfileScope): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : ''
}
/**
* The active registered gateway owns every row it returns, but its HTTP APIs
* correctly know nothing about this Desktop-local registry id. Preserve an
* explicit owner from a multi-source response; otherwise stamp the active
* non-local source so a later resume cannot fall back to a same-named local
* profile.
*/
function stampActiveConnectionOwner(sessions: SessionInfo[]): SessionInfo[] {
const connectionId = getApiRequestConnection()?.trim()
if (!connectionId || connectionId === 'local') {
return sessions
}
return sessions.map(session => (session.connection_id ? session : { ...session, connection_id: connectionId }))
}
/**
* Trim a page to its window WITHOUT discarding pinned rows.
*
@@ -68,7 +85,7 @@ export async function listSessions(
return {
...result,
sessions: pageWindow(result.sessions, limit),
sessions: pageWindow(stampActiveConnectionOwner(result.sessions), limit),
offset: 0
}
}
@@ -110,7 +127,7 @@ export async function listAllProfileSessions(
return {
...result,
sessions: pageWindow(result.sessions, limit),
sessions: pageWindow(stampActiveConnectionOwner(result.sessions), limit),
offset: 0
}
}
@@ -268,9 +285,9 @@ export async function listSidebarSessions(req: SidebarSessionsRequest): Promise<
}
return {
recents: { ...result.recents, sessions: result.recents?.sessions ?? [] },
cron: { ...result.cron, sessions: result.cron?.sessions ?? [] },
messaging: { ...result.messaging, sessions: result.messaging?.sessions ?? [] },
recents: { ...result.recents, sessions: stampActiveConnectionOwner(result.recents?.sessions ?? []) },
cron: { ...result.cron, sessions: stampActiveConnectionOwner(result.cron?.sessions ?? []) },
messaging: { ...result.messaging, sessions: stampActiveConnectionOwner(result.messaging?.sessions ?? []) },
errors: result.errors
}
}

View File

@@ -728,6 +728,25 @@ describe('project tree profile isolation', () => {
$projectTree.set([])
})
it('retries a dropped projects.tree request once on the active gateway', async () => {
const request = vi
.fn()
.mockRejectedValueOnce(new Error('request timed out after 30s: projects.tree'))
.mockResolvedValueOnce({
active_id: null,
projects: [{ id: 'remote-tree', label: 'Remote tree', path: null, repos: [], sessionCount: 0 }],
scoped_session_ids: []
})
const gateway = { connectionState: 'open', request }
activeGateway.mockReturnValue(gateway as never)
gatewayAtom.set(gateway as never)
await refreshProjectTree()
expect(request).toHaveBeenCalledTimes(2)
expect($projectTree.get().map(project => project.id)).toEqual(['remote-tree'])
})
it('does not publish a late response from the previous gateway', async () => {
let resolveA: ((value: unknown) => void) | undefined

View File

@@ -372,6 +372,12 @@ async function gatewayRequestOn<T>(
return gateway.request<T>(method, params)
}
function isRetryableProjectTreeReadError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error ?? '')
return message.includes('request timed out') || message.includes('gateway connection closed')
}
interface ActiveProjectsContext {
gateway: HermesGateway
profile: string
@@ -479,11 +485,29 @@ async function refreshProjectTreeOn(context: ActiveProjectsContext): Promise<voi
}
try {
const res = await gatewayRequestOn<ProjectTreePayload>(
gateway,
'projects.tree',
projectParams({ preview_limit: PROJECT_TREE_PREVIEW_LIMIT }, profile)
)
let res: ProjectTreePayload
try {
res = await gatewayRequestOn<ProjectTreePayload>(
gateway,
'projects.tree',
projectParams({ preview_limit: PROJECT_TREE_PREVIEW_LIMIT }, profile)
)
} catch (error) {
// A remote source switch can leave the first read RPC on a newly-opened
// socket without a response even though the gateway remains healthy.
// Retry once only while this exact gateway/profile is still foreground;
// missing-method and other authoritative failures stay visible as-is.
if (!isRetryableProjectTreeReadError(error) || !stillOnProjectsContext(context)) {
throw error
}
res = await gatewayRequestOn<ProjectTreePayload>(
gateway,
'projects.tree',
projectParams({ preview_limit: PROJECT_TREE_PREVIEW_LIMIT }, profile)
)
}
if (generation !== projectTreeRefreshGeneration || !stillOnProjectsContext(context)) {
return