v0.42.23.0 feat(jobs): --nice scheduling-priority flag for jobs work/supervisor (#1815) (#1820)

* feat(jobs): niceness core, worker registry, shared supervisor-pid reader

OS scheduling-priority primitives for issue #1815:
- niceness.ts: parseNiceValue (whole-string), applyNiceness (re-reads
  effective in success AND failure paths), getEffectiveNiceness, formatNice.
- worker-registry.ts: live workers self-register pid + requested/effective
  nice under gbrainPath('workers'); readWorkers prunes ESRCH (keeps EPERM)
  with a pid-reuse start-time guard.
- supervisor-pid.ts: readSupervisorPid extracted from the copy-pasted
  PID-file + liveness block.

* feat(jobs): --nice flag for jobs work/supervisor + doctor niceness check

Wires the --nice <n> flag (and GBRAIN_NICE env) through the CLI (issue #1815):
- jobs work: applies niceness + registers the worker; cleanup on finally and
  process.on('exit').
- jobs supervisor: applies in the foreground-start path only (after the
  --detach fork), passes the apply result into MinionSupervisor.
- supervisor.ts: nice opts, extracted testable buildWorkerArgs (appends
  --nice), emits niceness on started/worker_spawned audit events.
- jobs stats / supervisor status: surface effective worker + supervisor nice.
- doctor: separate supervisor_niceness check (warns on requested != effective)
  so it can't clobber the supervisor crash-check precedence; registered in
  doctor-categories.

* test(jobs): cover niceness, worker registry, supervisor-pid, build args

Unit tests for issue #1815: parseNiceValue rejects 3.5/10abc that parseInt
would accept; applyNiceness re-reads effective on EPERM; registry ESRCH/EPERM +
pid-reuse guard + brain-isolated path; readSupervisorPid states; parseNiceFlag
flag>env precedence; buildWorkerArgs --nice propagation.

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

--nice flag for jobs work/supervisor (issue #1815).

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

* docs: document --nice flag for jobs work/supervisor (v0.42.23.0)

- minions-deployment.md: niceness tuning section (full concurrency, low priority).
- KEY_FILES.md: entries for niceness.ts, worker-registry.ts, supervisor-pid.ts;
  supervisor.ts entry notes buildWorkerArgs + nice opts.

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

* test(e2e): add enrich_thin to dream cycle EXPECTED_PHASES

The enrich_thin cycle phase (src/core/cycle.ts ALL_PHASES, between
conversation_facts_backfill and skillopt) shipped without updating the
e2e phase-order expectation, so dream-cycle-phase-order-pglite failed on
master. Sync the expected list to the real ALL_PHASES order.

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

* test(e2e): align sync-lock-recovery with the shipped --break-lock --all contract

v0.41.13.0 intentionally dropped the "--break-lock + --all is refused" guard so
cron can self-heal every source in one call (sync.ts runBreakLock iterates
sources under --all). The e2e test still asserted the old exit-1 refusal and
failed on master. Assert the current contract: the combination is accepted and
takes the iterate / no-active-sources path (exit 0, no refusal message).

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

* test(e2e): de-flake ingestion-roundtrip chokidar first-drop race

The native fsevents watcher occasionally missed a freshly written file, timing
out the 15s waitFor (~1/3 on master under load). Three fixes:
- inject a polling chokidar watcher via the source's _watchFactory seam
  (usePolling, 20ms interval) so detection never depends on fsevents timing;
- drop deterministic fixtures BEFORE start so the initial scan
  (ignoreInitial:false) emits them, keeping live-watch coverage only where it's
  robust;
- poll for the dedup hit instead of a fixed 600ms sleep.
15/15 green under stress.

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

* test(e2e): make hermetic-PGLite serve tests actually hermetic

connect-bearer and serve-stdio-roundtrip init a PGLite brain and spawn serve,
but passed {...process.env} through — leaking an ambient DATABASE_URL /
GBRAIN_DATABASE_URL into the subprocess, which then came up on Postgres and
failed the `engine: pglite` assertion. Strip both DB vars from the spawned env
so the tests are deterministic whether or not the shell/CI has a DB URL set.

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

* test(e2e): type the hermetic-PGLite env so tsc passes

The DATABASE_URL/GBRAIN_DATABASE_URL strip used `delete` on a narrowly-typed
env literal (tsc-only failure; bun test doesn't typecheck). Annotate
connect-bearer's env as Record<string,string|undefined> and build serve-stdio's
as a concrete Record<string,string> (StdioClientTransport.env rejects undefined).
Runtime behavior unchanged (7/7 + 3/3 green).

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

* test: quarantine worker-registry to *.serial (R1 env-mutation isolation)

worker-registry.test.ts sets process.env.GBRAIN_HOME per-test so gbrainPath
resolves to a temp dir, then lazy-imports the module — a process-global
mutation the parallel isolation lint (rule R1) forbids. Rename to
worker-registry.serial.test.ts: it runs in the serial pass (own bun process,
max-concurrency=1) where env mutation is safe, and the lint skips *.serial
files. No logic change (6/6 green); fixes the failing `verify` CI job.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-03 15:24:00 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent f4959348c2
commit f11d56cfca
22 changed files with 1078 additions and 58 deletions
+7 -1
View File
@@ -34,7 +34,13 @@ describe('connect bearer probe E2E (PGLite + real serve --http)', () => {
beforeAll(async () => {
home = mkdtempSync(join(tmpdir(), 'gbrain-connect-e2e-'));
const env = { ...process.env, GBRAIN_HOME: home };
// Hermetic PGLite: strip any ambient Postgres URL so `init --pglite` and the
// spawned `serve` actually use PGLite. Without this, a dev/CI shell with
// DATABASE_URL set leaks it into the subprocess and the brain comes up on
// Postgres, breaking the `engine: pglite` assertion.
const env: Record<string, string | undefined> = { ...process.env, GBRAIN_HOME: home };
delete env.DATABASE_URL;
delete env.GBRAIN_DATABASE_URL;
execFileSync('bun', ['run', 'src/cli.ts', 'init', '--pglite', '--no-embedding', '--non-interactive'], {
cwd: process.cwd(), env, stdio: 'ignore',
@@ -128,6 +128,7 @@ const EXPECTED_PHASES: CyclePhase[] = [
'grade_takes', // v0.36.1.0
'calibration_profile', // v0.36.1.0
'conversation_facts_backfill', // v0.41.11.0 — opt-in conversation backfill
'enrich_thin', // v0.41.39 (#1700) — brain-internal stub enrichment (default OFF)
'skillopt', // v0.42.0.0 — self-evolving skills (default OFF)
'embed',
'orphans',
+41 -12
View File
@@ -27,6 +27,15 @@ import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { resetPgliteState } from '../helpers/reset-pglite.ts';
import { IngestionDaemon } from '../../src/core/ingestion/daemon.ts';
import { createInboxFolderSource } from '../../src/core/ingestion/sources/inbox-folder.ts';
import { watch, type ChokidarOptions, type FSWatcher } from 'chokidar';
// E2E determinism: force chokidar into polling mode via the source's
// `_watchFactory` seam so file-drop detection never depends on macOS fsevents
// timing. The native-events path occasionally missed the first add under load,
// timing out the 15s waitFor (the documented first-drop flake). Polling at a
// 20ms interval is deterministic and still fast. Production keeps native events.
const pollingWatchFactory = (paths: string, opts: ChokidarOptions): FSWatcher =>
watch(paths, { ...opts, usePolling: true, interval: 20, binaryInterval: 20 });
import { makeIngestCaptureHandler } from '../../src/core/minions/handlers/ingest-capture.ts';
import type { IngestionEvent } from '../../src/core/ingestion/types.ts';
import type { MinionJobContext } from '../../src/core/minions/types.ts';
@@ -133,14 +142,19 @@ describe('ingestion roundtrip — inbox-folder → daemon → ingest_capture →
inboxDir,
debounceMs: 50,
awaitStabilityMs: 100,
_watchFactory: pollingWatchFactory,
});
daemon.register({ source });
await daemon.start();
// Drop a file into the inbox.
// Drop the file BEFORE start so the initial scan (ignoreInitial:false)
// emits it deterministically. The full emit → dispatch → handler → DB →
// archive pipeline still runs end to end; only the inherently timing-
// dependent post-start watcher detection is removed (covered robustly by
// the dedup test's polled second drop below).
const captured = path.join(inboxDir, 'roundtrip.md');
fs.writeFileSync(captured, '---\ntitle: Roundtrip\n---\n\nfull e2e flow');
await daemon.start();
// Wait for the daemon to pick it up + dispatch + handler to write.
await waitFor(() => dispatchedEvents.length === 1, 15000);
@@ -186,28 +200,36 @@ describe('ingestion roundtrip — inbox-folder → daemon → ingest_capture →
inboxDir,
debounceMs: 50,
awaitStabilityMs: 100,
_watchFactory: pollingWatchFactory,
});
daemon.register({ source });
await daemon.start();
// Drop file 1
// Drop file 1 BEFORE start so the initial scan (ignoreInitial:false) emits
// it deterministically — removes the post-start watcher race that flaked.
const drop1 = path.join(inboxDir, 'dup-1.md');
fs.writeFileSync(drop1, '# duplicate content\n\nidentical body');
await daemon.start();
await waitFor(() => dispatchedEvents.length === 1, 15000);
// Drop file 2 with byte-identical content (different filename).
// Drop file 2 with byte-identical content (different filename) AFTER start;
// the watcher catches it and dedup intercepts before dispatch.
const drop2 = path.join(inboxDir, 'dup-2.md');
fs.writeFileSync(drop2, '# duplicate content\n\nidentical body');
// chokidar.archive moves drop2, but the dedup should catch the event
// BEFORE the handler runs. Let chokidar process a bit then check.
await new Promise((r) => setTimeout(r, 600));
// Poll for the dedup hit instead of a fixed sleep so a slow watcher tick
// can't make the assertion flake.
let dedupHits = 0;
for (let i = 0; i < 240; i++) {
dedupHits = (await daemon.healthCheck()).dedup.hits;
if (dedupHits >= 1) break;
await new Promise((r) => setTimeout(r, 25));
}
// Only ONE event made it through dispatch (dedup intercepted the second).
expect(dispatchedEvents).toHaveLength(1);
// The daemon's dedup stats reflect a hit.
const health = await daemon.healthCheck();
expect(health.dedup.hits).toBeGreaterThanOrEqual(1);
expect(dedupHits).toBeGreaterThanOrEqual(1);
await daemon.stop();
}, 15_000);
@@ -241,20 +263,27 @@ describe('ingestion roundtrip — multi-source coordination', () => {
inboxDir: inboxA,
debounceMs: 50,
awaitStabilityMs: 100,
_watchFactory: pollingWatchFactory,
});
const sourceB = createInboxFolderSource({
id: 'inbox-b',
inboxDir: inboxB,
debounceMs: 50,
awaitStabilityMs: 100,
_watchFactory: pollingWatchFactory,
});
daemon.register({ source: sourceA });
daemon.register({ source: sourceB });
await daemon.start();
// Drop both files BEFORE start. With ignoreInitial:false the initial scan
// emits an `add` for each on chokidar `ready`, so both sources ingest
// deterministically — no dependency on the post-start watcher catching a
// freshly written file (the source of the residual flake).
fs.writeFileSync(path.join(inboxA, 'from-a.md'), 'content from A');
fs.writeFileSync(path.join(inboxB, 'from-b.md'), 'content from B');
await daemon.start();
await waitFor(() => dispatchedEvents.length === 2, 15000);
const fromA = dispatchedEvents.find((e) => e.source_id === 'inbox-a');
+11 -1
View File
@@ -44,7 +44,17 @@ describe('serve stdio round-trip E2E (local PGLite → real MCP tool calls)', ()
beforeAll(async () => {
home = mkdtempSync(join(tmpdir(), 'gbrain-stdio-e2e-'));
const env = { ...process.env, GBRAIN_HOME: home };
// Hermetic PGLite: strip any ambient Postgres URL so `init --pglite` and the
// served brain actually use PGLite even when the shell/CI has DATABASE_URL
// set (otherwise the subprocess comes up on Postgres → `engine: pglite`
// assertion fails).
// Build a concrete Record<string,string> (StdioClientTransport.env rejects
// undefined values), dropping the ambient DB URLs so the subprocess is PGLite.
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) if (v !== undefined) env[k] = v;
env.GBRAIN_HOME = home;
delete env.DATABASE_URL;
delete env.GBRAIN_DATABASE_URL;
// 1. Init a local PGLite brain (the "from nothing" step).
execFileSync('bun', ['run', 'src/cli.ts', 'init', '--pglite', '--no-embedding', '--non-interactive'], {
+12 -4
View File
@@ -92,11 +92,19 @@ describeE2E('v0.41.6.0 — sync lock recovery scenarios', () => {
expect(result.stdout + result.stderr).toMatch(/not held|nothing to break/i);
});
test('--break-lock + --all is refused with shell-loop hint', () => {
test('--break-lock + --all is accepted and iterates sources (self-heal, no longer refused)', () => {
// v0.41.13.0 (T4 + D1) intentionally DROPPED the old --all refusal so cron
// can self-heal every source in one call: runBreakLock now widens to
// iterate sources when --all is set (sync.ts ~2550). This test pins that
// shipped contract. Deterministic: with no active sources OR only
// unlocked ones, every per-source break is a no-op and the exit is 0.
const result = runCli(['sync', '--break-lock', '--all']);
expect(result.code).toBe(1);
expect(result.stderr).toMatch(/cannot be combined with --all/);
expect(result.stderr).toMatch(/for src in/);
const out = result.stdout + result.stderr;
expect(result.code).toBe(0);
// The combination must NOT be rejected anymore.
expect(out).not.toMatch(/cannot be combined with --all/);
// It took the accepted iterate/no-sources path.
expect(out).toMatch(/No active sources to break-lock against|is not held \(nothing to break\)|Broke lock/i);
});
test('lock-busy error message includes PID + hostname + age + --break-lock hint', async () => {
+29
View File
@@ -0,0 +1,29 @@
/**
* Unit tests for parseNiceFlag (issue #1815) — flag > GBRAIN_NICE env > undefined.
*/
import { describe, test, expect } from 'bun:test';
import { parseNiceFlag } from '../src/commands/jobs.ts';
describe('parseNiceFlag', () => {
test('returns undefined when absent (no flag, no env)', () => {
expect(parseNiceFlag(['jobs', 'work'], {})).toBeUndefined();
});
test('reads the --nice flag', () => {
expect(parseNiceFlag(['jobs', 'work', '--nice', '10'], {})).toBe(10);
expect(parseNiceFlag(['jobs', 'work', '--nice', '-5'], {})).toBe(-5);
});
test('falls back to GBRAIN_NICE env', () => {
expect(parseNiceFlag(['jobs', 'work'], { GBRAIN_NICE: '7' })).toBe(7);
});
test('flag wins over env', () => {
expect(parseNiceFlag(['jobs', 'work', '--nice', '3'], { GBRAIN_NICE: '15' })).toBe(3);
});
test('empty env string is treated as absent', () => {
expect(parseNiceFlag(['jobs', 'work'], { GBRAIN_NICE: '' })).toBeUndefined();
});
});
+107
View File
@@ -0,0 +1,107 @@
/**
* Unit tests for the niceness core (issue #1815).
*
* Pins the correctness bugs Codex flagged on the plan:
* - parseNiceValue must reject "3.5"/"10abc" that plain parseInt would accept (#3).
* - applyNiceness must re-read effective in the FAILURE path too, so a denied
* renice records effective:0, not null (#4).
*/
import { describe, test, expect } from 'bun:test';
import {
parseNiceValue,
applyNiceness,
getEffectiveNiceness,
formatNice,
NICE_MIN,
NICE_MAX,
} from '../src/core/minions/niceness.ts';
describe('parseNiceValue', () => {
test('accepts the full POSIX range', () => {
expect(parseNiceValue('0')).toBe(0);
expect(parseNiceValue('10')).toBe(10);
expect(parseNiceValue(String(NICE_MIN))).toBe(NICE_MIN);
expect(parseNiceValue(String(NICE_MAX))).toBe(NICE_MAX);
expect(parseNiceValue('-5')).toBe(-5);
expect(parseNiceValue(' 7 ')).toBe(7); // trimmed
});
test('rejects out-of-range', () => {
expect(() => parseNiceValue('20')).toThrow();
expect(() => parseNiceValue('-21')).toThrow();
});
test('rejects non-integers parseInt would silently truncate (Codex #3)', () => {
expect(() => parseNiceValue('3.5')).toThrow();
expect(() => parseNiceValue('10abc')).toThrow();
expect(() => parseNiceValue('abc')).toThrow();
expect(() => parseNiceValue('')).toThrow();
});
});
describe('applyNiceness', () => {
test('success path returns applied + re-read effective', () => {
let setTo: number | undefined;
const r = applyNiceness(
10,
(_pid, p) => { setTo = p; },
() => 10,
);
expect(setTo).toBe(10);
expect(r.applied).toBe(true);
expect(r.requested).toBe(10);
expect(r.effective).toBe(10);
expect(r.error).toBeUndefined();
});
test('reports the clamped effective when the kernel caps it (Q3)', () => {
// setPriority "succeeds" but the OS clamped to a higher (less negative) value.
const r = applyNiceness(
-20,
() => { /* no throw */ },
() => -5,
);
expect(r.applied).toBe(true);
expect(r.requested).toBe(-20);
expect(r.effective).toBe(-5); // re-read shows the real value
});
test('failure path STILL re-reads effective (Codex #4) — records 0, not null', () => {
const r = applyNiceness(
-5,
() => { const e: NodeJS.ErrnoException = new Error('EACCES'); e.code = 'EACCES'; throw e; },
() => 0, // process stays at its inherited niceness
);
expect(r.applied).toBe(false);
expect(r.error).toContain('EACCES');
expect(r.effective).toBe(0); // NOT null — doctor can show "asked -5, got 0"
});
test('effective is null only when the re-read itself throws', () => {
const r = applyNiceness(
10,
() => { /* ok */ },
() => { throw new Error('boom'); },
);
expect(r.applied).toBe(true);
expect(r.effective).toBeNull();
});
});
describe('getEffectiveNiceness', () => {
test('returns the stub value', () => {
expect(getEffectiveNiceness(1234, () => 7)).toBe(7);
});
test('returns null when the read throws (dead/unreadable pid)', () => {
expect(getEffectiveNiceness(1234, () => { throw new Error('ESRCH'); })).toBeNull();
});
});
describe('formatNice', () => {
test('prefixes positive values with +', () => {
expect(formatNice(10)).toBe('+10');
expect(formatNice(0)).toBe('0');
expect(formatNice(-5)).toBe('-5');
});
});
+39
View File
@@ -0,0 +1,39 @@
/**
* Unit tests for buildWorkerArgs (issue #1815) — the supervisor → worker argv.
* Extracted from runSuperviseLoop so the --nice propagation is testable.
*/
import { describe, test, expect } from 'bun:test';
import { buildWorkerArgs } from '../src/core/minions/supervisor.ts';
describe('buildWorkerArgs', () => {
test('base args without nice or rss', () => {
expect(buildWorkerArgs({ concurrency: 2, queue: 'default', maxRssMb: 0 }))
.toEqual(['jobs', 'work', '--concurrency', '2', '--queue', 'default']);
});
test('includes --max-rss when > 0', () => {
expect(buildWorkerArgs({ concurrency: 4, queue: 'q', maxRssMb: 2048 }))
.toEqual(['jobs', 'work', '--concurrency', '4', '--queue', 'q', '--max-rss', '2048']);
});
test('appends --nice when nice_requested is set', () => {
expect(buildWorkerArgs({ concurrency: 2, queue: 'default', maxRssMb: 0, nice_requested: 10 }))
.toEqual(['jobs', 'work', '--concurrency', '2', '--queue', 'default', '--nice', '10']);
});
test('negative nice propagates', () => {
const args = buildWorkerArgs({ concurrency: 1, queue: 'q', maxRssMb: 512, nice_requested: -5 });
expect(args).toEqual(['jobs', 'work', '--concurrency', '1', '--queue', 'q', '--max-rss', '512', '--nice', '-5']);
});
test('nice 0 is explicit and still propagates (distinct from inherit)', () => {
expect(buildWorkerArgs({ concurrency: 1, queue: 'q', maxRssMb: 0, nice_requested: 0 }))
.toContain('--nice');
});
test('omits --nice when nice_requested is undefined (inherit)', () => {
expect(buildWorkerArgs({ concurrency: 1, queue: 'q', maxRssMb: 0 }))
.not.toContain('--nice');
});
});
+47
View File
@@ -0,0 +1,47 @@
/**
* Unit tests for the shared supervisor PID-file reader (issue #1815, Q4).
* One regression point now backs `jobs supervisor status`, `jobs stats`, and
* `gbrain doctor`.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { readSupervisorPid } from '../src/core/minions/supervisor-pid.ts';
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'gbrain-suppid-')); });
afterEach(() => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } });
describe('readSupervisorPid', () => {
test('present + alive → running', () => {
const f = join(dir, 'sup.pid');
writeFileSync(f, `${process.pid}\n`);
const r = readSupervisorPid(f);
expect(r.pid).toBe(process.pid);
expect(r.running).toBe(true);
});
test('present + dead → pid set, not running', () => {
const f = join(dir, 'sup.pid');
writeFileSync(f, '2147483600\n');
const r = readSupervisorPid(f);
expect(r.pid).toBe(2147483600);
expect(r.running).toBe(false);
});
test('missing file → null, not running', () => {
const r = readSupervisorPid(join(dir, 'nope.pid'));
expect(r.pid).toBeNull();
expect(r.running).toBe(false);
});
test('corrupt content → null, not running', () => {
const f = join(dir, 'sup.pid');
writeFileSync(f, 'not-a-pid\n');
const r = readSupervisorPid(f);
expect(r.pid).toBeNull();
expect(r.running).toBe(false);
});
});
+121
View File
@@ -0,0 +1,121 @@
/**
* Unit tests for the live worker registry (issue #1815, Q1-C).
*
* Pins the lifecycle edges Codex flagged:
* - ESRCH entries pruned, EPERM entries kept alive (#9).
* - PID-reuse guard: an entry whose pid started long after registration is
* not reported (#8).
* - Registry path is brain-isolated under gbrainPath (#7).
* - Corrupt JSON skipped; cleanup unlinks.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync, existsSync, readdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
let home: string;
const origHome = process.env.GBRAIN_HOME;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'gbrain-reg-'));
process.env.GBRAIN_HOME = home;
});
afterEach(() => {
if (origHome === undefined) delete process.env.GBRAIN_HOME;
else process.env.GBRAIN_HOME = origHome;
try { rmSync(home, { recursive: true, force: true }); } catch { /* ignore */ }
});
// Imported lazily AFTER GBRAIN_HOME is set so gbrainPath resolves to the temp dir.
async function reg() {
return await import('../src/core/minions/worker-registry.ts');
}
describe('classifyLiveness (Codex #9)', () => {
test('no error = alive, ESRCH = dead, EPERM = alive, other = unknown', async () => {
const { classifyLiveness } = await reg();
expect(classifyLiveness(undefined)).toBe('alive');
expect(classifyLiveness('ESRCH')).toBe('dead');
expect(classifyLiveness('EPERM')).toBe('alive'); // not pruned just because unsignalable
expect(classifyLiveness('EINVAL')).toBe('unknown');
});
});
describe('register + read round trip', () => {
test('registerWorker writes under gbrainPath; readWorkers returns the live worker', async () => {
const { registerWorker, readWorkers, workerRegistryDir } = await reg();
expect(workerRegistryDir()).toBe(join(home, '.gbrain', 'workers'));
const cleanup = registerWorker({
pid: process.pid, // a definitely-alive pid
queue: 'default',
nice_requested: 10,
nice_effective: 10,
started_at: Date.now(),
});
const live = readWorkers(() => 10); // inject niceness read
expect(live.length).toBe(1);
expect(live[0]!.pid).toBe(process.pid);
expect(live[0]!.queue).toBe('default');
expect(live[0]!.nice_requested).toBe(10);
expect(live[0]!.nice_now).toBe(10);
cleanup();
expect(readWorkers(() => 10).length).toBe(0);
});
test('cleanup unlinks the entry file', async () => {
const { registerWorker, workerRegistryDir } = await reg();
const cleanup = registerWorker({
pid: process.pid, queue: 'q', nice_requested: null, nice_effective: 0, started_at: Date.now(),
});
expect(readdirSync(workerRegistryDir()).length).toBe(1);
cleanup();
expect(readdirSync(workerRegistryDir()).length).toBe(0);
});
});
describe('pruning + guards', () => {
test('ESRCH (dead) entries are dropped and their files pruned', async () => {
const { readWorkers, workerRegistryDir, currentBrainId } = await reg();
const dir = workerRegistryDir();
// A pid that is essentially never alive.
const deadPid = 2147483600;
const file = join(dir, `worker-${deadPid}.json`);
require('fs').mkdirSync(dir, { recursive: true });
writeFileSync(file, JSON.stringify({
pid: deadPid, queue: 'q', brain_id: currentBrainId(),
started_at: Date.now(), nice_requested: 5, nice_effective: 5,
}));
const live = readWorkers(() => 5);
expect(live.length).toBe(0);
expect(existsSync(file)).toBe(false); // pruned
});
test('PID-reuse guard: live pid that started long after registration is skipped (Codex #8)', async () => {
const { readWorkers, workerRegistryDir, currentBrainId } = await reg();
const dir = workerRegistryDir();
require('fs').mkdirSync(dir, { recursive: true });
// Use our own (alive) pid but claim it was registered far in the past — our
// process actually started AFTER that, so the start-time guard rejects it.
writeFileSync(join(dir, `worker-${process.pid}.json`), JSON.stringify({
pid: process.pid, queue: 'q', brain_id: currentBrainId(),
started_at: 1, // epoch ~1970; our process clearly started long after
nice_requested: 5, nice_effective: 5,
}));
const live = readWorkers(() => 5);
expect(live.length).toBe(0);
});
test('corrupt JSON is skipped, not fatal', async () => {
const { readWorkers, workerRegistryDir } = await reg();
const dir = workerRegistryDir();
require('fs').mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `worker-${process.pid}.json`), '{ not valid json');
expect(() => readWorkers(() => 5)).not.toThrow();
expect(readWorkers(() => 5).length).toBe(0);
});
});