v0.42.41.0 fix: triage wave — 6 data-loss/availability fixes + 9 community PRs (#2128)

* fix(oauth): default omitted authorize scope to client's full grant

When a client omits `scope` on /authorize, the authorize() grant computed
`(params.scopes || []).filter(...)` → the empty set. That empty grant was
written to oauth_codes and propagated into the access AND refresh tokens, so
every request failed `insufficient_scope` even though the client was
registered with e.g. `read write`. Because refresh inherits the stored grant,
it never self-healed — reconnecting just minted another empty-scoped token.

Some MCP connectors (observed with Claude Desktop) omit `scope` on /authorize,
so they hit this on every connection.

Fix: when no scope is requested, default to the client's full registered scope
(RFC 6749 §3.3 permits a server default). This mirrors exchangeClientCredentials,
which already does `requestedScope ? ... : allowedScopes`. The result is still
clamped to the allowed set, so an explicit over-broad request cannot escalate.

Adds test/oauth-authorize-scope-default.test.ts covering: omitted/empty →
inherits full grant; explicit subset honored; clamp preserved (over-broad and
disallowed-only requests cannot escalate or trigger inheritance).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sync): skip Python venv/ in the code walker

collectSyncableFiles (first-sync walker) and the incremental PRUNE_DIR_NAMES
set skipped node_modules but not Python venv/. On a Python repo the walker
descended into venv/ (thousands of files); the resulting slug collisions
crashed putPage's INSERT ... ON CONFLICT ... RETURNING with
"undefined is not an object (evaluating 'row.deleted_at')".

Add `venv` alongside node_modules in both the import.ts inline skip and
PRUNE_DIR_NAMES. venv is the Python equivalent of node_modules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gateway): carry asymmetric input_type across the AI SDK to the wire body (#1400)

dimsProviderOptions() threads input_type ('query' | 'document') into
providerOptions.openaiCompatible for asymmetric models (ZE zembed-1,
Voyage v3+), but the AI SDK's openai-compatible adapter validates
providerOptions against a fixed schema and silently drops the field
before building the HTTP body. Every embedQuery() was therefore encoded
document-side: the ZE shim's hard default fired ('document'), Voyage and
local openai-compat servers got no input_type at all, and asymmetric
retrieval silently collapsed toward surface-token overlap — while the
providerOptions-level contract test stayed green.

Fix: an AsyncLocalStorage (same pattern as __budgetStore) populated in
embedSubBatch() only when providerOptions actually threads an
input_type, read at body-rewrite time by the fetch shims:
- zeroEntropyCompatFetch: recovers the threaded value; document default
  preserved for ingest paths.
- voyageCompatFetch: opt-in like the dims.ts Voyage branch — inject only
  when threaded; the field stays off the wire otherwise.
- NEW openAICompatAsymmetricFetch: fallthrough default for every other
  openai-compatible recipe (llama-server, litellm, ollama, ...) — the
  canonical local/proxy paths for asymmetric models. Strict pass-through
  when nothing was threaded, so symmetric deployments see zero wire
  change; recipes with their own compat fetch (azure) keep it via the
  compat.fetch ?? precedence.

KNOBS_HASH_VERSION bumped 10→11: cached query_cache rows were keyed on
document-side query vectors; pre-fix rows must not be served to post-fix
lookups (same convention as the v=3 embedding-provider bump). One-time
global cold-miss on upgrade; refills within cache.ttl_seconds.

Tests: test/embed-input-type-wire.test.ts runs the REAL SDK transport
with a mocked global fetch and asserts on the outbound body — the only
layer where this regression is observable. Covers ZE hosted, llama-server,
litellm, ollama (query + document sides) and pins the pass-through for
non-asymmetric models and Voyage's opt-in shape. 4 of the original 7
assertions fail on master, proving the pin. One structural pin in
test/ai/zeroentropy-compat-fetch.test.ts updated to the new line shape
(same semantic); KEY_FILES.md gateway.ts entry updated to the new truth.

Supersedes #1400 (closed unmerged) — same ALS mechanism, extended to
Voyage + all openai-compatible recipes. Credit to @billy-armstrong for
the original diagnosis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sync): honor .gitignore in code walk; prune vendor/dist/build

collectSyncableFiles (the full-sync / dry-run enumerator) reimplemented its
own directory skip list inline (node_modules || ops), bypassing the canonical
pruneDir gate and ignoring .gitignore entirely. On a Laravel/PHP repo this
descended into vendor/ (~50k Composer files), storage/, and public/build/,
trying to import 52k dependency/build files and flooding the index with
library internals (a 35-min sync that never finished, killed by the watchdog
at 3%).

- collectSyncableFiles now enumerates via `git ls-files --cached --others
  --exclude-standard` when dir is a git work tree, so the walk honors
  .gitignore (tracked + untracked-not-ignored). Falls back to the FS walk for
  non-git dirs. EroLab: 52164 -> 1028 files.
- The FS fallback now prunes through the canonical pruneDir() instead of a
  drifted inline list, so the two skip lists can't diverge again.
- PRUNE_DIR_NAMES gains vendor/dist/build (dependency + build-output trees).

Addresses #1483 (.gbrainignore), #1159 (--respect-gitignore), and the
maintainer's #1942 vendor/dist/build prune. Walker regression suites
(sync-walker-symlink, brain-writer-walk-prune, sync, sync-walker-submodule)
green: 90 pass.

