mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* 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:
co-authored by
Claude Opus 4.8
parent
f4959348c2
commit
f11d56cfca
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'], {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user