Skip to content
Merged
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
53 changes: 53 additions & 0 deletions packages/asil-improvement-loop/src/__tests__/task-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/asil-improvement-loop/src/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
60 changes: 54 additions & 6 deletions packages/asil-improvement-loop/src/task-queue.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import type {
DequeueMode,
ImprovementTask,
QueueItem,
QueueStatus,
Expand All @@ -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,
Expand Down Expand Up @@ -36,18 +42,28 @@ 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 {
private items: QueueItem[] = [];
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();
}

Expand All @@ -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;
Expand Down
17 changes: 17 additions & 0 deletions packages/asil-improvement-loop/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ export const CATEGORY_SKILL_MAP: Record<TaskCategory, string> = {

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
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions packages/asil-runners/src/run-a.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand All @@ -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];
Expand Down Expand Up @@ -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;
}
Expand All @@ -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<void> {
Expand Down Expand Up @@ -182,6 +190,7 @@ export async function main(): Promise<void> {
` 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);
Expand Down Expand Up @@ -297,6 +306,7 @@ export async function main(): Promise<void> {
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',
Expand Down
Loading