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>
262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
/**
|
|
* v0.41.6.0 D3 + D5 E2E — lock recovery scenarios.
|
|
*
|
|
* Combined coverage for the abnormal-termination + lock-owner-message +
|
|
* --break-lock flows that need real subprocess + shared DB state. Skips
|
|
* gracefully when DATABASE_URL is unset.
|
|
*
|
|
* Scenarios:
|
|
* 1. Concurrent sync: second exits with PID + age + --break-lock hint
|
|
* (per eng-review D10).
|
|
* 2. SIGTERM during sync: lock row deleted within 3s
|
|
* (per process-cleanup registry contract).
|
|
* 3. SIGPIPE via real `head -5` pipe: clean exit, next sync runs
|
|
* without "Another sync is in progress" (per outside-voice F14 /
|
|
* eng-review D19).
|
|
* 4. --break-lock with dead local PID: clears the row.
|
|
* 5. --break-lock with alive local PID: refuses with --force-break-lock hint.
|
|
* 6. --force-break-lock with alive PID: clears (with warning).
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { execFileSync, spawnSync, spawn } from 'child_process';
|
|
import { tmpdir, hostname } from 'os';
|
|
import { hasDatabase, setupDB, teardownDB, getEngine } from './helpers.ts';
|
|
import { tryAcquireDbLock, inspectLock } from '../../src/core/db-lock.ts';
|
|
|
|
const skip = !hasDatabase();
|
|
const describeE2E = skip ? describe.skip : describe;
|
|
if (skip) console.log('Skipping lock-recovery E2E (DATABASE_URL not set)');
|
|
|
|
const CLI = ['bun', 'run', join(import.meta.dir, '..', '..', 'src', 'cli.ts')];
|
|
|
|
let tmpHome: string;
|
|
let repoDir: string;
|
|
|
|
beforeAll(async () => {
|
|
if (skip) return;
|
|
tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-lock-recovery-e2e-'));
|
|
await setupDB();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (skip) return;
|
|
try { rmSync(tmpHome, { recursive: true, force: true }); } catch { /* */ }
|
|
await teardownDB();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
if (skip) return;
|
|
if (repoDir) { try { rmSync(repoDir, { recursive: true, force: true }); } catch { /* */ } }
|
|
repoDir = mkdtempSync(join(tmpdir(), 'gbrain-lock-recovery-repo-'));
|
|
mkdirSync(join(repoDir, 'people'), { recursive: true });
|
|
for (let i = 0; i < 5; i++) {
|
|
writeFileSync(join(repoDir, 'people', `alice-example-${i}.md`), [
|
|
'---', 'type: person', `title: Alice Example ${i}`, '---', '',
|
|
`Placeholder person ${i} for lock-recovery E2E.`,
|
|
].join('\n'));
|
|
}
|
|
execFileSync('git', ['init', '-q'], { cwd: repoDir });
|
|
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoDir });
|
|
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoDir });
|
|
execFileSync('git', ['add', '.'], { cwd: repoDir });
|
|
execFileSync('git', ['commit', '-q', '-m', 'initial'], { cwd: repoDir });
|
|
|
|
// Clean up any leftover lock rows from prior runs.
|
|
const eng = getEngine();
|
|
try { await (eng as any).sql`DELETE FROM gbrain_cycle_locks WHERE id LIKE 'gbrain-sync:%'`; } catch { /* */ }
|
|
});
|
|
|
|
function runCli(args: string[], env: Record<string, string | undefined> = {}): { code: number; stdout: string; stderr: string } {
|
|
const fullEnv: Record<string, string | undefined> = {
|
|
...(process.env as Record<string, string | undefined>),
|
|
GBRAIN_HOME: tmpHome,
|
|
DATABASE_URL: process.env.DATABASE_URL!,
|
|
...env,
|
|
};
|
|
for (const k of Object.keys(fullEnv)) if (fullEnv[k] === undefined) delete fullEnv[k];
|
|
const res = spawnSync(CLI[0], [...CLI.slice(1), ...args], {
|
|
env: fullEnv as Record<string, string>,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
encoding: 'utf8',
|
|
timeout: 30_000,
|
|
});
|
|
return { code: res.status ?? -1, stdout: res.stdout, stderr: res.stderr };
|
|
}
|
|
|
|
describeE2E('v0.41.6.0 — sync lock recovery scenarios', () => {
|
|
test('--break-lock refuses when no lock row exists (clean message, exit 0)', () => {
|
|
const result = runCli(['sync', '--break-lock', '--source', 'default']);
|
|
expect(result.code).toBe(0);
|
|
expect(result.stdout + result.stderr).toMatch(/not held|nothing to break/i);
|
|
});
|
|
|
|
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']);
|
|
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 () => {
|
|
// Acquire a lock from THIS process so the row exists for the subprocess to see.
|
|
const eng = getEngine();
|
|
const lockKey = 'gbrain-sync:default';
|
|
const handle = await tryAcquireDbLock(eng, lockKey);
|
|
expect(handle).not.toBeNull();
|
|
|
|
try {
|
|
const result = runCli(['sync', '--repo', repoDir, '--full', '--yes']);
|
|
expect(result.code).not.toBe(0);
|
|
const msg = result.stderr + result.stdout;
|
|
expect(msg).toMatch(new RegExp(`pid ${process.pid}`));
|
|
expect(msg).toMatch(/started \d+/);
|
|
expect(msg).toMatch(/--break-lock/);
|
|
} finally {
|
|
await handle!.release();
|
|
}
|
|
});
|
|
|
|
test('--break-lock with TTL-expired row clears the lock', async () => {
|
|
const eng = getEngine();
|
|
// Insert a TTL-expired row with a fake PID on this host.
|
|
await (eng as any).sql`
|
|
INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
|
|
VALUES ('gbrain-sync:default', 99999, ${hostname()}, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '30 minutes')
|
|
`;
|
|
|
|
const result = runCli(['sync', '--break-lock', '--source', 'default']);
|
|
expect(result.code).toBe(0);
|
|
expect(result.stdout + result.stderr).toMatch(/broke lock.*ttl_expired/i);
|
|
|
|
// Lock row should be gone.
|
|
const snap = await inspectLock(eng, 'gbrain-sync:default');
|
|
expect(snap).toBeNull();
|
|
});
|
|
|
|
test('--break-lock with alive local PID refuses with --force-break-lock hint', async () => {
|
|
const eng = getEngine();
|
|
// Use OUR pid → guaranteed alive on this host.
|
|
await (eng as any).sql`
|
|
INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
|
|
VALUES ('gbrain-sync:default', ${process.pid}, ${hostname()}, NOW(), NOW() + INTERVAL '30 minutes')
|
|
`;
|
|
|
|
const result = runCli(['sync', '--break-lock', '--source', 'default']);
|
|
expect(result.code).toBe(1);
|
|
expect(result.stderr).toMatch(/Refusing to break lock/);
|
|
expect(result.stderr).toMatch(/--force-break-lock/);
|
|
|
|
// Lock row should still exist.
|
|
const snap = await inspectLock(eng, 'gbrain-sync:default');
|
|
expect(snap).not.toBeNull();
|
|
|
|
// Cleanup.
|
|
await (eng as any).sql`DELETE FROM gbrain_cycle_locks WHERE id = 'gbrain-sync:default'`;
|
|
});
|
|
|
|
test('--force-break-lock clears even when holder PID is alive (with warning)', async () => {
|
|
const eng = getEngine();
|
|
await (eng as any).sql`
|
|
INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
|
|
VALUES ('gbrain-sync:default', ${process.pid}, ${hostname()}, NOW(), NOW() + INTERVAL '30 minutes')
|
|
`;
|
|
|
|
const result = runCli(['sync', '--force-break-lock', '--source', 'default']);
|
|
expect(result.code).toBe(0);
|
|
expect(result.stdout + result.stderr).toMatch(/[Ff]orce-broke lock|WARNING/);
|
|
|
|
const snap = await inspectLock(eng, 'gbrain-sync:default');
|
|
expect(snap).toBeNull();
|
|
});
|
|
|
|
test('SIGTERM during sync releases the lock within 3s', async () => {
|
|
// Start a sync subprocess that will hold the lock briefly.
|
|
// We'd ideally watch for the lock row to appear, then SIGTERM. Since
|
|
// sync is fast on a 5-file repo, we use a tight polling loop with
|
|
// an early-exit if we see the row.
|
|
const eng = getEngine();
|
|
const sigtermProc = spawn(CLI[0], [...CLI.slice(1), 'sync', '--repo', repoDir, '--full', '--yes', '--no-embed'], {
|
|
env: {
|
|
...process.env,
|
|
GBRAIN_HOME: tmpHome,
|
|
DATABASE_URL: process.env.DATABASE_URL!,
|
|
} as Record<string, string>,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
// Wait up to 5s for the lock row to appear, then SIGTERM.
|
|
let lockSeen = false;
|
|
for (let i = 0; i < 50; i++) {
|
|
const snap = await inspectLock(eng, 'gbrain-sync:default');
|
|
if (snap && snap.holder_pid === sigtermProc.pid) { lockSeen = true; break; }
|
|
await new Promise(r => setTimeout(r, 100));
|
|
}
|
|
if (!lockSeen) {
|
|
// Sync may have completed before we caught the lock. That's also fine.
|
|
sigtermProc.kill('SIGTERM');
|
|
await new Promise(r => sigtermProc.on('exit', r));
|
|
// Skip the rest of the assertion.
|
|
return;
|
|
}
|
|
|
|
sigtermProc.kill('SIGTERM');
|
|
await new Promise(r => sigtermProc.on('exit', r));
|
|
|
|
// Within 3s of exit, lock should be gone.
|
|
let lockGone = false;
|
|
for (let i = 0; i < 30; i++) {
|
|
const snap = await inspectLock(eng, 'gbrain-sync:default');
|
|
if (!snap || snap.holder_pid !== sigtermProc.pid) { lockGone = true; break; }
|
|
await new Promise(r => setTimeout(r, 100));
|
|
}
|
|
expect(lockGone).toBe(true);
|
|
});
|
|
|
|
// v0.41.7+ follow-up: this test's timing is brittle on slow CI.
|
|
// The SIGPIPE cleanup-registry codepath IS exercised structurally by
|
|
// unit test/process-cleanup.test.ts. The SIGTERM-during-sync E2E above
|
|
// verifies the lock-release on abnormal termination. Re-enable once
|
|
// the head-pipe scenario can be made deterministic across CI runners.
|
|
test.skip('pipe through `head -5` exits cleanly, next sync runs without lock-busy', async () => {
|
|
// Run `gbrain sync ... | head -5` via shell.
|
|
const cmd = `${CLI.join(' ')} sync --repo ${repoDir} --full --yes --no-embed 2>&1 | head -5`;
|
|
const result = spawnSync('sh', ['-c', cmd], {
|
|
env: {
|
|
...process.env,
|
|
GBRAIN_HOME: tmpHome,
|
|
DATABASE_URL: process.env.DATABASE_URL!,
|
|
} as Record<string, string>,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
encoding: 'utf8',
|
|
timeout: 30_000,
|
|
});
|
|
// head closes the pipe → SIGPIPE → cleanup → exit. Exit code from `sh` is
|
|
// last command (head) which exited 0 since it read its 5 lines.
|
|
expect(result.status).toBe(0);
|
|
|
|
// Next sync should NOT report "Another sync is in progress" — give the
|
|
// cleanup pass up to 5s to clear the lock.
|
|
let nextResult: ReturnType<typeof runCli>;
|
|
let nextOk = false;
|
|
for (let i = 0; i < 5; i++) {
|
|
nextResult = runCli(['sync', '--repo', repoDir, '--full', '--yes', '--no-embed']);
|
|
if (!/Another sync is in progress/.test(nextResult.stderr + nextResult.stdout)) {
|
|
nextOk = true;
|
|
break;
|
|
}
|
|
await new Promise(r => setTimeout(r, 1000));
|
|
}
|
|
expect(nextOk).toBe(true);
|
|
}, 60_000);
|
|
});
|