mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(brainstorm): T1 cost guardrails + judge chunking + far-set cap Ports PR #1234 with a typed-error swap (Q2). Brings: - `--max-cost`, `--max-far-set`, `--strict-budget`, `--judge-model`, `--max-ideas-per-judge-call` CLI flags on `gbrain brainstorm` / `lsd` - Domain-bank prefix-cap + shuffle + final-trim to `m` by distance score - Judge auto-chunks idea sets > 100 across multiple LLM calls - UTF-16 surrogate sanitization on cross prompts - Phase-0.5 hard cost ceiling + mid-run cost guard Phase-1 diff from PR #1234: per-cross error-rethrow uses inline typed `BudgetExhausted` instead of string-match on the error message. Phase 2 of the wave will move the class to `src/core/budget/budget-tracker.ts` and the orchestrator will import it. Postmortem doc + 12-case regression test included verbatim from #1234. T1 of the brainstorm cost cathedral plan (~/.claude/plans/system-instruction-you-are-working-rippling-moth.md). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(budget): T2 BudgetTracker + BudgetExhausted + audit-week helper The keystone primitive for the v0.37.x budget cathedral. One class, one typed error, one schema-stable audit JSONL. Replaces three parallel copies (brainstorm orchestrator inline class, cycle/budget-meter, eval-contradictions cost-prompt/tracker) — those adapt to this one in T5/T6. Contracts pinned by 26 unit tests: - TX1: record() throws BudgetExhausted(reason:'cost') when cumulative spend > cap. A single underestimated call cannot leak past the cap. - TX2: reserve() hard-fails with BudgetExhausted(reason:'no_pricing') when cap is set + model is missing from pricing maps. When cap is unset, legacy warn-once behavior is preserved. - A3 amended: extractUsageFromError(err, fallback) returns err.usage when SDK provides it, else the pessimistic fallback (caller passes maxOutputTokens, not the optimistic pre-call estimate). - onExhausted callback fires once, synchronously, before the throw propagates. Callbacks do sync I/O (writeFileSync) for checkpoint persistence. - Audit JSONL is schema-stable: every line carries schema_version=1. Reorderings tolerated, field renames are breaking. Also ships src/core/audit-week-file.ts — the shared ISO-week filename helper consumed by every audit writer in T4. Year-boundary correctness pinned by 5 cases including 2020-W53 (the 53-week year), 2025-W01 rolling in from 2024-12-30 (Monday), and the GBRAIN_AUDIT_DIR override. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(gateway): T3 withBudgetTracker + AsyncLocalStorage composition TX5: every gateway.chat / embed / rerank call now auto-composes the active BudgetTracker via a module-internal AsyncLocalStorage. No per-call injection seam, no flag plumbing — callers wrap their entrypoint in `withBudgetTracker(tracker, async () => { ... })` and every downstream LLM call honors the cap. Outside any scope, the gateway is a budget no-op (back-compat with the pre-v0.37 contract). Wiring: - chat(): reserves on entry using prompt-char heuristic + opts.maxTokens. Records actual usage from result.usage on success; on failure, charges the pessimistic A3-amended fallback so the cap is real. - embed(): reserves total estimated input tokens (chars / chars-per-token). Records the same total in try/finally; SDK doesn't surface per-batch embed token counts. - rerank(): reserves and records query + docs char count. Reranker pricing isn't in the canonical map yet, so reserve() takes the warn-once path under no-cap and the TX2 hard-fail under cap. 6 unit cases pin the contract: chat auto-composes, outside-scope is no-op, nested scope restores outer, over-cap reserve throws BEFORE provider call (proves circuit breaker), TX1 mid-run cumulative cap fires via record(), parallel Promise.all scopes do not bleed trackers. All 255 existing gateway tests and 50 brainstorm tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(audit): T4 migrate 4 audit writers to shared isoWeekFilename helper Q1: extract the ISO-week filename math into one canonical helper (src/core/audit-week-file.ts, landed in T2) and migrate every audit JSONL writer in the codebase to consume it. Sites migrated: - src/core/minions/handlers/shell-audit.ts (shell-jobs-YYYY-Www.jsonl) - src/core/facts/phantom-audit.ts (phantoms-YYYY-Www.jsonl) - src/core/audit-slug-fallback.ts (slug-fallback-YYYY-Www.jsonl) - src/core/cycle/budget-meter.ts (dream-budget-YYYY-Www.jsonl) Each call site had its own copy of the ISO-week-from-Date algorithm. They mostly agreed but subtle drift was already accumulating (one used local time, one approximated the Thursday-anchor formula, etc.). One helper, one set of regression tests, no drift. Compute helpers (computeAuditFilename, computePhantomAuditFilename, computeSlugFallbackAuditFilename) are preserved as thin wrappers so existing import sites and tests don't break. All audit + slug-fallback + phantom + budget-meter tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cycle): T5 BudgetMeter schema_version=1 + golden fixture (A2 amended) Adapter pass: the existing BudgetMeter keeps its public shape (`BudgetMeter`, `SubmitEstimate`, `BudgetCheckResult`) verbatim so every dream-cycle call site keeps working without rewires. The audit JSONL grew one new field on every line: `schema_version: 1`. A2 amended: the codex outside-voice review relaxed the byte-stable contract to schema-stable. Field reorderings are tolerated; the documented set (schema_version, ts, phase, event, model, label, plus per-event cost or token fields) is what every consumer can rely on. Renames or removals are breaking. test/fixtures/dream-budget-schema-v1.jsonl carries one canonical row per event variant (submit / submit_denied / submit_unpriced) as documentation of the schema. The new in-suite case in test/budget-meter.test.ts walks every emitted line and asserts the fields are present + the right type. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(eval): T6 wrap eval-contradictions runner in withBudgetTracker The runner now installs a BudgetTracker scope around its body so every gateway-layer chat / embed / rerank call (the judge model + per-query embedding) auto-records via the AsyncLocalStorage from T3. Currently telemetry-only — the existing CostTracker remains the primary soft- ceiling enforcement, so the public --budget-usd surface and PreFlightBudgetError shape are byte-identical. The wiring is the seam: future waves can promote the cap to BudgetTracker semantics (TX1 + TX2 semantics on cumulative + no_pricing) by passing maxCostUsd through to BudgetTracker without touching the CLI. All 79 eval-contradictions tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(doctor): T7 --remediate budget tracker + checkpoint + --resume (A4) A4 amended: doctor --remediate gains a resumable cost ceiling. The runRemediate loop now runs inside `withBudgetTracker(tracker, ...)` so every gateway-routed LLM call inside a Minion handler (synthesize, patterns, consolidate, embed) honors the cap. When BudgetExhausted fires mid-run, the onExhausted callback persists a checkpoint of completed step ids + idempotency_keys to ~/.gbrain/remediation/<plan_hash>.json BEFORE the throw propagates, and the catch surfaces a paste-ready --resume hint. Wire-up: - New --resume <plan_hash> flag (with implicit "most recent matching" when no hash given) loads the checkpoint and skips already- completed steps. Mismatched plan_hash refuses with an explicit message. - --max-cost is now an alias for --max-usd. Both spellings honored and threaded through to BudgetTracker.maxCostUsd so the cap is a real ceiling, not just pre-flight advice. - On BudgetExhausted, exit 1 with the resume hint; on clean completion, clear the checkpoint. New file: src/core/remediation-checkpoint.ts with computePlanHash / save / load / list / clear helpers. Atomic write via .tmp + rename. Pinned by 13 unit cases including determinism + sort-order invariance + schema-mismatch return-null + atomic-rename. All 48 doctor.test.ts cases still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(subagent): T8 A1 ordering ASCII diagram before acquireLease Documents the load-bearing ordering invariant: the gateway's BudgetTracker reserve() runs (implicitly, via AsyncLocalStorage) BEFORE acquireLease() inside the subagent loop. A BudgetExhausted throw must NOT consume a rate-lease slot, because the lease is the rate-limit pacer for the entire fleet. The handler body intentionally does NOT explicitly thread BudgetTracker; TX5 (gateway-layer composition) handles that. The comment is the reader's signpost. No behavioral change. All 58 subagent tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(diarize): T9 payload-fitter (P6) with batch + summarize + gate Generic utility for fitting arbitrarily-large item lists into a downstream caller's per-call token budget. Two strategies: - 'batch': deterministic token-budgeted chunking. No LLM calls. The fitted list shape matches the input; the caller decides how to consume it (e.g. brainstorm judge concatenates per-chunk results). Surfaces a `dropped` count for items that exceed the per-call cap. - 'summarize': embed-cluster into ceil(items/4) groups via cheap deterministic nearest-neighbor on cosine; Haiku-summarize each cluster via Promise.allSettled at parallelism=4 (Perf1). Each Haiku call composes the active BudgetTracker via the gateway's AsyncLocalStorage scope (T3) — no per-call injection. Quality gate (codex outside-voice finding #4): when summarize's success_ratio < min_success_ratio (default 0.75), the result is flagged `degraded: true` so the caller (brainstorm) can decide to surface a partial result or abort. The fitter itself preserves the successful subset either way. Tested via 4 cases across two files (T3 contract): - happy path (all clusters succeed → degraded=false) - partial failure tolerated (1/5 fails, success_ratio=0.8 > 0.75 → degraded=false) - high-failure rate flips the gate (3/5 fails → degraded=true) - budget-respecting (BudgetExhausted thrown mid-cluster propagates via Promise.allSettled) 11 unit cases across batch + summarize. Brainstorm + cost-guardrails tests still green; judges.ts internal chunking deferred to a follow-up wave (TODOS) so the existing chunked-batch contract stays byte-stable during this drop. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(brainstorm): T10 checkpoint + --resume with full idea bodies (P7) The brainstorm cathedral capstone. Crashed runs can resume cleanly via `gbrain brainstorm --resume <run_id>` (and `gbrain lsd --resume` etc). TX3 load-bearing contract: completed_crosses on disk carries FULL idea bodies (~50KB per run), not just counts. The resumed BrainstormResult contains the pre-crash ideas (loaded from disk) merged with the post- resume ideas — codex's outside-voice finding was that a resume that produces only "what we generated this run" is silent partial output. TX4 single rule: --resume continues any cross not in completed_crosses. The proposed --retry-failed was dropped per codex review; failed AND never-attempted crosses both go through --resume. A5 amended: run_id = sha256(question + profile + sort(close_slugs) + sort(far_slugs)).slice(0,16). NO embedding bits — stable across embedding-model swaps. 7-day mtime-based GC. Q2 fold: orchestrator.ts drops its inline BudgetExhausted class and re-exports the canonical one from src/core/budget/budget-tracker.ts (Phase 2). runBrainstorm now wraps the body in withBudgetTracker so every gateway-layer chat call auto-records cost. The cap remains opts.maxCostUsd (default $5). New CLI flags: --resume <run_id> Continue any cross not in completed_crosses. Refuses to start when run_id doesn't match the active inputs (paste-ready hint). --force-resume Bypass the 7-day staleness gate. --list-runs Print saved run_ids and exit. Cycle purge phase (the 9th cycle phase) now also GCs stale brainstorm checkpoints alongside op_checkpoints (~7d window). Tests: - 20 unit cases in test/brainstorm/checkpoint.test.ts: computeRunId is deterministic + slug-array-order invariant + stable across embedding-model swaps; round-trip preserves ideas verbatim; saveCheckpoint atomic via .tmp+rename; loadCheckpoint returns null on missing/schema-mismatch/corrupt-JSON; gcStaleCheckpoints unlinks >N days; listRuns mtime-ordered. - 3 E2E cases in test/e2e/brainstorm-resume.test.ts: crash on cross 4 → first run aborts with checkpoint of crosses 1..N with full idea bodies; second run with resumeRunId merges pre-crash + post-resume ideas (TX3 contract); mismatched run_id refuses with paste-ready hint. The PGLite schema-gap workaround in the E2E (CREATE VIEW page_links AS SELECT * FROM links) is filed as a follow-up in TODOS T12 — the real-engine brainstorm path needs that view to materialize as a canonical schema fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: T11 + T12 wave release docs + deferred follow-ups CHANGELOG entry for the brainstorm cost cathedral (Unreleased slot; /ship will assign the next version): - ELI10 lead per CLAUDE.md voice rules - "How to turn it on" with paste-ready commands - "Things to watch" calls out the A4 semantic shift for `doctor --remediate --max-usd` (pre-flight → mid-run abort with resumable checkpoint) - Itemized changes by file/area - "For contributors" section noting the 73 new tests + the PGLite schema-gap workaround for the E2E CLAUDE.md Key Files: 6 new entries for budget-tracker, audit-week-file, gateway withBudgetTracker, payload-fitter, brainstorm/checkpoint, remediation-checkpoint. Regenerated llms-full.txt + llms.txt (passes test/build-llms.test.ts). docs/incidents/2026-05-20-lsd-cost-explosion.md gains a closing "Shipped in v0.37.x (the budget cathedral wave)" section listing P1-P7 completion status + the deferred follow-ups so the incident's audit trail closes the loop. TODOS.md gets a new top section for the wave's deferred items: - PGLite `page_links` schema gap fix - Explicit --max-cost on extract / enrich / integrity auto - P5 config-schema budgets: block in ~/.gbrain/config.json - Multi-day brainstorm resume (>7d) - Async-batched audit writes (profiling trigger criterion) - BudgetLedger unification with BudgetTracker - judges.ts internal chunking → payload-fitter delegation Also: fixed a payload-fitter typecheck error (ChatFn import). Final typecheck is clean on every file the wave touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(schema): F1 page_links view alias for both engines Brainstorm's domain-bank queries reference `page_links` (pglite-engine.ts:896, postgres-engine.ts:959) but the canonical table is `links`. Without the alias view, `gbrain brainstorm` against PGLite fails with `relation "page_links" does not exist`; the same was a latent bug on Postgres. This commit lands the fix at three sites: 1. `src/core/pglite-schema.ts` — embedded schema bundle gets the view at table-bundle time, so fresh PGLite installs are correct from boot. 2. `src/core/migrate.ts` v81 (`page_links_view_alias`) — existing brains on either engine pick up the view via `gbrain apply-migrations`. CREATE OR REPLACE VIEW is idempotent; re-running is safe. 3. `test/e2e/brainstorm-resume.test.ts` — removed the ad-hoc workaround view from the test setup. The E2E now exercises the same schema path real users will see. `TODOS.md` entry for the gap closed out. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(brainstorm): F2 pre-flight --max-cost refusal smoke E2E Pins the user-facing path that closed the original \$50 incident: when the pre-run estimate exceeds the configured cap, runBrainstorm throws BudgetExhausted with reason='cost' and a paste-ready hint pointing at --limit / --max-cost / --max-far-set before any chat call happens. The four assertions are the four things a real user can verify after the throw lands: 1. Typed BudgetExhausted (not a generic Error) 2. reason === 'cost' (not runtime or no_pricing) 3. Message names the remediation flags 4. No provider HTTP would have happened (chat.crossCalls === 0) Uses the same PGLite engine + tinyProfile + stub chatFn as the existing --resume tests. Hermetic; ~5s wallclock. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(reindex-code): F3 --max-cost flag via withBudgetTracker Wires gbrain reindex --code into the v0.38 budget cathedral. When the caller passes --max-cost N (or --max-cost-usd N), runReindexCode wraps its per-page import loop in withBudgetTracker so every gateway.embed() call inside importCodeFile auto-composes the cap. On BudgetExhausted, the partial-progress result reports what got reindexed before the cap fired plus a synthetic failure row naming the cap throw. reindex-code is idempotent (content_hash short-circuit in importCodeFile), so a re-run after a budget abort picks up where the cap fired — no manual checkpoint state needed. Both --max-cost and --max-cost-usd are accepted (symmetry with brainstorm which uses --max-cost, and a precedent for the spelling we want long-term). When --max-cost is unset, the body runs outside any tracker scope — byte- stable pre-F3 behavior for legacy callers. Files: src/commands/reindex-code.ts: - ReindexCodeOpts.maxCostUsd?: number - runReindexCode wraps body in withBudgetTracker when set - runReindexCodeCli parses --max-cost / --max-cost-usd - BudgetExhausted caught + returned as partial-progress result test/reindex-code-max-cost.serial.test.ts (NEW): - dry-run + maxCostUsd happy path - empty-brain + maxCostUsd hits early-return cleanly - no tracker installed when cap is unset (regression guard for the conditional wrap) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(schema): narrow page_links view projection to bootstrap-safe columns The v0.38 page_links view alias initially used SELECT * FROM links, which broke the pre-v0.13 bootstrap test: applyForwardReferenceBootstrap drops link_source + origin_page_id to simulate the pre-v0.13 schema shape, but the SELECT * view created a dependency that blocked the column DROP. Engine queries only reference pl.id (via COUNT(*)) and pl.to_page_id, so the view's projection is now SELECT id, from_page_id, to_page_id FROM links — what callers actually use, no more. This unblocks legacy-brain upgrade paths AND keeps the bootstrap forward-reference probes safe. Bootstrap suite: 15/15 pass after the change. Also files a P0 TODO for a pre-existing test failure (test/doctor-report-remote.test.ts "full report on healthy brain") that fails on master too — out of scope for this wave but noticed during /ship triage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version to v0.39.0.0 Brainstorm cost cathedral wave (P1-P7). MINOR bump per user direction: new architectural seam (gateway-layer BudgetTracker via AsyncLocalStorage), 5 new modules, new CLI flags (--max-cost / --resume / --list-runs / --force-resume), new migration v81 (page_links view alias). No breaking changes — BudgetExhausted re-exported from orchestrator for back-compat; --max-usd preserved as alias for --max-cost; eval-contradictions --budget-usd surface byte-identical. CHANGELOG entry renamed from [Unreleased] to [0.39.0.0] and adds the mandatory "To take advantage of v0.39.0.0" block per CLAUDE.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(isolation): rename 3 env-mutating tests to .serial.test.ts (CI fix) CI's `check:test-isolation` flagged three tests added in the v0.39.0.0 cathedral that directly mutate `process.env` across test boundaries: - test/brainstorm/checkpoint.test.ts (mutates GBRAIN_HOME) - test/core/audit-week-file.test.ts (mutates GBRAIN_AUDIT_DIR) - test/core/remediation-checkpoint.test.ts (mutates GBRAIN_HOME) Per CLAUDE.md rule R1: env-mutating tests either use withEnv() OR rename to *.serial.test.ts (the quarantine escape hatch). The mutation lives in beforeEach/afterEach which spans the whole describe block, so .serial rename is the cleaner fix — withEnv() would require restructuring every test. The serial-test runner gives them their own bun process; no cross- file env races. Verified: check:test-isolation passes (527 non-serial unit files clean), `bun run verify` passes, all 41 tests in the three renamed files pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
/**
|
||
* v0.37.x — T2 amended (TX3 load-bearing): brainstorm crash + --resume.
|
||
*
|
||
* Stub chatFn succeeds on the first N crosses and throws BudgetExhausted
|
||
* on cross N+1 (mid-run crash). First runBrainstorm aborts; reading the
|
||
* checkpoint shows full idea bodies for the completed crosses.
|
||
*
|
||
* Second runBrainstorm with resumeRunId continues from the next cross.
|
||
* **The merged BrainstormResult MUST contain the ideas from the
|
||
* pre-crash crosses (loaded from disk) AND the post-resume crosses.**
|
||
* This is the codex load-bearing finding — resume must produce correct
|
||
* output, not just "pick up where we left off".
|
||
*
|
||
* Schema note: pglite-engine.ts + postgres-engine.ts both query a
|
||
* `page_links` relation. v0.38 lands the `page_links` VIEW (alias of the
|
||
* canonical `links` table) in both the embedded PGLite schema bundle and
|
||
* Postgres migration v81. This test no longer needs a workaround view.
|
||
*/
|
||
|
||
import { describe, test, expect, beforeAll, beforeEach, afterAll, afterEach } from 'bun:test';
|
||
import { mkdtempSync, rmSync, existsSync, readdirSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
||
import type { ChunkInput } from '../../src/core/types.ts';
|
||
import {
|
||
runBrainstorm,
|
||
BRAINSTORM_PROFILE,
|
||
type BrainstormProfile,
|
||
BudgetExhausted,
|
||
} from '../../src/core/brainstorm/orchestrator.ts';
|
||
import {
|
||
loadCheckpoint,
|
||
} from '../../src/core/brainstorm/checkpoint.ts';
|
||
import type { ChatOpts, ChatResult } from '../../src/core/ai/gateway.ts';
|
||
|
||
let engine: PGLiteEngine;
|
||
let tmp: string;
|
||
let homeBackup: string | undefined;
|
||
|
||
function basisEmbedding(idx: number, dim = 1536): Float32Array {
|
||
const v = new Float32Array(dim);
|
||
v[idx % dim] = 1.0;
|
||
return v;
|
||
}
|
||
|
||
async function seedSmallBrain(): Promise<void> {
|
||
// 2 close + 4 far across 2 distinct prefixes.
|
||
const closeSlugs = ['wiki/close-a', 'wiki/close-b'];
|
||
const farSlugs = [
|
||
'concepts/decay-a',
|
||
'concepts/decay-b',
|
||
'people/founder-a',
|
||
'people/founder-b',
|
||
];
|
||
|
||
for (let i = 0; i < closeSlugs.length; i++) {
|
||
const slug = closeSlugs[i];
|
||
await engine.putPage(slug, {
|
||
type: 'note',
|
||
title: `Close ${slug}`,
|
||
compiled_truth: `resume merge crash question test fixture body for close anchor ${slug}`,
|
||
timeline: '',
|
||
});
|
||
await engine.upsertChunks(slug, [
|
||
{
|
||
chunk_index: 0,
|
||
chunk_text: `resume merge crash question test ${slug}`,
|
||
chunk_source: 'compiled_truth',
|
||
embedding: basisEmbedding(10 + i),
|
||
token_count: 6,
|
||
},
|
||
] satisfies ChunkInput[]);
|
||
}
|
||
|
||
for (let i = 0; i < farSlugs.length; i++) {
|
||
const slug = farSlugs[i];
|
||
await engine.putPage(slug, {
|
||
type: 'note',
|
||
title: `Far ${slug}`,
|
||
compiled_truth: `Far content for ${slug}: distant cross-domain body.`,
|
||
timeline: '',
|
||
});
|
||
await engine.upsertChunks(slug, [
|
||
{
|
||
chunk_index: 0,
|
||
chunk_text: `cross-domain text ${slug}`,
|
||
chunk_source: 'compiled_truth',
|
||
embedding: basisEmbedding(200 + i),
|
||
token_count: 6,
|
||
},
|
||
] satisfies ChunkInput[]);
|
||
}
|
||
}
|
||
|
||
beforeAll(async () => {
|
||
engine = new PGLiteEngine();
|
||
await engine.connect({});
|
||
await engine.initSchema();
|
||
// page_links view is provided by the embedded schema bundle (v0.38).
|
||
await seedSmallBrain();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await engine.disconnect();
|
||
});
|
||
|
||
beforeEach(() => {
|
||
tmp = mkdtempSync(join(tmpdir(), 'gbrain-resume-e2e-'));
|
||
homeBackup = process.env.GBRAIN_HOME;
|
||
process.env.GBRAIN_HOME = tmp;
|
||
});
|
||
|
||
afterEach(() => {
|
||
if (homeBackup === undefined) delete process.env.GBRAIN_HOME;
|
||
else process.env.GBRAIN_HOME = homeBackup;
|
||
rmSync(tmp, { recursive: true, force: true });
|
||
});
|
||
|
||
function makeChatFnMixed(failOnCrossCallN: number) {
|
||
let crossCalls = 0;
|
||
let judgeCalls = 0;
|
||
const fn = async (opts: ChatOpts): Promise<ChatResult> => {
|
||
const userMsg = opts.messages.find((m) => m.role === 'user');
|
||
const content = typeof userMsg?.content === 'string' ? userMsg.content : '';
|
||
// Judge prompts include "(close=... × far=...)" lines below each `## Idea`
|
||
// heading; cross prompts only contain `## Idea 1` / `## Idea 2` as format
|
||
// instructions.
|
||
const isJudge = /\(close=.* × far=.*\)/.test(content);
|
||
if (isJudge) {
|
||
judgeCalls++;
|
||
const ideaIds = Array.from(content.matchAll(/## Idea (\S+)/g)).map((m) => m[1] as string);
|
||
const json = {
|
||
ideas: ideaIds.map((id) => ({
|
||
id,
|
||
scores: { originality: 4, resistance: 4, thesis_density: 4, concrete_grounding: 4, cognitive_load: 4 },
|
||
note: 'mock judge',
|
||
})),
|
||
};
|
||
const text = '```json\n' + JSON.stringify(json) + '\n```';
|
||
return {
|
||
text,
|
||
blocks: [{ type: 'text', text }],
|
||
stopReason: 'end',
|
||
model: 'claude-sonnet-4-6',
|
||
providerId: 'fake',
|
||
usage: { input_tokens: 200, output_tokens: 100, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
||
};
|
||
}
|
||
crossCalls++;
|
||
if (crossCalls === failOnCrossCallN) {
|
||
throw new BudgetExhausted(
|
||
`synthetic mid-run crash on cross call ${crossCalls}`,
|
||
{ reason: 'cost', spent: 1.5, cap: 1.0 },
|
||
);
|
||
}
|
||
const closeMatch = content.match(/\[(wiki\/close-[ab])\]/);
|
||
const farMatch = content.match(/\[((?:concepts|people)\/[\w-]+)\]/);
|
||
const closeSlug = closeMatch?.[1] ?? 'unknown';
|
||
const farSlug = farMatch?.[1] ?? 'unknown';
|
||
const ideaText = `IDEA-FOR-${closeSlug}--${farSlug}--call${crossCalls}`;
|
||
const text = `1. ${ideaText}\n2. backup idea ${crossCalls}\n3. extra idea ${crossCalls}`;
|
||
return {
|
||
text,
|
||
blocks: [{ type: 'text', text }],
|
||
stopReason: 'end',
|
||
model: 'claude-haiku-4-5-20251001',
|
||
providerId: 'fake',
|
||
usage: { input_tokens: 100, output_tokens: 50, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
||
};
|
||
};
|
||
return { fn, get crossCalls() { return crossCalls; }, get judgeCalls() { return judgeCalls; } };
|
||
}
|
||
|
||
const tinyProfile: BrainstormProfile = {
|
||
...BRAINSTORM_PROFILE,
|
||
k_close: 2,
|
||
m_far: 4,
|
||
ideas_per_cross: 1,
|
||
};
|
||
|
||
describe('brainstorm --resume (TX3 load-bearing)', () => {
|
||
test('crash on cross 4 → first run aborts, checkpoint has crosses 1..N with full idea bodies', async () => {
|
||
const chat1 = makeChatFnMixed(4);
|
||
let err1: unknown = null;
|
||
try {
|
||
await runBrainstorm(engine, {}, {
|
||
question: 'test resume crash question',
|
||
profile: tinyProfile,
|
||
skipCostPreview: true,
|
||
maxCostUsd: 100,
|
||
chatFn: chat1.fn,
|
||
embedQueryFn: async () => basisEmbedding(0),
|
||
stderrWrite: () => {},
|
||
});
|
||
} catch (e) {
|
||
err1 = e;
|
||
}
|
||
expect(err1).toBeInstanceOf(BudgetExhausted);
|
||
|
||
const dir = join(tmp, '.gbrain', 'brainstorm');
|
||
expect(existsSync(dir)).toBe(true);
|
||
const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
||
expect(files.length).toBe(1);
|
||
const runId = files[0].replace(/\.json$/, '');
|
||
const cp = loadCheckpoint(runId);
|
||
expect(cp).not.toBeNull();
|
||
expect(cp!.completed_crosses.length).toBeGreaterThanOrEqual(1);
|
||
// TX3 load-bearing — full idea bodies, not just counts.
|
||
for (const cc of cp!.completed_crosses) {
|
||
expect(cc.ideas.length).toBeGreaterThanOrEqual(1);
|
||
expect(cc.ideas[0].text.length).toBeGreaterThan(0);
|
||
}
|
||
});
|
||
|
||
test('second run with resumeRunId merges pre-crash ideas with post-resume ideas (TX3 contract)', async () => {
|
||
// First run: crash on cross 4 (mid-loop).
|
||
const chat1 = makeChatFnMixed(4);
|
||
try {
|
||
await runBrainstorm(engine, {}, {
|
||
question: 'test resume merge question',
|
||
profile: tinyProfile,
|
||
skipCostPreview: true,
|
||
maxCostUsd: 100,
|
||
chatFn: chat1.fn,
|
||
embedQueryFn: async () => basisEmbedding(0),
|
||
stderrWrite: () => {},
|
||
});
|
||
} catch {
|
||
// expected
|
||
}
|
||
const dir = join(tmp, '.gbrain', 'brainstorm');
|
||
const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
||
expect(files.length).toBe(1);
|
||
const runId = files[0].replace(/\.json$/, '');
|
||
const cpBefore = loadCheckpoint(runId)!;
|
||
const preCrashIdeaTexts = cpBefore.completed_crosses.flatMap((cc) => cc.ideas.map((i) => i.text));
|
||
expect(preCrashIdeaTexts.length).toBeGreaterThanOrEqual(1);
|
||
|
||
// Second run: no crash, no failures.
|
||
const chat2 = makeChatFnMixed(99999);
|
||
const result = await runBrainstorm(engine, {}, {
|
||
question: 'test resume merge question',
|
||
profile: tinyProfile,
|
||
skipCostPreview: true,
|
||
maxCostUsd: 100,
|
||
chatFn: chat2.fn,
|
||
embedQueryFn: async () => basisEmbedding(0),
|
||
stderrWrite: () => {},
|
||
resumeRunId: runId,
|
||
});
|
||
|
||
// TX3: every pre-crash idea text from disk MUST appear in the
|
||
// merged result. Resume cannot drop them silently.
|
||
const allIdeaTexts = result.ideas.map((i) => i.text);
|
||
for (const pre of preCrashIdeaTexts) {
|
||
expect(allIdeaTexts).toContain(pre);
|
||
}
|
||
|
||
// Total idea count: profile is k_close=2, m_far=4, ideas_per_cross=1
|
||
// → 8 ideas in a clean run. The judge may filter; check raw count
|
||
// by total entries in BrainstormResult.ideas.
|
||
expect(result.ideas.length).toBe(8);
|
||
|
||
// After clean completion the checkpoint is cleared.
|
||
expect(readdirSync(dir).filter((f) => f.endsWith('.json')).length).toBe(0);
|
||
});
|
||
|
||
test('resumeRunId with mismatched id refuses with paste-ready hint', async () => {
|
||
const chat = makeChatFnMixed(99999);
|
||
let caught: unknown = null;
|
||
try {
|
||
await runBrainstorm(engine, {}, {
|
||
question: 'mismatch test question',
|
||
profile: tinyProfile,
|
||
skipCostPreview: true,
|
||
chatFn: chat.fn,
|
||
embedQueryFn: async () => basisEmbedding(0),
|
||
stderrWrite: () => {},
|
||
resumeRunId: 'deadbeefcafe0000',
|
||
});
|
||
} catch (e) {
|
||
caught = e;
|
||
}
|
||
expect(caught).toBeInstanceOf(Error);
|
||
expect((caught as Error).message).toMatch(/--resume run_id=deadbeefcafe0000 does not match/);
|
||
});
|
||
});
|
||
|
||
// F2 smoke test: end-to-end --max-cost pre-flight refusal. The user-facing
|
||
// path is "estimate exceeds cap, run aborts before any LLM call". This pins
|
||
// the (a) typed-throw, (b) reason='cost', (c) paste-ready error message
|
||
// content, and (d) that no chatFn calls happen during pre-flight.
|
||
describe('brainstorm --max-cost pre-flight refusal (F2 smoke)', () => {
|
||
test('estimate above cap → BudgetExhausted(reason="cost") before any chat call', async () => {
|
||
const chat = makeChatFnMixed(99999);
|
||
let caught: unknown = null;
|
||
try {
|
||
await runBrainstorm(engine, {}, {
|
||
question: 'pre-flight cap smoke question',
|
||
profile: tinyProfile,
|
||
skipCostPreview: true,
|
||
// Pre-run estimate is at the cents level; $0.0001 forces a refusal.
|
||
maxCostUsd: 0.0001,
|
||
chatFn: chat.fn,
|
||
embedQueryFn: async () => basisEmbedding(0),
|
||
stderrWrite: () => {},
|
||
});
|
||
} catch (e) {
|
||
caught = e;
|
||
}
|
||
expect(caught).toBeInstanceOf(BudgetExhausted);
|
||
const err = caught as BudgetExhausted;
|
||
expect(err.reason).toBe('cost');
|
||
// User-facing hint must point at remediation paths so the operator
|
||
// can fix forward without reading the source.
|
||
expect(err.message).toMatch(/exceeds --max-cost/);
|
||
expect(err.message).toMatch(/--limit/);
|
||
expect(err.message).toMatch(/--max-far-set/);
|
||
// No chat calls during pre-flight — the cap fires before any provider
|
||
// HTTP would happen on a real run.
|
||
expect(chat.crossCalls).toBe(0);
|
||
expect(chat.judgeCalls).toBe(0);
|
||
});
|
||
});
|