fix(embed): stop one bad chunk darkening a page, and exit non-zero on failures (#3037) (#3532)

Co-Authored-By: Garry Tan <garrytan@gmail.com>
This commit is contained in:
Garry Tan
2026-08-01 07:40:48 +08:00
committed by Sina Matian
parent 9c1a4b8fce
commit d6f929bfbd
5 changed files with 624 additions and 38 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
}
}
+132
View File
@@ -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);
});
});