mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat: add `gbrain jobs supervisor` — self-healing worker process manager Adds a first-class supervisor command that: - Spawns `gbrain jobs work` as a child process - Restarts on crash with exponential backoff (1s→60s cap) - Resets crash counter after 5min of stable operation - PID file locking prevents duplicate supervisors - Periodic health checks (stalled jobs, completion gaps) - Graceful shutdown (SIGTERM→35s→SIGKILL) Usage: gbrain jobs supervisor --concurrency 4 Replaces ad-hoc nohup patterns in bootstrap scripts. The autopilot command's internal supervisor can be migrated to use this in a follow-up. Tests: 7 pass (backoff calc, PID management, crash tracking) * supervisor: atomic PID lock, queue-scoped health, env safety, unified exit Lane A of PR #364 review fixes (20-item multi-lane plan). Addresses the codex-tier + CEO + Eng findings on src/core/minions/supervisor.ts: Safety + correctness: - Atomic O_CREAT|O_EXCL PID lock via openSync('wx') with stale-file liveness check. Prevents two supervisors racing on the same PID file. (codex #1) - Health check now queries status='active' AND lock_until < now() matching queue.ts:848's authoritative stalled definition. The prior `status = 'stalled'` predicate returned zero rows forever because 'stalled' is not a persisted value in the schema. (codex #2) - All health queries scoped to WHERE queue = $1 via opts.queue binding. Multi-queue installs no longer see cross-queue false positives. (codex #3) - Class default allowShellJobs flipped true→false AND explicit `delete env.GBRAIN_ALLOW_SHELL_JOBS` when false, so child workers don't silently inherit the var from the parent shell. (eng #8, codex #9) - Unified shutdown(reason, exitCode) — max-crashes now routes through the same drain path as SIGTERM. Single source of truth for lifecycle cleanup; prerequisite for trustworthy audit events (Lane C). (eng #1) - Default PID path moves from /tmp to ~/.gbrain/supervisor.pid with mkdirSync recursive + GBRAIN_SUPERVISOR_PID_FILE env override. Matches the rest of the product's ~/.gbrain/ convention; fresh installs no longer hit ENOENT. (CEO #2 + codex #6) Refinements: - crashCount = 1 after 5-min stable-run reset (was 0, produced calculateBackoffMs(-1) = 500ms by accident). Now reads as 'first crash of a new cycle' with a clean 1s backoff. (Nit 1) - Top-of-file POSTGRES-ONLY docstring documenting why the supervisor can't run against PGLite. (Nit 2) - inBackoff flag suppresses 'worker not alive' warn during the expected null-child window (crash → sleep → next spawn). (eng #2) - Tracked listener refs for SIGTERM/SIGINT removed in shutdown() so integration tests spinning up/tearing down multiple supervisors on one process don't leak handlers. (eng #3) - Single FILTER query replaces two SELECT counts — one round-trip instead of two, three metrics in one pass. (eng #10) - child.on('error') listener emits worker_spawn_failed event for ENOENT/EACCES; exit handler still increments crashCount as usual so max-crashes bounds permanent misconfigurations. (codex #7) - healthInFlight boolean guard with try/finally prevents overlapping health checks from stacking on a hung DB. (codex #8) Documented exit codes (ExitCodes const): 0 CLEAN, 1 MAX_CRASHES, 2 LOCK_HELD, 3 PID_UNWRITABLE Agent can branch on exit=2 ('another supervisor, I'm fine') vs exit=1 ('escalate to human'). Event emitter surface: - started / worker_spawned / worker_exited / worker_spawn_failed - backoff / health_warn / health_error / max_crashes_exceeded - shutting_down / stopped Plumbed through emit() with an onEvent callback hook for Lane C's audit writer. json:false is the default; Lane C's --json mode flips it and writes JSONL to stderr. CLI changes (src/commands/jobs.ts): - `gbrain jobs supervisor` gains --allow-shell-jobs (explicit opt-in mirroring the env-var gate), --cli-path (override auto-resolution for exotic setups), and --json (JSONL lifecycle events on stderr). - Expanded --help body with description, 3 examples, and exit-code table. (DX Fix A per review) - Three-tier PID path resolution: --pid-file > GBRAIN_SUPERVISOR_PID_FILE > ~/.gbrain/supervisor.pid (via exported DEFAULT_PID_FILE). - Removed the catch-fallback to process.argv[1] — resolveGbrainCliPath() throws its own actionable install-hint error, which is what dev users need instead of a cryptic spawn failure on a .ts path. (codex #5) Tests: existing 7 supervisor.test.ts cases continue to pass. Integration tests (crash-restart, max-crashes, SIGTERM-during-backoff, env-inheritance regression) land in Lane E. Out of scope for this lane (tracked in follow-up lanes): - Audit file writer at ~/.gbrain/audit/supervisor-YYYY-Www.jsonl (Lane C) - Documentation pass (Lane B) - supervisor start/status/stop subcommands (Lane C) - gbrain doctor supervisor check (Lane D) - /ship release hygiene (Lane F) - autopilot.ts migration to MinionSupervisor (deferred to follow-up PR per codex — requires non-blocking start() API redesign, not ~30 lines) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: supervisor as canonical worker deployment pattern Lane B of PR #364 review fixes. Reframes docs/guides/minions-deployment.md around `gbrain jobs supervisor` as the default answer (blocker 7), deletes the 68-line legacy bash watchdog (F10), and updates README + deployment snippets to match. docs/guides/minions-deployment.md: - New 'Worker supervision' section at the top with the canonical 3-command agent pattern (start --detach / status --json / stop) and a documented exit-code table (0 clean, 1 max-crashes, 2 lock-held, 3 PID-unwritable). - 'Which supervisor when?' decision table: container = supervisor as PID 1, Linux VM = systemd-over-supervisor, dev laptop = bare terminal. - New 'Agent usage' section for OpenClaw / Hermes / Cursor / Codex — the 3-turn discover-start-maintain workflow that replaces shell archaeology with machine-parseable JSON events + an audit file at ~/.gbrain/audit/supervisor-YYYY-Www.jsonl. - Demoted the 'Option 1: watchdog cron' path entirely; replaced with a straightforward upgrade migration block (stop script, remove cron line, start supervisor, verify via doctor). - Preconditions now check Postgres connectivity directly (supervisor is Postgres-only; the CLI rejects PGLite with a clear error). Snippets: - systemd.service: ExecStart now invokes `gbrain jobs supervisor` instead of raw `gbrain jobs work`. Two-layer supervision (systemd → supervisor → worker) buys automatic restart on reboot plus fast crash recovery. ReadWritePaths expanded to cover $HOME/.gbrain (supervisor PID + audit). - Procfile + fly.toml.partial: same change — platform restarts the container on host events, supervisor restarts the worker on crashes. - minion-watchdog.sh: deleted (git history retains it for anyone in an exotic deployment). Supervisor subsumes every capability it had plus atomic PID locking, structured audit events, queue-scoped health checks, and graceful drain on SIGTERM. README.md: - Added a paragraph under the Minions section pointing `gbrain jobs supervisor` as canonical, noting the --detach / status / stop surface and the audit file path, with a link to the full deployment guide. Kept `gbrain jobs work` documented for direct raw invocation but flagged 'prefer supervisor' for any long-running use. The supervisor `--help` body itself (3 examples + exit-code table in src/commands/jobs.ts) landed with Lane A — this lane finishes the discoverability story by making the supervisor findable via doc grep, README landing, and deployment-guide landing paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * supervisor: daemon-manager subcommands + JSONL audit writer Lane C of PR #364 review fixes. Adds the daemon-manager CLI surface so agents can drive `gbrain jobs supervisor` in 3 turns instead of 10, and the audit writer that makes lifecycle events inspectable across process restarts. (Blocker 8, closes DX Fix A/B/C.) New: src/core/minions/handlers/supervisor-audit.ts - writeSupervisorEvent(emission, supervisorPid) appends JSONL to `${GBRAIN_AUDIT_DIR:-~/.gbrain/audit}/supervisor-YYYY-Www.jsonl`. ISO-week rotation via a `computeSupervisorAuditFilename()` helper that mirrors `shell-audit.ts` exactly (year-boundary ISO week math, Thursday anchor, etc). - readSupervisorEvents({sinceMs}) returns parsed events from the current week's file, oldest-first, for Lane D's doctor check. Malformed lines are skipped silently (disk-full truncation is already best-effort at write time). - Reuses `resolveAuditDir()` from shell-audit.ts so the `GBRAIN_AUDIT_DIR` env var override works identically across all gbrain audit trails. src/commands/jobs.ts: supervisor subcommand dispatcher - `gbrain jobs supervisor [start] [--detach] [--json] ...` — default subcommand. Without --detach, runs foreground as before. With --detach, forks a background child (inheriting stderr so the caller can still tail JSONL events), writes a stdout payload: {"event":"started","supervisor_pid":N,"pid_file":"...","detached":true} and exits 0. Stdin/stdout on the detached child are /dev/null so the parent shell isn't held open. - `gbrain jobs supervisor status [--json]` — reads the PID file, checks liveness via `kill -0`, then reads the last 24h from the supervisor audit file to compute crashes_24h / last_start / max_crashes_exceeded. Exits 0 if running, 1 if not. JSON output is machine-parseable; human output is a 5-line ASCII report. - `gbrain jobs supervisor stop [--json]` — reads PID, sends SIGTERM, polls `kill -0` every 250ms for up to 40s (supervisor's own 35s worker-drain + 5s slack). Reports outcome: drained / timeout_40s / pid_file_missing / pid_file_corrupt / process_gone. Exit 0 on clean stop. - `--json` flag is already plumbed through to the supervisor opts from Lane A — this lane adds the onEvent audit-writer callback so every supervisor emission (started, worker_spawned, worker_exited, worker_spawn_failed, backoff, health_warn, health_error, max_crashes_exceeded, shutting_down, stopped) lands in the JSONL file with the supervisor's PID attached. --help body updated: - Three separate usage lines (start / status / stop). - SUBCOMMANDS block with one-line summaries each. - EXIT CODES block (unchanged from Lane A, moved under SUBCOMMANDS). - EXAMPLES block updated with status --json + stop + --detach forms. Tests: existing 127 supervisor + minions tests continue to pass. Integration tests for the new subcommands + audit writer land with Lane E. Follow-up (Lane D): `gbrain doctor` will read readSupervisorEvents() from this module to surface a `supervisor` health check alongside its existing checks (DB connectivity, schema version, queue health). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * doctor: add supervisor health check Lane D of PR #364 review fixes. Closes the observability loop: now that Lane C writes supervisor lifecycle events to `${GBRAIN_AUDIT_DIR:-~/.gbrain/audit}/supervisor-YYYY-Www.jsonl`, `gbrain doctor` surfaces a `supervisor` check alongside its existing health indicators. Implementation (src/commands/doctor.ts, filesystem-only block 3b-bis): - Resolves DEFAULT_PID_FILE via the same three-tier logic as the start path (--pid-file > GBRAIN_SUPERVISOR_PID_FILE > ~/.gbrain/supervisor.pid). - Reads the PID file + `kill -0 <pid>` for liveness. - Calls readSupervisorEvents({sinceMs: 24h}) from the audit module to derive last_start / crashes_24h / max_crashes_exceeded. - Suppresses the check entirely when the user has never invoked the supervisor (no PID file AND no audit events) — avoids noise on installs that don't use the feature. Status thresholds: fail max_crashes_exceeded event seen in last 24h (supervisor gave up; operator needs to restart or triage) warn supervisor not running but audit shows prior use (unexpected stop — likely crash or manual kill) warn running but > 3 crashes in last 24h (supervisor recovering but worker is unstable) ok running + ≤ 3 crashes + no max_crashes event All failure paths emit a paste-ready recovery command. Read/import errors are swallowed (best-effort like the other doctor checks). Tests: all 127 supervisor + minions tests still green; 13 existing doctor tests unaffected. F3 done. All four lanes A/B/C/D are now committed; Lane E (integration tests) and Lane F (/ship v0.20.2) remain. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: 4 critical integration tests for supervisor lifecycle Lane E of PR #364 review fixes (blocker 10). Fills the ~15% coverage gap flagged in the eng review by actually exercising the code paths that will break in production — crash-restart loop, max-crashes exit, SIGTERM-during-backoff, env-var inheritance — via real spawn() calls against fake shell-script workers. No mocks: real fork, real signals, real env propagation, real audit file writes. test/fixtures/supervisor-runner.ts (new, 55 lines): A standalone bun script that constructs a MinionSupervisor from env vars (SUP_PID_FILE / SUP_CLI_PATH / SUP_MAX_CRASHES / SUP_BACKOFF_FLOOR_MS / SUP_HEALTH_INTERVAL_MS / SUP_ALLOW_SHELL_JOBS / SUP_AUDIT_DIR) and calls start(). Mock engine returns empty rows for executeRaw (health check path still exercised without Postgres). Tests spawn this as a subprocess because MinionSupervisor.start() calls process.exit() on shutdown — can't run it in the test runner's own process. test/supervisor.test.ts (existing; 91 → 300 lines): - Added IntegrationHarness helper: creates a unique tmpdir per test, a fake worker shell script, a PID-file path, and an audit-dir path; cleanup runs in finally. - spawnSupervisor() forks bun on the runner with env vars set. - readAudit() reads the supervisor-YYYY-Www.jsonl file via the existing readSupervisorEvents() helper (Lane C), threading GBRAIN_AUDIT_DIR through so tests don't collide on ~/.gbrain. - waitFor(pred, timeoutMs) polls helper for event-driven tests. Four integration tests (with _backoffFloorMs=5 for <1s suite runs): 1. "respawns the worker after a crash and eventually exits with max-crashes code=1" Worker always `exit 1`. maxCrashes=3. Asserts: exit code 1, PID file cleaned up, audit contains started + 3x worker_spawned + 3x worker_exited + max_crashes_exceeded + shutting_down + stopped, and the stopped event carries {reason:'max_crashes', exit_code:1}. Locks in blockers 1 (PID lock), 2+3+6 (health SQL doesn't 500), 5 (unified shutdown emits right events), F8 (spawn errors counted). 2. "receives SIGTERM while sleeping between crashes and exits 0 cleanly" Worker always `exit 1`, backoff floor 800ms to catch the sleep. Asserts: SIGTERM during backoff → exit code 0 (not 1) in <5s, no signal kill (process.exit via shutdown), audit contains shutting_down {reason:'SIGTERM'} + stopped, PID file cleaned up. Locks in eng Issue 1 (unified exit path), eng Issue 3 (signal handlers don't accumulate across shutdowns). 3. "strips inherited GBRAIN_ALLOW_SHELL_JOBS when allowShellJobs=false, even if parent has it set" ⚠ CRITICAL regression test Parent env has GBRAIN_ALLOW_SHELL_JOBS=1. SUP_ALLOW_SHELL_JOBS=0. Worker writes $GBRAIN_ALLOW_SHELL_JOBS (or 'UNSET' if absent) to an OUT_FILE. Asserts child sees 'UNSET'. Locks in codex #9 + eng #8: the `else delete env.GBRAIN_ALLOW_SHELL_JOBS` branch from Lane A is load-bearing for the supervisor's security posture; this test prevents a future refactor silently re-opening the inheritance hole. 4. "DOES pass GBRAIN_ALLOW_SHELL_JOBS to child when allowShellJobs=true" Positive-path companion to #3. SUP_ALLOW_SHELL_JOBS=1 → worker sees '1'. Confirms the else-branch doesn't over-strip and that operators who explicitly opt in still get shell-exec enabled. Plus two audit-format unit tests: - computeSupervisorAuditFilename format (regex match) - Year-boundary ISO week: 2027-01-01 → supervisor-2026-W53.jsonl (matches the shell-audit.ts pattern exactly) Before: 7 tests covering backoff math + PID helpers (~15% behavioral coverage per eng review). After: 13 tests across all critical lifecycle paths (crash-restart, max-crashes, SIGTERM, env-inheritance, audit rotation). All 146 tests in supervisor + minions + doctor suites green in ~8s. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.20.2) Lane F of PR #364 review fixes. Closes the multi-lane plan with release hygiene: VERSION bump 0.19.0 → 0.20.2, package.json sync, CHANGELOG entry in GStack voice with release summary + "numbers that matter" table + "To take advantage of v0.20.2" migration block + itemized changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: escape template-literal interpolation in supervisor --help The --help body in src/commands/jobs.ts is one big backtick template literal. The supervisor subcommand description I added in Lane B used both `${GBRAIN_AUDIT_DIR:-~/.gbrain/audit}` (parsed as a template interpolation into an undefined variable) and inline `code` backticks (parsed as nested template literals). CI caught it with ~200 tsc parse errors across the file. Fix: - Escape `${...}` → `\${...}` so the audit-file path renders literally. - Replace prose inline-code backticks with plain single-quote fences (`gbrain jobs work` → 'gbrain jobs work', `~/.gbrain/supervisor.pid` → ~/.gbrain/supervisor.pid). `--help` output is human prose; the single-quote form reads cleanly in a terminal without needing to smuggle nested backticks through a template literal. `bunx tsc --noEmit` is clean. 146 tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: regenerate llms-full.txt after Lane B doc rewrite CI drift guard caught that `llms-full.txt` didn't match the current generator output. Root cause: the Lane B rewrite of `docs/guides/minions-deployment.md` (supervisor as canonical, watchdog deleted) changed content that gets inlined into `llms-full.txt`, but I didn't run `bun run build:llms` to regenerate. `bun test test/build-llms.test.ts` now clean (7/7 pass). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: root <root@localhost> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
344 lines
13 KiB
TypeScript
344 lines
13 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 } 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,
|
|
};
|
|
|
|
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('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 {
|
|
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 */ }
|
|
|
|
const h = makeHarness('env-strip-outfile', `printf '%s\\n' "\${GBRAIN_ALLOW_SHELL_JOBS-UNSET}" > "$OUT_FILE" ; exit 0`);
|
|
|
|
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 */ }
|
|
|
|
const h = makeHarness('env-pass-on-opt-in', `printf '%s\\n' "\${GBRAIN_ALLOW_SHELL_JOBS-UNSET}" > "$OUT_FILE" ; exit 0`);
|
|
|
|
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: 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');
|
|
});
|
|
});
|
|
});
|