mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
* test(supervisor): pin LOCK_HELD fence-exit is never counted as a crash (#2227) A duplicate supervisor loses the queue-scoped DB singleton lock (#1849) and exits LOCK_HELD before spawning a worker or emitting 'started'. summarizeCrashes counts only worker_exited, so the fence path is structurally uncountable. Pin it so a future refactor that logs worker_exited on the fence path fails here instead of silently re-introducing the crash-budget breaker-trip loop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(autopilot): per-source cycle binds FS phases to source.local_path, not global repo (#2194 #2227) A per-source autopilot-cycle inherited the global sync.repo_path as brainDir while stamping DB freshness for source_id — mixed scope. FS phases (sync/lint/extract) ran against the wrong tree, so the failure-cooldown and freshness gates would attribute work to the wrong source. Resolve the source's local_path in the handler (reuse the archive-recheck SELECT) and bind brainDir to it; a pure-DB source gets null (FS phases skip) instead of falling through to the global checkout. Legacy no-source dispatch keeps the global repoPath. Prerequisite for the cooldown/split commits (codex outside-voice #8). Resolves TODOS:634. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(supervisor): detect a live supervisor via the DB lock under split $HOME (#2227) jobs supervisor status + doctor read the HOME-derived pidfile, so a supervisor started under a different $HOME (keeper=/root vs ops=/data) read as 'not running' while healthy — the false signal that drives an operator to spawn a duplicate. Both surfaces now fall back to the queue-scoped DB singleton lock (#1849), the HOME-independent authority, when the pidfile shows nothing. New isLockHolderLive keys on lock freshness (ttl + heartbeat steal-grace), never process.kill, so PID reuse can't false-positive (pid-liveness-alone-pid-reuse). Status surfaces the holder host/pid + recorded concurrency/max-rss from the latest started event. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(supervisor): degraded retry instead of permanent give-up on crash storm (#1994 #2227) max_crashes_exceeded gave up forever, so a transient DB-pooler blip that tripped the soft budget wedged the queue until a human restart (#2227's breaker-trips tail). Crossing the soft budget now enters degraded mode: keep respawning with capped exponential backoff (60s cap — a paced retry, not a hot loop) and emit a loud crash_budget_degraded health_warn. The existing stable-run reset clears the count once a respawn survives >5min, so a recovered DB self-heals. Permanent give-up fires only at a much-higher hard ceiling (maxCrashes × 10), tunable/disablable via GBRAIN_SUPERVISOR_HARD_STOP_CRASHES (0 = never). Resolves TODOS:92. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autopilot): clamp fan-out to worker concurrency + doctor warning (#2194) Fan-out resolved to 4 (Postgres) regardless of worker --concurrency, so surplus cycles queued behind the worker and raced the stalled-sweeper. Two fixes for the same mismatch: - resolveEffectiveFanoutMax clamps to max(1, concurrency-1) (reserve a slot), gated on a LIVE DB-lock holder so a stale started-audit row can't shrink throughput (codex #9/D5); no live holder → unknown → unclamped base. Escape hatch autopilot.fanout_clamp_to_concurrency. - doctor's autopilot_fanout_concurrency check warns when fan-out exceeds effective slots — the misconfig was silent before. Advisory (started-event concurrency), wired into both doctor surfaces. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autopilot): per-source failure cooldown — break the dead-job storm (#2194) Only SUCCESS gated dispatch, so a source whose cycle kept failing/timing-out re-fanned-out every 5-min tick forever (200+ dead jobs/24h). Now a failed source backs off with bounded exponential cooldown (10→120min). Read at DISPATCH from minion_jobs dead/failed rows (timeouts/RSS-kills dead-letter via SQL and never run handler code, so a write-only hook would miss them) AND re-checked at CLAIM time in the handler (codex #5: already-queued/retrying jobs). A success clears it (codex #7); null-source rows excluded (codex #6); engine-parity via executeRaw. Disable with autopilot.failure_cooldown_min=0. Fail-open if config/history reads error. Surfaced via fanout_cooldown_skipped + the fanout summary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autopilot): split the cycle — per-source phases + one global-maintenance job (#2194 #2227) N per-source cycles each ran the brain-wide global phases (embed-all/orphans/ purge/…) concurrently, thrashing the same rows and taking the worker 4→10GB in <60s → RSS-kill → orphaned stalls. Split them: per-source jobs now run only source-scoped (+ mixed) phases and stamp last_source_cycle_at; a new autopilot-global-maintenance job runs the global phases ONCE per window (idempotency_key + maxWaiting:1 = structural single-flight) and stamps autopilot.last_global_at. This is the codex-endorsed design that replaced the rejected skip-and-stamp-fresh approach (codex #1/#2): no freshness poisoning, no starvation — global work always runs as its own job, never marked done when it wasn't. PHASE_SCOPE is now a runtime partition (GLOBAL ∪ NON_GLOBAL == ALL). last_full_cycle_at still written for doctor/legacy (no longer a global gate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(doctor): guard nullable engine in supervisor DB-lock fallback (#2227) Follow-up to the supervisor-visibility commit: doctor's engine binding is BrainEngine | null, so the inspectLock fallback must guard on a non-null engine (tsc TS2345). No behavior change — a null engine simply skips the DB-lock probe and falls back to the pidfile reading, as before. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(doctor): categorize autopilot_fanout_concurrency check as ops (#2194) Follow-up to the fan-out/concurrency commit: the doctor-categories drift guard requires every check name in doctor.ts to belong to exactly one category set. Add the new autopilot_fanout_concurrency check to OPS_CHECK_NAMES (infrastructure liveness, alongside wedged_queue/supervisor). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: update KEY_FILES for the autopilot cycle split + supervisor degraded-retry (#2194 #2227) Post-ship document-release: refresh the KEY_FILES current-state entries that drifted — cycle.ts (GLOBAL/NON_GLOBAL phase split + last_source_cycle_at / autopilot.last_global_at), jobs.ts (per-source local_path brainDir, claim-time cooldown, autopilot-global-maintenance handler), supervisor.ts + child-worker (degraded retry instead of permanent give-up; hard ceiling), db-lock.ts (isLockHolderLive), handler-timeouts (new handler). Regenerated llms bundle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(minions): handleTimeouts counts the timed-out run as a spent attempt (#1737) The per-job timeout_at dead-letter (handleTimeouts) set status='dead' without incrementing attempts_made, unlike the wall-clock and stall dead-letter siblings. It is the FIRST killer to fire for the long-lane handlers (subagent / embed-backfill / autopilot-cycle) because timeout_ms is stamped at submit, so a timed-out long job reported `attempts: 0/N (started: N)`. Mirror the siblings with attempts_made + 1 (terminal, no retry). Safe against double-count: the worker sweep runs handleStalled -> handleTimeouts -> handleWallClockTimeouts sequentially and awaited, each guarded on status='active', so the first to dead-letter excludes the row from the rest. Regression assertions added (test/minions.test.ts + e2e/minions-resilience.test.ts) so the increment can't be silently dropped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent): recognize trailing switches in `agent run`, keep prompts freeform (#1738) parseRunFlags() broke flag parsing at the first positional token, so any flag after the prompt (`gbrain agent run "do X" --detach`) was swallowed into the prompt string and silently ignored. Now the no-value switches --detach/--follow/ --no-follow are hoisted when they trail the prompt, while everything else stays verbatim: an unknown --word is treated as prompt text (no "unknown flag" throw), a --switch mid-prompt is preserved, and `--` suppresses hoisting entirely for a literal escape. Value-flags now reject a missing or flag-shaped value (and --max-turns/--timeout-ms a non-number) instead of capturing undefined/NaN. Contract change: a prompt that starts with or trails an unguarded --word no longer errors; a literal trailing --detach needs `--`. Help text updated; tests revised + extended (test/agent-cli.test.ts). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): honest live-sync status + progress-aware stall-abort (#1950) Finishes the #2255 honest-freshness story for two gaps it left. (a) `gbrain sources status` printed "idle" while a sync proc held the per-source lock (the reported bug). New shared liveSyncStatus() helper in db-lock.ts reads the SAME live-lock signal `gbrain doctor` uses; runStatus now shows "running" (BACKFILL column + a sync_running field in --json) and suppresses the misleading "never synced" warning while a sync is live. One helper, so the surfaces can't drift (doctor/status retrofit tracked as a follow-up). (b) A sync wedged-but-alive kept refreshing its lock heartbeat (it fires on its own timer) and hadn't hit the wall-clock deadline, so only a manual pkill freed it. New in-band stall watchdog keys off FORWARD IMPORT PROGRESS (progress.tick), not the heartbeat: if no file completes for GBRAIN_SYNC_STALL_ABORT_SECONDS (default 900s), it aborts via a controller composed into opts.signal, so the drain returns partial() (last_commit unchanged, next run resumes from the checkpoint) and withRefreshingLock releases the lock. Limits, documented in code: a single file slower than the window trips it; a fully starved event loop won't fire the timer (the wall-clock hard deadline is that backstop). Tests: liveSyncStatus (live/expired/none/per-source) in db-lock-inspect; the resolveStallAbortSeconds env matrix in sync-hard-deadline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(status): version field + per-section --deadline-ms budget (#1984) `gbrain status` had no version in its JSON envelope and could hang on a slow connection with no way to get a partial answer. Two additions: - version: the StatusReport JSON now carries the local gbrain CLI version so a poller can pin behavior to a build. Thin-client also surfaces remote_version (the brain server's version), and the get_status_snapshot MCP op reports its version for that parity. - --deadline-ms=N / --fast: a shared wall-clock budget. Each section is raced against the REMAINING budget via Promise.race (NOT process-watchdog, which SIGKILLs and can't return partial output), so one slow/hung section can't strand the snapshot — it's marked stale and the rest still return. The envelope gains partial:true + stale_sections[]; exit code stays 0 (a snapshot was produced). Invalid --deadline-ms → exit 2. Tests: parseDeadlineFlag + withSectionDeadline (hermetic), the usage-error exit, version presence in the PGLite envelope, and the op's version key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): report stall_timeout distinctly + document in-flight limit (#1950) Pre-landing review (codex + adversarial): the stall watchdog aborted opts.signal but the per-iteration abort checks returned partial('timeout'), collapsing a wedge-reap into a user --timeout/SIGINT so JSON consumers couldn't tell them apart. Add a 'stall_timeout' reason (set via a stallAborted flag) on the three import-loop abort sites; deletes/renames-phase and checkpoint sites stay 'timeout'. Sharpen the watchdog comment: the abort is observed BETWEEN files, so a hang inside a single importFile is not interrupted until it returns (TODO: thread a cancellation signal through importFile). * fix(agent): `--` escape suppresses trailing-switch hoisting anywhere (#1738) Pre-landing review: the leading-flag loop breaks at the first positional, so the `escaped` flag only fired for a leading `--`. A `--` placed after a positional left trailing-switch hoisting active, so `agent run note -- body --detach` silently detached and dropped the `--` as junk. Suppress hoisting whenever a literal `--` appears in the prompt. Regression test added. * fix(status): deadline-ms usage-error + scoped stale_sections + cancel losing remote call (#1984) Pre-landing review (codex): (1) bare `--deadline-ms` with no value silently fell through to no-budget/--fast instead of a usage error; (2) thin-client timeout reported both sync+cycle stale even under `--section sync`, naming a section the caller excluded (local path was already correct); (3) the section race abandoned the remote promise locally but didn't cancel the in-flight MCP call — pass the budget as timeoutMs so the losing side actually cancels. Regression test added. * v0.42.52.0 fix(reliability): autopilot dead-job storm + supervisor wedge + sync/status/minion reliability (#2194 #2227 #1994 #1737 #1738 #1950 #1984) Bundles the already-reviewed autopilot/supervisor stabilization (#2194 #2227 #1994: cycle split, per-source failure cooldown, fan-out clamp, degraded supervisor retry, DB-lock live-supervisor detection) with four operational fixes: minion timeout attempt-accounting (#1737), agent-run trailing-flag parsing (#1738), honest live-sync sources status + progress-aware stall watchdog (#1950), and status version + --deadline-ms partial result (#1984). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: document GBRAIN_SYNC_STALL_ABORT_SECONDS env knob (#1950) Post-ship doc sync (/document-release): add the sync stall watchdog env var to the CLAUDE.md sync-tuning table (Five → Six knobs) + regenerate the llms bundle. * test: quarantine #2249 fanout tests as *.serial (R1 env-isolation) (#2194) The cherry-picked autopilot-fanout-clamp + doctor-autopilot-fanout-concurrency tests mutate process.env.GBRAIN_AUDIT_DIR in beforeEach/afterEach, which the check:test-isolation R1 lint flags (parallel shards load multiple files per process). Rename to *.serial.test.ts (sanctioned quarantine — they run under --max-concurrency=1) instead of restructuring the reviewed test bodies. No logic change; both files stay green (9 tests). Fixes the failing verify CI check. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
244 lines
12 KiB
TypeScript
244 lines
12 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);
|
|
});
|
|
});
|
|
|
|
// issue #2227 bug #1/#2: a duplicate supervisor (different $HOME / --pid-file)
|
|
// passes the pidfile guard but loses the queue-scoped DB singleton lock
|
|
// (#1849) and `process.exit(ExitCodes.LOCK_HELD)`s. That fence-exit happens in
|
|
// supervisor.ts:start() BEFORE the worker is ever spawned and BEFORE the
|
|
// `started` event is emitted (the DB-lock acquire at :528 precedes the
|
|
// `started` emit at :555), so the loser contributes NO `worker_exited` events
|
|
// to the audit trail. The crash ledger (summarizeCrashes) moves ONLY on
|
|
// `worker_exited`, so a fence-collision can never burn the crash budget the
|
|
// guard exists to protect. The field report claimed 20 "unknown" crashes were
|
|
// fence exits; this pins that the fence path is structurally uncountable, so a
|
|
// future refactor that (wrongly) logs a `worker_exited` on the LOCK_HELD path
|
|
// would fail here instead of silently re-introducing the breaker-trip loop.
|
|
describe('summarizeCrashes — LOCK_HELD fence-exit is never a crash (issue #2227)', () => {
|
|
test('a losing duplicate supervisor (no worker_exited) contributes zero crashes', () => {
|
|
// The loser exits at the DB-lock fence before emitting `started`, so its
|
|
// audit contribution is empty. Even paired with the winner's healthy
|
|
// stream, total crashes stay 0.
|
|
const winnerHealthy: SupervisorEmission[] = [
|
|
evt('started', { supervisor_pid: 4242, queue: 'default', concurrency: 3 }),
|
|
evt('worker_spawned', { worker_pid: 4243 }),
|
|
// ... no worker_exited yet (winner still running)
|
|
];
|
|
expect(summarizeCrashes(winnerHealthy).total).toBe(0);
|
|
});
|
|
|
|
test('started + stopped with no worker_exited (clean fence path) is zero crashes', () => {
|
|
// Defensive: even if a future change made the loser emit lifecycle events
|
|
// (started/shutting_down/stopped) around the LOCK_HELD exit, none of those
|
|
// are `worker_exited`, so the ledger must stay at 0. The only way this test
|
|
// fails is if someone logs a `worker_exited` on the fence path — which is
|
|
// exactly the regression #2227 fix #2 guards against.
|
|
const fenceStream: SupervisorEmission[] = [
|
|
evt('started', { supervisor_pid: 5252 }),
|
|
evt('shutting_down', { reason: 'LOCK_HELD' }),
|
|
evt('stopped', { exit_code: 2 }),
|
|
];
|
|
const summary = summarizeCrashes(fenceStream);
|
|
expect(summary.total).toBe(0);
|
|
expect(summary.clean_exits).toBe(0);
|
|
});
|
|
});
|