mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
Co-Authored-By: Garry Tan <garrytan@gmail.com>
This commit is contained in:
@@ -192,7 +192,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this
|
||||
- `src/core/extraction-review.ts` — Extraction quarantine lane markers (issue #160), sibling of `src/core/quarantine.ts` / `embed-skip.ts` (frontmatter-key pattern, no schema migration). Auto-extracted stubs from untrusted input carry the PAIR `provenance: 'auto-extracted'` + `status: 'unverified'` (both required — user pages with their own `status`/`provenance` never match). Exports `quarantineMarkers()`, `isUnverifiedExtraction()` (JS predicate) and `unverifiedExtractionFragment(alias)` — the single SQL source of truth consumed by `buildSourceFactorCase` (namespace source-boost guard), both engines' `getUnverifiedExtractionPageIds`, the `extraction_pending` op, and the `unverified_extractions` doctor check, so filter and marker keys can never drift. Consequences: unverified stubs are excluded from the compiled-truth fusion boost + the `people/`/`companies/` source-boost (rank as ordinary content), stamped `unverified: true` in search results (`stampUnverifiedExtractions`, hybrid.ts), listed by `extraction_pending`, promoted (status → `verified`, provenance kept for audit) or rejected (soft-delete) by the owner-only `extraction_review` op. Pinned by `test/extraction-review.test.ts` (PGLite) + `test/e2e/extraction-review-postgres.test.ts` (live Postgres parity).
|
||||
- `src/commands/enrich.ts` + `src/core/enrich/thin.ts` + `src/core/cycle/enrich-thin.ts` — `gbrain enrich --thin`: batch-develops stub (thin) pages via **brain-internal grounded synthesis**. gbrain's model tooling sees only brain-internal context (search / get_page / facts / backlinks), not the web, so enrich consolidates what the brain ALREADY knows about an entity (scattered across meetings, other pages, deals, facts) into one cited page via ONE `gateway.chat` call per page; web research stays the agent-driven `enrich` SKILL's job. `runEnrichCore(engine, opts, signal)` (strict per-source; multi-source iteration is the caller's job) drives `enrichOne` per candidate: `withRefreshingLock('enrich:<src>:<slug>')` → `getPage` → deterministic retrieve (hybridSearch + getBacklinks + facts + raw_data, source-scoped, sanitized via `INJECTION_PATTERNS`) → `assessGrounding` gate (skip < `MIN_CONTEXT_CHARS`, no LLM) → `buildEnrichPrompt` (grounded dossier, `[Source: slug]` citations, SKIP sentinel) → synth → `put_page` handler (`remote:false`, auto-link + write-through) stamping `enriched_at` + `enriched_by:'cli:enrich'`. Candidate selection is the SQL-native `engine.listEnrichCandidates(opts)` (`src/core/engine.ts` interface + `EnrichCandidate`/`EnrichCandidatesOpts`/`ENRICH_ORDER_SQL` in `src/core/types.ts` + pg/pglite impls): thin-filter + per-page source-correct inbound count (`to_page_id = p.id`, `mentions` excluded) + `enriched_at` recency guard + whitelisted ORDER BY + LIMIT, lightweight projection (NO bodies). Resume via `src/core/op-checkpoint.ts` (local `enrichFingerprint`); budget via `BudgetTracker` + `withBudgetTracker` (best-effort under `--workers > 1` — `runSlidingPool` aborts new claims on `BUDGET_EXHAUSTED` but does NOT cancel in-flight `gateway.chat`; pin `--workers 1` for a hard ceiling). `sanitizeContext` (thin.ts) neutralizes the `<context>…</context>` data-envelope delimiters (injection escape, mirrors the `</trajectory>` convention); the `--background` multi-source fan-out idempotency key carries the run fingerprint via exported `backgroundIdempotencyKey(sid, args)` (a bare `enrich:${sid}` would return stale completed jobs); `runEnrichCore` flags `budget_exhausted` post-hoc when `tracker.totalSpent > tracker.cap` even when the gateway swallowed the final-call throw (via read-only `BudgetTracker.cap` getter); `body()` flushes the checkpoint on `BudgetExhausted` before it propagates so resume doesn't re-charge. The opt-in `enrich_thin` cycle phase (default OFF via `cycle.enrich_thin.enabled`) trickles `max_pages_per_tick` (default 3) per source with per-source cost cap enforced as `min(per_source_cap, brain_wide_remaining)` + brain-wide total + walltime caps. Wired into `cycle.ts` (`CyclePhase`/`ALL_PHASES` between `conversation_facts_backfill` and `skillopt`/`embed`; `PHASE_SCOPE='source'`; `NEEDS_LOCK`; dispatch), `cli.ts` (`CLI_ONLY` + `CLI_ONLY_SELF_HELP` + `THIN_CLIENT_REFUSED_COMMANDS` + dispatch), `jobs.ts` (Minion `enrich` handler, strict per-source, NOT in `PROTECTED_JOB_NAMES`). DI seam `opts.synthesizeFn` keeps tests hermetic (no API key, no mock.module). Pinned by `test/enrich/thin.test.ts`, `test/enrich/idempotency.test.ts`, `test/enrich-cycle-phase.test.ts`, `test/e2e/enrich-pglite.test.ts` (grew-cited, skip, ordering, multi-source, recency, resume, budget abort + checkpoint flush, final-call overage, lock-skip, provenance), `test/e2e/engine-parity.test.ts` (`listEnrichCandidates` pg↔pglite parity).
|
||||
- `src/core/data-research.ts` — Recipe validation, field extraction (MRR/ARR regex), dedup, tracker parsing, HTML stripping.
|
||||
- `src/commands/embed.ts` — `gbrain embed [--stale|--all] [--slugs ...]`. `--stale` starts with `engine.countStaleChunks()` (single SELECT count(*) WHERE embedding IS NULL, ~50 bytes wire) so a fully-embedded brain short-circuits with no further reads. When stale chunks exist, `engine.listStaleChunks()` returns just the chunks needing embeddings (slug + chunk_index + chunk_text + metadata, no `vector(1536)` payload); caller groups by slug, embeds, re-upserts via `upsertChunks`. All `console.log`/`console.error` call sites use `slog`/`serr` from `src/core/console-prefix.ts` so when `runEmbedCore` runs inside a per-source `withSourcePrefix` scope (installed by the `gbrain sync --all` worker pool) every line carries the `[<source-id>] ` prefix; standalone callers see identical output because slog/serr fall through to bare console fns outside the wrap. Every embed-write path stamps `pages.embedding_signature` via `engine.setPageEmbeddingSignature(slug, {sourceId, signature: currentEmbeddingSignature()})` so a later model/dims swap is detectable as stale. The per-slug path (`embedPage`, used by `gbrain embed <slug>` AND sync's post-import embed step) and the full-re-embed path (`embedAll`) stamp unconditionally per page. The stale path (`embedAllStale`) first calls `invalidateStaleSignatureEmbeddings` on a live run so signature-drifted pages flow through the NULL cursor, then stamps each page — but ONLY when EVERY chunk was stale this pass (a partially-stale page keeps preserved chunks of unknown provenance, so it stays unstamped rather than falsely marked current; `embed --all` fully re-embeds + stamps those). dry-run never mutates: it counts signature-drift via the widened `countStaleChunks({signature})` predicate without NULLing anything. `--include-null-signature` (#3391) lifts the NULL-signature grandfather clause: threads `includeNullSignature: true` into the invalidation + counts so pages that predate the v108 stamp re-embed too after a model swap (both engines' `countStaleChunks`/`sumStaleChunkChars`/`invalidateStaleSignatureEmbeddings` accept the flag; predicate becomes `sig IS NULL OR sig <> current`). Without the flag, a live stale run that just invalidated drifted rows probes for left-behind NULL-signature chunks and emits a loud stderr warning naming the count + the fix — mixed embedding spaces in one index are never silent. Pinned by `test/embedding-migration.test.ts` + `test/e2e/migrate-embeddings-postgres.test.ts`.
|
||||
- `src/commands/embed.ts` — `gbrain embed [--stale|--all] [--slugs ...]`. `--stale` starts with `engine.countStaleChunks()` (single SELECT count(*) WHERE embedding IS NULL, ~50 bytes wire) so a fully-embedded brain short-circuits with no further reads. When stale chunks exist, `engine.listStaleChunks()` returns just the chunks needing embeddings (slug + chunk_index + chunk_text + metadata, no `vector(1536)` payload); caller groups by slug, embeds, re-upserts via `upsertChunks`. All `console.log`/`console.error` call sites use `slog`/`serr` from `src/core/console-prefix.ts` so when `runEmbedCore` runs inside a per-source `withSourcePrefix` scope (installed by the `gbrain sync --all` worker pool) every line carries the `[<source-id>] ` prefix; standalone callers see identical output because slog/serr fall through to bare console fns outside the wrap. Every embed-write path stamps `pages.embedding_signature` via `engine.setPageEmbeddingSignature(slug, {sourceId, signature: currentEmbeddingSignature()})` so a later model/dims swap is detectable as stale. The per-slug path (`embedPage`, used by `gbrain embed <slug>` AND sync's post-import embed step) and the full-re-embed path (`embedAll`) stamp per page when every chunk embedded cleanly. The stale path (`embedAllStale`) first calls `invalidateStaleSignatureEmbeddings` on a live run so signature-drifted pages flow through the NULL cursor, then stamps each page — but ONLY when EVERY chunk was stale this pass (a partially-stale page keeps preserved chunks of unknown provenance, so it stays unstamped rather than falsely marked current; `embed --all` fully re-embeds + stamps those). dry-run never mutates: it counts signature-drift via the widened `countStaleChunks({signature})` predicate without NULLing anything. `--include-null-signature` (#3391) lifts the NULL-signature grandfather clause: threads `includeNullSignature: true` into the invalidation + counts so pages that predate the v108 stamp re-embed too after a model swap (both engines' `countStaleChunks`/`sumStaleChunkChars`/`invalidateStaleSignatureEmbeddings` accept the flag; predicate becomes `sig IS NULL OR sig <> current`). Without the flag, a live stale run that just invalidated drifted rows probes for left-behind NULL-signature chunks and emits a loud stderr warning naming the count + the fix — mixed embedding spaces in one index are never silent. Pinned by `test/embedding-migration.test.ts` + `test/e2e/migrate-embeddings-postgres.test.ts`. Embed failures are never silent (#3037): all three page paths embed via `embedPageTexts`, which tries the page's chunks in one batch and, on a PERMANENT request-shaped failure (non-429, non-`AITransientError`, non-auth), retries once per chunk so one bad chunk costs one chunk instead of darkening the whole page (failed chunks stay `embedding IS NULL` for the next `--stale` pass; a partially-failed page is never signature-stamped). Rate-limit/outage/auth failures do NOT fan out (cost bounding — `embedBatchWithBackoff` already owns 429 backoff). Failed chunk counts land on `EmbedResult.failures` + capped `failure_samples`, and `src/cli.ts`'s embed case sets a non-zero exit verdict on `failures > 0` (mirror of the `import` errors>0 guard). Pinned by `test/embed-partial-failure-3037.serial.test.ts` + `test/embed-exit-code-3037.serial.test.ts` (real spawned CLI).
|
||||
- `src/core/retrieval-upgrade-planner.ts` — `runSchemaTransition(engine, targetDim)` (exported) is the ONE atomic dimension-transition path, shared by `ze-switch` and `gbrain migrate embeddings`. In a single transaction it rebuilds ALL THREE dim-pinned text-embedding-space columns at `targetDim` — `content_chunks.embedding`, `query_cache.embedding`, `facts.embedding` — preserving each column's declared type (`vector` vs `halfvec`, probed from `information_schema`) and recreating its HNSW index with the matching opclass, gated on `hnswIndexExpected` (above the per-type dim ceiling pgvector refuses the index and exact scans remain the path). query_cache + facts are created at brain-birth width by `migrate.ts` and NO migration ever ALTERs them, so omitting either leaves it silently broken: a narrow `query_cache.embedding` makes every `store()`/`lookup()` fail inside the cache's own error-swallowing (permanent 0% hit rate), and a narrow `facts.embedding` fails every per-fact embed write (the doctor check that would warn is skipped on PGLite, the default engine). `content_chunks.embedding_image` / `embedding_multimodal` are the deliberate exception — separate multimodal models, dimensions independent of the text model. Pinned by `test/embedding-migration.test.ts` (all three widths + a real INSERT at the new width into each) and `test/e2e/migrate-embeddings-postgres.test.ts`.
|
||||
- `src/core/embedding-migration.ts` — provider-agnostic embedding migration core (#3390): `planEmbeddingMigration` (workload counts via the widened stale predicates with the TARGET signature + `includeNullSignature`, so a mid-migration re-plan counts only what remains; cost via `embedding-pricing.ts`; `null_signature_chunks` split out for #3391 visibility; reranker-on-outgoing-provider warning), `applyEmbeddingMigration` (env-override gate BEFORE any mutation → in-flight state marker `embedding_migration.state` → `runSchemaTransition` when the ACTUAL column width differs from target → DB-plane `embedding_model`/`embedding_dimensions` → `persistConfig` callback for the file plane → `invalidateStaleSignatureEmbeddings({includeNullSignature: true})` → `SemanticQueryCache.clear()`), `completeEmbeddingMigration` (clears the marker + stamps `embedding_migration.completed`; call only at zero backlog), `resolveMigrationTarget` (validates `provider:model` via `resolveRecipe`, dims via `embeddingDimsForModel` or explicit `--dim`), `migrationSignature` (matches `currentEmbeddingSignature()` shape). Engine-pure; every step idempotent under crash + re-run — the NULL-embedding column is the checkpoint. Reuses `runSchemaTransition` (now exported from `retrieval-upgrade-planner.ts`) so ze-switch and the migration share ONE dimension-transition path. `reconcilePageSignatures(engine, plan)` runs after the re-embed drain and BEFORE the completion probe: it stamps the target signature on every page that has zero NULL-embedding chunks, covering pages whose chunks straddle a `listStaleChunks` batch boundary (the embed loop only stamps when `stale.length === existing.length`, so a split page is embedded correctly but never stamped — without the reconcile a >1-batch brain reports "incomplete" and the re-run re-invalidates and re-pays for those pages). Sound only because apply() invalidated everything not already in the target space; pages with a remaining NULL chunk stay unstamped so a real embed failure still surfaces. Invalidation is ordered BEFORE the config writes so a crash on a same-dim swap leaves rows merely stale (empty results) rather than new-space queries scored against old-space vectors (silently wrong). Pinned by `test/embedding-migration.test.ts` (PGLite) + `test/e2e/migrate-embeddings-postgres.test.ts` (real pgvector).
|
||||
- `src/commands/migrate-embeddings.ts` — `gbrain migrate embeddings --to <provider:model> [--dim N] [--dry-run] [--yes] [--json] [--no-embed] [--pace[=mode]] [--ignore-env-override]` (alias: `gbrain retrieval-upgrade`, the command README/doctor promised since v0.36). Flow: plan → render (stderr when `--json` so stdout stays JSON-clean) → consent gate (TTY y/N prompt or `--yes`; non-TTY without `--yes` refuses exit 2, mirroring the reindex-code cost gate) → live probe (one embed against the TARGET model/dims BEFORE any mutation — bad key/model/dim fails with nothing changed) → `applyEmbeddingMigration` with `persistEmbeddingFileConfig` (writes `~/.gbrain/config.json` + reconfigures the in-process gateway — the gateway reads file/env, NOT the DB plane) → `runEmbedCore({stale, catchUp, singleFlight, includeNullSignature, pace})` → drain check → `completeEmbeddingMigration` or exit 1 with the resume hint (re-run the same command). Also surfaced as the `migrate_embeddings` op (scope admin, localOnly, hidden cliHints; handler hard-refuses `ctx.remote !== false` and returns `needs_confirmation` + plan without `yes: true`). Pinned by `test/migrate-embeddings-flow.serial.test.ts` (full lifecycle incl. interrupted-run resume on PGLite).
|
||||
|
||||
+8
-1
@@ -1832,7 +1832,14 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
}
|
||||
case 'embed': {
|
||||
const { runEmbed } = await import('./commands/embed.ts');
|
||||
await runEmbed(engine, args);
|
||||
// #3037: mirror the `import` case above — the CLI was discarding the
|
||||
// result, so a run where every chunk failed to embed still exited 0
|
||||
// and cron/CI/health gates read total silence as success. Surface
|
||||
// non-zero on failures > 0. (undefined = backgrounded via --background.)
|
||||
const embedResult = await runEmbed(engine, args);
|
||||
if (embedResult && embedResult.failures > 0) {
|
||||
setCliExitVerdict(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'serve': {
|
||||
|
||||
+217
-36
@@ -19,10 +19,26 @@ import {
|
||||
} from '../core/pace-mode.ts';
|
||||
import { tryAcquireDbLock, type DbLockHandle } from '../core/db-lock.ts';
|
||||
import { embedBackfillLockId } from '../core/embed-backfill-lock.ts';
|
||||
import { AITransientError } from '../core/ai/errors.ts';
|
||||
import { wrapChunkTextsForStoredMode } from '../core/embedding-context.ts';
|
||||
import { titleTierCorpusGeneration } from '../core/contextual-retrieval-service.ts';
|
||||
import type { Page } from '../core/types.ts';
|
||||
|
||||
/** #3037: cap failure samples so a corpus-wide outage doesn't bloat --json. */
|
||||
const FAILURE_SAMPLE_CAP = 10;
|
||||
|
||||
/**
|
||||
* #3037: record embed failures on the run result. `chunkCount` is the number
|
||||
* of chunks left un-embedded by this failure (1 for page-level errors where
|
||||
* the chunk count isn't known at the catch site).
|
||||
*/
|
||||
function recordFailure(result: EmbedResult, chunkCount: number, slug: string, e: unknown): void {
|
||||
result.failures += chunkCount;
|
||||
if (result.failure_samples.length < FAILURE_SAMPLE_CAP) {
|
||||
result.failure_samples.push(`${slug}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #3507 — after a plain re-embed fully re-embedded a `per_chunk_synopsis`
|
||||
* page at the title-only tier (see wrapChunkTextsForStoredMode), restamp the
|
||||
@@ -166,6 +182,20 @@ export interface EmbedResult {
|
||||
total_chunks: number;
|
||||
/** Number of pages processed (whether or not they had stale chunks). */
|
||||
pages_processed: number;
|
||||
/**
|
||||
* #3037: chunks that FAILED to embed this run (batch failures + per-chunk
|
||||
* isolation failures). Callers must not read total silence as success:
|
||||
* `src/cli.ts` turns `failures > 0` into a non-zero exit verdict (mirrors
|
||||
* the `import` errors>0 guard), and structured consumers (--json, minion
|
||||
* handlers) can surface it. 0 on a clean run. Additive field.
|
||||
*/
|
||||
failures: number;
|
||||
/**
|
||||
* #3037: up to 10 `slug: error-message` samples of what failed, so the
|
||||
* operator gets a diagnosis without scrolling stderr. Capped so a
|
||||
* corpus-wide outage doesn't bloat structured output. Additive field.
|
||||
*/
|
||||
failure_samples: string[];
|
||||
/** True if this run was a dry-run. */
|
||||
dryRun: boolean;
|
||||
/**
|
||||
@@ -284,6 +314,8 @@ export async function runEmbedCore(engine: BrainEngine, opts: EmbedOpts): Promis
|
||||
would_embed: 0,
|
||||
total_chunks: 0,
|
||||
pages_processed: 0,
|
||||
failures: 0,
|
||||
failure_samples: [],
|
||||
dryRun: !!opts.dryRun,
|
||||
};
|
||||
|
||||
@@ -293,6 +325,11 @@ export async function runEmbedCore(engine: BrainEngine, opts: EmbedOpts): Promis
|
||||
try {
|
||||
await embedPage(engine, s, !!opts.dryRun, result, opts.sourceId, opts.signal, opts.quiet);
|
||||
} catch (e: unknown) {
|
||||
if (isAborted(opts.signal)) break; // shutdown, not a failure
|
||||
// #3037: a page-level error (not found, DB write) must not exit 0.
|
||||
// Chunk-level embed failures are counted inside embedPage; this
|
||||
// counts the page itself (chunk count unknown at this site).
|
||||
recordFailure(result, 1, s, e);
|
||||
serr(` Error embedding ${s}: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
@@ -535,6 +572,12 @@ export async function runEmbed(engine: BrainEngine, args: string[]): Promise<Emb
|
||||
try {
|
||||
const result = await runEmbedCore(engine, opts);
|
||||
if (progressStarted) progress.finish();
|
||||
// #3037: loud end-of-run summary so failures are visible even when the
|
||||
// per-page stderr lines scrolled away. cli.ts turns failures>0 into a
|
||||
// non-zero exit verdict.
|
||||
if (result.failures > 0) {
|
||||
serr(`[embed] ${result.failures} chunk(s) failed to embed. First error: ${result.failure_samples[0] ?? 'unknown'}`);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (progressStarted) progress.finish();
|
||||
@@ -623,10 +666,32 @@ async function embedPage(
|
||||
// contextual prefix when the page was embedded wrapped), not raw
|
||||
// chunk_text — otherwise a re-embed silently strips the contextual
|
||||
// prefixes the sync path applied. fenced_code chunks stay unwrapped.
|
||||
const embeddings = await embedBatch(wrapChunkTextsForStoredMode(page, toEmbed), { abortSignal: signal });
|
||||
// #3037: per-chunk failure isolation — one bad chunk must not leave the
|
||||
// page's sibling chunks NULL. The wrapped texts (computed once) feed the
|
||||
// fan-out too, so an isolation retry never strips the prefixes. Total
|
||||
// embed failure is recorded here (where the chunk count is known) and
|
||||
// swallowed: the page stays NULL exactly as before, but the run now
|
||||
// reports it (result.failures → non-zero exit) instead of pretending
|
||||
// success. Abort (shutdown) still propagates.
|
||||
let embeddings: (Float32Array | null)[];
|
||||
let failed = 0;
|
||||
let firstError: unknown;
|
||||
try {
|
||||
({ embeddings, failed, firstError } = await embedPageTexts(
|
||||
wrapChunkTextsForStoredMode(page, toEmbed),
|
||||
signal ? { abortSignal: signal } : {},
|
||||
));
|
||||
} catch (e: unknown) {
|
||||
if (isAborted(signal)) throw e;
|
||||
recordFailure(result, toEmbed.length, slug, e);
|
||||
result.pages_processed++;
|
||||
serr(` Error embedding ${slug}: ${e instanceof Error ? e.message : e}`);
|
||||
return;
|
||||
}
|
||||
const embeddingMap = new Map<number, Float32Array>();
|
||||
for (let j = 0; j < toEmbed.length; j++) {
|
||||
embeddingMap.set(toEmbed[j].chunk_index, embeddings[j]);
|
||||
const emb = embeddings[j];
|
||||
if (emb) embeddingMap.set(toEmbed[j].chunk_index, emb);
|
||||
}
|
||||
const updated: ChunkInput[] = chunks.map(c => preserveCodeMetadata(c, {
|
||||
chunk_index: c.chunk_index,
|
||||
@@ -643,16 +708,21 @@ async function embedPage(
|
||||
// Guard: only stamp when EVERY chunk was (re)embedded this pass. If some
|
||||
// chunks were preserved from a prior embed (unknown/old provenance), the
|
||||
// page is mixed — don't claim it's current. `embed --all` fully re-embeds
|
||||
// such a page and then stamps it.
|
||||
if (toEmbed.length === chunks.length) {
|
||||
// such a page and then stamps it. #3037: a partial failure leaves failed
|
||||
// chunks NULL, so don't stamp then either.
|
||||
if (failed === 0 && toEmbed.length === chunks.length) {
|
||||
await engine.setPageEmbeddingSignature(slug, { sourceId, signature: currentEmbeddingSignature() });
|
||||
// #3507: a fully re-embedded per_chunk_synopsis page landed at the
|
||||
// title tier — keep the stamped mode honest.
|
||||
await restampIfDemotedToTitleTier(engine, page, slug, page.source_id);
|
||||
}
|
||||
result.embedded += toEmbed.length;
|
||||
result.embedded += toEmbed.length - failed;
|
||||
if (failed > 0) {
|
||||
recordFailure(result, failed, slug, firstError);
|
||||
serr(` ${slug}: ${failed} chunk(s) failed to embed; embedded the other ${toEmbed.length - failed}`);
|
||||
}
|
||||
result.pages_processed++;
|
||||
if (!quiet) slog(`${slug}: embedded ${toEmbed.length} chunks`);
|
||||
if (!quiet) slog(`${slug}: embedded ${toEmbed.length - failed} chunks`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -791,11 +861,18 @@ async function embedAll(
|
||||
|
||||
try {
|
||||
// #3507: reproduce the page's stored wrapping convention (see embedPage).
|
||||
const embeddings = await embedBatch(wrapChunkTextsForStoredMode(page, toEmbed));
|
||||
// #3037: per-chunk failure isolation — one bad chunk costs one chunk,
|
||||
// not the whole page's siblings. The wrapped texts feed the fan-out
|
||||
// too, so an isolation retry never strips the contextual prefixes.
|
||||
const { embeddings, failed, firstError } = await embedPageTexts(
|
||||
wrapChunkTextsForStoredMode(page, toEmbed),
|
||||
signal ? { abortSignal: signal } : {},
|
||||
);
|
||||
// Build a map of new embeddings by chunk_index
|
||||
const embeddingMap = new Map<number, Float32Array>();
|
||||
for (let j = 0; j < toEmbed.length; j++) {
|
||||
embeddingMap.set(toEmbed[j].chunk_index, embeddings[j]);
|
||||
const emb = embeddings[j];
|
||||
if (emb) embeddingMap.set(toEmbed[j].chunk_index, emb);
|
||||
}
|
||||
// Preserve ALL chunks, only update embeddings for stale ones.
|
||||
// preserveCodeMetadata threads code-chunk metadata (#769) so re-embed
|
||||
@@ -809,17 +886,30 @@ async function embedAll(
|
||||
}));
|
||||
await observed(pacer, () => engine.upsertChunks(page.slug, updated, pageOpts));
|
||||
// v0.41.31: stamp embedding provenance so a later model swap is
|
||||
// detectable as stale.
|
||||
await observed(pacer, () =>
|
||||
engine.setPageEmbeddingSignature(page.slug, { sourceId: pageSourceId, signature }),
|
||||
);
|
||||
// #3507: --all fully re-embeds; a per_chunk_synopsis page landed at
|
||||
// the title tier — keep the stamped mode honest.
|
||||
await observed(pacer, () =>
|
||||
restampIfDemotedToTitleTier(engine, page, page.slug, pageSourceId),
|
||||
);
|
||||
result.embedded += toEmbed.length;
|
||||
// detectable as stale. #3037: not on partial failure — failed chunks
|
||||
// stay NULL under unknown provenance.
|
||||
if (failed === 0) {
|
||||
await observed(pacer, () =>
|
||||
engine.setPageEmbeddingSignature(page.slug, { sourceId: pageSourceId, signature }),
|
||||
);
|
||||
// #3507: --all fully re-embeds; a per_chunk_synopsis page landed at
|
||||
// the title tier — keep the stamped mode honest. #3037: gated on
|
||||
// failed === 0 — a partially-failed page was NOT fully re-embedded,
|
||||
// so restamping would make contextual_retrieval_mode lie again
|
||||
// (the exact #3461 bug).
|
||||
await observed(pacer, () =>
|
||||
restampIfDemotedToTitleTier(engine, page, page.slug, pageSourceId),
|
||||
);
|
||||
}
|
||||
result.embedded += toEmbed.length - failed;
|
||||
if (failed > 0) {
|
||||
recordFailure(result, failed, page.slug, firstError);
|
||||
serr(`\n ${page.slug}: ${failed} chunk(s) failed to embed; embedded the other ${toEmbed.length - failed}`);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
// #3037: count the darkened page so the run can't exit 0 (abort is a
|
||||
// shutdown, not a failure).
|
||||
if (!isAborted(signal)) recordFailure(result, toEmbed.length, page.slug, e);
|
||||
serr(`\n Error embedding ${page.slug}: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
|
||||
@@ -1037,10 +1127,8 @@ async function embedAllStale(
|
||||
let afterUpdatedAt: string | null = null;
|
||||
let totalChunksLoaded = 0;
|
||||
let budgetExitNotified = false;
|
||||
// #1946 (OV2a): track chunks that errored out so a catch-up pass that finishes
|
||||
// with stale chunks still remaining (un-embeddable for a non-transient reason)
|
||||
// surfaces that loudly instead of looking like a clean run.
|
||||
let embedFailures = 0;
|
||||
// #1946 (OV2a) + #3037: embed failures are tracked on result.failures so
|
||||
// the catch-up warning below AND the CLI exit verdict both see them.
|
||||
|
||||
// E-3 (paced-backfill): bounded end-of-run re-entry. A longer paced run gives
|
||||
// a live writer (sync / put_page) more time to insert NEW stale rows BEHIND
|
||||
@@ -1137,12 +1225,19 @@ async function embedAllStale(
|
||||
// NORMAL post-model-migration path, so raw-text embedding here
|
||||
// quietly converted whole corpora to the unwrapped convention.
|
||||
const pageRow = await observed(pacer, () => engine.getPage(slug, { sourceId: keySourceId }));
|
||||
const embeddings = await embedBatchWithBackoff(wrapChunkTextsForStoredMode(pageRow, stale), { abortSignal: effectiveSignal });
|
||||
// #3037: per-chunk failure isolation — one bad chunk costs one
|
||||
// chunk, not the whole page's siblings. The wrapped texts feed the
|
||||
// fan-out too, so an isolation retry never strips the prefixes.
|
||||
const { embeddings, failed, firstError } = await embedPageTexts(
|
||||
wrapChunkTextsForStoredMode(pageRow, stale),
|
||||
{ abortSignal: effectiveSignal },
|
||||
);
|
||||
// Re-fetch existing chunks and merge to avoid deleting non-stale chunks.
|
||||
const existing = await observed(pacer, () => engine.getChunks(slug, { sourceId: keySourceId }));
|
||||
const staleIdxToEmbedding = new Map<number, Float32Array>();
|
||||
for (let j = 0; j < stale.length; j++) {
|
||||
staleIdxToEmbedding.set(stale[j].chunk_index, embeddings[j]);
|
||||
const emb = embeddings[j];
|
||||
if (emb) staleIdxToEmbedding.set(stale[j].chunk_index, emb);
|
||||
}
|
||||
// preserveCodeMetadata threads code-chunk metadata (#769) so the
|
||||
// autopilot --stale path doesn't clobber language/symbol_name/etc
|
||||
@@ -1160,7 +1255,8 @@ async function embedAllStale(
|
||||
// A partially-stale page keeps preserved chunks of unknown/old
|
||||
// provenance, so don't claim it's current. (After invalidate, a
|
||||
// signature-drifted page IS fully stale → this stamps it.)
|
||||
if (signature && stale.length === existing.length) {
|
||||
// #3037: not on partial failure — failed chunks stay NULL.
|
||||
if (signature && failed === 0 && stale.length === existing.length) {
|
||||
await observed(pacer, () =>
|
||||
engine.setPageEmbeddingSignature(slug, { sourceId: keySourceId, signature }),
|
||||
);
|
||||
@@ -1168,17 +1264,24 @@ async function embedAllStale(
|
||||
// #3507: a FULLY re-embedded per_chunk_synopsis page landed at the
|
||||
// title tier — keep the stamped mode honest. Partially-stale pages
|
||||
// stay stamped as-is (mixed provenance; reindex sweeps fix them).
|
||||
if (stale.length === existing.length) {
|
||||
// #3037: `failed === 0` is part of "fully re-embedded" — if the
|
||||
// per-chunk isolation left some chunks NULL, restamping would make
|
||||
// contextual_retrieval_mode lie again (the exact #3461 bug).
|
||||
if (failed === 0 && stale.length === existing.length) {
|
||||
await observed(pacer, () =>
|
||||
restampIfDemotedToTitleTier(engine, pageRow, slug, keySourceId),
|
||||
);
|
||||
}
|
||||
result.embedded += stale.length;
|
||||
result.embedded += stale.length - failed;
|
||||
if (failed > 0) {
|
||||
recordFailure(result, failed, slug, firstError);
|
||||
serr(`\n ${slug}: ${failed} chunk(s) failed to embed; embedded the other ${stale.length - failed}`);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
// Budget/abort-fired cancellations are expected on the way out; don't
|
||||
// spam per-page "Error embedding" lines when we're shutting down.
|
||||
if (effectiveSignal.aborted) return;
|
||||
embedFailures++;
|
||||
recordFailure(result, stale.length, slug, e);
|
||||
serr(`\n Error embedding ${slug}: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
totalProcessedPages++;
|
||||
@@ -1231,14 +1334,14 @@ async function embedAllStale(
|
||||
// chunks unembedded means those chunks are stuck (a non-transient embed
|
||||
// failure), not that we ran out of time. Surface it loudly so it doesn't read
|
||||
// as a clean run — re-running won't help until the underlying failure is fixed.
|
||||
if (staleOpts?.catchUp && !effectiveSignal.aborted && embedFailures > 0) {
|
||||
if (staleOpts?.catchUp && !effectiveSignal.aborted && result.failures > 0) {
|
||||
const remaining = await engine.countStaleChunks(
|
||||
signature
|
||||
? { signature, ...(sourceId ? { sourceId } : {}), ...(includeNullSig && { includeNullSignature: true }) }
|
||||
: (sourceId ? { sourceId } : undefined),
|
||||
);
|
||||
if (remaining > 0) {
|
||||
serr(`\n [embed] catch-up finished but ${remaining} chunk(s) remain stale after ${embedFailures} embed failure(s). These are not embeddable as-is; re-running won't clear them until the underlying error is resolved.`);
|
||||
serr(`\n [embed] catch-up finished but ${remaining} chunk(s) remain stale after ${result.failures} embed failure(s). These are not embeddable as-is; re-running won't clear them until the underlying error is resolved.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1358,12 +1461,7 @@ export async function embedBatchWithBackoff(
|
||||
// If the budget fired we may have been aborted mid-fetch; bubble out.
|
||||
if (signal?.aborted) throw e;
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
// D4: structured detection first (handles gateway-wrapped errors via
|
||||
// cause chain); message-match as fallback for providers whose wrappers
|
||||
// strip `cause.status`.
|
||||
const isRateLimit = detect429FromCause(e)
|
||||
|| /rate.?limit|429/i.test(msg);
|
||||
if (!isRateLimit || attempt === MAX_RATE_LIMIT_RETRIES) throw e;
|
||||
if (!isRateLimitError(e) || attempt === MAX_RATE_LIMIT_RETRIES) throw e;
|
||||
|
||||
const delayMs = parseRetryDelayMs(msg);
|
||||
serr(` [rate-limit] attempt ${attempt + 1}/${MAX_RATE_LIMIT_RETRIES}, waiting ${delayMs}ms...`);
|
||||
@@ -1373,3 +1471,86 @@ export async function embedBatchWithBackoff(
|
||||
// Unreachable, but TypeScript needs it.
|
||||
return embedBatch(texts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 429 judgment shared by embedBatchWithBackoff (retry decision) and
|
||||
* embedPageTexts (fan-out decision). D4: structured detection first
|
||||
* (gateway-wrapped errors via cause chain); message-match as fallback for
|
||||
* providers whose wrappers strip `cause.status`.
|
||||
*/
|
||||
function isRateLimitError(e: unknown): boolean {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return detect429FromCause(e) || /rate.?limit|429/i.test(msg);
|
||||
}
|
||||
|
||||
/** Walk the cause chain (like detect429FromCause) for the first HTTP status. */
|
||||
function statusFromCause(e: unknown): number | undefined {
|
||||
let cur: unknown = e;
|
||||
for (let depth = 0; depth < 5 && cur !== undefined && cur !== null; depth++) {
|
||||
const obj = cur as { status?: unknown; statusCode?: unknown; cause?: unknown };
|
||||
if (typeof obj.status === 'number') return obj.status;
|
||||
if (typeof obj.statusCode === 'number') return obj.statusCode;
|
||||
cur = obj.cause;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* #3037: embed one page's chunk texts with per-chunk failure isolation.
|
||||
*
|
||||
* All three embed paths used to send a page's chunks in ONE
|
||||
* embedBatch call, so one bad chunk (e.g. an oversized chunk the provider
|
||||
* 400s) left EVERY sibling chunk NULL — an ~8.6x blast radius. This wrapper
|
||||
* tries the batch first (the cheap, common path), and only on a
|
||||
* PERMANENT-looking batch failure retries once per chunk so one bad chunk
|
||||
* costs one chunk.
|
||||
*
|
||||
* Cost bounding — when we do NOT fan out (rethrow instead):
|
||||
* - 429 / rate limit: embedBatchWithBackoff already retried with backoff;
|
||||
* fanning out N single-chunk calls would hammer the same limiter N-fold.
|
||||
* - AITransientError (5xx / network / unknown, per normalizeAIError): the
|
||||
* batch CONTENT isn't the problem, so isolation can't help — during an
|
||||
* outage it would just multiply failing calls per page.
|
||||
* - 401/403 (auth): nothing chunk-specific; every call would fail.
|
||||
* When we DO fan out (permanent request-shaped 4xx like 400/413/422), the
|
||||
* per-chunk pass happens at most ONCE per page per run and re-spends roughly
|
||||
* the same tokens the failed batch would have — bounded, no recursion. A
|
||||
* fresh 429 arising DURING the fan-out still gets the normal backoff (each
|
||||
* single-chunk call goes through embedBatchWithBackoff).
|
||||
*
|
||||
* Throws when nothing could be embedded (total failure — same contract as
|
||||
* the pre-#3037 single batch call). Returns `null` at the index of each
|
||||
* failed chunk otherwise.
|
||||
*/
|
||||
async function embedPageTexts(
|
||||
texts: string[],
|
||||
opts: EmbedBatchWithBackoffOpts = {},
|
||||
): Promise<{ embeddings: (Float32Array | null)[]; failed: number; firstError?: unknown }> {
|
||||
try {
|
||||
return { embeddings: await embedBatchWithBackoff(texts, opts), failed: 0 };
|
||||
} catch (e: unknown) {
|
||||
if (opts.abortSignal?.aborted) throw e; // shutdown, not a chunk problem
|
||||
if (texts.length <= 1) throw e; // nothing to isolate
|
||||
if (isRateLimitError(e) || e instanceof AITransientError) throw e;
|
||||
const status = statusFromCause(e);
|
||||
if (status === 401 || status === 403) throw e;
|
||||
|
||||
const embeddings: (Float32Array | null)[] = [];
|
||||
let failed = 0;
|
||||
let firstError: unknown;
|
||||
for (const t of texts) {
|
||||
try {
|
||||
const single = await embedBatchWithBackoff([t], opts);
|
||||
embeddings.push(single[0] ?? null);
|
||||
if (single[0] === undefined) { failed++; firstError ??= e; }
|
||||
} catch (chunkErr: unknown) {
|
||||
if (opts.abortSignal?.aborted) throw chunkErr;
|
||||
embeddings.push(null);
|
||||
failed++;
|
||||
firstError ??= chunkErr;
|
||||
}
|
||||
}
|
||||
if (failed === texts.length) throw firstError ?? e; // total failure: pre-#3037 contract
|
||||
return { embeddings, failed, firstError };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* #3037 — `gbrain embed` must exit non-zero when chunks failed to embed.
|
||||
*
|
||||
* Pre-fix, src/cli.ts discarded runEmbed's result entirely, so a run where
|
||||
* EVERY chunk failed to embed still exited 0 — cron, CI and health gates read
|
||||
* total failure as success. The fix mirrors the `import` case's
|
||||
* `errors > 0 → setCliExitVerdict(1)` guard.
|
||||
*
|
||||
* Real spawned CLI against a tmpdir PGLite brain, with the embedding
|
||||
* provider pointed at a local mock llama-server (OpenAI-compatible, no auth)
|
||||
* that can be flipped between failing and healthy. Single test, single
|
||||
* brain: every spawn pays a cold transpile cost (see
|
||||
* apply-migrations-pglite-spawn.serial.test.ts for the rationale).
|
||||
*
|
||||
* Serial: spawns subprocesses + binds a local port + writes tmpdirs.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
const REPO = new URL('..', import.meta.url).pathname.replace(/\/$/, '');
|
||||
const DIMS = 16;
|
||||
|
||||
async function runCli(
|
||||
args: string[],
|
||||
env: Record<string, string>,
|
||||
timeoutMs: number,
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
const proc = Bun.spawn(['bun', 'run', `${REPO}/src/cli.ts`, ...args], {
|
||||
cwd: REPO,
|
||||
env: { ...process.env, ...env },
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
const killer = setTimeout(() => {
|
||||
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
|
||||
}, timeoutMs);
|
||||
try {
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
return { exitCode, stdout, stderr };
|
||||
} finally {
|
||||
clearTimeout(killer);
|
||||
}
|
||||
}
|
||||
|
||||
describe('gbrain embed exit code on failures (#3037)', () => {
|
||||
test('embed --stale exits non-zero when embedding fails, 0 once it succeeds', async () => {
|
||||
// Mock OpenAI-compatible embeddings endpoint, flippable between modes.
|
||||
let mode: 'fail' | 'ok' = 'fail';
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname.endsWith('/embeddings')) {
|
||||
if (mode === 'fail') {
|
||||
return new Response(JSON.stringify({ error: { message: 'mock provider exploded' } }), {
|
||||
status: 500, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const body = await req.json() as { input: string | string[] };
|
||||
const inputs = Array.isArray(body.input) ? body.input : [body.input];
|
||||
const vec = Array.from({ length: DIMS }, () => 0.1);
|
||||
return new Response(JSON.stringify({
|
||||
data: inputs.map((_, i) => ({ object: 'embedding', index: i, embedding: vec })),
|
||||
usage: { prompt_tokens: 3, total_tokens: 3 },
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
// /v1/models probe shape.
|
||||
return new Response(JSON.stringify({ data: [{ id: 'test-model' }] }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const home = mkdtempSync(join(tmpdir(), 'gbrain-3037-exit-'));
|
||||
const notes = mkdtempSync(join(tmpdir(), 'gbrain-3037-notes-'));
|
||||
try {
|
||||
mkdirSync(join(home, '.gbrain'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, '.gbrain', 'config.json'),
|
||||
JSON.stringify({
|
||||
engine: 'pglite',
|
||||
database_path: join(home, '.gbrain', 'brain.pglite'),
|
||||
embedding_model: 'llama-server:test-model',
|
||||
embedding_dimensions: DIMS,
|
||||
}) + '\n',
|
||||
);
|
||||
writeFileSync(join(notes, 'note.md'), '# A note\n\nSome content to embed.\n');
|
||||
const env = {
|
||||
HOME: home,
|
||||
GBRAIN_HOME: home,
|
||||
LLAMA_SERVER_BASE_URL: `http://127.0.0.1:${server.port}/v1`,
|
||||
};
|
||||
|
||||
const init = await runCli(['init', '--migrate-only'], env, 120_000);
|
||||
expect(init.exitCode).toBe(0);
|
||||
|
||||
const imp = await runCli(['import', notes, '--no-embed'], env, 90_000);
|
||||
expect(imp.exitCode).toBe(0);
|
||||
|
||||
// THE #3037 PIN: provider fails every embed call → the run must exit
|
||||
// non-zero. Pre-fix this exited 0 (result discarded by cli.ts).
|
||||
const failing = await runCli(['embed', '--stale'], env, 90_000);
|
||||
if (failing.exitCode === 0) {
|
||||
console.error('--- failing-embed stdout ---\n' + failing.stdout);
|
||||
console.error('--- failing-embed stderr ---\n' + failing.stderr);
|
||||
}
|
||||
expect(failing.exitCode).not.toBe(0);
|
||||
expect(failing.stderr).toMatch(/failed to embed/i);
|
||||
|
||||
// Same brain, healthy provider: converges and exits 0 (failure exit is
|
||||
// not sticky; the failed chunks stayed NULL so --stale picks them up).
|
||||
mode = 'ok';
|
||||
const healthy = await runCli(['embed', '--stale'], env, 90_000);
|
||||
if (healthy.exitCode !== 0) {
|
||||
console.error('--- healthy-embed stdout ---\n' + healthy.stdout);
|
||||
console.error('--- healthy-embed stderr ---\n' + healthy.stderr);
|
||||
}
|
||||
expect(healthy.exitCode).toBe(0);
|
||||
expect(healthy.stdout + healthy.stderr).toMatch(/Embedded [1-9]\d* chunks/);
|
||||
} finally {
|
||||
server.stop(true);
|
||||
try { rmSync(home, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
try { rmSync(notes, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
}, 480_000);
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* #3037 — one oversized/bad chunk must not darken its ENTIRE page, and embed
|
||||
* failures must be visible on the run result.
|
||||
*
|
||||
* Pre-fix, all three embed paths sent a page's chunks in ONE embedBatch call
|
||||
* inside a try whose catch only logged to stderr: when the batch threw,
|
||||
* upsertChunks never ran, so EVERY sibling chunk stayed NULL (~8.6x blast
|
||||
* radius from a single bad chunk), and EmbedResult had no failure field —
|
||||
* `gbrain embed` exited 0 on a total no-op.
|
||||
*
|
||||
* Pinned here:
|
||||
* 1. --stale and --all: a page with ONE bad chunk still embeds its other
|
||||
* chunks (per-chunk isolation via embedPageTexts), failures are counted
|
||||
* on result.failures, and the embedding signature is NOT stamped for a
|
||||
* partially-failed page.
|
||||
* 2. Cost bounding: a 429 (rate limit) does NOT fan out into N
|
||||
* single-chunk calls, and neither does an AITransientError (outage) —
|
||||
* isolation only fires for permanent request-shaped failures.
|
||||
*
|
||||
* Serial: uses mock.module (leaks across files sharing a bun process).
|
||||
* The CLI exit-code half of #3037 is pinned by
|
||||
* test/embed-exit-code-3037.serial.test.ts (real spawned CLI).
|
||||
*/
|
||||
import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test';
|
||||
import type { BrainEngine } from '../src/core/engine.ts';
|
||||
import { AITransientError } from '../src/core/ai/errors.ts';
|
||||
|
||||
// Track every embedBatch call's shape so tests can assert batch-vs-single
|
||||
// fan-out behavior.
|
||||
let embedCalls: string[][] = [];
|
||||
let embedBatchBehavior: ((texts: string[], opts?: unknown) => Promise<Float32Array[]>) | null = null;
|
||||
|
||||
mock.module('../src/core/embedding.ts', () => ({
|
||||
embedBatch: async (texts: string[], opts?: unknown) => {
|
||||
embedCalls.push([...texts]);
|
||||
if (embedBatchBehavior) return embedBatchBehavior(texts, opts);
|
||||
return texts.map(() => new Float32Array(1536));
|
||||
},
|
||||
currentEmbeddingSignature: () => 'test:model:1536',
|
||||
}));
|
||||
|
||||
// Import AFTER mocking.
|
||||
const { runEmbedCore } = await import('../src/commands/embed.ts');
|
||||
|
||||
// Preflight seam (same as test/embed.serial.test.ts): make
|
||||
// diagnoseEmbedding's fast-path pass without real env vars.
|
||||
const { __setEmbedTransportForTests } = await import('../src/core/ai/gateway.ts');
|
||||
__setEmbedTransportForTests(async () => ({ embeddings: [], usage: { tokens: 0 } } as any));
|
||||
|
||||
function mockEngine(overrides: Partial<Record<string, any>> = {}): BrainEngine {
|
||||
const calls: { method: string; args: any[] }[] = [];
|
||||
const track = (method: string) => (...args: any[]) => {
|
||||
calls.push({ method, args });
|
||||
if (overrides[method]) return overrides[method](...args);
|
||||
return Promise.resolve(null);
|
||||
};
|
||||
return new Proxy({} as any, {
|
||||
get(_, prop: string) {
|
||||
if (prop === '_calls') return calls;
|
||||
if (overrides[prop]) return overrides[prop];
|
||||
return track(prop);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Permanent 400-shaped batch failure (e.g. one oversized chunk). */
|
||||
function permanentBatchError(): Error {
|
||||
const err = new Error('batch contains an invalid input');
|
||||
(err as any).cause = { status: 400 };
|
||||
return err;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
embedCalls = [];
|
||||
embedBatchBehavior = null;
|
||||
process.env.GBRAIN_EMBED_CONCURRENCY = '1';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.GBRAIN_EMBED_CONCURRENCY;
|
||||
});
|
||||
|
||||
// Behavior: the whole-page batch 400s; retried per-chunk, only 'BAD' fails.
|
||||
function oneBadChunkBehavior() {
|
||||
embedBatchBehavior = async (texts: string[]) => {
|
||||
if (texts.length > 1) throw permanentBatchError();
|
||||
if (texts[0] === 'BAD') throw permanentBatchError();
|
||||
return texts.map(() => new Float32Array(1536));
|
||||
};
|
||||
}
|
||||
|
||||
const THREE_CHUNKS = [
|
||||
{ chunk_index: 0, chunk_text: 'good-a', chunk_source: 'compiled_truth' as const, embedded_at: null, token_count: 1 },
|
||||
{ chunk_index: 1, chunk_text: 'BAD', chunk_source: 'compiled_truth' as const, embedded_at: null, token_count: 1 },
|
||||
{ chunk_index: 2, chunk_text: 'good-b', chunk_source: 'compiled_truth' as const, embedded_at: null, token_count: 1 },
|
||||
];
|
||||
|
||||
describe('#3037 — one bad chunk no longer darkens its page', () => {
|
||||
test('--stale: siblings of one bad chunk get embedded; failure counted; signature not stamped', async () => {
|
||||
oneBadChunkBehavior();
|
||||
const stale = THREE_CHUNKS.map(c => ({
|
||||
slug: 'poisoned-page', chunk_index: c.chunk_index, chunk_text: c.chunk_text,
|
||||
chunk_source: c.chunk_source, model: null, token_count: 1, source_id: 'default', page_id: 1,
|
||||
}));
|
||||
const upsertCalls: Array<{ slug: string; chunks: any[] }> = [];
|
||||
const engine = mockEngine({
|
||||
countStaleChunks: async () => 3,
|
||||
listStaleChunks: async () => stale,
|
||||
getChunks: async () => THREE_CHUNKS,
|
||||
upsertChunks: async (slug: string, chunks: any[]) => { upsertCalls.push({ slug, chunks }); },
|
||||
});
|
||||
|
||||
const result = await runEmbedCore(engine, { stale: true });
|
||||
|
||||
// Pre-fix: the batch threw, upsertChunks never ran, embedded stayed 0.
|
||||
expect(upsertCalls).toHaveLength(1);
|
||||
const byIdx = new Map(upsertCalls[0].chunks.map((c: any) => [c.chunk_index, c]));
|
||||
expect(byIdx.get(0)!.embedding).toBeInstanceOf(Float32Array);
|
||||
expect(byIdx.get(2)!.embedding).toBeInstanceOf(Float32Array);
|
||||
expect(byIdx.get(1)!.embedding).toBeUndefined(); // bad chunk stays NULL (re-run picks it up)
|
||||
expect(result.embedded).toBe(2);
|
||||
expect(result.failures).toBe(1);
|
||||
expect(result.failure_samples).toHaveLength(1);
|
||||
expect(result.failure_samples[0]).toContain('poisoned-page');
|
||||
// Partially-failed page must NOT be stamped as current provenance.
|
||||
const stamps = (engine as any)._calls.filter((c: any) => c.method === 'setPageEmbeddingSignature');
|
||||
expect(stamps).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('--all: same isolation on the listPages path', async () => {
|
||||
oneBadChunkBehavior();
|
||||
const upsertCalls: Array<{ slug: string; chunks: any[] }> = [];
|
||||
const engine = mockEngine({
|
||||
listPages: async () => [{ slug: 'poisoned-page', source_id: 'default' }],
|
||||
getChunks: async () => THREE_CHUNKS,
|
||||
upsertChunks: async (slug: string, chunks: any[]) => { upsertCalls.push({ slug, chunks }); },
|
||||
});
|
||||
|
||||
const result = await runEmbedCore(engine, { all: true });
|
||||
|
||||
expect(upsertCalls).toHaveLength(1);
|
||||
const byIdx = new Map(upsertCalls[0].chunks.map((c: any) => [c.chunk_index, c]));
|
||||
expect(byIdx.get(0)!.embedding).toBeInstanceOf(Float32Array);
|
||||
expect(byIdx.get(1)!.embedding).toBeUndefined();
|
||||
expect(result.embedded).toBe(2);
|
||||
expect(result.failures).toBe(1);
|
||||
const stamps = (engine as any)._calls.filter((c: any) => c.method === 'setPageEmbeddingSignature');
|
||||
expect(stamps).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('--stale x #3507: fan-out retries the WRAPPED texts and a partially-failed page is not restamped', async () => {
|
||||
// Composition pin for the #3037 + #3538 merge: the per-chunk isolation
|
||||
// retry must re-send the contextually WRAPPED text (raw chunk_text here
|
||||
// would silently strip prefixes on exactly the pages that hit an error),
|
||||
// and restampIfDemotedToTitleTier must NOT fire when isolation left
|
||||
// chunks NULL (the page was not fully re-embedded — restamping would
|
||||
// make contextual_retrieval_mode lie again, the exact #3461 bug).
|
||||
embedBatchBehavior = async (texts: string[]) => {
|
||||
if (texts.length > 1) throw permanentBatchError();
|
||||
if (texts[0].includes('BAD')) throw permanentBatchError();
|
||||
return texts.map(() => new Float32Array(1536));
|
||||
};
|
||||
const stale = THREE_CHUNKS.map(c => ({
|
||||
slug: 'wrapped-page', chunk_index: c.chunk_index, chunk_text: c.chunk_text,
|
||||
chunk_source: c.chunk_source, model: null, token_count: 1, source_id: 'default', page_id: 1,
|
||||
}));
|
||||
const engine = mockEngine({
|
||||
countStaleChunks: async () => 3,
|
||||
listStaleChunks: async () => stale,
|
||||
getPage: async () => ({
|
||||
slug: 'wrapped-page', title: 'My Title', compiled_truth: 'x', timeline: '',
|
||||
source_id: 'default', contextual_retrieval_mode: 'per_chunk_synopsis',
|
||||
}),
|
||||
getChunks: async () => THREE_CHUNKS,
|
||||
upsertChunks: async () => {},
|
||||
});
|
||||
|
||||
const result = await runEmbedCore(engine, { stale: true });
|
||||
|
||||
// Every embed call — the failed batch AND each single-chunk retry —
|
||||
// carries the stored-mode contextual prefix (fenced_code exemption is
|
||||
// pinned upstream in test/embedding-context.test.ts).
|
||||
expect(embedCalls.length).toBeGreaterThan(1);
|
||||
for (const call of embedCalls) {
|
||||
for (const text of call) expect(text).toStartWith('<context>My Title\n</context>\n');
|
||||
}
|
||||
expect(result.embedded).toBe(2);
|
||||
expect(result.failures).toBe(1);
|
||||
// Partially-failed page: neither signature-stamped nor CR-restamped.
|
||||
const calls = (engine as any)._calls as Array<{ method: string }>;
|
||||
expect(calls.filter(c => c.method === 'setPageEmbeddingSignature')).toHaveLength(0);
|
||||
expect(calls.filter(c => c.method === 'updatePageContextualRetrievalState')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('--stale: a fully-failed page is counted on result.failures (no more silent no-op)', async () => {
|
||||
embedBatchBehavior = async () => { throw permanentBatchError(); };
|
||||
const stale = THREE_CHUNKS.map(c => ({
|
||||
slug: 'dark-page', chunk_index: c.chunk_index, chunk_text: c.chunk_text,
|
||||
chunk_source: c.chunk_source, model: null, token_count: 1, source_id: 'default', page_id: 1,
|
||||
}));
|
||||
const engine = mockEngine({
|
||||
countStaleChunks: async () => 3,
|
||||
listStaleChunks: async () => stale,
|
||||
getChunks: async () => THREE_CHUNKS,
|
||||
upsertChunks: async () => {},
|
||||
});
|
||||
|
||||
const result = await runEmbedCore(engine, { stale: true });
|
||||
|
||||
expect(result.embedded).toBe(0);
|
||||
expect(result.failures).toBe(3);
|
||||
expect(result.failure_samples[0]).toContain('dark-page');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#3037 — cost bounding: no per-chunk fan-out on transient failures', () => {
|
||||
test('sustained 429 does not fan out into single-chunk calls', async () => {
|
||||
embedBatchBehavior = async () => {
|
||||
const err = new Error('Rate limit reached. Please try again in 0ms.');
|
||||
(err as any).cause = { status: 429 };
|
||||
throw err;
|
||||
};
|
||||
const stale = THREE_CHUNKS.map(c => ({
|
||||
slug: 'rate-limited-page', chunk_index: c.chunk_index, chunk_text: c.chunk_text,
|
||||
chunk_source: c.chunk_source, model: null, token_count: 1, source_id: 'default', page_id: 1,
|
||||
}));
|
||||
const engine = mockEngine({
|
||||
countStaleChunks: async () => 3,
|
||||
listStaleChunks: async () => stale,
|
||||
getChunks: async () => THREE_CHUNKS,
|
||||
upsertChunks: async () => {},
|
||||
});
|
||||
|
||||
const result = await runEmbedCore(engine, { stale: true });
|
||||
|
||||
// Every call must be the full 3-text batch: embedBatchWithBackoff's own
|
||||
// retries (initial + MAX_RATE_LIMIT_RETRIES), never a 1-text isolation call
|
||||
// hammering the limiter.
|
||||
expect(embedCalls.length).toBeGreaterThan(1);
|
||||
for (const call of embedCalls) expect(call).toHaveLength(3);
|
||||
expect(result.embedded).toBe(0);
|
||||
expect(result.failures).toBe(3);
|
||||
}, 30_000);
|
||||
|
||||
test('AITransientError (outage/network) does not fan out', async () => {
|
||||
embedBatchBehavior = async () => { throw new AITransientError('upstream 502', { status: 502 }); };
|
||||
const stale = THREE_CHUNKS.map(c => ({
|
||||
slug: 'outage-page', chunk_index: c.chunk_index, chunk_text: c.chunk_text,
|
||||
chunk_source: c.chunk_source, model: null, token_count: 1, source_id: 'default', page_id: 1,
|
||||
}));
|
||||
const engine = mockEngine({
|
||||
countStaleChunks: async () => 3,
|
||||
listStaleChunks: async () => stale,
|
||||
getChunks: async () => THREE_CHUNKS,
|
||||
upsertChunks: async () => {},
|
||||
});
|
||||
|
||||
const result = await runEmbedCore(engine, { stale: true });
|
||||
|
||||
// Non-429 → no backoff retries; transient → no isolation. Exactly 1 call.
|
||||
expect(embedCalls).toHaveLength(1);
|
||||
expect(embedCalls[0]).toHaveLength(3);
|
||||
expect(result.embedded).toBe(0);
|
||||
expect(result.failures).toBe(3);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user