diff --git a/src/commands/dream.ts b/src/commands/dream.ts index e90a01963..f5857097c 100644 --- a/src/commands/dream.ts +++ b/src/commands/dream.ts @@ -76,6 +76,18 @@ interface DreamArgs { drain: boolean; /** Drain wallclock budget in seconds. Default 300 (5 min). */ windowSeconds: number; + /** + * issue #2860 — `--once`. One-shot bypass of the named `--phase`'s own + * `dream..enabled` / `cycle..enabled` config gate, for this + * invocation only. Never reads or writes config — unlike the old + * "toggle enabled true, run, toggle back to false" workaround, a crash + * mid-run can't leave any global state stuck. Requires an explicit + * `--phase `; bare `--once` is a usage error (there'd be no single + * phase to target). Applies only to phases with a config `.enabled` gate + * (patterns, synthesize, conversation_facts_backfill, enrich_thin, + * skillopt) — a no-op for phases that always run when named directly. + */ + once: boolean; } const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; @@ -214,6 +226,20 @@ function parseArgs(args: string[]): DreamArgs { } } + // issue #2860: --once requires an explicit single --phase target. Bare + // `--once` (full/default cycle) has no single phase to bypass the gate + // for, and force-enabling EVERY currently-disabled phase at once would + // be exactly the kind of surprise-spend risk the flag exists to prevent. + const once = args.includes('--once'); + if (once && !phase) { + console.error( + '--once requires --phase (bypasses that one phase\'s ' + + 'dream..enabled / cycle..enabled gate for this run only; ' + + 'never touches config). Usage: gbrain dream --phase --once', + ); + process.exit(2); + } + return { json: args.includes('--json'), dryRun: args.includes('--dry-run'), @@ -229,6 +255,7 @@ function parseArgs(args: string[]): DreamArgs { source, drain, windowSeconds, + once, }; } @@ -310,6 +337,14 @@ Options: "--dry-run" does NOT mean "zero LLM calls." --json Emit the CycleReport as JSON (agent-readable) --phase Run a single phase: ${ALL_PHASES.join(' | ')} + --once With --phase : run that phase once even if its + own dream..enabled / cycle..enabled + config gate is false. Never reads or writes config — + unlike toggling the flag on/off around the run, a + crash mid-invocation can't leave it stuck. Applies to + patterns, synthesize, conversation_facts_backfill, + enrich_thin, skillopt; no-op on phases with no such + gate. Requires --phase (bare --once is a usage error). --pull git pull the brain repo before syncing (default: no pull) --dir Brain directory (default: configured brain). On a postgres/remote brain with no local checkout, the @@ -353,6 +388,7 @@ Examples: gbrain dream gbrain dream --dry-run --json gbrain dream --phase lint + gbrain dream --phase patterns --once # run once, ignore dream.patterns.enabled=false gbrain dream --phase synthesize --input ~/transcripts/2026-04-25.txt gbrain dream --phase synthesize --from 2026-04-01 --to 2026-04-25 0 2 * * * gbrain dream --json # nightly via cron @@ -594,6 +630,9 @@ export async function runDream(engine: BrainEngine | null, args: string[]): Prom synthFrom: opts.from ?? undefined, synthTo: opts.to ?? undefined, synthBypassDreamGuard: opts.bypassDreamGuard, + // issue #2860: opts.phase is guaranteed non-null here when opts.once is + // set (parseArgs enforces --once requires --phase). + onceForPhase: opts.once ? opts.phase! : undefined, }); if (opts.json) { diff --git a/src/core/cycle.ts b/src/core/cycle.ts index a52efcd89..a522d8a46 100644 --- a/src/core/cycle.ts +++ b/src/core/cycle.ts @@ -479,6 +479,27 @@ export interface CycleOpts { * Validated via `assertValidSourceId` in `cycleLockIdFor` (defense-in-depth). */ sourceId?: string; + /** + * issue #2860 — one-shot per-invocation bypass of a phase's own + * `dream..enabled` / `cycle..enabled` config gate. Wired + * from `gbrain dream --phase --once`. + * + * Deliberately typed as the SINGLE named `CyclePhase`, not a boolean — + * each gated phase's dispatch block below only honors the override when + * `onceForPhase` matches ITS OWN phase name, so the bypass can never leak + * to a different phase even if a caller passes a wider `phases` array + * than the CLI does (the CLI always restricts to `phases: [phase]`). + * + * Never reads or writes config — the phase still evaluates its config + * gate every call; this only overrides the boolean OUTCOME for that one + * call. Applies to: patterns, synthesize, conversation_facts_backfill, + * enrich_thin, skillopt (the phases that gate on a `.enabled` config + * key read inside the phase's own module). Does NOT apply to + * extract_atoms / synthesize_concepts — those are pack-gated via + * `packDeclaresPhase`, a different mechanism with its own existing + * one-shot escape hatch (`--drain` for extract_atoms). + */ + onceForPhase?: CyclePhase; } // ─── Lock primitives ─────────────────────────────────────────────── @@ -1685,6 +1706,7 @@ export async function runCycle( // #1586: scope synthesized writes to the cycle's resolved source // (explicit --source wins, else derived from the checkout dir). sourceId: cycleSourceId, + once: opts.onceForPhase === 'synthesize', })); result.duration_ms = duration_ms; phaseResults.push(result); @@ -1888,6 +1910,7 @@ export async function runCycle( brainDir, dryRun, yieldDuringPhase: opts.yieldDuringPhase, + once: opts.onceForPhase === 'patterns', })); result.duration_ms = duration_ms; phaseResults.push(result); @@ -2103,7 +2126,11 @@ export async function runCycle( progress.start('cycle.conversation_facts_backfill'); const { runPhaseConversationFactsBackfill } = await import('./cycle/conversation-facts-backfill.ts'); const { result, duration_ms } = await timePhase(() => - runPhaseConversationFactsBackfill(engine, { dryRun, signal: opts.signal }), + runPhaseConversationFactsBackfill(engine, { + dryRun, + signal: opts.signal, + once: opts.onceForPhase === 'conversation_facts_backfill', + }), ); result.duration_ms = duration_ms; phaseResults.push(result); @@ -2131,7 +2158,11 @@ export async function runCycle( progress.start('cycle.enrich_thin'); const { runPhaseEnrichThin } = await import('./cycle/enrich-thin.ts'); const { result, duration_ms } = await timePhase(() => - runPhaseEnrichThin(engine, { dryRun, signal: opts.signal }), + runPhaseEnrichThin(engine, { + dryRun, + signal: opts.signal, + once: opts.onceForPhase === 'enrich_thin', + }), ); result.duration_ms = duration_ms; phaseResults.push(result); @@ -2162,6 +2193,7 @@ export async function runCycle( runPhaseSkillopt({ engine, dryRun, + once: opts.onceForPhase === 'skillopt', ...(opts.signal ? { signal: opts.signal } : {}), }), ); diff --git a/src/core/cycle/conversation-facts-backfill.ts b/src/core/cycle/conversation-facts-backfill.ts index a6b3db7bc..68464a850 100644 --- a/src/core/cycle/conversation-facts-backfill.ts +++ b/src/core/cycle/conversation-facts-backfill.ts @@ -57,6 +57,14 @@ import { export interface ConversationFactsBackfillPhaseOpts { dryRun?: boolean; signal?: AbortSignal; + /** + * issue #2860 — `gbrain dream --phase conversation_facts_backfill --once`. + * Bypasses the `cycle.conversation_facts_backfill.enabled` gate for THIS + * call only; never reads or writes config. Per-source + brain-wide cost/ + * walltime caps still apply — the override lifts the on/off switch, not + * the spend guards. + */ + once?: boolean; } /** Phase return shape (matches PhaseResult contract from cycle.ts). */ @@ -155,17 +163,23 @@ export async function runPhaseConversationFactsBackfill( const cfg = await loadCfg(engine); if (!cfg.enabled) { - return { - phase: 'conversation_facts_backfill', - status: 'skipped', - duration_ms: 0, - summary: 'cycle.conversation_facts_backfill.enabled=false (default OFF)', - details: { - reason: 'disabled', - enable_hint: - 'gbrain config set cycle.conversation_facts_backfill.enabled true', - }, - }; + if (!opts.once) { + return { + phase: 'conversation_facts_backfill', + status: 'skipped', + duration_ms: 0, + summary: 'cycle.conversation_facts_backfill.enabled=false (default OFF)', + details: { + reason: 'disabled', + enable_hint: + 'gbrain config set cycle.conversation_facts_backfill.enabled true', + }, + }; + } + process.stderr.write( + '[dream] --once: cycle.conversation_facts_backfill.enabled is false but ' + + '--phase conversation_facts_backfill --once forces this run (config untouched)\n', + ); } const startedAt = Date.now(); diff --git a/src/core/cycle/enrich-thin.ts b/src/core/cycle/enrich-thin.ts index 02a89966f..85c3f9f6e 100644 --- a/src/core/cycle/enrich-thin.ts +++ b/src/core/cycle/enrich-thin.ts @@ -45,6 +45,12 @@ import { 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 { @@ -139,16 +145,22 @@ export async function runPhaseEnrichThin( const cfg = await loadCfg(engine); if (!cfg.enabled) { - 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', - }, - }; + 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(); diff --git a/src/core/cycle/patterns.ts b/src/core/cycle/patterns.ts index d96584dad..bcb321f32 100644 --- a/src/core/cycle/patterns.ts +++ b/src/core/cycle/patterns.ts @@ -37,6 +37,12 @@ export interface PatternsPhaseOpts { brainDir: string; dryRun: boolean; yieldDuringPhase?: () => Promise; + /** + * issue #2860 — `gbrain dream --phase patterns --once`. Bypasses the + * `dream.patterns.enabled` gate for THIS call only; never reads or + * writes config. + */ + once?: boolean; } export async function runPhasePatterns( @@ -48,7 +54,13 @@ export async function runPhasePatterns( const config = await loadPatternsConfig(engine); if (!config.enabled) { - return skipped('disabled', 'dream.patterns.enabled is false'); + if (!opts.once) { + return skipped('disabled', 'dream.patterns.enabled is false'); + } + process.stderr.write( + '[dream] --once: dream.patterns.enabled is false but ' + + '--phase patterns --once forces this run (config untouched)\n', + ); } // Gather reflections within lookback window. diff --git a/src/core/cycle/synthesize.ts b/src/core/cycle/synthesize.ts index 59aad6630..eea299413 100644 --- a/src/core/cycle/synthesize.ts +++ b/src/core/cycle/synthesize.ts @@ -252,6 +252,13 @@ export interface SynthesizePhaseOpts { * correct (source_id, slug) row. Unset → legacy 'default'. */ sourceId?: string; + /** + * issue #2860 — `gbrain dream --phase synthesize --once`. Bypasses the + * `dream.synthesize.enabled` gate for THIS call only (does NOT bypass + * the `session_corpus_dir` not-configured check — there's nothing to + * run without a corpus). Never reads or writes config. + */ + once?: boolean; } export async function runPhaseSynthesize( @@ -285,8 +292,14 @@ export async function runPhaseSynthesize( 'dream.synthesize.session_corpus_dir is unset'); } if (!opts.inputFile && !config.enabled) { - return skipped('not_configured', - 'dream.synthesize.enabled is explicitly false'); + if (!opts.once) { + return skipped('not_configured', + 'dream.synthesize.enabled is explicitly false'); + } + process.stderr.write( + '[dream] --once: dream.synthesize.enabled is false but ' + + '--phase synthesize --once forces this run (config untouched)\n', + ); } // Cooldown check (skipped for explicit --input / --date / --from / --to runs). diff --git a/src/core/skillopt/cycle-phase.ts b/src/core/skillopt/cycle-phase.ts index d10c79706..b3fcb6bae 100644 --- a/src/core/skillopt/cycle-phase.ts +++ b/src/core/skillopt/cycle-phase.ts @@ -29,6 +29,12 @@ export interface SkilloptPhaseOpts { engine: BrainEngine; dryRun?: boolean; signal?: AbortSignal; + /** + * issue #2860 — `gbrain dream --phase skillopt --once`. Bypasses the + * `cycle.skillopt.enabled` feature flag for THIS call only; never reads + * or writes config. Per-skill + brain-wide cost caps still apply. + */ + once?: boolean; } export interface SkilloptPhaseResult { @@ -63,13 +69,19 @@ export async function runPhaseSkillopt(opts: SkilloptPhaseOpts): Promise { expect(syncCalls.at(-1)?.sourceId).toBe(''); }); }); + +// ─── issue #2860: --once one-shot phase-enabled bypass (onceForPhase) ─ +// +// CycleOpts.onceForPhase is deliberately typed as a single CyclePhase (not +// a boolean) so the override can never leak to a phase other than the one +// it names — even if a caller passes a wider `phases` array than the CLI +// does (dream.ts always restricts to `phases: [phase]` when --once is +// set). This exercises that boundary directly against runCycle, using the +// real (unmocked) patterns.ts module — cheap because with zero reflections +// seeded it never reaches an LLM call regardless of the enabled gate. +describe('runCycle — onceForPhase bypasses only the named phase (issue #2860)', () => { + beforeEach(async () => { + await truncateCycleLocks(sharedEngine); + await sharedEngine.setConfig('dream.patterns.enabled', 'false'); + }); + + afterEach(async () => { + // Restore default so later describe blocks in this file (which run + // patterns as part of the full ALL_PHASES cycle) aren't affected. + await sharedEngine.setConfig('dream.patterns.enabled', 'true'); + }); + + test('onceForPhase matching the requested phase bypasses its disabled gate', async () => { + const report = await runCycle(sharedEngine, { + brainDir: '/tmp/brain-2860-a', + phases: ['patterns'], + onceForPhase: 'patterns', + }); + const patternsResult = report.phases.find(p => p.phase === 'patterns'); + // Bypassed 'disabled' → falls through to the next gate (no reflections + // seeded). If the override didn't work, this would read 'disabled'. + expect(patternsResult?.status).toBe('skipped'); + expect((patternsResult?.details as { reason?: string })?.reason).toBe('insufficient_evidence'); + }); + + test('onceForPhase naming a DIFFERENT phase does not leak the bypass', async () => { + const report = await runCycle(sharedEngine, { + brainDir: '/tmp/brain-2860-b', + phases: ['patterns'], + onceForPhase: 'synthesize', // mismatched — must NOT bypass patterns' gate + }); + const patternsResult = report.phases.find(p => p.phase === 'patterns'); + expect(patternsResult?.status).toBe('skipped'); + expect((patternsResult?.details as { reason?: string })?.reason).toBe('disabled'); + }); + + test('no onceForPhase set → unchanged behavior (still gated)', async () => { + const report = await runCycle(sharedEngine, { + brainDir: '/tmp/brain-2860-c', + phases: ['patterns'], + }); + const patternsResult = report.phases.find(p => p.phase === 'patterns'); + expect(patternsResult?.status).toBe('skipped'); + expect((patternsResult?.details as { reason?: string })?.reason).toBe('disabled'); + }); + + test('config is never written by the override', async () => { + await runCycle(sharedEngine, { + brainDir: '/tmp/brain-2860-d', + phases: ['patterns'], + onceForPhase: 'patterns', + }); + expect(await sharedEngine.getConfig('dream.patterns.enabled')).toBe('false'); + }); +}); diff --git a/test/dream-cli-flags.test.ts b/test/dream-cli-flags.test.ts index 1ffa759ce..935b28380 100644 --- a/test/dream-cli-flags.test.ts +++ b/test/dream-cli-flags.test.ts @@ -135,4 +135,28 @@ describe('dream CLI flag wiring', () => { expect(dreamSrc).toContain('cycle_already_running'); }); }); + + // issue #2860 — --once one-shot phase-enabled-gate bypass (structural). + // Behavioral coverage: test/e2e/dream-patterns-pglite.test.ts (bypass + + // config-untouched) and test/core/cycle.serial.test.ts (non-leak across + // phases via CycleOpts.onceForPhase). + describe('--once wiring (issue #2860)', () => { + test('declares --once flag', () => { + expect(dreamSrc).toContain("'--once'"); + }); + + test('rejects bare --once with no --phase (exit 2)', () => { + expect(dreamSrc).toContain('--once requires --phase '); + expect(dreamSrc).toContain('if (once && !phase)'); + }); + + test('threads onceForPhase to runCycle, gated on opts.once', () => { + expect(dreamSrc).toMatch(/onceForPhase:\s*opts\.once\s*\?\s*opts\.phase!\s*:\s*undefined/); + }); + + test('documents --once in --help output', () => { + expect(dreamSrc).toContain('--once'); + expect(dreamSrc).toContain('Never reads or writes config'); + }); + }); }); diff --git a/test/e2e/dream-patterns-pglite.test.ts b/test/e2e/dream-patterns-pglite.test.ts index 8d55f4a5e..08719166b 100644 --- a/test/e2e/dream-patterns-pglite.test.ts +++ b/test/e2e/dream-patterns-pglite.test.ts @@ -99,6 +99,45 @@ describe('E2E patterns — disabled', () => { await rig.cleanup(); } }, 30_000); + + // issue #2860 — `gbrain dream --phase patterns --once` must bypass the + // enabled gate WITHOUT touching config (the whole point: the old + // toggle-on/run/toggle-off workaround left `enabled` stuck true forever + // if the process died between steps, running patterns hourly and + // burning ~$400 in LLM spend before it was caught). + test('once:true bypasses the disabled gate for this call only', async () => { + const rig = await setupRig(); + try { + await rig.engine.setConfig('dream.patterns.enabled', 'false'); + + // Without --once: still skipped as 'disabled' (unchanged behavior). + const gated = await runPhasePatterns(rig.engine, { + brainDir: rig.brainDir, + dryRun: false, + }); + expect(gated.status).toBe('skipped'); + expect((gated.details as { reason?: string }).reason).toBe('disabled'); + + // With --once: the disabled gate no longer short-circuits — the call + // proceeds past it to the NEXT gate (insufficient_evidence, since no + // reflections were seeded). If --once didn't work, this would still + // report 'disabled'. + const forced = await runPhasePatterns(rig.engine, { + brainDir: rig.brainDir, + dryRun: false, + once: true, + }); + expect(forced.status).toBe('skipped'); + expect((forced.details as { reason?: string }).reason).toBe('insufficient_evidence'); + + // Config was NEVER written — the override is call-scoped only, unlike + // the toggle-on/toggle-off workaround the issue exists to replace. + const stillDisabled = await rig.engine.getConfig('dream.patterns.enabled'); + expect(stillDisabled).toBe('false'); + } finally { + await rig.cleanup(); + } + }, 30_000); }); describe('E2E patterns — insufficient_evidence', () => {