diff --git a/src/commands/autopilot-fanout.ts b/src/commands/autopilot-fanout.ts index 9c558ad57..1098bdf7a 100644 --- a/src/commands/autopilot-fanout.ts +++ b/src/commands/autopilot-fanout.ts @@ -186,6 +186,18 @@ export function isSourceStale(src: SourceRow, now = Date.now(), floorMin = FULL_ return ageMin >= floorMin; } +/** + * #2060: count sources past the per-source cycle freshness floor. Consumed + * by autopilot's dispatch decision — a stale source forces the fanout path + * even when the doctor plan is small (score 70–94, plan ≤ 3, est < 300s), + * so targeted mode can't leave cycle_freshness stale indefinitely. + * dispatchPerSource's own throttles (skipped_fresh / fanoutMax / failure + * cooldown) bound the resulting work. + */ +export function countStaleSources(sources: SourceRow[], now = Date.now(), floorMin = FULL_CYCLE_FLOOR_MIN): number { + return sources.filter((s) => isSourceStale(s, now, floorMin)).length; +} + /** * Most recent SUCCESSFUL cycle for a source. Prefers `last_source_cycle_at` * (per-source phases, written by the split cycle) and falls back to the legacy diff --git a/src/commands/autopilot.ts b/src/commands/autopilot.ts index 82979fb12..d524b128d 100644 --- a/src/commands/autopilot.ts +++ b/src/commands/autopilot.ts @@ -901,13 +901,27 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) { const FULL_CYCLE_FLOOR_MIN = 60; const minutesSinceLastFull = (Date.now() - lastFullCycleAt) / 60000; + // #2060: stale per-source cycle freshness is a dispatch input. Without + // it, a brain sitting at score 70–94 with a small targeted plan (≤3 + // steps, <300s) stays in targeted mode indefinitely and no per-source + // cycle is ever dispatched — cycle_freshness never advances. A stale + // source forces the fanout path; dispatchPerSource's throttles + // (skipped_fresh / fanoutMax / failure cooldown) bound the work. + // Fail-open to 0: a read failure must not block dispatch. + let staleCycleSources = 0; + try { + const { countStaleSources } = await import('./autopilot-fanout.ts'); + staleCycleSources = countStaleSources(await engine.listAllSources({ localPathOnly: true })); + } catch { /* fail-open: freshness is a dispatch hint, not a gate */ } + const shouldFullCycle = (score >= 95 && plan.length === 0 && minutesSinceLastFull >= FULL_CYCLE_FLOOR_MIN) || plan.length > 3 || estTotal >= 300 || - score < 70; + score < 70 || + staleCycleSources > 0; - const shouldSleep = score >= 95 && plan.length === 0 && minutesSinceLastFull < FULL_CYCLE_FLOOR_MIN; + const shouldSleep = score >= 95 && plan.length === 0 && minutesSinceLastFull < FULL_CYCLE_FLOOR_MIN && staleCycleSources === 0; if (shouldSleep) { if (jsonMode) { diff --git a/src/core/cycle.ts b/src/core/cycle.ts index a10b1ca80..b1f332862 100644 --- a/src/core/cycle.ts +++ b/src/core/cycle.ts @@ -2365,17 +2365,23 @@ export async function runCycle( } // v0.38 (codex r1 P0-5): persist per-source cycle completion timestamp - // when the cycle ran successfully against an explicit source. Read by - // autopilot's per-source freshness gate next tick. Skipped when: - // - opts.sourceId is unset (legacy callers — autopilot still here) - // - engine is null (no-DB path) + // when the cycle ran successfully against a resolvable source. Read by + // autopilot's per-source freshness gate next tick. + // + // #1993: keyed off `cycleSourceId` (opts.sourceId ?? the source resolved + // from brainDir) — the SAME id the cycle locked + scoped its phases to — + // NOT raw opts.sourceId. The autopilot's inline cycle sets brainDir but + // passes no explicit sourceId, so keying off opts.sourceId alone never + // advanced last_full_cycle_at and cycle_freshness stayed stale even while + // the autopilot cycled every interval. Skipped when: + // - no source resolves (engine null, or no checkout AND no opts.sourceId) // - status is 'failed' or 'skipped' (don't mark a non-run as fresh) // - dryRun (writes are out of scope) // // Best-effort: a write failure does NOT change the CycleReport status. // The cost of writing the wrong timestamp post-failure is higher than // the cost of missing a successful write (next cycle will redo work). - if (opts.sourceId && engine && !dryRun && !aborted && (status === 'ok' || status === 'clean' || status === 'partial')) { + if (cycleSourceId && engine && !dryRun && !aborted && (status === 'ok' || status === 'clean' || status === 'partial')) { try { const nowIso = new Date().toISOString(); // #2194 fix #3 (the cycle split): `last_source_cycle_at` is the NEW gate @@ -2385,13 +2391,13 @@ export async function runCycle( // phases (those gate on autopilot.last_global_at), so writing it on a // source-only cycle does not re-introduce the freshness poisoning codex // flagged in the rejected skip-based design. - await engine.updateSourceConfig(opts.sourceId, { + await engine.updateSourceConfig(cycleSourceId, { last_source_cycle_at: nowIso, last_full_cycle_at: nowIso, }); } catch (e) { // Best-effort; cycle already succeeded by the time we get here. - console.warn(`[cycle] failed to write last_source_cycle_at for source ${opts.sourceId}: ${e instanceof Error ? e.message : String(e)}`); + console.warn(`[cycle] failed to write last_source_cycle_at for source ${cycleSourceId}: ${e instanceof Error ? e.message : String(e)}`); } } diff --git a/test/autopilot-fanout-wiring.test.ts b/test/autopilot-fanout-wiring.test.ts index addf8dbe3..5e18c6e46 100644 --- a/test/autopilot-fanout-wiring.test.ts +++ b/test/autopilot-fanout-wiring.test.ts @@ -54,6 +54,20 @@ describe('autopilot.ts ↔ dispatchPerSource wiring', () => { expect(AUTOPILOT_SRC).toMatch(/lastFullCycleAt\s*=\s*Date\.now\(\)/); }); + test('stale per-source cycle freshness is a shouldFullCycle input (#2060)', () => { + // Targeted mode (score 70–94, plan ≤3, est <300s) must not be able to + // starve per-source cycle dispatch: a stale source (per countStaleSources + // over listAllSources) forces the fanout path, and the sleep gate must + // not fire while stale sources exist. Without these terms, cycle + // freshness never advances for a brain that always lands in targeted mode. + expect(AUTOPILOT_SRC).toMatch(/countStaleSources/); + const fullCycleDeclIdx = AUTOPILOT_SRC.indexOf('const shouldFullCycle'); + expect(fullCycleDeclIdx).toBeGreaterThan(-1); + const decl = AUTOPILOT_SRC.slice(fullCycleDeclIdx, fullCycleDeclIdx + 700); + expect(decl).toMatch(/staleCycleSources\s*>\s*0/); + expect(decl).toMatch(/const shouldSleep[^;]*staleCycleSources\s*===\s*0/); + }); + test('does NOT regress to the single-job dispatch on the full-cycle path', () => { // Pre-PR: the shouldFullCycle branch did: // const job = await queue.add('autopilot-cycle', { repoPath }, { diff --git a/test/autopilot-fanout.test.ts b/test/autopilot-fanout.test.ts index 2e4fee561..8caa4a88b 100644 --- a/test/autopilot-fanout.test.ts +++ b/test/autopilot-fanout.test.ts @@ -14,6 +14,7 @@ import { describe, test, expect } from 'bun:test'; import { readLastFullCycleAt, isSourceStale, + countStaleSources, selectSourcesForDispatch, resolveFanoutMax, dispatchPerSource, @@ -74,6 +75,23 @@ describe('isSourceStale', () => { }); }); +describe('countStaleSources (#2060 dispatch-decision input)', () => { + const NOW = Date.parse('2026-05-22T12:00:00.000Z'); + test('counts never-cycled + past-floor sources, ignores fresh', () => { + const sources = [ + src('never-cycled'), // stale (null) + src('old', new Date(NOW - 2 * 60 * 60_000).toISOString()), // stale (2h) + src('fresh', new Date(NOW - 30 * 60_000).toISOString()), // fresh (30min) + ]; + expect(countStaleSources(sources, NOW)).toBe(2); + }); + test('returns 0 for all-fresh and for empty list', () => { + const fresh = src('a', new Date(NOW - 10 * 60_000).toISOString()); + expect(countStaleSources([fresh], NOW)).toBe(0); + expect(countStaleSources([], NOW)).toBe(0); + }); +}); + describe('selectSourcesForDispatch', () => { const NOW = Date.parse('2026-05-22T12:00:00.000Z'); const fresh = (id: string, agoMin: number) => diff --git a/test/cycle-last-full-cycle-at.test.ts b/test/cycle-last-full-cycle-at.test.ts index 3abec822d..5d880e265 100644 --- a/test/cycle-last-full-cycle-at.test.ts +++ b/test/cycle-last-full-cycle-at.test.ts @@ -3,8 +3,10 @@ * cycles. Closes codex round-1 P0-5 (write site for last_full_cycle_at * was unspecified pre-PR). * - * Conditions for write: - * - opts.sourceId is set (legacy callers without sourceId skip the write) + * Conditions for write (keyed off `cycleSourceId` = opts.sourceId ?? the + * source resolved from brainDir, so the autopilot's inline cycle — brainDir + * set, no explicit sourceId — also advances the timestamp, #1993): + * - a source resolves (explicit sourceId, or brainDir matches a source) * - engine is non-null (no-DB path skips) * - status is 'ok' | 'clean' | 'partial' (failed/skipped don't mark fresh) * - dryRun is false @@ -90,17 +92,45 @@ describe('runCycle last_full_cycle_at exit hook', () => { }); }); - test('legacy caller (no sourceId) does NOT write any source timestamp', async () => { + test('no explicit sourceId but brainDir resolves a source → writes the resolved source timestamp', async () => { await withEnv({ GBRAIN_HOME: gbrainHome }, async () => { - await seedSource('default-like'); - // No sourceId passed; should remain untouched. + // The autopilot's inline cycle sets brainDir but passes no sourceId. + // runCycle resolves the source from brainDir (local_path match) into + // cycleSourceId and stamps last_full_cycle_at for it — otherwise + // cycle_freshness reports the brain stale even while the autopilot + // cycles every interval (#1993). + await seedSource('resolved-from-dir'); // local_path = brainDir + expect(await readLastFullCycleAt('resolved-from-dir')).toBeNull(); + + const t0 = Date.now(); + const report = await runCycle(engine, { + brainDir, + phases: ['lint'], + }); + expect(['ok', 'clean']).toContain(report.status); + + const after = await readLastFullCycleAt('resolved-from-dir'); + expect(after).not.toBeNull(); + expect(new Date(after!).getTime()).toBeGreaterThanOrEqual(t0); + }); + }); + + test('no sourceId and brainDir matches no source → does not write', async () => { + await withEnv({ GBRAIN_HOME: gbrainHome }, async () => { + // A source exists but its local_path does NOT match brainDir, so + // resolveSourceForDir returns undefined, cycleSourceId is undefined, + // and no per-source timestamp is written. + await engine.executeRaw( + `INSERT INTO sources (id, name, local_path, config, archived, created_at) + VALUES ('unmatched', 'unmatched', '/no/such/repo', '{}'::jsonb, false, NOW()) + ON CONFLICT (id) DO UPDATE SET local_path = EXCLUDED.local_path`, + [], + ); await runCycle(engine, { brainDir, phases: ['lint'], }); - // No per-source write happens; default source's config stays empty. - const after = await readLastFullCycleAt('default-like'); - expect(after).toBeNull(); + expect(await readLastFullCycleAt('unmatched')).toBeNull(); }); });