rmhladder Productionization — Phase 3: Observability & Alerting — Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make it automatically obvious when scraping degrades — a single health-alert detector that fires on “worker hasn’t completed a run in too long,” “the latest run’s error rate spiked,” “the mass-expiry circuit breaker tripped,” and “the resume subsystem isn’t ready” — surfaced in pnpm ladder:status, the worker log, and the admin /rmhladder/health page.
Architecture: One pure function, detectLadderHealthAlerts(input): LadderHealthAlert[], computes the active alerts from already-available signals. Three thin consumers feed it and render its output: the ladder:status CLI, the ladder-worker tick, and the health-page loader. No notification fan-out (email/Discord) is built — the health surface + worker log are the delivery, matching the spec’s “start with the health surface + existing notification path; expand only if cheap.” Thresholds are env-tunable.
Tech Stack: TypeScript (strict), Prisma 7, TanStack Start, Vitest, pnpm. Spec: docs/superpowers/specs/2026-07-15-rmhladder-productionization-design.md (Phase 3). Builds on Phases 0–2. Reuses Phase 0’s resumeSubsystemReadiness and ladder:status, and the existing /rmhladder/health page + LadderScrapeRun/LadderReviewTask models.
Global Constraints¶
No new notification channels (no email/Discord fan-out) this phase. Delivery =
ladder:statusoutput + a worker-tick warning log + the admin health page. (The spec allows expanding later only if cheap.)The detector is pure — no I/O, no env reads, no
Date.now()inside;nowand all inputs are passed in. This keeps it unit-testable and lets every consumer supply the same shape.Reuse, don’t rebuild: run history already renders on
/rmhladder/health(the runs table) and the ops runbook already exists (docs/rmhladder-operations.md). This phase ADDS the alert layer on top; it does not re-implement run history or the runbook.Thresholds are env-tunable:
LADDER_ALERT_STALE_RUN_MS(default86_400_000= 24h ≈ 2× the 12h cadence),LADDER_ALERT_ERROR_RATE(default0.5),LADDER_ALERT_MIN_RUN_FOR_RATE(default10).No schema change — alerts are derived from existing
LadderScrapeRun(finishedAt, errorCount, discoveredCount),LadderReviewTask(reasonmass_expiry_suspected, statusopen), andresumeSubsystemReadiness().No new type or lint warnings. Repo
tscneedsNODE_OPTIONS=--max-old-space-size=8192.pnpm lintclean.Admin-only on the web: the
/rmhladder/healthpage is already admin-gated — the alerts banner inherits that; do not expose alert internals to non-admins.Tests colocated
.test.ts, TDD.
File Structure¶
File |
Responsibility |
Task |
|---|---|---|
|
pure |
1 |
|
detector unit tests (every alert code + clean case) |
1 |
|
gather inputs, print an ALERTS section |
1 |
|
after each tick, warn-log active alerts |
1 |
|
add an “Alerts” section (codes + what to do) |
1 |
|
a small query for open mass-expiry tasks + last completed run (or reuse existing) |
2 |
|
gather + render an alerts banner (reuse the detector) |
2 |
Task 1: The health-alert detector + CLI + worker + runbook¶
Files:
Create:
lib/rmhladder/health-alerts.ts,lib/rmhladder/health-alerts.test.tsModify:
scripts/ladder-status.ts,server/ladder-worker/index.ts,docs/rmhladder-operations.md
Interfaces:
Produces:
type LadderAlertCode = 'worker_stale' | 'high_error_run' | 'breaker_tripped' | 'resume_not_ready'interface LadderHealthAlert { code: LadderAlertCode; severity: 'high' | 'medium'; message: string }interface AlertThresholds { staleRunMs: number; errorRate: number; minRunForRate: number }resolveAlertThresholds(env?): AlertThresholdsinterface AlertInput { now: Date; lastCompletedRunAt: Date | null; latestRun: { errorCount: number; discoveredCount: number } | null; openMassExpiryTasks: number; resumeReady: boolean; thresholds: AlertThresholds }detectLadderHealthAlerts(input: AlertInput): LadderHealthAlert[]
Consumed by:
ladder-status.ts,ladder-worker, and (Task 2) the health page.Step 1: Write the failing test
Create lib/rmhladder/health-alerts.test.ts:
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
detectLadderHealthAlerts,
resolveAlertThresholds,
DEFAULT_ALERT_THRESHOLDS,
type AlertInput,
} from './health-alerts';
const now = new Date('2026-07-16T12:00:00.000Z');
const base: AlertInput = {
now,
lastCompletedRunAt: new Date(now.getTime() - 60 * 60_000), // 1h ago
latestRun: { errorCount: 0, discoveredCount: 100 },
openMassExpiryTasks: 0,
resumeReady: true,
thresholds: DEFAULT_ALERT_THRESHOLDS,
};
const codes = (i: AlertInput) => detectLadderHealthAlerts(i).map((a) => a.code);
describe('detectLadderHealthAlerts', () => {
it('returns no alerts when everything is healthy', () => {
expect(detectLadderHealthAlerts(base)).toEqual([]);
});
it('flags worker_stale when no completed run is within the window', () => {
expect(
codes({ ...base, lastCompletedRunAt: new Date(now.getTime() - 25 * 60 * 60_000) }),
).toContain('worker_stale');
expect(codes({ ...base, lastCompletedRunAt: null })).toContain('worker_stale');
});
it('flags high_error_run when the latest run error rate exceeds the threshold', () => {
expect(codes({ ...base, latestRun: { errorCount: 60, discoveredCount: 100 } })).toContain(
'high_error_run',
);
});
it('does NOT flag high_error_run for a tiny run below minRunForRate', () => {
expect(codes({ ...base, latestRun: { errorCount: 3, discoveredCount: 3 } })).not.toContain(
'high_error_run',
);
});
it('flags breaker_tripped when open mass-expiry tasks exist', () => {
expect(codes({ ...base, openMassExpiryTasks: 2 })).toContain('breaker_tripped');
});
it('flags resume_not_ready when resume subsystem is not ready', () => {
expect(codes({ ...base, resumeReady: false })).toContain('resume_not_ready');
});
it('assigns severities (worker_stale/breaker/resume = high, error rate = medium)', () => {
const alerts = detectLadderHealthAlerts({
...base,
lastCompletedRunAt: null,
openMassExpiryTasks: 1,
resumeReady: false,
latestRun: { errorCount: 60, discoveredCount: 100 },
});
const bySeverity = Object.fromEntries(alerts.map((a) => [a.code, a.severity]));
expect(bySeverity.worker_stale).toBe('high');
expect(bySeverity.breaker_tripped).toBe('high');
expect(bySeverity.resume_not_ready).toBe('high');
expect(bySeverity.high_error_run).toBe('medium');
});
});
describe('resolveAlertThresholds', () => {
afterEach(() => vi.unstubAllEnvs());
it('defaults sanely', () => {
expect(resolveAlertThresholds({})).toEqual(DEFAULT_ALERT_THRESHOLDS);
expect(DEFAULT_ALERT_THRESHOLDS.staleRunMs).toBe(86_400_000);
});
it('honors valid overrides and ignores junk', () => {
expect(resolveAlertThresholds({ LADDER_ALERT_ERROR_RATE: '0.25' }).errorRate).toBe(0.25);
expect(resolveAlertThresholds({ LADDER_ALERT_ERROR_RATE: 'abc' }).errorRate).toBe(0.5);
});
});
Step 2: Run to verify it fails
Run: pnpm exec vitest run lib/rmhladder/health-alerts.test.ts
Expected: FAIL — module missing.
Step 3: Implement
health-alerts.ts
Create lib/rmhladder/health-alerts.ts:
export type LadderAlertCode =
'worker_stale' | 'high_error_run' | 'breaker_tripped' | 'resume_not_ready';
export interface LadderHealthAlert {
code: LadderAlertCode;
severity: 'high' | 'medium';
message: string;
}
export interface AlertThresholds {
staleRunMs: number;
errorRate: number;
minRunForRate: number;
}
export const DEFAULT_ALERT_THRESHOLDS: AlertThresholds = {
staleRunMs: 86_400_000, // 24h ≈ 2× the 12h cadence
errorRate: 0.5,
minRunForRate: 10,
};
function num(
value: string | undefined,
fallback: number,
predicate: (n: number) => boolean,
): number {
const parsed = Number(value);
return Number.isFinite(parsed) && predicate(parsed) ? parsed : fallback;
}
export function resolveAlertThresholds(
env: {
LADDER_ALERT_STALE_RUN_MS?: string;
LADDER_ALERT_ERROR_RATE?: string;
LADDER_ALERT_MIN_RUN_FOR_RATE?: string;
} = process.env,
): AlertThresholds {
return {
staleRunMs: num(
env.LADDER_ALERT_STALE_RUN_MS,
DEFAULT_ALERT_THRESHOLDS.staleRunMs,
(n) => n > 0,
),
errorRate: num(
env.LADDER_ALERT_ERROR_RATE,
DEFAULT_ALERT_THRESHOLDS.errorRate,
(n) => n > 0 && n <= 1,
),
minRunForRate: num(
env.LADDER_ALERT_MIN_RUN_FOR_RATE,
DEFAULT_ALERT_THRESHOLDS.minRunForRate,
(n) => n >= 0,
),
};
}
export interface AlertInput {
now: Date;
lastCompletedRunAt: Date | null;
latestRun: { errorCount: number; discoveredCount: number } | null;
openMassExpiryTasks: number;
resumeReady: boolean;
thresholds: AlertThresholds;
}
/** Pure: compute active health alerts from already-gathered signals. */
export function detectLadderHealthAlerts(input: AlertInput): LadderHealthAlert[] {
const alerts: LadderHealthAlert[] = [];
const { thresholds: t } = input;
const runAgeMs = input.lastCompletedRunAt
? input.now.getTime() - input.lastCompletedRunAt.getTime()
: Infinity;
if (runAgeMs >= t.staleRunMs) {
alerts.push({
code: 'worker_stale',
severity: 'high',
message: input.lastCompletedRunAt
? `No completed scrape run in ${Math.round(runAgeMs / 3_600_000)}h (threshold ${Math.round(t.staleRunMs / 3_600_000)}h).`
: 'No completed scrape run on record — the worker may not be running.',
});
}
if (input.openMassExpiryTasks > 0) {
alerts.push({
code: 'breaker_tripped',
severity: 'high',
message: `${input.openMassExpiryTasks} open mass-expiry review task(s) — the circuit breaker tripped on a source.`,
});
}
if (!input.resumeReady) {
alerts.push({
code: 'resume_not_ready',
severity: 'high',
message: 'Resume subsystem is not ready (object storage or encryption key unconfigured).',
});
}
const r = input.latestRun;
if (r && r.discoveredCount >= t.minRunForRate) {
const rate = r.errorCount / Math.max(1, r.discoveredCount);
if (rate > t.errorRate) {
alerts.push({
code: 'high_error_run',
severity: 'medium',
message: `Latest run error rate ${Math.round(rate * 100)}% (${r.errorCount}/${r.discoveredCount}) exceeds ${Math.round(t.errorRate * 100)}%.`,
});
}
}
return alerts;
}
Step 4: Run to verify it passes
Run: pnpm exec vitest run lib/rmhladder/health-alerts.test.ts
Expected: PASS.
Step 5: Surface alerts in
ladder:status
In scripts/ladder-status.ts, gather the alert inputs (reusing what’s already fetched — lastRun gives finishedAt/errorCount/discoveredCount; resumeSubsystemReadiness() is already called; add a count of open mass-expiry tasks) and print an ALERTS section. Add:
import { detectLadderHealthAlerts, resolveAlertThresholds } from '@/lib/rmhladder/health-alerts';
After the coverage print, add:
const openMassExpiryTasks = await prisma.ladderReviewTask.count({
where: { reason: 'mass_expiry_suspected', status: 'open' },
});
const alerts = detectLadderHealthAlerts({
now: new Date(),
lastCompletedRunAt: lastRun?.finishedAt ?? null,
latestRun: lastRun
? { errorCount: lastRun.errorCount ?? 0, discoveredCount: lastRun.discoveredCount ?? 0 }
: null,
openMassExpiryTasks,
resumeReady: readiness.ready,
thresholds: resolveAlertThresholds(),
});
console.log('\nalerts');
console.log('------');
if (alerts.length === 0) {
console.log(' none — all healthy');
} else {
for (const a of alerts) console.log(` [${a.severity.toUpperCase()}] ${a.code}: ${a.message}`);
}
(Reuse the existing lastRun and readiness variables from the Phase 0/2 code; do not re-query them. If lastRun’s select does not already include errorCount/discoveredCount, add those fields to its select.)
Step 6: Warn-log active alerts after each worker tick
In server/ladder-worker/index.ts, at the end of tick() (after refreshMatchingAndAlerts() in the try block, before the finally), gather the same inputs and log a warning line per active alert so operators watching worker logs see degradation immediately:
import {
detectLadderHealthAlerts,
resolveAlertThresholds,
} from '../../lib/rmhladder/health-alerts';
import { resumeSubsystemReadiness } from '../../lib/rmhladder/resume/readiness.server';
(the resume import may already exist from Phase 0 — do not duplicate). After the run + refresh:
try {
const lastRun = await prisma.ladderScrapeRun.findFirst({
where: { finishedAt: { not: null } },
orderBy: { finishedAt: 'desc' },
select: { finishedAt: true, errorCount: true, discoveredCount: true },
});
const openMassExpiryTasks = await prisma.ladderReviewTask.count({
where: { reason: 'mass_expiry_suspected', status: 'open' },
});
const alerts = detectLadderHealthAlerts({
now: new Date(),
lastCompletedRunAt: lastRun?.finishedAt ?? null,
latestRun: lastRun
? { errorCount: lastRun.errorCount ?? 0, discoveredCount: lastRun.discoveredCount ?? 0 }
: null,
openMassExpiryTasks,
resumeReady: resumeSubsystemReadiness().ready,
thresholds: resolveAlertThresholds(),
});
for (const a of alerts)
console.error(`[ladder-worker] HEALTH ALERT [${a.severity}] ${a.code}: ${a.message}`);
} catch (error) {
console.error('[ladder-worker] Alert detection failed:', error);
}
Step 7: Add an “Alerts” section to the runbook
In docs/rmhladder-operations.md, add a section documenting the four alert codes and the operator response for each:
worker_stale— worker down/stuck; checkdocker compose logs ladder-worker, restart if crash-looping.breaker_tripped— a source’s board looked empty for many active jobs; investigate the source at/rmhladder/reviewbefore clearing themass_expiry_suspectedtask.resume_not_ready— provisionS3_*+LADDER_RESUME_ENCRYPTION_KEY(see the resume section).high_error_run— many sources failed this run; check the run’s error rows on/rmhladder/health. Mention the tunable env vars (LADDER_ALERT_*).Step 8: Typecheck + tests
Run: NODE_OPTIONS=--max-old-space-size=8192 pnpm exec tsc --noEmit → clean.
Run: pnpm exec vitest run lib/rmhladder/health-alerts.test.ts → pass.
Step 9: Commit
git add lib/rmhladder/health-alerts.ts lib/rmhladder/health-alerts.test.ts scripts/ladder-status.ts server/ladder-worker/index.ts docs/rmhladder-operations.md
git commit -m "feat(rmhladder): health-alert detection in ladder:status + worker log"
Self-Review¶
Spec coverage (Phase 3):
3.1 Run metrics & history → already present (runs table on
/rmhladder/health); this phase adds derived health (latest-run error-rate alert). ✅3.2 Staleness & failure alerting → Task 1 (detector +
ladder:status+ worker log) & Task 2 (health banner): worker-down (worker_stale), runs-failing (high_error_run), breaker (breaker_tripped), resume readiness (resume_not_ready). ✅3.3 Resilience → per-source retry/backoff already explicit (
probe-sources.ts,process-source.tsnextProbeAt); health page hardened with the alerts banner. Not rebuilt (YAGNI). ✅3.4 Runbook → Task 1 Step 7 adds the Alerts section to the existing
docs/rmhladder-operations.md. ✅
Type consistency: AlertInput/LadderHealthAlert/AlertThresholds/detectLadderHealthAlerts/resolveAlertThresholds defined in Task 1 and consumed identically by the CLI, worker, and (Task 2) health page.
Placeholder scan: exact code for the detector, its tests, the CLI section, and the worker block. The health-page loader wiring (Task 2) points at the existing fetchHealth server fn and names the exact inputs to gather; the “reuse existing lastRun/readiness variables” notes are deliberate (avoid re-querying) and the shapes are fully specified.
Delivery scope: deliberately no email/Discord fan-out (Global Constraints) — the detector output is a stable, serializable contract, so a future phase can add a channel by feeding the same alerts to notifications.server.ts without touching detection.