Fix Resource Manager going empty when a runtime server is selected or stale (#7275)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-07-03 17:52:59 -07:00 committed by GitHub
parent d1bd91417e
commit 748bced2e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 640 additions and 262 deletions

View File

@ -178,6 +178,7 @@ import { applyElectronProxySettings } from './network/proxy-settings'
import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation'
import { CliInstaller } from './cli/cli-installer'
import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher'
import { selfHealRuntimeEnvironmentFocus } from './runtime-environment-focus-self-heal'
let mainWindow: BrowserWindow | null = null
/** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the
@ -1565,6 +1566,7 @@ app.whenReady().then(async () => {
store = new Store()
logStartupMilestone('store-loaded')
selfHealRuntimeEnvironmentFocus({ store, userDataPath: app.getPath('userData') })
applyAppIcon(store.getSettings().appIcon)
if (shouldSuppressDevEducation({ isDev: is.dev })) {
suppressDevEducationForStore(store)

View File

@ -14,6 +14,7 @@ import {
removeEnvironment,
updateEnvironmentFromPairingCode
} from '../../shared/runtime-environment-store'
import { clearActiveRuntimeEnvironmentFocusIfMatches } from '../runtime-environment-focus-self-heal'
import {
cleanupEphemeralVmRuntime,
resumeEphemeralVmRuntime,
@ -102,6 +103,7 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
if (result.ok && runtime.runtimeEnvironmentId) {
try {
removeEnvironment(userDataPath, runtime.runtimeEnvironmentId)
clearActiveRuntimeEnvironmentFocusIfMatches(store, runtime.runtimeEnvironmentId)
} catch {
// Cleanup of provider resources matters more than hiding a stale local
// environment row; users can still remove that manually.

View File

@ -71,9 +71,14 @@ function makeStore(repoPath: string) {
badgeColor: '#000',
addedAt: 0
}
let activeRuntimeEnvironmentId: string | null = null
return {
getRepo: vi.fn((repoId: string) => (repoId === 'repo-1' ? repo : null)),
getRepos: vi.fn(() => [repo])
getRepos: vi.fn(() => [repo]),
getSettings: vi.fn(() => ({ activeRuntimeEnvironmentId })),
updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => {
activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId
})
}
}
@ -129,7 +134,8 @@ describe('registerEphemeralVmHandlers', () => {
].join('\n')
)
registerEphemeralVmHandlers(makeStore(repoPath) as never)
const store = makeStore(repoPath)
registerEphemeralVmHandlers(store as never)
const result = await handlers.get('ephemeralVm:listRecipes')?.(null, {
repoId: 'repo-1'
} as never)
@ -163,7 +169,8 @@ describe('registerEphemeralVmHandlers', () => {
].join('\n')
)
registerEphemeralVmHandlers(makeStore(repoPath) as never)
const store = makeStore(repoPath)
registerEphemeralVmHandlers(store as never)
const result = await handlers.get('ephemeralVm:listRecipeCatalog')?.(null, undefined as never)
expect(result).toEqual([
@ -212,7 +219,8 @@ describe('registerEphemeralVmHandlers', () => {
].join('\n')
)
registerEphemeralVmHandlers(makeStore(repoPath) as never)
const store = makeStore(repoPath)
registerEphemeralVmHandlers(store as never)
const result = (await handlers.get('ephemeralVm:provision')?.(null, {
repoId: 'repo-1',
recipeId: 'cloud-sandbox',
@ -253,6 +261,17 @@ describe('registerEphemeralVmHandlers', () => {
workspaceId: 'repo-1::/workspace/repo/worktree'
})
)
store.updateSettings({ activeRuntimeEnvironmentId: result.environment!.id })
const cleaned = await handlers.get('ephemeralVm:cleanup')?.(null, {
runtimeId: result.runtime?.id
} as never)
expect(cleaned).toEqual(expect.objectContaining({ status: 'cleaned' }))
expect(listEnvironments(userDataPath)).toEqual([])
expect(store.updateSettings).toHaveBeenLastCalledWith(
{ activeRuntimeEnvironmentId: null },
{ notifyListeners: true }
)
})
it('provisions an ssh recipe without creating a runtime environment', async () => {

View File

@ -443,7 +443,7 @@ describe('registerCoreHandlers', () => {
expect(registerEmulatorVideoStreamHandlersMock).toHaveBeenCalled()
expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store)
expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime)
expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalled()
expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalledWith(store)
expect(registerEphemeralVmHandlersMock).toHaveBeenCalledWith(store)
expect(registerAiVaultHandlersMock).toHaveBeenCalledWith({
getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths

View File

@ -166,7 +166,7 @@ export function registerCoreHandlers(
}
registerFilesystemWatcherHandlers()
registerRuntimeHandlers(runtime)
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store)
registerEphemeralVmHandlers(store)
registerAiVaultHandlers({
getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths

View File

@ -80,9 +80,21 @@ function handler<TArgs, TResult>(
describe('registerRuntimeEnvironmentHandlers', () => {
let userDataPath: string
let activeRuntimeEnvironmentId: string | null
let store: {
getSettings: () => { activeRuntimeEnvironmentId: string | null }
updateSettings: ReturnType<typeof vi.fn>
}
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-ipc-'))
activeRuntimeEnvironmentId = null
store = {
getSettings: () => ({ activeRuntimeEnvironmentId }),
updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => {
activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId
})
}
getPathMock.mockReset()
getPathMock.mockReturnValue(userDataPath)
handleMock.mockReset()
@ -104,7 +116,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('registers desktop runtime environment management handlers', () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
expect(handleMock.mock.calls.map((call) => call[0])).toEqual([
'runtimeEnvironments:list',
@ -123,7 +135,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('clears stale IPC registrations before registering runtime environment handlers', () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
expect(removeHandlerMock.mock.calls.map((call) => call[0])).toEqual([
'runtimeEnvironments:list',
@ -140,7 +152,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('stores, resolves, lists, and removes environments under Electron userData', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
const add = handler<
{ name: string; pairingCode: string },
@ -149,6 +161,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
const added = await add(null, { name: 'desk', pairingCode: pairingCode() })
expect(JSON.stringify(added)).not.toContain('device-token')
expect(JSON.stringify(added)).not.toContain('publicKeyB64')
activeRuntimeEnvironmentId = added.environment.id
const list = handler<undefined, { id: string; name: string }[]>('runtimeEnvironments:list')
expect(await list(null, undefined)).toMatchObject([{ id: added.environment.id, name: 'desk' }])
@ -170,13 +183,18 @@ describe('registerRuntimeEnvironmentHandlers', () => {
expect(removed).toMatchObject({
removed: { id: added.environment.id, name: 'desk' }
})
expect(store.updateSettings).toHaveBeenCalledWith(
{ activeRuntimeEnvironmentId: null },
{ notifyListeners: true }
)
expect(activeRuntimeEnvironmentId).toBeNull()
expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(added.environment.id)
expect(JSON.stringify(removed)).not.toContain('device-token')
expect(await list(null, undefined)).toEqual([])
})
it('disconnects a saved runtime without removing it', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
const add = handler<
{ name: string; pairingCode: string },
@ -200,7 +218,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('marks environments owned by ephemeral VM runtimes in the public list', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
// The ephemeral-VM provision flow persists `source: 'ephemeral-vm'` directly
// on the environment record (ephemeral-vm.ts), so the public list reads it
@ -221,7 +239,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('checks a saved remote runtime and records the runtime id on success', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'rpc-1',
ok: true,
@ -260,7 +278,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('attaches shared-control diagnostics to saved remote runtime status', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue({
state: 'reconnecting',
pendingRequestCount: 1,
@ -296,7 +314,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('attaches shared-control diagnostics to failed saved remote runtime status', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue({
state: 'reconnecting',
pendingRequestCount: 0,
@ -334,7 +352,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('returns shared-control diagnostics when saved remote runtime status throws', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue({
state: 'reconnecting',
pendingRequestCount: 0,
@ -367,7 +385,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('proxies generic one-shot RPC calls to the saved remote runtime', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'rpc-2',
ok: true,
@ -401,7 +419,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('falls back to one-shot RPC when the saved runtime lacks shared-control support', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockImplementation(async (_pairing, method) => {
if (method === 'status.get') {
return {
@ -442,7 +460,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('uses the cached request connection for terminal hot path RPCs', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeConnectionRequestMock.mockResolvedValue({
id: 'rpc-terminal',
ok: true,
@ -482,7 +500,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('keeps terminal hot path RPCs on the cached request connection when shared control is supported', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'status',
ok: true,
@ -545,7 +563,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('routes one-shot RPC calls through shared control when the runtime advertises support', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'status',
ok: true,
@ -599,7 +617,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('rechecks shared-control support when the saved runtime identity changes', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
let statusCalls = 0
sendRemoteRuntimeRequestMock.mockImplementation(async (_pairing, method) => {
if (method === 'status.get') {
@ -654,7 +672,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('does not fall back after a shared-control request fails on a supported runtime', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'status',
ok: true,
@ -691,7 +709,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('keeps browser and terminal heavy streams on dedicated subscription sockets', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
const close = vi.fn()
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'status',
@ -761,7 +779,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('routes passive subscriptions through shared control when supported', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'status',
ok: true,
@ -814,7 +832,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('keeps shared-control subscriptions retained across transient errors until final close', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
const close = vi.fn()
const senderSend = vi.fn()
const destroyedListenerRemoved = vi.fn()
@ -893,7 +911,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('falls back to legacy passive subscriptions when shared control is unsupported', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'status',
ok: true,
@ -942,7 +960,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('dedupes concurrent shared-control capability probes per environment', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
let resolveStatus: (value: unknown) => void = () => {}
sendRemoteRuntimeRequestMock.mockImplementation((_pairing, method) => {
if (method === 'status.get') {
@ -989,7 +1007,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('clears rejected shared-control capability probes so a later call can retry', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock
.mockRejectedValueOnce(new Error('probe failed'))
.mockResolvedValueOnce({
@ -1031,7 +1049,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('clears shared-control capability cache when a runtime is disconnected', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'status',
ok: true,
@ -1075,7 +1093,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('clears shared-control capability cache when a runtime is removed and re-added', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'status',
ok: true,
@ -1119,7 +1137,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('limits background one-shot RPCs without blocking foreground runtime calls', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
const pendingBackground: ((value: unknown) => void)[] = []
sendRemoteRuntimeRequestMock.mockImplementation(async (_pairing, method) => {
if (method === 'status.get') {
@ -1210,7 +1228,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('starts and stops streaming subscriptions for a saved remote runtime', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
const close = vi.fn()
const sendBinary = vi.fn()
const markUsedSpy = vi.spyOn(environmentStore, 'markEnvironmentUsed')
@ -1307,7 +1325,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('closes streaming subscriptions when their saved runtime is removed', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
const close = vi.fn()
const sendBinary = vi.fn()
subscribeRemoteRuntimeRequestMock.mockResolvedValue({
@ -1371,7 +1389,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('rejects cross-window streaming subscription control', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
const close = vi.fn()
const sendBinary = vi.fn()
subscribeRemoteRuntimeRequestMock.mockResolvedValue({
@ -1441,7 +1459,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('closes a streaming subscription that resolves after the sender is destroyed', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
const close = vi.fn()
let resolveSubscribe: (value: {
requestId: string
@ -1519,7 +1537,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
it('removes the destroyed listener when streaming subscription setup rejects', async () => {
registerRuntimeEnvironmentHandlers()
registerRuntimeEnvironmentHandlers(store as never)
subscribeRemoteRuntimeRequestMock.mockRejectedValue(new Error('connect failed'))
const add = handler<

View File

@ -13,6 +13,8 @@ import {
import type { RuntimeStatus } from '../../shared/runtime-types'
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client'
import type { Store } from '../persistence'
import { clearActiveRuntimeEnvironmentFocusIfMatches } from '../runtime-environment-focus-self-heal'
import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections'
import {
callRuntimeEnvironment,
@ -63,7 +65,7 @@ function listPublicRuntimeEnvironments(): PublicKnownRuntimeEnvironment[] {
return listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment)
}
export function registerRuntimeEnvironmentHandlers(): void {
export function registerRuntimeEnvironmentHandlers(store: Store): void {
// Why: keep direct re-registration safe even though register-core-handlers
// normally guards this path; otherwise the binary send listener can stack.
resetSharedControlSupport()
@ -99,6 +101,7 @@ export function registerRuntimeEnvironmentHandlers(): void {
closeRemoteRuntimeRequestConnection(args.selector)
clearSharedControlSupport(args.selector)
}
clearActiveRuntimeEnvironmentFocusIfMatches(store, removed.id)
closeSubscriptionsForEnvironment(removed.id)
return { removed: redactRuntimeEnvironment(removed) }
}

View File

@ -0,0 +1,156 @@
import { describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../shared/types'
import type { KnownRuntimeEnvironment } from '../shared/runtime-environments'
import {
clearActiveRuntimeEnvironmentFocusIfMatches,
selfHealRuntimeEnvironmentFocus
} from './runtime-environment-focus-self-heal'
function environment(
id: string,
source?: KnownRuntimeEnvironment['source']
): KnownRuntimeEnvironment {
return {
id,
name: id,
createdAt: 0,
updatedAt: 0,
lastUsedAt: null,
runtimeId: null,
...(source ? { source } : {}),
endpoints: [
{
id: `ws-${id}`,
kind: 'websocket',
label: 'WebSocket',
endpoint: 'ws://127.0.0.1:6768',
deviceToken: 'token',
publicKeyB64: 'key'
}
],
preferredEndpointId: `ws-${id}`
}
}
function makeStore(activeRuntimeEnvironmentId: string | null | undefined) {
const settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> = {}
if (activeRuntimeEnvironmentId !== undefined) {
settings.activeRuntimeEnvironmentId = activeRuntimeEnvironmentId
}
const updateSettings = vi.fn((updates: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'>) => {
settings.activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId
return settings
})
return {
store: {
getSettings: () => settings,
updateSettings
},
updateSettings
}
}
describe('runtime environment focus self-heal', () => {
it('keeps a focus id that resolves to a user-managed environment', () => {
const { store, updateSettings } = makeStore('env-1')
selfHealRuntimeEnvironmentFocus({
store,
userDataPath: '/user-data',
listKnownEnvironments: () => [environment('env-1')]
})
expect(updateSettings).not.toHaveBeenCalled()
})
it('clears a dangling focus id and logs one diagnostic line', () => {
const { store, updateSettings } = makeStore('missing-env')
const log = vi.fn()
selfHealRuntimeEnvironmentFocus({
store,
userDataPath: '/user-data',
listKnownEnvironments: () => [environment('env-1')],
log
})
expect(updateSettings).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: null })
expect(log).toHaveBeenCalledTimes(1)
expect(log.mock.calls[0][0]).toContain('missing-env')
})
it('clears an ephemeral-VM focus id after restart', () => {
const { store, updateSettings } = makeStore('vm-env')
const log = vi.fn()
selfHealRuntimeEnvironmentFocus({
store,
userDataPath: '/user-data',
listKnownEnvironments: () => [environment('vm-env', 'ephemeral-vm')],
log
})
expect(updateSettings).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: null })
expect(log).toHaveBeenCalledTimes(1)
})
it('leaves null and absent focus settings untouched', () => {
const nullCase = makeStore(null)
const absentCase = makeStore(undefined)
const listKnownEnvironments = vi.fn(() => [environment('env-1')])
selfHealRuntimeEnvironmentFocus({
store: nullCase.store,
userDataPath: '/user-data',
listKnownEnvironments
})
selfHealRuntimeEnvironmentFocus({
store: absentCase.store,
userDataPath: '/user-data',
listKnownEnvironments
})
expect(nullCase.updateSettings).not.toHaveBeenCalled()
expect(absentCase.updateSettings).not.toHaveBeenCalled()
expect(listKnownEnvironments).not.toHaveBeenCalled()
})
it('normalizes an empty persisted id to null without reading the registry', () => {
const { store, updateSettings } = makeStore('')
const listKnownEnvironments = vi.fn(() => [environment('env-1')])
selfHealRuntimeEnvironmentFocus({
store,
userDataPath: '/user-data',
listKnownEnvironments
})
expect(updateSettings).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: null })
expect(listKnownEnvironments).not.toHaveBeenCalled()
})
it('fails soft when the registry cannot be read', () => {
const { store, updateSettings } = makeStore('env-1')
selfHealRuntimeEnvironmentFocus({
store,
userDataPath: '/user-data',
listKnownEnvironments: () => {
throw new Error('invalid registry')
}
})
expect(updateSettings).not.toHaveBeenCalled()
})
it('clears the active focus on matching in-process removal with listener notification', () => {
const { store, updateSettings } = makeStore('env-1')
clearActiveRuntimeEnvironmentFocusIfMatches(store, 'env-1')
expect(updateSettings).toHaveBeenCalledWith(
{ activeRuntimeEnvironmentId: null },
{ notifyListeners: true }
)
})
})

View File

@ -0,0 +1,71 @@
import type { GlobalSettings } from '../shared/types'
import { listEnvironments } from '../shared/runtime-environment-store'
import {
isUserManagedRuntimeEnvironment,
type KnownRuntimeEnvironment
} from '../shared/runtime-environments'
type RuntimeEnvironmentFocusStore = {
getSettings: () => Pick<GlobalSettings, 'activeRuntimeEnvironmentId'>
updateSettings: (
updates: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'>,
options?: { notifyListeners?: boolean }
) => unknown
}
type SelfHealRuntimeEnvironmentFocusArgs = {
store: RuntimeEnvironmentFocusStore
userDataPath: string
listKnownEnvironments?: (userDataPath: string) => KnownRuntimeEnvironment[]
log?: (message: string) => void
}
function logClearedFocus(log: ((message: string) => void) | undefined, reason: string): void {
const writeLog = log ?? console.info
writeLog(`[runtime-environment-focus] cleared active runtime environment: ${reason}`)
}
export function clearActiveRuntimeEnvironmentFocusIfMatches(
store: RuntimeEnvironmentFocusStore,
environmentId: string
): void {
if (store.getSettings().activeRuntimeEnvironmentId !== environmentId) {
return
}
store.updateSettings({ activeRuntimeEnvironmentId: null }, { notifyListeners: true })
}
export function selfHealRuntimeEnvironmentFocus({
store,
userDataPath,
listKnownEnvironments = listEnvironments,
log
}: SelfHealRuntimeEnvironmentFocusArgs): void {
const activeRuntimeEnvironmentId = store.getSettings().activeRuntimeEnvironmentId
if (activeRuntimeEnvironmentId === undefined || activeRuntimeEnvironmentId === null) {
return
}
if (activeRuntimeEnvironmentId.trim() === '') {
store.updateSettings({ activeRuntimeEnvironmentId: null })
logClearedFocus(log, 'empty persisted id')
return
}
let environments: KnownRuntimeEnvironment[]
try {
environments = listKnownEnvironments(userDataPath)
} catch {
// Why: an unreadable registry must not clear a possibly-valid focus; keep
// it and let a later launch heal once the registry reads again.
return
}
const focusedEnvironment = environments.find((entry) => entry.id === activeRuntimeEnvironmentId)
if (focusedEnvironment && isUserManagedRuntimeEnvironment(focusedEnvironment)) {
return
}
store.updateSettings({ activeRuntimeEnvironmentId: null })
logClearedFocus(log, `dangling id ${activeRuntimeEnvironmentId}`)
}

View File

@ -0,0 +1,140 @@
// @vitest-environment happy-dom
import React, { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants'
import type { UnifiedSessionRow, UnifiedWorktreeRow } from './resource-usage-merge-types'
vi.mock('@/store', () => {
const storeState = {}
const useAppStore = Object.assign(
(selector: (state: typeof storeState) => unknown) => selector(storeState),
{ getState: () => storeState }
)
return { useAppStore }
})
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
values
? Object.entries(values).reduce(
(text, [token, value]) => text.replace(`{{${token}}}`, value),
fallback
)
: fallback
}))
vi.mock('sonner', () => ({
toast: { error: vi.fn(), success: vi.fn() }
}))
import { WorktreeRow } from './ResourceUsageStatusSegment'
function makeSession(overrides: Partial<UnifiedSessionRow>): UnifiedSessionRow {
return {
sessionId: 'sess-1',
paneKey: null,
pid: 100,
label: 'zsh',
bound: true,
tabId: 'tab-1',
cpu: 1,
memory: 100,
hasLocalSamples: true,
...overrides
}
}
function makeWorktree(overrides: Partial<UnifiedWorktreeRow>): UnifiedWorktreeRow {
return {
worktreeId: 'wt-1',
worktreeName: 'feature-branch',
repoId: 'repo-1',
repoName: 'repo',
cpu: 1,
memory: 100,
history: [],
hasLocalSamples: true,
isRemote: false,
sessions: [],
...overrides
}
}
describe('resource manager row presentation', () => {
let container: HTMLDivElement
let root: Root
afterEach(() => {
act(() => {
root.unmount()
})
container.remove()
})
function renderWorktreeRow(worktree: UnifiedWorktreeRow): void {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root.render(
<WorktreeRow
worktree={worktree}
storeRecord={null}
activeWorktreeId={null}
isCollapsed={false}
onToggle={() => {}}
onNavigate={() => {}}
onDelete={() => {}}
onKillSession={() => {}}
navigateToTab={() => {}}
/>
)
})
}
it('keeps the remote chip and kill affordances on SSH-backed rows', () => {
renderWorktreeRow(
makeWorktree({
isRemote: true,
cpu: null,
memory: null,
sessions: [
makeSession({ sessionId: 'ssh-a', bound: true }),
makeSession({ sessionId: 'ssh-b', bound: false, tabId: null, cpu: null, memory: null })
]
})
)
expect(container.textContent).toContain('· remote')
const killButtons = container.querySelectorAll('button[aria-label^="Kill session"]')
expect(killButtons).toHaveLength(2)
expect(
container.querySelector('button[aria-label="Kill session ssh-a"]')
).not.toBeNull()
expect(
container.querySelector('button[aria-label="Kill session ssh-b"]')
).not.toBeNull()
})
it('keeps kill affordances on the orphan bucket rows', () => {
renderWorktreeRow(
makeWorktree({
worktreeId: ORPHAN_WORKTREE_ID,
worktreeName: 'Orphaned terminals',
sessions: [makeSession({ sessionId: 'orphan-a', bound: false, tabId: null })]
})
)
expect(
container.querySelector('button[aria-label="Kill session orphan-a"]')
).not.toBeNull()
})
})

View File

@ -12,7 +12,7 @@ describe('ResourceUsageStatusSegment session polling', () => {
expect(source).not.toContain('SESSIONS_POLL_MS')
expect(source.match(/window\.api\.pty\.listSessions\(\)/g) ?? []).toHaveLength(1)
const openEffectIndex = source.indexOf('if (!open || runtimeEnvironmentActive)')
const openEffectIndex = source.indexOf('if (!open)')
const refreshIndex = source.indexOf('void refreshSessions()', openEffectIndex)
// Why: pty.listSessions() is a global daemon inventory and can pause input

View File

@ -37,6 +37,7 @@ import { runWorktreeDelete } from '../sidebar/delete-worktree-flow'
import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions'
import type { AppMemory, UsageValues, Worktree } from '../../../../shared/types'
import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants'
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { isWorkspaceOldForCleanup } from '../../../../shared/workspace-cleanup'
import { mergeSnapshotAndSessions, UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions'
@ -352,7 +353,9 @@ function sortProjectGroups(groups: UnifiedProjectGroup[], sort: SortOption): Uni
// ─── Session row ────────────────────────────────────────────────────
function SessionRow({
// Exported (with WorktreeRow) for row-level regression tests pinning the kill
// affordance and remote-chip presentation for SSH/orphan rows.
export function SessionRow({
session,
worktreeId,
onNavigate,
@ -433,7 +436,7 @@ function SessionRow({
// ─── Worktree row ───────────────────────────────────────────────────
function WorktreeRow({
export function WorktreeRow({
worktree,
storeRecord,
activeWorktreeId,
@ -745,10 +748,6 @@ export function ResourceUsageStatusSegment({
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const workspaceSpaceScannedAt = useAppStore((s) => s.workspaceSpaceAnalysis?.scannedAt ?? null)
const workspaceSpaceScanning = useAppStore((s) => s.workspaceSpaceScanning)
const activeRuntimeEnvironmentId = useAppStore(
(s) => s.settings?.activeRuntimeEnvironmentId ?? null
)
const runtimeEnvironmentActive = Boolean(activeRuntimeEnvironmentId?.trim())
const [open, setOpen] = useState(false)
const [sortOption, setSortOption] = useState<SortOption>('memory')
@ -770,19 +769,12 @@ export function ResourceUsageStatusSegment({
// merged tree needs them only while open, so closed status-bar badges should
// not subscribe to those high-churn maps.
const runtimePaneTitlesByTabId = useAppStore((s) =>
getResourceUsageRuntimePaneTitlesByTabId(s, open, runtimeEnvironmentActive)
getResourceUsageRuntimePaneTitlesByTabId(s, open)
)
const repos = useAppStore((s) => getResourceUsageRepos(s, open, runtimeEnvironmentActive))
const allWorktrees = useAppStore((s) =>
getResourceUsageAllWorktrees(s, open, runtimeEnvironmentActive)
)
const tabsByWorktree = useAppStore((s) =>
getResourceUsageTabsByWorktree(s, open, runtimeEnvironmentActive)
)
// Why: this segment only understands the local Electron PTY/resource daemon.
// While a runtime server is active, hiding local samples avoids showing or
// killing sessions from the wrong machine.
const resourceSnapshot = runtimeEnvironmentActive ? null : snapshot
const repos = useAppStore((s) => getResourceUsageRepos(s, open))
const allWorktrees = useAppStore((s) => getResourceUsageAllWorktrees(s, open))
const tabsByWorktree = useAppStore((s) => getResourceUsageTabsByWorktree(s, open))
const resourceSnapshot = snapshot
// Why: ptyIdsByTabId intentionally tracks mounted/live panes only. Resource
// Manager also reads restored wake hints, but only for classification.
const resourceSessionBindings = useMemo<ResourceSessionBindingInputs>(
@ -822,13 +814,6 @@ export function ResourceUsageStatusSegment({
)
const refreshSessions = useCallback(async () => {
if (runtimeEnvironmentActive) {
if (mountedRef.current) {
setSessions([])
setSessionsError(false)
}
return
}
try {
const result = await window.api.pty.listSessions()
if (!mountedRef.current) {
@ -841,7 +826,7 @@ export function ResourceUsageStatusSegment({
setSessionsError(true)
}
}
}, [mountedRef, runtimeEnvironmentActive])
}, [mountedRef])
const daemonActions = useDaemonActions({
onRestartSettled: () => {
@ -858,7 +843,6 @@ export function ResourceUsageStatusSegment({
// closes this popover; the status-bar trigger becomes the handoff point.
const nextSpaceScanSnapshot = resolveResourceUsageSpaceScanReady({
snapshot: spaceScanSnapshot,
runtimeEnvironmentActive,
open,
activeView,
scannedAt: workspaceSpaceScannedAt,
@ -880,7 +864,7 @@ export function ResourceUsageStatusSegment({
// daemon PTYs because large preserved-session sets make that visible while
// typing.
useEffect(() => {
if (!open || runtimeEnvironmentActive) {
if (!open) {
return
}
void fetchSnapshot()
@ -894,14 +878,7 @@ export function ResourceUsageStatusSegment({
return () => {
window.clearInterval(memTimer)
}
}, [open, runtimeEnvironmentActive, fetchSnapshot, refreshSessions])
useEffect(() => {
if (runtimeEnvironmentActive) {
setSessions([])
setSessionsError(false)
}
}, [runtimeEnvironmentActive])
}, [open, fetchSnapshot, refreshSessions])
const repoDisplayNameById = useMemo(() => {
const map = new Map<string, string>()
@ -927,6 +904,17 @@ export function ResourceUsageStatusSegment({
return map
}, [repos])
// Why: runtime-hosted repos never have local daemon samples or killable
// local sessions; this map drives their per-row exclusion in the merge.
const repoRuntimeScopedById = useMemo(() => {
const map = new Map<string, boolean>()
for (const repo of repos) {
const parsed = parseExecutionHostId(getRepoExecutionHostId(repo))
map.set(repo.id, parsed?.kind === 'runtime')
}
return map
}, [repos])
const repoById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos])
const oldWorkspaceCount = useMemo(() => {
@ -951,7 +939,7 @@ export function ResourceUsageStatusSegment({
// feel laggy because the segment is always mounted in the status bar.
const unifiedRepos = useMemo(
() =>
open && !runtimeEnvironmentActive
open
? mergeSnapshotAndSessions(resourceSnapshot, sessions, {
tabsByWorktree,
ptyIdsByTabId,
@ -959,12 +947,12 @@ export function ResourceUsageStatusSegment({
runtimePaneTitlesByTabId,
workspaceSessionReady,
repoDisplayNameById,
repoConnectionIdById
repoConnectionIdById,
repoRuntimeScopedById
})
: [],
[
open,
runtimeEnvironmentActive,
resourceSnapshot,
sessions,
tabsByWorktree,
@ -973,30 +961,27 @@ export function ResourceUsageStatusSegment({
runtimePaneTitlesByTabId,
workspaceSessionReady,
repoDisplayNameById,
repoConnectionIdById
repoConnectionIdById,
repoRuntimeScopedById
]
)
// Why: orphan detection needs daemon inventory. Keep it open-only so the
// closed badge never reintroduces a background global session scan.
const orphanCount = useMemo(() => {
if (!open || !workspaceSessionReady || runtimeEnvironmentActive) {
if (!open || !workspaceSessionReady) {
return 0
}
return countUnboundDaemonSessions(sessions, resourceSessionBindings)
}, [open, sessions, resourceSessionBindings, workspaceSessionReady, runtimeEnvironmentActive])
}, [open, sessions, resourceSessionBindings, workspaceSessionReady])
const closedSessionCount = useMemo(() => {
if (!workspaceSessionReady || runtimeEnvironmentActive) {
if (!workspaceSessionReady) {
return 0
}
return buildResourceSessionBindingIndex(resourceSessionBindings).boundPtyIds.size
}, [resourceSessionBindings, workspaceSessionReady, runtimeEnvironmentActive])
const triggerSessionCount = runtimeEnvironmentActive
? 0
: open
? sessions.length
: closedSessionCount
}, [resourceSessionBindings, workspaceSessionReady])
const triggerSessionCount = open ? sessions.length : closedSessionCount
const { totalMemory, totalCpu, hostShare, memBadgeLabel } = useMemo(() => {
const memory = resourceSnapshot?.totalMemory ?? 0
@ -1013,26 +998,20 @@ export function ResourceUsageStatusSegment({
// Why: memorySnapshotError is null both for "last fetch succeeded" and
// "never fetched". If session refresh fails before a memory snapshot exists,
// treat that as daemon-unreachable too.
const daemonUnreachable =
!runtimeEnvironmentActive &&
sessionsError &&
(memorySnapshotError !== null || snapshot === null)
const daemonUnreachable = sessionsError && (memorySnapshotError !== null || snapshot === null)
// Why: a partial failure where the sessions IPC fails but the snapshot
// IPC still works was silently invisible after the merge — the old
// SessionsTabPanel surfaced it as "Terminal sessions unavailable". Show
// a slim inline notice so the user understands why the session list is
// empty/stale even though the resource numbers look fine.
const sessionsOnlyError =
!runtimeEnvironmentActive && sessionsError && memorySnapshotError === null
const sessionsOnlyError = sessionsError && memorySnapshotError === null
const resourceManagerTooltipLines = getResourceManagerTooltipLines({
memoryLabel: memBadgeLabel,
sessionCount: triggerSessionCount,
runtimeEnvironmentActive,
spaceScanReady
})
const resourceManagerAriaLabel = getResourceManagerAriaLabel({
sessionCount: triggerSessionCount,
runtimeEnvironmentActive,
spaceScanReady
})
@ -1089,12 +1068,9 @@ export function ResourceUsageStatusSegment({
}, [])
const handleOpenWorkspaceCleanup = useCallback((): void => {
if (runtimeEnvironmentActive) {
return
}
setOpen(false)
queueMicrotask(() => openModal('workspace-cleanup'))
}, [openModal, runtimeEnvironmentActive])
}, [openModal])
const handleKillSession = useCallback(
(session: UnifiedSessionRow): void => {
@ -1205,7 +1181,7 @@ export function ResourceUsageStatusSegment({
: resourceManagerAriaLabel
}
>
{spaceScanReady && !runtimeEnvironmentActive ? (
{spaceScanReady ? (
<span
className="absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary"
aria-hidden="true"
@ -1276,15 +1252,10 @@ export function ResourceUsageStatusSegment({
<div className="flex min-w-0 items-center gap-1.5 text-[11px] font-medium text-foreground">
<MemoryStick className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate">
{runtimeEnvironmentActive
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.6a822b06a7',
'Resource Manager'
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.6d9793d4bc',
'Resource Manager - Terminals'
)}
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.6d9793d4bc',
'Resource Manager - Terminals'
)}
</span>
</div>
@ -1294,7 +1265,7 @@ export function ResourceUsageStatusSegment({
<button
type="button"
onClick={() => daemonActions.setPending('restart')}
disabled={daemonActions.isBusy || runtimeEnvironmentActive}
disabled={daemonActions.isBusy}
aria-label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb',
'Restart daemon'
@ -1305,15 +1276,10 @@ export function ResourceUsageStatusSegment({
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{runtimeEnvironmentActive
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.14ff448686',
'Unavailable for runtime servers'
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb',
'Restart daemon'
)}
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb',
'Restart daemon'
)}
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={200}>
@ -1321,7 +1287,7 @@ export function ResourceUsageStatusSegment({
<button
type="button"
onClick={() => daemonActions.setPending('killAll')}
disabled={daemonActions.isBusy || runtimeEnvironmentActive}
disabled={daemonActions.isBusy}
aria-label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59',
'Kill all sessions'
@ -1332,15 +1298,10 @@ export function ResourceUsageStatusSegment({
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{runtimeEnvironmentActive
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.14ff448686',
'Unavailable for runtime servers'
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59',
'Kill all sessions'
)}
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59',
'Kill all sessions'
)}
</TooltipContent>
</Tooltip>
</div>
@ -1578,66 +1539,55 @@ export function ResourceUsageStatusSegment({
{!resourceSnapshot && !daemonUnreachable && (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
{runtimeEnvironmentActive
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.56b6888304',
'Local resource usage hidden for runtime servers.'
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.888dad8c55',
'Loading…'
)}
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.888dad8c55',
'Loading…'
)}
</div>
)}
</div>
</div>
{!runtimeEnvironmentActive || orphanCount > 0 ? (
<div className="border-t border-border/50 px-3 py-2 shrink-0">
{!runtimeEnvironmentActive ? (
<button
type="button"
onClick={handleOpenWorkspaceCleanup}
className="relative inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
>
<span className="min-w-0 truncate px-4 text-center">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.92924a14e3',
'Review inactive workspaces ({{value0}})',
{ value0: oldWorkspaceCount }
<div className="border-t border-border/50 px-3 py-2 shrink-0">
<button
type="button"
onClick={handleOpenWorkspaceCleanup}
className="relative inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
>
<span className="min-w-0 truncate px-4 text-center">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.92924a14e3',
'Review inactive workspaces ({{value0}})',
{ value0: oldWorkspaceCount }
)}
</span>
<ChevronRight
className="absolute right-2.5 size-3.5 text-muted-foreground"
aria-hidden
/>
</button>
{orphanCount > 0 ? (
<button
type="button"
onClick={() => void handleKillOrphans()}
className="mt-2 inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
>
{orphanCount === 1
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.c7e3b1a0d9f2',
'Kill {{value0}} orphan terminal',
{ value0: orphanCount }
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.d8f4c2b1e0a3',
'Kill {{value0}} orphan terminals',
{ value0: orphanCount }
)}
</span>
<ChevronRight
className="absolute right-2.5 size-3.5 text-muted-foreground"
aria-hidden
/>
</button>
) : null}
{orphanCount > 0 ? (
<button
type="button"
onClick={() => void handleKillOrphans()}
className="mt-2 inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
>
{orphanCount === 1
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.c7e3b1a0d9f2',
'Kill {{value0}} orphan terminal',
{ value0: orphanCount }
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.d8f4c2b1e0a3',
'Kill {{value0}} orphan terminals',
{ value0: orphanCount }
)}
</button>
) : null}
</div>
) : null}
</button>
) : null}
</div>
{!runtimeEnvironmentActive ? (
<WorkspaceSpaceCompactPanel onOpenFullPage={openSpaceResults} />
) : null}
<WorkspaceSpaceCompactPanel onOpenFullPage={openSpaceResults} />
</PopoverContent>
{/* Why: Radix Dialog must not be a descendant of PopoverContent when
the popover unmounts (e.g. clicking outside, focus moving to the
@ -1718,7 +1668,7 @@ export function ResourceUsageStatusSegment({
</DialogFooter>
</DialogContent>
</Dialog>
{!runtimeEnvironmentActive && <DaemonActionDialog api={daemonActions} />}
<DaemonActionDialog api={daemonActions} />
</Popover>
)
}

View File

@ -53,6 +53,7 @@ const baseCtx = (overrides: Partial<MergeContext> = {}): MergeContext => ({
workspaceSessionReady: true,
repoDisplayNameById: new Map(),
repoConnectionIdById: new Map(),
repoRuntimeScopedById: new Map(),
...overrides
})
@ -251,6 +252,58 @@ describe('mergeSnapshotAndSessions', () => {
expect(remote.worktrees[0].isRemote).toBe(true)
})
it('excludes runtime-scoped rows while preserving SSH and unattributed sessions', () => {
const runtimeWt: WorktreeMemory = {
worktreeId: 'runtime-repo::/runtime/Wt',
worktreeName: 'Wt',
repoId: 'runtime-repo',
repoName: 'RUNTIME',
cpu: 5,
memory: 500_000_000,
history: [],
sessions: [{ sessionId: 'runtime-pty', paneKey: null, pid: 2, cpu: 5, memory: 500_000_000 }]
}
const sessions: DaemonSession[] = [
{ id: 'runtime-repo::/runtime/Wt@@future-runtime', cwd: '', title: 'runtime/Wt' },
{ id: 'ssh-repo::/remote/Wt@@ssh-session', cwd: '', title: 'ssh/Wt' },
{ id: 'opaque-local-orphan', cwd: '', title: 'orphan shell' }
]
const ctx = baseCtx({
repoConnectionIdById: new Map<string, string | null>([
['runtime-repo', null],
['ssh-repo', 'ssh-target-1']
]),
repoRuntimeScopedById: new Map([
['runtime-repo', true],
['ssh-repo', false]
])
})
const out = mergeSnapshotAndSessions(makeSnapshot([runtimeWt]), sessions, ctx)
expect(out.map((repo) => repo.repoId)).toEqual(['ssh-repo', UNATTRIBUTED_REPO_ID])
expect(out.find((repo) => repo.repoId === 'runtime-repo')).toBeUndefined()
const ssh = out.find((repo) => repo.repoId === 'ssh-repo')!
expect(ssh.hasRemoteChildren).toBe(true)
expect(ssh.worktrees[0]).toMatchObject({
isRemote: true,
cpu: null,
memory: null
})
expect(ssh.worktrees[0].sessions[0]).toMatchObject({
sessionId: 'ssh-repo::/remote/Wt@@ssh-session',
bound: false
})
const orphan = out.find((repo) => repo.repoId === UNATTRIBUTED_REPO_ID)!
expect(orphan.hasRemoteChildren).toBe(false)
expect(orphan.worktrees[0].sessions[0]).toMatchObject({
sessionId: 'opaque-local-orphan',
bound: false
})
})
it('unresolvable session falls into unattributed bucket without flagging remote', () => {
// Why: under the connectionId predicate, an unresolved session is
// not evidence of remoteness — we just don't know what it belongs

View File

@ -152,6 +152,10 @@ export function mergeSnapshotAndSessions(
return ctx.repoConnectionIdById.get(repoId) != null
}
function isRuntimeScopedRepo(repoId: string): boolean {
return ctx.repoRuntimeScopedById.get(repoId) === true
}
function ensureRepo(
repoId: string,
repoName: string,
@ -183,6 +187,11 @@ export function mergeSnapshotAndSessions(
// ── Step 1: ingest snapshot worktrees as the local-truth foundation.
if (snapshot) {
for (const wt of snapshot.worktrees as readonly WorktreeMemory[]) {
// Why: local snapshot data must never render under a runtime-hosted repo
// row; belt-and-braces with the matching session-ingest guard below.
if (isRuntimeScopedRepo(wt.repoId)) {
continue
}
const repo = ensureRepo(wt.repoId, wt.repoName)
const sessions: UnifiedSessionRow[] = wt.sessions.map((s) => {
seenSessionIds.add(s.sessionId)
@ -243,6 +252,12 @@ export function mergeSnapshotAndSessions(
? session.title || session.id.slice(0, 12)
: deriveWorktreeNameFromWorktreeId(finalWorktreeId)
// Why: the current daemon inputs are local/SSH only; this guard prevents a
// future local daemon row accidentally exposing kill actions for runtime PTYs.
if (isRuntimeScopedRepo(finalRepoId)) {
continue
}
const repoIsRemote = isRepoRemote(finalRepoId)
const repo = ensureRepo(finalRepoId, finalRepoName, repoIsRemote)
if (repoIsRemote) {

View File

@ -16,7 +16,6 @@ describe('resource manager terminal copy', () => {
getResourceManagerTooltipLines({
memoryLabel: '512 MB',
sessionCount: 2,
runtimeEnvironmentActive: false,
spaceScanReady: false
})
).toEqual([
@ -25,17 +24,17 @@ describe('resource manager terminal copy', () => {
])
})
it('does not advertise local session navigation for runtime servers', () => {
it('keeps local session copy active under runtime focus', () => {
expect(
getResourceManagerTooltipLines({
memoryLabel: '-',
sessionCount: 0,
runtimeEnvironmentActive: true,
spaceScanReady: true
})
).toEqual([
'Resource Manager - memory unavailable - 0 terminal sessions',
'Local terminal sessions are hidden for runtime servers.'
'Space scan ready',
'No terminal sessions yet.'
])
})
@ -43,7 +42,6 @@ describe('resource manager terminal copy', () => {
expect(
getResourceManagerAriaLabel({
sessionCount: 1,
runtimeEnvironmentActive: false,
spaceScanReady: true
})
).toBe('Resource Manager, 1 terminal session, Space scan ready')

View File

@ -5,7 +5,6 @@ export function formatTerminalSessionCount(count: number): string {
export function getResourceManagerTooltipLines(args: {
memoryLabel: string
sessionCount: number
runtimeEnvironmentActive: boolean
spaceScanReady: boolean
}): string[] {
const rawMemoryLabel = args.memoryLabel.trim()
@ -17,13 +16,11 @@ export function getResourceManagerTooltipLines(args: {
`Resource Manager - ${memoryLabel} - ${formatTerminalSessionCount(args.sessionCount)}`
]
if (args.spaceScanReady && !args.runtimeEnvironmentActive) {
if (args.spaceScanReady) {
lines.push('Space scan ready')
}
if (args.runtimeEnvironmentActive) {
lines.push('Local terminal sessions are hidden for runtime servers.')
} else if (args.sessionCount > 0) {
if (args.sessionCount > 0) {
lines.push('Terminal sessions are grouped by workspace.')
} else {
lines.push('No terminal sessions yet.')
@ -34,18 +31,13 @@ export function getResourceManagerTooltipLines(args: {
export function getResourceManagerAriaLabel(args: {
sessionCount: number
runtimeEnvironmentActive: boolean
spaceScanReady: boolean
}): string {
const parts = ['Resource Manager', formatTerminalSessionCount(args.sessionCount)]
if (args.spaceScanReady && !args.runtimeEnvironmentActive) {
if (args.spaceScanReady) {
parts.push('Space scan ready')
}
if (args.runtimeEnvironmentActive) {
parts.push('local sessions hidden for runtime server')
}
return parts.join(', ')
}

View File

@ -60,4 +60,6 @@ export type MergeContext = {
repoDisplayNameById: Map<string, string>
/** Repo connectionId by repo id (null/missing == local). */
repoConnectionIdById: Map<string, string | null>
/** Repo runtime-host scope by repo id (missing == keep row). */
repoRuntimeScopedById: Map<string, boolean>
}

