Files
gbrain/test/worker-registry.serial.test.ts
T
f11d56cfca 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>
2026-06-03 15:24:00 -07:00

122 lines
4.7 KiB
TypeScript

/**
* 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);
});
});