Add relay health preflight job and scheduler deferral handling

This commit is contained in:
thePR0M3TH3AN committed 2026-02-14 19:35:56 -05:00
1 parent 37ee9eb0f2
commit 1e97118e8b
11 files changed
+458 -89

No files matched your search

+4 -3
View File
@@ -30,7 +30,8 @@ Track only **active, reproducible, unresolved** issues.
- **Area:** Runtime
- **Symptom:** Multiple scheduler runs fail with `Lock backend error` before prompt handoff/validation.
- **Trigger/Conditions:** Running scheduler cycles when relay connectivity is unstable or lock backend operations time out; observed in both `daily` and `weekly` task logs.
- **Workaround:** Retry with explicit platform and inspect reliability report output (`npm run report:lock-reliability`), then validate relay health (`node scripts/agent/check-relay-health.mjs --cadence <daily|weekly>`) before rerunning scheduler.
- **Impact:** Scheduled agent execution is skipped for affected cycles until lock acquisition succeeds.
- **Last verified:** 2026-02-14
- **Workaround:** Run relay preflight (`npm run lock:health -- --cadence <daily|weekly>`) and inspect `task-logs/relay-health/<cadence>.jsonl` for trend/alert data before rerunning scheduler. If scheduler logs `All relays unhealthy preflight`, treat it as an incident signal, defer lock attempts, and escalate relay/network checks (DNS/TLS/connectivity).
- **Impact:** Scheduled agent execution may be deferred early when all relays are unhealthy, preventing noisy lock retries until relay health recovers.
- **Related notes:** `docs/agent-handoffs/learnings/2026-02-15-relay-health-preflight-job.md`
- **Last verified:** 2026-02-15
+1
View File
@@ -124,6 +124,7 @@ If you are developing `torch-lock` itself:
- `npm run lock:check:daily`
- `npm run lock:check:weekly`
- `npm run lock:list`
- `npm run lock:health -- --cadence daily` (relay websocket + publish/read probe; writes history to `task-logs/relay-health/`)
- `npm run dashboard:serve`
- `npm test` (run unit tests)
- `npm run lint` (run linter)
@@ -0,0 +1,26 @@
# Relay health preflight job before scheduler lock acquisition
## Context
- Scheduler runs were intermittently failing during lock acquisition when relay connectivity degraded.
- Existing preflight checks only validated lock query behavior and did not preserve health history for trend analysis.
## Observation
- A dedicated relay probe that checks websocket reachability plus publish/read behavior per relay provides a clearer readiness signal before lock acquisition.
- Persisting each probe result enables windowed success-rate alerts and detection of prolonged all-relay outages.
## Action taken
- Added `torch-lock health` / `npm run lock:health` to run relay websocket + publish/read probes per relay.
- Integrated scheduler preflight to call `lock:health` immediately before lock acquisition.
- Updated scheduler behavior to defer early (status `_deferred`) with reason `All relays unhealthy preflight` and incident metadata when no relay is healthy.
- Stored probe history in `task-logs/relay-health/<cadence>.jsonl` and evaluated alert thresholds:
- all relays down for more than N minutes,
- success rate below X% over a rolling time window.
## Validation performed
- Ran targeted unit tests for relay health threshold evaluation.
- Ran scheduler flow parity checks to ensure preflight remains before lock acquisition in required ordering.
## Recommendation for next agents
- Keep `lock:health` enabled for scheduler preflight in automation.
- Review `task-logs/relay-health/<cadence>.jsonl` during incidents to distinguish transient single-relay flakiness from full relay outages.
- Tune threshold defaults (`--all-relays-down-minutes`, `--min-success-rate`, `--window-minutes`) per environment reliability goals.
+2
View File
@@ -16,6 +16,7 @@
"src/dashboard.mjs",
"src/roster.mjs",
"src/torch-config.mjs",
"src/relay-health.mjs",
"src/prompts/",
"src/docs/",
"scripts/check-file-size.mjs",
@@ -32,6 +33,7 @@
"lock:check:weekly": "node bin/torch-lock.mjs check --cadence weekly",
"lock:list": "node bin/torch-lock.mjs list",
"lock:lock": "node bin/torch-lock.mjs lock",
"lock:health": "node bin/torch-lock.mjs health",
"dashboard:serve": "node bin/torch-lock.mjs dashboard",
"build": "node build.mjs",
"lint": "eslint .",
+29 -77
View File
@@ -1,10 +1,14 @@
#!/usr/bin/env node
import { getRelays, getNamespace, getQueryTimeoutMs } from '../../src/torch-config.mjs';
import { queryLocks } from '../../src/lock-ops.mjs';
import { todayDateStr } from '../../src/utils.mjs';
import { runRelayHealthCheck } from '../../src/relay-health.mjs';
function parseArgs(argv) {
const args = { cadence: 'daily' };
const args = {
cadence: 'daily',
timeoutMs: 6000,
allRelaysDownMinutes: 10,
minSuccessRate: 0.7,
windowMinutes: 60,
};
for (let i = 0; i < argv.length; i += 1) {
const value = argv[i];
if (!value.startsWith('--') && !args.cadence) {
@@ -14,93 +18,41 @@ function parseArgs(argv) {
if (value === '--cadence') {
args.cadence = argv[i + 1] || args.cadence;
i += 1;
} else if (value === '--timeout-ms') {
args.timeoutMs = Number.parseInt(argv[i + 1], 10) || args.timeoutMs;
i += 1;
} else if (value === '--all-relays-down-minutes') {
args.allRelaysDownMinutes = Number.parseInt(argv[i + 1], 10) || args.allRelaysDownMinutes;
i += 1;
} else if (value === '--min-success-rate') {
const parsed = Number.parseFloat(argv[i + 1]);
args.minSuccessRate = Number.isFinite(parsed) ? parsed : args.minSuccessRate;
i += 1;
} else if (value === '--window-minutes') {
args.windowMinutes = Number.parseInt(argv[i + 1], 10) || args.windowMinutes;
i += 1;
}
}
return args;
}
function classifyRelayFailure(outputText) {
const text = String(outputText || '').toLowerCase();
if (!text.trim()) return 'unknown backend error';
if ((text.includes('relay') || text.includes('query')) && text.includes('timeout')) {
return 'relay query timeout';
}
if (
text.includes('connection refused')
|| text.includes('econnrefused')
|| text.includes('getaddrinfo')
|| text.includes('enotfound')
|| text.includes('eai_again')
|| (text.includes('websocket') && text.includes('dns'))
) {
return 'websocket connection refused/dns';
}
if (
text.includes('invalid url')
|| text.includes('malformed')
|| text.includes('unsupported protocol')
|| text.includes('must start with ws')
|| text.includes('invalid relay')
) {
return 'malformed relay url/config';
}
return 'unknown backend error';
}
async function main() {
const { cadence } = parseArgs(process.argv.slice(2));
if (cadence !== 'daily' && cadence !== 'weekly') {
const args = parseArgs(process.argv.slice(2));
if (args.cadence !== 'daily' && args.cadence !== 'weekly') {
console.error('Usage: node scripts/agent/check-relay-health.mjs --cadence <daily|weekly>');
process.exit(1);
}
const relays = getRelays();
const namespace = getNamespace();
const timeoutMs = getQueryTimeoutMs();
const dateStr = todayDateStr();
try {
const locks = await queryLocks(relays, cadence, dateStr, namespace);
console.log(JSON.stringify({
ok: true,
cadence,
namespace,
timeoutMs,
relays,
lockCount: locks.length,
}));
process.exit(0);
} catch (error) {
const message = error instanceof Error ? error.message : String(error ?? 'unknown error');
const failureCategory = classifyRelayFailure(message);
console.log(JSON.stringify({
ok: false,
cadence,
namespace,
timeoutMs,
relays,
failureCategory,
error: message,
}));
process.exit(2);
const result = await runRelayHealthCheck(args);
if (!result.ok) {
result.failureCategory = 'all relays unhealthy';
}
console.log(JSON.stringify(result));
process.exit(result.ok ? 0 : 2);
}
main().catch((error) => {
const message = error instanceof Error ? error.message : String(error ?? 'unknown error');
const relays = getRelays();
const namespace = getNamespace();
const failureCategory = classifyRelayFailure(message);
console.log(JSON.stringify({
ok: false,
namespace,
relays,
failureCategory,
error: message,
}));
console.log(JSON.stringify({ ok: false, failureCategory: 'health check failed', error: message }));
process.exit(2);
});
+15 -5
View File
@@ -417,7 +417,7 @@ function createIdempotencyKey({ cadence, selectedAgent, runDate }) {
}
async function runLockHealthPreflight({ cadence, platform }) {
const preflight = await runCommand('node', ['scripts/agent/check-relay-health.mjs', '--cadence', cadence], {
const preflight = await runCommand('npm', ['run', 'lock:health', '--', '--cadence', cadence], {
env: { AGENT_PLATFORM: platform },
});
const payload = parseJsonFromOutput(`${preflight.stdout}\n${preflight.stderr}`) || {};
@@ -593,22 +593,32 @@ async function main() {
if (schedulerConfig.lockHealthPreflight.enabled && !schedulerConfig.lockHealthPreflight.skip) {
const preflight = await runLockHealthPreflight({ cadence, platform });
if (preflight.code !== 0) {
const allRelaysUnhealthy = Boolean(preflight.payload?.summary?.allRelaysUnhealthy);
const incidentSignal = preflight.payload?.incidentSignal || null;
await writeLog({
cadence,
agent: 'scheduler',
status: 'failed',
status: allRelaysUnhealthy ? 'deferred' : 'failed',
platform,
reason: 'Lock backend unavailable preflight',
detail: `Preflight failed (${preflight.failureCategory}). Retry scheduler after verifying relay connectivity, relay URLs, and DNS/network status.`,
reason: allRelaysUnhealthy
? 'All relays unhealthy preflight'
: 'Lock backend unavailable preflight',
detail: allRelaysUnhealthy
? `Deferred run before lock acquisition: ${incidentSignal?.reason || 'all relays unhealthy'}.`
: `Preflight failed (${preflight.failureCategory}). Retry scheduler after verifying relay connectivity, relay URLs, and DNS/network status.`,
metadata: {
failure_class: 'backend_unavailable',
preflight_failure_category: preflight.failureCategory,
relay_list: preflight.relayList.join(', ') || '(none)',
preflight_stderr_excerpt: preflight.stderrExcerpt || '(empty)',
preflight_stdout_excerpt: preflight.stdoutExcerpt || '(empty)',
incident_signal_id: incidentSignal?.id || null,
incident_signal_severity: incidentSignal?.severity || null,
preflight_alerts: JSON.stringify(preflight.payload?.alerts || []),
relay_health_history_path: preflight.payload?.historyPath || null,
},
});
process.exit(preflight.code || 1);
process.exit(allRelaysUnhealthy ? 0 : (preflight.code || 1));
}
}
+4 -1
View File
@@ -13,6 +13,9 @@ AGENT_PLATFORM=codex node bin/torch-lock.mjs lock --agent docs-agent --cadence d
# List all active locks
node bin/torch-lock.mjs list
# Probe relay health before scheduler lock acquisition
node bin/torch-lock.mjs health --cadence daily
```
## Defaults
@@ -77,7 +80,7 @@ Operational note: scheduler handoff commands are treated as required execution s
Scheduler failure classes in task logs:
- `backend_unavailable` — lock backend unavailable preflight failures and lock acquisition backend exit code `2` failures/deferrals.
- `backend_unavailable` — lock backend unavailable preflight failures and lock acquisition backend exit code `2` failures/deferrals; includes relay health alert metadata and incident signal IDs when all relays are unhealthy.
- `prompt_validation_error` — prompt/handoff, memory evidence, artifact verification, and validation command failures.
+42
View File
@@ -32,6 +32,7 @@ import {
} from './services/memory/index.js';
import { ExitError } from './errors.mjs';
import { todayDateStr, nowUnix, getIsoWeekStr } from './utils.mjs';
import { runRelayHealthCheck } from './relay-health.mjs';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -527,6 +528,10 @@ function parseArgs(argv) {
offset: null,
retentionMs: null,
windowMs: null,
timeoutMs: null,
allRelaysDownMinutes: null,
minSuccessRate: null,
windowMinutes: null,
};
let i = 0;
@@ -589,6 +594,22 @@ function parseArgs(argv) {
args.windowMs = parseInt(argv[++i], 10);
} else if (arg.startsWith('--window-ms=')) {
args.windowMs = parseInt(arg.split('=')[1], 10);
} else if (arg === '--timeout-ms') {
args.timeoutMs = parseInt(argv[++i], 10);
} else if (arg.startsWith('--timeout-ms=')) {
args.timeoutMs = parseInt(arg.split('=')[1], 10);
} else if (arg === '--all-relays-down-minutes') {
args.allRelaysDownMinutes = parseInt(argv[++i], 10);
} else if (arg.startsWith('--all-relays-down-minutes=')) {
args.allRelaysDownMinutes = parseInt(arg.split('=')[1], 10);
} else if (arg === '--min-success-rate') {
args.minSuccessRate = parseFloat(argv[++i]);
} else if (arg.startsWith('--min-success-rate=')) {
args.minSuccessRate = parseFloat(arg.split('=')[1]);
} else if (arg === '--window-minutes') {
args.windowMinutes = parseInt(argv[++i], 10);
} else if (arg.startsWith('--window-minutes=')) {
args.windowMinutes = parseInt(arg.split('=')[1], 10);
}
}
@@ -603,6 +624,7 @@ Commands:
lock --agent <name> --cadence <daily|weekly> Claim a lock
complete --agent <name> --cadence <daily|weekly> Mark task as completed (permanent)
list [--cadence <daily|weekly>] Print active lock table
health --cadence <daily|weekly> Probe relay websocket + publish/read health
dashboard [--port <port>] Serve the dashboard (default: ${DEFAULT_DASHBOARD_PORT})
init [--force] Initialize torch/ directory in current project
update [--force] Update torch/ configuration (backups, merges)
@@ -697,6 +719,26 @@ export async function main(argv) {
break;
}
case 'health': {
if (!args.cadence || !VALID_CADENCES.has(args.cadence)) {
console.error(`ERROR: --cadence <${[...VALID_CADENCES].join('|')}> is required for health`);
throw new ExitError(1, 'Missing cadence');
}
const result = await runRelayHealthCheck({
cadence: args.cadence,
...(Number.isFinite(args.timeoutMs) ? { timeoutMs: args.timeoutMs } : {}),
...(Number.isFinite(args.allRelaysDownMinutes) ? { allRelaysDownMinutes: args.allRelaysDownMinutes } : {}),
...(Number.isFinite(args.minSuccessRate) ? { minSuccessRate: args.minSuccessRate } : {}),
...(Number.isFinite(args.windowMinutes) ? { windowMinutes: args.windowMinutes } : {}),
});
if (!result.ok) {
result.failureCategory = 'all relays unhealthy';
}
console.log(JSON.stringify(result, null, 2));
if (!result.ok) throw new ExitError(2, 'Relay health check failed');
break;
}
case 'dashboard': {
await cmdDashboard(args.port);
break;
+4 -3
View File
@@ -81,16 +81,17 @@ Every agent prompt invoked by the schedulers (daily/weekly) MUST enforce this co
5. When lock health preflight is enabled (`scheduler.lockHealthPreflight: true` or env `SCHEDULER_LOCK_HEALTH_PREFLIGHT=1`), verify relay/query health before selecting an agent or calling `lock:lock`:
```bash
node scripts/agent/check-relay-health.mjs --cadence <cadence>
npm run lock:health -- --cadence <cadence>
```
- Escape hatch: set `SCHEDULER_SKIP_LOCK_HEALTH_PREFLIGHT=1` to skip this check for local/offline workflows.
- If preflight exits non-zero, write `_failed.md` with reason `Lock backend unavailable preflight` and include:
- If preflight exits non-zero because every relay is unhealthy, write `_deferred.md` with reason `All relays unhealthy preflight`, include `incident_signal_id`, and stop before lock acquisition.
- For other non-zero preflight failures, write `_failed.md` with reason `Lock backend unavailable preflight` and include:
- `relay_list`
- `preflight_failure_category`
- `preflight_stderr_excerpt`
- `preflight_stdout_excerpt`
- Stop the run immediately when preflight fails.
- Always include any preflight alert payload (`preflight_alerts`) in the scheduler metadata for operational triage.
6. Find latest cadence log file, derive the previous agent, then choose the next roster agent not in exclusion set:
+281
View File
@@ -0,0 +1,281 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import WebSocket from 'ws';
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
import { getNamespace, getRelays } from './torch-config.mjs';
const PROBE_KIND = 27235;
function withTimeout(promise, timeoutMs, message) {
let timeoutId;
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
});
return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timeoutId));
}
function nowIso() {
return new Date().toISOString();
}
export function summarizeHistory(historyEntries, { nowMs = Date.now(), windowMinutes = 60 } = {}) {
const windowStartMs = nowMs - (windowMinutes * 60_000);
const recent = historyEntries.filter((entry) => Date.parse(entry.timestamp || '') >= windowStartMs);
const relayProbeCount = recent.reduce((sum, entry) => sum + (entry.summary?.totalRelays || 0), 0);
const successCount = recent.reduce((sum, entry) => sum + (entry.summary?.healthyRelays || 0), 0);
const successRate = relayProbeCount > 0 ? successCount / relayProbeCount : 1;
let lastHealthyAtMs = null;
for (let i = historyEntries.length - 1; i >= 0; i -= 1) {
if ((historyEntries[i].summary?.healthyRelays || 0) > 0) {
lastHealthyAtMs = Date.parse(historyEntries[i].timestamp || '');
break;
}
}
const allDownDurationMinutes = lastHealthyAtMs === null
? null
: Math.max(0, Math.floor((nowMs - lastHealthyAtMs) / 60_000));
return {
windowMinutes,
sampleCount: recent.length,
successRate,
allDownDurationMinutes,
};
}
export function evaluateAlertThresholds(currentResult, historyEntries, thresholds, { nowMs = Date.now() } = {}) {
const stats = summarizeHistory(historyEntries, { nowMs, windowMinutes: thresholds.windowMinutes });
const alerts = [];
if (currentResult.summary.allRelaysUnhealthy && stats.allDownDurationMinutes !== null && stats.allDownDurationMinutes >= thresholds.allRelaysDownMinutes) {
alerts.push({
type: 'all_relays_down_duration',
thresholdMinutes: thresholds.allRelaysDownMinutes,
actualMinutes: stats.allDownDurationMinutes,
severity: 'critical',
});
}
if (stats.sampleCount > 0 && stats.successRate < thresholds.minSuccessRate) {
alerts.push({
type: 'relay_success_rate_below_threshold',
threshold: thresholds.minSuccessRate,
actual: Number(stats.successRate.toFixed(4)),
windowMinutes: thresholds.windowMinutes,
severity: 'warning',
});
}
return { alerts, stats };
}
async function probeWebSocketReachability(relayUrl, timeoutMs) {
const startedAt = Date.now();
return withTimeout(new Promise((resolve, reject) => {
const ws = new WebSocket(relayUrl);
let settled = false;
const finish = (fn) => (value) => {
if (settled) return;
settled = true;
try {
ws.close();
} catch {
// ignore
}
fn(value);
};
ws.once('open', finish(() => resolve({ ok: true, latencyMs: Date.now() - startedAt })));
ws.once('error', finish((err) => reject(err)));
}), timeoutMs, `WebSocket reachability timeout after ${timeoutMs}ms`);
}
async function probePublishRead(relayUrl, namespace, timeoutMs) {
const ws = new WebSocket(relayUrl);
const sk = generateSecretKey();
const pk = getPublicKey(sk);
const createdAt = Math.floor(Date.now() / 1000);
const nonce = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
const probeTag = `${namespace}-health-probe-${nonce}`;
const event = finalizeEvent({
kind: PROBE_KIND,
created_at: createdAt,
tags: [
['t', `${namespace}-relay-health`],
['d', probeTag],
],
content: JSON.stringify({ type: 'relay_health_probe', nonce, namespace, pubkey: pk }),
}, sk);
const subId = `probe-${nonce}`;
return withTimeout(new Promise((resolve, reject) => {
let okAck = false;
let readSeen = false;
let settled = false;
const settle = (fn) => (value) => {
if (settled) return;
settled = true;
try {
ws.close();
} catch {
// ignore
}
fn(value);
};
ws.once('open', () => {
ws.send(JSON.stringify(['EVENT', event]));
ws.send(JSON.stringify(['REQ', subId, { ids: [event.id], limit: 1 }]));
});
ws.on('message', (raw) => {
let message;
try {
message = JSON.parse(raw.toString());
} catch {
return;
}
if (!Array.isArray(message) || message.length < 2) return;
const type = message[0];
if (type === 'OK' && message[1] === event.id) {
okAck = Boolean(message[2]);
if (!okAck) {
settle(reject)(new Error(`Relay rejected health probe event: ${String(message[3] || 'unknown reason')}`));
return;
}
}
if (type === 'EVENT' && message[1] === subId && message[2]?.id === event.id) {
readSeen = true;
}
if (okAck && readSeen) {
ws.send(JSON.stringify(['CLOSE', subId]));
settle(resolve)({ ok: true, eventId: event.id });
}
});
ws.once('error', settle(reject));
}), timeoutMs, `Publish/read probe timeout after ${timeoutMs}ms`);
}
async function readHistory(historyPath) {
try {
const raw = await fs.readFile(historyPath, 'utf8');
return raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter((entry) => entry && typeof entry === 'object');
} catch (error) {
if (error && error.code === 'ENOENT') return [];
throw error;
}
}
async function appendHistory(historyPath, entry) {
await fs.mkdir(path.dirname(historyPath), { recursive: true });
await fs.appendFile(historyPath, `${JSON.stringify(entry)}\n`, 'utf8');
}
export async function runRelayHealthCheck(options = {}) {
const {
cadence = 'daily',
relays = getRelays(),
namespace = getNamespace(),
timeoutMs = 6000,
historyPath = path.resolve(process.cwd(), 'task-logs', 'relay-health', `${cadence}.jsonl`),
allRelaysDownMinutes = 10,
minSuccessRate = 0.7,
windowMinutes = 60,
} = options;
const relayResults = [];
for (const relay of relays) {
const relayResult = {
relay,
websocketReachable: false,
publishReadOk: false,
healthy: false,
checks: {},
};
try {
const wsProbe = await probeWebSocketReachability(relay, timeoutMs);
relayResult.websocketReachable = true;
relayResult.checks.websocket = wsProbe;
} catch (error) {
relayResult.checks.websocket = { ok: false, error: error instanceof Error ? error.message : String(error) };
}
if (relayResult.websocketReachable) {
try {
const prProbe = await probePublishRead(relay, namespace, timeoutMs);
relayResult.publishReadOk = true;
relayResult.checks.publishRead = prProbe;
} catch (error) {
relayResult.checks.publishRead = { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
relayResult.healthy = relayResult.websocketReachable && relayResult.publishReadOk;
relayResults.push(relayResult);
}
const summary = {
totalRelays: relayResults.length,
healthyRelays: relayResults.filter((relay) => relay.healthy).length,
};
summary.unhealthyRelays = summary.totalRelays - summary.healthyRelays;
summary.allRelaysUnhealthy = summary.healthyRelays === 0;
const result = {
ok: !summary.allRelaysUnhealthy,
cadence,
namespace,
timestamp: nowIso(),
relays,
relayResults,
summary,
incidentSignal: null,
alerts: [],
alertStats: null,
historyPath,
};
const historyEntries = await readHistory(historyPath);
const prospectiveHistory = [...historyEntries, result];
const thresholdEval = evaluateAlertThresholds(result, prospectiveHistory, {
allRelaysDownMinutes,
minSuccessRate,
windowMinutes,
});
result.alerts = thresholdEval.alerts;
result.alertStats = thresholdEval.stats;
if (summary.allRelaysUnhealthy) {
const incidentId = `relay-health-${cadence}-${Date.now()}`;
result.incidentSignal = {
id: incidentId,
reason: 'All relays unhealthy before lock acquisition',
severity: 'critical',
suggestedAction: 'Defer scheduler run and investigate relay availability before retrying lock acquisition.',
};
}
await appendHistory(historyPath, result);
return result;
}
+50
View File
@@ -0,0 +1,50 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { evaluateAlertThresholds } from '../src/relay-health.mjs';
function entry(timestamp, healthyRelays, totalRelays = 3) {
return {
timestamp,
summary: {
healthyRelays,
totalRelays,
allRelaysUnhealthy: healthyRelays === 0,
},
};
}
test('raises all-relays-down duration alert after threshold', () => {
const now = Date.parse('2026-02-15T12:00:00Z');
const history = [
entry('2026-02-15T11:30:00Z', 1),
entry('2026-02-15T11:45:00Z', 0),
entry('2026-02-15T11:55:00Z', 0),
entry('2026-02-15T12:00:00Z', 0),
];
const current = history[history.length - 1];
const result = evaluateAlertThresholds(current, history, {
allRelaysDownMinutes: 10,
minSuccessRate: 0.1,
windowMinutes: 60,
}, { nowMs: now });
assert.ok(result.alerts.some((alert) => alert.type === 'all_relays_down_duration'));
});
test('raises success-rate alert when rolling health falls below threshold', () => {
const history = [
entry('2026-02-15T11:50:00Z', 3),
entry('2026-02-15T11:55:00Z', 0),
entry('2026-02-15T12:00:00Z', 0),
];
const current = history[history.length - 1];
const result = evaluateAlertThresholds(current, history, {
allRelaysDownMinutes: 999,
minSuccessRate: 0.5,
windowMinutes: 30,
}, { nowMs: Date.parse('2026-02-15T12:00:00Z') });
assert.ok(result.alerts.some((alert) => alert.type === 'relay_success_rate_below_threshold'));
});