Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/automation/autonomousRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> | null = null;
private readonly schedulerHandlers = new Set<Promise<void>>();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions src/automation/dispatchRamp.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
18 changes: 18 additions & 0 deletions src/automation/dispatchRamp.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
1 change: 1 addition & 0 deletions src/automation/runnerTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
4 changes: 2 additions & 2 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/core/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,7 @@ async function startServiceLocked(config: SwarmConfig): Promise<void> {
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,
Expand Down
1 change: 1 addition & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
Loading