mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
perf(resource-usage): track closed PTY inventory incrementally
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+101
-10
@@ -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<PtyListedSession[]> => {
|
||||
const listPtySessionInventory = async (): Promise<PtySessionInventorySnapshot> => {
|
||||
const providers = registeredPtyProviders()
|
||||
const deduped = new Map<string, PtyListedSession>()
|
||||
const hostIdBySessionId = new Map<string, ExecutionHostId>()
|
||||
const admission = new PtyProcessListAdmission()
|
||||
const queriedHostIds = new Set<ExecutionHostId>(
|
||||
providers.map(({ connectionId }) =>
|
||||
connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
|
||||
)
|
||||
)
|
||||
const respondingHostIds = new Set<ExecutionHostId>()
|
||||
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<ExecutionHostId>(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<ExecutionHostId, string[]>()
|
||||
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<PtyListedSession[]> => {
|
||||
const inventory = await listPtySessionInventory()
|
||||
return inventory.sessions
|
||||
})
|
||||
|
||||
ipc.handle('pty:listSessionInventory', listPtySessionInventory)
|
||||
|
||||
ipc.handle(
|
||||
'pty:getAuthoritativeBufferSnapshotCapabilities',
|
||||
async (_event, args: { ids?: unknown }) => {
|
||||
|
||||
@@ -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<string>
|
||||
getSize: (id: string) => Promise<{ cols: number; rows: number } | null>
|
||||
listSessions: () => Promise<PtyListedSession[]>
|
||||
/**
|
||||
* Detailed additive inventory. Missing on an older main process means
|
||||
* completeness is unknown and consumers must fail safe.
|
||||
*/
|
||||
listSessionInventory?: () => Promise<PtySessionInventorySnapshot>
|
||||
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
|
||||
|
||||
@@ -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<PtyListedSession[]> => ipcRenderer.invoke('pty:listSessions'),
|
||||
listSessionInventory: (): Promise<PtySessionInventorySnapshot> =>
|
||||
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)
|
||||
},
|
||||
|
||||
+9
-11
@@ -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', () => {
|
||||
|
||||
@@ -752,7 +752,7 @@ export function ResourceUsageStatusSegment({
|
||||
clearSessionsError,
|
||||
removeSession,
|
||||
removeSessions
|
||||
} = useResourceSessionInventory(workspaceSessionReady)
|
||||
} = useResourceSessionInventory(workspaceSessionReady, open)
|
||||
const sessions = sessionInventory.sessions
|
||||
const [killConfirm, setKillConfirm] = useState<UnifiedSessionRow | null>(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) {
|
||||
|
||||
@@ -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<PtySessionInventorySnapshot> {
|
||||
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<PtySessionInventorySnapshot> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<string, number>
|
||||
spawnedAtRevision: Map<string, number>
|
||||
currentKnownSessionIds: ReadonlySet<string>
|
||||
currentHostIdBySessionId: ReadonlyMap<string, ExecutionHostId>
|
||||
}
|
||||
|
||||
export type ResourceSessionInventoryReconciliation = {
|
||||
liveSessions: PtyListedSession[]
|
||||
listedSessionIds: Set<string>
|
||||
knownSessionIds: Set<string>
|
||||
hostIdBySessionId: Map<string, ExecutionHostId>
|
||||
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<string, ExecutionHostId>()
|
||||
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<string, ExecutionHostId>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, DaemonSession>()
|
||||
|
||||
export function removeSessionsFromInventory(
|
||||
inventory: DaemonSessionInventory,
|
||||
sessionIds: ReadonlySet<string>
|
||||
): 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<string>): 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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DaemonSession[]>>()
|
||||
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<string, ExecutionHostId>()
|
||||
|
||||
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<DaemonSession[]>()
|
||||
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<DaemonSession[]>()
|
||||
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<DaemonSession[]>()
|
||||
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<DaemonSession[]>()
|
||||
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<DaemonSession[]>()
|
||||
@@ -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<DaemonSession[]>()
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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<string, number>())
|
||||
const spawnedAtRevisionRef = useRef(new Map<string, number>())
|
||||
const knownSessionIdsRef = useRef(new Set<string>())
|
||||
const knownHostIdBySessionIdRef = useRef(new Map<string, ExecutionHostId>())
|
||||
const listedSessionIdsRef = useRef(new Set<string>())
|
||||
const sessionRowsRef = useRef(new ResourceSessionInventoryRows())
|
||||
const previousAuthoritativeInventoryRequestedRef = useRef(authoritativeInventoryRequested)
|
||||
const [storedState, setStoredState] = useState<ResourceSessionInventoryState>(() => ({
|
||||
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,
|
||||
|
||||
@@ -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<string, ExecutionHostId>
|
||||
queriedHostIds: ExecutionHostId[]
|
||||
/** Bounded last-known IDs for unavailable hosts, used by a cold renderer. */
|
||||
retainedSessionIdsByHost: Partial<Record<ExecutionHostId, string[]>>
|
||||
respondingHostIds: ExecutionHostId[]
|
||||
unavailableHostIds: ExecutionHostId[]
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
/** Only proven absence authorizes destroying a session without asking. */
|
||||
export function mayDestroyWithoutOwnerEvidence(session: {
|
||||
agentOwnership: AgentOwnershipEvidence
|
||||
|
||||
Reference in New Issue
Block a user