fix(agents): re-list a host's terminal agents when contact returns (#33816)

* fix(agents): re-list a host's terminal agents when contact returns after a failed probe

* fix(agents): list recordKey in the detection effect's dependencies
This commit is contained in:
Neil
2026-10-26 00:38:42 -07:00
committed by GitHub
parent 56025f21a4
commit 83ca5b6b6f
2 changed files with 207 additions and 1 deletions
@@ -0,0 +1,182 @@
// @vitest-environment happy-dom
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { useDetectedAgents, type UseDetectedAgentsResult } from './useDetectedAgents'
import {
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
RUNTIME_PROTOCOL_VERSION
} from '../../../shared/protocol-version'
import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments'
import type { RuntimeStatus } from '../../../shared/runtime-types'
import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client'
globalThis.IS_REACT_ACT_ENVIRONMENT = true
const runtimeEnvironmentCall = vi.fn()
const initialAppState = useAppStore.getInitialState()
const roots: Root[] = []
let latest: UseDetectedAgentsResult | null = null
function HookProbe(): null {
latest = useDetectedAgents({ kind: 'runtime', environmentId: 'env-1' })
return null
}
async function flushEffects(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
async function renderProbe(): Promise<void> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
await act(async () => {
root.render(createElement(HookProbe))
})
await flushEffects()
}
function environment(pairingRevision: number): PublicKnownRuntimeEnvironment {
return {
id: 'env-1',
name: 'Paired host',
createdAt: 1,
updatedAt: pairingRevision,
pairingRevision,
lastUsedAt: null,
runtimeId: null,
endpoints: [],
preferredEndpointId: 'host'
}
}
function reply(method: string, result: unknown) {
return { id: method, ok: true, result, _meta: { runtimeId: 'remote-runtime' } }
}
const STATUS = {
runtimeId: 'remote-runtime',
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0,
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
}
function mockHost(detect: (call: number, method: string) => Promise<unknown>): () => number {
let detectCalls = 0
runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => {
if (method === 'status.get') {
return Promise.resolve(reply(method, STATUS))
}
detectCalls += 1
return detect(detectCalls, method)
})
return () => detectCalls
}
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
useAppStore.setState(initialAppState, true)
latest = null
runtimeEnvironmentCall.mockReset()
Object.defineProperty(globalThis.window, 'api', {
configurable: true,
value: {
preflight: { detectAgents: vi.fn(), detectRemoteAgents: vi.fn(), refreshAgents: vi.fn() },
runtimeEnvironments: { call: runtimeEnvironmentCall },
platform: { get: () => ({ platform: 'win32' }) }
}
})
})
afterEach(async () => {
for (const root of roots) {
await act(async () => {
root.unmount()
})
}
roots.length = 0
})
const LIVE_STATUS: RuntimeStatus = { ...STATUS, graphStatus: 'ready' }
function setHostContact(entry: { live: boolean; hostContactEpoch: number }): void {
const runtimeStatusByEnvironmentId = new Map(useAppStore.getState().runtimeStatusByEnvironmentId)
runtimeStatusByEnvironmentId.set('env-1', {
status: entry.live ? LIVE_STATUS : null,
checkedAt: entry.hostContactEpoch,
connectionGeneration: 1,
hostContactEpoch: entry.hostContactEpoch
})
useAppStore.setState({ runtimeStatusByEnvironmentId })
}
describe('useDetectedAgents when the runtime host reconnects', () => {
it('re-lists agents once contact returns after a failed probe, and not while the host is down', async () => {
let hostUp = false
const detectCalls = mockHost((_call, method) =>
hostUp
? Promise.resolve(reply(method, ['codex']))
: Promise.reject(new Error('The WSL host isn’t connected.'))
)
useAppStore.getState().setRuntimeEnvironments([environment(1)])
setHostContact({ live: false, hostContactEpoch: 1 })
await renderProbe()
await flushEffects()
expect(latest?.detectionFailed).toBe(true)
const failedCalls = detectCalls()
// A down host is not probed again: re-listing must never boot a stopped distro.
await act(async () => {
setHostContact({ live: false, hostContactEpoch: 2 })
})
await flushEffects()
expect(detectCalls()).toBe(failedCalls)
expect(latest?.detectionFailed).toBe(true)
hostUp = true
await act(async () => {
setHostContact({ live: true, hostContactEpoch: 3 })
})
await flushEffects()
await flushEffects()
expect(detectCalls()).toBe(failedCalls + 1)
expect(latest?.detectedIds).toEqual(['codex'])
expect(latest?.detectionFailed).toBe(false)
})
it('re-lists after a reconnect even when the failed probe ran while contact looked live', async () => {
let hostUp = false
const detectCalls = mockHost((_call, method) =>
hostUp
? Promise.resolve(reply(method, ['codex']))
: Promise.reject(new Error('runtime disconnected'))
)
useAppStore.getState().setRuntimeEnvironments([environment(1)])
setHostContact({ live: true, hostContactEpoch: 1 })
await renderProbe()
await flushEffects()
expect(latest?.detectionFailed).toBe(true)
hostUp = true
await act(async () => {
setHostContact({ live: true, hostContactEpoch: 2 })
})
await flushEffects()
await flushEffects()
expect(detectCalls()).toBeGreaterThanOrEqual(2)
expect(latest?.detectedIds).toEqual(['codex'])
})
})
+25 -1
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef } from 'react'
import { useAppStore } from '@/store'
import { runtimeHostContactForEntry } from '../../../shared/runtime-host-contact'
import type { TuiAgent } from '../../../shared/tui-agent'
import { getRuntimeAgentInventoryKey } from '@/store/slices/runtime-agent-inventory-key'
@@ -75,6 +76,21 @@ export function useDetectedAgents(
return environment ? (environment.pairingRevision ?? environment.createdAt) : undefined
})
// Why: a probe that failed while the host was down must re-run once contact returns, and only then.
// 'down' only when the host's status says so; a never-asked host is still probed as before.
const runtimeContactEpoch = useAppStore((s) => {
if (targetKind !== 'runtime' || !targetId) {
return null
}
const entry = s.runtimeStatusByEnvironmentId.get(targetId)
if (!entry) {
return null
}
return runtimeHostContactForEntry(entry).verdict === 'live'
? `${entry.connectionGeneration ?? 0}:${entry.hostContactEpoch ?? 0}`
: 'down'
})
const detectedIds = useAppStore((s) => {
if (isUnknown) {
return null
@@ -167,8 +183,14 @@ export function useDetectedAgents(
void state.ensureRemoteDetectedAgents(targetId)
}
} else if (targetKind === 'runtime' && targetId) {
const failedWhileOffline =
runtimeContactEpoch === 'down' &&
recordKey !== null &&
state.runtimeAgentInventoryOutcome(recordKey) === 'failed'
if (detectedIds === null) {
void state.ensureRuntimeDetectedAgents(targetId, runtimeWorktreeId)
if (!failedWhileOffline) {
void state.ensureRuntimeDetectedAgents(targetId, runtimeWorktreeId)
}
} else if (isNewRemoteTarget && detectedIds.length > 0) {
// Refresh once when a new launch surface observes a host; cached inventory may predate installs.
void state.refreshRuntimeDetectedAgents(targetId, runtimeWorktreeId)
@@ -186,7 +208,9 @@ export function useDetectedAgents(
targetKind,
targetId,
remoteTargetKey,
recordKey,
pairingRevision,
runtimeContactEpoch,
detectedIds,
localProbePending,
localWorktreeId,