Compare commits

..
Author SHA1 Message Date
583975dee8 fix(sync): guard putPage 0-row RETURNING + reclassify sync failure copy (#2189)
Root cause of #2189's opaque per-file crash: putPage's INSERT … ON CONFLICT
DO UPDATE … RETURNING can yield 0 rows when brain-local DB state (e.g. a
BEFORE trigger) suppresses the write, and rowToPage(rows[0]) then died with
"undefined is not an object (evaluating 'row.deleted_at')". Both engines now
throw a descriptive error naming the slug + source_id so the failure is
diagnosable per-file instead of an anonymous TypeError.

Also lands the salvageable parts of community PR #2586 (takeover): the
"failed to parse / fix the frontmatter" copy misclassified runtime import
errors as YAML problems — reworded to "failed to import" — plus its
first-code-sync PGLite smoke test.

New regression test reproduces the exact 0-row RETURNING state via a
suppressing trigger: fails pre-fix with the reported crash signature,
passes with the guard.

Co-authored-by: javieraldape <javieraldape@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:41:32 -07:00
10 changed files with 125 additions and 122 deletions
-18
View File
@@ -1551,24 +1551,6 @@ export async function checkRerankerHealth(engine: BrainEngine): Promise<Check> {
};
}
// Historical #2059 rows were logged as `unknown` before missing reranker
// auth was classified at the gateway. Surface repeated unknowns instead of
// reporting "ok" while every rerank fails open.
const unknownFails = failures.filter((f) => f.reason === 'unknown');
if (unknownFails.length >= 3) {
const setupHint = unknownFails.some((f) => {
const summary = String(f.error_summary ?? '');
return summary.includes('ZEROENTROPY_API_KEY') || summary.toLowerCase().includes('api key');
})
? ' Fix: verify ZEROENTROPY_API_KEY and run `gbrain models doctor`.'
: '';
return {
name: 'reranker_health',
status: 'warn',
message: `${unknownFails.length} unknown reranker failure(s) in last 7 days.${setupHint}`,
};
}
return {
name: 'reranker_health',
status: 'ok',
+5 -5
View File
@@ -197,7 +197,7 @@ export interface SyncResult {
/** Pages re-embedded during this sync's auto-embed step. 0 if --no-embed or skipped. */
embedded: number;
pagesAffected: string[];
failedFiles?: number; // count of parse failures (Bug 9)
failedFiles?: number; // count of per-file import/sync failures (Bug 9)
/**
* v0.41.13.0 partial-sync fields (only set when status === 'partial').
*
@@ -3183,7 +3183,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
await clearOpCheckpoint(engine, ckpt.target);
};
// issue #1939 adversarial finding #1: a file that failed to parse (open ledger
// issue #1939 adversarial finding #1: a file that failed to import (open ledger
// row) and is then deleted/renamed-away never re-enters failedFiles and never
// imports, so its row would never clear and would age doctor to a permanent
// FAIL. Treat removed paths as resolved so the ledger self-heals.
@@ -3215,9 +3215,9 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
} else {
const fileFailCount = failedFiles.filter(f => isSkippablePath(f.path)).length;
serr(
`\nSync blocked: ${fileFailCount} file(s) failed to parse:\n` +
`\nSync blocked: ${fileFailCount} file(s) failed to import:\n` +
`${codeBreakdown}\n\n` +
`Fix the frontmatter and re-run, or use 'gbrain sync --skip-failed' to ` +
`Fix the listed file errors and re-run, or use 'gbrain sync --skip-failed' to ` +
`acknowledge and move on. A file that keeps failing auto-skips after ` +
`${resolveAutoSkipThreshold()} consecutive syncs.`,
);
@@ -5355,7 +5355,7 @@ function printSyncResult(result: SyncResult, sink: NodeJS.WriteStream = process.
case 'dry_run':
break; // already printed in performSync
case 'blocked_by_failures':
write(`Sync BLOCKED at ${result.toCommit.slice(0, 8)}: ${result.failedFiles ?? 0} file(s) failed to parse.`);
write(`Sync BLOCKED at ${result.toCommit.slice(0, 8)}: ${result.failedFiles ?? 0} file(s) failed to import.`);
write(` See ~/.gbrain/sync-failures.jsonl for details, or run 'gbrain doctor'.`);
write(` Fix the files then re-run 'gbrain sync', or 'gbrain sync --skip-failed' to move on.`);
break;
+1 -9
View File
@@ -3656,15 +3656,7 @@ export async function rerank(input: RerankInput): Promise<RerankResult[]> {
// whose request/response shape differs from ZE/llama.cpp (e.g. Voyage with
// `top_k` / `data[]`) needs separate adapter hooks in a follow-up plan.
const url = `${compat.baseURL.replace(/\/$/, '')}${tp.path ?? '/models/rerank'}`;
let auth: { apiKey?: string; headers?: Record<string, string> };
try {
auth = applyResolveAuth(recipe, cfg, 'reranker');
} catch (err) {
if (err instanceof AIConfigError) {
throw new RerankError(err.message, 'auth');
}
throw err;
}
const auth = applyResolveAuth(recipe, cfg, 'reranker');
// applyResolveAuth returns { apiKey } for Bearer-style auth (SDK's native
// path) or { headers } for custom-header providers (Azure). v0.37.6.0:
// recipes can ALSO declare default_headers (attribution etc.) which flow
+10
View File
@@ -1058,6 +1058,16 @@ export class PGLiteEngine implements BrainEngine {
RETURNING id, source_id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at, effective_date, effective_date_source, import_filename, source_kind, source_uri, ingested_via, ingested_at`,
[sourceId, slug, page.type, pageKind, page.title, page.compiled_truth, page.timeline || '', JSON.stringify(frontmatter), hash, effectiveDate, effectiveDateSource, importFilename, chunkerVersion, sourcePath, sourceKind, sourceUri, ingestedVia, ingestedAt]
);
// #2189: an INSERT … ON CONFLICT DO UPDATE … RETURNING that yields 0 rows
// (e.g. a BEFORE trigger suppressing the write) previously crashed in
// rowToPage with an opaque "undefined is not an object (row.deleted_at)".
// Throw a diagnosable error naming the row instead. Mirrors postgres-engine.ts.
if (!rows[0]) {
throw new Error(
`putPage: INSERT … RETURNING produced no row for slug='${slug}' source_id='${sourceId}'. ` +
`A trigger or rule on the pages table may be suppressing the write.`
);
}
return rowToPage(rows[0] as Record<string, unknown>);
}
+10
View File
@@ -1119,6 +1119,16 @@ export class PostgresEngine implements BrainEngine {
ingested_at = COALESCE(EXCLUDED.ingested_at, pages.ingested_at)
RETURNING id, source_id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at, effective_date, effective_date_source, import_filename, source_kind, source_uri, ingested_via, ingested_at
`;
// #2189: an INSERT … ON CONFLICT DO UPDATE … RETURNING that yields 0 rows
// (e.g. a BEFORE trigger suppressing the write) previously crashed in
// rowToPage with an opaque "undefined is not an object (row.deleted_at)".
// Throw a diagnosable error naming the row instead. Mirrors pglite-engine.ts.
if (!rows[0]) {
throw new Error(
`putPage: INSERT … RETURNING produced no row for slug='${slug}' source_id='${sourceId}'. ` +
`A trigger or rule on the pages table may be suppressing the write.`
);
}
return rowToPage(rows[0]);
}
-22
View File
@@ -154,28 +154,6 @@ describe('gateway.rerank() — happy path', () => {
describe('gateway.rerank() — error classification', () => {
beforeEach(() => configureZE());
test('missing required reranker API key → RerankError(auth) before HTTP call', async () => {
configureGateway({
reranker_model: 'zeroentropyai:zerank-2',
env: {},
});
let called = false;
__setRerankTransportForTests(async () => {
called = true;
return mockResp({ results: [{ index: 0, relevance_score: 0.5 }] });
});
try {
await rerank({ query: 'q', documents: ['d'] });
throw new Error('should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(RerankError);
expect((err as RerankError).reason).toBe('auth');
expect((err as Error).message).toContain('ZEROENTROPY_API_KEY');
expect(called).toBe(false);
}
});
test('401 → auth', async () => {
__setRerankTransportForTests(async () => new Response('Unauthorized', { status: 401 }));
try {
-33
View File
@@ -2,11 +2,6 @@ import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:tes
import { mkdirSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { withEnv } from './helpers/with-env.ts';
import { logRerankFailure } from '../src/core/rerank-audit.ts';
describe('doctor command', () => {
test('doctor module exports runDoctor', async () => {
@@ -52,34 +47,6 @@ describe('doctor command', () => {
expect(check.issues![0].action).toContain('trigger');
});
test('reranker_health warns on repeated unknown rerank failures', async () => {
const { checkRerankerHealth } = await import('../src/commands/doctor.ts');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-rerank-doctor-'));
try {
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => {
for (let i = 0; i < 3; i++) {
logRerankFailure({
model: 'zeroentropyai:zerank-2',
reason: 'unknown',
query_hash: `unknown${i}`,
doc_count: 30,
error_summary: 'ZeroEntropy reranker requires ZEROENTROPY_API_KEY.',
});
}
const check = await checkRerankerHealth({
async getConfig(key: string): Promise<string | null> {
return key === 'search.reranker.enabled' ? 'true' : null;
},
} as any);
expect(check.status).toBe('warn');
expect(check.message).toContain('unknown');
expect(check.message).toContain('ZEROENTROPY_API_KEY');
});
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test('runDoctor accepts null engine for filesystem-only mode', async () => {
const { runDoctor } = await import('../src/commands/doctor.ts');
// runDoctor should accept null engine — it runs filesystem checks only.
+74
View File
@@ -0,0 +1,74 @@
// #2189 regression guard: putPage's INSERT … ON CONFLICT DO UPDATE … RETURNING
// can yield 0 rows when brain-local DB state (e.g. a BEFORE INSERT trigger)
// suppresses the write. Pre-fix, rowToPage(rows[0]) crashed with the opaque
// "undefined is not an object (evaluating 'row.deleted_at')" that failed
// ~all files of a code sync. Post-fix, putPage throws a descriptive error
// naming the slug + source_id so the failure is diagnosable per-file.
//
// Same guard lands in postgres-engine.ts (engine-parity invariant); this test
// exercises the PGLite side, where the issue was reported.
import { describe, expect, test, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
// Simulate the reporter's state-dependent failure: a trigger that
// suppresses inserts for one slug, making RETURNING produce no row.
await engine.executeRaw(`
CREATE OR REPLACE FUNCTION suppress_pages_insert() RETURNS trigger AS $$
BEGIN
IF NEW.slug = 'suppressed-page' THEN RETURN NULL; END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
await engine.executeRaw(`
CREATE TRIGGER suppress_pages_insert_trg
BEFORE INSERT ON pages
FOR EACH ROW EXECUTE FUNCTION suppress_pages_insert();
`);
});
afterAll(async () => {
await engine.executeRaw('DROP TRIGGER IF EXISTS suppress_pages_insert_trg ON pages');
await engine.executeRaw('DROP FUNCTION IF EXISTS suppress_pages_insert');
await engine.disconnect();
});
describe('putPage RETURNING guard (#2189)', () => {
test('0-row RETURNING throws a descriptive error, not row.deleted_at TypeError', async () => {
let err: Error | undefined;
try {
await engine.putPage('suppressed-page', {
type: 'code',
title: 'Suppressed',
compiled_truth: 'x',
timeline: '',
});
} catch (e) {
err = e as Error;
}
expect(err).toBeDefined();
expect(err!.message).toContain('putPage');
expect(err!.message).toContain("slug='suppressed-page'");
expect(err!.message).toContain("source_id='default'");
// The pre-fix crash signature must be gone.
expect(err!.message).not.toContain('deleted_at');
});
test('unsuppressed slugs still upsert normally with the trigger installed', async () => {
const page = await engine.putPage('normal-page', {
type: 'concept',
title: 'Normal',
compiled_truth: 'y',
timeline: '',
});
expect(page.slug).toBe('normal-page');
expect(page.source_id).toBe('default');
});
});
-35
View File
@@ -12,14 +12,9 @@
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { applyReranker, type RerankerOpts } from '../../src/core/search/rerank.ts';
import { RerankError, type RerankResult } from '../../src/core/ai/gateway.ts';
import { readRecentRerankFailures } from '../../src/core/rerank-audit.ts';
import type { SearchResult } from '../../src/core/types.ts';
import { withEnv } from '../helpers/with-env.ts';
function makeResult(slug: string, score: number, chunk: string): SearchResult {
return {
@@ -165,36 +160,6 @@ describe('applyReranker — fail-open on every RerankError reason', () => {
expect(out).toEqual(results);
});
test('missing gateway reranker API key fail-opens and audits auth', async () => {
const { configureGateway } = await import('../../src/core/ai/gateway.ts');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-rerank-search-'));
try {
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => {
configureGateway({
reranker_model: 'zeroentropyai:zerank-2',
env: {},
});
const results = [makeResult('a', 1.0, 'doc a')];
const out = await applyReranker('q', results, {
enabled: true,
topNIn: 1,
topNOut: null,
model: 'zeroentropyai:zerank-2',
});
expect(out).toEqual(results);
const failures = readRecentRerankFailures(1);
expect(failures).toHaveLength(1);
expect(failures[0]!.reason).toBe('auth');
expect(failures[0]!.error_summary).toContain('ZEROENTROPY_API_KEY');
});
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
configureGateway({ env: { ZEROENTROPY_API_KEY: 'test-key' } });
}
});
test('fail-open on non-RerankError throw too', async () => {
const results = [makeResult('a', 1.0, 'a')];
const opts: RerankerOpts = {
+25
View File
@@ -375,6 +375,31 @@ describe('performSync dry-run never writes', () => {
expect(messages.some(m => m.includes('git pull failed'))).toBe(false);
});
test('first PGLite code sync imports code files without runtime failures', async () => {
const { performSync } = await import('../src/commands/sync.ts');
mkdirSync(join(repoPath, 'src'), { recursive: true });
writeFileSync(
join(repoPath, 'src/example.ts'),
'export function add(left: number, right: number) { return left + right; }\n',
);
execSync('git add -A && git commit -m "add code file"', { cwd: repoPath, stdio: 'pipe' });
const result = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
noExtract: true,
strategy: 'code',
});
expect(result.status).toBe('first_sync');
expect(result.added).toBe(1);
expect(result.failedFiles ?? 0).toBe(0);
const page = await engine.getPage('src-example-ts');
expect(page?.type).toBe('code');
expect(page?.frontmatter).toMatchObject({ file: 'src/example.ts', language: 'typescript' });
});
test('incremental dry-run does NOT write to DB or advance the bookmark', async () => {
const { performSync } = await import('../src/commands/sync.ts');
// First do a real sync to seed the bookmark.