From 8ef2ecd7e70adf30b07bf6ffc6616d5ecd3cc452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Du=C5=A1an=20Milicevic?= Date: Thu, 18 Jun 2026 00:20:43 -0500 Subject: [PATCH] feat(queue): category round-robin dequeue mode (Codex #4) Strict priority order means one busy category (e.g. dozens of test-failures) is drained completely before the loop ever touches a type-error or vulnerability. Add an opt-in round-robin selection mode. - `DequeueMode = 'priority' | 'round-robin'` (types.ts). - `TaskQueue` gains a `dequeueMode` option. Round-robin walks the category ring in priority order starting just after the last-served category, so each category with eligible work gets a turn per cycle; rotation stays fair even when a category empties mid-cycle (advance forward, never restart at the top). Within a category, the highest-priority task is still served first. - Shared `isEligible()` predicate now backs both modes (queued, under maxAttempts, meets the severity floor). - `ImprovementLoopConfig.dequeueMode` (optional, defaults to `priority`) threads through `runLoop`. - `run:a --round-robin` exposes it; the run summary prints the active order. Default behavior is unchanged: omitting the mode keeps strict priority order. Adds queue tests: full-cycle rotation across categories, fair fallback when a category empties mid-cycle, highest-severity-first within a category, and a priority-mode guard. Co-Authored-By: Claude Opus 4.8 --- .../src/__tests__/task-queue.test.ts | 53 ++++++++++++++++ packages/asil-improvement-loop/src/loop.ts | 1 + .../asil-improvement-loop/src/task-queue.ts | 60 +++++++++++++++++-- packages/asil-improvement-loop/src/types.ts | 17 ++++++ packages/asil-runners/src/run-a.ts | 10 ++++ 5 files changed, 135 insertions(+), 6 deletions(-) diff --git a/packages/asil-improvement-loop/src/__tests__/task-queue.test.ts b/packages/asil-improvement-loop/src/__tests__/task-queue.test.ts index 1d71506..9ee9647 100644 --- a/packages/asil-improvement-loop/src/__tests__/task-queue.test.ts +++ b/packages/asil-improvement-loop/src/__tests__/task-queue.test.ts @@ -154,6 +154,59 @@ describe('TaskQueue', () => { }); }); + describe('round-robin dequeue (Codex #4)', () => { + it('rotates across categories instead of draining the top one first', () => { + const q = new TaskQueue(path, { dequeueMode: 'round-robin' }); + // Three test-failures (top priority) + one type-error + one vulnerability. + q.enqueue(mkCategoryTask('test-failure', 'medium', 'tf1')); + q.enqueue(mkCategoryTask('test-failure', 'medium', 'tf2')); + q.enqueue(mkCategoryTask('test-failure', 'medium', 'tf3')); + q.enqueue(mkCategoryTask('type-error', 'medium', 'te1')); + q.enqueue(mkCategoryTask('vulnerability', 'medium', 'vu1')); + + const cats = [ + q.dequeue()?.task.category, + q.dequeue()?.task.category, + q.dequeue()?.task.category, + ]; + // First full cycle visits each category-with-work exactly once. + expect(cats).toEqual(['test-failure', 'type-error', 'vulnerability']); + }); + + it('falls back to remaining categories once others are exhausted', () => { + const q = new TaskQueue(path, { dequeueMode: 'round-robin' }); + q.enqueue(mkCategoryTask('test-failure', 'medium', 'tf1')); + q.enqueue(mkCategoryTask('test-failure', 'medium', 'tf2')); + q.enqueue(mkCategoryTask('type-error', 'medium', 'te1')); + + const order = [ + q.dequeue()?.task.id, + q.dequeue()?.task.id, + q.dequeue()?.task.id, + ]; + // tf, te (rotation), then back to the leftover tf. + expect(order).toEqual(['tf1', 'te1', 'tf2']); + expect(q.dequeue()).toBeNull(); + }); + + it('within a category, still serves the highest-severity task first', () => { + const q = new TaskQueue(path, { dequeueMode: 'round-robin' }); + q.enqueue(mkCategoryTask('test-failure', 'low', 'tf-low')); + q.enqueue(mkCategoryTask('test-failure', 'critical', 'tf-crit')); + expect(q.dequeue()?.task.id).toBe('tf-crit'); + }); + + it('priority mode (default) drains the top category first', () => { + const q = new TaskQueue(path); // default: priority + q.enqueue(mkCategoryTask('test-failure', 'medium', 'tf1')); + q.enqueue(mkCategoryTask('test-failure', 'medium', 'tf2')); + q.enqueue(mkCategoryTask('type-error', 'medium', 'te1')); + expect(q.dequeue()?.task.category).toBe('test-failure'); + expect(q.dequeue()?.task.category).toBe('test-failure'); + expect(q.dequeue()?.task.category).toBe('type-error'); + }); + }); + describe('meetsSeverityFloor', () => { it('passes when severity is at or above the floor', () => { expect(meetsSeverityFloor('critical', 'high')).toBe(true); diff --git a/packages/asil-improvement-loop/src/loop.ts b/packages/asil-improvement-loop/src/loop.ts index 4f2037a..9a67314 100644 --- a/packages/asil-improvement-loop/src/loop.ts +++ b/packages/asil-improvement-loop/src/loop.ts @@ -110,6 +110,7 @@ export async function runLoop( new TaskQueue(config.queuePath, { maxAttempts: config.maxAttempts, ...(config.minSeverity ? { minSeverity: config.minSeverity } : {}), + ...(config.dequeueMode ? { dequeueMode: config.dequeueMode } : {}), }); const cycleDetector = deps.cycleDetector ?? new CycleDetector(); const outcomes: TaskOutcome[] = []; diff --git a/packages/asil-improvement-loop/src/task-queue.ts b/packages/asil-improvement-loop/src/task-queue.ts index 4c20493..14f4e56 100644 --- a/packages/asil-improvement-loop/src/task-queue.ts +++ b/packages/asil-improvement-loop/src/task-queue.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import type { + DequeueMode, ImprovementTask, QueueItem, QueueStatus, @@ -9,6 +10,11 @@ import type { } from './types.js'; import { CATEGORY_PRIORITY, SEVERITY_RANK } from './types.js'; +/** Categories ordered by priority (most important first). */ +const CATEGORIES_BY_PRIORITY = ( + Object.keys(CATEGORY_PRIORITY) as TaskCategory[] +).sort((a, b) => CATEGORY_PRIORITY[a] - CATEGORY_PRIORITY[b]); + /** True when `severity` is at least as severe as `floor`. */ export function meetsSeverityFloor( severity: Severity, @@ -36,6 +42,12 @@ export interface TaskQueueOptions { * dequeue. Default: `low` (accept everything). */ minSeverity?: Severity; + /** + * Task selection strategy. `priority` (default) serves strict priority + * order; `round-robin` rotates through categories with eligible work so one + * busy category can't starve the rest. + */ + dequeueMode?: DequeueMode; } export class TaskQueue { @@ -43,11 +55,15 @@ export class TaskQueue { private readonly persistPath: string; private readonly maxAttempts: number; private readonly minSeverity: Severity; + private readonly dequeueMode: DequeueMode; + /** Last category served, for round-robin rotation (in-memory only). */ + private lastCategory: TaskCategory | null = null; constructor(persistPath: string, options: TaskQueueOptions = {}) { this.persistPath = persistPath; this.maxAttempts = options.maxAttempts ?? 2; this.minSeverity = options.minSeverity ?? 'low'; + this.dequeueMode = options.dequeueMode ?? 'priority'; this.load(); } @@ -67,14 +83,46 @@ export class TaskQueue { this.persist(); } - dequeue(): QueueItem | null { - const next = this.items.find( - (i) => - i.status === 'queued' && - i.attempts < i.maxAttempts && - meetsSeverityFloor(i.task.severity, this.minSeverity), + /** Whether an item can be served right now. */ + private isEligible(i: QueueItem): boolean { + return ( + i.status === 'queued' && + i.attempts < i.maxAttempts && + meetsSeverityFloor(i.task.severity, this.minSeverity) ); + } + + /** + * Round-robin pick: walk the category ring (in priority order) starting + * just after the last-served category, and return the first eligible task + * found. This keeps rotation fair even when a category empties mid-cycle — + * we always advance forward rather than restarting at the top. Within the + * chosen category, `items` is kept priority-sorted so the first match is + * the highest-priority one. + */ + private pickRoundRobin(): QueueItem | undefined { + const ring = CATEGORIES_BY_PRIORITY; + const startIdx = + this.lastCategory === null + ? 0 + : (ring.indexOf(this.lastCategory) + 1) % ring.length; + for (let k = 0; k < ring.length; k += 1) { + const cat = ring[(startIdx + k) % ring.length]!; + const item = this.items.find( + (i) => i.task.category === cat && this.isEligible(i), + ); + if (item) return item; + } + return undefined; + } + + dequeue(): QueueItem | null { + const next = + this.dequeueMode === 'round-robin' + ? this.pickRoundRobin() + : this.items.find((i) => this.isEligible(i)); if (!next) return null; + this.lastCategory = next.task.category; next.status = 'running'; next.attempts += 1; diff --git a/packages/asil-improvement-loop/src/types.ts b/packages/asil-improvement-loop/src/types.ts index ddf76fc..74131a6 100644 --- a/packages/asil-improvement-loop/src/types.ts +++ b/packages/asil-improvement-loop/src/types.ts @@ -40,6 +40,17 @@ export const CATEGORY_SKILL_MAP: Record = { export type Severity = 'critical' | 'high' | 'medium' | 'low'; +/** + * How the queue chooses the next task to run. + * + * - `priority`: strict priority order (category priority + severity). One + * busy category can monopolize a run. + * - `round-robin`: rotate through the categories that have eligible work, in + * priority order, so no single category starves the others. Within a + * category, the highest-priority task is still served first. + */ +export type DequeueMode = 'priority' | 'round-robin'; + /** * Ordinal rank for severities — lower is more severe. Used to compare a * task's severity against a configured floor (`minSeverity`): a task passes @@ -189,6 +200,12 @@ export interface ImprovementLoopConfig { * everything) when omitted, preserving prior behavior. */ minSeverity?: Severity; + /** + * Task selection strategy. Defaults to `priority` when omitted, preserving + * prior behavior. Use `round-robin` to keep one noisy category from + * starving the others within a run. + */ + dequeueMode?: DequeueMode; codexConfig: { apiKey: string; model: string; diff --git a/packages/asil-runners/src/run-a.ts b/packages/asil-runners/src/run-a.ts index 7d350d6..58c5371 100644 --- a/packages/asil-runners/src/run-a.ts +++ b/packages/asil-runners/src/run-a.ts @@ -59,6 +59,8 @@ interface Flags { profile: 'ts' | 'python'; /** Severity floor — tasks below this are never enqueued or run. Default: low. */ minSeverity: Severity; + /** Rotate through categories so one busy category can't starve the rest. */ + roundRobin: boolean; } const SEVERITIES: readonly Severity[] = ['critical', 'high', 'medium', 'low']; @@ -76,6 +78,7 @@ function parseFlags(argv: readonly string[]): Flags | null { transcriptsDir: null, profile: 'ts', minSeverity: 'low', + roundRobin: false, }; for (let i = 0; i < argv.length; i += 1) { const a = argv[i]; @@ -113,6 +116,8 @@ function parseFlags(argv: readonly string[]): Flags | null { } flags.minSeverity = v as Severity; i += 1; + } else if (a === '--round-robin') { + flags.roundRobin = true; } else if (a === '--help' || a === '-h') { return null; } @@ -137,6 +142,9 @@ Options: and mypy on PATH. --min-severity SEV Severity floor. Tasks below SEV are never enqueued or run. One of: critical, high, medium, low (default: low). + --round-robin Rotate through task categories instead of strict + priority order, so one busy category can't starve the + rest within a run. --help, -h Show this help`; export async function main(): Promise { @@ -182,6 +190,7 @@ export async function main(): Promise { ` Skip: ${flags.skipCategories.length > 0 ? flags.skipCategories.join(', ') : 'none'}`, ); console.log(` Min severity: ${flags.minSeverity}`); + console.log(` Order: ${flags.roundRobin ? 'round-robin' : 'priority'}`); console.log(` Dry run: ${flags.dryRun}\n`); const profile = resolveProfile(flags.profile); @@ -297,6 +306,7 @@ export async function main(): Promise { resolve(env.REPO_ROOT, '.asil', 'usage-data', 'queue.json'), skipCategories: flags.skipCategories, minSeverity: flags.minSeverity, + dequeueMode: flags.roundRobin ? 'round-robin' : 'priority', codexConfig: { apiKey: 'OPENAI_API_KEY', model: 'gpt-4o',