From fb393ee08b73f650443bf8076cb2e0df37db0557 Mon Sep 17 00:00:00 2001 From: joe-rodgers <25499388+joe-rodgers@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:45:41 -0700 Subject: [PATCH] fix(desktop): stamp remote list rows with their owning connection; retry one transient projects.tree loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/desktop/src/api/sessions.test.ts | 43 +++++++++++++++++++++++++ apps/desktop/src/api/sessions.ts | 29 +++++++++++++---- apps/desktop/src/store/projects.test.ts | 19 +++++++++++ apps/desktop/src/store/projects.ts | 34 ++++++++++++++++--- 4 files changed, 114 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/api/sessions.test.ts diff --git a/apps/desktop/src/api/sessions.test.ts b/apps/desktop/src/api/sessions.test.ts new file mode 100644 index 0000000000..15e362daab --- /dev/null +++ b/apps/desktop/src/api/sessions.test.ts @@ -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' }) + }) +}) diff --git a/apps/desktop/src/api/sessions.ts b/apps/desktop/src/api/sessions.ts index f938dc2381..c89ce10756 100644 --- a/apps/desktop/src/api/sessions.ts +++ b/apps/desktop/src/api/sessions.ts @@ -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 } } diff --git a/apps/desktop/src/store/projects.test.ts b/apps/desktop/src/store/projects.test.ts index e80b645882..994927f33e 100644 --- a/apps/desktop/src/store/projects.test.ts +++ b/apps/desktop/src/store/projects.test.ts @@ -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 diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index c6f9df8470..59d9fcf8d7 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -372,6 +372,12 @@ async function gatewayRequestOn( return gateway.request(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( - gateway, - 'projects.tree', - projectParams({ preview_limit: PROJECT_TREE_PREVIEW_LIMIT }, profile) - ) + let res: ProjectTreePayload + + try { + res = await gatewayRequestOn( + 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( + gateway, + 'projects.tree', + projectParams({ preview_limit: PROJECT_TREE_PREVIEW_LIMIT }, profile) + ) + } if (generation !== projectTreeRefreshGeneration || !stillOnProjectsContext(context)) { return