mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 03:12:32 +00:00
* 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>
268 lines
10 KiB
TypeScript
268 lines
10 KiB
TypeScript
/**
|
|
* Op-layer capture wrapper (v0.21.0).
|
|
*
|
|
* Catches MCP + CLI + subagent tool-bridge traffic from a single site —
|
|
* the `query` and `search` op handlers in src/core/operations.ts decorate
|
|
* themselves with `captureEvalCandidate` before they run. Post-codex O1+O2
|
|
* design: the MCP server used to be the capture site, but subagent tool
|
|
* calls dispatch straight to op.handler via brain-allowlist.ts and would
|
|
* have been invisible. Moving the hook to the op layer covers every caller.
|
|
*
|
|
* Best-effort, not await'd by the caller. Every failure is routed through
|
|
* `engine.logEvalCaptureFailure(reason)` so `gbrain doctor` can see drops
|
|
* cross-process (in-process counters would be invisible from doctor's
|
|
* separate process).
|
|
*
|
|
* Data-flow (happy path):
|
|
*
|
|
* op.handler() ──▶ {results, meta}
|
|
* │
|
|
* (fire-and-forget from caller)
|
|
* ▼
|
|
* outer try / inner .catch
|
|
* │
|
|
* ▼
|
|
* scrubPii(query)
|
|
* │
|
|
* ▼
|
|
* buildEvalCandidateInput(...)
|
|
* │
|
|
* ▼
|
|
* engine.logEvalCandidate(input)
|
|
*
|
|
* Every error path — scrub throw, build throw, INSERT reject — lands at
|
|
* `logEvalCaptureFailure(reason)` with the right reason tag.
|
|
*/
|
|
|
|
import type { BrainEngine } from './engine.ts';
|
|
import type {
|
|
EvalCandidateInput,
|
|
EvalCaptureFailureReason,
|
|
HybridSearchMeta,
|
|
SearchResult,
|
|
} from './types.ts';
|
|
import type { GBrainConfig } from './config.ts';
|
|
import { scrubPii } from './eval-capture-scrub.ts';
|
|
import { registerBackgroundWorkDrainer } from './background-work.ts';
|
|
|
|
// HybridSearchMeta is canonical in src/core/types.ts and exported via the
|
|
// public `gbrain/types` subpath. Surfaced from hybridSearch via the
|
|
// optional onMeta callback in HybridSearchOpts (Lane 1C).
|
|
export type { HybridSearchMeta };
|
|
|
|
/** Context needed to build a capture row. Bundled so op handlers don't thread 8 args. */
|
|
export interface CaptureContext {
|
|
/** 'query' or 'search'; captured only for these two ops. */
|
|
tool_name: 'query' | 'search';
|
|
/** Pre-scrub query text. scrubPii runs INSIDE buildEvalCandidateInput when scrub_pii !== false. */
|
|
query: string;
|
|
/** Result set from the op handler. */
|
|
results: SearchResult[];
|
|
/** Side-channel metadata from hybridSearch. For 'search' ops, pass vector_enabled:false, others null/false. */
|
|
meta: HybridSearchMeta;
|
|
/** How long the underlying op took, in milliseconds. */
|
|
latency_ms: number;
|
|
/** OperationContext.remote — true for MCP callers, false for local CLI. */
|
|
remote: boolean;
|
|
/** The `expand` flag as requested by the caller (query-only; null for search). */
|
|
expand_enabled: boolean | null;
|
|
/** The `detail` flag as requested (query-only). */
|
|
detail: 'low' | 'medium' | 'high' | null;
|
|
/** OperationContext.jobId if present. */
|
|
job_id: number | null;
|
|
/** OperationContext.subagentId if present. */
|
|
subagent_id: number | null;
|
|
}
|
|
|
|
/**
|
|
* Build the insert row for `eval_candidates` from a capture context.
|
|
*
|
|
* Runs the PII scrubber on `query` unless `scrub_pii === false`. Pure
|
|
* function: throws only if the scrubber throws (caller wraps in try/catch).
|
|
*
|
|
* Exported for testing and for CLI backfill paths; the hot-path caller is
|
|
* `captureEvalCandidate` below.
|
|
*/
|
|
export function buildEvalCandidateInput(
|
|
ctx: CaptureContext,
|
|
opts: { scrub_pii?: boolean } = {},
|
|
): EvalCandidateInput {
|
|
const shouldScrub = opts.scrub_pii !== false;
|
|
const query = shouldScrub ? scrubPii(ctx.query) : ctx.query;
|
|
|
|
// Deduplicate + preserve order for slug + source_id extraction.
|
|
// Both arrays are small (hybridSearch clamps at 100 results) so the
|
|
// O(n) Set-based dedup is fine.
|
|
const slugsSet = new Set<string>();
|
|
const sourceSet = new Set<string>();
|
|
const chunkIds: number[] = [];
|
|
for (const r of ctx.results) {
|
|
slugsSet.add(r.slug);
|
|
if (r.source_id) sourceSet.add(r.source_id);
|
|
chunkIds.push(r.chunk_id);
|
|
}
|
|
|
|
return {
|
|
tool_name: ctx.tool_name,
|
|
query,
|
|
retrieved_slugs: [...slugsSet],
|
|
retrieved_chunk_ids: chunkIds,
|
|
source_ids: [...sourceSet],
|
|
expand_enabled: ctx.expand_enabled,
|
|
detail: ctx.detail,
|
|
detail_resolved: ctx.meta.detail_resolved,
|
|
vector_enabled: ctx.meta.vector_enabled,
|
|
expansion_applied: ctx.meta.expansion_applied,
|
|
latency_ms: ctx.latency_ms,
|
|
remote: ctx.remote,
|
|
job_id: ctx.job_id,
|
|
subagent_id: ctx.subagent_id,
|
|
// v0.36 (D16 / CDX-10): persist the column that ran. Null for
|
|
// keyword-only `search` (meta.embedding_column omitted), preserving
|
|
// back-compat with rows captured before the column tracking landed.
|
|
embedding_column: ctx.meta.embedding_column ?? null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Map a caught error to an `EvalCaptureFailureReason` so doctor can
|
|
* group failures by cause (DB down vs RLS reject vs CHECK violation).
|
|
*
|
|
* Narrow by Postgres SQLSTATE where we can (postgres-js + PGLite both
|
|
* surface `.code`), fall back to 'other'.
|
|
*/
|
|
export function classifyCaptureFailure(err: unknown): EvalCaptureFailureReason {
|
|
if (err && typeof err === 'object') {
|
|
const code = (err as { code?: string }).code;
|
|
if (code === '23514') return 'check_violation'; // CHECK constraint violation
|
|
if (code === '42501') return 'rls_reject'; // insufficient_privilege
|
|
if (code === '42P01') return 'db_down'; // undefined_table (pre-v25)
|
|
if (code === '53300' || code === '08006' || code === '08003') return 'db_down';
|
|
const name = (err as { name?: string }).name;
|
|
if (name === 'RegExpMatchError' || name === 'SyntaxError') {
|
|
return 'scrubber_exception';
|
|
}
|
|
}
|
|
return 'other';
|
|
}
|
|
|
|
/**
|
|
* Fire-and-forget capture side-effect. Never throws: every failure is
|
|
* swallowed, classified, and routed through `engine.logEvalCaptureFailure`
|
|
* (itself wrapped — failure-of-failure is logged to stderr and dropped).
|
|
*
|
|
* Callers invoke as `void captureEvalCandidate(...)` — we explicitly do
|
|
* NOT await so the op response latency is unaffected.
|
|
*/
|
|
export async function captureEvalCandidate(
|
|
engine: BrainEngine,
|
|
ctx: CaptureContext,
|
|
opts: { scrub_pii?: boolean } = {},
|
|
): Promise<void> {
|
|
// v0.42.20.0 — track the fire-and-forget promise so the background-work
|
|
// registry can drain it before CLI disconnect. Callers still `void` this; the
|
|
// returned promise is back-compat for any awaiter. The async DB write
|
|
// (logEvalCandidate) is the same lock-pin / disconnect-race class as the other
|
|
// sinks — on PGLite an undrained capture racing db.close() can wedge.
|
|
const p = doCaptureEvalCandidate(engine, ctx, opts);
|
|
trackEvalCapture(p);
|
|
return p;
|
|
}
|
|
|
|
async function doCaptureEvalCandidate(
|
|
engine: BrainEngine,
|
|
ctx: CaptureContext,
|
|
opts: { scrub_pii?: boolean } = {},
|
|
): Promise<void> {
|
|
try {
|
|
const input = buildEvalCandidateInput(ctx, opts);
|
|
await engine.logEvalCandidate(input);
|
|
} catch (err) {
|
|
const reason = classifyCaptureFailure(err);
|
|
try {
|
|
await engine.logEvalCaptureFailure(reason);
|
|
} catch (failureErr) {
|
|
// Failure-of-failure: last-resort stderr. Doctor can't see this
|
|
// row, but we've exhausted the persistent path.
|
|
// eslint-disable-next-line no-console
|
|
console.warn('[eval-capture] secondary failure logging also failed:', failureErr);
|
|
}
|
|
}
|
|
}
|
|
|
|
// v0.42.20.0 — bounded drain + registration (mirrors last-retrieved). The 4th
|
|
// fire-and-forget DB-write sink (codex caught this one missing from the
|
|
// registry). order 3, no abort (bare INSERT, nothing to hard-stop).
|
|
const pendingEvalCaptures = new Set<Promise<unknown>>();
|
|
|
|
function trackEvalCapture(promise: Promise<unknown>): void {
|
|
pendingEvalCaptures.add(promise);
|
|
promise.finally(() => pendingEvalCaptures.delete(promise)).catch(() => { /* swallow */ });
|
|
}
|
|
|
|
export async function awaitPendingEvalCaptures(timeoutMs = 5_000): Promise<{ unfinished: number }> {
|
|
if (pendingEvalCaptures.size === 0) return { unfinished: 0 };
|
|
const snapshot = [...pendingEvalCaptures];
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const timeout = new Promise<'timeout'>((resolve) => {
|
|
timer = setTimeout(() => resolve('timeout'), timeoutMs);
|
|
});
|
|
const drain = Promise.allSettled(snapshot).then(() => 'drained' as const);
|
|
const outcome = await Promise.race([drain, timeout]);
|
|
if (timer) clearTimeout(timer);
|
|
if (outcome === 'timeout') {
|
|
const unfinished = pendingEvalCaptures.size;
|
|
for (const pr of snapshot) pendingEvalCaptures.delete(pr);
|
|
return { unfinished };
|
|
}
|
|
return { unfinished: 0 };
|
|
}
|
|
|
|
/** Test seam — clears the pending eval-capture set. */
|
|
export function _resetPendingEvalCapturesForTests(): void {
|
|
pendingEvalCaptures.clear();
|
|
}
|
|
|
|
registerBackgroundWorkDrainer({
|
|
name: 'eval-capture',
|
|
order: 3,
|
|
drain: (ms) => awaitPendingEvalCaptures(ms),
|
|
});
|
|
|
|
/**
|
|
* Check whether capture is enabled for this process.
|
|
*
|
|
* Resolution order:
|
|
* 1. `config.eval.capture === true` → on (explicit user opt-in wins)
|
|
* 2. `config.eval.capture === false` → off (explicit user opt-out wins)
|
|
* 3. `process.env.GBRAIN_CONTRIBUTOR_MODE === '1'` → on (contributor opt-in)
|
|
* 4. otherwise → off (default-off, privacy-positive for end users)
|
|
*
|
|
* The default flipped in v0.25.0 from "on for everyone" to "off unless you
|
|
* opt in." Capturing every query a real user runs without their consent is
|
|
* a footgun even with PII scrubbing; tying capture to CONTRIBUTOR_MODE makes
|
|
* the developer-skill nature of the feature explicit. Production users get
|
|
* a quiet brain; contributors get the BrainBench-Real replay loop with one
|
|
* shell rc line. See docs/eval-bench.md and CONTRIBUTING.md.
|
|
*
|
|
* Takes the already-loaded config so callers control the loadConfig()
|
|
* lifecycle (MCP server loads once at boot, CLI commands load per-invocation).
|
|
*/
|
|
export function isEvalCaptureEnabled(config: GBrainConfig | null | undefined): boolean {
|
|
if (config?.eval?.capture === true) return true;
|
|
if (config?.eval?.capture === false) return false;
|
|
return process.env.GBRAIN_CONTRIBUTOR_MODE === '1';
|
|
}
|
|
|
|
/**
|
|
* PII scrubbing enabled? Defaults to true; explicit `false` opts out.
|
|
*
|
|
* Independent of `isEvalCaptureEnabled` — scrubbing only matters when capture
|
|
* is actually running, but the gate stays separate so CONTRIBUTOR_MODE
|
|
* doesn't accidentally turn off scrubbing on a brain that happens to also
|
|
* have explicit `capture: true`.
|
|
*/
|
|
export function isEvalScrubEnabled(config: GBrainConfig | null | undefined): boolean {
|
|
return config?.eval?.scrub_pii !== false;
|
|
}
|