From f7fb6ef686dde2ea401e990d31f5a6b16990c359 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:14:15 -0700 Subject: [PATCH] Expose memory diagnostics through CLI (#4408) --- docs/renderer-memory-profile-2026-06-01.md | 13 ++++ src/cli/args.ts | 16 +++-- src/cli/dispatch.ts | 2 + src/cli/format.ts | 64 +++++++++++++++++++ src/cli/handlers/diagnostics.ts | 10 +++ src/cli/help.ts | 5 ++ src/cli/index.test.ts | 56 ++++++++++++++++ src/cli/specs/diagnostics.ts | 15 +++++ src/cli/specs/index.ts | 2 + src/main/memory/collector.test.ts | 4 +- src/main/memory/collector.ts | 8 ++- src/main/runtime/orca-runtime.ts | 9 +++ .../runtime/rpc/methods/diagnostics.test.ts | 49 ++++++++++++++ src/main/runtime/rpc/methods/diagnostics.ts | 11 ++++ src/main/runtime/rpc/methods/index.ts | 2 + src/main/runtime/runtime-rpc.ts | 1 + 16 files changed, 258 insertions(+), 9 deletions(-) create mode 100644 src/cli/handlers/diagnostics.ts create mode 100644 src/cli/specs/diagnostics.ts create mode 100644 src/main/runtime/rpc/methods/diagnostics.test.ts create mode 100644 src/main/runtime/rpc/methods/diagnostics.ts diff --git a/docs/renderer-memory-profile-2026-06-01.md b/docs/renderer-memory-profile-2026-06-01.md index f8e321a881b..e9d28282b4c 100644 --- a/docs/renderer-memory-profile-2026-06-01.md +++ b/docs/renderer-memory-profile-2026-06-01.md @@ -89,3 +89,16 @@ forcing profiling to use the packaged CLI fallback. The follow-up fix teaches the installer to recognize only generated Orca Unix launcher files as stale and replaceable. Arbitrary regular files at the command path remain conflicts. + +## Follow-up: Repeatable Memory Diagnostics + +The next profiling blocker was repeatability: collecting a useful memory sample +still required combining Resource Usage IPC, terminal lists, browser tab state, +and host process output by hand. This branch adds `orca diagnostics memory`, +which exposes the existing main-process memory collector through runtime RPC. + +The command returns the same `MemorySnapshot` shape used by Resource Usage when +run with `--json`, including host memory, Orca app process buckets, worktree +terminal memory, per-session process roots, and history samples. Text output +prints a compact point-in-time summary and the top worktrees by retained +terminal memory. diff --git a/src/cli/args.ts b/src/cli/args.ts index 5e2a703a0e2..204c4eabd85 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -75,9 +75,16 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean { return false } if ( - ['automations', 'repo', 'worktree', 'terminal', 'file', 'computer', 'note'].includes( - commandPath[0] - ) + [ + 'automations', + 'repo', + 'worktree', + 'terminal', + 'file', + 'computer', + 'note', + 'diagnostics' + ].includes(commandPath[0]) ) { return false } @@ -112,7 +119,8 @@ export function isCommandGroup(commandPath: string[]): boolean { 'orchestration', 'computer', 'agent', - 'environment' + 'environment', + 'diagnostics' ].includes(commandPath[0])) || (commandPath.length === 2 && commandPath[0] === 'agent' && commandPath[1] === 'hooks') || (commandPath.length === 2 && diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index c560fd75f11..23d4263d9c5 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -18,6 +18,7 @@ import { ORCHESTRATION_HANDLERS } from './handlers/orchestration' import { COMPUTER_HANDLERS } from './handlers/computer' import { ENVIRONMENT_HANDLERS } from './handlers/environment' import { AGENT_HOOK_HANDLERS } from './handlers/agent-hooks' +import { DIAGNOSTICS_HANDLERS } from './handlers/diagnostics' export type HandlerContext = { flags: Map @@ -48,6 +49,7 @@ function buildHandlers(): Map { ORCHESTRATION_HANDLERS, COMPUTER_HANDLERS, AGENT_HOOK_HANDLERS, + DIAGNOSTICS_HANDLERS, ENVIRONMENT_HANDLERS ] for (const group of groups) { diff --git a/src/cli/format.ts b/src/cli/format.ts index d05b6eef54a..573f3732041 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -37,6 +37,7 @@ import type { Automation, AutomationRun } from '../shared/automations-types' import { formatAutomationPrecheckTimeout } from '../shared/automation-precheck' import { formatAutomationSchedule } from '../shared/automation-schedules' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' +import type { MemorySnapshot, WorktreeMemory } from '../shared/types' import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client' import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client' @@ -116,6 +117,69 @@ export function formatStatus(status: CliStatusResult): string { return formatCliStatus(status) } +export function formatMemorySnapshot(snapshot: MemorySnapshot): string { + const topWorktrees = [...snapshot.worktrees].sort((a, b) => b.memory - a.memory).slice(0, 10) + const lines = [ + `collectedAt: ${new Date(snapshot.collectedAt).toISOString()}`, + `totalMemory: ${formatByteCount(snapshot.totalMemory)}`, + `totalCpu: ${formatCpu(snapshot.totalCpu)}`, + [ + `hostUsed: ${formatByteCount(snapshot.host.usedMemory)}`, + `/ ${formatByteCount(snapshot.host.totalMemory)}`, + `(${snapshot.host.memoryUsagePercent.toFixed(1)}%)` + ].join(' '), + [ + `app: ${formatByteCount(snapshot.app.memory)}`, + `(main ${formatByteCount(snapshot.app.main.memory)},`, + `renderer ${formatByteCount(snapshot.app.renderer.memory)},`, + `other ${formatByteCount(snapshot.app.other.memory)})` + ].join(' '), + `worktrees: ${snapshot.worktrees.length}` + ] + + if (topWorktrees.length === 0) { + lines.push('topWorktrees: none') + return lines.join('\n') + } + + lines.push('', 'Top worktrees:') + for (const worktree of topWorktrees) { + lines.push(formatWorktreeMemoryLine(worktree)) + } + if (snapshot.worktrees.length > topWorktrees.length) { + lines.push(`... ${snapshot.worktrees.length - topWorktrees.length} more worktrees`) + } + return lines.join('\n') +} + +function formatWorktreeMemoryLine(worktree: WorktreeMemory): string { + return [ + `- ${worktree.worktreeName}`, + `${formatByteCount(worktree.memory)}`, + `${formatCpu(worktree.cpu)}`, + `${worktree.sessions.length} session${worktree.sessions.length === 1 ? '' : 's'}` + ].join(' ') +} + +function formatCpu(cpu: number): string { + return `${cpu.toFixed(1)}%` +} + +function formatByteCount(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) { + return '0 B' + } + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let value = bytes + let unitIndex = 0 + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024 + unitIndex += 1 + } + const formatted = value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1) + return `${formatted} ${units[unitIndex]}` +} + export function formatEnvironmentList(result: { environments: PublicKnownRuntimeEnvironment[] }): string { diff --git a/src/cli/handlers/diagnostics.ts b/src/cli/handlers/diagnostics.ts new file mode 100644 index 00000000000..ca2ffc36855 --- /dev/null +++ b/src/cli/handlers/diagnostics.ts @@ -0,0 +1,10 @@ +import type { MemorySnapshot } from '../../shared/types' +import type { CommandHandler } from '../dispatch' +import { formatMemorySnapshot, printResult } from '../format' + +export const DIAGNOSTICS_HANDLERS: Record = { + 'diagnostics memory': async ({ client, json }) => { + const result = await client.call('diagnostics.memory') + printResult(result, json, formatMemorySnapshot) + } +} diff --git a/src/cli/help.ts b/src/cli/help.ts index b5990b63eac..88493c85c52 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -11,6 +11,9 @@ Startup: serve Start a headless Orca runtime server status Show app/runtime/graph readiness +Diagnostics: + diagnostics memory Collect a memory snapshot for Orca and managed terminals + Environments: environment add Save a remote Orca runtime from a pairing code environment list List saved remote Orca runtimes @@ -161,6 +164,7 @@ Common Commands: orca open [--json] orca serve [--port ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--json] orca status [--json] + orca diagnostics memory [--json] orca environment add --name --pairing-code [--json] orca environment list [--json] orca environment show --environment [--json] @@ -254,6 +258,7 @@ Browser Options: Examples: $ orca open $ orca status --json + $ orca diagnostics memory --json $ orca repo list $ orca worktree create --repo name:orca --name cli-test-1 --issue 273 $ orca worktree show --worktree branch:Jinwoo-H/cli diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 8da612f0708..14614cd5b3f 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1575,6 +1575,62 @@ describe('orca cli worktree awareness', () => { }) }) + it('collects and formats memory diagnostics', async () => { + queueFixtures( + callMock, + okFixture('req_memory', { + app: { + cpu: 1.25, + memory: 1024 * 1024, + main: { cpu: 0.5, memory: 512 * 1024 }, + renderer: { cpu: 0.5, memory: 384 * 1024 }, + other: { cpu: 0.25, memory: 128 * 1024 }, + history: [1024 * 1024] + }, + worktrees: [ + { + worktreeId: 'repo::/tmp/repo/feature', + worktreeName: 'feature', + repoId: 'repo', + repoName: 'Orca', + cpu: 2.5, + memory: 1024 * 1024, + sessions: [ + { + sessionId: 'pty-1', + paneKey: null, + pid: 123, + cpu: 2.5, + memory: 1024 * 1024 + } + ], + history: [1024 * 1024] + } + ], + host: { + totalMemory: 8 * 1024 * 1024, + freeMemory: 2 * 1024 * 1024, + usedMemory: 6 * 1024 * 1024, + memoryUsagePercent: 75, + cpuCoreCount: 8, + loadAverage1m: 1.25 + }, + totalCpu: 3.75, + totalMemory: 2 * 1024 * 1024, + collectedAt: 1000 + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['diagnostics', 'memory'], '/tmp/repo') + + expect(callMock).toHaveBeenCalledWith('diagnostics.memory') + const output = logSpy.mock.calls.flat().join('\n') + expect(output).toContain('totalMemory: 2.0 MB') + expect(output).toContain('app: 1.0 MB') + expect(output).toContain('- feature 1.0 MB 2.5% 1 session') + }) + it('exits nonzero when terminal wait returns an unsatisfied blocked result', async () => { process.env.ORCA_TERMINAL_HANDLE = 'term_worker' callMock.mockResolvedValueOnce({ diff --git a/src/cli/specs/diagnostics.ts b/src/cli/specs/diagnostics.ts new file mode 100644 index 00000000000..24bfc3fd146 --- /dev/null +++ b/src/cli/specs/diagnostics.ts @@ -0,0 +1,15 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +export const DIAGNOSTICS_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['diagnostics', 'memory'], + summary: 'Collect a memory snapshot for Orca and managed terminals', + usage: 'orca diagnostics memory [--json]', + allowedFlags: [...GLOBAL_FLAGS], + notes: [ + 'Runs the same host process sweep used by the Resource Usage popover, so call it when you need a point-in-time diagnostic rather than a cheap heartbeat.' + ], + examples: ['orca diagnostics memory --json'] + } +] diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index ccffbac65ef..86dd7be9ec7 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -8,6 +8,7 @@ import { ORCHESTRATION_COMMAND_SPECS } from './orchestration' import { COMPUTER_COMMAND_SPECS } from './computer' import { ENVIRONMENT_COMMAND_SPECS } from './environment' import { AGENT_HOOK_COMMAND_SPECS } from './agent-hooks' +import { DIAGNOSTICS_COMMAND_SPECS } from './diagnostics' export const COMMAND_SPECS: CommandSpec[] = [ ...CORE_COMMAND_SPECS, @@ -18,5 +19,6 @@ export const COMMAND_SPECS: CommandSpec[] = [ ...ORCHESTRATION_COMMAND_SPECS, ...COMPUTER_COMMAND_SPECS, ...AGENT_HOOK_COMMAND_SPECS, + ...DIAGNOSTICS_COMMAND_SPECS, ...ENVIRONMENT_COMMAND_SPECS ] diff --git a/src/main/memory/collector.test.ts b/src/main/memory/collector.test.ts index d9460796784..e1ff3dda63d 100644 --- a/src/main/memory/collector.test.ts +++ b/src/main/memory/collector.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Store } from '../persistence' +import type { MemorySnapshotStore } from './collector' type AppMetricFixture = { pid: number @@ -37,7 +37,7 @@ async function loadCollector() { const emptyStore = { getWorktreeMeta: () => undefined, getRepo: () => undefined -} as unknown as Store +} satisfies MemorySnapshotStore describe('parsePsOutput', () => { it('parses a well-formed listing into rows', async () => { diff --git a/src/main/memory/collector.ts b/src/main/memory/collector.ts index 03bae642e62..5cc366534e4 100644 --- a/src/main/memory/collector.ts +++ b/src/main/memory/collector.ts @@ -36,13 +36,15 @@ import type { Store } from '../persistence' import { ORPHAN_WORKTREE_ID } from '../../shared/constants' import { listRegisteredPtys } from './pty-registry' +export type MemorySnapshotStore = Pick + // ─── Module state ─────────────────────────────────────────────────── let inflight: Promise | null = null // ─── Public API ───────────────────────────────────────────────────── -export async function collectMemorySnapshot(store: Store): Promise { +export async function collectMemorySnapshot(store: MemorySnapshotStore): Promise { // Why: coalescing relies on the persistence store being a process-wide // singleton at runtime. Concurrent callers all hand in the same instance, // so it is safe to return the existing in-flight promise (which was @@ -380,7 +382,7 @@ type WorktreeBucket = { function resolveWorktreeNames( worktreeId: string, - store: Store + store: MemorySnapshotStore ): { worktreeName: string repoId: string @@ -413,7 +415,7 @@ function makeEmptyBucket( // ─── Main collection path ─────────────────────────────────────────── -async function runSnapshot(store: Store): Promise { +async function runSnapshot(store: MemorySnapshotStore): Promise { const processIndex = await enumerateProcesses() const appBuckets = bucketElectronMetrics(processIndex) const ptys = listRegisteredPtys() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a712664c16a..c7251e55f5f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -60,6 +60,7 @@ import type { ProjectGroup, ProjectGroupImportMode, ProjectGroupImportResult, + MemorySnapshot, TabGroupLayoutNode, TuiAgent, WorkspaceCreateTelemetrySource @@ -158,6 +159,7 @@ import { RuntimeBrowserCommands } from './orca-runtime-browser' import { RuntimeFileCommands } from './orca-runtime-files' import { RuntimeGitCommands } from './orca-runtime-git' import { joinWorktreeRelativePath } from './runtime-relative-paths' +import { collectMemorySnapshot } from '../memory/collector' import { BrowserWindow, ipcMain } from 'electron' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import { BrowserError } from '../browser/cdp-bridge' @@ -1458,6 +1460,13 @@ export class OrcaRuntimeService { return this.stats?.getSummary() ?? null } + getMemorySnapshot(): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + return collectMemorySnapshot(this.store) + } + getUIState(): PersistedUIState { if (!this.store?.getUI) { throw new Error('runtime_unavailable') diff --git a/src/main/runtime/rpc/methods/diagnostics.test.ts b/src/main/runtime/rpc/methods/diagnostics.test.ts new file mode 100644 index 00000000000..41278554f65 --- /dev/null +++ b/src/main/runtime/rpc/methods/diagnostics.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { DIAGNOSTICS_METHODS } from './diagnostics' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('diagnostics RPC methods', () => { + it('collects the runtime memory snapshot', async () => { + const snapshot = { + app: { + cpu: 1, + memory: 1024, + main: { cpu: 1, memory: 512 }, + renderer: { cpu: 0, memory: 256 }, + other: { cpu: 0, memory: 256 }, + history: [1024] + }, + worktrees: [], + host: { + totalMemory: 4096, + freeMemory: 1024, + usedMemory: 3072, + memoryUsagePercent: 75, + cpuCoreCount: 8, + loadAverage1m: 1.25 + }, + totalCpu: 1, + totalMemory: 1024, + collectedAt: 123 + } + const runtime = { + getRuntimeId: () => 'test-runtime', + getMemorySnapshot: vi.fn().mockResolvedValue(snapshot) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: DIAGNOSTICS_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('diagnostics.memory')) + + expect(runtime.getMemorySnapshot).toHaveBeenCalledTimes(1) + expect(response).toMatchObject({ + ok: true, + result: snapshot + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/diagnostics.ts b/src/main/runtime/rpc/methods/diagnostics.ts new file mode 100644 index 00000000000..4d158d98f63 --- /dev/null +++ b/src/main/runtime/rpc/methods/diagnostics.ts @@ -0,0 +1,11 @@ +import { defineMethod, type RpcMethod } from '../core' + +export const DIAGNOSTICS_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'diagnostics.memory', + params: null, + handler: async (_params, { runtime }) => { + return await runtime.getMemorySnapshot() + } + }) +] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 00ffa4080bf..d971a959e12 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -10,6 +10,7 @@ import { BROWSER_SCREENCAST_METHODS } from './browser-screencast' import { ORCHESTRATION_METHODS } from './orchestration' import { NOTIFICATION_METHODS } from './notifications' import { STATS_METHODS } from './stats' +import { DIAGNOSTICS_METHODS } from './diagnostics' import { ACCOUNT_METHODS } from './accounts' import { PREFLIGHT_METHODS } from './preflight' import { COMPUTER_METHODS } from './computer' @@ -42,6 +43,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...ORCHESTRATION_METHODS, ...NOTIFICATION_METHODS, ...STATS_METHODS, + ...DIAGNOSTICS_METHODS, ...ACCOUNT_METHODS, ...PREFLIGHT_METHODS, ...COMPUTER_METHODS, diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 5c41bd66562..3c56dc56ad9 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -141,6 +141,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'browser.tabCreate', 'browser.viewport', 'clipboard.saveImageAsTempFile', + 'diagnostics.memory', 'files.createFile', 'files.list', 'files.open',