diff --git a/trios/agent-server/apps/server/src/api/services/queen-priority.gen.js b/trios/agent-server/apps/server/src/api/services/queen-priority.gen.js new file mode 100644 index 0000000000..38249cf3db --- /dev/null +++ b/trios/agent-server/apps/server/src/api/services/queen-priority.gen.js @@ -0,0 +1,51 @@ +// Generated by `t27c gen-js` from priority.t27. Do not edit. +// +// Every name and every value here comes from that spec. Change the spec and +// regenerate; an edit made here is lost the next time anyone builds. + +export const LEVEL_CRITICAL = 0; +export const LEVEL_HIGH = 1; +export const LEVEL_NORMAL = 2; +export const LEVEL_LOW = 3; +export const CRITICAL_LABELS = "P0,priority/critical,critical"; +export const HIGH_LABELS = "P1,priority/high,high-priority"; +export const NORMAL_LABELS = "P2,priority/medium"; +export const LOW_LABELS = "P3,P4,priority/low"; +export const AGING_DAYS = 14; +export const MAX_AGING_STEPS = 1; +export const CRITICAL_CAP = 3; +export const WHY_LISTING = 0; +export const WHY_LABEL = 1; +export const WHY_AGED = 2; +export const WHY_CAPPED = 3; +export const WHY_BLOCKED = 4; +// t27c gen-js: fn capped_level was not emitted -- this backend lowers declarations, not bodies. +// t27c gen-js: fn effective_level was not emitted -- this backend lowers declarations, not bodies. +// t27c gen-js: fn eligible was not emitted -- this backend lowers declarations, not bodies. +// t27c gen-js: fn outranks was not emitted -- this backend lowers declarations, not bodies. +// t27c gen-js: fn why was not emitted -- this backend lowers declarations, not bodies. +// t27c gen-js: a TestBlock was not emitted -- it is checked by the compiler, not by the artifact. +// t27c gen-js: a TestBlock was not emitted -- it is checked by the compiler, not by the artifact. +// t27c gen-js: a TestBlock was not emitted -- it is checked by the compiler, not by the artifact. +// t27c gen-js: a TestBlock was not emitted -- it is checked by the compiler, not by the artifact. +// t27c gen-js: a TestBlock was not emitted -- it is checked by the compiler, not by the artifact. +// t27c gen-js: a TestBlock was not emitted -- it is checked by the compiler, not by the artifact. +// t27c gen-js: a TestBlock was not emitted -- it is checked by the compiler, not by the artifact. +// t27c gen-js: a TestBlock was not emitted -- it is checked by the compiler, not by the artifact. +// t27c gen-js: a InvariantBlock was not emitted -- it is checked by the compiler, not by the artifact. + +// Declaration order, which the spec's own laws depend on. +export const __STRUCT_ORDER__ = []; +export const __DECL_ORDER__ = ["LEVEL_CRITICAL", "LEVEL_HIGH", "LEVEL_NORMAL", "LEVEL_LOW", "CRITICAL_LABELS", "HIGH_LABELS", "NORMAL_LABELS", "LOW_LABELS", "AGING_DAYS", "MAX_AGING_STEPS", "CRITICAL_CAP", "WHY_LISTING", "WHY_LABEL", "WHY_AGED", "WHY_CAPPED", "WHY_BLOCKED"]; + +// What this spec holds and this backend did not print. Empty is the whole +// story most of the time; an entry here is a promise the artifact does not +// keep, and reading it is how a tool tells a partial module from a complete +// one without parsing comments. +// +// A LIST, not a map keyed by name. `specs/numeric/formats.t27` declares four +// separate consts called `result`, one per test block, and under an object +// literal three of the four omissions vanished into the fourth -- a record of +// what went missing that itself went missing. A spec is free to reuse a +// name; this file is not free to lose the second one. +export const __NOT_EMITTED__ = Object.freeze([]); diff --git a/trios/agent-server/apps/server/src/api/services/queen-priority.ts b/trios/agent-server/apps/server/src/api/services/queen-priority.ts new file mode 100644 index 0000000000..dd3b21086c --- /dev/null +++ b/trios/agent-server/apps/server/src/api/services/queen-priority.ts @@ -0,0 +1,212 @@ +/** + * The order the Queen takes open issues in. + * + * SOURCE. gHashTag/t27 `specs/queen/priority.t27` (PR gHashTag/t27#6367, + * commit c44c7aea32a50abe3b73cb65368a0c9403091698, blob fbbf548c). The + * vocabulary - levels, label lists, aging, the critical cap, the why codes - + * is NOT restated here: it is imported from `queen-priority.gen.js`, the + * verbatim output of `t27c gen-js specs/queen/priority.t27` (t27c 0.4.0). + * Change the spec and regenerate; never edit the generated file. + * + * `t27c gen-js` lowers declarations, not bodies, so the five functions below + * are written by hand. Each one mirrors the spec function of the same name + * line for line; if they differ, the spec is right. The rank itself mirrors + * `rank()` in t27 `scripts/tri_loop/queue.py` (`tri queue`), so the order + * that command prints is the order this supervisor takes. + */ +import { + AGING_DAYS, + CRITICAL_CAP, + CRITICAL_LABELS, + HIGH_LABELS, + LEVEL_CRITICAL, + LEVEL_HIGH, + LEVEL_NORMAL, + LOW_LABELS, + MAX_AGING_STEPS, + NORMAL_LABELS, + WHY_AGED, + WHY_BLOCKED, + WHY_CAPPED, + WHY_LABEL, + WHY_LISTING, +} from './queen-priority.gen' + +export const LEVEL_NAMES = ['CRITICAL', 'HIGH', 'NORMAL', 'LOW'] as const +export const WHY_NAMES = [ + 'listing', + 'label', + 'aged', + 'capped', + 'blocked', +] as const + +/** spec: capped_level */ +export function cappedLevel(base: number, criticalsBefore: number): number { + if (base === LEVEL_CRITICAL) { + if (criticalsBefore >= CRITICAL_CAP) { + return LEVEL_HIGH + } + } + return base +} + +/** spec: effective_level */ +export function effectiveLevel( + level: number, + labelled: boolean, + ageDays: number, +): number { + if (labelled === false) { + return level + } + if (level <= LEVEL_HIGH) { + return level + } + let steps = Math.floor(ageDays / AGING_DAYS) + if (steps > MAX_AGING_STEPS) { + steps = MAX_AGING_STEPS + } + const gap = level - LEVEL_HIGH + if (steps >= gap) { + return LEVEL_HIGH + } + return level - steps +} + +/** spec: eligible */ +export function eligible(openBlockers: number): boolean { + return openBlockers === 0 +} + +/** spec: outranks */ +export function outranks( + effA: number, + levelA: number, + indexA: number, + effB: number, + levelB: number, + indexB: number, +): boolean { + if (effA < effB) return true + if (effA > effB) return false + if (levelA < levelB) return true + if (levelA > levelB) return false + return indexA < indexB +} + +/** spec: why */ +export function why( + base: number, + level: number, + eff: number, + labelled: boolean, + openBlockers: number, +): number { + if (openBlockers > 0) return WHY_BLOCKED + if (level !== base) return WHY_CAPPED + if (eff !== level) return WHY_AGED + if (labelled) return WHY_LABEL + return WHY_LISTING +} + +/** label -> level, from the spec's lists. A label listed twice keeps the more urgent level. */ +const VOCABULARY: ReadonlyMap = (() => { + const lists = [CRITICAL_LABELS, HIGH_LABELS, NORMAL_LABELS, LOW_LABELS] + const out = new Map() + for (let level = lists.length - 1; level >= 0; level--) { + for (const label of lists[level].split(',')) out.set(label, level) + } + return out +})() + +/** The most urgent level among the labels; an issue with none of the spec's labels is NORMAL, unlabelled. */ +export function baseLevel(labels: readonly string[]): { + level: number + labelled: boolean +} { + const hits = labels + .map((name) => VOCABULARY.get(name)) + .filter((level): level is number => level !== undefined) + return hits.length > 0 + ? { level: Math.min(...hits), labelled: true } + : { level: LEVEL_NORMAL, labelled: false } +} + +/** What the rank reads from one open issue. */ +export interface RankableIssue { + number: number + labels: readonly string[] + /** ISO 8601, as GitHub sends `created_at`. */ + createdAt: string + /** `issue_dependencies_summary.blocked_by`. */ + blockedBy: number +} + +export interface RankedIssue { + issue: T + index: number + ageDays: number + base: number + labelled: boolean + level: number + eff: number + eligible: boolean + why: (typeof WHY_NAMES)[number] +} + +const DAY_MS = 86_400_000 + +/** + * Issues in GitHub's listing order -> every issue with its decision, in the + * order the spec takes them (stable: equal ranks keep the listing order). + * Blocked issues are kept in the result with `eligible: false`, so a caller can + * say what it skipped; the Queen's candidates are the eligible ones. + */ +export function rankIssues( + issues: readonly T[], + now: Date, +): RankedIssue[] { + let criticals = 0 + const rows = issues.map((issue, index) => { + const { level: base, labelled } = baseLevel(issue.labels) + const level = cappedLevel(base, criticals) + if (base === LEVEL_CRITICAL) criticals += 1 + const created = Date.parse(issue.createdAt) + const ageDays = Number.isFinite(created) + ? Math.max(0, Math.floor((now.getTime() - created) / DAY_MS)) + : 0 + const eff = effectiveLevel(level, labelled, ageDays) + const blockers = issue.blockedBy + return { + issue, + index, + ageDays, + base, + labelled, + level, + eff, + eligible: eligible(blockers), + why: WHY_NAMES[why(base, level, eff, labelled, blockers)], + } + }) + return rows.sort((a, b) => { + if (outranks(a.eff, a.level, a.index, b.eff, b.level, b.index)) return -1 + if (outranks(b.eff, b.level, b.index, a.eff, a.level, a.index)) return 1 + return 0 + }) +} + +/** One line for the round's log: the top pick, its level, and why it is there. */ +export function topPickLine(ranked: readonly RankedIssue[]): string { + const top = ranked.find((r) => r.eligible) + const skipped = ranked.length - ranked.filter((r) => r.eligible).length + if (!top) { + return `priority: no eligible issue (${skipped} blocked)` + } + return ( + `priority: top #${top.issue.number} ${LEVEL_NAMES[top.eff]} ` + + `(base ${LEVEL_NAMES[top.base]}, why=${top.why}, listing ${top.index}, ` + + `age ${top.ageDays}d); ${skipped} blocked skipped` + ) +} diff --git a/trios/agent-server/apps/server/src/api/services/queen-tick.ts b/trios/agent-server/apps/server/src/api/services/queen-tick.ts index 67e71f8886..bf8bbccc02 100644 --- a/trios/agent-server/apps/server/src/api/services/queen-tick.ts +++ b/trios/agent-server/apps/server/src/api/services/queen-tick.ts @@ -75,6 +75,7 @@ import { queenLeaseDatabaseUrl, releaseQueenLease, } from './queen-lease' +import { rankIssues, topPickLine } from './queen-priority' import { containerRefusal, type DispatchReportOutcome, @@ -331,11 +332,26 @@ export function oracleOutcome(witness: Witness | null): string { return failing.every((s) => s.oraclePreBroken) ? 'pre-broken' : 'fail' } +/** + * One open issue as the round reads it. `labels`, `createdAt` and `blockedBy` + * are what the priority rule needs (queen-priority.ts, gHashTag/t27 + * specs/queen/priority.t27); they come in the same response, so ranking costs + * no extra request. + */ +export interface OpenIssue { + number: number + body: string + title: string + labels: string[] + createdAt: string + blockedBy: number +} + export async function openIssues(repo: string): Promise<{ - issues: Array<{ number: number; body: string; title: string }> + issues: OpenIssue[] complete: boolean }> { - const collected: Array<{ number: number; body: string; title: string }> = [] + const collected: OpenIssue[] = [] let complete = false const cap = issuePageCap() for (let page = 1; page <= cap; page++) { @@ -350,6 +366,9 @@ export async function openIssues(repo: string): Promise<{ title?: string body?: string | null pull_request?: unknown + labels?: Array<{ name?: string } | string> + created_at?: string + issue_dependencies_summary?: { blocked_by?: number } | null }> // The issues endpoint returns pull requests too, and a PR is not work to // delegate - it is work already done waiting for a verdict. @@ -363,6 +382,11 @@ export async function openIssues(repo: string): Promise<{ number: i.number, body: i.body ?? '', title: i.title ?? `#${i.number}`, + labels: (i.labels ?? []) + .map((l) => (typeof l === 'string' ? l : (l.name ?? ''))) + .filter((name) => name !== ''), + createdAt: i.created_at ?? '', + blockedBy: Number(i.issue_dependencies_summary?.blocked_by ?? 0) || 0, }) } // The RAW page length decides, not the filtered one: a page that was all @@ -1531,7 +1555,16 @@ export async function runRound( ?.verdicts ?? {} } else { const { issues: open, complete } = await openIssues(repo) - candidates = open.map((i) => i.number) + // Priority order, gHashTag/t27 specs/queen/priority.t27: an issue with an + // open blocker is not a candidate, and the rest are taken by label level + // (aged, critical-capped), ties in GitHub's listing order. `queend choose` + // still takes the first eligible candidate in the order it is handed, so + // ordering its input is the whole change. With no priority labels and no + // blockers this is exactly the listing order the round used before. + // `open` itself stays whole: the board below remembers blocked issues too. + const ranked = rankIssues(open, new Date()) + candidates = ranked.filter((r) => r.eligible).map((r) => r.issue.number) + logger.info(topPickLine(ranked)) candidateBodies = Object.fromEntries( open.map((i) => [String(i.number), i.body]), ) diff --git a/trios/agent-server/apps/server/tests/api/queen-issue-pages.test.ts b/trios/agent-server/apps/server/tests/api/queen-issue-pages.test.ts index 6fd1309271..0e5ddeedc6 100644 --- a/trios/agent-server/apps/server/tests/api/queen-issue-pages.test.ts +++ b/trios/agent-server/apps/server/tests/api/queen-issue-pages.test.ts @@ -1,6 +1,13 @@ import { afterEach, describe, expect, it } from 'bun:test' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import type { Pool } from 'pg' -import { openIssues, rememberIssues } from '../../src/api/services/queen-tick' +import { rankIssues, topPickLine } from '../../src/api/services/queen-priority' +import { + type OpenIssue, + openIssues, + rememberIssues, +} from '../../src/api/services/queen-tick' const realFetch = globalThis.fetch afterEach(() => { @@ -114,3 +121,137 @@ describe('remembering the issue list', () => { ) }) }) + +/** + * gHashTag/t27 specs/queen/priority.t27, as the round applies it. Each case + * below is one of the spec's own tests or the failure it names. + */ +describe('taking open issues in priority order', () => { + const NOW = new Date('2026-10-05T00:00:00Z') + const daysAgo = (n: number) => + new Date(NOW.getTime() - n * 86_400_000).toISOString() + + /** One open issue, in the shape `openIssues` returns. */ + function open( + number: number, + labels: string[] = [], + opts: { age?: number; blockedBy?: number } = {}, + ): OpenIssue { + return { + number, + title: `#${number}`, + body: '', + labels, + createdAt: daysAgo(opts.age ?? 1), + blockedBy: opts.blockedBy ?? 0, + } + } + /** The candidate list the round hands `queend choose`. */ + const order = (issues: OpenIssue[]) => + rankIssues(issues, NOW) + .filter((r) => r.eligible) + .map((r) => r.issue.number) + + it('keeps the listing order exactly when nothing is labelled', () => { + const listing = [9, 7, 5, 3, 1].map((n, i) => open(n, [], { age: i * 30 })) + expect(order(listing)).toEqual([9, 7, 5, 3, 1]) + }) + + /** stokowski sorted Linear's 0 = "no priority" ahead of 1 = urgent. */ + it('never puts an unlabelled issue ahead of a P0', () => { + const listing = [ + open(50), + open(49, ['bug']), + open(10, ['P0'], { age: 400 }), + ] + expect(order(listing)[0]).toBe(10) + expect(order([open(50), open(11, ['priority/high'])])[0]).toBe(11) + }) + + it('runs the fourth critical of a listing as HIGH', () => { + const listing = [ + open(1, ['P0']), + open(2, ['priority/critical']), + open(3, ['critical']), + open(4, ['P0']), + open(5, ['P1']), + ] + const ranked = rankIssues(listing, NOW) + const fourth = ranked.find((r) => r.issue.number === 4) + expect(fourth?.level).toBe(1) + expect(fourth?.why).toBe('capped') + // A capped critical still beats a real HIGH listed after it only by the + // listing; it is HIGH now, and #5 is HIGH listed later. + expect(order(listing)).toEqual([1, 2, 3, 4, 5]) + }) + + it('skips an issue with an open blocker instead of waiting on it', () => { + const listing = [open(1, ['P0'], { blockedBy: 2 }), open(2), open(3)] + expect(order(listing)).toEqual([2, 3]) + const blocked = rankIssues(listing, NOW).find((r) => r.issue.number === 1) + expect(blocked?.why).toBe('blocked') + }) + + /** Aging one step, not more: "low" must not become the fast lane. */ + it('never lets an aged priority/low pass unlabelled work', () => { + const listing = [open(1, ['priority/low'], { age: 9000 }), open(2)] + expect(order(listing)).toEqual([2, 1]) + const aged = rankIssues(listing, NOW).find((r) => r.issue.number === 1) + expect(aged?.eff).toBe(2) + expect(aged?.why).toBe('aged') + }) + + it('takes the more urgent label when an issue carries two', () => { + expect(order([open(1), open(2, ['P3', 'P1'])])).toEqual([2, 1]) + }) + + it('logs the top pick with its level and reason', () => { + const line = topPickLine( + rankIssues( + [open(1), open(2, ['P0']), open(3, [], { blockedBy: 1 })], + NOW, + ), + ) + expect(line).toContain('#2 CRITICAL') + expect(line).toContain('why=label') + expect(line).toContain('1 blocked skipped') + }) + + it('reads labels, age and blockers from the same GitHub response', async () => { + globalThis.fetch = (async () => + ({ + ok: true, + json: async () => [ + { + number: 7, + title: 't', + body: '', + labels: [{ name: 'P0' }, 'P1'], + created_at: '2026-09-01T00:00:00Z', + issue_dependencies_summary: { blocked_by: 2 }, + }, + { number: 8, title: 'u', body: null }, + ], + }) as Response) as typeof fetch + const { issues } = await openIssues('gHashTag/BrowserOS') + expect(issues[0]).toMatchObject({ + number: 7, + labels: ['P0', 'P1'], + createdAt: '2026-09-01T00:00:00Z', + blockedBy: 2, + }) + expect(issues[1]).toMatchObject({ labels: [], createdAt: '', blockedBy: 0 }) + }) + + /** The vocabulary is generated, not copied: the file must say so. */ + it('imports the vocabulary from the generated module', () => { + const dir = join(import.meta.dir, '../../src/api/services') + const gen = readFileSync(join(dir, 'queen-priority.gen.js'), 'utf8') + expect( + gen.startsWith('// Generated by `t27c gen-js` from priority.t27.'), + ).toBe(true) + const rule = readFileSync(join(dir, 'queen-priority.ts'), 'utf8') + expect(rule).toContain("from './queen-priority.gen'") + expect(rule).not.toContain('priority/critical') + }) +}) diff --git a/trios/agent-server/apps/server/tests/api/queen-round.test.ts b/trios/agent-server/apps/server/tests/api/queen-round.test.ts index 79db4a1ba8..dacc60b318 100644 --- a/trios/agent-server/apps/server/tests/api/queen-round.test.ts +++ b/trios/agent-server/apps/server/tests/api/queen-round.test.ts @@ -631,6 +631,72 @@ describe('the brief promises only what the system does', () => { * Unset is a configuration error. It must stop the round rather than pick a * repository, because reading the wrong one looks exactly like working. */ +/** + * The round takes open issues in the order gHashTag/t27 + * specs/queen/priority.t27 gives them, and `queend choose` takes the first + * eligible one it is handed - so the order the round hands it IS the policy. + * Before this the round handed GitHub's listing order and read no label: a P0 + * listed after a newer unlabelled issue waited behind it. + */ +describe('queen round, priority order', () => { + const issueBody = (n: number) => + [ + '## Success Criteria', + '- make check exits 0.', + '', + '## Boundary', + `\`docs/only-${n}.md\``, + ].join('\n') + const listed = (number: number, labels: string[] = [], blockedBy = 0) => ({ + number, + title: `#${number}`, + body: issueBody(number), + labels: labels.map((name) => ({ name })), + created_at: '2026-10-01T00:00:00Z', + issue_dependencies_summary: { blocked_by: blockedBy }, + }) + function serveListing(items: Array>) { + globalThis.fetch = (async (input: string | URL | Request) => { + const url = new URL(String(input)) + if ( + url.pathname.endsWith('/issues') && + url.searchParams.get('page') === '1' + ) { + return new Response(JSON.stringify(items), { status: 200 }) + } + return new Response('[]', { status: 200 }) + }) as typeof fetch + } + + it.if(present)('chooses the P0 over newer unlabelled work', async () => { + serveListing([listed(30), listed(20), listed(10, ['P0'])]) + const { pool } = roundPool() + const result = await runRound(pool, 'me', 7, { held: false }) + expect(result.choice?.chosen).toBe(10) + }) + + it.if(present)( + 'moves past a blocked P0 rather than waiting on it', + async () => { + serveListing([listed(10, ['P0'], 1), listed(30)]) + const { pool } = roundPool() + const result = await runRound(pool, 'me', 7, { held: false }) + expect(result.choice?.chosen).toBe(30) + }, + ) + + /** The control: no labels, no blockers, and the round is what it was. */ + it.if(present)( + 'keeps the listing order when nothing is labelled', + async () => { + serveListing([listed(30), listed(20), listed(10)]) + const { pool } = roundPool() + const result = await runRound(pool, 'me', 7, { held: false }) + expect(result.choice?.chosen).toBe(30) + }, + ) +}) + describe('queen round, repository named', () => { it('refuses to run rather than guess a repository', async () => { const before = process.env.TRIOS_GITHUB_REPO diff --git a/trios/agent-server/biome.json b/trios/agent-server/biome.json index 927ab04577..beaf818bcf 100644 --- a/trios/agent-server/biome.json +++ b/trios/agent-server/biome.json @@ -10,7 +10,8 @@ "includes": [ "**", "!**/apps/eval/src/dashboard/index.html", - "!**/apps/server/src/api/services/queen-contributor-policy.gen.ts" + "!**/apps/server/src/api/services/queen-contributor-policy.gen.ts", + "!**/apps/server/src/api/services/queen-priority.gen.js" ] }, "formatter": {