diff --git a/src/main/ipc/pty-session-liveness-and-ownership.test.ts b/src/main/ipc/pty-session-liveness-and-ownership.test.ts index e7377dbe927..595e25bb4d5 100644 --- a/src/main/ipc/pty-session-liveness-and-ownership.test.ts +++ b/src/main/ipc/pty-session-liveness-and-ownership.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { onMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-host-authority' +import type { PtySessionInventorySnapshot } from '../../shared/pty-listed-session' import { registerPtyHandlers, registerSshPtyProvider, @@ -169,6 +170,44 @@ describe('registerPtyHandlers', () => { keepHistory: false }) }) + it('keeps a connection-lost SSH ownership scope unavailable after provider unregister', async () => { + registerPtyHandlers(mainWindow as never) + registerSshPtyProvider('ssh-available', { + listProcesses: vi.fn(async () => [ + { id: 'ssh:ssh-available@@pty-1', cwd: '/repo', title: 'available' } + ]) + } as never) + // Connection loss unregisters the provider but intentionally retains PTY + // routing ownership for daemon/relay grace and later reattach. + setPtyOwnership('ssh:ssh-disconnected@@retained', 'ssh-disconnected') + + try { + const inventory = (await handlers.get('pty:listSessionInventory')!( + null, + undefined + )) as PtySessionInventorySnapshot + expect(inventory.sessions).toEqual([ + { + id: 'ssh:ssh-available@@pty-1', + cwd: '/repo', + title: 'available', + agentOwnership: 'unknown' + } + ]) + expect(inventory.hostIdBySessionId).toEqual({ + 'ssh:ssh-available@@pty-1': 'ssh:ssh-available' + }) + expect(new Set(inventory.queriedHostIds)).toEqual(new Set(['local', 'ssh:ssh-available'])) + expect(new Set(inventory.respondingHostIds)).toEqual(new Set(['local', 'ssh:ssh-available'])) + expect(inventory.unavailableHostIds).toEqual(['ssh:ssh-disconnected']) + expect(inventory.retainedSessionIdsByHost).toEqual({ + 'ssh:ssh-disconnected': ['ssh:ssh-disconnected@@retained'] + }) + expect(inventory.complete).toBe(false) + } finally { + unregisterSshPtyProvider('ssh-available') + } + }) it('reports agent ownership through pty:listSessions so the renderer cannot guess it', async () => { registerPtyHandlers(mainWindow as never) // Why: the renderer's binding map is empty during restore, so agent ownership is the only diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index 18fbf7019f2..5ba975c5bce 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -118,7 +118,9 @@ describe('registerPtyHandlers', () => { await spawnAndGetEnv() expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:spawned', { - id: expect.any(String) + id: expect.any(String), + hostId: 'local', + isReattach: false }) }) it('marks local Claude launches live until the PTY is killed', async () => { diff --git a/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts b/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts index 219b3f94d9d..de222495faf 100644 --- a/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts +++ b/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts @@ -453,6 +453,11 @@ describe('registerPtyHandlers', () => { expect(result).toMatchObject({ id: 'ssh-reattach', isReattach: true }) expect(result.launchConfig).toBeUndefined() + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:spawned', { + id: 'ssh-reattach', + hostId: 'ssh:ssh-reattach-1', + isReattach: true + }) }) it('reuses the runtime background handle in local PTY spawn env', async () => { type RuntimeSpawnController = { @@ -497,7 +502,9 @@ describe('registerPtyHandlers', () => { 'term_expected' ) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:spawned', { - id: spawned.id + id: spawned.id, + hostId: 'local', + isReattach: false }) }) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index cac4538be37..64892137d87 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -240,7 +240,11 @@ import { import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' import { isPtyIncarnationId } from '../../shared/pty-incarnation' -import type { PtyListedSession } from '../../shared/pty-listed-session' +import { + PTY_SESSION_INVENTORY_RETAINED_ID_LIMIT, + type PtyListedSession, + type PtySessionInventorySnapshot +} from '../../shared/pty-listed-session' // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId (null = local provider). @@ -2475,6 +2479,7 @@ export function registerPtyHandlers( ipc.removeHandler('pty:spawn') ipc.removeHandler('pty:kill') ipc.removeHandler('pty:listSessions') + ipc.removeHandler('pty:listSessionInventory') ipc.removeHandler('pty:hasPty') ipc.removeHandler('pty:hasChildProcesses') ipc.removeHandler('pty:getForegroundProcess') @@ -3796,9 +3801,19 @@ export function registerPtyHandlers( } } - function sendPtySpawnedToRenderer(id: string): void { + function sendPtySpawnedToRenderer( + id: string, + hostId: ExecutionHostId, + isReattach: boolean, + exitedBeforeSpawnReply: boolean + ): void { if (!isRendererGone(mainWindow)) { - sendToRenderer(mainWindow, 'pty:spawned', { id }) + sendToRenderer(mainWindow, 'pty:spawned', { + id, + hostId, + isReattach, + ...(exitedBeforeSpawnReply ? { exitedBeforeSpawnReply: true } : {}) + }) } } @@ -5466,7 +5481,12 @@ export function registerPtyHandlers( }) } // Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal. - sendPtySpawnedToRenderer(result.id) + sendPtySpawnedToRenderer( + result.id, + args.connectionId ? toSshExecutionHostId(args.connectionId) : LOCAL_EXECUTION_HOST_ID, + result.isReattach === true, + result.exitedBeforeSpawnReply === true + ) if (!args.connectionId) { options?.onCodexHomePtySpawned?.({ id: result.id, @@ -7189,7 +7209,12 @@ export function registerPtyHandlers( : {}) } // Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory. - sendPtySpawnedToRenderer(result.id) + sendPtySpawnedToRenderer( + result.id, + args.connectionId ? toSshExecutionHostId(args.connectionId) : LOCAL_EXECUTION_HOST_ID, + result.isReattach === true, + result.exitedBeforeSpawnReply === true + ) if (!args.connectionId) { options?.onCodexHomePtySpawned?.({ id: result.id, @@ -7773,18 +7798,39 @@ export function registerPtyHandlers( } }) - ipc.handle('pty:listSessions', async (): Promise => { + const listPtySessionInventory = async (): Promise => { + const providers = registeredPtyProviders() const deduped = new Map() + const hostIdBySessionId = new Map() const admission = new PtyProcessListAdmission() + const queriedHostIds = new Set( + providers.map(({ connectionId }) => + connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID + ) + ) + const respondingHostIds = new Set() await visitPtyProcessListingsInBatches( - registeredPtyProviders(), - ({ provider, connectionId }) => - connectionId === null ? provider.listProcesses() : provider.listProcesses().catch(() => []), + providers, + async ({ provider, connectionId }) => { + const hostId = connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID + try { + const sessions = await provider.listProcesses() + respondingHostIds.add(hostId) + return sessions + } catch (error) { + if (!connectionId) { + throw error + } + return [] + } + }, ({ provider, connectionId }, sessions) => { + const hostId = connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID for (const rawSession of sessions) { const session = admission.admit(rawSession) // Why: kill actions only send back the PTY id, so rebuild ownership while listing to keep reconnect-discovered remote sessions routed to their provider. ptyOwnership.set(session.id, connectionId) + hostIdBySessionId.set(session.id, hostId) deduped.set(session.id, { id: session.id, cwd: session.cwd, @@ -7802,9 +7848,54 @@ export function registerPtyHandlers( } } ) - return Array.from(deduped.values()) + // A connection-lost SSH provider is unregistered before its durable PTYs + // disappear. Retained routing ownership keeps that host in the expected set. + const expectedHostIds = new Set(queriedHostIds) + for (const connectionId of ptyOwnership.values()) { + expectedHostIds.add( + connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID + ) + } + const unavailableHostIds = Array.from(expectedHostIds).filter( + (hostId) => !respondingHostIds.has(hostId) + ) + const unavailableHostIdSet = new Set(unavailableHostIds) + const retainedSessionIdsByHost = new Map() + let retainedSessionIdCount = 0 + for (const [sessionId, connectionId] of ptyOwnership) { + if (retainedSessionIdCount >= PTY_SESSION_INVENTORY_RETAINED_ID_LIMIT) { + break + } + if (connectionId === null || deduped.has(sessionId)) { + continue + } + const hostId = toSshExecutionHostId(connectionId) + if (!unavailableHostIdSet.has(hostId)) { + continue + } + const retainedSessionIds = retainedSessionIdsByHost.get(hostId) ?? [] + retainedSessionIds.push(sessionId) + retainedSessionIdsByHost.set(hostId, retainedSessionIds) + retainedSessionIdCount += 1 + } + return { + sessions: Array.from(deduped.values()), + hostIdBySessionId: Object.fromEntries(hostIdBySessionId), + queriedHostIds: Array.from(queriedHostIds), + retainedSessionIdsByHost: Object.fromEntries(retainedSessionIdsByHost), + respondingHostIds: Array.from(respondingHostIds), + unavailableHostIds, + complete: unavailableHostIds.length === 0 + } + } + + ipc.handle('pty:listSessions', async (): Promise => { + const inventory = await listPtySessionInventory() + return inventory.sessions }) + ipc.handle('pty:listSessionInventory', listPtySessionInventory) + ipc.handle( 'pty:getAuthoritativeBufferSnapshotCapabilities', async (_event, args: { ids?: unknown }) => { diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index d69d906047d..2d923d26fae 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -3,8 +3,9 @@ import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { ExecutionHostId } from '../../shared/execution-host' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' -import type { PtyListedSession } from '../../shared/pty-listed-session' +import type { PtyListedSession, PtySessionInventorySnapshot } from '../../shared/pty-listed-session' import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker' import type { @@ -17,6 +18,16 @@ import type { TerminalViewAttributes } from '../../shared/terminal-view-attribut import type { TuiAgent } from '../../shared/tui-agent' import type { PtyManagementApi } from './pty-management-api' +export type PtySpawnedEvent = { + id: string + /** Provider scope for authoritative per-host pruning. Missing is fail-safe unknown. */ + hostId?: ExecutionHostId + /** False proves a new provider session; true is a reattach. Missing means a mixed-version sender is ambiguous. */ + isReattach?: boolean + /** The provider reported that this spawn was already dead before its reply, so consumers must reconcile. */ + exitedBeforeSpawnReply?: true +} + export type PtyApi = { spawn: (opts: { cols: number @@ -115,6 +126,11 @@ export type PtyApi = { getCwd: (id: string) => Promise getSize: (id: string) => Promise<{ cols: number; rows: number } | null> listSessions: () => Promise + /** + * Detailed additive inventory. Missing on an older main process means + * completeness is unknown and consumers must fail safe. + */ + listSessionInventory?: () => Promise getAuthoritativeBufferSnapshotCapabilities?: ( ids: string[] ) => Promise<{ id: string; authoritative: boolean | null }[]> @@ -196,7 +212,7 @@ export type PtyApi = { onExit: ( callback: (data: { id: string; code: number; preserveRendererBinding?: boolean }) => void ) => () => void - onSpawned: (callback: (data: { id: string }) => void) => () => void + onSpawned: (callback: (data: PtySpawnedEvent) => void) => () => void onSerializeBufferRequest: ( callback: (data: { requestId: string diff --git a/src/preload/index.ts b/src/preload/index.ts index b8a00c6d647..fd950f49405 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -116,7 +116,7 @@ import type { } from '../shared/worktree/launch-types' import type { GitPushTarget, WorktreeHeadIdentity } from '../shared/worktree/types' import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' -import type { PtyListedSession } from '../shared/pty-listed-session' +import type { PtyListedSession, PtySessionInventorySnapshot } from '../shared/pty-listed-session' import type { PtyRendererDeliveryHealthReply, PtyRendererDeliveryStateReport @@ -259,6 +259,7 @@ import type { PluginHostLogLine, PreloadApi } from './api-types' +import type { PtySpawnedEvent } from './api/pty-api' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' import { KEYBOARD_LAYOUT_CHANGED_CHANNEL, @@ -1139,6 +1140,8 @@ const api = { ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), listSessions: (): Promise => ipcRenderer.invoke('pty:listSessions'), + listSessionInventory: (): Promise => + ipcRenderer.invoke('pty:listSessionInventory'), getAuthoritativeBufferSnapshotCapabilities: ( ids: string[] ): Promise<{ id: string; authoritative: boolean | null }[]> => @@ -1283,8 +1286,8 @@ const api = { return () => ipcRenderer.removeListener('pty:exit', listener) }, - onSpawned: (callback: (data: { id: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { id: string }) => callback(data) + onSpawned: (callback: (data: PtySpawnedEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: PtySpawnedEvent) => callback(data) ipcRenderer.on('pty:spawned', listener) return () => ipcRenderer.removeListener('pty:spawned', listener) }, diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts index 8dc4a515a23..5f27ddb697a 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts @@ -4,26 +4,24 @@ import { describe, expect, it } from 'vitest' const SOURCE_PATH = resolve(__dirname, 'ResourceUsageStatusSegment.tsx') const INVENTORY_HOOK_PATH = resolve(__dirname, 'use-resource-session-inventory.ts') +const INVENTORY_READER_PATH = resolve(__dirname, 'resource-session-inventory-reader.ts') describe('ResourceUsageStatusSegment session inventory', () => { it('does not poll global terminal sessions while the popover is closed', () => { const source = readFileSync(SOURCE_PATH, 'utf8') const inventoryHookSource = readFileSync(INVENTORY_HOOK_PATH, 'utf8') + const inventoryReaderSource = readFileSync(INVENTORY_READER_PATH, 'utf8') expect(source).not.toContain('installWindowVisibilityInterval') expect(source).not.toContain('SESSIONS_POLL_MS') expect(inventoryHookSource).not.toContain('setInterval') - // Why: every seed/action/lifecycle refresh shares one guarded inventory - // read, and the closed path never installs a polling interval. - expect(inventoryHookSource.match(/window\.api\.pty\.listSessions\(\)/g) ?? []).toHaveLength(1) - - 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 - // with large preserved-session sets. Keep continuous use off the closed path. - expect(openEffectIndex).toBeGreaterThanOrEqual(0) - expect(refreshIndex).toBeGreaterThan(openEffectIndex) + // The hook's 100-spawn behavioral regression proves zero closed fanout. + // This source guard keeps every full read behind the one detailed reader, + // whose legacy call is reachable only after method absence/rejection. + expect(inventoryReaderSource).toContain('listSessionInventory') + expect(inventoryReaderSource).toContain('isUnsupportedSessionInventoryMethod') + expect(inventoryReaderSource.match(/window\.api\.pty\.listSessions\(\)/g) ?? []).toHaveLength(1) + expect(source).toContain('useResourceSessionInventory(workspaceSessionReady, open)') }) it('seeds the closed badge from daemon inventory instead of wake-hint bound PTYs', () => { diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index d93f7a69825..036e91a7a83 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -752,7 +752,7 @@ export function ResourceUsageStatusSegment({ clearSessionsError, removeSession, removeSessions - } = useResourceSessionInventory(workspaceSessionReady) + } = useResourceSessionInventory(workspaceSessionReady, open) const sessions = sessionInventory.sessions const [killConfirm, setKillConfirm] = useState(null) const [killing, setKilling] = useState(false) @@ -855,23 +855,22 @@ export function ResourceUsageStatusSegment({ } }, [workspaceSessionReady, fetchSnapshot]) - // Poll memory only while the popover is open. Session inventory is still - // explicit-on-open/action/seed (not a closed interval) because full - // listSessions can pause input with large preserved-session sets. + // Poll memory only while the popover is open. The inventory hook performs + // one authoritative session reconciliation on open, then uses lifecycle + // events between explicit action/management refreshes. useEffect(() => { if (!open) { return } void fetchSnapshot() - void refreshSessions() - // Why: only memory polls on an interval; session inventory is explicit on open/action since it's expensive with many terminals. + // Why: session inventory is event-driven; only memory needs an open interval. const memTimer = window.setInterval(() => { void fetchSnapshot() }, POLL_MS) return () => { window.clearInterval(memTimer) } - }, [open, fetchSnapshot, refreshSessions]) + }, [open, fetchSnapshot]) useEffect(() => { if (!open) { diff --git a/src/renderer/src/components/status-bar/resource-session-inventory-reader.ts b/src/renderer/src/components/status-bar/resource-session-inventory-reader.ts new file mode 100644 index 00000000000..71b4c130b74 --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-session-inventory-reader.ts @@ -0,0 +1,38 @@ +import type { PtySessionInventorySnapshot } from '../../../../shared/pty-listed-session' + +function isUnsupportedSessionInventoryMethod(error: unknown): boolean { + return ( + error instanceof Error && + error.message.includes("No handler registered for 'pty:listSessionInventory'") + ) +} + +async function readLegacyPartialInventory(): Promise { + return { + sessions: await window.api.pty.listSessions(), + hostIdBySessionId: {}, + retainedSessionIdsByHost: {}, + queriedHostIds: [], + respondingHostIds: [], + unavailableHostIds: [], + // Why: the legacy array cannot prove which providers answered. + complete: false + } +} + +export async function readResourceSessionInventory(): Promise { + const listSessionInventory = window.api.pty.listSessionInventory + if (!listSessionInventory) { + return readLegacyPartialInventory() + } + try { + return await listSessionInventory() + } catch (error) { + if (!isUnsupportedSessionInventoryMethod(error)) { + throw error + } + // Why: during mixed-version restart a new preload can outlive an old main + // that has no handler for the additive detailed method. + return readLegacyPartialInventory() + } +} diff --git a/src/renderer/src/components/status-bar/resource-session-inventory-reconciliation.ts b/src/renderer/src/components/status-bar/resource-session-inventory-reconciliation.ts new file mode 100644 index 00000000000..83c4ae5f414 --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-session-inventory-reconciliation.ts @@ -0,0 +1,138 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { + PTY_SESSION_INVENTORY_RETAINED_ID_LIMIT, + type PtyListedSession, + type PtySessionInventorySnapshot +} from '../../../../shared/pty-listed-session' + +type ReconciliationInput = { + snapshot: PtySessionInventorySnapshot + lifecycleRevision: number + removedAtRevision: Map + spawnedAtRevision: Map + currentKnownSessionIds: ReadonlySet + currentHostIdBySessionId: ReadonlyMap +} + +export type ResourceSessionInventoryReconciliation = { + liveSessions: PtyListedSession[] + listedSessionIds: Set + knownSessionIds: Set + hostIdBySessionId: Map + complete: boolean +} + +export function reconcileResourceSessionInventory({ + snapshot, + lifecycleRevision, + removedAtRevision, + spawnedAtRevision, + currentKnownSessionIds, + currentHostIdBySessionId +}: ReconciliationInput): ResourceSessionInventoryReconciliation { + const listedSessions = snapshot.sessions.filter( + ({ id }) => (removedAtRevision.get(id) ?? 0) <= lifecycleRevision + ) + const liveSessions = listedSessions.slice() + const listedSessionIds = new Set(listedSessions.map(({ id }) => id)) + const knownSessionIds = new Set(listedSessionIds) + const hasHostScopeMetadata = + Array.isArray(snapshot.queriedHostIds) && + Array.isArray(snapshot.respondingHostIds) && + Array.isArray(snapshot.unavailableHostIds) && + snapshot.hostIdBySessionId !== null && + typeof snapshot.hostIdBySessionId === 'object' + const queriedHostIds = new Set( + hasHostScopeMetadata ? snapshot.queriedHostIds : ([] as ExecutionHostId[]) + ) + const respondingHostIds = new Set( + hasHostScopeMetadata ? snapshot.respondingHostIds : ([] as ExecutionHostId[]) + ) + const unavailableHostIds = hasHostScopeMetadata ? snapshot.unavailableHostIds : [] + const retainedHostIdBySessionId = new Map() + let retainedSessionIdCount = 0 + for (const [rawHostId, retainedSessionIds] of Object.entries( + snapshot.retainedSessionIdsByHost ?? {} + )) { + const hostId = unavailableHostIds.find((candidate) => candidate === rawHostId) + if (!hostId || !Array.isArray(retainedSessionIds)) { + continue + } + for (const sessionId of retainedSessionIds) { + if (retainedSessionIdCount >= PTY_SESSION_INVENTORY_RETAINED_ID_LIMIT) { + break + } + if ( + typeof sessionId !== 'string' || + listedSessionIds.has(sessionId) || + (removedAtRevision.get(sessionId) ?? 0) > lifecycleRevision + ) { + continue + } + retainedSessionIdCount += 1 + knownSessionIds.add(sessionId) + retainedHostIdBySessionId.set(sessionId, hostId) + liveSessions.push({ + id: sessionId, + cwd: '', + title: sessionId, + agentOwnership: 'unknown' + }) + } + } + const complete = + hasHostScopeMetadata && + snapshot.complete && + unavailableHostIds.length === 0 && + Array.from(queriedHostIds).every((hostId) => respondingHostIds.has(hostId)) + const hostIdBySessionId = new Map() + for (const { id } of liveSessions) { + const listedHostId = Object.hasOwn(snapshot.hostIdBySessionId, id) + ? snapshot.hostIdBySessionId[id] + : undefined + const hostId = + listedHostId ?? retainedHostIdBySessionId.get(id) ?? currentHostIdBySessionId.get(id) + if (hostId) { + hostIdBySessionId.set(id, hostId) + } + } + // Each responding host is authoritative only for its own absent IDs. + // Unknown or unavailable scopes stay known until a later scoped snapshot. + for (const id of currentKnownSessionIds) { + const hostId = currentHostIdBySessionId.get(id) + const canPrune = hostId ? respondingHostIds.has(hostId) : complete + if (!canPrune) { + knownSessionIds.add(id) + if (hostId) { + hostIdBySessionId.set(id, hostId) + } + } + } + for (const [id, spawnedRevision] of spawnedAtRevision) { + if (spawnedRevision > lifecycleRevision && (removedAtRevision.get(id) ?? 0) < spawnedRevision) { + knownSessionIds.add(id) + const hostId = currentHostIdBySessionId.get(id) + if (hostId) { + hostIdBySessionId.set(id, hostId) + } + } + } + // Markers at or before this request are now settled by its scoped authority. + for (const [id, removedRevision] of removedAtRevision) { + if (removedRevision <= lifecycleRevision) { + removedAtRevision.delete(id) + } + } + for (const [id, spawnedRevision] of spawnedAtRevision) { + if (spawnedRevision <= lifecycleRevision) { + spawnedAtRevision.delete(id) + } + } + return { + liveSessions, + listedSessionIds, + knownSessionIds, + hostIdBySessionId, + complete + } +} diff --git a/src/renderer/src/components/status-bar/resource-session-inventory.test.ts b/src/renderer/src/components/status-bar/resource-session-inventory.test.ts index 63784cc00a5..515046914a8 100644 --- a/src/renderer/src/components/status-bar/resource-session-inventory.test.ts +++ b/src/renderer/src/components/status-bar/resource-session-inventory.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it } from 'vitest' import { EMPTY_DAEMON_SESSION_INVENTORY, - inventoryFromSessions, - removeSessionFromInventory, - removeSessionsFromInventory + EMPTY_DAEMON_SESSION_ROWS, + ResourceSessionInventoryRows } from './resource-session-inventory' import type { DaemonSession } from './resource-usage-merge-types' @@ -11,35 +10,37 @@ function session(id: string): DaemonSession { return { id, cwd: '/workspace', title: id, agentOwnership: 'absent' as const } } -describe('resource session inventory', () => { - it('builds count from daemon listSessions payloads', () => { - const inventory = inventoryFromSessions([session('a'), session('b')]) - expect(inventory.count).toBe(2) - expect(inventory.sessions.map((entry) => entry.id)).toEqual(['a', 'b']) - }) - - it('returns a detached sessions array so callers can mutate safely', () => { - const source = [session('a')] - const inventory = inventoryFromSessions(source) +describe('resource session inventory rows', () => { + it('replaces rows with a detached session-id index', () => { + const source = [session('a'), session('b')] + const rows = new ResourceSessionInventoryRows() + rows.replace(source) source.pop() - expect(inventory.sessions).toEqual([session('a')]) - expect(inventory.count).toBe(1) + + expect(rows.toArray()).toEqual([session('a'), session('b')]) }) - it('removes killed or exited sessions without inventing wake-hint ids', () => { - const start = inventoryFromSessions([session('live'), session('orphan'), session('other')]) - const afterOne = removeSessionFromInventory(start, 'orphan') - expect(afterOne.count).toBe(2) - expect(afterOne.sessions.map((entry) => entry.id)).toEqual(['live', 'other']) + it('removes one or many rows by session id', () => { + const rows = new ResourceSessionInventoryRows() + rows.replace([session('live'), session('orphan'), session('other')]) - const afterMany = removeSessionsFromInventory(start, new Set(['live', 'missing', 'other'])) - expect(afterMany).toEqual(inventoryFromSessions([session('orphan')])) + expect(rows.remove('orphan')).toBe(true) + expect(rows.toArray().map(({ id }) => id)).toEqual(['live', 'other']) + expect(rows.removeMany(new Set(['live', 'missing', 'other']))).toBe(2) + expect(rows.toArray()).toEqual([]) }) - it('is a no-op when removed ids are absent', () => { - const start = inventoryFromSessions([session('live')]) - expect(removeSessionFromInventory(start, 'gone')).toBe(start) - expect(removeSessionsFromInventory(start, new Set())).toBe(start) + it('does not report row changes for absent ids', () => { + const rows = new ResourceSessionInventoryRows() + rows.replace([session('live')]) + + expect(rows.remove('gone')).toBe(false) + expect(rows.removeMany(new Set())).toBe(0) + expect(rows.toArray()).toEqual([session('live')]) + }) + + it('shares the allocation-free closed inventory rows', () => { + expect(EMPTY_DAEMON_SESSION_INVENTORY.sessions).toBe(EMPTY_DAEMON_SESSION_ROWS) expect(EMPTY_DAEMON_SESSION_INVENTORY.count).toBe(0) }) }) diff --git a/src/renderer/src/components/status-bar/resource-session-inventory.ts b/src/renderer/src/components/status-bar/resource-session-inventory.ts index fda71c1beae..cdfc19deb6a 100644 --- a/src/renderer/src/components/status-bar/resource-session-inventory.ts +++ b/src/renderer/src/components/status-bar/resource-session-inventory.ts @@ -1,43 +1,45 @@ import type { DaemonSession } from './resource-usage-merge-types' -/** Last-known daemon terminal inventory for the Resource Manager badge. */ +/** Last-known daemon terminal inventory exposed to Resource Usage consumers. */ export type DaemonSessionInventory = { sessions: DaemonSession[] count: number } +export const EMPTY_DAEMON_SESSION_ROWS: DaemonSession[] = [] + export const EMPTY_DAEMON_SESSION_INVENTORY: DaemonSessionInventory = { - sessions: [], + sessions: EMPTY_DAEMON_SESSION_ROWS, count: 0 } -export function inventoryFromSessions(sessions: readonly DaemonSession[]): DaemonSessionInventory { - return { - sessions: sessions.slice(), - count: sessions.length - } -} +/** Mutable row index owned by one inventory hook instance. Lifecycle removals are O(1). */ +export class ResourceSessionInventoryRows { + private rowsById = new Map() -export function removeSessionsFromInventory( - inventory: DaemonSessionInventory, - sessionIds: ReadonlySet -): DaemonSessionInventory { - if (sessionIds.size === 0 || inventory.sessions.length === 0) { - return inventory + replace(sessions: readonly DaemonSession[]): void { + this.rowsById = new Map(sessions.map((session) => [session.id, session])) } - const sessions = inventory.sessions.filter((session) => !sessionIds.has(session.id)) - if (sessions.length === inventory.sessions.length) { - return inventory - } - return { - sessions, - count: sessions.length - } -} -export function removeSessionFromInventory( - inventory: DaemonSessionInventory, - sessionId: string -): DaemonSessionInventory { - return removeSessionsFromInventory(inventory, new Set([sessionId])) + remove(sessionId: string): boolean { + return this.rowsById.delete(sessionId) + } + + removeMany(sessionIds: ReadonlySet): number { + let removed = 0 + for (const sessionId of sessionIds) { + if (this.rowsById.delete(sessionId)) { + removed += 1 + } + } + return removed + } + + clear(): void { + this.rowsById.clear() + } + + toArray(): DaemonSession[] { + return Array.from(this.rowsById.values()) + } } diff --git a/src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx b/src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx index 5112e613672..3273877d6c6 100644 --- a/src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx +++ b/src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx @@ -1,6 +1,11 @@ // @vitest-environment happy-dom import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { + EMPTY_DAEMON_SESSION_ROWS, + ResourceSessionInventoryRows +} from './resource-session-inventory' import type { DaemonSession } from './resource-usage-merge-types' import { notifyDaemonSessionInventoryInvalidated } from './daemon-session-inventory-invalidation' import { useResourceSessionInventory } from './use-resource-session-inventory' @@ -21,19 +26,57 @@ describe('useResourceSessionInventory', () => { const listSessions = vi.fn<() => Promise>() const unsubscribeSpawned = vi.fn() const unsubscribeExit = vi.fn() - let spawnedCallback: ((data: { id: string }) => void) | null = null + let spawnedCallback: + | ((data: { + id: string + hostId?: ExecutionHostId + isReattach?: boolean + exitedBeforeSpawnReply?: true + }) => void) + | null = null let exitCallback: ((data: { id: string; code: number }) => void) | null = null + let inventoryComplete = true + let queriedHostIds: ExecutionHostId[] = ['local'] + let respondingHostIds: ExecutionHostId[] = ['local'] + let unavailableHostIds: ExecutionHostId[] = [] + const inventoryHostIdBySessionId = new Map() beforeEach(() => { spawnedCallback = null exitCallback = null + inventoryComplete = true + queriedHostIds = ['local'] + respondingHostIds = ['local'] + unavailableHostIds = [] + inventoryHostIdBySessionId.clear() listSessions.mockReset() unsubscribeSpawned.mockReset() unsubscribeExit.mockReset() ;(window as unknown as { api: unknown }).api = { pty: { listSessions, - onSpawned: (callback: (data: { id: string }) => void) => { + listSessionInventory: async () => { + const sessions = await listSessions() + return { + sessions, + hostIdBySessionId: Object.fromEntries( + sessions.map(({ id }) => [id, inventoryHostIdBySessionId.get(id) ?? 'local']) + ), + retainedSessionIdsByHost: {}, + queriedHostIds, + respondingHostIds, + unavailableHostIds, + complete: inventoryComplete + } + }, + onSpawned: ( + callback: (data: { + id: string + hostId?: ExecutionHostId + isReattach?: boolean + exitedBeforeSpawnReply?: true + }) => void + ) => { spawnedCallback = callback return unsubscribeSpawned }, @@ -53,9 +96,12 @@ describe('useResourceSessionInventory', () => { it('seeds from the daemon inventory and resets when session restore is not ready', async () => { listSessions.mockResolvedValue([session('one'), session('two')]) - const { result, rerender } = renderHook(({ ready }) => useResourceSessionInventory(ready), { - initialProps: { ready: false } - }) + const { result, rerender } = renderHook( + ({ ready }) => useResourceSessionInventory(ready, false), + { + initialProps: { ready: false } + } + ) expect(result.current.sessionInventory.count).toBe(0) expect(listSessions).not.toHaveBeenCalled() @@ -73,7 +119,7 @@ describe('useResourceSessionInventory', () => { listSessions .mockRejectedValueOnce(new Error('daemon unavailable')) .mockResolvedValueOnce([session('recovered')]) - const { result } = renderHook(() => useResourceSessionInventory(true)) + const { result } = renderHook(() => useResourceSessionInventory(true, true)) await waitFor(() => expect(result.current.sessionsError).toBe(true)) await act(async () => { @@ -84,129 +130,267 @@ describe('useResourceSessionInventory', () => { expect(result.current.sessionsError).toBe(false) }) - it('refreshes for background spawns without depending on mounted pane state', async () => { - listSessions - .mockResolvedValueOnce([session('one')]) - .mockResolvedValue([session('one'), session('background')]) - const { result } = renderHook(() => useResourceSessionInventory(true)) + it('counts 100 authoritative novel spawns while closed without provider-wide lists', async () => { + listSessions.mockResolvedValue([session('seed')]) + const { result } = renderHook(() => useResourceSessionInventory(true, false)) await waitFor(() => expect(result.current.sessionInventory.count).toBe(1)) - await act(async () => { - spawnedCallback?.({ id: 'one' }) - spawnedCallback?.({ id: 'background' }) - spawnedCallback?.({ id: 'background-2' }) - await new Promise((resolve) => window.setTimeout(resolve, 0)) + act(() => { + for (let index = 0; index < 100; index += 1) { + spawnedCallback?.({ id: `background-${index}`, isReattach: false }) + } + }) + + expect(result.current.sessionInventory.count).toBe(101) + expect(result.current.sessionInventory.sessions).toBe(EMPTY_DAEMON_SESSION_ROWS) + expect(listSessions).toHaveBeenCalledTimes(1) + }) + + it('removes 1000 closed sessions without fleet row materialization or inventory scans', async () => { + const sessions = Array.from({ length: 1000 }, (_, index) => session(`scale-${index}`)) + const rowSnapshots = vi.spyOn(ResourceSessionInventoryRows.prototype, 'toArray') + listSessions.mockResolvedValue(sessions) + const { result } = renderHook(() => useResourceSessionInventory(true, false)) + await waitFor(() => expect(result.current.sessionInventory.count).toBe(1000)) + const snapshotsAfterSeed = rowSnapshots.mock.calls.length + const closedRows = result.current.sessionInventory.sessions + + act(() => { + for (const { id } of sessions) { + exitCallback?.({ id, code: 0 }) + } + }) + + expect(result.current.sessionInventory.count).toBe(0) + expect(result.current.sessionInventory.sessions).toBe(closedRows) + expect(result.current.sessionInventory.sessions).toBe(EMPTY_DAEMON_SESSION_ROWS) + expect(rowSnapshots).toHaveBeenCalledTimes(snapshotsAfterSeed) + expect(listSessions).toHaveBeenCalledTimes(1) + rowSnapshots.mockRestore() + }) + + it('ignores a same-ID reattach without changing the count or listing again', async () => { + listSessions.mockResolvedValue([session('one')]) + const { result } = renderHook(() => useResourceSessionInventory(true, false)) + await waitFor(() => expect(result.current.sessionInventory.count).toBe(1)) + + act(() => { + spawnedCallback?.({ id: 'one', isReattach: true }) + }) + + expect(result.current.sessionInventory.count).toBe(1) + expect(listSessions).toHaveBeenCalledTimes(1) + }) + + it('decrements exits immediately and admits reuse of the same ID as a novel spawn', async () => { + listSessions.mockResolvedValue([session('reused')]) + const { result } = renderHook(() => useResourceSessionInventory(true, false)) + await waitFor(() => expect(result.current.sessionInventory.count).toBe(1)) + + act(() => { + exitCallback?.({ id: 'reused', code: 0 }) + }) + expect(result.current.sessionInventory.count).toBe(0) + + act(() => { + spawnedCallback?.({ id: 'reused', isReattach: false }) + }) + expect(result.current.sessionInventory.count).toBe(1) + expect(result.current.sessionInventory.sessions).toEqual([]) + expect(listSessions).toHaveBeenCalledTimes(1) + }) + + it('performs one authoritative reconciliation when the popover opens', async () => { + listSessions + .mockResolvedValueOnce([session('one')]) + .mockResolvedValueOnce([session('one'), session('background')]) + const { result, rerender } = renderHook(({ open }) => useResourceSessionInventory(true, open), { + initialProps: { open: false } + }) + await waitFor(() => expect(result.current.sessionInventory.count).toBe(1)) + + act(() => { + spawnedCallback?.({ id: 'background', isReattach: false }) + }) + expect(result.current.sessionInventory.count).toBe(2) + expect(listSessions).toHaveBeenCalledTimes(1) + + rerender({ open: true }) + await waitFor(() => + expect(result.current.sessionInventory.sessions.map(({ id }) => id)).toEqual([ + 'one', + 'background' + ]) + ) + expect(listSessions).toHaveBeenCalledTimes(2) + }) + + it('reconciles an unknown reattach or mixed-version spawn instead of guessing', async () => { + listSessions + .mockResolvedValueOnce([session('one')]) + .mockResolvedValueOnce([session('one'), session('legacy')]) + const { result } = renderHook(() => useResourceSessionInventory(true, false)) + await waitFor(() => expect(result.current.sessionInventory.count).toBe(1)) + + act(() => { + spawnedCallback?.({ id: 'legacy' }) + spawnedCallback?.({ + id: 'already-exited', + isReattach: false, + exitedBeforeSpawnReply: true + }) }) await waitFor(() => expect(result.current.sessionInventory.count).toBe(2)) expect(listSessions).toHaveBeenCalledTimes(2) }) - it('does not inventory again when an existing session reattaches', async () => { - listSessions.mockResolvedValue([session('one')]) - const { result } = renderHook(() => useResourceSessionInventory(true)) - await waitFor(() => expect(result.current.sessionInventory.count).toBe(1)) + it('prunes responding local, WSL, and SSH scopes while retaining an unavailable SSH host', async () => { + listSessions.mockResolvedValue([]) + const { result, rerender } = renderHook(({ open }) => useResourceSessionInventory(true, open), { + initialProps: { open: false } + }) + await waitFor(() => expect(listSessions).toHaveBeenCalledTimes(1)) act(() => { - spawnedCallback?.({ id: 'one' }) + spawnedCallback?.({ id: 'local-stale', hostId: 'local', isReattach: false }) + spawnedCallback?.({ id: 'wsl-stale', hostId: 'local', isReattach: false }) + spawnedCallback?.({ + id: 'ssh:ssh-available@@stale', + hostId: 'ssh:ssh-available', + isReattach: false + }) + spawnedCallback?.({ + id: 'ssh:ssh-unavailable@@retained', + hostId: 'ssh:ssh-unavailable', + isReattach: false + }) }) - + expect(result.current.sessionInventory.count).toBe(4) expect(listSessions).toHaveBeenCalledTimes(1) - }) - it('does not overlap provider-wide inventory reads for spawns during a slow refresh', async () => { - listSessions.mockResolvedValueOnce([session('one')]) - const { result } = renderHook(() => useResourceSessionInventory(true)) + inventoryComplete = false + queriedHostIds = ['local', 'ssh:ssh-available'] + respondingHostIds = ['local', 'ssh:ssh-available'] + unavailableHostIds = ['ssh:ssh-unavailable'] + rerender({ open: true }) + await waitFor(() => expect(result.current.sessionInventory.count).toBe(1)) - - const inFlight = deferred() - listSessions.mockReturnValueOnce(inFlight.promise) - await act(async () => { - spawnedCallback?.({ id: 'background-one' }) - await new Promise((resolve) => window.setTimeout(resolve, 0)) - }) + expect(result.current.sessionInventory.sessions).toEqual([]) + expect(result.current.sessionsError).toBe(true) expect(listSessions).toHaveBeenCalledTimes(2) + inventoryComplete = true + queriedHostIds = ['local', 'ssh:ssh-available', 'ssh:ssh-unavailable'] + respondingHostIds = ['local', 'ssh:ssh-available', 'ssh:ssh-unavailable'] + unavailableHostIds = [] await act(async () => { - spawnedCallback?.({ id: 'background-two' }) - await new Promise((resolve) => window.setTimeout(resolve, 0)) + await result.current.refreshSessions() }) - expect(listSessions).toHaveBeenCalledTimes(2) - - await act(async () => { - inFlight.resolve([session('one'), session('background-one'), session('background-two')]) - await inFlight.promise - }) - await waitFor(() => expect(result.current.sessionInventory.count).toBe(3)) - expect(listSessions).toHaveBeenCalledTimes(2) - }) - - it('reconciles once when an in-flight inventory misses a later spawn', async () => { - listSessions.mockResolvedValueOnce([session('one')]) - const { result } = renderHook(() => useResourceSessionInventory(true)) - await waitFor(() => expect(result.current.sessionInventory.count).toBe(1)) - - const inFlight = deferred() - listSessions - .mockReturnValueOnce(inFlight.promise) - .mockResolvedValueOnce([session('one'), session('background-one'), session('background-two')]) - await act(async () => { - spawnedCallback?.({ id: 'background-one' }) - await new Promise((resolve) => window.setTimeout(resolve, 0)) - }) - act(() => { - spawnedCallback?.({ id: 'background-two' }) - }) - - await act(async () => { - inFlight.resolve([session('one'), session('background-one')]) - await inFlight.promise - }) - await waitFor(() => expect(result.current.sessionInventory.count).toBe(3)) + expect(result.current.sessionInventory.count).toBe(0) + expect(result.current.sessionsError).toBe(false) expect(listSessions).toHaveBeenCalledTimes(3) }) - it('cancels a queued inventory read when the unknown session exits first', async () => { - listSessions.mockResolvedValueOnce([session('one')]) - const { result } = renderHook(() => useResourceSessionInventory(true)) + it('seeds a cold renderer from bounded unavailable-host ownership until reconnect', async () => { + const retainedId = 'ssh:ssh-disconnected@@retained' + const listSessionInventory = vi + .fn() + .mockResolvedValueOnce({ + sessions: [], + hostIdBySessionId: {}, + retainedSessionIdsByHost: { 'ssh:ssh-disconnected': [retainedId] }, + queriedHostIds: ['local'], + respondingHostIds: ['local'], + unavailableHostIds: ['ssh:ssh-disconnected'], + complete: false + }) + .mockResolvedValueOnce({ + sessions: [], + hostIdBySessionId: {}, + retainedSessionIdsByHost: {}, + queriedHostIds: ['local', 'ssh:ssh-disconnected'], + respondingHostIds: ['local', 'ssh:ssh-disconnected'], + unavailableHostIds: [], + complete: true + }) + window.api.pty.listSessionInventory = listSessionInventory + const { result } = renderHook(() => useResourceSessionInventory(true, true)) + await waitFor(() => expect(result.current.sessionInventory.count).toBe(1)) + expect(result.current.sessionInventory.sessions).toEqual([ + { id: retainedId, cwd: '', title: retainedId, agentOwnership: 'unknown' } + ]) + expect(result.current.sessionsError).toBe(true) + expect(listSessions).not.toHaveBeenCalled() - act(() => { - spawnedCallback?.({ id: 'short-lived' }) - exitCallback?.({ id: 'short-lived', code: 0 }) + await act(async () => { + await result.current.refreshSessions() }) - await new Promise((resolve) => window.setTimeout(resolve, 0)) - - expect(listSessions).toHaveBeenCalledTimes(1) - expect(result.current.sessionInventory.count).toBe(1) + expect(result.current.sessionInventory.count).toBe(0) + expect(result.current.sessionInventory.sessions).toEqual([]) + expect(result.current.sessionsError).toBe(false) + expect(listSessionInventory).toHaveBeenCalledTimes(2) }) - it('does not schedule follow-up inventory after unmount', async () => { - listSessions.mockResolvedValueOnce([session('one')]) - const { result, unmount } = renderHook(() => useResourceSessionInventory(true)) - await waitFor(() => expect(result.current.sessionInventory.count).toBe(1)) - - const inFlight = deferred() - listSessions.mockReturnValueOnce(inFlight.promise) - await act(async () => { - spawnedCallback?.({ id: 'background-one' }) - await new Promise((resolve) => window.setTimeout(resolve, 0)) + it('falls back conservatively when a new preload invokes an old main', async () => { + const unsupportedInventory = vi.fn(async () => { + throw new Error( + "Error invoking remote method 'pty:listSessionInventory': Error: No handler registered for 'pty:listSessionInventory'" + ) }) + window.api.pty.listSessionInventory = unsupportedInventory + const refreshedLocalSession = { ...session('local-session'), title: 'refreshed' } + listSessions + .mockResolvedValueOnce([session('local-session')]) + .mockResolvedValueOnce([refreshedLocalSession]) + const { result, rerender } = renderHook(({ open }) => useResourceSessionInventory(true, open), { + initialProps: { open: false } + }) + await waitFor(() => expect(result.current.sessionsError).toBe(true)) + expect(listSessions).toHaveBeenCalledTimes(1) + act(() => { - spawnedCallback?.({ id: 'background-two' }) + spawnedCallback?.({ + id: 'ssh:ssh-old@@remote-session', + hostId: 'ssh:ssh-old', + isReattach: false + }) }) - unmount() - - inFlight.resolve([session('one'), session('background-one')]) - await inFlight.promise - await new Promise((resolve) => window.setTimeout(resolve, 0)) + expect(result.current.sessionInventory.count).toBe(2) + rerender({ open: true }) + await waitFor(() => + expect(result.current.sessionInventory.sessions).toEqual([refreshedLocalSession]) + ) + expect(result.current.sessionInventory.count).toBe(2) + expect(result.current.sessionsError).toBe(true) expect(listSessions).toHaveBeenCalledTimes(2) + expect(unsupportedInventory).toHaveBeenCalledTimes(2) + }) + + it('retains a novel spawn that races the initial authoritative seed', async () => { + const seed = deferred() + listSessions.mockReturnValueOnce(seed.promise) + const { result } = renderHook(() => useResourceSessionInventory(true, false)) + await waitFor(() => expect(listSessions).toHaveBeenCalledTimes(1)) + + act(() => { + spawnedCallback?.({ id: 'raced', isReattach: false }) + }) + expect(result.current.sessionInventory.count).toBe(1) + + await act(async () => { + seed.resolve([]) + await seed.promise + }) + expect(result.current.sessionInventory.count).toBe(1) + expect(listSessions).toHaveBeenCalledTimes(1) }) it('filters an exit from an in-flight list without losing other new sessions', async () => { listSessions.mockResolvedValueOnce([session('one'), session('exited')]) - const { result } = renderHook(() => useResourceSessionInventory(true)) + const { result } = renderHook(() => useResourceSessionInventory(true, true)) await waitFor(() => expect(result.current.sessionInventory.count).toBe(2)) const stale = deferred() @@ -232,7 +416,7 @@ describe('useResourceSessionInventory', () => { it('keeps the newest result when refreshes resolve out of order', async () => { listSessions.mockResolvedValueOnce([session('one')]) - const { result } = renderHook(() => useResourceSessionInventory(true)) + const { result } = renderHook(() => useResourceSessionInventory(true, false)) await waitFor(() => expect(result.current.sessionInventory.count).toBe(1)) const older = deferred() @@ -261,7 +445,7 @@ describe('useResourceSessionInventory', () => { listSessions .mockResolvedValueOnce([session('one'), session('two')]) .mockResolvedValue([session('one')]) - const { result } = renderHook(() => useResourceSessionInventory(true)) + const { result } = renderHook(() => useResourceSessionInventory(true, false)) await waitFor(() => expect(result.current.sessionInventory.count).toBe(2)) await act(async () => { @@ -272,9 +456,30 @@ describe('useResourceSessionInventory', () => { expect(listSessions).toHaveBeenCalledTimes(2) }) + it('seeds exactly once after a daemon or runtime readiness restart', async () => { + listSessions + .mockResolvedValueOnce([session('before-restart')]) + .mockResolvedValueOnce([session('after-restart')]) + const { result, rerender } = renderHook( + ({ ready }) => useResourceSessionInventory(ready, true), + { initialProps: { ready: true } } + ) + await waitFor(() => expect(result.current.sessionInventory.count).toBe(1)) + expect(listSessions).toHaveBeenCalledTimes(1) + + rerender({ ready: false }) + expect(result.current.sessionInventory.count).toBe(0) + rerender({ ready: true }) + + await waitFor(() => + expect(result.current.sessionInventory.sessions).toEqual([session('after-restart')]) + ) + expect(listSessions).toHaveBeenCalledTimes(2) + }) + it('ignores inventory invalidation before session restore is ready and after unmount', async () => { listSessions.mockResolvedValue([session('one')]) - const { unmount } = renderHook(({ ready }) => useResourceSessionInventory(ready), { + const { unmount } = renderHook(({ ready }) => useResourceSessionInventory(ready, false), { initialProps: { ready: false } }) @@ -292,7 +497,7 @@ describe('useResourceSessionInventory', () => { it('unsubscribes from lifecycle events on unmount', () => { listSessions.mockResolvedValue([]) - const { unmount } = renderHook(() => useResourceSessionInventory(true)) + const { unmount } = renderHook(() => useResourceSessionInventory(true, false)) unmount() diff --git a/src/renderer/src/components/status-bar/use-resource-session-inventory.ts b/src/renderer/src/components/status-bar/use-resource-session-inventory.ts index dd28e4b0398..443b3d8e9fc 100644 --- a/src/renderer/src/components/status-bar/use-resource-session-inventory.ts +++ b/src/renderer/src/components/status-bar/use-resource-session-inventory.ts @@ -1,13 +1,14 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useMountedRef } from '@/hooks/useMountedRef' +import type { ExecutionHostId } from '../../../../shared/execution-host' import { subscribeDaemonSessionInventoryInvalidated } from './daemon-session-inventory-invalidation' import { - EMPTY_DAEMON_SESSION_INVENTORY, - inventoryFromSessions, - removeSessionFromInventory, - removeSessionsFromInventory, + EMPTY_DAEMON_SESSION_ROWS, + ResourceSessionInventoryRows, type DaemonSessionInventory } from './resource-session-inventory' +import { readResourceSessionInventory } from './resource-session-inventory-reader' +import { reconcileResourceSessionInventory } from './resource-session-inventory-reconciliation' type ResourceSessionInventory = { sessionInventory: DaemonSessionInventory @@ -20,19 +21,29 @@ type ResourceSessionInventory = { type ResourceSessionInventoryState = { ready: boolean - sessionInventory: DaemonSessionInventory + sessionCount: number + sessionRowsRevision: number sessionsError: boolean } -export function useResourceSessionInventory(ready: boolean): ResourceSessionInventory { +export function useResourceSessionInventory( + ready: boolean, + authoritativeInventoryRequested: boolean +): ResourceSessionInventory { const mountedRef = useMountedRef() const refreshGenerationRef = useRef(0) const lifecycleRevisionRef = useRef(0) const removedAtRevisionRef = useRef(new Map()) + const spawnedAtRevisionRef = useRef(new Map()) const knownSessionIdsRef = useRef(new Set()) + const knownHostIdBySessionIdRef = useRef(new Map()) + const listedSessionIdsRef = useRef(new Set()) + const sessionRowsRef = useRef(new ResourceSessionInventoryRows()) + const previousAuthoritativeInventoryRequestedRef = useRef(authoritativeInventoryRequested) const [storedState, setStoredState] = useState(() => ({ ready, - sessionInventory: EMPTY_DAEMON_SESSION_INVENTORY, + sessionCount: 0, + sessionRowsRevision: 0, sessionsError: false })) const state = @@ -40,7 +51,8 @@ export function useResourceSessionInventory(ready: boolean): ResourceSessionInve ? storedState : { ready, - sessionInventory: EMPTY_DAEMON_SESSION_INVENTORY, + sessionCount: 0, + sessionRowsRevision: storedState.sessionRowsRevision + 1, sessionsError: false } if (state !== storedState) { @@ -56,29 +68,38 @@ export function useResourceSessionInventory(ready: boolean): ResourceSessionInve const generation = ++refreshGenerationRef.current const lifecycleRevision = lifecycleRevisionRef.current try { - const sessions = await window.api.pty.listSessions() - // Why: an exit or newer refresh can land while the global provider list - // is in flight; stale results must not resurrect dead sessions. + const inventory = await readResourceSessionInventory() + // Why: lifecycle events can land while the global provider list is in flight. + // Exits must not resurrect dead sessions, while authoritative novel spawns + // must remain counted even if this particular list started too early. if (!mountedRef.current || generation !== refreshGenerationRef.current) { return } - const currentRemovedAtRevision = removedAtRevisionRef.current - const liveSessions = sessions.filter( - ({ id }) => (currentRemovedAtRevision.get(id) ?? 0) <= lifecycleRevision - ) - // Tombstones at or before this request cannot suppress later ID reuse; - // only exits that raced this request must survive to the next refresh. - for (const [id, removedAtRevision] of currentRemovedAtRevision) { - if (removedAtRevision <= lifecycleRevision) { - currentRemovedAtRevision.delete(id) - } - } - knownSessionIdsRef.current = new Set(liveSessions.map(({ id }) => id)) - setStoredState({ - ready: true, - sessionInventory: inventoryFromSessions(liveSessions), - sessionsError: false + const reconciliation = reconcileResourceSessionInventory({ + snapshot: inventory, + lifecycleRevision, + removedAtRevision: removedAtRevisionRef.current, + spawnedAtRevision: spawnedAtRevisionRef.current, + currentKnownSessionIds: knownSessionIdsRef.current, + currentHostIdBySessionId: knownHostIdBySessionIdRef.current }) + const { liveSessions, listedSessionIds, knownSessionIds, hostIdBySessionId, complete } = + reconciliation + knownSessionIdsRef.current = knownSessionIds + knownHostIdBySessionIdRef.current = hostIdBySessionId + listedSessionIdsRef.current = listedSessionIds + const retainedSessions = sessionRowsRef.current + .toArray() + .filter(({ id }) => knownSessionIds.has(id) && !listedSessionIds.has(id)) + const sessions = + retainedSessions.length === 0 ? liveSessions : [...liveSessions, ...retainedSessions] + sessionRowsRef.current.replace(sessions) + setStoredState((current) => ({ + ready: true, + sessionCount: knownSessionIds.size, + sessionRowsRevision: current.sessionRowsRevision + 1, + sessionsError: !complete + })) } catch { if (mountedRef.current && generation === refreshGenerationRef.current) { setStoredState((current) => ({ ...current, sessionsError: true })) @@ -95,10 +116,15 @@ export function useResourceSessionInventory(ready: boolean): ResourceSessionInve // only that id preserves unrelated sessions discovered by the same list. const lifecycleRevision = ++lifecycleRevisionRef.current removedAtRevisionRef.current.set(sessionId, lifecycleRevision) + spawnedAtRevisionRef.current.delete(sessionId) knownSessionIdsRef.current.delete(sessionId) + knownHostIdBySessionIdRef.current.delete(sessionId) + listedSessionIdsRef.current.delete(sessionId) + const removedRow = sessionRowsRef.current.remove(sessionId) setStoredState((current) => ({ ...current, - sessionInventory: removeSessionFromInventory(current.sessionInventory, sessionId) + sessionCount: knownSessionIdsRef.current.size, + sessionRowsRevision: current.sessionRowsRevision + (removedRow ? 1 : 0) })) }, []) @@ -106,11 +132,16 @@ export function useResourceSessionInventory(ready: boolean): ResourceSessionInve const lifecycleRevision = ++lifecycleRevisionRef.current for (const sessionId of sessionIds) { removedAtRevisionRef.current.set(sessionId, lifecycleRevision) + spawnedAtRevisionRef.current.delete(sessionId) knownSessionIdsRef.current.delete(sessionId) + knownHostIdBySessionIdRef.current.delete(sessionId) + listedSessionIdsRef.current.delete(sessionId) } + const removedRows = sessionRowsRef.current.removeMany(sessionIds) setStoredState((current) => ({ ...current, - sessionInventory: removeSessionsFromInventory(current.sessionInventory, sessionIds) + sessionCount: knownSessionIdsRef.current.size, + sessionRowsRevision: current.sessionRowsRevision + removedRows })) }, []) @@ -118,12 +149,24 @@ export function useResourceSessionInventory(ready: boolean): ResourceSessionInve refreshGenerationRef.current += 1 if (!ready) { removedAtRevisionRef.current.clear() + spawnedAtRevisionRef.current.clear() knownSessionIdsRef.current.clear() + knownHostIdBySessionIdRef.current.clear() + listedSessionIdsRef.current.clear() + sessionRowsRef.current.clear() return } void refreshSessions() }, [ready, refreshSessions]) + useEffect(() => { + const wasRequested = previousAuthoritativeInventoryRequestedRef.current + previousAuthoritativeInventoryRequestedRef.current = authoritativeInventoryRequested + if (ready && authoritativeInventoryRequested && !wasRequested) { + void refreshSessions() + } + }, [authoritativeInventoryRequested, ready, refreshSessions]) + useEffect(() => { if (!ready) { return @@ -161,7 +204,7 @@ export function useResourceSessionInventory(ready: boolean): ResourceSessionInve } lifecycleRefresh = null for (const id of pendingSpawnIds) { - if (knownSessionIdsRef.current.has(id)) { + if (listedSessionIdsRef.current.has(id)) { pendingSpawnIds.delete(id) } } @@ -169,15 +212,44 @@ export function useResourceSessionInventory(ready: boolean): ResourceSessionInve }) }, 0) } - const unsubscribeSpawned = window.api.pty.onSpawned(({ id }) => { - // Why: reattach emits the same lifecycle signal; known IDs must not turn remounts into global inventory scans. - if (knownSessionIdsRef.current.has(id)) { - return + const unsubscribeSpawned = window.api.pty.onSpawned( + ({ id, hostId, isReattach, exitedBeforeSpawnReply }) => { + // Why: a spawn already dead before its reply is ambiguous even when + // its reusable ID is still known; only a full inventory can settle it. + if (exitedBeforeSpawnReply) { + pendingSpawnIds.add(id) + scheduleLifecycleRefresh() + return + } + if (hostId) { + knownHostIdBySessionIdRef.current.set(id, hostId) + } + // Reattach emits the same lifecycle signal. A known ID is already + // counted; unknown or mixed-version classification must reconcile. + if (knownSessionIdsRef.current.has(id)) { + return + } + if (isReattach !== false) { + pendingSpawnIds.add(id) + scheduleLifecycleRefresh() + return + } + + const lifecycleRevision = ++lifecycleRevisionRef.current + spawnedAtRevisionRef.current.set(id, lifecycleRevision) + knownSessionIdsRef.current.add(id) + setStoredState((current) => ({ + ...current, + sessionCount: knownSessionIdsRef.current.size + })) + + if (authoritativeInventoryRequested) { + // The open popover needs full rows, not only the authoritative badge count. + pendingSpawnIds.add(id) + scheduleLifecycleRefresh() + } } - pendingSpawnIds.add(id) - // Why: serialize slow provider-wide lists; retry once only when a result missed a later spawn. - scheduleLifecycleRefresh() - }) + ) const unsubscribeExit = window.api.pty.onExit(({ id }) => { pendingSpawnIds.delete(id) if (pendingSpawnIds.size === 0 && refreshTimer !== null) { @@ -195,10 +267,21 @@ export function useResourceSessionInventory(ready: boolean): ResourceSessionInve unsubscribeSpawned() unsubscribeExit() } - }, [ready, refreshSessions, removeSession]) + }, [authoritativeInventoryRequested, ready, refreshSessions, removeSession]) + const sessions = useMemo( + () => + ready && authoritativeInventoryRequested + ? sessionRowsRef.current.toArray() + : EMPTY_DAEMON_SESSION_ROWS, + [authoritativeInventoryRequested, ready, state.sessionRowsRevision] + ) + const sessionInventory = useMemo( + () => ({ sessions, count: state.sessionCount }), + [sessions, state.sessionCount] + ) return { - sessionInventory: state.sessionInventory, + sessionInventory, sessionsError: state.sessionsError, refreshSessions, clearSessionsError, diff --git a/src/shared/pty-listed-session.ts b/src/shared/pty-listed-session.ts index 064477dd12a..060bd082ad8 100644 --- a/src/shared/pty-listed-session.ts +++ b/src/shared/pty-listed-session.ts @@ -1,3 +1,6 @@ +import type { ExecutionHostId } from './execution-host' + +export const PTY_SESSION_INVENTORY_RETAINED_ID_LIMIT = 4096 /** * Whether an agent holds a listed session. * @@ -24,6 +27,22 @@ export type PtyListedSession = { agentOwnership: AgentOwnershipEvidence } +/** + * Additive detailed inventory contract. Only `respondingHostIds` are + * authoritative for pruning. `unavailableHostIds` includes retained ownership + * scopes whose provider is currently unregistered after connection loss. + */ +export type PtySessionInventorySnapshot = { + sessions: PtyListedSession[] + hostIdBySessionId: Record + queriedHostIds: ExecutionHostId[] + /** Bounded last-known IDs for unavailable hosts, used by a cold renderer. */ + retainedSessionIdsByHost: Partial> + respondingHostIds: ExecutionHostId[] + unavailableHostIds: ExecutionHostId[] + complete: boolean +} + /** Only proven absence authorizes destroying a session without asking. */ export function mayDestroyWithoutOwnerEvidence(session: { agentOwnership: AgentOwnershipEvidence