mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(minions): self-identifying RSS watchdog + cgroup-aware default + pooler-reap self-heal (#1678) Problem 1: distinct WORKER_EXIT_RSS_WATCHDOG exit code + cause-keyed supervisor breaker (bypasses the stable-run reset that hid the 400x/24h loop) + rss_watchdog audit bucket + 80% soft-warn; cgroup-aware resolveDefaultMaxRssMb replaces the flat 2048 default at every spawn site. Problem 2: CONNECTION_ENDED classified retryable; postgres-engine sql getter throws a retryable error on a reaped instance pool instead of the misleading module-singleton fallthrough; promoteDelayed reconnect-retry; claim recovers on the next poll tick (no double-claim); lock-renewal tick reconnect-once dep. * feat(cycle): surface silent extract_atoms backlog + bounded --drain + fix lint clobbering the shared DB connection (#1678) Problem 3: extract_atoms_backlog doctor check + pack_gated skip marker + shared countExtractAtomsBacklog; `gbrain dream --phase extract_atoms --drain [--window N]` single-hold bounded drain (same cycleLockIdFor, rediscover each batch, reports remaining, exits non-zero while work remains). Also fixes a real production bug found via E2E: the cycle lint phase's resolveLintContentSanity created + disconnected a module-style engine that nulled the shared db singleton mid-cycle, breaking every later phase with "connect() has not been called". Lint now reuses the caller's live engine (cycle + Minion handlers thread it; standalone CLI keeps the create-own path). * chore: bump version and changelog (v0.41.39.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#1678): pre-landing review — route transaction/withReservedConnection through the sql getter + drain treats failed count as incomplete Codex adversarial review findings: - #2: transaction(), withReservedConnection(), and one other site bypassed the v0.42.2.0 sql-getter self-heal via `this._sql || db.getConnection()`, so a reaped instance pool fell through to the module singleton there. Route all three through `this.sql` so they throw the retryable instance-pool error and recover consistently (MinionQueue.transaction hits this). - #4: `gbrain dream --drain` treated a null backlog count (query failure) as success via `remaining ?? 0`; now null exits EXIT_DRAIN_INCOMPLETE so automation never believes an unverified backlog drained. - #1 (claim orphan) + #3 (PGLite drain lock) documented as follow-ups in TODOS. * docs: document v0.42.2.0 #1678 modules + behavior in CLAUDE.md Adds Key Files entries for worker-exit-codes.ts, rss-default.ts, and extract-atoms-drain.ts, plus v0.42.2.0 annotations on worker.ts, child-worker-supervisor.ts, lock-renewal-tick.ts, and dream.ts. Regenerated llms-full.txt to match (test/build-llms.test.ts gate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: re-version v0.42.2.0 → v0.42.5.0 across VERSION/package.json/CHANGELOG/docs/comments Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
202 lines
9.4 KiB
TypeScript
202 lines
9.4 KiB
TypeScript
/**
|
|
* Unit tests for the shared crash classifier used by `gbrain doctor` and
|
|
* `gbrain jobs supervisor status`. Both surfaces import `isCrashExit` +
|
|
* `summarizeCrashes` from `src/core/minions/handlers/supervisor-audit.ts`;
|
|
* pinning them here keeps the two CLI surfaces from drifting.
|
|
*
|
|
* Why this file exists: pre-fix the doctor counted every `worker_exited`
|
|
* event as a crash, regardless of `likely_cause`. Clean SIGTERM shutdowns
|
|
* and RSS-watchdog drains (code=0) inflated `crashes_24h` to 120+/day on
|
|
* Garry's brain. The classifier upstream in child-worker-supervisor.ts
|
|
* already stamped `likely_cause` correctly; the read sites just ignored it.
|
|
* These tests pin every branch of the new shared classifier so the bug
|
|
* cannot silently recur.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
isCrashExit,
|
|
summarizeCrashes,
|
|
type CrashSummary,
|
|
} from '../src/core/minions/handlers/supervisor-audit.ts';
|
|
import type { SupervisorEmission } from '../src/core/minions/supervisor.ts';
|
|
|
|
// Helper: build a SupervisorEmission of the given event with arbitrary extra
|
|
// fields. `ts` is required by the type but irrelevant to the classifier; we
|
|
// stamp a constant so failures show predictable fixtures.
|
|
function evt(
|
|
event: SupervisorEmission['event'],
|
|
extra: Record<string, unknown> = {},
|
|
): SupervisorEmission {
|
|
return { event, ts: '2026-05-16T00:00:00Z', ...extra };
|
|
}
|
|
|
|
describe('isCrashExit — branch matrix', () => {
|
|
// Case 1: explicit clean exit (worker returned code=0 voluntarily,
|
|
// e.g. RSS watchdog drain). The most common cause of the original
|
|
// bug — every drain was being counted as a crash.
|
|
test('clean_exit is not a crash', () => {
|
|
expect(isCrashExit(evt('worker_exited', { likely_cause: 'clean_exit', code: 0 }))).toBe(false);
|
|
});
|
|
|
|
// Case 2: SIGTERM-driven shutdown (operator stop, OS-initiated graceful
|
|
// termination). Also not a crash.
|
|
test('graceful_shutdown is not a crash', () => {
|
|
expect(isCrashExit(evt('worker_exited', { likely_cause: 'graceful_shutdown', signal: 'SIGTERM' }))).toBe(false);
|
|
});
|
|
|
|
// Case 3: code=1 from the worker process — a real runtime error.
|
|
test('runtime_error is a crash', () => {
|
|
expect(isCrashExit(evt('worker_exited', { likely_cause: 'runtime_error', code: 1 }))).toBe(true);
|
|
});
|
|
|
|
// Case 4: SIGKILL (kernel OOM kill, external `kill -9`). Real crash.
|
|
test('oom_or_external_kill is a crash', () => {
|
|
expect(isCrashExit(evt('worker_exited', { likely_cause: 'oom_or_external_kill', signal: 'SIGKILL' }))).toBe(true);
|
|
});
|
|
|
|
// Case 5: catch-all bucket from the upstream classifier (unusual code or
|
|
// signal combination). Real crash.
|
|
test('unknown is a crash', () => {
|
|
expect(isCrashExit(evt('worker_exited', { likely_cause: 'unknown', code: 137 }))).toBe(true);
|
|
});
|
|
|
|
// Case 6: denylist regression guard. If a future maintainer adds a NEW
|
|
// `likely_cause` value upstream (e.g. `lock_lost`, `panic`,
|
|
// `db_connection_lost`), the doctor MUST surface it by default. Allowlist
|
|
// semantics would have silently misclassified this as clean — the exact
|
|
// bug class this fix exists to close.
|
|
test('unrecognized future likely_cause is a crash (denylist regression guard)', () => {
|
|
expect(isCrashExit(evt('worker_exited', { likely_cause: 'future_value_not_known' }))).toBe(true);
|
|
});
|
|
|
|
// Case 7: legacy fallback path — pre-v0.34 audit lines lacking
|
|
// `likely_cause`. Use `code` to classify: code=0 is clean.
|
|
test('legacy (no likely_cause) with code=0 is not a crash', () => {
|
|
expect(isCrashExit(evt('worker_exited', { code: 0 }))).toBe(false);
|
|
});
|
|
|
|
// Case 8: legacy fallback path — pre-v0.34 entry with code=1 (real crash).
|
|
test('legacy (no likely_cause) with code=1 is a crash', () => {
|
|
expect(isCrashExit(evt('worker_exited', { code: 1 }))).toBe(true);
|
|
});
|
|
|
|
// Case 9: defensive — non-exit lifecycle events MUST never be counted as
|
|
// crashes regardless of their other fields. Catches a future caller that
|
|
// forgets the upstream `event === 'worker_exited'` filter.
|
|
test('non-exit event is never a crash', () => {
|
|
expect(isCrashExit(evt('started'))).toBe(false);
|
|
expect(isCrashExit(evt('worker_spawned', { code: 1 }))).toBe(false);
|
|
expect(isCrashExit(evt('max_crashes_exceeded', { likely_cause: 'runtime_error' }))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('summarizeCrashes — aggregation', () => {
|
|
// Feed a representative mixed stream and assert every bucket. The mix
|
|
// exercises every classifier branch so the message-format consumers
|
|
// (doctor.ts and jobs.ts) get a stable shape.
|
|
test('aggregates a mixed stream into per-cause buckets and clean_exits', () => {
|
|
const events: SupervisorEmission[] = [
|
|
evt('worker_exited', { likely_cause: 'runtime_error', code: 1 }),
|
|
evt('worker_exited', { likely_cause: 'runtime_error', code: 1 }),
|
|
evt('worker_exited', { likely_cause: 'oom_or_external_kill', signal: 'SIGKILL' }),
|
|
evt('worker_exited', { likely_cause: 'unknown', code: 137 }),
|
|
evt('worker_exited', { code: 1 }), // legacy (no likely_cause)
|
|
evt('worker_exited', { likely_cause: 'clean_exit', code: 0 }),
|
|
evt('worker_exited', { likely_cause: 'clean_exit', code: 0 }),
|
|
evt('worker_exited', { likely_cause: 'clean_exit', code: 0 }),
|
|
evt('worker_exited', { likely_cause: 'graceful_shutdown', signal: 'SIGTERM' }),
|
|
// Non-exit events MUST be ignored (no double-counting against either bucket).
|
|
evt('started'),
|
|
evt('worker_spawned'),
|
|
evt('health_warn'),
|
|
];
|
|
|
|
const summary: CrashSummary = summarizeCrashes(events);
|
|
|
|
expect(summary.total).toBe(5);
|
|
expect(summary.by_cause.runtime_error).toBe(2);
|
|
expect(summary.by_cause.oom_or_external_kill).toBe(1);
|
|
expect(summary.by_cause.unknown).toBe(1);
|
|
expect(summary.by_cause.legacy).toBe(1);
|
|
expect(summary.clean_exits).toBe(4);
|
|
// total + clean_exits should equal the count of worker_exited events,
|
|
// proving non-exit lifecycle events were excluded from both buckets.
|
|
const exitCount = events.filter((e) => e.event === 'worker_exited').length;
|
|
expect(summary.total + summary.clean_exits).toBe(exitCount);
|
|
});
|
|
|
|
test('empty input returns zero summary', () => {
|
|
const summary = summarizeCrashes([]);
|
|
expect(summary).toEqual({
|
|
total: 0,
|
|
by_cause: { runtime_error: 0, oom_or_external_kill: 0, rss_watchdog: 0, unknown: 0, legacy: 0 },
|
|
clean_exits: 0,
|
|
});
|
|
});
|
|
|
|
// issue #1678: rss_watchdog is a crash-classified cause (NOT in
|
|
// CLEAN_EXIT_CAUSES) with its OWN bucket — operators watching
|
|
// by_cause.rss_watchdog rise know the cap is too low for the workload, a
|
|
// distinct signal from a generic runtime_error or OOM-killer SIGKILL.
|
|
test('rss_watchdog routes to its own bucket, not legacy', () => {
|
|
const summary = summarizeCrashes([
|
|
evt('worker_exited', { likely_cause: 'rss_watchdog' }),
|
|
evt('worker_exited', { likely_cause: 'rss_watchdog' }),
|
|
evt('worker_exited', { likely_cause: 'runtime_error' }),
|
|
]);
|
|
expect(summary.total).toBe(3);
|
|
expect(summary.by_cause.rss_watchdog).toBe(2);
|
|
expect(summary.by_cause.runtime_error).toBe(1);
|
|
expect(summary.by_cause.legacy).toBe(0);
|
|
expect(summary.clean_exits).toBe(0);
|
|
});
|
|
|
|
// isCrashExit treats rss_watchdog as a crash (it's a real problem), NOT a
|
|
// clean exit — pins that the worker draining itself on a too-low cap shows
|
|
// up in operator health surfaces instead of looking like a clean drain.
|
|
test('isCrashExit classifies rss_watchdog as a crash', () => {
|
|
expect(isCrashExit(evt('worker_exited', { likely_cause: 'rss_watchdog' }))).toBe(true);
|
|
});
|
|
|
|
test('only non-exit events returns zero summary', () => {
|
|
const summary = summarizeCrashes([evt('started'), evt('worker_spawned'), evt('stopped')]);
|
|
expect(summary.total).toBe(0);
|
|
expect(summary.clean_exits).toBe(0);
|
|
});
|
|
|
|
// Denylist regression guard at the AGGREGATOR level. isCrashExit Case 6
|
|
// proves an unrecognized future `likely_cause` is counted as a crash; this
|
|
// pins which BUCKET it lands in. The `else` branch in summarizeCrashes
|
|
// routes any crash-classified event whose cause doesn't match the three
|
|
// explicit buckets into `legacy`. Operators watching `legacy=N` rise know
|
|
// the upstream classifier added a value the doctor doesn't yet name —
|
|
// that's the intended signal.
|
|
test('unrecognized future likely_cause routes to legacy bucket', () => {
|
|
const summary = summarizeCrashes([
|
|
evt('worker_exited', { likely_cause: 'lock_lost' }),
|
|
evt('worker_exited', { likely_cause: 'panic' }),
|
|
]);
|
|
expect(summary.total).toBe(2);
|
|
expect(summary.by_cause.legacy).toBe(2);
|
|
expect(summary.by_cause.runtime_error).toBe(0);
|
|
expect(summary.by_cause.oom_or_external_kill).toBe(0);
|
|
expect(summary.by_cause.unknown).toBe(0);
|
|
});
|
|
|
|
// Truly malformed legacy line — `likely_cause` missing AND `code` null
|
|
// (or undefined). The classifier comment explicitly says "fail-loud, the
|
|
// user can investigate the audit file directly", which means count it.
|
|
// null !== 0 is true so isCrashExit returns true; summarizeCrashes then
|
|
// lands it in legacy. Pinning this prevents a regression where a future
|
|
// refactor adds `code != null` and silently drops malformed entries.
|
|
test('legacy entry with null code counts as crash in legacy bucket', () => {
|
|
const summary = summarizeCrashes([
|
|
evt('worker_exited', { code: null }),
|
|
]);
|
|
expect(summary.total).toBe(1);
|
|
expect(summary.by_cause.legacy).toBe(1);
|
|
expect(summary.clean_exits).toBe(0);
|
|
});
|
|
});
|