Compare commits

..
Author SHA1 Message Date
Garry TanandClaude Fable 5 3fa01a4538 fix(cycle): ambiguous local_path resolves no source — don't stamp an arbitrary pick
resolveSourceForDir used LIMIT 1, so when two sources share a local_path
the dir-keyed freshness stamp (new in this PR) fired for a
nondeterministic one — exactly the 'freshness stamp that lies' the
cycleSourceId comment warns against, and the CI failure in
test/dream.test.ts ('gbrain dream (no --source) leaves all sources
untouched'). Require exactly one match; ambiguous or no match falls back
to the pre-v0.18 behavior (undefined), same as before this PR for the
no-match case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:06:41 -07:00
ded4aeaeae fix(cycle): stamp last_full_cycle_at for the resolved source; stale freshness forces autopilot fanout (#1993, #2060)
Two fixes to the per-source cycle freshness loop:

1. runCycle's freshness stamp now keys off cycleSourceId (opts.sourceId ??
   the source resolved from brainDir) — the same id the cycle locked and
   scoped its phases to — instead of raw opts.sourceId. The autopilot's
   inline cycle passes brainDir with no explicit sourceId, so the stamp
   never fired and cycle_freshness stayed stale forever. Keeps master's
   !aborted guard and the last_source_cycle_at write. (takeover of #1993)

2. autopilot's dispatch decision now consults per-source cycle staleness:
   countStaleSources (new pure helper in autopilot-fanout.ts) over
   listAllSources({ localPathOnly: true }). A stale source forces the
   fanout path and blocks the healthy-sleep gate, so a brain sitting at
   score 70-94 with a small targeted plan can no longer starve per-source
   cycle dispatch indefinitely. Fail-open to 0 on read errors;
   dispatchPerSource's existing throttles (skipped_fresh / fanoutMax /
   failure cooldown) bound the work. (#2060)

Tests: cycle-last-full-cycle-at gains the brainDir-resolves-source and
brainDir-matches-nothing cases (first one fails without fix 1);
autopilot-fanout unit tests cover countStaleSources; the fanout wiring
guard pins the staleCycleSources terms in shouldFullCycle/shouldSleep.

Co-authored-by: 100menotu001 <100menotu001@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:24:01 -07:00
16 changed files with 148 additions and 488 deletions
-43
View File
@@ -466,11 +466,6 @@ async function main() {
const result = JSON.parse(JSON.stringify(rawResult, bigintToStringReplacer));
const output = formatResult(op.name, result);
if (output) process.stdout.write(output);
// #1484 — invisible-miss hint: a bare query/search that hit zero results
// on a multi-source brain tells the user (stderr) which source it
// actually searched and how to widen the scope.
const hint = await sourceScopeHint(op.name, params, ctx.sourceId, engine, result);
if (hint) console.error(hint);
} catch (e: unknown) {
// v0.42.20.0 (codex D4): on error, set exitCode + return so the `finally`
// STILL runs (drains every background-work sink + disconnects). A bare
@@ -842,44 +837,6 @@ async function makeContext(engine: BrainEngine, params: Record<string, unknown>)
};
}
/**
* #1484 — a bare `gbrain query`/`search` silently scopes to the resolved
* source (usually 'default'); on a multi-source brain a zero-hit run looks
* identical to "the brain doesn't know this" even when the answer lives in
* another source. Returns a stderr hint when (a) the op is query/search,
* (b) it returned zero results, (c) the caller did NOT scope explicitly
* (--source / --source-id / --all-sources), and (d) the brain has >1
* registered source. Best-effort: any lookup failure returns null.
*
* Exported for tests (same import-safety contract as formatResult).
*/
export async function sourceScopeHint(
opName: string,
params: Record<string, unknown>,
sourceId: string,
engine: BrainEngine,
result: unknown,
): Promise<string | null> {
if (opName !== 'query' && opName !== 'search') return null;
if (!Array.isArray(result) || result.length > 0) return null;
// Explicit scoping (flag tier) = user intent; don't second-guess it.
if (params.source || params.source_id || params.all_sources) return null;
if (sourceId === '__all__') return null;
try {
const rows = await engine.executeRaw<{ n: number }>(
`SELECT count(*)::int AS n FROM sources`,
);
const n = Number(rows[0]?.n ?? 0);
if (n <= 1) return null;
return (
`Hint: this brain has ${n} sources; you searched only "${sourceId}". ` +
`Retry with --source-id __all__ (all sources) or --source-id <id>.`
);
} catch {
return null; // hint is best-effort; never fail the query over it
}
}
// Exported for tests (same import-safety contract as cliAliases/printOpHelp).
export function formatResult(opName: string, result: unknown): string {
switch (opName) {
+12
View File
@@ -186,6 +186,18 @@ export function isSourceStale(src: SourceRow, now = Date.now(), floorMin = FULL_
return ageMin >= floorMin;
}
/**
* #2060: count sources past the per-source cycle freshness floor. Consumed
* by autopilot's dispatch decision — a stale source forces the fanout path
* even when the doctor plan is small (score 7094, plan ≤ 3, est < 300s),
* so targeted mode can't leave cycle_freshness stale indefinitely.
* dispatchPerSource's own throttles (skipped_fresh / fanoutMax / failure
* cooldown) bound the resulting work.
*/
export function countStaleSources(sources: SourceRow[], now = Date.now(), floorMin = FULL_CYCLE_FLOOR_MIN): number {
return sources.filter((s) => isSourceStale(s, now, floorMin)).length;
}
/**
* Most recent SUCCESSFUL cycle for a source. Prefers `last_source_cycle_at`
* (per-source phases, written by the split cycle) and falls back to the legacy
+16 -2
View File
@@ -901,13 +901,27 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
const FULL_CYCLE_FLOOR_MIN = 60;
const minutesSinceLastFull = (Date.now() - lastFullCycleAt) / 60000;
// #2060: stale per-source cycle freshness is a dispatch input. Without
// it, a brain sitting at score 7094 with a small targeted plan (≤3
// steps, <300s) stays in targeted mode indefinitely and no per-source
// cycle is ever dispatched — cycle_freshness never advances. A stale
// source forces the fanout path; dispatchPerSource's throttles
// (skipped_fresh / fanoutMax / failure cooldown) bound the work.
// Fail-open to 0: a read failure must not block dispatch.
let staleCycleSources = 0;
try {
const { countStaleSources } = await import('./autopilot-fanout.ts');
staleCycleSources = countStaleSources(await engine.listAllSources({ localPathOnly: true }));
} catch { /* fail-open: freshness is a dispatch hint, not a gate */ }
const shouldFullCycle =
(score >= 95 && plan.length === 0 && minutesSinceLastFull >= FULL_CYCLE_FLOOR_MIN) ||
plan.length > 3 ||
estTotal >= 300 ||
score < 70;
score < 70 ||
staleCycleSources > 0;
const shouldSleep = score >= 95 && plan.length === 0 && minutesSinceLastFull < FULL_CYCLE_FLOOR_MIN;
const shouldSleep = score >= 95 && plan.length === 0 && minutesSinceLastFull < FULL_CYCLE_FLOOR_MIN && staleCycleSources === 0;
if (shouldSleep) {
if (jsonMode) {
+20 -49
View File
@@ -1000,24 +1000,9 @@ const voyageCompatFetch = (async (input: RequestInfo | URL, init?: RequestInit)
// Voyage diverges from OpenAI in two places that break the parser:
// - `embedding` is a base64 string (SDK schema expects `number[]`)
// - `usage` lacks `prompt_tokens` (SDK schema requires it when usage present)
//
// #1610: read the body ONCE via text() and JSON.parse it. The pre-fix
// `await resp.clone().json()` truncated large bodies on bun < 1.1.27
// (oven-sh/bun#6348) — the parse threw, the catch fell back to the raw
// response, and multi-chunk pages died with "Invalid JSON response".
// Every JSON return path below rebuilds the Response so a stale
// Content-Length/Content-Encoding header from the original can't lie
// about the rewritten body.
const bodyText = await resp.text();
const rebuild = (body: string) => {
const headers = new Headers(resp.headers);
headers.delete('content-length');
headers.delete('content-encoding');
return new Response(body, { status: resp.status, statusText: resp.statusText, headers });
};
try {
const json: any = JSON.parse(bodyText);
if (!json || typeof json !== 'object') return rebuild(bodyText);
const json: any = await resp.clone().json();
if (!json || typeof json !== 'object') return resp;
let modified = false;
if (Array.isArray(json.data)) {
for (const item of json.data) {
@@ -1052,19 +1037,22 @@ const voyageCompatFetch = (async (input: RequestInfo | URL, init?: RequestInit)
: 0;
modified = true;
}
if (!modified) return rebuild(bodyText);
return rebuild(JSON.stringify(json));
if (!modified) return resp;
return new Response(JSON.stringify(json), {
status: resp.status,
statusText: resp.statusText,
headers: resp.headers,
});
} catch (err) {
// OOM-cap throws MUST propagate. The catch is here for "Voyage returned
// JSON I can't reshape" (parse error, unexpected schema) — falling back
// to the original body is correct in that case. Letting the
// to the original response is correct in that case. Letting the
// too-large response through here would defeat the entire purpose of
// Layer 2 (the per-embedding cap that fires when Content-Length wasn't
// available to Layer 1).
if (err instanceof VoyageResponseTooLargeError) throw err;
// If parsing/transformation fails, pass the original body through
// (rebuilt — resp's body stream is already consumed by text()).
return rebuild(bodyText);
// If parsing/transformation fails, fall back to the original response.
return resp;
}
}) as unknown as typeof fetch;
@@ -1204,21 +1192,9 @@ const zeroEntropyCompatFetch = (async (input: RequestInfo | URL, init?: RequestI
// validates. Also map usage.total_tokens → prompt_tokens (SDK requires
// prompt_tokens when `usage` is present — same divergence Voyage hit at
// gateway.ts:655).
//
// #1610: read the body ONCE via text() + JSON.parse — `resp.clone().json()`
// truncated large bodies on bun < 1.1.27 (oven-sh/bun#6348), so the parse
// threw and the catch fell back to the RAW ZE `{results: ...}` shape, which
// the AI SDK schema rejects → "Invalid JSON response" on multi-chunk pages.
const bodyText = await resp.text();
const rebuild = (body: string) => {
const headers = new Headers(resp.headers);
headers.delete('content-length');
headers.delete('content-encoding');
return new Response(body, { status: resp.status, statusText: resp.statusText, headers });
};
try {
const json: any = JSON.parse(bodyText);
if (!json || typeof json !== 'object') return rebuild(bodyText);
const json: any = await resp.clone().json();
if (!json || typeof json !== 'object') return resp;
let modified = false;
if (Array.isArray(json.results) && !Array.isArray(json.data)) {
// Layer 2 OOM cap — per-embedding size. ZE returns float[] arrays,
@@ -1252,25 +1228,20 @@ const zeroEntropyCompatFetch = (async (input: RequestInfo | URL, init?: RequestI
// SDK also expects total_tokens; ZE provides it directly.
modified = true;
}
if (!modified) return rebuild(bodyText);
return rebuild(JSON.stringify(json));
if (!modified) return resp;
return new Response(JSON.stringify(json), {
status: resp.status,
statusText: resp.statusText,
headers: resp.headers,
});
} catch (err) {
// OOM-cap throws MUST propagate. Voyage's pattern: instanceof check on
// its own tagged class. Same here — only rethrow our own cap class.
if (err instanceof ZeroEntropyResponseTooLargeError) throw err;
return rebuild(bodyText);
return resp;
}
}) as unknown as typeof fetch;
/**
* Test-only seams (#1610): the compat shims are module-private closures;
* exporting them lets tests drive the response-rewrite paths behaviorally
* (truncating clone(), stale Content-Length) without a live provider.
* Same pattern as __getShrinkStateForTests.
*/
export const __voyageCompatFetchForTests = voyageCompatFetch;
export const __zeroEntropyCompatFetchForTests = zeroEntropyCompatFetch;
/**
* Generic asymmetric-embedding shim for openai-compatible recipes that
* ship no compat fetch of their own (llama-server, litellm, ollama, ...).
+19 -10
View File
@@ -854,7 +854,10 @@ interface SyncPhaseResult extends PhaseResult {
/**
* Resolve the source id for a brain directory by looking up the sources
* table. Returns undefined when no registered source matches (falls back
* to pre-v0.18 global config.sync.* keys).
* to pre-v0.18 global config.sync.* keys) OR when MORE than one source
* claims the path — an ambiguous match must not scope phases or stamp
* last_full_cycle_at for an arbitrarily-picked source (the "freshness
* stamp that lies" this resolution exists to prevent).
*/
async function resolveSourceForDir(
engine: BrainEngine,
@@ -865,10 +868,10 @@ async function resolveSourceForDir(
if (brainDir === null) return undefined;
try {
const rows = await engine.executeRaw<{ id: string }>(
`SELECT id FROM sources WHERE local_path = $1 LIMIT 1`,
`SELECT id FROM sources WHERE local_path = $1 LIMIT 2`,
[brainDir],
);
return rows[0]?.id;
return rows.length === 1 ? rows[0]!.id : undefined;
} catch {
// sources table might not exist on very old brains — fall through.
return undefined;
@@ -2365,17 +2368,23 @@ export async function runCycle(
}
// v0.38 (codex r1 P0-5): persist per-source cycle completion timestamp
// when the cycle ran successfully against an explicit source. Read by
// autopilot's per-source freshness gate next tick. Skipped when:
// - opts.sourceId is unset (legacy callers — autopilot still here)
// - engine is null (no-DB path)
// when the cycle ran successfully against a resolvable source. Read by
// autopilot's per-source freshness gate next tick.
//
// #1993: keyed off `cycleSourceId` (opts.sourceId ?? the source resolved
// from brainDir) — the SAME id the cycle locked + scoped its phases to —
// NOT raw opts.sourceId. The autopilot's inline cycle sets brainDir but
// passes no explicit sourceId, so keying off opts.sourceId alone never
// advanced last_full_cycle_at and cycle_freshness stayed stale even while
// the autopilot cycled every interval. Skipped when:
// - no source resolves (engine null, or no checkout AND no opts.sourceId)
// - status is 'failed' or 'skipped' (don't mark a non-run as fresh)
// - dryRun (writes are out of scope)
//
// Best-effort: a write failure does NOT change the CycleReport status.
// The cost of writing the wrong timestamp post-failure is higher than
// the cost of missing a successful write (next cycle will redo work).
if (opts.sourceId && engine && !dryRun && !aborted && (status === 'ok' || status === 'clean' || status === 'partial')) {
if (cycleSourceId && engine && !dryRun && !aborted && (status === 'ok' || status === 'clean' || status === 'partial')) {
try {
const nowIso = new Date().toISOString();
// #2194 fix #3 (the cycle split): `last_source_cycle_at` is the NEW gate
@@ -2385,13 +2394,13 @@ export async function runCycle(
// phases (those gate on autopilot.last_global_at), so writing it on a
// source-only cycle does not re-introduce the freshness poisoning codex
// flagged in the rejected skip-based design.
await engine.updateSourceConfig(opts.sourceId, {
await engine.updateSourceConfig(cycleSourceId, {
last_source_cycle_at: nowIso,
last_full_cycle_at: nowIso,
});
} catch (e) {
// Best-effort; cycle already succeeded by the time we get here.
console.warn(`[cycle] failed to write last_source_cycle_at for source ${opts.sourceId}: ${e instanceof Error ? e.message : String(e)}`);
console.warn(`[cycle] failed to write last_source_cycle_at for source ${cycleSourceId}: ${e instanceof Error ? e.message : String(e)}`);
}
}
+2 -13
View File
@@ -499,7 +499,7 @@ export function linkReadScopeOpts(ctx: OperationContext): { sourceId?: string; s
* FAIL-CLOSED: anything not strictly `ctx.remote === false` is untrusted.
*
* This is the SINGLE resolver for every read op that accepts a per-call
* `source_id` / `all_sources` parameter (query, search, code_callers, code_callees,
* `source_id` / `all_sources` parameter (query, code_callers, code_callees,
* get_page, search_by_image, code_blast, code_flow). Inlining the `__all__`
* branch per handler is the bug class that leaked cross-source reads (#1924,
* #1371): a remote client could pass `source_id: '__all__'` to opt out of its
@@ -1442,24 +1442,13 @@ const search: Operation = {
limit: { type: 'number', description: 'Max results (default 20)' },
offset: { type: 'number', description: 'Skip first N results (for pagination)' },
mode: { type: 'string', description: 'Search mode (conservative|balanced|tokenmax). Local callers only.' },
source_id: {
type: 'string',
description:
"Scope search to a single source. Defaults to OperationContext.sourceId. Pass '__all__' to span every source for trusted local callers; for remote callers '__all__' spans only your granted sources.",
},
all_sources: { type: 'boolean', description: "Span sources (equivalent to source_id=__all__): every source locally, your grant remotely." },
},
handler: async (ctx, p) => {
const startedAt = Date.now();
const queryText = p.query as string;
const limit = (p.limit as number) || 20;
const offset = (p.offset as number) || 0;
// #1484 follow-up: route through the canonical fail-closed resolver so
// `--source-id __all__` / `all_sources` behave the same as on `query`
// (the zero-hit CLI hint advises exactly that retry). Without a per-call
// param, `search` silently ignored --source-id — the retry looked like
// a genuine miss.
const scope = resolveRequestedScope(ctx, p.source_id as string | undefined, p.all_sources === true);
const scope = sourceScopeOpts(ctx);
// T4/D5 — per-call mode honored ONLY for trusted/local callers so a remote
// OAuth client can't escalate to the costly tokenmax bundle. Local + unknown
+2 -12
View File
@@ -1323,18 +1323,8 @@ export async function hybridSearch(
if (effectiveModality === 'both' && imageVectorList !== null) {
vectorLists = [...vectorLists, imageVectorList];
}
} catch (err) {
// Embedding/vector failure is non-fatal fall back to keyword-only
// but say WHY (#1626): this arm only runs when the embedding provider
// probed available, so a throw here is a real failure (embed timeout,
// transient pooler error on the searchVector fan-out). Pre-fix the bare
// catch made a cross-source `--source __all__` run silently collapse to
// keyword-only/"No results" with zero diagnostics.
warnOncePerProcess(
'hybrid-vector-arm-failed',
`[gbrain] vector arm failed (fail-open, keyword-only fallback): ` +
`${err instanceof Error ? err.message : String(err)}`,
);
} catch {
// Embedding failure is non-fatal, fall back to keyword-only
}
}
@@ -1,115 +0,0 @@
/**
* #1610 Voyage/ZeroEntropy compat shims must read the response body ONCE
* via text() instead of `resp.clone().json()`.
*
* On bun < 1.1.27, Response.clone() truncates large bodies (oven-sh/bun#6348):
* the clone().json() parse threw, the shim's catch fell back to the ORIGINAL
* response whose wire shape (ZE `{results: ...}`, Voyage base64 embeddings)
* the AI SDK's openai-compatible Zod schema rejects and multi-chunk pages
* failed with "Invalid JSON response".
*
* These tests simulate the truncating clone() and assert the shims still
* return the fully rewritten body. They also pin that the rewritten Response
* does NOT carry the original (now stale) Content-Length header, which lied
* about the rewritten body's size (gateway.ts previously copied
* `headers: resp.headers` verbatim).
*/
import { afterEach, describe, expect, test } from 'bun:test';
import {
__voyageCompatFetchForTests,
__zeroEntropyCompatFetchForTests,
} from '../../src/core/ai/gateway.ts';
const origFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = origFetch;
});
/** Build a Response whose clone() truncates the body (bun < 1.1.27 behavior). */
function truncatingCloneResponse(body: string): Response {
const headers = {
'content-type': 'application/json',
// Deliberately stale after any rewrite: the original wire body's length.
'content-length': String(Buffer.byteLength(body)),
};
const resp = new Response(body, { status: 200, headers });
(resp as any).clone = () =>
new Response(body.slice(0, 32), { status: 200, headers });
return resp;
}
describe('voyageCompatFetch — single body read (#1610)', () => {
test('rewrites base64 embeddings even when clone() truncates the body', async () => {
const floats = new Float32Array([0.5, 0.25, -1]);
const b64 = Buffer.from(floats.buffer).toString('base64');
const wireBody = JSON.stringify({
object: 'list',
data: [{ object: 'embedding', embedding: b64, index: 0 }],
model: 'voyage-3',
usage: { total_tokens: 7 },
});
globalThis.fetch = (async () => truncatingCloneResponse(wireBody)) as unknown as typeof fetch;
const out = await __voyageCompatFetchForTests('https://api.voyageai.com/v1/embeddings', {
method: 'POST',
body: JSON.stringify({ input: ['hello'], model: 'voyage-3' }),
headers: { 'content-type': 'application/json' },
});
const json: any = await out.json();
expect(Array.from(json.data[0].embedding)).toEqual([0.5, 0.25, -1]);
expect(json.usage.prompt_tokens).toBe(7);
// Stale Content-Length from the wire body must not survive the rewrite.
expect(out.headers.get('content-length')).toBeNull();
expect(out.headers.get('content-encoding')).toBeNull();
});
});
describe('zeroEntropyCompatFetch — single body read (#1610)', () => {
test('rewrites {results} → {data} even when clone() truncates the body', async () => {
const wireBody = JSON.stringify({
results: [{ embedding: [0.1, 0.2] }, { embedding: [0.3, 0.4] }],
usage: { total_bytes: 42, total_tokens: 9 },
});
let fetchedUrl = '';
globalThis.fetch = (async (url: string | URL | Request) => {
fetchedUrl = String(url);
return truncatingCloneResponse(wireBody);
}) as unknown as typeof fetch;
const out = await __zeroEntropyCompatFetchForTests('https://api.zeroentropy.dev/v1/embeddings', {
method: 'POST',
body: JSON.stringify({ input: ['hello'], model: 'zembed-1' }),
headers: { 'content-type': 'application/json' },
});
expect(fetchedUrl.endsWith('/v1/models/embed')).toBe(true);
const json: any = await out.json();
// The AI SDK schema requires {data: [{embedding, index}]} — the raw ZE
// {results} fallback is exactly the pre-fix "Invalid JSON response".
expect(json.results).toBeUndefined();
expect(json.data).toHaveLength(2);
expect(json.data[0]).toEqual({ object: 'embedding', embedding: [0.1, 0.2], index: 0 });
expect(json.data[1].index).toBe(1);
expect(json.usage.prompt_tokens).toBe(9);
expect(out.headers.get('content-length')).toBeNull();
});
test('non-JSON body falls back to the original bytes (rebuilt, still readable)', async () => {
const wireBody = 'plain text, not json';
globalThis.fetch = (async () =>
new Response(wireBody, {
status: 200,
headers: { 'content-type': 'application/json' },
})) as unknown as typeof fetch;
const out = await __zeroEntropyCompatFetchForTests('https://api.zeroentropy.dev/v1/embeddings', {
method: 'POST',
body: JSON.stringify({ input: ['hello'] }),
});
// Body was consumed by the shim's single read; the fallback must
// rebuild a readable Response rather than return the drained original.
expect(await out.text()).toBe(wireBody);
});
});
+4 -6
View File
@@ -98,18 +98,16 @@ describe('zeroEntropyCompatFetch — OOM caps', () => {
expect(src).toMatch(/MAX_ZEROENTROPY_RESPONSE_BYTES\s*=\s*256\s*\*\s*1024\s*\*\s*1024/);
});
test('Layer 1: Content-Length pre-check before the body is read', async () => {
test('Layer 1: Content-Length pre-check before resp.clone().json()', async () => {
const src = await Bun.file(GATEWAY_PATH).text();
// Find the zeroEntropyCompatFetch block bounds, then assert ordering
// within it (mirroring the voyage cap test pattern). #1610 moved the
// body read from `resp.clone().json()` to a single `resp.text()` (bun
// < 1.1.27 truncates clone()d bodies, oven-sh/bun#6348).
// within it (mirroring the voyage cap test pattern).
const zeFetchStart = src.indexOf('const zeroEntropyCompatFetch');
expect(zeFetchStart).toBeGreaterThan(0);
const block = src.slice(zeFetchStart, zeFetchStart + 9000);
const block = src.slice(zeFetchStart, zeFetchStart + 8000);
const preCheckIdx = block.indexOf("resp.headers.get('content-length')");
const jsonParseIdx = block.indexOf('const bodyText = await resp.text()');
const jsonParseIdx = block.indexOf('await resp.clone().json()');
expect(preCheckIdx).toBeGreaterThan(0);
expect(jsonParseIdx).toBeGreaterThan(0);
// The pre-check MUST appear before the JSON parse — Voyage's lesson
+14
View File
@@ -54,6 +54,20 @@ describe('autopilot.ts ↔ dispatchPerSource wiring', () => {
expect(AUTOPILOT_SRC).toMatch(/lastFullCycleAt\s*=\s*Date\.now\(\)/);
});
test('stale per-source cycle freshness is a shouldFullCycle input (#2060)', () => {
// Targeted mode (score 7094, plan ≤3, est <300s) must not be able to
// starve per-source cycle dispatch: a stale source (per countStaleSources
// over listAllSources) forces the fanout path, and the sleep gate must
// not fire while stale sources exist. Without these terms, cycle
// freshness never advances for a brain that always lands in targeted mode.
expect(AUTOPILOT_SRC).toMatch(/countStaleSources/);
const fullCycleDeclIdx = AUTOPILOT_SRC.indexOf('const shouldFullCycle');
expect(fullCycleDeclIdx).toBeGreaterThan(-1);
const decl = AUTOPILOT_SRC.slice(fullCycleDeclIdx, fullCycleDeclIdx + 700);
expect(decl).toMatch(/staleCycleSources\s*>\s*0/);
expect(decl).toMatch(/const shouldSleep[^;]*staleCycleSources\s*===\s*0/);
});
test('does NOT regress to the single-job dispatch on the full-cycle path', () => {
// Pre-PR: the shouldFullCycle branch did:
// const job = await queue.add('autopilot-cycle', { repoPath }, {
+18
View File
@@ -14,6 +14,7 @@ import { describe, test, expect } from 'bun:test';
import {
readLastFullCycleAt,
isSourceStale,
countStaleSources,
selectSourcesForDispatch,
resolveFanoutMax,
dispatchPerSource,
@@ -74,6 +75,23 @@ describe('isSourceStale', () => {
});
});
describe('countStaleSources (#2060 dispatch-decision input)', () => {
const NOW = Date.parse('2026-05-22T12:00:00.000Z');
test('counts never-cycled + past-floor sources, ignores fresh', () => {
const sources = [
src('never-cycled'), // stale (null)
src('old', new Date(NOW - 2 * 60 * 60_000).toISOString()), // stale (2h)
src('fresh', new Date(NOW - 30 * 60_000).toISOString()), // fresh (30min)
];
expect(countStaleSources(sources, NOW)).toBe(2);
});
test('returns 0 for all-fresh and for empty list', () => {
const fresh = src('a', new Date(NOW - 10 * 60_000).toISOString());
expect(countStaleSources([fresh], NOW)).toBe(0);
expect(countStaleSources([], NOW)).toBe(0);
});
});
describe('selectSourcesForDispatch', () => {
const NOW = Date.parse('2026-05-22T12:00:00.000Z');
const fresh = (id: string, agoMin: number) =>
-61
View File
@@ -1,61 +0,0 @@
/**
* #1484 invisible-miss hint. A bare `gbrain query` resolves to a single
* source (usually 'default'); on a multi-source brain a zero-hit run gave no
* signal that the answer might live in another source. sourceScopeHint
* returns the stderr hint exactly when: query/search op + zero results +
* no explicit scoping param + >1 registered source.
*/
import { describe, expect, test } from 'bun:test';
import { sourceScopeHint } from '../src/cli.ts';
import type { BrainEngine } from '../src/core/engine.ts';
function fakeEngine(sourceCount: number, fail = false): BrainEngine {
return {
executeRaw: async () => {
if (fail) throw new Error('sources table missing');
return [{ n: sourceCount }];
},
} as unknown as BrainEngine;
}
describe('sourceScopeHint (#1484)', () => {
test('fires on a bare zero-hit query against a multi-source brain', async () => {
const hint = await sourceScopeHint('query', {}, 'default', fakeEngine(3), []);
expect(hint).toContain('3 sources');
expect(hint).toContain('"default"');
expect(hint).toContain('--source-id __all__');
});
test('fires for search too', async () => {
const hint = await sourceScopeHint('search', {}, 'wiki', fakeEngine(2), []);
expect(hint).toContain('"wiki"');
});
test('silent when results were found', async () => {
expect(await sourceScopeHint('query', {}, 'default', fakeEngine(3), [{ slug: 'a' }])).toBeNull();
});
test('silent when the caller scoped explicitly', async () => {
expect(await sourceScopeHint('query', { source_id: 'wiki' }, 'wiki', fakeEngine(3), [])).toBeNull();
expect(await sourceScopeHint('query', { source: 'wiki' }, 'wiki', fakeEngine(3), [])).toBeNull();
expect(await sourceScopeHint('query', { all_sources: true }, '__all__', fakeEngine(3), [])).toBeNull();
});
test('silent when the resolved scope is already __all__', async () => {
expect(await sourceScopeHint('query', {}, '__all__', fakeEngine(3), [])).toBeNull();
});
test('silent on a single-source brain', async () => {
expect(await sourceScopeHint('query', {}, 'default', fakeEngine(1), [])).toBeNull();
});
test('silent for non-search ops and non-array results', async () => {
expect(await sourceScopeHint('get_stats', {}, 'default', fakeEngine(3), [])).toBeNull();
expect(await sourceScopeHint('query', {}, 'default', fakeEngine(3), { rows: [] })).toBeNull();
});
test('best-effort: sources lookup failure returns null, never throws', async () => {
expect(await sourceScopeHint('query', {}, 'default', fakeEngine(3, true), [])).toBeNull();
});
});
+38 -8
View File
@@ -3,8 +3,10 @@
* cycles. Closes codex round-1 P0-5 (write site for last_full_cycle_at
* was unspecified pre-PR).
*
* Conditions for write:
* - opts.sourceId is set (legacy callers without sourceId skip the write)
* Conditions for write (keyed off `cycleSourceId` = opts.sourceId ?? the
* source resolved from brainDir, so the autopilot's inline cycle brainDir
* set, no explicit sourceId also advances the timestamp, #1993):
* - a source resolves (explicit sourceId, or brainDir matches a source)
* - engine is non-null (no-DB path skips)
* - status is 'ok' | 'clean' | 'partial' (failed/skipped don't mark fresh)
* - dryRun is false
@@ -90,17 +92,45 @@ describe('runCycle last_full_cycle_at exit hook', () => {
});
});
test('legacy caller (no sourceId) does NOT write any source timestamp', async () => {
test('no explicit sourceId but brainDir resolves a source → writes the resolved source timestamp', async () => {
await withEnv({ GBRAIN_HOME: gbrainHome }, async () => {
await seedSource('default-like');
// No sourceId passed; should remain untouched.
// The autopilot's inline cycle sets brainDir but passes no sourceId.
// runCycle resolves the source from brainDir (local_path match) into
// cycleSourceId and stamps last_full_cycle_at for it — otherwise
// cycle_freshness reports the brain stale even while the autopilot
// cycles every interval (#1993).
await seedSource('resolved-from-dir'); // local_path = brainDir
expect(await readLastFullCycleAt('resolved-from-dir')).toBeNull();
const t0 = Date.now();
const report = await runCycle(engine, {
brainDir,
phases: ['lint'],
});
expect(['ok', 'clean']).toContain(report.status);
const after = await readLastFullCycleAt('resolved-from-dir');
expect(after).not.toBeNull();
expect(new Date(after!).getTime()).toBeGreaterThanOrEqual(t0);
});
});
test('no sourceId and brainDir matches no source → does not write', async () => {
await withEnv({ GBRAIN_HOME: gbrainHome }, async () => {
// A source exists but its local_path does NOT match brainDir, so
// resolveSourceForDir returns undefined, cycleSourceId is undefined,
// and no per-source timestamp is written.
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config, archived, created_at)
VALUES ('unmatched', 'unmatched', '/no/such/repo', '{}'::jsonb, false, NOW())
ON CONFLICT (id) DO UPDATE SET local_path = EXCLUDED.local_path`,
[],
);
await runCycle(engine, {
brainDir,
phases: ['lint'],
});
// No per-source write happens; default source's config stays empty.
const after = await readLastFullCycleAt('default-like');
expect(after).toBeNull();
expect(await readLastFullCycleAt('unmatched')).toBeNull();
});
});
@@ -1,77 +0,0 @@
/**
* #1626 hybridSearch's text-vector arm must not fail DARK.
*
* The arm only runs when the embedding provider probed available, so a throw
* inside it (embed timeout, transient pooler error on searchVector) is a real
* failure. Pre-fix, a bare `catch {}` swallowed it and the run silently
* collapsed to keyword-only under `--source __all__` on a strained pooler
* that read as a non-deterministic "No results". The fix logs the swallowed
* reason via warnOncePerProcess while keeping the keyword fallback.
*/
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 {
__setEmbedTransportForTests,
configureGateway,
resetGateway,
} from '../src/core/ai/gateway.ts';
import { _resetWarnOnceForTests } from '../src/core/utils.ts';
let engine: PGLiteEngine;
const origWarn = console.warn;
beforeAll(async () => {
// Pin the gateway to OpenAI with a stub key (put-page-provenance pattern):
// embed() runs instantiateEmbedding — which requires OPENAI_API_KEY — BEFORE
// the stubbed transport is reached. Without this, a keyless CI environment
// throws the config error instead of the transport's, and the assertion on
// the swallowed reason fails. The key never leaves the process.
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
env: { ...process.env, OPENAI_API_KEY: process.env.OPENAI_API_KEY || 'sk-test-stub' },
});
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
await engine.putPage('people/alice-example', {
type: 'person',
title: 'Alice Example',
compiled_truth: 'Alice Example is a test person for the vector-arm warn test.',
});
});
afterAll(async () => {
console.warn = origWarn;
__setEmbedTransportForTests(null);
resetGateway();
await engine.disconnect();
});
describe('hybridSearch vector-arm failure telemetry (#1626)', () => {
test('embed failure logs the swallowed reason and falls back to keyword', async () => {
_resetWarnOnceForTests();
// Installing a transport makes isAvailable('embedding') true (test-seam
// fast path), so the vector arm RUNS — and then throws.
__setEmbedTransportForTests(() => {
throw new Error('pooler exploded mid-fanout');
});
const warnings: string[] = [];
console.warn = (...args: unknown[]) => {
warnings.push(args.map(String).join(' '));
};
try {
const results = await hybridSearch(engine, 'alice');
// Keyword fallback still returns results — fail-open preserved.
expect(results.some((r) => r.slug === 'people/alice-example')).toBe(true);
} finally {
console.warn = origWarn;
__setEmbedTransportForTests(null);
}
const armWarnings = warnings.filter((w) => w.includes('vector arm failed'));
expect(armWarnings).toHaveLength(1);
expect(armWarnings[0]).toContain('pooler exploded mid-fanout');
});
});
-87
View File
@@ -1,87 +0,0 @@
/**
* #1484 follow-up the `search` op must honor per-call `source_id` /
* `all_sources` through the canonical fail-closed resolver
* (resolveRequestedScope), exactly like `query` does.
*
* Pre-fix, `search` had no source_id param at all: the zero-hit CLI hint
* advised "retry with --source-id __all__", the flag parsed into params,
* NOTHING consumed it, and the retry silently re-ran the same single-source
* search an invisible false negative (and the retry's params.source_id
* suppressed the hint, so the user got no second warning).
*/
import { describe, expect, test } from 'bun:test';
import { operationsByName } from '../src/core/operations.ts';
import type { OperationContext } from '../src/core/operations.ts';
import type { BrainEngine } from '../src/core/engine.ts';
const searchOp = operationsByName['search'];
/** Fake engine: keyword-only config so the handler's scope goes straight to
* searchKeyword, where we capture the opts it was called with. */
function makeCtx(remote: boolean, allowedSources?: string[]) {
const captured: { opts?: Record<string, unknown> } = {};
const engine = {
getConfig: async (key: string) => (key === 'search.mcp_keyword_only' ? 'true' : null),
searchKeyword: async (_q: string, opts: Record<string, unknown>) => {
captured.opts = opts;
return [];
},
} as unknown as BrainEngine;
const ctx = {
engine,
config: { engine: 'pglite' },
logger: { info: () => {}, warn: () => {}, error: () => {} },
dryRun: false,
remote,
sourceId: 'default',
...(allowedSources ? { auth: { allowedSources } } : {}),
} as unknown as OperationContext;
return { ctx, captured };
}
describe('search op per-call source scope (#1484 follow-up)', () => {
test('op declares source_id + all_sources params (the CLI hint advises them)', () => {
expect(searchOp.params.source_id).toBeDefined();
expect(searchOp.params.all_sources).toBeDefined();
});
test('default: scopes to ctx.sourceId', async () => {
const { ctx, captured } = makeCtx(false);
await searchOp.handler(ctx, { query: 'x' });
expect(captured.opts?.sourceId).toBe('default');
});
test("local + source_id '__all__' spans the whole brain (no source filter)", async () => {
const { ctx, captured } = makeCtx(false);
await searchOp.handler(ctx, { query: 'x', source_id: '__all__' });
expect(captured.opts?.sourceId).toBeUndefined();
expect(captured.opts?.sourceIds).toBeUndefined();
});
test('local + all_sources=true spans the whole brain', async () => {
const { ctx, captured } = makeCtx(false);
await searchOp.handler(ctx, { query: 'x', all_sources: true });
expect(captured.opts?.sourceId).toBeUndefined();
expect(captured.opts?.sourceIds).toBeUndefined();
});
test('explicit source_id wins over ctx.sourceId', async () => {
const { ctx, captured } = makeCtx(false);
await searchOp.handler(ctx, { query: 'x', source_id: 'wiki' });
expect(captured.opts?.sourceId).toBe('wiki');
});
test("remote + '__all__' collapses to the caller's grant (fail-closed)", async () => {
const { ctx, captured } = makeCtx(true, ['wiki', 'essays']);
await searchOp.handler(ctx, { query: 'x', source_id: '__all__' });
expect(captured.opts?.sourceIds).toEqual(['wiki', 'essays']);
});
test('remote + out-of-grant source_id is denied', async () => {
const { ctx } = makeCtx(true, ['wiki']);
await expect(searchOp.handler(ctx, { query: 'x', source_id: 'secrets' })).rejects.toThrow(
/outside your granted sources/,
);
});
});
+3 -5
View File
@@ -34,7 +34,7 @@ describe('v0.31.8 — voyage Content-Length pre-check + per-item cap', () => {
expect(source).toMatch(/MAX_VOYAGE_RESPONSE_BYTES\s*=\s*256\s*\*\s*1024\s*\*\s*1024/);
});
test('Layer 1: Content-Length pre-check fires BEFORE the body is read (D10 OOM defense)', async () => {
test('Layer 1: Content-Length pre-check fires BEFORE resp.clone().json() (D10 OOM defense)', async () => {
const source = await Bun.file(new URL('../src/core/ai/gateway.ts', import.meta.url)).text();
// Anchor relative to the post-fetch handler block. The function declaration
// contains an OUTBOUND request body section earlier; we want to verify
@@ -47,10 +47,8 @@ describe('v0.31.8 — voyage Content-Length pre-check + per-item cap', () => {
// doesn't pin to comment text.
const preCheckIdx = inboundBlock.indexOf("resp.headers.get('content-length')");
// Use the full lvalue assignment so the match doesn't accidentally hit
// comment text that mentions the body read for context. (#1610 moved the
// read from `resp.clone().json()` to a single `resp.text()` — bun <
// 1.1.27 truncates clone()d bodies, oven-sh/bun#6348.)
const jsonParseIdx = inboundBlock.indexOf('const bodyText = await resp.text()');
// comment text that mentions `await resp.clone().json()` for context.
const jsonParseIdx = inboundBlock.indexOf('const json: any = await resp.clone().json()');
expect(preCheckIdx).toBeGreaterThan(0);
expect(jsonParseIdx).toBeGreaterThan(0);
// The pre-check MUST appear before the JSON parse — otherwise the OOM