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
46 changes: 45 additions & 1 deletion packages/asil-improvement-loop/src/__tests__/task-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { TaskQueue, priorityFor } from '../task-queue.js';
import { TaskQueue, priorityFor, meetsSeverityFloor } from '../task-queue.js';
import { mkCategoryTask, mkTask } from './helpers.js';

describe('TaskQueue', () => {
Expand Down Expand Up @@ -119,4 +119,48 @@ describe('TaskQueue', () => {
const q = new TaskQueue(path);
expect(q.stats().total).toBe(0);
});

describe('minSeverity floor (Codex #3)', () => {
it('drops below-floor tasks at enqueue, keeps at-or-above-floor', () => {
const q = new TaskQueue(path, { minSeverity: 'high' });
q.enqueue(mkCategoryTask('type-error', 'critical', 'crit'));
q.enqueue(mkCategoryTask('type-error', 'high', 'high'));
q.enqueue(mkCategoryTask('type-error', 'medium', 'med'));
q.enqueue(mkCategoryTask('type-error', 'low', 'low'));
expect(q.stats().queued).toBe(2);
const ids = q.snapshot().map((i) => i.task.id).sort();
expect(ids).toEqual(['crit', 'high']);
});

it('default floor (low) accepts every severity — backwards compatible', () => {
const q = new TaskQueue(path);
for (const sev of ['critical', 'high', 'medium', 'low'] as const) {
q.enqueue(mkCategoryTask('type-error', sev, sev));
}
expect(q.stats().queued).toBe(4);
});

it('skips below-floor tasks persisted under an older lower floor', () => {
// Persist a medium task with the default (low) floor.
const lenient = new TaskQueue(path);
lenient.enqueue(mkCategoryTask('type-error', 'medium', 'med'));
lenient.enqueue(mkCategoryTask('type-error', 'critical', 'crit'));

// Reload with a stricter floor — the medium one must not be served.
const strict = new TaskQueue(path, { minSeverity: 'high' });
const first = strict.dequeue();
expect(first?.task.id).toBe('crit');
expect(strict.dequeue()).toBeNull();
});
});

describe('meetsSeverityFloor', () => {
it('passes when severity is at or above the floor', () => {
expect(meetsSeverityFloor('critical', 'high')).toBe(true);
expect(meetsSeverityFloor('high', 'high')).toBe(true);
expect(meetsSeverityFloor('medium', 'high')).toBe(false);
expect(meetsSeverityFloor('low', 'low')).toBe(true);
expect(meetsSeverityFloor('critical', 'low')).toBe(true);
});
});
});
7 changes: 6 additions & 1 deletion packages/asil-improvement-loop/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
* a PR if everything passes. Budget enforced via asil-cost-controller.
*/
export * from './types.js';
export { TaskQueue, priorityFor } from './task-queue.js';
export {
TaskQueue,
priorityFor,
meetsSeverityFloor,
type TaskQueueOptions,
} from './task-queue.js';
export {
CycleDetector,
type CycleCheck,
Expand Down
5 changes: 4 additions & 1 deletion packages/asil-improvement-loop/src/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,10 @@ export async function runLoop(

const queue =
deps.queue ??
new TaskQueue(config.queuePath, { maxAttempts: config.maxAttempts });
new TaskQueue(config.queuePath, {
maxAttempts: config.maxAttempts,
...(config.minSeverity ? { minSeverity: config.minSeverity } : {}),
});
const cycleDetector = deps.cycleDetector ?? new CycleDetector();
const outcomes: TaskOutcome[] = [];
let cyclesDetected = 0;
Expand Down
25 changes: 23 additions & 2 deletions packages/asil-improvement-loop/src/task-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,18 @@ import type {
ImprovementTask,
QueueItem,
QueueStatus,
Severity,
TaskCategory,
} from './types.js';
import { CATEGORY_PRIORITY } from './types.js';
import { CATEGORY_PRIORITY, SEVERITY_RANK } from './types.js';

/** True when `severity` is at least as severe as `floor`. */
export function meetsSeverityFloor(
severity: Severity,
floor: Severity,
): boolean {
return SEVERITY_RANK[severity] <= SEVERITY_RANK[floor];
}

interface SerializedQueueItem {
task: Omit<ImprovementTask, 'discoveredAt'> & { discoveredAt: string };
Expand All @@ -21,20 +30,29 @@ interface SerializedQueueItem {
export interface TaskQueueOptions {
/** Default max attempts per task. Default: 2. */
maxAttempts?: number;
/**
* Severity floor. Tasks less severe than this are never enqueued, and any
* below-floor task persisted under an older, lower floor is skipped at
* dequeue. Default: `low` (accept everything).
*/
minSeverity?: Severity;
}

export class TaskQueue {
private items: QueueItem[] = [];
private readonly persistPath: string;
private readonly maxAttempts: number;
private readonly minSeverity: Severity;

constructor(persistPath: string, options: TaskQueueOptions = {}) {
this.persistPath = persistPath;
this.maxAttempts = options.maxAttempts ?? 2;
this.minSeverity = options.minSeverity ?? 'low';
this.load();
}

enqueue(task: ImprovementTask): void {
if (!meetsSeverityFloor(task.severity, this.minSeverity)) return;
if (this.items.some((i) => i.task.id === task.id)) return;

this.items.push({
Expand All @@ -51,7 +69,10 @@ export class TaskQueue {

dequeue(): QueueItem | null {
const next = this.items.find(
(i) => i.status === 'queued' && i.attempts < i.maxAttempts,
(i) =>
i.status === 'queued' &&
i.attempts < i.maxAttempts &&
meetsSeverityFloor(i.task.severity, this.minSeverity),
);
if (!next) return null;

Expand Down
18 changes: 18 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,18 @@ export const CATEGORY_SKILL_MAP: Record<TaskCategory, string> = {

export type Severity = 'critical' | 'high' | 'medium' | 'low';

/**
* Ordinal rank for severities — lower is more severe. Used to compare a
* task's severity against a configured floor (`minSeverity`): a task passes
* the floor when its rank is `<=` the floor's rank.
*/
export const SEVERITY_RANK: Record<Severity, number> = {
critical: 0,
high: 1,
medium: 2,
low: 3,
};

export interface ImprovementTask {
id: string;
category: TaskCategory;
Expand Down Expand Up @@ -171,6 +183,12 @@ export interface ImprovementLoopConfig {
repoRoot: string;
queuePath: string;
skipCategories: TaskCategory[];
/**
* Severity floor. Tasks less severe than this are never enqueued or run —
* the loop spends its budget on what matters. Defaults to `low` (accept
* everything) when omitted, preserving prior behavior.
*/
minSeverity?: Severity;
codexConfig: {
apiKey: string;
model: string;
Expand Down
20 changes: 20 additions & 0 deletions packages/asil-runners/src/run-a.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
type ImprovementLoopConfig,
type LanguageProfile,
type LoopDeps,
type Severity,
type TaskCategory,
} from 'asil-improvement-loop';
import {
Expand Down Expand Up @@ -56,8 +57,12 @@ interface Flags {
transcriptsDir: string | null;
/** Language profile selector. Default: ts. */
profile: 'ts' | 'python';
/** Severity floor — tasks below this are never enqueued or run. Default: low. */
minSeverity: Severity;
}

const SEVERITIES: readonly Severity[] = ['critical', 'high', 'medium', 'low'];

function resolveProfile(name: 'ts' | 'python'): LanguageProfile {
return name === 'python' ? pythonProfile : typescriptProfile;
}
Expand All @@ -70,6 +75,7 @@ function parseFlags(argv: readonly string[]): Flags | null {
skipQuestions: false,
transcriptsDir: null,
profile: 'ts',
minSeverity: 'low',
};
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i];
Expand Down Expand Up @@ -97,6 +103,16 @@ function parseFlags(argv: readonly string[]): Flags | null {
}
flags.profile = v;
i += 1;
} else if (a === '--min-severity' && argv[i + 1]) {
const v = argv[i + 1]!;
if (!SEVERITIES.includes(v as Severity)) {
console.error(
`Unknown --min-severity value: ${v} (expected one of: ${SEVERITIES.join(', ')})`,
);
return null;
}
flags.minSeverity = v as Severity;
i += 1;
} else if (a === '--help' || a === '-h') {
return null;
}
Expand All @@ -119,6 +135,8 @@ Options:
--profile NAME Language profile for the scanner. One of: ts, python.
Defaults to ts. Python requires pytest-json-report
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).
--help, -h Show this help`;

export async function main(): Promise<void> {
Expand Down Expand Up @@ -163,6 +181,7 @@ export async function main(): Promise<void> {
console.log(
` Skip: ${flags.skipCategories.length > 0 ? flags.skipCategories.join(', ') : 'none'}`,
);
console.log(` Min severity: ${flags.minSeverity}`);
console.log(` Dry run: ${flags.dryRun}\n`);

const profile = resolveProfile(flags.profile);
Expand Down Expand Up @@ -277,6 +296,7 @@ export async function main(): Promise<void> {
process.env.ASIL_QUEUE_PATH ??
resolve(env.REPO_ROOT, '.asil', 'usage-data', 'queue.json'),
skipCategories: flags.skipCategories,
minSeverity: flags.minSeverity,
codexConfig: {
apiKey: 'OPENAI_API_KEY',
model: 'gpt-4o',
Expand Down
Loading