Files
gstack/lib/ship-queue-train-gate.ts
T
Garry Tanandcapy-ai[bot] 914e7b6fb3 Write PR titles, commit messages and branch names in plain English (GST-1825) (#4146)
* Add a check that keeps tracker codes and internal jargon out of PR titles and branch names

gstack-plain-title checks the readable front of a title, branch name, PR opening or changelog entry against the repository's .gstack/plain-titles.json; one trailing issue code stays allowed. A Plain title workflow runs it on every new pull request, and /ship's push refuses a never-pushed branch whose name fails it.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Move the PR body's machine-read lines into a closing Agent notes section

The ship receipt, its runs block and the Plan line now live in one collapsed section at the end of the body, and every reader looks there first while still accepting the old receipt-at-top layout. gstack-post refuses to post a title the plain-title check refuses, and land uses the checked PR title as main's commit title.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Teach /ship to write plain titles, PR openings, commit messages and changelog entries

The README now lists what gstack is for, so a PR opening can name the goal it serves.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Keep tests, size budgets and generated files in step with plain titles and Agent notes

Copy lists that install result-code modules now include the plain-title codes, the ship goldens and size ledger are refreshed, the new workflow is classified, and the pinned prose tests read the new title and body layout.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Record the test-profile list change as non-behavioral for the shared test harness

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Keep text people add below the PR notes, and fix three smaller review findings

The notes section now ends at its own closing tag, so a collapsible added after it survives the next post. The opening check splits the body the same way the notes reader does. Wave PR titles no longer name the area, which the plain-title check could refuse. The Capy line in the PR-body steps now puts Approvals in the notes.

Review-Finding: p1/specialist-batched#1 reader=testing
Review-Finding: p1/red-team#1 reader=red-team
Review-Finding: p1/red-team#2 reader=red-team
Review-Finding: p1/red-team#3 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Mask credentials in refused-title lines and keep older PRs' plan lines readable

A refused title's printed match is masked by the secret scanner first. The Plan line reader falls back to the whole body when the Agent notes section has none, so a body written before this change keeps its plan when its first receipt lands. The notes section also ends at its own closing tag when its opener is split or marked open, or when a collapsible inside it is left unclosed.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Print a fixed placeholder when a credential in a refused title cannot be masked

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Keep a plain-title policy edit from scheduling every paid eval

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Record the paid-eval list change as non-behavioral for the shared test harness

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Recheck the PR title on every edit so a base change cannot turn a refused title green

Review-Finding: s11/targeted#1 reader=targeted
Review-Finding: s11/adversarial-native#1 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-08 00:59:38 +00:00

571 lines
41 KiB
TypeScript

/**
* Generic train gates (docs/ship-policy.md#run-a-merge-train; plan GSTA-157 PR 2): a train whose base policy
* declares `train.gates` gates every prefix T0..Tk (or T0 and S1..Sk when the train stamps) on its own
* `train-prefix/<nonce>/t<i>` ref, with every gate in the list:
*
* - `workflows`: each workflow is dispatched on the prefix ref with a `nonce` input, recorded before it is sent,
* and only the workflow_dispatch run whose run-name carries `[eval-bg <nonce>]` is adopted, never a run found by
* sha. GREEN needs that run at exactly the prefix head, every required job (`<workflow>/<job name>`) concluded
* success, the run concluded success and, when the gate declares `record`, an artifact reporting at least one
* executed test. A red run gets one `gh run rerun --failed` (CREW rule 16): a pass is GREEN with FLAKY lines,
* a second red is real; both attempts' jobs are kept.
* - `command`: a clean detached checkout of the prefix head runs on a Ubicloud VM through
* scripts/ubicloud/ubi-runner.sh, forwarding only `pass_env`; the VM's `git write-tree` must equal the prefix
* tree and exit 0 is GREEN. One spend cap per command gate covers that gate's runs across every prefix of the train.
*
* The verdict is per prefix; the train is GREEN when every prefix is, and otherwise the first red prefix after a
* green one names the culprit (classifyPrefixes). Slot 1's READY may declare FIXES_BASE_RED: T0 is then allowed
* red on exactly those cases and nothing else.
*/
import { spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { awaitNonceRun, listDispatchRuns, makeNonce, runForNonce } from '../scripts/eval-bg';
import { scriptInvocation } from './script-invocation';
import type { ShipReceipt } from './headless-artifacts';
import { extractReceipt, placeReceipt } from './ship-receipt';
import { bindShippedReceipt } from './ship-receipt-depth';
import type { CommandGate, TrainGate, WorkflowsGate } from './ship-policy';
import { git, type QueueFailure } from './ship-queue-gate';
import { execFor, repoSlug } from './ship-queue-train-github';
import {
attemptsOf, listTrains, loadTrain, nowIso, queueRoot, trainDir, trainFile,
type AttemptRecord, type GateOutcome, type GatedPrefix, type GenericVerdict, type TrainPrefix, type TrainRecord, type TrainSlot,
} from './ship-queue-train-record';
import { effect, outFailed, outOk, outPending, outRefused, prsArgs, save, type TrainEnv, type TrainOut } from './ship-queue-train';
const Q = 'gstack-ship-queue';
const UBI_RUNNER = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'ubicloud', 'ubi-runner.sh');
/** Ubicloud list price per minute of the size command gates run on (scripts/ubicloud/pregate-remote.sh). */
export const COMMAND_SIZE = 'standard-16';
export const COMMAND_USD_PER_MIN = 0.00831;
/** Create, upload and teardown minutes reserved beside a command's own timeout. */
const COMMAND_OVERHEAD_MIN = 10;
const FAILED = ['failure', 'timed_out', 'startup_failure'];
/** One dispatch unit: a workflow of a workflows gate, or a command gate (workflow 'command'). */
export interface GateUnit { gate: number; workflow: string }
export const gateUnits = (gates: readonly TrainGate[]): GateUnit[] =>
gates.flatMap((g, i) => (g.kind === 'workflows' ? g.workflows.map(workflow => ({ gate: i, workflow })) : [{ gate: i, workflow: 'command' }]));
/** The commits a generic train gates, with their refs: T0, then T1..Tk, or S1..Sk when the train stamps. */
export function gatedChain(nonce: string, prefixes: readonly TrainPrefix[], stamped?: ReadonlyArray<{ commit: string; tree: string }>): GatedPrefix[] {
const chain = [prefixes[0]!, ...(stamped ?? prefixes.slice(1))];
return chain.map((p, i) => ({ index: i, ref: `train-prefix/${nonce}/${stamped && i ? 's' : 't'}${i}`, commit: p.commit, tree: p.tree, outcome: null, red: [], detail: null }));
}
/**
* bindShippedReceipt for a train slot. A conditional receipt (its base_red block) binds only for a slot whose READY
* carried the condition (`ready --base-red --fixer`): the owner-exception and review-depth checks then run on the
* receipt without that block, so the condition is the READY's to carry and every other check still applies.
*/
export function bindTrainReceipt(root: string, baseRef: string, body: string, pr: string, accepted: readonly string[], conditional: boolean): ReturnType<typeof bindShippedReceipt> {
const b = bindShippedReceipt(root, baseRef, body, pr, accepted);
if (b.ok || b.code !== 'QUEUE_RECEIPT_CONDITIONAL' || !conditional) return b;
const { base_red: _, ...rest } = extractReceipt(body).receipt!;
return bindShippedReceipt(root, baseRef, placeReceipt(body, rest as ShipReceipt), pr, accepted);
}
/** Per-run price reservation of a command gate: its timeout plus overhead, at the list price. */
export const commandReservation = (g: CommandGate) => Math.ceil((g.timeout_sec / 60 + COMMAND_OVERHEAD_MIN) * COMMAND_USD_PER_MIN * 100) / 100;
/**
* A conditional slot rides only a generic train whose slot 1 is its fixer, and a fixer (FIXES_BASE_RED) rides only
* at slot 1. Without an explicit order the fixer moves to slot 1; with one, a fixer elsewhere is refused.
*/
export function baseRedAdmission(slots: TrainSlot[], generic: boolean, explicit: boolean): { slots: TrainSlot[]; lines: string[] } | QueueFailure {
const conditional = slots.filter(s => s.base_red);
const fixers = slots.filter(s => s.fixes_base_red?.length);
if (!conditional.length && !fixers.length) return { slots, lines: [] };
const named = [...new Set([...conditional, ...fixers])].map(s => '#' + s.pr).join(', ');
if (!generic) return { code: 'QUEUE_TRAIN_BASE_RED', message: `${named}: a base-red condition rides only a train with gates, which gates every prefix; a gstack-evals train gates only its tip` };
if (fixers.length > 1) return { code: 'QUEUE_TRAIN_BASE_RED', message: `${fixers.map(s => '#' + s.pr).join(' and ')} each declare FIXES_BASE_RED; only slot 1 may, so one train carries one fixer` };
const fixer = fixers[0];
let order = slots;
if (fixer && slots[0] !== fixer) {
if (explicit) return { code: 'QUEUE_TRAIN_BASE_RED', message: `#${fixer.pr} declares FIXES_BASE_RED but --order seats it at slot ${slots.indexOf(fixer) + 1}; a fixer must be slot 1` };
order = [fixer, ...slots.filter(s => s !== fixer)];
}
for (const s of conditional) {
if (s === fixer) return { code: 'QUEUE_TRAIN_BASE_RED', message: `#${s.pr} is conditional and declares FIXES_BASE_RED; a fixer's own head must be green` };
if (!fixer || s.base_red!.fixer !== fixer.pr) return { code: 'QUEUE_TRAIN_BASE_RED', message: `#${s.pr} is conditional on fixer #${s.base_red!.fixer}, which is not in this train at slot 1 with FIXES_BASE_RED` };
if (!(Date.parse(s.base_red!.expires_at) >= Date.parse(nowIso()))) return { code: 'QUEUE_TRAIN_BASE_RED', message: `#${s.pr}'s base-red condition expired at ${s.base_red!.expires_at}; its lane classifies again` };
}
return { slots: order, lines: [`BASE_RED: fixer #${fixer!.pr} at slot 1 (FIXES_BASE_RED: ${fixer!.fixes_base_red!.join(', ')}); conditional ${conditional.map(s => '#' + s.pr).join(', ') || '(none)'}; every prefix is gated with nothing excluded`] };
}
const sameSet = (a: readonly string[], b: readonly string[]) => a.length === b.length && [...a].sort().join('\n') === [...b].sort().join('\n');
/**
* The train verdict over its prefix verdicts. T0 red is red on main, unless slot 1 declares FIXES_BASE_RED and
* T0's red cases are exactly those. Then the first non-green prefix decides: incomplete stops the train; red with
* any later prefix green is unattributed (a repair or a new order, never an unchanged rebuild); otherwise its slot
* is the culprit and the slots before it may land.
*/
export function classifyPrefixes(p: ReadonlyArray<Pick<GatedPrefix, 'outcome' | 'red'>>, slots: ReadonlyArray<Pick<TrainSlot, 'pr' | 'fixes_base_red'>>): Omit<GenericVerdict, 'at'> | { kind: 'pending' } {
if (p.some(x => !x.outcome || x.outcome === 'PENDING')) return { kind: 'pending' };
const none = { landable: 0, culprit: null };
const t0 = p[0]!;
if (t0.outcome === 'INCOMPLETE') return { kind: 'incomplete', ...none };
const fixes = slots[0]?.fixes_base_red ?? [];
if (t0.outcome === 'RED' && !(fixes.length && sameSet(t0.red, fixes))) return { kind: 'red-on-main', ...none };
const j = p.findIndex((x, i) => i > 0 && x.outcome !== 'GREEN');
if (j < 0) return { kind: 'green', landable: slots.length, culprit: null };
if (p[j]!.outcome === 'INCOMPLETE') return { kind: 'incomplete', ...none };
if (p.slice(j + 1).some(x => x.outcome === 'GREEN')) return { kind: 'unattributed', ...none };
return { kind: 'attributed', landable: j - 1, culprit: slots[j - 1]!.pr };
}
export interface RunFacts { status: string; conclusion: string | null; head_sha: string; event: string; run_attempt: number }
export interface JobFacts { name: string; conclusion: string | null }
export interface RecordFacts { executed: number; failed: string[] }
/** One workflow run against its prefix: PENDING until completed, RED on a failed job (or failed record tests), GREEN only when nothing is missing. */
export function classifyWorkflowRun(o: { run: RunFacts; head: string; workflow: string; jobs: JobFacts[]; required: string[]; record?: RecordFacts | null }): { outcome: GateOutcome; red: string[]; detail: string } {
const { run } = o;
if (run.status !== 'completed') return { outcome: 'PENDING', red: [], detail: `run is ${run.status}` };
if (run.event !== 'workflow_dispatch') return { outcome: 'INCOMPLETE', red: [], detail: `the adopted run is a ${run.event} run, not the nonce dispatch` };
if (run.head_sha !== o.head) return { outcome: 'INCOMPLETE', red: [], detail: `the run tested ${run.head_sha.slice(0, 12) || 'no commit'}, not the prefix head ${o.head.slice(0, 12)}` };
const failedJobs = o.jobs.filter(j => FAILED.includes(j.conclusion ?? '')).map(j => `${o.workflow}/${j.name}`);
const red = o.record?.failed.length ? o.record.failed.map(id => `${o.workflow}:${id}`) : failedJobs;
if (red.length) return { outcome: 'RED', red: [...new Set(red)].sort(), detail: `${red.length} red: ${red.slice(0, 8).join(', ')}` };
const gaps: string[] = [];
for (const name of o.required) {
const hits = o.jobs.filter(j => j.name === name);
if (!hits.length) gaps.push(`required job ${o.workflow}/${name} is missing`);
else if (hits.some(j => j.conclusion !== 'success')) gaps.push(`required job ${o.workflow}/${name} concluded ${hits.map(j => j.conclusion ?? 'unknown').join('/')}`);
}
if (o.record === null) gaps.push('the declared record artifact is missing or unreadable');
else if (o.record && o.record.executed < 1) gaps.push('the record reports no executed test');
if (run.conclusion !== 'success') gaps.push(`the run concluded ${run.conclusion ?? 'unknown'}`);
if (gaps.length) return { outcome: 'INCOMPLETE', red: [], detail: gaps.join('; ') };
return { outcome: 'GREEN', red: [], detail: `${o.required.length} required job(s) green${o.record ? `, ${o.record.executed} test(s) executed` : ''}` };
}
/** JUnit XML (testcase elements) or a JSON test list ({ tests: [{ id, status }] }) to executed and failed test ids. */
export function parseTestRecord(files: Array<{ name: string; text: string }>): RecordFacts | null {
let executed = 0; const failed: string[] = []; let read = false;
for (const f of files) {
if (f.name.endsWith('.json')) {
let tests: unknown;
try { tests = (JSON.parse(f.text) as { tests?: unknown }).tests; } catch { continue; }
if (!Array.isArray(tests)) continue;
read = true;
for (const t of tests as Array<{ id?: unknown; status?: unknown }>) {
if (typeof t?.id !== 'string' || t.status === 'skip' || t.status === 'skipped') continue;
executed++;
if (t.status !== 'pass' && t.status !== 'passed') failed.push(t.id);
}
} else if (f.name.endsWith('.xml')) {
read = true;
for (const m of f.text.matchAll(/<testcase\b([^>]*?)(\/>|>([\s\S]*?)<\/testcase>)/g)) {
const attr = (k: string) => new RegExp(`\\b${k}="([^"]*)"`).exec(m[1]!)?.[1] ?? '';
const body = m[3] ?? '';
if (/<skipped\b/.test(body)) continue;
executed++;
if (/<(failure|error)\b/.test(body)) failed.push([attr('classname'), attr('name')].filter(Boolean).join('.'));
}
}
}
return read ? { executed, failed: failed.sort() } : null;
}
/** Whether a workflow's concurrency groups key on the ref, the run or the nonce; a global group (or one keyed only on github.head_ref, empty on a dispatch) serializes the prefix dispatches. */
export function concurrencyKeyed(yaml: string): boolean {
const groups = [...yaml.matchAll(/^\s*concurrency:[ \t]*(.*)$/gm)].map(m => {
const inline = m[1]!.trim();
if (inline && !inline.startsWith('#')) return inline;
const after = yaml.slice(m.index! + m[0].length).split('\n').slice(1, 4).join('\n');
return /^\s*group:\s*(.+)$/m.exec(after)?.[1] ?? '';
});
return groups.every(g => /github\.(ref|run_id)\b|inputs\.nonce/.test(g));
}
const json = <T>(r: { ok: boolean; out: string }): T | null => { if (!r.ok) return null; try { return JSON.parse(r.out) as T; } catch { return null; } };
function readRun(env: TrainEnv, slug: string, id: number): RunFacts | null {
const r = json<Partial<RunFacts>>(env.ctx.gh(['api', `repos/${slug}/actions/runs/${id}`]));
return r && typeof r.status === 'string' ? { status: r.status, conclusion: r.conclusion ?? null, head_sha: r.head_sha ?? '', event: r.event ?? '', run_attempt: Number(r.run_attempt) || 1 } : null;
}
function readJobs(env: TrainEnv, slug: string, id: number, attempt: number): JobFacts[] | string {
const r = json<{ total_count?: number; jobs?: Array<{ name?: string; conclusion?: string | null }> }>(env.ctx.gh(['api', `repos/${slug}/actions/runs/${id}/attempts/${attempt}/jobs?per_page=100`]));
if (!Array.isArray(r?.jobs)) return `the jobs of run ${id} attempt ${attempt} are unreadable`;
if ((r.total_count ?? 0) > 100) return `run ${id} has ${r.total_count} jobs; a gate reads at most 100`;
return r.jobs.map(j => ({ name: String(j.name ?? ''), conclusion: j.conclusion ?? null }));
}
function readRecord(env: TrainEnv, slug: string, t: TrainRecord, id: number, name: string): RecordFacts | null {
const dir = path.join(trainDir(t.nonce), 'scratch', `${id}-${name}`);
fs.rmSync(dir, { recursive: true, force: true });
fs.mkdirSync(dir, { recursive: true });
try {
if (!env.ctx.gh(['run', 'download', String(id), '--repo', slug, '-n', name, '-D', dir]).ok) return null;
const files = (fs.readdirSync(dir, { recursive: true }) as string[]).filter(f => /\.(xml|json)$/.test(f) && fs.statSync(path.join(dir, f)).isFile());
return parseTestRecord(files.map(f => ({ name: f, text: fs.readFileSync(path.join(dir, f), 'utf8') })));
} catch { return null; } finally { fs.rmSync(dir, { recursive: true, force: true }); }
}
/** The newest attempt of one unit on one prefix. */
export const unitAttempt = (all: AttemptRecord[], u: GateUnit, index: number) =>
all.filter(a => a.gate === u.gate && a.workflow === u.workflow && a.prefixes[0] === index).at(-1);
function newAttempt(t: TrainRecord, u: GateUnit, p: GatedPrefix, nonce: string, inputs: Record<string, string>): AttemptRecord {
const now = nowIso();
return {
schema: 1, kind: 'train-attempt', train: t.nonce, id: `a${t.attempts.filter(id => id.startsWith('a')).length + 1}`, purpose: 'gate', nonce, workflow: u.workflow, ref: p.ref,
inputs, state: 'dispatching', run: null, outcome: null, detail: null, red_cases: [], panels: [], infra_eligible: false, cost_usd: null, prefixes: [p.index],
gate: u.gate, created_at: now, updated_at: now,
};
}
/** A green run of the same unit at main's exact sha in another train's record: T0 needs no new run. */
function reusedT0(env: TrainEnv, t: TrainRecord, u: GateUnit, gate: TrainGate): AttemptRecord | null {
const same = (a: AttemptRecord) => a.workflow === u.workflow && a.outcome === 'GREEN' && a.prefixes[0] === 0 && !a.reused
&& (gate.kind === 'workflows' || a.inputs.command === gate.command);
for (const other of listTrains(env.repo.keys)) {
if (other.nonce === t.nonce || other.gated?.[0]?.commit !== t.base.sha) continue;
const hit = attemptsOf(other).find(same);
if (hit) return { ...newAttempt(t, u, t.gated![0]!, hit.nonce, hit.inputs), state: 'completed', run: hit.run, outcome: 'GREEN', detail: `reused ${other.nonce}/${hit.id} (green at main ${t.base.sha.slice(0, 12)})`, reused: `${other.nonce}/${hit.id}` };
}
return null;
}
/** Push the prefix refs (one atomic push); the record says so before any dispatch. */
function pushPrefixes(env: TrainEnv, t: TrainRecord): string | null {
if (t.state !== 'built') return null;
const p = git(env.ctx.repoRoot, ['push', '-q', '--atomic', env.remote, ...t.gated!.map(x => `${x.commit}:refs/heads/${x.ref}`)]);
if (!p.ok) { t.error = `prefix push failed: ${p.err.slice(0, 160)}`; save(env, t); return t.error; }
t.state = 'pushed';
effect(t, `prefix refs pushed: ${t.gated!.map(x => x.ref).join(', ')}`);
save(env, t);
return null;
}
/** Dispatch every workflow unit that has no live attempt on a prefix; a workflow with a global concurrency group gets one prefix at a time. A unit is sent at most twice per prefix: two sends no run adopted refuse. */
async function dispatchWorkflows(env: TrainEnv, slug: string, t: TrainRecord, units: GateUnit[], lines: string[]): Promise<QueueFailure | null> {
const exec = execFor(env.ctx);
for (const u of units.filter(x => x.workflow !== 'command')) {
const yaml = git(env.ctx.repoRoot, ['show', `${env.remote}/${env.ctx.base}:.github/workflows/${u.workflow}`]);
const serial = yaml.ok && !concurrencyKeyed(yaml.out);
if (serial) lines.push(`SERIAL: ${u.workflow} has a concurrency group not keyed by ref; its prefixes run one at a time`);
for (const p of t.gated!) {
const all = attemptsOf(t);
const prior = unitAttempt(all, u, p.index);
if (prior && prior.state !== 'absent') continue;
if (all.filter(a => a.gate === u.gate && a.workflow === u.workflow && a.prefixes[0] === p.index && a.state === 'absent').length >= 2) return { code: 'QUEUE_TRAIN_DISPATCH_UNKNOWN', message: `${u.workflow} on T${p.index} was sent twice under nonce ${prior!.nonce} and no run carrying it appeared; it is not sent a third time (check that its run-name names the nonce: train doctor)` };
if (serial && t.gated!.some(q => { const a = unitAttempt(all, u, q.index); return !!a && a.state !== 'absent' && a.state !== 'completed'; })) break;
const reuse = p.index === 0 ? reusedT0(env, t, u, env.train.gates![u.gate]!) : null;
const nonce = prior?.nonce ?? makeNonce('gate', p.commit, t.base.sha, randomBytes(3).toString('hex'), { purpose: 'train' });
const a = reuse ?? newAttempt(t, u, p, nonce, { nonce });
t.attempts.push(a.id);
save(env, t, a);
if (reuse) { lines.push(`REUSED: T0 ${u.workflow} ${a.detail}`); continue; }
const sent = env.ctx.gh(['workflow', 'run', u.workflow, '--repo', slug, '--ref', p.ref, '-f', `nonce=${a.nonce}`]).ok;
const found = sent ? await awaitNonceRun(u.workflow, a.nonce, exec, slug) : null;
a.state = found ? 'dispatched' : 'unknown';
a.run = found ? { id: found.databaseId, url: found.url } : null;
effect(t, `gate ${u.gate} ${u.workflow} on T${p.index}: ${found ? `run ${found.databaseId}` : 'dispatch unconfirmed'} (nonce ${a.nonce})`);
save(env, t, a);
lines.push(`DISPATCH: ${u.workflow} on ${p.ref} ${found ? `run ${found.databaseId}` : 'unconfirmed'} nonce ${a.nonce}`);
if (serial) break;
}
}
return null;
}
/** Dollars reserved by one command gate's runs in this train (an interrupted run stays counted); each gate has its own cap. */
function commandSpend(t: TrainRecord, gate: number): number {
return Math.round(attemptsOf(t).filter(a => a.workflow === 'command' && a.gate === gate && !a.reused).reduce((s, a) => s + (a.spend_usd ?? 0), 0) * 100) / 100;
}
const pidAlive = (pid: unknown): boolean => {
if (typeof pid !== 'number') return false;
try { process.kill(pid, 0); return true; } catch (e) { return (e as NodeJS.ErrnoException).code === 'EPERM'; }
};
/** Whether a command attempt's process may still be running: its owner is alive on this host, or on another host within the run's time. */
function commandLive(a: AttemptRecord, g: TrainGate | undefined): boolean {
if (a.workflow !== 'command' || a.state !== 'dispatching' || !a.owner) return false;
if (a.owner.host !== os.hostname()) return g?.kind === 'command' && Date.now() - Date.parse(a.updated_at) < (g.timeout_sec + COMMAND_OVERHEAD_MIN * 60) * 2000;
return pidAlive(a.owner.pid);
}
/** One build or verdict at a time writes a generic train's attempts: a lock file naming its owner, taken over once that owner is gone (another host's after six hours). */
function trainLease(t: TrainRecord): { release: () => void } | QueueFailure {
const file = path.join(trainDir(t.nonce), 'gate.lock');
fs.mkdirSync(path.dirname(file), { recursive: true });
for (let i = 0; i < 2; i++) {
try {
fs.writeFileSync(file, JSON.stringify({ pid: process.pid, host: os.hostname() }), { flag: 'wx' });
return { release: () => fs.rmSync(file, { force: true }) };
} catch (e) { if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e; }
let o: { pid?: unknown; host?: unknown } | null = null;
try { o = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { o = null; }
const age = Date.now() - (fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0);
const held = !o ? age < 10_000 : o.host === os.hostname() ? pidAlive(o.pid) : age < 6 * 3600_000;
if (held) return { code: 'QUEUE_TRAIN_PENDING', message: `another train build or verdict (process ${String(o?.pid ?? '?')} on ${String(o?.host ?? '?')}) holds ${file}; nothing read or sent` };
fs.rmSync(file, { force: true });
}
return { code: 'QUEUE_TRAIN_PENDING', message: `${file} changed hands twice while this process waited; nothing read or sent` };
}
/** Run one command gate on a prefix through ubi-runner; resolves with the outcome. */
async function runCommand(env: TrainEnv, t: TrainRecord, a: AttemptRecord, g: CommandGate, p: GatedPrefix): Promise<{ outcome: GateOutcome; detail: string; tree: string | null }> {
const wt = path.join(queueRoot(), 'gw', `${a.nonce.slice(-6)}-${p.index}-${a.id}`);
fs.rmSync(wt, { recursive: true, force: true });
fs.mkdirSync(path.dirname(wt), { recursive: true });
if (!git(env.ctx.repoRoot, ['worktree', 'add', '--detach', '-q', wt, p.commit]).ok) return { outcome: 'INCOMPLETE', detail: `cannot check out T${p.index}`, tree: null };
try {
const dirty = git(wt, ['status', '--porcelain', '--untracked-files=all']);
if (!dirty.ok || dirty.out) return { outcome: 'INCOMPLETE', detail: 'the checkout of the prefix is not clean', tree: null };
const remote = `git add -A >/dev/null 2>&1 && printf 'GSTACK_GATE_TREE=%s\n' "$(git write-tree)" && ( ${g.command} )`;
const inv = scriptInvocation(process.env.GSTACK_TRAIN_UBI_RUNNER || UBI_RUNNER, ['run', '--src', wt, ...(g.pass_env ?? []).flatMap(n => ['--pass', n]), '--', remote]);
if (!inv.ok) return { outcome: 'INCOMPLETE', detail: inv.error, tree: null };
const childEnv = { ...process.env, UBI_SIZE: COMMAND_SIZE, UBI_RUN_TIMEOUT: String(g.timeout_sec), UBI_TTL_HOURS: String(Math.ceil(g.timeout_sec / 3600) + 1) };
const child = spawn(inv.cmd, inv.argv, { cwd: wt, env: childEnv, timeout: (g.timeout_sec + COMMAND_OVERHEAD_MIN * 60) * 1000 });
let out = '';
let tree = null as string | null;
const keep = (b: Buffer) => { const all = out + b.toString('utf8'); tree ??= /^GSTACK_GATE_TREE=([0-9a-f]{40,64})$/m.exec(all)?.[1] ?? null; out = all.slice(-1_000_000); };
child.stdout.on('data', keep);
child.stderr.on('data', keep);
const code = await new Promise<number | null>(resolve => { child.on('close', c => resolve(c)); child.on('error', () => resolve(null)); });
const log = path.join(trainDir(t.nonce), 'attempts', `${a.id}.log`);
fs.mkdirSync(path.dirname(log), { recursive: true });
fs.writeFileSync(log, out, { mode: 0o600 });
const left = [...out.matchAll(/UBICLOUD: left=(\S+)/g)].at(-1)?.[1];
if (!tree) return { outcome: 'INCOMPLETE', detail: `the VM reported no tree (exit ${code ?? 'none'}; log ${log})`, tree };
if (tree !== p.tree) return { outcome: 'INCOMPLETE', detail: `the VM tested tree ${tree.slice(0, 12)}, not the prefix tree ${p.tree.slice(0, 12)}`, tree };
if (left !== undefined && left !== '0') return { outcome: 'INCOMPLETE', detail: `ubi-runner reports left=${left}; the run is not counted`, tree };
if (code === 0) return { outcome: 'GREEN', detail: `exit 0 on tree ${tree.slice(0, 12)}`, tree };
if (code === 124 || code === null) return { outcome: 'INCOMPLETE', detail: `the command ${code === 124 ? 'timed out' : 'did not finish'} (log ${log})`, tree };
return { outcome: 'RED', detail: `exit ${code} (log ${log})`, tree };
} finally {
git(env.ctx.repoRoot, ['worktree', 'remove', '--force', wt]);
fs.rmSync(wt, { recursive: true, force: true });
}
}
/** Run one command attempt; a red run gets one rerun while the cap allows: a pass is GREEN and FLAKY, a second red is real. */
async function settleCommand(env: TrainEnv, t: TrainRecord, a: AttemptRecord, g: CommandGate, p: GatedPrefix, lines: string[]): Promise<void> {
const price = commandReservation(g);
let r = await runCommand(env, t, a, g, p);
if (r.outcome === 'RED' && commandSpend(t, a.gate!) + price <= g.spend_cap_usd) {
a.spend_usd = (a.spend_usd ?? 0) + price;
save(env, t, a);
const again = await runCommand(env, t, a, g, p);
if (again.outcome === 'GREEN') { a.flaky = [`command:${a.gate}`]; r = { ...again, detail: `${again.detail}; FLAKY: the first run was red (${r.detail})` }; }
else r = again.outcome === 'RED' ? { ...again, detail: `red twice: ${again.detail}` } : again;
}
Object.assign(a, { state: 'completed', outcome: r.outcome, detail: r.detail, tree: r.tree, red_cases: r.outcome === 'RED' ? [`command:${a.gate}`] : [] });
effect(t, `gate ${a.gate} command on T${p.index}: ${r.outcome}`);
save(env, t, a);
lines.push(`COMMAND: gate ${a.gate} on T${p.index} ${r.outcome} (${r.detail})`);
}
/** Run every command unit that has no finished attempt, all prefixes in parallel, under one spend cap per command gate across every prefix of the train. */
async function runCommands(env: TrainEnv, t: TrainRecord, units: GateUnit[], lines: string[]): Promise<QueueFailure | null> {
const commands = units.filter(u => u.workflow === 'command');
if (!commands.length) return null;
if (!process.env.UBI_OWNER) return { code: 'QUEUE_TRAIN_SPEND_CAP', message: 'a command gate runs on a Ubicloud VM, and UBI_OWNER is not set (every VM names its owner); nothing ran' };
const jobs: Array<Promise<void>> = [];
for (const u of commands) {
const g = env.train.gates![u.gate] as CommandGate;
const price = commandReservation(g);
for (const p of t.gated!) {
if (unitAttempt(attemptsOf(t), u, p.index)?.state === 'completed') continue;
const reuse = p.index === 0 ? reusedT0(env, t, u, g) : null;
if (reuse) { t.attempts.push(reuse.id); save(env, t, reuse); lines.push(`REUSED: T0 command ${reuse.detail}`); continue; }
if (commandSpend(t, u.gate) + price > g.spend_cap_usd) {
await Promise.all(jobs);
return { code: 'QUEUE_TRAIN_SPEND_CAP', message: `gate ${u.gate}'s run on T${p.index} reserves $${price.toFixed(2)}; with $${commandSpend(t, u.gate).toFixed(2)} reserved, this train would pass the gate's cap $${g.spend_cap_usd.toFixed(2)}` };
}
const a = newAttempt(t, u, p, makeNonce('gate', p.commit, t.base.sha, randomBytes(3).toString('hex'), { purpose: 'train' }), { command: g.command });
a.spend_usd = price;
a.owner = { pid: process.pid, host: os.hostname() };
t.attempts.push(a.id);
save(env, t, a);
jobs.push(settleCommand(env, t, a, g, p, lines));
}
}
await Promise.all(jobs);
return null;
}
/** `train build` for a generic train: push the prefix refs, dispatch every workflow unit, run every command unit. */
export async function advanceGeneric(env: TrainEnv, t: TrainRecord, lines: string[], describe: (t: TrainRecord) => string[]): Promise<TrainOut> {
const run = trainFile(t.nonce);
const slug = repoSlug(env.ctx);
if (!slug) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: 'gh repo view could not name the repository' }, lines, `${Q} train build --train ${t.nonce}`, run);
const lease = trainLease(t);
if ('code' in lease) return outPending(lease, lines, `${Q} train build --train ${t.nonce} (once that process finishes)`, run);
try {
Object.assign(t, loadTrain(t.nonce) ?? {});
const pushed = pushPrefixes(env, t);
if (pushed) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: pushed }, lines, `${Q} train build --train ${t.nonce}`, run);
const units = gateUnits(env.train.gates!);
if (t.verdict) return outOk([...lines, ...describe(t), `VERDICT: ${t.verdict.kind.toUpperCase()} (settled; nothing dispatched)`], genericNext(t), run);
const live = attemptsOf(t).find(a => commandLive(a, env.train.gates![a.gate ?? -1]));
if (live) return outPending({ code: 'QUEUE_TRAIN_PENDING', message: `command run ${live.id} is still running under process ${live.owner!.pid} on ${live.owner!.host}` }, lines, `${Q} train verdict --train ${t.nonce} (once that run finishes)`, run);
reconcile(env, slug, t);
const unsent = await dispatchWorkflows(env, slug, t, units, lines);
const capped = unsent ? null : await runCommands(env, t, units, lines);
t.state = 'gate-pending';
save(env, t);
if (unsent) return outRefused(unsent, [...lines, ...describe(t)], `run ${Q} train doctor and fix the workflow on the base branch, then build a new train on the new main: ${Q} train build ${prsArgs(t.slots, t.explicit_order)}`, run);
if (capped) return outRefused(capped, [...lines, ...describe(t)], `raise the gate's spend_cap_usd (or lower its timeout_sec) on the base branch, then build a new train on the new main: ${Q} train build ${prsArgs(t.slots, t.explicit_order)}`, run);
return outOk([...lines, ...describe(t), `GATES: ${units.length} unit(s) on ${t.gated!.length} prefix(es); record: ${run}`], `${Q} train verdict --train ${t.nonce}`, run);
} finally { lease.release(); }
}
/** Settle unconfirmed workflow dispatches by nonce (found, or absent so the next build re-sends the same nonce); an interrupted command run is absent. */
function reconcile(env: TrainEnv, slug: string, t: TrainRecord): void {
for (const a of attemptsOf(t)) {
if (a.state !== 'dispatching' && a.state !== 'unknown') continue;
if (a.workflow === 'command') {
if (commandLive(a, env.train.gates?.[a.gate ?? -1])) continue;
a.state = 'absent'; effect(t, `command run ${a.id} was interrupted; it runs again`); save(env, t, a); continue;
}
const runs = listDispatchRuns(a.workflow, execFor(env.ctx), slug, 100);
if (!runs) continue;
const hit = runForNonce(runs, a.nonce);
a.state = hit ? 'dispatched' : 'absent';
a.run = hit ? { id: hit.databaseId, url: hit.url } : null;
effect(t, `reconciled ${a.id} (nonce ${a.nonce}): ${hit ? `run ${hit.databaseId}` : 'absent'}`);
save(env, t, a);
}
}
/** Read one workflow attempt's run; a first red asks for one rerun of the failed jobs and waits for it. */
function readWorkflowAttempt(env: TrainEnv, slug: string, t: TrainRecord, a: AttemptRecord): void {
const gate = env.train.gates![a.gate!] as WorkflowsGate;
const run = readRun(env, slug, a.run!.id);
if (!run) { a.detail = `run ${a.run!.id} is unreadable`; return; }
if (run.status !== 'completed' || (a.rerun === 'requested' && run.run_attempt < 2)) {
Object.assign(a, { outcome: 'PENDING', detail: `run ${a.run!.id} attempt ${run.run_attempt} is ${run.status}${a.rerun === 'requested' ? ' (waiting for the rerun)' : ''}` });
return;
}
const jobs = readJobs(env, slug, a.run!.id, run.run_attempt);
if (typeof jobs === 'string') { Object.assign(a, { outcome: 'INCOMPLETE', state: 'completed', detail: jobs }); return; }
const earlier = (a.jobs ?? []).filter(j => j.attempt < run.run_attempt);
const merged = new Map(earlier.filter(j => j.attempt === run.run_attempt - 1).map(j => [j.name, j.conclusion] as const));
for (const j of jobs) merged.set(j.name, j.conclusion);
a.jobs = [...earlier, ...jobs.map(j => ({ attempt: run.run_attempt, ...j }))];
a.run_attempt = run.run_attempt;
const required = gate.required_jobs.filter(r => r.startsWith(`${a.workflow}/`)).map(r => r.slice(a.workflow.length + 1));
const record = gate.record ? readRecord(env, slug, t, a.run!.id, gate.record) : undefined;
const c = classifyWorkflowRun({ run, head: t.gated![a.prefixes[0]!]!.commit, workflow: a.workflow, jobs: [...merged].map(([name, conclusion]) => ({ name, conclusion })), required, record });
if (c.outcome === 'RED' && !a.rerun) {
const sent = env.ctx.gh(['run', 'rerun', String(a.run!.id), '--failed', '--repo', slug]).ok;
a.rerun = sent ? 'requested' : 'refused';
a.red_cases = c.red;
if (sent) { Object.assign(a, { outcome: 'PENDING', detail: `red on ${c.red.join(', ')}; one rerun of the failed jobs requested (CREW rule 16)` }); effect(t, `rerun requested for run ${a.run!.id}`); return; }
}
const firstRed = a.rerun === 'requested' ? a.red_cases : [];
Object.assign(a, { state: 'completed', outcome: c.outcome, red_cases: c.red, detail: c.detail });
if (c.outcome === 'GREEN' && firstRed.length) { a.flaky = firstRed; a.detail = `${c.detail}; FLAKY: ${firstRed.join(', ')} red on attempt 1, green on the rerun`; }
else if (c.outcome === 'RED' && a.rerun === 'requested') a.detail = `red on the rerun too: ${c.detail}`;
}
/** Per prefix: PENDING while any unit is, then INCOMPLETE, then RED (red cases united), else GREEN. */
function aggregate(env: TrainEnv, t: TrainRecord): void {
const all = attemptsOf(t);
const units = gateUnits(env.train.gates!);
for (const p of t.gated!) {
const attempts = units.map(u => unitAttempt(all, u, p.index));
const outs = attempts.map(a => (!a || a.state === 'absent' ? 'PENDING' : a.outcome ?? 'PENDING'));
p.outcome = outs.includes('PENDING') ? 'PENDING' : outs.includes('INCOMPLETE') ? 'INCOMPLETE' : outs.includes('RED') ? 'RED' : 'GREEN';
p.red = p.outcome === 'RED' ? [...new Set(attempts.flatMap(a => (a?.outcome === 'RED' ? a.red_cases : [])))].sort() : [];
p.detail = attempts.map((a, i) => `${units[i]!.workflow}: ${!a || a.state === 'absent' ? 'not dispatched' : a.detail ?? a.state}`).join('; ');
}
}
const VERDICT_STATE = { green: 'green', attributed: 'attributed', 'red-on-main': 'red-on-main', unattributed: 'unattributed', incomplete: 'incomplete' } as const;
/** The coded failure for a settled, non-green verdict. */
function verdictFailure(t: TrainRecord, v: GenericVerdict): QueueFailure {
if (v.kind === 'attributed') {
return t.slots[0]?.fixes_base_red?.length && v.culprit === t.slots[0]!.pr && t.slots.some(s => s.base_red)
? { code: 'QUEUE_TRAIN_BASE_RED', message: `fixer #${v.culprit}'s prefix T1 is red (${t.gated![1]!.red.join(', ')}); its conditional PRs cannot ride without it` }
: { code: 'QUEUE_TRAIN_RED', message: `T${v.landable + 1} is the first red prefix after a green one: #${v.culprit} is held${v.landable ? `; slots 1..${v.landable} may land` : ''}` };
}
if (v.kind === 'red-on-main') {
const fixes = t.slots[0]?.fixes_base_red;
return { code: 'QUEUE_TRAIN_RED_ON_MAIN', message: `T0 (main ${t.base.sha.slice(0, 12)}) is red on ${t.gated![0]!.red.join(', ') || 'its gate'}${fixes?.length ? `, not exactly slot 1's FIXES_BASE_RED (${fixes.join(', ')})` : ''}` };
}
if (v.kind === 'unattributed') return { code: 'QUEUE_TRAIN_UNATTRIBUTED', message: 'a red prefix is followed by a green one, so no slot is the culprit; a generic train gets no unchanged rebuild' };
return { code: 'QUEUE_TRAIN_INCOMPLETE', message: `a prefix gate is incomplete: ${t.gated!.find(p => p.outcome === 'INCOMPLETE')?.detail ?? 'see the PREFIX lines'}` };
}
/** `train verdict` for a generic train: read every run, settle reruns, then classify the prefixes. */
export function genericVerdict(env: TrainEnv, t: TrainRecord): TrainOut {
const run = trainFile(t.nonce);
const slug = repoSlug(env.ctx);
if (!slug) return outPending({ code: 'QUEUE_PROOF_MISSING', message: 'gh repo view could not name the repository' }, [], `${Q} train verdict --train ${t.nonce}`, run);
if (!t.verdict) {
const lease = trainLease(t);
if ('code' in lease) return outPending(lease, [], `${Q} train verdict --train ${t.nonce} (once that process finishes)`, run);
try {
Object.assign(t, loadTrain(t.nonce) ?? {});
const live = t.verdict ? null : attemptsOf(t).find(a => commandLive(a, env.train.gates![a.gate ?? -1]));
if (live) return outPending({ code: 'QUEUE_TRAIN_PENDING', message: `command run ${live.id} is still running under process ${live.owner!.pid} on ${live.owner!.host}; nothing read or saved` }, [], `${Q} train verdict --train ${t.nonce} (once that run finishes)`, run);
if (!t.verdict) {
reconcile(env, slug, t);
for (const a of attemptsOf(t)) {
if (a.workflow === 'command' || a.state !== 'dispatched' || !a.run) continue;
readWorkflowAttempt(env, slug, t, a);
save(env, t, a);
}
aggregate(env, t);
const c = classifyPrefixes(t.gated!, t.slots);
if (c.kind !== 'pending') { t.verdict = { ...c, at: nowIso() }; t.state = VERDICT_STATE[c.kind]; effect(t, `verdict: ${c.kind}${c.culprit ? ` (culprit #${c.culprit})` : ''}`); }
save(env, t);
}
} finally { lease.release(); }
}
const lines = [
...t.gated!.map(p => `PREFIX: T${p.index} ${p.outcome ?? 'PENDING'} ${p.commit.slice(0, 12)} ${p.ref}${p.red.length ? ` red=${p.red.join(',')}` : ''}${p.detail ? ` (${p.detail})` : ''}`),
...attemptsOf(t).filter(a => a.flaky?.length).map(a => `FLAKY: T${a.prefixes[0]} ${a.workflow} ${a.flaky!.join(', ')} (run ${a.run?.id ?? '-'}; flake recorded on ${a.id})`),
];
const v = t.verdict;
if (!v) {
const waiting = t.gated!.some(p => /not dispatched/.test(p.detail ?? ''));
return outPending({ code: 'QUEUE_TRAIN_PENDING', message: `prefix gates still running${waiting ? ' or waiting for their serial turn' : ''}` }, lines, waiting ? `${Q} train build --train ${t.nonce}` : `${Q} train verdict --train ${t.nonce}`, run);
}
lines.push(`VERDICT: ${v.kind.toUpperCase()} train ${t.nonce} landable=${v.landable}/${t.slots.length}${v.culprit ? ` culprit=#${v.culprit}` : ''}`);
return v.kind === 'green' ? outOk(lines, genericNext(t), run) : outFailed(verdictFailure(t, v), lines, genericNext(t), run);
}
/** NEXT for a generic train. */
export function genericNext(t: TrainRecord): string {
const n = t.nonce;
const merged = (pr: string) => t.merged.some(m => m.pr === pr);
if (t.state === 'built' || t.state === 'pushed') return `${Q} train build --train ${n}`;
if (!t.verdict) return `${Q} train verdict --train ${n}`;
const v = t.verdict;
const next = t.slots.slice(0, v.landable).find(s => !merged(s.pr));
if (next) return `${Q} train land --train ${n} --pr ${next.pr}`;
const rest = t.slots.filter(s => !merged(s.pr) && s.pr !== v.culprit);
if (v.kind === 'green' || v.kind === 'attributed') return rest.length ? `${Q} train build ${prsArgs(rest, t.explicit_order)}` : `${Q} train prune --train ${n}`;
if (v.kind === 'red-on-main') return `fix forward on main first, then ${Q} train build ${prsArgs(t.slots, t.explicit_order)}`;
if (v.kind === 'unattributed') return `repair the red cases, or seat the PRs in another order (never this train unchanged): ${Q} train build --prs ${t.slots.map(s => `${s.pr}@${s.head}`).join(',')} --order <new order>`;
return `diagnose the incomplete prefix run, then ${Q} train build ${prsArgs(t.slots, t.explicit_order)} (a new train)`;
}
/** Freeze backstop for a generic train: every workflow unit dispatched on the base branch by nonce; the first run is recorded. */
export async function genericBackstop(env: TrainEnv, mainSha: string): Promise<{ nonce: string; run: number | null; state: string }> {
const slug = repoSlug(env.ctx);
const nonce = makeNonce('gate', mainSha, mainSha, randomBytes(3).toString('hex'), { purpose: 'backstop' });
const units = gateUnits(env.train.gates!).filter(u => u.workflow !== 'command');
let first: number | null = null;
for (const u of units) {
if (!slug || !env.ctx.gh(['workflow', 'run', u.workflow, '--repo', slug, '--ref', env.ctx.base, '-f', `nonce=${nonce}`]).ok) continue;
const found = await awaitNonceRun(u.workflow, nonce, execFor(env.ctx), slug);
first ??= found?.databaseId ?? null;
}
return { nonce, run: first, state: first ? 'dispatched' : units.length ? 'unconfirmed' : 'not dispatched (command gates only: run the gate command on main)' };
}