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>
108 lines
3.4 KiB
TypeScript
108 lines
3.4 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|