View File

@ -71,22 +71,20 @@ describe('resource usage open slices', () => {
)
})
it('gates repo and worktree slices while closed or runtime-backed', () => {
it('gates repo and worktree slices only while closed', () => {
const repos = [{ id: 'repo-1', path: '/repo', kind: 'git' }] as AppState['repos']
const row = worktree()
const worktreesByRepo = {
'repo-1': [row]
}
expect(getResourceUsageRepos({ repos }, false, false)).toBe(
getResourceUsageRepos({ repos: [] }, false, false)
expect(getResourceUsageRepos({ repos }, false)).toBe(
getResourceUsageRepos({ repos: [] }, false)
)
expect(getResourceUsageAllWorktrees({ worktreesByRepo }, false, false)).toBe(
getResourceUsageAllWorktrees({ worktreesByRepo: {} }, false, false)
expect(getResourceUsageAllWorktrees({ worktreesByRepo }, false)).toBe(
getResourceUsageAllWorktrees({ worktreesByRepo: {} }, false)
)
expect(getResourceUsageRepos({ repos }, true, true)).toEqual([])
expect(getResourceUsageAllWorktrees({ worktreesByRepo }, true, true)).toEqual([])
expect(getResourceUsageRepos({ repos }, true, false)).toBe(repos)
expect(getResourceUsageAllWorktrees({ worktreesByRepo }, true, false)).toEqual([row])
expect(getResourceUsageRepos({ repos }, true)).toBe(repos)
expect(getResourceUsageAllWorktrees({ worktreesByRepo }, true)).toEqual([row])
})
})

View File

@ -6,44 +6,36 @@ const EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID: AppState['runtimePaneTitlesByTabId']
const EMPTY_REPOS: AppState['repos'] = []
const EMPTY_WORKTREES: ReturnType<typeof getAllWorktreesFromState> = []
function shouldReadPopoverSlices(open: boolean, runtimeEnvironmentActive: boolean): boolean {
return open && !runtimeEnvironmentActive
function shouldReadPopoverSlices(open: boolean): boolean {
return open
}
export function getResourceUsageTabsByWorktree(
state: Pick<AppState, 'tabsByWorktree'>,
open: boolean,
runtimeEnvironmentActive = false
open: boolean
): AppState['tabsByWorktree'] {
return shouldReadPopoverSlices(open, runtimeEnvironmentActive)
? state.tabsByWorktree
: EMPTY_TABS_BY_WORKTREE
return shouldReadPopoverSlices(open) ? state.tabsByWorktree : EMPTY_TABS_BY_WORKTREE
}
export function getResourceUsageRuntimePaneTitlesByTabId(
state: Pick<AppState, 'runtimePaneTitlesByTabId'>,
open: boolean,
runtimeEnvironmentActive = false
open: boolean
): AppState['runtimePaneTitlesByTabId'] {
return shouldReadPopoverSlices(open, runtimeEnvironmentActive)
return shouldReadPopoverSlices(open)
? state.runtimePaneTitlesByTabId
: EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID
}
export function getResourceUsageRepos(
state: Pick<AppState, 'repos'>,
open: boolean,
runtimeEnvironmentActive: boolean
open: boolean
): AppState['repos'] {
return shouldReadPopoverSlices(open, runtimeEnvironmentActive) ? state.repos : EMPTY_REPOS
return shouldReadPopoverSlices(open) ? state.repos : EMPTY_REPOS
}
export function getResourceUsageAllWorktrees(
state: Pick<AppState, 'worktreesByRepo'>,
open: boolean,
runtimeEnvironmentActive: boolean
open: boolean
): ReturnType<typeof getAllWorktreesFromState> {
return shouldReadPopoverSlices(open, runtimeEnvironmentActive)
? getAllWorktreesFromState(state)
: EMPTY_WORKTREES
return shouldReadPopoverSlices(open) ? getAllWorktreesFromState(state) : EMPTY_WORKTREES
}

View File

@ -15,7 +15,6 @@ describe('resolveResourceUsageSpaceScanReady', () => {
expect(
resolveResourceUsageSpaceScanReady({
snapshot: { ...baseSnapshot, previousScanning: true },
runtimeEnvironmentActive: false,
open: false,
activeView: 'terminal',
scannedAt: 100,
@ -32,7 +31,6 @@ describe('resolveResourceUsageSpaceScanReady', () => {
expect(
resolveResourceUsageSpaceScanReady({
snapshot: { ...baseSnapshot, previousScanning: true },
runtimeEnvironmentActive: false,
open: true,
activeView: 'terminal',
scannedAt: 100,
@ -47,7 +45,6 @@ describe('resolveResourceUsageSpaceScanReady', () => {
expect(
resolveResourceUsageSpaceScanReady({
snapshot: { ...baseSnapshot, previousScanning: true },
runtimeEnvironmentActive: false,
open: false,
activeView: 'space',
scannedAt: 100,
@ -66,7 +63,6 @@ describe('resolveResourceUsageSpaceScanReady', () => {
expect(
resolveResourceUsageSpaceScanReady({
snapshot: readySnapshot,
runtimeEnvironmentActive: false,
open: true,
activeView: 'terminal',
scannedAt: 100,
@ -81,7 +77,6 @@ describe('resolveResourceUsageSpaceScanReady', () => {
expect(
resolveResourceUsageSpaceScanReady({
snapshot: readySnapshot,
runtimeEnvironmentActive: false,
open: false,
activeView: 'space',
scannedAt: 100,
@ -97,7 +92,6 @@ describe('resolveResourceUsageSpaceScanReady', () => {
previousScanning: true,
lastSeenScannedAt: 100
},
runtimeEnvironmentActive: false,
open: false,
activeView: 'terminal',
scannedAt: 100,
@ -108,7 +102,7 @@ describe('resolveResourceUsageSpaceScanReady', () => {
expect(result.lastSeenScannedAt).toBe(100)
})
it('hides local Space scan handoffs while a remote runtime is active', () => {
it('keeps local Space scan handoffs independent of remote runtime focus', () => {
expect(
resolveResourceUsageSpaceScanReady({
snapshot: {
@ -116,15 +110,14 @@ describe('resolveResourceUsageSpaceScanReady', () => {
previousScanning: true,
lastSeenScannedAt: 100
},
runtimeEnvironmentActive: true,
open: false,
activeView: 'terminal',
scannedAt: 200,
scanning: true
})
).toEqual({
ready: false,
previousScanning: false,
ready: true,
previousScanning: true,
lastSeenScannedAt: 100
})
})
@ -133,7 +126,6 @@ describe('resolveResourceUsageSpaceScanReady', () => {
expect(
resolveResourceUsageSpaceScanReady({
snapshot: baseSnapshot,
runtimeEnvironmentActive: false,
open: false,
activeView: 'terminal',
scannedAt: null,

View File

@ -6,27 +6,17 @@ export type ResourceUsageSpaceScanSnapshot = {
export function resolveResourceUsageSpaceScanReady({
snapshot,
runtimeEnvironmentActive,
open,
activeView,
scannedAt,
scanning
}: {
snapshot: ResourceUsageSpaceScanSnapshot
runtimeEnvironmentActive: boolean
open: boolean
activeView: string
scannedAt: number | null
scanning: boolean
}): ResourceUsageSpaceScanSnapshot {
if (runtimeEnvironmentActive) {
return {
ready: false,
previousScanning: false,
lastSeenScannedAt: snapshot.lastSeenScannedAt
}
}
const scanCompleted =
snapshot.previousScanning &&
!scanning &&

View File

@ -2845,10 +2845,7 @@
"41ae4fa725": "Killing…",
"138b99bd80": "this session",
"888dad8c55": "Loading…",
"56b6888304": "Local resource usage hidden for runtime servers.",
"14ff448686": "Unavailable for runtime servers",
"6d9793d4bc": "Resource Manager - Terminals",
"6a822b06a7": "Resource Manager",
"ca95d077db": "Daemon unreachable",
"a82253b458": "Delete workspace.",
"946724a70a": "The main workspace cannot be deleted.",

View File

@ -2845,10 +2845,7 @@
"41ae4fa725": "Asesinato…",
"138b99bd80": "esta sesión",
"888dad8c55": "Cargando…",
"56b6888304": "Uso de recursos locales oculto para servidores de ejecución.",
"14ff448686": "No disponible para servidores de ejecución",
"6d9793d4bc": "Administrador de recursos - Terminales",
"6a822b06a7": "Administrador de recursos",
"ca95d077db": "demonio inalcanzable",
"a82253b458": "Eliminar espacio de trabajo.",
"946724a70a": "El espacio de trabajo principal no se puede eliminar.",

View File

@ -2845,10 +2845,7 @@
"41ae4fa725": "終了中…",
"138b99bd80": "このセッション",
"888dad8c55": "読み込み中…",
"56b6888304": "ランタイム サーバーではローカル リソースの使用量が非表示になります。",
"14ff448686": "ランタイムサーバーでは使用できません",
"6d9793d4bc": "リソースマネージャー - Terminals",
"6a822b06a7": "リソースマネージャー",
"ca95d077db": "デーモンに到達できません",
"a82253b458": "ワークスペースを削除します。",
"946724a70a": "メインワークスペースは削除できません。",

View File

@ -2845,10 +2845,7 @@
"41ae4fa725": "종료 중…",
"138b99bd80": "이번 세션",
"888dad8c55": "로드 중…",
"56b6888304": "런타임 서버에 대한 로컬 리소스 사용량이 숨겨졌습니다.",
"14ff448686": "런타임 서버에서는 사용할 수 없습니다.",
"6d9793d4bc": "리소스 관리자 - Terminals",
"6a822b06a7": "자원 관리자",
"ca95d077db": "데몬에 연결할 수 없음",
"a82253b458": "워크스페이스를 삭제합니다.",
"946724a70a": "기본 워크스페이스는 삭제할 수 없습니다.",

View File

@ -2845,10 +2845,7 @@
"41ae4fa725": "正在结束…",
"138b99bd80": "本次会话",
"888dad8c55": "加载中…",
"56b6888304": "运行时服务器隐藏本地资源使用情况。",
"14ff448686": "不可用于运行时服务器",
"6d9793d4bc": "资源管理器 - 终端",
"6a822b06a7": "资源管理器",
"ca95d077db": "守护进程无法访问",
"a82253b458": "删除工作区。",
"946724a70a": "主工作区无法删除。",