mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 23:14:33 +01:00
* feat(ship): pause /ship on a red base instead of failing (gstack-base-red, conditional receipts) (GSTA-157 PR 1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: base-red shell runner launches bash through runBashSync Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: base-red coverage additions (unreproduced head, unresolved head, readPause, wait unconfigured) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: base-red pause in unattended endings; BASE_RED_UNCONFIGURED covers an invalid base policy Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: base-red coverage pass 2 (write --base-red, summary line, classify usage before fetch, unresolved-head detail) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship-queue): merge trains for any repo — train.gates (workflows/command), every prefix gated, generalized train land (direct/update, merge method), base-red trains, train init/doctor, watch --land (GSTA-157 PR 2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (train gates: dispatch bound, lease, rebase, post-merge checks, output tail, caps) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: train land refuses the next slot until the previous slot's post-merge checks are green Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: ship-policy, troubleshooting and captain for train gates review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: changelog fragment for merge trains for any repo Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: keep ship-queue-train-generic out of the Windows subset (gh shim resolves gh.exe there) 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>
571 lines
41 KiB
TypeScript
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 covers every run 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, placeReceiptFirst } 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, placeReceiptFirst(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 the train's one spend cap. */
|
|
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)' };
|
|
}
|