Files
gbrain/src/core/cycle/enrich-thin.ts
T
60125ee626 feat(dream): --once for one-shot phase runs without toggling config gates (takeover of #2983) (#3031)
* 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>
2026-07-21 12:29:35 -07:00

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,
},
};
}