mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(sync): contention-free page-generation clock — sequence swap The page-generation clock backed the query-cache Layer-1 bookmark via a FOR EACH STATEMENT trigger running `UPDATE page_generation_clock SET value=value+1 WHERE id=1`. That took a transaction-length RowExclusiveLock on one tuple, so every concurrent page writer serialized on the prior writer's COMMIT — sync ran at ~0.8 cores regardless of worker count. Swap to a SEQUENCE bumped by nextval() (a microsecond LWLock, never a row lock). The clock's only contract is monotonic advancement on any page INSERT/UPDATE/DELETE; last_value is non-transactional, so rolled-back or concurrent-uncommitted writers only OVER-invalidate the cache (lose a hit), never serve stale. - migration v118: CREATE SEQUENCE + load-bearing 2-arg setval (is_called= true, floor 1, seeded >= old clock and MAX(generation)) + repoint the trigger function body + DELETE query_cache so no old-clock bookmark survives the swap. v107 left immutable. - query-cache-gate.ts: 3 readers -> SELECT last_value FROM page_generation_clock_seq. - schema.sql + pglite-schema.ts (+ regenerated schema-embedded.ts) ship the sequence on fresh install; table + trigger names retained. - tests: clockValue reads last_value; mechanism proof (trigger fn uses nextval not the row UPDATE); rollback-advances-clock safety pin; real PGLite sequence round-trip (is_called gotcha); shape test requires _seq. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): op_checkpoints array-shape guard — CHECK + repair + defensive loader completed_keys is JSONB and the checkpoint loader runs jsonb_array_elements_text over it. A non-array (scalar) value makes that throw "cannot extract elements from a scalar", which takes down the whole UNION load — including the valid op_checkpoint_paths child rows — and loses all checkpoint progress for that key. No current writer produces a scalar, but an older binary / external script / future bug could. Make the corruption class structurally impossible and self-healing: - migration v119: LOCK TABLE (so an out-of-band scalar can't land between repair and constrain; no-op on single-connection PGLite), repair any pre-existing scalar to '[]' (op_checkpoint_paths child rows are the append-only source of truth, so the reset loses nothing), then add the named CHECK (jsonb_typeof(completed_keys) = 'array') via a pg_constraint IF NOT EXISTS guard. A DB-enforced always-on guard — the correct pattern vs a migration verify-hook, which never runs on already-stamped brains. - schema.sql + pglite-schema.ts (+ regenerated schema-embedded.ts) ship the same NAMED inline CHECK so fresh installs match migrated brains and v119 skips the duplicate. - op-checkpoint.ts loader: gate the legacy arm on jsonb_typeof = 'array' so a scalar parent is skipped (children still load) instead of throwing the whole union, and log a specific corruption warning when one is seen. - tests: CHECK rejects a scalar (exactly one constraint, no blob+migration dupe); loader survives a scalar parent and returns the children; v119 repair converts a scalar to '[]'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(doctor): report actively-running sync via live lock, not stale freshness A slow source that makes partial progress every cycle but never fully completes used to read as permanently "stale" / "never synced" because last_sync_at only advances on a full successful sync. The naive fix (treat recent checkpoint banking as "in progress") is unsafe: a blocked sync banks the good files then writes no anchor, so banking can't tell in-progress from wedged. Use the only honest signal: a LIVE, non-expired per-source sync lock (inspectLock + syncLockId against gbrain_cycle_locks). Every non-skipLock sync holds it and refreshes it; a blocked/failed sync's process has exited (no lock row) and a wedged holder stops refreshing (TTL lapses), so either correctly falls through to the stale path and is NEVER masked. An actively-syncing source (including a never-synced source doing its first sync) counts as synced_recently, preserving the pinned 3-bucket invariant. The lock lookup reuses doctor's existing dynamic db-lock import and swallows any throw (stub engine, pre-lock-table brain) to false, so it can only ADD an in-progress verdict, never suppress a real stale one. Tests (real PGLiteEngine + real lock rows): stale+no-lock -> fail; stale+live-lock -> ok; never-synced+live-lock -> ok; never-synced+no-lock -> fail; expired-TTL lock -> fail (wedged not masked); blocked source with banked checkpoint rows but no lock -> still fail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): honest --force-break-lock diagnostic when no lock is held --force-break-lock used to emit the same terse "Lock ... is not held (nothing to break)" line and exit 0 even when a sync was genuinely wedged, sending the operator down a dead end — the wedge was not a held lock. Keep rc=0 (breaking a non-existent lock is idempotently successful; flipping the exit code would break automation), but under --force say plainly that nothing was broken and point at the real next step (gbrain sync / gbrain doctor) plus a `wedge_hint` field in --json output. The non-force path is byte-for-byte unchanged. runBreakLock is exported for the test. Tests: force+no-lock -> wedge_hint JSON + human hint, rc 0; non-force+no-lock -> unchanged terse line, no hint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(doctor): surface the in-progress sync holder in the freshness message Plan-completion follow-up to the BUG 4 live-lock signal: when a source is actively syncing, name the holder (pid + host) in the check message instead of silently folding it into synced_recently. The note is appended only when something is in progress, so steady-state messages stay byte-for-byte unchanged (the pinned exact-message + 3-bucket-invariant tests still pass). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): pre-landing review fixes — monotonic clock seed, scoped CHECK guard Adversarial (codex) review of the implementation diff caught three: - P1 (correctness): the fresh-schema setval was not monotonic. initSchema replays the schema blob, and the unconditional setval(MAX(generation)) could move page_generation_clock_seq.last_value BACKWARD on an already-upgraded brain, letting a stored query_cache bookmark serve stale rows. Seed via GREATEST over the sequence's OWN last_value (+ old table value + MAX(generation)) in all 3 fresh schemas and migration v118, so a replay is idempotent — mirrors the old table's ON CONFLICT DO NOTHING. Pinned by a new monotonic regression test. - P2: v119's CHECK-exists guard keyed on conname only (not globally unique). Scope it to conrelid = 'op_checkpoints'::regclass. - P3: in-progress note ran into the prior sentence in fail/warn doctor messages; separate it with '. '. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: make Anthropic/ZE no-key tests hermetic against a dev config key These "no key" tests cleared only ANTHROPIC_API_KEY / ZEROENTROPY_API_KEY from the env, but hasAnthropicKey() and checkZeEmbeddingHealth() also read the key from ~/.gbrain/config.json. On a dev machine whose real config holds a key, the no-key assertions flipped and the tests failed locally (they passed only in key-less CI). Add a shared with-env emptyHome() helper and point GBRAIN_HOME at an empty dir in every no-key path so loadConfig finds nothing — matching the already-hermetic anthropic-key / gateway-probe tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.44.1.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(key-files): sync doctor + op-checkpoint entries to v0.44.1.0 truth checkSyncFreshness now reports an actively-running sync via the live per-source lock (names holder pid+host, counts as synced_recently) instead of flagging it stale; loadOpCheckpoint gates the legacy union arm on jsonb_typeof = 'array' so a scalar parent can't take down the whole load, and migration v119's CHECK constraint makes the corruption class structurally impossible. Reference docs describe current behavior only — both entries updated in place, no release-clause appends. Guard + llms freshness test green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: re-version to v0.42.51.0 (natural next-off-master) Maintainer override of the queue allocator's leap to 0.44.1.0 (it jumped past in-flight sibling PR claims at 0.42.50/0.43.0/0.44.0). Take the natural next slot in the 0.42.x line above the immediate sibling claim (0.42.50.0); a merge re-bump resolves any collision if a cathedral PR lands first. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(e2e): bound + retry the OpenClaw install so a transient npm hang can't burn the Tier 2 budget The Tier 2 (LLM Skills) job failed at 30m16s — the `npm install -g openclaw@2026.4.9` step hung on a transient npm/registry stall (orphan `npm install openclaw` was still running at cancel time) and consumed the entire 30m job budget that v0.42.50.0 (#2254) introduced. The install normally finishes in under a minute (Tier 2 is ~4m end to end on master), so this is flaky-install infra, not a test failure. Wrap the install in `timeout 120` + a 3-attempt retry loop with an 8-minute step backstop: a hung attempt is killed in 2 min and retried instead of eating the whole job. Same bound-the-hang philosophy as #2254's job timeouts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
154 lines
5.7 KiB
TypeScript
154 lines
5.7 KiB
TypeScript
/**
|
|
* v0.36.0.0 (A5) — Doctor checks for the ZE cutover.
|
|
*
|
|
* Pins:
|
|
* - ze_embedding_health: warns when embedding_model is ZE but no key
|
|
* is configured; OK when key present; OK when not on ZE (skip).
|
|
* - embedding_width_consistency: warns when configured dim diverges
|
|
* from the actual vector(N) column width.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
|
import { withEnv, emptyHome } from './helpers/with-env.ts';
|
|
import {
|
|
checkZeEmbeddingHealth,
|
|
checkEmbeddingWidthConsistency,
|
|
} from '../src/commands/doctor.ts';
|
|
import { configureGateway } from '../src/core/ai/gateway.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
// Env is owned per-test by withEnv; nothing to clean up here.
|
|
});
|
|
|
|
describe('checkZeEmbeddingHealth', () => {
|
|
// v0.37 fix wave (Lane E.3 + CDX2-10): checkZeEmbeddingHealth now reads
|
|
// from the gateway (file plane source of truth) instead of the DB config
|
|
// table. Tests configure the gateway directly via configureGateway()
|
|
// rather than writing via engine.setConfig().
|
|
|
|
test('not on ZE: returns ok with skip message', async () => {
|
|
configureGateway({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 1536,
|
|
env: { ...process.env },
|
|
});
|
|
const check = await checkZeEmbeddingHealth(engine);
|
|
expect(check.status).toBe('ok');
|
|
expect(check.message).toContain('not ZeroEntropy');
|
|
});
|
|
|
|
test('on ZE + no key: warns with setup hint', async () => {
|
|
configureGateway({
|
|
embedding_model: 'zeroentropyai:zembed-1',
|
|
embedding_dimensions: 1280,
|
|
env: { ...process.env, ZEROENTROPY_API_KEY: undefined as any },
|
|
});
|
|
// Clear the env var AND isolate GBRAIN_HOME for the no-key path: the check
|
|
// reads ZEROENTROPY_API_KEY from env OR the gbrain config file, so a dev
|
|
// machine whose real ~/.gbrain/config.json holds the key needs both cleared.
|
|
await withEnv({ ZEROENTROPY_API_KEY: undefined, GBRAIN_HOME: emptyHome() }, async () => {
|
|
const check = await checkZeEmbeddingHealth(engine);
|
|
expect(check.status).toBe('warn');
|
|
expect(check.message).toContain('ZEROENTROPY_API_KEY');
|
|
expect(check.message).toContain('zeroentropy.dev');
|
|
});
|
|
});
|
|
|
|
test('on ZE + env key: ok', async () => {
|
|
configureGateway({
|
|
embedding_model: 'zeroentropyai:zembed-1',
|
|
embedding_dimensions: 1280,
|
|
env: { ...process.env },
|
|
});
|
|
await withEnv({ ZEROENTROPY_API_KEY: 'sk-fake-test' }, async () => {
|
|
const check = await checkZeEmbeddingHealth(engine);
|
|
expect(check.status).toBe('ok');
|
|
});
|
|
});
|
|
|
|
// v0.37 fix wave note: ZE key now lives in file plane only (not DB plane).
|
|
// The "config key" path here exercises the file-plane fallback that
|
|
// checkZeEmbeddingHealth checks via loadConfigFileOnly().
|
|
test('on ZE + env key (file-plane equivalent): ok', async () => {
|
|
configureGateway({
|
|
embedding_model: 'zeroentropyai:zembed-1',
|
|
embedding_dimensions: 1280,
|
|
env: { ...process.env },
|
|
});
|
|
await withEnv({ ZEROENTROPY_API_KEY: 'sk-fake-from-env' }, async () => {
|
|
const check = await checkZeEmbeddingHealth(engine);
|
|
expect(check.status).toBe('ok');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('checkEmbeddingWidthConsistency', () => {
|
|
// v0.37 fix wave (Lane E.1 + CDX-8): check reads from gateway, NOT DB
|
|
// config. Tests configure the gateway directly so we can simulate the
|
|
// mismatch scenario.
|
|
|
|
test('config matches schema width: ok', async () => {
|
|
// Read the actual schema column dim, then configure the gateway to
|
|
// match. The check should report ok.
|
|
const rows = await engine.executeRaw<{ format_type: string }>(
|
|
`SELECT format_type(atttypid, atttypmod) AS format_type
|
|
FROM pg_attribute
|
|
WHERE attrelid = 'content_chunks'::regclass
|
|
AND attname = 'embedding'
|
|
AND NOT attisdropped`,
|
|
);
|
|
const m = rows[0].format_type.match(/vector\((\d+)\)/i);
|
|
expect(m).not.toBeNull();
|
|
const schemaDim = parseInt(m![1], 10);
|
|
|
|
configureGateway({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: schemaDim,
|
|
env: { ...process.env },
|
|
});
|
|
const check = await checkEmbeddingWidthConsistency(engine);
|
|
expect(check.status).toBe('ok');
|
|
expect(check.message).toContain(`${schemaDim}d`);
|
|
});
|
|
|
|
test('config mismatches schema width: warns with fix hint', async () => {
|
|
// Configure gateway to a dim that doesn't match the schema. With the
|
|
// preload setting OpenAI/1536 and re-applying per-test, the schema
|
|
// is 1536 — so 768 is guaranteed-different here.
|
|
configureGateway({
|
|
embedding_model: 'openai:text-embedding-3-small',
|
|
embedding_dimensions: 768,
|
|
env: { ...process.env },
|
|
});
|
|
const check = await checkEmbeddingWidthConsistency(engine);
|
|
expect(check.status).toBe('warn');
|
|
expect(check.message).toContain('mismatch');
|
|
// v0.37 hint points at gbrain init (the path that works), not config set.
|
|
expect(check.message).toContain('gbrain init');
|
|
});
|
|
|
|
test('gateway unconfigured: skips with ok', async () => {
|
|
// Reset gateway so requireConfig() throws.
|
|
const { resetGateway } = await import('../src/core/ai/gateway.ts');
|
|
resetGateway();
|
|
const check = await checkEmbeddingWidthConsistency(engine);
|
|
expect(check.status).toBe('ok');
|
|
expect(check.message).toContain('gateway not configured');
|
|
});
|
|
});
|