mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
perf(desktop): pre-warm profile pool backends on hover intent
A cold profile switch pays the full pool-backend spawn — Python boot, port announcement, readiness probe, token adoption — before the profile's gateway can even open. Measured with the new CDP harness (scripts/measure-profile-switch.mjs, same family as profile-session-switch.mjs): click → WS open is ~2.5-2.9s on a cold profile, ~3-3.6s to a settled sidebar; a warm profile settles in ~0.5-0.8s. The pointer entering a profile square telegraphs the switch hundreds of ms before the click lands, so start the spawn then. - store/profile: prewarmProfileBackend(name) — fires the existing hermesDesktop.getConnection IPC, which is idempotent (ensureBackend returns the pooled connectionPromise), so the real switch joins the in-flight spawn instead of starting it. Skips the active gateway profile, throttles per profile (60s) so drive-by hovers can't spam spawn attempts, and swallows failures — error UX belongs to the real switch. No new IPC surface; the pool's existing LRU cap + idle reaper still bound resource use, and the LRU guard never evicts a keepalive-fresh backend for a hover spawn. - sidebar/use-profile-prewarm: pointerenter/pointerleave handlers with a 120ms dwell so sweeping the pointer across the rail or a mixed-profile session list doesn't spawn a backend per element crossed. - Wired at the three switch surfaces: rail ProfileSquare, the condensed ProfileDropdown items (extracted ProfileDropdownItem so each row owns its dwell timer), and SidebarSessionRow (covers cross-profile resumes from the all-profiles view; same-profile rows no-op inside the guard). Measured E2E over CDP: synthetic hover on a cold profile square spawns its backend in the background; the subsequent click settles in ~519ms vs ~3.0-3.6s unhovered — and any hover shorter than the spawn still shaves its dwell off the click's wait. Verification: apps/desktop `npx tsc --noEmit` clean; full `npx vitest run` 212 files / 1777 passed (new prewarm guard/throttle tests in store/profile.test.ts); eslint + prettier clean.
This commit is contained in:
parent
71252f0dcb
commit
e0390c0f70
6 changed files with 315 additions and 21 deletions
159
apps/desktop/scripts/measure-profile-switch.mjs
Normal file
159
apps/desktop/scripts/measure-profile-switch.mjs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
// Measure a profile switch end-to-end: click a profile square in the rail,
|
||||
// then break the wall time into the phases the renderer can observe:
|
||||
// - getConnection IPC (Electron: pool backend spawn / reuse + readiness)
|
||||
// - gateway WS connect
|
||||
// - swap-target clear ($gatewaySwapTarget → sidebar loader gone)
|
||||
// - sidebar session rows for the new profile painted
|
||||
//
|
||||
// Instruments window.hermesDesktop.getConnection + WebSocket to timestamp the
|
||||
// phases without touching app code.
|
||||
//
|
||||
// Usage:
|
||||
// node apps/desktop/scripts/measure-profile-switch.mjs <profileName> [settleTimeoutMs]
|
||||
|
||||
const CDP_HTTP = 'http://127.0.0.1:9222'
|
||||
const PROFILE = process.argv[2]
|
||||
const SETTLE_TIMEOUT = Number(process.argv[3] || 60000)
|
||||
|
||||
if (!PROFILE) {
|
||||
console.error('usage: measure-profile-switch.mjs <profileName>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
class CDP {
|
||||
constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map() }
|
||||
static async open(url) {
|
||||
const ws = new WebSocket(url)
|
||||
await new Promise((r) => ws.addEventListener('open', r, { once: true }))
|
||||
const cdp = new CDP(ws)
|
||||
ws.addEventListener('message', (ev) => {
|
||||
const m = JSON.parse(ev.data.toString())
|
||||
if (m.id != null && cdp.pending.has(m.id)) {
|
||||
const { resolve, reject } = cdp.pending.get(m.id)
|
||||
cdp.pending.delete(m.id)
|
||||
if (m.error) reject(new Error(m.error.message))
|
||||
else resolve(m.result)
|
||||
}
|
||||
})
|
||||
ws.addEventListener('close', () => {
|
||||
for (const { reject } of cdp.pending.values()) reject(new Error('CDP socket closed'))
|
||||
cdp.pending.clear()
|
||||
})
|
||||
return cdp
|
||||
}
|
||||
send(method, params) {
|
||||
const id = ++this.id
|
||||
return new Promise((res, rej) => {
|
||||
this.pending.set(id, { resolve: res, reject: rej })
|
||||
this.ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
async eval(expr) {
|
||||
const r = await this.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval failed')
|
||||
return r.result.value
|
||||
}
|
||||
close() { this.ws.close() }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const list = await (await fetch(`${CDP_HTTP}/json`)).json()
|
||||
const target = list.find((t) => t.type === 'page' && /5174/.test(t.url))
|
||||
if (!target) { console.error('renderer not found on 9222'); process.exit(1) }
|
||||
const cdp = await CDP.open(target.webSocketDebuggerUrl)
|
||||
|
||||
// Instrument getConnection + WebSocket once.
|
||||
await cdp.eval(`(() => {
|
||||
if (window.__PROFILE_SWITCH_OBS__) return 'already'
|
||||
const obs = { events: [] }
|
||||
const mark = (name, extra) => obs.events.push({ name, t: performance.now(), ...(extra || {}) })
|
||||
window.__PROFILE_SWITCH_OBS__ = obs
|
||||
window.__psMark = mark
|
||||
|
||||
const desktop = window.hermesDesktop
|
||||
if (desktop && desktop.getConnection) {
|
||||
const orig = desktop.getConnection.bind(desktop)
|
||||
desktop.getConnection = async (profile) => {
|
||||
mark('getConnection:start', { profile })
|
||||
try {
|
||||
const res = await orig(profile)
|
||||
mark('getConnection:done', { profile })
|
||||
return res
|
||||
} catch (e) {
|
||||
mark('getConnection:error', { profile, error: String(e).slice(0, 120) })
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const OrigWS = window.WebSocket
|
||||
window.WebSocket = function (url, ...rest) {
|
||||
const ws = new OrigWS(url, ...rest)
|
||||
if (String(url).includes('/api/ws')) {
|
||||
mark('ws:new', { url: String(url).replace(/token=[^&]+/, 'token=…').slice(0, 90) })
|
||||
ws.addEventListener('open', () => mark('ws:open'))
|
||||
}
|
||||
return ws
|
||||
}
|
||||
window.WebSocket.prototype = OrigWS.prototype
|
||||
Object.assign(window.WebSocket, OrigWS)
|
||||
return 'installed'
|
||||
})()`)
|
||||
|
||||
const before = await cdp.eval(`(() => {
|
||||
const rail = document.querySelector('[data-slot="profile-rail"]')
|
||||
return {
|
||||
railButtons: rail ? [...rail.querySelectorAll('[role="tab"], button')].map(b => (b.getAttribute('aria-label') || b.title || b.textContent || '').slice(0, 30)) : [],
|
||||
sessions: document.querySelectorAll('[data-slot="sidebar-session-row"], [data-session-id]').length
|
||||
}
|
||||
})()`)
|
||||
console.log('rail buttons:', JSON.stringify(before.railButtons))
|
||||
|
||||
const clicked = await cdp.eval(`(() => {
|
||||
window.__psMark('click', { profile: ${JSON.stringify(PROFILE)} })
|
||||
const rail = document.querySelector('[data-slot="profile-rail"]')
|
||||
if (!rail) return 'no-rail'
|
||||
const target = [...rail.querySelectorAll('button, [role="tab"]')].find(b =>
|
||||
((b.getAttribute('aria-label') || '') + ' ' + (b.title || '') + ' ' + (b.textContent || '')).toLowerCase().includes(${JSON.stringify(PROFILE.toLowerCase())}))
|
||||
if (!target) return 'not-found'
|
||||
target.click()
|
||||
return 'clicked'
|
||||
})()`)
|
||||
console.log('click:', clicked)
|
||||
if (clicked !== 'clicked') { cdp.close(); process.exit(2) }
|
||||
|
||||
// Poll until the swap settles: loader gone + session rows painted (or empty
|
||||
// list settled) + active profile pill shows the target.
|
||||
const t0 = Date.now()
|
||||
let settled = null
|
||||
while (Date.now() - t0 < SETTLE_TIMEOUT) {
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
const s = await cdp.eval(`(() => {
|
||||
// The swap overlay stays mounted at opacity-0 after the swap — check the
|
||||
// computed opacity of the container that holds the "Waking up …" label.
|
||||
const label = [...document.querySelectorAll('div[aria-hidden]')].find(el => /waking up/i.test(el.textContent || ''))
|
||||
const overlayVisible = label ? Number(getComputedStyle(label).opacity) > 0.05 : false
|
||||
return {
|
||||
t: performance.now(),
|
||||
overlayVisible,
|
||||
sessions: document.querySelectorAll('[data-slot="row-button"]').length
|
||||
}
|
||||
})()`)
|
||||
if (!s.overlayVisible && s.sessions > 0) { settled = s; break }
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 400))
|
||||
const obs = await cdp.eval('window.__PROFILE_SWITCH_OBS__')
|
||||
const events = obs.events
|
||||
const click = events.find((e) => e.name === 'click' && e.profile === PROFILE)
|
||||
console.log('\n=== PHASES (ms after click) ===')
|
||||
for (const e of events) {
|
||||
if (e.t < click.t - 5) continue
|
||||
console.log(`${(e.t - click.t).toFixed(0).padStart(7)} ${e.name}${e.profile ? ' [' + e.profile + ']' : ''}${e.error ? ' ' + e.error : ''}${e.url ? ' ' + e.url : ''}`)
|
||||
}
|
||||
console.log(settled ? `\nsettled (loader gone + rows painted) at ~${Date.now() - t0} ms wall` : '\nTIMEOUT waiting for settle')
|
||||
|
||||
cdp.close()
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1) })
|
||||
|
|
@ -66,6 +66,8 @@ import { DeleteProfileDialog } from '../../profiles/delete-profile-dialog'
|
|||
import { RenameProfileDialog } from '../../profiles/rename-profile-dialog'
|
||||
import { PROFILES_ROUTE } from '../../routes'
|
||||
|
||||
import { useProfilePrewarm } from './use-profile-prewarm'
|
||||
|
||||
const RAIL_GAP = 4 // px — matches gap-1 between squares.
|
||||
|
||||
// Past this many profiles the strip of colored squares stops scaling (tiny
|
||||
|
|
@ -457,30 +459,40 @@ function ProfileDropdown({
|
|||
<SelectValue placeholder={p.title} />
|
||||
</SelectTrigger>
|
||||
<SelectContent collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }} side="top">
|
||||
{profiles.map(profile => {
|
||||
const color = resolveProfileColor(profile.name, colors)
|
||||
const hue = color ?? 'var(--ui-text-quaternary)'
|
||||
|
||||
return (
|
||||
<SelectItem key={profile.name} value={profile.name}>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="grid size-4 shrink-0 place-items-center rounded-[3px] text-[0.5rem] font-semibold uppercase leading-none"
|
||||
style={{ backgroundColor: profileColorSoft(hue, 22), color: color ?? undefined }}
|
||||
>
|
||||
{profile.name.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
|
||||
</span>
|
||||
<span className="truncate">{profile.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
{profiles.map(profile => (
|
||||
<ProfileDropdownItem
|
||||
color={resolveProfileColor(profile.name, colors)}
|
||||
key={profile.name}
|
||||
name={profile.name}
|
||||
/>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
// One dropdown row per profile — its own component so each row can own a
|
||||
// hover-intent prewarm timer (see useProfilePrewarm).
|
||||
function ProfileDropdownItem({ color, name }: { color: null | string; name: string }) {
|
||||
const hue = color ?? 'var(--ui-text-quaternary)'
|
||||
const { cancelPrewarm, startPrewarm } = useProfilePrewarm(name)
|
||||
|
||||
return (
|
||||
<SelectItem onPointerEnter={startPrewarm} onPointerLeave={cancelPrewarm} value={name}>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="grid size-4 shrink-0 place-items-center rounded-[3px] text-[0.5rem] font-semibold uppercase leading-none"
|
||||
style={{ backgroundColor: profileColorSoft(hue, 22), color: color ?? undefined }}
|
||||
>
|
||||
{name.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
|
||||
</span>
|
||||
<span className="truncate">{name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProfilePillProps {
|
||||
active: boolean
|
||||
// home / All / Manage are glyph action buttons (navigation, not identity).
|
||||
|
|
@ -548,6 +560,9 @@ function ProfileSquare({
|
|||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const pressTimer = useRef<null | number>(null)
|
||||
const suppressClick = useRef(false)
|
||||
// Hovering a square telegraphs the switch — start that profile's backend
|
||||
// spawn now so a cold click doesn't pay the full boot.
|
||||
const { cancelPrewarm, startPrewarm } = useProfilePrewarm(label)
|
||||
|
||||
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({
|
||||
id: label,
|
||||
|
|
@ -637,7 +652,11 @@ function ProfileSquare({
|
|||
setPickerOpen(true)
|
||||
}, LONG_PRESS_MS)
|
||||
}}
|
||||
onPointerLeave={clearPress}
|
||||
onPointerEnter={startPrewarm}
|
||||
onPointerLeave={() => {
|
||||
clearPress()
|
||||
cancelPrewarm()
|
||||
}}
|
||||
onPointerUp={clearPress}
|
||||
>
|
||||
{label.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
|||
|
||||
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
|
||||
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
|
||||
import { useProfilePrewarm } from './use-profile-prewarm'
|
||||
|
||||
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
||||
session: SessionInfo
|
||||
|
|
@ -68,6 +69,7 @@ export function SidebarSessionRow({
|
|||
}: SidebarSessionRowProps) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
const { cancelPrewarm, startPrewarm } = useProfilePrewarm(session.profile)
|
||||
const title = sessionTitle(session)
|
||||
const age = formatAge(session.last_active || session.started_at, r)
|
||||
const handleLabel = `Reorder ${title}`
|
||||
|
|
@ -161,6 +163,12 @@ export function SidebarSessionRow({
|
|||
|
||||
startSessionDrag({ id: session.id, profile: session.profile || 'default', title }, event)
|
||||
}}
|
||||
// Hovering a row from another profile (the all-profiles view) telegraphs
|
||||
// a cross-profile resume — start that backend's spawn now so the click
|
||||
// doesn't pay the full cold boot. Same-profile rows no-op inside
|
||||
// prewarmProfileBackend.
|
||||
onPointerEnter={startPrewarm}
|
||||
onPointerLeave={cancelPrewarm}
|
||||
ref={ref}
|
||||
style={style}
|
||||
{...rest}
|
||||
|
|
|
|||
38
apps/desktop/src/app/chat/sidebar/use-profile-prewarm.ts
Normal file
38
apps/desktop/src/app/chat/sidebar/use-profile-prewarm.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { prewarmProfileBackend } from '@/store/profile'
|
||||
|
||||
// Dwell before firing: long enough that sweeping the pointer across the rail
|
||||
// or a mixed-profile session list doesn't spawn a backend for every element
|
||||
// passed through, short enough to beat the click by hundreds of ms.
|
||||
const PREWARM_DWELL_MS = 120
|
||||
|
||||
/**
|
||||
* pointerenter/pointerleave handlers that pre-warm `profile`'s pool backend
|
||||
* after a short hover dwell (see prewarmProfileBackend in store/profile).
|
||||
* Consumers merge these with their own pointer handlers.
|
||||
*/
|
||||
export function useProfilePrewarm(profile: string | null | undefined) {
|
||||
const timer = useRef<null | number>(null)
|
||||
const profileRef = useRef(profile)
|
||||
profileRef.current = profile
|
||||
|
||||
const cancelPrewarm = useCallback(() => {
|
||||
if (timer.current != null) {
|
||||
clearTimeout(timer.current)
|
||||
timer.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => cancelPrewarm, [cancelPrewarm])
|
||||
|
||||
const startPrewarm = useCallback(() => {
|
||||
cancelPrewarm()
|
||||
timer.current = window.setTimeout(() => {
|
||||
timer.current = null
|
||||
prewarmProfileBackend(profileRef.current || 'default')
|
||||
}, PREWARM_DWELL_MS)
|
||||
}, [cancelPrewarm])
|
||||
|
||||
return { cancelPrewarm, startPrewarm }
|
||||
}
|
||||
|
|
@ -18,7 +18,9 @@ vi.mock('@/hermes', () => ({
|
|||
vi.mock('@/lib/query-client', () => ({ queryClient: { invalidateQueries: vi.fn() } }))
|
||||
vi.mock('@/store/starmap', () => ({ resetStarmapGraph }))
|
||||
|
||||
const { $activeGatewayProfile, $profiles, ensureGatewayProfile, refreshProfiles } = await import('./profile')
|
||||
const { $activeGatewayProfile, $profiles, ensureGatewayProfile, prewarmProfileBackend, refreshProfiles } =
|
||||
await import('./profile')
|
||||
|
||||
const { $connection } = await import('./session')
|
||||
const { queryClient } = await import('@/lib/query-client')
|
||||
const { getProfiles } = await import('@/hermes')
|
||||
|
|
@ -115,6 +117,42 @@ describe('profile-scoped cache invalidation', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('prewarmProfileBackend (hover-intent pool spawn)', () => {
|
||||
it('kicks getConnection for a non-active profile', () => {
|
||||
getConnection.mockResolvedValue(localConn())
|
||||
|
||||
prewarmProfileBackend('warm-basic')
|
||||
|
||||
expect(getConnection).toHaveBeenCalledWith('warm-basic')
|
||||
})
|
||||
|
||||
it('skips the profile the gateway is already on', () => {
|
||||
$activeGatewayProfile.set('warm-active')
|
||||
|
||||
prewarmProfileBackend('warm-active')
|
||||
|
||||
expect(getConnection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throttles repeat pre-warms for the same profile within the interval', () => {
|
||||
getConnection.mockResolvedValue(localConn())
|
||||
|
||||
prewarmProfileBackend('warm-throttle-a')
|
||||
prewarmProfileBackend('warm-throttle-a')
|
||||
prewarmProfileBackend('warm-throttle-b')
|
||||
|
||||
const calls = getConnection.mock.calls.map(([name]) => name)
|
||||
expect(calls.filter(name => name === 'warm-throttle-a')).toHaveLength(1)
|
||||
expect(calls.filter(name => name === 'warm-throttle-b')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('swallows spawn failures — error UX belongs to the real switch', () => {
|
||||
getConnection.mockRejectedValue(new Error('spawn failed'))
|
||||
|
||||
expect(() => prewarmProfileBackend('warm-failing')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshProfiles shared rail list (#49289)', () => {
|
||||
it('removes a deleted profile from the shared $profiles cache after Manage Profiles refreshes', async () => {
|
||||
$profiles.set([profile('default', true), profile('test1')])
|
||||
|
|
|
|||
|
|
@ -189,6 +189,38 @@ $activeGatewayProfile.subscribe(value => {
|
|||
// so a lazy spawn doesn't read as a hang. Single-profile users never swap.
|
||||
export const $gatewaySwapTarget = atom<string | null>(null)
|
||||
|
||||
// ── Hover-intent backend pre-warm ───────────────────────────────────────────
|
||||
// A cold switch to a profile whose pool backend isn't running pays the full
|
||||
// spawn (Python boot + port announce + readiness probe — measured ~2.5-3s)
|
||||
// before its gateway can even open. The pointer entering a profile square in
|
||||
// the rail signals the switch a few hundred ms before the click lands, so we
|
||||
// start the spawn then. `getConnection` → `ensureBackend` in the Electron main
|
||||
// is idempotent (a pooled profile returns its existing connectionPromise), so
|
||||
// the real switch's getConnection joins the in-flight spawn instead of
|
||||
// starting it — and a pre-warm for an already-live backend is a cheap no-op.
|
||||
// Throttled per profile so drive-by hovers can't spam spawn attempts; failures
|
||||
// stay silent here and surface on the real switch, which owns error UX.
|
||||
const PREWARM_MIN_INTERVAL_MS = 60_000
|
||||
|
||||
const prewarmedAt = new Map<string, number>()
|
||||
|
||||
export function prewarmProfileBackend(name: string): void {
|
||||
const key = normalizeProfileKey(name)
|
||||
|
||||
if (key === normalizeProfileKey($activeGatewayProfile.get())) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
if (now - (prewarmedAt.get(key) ?? 0) < PREWARM_MIN_INTERVAL_MS) {
|
||||
return
|
||||
}
|
||||
|
||||
prewarmedAt.set(key, now)
|
||||
window.hermesDesktop?.getConnection?.(key).catch(() => undefined)
|
||||
}
|
||||
|
||||
let gatewaySwitch: Promise<void> | null = null
|
||||
|
||||
// Keep the renderer's $connection (mode / baseUrl / profile) in lockstep with
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue