mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
Reduce Orca idle CPU (#5402)
* Reduce Orca idle CPU Co-authored-by: Orca <help@stably.ai> * Restore low-cost working spinner Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
export async function installSyntheticVisibleSpinners(page, count, animation, steps) {
|
||||
if (count <= 0) {
|
||||
return
|
||||
}
|
||||
const animationTiming =
|
||||
animation === 'steps' ? `1s steps(${steps}, end) infinite` : '1s linear infinite'
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
@keyframes orca-idle-bench-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.orca-idle-bench-spinner-host {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 2147483647;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.orca-idle-bench-spinner {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 2px solid rgb(234 179 8);
|
||||
border-top-color: transparent;
|
||||
border-radius: 9999px;
|
||||
animation: orca-idle-bench-spin ${animationTiming};
|
||||
}
|
||||
`
|
||||
})
|
||||
await page.evaluate((spinnerCount) => {
|
||||
document.querySelector('[data-orca-idle-bench-spinners]')?.remove()
|
||||
const host = document.createElement('div')
|
||||
host.className = 'orca-idle-bench-spinner-host'
|
||||
host.setAttribute('data-orca-idle-bench-spinners', String(spinnerCount))
|
||||
for (let index = 0; index < spinnerCount; index += 1) {
|
||||
const spinner = document.createElement('div')
|
||||
spinner.className = 'orca-idle-bench-spinner'
|
||||
host.appendChild(spinner)
|
||||
}
|
||||
document.body.appendChild(host)
|
||||
}, count)
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
#!/usr/bin/env node
|
||||
import { _electron as electron } from '@stablyai/playwright-test'
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { installSyntheticVisibleSpinners } from './idle-cpu-synthetic-spinners.mjs'
|
||||
|
||||
const DEFAULT_WARMUP_MS = 15_000
|
||||
const DEFAULT_SAMPLE_MS = 30_000
|
||||
const DEFAULT_INTERVAL_MS = 1_000
|
||||
const DEFAULT_WORKTREE_COUNT = 1
|
||||
const ONBOARDING_FINAL_STEP = 3
|
||||
const ONBOARDING_FLOW_VERSION = 2
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
warmupMs: DEFAULT_WARMUP_MS,
|
||||
sampleMs: DEFAULT_SAMPLE_MS,
|
||||
intervalMs: DEFAULT_INTERVAL_MS,
|
||||
worktrees: DEFAULT_WORKTREE_COUNT,
|
||||
skipBuild: false,
|
||||
headful: false,
|
||||
output: null,
|
||||
disableRendererAnimations: false,
|
||||
syntheticVisibleSpinners: 0,
|
||||
syntheticSpinnerAnimation: 'smooth',
|
||||
syntheticSpinnerSteps: 12
|
||||
}
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
const readValue = () => {
|
||||
const value = argv[index + 1]
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error(`Missing value for ${arg}`)
|
||||
}
|
||||
index += 1
|
||||
return value
|
||||
}
|
||||
if (arg === '--') {
|
||||
continue
|
||||
} else if (arg === '--warmup-ms') {
|
||||
options.warmupMs = Number(readValue())
|
||||
} else if (arg === '--sample-ms') {
|
||||
options.sampleMs = Number(readValue())
|
||||
} else if (arg === '--interval-ms') {
|
||||
options.intervalMs = Number(readValue())
|
||||
} else if (arg === '--worktrees') {
|
||||
options.worktrees = Number(readValue())
|
||||
} else if (arg === '--output') {
|
||||
options.output = readValue()
|
||||
} else if (arg === '--skip-build') {
|
||||
options.skipBuild = true
|
||||
} else if (arg === '--headful') {
|
||||
options.headful = true
|
||||
} else if (arg === '--disable-renderer-animations') {
|
||||
options.disableRendererAnimations = true
|
||||
} else if (arg === '--synthetic-visible-spinners') {
|
||||
options.syntheticVisibleSpinners = Number(readValue())
|
||||
} else if (arg === '--synthetic-spinner-animation') {
|
||||
options.syntheticSpinnerAnimation = readValue()
|
||||
} else if (arg === '--synthetic-spinner-steps') {
|
||||
options.syntheticSpinnerSteps = Number(readValue())
|
||||
} else if (arg === '--help') {
|
||||
printUsage()
|
||||
process.exit(0)
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`)
|
||||
}
|
||||
}
|
||||
for (const key of [
|
||||
'warmupMs',
|
||||
'sampleMs',
|
||||
'intervalMs',
|
||||
'worktrees',
|
||||
'syntheticVisibleSpinners',
|
||||
'syntheticSpinnerSteps'
|
||||
]) {
|
||||
if (!Number.isFinite(options[key]) || options[key] < 0) {
|
||||
throw new Error(`Invalid --${key}: ${options[key]}`)
|
||||
}
|
||||
}
|
||||
options.worktrees = Math.max(1, Math.floor(options.worktrees))
|
||||
options.intervalMs = Math.max(250, Math.floor(options.intervalMs))
|
||||
options.syntheticVisibleSpinners = Math.max(0, Math.floor(options.syntheticVisibleSpinners))
|
||||
options.syntheticSpinnerSteps = Math.max(1, Math.floor(options.syntheticSpinnerSteps))
|
||||
if (!['smooth', 'steps'].includes(options.syntheticSpinnerAnimation)) {
|
||||
throw new Error(`Invalid --synthetic-spinner-animation: ${options.syntheticSpinnerAnimation}`)
|
||||
}
|
||||
return options
|
||||
}
|
||||
function printUsage() {
|
||||
console.log(
|
||||
`Usage: node config/scripts/run-idle-cpu-benchmark.mjs [options]\n\nOptions:\n --warmup-ms <n> Time to wait after app readiness before sampling (default ${DEFAULT_WARMUP_MS})\n --sample-ms <n> Sampling window duration (default ${DEFAULT_SAMPLE_MS})\n --interval-ms <n> Sampling cadence (default ${DEFAULT_INTERVAL_MS})\n --worktrees <n> Seed repo worktree count, including primary (default ${DEFAULT_WORKTREE_COUNT})\n --headful Show the Electron window while measuring\n --skip-build Reuse out/main/index.js instead of building first\n --output <path> Write JSON report to this path\n --disable-renderer-animations Inject measurement-only CSS that disables animations/transitions\n --synthetic-visible-spinners <n> Measurement-only: add visible working spinners\n --synthetic-spinner-animation <smooth|steps> Spinner animation style (default smooth)\n --synthetic-spinner-steps <n> Step count for --synthetic-spinner-animation steps (default 12)\n`
|
||||
)
|
||||
}
|
||||
function run(command, args, options = {}) {
|
||||
execFileSync(command, args, { stdio: options.stdio ?? 'pipe', encoding: 'utf8', ...options })
|
||||
}
|
||||
|
||||
function buildAppIfNeeded(root, skipBuild) {
|
||||
const mainPath = path.join(root, 'out', 'main', 'index.js')
|
||||
if (skipBuild && existsSync(mainPath)) {
|
||||
return mainPath
|
||||
}
|
||||
if (skipBuild) {
|
||||
throw new Error(`--skip-build requested, but ${mainPath} does not exist`)
|
||||
}
|
||||
console.log('[idle-cpu] building Electron app with electron-vite --mode e2e')
|
||||
run('npx', ['electron-vite', 'build', '--mode', 'e2e'], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, VITE_EXPOSE_STORE: 'true' }
|
||||
})
|
||||
return mainPath
|
||||
}
|
||||
|
||||
function makeCompletedOnboardingProfile() {
|
||||
return {
|
||||
settings: {
|
||||
telemetry: {
|
||||
optedIn: true,
|
||||
installId: '00000000-0000-4000-8000-000000000000',
|
||||
existedBeforeTelemetryRelease: false
|
||||
}
|
||||
},
|
||||
onboarding: {
|
||||
flowVersion: ONBOARDING_FLOW_VERSION,
|
||||
closedAt: 1,
|
||||
outcome: 'completed',
|
||||
lastCompletedStep: ONBOARDING_FINAL_STEP
|
||||
},
|
||||
ui: {
|
||||
contextualToursSeenIds: [
|
||||
'workspace-board',
|
||||
'browser',
|
||||
'tasks',
|
||||
'automations',
|
||||
'workspace-creation'
|
||||
],
|
||||
contextualToursAutoEligible: false,
|
||||
projectOrderManualDefaultNoticeDismissed: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createIdleRepo(worktreeCount) {
|
||||
const repoDir = mkdtempSync(path.join(os.tmpdir(), 'orca-idle-cpu-repo-'))
|
||||
const cleanupDirs = [repoDir]
|
||||
run('git', ['init'], { cwd: repoDir })
|
||||
run('git', ['config', 'user.email', 'idle-cpu@test.local'], { cwd: repoDir })
|
||||
run('git', ['config', 'user.name', 'Idle CPU Benchmark'], { cwd: repoDir })
|
||||
writeFileSync(path.join(repoDir, 'README.md'), '# Orca idle CPU benchmark\n')
|
||||
writeFileSync(
|
||||
path.join(repoDir, 'package.json'),
|
||||
`${JSON.stringify({ private: true }, null, 2)}\n`
|
||||
)
|
||||
mkdirSync(path.join(repoDir, 'src'), { recursive: true })
|
||||
writeFileSync(path.join(repoDir, 'src', 'index.ts'), 'export const idleBenchmark = true\n')
|
||||
run('git', ['add', '-A'], { cwd: repoDir })
|
||||
run('git', ['commit', '-m', 'Initial idle CPU fixture'], { cwd: repoDir })
|
||||
for (let i = 2; i <= worktreeCount; i += 1) {
|
||||
const worktreeDir = path.join(
|
||||
path.dirname(repoDir),
|
||||
`orca-idle-cpu-worktree-${i}-${Date.now()}`
|
||||
)
|
||||
cleanupDirs.push(worktreeDir)
|
||||
run('git', ['worktree', 'add', worktreeDir, '-b', `idle-cpu-${i}`], { cwd: repoDir })
|
||||
}
|
||||
return { repoDir, cleanupDirs }
|
||||
}
|
||||
|
||||
function launchArgs(mainPath, headful) {
|
||||
if (headful || process.platform !== 'linux') {
|
||||
return [mainPath]
|
||||
}
|
||||
return [
|
||||
'--disable-gpu',
|
||||
'--disable-gpu-compositing',
|
||||
'--disable-gpu-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--in-process-gpu',
|
||||
mainPath
|
||||
]
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function parseCpuTimeSeconds(value) {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
const [dayOrTime, maybeTime] = trimmed.includes('-') ? trimmed.split('-', 2) : [null, trimmed]
|
||||
const days = dayOrTime === null ? 0 : Number(dayOrTime)
|
||||
const parts = maybeTime.split(':').map(Number)
|
||||
if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) {
|
||||
return null
|
||||
}
|
||||
if (parts.length === 3) {
|
||||
return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return days * 86400 + parts[0] * 60 + parts[1]
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
return days * 86400 + parts[0]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseUnixProcesses(stdout) {
|
||||
const rows = []
|
||||
for (const raw of stdout.split('\n')) {
|
||||
const line = raw.trim()
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
const match = line.match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
rows.push({
|
||||
pid: Number(match[1]),
|
||||
ppid: Number(match[2]),
|
||||
percentCpu: Number(match[3]),
|
||||
rssBytes: Number(match[4]) * 1024,
|
||||
cpuTimeSeconds: parseCpuTimeSeconds(match[5]),
|
||||
command: match[6]
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function readUnixProcesses() {
|
||||
const stdout = execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu=,rss=,cputime=,command='], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
||||
maxBuffer: 20 * 1024 * 1024
|
||||
})
|
||||
return parseUnixProcesses(stdout)
|
||||
}
|
||||
|
||||
function readWindowsProcesses() {
|
||||
const script =
|
||||
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,CommandLine | ConvertTo-Json -Compress'
|
||||
const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 20 * 1024 * 1024
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || 'PowerShell process enumeration failed')
|
||||
}
|
||||
const parsed = JSON.parse(result.stdout || '[]')
|
||||
const entries = Array.isArray(parsed) ? parsed : [parsed]
|
||||
return entries.map((entry) => ({
|
||||
pid: Number(entry.ProcessId),
|
||||
ppid: Number(entry.ParentProcessId),
|
||||
percentCpu: 0,
|
||||
cpuTimeSeconds: null,
|
||||
rssBytes: Number(entry.WorkingSetSize) || 0,
|
||||
command: String(entry.CommandLine || '')
|
||||
}))
|
||||
}
|
||||
|
||||
function readProcessRows() {
|
||||
return process.platform === 'win32' ? readWindowsProcesses() : readUnixProcesses()
|
||||
}
|
||||
|
||||
function descendantsOf(rows, rootPid) {
|
||||
const children = new Map()
|
||||
for (const row of rows) {
|
||||
const list = children.get(row.ppid) ?? []
|
||||
list.push(row)
|
||||
children.set(row.ppid, list)
|
||||
}
|
||||
const result = []
|
||||
const stack = [rootPid]
|
||||
const seen = new Set()
|
||||
while (stack.length > 0) {
|
||||
const pid = stack.pop()
|
||||
if (seen.has(pid)) {
|
||||
continue
|
||||
}
|
||||
seen.add(pid)
|
||||
const row = rows.find((candidate) => candidate.pid === pid)
|
||||
if (row) {
|
||||
result.push(row)
|
||||
}
|
||||
for (const child of children.get(pid) ?? []) {
|
||||
stack.push(child.pid)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function classify(row, rootPid) {
|
||||
const command = row.command.toLowerCase()
|
||||
if (row.pid === rootPid) {
|
||||
return 'main'
|
||||
}
|
||||
if (command.includes('daemon-entry')) {
|
||||
return 'daemon'
|
||||
}
|
||||
if (command.includes('--type=gpu-process')) {
|
||||
return 'gpu'
|
||||
}
|
||||
if (command.includes('--type=renderer')) {
|
||||
return 'renderer'
|
||||
}
|
||||
if (command.includes('--type=utility')) {
|
||||
return 'utility'
|
||||
}
|
||||
if (command.includes('--type=')) {
|
||||
return 'electron-other'
|
||||
}
|
||||
if (command.includes('node') || command.includes('/pi') || command.endsWith(' pi')) {
|
||||
return 'agent-or-node'
|
||||
}
|
||||
return 'other-descendant'
|
||||
}
|
||||
|
||||
async function collectRendererIdleState(page) {
|
||||
return page.evaluate(() => {
|
||||
const describeElement = (element) => {
|
||||
if (!(element instanceof Element)) {
|
||||
return null
|
||||
}
|
||||
const classes = typeof element.className === 'string' ? element.className : ''
|
||||
const testId = element.getAttribute('data-testid')
|
||||
const label = element.getAttribute('aria-label')
|
||||
return {
|
||||
tag: element.tagName.toLowerCase(),
|
||||
id: element.id || null,
|
||||
testId,
|
||||
label,
|
||||
classes: classes.split(/\s+/).filter(Boolean).slice(0, 12),
|
||||
text: (element.textContent || '').trim().slice(0, 80)
|
||||
}
|
||||
}
|
||||
const animations = document.getAnimations({ subtree: true }).map((animation) => {
|
||||
const effect = animation.effect
|
||||
const target = effect instanceof KeyframeEffect ? effect.target : null
|
||||
return {
|
||||
playState: animation.playState,
|
||||
currentTime: typeof animation.currentTime === 'number' ? animation.currentTime : null,
|
||||
playbackRate: animation.playbackRate,
|
||||
duration:
|
||||
effect instanceof KeyframeEffect && typeof effect.getTiming().duration === 'number'
|
||||
? effect.getTiming().duration
|
||||
: null,
|
||||
iterations: effect instanceof KeyframeEffect ? effect.getTiming().iterations : null,
|
||||
target: describeElement(target)
|
||||
}
|
||||
})
|
||||
return {
|
||||
visibilityState: document.visibilityState,
|
||||
runningAnimationCount: animations.filter((animation) => animation.playState === 'running')
|
||||
.length,
|
||||
animations: animations.slice(0, 80)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function summarizeSamples(samples) {
|
||||
const byKind = new Map()
|
||||
for (const sample of samples) {
|
||||
for (const proc of sample.processes) {
|
||||
const bucket = byKind.get(proc.kind) ?? { cpuValues: [], rssValues: [], maxProcessCount: 0 }
|
||||
bucket.cpuValues.push(proc.cpu)
|
||||
bucket.rssValues.push(proc.rssBytes)
|
||||
byKind.set(proc.kind, bucket)
|
||||
}
|
||||
const counts = new Map()
|
||||
for (const proc of sample.processes) {
|
||||
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
|
||||
}
|
||||
for (const [kind, count] of counts) {
|
||||
byKind.get(kind).maxProcessCount = Math.max(byKind.get(kind).maxProcessCount, count)
|
||||
}
|
||||
}
|
||||
const summary = {}
|
||||
for (const [kind, values] of byKind) {
|
||||
const cpuSorted = [...values.cpuValues].sort((a, b) => a - b)
|
||||
const rssSumBySample = samples.map((sample) =>
|
||||
sample.processes
|
||||
.filter((proc) => proc.kind === kind)
|
||||
.reduce((sum, proc) => sum + proc.rssBytes, 0)
|
||||
)
|
||||
summary[kind] = {
|
||||
meanCpuPercent: mean(values.cpuValues),
|
||||
p95CpuPercent: percentile(cpuSorted, 0.95),
|
||||
maxCpuPercent: Math.max(0, ...values.cpuValues),
|
||||
meanRssBytes: mean(rssSumBySample),
|
||||
maxProcessCount: values.maxProcessCount
|
||||
}
|
||||
}
|
||||
summary.total = {
|
||||
meanCpuPercent: mean(samples.map((sample) => sample.totalCpuPercent)),
|
||||
p95CpuPercent: percentile(
|
||||
samples.map((sample) => sample.totalCpuPercent).sort((a, b) => a - b),
|
||||
0.95
|
||||
),
|
||||
meanRssBytes: mean(samples.map((sample) => sample.totalRssBytes))
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
function summarizeProcessInventory(samples) {
|
||||
const inventory = {}
|
||||
for (const sample of samples) {
|
||||
const counts = new Map()
|
||||
for (const proc of sample.processes) {
|
||||
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
|
||||
const entry = inventory[proc.kind] ?? {
|
||||
maxProcessCount: 0,
|
||||
maxCpuPercent: 0,
|
||||
commandSamples: []
|
||||
}
|
||||
entry.maxCpuPercent = Math.max(entry.maxCpuPercent, proc.cpu)
|
||||
if (!entry.commandSamples.includes(proc.command) && entry.commandSamples.length < 6) {
|
||||
entry.commandSamples.push(proc.command)
|
||||
}
|
||||
inventory[proc.kind] = entry
|
||||
}
|
||||
for (const [kind, count] of counts) {
|
||||
inventory[kind].maxProcessCount = Math.max(inventory[kind].maxProcessCount, count)
|
||||
}
|
||||
}
|
||||
return inventory
|
||||
}
|
||||
function mean(values) {
|
||||
return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
}
|
||||
|
||||
function percentile(sorted, fraction) {
|
||||
if (sorted.length === 0) {
|
||||
return 0
|
||||
}
|
||||
const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)
|
||||
return sorted[index]
|
||||
}
|
||||
|
||||
function terminateProcesses(processes) {
|
||||
for (const proc of processes) {
|
||||
try {
|
||||
process.kill(proc.pid)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
|
||||
const mainPath = buildAppIfNeeded(root, options.skipBuild)
|
||||
const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-idle-cpu-userdata-'))
|
||||
const { repoDir, cleanupDirs } = createIdleRepo(options.worktrees)
|
||||
writeFileSync(
|
||||
path.join(userDataDir, 'orca-data.json'),
|
||||
`${JSON.stringify(makeCompletedOnboardingProfile(), null, 2)}\n`
|
||||
)
|
||||
const { ELECTRON_RUN_AS_NODE, ...cleanEnv } = process.env
|
||||
void ELECTRON_RUN_AS_NODE
|
||||
const app = await electron.launch({
|
||||
args: launchArgs(mainPath, options.headful),
|
||||
env: {
|
||||
...cleanEnv,
|
||||
NODE_ENV: 'development',
|
||||
ORCA_E2E_USER_DATA_DIR: userDataDir,
|
||||
...(options.headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' })
|
||||
}
|
||||
})
|
||||
const rootPid = app.process().pid
|
||||
try {
|
||||
const page = await app.firstWindow({ timeout: 120_000 })
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 })
|
||||
const measurementCss = []
|
||||
if (options.disableRendererAnimations) {
|
||||
measurementCss.push(
|
||||
'*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}'
|
||||
)
|
||||
}
|
||||
if (measurementCss.length > 0) {
|
||||
await page.addStyleTag({ content: measurementCss.join('\n') })
|
||||
}
|
||||
await installSyntheticVisibleSpinners(
|
||||
page,
|
||||
options.syntheticVisibleSpinners,
|
||||
options.syntheticSpinnerAnimation,
|
||||
options.syntheticSpinnerSteps
|
||||
)
|
||||
await page.evaluate(async (repoPath) => {
|
||||
await window.api.repos.add({ path: repoPath })
|
||||
const store = window.__store
|
||||
await store?.getState().fetchRepos()
|
||||
const repo = store?.getState().repos.find((candidate) => candidate.path === repoPath)
|
||||
if (repo) {
|
||||
await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' })
|
||||
await store.getState().fetchWorktrees(repo.id)
|
||||
}
|
||||
}, repoDir)
|
||||
await page.waitForFunction(
|
||||
() => window.__store?.getState().workspaceSessionReady === true,
|
||||
null,
|
||||
{ timeout: 60_000 }
|
||||
)
|
||||
console.log(
|
||||
`[idle-cpu] root pid=${rootPid}; warmup=${options.warmupMs}ms sample=${options.sampleMs}ms interval=${options.intervalMs}ms worktrees=${options.worktrees}`
|
||||
)
|
||||
await sleep(options.warmupMs)
|
||||
const rendererIdleState = await collectRendererIdleState(page)
|
||||
const deadline = Date.now() + options.sampleMs
|
||||
const samples = []
|
||||
let previousSnapshot = null
|
||||
while (Date.now() <= deadline || samples.length === 0) {
|
||||
const sampledAt = Date.now()
|
||||
const processRows = descendantsOf(readProcessRows(), rootPid)
|
||||
const rawProcesses = processRows.map((row) => ({ ...row, kind: classify(row, rootPid) }))
|
||||
if (previousSnapshot) {
|
||||
const elapsedSeconds = Math.max(0.001, (sampledAt - previousSnapshot.at) / 1000)
|
||||
const previousByPid = new Map(previousSnapshot.processes.map((proc) => [proc.pid, proc]))
|
||||
const processes = rawProcesses.map((row) => {
|
||||
const previous = previousByPid.get(row.pid)
|
||||
const canComputeDelta =
|
||||
typeof row.cpuTimeSeconds === 'number' && typeof previous?.cpuTimeSeconds === 'number'
|
||||
const cpu = canComputeDelta
|
||||
? Math.max(0, ((row.cpuTimeSeconds - previous.cpuTimeSeconds) / elapsedSeconds) * 100)
|
||||
: row.percentCpu
|
||||
return { ...row, cpu }
|
||||
})
|
||||
samples.push({
|
||||
at: sampledAt,
|
||||
elapsedMs: sampledAt - previousSnapshot.at,
|
||||
totalCpuPercent: processes.reduce((sum, proc) => sum + proc.cpu, 0),
|
||||
totalRssBytes: processes.reduce((sum, proc) => sum + proc.rssBytes, 0),
|
||||
processes
|
||||
})
|
||||
}
|
||||
previousSnapshot = { at: sampledAt, processes: rawProcesses }
|
||||
await sleep(options.intervalMs)
|
||||
}
|
||||
const report = {
|
||||
benchmark: 'orca-idle-cpu',
|
||||
createdAt: new Date().toISOString(),
|
||||
options,
|
||||
rootPid,
|
||||
platform: { platform: process.platform, arch: process.arch, cpus: os.cpus().length },
|
||||
rendererIdleState,
|
||||
sampleCount: samples.length,
|
||||
summary: summarizeSamples(samples),
|
||||
processInventory: summarizeProcessInventory(samples),
|
||||
samples
|
||||
}
|
||||
if (options.output) {
|
||||
mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true })
|
||||
writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`)
|
||||
console.log(`[idle-cpu] wrote ${options.output}`)
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
summary: report.summary,
|
||||
processInventory: report.processInventory,
|
||||
sampleCount: report.sampleCount
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
const launchedProcesses = descendantsOf(readProcessRows(), rootPid).filter(
|
||||
(proc) => proc.pid !== rootPid
|
||||
)
|
||||
await app.close().catch(() => undefined)
|
||||
await sleep(250)
|
||||
terminateProcesses(launchedProcesses)
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
for (const dir of cleanupDirs) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
+2
-1
@@ -79,7 +79,8 @@
|
||||
"test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs",
|
||||
"test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs",
|
||||
"test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful",
|
||||
"test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts"
|
||||
"test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts",
|
||||
"bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
|
||||
@@ -49,7 +49,6 @@ import { registerCodexAccountHandlers } from './codex-accounts'
|
||||
import { registerAgentHookHandlers } from './agent-hooks'
|
||||
import { registerAgentTrustHandlers } from './agent-trust'
|
||||
import { registerClaudeAccountHandlers } from './claude-accounts'
|
||||
import { warmSystemFontFamilies } from '../system-fonts'
|
||||
import { registerUpdaterHandlers } from '../window/attach-main-window-services'
|
||||
import { registerClipboardHandlers } from '../window/clipboard-ipc-handlers'
|
||||
import type { ClaudeUsageStore } from '../claude-usage/store'
|
||||
@@ -163,5 +162,4 @@ export function registerCoreHandlers(
|
||||
registerClipboardHandlers()
|
||||
registerUpdaterHandlers(store)
|
||||
registerSpeechHandlers(store)
|
||||
warmSystemFontFamilies()
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ describe('RateLimitService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('can defer the startup fetch until the attached window becomes active', async () => {
|
||||
it('does not turn the initial window activation into a hidden startup quota fetch', async () => {
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 12))
|
||||
vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24))
|
||||
const service = new RateLimitService()
|
||||
@@ -205,14 +205,17 @@ describe('RateLimitService', () => {
|
||||
service.start({ fetchImmediately: false })
|
||||
await Promise.resolve()
|
||||
|
||||
window.emit('show')
|
||||
window.emit('focus')
|
||||
window.emit('restore')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(fetchClaudeRateLimits).not.toHaveBeenCalled()
|
||||
expect(fetchCodexRateLimits).not.toHaveBeenCalled()
|
||||
|
||||
window.emit('show')
|
||||
await service.refresh()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
|
||||
|
||||
service.stop()
|
||||
|
||||
@@ -80,6 +80,7 @@ export class RateLimitService {
|
||||
private codexOnlyFetchQueued = false
|
||||
private claudeOnlyFetchQueued = false
|
||||
private fetchIdleResolvers: (() => void)[] = []
|
||||
private hasCompletedFetch = false
|
||||
private codexFetchGeneration = 0
|
||||
private claudeFetchGeneration = 0
|
||||
private opencodeFetchGeneration = 0
|
||||
@@ -556,6 +557,9 @@ export class RateLimitService {
|
||||
if (!this.shouldBackgroundPoll()) {
|
||||
return
|
||||
}
|
||||
if (!this.hasCompletedFetch) {
|
||||
return
|
||||
}
|
||||
if (Date.now() - this.lastFetchAt < MIN_REFETCH_MS) {
|
||||
return
|
||||
}
|
||||
@@ -592,6 +596,7 @@ export class RateLimitService {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.hasCompletedFetch = true
|
||||
this.isFetching = false
|
||||
this.resolveFetchIdleWaiters()
|
||||
}
|
||||
@@ -627,6 +632,7 @@ export class RateLimitService {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.hasCompletedFetch = true
|
||||
this.isFetching = false
|
||||
this.resolveFetchIdleWaiters()
|
||||
}
|
||||
@@ -662,6 +668,7 @@ export class RateLimitService {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.hasCompletedFetch = true
|
||||
this.isFetching = false
|
||||
this.resolveFetchIdleWaiters()
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('CmdJPaletteFeatureTipVisual', () => {
|
||||
expect(html).toContain('auth-redirect')
|
||||
expect(html).not.toContain('payments-api')
|
||||
expect(html).not.toContain('animate-spin')
|
||||
expect(html).not.toContain('animate-cmd-j-tip-caret')
|
||||
expect(html).not.toContain('animate-cmd-j-tip-result-in')
|
||||
})
|
||||
|
||||
@@ -83,6 +84,31 @@ describe('CmdJPaletteFeatureTipVisual', () => {
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('settles after the one-shot demo instead of looping idle timers', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const { container, root } = await renderVisual()
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain('auth')
|
||||
expect(container.textContent).toContain('auth-redirect')
|
||||
expect(container.textContent).not.toContain('payments-api')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
await act(async () => {
|
||||
root.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not render infinite animation classes in the default preview', () => {
|
||||
const html = renderToStaticMarkup(<CmdJPaletteFeatureTipVisual />)
|
||||
|
||||
expect(html).not.toContain('animate-spin')
|
||||
expect(html).not.toContain('animate-cmd-j-tip-caret')
|
||||
})
|
||||
|
||||
it('falls back to default per-key chips when the live binding is unassigned', () => {
|
||||
shortcutKeysMock.mockReturnValue([])
|
||||
formatShortcutKeysMock.mockReturnValue(['Ctrl', 'Shift', 'J'])
|
||||
|
||||
@@ -34,13 +34,7 @@ function filterDemoWorktrees(query: string): typeof DEMO_WORKTREES {
|
||||
// Why: cycle phases are sequenced so the keypress visibly precedes the palette
|
||||
// opening (cause → effect), matching what the user will see when they actually
|
||||
// press the shortcut.
|
||||
type CyclePhase = 'idle' | 'pressed' | 'open' | 'typing' | 'closing'
|
||||
|
||||
type ClosingFrame = {
|
||||
query: string
|
||||
worktrees: ReturnType<typeof filterDemoWorktrees>
|
||||
showCreate: boolean
|
||||
}
|
||||
type CyclePhase = 'idle' | 'pressed' | 'open' | 'typing'
|
||||
|
||||
const KEYPRESS_AT_MS = 450
|
||||
const PALETTE_OPEN_AT_MS = 850
|
||||
@@ -50,12 +44,6 @@ const HOLD_BEFORE_TYPING_MS = 700
|
||||
// Per-character typing interval. Kept tight and constant so the cursor advances
|
||||
// at an even cadence instead of feeling staggered.
|
||||
const TYPE_INTERVAL_MS = 120
|
||||
// Pause on the final, filtered state before the cycle resets, so the
|
||||
// user has time to actually read the matched worktrees + create option.
|
||||
const HOLD_AFTER_RESULTS_MS = 3200
|
||||
// Matches the palette container's `duration-300` fade plus a small buffer so we
|
||||
// never swap list content while the closing fade is still running.
|
||||
const PALETTE_FADE_OUT_MS = 350
|
||||
|
||||
export function CmdJPaletteFeatureTipVisual(): JSX.Element {
|
||||
const reducedMotion = usePrefersReducedMotion()
|
||||
@@ -69,7 +57,6 @@ export function CmdJPaletteFeatureTipVisual(): JSX.Element {
|
||||
|
||||
const [phase, setPhase] = useState<CyclePhase>('idle')
|
||||
const [typedLength, setTypedLength] = useState(0)
|
||||
const [closingFrame, setClosingFrame] = useState<ClosingFrame | null>(null)
|
||||
|
||||
// Why: for reduced-motion users, jump straight to the fully-populated end
|
||||
// state so they see what the feature does without any animation.
|
||||
@@ -78,23 +65,16 @@ export function CmdJPaletteFeatureTipVisual(): JSX.Element {
|
||||
const currentQuery = TYPED_QUERY.slice(0, effectiveTypedLength)
|
||||
const visibleWorktrees = filterDemoWorktrees(currentQuery)
|
||||
const showCreateAction = currentQuery.trim().length > 0
|
||||
// Why: snapshot the final filtered frame during `closing` so loop reset never
|
||||
// re-renders the empty-query worktree list while the palette is still visible.
|
||||
const renderQuery = phase === 'closing' && closingFrame ? closingFrame.query : currentQuery
|
||||
const renderWorktrees =
|
||||
phase === 'closing' && closingFrame ? closingFrame.worktrees : visibleWorktrees
|
||||
const renderShowCreate =
|
||||
phase === 'closing' && closingFrame ? closingFrame.showCreate : showCreateAction
|
||||
const renderQuery = currentQuery
|
||||
const renderWorktrees = visibleWorktrees
|
||||
const renderShowCreate = showCreateAction
|
||||
// Why: mirror WorktreeJumpPalette — recent worktrees render as soon as the
|
||||
// palette opens; typing only filters them down. Keep the list mounted through
|
||||
// `closing` so the final filtered frame fades out with the palette.
|
||||
const showWorktreeList =
|
||||
reducedMotion || phase === 'open' || phase === 'typing' || phase === 'closing'
|
||||
// Why: keep the palette hidden during `pressed` — an empty search shell between
|
||||
// cycles read as the pre-search list flashing back before the fade finished.
|
||||
const paletteMounted =
|
||||
reducedMotion || phase === 'open' || phase === 'typing' || phase === 'closing'
|
||||
const paletteOpaque = reducedMotion || (paletteMounted && phase !== 'closing')
|
||||
// palette opens; typing only filters them down.
|
||||
const showWorktreeList = reducedMotion || phase === 'open' || phase === 'typing'
|
||||
// Why: keep the palette hidden during `pressed` so the keypress visibly
|
||||
// precedes the palette opening.
|
||||
const paletteMounted = reducedMotion || phase === 'open' || phase === 'typing'
|
||||
const paletteOpaque = reducedMotion || paletteMounted
|
||||
const resultEnterClass =
|
||||
showWorktreeList && !reducedMotion && phase === 'open' ? 'animate-cmd-j-tip-result-in' : ''
|
||||
|
||||
@@ -118,45 +98,27 @@ export function CmdJPaletteFeatureTipVisual(): JSX.Element {
|
||||
i += 1
|
||||
setTypedLength(i)
|
||||
if (i >= TYPED_QUERY.length) {
|
||||
later(() => closeAndRestart(), HOLD_AFTER_RESULTS_MS)
|
||||
return
|
||||
}
|
||||
timeouts.push(window.setTimeout(typeNext, TYPE_INTERVAL_MS))
|
||||
}
|
||||
if (i >= TYPED_QUERY.length) {
|
||||
later(() => closeAndRestart(), HOLD_AFTER_RESULTS_MS)
|
||||
return
|
||||
}
|
||||
later(typeNext, TYPE_INTERVAL_MS)
|
||||
}
|
||||
|
||||
const scheduleCycle = (): void => {
|
||||
later(() => setPhase('pressed'), KEYPRESS_AT_MS)
|
||||
later(() => setPhase('open'), PALETTE_OPEN_AT_MS)
|
||||
later(() => {
|
||||
setPhase('typing')
|
||||
startTyping(0)
|
||||
}, PALETTE_OPEN_AT_MS + HOLD_BEFORE_TYPING_MS)
|
||||
}
|
||||
|
||||
const closeAndRestart = (): void => {
|
||||
setClosingFrame({
|
||||
query: TYPED_QUERY,
|
||||
worktrees: filterDemoWorktrees(TYPED_QUERY),
|
||||
showCreate: true
|
||||
})
|
||||
setPhase('closing')
|
||||
later(() => {
|
||||
setPhase('idle')
|
||||
setClosingFrame(null)
|
||||
setTypedLength(0)
|
||||
scheduleCycle()
|
||||
}, PALETTE_FADE_OUT_MS)
|
||||
}
|
||||
// Why: this tip may remain open while Orca is idle. Play the demo once,
|
||||
// then settle on the final useful state instead of looping timers forever.
|
||||
later(() => setPhase('pressed'), KEYPRESS_AT_MS)
|
||||
later(() => setPhase('open'), PALETTE_OPEN_AT_MS)
|
||||
later(() => {
|
||||
setPhase('typing')
|
||||
startTyping(0)
|
||||
}, PALETTE_OPEN_AT_MS + HOLD_BEFORE_TYPING_MS)
|
||||
|
||||
setPhase('idle')
|
||||
setTypedLength(0)
|
||||
scheduleCycle()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
@@ -210,7 +172,9 @@ export function CmdJPaletteFeatureTipVisual(): JSX.Element {
|
||||
<span className="block truncate">
|
||||
{renderQuery}
|
||||
{!reducedMotion && (phase === 'open' || phase === 'typing') ? (
|
||||
<span className="ml-px inline-block h-[14px] w-px -translate-y-px align-middle bg-foreground/75 animate-cmd-j-tip-caret" />
|
||||
// Why: this tip can sit open while Orca is idle; keep the
|
||||
// caret static so the preview does not wake the compositor.
|
||||
<span className="ml-px inline-block h-[14px] w-px -translate-y-px align-middle bg-foreground/75" />
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
@@ -227,14 +191,9 @@ export function CmdJPaletteFeatureTipVisual(): JSX.Element {
|
||||
{result.status === 'done' ? (
|
||||
<span className="size-2.5 rounded-full bg-emerald-500" aria-hidden="true" />
|
||||
) : (
|
||||
// Why: yellow border spinner mirrors StatusIndicator's
|
||||
// 'working' affordance, so users connect the icon to the
|
||||
// same running-workspace state they see in the sidebar.
|
||||
<span
|
||||
className={`block size-2.5 rounded-full border-[1.5px] border-yellow-500 ${
|
||||
reducedMotion ? 'border-t-yellow-500' : 'animate-spin border-t-transparent'
|
||||
}`}
|
||||
/>
|
||||
// Why: this tip can stay mounted while Orca is idle; mirror
|
||||
// the sidebar's static working ring instead of spinning.
|
||||
<span className="block size-2.5 rounded-full border-[1.5px] border-yellow-500 bg-yellow-500/15" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -17,12 +17,13 @@ function renderDotClassNames(status: Status): string[] {
|
||||
}
|
||||
|
||||
describe('StatusIndicator', () => {
|
||||
it('renders working as a yellow spinner', () => {
|
||||
it('renders working as a stepped yellow spinner', () => {
|
||||
const classNames = renderDotClassNames('working')
|
||||
|
||||
expect(classNames).toContain('border-yellow-500')
|
||||
expect(classNames).toContain('border-t-transparent')
|
||||
expect(classNames).toContain('animate-spin')
|
||||
expect(classNames).toContain('[animation:spin_1s_steps(12,end)_infinite]')
|
||||
expect(classNames).not.toContain('animate-spin')
|
||||
})
|
||||
|
||||
it('renders permission as an amber attention dot', () => {
|
||||
|
||||
@@ -33,7 +33,9 @@ const StatusIndicator = React.memo(function StatusIndicator({
|
||||
title={resolvedTitle}
|
||||
{...rest}
|
||||
>
|
||||
<span className="block size-2 rounded-full border-2 border-yellow-500 border-t-transparent animate-spin" />
|
||||
{/* Why: a stepped spin preserves the worker-is-running affordance while
|
||||
avoiding a full-refresh-rate compositor loop for long agent runs. */}
|
||||
<span className="block size-2 rounded-full border-2 border-yellow-500 border-t-transparent [animation:spin_1s_steps(12,end)_infinite]" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -52,8 +54,7 @@ const StatusIndicator = React.memo(function StatusIndicator({
|
||||
: status === 'done' || status === 'active'
|
||||
? // Green dot for both hook-reported 'done' and the heuristic
|
||||
// 'active' (terminal open, quiet). Working uses a yellow
|
||||
// spinner so working vs done differ by motion; 'inactive'
|
||||
// stays grey.
|
||||
// ring above; 'inactive' stays grey.
|
||||
'bg-emerald-500'
|
||||
: 'bg-neutral-500/40'
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user