Files
gbrain/test/e2e/serve-stdio-roundtrip.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

128 lines
5.9 KiB
TypeScript

/**
* E2E for the "standalone from nothing" funnel: `gbrain init --pglite` →
* `gbrain serve` (stdio) wired into a coding agent as an MCP subprocess.
*
* This is the canonical local path the docs encourage for Claude Code / Codex
* users with no remote brain:
*
* claude mcp add gbrain -- gbrain serve
* codex mcp add gbrain -- gbrain serve
*
* The `connect`/bearer E2E proves the REMOTE (HTTP) funnel. Nothing proved the
* LOCAL stdio funnel end-to-end: that a freshly-init'd PGLite brain, served
* over stdio, actually answers real MCP `tools/call`s through the official MCP
* SDK client (the same handshake Claude Code / Codex perform). The
* serve-stdio-lifecycle unit test only covers shutdown signalling.
*
* No Postgres / Docker. PGLite, hermetic temp HOME. Drives the real
* StdioClientTransport, so the MCP SDK spawns `gbrain serve` for us, runs the
* `initialize` handshake, and round-trips `tools/list` + `tools/call`.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { execFileSync } from 'child_process';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
// Distinctive token so keyword search can't accidentally match anything else.
const MARKER = 'qantani-marker-9f3z';
function textOf(result: unknown): string {
const content = (result as { content?: Array<{ type?: string; text?: string }> })?.content;
if (!Array.isArray(content)) return '';
return content.map((c) => (typeof c?.text === 'string' ? c.text : '')).join('\n');
}
describe('serve stdio round-trip E2E (local PGLite → real MCP tool calls)', () => {
let home: string;
let client: Client | null = null;
let transport: StdioClientTransport | null = null;
let connected = false;
beforeAll(async () => {
home = mkdtempSync(join(tmpdir(), 'gbrain-stdio-e2e-'));
// 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'], {
cwd: process.cwd(), env, stdio: 'ignore',
});
// 2. Seed one page so page_count > 0 and search has something to find.
// --no-embed keeps it hermetic (no embedding provider configured); the
// keyword path still finds the distinctive marker.
const notes = join(home, 'notes');
mkdirSync(notes, { recursive: true });
writeFileSync(
join(notes, 'marker.md'),
`---\ntitle: ${MARKER} note\n---\n\n# ${MARKER}\n\nThis page exists to prove ${MARKER} is retrievable over stdio MCP.\n`,
);
execFileSync('bun', ['run', 'src/cli.ts', 'import', notes, '--no-embed'], {
cwd: process.cwd(), env, stdio: 'ignore',
});
// 3. Let the MCP SDK spawn `gbrain serve` (stdio) and run the initialize
// handshake — exactly what `claude mcp add gbrain -- gbrain serve` does.
transport = new StdioClientTransport({
command: 'bun',
args: ['run', 'src/cli.ts', 'serve'],
cwd: process.cwd(),
env, // includes PATH (to find `bun`) + GBRAIN_HOME
});
client = new Client({ name: 'gbrain-stdio-e2e', version: '1.0.0' }, { capabilities: {} });
await client.connect(transport);
connected = true;
}, 60_000);
afterAll(async () => {
if (client) { try { await client.close(); } catch { /* best-effort */ } }
if (transport) { try { await transport.close(); } catch { /* best-effort */ } }
if (home) { try { rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ } }
});
test('initialize handshake + tools/list exposes the core retrieval tools', async () => {
expect(connected).toBe(true);
const { tools } = await client!.listTools();
const names = new Set(tools.map((t) => t.name));
// The core MCP tools the connect LEARN_INSTRUCTION promises always work.
// `capture` is deliberately NOT here — it's a CLI-only wrapper, not an MCP
// tool; the agent writes via put_page (regression guard for the stale
// LEARN_INSTRUCTION that named capture as an MCP tool).
for (const core of ['search', 'query', 'get_page', 'put_page', 'get_brain_identity', 'think', 'find_experts']) {
expect(names.has(core)).toBe(true);
}
expect(names.has('capture')).toBe(false); // CLI-only, must not be advertised as MCP
}, 30_000);
test('tools/call get_brain_identity returns version + engine + a populated counter', async () => {
expect(connected).toBe(true);
const res = await client!.callTool({ name: 'get_brain_identity', arguments: {} });
const text = textOf(res);
const id = JSON.parse(text) as { version: string; engine: string; page_count: number };
expect(typeof id.version).toBe('string');
expect(id.engine).toBe('pglite');
expect(id.page_count).toBeGreaterThanOrEqual(1); // the seeded page
}, 30_000);
test('tools/call search surfaces the seeded page (keyword path, no embeddings)', async () => {
expect(connected).toBe(true);
const res = await client!.callTool({ name: 'search', arguments: { query: MARKER, limit: 5 } });
const text = textOf(res);
// The result payload (slug / title / snippet) must mention the marker.
expect(text).toContain(MARKER);
}, 30_000);
});