Files
gbrain/test/e2e/dream-cycle-phase-order-pglite.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

239 lines
9.3 KiB
TypeScript

/**
* E2E full cycle on PGLite, no API key required.
*
* Verifies the current phase order is honored end-to-end through runCycle
* when no API key is present (synthesize + patterns skip cleanly, the
* remaining phases run unchanged).
*
* Phase ordering history:
* v0.23 — 8 phases: lint → backlinks → sync → synthesize → extract →
* patterns → embed → orphans
* v0.26.5 — 9 phases (added `purge` last)
* v0.29 — 10 phases (added `recompute_emotional_weight` between patterns
* and embed; `purge` stays last)
*
* Two regression-relevant invariants:
* 1. CycleReport.phases preserves the documented order — no future
* reorder regresses without breaking this test.
* 2. CycleReport.totals carries the v0.23 fields:
* transcripts_processed, synth_pages_written, patterns_written.
*
* No DATABASE_URL required. Mocks embedBatch so the embed phase doesn't
* attempt OpenAI calls.
*
* Run: bun test test/e2e/dream-cycle-phase-order-pglite.test.ts
*/
import { describe, test, expect, mock } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
import { join } from 'path';
import { execSync } from 'child_process';
import { tmpdir } from 'os';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
// Mock must declare EVERY symbol src/core/embedding.ts exports — Bun's module
// linker fails-fast if any consumer downstream imports a missing one. v0.36.1.0
// added `embedMultimodal` and `embedQuery` to the module; the propose_takes
// phase + other v0.36 phases pull both, so the mock has to keep parity.
mock.module('../../src/core/embedding.ts', () => ({
embed: async () => new Float32Array(1536),
embedQuery: async () => new Float32Array(1536),
embedBatch: async (texts: string[]) => texts.map(() => new Float32Array(1536)),
embedMultimodal: async () => [],
getEmbeddingModelName: () => 'text-embedding-3-large',
getEmbeddingDimensions: () => 1536,
EMBEDDING_MODEL: 'text-embedding-3-large',
EMBEDDING_DIMENSIONS: 1536,
EMBEDDING_COST_PER_1K_TOKENS: 0.00013,
estimateEmbeddingCostUsd: (tokens: number) => (tokens / 1000) * 0.00013,
// v0.41.31: embed phase reads the current signature to stamp provenance.
currentEmbeddingSignature: () => 'text-embedding-3-large:1536',
}));
const { runCycle, ALL_PHASES } = await import('../../src/core/cycle.ts');
interface TestRig {
engine: PGLiteEngine;
brainDir: string;
cleanup: () => Promise<void>;
}
async function setupRig(): Promise<TestRig> {
const engine = new PGLiteEngine();
await engine.connect({ engine: 'pglite' } as never);
await engine.initSchema();
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-cycle8-'));
execSync('git init', { cwd: brainDir, stdio: 'pipe' });
execSync('git config user.email test@test.co', { cwd: brainDir, stdio: 'pipe' });
execSync('git config user.name test', { cwd: brainDir, stdio: 'pipe' });
mkdirSync(join(brainDir, 'concepts'), { recursive: true });
writeFileSync(
join(brainDir, 'concepts/testing.md'),
'---\ntype: concept\ntitle: Testing\n---\n\nTest body content.\n',
);
execSync('git add -A && git commit -m init', { cwd: brainDir, stdio: 'pipe' });
await engine.setConfig('sync.repo_path', brainDir);
return {
engine,
brainDir,
cleanup: async () => {
try { await engine.disconnect(); } catch { /* */ }
try { rmSync(brainDir, { recursive: true, force: true }); } catch { /* */ }
},
};
}
async function withoutAnthropicKey<T>(body: () => Promise<T>): Promise<T> {
const saved = process.env.ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_API_KEY;
try {
return await body();
} finally {
if (saved === undefined) delete process.env.ANTHROPIC_API_KEY;
else process.env.ANTHROPIC_API_KEY = saved;
}
}
// v0.31: phase set has grown from v0.23's 8 phases. The order below is
// the canonical sequence enforced by ALL_PHASES in src/core/cycle.ts.
// Maintenance contract: when a future migration adds or removes a phase,
// extend this constant AND update both assertions below.
//
// Phase history:
// v0.23 — 8 phases (lint → ... → orphans)
// v0.26.5 — added `purge` (last)
// v0.29 — added `recompute_emotional_weight` between patterns and embed
// v0.31 — added `consolidate` between recompute_emotional_weight and embed
// v0.33 — added `resolve_symbol_edges` between extract and patterns
type CyclePhase = (typeof ALL_PHASES)[number];
// Mirrors src/core/cycle.ts ALL_PHASES order exactly. v0.41.31: synced the
// three phases that drifted in after this test was last touched (v0.41.0.0):
// extract_atoms (v0.41 T9), synthesize_concepts, conversation_facts_backfill.
const EXPECTED_PHASES: CyclePhase[] = [
'lint',
'backlinks',
'sync',
'synthesize',
'extract',
'extract_facts', // v0.32.2 — reconcile fence → DB facts index
'extract_atoms', // v0.41 T9 — atom extraction (pack-gated)
'resolve_symbol_edges', // v0.33.3 — within-file symbol resolution
'patterns',
'synthesize_concepts', // v0.41 T9 — concept synthesis (pack-gated)
'recompute_emotional_weight', // v0.29
'consolidate', // v0.31
'propose_takes', // v0.36.1.0 — hindsight calibration wave
'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',
'schema-suggest', // v0.39.0.0 — passive schema-suggest after orphans
'purge', // v0.26.5
];
describe('E2E full cycle phase order', () => {
test('ALL_PHASES matches the documented sequence', () => {
expect(ALL_PHASES).toEqual(EXPECTED_PHASES);
});
test('full cycle on dry-run returns CycleReport.phases in canonical order with v0.23 totals fields', async () => {
const rig = await setupRig();
try {
await withoutAnthropicKey(async () => {
const report = await runCycle(rig.engine, {
brainDir: rig.brainDir,
dryRun: true,
});
// Phase ordering preserved across releases
const phaseNames = report.phases.map(p => p.phase);
expect(phaseNames).toEqual(EXPECTED_PHASES);
// Additive totals fields across v0.23, v0.26.5, v0.31 all present
expect(report.totals).toMatchObject({
transcripts_processed: 0,
synth_pages_written: 0,
patterns_written: 0,
purged_sources_count: 0,
purged_pages_count: 0,
facts_consolidated: 0,
consolidate_takes_written: 0,
});
// Synthesize and patterns are skipped (not_configured / insufficient_evidence)
const synth = report.phases.find(p => p.phase === 'synthesize');
const patterns = report.phases.find(p => p.phase === 'patterns');
expect(synth?.status).toBe('skipped');
expect(patterns?.status).toBe('skipped');
});
} finally {
await rig.cleanup();
}
});
test('--phase synthesize alone runs only that phase, returns skipped/not_configured', async () => {
const rig = await setupRig();
try {
await withoutAnthropicKey(async () => {
const report = await runCycle(rig.engine, {
brainDir: rig.brainDir,
dryRun: false,
phases: ['synthesize'],
});
expect(report.phases).toHaveLength(1);
expect(report.phases[0].phase).toBe('synthesize');
expect(report.phases[0].status).toBe('skipped');
});
} finally {
await rig.cleanup();
}
});
test('--phase patterns alone runs only that phase, returns skipped/insufficient_evidence', async () => {
const rig = await setupRig();
try {
await withoutAnthropicKey(async () => {
const report = await runCycle(rig.engine, {
brainDir: rig.brainDir,
dryRun: false,
phases: ['patterns'],
});
expect(report.phases).toHaveLength(1);
expect(report.phases[0].phase).toBe('patterns');
expect(report.phases[0].status).toBe('skipped');
expect((report.phases[0].details as { reason?: string }).reason).toBe('insufficient_evidence');
});
} finally {
await rig.cleanup();
}
});
test('synthInputFile flag is plumbed through runCycle to runPhaseSynthesize', async () => {
const rig = await setupRig();
try {
const transcript = join(tmpdir(), `gbrain-e2e-cycle8-input-${Date.now()}.txt`);
writeFileSync(transcript, 'sample conversation '.repeat(300));
try {
await withoutAnthropicKey(async () => {
const report = await runCycle(rig.engine, {
brainDir: rig.brainDir,
dryRun: false,
phases: ['synthesize'],
synthInputFile: transcript,
});
// Without API key, synthesize falls through to no-key skip-path
// and returns ok (NOT cooldown_active — explicit input bypasses).
expect(report.phases[0].phase).toBe('synthesize');
expect(report.phases[0].status).toBe('ok');
});
} finally {
rmSync(transcript, { force: true });
}
} finally {
await rig.cleanup();
}
});
});