* fix(config): ignore DATABASE_URL auto-loaded from cwd .env (#427)

Bun merges .env files from the process cwd into process.env before any
user code runs. loadConfig() prefers env DATABASE_URL over
~/.gbrain/config.json, so any gbrain invocation from inside a web-app
checkout silently retargets the brain at that app's database — reads go
to the wrong DB and apply-migrations can write gbrain's schema into a
production app database (#427).

effectiveEnvDatabaseUrl() re-parses the .env files Bun auto-loads from
cwd and treats a DATABASE_URL whose value matches one of them as
file-origin: ignored, with a one-time stderr notice. GBRAIN_DATABASE_URL
and genuinely exported DATABASE_URLs are honored unchanged, so the
operator escape hatch and the e2e suite's env-provided URL keep working.
Applied at loadConfig, getDbUrlSource (doctor parity), init
--non-interactive, and migrate --to.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli): arm the disconnect hard-deadline at teardown entry, not before the op body

The 10s force-exit timer in the shared-op dispatch was armed BEFORE the
try block, so any op whose handler ran past 10s wall-clock was killed
mid-flight with process.exit(0) and zero stdout. On a slow Postgres
pooler (6-10s per fresh connection) a healthy `gbrain search` was
force-exited every time — an empty 'success' indistinguishable from no
results. The v0.42.20.0 exitCode honor can't help: a mid-op kill fires
before any error path sets exitCode.

Move the arming into the finally (teardown entry), matching the
fall-through owner-disconnect site later in main(): the timer still
bounds a hung drain/disconnect (the C13 contract) but can no longer
kill a slow-but-progressing op. Verified on a transaction-pooler
Supabase brain: search went from 0 bytes/exit 0 at 10s to real results
at ~21s.

* fix(import): stamp source_id on extracted call-graph edges

importCodeFile built CodeEdgeInput rows without source_id, so every
edge landed NULL. getCallersOf/getCalleesOf filter
`AND source_id = <scoped>` whenever a worktree pin or --source is in
play — NULL never matches, so scoped call-graph queries silently
returned 0 rows on multi-source brains even though the edges existed
(2,122 edges, 26 targeting the probed symbol, count 0 returned).

One-line fix: carry the sourceId already in scope into the edge input.
Existing NULL rows backfill with:
  UPDATE code_edges_symbol e SET source_id = p.source_id
    FROM content_chunks c JOIN pages p ON p.id = c.page_id
   WHERE c.id = e.from_chunk_id AND e.source_id IS NULL;
(same for code_edges_chunk). Verified: code-callers returns 21 callers
where it returned 0.

* docs(migrations): NULL embeddings BEFORE the column-type alter

The Postgres recipe ordered ALTER COLUMN TYPE vector(N) before the
UPDATE that clears stale embeddings. pgvector refuses to cast existing
vectors across dimensions ('expected 1024 dimensions, not 1536'), so
the recipe as written aborts the transaction on any brain that has
embeddings — which is every brain doing this migration. Swap the steps:
NULLs cast fine.

* fix: honor legacy token source grants in oauth

* fix(cli): bound read-scope op handlers at 180s wallclock (pre-landing review)

With the hard-deadline timer correctly scoped to teardown, a genuinely
wedged read handler (hung pooler connection mid-query) would hang the
CLI forever — the #1633 zombie class the old pre-try timer accidentally
bounded at 10s. Reads now get a generous withTimeout (180s default, far
above any healthy slow-pooler run; --timeout=Ns overrides; exit 124 with
the teardown finally still draining + disconnecting). Writes/admin stay
unbounded: a long import/embed must never be killed by a default.

* fix(import): stamp unscoped edges 'default', matching the pages-table default

Review catch: 'sourceId ?? null' fixed the scoped path but left the
unscoped one (reindex --code without --source, importCodeFile callers
without opts.sourceId) stranding edges at NULL while their pages land
under the schema default (pages.source_id DEFAULT 'default') — so
getCallersOf(sym, { sourceId: 'default' }) missed them. Same bug,
other door. Fallback is now 'default'.

* fix(core): runtime dim-migration recipe NULLs embeddings before the alter

Review catch: the doc fix corrected docs/embedding-migrations.md, but
embeddingMismatchMessage still PRINTED the broken order — ALTER before
UPDATE ... SET embedding = NULL — and linked to the now-contradicting
doc. pgvector refuses to cast existing vectors across dimensions, so
the printed recipe aborted on any brain that has embeddings. Swap the
steps and say why inline.

* feat(migrate): v116 — backfill NULL edge source_id + index from_symbol_qualified

1. Backfill: edges written before the stamping fix sit at source_id=NULL
   and stay invisible to scoped call-graph queries until repaired. Derive
   each edge's source from its own from_chunk's page (pages.source_id is
   NOT NULL DEFAULT 'default'). Same SQL verified live on a 2,122-edge
   production brain.
2. Indexes: getCalleesOf filters both edge tables on from_symbol_qualified,
   which had no index — every callee lookup was a seq scan, amplified
   per-BFS-node by the recursive code walk. With NULL edges repaired,
   scoped walks actually expand, so the latent cost becomes real.
   Mirrored into src/schema.sql; schema-embedded.ts regenerated.

* docs(migrations): align the rationale list with the corrected recipe order

The 'Why we don't do this automatically' list still said alter-then-wipe;
reorder to wipe-then-alter and replace the fragile 'step 3' numeric
cross-reference with a name-based one.

* test: regression coverage for edge source_id stamping, timer placement, recipe order

- import-code-edges-source-id: scoped import stamps edges + scoped
  getCallersOf/getCalleesOf match (verified failing pre-fix), plus the
  unscoped-import case asserting 'default' stamping.
- cli-force-exit-teardown-arming: structural pin — the hard-deadline
  timer arms inside the finally (teardown entry), never before the op
  body; daemon guard, unref, clearTimeout intact.
- embedding-dim-check: recipe order pinned — UPDATE precedes ALTER so
  the printed SQL can't drift from docs/embedding-migrations.md again.

* fix(cli): hard-exit after teardown on wallclock timeout; bound makeContext too

Adversarial review, two findings on the new timeout path:
1. On timeout the finally drained, disconnected, then CLEARED the
   hard-deadline timer — removing the only backstop while the abandoned
   handler (withTimeout races, it does not cancel) can hold ref'd
   sockets/SDK timers that keep Bun's loop alive: 'timed out' printed,
   process immortal — the zombie class this branch exists to kill,
   resurrected through its own fix. The finally now exits explicitly
   after teardown completes on the timeout path.
2. makeContext does DB I/O (resolveSourceId) for EVERY op and sat
   outside any bound — a pooler wedge at context build hung reads,
   writes, and admin alike. It now shares the same wallclock bound.

* fix(import): normalize edge source once — closes the '' door and the unscoped chunk fan-out

Adversarial review: txOpts used truthiness while the edge stamp used
nullish — sourceId:'' put pages under 'default' but stamped edges '',
FK-violating against sources(id) and silently dropping the file's whole
call graph in the best-effort catch. The unscoped getChunks could also
fan out to same-slug chunks from another source. One normalized
edgeSourceId (sourceId || 'default') now drives both the chunk lookup
and the stamp.

* fix(engine): default edge source_id to 'default' at the insert layer (both engines)

Adversarial review: addCodeEdges still wrote e.source_id ?? null, so any
future caller that forgets the field reintroduces invisible NULL edges
the day after the v116 backfill runs. A NULL source_id is invisible to
every scoped call-graph query; default to the schema-default source the
way the pages table does. Applied to both engines (parity).

* fix(core): facts alter recipe NULLs embeddings before cross-dimension alters

Adversarial review: buildFactsAlterRecipe shipped the same defect class
this branch fixes for content_chunks 350 lines up — a cross-dimension
ALTER ... USING cast that pgvector refuses while rows hold old-width
vectors. Dimension changes now wipe first (the facts pipeline re-embeds
on next write); same-dim type swaps (halfvec <-> vector) keep the
lossless cast and PRESERVE data. Both behaviors pinned by tests.

* v0.42.39.0 chore: version bump + CHANGELOG + TODOS

Marks the v0.42.20.0 'decouple the op-dispatch force-exit timer' follow-up
complete — this branch ships exactly that decoupling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(postgres-engine): atomic JSONB merge in updateSourceConfig — eliminate lost-update race

## Problem

`updateSourceConfig` used a read-then-write pattern: read the current
`config` row, normalize it in JavaScript, then write the merged result
back with `SET config = <normalized> || <patch>`.

Under concurrent callers (two background autopilot/cycle paths patching
different keys simultaneously), both callers can read the same stale
row. The later `SET config = ...` then clobbers the earlier patch,
silently dropping whatever keys the first caller wrote. Reproduced
at 21/25 lost-update events under real Postgres with parallel callers.

## Fix

Fold the normalization and merge into a single atomic `UPDATE … SET
config = CASE … END || patch` statement. Because the `SET` expression
evaluates against the row-locked latest version of `config`, there is
no snapshot window between the read and the write. Concurrent callers
now converge correctly (50/50 clean in reproduction test).

The `CASE` also normalizes historical bad JSONB shapes inline:
- `object` — used as-is
- `string` — double-encoded config; inner text parsed with the SQL
  `IS JSON` guard (Postgres 16+) so unparseable strings fall back to
  `{}` instead of raising `invalid input syntax for type json`
- `array` — array of patch objects aggregated into a flat object via
  `jsonb_object_agg`
- anything else — falls back to `{}`

`pglite-engine.updateSourceConfig` already used an atomic `||` merge;
this change brings postgres-engine to parity.

## Test

Added two assertions to `test/list-all-sources.test.ts`:
1. JSONB string holding non-JSON text normalizes to `{}` (no cast throw)
2. JSONB string holding double-encoded valid JSON is parsed then merged

* fix(doctor): five correctness fixes — stale locks, content sanity, graph coverage, exit code, gateway guard

## 1. Stale lock break hints cover gbrain-cycle: keys

The doctor stale-lock report only recognized `gbrain-sync:` lock prefixes;
everything else fell back to `gbrain sync --break-lock`, which is wrong for
dream/autopilot cycle locks. A `gbrain-cycle:<source>` or `gbrain-cycle`
lock now suggests `gbrain dream --break-lock [--source <name>]`, and
unknown lock shapes fall back to `gbrain doctor` instead of a
misleading sync command.

## 2. content_sanity_audit_recent counts reject and quarantine as hard failures

v0.42 renamed the hard disposition path: rejected pages emit a `reject`
event and quarantined junk pages emit `quarantine`; `hard_block` is now
only the pre-v0.42 legacy alias. The status check only counted `hard_block`,
so fresh `reject` / `quarantine` events from the new path cleared as `ok`
whenever fewer than 10 events existed. The check now sums all three for the
hard count, and `soft_block + flag` for the soft count.

## 3. graph_coverage excludes test fixture entity pages from the denominator

Brains seeded with code sources (e.g. a sync of the gbrain repo itself)
could accumulate test fixture pages typed as `entity` / `person`. Including
these in the entity-count denominator diluted coverage and produced spurious
warnings ("Entity link coverage 0%, timeline 0%") on knowledge-only brains
with no real entity pages. The check now queries a per-entity stats CTE that
excludes `tools/gbrain/test/*` slugs and the `templates/new-person` stub,
with an additional guard for the all-fixture case (`eligibleEntityCount = 0`).

## 4. process.exitCode instead of process.exit at doctor main exit point

`process.exit(hasFail ? 1 : 0)` was a hard kill that prevented cleanup
handlers (Bun unload events, open DB connections) from running. Using
`process.exitCode = hasFail ? 1 : 0` defers the actual termination until
the end of the event loop, allowing cleanup to complete.

## 5. checkSubagentCapability exported for test seams + gateway loop guard

The function was private, making it untestable in isolation. It is now
exported. Additionally, users running gbrain with a non-Anthropic chat model
via `agent.use_gateway_loop=true` no longer receive a spurious warning that
`ANTHROPIC_API_KEY` is missing — subagents route via the gateway loop in
that configuration and do not need the key directly.

## Tests

Doctor test suite: 77 pass, 0 fail (no regressions).

* fix(engine): deleteFactsForPage excludeSourcePrefixes (#1928) + reconnect() parity (#2034)

Engine-layer API for two cycle/availability fixes that share these files:
- deleteFactsForPage gains optional excludeSourcePrefixes so the fence
  reconcile can protect non-fence facts (e.g. cli: conversation facts).
- reconnect(ctx?) is now a first-class BrainEngine method on both engines
  (PostgresEngine already had it; PGLite gains config capture + reconnect)
  so callers stop using disconnect()+bare connect().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cycle): stop extract_facts from wiping conversation facts (#1928)

The fence reconcile delete-then-reinsert wiped cli:-origin facts (no fence to
recreate them); a failed-sync full walk turned it brain-wide (1829 rows, 0
reinserted, status ok). Now: exclude cli: rows from the wipe, do NOT inherit
the failed-sync->full-walk fallback for this destructive phase, and warn on
net-negative reconcile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(autopilot,supervisor): reconnect() instead of disconnect()+bare connect() (#2034)

The autopilot health-probe recovery called connect() with no args after
disconnect(), losing the startup config (database_url undefined -> FATAL
restart-loop on every DB blip) and opening a null-pool window. Both call sites
now use engine.reconnect(), which restores the captured config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(write-through): mirror to the assigned source's local_path, never the global repo (#2018)

put_page write-through resolved the disk target from the global sync.repo_path,
so a default-source page (local_path NULL) got written into an unrelated
federated source's working tree. Now it uses the assigned source's own
local_path; NULL local_path skips (no leak); the global path is used only as a
sole-source fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pglite-lock): heartbeat + steal-grace so live holders are never stolen (#2058)

A live holder's lock was force-removed after 5min age alone, letting a second
process share the single-writer data dir -> WAL corruption. The lock now
heartbeats while held; a holder is reaped only when its PID is dead OR its
heartbeat went stale past the steal grace. Pairs PID liveness with heartbeat
age to also defeat PID reuse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migrate,doctor): self-heal idx_timeline_dedup drift (#2038)

A migration renumbered during a merge (v102) could be recorded-as-applied
without its DDL running, leaving the 3-column index so every timeline write
failed the 4-column ON CONFLICT. runMigrations now always runs a shape-keyed
drift repair (dedupe-then-rebuild) even when no migration is pending, and
doctor surfaces the drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(timeline): un-silence the swallowed batch catch; pin Date-batch round-trip (#2057)

The meetings extractor's bare catch {} hid a brain-wide timeline-write failure
(0 entries, no error). It now counts + surfaces batch errors. Adds a Date-bearing
batch regression test proving the #1861 jsonb_to_recordset refactor already
fixed the original ::text[] cast failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: bump version and changelog (v0.42.41.0)

Triage fix wave: 6 authored critical fixes (#1928 facts wipe, #2018
write-through leak, #2034 reconnect loop, #2058 WAL lock, #2038 timeline
migration drift, #2057 timeline silent-empty) + community PRs #2064 #2052
#2020 #2033 #2074 #2075 #2009 #2072 #2073. TODOS: deferred #1994 #1963 #2050.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: address adversarial review findings (#1928, #2058, #2038, #2057)

Codex as-built review of the authored fixes surfaced 4 real issues:
- #2058: add a pid+acquired_at ownership token. A stale holder reaped + replaced
  past the grace must NOT let its resumed heartbeat refresh, nor releaseLock
  remove, the NEW owner's lock (re-opened the concurrent-writer hole). Heartbeat
  and release now verify the on-disk lock is still ours. + regression test.
- #1928: the destructive-full-walk guard keyed off phases.includes('sync'),
  which wrongly suppressed a legitimate full reconcile when sync was SKIPPED
  (no engine / no brainDir). Key off a syncAttempted flag set only when sync
  actually ran.
- #2038: dedupe keeps MIN(id) not MIN(ctid) — deterministic and consistent with
  the existing v-migration lower-id rule.
- #2057: the extract CLI caller now surfaces batch_errors (stderr + exit 1)
  instead of printing a clean success over failed inserts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(key-files): sync reference to v0.42.41.0 triage-wave behavior

Update KEY_FILES.md to current-state truth for the shipped fixes (no
release-history clauses, per the reference-doc discipline):

- write-through.ts (#2018): resolves the disk target from the assigned
  source's own local_path; sole-source falls back to sync.repo_path,
  multi-source skips with source_has_no_local_path rather than leak.
- engine.ts (#2034): reconnect() is now a REQUIRED lifecycle method on
  both engines; config-restoring, never disconnect()+bare connect().
- migrate.ts (#2073): document v116 edge source_id backfill + callee
  index, and the always-run (version-counter-blind) timeline dedup
  self-heal.
- new entry for timeline-dedup-repair.ts (#2038) + the
  timeline_dedup_index doctor check.
- new entry for pglite-lock.ts (#2058): heartbeat + steal-grace
  (GBRAIN_PGLITE_LOCK_STEAL_GRACE_SECONDS) so a live holder is never
  stolen.
- extract-facts.ts (#1928): cli:-fact protection, no failed-sync
  full-walk inheritance, net_fact_deletion warn floor.

bun run build:llms re-run (KEY_FILES is link-only so bundles unchanged);
freshness + current-state guards green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(write-through): preserve nested multi-source layout; narrow #2018 leak guard

The first #2018 fix skipped any no-local_path source on a multi-source brain,
which broke the legitimate nested layout (a source without its own tree nests
under the host repo at .sources/<id>/ — pinned by put-page-write-through.test).
Narrow the guard: a no-local_path source nests under sync.repo_path as before;
only SKIP when sync.repo_path is literally another source's own local_path
(the actual leak — writing there pollutes that sibling's repo). Caught by the
sharded suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: satisfy test-isolation guard for the new lock/reconnect tests

CI `verify` flagged 3 intra-process isolation violations in the tests added
this wave (the parallel runner shares one process per shard):
- pglite-lock.test.ts: the GBRAIN_PGLITE_LOCK_STEAL_GRACE_SECONDS mutation now
  goes through withEnv() instead of a raw process.env write (R1).
- pglite-reconnect: renamed to *.serial.test.ts — it creates per-test engines
  to exercise the connect/reconnect lifecycle, which doesn't fit the shared
  beforeAll-engine model (R3/R4).
verify is now 30/30; both files green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pglite): reconnect() is a no-op for in-memory engines (#2034)

CI serial-tests + test(5) caught two in-branch regressions from the #2034
PGLite reconnect():
- worker/queue claim-error recovery + their renewLock e2e test assume PGLite
  reconnect is absent/no-op (queue.ts documents it). Making it a real
  disconnect+reopen wiped an in-memory engine's state mid-job. reconnect() now
  no-ops for in-memory (no database_path) — file-backed still re-opens the dir
  (state persists on disk). Restores the documented worker assumption.
- connection-resilience 'Supervisor still has the 3-strikes-then-reconnect
  path' pinned the removed unsafe-cast text; updated to assert the direct
  this.engine.reconnect() call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: quarantine embed-input-type-wire to serial lane (CI test(5) leak)

#2033's embed-input-type-wire.test.ts configures a 1280-dim embedding gateway;
the active dimension survived into engine-find-trajectory when CI's 10-way
hash-disjoint sharding co-located them (this branch's added files reshuffled the
assignment), failing 7 trajectory tests with 'expected 1280 dimensions, not
1536'. resetGateway() in afterEach clears the gateway but the dimension still
leaked. It mutates global gateway/embedding state, so it belongs in the serial
lane (own bun process, true isolation) by the repo's own definition. Root-caused
by reproducing the exact failing pair locally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Austin Arnett <austin@sdsconsultinggroup.org>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Dave MacDonald <djmacdonald@ucdavis.edu>
Co-authored-by: pabloglzg <186649799+pabloglzg@users.noreply.github.com>
Co-authored-by: Alex P. <12667893+aphaiboon@users.noreply.github.com>
Co-authored-by: Garry Tan <bo.m.liu@gmail.com>
Co-authored-by: jbarol <barol.j@gmail.com>
Co-authored-by: maxpetrusenkoagent <max.petrusenko.agent@gmail.com>
Co-authored-by: PAI <pai@scaffolde.ai>
This commit is contained in:
Garry Tan
2026-06-12 06:05:34 -07:00
committed by GitHub
co-authored by Austin Arnett Claude Opus 4.8 Dave MacDonald pabloglzg Alex P. Garry Tan jbarol maxpetrusenkoagent PAI
parent ecd6ae8772
commit 7c27fa129b
57 changed files with 2571 additions and 202 deletions
+7 -5
View File
@@ -53,13 +53,15 @@ describe('zeroEntropyCompatFetch — shim structural shape', () => {
expect(src).not.toContain('/v1/v1/');
});
test('body injects input_type default "document"', async () => {
test('body recovers threaded input_type, defaulting to "document"', async () => {
const src = await Bun.file(GATEWAY_PATH).text();
// The wrapper defaults input_type to 'document' when caller didn't
// thread one (matches the document-side correctness for sync /
// import / embed CLI paths).
// The wrapper recovers the SDK-stripped input_type from
// __embedInputTypeStore (#1400) and still defaults to 'document' when
// no caller threaded one (document-side correctness for sync / import /
// embed CLI paths). Wire-level behavioral coverage lives in
// test/embed-input-type-wire.test.ts.
expect(src).toMatch(/parsed\.input_type\s*===\s*undefined/);
expect(src).toMatch(/parsed\.input_type\s*=\s*['"]document['"]/);
expect(src).toMatch(/parsed\.input_type\s*=\s*__embedInputTypeStore\.getStore\(\)\s*\?\?\s*['"]document['"]/);
});
test('body forces encoding_format=float (CDX2-F2)', async () => {
@@ -0,0 +1,56 @@
/**
* Structural regression — the DISCONNECT_HARD_DEADLINE_MS force-exit timer in
* cli.ts main() must be armed at TEARDOWN ENTRY (inside the finally, before
* the drain + disconnect), never before the op-dispatch try block.
*
* Pre-fix bug: the 10s unref'd setTimeout was armed BEFORE the try, so any op
* whose handler ran past 10s wall-clock was killed mid-flight with
* process.exit(0) and ZERO stdout — an empty "success" indistinguishable from
* no results (a healthy `gbrain search` on a slow Postgres pooler hit this on
* every run). Armed in the finally, the timer still bounds a hung
* drain/disconnect (the C13 contract) but can no longer kill a
* slow-but-progressing op body.
*
* Source-grep is the right tool here (same rationale as
* fix-wave-structural.test.ts): the rule is "this arming must stay at this
* location". A behavioral test would need >10s of real wall-clock plus a
* deliberately slow op handler in a spawned CLI — slow and flaky by
* construction.
*/
import { describe, test, expect } from 'bun:test';
import { readFileSync } from 'fs';
describe('cli.ts — disconnect hard-deadline armed at teardown entry, not before the op body', () => {
test('forceExitTimer setTimeout lives inside the finally, gated on the daemon guard, before the drain', () => {
const src = readFileSync('src/cli.ts', 'utf8');
const decl = src.indexOf('const DISCONNECT_HARD_DEADLINE_MS');
expect(decl).toBeGreaterThan(-1);
const tryIdx = src.indexOf('try {', decl);
expect(tryIdx).toBeGreaterThan(-1);
const finallyIdx = src.indexOf('} finally {', tryIdx);
expect(finallyIdx).toBeGreaterThan(-1);
const armIdx = src.indexOf('forceExitTimer = setTimeout', decl);
expect(armIdx).toBeGreaterThan(-1);
const drainIdx = src.indexOf('drainAllBackgroundWorkForCliExit', finallyIdx);
expect(drainIdx).toBeGreaterThan(-1);
// NO arming between the deadline declaration and the op-body try — a
// pre-try timer kills slow-but-progressing op handlers mid-flight with
// exit 0 and empty stdout. (`setTimeout(` matches only a call site; the
// `ReturnType<typeof setTimeout>` type annotation stays allowed.)
expect(src.slice(decl, tryIdx)).not.toContain('setTimeout(');
// The arming sits AFTER the finally opens (teardown entry) and BEFORE the
// drain + disconnect it exists to bound.
expect(armIdx).toBeGreaterThan(finallyIdx);
expect(armIdx).toBeLessThan(drainIdx);
// Still gated on the daemon-survival guard so `serve` stays alive, and
// still unref'd + cleared on clean teardown.
expect(src.slice(finallyIdx, drainIdx)).toMatch(/if \(shouldForceExitAfterMain\(\)\)/);
expect(src.slice(finallyIdx, drainIdx)).toContain('forceExitTimer.unref?.()');
expect(src.slice(drainIdx)).toContain('if (forceExitTimer) clearTimeout(forceExitTimer)');
});
});
+144
View File
@@ -0,0 +1,144 @@
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { describe, expect, test } from 'bun:test';
import { dotenvValuesForKey, effectiveEnvDatabaseUrl } from '../src/core/config.ts';
import { withEnv } from './helpers/with-env.ts';
// #427 — Bun auto-loads `.env` from the process cwd, so running gbrain inside
// any web-app checkout whose `.env` defines DATABASE_URL silently retargets
// the brain at that app's database. These tests pin the guard:
// effectiveEnvDatabaseUrl() must treat a DATABASE_URL whose value matches a
// cwd .env assignment as file-origin (ignored), while still honoring
// GBRAIN_DATABASE_URL and genuinely exported DATABASE_URLs.
//
// The dir is injected instead of process.chdir'd so these tests stay safe in
// the parallel shard runner (cwd is process-global, same reason with-env.ts
// exists for process.env).
function tmpProject(envFiles: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-env-hijack-'));
for (const [name, content] of Object.entries(envFiles)) {
writeFileSync(join(dir, name), content);
}
return dir;
}
describe('dotenvValuesForKey', () => {
test('collects assignments across all auto-loaded .env variants', () => {
const dir = tmpProject({
'.env': 'DATABASE_URL=postgres://app:pw@prod.example.test:5432/app\n',
'.env.local': "DATABASE_URL='postgres://app:pw@local.example.test:5432/app'\n",
});
try {
const values = dotenvValuesForKey('DATABASE_URL', dir);
expect(values.has('postgres://app:pw@prod.example.test:5432/app')).toBe(true);
expect(values.has('postgres://app:pw@local.example.test:5432/app')).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('handles export prefix, double quotes, comments, and unrelated keys', () => {
const dir = tmpProject({
'.env': [
'# comment line',
'export DATABASE_URL="postgres://quoted.example.test/db"',
'OTHER_KEY=not-a-db-url',
'EMPTY=',
'',
].join('\n'),
});
try {
const values = dotenvValuesForKey('DATABASE_URL', dir);
expect(values.has('postgres://quoted.example.test/db')).toBe(true);
expect(values.size).toBe(1);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('returns empty set when no .env files exist', () => {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-env-hijack-none-'));
try {
expect(dotenvValuesForKey('DATABASE_URL', dir).size).toBe(0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe('effectiveEnvDatabaseUrl (#427 guard)', () => {
const HIJACK_URL = 'postgres://app:pw@victim-prod.example.test:5432/app';
const OPERATOR_URL = 'postgres://operator@deliberate.example.test:5432/brain';
test('ignores DATABASE_URL whose value matches a cwd .env assignment', async () => {
const dir = tmpProject({ '.env': `DATABASE_URL=${HIJACK_URL}\n` });
try {
await withEnv(
{ GBRAIN_DATABASE_URL: undefined, DATABASE_URL: HIJACK_URL },
() => {
expect(effectiveEnvDatabaseUrl(dir)).toBeUndefined();
},
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('honors an exported DATABASE_URL that differs from the cwd .env', async () => {
const dir = tmpProject({ '.env': `DATABASE_URL=${HIJACK_URL}\n` });
try {
await withEnv(
{ GBRAIN_DATABASE_URL: undefined, DATABASE_URL: OPERATOR_URL },
() => {
expect(effectiveEnvDatabaseUrl(dir)).toBe(OPERATOR_URL);
},
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('GBRAIN_DATABASE_URL is never ignored, even when it matches the cwd .env', async () => {
const dir = tmpProject({ '.env': `GBRAIN_DATABASE_URL=${OPERATOR_URL}\nDATABASE_URL=${HIJACK_URL}\n` });
try {
await withEnv(
{ GBRAIN_DATABASE_URL: OPERATOR_URL, DATABASE_URL: HIJACK_URL },
() => {
expect(effectiveEnvDatabaseUrl(dir)).toBe(OPERATOR_URL);
},
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('honors DATABASE_URL when no .env files exist in cwd', async () => {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-env-hijack-clean-'));
try {
await withEnv(
{ GBRAIN_DATABASE_URL: undefined, DATABASE_URL: OPERATOR_URL },
() => {
expect(effectiveEnvDatabaseUrl(dir)).toBe(OPERATOR_URL);
},
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('returns undefined when neither env var is set', async () => {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-env-hijack-unset-'));
try {
await withEnv(
{ GBRAIN_DATABASE_URL: undefined, DATABASE_URL: undefined },
() => {
expect(effectiveEnvDatabaseUrl(dir)).toBeUndefined();
},
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+4 -2
View File
@@ -317,8 +317,10 @@ describe('Eng-review D3 — executeRaw has no per-call retry wrapper', () => {
it('Supervisor still has the 3-strikes-then-reconnect path', () => {
const src = readFileSync(resolve('src/core/minions/supervisor.ts'), 'utf-8');
expect(src).toContain('consecutiveHealthFailures');
// Supervisor invokes reconnect via a typed cast after 3 consecutive failures.
expect(src).toMatch(/reconnect\(\): Promise<void>/);
// #2034: reconnect() is now a first-class BrainEngine method, so the
// supervisor calls it directly after 3 consecutive failures (the prior
// `(engine as unknown as { reconnect(): Promise<void> })` cast was removed).
expect(src).toContain('this.engine.reconnect(');
expect(src).toContain('this.consecutiveHealthFailures >= 3');
});
});
+5 -3
View File
@@ -136,15 +136,17 @@ describe('D2 — knobsHash differs across cross-modal knob values', () => {
return resolveSearchMode({ mode: 'balanced' });
}
test('KNOBS_HASH_VERSION is 10 (cross-modal still appended; 9→10 relational recall)', () => {
test('KNOBS_HASH_VERSION is 11 (cross-modal still appended; 10→11 asymmetric input_type fix)', () => {
// v0.35 ladder: 1→2 reranker, 2→3 floor_ratio. v0.36 piggybacks on v=3
// with 7 cross-modal knobs + column/provider context. v0.40.4 (salem) +
// v0.39 T21 (master) bump to v=4 for graph_signals + schema-pack fields.
// v0.40.3.0 D8 bumps to v=5 (sequenced behind salem's v=4 graph-signals).
// v0.41.22.0 (type-unification): 5→6 for alias_resolved post-fusion boost.
// T2: 6→7 title_boost. v0.42.3.0: 7→8 autocut. issue #1777: 8→9 archive/ demote.
// v0.43: 9→10 relational recall arm.
expect(KNOBS_HASH_VERSION).toBe(10);
// v0.43: 9→10 relational recall arm. #1400: 10→11 query-side input_type
// finally reaches asymmetric providers — pre-fix rows were keyed on
// document-side query vectors.
expect(KNOBS_HASH_VERSION).toBe(11);
});
test('flipping unified_multimodal changes the hash', () => {
+247
View File
@@ -0,0 +1,247 @@
/**
* #1400 — asymmetric `input_type` must survive the AI SDK boundary and
* reach the WIRE BODY.
*
* test/asymmetric-encoding-contract.test.ts pins that embedQuery() threads
* `input_type: 'query'` into the transport's providerOptions. This file
* pins the layer BELOW that contract: the AI SDK's openai-compatible
* adapter validates providerOptions against a fixed schema and silently
* drops `input_type` before building the HTTP body. Without the
* `__embedInputTypeStore` recovery in the per-recipe fetch shims, every
* query was encoded document-side (ZE shim's hard default) or with no
* input_type at all (Voyage, llama-server) — asymmetric retrieval silently
* collapsed while the providerOptions-level test stayed green.
*
* These tests run the REAL SDK transport with a mocked global fetch and
* assert on the outbound request body — the only place the regression is
* observable.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { configureGateway, embed, embedQuery, resetGateway } from '../src/core/ai/gateway.ts';
type FetchHandler = (url: string, init: RequestInit) => Promise<Response>;
let fetchHandler: FetchHandler | null = null;
const origFetch = globalThis.fetch;
beforeEach(() => {
fetchHandler = null;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
if (!fetchHandler) {
throw new Error('fetch called but no handler installed');
}
return fetchHandler(typeof url === 'string' ? url : url.toString(), init ?? {});
}) as typeof fetch;
});
afterEach(() => {
globalThis.fetch = origFetch;
resetGateway();
});
/** OpenAI-shaped /v1/embeddings response (llama-server is already OpenAI-shaped). */
function openAIShapedResponse(dims: number, count: number): Response {
const vec = Array.from({ length: dims }, () => 0.1);
return new Response(
JSON.stringify({
data: Array.from({ length: count }, (_, i) => ({ object: 'embedding', index: i, embedding: vec })),
usage: { prompt_tokens: 3, total_tokens: 3 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
/** ZE-shaped /v1/models/embed response (zeroEntropyCompatFetch rewrites results→data). */
function zeShapedResponse(dims: number, count: number): Response {
const vec = Array.from({ length: dims }, () => 0.1);
return new Response(
JSON.stringify({
results: Array.from({ length: count }, () => ({ embedding: vec })),
usage: { total_bytes: 12, total_tokens: 3 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
/** Voyage-shaped response: base64 Float32 LE embeddings (rewriter decodes). */
function voyageShapedResponse(dims: number, count: number): Response {
const b64 = Buffer.from(new Float32Array(dims).fill(0.1).buffer).toString('base64');
return new Response(
JSON.stringify({
data: Array.from({ length: count }, (_, i) => ({ object: 'embedding', index: i, embedding: b64 })),
usage: { total_tokens: 3 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
describe('ZeroEntropy hosted — input_type reaches the wire body', () => {
function configureZE() {
configureGateway({
embedding_model: 'zeroentropyai:zembed-1',
embedding_dimensions: 1280,
env: { ZEROENTROPY_API_KEY: 'sk-fake' },
});
}
test('embedQuery sends input_type=query (not the document default)', async () => {
configureZE();
let capturedUrl = '';
let capturedBody: any = null;
fetchHandler = async (url, init) => {
capturedUrl = url;
capturedBody = JSON.parse(init.body as string);
return zeShapedResponse(1280, 1);
};
await embedQuery('what does foo bar do?');
// Sanity: the ZE shim ran (URL path rewritten).
expect(capturedUrl).toContain('/models/embed');
expect(capturedBody.input_type).toBe('query');
});
test('embed (index path) sends input_type=document', async () => {
configureZE();
let capturedBody: any = null;
fetchHandler = async (_url, init) => {
capturedBody = JSON.parse(init.body as string);
return zeShapedResponse(1280, 1);
};
await embed(['this is a document being indexed']);
expect(capturedBody.input_type).toBe('document');
});
});
describe('openai-compatible recipes (local/proxy asymmetric models) — input_type reaches the wire body', () => {
function configureLlamaServer(modelId: string, dims: number) {
configureGateway({
embedding_model: `llama-server:${modelId}`,
embedding_dimensions: dims,
env: {},
});
}
test('embedQuery against a local zembed-1 sends input_type=query', async () => {
configureLlamaServer('zembed-1', 1280);
let capturedUrl = '';
let capturedBody: any = null;
fetchHandler = async (url, init) => {
capturedUrl = url;
capturedBody = JSON.parse(init.body as string);
return openAIShapedResponse(1280, 1);
};
await embedQuery('what does foo bar do?');
// URL untouched — llama-server's /v1/embeddings is already OpenAI-shaped.
expect(capturedUrl).toContain('/embeddings');
expect(capturedUrl).not.toContain('/models/embed');
expect(capturedBody.input_type).toBe('query');
});
test('embed (index path) against a local zembed-1 sends input_type=document', async () => {
configureLlamaServer('zembed-1', 1280);
let capturedBody: any = null;
fetchHandler = async (_url, init) => {
capturedBody = JSON.parse(init.body as string);
return openAIShapedResponse(1280, 1);
};
await embed(['this is a document being indexed']);
expect(capturedBody.input_type).toBe('document');
});
test('non-asymmetric model: wire body carries NO input_type (strict pass-through)', async () => {
// dims.ts only threads input_type for recognized asymmetric models;
// for anything else the shim must leave the body untouched so vanilla
// llama-server deployments see zero wire change.
configureLlamaServer('my-gguf', 768);
let capturedBody: any = null;
fetchHandler = async (_url, init) => {
capturedBody = JSON.parse(init.body as string);
return openAIShapedResponse(768, 1);
};
await embedQuery('hello');
expect(capturedBody).not.toBeNull();
expect('input_type' in capturedBody).toBe(false);
});
test('litellm proxying an asymmetric model: embedQuery sends input_type=query', async () => {
// The shim is the fallthrough default for every openai-compatible
// recipe without its own compat fetch — dims.ts threads input_type by
// model id, so a zembed-1 behind a LiteLLM proxy (e.g. fronting vLLM)
// gets the same signal as llama-server.
configureGateway({
embedding_model: 'litellm:zembed-1',
embedding_dimensions: 1280,
env: { LITELLM_API_KEY: 'sk-fake' },
base_urls: { litellm: 'http://localhost:4000' },
});
let capturedBody: any = null;
fetchHandler = async (_url, init) => {
capturedBody = JSON.parse(init.body as string);
return openAIShapedResponse(1280, 1);
};
await embedQuery('what does foo bar do?');
expect(capturedBody.input_type).toBe('query');
});
test('ollama serving an asymmetric model: embedQuery sends input_type=query', async () => {
configureGateway({
embedding_model: 'ollama:zembed-1',
embedding_dimensions: 1280,
env: {},
});
let capturedBody: any = null;
fetchHandler = async (_url, init) => {
capturedBody = JSON.parse(init.body as string);
return openAIShapedResponse(1280, 1);
};
await embedQuery('what does foo bar do?');
expect(capturedBody.input_type).toBe('query');
});
});
describe('Voyage hosted — input_type reaches the wire body (opt-in preserved)', () => {
function configureVoyage() {
configureGateway({
embedding_model: 'voyage:voyage-3-large',
embedding_dimensions: 1024,
env: { VOYAGE_API_KEY: 'sk-fake' },
});
}
test('embedQuery sends input_type=query', async () => {
configureVoyage();
let capturedBody: any = null;
fetchHandler = async (_url, init) => {
capturedBody = JSON.parse(init.body as string);
return voyageShapedResponse(1024, 1);
};
await embedQuery('what does foo bar do?');
expect(capturedBody.input_type).toBe('query');
// Existing voyage translation still applies on the same body.
expect(capturedBody.output_dimension).toBe(1024);
expect(capturedBody.encoding_format).toBe('base64');
});
test('embed (index path) keeps input_type OFF the wire (pre-v0.35.0.0 opt-in shape)', async () => {
// dims.ts deliberately emits no input_type for Voyage unless threaded
// (`...(inputType ? { input_type: inputType } : {})`); the shim must
// not invent a default for it.
configureVoyage();
let capturedBody: any = null;
fetchHandler = async (_url, init) => {
capturedBody = JSON.parse(init.body as string);
return voyageShapedResponse(1024, 1);
};
await embed(['this is a document being indexed']);
expect(capturedBody).not.toBeNull();
expect('input_type' in capturedBody).toBe(false);
});
});
+21
View File
@@ -142,6 +142,27 @@ describe('buildFactsAlterRecipe', () => {
const recipe = buildFactsAlterRecipe(1536, 1280, 'halfvec');
expect(recipe).toMatch(/DROP INDEX[\s\S]*ALTER TABLE[\s\S]*CREATE INDEX/);
});
test('dimension change NULLs embeddings BEFORE the alter (pgvector refuses cross-dim casts)', () => {
// Same defect class fixed for content_chunks in embeddingMismatchMessage:
// pgvector aborts a cross-dimension ALTER while rows still hold old-width
// vectors. The dims-change recipe must wipe first; order pinned.
const recipe = buildFactsAlterRecipe(1536, 1280, 'halfvec');
const nullIdx = recipe.indexOf('UPDATE facts SET embedding = NULL');
const alterIdx = recipe.indexOf('ALTER TABLE facts ALTER COLUMN embedding TYPE');
expect(nullIdx).toBeGreaterThan(-1);
expect(alterIdx).toBeGreaterThan(-1);
expect(nullIdx).toBeLessThan(alterIdx);
});
test('same-dim type swap PRESERVES embeddings (no NULL wipe)', () => {
// halfvec(1536) <-> vector(1536): the USING cast is lossless and the
// whole point is keeping the data. A wipe here would destroy valid
// embeddings for no reason.
const recipe = buildFactsAlterRecipe(1536, 1536, 'vector');
expect(recipe).not.toContain('UPDATE facts SET embedding = NULL');
expect(recipe).toContain('USING embedding::vector(1536)');
});
});
describe('FactsEmbeddingDimMismatchError', () => {
+20
View File
@@ -103,6 +103,26 @@ describe('embeddingMismatchMessage', () => {
expect(msg).toContain('docs/embedding-migrations.md');
});
test('Postgres recipe NULLs embeddings BEFORE the column alter (pgvector refuses cross-dim casts)', () => {
// pgvector aborts `ALTER COLUMN TYPE vector(N)` with "expected N
// dimensions, not M" while rows still hold old-width vectors — which is
// every brain running this recipe. The UPDATE must precede the ALTER
// (NULLs cast fine). Order pinned so the printed recipe can't drift from
// the corrected docs/embedding-migrations.md again.
const msg = embeddingMismatchMessage({
currentDims: 1536,
requestedDims: 768,
requestedModel: 'nomic-embed-text',
source: 'init',
engineKind: 'postgres',
});
const nullIdx = msg.indexOf('UPDATE content_chunks SET embedding = NULL');
const alterIdx = msg.indexOf('ALTER TABLE content_chunks ALTER COLUMN embedding TYPE vector(768)');
expect(nullIdx).toBeGreaterThan(-1);
expect(alterIdx).toBeGreaterThan(-1);
expect(nullIdx).toBeLessThan(alterIdx);
});
test('Postgres branch skips HNSW recreate when requested dims exceed pgvector cap', () => {
// Codex finding #8: 2048d (Voyage 4 Large) cannot be HNSW-indexed in pgvector.
// The recipe must NOT instruct a CREATE INDEX HNSW for that dim.
+78
View File
@@ -0,0 +1,78 @@
/**
* #1928 regression — extract_facts must NOT wipe conversation facts.
*
* `extract-conversation-facts` writes facts with source='cli:...' and
* source_markdown_slug=<transcript slug>. Those pages carry no `## Facts`
* fence, so the cycle's wipe-and-reinsert reconcile (deleteFactsForPage +
* insertFactsBatch) used to delete them and reinsert nothing — a brain-wide
* conversation-facts wipe on a failed-sync full walk (status `ok`, 0
* inserted, 1829 rows gone in the original report).
*
* The fix scopes the cycle delete with excludeSourcePrefixes: ['cli:'].
* These tests pin: (a) the exclusion protects cli:-origin rows while still
* deleting fence-owned rows on the same page coordinate, and (b) the default
* (no opts) behavior is unchanged — every fact on the coordinate is deleted.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
async function seedPageWithMixedFacts(slug: string, sourceId: string) {
await engine.insertFacts(
[
// Fence-owned row (what extract_facts recreates from the page fence).
{ fact: 'fence fact', kind: 'fact', source: 'fence', row_num: 1, source_markdown_slug: slug },
// Empty-source row — also fence-default; must stay deletable.
{ fact: 'blank-source fact', kind: 'fact', source: '', row_num: 2, source_markdown_slug: slug },
// Conversation fact — NOT fence-owned; must survive the reconcile.
{ fact: 'conversation fact', kind: 'fact', source: 'cli:extract-conversation-facts', row_num: 3, source_markdown_slug: slug },
],
{ source_id: sourceId },
);
}
async function factSourcesOnPage(slug: string, sourceId: string): Promise<string[]> {
const rows = await engine.executeRaw<{ source: string }>(
`SELECT COALESCE(source, '') AS source FROM facts
WHERE source_id = $1 AND source_markdown_slug = $2 ORDER BY row_num`,
[sourceId, slug],
);
return rows.map(r => r.source);
}
describe('#1928 deleteFactsForPage excludeSourcePrefixes', () => {
test('protects cli:-origin facts, still deletes fence + empty-source rows', async () => {
await seedPageWithMixedFacts('transcripts/2026-06-01', 'default');
expect((await factSourcesOnPage('transcripts/2026-06-01', 'default')).length).toBe(3);
const { deleted } = await engine.deleteFactsForPage('transcripts/2026-06-01', 'default', {
excludeSourcePrefixes: ['cli:'],
});
expect(deleted).toBe(2); // fence + blank-source removed
const survivors = await factSourcesOnPage('transcripts/2026-06-01', 'default');
expect(survivors).toEqual(['cli:extract-conversation-facts']);
});
test('default behavior (no opts) deletes every fact on the coordinate', async () => {
await seedPageWithMixedFacts('transcripts/2026-06-02', 'default');
expect((await factSourcesOnPage('transcripts/2026-06-02', 'default')).length).toBe(3);
const { deleted } = await engine.deleteFactsForPage('transcripts/2026-06-02', 'default');
expect(deleted).toBe(3);
expect((await factSourcesOnPage('transcripts/2026-06-02', 'default')).length).toBe(0);
});
});
+112
View File
@@ -0,0 +1,112 @@
/**
* Regression — importCodeFile stamps source_id on extracted call-graph edges.
*
* Pre-fix bug: importCodeFile built CodeEdgeInput rows WITHOUT source_id, so
* every extracted edge landed NULL in code_edges_symbol. getCallersOf /
* getCalleesOf add `AND source_id = <scoped>` whenever a worktree pin or
* --source is in play — NULL never matches that filter, so scoped call-graph
* queries silently returned 0 rows on multi-source brains even though the
* edges existed. Pre-existing coverage (cathedral-ii-brainbench.test.ts,
* code-edges.test.ts) only ever queried with { allSources: true }, which
* bypasses the filter — exactly why the NULL never surfaced.
*
* This test imports a caller/callee pair under a non-default source and
* asserts (a) the persisted code_edges_symbol rows carry the source_id, and
* (b) the SCOPED getCallersOf/getCalleesOf — the user-visible path that
* returned 0 — now find the edge, while a different source scope does not.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { importCodeFile } from '../src/core/import-file.ts';
import { runSources } from '../src/commands/sources.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
await runSources(engine, ['add', 'testsrc', '--no-federated']);
// Same fixture shape as cathedral-ii-brainbench: runner() calls helper(),
// Layer 5 edge extraction captures the unresolved 'calls' edge — but here
// the import is pinned to a non-default source.
await importCodeFile(
engine,
'src/a.ts',
'export function runner() { return helper(); }\n',
{ noEmbed: true, sourceId: 'testsrc' },
);
await importCodeFile(
engine,
'src/b.ts',
'export function helper() { return 42; }\n',
{ noEmbed: true, sourceId: 'testsrc' },
);
}, 60_000);
afterAll(async () => {
if (engine) await engine.disconnect();
}, 30_000);
describe('importCodeFile — source_id stamped on extracted call-graph edges', () => {
test('edges land with the import source_id and scoped caller/callee queries match', async () => {
// (a) The persisted unresolved edge rows carry the source, not NULL.
const rows = await engine.executeRaw<{ source_id: string | null }>(
`SELECT source_id FROM code_edges_symbol WHERE from_symbol_qualified = $1`,
['runner'],
);
expect(rows.length).toBeGreaterThanOrEqual(1);
for (const r of rows) {
expect(r.source_id).toBe('testsrc');
}
// (b) The scoped queries — the path that silently returned 0 pre-fix.
const callers = await engine.getCallersOf('helper', { sourceId: 'testsrc' });
const fromRunner = callers.find(r => r.from_symbol_qualified === 'runner');
expect(fromRunner).toBeDefined();
expect(fromRunner!.edge_type).toBe('calls');
expect(fromRunner!.source_id).toBe('testsrc');
const callees = await engine.getCalleesOf('runner', { sourceId: 'testsrc' });
expect(callees.some(r => r.to_symbol_qualified === 'helper')).toBe(true);
// Source isolation still holds: a different scope must NOT see the edge.
const otherScope = await engine.getCallersOf('helper', { sourceId: 'default' });
expect(otherScope.find(r => r.from_symbol_qualified === 'runner')).toBeUndefined();
});
test('UNSCOPED import stamps edges with the schema-default source, not NULL', async () => {
// The other door of the same bug: an import WITHOUT opts.sourceId (legacy
// unscoped callers — `gbrain reindex --code` with no --source) lands its
// pages under the schema default (pages.source_id DEFAULT 'default').
// If its edges were stamped NULL, the matching scoped query
// getCallersOf(sym, { sourceId: 'default' }) — a worktree pinned to
// default, --source default, GBRAIN_SOURCE=default — would miss them.
await importCodeFile(
engine,
'src/c.ts',
'export function unscopedRunner() { return unscopedHelper(); }\n',
{ noEmbed: true },
);
await importCodeFile(
engine,
'src/d.ts',
'export function unscopedHelper() { return 7; }\n',
{ noEmbed: true },
);
const rows = await engine.executeRaw<{ source_id: string | null }>(
`SELECT source_id FROM code_edges_symbol WHERE from_symbol_qualified = $1`,
['unscopedRunner'],
);
expect(rows.length).toBeGreaterThanOrEqual(1);
for (const r of rows) {
expect(r.source_id).toBe('default');
}
const callers = await engine.getCallersOf('unscopedHelper', { sourceId: 'default' });
expect(callers.some(r => r.from_symbol_qualified === 'unscopedRunner')).toBe(true);
});
});
+47
View File
@@ -135,4 +135,51 @@ describe('engine.updateSourceConfig', () => {
const all = await engine.listAllSources();
expect(all.find(s => s.id === 'delta')!.config.last_full_cycle_at).toBe('2026-05-22T11:00:00.000Z');
});
// IS JSON guard: the postgres-engine atomic merge gates its `::jsonb` cast
// behind the SQL `IS JSON` predicate so a historical bad row whose config is
// a JSONB string of NON-JSON text normalizes to `{}` instead of raising
// `invalid input syntax for type json` and aborting the UPDATE.
//
// We exercise the SQL CASE expression directly via executeRaw against PGLite
// (which ships Postgres 17 + IS JSON parity) rather than calling
// PostgresEngine.updateSourceConfig (which requires a live Postgres pool).
test('IS-JSON guard: non-JSON string config normalizes to {} on merge', async () => {
const patch = { merged_key: 'v' };
const guarded = (configExpr: string) => `
SELECT (
CASE
WHEN jsonb_typeof(${configExpr}) = 'object' THEN ${configExpr}
WHEN jsonb_typeof(${configExpr}) = 'string'
THEN CASE
WHEN (${configExpr} #>> '{}') IS JSON
THEN COALESCE(NULLIF((${configExpr} #>> '{}'), '')::jsonb, '{}'::jsonb)
ELSE '{}'::jsonb
END
WHEN jsonb_typeof(${configExpr}) = 'array'
THEN COALESCE(
(SELECT jsonb_object_agg(kv.key, kv.value)
FROM jsonb_array_elements(${configExpr}) elem,
jsonb_each(elem) kv),
'{}'::jsonb
)
ELSE '{}'::jsonb
END || $1::jsonb
) AS result`;
// 1. JSONB string holding NON-JSON text → normalizes to {} then merges patch.
const bad = await engine.executeRaw<{ result: Record<string, unknown> }>(
guarded(`to_jsonb('garbage text'::text)`),
[JSON.stringify(patch)],
);
expect(bad[0].result).toEqual({ merged_key: 'v' });
// 2. JSONB string holding double-encoded valid JSON object → parsed + merged.
const good = await engine.executeRaw<{ result: Record<string, unknown> }>(
guarded(`to_jsonb('{"x":1}'::text)`),
[JSON.stringify(patch)],
);
expect(good[0].result).toEqual({ x: 1, merged_key: 'v' });
});
});
@@ -0,0 +1,89 @@
/**
* Authorize-grant scope default (RFC 6749 §3.3).
*
* When a client omits `scope` on /authorize, the granted scope must default to
* the client's full registered scope — NOT the empty set. Regression guard for
* the bug where an omitted request granted [], which then propagated into the
* access + refresh tokens and never self-healed: every op failed
* `insufficient_scope` even though the client was registered `read write`
* (some MCP connectors omit `scope` on /authorize). The clamp must still hold —
* an explicit over-broad request cannot escalate past the client's allowed set.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { GBrainOAuthProvider } from '../src/core/oauth-provider.ts';
import { sqlQueryForEngine } from '../src/core/sql-query.ts';
let engine: PGLiteEngine;
let provider: GBrainOAuthProvider;
let sql: ReturnType<typeof sqlQueryForEngine>;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
sql = sqlQueryForEngine(engine);
provider = new GBrainOAuthProvider({ sql });
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await (engine as any).db.exec('DELETE FROM oauth_tokens');
await (engine as any).db.exec('DELETE FROM oauth_codes');
await (engine as any).db.exec('DELETE FROM oauth_clients');
});
// authorize() writes the granted scope into oauth_codes then redirects; we
// assert on the stored grant directly, so the redirect is a no-op.
const noopRes = { redirect() {} } as any;
async function authorizeAndReadScopes(
scope: string,
requested: string[] | undefined,
): Promise<string[]> {
const reg = await provider.registerClientManual(
'authz-test', ['authorization_code'], scope, ['https://example.test/cb'],
);
const client = await provider.clientsStore.getClient(reg.clientId);
expect(client).toBeTruthy();
await provider.authorize(
client!,
{
scopes: requested,
codeChallenge: 'test-challenge',
redirectUri: 'https://example.test/cb',
state: 'xyz',
} as any,
noopRes,
);
const rows = (await sql`
SELECT scopes FROM oauth_codes WHERE client_id = ${reg.clientId}
`) as Array<{ scopes: string[] }>;
expect(rows.length).toBe(1);
return rows[0].scopes ?? [];
}
describe('authorize() scope default — omitted scope inherits client grant', () => {
test('omitted scope → inherits full registered scope', async () => {
expect((await authorizeAndReadScopes('read write', undefined)).sort()).toEqual(['read', 'write']);
});
test('empty scope array → inherits full registered scope', async () => {
expect((await authorizeAndReadScopes('read write', [])).sort()).toEqual(['read', 'write']);
});
test('explicit subset is honored (not overridden to full)', async () => {
expect(await authorizeAndReadScopes('read write admin', ['read'])).toEqual(['read']);
});
test('clamp preserved: over-broad request cannot escalate', async () => {
expect(await authorizeAndReadScopes('read', ['read', 'admin'])).toEqual(['read']);
});
test('clamp preserved: requesting only a disallowed scope grants nothing (no inheritance)', async () => {
expect(await authorizeAndReadScopes('read write', ['admin'])).toEqual([]);
});
});
+28
View File
@@ -12,6 +12,7 @@ import {
import { hashToken, generateToken } from '../src/core/utils.ts';
import { PGLITE_SCHEMA_SQL } from '../src/core/pglite-schema.ts';
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
import type { AuthInfo as CoreAuthInfo } from '../src/core/operations.ts';
// ---------------------------------------------------------------------------
// Test setup: in-memory PGLite with OAuth tables
@@ -310,6 +311,33 @@ describe('verifyAccessToken', () => {
expect(authInfo.clientId).toBe('legacy-agent');
expect(authInfo.scopes).toEqual(['read', 'write', 'admin']); // grandfathered full access
});
test('legacy access_tokens fallback honors permissions.source_id array grants', async () => {
// oauth.test.ts initializes the static PGLite schema blob, not the full
// migration stack. Add the v38 permissions column here so the row matches
// a modern brain carrying a legacy-token source grant.
await sql`
ALTER TABLE access_tokens
ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '{"takes_holders":["world"]}'::jsonb
`;
const legacyToken = generateToken('gbrain_');
const hash = hashToken(legacyToken);
await sql`
INSERT INTO access_tokens (id, name, token_hash, permissions)
VALUES (
${crypto.randomUUID()},
${'legacy-federated-agent'},
${hash},
${JSON.stringify({ source_id: ['default', 'src-a', 'src-b'] })}::jsonb
)
`;
const authInfo = await provider.verifyAccessToken(legacyToken) as CoreAuthInfo;
expect(authInfo.clientId).toBe('legacy-federated-agent');
expect(authInfo.sourceId).toBe('default');
expect(authInfo.allowedSources).toEqual(['default', 'src-a', 'src-b']);
});
});
// ---------------------------------------------------------------------------
+94
View File
@@ -3,6 +3,7 @@ import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { acquireLock, releaseLock, type LockHandle } from '../src/core/pglite-lock';
import { withEnv } from './helpers/with-env.ts';
const TEST_DIR = join(tmpdir(), 'gbrain-lock-test-' + process.pid);
@@ -99,3 +100,96 @@ describe('pglite-lock', () => {
await releaseLock(lock2);
});
});
describe('pglite-lock #2058 heartbeat + steal-grace', () => {
beforeEach(() => {
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true });
mkdirSync(TEST_DIR, { recursive: true });
});
afterEach(() => {
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true });
});
function writeHolder(fields: { pid: number; acquiredAgoMs: number; refreshedAgoMs: number }) {
const lockDir = join(TEST_DIR, '.gbrain-lock');
mkdirSync(lockDir, { recursive: true });
const now = Date.now();
writeFileSync(join(lockDir, 'lock'), JSON.stringify({
pid: fields.pid,
acquired_at: now - fields.acquiredAgoMs,
refreshed_at: now - fields.refreshedAgoMs,
command: 'test holder',
}));
}
test('[REGRESSION] a LIVE holder with a fresh heartbeat is NOT stolen even when the lock is old', async () => {
// The WAL-corruption bug: a >5min embed used to get its lock force-removed.
// Now an alive holder that heartbeated recently is left alone regardless of
// age. acquired 20min ago, but refreshed just now → must wait, not steal.
writeHolder({ pid: process.pid, acquiredAgoMs: 20 * 60_000, refreshedAgoMs: 0 });
await expect(acquireLock(TEST_DIR, { timeoutMs: 1200 })).rejects.toThrow(/Timed out/);
// Holder's lock still present (was never stolen).
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(true);
});
test('a LIVE PID whose heartbeat went stale past the grace window IS reaped', async () => {
// PID is alive (our own) but hasn't refreshed in 20min (> 600s grace):
// hung holder, or a reused PID whose real holder is gone. Reap + acquire.
writeHolder({ pid: process.pid, acquiredAgoMs: 25 * 60_000, refreshedAgoMs: 20 * 60_000 });
const lock = await acquireLock(TEST_DIR, { timeoutMs: 2000 });
expect(lock.acquired).toBe(true);
await releaseLock(lock);
});
test('GBRAIN_PGLITE_LOCK_STEAL_GRACE_SECONDS tunes the grace window', async () => {
// withEnv keeps the process-global mutation isolated across shard files.
await withEnv({ GBRAIN_PGLITE_LOCK_STEAL_GRACE_SECONDS: '5' }, async () => {
// Refreshed 30s ago — fresh under the 600s default, STALE under 5s.
writeHolder({ pid: process.pid, acquiredAgoMs: 60_000, refreshedAgoMs: 30_000 });
const lock = await acquireLock(TEST_DIR, { timeoutMs: 2000 });
expect(lock.acquired).toBe(true);
await releaseLock(lock);
});
});
test('[REGRESSION] releaseLock does NOT remove a lock that was stolen + re-acquired by another process', async () => {
// We acquire, then simulate a steal: another process reaped us past grace
// and now owns the lock (different pid + acquired_at). Our releaseLock must
// NOT delete their live lock — doing so would let a third process in
// alongside the new owner (the #2058 corruption class).
const lock: LockHandle = await acquireLock(TEST_DIR);
expect(lock.acquired).toBe(true);
expect(lock.ownerToken).toBeDefined();
if (lock.heartbeat) clearInterval(lock.heartbeat); // stop our heartbeat for a deterministic test
// Overwrite the lock file as if process B re-acquired it.
const lockFile = join(TEST_DIR, '.gbrain-lock', 'lock');
const bNow = Date.now() + 1;
writeFileSync(lockFile, JSON.stringify({ pid: 999999, acquired_at: bNow, refreshed_at: bNow, command: 'process B' }));
await releaseLock(lock); // our (stale) handle
// B's lock survives — we did not clobber it.
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(true);
const after = JSON.parse(readFileSync(lockFile, 'utf-8'));
expect(after.pid).toBe(999999);
// Cleanup for afterEach.
rmSync(join(TEST_DIR, '.gbrain-lock'), { recursive: true, force: true });
});
test('acquire starts a heartbeat and seeds refreshed_at; release clears it', async () => {
const lock: LockHandle = await acquireLock(TEST_DIR);
expect(lock.acquired).toBe(true);
expect(lock.heartbeat).toBeDefined();
const data = JSON.parse(readFileSync(join(TEST_DIR, '.gbrain-lock', 'lock'), 'utf-8'));
expect(data.refreshed_at).toBeDefined();
expect(typeof data.refreshed_at).toBe('number');
await releaseLock(lock);
expect(lock.heartbeat).toBeUndefined();
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(false);
});
});
+45
View File
@@ -0,0 +1,45 @@
/**
* #2034 — engine-parity reconnect().
*
* The autopilot health-probe recovery path used `disconnect()` + bare
* `connect()`, which (a) threw `database_url undefined` forever on Postgres
* because the config was lost, and (b) was a silent no-op on PGLite which had
* no reconnect() at all. Both engines now expose `reconnect()`; this pins the
* PGLite side: it restores connectivity against the config captured at the
* last connect(), and is a safe no-op when never connected.
*/
import { describe, test, expect } from 'bun:test';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
describe('#2034 PGLiteEngine.reconnect', () => {
test('restores connectivity and persisted state against the saved config', async () => {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-reconnect-'));
const engine = new PGLiteEngine();
try {
await engine.connect({ database_path: dir });
await engine.initSchema();
await engine.setConfig('reconnect.probe', 'pre');
// Reconnect with no args — the #2034 contract: it must reuse the config
// captured at connect(), not require it to be re-passed.
await engine.reconnect();
// Still queryable, and persisted state survived (same data dir).
const rows = await engine.executeRaw<{ x: number }>('SELECT 1 AS x');
expect(rows[0]?.x).toBe(1);
expect(await engine.getConfig('reconnect.probe')).toBe('pre');
} finally {
await engine.disconnect();
rmSync(dir, { recursive: true, force: true });
}
});
test('reconnect() before any connect() is a safe no-op', async () => {
const engine = new PGLiteEngine();
await expect(engine.reconnect()).resolves.toBeUndefined();
});
});
+2 -2
View File
@@ -89,7 +89,7 @@ describe('alias_resolved boost stage', () => {
});
describe('KNOBS_HASH_VERSION', () => {
it('is 10 (9→10 relational recall arm invalidates rel-off cache rows, v0.43)', () => {
expect(KNOBS_HASH_VERSION).toBe(10);
it('is 11 (10→11 asymmetric input_type fix invalidates document-side query-vector rows, #1400)', () => {
expect(KNOBS_HASH_VERSION).toBe(11);
});
});
+7 -3
View File
@@ -403,7 +403,11 @@ describe('knobsHash determinism + cross-mode separation (CDX-4)', () => {
// must not be served from a cache row written before the policy change.
// v0.43: bumped 9→10 for the relational recall arm (rel=/reld=) — a
// relational-on write must not be served to a relational-off lookup.
expect(KNOBS_HASH_VERSION).toBe(10);
// #1400: bumped 10→11 for the asymmetric input_type fix — embedQuery()
// now produces query-side vectors for asymmetric providers (zembed-1,
// Voyage v3+), so rows keyed on pre-fix document-side query vectors
// must not be served to post-fix lookups.
expect(KNOBS_HASH_VERSION).toBe(11);
});
test('T1 (codex): floor_ratio set vs unset produces DIFFERENT hashes (cache contamination prevention)', () => {
@@ -568,8 +572,8 @@ describe('v0.40.4 — graph_signals knob', () => {
});
describe('v0.42.3.0 — autocut knobs', () => {
test('KNOBS_HASH_VERSION is 10 (9→10 relational recall arm, v0.43)', () => {
expect(KNOBS_HASH_VERSION).toBe(10);
test('KNOBS_HASH_VERSION is 11 (10→11 asymmetric input_type fix, #1400)', () => {
expect(KNOBS_HASH_VERSION).toBe(11);
});
test('bundle defaults: conservative off, balanced/tokenmax on @0.20', () => {
+5 -2
View File
@@ -43,7 +43,7 @@ function baseKnobs(): ResolvedSearchKnobs {
}
describe('KNOBS_HASH_VERSION + version invariants', () => {
test('version is 10 (…; 7→8 autocut; 8→9 archive-demote #1777; 9→10 relational recall)', () => {
test('version is 11 (…; 8→9 archive-demote #1777; 9→10 relational recall; 10→11 asymmetric input_type #1400)', () => {
// v0.35.0.0: 1→2 to fold reranker fields. v0.35.6.0: 2→3 to fold
// floor_ratio. v0.36 wave: piggybacks on v=3 with 7 cross-modal knobs
// (D2) PLUS column + provider context (D8/CDX-2 cross-column isolation).
@@ -58,7 +58,10 @@ describe('KNOBS_HASH_VERSION + version invariants', () => {
// autocut. issue #1777: 8→9 archive/ demote (search-exclude policy change
// isn't in the hash, so the bump invalidates archive-excluded cache rows).
// v0.43: 9→10 relational recall arm (rel=/reld=).
expect(KNOBS_HASH_VERSION).toBe(10);
// #1400: 10→11 asymmetric input_type fix — embedQuery() now produces
// query-side vectors for asymmetric providers, so rows keyed on
// pre-fix document-side query vectors must not be served.
expect(KNOBS_HASH_VERSION).toBe(11);
});
test('hash is 16 hex chars regardless of reranker config', () => {
+76
View File
@@ -0,0 +1,76 @@
/**
* #2057 regression — addTimelineEntriesBatch must accept JS Date `date` values.
*
* The original bug: callers source `date` from SQL rows (e.g.
* `meeting.effective_date`), which arrive as JS Date objects. The OLD insert
* bound them into `${dates}::text[]`, which threw `cannot cast type timestamp
* with time zone to text[]`, and a bare `catch {}` in the meetings extractor
* swallowed it — timeline stayed empty forever.
*
* The #1861 refactor moved the insert to `jsonb_to_recordset` + `v.date::date`,
* where a Date serializes to an ISO string that casts cleanly. This test pins
* that a Date-bearing batch round-trips, so a future refactor can't silently
* reintroduce the cast failure. (The companion fix un-silences the extractor's
* catch so any future failure is visible rather than a phantom "0 entries".)
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { importFromContent } from '../src/core/import-file.ts';
import type { TimelineBatchInput } from '../src/core/engine.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
await importFromContent(engine, 'people/alice-example', `---\ntitle: Alice\ntype: note\n---\n\n# Alice\n`, {
noEmbed: true,
sourceId: 'default',
sourcePath: 'people/alice-example.md',
});
});
afterAll(async () => {
await engine.disconnect();
});
describe('#2057 addTimelineEntriesBatch with Date date values', () => {
test('a Date `date` is inserted and round-trips as the right calendar day', async () => {
// Mimic the real caller: `date` typed string on the interface, but a JS
// Date at runtime (straight off a TIMESTAMPTZ column). The cast must hold.
const effectiveDate = new Date('2026-04-03T00:00:00.000Z');
const batch: TimelineBatchInput[] = [
{
slug: 'people/alice-example',
date: effectiveDate as unknown as string,
source: 'cli:test',
summary: 'met alice',
source_id: 'default',
},
];
const inserted = await engine.addTimelineEntriesBatch(batch);
expect(inserted).toBe(1);
const rows = await engine.executeRaw<{ date: string }>(
`SELECT date::text AS date FROM timeline_entries WHERE summary = 'met alice'`,
);
expect(rows.length).toBe(1);
expect(rows[0].date).toBe('2026-04-03');
});
test('a plain ISO string `date` still works (no regression)', async () => {
const inserted = await engine.addTimelineEntriesBatch([
{
slug: 'people/alice-example',
date: '2026-05-01',
source: 'cli:test',
summary: 'string-dated entry',
source_id: 'default',
},
]);
expect(inserted).toBe(1);
});
});
+119
View File
@@ -0,0 +1,119 @@
/**
* #2038 — idx_timeline_dedup schema-drift self-heal.
*
* A brain that ran the pre-renumber v99 variant of the dedup migration is
* stamped past v102 with the OLD 3-column index. `runMigrations` early-returns
* (nothing pending) so a migration verify-hook can't fix it. The repair is
* keyed off the index SHAPE and runs regardless. These tests simulate the
* drifted states directly and pin: detection, rebuild, dedupe-before-rebuild
* (only possible when the index was absent), and idempotency.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import {
checkTimelineDedupIndex,
repairTimelineDedupIndex,
} from '../src/core/timeline-dedup-repair.ts';
import { importFromContent } from '../src/core/import-file.ts';
let engine: PGLiteEngine;
let pageId: string;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
await importFromContent(engine, 'people/alice-example', `---\ntitle: Alice\ntype: note\n---\n\n# Alice\n`, {
noEmbed: true,
sourceId: 'default',
sourcePath: 'people/alice-example.md',
});
const pid = await engine.executeRaw<{ id: string }>(
`SELECT id::text AS id FROM pages WHERE slug = 'people/alice-example' AND source_id = 'default'`,
);
pageId = pid[0].id;
});
afterAll(async () => {
await engine.disconnect();
});
/** Force the index back to the broken pre-v102 3-column shape. */
async function regressTo3Col() {
await engine.executeRaw(`DELETE FROM timeline_entries`);
await engine.executeRaw(`DROP INDEX IF EXISTS idx_timeline_dedup`);
await engine.executeRaw(
`CREATE UNIQUE INDEX idx_timeline_dedup ON timeline_entries(page_id, date, summary)`,
);
}
/** The other drift shape: the index was dropped entirely, letting true
* 4-tuple duplicates accumulate that would block a naive CREATE UNIQUE INDEX. */
async function regressToAbsentWithDupes() {
await engine.executeRaw(`DELETE FROM timeline_entries`);
await engine.executeRaw(`DROP INDEX IF EXISTS idx_timeline_dedup`);
await engine.executeRaw(
`INSERT INTO timeline_entries (page_id, date, summary, source, detail)
VALUES ($1, '2026-04-03', 'met alice', 'meeting', ''),
($1, '2026-04-03', 'met alice', 'meeting', ''),
($1, '2026-04-03', 'met alice', 'cli:extract', '')`,
[pageId],
);
}
describe('#2038 idx_timeline_dedup drift repair', () => {
test('detects the 3-column drift', async () => {
await regressTo3Col();
const status = await checkTimelineDedupIndex(engine);
expect(status.tablePresent).toBe(true);
expect(status.indexPresent).toBe(true);
expect(status.columns).toEqual(['page_id', 'date', 'summary']);
expect(status.needsRepair).toBe(true);
});
test('rebuilds the 3-column index to 4 columns (no dupes to collapse)', async () => {
await regressTo3Col();
await engine.executeRaw(
`INSERT INTO timeline_entries (page_id, date, summary, source, detail)
VALUES ($1, '2026-04-03', 'met alice', 'meeting', '')`,
[pageId],
);
const res = await repairTimelineDedupIndex(engine);
expect(res.repaired).toBe(true);
expect(res.reason).toBe('rebuilt');
expect(res.collapsedDuplicates).toBe(0);
const after = await checkTimelineDedupIndex(engine);
expect(after.columns).toEqual(['page_id', 'date', 'summary', 'source']);
expect(after.needsRepair).toBe(false);
});
test('dedupes true 4-tuple duplicates before building the unique index', async () => {
await regressToAbsentWithDupes(); // index absent + a real (meeting) dup
const before = await checkTimelineDedupIndex(engine);
expect(before.indexPresent).toBe(false);
expect(before.needsRepair).toBe(true);
const res = await repairTimelineDedupIndex(engine);
expect(res.repaired).toBe(true);
expect(res.collapsedDuplicates).toBe(1); // one of the two 'meeting' rows removed
const after = await checkTimelineDedupIndex(engine);
expect(after.columns).toEqual(['page_id', 'date', 'summary', 'source']);
const rows = await engine.executeRaw<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM timeline_entries`,
);
expect(parseInt(rows[0].n, 10)).toBe(2); // meeting (deduped) + cli:extract
});
test('idempotent — a second repair is a no-op', async () => {
await regressTo3Col();
await repairTimelineDedupIndex(engine);
const second = await repairTimelineDedupIndex(engine);
expect(second.repaired).toBe(false);
expect(second.reason).toBe('already_correct');
});
});
+52
View File
@@ -120,6 +120,58 @@ describe('writePageThrough', () => {
expect(res).toEqual({ written: false, skipped: 'page_not_found_after_write' });
});
test('[REGRESSION #2018] default page (null local_path) in a multi-source brain → skipped, no leak into a sibling source repo', async () => {
// A sibling federated source with its OWN working tree.
const siblingDir = path.join(tmpRoot, 'housefax');
fs.mkdirSync(siblingDir, { recursive: true });
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config) VALUES ('housefax', 'Housefax', $1, '{}'::jsonb)`,
[siblingDir],
);
// The leak trigger: global sync.repo_path points at the sibling's tree
// while the default source (re-seeded by resetPgliteState) has no
// local_path of its own.
await engine.setConfig('sync.repo_path', siblingDir);
const slug = 'internal/cross-cutting-note';
await seedPage(slug); // sourceId 'default'
const res = await writePageThrough(engine, slug, { sourceId: 'default' });
expect(res).toEqual({ written: false, skipped: 'source_repo_belongs_to_other_source' });
// The sibling source's repo stays clean — the whole point of #2018.
expect(walkFiles(siblingDir).some((f) => f.endsWith('.md'))).toBe(false);
});
test('[#2018] page assigned to a source with its own local_path writes to that tree root, not the global path', async () => {
const alphaDir = path.join(tmpRoot, 'alpha-repo');
fs.mkdirSync(alphaDir, { recursive: true });
const globalDir = path.join(tmpRoot, 'global-repo');
fs.mkdirSync(globalDir, { recursive: true });
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config) VALUES ('alpha', 'Alpha', $1, '{}'::jsonb)`,
[alphaDir],
);
// Must NOT be used — the assigned source has its own tree.
await engine.setConfig('sync.repo_path', globalDir);
const slug = 'notes/alpha-thing';
await importFromContent(engine, slug, `---\ntitle: T\ntype: note\n---\n\n# Body\n`, {
noEmbed: true,
sourceId: 'alpha',
sourcePath: `${slug}.md`,
});
const res = await writePageThrough(engine, slug, { sourceId: 'alpha' });
expect(res.written).toBe(true);
// File at the source's tree ROOT, never nested under `.sources/<id>/`.
expect(res.path).toBe(path.join(alphaDir, `${slug}.md`));
expect(fs.existsSync(path.join(alphaDir, `${slug}.md`))).toBe(true);
// The global repo path is untouched.
expect(walkFiles(globalDir).some((f) => f.endsWith('.md'))).toBe(false);
});
test('[REGRESSION] mkdir ENOTDIR (parent is a file) → error, no partial .md, no .tmp', async () => {
await engine.setConfig('sync.repo_path', brainDir);
// Block the `wiki/` directory by putting a FILE named "wiki" under the repo,