mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Two problems, both found by distrusting the harness's own numbers. 1. The scenario slept a fixed 1s after mounting tabs, then recorded. Boot and session hydration are not reliably done by then, so a variable amount of unrelated work landed inside the measurement window. Three back-to-back runs on identical code spread 2.2x on total_renders and 3.8x on wasted_renders — wide enough that a single-run before/after delta could be mostly noise. Replaced with a quiesce gate that waits for commits to hold still before recording, and reports 'quiet:N' or 'timeout:...' so a contaminated run is visible instead of silent. 2. The counter attributed a context-driven re-render as 'wasted', which pointed at memo() as the fix when memo cannot block context at all. Adds contextChanged via the fiber's context dependency list, and excludes it from wasted. The gate also turned up a finding worth more than the fix: with five busy tiles and NO driver running, the renderer still commits ~18x/sec. The report now names the cascade roots (own state changed, props did not) rather than leaving them to be guessed at — Streamdown re-renders itself 105 times while idle, which is what drives Block/Ct.
259 lines
9.9 KiB
JavaScript
259 lines
9.9 KiB
JavaScript
// Render churn during multi-tab streaming: WHAT re-rendered and WHY, and which
|
|
// store published the update. Frame pacing (see `multitab`) tells you the cost;
|
|
// this tells you the cause.
|
|
//
|
|
// Drives the same synthetic pipeline as `multitab` — publishSessionState per
|
|
// session per flush via `__HERMES_SESSION_TILES__`, no backend, no credits —
|
|
// then reads the dev-only counters installed by `src/debug/`:
|
|
//
|
|
// window.__RENDER_COUNTS__ — per-component renders, attributed to
|
|
// props / hook state / parent-only ("wasted")
|
|
// window.__ATOM_CHURN__ — per-store notifications, listener fan-out, and
|
|
// notifications whose value was deep-equal to the
|
|
// previous one ("wasted")
|
|
//
|
|
// The headline metric is `sidebar_renders`: how many times the sidebar tree
|
|
// re-rendered while agents were typing in other tabs. It should be 0.
|
|
//
|
|
// node scripts/perf/run.mjs render-churn --spawn [--tiles 5] [--tokens 240]
|
|
|
|
import { sleep } from '../lib/cdp.mjs'
|
|
|
|
/** Components that make up the sidebar tree. A render of any of these while
|
|
* a background tab streams is work the user cannot see. */
|
|
const SIDEBAR_COMPONENTS = [
|
|
'ChatSidebar',
|
|
'SidebarSurface',
|
|
'SessionRow',
|
|
'SessionsSection',
|
|
'CronJobsSection',
|
|
'ProfileSwitcher',
|
|
'VirtualSessionList',
|
|
'WorkspaceGroup',
|
|
'OverviewRow',
|
|
'SessionStatusDot'
|
|
]
|
|
|
|
/** Page-side setup: open `tiles` session tiles, seed each with a transcript.
|
|
* Mirrors `multitab.mjs` so the two scenarios measure the same workload. */
|
|
const setup = (tiles, seedTurns) => `
|
|
(() => {
|
|
const hook = window.__HERMES_SESSION_TILES__
|
|
if (!hook) return 'no-hook'
|
|
if (!window.__RENDER_COUNTS__) return 'no-render-counter'
|
|
if (!window.__ATOM_CHURN__) return 'no-atom-churn'
|
|
|
|
const turn = (sid, i) => ([
|
|
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
|
|
parts: [{ type: 'text', text: 'Review question ' + i + ': does the diff handle the error path?' }] },
|
|
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
|
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nThe handler swallows the rejection.\\n\\n- The catch block drops the error.\\n- Retries are unbounded.\\n' }] }
|
|
])
|
|
|
|
const state = (sid) => {
|
|
const messages = []
|
|
for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i))
|
|
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
|
parts: [{ type: 'text', text: '' }] })
|
|
return {
|
|
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
|
|
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
|
|
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
|
|
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
|
|
needsInput: false, turnStartedAt: Date.now(), usage: null
|
|
}
|
|
}
|
|
|
|
window.__RC__ = { ids: [], timer: null }
|
|
for (let n = 1; n <= ${tiles}; n++) {
|
|
const sid = 'churn-tile-' + n
|
|
const rid = 'churn-rt-' + n
|
|
window.__RC__.ids.push({ sid, rid })
|
|
hook.open(sid, 'center')
|
|
hook.patch(sid, { runtimeId: rid })
|
|
hook.publish(rid, state(sid))
|
|
}
|
|
return 'ok'
|
|
})()
|
|
`
|
|
|
|
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
|
|
|
/** Grow every tile's streaming tail by `chunk` each `intervalMs`, through the
|
|
* same publish path the gateway's delta flush uses. */
|
|
const drive = (chunk, intervalMs, totalTokens) => `
|
|
(() => {
|
|
const hook = window.__HERMES_SESSION_TILES__
|
|
let pushed = 0
|
|
const tick = () => {
|
|
const states = hook.states()
|
|
for (const { rid } of window.__RC__.ids) {
|
|
const prev = states[rid]
|
|
if (!prev) continue
|
|
const messages = prev.messages.map(m => {
|
|
if (m.id !== prev.streamId) return m
|
|
const head = m.parts.slice(0, -1)
|
|
const last = m.parts[m.parts.length - 1]
|
|
return { ...m, parts: [...head, { type: 'text', text: last.text + ${JSON.stringify(chunk)} }] }
|
|
})
|
|
hook.publish(rid, { ...prev, messages })
|
|
}
|
|
pushed += 1
|
|
if (pushed < ${totalTokens}) window.__RC__.timer = setTimeout(tick, ${intervalMs})
|
|
else window.__RC__.done = true
|
|
}
|
|
window.__RC__.timer = setTimeout(tick, ${intervalMs})
|
|
return 'driving'
|
|
})()
|
|
`
|
|
|
|
/** Wait until the renderer stops committing on its own, so the recording window
|
|
* captures STREAMING cost and not whatever boot/hydration work happened to
|
|
* still be in flight. Returns `quiet:N` once commits hold still for `quietMs`.
|
|
*
|
|
* If it returns `timeout:...` the app never went idle at all — with tiles
|
|
* marked busy and NO driver running, that means something is ticking on its
|
|
* own. The report of what rendered during the wait is attached so the culprit
|
|
* is named rather than guessed at. */
|
|
const quiesce = (quietMs, timeoutMs) => `
|
|
(async () => {
|
|
const rc = window.__RENDER_COUNTS__
|
|
rc.start()
|
|
const deadline = Date.now() + ${timeoutMs}
|
|
const startedAt = Date.now()
|
|
let last = -1
|
|
let stableSince = Date.now()
|
|
while (Date.now() < deadline) {
|
|
await new Promise(r => setTimeout(r, 100))
|
|
const n = rc.commits()
|
|
if (n !== last) { last = n; stableSince = Date.now(); continue }
|
|
if (Date.now() - stableSince >= ${quietMs}) { rc.stop(); return 'quiet:' + n }
|
|
}
|
|
const idle = {
|
|
commits: last,
|
|
seconds: (Date.now() - startedAt) / 1000,
|
|
top: rc.report(8),
|
|
// Who OWNS the update? The component whose own hook state changed with
|
|
// no changed props is the root of a churn cascade; everything under it
|
|
// is collateral. Naming it is the difference between fixing the cause
|
|
// and memoizing a symptom.
|
|
owners: rc.report(200).filter(r => r.stateChanged > 0 && r.propsChanged === 0).slice(0, 8)
|
|
}
|
|
rc.stop()
|
|
return 'timeout:' + JSON.stringify(idle)
|
|
})()
|
|
`
|
|
|
|
const START = `
|
|
(() => {
|
|
window.__RENDER_COUNTS__.start()
|
|
window.__ATOM_CHURN__.start()
|
|
return 'recording'
|
|
})()
|
|
`
|
|
|
|
const COLLECT = `
|
|
(() => {
|
|
window.__RENDER_COUNTS__.stop()
|
|
window.__ATOM_CHURN__.stop()
|
|
return JSON.stringify({
|
|
commits: window.__RENDER_COUNTS__.commits(),
|
|
renders: window.__RENDER_COUNTS__.report(200),
|
|
atoms: window.__ATOM_CHURN__.report(200)
|
|
})
|
|
})()
|
|
`
|
|
|
|
const CLEANUP = `
|
|
(() => {
|
|
if (window.__RC__) {
|
|
clearTimeout(window.__RC__.timer)
|
|
for (const { sid, rid } of window.__RC__.ids) {
|
|
const states = window.__HERMES_SESSION_TILES__.states()
|
|
window.__HERMES_SESSION_TILES__.publish(rid, { ...states[rid], busy: false, streamId: null })
|
|
window.__HERMES_SESSION_TILES__.close(sid)
|
|
}
|
|
window.__RC__ = null
|
|
}
|
|
window.__RENDER_COUNTS__.clear()
|
|
window.__ATOM_CHURN__.clear()
|
|
return 'cleaned'
|
|
})()
|
|
`
|
|
|
|
export default {
|
|
name: 'render-churn',
|
|
tier: 'ci',
|
|
description: 'N streaming tabs: per-component render attribution + store churn.',
|
|
async run(cdp, opts = {}) {
|
|
const tiles = Number(opts.tiles ?? 5)
|
|
const seedTurns = Number(opts.turns ?? 20)
|
|
const tokens = Number(opts.tokens ?? 240)
|
|
// Matches STREAM_DELTA_FLUSH_MS — one publish per session per real flush.
|
|
const intervalMs = Number(opts.intervalMs ?? 33)
|
|
const chunk = opts.chunk ?? 'A streamed review sentence with **bold** and `code`.\n\n'
|
|
|
|
await cdp.send('Runtime.enable')
|
|
|
|
const ok = await cdp.eval(setup(tiles, seedTurns))
|
|
|
|
if (ok !== 'ok') {
|
|
throw new Error(
|
|
`render-churn setup failed (${ok}) — needs a dev renderer with src/debug installed ` +
|
|
'(the counters are aliased out of production builds unless VITE_PERF_PROBE=1).'
|
|
)
|
|
}
|
|
|
|
// Mount every tab (keep-alive mounts on first activation), then settle.
|
|
for (let n = 1; n <= tiles; n++) {
|
|
await cdp.eval(reveal(`churn-tile-${n}`))
|
|
await sleep(350)
|
|
}
|
|
|
|
// Let the app go quiet before recording, so boot/hydration commits that
|
|
// happen to still be in flight don't land in the streaming window. This is
|
|
// what makes runs comparable — a fixed sleep let 2-4x of hydration churn
|
|
// leak in depending on machine load.
|
|
const settle = await cdp.eval(quiesce(600, 15000))
|
|
await cdp.eval(START)
|
|
await cdp.eval(drive(chunk, intervalMs, tokens))
|
|
await sleep(tokens * intervalMs + 1500)
|
|
|
|
const data = JSON.parse(await cdp.eval(COLLECT))
|
|
await cdp.eval(CLEANUP)
|
|
|
|
const byName = new Map(data.renders.map(r => [r.name, r]))
|
|
const sidebarRows = SIDEBAR_COMPONENTS.map(n => byName.get(n)).filter(Boolean)
|
|
const sidebarRenders = sidebarRows.reduce((a, r) => a + r.renders, 0)
|
|
const sidebarWasted = sidebarRows.reduce((a, r) => a + r.wasted, 0)
|
|
const totalRenders = data.renders.reduce((a, r) => a + r.renders, 0)
|
|
const totalWasted = data.renders.reduce((a, r) => a + r.wasted, 0)
|
|
const atomWasted = data.atoms.reduce((a, r) => a + r.wasted, 0)
|
|
|
|
return {
|
|
metrics: {
|
|
// The hypothesis, as a number: sidebar renders while background tabs
|
|
// stream. Should be 0.
|
|
sidebar_renders: sidebarRenders,
|
|
sidebar_wasted: sidebarWasted,
|
|
// Renders with no changed props and no changed hook state — pure
|
|
// parent-driven work, across the whole tree.
|
|
wasted_renders: totalWasted,
|
|
total_renders: totalRenders,
|
|
commits: data.commits,
|
|
// Store notifications that published a value equal to the last one.
|
|
wasted_notifies: atomWasted
|
|
},
|
|
detail: {
|
|
tiles,
|
|
tokens,
|
|
// 'quiet:N' = the app went idle before recording (comparable run).
|
|
// 'timeout:N' = it never did, so boot churn is mixed into the numbers.
|
|
settle,
|
|
sidebar: sidebarRows,
|
|
topRenders: data.renders.slice(0, 15),
|
|
topAtoms: data.atoms.slice(0, 15)
|
|
}
|
|
}
|
|
}
|
|
}
|