fix(ingest,sync,serve): three singleton P0s — type round-trip, deleted-slug embed noise, stateless width guard (#3140)

- #1035: importFromContent preserves an existing page's type when incoming
  frontmatter omits an explicit type: field. Explicit type stays an override;
  absence means preserve; new pages still path-infer. The existing-page fetch
  moved above the content-hash compute so a no-op re-put stays a hash-match
  skip. Root-cause fix covers put_page, sync, capture — every caller.
- #1284: sync's end-of-run auto-embed no longer receives slugs deleted in the
  same run (embedPage threw 'Page not found' per deleted slug and serr-logged
  noise on every rename/delete sync). pagesAffected stays the full manifest
  for extract/report paths; a slug deleted then re-imported in the same run
  stays embeddable.
- #1196: gbrain serve --http now runs doctor's embedding_width_consistency
  check at startup and prints a loud stderr banner (with the paste-ready
  recipe + GBRAIN_EMBEDDING_MODEL/DIMENSIONS hint) when the resolved width
  diverges from the brain's vector(N) column — the stateless-container
  fallthrough that broke every write. Fail-open; reads unaffected.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Time Attakc
2026-07-23 12:57:37 -07:00
committed by GitHub
co-authored by Garry Tan Claude Fable 5
parent 3594c316b5
commit 69e7e79a1f
7 changed files with 355 additions and 6 deletions
+36
View File
@@ -449,6 +449,34 @@ export function skillPublishStatus(publishSkills: boolean): { bannerValue: strin
};
}
/**
* #1196: startup embedding-width guard for stateless host deployments.
*
* `embedding_model` / `embedding_dimensions` are file/env-plane only, so a
* container booted WITHOUT a config.json (stateless host) resolves the
* compiled-in default embedding width. Against an existing brain whose
* `content_chunks.embedding` is a different `vector(N)`, every write then
* fails with an opaque dim mismatch. Run doctor's existing
* embedding_width_consistency check at serve startup and return a loud
* banner (with the paste-ready recipe) when it isn't ok. Fail-open: a check
* error never blocks serving read traffic.
*/
export async function embeddingWidthStartupWarning(engine: BrainEngine): Promise<string | null> {
try {
const { checkEmbeddingWidthConsistency } = await import('./doctor.ts');
const check = await checkEmbeddingWidthConsistency(engine);
if (check.status === 'ok') return null;
return (
`[serve-http] WARNING: embedding width check failed — writes that embed will fail until fixed.\n` +
`${check.message}\n` +
`Stateless hosts: embedding_model/embedding_dimensions resolve from env/config.json only — ` +
`set GBRAIN_EMBEDDING_MODEL / GBRAIN_EMBEDDING_DIMENSIONS (or mount config.json) to match the brain's schema.`
);
} catch {
return null;
}
}
export async function runServeHttp(engine: BrainEngine, options: ServeHttpOptions) {
const { port, tokenTtl, enableDcr, enableDcrInsecure, publicUrl, logFullParams } = options;
// v0.34.1 (#864, D11): default bind flipped from 0.0.0.0 to 127.0.0.1.
@@ -473,6 +501,14 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
);
}
// #1196: fail-loud at startup when the resolved embedding width diverges
// from the brain's actual vector(N) column (stateless containers falling
// through to the compiled-in default). Non-fatal: reads still work.
{
const widthWarn = await embeddingWidthStartupWarning(engine);
if (widthWarn) console.error(widthWarn);
}
// Skill-publishing status for the banner + nudge. Mirrors readMcpPublishSkills
// (skill-catalog.ts): the DB plane (`gbrain config set`) wins over the file
// plane. When OFF, a connected coding agent can't see the host's skill
+21 -4
View File
@@ -2475,6 +2475,13 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
};
const pagesAffected: string[] = [];
// #1284: slugs deleted this run (delete loop, or renamed-away old slugs are
// NOT pushed — only confirmed deletes land here). pagesAffected stays the
// full manifest for extract/report paths, but the auto-embed at the end
// must NOT be handed deleted slugs: embedPage throws 'Page not found' for
// each one and serr-logs noise. A slug re-imported later in the same run
// (delete + re-add) is removed from this set at its push site.
const deletedSlugs = new Set<string>();
// issue #1939: file paths that imported cleanly this run. The failure-ledger
// gate clears these so a previously-failing file's `attempts` streak resets
// on success (consecutive-failure semantics for the auto-skip valve).
@@ -2635,6 +2642,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
// slugs (paths in filtered.deleted but with no DB row) so
// downstream extract/embed don't waste lookups.
pagesAffected.push(...deleted);
for (const s of deleted) deletedSlugs.add(s);
// v0.42.x (#1794): the whole batch is handled (deleted or already
// gone); checkpoint every path so a resume skips it.
for (const p of batch) await markCompleted(p);
@@ -2647,6 +2655,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
try {
await engine.deletePage(slugs[j], deleteScopedOpts);
pagesAffected.push(slugs[j]);
deletedSlugs.add(slugs[j]);
await markCompleted(batch[j]);
} catch (perSlugErr) {
failedFiles.push({
@@ -2674,6 +2683,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
try {
await engine.deletePage(slug, deleteOpts);
pagesAffected.push(slug);
deletedSlugs.add(slug);
await markCompleted(path);
} catch (err) {
failedFiles.push({
@@ -2774,6 +2784,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
}
}
pagesAffected.push(newSlug);
deletedSlugs.delete(newSlug); // #1284: rename landed on a previously-deleted slug → embeddable again
await markCompleted(to);
progress.tick(1, newSlug);
}
@@ -2974,6 +2985,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
if (result.status === 'imported') {
chunksCreated += result.chunks;
pagesAffected.push(result.slug);
deletedSlugs.delete(result.slug); // #1284: deleted-then-re-added in the same run → embeddable again
// issue #1939: record the file path (not slug) so the gate clears any
// prior failure-ledger row — success resets the auto-skip attempt streak.
succeededPaths.push(path);
@@ -3396,14 +3408,19 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
// sync. Non-mismatch errors stay best-effort (rate limits, transient
// network) — those shouldn't break sync.
let embedded = 0;
if (!noEmbed && pagesAffected.length > 0 && pagesAffected.length <= 100) {
// #1284: never hand deleted slugs to the embedder — embedPage throws
// 'Page not found' per deleted slug and logs one error line each. Filter
// against this run's confirmed-deleted set (slugs re-imported later in the
// run were removed from it at their push sites).
const embedSlugs = pagesAffected.filter((s) => !deletedSlugs.has(s));
if (!noEmbed && embedSlugs.length > 0 && pagesAffected.length <= 100) {
try {
const { runEmbedCore } = await import('./embed.ts');
const embedOpts = opts.sourceId
? { slugs: pagesAffected, sourceId: opts.sourceId }
: { slugs: pagesAffected };
? { slugs: embedSlugs, sourceId: opts.sourceId }
: { slugs: embedSlugs };
await runEmbedCore(engine, embedOpts);
embedded = pagesAffected.length;
embedded = embedSlugs.length;
} catch (e: unknown) {
const { EmbeddingDimMismatchError } = await import('./embed.ts');
if (e instanceof EmbeddingDimMismatchError) {
+14 -1
View File
@@ -537,6 +537,20 @@ export async function importFromContent(
// is real, unbounded embedding spend). Same bug class as the captured_at /
// ingested_at fix above; the gate re-derives the markers deterministically
// on the next import, so dropping them from the hash is safe.
// #1035: fetch the existing page BEFORE the hash compute so (a) the type
// preservation below participates in the hash (a no-op re-put stays a
// hash-match skip) and (b) the hash short-circuit below reuses this row.
const existing = await engine.getPage(slug, sourceId ? { sourceId } : undefined);
// #1035: absence of an explicit frontmatter `type:` on an EXISTING page
// means "preserve the stored type", not "re-infer". Pre-fix, a round-trip
// put (get_page → edit body → put_page without `type:`) silently regressed
// a curated type to the path-inferred default ('concept' for bare slugs).
// Explicit frontmatter type stays an override; new pages still infer.
if (parsed.typeExplicit !== true && existing) {
parsed.type = existing.type;
}
const HASH_EPHEMERAL_FRONTMATTER_KEYS = [
'captured_at',
'ingested_at',
@@ -569,7 +583,6 @@ export async function importFromContent(
tags: parsed.tags,
};
const existing = await engine.getPage(slug, sourceId ? { sourceId } : undefined);
if (existing?.content_hash === hash && !opts.forceRechunk) {
return { slug, status: 'skipped', chunks: 0, parsedPage };
}
+10 -1
View File
@@ -44,6 +44,13 @@ export interface ParsedMarkdown {
timeline: string;
slug: string;
type: PageType;
/**
* #1035: true when `type` came from an explicit frontmatter `type:` field,
* false when it was inferred from the file path (or defaulted to 'concept').
* Importers use this to preserve an existing page's type on round-trip:
* explicit frontmatter type is an override; absence means "don't change it".
*/
typeExplicit?: boolean;
title: string;
tags: string[];
/** Present iff opts.validate. Empty array means no errors. */
@@ -132,7 +139,8 @@ export function parseMarkdown(
// coerceFrontmatterString turns a scalar/date into a usable string (a date slug
// `2024-06-01` is legitimate); the NON_STRING_FIELD lint finding below still
// surfaces the un-quoted field so it can be cleaned up.
const type = coerceFrontmatterString(frontmatter.type) || (
const explicitType = coerceFrontmatterString(frontmatter.type);
const type = explicitType || (
opts?.activePack ? inferTypeFromPack(filePath, opts.activePack) : inferType(filePath)
);
// #2446: title precedence is frontmatter `title:` > the body's first H1 >
@@ -160,6 +168,7 @@ export function parseMarkdown(
timeline: timeline.trim(),
slug,
type,
typeExplicit: explicitType !== '',
title,
tags,
};
+86
View File
@@ -760,3 +760,89 @@ body unchanged
expect(shortCircuited).toBe(true);
});
});
// ────────────────────────────────────────────────────────────────
// #1035 — type round-trip preservation
//
// put_page → importFromContent → parseMarkdown infers type from the file
// path when frontmatter omits `type:`, and bare slugs infer 'concept'.
// Pre-fix, a round-trip put (get_page → edit body → put_page WITHOUT a
// type: line) silently regressed a curated type to 'concept'. Absence of
// an explicit frontmatter type on an EXISTING page must preserve the
// stored type; an explicit type stays an override; new pages still infer.
// ────────────────────────────────────────────────────────────────
describe('importFromContent type round-trip (#1035)', () => {
test('re-put without type: preserves the existing page type', async () => {
let putType: string | undefined;
const engine = mockEngine({
getPage: () => Promise.resolve({
slug: 'founder-notes-example',
type: 'person',
content_hash: 'different-hash',
updated_at: new Date(),
created_at: new Date(),
} as any),
putPage: (_slug: string, page: any) => {
putType = page.type;
return Promise.resolve(null);
},
});
const result = await importFromContent(engine, 'founder-notes-example', [
'---',
'title: Notes',
'---',
'',
'Edited body, no type in frontmatter.',
].join('\n'), { noEmbed: true });
expect(result.status).toBe('imported');
expect(putType).toBe('person'); // pre-fix: 'concept' (bare-slug inference default)
});
test('explicit frontmatter type: still overrides the existing type', async () => {
let putType: string | undefined;
const engine = mockEngine({
getPage: () => Promise.resolve({
slug: 'founder-notes-example',
type: 'person',
content_hash: 'different-hash',
updated_at: new Date(),
created_at: new Date(),
} as any),
putPage: (_slug: string, page: any) => {
putType = page.type;
return Promise.resolve(null);
},
});
const result = await importFromContent(engine, 'founder-notes-example', [
'---',
'type: note',
'title: Notes',
'---',
'',
'Explicit type wins.',
].join('\n'), { noEmbed: true });
expect(result.status).toBe('imported');
expect(putType).toBe('note');
});
test('new page without type: still infers from path', async () => {
let putType: string | undefined;
const engine = mockEngine({
getPage: () => Promise.resolve(null),
putPage: (_slug: string, page: any) => {
putType = page.type;
return Promise.resolve(null);
},
});
const result = await importFromContent(engine, 'people/alice-example', [
'---',
'title: Alice',
'---',
'',
'A new person page.',
].join('\n'), { noEmbed: true });
expect(result.status).toBe('imported');
expect(putType).toBe('person'); // /people/ path-prefix inference intact
});
});
+70
View File
@@ -0,0 +1,70 @@
/**
* #1196 serve --http startup embedding-width guard.
*
* A stateless host (container without config.json) resolves the compiled-in
* default embedding width; against an existing brain with a different
* vector(N) column, every write fails. runServeHttp now runs doctor's
* embedding_width_consistency check at startup and prints a loud stderr
* banner. This pins the helper: mismatch banner with the recipe;
* match null (no banner noise on healthy brains).
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
import { embeddingWidthStartupWarning } from '../src/commands/serve-http.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
// Rule: files that configureGateway must resetGateway in afterAll.
// The legacy-embedding preload's beforeEach re-applies defaults for
// subsequent files in the same shard.
resetGateway();
await engine.disconnect();
});
async function schemaDims(): Promise<number> {
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);
return parseInt(m![1], 10);
}
describe('embeddingWidthStartupWarning (#1196)', () => {
test('resolved width matches the schema: no banner', async () => {
const dims = await schemaDims();
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: dims,
env: { ...process.env },
});
expect(await embeddingWidthStartupWarning(engine)).toBeNull();
});
test('resolved width diverges from the schema: loud banner with recipe', async () => {
// Simulate the stateless-container fallthrough: gateway resolves a
// width different from the brain's actual vector(N) column.
configureGateway({
embedding_model: 'openai:text-embedding-3-small',
embedding_dimensions: 768,
env: { ...process.env },
});
const warn = await embeddingWidthStartupWarning(engine);
expect(warn).not.toBeNull();
expect(warn!).toContain('[serve-http] WARNING');
expect(warn!).toContain('mismatch');
expect(warn!).toContain('GBRAIN_EMBEDDING_DIMENSIONS');
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* #1284 sync auto-embed must not be handed slugs deleted in the same run.
*
* The delete loop pushes confirmed-deleted slugs into pagesAffected (the
* full manifest for extract/report paths), but the end-of-run auto-embed
* used to pass that same list to runEmbedCore. embedPage throws
* 'Page not found: <slug>' for each deleted slug and the per-slug loop
* serr-logs 'Error embedding <slug>: Page not found' pure noise on every
* rename/delete sync. The embed call now filters against the run's
* deleted-slug set (a slug re-imported later in the run stays embeddable).
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
import { join } from 'path';
import { execSync } from 'child_process';
import { tmpdir } from 'os';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
let engine: PGLiteEngine;
let repoPath: string;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
resetGateway(); // preload beforeEach restores legacy defaults for later files
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
repoPath = mkdtempSync(join(tmpdir(), 'gbrain-sync-del-embed-'));
execSync('git init', { cwd: repoPath, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', { cwd: repoPath, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: repoPath, stdio: 'pipe' });
mkdirSync(join(repoPath, 'people'), { recursive: true });
writeFileSync(join(repoPath, 'people/alice-example.md'), [
'---',
'type: person',
'title: Alice Example',
'---',
'',
'Alice is a person page that will be deleted.',
].join('\n'));
execSync('git add -A && git commit -m "initial"', { cwd: repoPath, stdio: 'pipe' });
});
afterEach(() => {
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
});
describe('sync auto-embed vs deleted slugs (#1284)', () => {
test('delete-only incremental sync does not log Page not found from embed', async () => {
const { performSync } = await import('../src/commands/sync.ts');
// Seed: first sync with --no-embed imports alice and sets the bookmark.
const first = await performSync(engine, { repoPath, noPull: true, noEmbed: true });
expect(first.status).toBe('first_sync');
expect(await engine.getPage('people/alice-example')).not.toBeNull();
// Delete the file and commit.
execSync('git rm -q people/alice-example.md && git commit -qm "delete alice"', {
cwd: repoPath, stdio: 'pipe', shell: '/bin/bash',
});
// Configure the gateway to MATCH the schema width with creds present,
// so runEmbedCore's preflights (assertEmbeddingEnabled, creds check,
// dim-mismatch check) all pass and the per-slug embed loop actually
// runs. Pre-fix, that loop received the deleted slug and serr-logged
// 'Error embedding people/alice-example: Page not found'.
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 dims = parseInt(rows[0].format_type.match(/vector\((\d+)\)/i)![1], 10);
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: dims,
env: { ...process.env, OPENAI_API_KEY: 'sk-test-not-real' },
});
// Capture both stderr channels serr() can write to.
const captured: string[] = [];
const origErr = console.error;
const origWrite = process.stderr.write.bind(process.stderr);
console.error = (...args: unknown[]) => { captured.push(args.map(String).join(' ')); };
(process.stderr as { write: unknown }).write = ((s: unknown) => {
captured.push(String(s));
return true;
}) as typeof process.stderr.write;
let result: Awaited<ReturnType<typeof performSync>>;
try {
// NOTE: no noEmbed — the auto-embed path must run to pin the bug.
result = await performSync(engine, { repoPath, noPull: true });
} finally {
console.error = origErr;
(process.stderr as { write: unknown }).write = origWrite;
}
expect(result.status).toBe('synced');
expect(result.deleted).toBe(1);
// pagesAffected stays the full manifest for extract/report consumers.
expect(result.pagesAffected).toContain('people/alice-example');
// The regression: deleted slug handed to the embedder.
const all = captured.join('\n');
expect(all).not.toContain('Page not found');
});
});