mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
A pooled backend entry pointing at a remote host has no child process, so the 'exit' handler that clears a dead local backend never fires. The renderer's 60s keepalive touch also spares it from the idle reaper. Nothing was left to retire the descriptor, so once the host went away the pool kept serving it and every profile bound to that host stayed broken until restart. Pooled remote descriptors now share the primary's liveness policy: probed on the same revalidate tick, keyed per base URL, and dropped only after the same consecutive-failure limit, so the next ensureBackend() rebuilds. Co-authored-by: Rodrigo Fernandez <rod@nxtlevel.dev>
244 lines
7.6 KiB
TypeScript
244 lines
7.6 KiB
TypeScript
export const REMOTE_LIVENESS_TIMEOUT_MS = 10_000
|
|
export const REMOTE_LIVENESS_FAILURE_LIMIT = 3
|
|
// Even at the capped retry path, consecutive liveness observations are at most
|
|
// about 48s apart (ticket mint + socket open + backoff + the next status probe).
|
|
// One minute keeps a continuous outage together without carrying old failures.
|
|
export const REMOTE_LIVENESS_FAILURE_WINDOW_MS = 60_000
|
|
|
|
export interface RemoteLivenessFailure {
|
|
failures: number
|
|
shouldReset: boolean
|
|
}
|
|
|
|
interface RemoteConnectionDescriptor {
|
|
baseUrl?: null | string
|
|
mode?: null | string
|
|
}
|
|
|
|
export interface RevalidateRemoteConnectionOptions<TConnection extends RemoteConnectionDescriptor> {
|
|
connectionPromise: Promise<TConnection>
|
|
currentConnectionPromise: () => null | Promise<TConnection>
|
|
log: (message: string) => void
|
|
probe: (url: string, options: { timeoutMs: number }) => Promise<unknown>
|
|
resetConnection: () => void
|
|
tracker: RemoteLivenessTracker
|
|
}
|
|
|
|
export interface RemoteRevalidationResult {
|
|
ok: true
|
|
rebuilt: boolean
|
|
}
|
|
|
|
/**
|
|
* Coalesces revalidation work for one cached connection promise.
|
|
*
|
|
* Every Desktop BrowserWindow owns a renderer gateway loop. When several
|
|
* windows observe the same disconnect they can all ask the Electron main
|
|
* process to revalidate the shared primary connection at once. Those calls
|
|
* must count as one probe, not several consecutive failures.
|
|
*/
|
|
export class RemoteRevalidationCoordinator {
|
|
readonly #inflightByConnection = new WeakMap<object, Promise<unknown>>()
|
|
|
|
run<T>(connection: object, task: () => Promise<T>): Promise<T> {
|
|
const existing = this.#inflightByConnection.get(connection) as Promise<T> | undefined
|
|
|
|
if (existing) {
|
|
return existing
|
|
}
|
|
|
|
const pending = Promise.resolve().then(task)
|
|
|
|
const clear = () => {
|
|
if (this.#inflightByConnection.get(connection) === pending) {
|
|
this.#inflightByConnection.delete(connection)
|
|
}
|
|
}
|
|
|
|
this.#inflightByConnection.set(connection, pending)
|
|
// Clean up on both outcomes without creating an unhandled rejected branch.
|
|
void pending.then(clear, clear)
|
|
|
|
return pending
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tracks consecutive remote liveness failures independently per gateway.
|
|
* A successful probe clears the streak, and reaching the limit consumes it so
|
|
* a rebuilt connection starts from a clean state.
|
|
*/
|
|
export class RemoteLivenessTracker {
|
|
readonly #failureLimit: number
|
|
readonly #failureWindowMs: number
|
|
readonly #failuresByBaseUrl = new Map<string, { failures: number; lastFailureAt: number }>()
|
|
readonly #now: () => number
|
|
|
|
constructor(
|
|
failureLimit = REMOTE_LIVENESS_FAILURE_LIMIT,
|
|
failureWindowMs = REMOTE_LIVENESS_FAILURE_WINDOW_MS,
|
|
now: () => number = Date.now
|
|
) {
|
|
if (!Number.isInteger(failureLimit) || failureLimit < 1) {
|
|
throw new Error('Remote liveness failure limit must be a positive integer.')
|
|
}
|
|
|
|
if (!Number.isFinite(failureWindowMs) || failureWindowMs < 1) {
|
|
throw new Error('Remote liveness failure window must be positive.')
|
|
}
|
|
|
|
this.#failureLimit = failureLimit
|
|
this.#failureWindowMs = failureWindowMs
|
|
this.#now = now
|
|
}
|
|
|
|
recordSuccess(baseUrl: string): void {
|
|
this.#failuresByBaseUrl.delete(baseUrl)
|
|
}
|
|
|
|
recordFailure(baseUrl: string): RemoteLivenessFailure {
|
|
const now = this.#now()
|
|
const previous = this.#failuresByBaseUrl.get(baseUrl)
|
|
const withinFailureWindow = previous && now - previous.lastFailureAt <= this.#failureWindowMs
|
|
const failures = (withinFailureWindow ? previous.failures : 0) + 1
|
|
const shouldReset = failures >= this.#failureLimit
|
|
|
|
if (shouldReset) {
|
|
this.#failuresByBaseUrl.delete(baseUrl)
|
|
} else {
|
|
this.#failuresByBaseUrl.set(baseUrl, { failures, lastFailureAt: now })
|
|
}
|
|
|
|
return { failures, shouldReset }
|
|
}
|
|
|
|
clear(): void {
|
|
this.#failuresByBaseUrl.clear()
|
|
}
|
|
}
|
|
|
|
export interface PooledRemoteEntry {
|
|
process?: unknown
|
|
remoteBaseUrl?: null | string
|
|
}
|
|
|
|
export interface RevalidatePooledRemoteBackendsOptions {
|
|
entries: Iterable<[string, PooledRemoteEntry]>
|
|
log: (message: string) => void
|
|
probe: (url: string, options: { timeoutMs: number }) => Promise<unknown>
|
|
stopBackend: (profile: string) => void
|
|
tracker: RemoteLivenessTracker
|
|
}
|
|
|
|
/**
|
|
* Probe pooled REMOTE descriptors and drop the dead ones.
|
|
*
|
|
* A pooled entry backed by a remote host has no child process, so the 'exit'
|
|
* handler that clears a dead local backend never fires, and the renderer's
|
|
* keepalive touch keeps the idle reaper off it. Without this the pool serves a
|
|
* descriptor for an unreachable host indefinitely.
|
|
*
|
|
* Entries share the primary's failure policy, keyed per base URL, so a profile
|
|
* pointing at the same host as another does not burn the streak twice as fast.
|
|
*/
|
|
export async function revalidatePooledRemoteBackends({
|
|
entries,
|
|
log,
|
|
probe,
|
|
stopBackend,
|
|
tracker
|
|
}: RevalidatePooledRemoteBackendsOptions): Promise<{ dropped: string[] }> {
|
|
const remotes = [...entries].filter(([, entry]) => !entry.process && entry.remoteBaseUrl)
|
|
const dropped: string[] = []
|
|
|
|
await Promise.all(
|
|
remotes.map(async ([profile, entry]) => {
|
|
const baseUrl = String(entry.remoteBaseUrl).replace(/\/+$/, '')
|
|
|
|
try {
|
|
await probe(`${baseUrl}/api/status`, { timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS })
|
|
tracker.recordSuccess(baseUrl)
|
|
} catch {
|
|
const failure = tracker.recordFailure(baseUrl)
|
|
|
|
if (!failure.shouldReset) {
|
|
log(
|
|
`Pooled remote backend for profile "${profile}" failed liveness probe (${failure.failures}/${REMOTE_LIVENESS_FAILURE_LIMIT}); keeping descriptor for retry.`
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
log(`Pooled remote backend for profile "${profile}" failed liveness probe; dropping stale descriptor.`)
|
|
stopBackend(profile)
|
|
dropped.push(profile)
|
|
}
|
|
})
|
|
)
|
|
|
|
return { dropped }
|
|
}
|
|
|
|
/**
|
|
* Probe the cached primary remote connection and apply the failure policy.
|
|
* The caller owns single-flight coordination; identity checks here ensure an
|
|
* old async result cannot mutate or reset a replacement connection.
|
|
*/
|
|
export async function revalidateRemoteConnection<TConnection extends RemoteConnectionDescriptor>({
|
|
connectionPromise,
|
|
currentConnectionPromise,
|
|
log,
|
|
probe,
|
|
resetConnection,
|
|
tracker
|
|
}: RevalidateRemoteConnectionOptions<TConnection>): Promise<RemoteRevalidationResult> {
|
|
let connection: TConnection
|
|
|
|
try {
|
|
connection = await connectionPromise
|
|
} catch {
|
|
// The cached boot already rejected; its own recovery path will clear it.
|
|
return { ok: true, rebuilt: false }
|
|
}
|
|
|
|
if (currentConnectionPromise() !== connectionPromise) {
|
|
return { ok: true, rebuilt: false }
|
|
}
|
|
|
|
if (connection.mode !== 'remote' || !connection.baseUrl) {
|
|
return { ok: true, rebuilt: false }
|
|
}
|
|
|
|
const baseUrl = connection.baseUrl.replace(/\/+$/, '')
|
|
|
|
try {
|
|
await probe(`${baseUrl}/api/status`, { timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS })
|
|
|
|
if (currentConnectionPromise() !== connectionPromise) {
|
|
return { ok: true, rebuilt: false }
|
|
}
|
|
|
|
tracker.recordSuccess(baseUrl)
|
|
|
|
return { ok: true, rebuilt: false }
|
|
} catch {
|
|
if (currentConnectionPromise() !== connectionPromise) {
|
|
return { ok: true, rebuilt: false }
|
|
}
|
|
|
|
const failure = tracker.recordFailure(baseUrl)
|
|
|
|
if (!failure.shouldReset) {
|
|
log(
|
|
`Cached remote Hermes backend failed liveness probe (${failure.failures}/${REMOTE_LIVENESS_FAILURE_LIMIT}); keeping connection for retry.`
|
|
)
|
|
|
|
return { ok: true, rebuilt: false }
|
|
}
|
|
|
|
log('Cached remote Hermes backend failed liveness probe; dropping stale connection.')
|
|
resetConnection()
|
|
|
|
return { ok: true, rebuilt: true }
|
|
}
|
|
}
|