From b98fae9b610d9ef6e18dcf4edde8d400ed3aaaee Mon Sep 17 00:00:00 2001 From: Sanchal Ranjan <84386862+sanchalr@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:38:05 -0500 Subject: [PATCH] fix(autopilot): give full-cycle dispatch a 30-minute timeout floor (#2852) Dispatch timeout was derived as interval*2 with a 5-minute floor, tuned for light per-interval work. A full autopilot cycle routinely needs more than 10 minutes at common intervals, so healthy full cycles were killed mid-run. Full-cycle dispatch now gets a 30-minute floor; lighter dispatches keep the interval-derived budget. Adds a regression test for the full-cycle floor. --- src/commands/autopilot-timeout.ts | 9 +++++++++ src/commands/autopilot.ts | 7 +++++-- test/autopilot-fanout-wiring.test.ts | 16 ++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 src/commands/autopilot-timeout.ts diff --git a/src/commands/autopilot-timeout.ts b/src/commands/autopilot-timeout.ts new file mode 100644 index 000000000..0ef6b5b59 --- /dev/null +++ b/src/commands/autopilot-timeout.ts @@ -0,0 +1,9 @@ +export function resolveAutopilotDispatchTimeoutMs( + baseIntervalSeconds: number, + fullCycle: boolean, +): number { + const intervalDerivedTimeoutMs = Math.max(baseIntervalSeconds * 2 * 1000, 300_000); + return fullCycle + ? Math.max(intervalDerivedTimeoutMs, 1_800_000) + : intervalDerivedTimeoutMs; +} diff --git a/src/commands/autopilot.ts b/src/commands/autopilot.ts index 82979fb12..4f0ca39a0 100644 --- a/src/commands/autopilot.ts +++ b/src/commands/autopilot.ts @@ -38,6 +38,7 @@ import { logSelfUpgrade } from '../core/audit/self-upgrade-audit.ts'; import { detectInstallMethod } from './upgrade.ts'; import { evaluateQuietHours } from '../core/minions/quiet-hours.ts'; import { inspectLock } from '../core/db-lock.ts'; +import { resolveAutopilotDispatchTimeoutMs } from './autopilot-timeout.ts'; /** * v0.37.7.0 #1162 — classify autopilot reconnect-loop errors. @@ -689,7 +690,7 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) { const queue = new MinionQueue(engine); const slotMs = Math.floor(Date.now() / (baseInterval * 1000)) * baseInterval * 1000; const slot = new Date(slotMs).toISOString(); - const timeoutMs = Math.max(baseInterval * 2 * 1000, 300_000); + const timeoutMs = resolveAutopilotDispatchTimeoutMs(baseInterval, false); // ── v0.40 D17: per-source freshness check ──────────────────── // Runs first; independent of score gate. Submits a 'sync' job per @@ -931,7 +932,9 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) { const result = await dispatchPerSource(engine, queue, { repoPath, slot, - timeoutMs, + // Full cycles can outlive short daemon intervals. Keep lighter dispatches + // interval-derived while giving per-source consolidation enough time. + timeoutMs: resolveAutopilotDispatchTimeoutMs(baseInterval, true), fanoutMax, jsonMode, }); diff --git a/test/autopilot-fanout-wiring.test.ts b/test/autopilot-fanout-wiring.test.ts index addf8dbe3..213b5d7c7 100644 --- a/test/autopilot-fanout-wiring.test.ts +++ b/test/autopilot-fanout-wiring.test.ts @@ -15,6 +15,7 @@ import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'fs'; import { join } from 'path'; +import { resolveAutopilotDispatchTimeoutMs } from '../src/commands/autopilot-timeout.ts'; const AUTOPILOT_SRC = readFileSync( join(import.meta.dir, '..', 'src', 'commands', 'autopilot.ts'), @@ -48,6 +49,21 @@ describe('autopilot.ts ↔ dispatchPerSource wiring', () => { expect(Math.abs(dispatchIdx - fullCycleIdx)).toBeLessThan(3000); }); + test('applies the 30-minute timeout floor only to full-cycle dispatch', () => { + const baseIntervalSeconds = 60; + const intervalDerivedTimeoutMs = Math.max(baseIntervalSeconds * 2 * 1000, 300_000); + + expect(resolveAutopilotDispatchTimeoutMs(baseIntervalSeconds, true)).toBeGreaterThanOrEqual(30 * 60_000); + expect(resolveAutopilotDispatchTimeoutMs(baseIntervalSeconds, false)).toBe(intervalDerivedTimeoutMs); + + expect(AUTOPILOT_SRC).toContain( + 'const timeoutMs = resolveAutopilotDispatchTimeoutMs(baseInterval, false);', + ); + expect(AUTOPILOT_SRC).toMatch( + /dispatchPerSource\(engine, queue, \{[\s\S]{0,300}timeoutMs: resolveAutopilotDispatchTimeoutMs\(baseInterval, true\)/, + ); + }); + test('updates lastFullCycleAt on dispatch (so the 60-min floor is honored)', () => { // After the dispatchPerSource call, the lastFullCycleAt module var // must update so the next tick doesn't immediately re-fan-out.