mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-06 12:51:54 +00:00
Add relay health preflight job and scheduler deferral handling
This commit is contained in:
1 parent
37ee9eb0f2
commit
1e97118e8b
11 files changed
+458
-89
No files matched your search
+4
-3
@@ -30,7 +30,8 @@ Track only **active, reproducible, unresolved** issues.
|
|||||||
- **Area:** Runtime
|
- **Area:** Runtime
|
||||||
- **Symptom:** Multiple scheduler runs fail with `Lock backend error` before prompt handoff/validation.
|
- **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.
|
- **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.
|
- **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 is skipped for affected cycles until lock acquisition succeeds.
|
- **Impact:** Scheduled agent execution may be deferred early when all relays are unhealthy, preventing noisy lock retries until relay health recovers.
|
||||||
- **Last verified:** 2026-02-14
|
- **Related notes:** `docs/agent-handoffs/learnings/2026-02-15-relay-health-preflight-job.md`
|
||||||
|
- **Last verified:** 2026-02-15
|
||||||
|
|
||||||
@@ -124,6 +124,7 @@ If you are developing `torch-lock` itself:
|
|||||||
- `npm run lock:check:daily`
|
- `npm run lock:check:daily`
|
||||||
- `npm run lock:check:weekly`
|
- `npm run lock:check:weekly`
|
||||||
- `npm run lock:list`
|
- `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 run dashboard:serve`
|
||||||
- `npm test` (run unit tests)
|
- `npm test` (run unit tests)
|
||||||
- `npm run lint` (run linter)
|
- `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.
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"src/dashboard.mjs",
|
"src/dashboard.mjs",
|
||||||
"src/roster.mjs",
|
"src/roster.mjs",
|
||||||
"src/torch-config.mjs",
|
"src/torch-config.mjs",
|
||||||
|
"src/relay-health.mjs",
|
||||||
"src/prompts/",
|
"src/prompts/",
|
||||||
"src/docs/",
|
"src/docs/",
|
||||||
"scripts/check-file-size.mjs",
|
"scripts/check-file-size.mjs",
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"lock:check:weekly": "node bin/torch-lock.mjs check --cadence weekly",
|
"lock:check:weekly": "node bin/torch-lock.mjs check --cadence weekly",
|
||||||
"lock:list": "node bin/torch-lock.mjs list",
|
"lock:list": "node bin/torch-lock.mjs list",
|
||||||
"lock:lock": "node bin/torch-lock.mjs lock",
|
"lock:lock": "node bin/torch-lock.mjs lock",
|
||||||
|
"lock:health": "node bin/torch-lock.mjs health",
|
||||||
"dashboard:serve": "node bin/torch-lock.mjs dashboard",
|
"dashboard:serve": "node bin/torch-lock.mjs dashboard",
|
||||||
"build": "node build.mjs",
|
"build": "node build.mjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { getRelays, getNamespace, getQueryTimeoutMs } from '../../src/torch-config.mjs';
|
import { runRelayHealthCheck } from '../../src/relay-health.mjs';
|
||||||
import { queryLocks } from '../../src/lock-ops.mjs';
|
|
||||||
import { todayDateStr } from '../../src/utils.mjs';
|
|
||||||
|
|
||||||
function parseArgs(argv) {
|
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) {
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
const value = argv[i];
|
const value = argv[i];
|
||||||
if (!value.startsWith('--') && !args.cadence) {
|
if (!value.startsWith('--') && !args.cadence) {
|
||||||
@@ -14,93 +18,41 @@ function parseArgs(argv) {
|
|||||||
if (value === '--cadence') {
|
if (value === '--cadence') {
|
||||||
args.cadence = argv[i + 1] || args.cadence;
|
args.cadence = argv[i + 1] || args.cadence;
|
||||||
i += 1;
|
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;
|
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() {
|
async function main() {
|
||||||
const { cadence } = parseArgs(process.argv.slice(2));
|
const args = parseArgs(process.argv.slice(2));
|
||||||
if (cadence !== 'daily' && cadence !== 'weekly') {
|
if (args.cadence !== 'daily' && args.cadence !== 'weekly') {
|
||||||
console.error('Usage: node scripts/agent/check-relay-health.mjs --cadence <daily|weekly>');
|
console.error('Usage: node scripts/agent/check-relay-health.mjs --cadence <daily|weekly>');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const relays = getRelays();
|
const result = await runRelayHealthCheck(args);
|
||||||
const namespace = getNamespace();
|
if (!result.ok) {
|
||||||
const timeoutMs = getQueryTimeoutMs();
|
result.failureCategory = 'all relays unhealthy';
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
console.log(JSON.stringify(result));
|
||||||
|
process.exit(result.ok ? 0 : 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
const message = error instanceof Error ? error.message : String(error ?? 'unknown error');
|
const message = error instanceof Error ? error.message : String(error ?? 'unknown error');
|
||||||
const relays = getRelays();
|
console.log(JSON.stringify({ ok: false, failureCategory: 'health check failed', error: message }));
|
||||||
const namespace = getNamespace();
|
|
||||||
const failureCategory = classifyRelayFailure(message);
|
|
||||||
console.log(JSON.stringify({
|
|
||||||
ok: false,
|
|
||||||
namespace,
|
|
||||||
relays,
|
|
||||||
failureCategory,
|
|
||||||
error: message,
|
|
||||||
}));
|
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
});
|
});
|
||||||
@@ -417,7 +417,7 @@ function createIdempotencyKey({ cadence, selectedAgent, runDate }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runLockHealthPreflight({ cadence, platform }) {
|
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 },
|
env: { AGENT_PLATFORM: platform },
|
||||||
});
|
});
|
||||||
const payload = parseJsonFromOutput(`${preflight.stdout}\n${preflight.stderr}`) || {};
|
const payload = parseJsonFromOutput(`${preflight.stdout}\n${preflight.stderr}`) || {};
|
||||||
@@ -593,22 +593,32 @@ async function main() {
|
|||||||
if (schedulerConfig.lockHealthPreflight.enabled && !schedulerConfig.lockHealthPreflight.skip) {
|
if (schedulerConfig.lockHealthPreflight.enabled && !schedulerConfig.lockHealthPreflight.skip) {
|
||||||
const preflight = await runLockHealthPreflight({ cadence, platform });
|
const preflight = await runLockHealthPreflight({ cadence, platform });
|
||||||
if (preflight.code !== 0) {
|
if (preflight.code !== 0) {
|
||||||
|
const allRelaysUnhealthy = Boolean(preflight.payload?.summary?.allRelaysUnhealthy);
|
||||||
|
const incidentSignal = preflight.payload?.incidentSignal || null;
|
||||||
await writeLog({
|
await writeLog({
|
||||||
cadence,
|
cadence,
|
||||||
agent: 'scheduler',
|
agent: 'scheduler',
|
||||||
status: 'failed',
|
status: allRelaysUnhealthy ? 'deferred' : 'failed',
|
||||||
platform,
|
platform,
|
||||||
reason: 'Lock backend unavailable preflight',
|
reason: allRelaysUnhealthy
|
||||||
detail: `Preflight failed (${preflight.failureCategory}). Retry scheduler after verifying relay connectivity, relay URLs, and DNS/network status.`,
|
? '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: {
|
metadata: {
|
||||||
failure_class: 'backend_unavailable',
|
failure_class: 'backend_unavailable',
|
||||||
preflight_failure_category: preflight.failureCategory,
|
preflight_failure_category: preflight.failureCategory,
|
||||||
relay_list: preflight.relayList.join(', ') || '(none)',
|
relay_list: preflight.relayList.join(', ') || '(none)',
|
||||||
preflight_stderr_excerpt: preflight.stderrExcerpt || '(empty)',
|
preflight_stderr_excerpt: preflight.stderrExcerpt || '(empty)',
|
||||||
preflight_stdout_excerpt: preflight.stdoutExcerpt || '(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
@@ -13,6 +13,9 @@ AGENT_PLATFORM=codex node bin/torch-lock.mjs lock --agent docs-agent --cadence d
|
|||||||
|
|
||||||
# List all active locks
|
# List all active locks
|
||||||
node bin/torch-lock.mjs list
|
node bin/torch-lock.mjs list
|
||||||
|
|
||||||
|
# Probe relay health before scheduler lock acquisition
|
||||||
|
node bin/torch-lock.mjs health --cadence daily
|
||||||
```
|
```
|
||||||
|
|
||||||
## Defaults
|
## Defaults
|
||||||
@@ -77,7 +80,7 @@ Operational note: scheduler handoff commands are treated as required execution s
|
|||||||
|
|
||||||
Scheduler failure classes in task logs:
|
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.
|
- `prompt_validation_error` — prompt/handoff, memory evidence, artifact verification, and validation command failures.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+42
@@ -32,6 +32,7 @@ import {
|
|||||||
} from './services/memory/index.js';
|
} from './services/memory/index.js';
|
||||||
import { ExitError } from './errors.mjs';
|
import { ExitError } from './errors.mjs';
|
||||||
import { todayDateStr, nowUnix, getIsoWeekStr } from './utils.mjs';
|
import { todayDateStr, nowUnix, getIsoWeekStr } from './utils.mjs';
|
||||||
|
import { runRelayHealthCheck } from './relay-health.mjs';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
@@ -527,6 +528,10 @@ function parseArgs(argv) {
|
|||||||
offset: null,
|
offset: null,
|
||||||
retentionMs: null,
|
retentionMs: null,
|
||||||
windowMs: null,
|
windowMs: null,
|
||||||
|
timeoutMs: null,
|
||||||
|
allRelaysDownMinutes: null,
|
||||||
|
minSuccessRate: null,
|
||||||
|
windowMinutes: null,
|
||||||
};
|
};
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
||||||
@@ -589,6 +594,22 @@ function parseArgs(argv) {
|
|||||||
args.windowMs = parseInt(argv[++i], 10);
|
args.windowMs = parseInt(argv[++i], 10);
|
||||||
} else if (arg.startsWith('--window-ms=')) {
|
} else if (arg.startsWith('--window-ms=')) {
|
||||||
args.windowMs = parseInt(arg.split('=')[1], 10);
|
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
|
lock --agent <name> --cadence <daily|weekly> Claim a lock
|
||||||
complete --agent <name> --cadence <daily|weekly> Mark task as completed (permanent)
|
complete --agent <name> --cadence <daily|weekly> Mark task as completed (permanent)
|
||||||
list [--cadence <daily|weekly>] Print active lock table
|
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})
|
dashboard [--port <port>] Serve the dashboard (default: ${DEFAULT_DASHBOARD_PORT})
|
||||||
init [--force] Initialize torch/ directory in current project
|
init [--force] Initialize torch/ directory in current project
|
||||||
update [--force] Update torch/ configuration (backups, merges)
|
update [--force] Update torch/ configuration (backups, merges)
|
||||||
@@ -697,6 +719,26 @@ export async function main(argv) {
|
|||||||
break;
|
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': {
|
case 'dashboard': {
|
||||||
await cmdDashboard(args.port);
|
await cmdDashboard(args.port);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -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`:
|
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
|
```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.
|
- 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`
|
- `relay_list`
|
||||||
- `preflight_failure_category`
|
- `preflight_failure_category`
|
||||||
- `preflight_stderr_excerpt`
|
- `preflight_stderr_excerpt`
|
||||||
- `preflight_stdout_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:
|
6. Find latest cadence log file, derive the previous agent, then choose the next roster agent not in exclusion set:
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
});
|
||||||
Reference in new issue
Block a user