mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
* feat(dream): --once for one-shot phase runs without toggling config gates Fixes the "toggle enabled true, run, toggle back to false" workaround that gbrain doctor's extract_atoms_backlog message implicitly recommends and that #2860's reporter had to script around: with an external orchestrator running `gbrain dream --phase patterns` on a cadence outside the autopilot, the only way to run patterns once was `config set dream.patterns.enabled true` -> run -> `config set ... false`. A crash between steps left the flag stuck true, and the autopilot (which polls the same flag) re-enqueued patterns every cycle -- 119 LLM jobs / ~$400 over 24h before it was caught. Root cause: `--phase X` only controls which phase FUNCTION cycle.ts calls; it does not bypass that phase's own `dream.<phase>.enabled` / `cycle.<phase>.enabled` config read. Each gated phase (patterns, synthesize, conversation_facts_backfill, enrich_thin, skillopt) reads its enabled flag internally and skips regardless of how the phase was selected -- confirmed by reading each phase module, not assumed. extract_atoms/synthesize_concepts are a DIFFERENT mechanism entirely (pack-declaration via packDeclaresPhase, not a config .enabled read) and already have a working one-shot escape hatch: `--drain`. The existing doctor message for extract_atoms already says `--phase extract_atoms --drain --window 120`, so no doctor text needed updating there -- verified by reading src/commands/doctor.ts directly rather than assuming the paraphrase in the issue was literal. Design: `gbrain dream --phase <name> --once`. Requires an explicit --phase (bare --once is a usage error, exit 2) so it can never force-enable every disabled phase at once in a full/default cycle -- that would recreate the same unbounded-spend risk the flag exists to prevent. Threaded through CycleOpts as `onceForPhase?: CyclePhase` (the literal phase name, not a boolean) so the bypass can never leak to a phase other than the one named, even if a future programmatic caller passes a wider `phases` array than the CLI does. Never reads or writes config -- the phase still evaluates its .enabled gate every call; --once only overrides the boolean OUTCOME for that one invocation, mirroring the existing --unsafe-bypass-dream-guard / --input precedents (stderr warning at the bypass point, no new config-touching code path). Rejected alternatives (documented per task instructions): - Making explicit --phase X always bypass .enabled: breaking change for existing crons that rely on the disabled flag as a cheap no-op; an upgrade would silently start running LLM/write phases. - A new subcommand: adds a whole dispatch/help/arg surface that internally routes through the same override anyway. - Extending --once to also bypass packDeclaresPhase for extract_atoms/synthesize_concepts: conflates two different gating mechanisms (config toggle vs. pack membership) under one flag; extract_atoms already has --drain, which is purpose-built for its batched/windowed execution model. Design was cross-validated by an independent second-model review (external design consultation) before implementation; its recommendation to also update the extract_atoms doctor message to `--once` was NOT adopted because that phase has no .enabled gate to bypass -- doing so would be a documented no-op, contradicted by reading src/commands/doctor.ts:3264 directly. Tests: 9 new (structural CLI-flag wiring in dream-cli-flags.test.ts; a real PGLite E2E test in dream-patterns-pglite.test.ts proving the bypass fires AND that dream.patterns.enabled is never written; 4 runCycle-level tests in cycle.serial.test.ts proving onceForPhase does not leak across phases). Verified 6 of 9 fail against the pre-fix source (via git stash of source-only changes) to confirm they're meaningful regressions, not tautologies. Closes #2860 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(dream): --help short-circuits before --once usage validation Codex review finding (P2): `gbrain dream --help --once` (no --phase) called process.exit(2) from the new --once usage-error check inside parseArgs before runDream's documented IRON RULE ("--help short-circuits BEFORE any engine-bearing work") ever got a chance to run -- parseArgs computes ALL its validations unconditionally before runDream checks opts.help. Repo precedent for this ordering already exists as a pinned regression test (test/dream.test.ts's "--help --source whatever prints help and exits 0"). Fix: compute wantsHelp once in parseArgs and exempt the --once validation when it's set, mirroring that precedent. Added the same class of pinned tests here: bare `--once` still exits 2 with the usage hint, `--help --once` prints help and exits 0, and a real --phase patterns --once run against a PGLite engine proves the bypass actually fires (falls through to insufficient_evidence instead of disabled) without writing dream.patterns.enabled. Also fixed the structural test in dream-cli-flags.test.ts that asserted the exact pre-fix guard-condition source text. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(dream): --once must require an EXPLICIT --phase, not a derived one Codex review finding (P3): the --once validation checked the derived `phase` value, but `phase` gets defaulted implicitly by --input (implies --phase synthesize) and --drain (implies --phase extract_atoms) BEFORE that check ran. So `gbrain dream --input <f> --once` and `gbrain dream --drain --once` both slipped past the "explicit --phase required" contract silently -- and --once became a true no-op in both cases: --drain returns from runDream before onceForPhase is ever read (the drain path doesn't call runCycle at all), and --input already bypasses the synthesize enabled-gate on its own via the existing opts.inputFile check, so onceForPhase would never even be consulted. Fix: capture `phaseWasExplicit = phaseIdx !== -1` at the very top of parseArgs, before the --input/--drain defaulting blocks run, and validate --once against that instead of the derived `phase`. Updated the usage-error message and --help text to say "an explicit --phase" so a user hitting this understands why `--input ... --once` doesn't count. Tests: 2 new pins in test/dream.test.ts exercising runDream directly (--input <file> --once exits 2; --drain --once exits 2), plus a structural test in dream-cli-flags.test.ts pinning that phaseWasExplicit is captured before both implicit-defaulting blocks. Updated the two existing structural/behavioral tests whose literal guard-condition / error-message assertions changed shape. Verified: dream-cli-flags.test.ts 27/27, dream.test.ts 31/31, typecheck clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: masashiono0611 <masashi.ono.0611@gmail.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Garry Tan <garrytan@gmail.com>
278 lines
9.7 KiB
TypeScript
278 lines
9.7 KiB
TypeScript
/**
|
|
* v0.41.39 (issue #1700) — cycle phase `enrich_thin`.
|
|
*
|
|
* Opt-in autopilot trickle around `runEnrichCore`. Default OFF; enable with
|
|
* `gbrain config set cycle.enrich_thin.enabled true`. Each tick develops a few
|
|
* thin (stub) pages per source so the brain gets smarter over time, not just
|
|
* bigger — the issue's explicit payoff.
|
|
*
|
|
* Architecture mirrors `conversation-facts-backfill.ts` (the precedent):
|
|
*
|
|
* - Per-source iteration HERE. PHASE_SCOPE='source' is taxonomy-only (no
|
|
* runtime fan-out exists yet); the wrapper loops `listSources(engine)`.
|
|
* - ONE brain-wide BudgetTracker per tick, passed into every per-source
|
|
* `runEnrichCore` via `opts.budgetTracker` so the core uses it as-is (no
|
|
* nested `withBudgetTracker`, which would REPLACE the brain-wide cap).
|
|
* - Brain-wide walltime cap checked between sources.
|
|
* - Small per-source page cap (`max_pages_per_tick`, default 3) so a tick
|
|
* trickles rather than draining the whole stub backlog at once.
|
|
*
|
|
* Config keys (defaults explicit):
|
|
* cycle.enrich_thin.enabled (false)
|
|
* cycle.enrich_thin.max_cost_usd (1.00) per source per tick
|
|
* cycle.enrich_thin.max_total_cost_usd (5.00) brain-wide per tick
|
|
* cycle.enrich_thin.max_total_walltime_min (30) brain-wide per tick
|
|
* cycle.enrich_thin.max_pages_per_tick (3) per source per tick
|
|
* cycle.enrich_thin.types (["person","company"])
|
|
* cycle.enrich_thin.order ("inbound-links")
|
|
* cycle.enrich_thin.workers (1)
|
|
* cycle.enrich_thin.model (configured chat model)
|
|
*/
|
|
|
|
import type { BrainEngine } from '../engine.ts';
|
|
import type { PageType } from '../types.ts';
|
|
import { BudgetExhausted } from '../budget/budget-tracker.ts';
|
|
import { isAvailable } from '../ai/gateway.ts';
|
|
import { listSources } from '../sources-ops.ts';
|
|
import {
|
|
runEnrichCore,
|
|
DEFAULT_TYPES,
|
|
ENRICH_ORDERS,
|
|
type EnrichOrder,
|
|
type EnrichResult,
|
|
} from '../../commands/enrich.ts';
|
|
|
|
export interface EnrichThinPhaseOpts {
|
|
dryRun?: boolean;
|
|
signal?: AbortSignal;
|
|
/**
|
|
* issue #2860 — `gbrain dream --phase enrich_thin --once`. Bypasses the
|
|
* `cycle.enrich_thin.enabled` gate for THIS call only; never reads or
|
|
* writes config. Per-source + brain-wide cost/walltime caps still apply.
|
|
*/
|
|
once?: boolean;
|
|
}
|
|
|
|
export interface EnrichThinPhaseResult {
|
|
phase: 'enrich_thin';
|
|
status: 'ok' | 'warn' | 'fail' | 'skipped';
|
|
duration_ms: number;
|
|
summary: string;
|
|
details: Record<string, unknown>;
|
|
}
|
|
|
|
const CFG_PREFIX = 'cycle.enrich_thin';
|
|
|
|
interface ResolvedConfig {
|
|
enabled: boolean;
|
|
maxCostUsd: number; // per source per tick
|
|
maxTotalCostUsd: number; // brain-wide per tick
|
|
maxTotalWalltimeMin: number; // brain-wide per tick
|
|
maxPagesPerTick: number; // per source per tick
|
|
types: PageType[];
|
|
order: EnrichOrder;
|
|
workers: number;
|
|
model?: string;
|
|
}
|
|
|
|
async function loadCfg(engine: BrainEngine): Promise<ResolvedConfig> {
|
|
const get = (k: string) => engine.getConfig(`${CFG_PREFIX}.${k}`);
|
|
const [enabled, maxCost, maxTotalCost, maxTotalWall, maxPages, typesRaw, orderRaw, workersRaw, model] =
|
|
await Promise.all([
|
|
get('enabled'),
|
|
get('max_cost_usd'),
|
|
get('max_total_cost_usd'),
|
|
get('max_total_walltime_min'),
|
|
get('max_pages_per_tick'),
|
|
get('types'),
|
|
get('order'),
|
|
get('workers'),
|
|
get('model'),
|
|
]);
|
|
|
|
const enabledFlag = (() => {
|
|
if (enabled == null) return false;
|
|
const v = enabled.trim().toLowerCase();
|
|
return !['false', '0', 'no', 'off', ''].includes(v);
|
|
})();
|
|
|
|
const parseFloatOrDefault = (raw: string | null, fallback: number): number => {
|
|
if (raw == null) return fallback;
|
|
const n = parseFloat(raw);
|
|
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
};
|
|
const parseIntOrDefault = (raw: string | null, fallback: number): number => {
|
|
if (raw == null) return fallback;
|
|
const n = parseInt(raw, 10);
|
|
return Number.isFinite(n) && n >= 1 ? n : fallback;
|
|
};
|
|
|
|
let types: PageType[] = [...DEFAULT_TYPES];
|
|
if (typesRaw) {
|
|
try {
|
|
const parsed = JSON.parse(typesRaw);
|
|
if (Array.isArray(parsed)) {
|
|
const filtered = parsed.filter((t): t is string => typeof t === 'string' && t.length > 0);
|
|
if (filtered.length > 0) types = filtered as PageType[];
|
|
}
|
|
} catch {
|
|
// fall through to default
|
|
}
|
|
}
|
|
|
|
const order: EnrichOrder =
|
|
orderRaw && (ENRICH_ORDERS as readonly string[]).includes(orderRaw.trim())
|
|
? (orderRaw.trim() as EnrichOrder)
|
|
: 'inbound-links';
|
|
|
|
return {
|
|
enabled: enabledFlag,
|
|
maxCostUsd: parseFloatOrDefault(maxCost, 1.0),
|
|
maxTotalCostUsd: parseFloatOrDefault(maxTotalCost, 5.0),
|
|
maxTotalWalltimeMin: parseFloatOrDefault(maxTotalWall, 30),
|
|
maxPagesPerTick: parseIntOrDefault(maxPages, 3),
|
|
types,
|
|
order,
|
|
workers: parseIntOrDefault(workersRaw, 1),
|
|
model: model ?? undefined,
|
|
};
|
|
}
|
|
|
|
export async function runPhaseEnrichThin(
|
|
engine: BrainEngine,
|
|
opts: EnrichThinPhaseOpts = {},
|
|
): Promise<EnrichThinPhaseResult> {
|
|
const cfg = await loadCfg(engine);
|
|
|
|
if (!cfg.enabled) {
|
|
if (!opts.once) {
|
|
return {
|
|
phase: 'enrich_thin',
|
|
status: 'skipped',
|
|
duration_ms: 0,
|
|
summary: 'cycle.enrich_thin.enabled=false (default OFF)',
|
|
details: {
|
|
reason: 'disabled',
|
|
enable_hint: 'gbrain config set cycle.enrich_thin.enabled true',
|
|
},
|
|
};
|
|
}
|
|
process.stderr.write(
|
|
'[dream] --once: cycle.enrich_thin.enabled is false but ' +
|
|
'--phase enrich_thin --once forces this run (config untouched)\n',
|
|
);
|
|
}
|
|
|
|
const startedAt = Date.now();
|
|
|
|
// Chat gateway required for synthesis (dry-run skips the LLM but still needs
|
|
// the candidate query; allow dry-run without a gateway).
|
|
if (!opts.dryRun && !isAvailable('chat')) {
|
|
return {
|
|
phase: 'enrich_thin',
|
|
status: 'skipped',
|
|
duration_ms: Date.now() - startedAt,
|
|
summary: 'no chat gateway configured',
|
|
details: { reason: 'no_chat_gateway' },
|
|
};
|
|
}
|
|
|
|
const maxTotalWalltimeMs = cfg.maxTotalWalltimeMin * 60_000;
|
|
const sources = await listSources(engine);
|
|
if (sources.length === 0) {
|
|
return {
|
|
phase: 'enrich_thin',
|
|
status: 'ok',
|
|
duration_ms: Date.now() - startedAt,
|
|
summary: 'no sources to process',
|
|
details: { sources_count: 0 },
|
|
};
|
|
}
|
|
|
|
// P2#2 (codex): enforce BOTH a per-source cap AND the brain-wide total. Per
|
|
// source we run with maxCostUsd = min(per-source cap, brain-wide remaining);
|
|
// runEnrichCore creates + enforces its own tracker for that cap (its internal
|
|
// withBudgetTracker). We sum each source's spend and stop the loop once the
|
|
// brain-wide total is reached. The prior single brain-wide tracker let one
|
|
// source drain the whole tick; passing a per-source cap fixes that without
|
|
// nested withBudgetTracker (which REPLACES, not stacks).
|
|
const perSourceResults: Record<string, EnrichResult & { error?: string }> = {};
|
|
let skippedByBrainWideWalltime = 0;
|
|
let totalSpent = 0;
|
|
|
|
for (const src of sources) {
|
|
if (opts.signal?.aborted) throw new Error('aborted'); // propagates; cycle handles
|
|
if (Date.now() - startedAt > maxTotalWalltimeMs) {
|
|
skippedByBrainWideWalltime++;
|
|
continue;
|
|
}
|
|
const remainingBrainWide = cfg.maxTotalCostUsd - totalSpent;
|
|
if (remainingBrainWide <= 0) break; // brain-wide cap reached
|
|
const perSourceCap = Math.min(cfg.maxCostUsd, remainingBrainWide);
|
|
try {
|
|
const r = await runEnrichCore(engine, {
|
|
sourceId: src.id,
|
|
types: cfg.types,
|
|
order: cfg.order,
|
|
limit: cfg.maxPagesPerTick,
|
|
workers: cfg.workers,
|
|
model: cfg.model,
|
|
dryRun: opts.dryRun,
|
|
maxCostUsd: perSourceCap,
|
|
}, opts.signal);
|
|
perSourceResults[src.id] = r;
|
|
totalSpent += r.spent_usd ?? 0;
|
|
// r.budget_exhausted here means THIS source hit perSourceCap. Only stop the
|
|
// whole tick when the brain-wide total is actually reached; otherwise move
|
|
// on so a cheap source isn't starved by an expensive earlier one.
|
|
if (totalSpent >= cfg.maxTotalCostUsd) break;
|
|
} catch (err) {
|
|
if (err instanceof BudgetExhausted) {
|
|
// Defensive: runEnrichCore returns partial on budget rather than throwing.
|
|
continue;
|
|
}
|
|
perSourceResults[src.id] = {
|
|
candidates_considered: 0,
|
|
pages_enriched: 0,
|
|
pages_skipped_insufficient: 0,
|
|
pages_skipped_lock: 0,
|
|
pages_skipped_disappeared: 0,
|
|
pages_failed: 0,
|
|
error: (err as Error).message,
|
|
};
|
|
}
|
|
}
|
|
|
|
const totals = { enriched: 0, skipped_insufficient: 0, sources_processed: 0 };
|
|
for (const r of Object.values(perSourceResults)) {
|
|
if (!r.error) totals.sources_processed++;
|
|
totals.enriched += r.pages_enriched;
|
|
totals.skipped_insufficient += r.pages_skipped_insufficient;
|
|
}
|
|
|
|
const anyError = Object.values(perSourceResults).some((r) => r.error);
|
|
const status = anyError ? 'warn' : 'ok';
|
|
const summary = `${totals.enriched} page(s) enriched across ${totals.sources_processed}/${sources.length} sources, ~$${totalSpent.toFixed(4)} spent`;
|
|
|
|
return {
|
|
phase: 'enrich_thin',
|
|
status,
|
|
duration_ms: Date.now() - startedAt,
|
|
summary,
|
|
details: {
|
|
sources_count: sources.length,
|
|
sources_processed: totals.sources_processed,
|
|
pages_enriched: totals.enriched,
|
|
pages_skipped_insufficient: totals.skipped_insufficient,
|
|
spent_usd: totalSpent,
|
|
skipped_by_brain_wide_walltime: skippedByBrainWideWalltime,
|
|
max_cost_usd: cfg.maxCostUsd,
|
|
max_total_cost_usd: cfg.maxTotalCostUsd,
|
|
max_pages_per_tick: cfg.maxPagesPerTick,
|
|
types: cfg.types,
|
|
order: cfg.order,
|
|
per_source: perSourceResults,
|
|
},
|
|
};
|
|
}
|