Files
gbrain/test/eval-capture-drain.test.ts
T
ec5fed2921 v0.42.20.0 fix: reliability wave — PGLite capture lock-pin + Postgres reconnect race + search embed-hang (#1762 #1745 #1775) (#1810)
* fix(core): drain fire-and-forget sinks before disconnect via a background-work registry (#1762)

New src/core/background-work.ts registry (Map<name,drainer>, ordered drain,
awaited abort). facts-queue (order 0, abort=shutdown), last-retrieved (1), and
eval-capture (3, now self-tracked) register as sinks. Both CLI exit paths
(op-dispatch finally + handleCliOnly finally) drain the registry before
engine.disconnect() so a PGLite db.close() can't race in-flight work into the
re-pump busy-loop that pinned the single-writer lock. Op-dispatch error path
converts process.exit(1) to exitCode+return so the finally still drains.

* fix(ai): bound every outbound AI call so a stalled provider can't hang (#1762/#1775)

withDefaultTimeout composes a per-touchpoint default deadline (chat 300s,
embed/multimodal 60s) with any caller signal via AbortSignal.any. Applied at the
SDK call layer (chat generateText, expand generateObject, OCR, per-sub-batch
embed) — covers native-anthropic + retries — plus per-request multimodal fetch.
embedQuery forwards abortSignal. Env: GBRAIN_AI_{CHAT,EMBED,MULTIMODAL}_TIMEOUT_MS.

* fix(postgres): module-mode reconnect preserves the shared singleton (#1745)

reconnect() branches on connection style. Module-singleton engines re-establish
idempotently via db.connect() (no-op when alive) + refresh the ConnectionManager
read pool, never db.disconnect() — so a transient blip no longer nulls the shared
sql out from under concurrent ops (which threw 'connect() has not been called').
Fail-loud on real connect failure. Instance pools keep teardown+recreate.

* fix(search): bound the query-time embed so a stall falls back to keyword (#1775)

search/query default to cheap-hybrid (embeds the query); a stalled provider made
the embed never settle, so the keyword fallback never engaged and the command
force-exited with no output. One shared QueryEmbedDeadline (6s, floored 2s per
embed) covers both the cache-lookup and inner embeds via embedQueryBounded
(abortSignal + Promise.race) → existing keyword fallback engages. Also registers
the search-cache background-work drainer (now bounded). Env: GBRAIN_QUERY_EMBED_TIMEOUT_MS.

* test+chore: reliability wave tests + v0.42.11.0 (#1762 #1745 #1775)

New: background-work registry unit, query-embed deadline unit, eval-capture
drain unit, postgres reconnect E2E (#1745), gbrain capture exit-clean case in
the PGLite serial test. Updated fix-wave-structural assertions to the registry
shape. VERSION/package.json/CHANGELOG -> 0.42.11.0; TODOS retrofit marked done.

Incorporates + hardens PR #1763 (drain-before-disconnect + embed fetch timeout);
the residual hung-Haiku hole is closed by the facts shutdown() abort belt.

Co-Authored-By: ElliotDrel <noreply@github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: document background-work registry + v0.42.11.0 reliability wave in CLAUDE.md (regen llms)

* chore: bump release version 0.42.11.0 -> 0.42.20.0

Rename the reliability-wave release version per request. Trio
(VERSION / package.json / CHANGELOG) reconciled; in-code version-tag
comments and test fixtures updated; llms regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: ElliotDrel <noreply@github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:42:25 -07:00

78 lines
2.5 KiB
TypeScript

/**
* v0.42.20.0 (#1762) — eval-capture is the 4th fire-and-forget DB-write sink the
* background-work registry drains before CLI disconnect. `captureEvalCandidate`
* is `void`-ed by the search/query op handlers; its async `logEvalCandidate`
* write is the same lock-pin / disconnect-race class as the other sinks. These
* tests pin the bounded drain (`awaitPendingEvalCaptures`).
*/
import { describe, test, expect, afterEach } from 'bun:test';
import {
captureEvalCandidate,
awaitPendingEvalCaptures,
_resetPendingEvalCapturesForTests,
type CaptureContext,
} from '../src/core/eval-capture.ts';
import type { BrainEngine } from '../src/core/engine.ts';
import type { SearchResult } from '../src/core/types.ts';
function makeCtx(): CaptureContext {
const result: SearchResult = {
slug: 'people/alice-example',
page_id: 1,
title: 'Alice Example',
type: 'person',
chunk_text: '…',
chunk_source: 'compiled_truth',
chunk_id: 42,
chunk_index: 0,
score: 0.9,
stale: false,
source_id: 'default',
};
return {
tool_name: 'query',
query: 'who is alice',
results: [result],
meta: { vector_enabled: true, detail_resolved: 'medium', expansion_applied: false },
latency_ms: 1,
remote: false,
expand_enabled: false,
detail: null,
job_id: null,
subagent_id: null,
};
}
afterEach(() => _resetPendingEvalCapturesForTests());
describe('awaitPendingEvalCaptures', () => {
test('empty set drains instantly', async () => {
const r = await awaitPendingEvalCaptures(50);
expect(r.unfinished).toBe(0);
});
test('drains a settled capture to unfinished:0', async () => {
const engine = {
logEvalCandidate: async () => 1,
logEvalCaptureFailure: async () => {},
} as unknown as BrainEngine;
void captureEvalCandidate(engine, makeCtx(), { scrub_pii: false });
const r = await awaitPendingEvalCaptures(1000);
expect(r.unfinished).toBe(0);
});
test('a hanging capture is bounded by the timeout (not a hang)', async () => {
const engine = {
// Never resolves — simulates a wedged DB write.
logEvalCandidate: () => new Promise<number>(() => {}),
logEvalCaptureFailure: async () => {},
} as unknown as BrainEngine;
void captureEvalCandidate(engine, makeCtx(), { scrub_pii: false });
const start = Date.now();
const r = await awaitPendingEvalCaptures(150);
const elapsed = Date.now() - start;
expect(r.unfinished).toBe(1);
expect(elapsed).toBeLessThan(1000);
});
});