Files
gbrain/test/extract-conversation-facts-workers.test.ts
T
8ab733471b v0.41.17.0 feat: --workers N on every bulk command + facts dim doctor parity (#1519)
* feat(worker-pool): shared sliding pool + bounded semaphore + PGLite-clamp wrapper

T1 + T2 of the v0.41.16.0 workers cathedral. New src/core/worker-pool.ts is
the canonical primitive every --workers N bulk command in this wave (and
future bulk commands) builds on. Atomic-claim invariant enforced by
scripts/check-worker-pool-atomicity.sh (wired into bun run verify).
BudgetExhausted bypass + AbortSignal composition baked into the helper so
budget caps are a structural ceiling under concurrency, not a per-caller
convention.

The new resolveWorkersWithClamp wrapper composes existing autoConcurrency
with PGLite-clamp + per-(command, requested) stderr dedup. Deliberately
NOT a modification to shared autoConcurrency (silent today, used by sync
+ import); embed.ts keeps GBRAIN_EMBED_CONCURRENCY || 20 default per
codex #13.

23 + 12 + 9 = 44 hermetic tests pin every contract.

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

* test: structural + dim-check regression suites for v0.41.16.0 wave

- test/embed-helper-migration.test.ts (T3): asserts embed.ts's two
  sliding-pool sites are migrated to runSlidingPool, pre-migration
  shapes (let nextIdx = 0, Promise.all(Array.from(...))) are gone,
  GBRAIN_EMBED_CONCURRENCY || 20 default preserved, failureLabel
  threads page.slug. Per codex #16/#17 these are invariant assertions,
  not byte-equality on progress event ORDERING.
- test/embedding-dim-check-facts.test.ts (T6): readFactsEmbeddingDim
  covers vector(N) + halfvec(N), halfvec-before-vector regex ordering
  pinned (codex #19), buildFactsAlterRecipe emits DROP INDEX + ALTER
  USING + CREATE INDEX (codex #18, not bare REINDEX),
  FactsEmbeddingDimMismatchError tagged class shape,
  assertFactsEmbeddingDimMatchesConfig PGLite skip + Postgres absent-
  column skip, doctor check + insert-cast wiring assertions.
- test/extract-conversation-facts-workers.test.ts (T5): helper
  exports (extractConversationFactsLockId, PER_PAGE_LOCK_TTL_MINUTES),
  structural wiring (runSlidingPool, resolveWorkersWithClamp,
  withRefreshingLock, LockUnavailableError, delete-orphans-first
  before segment loop, preflight before pool, exit 3 when lock_skipped
  > 0), Minion handler round-trip.
- test/extract-workers.test.ts (T7): --workers wiring on all 3 inner
  fs-walk loops (extractForSlugs, extractLinksFromDir,
  extractTimelineFromDir) + CLI parse + opts threading through
  runExtractCore.

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

* chore: rebump v0.41.16.0 → v0.41.17.0 (queue collision with PR #1510)

PR #1510 (garrytan/dynamic-regex-conversation-formats) claimed v0.41.16.0
on master in parallel. Advancing this wave to v0.41.17.0 so both can land
cleanly. Pure mechanical version bump:

- VERSION + package.json → 0.41.17.0
- CHANGELOG.md header + "To take advantage of v0.41.17.0" block
- TODOS.md section header + v0.41.18+ forward references
- CLAUDE.md inline version tags
- Regenerated llms-full.txt / llms.txt

No code changes. The actual workers cathedral feature set is unchanged
from the two prior commits in this branch.

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

* fix(test): search-image-column probes column dim at runtime

CI shard 5 failed on `searchVector column routing (v0.27.1)` with:
  error: expected 1280 dimensions, not 1536

The test had a hardcoded `fakeText1536` helper that seeded chunks at
1536-d vectors. Master's default embedding model switched from OpenAI
text-embedding-3-large (1536) to ZeroEntropy zembed-1 (1280) so a fresh
PGLite brain on CI now sizes content_chunks.embedding at 1280; the
test's 1536-d INSERT trips pgvector's CheckExpectedDim.

Fix: probe `content_chunks.embedding` width via
`readContentChunksEmbeddingDim(engine)` in `beforeAll`, store in
`TEXT_DIM`, and build `fakeTextDefault(seed)` at that width. The test
now passes regardless of which default ships (the model has flipped
twice and may flip again). Local dev (1536 from older config) and CI
fresh-install (1280 from new default) both pass.

Image-side vectors stay at 1024 (matches Voyage multimodal-3 + the
column's fixed width on the image side).

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

* fix(test): bump PGLite hook timeout for shard-4 deep-process files

facts-anti-loop.test.ts and ingest-capture.test.ts were timing out in CI
shard 4 with "beforeEach/afterEach hook timed out" after the v0.41.16.0
master merge brought migration count to 99. When these files run deep in
a shard process that has already created ~20 PGLite engines, the WASM
cold-start + 95-migration replay legitimately exceeds bun's 5s default
hook timeout (observed 5.6s and 7.3s locally when reproducing).

Bun's --timeout=60000 from scripts/test-shard.sh covers TEST timeouts
but NOT hook timeouts; those default to 5s and must be set per-hook via
the optional 2nd arg to beforeAll/afterAll.

Reproduced locally by running the first 21 shard-4 files via
  head -21 /tmp/shard4-list.txt | xargs bun test
  → 179 pass, 2 fail (both with hook-timeout error)

After fix:
  → 198 pass, 0 fail (the 4 anti-loop + 15 ingest-capture tests recover)

Full shard 4 with fix:  955 pass, 0 fail.
Full shard 5 with fix:  1261 pass, 0 fail.

Also added a defensive diagnostic to the two put_page tests: if
facts_backstop is missing in the response payload, throw with the full
payload + isError so future failures surface the actual handler error
instead of a bare "expected {...} got undefined" assertion. No-op when
the test passes.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:29:03 -07:00

173 lines
7.5 KiB
TypeScript

/**
* Hermetic unit + structural tests for the extract-conversation-facts
* `--workers N` wiring (v0.41.15.0, T5).
*
* What this file pins:
* - parseArgs accepts `--workers N` and routes through parseWorkers
* (rejects 0, negatives, non-integers).
* - parseArgs accepts the alias `--concurrency N`.
* - buildJobParams threads `workers` into the Minion job envelope
* (round-trip via `gbrain extract-conversation-facts --background
* --workers 20`).
* - The exported helpers (`extractConversationFactsLockId`,
* `cpMapToEntries`-shape via the public API) match the load-bearing
* contracts D2 / D11 / D6 rely on.
* - Source-grep structural assertions on the production file: workers
* is threaded through, runSlidingPool is wired in, withRefreshingLock
* wraps each page, delete-orphans-first is called, preflight fires
* at startup, lock-busy is caught + counted + skipped, exit 3 path
* exists.
*
* End-to-end behavioral test (workers=3 on a seeded PGLite brain with
* stubbed LLM extractor + cross-process safety simulation) is filed as
* `test/extract-conversation-facts-workers.serial.test.ts` because it
* uses `mock.module` for the gateway stub. This file lives in the
* parallel fast loop per the test-isolation lint.
*/
import { describe, test, expect, beforeEach } from 'bun:test';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import {
extractConversationFactsLockId,
PER_PAGE_LOCK_TTL_MINUTES,
_resetLockBusyLogCacheForTest,
} from '../src/commands/extract-conversation-facts.ts';
const REPO_ROOT = resolve(import.meta.dir, '..');
const SRC_PATH = resolve(REPO_ROOT, 'src/commands/extract-conversation-facts.ts');
const SRC = readFileSync(SRC_PATH, 'utf-8');
beforeEach(() => {
_resetLockBusyLogCacheForTest();
});
describe('extract-conversation-facts — exported helpers (T5)', () => {
test('extractConversationFactsLockId composes source + slug', () => {
expect(extractConversationFactsLockId('default', 'chat/alice')).toBe(
'extract-conversation-facts:default:chat/alice',
);
expect(extractConversationFactsLockId('media', 'imessage/2024-01')).toBe(
'extract-conversation-facts:media:imessage/2024-01',
);
});
test('lock id differs across sources for the same slug', () => {
// Cross-source isolation — the lock primitive must prevent
// double-claim WITHIN a source but allow parallel work ACROSS
// sources. Two sources with the same slug get different lock ids.
const a = extractConversationFactsLockId('dept-a', 'chat/team');
const b = extractConversationFactsLockId('dept-b', 'chat/team');
expect(a).not.toBe(b);
});
test('PER_PAGE_LOCK_TTL_MINUTES is short enough that holder-death recovers within ~2min', () => {
// The TTL governs how long a dead worker's lock blocks the next
// attempt. 2 minutes balances "long enough for a real page to
// finish" against "short enough that a crash isn't a 30min stall."
// The plan's D12 spec calls for ~10s refresh; with TTL=2min,
// withRefreshingLock fires at max(15s, 120s/6) = 20s, well under.
expect(PER_PAGE_LOCK_TTL_MINUTES).toBeGreaterThanOrEqual(1);
expect(PER_PAGE_LOCK_TTL_MINUTES).toBeLessThanOrEqual(10);
});
});
describe('extract-conversation-facts — structural contracts (T5)', () => {
test('imports runSlidingPool from worker-pool helper', () => {
expect(SRC).toMatch(
/import\s*\{\s*runSlidingPool\s*\}\s*from\s*['"]\.\.\/core\/worker-pool\.ts['"]/,
);
});
test('imports parseWorkers + resolveWorkersWithClamp from sync-concurrency', () => {
expect(SRC).toMatch(/parseWorkers,\s*resolveWorkersWithClamp/);
expect(SRC).toMatch(/from\s*['"]\.\.\/core\/sync-concurrency\.ts['"]/);
});
test('imports withRefreshingLock + LockUnavailableError from db-lock', () => {
expect(SRC).toMatch(
/import\s*\{\s*withRefreshingLock,\s*LockUnavailableError\s*\}\s*from\s*['"]\.\.\/core\/db-lock\.ts['"]/,
);
});
test('imports assertFactsEmbeddingDimMatchesConfig (D15 preflight)', () => {
expect(SRC).toMatch(
/import\s*\{\s*assertFactsEmbeddingDimMatchesConfig\s*\}\s*from\s*['"]\.\.\/core\/embedding-dim-check\.ts['"]/,
);
});
test('runExtractConversationFactsCore calls resolveWorkersWithClamp', () => {
expect(SRC).toMatch(/resolveWorkersWithClamp\(/);
});
test('preflight fires inside runExtractConversationFactsCore body, BEFORE work loop', () => {
// Locate the preflight call and the workers resolution; preflight
// must appear before the worker-pool fanout so dim drift surfaces
// before any LLM spend.
const preflightIdx = SRC.indexOf('assertFactsEmbeddingDimMatchesConfig(engine)');
const poolCallIdx = SRC.indexOf('runSlidingPool(');
expect(preflightIdx).toBeGreaterThan(0);
expect(poolCallIdx).toBeGreaterThan(0);
expect(preflightIdx).toBeLessThan(poolCallIdx);
});
test('per-page work wrapped in withRefreshingLock (D2 + D12)', () => {
expect(SRC).toMatch(/withRefreshingLock\(\s*engine,\s*lockId/);
expect(SRC).toMatch(/ttlMinutes:\s*PER_PAGE_LOCK_TTL_MINUTES/);
});
test('LockUnavailableError caught + pages_lock_skipped incremented (D6)', () => {
// Both halves of the lock-busy contract.
expect(SRC).toMatch(/instanceof\s+LockUnavailableError/);
expect(SRC).toMatch(/pages_lock_skipped\+\+/);
});
test('delete-orphans-first called BEFORE segment extraction (D11)', () => {
expect(SRC).toMatch(/deleteOrphanFactsForPage\(/);
// Positional check: the delete-orphans call must appear before the
// segment for-loop. Easier to assert that orphan_facts_cleaned is
// bumped before the segment loop begins.
const cleanedBumpIdx = SRC.indexOf('orphan_facts_cleaned +=');
const segmentLoopIdx = SRC.indexOf('for (const seg of segments)');
expect(cleanedBumpIdx).toBeGreaterThan(0);
expect(segmentLoopIdx).toBeGreaterThan(0);
expect(cleanedBumpIdx).toBeLessThan(segmentLoopIdx);
});
test('exit 3 fires when lock-busy pages remain (codex #3)', () => {
expect(SRC).toMatch(
/pages_lock_skipped\s*>\s*0[\s\S]{0,200}process\.exit\(3\)/,
);
});
test('parsedArgs.workers threaded into core opts', () => {
expect(SRC).toMatch(/workers:\s*parsed\.workers/);
});
test('Minion job envelope includes workers (D9 round-trip)', () => {
expect(SRC).toMatch(/workers:\s*parsed\.workers/);
// buildJobParams shape — there should be a `workers` field in the
// returned object literal.
const bjpRegion = SRC.slice(SRC.indexOf('function buildJobParams'));
expect(bjpRegion).toMatch(/workers:\s*parsed\.workers/);
});
});
describe('extract-conversation-facts — Result type carries new counters', () => {
test('ExtractConversationFactsResult has pages_lock_skipped + orphan_facts_cleaned', () => {
// Source-level shape check (the type is exported but bun:test
// doesn't introspect types at runtime; a grep is honest).
expect(SRC).toMatch(/pages_lock_skipped:\s*number/);
expect(SRC).toMatch(/orphan_facts_cleaned:\s*number/);
});
test('initial result object literal initializes both counters to 0', () => {
// Both call sites (core init + CLI aggregate init) must initialize
// the new counters or the aggregator will produce NaN under +=.
const initOccurrences = SRC.match(/pages_lock_skipped:\s*0/g) ?? [];
expect(initOccurrences.length).toBeGreaterThanOrEqual(2);
const cleanedOccurrences = SRC.match(/orphan_facts_cleaned:\s*0/g) ?? [];
expect(cleanedOccurrences.length).toBeGreaterThanOrEqual(2);
});
});