mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
fix(serve): give headless hosts the services the desktop window path started (#37633)
Headless orca serve (orcad and Electron --serve) was missing services that only the desktop composition or a window attach started. - orcad now wires the orchestration environment transport, so a headless Run home can place workers on its paired servers again. The transport moves into a shared factory both hosts use. Its routing graph broadcast to renderers through BrowserWindow directly; that now goes through the desktop-surface port, so the graph runs on plain Node. - orcad also resolves paired-device names, which was a declared gap. - Usage polling runs from the account composition every host builds. Polls fire while someone watches: the focused desktop window, or a live accounts.subscribe stream. A headless host's remote readers now get the polls that stream promises, and unwatched hosts spend no usage budget. - The composition parity test now accounts for every service a window attach starts, so a new window-only start can't silently skip headless hosts. Fixes #36853 Refs #37256 Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
@@ -17,6 +17,18 @@ export const electronRuntimeDesktopSurface: RuntimeDesktopSurface = {
|
||||
new Notification({ title, body }).show()
|
||||
return true
|
||||
},
|
||||
sendToRenderers: (channel, payload) => {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (window.isDestroyed()) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
window.webContents.send(channel, payload)
|
||||
} catch {
|
||||
// A renderer can disappear between isDestroyed() and send().
|
||||
}
|
||||
}
|
||||
},
|
||||
findWindowById: (id) => BrowserWindow.fromId(id),
|
||||
isResourceViewVisible: (sender) => isResourceWindowVisible(BrowserWindow.fromWebContents(sender)),
|
||||
onIpc: (channel, listener) => {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getAppEnvironment } from '../../../shared/app-environment'
|
||||
import { getPreferredPairedRuntimeConnection } from '../../../shared/remote-runtime/host-catalog/runtime-environments'
|
||||
import { resolveEnvironment } from '../../../shared/remote-runtime/host-store/runtime-environment-store'
|
||||
import {
|
||||
pairedOrchestrationPeers,
|
||||
type OrchestrationEnvironmentTransport
|
||||
} from '../../runtime/orchestration/environment-transport'
|
||||
|
||||
/** Dials this host's paired servers for cross-server orchestration; host-free, so desktop and orcad share it. */
|
||||
export function createOrchestrationEnvironmentTransport(): OrchestrationEnvironmentTransport {
|
||||
// Read per call: the desktop settles userData casing only after app.setName.
|
||||
const registryRoot = (): string => getAppEnvironment().getPath('userData')
|
||||
return {
|
||||
resolve: (selector) => {
|
||||
const environment = resolveEnvironment(registryRoot(), selector)
|
||||
const connection = getPreferredPairedRuntimeConnection(environment)
|
||||
return {
|
||||
environmentId: environment.id,
|
||||
name: environment.name,
|
||||
...pairedOrchestrationPeers(connection.hostPublicKeyB64, connection.credential.deviceToken),
|
||||
pairingRevision: environment.pairingRevision ?? environment.createdAt
|
||||
}
|
||||
},
|
||||
// Loaded on first dial: the routing graph is large and most hosts never place remote workers.
|
||||
call: async (selector, method, params, timeoutMs, envelope, expectedPairingRevision) =>
|
||||
(await import('./runtime-environment-transport-routing')).callRuntimeEnvironment(
|
||||
registryRoot(),
|
||||
selector,
|
||||
method,
|
||||
params,
|
||||
timeoutMs,
|
||||
expectedPairingRevision,
|
||||
envelope
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../shared/remote-runtime/control/remote-runtime-shared-control-types'
|
||||
import { RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL } from '../../../shared/remote-runtime/host-catalog/runtime-environment-diagnostics'
|
||||
import { getRuntimeDesktopSurface } from '../../runtime/runtime-desktop-surface'
|
||||
|
||||
export type RuntimeEnvironmentDiagnosticsEvent = {
|
||||
environmentId: string
|
||||
@@ -11,14 +11,5 @@ export type RuntimeEnvironmentDiagnosticsEvent = {
|
||||
export function publishRuntimeEnvironmentDiagnostics(
|
||||
event: RuntimeEnvironmentDiagnosticsEvent
|
||||
): void {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (window.isDestroyed()) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
window.webContents.send(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, event)
|
||||
} catch {
|
||||
// A renderer can disappear between isDestroyed() and send().
|
||||
}
|
||||
}
|
||||
getRuntimeDesktopSurface().sendToRenderers?.(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, event)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { getRuntimeDesktopSurface } from '../../runtime/runtime-desktop-surface'
|
||||
import { sendRemoteRuntimeRequest } from '../../../shared/remote-runtime/remote-runtime-client'
|
||||
import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../shared/protocol-version'
|
||||
import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../shared/electron-remote-runtime-client-capabilities'
|
||||
@@ -140,16 +140,7 @@ export function createRuntimeEnvironmentStatusOwner(
|
||||
return capable && active
|
||||
},
|
||||
publish: (snapshot) => {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (window.isDestroyed()) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
window.webContents.send(RUNTIME_HOST_STATUS_CHANNEL, snapshot)
|
||||
} catch {
|
||||
/* A renderer can close during publication. */
|
||||
}
|
||||
}
|
||||
getRuntimeDesktopSurface().sendToRenderers?.(RUNTIME_HOST_STATUS_CHANNEL, snapshot)
|
||||
for (const listener of statusListeners) {
|
||||
listener()
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ export function createOrcadAccountServices(args: {
|
||||
const services = createProviderAccountServices(store, {
|
||||
setClaudeStatusLineListener: args.setClaudeStatusLineListener
|
||||
})
|
||||
// Refresh usage on demand through list/subscribe rather than starting a background poller.
|
||||
args.registerCleanup(() => {
|
||||
services.rateLimits.stop()
|
||||
args.setClaudeStatusLineListener(null)
|
||||
|
||||
@@ -189,10 +189,9 @@ async function startOrcadRuntime(
|
||||
|
||||
const { loadAgentSessionClaimSigner } =
|
||||
await import('../runtime/agent-session/ownership/agent-session-claim-identity')
|
||||
let runtimeRpc: InstanceType<typeof OrcaRuntimeRpcServer> | null = null
|
||||
const { createOrcadPairedEnvironmentOptions } = await import('./orcad-paired-environment-options')
|
||||
const pairedEnvironments = createOrcadPairedEnvironmentOptions()
|
||||
const runtime = new OrcaRuntimeService(profileStore, stats, {
|
||||
getPairedDeviceName: (deviceId) =>
|
||||
runtimeRpc?.getDeviceRegistry()?.getDevice(deviceId)?.name ?? null,
|
||||
// Persistent signatures let restarted runtimes adopt PTYs owned before restart.
|
||||
agentSessionClaimSigner: loadAgentSessionClaimSigner(runtimeUserDataPath, runtimeUserDataPath),
|
||||
getAdditionalAiVaultCodexHomePaths: () => [
|
||||
@@ -206,6 +205,7 @@ async function startOrcadRuntime(
|
||||
),
|
||||
// Resolve lazily because daemon swaps replace the provider after construction.
|
||||
getLocalProvider: () => getLocalPtyProvider(),
|
||||
...pairedEnvironments.runtimeOptions,
|
||||
// Read current daemon ownership; failed/degraded launch cannot claim fresh PTY persistence.
|
||||
canRecoverPersistentLocalPtys: () => daemonOwnsFreshPersistentPtys(),
|
||||
// Node hosts cannot promote into desktop windows; override the constructor’s openable default.
|
||||
@@ -304,7 +304,7 @@ async function startOrcadRuntime(
|
||||
webClientRoot: resolveOrcadWebClientRoot(options.webClientRoot),
|
||||
...orcadWsPortOptions(options.port)
|
||||
})
|
||||
runtimeRpc = rpc
|
||||
pairedEnvironments.bindRpc(rpc)
|
||||
runtime.setAgentSenderPeerKey(() => rpc.getE2EEPublicKey())
|
||||
// Stop RPC before cleanup can tear down writers and runtime state.
|
||||
registerCleanup(() => rpc.stop())
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createOrchestrationEnvironmentTransport } from '../ipc/runtime-environments/orchestration-environment-transport'
|
||||
import type { RuntimeConstructionDependencies } from '../runtime/construction/runtime-construction-dependencies'
|
||||
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
|
||||
|
||||
type PairedEnvironmentRuntimeOptions = Required<
|
||||
Pick<RuntimeConstructionDependencies, 'orchestrationEnvironmentTransport' | 'getPairedDeviceName'>
|
||||
>
|
||||
|
||||
/** Runtime options for this host's pairings: the servers it dials and the devices paired to it. */
|
||||
export function createOrcadPairedEnvironmentOptions(): {
|
||||
runtimeOptions: PairedEnvironmentRuntimeOptions
|
||||
/** RPC owns the device registry and starts after the runtime. */
|
||||
bindRpc: (rpc: Pick<OrcaRuntimeRpcServer, 'getDeviceRegistry'>) => void
|
||||
} {
|
||||
let rpc: Pick<OrcaRuntimeRpcServer, 'getDeviceRegistry'> | null = null
|
||||
return {
|
||||
runtimeOptions: {
|
||||
// A headless host is a Run home too; it places workers on its own paired servers (#36853).
|
||||
orchestrationEnvironmentTransport: createOrchestrationEnvironmentTransport(),
|
||||
getPairedDeviceName: (pairedDeviceId) =>
|
||||
rpc?.getDeviceRegistry()?.getDevice(pairedDeviceId)?.name ?? null
|
||||
},
|
||||
bindRpc: (server) => {
|
||||
rpc = server
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,5 +173,7 @@ export function createProviderAccountServices(
|
||||
)
|
||||
}))
|
||||
})
|
||||
// Every host runs the poll timer; ticks fetch only while a window or remote reader watches.
|
||||
rateLimits.start({ fetchImmediately: false })
|
||||
return { rateLimits, codexRuntimeHome, codexAccounts, claudeRuntimeAuth, claudeAccounts }
|
||||
}
|
||||
|
||||
@@ -129,6 +129,32 @@ describe('RateLimitService', () => {
|
||||
service.stop()
|
||||
})
|
||||
|
||||
// #37256: a headless host has no window, so its only watchers are remote account streams.
|
||||
it('polls without a window only while a remote reader watches', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
mockFreshBackgroundProviderFetches()
|
||||
vi.mocked(fetchClaudeRateLimits).mockImplementation(async () => okProvider('claude', 12))
|
||||
const service = new RateLimitService()
|
||||
service.start({ fetchImmediately: false })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15 * 60 * 1000)
|
||||
expect(fetchClaudeRateLimits).not.toHaveBeenCalled()
|
||||
|
||||
const release = service.addUsageViewer()
|
||||
await vi.advanceTimersByTimeAsync(15 * 60 * 1000)
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
|
||||
|
||||
release()
|
||||
await vi.advanceTimersByTimeAsync(15 * 60 * 1000)
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
|
||||
|
||||
service.stop()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('performs a one-shot active-window fetch when startup focus was missed', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -85,7 +85,7 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco
|
||||
this.detachWindowListeners?.()
|
||||
this.mainWindow = mainWindow
|
||||
const refreshOnResume = (): void => {
|
||||
void this.refreshIfWindowActive()
|
||||
void this.refreshIfWatched()
|
||||
}
|
||||
// Replacing windows must also detach the old closed listener.
|
||||
const detachWindowListeners = (): void => {
|
||||
@@ -108,10 +108,25 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco
|
||||
mainWindow.on('restore', refreshOnResume)
|
||||
mainWindow.on('closed', onClosed)
|
||||
this.detachWindowListeners = detachWindowListeners
|
||||
// Quota probes spawn CLIs/use network; defer past first paint, then show/focus refresh.
|
||||
this.scheduleDeferredStartupRefresh()
|
||||
}
|
||||
|
||||
/** Counts a paired client's usage stream so polls run without a focused window; returns its release. */
|
||||
addUsageViewer(): () => void {
|
||||
this.usageViewers += 1
|
||||
let released = false
|
||||
return () => {
|
||||
if (!released) {
|
||||
released = true
|
||||
this.usageViewers -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start(options: { fetchImmediately?: boolean } = {}): void {
|
||||
if (options.fetchImmediately !== false) {
|
||||
this.clearDeferredStartupRefresh()
|
||||
void this.fetchAll()
|
||||
} else {
|
||||
this.scheduleDeferredStartupRefresh()
|
||||
|
||||
@@ -42,7 +42,7 @@ export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue
|
||||
this.clearDeferredStartupRefresh()
|
||||
this.deferredStartupRefreshTimer = setTimeout(() => {
|
||||
this.deferredStartupRefreshTimer = null
|
||||
void this.refreshIfWindowActive()
|
||||
void this.refreshIfWatched()
|
||||
}, DEFERRED_STARTUP_ACTIVE_REFRESH_MS)
|
||||
}
|
||||
|
||||
@@ -54,6 +54,10 @@ export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue
|
||||
}
|
||||
|
||||
protected shouldBackgroundPoll(): boolean {
|
||||
// Poll only while someone watches: a paired client's stream, or the focused desktop window.
|
||||
if (this.usageViewers > 0) {
|
||||
return true
|
||||
}
|
||||
if (!this.mainWindow || this.mainWindow.isDestroyed()) {
|
||||
return false
|
||||
}
|
||||
@@ -174,7 +178,7 @@ export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue
|
||||
}
|
||||
}
|
||||
|
||||
protected async refreshIfWindowActive(): Promise<void> {
|
||||
protected async refreshIfWatched(): Promise<void> {
|
||||
if (!this.shouldBackgroundPoll()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@ export abstract class RateLimitServiceState {
|
||||
zcode: 0
|
||||
}
|
||||
protected mainWindow: BrowserWindow | null = null
|
||||
/** Paired clients streaming usage (accounts.subscribe); headless hosts have no window to watch. */
|
||||
protected usageViewers = 0
|
||||
protected detachWindowListeners: (() => void) | null = null
|
||||
protected isFetching = false
|
||||
protected fullFetchQueued = false
|
||||
|
||||
@@ -75,11 +75,6 @@ const DECLARED_HOST_DIFFERENCES: readonly DeclaredDifference[] = [
|
||||
missingOn: 'orcad',
|
||||
reason: 'Pairs with attestAgentHookCompatibilityAuthority.'
|
||||
},
|
||||
{
|
||||
wiring: 'ctor:orchestrationEnvironmentTransport',
|
||||
missingOn: 'orcad',
|
||||
reason: 'Cross-environment orchestration dials paired environments from the desktop only.'
|
||||
},
|
||||
{
|
||||
wiring: 'ctor:skillTransactionRecovery',
|
||||
missingOn: 'orcad',
|
||||
@@ -160,6 +155,68 @@ const DECLARED_HOST_DIFFERENCES: readonly DeclaredDifference[] = [
|
||||
}
|
||||
]
|
||||
|
||||
type WindowAttachStart = {
|
||||
reason: string
|
||||
/** Where each headless host starts its own equivalent; omitted only when none needs one. */
|
||||
headless?: Record<Root, { file: string; call: string }>
|
||||
}
|
||||
|
||||
/** Background work started when a desktop window attaches; headless hosts never attach one (#37256). */
|
||||
const WINDOW_ATTACH_STARTS: Readonly<Record<string, WindowAttachStart>> = {
|
||||
automations: {
|
||||
reason: 'The desktop binds automations to its window; headless hosts start them at launch.',
|
||||
headless: {
|
||||
electron: {
|
||||
file: 'src/main/startup/main-process-runtime-launch.ts',
|
||||
call: 'automations?.start()'
|
||||
},
|
||||
orcad: { file: 'src/main/orcad/orcad-entry.ts', call: 'startOrcadAutomations(' }
|
||||
}
|
||||
},
|
||||
'rateLimits.attach': {
|
||||
reason: 'A focused window is one usage watcher; the shared account composition runs the poller.'
|
||||
},
|
||||
initTccPromptNotice: { reason: 'macOS privacy-prompt notices render in the window.' },
|
||||
installPluginDesktopFrameServices: { reason: 'Plugin frames render inside the window.' },
|
||||
attachMainWindowServices: {
|
||||
reason: 'Window IPC and renderer wiring; its starts are listed here.'
|
||||
},
|
||||
hydrateLocalPtyRegistryAtBoot: {
|
||||
reason: 'Headless PTY registration hydrates the same registry.',
|
||||
headless: {
|
||||
electron: {
|
||||
file: 'src/main/startup/main-process-runtime-launch.ts',
|
||||
call: 'registerHeadlessPtyRuntime('
|
||||
},
|
||||
orcad: { file: 'src/main/orcad/orcad-entry.ts', call: 'registerHeadlessPtyRuntime(' }
|
||||
}
|
||||
},
|
||||
scheduleMainWindowAutoUpdaterSetup: {
|
||||
reason: 'Serve sets up its own updater; orcad is updated by the client that manages it.'
|
||||
},
|
||||
scheduleWorktreeBaseDirectoryWatcherSync: {
|
||||
reason: 'Unwired gap (follow-up): external worktree changes on a headless host push nothing.'
|
||||
},
|
||||
startFolderRepoGitUpgradeWatch: {
|
||||
reason:
|
||||
'Unwired gap (follow-up): git init in a headless folder project needs a manual type change.'
|
||||
},
|
||||
scheduleHistoryGc: {
|
||||
reason: 'Unwired gap (follow-up): headless hosts never prune history of vanished worktrees.'
|
||||
},
|
||||
'openCodeHookService.configDirGc.schedule': {
|
||||
reason: 'Unwired gap (follow-up): headless hosts never prune stale OpenCode overlays.'
|
||||
},
|
||||
'openCode2HookService.configDirGc.schedule': {
|
||||
reason: 'Unwired gap (follow-up): headless hosts never prune stale OpenCode overlays.'
|
||||
}
|
||||
}
|
||||
|
||||
const WINDOW_ATTACH_SOURCES = [
|
||||
{ file: 'src/main/startup/main-window-core-services.ts', fn: 'attachMainWindowCoreServices' },
|
||||
{ file: 'src/main/window/attach-main-window-services.ts', fn: 'attachMainWindowServices' }
|
||||
] as const
|
||||
|
||||
const UNWIRED_CONSTRUCTION_OPTIONS: Readonly<Record<string, string>> = {}
|
||||
|
||||
const POSITIONAL_CONSTRUCTOR_PARAMETERS = ['stats'] as const
|
||||
@@ -206,6 +263,10 @@ const SPREAD_SOURCES: Record<string, { file: string; anchor: string }> = {
|
||||
file: 'src/main/orcad/orcad-account-services.ts',
|
||||
anchor: 'runtimeDeps: {'
|
||||
},
|
||||
'pairedEnvironments.runtimeOptions': {
|
||||
file: 'src/main/orcad/orcad-paired-environment-options.ts',
|
||||
anchor: 'runtimeOptions: {'
|
||||
},
|
||||
'orcadWsPortOptions(': { file: 'src/main/orcad/orcad-ws-port-policy.ts', anchor: 'wsPort: port' }
|
||||
}
|
||||
|
||||
@@ -399,6 +460,31 @@ function sourceFilesUnder(dir: string): string[] {
|
||||
.map((file) => join(dir, file).split(sep).join('/'))
|
||||
}
|
||||
|
||||
function functionBody(source: string, name: string): string {
|
||||
const at = source.indexOf(`export function ${name}(`)
|
||||
if (at === -1) {
|
||||
throw new Error(`function not found: ${name}`)
|
||||
}
|
||||
const open = source.indexOf('(', at)
|
||||
const paramsEnd = open + parenGroup(source, open).length + 1
|
||||
return braceGroup(source, source.indexOf('{', paramsEnd))
|
||||
}
|
||||
|
||||
/** Calls whose final member starts, schedules, hydrates, initializes, attaches or installs something. */
|
||||
function windowAttachStarts(): Set<string> {
|
||||
const starts = new Set<string>()
|
||||
for (const { file, fn } of WINDOW_ATTACH_SOURCES) {
|
||||
const body = functionBody(read(file), fn)
|
||||
for (const [, callee] of body.matchAll(
|
||||
/(?<![\w$.])((?:[A-Za-z_$][\w$]*\??\.)*(?:start|schedule|hydrate|init|attach|install)[\w$]*)(?:\?\.)?\(/g
|
||||
)) {
|
||||
const path = callee.replaceAll('?', '')
|
||||
starts.add(path.endsWith('.start') ? path.slice(0, -'.start'.length) : path)
|
||||
}
|
||||
}
|
||||
return starts
|
||||
}
|
||||
|
||||
function declaredConstructionOptions(): string[] {
|
||||
const source = read('src/main/runtime/construction/runtime-construction-dependencies.ts')
|
||||
const inner = objectLiteralAfter(source, 'export type RuntimeConstructionDependencies = {')
|
||||
@@ -420,6 +506,9 @@ describe('runtime composition parity (Electron vs orcad)', () => {
|
||||
'ctor:getLocalProvider',
|
||||
'ctor:onTerminalAgentStatus',
|
||||
'ctor:agentSessionClaimSigner',
|
||||
// #36853: without it a headless Run home refuses worker-start --on with server_required.
|
||||
'ctor:orchestrationEnvironmentTransport',
|
||||
'ctor:getPairedDeviceName',
|
||||
'rpc-option:webClientRoot',
|
||||
'rpc-option:wsPort',
|
||||
'runtime.setAccountServices',
|
||||
@@ -492,6 +581,16 @@ describe('runtime composition parity (Electron vs orcad)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('accounts for every service a desktop window starts, which headless hosts never attach', () => {
|
||||
expect([...windowAttachStarts()].sort()).toEqual(Object.keys(WINDOW_ATTACH_STARTS).sort())
|
||||
for (const [start, { headless }] of Object.entries(WINDOW_ATTACH_STARTS)) {
|
||||
for (const [root, counterpart] of Object.entries(headless ?? {})) {
|
||||
expect(read(counterpart.file), `${root} ${start}`).toContain(counterpart.call)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// #37256: one composition runs the usage poller on desktop, Electron serve and orcad alike.
|
||||
it('lists each advertised-but-unserved capability against what the host advertises', () => {
|
||||
for (const difference of DECLARED_HOST_DIFFERENCES) {
|
||||
expect(difference.reason, difference.wiring).not.toBe('')
|
||||
|
||||
@@ -562,6 +562,7 @@ describe('remote runtime request connection integration', () => {
|
||||
},
|
||||
getAccountsSnapshot: () => ({ claude: null, codex: null }),
|
||||
getAccountsStreamSnapshot: () => ({ claude: null, codex: null }),
|
||||
getUsageRateLimits: () => null,
|
||||
refreshAccountsForMobile: async () => {
|
||||
for (const listener of accountsListeners) {
|
||||
listener({ claude: null, codex: null })
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { eraseRpcMethods, isStreamingMethod, type RpcContext } from '../core'
|
||||
import { ACCOUNT_METHODS } from './accounts'
|
||||
import { RateLimitService } from '../../../rate-limits/service'
|
||||
import { createEmptyRateLimitState } from '../../../../shared/rate-limit-state-factory'
|
||||
import type {
|
||||
HostAccountSignInChannel,
|
||||
@@ -337,6 +338,62 @@ describe('account RPC methods', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// #37256: a paired client's stream keeps a windowless host polling; the host's own renderer follows window focus.
|
||||
it.each([
|
||||
{ caller: { kind: 'paired-device', deviceId: 'phone-1' } as const, holds: true },
|
||||
{ caller: { kind: 'desktop' } as const, holds: false }
|
||||
])('holds a usage viewer for a $caller.kind stream: $holds', async ({ caller, holds }) => {
|
||||
const rateLimits = new RateLimitService()
|
||||
const releaseViewer = vi.fn()
|
||||
const addUsageViewer = vi.spyOn(rateLimits, 'addUsageViewer').mockReturnValue(releaseViewer)
|
||||
let cleanup: (() => void) | undefined
|
||||
const runtime: Pick<
|
||||
OrcaRuntimeService,
|
||||
| 'getAccountsStreamSnapshot'
|
||||
| 'getUsageRateLimits'
|
||||
| 'onAccountsChanged'
|
||||
| 'registerSubscriptionCleanup'
|
||||
| 'cleanupSubscription'
|
||||
| 'refreshAccountsForMobileSubscriber'
|
||||
> = {
|
||||
getAccountsStreamSnapshot: vi.fn(() => ({
|
||||
claude: { accounts: [], activeAccountId: null },
|
||||
codex: { accounts: [], activeAccountId: null },
|
||||
rateLimits: createEmptyRateLimitState()
|
||||
})),
|
||||
getUsageRateLimits: () => rateLimits,
|
||||
onAccountsChanged: vi.fn(() => vi.fn()),
|
||||
registerSubscriptionCleanup: vi.fn((_id: string, next: () => void) => {
|
||||
cleanup = next
|
||||
}),
|
||||
cleanupSubscription: vi.fn(() => {
|
||||
const release = cleanup
|
||||
cleanup = undefined
|
||||
release?.()
|
||||
}),
|
||||
refreshAccountsForMobileSubscriber: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
const subscribe = method('accounts.subscribe')
|
||||
if (!isStreamingMethod(subscribe)) {
|
||||
throw new Error('accounts.subscribe must be a streaming method')
|
||||
}
|
||||
const stream: {
|
||||
handler(
|
||||
params: unknown,
|
||||
context: { runtime: typeof runtime; caller?: RpcContext['caller']; signal?: AbortSignal },
|
||||
emit: (frame: unknown) => void
|
||||
): Promise<void>
|
||||
} = subscribe
|
||||
const controller = new AbortController()
|
||||
const running = stream.handler(undefined, { runtime, caller, signal: controller.signal }, vi.fn())
|
||||
await vi.waitFor(() => expect(runtime.refreshAccountsForMobileSubscriber).toHaveBeenCalled())
|
||||
|
||||
expect(addUsageViewer).toHaveBeenCalledTimes(holds ? 1 : 0)
|
||||
controller.abort()
|
||||
await running
|
||||
expect(releaseViewer).toHaveBeenCalledTimes(holds ? 1 : 0)
|
||||
})
|
||||
|
||||
it('rejects the retired Claude import even for a local socket, before touching its supplied path', async () => {
|
||||
const old = method('accounts.addClaudeFromConfigDir')
|
||||
if (isStreamingMethod(old)) {
|
||||
|
||||
@@ -240,7 +240,7 @@ const ACCOUNT_METHOD_DEFINITIONS = [
|
||||
name: 'accounts.subscribe',
|
||||
permission: 'workspace',
|
||||
params: null,
|
||||
handler: async (_params, { runtime, connectionId, signal }, emit) => {
|
||||
handler: async (_params, { runtime, connectionId, signal, caller }, emit) => {
|
||||
if (signal?.aborted) {
|
||||
return
|
||||
}
|
||||
@@ -248,6 +248,11 @@ const ACCOUNT_METHOD_DEFINITIONS = [
|
||||
const unsubscribe = runtime.onAccountsChanged((snapshot) => {
|
||||
emit({ type: 'snapshot', snapshot })
|
||||
})
|
||||
// A paired client watching keeps polls running without a window; the host's own renderer follows window focus.
|
||||
const releaseUsageViewer =
|
||||
caller?.kind === 'paired-device'
|
||||
? runtime.getUsageRateLimits()?.addUsageViewer()
|
||||
: undefined
|
||||
|
||||
// Key cleanup by socket plus sequence so sibling sockets and concurrent subscribes cannot evict each other.
|
||||
const seq = ++accountsSubscriptionSeq
|
||||
@@ -258,6 +263,7 @@ const ACCOUNT_METHOD_DEFINITIONS = [
|
||||
() => {
|
||||
signal?.removeEventListener('abort', release)
|
||||
unsubscribe()
|
||||
releaseUsageViewer?.()
|
||||
stopped.resolve()
|
||||
},
|
||||
connectionId
|
||||
|
||||
@@ -10,6 +10,8 @@ export type RuntimeDesktopSurface = {
|
||||
isAwayForMobileNotifications?(): boolean | undefined
|
||||
/** Show a native notification. Returns false when the host cannot, so callers can say so. */
|
||||
showNotification(input: { title: string; body: string }): boolean
|
||||
/** Sends to every open renderer window. Absent where there is no desktop. */
|
||||
sendToRenderers?(channel: string, payload: unknown): void
|
||||
/** The renderer window with this id, or null when there is no desktop. */
|
||||
findWindowById(id: number): BrowserWindow | null
|
||||
isResourceViewVisible?(sender: WebContents): boolean
|
||||
|
||||
@@ -22,15 +22,9 @@ import { resolveHostCodexSessionSourceHome } from '../codex/history/rollouts/cod
|
||||
import { agentHookRuntimeDeps } from '../agent-hooks/agent-hook-runtime-deps'
|
||||
import { getDaemonProvider } from '../daemon/daemon-provider-state'
|
||||
import type { TerminalSideEffectBatch } from '../../shared/terminal/output/terminal-side-effect-facts'
|
||||
|
||||
import { resolveEnvironment } from '../../shared/remote-runtime/host-store/runtime-environment-store'
|
||||
import { resolveTuiAgentLaunchEnv } from '../../shared/agent-launch/choices/tui-agent-launch-defaults'
|
||||
import { getPreferredPairedRuntimeConnection } from '../../shared/remote-runtime/host-catalog/runtime-environments'
|
||||
import {
|
||||
type OrchestrationEnvironmentTransport,
|
||||
pairedOrchestrationPeers
|
||||
} from '../runtime/orchestration/environment-transport'
|
||||
import { callRuntimeEnvironment } from '../ipc/runtime-environments/runtime-environment-transport-routing'
|
||||
import { createOrchestrationEnvironmentTransport } from '../ipc/runtime-environments/orchestration-environment-transport'
|
||||
import { mainProcessState as state } from './main-process-state'
|
||||
import { suspendEphemeralVmWorkspace } from '../ephemeral-vm/workspace-suspend'
|
||||
import { installWslOrcadAccountShim } from './wsl-orcad-account-env-wiring'
|
||||
@@ -68,28 +62,6 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService {
|
||||
if (!store || !stats) {
|
||||
throw new Error('Store and stats must be initialized before runtime')
|
||||
}
|
||||
const orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport = {
|
||||
resolve: (selector) => {
|
||||
const environment = resolveEnvironment(getRuntimeEnvironmentRegistryRoot(), selector)
|
||||
const connection = getPreferredPairedRuntimeConnection(environment)
|
||||
return {
|
||||
environmentId: environment.id,
|
||||
name: environment.name,
|
||||
...pairedOrchestrationPeers(connection.hostPublicKeyB64, connection.credential.deviceToken),
|
||||
pairingRevision: environment.pairingRevision ?? environment.createdAt
|
||||
}
|
||||
},
|
||||
call: (selector, method, params, timeoutMs, envelope, expectedPairingRevision) =>
|
||||
callRuntimeEnvironment(
|
||||
getRuntimeEnvironmentRegistryRoot(),
|
||||
selector,
|
||||
method,
|
||||
params,
|
||||
timeoutMs,
|
||||
expectedPairingRevision,
|
||||
envelope
|
||||
)
|
||||
}
|
||||
// Process-scope subscription covers desktop, headless serve and fleet status.
|
||||
const observedPaneIdentities = new AgentStatusObservedPaneIdentities()
|
||||
const runtime = new OrcaRuntimeService(store, stats, {
|
||||
@@ -134,7 +106,7 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService {
|
||||
homePath,
|
||||
resolveTuiAgentLaunchEnv('codex', store.getSettings())
|
||||
),
|
||||
orchestrationEnvironmentTransport,
|
||||
orchestrationEnvironmentTransport: createOrchestrationEnvironmentTransport(),
|
||||
workspaceSleepPlatform: {
|
||||
callEnvironment: (environmentId, method, params, timeoutMs) =>
|
||||
callRuntimeEnvironment(
|
||||
|
||||
@@ -161,6 +161,4 @@ export function attachMainWindowCoreServices(
|
||||
// Attach durable diagnostic pulls now, defer process launch past first paint.
|
||||
initTccPromptNotice(window, { deferWatchUntilReadyToShow: true })
|
||||
rateLimits.attach(window)
|
||||
// Quota probes spawn CLIs/use network; defer to show/focus refresh so they do not compete with first paint.
|
||||
rateLimits.start({ fetchImmediately: false })
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ export function useOwnerUsageRateLimits(local: RateLimitState): OwnerUsageRateLi
|
||||
}
|
||||
let active = true
|
||||
const reopen = (): void => setStreamGeneration((value) => value + 1)
|
||||
// Why: orcad does not poll on its own, so a long-lived stream would freeze the meters.
|
||||
// Why: older hosts never poll for a paired stream, so a long-lived one would freeze the meters.
|
||||
const staleTimer = setTimeout(reopen, REMOTE_USAGE_STALE_REOPEN_MS)
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const lost = (): void => {
|
||||
|
||||
Reference in New Issue
Block a user