Expose memory diagnostics through CLI (#4408)

This commit is contained in:
Neil
2026-06-01 15:14:15 -07:00
committed by GitHub
parent a05a108e67
commit f7fb6ef686
16 changed files with 258 additions and 9 deletions
@@ -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.
+12 -4
View File
@@ -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 &&
+2
View File
@@ -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<string, string | boolean>
@@ -48,6 +49,7 @@ function buildHandlers(): Map<string, CommandHandler> {
ORCHESTRATION_HANDLERS,
COMPUTER_HANDLERS,
AGENT_HOOK_HANDLERS,
DIAGNOSTICS_HANDLERS,
ENVIRONMENT_HANDLERS
]
for (const group of groups) {
+64
View File
@@ -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 {
+10
View File
@@ -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<string, CommandHandler> = {
'diagnostics memory': async ({ client, json }) => {
const result = await client.call<MemorySnapshot>('diagnostics.memory')
printResult(result, json, formatMemorySnapshot)
}
}
+5
View File
@@ -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 <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--json]
orca status [--json]
orca diagnostics memory [--json]
orca environment add --name <name> --pairing-code <code> [--json]
orca environment list [--json]
orca environment show --environment <selector> [--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
+56
View File
@@ -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({
+15
View File
@@ -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']
}
]
+2
View File
@@ -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
]
+2 -2
View File
@@ -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 () => {
+5 -3
View File
@@ -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<Store, 'getRepo' | 'getWorktreeMeta'>
// ─── Module state ───────────────────────────────────────────────────
let inflight: Promise<MemorySnapshot> | null = null
// ─── Public API ─────────────────────────────────────────────────────
export async function collectMemorySnapshot(store: Store): Promise<MemorySnapshot> {
export async function collectMemorySnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot> {
// 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<MemorySnapshot> {
async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot> {
const processIndex = await enumerateProcesses()
const appBuckets = bucketElectronMetrics(processIndex)
const ptys = listRegisteredPtys()
+9
View File
@@ -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<MemorySnapshot> {
if (!this.store) {
throw new Error('runtime_unavailable')
}
return collectMemorySnapshot(this.store)
}
getUIState(): PersistedUIState {
if (!this.store?.getUI) {
throw new Error('runtime_unavailable')
@@ -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
})
})
})
@@ -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()
}
})
]
+2
View File
@@ -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,
+1
View File
@@ -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',