mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
* fix(patterns): make reflections/patterns slug sub-paths configurable gatherReflections()'s SQL WHERE clause and the pattern-page write slug were hardcoded to wiki/personal/reflections/ and wiki/personal/patterns/ respectively. A prior fix (#2415/#2939) made the leading namespace root configurable via dream.synthesize.output_root, but the personal/reflections and personal/patterns sub-path segments stayed pinned literals, so brains whose schema has no personal/ nesting (e.g. a flat meetings/ tree) could not point the phase at their own compiled_truth source. Adds two new config keys: - dream.patterns.source_slug_prefix (default: <output_root>/personal/reflections) - dream.patterns.output_slug_prefix (default: <output_root>/personal/patterns) Both default to the exact literal the code previously hardcoded, so existing installs see no behavior change. A custom output_slug_prefix is also added to the subagent's put_page allow-list, since the filing-rules JSON globs only remap the wiki/personal/patterns/* literal by output_root and would otherwise reject writes to a differently-shaped output path. Updated test/cycle-patterns.test.ts's scope-filter assertions to match; added coverage for the two new config keys and the allow-list addition. * fix(patterns): drain PGLite subagent job inline (no worker claims it) runPhasePatterns submitted a subagent job via queue.add() and waited on it via waitForCompletion, but on PGLite there is no separate Minions worker process (the embedded data-dir holds an exclusive file lock; 'gbrain jobs work' refuses to start against it). synthesize.ts already has runPgliteSubagentsInline to drive the claim -> run -> complete loop inline for exactly this reason; patterns.ts never called it, so a real (non-dry-run) invocation against a PGLite brain always hung until subagentWaitTimeoutMs (default 35 min) with the job stuck in 'waiting'. Exports runPgliteSubagentsInline from synthesize.ts (was test-only via __testing) and calls it from patterns.ts with the same private per-run childQueueName derivation synthesize.ts uses, so the inline drain never claims unrelated 'default'-queue jobs a Postgres worker owns. Updated test/cycle-patterns-child-outcome.test.ts's #2782 regression test: its premise (no worker running with a 1ms wait timeout, so the job never completes and waitForCompletion genuinely times out) is exactly the scenario this fix addresses. With the inline drain, a fake ANTHROPIC_API_KEY test fixture now gets claimed and actually attempted, failing fast and landing the job in 'dead' rather than staying uncompleted until a timeout. The #2782 status-reflects-outcome contract the test exists to pin is unchanged (any non-'complete' outcome with zero writes still surfaces as status 'fail'); updated the expected outcome/error code to match the outcome that now actually occurs. * feat(think): surface usage/cost_usd in --json output think's own cost was previously unsurfaced anywhere: not in this CLI's own --json output, not in budget_ledger (nothing in src/core/think/*.ts ever writes to it), and invisible to a wrapping caller's own token accounting since the LLM call think makes is its own, separate API call from anything the caller's session tracks. runThink() already captured result.usage.{input_tokens,output_tokens} from the underlying client.create() call but discarded it. Adds usage/cost_usd to ThinkResult, populates usage on the real-LLM-call path (undefined on the no-client/stub paths, matching how synthesisOk already distinguishes those), and computes cost_usd in think.ts's CLI handler via the existing canonicalLookup() pricing table (same pattern brain-score-recommendations.ts's estimateAnthropicCost already uses). Extracted the multiply-and-sum into a small exported computeThinkCostUsd for direct unit testing. Also appends the cost to the human-readable footer. Verified live: gbrain think --json against a real anchor returned usage:{input_tokens:3271,output_tokens:1490}, cost_usd:0.0536, matching Opus pricing ($5/$25 per MTok) by hand calculation.
107 lines
4.1 KiB
TypeScript
107 lines
4.1 KiB
TypeScript
/**
|
|
* #2782 — patterns phase status must reflect the child subagent outcome.
|
|
*
|
|
* Pre-fix, runPhasePatterns returned status:ok with child_outcome:timeout and
|
|
* zero pattern pages written (e.g. when no subagent-capable worker slot was
|
|
* free for the whole wait window) — a silent no-op for days.
|
|
*
|
|
* A later fix added runPgliteSubagentsInline to this phase (patterns.ts
|
|
* previously submitted a job and waited without anything ever claiming it on
|
|
* PGLite — synthesize.ts already had this inline drain, patterns.ts didn't).
|
|
* So a fake ANTHROPIC_API_KEY here now gets claimed and actually attempted;
|
|
* the real Anthropic call fails immediately, exhausting max_attempts and
|
|
* landing the job in 'dead' (not 'timeout' — nothing ever times out, the
|
|
* failure is immediate). The #2782 status-reflects-outcome contract this
|
|
* test exists to pin is unchanged: any non-'complete' outcome with zero
|
|
* writes must still surface as status 'fail', just under the outcome that
|
|
* actually occurs now that the job is drained instead of left stuck in
|
|
* 'waiting' for the full wait window.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
|
import { runPhasePatterns } from '../src/core/cycle/patterns.ts';
|
|
import { withEnv } from './helpers/with-env.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
let schemaVersion: string;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({ database_url: '' });
|
|
await engine.initSchema();
|
|
// resetPgliteState truncates `config`, wiping the `version` row that
|
|
// MinionQueue.ensureSchema checks. Capture it so beforeEach can restore.
|
|
schemaVersion = (await engine.getConfig('version')) ?? '7';
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
await engine.setConfig('version', schemaVersion);
|
|
});
|
|
|
|
async function seedReflections(): Promise<void> {
|
|
// Enough recent reflections to clear min_evidence (default 3).
|
|
for (let i = 0; i < 3; i++) {
|
|
await engine.executeRaw(
|
|
`INSERT INTO pages (slug, type, title, compiled_truth)
|
|
VALUES ($1, 'note', $2, $3)`,
|
|
[
|
|
`wiki/personal/reflections/2026-07-0${i + 1}-reflection`,
|
|
`Reflection ${i + 1}`,
|
|
`Recurring theme fixture number ${i + 1}.`,
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
describe('runPhasePatterns child-outcome status (#2782)', () => {
|
|
test('child dead with zero writes → status fail (was silent ok)', async () => {
|
|
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-patterns-outcome-'));
|
|
try {
|
|
await seedReflections();
|
|
|
|
const result = await withEnv({ ANTHROPIC_API_KEY: 'sk-ant-test' }, () =>
|
|
runPhasePatterns(engine, { brainDir, dryRun: false }),
|
|
);
|
|
|
|
expect(result.status).toBe('fail');
|
|
expect(result.details.child_outcome).toBe('dead');
|
|
expect(result.details.patterns_written).toBe(0);
|
|
expect(result.error?.code).toBe('PATTERNS_CHILD_DEAD');
|
|
expect(result.error?.class).toBe('InternalError');
|
|
} finally {
|
|
rmSync(brainDir, { recursive: true, force: true });
|
|
}
|
|
}, 60_000);
|
|
|
|
test('dream.patterns.subagent_timeout_ms flows to the submitted job', async () => {
|
|
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-patterns-timeout-'));
|
|
try {
|
|
await seedReflections();
|
|
await engine.setConfig('dream.patterns.subagent_timeout_ms', '600000');
|
|
await engine.setConfig('dream.patterns.subagent_wait_timeout_ms', '1');
|
|
|
|
await withEnv({ ANTHROPIC_API_KEY: 'sk-ant-test' }, () =>
|
|
runPhasePatterns(engine, { brainDir, dryRun: false }),
|
|
);
|
|
|
|
const jobs = await engine.executeRaw<{ timeout_ms: string | number | null }>(
|
|
`SELECT timeout_ms FROM minion_jobs WHERE name = 'subagent' ORDER BY id DESC LIMIT 1`,
|
|
);
|
|
expect(jobs).toHaveLength(1);
|
|
expect(Number(jobs[0]!.timeout_ms)).toBe(600000);
|
|
} finally {
|
|
rmSync(brainDir, { recursive: true, force: true });
|
|
}
|
|
}, 60_000);
|
|
});
|