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>
239 lines
9.3 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|