mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(minions): self-identifying RSS watchdog + cgroup-aware default + pooler-reap self-heal (#1678) Problem 1: distinct WORKER_EXIT_RSS_WATCHDOG exit code + cause-keyed supervisor breaker (bypasses the stable-run reset that hid the 400x/24h loop) + rss_watchdog audit bucket + 80% soft-warn; cgroup-aware resolveDefaultMaxRssMb replaces the flat 2048 default at every spawn site. Problem 2: CONNECTION_ENDED classified retryable; postgres-engine sql getter throws a retryable error on a reaped instance pool instead of the misleading module-singleton fallthrough; promoteDelayed reconnect-retry; claim recovers on the next poll tick (no double-claim); lock-renewal tick reconnect-once dep. * feat(cycle): surface silent extract_atoms backlog + bounded --drain + fix lint clobbering the shared DB connection (#1678) Problem 3: extract_atoms_backlog doctor check + pack_gated skip marker + shared countExtractAtomsBacklog; `gbrain dream --phase extract_atoms --drain [--window N]` single-hold bounded drain (same cycleLockIdFor, rediscover each batch, reports remaining, exits non-zero while work remains). Also fixes a real production bug found via E2E: the cycle lint phase's resolveLintContentSanity created + disconnected a module-style engine that nulled the shared db singleton mid-cycle, breaking every later phase with "connect() has not been called". Lint now reuses the caller's live engine (cycle + Minion handlers thread it; standalone CLI keeps the create-own path). * chore: bump version and changelog (v0.41.39.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#1678): pre-landing review — route transaction/withReservedConnection through the sql getter + drain treats failed count as incomplete Codex adversarial review findings: - #2: transaction(), withReservedConnection(), and one other site bypassed the v0.42.2.0 sql-getter self-heal via `this._sql || db.getConnection()`, so a reaped instance pool fell through to the module singleton there. Route all three through `this.sql` so they throw the retryable instance-pool error and recover consistently (MinionQueue.transaction hits this). - #4: `gbrain dream --drain` treated a null backlog count (query failure) as success via `remaining ?? 0`; now null exits EXIT_DRAIN_INCOMPLETE so automation never believes an unverified backlog drained. - #1 (claim orphan) + #3 (PGLite drain lock) documented as follow-ups in TODOS. * docs: document v0.42.2.0 #1678 modules + behavior in CLAUDE.md Adds Key Files entries for worker-exit-codes.ts, rss-default.ts, and extract-atoms-drain.ts, plus v0.42.2.0 annotations on worker.ts, child-worker-supervisor.ts, lock-renewal-tick.ts, and dream.ts. Regenerated llms-full.txt to match (test/build-llms.test.ts gate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: re-version v0.42.2.0 → v0.42.5.0 across VERSION/package.json/CHANGELOG/docs/comments Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
229 lines
8.3 KiB
TypeScript
229 lines
8.3 KiB
TypeScript
/**
|
|
* E2E cycle tests — Tier 1 (no API keys required).
|
|
*
|
|
* Exercises runCycle against REAL Postgres (via the E2E helpers' setupDB /
|
|
* teardownDB lifecycle) with a real git repo and a mocked embedBatch.
|
|
* Covers what the unit tests can't: the gbrain_cycle_locks table's
|
|
* INSERT...ON CONFLICT...WHERE semantics under a real postgres-js client,
|
|
* the v0.17 schema migration applying cleanly to a fresh Postgres, and the
|
|
* dry-run regression guard asserting zero writes when flag is set.
|
|
*
|
|
* Run: DATABASE_URL=... bun test test/e2e/cycle.test.ts
|
|
*/
|
|
|
|
import { describe, test, expect, mock, beforeAll, afterAll } from 'bun:test';
|
|
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { execSync } from 'child_process';
|
|
import { tmpdir } from 'os';
|
|
import { hasDatabase, setupDB, teardownDB, getEngine, getConn } from './helpers.ts';
|
|
|
|
// Mock embedBatch BEFORE importing runCycle so no real OpenAI calls happen
|
|
// even when the full cycle's embed phase runs.
|
|
mock.module('../../src/core/embedding.ts', () => ({
|
|
embedBatch: async (texts: string[]) => {
|
|
// Deterministic fake vector for each chunk.
|
|
return texts.map(() => new Float32Array(1536));
|
|
},
|
|
// v0.41.31: embed phase reads the current signature to stamp provenance.
|
|
currentEmbeddingSignature: () => 'test:model:1536',
|
|
}));
|
|
|
|
const { runCycle, ALL_PHASES } = await import('../../src/core/cycle.ts');
|
|
|
|
const skip = !hasDatabase();
|
|
const describeE2E = skip ? describe.skip : describe;
|
|
|
|
if (skip) {
|
|
console.log('Skipping E2E cycle tests (DATABASE_URL not set)');
|
|
}
|
|
|
|
function makeGitRepo(): string {
|
|
const dir = mkdtempSync(join(tmpdir(), 'gbrain-e2e-cycle-'));
|
|
execSync('git init', { cwd: dir, stdio: 'pipe' });
|
|
execSync('git config user.email test@test.co', { cwd: dir, stdio: 'pipe' });
|
|
execSync('git config user.name test', { cwd: dir, stdio: 'pipe' });
|
|
|
|
mkdirSync(join(dir, 'people'), { recursive: true });
|
|
writeFileSync(
|
|
join(dir, 'people/alice.md'),
|
|
'---\ntype: person\ntitle: Alice\n---\n\nAlice collaborates with Bob.\n',
|
|
);
|
|
writeFileSync(
|
|
join(dir, 'people/bob.md'),
|
|
'---\ntype: person\ntitle: Bob\n---\n\nBob is a person.\n',
|
|
);
|
|
execSync('git add -A && git commit -m init', { cwd: dir, stdio: 'pipe' });
|
|
return dir;
|
|
}
|
|
|
|
describeE2E('E2E: runCycle against real Postgres', () => {
|
|
let repo: string;
|
|
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
repo = makeGitRepo();
|
|
}, 30_000);
|
|
|
|
afterAll(async () => {
|
|
await teardownDB();
|
|
if (repo) rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
test('v0.17 migration v16 created gbrain_cycle_locks table', async () => {
|
|
const conn = getConn();
|
|
const rows = await conn.unsafe(
|
|
`SELECT tablename FROM pg_tables WHERE tablename = 'gbrain_cycle_locks'`,
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
|
|
// idx_cycle_locks_ttl index also exists.
|
|
const idx = await conn.unsafe(
|
|
`SELECT indexname FROM pg_indexes WHERE indexname = 'idx_cycle_locks_ttl'`,
|
|
);
|
|
expect(idx.length).toBe(1);
|
|
});
|
|
|
|
test('dry-run full cycle: zero DB writes + zero filesystem changes', async () => {
|
|
const conn = getConn();
|
|
// Baseline: track initial state.
|
|
const beforePages = await conn.unsafe(`SELECT count(*)::int AS n FROM pages`);
|
|
const beforeSync = await conn.unsafe(
|
|
`SELECT value FROM config WHERE key = 'sync.last_commit'`,
|
|
);
|
|
|
|
const report = await runCycle(getEngine(), {
|
|
brainDir: repo,
|
|
dryRun: true,
|
|
pull: false,
|
|
});
|
|
|
|
expect(report.schema_version).toBe('1');
|
|
// Every phase in ALL_PHASES pushes exactly one result (pack-gated phases
|
|
// like extract_atoms / synthesize_concepts push a 'skipped' result), so
|
|
// the full dry-run cycle's phase count always equals ALL_PHASES.length.
|
|
// Assert against the live constant rather than a hardcoded number so this
|
|
// doesn't go stale every time a phase is added (it drifted to 19 while the
|
|
// real count was 20 — the conversation_facts_backfill phase was missed).
|
|
expect(report.phases.length).toBe(ALL_PHASES.length);
|
|
|
|
// Nothing got written.
|
|
const afterPages = await conn.unsafe(`SELECT count(*)::int AS n FROM pages`);
|
|
expect(afterPages[0].n).toBe(beforePages[0].n);
|
|
|
|
// sync.last_commit unchanged (wasn't set before, isn't set now).
|
|
const afterSync = await conn.unsafe(
|
|
`SELECT value FROM config WHERE key = 'sync.last_commit'`,
|
|
);
|
|
expect(afterSync.length).toBe(beforeSync.length);
|
|
|
|
// Cycle lock was acquired + released; table should be empty after.
|
|
const locks = await conn.unsafe(`SELECT COUNT(*)::int AS n FROM gbrain_cycle_locks`);
|
|
expect(locks[0].n).toBe(0);
|
|
});
|
|
|
|
test('live cycle: pages get synced + chunks created + cycle lock cleaned up', async () => {
|
|
const conn = getConn();
|
|
|
|
const report = await runCycle(getEngine(), {
|
|
brainDir: repo,
|
|
dryRun: false,
|
|
pull: false,
|
|
});
|
|
|
|
expect(report.schema_version).toBe('1');
|
|
// The sync phase should have run and imported real pages.
|
|
const syncPhase = report.phases.find(p => p.phase === 'sync');
|
|
expect(syncPhase).toBeDefined();
|
|
expect(syncPhase?.status).not.toBe('fail');
|
|
|
|
// Pages exist in the DB.
|
|
const pages = await conn.unsafe(`SELECT slug FROM pages ORDER BY slug`);
|
|
const slugs = (pages as unknown as Array<{ slug: string }>).map(p => p.slug);
|
|
expect(slugs).toContain('people/alice');
|
|
expect(slugs).toContain('people/bob');
|
|
|
|
// sync.last_commit bookmark is now set.
|
|
const sync = await conn.unsafe(
|
|
`SELECT value FROM config WHERE key = 'sync.last_commit'`,
|
|
);
|
|
expect(sync.length).toBe(1);
|
|
expect((sync[0] as any).value.length).toBeGreaterThanOrEqual(7);
|
|
|
|
// Cycle lock is released.
|
|
const locks = await conn.unsafe(`SELECT COUNT(*)::int AS n FROM gbrain_cycle_locks`);
|
|
expect(locks[0].n).toBe(0);
|
|
}, 60_000);
|
|
|
|
test('concurrent cycle is blocked by the lock (status:skipped)', async () => {
|
|
const conn = getConn();
|
|
|
|
// Seed a fresh-TTL lock held by a different (fake) PID.
|
|
await conn.unsafe(
|
|
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
|
|
VALUES ('gbrain-cycle', 99999, 'other-host', NOW(), NOW() + INTERVAL '1 hour')`,
|
|
);
|
|
|
|
try {
|
|
const report = await runCycle(getEngine(), {
|
|
brainDir: repo,
|
|
dryRun: true,
|
|
pull: false,
|
|
});
|
|
expect(report.status).toBe('skipped');
|
|
expect(report.reason).toBe('cycle_already_running');
|
|
expect(report.phases.length).toBe(0);
|
|
} finally {
|
|
// Clean up the seeded lock.
|
|
await conn.unsafe(`DELETE FROM gbrain_cycle_locks WHERE id = 'gbrain-cycle'`);
|
|
}
|
|
});
|
|
|
|
test('TTL-expired lock is auto-claimed (crashed holder recovery)', async () => {
|
|
const conn = getConn();
|
|
|
|
// Seed a stale lock (TTL in the past).
|
|
await conn.unsafe(
|
|
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
|
|
VALUES ('gbrain-cycle', 99999, 'crashed-host', NOW() - INTERVAL '2 hours', NOW() - INTERVAL '1 hour')`,
|
|
);
|
|
|
|
const report = await runCycle(getEngine(), {
|
|
brainDir: repo,
|
|
dryRun: true,
|
|
pull: false,
|
|
});
|
|
// Crashed holder's stale TTL lets the new run acquire the lock.
|
|
expect(report.status).not.toBe('skipped');
|
|
|
|
// Lock released after the run.
|
|
const locks = await conn.unsafe(`SELECT COUNT(*)::int AS n FROM gbrain_cycle_locks`);
|
|
expect(locks[0].n).toBe(0);
|
|
});
|
|
|
|
test('--phase orphans skips the lock entirely (read-only optimization)', async () => {
|
|
const conn = getConn();
|
|
|
|
// Seed a fresh-TTL lock held by someone else. A read-only phase
|
|
// selection should succeed anyway (orphans never acquires the lock).
|
|
await conn.unsafe(
|
|
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
|
|
VALUES ('gbrain-cycle', 99999, 'other-host', NOW(), NOW() + INTERVAL '1 hour')`,
|
|
);
|
|
|
|
try {
|
|
const report = await runCycle(getEngine(), {
|
|
brainDir: repo,
|
|
phases: ['orphans'],
|
|
pull: false,
|
|
});
|
|
// Status is NOT skipped — orphans ran despite the held lock.
|
|
expect(report.status).not.toBe('skipped');
|
|
const orphansPhase = report.phases.find(p => p.phase === 'orphans');
|
|
expect(orphansPhase).toBeDefined();
|
|
} finally {
|
|
await conn.unsafe(`DELETE FROM gbrain_cycle_locks WHERE id = 'gbrain-cycle'`);
|
|
}
|
|
});
|
|
});
|