Compare commits

..
Author SHA1 Message Date
Garry TanandClaude Fable 5 b46b349028 test: reset gateway in afterAll in cli-multimodal-integration — cross-file config leak
Review hardening on #3116: the file resets the gateway in beforeEach but
left it configured after the last test, leaking a multimodal gateway
config into whatever file runs next in its shard (same class as the
adaptive-embed-batch leak fixed on master).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 12:12:41 -07:00
73256e56a0 fix(config): route remaining hand-rolled configureGateway callsites through buildGatewayConfig
Takeover of #2430 (rebased onto master; the providers.ts hunk already
landed there). Five command callsites still rebuilt partial gateway
configs by hand, dropping file-plane API keys (config.json
openai/anthropic/zeroentropy/openrouter keys), env-provided provider
base URLs, and provider_chat_options:

- src/commands/eval-takes-quality.ts: spread `{...cfg, ...process.env}`
  at top level, so the gateway's `env` field was never populated at all —
  availability/diagnose checks dereferenced undefined and threw.
- src/commands/eval-cross-modal.ts: configureGatewayForCli hand-rolled
  both branches; now one buildGatewayConfig call.
- src/commands/init.ts: all three configureGateway sites (merged-
  precedence helper + PGLite + Postgres init) now overlay the resolved
  model fields on loadConfig() and route through the adapter.
- src/commands/migrations/in-process.ts: runMigrateOnlyCore same.

Tests: new test/eval-takes-quality-gateway.test.ts (fails with a throw
on the old code path); LLAMA_SERVER_RERANKER_BASE_URL added to the
adapter passthrough sweep; cli-multimodal-integration now imports the
real adapter instead of a hand-maintained mirror copy. KEY_FILES.md
entries updated to current state.

