Files
orca/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts
T
OrcaWinandm4air 4085e1cf60 fix(memory): release stale session registries (#21734)
* fix(memory): bound session and lifecycle registries

* fix(memory): bound transient filesystem registries

* fix(memory): cap path and locale caches

* fix(memory): bound runtime recovery registries

* fix(memory): bound host mirror gap verdicts

* fix(memory): bound shell startup env cache

* fix(memory): bound gitlab host context cache

* fix(memory): release removed ssh generations

* fix(memory): expire cloud refresh replay guards

* fix(memory): release retired plugin generations

* fix(memory): bound plugin log key retention

* fix(memory): bound automation authority generations

* fix(memory): bound native chat enrichment cache

* fix(memory): bound web session tracking generations

* fix(memory): bound codex credential absence paths

* fix(memory): bound WSL canonical path cache

* fix(memory): bound sparse checkout cache

* fix(memory): bound shared directory cache

* fix(memory): bound advertised URL scan snapshots

* fix(memory): bound automation manager cache

* fix(memory): bound web session reorder intents

* fix(memory): bound web session focus intents

* fix(memory): bound web session handoffs

* fix(memory): bound automation dispatch tokens

* fix(memory): bound host mirror waiters

* fix(memory): bound retained session activity

* fix(memory): bound retained session activity

* fix(memory): bound web session close intents

* fix(memory): bound cloud session cache

* fix(memory): bound WSL home cache

* fix(memory): bound SSH capability cache

* fix(memory): bound trust grant cooldowns

* fix(memory): bound WSL auth drain state

* fix(memory): bound Linear workspace credential cache

* fix(memory): bound local Git capability cache

* fix(memory): bound WSL Git environment cache

* fix(memory): bound WSL Git environment cache

* fix(memory): bound WSL preflight cache

* fix(memory): keep hot cache entries warm

* fix(memory): preserve generation fences across eviction

* fix(memory): close remaining eviction fences

* fix(memory): align evicted upstream generations

* fix(memory): trim successful capability probes

* fix(auth): retain expired refresh replay evidence

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-20 14:41:50 -07:00

217 lines
7.9 KiB
TypeScript

// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithGetStatus } from './orca-runtime-get-status'
import type { SshConnectionState } from '../../shared/ssh-types'
import { getPublicSshState } from './public-ssh-state'
import { splitWorktreeId } from '../../shared/worktree/id'
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
import { navigationTargetsClients, navigationTargetsHost } from '../../shared/runtime-navigation'
import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import type { BrowserBackend } from '../browser/browser-backend'
import type { EmulatorBridge } from '../emulator/emulator-bridge'
export class OrcaRuntimeWithNotifySshStateChanged extends OrcaRuntimeWithGetStatus {
private static readonly MAX_SSH_RELAY_RECOVERY_GENERATIONS = 512
private sshRelayRecoveryGenerationSequence = 0
// Why: SSH state changes originate in main's ssh handlers, not in runtime
// methods, so they need a public entry point onto the client-event stream.
notifySshStateChanged(targetId: string, state: SshConnectionState): void {
this.bumpSshRelayRecoveryGeneration(targetId)
this.invalidateSshWorktreeScanCache(targetId)
if (state.status !== 'connected') {
this.legacyWorkerRecovery.cancelScope(`ssh:${targetId}`)
}
this.emitClientEvent({ type: 'sshStateChanged', targetId, state: getPublicSshState(state)! })
}
notifySshRelayReady(targetId: string): void {
const generation = this.bumpSshRelayRecoveryGeneration(targetId)
const publish = async (): Promise<void> => {
try {
await this.publishRecoveredSshMobileSessionTabs(targetId, generation)
} catch (error) {
if (this.sshRelayRecoveryGenerationByTargetId.get(targetId) === generation) {
console.warn('[runtime] failed to publish recovered SSH session tabs', {
targetId,
error
})
}
}
}
const initialPublication = publish()
void initialPublication
void this.refreshRestoredOrchestrationAuthority(targetId)
.then(() =>
this.reconcileLegacyWorkerTerminals({
connectionId: targetId,
materializeRenderer: this.notifier !== null
})
)
.then(async () => {
await initialPublication
await publish()
})
.catch((error) => {
if (this.sshRelayRecoveryGenerationByTargetId.get(targetId) !== generation) {
return
}
console.warn('[orchestration] legacy worker reconcile failed on relay ready', {
targetId,
error
})
})
}
protected bumpSshRelayRecoveryGeneration(targetId: string): number {
const generation = ++this.sshRelayRecoveryGenerationSequence
this.sshRelayRecoveryGenerationByTargetId.set(targetId, generation)
while (
this.sshRelayRecoveryGenerationByTargetId.size >
OrcaRuntimeWithNotifySshStateChanged.MAX_SSH_RELAY_RECOVERY_GENERATIONS
) {
const oldest = this.sshRelayRecoveryGenerationByTargetId.keys().next()
if (oldest.done) {
break
}
this.sshRelayRecoveryGenerationByTargetId.delete(oldest.value)
}
return generation
}
protected async publishRecoveredSshMobileSessionTabs(
targetId: string,
generation: number
): Promise<void> {
const repoIds = new Set(
(this.store?.getRepos() ?? [])
.filter((repo) => repo.connectionId === targetId)
.map((repo) => repo.id)
)
if (repoIds.size === 0) {
return
}
const worktreeIds = new Set<string>()
for (const worktreeId of [
...this.getKnownWorkspaceSessionWorktreeIds(),
...this.mobileSessionTabsByWorktree.keys()
]) {
const parsed = splitWorktreeId(worktreeId)
if (parsed && repoIds.has(parsed.repoId)) {
worktreeIds.add(worktreeId)
}
}
if (worktreeIds.size === 0) {
return
}
// Why: relay readiness follows PTY reattach; rebuild the HUB-owned panes before paired clients consume the connected event.
for (const worktreeId of worktreeIds) {
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, {
allowAttachedWindow: true,
onlyRuntimeOwnedTerminals: true
})
}
await this.refreshMobileSessionPtyRecords()
if (this.sshRelayRecoveryGenerationByTargetId.get(targetId) !== generation) {
return
}
for (const worktreeId of worktreeIds) {
this.notifyMobileSessionTabsChangedNow(worktreeId, ++this.mobileSessionTabsChangeSequence)
}
}
invalidateSshWorktreeScanCache(targetId: string): void {
this.invalidateSshWorktreeScanCacheInternal(targetId)
}
// Why: renderer-initiated meta updates intentionally skip the renderer
// notifier (the renderer already applied them optimistically), but remote
// clients hold no optimistic copy and need the invalidation event.
notifyWorktreesChangedForRemoteClients(repoId: string): void {
this.invalidateResolvedWorktreeCache()
this.emitClientEvent({ type: 'worktreesChanged', repoId })
}
// Why: structural catalog changes require a fresh Git scan; renderer metadata edits do not.
notifyWorktreeCatalogChangedForRemoteClients(repoId: string): void {
this.invalidateWorktreeScanCacheForRepo(repoId)
const matchingRepos = this.store?.getRepos().filter((repo) => repo.id === repoId) ?? []
if (matchingRepos.length !== 1 || matchingRepos[0]?.connectionId) {
return
}
this.notifyWorktreesChangedForRemoteClients(repoId)
}
// Why: host-local repo IPC mutations never enter runtime methods, so paired
// clients need an explicit catalog invalidation; the local renderer already
// got its own repos:changed and must not be re-notified (#11994).
notifyReposChangedForRemoteClients(): void {
this.emitClientEvent({ type: 'reposChanged' })
}
protected notifyActivateWorktree(
repoId: string,
worktreeId: string,
setup?: CreateWorktreeResult['setup'],
startup?: WorktreeStartupLaunch,
defaultTabs?: CreateWorktreeResult['defaultTabs'],
navigationTarget?: RuntimeNavigationTarget
): void {
const navigation = navigationTarget ?? 'all'
if (navigationTargetsHost(navigation)) {
this.notifyHostActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs)
}
if (navigationTargetsClients(navigation)) {
this.notifyClientsActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs)
}
}
protected notifyHostActivateWorktree(
repoId: string,
worktreeId: string,
setup?: CreateWorktreeResult['setup'],
startup?: WorktreeStartupLaunch,
defaultTabs?: CreateWorktreeResult['defaultTabs']
): void {
this.notifier?.activateWorktree(repoId, worktreeId, setup, startup, defaultTabs)
}
protected notifyClientsActivateWorktree(
repoId: string,
worktreeId: string,
setup?: CreateWorktreeResult['setup'],
startup?: WorktreeStartupLaunch,
defaultTabs?: CreateWorktreeResult['defaultTabs']
): void {
this.emitClientEvent(
toRuntimeActivateWorktreeEvent(repoId, worktreeId, setup, startup, defaultTabs)
)
}
setAgentBrowserBridge(bridge: AgentBrowserBridge | null): void {
this.agentBrowserBridge = bridge
}
getAgentBrowserBridge(): AgentBrowserBridge | null {
return this.agentBrowserBridge
}
setOffscreenBrowserBackend(backend: BrowserBackend | null): void {
this.offscreenBrowserBackend = backend
}
getOffscreenBrowserBackend(): BrowserBackend | null {
return this.offscreenBrowserBackend
}
setEmulatorBridge(bridge: EmulatorBridge | null): void {
this.emulatorBridge = bridge
}
getEmulatorBridge(): EmulatorBridge | null {
return this.emulatorBridge
}
}