Files
gbrain/test/doctor-worker-oom-loop.test.ts
T
3fe449361c v0.42.16.0 feat(doctor): brain health as a solved problem — cause-ranked doctor + OOM-loop line + auto-drain + pool-reap (#1685) (#1802)
* feat(minions): pool-recovery audit + reconnect reason-threading + shared drain helper (#1685 GAP B, 5A)

- pool-recovery-audit.ts: reap_detected (CONNECTION_ENDED) vs reconnect_other; recovered/failed split
- postgres-engine reconnect(ctx?) classifies the triggering error so only true pooler reaps are tagged (CODEX #8)
- retry.ts reconnect callback widened to thread the error; retry-matcher isConnectionEndedError
- runExtractAtomsDrainForSource shared helper (cycleLockIdFor + withRefreshingLock) — one drain path (5A)
- supervisor-audit readRecentSupervisorEvents (current+prev ISO week, CODEX #7)
- extract-atoms-drain PROTECTED; autopilot.auto_drain.* config keys

* feat(doctor): worker_oom_loop + pool_reap_health checks + cause-ranked top_issues (#1685 GAP A/B/C)

- computeWorkerOomLoopCheck: unions supervisor rss_watchdog + minion_jobs watchdog-abort (CODEX #5), cap fallback to resolveDefaultMaxRssMb (CODEX #6)
- computePoolReapHealthCheck: reaps-not-recovering fail, thrash warn
- doctor-cause-rank rankIssues: tier ordering + grounded downstream_of (CODEX #9) + drift guard (4A)
- supervisor causeStr + queue_health cross-reference worker_oom_loop (DRY 1C)
- register both checks in doctor-categories ops

* feat(autopilot): per-source extract_atoms auto-drain + handler + dream --drain refactor (#1685 GAP D)

- autopilot per-source gate: enabled + !packDeclares + backlog>threshold + daily cap; time-sloted idempotency key (CODEX #2)
- extract-atoms-drain Minion handler (thin wrapper, LockUnavailableError -> deferred)
- dream --drain routes through the shared helper (5A)

* chore: bump version and changelog (v0.42.12.0)

#1685 brain-health-as-solved-problem: cause-ranked doctor, worker_oom_loop
line, per-source auto-drain, pool-reap health. Layers on #1678/#1735.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(todos): file #1685 GAP E + remote-path follow-ups (v0.42.12.0)

* fix(#1685): pre-landing review — multi-source auto-drain, honest pool-reap signal, lock-renewal reap labeling

- autopilot: drop maxWaiting (coalesces by name+queue not source → only one source drained + cap over-count); pre-check idempotency key so only genuinely-new sources submit+count
- pool_reap_health: fail on reconnect FAILURES (the real signal), not reaps>0&&failures>0 (false causality when a recovered reap + unrelated failure co-occur)
- lock-renewal-tick threads its triggering error to reconnect() so a CONNECTION_ENDED pooler reap is labeled reap_detected not reconnect_other (pool_reap_health now fires for the #1678 incident path)

* chore: re-version v0.42.12.0 → v0.42.16.0 (#1685)

Slot collision avoidance per queue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: restore slim CLAUDE.md + move #1685 entries to KEY_FILES.md (fix check:doc-history)

The master merge wrongly kept the pre-restructure 577KB CLAUDE.md; the
check:doc-history guard caps it at 60KB. Take master's slim CLAUDE.md and
record the #1685 files (doctor-cause-rank, pool-recovery-audit, worker_oom_loop
+ pool_reap_health checks, auto-drain, 5A helper) as current-state prose in
docs/architecture/KEY_FILES.md (no release markers). llms regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 07:27:34 -07:00

102 lines
4.3 KiB
TypeScript

// #1685 GAP A — worker_oom_loop doctor check.
//
// Hermetic: writes synthetic supervisor audit JSONL into a GBRAIN_AUDIT_DIR
// tempdir and drives computeWorkerOomLoopCheck with engine=null (supervised
// half + cap logic + thresholds; the minion_jobs bare-worker branch is
// Postgres-only and mirrors the queue_health subcheck-3 query covered by E2E).
// Also pins the cross-week supervisor reader (CODEX #7).
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { withEnv } from './helpers/with-env.ts';
import { computeWorkerOomLoopCheck } from '../src/commands/doctor.ts';
import {
computeSupervisorAuditFilename,
readRecentSupervisorEvents,
} from '../src/core/minions/handlers/supervisor-audit.ts';
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'worker-oom-loop-'));
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ }
});
function writeSupervisorRows(rows: object[], fileDate = new Date()): void {
const file = path.join(tmpDir, computeSupervisorAuditFilename(fileDate));
fs.writeFileSync(file, rows.map((r) => JSON.stringify(r)).join('\n') + '\n', 'utf8');
}
const nowIso = () => new Date().toISOString();
const rssKill = () => ({ event: 'worker_exited', ts: nowIso(), likely_cause: 'rss_watchdog', code: 12 });
const breaker = (cap: number) => ({ event: 'health_warn', ts: nowIso(), reason: 'rss_watchdog_loop', max_rss_mb: cap });
describe('computeWorkerOomLoopCheck', () => {
test('fail with breaker-stamped cap when the breaker tripped', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => {
writeSupervisorRows([rssKill(), rssKill(), rssKill(), rssKill(), rssKill(), rssKill(), breaker(2048)]);
const c = await computeWorkerOomLoopCheck(null);
expect(c?.status).toBe('fail');
expect(c?.name).toBe('worker_oom_loop');
expect(c?.message).toContain('cap=2048MB');
expect(c?.message).toContain('raise --max-rss');
expect(c?.details?.oom_kills).toBe(6);
expect(c?.details?.supervisor_kills).toBe(6);
expect(c?.details?.bare_worker_kills).toBe(0);
expect(c?.details?.cap_source).toBe('breaker');
expect(c?.details?.breaker_tripped).toBe(true);
});
});
test('warn with auto-sized cap fallback when no breaker event stamped a cap (CODEX #6)', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => {
writeSupervisorRows([rssKill(), rssKill()]);
const c = await computeWorkerOomLoopCheck(null);
expect(c?.status).toBe('warn');
expect(c?.details?.cap_source).toBe('default');
expect(c?.message).toContain('auto-sized default');
expect(c?.details?.oom_kills).toBe(2);
});
});
test('fail at >=5 kills even without a breaker event', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => {
writeSupervisorRows([rssKill(), rssKill(), rssKill(), rssKill(), rssKill()]);
const c = await computeWorkerOomLoopCheck(null);
expect(c?.status).toBe('fail');
expect(c?.details?.breaker_tripped).toBe(false);
});
});
test('null when the worker never OOM-looped', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => {
writeSupervisorRows([{ event: 'started', ts: nowIso() }]);
expect(await computeWorkerOomLoopCheck(null)).toBeNull();
});
});
test('null on an empty audit dir', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => {
expect(await computeWorkerOomLoopCheck(null)).toBeNull();
});
});
});
describe('readRecentSupervisorEvents — cross-week (CODEX #7)', () => {
test('reads a within-24h event from the PREVIOUS ISO-week file', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => {
// Event timestamped within the 24h window but written into last week's
// file (the Monday-reads-Sunday case). The single-file reader would miss
// it; the cross-week reader must find it.
const prevWeekFileDate = new Date(Date.now() - 7 * 86400000);
writeSupervisorRows([rssKill()], prevWeekFileDate);
const events = readRecentSupervisorEvents(24);
expect(events.length).toBe(1);
expect(events[0].event).toBe('worker_exited');
});
});
});