mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Maintainer rework of #45580 (issue #54036) on top of the contributor's cherry-pick, which targeted spectrum-ts 3.1.0 while main pins 8.0.0: Sidecar (primary detection, new): - stream-staleness.mjs: pure decision rules, executable under node. * classifyProbeRejection: only a not-found-shaped rejection of the synthetic-id read counts as a completed round-trip (ALIVE); any other rejection is INCONCLUSIVE — never alive. The original /probe treated ANY rejection as alive, which was too loose. * shouldProbe: probe only after 10+ min of stream silence (configurable via PHOTON_STREAM_SILENCE_PROBE_MS; <=0 disables) with a cooldown. * isZombieSuspect: zombie only on silence past threshold AND a probe-proven live channel. Silence alone NEVER degrades (shared lines can be quiet for hours); inconclusive probes NEVER degrade (network may be down — the iterator will throw and the re-subscribe loop recovers on its own). - index.mjs: track last inbound-iterator yield (noteInboundYield), run a 30s watchdog tick, and on a confirmed zombie feed markStreamDegraded -> the existing exit-75 restart path. /healthz gains a stream.staleness block (silentForMs, threshold, lastProbeOutcome, zombieSuspected). /probe reworked to strict semantics: 200 only on a proven round-trip, 503 with outcome hung|inconclusive otherwise. Adapter (second layer, reworked): - _probe_once returns tri-state alive|hung|inconclusive; only a hung sidecar HTTP call counts toward the respawn counter — inconclusive resets nothing and triggers nothing. - default probe_interval_seconds 60 -> 600 (conservative; avoid restart storms on quiet lines). - _monitor_sidecar_health surfaces zombieSuspected from /healthz as a warning; the fatal UPSTREAM_STREAM_DEGRADED path is unchanged and fires when the sidecar escalates. Tests: test_zombie_stream_watchdog.py executes the real node decision module and drives the adapter against mocked /healthz responses; test_presence_watchdog.py updated for the tri-state probe. Also adds contributor mappings for nickkarhan (#53283) and vaibhavjnf (#45580).
80 lines
3.8 KiB
JavaScript
80 lines
3.8 KiB
JavaScript
// Pure decision helpers for the zombie-stream (half-open gRPC) watchdog.
|
|
//
|
|
// spectrum-ts only reconnects when its inbound async iterator throws or ends.
|
|
// A half-open ("zombie") socket makes the iterator hang forever — no error,
|
|
// no end — so inbound silently dies while /healthz still looks fine. The
|
|
// watchdog in index.mjs tracks the last time the inbound iterator yielded and,
|
|
// once the stream has been silent past a conservative threshold, drives a
|
|
// cheap authenticated unary read over the same channel. STRICT semantics:
|
|
//
|
|
// - probe resolves, or rejects with a not-found-shaped error for our
|
|
// synthetic id -> ALIVE (the wire round-tripped)
|
|
// - probe rejects any other way (UNAVAILABLE, DEADLINE_EXCEEDED, network
|
|
// down, ...) -> INCONCLUSIVE — never treated as alive, and
|
|
// never treated as zombie-proof either
|
|
//
|
|
// A zombie is only declared when the stream is silent past the threshold AND
|
|
// a probe proves connectivity (the wire works but the stream is deaf). Silence
|
|
// alone NEVER degrades the stream: shared lines can be legitimately quiet for
|
|
// hours. Inconclusive probes NEVER degrade it either: the network may simply
|
|
// be down, and in that case the iterator will eventually throw and the
|
|
// existing re-subscribe loop recovers on its own.
|
|
//
|
|
// These helpers are pure (no SDK, no timers) so tests can execute them under
|
|
// node — see tests/plugins/platforms/photon/test_zombie_stream_watchdog.py.
|
|
|
|
// gRPC NOT_FOUND is code 5; SDKs also surface it as "not found" / "NotFound"
|
|
// message text. Anything not clearly not-found is inconclusive.
|
|
const NOT_FOUND_RE = /not[\s_-]?found/i;
|
|
|
|
/**
|
|
* Classify the rejection of the synthetic-id probe read.
|
|
*
|
|
* @param {unknown} err error thrown by `space.getMessage(<synthetic id>)`
|
|
* @returns {{alive: boolean, inconclusive: boolean, reason: string}}
|
|
*/
|
|
export function classifyProbeRejection(err) {
|
|
const code = err && typeof err === "object" ? err.code : undefined;
|
|
const message =
|
|
err && typeof err === "object" && err.message
|
|
? String(err.message)
|
|
: String(err);
|
|
if (code === 5 || code === "notFound" || NOT_FOUND_RE.test(message)) {
|
|
// Expected: the synthetic id doesn't exist. The unary call completed a
|
|
// round-trip, so the channel is provably alive.
|
|
return { alive: true, inconclusive: false, reason: "not-found round-trip" };
|
|
}
|
|
// Anything else (UNAVAILABLE, DEADLINE_EXCEEDED, TLS, auth, ...) does NOT
|
|
// prove liveness — and doesn't prove a zombie either.
|
|
return { alive: false, inconclusive: true, reason: message };
|
|
}
|
|
|
|
/**
|
|
* Should the watchdog probe at all this tick?
|
|
*
|
|
* @param {number} silentForMs ms since the inbound iterator last yielded
|
|
* @param {number} thresholdMs silence threshold (<= 0 disables the watchdog)
|
|
* @param {number} sinceLastProbeMs ms since the previous probe attempt
|
|
* @param {number} probeCooldownMs min spacing between probe attempts
|
|
* @returns {boolean}
|
|
*/
|
|
export function shouldProbe(silentForMs, thresholdMs, sinceLastProbeMs, probeCooldownMs) {
|
|
if (!(thresholdMs > 0)) return false;
|
|
if (silentForMs < thresholdMs) return false;
|
|
return sinceLastProbeMs >= probeCooldownMs;
|
|
}
|
|
|
|
/**
|
|
* Final classification: zombie only on silence past threshold + probe-proven
|
|
* connectivity. Never on silence alone, never on an inconclusive probe.
|
|
*
|
|
* @param {number} silentForMs ms since the inbound iterator last yielded
|
|
* @param {number} thresholdMs silence threshold (<= 0 disables the watchdog)
|
|
* @param {{alive: boolean}} probeOutcome
|
|
* @returns {boolean}
|
|
*/
|
|
export function isZombieSuspect(silentForMs, thresholdMs, probeOutcome) {
|
|
if (!(thresholdMs > 0)) return false;
|
|
if (silentForMs < thresholdMs) return false;
|
|
return probeOutcome != null && probeOutcome.alive === true;
|
|
}
|