mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Expose memory diagnostics through CLI (#4408)
This commit is contained in:
@@ -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
@@ -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 &&
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user