Co-authored-by: TheAngryPit <TheAngryPit@users.noreply.github.com>

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:34:19 -07:00
16 changed files with 115 additions and 297 deletions
+2 -2
View File
@@ -79,7 +79,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this
- `docs/architecture/RETRIEVAL.md` + `docs/architecture/RETRIEVAL_MAXPOOL_INCIDENT.md` — retrieval-pipeline architecture reference + the named-thing-miss incident write-up (root cause, the five-layer fix, the eval that pins it).
- `src/core/types.ts` extension + `src/core/operations.ts:search` + `src/core/import-file.ts` + `src/cli.ts` + `src/core/search/telemetry.ts` — the wiring layer for the retrieval cathedral. `SearchResult` gains `evidence`, `create_safety`, `title_match_boost`, `alias_hit` (all optional; evidence/create_safety reference the union types in `evidence.ts`). The `search` MCP op uses a cheap-hybrid path by default and accepts a per-call `mode` (conservative|balanced|tokenmax) honored ONLY for trusted/local callers (`resolvePerCallMode(ctx, ...)` — remote callers use the configured mode so a remote provider can't force tokenmax spend); every search path stamps evidence fail-soft. `importFromContent` projects frontmatter `aliases:` into `page_aliases` via `normalizeAliasList` + `engine.setPageAliases` so new + changed pages register aliases at ingest. `src/cli.ts` adds the `gbrain search diagnose` dispatch (lazy import) and reconciles the `search` CLI path with the cheap-hybrid op. `src/core/search/telemetry.ts` extends the rollup with the rank-1 base_score drift signal (sum/count + 3 coarse buckets, aggregate not per-query), surfaced via `gbrain search stats`, backed by migration v111's `search_telemetry` columns. Tests: `test/cli-search-dispatch.test.ts`, `test/search/per-call-mode.test.ts`, `test/search/telemetry-rank1.test.ts`, `test/search/title-boost-stage.test.ts`, `test/search/alias-hop.test.ts`, `test/search/evidence.test.ts`, `test/search/searchvector-maxpool.test.ts`, `test/search/pre-migration-failopen.test.ts`.
- `src/commands/eval.ts``gbrain eval` command: single-run table + A/B config comparison. Sub-subcommand dispatch on `args[0]` routes `gbrain eval export` + `gbrain eval prune` + `gbrain eval replay` into session-capture handlers; bare `gbrain eval --qrels …` fall-through preserves the legacy IR-metrics flow. `gbrain eval cross-modal` is in the dispatch (the user-facing path is the cli.ts no-DB branch — `src/commands/eval.ts:cross-modal` only fires when callers re-enter with an existing engine).
- `src/commands/eval-cross-modal.ts` — multi-model quality gate. Three different-provider frontier models score the OUTPUT against the TASK on a 5-dim list. Verdict `pass` (exit 0) / `fail` (exit 1) / `inconclusive` (exit 2; <2/3 model successes). Reuses `src/core/ai/gateway.ts:chat()` so config/auth/aliasing comes from the gateway recipe registry — no parallel provider stack. Self-configures the gateway (`configureGateway(loadConfig() + process.env)`) since the cli.ts dispatch bypasses `connectEngine()`. Default cycles 3 in TTY, 1 in non-TTY (partial cost guardrail) via the shared `resolveCycleDefault(explicit, isTty)` in `src/core/eval/cycle-default.ts`; the cost-estimate banner appends `cycleDefaultSuffix(...)` (`for 1 cycle(s) (non-interactive default; --cycles N for more)`) when the value is the silent non-TTY fallback, so the 1-vs-3 difference isn't hidden. Receipts land at `gbrainPath('eval-receipts')/<slug>-<sha8-of-output>.json`. `--batch <jsonl> [--limit N] [--concurrent N] [--max-usd FLOAT] [--yes]` fans out cross-modal scoring across a LongMemEval-shape JSONL; mutually exclusive with `--task` (fail-fast usage error if both set); filters `kind: "by_type_summary"` rows; pre-flight cost estimate refuses if `> --max-usd` without `--yes` (default cap 5.00 USD). Semaphore-bounded fan-out via inline `runWithLimit<T>(items, limit, fn)` (exported for unit tests): max N questions in-flight × 3 model slots = ceiling of 3N parallel API calls (default `--concurrent 3` → 9). Per-question receipts land in a per-batch tempdir and are deleted at end of run; the summary receipt inlines per-question verdicts as JSON, not file paths. Exit precedence (batch-level policy, NOT inherited from aggregate.ts): ERROR > FAIL > INCONCLUSIVE > PASS. DI seam: `runEvalCrossModal(args, opts?: {runEval?: typeof runEval})` mirrors `runEvalLongMemEval(args, {client?})`; tests pass `opts.runEval` to bypass real LLM calls AND the gateway availability check. Pinned by `test/eval-cross-modal-batch.test.ts`.
- `src/commands/eval-cross-modal.ts` — multi-model quality gate. Three different-provider frontier models score the OUTPUT against the TASK on a 5-dim list. Verdict `pass` (exit 0) / `fail` (exit 1) / `inconclusive` (exit 2; <2/3 model successes). Reuses `src/core/ai/gateway.ts:chat()` so config/auth/aliasing comes from the gateway recipe registry — no parallel provider stack. Self-configures the gateway via `buildGatewayConfig(loadConfig() ?? {})` since the cli.ts dispatch bypasses `connectEngine()`, so file-plane keys, env base URLs, and provider_chat_options follow the same adapter path as runtime. Default cycles 3 in TTY, 1 in non-TTY (partial cost guardrail) via the shared `resolveCycleDefault(explicit, isTty)` in `src/core/eval/cycle-default.ts`; the cost-estimate banner appends `cycleDefaultSuffix(...)` (`for 1 cycle(s) (non-interactive default; --cycles N for more)`) when the value is the silent non-TTY fallback, so the 1-vs-3 difference isn't hidden. Receipts land at `gbrainPath('eval-receipts')/<slug>-<sha8-of-output>.json`. `--batch <jsonl> [--limit N] [--concurrent N] [--max-usd FLOAT] [--yes]` fans out cross-modal scoring across a LongMemEval-shape JSONL; mutually exclusive with `--task` (fail-fast usage error if both set); filters `kind: "by_type_summary"` rows; pre-flight cost estimate refuses if `> --max-usd` without `--yes` (default cap 5.00 USD). Semaphore-bounded fan-out via inline `runWithLimit<T>(items, limit, fn)` (exported for unit tests): max N questions in-flight × 3 model slots = ceiling of 3N parallel API calls (default `--concurrent 3` → 9). Per-question receipts land in a per-batch tempdir and are deleted at end of run; the summary receipt inlines per-question verdicts as JSON, not file paths. Exit precedence (batch-level policy, NOT inherited from aggregate.ts): ERROR > FAIL > INCONCLUSIVE > PASS. DI seam: `runEvalCrossModal(args, opts?: {runEval?: typeof runEval})` mirrors `runEvalLongMemEval(args, {client?})`; tests pass `opts.runEval` to bypass real LLM calls AND the gateway availability check. Pinned by `test/eval-cross-modal-batch.test.ts`.
- `src/core/eval/cycle-default.ts` — single source of truth for the eval cycle-count default. Exports `DEFAULT_CYCLES_TTY = 3`, `DEFAULT_CYCLES_NONTTY = 1`, `resolveCycleDefault(explicit, isTty): {cycles, usedNonTtyDefault}`, and `cycleDefaultSuffix(r)` (returns ` (non-interactive default; --cycles N for more)` only when the non-TTY default was applied, else `''`). Consumed by `eval-cross-modal.ts`, `eval-takes-quality.ts` (run + regress), and `takes-quality-eval/runner.ts` (core uses only the constant — library stays TTY-agnostic; the CLI owns the TTY=3 upgrade + banner annotation). `eval-suspected-contradictions.ts` applies the same transparency to its `$5`/`$1` budget default via a `budgetUsdExplicit` flag (the budget is overwritten in-place so explicitness can't be inferred post-hoc). Not shared with `resolveWorkersWithClamp` (different domain, no engine, no dedup). Pinned by `test/eval/cycle-default.test.ts`, `test/eval-suspected-contradictions-budget-default.test.ts`.
- `src/core/cross-modal-eval/json-repair.ts``parseModelJSON(raw)` named export with a 4-strategy fallback chain (direct parse → fence-strip → trailing-comma + single-quote + embedded-newline repair → regex nuclear option). Adversarial input throws rather than fabricating scores — the aggregator treats a throw as "this model contributed nothing this cycle" so the gate stays correct at >=2/3 successes.
- `src/core/cross-modal-eval/aggregate.ts` — pure verdict logic. Pass criterion: `(successes >= 2) AND (every dim mean >= 7) AND (every dim min across models >= 5)`. Inconclusive when <2/3 models returned parseable scores (regression guard for the v1 `Object.values({}).every(...) === true` empty-array PASS bug).
@@ -138,7 +138,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this
- `src/core/minions/queue.ts` extension — `MinionQueue.add()` rejects `subagent` jobs whose `data.model` resolves via `isAnthropicProvider()` to a non-Anthropic provider. Lazy-imports `model-config.ts` to avoid pulling engine types into queue's eager-load surface. Layer 1 of the three-layer subagent provider enforcement (layers 2+3: `model-config.ts:enforceSubagentAnthropic` runtime fallback + `src/commands/doctor.ts` `subagent_provider` check). Pinned by `test/agent-cli.test.ts`.
- `src/commands/models.ts``gbrain models [--json]` read-only routing dashboard: prints tier defaults (`utility`/`reasoning`/`deep`/`subagent`), the resolved value for each (re-walking the resolution chain), every per-task override (11 `PER_TASK_KEYS`: `models.dream.synthesize`, `models.dream.patterns`, `models.drift`, `models.auto_think`, `models.think`, `models.subagent`, `facts.extraction_model`, `models.eval.longmemeval`, `models.expansion`, `models.chat`, `models.dream.synthesize_verdict`), the alias map, and a source-of-truth column (`default` / `config: <key>` / `env: <VAR>`). `gbrain models doctor [--skip=<provider>] [--json]` fires a 1-token `gateway.chat()` probe against each configured chat + expansion model and classifies failures into `{model_not_found, auth, rate_limit, network, unknown}`. Wired into `cli.ts` dispatch + `CLI_ONLY` set. A zero-token `embedding_config` probe runs FIRST, before any chat/expansion probes spend money: `probeEmbeddingConfig()` reads `getEmbeddingModel()` + `getEmbeddingDimensions()` and (for Voyage flexible-dim models) checks `isValidVoyageOutputDim(dims)` against `VOYAGE_VALID_OUTPUT_DIMS`. `ProbeStatus` variant `'config'` + optional `fix?: string` on `ProbeResult` surface a paste-ready `gbrain config set ...` line in human + JSON output; touchpoint label `'embedding_config'` joins `'chat'` and `'expansion'`.
- `src/core/init-embed-check.ts` — embedding-key validation at `gbrain init`. `runInitEmbedCheck(opts)` runs a config-only `diagnoseEmbedding` (catches a missing key for ANY provider) plus a best-effort `liveTestEmbed` (1-token `gateway.embed(['probe'], {inputType:'query', abortSignal})`, 5s `AbortController` timeout, never throws — catches an invalid/expired key). Loud warning to stderr; init still exits 0 (`--no-embedding` is the deferred-setup escape; `--skip-embed-check` / `GBRAIN_INIT_SKIP_EMBED_CHECK=1` skip the check). Builds the effective env (`process.env` + file-plane `openai/anthropic/zeroentropy_api_key` from `loadConfigFileOnly()` + `opts.apiKey`) and configures the gateway via `buildGatewayConfig` before diagnose/probe, so the check sees the same keys AND provider base URLs runtime will (no false "missing key" for config.json-keyed users; the probe hits the right endpoint). Init-specific warning text names `--no-embedding` / `--skip-embed-check`, not the sync-flavored `--no-embed`. Wired into `initPGLite` + `initPostgres` in `src/commands/init.ts`, with the result added to the `--json` envelope as `embedding_check {ok, reason?, live_ok?}`. Pinned by `test/init-embed-check.test.ts` (hermetic via the gateway embed-transport seam + `withEnv`).
- `src/core/ai/build-gateway-config.ts``buildGatewayConfig(c: GBrainConfig): AIGatewayConfig`, extracted from `src/cli.ts` (which re-exports it for back-compat). Lets core modules (`init-embed-check.ts`) reuse it without importing the CLI entrypoint. Single owner of folding file-plane API keys (openai/anthropic/zeroentropy) into the gateway env and threading local-server `*_BASE_URL` env vars into base_urls. `process.env` wins EXCEPT empty-string / undefined values are dropped before the merge, so an injected empty `ANTHROPIC_API_KEY=''` (Claude Code neuters subprocess LLM calls this way) can't clobber a valid config-plane key; `'0'` / `'false'` are preserved. Pinned by `test/ai/build-gateway-config.test.ts`.
- `src/core/ai/build-gateway-config.ts``buildGatewayConfig(c: GBrainConfig): AIGatewayConfig`, extracted from `src/cli.ts` (which re-exports it for back-compat). Single owner of translating stored config into gateway config — consumed by CLI runtime, init (`gbrain init`'s three configureGateway sites), `init-embed-check.ts`, the eval commands (cross-modal, takes-quality), provider diagnostics, and the in-process migration path. Folds file-plane API keys (openai/anthropic/zeroentropy/openrouter) into the gateway env and threads local-server `*_BASE_URL` env vars into base_urls; caller-provided `provider_base_urls` config wins over env base URLs. `process.env` wins EXCEPT empty-string / undefined values are dropped before the merge, so an injected empty `ANTHROPIC_API_KEY=''` (Claude Code neuters subprocess LLM calls this way) can't clobber a valid config-plane key; `'0'` / `'false'` are preserved. Pinned by `test/ai/build-gateway-config.test.ts`.
- `src/commands/doctor.ts` extension — `subagent_provider` check (layer 3 of 3). Warns when `models.tier.subagent` is explicitly set non-Anthropic (message names the bad value + paste-ready fix `gbrain config set models.tier.subagent anthropic:claude-sonnet-4-6`); also warns when `models.default` would sneak `subagent` into a non-Anthropic provider via tier inheritance. OK when subagent tier resolves to Anthropic. Tests in `test/doctor.test.ts`.
- `src/core/skill-trigger-index.ts` — Shared loader that unions per-skill SKILL.md frontmatter `triggers:` with curated RESOLVER.md / AGENTS.md rows from `skillsDir` AND the parent dir (preserves the OpenClaw workspace-root layout). UNION semantics: explicit RESOLVER.md rows ADD to frontmatter triggers (don't replace). Dedup keyed on `(skillPath, trigger.trim().toLowerCase())`. Three consumers fold through this primitive — `checkResolvable`, `runRoutingEvalCli`, `mounts-cache.composeResolvers` — so fixing frontmatter reaches all of them. Exports `loadSkillTriggerIndex(skillsDir): SkillTriggerEntry[]`, `entriesToResolverContent(entries): string` (synthesizes a markdown-table resolver string for `runRoutingEval`'s string-content API), `findPrimaryResolverPath(skillsDir): string | null`, the `FRONTMATTER_SECTION` constant, and `_resetWarnedSkillsForTests`. Skip rules: non-directory entries, `_*`/`.*` prefixes, `conventions/`+`migrations/` subdirs, skills with no `SKILL.md` (deprecated `install/` graceful-skipped), no `triggers:` array, or malformed YAML (warn-once + skip). Reuses `parseSkillFrontmatter` from `src/core/skill-frontmatter.ts` (regex-based, not full YAML). Pinned by `test/skill-trigger-index.test.ts` (18 hermetic cases). CI gate `bun run check:resolver` (= `bun src/cli.ts check-resolvable --strict --skills-dir skills/`) wired into `bun run verify`.
- `src/core/skill-catalog.ts` — host-repo skill catalog backing the MCP `list_skills` / `get_skill` ops. Lets a thin MCP client (Codex desktop, Claude Code, Claude Cowork, Perplexity) DISCOVER + FOLLOW the agent repo's fat-markdown skills over `gbrain serve` — a skill is prose, so "using" one = fetching its body then calling the gbrain MCP tools the server already exposes. Read-scope, NOT localOnly (defensible only via the full mitigation stack): (1) **publish gate**`assertPublishEnabled(ctx, publishSkills)`; remote callers require `mcp.publish_skills === true`, default-OFF so an upgrade never silently grants existing read tokens host-skill read; local callers (`ctx.remote === false`) always pass. (2) **path confinement**`assertSkillNameShape` rejects separators/`..`/null/space before any FS access; the client `name` is a manifest LOOKUP KEY (via `loadOrDeriveManifest`), never a raw path segment; `confineManifestPath` does realpath + relative-containment + `SKILL.md`-regular-file check on EVERY entry (defeats poisoned manifest.json `path`, symlink/`..` escape). (3) **frontmatter allowlist**`GetSkillResult.frontmatter` projects a safe subset; private `writes_to` + `sources` dropped. (4) **prose-only + 256KB cap** (`MAX_SKILL_MD_BYTES`, env `GBRAIN_MAX_SKILL_MD_BYTES`), size-checked twice (statSync + UTF-8 byte length). (5) **no install_path serve for remote** — remote callers use `autoDetectSkillsDir` (no install-path tier) so a hosted gbrain with no agent repo returns `storage_error`; local callers use `autoDetectSkillsDirReadOnly`. (6) MCP rate-limiter caps call rate. Config reads honor BOTH planes: `readMcpPublishSkills` / `readMcpSkillsDir` prefer the DB plane (`engine.getConfig`) over the file plane (`ctx.config.mcp`). Tool-honesty: `crossReferenceTools(declared, ctx)` splits a skill's declared `tools:` into `usable_tools` vs `unavailable_tools`; `buildSkillCatalog`'s `instructions` envelope (`SKILL_CATALOG_INSTRUCTIONS`) carries the "these are prose, follow-then-call-tools" protocol. Skills are host-filesystem repo-global — `sourceScopeOpts(ctx)` / `ctx.brainId` deliberately do NOT apply. `buildSkillCatalog` is resilient (one malformed/escaping skill is skipped, never throws). Config keys in `src/core/config.ts`: `GBrainConfig.mcp?: { publish_skills?, skills_dir? }` + `KNOWN_CONFIG_KEYS` entries `mcp.publish_skills`/`mcp.publish_skills_prompted`/`mcp.skills_dir` + `mcp.` prefix in `KNOWN_CONFIG_KEY_PREFIXES`. `src/commands/init.ts` writes `config.mcp = { publish_skills: true, ... }` for new installs (existing config wins on re-init). `src/commands/upgrade.ts:runPostUpgrade` adds a one-time consent prompt (gated by `mcp.publish_skills_prompted`; existing installs stay OFF until owner opts in). Two ops register in `src/core/operations.ts` (`list_skills` with optional `section` filter + `cliHints:{name:'skills'}`; `get_skill` taking `name` + `cliHints:{name:'skill', positional:['name']}`) and dynamically import this module to avoid the import cycle (skill-catalog statically imports the `operations` array). Descriptions in `src/core/operations-descriptions.ts` (`LIST_SKILLS_DESCRIPTION`, `GET_SKILL_DESCRIPTION`, `SKILL_CATALOG_INSTRUCTIONS`, `SKILL_CLIENT_GUIDANCE`), pinned by `test/operations-descriptions.test.ts`. CLI: `gbrain skills` / `gbrain skill <name>`. Pinned by `test/skill-catalog.test.ts`, `test/skill-catalog-security.test.ts` (path-confinement / poisoned-manifest / symlink-escape), `test/skill-catalog-transports.test.ts` (publish-gate + remote-vs-local) over `test/fixtures/skill-catalog/`.
-4
View File
@@ -686,7 +686,6 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
try {
const { MinionQueue } = await import('../core/minions/queue.ts');
const { computeRecommendations, embeddingProviderConfigured, HOSTED_EMBED_KEY_CONFIG } = await import('../core/brain-score-recommendations.ts');
const { countExtractionLag } = await import('../core/remediation/context.ts');
const queue = new MinionQueue(engine);
const slotMs = Math.floor(Date.now() / (baseInterval * 1000)) * baseInterval * 1000;
const slot = new Date(slotMs).toISOString();
@@ -878,9 +877,6 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
return !!(process.env[envVar] || (cfgField ? embedKeyCfg[cfgField] : undefined));
}),
hasChatApiKey: !!(process.env.ANTHROPIC_API_KEY || await engine.getConfig('anthropic_api_key')),
// Real extraction-lag gate for sync.repo/extract.all — same counter
// loadRecommendationContext uses (replaces the health.stale_pages proxy).
extractionLagPages: await countExtractionLag(engine),
};
// v0.41.18.0 (A5 + A19 + A22, T15): consult onboard recommendations
// ALONGSIDE doctor's brain-score recommendations. Onboard's 4 new
+6 -26
View File
@@ -21,7 +21,8 @@ import { join } from 'path';
import { tmpdir } from 'os';
import { createHash } from 'crypto';
import { gbrainPath, loadConfig } from '../core/config.ts';
import { gbrainPath, loadConfig, type GBrainConfig } from '../core/config.ts';
import { buildGatewayConfig } from '../core/ai/build-gateway-config.ts';
import { configureGateway, isAvailable } from '../core/ai/gateway.ts';
import { runWithLimit } from '../core/worker-pool.ts';
import { resolveCycleDefault, cycleDefaultSuffix } from '../core/eval/cycle-default.ts';
@@ -264,32 +265,11 @@ function isTTY(): boolean {
* Returns true on success; false (and prints a hint) when no config is found.
*/
function configureGatewayForCli(): boolean {
// Route through buildGatewayConfig (the single adapter seam) so file-plane
// API keys, env base URLs, and provider_chat_options follow the same
// precedence as the runtime path. No config file is fine — env alone serves.
const config = loadConfig();
if (!config) {
// No config file is fine for the eval command — env vars alone may serve.
// We still call configureGateway so gateway recipes can read the env map.
configureGateway({
embedding_model: undefined,
embedding_dimensions: undefined,
expansion_model: undefined,
chat_model: undefined,
chat_fallback_chain: undefined,
base_urls: undefined,
provider_chat_options: undefined,
env: { ...process.env },
});
return true;
}
configureGateway({
embedding_model: config.embedding_model,
embedding_dimensions: config.embedding_dimensions,
expansion_model: config.expansion_model,
chat_model: config.chat_model,
chat_fallback_chain: config.chat_fallback_chain,
base_urls: config.provider_base_urls,
provider_chat_options: config.provider_chat_options,
env: { ...process.env },
});
configureGateway(buildGatewayConfig(config ?? ({} as GBrainConfig)));
return true;
}
+7 -2
View File
@@ -21,7 +21,8 @@
*/
import type { BrainEngine } from '../core/engine.ts';
import { configureGateway } from '../core/ai/gateway.ts';
import { loadConfig } from '../core/config.ts';
import { buildGatewayConfig } from '../core/ai/build-gateway-config.ts';
import { loadConfig, type GBrainConfig } from '../core/config.ts';
import { runEval, DEFAULT_MODEL_PANEL } from '../core/takes-quality-eval/runner.ts';
import { resolveCycleDefault, cycleDefaultSuffix } from '../core/eval/cycle-default.ts';
import { writeReceipt } from '../core/takes-quality-eval/receipt-write.ts';
@@ -127,8 +128,12 @@ export async function runReplayNoBrain(argv: string[]): Promise<number> {
export async function runEvalTakesQuality(engine: BrainEngine, args: string[]): Promise<void> {
// Self-configure the AI gateway (mirrors eval-cross-modal pattern). The
// gateway needs config.ai_gateway + env vars; configureGateway reads both.
// Route through buildGatewayConfig: the old `{ ...cfg, ...process.env }`
// spread never populated the gateway's `env` field (the gateway NEVER reads
// process.env at call time), so availability checks saw no keys at all and
// file-plane API keys / provider base URLs were dropped.
const cfg = loadConfig();
configureGateway({ ...cfg, ...(process.env as Record<string, string>) } as any);
configureGateway(buildGatewayConfig(cfg ?? ({} as GBrainConfig)));
const { subcmd, argv, json } = parseSubcmd(args);
+12 -14
View File
@@ -7,6 +7,7 @@ import { homedir } from 'os';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { saveConfig, loadConfig, loadConfigFileOnly, toEngineConfig, gbrainPath, configPath, isThinClient, effectiveEnvDatabaseUrl, type GBrainConfig } from '../core/config.ts';
import { buildGatewayConfig } from '../core/ai/build-gateway-config.ts';
import { createEngine } from '../core/engine-factory.ts';
import { discoverOAuth, mintClientCredentialsToken, smokeTestMcp } from '../core/remote-mcp-probe.ts';
import { runInitEmbedCheck } from '../core/init-embed-check.ts';
@@ -722,7 +723,8 @@ async function configureGatewayWithMergedPrecedence(
// pollutes config.json.
const envOverlay = loadConfig() ?? ({} as GBrainConfig);
const merged = {
const merged: GBrainConfig = {
...envOverlay,
embedding_model: aiOpts?.embedding_model ?? envOverlay.embedding_model ?? existingFile.embedding_model,
embedding_dimensions: aiOpts?.embedding_dimensions ?? envOverlay.embedding_dimensions ?? existingFile.embedding_dimensions,
expansion_model: aiOpts?.expansion_model ?? envOverlay.expansion_model ?? existingFile.expansion_model,
@@ -730,13 +732,9 @@ async function configureGatewayWithMergedPrecedence(
};
const { configureGateway, getEmbeddingModel, getEmbeddingDimensions, getExpansionModel, getChatModel } = await import('../core/ai/gateway.ts');
configureGateway({
embedding_model: merged.embedding_model,
embedding_dimensions: merged.embedding_dimensions,
expansion_model: merged.expansion_model,
chat_model: merged.chat_model,
env: { ...process.env },
});
// buildGatewayConfig (the single adapter seam) so file-plane API keys and
// env base URLs reach the gateway — a hand-rolled config here dropped them.
configureGateway(buildGatewayConfig(merged));
// Read back resolved values — gateway applies internal defaults for unset
// fields, so these are the values that actually shaped the schema.
@@ -831,13 +829,13 @@ async function initPGLite(opts: {
// resolveAIOptions above: CLI flags > env vars > existing file > gateway
// defaults.
const { configureGateway } = await import('../core/ai/gateway.ts');
configureGateway({
configureGateway(buildGatewayConfig({
...(loadConfig() ?? ({} as GBrainConfig)),
embedding_model: resolvedModel ?? opts.aiOpts?.embedding_model,
embedding_dimensions: resolvedDim ?? opts.aiOpts?.embedding_dimensions,
expansion_model: opts.aiOpts?.expansion_model,
chat_model: opts.aiOpts?.chat_model,
env: { ...process.env },
});
} as GBrainConfig));
if (resolvedModel) console.log(` Embedding: ${resolvedModel} (${resolvedDim}d)`);
if (opts.aiOpts?.expansion_model) console.log(` Expansion: ${opts.aiOpts.expansion_model}`);
if (opts.aiOpts?.chat_model) console.log(` Chat: ${opts.aiOpts.chat_model}`);
@@ -1046,13 +1044,13 @@ async function initPostgres(opts: {
// T6: unconditional configureGateway BEFORE initSchema.
const { configureGateway } = await import('../core/ai/gateway.ts');
configureGateway({
configureGateway(buildGatewayConfig({
...(loadConfig() ?? ({} as GBrainConfig)),
embedding_model: resolvedModel ?? opts.aiOpts?.embedding_model,
embedding_dimensions: resolvedDim ?? opts.aiOpts?.embedding_dimensions,
expansion_model: opts.aiOpts?.expansion_model,
chat_model: opts.aiOpts?.chat_model,
env: { ...process.env },
});
} as GBrainConfig));
if (resolvedModel) console.log(` Embedding: ${resolvedModel} (${resolvedDim}d)`);
if (opts.aiOpts?.expansion_model) console.log(` Expansion: ${opts.aiOpts.expansion_model}`);
if (opts.aiOpts?.chat_model) console.log(` Chat: ${opts.aiOpts.chat_model}`);
+4 -8
View File
@@ -66,15 +66,11 @@ export async function runMigrateOnlyCore(opts?: { timeoutMs?: number }): Promise
// configureGateway BEFORE initSchema (init.ts B.3): a schema bump on a brain
// whose file config is missing embedding fields must not fall through to
// stale hardcoded fallbacks. loadConfig already merged env; propagate it.
// stale hardcoded fallbacks. Route through buildGatewayConfig so file-plane
// API keys and provider base URLs follow the same precedence as runtime.
const { configureGateway } = await import('../../core/ai/gateway.ts');
configureGateway({
embedding_model: config.embedding_model,
embedding_dimensions: config.embedding_dimensions,
expansion_model: config.expansion_model,
chat_model: config.chat_model,
env: { ...process.env },
});
const { buildGatewayConfig } = await import('../../core/ai/build-gateway-config.ts');
configureGateway(buildGatewayConfig(config));
const timeoutMs = opts?.timeoutMs ?? MIGRATE_ONLY_TIMEOUT_MS;
const engine = await createEngine(toEngineConfig(config));
+10 -28
View File
@@ -12,8 +12,8 @@ import { parseModelId } from './ai/model-resolver.ts';
* `resolveKey` closure without re-parsing recipes.
*
* Only OPENAI_API_KEY and ZEROENTROPY_API_KEY appear here because those are the
* only embedding keys `buildGatewayConfig` (src/cli.ts) folds from config into
* the gateway env. VOYAGE_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY are deliberately
* only embedding keys `buildGatewayConfig` (src/core/ai/build-gateway-config.ts)
* folds from config into the gateway env. VOYAGE_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY are deliberately
* absent: their config fields are NOT threaded to the gateway today, so the
* producer closures fall through to checking `process.env` ONLY for them. That
* matches what the gateway can actually use (the recipes read those keys from
@@ -146,16 +146,6 @@ export interface RecommendationContext {
chatModel?: string;
/** Whether the chat provider has a usable API key. */
hasChatApiKey?: boolean;
/**
* Count of pages needing link/timeline extraction — the SAME staleness the
* `gbrain extract --stale` walk and doctor's `links_extraction_lag` check use
* (`engine.countStalePagesForExtraction`). Gates the sync→extract pipeline
* (sync.repo / extract.all). Replaces the old `health.stale_pages` gate, which
* counted "pages whose updated_at predates their newest timeline entry" — a
* proxy that broke when the updated_at-on-timeline-insert trigger was dropped
* (migration v10) and never reflected real extraction work.
*/
extractionLagPages?: number;
}
/** Triage result for one check. */
@@ -202,28 +192,20 @@ export function computeRecommendations(
const source = ctx.sourceId ?? 'default';
// ---------------------------------------------------------------------
// sync.repo + extract.all — the materialization pipeline, gated on the REAL
// extraction lag (pages whose link/timeline edges are stale), NOT on the
// legacy `health.stale_pages` proxy. `extractionLagPages` comes from the same
// counter the `extract --stale` walk + doctor's `links_extraction_lag` use, so
// the recommendation can only fire when running extract will actually reduce
// it (and clear the rec). See RecommendationContext.extractionLagPages.
// sync.repo is the prerequisite: re-sync so pages are current before extract
// materializes their edges.
// sync.repo — fires when sync hasn't run recently OR pages are stale
// ---------------------------------------------------------------------
const extractionLag = ctx.extractionLagPages ?? 0;
if (ctx.repoPath && extractionLag > 0) {
if (ctx.repoPath && health.stale_pages > 0) {
const params = { repoPath: ctx.repoPath, sourceId: ctx.sourceId, noEmbed: true };
out.push({
id: 'sync.repo',
job: 'sync',
params,
idempotency_key: idemKey(source, 'sync', params),
severity: extractionLag > 50 ? 'high' : 'medium',
est_seconds: Math.min(600, 30 + extractionLag * 0.5),
severity: health.stale_pages > 50 ? 'high' : 'medium',
est_seconds: Math.min(600, 30 + health.stale_pages * 0.5),
est_usd_cost: 0, // sync is fs+DB only
depends_on: [],
rationale: `Sync before extracting ${extractionLag} page${extractionLag === 1 ? '' : 's'} with stale link/timeline edges`,
rationale: `${health.stale_pages} stale page${health.stale_pages === 1 ? '' : 's'} on disk`,
status: 'remediable',
});
}
@@ -255,7 +237,7 @@ export function computeRecommendations(
est_seconds: Math.min(3600, 5 + health.missing_embeddings * 0.05),
est_usd_cost,
// sync should run first so embed sees fresh pages.
depends_on: ctx.repoPath && extractionLag > 0 ? ['sync.repo'] : [],
depends_on: ctx.repoPath && health.stale_pages > 0 ? ['sync.repo'] : [],
rationale: `${health.missing_embeddings} chunk${health.missing_embeddings === 1 ? '' : 's'} invisible to vector search`,
status: 'remediable',
});
@@ -285,7 +267,7 @@ export function computeRecommendations(
// Triggered when sync.repo fires (because sync was set to noEmbed:true,
// and noExtract:true after T5 lands → extract job is the materializer).
// ---------------------------------------------------------------------
if (ctx.repoPath && extractionLag > 0) {
if (ctx.repoPath && health.stale_pages > 0) {
const params = { mode: 'all', dir: ctx.repoPath };
out.push({
id: 'extract.all',
@@ -296,7 +278,7 @@ export function computeRecommendations(
est_seconds: Math.min(600, 30 + health.page_count * 0.01),
est_usd_cost: 0,
depends_on: ['sync.repo'],
rationale: `Materialize link + timeline edges for ${extractionLag} page${extractionLag === 1 ? '' : 's'} with stale extraction`,
rationale: 'Materialize link + timeline edges from fresh pages',
status: 'remediable',
});
}
-25
View File
@@ -8,7 +8,6 @@
import type { BrainEngine } from '../engine.ts';
import type { RecommendationContext } from '../brain-score-recommendations.ts';
import { LINK_EXTRACTOR_VERSION_TS } from '../link-extraction.ts';
// Re-export so consumers can `import { RecommendationContext } from '../remediation'`
// — the canonical RecommendationContext type still lives in
@@ -69,29 +68,5 @@ export async function loadRecommendationContext(
embeddingDimensions,
embeddingProviderConfigured: embeddingConfigured,
hasChatApiKey: !!(process.env.ANTHROPIC_API_KEY || fileCfg?.anthropic_api_key),
extractionLagPages: await countExtractionLag(engine),
};
}
/**
* Real extraction-lag count — the SAME staleness `gbrain extract --stale`
* processes (engine.countStalePagesForExtraction with
* versionTs=LINK_EXTRACTOR_VERSION_TS, matching doctor's links_extraction_lag
* check — without versionTs, pages stamped before an extractor version bump
* would lag for doctor/extract but never trip this gate). Drives the
* sync→extract recommendation pipeline; replaces the legacy
* `health.stale_pages` proxy that no longer reflected real extraction work
* after the v10 trigger drop.
*
* Shared by loadRecommendationContext AND the D7 per-step recheck in
* runRemediation — the recheck MUST refresh this gate alongside getHealth,
* or a completed extract step keeps re-firing off the frozen initial count.
*/
export async function countExtractionLag(engine: BrainEngine): Promise<number> {
try {
return await engine.countStalePagesForExtraction({ versionTs: LINK_EXTRACTOR_VERSION_TS });
} catch {
/* counter unavailable (very old brain / mid-migration) — treat as 0 */
return 0;
}
}
+2 -7
View File
@@ -16,7 +16,7 @@ import {
computeRecommendations,
} from '../brain-score-recommendations.ts';
import type { RemediationStep } from '../remediation-step.ts';
import { countExtractionLag, loadRecommendationContext } from './context.ts';
import { loadRecommendationContext } from './context.ts';
import { computeRemediationPlan } from './plan.ts';
import type {
RemediationHooks,
@@ -65,7 +65,7 @@ export async function runRemediation(
clearRemediationCheckpoint,
} = await import('../remediation-checkpoint.ts');
let ctx = await loadRecommendationContext(engine);
const ctx = await loadRecommendationContext(engine);
// Pre-flight ceiling check via the shared plan computation.
const initialPlan = await computeRemediationPlan(engine, { targetScore });
@@ -305,11 +305,6 @@ export async function runRemediation(
// steps with bumped retry suffix (D1).
if (recs.length === 0 || stepCount >= maxJobs) break;
const freshHealth = await engine.getHealth();
// Refresh the extraction-lag gate alongside health: ctx was loaded once
// before the loop, and a completed sync/extract step is exactly what
// drives the count down. Reusing the frozen initial count would re-fire
// sync.repo/extract.all every recheck until maxJobs.
ctx = { ...ctx, extractionLagPages: await countExtractionLag(engine) };
recs = computeRecommendations(freshHealth, ctx).filter((r) => r.status === 'remediable');
}
};
-9
View File
@@ -1423,15 +1423,6 @@ export interface BrainStats {
export interface BrainHealth {
page_count: number;
embed_coverage: number;
/**
* LEGACY proxy: count of pages whose `updated_at` predates their newest
* timeline entry. This bumped meaningfully only while a trigger updated
* `pages.updated_at` on timeline insert; that trigger was dropped in
* migration v10, so the metric no longer reflects real "needs work" state.
* NO LONGER gates remediations — the sync→extract pipeline now gates on
* `RecommendationContext.extractionLagPages` (the real extraction-lag from
* `countStalePagesForExtraction`). Retained for the CLI health line + back-compat.
*/
stale_pages: number;
/**
* Islanded pages — zero inbound AND zero outbound links. A hub page
+1
View File
@@ -25,6 +25,7 @@ import { withEnv } from '../helpers/with-env.ts';
const PASSTHROUGHS: Array<{ envVar: string; recipeId: string }> = [
{ envVar: 'LLAMA_SERVER_BASE_URL', recipeId: 'llama-server' },
{ envVar: 'LLAMA_SERVER_RERANKER_BASE_URL', recipeId: 'llama-server-reranker' },
{ envVar: 'OLLAMA_BASE_URL', recipeId: 'ollama' },
{ envVar: 'LMSTUDIO_BASE_URL', recipeId: 'lmstudio' },
{ envVar: 'LITELLM_BASE_URL', recipeId: 'litellm' },
+12 -9
View File
@@ -119,12 +119,13 @@ describe('computeRecommendations', () => {
expect(recs.find((r) => r.id === 'embed.stale')).toBeUndefined();
});
test('extraction lag + dead links produce sync + backlinks + extract', () => {
test('stale pages + dead links produce sync + backlinks + extract', () => {
const health = makeHealth({
stale_pages: 25,
dead_links: 8,
brain_score: 70,
});
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true, extractionLagPages: 25 });
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true });
const ids = recs.map((r) => r.id);
expect(ids).toContain('sync.repo');
expect(ids).toContain('backlinks.fix');
@@ -132,17 +133,18 @@ describe('computeRecommendations', () => {
});
test('extract.all depends on sync.repo (D14: stable ids)', () => {
const health = makeHealth();
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true, extractionLagPages: 10 });
const health = makeHealth({ stale_pages: 10 });
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true });
const extract = recs.find((r) => r.id === 'extract.all');
expect(extract?.depends_on).toContain('sync.repo');
});
test('embed.stale depends on sync.repo when extraction also needed', () => {
test('embed.stale depends on sync.repo when sync also needed', () => {
const health = makeHealth({
stale_pages: 10,
missing_embeddings: 100,
});
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true, extractionLagPages: 10 });
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true });
const embed = recs.find((r) => r.id === 'embed.stale');
expect(embed?.depends_on).toContain('sync.repo');
});
@@ -157,9 +159,9 @@ describe('computeRecommendations', () => {
test('severity ordering: critical before high before medium', () => {
const health = makeHealth({
missing_embeddings: 100, // critical
stale_pages: 80, // high
});
// extractionLagPages > 50 → sync.repo fires at 'high' severity.
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true, extractionLagPages: 80 });
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true });
const critIdx = recs.findIndex((r) => r.severity === 'critical');
const highIdx = recs.findIndex((r) => r.severity === 'high');
expect(critIdx).toBeLessThan(highIdx);
@@ -168,10 +170,11 @@ describe('computeRecommendations', () => {
// D6 #5 — THE critical regression test for the agent contract.
test('D6 #5: determinism — same input twice produces identical output', () => {
const health = makeHealth({
stale_pages: 10,
missing_embeddings: 50,
dead_links: 3,
});
const ctx = { repoPath: '/brain', embeddingProviderConfigured: true, sourceId: 'default', extractionLagPages: 10 };
const ctx = { repoPath: '/brain', embeddingProviderConfigured: true, sourceId: 'default' };
const run1 = computeRecommendations(health, ctx);
const run2 = computeRecommendations(health, ctx);
expect(JSON.stringify(run1)).toBe(JSON.stringify(run2));
+2 -20
View File
@@ -11,32 +11,13 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { loadConfigWithEngine, type GBrainConfig } from '../src/core/config.ts';
import { buildGatewayConfig } from '../src/core/ai/build-gateway-config.ts';
import {
configureGateway,
getEmbeddingModel,
getMultimodalModel,
resetGateway,
} from '../src/core/ai/gateway.ts';
import type { AIGatewayConfig } from '../src/core/ai/types.ts';
// Mirror the cli.ts buildGatewayConfig helper exactly. Keeping a copy here
// (instead of exporting from cli.ts) is intentional: the test asserts the
// shape of the contract, not the helper's identity. If cli.ts drifts, the
// e2e behavior these tests care about (DB-set value lands in gateway) still
// holds, but a helper-shape test would also catch the drift in PR review.
function buildGatewayConfig(c: GBrainConfig): AIGatewayConfig {
return {
embedding_model: c.embedding_model,
embedding_dimensions: c.embedding_dimensions,
embedding_multimodal_model: c.embedding_multimodal_model,
expansion_model: c.expansion_model,
chat_model: c.chat_model,
chat_fallback_chain: c.chat_fallback_chain,
base_urls: c.provider_base_urls,
provider_chat_options: c.provider_chat_options,
env: { ...process.env },
};
}
let engine: PGLiteEngine;
@@ -47,6 +28,7 @@ beforeAll(async () => {
});
afterAll(async () => {
resetGateway(); // don't leak this file's gateway config into shard siblings
await engine.disconnect();
});
+57
View File
@@ -0,0 +1,57 @@
/**
* eval-takes-quality gateway self-config — adapter-boundary regression
* (takeover of PR #2430).
*
* The old callsite spread `{ ...cfg, ...process.env }` straight into
* configureGateway. The gateway NEVER reads process.env at call time — it
* reads `_config.env` — and that spread never populated an `env` field at
* all, so every availability/diagnose check dereferenced `undefined.env[k]`
* and file-plane API keys (config.json `openai_api_key` etc.) were dropped.
* Routing through buildGatewayConfig fixes both. This test fails (throws)
* on the old code path.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runEvalTakesQuality } from '../src/commands/eval-takes-quality.ts';
import { isAvailable, resetGateway } from '../src/core/ai/gateway.ts';
import { withEnv } from './helpers/with-env.ts';
import type { BrainEngine } from '../src/core/engine.ts';
afterAll(() => {
resetGateway();
});
describe('runEvalTakesQuality — gateway self-config routes through buildGatewayConfig', () => {
test('file-plane openai_api_key reaches the gateway env (help path, engine untouched)', async () => {
const home = mkdtempSync(join(tmpdir(), 'gbrain-etq-gw-'));
try {
mkdirSync(join(home, '.gbrain'), { recursive: true });
writeFileSync(
join(home, '.gbrain', 'config.json'),
JSON.stringify({
engine: 'pglite',
database_path: join(home, '.gbrain', 'brain'),
openai_api_key: 'sk-file-plane-test',
}),
);
await withEnv(
{
GBRAIN_HOME: home,
OPENAI_API_KEY: undefined,
DATABASE_URL: undefined,
GBRAIN_DATABASE_URL: undefined,
},
async () => {
// 'help' returns before touching the engine, but the gateway is
// configured first — exactly the seam under test.
await runEvalTakesQuality({} as BrainEngine, ['--help']);
expect(isAvailable('embedding', 'openai:text-embedding-3-small')).toBe(true);
},
);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});
@@ -1,62 +0,0 @@
// test/remediation-context-extraction-lag.test.ts
//
// Pins the v-next fix: the sync→extract remediation pipeline gates on REAL
// extraction lag, not the legacy `health.stale_pages` proxy (which counted
// "updated_at predates newest timeline entry" — meaningless after the v10
// trigger drop). loadRecommendationContext now populates `extractionLagPages`
// from `engine.countStalePagesForExtraction` — the SAME counter the
// `gbrain extract --stale` walk and doctor's `links_extraction_lag` use — so a
// recommendation can only fire when running extract will actually reduce it.
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { loadRecommendationContext } from '../src/core/remediation/context.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
describe('loadRecommendationContext — extractionLagPages wiring', () => {
it('is 0 on an empty brain (nothing to extract)', async () => {
const ctx = await loadRecommendationContext(engine);
expect(ctx.extractionLagPages).toBe(0);
});
it('reflects the real extraction-lag count once a page needs extraction', async () => {
// A freshly-imported page has links_extracted_at = NULL, which the canonical
// countStalePagesForExtraction predicate counts as stale-for-extraction.
await engine.putPage('p0', {
title: 'p0',
type: 'note' as never,
compiled_truth: 'body that is long enough to pass any minimum-length guards in the codebase',
timeline: '',
frontmatter: {},
source_path: 'p0.md',
});
const ctx = await loadRecommendationContext(engine);
expect(ctx.extractionLagPages).toBeGreaterThan(0);
});
it('counts pages stamped before LINK_EXTRACTOR_VERSION_TS (version-bump arm)', async () => {
// Backdate p0 so BOTH the NULL arm and the updated_at arm are quiet:
// updated_at < links_extracted_at, but links_extracted_at predates the
// extractor version stamp. doctor's links_extraction_lag and
// `extract --stale` both count this page; the remediation gate must too.
await engine.executeRaw(
`UPDATE pages SET updated_at = '2020-01-01T00:00:00Z'::timestamptz,
links_extracted_at = '2020-01-02T00:00:00Z'::timestamptz
WHERE slug = 'p0'`,
[],
);
const ctx = await loadRecommendationContext(engine);
expect(ctx.extractionLagPages).toBeGreaterThan(0);
});
});
@@ -1,81 +0,0 @@
// test/remediation-run-d7-refresh.serial.test.ts
//
// Pins the D7-recheck half of the extraction-lag gate fix: runRemediation
// loads RecommendationContext ONCE before the step loop, and the per-step
// recheck (D7) must REFRESH ctx.extractionLagPages alongside getHealth.
// Without the refresh, a completed sync/extract step keeps re-firing off
// the frozen initial count — the plan never converges and the loop burns
// steps until maxJobs.
//
// SERIAL (R2): uses top-level mock.module for the minion queue +
// wait-for-completion so no real worker is needed — mocks leak across
// files in a shard process, so this file must run in its own process.
import { describe, expect, mock, test } from 'bun:test';
// The fake brain: sync.repo clears the extraction lag when it "runs"
// (today's sync materializes link/timeline edges; extract.all is the
// explicit re-materializer). The frozen-ctx bug makes runRemediation
// ignore that and resubmit sync.repo on every D7 recheck.
let extractionLag = 25;
const submittedJobs: string[] = [];
mock.module('../src/core/minions/queue.ts', () => ({
MinionQueue: class {
constructor(_engine: unknown) {}
async add(job: string): Promise<{ id: number }> {
submittedJobs.push(job);
if (job === 'sync' || job === 'extract') extractionLag = 0;
return { id: submittedJobs.length };
}
},
}));
mock.module('../src/core/minions/wait-for-completion.ts', () => ({
waitForCompletion: async () => ({ status: 'completed' }),
}));
const health = () => ({
page_count: 100,
embed_coverage: 1.0,
stale_pages: 0, // legacy proxy stays 0 — the real counter drives the gate
orphan_pages: 0,
missing_embeddings: 0,
brain_score: 70,
dead_links: 0,
link_coverage: 1.0,
timeline_coverage: 1.0,
most_connected: [],
embed_coverage_score: 35,
link_density_score: 25,
timeline_coverage_score: 15,
no_orphans_score: 15,
no_dead_links_score: 10,
});
const fakeEngine = {
kind: 'pglite' as const,
getHealth: async () => health(),
getConfig: async (key: string) =>
key === 'sync.repo_path' ? '/tmp/brain-example' : null,
countStalePagesForExtraction: async () => extractionLag,
};
describe('runRemediation D7 recheck — extraction-lag gate refresh', () => {
test('a completed materializer step clears the gate; the pipeline is not resubmitted', async () => {
const { runRemediation } = await import('../src/core/remediation/run.ts');
const result = await runRemediation(
// Only the methods the orchestrator touches are needed.
fakeEngine as never,
{ targetScore: 0, maxJobs: 6 },
);
// Frozen-ctx bug: extractionLagPages stays 25 forever, so every D7
// recheck re-introduces the sync/extract pipeline and the loop burns
// all 6 maxJobs. With the refresh, the plan converges after the first
// completed step: no step id is ever submitted twice.
const ids = result.submitted.map((s) => s.id);
expect(new Set(ids).size).toBe(ids.length);
expect(submittedJobs.length).toBeLessThan(3);
expect(extractionLag).toBe(0);
});
});