mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat: classify Anthropic prompt-too-long as UnrecoverableError
The subagent handler now detects 400 "prompt is too long" responses
from the Anthropic SDK and rethrows as UnrecoverableError. The worker
already routes UnrecoverableError straight to `dead`, so doomed jobs
fail terminally on first attempt instead of stalling 3x with the same
oversized prompt.
isPromptTooLongError matches the production message verbatim
("prompt is too long: N tokens > N maximum"), case-insensitive, on
both the outer message and inner error.message paths. Defensive
secondary match for status=400 + invalid_request_error/request_too_large
with the words "too long"/"exceed"/"maximum".
9 unit cases pin the detection: production wording, case folding,
nested SDK shape, defensive 400 paths, unrelated 400s, transient
errors, null/empty inputs.
* feat: model-aware chunking + slug-rewrite for dream synthesize
The synthesize phase now chunks oversized transcripts at paragraph
boundaries instead of submitting one giant prompt that 400s on
Anthropic. Closes the v0.30 dream-cycle queue clog where 1.7M-token
transcripts dead-lettered after 3 stalls and re-discovered every
cycle.
D1: per-chunk budget = floor(model_context_tokens × 0.9 × 3.5).
MODEL_CONTEXT_TOKENS keys on resolved Anthropic ids (Opus 4.7 = 1M,
Sonnet 4.6 = 200K, Haiku = 200K). Non-Anthropic models fall back to
180K-token safe default with a once-per-process stderr warning.
dream.synthesize.max_prompt_tokens overrides the model lookup
(token-shaped, name from PR #748, floor 100K).
D5: on max_chunks_per_transcript cap hit, log + skip; do NOT write to
dream_verdicts. Closes the cache-poisoning class — next cycle
re-attempts under whatever budget is then current.
D6: orchestrator-side deterministic slug rewrite, zero Sonnet trust.
collectChildPutPageSlugs raw-fetches every (job_id, slug) pair (no
SELECT DISTINCT — that erased the collision evidence the audit
claimed to detect) and rewrites bare-hash6 slugs to <hash6>-c<idx>
for chunked children.
D8: pre-fan-out lookup of completed legacy `dream:synth:<filePath>:
<hash16>` jobs. Transcripts already synthesized under the
single-chunk shape skip submission with `already_synthesized_legacy_
single_chunk` instead of resubmitting under chunked keys.
D9: hash-deterministic chunk boundaries. The 3-tier ladder lifted
from PR #748 (## Topic: > --- > nearest \\n) is fed a back-half
search-window offset derived from contentHash. Same content always
chunks identically across runs; chunk N of a previously-failed
transcript produces byte-identical content on retry.
D10: 24-chunk default cap, operator-configurable via
dream.synthesize.max_chunks_per_transcript.
18 unit cases pin the chunker (boundary ladder, hash determinism,
hard fallback, slug rewrite all 7 shapes). 4 PGLite E2E cases pin
fan-out shape (single-chunk legacy key parity, multi-chunk chunked
key shape) + skip paths (D5 cap hit no verdict-cache write, D8
legacy-key skip).
Credits PR #748 (Wintermute) for the boundary ladder, config key
naming, and 3.5 chars/token estimator. This branch supersedes #748
with the structural safeguards (model-aware budget, terminal-error
classify, slug rewrite, hash-determinism, doctor surfacing).
* feat: surface dead-lettered prompt_too_long jobs in doctor queue_health
queue_health gains a 4th subcheck counting dead `subagent` jobs in
the last 24h whose error_text starts with `prompt_too_long:`. When
present, prints a fix hint pointing at
`gbrain dream --phase synthesize --dry-run --json` to identify the
fat transcripts and naming the two operator escape hatches
(`dream.synthesize.max_prompt_tokens` for budget tuning,
larger-context model for capacity).
Operators now see the chunking failure mode without grepping
minion_jobs by hand.
* chore: bump version and changelog (v0.30.2)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: update README + CLAUDE.md for v0.30.2
- README dream help: 8-phase → 9-phase, mention v0.30.2 chunking + config keys
- CLAUDE.md synthesize.ts: chunker + per-chunk idempotency + D6 slug rewrite + D7 scope + D8 legacy-key
- CLAUDE.md subagent.ts: prompt_too_long terminal classification
- CLAUDE.md doctor.ts: queue_health subcheck 4 (dead-lettered prompt_too_long)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: regenerate llms-full.txt after v0.30.2 CLAUDE.md updates
The docs/ pass extended three Key Files entries in CLAUDE.md
(synthesize.ts, subagent.ts, doctor.ts). The auto-derived
llms-full.txt bundle picks up those CLAUDE.md changes via
build-llms; the build-llms test caught the drift in CI.
Generated by: bun run build:llms
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
/**
|
|
* E2E for v0.30.2 dream/synthesize chunking. PGLite, no API key required.
|
|
*
|
|
* Pre-seeds verdicts so the Haiku gate is bypassed; submits subagent jobs
|
|
* but never runs them (no worker spawned). Tests inspect minion_jobs to
|
|
* verify submission shape (chunk count, idempotency keys, skip-paths).
|
|
*
|
|
* Coverage:
|
|
* - D5 cap-hit: chunks > maxChunks → log + skip with no minion_jobs row
|
|
* and no dream_verdicts cache write (closes the poison-pill class).
|
|
* - D8 legacy single-chunk migration: pre-seed a `completed` legacy job
|
|
* for the same content hash → next synthesize skips submission.
|
|
* - Chunked path: fat transcript spawns N children with chunk-suffixed
|
|
* idempotency keys; single-chunk path keeps the legacy key shape.
|
|
*
|
|
* Run: bun test test/e2e/dream-synthesize-chunking.test.ts
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
|
import { tmpdir } from 'os';
|
|
import { join } from 'path';
|
|
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
|
import { runPhaseSynthesize } from '../../src/core/cycle/synthesize.ts';
|
|
|
|
interface TestRig {
|
|
engine: PGLiteEngine;
|
|
brainDir: string;
|
|
corpusDir: string;
|
|
cleanup: () => Promise<void>;
|
|
}
|
|
|
|
async function setupRig(): Promise<TestRig> {
|
|
const engine = new PGLiteEngine();
|
|
await engine.connect({ engine: 'pglite' } as never);
|
|
await engine.initSchema();
|
|
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-chunk-brain-'));
|
|
const corpusDir = mkdtempSync(join(tmpdir(), 'gbrain-chunk-corpus-'));
|
|
return {
|
|
engine,
|
|
brainDir,
|
|
corpusDir,
|
|
cleanup: async () => {
|
|
try { await engine.disconnect(); } catch { /* best-effort */ }
|
|
try { rmSync(brainDir, { recursive: true, force: true }); } catch { /* */ }
|
|
try { rmSync(corpusDir, { recursive: true, force: true }); } catch { /* */ }
|
|
},
|
|
};
|
|
}
|
|
|
|
async function withoutAnthropicKey<T>(body: () => Promise<T>): Promise<T> {
|
|
const saved = process.env.ANTHROPIC_API_KEY;
|
|
delete process.env.ANTHROPIC_API_KEY;
|
|
try {
|
|
return await body();
|
|
} finally {
|
|
if (saved === undefined) delete process.env.ANTHROPIC_API_KEY;
|
|
else process.env.ANTHROPIC_API_KEY = saved;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run `body` while a background loop force-cancels any subagent jobs the
|
|
* synthesize phase submits. Without a worker, those jobs would sit in
|
|
* `waiting` forever and runPhaseSynthesize's waitForCompletion blocks for
|
|
* 35 minutes. Cancelling moves them to a terminal state so the phase
|
|
* returns and we can inspect submission shape.
|
|
*/
|
|
async function withSubagentAutoCancel<T>(engine: PGLiteEngine, body: () => Promise<T>): Promise<T> {
|
|
let stopped = false;
|
|
const loop = (async () => {
|
|
while (!stopped) {
|
|
await new Promise(r => setTimeout(r, 50));
|
|
try {
|
|
await engine.executeRaw(
|
|
`UPDATE minion_jobs
|
|
SET status = 'cancelled', finished_at = now()
|
|
WHERE name = 'subagent' AND status IN ('waiting', 'active')`,
|
|
);
|
|
} catch {
|
|
// Race against shutdown is fine; ignore.
|
|
}
|
|
}
|
|
})();
|
|
try {
|
|
return await body();
|
|
} finally {
|
|
stopped = true;
|
|
await loop;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pre-seed a `worth_processing=true` verdict so the synthesize phase skips
|
|
* the Haiku call and proceeds directly to fan-out. Computes the hash the
|
|
* same way `discoverTranscripts` does (sha256 of content).
|
|
*/
|
|
async function seedVerdict(engine: PGLiteEngine, filePath: string, content: string): Promise<string> {
|
|
const { createHash } = await import('node:crypto');
|
|
const contentHash = createHash('sha256').update(content, 'utf8').digest('hex');
|
|
await engine.putDreamVerdict(filePath, contentHash, {
|
|
worth_processing: true,
|
|
reasons: ['seeded for chunking E2E test'],
|
|
});
|
|
return contentHash;
|
|
}
|
|
|
|
/**
|
|
* Resolve the absolute path the discover walker will see for a file in the
|
|
* corpus dir, since `discoverTranscripts` joins corpus + name.
|
|
*/
|
|
function corpusPath(corpusDir: string, basename: string): string {
|
|
return join(corpusDir, basename);
|
|
}
|
|
|
|
describe('E2E synthesize chunking — D5 cap hit', () => {
|
|
test('chunks > max_chunks_per_transcript → skipped with no jobs and no verdict-cache write', async () => {
|
|
const rig = await setupRig();
|
|
try {
|
|
// Tiny chunk budget (forces N chunks) + tiny cap (forces cap hit).
|
|
// 100K is the floor; even at the floor, 350K-char tester content
|
|
// chunks to ~1 chunk... we need budget below floor to force many
|
|
// chunks. Use the chunks_per_transcript cap instead.
|
|
await rig.engine.setConfig('dream.synthesize.enabled', 'true');
|
|
await rig.engine.setConfig('dream.synthesize.session_corpus_dir', rig.corpusDir);
|
|
await rig.engine.setConfig('dream.synthesize.max_prompt_tokens', '100000'); // floor → 350K char budget
|
|
await rig.engine.setConfig('dream.synthesize.max_chunks_per_transcript', '2');
|
|
|
|
// 1.5M chars → 5 chunks at 350K-char budget → exceeds cap=2.
|
|
const basename = '2026-05-08-fat-transcript.txt';
|
|
const filePath = corpusPath(rig.corpusDir, basename);
|
|
const content = 'fat transcript line\n'.repeat(75_000); // ~1.5M chars
|
|
writeFileSync(filePath, content);
|
|
await seedVerdict(rig.engine, filePath, content);
|
|
|
|
await withoutAnthropicKey(async () => {
|
|
const result = await runPhaseSynthesize(rig.engine, {
|
|
brainDir: rig.brainDir,
|
|
dryRun: false,
|
|
});
|
|
|
|
expect(result.status).toBe('ok');
|
|
const details = result.details as {
|
|
children_submitted: number;
|
|
skips: Array<{ filePath: string; reason: string }>;
|
|
};
|
|
expect(details.children_submitted).toBe(0);
|
|
expect(details.skips).toHaveLength(1);
|
|
expect(details.skips[0].filePath).toBe(filePath);
|
|
expect(details.skips[0].reason).toMatch(/oversize_after_split/);
|
|
});
|
|
|
|
// No subagent jobs submitted.
|
|
const jobs = await rig.engine.executeRaw<{ cnt: string | number }>(
|
|
`SELECT count(*) AS cnt FROM minion_jobs WHERE name = 'subagent'`,
|
|
);
|
|
expect(Number(jobs[0].cnt)).toBe(0);
|
|
|
|
// D5: dream_verdicts NOT written for the cap-hit path.
|
|
// Verify by re-reading the verdict — our seeded row is the ONLY entry.
|
|
const verdicts = await rig.engine.executeRaw<{ cnt: string | number }>(
|
|
`SELECT count(*) AS cnt FROM dream_verdicts`,
|
|
);
|
|
expect(Number(verdicts[0].cnt)).toBe(1); // only the seed; no cap-hit row added
|
|
} finally {
|
|
await rig.cleanup();
|
|
}
|
|
}, 30_000);
|
|
});
|
|
|
|
describe('E2E synthesize chunking — D8 legacy single-chunk migration', () => {
|
|
test('completed legacy idempotency key → skip submission entirely', async () => {
|
|
const rig = await setupRig();
|
|
try {
|
|
await rig.engine.setConfig('dream.synthesize.enabled', 'true');
|
|
await rig.engine.setConfig('dream.synthesize.session_corpus_dir', rig.corpusDir);
|
|
|
|
const basename = '2026-04-25-already-synthesized.txt';
|
|
const filePath = corpusPath(rig.corpusDir, basename);
|
|
const content = 'meaningful conversation lines\n'.repeat(200);
|
|
writeFileSync(filePath, content);
|
|
const contentHash = await seedVerdict(rig.engine, filePath, content);
|
|
|
|
// Pre-seed a completed `subagent` job at the legacy idempotency key.
|
|
const legacyKey = `dream:synth:${filePath}:${contentHash.slice(0, 16)}`;
|
|
await rig.engine.executeRaw(
|
|
`INSERT INTO minion_jobs (name, queue, status, idempotency_key, finished_at)
|
|
VALUES ('subagent', 'default', 'completed', $1, now())`,
|
|
[legacyKey],
|
|
);
|
|
|
|
await withoutAnthropicKey(async () => {
|
|
const result = await runPhaseSynthesize(rig.engine, {
|
|
brainDir: rig.brainDir,
|
|
dryRun: false,
|
|
});
|
|
const details = result.details as {
|
|
children_submitted: number;
|
|
skips: Array<{ reason: string }>;
|
|
};
|
|
expect(details.children_submitted).toBe(0);
|
|
expect(details.skips).toHaveLength(1);
|
|
expect(details.skips[0].reason).toBe('already_synthesized_legacy_single_chunk');
|
|
});
|
|
|
|
// No NEW subagent job: still exactly one (the seeded completed row).
|
|
const jobs = await rig.engine.executeRaw<{ cnt: string | number }>(
|
|
`SELECT count(*) AS cnt FROM minion_jobs WHERE name = 'subagent'`,
|
|
);
|
|
expect(Number(jobs[0].cnt)).toBe(1);
|
|
} finally {
|
|
await rig.cleanup();
|
|
}
|
|
}, 30_000);
|
|
});
|
|
|
|
describe('E2E synthesize chunking — fan-out shape', () => {
|
|
test('single-chunk transcript uses legacy idempotency key (parity on upgrade)', async () => {
|
|
const rig = await setupRig();
|
|
try {
|
|
await rig.engine.setConfig('dream.synthesize.enabled', 'true');
|
|
await rig.engine.setConfig('dream.synthesize.session_corpus_dir', rig.corpusDir);
|
|
// Default budget is plenty for 5KB content.
|
|
|
|
const basename = '2026-04-25-small.txt';
|
|
const filePath = corpusPath(rig.corpusDir, basename);
|
|
const content = 'small transcript content\n'.repeat(100); // ~2.5KB
|
|
writeFileSync(filePath, content);
|
|
const contentHash = await seedVerdict(rig.engine, filePath, content);
|
|
|
|
await withoutAnthropicKey(async () => {
|
|
await withSubagentAutoCancel(rig.engine, async () => {
|
|
const result = await runPhaseSynthesize(rig.engine, {
|
|
brainDir: rig.brainDir,
|
|
dryRun: false,
|
|
});
|
|
const details = result.details as { children_submitted: number };
|
|
expect(details.children_submitted).toBe(1);
|
|
});
|
|
});
|
|
|
|
const expectedKey = `dream:synth:${filePath}:${contentHash.slice(0, 16)}`;
|
|
const rows = await rig.engine.executeRaw<{ idempotency_key: string }>(
|
|
`SELECT idempotency_key FROM minion_jobs WHERE name = 'subagent' ORDER BY id`,
|
|
);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].idempotency_key).toBe(expectedKey);
|
|
// Specifically: legacy key shape has NO ":c<idx>of<n>" suffix.
|
|
expect(rows[0].idempotency_key).not.toMatch(/:c\d+of\d+$/);
|
|
} finally {
|
|
await rig.cleanup();
|
|
}
|
|
}, 30_000);
|
|
|
|
test('multi-chunk transcript spawns N children with chunk-suffixed idempotency keys', async () => {
|
|
const rig = await setupRig();
|
|
try {
|
|
await rig.engine.setConfig('dream.synthesize.enabled', 'true');
|
|
await rig.engine.setConfig('dream.synthesize.session_corpus_dir', rig.corpusDir);
|
|
// Floor at 100K tokens → 350K-char chunk budget. A 1.5M-char transcript
|
|
// chunks to ~5 chunks. Default cap is 24, so submission proceeds.
|
|
await rig.engine.setConfig('dream.synthesize.max_prompt_tokens', '100000');
|
|
|
|
const basename = '2026-05-08-fat.txt';
|
|
const filePath = corpusPath(rig.corpusDir, basename);
|
|
const content = 'fat transcript line with newline\n'.repeat(50_000); // ~1.65M chars
|
|
writeFileSync(filePath, content);
|
|
const contentHash = await seedVerdict(rig.engine, filePath, content);
|
|
const hash16 = contentHash.slice(0, 16);
|
|
|
|
await withoutAnthropicKey(async () => {
|
|
await withSubagentAutoCancel(rig.engine, async () => {
|
|
const result = await runPhaseSynthesize(rig.engine, {
|
|
brainDir: rig.brainDir,
|
|
dryRun: false,
|
|
});
|
|
const details = result.details as { children_submitted: number };
|
|
expect(details.children_submitted).toBeGreaterThan(1);
|
|
});
|
|
});
|
|
|
|
const rows = await rig.engine.executeRaw<{ idempotency_key: string }>(
|
|
`SELECT idempotency_key FROM minion_jobs WHERE name = 'subagent' ORDER BY id`,
|
|
);
|
|
expect(rows.length).toBeGreaterThan(1);
|
|
// Every key matches the chunked shape `dream:synth:<path>:<hash16>:c<i>of<N>`.
|
|
for (const r of rows) {
|
|
expect(r.idempotency_key).toMatch(
|
|
new RegExp(`^dream:synth:${escapeRe(filePath)}:${hash16}:c\\d+of\\d+$`),
|
|
);
|
|
}
|
|
// Chunk indices are unique 0..N-1.
|
|
const indices = rows
|
|
.map(r => /:c(\d+)of/.exec(r.idempotency_key)?.[1])
|
|
.map(s => Number(s))
|
|
.sort((a, b) => a - b);
|
|
const expected = Array.from({ length: rows.length }, (_, i) => i);
|
|
expect(indices).toEqual(expected);
|
|
} finally {
|
|
await rig.cleanup();
|
|
}
|
|
}, 30_000);
|
|
});
|
|
|
|
function escapeRe(s: string): string {
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|