From c4f03a9df661707a36924dc5ec5eebe9d3f18c23 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 24 May 2026 02:02:15 -0700 Subject: [PATCH] fix(e2e): 4 root-cause fixes for pre-existing E2E flakes (master polish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After merging origin/master (which landed v0.40.8.0's flake-fix wave), re-ran the 6 E2E files previously called out as pre-existing failures. v0.40.8.0 had already fixed 3; the remaining 3 had real root causes: 1. autopilot-fanout-postgres — hardcoded date 2026-05-22 was 30min ago when the test was written; today (2026-05-24) it's 2 days past the 60-min freshness window. selectSourcesForDispatch correctly classifies the source as STALE (dispatch.length=1) instead of FRESH (length=0). Fix: replace literal date with Date.now() - 30 * 60 * 1000 so the timestamp stays relative-fresh forever. 2. ingestion-roundtrip — chokidar cross-test contamination on macOS FSEvents. Tests share OS-level fd resources across describe blocks; the first test's watcher hasn't fully released when the second test's watcher attaches, so the new watcher's events queue behind pending cleanup and the waitFor(15s) for the first file drop times out. Fixes: - Move fs.mkdirSync(inboxDir) BEFORE createInboxFolderSource + daemon.start to eliminate the chokidar attach race (chokidar can watch non-existent dirs but the timing is unreliable under test load). - Add 200ms grace period in beforeEach after resetPgliteState to let prior watchers fully release FSEvents handles. - mkdirSync both inboxA + inboxB BEFORE source registration in the multi-source test (same race shape). - Bump waitFor timeouts 6s → 15s for fs.watch flake tolerance. 3. fresh-install-pglite — dev machines with multi-provider env (OPENAI_API_KEY + VOYAGE_API_KEY + ZEROENTROPY_API_KEY set in zsh) fail init's disambiguation gate with "Multiple embedding providers env-ready". The test sets ZE_API_KEY but doesn't NEGATE the others. Fix: beforeEach saves + clears OPENAI_API_KEY + VOYAGE_API_KEY so init sees only ZE. afterEach restores. Hermetic per dev machine. 4. dream-synthesize-chunking — TIER_DEFAULTS + DEFAULT_ALIASES in src/core/model-config.ts had BARE Anthropic model ids (e.g. 'claude-sonnet-4-6' instead of 'anthropic:claude-sonnet-4-6'). The v0.40.8+ subagent queue's classifyCapabilities() now validates that submitted models have a provider prefix via resolveRecipe(), which throws "unknown provider" on bare ids. The synthesize phase resolveModel → bare 'claude-sonnet-4-6' → submit_job → REJECT → phase 'fail' status with empty details (test expected children_submitted=1). Fix: prefix all 4 TIER_DEFAULTS + 5 DEFAULT_ALIASES with their provider (anthropic:claude-*, google:gemini-3-pro, openai:gpt-5). Production paths already worked because user pack manifests have explicit `models.tier.subagent = anthropic:...`; only the fallback path (used in tests with no API key + no model config) hit the bare-id format and broke. Verification (all run against DATABASE_URL=...:5434/gbrain_test): test/e2e/autopilot-fanout-postgres.test.ts → 6/6 pass test/e2e/dream-cycle-phase-order-pglite.test.ts → 5/5 pass test/e2e/dream-synthesize-chunking.test.ts → 4/4 pass test/e2e/fresh-install-pglite.test.ts → 2/2 pass test/e2e/http-transport.test.ts → 8/8 pass test/e2e/ingestion-roundtrip.test.ts → 3/3 pass test/e2e/mechanical.test.ts → 78/78 pass Total: 106/106 pass, 0 fail. Adjacent unit tests verified green: test/anthropic-model-ids.test.ts → 6/6 pass test/model-config.serial.test.ts → 19/19 pass typecheck clean. Plan: v0.41 wave (~/.claude/plans/system-instruction-you-are-working-toasty-milner.md). Post-merge polish — every E2E failure surfaced in the v0.41 ship reports is now green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/core/model-config.ts | 25 ++++++++++-------- test/e2e/autopilot-fanout-postgres.test.ts | 8 +++++- test/e2e/fresh-install-pglite.test.ts | 13 ++++++++++ test/e2e/ingestion-roundtrip.test.ts | 30 +++++++++++++++++----- 4 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/core/model-config.ts b/src/core/model-config.ts index d99d1b185..ba6ce9db6 100644 --- a/src/core/model-config.ts +++ b/src/core/model-config.ts @@ -47,13 +47,18 @@ export interface ResolveModelOpts { fallback: string; } -/** Default aliases shipped in code. Users override via `models.aliases.` config. */ +/** Default aliases shipped in code. Users override via `models.aliases.` config. + * Values include the `provider:` prefix so resolved model strings always + * carry an explicit provider — required by the v0.40.8+ subagent queue's + * classifyCapabilities() validation. Bare model ids (e.g. `claude-opus-4-7`) + * cause `resolveRecipe()` to throw "unknown provider" and the queue rejects + * the submit. */ export const DEFAULT_ALIASES: Record = { - opus: 'claude-opus-4-7', - sonnet: 'claude-sonnet-4-6', - haiku: 'claude-haiku-4-5-20251001', - gemini: 'gemini-3-pro', - gpt: 'gpt-5', + opus: 'anthropic:claude-opus-4-7', + sonnet: 'anthropic:claude-sonnet-4-6', + haiku: 'anthropic:claude-haiku-4-5-20251001', + gemini: 'google:gemini-3-pro', + gpt: 'openai:gpt-5', }; /** @@ -66,10 +71,10 @@ export const DEFAULT_ALIASES: Record = { * Users override via `gbrain config set models.tier. `. */ export const TIER_DEFAULTS: Record = { - utility: 'claude-haiku-4-5-20251001', - reasoning: 'claude-sonnet-4-6', - deep: 'claude-opus-4-7', - subagent: 'claude-sonnet-4-6', + utility: 'anthropic:claude-haiku-4-5-20251001', + reasoning: 'anthropic:claude-sonnet-4-6', + deep: 'anthropic:claude-opus-4-7', + subagent: 'anthropic:claude-sonnet-4-6', }; /** diff --git a/test/e2e/autopilot-fanout-postgres.test.ts b/test/e2e/autopilot-fanout-postgres.test.ts index 38a16ceff..3cc2f2779 100644 --- a/test/e2e/autopilot-fanout-postgres.test.ts +++ b/test/e2e/autopilot-fanout-postgres.test.ts @@ -144,7 +144,13 @@ describeIfDB('autopilot fan-out — Postgres E2E', () => { await seedSource('full-round-trip'); await engine.executeRaw(`UPDATE sources SET local_path = NULL WHERE id = 'default'`); - const ts = '2026-05-22T15:00:00.000Z'; + // Use a recent (within-freshness-window) timestamp so the source + // classifies as fresh. Hardcoded dates rot — when this test was + // written, '2026-05-22T15:00:00.000Z' was 30 minutes ago and within + // the window. Two days later it's past the window and the source + // dispatches instead of being skipped, breaking the assertion on + // line below. Relative timestamp keeps the test valid forever. + const ts = new Date(Date.now() - 30 * 60 * 1000).toISOString(); const updated = await engine.updateSourceConfig('full-round-trip', { last_full_cycle_at: ts, }); diff --git a/test/e2e/fresh-install-pglite.test.ts b/test/e2e/fresh-install-pglite.test.ts index d2836cc6b..1b3f3f3bc 100644 --- a/test/e2e/fresh-install-pglite.test.ts +++ b/test/e2e/fresh-install-pglite.test.ts @@ -27,11 +27,22 @@ describe('E2E: fresh gbrain init --pglite → import → embed works end-to-end' let tmpHome: string; let origHome: string | undefined; let origZeKey: string | undefined; + let origOpenaiKey: string | undefined; + let origVoyageKey: string | undefined; beforeEach(() => { tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-e2e-fresh-')); origHome = process.env.GBRAIN_HOME; origZeKey = process.env.ZEROENTROPY_API_KEY; + // Save + clear OPENAI_API_KEY + VOYAGE_API_KEY so init only sees + // one provider as env-ready (ZE). Without this, dev machines with + // multi-provider env (Garry's setup) fail init's disambiguation gate + // ("Multiple embedding providers env-ready: openai, voyage, + // zeroentropyai") before the test body runs. + origOpenaiKey = process.env.OPENAI_API_KEY; + origVoyageKey = process.env.VOYAGE_API_KEY; + delete process.env.OPENAI_API_KEY; + delete process.env.VOYAGE_API_KEY; process.env.GBRAIN_HOME = tmpHome; // Stub key so init's setup-hint check passes. process.env.ZEROENTROPY_API_KEY = 'sk-test-ze'; @@ -43,6 +54,8 @@ describe('E2E: fresh gbrain init --pglite → import → embed works end-to-end' else process.env.GBRAIN_HOME = origHome; if (origZeKey === undefined) delete process.env.ZEROENTROPY_API_KEY; else process.env.ZEROENTROPY_API_KEY = origZeKey; + if (origOpenaiKey !== undefined) process.env.OPENAI_API_KEY = origOpenaiKey; + if (origVoyageKey !== undefined) process.env.VOYAGE_API_KEY = origVoyageKey; __setEmbedTransportForTests(null); // Restore legacy-preload gateway state. configureGateway({ diff --git a/test/e2e/ingestion-roundtrip.test.ts b/test/e2e/ingestion-roundtrip.test.ts index 705c7c59f..23fedbcad 100644 --- a/test/e2e/ingestion-roundtrip.test.ts +++ b/test/e2e/ingestion-roundtrip.test.ts @@ -66,6 +66,12 @@ afterAll(async () => { beforeEach(async () => { await resetPgliteState(engine); + // 200ms grace period for the previous test's chokidar watchers to fully + // release OS-level FSEvents handles on macOS. Without this, the second + // test's watcher events queue behind the first test's pending cleanup + // and the waitFor(15s) for the first file drop times out. See + // ingestion-roundtrip cross-test contamination notes. + await new Promise((r) => setTimeout(r, 200)); tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-e2e-roundtrip-')); inboxDir = path.join(tmpRoot, 'inbox'); brainDir = path.join(tmpRoot, 'brain'); @@ -117,6 +123,12 @@ describe('ingestion roundtrip — inbox-folder → daemon → ingest_capture → }, }); + // Create the inbox dir BEFORE starting the watcher to eliminate a race + // where chokidar hasn't attached yet when the first write fires (the + // 6s→15s waitFor flake on the source.) Without this, the test relies on + // chokidar's polling fallback to notice the dir, which is timing-dependent. + fs.mkdirSync(inboxDir, { recursive: true }); + const source = createInboxFolderSource({ inboxDir, debounceMs: 50, @@ -126,12 +138,11 @@ describe('ingestion roundtrip — inbox-folder → daemon → ingest_capture → await daemon.start(); // Drop a file into the inbox. - fs.mkdirSync(inboxDir, { recursive: true }); const captured = path.join(inboxDir, 'roundtrip.md'); fs.writeFileSync(captured, '---\ntitle: Roundtrip\n---\n\nfull e2e flow'); // Wait for the daemon to pick it up + dispatch + handler to write. - await waitFor(() => dispatchedEvents.length === 1, 6000); + await waitFor(() => dispatchedEvents.length === 1, 15000); // Page is in the DB. const page = await engine.getPage(dispatchedEvents[0]!.metadata!.slug as string ?? @@ -168,6 +179,9 @@ describe('ingestion roundtrip — inbox-folder → daemon → ingest_capture → }, }); + // mkdirSync BEFORE daemon.start to eliminate chokidar attach race. + fs.mkdirSync(inboxDir, { recursive: true }); + const source = createInboxFolderSource({ inboxDir, debounceMs: 50, @@ -176,12 +190,10 @@ describe('ingestion roundtrip — inbox-folder → daemon → ingest_capture → daemon.register({ source }); await daemon.start(); - fs.mkdirSync(inboxDir, { recursive: true }); - // Drop file 1 const drop1 = path.join(inboxDir, 'dup-1.md'); fs.writeFileSync(drop1, '# duplicate content\n\nidentical body'); - await waitFor(() => dispatchedEvents.length === 1, 6000); + await waitFor(() => dispatchedEvents.length === 1, 15000); // Drop file 2 with byte-identical content (different filename). const drop2 = path.join(inboxDir, 'dup-2.md'); @@ -217,9 +229,13 @@ describe('ingestion roundtrip — multi-source coordination', () => { }, }); - // Two distinct inbox dirs, two sources. + // Two distinct inbox dirs, two sources. Create the dirs BEFORE + // daemon.start to eliminate the chokidar attach race (same fix as + // the single-source tests above). const inboxA = path.join(tmpRoot, 'inbox-a'); const inboxB = path.join(tmpRoot, 'inbox-b'); + fs.mkdirSync(inboxA, { recursive: true }); + fs.mkdirSync(inboxB, { recursive: true }); const sourceA = createInboxFolderSource({ id: 'inbox-a', inboxDir: inboxA, @@ -239,7 +255,7 @@ describe('ingestion roundtrip — multi-source coordination', () => { fs.writeFileSync(path.join(inboxA, 'from-a.md'), 'content from A'); fs.writeFileSync(path.join(inboxB, 'from-b.md'), 'content from B'); - await waitFor(() => dispatchedEvents.length === 2, 6000); + await waitFor(() => dispatchedEvents.length === 2, 15000); const fromA = dispatchedEvents.find((e) => e.source_id === 'inbox-a'); const fromB = dispatchedEvents.find((e) => e.source_id === 'inbox-b');