Compare commits

...
Author SHA1 Message Date
Garry TanandClaude Opus 5 fd9522f724 fix(doctor,upgrade): warn brains pinned to a provider shutting down 2026-09-04
The hosted embedding + rerank API that the shipped defaults resolve to
shuts down on 2026-09-04. Query embedding uses the same endpoint as
ingestion, so an affected brain does not just stop indexing new content —
existing vectors become unqueryable and semantic retrieval dies entirely.
`gbrain migrate embeddings` (#3459) is the fix, but nothing told affected
brains to run it beyond a one-shot upgrade banner.

- New `provider_sunset` doctor check: fires on EVERY doctor run until the
  brain is off the provider (warn before the date, fail after). The
  paste-ready `gbrain migrate embeddings --to <provider:model> --dim <N>`
  hint fills `--dim` from the brain's ACTUAL `content_chunks.embedding`
  column width (not the config value, which can drift) — a wrong --dim
  forces a needless dimension transition + index rebuild. The self-host
  escape hatch (zembed-1 weights are Apache-2.0; llama-server/Ollama keep
  every existing vector with no re-embed) gets equal billing.
- Upgrade banner: same actual-width `--dim`, states plainly that EXISTING
  vectors become unqueryable, and points at the recurring doctor check.
- Shutdown date now lives once (`ZEROENTROPY_SUNSET_DATE` in
  `src/core/ai/defaults.ts`) instead of inlined per surface.
- `EmbedResult.lock_skipped` + an accurate `migrate embeddings` pause
  message: E2E verification (kill -9 mid-migration) showed a hard-killed
  run leaves its single-flight embed lock behind for up to the 60-min TTL,
  and the immediate re-run skipped the re-embed while still printing
  "re-run the same command to resume" — a hint the re-run could not honor.
  The command now says the lock is the blocker and when it clears.
- Docs: migration guide (discovery surfaces, --dim guidance, hard-kill
  lock caveat), provider matrix + provider doc (shutdown note), KEY_FILES.

Tests: test/provider-sunset-doctor.serial.test.ts — fails behaviorally on
master (buildChecks never surfaces the sunset; lock-skip invisible on the
result), passes here. Deliberately does NOT touch DEFAULT_EMBEDDING_MODEL /
DEFAULT_RERANKER_MODEL (#3481/#3492 are on hold as owner decisions); every
surface here is correct whichever way those land.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:35:21 -07:00
11 changed files with 372 additions and 13 deletions
+18
View File
@@ -1,5 +1,23 @@
# ZeroEntropy — zembed-1 + zerank-2
> **Hosted API shutdown: 2026-09-04.** ZeroEntropy announced (2026-07-24)
> that its hosted endpoints — `/models/embed` and `/models/rerank` — shut
> down on that date. A brain still embedding through the hosted API loses
> semantic retrieval entirely on that date: query embedding uses the same
> endpoint, so **existing vectors become unqueryable**, not just new
> content. Two fixes, either works:
>
> 1. **Self-host the same model** — zembed-1 weights are Apache-2.0. Serve
> them via `llama-server` or Ollama and point the config at the local
> endpoint. Keeps every existing vector; no re-embed at all.
> 2. **Migrate to another provider** — `gbrain migrate embeddings --to
> <provider:model> --dim <N> --dry-run` (resumable; see
> [the migration guide](../guides/embedding-migration.md)). `gbrain
> doctor` (check `provider_sunset`) prints this command with your
> brain's actual `--dim` filled in.
>
> The hosted setup below remains accurate until the shutdown date.
[ZeroEntropy](https://zeroentropy.dev) ships two specialized small models
for retrieval pipelines:
+1 -1
View File
@@ -195,7 +195,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this
- `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/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).
- `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). When the re-embed was skipped because another backfill holds the per-source single-flight lock (`EmbedResult.lock_skipped` — the state a hard-killed run leaves behind for up to the 60-min lock TTL), the exit-1 message says so instead of misreporting embed failures, since an immediate re-run cannot resume until the lock expires. 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) + `test/provider-sunset-doctor.serial.test.ts` (lock-skip visibility). Discovery surfaces for brains pinned to a sunsetting provider: the `provider_sunset` doctor check (every run; paste-ready command with the ACTUAL column width via `readContentChunksEmbeddingDim`, warn before / fail after the date, self-host escape hatch given equal billing) and the one-shot `gbrain upgrade` banner (`ze_sunset_notice_shown`); the shutdown date lives once as `ZEROENTROPY_SUNSET_DATE` in `src/core/ai/defaults.ts`.
- `src/core/conversation-parser/` — 17-pattern built-in chat-format registry + opt-in LLM polish/fallback. Modules: `types.ts` (PatternEntry + ParseResult + DateContext + CaptureMap + TimezonePolicy), `builtins.ts` (17 hand-vetted patterns sourced from public format docs — iMessage/Slack, Telegram bracket + text-export, bold-paren-time, bold-time-dash, bold-name-no-time, Discord classic + export, WhatsApp ISO + US, Signal, Matrix/Element, IRC classic + weechat, Teams export; module-load validation runs every `test_positive[]` + `test_negative[]` sample at startup so a typo in any built-in regex makes gbrain refuse to start; `DEFAULT_SPEAKER_CLEAN` exported as a module-level default), `parse.ts` (orchestrator with pattern-priority scoring across the first 10 lines + date derivation chain `explicit > frontmatter.date > effective_date > '1970-01-01'` + multi-line continuation + timezone warning), `llm-base.ts` (shared `runLlmCall<T>` with content-hash cache in-process + DB-persistent via migration v97 + 4-strategy JSON repair + Anthropic-key probe; polish and fallback are thin wrappers), `llm-polish.ts` (opt-IN; headroom guard skips when tracker within $0.10 of cap; pure `applyPolish` for merge/drop/edit ops), `llm-fallback.ts` (opt-IN; NO regex inference + NO persistence), `eval.ts` (`scoreFixture` + `aggregateScores` + `parseFixtureJsonl` for the fixture-corpus CI gate), `nightly-probe.ts` (DI-stubbed; mode-gated default tokenmax=ON, conservative/balanced opt-in; adversarial false-positive detection). Pattern `bold-name-no-time` (regex `/^\*\*(?!\[)(.+?):\*\*\s*(.*)$/`, ordered after the time-bearing bold patterns) parses `**Speaker:** text` with NO per-line timestamp (Circleback/Granola/Zoom), anchoring every message at `T00:00:00Z` of the frontmatter date (line order preserves sequence, same no-time convention as `irc-classic`); the `(?!\[)` lookahead rejects telegram-bracket `**[18:37] Name:**`; non-shadow is the colon-INSIDE-bold regex (NOT declaration order — `parse.ts` scores every candidate independently, order is only the tie-break). Because `**Label:** text` is a common prose idiom, the pattern sets optional `PatternEntry.score_full_body: true` so `parse.ts` recomputes the winner's acceptance score over the FULL body before the `SCORING_MIN_ACCEPTANCE` floor, keeping a bold-label notes page at `no_match`. Pattern `bold-paren-time` parses `**Speaker** (HH:MM): text` and `(HH:MM:SS)` (date_source: frontmatter). Fallback gates: `SCORING_HEAD_TRIGGER_THRESHOLD = 0.3` triggers a full-body re-score when the head pass scores below that; `SCORING_MIN_ACCEPTANCE = 0.05` blocks essay false-positives. Exported `scorePatternFull(body, entry)`; private `getNonBlankLines(body, headCap?)` + `scoreFromLines(lines, entry)` DRY the quick_reject+regex loop. CLI surfaces at `src/commands/eval-conversation-parser.ts` (`gbrain eval conversation-parser <fixture.jsonl>` exit 0/1/2, wired into `bun run verify` via `check:conversation-parser`) and `src/commands/conversation-parser.ts` (`scan <slug>` debug, `list-builtins`, `validate <file>`). Doctor checks: `conversation_format_coverage`, `progressive_batch_audit_health`, `conversation_parser_probe_health`. Pinned by `test/conversation-parser/{parse,llm-base,llm-fallback,llm-polish,nightly-probe}.test.ts` + the 27-case baseline at `test/extract-conversation-facts.test.ts` (back-compat invariant). Migration v97 (`conversation_parser_llm_cache_table`). Fixtures at `test/fixtures/conversation-formats/{imessage,imessage-time-only-12h,telegram-bracket,whatsapp-iso,whatsapp-us,signal-export,irc-classic,irc-weechat,matrix-element,teams-export,all,adversarial,bold-name-no-time,bold-time-dash}.jsonl` with `scripts/check-fixture-privacy.sh` banning real-name leaks. Maintainer guidance: [conversation parser patterns](conversation-parser-patterns.md).
- `src/core/progressive-batch/` — shared ramp-up + cost-cap + verification primitive (trial 10 → ramp 100 → ramp 500 → full, with verification at each stage), with verifier+policy injection (callers describe HOW TO MEASURE SUCCESS, not WHEN TO WAIT FOR CTRL-C). Modules: `types.ts` (Stage, StageVerdict, AbortReason, discriminated `Verifier` union `OutputCountVerifier | IdempotentMutationVerifier | NoopVerifier`, Policy, StageReport), `orchestrator.ts` (`runProgressiveBatch(items, verifier, policy, runner)` — reads `getCurrentBudgetTracker()` ahead of `Policy.maxCostUsd` fail-closed; null both ways triggers `abort_cost_cap reason='no_budget_safety_net'`), `audit.ts` (ISO-week JSONL at `~/.gbrain/audit/progressive-batch-YYYY-Www.jsonl` via the shared `audit-writer` primitive), `stage-report.ts` (ASCII formatter for the default `Policy.onStageReport`). Env knobs: `GBRAIN_PROGRESSIVE_BATCH_DISABLED=1`, `GBRAIN_PROGRESSIVE_BATCH_AUTO=1` (skip Ctrl-C grace), `GBRAIN_PROGRESSIVE_BATCH_STAGES=10,100,500`. Sites that "jump straight to full" stay that way by default; ramp is opt-in per-site via `Policy.interactiveAbortMs > 0`. Pinned by `test/progressive-batch/orchestrator.test.ts` (35 cases, every verdict path).
- `src/commands/extract-conversation-facts.ts` + `src/core/cycle/conversation-facts-backfill.ts` — bulk fact extraction for long-form conversation pages. Walks conversation/meeting/slack/email/imessage/imessage-daily pages, splits them into time-windowed segments (30-min gap or 30-msg cap), prepends a topical/temporal header, and uses the strict `extractFactsFromTurnWithOutcome()` path so provider and output failures remain retryable instead of becoming successful empty pages. Invariants: **strict per-source core** (`runExtractConversationFactsCore({sourceId, ...})` always takes one sourceId; CLI + cycle phase each do their own multi-source iteration because `PHASE_SCOPE='source'` is taxonomy-only); **bounded two-phase enumeration** (paginated `listPages({type, sourceId, limit:10})`; per-page body cap `MAX_PAGE_BODY_BYTES=25MB`); **page-global `row_num` accumulator** (the facts unique index is `(source_id, source_markdown_slug, row_num)`); **versioned snapshot-bound outcomes** (`cli:extract-conversation-facts:terminal:v2` for complete pages and a separate `non-extractable:v2` source for recognized pages with no eligible segment); **operation checkpoints are scheduling hints only** and never suppress a replay without a matching v2 outcome; **optional `opts.budgetTracker?`** is used as-is, while an absent tracker is created with `maxCostUsd`; **body reads cover compiled truth, timeline, and configured raw-transcript sidecars**; **`facts.extraction_enabled` kill-switch** with `--override-disabled`; **`--types LIST` allowlist** (`conversation,meeting,slack,email,imessage,imessage-daily`); and **`--background` via `maybeBackground`**. The companion `conversation_facts_backfill` cycle phase is default-off, iterates every source, and enforces per-source plus brain-wide cost and wall-time caps. Migration v94 provides the partial facts index used by outcome lookups. `computeConversationFactsBacklogCheck` reports fresh completed, scanned-not-extractable, and unfinished counts separately, warning when more than 10 eligible pages lack a fresh v2 outcome. `sources audit` exposes `facts_backfill_estimate: {pages, est_segments, est_cost_usd, types}`. Pinned by `test/extract-conversation-facts.test.ts` and `test/doctor-conversation-facts-backlog.test.ts`.
+31
View File
@@ -27,6 +27,31 @@ gbrain migrate embeddings --to voyage:voyage-3-large --yes
declared width and is required for recipes that don't declare one (litellm,
llama-server, and other bring-your-own-model providers).
**Pick `--dim` = your brain's current column width when the target supports
it.** A different width triggers the destructive schema transition (column +
index rebuild across all three dim-pinned tables); the same width skips it
entirely. `gbrain doctor` (check `provider_sunset`, for providers with an
announced shutdown) prints the paste-ready command with your actual width
already filled in — it reads the real `vector(N)` column, not the config
value, which can drift.
## How affected brains find out (provider sunsets)
Two surfaces flag a brain whose embedding model (or reranker) is on a
provider with an announced hosted-API shutdown, such as ZeroEntropy
(2026-09-04):
- **`gbrain doctor`** — the `provider_sunset` check warns on every run until
the brain is off the provider (and fails after the shutdown date, when
retrieval is already down). The message carries the paste-ready migration
command with the brain's actual `--dim`.
- **`gbrain upgrade`** — a one-shot banner (gated by
`ze_sunset_notice_shown`) with the same two fixes.
Both state the full consequence: after the shutdown, **existing vectors
become unqueryable** — query embedding uses the same endpoint as ingestion —
not just new content.
## What it does, in order
1. **Plan.** Counts every chunk not already in the target embedding space —
@@ -88,6 +113,12 @@ continues where it stopped. An in-flight marker (`embedding_migration.state`
in DB config) records the target; it is cleared only when the backlog drains
to zero.
One caveat after a HARD kill (SIGKILL, crash, power loss — not Ctrl-C): the
run's per-source single-flight embed lock is left behind, and an immediate
re-run skips the re-embed and reports the migration as paused. The command
says so explicitly (`lock_skipped` in `--json`); the lock expires on its own
after at most 60 minutes, then the same re-run resumes normally.
A page whose chunks straddle two stale batches is embedded correctly but not
stamped by the embed loop (which only stamps all-or-nothing per batch), so the
migration runs one reconcile pass after the drain that stamps every
+3 -1
View File
@@ -23,7 +23,7 @@ The resolved provider + dimensions get persisted to `~/.gbrain/config.json` atom
| Provider | env vars | default dims | cost ($/1M tokens) | local? | multimodal? |
|---|---|---|---|---|---|
| `zeroentropyai` | `ZEROENTROPY_API_KEY` | 2560 (Matryoshka to 1280/640/320/...) | 0.05 | no | no |
| `zeroentropyai` (hosted API **shuts down 2026-09-04** — see note below) | `ZEROENTROPY_API_KEY` | 2560 (Matryoshka to 1280/640/320/...) | 0.05 | no | no |
| `openai` | `OPENAI_API_KEY` | 1536 | 0.13 | no | no |
| `openrouter` | `OPENROUTER_API_KEY` | 1536 | 0.02 | no | model-dependent |
| `voyage` | `VOYAGE_API_KEY` | 1024 | 0.18 | no | yes (`voyage-multimodal-3`) |
@@ -42,6 +42,8 @@ The resolved provider + dimensions get persisted to `~/.gbrain/config.json` atom
**Note on local providers.** Ollama and llama-server have no required API key, so they don't show up in env-detection auto-pick. Pick them explicitly with `--embedding-model ollama:<model>` to avoid silently routing to a daemon that may not be running.
**Note on the ZeroEntropy hosted API.** ZeroEntropy announced (2026-07-24) that its hosted endpoints shut down on **2026-09-04**. A brain still embedding through the hosted API loses semantic retrieval entirely on that date — query embedding uses the same endpoint, so existing vectors become unqueryable, not just new content. Either self-host the Apache-2.0 zembed-1 weights via llama-server/Ollama (keeps every existing vector, no re-embed), or migrate with `gbrain migrate embeddings` — see [the migration guide](../guides/embedding-migration.md). `gbrain doctor` (check `provider_sunset`) flags affected brains and prints the paste-ready command with the brain's actual `--dim` filled in.
## If first import fails
If `gbrain import` fails with `expected N dimensions, not M`, run `gbrain doctor`. The output will print the exact `gbrain config set ...` or `gbrain retrieval-upgrade` command to repair the mismatch. **You should not need to delete `~/.gbrain`.** The bug-class that historically forced `rm -rf` recoveries is closed as of v0.37.
+83
View File
@@ -2337,6 +2337,84 @@ export async function checkZeEmbeddingHealth(engine: BrainEngine): Promise<Check
}
}
/**
* provider_sunset doctor check (#3390 follow-up).
*
* Detects a brain whose EFFECTIVE embedding model (gateway-resolved, which is
* how default-config brains land on the shipped default) is on a provider
* with an announced hosted-API shutdown, and prints a paste-ready migration
* command with the brain's ACTUAL `content_chunks.embedding` column width
* filled in not the config value, which can drift. Keeping the current
* width avoids a needless dimension transition + index rebuild when the
* target supports it.
*
* Unlike the one-shot upgrade banner (`ze_sunset_notice_shown`), this fires
* on every `gbrain doctor` run until the brain is off the provider
* warn before the shutdown date, fail after it (retrieval is down by then).
* No network call; one catalog query for the column width.
*/
export async function checkProviderSunset(engine: BrainEngine): Promise<Check> {
const name = 'provider_sunset';
try {
const { DEFAULT_EMBEDDING_MODEL, ZEROENTROPY_SUNSET_DATE } = await import('../core/ai/defaults.ts');
// Effective model: gateway when configured (file/env plane, the runtime
// truth); the shipped default otherwise — an unset-config brain resolves
// to the default at runtime, so it is just as affected.
let model = DEFAULT_EMBEDDING_MODEL;
let reranker: string | undefined;
try {
const { getEmbeddingModel, getRerankerModel } = await import('../core/ai/gateway.ts');
model = getEmbeddingModel();
reranker = getRerankerModel();
} catch {
// Gateway unconfigured — runtime resolves the shipped default.
}
const onSunsetEmbedding = model.startsWith('zeroentropyai:');
const onSunsetReranker = !!reranker?.startsWith('zeroentropyai:');
if (!onSunsetEmbedding && !onSunsetReranker) {
return {
name,
status: 'ok',
message: `No configured provider has an announced shutdown (embedding: ${model}).`,
};
}
const past = Date.now() >= Date.parse(`${ZEROENTROPY_SUNSET_DATE}T00:00:00Z`);
const parts: string[] = [];
if (onSunsetEmbedding) {
let dims: number | null = null;
try {
const { readContentChunksEmbeddingDim } = await import('../core/embedding-dim-check.ts');
dims = (await readContentChunksEmbeddingDim(engine)).dims;
} catch {
// Column probe failed (fresh/odd brain) — omit --dim from the hint.
}
const dimFlag = dims ? ` --dim ${dims}` : '';
parts.push(
past
? `embedding_model="${model}": the hosted API shut down on ${ZEROENTROPY_SUNSET_DATE} — semantic retrieval is offline (queries can no longer be embedded against your existing vectors).`
: `embedding_model="${model}": the hosted API shuts down on ${ZEROENTROPY_SUNSET_DATE}. On that date semantic retrieval stops entirely — existing vectors become unqueryable (query embedding uses the same endpoint), not just new content.`,
);
parts.push(
`Two fixes, either works: ` +
`[1] self-host the same model — zembed-1 weights are Apache-2.0; serve them via llama-server or Ollama and point the config at the local endpoint. Keeps every existing vector, no re-embed (docs/guides/embedding-migration.md, "Self-hosting instead of migrating"). ` +
`[2] migrate to another provider (resumable; preview cost first): ` +
`gbrain migrate embeddings --to <provider:model>${dimFlag} --dry-run` +
(dims ? ` — keep --dim ${dims} (this brain's actual index width) to avoid a needless schema rebuild when the target supports it.` : ''),
);
}
if (onSunsetReranker) {
parts.push(
`The reranker (${reranker}) is on the same provider; after the shutdown search falls back to unreranked ordering. ` +
`Fix: gbrain config set search.reranker.enabled false, or point search.reranker.model at another provider.`,
);
}
return { name, status: past ? 'fail' : 'warn', message: parts.join(' ') };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { name, status: 'warn', message: `Could not check provider sunset status: ${msg}` };
}
}
/**
* v0.36.0.0 (A5): embedding_width_consistency doctor check.
*
@@ -7666,6 +7744,11 @@ export async function buildChecks(
// v0.36.0.0 (A5): ZE embedding key health + schema/config width consistency.
progress.heartbeat('ze_embedding_health');
checks.push(await checkZeEmbeddingHealth(engine));
// provider_sunset — brain pinned to a provider with an announced
// hosted-API shutdown; paste-ready migration hint with the actual
// column width. Warn before the date, fail after.
progress.heartbeat('provider_sunset');
checks.push(await checkProviderSunset(engine));
progress.heartbeat('embedding_width_consistency');
checks.push(await checkEmbeddingWidthConsistency(engine));
// v0.41.15.0 (T6, codex #19/#20) — facts.embedding column drift
+9
View File
@@ -148,6 +148,14 @@ export interface EmbedResult {
pages_processed: number;
/** True if this run was a dry-run. */
dryRun: boolean;
/**
* Set when a single-flight run did NO work because another backfill holds
* the per-source embed lock. A hard-killed (SIGKILL/crash) run leaves its
* lock behind for up to EMBED_BACKFILL_LOCK_TTL_MIN — callers that promise
* "re-run to resume" (migrate embeddings) use this to say so instead of
* misreporting embed failures.
*/
lock_skipped?: boolean;
/**
* E1 (paced-backfill): end-of-run pacing telemetry. Present ONLY when pacing
* was active (enabled bundle). The number the operator could not get from an
@@ -318,6 +326,7 @@ export async function runEmbedCore(engine: BrainEngine, opts: EmbedOpts): Promis
try { await h.release(); } catch { /* best-effort */ }
}
serr(` [embed] another backfill is already running for source "${sid}"; skipping (single-flight).`);
result.lock_skipped = true;
return result;
}
sfLocks.push(lock);
+14 -1
View File
@@ -389,7 +389,20 @@ export async function runMigrateEmbeddings(
exit(0);
} else {
if (flags.json) {
console.log(JSON.stringify({ status: 'incomplete', plan, embedded: embedResult.embedded, remaining }, null, 2));
console.log(JSON.stringify({
status: 'incomplete', plan, embedded: embedResult.embedded, remaining,
...(embedResult.lock_skipped && { lock_skipped: true }),
}, null, 2));
} else if (embedResult.lock_skipped) {
// E2E-observed failure mode: a hard-killed (SIGKILL/crash) migration
// leaves its single-flight embed lock behind, and every immediate
// re-run "resumes" without embedding anything. Say so — "re-run to
// resume" would be a lie until the lock expires.
const { EMBED_BACKFILL_LOCK_TTL_MIN } = await import('../core/embed-backfill-lock.ts');
serr(`Migration paused: ${remaining} chunk(s) still stale, and the re-embed was SKIPPED because`);
serr('another embed backfill holds the per-source lock. If that is a live run (check');
serr('`gbrain jobs list`), let it finish. If a previous migration was killed hard, its lock');
serr(`expires after at most ${EMBED_BACKFILL_LOCK_TTL_MIN} minutes — re-run the same command then.`);
} else {
serr(`Migration incomplete: ${remaining} chunk(s) still stale (embed failures or an interrupted run).`);
serr('Re-run the same command to resume — completed chunks are never re-embedded.');
+31 -10
View File
@@ -472,19 +472,29 @@ export async function runPostUpgrade(args: string[] = []): Promise<void> {
// `ze_sunset_notice_shown` (same pattern as the search-mode banner).
try {
const shown = await engine.getConfig('ze_sunset_notice_shown');
const { DEFAULT_EMBEDDING_MODEL } = await import('../core/ai/defaults.ts');
const { DEFAULT_EMBEDDING_MODEL, ZEROENTROPY_SUNSET_DATE } = await import('../core/ai/defaults.ts');
const effectiveModel = cfgSchema.embedding_model ?? DEFAULT_EMBEDDING_MODEL;
const rerankerModel = await engine.getConfig('search.reranker.model');
const onZeEmbedding = effectiveModel.startsWith('zeroentropyai:');
const onZeReranker = !!rerankerModel?.startsWith('zeroentropyai:');
if (shown !== 'true' && (onZeEmbedding || onZeReranker)) {
// Paste-ready --dim from the ACTUAL column width (config can
// drift): keeping the current width avoids a needless dimension
// transition + index rebuild when the target supports it.
let colDims: number | null = null;
try {
const { readContentChunksEmbeddingDim } = await import('../core/embedding-dim-check.ts');
colDims = (await readContentChunksEmbeddingDim(engine)).dims;
} catch { /* fresh brain — omit --dim */ }
const dimFlag = colDims ? ` --dim ${colDims}` : '';
console.log('');
console.log('═══════════════════════════════════════════════════════════════');
console.log('[gbrain] ACTION REQUIRED: ZeroEntropy hosted API sunsets 2026-09-04.');
console.log(`[gbrain] ACTION REQUIRED: ZeroEntropy hosted API sunsets ${ZEROENTROPY_SUNSET_DATE}.`);
if (onZeEmbedding) {
console.log(`[gbrain] This brain embeds with ${effectiveModel}. After the sunset,`);
console.log('[gbrain] semantic retrieval STOPS WORKING (queries can no longer be');
console.log('[gbrain] embedded against your existing vectors).');
console.log('[gbrain] semantic retrieval STOPS WORKING entirely — your EXISTING');
console.log('[gbrain] vectors become unqueryable (queries embed through the same');
console.log('[gbrain] endpoint), not just new content.');
}
if (onZeReranker) {
console.log(`[gbrain] The reranker (${rerankerModel}) also sunsets; search falls`);
@@ -492,17 +502,28 @@ export async function runPostUpgrade(args: string[] = []): Promise<void> {
}
console.log('═══════════════════════════════════════════════════════════════');
console.log('');
console.log('Migrate before the sunset (resumable; preview cost first):');
console.log(' gbrain migrate embeddings --to <provider:model> --dry-run');
console.log(' gbrain migrate embeddings --to <provider:model>');
console.log('Two fixes, either works:');
console.log('');
console.log('Self-hosting zembed-1 (weights are Apache-2.0) via llama-server /');
console.log('ollama also works and preserves your existing vectors — point');
console.log('embedding at the local endpoint instead of migrating.');
console.log('[1] Self-host the same model — zembed-1 weights are Apache-2.0. Serve');
console.log(' them via llama-server or Ollama and point the config at the local');
console.log(' endpoint. Keeps every existing vector; NO re-embed at all. See');
console.log(' docs/guides/embedding-migration.md ("Self-hosting instead of migrating").');
console.log('');
console.log('[2] Migrate to another provider (resumable; preview cost first):');
console.log(` gbrain migrate embeddings --to <provider:model>${dimFlag} --dry-run`);
console.log(` gbrain migrate embeddings --to <provider:model>${dimFlag}`);
if (colDims) {
console.log(` (--dim ${colDims} is this brain's current index width — keep it to`);
console.log(' avoid a needless schema rebuild when the target supports it.)');
}
if (onZeReranker) {
console.log('');
console.log('Reranker: gbrain config set search.reranker.enabled false (or pick another).');
}
console.log('');
console.log(`\`gbrain doctor\` will keep flagging this until the brain is off the`);
console.log('provider (check name: provider_sunset).');
console.log('');
await engine.setConfig('ze_sunset_notice_shown', 'true');
}
} catch {
+11
View File
@@ -19,3 +19,14 @@
// steps: {2560, 1280, 640, 320, 160, 80, 40} — see ai/dims.ts.
export const DEFAULT_EMBEDDING_MODEL = 'zeroentropyai:zembed-1';
export const DEFAULT_EMBEDDING_DIMENSIONS = 1280;
/**
* ZeroEntropy announced (2026-07-24) that its hosted API — including
* /models/embed and /models/rerank — shuts down on this date. Query
* embedding uses the same endpoint as ingestion, so a brain still on a
* `zeroentropyai:*` embedding model loses semantic retrieval ENTIRELY on
* that date (existing vectors become unqueryable, not just new content).
* Single source of truth for the upgrade banner + the `provider_sunset`
* doctor check. Self-hosting the Apache-2.0 zembed-1 weights is unaffected.
*/
export const ZEROENTROPY_SUNSET_DATE = '2026-09-04';
+1
View File
@@ -151,6 +151,7 @@ export const OPS_CHECK_NAMES: ReadonlySet<string> = new Set([
'pgvector',
'pool_budget',
'progressive_batch_audit_health',
'provider_sunset',
'queue_health',
'reranker_health',
'rls',
+170
View File
@@ -0,0 +1,170 @@
/**
* provider_sunset doctor check + embed lock-skip visibility (#3390 follow-up).
*
* A brain whose effective embedding model is on a provider with an announced
* hosted-API shutdown must be flagged on EVERY `gbrain doctor` run (the
* upgrade banner is one-shot), with a paste-ready `gbrain migrate embeddings`
* command whose `--dim` is the brain's ACTUAL column width — not the config
* value, which can drift. Getting `--dim` wrong forces a needless dimension
* transition + index rebuild.
*
* Also pins `EmbedResult.lock_skipped`: a single-flight embed run that did no
* work because another backfill holds the per-source lock says so, instead of
* letting `migrate embeddings` misreport "embed failures — re-run to resume"
* (a hard-killed run leaves its lock behind for up to the lock TTL, so the
* immediate re-run would no-op).
*
* The first test goes through the `buildChecks` orchestrator (exists on
* master) so the assertion is behavioral: master's doctor simply never
* surfaces the sunset, and the test fails on the missing check — not on a
* missing export.
*
* `.serial.test.ts`: configures the process-global gateway + GBRAIN_HOME for
* its whole lifecycle.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
import { buildChecks } from '../src/commands/doctor.ts';
import { runEmbedCore } from '../src/commands/embed.ts';
import { tryAcquireDbLock, type DbLockHandle } from '../src/core/db-lock.ts';
import { embedBackfillLockId } from '../src/core/embed-backfill-lock.ts';
let engine: PGLiteEngine;
let tmpHome: string;
let savedHome: string | undefined;
beforeAll(async () => {
savedHome = process.env.GBRAIN_HOME;
tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-sunset-'));
process.env.GBRAIN_HOME = tmpHome;
// Gateway BEFORE initSchema — the schema sizes the embedding column from
// the configured dims (same order as migrate-embeddings-flow.serial).
configureGateway({
embedding_model: 'zeroentropyai:zembed-1',
embedding_dimensions: 1280,
env: { OPENAI_API_KEY: 'sk-test-fake', ZEROENTROPY_API_KEY: 'ze-test-fake' },
});
engine = new PGLiteEngine();
await engine.connect({ embedding_dimensions: 1280 } as never);
await engine.initSchema(); // content_chunks.embedding at the shipped-default 1280 width
});
afterAll(async () => {
resetGateway();
await engine.disconnect();
if (savedHome !== undefined) process.env.GBRAIN_HOME = savedHome;
else delete process.env.GBRAIN_HOME;
rmSync(tmpHome, { recursive: true, force: true });
});
/** The check may legitimately be warn (pre-date) or fail (post-date). */
const FLAGGED = ['warn', 'fail'];
describe('provider_sunset — doctor flags brains pinned to a sunsetting provider', () => {
test('buildChecks surfaces the sunset for a zembed-1 brain, with the actual --dim filled in', async () => {
configureGateway({
embedding_model: 'zeroentropyai:zembed-1',
embedding_dimensions: 1280,
env: { OPENAI_API_KEY: 'sk-test-fake', ZEROENTROPY_API_KEY: 'ze-test-fake' },
});
const checks = await buildChecks(engine, []);
const sunset = checks.find((c) => c.name === 'provider_sunset');
expect(sunset, 'doctor never surfaced the provider sunset').toBeDefined();
expect(FLAGGED).toContain(sunset!.status);
// The date and BOTH consequences must be stated plainly.
expect(sunset!.message).toContain('2026-09-04');
expect(sunset!.message.toLowerCase()).toContain('existing vectors');
// Paste-ready migration command with the brain's ACTUAL column width.
expect(sunset!.message).toContain('gbrain migrate embeddings --to <provider:model> --dim 1280');
// The self-host escape hatch gets equal billing (no migration at all).
expect(sunset!.message).toContain('Apache-2.0');
});
test('the ACTUAL column width wins over drifted config', async () => {
const { checkProviderSunset } = await import('../src/commands/doctor.ts');
// Simulate config/schema drift: column rebuilt at 640, config still 1280.
await engine.executeRaw(`ALTER TABLE content_chunks DROP COLUMN IF EXISTS embedding`);
await engine.executeRaw(`ALTER TABLE content_chunks ADD COLUMN embedding vector(640)`);
try {
configureGateway({
embedding_model: 'zeroentropyai:zembed-1',
embedding_dimensions: 1280,
env: { OPENAI_API_KEY: 'sk-test-fake', ZEROENTROPY_API_KEY: 'ze-test-fake' },
});
const check = await checkProviderSunset(engine);
expect(FLAGGED).toContain(check.status);
expect(check.message).toContain('--dim 640');
expect(check.message).not.toContain('--dim 1280');
} finally {
await engine.executeRaw(`ALTER TABLE content_chunks DROP COLUMN IF EXISTS embedding`);
await engine.executeRaw(`ALTER TABLE content_chunks ADD COLUMN embedding vector(1280)`);
}
});
test('non-sunsetting provider → ok', async () => {
const { checkProviderSunset } = await import('../src/commands/doctor.ts');
configureGateway({
embedding_model: 'openai:text-embedding-3-small',
embedding_dimensions: 1536,
env: { OPENAI_API_KEY: 'sk-test-fake', ZEROENTROPY_API_KEY: 'ze-test-fake' },
});
const check = await checkProviderSunset(engine);
expect(check.status).toBe('ok');
});
test('reranker on the sunsetting provider is flagged even when embeddings are elsewhere', async () => {
const { checkProviderSunset } = await import('../src/commands/doctor.ts');
configureGateway({
embedding_model: 'openai:text-embedding-3-small',
embedding_dimensions: 1536,
reranker_model: 'zeroentropyai:zerank-2',
env: { OPENAI_API_KEY: 'sk-test-fake', ZEROENTROPY_API_KEY: 'ze-test-fake' },
});
const check = await checkProviderSunset(engine);
expect(FLAGGED).toContain(check.status);
expect(check.message).toContain('zerank-2');
expect(check.message).toContain('search.reranker');
// Embeddings are safe — no migration command in this message.
expect(check.message).not.toContain('migrate embeddings');
});
});
describe('EmbedResult.lock_skipped — single-flight bail is observable', () => {
test('a held per-source lock makes runEmbedCore report lock_skipped', async () => {
// Dims match the column (1280) so the embed dim preflight passes; the
// fake key satisfies the credential preflight (no embed call happens —
// the lock bail fires before any work).
configureGateway({
embedding_model: 'openai:text-embedding-3-small',
embedding_dimensions: 1280,
env: { OPENAI_API_KEY: 'sk-test-fake', ZEROENTROPY_API_KEY: 'ze-test-fake' },
});
const sources = await engine.listAllSources();
const ids = sources.length > 0 ? sources.map((s) => s.id) : ['default'];
const locks: DbLockHandle[] = [];
for (const sid of ids) {
const lock = await tryAcquireDbLock(engine, embedBackfillLockId(sid), 60);
expect(lock).not.toBeNull();
locks.push(lock!);
}
try {
const result = await runEmbedCore(engine, { stale: true, singleFlight: true, quiet: true });
expect(result.embedded).toBe(0);
// Behavioral pin: master returns the same zero-work result WITHOUT the
// flag, so `migrate embeddings` can't tell "lock held" from "embed
// failures" and prints a resume hint that a re-run cannot honor.
expect(result.lock_skipped).toBe(true);
} finally {
for (const l of locks) await l.release();
}
});
test('no lock contention → lock_skipped is not set', async () => {
const result = await runEmbedCore(engine, { stale: true, singleFlight: true, quiet: true });
expect(result.lock_skipped).toBeFalsy();
});
});