Run local usage services in a supervised backend process (#40792)

* Move local usage services into a supervised backend process

* Own usage recovery and worker retirement lifetimes

* Preserve answered usage data during background refresh

* Run independent usage scans concurrently in the backend

* Limit backend startup paths to the delegated usage service

* Show usage failures through backend recovery

* Include usage failure strings in the English catalog

* Keep usage update details beside refresh failures

* Resolve usage freshness failure from the final producer snapshot

* Retain the forced refresh completion requirement

* Keep managed backend failures out of application crash reports

* Preserve usage recovery boundaries across backend reconnects

* Match the backend RPC cancellation signal in the usage seam proof

* Avoid serializing backend requests before port delivery

* Keep receiving request validation in the boundary schemas

* test: align usage transport coverage with renderer project

* fix: preserve unknown usage state and renderer retirement

* fix(usage): revalidate tracking before scan admission

* fix(usage): retry failed backend startup on demand

* test(usage): isolate stale backend ownership checks
This commit is contained in:
Brennan Benson
2026-11-11 16:39:52 -08:00
committed by GitHub
parent 890ad4d8aa
commit 175f10e32b
85 changed files with 4898 additions and 789 deletions
+25 -18
View File
@@ -1,3 +1,4 @@
import type { AutomationUsageClient } from '../../shared/backend-service-contract'
import type { Automation } from '../../shared/automations/automation-definition'
import type {
AutomationRun,
@@ -5,8 +6,6 @@ import type {
} from '../../shared/automations/history/automation-run'
import type { Store } from '../persistence/loading-store/store'
import type { AutomationRunWriter } from './automation-run-writer'
import type { ClaudeUsageStore } from '../claude-usage/store'
import type { CodexUsageStore } from '../codex-usage/store'
function createUnavailableAutomationUsage(
collectedAt: number,
@@ -54,8 +53,8 @@ export async function collectAutomationRunUsage({
}: {
automation: Automation | undefined
run: AutomationRun
claudeUsage: ClaudeUsageStore | null
codexUsage: CodexUsageStore | null
claudeUsage: AutomationUsageClient | null
codexUsage: AutomationUsageClient | null
}): Promise<AutomationRunUsage> {
const collectedAt = Date.now()
const unavailable = (
@@ -83,23 +82,31 @@ export async function collectAutomationRunUsage({
if (!claudeUsage) {
return unavailable('claude', 'scan_failed', 'Claude usage store is unavailable.')
}
return claudeUsage.getAutomationRunUsage({
worktreeId: run.workspaceId,
terminalSessionId: run.terminalSessionId,
startedAt: run.startedAt,
completedAt: collectedAt
})
return claudeUsage
.getAutomationRunUsage({
worktreeId: run.workspaceId,
terminalSessionId: run.terminalSessionId,
startedAt: run.startedAt,
completedAt: collectedAt
})
.catch(() =>
unavailable('claude', 'scan_failed', 'Claude usage was unavailable when this run finished.')
)
}
if (automation.agentId === 'codex') {
if (!codexUsage) {
return unavailable('codex', 'scan_failed', 'Codex usage store is unavailable.')
}
return codexUsage.getAutomationRunUsage({
worktreeId: run.workspaceId,
terminalSessionId: run.terminalSessionId,
startedAt: run.startedAt,
completedAt: collectedAt
})
return codexUsage
.getAutomationRunUsage({
worktreeId: run.workspaceId,
terminalSessionId: run.terminalSessionId,
startedAt: run.startedAt,
completedAt: collectedAt
})
.catch(() =>
unavailable('codex', 'scan_failed', 'Codex usage was unavailable when this run finished.')
)
}
return unavailable(null, 'provider_unsupported', 'This agent does not report usage to Orca yet.')
}
@@ -108,8 +115,8 @@ export async function writeAutomationRunUsage(input: {
store: Store
runs: AutomationRunWriter
run: AutomationRun
claudeUsage: ClaudeUsageStore | null
codexUsage: CodexUsageStore | null
claudeUsage: AutomationUsageClient | null
codexUsage: AutomationUsageClient | null
}): Promise<AutomationRun> {
const { store, run } = input
const usage = await collectAutomationRunUsage({
@@ -1,10 +1,9 @@
import type { AutomationUsageClient } from '../../shared/backend-service-contract'
import { observeAutomationChatRun } from './runtime-chat-run-observer'
import { readAutomationRunReceipt } from './automation-run-receipt'
import { publishedLaunchTarget } from './automation-agent-run-result'
import { launchAutomationChatAgent } from './automation-chat-agent-launch'
import { tuiAgentDisplayName } from '../../shared/agent-launch/catalog/tui-agent-display-names'
import type { ClaudeUsageStore } from '../claude-usage/store'
import type { CodexUsageStore } from '../codex-usage/store'
import type { Store } from '../persistence/loading-store/store'
import type { AutomationRun } from '../../shared/automations/history/automation-run'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
@@ -29,8 +28,8 @@ const MAX_REMEMBERED_LAUNCHES = 256
export function createRuntimeAutomationService(input: {
store: Store
runtime: OrcaRuntimeService
claudeUsage?: ClaudeUsageStore
codexUsage?: CodexUsageStore
claudeUsage?: AutomationUsageClient
codexUsage?: AutomationUsageClient
/** Only server processes execute remote_host_service schedules. */
allowRemoteHostScheduling: boolean
}): AutomationService {
+43
View File
@@ -68,6 +68,49 @@ describe('AutomationService', () => {
rmSync(testState.dir, { recursive: true, force: true })
})
it('keeps completed history when the usage backend disconnects', async () => {
const store = await createStore()
store.addRepo(makeRepo())
const automation = store.createAutomation({
name: 'Check',
prompt: 'Check spend',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'existing',
workspaceId: 'wt1',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: Date.now()
})
const run = store.createAutomationRun(automation, Date.now(), 'manual')
const service = new AutomationService(store, {
claudeUsage: {
getAutomationRunUsage: async () => {
throw new Error('Backend disconnected')
}
}
})
try {
const updated = await service.markDispatchResult({
runId: run.id,
status: 'completed',
workspaceId: 'wt1',
terminalSessionId: 'tab-1',
error: null
})
expect(updated).toMatchObject({
status: 'completed',
usage: { status: 'unavailable', provider: 'claude', unavailableReason: 'scan_failed' }
})
expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({
status: 'completed',
usage: { status: 'unavailable' }
})
} finally {
service.stop()
}
})
it('does not throw when retention evicts the run during usage collection', async () => {
vi.setSystemTime(new Date('2026-05-13T10:00:00'))
const store = await createStore()
+5 -6
View File
@@ -1,3 +1,4 @@
import type { AutomationUsageClient } from '../../shared/backend-service-contract'
import {
AutomationDispatchCancelledError,
requestAutomationDispatch,
@@ -11,8 +12,6 @@ import {
type AutomationRun
} from '../../shared/automations/history/automation-run'
import type { Automation } from '../../shared/automations/automation-definition'
import type { ClaudeUsageStore } from '../claude-usage/store'
import type { CodexUsageStore } from '../codex-usage/store'
import {
captureLegacyRunTarget,
resolveAutomationRunTarget,
@@ -50,8 +49,8 @@ export class AutomationService {
private evaluating = false
private stopped = false
private dispatchGeneration = 0
private readonly claudeUsage: ClaudeUsageStore | null
private readonly codexUsage: CodexUsageStore | null
private readonly claudeUsage: AutomationUsageClient | null
private readonly codexUsage: AutomationUsageClient | null
private readonly allowRemoteHostScheduling: boolean
private readonly terminalLauncher: AutomationTerminalLauncher | null
private readonly chatDispatcher: AutomationLauncher | null
@@ -69,8 +68,8 @@ export class AutomationService {
store: Store,
opts: {
tickMs?: number
claudeUsage?: ClaudeUsageStore
codexUsage?: CodexUsageStore
claudeUsage?: AutomationUsageClient
codexUsage?: AutomationUsageClient
allowRemoteHostScheduling?: boolean
terminalLauncher?: AutomationTerminalLauncher
chatDispatcher?: AutomationLauncher
@@ -0,0 +1,76 @@
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { getAppEnvironment, setAppEnvironment } from '../../shared/app-environment'
import { BackendBootstrapSchema } from '../../shared/backend-service-contract'
import { createDesktopBackendEnvironment } from '../backend/node-service-environment'
import { getLogsDirectory } from '../observability/logs-directory'
import { captureBackendBootstrap, capturedBootstrap } from './backend-bootstrap'
const { getPath } = vi.hoisted(() => ({ getPath: vi.fn<(name: string) => string>() }))
vi.mock('electron', () => ({
app: {
getPath,
getVersion: () => '1.2.3',
getAppPath: () => '/installed/orca',
isPackaged: true
}
}))
let originalEnvironment: ReturnType<typeof getAppEnvironment>
beforeEach(() => {
originalEnvironment = getAppEnvironment()
getPath.mockImplementation((name) => {
if (name !== 'userData') {
throw new Error(`Failed to get ${name} path`)
}
return '/profiles/usage-profile'
})
})
afterEach(() => setAppEnvironment(originalEnvironment))
it('starts the usage environment at its authoritative profile when optional folders are unavailable', () => {
captureBackendBootstrap()
const config = BackendBootstrapSchema.parse(capturedBootstrap)
const host = createDesktopBackendEnvironment(config)
setAppEnvironment(host.environment)
expect(getAppEnvironment().getPath('userData')).toBe('/profiles/usage-profile')
expect(getLogsDirectory()).toBe(join('/profiles/usage-profile', 'logs'))
expect(() => getAppEnvironment().getPath('downloads')).toThrow(
'Desktop backend path is unsupported: downloads'
)
})
it('fails startup preparation when the required profile is unavailable', () => {
getPath.mockImplementation(() => {
throw new Error('Failed to get userData path')
})
expect(captureBackendBootstrap).toThrow('Failed to get userData path')
})
it('accepts legacy full and minimal snapshots without a build restriction while requiring the profile', () => {
captureBackendBootstrap()
const minimal = BackendBootstrapSchema.parse(capturedBootstrap)
const legacy = {
...minimal,
build: 'older-shell',
paths: {
userData: '/profiles/legacy-profile',
home: '/legacy/home',
appData: '/legacy/appData',
temp: '/legacy/temp',
downloads: '/legacy/downloads',
logs: '/legacy/logs',
exe: '/legacy/orca'
}
}
expect(
createDesktopBackendEnvironment(BackendBootstrapSchema.parse(legacy)).environment.getPath(
'userData'
)
).toBe('/profiles/legacy-profile')
expect(createDesktopBackendEnvironment(minimal).environment.getPath('userData')).toBe(
'/profiles/usage-profile'
)
expect(BackendBootstrapSchema.safeParse({ ...minimal, paths: {} }).success).toBe(false)
})
@@ -0,0 +1,19 @@
import { app } from 'electron'
import { BACKEND_CAPABILITIES, type BackendBootstrap } from '../../shared/backend-service-contract'
export let capturedBootstrap: BackendBootstrap | undefined
/** Capture at the same pre-name-change phase as the former usage path initializers. */
export function captureBackendBootstrap(): void {
capturedBootstrap = {
protocol: 1,
requiredCapabilities: [...BACKEND_CAPABILITIES],
services: ['usage'],
build: app.getVersion(),
paths: {
userData: app.getPath('userData')
},
appPath: app.getAppPath(),
isPackaged: app.isPackaged
}
}
@@ -0,0 +1,41 @@
import { expect, it, vi } from 'vitest'
import { createBackendDesktopCapabilities } from './backend-desktop-capabilities'
vi.mock('../telemetry/client', () => ({ isTelemetryEnabled: vi.fn(), track: vi.fn() }))
vi.mock('../claude-usage/transcript-file-discovery', () => ({
claudeProfileTranscriptDirs: vi.fn()
}))
it('loads remembered worktrees before sending the backend attribution catalog', async () => {
const remembered = new Map<string, string>()
let finishLoading!: () => void
const loaded = new Promise<void>((resolve) => {
finishLoading = resolve
}).then(() => {
remembered.set('repo-1::/ws/removed', 'removed')
})
const capability = createBackendDesktopCapabilities({
getRepos: () => [
{ id: 'repo-1', path: '/src/app', displayName: 'app', badgeColor: '#000000', addedAt: 0 }
],
getAllWorktreeMeta: () => ({}),
getRememberedWorktreeDisplayNames: () => remembered,
whenLoaded: () => loaded
})
const catalog = capability('usage.catalog', null)
finishLoading()
expect(await catalog).toEqual({
repoIds: ['repo-1'],
worktreesByRepo: [
[
'repo-1',
[
{ worktreeId: 'repo-1::/src/app', path: '/src/app', displayName: 'app' },
{ worktreeId: 'repo-1::/ws/removed', path: '/ws/removed', displayName: 'removed' }
]
]
]
})
})
@@ -0,0 +1,95 @@
import { BrowserWindow, type WebContents, type WebFrameMain } from 'electron'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { agentTokenUsageSchema } from '../../shared/telemetry-agent-token-usage-schema'
import { claudeProfileTranscriptDirs } from '../claude-usage/transcript-file-discovery'
import { loadKnownUsageWorktreesByRepo } from '../usage/worktree-attribution/usage-worktree-metadata'
import { isTelemetryEnabled, track } from '../telemetry/client'
import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata'
import { currentWorkerEntryLayout } from '../worker-threads/worker-thread-entry-path'
import { isRendererDocumentNavigation } from '../window/renderer-document-navigation'
export function trustsBackendRenderer(contents: WebContents): boolean {
if (
contents.isDestroyed() ||
contents.getType() !== 'window' ||
!BrowserWindow.fromWebContents(contents)
) {
return false
}
const layout = currentWorkerEntryLayout(__dirname)
const roots = process.env.ELECTRON_RENDERER_URL
? [process.env.ELECTRON_RENDERER_URL]
: ['index.html', 'space.html', 'popout.html'].map(
(name) => pathToFileURL(join(layout.moduleDir, '../renderer', name)).href
)
return roots.some((root) => isRendererDocumentNavigation(root, contents.getURL()))
}
export function createBackendDesktopCapabilities(
store: UsageWorktreeSource
): (method: string, params: unknown) => Promise<unknown> {
return async (method, params) => {
if (method === 'usage.catalog') {
await store.whenLoaded()
const repos = store.getRepos()
return {
repoIds: repos.map((repo) => repo.id),
worktreesByRepo: [...loadKnownUsageWorktreesByRepo(store, repos)]
}
}
if (method === 'usage.claudeProfileDirs') {
return claudeProfileTranscriptDirs()
}
if (method === 'usage.telemetry.enabled') {
return isTelemetryEnabled()
}
if (method === 'usage.telemetry.capture') {
return track('agent_token_usage', agentTokenUsageSchema.parse(params))
}
throw new Error('This desktop capability is not supported.')
}
}
export type BackendDocument = {
contents: WebContents
frame: WebFrameMain
token: string
retire(): void
}
export function trackBackendDocument(
documents: Map<number, BackendDocument>,
contents: WebContents,
frame: WebFrameMain,
token: string,
retireToken: (token: string) => void
): BackendDocument {
documents.get(contents.id)?.retire()
const document: BackendDocument = { contents, frame, token, retire: () => {} }
const retire = (): void => {
if (documents.get(contents.id) !== document) {
return
}
documents.delete(contents.id)
retireToken(document.token)
contents.off('did-frame-navigate', navigation)
contents.off('destroyed', retire)
}
const navigation = (
_event: unknown,
_url: string,
_httpCode: number,
_httpStatus: string,
mainFrame: boolean
): void => {
if (mainFrame) {
retire()
}
}
document.retire = retire
documents.set(contents.id, document)
contents.on('did-frame-navigate', navigation)
contents.once('destroyed', retire)
return document
}
@@ -0,0 +1,379 @@
import { EventEmitter } from 'node:events'
import { afterEach, expect, it, vi } from 'vitest'
import {
BACKEND_CONNECTION_CHANGED,
BACKEND_PORT_DELIVERY,
BACKEND_PORT_REQUEST
} from '../../shared/backend-service-contract'
const rig = vi.hoisted(() => ({
fork: vi.fn(),
handle: vi.fn(),
call: vi.fn(),
appOn: vi.fn(),
appOff: vi.fn()
}))
vi.mock('electron', () => ({
app: { on: rig.appOn, off: rig.appOff },
BrowserWindow: { fromWebContents: () => ({}) },
ipcMain: { handle: rig.handle, removeHandler: vi.fn() },
utilityProcess: { fork: rig.fork },
MessageChannelMain: class {
port1 = {}
port2 = {}
}
}))
vi.mock('./backend-bootstrap', () => ({ capturedBootstrap: {} }))
vi.mock('../../shared/backend-port-rpc', () => ({
BackendPortRpc: class {
call = rig.call
close() {}
}
}))
vi.mock('../backend/backend-message-port', () => ({ backendMessagePort: () => ({}) }))
vi.mock('../crash-reporting/breadcrumbs/durable-crash-breadcrumb', () => ({
recordDurableCrashBreadcrumb: vi.fn()
}))
vi.mock('../worker-threads/worker-thread-entry-path', () => ({
currentWorkerEntryLayout: () => ({ moduleDir: '/' }),
resolveWorkerThreadEntryPath: () => 'backend-entry.js'
}))
vi.mock('../window/renderer-document-navigation', () => ({
isRendererDocumentNavigation: () => true
}))
vi.mock('../usage/worktree-attribution/usage-worktree-metadata', () => ({
loadKnownUsageWorktreesByRepo: vi.fn()
}))
vi.mock('../claude-usage/transcript-file-discovery', () => ({
claudeProfileTranscriptDirs: vi.fn()
}))
vi.mock('../telemetry/client', () => ({ isTelemetryEnabled: vi.fn(), track: vi.fn() }))
import { BackendSupervisor } from './backend-supervisor'
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it('keeps ready-generated recovery behind owner backoff and the cap, then admits real demand', async () => {
vi.useFakeTimers()
const children: (EventEmitter & {
postMessage: ReturnType<typeof vi.fn>
kill: ReturnType<typeof vi.fn>
})[] = []
rig.fork.mockImplementation(() => {
const child = Object.assign(new EventEmitter(), { postMessage: vi.fn(), kill: vi.fn() })
children.push(child)
return child
})
const supervisor = new BackendSupervisor({
getRepos: () => [],
getAllWorktreeMeta: () => ({}),
getRememberedWorktreeDisplayNames: () => new Map(),
whenLoaded: async () => {}
})
const frame = { postMessage: vi.fn() }
const sender = Object.assign(new EventEmitter(), {
id: 2,
mainFrame: frame,
isDestroyed: () => false,
getType: () => 'window',
getURL: () => 'file:///index.html'
})
const request = rig.handle.mock.calls.find(([channel]) => channel === BACKEND_PORT_REQUEST)?.[1]
const event = { sender, senderFrame: frame }
const token = '11111111-1111-1111-1111-111111111111'
const joined = request(event, token, 'recovery')
children[0].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
await joined
for (let i = 0; i < 3; i++) {
children[i].emit('exit', 1)
await expect(request(event, token, 'recovery')).rejects.toThrow('Usage is unavailable')
expect(children).toHaveLength(i + 1)
if (i < 2) {
const delay = i === 0 ? 500 : 1000
await vi.advanceTimersByTimeAsync(delay - 1)
expect(children).toHaveLength(i + 1)
await vi.advanceTimersByTimeAsync(1)
expect(children).toHaveLength(i + 2)
const recovery = request(event, token, 'recovery')
children[i + 1].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
await recovery
}
}
await vi.advanceTimersByTimeAsync(5000)
expect(children).toHaveLength(3)
expect(frame.postMessage).toHaveBeenCalledWith(BACKEND_CONNECTION_CHANGED, {
connected: false,
unavailable: true
})
await expect(request(event, token, 'invalid')).rejects.toThrow(
'Invalid backend connection request'
)
const demand = request(event, token)
expect(children).toHaveLength(4)
children[3].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
await demand
children[3].emit('exit', 1)
rig.call.mockResolvedValueOnce({ inputTokens: 42 })
const automation = supervisor.automationClient('claudeUsage').getAutomationRunUsage({
worktreeId: null,
terminalSessionId: null,
startedAt: null,
completedAt: null
})
expect(children).toHaveLength(5)
children[4].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
await expect(automation).resolves.toEqual({ inputTokens: 42 })
await vi.advanceTimersByTimeAsync(1000)
expect(children).toHaveLength(5)
children[4].emit('exit', 1)
await vi.advanceTimersByTimeAsync(499)
expect(children).toHaveLength(5)
await vi.advanceTimersByTimeAsync(1)
expect(children).toHaveLength(6)
children[5].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
const stopped = supervisor.stop()
children[5].emit('exit', 0)
await stopped
})
it('publishes terminal unavailability on the third death and preserves explicit recovery', async () => {
vi.useFakeTimers()
const children: (EventEmitter & {
postMessage: ReturnType<typeof vi.fn>
kill: ReturnType<typeof vi.fn>
})[] = []
rig.fork.mockImplementation(() => {
const child = Object.assign(new EventEmitter(), { postMessage: vi.fn(), kill: vi.fn() })
children.push(child)
return child
})
const supervisor = new BackendSupervisor({
getRepos: () => [],
getAllWorktreeMeta: () => ({}),
getRememberedWorktreeDisplayNames: () => new Map(),
whenLoaded: async () => {}
})
const frame = { postMessage: vi.fn() }
const sender = Object.assign(new EventEmitter(), {
id: 1,
mainFrame: frame,
isDestroyed: () => false,
getType: () => 'window',
getURL: () => 'file:///index.html'
})
const request = rig.handle.mock.calls.find(([channel]) => channel === BACKEND_PORT_REQUEST)?.[1]
const initial = request({ sender, senderFrame: frame }, '11111111-1111-1111-1111-111111111111')
children[0].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
await initial
for (let i = 0; i < 3; i++) {
children[i].emit('exit', 1)
expect(frame.postMessage).toHaveBeenLastCalledWith(BACKEND_CONNECTION_CHANGED, {
connected: false,
unavailable: i === 2
})
await vi.advanceTimersByTimeAsync(2000)
if (i < 2) {
children[i + 1].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
}
}
expect(children).toHaveLength(3)
const retry = request({ sender, senderFrame: frame }, '22222222-2222-2222-2222-222222222222')
children[3].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
await retry
expect(frame.postMessage).toHaveBeenCalledWith(BACKEND_CONNECTION_CHANGED, {
connected: true,
unavailable: false
})
const stopped = supervisor.stop()
children[3].emit('exit', 0)
await stopped
})
it('retires only the exited renderer document and rejects its pending port delivery', async () => {
const child = Object.assign(new EventEmitter(), { postMessage: vi.fn(), kill: vi.fn() })
rig.fork.mockReturnValue(child)
const supervisor = new BackendSupervisor({
getRepos: () => [],
getAllWorktreeMeta: () => ({}),
getRememberedWorktreeDisplayNames: () => new Map(),
whenLoaded: async () => {}
})
const frame = { postMessage: vi.fn() }
const sender = Object.assign(new EventEmitter(), {
id: 3,
mainFrame: frame,
isDestroyed: () => false,
getType: () => 'window',
getURL: () => 'file:///index.html'
})
const request = rig.handle.mock.calls.find(([channel]) => channel === BACKEND_PORT_REQUEST)?.[1]
const gone = rig.appOn.mock.calls.find(([event]) => event === 'render-process-gone')?.[1]
const token = '33333333-3333-3333-3333-333333333333'
const initial = request({ sender, senderFrame: frame }, token)
const rejected = expect(initial).rejects.toThrow('The document was replaced')
gone({}, { id: 99 })
expect(child.postMessage).not.toHaveBeenCalledWith({ type: 'retire', token })
gone({}, sender)
expect(child.postMessage).toHaveBeenCalledWith({ type: 'retire', token })
child.emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
await rejected
expect(frame.postMessage.mock.calls.some(([channel]) => channel === BACKEND_PORT_DELIVERY)).toBe(
false
)
await request({ sender, senderFrame: frame }, token)
expect(
child.postMessage.mock.calls.filter(([message]) => message.type === 'client')
).toHaveLength(1)
gone({}, sender)
expect(
child.postMessage.mock.calls.filter(([message]) => message.type === 'retire')
).toHaveLength(2)
const stopped = supervisor.stop()
expect(rig.appOff).toHaveBeenCalledWith('render-process-gone', gone)
child.emit('exit', 0)
await stopped
})
function startupFixture() {
const children: (EventEmitter & {
postMessage: ReturnType<typeof vi.fn>
kill: ReturnType<typeof vi.fn>
})[] = []
rig.fork.mockImplementation(() => {
const child = Object.assign(new EventEmitter(), {
postMessage: vi.fn(),
kill: vi.fn(() => false)
})
children.push(child)
return child
})
const supervisor = new BackendSupervisor({
getRepos: () => [],
getAllWorktreeMeta: () => ({}),
getRememberedWorktreeDisplayNames: () => new Map(),
whenLoaded: async () => {}
})
const frame = { postMessage: vi.fn() }
const sender = Object.assign(new EventEmitter(), {
id: 4,
mainFrame: frame,
isDestroyed: () => false,
getType: () => 'window',
getURL: () => 'file:///index.html'
})
const request = rig.handle.mock.calls.find(([channel]) => channel === BACKEND_PORT_REQUEST)?.[1]
const token = '44444444-4444-4444-4444-444444444444'
const read = () =>
supervisor.automationClient('claudeUsage').getAutomationRunUsage({
worktreeId: null,
terminalSessionId: null,
startedAt: null,
completedAt: null
})
return {
children,
supervisor,
frame,
read,
request: (purpose = 'demand', contents = sender, requestToken = token) =>
request({ sender: contents, senderFrame: contents.mainFrame }, requestToken, purpose)
}
}
it('retries a rejected signal-free launch on new demand without replay or false retirement', async () => {
vi.useFakeTimers()
const { children, supervisor, frame, read, request } = startupFixture()
const ready = { type: 'ready', protocol: 1, capabilities: ['usage'] }
for (let attempt = 0; attempt < 4; attempt++) {
const demand = expect(read()).rejects.toThrow('Usage is still unavailable')
const joined = expect(request('recovery')).rejects.toThrow('Usage is still unavailable')
await vi.advanceTimersByTimeAsync(30_000)
await demand
await joined
children[attempt].emit('message', ready)
expect(frame.postMessage).not.toHaveBeenCalledWith(BACKEND_CONNECTION_CHANGED, {
connected: true,
unavailable: false
})
await expect(request('recovery')).rejects.toThrow('Usage is still unavailable')
expect(children).toHaveLength(attempt + 1)
expect(rig.call).not.toHaveBeenCalled()
}
const retry = read()
const port = request()
children[4].emit('message', ready)
rig.call.mockResolvedValueOnce({ inputTokens: 42 })
await expect(retry).resolves.toEqual({ inputTokens: 42 })
await port
expect(rig.call).toHaveBeenCalledTimes(1)
const notifications = frame.postMessage.mock.calls.slice()
for (const child of children.slice(0, 4)) {
child.kill.mockClear()
child.emit('spawn')
expect(child.kill).toHaveBeenCalledTimes(1)
child.emit('message', ready)
child.emit('exit', 1)
}
expect(frame.postMessage.mock.calls).toEqual(notifications)
const { recordDurableCrashBreadcrumb } =
await import('../crash-reporting/breadcrumbs/durable-crash-breadcrumb')
expect(recordDurableCrashBreadcrumb).toHaveBeenLastCalledWith('backend_process_exited', {
pid: null,
incarnation: null,
exitCode: 1,
expected: true
})
// A rejection after successful readiness cannot turn that owner into a failed startup.
children[4].emit('error', new Error('late error'))
rig.call.mockResolvedValueOnce({ inputTokens: 43 })
await expect(read()).resolves.toEqual({ inputTokens: 43 })
expect(children).toHaveLength(5)
const stopped = supervisor.stop()
let quit = false
void stopped.then(() => {
quit = true
})
await Promise.resolve()
expect(quit).toBe(false)
children[4].emit('exit', 0)
await stopped
await expect(read()).rejects.toThrow('Orca is shutting down')
expect(children).toHaveLength(5)
})
it('rejects old requests when ownership changes across readiness instead of rerouting them', async () => {
const { children, supervisor, frame, read, request } = startupFixture()
const ready = { type: 'ready', protocol: 1, capabilities: ['usage'] }
children[0].emit('message', ready)
const oldRead = expect(read()).rejects.toThrow('Usage disconnected')
const oldPort = expect(request()).rejects.toThrow('The document was replaced')
children[0].emit('exit', 1)
const replacementFrame = { postMessage: vi.fn() }
const replacementSender = Object.assign(new EventEmitter(), {
id: 5,
mainFrame: replacementFrame,
isDestroyed: () => false,
getType: () => 'window',
getURL: () => 'file:///index.html'
})
const token = '55555555-5555-5555-5555-555555555555'
const replacement = request('demand', replacementSender, token)
children[1].emit('message', ready)
await oldRead
await oldPort
await replacement
expect(rig.call).not.toHaveBeenCalled()
expect(
frame.postMessage.mock.calls.filter(([channel]) => channel === BACKEND_PORT_DELIVERY)
).toHaveLength(0)
expect(
replacementFrame.postMessage.mock.calls.filter(([channel]) => channel === BACKEND_PORT_DELIVERY)
).toHaveLength(1)
expect(
children[1].postMessage.mock.calls.filter(([message]) => message.type === 'client')
).toEqual([[{ type: 'client', token }, [{}]]])
const stopped = supervisor.stop()
children[1].emit('spawn')
expect(children[1].kill).toHaveBeenCalled()
children[1].emit('exit', 0)
await stopped
})
@@ -0,0 +1,303 @@
import { app, ipcMain, MessageChannelMain, utilityProcess, type WebContents } from 'electron'
import { recordDurableCrashBreadcrumb } from '../crash-reporting/breadcrumbs/durable-crash-breadcrumb'
import { BackendPortRpc } from '../../shared/backend-port-rpc'
import {
BACKEND_CONNECTION_CHANGED,
BACKEND_PORT_DELIVERY,
BACKEND_PORT_REQUEST,
BACKEND_UTILITY_PROCESS_NAME,
type AutomationUsageClient
} from '../../shared/backend-service-contract'
import { backendMessagePort } from '../backend/backend-message-port'
import {
currentWorkerEntryLayout,
resolveWorkerThreadEntryPath
} from '../worker-threads/worker-thread-entry-path'
import { ManagedOrcadEventWindow } from '../managed-server/lifecycle/orcad-managed-event-window'
import { ORCAD_SHUTDOWN_DEADLINE_MS } from '../orcad/orcad-stop-deadlines'
import {
createBackendDesktopCapabilities,
trustsBackendRenderer,
trackBackendDocument,
type BackendDocument
} from './backend-desktop-capabilities'
import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata'
export { captureBackendBootstrap } from './backend-bootstrap'
import { capturedBootstrap } from './backend-bootstrap'
export class BackendSupervisor {
private child: ReturnType<typeof utilityProcess.fork> | null = null
private control: BackendPortRpc | null = null
private ready: Promise<void> | null = null
private disposeFailedStartup?: () => void
private desiredRunning = true
private exited: Promise<void> = Promise.resolve()
private restartTimer: ReturnType<typeof setTimeout> | undefined
private readonly failures = new ManagedOrcadEventWindow(10 * 60_000, Date.now)
private readonly documents = new Map<number, BackendDocument>()
private stopping?: Promise<void>
private readonly retireRenderer = (_event: unknown, contents: WebContents): void =>
this.documents.get(contents.id)?.retire()
constructor(private readonly store: UsageWorktreeSource) {
if (!capturedBootstrap) {
throw new Error('Backend paths were not captured during preflight.')
}
app.on('render-process-gone', this.retireRenderer)
ipcMain.handle(
BACKEND_PORT_REQUEST,
async (event, token: unknown, purpose: unknown = 'demand') => {
if (
typeof token !== 'string' ||
!/^[0-9a-f-]{36}$/i.test(token) ||
(purpose !== 'demand' && purpose !== 'recovery')
) {
throw new Error('Invalid backend connection request.')
}
const { sender, senderFrame } = event
if (!senderFrame || senderFrame !== sender.mainFrame || !trustsBackendRenderer(sender)) {
throw new Error('This document cannot connect to Orca.')
}
const document = trackBackendDocument(
this.documents,
sender,
senderFrame,
token,
(retiredToken) => this.child?.postMessage({ type: 'retire', token: retiredToken })
)
if (!this.desiredRunning) {
throw new Error('Orca is shutting down.')
}
const ready = purpose === 'recovery' ? this.ready : this.ensureReady(true)
const child = this.child
if (!ready) {
throw new Error('Usage is unavailable. Try refreshing the usage view.')
}
await ready
if (
this.documents.get(sender.id) !== document ||
!this.desiredRunning ||
!child ||
this.child !== child ||
sender.isDestroyed() ||
sender.mainFrame !== senderFrame
) {
throw new Error('The document was replaced before Orca connected.')
}
const { port1, port2 } = new MessageChannelMain()
child.postMessage({ type: 'client', token: document.token }, [port1])
senderFrame.postMessage(BACKEND_PORT_DELIVERY, { token: document.token }, [port2])
return document.token
}
)
void this.ensureReady(false).catch(() => {})
}
automationClient(provider: 'claudeUsage' | 'codexUsage'): AutomationUsageClient {
return {
getAutomationRunUsage: async (input) => {
const ready = this.ensureReady(true)
const control = this.control
await ready
if (!this.desiredRunning || !control || this.control !== control) {
throw new Error('Usage disconnected. Try again after it reconnects.')
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The private usage service returns its typed automation result for this provider method.
return (await control.call(`${provider}:getAutomationRunUsage`, input)) as Awaited<
ReturnType<AutomationUsageClient['getAutomationRunUsage']>
>
}
}
}
stop(): Promise<void> {
this.stopping ??= (async () => {
this.desiredRunning = false
app.off('render-process-gone', this.retireRenderer)
clearTimeout(this.restartTimer)
this.restartTimer = undefined
const child = this.child
for (const document of this.documents.values()) {
document.retire()
}
ipcMain.removeHandler(BACKEND_PORT_REQUEST)
if (!child) {
return
}
const exited = this.exited
const deadline = setTimeout(() => {
if (this.child === child) {
child.kill()
}
}, ORCAD_SHUTDOWN_DEADLINE_MS + 1_000)
this.control?.close()
await exited
clearTimeout(deadline)
})()
return this.stopping
}
private notify(connected: boolean, unavailable = false): void {
for (const document of this.documents.values()) {
if (!document.contents.isDestroyed() && document.contents.mainFrame === document.frame) {
document.frame.postMessage(BACKEND_CONNECTION_CHANGED, { connected, unavailable })
}
}
}
private async ensureReady(explicit: boolean): Promise<void> {
if (!this.desiredRunning) {
throw new Error('Orca is shutting down.')
}
if (explicit) {
this.disposeFailedStartup?.()
}
if (this.ready) {
return this.ready
}
if (explicit) {
this.failures.forget('backend')
clearTimeout(this.restartTimer)
this.restartTimer = undefined
}
if (this.failures.recent('backend').length >= 3) {
throw new Error('Usage could not start. Try refreshing the usage view.')
}
const { port1, port2 } = new MessageChannelMain()
const entry = resolveWorkerThreadEntryPath(
currentWorkerEntryLayout(__dirname),
'backend-entry.js'
)
const child = utilityProcess.fork(entry, [], {
serviceName: BACKEND_UTILITY_PROCESS_NAME,
stdio: ['ignore', 'ignore', 'pipe']
})
this.child = child
this.control = new BackendPortRpc(
backendMessagePort(port1),
'desktop',
createBackendDesktopCapabilities(this.store)
)
let childPid = child.pid
child.once('spawn', () => {
childPid = child.pid
if (this.child !== child || !this.desiredRunning) {
child.kill()
}
})
let incarnation: string | undefined
let settleExit!: () => void
this.exited = new Promise<void>((resolve) => {
settleExit = resolve
})
let failed!: (error: Error) => void
let settled = false
let disposed = false
const ready = new Promise<void>((resolve, reject) => {
failed = (error) => {
if (settled) {
return
}
settled = true
if (this.child === child && this.ready === ready) {
this.disposeFailedStartup = () => {
disposed = true
const control = this.control
this.child = null
this.control = null
this.ready = null
this.disposeFailedStartup = undefined
clearTimeout(startupDeadline)
control?.close()
try {
child.kill()
} catch {}
}
}
reject(error)
}
child.on(
'message',
(message: {
type?: string
protocol?: number
capabilities?: string[]
incarnation?: string
}) => {
if (this.child !== child || settled || !this.desiredRunning) {
return
}
if (
message.type === 'ready' &&
message.protocol === 1 &&
message.capabilities?.includes('usage')
) {
incarnation = message.incarnation
settled = true
clearTimeout(startupDeadline)
resolve()
this.notify(true)
}
if (message.type === 'failed') {
failed(new Error('Usage could not start. Try refreshing the usage view.'))
child.kill()
}
}
)
})
this.ready = ready
const startupDeadline = setTimeout(() => {
failed(new Error('Usage is still unavailable. Try refreshing the usage view.'))
if (this.child === child) {
child.kill()
}
}, ORCAD_SHUTDOWN_DEADLINE_MS * 2)
child.stderr?.on('data', (chunk: Buffer) =>
console.warn(`[backend] ${chunk.toString().slice(0, 16_384)}`)
)
child.on('error', () => {
failed(new Error('Usage could not start. Try refreshing the usage view.'))
})
child.once('exit', (code) => {
recordDurableCrashBreadcrumb('backend_process_exited', {
pid: childPid ?? null,
incarnation: incarnation ?? null,
exitCode: code,
expected: disposed || !this.desiredRunning
})
clearTimeout(startupDeadline)
failed(new Error('Usage disconnected. Try again after it reconnects.'))
if (this.child === child) {
this.control?.close()
this.control = null
this.child = null
this.ready = null
this.disposeFailedStartup = undefined
if (this.desiredRunning) {
this.failures.record('backend')
}
this.notify(false, this.desiredRunning && this.failures.recent('backend').length >= 3)
if (this.desiredRunning) {
if (this.failures.recent('backend').length < 3) {
const restartTimer = setTimeout(
() => {
if (this.restartTimer !== restartTimer) {
return
}
this.restartTimer = undefined
void this.ensureReady(false).catch(() => {})
},
250 * 2 ** this.failures.recent('backend').length
)
this.restartTimer = restartTimer
}
}
}
settleExit()
})
child.postMessage({ type: 'bootstrap', config: capturedBootstrap }, [port2])
return ready
}
}
+222
View File
@@ -0,0 +1,222 @@
import { randomUUID } from 'node:crypto'
import { join } from 'node:path'
import { BackendBootstrapSchema, BACKEND_CAPABILITIES } from '../../shared/backend-service-contract'
import { BackendPortRpc } from '../../shared/backend-port-rpc'
import { setAppEnvironment } from '../../shared/app-environment'
import { agentTokenUsageSchema } from '../../shared/telemetry-agent-token-usage-schema'
import { backendMessagePort, type BackendNativePort } from './backend-message-port'
import { createDesktopBackendEnvironment } from './node-service-environment'
import { BackendServiceLifetime, activateBackendServices } from './service-composition'
import { acquireOrcadInstanceLock } from '../orcad/orcad-instance-lock'
import { OrcadInstanceLockError } from '../orcad/orcad-data-root-privacy'
import { ORCAD_SHUTDOWN_DEADLINE_MS } from '../orcad/orcad-stop-deadlines'
import { clearWindowsStdHandleInheritance } from '../startup/windows-std-handle-inheritance'
import { installWindowsIsolatedSpawner } from '../startup/windows-isolated-spawner'
import { dropE2eSpawnLogOutsideE2e } from '../startup/e2e-spawn-log-env'
import { ignoreInheritedWslHostsGateState } from '../../shared/wsl-host-internal-gate'
import type { createUsageService } from './usage-service'
import type { UsageWorktreeCatalog } from '../usage/usage-store-host'
dropE2eSpawnLogOutsideE2e()
clearWindowsStdHandleInheritance()
installWindowsIsolatedSpawner()
ignoreInheritedWslHostsGateState()
const parent:
| {
on(
event: 'message',
listener: (event: { data: unknown; ports: BackendNativePort[] }) => void
): unknown
postMessage(message: unknown): void
}
| undefined = process.parentPort
if (!parent) {
throw new Error('Backend must be launched by its desktop supervisor.')
}
const incarnation = randomUUID()
const lifetime = new BackendServiceLifetime()
const abort = new AbortController()
const clients = new Map<string, BackendPortRpc>()
let control: BackendPortRpc | undefined
let usage: ReturnType<typeof createUsageService> | undefined
let stopping: Promise<void> | undefined
let booting = false
let startup: Promise<void> | undefined
function stop(): Promise<void> {
stopping ??= (async () => {
const deadline = setTimeout(() => process.exit(1), ORCAD_SHUTDOWN_DEADLINE_MS)
abort.abort(new Error('Backend is stopping.'))
for (const client of clients.values()) {
client.close()
}
try {
await startup?.catch(() => {})
await lifetime.stop()
parent!.postMessage({ type: 'stopped', incarnation })
clearTimeout(deadline)
process.exit(0)
} catch {
console.error(`[backend ${incarnation}] Shutdown did not finish cleanly.`)
process.exit(1)
}
})()
return stopping
}
async function acquireServiceLock(userDataPath: string) {
const deadline = Date.now() + ORCAD_SHUTDOWN_DEADLINE_MS
while (!abort.signal.aborted) {
try {
return acquireOrcadInstanceLock(join(userDataPath, 'backend-service'), {
role: 'orcad',
enforcePrivateDataRoot: false,
onExclusivityLost: () => {
void stop()
}
})
} catch (error) {
if (
!(error instanceof OrcadInstanceLockError) ||
error.code !== 'orcad_instance_lock_held' ||
Date.now() >= deadline
) {
throw error
}
await new Promise<void>((resolve) => setTimeout(resolve, 250))
}
}
throw abort.signal.reason
}
async function bootstrap(raw: unknown, port: BackendNativePort): Promise<void> {
if (booting) {
port.close()
throw new Error('Backend was already bootstrapped.')
}
booting = true
const config = BackendBootstrapSchema.parse(raw)
if (
config.requiredCapabilities.some(
(capability) => !BACKEND_CAPABILITIES.some((offered) => offered === capability)
)
) {
throw new Error('Backend contract is unsupported.')
}
const host = createDesktopBackendEnvironment(config)
setAppEnvironment(host.environment)
control = new BackendPortRpc(
backendMessagePort(port),
incarnation,
async (method, params, signal) => {
if (method === 'backend.stop') {
void stop()
return null
}
if (!usage) {
throw new Error('Usage is still starting. Try again shortly.')
}
await usage.ready
return usage.invoke(method, params, true, signal)
},
() => {
void stop()
}
)
const lock = await acquireServiceLock(config.paths.userData)
// Release only after services and their worker writers have retired.
lifetime.add(({ failed }) => {
if (!failed) {
lock.release()
}
})
abort.signal.throwIfAborted()
lifetime.add(() => host.runQuitHandlers())
await activateBackendServices({
supervised: true,
registerCleanup: (cleanup) => lifetime.add(cleanup),
activate: async (registerCleanup) => {
const { createUsageService } = await import('./usage-service')
abort.signal.throwIfAborted()
const call = async (method: string, params?: unknown): Promise<unknown> => {
return control!.call(method, params)
}
usage = createUsageService({
signal: abort.signal,
userDataPath: config.paths.userData,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The private control peer returns the catalog directly from its typed Store adapter.
readCatalog: async () => (await call('usage.catalog')) as UsageWorktreeCatalog,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The private control peer returns the account owner's typed string array.
claudeProfileDirs: async () => (await call('usage.claudeProfileDirs')) as string[],
telemetry: {
isEnabled: async () => (await call('usage.telemetry.enabled')) === true,
capture: async (snapshot) =>
(await call('usage.telemetry.capture', agentTokenUsageSchema.parse(snapshot))) === true
}
})
registerCleanup(() => usage!.stop())
await usage.ready
}
})
abort.signal.throwIfAborted()
parent!.postMessage({
type: 'ready',
protocol: 1,
capabilities: BACKEND_CAPABILITIES,
incarnation,
build: config.build
})
}
parent.on('message', (event) => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only the owning desktop can send parent messages; every consumed field is validated below.
const message = event.data as { type?: unknown; config?: unknown; token?: unknown }
const [port, ...surplus] = event.ports
for (const extra of surplus) {
extra.close()
}
if (message?.type === 'bootstrap' && port) {
startup = bootstrap(message.config, port)
void startup.catch(() => {
console.error(`[backend ${incarnation}] Startup failed.`)
parent!.postMessage({ type: 'failed', incarnation })
void stop()
})
return
}
if (message?.type === 'retire' && typeof message.token === 'string') {
clients.get(message.token)?.close()
port?.close()
return
}
if (
message?.type === 'client' &&
typeof message.token === 'string' &&
port &&
usage &&
!stopping
) {
const token = message.token
clients.get(token)?.close()
let client: BackendPortRpc
client = new BackendPortRpc(
backendMessagePort(port),
incarnation,
async (method, params, signal) => {
await usage!.ready
return usage!.invoke(method, params, false, signal)
},
() => clients.delete(token)
)
clients.set(token, client)
return
}
port?.close()
})
process.on('SIGTERM', () => {
void stop()
})
process.on('SIGINT', () => {
void stop()
})
+32
View File
@@ -0,0 +1,32 @@
import type { BackendPort } from '../../shared/backend-port-rpc'
export type BackendNativePort = {
postMessage(message: unknown): void
on(event: 'message', listener: (event: { data: unknown }) => void): unknown
on(event: 'close', listener: () => void): unknown
off(event: 'message', listener: (event: { data: unknown }) => void): unknown
off(event: 'close', listener: () => void): unknown
start(): void
close(): void
}
export function backendMessagePort(port: BackendNativePort): BackendPort {
return {
postMessage: (message) => port.postMessage(message),
onMessage(listener) {
const receive = (event: { data: unknown }): void => listener(event.data)
port.on('message', receive)
port.start()
return () => {
port.off('message', receive)
}
},
onClose(listener) {
port.on('close', listener)
return () => {
port.off('close', listener)
}
},
close: () => port.close()
}
}
@@ -0,0 +1,49 @@
import type { AppEnvironment } from '../../shared/app-environment'
import type { BackendBootstrap } from '../../shared/backend-service-contract'
export function createDesktopBackendEnvironment(config: BackendBootstrap) {
return createNodeServiceEnvironment({
getPath(name) {
if (name !== 'userData') {
throw new Error(`Desktop backend path is unsupported: ${name}`)
}
return config.paths.userData
},
getAppPath: () => config.appPath,
getVersion: () => config.build,
isPackaged: () => config.isPackaged
})
}
export function createNodeServiceEnvironment(
host: Pick<
AppEnvironment,
'getPath' | 'getAppPath' | 'getVersion' | 'isPackaged' | 'getCliLauncherPath'
>
): {
environment: AppEnvironment
runQuitHandlers(): void
} {
const quitHandlers: (() => void)[] = []
return {
environment: {
...host,
onWillQuit: (handler) => quitHandlers.push(handler),
exit: (code = 0) => process.exit(code),
getAppMetrics: () => []
},
runQuitHandlers() {
const errors: unknown[] = []
for (const handler of quitHandlers.splice(0)) {
try {
handler()
} catch (error) {
errors.push(error)
}
}
if (errors.length) {
throw new AggregateError(errors, 'backend_service_quit_handlers_failed')
}
}
}
}
+50
View File
@@ -0,0 +1,50 @@
import { startOrcadEventLoopWatchdog } from '../orcad/orcad-event-loop-watchdog'
export type BackendServiceCleanup = (state: { failed: boolean }) => void | Promise<void>
/** A service keeps its ownership fence until all accepted work and writers have retired. */
export class BackendServiceLifetime {
private readonly cleanups: BackendServiceCleanup[] = []
private stopping?: Promise<void>
constructor(private readonly release: () => void = () => {}) {}
add(cleanup: BackendServiceCleanup): void {
if (this.stopping) {
throw new Error('backend_service_lifetime_stopping')
}
this.cleanups.push(cleanup)
}
stop(): Promise<void> {
this.stopping ??= Promise.resolve().then(async () => {
const errors: unknown[] = []
for (const cleanup of this.cleanups.splice(0).toReversed()) {
try {
await cleanup({ failed: errors.length > 0 })
} catch (error) {
errors.push(error)
}
}
if (errors.length === 1) {
throw errors[0]
}
if (errors.length > 1) {
throw new AggregateError(errors, 'backend_service_cleanup_failed')
}
this.release()
})
return this.stopping
}
}
/** Both hosts enter service activation here without importing an inactive service set. */
export async function activateBackendServices<T>(options: {
supervised: boolean
registerCleanup: (cleanup: BackendServiceCleanup) => void
activate: (registerCleanup: (cleanup: BackendServiceCleanup) => void) => Promise<T>
}): Promise<T> {
const watchdog = startOrcadEventLoopWatchdog({ supervised: options.supervised })
options.registerCleanup(() => watchdog?.stop())
return options.activate(options.registerCleanup)
}
+152
View File
@@ -0,0 +1,152 @@
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { createUsageService } from './usage-service'
import { createTestUsageStoreHost } from '../usage/usage-store-host-test-fixture'
import type { ClaudeUsagePersistedState } from '../claude-usage/types'
import type { UsageCacheSplitResult } from '../usage/cache/usage-source-cache-file'
import {
scanClaudeUsageFilesViaWorker,
splitUsageCacheFileViaWorker,
stopUsageScanWorker
} from '../usage/scan-worker/usage-scan-worker-spawn'
vi.mock('../usage/scan-worker/usage-scan-worker-spawn', () => ({
scanClaudeUsageFilesViaWorker: vi.fn(),
scanCodexUsageFilesViaWorker: vi.fn(),
scanOpenCodeUsageDatabasesViaWorker: vi.fn(),
scanMuseUsageFilesViaWorker: vi.fn(),
splitUsageCacheFileViaWorker: vi.fn(),
stopUsageScanWorker: vi.fn(async () => {})
}))
let directory: string
let services: ReturnType<typeof createUsageService>[]
let releasePending: (() => void)[]
function nextTurn(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve))
}
function reportText(state: ClaudeUsagePersistedState): string {
const { processedFiles: _files, ...report } = state
return JSON.stringify(report)
}
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'orca-backend-usage-'))
services = []
releasePending = []
vi.clearAllMocks()
})
afterEach(async () => {
releasePending.forEach((release) => release())
await Promise.all(services.map((service) => service.stop()))
rmSync(directory, { recursive: true, force: true })
vi.restoreAllMocks()
})
function historicalState(): ClaudeUsagePersistedState {
return {
schemaVersion: 6,
worktreeFingerprint: null,
processedFiles: [],
sessions: [],
dailyAggregates: [
{
day: '2026-10-09',
model: null,
projectKey: 'unscoped',
projectLabel: 'Unknown location',
repoId: null,
worktreeId: null,
turnCount: 1,
zeroCacheReadTurnCount: 1,
inputTokens: 7,
outputTokens: 2,
cacheReadTokens: 0,
cacheWriteTokens: 0,
cacheWrite1hTokens: 0
}
],
scanState: {
enabled: false,
lastScanStartedAt: null,
lastScanCompletedAt: 1,
lastScanError: null
}
}
}
it('awaits loading before the first disabled cached snapshot and all historical queries', async () => {
const cached = historicalState()
writeFileSync(
join(directory, 'orca-claude-usage.json'),
`${JSON.stringify(cached)}${' '.repeat(9 * 1024 * 1024)}`
)
const pending = Promise.withResolvers<UsageCacheSplitResult>()
releasePending.push(() => pending.resolve({ reportText: reportText(cached), migrated: false }))
vi.mocked(splitUsageCacheFileViaWorker).mockReturnValueOnce(pending.promise)
const service = createUsageService(createTestUsageStoreHost({ userDataPath: directory }))
services.push(service)
const settled = vi.fn()
const args = { scope: 'all', range: 'all', limit: 7, kind: 'model' }
const requests = [
'getSnapshot',
'getScanState',
'getSummary',
'getDaily',
'getBreakdown',
'getRecentSessions'
].map((query) =>
Promise.resolve(service.invoke(`claudeUsage:${query}`, args, false)).then((value) => {
settled()
return value
})
)
await nextTurn()
expect(settled).not.toHaveBeenCalled()
expect(scanClaudeUsageFilesViaWorker).not.toHaveBeenCalled()
pending.resolve({ reportText: reportText(cached), migrated: false })
const [snapshot, scanState, summary, daily] = await Promise.all(requests)
expect(snapshot).toMatchObject({
scanState: { enabled: false, isScanning: false, hasAnyClaudeData: true },
summary: { turns: 1, inputTokens: 7 }
})
expect(scanState).toMatchObject({ enabled: false, isScanning: false, hasAnyClaudeData: true })
expect(summary).toMatchObject({ turns: 1, inputTokens: 7 })
expect(daily).toEqual([
{ day: '2026-10-09', inputTokens: 7, outputTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0 }
])
expect(scanClaudeUsageFilesViaWorker).not.toHaveBeenCalled()
})
it('retires the scanner before releasing ownership and preserves enabled state without a shutdown error', async () => {
const service = createUsageService(createTestUsageStoreHost({ userDataPath: directory }))
services.push(service)
await service.invoke('claudeUsage:setEnabled', { enabled: true }, false)
const scanStarted = Promise.withResolvers<void>()
const scan = Promise.withResolvers<never>()
vi.mocked(scanClaudeUsageFilesViaWorker).mockImplementationOnce(() => {
scanStarted.resolve()
return scan.promise
})
const refreshing = service.invoke('claudeUsage:refresh', { force: true }, false)
await scanStarted.promise
const retirement = Promise.withResolvers<void>()
releasePending.push(() => {
scan.reject(new Error('worker retired'))
retirement.resolve()
})
vi.mocked(stopUsageScanWorker).mockImplementationOnce(() => retirement.promise)
const stopped = vi.fn()
const stopping = service.stop().then(stopped)
await nextTurn()
expect(stopped).not.toHaveBeenCalled()
await expect(
service.invoke('claudeUsage:setEnabled', { enabled: false }, false)
).rejects.toThrow()
scan.reject(new Error('worker retired'))
retirement.resolve()
await Promise.all([refreshing, stopping])
expect(JSON.parse(readFileSync(join(directory, 'orca-claude-usage.json'), 'utf8'))).toMatchObject(
{
scanState: { enabled: true, lastScanError: null }
}
)
})
+142
View File
@@ -0,0 +1,142 @@
import { z } from 'zod'
import { ClaudeUsageStore } from '../claude-usage/store'
import { CodexUsageStore } from '../codex-usage/store'
import { OpenCodeUsageStore } from '../opencode-usage/store'
import { MuseUsageStore } from '../muse-usage/store'
import type { UsageStoreHost } from '../usage/usage-store-host'
import { stopUsageScanWorker } from '../usage/scan-worker/usage-scan-worker-spawn'
import type { UsageProviderKey } from '../../shared/backend-service-contract'
const rangeSchema = z.object({
scope: z.string().max(4096),
range: z.string().max(128),
limit: z.number().int().nonnegative().optional()
})
const automationSchema = z.object({
worktreeId: z.string().nullable(),
terminalSessionId: z.string().nullable(),
startedAt: z.number().nullable(),
completedAt: z.number().nullable()
})
type Handler = (params: unknown) => unknown
type QueryStore<Scope, Range, Kind> = {
whenLoaded(): Promise<void>
getScanState(): unknown
setEnabled(enabled: boolean): unknown
refresh(force?: boolean): unknown
getSnapshot(scope: Scope, range: Range, limit?: number): unknown
getSummary(scope: Scope, range: Range): unknown
getDaily(scope: Scope, range: Range): unknown
getBreakdown(scope: Scope, range: Range, kind: Kind): unknown
getRecentSessions(scope: Scope, range: Range, limit?: number): unknown
}
function registerUsageQueries<Scope extends string, Range extends string, Kind extends string>(
handlers: Map<string, Handler>,
prefix: UsageProviderKey,
store: QueryStore<Scope, Range, Kind>
): void {
const query =
(fn: (scope: Scope, range: Range, limit?: number) => unknown): Handler =>
(raw) => {
const args = rangeSchema.parse(raw)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Each registered provider uses the same public scope/range contract; the generic preserves its query signature.
return fn(args.scope as Scope, args.range as Range, args.limit)
}
handlers.set(`${prefix}:getScanState`, () => store.getScanState())
handlers.set(`${prefix}:setEnabled`, (raw) =>
store.setEnabled(z.object({ enabled: z.boolean() }).parse(raw).enabled)
)
handlers.set(`${prefix}:refresh`, (raw) =>
store.refresh(z.object({ force: z.boolean().optional() }).optional().parse(raw)?.force ?? false)
)
handlers.set(
`${prefix}:getSnapshot`,
query((scope, range, limit) => store.getSnapshot(scope, range, limit))
)
handlers.set(
`${prefix}:getSummary`,
query((scope, range) => store.getSummary(scope, range))
)
handlers.set(
`${prefix}:getDaily`,
query((scope, range) => store.getDaily(scope, range))
)
handlers.set(
`${prefix}:getRecentSessions`,
query((scope, range, limit) => store.getRecentSessions(scope, range, limit))
)
handlers.set(`${prefix}:getBreakdown`, (raw) => {
const args = rangeSchema.extend({ kind: z.string().max(128) }).parse(raw)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These are the provider API arguments shared by all four registered query stores.
return store.getBreakdown(args.scope as Scope, args.range as Range, args.kind as Kind)
})
}
export function createUsageService(host: UsageStoreHost) {
const stores = {
claudeUsage: new ClaudeUsageStore(host),
codexUsage: new CodexUsageStore(host),
openCodeUsage: new OpenCodeUsageStore(host),
museUsage: new MuseUsageStore(host)
}
const all = Object.values(stores)
const handlers = new Map<string, Handler>()
registerUsageQueries(handlers, 'claudeUsage', stores.claudeUsage)
registerUsageQueries(handlers, 'codexUsage', stores.codexUsage)
registerUsageQueries(handlers, 'openCodeUsage', stores.openCodeUsage)
registerUsageQueries(handlers, 'museUsage', stores.museUsage)
let accepting = true
const active = new Set<Promise<unknown>>()
const stop = async (): Promise<void> => {
accepting = false
for (const store of all) {
store.stopScanAdmission()
}
await ready
await stopUsageScanWorker()
await Promise.allSettled(active)
await Promise.all(all.map((store) => store.settleScansForShutdown()))
await Promise.all(all.map((store) => store.flush()))
}
const ready = Promise.all(all.map((store) => store.whenLoaded())).then(() => {})
const dispatch = async (
method: string,
params: unknown,
privileged: boolean
): Promise<unknown> => {
if (!accepting) {
throw new Error('Usage service is stopping. Try again after Orca reconnects.')
}
if (privileged && method === 'claudeUsage:getAutomationRunUsage') {
return stores.claudeUsage.getAutomationRunUsage(automationSchema.parse(params))
}
if (privileged && method === 'codexUsage:getAutomationRunUsage') {
return stores.codexUsage.getAutomationRunUsage(automationSchema.parse(params))
}
const handler = handlers.get(method)
if (!handler) {
throw new Error('This usage operation is not supported.')
}
return handler(params)
}
return {
ready,
invoke(
method: string,
params: unknown,
privileged: boolean,
signal?: AbortSignal
): Promise<unknown> {
const operation = ready.then(() => {
signal?.throwIfAborted()
return dispatch(method, params, privileged)
})
active.add(operation)
void operation.finally(() => active.delete(operation)).catch(() => {})
return operation
},
stop
}
}
@@ -1,7 +1,8 @@
import { createTestUsageStoreHost } from '../usage/usage-store-host-test-fixture'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { vi, type Mock } from 'vitest'
import { vi } from 'vitest'
import type { ClaudeUsagePersistedFile, ClaudeUsagePersistedState } from './types'
import { readClaudeUsageScanFile } from './transcript-record-parser'
import { projectClaudeUsageScanFile } from './transcript-usage-projection'
@@ -14,14 +15,14 @@ import {
import { normalizeClaudeUsageSourceFiles } from './persisted-projection-validation'
import { usageSourceCachePath } from '../usage/cache/usage-source-cache-file'
import { ClaudeUsageStore, initClaudeUsagePath } from './store'
import { ClaudeUsageStore } from './store'
import { listClaudeTranscriptFiles } from './transcript-file-discovery'
import {
scanClaudeUsageFilesViaWorker,
splitUsageCacheFileViaWorker
} from '../usage/scan-worker/usage-scan-worker-spawn'
export async function createPersistedUsageTestFixture(getPath: Mock<() => string>) {
export async function createPersistedUsageTestFixture() {
const directory = await mkdtemp(join(tmpdir(), 'orca-claude-persisted-usage-'))
const transcript = join(directory, 'session.jsonl')
const reportPath = join(directory, 'orca-claude-usage.json')
@@ -32,25 +33,25 @@ export async function createPersistedUsageTestFixture(getPath: Mock<() => string
reuse: true
}
const transcripts = [transcript]
getPath.mockReturnValue(directory)
initClaudeUsagePath()
vi.mocked(listClaudeTranscriptFiles).mockReset()
vi.mocked(listClaudeTranscriptFiles).mockImplementation(async () => [...transcripts].sort())
vi.mocked(splitUsageCacheFileViaWorker).mockReset()
vi.mocked(splitUsageCacheFileViaWorker).mockImplementation(splitClaudeUsageCacheFile)
vi.mocked(scanClaudeUsageFilesViaWorker).mockReset()
vi.mocked(scanClaudeUsageFilesViaWorker).mockImplementation(async (worktrees, ref) => {
const previous = await readClaudeUsageSourceCache(ref)
const result = await scanClaudeUsageFiles(
worktrees,
previous.sources,
undefined,
[],
previous.verifiedSources
)
await persistClaudeUsageSourceCache(ref, result.processedFiles, previous)
return { sessions: result.sessions, dailyAggregates: result.dailyAggregates }
})
vi.mocked(scanClaudeUsageFilesViaWorker).mockImplementation(
async (worktrees, ref, profileDirs) => {
const previous = await readClaudeUsageSourceCache(ref)
const result = await scanClaudeUsageFiles(
worktrees,
previous.sources,
undefined,
profileDirs,
previous.verifiedSources
)
await persistClaudeUsageSourceCache(ref, result.processedFiles, previous)
return { sessions: result.sessions, dailyAggregates: result.dailyAggregates }
}
)
function row(index: number, duplicate = false): string {
return `${JSON.stringify({
@@ -118,13 +119,7 @@ export async function createPersistedUsageTestFixture(getPath: Mock<() => string
project,
state,
writeResumable,
createStore: () =>
new ClaudeUsageStore({
getRepos: () => [],
getAllWorktreeMeta: () => ({}),
getRememberedWorktreeDisplayNames: () => new Map(),
whenLoaded: async () => {}
}),
createStore: () => new ClaudeUsageStore(createTestUsageStoreHost({ userDataPath: directory })),
decodedSources: async () => {
const read = await readClaudeUsageSourceCache(sourceRef)
return normalizeClaudeUsageSourceFiles(read.sources, read.verifiedSources).processedFiles
+9 -20
View File
@@ -1,5 +1,4 @@
import { claudeTokenSessions } from '../usage/reporting/token-usage/agent-token-usage'
import { app } from 'electron'
import { join } from 'node:path'
import type {
ClaudeUsageBreakdownKind,
@@ -12,7 +11,7 @@ import type {
ClaudeUsageSummary
} from '../../shared/claude-usage-types'
import type { AutomationRunUsage } from '../../shared/automations/history/automation-run'
import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata'
import type { UsageStoreHost } from '../usage/usage-store-host'
import type { ClaudeUsagePersistedState } from './types'
import {
scanClaudeUsageFilesViaWorker,
@@ -29,9 +28,6 @@ import { parseClaudeUsageReport, serializeClaudeUsageReport } from './persisted-
// v7 stores protected column checkpoints on the worker and a separately protected report.
const SCHEMA_VERSION = 7
// Capture after configureDevUserDataPath(), before app.setName() changes the derived userData path.
let _claudeUsageFile: string | null = null
function getDefaultState(): ClaudeUsagePersistedState {
return {
schemaVersion: SCHEMA_VERSION,
@@ -63,30 +59,19 @@ function normalizePersistedState(state: ClaudeUsagePersistedState): ClaudeUsageP
}
}
export function initClaudeUsagePath(): void {
_claudeUsageFile = join(app.getPath('userData'), 'orca-claude-usage.json')
}
function getClaudeUsageFile(): string {
if (!_claudeUsageFile) {
_claudeUsageFile = join(app.getPath('userData'), 'orca-claude-usage.json')
}
return _claudeUsageFile
}
export class ClaudeUsageStore extends UsageProviderStoreLifecycle<
'processedFiles',
ClaudeUsagePersistedState,
'hasAnyClaudeData'
> {
constructor(store: UsageWorktreeSource) {
super(store, {
constructor(host: UsageStoreHost) {
super(host, {
tokenUsage: {
provider: 'claude',
selectSessions: (state) => claudeTokenSessions(state.sessions)
},
logTag: '[claude-usage]',
resolveCacheFile: getClaudeUsageFile,
resolveCacheFile: () => join(host.userDataPath, 'orca-claude-usage.json'),
createDefaultState: getDefaultState,
normalizeState: normalizePersistedState,
parseReport: parseClaudeUsageReport,
@@ -94,7 +79,11 @@ export class ClaudeUsageStore extends UsageProviderStoreLifecycle<
providerId: 'claude',
sourceKey: 'processedFiles',
dataPresenceKey: 'hasAnyClaudeData',
scan: scanClaudeUsageFilesViaWorker,
scan: async (worktrees, sourceCache) => {
const profileDirs = await host.claudeProfileDirs()
host.signal.throwIfAborted()
return scanClaudeUsageFilesViaWorker(worktrees, sourceCache, profileDirs)
},
splitCacheFile: splitUsageCacheFileViaWorker
})
}
+24 -30
View File
@@ -1,10 +1,10 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, vi } from 'vitest'
import type { Mock } from 'vitest'
import { createTestUsageStoreHost } from '../usage/usage-store-host-test-fixture'
import { scanCodexUsageFilesViaWorker } from '../usage/scan-worker/usage-scan-worker-spawn'
import { CodexUsageStore, initCodexUsagePath } from './store'
import { CodexUsageStore } from './store'
import type { CodexUsagePersistedState } from './types'
export function createEmptyScanResult() {
@@ -66,39 +66,33 @@ export function createWorktreeUsageSession(worktreeId: string) {
}
export function createStoreWithState(state: Partial<CodexUsagePersistedState>): CodexUsageStore {
const store = new CodexUsageStore({
getRepos: () => [],
getAllWorktreeMeta: () => ({}),
getRememberedWorktreeDisplayNames: () => new Map(),
whenLoaded: async () => {}
})
;(store as unknown as { state: CodexUsagePersistedState }).state = {
schemaVersion: 1,
worktreeFingerprint: null,
processedFiles: [],
sessions: [],
dailyAggregates: [],
scanState: {
enabled: false,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
},
...state
}
return store
writeFileSync(
join(activeUserData, 'orca-codex-usage.json'),
JSON.stringify({
schemaVersion: 7,
worktreeFingerprint: null,
processedFiles: [],
sessions: [],
dailyAggregates: [],
scanState: {
enabled: false,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
},
...state
})
)
return new CodexUsageStore(createTestUsageStoreHost({ userDataPath: activeUserData }))
}
/** The caller owns the hoisted Electron mock. */
export function setupCodexUsageStoreEnv(getPathMock: Mock): { tempUserData: string } {
let activeUserData: string
export function setupCodexUsageStoreEnv(): { tempUserData: string } {
const env = { tempUserData: '' }
beforeEach(() => {
env.tempUserData = mkdtempSync(join(tmpdir(), 'orca-codex-usage-store-'))
getPathMock.mockReturnValue(env.tempUserData)
initCodexUsagePath()
activeUserData = env.tempUserData
vi.mocked(scanCodexUsageFilesViaWorker).mockReset()
vi.mocked(scanCodexUsageFilesViaWorker).mockResolvedValue(createEmptyScanResult())
vi.useFakeTimers()
+4 -18
View File
@@ -1,5 +1,4 @@
import { codexOpenCodeTokenSessions } from '../usage/reporting/token-usage/agent-token-usage'
import { app } from 'electron'
import { join } from 'node:path'
import type {
CodexUsageBreakdownKind,
@@ -12,7 +11,7 @@ import type {
CodexUsageSummary
} from '../../shared/codex-usage-types'
import type { AutomationRunUsage } from '../../shared/automations/history/automation-run'
import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata'
import type { UsageStoreHost } from '../usage/usage-store-host'
import type { CodexUsagePersistedState } from './types'
import type { AutomationUsageLookupInput } from './codex-automation-run-attribution'
import { CODEX_USAGE_SCHEMA_VERSION, codexUsageProvider } from './codex-usage-provider'
@@ -24,8 +23,6 @@ import { splitUsageCacheFileViaWorker } from '../usage/scan-worker/usage-scan-wo
const SCHEMA_VERSION = CODEX_USAGE_SCHEMA_VERSION
let _codexUsageFile: string | null = null
function getDefaultState(): CodexUsagePersistedState {
return {
schemaVersion: SCHEMA_VERSION,
@@ -63,30 +60,19 @@ export function normalizePersistedState(state: CodexUsagePersistedState): CodexU
}
}
export function initCodexUsagePath(): void {
_codexUsageFile = join(app.getPath('userData'), 'orca-codex-usage.json')
}
function getCodexUsageFile(): string {
if (!_codexUsageFile) {
_codexUsageFile = join(app.getPath('userData'), 'orca-codex-usage.json')
}
return _codexUsageFile
}
export class CodexUsageStore extends UsageProviderStoreLifecycle<
'processedFiles',
CodexUsagePersistedState,
'hasAnyCodexData'
> {
constructor(store: UsageWorktreeSource) {
super(store, {
constructor(host: UsageStoreHost) {
super(host, {
tokenUsage: {
provider: 'codex',
selectSessions: (state) => codexOpenCodeTokenSessions(state.sessions)
},
logTag: '[codex-usage]',
resolveCacheFile: getCodexUsageFile,
resolveCacheFile: () => join(host.userDataPath, 'orca-codex-usage.json'),
createDefaultState: getDefaultState,
normalizeState: normalizePersistedState,
sourceKey: 'processedFiles',
@@ -1,4 +1,5 @@
import { STT_UTILITY_PROCESS_NAME } from '../../speech/stt-worker-process'
import { BACKEND_UTILITY_PROCESS_NAME } from '../../../shared/backend-service-contract'
export type ProcessGoneSource = 'renderer' | 'child'
export type ExpectedTeardownScope = 'none' | 'renderer-reload' | 'app-shutdown'
@@ -14,7 +15,10 @@ const RECOVERABLE_UTILITY_SERVICE_NAMES = new Set([
'video_capture.mojom.VideoCaptureService'
])
// Orca's own utility processes whose exits the app already handles and reports in place.
const RECOVERABLE_ORCA_UTILITY_NAMES = new Set([STT_UTILITY_PROCESS_NAME])
const RECOVERABLE_ORCA_UTILITY_NAMES = new Set([
STT_UTILITY_PROCESS_NAME,
BACKEND_UTILITY_PROCESS_NAME
])
const RECOVERABLE_CHILD_PROCESS_REASONS = new Set(['abnormal-exit', 'crashed', 'killed'])
const NON_RECOVERABLE_RENDERER_REASONS = new Set(['integrity-failure'])
@@ -67,6 +67,50 @@ afterEach(() => {
})
describe('recordProcessGoneCrash', () => {
it('reports no application crash for managed utilities with their own failure presentation', async () => {
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
const utility = (name: string, reason: string, exitCode: number) =>
event({
source: 'child',
processType: 'Utility',
reason,
exitCode,
details: { name, serviceName: 'node.mojom.NodeService', type: 'Utility' }
})
for (const name of ['Orca Speech', 'Orca backend']) {
for (const [reason, exitCode] of [
['killed', 9],
['crashed', 11],
['oom', 1]
] as const) {
recordProcessGoneCrash(
{ record, attachDetails } as never,
utility(name, reason, exitCode),
new ProcessGoneDedupe(),
noMinidump
)
}
}
recordProcessGoneCrash(
{ record, attachDetails } as never,
utility('Some Other Service', 'crashed', 11),
new ProcessGoneDedupe(),
noMinidump
)
await vi.waitFor(() => expect(record).toHaveBeenCalledOnce())
expect(record.mock.calls[0][0].details).toMatchObject({ name: 'Some Other Service' })
expect(getCrashBreadcrumbSnapshot()).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'process_gone_suppressed',
data: expect.objectContaining({ name: 'Orca backend', reason: 'killed', exitCode: 9 })
})
])
)
})
it('allows the same renderer crash to retry after persistence fails', async () => {
const record = vi
.fn()
@@ -12,7 +12,6 @@ import type { StatsCollector } from '../../stats/collector'
import { registerFilesystemHandlers } from '../filesystem/filesystem'
import type { CommitMessageAgentEnvironmentResolvers } from '../../text-generation/commit-message-agent-environment'
import { registerFilesystemWatcherHandlers } from '../filesystem/watchers/filesystem-watcher-handlers'
import { registerUsageProviderHandlers } from '../usage-provider-handlers'
import { registerGitHubHandlers } from '../source-control/github'
import { registerGitLabHandlers } from '../source-control/gitlab'
import { registerHostedReviewHandlers } from '../source-control/hosted-review'
@@ -85,10 +84,6 @@ import {
setTrustedClipboardRendererWebContentsId
} from '../../window/clipboard-ipc-handlers'
import { isDashboardPopoutRenderer } from '../../window/dashboard-popout-window'
import type { ClaudeUsageStore } from '../../claude-usage/store'
import type { CodexUsageStore } from '../../codex-usage/store'
import type { OpenCodeUsageStore } from '../../opencode-usage/store'
import type { MuseUsageStore } from '../../muse-usage/store'
import type { RateLimitService } from '../../rate-limits/service/rate-limit-service'
import type { CodexAccountService } from '../../codex-accounts/service'
import type { ClaudeAccountService } from '../../claude-accounts/service'
@@ -147,10 +142,6 @@ export function registerCoreHandlers(
store: Store,
runtime: OrcaRuntimeService,
stats: StatsCollector,
claudeUsage: ClaudeUsageStore,
codexUsage: CodexUsageStore,
openCodeUsage: OpenCodeUsageStore,
museUsage: MuseUsageStore,
codexAccounts: CodexAccountService,
claudeAccounts: ClaudeAccountService,
rateLimits: RateLimitService,
@@ -188,7 +179,6 @@ export function registerCoreHandlers(
})
registerCliHandlers()
registerPreflightHandlers(store)
registerUsageProviderHandlers({ claudeUsage, codexUsage, openCodeUsage, museUsage })
registerCodexAccountHandlers(codexAccounts, () => store.getSettings())
registerAgentHookHandlers(runtime, { getPtyIdForPaneKey })
registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService)
-78
View File
@@ -1,78 +0,0 @@
import { ipcMain } from 'electron'
import type { ClaudeUsageStore } from '../claude-usage/store'
import type { CodexUsageStore } from '../codex-usage/store'
import type { OpenCodeUsageStore } from '../opencode-usage/store'
import type { MuseUsageStore } from '../muse-usage/store'
type UsageProviderStores = {
claudeUsage: ClaudeUsageStore
codexUsage: CodexUsageStore
openCodeUsage: OpenCodeUsageStore
museUsage: MuseUsageStore
}
type UsageProviderChannelPrefix = keyof UsageProviderStores
type UsageProviderHandlerStore<Scope, Range, BreakdownKind> = {
whenLoaded: () => Promise<void>
getScanState: () => unknown
setEnabled: (enabled: boolean) => unknown
refresh: (force?: boolean) => unknown
getSnapshot: (scope: Scope, range: Range, limit?: number) => unknown
getSummary: (scope: Scope, range: Range) => unknown
getDaily: (scope: Scope, range: Range) => unknown
getBreakdown: (scope: Scope, range: Range, kind: BreakdownKind) => unknown
getRecentSessions: (scope: Scope, range: Range, limit?: number) => unknown
}
type UsageRangeArgs<Scope, Range> = {
scope: Scope
range: Range
}
function registerProviderHandlers<Scope, Range, BreakdownKind>(
prefix: UsageProviderChannelPrefix,
usage: UsageProviderHandlerStore<Scope, Range, BreakdownKind>
): void {
// Wait for worker hydration so getters cannot return persisted-state defaults.
ipcMain.handle(`${prefix}:getScanState`, async () => {
await usage.whenLoaded()
return usage.getScanState()
})
ipcMain.handle(`${prefix}:setEnabled`, (_event, args: { enabled: boolean }) =>
usage.setEnabled(args.enabled)
)
ipcMain.handle(`${prefix}:refresh`, (_event, args?: { force?: boolean }) =>
usage.refresh(args?.force ?? false)
)
ipcMain.handle(
`${prefix}:getSnapshot`,
async (_event, args: UsageRangeArgs<Scope, Range> & { limit?: number }) => {
await usage.whenLoaded()
return usage.getSnapshot(args.scope, args.range, args.limit)
}
)
ipcMain.handle(`${prefix}:getSummary`, (_event, args: UsageRangeArgs<Scope, Range>) =>
usage.getSummary(args.scope, args.range)
)
ipcMain.handle(`${prefix}:getDaily`, (_event, args: UsageRangeArgs<Scope, Range>) =>
usage.getDaily(args.scope, args.range)
)
ipcMain.handle(
`${prefix}:getBreakdown`,
(_event, args: UsageRangeArgs<Scope, Range> & { kind: BreakdownKind }) =>
usage.getBreakdown(args.scope, args.range, args.kind)
)
ipcMain.handle(
`${prefix}:getRecentSessions`,
(_event, args: UsageRangeArgs<Scope, Range> & { limit?: number }) =>
usage.getRecentSessions(args.scope, args.range, args.limit)
)
}
export function registerUsageProviderHandlers(stores: UsageProviderStores): void {
registerProviderHandlers('claudeUsage', stores.claudeUsage)
registerProviderHandlers('codexUsage', stores.codexUsage)
registerProviderHandlers('openCodeUsage', stores.openCodeUsage)
registerProviderHandlers('museUsage', stores.museUsage)
}
+4 -16
View File
@@ -1,4 +1,3 @@
import { app } from 'electron'
import { join } from 'node:path'
import type {
MuseUsageBreakdownKind,
@@ -10,7 +9,7 @@ import type {
MuseUsageSnapshot,
MuseUsageSummary
} from '../../shared/muse-usage-types'
import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata'
import type { UsageStoreHost } from '../usage/usage-store-host'
import { UsageProviderStoreLifecycle } from '../usage/provider-lifecycle/usage-provider-store-lifecycle'
import { splitUsageCacheFileViaWorker } from '../usage/scan-worker/usage-scan-worker-spawn'
import {
@@ -27,26 +26,15 @@ import {
} from './snapshot-rollups'
import type { MuseUsageDailyAggregate, MuseUsagePersistedState, MuseUsageSession } from './types'
let museUsageFile: string | null = null
export function initMuseUsagePath(): void {
museUsageFile = join(app.getPath('userData'), 'orca-muse-usage.json')
}
function getMuseUsageFile(): string {
museUsageFile ??= join(app.getPath('userData'), 'orca-muse-usage.json')
return museUsageFile
}
export class MuseUsageStore extends UsageProviderStoreLifecycle<
'processedFiles',
MuseUsagePersistedState,
'hasAnyMuseData'
> {
constructor(store: UsageWorktreeSource) {
super(store, {
constructor(host: UsageStoreHost) {
super(host, {
logTag: '[muse-usage]',
resolveCacheFile: getMuseUsageFile,
resolveCacheFile: () => join(host.userDataPath, 'orca-muse-usage.json'),
createDefaultState: getDefaultMuseUsageState,
normalizeState: normalizeMuseUsageState,
sourceKey: 'processedFiles',
+4 -18
View File
@@ -1,5 +1,4 @@
import { codexOpenCodeTokenSessions } from '../usage/reporting/token-usage/agent-token-usage'
import { app } from 'electron'
import { join } from 'node:path'
import type {
OpenCodeUsageBreakdownKind,
@@ -11,7 +10,7 @@ import type {
OpenCodeUsageSnapshot,
OpenCodeUsageSummary
} from '../../shared/opencode-usage-types'
import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata'
import type { UsageStoreHost } from '../usage/usage-store-host'
import type {
OpenCodeUsageDailyAggregate,
OpenCodeUsagePersistedState,
@@ -32,32 +31,19 @@ import {
import { UsageProviderStoreLifecycle } from '../usage/provider-lifecycle/usage-provider-store-lifecycle'
import { splitUsageCacheFileViaWorker } from '../usage/scan-worker/usage-scan-worker-spawn'
let _openCodeUsageFile: string | null = null
export function initOpenCodeUsagePath(): void {
_openCodeUsageFile = join(app.getPath('userData'), 'orca-opencode-usage.json')
}
function getOpenCodeUsageFile(): string {
if (!_openCodeUsageFile) {
_openCodeUsageFile = join(app.getPath('userData'), 'orca-opencode-usage.json')
}
return _openCodeUsageFile
}
export class OpenCodeUsageStore extends UsageProviderStoreLifecycle<
'processedDatabases',
OpenCodeUsagePersistedState,
'hasAnyOpenCodeData'
> {
constructor(store: UsageWorktreeSource) {
super(store, {
constructor(host: UsageStoreHost) {
super(host, {
tokenUsage: {
provider: 'opencode',
selectSessions: (state) => codexOpenCodeTokenSessions(state.sessions)
},
logTag: '[opencode-usage]',
resolveCacheFile: getOpenCodeUsageFile,
resolveCacheFile: () => join(host.userDataPath, 'orca-opencode-usage.json'),
createDefaultState: getDefaultState,
normalizeState: normalizePersistedState,
sourceKey: 'processedDatabases',
+8 -8
View File
@@ -29,7 +29,7 @@ import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import { restoreOrcadTerminalAuthority } from './orcad-restored-terminal-authority'
import { installOrcadFeatureReadiness } from '../runtime/rpc/orcad-feature-readiness'
import { ORCA_SERVE_SUPERVISED_ENV } from '../../shared/serve-supervised-terminal-durability'
import { startOrcadEventLoopWatchdog } from './orcad-event-loop-watchdog'
import { activateBackendServices } from '../backend/service-composition'
import { isAgentStatusHooksEnabled } from '../../shared/agent-hook-listener/configuration/agent-status-hooks-setting'
let closeOrcadObservability = (): Promise<void> => Promise.resolve()
@@ -44,7 +44,12 @@ export async function startOrcad(options: OrcadOptions = {}): Promise<OrcadHandl
process.env.ORCA_USER_DATA_PATH = userDataPath
const { readiness, instance, stop } = await startOrcadWithHost(
userDataPath,
(registerCleanup) => startOrcadRuntime(options, registerCleanup),
(registerCleanup) =>
activateBackendServices({
supervised: process.env[ORCA_SERVE_SUPERVISED_ENV] === '1',
registerCleanup,
activate: (cleanup) => activateOrcadRuntime(options, cleanup)
}),
async () => {
try {
runOrcadQuitHandlers()
@@ -60,16 +65,11 @@ export async function startOrcad(options: OrcadOptions = {}): Promise<OrcadHandl
return { readiness, managedStop: { version, runtimeId: readiness.runtimeId, instance }, stop }
}
async function startOrcadRuntime(
async function activateOrcadRuntime(
options: OrcadOptions,
registerCleanup: (cleanup: OrcadRuntimeCleanup) => void
): Promise<Pick<OrcadHandle, 'readiness'>> {
const { time: timed, report: reportStartupTiming } = createOrcadStartupTiming()
// Read before startOrcadDaemon consumes the supervised flag; only a supervised server exits on a long stall.
const eventLoopWatchdog = startOrcadEventLoopWatchdog({
supervised: process.env[ORCA_SERVE_SUPERVISED_ENV] === '1'
})
registerCleanup(() => eventLoopWatchdog?.stop())
// Overlap login-shell probing with imports and store startup.
const loginEnvironment = adoptWslHostLoginEnvironmentLogged()
const { startOrcadDaemon, stopOrcadDaemon } = await import('./orcad-daemon-supervision')
+6 -23
View File
@@ -3,6 +3,7 @@ import { join } from 'node:path'
import process from 'node:process'
import { ORCAD_CLI_PACKAGE_FILENAME } from '../../shared/orcad-artifacts'
import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment'
import { createNodeServiceEnvironment } from '../backend/node-service-environment'
import { setSecretStore } from '../../shared/secret-store'
import { resolveOrcadInstallRoot, resolveOrcadPath } from './orcad-app-paths'
import { getOrcadCliLauncherPath } from './orcad-cli-launcher'
@@ -17,33 +18,15 @@ export function runOrcadQuitHandlers(): void {
}
function createNodeAppEnvironment(): AppEnvironment {
const quitHandlers: (() => void)[] = []
// Synchronous quit handlers must not preempt the signal handler’s awaited runtime/browser teardown.
quitRegisteredHandlers = (): void => {
const errors: unknown[] = []
for (const handler of quitHandlers.splice(0)) {
try {
handler()
} catch (error) {
errors.push(error)
}
}
// Failed handlers may leave writers running and keep the instance lock held.
if (errors.length > 0) {
throw new AggregateError(errors, 'orcad_quit_handlers_failed')
}
}
return {
const host = createNodeServiceEnvironment({
getPath: resolveOrcadPath,
getAppPath: () => resolveOrcadInstallRoot(),
getVersion: () => process.env.ORCA_VERSION ?? '0.0.0-orcad',
// Packaged gates production HTTPS/CLI/PATH policy; layout callers must inspect asar separately.
isPackaged: () => true,
getCliLauncherPath: getOrcadCliLauncherPath,
onWillQuit: (handler) => quitHandlers.push(handler),
exit: (code = 0) => process.exit(code),
getAppMetrics: () => []
}
getCliLauncherPath: getOrcadCliLauncherPath
})
quitRegisteredHandlers = host.runQuitHandlers
return host.environment
}
let orcadSecretStore: OrcadSecretStore | null = null
+4 -37
View File
@@ -1,37 +1,4 @@
export type OrcadRuntimeCleanup = (state: { failed: boolean }) => void | Promise<void>
/** Failed teardown retains profile ownership: surviving writers cannot share a root with a new host. */
export class OrcadRuntimeLifetime {
private readonly cleanups: OrcadRuntimeCleanup[] = []
private stopping?: Promise<void>
constructor(private readonly release: () => void = () => {}) {}
add(cleanup: OrcadRuntimeCleanup): void {
if (this.stopping) {
throw new Error('orcad_runtime_lifetime_stopping')
}
this.cleanups.push(cleanup)
}
stop(): Promise<void> {
this.stopping ??= Promise.resolve().then(async () => {
const errors: unknown[] = []
for (const cleanup of this.cleanups.splice(0).toReversed()) {
try {
await cleanup({ failed: errors.length > 0 })
} catch (error) {
errors.push(error)
}
}
if (errors.length === 1) {
throw errors[0]
}
if (errors.length > 1) {
throw new AggregateError(errors, 'orcad_runtime_cleanup_failed')
}
this.release()
})
return this.stopping
}
}
export {
BackendServiceLifetime as OrcadRuntimeLifetime,
type BackendServiceCleanup as OrcadRuntimeCleanup
} from '../backend/service-composition'
@@ -1,3 +1,4 @@
vi.mock('../backend-desktop/backend-supervisor')
import { expect, it, vi } from 'vitest'
import { runMainProcessPreflight } from './main-process-preflight'
@@ -181,9 +182,6 @@ vi.mock('../persistence/profile-state/offline-access/profile-state-active-locati
getActiveProfileStateLocation: () => ({ profileId: 'active-profile' })
}))
vi.mock('../stats/collector')
vi.mock('../claude-usage/store')
vi.mock('../codex-usage/store')
vi.mock('../opencode-usage/store')
vi.mock('../browser/document-preview/doc-preview-protocol')
vi.mock('../crash-reporting/capture/crashpad-capture')
vi.mock('../crash-reporting/reports/crash-report-store')
@@ -31,13 +31,7 @@ const MODEL_CATALOG_ACCOUNT_SETTINGS = [
export function initializeMainProcessAccountServices(): void {
const store = state.store
if (
!store ||
!state.claudeUsage ||
!state.codexUsage ||
!state.openCodeUsage ||
!state.museUsage
) {
if (!store || !state.claudeUsage || !state.codexUsage) {
throw new Error('Usage stores must be initialized before account services')
}
state.codexSessionMigration = createCodexSessionMigrationScheduler({
+5 -9
View File
@@ -1,3 +1,4 @@
import { BackendSupervisor } from '../backend-desktop/backend-supervisor'
import { app, powerMonitor } from 'electron'
import { join } from 'node:path'
import { AgentAwakeService } from '../power/agent-awake-service'
@@ -20,12 +21,8 @@ import { recoverPendingSkillTransactions } from '../skills/transaction-state/ski
import { initCohortClassifier } from '../telemetry/cohort-classifier'
import { initOnboardingCohortClassifier } from '../telemetry/onboarding-cohort-classifier'
import { StatsCollector } from '../stats/collector'
import { feedAgentSessionStats } from '../stats/agent-session-stats-feed'
import { ClaudeUsageStore } from '../claude-usage/store'
import { CodexUsageStore } from '../codex-usage/store'
import { OpenCodeUsageStore } from '../opencode-usage/store'
import { MuseUsageStore } from '../muse-usage/store'
import { UsageWorktreeHistory } from '../usage/worktree-attribution/usage-worktree-history'
import { feedAgentSessionStats } from '../stats/agent-session-stats-feed'
import { installRepoMaintenanceIdleGate } from '../workspaces/catalog/repo-maintenance-idle-gate'
import { mainProcessState as state } from './main-process-state'
import { runAfterFirstWindowShown } from './first-window-deferral'
@@ -152,8 +149,7 @@ export function initializeMainProcessObservers(): void {
join(getCanonicalUserDataPath(), 'orca-usage-worktree-history.json')
)
state.usageWorktreeHistory = usageWorktrees
state.claudeUsage = new ClaudeUsageStore(usageWorktrees)
state.codexUsage = new CodexUsageStore(usageWorktrees)
state.openCodeUsage = new OpenCodeUsageStore(usageWorktrees)
state.museUsage = new MuseUsageStore(usageWorktrees)
state.backend = new BackendSupervisor(usageWorktrees)
state.claudeUsage = state.backend.automationClient('claudeUsage')
state.codexUsage = state.backend.automationClient('codexUsage')
}
+2 -8
View File
@@ -1,3 +1,4 @@
import { captureBackendBootstrap } from '../backend-desktop/backend-supervisor'
import { antigravityHookService } from '../antigravity/hook-service'
import { getRelocatedDaemonHost } from '../daemon/executable-host/windows/daemon-host-relocation'
import { app, ipcMain, powerMonitor, session } from 'electron'
@@ -80,10 +81,6 @@ import '../orca-profiles/profile-index-store'
import { getProfileUserDataPath, initOrcaProfilePaths } from '../orca-profiles/profile-storage-paths'
import { recoverPendingProfileProjectMoves } from '../orca-profiles/project-transfer/profile-project-move-intent'
import { initStatsPath } from '../stats/collector'
import { initClaudeUsagePath } from '../claude-usage/store'
import { initCodexUsagePath } from '../codex-usage/store'
import { initOpenCodeUsagePath } from '../opencode-usage/store'
import { initMuseUsagePath } from '../muse-usage/store'
import { registerDocPreviewSchemePrivileges } from '../browser/document-preview/doc-preview-protocol'
import { PRIVILEGED_CUSTOM_SCHEMES } from './privileged-custom-schemes'
import { startCrashpadCapture } from '../crash-reporting/capture/crashpad-capture'
@@ -304,10 +301,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b
)
// Capture stats/usage paths before app naming can shift userData.
initStatsPath()
initClaudeUsagePath()
initCodexUsagePath()
initOpenCodeUsagePath()
initMuseUsagePath()
captureBackendBootstrap()
// Register preview privileges before ready freezes the table or webviews lose fetch/secure-origin access.
registerDocPreviewSchemePrivileges(PRIVILEGED_CUSTOM_SCHEMES)
// Install Crashpad before first renderer; earlier CHECK failures have exit-code evidence only.
+1 -5
View File
@@ -235,12 +235,8 @@ function installWillQuitHandler(): void {
const sshShutdown = Promise.resolve(finalSaves).then((saves) => saves.sshShutdown)
const storeFlush = Promise.resolve(finalSaves).then((saves) => saves.storeFlush)
const watcherShutdown = Promise.resolve(finalSaves).then((saves) => saves.watcherShutdown)
// Capture queued usage writes before awaiting teardown or quit can lose the last enabled/scan snapshot.
const usageCacheFlush = Promise.all([
state.claudeUsage?.flush(),
state.codexUsage?.flush(),
state.openCodeUsage?.flush(),
state.museUsage?.flush(),
state.backend?.stop(),
state.usageWorktreeHistory?.flush()
]).then(() => {})
disposeDesktopBrowserBridge()
+12 -10
View File
@@ -1,10 +1,8 @@
import type { BackendSupervisor } from '../backend-desktop/backend-supervisor'
import type { AutomationUsageClient } from '../../shared/backend-service-contract'
import { type BrowserWindow, type Tray, app } from 'electron'
import type { Store } from '../persistence/loading-store/store'
import type { StatsCollector } from '../stats/collector'
import type { ClaudeUsageStore } from '../claude-usage/store'
import type { CodexUsageStore } from '../codex-usage/store'
import type { OpenCodeUsageStore } from '../opencode-usage/store'
import type { MuseUsageStore } from '../muse-usage/store'
import type { UsageWorktreeHistory } from '../usage/worktree-attribution/usage-worktree-history'
import type { CodexAccountService } from '../codex-accounts/service'
import type { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service'
@@ -67,6 +65,14 @@ function createInitialProfileStateAdmission(): ProfileStateRuntimeAdmission | un
return undefined
}
function createInitialBackendServices(): {
backend: BackendSupervisor | null
claudeUsage: AutomationUsageClient | null
codexUsage: AutomationUsageClient | null
} {
return { backend: null, claudeUsage: null, codexUsage: null }
}
/** Mutable composition-root state shared by startup, window, serve, and quit phases. */
const desktopRelayStatus: MobileRelayStatusDetail = { status: 'offline' }
@@ -80,12 +86,8 @@ export const mainProcessState = {
profileStateStartup: createInitialProfileStateStartup(),
profileStateAdmission: createInitialProfileStateAdmission(),
stats: null as StatsCollector | null,
claudeUsage: null as ClaudeUsageStore | null,
codexUsage: null as CodexUsageStore | null,
openCodeUsage: null as OpenCodeUsageStore | null,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: widens the null slot to the store type assigned by main-process-observers.
museUsage: null as MuseUsageStore | null,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: widens the null slot to the history assigned by main-process-observers.
...createInitialBackendServices(),
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: widens the null slot assigned by main-process-observers.
usageWorktreeHistory: null as UsageWorktreeHistory | null,
codexAccounts: null as CodexAccountService | null,
codexRuntimeHome: null as CodexRuntimeHomeService | null,
@@ -55,10 +55,6 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}
store: state.store,
runtime: state.runtime,
stats: state.stats,
claudeUsage: state.claudeUsage,
codexUsage: state.codexUsage,
openCodeUsage: state.openCodeUsage,
museUsage: state.museUsage,
rateLimits: state.rateLimits,
automations: state.automations,
codexAccounts: state.codexAccounts,
@@ -34,10 +34,6 @@ export function attachMainWindowCoreServices(
const store = state.store
const runtime = state.runtime
const stats = state.stats
const claudeUsage = state.claudeUsage
const codexUsage = state.codexUsage
const openCodeUsage = state.openCodeUsage
const museUsage = state.museUsage
const codexAccounts = state.codexAccounts
const claudeAccounts = state.claudeAccounts
const rateLimits = state.rateLimits
@@ -49,10 +45,6 @@ export function attachMainWindowCoreServices(
!store ||
!runtime ||
!stats ||
!claudeUsage ||
!codexUsage ||
!openCodeUsage ||
!museUsage ||
!codexAccounts ||
!claudeAccounts ||
!rateLimits ||
@@ -69,10 +61,6 @@ export function attachMainWindowCoreServices(
store,
runtime,
stats,
claudeUsage,
codexUsage,
openCodeUsage,
museUsage,
codexAccounts,
claudeAccounts,
rateLimits,
@@ -3,10 +3,6 @@ const MAIN_WINDOW_SERVICE_REQUIREMENTS = [
['store', 'Store must be initialized before opening the main window'],
['runtime', 'Runtime must be initialized before opening the main window'],
['stats', 'Stats must be initialized before opening the main window'],
['claudeUsage', 'Claude usage store must be initialized before opening the main window'],
['codexUsage', 'Codex usage store must be initialized before opening the main window'],
['openCodeUsage', 'OpenCode usage store must be initialized before opening the main window'],
['museUsage', 'Muse usage store must be initialized before opening the main window'],
['rateLimits', 'Rate limit service must be initialized before opening the main window'],
['automations', 'Automation service must be initialized before opening the main window'],
['codexAccounts', 'Codex account service must be initialized before opening the main window'],
@@ -0,0 +1,319 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import type { UsageStoreHost, UsageTelemetryHost } from '../usage-store-host'
import { createTestUsageStoreHost } from '../usage-store-host-test-fixture'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { UsageScanWorktreeRef } from './usage-provider-contract'
import { UsageProviderStoreLifecycle } from './usage-provider-store-lifecycle'
import {
splitUsageCacheFile,
type UsageCacheSplitRequest,
type UsageCacheSplitResult,
type UsageSourceCacheRef
} from '../cache/usage-source-cache-file'
type TestSource = { id: string }
type TestSession = { id: string }
type TestDailyAggregate = { day: string }
type TestScanState = {
enabled: boolean
lastScanStartedAt: number | null
lastScanCompletedAt: number | null
lastScanError: string | null
}
type TestState = {
schemaVersion: number
worktreeFingerprint: string | null
processedSources: TestSource[]
sessions: TestSession[]
dailyAggregates: TestDailyAggregate[]
scanState: TestScanState
}
type TestScanResult = Pick<TestState, 'sessions' | 'dailyAggregates'>
type TestScan = (
worktrees: UsageScanWorktreeRef[],
sourceCache: UsageSourceCacheRef
) => Promise<TestScanResult>
type TestSplit = (request: UsageCacheSplitRequest) => Promise<UsageCacheSplitResult>
const NOW = Date.parse('2026-04-10T16:00:00.000Z')
function makeState(
overrides: Partial<Omit<TestState, 'scanState'>> & { scanState?: Partial<TestScanState> } = {}
): TestState {
const { scanState, ...stateOverrides } = overrides
return {
schemaVersion: 1,
worktreeFingerprint: null,
processedSources: [],
sessions: [],
dailyAggregates: [],
...stateOverrides,
scanState: {
enabled: false,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null,
...scanState
}
}
}
function emptyScanResult(): TestScanResult {
return { sessions: [], dailyAggregates: [] }
}
class TestUsageStore extends UsageProviderStoreLifecycle<
'processedSources',
TestState,
'hasAnyTestData'
> {
constructor(
cacheFile: string,
scan: TestScan,
splitCacheFile: TestSplit,
capture: ReturnType<typeof vi.fn<UsageTelemetryHost['capture']>>,
readCatalog?: UsageStoreHost['readCatalog'],
isEnabled?: UsageTelemetryHost['isEnabled'],
signal?: AbortSignal
) {
super(
{
...createTestUsageStoreHost({
userDataPath: dirname(cacheFile),
telemetryEnabled: true,
capture,
signal
}),
...(readCatalog ? { readCatalog } : {}),
...(isEnabled ? { telemetry: { isEnabled, capture } } : {})
},
{
tokenUsage: {
provider: 'claude',
selectSessions: (state) =>
state.sessions.map((session) => ({
providerSessionId: session.id,
input_tokens: 10,
output_tokens: 2,
cached_input_tokens: 3,
cache_write_input_tokens: 1
}))
},
logTag: '[test-usage]',
resolveCacheFile: () => cacheFile,
createDefaultState: makeState,
normalizeState: (state) => state,
sourceKey: 'processedSources',
dataPresenceKey: 'hasAnyTestData',
scan,
splitCacheFile
}
)
}
replaceState(state: TestState): void {
this.state = state
}
getState(): TestState {
return this.state
}
}
describe('UsageProviderStoreLifecycle', () => {
let tempDirectory: string
let stores: TestUsageStore[]
let scan: ReturnType<typeof vi.fn<TestScan>>
let split: ReturnType<typeof vi.fn<TestSplit>>
let capture: ReturnType<typeof vi.fn<UsageTelemetryHost['capture']>>
beforeEach(() => {
capture = vi.fn<UsageTelemetryHost['capture']>(async () => true)
tempDirectory = mkdtempSync(join(tmpdir(), 'orca-usage-lifecycle-'))
stores = []
scan = vi.fn<TestScan>().mockResolvedValue(emptyScanResult())
// Run the real cache split in-process; the worker only wraps it in a thread.
split = vi.fn<TestSplit>(splitUsageCacheFile)
vi.spyOn(Date, 'now').mockReturnValue(NOW)
})
afterEach(async () => {
await Promise.all(stores.map((store) => store.flush()))
rmSync(tempDirectory, { recursive: true, force: true })
vi.restoreAllMocks()
})
it.each([100, 1000])('uses one consistent catalog per refresh at %i worktrees', async (count) => {
const worktrees = Array.from({ length: count }, (_, i) => ({
worktreeId: `repo::/work/${i}`,
path: `/work/${i}`,
displayName: `Work ${i}`
}))
const readCatalog = vi.fn<UsageStoreHost['readCatalog']>().mockResolvedValue({
repoIds: ['repo'],
worktreesByRepo: [['repo', worktrees]]
})
const store = new TestUsageStore(
join(tempDirectory, 'catalog.json'),
scan,
split,
capture,
readCatalog
)
stores.push(store)
await store.whenLoaded()
await store.refresh(true)
expect(readCatalog).not.toHaveBeenCalled()
await store.setEnabled(true)
const blocked = Promise.withResolvers<TestScanResult>()
scan.mockReturnValueOnce(blocked.promise)
const first = store.refresh(true)
const second = store.refresh(true)
await vi.waitFor(() => expect(scan).toHaveBeenCalledTimes(1))
expect(readCatalog).toHaveBeenCalledTimes(2)
expect(scan.mock.calls[0][0]).toHaveLength(count)
expect(scan.mock.calls[0][0][0]).toMatchObject({ path: '/work/0' })
blocked.resolve({ sessions: [{ id: 'catalog-result' }], dailyAggregates: [] })
await Promise.all([first, second])
expect(store.getState().sessions).toEqual([{ id: 'catalog-result' }])
readCatalog.mockClear()
await store.refresh()
expect(readCatalog).toHaveBeenCalledTimes(1)
expect(scan).toHaveBeenCalledTimes(1)
store.stopScanAdmission()
readCatalog.mockClear()
await store.refresh(true)
expect(readCatalog).not.toHaveBeenCalled()
})
it('keeps a successful scan when the first async telemetry consent request rejects', async () => {
const cacheFile = join(tempDirectory, 'consent-rejection.json')
const consent = vi
.fn<UsageTelemetryHost['isEnabled']>()
.mockRejectedValue(new Error('No RPC credit'))
const store = new TestUsageStore(cacheFile, scan, split, capture, undefined, consent)
stores.push(store)
await store.setEnabled(true)
scan.mockResolvedValueOnce({
sessions: [{ id: 'fresh' }],
dailyAggregates: [{ day: '2026-04-10' }]
})
vi.spyOn(console, 'warn').mockImplementation(() => {})
await expect(store.refresh(true)).resolves.toMatchObject({
isScanning: false,
lastScanCompletedAt: NOW,
lastScanError: null,
hasAnyTestData: true
})
await store.flush()
expect(store.getState().sessions).toEqual([{ id: 'fresh' }])
expect(JSON.parse(readFileSync(cacheFile, 'utf8'))).toMatchObject({
sessions: [{ id: 'fresh' }],
dailyAggregates: [{ day: '2026-04-10' }],
scanState: { lastScanCompletedAt: NOW, lastScanError: null }
})
expect(consent).toHaveBeenCalledTimes(1)
expect(capture).not.toHaveBeenCalled()
})
it('stops admission without persisting a host catalog abort as a scan failure', async () => {
const cacheFile = join(tempDirectory, 'aborted-catalog.json')
const abort = new AbortController()
const readCatalog = vi.fn<UsageStoreHost['readCatalog']>().mockResolvedValue({
repoIds: ['repo-1'],
worktreesByRepo: [
['repo-1', [{ worktreeId: 'repo-1::/workspace/a', path: '/workspace/a', displayName: 'A' }]]
]
})
const store = new TestUsageStore(
cacheFile,
scan,
split,
capture,
readCatalog,
undefined,
abort.signal
)
stores.push(store)
await store.setEnabled(true)
scan.mockResolvedValueOnce({ sessions: [{ id: 'scan-result' }], dailyAggregates: [] })
await store.refresh(true)
expect(scan.mock.calls[0]?.[0]).toEqual([
{
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/a',
path: '/workspace/a',
displayName: 'A'
}
])
const catalog = Promise.withResolvers<Awaited<ReturnType<UsageStoreHost['readCatalog']>>>()
readCatalog.mockReturnValueOnce(catalog.promise)
const refresh = store.refresh(true)
abort.abort(new Error('Backend stopping'))
catalog.resolve({ repoIds: [], worktreesByRepo: [] })
await expect(refresh).rejects.toThrow('Backend stopping')
store.stopScanAdmission()
await store.refresh(true)
await store.flush()
expect(JSON.parse(readFileSync(cacheFile, 'utf8'))).toMatchObject({
sessions: [{ id: 'scan-result' }],
scanState: { enabled: true, lastScanError: null }
})
})
it.each([
{ waitingOn: 'catalog', sessions: [{ id: 'prior' }], completedAt: 12_000 },
{ waitingOn: 'scanner', sessions: [{ id: 'fresh' }], completedAt: NOW }
])(
'preserves disable while waiting on $waitingOn',
async ({ waitingOn, sessions, completedAt }) => {
const cacheFile = join(tempDirectory, `disable-${waitingOn}.json`)
const entered = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const readCatalog = async () => {
if (waitingOn === 'catalog') {
entered.resolve()
await release.promise
}
return { repoIds: [], worktreesByRepo: [] }
}
scan.mockImplementationOnce(async () => {
if (waitingOn === 'scanner') {
entered.resolve()
await release.promise
}
return { sessions: [{ id: 'fresh' }], dailyAggregates: [] }
})
const store = new TestUsageStore(cacheFile, scan, split, capture, readCatalog)
stores.push(store)
await store.whenLoaded()
store.replaceState(
makeState({
sessions: [{ id: 'prior' }],
scanState: { enabled: true, lastScanCompletedAt: 12_000 }
})
)
const refresh = store.refresh(true)
await entered.promise
await store.setEnabled(false)
release.resolve()
await expect(refresh).resolves.toMatchObject({
enabled: false,
isScanning: false,
lastScanCompletedAt: completedAt,
lastScanError: null
})
expect(store.getState().sessions).toEqual(sessions)
await store.flush()
expect(JSON.parse(readFileSync(cacheFile, 'utf8'))).toMatchObject({
sessions,
scanState: { enabled: false, lastScanCompletedAt: completedAt, lastScanError: null }
})
expect(capture).not.toHaveBeenCalled()
}
)
})
@@ -1,19 +1,13 @@
import { readFileSync, statSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { AgentTokenUsageReporter } from '../reporting/token-usage/agent-token-usage-reporter'
import { isTelemetryEnabled } from '../../telemetry/client'
import { join, parse } from 'node:path'
import {
AnalyticsSessionIdStore,
type AnalyticsSessionId
} from '../reporting/token-usage/analytics-session-id-store'
import type { UsageStoreHost } from '../usage-store-host'
import { UsageCacheSnapshotWriter } from '../cache/usage-cache-snapshot-writer'
import {
loadKnownUsageWorktreesByRepo,
type UsageWorktreeSource
} from '../worktree-attribution/usage-worktree-metadata'
import {
createWorktreeRefs,
getUsageWorktreeFingerprint
@@ -40,20 +34,21 @@ export abstract class UsageProviderStoreLifecycle<
private readonly loaded: Promise<void>
private readonly schemaVersion: number
private scanPromise: Promise<void> | null = null
private stopping = false
private tokenReporter: AgentTokenUsageReporter | null = null
private analyticsSessionIds: AnalyticsSessionIdStore | null = null
private readonly writer: UsageCacheSnapshotWriter
private readonly inlineCache = new UsageInlineCachePreservation()
constructor(
private readonly store: UsageWorktreeSource,
private readonly host: UsageStoreHost,
private readonly config: UsageProviderStoreLifecycleConfig<SourceKey, State, DataPresenceKey>
) {
this.writer = new UsageCacheSnapshotWriter(config.logTag, config.resolveCacheFile)
const defaults = config.createDefaultState()
this.schemaVersion = defaults.schemaVersion
this.state = defaults
this.loaded = Promise.all([this.load(), store.whenLoaded()]).then(() => {})
this.loaded = this.load()
}
/** Resolves once persisted state is in memory; synchronous readers must wait for it first. */
@@ -82,6 +77,14 @@ export abstract class UsageProviderStoreLifecycle<
}
/** Await queued cache writes so quit does not drop the final snapshot. */
stopScanAdmission(): void {
this.stopping = true
}
async settleScansForShutdown(): Promise<void> {
await this.scanPromise
}
async flush(): Promise<void> {
await this.loaded
await this.inlineCache.settle()
@@ -98,17 +101,22 @@ export abstract class UsageProviderStoreLifecycle<
async refresh(force = false): Promise<PublicUsageProviderScanState<DataPresenceKey>> {
await this.loaded
if (!this.state.scanState.enabled) {
if (this.stopping || !this.state.scanState.enabled) {
return this.getScanState()
}
const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint()
const catalog = await this.host.readCatalog()
this.host.signal.throwIfAborted()
const currentWorktreeFingerprint = getUsageWorktreeFingerprint(new Map(catalog.worktreesByRepo))
if (!force && this.state.scanState.lastScanCompletedAt) {
const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt
if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) {
return this.getScanState()
}
}
await this.runScan()
if (this.stopping || !this.state.scanState.enabled) {
return this.getScanState()
}
await this.runScan(catalog)
return this.getScanState()
}
@@ -219,7 +227,9 @@ export abstract class UsageProviderStoreLifecycle<
})
}
private async runScan(): Promise<void> {
private async runScan(
catalog: Awaited<ReturnType<UsageStoreHost['readCatalog']>>
): Promise<void> {
if (this.scanPromise) {
await this.scanPromise
return
@@ -231,15 +241,22 @@ export abstract class UsageProviderStoreLifecycle<
// Assign before yielding so concurrent refreshes share one scan.
this.scanPromise = (async () => {
try {
const repos = this.store.getRepos()
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
const { repoIds, worktreesByRepo: entries } = catalog
this.host.signal.throwIfAborted()
const worktreesByRepo = new Map(entries)
const worktreeFingerprint = getUsageWorktreeFingerprint(worktreesByRepo)
const result = await this.config.scan(createWorktreeRefs(repos, worktreesByRepo), {
path: usageSourceCachePath(this.config.resolveCacheFile()),
schemaVersion: this.schemaVersion,
worktreeFingerprint,
reuse: this.state.worktreeFingerprint === worktreeFingerprint
})
const result = await this.config.scan(
createWorktreeRefs(
repoIds.map((id) => ({ id })),
worktreesByRepo
),
{
path: usageSourceCachePath(this.config.resolveCacheFile()),
schemaVersion: this.schemaVersion,
worktreeFingerprint,
reuse: this.state.worktreeFingerprint === worktreeFingerprint
}
)
this.state.sessions = result.sessions
this.state.dailyAggregates = result.dailyAggregates
this.state.worktreeFingerprint = worktreeFingerprint
@@ -251,6 +268,9 @@ export abstract class UsageProviderStoreLifecycle<
await this.writeToDisk().catch(() => {})
await this.reportTokenUsage()
} catch (error) {
if (this.stopping || this.host.signal.aborted) {
return
}
this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error)
await this.writeToDisk().catch(() => {})
} finally {
@@ -263,16 +283,20 @@ export abstract class UsageProviderStoreLifecycle<
private async reportTokenUsage(): Promise<void> {
const config = this.config.tokenUsage
if (!config || !isTelemetryEnabled() || !this.state.scanState.enabled) {
if (!config) {
return
}
try {
if (!(await this.host.telemetry.isEnabled()) || !this.state.scanState.enabled) {
return
}
if (!this.tokenReporter) {
const { dir, name } = parse(this.config.resolveCacheFile())
this.tokenReporter = new AgentTokenUsageReporter(
join(dir, `${name}-token-usage.json`),
config.provider,
(ids) => this.getAnalyticsSessionIds(ids)
(ids) => this.getAnalyticsSessionIds(ids),
this.host.telemetry
)
}
await this.tokenReporter.report(config.selectSessions(this.state))
@@ -283,11 +307,6 @@ export abstract class UsageProviderStoreLifecycle<
)
}
}
private async getCurrentWorktreeFingerprint(): Promise<string> {
const repos = this.store.getRepos()
return getUsageWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos))
}
}
function isMissingFileError(error: unknown): boolean {
@@ -5,7 +5,7 @@ import {
agentTokenUsageSchema,
type AgentTokenUsage
} from '../../../../shared/telemetry-agent-token-usage-schema'
import { isTelemetryEnabled, track } from '../../../telemetry/client'
import type { UsageTelemetryHost } from '../../usage-store-host'
import { UsageCacheSnapshotWriter } from '../../cache/usage-cache-snapshot-writer'
import type { AgentTokenSession } from './agent-token-usage'
@@ -26,7 +26,8 @@ export class AgentTokenUsageReporter {
constructor(
private readonly file: string,
private readonly provider: AgentTokenUsage['provider'],
private readonly getSessionIds: (providerSessionIds: readonly string[]) => Promise<string[]>
private readonly getSessionIds: (providerSessionIds: readonly string[]) => Promise<string[]>,
private readonly telemetry: UsageTelemetryHost
) {
this.writer = new UsageCacheSnapshotWriter('[agent-token-usage]', () => file)
}
@@ -43,7 +44,7 @@ export class AgentTokenUsageReporter {
}
private async publish(sessions: AgentTokenSession[]): Promise<void> {
if (!isTelemetryEnabled()) {
if (!(await this.telemetry.isEnabled())) {
return
}
const rows = sessions.flatMap(({ providerSessionId, ...counts }) => {
@@ -51,7 +52,7 @@ export class AgentTokenUsageReporter {
return parsed.success ? [{ providerSessionId, counts: parsed.data }] : []
})
const ids = await this.getSessionIds(rows.map((row) => row.providerSessionId))
if (!isTelemetryEnabled()) {
if (!(await this.telemetry.isEnabled())) {
return
}
const previous = this.snapshots ?? (await this.load())
@@ -91,7 +92,7 @@ export class AgentTokenUsageReporter {
if (this.acceptedRevisions.get(snapshot.analytics_session_id) === snapshot.revision) {
continue
}
if (!track('agent_token_usage', snapshot)) {
if (!(await this.telemetry.capture(snapshot))) {
break
}
// Queue acceptance only deduplicates this process; restart must retry undelivered snapshots.
@@ -1,4 +1,4 @@
import { WorkerThreadRequestQueue } from '../../worker-threads/worker-thread-request-queue'
import { UsageScanRequestScheduler } from '../usage-scan-request-scheduler'
import type { WorkerThreadFactory } from '../../worker-threads/worker-thread-transport'
import type { ClaudeUsageDailyAggregate, ClaudeUsageSession } from '../../claude-usage/types'
import type { CodexUsageDailyAggregate, CodexUsageSession } from '../../codex-usage/types'
@@ -7,9 +7,7 @@ import type { MuseUsageDailyAggregate, MuseUsageSession } from '../../muse-usage
import type { UsageScanWorktreeRef } from '../provider-lifecycle/usage-provider-contract'
import type {
UsageScanWorkerProviderId,
UsageScanWorkerRequest,
UsageScanWorkerRequestBody,
UsageScanWorkerResponse,
UsageScanWorkerScanBody,
UsageScanWorkerValue
} from './usage-scan-worker-protocol'
@@ -18,10 +16,9 @@ import type {
UsageCacheSplitResult,
UsageSourceCacheRef
} from '../cache/usage-source-cache-file'
import { isUsageScanWorkerProgress } from './usage-scan-worker-protocol'
import { createUsageScanWorkerTransport } from './usage-scan-worker-cache-cleanup'
// #20940: serialize provider scans with WorkerThreadRequestQueue; LazyWorkerThreadHost owns thread lifetime and the spawn module owns layout/singleton.
// Provider-local ordering shares one worker; LazyWorkerThreadHost owns its lifetime.
// Cold corpora can exceed any total deadline; time only gaps between progress reports. A huge rollout is one gap, so keep the window generous.
export const USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS = 10 * 60_000
@@ -39,25 +36,26 @@ type ProviderScanResult<TSession, TDaily> = {
/** Unavailable, timed-out or crashed workers reject; stores retain their previous projection instead of parsing on main. */
export class UsageScanWorkerClient {
private readonly queue: WorkerThreadRequestQueue<UsageScanWorkerRequest, UsageScanWorkerResponse>
private readonly queue: UsageScanRequestScheduler
constructor(options: { workerFactory: WorkerThreadFactory; log?: (message: string) => void }) {
const log = options.log ?? ((message: string) => console.warn(message))
this.queue = new WorkerThreadRequestQueue({
this.queue = new UsageScanRequestScheduler({
factory: () => createUsageScanWorkerTransport(options.workerFactory),
idleTeardownMs: IDLE_TEARDOWN_MS,
maxConsecutiveDeaths: MAX_CONSECUTIVE_DEATHS,
createUnavailableError: (message) => new UsageScanWorkerUnavailableError(message),
isProgress: isUsageScanWorkerProgress,
describeTimeout: (timeoutMs) => `Usage scan worker reported no progress for ${timeoutMs}ms`,
describeExit: (code) => `Usage scan worker exited with code ${code}`,
describeCrashLoop: (lastError) => `Usage scan worker crashed repeatedly (${lastError})`,
// Missing/resource-starved workers must fail closed; main-thread scanning would reintroduce event-loop blocking.
onUnavailable: (err) =>
log(`[usage-scan] worker unavailable; usage scans will report an error. ${message(err)}`)
})
}
async dispose(): Promise<void> {
this.queue.dispose()
await this.queue.retired()
}
/** sourceCache identifies provider records that stay on the worker. */
scan(body: UsageScanWorkerScanBody): Promise<UsageScanWorkerValue> {
return this.dispatch(body)
@@ -79,10 +77,7 @@ export class UsageScanWorkerClient {
}
private async dispatch(body: UsageScanWorkerRequestBody): Promise<UsageScanWorkerValue> {
const response = await this.queue.dispatch(
(id) => ({ ...body, id }),
USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS
)
const response = await this.queue.dispatch(body, USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS)
if (!response.ok) {
throw new Error(response.error)
}
@@ -1,4 +1,3 @@
import { claudeProfileTranscriptDirs } from '../../claude-usage/transcript-file-discovery'
import { existsSync } from 'node:fs'
import type { Worker } from 'node:worker_threads'
import { spawnWorkerThread } from '../../worker-threads/worker-thread-spawn'
@@ -49,18 +48,15 @@ function getSharedClient(): UsageScanWorkerClient {
export function scanClaudeUsageFilesViaWorker(
worktrees: UsageScanWorktreeRef[],
sourceCache: UsageSourceCacheRef
sourceCache: UsageSourceCacheRef,
profileDirs: string[]
): Promise<{
sessions: ClaudeUsageSession[]
dailyAggregates: ClaudeUsageDailyAggregate[]
}> {
return scanClaudeUsageOnWorker(
(body) =>
getSharedClient().scan(
body.providerId === 'claude'
? { ...body, profileDirs: claudeProfileTranscriptDirs() }
: body
),
getSharedClient().scan(body.providerId === 'claude' ? { ...body, profileDirs } : body),
worktrees,
sourceCache
)
@@ -102,3 +98,9 @@ export function splitUsageCacheFileViaWorker(
): Promise<UsageCacheSplitResult> {
return getSharedClient().splitCacheFile(request)
}
export async function stopUsageScanWorker(): Promise<void> {
const client = sharedClient
sharedClient = null
await client?.dispose()
}
@@ -0,0 +1,274 @@
import { EventEmitter } from 'node:events'
import type { Worker } from 'node:worker_threads'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
UsageScanWorkerClient,
UsageScanWorkerUnavailableError,
USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS
} from './scan-worker/usage-scan-worker-client'
import type {
UsageScanWorkerProviderId,
UsageScanWorkerRequest,
UsageScanWorkerScanBody,
UsageScanWorkerValue
} from './scan-worker/usage-scan-worker-protocol'
class ScanWorker extends EventEmitter {
posted: UsageScanWorkerRequest[] = []
exit: Promise<number> = Promise.resolve(1)
terminate = vi.fn(() => this.exit)
unref(): void {}
postMessage(request: UsageScanWorkerRequest): void {
this.posted.push(request)
}
complete(index: number): void {
const request = this.posted[index]
const value: UsageScanWorkerValue =
request.operation === 'scan'
? { operation: 'scan', providerId: request.providerId, sessions: [], dailyAggregates: [] }
: { operation: 'splitCacheFile', reportText: 'saved report', migrated: false }
this.emit('message', { id: request.id, ok: true, value })
}
}
function body(providerId: UsageScanWorkerProviderId, population = 1): UsageScanWorkerScanBody {
return {
operation: 'scan',
providerId,
worktrees: Array.from({ length: population }, (_, i) => ({
repoId: 'repo',
worktreeId: `tree-${i}`,
displayName: `tree-${i}`,
path: `/tree-${i}`
})),
sourceCache: {
path: `/${providerId}-sources.json`,
schemaVersion: 1,
worktreeFingerprint: '[]',
reuse: true
}
}
}
const clients: UsageScanWorkerClient[] = []
function fixture(): { client: UsageScanWorkerClient; workers: ScanWorker[] } {
const workers: ScanWorker[] = []
const client = new UsageScanWorkerClient({
workerFactory: () => {
const worker = new ScanWorker()
workers.push(worker)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements every Worker member the host and transport use; no native Worker API is reached.
return worker as unknown as Worker
},
log: () => {}
})
clients.push(client)
return { client, workers }
}
beforeEach(() => vi.useFakeTimers())
afterEach(async () => {
await Promise.all(clients.splice(0).map((client) => client.dispose().catch(() => {})))
vi.useRealTimers()
})
describe('usage scan scheduling through the production client', () => {
it.each([100, 1000])(
'admits only four providers at %i worktrees and routes unordered replies',
async (population) => {
const { client, workers } = fixture()
const calls = [
client.scan(body('claude', population)),
client.scan(body('codex', population)),
client.scan(body('opencode', population)),
client.scan(body('muse', population))
]
const worker = workers[0]
expect(worker.posted.map((request) => request.providerId)).toEqual([
'claude',
'codex',
'opencode',
'muse'
])
worker.emit('message', { id: worker.posted[1].id, filesScanned: 1 })
for (const index of [2, 0, 3, 1]) {
worker.complete(index)
}
expect(await Promise.all(calls)).toEqual([
{ operation: 'scan', providerId: 'claude', sessions: [], dailyAggregates: [] },
{ operation: 'scan', providerId: 'codex', sessions: [], dailyAggregates: [] },
{ operation: 'scan', providerId: 'opencode', sessions: [], dailyAggregates: [] },
{ operation: 'scan', providerId: 'muse', sessions: [], dailyAggregates: [] }
])
}
)
it('keeps same-provider FIFO and makes a split an exclusive barrier without overtaking', async () => {
const { client, workers } = fixture()
const first = client.scan(body('claude'))
const second = client.scan(body('claude'))
const codex = client.scan(body('codex'))
const split = client.splitCacheFile({ cacheFile: '/claude.json', sourceKey: 'processedFiles' })
const muse = client.scan(body('muse'))
const worker = workers[0]
expect(worker.posted.map((request) => request.providerId)).toEqual(['claude', 'codex'])
worker.complete(0)
expect(worker.posted.map((request) => request.providerId)).toEqual([
'claude',
'codex',
'claude'
])
worker.complete(2)
expect(worker.posted).toHaveLength(3)
worker.complete(1)
expect(worker.posted[3].operation).toBe('splitCacheFile')
expect(worker.posted).toHaveLength(4)
worker.complete(3)
expect(worker.posted[4].providerId).toBe('muse')
worker.complete(4)
await Promise.all([first, second, codex, muse])
expect(await split).toEqual({ reportText: 'saved report', migrated: false })
})
it('settles every posted call on one fault, parks only unposted work, and never replays', async () => {
const { client, workers } = fixture()
const posted = [
client.scan(body('claude')),
client.scan(body('codex')),
client.scan(body('muse'))
].map((promise) => promise.catch((error: unknown) => error))
const pending = client.scan(body('claude'))
const old = workers[0]
let proveExit!: (code: number) => void
old.exit = new Promise((resolve) => {
proveExit = resolve
})
const failure = new Error('worker crashed')
old.emit('error', failure)
expect(await Promise.all(posted)).toEqual([failure, failure, failure])
expect(old.terminate).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS + 1)
expect(workers).toHaveLength(1)
proveExit(1)
await vi.advanceTimersByTimeAsync(0)
expect(workers).toHaveLength(2)
expect(workers[1].posted.map((request) => request.providerId)).toEqual(['claude'])
old.complete(0)
workers[1].complete(0)
expect(await pending).toEqual({
operation: 'scan',
providerId: 'claude',
sessions: [],
dailyAggregates: []
})
})
it('times each dispatched ID independently, ignoring other-provider and unknown progress', async () => {
const { client, workers } = fixture()
const claude = client.scan(body('claude')).catch((error: unknown) => error)
const codex = client.scan(body('codex')).catch((error: unknown) => error)
await vi.advanceTimersByTimeAsync(USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS / 2)
workers[0].emit('message', { id: workers[0].posted[1].id, filesScanned: 1 })
workers[0].emit('message', { id: 999, filesScanned: 1 })
await vi.advanceTimersByTimeAsync(USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS / 2 + 1)
expect(await claude).toEqual(new Error('Usage scan worker reported no progress for 600000ms'))
expect(await codex).toEqual(new Error('Usage scan worker reported no progress for 600000ms'))
expect(workers[0].terminate).toHaveBeenCalledTimes(1)
})
it('counts a shared death once and caps consecutive worker deaths in one queued burst', async () => {
const { client, workers } = fixture()
const calls = [
client.scan(body('claude')),
client.scan(body('codex')),
client.scan(body('opencode')),
client.scan(body('muse')),
client.scan(body('claude')),
client.scan(body('claude')),
client.scan(body('claude'))
].map((promise) => promise.catch((error: unknown) => error))
workers[0].emit('error', new Error('fault one'))
await vi.advanceTimersByTimeAsync(0)
expect(workers).toHaveLength(2)
workers[1].emit('error', new Error('fault two'))
await vi.advanceTimersByTimeAsync(0)
expect(workers).toHaveLength(3)
workers[2].emit('error', new Error('fault three'))
const results = await Promise.all(calls)
expect(results).toEqual([
new Error('fault one'),
new Error('fault one'),
new Error('fault one'),
new Error('fault one'),
new Error('fault two'),
new Error('fault three'),
new Error('Usage scan worker crashed repeatedly (fault three)')
])
expect(workers).toHaveLength(3)
})
it('keeps a request-level error local while the other provider completes', async () => {
const { client, workers } = fixture()
const claude = client.scan(body('claude')).catch((error: unknown) => error)
const codex = client.scan(body('codex'))
workers[0].emit('message', { id: workers[0].posted[0].id, ok: false, error: 'scan failed' })
expect(await claude).toEqual(new Error('scan failed'))
expect(workers[0].terminate).not.toHaveBeenCalled()
workers[0].complete(1)
expect(await codex).toEqual({
operation: 'scan',
providerId: 'codex',
sessions: [],
dailyAggregates: []
})
})
it('cancels a parked wake when disposed and cannot spawn after the exact exit', async () => {
const { client, workers } = fixture()
const active = client.scan(body('claude')).catch((error: unknown) => error)
const pending = client.scan(body('claude')).catch((error: unknown) => error)
let proveExit!: (code: number) => void
workers[0].exit = new Promise((resolve) => {
proveExit = resolve
})
workers[0].emit('error', new Error('worker crashed'))
const disposed = client.dispose()
expect(await active).toEqual(new Error('worker crashed'))
expect(await pending).toEqual(new Error('Usage scan scheduler disposed'))
proveExit(1)
await disposed
await vi.advanceTimersByTimeAsync(0)
expect(workers).toHaveLength(1)
})
it('keeps failed termination fenced until actual exit and rejects strict disposal honestly', async () => {
const { client, workers } = fixture()
const first = client.scan(body('claude')).catch((error: unknown) => error)
const pending = client.scan(body('claude')).catch((error: unknown) => error)
const failure = new Error('termination failed')
workers[0].exit = Promise.reject(failure)
workers[0].emit('error', new Error('worker crashed'))
expect(await first).toEqual(new Error('worker crashed'))
expect(await pending).toBe(failure)
expect(await client.scan(body('codex')).catch((error: unknown) => error)).toBe(failure)
expect(workers).toHaveLength(1)
await expect(client.dispose()).rejects.toBe(failure)
workers[0].emit('exit', 1)
await client.dispose()
})
it('preserves unavailable classification without falling back to caller-thread scans', async () => {
const client = new UsageScanWorkerClient({
workerFactory: () => {
throw new Error('cannot spawn')
},
log: () => {}
})
clients.push(client)
await expect(client.scan(body('claude'))).rejects.toBeInstanceOf(
UsageScanWorkerUnavailableError
)
})
})
@@ -0,0 +1,219 @@
import { LazyWorkerThreadHost } from '../worker-threads/lazy-worker-thread-host'
import type { WorkerThreadFactory } from '../worker-threads/worker-thread-transport'
import {
isUsageScanWorkerProgress,
type UsageScanWorkerMessage,
type UsageScanWorkerRequest,
type UsageScanWorkerRequestBody,
type UsageScanWorkerResponse
} from './scan-worker/usage-scan-worker-protocol'
type ScanCall = {
request: UsageScanWorkerRequest
timeoutMs: number
timer: NodeJS.Timeout | null
resolve: (response: UsageScanWorkerResponse) => void
reject: (error: Error) => void
}
/** Independent providers share a worker; each provider and cache split retain exclusive ownership. */
export class UsageScanRequestScheduler {
private readonly active = new Map<number, ScanCall>()
private pending: ScanCall[] = []
private nextId = 1
private consecutiveDeaths = 0
private disposed = false
private retirementWait: Promise<void> | null = null
private readonly host: LazyWorkerThreadHost<UsageScanWorkerMessage>
constructor(
private readonly options: {
factory: WorkerThreadFactory
idleTeardownMs: number
maxConsecutiveDeaths: number
createUnavailableError: (message: string) => Error
onUnavailable: (error: unknown) => void
}
) {
this.host = new LazyWorkerThreadHost({
factory: options.factory,
idleTeardownMs: options.idleTeardownMs,
onMessage: (response) => this.onMessage(response),
onError: (error) => this.onFault(error),
onExit: (code) => this.onExit(code),
isIdle: () => this.active.size === 0 && this.pending.length === 0,
onUnavailable: options.onUnavailable,
awaitRetirement: true,
strictRetirement: true
})
}
dispatch(body: UsageScanWorkerRequestBody, timeoutMs: number): Promise<UsageScanWorkerResponse> {
return new Promise((resolve, reject) => {
if (this.disposed) {
reject(new Error('Usage scan scheduler disposed'))
return
}
if (this.active.size === 0 && this.pending.length === 0) {
this.consecutiveDeaths = 0
}
this.pending.push({
request: { ...body, id: this.nextId++ },
timeoutMs,
timer: null,
resolve,
reject
})
this.pump()
})
}
dispose(): void {
this.disposed = true
this.host.destroy()
const calls = [...this.active.values(), ...this.pending]
this.pending = []
for (const call of calls) {
this.settle(call, () => call.reject(new Error('Usage scan scheduler disposed')))
}
}
retired(): Promise<void> {
return this.host.retired()
}
private pump(): void {
if (this.disposed) {
return
}
if (this.pending.length === 0) {
if (this.active.size === 0) {
this.host.scheduleIdleTeardown()
}
return
}
if (this.host.isRetiring) {
this.waitForRetirement()
return
}
while (this.pending.length > 0) {
const index = this.nextEligible()
if (index < 0) {
return
}
const worker = this.host.ensure()
if (!worker) {
this.rejectPending(this.options.createUnavailableError('worker spawn failed'))
return
}
const call = this.pending.splice(index, 1)[0]
this.active.set(call.request.id, call)
this.host.clearIdleTimer()
this.armDeadline(call)
try {
worker.postMessage(call.request)
} catch (error) {
this.onFault(error instanceof Error ? error : new Error(String(error)))
return
}
}
}
private nextEligible(): number {
const requests = [...this.active.values()].map((call) => call.request)
if (requests.some((request) => request.operation === 'splitCacheFile')) {
return -1
}
for (let index = 0; index < this.pending.length; index++) {
const request = this.pending[index].request
if (request.operation === 'splitCacheFile') {
return index === 0 && requests.length === 0 ? index : -1
}
if (!requests.some((active) => active.providerId === request.providerId)) {
return index
}
}
return -1
}
private waitForRetirement(): void {
if (this.retirementWait) {
return
}
this.retirementWait = this.host.retired().then(
() => {
this.retirementWait = null
this.pump()
},
(error: unknown) => {
this.retirementWait = null
this.rejectPending(error instanceof Error ? error : new Error(String(error)))
}
)
}
private armDeadline(call: ScanCall): void {
if (call.timer) {
clearTimeout(call.timer)
}
call.timer = setTimeout(() => {
if (this.active.get(call.request.id) === call) {
this.onFault(new Error(`Usage scan worker reported no progress for ${call.timeoutMs}ms`))
}
}, call.timeoutMs)
call.timer.unref?.()
}
private onMessage(response: UsageScanWorkerMessage): void {
const call = this.active.get(response.id)
if (!call) {
return
}
if (isUsageScanWorkerProgress(response)) {
this.armDeadline(call)
return
}
this.consecutiveDeaths = 0
this.settle(call, () => call.resolve(response))
this.pump()
}
private onExit(code: number): void {
if (code === 0 && this.active.size === 0 && this.pending.length === 0) {
this.host.destroy()
return
}
this.onFault(new Error(`Usage scan worker exited with code ${code}`))
}
private onFault(error: Error): void {
const posted = [...this.active.values()]
this.host.destroy()
for (const call of posted) {
this.settle(call, () => call.reject(error))
}
if (posted.length > 0 && ++this.consecutiveDeaths >= this.options.maxConsecutiveDeaths) {
this.consecutiveDeaths = 0
this.rejectPending(new Error(`Usage scan worker crashed repeatedly (${error.message})`))
return
}
this.pump()
}
private rejectPending(error: Error): void {
const pending = this.pending
this.pending = []
for (const call of pending) {
this.settle(call, () => call.reject(error))
}
}
private settle(call: ScanCall, finish: () => void): void {
if (call.timer) {
clearTimeout(call.timer)
call.timer = null
}
this.active.delete(call.request.id)
finish()
}
}
@@ -0,0 +1,21 @@
import type { UsageStoreHost, UsageWorktreeCatalog } from './usage-store-host'
export function createTestUsageStoreHost(options: {
userDataPath: string
catalog?: UsageWorktreeCatalog
profileDirs?: string[]
telemetryEnabled?: boolean
capture?: UsageStoreHost['telemetry']['capture']
signal?: AbortSignal
}): UsageStoreHost {
return {
userDataPath: options.userDataPath,
signal: options.signal ?? new AbortController().signal,
readCatalog: async () => options.catalog ?? { repoIds: [], worktreesByRepo: [] },
claudeProfileDirs: async () => options.profileDirs ?? [],
telemetry: {
isEnabled: async () => options.telemetryEnabled ?? false,
capture: options.capture ?? (async () => true)
}
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { AgentTokenUsage } from '../../shared/telemetry-agent-token-usage-schema'
import type { UsageWorktreeRef } from './worktree-attribution/usage-worktree-metadata'
export type UsageWorktreeCatalog = {
repoIds: string[]
worktreesByRepo: [string, UsageWorktreeRef[]][]
}
export type UsageTelemetryHost = {
isEnabled(): Promise<boolean>
capture(snapshot: AgentTokenUsage): Promise<boolean>
}
export type UsageStoreHost = {
signal: AbortSignal
userDataPath: string
readCatalog(): Promise<UsageWorktreeCatalog>
claudeProfileDirs(): Promise<string[]>
telemetry: UsageTelemetryHost
}
@@ -21,7 +21,7 @@ export function getUsageWorktreeFingerprint(
}
export function createWorktreeRefs(
repos: Repo[],
repos: readonly Pick<Repo, 'id'>[],
worktreesByRepo: Map<string, { path: string; worktreeId: string; displayName: string }[]>
): UsageScanWorktreeRef[] {
const refs: UsageScanWorktreeRef[] = []
@@ -6,7 +6,7 @@ export class LazyWorkerThreadHost<TResponse> {
private idleTimer: NodeJS.Timeout | null = null
private cleanupListeners: (() => void) | null = null
private reportedUnavailable = false
private retiring: Promise<void> | null = null
private retiring: { completion: Promise<void>; failure?: unknown } | null = null
constructor(
private readonly options: {
@@ -21,6 +21,8 @@ export class LazyWorkerThreadHost<TResponse> {
onUnavailable: (error: unknown) => void
/** Native calls can delay termination; wait for exit before spawning another worker (#24572). */
awaitRetirement?: boolean
/** Usage ownership fences require exact exit proof after termination fails. */
strictRetirement?: boolean
}
) {}
@@ -76,17 +78,42 @@ export class LazyWorkerThreadHost<TResponse> {
this.cleanupListeners?.()
this.cleanupListeners = null
worker.removeAllListeners()
// A rejected terminate still ends retirement, so it cannot latch spawning off.
const exited = worker.terminate().catch(() => undefined)
if (this.options.awaitRetirement) {
this.retiring = exited.then(() => {
this.retiring = null
})
if (!this.options.awaitRetirement) {
void worker.terminate().catch(() => {})
return
}
const retirement: { completion: Promise<void>; failure?: unknown } = {
completion: Promise.resolve()
}
this.retiring = retirement
let proveExit!: () => void
const exitProof = new Promise<void>((resolve) => {
proveExit = resolve
})
const onExit = (): void => {
worker.off('exit', onExit)
if (this.retiring === retirement) {
this.retiring = null
}
proveExit()
}
worker.on('exit', onExit)
const terminated = worker.terminate().then(onExit, (error: unknown) => {
if (this.options.strictRetirement) {
retirement.failure = error
} else {
onExit()
}
})
retirement.completion = Promise.race([exitProof, terminated])
}
retired(): Promise<void> {
return this.retiring ?? Promise.resolve()
async retired(): Promise<void> {
const retirement = this.retiring
await retirement?.completion
if (this.retiring === retirement && retirement?.failure !== undefined) {
throw retirement.failure
}
}
scheduleIdleTeardown(): void {
@@ -63,7 +63,10 @@ const MAX_CONSECUTIVE_DEATHS = 3
function makeQueue(
workers: FakeWorker[],
options: { awaitRetirement?: boolean; makeWorker?: () => FakeWorker } = {}
options: {
awaitRetirement?: boolean
makeWorker?: () => FakeWorker
} = {}
): WorkerThreadRequestQueue<Request, Response> {
return new WorkerThreadRequestQueue<Request, Response>({
awaitRetirement: options.awaitRetirement,
@@ -434,6 +437,7 @@ describe('WorkerThreadRequestQueue', () => {
await vi.advanceTimersByTimeAsync(TIMEOUT_MS)
expect(await first).toMatchObject({ message: `timed out after ${TIMEOUT_MS}ms` })
await expect(queue.retired()).resolves.toBeUndefined()
const next = send(queue, 'next')
expect(workers).toHaveLength(2)
workers[1].respond()
@@ -129,8 +129,12 @@ export class WorkerThreadRequestQueue<
}
}
retired(): Promise<void> {
return this.host.retired()
}
private pump(): void {
if (this.active || this.queue.length === 0) {
if (this.disposed || this.active || this.queue.length === 0) {
return
}
// A shared signal is already aborted before its remaining listeners run.