mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 21:19:18 +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>
526 lines
21 KiB
TypeScript
526 lines
21 KiB
TypeScript
import { describe, it, expect, afterEach } from 'bun:test';
|
||
import { existsSync, readFileSync, writeFileSync, unlinkSync, chmodSync, mkdirSync, rmSync } from 'fs';
|
||
import { spawn } from 'child_process';
|
||
import { join } from 'path';
|
||
import { tmpdir } from 'os';
|
||
import { readSupervisorEvents, computeSupervisorAuditFilename } from '../src/core/minions/handlers/supervisor-audit.ts';
|
||
import { calculateBackoffMs, resolveHardStopMaxCrashes } from '../src/core/minions/supervisor.ts';
|
||
|
||
const TEST_PID_FILE = '/tmp/gbrain-supervisor-test.pid';
|
||
|
||
afterEach(() => {
|
||
try { unlinkSync(TEST_PID_FILE); } catch { /* noop */ }
|
||
});
|
||
|
||
// ----- Integration test helpers -----
|
||
|
||
interface IntegrationHarness {
|
||
pidFile: string;
|
||
auditDir: string;
|
||
workerScript: string;
|
||
envOutFile: string;
|
||
cleanup: () => void;
|
||
}
|
||
|
||
/** Create per-test temp files + a fake worker shell script. */
|
||
function makeHarness(name: string, workerBody: string): IntegrationHarness {
|
||
const tmpRoot = join(tmpdir(), `gbrain-sup-test-${name}-${process.pid}-${Date.now()}`);
|
||
mkdirSync(tmpRoot, { recursive: true });
|
||
const pidFile = join(tmpRoot, 'supervisor.pid');
|
||
const auditDir = join(tmpRoot, 'audit');
|
||
const workerScript = join(tmpRoot, 'worker.sh');
|
||
const envOutFile = join(tmpRoot, 'env-out.txt');
|
||
|
||
writeFileSync(workerScript, `#!/bin/sh\n${workerBody}\n`, 'utf8');
|
||
chmodSync(workerScript, 0o755);
|
||
|
||
return {
|
||
pidFile,
|
||
auditDir,
|
||
workerScript,
|
||
envOutFile,
|
||
cleanup: () => { try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* noop */ } },
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Spawn the supervisor runner as a subprocess. Returns a handle with the
|
||
* child, a promise resolving to exit code + signal, and a kill helper.
|
||
*/
|
||
function spawnSupervisor(h: IntegrationHarness, overrides: Record<string, string> = {}) {
|
||
const env: Record<string, string> = {
|
||
...(process.env as Record<string, string>),
|
||
SUP_PID_FILE: h.pidFile,
|
||
SUP_CLI_PATH: h.workerScript,
|
||
SUP_AUDIT_DIR: h.auditDir,
|
||
SUP_BACKOFF_FLOOR_MS: '5',
|
||
SUP_MAX_CRASHES: '3',
|
||
SUP_HEALTH_INTERVAL_MS: '999999', // effectively off
|
||
...overrides,
|
||
};
|
||
// issue #1994: the soft crash budget now DEGRADES (retry-with-backoff) rather
|
||
// than permanently giving up; permanent give-up fires at a much-higher hard
|
||
// ceiling (maxCrashes × 10). These integration tests assert the give-up
|
||
// LIFECYCLE (audit events, exit code, pidfile cleanup), so pin the hard
|
||
// ceiling to the soft budget by default — the degraded path is unit-tested in
|
||
// child-worker-supervisor.test.ts. Tests that want true degraded behavior
|
||
// pass GBRAIN_SUPERVISOR_HARD_STOP_CRASHES explicitly.
|
||
if (env.GBRAIN_SUPERVISOR_HARD_STOP_CRASHES === undefined) {
|
||
env.GBRAIN_SUPERVISOR_HARD_STOP_CRASHES = env.SUP_MAX_CRASHES;
|
||
}
|
||
|
||
const child = spawn('bun', [join(import.meta.dir, 'fixtures/supervisor-runner.ts')], {
|
||
env,
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
|
||
let stdout = '';
|
||
let stderr = '';
|
||
child.stdout?.on('data', (d) => { stdout += d.toString(); });
|
||
child.stderr?.on('data', (d) => { stderr += d.toString(); });
|
||
|
||
const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
|
||
child.on('exit', (code, signal) => resolve({ code, signal }));
|
||
});
|
||
|
||
return {
|
||
child,
|
||
exited,
|
||
getStdout: () => stdout,
|
||
getStderr: () => stderr,
|
||
};
|
||
}
|
||
|
||
/** Read the audit JSONL for the current week. */
|
||
function readAudit(auditDir: string) {
|
||
const origEnv = process.env.GBRAIN_AUDIT_DIR;
|
||
process.env.GBRAIN_AUDIT_DIR = auditDir;
|
||
try {
|
||
return readSupervisorEvents();
|
||
} finally {
|
||
if (origEnv === undefined) delete process.env.GBRAIN_AUDIT_DIR;
|
||
else process.env.GBRAIN_AUDIT_DIR = origEnv;
|
||
}
|
||
}
|
||
|
||
/** Poll until predicate returns true or deadline elapses. */
|
||
async function waitFor(pred: () => boolean, timeoutMs: number, tickMs = 20): Promise<boolean> {
|
||
const deadline = Date.now() + timeoutMs;
|
||
while (Date.now() < deadline) {
|
||
if (pred()) return true;
|
||
await new Promise(r => setTimeout(r, tickMs));
|
||
}
|
||
return pred();
|
||
}
|
||
|
||
describe('MinionSupervisor', () => {
|
||
describe('resolveHardStopMaxCrashes (issue #1994)', () => {
|
||
const KEY = 'GBRAIN_SUPERVISOR_HARD_STOP_CRASHES';
|
||
afterEach(() => { delete process.env[KEY]; });
|
||
|
||
it('defaults to maxCrashes × 10 when no override', () => {
|
||
delete process.env[KEY];
|
||
expect(resolveHardStopMaxCrashes(10)).toBe(100);
|
||
expect(resolveHardStopMaxCrashes(3)).toBe(30);
|
||
});
|
||
|
||
it('honors a valid non-negative integer override', () => {
|
||
process.env[KEY] = '0'; // 0 = disable permanent give-up
|
||
expect(resolveHardStopMaxCrashes(10)).toBe(0);
|
||
process.env[KEY] = '5';
|
||
expect(resolveHardStopMaxCrashes(10)).toBe(5);
|
||
});
|
||
|
||
it('ignores a negative or non-integer override (falls back to default)', () => {
|
||
process.env[KEY] = '-1';
|
||
expect(resolveHardStopMaxCrashes(10)).toBe(100);
|
||
process.env[KEY] = 'abc';
|
||
expect(resolveHardStopMaxCrashes(10)).toBe(100);
|
||
});
|
||
});
|
||
|
||
describe('calculateBackoffMs', () => {
|
||
it('returns ~1s for first crash', () => {
|
||
const backoff = calculateBackoffMs(0);
|
||
expect(backoff).toBeGreaterThanOrEqual(1000);
|
||
expect(backoff).toBeLessThan(1200); // 1000 + 10% jitter max
|
||
});
|
||
|
||
it('doubles with each crash', () => {
|
||
const b0 = calculateBackoffMs(0);
|
||
const b1 = calculateBackoffMs(1);
|
||
const b2 = calculateBackoffMs(2);
|
||
// Approximate: b1 should be ~2x b0, b2 ~2x b1 (within jitter)
|
||
expect(b1).toBeGreaterThan(1800);
|
||
expect(b2).toBeGreaterThan(3600);
|
||
});
|
||
|
||
it('caps at 60s', () => {
|
||
const backoff = calculateBackoffMs(20); // 2^20 * 1000 would be huge
|
||
expect(backoff).toBeLessThanOrEqual(66_000); // 60s + 10% jitter
|
||
});
|
||
|
||
it('includes jitter (not perfectly deterministic)', () => {
|
||
const values = new Set<number>();
|
||
for (let i = 0; i < 10; i++) {
|
||
values.add(Math.round(calculateBackoffMs(3)));
|
||
}
|
||
// With 10% jitter, we should get some variation
|
||
expect(values.size).toBeGreaterThan(1);
|
||
});
|
||
});
|
||
|
||
describe('PID file management', () => {
|
||
it('detects stale PID files', () => {
|
||
// Write a PID file with a non-existent PID
|
||
writeFileSync(TEST_PID_FILE, '999999999');
|
||
expect(existsSync(TEST_PID_FILE)).toBe(true);
|
||
|
||
// A real supervisor would detect this as stale and overwrite
|
||
const existingPid = parseInt(readFileSync(TEST_PID_FILE, 'utf8').trim(), 10);
|
||
let isAlive = false;
|
||
try {
|
||
process.kill(existingPid, 0);
|
||
isAlive = true;
|
||
} catch {
|
||
isAlive = false;
|
||
}
|
||
expect(isAlive).toBe(false);
|
||
});
|
||
|
||
it('detects live PID files (current process)', () => {
|
||
// Write our own PID
|
||
writeFileSync(TEST_PID_FILE, String(process.pid));
|
||
|
||
const existingPid = parseInt(readFileSync(TEST_PID_FILE, 'utf8').trim(), 10);
|
||
let isAlive = false;
|
||
try {
|
||
process.kill(existingPid, 0);
|
||
isAlive = true;
|
||
} catch {
|
||
isAlive = false;
|
||
}
|
||
expect(isAlive).toBe(true);
|
||
expect(existingPid).toBe(process.pid);
|
||
});
|
||
});
|
||
|
||
describe('crash count tracking', () => {
|
||
it('backoff escalates with crash count', () => {
|
||
const backoffs = [];
|
||
for (let i = 0; i < 7; i++) {
|
||
backoffs.push(calculateBackoffMs(i));
|
||
}
|
||
// Each should be roughly 2x the previous (within jitter)
|
||
for (let i = 1; i < 6; i++) {
|
||
// The base doubles, so even with jitter the next should be > 1.5x previous
|
||
expect(backoffs[i]).toBeGreaterThan(backoffs[i - 1] * 1.5);
|
||
}
|
||
});
|
||
});
|
||
|
||
// --------------------------------------------------------------
|
||
// Integration tests: real spawn(), real signals, real audit file.
|
||
// Each test uses a unique tmpdir harness so they can run in parallel
|
||
// without colliding. `_backoffFloorMs: 5` (set via SUP_BACKOFF_FLOOR_MS)
|
||
// keeps the whole suite under a few seconds.
|
||
// --------------------------------------------------------------
|
||
|
||
describe('integration: crash → restart → max-crashes lifecycle', () => {
|
||
it('respawns the worker after a crash and eventually exits with max-crashes code=1', async () => {
|
||
// Worker always exits with code 1; supervisor should respawn it 3 times,
|
||
// hit max-crashes, then exit via shutdown() with code 1.
|
||
const h = makeHarness('max-crashes', 'exit 1');
|
||
try {
|
||
// hard ceiling defaults to SUP_MAX_CRASHES in the harness (see
|
||
// spawnSupervisor) so this give-up lifecycle still fires at 3 (#1994).
|
||
const sup = spawnSupervisor(h, { SUP_MAX_CRASHES: '3' });
|
||
const { code } = await sup.exited;
|
||
|
||
expect(code).toBe(1);
|
||
|
||
// PID file cleaned up on exit (synchronous process.on('exit') handler).
|
||
expect(existsSync(h.pidFile)).toBe(false);
|
||
|
||
// Audit file should contain started + 3x worker_spawned/worker_exited +
|
||
// max_crashes_exceeded + shutting_down + stopped.
|
||
const events = readAudit(h.auditDir);
|
||
const eventTypes = events.map(e => e.event);
|
||
expect(eventTypes).toContain('started');
|
||
expect(eventTypes.filter(t => t === 'worker_spawned').length).toBeGreaterThanOrEqual(3);
|
||
expect(eventTypes.filter(t => t === 'worker_exited').length).toBeGreaterThanOrEqual(3);
|
||
expect(eventTypes).toContain('max_crashes_exceeded');
|
||
expect(eventTypes).toContain('shutting_down');
|
||
expect(eventTypes).toContain('stopped');
|
||
|
||
// The stopped event should carry exit_code=1 and reason=max_crashes.
|
||
const stoppedEvt = events.filter(e => e.event === 'stopped').pop();
|
||
expect((stoppedEvt as Record<string, unknown>).exit_code).toBe(1);
|
||
expect((stoppedEvt as Record<string, unknown>).reason).toBe('max_crashes');
|
||
} finally {
|
||
h.cleanup();
|
||
}
|
||
}, 15_000);
|
||
});
|
||
|
||
describe('integration: graceful SIGTERM during backoff', () => {
|
||
it('receives SIGTERM while sleeping between crashes and exits 0 cleanly', async () => {
|
||
// Worker always exits with code 1; supervisor has a high max-crashes
|
||
// and a long-enough backoff floor that we can reliably catch it mid-sleep.
|
||
const h = makeHarness('sigterm-backoff', 'exit 1');
|
||
try {
|
||
const sup = spawnSupervisor(h, {
|
||
SUP_MAX_CRASHES: '100',
|
||
SUP_BACKOFF_FLOOR_MS: '800', // 800ms between restarts — enough to catch
|
||
});
|
||
|
||
// Wait until the supervisor has written the PID file AND survived at
|
||
// least one worker_exited (so it's definitely in the backoff sleep).
|
||
const ready = await waitFor(() => {
|
||
if (!existsSync(h.pidFile)) return false;
|
||
const events = readAudit(h.auditDir);
|
||
return events.some(e => e.event === 'worker_exited');
|
||
}, 3000);
|
||
expect(ready).toBe(true);
|
||
|
||
// Now SIGTERM the supervisor. It must exit cleanly within 200ms
|
||
// (short-circuits the 800ms backoff sleep via the stopping flag).
|
||
const sigSentAt = Date.now();
|
||
sup.child.kill('SIGTERM');
|
||
|
||
const { code, signal } = await sup.exited;
|
||
const elapsed = Date.now() - sigSentAt;
|
||
|
||
// Exit code 0 = clean; signal=null means we exited via process.exit, not got killed.
|
||
expect(code).toBe(0);
|
||
expect(signal).toBe(null);
|
||
// Graceful, not hung: exit within 5s (process.exit() through shutdown()
|
||
// should be near-instant; generous bound to tolerate CI slowness).
|
||
expect(elapsed).toBeLessThan(5000);
|
||
|
||
const events = readAudit(h.auditDir);
|
||
const eventTypes = events.map(e => e.event);
|
||
expect(eventTypes).toContain('shutting_down');
|
||
expect(eventTypes).toContain('stopped');
|
||
|
||
const shuttingEvt = events.filter(e => e.event === 'shutting_down').pop();
|
||
expect((shuttingEvt as Record<string, unknown>).reason).toBe('SIGTERM');
|
||
|
||
// PID file cleaned up.
|
||
expect(existsSync(h.pidFile)).toBe(false);
|
||
} finally {
|
||
h.cleanup();
|
||
}
|
||
}, 20_000);
|
||
});
|
||
|
||
describe('integration: env-var inheritance regression (codex #9 / eng #8)', () => {
|
||
it('strips inherited GBRAIN_ALLOW_SHELL_JOBS when allowShellJobs=false, even if parent has it set', async () => {
|
||
const outFile = join(tmpdir(), `gbrain-sup-env-${process.pid}-${Date.now()}.txt`);
|
||
try { unlinkSync(outFile); } catch { /* may not exist */ }
|
||
|
||
// Worker writes env to OUT_FILE then exits 1. exit=1 is required (not
|
||
// exit=0) because post-D1/D2 (v0.33) clean exits don't count toward
|
||
// crashCount — the supervisor would respawn forever. The test's
|
||
// assertion is on the OUT_FILE contents (env plumbing), not the
|
||
// exit code, so any non-zero code that trips SUP_MAX_CRASHES=1 works.
|
||
const h = makeHarness('env-strip-outfile', `printf '%s\\n' "\${GBRAIN_ALLOW_SHELL_JOBS-UNSET}" > "$OUT_FILE" ; exit 1`);
|
||
|
||
try {
|
||
const sup = spawnSupervisor(h, {
|
||
OUT_FILE: outFile,
|
||
GBRAIN_ALLOW_SHELL_JOBS: '1', // parent has it
|
||
SUP_ALLOW_SHELL_JOBS: '0', // supervisor says NO
|
||
SUP_MAX_CRASHES: '1',
|
||
});
|
||
|
||
await sup.exited;
|
||
|
||
// Worker should have written "UNSET" (parent env var stripped from child).
|
||
expect(existsSync(outFile)).toBe(true);
|
||
const childSawEnv = readFileSync(outFile, 'utf8').trim();
|
||
expect(childSawEnv).toBe('UNSET');
|
||
} finally {
|
||
try { unlinkSync(outFile); } catch { /* noop */ }
|
||
h.cleanup();
|
||
}
|
||
}, 15_000);
|
||
|
||
it('DOES pass GBRAIN_ALLOW_SHELL_JOBS to child when allowShellJobs is true', async () => {
|
||
const outFile = join(tmpdir(), `gbrain-sup-env-ok-${process.pid}-${Date.now()}.txt`);
|
||
try { unlinkSync(outFile); } catch { /* may not exist */ }
|
||
|
||
// Worker exits 1 (not 0) so SUP_MAX_CRASHES=1 actually trips. See
|
||
// the comment on the env-strip test above for the v0.33 rationale.
|
||
const h = makeHarness('env-pass-on-opt-in', `printf '%s\\n' "\${GBRAIN_ALLOW_SHELL_JOBS-UNSET}" > "$OUT_FILE" ; exit 1`);
|
||
|
||
try {
|
||
const sup = spawnSupervisor(h, {
|
||
OUT_FILE: outFile,
|
||
SUP_ALLOW_SHELL_JOBS: '1',
|
||
SUP_MAX_CRASHES: '1',
|
||
});
|
||
|
||
await sup.exited;
|
||
|
||
expect(existsSync(outFile)).toBe(true);
|
||
expect(readFileSync(outFile, 'utf8').trim()).toBe('1');
|
||
} finally {
|
||
try { unlinkSync(outFile); } catch { /* noop */ }
|
||
h.cleanup();
|
||
}
|
||
}, 15_000);
|
||
});
|
||
|
||
describe('integration: GBRAIN_SUPERVISED env var (v0.22.14)', () => {
|
||
it('sets GBRAIN_SUPERVISED=1 on spawned worker child', async () => {
|
||
const outFile = join(tmpdir(), `gbrain-sup-supervised-${process.pid}-${Date.now()}.txt`);
|
||
try { unlinkSync(outFile); } catch { /* may not exist */ }
|
||
|
||
// exit 1 required post-D1/D2 to trip SUP_MAX_CRASHES=1; clean exits
|
||
// no longer count toward the crash limit.
|
||
const h = makeHarness('supervised-env', `printf '%s\n' "\${GBRAIN_SUPERVISED-UNSET}" > "$OUT_FILE" ; exit 1`);
|
||
|
||
try {
|
||
const sup = spawnSupervisor(h, {
|
||
OUT_FILE: outFile,
|
||
SUP_MAX_CRASHES: '1',
|
||
});
|
||
|
||
await sup.exited;
|
||
|
||
expect(existsSync(outFile)).toBe(true);
|
||
const childSawEnv = readFileSync(outFile, 'utf8').trim();
|
||
expect(childSawEnv).toBe('1');
|
||
} finally {
|
||
try { unlinkSync(outFile); } catch { /* noop */ }
|
||
h.cleanup();
|
||
}
|
||
}, 15_000);
|
||
});
|
||
|
||
describe('regression (R3): healthInterval=0 disables timer (v0.22.14)', () => {
|
||
// Pre-fix: supervisor unconditionally called setInterval(callback, 0),
|
||
// which schedules a tight loop on the next event-loop tick. The
|
||
// operator-facing CLI claim "Use 0 to disable" was a lie — passing 0
|
||
// produced a DB-probe loop that hammered Postgres.
|
||
//
|
||
// Post-fix: setInterval is gated on healthInterval > 0. With 0, the
|
||
// supervisor runs its supervise loop normally with the health timer
|
||
// entirely absent.
|
||
//
|
||
// Assertion strategy: spawn the supervisor with SUP_HEALTH_INTERVAL_MS=0,
|
||
// a fast worker that exits cleanly, and SUP_MAX_CRASHES=1. A working fix
|
||
// should produce a single worker spawn → exit → supervisor shutdown
|
||
// sequence. If the tight-loop bug returned, the supervisor would still
|
||
// exit (max-crashes path) but the audit trail would show the tell-tale
|
||
// signature of an extremely high health-check call rate during the brief
|
||
// window before max-crashes fires. We assert the basic completion path
|
||
// and let CI's wall-clock detect any pathological CPU spike.
|
||
it('completes a normal supervise lifecycle with healthInterval=0', async () => {
|
||
// exit 1 (not exit 0) because post-D1/D2 (v0.33) clean exits don't
|
||
// count toward max_crashes — a code=0 worker would respawn forever.
|
||
// The test's purpose is regression coverage that healthInterval=0
|
||
// disables the timer; the exit code doesn't matter to that assertion.
|
||
const h = makeHarness('health-interval-zero', 'exit 1');
|
||
|
||
try {
|
||
const sup = spawnSupervisor(h, {
|
||
SUP_HEALTH_INTERVAL_MS: '0',
|
||
SUP_MAX_CRASHES: '1',
|
||
});
|
||
|
||
const start = Date.now();
|
||
const { code } = await sup.exited;
|
||
const elapsedMs = Date.now() - start;
|
||
|
||
// Clean exit (max-crashes path returns 1; this is fine — we just
|
||
// want to confirm the supervisor reached its terminal state without
|
||
// hanging or runaway looping).
|
||
expect(code).toBe(1);
|
||
|
||
// Sanity: a tight loop on setInterval(0) plus the spawn-respawn
|
||
// loop would still terminate at max-crashes, but it would be
|
||
// measurably slower than a clean run because the event loop is
|
||
// saturated with health-check callbacks. Cap the upper bound at
|
||
// 10s — clean runs typically finish in 1–2s.
|
||
expect(elapsedMs).toBeLessThan(10_000);
|
||
} finally {
|
||
h.cleanup();
|
||
}
|
||
}, 15_000);
|
||
});
|
||
|
||
describe('integration: --max-rss spawn args (v0.21, auto-sized v0.41.39.0)', () => {
|
||
it('passes an explicit --max-rss through to the spawned worker', async () => {
|
||
const outFile = join(tmpdir(), `gbrain-sup-maxrss-${process.pid}-${Date.now()}.txt`);
|
||
try { unlinkSync(outFile); } catch { /* may not exist */ }
|
||
|
||
// SUP_MAX_RSS pins an explicit cap; the supervisor must pass it through
|
||
// verbatim. exit 1 required post-D1/D2: code=0 workers respawn forever.
|
||
const h = makeHarness('maxrss-explicit', `printf '%s\\n' "$*" > "$OUT_FILE" ; exit 1`);
|
||
|
||
try {
|
||
const sup = spawnSupervisor(h, {
|
||
OUT_FILE: outFile,
|
||
SUP_MAX_CRASHES: '1',
|
||
SUP_MAX_RSS: '2048',
|
||
});
|
||
|
||
await sup.exited;
|
||
|
||
expect(existsSync(outFile)).toBe(true);
|
||
const argv = readFileSync(outFile, 'utf8').trim();
|
||
expect(argv).toContain('--max-rss 2048');
|
||
} finally {
|
||
try { unlinkSync(outFile); } catch { /* noop */ }
|
||
h.cleanup();
|
||
}
|
||
}, 15_000);
|
||
|
||
// issue #1678: with no explicit cap the supervisor auto-sizes cgroup-aware
|
||
// instead of the old flat 2048 footgun. Same machine → the in-test
|
||
// resolveDefaultMaxRssMb() equals what the spawned supervisor computes.
|
||
it('auto-sizes --max-rss when no explicit cap is given', async () => {
|
||
const outFile = join(tmpdir(), `gbrain-sup-maxrss-auto-${process.pid}-${Date.now()}.txt`);
|
||
try { unlinkSync(outFile); } catch { /* may not exist */ }
|
||
|
||
const { resolveDefaultMaxRssMb } = await import('../src/core/minions/rss-default.ts');
|
||
const expected = resolveDefaultMaxRssMb();
|
||
|
||
const h = makeHarness('maxrss-auto', `printf '%s\\n' "$*" > "$OUT_FILE" ; exit 1`);
|
||
try {
|
||
const sup = spawnSupervisor(h, {
|
||
OUT_FILE: outFile,
|
||
SUP_MAX_CRASHES: '1',
|
||
});
|
||
await sup.exited;
|
||
|
||
expect(existsSync(outFile)).toBe(true);
|
||
const argv = readFileSync(outFile, 'utf8').trim();
|
||
expect(argv).toContain(`--max-rss ${expected}`);
|
||
// Auto-sized value is clamped into the sane range, never the old 2048
|
||
// unless the box genuinely resolves there.
|
||
expect(expected).toBeGreaterThanOrEqual(4096);
|
||
expect(expected).toBeLessThanOrEqual(16384);
|
||
} finally {
|
||
try { unlinkSync(outFile); } catch { /* noop */ }
|
||
h.cleanup();
|
||
}
|
||
}, 15_000);
|
||
});
|
||
|
||
describe('integration: audit file rotation + helper', () => {
|
||
it('computeSupervisorAuditFilename returns supervisor-YYYY-Www.jsonl format', () => {
|
||
const jan15_2026 = new Date(Date.UTC(2026, 0, 15)); // Thu
|
||
expect(computeSupervisorAuditFilename(jan15_2026)).toMatch(/^supervisor-2026-W\d\d\.jsonl$/);
|
||
});
|
||
|
||
it('year-boundary ISO week: 2027-01-01 reports as 2026-W53', () => {
|
||
const jan1_2027 = new Date(Date.UTC(2027, 0, 1));
|
||
// ISO week: 2027-01-01 is Friday of W53 of 2026
|
||
expect(computeSupervisorAuditFilename(jan1_2027)).toBe('supervisor-2026-W53.jsonl');
|
||
});
|
||
});
|
||
});
|