Files
orca/src/main/startup/main-process-runtime-service.ts
T
Jinwoo Hong 564f4d021a feat: live updates for agent state rules (#24387)
Orca downloads a newer agent-state-rules.json from a fixed GitHub release (stable or next channel), validates it like the bundled rules, and applies it without a restart; a local override wins over the download, which wins over the bundled rules. A hand-started workflow from main is the only publisher; merging publishes nothing.
2026-10-02 14:59:24 -04:00

205 lines
11 KiB
TypeScript

import {
applySessionSearchSettingsChange,
installChildSessionSearchService
} from '../ai-vault-search/session-search-enablement'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { sessionSearchScopeCatalogFromStore } from '../ai-vault-search/session-search-store-scope-catalog'
import { getCanonicalUserDataPath } from '../persistence/loading-store/user-data-path'
import { app } from 'electron'
import { OrcaRuntimeService } from '../runtime/orca-runtime'
import { getLocalPtyProvider, getSshPtyProvider, clearProviderPtyState } from '../ipc/pty'
import { agentHookServer } from '../agent-hooks/server'
import { browserManager } from '../browser/browser-manager'
import { loadAgentSessionClaimSigner } from '../runtime/agent-session-claim-identity'
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
import { prepareCodexAiVaultSessionResume } from '../codex/codex-ai-vault-session-resume'
import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home'
import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import { getDaemonProvider } from '../daemon/daemon-init'
import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts'
import type { OrchestrationEnvironmentTransport } from '../runtime/orchestration/environment-transport'
import { resolveEnvironment } from '../../shared/runtime-environment-store'
import { getPreferredPairingOffer } from '../../shared/runtime-environments'
import { fingerprintOrchestrationPeer } from '../runtime/orchestration/environment-transport'
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
import { mainProcessState as state } from './main-process-state'
import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation'
import type { RuntimeDesktopWindowStatus } from '../../shared/runtime-types'
import { ArtifactCloudService } from '../artifacts/artifact-cloud-service'
import { SkillCloudService } from '../skills/skill-cloud-service'
import { isArtifactSharingEnabled } from '../../shared/artifact-sharing-gate'
import {
AgentStatusObservedPaneIdentities,
recordObservedAgentStatusPaneIdentity
} from '../runtime/agent-status-observed-pane-identity'
import { startAgentStateRulesLiveUpdates } from '../runtime/agent-state-rules/agent-state-rules-live-update'
import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb'
export function getDesktopWindowStatus(): RuntimeDesktopWindowStatus {
const activation = state.desktopActivationGate
if (!activation) {
return 'available'
}
const value = activation.getState()
return value === 'ready' ? 'openable' : value
}
export function initializeMainProcessRuntime(): OrcaRuntimeService {
const store = state.store
const stats = state.stats
if (!store || !stats) {
throw new Error('Store and stats must be initialized before runtime')
}
const orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport = {
resolve: (selector) => {
const environment = resolveEnvironment(app.getPath('userData'), selector)
const pairing = getPreferredPairingOffer(environment)
return {
environmentId: environment.id,
name: environment.name,
peerFingerprint: fingerprintOrchestrationPeer(pairing.publicKeyB64),
pairingRevision: environment.pairingRevision ?? environment.createdAt
}
},
call: (selector, method, params, timeoutMs, envelope, expectedPairingRevision) =>
callRuntimeEnvironment(
app.getPath('userData'),
selector,
method,
params,
timeoutMs,
expectedPairingRevision,
envelope
)
}
// Why here and not in the window listener: `subscribeEnrichedStatus` also fires under headless
// `orca serve`, which never opens one, and the fleet path runs there too.
const observedPaneIdentities = new AgentStatusObservedPaneIdentities()
const runtime = new OrcaRuntimeService(store, stats, {
prepareClaudeAuth: (target) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target),
agentSessionClaimSigner: loadAgentSessionClaimSigner(
getProfileUserDataPath(),
getProfileUserDataPath()
),
// Why: resolve the PTY provider lazily — a daemon swap happens later, so an eager reference would freeze the pre-daemon provider (design §4.3).
getLocalProvider: () => getLocalPtyProvider(),
// Why: SSH relay providers register after construction and may reconnect, so destructive cleanup must resolve the current generation.
getSshProvider: (connectionId) => getSshPtyProvider(connectionId),
onPtyStopped: clearProviderPtyState,
onTerminalAgentStatus: (event) => agentHookServer.ingestTerminalStatus(event),
// Why: serve can be promoted in place, so wire the listener from startup; runtime enables desktop-only scanners only for a ready renderer.
onTerminalSideEffects: (batch: TerminalSideEffectBatch) => {
if (state.mainWindow && !state.mainWindow.isDestroyed()) {
state.mainWindow.webContents.send('pty:sideEffect', batch)
}
},
getDesktopWindowStatus,
// Why: worktree.ps pulls hook-reported agent status (same source as the desktop sidebar) at query time so mobile shows the same agents.
getAgentStatusSnapshot: () =>
agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true),
// Why: structured chats have no hooks, so the host writes their projections here itself; the
// snapshot above then lists them for the CLI and mobile without a second store.
structuredAgentStatusSink: {
publish: (summary, subject) => agentHookServer.ingestStructuredStatus(summary, subject),
forget: (subject) => agentHookServer.dropStructuredStatus(subject),
publishChildWork: (subject, evidence, provider) =>
agentHookServer.ingestStructuredChildWork(subject, evidence, provider),
readChildWork: (subject) => agentHookServer.getStructuredChildWorkViews(subject)
},
// Why captured rather than resolved at read: the fleet snapshot remints cached rows on every
// read, so a row observed under one process otherwise acquires whatever the pane owns now.
readObservedAgentStatusPaneIdentity: (paneKey) => observedPaneIdentities.read(paneKey),
// Why: the filter above hides resume-identity rows from the live-agent views, but
// those rows carry the provider session mobile native chat addresses transcripts
// by — Pi publishes identity that way and would otherwise be unreachable.
getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(),
getAgentProviderSessionRowsForPane: (paneKey) =>
agentHookServer.getStatusSnapshotForPane(paneKey),
attestAgentHookCompatibilityAuthority: (candidate) =>
agentHookServer.attestCompatibilityAuthority(candidate),
retireAgentHookCompatibilityAuthority: (paneKey) =>
agentHookServer.retirePaneAuthority(paneKey),
checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey),
reconcileAgentStatusForEndedProcess: (paneKeys) =>
agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys),
canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null,
// Why: evaluated per call, not captured — the RPC server that owns the device registry is
// constructed with this runtime and does not exist yet at this point.
getPairedDeviceName: (pairedDeviceId) =>
state.runtimeRpc?.getDeviceRegistry()?.getDevice(pairedDeviceId)?.name ?? null,
// Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only.
getAdditionalAiVaultCodexHomePaths: () =>
state.codexRuntimeHome?.getHostCodexHomePathsForSessionDiscovery() ?? [],
prepareAiVaultSessionResume: (args) =>
prepareCodexAiVaultSessionResume(args, {
runtimeHome: state.codexRuntimeHome,
systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings())
}),
prepareCodexStructuredLaunch: ({ launchEnv }) =>
prepareCodexRuntimeHomeForLaunch(undefined, launchEnv),
// Why throw like prepare does: a null from an uninitialized service would
// map to the system home and key a catalog read to the wrong account.
resolveCodexStructuredLaunchHome: ({ launchEnv }) => {
const runtimeHome = state.codexRuntimeHome
if (!runtimeHome) {
throw new Error('Codex runtime home service is not initialized')
}
return runtimeHome.resolveHostCodexHomePathForLaunchReadOnly(launchEnv)
},
buildAgentHookPtyEnv: () =>
isAgentStatusHooksEnabled(state.store?.getSettings()) ? agentHookServer.buildPtyEnv() : {},
orchestrationEnvironmentTransport,
// Why the same function the settings IPC handler calls: a paired client's write and a
// local one must reconcile the scanner child through one path, or they can disagree.
applySessionSearchSettings: applySessionSearchSettingsChange,
skillTransactionRecovery: state.skillTransactionRecovery
})
// Both desktop and headless serve own a host-local search service.
const sessionSearch = installChildSessionSearchService({
dataRoot: getCanonicalUserDataPath(),
getSettings: () => store.getSettings(),
// Why read per request rather than snapshot: a repo added or a workspace
// renamed between two searches has to be in scope for the second one.
// This process answers for its own host, so the catalog is bound to it here.
getScopeCatalog: () => sessionSearchScopeCatalogFromStore(store, LOCAL_EXECUTION_HOST_ID)
})
app.once('will-quit', () => sessionSearch?.dispose())
// Why here: this runs for the desktop and headless `orca serve`, and each evaluates its own panes.
startAgentStateRulesLiveUpdates(store, (rules) =>
recordDurableCrashBreadcrumb('agent_state_rules_active', rules)
)
state.runtime = runtime
agentHookServer.subscribeEnrichedStatus((enriched) =>
recordObservedAgentStatusPaneIdentity(observedPaneIdentities, enriched.paneKey, runtime)
)
// Why before anything can attach: a client host that reattaches to a restarted runtime is only
// handed its pages back if the runtime found them first.
runtime.rehydrateClientHostedBrowserPages()
browserManager.setBrowserGuestStateChangedListener((worktreeId) => {
runtime.notifyMobileSessionTabsChanged(worktreeId)
})
return runtime
}
export function configureRuntimeServices(runtime: OrcaRuntimeService): void {
const store = state.store
const claudeAccounts = state.claudeAccounts
const codexAccounts = state.codexAccounts
const rateLimits = state.rateLimits
if (!store || !claudeAccounts || !codexAccounts || !rateLimits) {
throw new Error('Account services must be initialized before runtime wiring')
}
runtime.setArtifactService(
new ArtifactCloudService(app.getPath('userData'), () =>
isArtifactSharingEnabled(state.store?.getSettings())
)
)
runtime.setSkillCloudService(new SkillCloudService(app.getPath('userData')))
runtime.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })
runtime.setCommitMessageAgentEnvironmentResolvers({
// Why: Codex hooks/auth live in Orca's managed runtime home even for the default path, so every launch must resolve CODEX_HOME via runtime-home.
prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch,
prepareForClaudeLaunch: (target) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target)
})
}