mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +00:00
Merge remote-tracking branch 'origin/master' into garrytan/enrich-thin-batch
# Conflicts: # CHANGELOG.md # VERSION # package.json
This commit is contained in:
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']));
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Vendored
+6
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user