diff --git a/src/automation/autonomousRunner.ts b/src/automation/autonomousRunner.ts index 07d227a2..9624316a 100644 --- a/src/automation/autonomousRunner.ts +++ b/src/automation/autonomousRunner.ts @@ -57,6 +57,7 @@ import { import type { DefaultRolesConfig } from '../core/types.js'; import { resolveHardTaskTimeoutMs } from '../orchestration/taskBudget.js'; import { stageTimeoutMs } from '../agents/stageTimeouts.js'; +import { DispatchRamp } from './dispatchRamp.js'; import { pipelineExecutionBoundary } from '../agents/stageExecutionBoundary.js'; import * as execution from './runnerExecution.js'; import { draftFingerprint, pruneDraftCache, readDraftCache, writeDraftCache } from './draftCache.js'; @@ -331,6 +332,7 @@ export class AutonomousRunner { private config: AutonomousConfig; private engine: DecisionEngine; private scheduler: TaskScheduler; + private readonly dispatchRamp: DispatchRamp; private readonly durableRuns: DurableRunCoordinator; private outboxDrain: Promise | null = null; private readonly schedulerHandlers = new Set>(); @@ -601,6 +603,7 @@ export class AutonomousRunner { constructor(config: AutonomousConfig) { this.config = config; + this.dispatchRamp = new DispatchRamp(config.dispatchRampClaims ?? 2); // Config files may retain model pins from a previous provider. Normalize // them before the first heartbeat; switchProvider() used to do this only // after a live dashboard toggle, so a restart leaked OpenRouter model ids @@ -3005,9 +3008,6 @@ export class AutonomousRunner { this.syslog(` Filtered: ${agentTasks.length} → ${filteredTasks.length} (skipped ${agentTasks.length - filteredTasks.length} completed/failed)`); } - // Parallel processing mode. vela runs this branch on every heartbeat - // (maxConcurrentTasks 12, pairMode true) — an early `return` here used to - // skip everything below, including the retrospective lane (AGT-4181): // it was configured and deployed for 9+ hours (2026-09-02 20:26– // 2026-09-03 07:55) and never ran once, because vela never takes the // serial branch the lane's call sat in. `else` instead of `return` so @@ -3062,7 +3062,7 @@ export class AutonomousRunner { } // Fill all available slots (worktree mode isolates each task) - const maxSlots = availableSlots; + const maxSlots = this.dispatchRamp.budget(availableSlots); // Pre-filter tasks to enabled projects only (before DecisionEngine selection) // This prevents DecisionEngine from wasting its max-slot budget on non-enabled projects. diff --git a/src/automation/dispatchRamp.test.ts b/src/automation/dispatchRamp.test.ts new file mode 100644 index 00000000..8b84b622 --- /dev/null +++ b/src/automation/dispatchRamp.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { DispatchRamp } from './dispatchRamp.js'; + +describe('DispatchRamp', () => { + it('caps first-heartbeat claims with eight free slots and a full queue', () => { + const ramp = new DispatchRamp(); + expect(ramp.budget(8)).toBe(2); + }); + + it('lifts the cap after the configured warm-up heartbeats', () => { + const ramp = new DispatchRamp(); + expect(ramp.budget(8)).toBe(2); + expect(ramp.budget(8)).toBe(2); + expect(ramp.budget(8)).toBe(8); + }); + + it('uses the configured claim cap', () => { + expect(new DispatchRamp(3).budget(8)).toBe(3); + }); +}); \ No newline at end of file diff --git a/src/automation/dispatchRamp.ts b/src/automation/dispatchRamp.ts new file mode 100644 index 00000000..36acec6f --- /dev/null +++ b/src/automation/dispatchRamp.ts @@ -0,0 +1,18 @@ +/** Limits new claims while a daemon warms up after startup. */ +export class DispatchRamp { + private heartbeatCount = 0; + + constructor( + private readonly claimsPerHeartbeat = 2, + private readonly rampHeartbeats = 2, + ) {} + + budget(availableSlots: number): number { + const slots = Math.max(0, Math.floor(availableSlots)); + const budget = this.heartbeatCount < this.rampHeartbeats + ? Math.min(slots, Math.max(0, Math.floor(this.claimsPerHeartbeat))) + : slots; + this.heartbeatCount++; + return budget; + } +} \ No newline at end of file diff --git a/src/automation/runnerTypes.ts b/src/automation/runnerTypes.ts index 09738411..46f4559d 100644 --- a/src/automation/runnerTypes.ts +++ b/src/automation/runnerTypes.ts @@ -33,6 +33,7 @@ export interface AutonomousConfig { */ autonomousHeartbeat?: boolean; maxConcurrentTasks?: number; + dispatchRampClaims?: number; /** Move unowned In Progress issues back to Backlog after this many idle hours. */ stalledInProgressHours?: number; /** Issues the heartbeat must not pick because a person owns them (AGT-4682). */ diff --git a/src/core/config.ts b/src/core/config.ts index a9e02c2a..4c599e60 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -399,9 +399,8 @@ const AutonomousConfigSchema = z.object({ workerTimeoutMs: z.number().min(0).default(0), /** Reviewer timeout (ms). 0/unset = pipeline per-stage ceiling (not unlimited). */ reviewerTimeoutMs: z.number().min(0).default(0), - /** Operator-controlled global task capacity. Safety comes from scopes and leases, not a hidden low cap. */ maxConcurrentTasks: z.number().int().min(1).max(256).default(64), - /** Retire stale tracker claims so In Progress means a worker actually owns the task. */ + dispatchRampClaims: z.number().int().min(1).max(256).default(2), stalledInProgressHours: z.number().min(1).max(168).default(6), /** Issues the heartbeat must not pick because a person owns them (AGT-4682). */ skip: AgentSkipConfigSchema, @@ -847,6 +846,7 @@ function transformConfig(raw: RawConfig): SwarmConfig { workerTimeoutMs: raw.autonomous.workerTimeoutMs, reviewerTimeoutMs: raw.autonomous.reviewerTimeoutMs, maxConcurrentTasks: raw.autonomous.maxConcurrentTasks, + dispatchRampClaims: raw.autonomous.dispatchRampClaims, stalledInProgressHours: raw.autonomous.stalledInProgressHours, skip: raw.autonomous.skip, maxConcurrentPerProject: raw.autonomous.maxConcurrentPerProject, diff --git a/src/core/service.ts b/src/core/service.ts index 53ffe6ca..9744d0b4 100644 --- a/src/core/service.ts +++ b/src/core/service.ts @@ -350,6 +350,7 @@ async function startServiceLocked(config: SwarmConfig): Promise { autonomousHeartbeat: heartbeatEnabled, triggerNow: heartbeatEnabled, // Execute immediately on start (heartbeat mode only) maxConcurrentTasks: config.autonomous.maxConcurrentTasks, + dispatchRampClaims: config.autonomous.dispatchRampClaims, stalledInProgressHours: config.autonomous.stalledInProgressHours, skip: config.autonomous.skip, maxConcurrentPerProject: config.autonomous.maxConcurrentPerProject, diff --git a/src/core/types.ts b/src/core/types.ts index c9036135..f572c241 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -648,6 +648,7 @@ export type AutonomousStartupConfig = { reviewerTimeoutMs?: number; /** Max concurrent tasks */ maxConcurrentTasks?: number; + dispatchRampClaims?: number; /** Move unowned In Progress issues back to Backlog after this many idle hours. */ stalledInProgressHours?: number; /** Issues the heartbeat must not pick because a person owns them (labels, epics, title tags). */