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:
43
apps/desktop/src/api/sessions.test.ts
Normal file
43
apps/desktop/src/api/sessions.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user