* fix(cli): exit deliberately after bounded teardown instead of riding the 10s backstop (#2084)
Root cause: bounded teardown (endPoolBounded, #2015) RESOLVES, but lingering
sockets — embedding-provider fetch keep-alive, PgBouncer txn-mode sockets the
bound raced past — keep Bun's event loop alive, so every `gbrain query` paid
a flat 10s tax exiting via the hard-deadline force-exit banner.
Three changes, one contract:
- flushStdoutThenExit (cli-force-exit.ts): when main() resolves and the
command is not a daemon, exit deliberately — after stdout AND stderr drain
(writableLength===0, 'drain'-event + poll loop, 2s unref'd guard for a
blocked pipe). Incident #1959 (force-exit truncating piped stdout) is the
regression class; pinned by a 256KB real-pipe subprocess test.
- drainThenDisconnect (cli.ts): ONE owner-disconnect helper at all 8 sites
(op-dispatch, CLI_ONLY fall-through, search dashboard, doctor remediation
x3, ze-switch, dream, read-only timeout path). Drains the background-work
registry, then disconnect (best-effort), bounded by the 10s unref'd
hard-deadline — which is now armed around the TEARDOWN window only, not
before the op handler (the old placement would have force-killed any op
slower than 10s). Closes the filed TODOS P3 drain-hoist: six sites
previously skipped the drain entirely and had no hang timer at all.
- Inner process.exit sweep: mid-handler exits in engine-owning/output-bearing
paths (status, friction, claw-test, smoke-test, eval cross-modal /
takes-quality replay / conversation-parser / whoknows-thin, status-thin)
become process.exitCode + return so they flow through the drains and the
flush-exit. Pre-engine usage/parse/refusal exits stay as-is.
BrainRegistry.disconnectAll deliberately unchanged: zero production callers
in src/, per-engine disconnects already bounded, and the kernel reclaims
sockets on exit (src/core/timeout.ts doctrine).
DAEMON_COMMANDS gains 'watch' ahead of the #2095 push transport.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): PgBouncer transaction-mode pooler in CI + teardown e2e (#2084)
Three consecutive waves (#1972 → #2015 → #2084) fixed pooler-teardown bugs
verified only against one production deployment — CI had no transaction-mode
pooler and could never see the class. Now it can:
- docker-compose.ci.yml: `pgbouncer` service (transaction pooling) fronting
postgres-1, mirroring the production split-pool topology (direct :5432 +
pooled :6543). AUTH_TYPE=plain (pg16 SCRAM verifiers need the plaintext
password in the userlist) + IGNORE_STARTUP_PARAMETERS for the
statement_timeout/idle_in_transaction_session_timeout startup params
gbrain's client sets (the Supabase pooler whitelists the same).
- test/e2e/pgbouncer-teardown.test.ts: schema + fixture via the DIRECT url
into a dedicated `gbrain_pgbouncer` database (never races shard TRUNCATEs),
then spawns the real CLI against the POOLED url and asserts: exit 0,
stdout intact (the #1959 truncation class), and NO
"did not return within 10000ms — force-exiting" banner (pre-#2084 it
printed on 100% of query-shaped ops on this topology). Class bound, not
exact timing. Skips gracefully without GBRAIN_PGBOUNCER_URL.
- scripts/ci-local.sh: threads GBRAIN_PGBOUNCER_URL +
GBRAIN_PGBOUNCER_DIRECT_URL into all three e2e phases.
Verified live: both tests green against pgbouncer 1.25.2 in transaction mode.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(schema): context_volunteer_events table (v116) — push-context feedback log (#2095)
One row per page the brain volunteers (op / reflex / watch channels).
"Used" is DERIVED, never written: pages.last_retrieved_at > volunteered_at
(the existing bumpLastRetrievedAt write-back is the open/cite signal), so
there is no second tracking path. session_id/turn are nullable
caller-supplied attribution; rationale is a deterministic template string,
never raw conversation text.
- Migration v116 (idempotent) + mirrors in src/schema.sql +
src/core/pglite-schema.ts + regenerated schema-embedded.ts (regen also
folds in pre-existing comment-only drift from the v114 links edits).
- src/core/context/volunteer-events.ts: insertVolunteerEvents (ONE
multi-row parameterized INSERT — never per-row awaited round-trips) +
purgeStaleVolunteerEvents (90-day GC, returns 0 on pre-v116 brains).
- Dream cycle purge phase prunes stale events alongside op_checkpoints /
brainstorm checkpoints / batch-retry audit files.
- RLS on Postgres comes from the v35 auto_rls_on_create_table event
trigger (the same mechanism that covered v110 page_aliases and v115
op_checkpoint_paths); the volunteer Postgres e2e pins it.
- No ::jsonb anywhere; no bootstrap probe needed (nothing references the
table pre-creation; writers guard with try/catch).
Tests: v116 shape + columns + indexes + live insert/purge round-trip on
PGLite (test/migrate.test.ts, 161 pass); schema-bootstrap-coverage green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(context): multi-turn window extraction + confidence-scored volunteer core (#2095)
- entity-salience.ts: extractCandidatesFromWindow(turns) — runs the existing
per-turn extractor across the last N turns (oldest→newest), merges by the
normalizeAlias form with occurrence/newest-turn/user-mention metadata, and
orders by salience (recency > frequency > user-role) so the MAX_CANDIDATES
cap drops stale assistant chatter, not the entity the user just named.
Closes the filed assistant-introduced-entities recall TODO; true pronoun
coreference (never-named antecedents) stays out of scope.
- retrieval-reflex.ts: ReflexPointer gains source_id + arm + confidence +
matchedNorm. ARM_CONFIDENCE (alias 0.9 / title 0.8 / slug-suffix 0.6)
lives next to the arm definitions so identity and score can't drift.
Arm-2 provenance is classified in JS (codex D8 — the combined OR can't
report which predicate matched). Federated sourceIds[] scope (alias arm
loops per source; arm 2 uses source_id = ANY — no engine-interface
change). Suppression gains 'slug-only' mode (codex D7, REQUIRED for
windowing): the legacy title-whole-word rule would suppress every entity
merely MENTIONED in a prior window turn, breaking the feature by
construction — slugs only enter context when a pointer/page was actually
surfaced. Default stays 'slug-and-title' for the window=1 legacy path.
- volunteer.ts (new): parseWindow (lenient user:/assistant: prefixes, CRLF,
unprefixed → one user turn), volunteerContext (zero-LLM: extract →
resolve → +0.05 multi-turn/newest-turn boost → min_confidence 0.7 gate →
cap 3/5; deterministic rationale strings, never raw conversation text),
and volunteerUsageStats (per-arm/channel precision from the
last_retrieved_at join, labeled approximate — 5-min throttle false
negatives, unrelated-read false positives; codex D9).
Tests: 35 green across volunteer-context (window parsing, pronoun follow-up
via assistant-introduced entity, confidence gating, slug-only suppression,
takes-fence privacy, multi-source scope, caps, stats join math) +
retrieval-reflex back-compat + resolve-ipc.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(ops): volunteer_context op — CLI (stdin) + MCP, drained event sink (#2095)
New read-scope op on the contract surface (CLI `gbrain volunteer-context`
with stdin → window, MCP tool for free): takes a rolling conversation
window, returns confidence-gated page pointers with rationales + synopses.
`window` is optional-unless-stats (validated in the handler, codex D9);
`stats: true` returns the volunteered-vs-used precision summary, labeled
APPROXIMATE (the 5-min last-retrieved throttle and unrelated reads both
bias the join). Source scope threads through sourceScopeOpts — federated
grants narrow the volunteer to the granted sources.
Event logging is fire-and-forget through a new `volunteer-events`
background-work sink (volunteer-events.ts, mirrors last-retrieved: tracked
dangling promise set + bounded drain + snapshot-drop on timeout so a
long-lived process never accumulates ghosts). ONE batched INSERT per call,
drained on every exit path by the commit-1 drain hoist; failure never
fails the op (pinned by an injected failing-engine test).
cli formatResult renders both shapes (pointer lines with confidence/arm/
rationale; the stats summary with per-arm precision).
Tests: op contract surface, window-required validation, sink round-trip
with session_id/turn attribution, failing-engine fail-open, federated
grant scoping, stats mode (26 green on PGLite) + a real-Postgres e2e
proving the op + sink + stats join AND that context_volunteer_events has
RLS enabled (keeps the auto-RLS event-trigger mechanism honest for v116).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(context): reflex consumes the rolling window + ambient-channel logging (#2095)
The default-on retrieval reflex now extracts entities from the last N turns
(retrieval_reflex_window_turns, default 4; env
GBRAIN_RETRIEVAL_REFLEX_WINDOW_TURNS; window=1 reproduces the legacy
current-turn-only behavior exactly). assemble() passes the recent
user/assistant turns (hard cap 12); the reflex slices to the configured
window. Assistant-introduced entities and "what did she invest in?"
follow-ups whose antecedent was NAMED in the window now surface pointers —
the issue's "zero agent-initiated queries" success criterion on the
ambient path.
Under windowing, suppression switches to slug-only (codex D7): the legacy
title-whole-word rule would suppress every entity merely MENTIONED in a
prior window turn, breaking the feature by construction. Slugs only enter
prior context when a pointer/page was actually surfaced, so
already-surfaced pages still suppress. The suppression mode flows through
all three resolver rungs (host opts, serve IPC request, direct Postgres).
Ambient-channel feedback (codex D11): the server-side resolver paths
(serve IPC + direct Postgres) log volunteered pointers with
channel: 'reflex' through the drained volunteer-events sink, so
`gbrain volunteer-context --stats` measures the default-on path where most
volunteering happens. Host-injected resolvers (no gbrain engine) can't
log — documented gap. Precision gates, 1.5s ceiling, fail-open, and the
pointer cap are unchanged.
Tests: prev-assistant-turn entity fires; window=1 legacy parity; slug-only
vs already-surfaced suppression; throwing resolver stays fail-open
(16 green).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(cli): gbrain watch — push transport over stdin (#2095)
The issue's headline: the brain volunteers pages as the conversation flows,
instead of waiting to be asked. `some-transcript-feed | gbrain watch` reads
turns line-by-line ('user:'/'assistant:' prefixes set the role; unprefixed
lines are user turns), keeps a rolling window (--window-turns, default 4),
and streams confidence-gated pointers with rationales to stdout (--json for
JSONL). Session dedupe rides the core's slug-only suppression — a slug is
volunteered at most once per session. Events log on channel 'watch' with
session_id + turn through the drained sink.
Lifecycle: watch BLOCKS in the stdin iteration (like `jobs work`) — an
interactive TTY stays alive until Ctrl-C/Ctrl-D, piped input ends at EOF —
so it is deliberately NOT in DAEMON_COMMANDS (reverts the commit-1
placeholder): when main() resolves the work is over, the CLI_ONLY finally
drains volunteer events via drainThenDisconnect, and the entrypoint
flush-exit ends the process. Keeping it in the daemon set would have made
the piped EOF path hang on lingering sockets — the exact #2084 class.
SIGINT closes the stream and flows through the same drain path instead of
killing mid-write. Per-turn resolution failures are fail-open (the stream
never dies on a transient DB error).
Full wiring (eng-review D12): CLI_ONLY + CLI_ONLY_SELF_HELP (WATCH_HELP) +
THIN_CLIENT_REFUSED_COMMANDS (thin clients use the volunteer_context MCP
op) + main --help entry.
Tests: 18 green — help, per-turn volunteering + clean EOF return, rolling
window via assistant-introduced entity, session dedupe, --json shape with
turn attribution, channel-watch event rows, --min-confidence gate, CRLF/
blank tolerance, daemon-gate semantics. Live smoke: piped `gbrain watch`
on a fresh PGLite brain exits 0 at EOF with no force-exit banner.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: KEY_FILES + push-context guide + TODOS for the #2084/#2095 wave
- docs/architecture/KEY_FILES.md (current-state): context entries gain the
window extractor, arm provenance/confidence, suppression modes, volunteer
+ volunteer-events modules; background-work entry now lists FIVE sinks and
the drainThenDisconnect owner-disconnect contract; new entries for
src/core/cli-force-exit.ts (the exit contract) and src/commands/watch.ts.
- docs/guides/push-context.md (new): the three channels (reflex/op/watch),
the confidence model, CLI usage, config keys, and the approximate-stats
caveat. Linked from CLAUDE.md's reference map.
- CLAUDE.md: ops line mentions volunteer_context + the guide link;
bun run build:llms regenerated in the same commit (freshness test green).
- TODOS.md: #2095 deferrals filed (SSE push channel, policy skill + doctor
check, structured messages[] param); the #1981 entity-detection TODO
narrowed (window extraction covered assistant-introduced entities +
named-antecedent follow-ups); the drain-hoist P3 marked DONE by the
#2084 wave.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): truncate context_volunteer_events in setupDB (#2095)
The new feedback-log table wasn't in ALL_TABLES, so volunteered-event rows
persisted across e2e runs on a reused database and poisoned count/stats
assertions in volunteer-context-postgres on the second run. No FK to pages
(slug join), so position before pages is for hygiene only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): own the exit verdict — never trust ambient process.exitCode (#2084)
Caught by the full unit suite: `gbrain apply-migrations` on PGLite started
exiting 99. Root cause: PGLite's Emscripten runtime writes the WASM
backend's proc_exit status into process.exitCode (initdb at create-time,
the postmaster at close-time — `exitCode=status` in pglite's dist), and
the writes land ASYNCHRONOUSLY, outside any snapshot/restore window around
create/close (a guarded attempt verified this). The pre-#2084 success path
never read process.exitCode, so the pollution was invisible; the new
deliberate flush-exit propagated it faithfully.
Fix: gbrain records its own verdict. setCliExitCode(n)/getCliExitCode() in
cli-force-exit.ts — every gbrain-owned exit-code assignment routes through
the setter (still mirrored to process.exitCode for outside readers), and
both exit paths (entrypoint flushStdoutThenExit + the drainThenDisconnect
hard-deadline backstop) read the getter. Swept all assignment sites:
cli.ts (op error, friction, claw-test, smoke-test, eval runners, status,
import errors) + reindex/transcripts/brainstorm/frontmatter/autopilot.
Also updates the v0.42.20 structural pins to the drainThenDisconnect shape
(ordering invariant asserted INSIDE the helper + >=8 helper call sites,
superseding the two-inline-pairs assertion).
Verified: apply-migrations spawn test green; `init --migrate-only` exits 0;
an errored op still exits 1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: re-pin the teardown-arming invariant at its post-#2084 home
Master's v0.42.41.0 triage wave and the #2084 wave fixed the same
pre-armed-timer bug independently; the merge keeps #2084's shape (arming
inside the shared drainThenDisconnect helper, covering all 8 exit paths).
The structural pin now asserts the same invariant — no pre-try arming;
gated, unref'd, before-drain, cleared — at the helper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: coverage for ambient reflex-channel logging + watch window/cap flags
Ship coverage audit (85%, gate PASS) named five gaps; the two substantive
cheap ones close here: the codex-D11 logChannel='reflex' path now has a
behavioral pin (events land on channel 'reflex' through the drained sink;
no logChannel → no events), and gbrain watch's --window-turns / --max-pages
flags are exercised (turn-1 attribution under window=1; cap to one page).
Remaining flagged-not-blocking: the wallclock-timeout branch (untestable
without >10s real-clock flake — same rationale as the arming pin),
formatResult's volunteer case (module-private), and the cycle purge wiring.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: close the remaining plan-audit gaps — formatResult rendering + watch SIGINT
formatResult exported for tests (same import-safety contract as cliAliases);
test/cli-format-volunteer.test.ts pins the pointer lines, empty-gate message,
and approximate stats summary. test/watch-command.test.ts gains a real
subprocess SIGINT test: piped stdin that never reaches EOF, SIGINT mid-stream,
assert exit 0 with no force-exit banner — the drain-then-exit lifecycle under
the actual signal, not just the shared exit path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: doctor's FAIL verdict was zeroed by the owned exit — sweep stragglers + class pin
The merged-state suite caught it: doctor --fast --json reported FAIL but
exited 0. Master's v0.42.41.0 brought raw `process.exitCode =` writes
(doctor.ts hasFail ternary, extract.ts) that the #2084 verdict-owning exit
silently zeroes — getCliExitCode() deliberately never reads ambient
process.exitCode (the PGLite-Emscripten pollution defense), so any setter
that bypasses setCliExitCode reports success on failure.
Swept both sites and added the structural class pin: a test greps src/ for
raw `process.exitCode =` outside cli-force-exit.ts, so the next merge that
introduces one fails loudly instead of lying about exit codes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: bump version and changelog (v0.42.43.0)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: quarantine the watch SIGINT subprocess test to the serial lane
The parallel unit shards flake on concurrent CLI subprocess spawns (failed
at 7ms in-suite, green solo) — same isolation rationale as
apply-migrations-pglite-spawn.serial.test.ts and #2141's R3 quarantine.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: update project documentation for v0.42.43.0
Post-ship doc verification against the release diff (#2095 push-based
context + #2084 superset hardening), with a cross-model doc review:
- push-context.md: version tag corrected to v0.42.43.0; per-call knobs
now cover prior_context/days and watch's flag surface accurately;
feedback-log writes described as best-effort; synopsis fence-strip
described as unconditional.
- CLAUDE.md: stale operation count (~47 -> ~90); volunteer_context
release reference corrected to v0.42.43.0.
- KEY_FILES.md: ci-local entry rewritten to current topology (4-shard
parallel default, four Postgres services, transaction-mode PgBouncer
+ GBRAIN_PGBOUNCER_URL/_DIRECT_URL exports); stale E2E file counts
dropped from the selector entry.
- TESTING.md: inventory entries for the new #2084 structural pins
(cli-exit-verdict-pin, cli-pipe-truncation), the push-context test
suite (volunteer-context, watch-command, watch-sigint.serial,
cli-format-volunteer), migrate v117 coverage, and the two new E2E
files (pgbouncer-teardown env gating, volunteer-context-postgres RLS
pin); check:all row corrected (not a superset of verify).
- AGENTS.md + RELEASING.md: ci:local descriptions updated to the
sharded + pooler topology.
- CHANGELOG (wording only, entry preserved): "retrieved" instead of
"opened" for the used-signal, pooler scoped to the local CI gate,
feedback log labeled best-effort.
- llms-config.ts: index the new push-context guide; bundles
regenerated (build:llms) and freshness test green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(test): correct the v116 reference — the table shipped as migration v117
* fix: pre-landing review hardening — federated alias parallelism, trust-boundary clamps, shared protocol helpers (#2095)
Five specialist reviewers (testing/maintainability/security/performance/
data-migration) on the reconciled diff; every finding applied:
Performance: the alias arm now resolves all granted sources CONCURRENTLY
(a federated caller paid M sequential RTTs per turn — ~355ms at 5 sources
cross-region, inside the reflex's 1.5s budget); watch's session dedupe is
O(1) Set membership instead of a monotonically growing priorContext string
(O(T²) over a long-lived session); getWindowTurns iterates from the tail
(per-turn cost no longer grows with session length); the resolver's
provenance maps fold into the existing candidate pass.
Security: volunteer_context clamps caller-supplied attribution at the trust
boundary — session_id capped at 256 chars (a read-scoped token could bank
~1MiB TEXT per request, retained 90 days), turn logged only when a safe
integer (a non-integer threw inside the batched INSERT and silently dropped
the whole batch). The privacy comments now state precisely what rationale
may contain (the matched entity's surface form — which by construction
resolved to an existing alias/title/slug — never free conversation text).
Maintainability: TURN_PREFIX_RE + formatVolunteeredPage exported from
volunteer.ts and shared by watch/cli (the two surfaces can no longer
drift); volunteerEventRowsFrom is the single VolunteerEventRow assembly
site for all three channels; watch's window default now honors the same
retrieval_reflex_window_turns config knob the reflex reads; the stale
pre-v116 comments swept to pre-v117.
Testing: the two flake-class CRITICALs fixed (pipe test asserts the
backstop banner instead of a cold-CI-hostile 9s wall bound; the SIGINT test
waits on watch's new machine-readable ready line instead of a fixed 15s
sleep — 2.5s and deterministic now); new coverage for the sink's timeout
branch + ghost-reference drop, watch per-turn fail-open, untrusted knob
clamps (min_confidence/max_pages/days), window-cap ordering (newest user
mention survives), serve-IPC suppression passthrough + channel=reflex
logging, windowTurnCount edge semantics, and structural pins for the sink
registration + cycle purge wiring. The exit-verdict pin's grep is now
operator/whitespace-tolerant.
Deferred with TODOs: resolver index shapes for the per-turn query;
batched first-prune after a long dream-cycle gap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(context): red-team hardening — pre-cap dedupe, delivery-side reflex logging, window clamp
Four red-team findings on the #2095 push-context surface:
- RT1 starvation: watch's session-dedupe Set filtered AFTER volunteerContext's
cap, so a recurring already-pushed entity burned cap slots every turn and
starved fresh pages behind it. VolunteerOpts.excludeSlugs now skips inside
the pointer loop BEFORE the confidence gate and the cap.
- RT3 honest stats: reflex-channel event logging moved from inside the
resolver to the DELIVERY point — serve's resolve-IPC onDelivered hook fires
only after the response write succeeds, and buildReflexAddition logs only
after the per-turn timeout admits the block. A block the client's 250ms
budget abandoned was never injected and no longer counts as volunteered.
(logChannel resolver opt removed; logDeliveredReflexPointers is the seam.)
- RT5 unbounded window: --window-turns is clamped to [1, 64] so a config typo
can't reintroduce the re-scan-everything-per-turn cost class.
- RT2/RT4 documented + filed: PGLite watch connection monopoly (WATCH_HELP,
push-context guide, TODO to route watch via serve IPC); host-resolver
suppression contract at ResolveEntitiesFn (TODO for a capability gate).
Tests: starvation guard (watch + volunteerContext unit), window clamp floor +
ceiling, delivery-side logging (helper writes channel=reflex through the
drained sink; bare resolver writes nothing; empty list no-op), IPC wiring test
rewired to onDelivered. KEY_FILES.md + push-context.md updated; build:llms run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(context): env-plane window knob works config-less; harden two gateway-state-leak victims
Three CI-only check failures, two root causes:
1. windowTurnCount ignored GBRAIN_RETRIEVAL_REFLEX_WINDOW_TURNS when
loadConfig() returned null (no config file AND no DATABASE_URL — a clean
CI shard with no brain). loadConfig drops its env→config mapping in that
case, so the documented escape hatch silently died and the window fell
back to 4 → windowed extraction widened when the test set window=1 →
prior-turn entity leaked. Fixed: read the env var directly in
windowTurnCount, mirroring reflexEnabled's direct process.env read. This
is a real product bug, not just a test artifact — any config-less host
using the env hatch was affected. Regression test pins it.
2. sync-cost-preview + doctor-federation-health failed only IN-SHARD: a
sibling test configured a non-legacy (ZeroEntropy 1280-d / $0.05) gateway
and never reset it. The legacy-embedding preload only restores the
OpenAI/1536 default when the gateway slot is EMPTY, so a non-empty foreign
config survives into the next file — and a file's beforeAll runs BEFORE
the preload's restoring beforeEach, so federation-health built a
vector(1280) column and its 1536-d fixture hit CheckExpectedDim. My new
test files reshuffled the deterministic file→shard assignment, exposing
this latent ordering bug. Hardened both victims to establish the gateway
state they assert (sync-cost-preview resets to the unconfigured fallback;
federation-health pins legacy 1536 before initSchema) so they're
order-independent. Verified against a simulated leaker run before them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(context): use withEnv() in the window env-hatch test (test-isolation guard)
The regression test added in 82cc7fff mutated process.env directly, which
check:test-isolation (R1) forbids — use the withEnv() helper that restores on
exit, same as the rest of this file. Behavior identical; guard green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
45 KiB
Testing (gbrain repo)
On-demand reference (see CLAUDE.md Reference map). Current behavior + invariants only.
Test command tiers
Seven test command tiers, each with a clear scope:
| Command | What it runs | Wallclock | When to use |
|---|---|---|---|
bun run test |
Parallel unit-test fast loop. 8-shard fan-out via scripts/run-unit-parallel.sh, then a serial pass over *.serial.test.ts. Excludes *.slow.test.ts and test/e2e/*. No pre-checks, no typecheck. |
~85s on a Mac dev box (3650+ tests) | Inner edit loop. Default. |
bun run verify |
CI's authoritative pre-test gate set, fanned out in parallel by scripts/run-verify-parallel.sh: the full check:* battery (~30 checks — privacy, jsonb, progress, source-id, test-isolation, wasm, …) plus bun run typecheck. The CHECKS array in that script is the single source of truth — CI literally calls bun run verify in a dedicated job. |
~16s (parallel; typecheck dominates) | Before pushing; before /ship. |
bun run test:full |
verify && bun run test && bun run test:slow && [smart e2e]. The local equivalent of "everything CI runs." Smart e2e: runs e2e only when DATABASE_URL is set; else loud skip notice to stderr. |
~3-5min depending on slow + e2e | Pre-merge sanity, before opening a PR. |
bun run test:slow |
Just the *.slow.test.ts set (intentional cold-path correctness checks). |
seconds-to-minutes | When touching slow-path code. |
bun run test:serial |
Just the *.serial.test.ts set (cross-file-contention quarantine; one bun process per file for true module-registry isolation). |
~1s per quarantined file | Debugging a specific quarantined file. |
bun run test:e2e |
Real Postgres E2E. Requires Docker + DATABASE_URL. Sequential. |
~5-10min | Pre-ship; nightly. |
bun run check:all |
The historical pre-check scripts (22, chained sequentially in package.json). Overlaps verify heavily but is NOT a superset — verify's CHECKS array in scripts/run-verify-parallel.sh (~30 entries incl. typecheck) is the authoritative gate; check:all keeps a few local-only extras (trailing-newline, exports-count, no-legacy-getconnection). |
~10s | Local-only sweep for the extras. |
CI vs local: intentionally divergent file sets
- CI matrix (
.github/workflows/test.yml) runsscripts/test-shard.shacross 10 matrix shards partitioned by weight-aware LPT bin-packing (scripts/sharding.ts) and INCLUDES*.slow.test.ts(the two outlier slow files run as dedicated jobs alongside the matrix). CI EXCLUDES*.serial.test.tsfrom the shards and runs them in a dedicated job viabun run test:serial, one bun process per file — keeping serial files out of the shard processes is what preserves themock.modulequarantine (a top-level mock in one file leaks into every other file sharing its process).bun run verifygets its own job too. CI is the ground truth for "did everything pass." - Local fast loop (
scripts/run-unit-shard.shvia the parallel wrapper) uses round-robin-by-index sharding and EXCLUDES*.slow.test.tsAND*.serial.test.ts. Local trades coverage for inner-loop speed; CI catches what local skips.
This divergence is intentional. Don't try to make them equal — the two scripts deliberately solve different problems. The regression test at test/scripts/run-unit-shard.test.ts pins what the local fast loop should and shouldn't include.
Failure-first logging
When bun run test finds any failure, the wrapper:
- Writes failure blocks (each prefixed with
--- shard N: <test name> ---) to.context/test-failures.log(workspace-local, gitignored). On systems without a writable.context/, falls back to/tmp/gbrain-test-failures.log. - Prints a loud stderr banner with the absolute log path, plus the last 30 lines of the failure log inlined. Banner survives
| head/| tail/ agent-side log truncation. - Writes a one-line-per-shard summary to
.context/test-summary.txt(shard N/M: pass=X fail=Y skip=Z rc=W). - Exits non-zero. Empty failure log + non-zero exit = infrastructure problem (wedged shard, killed child); the banner says so.
If a shard wedges (per-shard GBRAIN_TEST_SHARD_TIMEOUT cap, default 600s), the wrapper writes --- shard N: WEDGED after ${SHARD_TIMEOUT}s --- to the failure log, includes the last 50 lines of the shard log, and proceeds with other shards' results.
File taxonomy
*.test.ts→ fast loop (parallel 8-shard fan-out).*.slow.test.ts→ run viabun run test:slowonly (intentional cold-path tests; would dominate the fast loop's wallclock).*.serial.test.ts→ run viabun run test:serialafter the parallel pass completes; one bun process per file (--max-concurrency=1within a shared process is not enough — the module registry still leaksmock.module). Quarantine for tests that share file-wide state and race when run alongside other files in the samebun testprocess. Several dozen files, discovered by the*.serial.test.tsglob — no list to maintain. Typical residents:mock.module(...)users (top-level mocks leak across files in a shard process, e.g.test/embed.serial.test.ts), env-coupled files (e.g.test/brain-registry.serial.test.ts), and process-lifecycle suites that assert onprocess.exitCode(e.g.test/pglite-engine-disconnect.serial.test.ts). Do not put the parallelism back on a serial file unless you've fixed the contention root cause (it just re-introduces the flake).test/e2e/*.test.ts→ real-Postgres E2E. Skipped whenDATABASE_URLis unset.tests/heavy/*.sh→ ops-shape shell scripts. Cost minutes per run; NOT in defaultbun test. Run viabun run test:heavyor scheduled nightly via.github/workflows/heavy-tests.yml. Examples: pg_upgrade matrix (boot legacy brain → walk to head), RSS budget gate (measure peak worker RSS vs committed baseline), read-latency-under-sync (p50/p95/p99 under concurrent writer load), sync lock regression (N concurrent syncs assert 1 winner + N-1 lock-busy + zero leakedgbrain_cycle_locksrows). Seetests/heavy/README.mdfor when to add a script here vs*.slow.test.ts. Files prefixed with_(e.g.tests/heavy/_build_legacy_fixtures.sh) are helpers/libs invoked by sibling tests — the runner skips them.test/fuzz/*.test.ts→ property-based fuzz harness. Pure-validator targets inpure-validators.test.tsare guarded byscripts/check-fuzz-purity.sh(inbun run verify), whichbun build --target=bunbundles each target and greps the resulting bundle for banned transitive imports (node:fs,node:child_process, engine modules). Anything that fails the guard moves tomixed-validators.test.ts(still property-tested, but no purity guarantee) orfilesystem-validators.test.ts(fs-backed, uses temp dirs). Fuzz tests run in the defaultbun testloop because they're fast (~3s for ~12 properties × 1000 runs each).
Test-isolation lint and helpers
The cross-file flake class is enforced statically by scripts/check-test-isolation.sh, wired into bun run verify and bun run check:all. Rules (non-serial unit files only; *.serial.test.ts and test/e2e/* are skipped):
| Rule | What it bans | Fix |
|---|---|---|
| R1 | process.env.X = ..., bracket assignment, delete process.env.X, Object.assign(process.env, ...), Reflect.set(process.env, ...) |
Use withEnv() from test/helpers/with-env.ts, OR rename file to *.serial.test.ts |
| R2 | mock.module(...) anywhere in the file |
Rename file to *.serial.test.ts (no DI on production code for testability) |
| R3 | new PGLiteEngine( outside ~50 lines after a beforeAll( line |
Use the canonical block (below) inside beforeAll( |
| R4 | Files creating new PGLiteEngine( without engine.disconnect( inside an afterAll( block |
Add afterAll(() => engine.disconnect()) |
Files that violated these rules at the isolation-lint baseline are listed in scripts/check-test-isolation.allowlist. The allow-list MUST shrink over time — never add new entries.
Canonical PGLite block (R3 + R4 compliant)
Every test file that needs a PGLite engine should use this exact pattern:
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
Why this exact shape: beforeAll creates a single engine per file (PGLite WASM cold-start + initSchema is ~20s); beforeEach truncates user data via resetPgliteState ("two orders of magnitude faster" than fresh-engine-per-test); afterAll disconnects so the engine doesn't leak across file boundaries within a shard process.
withEnv pattern (R1 fix)
import { withEnv } from './helpers/with-env.ts';
test('reads OPENAI_API_KEY', async () => {
await withEnv({ OPENAI_API_KEY: 'sk-test' }, async () => {
expect(loadConfig().openai_key).toBe('sk-test');
});
});
// Delete a var (override is undefined):
await withEnv({ GBRAIN_HOME: undefined }, fn);
// Multiple keys:
await withEnv({ A: '1', B: '2', C: undefined }, fn);
withEnv saves the prior value of every key it touches and restores via try/finally — including when the callback throws. It is cross-test safe but NOT intra-file concurrent-safe. process.env is process-global; two test.concurrent() calls in the same file both touching the same key will race. Files using withEnv stay outside the test.concurrent() codemod's eligibility filter.
When to quarantine instead of fix
Rename to *.serial.test.ts when:
- The file uses
mock.module(...)(R2 — there's no clean fix without changing production code). - The file is genuinely env-coupled (e.g.
gbrain-home-isolation.test.ts,claw-test-cli.test.ts) — module-load env readers + ESM caching defeat dynamic-import-after-env tricks. - The file's tests intentionally share state across
it()boundaries.
The quarantine has grown to dozens of files — treat it as debt: every addition needs a reason from the list above, and prefer fixing the contention root cause when one exists.
Unit test inventory
bun test runs all tests without a database. E2E tests skip gracefully when DATABASE_URL is not set.
Unit tests and what they cover:
test/markdown.test.ts— frontmatter parsing;splitBodysentinel precedence, horizontal-rule preservation,inferTypewiki subtypes.test/chunkers/recursive.test.ts— chunking.test/parity.test.ts— operations contract parity.test/cli.test.ts— CLI structure.test/cli-finish-teardown.test.ts— the #2084 teardown contract:computeTeardownDeadlineMsformula/floor/live-registry scaling +GBRAIN_TEARDOWN_DEADLINE_MSoverride (garbage/zero/negative values fall back to the formula);finishCliTeardownclean path (drain BEFORE disconnect, no exit, no warn), backstop on hung drain or disconnect (honors an errored op's exit code), throwing drain/disconnect warned + swallowed; the gbrain-owned verdict channel is immune to PGLite WASMprocess.exitCodewrites;flushThenExitunit coverage with mocked streams (exits once after both stream callbacks, non-TTY aliveness grace, blocked-pipe guard, EPIPE-safe,GBRAIN_FLUSH_GRACE_MSoverride).test/flush-then-exit-harness.test.ts— real spawned-Bun pipe semantics forflushThenExit(fixture:test/fixtures/flush-then-exit-harness.ts): a 4MB piped stdout payload arrives byte-complete with the exit code even with a late reader, small output survives exit with a concurrent reader, and the fence resolves promptly (wall time well under the guard + grace ceiling).test/cli-should-force-exit.test.ts—shouldForceExitAfterMaindaemon-survival gate:serve(stdio and--http) never force-exits, including with preceding global flags; op commands / empty / flag-only argv do; the #2084 case that space-separated global-flag VALUES can't fake a command (--timeout 30s serveresolves to theservedaemon, not a30scommand).test/cli-exit-verdict-pin.test.ts— #2084 structural class pin: grepssrc/so the NEXT rawprocess.exitCode =write fails CI (a raw write bypasses the gbrain-owned verdict channel and gets silently zeroed by the deliberate flush-exit — the bug that made doctor's FAIL path exit 0). Runtime variants live intest/cli-finish-teardown.test.ts; this is the review-time guard.test/cli-pipe-truncation.test.ts— real-CLI pipe completeness (the #1959 incident class), implementation-agnostic: the actual CLI run the way agents run it (piped stdout) produces complete, parseable, byte-stable--tools-jsonoutput and exits deliberately, well under the teardown backstop. Synthetic flush-mechanism coverage stays intest/flush-then-exit-harness.test.ts.test/volunteer-context.test.ts— push-based context core (#2095), hermetic in-memory PGLite:parseWindowlenientuser:/assistant:parsing, multi-turn window extraction, confidence-gated volunteering (arm confidences, multi-turn/newest-turn boosts,min_confidencegate, max-pages cap), slug-only suppression, privacy (rationales are deterministic templates; synopses pass the takes/facts fence), and the approximate usage-stats join.test/watch-command.test.ts—gbrain watchpush transport (#2095): streaming loop, rolling window, session dedupe,--jsonJSONL shape,channel: 'watch'event logging, clean EOF return. Hermetic PGLite + injected line/write deps (no subprocess, no real stdin).test/watch-sigint.serial.test.ts—gbrain watchSIGINT lifecycle against a real spawned CLI subprocess with a tmpdir brain. SERIAL: parallel unit shards flake on concurrent subprocess spawns (same rationale asapply-migrations-pglite-spawn.serial.test.ts).test/cli-format-volunteer.test.ts—formatResult'svolunteer_contexthuman rendering: pointer lines with confidence/arm/rationale, the empty-result message, the approximate stats summary.test/config.test.ts— config redaction.test/files.test.ts— MIME/hash.test/import-file.test.ts— import pipeline.test/upgrade.test.ts— schema migrations.test/file-migration.test.ts— file migration.test/file-resolver.test.ts— file resolution.test/import-resume.test.ts— import checkpoints.test/migrate.test.ts— migration: v8/v9 helper-btree-index SQL structural assertions; 1000-row wall-clock fixtures guarding the O(n²)→O(n log n) fix; v12/v13 SQL shape;sqlFor+transaction:falserunner semantics; themax_stalled DEFAULT 1regression guard; v24sqlFor.pglite: ''no-op assertion; v117context_volunteer_events(named + idempotent entry, documented columns + both source-scoped indexes afterinitSchema, insert + 90-daypurgeStaleVolunteerEventsround-trip).test/bootstrap.test.ts— bootstrap contract: no-op on fresh install, idempotent across twoinitSchema()calls, no-op on modern brain that already has every probed column, full bootstrap path on a simulated legacy brain, fresh-install regression guard, legacylinksshape coverage.test/schema-bootstrap-coverage.test.ts— CI guard.REQUIRED_BOOTSTRAP_COVERAGElists every forward reference inPGLITE_SCHEMA_SQL; the test fails loudly ifapplyForwardReferenceBootstrapskips one (extend both arrays when adding a column-with-index to the embedded schema blob). Also parsessrc/core/migrate.tssource text for everyALTER TABLE ... ADD COLUMN(top-levelsql:,sqlFor.{postgres,pglite}overrides, AND handler-bodyengine.runMigration(N, \ALTER TABLE ...`)) and asserts each (table, column) pair is covered by the bootstrap OR by the schema blob's CREATE TABLE bodies — catching the column-only forward-reference class (e.g.sources.archived,oauth_clients.source_id) that a CREATE INDEX parser alone can't see.parseBaseTableColumns` strips SQL line + block comments before identifying column names so commented-out lines don't hide adjacent columns.test/helpers/schema-diff.ts+test/helpers/schema-diff.test.ts+test/e2e/schema-drift.test.ts— cross-engine schema parity gate. Helper exports puresnapshotSchema(query)/diffSnapshots(pg, pglite, opts)/formatDiffForFailure(diff)/isCleanDiff(diff)over a four-tuple per column (data_type,udt_name,is_nullable,column_default). E2E test spins up fresh PGLite + Postgres, runsengine.initSchema()on each, snapshotsinformation_schema.columns, then diffs. 2-table allowlist (files,file_migration_ledger) — every other Postgres table must reach PGLite viaPGLITE_SCHEMA_SQLor a migration'ssqlFor.pglitebranch. Sentinels foroauth_clients,mcp_request_log,access_tokens,eval_candidatesgive tighter blame messages. Skips withoutDATABASE_URL. Wired intoscripts/e2e-test-map.tsso changes tosrc/schema.sql,src/core/pglite-schema.ts, orsrc/core/migrate.tstrigger it. The failure message names every drift with a paste-ready hint pointing atsrc/core/pglite-schema.ts.test/setup-branching.test.ts— setup flow.test/slug-validation.test.ts— slug validation.test/storage.test.ts— storage backends.test/supabase-admin.test.ts— Supabase admin.test/yaml-lite.test.ts— YAML parsing.test/check-update.test.ts— version check + update CLI.test/pglite-engine.test.ts— PGLite engine, all BrainEngine methods includingaddLinksBatch/addTimelineEntriesBatch(empty batch, missing optionals, within-batch dedup via ON CONFLICT, missing-slug rows dropped by JOIN, half-existing batch, batch of 100) plusconnect()error-wrap assertion (original error nested, #223 link in message, lock released).test/links-timeline-jsonb-poison.test.ts— gbrain#1861 PGLite half (always-on, noDATABASE_URL). Locks thejsonb_to_recordsetbatch-insert path for links/timeline/takes against free-text "poison" payloads (commas, quotes, backslashes, braces, em-dashes) and asserts NUL is stripped from free-text body fields but rejected in identity fields. gbrain#2011 adds lone-UTF-16-surrogate cases: every free-text field (link context; timeline summary/detail/source; take claim/source) well-forms to U+FFFD across batch + scalar write paths, while a surrogate in an identity field (slug) still fail-closed rejects the batch. The Postgres lane (test/e2e/jsonb-batch-poison-postgres.test.ts) is the one that actually reproduced the original crash.test/engine-factory.test.ts— engine factory + dynamic imports.test/integrations.test.ts— recipe parsing, CLI routing, recipe validation.test/publish.test.ts— content stripping, encryption, password generation, HTML output.test/backlinks.test.ts— entity extraction, back-link detection, timeline entry generation.test/lint.test.ts— LLM artifact detection, code fence stripping, frontmatter validation.test/report.test.ts— report format, directory structure.test/skills-conformance.test.ts— skill frontmatter + required sections validation.test/resolver.test.ts— RESOLVER.md coverage, routing validation; round-trip that every quoted RESOLVER.md trigger matches a frontmattertriggers:entry in the target skill, and everyname="<word>"reference in any SKILL.md resolves to a declared op insrc/core/operations.tsor a Minions handler inPROTECTED_JOB_NAMES.test/search.test.ts— RRF normalization, compiled truth boost, cosine similarity, dedup key.test/sql-ranking.test.ts— source-boost helpers: longest-prefix-match in SQL CASE,detail=hightemporal-bypass, three-meta-char LIKE escape (%,_,\), single-quote SQL-literal doubling, env override parsing forGBRAIN_SOURCE_BOOST+GBRAIN_SEARCH_EXCLUDE,resolveBoostMap/resolveHardExcludesmerge semantics.test/dedup.test.ts— source-aware dedup, compiled truth guarantee, layer interactions.test/intent.test.ts— query intent classification: entity/temporal/event/general.test/eval.test.ts— retrieval metrics:precisionAtK,recallAtK,mrr,ndcgAtK,parseQrels.test/check-resolvable.test.ts— resolver reachability, MECE overlap, gap detection, proximity-based DRY detection,extractDelegationTargetscoverage.test/dry-fix.test.ts— auto-fix: three shape-aware expander pure-function tests; five guards (working-tree-dirty, no-git-backup, inside-code-fence, already-delegated within 40 lines, ambiguous-multi-match, block-is-callout).test/doctor-fix.test.ts—gbrain doctor --fixCLI integration: dry-run preview, apply path, JSON output shape.test/backoff.test.ts— load-aware throttling, concurrency limits, active hours.test/fail-improve.test.ts— deterministic/LLM cascade, JSONL logging, test generation, rotation.test/transcription.test.ts— provider detection, format validation, API key errors.test/enrichment-service.test.ts— entity slugification, extraction, tier escalation.test/data-research.test.ts— recipe validation, MRR/ARR extraction, dedup, tracker parsing, HTML stripping.test/minions.test.ts— Minions job queue: CRUD, state machine, backoff, stall detection, dependencies, worker lifecycle, lock management, claim mechanics, depth/child-cap, timeouts, cascade kill, idempotency,child_doneinbox, attachments, removeOnComplete/Fail,max_stalledclamp/default/plumbing coverage.test/extract.test.ts— link extraction, timeline extraction, frontmatter parsing, directory type inference.test/extract-db.test.ts—gbrain extract --source db: typed link inference, idempotency,--typefilter,--dry-runJSON output.test/extract-fs.test.ts—gbrain extract --source fs: first-run inserts + second-run reports zero, dry-run dedups candidates across files, second-run perf regression guard for the N+1 dedup bug.test/link-extraction.test.ts— canonicalextractEntityRefsboth formats,extractPageLinksdedup,inferLinkTypeheuristics,parseTimelineEntriesdate variants,isAutoLinkEnabledconfig.test/graph-query.test.ts— direction in/out/both, type filter, indented tree output.test/features.test.ts— feature scanning, brain_score calculation, CLI routing, persistence.test/file-upload-security.test.ts— symlink traversal, cwd confinement, slug + filename allowlists, remote vs local trust.test/query-sanitization.test.ts— prompt-injection stripping, output sanitization, structural boundary.test/search-limit.test.ts—clampSearchLimitdefault/cap behavior acrosslist_pagesandget_ingest_log.test/repair-jsonb.test.ts— JSONB repair: TARGETS list, idempotency, engine-awareness.test/migrations-v0_12_2.test.ts— JSONB-repair orchestrator phases: schema → repair → verify → record.test/orphans.test.ts— orphans command: detection, pseudo filtering, text/json/count outputs, MCP op.test/postgres-engine.test.ts—statement_timeoutscoping:sql.begin+SET LOCALshape, source-level grep guardrail against a reintroduced bareSET statement_timeout.test/sync.test.ts— sync logic + regression guard asserting top-levelengine.transactionis not called.test/sync-concurrency.test.ts—autoConcurrency()thresholds + PGLite-forces-serial + explicit-override clamping;shouldRunParallel()explicit-bypasses-floor contract;parseWorkers()validation rejecting'0'/'-3'/'foo'/'1.5'/trailing chars.test/sync-parallel.test.ts— PGLite-routed coverage of the bookmark gate under concurrency, head-drift gate, vanished-file failure capture, PGLite-stays-serial, and thegbrain-syncwriter-lock contract.test/sync-failures.test.ts—classifyErrorCoderegex coverage for all 12 codes against literal production message strings frommarkdown.tsandimport-file.ts;summarizeFailuresByCodesort + pre-classified-honor;recordSyncFailurescode-field persistence;acknowledgeSyncFailuresAcknowledgeResultshape + backfill on legacy entries.test/doctor.test.ts— doctor command; assertions thatjsonb_integrityscans the four JSONB write sites andmarkdown_body_completenessis present.test/utils.test.ts— shared SQL utilities +tryParseEmbeddingnull-return and single-warn semantics.test/build-llms.test.ts—llms.txt/llms-full.txtgenerator: path resolution, idempotence, spec shape, regen-drift guard, content contract, AGENTS.md install-path mirror, size-budget enforcement.test/oauth.test.ts— OAuth 2.1 provider: register, getClient,client_credentialsgrant exchange,authorization_codeflow with PKCE challenge/verifier, refresh token rotation,verifyAccessTokenwith both OAuth + legacyaccess_tokensfallback,revokeToken,sweepExpiredTokens; contract test assertingscope+localOnlyannotations on all operations;coerceTimestampunit cases (null/undefined/string/number/throw-on-NaN); NULL-expires_at-as-expired contract for both refresh + access token paths; cascade-delete contract assertingrevoke-clientpurgesoauth_tokens+oauth_codesvia FK CASCADE; cross-client isolation (wrong-client attempt MUST reject AND rightful owner MUST still succeed atomically afterward); empty-stringredirect_uribypass guard; PKCE DCR public-client gate (token_endpoint_auth_method: "none"returns noclient_secret, defaultclient_secret_postclients get the one-time-reveal secret,getClientNULL→undefined normalization, full PKCE/authorize→/tokenround-trip against a public client).test/mcp-dispatch-summarize.test.ts—summarizeMcpParamsinvariants: declared-keys allow-list intersection, attacker-key-name leak guard (unknown keys counted not named), 1KB byte bucketing for size-probe defense, missing op falls through to fully-redacted shape, declared-keys sorted for deterministic output.test/trust-boundary-contract.test.ts— fail-closed trust semantics under cast bypass:ctx.remote === undefinedtreated as remote/untrusted at every flipped call site;as anyandPartial<>spreads can't downgrade trust by accident.test/check-resolvable-cli.test.ts— CLI wrapper: exit codes, JSON envelope shape, AGENTS.md fallback chain.test/regression-v0_16_4.test.ts—findRepoRootregression guard, hermetic startDir parameterization.test/repo-root.test.ts—findRepoRootwalk semantics + default-arg parity; the 4-tierautoDetectSkillsDirfallback chain ($OPENCLAW_WORKSPACE→~/.openclaw/workspace→ repo-root →./skills); RESOLVER.md/AGENTS.md filename precedence; explicit-env-wins-over-repo-root; tier-0$GBRAIN_SKILLS_DIRvalid/invalid/precedence-over-OPENCLAW_WORKSPACE; the install-path walk inautoDetectSkillsDirReadOnly; no-drift on primary success;AUTO_DETECT_HINT+AUTO_DETECT_HINT_READ_ONLYcontent; regression guard asserting the sharedautoDetectSkillsDirMUST NEVER return'install_path'source (how the read-path/write-path split stays safe).test/resolver-merge.test.ts— multi-file resolver merge:findAllResolverFilesempty / RESOLVER.md-only / AGENTS.md-only / both-present (RESOLVER.md first);checkResolvablemerge semantics acrossskills/RESOLVER.md+../AGENTS.mdfor the OpenClaw layout where the skillpack ships a thin RESOLVER.md and the real dispatcher lives at the workspace root; dedup byskillPath(first occurrence wins); AGENTS.md-at-workspace-root works alone.test/filing-audit.test.ts— filing audit:writes_pages/writes_tofrontmatter, filing-rules JSON validation.test/skill-brain-first.test.ts— shared frontmatter parser;analyzeSkillBrainFirstcompliance ladder across 9 fixtures undertest/fixtures/brain-first-skills/(compliant-callout, compliant-phase, compliant-position, exempt-frontmatter, missing-brain-first, multi-pattern, negation-prose, no-external, typo-frontmatter); offset helpers; external-lookup regex shape; audit snapshot+diff transition logic;FORMERLY_HARDCODED_EXEMPTregression absorption.test/routing-eval.test.ts— fixture parsing, structural routing,ambiguous_with, Haiku tie-break layer.test/skill-manifest.test.ts— skill manifest parser: drift detection, managed-block markers.test/skillify-scaffold.test.ts—gbrain skillify scaffoldstubs: SKILL.md, script, tests, routing-eval fixtures.test/skillpack-install.test.ts—gbrain skillpack installmanaged-block install / update / no-clobber semantics.test/skillpack-sync-guard.test.ts— sync-guard: bundled skills stay byte-identical toskills/source.test/http-transport.test.ts— HTTP transport: bearer auth + missing/no-Bearer/unknown/revoked +/healthbypass; dispatch.ts round-trip; invalid_params; application/json response shape (not SSE); CORS default-deny + allowlist; body cap on Content-Length AND chunked; two-bucket rate limit (refill, exhaust+Retry-After, LRU eviction, TTL prune, pre-auth IP fires before DB);mcp_request_logaudit on success + auth_failed.test/restart-sweep.test.ts—recipes/restart-sweep.mdinlined script: sentinel-anchored fenced-block extraction with salted tmp filenames to bypass ESM cache; constructor-time env reads (proves no module-load snapshot); idempotency layer load/save/atomic-tmp-rename/corrupt-JSON-recovery/30-day-prune;(sessionKey, lastAlertedAt)cooldown gate with 6h threshold; AGGRESSIVE-gate two-state tests; execFile argv shape proving shell metachars inOPENCLAW_TELEGRAM_GROUPcannot reach/bin/sh; real-\n-not-literal alert formatting;GBRAIN_HOMEstate path override.test/eval-longmemeval.test.ts— LongMemEval harness, hermetic with noDATABASE_URLand no API keys: PGLite create + reset over runtime-enumeratedpg_tables, infrastructure-table preservation across resets, JSONL question parsing, retrieval-only and answer-gen modes via stubbedThinkLLMClient,--limitcutoff,--keyword-onlyvs hybrid, default--expansion=offbehavior, perf gate (p50 < 30ms / p99 < 50ms warm reset+import+search on Apple Silicon),--helpworks without a configured brain, fixture round-trip viatest/fixtures/longmemeval-mini.jsonl.test/longmemeval-sanitize.test.ts— sanitization parity pinning thatINJECTION_PATTERNSfromsrc/core/think/sanitize.tsis the single source of truth (adding a pattern there must cover both<take>framing and<chat_session>framing, no per-surface regex drift).test/openai-compat-multimodal.test.ts— gateway's openai-compatible multimodal path: happy-path single + multi-input embedding, unauthenticated proxy mode, dimension-mismatch guard (throwsAIConfigErrorwith model id + observed + expected pre-storage), default-dim fallback when recipe declaresdefault_dims, HTTP 401 / 400 / malformed-JSON / non-array error paths, regression that the existing Voyage/multimodalembeddingsrecipe still routes through its dedicated path. Hermetic via the__setEmbedTransportForTestsseam.test/serve-stdio-lifecycle.test.ts—MCP_STDIO=1env guard: stdin EOF does NOT trigger shutdown when the env is set, SIGTERM still does (guard scope is correct), unset env preserves the CLI lifecycle. Exercises theServeOptions.mcpStdio?: booleantest seam directly so tests don't mutateprocess.env.
E2E test inventory
E2E tests live in test/e2e/ and run against real Postgres+pgvector (require DATABASE_URL), except where noted as PGLite in-memory (no DATABASE_URL needed).
bun run test:e2eruns Tier 1 (mechanical, all operations, no API keys). Includes dedicated cases for the postgres-engineaddLinksBatch/addTimelineEntriesBatchbind path — postgres-js's JSONB bind (jsonb_to_recordset(($1::jsonb)->'rows')) differs from PGLite's and gets its own coverage.test/e2e/search-quality.test.ts— search quality against PGLite (no API keys, in-memory).test/e2e/graph-quality.test.ts— knowledge graph pipeline (auto-link via put_page, reconciliation, traversePaths) against PGLite in-memory.test/e2e/jsonb-batch-poison-postgres.test.ts— gbrain#1861 regression, the engine that actually crashed. Seeds free-text "poison" context (Zoom URL with?pwd=, commas, quotes, Windows backslash path, braces, em-dash) and asserts the links/timeline/takes batch writers no longer error with "malformed array literal"; also asserts NUL is stripped from free-text bodies (context/summary/detail/claim) and still rejected in identity fields. gbrain#2011 adds the lone-surrogate crash lock: a lone UTF-16 surrogate in free text (the value that abortedextract --stalewith22P02on Supabase) well-forms to U+FFFD across batch + scalar paths (incl. timeline + takesource), while a surrogate in an identity field still rejects the batch.DATABASE_URL-gated.test/e2e/postgres-jsonb.test.ts— round-trips all 5 JSONB write sites (pages.frontmatter,raw_data.data,ingest_log.pages_updated,files.metadata,page_versions.frontmatter) against real Postgres and assertsjsonb_typeof='object'plus->>'key'returns the expected scalar. Guards against the double-encode bug.test/e2e/integrity-batch.test.ts— parity forscanIntegrity's batch-load fast path vs sequential. Cases (dedup, hits, validate, topPages) seed a fixture and assert both paths return identical results. Dedup case uses raw SQL viagetConn().unsafe()to seed a(test-source-2, people/alice)row alongside the default-source row, sinceengine.putPagedoesn't take asource_id. Pins multi-source overcounting; the "multi-source duplicate slugs scan once" case expects both batch + sequential paths to report 2.test/e2e/jsonb-roundtrip.test.ts— companion regression against the 4 doctor-scanned JSONB sites. Assertion-level overlap withpostgres-jsonb.test.tsis intentional defense-in-depth: if doctor's scan surface drifts from the actual write surface, one of these tests catches it.test/e2e/sync.test.ts—--skip-failedfailure-loop test alongside happy-path tests: broken file →performSyncreturnsblocked_by_failureswith grouped breakdown →performSync({skipFailed: true})advances bookmark and returnsAcknowledgeResultwith code summary → second broken file → second cycle. Saves and restores the user's real~/.gbrain/sync-failures.jsonlso the test is hermetic. Asserts bookmark gating, JSONL state, dedup across paths, summary aggregation, and the literal doctor-rendering string format.test/e2e/upgrade.test.ts— check-update against real GitHub API (network required).test/e2e/minions-shell-pglite.test.ts— PGLite--followinline shell-job path (in-memory, noDATABASE_URLrequired) — the path the minion-orchestrator skill documents for dev use.test/e2e/pglite-cli-exit.serial.test.ts— real spawned-CLI exit behavior on PGLite (in-memory, noDATABASE_URL): read commands (search/get/query) exit 0 promptly; CLI_ONLYcaptureexits clean and frees the single-writer lock; the#2084describes pin every swept disconnect site — a failed op exits 1 with the error on stderr, and the dashboard, read-only-timeout, doctor, anddream --dry-runpaths all exit with no force-exit banner.test/e2e/pgbouncer-teardown.test.ts— PgBouncer TRANSACTION-mode teardown (#2084 / the #1972→#2015→#2084 class). Pins the bug CLASS, not timings: a CLI op against a txn-mode pooled URL exits 0 with intact stdout and does NOT ride the 10s hard-deadline backstop (theengine.disconnect() did not returnbanner is the smoking gun — pre-#2084 it printed on 100% of query-shaped ops). Gated byGBRAIN_PGBOUNCER_URL+GBRAIN_PGBOUNCER_DIRECT_URL(NOTDATABASE_URL) — set automatically bybun run ci:local'spgbouncercompose service; skips gracefully elsewhere. Uses a DEDICATEDgbrain_pgbouncerdatabase so it never races thegbrain_testTRUNCATE fixtures.test/e2e/volunteer-context-postgres.test.ts—volunteer_contexton REAL Postgres (#2095; engine parity beyond the hermetic PGLite unit suite): resolution arms through the actual op handler, the fire-and-forget volunteer-event sink landing rows, the stats join, and the RLS pin thatcontext_volunteer_eventshas ROW LEVEL SECURITY enabled (keeps the v35 auto-RLS event trigger honest for migration-created tables).DATABASE_URL-gated.test/e2e/openclaw-reference-compat.test.ts—check-resolvable+skillpack installagainst a minimal AGENTS.md workspace fixture (test/fixtures/openclaw-reference-minimal/), regression guard for the OpenClaw deployment shape.test/e2e/search-swamp.test.ts— reproduces the source-swamp case. Seeds a curatedoriginals/talks/article-outline-fat-codepage against two<fork>/chat/pages stuffed with the same multi-word phrase. Asserts the article wins keyword AND vector ranking, thatdetail=highlets the chat swamp re-surface, and thatsource_idpasses through the two-stage CTE intact. PGLite in-memory.test/e2e/search-exclude.test.ts—test/+archive/pages hidden by default,include_slug_prefixesopts back in, caller-suppliedexclude_slug_prefixesadds to defaults. Both keyword and vector search paths.test/e2e/engine-parity.test.ts— Postgres ↔ PGLite top-result and result-set parity forsearchKeyword+searchVector(Postgres ranks pages then picks best chunk while PGLite returns chunks directly, so the source-boost behavior needs parity coverage). Skips withoutDATABASE_URL.test/e2e/postgres-bootstrap.test.ts— exercisesPostgresEngine.initSchema()directly against a fresh real Postgres database. Asserts the bootstrap path is no-op on fresh installs and that SCHEMA_SQL replays cleanly through the engine path (not via the standalonedb.initSchemafromsrc/core/db.ts).test/e2e/http-transport.test.ts—gbrain serve --httpend-to-end against real Postgres: bearer auth round-trip,last_used_atSQL-level debounce,mcp_request_logrow insertion on success and auth_failed paths,/healthDB-down → 503 (DB-probing health check), and the dispatch round-trip with a real operation. Skips withoutDATABASE_URL.test/e2e/serve-http-oauth.test.ts— real-Postgres E2E againstgbrain serve --httpwith full OAuth 2.1. Spawns a subprocess server, registers a client via the CLI, mintsclient_credentialstokens, exercises the/mcpJSON-RPC pipeline. Real DCR/registerHTTP-level response-shape test (assertstypeof body.client_id_issued_at === 'number'over the wire, RFC 7591 §3.2.1); real CLI subprocess test forrevoke-client(registers → mints token → revokes viaexecSync→ asserts token rejected at/mcp→ asserts re-run exits 1); server fixture flips on--enable-dcrso/registeris reachable. bun execSync env-inheritance contract: bun'sexecSyncdoes NOT inherit env mutations done viaprocess.env.X = ..., only OS-level env from before bun started. helpers.ts loads.env.testingand setsDATABASE_URLviaprocess.envmutation, which is invisible to subprocesses unlessenv: { ...process.env }is passed explicitly — every subprocess call in this file passesenv: { ...process.env }. Reference fix for the same failure mode in sibling sync/cycle/dream/claw-test E2Es.afterAllcleanup is guarded onclientId(won't throw ifbeforeAllfailed before registration); cleanup errors surface to stderr without throwing so real test failures aren't masked. Also covers the trust-boundary fix: an HTTP MCPsubmit_jobforname: "shell"MUST reject with a permission error (request handler setsremote: trueandsubmit_job's protected-name guard fires), and the same guard rejects subagent submission. Skips withoutDATABASE_URL.test/e2e/sync-parallel.test.ts—DATABASE_URL-gated. 60-file Postgres sync at concurrency=4 imports all + no connection leak (probespg_stat_activitybefore/after to confirm worker engines disconnected). 120-file serial-vs-parallel benchmark printsSYNC_PARALLEL_BENCH N files | serial=Xms | parallel(4)=Yms | speedup=Zx. Asserts parallel ≤ serial × 1.5 (CI-noise tolerant; not a strict speedup gate).test/e2e/multi-source-bug-class.test.ts— PGLite in-memory regression suite pinning every multi-source bug site:listAllPageRefsordering by(source_id, slug),getPagewith sourceId picks the right(source, slug)row,extract-takesprocesses both overlappingpeople/alicerows independently,listPagesfilters correctly withPageFilters.sourceId,addLinksBatchwithfrom/to_source_idtargets the right rows,validateSourceIdrejects path traversal, reverse-write disk layout usesbrainDir/.sources/<id>/<slug>.mdfor non-default sources. NoDATABASE_URLneeded. Wired intoscripts/e2e-test-map.tsso changes to extract-takes / patterns / synthesize / embed / extract / migrate-engine auto-trigger it.test/e2e/source-isolation-pglite.test.ts— PGLite in-memory regression suite pinning the source-isolation seal at two layers. Engine layer:searchKeyword/searchVector/searchKeywordChunks/listPages/getPage/traverseGraph/traversePathsapplysourceId(scalar fast path) andsourceIds(array path) correctly across both engines. Op-handler layer: routes throughsourceScopeOpts(ctx)so aread+write-scoped OAuth client bound to--source dept-xcannot see rows from neighboring sources viasearch,query,list_pages,get_page, orfind_experts. Covers bothctx.sourceId(single-source clients) andctx.auth.allowedSources(federated_read clients) precedence; federated array wins over scalar wins over nothing. NoDATABASE_URLneeded.test/e2e/skill-brain-first.test.ts— doctor reportsskill_brain_firstcheck with structured issues;--fix --dry-runpreviews insertion without writing;--fixapplies the canonical Convention callout idempotently;brain_first: exemptfrontmatter resolves the warn;brain_first_typosurfaces a paste-ready hint; audit JSONL recordsdetected/resolved/fixedtransitions; stable brain emits 0 audit lines/run.- Tier 2 (
test/e2e/skills.test.ts) requires OpenClaw + API keys, runs nightly in CI. - If
.env.testingdoesn't exist in this directory, check sibling worktrees:find ../ -maxdepth 2 -name .env.testing -print -quitand copy it here if found. - Run E2E tests without asking permission. When you want to verify behavior, there's a relevant E2E test, or you're shipping anything covered by an E2E suite — spin up the test DB, run the tests, tear down. Don't ask, don't propose it, don't defer. The lifecycle is short (~2-30s startup, sub-minute tests, instant teardown) and the gate value is high. Skipping with "DATABASE_URL unset" is silent regression, not caution.
API keys and running ALL tests
ALWAYS source the user's shell profile before running tests:
source ~/.zshrc 2>/dev/null || true
This loads OPENAI_API_KEY and ANTHROPIC_API_KEY. Without these, Tier 2 tests
skip silently. Do NOT skip Tier 2 tests just because they require API keys — load
the keys and run them.
When asked to "run all E2E tests" or "run tests", that means ALL tiers:
- Tier 1:
bun run test:e2e(mechanical, sync, upgrade — no API keys needed) - Tier 2:
test/e2e/skills.test.ts(requires OpenAI + Anthropic + openclaw CLI) - Always spin up the test DB, source zshrc, run everything, tear down.
E2E test DB lifecycle (ALWAYS follow this)
You are responsible for spinning up and tearing down the test Postgres container. Do not leave containers running after tests. Do not skip E2E tests, do not ask permission to run them — see the "run without asking" rule above.
- Check for
.env.testing— if missing, copy from sibling worktree. Read it to get the DATABASE_URL (it has the port number). - Check if the port is free:
docker ps --filter "publish=PORT"— if another container is on that port, pick a different port (try 5435, 5436, 5437) and start on that one instead. - Start the test DB:
Wait for ready:
docker run -d --name gbrain-test-pg \ -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres \ -e POSTGRES_DB=gbrain_test \ -p PORT:5432 pgvector/pgvector:pg16docker exec gbrain-test-pg pg_isready -U postgres - Bootstrap the schema (required — fresh containers have no
oauth_clients,mcp_request_log,pagesetc.; tests likeserve-http-oauth.test.tswill fail withrelation "oauth_clients" does not existif you skip this):DATABASE_URL=postgresql://postgres:postgres@localhost:PORT/gbrain_test \ bun run src/cli.ts doctor --json > /dev/null 2>&1gbrain doctortriggersinitSchema()on first connect, which is the canonical way to bring a fresh DB to head.apply-migrations --yesalone does NOT seed the base schema — it runs ALTER-style migrations on top ofinitSchema. Tests that bypass the engine (rawexecSync-spawnedauth register-client) hit the schema directly and need this step to have run first. - Run E2E tests:
DATABASE_URL=postgresql://postgres:postgres@localhost:PORT/gbrain_test bun run test:e2e - Tear down immediately after tests finish (pass or fail):
docker stop gbrain-test-pg && docker rm gbrain-test-pg
Never leave gbrain-test-pg running. If you find a stale one from a previous run,
stop and remove it before starting a new one.