Merge remote-tracking branch 'origin/master' into garrytan/enrich-thin-batch

# Conflicts:
#	CHANGELOG.md
#	VERSION
#	package.json
This commit is contained in:
Garry Tan
2026-06-01 22:05:55 -07:00
80 changed files with 4357 additions and 243 deletions
+64
View File
@@ -0,0 +1,64 @@
/**
* #1698 — shared `hasAnthropicKey` (consolidated from 3 private copies).
*
* Hermetic: every case isolates env + GBRAIN_HOME via `withEnv` (R1) so the
* dev machine's real ~/.gbrain/config.json never leaks into the "neither" case.
*/
import { describe, test, expect, afterEach } from 'bun:test';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { withEnv } from '../helpers/with-env.ts';
import { hasAnthropicKey } from '../../src/core/ai/anthropic-key.ts';
const tmpDirs: string[] = [];
function freshHome(withConfig?: Record<string, unknown>): string {
const home = mkdtempSync(join(tmpdir(), 'gbrain-akey-'));
tmpDirs.push(home);
if (withConfig) {
const dir = join(home, '.gbrain');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'config.json'), JSON.stringify(withConfig), 'utf8');
}
return home;
}
afterEach(() => {
while (tmpDirs.length) {
const d = tmpDirs.pop()!;
try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ }
}
});
describe('hasAnthropicKey', () => {
test('env ANTHROPIC_API_KEY set → true (no config read needed)', async () => {
const home = freshHome(); // empty home so config can't accidentally satisfy it
await withEnv(
{ ANTHROPIC_API_KEY: 'sk-test', GBRAIN_HOME: home, DATABASE_URL: undefined, GBRAIN_DATABASE_URL: undefined },
async () => {
expect(hasAnthropicKey()).toBe(true);
},
);
});
test('gbrain config anthropic_api_key set (no env) → true', async () => {
const home = freshHome({ anthropic_api_key: 'sk-from-config' });
await withEnv(
{ ANTHROPIC_API_KEY: undefined, GBRAIN_HOME: home, DATABASE_URL: undefined, GBRAIN_DATABASE_URL: undefined },
async () => {
expect(hasAnthropicKey()).toBe(true);
},
);
});
test('neither env nor config → false', async () => {
const home = freshHome(); // no config.json written
await withEnv(
{ ANTHROPIC_API_KEY: undefined, GBRAIN_HOME: home, DATABASE_URL: undefined, GBRAIN_DATABASE_URL: undefined },
async () => {
expect(hasAnthropicKey()).toBe(false);
},
);
});
});
+105
View File
@@ -0,0 +1,105 @@
/**
* #1698 — validateModelId (C1 id-validity core) + probeChatModel (= validity + key).
*
* validateModelId reads the recipe REGISTRY (not gateway _config), so it works without
* configureGateway — that's the property makeJudgeClient + tryBuildGatewayClient rely on
* (C1 #6). probeChatModel adds the key layer via hasAnthropicKey (env OR gbrain config
* file — also gateway-config-independent). Non-Anthropic providers pass the probe (lazy
* key check deferred to gateway.chat).
*
* Hermetic: key-sensitive cases isolate env + GBRAIN_HOME via withEnv (R1) so the dev
* machine's real ~/.gbrain/config.json never leaks in.
*/
import { describe, test, expect, afterEach } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { validateModelId, probeChatModel } from '../../src/core/ai/gateway.ts';
import { normalizeModelId } from '../../src/core/model-id.ts';
import { withEnv } from '../helpers/with-env.ts';
const REAL = 'anthropic:claude-sonnet-4-6';
const tmpDirs: string[] = [];
function emptyHome(): string {
const d = mkdtempSync(join(tmpdir(), 'gbrain-probe-'));
tmpDirs.push(d);
return d;
}
afterEach(() => {
while (tmpDirs.length) {
try { rmSync(tmpDirs.pop()!, { recursive: true, force: true }); } catch { /* best effort */ }
}
});
// No-key env: ANTHROPIC_API_KEY unset + GBRAIN_HOME pointed at an empty dir so the
// config-file branch of hasAnthropicKey finds nothing.
const noKeyEnv = () => ({ ANTHROPIC_API_KEY: undefined, GBRAIN_HOME: emptyHome() });
const withKeyEnv = () => ({ ANTHROPIC_API_KEY: 'sk-test', GBRAIN_HOME: emptyHome() });
describe('validateModelId (#1698 C1 core)', () => {
test('ok for a real model id, returns parsed + recipe', () => {
const v = validateModelId(REAL);
expect(v.ok).toBe(true);
if (v.ok) {
expect(v.parsed.providerId).toBe('anthropic');
expect(v.recipe).toBeDefined();
}
});
test('works WITHOUT configureGateway (reads registry, not _config) — C1 #6 guard', () => {
// No gateway configured in this process; validateModelId must still resolve.
const v = validateModelId(REAL);
expect(v.ok).toBe(true);
});
test('unknown_provider when resolveRecipe throws', () => {
const v = validateModelId('bogusprovider:whatever');
expect(v.ok).toBe(false);
if (!v.ok) expect(v.reason).toBe('unknown_provider');
});
test('unknown_model for a typo native model', () => {
const v = validateModelId('anthropic:claude-bogus-9');
expect(v.ok).toBe(false);
if (!v.ok) expect(v.reason).toBe('unknown_model');
});
});
describe('probeChatModel (#1698 = validity + key, config-independent)', () => {
test('unavailable: anthropic + no key', async () => {
await withEnv(noKeyEnv(), async () => {
const p = probeChatModel(REAL);
expect(p.ok).toBe(false);
if (!p.ok) expect(p.reason).toBe('unavailable');
});
});
test('ok: anthropic + key set (no configureGateway needed)', async () => {
await withEnv(withKeyEnv(), async () => {
expect(probeChatModel(REAL).ok).toBe(true);
});
});
test('unknown_provider / unknown_model classify regardless of key (validity runs first)', async () => {
await withEnv(withKeyEnv(), async () => {
expect(probeChatModel('bogusprovider:x')).toMatchObject({ ok: false, reason: 'unknown_provider' });
expect(probeChatModel('anthropic:claude-bogus-9')).toMatchObject({ ok: false, reason: 'unknown_model' });
});
});
test('non-anthropic provider passes the probe even with no key (lazy key check)', async () => {
// deepseek is a registered recipe; its key check is deferred to gateway.chat()
// (the per-transcript-degrade contract — A9). probe should be ok here.
await withEnv(noKeyEnv(), async () => {
expect(probeChatModel('deepseek:deepseek-chat').ok).toBe(true);
});
});
test('reported repro: slash form normalizes then probes ok (with key)', async () => {
await withEnv(withKeyEnv(), async () => {
expect(probeChatModel(normalizeModelId('anthropic/claude-sonnet-4-6')).ok).toBe(true);
});
});
});
+8 -7
View File
@@ -180,14 +180,15 @@ describe('pruneOldBatchRetryAuditFiles — codex H-8 actual pruning', () => {
});
test('no-op when audit dir does not exist (ENOENT)', async () => {
// Point GBRAIN_AUDIT_DIR at a guaranteed-missing subdir of the per-test
// tmpDir. Without this override the function reads the real ~/.gbrain/audit,
// so the assertion flakes on any dev machine that already has a real
// batch-retry-*.jsonl on disk (returns kept:1, not kept:0). Hermetic now,
// matching this file's header contract.
await withEnv({ GBRAIN_AUDIT_DIR: path.join(tmpDir, 'does-not-exist') }, async () => {
// Isolate GBRAIN_AUDIT_DIR at a guaranteed-nonexistent path (CLAUDE.md R1).
// Pre-fix this called pruneOld with no override, so on a real dev machine it
// walked ~/.gbrain/audit — which may already hold this-week's batch-retry
// files from other (un-isolated) suites, making `kept` non-zero and the test
// flaky. Pointing at a missing subdir makes the ENOENT path deterministic.
const missing = path.join(tmpDir, 'does-not-exist');
await withEnv({ GBRAIN_AUDIT_DIR: missing }, async () => {
const result = pruneOldBatchRetryAuditFiles(30, new Date());
// The function never throws on a missing dir; it returns the empty result.
// The function never throws on a missing dir; returns the empty result.
expect(result).toEqual({ removed: 0, kept: 0 });
});
});
+25
View File
@@ -94,6 +94,31 @@ describe('runPhaseAutoThink', () => {
await engine.setConfig('dream.auto_think.enabled', 'false');
});
// #1698 (codex #5): an empty synthesis must NOT count as complete or advance the
// cooldown — otherwise auto-think silently reports success and suppresses retry until
// the cooldown expires. An empty-answer stub drives runThink's synthesisOk=false path.
test('empty synthesis → partial, 0 synthesized, cooldown NOT advanced', async () => {
await engine.setConfig('dream.auto_think.enabled', 'true');
await engine.setConfig('dream.auto_think.questions', JSON.stringify(['Q-empty']));
await engine.setConfig('dream.auto_think.max_per_cycle', '1');
await engine.setConfig('dream.auto_think.budget', '10.0');
await engine.setConfig('dream.auto_think.auto_commit', 'false');
await engine.setConfig('dream.auto_think.cooldown_days', '30');
await engine.setConfig('dream.auto_think.last_completion_ts', '');
const r = await runPhaseAutoThink(engine, {
dryRun: false,
client: makeStubClient(''), // empty answer → synthesisOk=false
auditPath: join(tmpDir, 'b-empty.jsonl'),
});
expect(r.status).toBe('partial');
expect((r.totals as { synthesized?: number }).synthesized).toBe(0);
// Cooldown must stay empty so the next cycle retries (no silent success).
const ts = await engine.getConfig('dream.auto_think.last_completion_ts');
expect(ts ?? '').toBe('');
await engine.setConfig('dream.auto_think.enabled', 'false');
await engine.setConfig('dream.auto_think.cooldown_days', '0');
});
test('cooldown skips next run', async () => {
await engine.setConfig('dream.auto_think.enabled', 'true');
await engine.setConfig('dream.auto_think.questions', JSON.stringify(['Q1']));
+9 -6
View File
@@ -13,7 +13,7 @@
* static-shape regressions read the source file and pin the load-bearing
* constants:
*
* - `--max-rss 2048` is passed to the worker (incident-driving default)
* - the worker is spawned with an auto-sized `--max-rss` (issue #1678)
* - `maxCrashes: 5` matches the prior `crashCount >= 5` give-up rule
* - The autopilot composes ChildWorkerSupervisor (not the legacy
* inline `child.on('exit')` loop)
@@ -50,12 +50,15 @@ describe('autopilot.ts ↔ ChildWorkerSupervisor wiring', () => {
expect(AUTOPILOT_SRC).not.toContain('STABLE_RUN_RESET_MS');
});
it("constructs ChildWorkerSupervisor with --max-rss 2048", () => {
// The worker spawn args must include both flag tokens in argv order.
// This is the incident-driving default; changing it without a deliberate
// decision would regress the workaround for VmRSS inflation.
expect(AUTOPILOT_SRC).toContain("'--max-rss', '2048'");
it("spawns the worker with an auto-sized --max-rss (issue #1678)", () => {
// Post-v0.41.39.0 the flat 2048 default is gone: autopilot resolves
// resolveDefaultMaxRssMb() (cgroup-aware) and passes it as the cap. The
// argv must still carry the --max-rss flag token + the resolved value.
expect(AUTOPILOT_SRC).toContain("resolveDefaultMaxRssMb");
expect(AUTOPILOT_SRC).toContain("'--max-rss', String(autopilotMaxRssMb)");
expect(AUTOPILOT_SRC).toContain("'jobs', 'work'");
// The footgun literal must NOT come back.
expect(AUTOPILOT_SRC).not.toContain("'--max-rss', '2048'");
});
it("constructs ChildWorkerSupervisor with maxCrashes: 5", () => {
+63
View File
@@ -55,6 +55,9 @@ async function runUntilTerminal(
cleanRestartWindowMs: number;
cleanRestartBudgetBackoffMs: number;
stableRunResetMs: number;
watchdogLoopBudget: number;
watchdogLoopWindowMs: number;
watchdogBackoffMs: number;
_now: () => number;
stopAfterEvents: number; // safety net so a buggy test can't hang
}>,
@@ -73,6 +76,9 @@ async function runUntilTerminal(
cleanRestartWindowMs: overrides.cleanRestartWindowMs,
cleanRestartBudgetBackoffMs: overrides.cleanRestartBudgetBackoffMs,
stableRunResetMs: overrides.stableRunResetMs,
watchdogLoopBudget: overrides.watchdogLoopBudget,
watchdogLoopWindowMs: overrides.watchdogLoopWindowMs,
watchdogBackoffMs: overrides.watchdogBackoffMs,
_now: overrides._now,
isStopping: () => stopping,
onMaxCrashesExceeded: (count, max) => {
@@ -407,4 +413,61 @@ esac
}
});
});
// issue #1678: RSS-watchdog exits (code 12) are cause-keyed and must NOT
// route through the generic crash path — the >5-min stable-run reset would
// defeat max_crashes and the 400×/24h loop would never stop being silent.
describe('rss_watchdog breaker (issue #1678)', () => {
it('code=12 is labeled rss_watchdog and never increments crashCount', async () => {
const h = makeHarness('wd-nocrash', 'exit 12');
try {
const { events, maxCrashesFired } = await runUntilTerminal(h, {
maxCrashes: 3,
_backoffFloorMs: 1,
stopAfterEvents: 18, // ~6 spawn/exit/backoff triples
});
const exited = events.filter(
(e): e is Extract<ChildSupervisorEvent, { kind: 'worker_exited' }> =>
e.kind === 'worker_exited',
);
// Looped well past maxCrashes WITHOUT tripping it — the whole point.
expect(maxCrashesFired).toBeNull();
expect(exited.length).toBeGreaterThan(3);
for (const e of exited) {
expect(e.code).toBe(12);
expect(e.likelyCause).toBe('rss_watchdog');
expect(e.crashCount).toBe(0); // never counted as a crash
}
} finally {
h.cleanup();
}
});
it('emits rss_watchdog_loop health_warn once the window budget is exceeded', async () => {
const h = makeHarness('wd-loop', 'exit 12');
try {
const { events } = await runUntilTerminal(h, {
maxCrashes: 99,
_backoffFloorMs: 1,
watchdogLoopBudget: 2,
watchdogLoopWindowMs: 600_000,
stopAfterEvents: 24,
});
const warns = events.filter(
(e): e is Extract<ChildSupervisorEvent, { kind: 'health_warn' }> =>
e.kind === 'health_warn' && e.reason === 'rss_watchdog_loop',
);
// Budget=2 → the 3rd+ watchdog exit in-window fires the loud alert.
expect(warns.length).toBeGreaterThan(0);
expect(warns[0].count).toBeGreaterThan(2);
// And every backoff after a watchdog exit is reason=rss_watchdog.
const wdBackoffs = events.filter(
(e) => e.kind === 'backoff' && e.reason === 'rss_watchdog',
);
expect(wdBackoffs.length).toBeGreaterThan(0);
} finally {
h.cleanup();
}
});
});
});
+1
View File
@@ -348,6 +348,7 @@ describe('BATCH_AUDIT_SITES typed enum + isBatchAuditSite guard (D10c codex)', (
'sync.import_file',
'reindex.markdown', 'reindex.multimodal',
'backfill.outer',
'minion-lock',
]);
expect(new Set<string>([...BATCH_AUDIT_SITES])).toEqual(expected);
});
+1 -1
View File
@@ -142,7 +142,7 @@ describe('D2 — knobsHash differs across cross-modal knob values', () => {
// v0.39 T21 (master) bump to v=4 for graph_signals + schema-pack fields.
// v0.40.3.0 D8 bumps to v=5 (sequenced behind salem's v=4 graph-signals).
// v0.41.22.0 (type-unification): 5→6 for alias_resolved post-fusion boost.
expect(KNOBS_HASH_VERSION).toBe(7);
expect(KNOBS_HASH_VERSION).toBe(8);
});
test('flipping unified_multimodal changes the hash', () => {
@@ -78,12 +78,24 @@ describe('makeJudgeClient — construction-time provider probe', () => {
// The deepseek recipe declares DEEPSEEK_API_KEY in auth_env.required;
// makeJudgeClient delegates that probe to gateway.chat at call time
// (where it would throw AIConfigError, caught per-transcript by the loop).
// #1698 C1: this MUST stay green — validateModelId (id-validity only) does NOT
// reject a non-anthropic provider for a missing key (no isAvailable here).
await withEnv({ DEEPSEEK_API_KEY: undefined }, async () => {
const judge = makeJudgeClient('deepseek:deepseek-chat');
expect(judge).not.toBeNull();
expect(typeof judge?.create).toBe('function');
});
});
test('A8b (#1698): typo native model → null at construction (validateModelId unknown_model)', async () => {
// Pre-#1698 makeJudgeClient only checked the provider (resolveRecipe) and would
// have returned a client that failed at call time. Now the shared validateModelId
// core runs assertTouchpoint, so a typo'd native model is rejected up front.
await withEnv({ ANTHROPIC_API_KEY: 'sk-test-A8b' }, async () => {
const judge = makeJudgeClient('anthropic:claude-bogus-9');
expect(judge).toBeNull();
});
});
});
describe('JudgeClient.create — gateway routing + shape adapter', () => {
+98
View File
@@ -0,0 +1,98 @@
/**
* issue #1678 — extract_atoms backlog count + doctor check.
*
* Pins:
* - countExtractAtomsBacklog counts eligible-but-unextracted pages (scoped +
* brain-wide) and excludes pages that already have an atom (NOT EXISTS).
* - computeExtractAtomsBacklogCheck WARNs with a `--drain` hint when the pack
* doesn't run the phase and the backlog is real; OK at 0.
*
* Real in-memory PGLite (canonical block, R3+R4). GBRAIN_HOME is pointed at an
* empty tmpdir for the doctor-check cases so packDeclaresPhase resolves the
* bundled base pack (which does NOT declare extract_atoms) deterministically,
* independent of the developer's real ~/.gbrain config.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { withEnv } from './helpers/with-env.ts';
import { countExtractAtomsBacklog } from '../src/core/cycle/extract-atoms.ts';
import { computeExtractAtomsBacklogCheck } from '../src/commands/doctor.ts';
let engine: PGLiteEngine;
const EMPTY_HOME = mkdtempSync(join(tmpdir(), 'gbrain-xa-backlog-home-'));
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
const BODY = 'x'.repeat(600); // >= MIN_PAGE_CHARS_FOR_EXTRACTION (500)
async function seedArticle(slug: string) {
return engine.putPage(slug, { type: 'article', title: slug, compiled_truth: BODY });
}
describe('countExtractAtomsBacklog (issue #1678)', () => {
it('counts eligible pages with no atom (scoped + brain-wide)', async () => {
await seedArticle('article-a');
await seedArticle('article-b');
await seedArticle('article-c');
expect(await countExtractAtomsBacklog(engine)).toBe(3);
expect(await countExtractAtomsBacklog(engine, 'default')).toBe(3);
});
it('excludes a page that already has a matching atom (NOT EXISTS)', async () => {
const p = await seedArticle('article-x');
const h16 = (p.content_hash ?? '').slice(0, 16);
expect(h16.length).toBe(16);
await engine.putPage('atoms/a1', {
type: 'atom',
title: 'a1',
compiled_truth: 'an extracted nugget',
frontmatter: { source_hash: h16 },
});
expect(await countExtractAtomsBacklog(engine)).toBe(0);
});
it('ignores short pages and dream-generated pages', async () => {
await engine.putPage('article-short', { type: 'article', title: 's', compiled_truth: 'too short' });
await engine.putPage('article-dream', {
type: 'article', title: 'd', compiled_truth: BODY,
frontmatter: { dream_generated: 'true' },
});
expect(await countExtractAtomsBacklog(engine)).toBe(0);
});
});
describe('computeExtractAtomsBacklogCheck (issue #1678)', () => {
it('OK with no backlog', async () => {
const check = await withEnv({ GBRAIN_HOME: EMPTY_HOME }, () =>
computeExtractAtomsBacklogCheck(engine));
expect(check.status).toBe('ok');
expect((check.details as { backlog: number }).backlog).toBe(0);
});
it('WARNs with a --drain hint when the pack does not run the phase and backlog > 10', async () => {
for (let i = 0; i < 11; i++) await seedArticle(`article-${i}`);
const check = await withEnv({ GBRAIN_HOME: EMPTY_HOME }, () =>
computeExtractAtomsBacklogCheck(engine));
expect(check.status).toBe('warn');
expect(check.message).toContain('--drain');
expect((check.details as { pack_declares_phase: boolean }).pack_declares_phase).toBe(false);
expect((check.details as { known_approximation: string }).known_approximation).toContain('page backlog only');
});
});
+24
View File
@@ -105,4 +105,28 @@ describe('dream CLI flag wiring', () => {
expect(dreamSrc).toContain('gbrain sources restore');
});
});
// issue #1678 — --drain bounded backlog drain wiring (structural).
describe('--drain wiring', () => {
test('declares --drain and --window flags', () => {
expect(dreamSrc).toContain("'--drain'");
expect(dreamSrc).toContain("'--window'");
expect(dreamSrc).toContain('windowSeconds');
});
test('--drain defaults to extract_atoms and rejects other phases', () => {
expect(dreamSrc).toContain("phase = 'extract_atoms'");
expect(dreamSrc).toContain('--drain currently supports only --phase extract_atoms');
});
test('drain holds the same cycle lock id (contends with the routine cycle)', () => {
expect(dreamSrc).toContain('cycleLockIdFor(resolvedSourceId)');
expect(dreamSrc).toContain('withRefreshingLock');
});
test('drain reports remaining + exits non-zero when incomplete', () => {
expect(dreamSrc).toContain('EXIT_DRAIN_INCOMPLETE');
expect(dreamSrc).toContain('cycle_already_running');
});
});
});
+8 -12
View File
@@ -29,7 +29,7 @@ mock.module('../../src/core/embedding.ts', () => ({
currentEmbeddingSignature: () => 'test:model:1536',
}));
const { runCycle } = await import('../../src/core/cycle.ts');
const { runCycle, ALL_PHASES } = await import('../../src/core/cycle.ts');
const skip = !hasDatabase();
const describeE2E = skip ? describe.skip : describe;
@@ -99,17 +99,13 @@ describeE2E('E2E: runCycle against real Postgres', () => {
});
expect(report.schema_version).toBe('1');
// Cycle ran all 16 phases (or skipped the ones that don't support dry-run).
// Phase history:
// v0.23 = 8 phases (lint → backlinks → sync → synthesize → extract → patterns → embed → orphans)
// v0.26.5 = 9 (added `purge` after orphans)
// v0.29 = 10 (added `recompute_emotional_weight` between patterns and embed)
// v0.31 = 11 (added `consolidate` between recompute_emotional_weight and embed)
// v0.32.2 = 12 (added `extract_facts` between extract and patterns)
// v0.33.3 = 13 (added `resolve_symbol_edges` between extract_facts and patterns)
// v0.36.1.0 = 16 (added propose_takes + grade_takes + calibration_profile — hindsight calibration wave)
// v0.39.0.0 = 17 (added `schema-suggest` between orphans and purge — T12 schema cathedral)
expect(report.phases.length).toBe(19); // v0.41: +extract_atoms, +synthesize_concepts
// Every phase in ALL_PHASES pushes exactly one result (pack-gated phases
// like extract_atoms / synthesize_concepts push a 'skipped' result), so
// the full dry-run cycle's phase count always equals ALL_PHASES.length.
// Assert against the live constant rather than a hardcoded number so this
// doesn't go stale every time a phase is added (it drifted to 19 while the
// real count was 20 — the conversation_facts_backfill phase was missed).
expect(report.phases.length).toBe(ALL_PHASES.length);
// Nothing got written.
const afterPages = await conn.unsafe(`SELECT count(*)::int AS n FROM pages`);
+108
View File
@@ -0,0 +1,108 @@
/**
* issue #1678 — bounded single-hold extract_atoms drain loop.
*
* Pure-over-injected-deps, so no DB / LLM / lock primitive. Pins:
* - drains to empty (rediscovers each batch via countRemaining), stops 'drained'
* - the wallclock window bounds the loop, stops 'window' with remaining > 0
* - a zero-progress batch stops the loop (no hot loop burning budget)
* - a busy lock (withLock throws) propagates so the caller reports skipped
*/
import { describe, it, expect } from 'bun:test';
import {
runExtractAtomsDrain,
type ExtractAtomsDrainDeps,
} from '../src/core/cycle/extract-atoms-drain.ts';
function seq(values: Array<number | null>): () => Promise<number | null> {
let i = 0;
return async () => values[Math.min(i++, values.length - 1)];
}
const passThroughLock: ExtractAtomsDrainDeps['withLock'] = (work) => work();
describe('runExtractAtomsDrain (issue #1678)', () => {
it('drains to empty and reports stopped=drained', async () => {
let batches = 0;
const result = await runExtractAtomsDrain(
{
withLock: passThroughLock,
countRemaining: seq([3, 2, 1, 0, 0]),
runBatch: async () => { batches++; return { extracted: 1, skipped: 0 }; },
now: () => 0,
},
{ windowMs: 1_000_000 },
);
expect(result.stopped).toBe('drained');
expect(result.remaining).toBe(0);
expect(result.batches).toBe(3);
expect(result.extracted).toBe(3);
expect(batches).toBe(3);
});
it('stops at the wallclock window with remaining > 0', async () => {
// SYNC stepping clock: now() #1 sets deadline (0+100=100); the while-check
// then sees 50, 50 (two batches), then 999999 → past deadline → stop.
const times = [0, 50, 50, 999_999];
let ti = 0;
const now = () => times[Math.min(ti++, times.length - 1)];
const result = await runExtractAtomsDrain(
{
withLock: passThroughLock,
countRemaining: async () => 5, // never drains
runBatch: async () => ({ extracted: 1, skipped: 0 }),
now,
},
{ windowMs: 100 },
);
expect(result.stopped).toBe('window');
expect(result.remaining).toBe(5);
expect(result.batches).toBe(2);
});
it('stops on a zero-progress batch (no hot loop)', async () => {
let batches = 0;
const result = await runExtractAtomsDrain(
{
withLock: passThroughLock,
countRemaining: async () => 5,
runBatch: async () => { batches++; return { extracted: 0, skipped: 0 }; },
now: () => 0,
},
{ windowMs: 1_000_000 },
);
expect(result.stopped).toBe('no_progress');
expect(batches).toBe(1);
expect(result.remaining).toBe(5);
});
it('propagates a busy-lock error (caller reports cycle_already_running)', async () => {
class FakeBusy extends Error {}
await expect(
runExtractAtomsDrain(
{
withLock: () => { throw new FakeBusy('held'); },
countRemaining: async () => 5,
runBatch: async () => ({ extracted: 1, skipped: 0 }),
now: () => 0,
},
{ windowMs: 1000 },
),
).rejects.toThrow('held');
});
it('respects maxBatches as a belt-and-suspenders cap', async () => {
let batches = 0;
const result = await runExtractAtomsDrain(
{
withLock: passThroughLock,
countRemaining: async () => 999, // never drains
runBatch: async () => { batches++; return { extracted: 1, skipped: 0 }; },
now: () => 0, // window never elapses
},
{ windowMs: 1_000_000, maxBatches: 4 },
);
expect(result.stopped).toBe('max_batches');
expect(batches).toBe(4);
});
});
+6
View File
@@ -40,6 +40,11 @@ const backoffFloor = parseInt(process.env.SUP_BACKOFF_FLOOR_MS ?? '1', 10);
const healthInterval = parseInt(process.env.SUP_HEALTH_INTERVAL_MS ?? '999999', 10);
const allowShellJobs = process.env.SUP_ALLOW_SHELL_JOBS === '1';
const queueName = process.env.SUP_QUEUE ?? 'default';
// SUP_MAX_RSS: when set, pin an explicit watchdog cap (tests the passthrough
// path). When unset, MinionSupervisor auto-sizes cgroup-aware (issue #1678).
const maxRssExplicit = process.env.SUP_MAX_RSS !== undefined
? parseInt(process.env.SUP_MAX_RSS, 10)
: undefined;
if (process.env.SUP_AUDIT_DIR) {
process.env.GBRAIN_AUDIT_DIR = process.env.SUP_AUDIT_DIR;
@@ -57,6 +62,7 @@ const supervisor = new MinionSupervisor(mockEngine as BrainEngine, {
allowShellJobs,
json: true,
_backoffFloorMs: backoffFloor,
...(maxRssExplicit !== undefined ? { maxRssMb: maxRssExplicit } : {}),
onEvent: (emission) => writeSupervisorEvent(emission, supervisorPid),
});
+51
View File
@@ -0,0 +1,51 @@
/**
* issue #1678 — lint must REUSE a caller-provided engine for the
* content-sanity DB-plane config lift, never create + disconnect its own.
*
* The bug: resolveLintContentSanity created a module-style engine
* (createEngine without poolSize wraps the db.ts singleton) and disconnect()ed
* it, which cascaded to db.disconnect() and NULLED the shared singleton the
* cycle's lint phase depends on — breaking every subsequent cycle phase with a
* misleading "connect() has not been called". When the caller passes a live
* engine, lint must use it directly with zero connection churn.
*
* Hermetic: a fake BrainEngine that records disconnect() calls + serves
* getConfig. No real DB.
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { runLintCore } from '../src/commands/lint.ts';
import type { BrainEngine } from '../src/core/engine.ts';
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'lint-shared-engine-'));
writeFileSync(join(dir, 'a.md'), '---\ntype: note\ntitle: A\n---\n\nSome content.\n');
});
afterEach(() => {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ }
});
describe('runLintCore engine reuse (issue #1678)', () => {
it('reuses a provided engine for the content-sanity lift and NEVER disconnects it', async () => {
const state = { disconnects: 0, connects: 0, getConfigCalls: 0 };
const engine = {
kind: 'postgres' as const,
getConfig: async () => { state.getConfigCalls++; return null; },
connect: async () => { state.connects++; },
disconnect: async () => { state.disconnects++; },
} as unknown as BrainEngine;
await runLintCore({ target: dir, fix: false, dryRun: true, engine });
// The load-bearing assertion: the shared engine was used (getConfig hit)
// but NEVER disconnected and NEVER re-connected — no connection churn that
// could null a shared singleton mid-cycle.
expect(state.getConfigCalls).toBeGreaterThan(0);
expect(state.disconnects).toBe(0);
expect(state.connects).toBe(0);
});
});
+82 -1
View File
@@ -13,7 +13,12 @@
*/
import { describe, test, expect } from 'bun:test';
import { splitProviderModelId } from '../src/core/model-id.ts';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { splitProviderModelId, normalizeModelId } from '../src/core/model-id.ts';
const REPO_ROOT = join(import.meta.dir, '..');
const readSrc = (rel: string) => readFileSync(join(REPO_ROOT, rel), 'utf8');
describe('splitProviderModelId', () => {
describe('happy paths', () => {
@@ -138,3 +143,79 @@ describe('splitProviderModelId', () => {
});
});
});
describe('normalizeModelId (#1698)', () => {
test('slash form → colon — THE REPORTED BUG', () => {
// Pre-fix: the colon-only inline check left this as the malformed
// `anthropic:anthropic/claude-sonnet-4-6` and silently degraded to no-LLM.
expect(normalizeModelId('anthropic/claude-sonnet-4-6')).toBe('anthropic:claude-sonnet-4-6');
});
test('bare → anthropic: default', () => {
expect(normalizeModelId('claude-sonnet-4-6')).toBe('anthropic:claude-sonnet-4-6');
});
test('colon identity (already provider:model)', () => {
expect(normalizeModelId('anthropic:claude-sonnet-4-6')).toBe('anthropic:claude-sonnet-4-6');
});
test('openrouter nested form preserved (inner slash kept)', () => {
expect(normalizeModelId('openrouter:anthropic/claude-sonnet-4.6')).toBe('openrouter:anthropic/claude-sonnet-4.6');
});
test('non-anthropic bare with custom default provider', () => {
expect(normalizeModelId('gpt-5', 'openai')).toBe('openai:gpt-5');
});
test('empty / whitespace returns input as-is (downstream throws loudly)', () => {
expect(normalizeModelId('')).toBe('');
expect(normalizeModelId(' ')).toBe(' ');
});
// #1698 (codex #2): a malformed leading separator yields an EMPTY-STRING provider from
// splitProviderModelId. It must be returned unchanged (so resolveRecipe throws loudly),
// NOT silently coerced to the default provider — otherwise `:claude-sonnet-4-6` would run
// as `anthropic:claude-sonnet-4-6`, masking a typo as a valid Anthropic model.
test('leading-colon malformed id returns input as-is (NOT coerced to anthropic)', () => {
expect(normalizeModelId(':claude-sonnet-4-6')).toBe(':claude-sonnet-4-6');
});
test('leading-slash malformed id returns input as-is (NOT coerced to anthropic)', () => {
expect(normalizeModelId('/claude-sonnet-4-6')).toBe('/claude-sonnet-4-6');
});
test('leading separator is not coerced even with a custom default provider', () => {
expect(normalizeModelId(':gpt-5', 'openai')).toBe(':gpt-5');
});
});
describe('normalize-everywhere structural guards (#1698)', () => {
// Positive: every chat-adapter site references the shared normalizer.
const SITES = [
'src/core/think/index.ts',
'src/core/cycle/synthesize.ts',
'src/core/conversation-parser/llm-base.ts',
'src/core/facts/extract.ts',
];
// The exact colon-only inline that #1698 fixed: `X.includes(':') ? X : `anthropic:`.
const COLON_ONLY_INLINE = /\.includes\(['"]:['"]\)\s*\?\s*[\w.]+\s*:\s*`anthropic:/;
for (const site of SITES) {
test(`${site} uses normalizeModelId and not the colon-only inline`, () => {
const src = readSrc(site);
expect(src).toContain('normalizeModelId');
expect(COLON_ONLY_INLINE.test(src)).toBe(false);
});
}
test('hasAnthropicKey is defined exactly once (the shared helper), not re-copied', () => {
const FORMER_COPIES = [
'src/core/think/index.ts',
'src/core/cycle/synthesize.ts',
'src/core/conversation-parser/llm-base.ts',
];
for (const f of FORMER_COPIES) {
expect(readSrc(f)).not.toMatch(/function hasAnthropicKey\s*\(/);
}
// The one canonical definition lives here.
expect(readSrc('src/core/ai/anthropic-key.ts')).toMatch(/export function hasAnthropicKey\s*\(/);
});
});
@@ -0,0 +1,43 @@
/**
* issue #1678 — the `PostgresEngine.sql` getter must not fall through to the
* never-connected module singleton when an INSTANCE pool's _sql went null
* (mid-process disconnect, or a reaped pooler socket). Pre-fix it threw the
* misleading "connect() has not been called"; post-fix it throws a tailored
* RETRYABLE error so withRetry+reconnect rebuilds the pool and recovers.
*
* Pure: pokes the private fields and reads the synchronous getter; no real DB.
*/
import { describe, it, expect } from 'bun:test';
import { PostgresEngine } from '../src/core/postgres-engine.ts';
import { isRetryableConnError } from '../src/core/retry-matcher.ts';
describe('PostgresEngine.sql getter self-heal (issue #1678)', () => {
it('instance-pool + null _sql throws a RETRYABLE error naming the reaped pool', () => {
const e = new PostgresEngine();
(e as unknown as { _connectionStyle: string })._connectionStyle = 'instance';
(e as unknown as { _sql: unknown })._sql = null;
let thrown: unknown;
try {
// accessing the getter triggers the throw
void e.sql;
} catch (err) {
thrown = err;
}
expect(thrown).toBeDefined();
// Must be classified retryable so the lock/batch retry paths reconnect.
expect(isRetryableConnError(thrown)).toBe(true);
// Must NOT be the misleading legacy message.
const msg = (thrown as Error).message;
expect(msg).toContain('instance connection pool');
expect(msg).not.toContain('connect() has not been called');
});
it('a live instance _sql is returned directly (no throw)', () => {
const e = new PostgresEngine();
const fakeSql = { tag: 'live-pool' };
(e as unknown as { _sql: unknown })._sql = fakeSql;
expect(e.sql as unknown).toBe(fakeSql);
});
});
+70
View File
@@ -0,0 +1,70 @@
/**
* issue #1678 — Minion hot-path lock recovery contract.
*
* - promoteDelayed (idempotent) self-heals: a reaped-socket CONNECTION_ENDED
* triggers a reconnect + retry against a fresh pool.
* - claim does NOT retry inline (Codex #1): blind-retrying a claim whose
* UPDATE...RETURNING may have committed could double-claim a job. The error
* propagates; the worker poll loop reconnects + re-claims on the next tick.
*
* Hermetic: a fake BrainEngine whose executeRaw is scripted; no real DB.
*/
import { describe, it, expect } from 'bun:test';
import { tmpdir } from 'os';
import { join } from 'path';
import { MinionQueue } from '../src/core/minions/queue.ts';
import { withEnv } from './helpers/with-env.ts';
function connEndedError(): Error & { code: string } {
const e = new Error('write CONNECTION_ENDED localhost:6543') as Error & { code: string };
e.code = 'CONNECTION_ENDED';
return e;
}
const AUDIT_DIR = join(tmpdir(), `gbrain-queue-lock-retry-${process.pid}-${Date.now()}`);
// Fast retry + isolated audit dir so the test doesn't sleep ~1s or pollute ~/.gbrain.
const FAST_ENV = {
GBRAIN_BULK_RETRY_BASE_MS: '1',
GBRAIN_BULK_RETRY_MAX_MS: '2',
GBRAIN_AUDIT_DIR: AUDIT_DIR,
};
describe('MinionQueue lock-path recovery (issue #1678)', () => {
it('promoteDelayed reconnects + retries on a reaped-socket error', async () => {
await withEnv(FAST_ENV, async () => {
let calls = 0;
let reconnects = 0;
const engine = {
kind: 'postgres',
executeRaw: async () => {
calls++;
if (calls === 1) throw connEndedError();
return [];
},
reconnect: async () => { reconnects++; },
} as unknown as ConstructorParameters<typeof MinionQueue>[0];
const q = new MinionQueue(engine);
const out = await q.promoteDelayed();
expect(out).toEqual([]);
expect(calls).toBe(2); // first attempt threw, retry succeeded
expect(reconnects).toBe(1); // reconnect fired between attempts
});
});
it('claim does NOT retry inline on a reaped-socket error (Codex #1 double-claim guard)', async () => {
await withEnv(FAST_ENV, async () => {
let calls = 0;
const engine = {
kind: 'postgres',
executeRaw: async () => { calls++; throw connEndedError(); },
reconnect: async () => {},
} as unknown as ConstructorParameters<typeof MinionQueue>[0];
const q = new MinionQueue(engine);
await expect(q.claim('tok', 1000, 'default', ['sync'])).rejects.toThrow('CONNECTION_ENDED');
expect(calls).toBe(1); // exactly one attempt — no inline retry
});
});
});
+18
View File
@@ -75,6 +75,24 @@ describe('isRetryableConnError', () => {
test('does not match arbitrary errors', () => {
expect(isRetryableConnError(new Error('something else'))).toBe(false);
});
// issue #1678: postgres.js's transaction-mode pooler reaps idle sockets and
// throws errors carrying `code: 'CONNECTION_ENDED'` (a library code, not an
// 08xxx SQLSTATE). Must be retryable via BOTH the code and the message form.
test('matches CONNECTION_ENDED via code', () => {
expect(isRetryableConnError(pgError('CONNECTION_ENDED', 'write CONNECTION_ENDED'))).toBe(true);
});
test('matches CONNECTION_ENDED via message even without the code', () => {
expect(isRetryableConnError(new Error('write CONNECTION_ENDED localhost:6543'))).toBe(true);
});
// The getter self-heal throws a GBrainError whose `problem` field is
// 'No database connection' — the existing typed-shape match must keep firing.
test('matches the instance-pool-reaped GBrainError shape (problem field)', () => {
const err = { problem: 'No database connection', message: 'instance pool torn down' };
expect(isRetryableConnError(err)).toBe(true);
});
});
describe('isRetryableError', () => {
+98
View File
@@ -0,0 +1,98 @@
/**
* issue #1678 — cgroup-aware auto-sized RSS watchdog default.
*
* The load-bearing case (Codex #5): a tiny cgroup limit on a huge host must
* win, so the watchdog cap sits BELOW the real ceiling and the graceful drain
* beats the kernel OOM-killer. Plain os.totalmem() would pick a 16GB cap on a
* 4GB-limited container and re-break into a silent SIGKILL.
*/
import { describe, it, expect } from 'bun:test';
import {
resolveDefaultMaxRssMb,
describeDefaultMaxRss,
readCgroupMemLimitBytes,
RSS_DEFAULT_FLOOR_MB,
RSS_DEFAULT_CEIL_MB,
} from '../src/core/minions/rss-default.ts';
const GB = 1024 * 1024 * 1024;
describe('resolveDefaultMaxRssMb — clamp', () => {
it('8GB host (no cgroup) → floor 4096', () => {
expect(resolveDefaultMaxRssMb({ totalMemBytes: 8 * GB, cgroupLimitBytes: null })).toBe(4096);
});
it('16GB host → 8192 (0.5x, inside the band)', () => {
expect(resolveDefaultMaxRssMb({ totalMemBytes: 16 * GB, cgroupLimitBytes: null })).toBe(8192);
});
it('32GB host → ceil 16384', () => {
expect(resolveDefaultMaxRssMb({ totalMemBytes: 32 * GB, cgroupLimitBytes: null })).toBe(16384);
});
it('126GB host → ceil 16384 (the incident box)', () => {
expect(resolveDefaultMaxRssMb({ totalMemBytes: 126 * GB, cgroupLimitBytes: null })).toBe(16384);
});
it('result always within [floor, ceil] for huge hosts', () => {
const mb = resolveDefaultMaxRssMb({ totalMemBytes: 1024 * GB, cgroupLimitBytes: null });
expect(mb).toBeGreaterThanOrEqual(RSS_DEFAULT_FLOOR_MB);
expect(mb).toBeLessThanOrEqual(RSS_DEFAULT_CEIL_MB);
});
});
describe('resolveDefaultMaxRssMb — cgroup limit wins (Codex #5)', () => {
it('4GB cgroup on a 126GB host → cap stays BELOW the 4GB ceiling', () => {
const d = describeDefaultMaxRss({ totalMemBytes: 126 * GB, cgroupLimitBytes: 4 * GB });
expect(d.source).toBe('cgroup-limited');
expect(d.basisMb).toBe(4096);
// 0.5x4096 = 2048, below the 4096 floor but the floor must NOT push the cap
// up to/above the real 4GB ceiling — that would defeat drain-before-OOM.
expect(d.mb).toBeLessThan(4096);
expect(d.mb).toBe(2048);
});
it('8GB cgroup on a big host → 4096 (0.5x), source cgroup-limited', () => {
const d = describeDefaultMaxRss({ totalMemBytes: 64 * GB, cgroupLimitBytes: 8 * GB });
expect(d.mb).toBe(4096);
expect(d.source).toBe('cgroup-limited');
});
it('cgroup limit >= host RAM reads as host (unlimited sentinel collapses via min)', () => {
const d = describeDefaultMaxRss({ totalMemBytes: 16 * GB, cgroupLimitBytes: 9_223_372_036_854_771_712 });
expect(d.source).toBe('host');
expect(d.mb).toBe(8192);
});
});
describe('readCgroupMemLimitBytes', () => {
it('cgroup v2 "max" → null (no enforced limit)', () => {
const read = (p: string) => {
if (p === '/sys/fs/cgroup/memory.max') return 'max\n';
throw new Error('ENOENT');
};
expect(readCgroupMemLimitBytes(read)).toBeNull();
});
it('cgroup v2 numeric → that value', () => {
const read = (p: string) => {
if (p === '/sys/fs/cgroup/memory.max') return String(4 * GB) + '\n';
throw new Error('ENOENT');
};
expect(readCgroupMemLimitBytes(read)).toBe(4 * GB);
});
it('falls back to cgroup v1 when v2 unreadable', () => {
const read = (p: string) => {
if (p === '/sys/fs/cgroup/memory/memory.limit_in_bytes') return String(2 * GB);
throw new Error('ENOENT');
};
expect(readCgroupMemLimitBytes(read)).toBe(2 * GB);
});
it('neither file present → null', () => {
const read = () => { throw new Error('ENOENT'); };
expect(readCgroupMemLimitBytes(read)).toBeNull();
});
});
+1 -1
View File
@@ -90,6 +90,6 @@ describe('alias_resolved boost stage', () => {
describe('KNOBS_HASH_VERSION', () => {
it('bumped to 6 to invalidate caches across v0.42 boost stage addition', () => {
expect(KNOBS_HASH_VERSION).toBe(7);
expect(KNOBS_HASH_VERSION).toBe(8);
});
});
+84 -6
View File
@@ -74,6 +74,9 @@ describe('SEARCH_MODES + MODE_BUNDLES canonical shape', () => {
graph_signals: false,
...CR_DISABLED_DEFAULT,
contextual_retrieval: 'none',
// v0.42.3.0 — autocut OFF for conservative (no reranker).
autocut: false,
autocut_jump: 0.2,
});
});
@@ -90,7 +93,8 @@ describe('SEARCH_MODES + MODE_BUNDLES canonical shape', () => {
searchLimit: 25,
reranker_enabled: true,
reranker_model: 'zeroentropyai:zerank-2',
reranker_top_n_in: 30,
// v0.42.3.0 D4: topNIn = searchLimit (25), was 30.
reranker_top_n_in: 25,
reranker_top_n_out: null,
reranker_timeout_ms: 5000,
floor_ratio: undefined,
@@ -99,6 +103,9 @@ describe('SEARCH_MODES + MODE_BUNDLES canonical shape', () => {
graph_signals: true,
...CR_DISABLED_DEFAULT,
contextual_retrieval: 'title',
// v0.42.3.0 — autocut ON.
autocut: true,
autocut_jump: 0.2,
});
});
@@ -113,7 +120,8 @@ describe('SEARCH_MODES + MODE_BUNDLES canonical shape', () => {
searchLimit: 50,
reranker_enabled: true,
reranker_model: 'zeroentropyai:zerank-2',
reranker_top_n_in: 30,
// v0.42.3.0 D4: topNIn = searchLimit (50), was 30.
reranker_top_n_in: 50,
reranker_top_n_out: null,
reranker_timeout_ms: 5000,
floor_ratio: undefined,
@@ -122,6 +130,9 @@ describe('SEARCH_MODES + MODE_BUNDLES canonical shape', () => {
graph_signals: true,
...CR_DISABLED_DEFAULT,
contextual_retrieval: 'per_chunk_synopsis',
// v0.42.3.0 — autocut ON.
autocut: true,
autocut_jump: 0.2,
});
});
@@ -376,10 +387,11 @@ describe('knobsHash determinism + cross-mode separation (CDX-4)', () => {
// written when the brain was on balanced (title-only) — different
// embedding spaces. Sequenced behind salem's v=4 graph-signals work.
// v0.41.22.0 (type-unification): bumped 5→6 for the new alias_resolved
// post-fusion boost stage. A query against a brain with slug_aliases
// populated must not be served from a cache row written before the
// boost stage existed.
expect(KNOBS_HASH_VERSION).toBe(7);
// post-fusion boost stage. T2: bumped 6→7 for title_boost. v0.42.3.0:
// bumped 7→8 for autocut (ac=/acj=). A query against a brain with
// slug_aliases populated must not be served from a cache row written
// before the boost stage existed.
expect(KNOBS_HASH_VERSION).toBe(8);
});
test('T1 (codex): floor_ratio set vs unset produces DIFFERENT hashes (cache contamination prevention)', () => {
@@ -542,3 +554,69 @@ describe('v0.40.4 — graph_signals knob', () => {
expect(attr.value).toBe(true);
});
});
describe('v0.42.3.0 — autocut knobs', () => {
test('KNOBS_HASH_VERSION bumped to 7', () => {
expect(KNOBS_HASH_VERSION).toBe(8);
});
test('bundle defaults: conservative off, balanced/tokenmax on @0.20', () => {
expect(MODE_BUNDLES.conservative.autocut).toBe(false);
expect(MODE_BUNDLES.balanced.autocut).toBe(true);
expect(MODE_BUNDLES.tokenmax.autocut).toBe(true);
for (const m of ['conservative', 'balanced', 'tokenmax'] as const) {
expect(MODE_BUNDLES[m].autocut_jump).toBe(0.2);
}
});
test('D4: reranked modes set top_n_in = searchLimit (no unscored tail)', () => {
expect(MODE_BUNDLES.balanced.reranker_top_n_in).toBe(MODE_BUNDLES.balanced.searchLimit);
expect(MODE_BUNDLES.tokenmax.reranker_top_n_in).toBe(MODE_BUNDLES.tokenmax.searchLimit);
expect(MODE_BUNDLES.balanced.reranker_top_n_in).toBe(25);
expect(MODE_BUNDLES.tokenmax.reranker_top_n_in).toBe(50);
});
test('resolveSearchMode threads autocut: per-call > config > bundle', () => {
// per-call wins
expect(resolveSearchMode({ mode: 'balanced', perCall: { autocut: false } }).autocut).toBe(false);
// config override wins over bundle
expect(resolveSearchMode({ mode: 'balanced', overrides: { autocut: false } }).autocut).toBe(false);
// per-call beats config
expect(
resolveSearchMode({ mode: 'balanced', overrides: { autocut: false }, perCall: { autocut: true } }).autocut,
).toBe(true);
// jump knob threads too
expect(resolveSearchMode({ mode: 'balanced', perCall: { autocut_jump: 0.5 } }).autocut_jump).toBe(0.5);
});
test('loadOverridesFromConfig reads search.autocut + search.autocut_jump', () => {
const ov = loadOverridesFromConfig({ 'search.autocut': 'false', 'search.autocut_jump': '0.35' });
expect(ov.autocut).toBe(false);
expect(ov.autocut_jump).toBe(0.35);
});
test('SEARCH_MODE_CONFIG_KEYS includes the autocut keys', () => {
expect(SEARCH_MODE_CONFIG_KEYS).toContain('search.autocut');
expect(SEARCH_MODE_CONFIG_KEYS).toContain('search.autocut_jump');
});
test('knobsHash includes ac= / acj= — autocut-on vs off differ', () => {
const on = knobsHash(resolveSearchMode({ mode: 'balanced' })); // autocut true
const off = knobsHash(resolveSearchMode({ mode: 'balanced', perCall: { autocut: false } }));
expect(on).not.toBe(off);
});
test('knobsHash differs on jump sensitivity', () => {
const a = knobsHash(resolveSearchMode({ mode: 'balanced' }));
const b = knobsHash(resolveSearchMode({ mode: 'balanced', perCall: { autocut_jump: 0.5 } }));
expect(a).not.toBe(b);
});
test('attributeKnob reports autocut source', () => {
const input = { mode: 'balanced', perCall: { autocut: false } };
const resolved = resolveSearchMode(input);
const attr = attributeKnob('autocut', input, resolved);
expect(attr.source).toBe('per-call');
expect(attr.value).toBe(false);
});
});
+197
View File
@@ -0,0 +1,197 @@
/**
* v0.42.3.0 — autocut precision/recall eval gate (in-repo, hermetic).
*
* This is the D2 precondition for default-ON, runnable in CI without the
* sibling gbrain-evals repo and without any API key. It measures the EXACT
* claim default-ON rests on: does cutting at the rerank-score cliff lift
* precision WITHOUT regressing recall — especially on enumeration queries?
*
* WHAT IT MODELS (and what it does NOT): autocut cuts on cross-encoder
* rerank scores. This gate feeds applyAutocut labeled qrels fixtures whose
* per-candidate `rerank_score` follows realistic cross-encoder distributions
* (clean cliffs for single-answer queries, a high cluster + cliff for
* enumeration, flat curves for ambiguous breadth, and an adversarial case
* where the reranker mis-scores a relevant doc below a cliff). It measures
* the precision/recall tradeoff of the CUT DECISION on those distributions.
* It does NOT claim that ZeroEntropy's live scores look like these fixtures
* on a specific brain — that empirical confirmation is the optional
* gbrain-evals PrecisionMemBench run. What this gate DOES guarantee, in CI:
* - autocut lifts mean precision well above the no-autocut baseline,
* - it does NOT regress recall below a floor,
* - it NEVER regresses recall on enumeration/flat queries (structural:
* a flat curve has no cliff, so autocut declines → identical recall).
*
* The gate fails (correctly) if someone over-tunes autocut (e.g. drops
* jumpRatio so low it cuts into clusters) or if the cut math regresses.
* Floors are env-overridable so an intentional ranking change edits the
* threshold with a documented reason.
*/
import { describe, expect, test } from 'bun:test';
import { applyAutocut, DEFAULT_AUTOCUT } from '../../src/core/search/autocut.ts';
type Candidate = { rerank_score: number; relevant: boolean };
type EvalQuery = {
id: string;
kind: 'single' | 'cluster' | 'enumeration' | 'adversarial';
/** Candidates in reranked (descending-score) order. */
candidates: Candidate[];
};
const scoreOf = (c: Candidate) => c.rerank_score;
const LIMIT = 10; // mirrors a typical returned-set cap; doesn't bind these lists
// Realistic cross-encoder distributions. Proportions reflect the empirical
// reality return-policy.ts cites: rank-1 is correct in ~94% of single-answer
// cases, so clean cliffs dominate and adversarial mis-scores are rare.
const FIXTURE: EvalQuery[] = [
// 5 single-answer queries with a clean cliff after the one right answer.
...Array.from({ length: 5 }, (_, i): EvalQuery => ({
id: `single-${i}`,
kind: 'single',
candidates: [
{ rerank_score: 0.95, relevant: true },
{ rerank_score: 0.30, relevant: false },
{ rerank_score: 0.25, relevant: false },
{ rerank_score: 0.20, relevant: false },
{ rerank_score: 0.15, relevant: false },
{ rerank_score: 0.10, relevant: false },
{ rerank_score: 0.08, relevant: false },
{ rerank_score: 0.05, relevant: false },
],
})),
// 2 cluster queries: a tight relevant cluster, then a cliff to noise.
...Array.from({ length: 2 }, (_, i): EvalQuery => ({
id: `cluster-${i}`,
kind: 'cluster',
candidates: [
{ rerank_score: 0.90, relevant: true },
{ rerank_score: 0.88, relevant: true },
{ rerank_score: 0.85, relevant: true },
{ rerank_score: 0.25, relevant: false },
{ rerank_score: 0.20, relevant: false },
{ rerank_score: 0.15, relevant: false },
{ rerank_score: 0.10, relevant: false },
],
})),
// 2 enumeration/broad queries: flat curve, many relevant, NO cliff.
// Autocut must DECLINE here — this is the recall-regression risk D2 gates.
...Array.from({ length: 2 }, (_, i): EvalQuery => ({
id: `enumeration-${i}`,
kind: 'enumeration',
candidates: [
{ rerank_score: 0.60, relevant: true },
{ rerank_score: 0.58, relevant: true },
{ rerank_score: 0.56, relevant: true },
{ rerank_score: 0.54, relevant: true },
{ rerank_score: 0.52, relevant: true },
{ rerank_score: 0.50, relevant: false },
{ rerank_score: 0.48, relevant: false },
],
})),
// 1 adversarial query: the reranker mis-scores a relevant doc BELOW an
// early cliff. Autocut will drop it — this models reranker error, the only
// way autocut can hurt recall. Kept rare to match real distributions.
{
id: 'adversarial-0',
kind: 'adversarial',
candidates: [
{ rerank_score: 0.90, relevant: true },
{ rerank_score: 0.50, relevant: true }, // relevant but mis-scored below the cliff
{ rerank_score: 0.48, relevant: false },
{ rerank_score: 0.46, relevant: false },
{ rerank_score: 0.20, relevant: false },
],
},
];
function precisionRecall(kept: Candidate[], totalRelevant: number): { p: number; r: number } {
const relevantKept = kept.filter((c) => c.relevant).length;
const p = kept.length === 0 ? 0 : relevantKept / kept.length;
const r = totalRelevant === 0 ? 1 : relevantKept / totalRelevant;
return { p, r };
}
function evalQuery(q: EvalQuery, autocutOn: boolean) {
const totalRelevant = q.candidates.filter((c) => c.relevant).length;
const pool = autocutOn
? applyAutocut(q.candidates, scoreOf, { ...DEFAULT_AUTOCUT }).kept
: q.candidates;
const kept = pool.slice(0, LIMIT);
return precisionRecall(kept, totalRelevant);
}
function mean(xs: number[]): number {
return xs.length === 0 ? 0 : xs.reduce((a, b) => a + b, 0) / xs.length;
}
function envFloor(name: string, fallback: number): number {
const v = process.env[name];
if (v === undefined) return fallback;
const n = parseFloat(v);
return Number.isFinite(n) ? n : fallback;
}
describe('autocut eval gate (D2 — precision lift without recall regression)', () => {
const off = FIXTURE.map((q) => evalQuery(q, false));
const on = FIXTURE.map((q) => evalQuery(q, true));
const precisionOff = mean(off.map((x) => x.p));
const precisionOn = mean(on.map((x) => x.p));
const recallOff = mean(off.map((x) => x.r));
const recallOn = mean(on.map((x) => x.r));
// Surface the numbers (the CHANGELOG/eval record reads these).
// eslint-disable-next-line no-console
console.error(
`[autocut-eval] precision ${precisionOff.toFixed(3)}${precisionOn.toFixed(3)} ` +
`(+${(precisionOn - precisionOff).toFixed(3)}) | recall ${recallOff.toFixed(3)}` +
`${recallOn.toFixed(3)} (${(recallOn - recallOff).toFixed(3)})`,
);
// Floors are env-overridable (document the reason in the commit when you move them).
const LIFT_FLOOR = envFloor('GBRAIN_AUTOCUT_EVAL_PRECISION_LIFT_FLOOR', 0.15);
const RECALL_FLOOR = envFloor('GBRAIN_AUTOCUT_EVAL_RECALL_FLOOR', 0.9);
const RECALL_REGRESSION_TOLERANCE = envFloor('GBRAIN_AUTOCUT_EVAL_RECALL_TOLERANCE', 0.1);
test('autocut lifts mean precision well above baseline', () => {
expect(precisionOn - precisionOff).toBeGreaterThanOrEqual(LIFT_FLOOR);
});
test('autocut keeps mean recall above the floor', () => {
expect(recallOn).toBeGreaterThanOrEqual(RECALL_FLOOR);
});
test('recall regression is bounded', () => {
expect(recallOff - recallOn).toBeLessThanOrEqual(RECALL_REGRESSION_TOLERANCE);
});
test('ZERO recall regression on enumeration/flat queries (the D2 core concern)', () => {
// Where recall matters most — broad enumeration with no cliff — autocut
// must decline and return the full set, so recall is identical on/off.
const enums = FIXTURE.filter((q) => q.kind === 'enumeration');
expect(enums.length).toBeGreaterThan(0);
for (const q of enums) {
const offR = evalQuery(q, false).r;
const onR = evalQuery(q, true).r;
expect(onR).toBe(offR);
}
});
test('single-answer queries: precision goes to 1.0 with recall preserved', () => {
const singles = FIXTURE.filter((q) => q.kind === 'single');
for (const q of singles) {
const onPR = evalQuery(q, true);
expect(onPR.p).toBe(1); // only the right answer returned
expect(onPR.r).toBe(1); // and it IS the right answer
}
});
test('cluster queries: the whole relevant cluster survives (no over-cut)', () => {
const clusters = FIXTURE.filter((q) => q.kind === 'cluster');
for (const q of clusters) {
// recall stays 1.0 — autocut cuts AFTER the cluster, not into it.
expect(evalQuery(q, true).r).toBe(1);
}
});
});
@@ -0,0 +1,169 @@
/**
* v0.42.3.0 — autocut end-to-end through hybridSearch (the IRON-RULE
* behavioral regression).
*
* Drives bare hybridSearch against PGLite with a stubbed rerankerFn so we
* pin the behavior the pure-fn tests can't:
* - A cliff-shaped rerank → autocut trims the result set at the cliff.
* - A flat rerank → autocut declines (full set returned).
* - Reranker disabled → autocut is a no-op (no rerank scores to cut on),
* which is the load-bearing gate (no trustworthy signal without a reranker).
* - Per-call autocut:false forces the full top-K even with a cliff (ceiling).
* - Composes with adaptive-return without violating the never-empty floor.
*
* Serial because it mutates gateway global state (configureGateway +
* __setEmbedTransportForTests). No API keys; embedding + reranker stubbed.
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { hybridSearch } from '../../src/core/search/hybrid.ts';
import {
configureGateway,
resetGateway,
__setEmbedTransportForTests,
} from '../../src/core/ai/gateway.ts';
import type { PageInput, SearchOpts } from '../../src/core/types.ts';
import type { RerankInput, RerankResult } from '../../src/core/ai/gateway.ts';
let engine: PGLiteEngine;
const DIMS = 1536;
const FAKE_EMB = Array.from({ length: DIMS }, (_, j) => (j === 0 ? 1 : 0.01));
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
// Seed 5 pages sharing a keyword so the candidate pool is 5 deep.
const pages: Array<[string, PageInput, string]> = [
['notes/a', { type: 'note', title: 'A', compiled_truth: 'alpha keyword one' }, 'alpha keyword one chunk'],
['notes/b', { type: 'note', title: 'B', compiled_truth: 'alpha keyword two' }, 'alpha keyword two chunk'],
['notes/c', { type: 'note', title: 'C', compiled_truth: 'alpha keyword three' }, 'alpha keyword three chunk'],
['notes/d', { type: 'note', title: 'D', compiled_truth: 'alpha keyword four' }, 'alpha keyword four chunk'],
['notes/e', { type: 'note', title: 'E', compiled_truth: 'alpha keyword five' }, 'alpha keyword five chunk'],
];
for (const [slug, page, chunkText] of pages) {
await engine.putPage(slug, page);
await engine.upsertChunks(slug, [
{ chunk_index: 0, chunk_text: chunkText, chunk_source: 'compiled_truth' },
]);
}
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: DIMS,
env: { OPENAI_API_KEY: 'sk-test' },
});
__setEmbedTransportForTests(async (args: any) => ({
embeddings: args.values.map(() => FAKE_EMB),
}) as any);
});
afterAll(async () => {
__setEmbedTransportForTests(null);
resetGateway();
await engine.disconnect();
});
// A reranker that assigns descending scores from a fixed array (by index).
function rerankerWithScores(scores: number[]) {
return async (input: RerankInput): Promise<RerankResult[]> =>
input.documents.map((_, i) => ({ index: i, relevanceScore: scores[i] ?? 0.01 }));
}
// balanced mode (the default) has autocut ON. We pass opts.reranker to stub
// the cross-encoder; resolvedMode.autocut stays true (no search.mode config).
function rerankerOpts(scores: number[]): SearchOpts['reranker'] {
return {
enabled: true,
topNIn: 30,
topNOut: null,
rerankerFn: rerankerWithScores(scores),
};
}
describe('autocut — fires on a real cliff', () => {
test('cliff after rank 2 → result set trimmed to 2', async () => {
const out = await hybridSearch(engine, 'alpha keyword', {
limit: 10,
reranker: rerankerOpts([0.95, 0.9, 0.2, 0.15, 0.1]),
});
expect(out.length).toBe(2);
expect(out.map((r) => r.rerank_score)).toEqual([0.95, 0.9]);
});
test('cliff after rank 1 → single obvious answer', async () => {
const out = await hybridSearch(engine, 'alpha keyword', {
limit: 10,
reranker: rerankerOpts([0.98, 0.12, 0.1, 0.08, 0.05]),
});
expect(out.length).toBe(1);
});
});
describe('autocut — declines on a flat curve', () => {
test('flat rerank scores → full set returned (no trim)', async () => {
const baseline = await hybridSearch(engine, 'alpha keyword', { limit: 10 });
const out = await hybridSearch(engine, 'alpha keyword', {
limit: 10,
reranker: rerankerOpts([0.9, 0.88, 0.86, 0.84, 0.82]),
});
expect(out.length).toBe(baseline.length);
expect(out.length).toBeGreaterThanOrEqual(3); // meaningful pool to NOT trim
});
});
describe('autocut — no-op without a reranker (the load-bearing gate)', () => {
test('reranker disabled → no trim even though autocut is on for the mode', async () => {
const baseline = await hybridSearch(engine, 'alpha keyword', { limit: 10 });
const out = await hybridSearch(engine, 'alpha keyword', {
limit: 10,
reranker: { enabled: false, topNIn: 30, topNOut: null, rerankerFn: rerankerWithScores([0.95, 0.1]) },
});
// No rerank scores were stamped → autocut sees <2 finite scores → no-op.
expect(out.length).toBe(baseline.length);
});
test('reranker fails open (throws) → no trim (fail-open + autocut no-op)', async () => {
const baseline = await hybridSearch(engine, 'alpha keyword', { limit: 10 });
const out = await hybridSearch(engine, 'alpha keyword', {
limit: 10,
reranker: {
enabled: true,
topNIn: 30,
topNOut: null,
rerankerFn: async () => {
throw new Error('upstream down');
},
},
});
expect(out.map((r) => r.slug)).toEqual(baseline.map((r) => r.slug));
});
});
describe('autocut — ceiling override', () => {
test('per-call autocut:false forces full top-K even with a cliff', async () => {
const baseline = await hybridSearch(engine, 'alpha keyword', { limit: 10 });
const out = await hybridSearch(engine, 'alpha keyword', {
limit: 10,
autocut: false,
reranker: rerankerOpts([0.95, 0.9, 0.2, 0.15, 0.1]),
});
// Cliff present, but the override keeps the full reranked set.
expect(out.length).toBe(baseline.length);
});
});
describe('autocut — composes with adaptive-return (never-empty holds)', () => {
test('adaptive-return + autocut both on → non-empty, bounded', async () => {
const out = await hybridSearch(engine, 'alpha keyword', {
limit: 10,
adaptiveReturn: true,
reranker: rerankerOpts([0.95, 0.9, 0.2, 0.15, 0.1]),
});
expect(out.length).toBeGreaterThanOrEqual(1);
expect(out.length).toBeLessThanOrEqual(2); // cliff caps at 2; adaptive may cap further
});
});
+232
View File
@@ -0,0 +1,232 @@
/**
* v0.42.3.0 — autocut pure-function tests.
*
* Pins the score-discontinuity algorithm + the resolve ladder. No engine,
* no network — applyAutocut takes a list + a scoreOf accessor.
*/
import { describe, expect, test } from 'bun:test';
import {
DEFAULT_AUTOCUT,
autocutFromConfig,
resolveAutocut,
applyAutocut,
type AutocutConfig,
} from '../../src/core/search/autocut.ts';
// A tiny result shape: just a score and an id so we can assert which
// items survived the cut.
type R = { id: string; rs?: number };
const scoreOf = (r: R) => r.rs;
const ON: AutocutConfig = { enabled: true, jumpRatio: 0.2, minKeep: 1 };
function mk(scores: Array<number | undefined>): R[] {
return scores.map((rs, i) => ({ id: `r${i}`, rs }));
}
describe('DEFAULT_AUTOCUT', () => {
test('module default is enabled with 0.20 jump + minKeep 1', () => {
expect(DEFAULT_AUTOCUT.enabled).toBe(true);
expect(DEFAULT_AUTOCUT.jumpRatio).toBe(0.2);
expect(DEFAULT_AUTOCUT.minKeep).toBe(1);
});
test('is frozen', () => {
expect(Object.isFrozen(DEFAULT_AUTOCUT)).toBe(true);
});
});
describe('applyAutocut — cuts on a real cliff', () => {
test('clear cliff after rank 2 → keeps 2', () => {
// 1.0, 0.944, 0.222, 0.111 → biggest gap 0.85→0.2 (0.722) clears 0.20.
const r = applyAutocut(mk([0.9, 0.85, 0.2, 0.1]), scoreOf, ON);
expect(r.kept.map((x) => x.id)).toEqual(['r0', 'r1']);
expect(r.decision.applied).toBe(true);
expect(r.decision.signal).toBe('rerank');
expect(r.decision.kept).toBe(2);
expect(r.decision.total).toBe(4);
expect(r.decision.gapRatio).toBeGreaterThan(0.2);
});
test('cliff after rank 1 → keeps the single obvious answer', () => {
const r = applyAutocut(mk([0.95, 0.1, 0.08, 0.05]), scoreOf, ON);
expect(r.kept.map((x) => x.id)).toEqual(['r0']);
expect(r.decision.applied).toBe(true);
});
test('preserves original order among kept items (robust to unsorted input)', () => {
// Provider returned them out of order; autocut finds the cliff on a
// sorted copy and keeps items >= threshold IN INPUT ORDER.
const items = mk([0.85, 0.95, 0.12, 0.9]); // sorted desc: .95 .9 .85 .12
const r = applyAutocut(items, scoreOf, ON);
// Cliff is between .85 and .12 → threshold .85 → keep .85,.95,.9 → r0,r1,r3.
expect(r.kept.map((x) => x.id)).toEqual(['r0', 'r1', 'r3']);
});
});
describe('applyAutocut — declines to cut', () => {
test('flat scores (no cliff) → returns all, signal none', () => {
const r = applyAutocut(mk([0.9, 0.88, 0.86, 0.84]), scoreOf, ON);
expect(r.kept.length).toBe(4);
expect(r.decision.applied).toBe(false);
expect(r.decision.signal).toBe('none');
});
test('all-equal scores → no cut', () => {
const r = applyAutocut(mk([0.7, 0.7, 0.7, 0.7]), scoreOf, ON);
expect(r.kept.length).toBe(4);
expect(r.decision.applied).toBe(false);
});
test('the measured-RRF-flat case: rank1≈rank2 gap → no cut', () => {
// Mirrors return-policy.ts's documented finding: a ~identical top gap is
// NOT a separatrix. With these (correct vs wrong) shapes autocut stays out.
const correct = applyAutocut(mk([0.602, 0.569, 0.55, 0.54]), scoreOf, ON);
const wrong = applyAutocut(mk([0.569, 0.55, 0.54, 0.53]), scoreOf, ON);
expect(correct.decision.applied).toBe(false);
expect(wrong.decision.applied).toBe(false);
});
});
describe('applyAutocut — no-op guards', () => {
test('disabled → returns input unchanged', () => {
const items = mk([0.9, 0.1]);
const r = applyAutocut(items, scoreOf, { ...ON, enabled: false });
expect(r.kept).toBe(items);
expect(r.decision.applied).toBe(false);
});
test('empty input → no-op', () => {
const r = applyAutocut([] as R[], scoreOf, ON);
expect(r.kept).toEqual([]);
expect(r.decision.applied).toBe(false);
});
test('single item → no-op (no cliff possible)', () => {
const items = mk([0.9]);
const r = applyAutocut(items, scoreOf, ON);
expect(r.kept.length).toBe(1);
expect(r.decision.applied).toBe(false);
});
test('<2 finite scores → no-op (fail-open reranker: no scores stamped)', () => {
// All un-scored (reranker failed open → RRF order, no rerank_score).
const items = mk([undefined, undefined, undefined]);
const r = applyAutocut(items, scoreOf, ON);
expect(r.kept.length).toBe(3);
expect(r.decision.signal).toBe('none');
});
test('exactly 1 finite score among many → no-op', () => {
const items = mk([0.9, undefined, undefined]);
const r = applyAutocut(items, scoreOf, ON);
expect(r.kept.length).toBe(3);
expect(r.decision.applied).toBe(false);
});
test('top score <= 0 → no-op (score scale unusable)', () => {
const r = applyAutocut(mk([0, -0.1, -0.5]), scoreOf, ON);
expect(r.decision.applied).toBe(false);
});
test('non-finite scores are ignored', () => {
const items = mk([0.9, Number.NaN, 0.1]);
const r = applyAutocut(items, scoreOf, ON);
// Only 0.9 and 0.1 are finite → 2 scored, cliff → keep r0; NaN item dropped.
expect(r.kept.map((x) => x.id)).toEqual(['r0']);
});
});
describe('applyAutocut — failsafe', () => {
test('never returns empty when input is non-empty', () => {
const r = applyAutocut(mk([0.9, 0.01]), scoreOf, ON);
expect(r.kept.length).toBeGreaterThanOrEqual(1);
});
test('minKeep floor holds even with a cliff after rank 1', () => {
// Cliff says keep 1, but minKeep=2 expands to 2.
const r = applyAutocut(mk([0.95, 0.1, 0.08]), scoreOf, { ...ON, minKeep: 2 });
expect(r.kept.length).toBeGreaterThanOrEqual(2);
});
test('higher jumpRatio means only dramatic cliffs cut', () => {
const scores = mk([0.9, 0.6, 0.5, 0.4]); // top gap normalized ~0.33
const lenient = applyAutocut(scores, scoreOf, { ...ON, jumpRatio: 0.2 });
const strict = applyAutocut(scores, scoreOf, { ...ON, jumpRatio: 0.5 });
expect(lenient.decision.applied).toBe(true);
expect(strict.decision.applied).toBe(false);
});
});
describe('applyAutocut — preserve predicate (Codex P1: alias-injected matches)', () => {
// An alias-hop exact match is injected AFTER reranking, so it has no score.
type AR = { id: string; rs?: number; alias?: boolean };
const arScore = (r: AR) => r.rs;
const isAlias = (r: AR) => r.alias === true;
test('unscored alias item survives a cut that drops scored noise', () => {
const items: AR[] = [
{ id: 'alias', alias: true }, // injected, no rerank_score
{ id: 'top', rs: 0.95 },
{ id: 'noise1', rs: 0.2 },
{ id: 'noise2', rs: 0.1 },
];
const r = applyAutocut(items, arScore, ON, isAlias);
// Cliff after 'top' drops noise1/noise2; 'alias' is preserved despite no score.
expect(r.kept.map((x) => x.id).sort()).toEqual(['alias', 'top']);
expect(r.decision.applied).toBe(true);
});
test('without the predicate, the unscored alias item is dropped on a cut', () => {
const items: AR[] = [
{ id: 'alias', alias: true },
{ id: 'top', rs: 0.95 },
{ id: 'noise', rs: 0.1 },
];
const r = applyAutocut(items, arScore, ON); // no preserve
expect(r.kept.map((x) => x.id)).toEqual(['top']);
});
test('preserve does not force a cut on a flat curve (no-op still returns all)', () => {
const items: AR[] = [
{ id: 'alias', alias: true },
{ id: 'a', rs: 0.6 },
{ id: 'b', rs: 0.58 },
];
const r = applyAutocut(items, arScore, ON, isAlias);
expect(r.decision.applied).toBe(false);
expect(r.kept.length).toBe(3);
});
});
describe('autocutFromConfig', () => {
test('reads search.autocut + search.autocut_jump', () => {
const out = autocutFromConfig({ search: { autocut: false, autocut_jump: 0.4 } });
expect(out.enabled).toBe(false);
expect(out.jumpRatio).toBe(0.4);
});
test('clamps out-of-range jump to fallback (ignored)', () => {
expect(autocutFromConfig({ search: { autocut_jump: 5 } }).jumpRatio).toBeUndefined();
expect(autocutFromConfig({ search: { autocut_jump: 0 } }).jumpRatio).toBeUndefined();
});
test('empty / missing config → empty partial', () => {
expect(autocutFromConfig(null)).toEqual({});
expect(autocutFromConfig({})).toEqual({});
});
});
describe('resolveAutocut — precedence ladder', () => {
test('defaults → config → per-call', () => {
const cfg = resolveAutocut(undefined, { jumpRatio: 0.3 });
expect(cfg.jumpRatio).toBe(0.3);
expect(cfg.enabled).toBe(true); // default
});
test('per-call true/false overrides config enabled', () => {
expect(resolveAutocut(false, { enabled: true }).enabled).toBe(false);
expect(resolveAutocut(true, { enabled: false }).enabled).toBe(true);
});
test('per-call partial overrides specific fields', () => {
const cfg = resolveAutocut({ jumpRatio: 0.5 }, { jumpRatio: 0.3, enabled: false });
expect(cfg.jumpRatio).toBe(0.5);
expect(cfg.enabled).toBe(false); // inherited from config (partial didn't set it)
});
});
+63
View File
@@ -10,7 +10,9 @@ import type { SearchResult } from '../../src/core/types.ts';
import {
formatResultExplain,
formatResultsExplain,
formatAutocutSummary,
} from '../../src/core/search/explain-formatter.ts';
import type { AutocutDecision } from '../../src/core/search/autocut.ts';
function r(slug: string, score: number, extras: Partial<SearchResult> = {}): SearchResult {
return {
@@ -197,3 +199,64 @@ describe('formatResultExplain — number formatting', () => {
expect(out).toContain('score=NaN');
});
});
describe('v0.42.3.0 — autocut in --explain', () => {
test('rerank_score renders per result (the cliff signal)', () => {
const out = formatResultExplain(r('a/b', 1.2, { rerank_score: 0.87 }), 1);
expect(out).toContain('rerank score=0.87');
});
test('no rerank score line when absent', () => {
const out = formatResultExplain(r('a/b', 1.2), 1);
expect(out).not.toContain('rerank score');
});
const applied: AutocutDecision = {
applied: true,
signal: 'rerank',
cut: 2,
kept: 2,
total: 7,
gapRatio: 0.41,
};
const declined: AutocutDecision = {
applied: false,
signal: 'none',
cut: 7,
kept: 7,
total: 7,
gapRatio: 0.05,
};
test('formatAutocutSummary — applied cut', () => {
const s = formatAutocutSummary(applied);
expect(s).toContain('kept 2/7');
expect(s).toContain('0.41');
});
test('formatAutocutSummary — declined', () => {
const s = formatAutocutSummary(declined);
expect(s).toContain('no cut');
expect(s).toContain('full 7');
});
test('formatAutocutSummary — undefined → null (omit cleanly)', () => {
expect(formatAutocutSummary(undefined)).toBeNull();
});
test('formatResultsExplain prepends the autocut summary when meta has a decision', () => {
const out = formatResultsExplain([r('a/b', 1.2, { rerank_score: 0.9 })], {
vector_enabled: true,
detail_resolved: null,
expansion_applied: false,
autocut: applied,
});
expect(out.startsWith('autocut:')).toBe(true);
expect(out).toContain('kept 2/7');
});
test('formatResultsExplain omits the summary when meta has no autocut decision', () => {
const out = formatResultsExplain([r('a/b', 1.2)]);
expect(out.startsWith('autocut:')).toBe(false);
});
});
@@ -167,8 +167,14 @@ describe('hybridSearch — reranker enabled (reorder)', () => {
// Now rerank only the top 2 (swap them); the tail (indices 2..N-1)
// must keep its baseline order.
// v0.42.3.0: autocut is default-ON in balanced mode and would cut this
// artificial 2-item scored head (0.99 vs 0.5 is a cliff) down to 1,
// dropping the un-scored tail. This test isolates RERANKER tail mechanics,
// so disable autocut here — in real balanced mode top_n_in = searchLimit
// (D4), so topNIn < pool with an un-scored tail never happens by default.
const reranked = await hybridSearch(engine, 'alpha keyword', {
limit: 10,
autocut: false,
reranker: {
enabled: true,
topNIn: 2,
+1 -1
View File
@@ -55,7 +55,7 @@ describe('KNOBS_HASH_VERSION + version invariants', () => {
// v0.41.22.0 (type-unification): 5→6 to fold the alias_resolved
// post-fusion boost. Cache rows written before the boost stage
// cannot leak past the new stage.
expect(KNOBS_HASH_VERSION).toBe(7);
expect(KNOBS_HASH_VERSION).toBe(8);
});
test('hash is 16 hex chars regardless of reranker config', () => {
+44
View File
@@ -0,0 +1,44 @@
/**
* v0.42.3.0 — pins the agent-facing autocut surface on the `query` op.
*
* Autocut is the smart default; the param exists ONLY as a ceiling override
* (force the full top-K). The description must teach the agent that they
* almost never set it, and that `false` is the breadth escape hatch. Guards
* against a refactor silently dropping the param or the instruction.
*/
import { describe, expect, test } from 'bun:test';
import { operationsByName } from '../../src/core/operations.ts';
describe('query op — autocut agent surface', () => {
const query = operationsByName['query'];
test('query op exists', () => {
expect(query).toBeDefined();
});
test('autocut is a boolean param on query', () => {
const param = query.params?.autocut as { type?: string; description?: string } | undefined;
expect(param).toBeDefined();
expect(param?.type).toBe('boolean');
});
test('description frames autocut as the default and FALSE as the breadth override', () => {
const desc = ((query.params?.autocut as { description?: string })?.description ?? '').toLowerCase();
// It's a default, not a feature the agent turns on.
expect(desc).toContain('default');
// The actionable direction is FALSE for breadth.
expect(desc).toContain('false');
expect(desc).toContain('breadth');
// Safety contract so the agent trusts it.
expect(desc).toContain('never returns empty');
// Distinguish from adaptive_return so the agent picks the right knob.
expect(desc).toContain('adaptive_return');
});
test('search op (keyword-only) does NOT carry autocut (no reranker there)', () => {
const search = operationsByName['search'];
expect(search).toBeDefined();
expect(search.params?.autocut).toBeUndefined();
});
});
+25 -1
View File
@@ -130,11 +130,35 @@ describe('summarizeCrashes — aggregation', () => {
const summary = summarizeCrashes([]);
expect(summary).toEqual({
total: 0,
by_cause: { runtime_error: 0, oom_or_external_kill: 0, unknown: 0, legacy: 0 },
by_cause: { runtime_error: 0, oom_or_external_kill: 0, rss_watchdog: 0, unknown: 0, legacy: 0 },
clean_exits: 0,
});
});
// issue #1678: rss_watchdog is a crash-classified cause (NOT in
// CLEAN_EXIT_CAUSES) with its OWN bucket — operators watching
// by_cause.rss_watchdog rise know the cap is too low for the workload, a
// distinct signal from a generic runtime_error or OOM-killer SIGKILL.
test('rss_watchdog routes to its own bucket, not legacy', () => {
const summary = summarizeCrashes([
evt('worker_exited', { likely_cause: 'rss_watchdog' }),
evt('worker_exited', { likely_cause: 'rss_watchdog' }),
evt('worker_exited', { likely_cause: 'runtime_error' }),
]);
expect(summary.total).toBe(3);
expect(summary.by_cause.rss_watchdog).toBe(2);
expect(summary.by_cause.runtime_error).toBe(1);
expect(summary.by_cause.legacy).toBe(0);
expect(summary.clean_exits).toBe(0);
});
// isCrashExit treats rss_watchdog as a crash (it's a real problem), NOT a
// clean exit — pins that the worker draining itself on a too-low cap shows
// up in operator health surfaces instead of looking like a clean drain.
test('isCrashExit classifies rss_watchdog as a crash', () => {
expect(isCrashExit(evt('worker_exited', { likely_cause: 'rss_watchdog' }))).toBe(true);
});
test('only non-exit events returns zero summary', () => {
const summary = summarizeCrashes([evt('started'), evt('worker_spawned'), evt('stopped')]);
expect(summary.total).toBe(0);
+37 -7
View File
@@ -414,21 +414,20 @@ describe('MinionSupervisor', () => {
}, 15_000);
});
describe('integration: --max-rss spawn args (v0.21)', () => {
it('passes --max-rss 2048 to spawned worker by default', async () => {
describe('integration: --max-rss spawn args (v0.21, auto-sized v0.41.39.0)', () => {
it('passes an explicit --max-rss through to the spawned worker', async () => {
const outFile = join(tmpdir(), `gbrain-sup-maxrss-${process.pid}-${Date.now()}.txt`);
try { unlinkSync(outFile); } catch { /* may not exist */ }
// Worker logs its argv to OUT_FILE so the test can assert --max-rss 2048
// landed there. spawnOnce in supervisor.ts builds:
// ['jobs', 'work', '--concurrency', '1', '--queue', 'default', '--max-rss', '2048']
// exit 1 required post-D1/D2: code=0 workers respawn forever.
const h = makeHarness('maxrss-default', `printf '%s\\n' "$*" > "$OUT_FILE" ; exit 1`);
// SUP_MAX_RSS pins an explicit cap; the supervisor must pass it through
// verbatim. exit 1 required post-D1/D2: code=0 workers respawn forever.
const h = makeHarness('maxrss-explicit', `printf '%s\\n' "$*" > "$OUT_FILE" ; exit 1`);
try {
const sup = spawnSupervisor(h, {
OUT_FILE: outFile,
SUP_MAX_CRASHES: '1',
SUP_MAX_RSS: '2048',
});
await sup.exited;
@@ -441,6 +440,37 @@ describe('MinionSupervisor', () => {
h.cleanup();
}
}, 15_000);
// issue #1678: with no explicit cap the supervisor auto-sizes cgroup-aware
// instead of the old flat 2048 footgun. Same machine → the in-test
// resolveDefaultMaxRssMb() equals what the spawned supervisor computes.
it('auto-sizes --max-rss when no explicit cap is given', async () => {
const outFile = join(tmpdir(), `gbrain-sup-maxrss-auto-${process.pid}-${Date.now()}.txt`);
try { unlinkSync(outFile); } catch { /* may not exist */ }
const { resolveDefaultMaxRssMb } = await import('../src/core/minions/rss-default.ts');
const expected = resolveDefaultMaxRssMb();
const h = makeHarness('maxrss-auto', `printf '%s\\n' "$*" > "$OUT_FILE" ; exit 1`);
try {
const sup = spawnSupervisor(h, {
OUT_FILE: outFile,
SUP_MAX_CRASHES: '1',
});
await sup.exited;
expect(existsSync(outFile)).toBe(true);
const argv = readFileSync(outFile, 'utf8').trim();
expect(argv).toContain(`--max-rss ${expected}`);
// Auto-sized value is clamped into the sane range, never the old 2048
// unless the box genuinely resolves there.
expect(expected).toBeGreaterThanOrEqual(4096);
expect(expected).toBeLessThanOrEqual(16384);
} finally {
try { unlinkSync(outFile); } catch { /* noop */ }
h.cleanup();
}
}, 15_000);
});
describe('integration: audit file rotation + helper', () => {
+95
View File
@@ -17,6 +17,7 @@
import { describe, test, expect } from 'bun:test';
import { __thinkAdapter } from '../src/core/think/index.ts';
import { resetGateway } from '../src/core/ai/gateway.ts';
import { withEnv } from './helpers/with-env.ts';
describe('think gateway adapter — response shape conversion', () => {
@@ -91,6 +92,100 @@ describe('think gateway adapter — model-id normalization', () => {
});
});
describe('think gateway adapter — #1698 slash form + explicit-model fork', () => {
test('tryBuildGatewayClient accepts SLASH form (anthropic/claude-...) — the reported bug', async () => {
// Pre-fix the colon-only inline produced `anthropic:anthropic/claude-sonnet-4-6`
// and the client silently degraded. normalizeModelId fixes it → builds cleanly.
await withEnv({ ANTHROPIC_API_KEY: 'sk-test-fake' }, async () => {
const client = await __thinkAdapter.tryBuildGatewayClient('anthropic/claude-sonnet-4-6');
expect(client).not.toBeNull();
});
});
test('explicit unresolvable model THROWS (does not degrade to null)', async () => {
await expect(
__thinkAdapter.tryBuildGatewayClient('bogusprovider:foo-1', { explicitModel: true }),
).rejects.toThrow(/not usable.*unknown_provider/);
});
test('explicit typo native model THROWS (unknown_model)', async () => {
await expect(
__thinkAdapter.tryBuildGatewayClient('anthropic:claude-bogus-9', { explicitModel: true }),
).rejects.toThrow(/not usable.*unknown_model/);
});
test('explicit anthropic model with no key THROWS (unavailable)', async () => {
await withEnv({ ANTHROPIC_API_KEY: undefined }, async () => {
await expect(
__thinkAdapter.tryBuildGatewayClient('anthropic:claude-sonnet-4-6', { explicitModel: true }),
).rejects.toThrow(/not usable.*unavailable/);
});
});
test('NON-explicit unresolvable model returns null (graceful, unchanged)', async () => {
const client = await __thinkAdapter.tryBuildGatewayClient('bogusprovider:foo-1', { explicitModel: false });
expect(client).toBeNull();
});
test('create-callback fork: explicit rethrows AIConfigError; non-explicit returns the sentinel', async () => {
await withEnv({ ANTHROPIC_API_KEY: 'sk-test-fake' }, async () => {
// Build valid clients (key present → probe ok) but leave the gateway UNCONFIGURED
// so gateway.chat() throws AIConfigError (requireConfig) at create() time.
resetGateway();
const params: any = {
model: 'anthropic:claude-sonnet-4-6',
max_tokens: 16,
system: 'sys',
messages: [{ role: 'user', content: 'hi' }],
};
const explicitClient = await __thinkAdapter.tryBuildGatewayClient(
'anthropic:claude-sonnet-4-6', { explicitModel: true },
);
expect(explicitClient).not.toBeNull();
await expect(explicitClient!.create(params)).rejects.toThrow();
const gracefulClient = await __thinkAdapter.tryBuildGatewayClient(
'anthropic:claude-sonnet-4-6', { explicitModel: false },
);
expect(gracefulClient).not.toBeNull();
const msg = await gracefulClient!.create(params);
const text = msg.content.find((b: any) => b.type === 'text');
expect(text && 'text' in text ? text.text : '').toContain('no LLM available');
});
});
// D1 BACKSTOP (codex #1, accepted-as-is): probeChatModel only PRE-checks the Anthropic
// key, so an explicit NON-anthropic model (deepseek/openai/...) passes the early gate and
// BUILDS a client even with no provider key — its key is checked lazily at chat time. The
// create-callback rethrow is then the ONLY thing standing between "explicit unusable model"
// and a silent degrade. This test locks that backstop into a contract: the client builds
// (proving the deviation), and create() HARD-ERRORS (proving it never degrades to the
// 'no LLM available' stub). A future refactor that turns this into a graceful path fails here.
test('D1 backstop: explicit non-anthropic model, no key → BUILDS then create() THROWS (never a stub)', async () => {
await withEnv(
{ ANTHROPIC_API_KEY: undefined, DEEPSEEK_API_KEY: undefined, OPENAI_API_KEY: undefined },
async () => {
resetGateway(); // unconfigured → gateway.chat() throws AIConfigError at create()
// deepseek:deepseek-chat passes validateModelId (real recipe + chat touchpoint) — the
// A9 non-anthropic model. probeChatModel returns ok (no anthropic key check) → builds.
const client = await __thinkAdapter.tryBuildGatewayClient(
'deepseek:deepseek-chat', { explicitModel: true },
);
expect(client).not.toBeNull(); // proves the early gate did NOT pre-reject non-anthropic
const params: any = {
model: 'deepseek:deepseek-chat',
max_tokens: 16,
system: 'sys',
messages: [{ role: 'user', content: 'hi' }],
};
// The backstop fires: explicit → AIConfigError rethrown, NOT the graceful sentinel.
await expect(client!.create(params)).rejects.toThrow();
},
);
});
});
describe('think gateway adapter — graceful fallback shape', () => {
test('buildGracefulMessage produces a parseable Anthropic.Message-shaped object', () => {
const m = __thinkAdapter.buildGracefulMessage('anthropic:claude-opus-4-7');
+128
View File
@@ -1,5 +1,6 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { operationsByName } from '../src/core/operations.ts';
import { runThink, persistSynthesis, type ThinkLLMClient } from '../src/core/think/index.ts';
import { sanitizeTakeForPrompt, renderTakesBlock } from '../src/core/think/sanitize.ts';
import { resolveCitations, parseInlineCitations, normalizeStructuredCitations } from '../src/core/think/cite-render.ts';
@@ -257,3 +258,130 @@ describe('runThink (with stub client)', () => {
expect(Number(ev[0]?.count)).toBe(1);
});
});
// #1698 — fail loud, never persist empty.
function stubClientFromText(text: string): ThinkLLMClient {
return {
create: async () => ({
id: 'msg_1698', type: 'message', role: 'assistant', model: 'stub',
stop_reason: 'end_turn', stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, server_tool_use: null, service_tier: null },
content: [{ type: 'text', text }],
}),
};
}
describe('runThink — #1698 explicit-model hard error', () => {
test('explicit unresolvable --model THROWS before gather (unknown_provider)', async () => {
await expect(
runThink(engine, { question: 'x', model: 'bogusprovider:foo', modelExplicit: true }),
).rejects.toThrow(/not usable.*unknown_provider/);
});
test('explicit typo native --model THROWS (unknown_model)', async () => {
await expect(
runThink(engine, { question: 'x', model: 'anthropic:claude-bogus-9', modelExplicit: true }),
).rejects.toThrow(/not usable.*unknown_model/);
});
test('NON-explicit bad model does NOT throw — graceful degrade (no modelExplicit)', async () => {
const origKey = process.env.ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_API_KEY;
try {
// model present but modelExplicit unset → early gate skipped; builder returns null.
const result = await runThink(engine, { question: 'nonexplicit bad', model: 'bogusprovider:foo' });
expect(result.warnings).toContain('NO_ANTHROPIC_API_KEY');
expect(result.synthesisOk).toBe(false);
} finally {
if (origKey) process.env.ANTHROPIC_API_KEY = origKey;
}
});
});
describe('runThink + persistSynthesis — #1698 never persist empty', () => {
test('empty-but-valid-JSON answer → synthesisOk false → persist-skip signal', async () => {
const result = await runThink(engine, {
question: 'empty answer test',
client: stubClientFromText(JSON.stringify({ answer: '', citations: [], gaps: [] })),
});
expect(result.synthesisOk).toBe(false);
const saved = await persistSynthesis(engine, result);
expect(saved.slug).toBe('');
expect(saved.warnings).toContain('SYNTHESIS_EMPTY_NOT_PERSISTED');
});
test('malformed (not-JSON) output → synthesisOk false → persist-skip', async () => {
const result = await runThink(engine, {
question: 'malformed persist test',
client: stubClientFromText('not json at all, just prose'),
});
expect(result.warnings).toContain('LLM_OUTPUT_NOT_JSON');
expect(result.synthesisOk).toBe(false);
const saved = await persistSynthesis(engine, result);
expect(saved.slug).toBe('');
expect(saved.warnings).toContain('SYNTHESIS_EMPTY_NOT_PERSISTED');
});
test('valid non-empty synthesis → synthesisOk true → persists', async () => {
const result = await runThink(engine, {
question: 'nonempty persist test',
client: stubClientFromText(JSON.stringify({ answer: 'A real answer.', citations: [], gaps: [] })),
});
expect(result.synthesisOk).toBe(true);
const saved = await persistSynthesis(engine, result);
expect(saved.slug).toContain('synthesis/nonempty-persist-test');
});
test('stubResponse with empty answer → synthesisOk false; non-empty → true', async () => {
const empty = await runThink(engine, {
question: 'stub empty', stubResponse: { answer: '', citations: [], gaps: [] },
});
expect(empty.synthesisOk).toBe(false);
const full = await runThink(engine, {
question: 'stub full', stubResponse: { answer: 'has content', citations: [], gaps: [] },
});
expect(full.synthesisOk).toBe(true);
});
test('pre-existing ThinkResult literal without synthesisOk still persists (back-compat)', async () => {
const legacy: any = {
question: 'legacy backcompat', answer: 'legacy body', citations: [], gaps: [],
pagesGathered: 0, takesGathered: 0, graphHits: 0, modelUsed: 'stub', rounds: 1, warnings: [],
diagnostics: { pagesFromHybrid: 0, takesFromKeyword: 0, takesFromVector: 0, graphHits: 0 },
// NOTE: no synthesisOk field
};
const saved = await persistSynthesis(engine, legacy);
expect(saved.slug).toContain('synthesis/legacy-backcompat');
});
});
describe('think MCP op — #1698 C3 + #10', () => {
const baseCtx = (remote: boolean) => ({
engine, config: {} as any, dryRun: false, remote,
logger: { info() {}, warn() {}, error() {}, debug() {} } as any,
});
test('C3: remote caller with explicit bad model → op throws (modelExplicit wired)', async () => {
const op = operationsByName['think'];
expect(op).toBeDefined();
await expect(
op.handler(baseCtx(true) as any, { question: 'q', model: 'bogusprovider:foo' }),
).rejects.toThrow(/not usable.*unknown_provider/);
});
test('#10: local save with no synthesis → saved_slug is null, not "" + warning surfaced', async () => {
const op = operationsByName['think'];
const origKey = process.env.ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_API_KEY;
try {
// Local (remote:false), save:true, no key → graceful stub → persist-skip.
const res: any = await op.handler(baseCtx(false) as any, { question: 'op empty save test', save: true });
expect(res.saved_slug).toBeNull();
expect(res.warnings).toContain('SYNTHESIS_EMPTY_NOT_PERSISTED');
} finally {
if (origKey) process.env.ANTHROPIC_API_KEY = origKey;
}
});
});
+66
View File
@@ -483,3 +483,69 @@ describe('resolveLockRenewalKnobs', () => {
expect(resolveLockRenewalKnobs({}, 30_000).maxFailuresForAudit).toBe(3);
});
});
// issue #1678 (Codex #2): the bounded reconnect-once hook. NOT a withRetry on
// renewLock (that races this tick's own timeout); a single pool rebuild before
// the next tick so the next renewLock hits a live connection.
describe('runLockRenewalTick: reconnect-once dep (issue #1678)', () => {
test('reconnect fires after a renewLock throw within deadline; result still ok', async () => {
const audit = freshAudit();
let reconnectCalls = 0;
const deps: LockRenewalDeps = {
renewLock: async () => { throw new Error('write CONNECTION_ENDED'); },
audit: audit.sink,
now: () => 1000, // well within deadline
setTimeout: makeFakeTimer().setTimeout,
reconnect: async () => { reconnectCalls++; },
};
const state = makeState({ lastSuccessfulRenewalAt: 0 });
const result = await runLockRenewalTick(deps, state);
expect(result).toEqual({ kind: 'ok' });
expect(reconnectCalls).toBe(1);
expect(state.consecutiveFailures).toBe(1);
});
test('a reconnect throw is swallowed — tick still returns ok (no unhandledRejection class)', async () => {
const audit = freshAudit();
const deps: LockRenewalDeps = {
renewLock: async () => { throw new Error('Connection terminated unexpectedly'); },
audit: audit.sink,
now: () => 1000,
setTimeout: makeFakeTimer().setTimeout,
reconnect: async () => { throw new Error('reconnect failed: EHOSTUNREACH'); },
};
const state = makeState({ lastSuccessfulRenewalAt: 0 });
const result = await runLockRenewalTick(deps, state);
expect(result).toEqual({ kind: 'ok' });
});
test('reconnect is NOT called on a successful renewal', async () => {
let reconnectCalls = 0;
const deps: LockRenewalDeps = {
renewLock: async () => true,
audit: freshAudit().sink,
now: () => 1000,
setTimeout: makeFakeTimer().setTimeout,
reconnect: async () => { reconnectCalls++; },
};
const result = await runLockRenewalTick(deps, makeState({ lastSuccessfulRenewalAt: 500 }));
expect(result).toEqual({ kind: 'ok' });
expect(reconnectCalls).toBe(0);
});
test('reconnect is NOT called when the tick aborts at the deadline', async () => {
let reconnectCalls = 0;
const deps: LockRenewalDeps = {
renewLock: async () => { throw new Error('write CONNECTION_ENDED'); },
audit: freshAudit().sink,
// sinceLastSuccess = 30000 - 0 = 30000 >= deadline (30000-5000=25000) → abort
now: () => 30_000,
setTimeout: makeFakeTimer().setTimeout,
reconnect: async () => { reconnectCalls++; },
};
const state = makeState({ lastSuccessfulRenewalAt: 0 });
const result = await runLockRenewalTick(deps, state);
expect(result).toEqual({ kind: 'should_abort', reason: 'lock-renewal-failed' });
expect(reconnectCalls).toBe(0); // pointless to reconnect when we're giving up the lock
});
});
+80
View File
@@ -0,0 +1,80 @@
/**
* issue #1678 — worker-side RSS watchdog behavior.
*
* Pins that:
* 1. Crossing the cap sets `rssWatchdogTriggered` (the flag the CLI reads to
* exit with WORKER_EXIT_RSS_WATCHDOG) and drains the worker.
* 2. The 80%-of-cap soft warn fires BEFORE the kill, once per crossing,
* carrying the peak + in-flight job kinds — and does NOT drain.
*
* Uses a real in-memory PGLite engine (canonical block per CLAUDE.md R3+R4)
* + a stubbed `getRss` so the test is deterministic and hermetic.
*/
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { MinionWorker } from '../src/core/minions/worker.ts';
const MB = 1024 * 1024;
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
describe('worker RSS watchdog (issue #1678)', () => {
it('crossing the cap sets rssWatchdogTriggered and drains', async () => {
const worker = new MinionWorker(engine, {
queue: 'default',
concurrency: 1,
maxRssMb: 100,
getRss: () => 500 * MB, // 5x the cap
rssCheckInterval: 25,
healthCheckInterval: 0, // no self-health timer in this test
pollInterval: 25,
});
worker.register('noop', async () => {});
// start() resolves on its own: the periodic check trips the watchdog,
// gracefulShutdown sets running=false, the loop exits.
await worker.start();
expect(worker.rssWatchdogTriggered).toBe(true);
});
it('80% soft-warn fires before the kill and does not drain', async () => {
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...a: unknown[]) => { warns.push(a.join(' ')); };
const worker = new MinionWorker(engine, {
queue: 'default',
concurrency: 1,
maxRssMb: 100,
getRss: () => 85 * MB, // 85% — above soft line, below cap
rssCheckInterval: 25,
healthCheckInterval: 0,
pollInterval: 25,
});
worker.register('noop', async () => {});
const runPromise = worker.start();
// Let a couple of periodic checks fire.
await new Promise((r) => setTimeout(r, 120));
worker.stop();
await runPromise;
console.warn = origWarn;
const softWarn = warns.find((w) => w.includes('approaching cap'));
expect(softWarn).toBeDefined();
expect(softWarn).toContain('85%');
// Soft warn must NOT have drained the worker.
expect(worker.rssWatchdogTriggered).toBe(false);
});
});