v1.91.146.0 feat(ship-queue): merge trains with pattern bisect and train-record verify (GSTA-49 T1) (#3272)

* feat(ship-queue): merge trains with pattern bisect and train-record verify (GSTA-49 T1)

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

* test(windows): curate ship-queue-train.test.ts out of the Windows subset (gh shim not resolved on windows-latest)

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

* v1.91.145.0 feat(ship-queue): merge trains with pattern bisect and train-record verify (GSTA-49 T1)

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

* v1.91.146.0 restamp: #3295 takes v1.91.145.0

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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-19 10:29:44 +00:00
1 parent a09b3b8532
commit 6a4ab69dcd
25 files changed
+3047 -36

No files matched your search

+91
View File
@@ -46,6 +46,97 @@ export const RELEASE_RESULT_CODES = {
summary: 'the gate-ahead attempt is unknown, already recorded, or its worktree no longer holds the gated commit',
fix: 'list attempts with gstack-ship-queue gate-ahead --list, then record the live one, or prepare a new attempt',
},
// GSTA-49 lane T1: merge trains in gstack-ship-queue (docs/ship-policy.md#run-a-merge-train).
QUEUE_TRAIN_OFF: {
anchor: 'queue-train-off',
summary: 'the base revision’s .gstack/ship-policy.json declares no `train` block, so the merge-train surface is off and the per-PR queue applies',
fix: 'use the per-PR queue (ready, stamp, verify), or land a `train` block with min_gstack_version on the base branch first',
},
QUEUE_TRAIN_UNTRUSTED: {
anchor: 'queue-train-untrusted',
summary: 'a PR’s head repository is not the base repository; fork work never runs in a train (the train branch and its dispatch carry repository secrets)',
fix: 'leave the PR out of the train (gstack-ship-queue train build --prs <list without it>); fork work stays on the trusted-branch path',
},
QUEUE_TRAIN_MOVED: {
anchor: 'queue-train-moved',
summary: 'a PR’s head (or the train branch) is no longer the SHA the train pinned, so the train would not test what merges',
fix: 'ask the lane for a fresh READY on the new head, then rebuild the train with the new pin (train build --prs <n>@<new sha>,...)',
},
QUEUE_TRAIN_CONFLICT: {
anchor: 'queue-train-conflict',
summary: 'merging a PR onto the previous prefix conflicts outside the version files and the CHANGELOG; nothing was dispatched and no branch pushed',
fix: 'run the printed train build without that PR; the PR’s lane merges main (with the earlier PRs once they land) and reports READY again',
},
QUEUE_TRAIN_FREE_RED: {
anchor: 'queue-train-free-red',
summary: 'the free tests dispatched on train/<nonce> are red, missing, cancelled or ran on another SHA, so the paid gate was not dispatched',
fix: 'read the free-tests run; a PR-caused red drops that PR (train build --prs <list without it>), a flaky one goes to its owner before the next build',
},
QUEUE_TRAIN_DISPATCH_UNKNOWN: {
anchor: 'queue-train-dispatch-unknown',
summary: 'a train dispatch was attempted but its run could not be found by nonce, so whether it started is unknown; it is never re-dispatched blindly',
fix: 'run gstack-ship-queue train status --train <nonce>: it finds the run by nonce, or marks the dispatch absent so train build --train <nonce> may dispatch it',
},
QUEUE_TRAIN_WAITING: {
anchor: 'queue-train-waiting',
summary: 'the train is below its size and its oldest READY PR has waited less than the policy’s fill time, so it does not dispatch yet',
fix: 'add READY PRs, rerun once the printed time has passed, or pass --urgent for a one-PR train',
},
QUEUE_TRAIN_PENDING: {
anchor: 'queue-train-pending',
summary: 'the train’s run (free tests, the full gate or the bisect) has not finished, so there is no verdict yet',
fix: 'rerun the printed NEXT: command later; nothing is dispatched again while a run is pending',
},
QUEUE_TRAIN_INCOMPLETE: {
anchor: 'queue-train-incomplete',
summary: 'the train gate did not complete its census (INFRA or INCOMPLETE panels, an unbound, partial or cancelled shard, a slice killed before upload, or no published record), so it is never green',
fix: 'when every gap is machine-classified INFRA or INCOMPLETE, take the one re-dispatch EVAL_POLICY.infraRedispatch allows (train rebuild --train <nonce> --infra) and report both runs; otherwise diagnose the run',
},
QUEUE_TRAIN_RED: {
anchor: 'queue-train-red',
summary: 'the train’s full gate is red on the train tree; its PRs do not merge, and nothing is retried to change the verdict',
fix: 'bisect the red cases (train bisect --train <nonce> --spend-cap <usd>); after a rebuild’s second red, route the cases to a repair lane',
},
QUEUE_TRAIN_RED_ON_MAIN: {
anchor: 'queue-train-red-on-main',
summary: 'the bisect found the red cases red on every prefix, main (T0) included: main is broken, not the train’s PRs',
fix: 'fix forward on main first (a one-PR train), then rebuild this train unchanged',
},
QUEUE_TRAIN_UNATTRIBUTED: {
anchor: 'queue-train-unattributed',
summary: 'the bisect pattern blames no PR and not main (all green, or not monotone); one trial cannot tell a flake from a break',
fix: 'all green: the one preregistered rebuild (train rebuild --train <nonce>), reporting both runs; any other pattern: a repair lane, then a changed train',
},
QUEUE_TRAIN_BISECT_CAPPED: {
anchor: 'queue-train-bisect-capped',
summary: 'the bisect’s reserved price (every case×prefix trial, unknown costs at the policy reservation) passes the spend cap; completed prefixes are kept and the outcome stays unattributed',
fix: 'resume with a higher cap (train bisect --train <nonce> --spend-cap <usd>); only prefixes without a result run',
},
QUEUE_TRAIN_PREFIX_INVALID: {
anchor: 'queue-train-prefix-invalid',
summary: 'a bisect prefix is not an ancestor of the train head on train/<nonce>, or a red case is not in the gate’s plan, so the bisect inputs are refused',
fix: 'check that train/<nonce> still points at the recorded head (train status); a moved branch needs a new train',
},
QUEUE_TRAIN_REBUILD_REFUSED: {
anchor: 'queue-train-rebuild-refused',
summary: 'the rebuild is not allowed: the bisect was not all green, the train was already rebuilt (or re-dispatched), or there is no bisect or INFRA record',
fix: 'route the red cases to a repair lane; the train is rebuilt only once the repair merges (a changed train)',
},
QUEUE_TRAIN_TREE_MISMATCH: {
anchor: 'queue-train-tree-mismatch',
summary: 'main’s tree is neither the train’s prefix before this PR nor after it (modulo stamps), so the merge does not publish what the gate tested',
fix: 'stop merging; if earlier PRs of the train merged, the queue is frozen (QUEUE_TRAIN_FROZEN); otherwise rebuild the train on current main',
},
QUEUE_TRAIN_FROZEN: {
anchor: 'queue-train-frozen',
summary: 'a train stopped partway through publication, so main holds a prefix no gate covered on its own; ordinary trains are refused until a repair lands',
fix: 'admit one repair PR at its exact head (train build --repair --prs <n>@<sha>); the freeze clears when verify finds main’s tree after that merge in a green record',
},
READY_SCREEN_CHOICE_REQUIRED: {
anchor: 'ready-screen-choice-required',
summary: 'the base policy turns trains on, so READY needs an explicit screen choice: --screen (pay for a diff-relevant screen now) or --no-screen',
fix: 'rerun gstack-ship-queue ready with the printed recommendation (--screen or --no-screen); say in the READY block if focused local cases already ran',
},
// Field list 2 W4 (pregate `timing`).
PREGATE_TIMING_ASSERTION: {
anchor: 'pregate-timing-assertion',
+49
View File
@@ -65,6 +65,28 @@ export interface ShipPolicy {
full_e2e: boolean;
/** The oldest gstack that may read this policy; a newer key set to a non-default value requires it. */
min_gstack_version: string | null;
/** Merge trains in gstack-ship-queue (docs/ship-policy.md#run-a-merge-train); null keeps the per-PR queue. */
train: TrainPolicy | null;
}
/** The `train` block: the gate workflow trains dispatch and how its verdict is read. */
export interface TrainPolicy {
/** Workflow file dispatched for the full gate and the bisect (it accepts the inputs in TRAIN_DISPATCH_INPUTS). */
gate_workflow: string;
/** Workflow file dispatched on train/<nonce> before the paid gate; its run must be green on the train head. */
free_workflow: string;
/** The check run the train's own gate run publishes on the train head when its census is complete and green. */
full_gate_check: string;
/** Artifact name of the train record (and the prefix of each bisect prefix's artifact). */
record_artifact: string;
/** Most PRs in one train. */
max_size: number;
/** A train under max_size dispatches once its oldest READY PR has waited this long. */
fill_hours: number;
/** How `ready` computes the screen's selection: gstack's paid selector, or not at all. */
screen_selection: 'gstack-paid' | 'none';
/** Bisect admission price of one case trial that has no recorded cost. */
trial_reservation_usd: number;
}
export const DEFAULT_POLICY: ShipPolicy = {
@@ -87,6 +109,7 @@ export const DEFAULT_POLICY: ShipPolicy = {
gate_command: null,
full_e2e: false,
min_gstack_version: null,
train: null,
};
export type PolicyKey = Exclude<keyof ShipPolicy, '$schema'>;
@@ -112,6 +135,7 @@ export const POLICY_EXPLANATIONS: Record<PolicyKey, string> = {
migrations: '{ dir, id }: the migration directory and a regex with one capture group for the number in a file name; `gstack-pregate regate` reports MIGRATION_COLLISION against the base branch and every open PR.',
gate_command: 'The repo’s full pre-merge gate command. gstack-ship-queue gate-ahead and ready print it for the caller to run (in the background or in CI); gstack never executes it. null when the repo has none.',
full_e2e: 'true: every merge needs a passing full-gate result (gstack-ship-queue gate-ahead --record, or the lane\'s --gate-exit 0 --gate-log on the gated head) bound to a tree equal to the merging one modulo stamps; ready, stamp and verify refuse without one (QUEUE_GATE_MISSING).',
train: '{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd } or null: turns on merge trains in gstack-ship-queue (train build|status|verdict|bisect|rebuild|prune, ready --screen|--no-screen, verify --train). null keeps today\'s per-PR queue unchanged.',
min_gstack_version: 'The oldest gstack that may read this file. Any key newer than 1.91.98.0 set to a non-default value requires it at that key’s introducing version (validate prints each); a gstack older than it refuses the file (POLICY_GSTACK_TOO_OLD).',
};
@@ -124,11 +148,13 @@ export const POLICY_EXPLANATIONS: Record<PolicyKey, string> = {
*/
export const POLICY_COMPAT_FLOOR = '1.91.98.0';
export const SHIP_QUEUE_KEYS_VERSION = '1.91.108.0';
export const TRAIN_KEYS_VERSION = '1.91.146.0';
export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0',
changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0',
pr_count: '1.91.73.0', maintainers: '1.91.73.0', stamp_files: '1.91.98.0', stamp_patterns: '1.91.98.0', restamp_command: '1.91.98.0',
migrations: '1.91.98.0', gate_command: SHIP_QUEUE_KEYS_VERSION, full_e2e: SHIP_QUEUE_KEYS_VERSION, min_gstack_version: SHIP_QUEUE_KEYS_VERSION,
train: TRAIN_KEYS_VERSION,
};
const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, parseVersion(b)!) > 0;
@@ -241,6 +267,28 @@ function migrationErrors(value: unknown, out: PolicyError[]): void {
} catch (e: any) { out.push({ code: 'POLICY_INVALID', path: 'migrations.id', message: `not a valid regex: ${e.message}` }); }
}
const TRAIN_FIELDS: Record<keyof TrainPolicy, (v: unknown) => string | null> = {
gate_workflow: v => (typeof v === 'string' && /^[A-Za-z0-9_.-]{1,64}\.ya?ml$/.test(v) ? null : 'must be a workflow file name such as evals.yml'),
free_workflow: v => (typeof v === 'string' && /^[A-Za-z0-9_.-]{1,64}\.ya?ml$/.test(v) ? null : 'must be a workflow file name such as free-tests.yml'),
full_gate_check: v => (typeof v === 'string' && /^[A-Za-z0-9_./-]{1,64}$/.test(v) ? null : 'must be a check-run name ([A-Za-z0-9_./-]{1,64})'),
record_artifact: v => (typeof v === 'string' && /^[A-Za-z0-9_.-]{1,64}$/.test(v) ? null : 'must be an artifact name ([A-Za-z0-9_.-]{1,64})'),
max_size: v => (Number.isInteger(v) && (v as number) >= 1 && (v as number) <= 6 ? null : 'must be an integer from 1 to 6'),
fill_hours: v => (typeof v === 'number' && v > 0 && v <= 24 ? null : 'must be a number of hours above 0 and at most 24'),
screen_selection: v => (v === 'gstack-paid' || v === 'none' ? null : 'must be gstack-paid | none'),
trial_reservation_usd: v => (typeof v === 'number' && v > 0 && v <= 100 ? null : 'must be dollars above 0 and at most 100'),
};
function trainErrors(value: unknown, out: PolicyError[]): void {
const t = value as Record<string, unknown> | null;
if (!t || typeof t !== 'object' || Array.isArray(t)) { out.push({ code: 'POLICY_INVALID', path: 'train', message: `must be { ${Object.keys(TRAIN_FIELDS).join(', ')} } or null` }); return; }
for (const k of Object.keys(t)) if (!(k in TRAIN_FIELDS)) out.push({ code: 'POLICY_INVALID', path: `train.${k}`, message: 'unknown key' });
for (const [k, check] of Object.entries(TRAIN_FIELDS)) {
if (!(k in t)) { out.push({ code: 'POLICY_INVALID', path: `train.${k}`, message: 'missing (every train field is required)' }); continue; }
const why = check(t[k]);
if (why) out.push({ code: 'POLICY_INVALID', path: `train.${k}`, message: `${JSON.stringify(t[k])} ${why}` });
}
}
/**
* Schema and containment check of a parsed policy object. Unknown keys are
* errors (a typo must not silently mean "default", and a key from a newer
@@ -282,6 +330,7 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } =
else if (t.length > MAX_COMMAND || /[\0\r\n]/.test(t)) errors.push({ code: 'POLICY_CONTAINMENT', path: 'gate_command', message: `must be one line under ${MAX_COMMAND} characters` });
}
if ('full_e2e' in obj && typeof obj.full_e2e !== 'boolean') errors.push({ code: 'POLICY_INVALID', path: 'full_e2e', message: 'must be true or false' });
if ('train' in obj && obj.train !== null) trainErrors(obj.train, errors);
if ('stamp_paths' in obj) listErrors('stamp_paths', obj.stamp_paths, errors, globError);
for (const key of ['changelog', 'mirror'] as const) {
if (!(key in obj) || obj[key] === null) continue;
+1 -1
View File
@@ -129,7 +129,7 @@ export function applyStampPatch(repoRoot: string, mergeTree: string, patch: stri
});
}
function withIndex<T>(repoRoot: string, tree: string, fn: (env: NodeJS.ProcessEnv) => T): T | { error: string } {
export function withIndex<T>(repoRoot: string, tree: string, fn: (env: NodeJS.ProcessEnv) => T): T | { error: string } {
const gitDir = git(repoRoot, ['rev-parse', '--absolute-git-dir']).out;
const indexFile = path.join(gitDir, `gstack-ship-queue-${process.pid}-${randomBytes(4).toString('hex')}.index`);
const env = { ...process.env, GIT_INDEX_FILE: indexFile };
+108
View File
@@ -0,0 +1,108 @@
/**
* Argument parsing and output for the merge-train surface of
* bin/gstack-ship-queue: `train build|status|verdict|bisect|rebuild|prune`,
* `ready --screen|--no-screen [--broad]` and `verify --train`. Every command
* prints its lines, the coded error line on stderr, then `NEXT: <command>`
* and the canonical `GSTACK_RESULT: skill=ship-queue status=<s> run=<record>`.
* Loaded by the bin only when one of those paths runs.
*/
import { EXIT, formatResult } from './headless-artifacts';
import { RESULT_CODES } from './result-codes';
import type { QueueContext } from './ship-queue';
import { buildTrain, pruneTrains, trainStatus, type TrainOut } from './ship-queue-train';
import { trainReady } from './ship-queue-train-ready';
import { trainBisect, trainRebuild, trainVerdict } from './ship-queue-train-verdict';
import { verifyTrain } from './ship-queue-train-verify';
import { redactCause } from './ship-queue-train-record';
export const TRAIN_HELP = ` train build --prs <n>@<sha>[,...] [--urgent | --repair] [--dry-run] [--free-timeout <s>]
train build --train <nonce> resume a train (push, free tests, dispatch) from its record
merge pinned unstamped heads in slot order onto main, free tests on
train/<nonce>, then one full gate through eval-bg's nonce
train status [--train <nonce>] never spends: rebuild records from refs/notes/gstack-trains, reconcile
unconfirmed dispatches by nonce
train verdict --train <nonce> read the gate run and its record: GREEN, RED, INCOMPLETE or PENDING
train bisect --train <nonce> --spend-cap <usd> [--dry-run]
red cases on every prefix T0..Tk, resumable and capped
train rebuild --train <nonce> [--infra] the one rebuild an all-green bisect earns (or the one INFRA re-dispatch)
train prune [--train <nonce>] [--dry-run]
delete train/<nonce> branches of merged, superseded or dropped trains
ready --pr <n> --screen [--broad] | --no-screen [--dry-run]
(train policy) READY plus the screen choice, recorded as a receipt
verify --pr <n> --train <nonce> [--no-fetch]
(train policy) the merge check against a green train record
Merge trains need a "train" block in the base revision's ship policy (docs/ship-policy.md#run-a-merge-train).`;
function flag(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
}
export function printTrain(cmd: string, out: TrainOut): number {
for (const line of out.lines) process.stdout.write(line + '\n');
if (out.failure) {
const f = out.failure;
process.stderr.write(`gstack-ship-queue ${cmd}: ${f.message}; fix: ${f.fix ?? RESULT_CODES[f.code].fix} (${f.code})\n`);
}
process.stdout.write(`NEXT: ${out.next}\n`);
process.stdout.write(formatResult('ship-queue', out.status, out.run) + '\n');
return out.exit;
}
function usage(message: string): number {
process.stderr.write(`gstack-ship-queue: ${message}\n${TRAIN_HELP}\n`);
return EXIT.usage;
}
async function guarded(cmd: string, fn: () => Promise<TrainOut> | TrainOut): Promise<number> {
try {
return printTrain(cmd, await fn());
} catch (error) {
const cause = redactCause(error instanceof Error ? error.message : String(error));
process.stderr.write(`gstack-ship-queue ${cmd}: unexpected failure: ${cause}\n`);
process.stdout.write(`NEXT: gstack-ship-queue train status\n`);
process.stdout.write(formatResult('ship-queue', 'interrupted', 'none') + '\n');
return EXIT.fail;
}
}
const positive = (s: string | undefined, fallback: number) => (s !== undefined && /^\d+(\.\d+)?$/.test(s) && Number(s) > 0 ? Number(s) : s === undefined ? fallback : NaN);
export async function trainMain(ctx: QueueContext, args: string[]): Promise<number> {
const [sub, ...rest] = args;
const nonce = flag(rest, '--train');
const dryRun = rest.includes('--dry-run');
if (sub === 'build') {
if (!nonce && !flag(rest, '--prs')) return usage('train build needs --prs <n>@<sha>[,...] or --train <nonce>');
const freeTimeoutSec = positive(flag(rest, '--free-timeout'), 2700);
if (Number.isNaN(freeTimeoutSec)) return usage('--free-timeout needs seconds');
if (rest.includes('--urgent') && rest.includes('--repair')) return usage('--urgent and --repair are separate one-PR trains; pass one');
return guarded('train build', () => buildTrain(ctx, {
pins: flag(rest, '--prs'), resume: nonce, dryRun, urgent: rest.includes('--urgent'), repair: rest.includes('--repair'), freeTimeoutSec,
pollMs: Number(process.env.GSTACK_SHIP_QUEUE_POLL_MS) || 30_000,
}));
}
if (sub === 'status') return guarded('train status', () => trainStatus(ctx, { train: nonce }));
if (sub === 'prune') return guarded('train prune', () => pruneTrains(ctx, { dryRun, train: nonce }));
if (!nonce) return usage(`train ${sub ?? '<subcommand>'} needs --train <nonce>`);
if (sub === 'verdict') return guarded('train verdict', () => trainVerdict(ctx, { train: nonce }));
if (sub === 'rebuild') return guarded('train rebuild', () => trainRebuild(ctx, { train: nonce, infra: rest.includes('--infra') }));
if (sub === 'bisect') {
const cap = positive(flag(rest, '--spend-cap'), NaN);
if (Number.isNaN(cap)) return usage('train bisect needs --spend-cap <usd>');
return guarded('train bisect', () => trainBisect(ctx, { train: nonce, spendCap: cap, dryRun }));
}
return usage(`unknown train subcommand ${sub}`);
}
export async function readyMain(ctx: QueueContext, args: string[], base: { pr: string; pregate?: string; gateExit?: number; gateLog?: string }): Promise<number> {
const screen = args.includes('--screen');
const noScreen = args.includes('--no-screen');
if (screen && noScreen) return usage('pass one of --screen or --no-screen');
if (args.includes('--broad') && !screen) return usage('--broad selects the screen policy; it needs --screen');
return guarded('ready', () => trainReady(ctx, { ...base, screen: screen ? 'screen' : noScreen ? 'no-screen' : undefined, broad: args.includes('--broad'), dryRun: args.includes('--dry-run') }));
}
export async function verifyMain(ctx: QueueContext, args: string[], pr: string): Promise<number> {
return guarded('verify', () => verifyTrain(ctx, { pr, train: flag(args, '--train')!, noFetch: args.includes('--no-fetch') }));
}
+83
View File
@@ -0,0 +1,83 @@
/**
* GitHub reads and writes for merge trains, all through the queue's `gh`
* seam (QueueContext.gh) so tests answer them from a fixture: PR identity,
* workflow runs found by branch or by eval-bg nonce, run artifacts and the
* full-gate check run. Dispatch itself goes through scripts/eval-bg.ts.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { Exec, RunSummary } from '../scripts/eval-bg';
import type { QueueContext } from './ship-queue';
export interface PrInfo { headRefOid: string; isCrossRepository: boolean; state: string; headRefName: string }
export interface RunInfo { databaseId: number; status: string; conclusion: string | null; url: string; headSha: string; event: string; displayTitle: string }
export interface CheckInfo { status: string; conclusion: string | null }
function 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; }
}
/** eval-bg's process seam bound to the queue's gh (only `gh` is ever routed through it). */
export function execFor(ctx: QueueContext): Exec {
return (command, args) => {
if (command !== 'gh') return { status: null, stdout: '' };
const r = ctx.gh(args);
return { status: r.ok ? 0 : 1, stdout: r.out };
};
}
export function repoSlug(ctx: QueueContext): string | null {
const r = json<{ nameWithOwner?: string }>(ctx.gh(['repo', 'view', '--json', 'nameWithOwner']));
return r?.nameWithOwner && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(r.nameWithOwner) ? r.nameWithOwner : null;
}
export function prInfo(ctx: QueueContext, pr: string): PrInfo | { error: string } {
const r = ctx.gh(['pr', 'view', pr, '--json', 'headRefOid,isCrossRepository,state,headRefName']);
const p = json<Partial<PrInfo>>(r);
if (!p?.headRefOid) return { error: `cannot read PR #${pr} through gh (${(r.err ?? r.out).trim().slice(0, 120) || 'no headRefOid'})` };
return { headRefOid: p.headRefOid, isCrossRepository: p.isCrossRepository !== false, state: p.state ?? 'OPEN', headRefName: p.headRefName ?? '' };
}
const RUN_FIELDS = 'databaseId,status,conclusion,url,headSha,event,displayTitle';
/** Newest workflow_dispatch run of a workflow on one branch; null when none; 'unreadable' when gh failed. */
export function newestBranchRun(ctx: QueueContext, slug: string, workflow: string, branch: string): RunInfo | null | 'unreadable' {
const runs = json<RunInfo[]>(ctx.gh(['run', 'list', '--repo', slug, '--workflow', workflow, '--branch', branch, '--event', 'workflow_dispatch', '--limit', '20', '--json', RUN_FIELDS]));
if (!Array.isArray(runs)) return 'unreadable';
return runs.filter(r => r.event === 'workflow_dispatch').sort((a, b) => b.databaseId - a.databaseId)[0] ?? null;
}
export function runView(ctx: QueueContext, slug: string, id: number): RunInfo | null {
return json<RunInfo>(ctx.gh(['run', 'view', String(id), '--repo', slug, '--json', RUN_FIELDS]));
}
export function asRun(summary: RunSummary): { id: number; url: string } {
return { id: summary.databaseId, url: summary.url };
}
/** One JSON artifact of a run (the first .json file it holds); null when it is missing or unreadable. */
export function downloadJson(ctx: QueueContext, slug: string, run: number, name: string, scratch: string): unknown {
const dir = path.join(scratch, `${run}-${name}`);
fs.rmSync(dir, { recursive: true, force: true });
fs.mkdirSync(dir, { recursive: true });
try {
if (!ctx.gh(['run', 'download', String(run), '--repo', slug, '-n', name, '-D', dir]).ok) return null;
const file = fs.readdirSync(dir).find(f => f.endsWith('.json'));
return file ? JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')) : null;
} catch { return null; } finally { fs.rmSync(dir, { recursive: true, force: true }); }
}
/** The named check run on a commit (the newest when several); null when absent or unreadable. */
export function checkRun(ctx: QueueContext, sha: string, name: string): CheckInfo | null {
const r = json<{ check_runs?: Array<{ name?: string; status?: string; conclusion?: string | null }> }>(
ctx.gh(['api', `repos/{owner}/{repo}/commits/${sha}/check-runs?check_name=${encodeURIComponent(name)}`]));
const hit = r?.check_runs?.find(c => c.name === name);
return hit ? { status: hit.status ?? 'unknown', conclusion: hit.conclusion ?? null } : null;
}
/** Whether a branch exists on the remote (ls-remote, never a fetch). */
export function remoteBranchSha(git: (args: string[]) => { ok: boolean; out: string }, remote: string, branch: string): string | null {
const r = git(['ls-remote', remote, `refs/heads/${branch}`]);
return r.ok ? (r.out.split(/\s/)[0] || null) : null;
}
+128
View File
@@ -0,0 +1,128 @@
/**
* `ready --pr <n> --screen | --no-screen` under a ship policy with a `train`
* block (docs/ship-policy.md#run-a-merge-train; plan GSTA-49 T1). The
* READY block is today's (lib/ship-queue.ts ready()); this adds the screen
* selection and Garry's default recommendation (screen when the screen
* selects a skill's own cases), requires an explicit choice
* (READY_SCREEN_CHOICE_REQUIRED), dispatches a chosen screen through
* eval-bg's nonce with its purpose and policy in the key, and records the
* choice as a READY receipt (local state and refs/notes/gstack-trains) that
* `train build` requires. The screen's dollar price is left to the T0 cost
* replay (lib/gate-cost.ts) and printed as pending until it lands.
*/
import { randomBytes } from 'node:crypto';
import { DISPATCH_INPUTS, awaitNonceRun, dedupeKey, dispatchWorkflow, findDuplicateRun, listDispatchRuns, makeNonce, type WorkflowDispatch } from '../scripts/eval-bg';
import { EXIT } from './headless-artifacts';
import { ready, type QueueContext } from './ship-queue';
import { git } from './ship-queue-gate';
import { execFor, repoSlug } from './ship-queue-train-github';
import { loadReady, nowIso, persist, readyPath, type ReadyReceipt, type ScreenChoice } from './ship-queue-train-record';
import { outOk, outPending, outRefused, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train';
const Q = 'gstack-ship-queue';
export interface ReadyOptions { pr: string; pregate?: string; gateExit?: number; gateLog?: string; screen?: 'screen' | 'no-screen'; broad: boolean; dryRun: boolean }
export interface ScreenSelection { lines: string[]; recommend: 'screen' | 'no-screen' | null }
/** Skill directories a diff touches: top-level directories that hold a SKILL.md.tmpl at the head. */
export function touchedSkills(changed: readonly string[], hasTemplate: (dir: string) => boolean): string[] {
return [...new Set(changed.filter(f => f.includes('/')).map(f => f.split('/')[0]!))].filter(hasTemplate).sort();
}
/** The selected cases that are a touched skill's own: their touchfiles name a path inside that skill's directory. */
export function skillOwnCases(selected: readonly string[], touchfiles: Record<string, string[]>, skills: readonly string[]): string[] {
return selected.filter(id => (touchfiles[id] ?? []).some(glob => skills.some(s => glob.startsWith(`${s}/`)))).sort();
}
/** gstack's paid selector over a diff (today's PR selection; T2 narrows it), loaded only when the policy asks for it. */
export async function gstackPaidSelection(repoRoot: string, baseSha: string, changed: string[], hasTemplate: (dir: string) => boolean): Promise<ScreenSelection> {
const { computePaidCaseSelection } = await import('../scripts/lib/paid-select');
const { E2E_TOUCHFILES, LLM_JUDGE_TOUCHFILES } = await import('../test/helpers/touchfiles');
const skills = touchedSkills(changed, hasTemplate);
let e2e: string[]; let judges: number; let note = "today's PR selection; T2's narrowed screen has not landed";
try {
const s = computePaidCaseSelection({ profile: 'pr', rootDir: repoRoot, changedFiles: changed, env: { ...process.env, EVALS_BASE: baseSha, EVALS_ALL: '' } }).selection;
e2e = s.e2e ?? Object.keys(E2E_TOUCHFILES);
judges = s.judges ? s.judges.length : Object.keys(LLM_JUDGE_TOUCHFILES).length;
} catch (error) {
e2e = Object.keys(E2E_TOUCHFILES);
judges = Object.keys(LLM_JUDGE_TOUCHFILES).length;
note = `the PR profile needs full validation (${error instanceof Error ? error.message.slice(0, 160) : String(error)}): the screen is the full gate selection`;
}
const own = skillOwnCases(e2e, E2E_TOUCHFILES, skills);
const recommend = own.length ? 'screen' : 'no-screen';
return {
recommend,
lines: [
`SCREEN: selection ${e2e.length} e2e case(s) + ${judges} judge(s) (${note})`,
`SCREEN: skill's own cases ${own.length ? own.slice(0, 12).join(',') + (own.length > 12 ? ` (+${own.length - 12})` : '') : 'none'} → recommend --${recommend}`,
],
};
}
async function screenSelection(env: TrainEnv, head: string, baseSha: string): Promise<ScreenSelection> {
const price = 'SCREEN: price pending the T0 cost replay (lib/gate-cost.ts): cases without cost data are unknown; a red train holds its PRs about an hour';
if (env.train.screen_selection === 'none') return { recommend: null, lines: ['SCREEN: selection not computed (train.screen_selection is none); no recommendation, the lane decides', price] };
const changed = git(env.ctx.repoRoot, ['diff', '--name-only', `${baseSha}...${head}`]).out.split('\n').filter(Boolean);
const hasTemplate = (dir: string) => git(env.ctx.repoRoot, ['cat-file', '-e', `${head}:${dir}/SKILL.md.tmpl`]).ok;
const s = await gstackPaidSelection(env.ctx.repoRoot, baseSha, changed, hasTemplate);
return { ...s, lines: [...s.lines, price] };
}
/** Dispatch (or adopt) the PR's screen on the base branch: inputs name the PR, its merge SHA and the screen policy. */
async function dispatchScreen(env: TrainEnv, o: ReadyOptions, mergeSha: string, baseSha: string): Promise<{ choice: ScreenChoice; line: string } | TrainOut> {
const policy = o.broad ? 'broad' : 'narrow';
const scope = { purpose: 'screen' as const, policy: policy as 'narrow' | 'broad' };
const nonce = makeNonce('pr', mergeSha, baseSha, randomBytes(3).toString('hex'), scope);
const dispatch: WorkflowDispatch = { workflow: env.train.gate_workflow, inputs: {
[DISPATCH_INPUTS.evalsAll]: 'false', [DISPATCH_INPUTS.expectedSha]: mergeSha, [DISPATCH_INPUTS.nonce]: nonce, [DISPATCH_INPUTS.baseRef]: env.ctx.base,
[DISPATCH_INPUTS.baseSha]: baseSha, [DISPATCH_INPUTS.screenPr]: o.pr, [DISPATCH_INPUTS.screenMergeSha]: mergeSha, [DISPATCH_INPUTS.screenPolicy]: policy,
} };
const inputs = Object.entries(dispatch.inputs).map(([k, v]) => `${k}=${v}`).join(' ');
if (o.dryRun) return { choice: { choice: 'screen', policy, nonce, run: null, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: ${dispatch.workflow} --ref ${env.ctx.base} ${inputs} (dry run: not dispatched)` };
const slug = repoSlug(env.ctx);
if (!slug) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: 'gh repo view could not name the repository' }, [], `${Q} ready --pr ${o.pr} --screen`, 'none');
const exec = execFor(env.ctx);
const existing = findDuplicateRun(listDispatchRuns(dispatch.workflow, exec, slug) ?? [], dedupeKey('pr', mergeSha, baseSha, scope));
if (existing) return { choice: { choice: 'screen', policy, nonce: /eval-bg (\S+?)\]/.exec(existing.displayTitle)?.[1] ?? nonce, run: existing.databaseId, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: following run ${existing.databaseId} (same merge SHA, base and policy; no duplicate)` };
const found = dispatchWorkflow(dispatch, env.ctx.base, exec, slug) ? await awaitNonceRun(dispatch.workflow, nonce, exec, slug) : null;
if (!found) return outPending({ code: 'QUEUE_TRAIN_DISPATCH_UNKNOWN', message: `no ${dispatch.workflow} run named ${nonce} appeared after the screen dispatch` }, [], `${Q} ready --pr ${o.pr} --screen${o.broad ? ' --broad' : ''} (adopts the run by its key once it appears)`, 'none');
return { choice: { choice: 'screen', policy, nonce, run: found.databaseId, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: ${dispatch.workflow} run ${found.databaseId} ${found.url} nonce ${nonce}` };
}
export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise<TrainOut> {
const env = trainEnv(ctx);
if ('code' in env) return outRefused(env, [], 'none', 'none');
const base = ready(ctx, o);
if (!base.ok) {
const out = outRefused(base.failure!, base.lines, `fix the refusal, then ${Q} ready --pr ${o.pr} --screen|--no-screen`, base.run);
return ['QUEUE_VERIFY_MISMATCH', 'QUEUE_STAMP_NOT_STAMP_ONLY'].includes(base.failure!.code) ? { ...out, exit: EXIT.fail } : out;
}
const head = git(ctx.repoRoot, ['rev-parse', 'HEAD']).out;
const baseSha = git(ctx.repoRoot, ['rev-parse', `${env.remote}/${ctx.base}`]).out;
const sel = await screenSelection(env, head, baseSha);
const lines = [...base.lines, ...sel.lines];
const rec = sel.recommend ?? 'no-screen';
if (!o.screen) {
return outRefused({ code: 'READY_SCREEN_CHOICE_REQUIRED', message: `the base policy runs merge trains; choose --screen or --no-screen (recommended: --${rec})` }, lines,
`${Q} ready --pr ${o.pr} --${rec}`, 'none');
}
let choice: ScreenChoice = { choice: 'no-screen', policy: null, nonce: null, run: null, merge_sha: null };
if (o.screen === 'screen') {
const ls = git(ctx.repoRoot, ['ls-remote', env.remote, `refs/pull/${o.pr}/merge`]);
const mergeSha = ls.ok ? ls.out.split(/\s/)[0] ?? '' : '';
if (!/^[0-9a-f]{40,64}$/.test(mergeSha)) return outRefused({ code: 'QUEUE_NOT_READY', message: `${env.remote} has no refs/pull/${o.pr}/merge (the PR conflicts with ${ctx.base}, or GitHub has not computed it yet)` }, lines, `merge ${ctx.base} into the branch, push, then ${Q} ready --pr ${o.pr} --screen`, 'none');
const d = await dispatchScreen(env, o, mergeSha, baseSha);
if ('exit' in d) return { ...d, lines: [...lines, ...d.lines] };
choice = d.choice;
lines.push(d.line);
}
lines.splice(1, 0, `screen: ${choice.choice}${choice.policy ? ` policy=${choice.policy} nonce=${choice.nonce} run=${choice.run ?? '-'}` : ''}`);
const now = nowIso();
const readyAt = loadReady(env.repo.key, o.pr, head)?.ready_at ?? now;
const receipt: ReadyReceipt = { schema: 1, kind: 'ready', repo_key: env.repo.key, pr: o.pr, head, base_sha: baseSha, ready_at: readyAt, screen: choice, updated_at: now };
if (o.dryRun) return outOk([...lines, 'DRY-RUN: no READY receipt written, nothing dispatched'], `${Q} ready --pr ${o.pr} --${o.screen}${o.broad ? ' --broad' : ''}`, 'none');
const p = persist(ctx.repoRoot, [receipt], { remote: env.remote });
lines.push(`READY_RECEIPT: ${readyPath(env.repo.key, o.pr, head)} (${p.detail})`);
return outOk(lines, `send the READY block; the coordinator builds the train: ${Q} train build --prs ${o.pr}@${head},...`, readyPath(env.repo.key, o.pr, head));
}
+207
View File
@@ -0,0 +1,207 @@
/**
* Merge-train records (docs/ship-policy.md#run-a-merge-train): versioned
* `train.json`, one attempt record per dispatch, the bisect ledger, READY
* receipts and the queue freeze, under `$GSTACK_STATE_ROOT/ship-queue/`
* (lib/state-root.ts resolves the root). Every record is mirrored to origin
* as a git note under `refs/notes/gstack-trains`, each on a blob named for
* the record, so a lost machine rebuilds its state with `train status`. A
* record is written locally before its note, and the note before any remote
* effect that depends on it.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { redactFindingSpans } from './redact-engine';
import { git, sha256, writeJsonAtomic } from './ship-queue-gate';
import { resolveStateRoot } from './state-root';
export const TRAIN_SCHEMA = 1;
export const NOTES_REF = 'refs/notes/gstack-trains';
const NOTES_REMOTE_REF = 'refs/notes/gstack-trains-origin';
export type TrainState =
| 'built' | 'pushed' | 'free-pending' | 'free-red' | 'free-green' | 'dispatch-unknown' | 'dispatch-absent' | 'gate-pending'
| 'green' | 'red' | 'incomplete' | 'attributed' | 'red-on-main' | 'unattributed' | 'merging' | 'merged' | 'superseded';
/** States after which a train never dispatches again; prune deletes their branches. */
export const TERMINAL_STATES: readonly TrainState[] = ['free-red', 'attributed', 'red-on-main', 'merged', 'superseded'];
export interface ScreenChoice { choice: 'screen' | 'no-screen'; policy: 'narrow' | 'broad' | null; nonce: string | null; run: number | null; merge_sha: string | null }
export interface TrainSlot { pr: string; head: string; ready_at: string; screen: ScreenChoice }
export interface TrainPrefix { index: number; commit: string; tree: string; pr: string | null }
export interface FreeRun { run: number | null; url: string | null; head_sha: string | null; conclusion: string | null; verdict: 'green' | 'red' | 'missing' | 'stale' | 'cancelled' | 'pending' }
export interface TrainRecord {
schema: 1; kind: 'train'; nonce: string; key: string; repo_key: string; repo: string; base: { ref: string; sha: string }; policy: string;
slots: TrainSlot[]; held: string[]; prefixes: TrainPrefix[]; head: string; tree: string; branch: string;
urgent: boolean; repair: boolean; state: TrainState; free: FreeRun | null; attempts: string[];
merged: Array<{ pr: string; sha: string; tree: string; at: string }>; superseded_by: string | null;
effects: Array<{ at: string; what: string }>; error: string | null; created_at: string; updated_at: string;
}
export type AttemptPurpose = 'gate' | 'rebuild' | 'infra-redispatch' | 'bisect';
export type GateOutcome = 'PENDING' | 'GREEN' | 'RED' | 'INCOMPLETE';
export interface CasePanel { case: string; kind: string; status: string; red_class: string | null; cost_usd: number | null }
export interface AttemptRecord {
schema: 1; kind: 'train-attempt'; train: string; id: string; purpose: AttemptPurpose; nonce: string; workflow: string; ref: string;
inputs: Record<string, string>; state: 'dispatching' | 'unknown' | 'absent' | 'dispatched' | 'completed'; run: { id: number; url: string } | null;
outcome: GateOutcome | null; detail: string | null; red_cases: string[]; panels: CasePanel[]; infra_eligible: boolean;
cost_usd: number | null; prefixes: number[]; created_at: string; updated_at: string;
}
export type PrefixResult = 'red' | 'green';
export interface BisectRecord {
schema: 1; kind: 'train-bisect'; train: string; cases: string[]; spend_cap: number; spent_usd: number;
prefixes: Array<{ index: number; sha: string; results: Record<string, PrefixResult>; attempts: string[] }>;
outcome: null | { kind: 'attributed' | 'red-on-main' | 'unattributed'; all_green: boolean; prs: string[]; patterns: Record<string, string>; at: string };
updated_at: string;
}
export interface FreezeRecord {
schema: 1; kind: 'freeze'; repo_key: string; train: string; merged: string[]; unmerged: Array<{ pr: string; head: string }>;
main: { sha: string; tree: string }; reason: string; backstop: { nonce: string; run: number | null; state: string } | null;
repair: { pr: string; head: string; train: string | null } | null; created_at: string; cleared_at: string | null; cleared_by: string | null; updated_at: string;
}
export interface ReadyReceipt {
schema: 1; kind: 'ready'; repo_key: string; pr: string; head: string; base_sha: string; ready_at: string; screen: ScreenChoice; updated_at: string;
}
export type AnyRecord = TrainRecord | AttemptRecord | BisectRecord | FreezeRecord | ReadyReceipt;
/** Clock seam for fill-time and record tests (`GSTACK_SHIP_QUEUE_NOW`, ISO-8601). */
export function nowIso(): string {
const fixed = process.env.GSTACK_SHIP_QUEUE_NOW;
return fixed && !Number.isNaN(Date.parse(fixed)) ? new Date(fixed).toISOString() : new Date().toISOString();
}
/** An unexpected failure's cause, with secrets withheld, for the record and the error line. */
export function redactCause(message: string): string {
const text = message.replace(/\s+/g, ' ').slice(0, 400);
return redactFindingSpans(text) ?? '(cause withheld: it could not be redacted safely)';
}
export function queueRoot(env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveStateRoot(env), 'ship-queue');
}
export const trainDir = (nonce: string) => path.join(queueRoot(), 'trains', nonce);
export const trainFile = (nonce: string) => path.join(trainDir(nonce), 'train.json');
const attemptPath = (nonce: string, id: string) => path.join(trainDir(nonce), 'attempts', `${id}.json`);
const bisectPath = (nonce: string) => path.join(trainDir(nonce), 'bisect.json');
const freezePath = (repoKey: string) => path.join(queueRoot(), 'freeze', `${repoKey}.json`);
export const readyPath = (repoKey: string, pr: string, head: string) => path.join(queueRoot(), 'ready', repoKey, `${pr}-${head}.json`);
/** The repository's identity: origin's URL, keyed so records of different repositories never mix. */
export function repoIdentity(repoRoot: string): { key: string; url: string } {
const url = git(repoRoot, ['config', '--get', 'remote.origin.url']).out.trim() || repoRoot;
const norm = url.replace(/\.git$/, '').replace(/^git@github\.com:/, 'https://github.com/').replace(/\/+$/, '');
return { key: sha256(norm).slice(0, 16), url: norm };
}
function readJson<T>(file: string, kind: string): T | null {
try { const r = JSON.parse(fs.readFileSync(file, 'utf8')); return r?.schema === TRAIN_SCHEMA && r.kind === kind ? r as T : null; } catch { return null; }
}
export const loadTrain = (nonce: string) => (/^[A-Za-z0-9._-]{1,128}$/.test(nonce) ? readJson<TrainRecord>(trainFile(nonce), 'train') : null);
export const loadAttempt = (nonce: string, id: string) => (/^[A-Za-z0-9._-]{1,64}$/.test(id) ? readJson<AttemptRecord>(attemptPath(nonce, id), 'train-attempt') : null);
export const loadBisect = (nonce: string) => readJson<BisectRecord>(bisectPath(nonce), 'train-bisect');
export const loadFreeze = (repoKey: string) => readJson<FreezeRecord>(freezePath(repoKey), 'freeze');
export const loadReady = (repoKey: string, pr: string, head: string) => readJson<ReadyReceipt>(readyPath(repoKey, pr, head), 'ready');
export const activeFreeze = (repoKey: string): FreezeRecord | null => { const f = loadFreeze(repoKey); return f && !f.cleared_at ? f : null; };
export function attemptsOf(train: TrainRecord): AttemptRecord[] {
return train.attempts.map(id => loadAttempt(train.nonce, id)).filter((a): a is AttemptRecord => !!a);
}
/** Every train of this repository, newest first. */
export function listTrains(repoKey: string): TrainRecord[] {
const dir = path.join(queueRoot(), 'trains');
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir).map(n => loadTrain(n)).filter((t): t is TrainRecord => !!t && t.repo_key === repoKey)
.sort((a, b) => b.created_at.localeCompare(a.created_at) || b.nonce.localeCompare(a.nonce));
}
export function recordPath(r: AnyRecord): string {
if (r.kind === 'train') return trainFile(r.nonce);
if (r.kind === 'train-attempt') return attemptPath(r.train, r.id);
if (r.kind === 'train-bisect') return bisectPath(r.train);
if (r.kind === 'freeze') return freezePath(r.repo_key);
return readyPath(r.repo_key, r.pr, r.head);
}
function noteName(r: AnyRecord): string {
if (r.kind === 'train') return `gstack-trains:train:${r.nonce}`;
if (r.kind === 'train-attempt') return `gstack-trains:attempt:${r.train}:${r.id}`;
if (r.kind === 'train-bisect') return `gstack-trains:bisect:${r.train}`;
if (r.kind === 'freeze') return `gstack-trains:freeze:${r.repo_key}`;
return `gstack-trains:ready:${r.repo_key}:${r.pr}:${r.head}`;
}
const NOTES_IDENTITY = { GIT_AUTHOR_NAME: 'gstack-ship-queue', GIT_AUTHOR_EMAIL: 'ship-queue@gstack.local', GIT_COMMITTER_NAME: 'gstack-ship-queue', GIT_COMMITTER_EMAIL: 'ship-queue@gstack.local' };
const notesEnv = () => ({ ...process.env, ...NOTES_IDENTITY });
/** Fetch origin's notes and fold them into the local notes ref (the local side wins a same-record conflict). */
export function fetchNotes(repoRoot: string, remote = 'origin'): { ok: boolean; detail: string } {
const f = git(repoRoot, ['fetch', '--no-tags', '-q', remote, `+${NOTES_REF}:${NOTES_REMOTE_REF}`]);
if (!f.ok) {
const absent = /couldn't find remote ref|no such ref/i.test(f.err);
return { ok: absent, detail: absent ? `${remote} has no ${NOTES_REF} yet` : `git fetch ${NOTES_REF} failed: ${f.err.slice(0, 160)}` };
}
const local = git(repoRoot, ['rev-parse', '--verify', '--quiet', NOTES_REF]).out;
const fetched = git(repoRoot, ['rev-parse', '--verify', '--quiet', NOTES_REMOTE_REF]).out;
if (!local) { git(repoRoot, ['update-ref', NOTES_REF, fetched]); return { ok: true, detail: 'notes fetched' }; }
if (local === fetched || git(repoRoot, ['merge-base', '--is-ancestor', fetched, local]).ok) return { ok: true, detail: 'notes current' };
const m = git(repoRoot, ['notes', `--ref=${NOTES_REF}`, 'merge', '-q', '-s', 'ours', NOTES_REMOTE_REF], notesEnv());
return m.ok ? { ok: true, detail: 'notes merged' } : { ok: false, detail: `git notes merge failed: ${m.err.slice(0, 160)}` };
}
function addNote(repoRoot: string, r: AnyRecord): boolean {
const blob = git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, noteName(r));
if (!blob.ok) return false;
return git(repoRoot, ['notes', `--ref=${NOTES_REF}`, 'add', '-f', '-F', '-', blob.out], notesEnv(), JSON.stringify(r)).ok;
}
/**
* Write records locally, then as notes, then push the notes ref (fetch,
* merge and retry on a race). A failed push leaves the local records and
* notes for the next write to carry, and says so.
*/
export function persist(repoRoot: string, records: AnyRecord[], opts: { push?: boolean; remote?: string } = {}): { pushed: boolean; detail: string } {
for (const r of records) writeJsonAtomic(recordPath(r), r);
if (!records.every(r => addNote(repoRoot, r))) return { pushed: false, detail: 'could not write the notes locally' };
if (opts.push === false) return { pushed: false, detail: 'notes written locally (not pushed)' };
const remote = opts.remote ?? 'origin';
let last = '';
for (let i = 0; i < 3; i++) {
const p = git(repoRoot, ['push', '-q', remote, `${NOTES_REF}:${NOTES_REF}`]);
if (p.ok) return { pushed: true, detail: `notes pushed to ${remote} ${NOTES_REF}` };
last = p.err.slice(0, 160);
const f = fetchNotes(repoRoot, remote);
if (!f.ok) return { pushed: false, detail: f.detail };
}
return { pushed: false, detail: `git push ${NOTES_REF} failed: ${last}` };
}
function newerOrMissing(r: AnyRecord): boolean {
try {
const cur = JSON.parse(fs.readFileSync(recordPath(r), 'utf8'));
return !(typeof cur.updated_at === 'string' && cur.updated_at >= r.updated_at);
} catch { return true; }
}
function parseNote(text: string): AnyRecord | null {
try {
const r = JSON.parse(text);
const ids = /^[A-Za-z0-9._-]{1,128}$/;
if (r?.schema !== TRAIN_SCHEMA || typeof r.updated_at !== 'string') return null;
if (r.kind === 'train' && ids.test(r.nonce)) return r;
if ((r.kind === 'train-attempt' && ids.test(r.train) && ids.test(r.id)) || (r.kind === 'train-bisect' && ids.test(r.train))) return r;
if (r.kind === 'freeze' && ids.test(r.repo_key)) return r;
if (r.kind === 'ready' && ids.test(r.repo_key) && /^\d+$/.test(r.pr) && /^[0-9a-f]{40,64}$/.test(r.head)) return r;
} catch {}
return null;
}
/** Rebuild local records from the notes ref: a note whose record is missing locally, or newer than the local copy, is written. */
export function rebuildFromNotes(repoRoot: string, remote = 'origin'): { restored: number; read: number; detail: string } {
const f = fetchNotes(repoRoot, remote);
const list = git(repoRoot, ['notes', `--ref=${NOTES_REF}`, 'list']);
if (!list.ok || !list.out.trim()) return { restored: 0, read: 0, detail: f.detail };
const records = list.out.split('\n').map(l => l.split(' ')[0]!).filter(Boolean)
.map(blob => parseNote(git(repoRoot, ['cat-file', 'blob', blob]).out)).filter((r): r is AnyRecord => !!r);
const fresh = records.filter(newerOrMissing);
for (const r of fresh) writeJsonAtomic(recordPath(r), r);
return { restored: fresh.length, read: records.length, detail: f.detail };
}
+332
View File
@@ -0,0 +1,332 @@
/**
* Train verdict, bisect and the one preregistered rebuild
* (docs/ship-policy.md#run-a-merge-train; plan GSTA-49 lane T1, P3).
*
* The verdict reads the train's own gate run and the record artifact that run
* publishes (with the `gstack/full-gate` check run) on the train head; it
* only reads, never writes a check. Green needs a complete census from the
* run's own plan manifest. A red train stays red: the bisect runs only its
* red cases, one trial on every prefix T0..Tk, and attributes by pattern.
* Rebuild-once (Garry, 2026-10-18; to be recorded in EVAL_POLICY beside
* infraRedispatch): an all-green bisect earns one unchanged rebuild.
*/
import * as path from 'node:path';
import { randomBytes } from 'node:crypto';
import { DISPATCH_INPUTS, listDispatchRuns, makeNonce, runForNonce } from '../scripts/eval-bg';
import type { QueueContext } from './ship-queue';
import { git } from './ship-queue-gate';
import {
checkRun, downloadJson, execFor, asRun, remoteBranchSha, repoSlug, runView, type CheckInfo, type RunInfo,
} from './ship-queue-train-github';
import {
attemptsOf, loadBisect, nowIso, persist, trainDir, trainFile,
type AttemptRecord, type BisectRecord, type CasePanel, type GateOutcome, type PrefixResult, type TrainRecord,
} from './ship-queue-train-record';
import {
dispatchAttempt, effect, findTrain, gateDispatch, nextFor, outFailed, outOk, outPending, outRefused, pinList, save, trainEnv,
type TrainEnv, type TrainOut,
} from './ship-queue-train';
/** EVAL_POLICY.infraRedispatch (test/helpers/periodic-exclude-data.ts): one re-dispatch of an INFRA/INCOMPLETE-only census. */
export const INFRA_REDISPATCH = 1;
/** Rebuild-once (Garry, 2026-10-18): an all-green bisect earns exactly one unchanged rebuild; pinned to EVAL_POLICY by its test. */
export const REBUILD_ONCE = 1;
const Q = 'gstack-ship-queue';
/** The record artifact the train's own gate run publishes (plan T1b), read by verdict, verify and the backstop. */
export interface TrainGateRecord {
schema: 1; kind: 'train-gate'; nonce: string; head_sha: string; tree: string; verdict: 'GREEN' | 'RED';
census: { complete: boolean; planned: { rule: number; judge: number }; executed: { rule: number; judge: number };
unbound: number; partial_shards: number; cancelled_shards: number; killed_before_upload: number };
panels: CasePanel[];
}
/** One bisect prefix's artifact: the red cases on that prefix, one trial each. */
export interface BisectPrefixRecord { schema: 1; kind: 'train-bisect-prefix'; nonce: string; prefix_sha: string; checked_out_sha: string; panels: CasePanel[] }
export const bisectArtifact = (recordArtifact: string, sha: string) => `${recordArtifact}-bisect-${sha.slice(0, 12)}`;
export interface GateClass { outcome: GateOutcome; detail: string; red: string[]; panels: CasePanel[]; infraEligible: boolean; cost: number | null }
/** Every paid-report class mapped to one train outcome; green only for a complete, bound, published census. */
export function classifyGate(raw: unknown, expect: { nonce: string; head: string; tree: string }, run: Pick<RunInfo, 'status' | 'conclusion'>, check: CheckInfo | null): GateClass {
const base = { red: [] as string[], panels: [] as CasePanel[], infraEligible: false, cost: null as number | null };
if (run.status !== 'completed') return { ...base, outcome: 'PENDING', detail: `run is ${run.status}` };
const rec = raw as TrainGateRecord | null;
if (!rec || rec.schema !== 1 || rec.kind !== 'train-gate' || !Array.isArray(rec.panels) || !rec.census) {
return { ...base, outcome: 'INCOMPLETE', infraEligible: true, detail: `no train record artifact (run concluded ${run.conclusion ?? 'unknown'}; a slice killed before its upload step counts here)` };
}
const panels = rec.panels;
const known = panels.map(p => p.cost_usd).filter((c): c is number => typeof c === 'number');
const cost = known.length ? Math.round(known.reduce((a, b) => a + b, 0) * 100) / 100 : null;
const withPanels = { ...base, panels, cost };
if (rec.nonce !== expect.nonce || rec.head_sha !== expect.head || rec.tree !== expect.tree) {
return { ...withPanels, outcome: 'INCOMPLETE', detail: `the record is bound to nonce ${rec.nonce} head ${String(rec.head_sha).slice(0, 12)} tree ${String(rec.tree).slice(0, 12)}, not this train's attempt` };
}
const red = panels.filter(p => p.status === 'FAIL' && p.red_class === 'VERDICT').map(p => p.case).sort();
if (red.length) return { ...withPanels, outcome: 'RED', red, detail: `${red.length} red case(s): ${red.join(', ')}` };
const c = rec.census;
const gaps = [
...(c.planned.rule + c.planned.judge <= 0 ? ['the plan manifest lists no cases'] : []),
...(c.executed.rule !== c.planned.rule ? [`rule ${c.executed.rule}/${c.planned.rule} executed`] : []),
...(c.executed.judge !== c.planned.judge ? [`judge ${c.executed.judge}/${c.planned.judge} executed`] : []),
...(c.unbound ? [`${c.unbound} unbound result`] : []), ...(c.partial_shards ? [`${c.partial_shards} partial shard`] : []),
...(c.cancelled_shards ? [`${c.cancelled_shards} cancelled shard`] : []), ...(c.killed_before_upload ? [`${c.killed_before_upload} slice killed before upload`] : []),
...(!c.complete ? ['census marked incomplete'] : []),
...panels.filter(p => p.status !== 'PASS').map(p => `${p.case} ${p.status}${p.red_class ? ` (${p.red_class})` : ''}`),
];
if (gaps.length) return { ...withPanels, outcome: 'INCOMPLETE', infraEligible: !panels.some(p => p.status === 'SKIPPED'), detail: gaps.join('; ') };
if (run.conclusion !== 'success' || rec.verdict !== 'GREEN') return { ...withPanels, outcome: 'INCOMPLETE', detail: `run concluded ${run.conclusion ?? 'unknown'} with record verdict ${rec.verdict}` };
if (!check || check.conclusion !== 'success') return { ...withPanels, outcome: 'INCOMPLETE', detail: `the run did not publish a successful check run on the train head (${check ? `${check.status}/${check.conclusion}` : 'missing'})` };
return { ...withPanels, outcome: 'GREEN', detail: `rule ${c.executed.rule}/${c.planned.rule}, judge ${c.executed.judge}/${c.planned.judge}` };
}
const gateAttempts = (t: TrainRecord) => attemptsOf(t).filter(a => a.purpose !== 'bisect');
const attemptLine = (a: AttemptRecord) => `ATTEMPT: ${a.id} ${a.purpose} run ${a.run?.id ?? '-'} ${a.outcome ?? a.state}${a.red_cases.length ? ` red=${a.red_cases.join(',')}` : ''}${a.cost_usd !== null ? ` cost=$${a.cost_usd.toFixed(2)}` : ''}${a.detail ? ` (${a.detail})` : ''}`;
function casePrice(env: TrainEnv, red: AttemptRecord, id: string): number {
return red.panels.find(p => p.case === id)?.cost_usd ?? env.train.trial_reservation_usd;
}
/** The whole-bisect reservation for a red attempt: every red case on every prefix. */
export function bisectReservation(env: TrainEnv, t: TrainRecord, red: AttemptRecord): number {
const perPrefix = red.red_cases.reduce((sum, id) => sum + casePrice(env, red, id), 0);
return Math.ceil(perPrefix * t.prefixes.length * 100) / 100;
}
function unknownDispatch(t: TrainRecord, a: AttemptRecord): TrainOut {
return outPending({ code: 'QUEUE_TRAIN_DISPATCH_UNKNOWN', message: `the ${a.purpose} dispatch ${a.id} (nonce ${a.nonce}) is unconfirmed` }, [attemptLine(a)], `${Q} train status --train ${t.nonce}`, trainFile(t.nonce));
}
/** `train verdict`: read the current gate attempt's run and record; print every attempt, so a rebuild reports both runs. */
export function trainVerdict(ctx: QueueContext, o: { train: string }): TrainOut {
const env = trainEnv(ctx);
if ('code' in env) return outRefused(env, [], 'none', 'none');
const t = findTrain(env, o.train);
if (!t) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `no train record ${o.train} here or in refs/notes/gstack-trains` }, [], `${Q} train status`, 'none');
const run = trainFile(t.nonce);
const a = gateAttempts(t).at(-1);
if (!a) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `train ${t.nonce} has no gate dispatch yet` }, [], nextFor(t), run);
if (a.state === 'dispatching' || a.state === 'unknown') return unknownDispatch(t, a);
if (a.state === 'absent') return outRefused({ code: 'QUEUE_TRAIN_DISPATCH_UNKNOWN', message: `the dispatch ${a.nonce} never started (train status found no run)` }, [attemptLine(a)], `${Q} train build --train ${t.nonce}`, run);
const slug = repoSlug(ctx);
if (!a.outcome || a.outcome === 'PENDING') {
const view = slug && a.run ? runView(ctx, slug, a.run.id) : null;
if (!slug || !view) return outPending({ code: 'QUEUE_TRAIN_PENDING', message: `cannot read run ${a.run?.id} through gh` }, [attemptLine(a)], `${Q} train verdict --train ${t.nonce}`, run);
const record = view.status === 'completed' ? downloadJson(ctx, slug, a.run!.id, env.train.record_artifact, path.join(trainDir(t.nonce), 'scratch')) : null;
const check = view.status === 'completed' ? checkRun(ctx, t.head, env.train.full_gate_check) : null;
const c = classifyGate(record, { nonce: a.nonce, head: t.head, tree: t.tree }, view, check);
Object.assign(a, { outcome: c.outcome, detail: c.detail, red_cases: c.red, panels: c.panels, infra_eligible: c.infraEligible, cost_usd: c.cost, state: view.status === 'completed' ? 'completed' : a.state });
if (c.outcome !== 'PENDING') {
t.state = c.outcome === 'GREEN' ? 'green' : c.outcome === 'RED' ? (a.purpose === 'rebuild' ? 'unattributed' : 'red') : 'incomplete';
effect(t, `verdict ${a.id}: ${c.outcome}`);
}
save(env, t, a);
}
const lines = [...gateAttempts(t).map(attemptLine), `VERDICT: ${a.outcome} train ${t.nonce} run ${a.run?.id ?? '-'} head ${t.head.slice(0, 12)} tree ${t.tree.slice(0, 12)}`];
if (a.outcome === 'PENDING') return outPending({ code: 'QUEUE_TRAIN_PENDING', message: `the gate run ${a.run?.id} has not finished (${a.detail})` }, lines, `${Q} train verdict --train ${t.nonce}`, run);
if (a.outcome === 'GREEN') return outOk(lines, nextFor(t), run);
if (a.outcome === 'RED') {
const next = a.purpose === 'rebuild'
? `second red stays red: route ${a.red_cases.join(',')} to a repair lane (GStack Minder), then ${Q} train build --prs <pins without the PRs held>`
: `${Q} train bisect --train ${t.nonce} --spend-cap ${bisectReservation(env, t, a).toFixed(2)}`;
return outFailed({ code: 'QUEUE_TRAIN_RED', message: `train ${t.nonce} is red on ${a.red_cases.join(', ')}` }, lines, next, run);
}
const used = gateAttempts(t).filter(x => x.purpose === 'infra-redispatch').length;
const next = a.infra_eligible && used < INFRA_REDISPATCH
? `${Q} train rebuild --train ${t.nonce} --infra (the single re-dispatch EVAL_POLICY.infraRedispatch allows; report both runs)`
: `diagnose run ${a.run?.url ?? a.run?.id}; the train stays incomplete (GStack Minder routes it)`;
return outFailed({ code: 'QUEUE_TRAIN_INCOMPLETE', message: `train ${t.nonce} gate is incomplete: ${a.detail}` }, lines, next, run);
}
export interface BisectClass { kind: 'attributed' | 'red-on-main' | 'unattributed'; all_green: boolean; prs: string[]; patterns: Record<string, string> }
/**
* Pattern attribution over T0..Tk (one trial each, so only shapes count):
* monotone green-then-red from Ti blames slot i's PR; red everywhere,
* T0 included, is main; all green, or anything else, is unattributed.
*/
export function classifyBisect(cases: string[], results: Array<Record<string, PrefixResult>>, slots: Array<{ pr: string }>): BisectClass {
const patterns: Record<string, string> = {};
const blamed = new Set<string>();
let main = false; let other = false; let green = 0;
for (const c of cases) {
const seq = results.map(r => (r[c] === 'red' ? 'R' : 'G')).join('');
patterns[c] = seq;
const first = seq.indexOf('R');
if (first < 0) green++;
else if (first === 0 && !seq.includes('G')) main = true;
else if (first > 0 && !seq.slice(first).includes('G')) blamed.add(slots[first - 1]!.pr);
else other = true;
}
if (main) return { kind: 'red-on-main', all_green: false, prs: [], patterns };
if (other || !blamed.size) return { kind: 'unattributed', all_green: green === cases.length, prs: [], patterns };
return { kind: 'attributed', all_green: false, prs: [...blamed].sort((a, b) => Number(a) - Number(b)), patterns };
}
function prefixResults(raw: unknown, sha: string, nonce: string, cases: string[]): { results: Record<string, PrefixResult>; cost: number } | null {
const rec = raw as BisectPrefixRecord | null;
if (!rec || rec.schema !== 1 || rec.kind !== 'train-bisect-prefix' || rec.nonce !== nonce || rec.prefix_sha !== sha || rec.checked_out_sha !== sha || !Array.isArray(rec.panels)) return null;
const results: Record<string, PrefixResult> = {};
let cost = 0;
for (const p of rec.panels) {
if (!cases.includes(p.case)) continue;
cost += typeof p.cost_usd === 'number' ? p.cost_usd : 0;
if (p.status === 'PASS') results[p.case] = 'green';
else if (p.status === 'FAIL' && p.red_class === 'VERDICT') results[p.case] = 'red';
}
return { results, cost };
}
/** Collect a finished bisect dispatch's prefix artifacts into the ledger; a still-running one returns its run state. */
function collect(env: TrainEnv, slug: string, t: TrainRecord, b: BisectRecord, a: AttemptRecord): string | null {
const view = a.run ? runView(env.ctx, slug, a.run.id) : null;
if (!view) return 'unreadable through gh';
if (view.status !== 'completed') return view.status;
let got = 0;
for (const index of a.prefixes) {
const p = b.prefixes[index]!;
const r = prefixResults(downloadJson(env.ctx, slug, a.run!.id, bisectArtifact(env.train.record_artifact, p.sha), path.join(trainDir(t.nonce), 'scratch')), p.sha, a.nonce, b.cases);
if (!r) continue;
Object.assign(p.results, r.results);
b.spent_usd = Math.round((b.spent_usd + r.cost) * 100) / 100;
got++;
}
Object.assign(a, { state: 'completed', detail: `collected ${got} of ${a.prefixes.length} prefix result(s)` });
return null;
}
function bisectOutcome(env: TrainEnv, t: TrainRecord, b: BisectRecord): TrainOut {
const c = classifyBisect(b.cases, b.prefixes.map(p => p.results), t.slots);
b.outcome = { ...c, at: nowIso() };
t.state = c.kind;
effect(t, `bisect: ${c.kind}${c.prs.length ? ' #' + c.prs.join(',#') : ''}`);
b.updated_at = nowIso();
persist(env.ctx.repoRoot, [b], { remote: env.remote });
save(env, t);
const run = trainFile(t.nonce);
const lines = [...Object.entries(c.patterns).map(([id, s]) => `PATTERN: ${id} ${s.split('').map((x, i) => `T${i}=${x}`).join(' ')}`), `BISECT: spent $${b.spent_usd.toFixed(2)} of cap $${b.spend_cap.toFixed(2)}`];
if (c.kind === 'attributed') return outOk([...lines, `BISECT: ATTRIBUTED ${c.prs.map(p => '#' + p).join(',')}`], nextFor(t), run);
if (c.kind === 'red-on-main') return outFailed({ code: 'QUEUE_TRAIN_RED_ON_MAIN', message: `${b.cases.join(', ')} red on every prefix, T0 (main) included` }, [...lines, 'BISECT: RED_ON_MAIN'], nextFor(t), run);
return outFailed({ code: 'QUEUE_TRAIN_UNATTRIBUTED', message: c.all_green ? 'every prefix was green: the red is unattributed; recovery: the one preregistered rebuild' : 'the pattern is not monotone: recovery: a repair lane, then a changed train' },
[...lines, `BISECT: UNATTRIBUTED (${c.all_green ? 'all green' : 'not monotone'})`], nextFor(t), run);
}
/** Validate bisect inputs: red cases from the gate's own plan, every prefix an ancestor of train/<nonce>'s head. */
function bisectInputs(env: TrainEnv, t: TrainRecord, red: AttemptRecord): string | null {
for (const id of red.red_cases) {
if (!/^[A-Za-z0-9_.:/-]{1,128}$/.test(id) || !red.panels.some(p => p.case === id)) return `red case ${id} is not in the gate's plan`;
}
const ref = `refs/gstack/${t.branch}`;
const f = git(env.ctx.repoRoot, ['fetch', '--no-tags', '-q', env.remote, `+refs/heads/${t.branch}:${ref}`]);
if (!f.ok) return `cannot fetch ${t.branch}: ${f.err.slice(0, 120)}`;
const tip = git(env.ctx.repoRoot, ['rev-parse', ref]).out;
if (tip !== t.head) return `${t.branch} is at ${tip.slice(0, 12)}, not the recorded train head ${t.head.slice(0, 12)}`;
for (const p of t.prefixes) {
if (!git(env.ctx.repoRoot, ['merge-base', '--is-ancestor', p.commit, tip]).ok) return `prefix T${p.index} ${p.commit.slice(0, 12)} is not an ancestor of ${t.branch}`;
}
return null;
}
/**
* `train bisect`: resumable and spend-capped. Each run reconciles the earlier
* dispatch by nonce, records every prefix result it published, then reserves
* the remaining case×prefix trials at their recorded price (unknown at the
* policy reservation) and launches only the prefixes that fit under the cap.
*/
export async function trainBisect(ctx: QueueContext, o: { train: string; spendCap: number; dryRun: boolean }): Promise<TrainOut> {
const env = trainEnv(ctx);
if ('code' in env) return outRefused(env, [], 'none', 'none');
const t = findTrain(env, o.train);
if (!t) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `no train record ${o.train} here or in refs/notes/gstack-trains` }, [], `${Q} train status`, 'none');
const run = trainFile(t.nonce);
const red = gateAttempts(t).at(-1);
if (!red || red.outcome !== 'RED') return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `bisect needs a red verdict; the train's last gate is ${red?.outcome ?? 'missing'}` }, [], nextFor(t), run);
if (red.purpose === 'rebuild') return outRefused({ code: 'QUEUE_PROOF_MISSING', message: 'a rebuild\'s second red is not bisected again' }, [], nextFor(t), run);
const prior = loadBisect(t.nonce);
if (prior?.outcome) return bisectOutcome(env, t, prior);
const bad = bisectInputs(env, t, red);
if (bad) return outRefused({ code: 'QUEUE_TRAIN_PREFIX_INVALID', message: bad }, [], `${Q} train status --train ${t.nonce}`, run);
const b: BisectRecord = prior ?? { schema: 1, kind: 'train-bisect', train: t.nonce, cases: red.red_cases, spend_cap: o.spendCap, spent_usd: 0,
prefixes: t.prefixes.map(p => ({ index: p.index, sha: p.commit, results: {}, attempts: [] })), outcome: null, updated_at: nowIso() };
b.spend_cap = o.spendCap;
const slug = repoSlug(ctx);
if (!slug) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: 'gh repo view could not name the repository' }, [], `${Q} train bisect --train ${t.nonce} --spend-cap ${o.spendCap}`, run);
for (const a of attemptsOf(t).filter(x => x.purpose === 'bisect' && x.state !== 'completed' && x.state !== 'absent')) {
if (a.state === 'dispatching' || a.state === 'unknown') {
const hit = runForNonce(listDispatchRuns(a.workflow, execFor(ctx), slug, 100) ?? [], a.nonce);
if (!hit) return unknownDispatch(t, a);
Object.assign(a, { state: 'dispatched', run: asRun(hit) });
}
const waiting = collect(env, slug, t, b, a);
save(env, t, a);
persist(ctx.repoRoot, [b], { remote: env.remote });
if (waiting) return outPending({ code: 'QUEUE_TRAIN_PENDING', message: `bisect run ${a.run?.id} is ${waiting}` }, [attemptLine(a)], `${Q} train bisect --train ${t.nonce} --spend-cap ${o.spendCap}`, run);
}
const pending = b.prefixes.filter(p => b.cases.some(c => !p.results[c]));
if (!pending.length) return bisectOutcome(env, t, b);
const perPrefix = b.cases.reduce((sum, id) => sum + casePrice(env, red, id), 0);
const admitted: typeof pending = [];
let reserved = b.spent_usd;
for (const p of pending) if (reserved + perPrefix <= o.spendCap + 1e-9) { admitted.push(p); reserved += perPrefix; }
const higher = Math.ceil((b.spent_usd + perPrefix * pending.length) * 100) / 100;
const lines = [`BISECT: cases ${b.cases.join(',')} prefixes ${pending.map(p => 'T' + p.index).join(',')} need results; $${perPrefix.toFixed(2)} reserved per prefix; spent $${b.spent_usd.toFixed(2)}, cap $${o.spendCap.toFixed(2)}`,
`BISECT: launch ${admitted.map(p => 'T' + p.index).join(',') || '(none)'}${admitted.length < pending.length ? `; ${pending.length - admitted.length} prefix(es) wait for a higher cap` : ''}`];
if (o.dryRun) return outOk([...lines, 'DRY-RUN: nothing dispatched'], `${Q} train bisect --train ${t.nonce} --spend-cap ${o.spendCap}`, run);
persist(ctx.repoRoot, [b], { remote: env.remote });
if (!admitted.length) {
return outPending({ code: 'QUEUE_TRAIN_BISECT_CAPPED', message: `the next prefix needs $${perPrefix.toFixed(2)}; $${(o.spendCap - b.spent_usd).toFixed(2)} of the cap is left; the outcome stays unattributed` }, lines,
`${Q} train bisect --train ${t.nonce} --spend-cap ${higher.toFixed(2)}`, run);
}
const nonce = makeNonce('gate', t.head, t.base.sha, randomBytes(3).toString('hex'), { purpose: 'bisect' });
const a = await dispatchAttempt(env, slug, t, 'bisect', { workflow: env.train.gate_workflow, inputs: {
[DISPATCH_INPUTS.cases]: b.cases.join(','), [DISPATCH_INPUTS.prefixShas]: admitted.map(p => p.sha).join(','),
[DISPATCH_INPUTS.expectedSha]: t.head, [DISPATCH_INPUTS.nonce]: nonce,
} }, admitted.map(p => p.index));
for (const p of admitted) p.attempts.push(a.id);
b.updated_at = nowIso();
persist(ctx.repoRoot, [b], { remote: env.remote });
if (a.state !== 'dispatched') return unknownDispatch(t, a);
lines.push(attemptLine(a));
if (admitted.length < pending.length) {
return outPending({ code: 'QUEUE_TRAIN_BISECT_CAPPED', message: `${pending.length - admitted.length} prefix(es) are past the cap; completed prefixes are kept and the outcome stays unattributed` }, lines,
`${Q} train bisect --train ${t.nonce} --spend-cap ${higher.toFixed(2)}`, run);
}
return outOk(lines, `${Q} train bisect --train ${t.nonce} --spend-cap ${o.spendCap} (collects run ${a.run?.id} once it finishes)`, run);
}
/**
* `train rebuild`: the unchanged train dispatched once more. Without
* `--infra`, only after an all-green bisect and only once (rebuild-once);
* with `--infra`, only after an INFRA/INCOMPLETE-only census and at most
* EVAL_POLICY.infraRedispatch times. Both runs stay in the record.
*/
export async function trainRebuild(ctx: QueueContext, o: { train: string; infra: boolean }): Promise<TrainOut> {
const env = trainEnv(ctx);
if ('code' in env) return outRefused(env, [], 'none', 'none');
const t = findTrain(env, o.train);
if (!t) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `no train record ${o.train} here or in refs/notes/gstack-trains` }, [], `${Q} train status`, 'none');
const run = trainFile(t.nonce);
const gates = gateAttempts(t);
const last = gates.at(-1);
const repair = `route ${last?.red_cases.join(',') || 'the red cases'} to a repair lane (GStack Minder); the train is rebuilt only once the repair merges: ${Q} train build --prs ${pinList(t.slots)}`;
const refuse = (reason: string) => outRefused({ code: 'QUEUE_TRAIN_REBUILD_REFUSED', message: `rebuild refused: ${reason}` }, gates.map(attemptLine), repair, run);
if (o.infra) {
if (!last || last.outcome !== 'INCOMPLETE' || !last.infra_eligible) return refuse(`no INFRA/INCOMPLETE-only record (the last gate is ${last?.outcome ?? 'missing'})`);
if (gates.filter(a => a.purpose === 'infra-redispatch').length >= INFRA_REDISPATCH) return refuse('already re-dispatched once (EVAL_POLICY.infraRedispatch)');
} else {
const b = loadBisect(t.nonce);
if (!b?.outcome) return refuse('no bisect record');
if (gates.filter(a => a.purpose === 'rebuild').length >= REBUILD_ONCE) return refuse('already rebuilt once');
if (b.outcome.kind !== 'unattributed' || !b.outcome.all_green) return refuse(`the bisect was not all green (${Object.entries(b.outcome.patterns).map(([c, s]) => `${c}=${s}`).join(' ')})`);
}
const tip = remoteBranchSha(args => git(ctx.repoRoot, args), env.remote, t.branch);
if (tip !== t.head) return outRefused({ code: 'QUEUE_TRAIN_MOVED', message: `${t.branch} is at ${tip?.slice(0, 12) ?? 'nothing'}, not the recorded head ${t.head.slice(0, 12)}` }, [], `${Q} train build --prs ${pinList(t.slots)}`, run);
const slug = repoSlug(ctx);
if (!slug) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: 'gh repo view could not name the repository' }, [], `${Q} train rebuild --train ${t.nonce}${o.infra ? ' --infra' : ''}`, run);
const a = await dispatchAttempt(env, slug, t, o.infra ? 'infra-redispatch' : 'rebuild', gateDispatch(env, t, `${t.nonce}.${gates.length + 1}`));
t.state = a.state === 'dispatched' ? 'gate-pending' : 'dispatch-unknown';
save(env, t);
if (a.state !== 'dispatched') return unknownDispatch(t, a);
return outOk([...gateAttempts(t).map(attemptLine), `REBUILD: ${a.purpose} of the unchanged train ${t.nonce} (head ${t.head.slice(0, 12)}); both runs are reported`], nextFor(t), run);
}
+122
View File
@@ -0,0 +1,122 @@
/**
* `verify --pr <n> --train <nonce>`: the coordinator's merge check under
* merge trains (docs/ship-policy.md#run-a-merge-train; plan GSTA-49 T1, P1).
*
* Each merge needs a green train record that includes the PR at its pinned
* head, with main's tree equal to the prefix before the PR (pre-merge:
* MERGE_CONDITION) or after it (post-merge confirmation), modulo stamps
* (lib/tree-receipt.ts). When a train stops partway (a mismatch or a moved
* head after earlier slots merged) the queue freezes: the freeze is written
* locally and to the notes ref first, then the backstop gate is dispatched on
* main's published prefix at once. One repair train is admitted under the
* freeze; verify clears the freeze when main after that merge carries it.
*/
import { randomBytes } from 'node:crypto';
import { DISPATCH_INPUTS, awaitNonceRun, dispatchWorkflow, makeNonce } from '../scripts/eval-bg';
import type { QueueContext } from './ship-queue';
import { git } from './ship-queue-gate';
import { checkRun, execFor, repoSlug } from './ship-queue-train-github';
import { activeFreeze, attemptsOf, nowIso, persist, trainFile, type FreezeRecord, type TrainRecord } from './ship-queue-train-record';
import {
effect, fetchBase, fetchTrainBranch, findTrain, frozenLines, nextFor, outFailed, outOk, outRefused, pinList, save, trainEnv, type TrainEnv, type TrainOut,
} from './ship-queue-train';
import { buildReceipt } from './tree-receipt';
const Q = 'gstack-ship-queue';
function sameModuloStamps(env: TrainEnv, gated: string, current: string): { same: boolean; detail: string } {
const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated, current, policy: env.policy.policy, policyLabel: env.policy.source.label });
if ('error' in r) return { same: false, detail: r.error };
return { same: r.tree === 'same-modulo-stamps', detail: r.tree === 'same-modulo-stamps' ? `same modulo stamps (${r.files.length} stamp file(s))` : `differs: ${r.residual.slice(0, 8).join(', ')}` };
}
/**
* Freeze the queue: the record goes to local state and the notes ref before
* anything remote, then the backstop gate is dispatched on main's sha.
*/
async function freezeQueue(env: TrainEnv, t: TrainRecord, reason: string, mainSha: string): Promise<FreezeRecord> {
const now = nowIso();
const f: FreezeRecord = {
schema: 1, kind: 'freeze', repo_key: env.repo.key, train: t.nonce, merged: t.merged.map(m => m.pr),
unmerged: t.slots.filter(s => !t.merged.some(m => m.pr === s.pr)).map(s => ({ pr: s.pr, head: s.head })),
main: { sha: mainSha, tree: git(env.ctx.repoRoot, ['rev-parse', `${mainSha}^{tree}`]).out }, reason, backstop: null, repair: null,
created_at: now, cleared_at: null, cleared_by: null, updated_at: now,
};
persist(env.ctx.repoRoot, [f], { remote: env.remote });
effect(t, `queue frozen: ${reason}`);
save(env, t);
const slug = repoSlug(env.ctx);
const nonce = makeNonce('gate', mainSha, mainSha, randomBytes(3).toString('hex'), { purpose: 'backstop' });
const dispatch = { workflow: env.train.gate_workflow, inputs: { [DISPATCH_INPUTS.evalsAll]: 'true', [DISPATCH_INPUTS.expectedSha]: mainSha, [DISPATCH_INPUTS.nonce]: nonce } };
const exec = execFor(env.ctx);
const found = slug && dispatchWorkflow(dispatch, env.ctx.base, exec, slug) ? await awaitNonceRun(dispatch.workflow, nonce, exec, slug) : null;
f.backstop = { nonce, run: found?.databaseId ?? null, state: found ? 'dispatched' : 'unconfirmed' };
f.updated_at = nowIso();
persist(env.ctx.repoRoot, [f], { remote: env.remote });
return f;
}
/** Interrupted publication: freeze when earlier slots merged; otherwise a plain refusal (nothing was published). */
async function stopped(env: TrainEnv, t: TrainRecord, code: 'QUEUE_TRAIN_TREE_MISMATCH' | 'QUEUE_TRAIN_MOVED', message: string, mainSha: string, lines: string[]): Promise<TrainOut> {
const run = trainFile(t.nonce);
if (!t.merged.length) return outFailed({ code, message: `${message}; nothing of train ${t.nonce} merged, so the queue is not frozen` }, lines, `${Q} train build --prs ${pinList(t.slots)} (a new train on current main)`, run);
const f = activeFreeze(env.repo.key) ?? await freezeQueue(env, t, message, mainSha);
return outFailed({ code, message: `${message}; the queue is frozen` }, [...lines, ...frozenLines(f)], `${Q} train build --repair --prs <repair-pr>@<sha>`, run);
}
export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: string; noFetch: boolean }): Promise<TrainOut> {
const fetchErr = o.noFetch ? null : fetchBase(ctx);
const env = trainEnv(ctx);
if ('code' in env) return outRefused(env, [], 'none', 'none');
if (fetchErr) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: fetchErr }, [], 'none', 'none');
const t = findTrain(env, o.train);
if (!t) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `no train record ${o.train} here or in refs/notes/gstack-trains` }, [], `${Q} train status`, 'none');
const run = trainFile(t.nonce);
const i = t.slots.findIndex(s => s.pr === o.pr);
if (i < 0) return outRefused({ code: 'QUEUE_GATE_MISSING', message: `train ${t.nonce} does not include #${o.pr}` }, [], nextFor(t), run);
const gate = attemptsOf(t).filter(a => a.purpose !== 'bisect').at(-1);
if (gate?.outcome !== 'GREEN' || !['green', 'merging', 'merged'].includes(t.state)) {
return outRefused({ code: 'QUEUE_GATE_MISSING', message: `train ${t.nonce} has no green record (state ${t.state}, last gate ${gate?.outcome ?? 'none'})` }, [], nextFor(t), run);
}
const check = checkRun(ctx, t.head, env.train.full_gate_check);
if (check?.conclusion !== 'success') return outRefused({ code: 'QUEUE_GATE_MISSING', message: `no successful ${env.train.full_gate_check} check run on the train head ${t.head.slice(0, 12)}` }, [], `${Q} train verdict --train ${t.nonce}`, run);
if (!fetchTrainBranch(env, t)) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `the train's prefix commits are not here and ${t.branch} could not be fetched` }, [], `${Q} train status --train ${t.nonce}`, run);
const main = git(ctx.repoRoot, ['rev-parse', `${env.remote}/${ctx.base}`]).out;
const freeze = activeFreeze(env.repo.key);
if (freeze && freeze.repair?.train !== t.nonce) return outRefused({ code: 'QUEUE_TRAIN_FROZEN', message: `the queue is frozen (${freeze.reason}); only the admitted repair train merges` }, frozenLines(freeze), `${Q} train build --repair --prs <repair-pr>@<sha>`, run);
const after = t.prefixes[i + 1]!;
const before = t.prefixes[i]!;
const post = sameModuloStamps(env, after.commit, main);
const lines = [`TRAIN: ${t.nonce} slot ${i + 1}/${t.slots.length} #${o.pr} pinned ${t.slots[i]!.head}`];
if (post.same) return confirmMerged(env, t, o.pr, main, after.index, [...lines, `main: ${main} = T${after.index} (${post.detail})`]);
const pre = sameModuloStamps(env, before.commit, main);
if (!pre.same) {
return stopped(env, t, 'QUEUE_TRAIN_TREE_MISMATCH', `main ${main.slice(0, 12)} is neither T${before.index} nor T${after.index} modulo stamps (vs T${after.index}: ${post.detail})`, main, lines);
}
const headRef = o.noFetch ? '' : (git(ctx.repoRoot, ['fetch', '--no-tags', '-q', env.remote, `+refs/pull/${o.pr}/head:refs/gstack/queue/pr-${o.pr}`]).ok ? `refs/gstack/queue/pr-${o.pr}` : '');
const head = headRef ? git(ctx.repoRoot, ['rev-parse', headRef]).out : t.slots[i]!.head;
const stampOnly = head === t.slots[i]!.head ? { same: true, detail: 'the pinned head' } : sameModuloStamps(env, t.slots[i]!.head, head);
if (!stampOnly.same) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr} moved past its pinned head ${t.slots[i]!.head.slice(0, 12)} (${head.slice(0, 12)}: ${stampOnly.detail})`, main, lines);
return outOk([...lines, `main: ${main} = T${before.index} (${pre.detail})`, `head: ${head} (${stampOnly.detail})`,
`VERIFY_TRAIN: #${o.pr} may merge as slot ${i + 1} of train ${t.nonce} (green run ${gate.run?.id ?? '-'}, ${env.train.full_gate_check} success)`, `MERGE_CONDITION: sha=${head}`],
`merge #${o.pr} with expected head ${head.slice(0, 12)} (gstack-restamp stamps at merge), then ${Q} verify --pr ${o.pr} --train ${t.nonce}`, run);
}
function confirmMerged(env: TrainEnv, t: TrainRecord, pr: string, main: string, index: number, lines: string[]): TrainOut {
const run = trainFile(t.nonce);
if (!t.merged.some(m => m.pr === pr)) t.merged.push({ pr, sha: main, tree: git(env.ctx.repoRoot, ['rev-parse', `${main}^{tree}`]).out, at: nowIso() });
t.state = t.merged.length === t.slots.length ? 'merged' : 'merging';
effect(t, `verified #${pr} merged as T${index}`);
save(env, t);
lines.push(`VERIFY_TRAIN: #${pr} merged; main's tree is T${index} of train ${t.nonce} (${t.merged.length}/${t.slots.length} merged)`);
const freeze = activeFreeze(env.repo.key);
if (freeze && freeze.repair?.train === t.nonce && t.state === 'merged') {
freeze.cleared_at = nowIso();
freeze.cleared_by = `repair #${pr} via train ${t.nonce}; main ${main.slice(0, 12)} carries a green record`;
freeze.updated_at = freeze.cleared_at;
persist(env.ctx.repoRoot, [freeze], { remote: env.remote });
lines.push(`FREEZE: cleared (${freeze.cleared_by})`);
return outOk(lines, freeze.unmerged.length ? `${Q} train build --prs ${pinList(freeze.unmerged)}` : 'none', run);
}
return outOk(lines, nextFor(t), run);
}
+509
View File
@@ -0,0 +1,509 @@
/**
* Merge trains for gstack-ship-queue (docs/ship-policy.md#run-a-merge-train;
* plan GSTA-49 lane T1): `train build`, `train status` and `train prune`,
* plus the pieces the verdict, bisect, rebuild and verify modules share.
* The surface is active only when the base revision's ship policy declares a
* `train` block (QUEUE_TRAIN_OFF otherwise).
*
* A train is main plus each READY PR's pinned, unstamped head merged in slot
* order. The merges run in object storage (`git merge-tree --write-tree`, the
* gate-ahead merge rules: version files take the earlier prefix, the
* CHANGELOG merges by section), so each prefix T0..Tk is a commit with a
* fixed identity and date and the same pins always give the same train head.
* Records (lib/ship-queue-train-record.ts) are written before every remote
* effect: the branch push, the free-tests dispatch, the paid dispatch through
* scripts/eval-bg.ts's nonce.
*/
import { randomBytes } from 'node:crypto';
import { DISPATCH_INPUTS, awaitNonceRun, dispatchWorkflow, listDispatchRuns, makeNonce, runForNonce, type WorkflowDispatch } from '../scripts/eval-bg';
import { mergeChangelogs, parseChangelog, type ChangelogSection } from './changelog-check';
import type { ResultStatus } from './headless-artifacts';
import { versionAtRef, versionFiles } from './restamp';
import type { LoadedPolicy, ShipPolicy, TrainPolicy } from './ship-policy';
import { loadBasePolicy, type QueueContext } from './ship-queue';
import { git, sha256, withIndex, type QueueFailure } from './ship-queue-gate';
import { asRun, execFor, newestBranchRun, prInfo, remoteBranchSha, repoSlug, runView, checkRun } from './ship-queue-train-github';
import {
TERMINAL_STATES, activeFreeze, attemptsOf, listTrains, loadBisect, loadReady, loadTrain, nowIso, persist, rebuildFromNotes, repoIdentity, trainFile,
type AttemptPurpose, type AttemptRecord, type TrainRecord, type TrainSlot,
} from './ship-queue-train-record';
export interface TrainOut { exit: number; status: ResultStatus; lines: string[]; next: string; failure?: QueueFailure; run: string }
export const outOk = (lines: string[], next: string, run: string): TrainOut => ({ exit: 0, status: 'complete', lines, next, run });
export const outRefused = (failure: QueueFailure, lines: string[], next: string, run: string): TrainOut => ({ exit: 3, status: 'refused', lines, next, failure, run });
export const outFailed = (failure: QueueFailure, lines: string[], next: string, run: string): TrainOut => ({ exit: 1, status: 'refused', lines, next, failure, run });
export const outPending = (failure: QueueFailure, lines: string[], next: string, run: string): TrainOut => ({ exit: 3, status: 'incomplete', lines, next, failure, run });
export interface TrainEnv { ctx: QueueContext; policy: LoadedPolicy; train: TrainPolicy; repo: { key: string; url: string }; remote: string }
const HEX = /^[0-9a-f]{40,64}$/;
const Q = 'gstack-ship-queue';
/** The policy gate: the base revision's ship policy must declare a `train` block. */
export function trainEnv(ctx: QueueContext): TrainEnv | QueueFailure {
const policy = loadBasePolicy(ctx);
if ('message' in policy) return policy;
if (!policy.policy.train) return { code: 'QUEUE_TRAIN_OFF', message: `${policy.source.label} declares no train block; the per-PR queue applies` };
return { ctx, policy, train: policy.policy.train, repo: repoIdentity(ctx.repoRoot), remote: ctx.remote ?? 'origin' };
}
export function fetchBase(ctx: QueueContext): string | null {
const remote = ctx.remote ?? 'origin';
const f = git(ctx.repoRoot, ['fetch', '--no-tags', '-q', remote, `+refs/heads/${ctx.base}:refs/remotes/${remote}/${ctx.base}`]);
return f.ok ? null : `git fetch ${remote} ${ctx.base} failed: ${f.err.slice(0, 160)}`;
}
/** Make the train's prefix commits local (another machine built them): fetch train/<nonce>. */
export function fetchTrainBranch(env: TrainEnv, train: TrainRecord): string | null {
if (git(env.ctx.repoRoot, ['cat-file', '-e', `${train.head}^{commit}`]).ok) return train.head;
const f = git(env.ctx.repoRoot, ['fetch', '--no-tags', '-q', env.remote, `+refs/heads/${train.branch}:refs/gstack/${train.branch}`]);
return f.ok ? git(env.ctx.repoRoot, ['rev-parse', `refs/gstack/${train.branch}`]).out : null;
}
/** A train record from local state, or from the notes ref when this machine has none. */
export function findTrain(env: TrainEnv, nonce: string): TrainRecord | null {
const local = loadTrain(nonce);
if (local) return local;
rebuildFromNotes(env.ctx.repoRoot, env.remote);
return loadTrain(nonce);
}
export const touch = <T extends { updated_at: string }>(r: T): T => { r.updated_at = nowIso(); return r; };
export function effect(train: TrainRecord, what: string): void {
train.effects.push({ at: nowIso(), what });
}
/** Save the train (and any attempt) locally and to the notes ref; a failed notes push is recorded as an effect, never hidden. */
export function save(env: TrainEnv, train: TrainRecord, ...extra: AttemptRecord[]): string {
touch(train);
for (const a of extra) touch(a);
const r = persist(env.ctx.repoRoot, [train, ...extra], { remote: env.remote });
if (!r.pushed) { effect(train, `notes not pushed: ${r.detail}`); persist(env.ctx.repoRoot, [train], { push: false }); }
return r.detail;
}
export function parsePins(spec: string | undefined): Array<{ pr: string; sha: string }> | string {
if (!spec) return 'needs --prs <n>@<sha>[,<n>@<sha>...]';
const pins = spec.split(',').map(s => s.trim()).filter(Boolean).map(s => /^#?(\d+)@([0-9a-f]{7,64})$/.exec(s));
if (!pins.length || pins.some(m => !m)) return `--prs entries are <pr>@<sha> (got ${spec})`;
const out = pins.map(m => ({ pr: m![1]!, sha: m![2]! }));
return new Set(out.map(p => p.pr)).size === out.length ? out : 'a PR appears twice in --prs';
}
export const pinList = (slots: Array<{ pr: string; head: string }>) => slots.map(s => `${s.pr}@${s.head}`).join(',');
/** Slot order: screened PRs first, then unscreened, each in READY order; enough unscreened PRs form their own train. */
export function orderSlots(slots: TrainSlot[], max: number): { train: TrainSlot[]; held: TrainSlot[]; split: boolean } {
const byReady = (a: TrainSlot, b: TrainSlot) => a.ready_at.localeCompare(b.ready_at) || Number(a.pr) - Number(b.pr);
const screened = slots.filter(s => s.screen.choice === 'screen').sort(byReady);
const unscreened = slots.filter(s => s.screen.choice !== 'screen').sort(byReady);
if (screened.length && unscreened.length >= max) return { train: screened.slice(0, max), held: [...screened.slice(max), ...unscreened], split: true };
const all = [...screened, ...unscreened];
return { train: all.slice(0, max), held: all.slice(max), split: false };
}
/** When a train under its size may dispatch: its oldest READY plus the fill time; null when it may dispatch now. */
export function fillDue(slots: TrainSlot[], train: TrainPolicy, now: string, urgent: boolean): string | null {
if (urgent || slots.length >= train.max_size) return null;
const oldest = Math.min(...slots.map(s => Date.parse(s.ready_at)));
const due = oldest + train.fill_hours * 3_600_000;
return Date.parse(now) >= due ? null : new Date(due).toISOString();
}
function unionChangelog(ours: string, theirs: string): string {
const a = parseChangelog(ours);
const b = parseChangelog(theirs);
const entry = (s: ChangelogSection) => s.lines.slice(1).join('\n').replace(/\n+$/, '');
const known = new Set(b.sections.map(entry));
const header = [...b.header];
while (header.length && header[header.length - 1] === '') header.pop();
const body = [...a.sections.filter(s => !known.has(entry(s))), ...b.sections].flatMap(s => {
const lines = [...s.lines];
while (lines.length && lines[lines.length - 1] === '') lines.pop();
return [...lines, ''];
});
return [...header, ...(header.length ? [''] : []), ...body].join('\n').replace(/\n+$/, '\n');
}
/**
* One train merge, in object storage: the PR head onto the previous prefix.
* Version files take the prefix's side; the CHANGELOG merges by section (the
* gate-ahead rule, else a union of sections, since each heading is restamped
* at merge). Any other conflict is reported with every conflicting path.
*/
export function mergeStep(repoRoot: string, pred: string, head: string, policy: ShipPolicy): { tree: string } | { conflicts: string[] } {
const r = git(repoRoot, ['merge-tree', '--write-tree', head, pred]);
const lines = r.out.split('\n');
const tree = lines[0]?.trim() ?? '';
if (r.status === 0 && HEX.test(tree)) return { tree };
if (r.status !== 1 || !HEX.test(tree)) return { conflicts: [`(git merge-tree failed: ${(r.err || r.out).slice(0, 120)})`] };
const stages = new Map<string, Map<number, string>>();
for (const line of lines.slice(1)) {
if (!line.trim()) break;
const m = /^(\d{6}) ([0-9a-f]{40,64}) ([123])\t(.+)$/.exec(line);
if (!m) continue;
const entry = stages.get(m[4]!) ?? new Map<number, string>();
entry.set(Number(m[3]), m[2]!);
stages.set(m[4]!, entry);
}
const at = versionAtRef(repoRoot, pred);
const stampFiles = versionFiles(repoRoot, 'rel' in at ? at.rel : 'VERSION');
const resolved: Array<{ path: string; blob: string }> = [];
const conflicts: string[] = [];
for (const [file, entry] of stages) {
const ours = entry.get(2);
const theirs = entry.get(3);
if (!ours || !theirs) conflicts.push(file);
else if (file === policy.changelog) {
const text = (sha: string) => git(repoRoot, ['cat-file', 'blob', sha]).out + '\n';
const merged = mergeChangelogs(text(ours), text(theirs)) ?? unionChangelog(text(ours), text(theirs));
const blob = git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, merged);
if (blob.ok) resolved.push({ path: file, blob: blob.out }); else conflicts.push(file);
} else if (stampFiles.includes(file)) resolved.push({ path: file, blob: theirs });
else conflicts.push(file);
}
if (conflicts.length) return { conflicts: conflicts.sort() };
const out = withIndex<{ tree: string } | { error: string }>(repoRoot, tree, index => {
for (const { path: p, blob } of resolved) {
const mode = git(repoRoot, ['ls-tree', tree, '--', p], index).out.split(/\s/)[0] || '100644';
if (!git(repoRoot, ['update-index', '--add', '--cacheinfo', `${mode},${blob},${p}`], index).ok) return { error: `cannot stage ${p}` };
}
const w = git(repoRoot, ['write-tree'], index);
return w.ok ? { tree: w.out } : { error: `write-tree failed: ${w.err}` };
});
return 'error' in out ? { conflicts: [`(${out.error})`] } : { tree: out.tree };
}
const TRAIN_IDENTITY = { GIT_AUTHOR_NAME: 'gstack-ship-queue', GIT_AUTHOR_EMAIL: 'ship-queue@gstack.local', GIT_COMMITTER_NAME: 'gstack-ship-queue',
GIT_COMMITTER_EMAIL: 'ship-queue@gstack.local', GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z' };
/** Build T1..Tk on T0 = base; a conflict stops at the PR that conflicts. */
export function buildChain(repoRoot: string, baseSha: string, slots: TrainSlot[], policy: ShipPolicy):
{ prefixes: TrainRecord['prefixes'] } | { stoppedAt: string; conflicts: string[]; merged: string[] } {
const prefixes: TrainRecord['prefixes'] = [{ index: 0, commit: baseSha, tree: git(repoRoot, ['rev-parse', `${baseSha}^{tree}`]).out, pr: null }];
for (const [i, slot] of slots.entries()) {
const pred = prefixes[i]!.commit;
const m = mergeStep(repoRoot, pred, slot.head, policy);
if ('conflicts' in m) return { stoppedAt: slot.pr, conflicts: m.conflicts, merged: slots.slice(0, i).map(s => s.pr) };
const c = git(repoRoot, ['commit-tree', m.tree, '-p', pred, '-p', slot.head, '-m', `train T${i + 1}: merge #${slot.pr} at ${slot.head.slice(0, 12)}`], { ...process.env, ...TRAIN_IDENTITY });
if (!c.ok) return { stoppedAt: slot.pr, conflicts: [`(commit-tree failed: ${c.err.slice(0, 120)})`], merged: slots.slice(0, i).map(s => s.pr) };
prefixes.push({ index: i + 1, commit: c.out, tree: m.tree, pr: slot.pr });
}
return { prefixes };
}
/** NEXT for a train in its current state. */
export function nextFor(t: TrainRecord): string {
const n = t.nonce;
const unmerged = t.slots.filter(s => !t.merged.some(m => m.pr === s.pr));
switch (t.state) {
case 'built': case 'pushed': case 'free-pending': case 'free-green': case 'dispatch-absent': return `${Q} train build --train ${n}`;
case 'dispatch-unknown': return `${Q} train status --train ${n}`;
case 'gate-pending': return `${Q} train verdict --train ${n}`;
case 'green': case 'merging': return unmerged.length ? `${Q} verify --pr ${unmerged[0]!.pr} --train ${n}` : `${Q} train prune --train ${n}`;
case 'red': return `${Q} train bisect --train ${n} --spend-cap <usd>`;
case 'incomplete': return `${Q} train verdict --train ${n}`;
case 'attributed': {
const drop = loadBisect(n)?.outcome?.prs ?? [];
const keep = t.slots.filter(s => !drop.includes(s.pr));
return keep.length ? `${Q} train build --prs ${pinList(keep)}` : 'none (every PR was dropped)';
}
case 'red-on-main': return `fix forward on main first, then ${Q} train build --prs ${pinList(t.slots)}`;
case 'unattributed': return loadBisect(n)?.outcome?.all_green && !attemptsOf(t).some(a => a.purpose === 'rebuild')
? `${Q} train rebuild --train ${n}`
: `route the red cases to a repair lane (GStack Minder); once the repair merges: ${Q} train build --prs ${pinList(t.slots)}`;
default: return `${Q} train prune --train ${n}`;
}
}
export function attemptId(train: TrainRecord, purpose: AttemptPurpose): string {
const bisect = purpose === 'bisect';
return `${bisect ? 'b' : 'a'}${train.attempts.filter(id => id.startsWith(bisect ? 'b' : 'a')).length + 1}`;
}
/**
* Record an attempt as dispatching (locally and in the notes ref), dispatch
* through eval-bg, then find the run by its nonce. A failed or unconfirmed
* dispatch is `unknown`: train status reconciles it, nothing re-dispatches it.
*/
export async function dispatchAttempt(env: TrainEnv, slug: string, train: TrainRecord, purpose: AttemptPurpose, dispatch: WorkflowDispatch, prefixes: number[] = []): Promise<AttemptRecord> {
const now = nowIso();
const attempt: AttemptRecord = {
schema: 1, kind: 'train-attempt', train: train.nonce, id: attemptId(train, purpose), purpose, nonce: dispatch.inputs[DISPATCH_INPUTS.nonce]!,
workflow: dispatch.workflow, ref: train.branch, inputs: dispatch.inputs, state: 'dispatching', run: null, outcome: null, detail: null,
red_cases: [], panels: [], infra_eligible: false, cost_usd: null, prefixes, created_at: now, updated_at: now,
};
train.attempts.push(attempt.id);
save(env, train, attempt);
const exec = execFor(env.ctx);
const found = dispatchWorkflow(dispatch, train.branch, exec, slug) ? await awaitNonceRun(dispatch.workflow, attempt.nonce, exec, slug) : null;
attempt.state = found ? 'dispatched' : 'unknown';
attempt.run = found ? asRun(found) : null;
effect(train, found ? `${purpose} dispatched: ${dispatch.workflow} run ${found.databaseId} (nonce ${attempt.nonce})` : `${purpose} dispatch unconfirmed (nonce ${attempt.nonce})`);
save(env, train, attempt);
return attempt;
}
export function gateDispatch(env: TrainEnv, train: TrainRecord, nonce: string): WorkflowDispatch {
return { workflow: env.train.gate_workflow, inputs: { [DISPATCH_INPUTS.evalsAll]: 'true', [DISPATCH_INPUTS.expectedSha]: train.head, [DISPATCH_INPUTS.nonce]: nonce } };
}
export interface BuildOptions { pins?: string; resume?: string; dryRun: boolean; urgent: boolean; repair: boolean; freeTimeoutSec: number; pollMs: number }
/** The slots a new train would carry: trusted, unmoved, READY PRs, in slot order. */
function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>): { slots: TrainSlot[]; lines: string[] } | TrainOut {
const { ctx } = env;
const specs = pins.map(p => `+refs/pull/${p.pr}/head:refs/gstack/train/pr-${p.pr}`);
const f = git(ctx.repoRoot, ['fetch', '--no-tags', '-q', env.remote, ...specs]);
if (!f.ok) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `git fetch of the PR heads failed: ${f.err.slice(0, 160)}` }, [], 'none', 'none');
const slots: TrainSlot[] = [];
for (const p of pins) {
const info = prInfo(ctx, p.pr);
if ('error' in info) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: info.error }, [], 'none', 'none');
const without = pinList(pins.filter(x => x.pr !== p.pr).map(x => ({ pr: x.pr, head: x.sha })));
if (info.isCrossRepository) return outRefused({ code: 'QUEUE_TRAIN_UNTRUSTED', message: `PR #${p.pr}'s head repository is not the base repository; fork work stays on the trusted-branch path` }, [], without ? `${Q} train build --prs ${without}` : 'none', 'none');
const fetched = git(ctx.repoRoot, ['rev-parse', `refs/gstack/train/pr-${p.pr}`]).out;
if (!info.headRefOid.startsWith(p.sha) || fetched !== info.headRefOid) {
return outRefused({ code: 'QUEUE_TRAIN_MOVED', message: `PR #${p.pr} moved: pinned ${p.sha.slice(0, 12)}, head is now ${info.headRefOid.slice(0, 12)}` }, [], `ask #${p.pr}'s lane for READY on ${info.headRefOid.slice(0, 12)}, then ${Q} train build --prs <pins>`, 'none');
}
const ready = loadReady(env.repo.key, p.pr, info.headRefOid);
if (!ready) return outRefused({ code: 'QUEUE_NOT_READY', message: `no READY receipt for #${p.pr} at ${info.headRefOid.slice(0, 12)} (local state or ${'refs/notes/gstack-trains'})` }, [], `the lane runs ${Q} ready --pr ${p.pr} --screen|--no-screen`, 'none');
slots.push({ pr: p.pr, head: info.headRefOid, ready_at: ready.ready_at, screen: ready.screen });
}
return { slots, lines: [] };
}
/** Start a new train from --prs, or find the live train with the same pins and base. */
async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecord; lines: string[] } | TrainOut> {
const pins = parsePins(o.pins);
if (typeof pins === 'string') return outRefused({ code: 'QUEUE_PROOF_MISSING', message: pins }, [], 'none', 'none');
if ((o.urgent || o.repair) && pins.length !== 1) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `${o.repair ? '--repair' : '--urgent'} admits exactly one PR` }, [], 'none', 'none');
rebuildFromNotes(env.ctx.repoRoot, env.remote);
const freeze = activeFreeze(env.repo.key);
if (freeze && !o.repair) return outRefused({ code: 'QUEUE_TRAIN_FROZEN', message: `the queue is frozen since ${freeze.created_at}: train ${freeze.train} stopped after merging ${freeze.merged.map(p => '#' + p).join(',')} (${freeze.reason})` },
frozenLines(freeze), `${Q} train build --repair --prs <repair-pr>@<sha>`, 'none');
const admitted = admitSlots(env, pins);
if ('exit' in admitted) return admitted;
const order = orderSlots(admitted.slots, o.repair ? 1 : env.train.max_size);
const lines = [
`ORDER: ${order.train.map((s, i) => `${i + 1}:#${s.pr}(${s.screen.choice === 'screen' ? 'screened' : 'unscreened'})`).join(' ')}`,
...(order.held.length ? [`HELD: ${order.held.map(s => '#' + s.pr).join(',')} (${order.split ? 'unscreened PRs form their own train' : `over the train size ${env.train.max_size}`})`] : []),
];
const due = fillDue(order.train, env.train, nowIso(), o.urgent || o.repair);
if (due && !o.dryRun) return outPending({ code: 'QUEUE_TRAIN_WAITING', message: `${order.train.length} of ${env.train.max_size} PRs; the oldest READY dispatches at ${due}` }, lines, `${Q} train build --prs ${pinList(order.train)} (after ${due}, or with more PRs, or --urgent for one)`, 'none');
const baseSha = git(env.ctx.repoRoot, ['rev-parse', `${env.remote}/${env.ctx.base}`]).out;
const key = sha256(JSON.stringify({ base: baseSha, slots: order.train.map(s => `${s.pr}@${s.head}`) }));
const live = listTrains(env.repo.key).find(t => t.key === key && !TERMINAL_STATES.includes(t.state) && t.state !== 'red' && t.state !== 'unattributed');
if (live) return { t: Object.assign(live, { held: order.held.map(s => s.pr) }), lines };
const chain = buildChain(env.ctx.repoRoot, baseSha, order.train, env.policy.policy);
if ('stoppedAt' in chain) {
const without = order.train.filter(s => s.pr !== chain.stoppedAt);
return outFailed({ code: 'QUEUE_TRAIN_CONFLICT', message: `#${chain.stoppedAt} conflicts with the prefix before it` }, [...lines,
`QUEUE_TRAIN_CONFLICT: train ${key.slice(0, 12)} stopped at #${chain.stoppedAt} (conflicts: ${chain.conflicts.join(', ')}) after merging ${chain.merged.map(p => '#' + p).join(',') || '(none)'}; nothing dispatched, no branch pushed; fix: ${Q} train build --prs ${pinList(without)}`],
without.length ? `${Q} train build --prs ${pinList(without)}` : 'none', 'none');
}
const head = chain.prefixes.at(-1)!;
const nonce = makeNonce('gate', head.commit, baseSha, randomBytes(3).toString('hex'), { purpose: 'train' });
const now = nowIso();
return { lines, t: {
schema: 1, kind: 'train', nonce, key, repo_key: env.repo.key, repo: env.repo.url, base: { ref: env.ctx.base, sha: baseSha }, policy: env.policy.source.label,
slots: order.train, held: order.held.map(s => s.pr), prefixes: chain.prefixes, head: head.commit, tree: head.tree, branch: `train/${nonce}`,
urgent: o.urgent, repair: o.repair, state: 'built', free: null, attempts: [], merged: [], superseded_by: null, effects: [], error: null,
created_at: now, updated_at: now,
} };
}
export function frozenLines(f: NonNullable<ReturnType<typeof activeFreeze>>): string[] {
return [
`QUEUE_TRAIN_FROZEN: train ${f.train} merged ${f.merged.map(p => '#' + p).join(',') || '(none)'}; main ${f.main.sha.slice(0, 12)} tree ${f.main.tree.slice(0, 12)}; backstop ${f.backstop ? `${f.backstop.state} run ${f.backstop.run ?? 'unknown'} (nonce ${f.backstop.nonce})` : 'not dispatched'}`,
`freeze: ${f.reason}; unmerged ${f.unmerged.map(u => '#' + u.pr).join(',') || '(none)'}; repair ${f.repair ? `#${f.repair.pr}@${f.repair.head.slice(0, 12)}` : 'none admitted'}`,
];
}
function describe(t: TrainRecord): string[] {
return [
`TRAIN: ${t.nonce} state=${t.state} prs=${t.slots.map(s => '#' + s.pr).join(',')} head=${t.head} tree=${t.tree}`,
`base: origin/${t.base.ref}@${t.base.sha}`,
...t.prefixes.map(p => `PREFIX: T${p.index} ${p.commit} tree=${p.tree}${p.pr ? ` (+#${p.pr})` : ' (main)'}`),
...t.slots.map((s, i) => `SLOT: ${i + 1} #${s.pr} head=${s.head} ${s.screen.choice === 'screen' ? `screened (${s.screen.policy ?? 'narrow'}${s.screen.run ? `, run ${s.screen.run}` : ''})` : 'unscreened'} ready_at=${s.ready_at}`),
];
}
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
/** Free tests on train/<nonce>: dispatched once, then followed until a verdict on the exact train head. */
async function freePhase(env: TrainEnv, slug: string, t: TrainRecord, o: BuildOptions): Promise<TrainOut | null> {
if (t.free?.verdict === 'green' && t.free.head_sha === t.head) return null;
const run = trainFile(t.nonce);
if (t.state !== 'free-pending') {
if (!env.ctx.gh(['workflow', 'run', env.train.free_workflow, '--repo', slug, '--ref', t.branch]).ok) {
t.free = { run: null, url: null, head_sha: null, conclusion: null, verdict: 'missing' };
t.state = 'free-red';
effect(t, `${env.train.free_workflow} dispatch refused by gh`);
save(env, t);
return outFailed({ code: 'QUEUE_TRAIN_FREE_RED', message: `gh could not dispatch ${env.train.free_workflow} on ${t.branch}` }, describe(t), `${Q} train build --prs ${pinList(t.slots)}`, run);
}
t.state = 'free-pending';
effect(t, `${env.train.free_workflow} dispatched on ${t.branch}`);
save(env, t);
}
const deadline = Date.now() + o.freeTimeoutSec * 1000;
let found = newestBranchRun(env.ctx, slug, env.train.free_workflow, t.branch);
while ((found === null || found === 'unreadable' || found.status !== 'completed') && Date.now() < deadline) {
await sleep(o.pollMs);
found = newestBranchRun(env.ctx, slug, env.train.free_workflow, t.branch);
}
if (found && found !== 'unreadable' && found.status !== 'completed') {
t.free = { run: found.databaseId, url: found.url, head_sha: found.headSha, conclusion: null, verdict: 'pending' };
save(env, t);
return outPending({ code: 'QUEUE_TRAIN_PENDING', message: `free tests run ${found.databaseId} on ${t.branch} is still ${found.status}` }, describe(t), `${Q} train build --train ${t.nonce}`, run);
}
const r = found && found !== 'unreadable' ? found : null;
const verdict = !r ? 'missing' : r.event !== 'workflow_dispatch' || r.headSha !== t.head ? 'stale' : r.conclusion === 'success' ? 'green' : r.conclusion === 'cancelled' ? 'cancelled' : 'red';
t.free = { run: r?.databaseId ?? null, url: r?.url ?? null, head_sha: r?.headSha ?? null, conclusion: r?.conclusion ?? null, verdict };
t.state = verdict === 'green' ? 'free-green' : 'free-red';
save(env, t);
if (verdict === 'green') return null;
const why = verdict === 'missing' ? `no ${env.train.free_workflow} dispatch run appeared on ${t.branch}`
: verdict === 'stale' ? `run ${r!.databaseId} tested ${(r!.headSha || '?').slice(0, 12)} (${r!.event}), not the train head ${t.head.slice(0, 12)}`
: `run ${r!.databaseId} concluded ${r!.conclusion}`;
return outFailed({ code: 'QUEUE_TRAIN_FREE_RED', message: `free tests ${verdict}: ${why}` }, [...describe(t), `FREE: ${verdict} ${r?.url ?? ''}`.trim()],
`read the free-tests run; drop a PR that caused it: ${Q} train build --prs <pins without it>`, run);
}
/** Push the branch, gate it with the free tests, then dispatch the paid gate once. */
async function advance(env: TrainEnv, t: TrainRecord, o: BuildOptions, lines: 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);
if (t.state === 'built') {
const p = git(env.ctx.repoRoot, ['push', '-q', env.remote, `${t.head}:refs/heads/${t.branch}`]);
if (!p.ok) { t.error = `branch push failed: ${p.err.slice(0, 160)}`; save(env, t); return outRefused({ code: 'QUEUE_PROOF_MISSING', message: t.error }, lines, `${Q} train build --train ${t.nonce}`, run); }
t.state = 'pushed';
effect(t, `branch pushed: ${t.branch} at ${t.head.slice(0, 12)}`);
save(env, t);
}
const free = await freePhase(env, slug, t, o);
if (free) return { ...free, lines: [...lines, ...free.lines] };
if (t.state === 'dispatch-unknown') return outPending({ code: 'QUEUE_TRAIN_DISPATCH_UNKNOWN', message: `the gate dispatch for ${t.nonce} is unconfirmed` }, [...lines, ...describe(t)], `${Q} train status --train ${t.nonce}`, run);
if (t.state === 'free-green' || t.state === 'dispatch-absent') {
const prior = attemptsOf(t).filter(a => a.purpose === 'gate');
const nonce = prior.at(-1)?.state === 'absent' ? prior.at(-1)!.nonce : t.nonce;
const a = await dispatchAttempt(env, slug, t, 'gate', gateDispatch(env, t, nonce));
t.state = a.state === 'dispatched' ? 'gate-pending' : 'dispatch-unknown';
save(env, t);
if (a.state !== 'dispatched') return outPending({ code: 'QUEUE_TRAIN_DISPATCH_UNKNOWN', message: `no ${a.workflow} run named ${a.nonce} appeared after the dispatch` }, [...lines, ...describe(t)], `${Q} train status --train ${t.nonce}`, run);
}
const gate = attemptsOf(t).filter(a => a.purpose !== 'bisect').at(-1);
return outOk([...lines, ...describe(t), `FREE: green run ${t.free?.run ?? '?'} on ${t.head.slice(0, 12)}`,
`GATE: ${gate?.workflow ?? env.train.gate_workflow} run ${gate?.run?.id ?? '?'} ${gate?.run?.url ?? ''} nonce ${gate?.nonce ?? t.nonce}`.trim(), `record: ${run}`], nextFor(t), run);
}
/** `train build`: admit, order, merge, record, push, free tests, dispatch; `--dry-run` spends nothing and writes nothing. */
export async function buildTrain(ctx: QueueContext, o: BuildOptions): Promise<TrainOut> {
const fetchErr = fetchBase(ctx);
const env = trainEnv(ctx);
if ('code' in env) return outRefused(env, [], 'none', 'none');
if (fetchErr) return outRefused({ code: 'POLICY_SOURCE_UNAVAILABLE', message: fetchErr }, [], 'none', 'none');
let t: TrainRecord | TrainOut;
let lines: string[] = [];
if (o.resume) {
t = findTrain(env, o.resume) ?? outRefused({ code: 'QUEUE_PROOF_MISSING', message: `no train record ${o.resume} here or in refs/notes/gstack-trains` }, [], `${Q} train status`, 'none');
if ('kind' in t && TERMINAL_STATES.includes(t.state)) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `train ${t.nonce} is ${t.state}; it never dispatches again` }, describe(t), nextFor(t), trainFile(t.nonce));
if ('kind' in t && t.state !== 'free-pending' && t.state !== 'dispatch-absent' && t.state !== 'dispatch-unknown' && t.state !== 'built' && t.state !== 'pushed' && t.state !== 'free-green') {
return outOk(describe(t), nextFor(t), trainFile(t.nonce));
}
} else {
const made = await newTrain(env, o);
if ('exit' in made) return made;
({ t, lines } = made);
}
if (!('kind' in t)) return t;
if (o.dryRun) {
return outOk([...lines, ...describe(t), `ESTIMATE: one ${env.train.gate_workflow} full gate (evals_all) on T${t.prefixes.length - 1} plus ${env.train.free_workflow}; dollar figure pending the T0 cost replay (lib/gate-cost.ts)`,
'DRY-RUN: nothing written, pushed or dispatched'], `${Q} train build --prs ${pinList(t.slots)}`, 'none');
}
if (t.state === 'built' && !t.attempts.length && !loadTrain(t.nonce)) {
effect(t, 'train.json written');
save(env, t);
for (const old of listTrains(env.repo.key)) {
if (old.nonce === t.nonce || TERMINAL_STATES.includes(old.state) || old.state === 'green' || old.state === 'merging') continue;
if (!old.slots.some(s => t.slots.some((n: TrainSlot) => n.pr === s.pr))) continue;
old.state = 'superseded';
old.superseded_by = t.nonce;
save(env, old);
}
if (o.repair) {
const f = activeFreeze(env.repo.key)!;
f.repair = { pr: t.slots[0]!.pr, head: t.slots[0]!.head, train: t.nonce };
f.updated_at = nowIso();
persist(env.ctx.repoRoot, [f], { remote: env.remote });
}
}
return advance(env, t, o, lines);
}
/**
* `train status`: never spends. Rebuilds local records from the notes ref,
* reconciles each unconfirmed dispatch by its nonce (found, or absent when a
* full listing shows no such run) and reads run and check state.
*/
export function trainStatus(ctx: QueueContext, o: { train?: string }): TrainOut {
const env = trainEnv(ctx);
if ('code' in env) return outRefused(env, [], 'none', 'none');
const restored = rebuildFromNotes(ctx.repoRoot, env.remote);
const lines = [`NOTES: read ${restored.read} record(s), restored ${restored.restored} (${restored.detail})`];
const trains = o.train ? [loadTrain(o.train)].filter((t): t is TrainRecord => !!t) : listTrains(env.repo.key).filter(t => !TERMINAL_STATES.includes(t.state));
if (o.train && !trains.length) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `no train record ${o.train} here or in refs/notes/gstack-trains` }, lines, `${Q} train status`, 'none');
const slug = trains.length ? repoSlug(ctx) : null;
const exec = execFor(ctx);
for (const t of trains) {
lines.push(...describe(t));
for (const a of attemptsOf(t)) {
if (slug && (a.state === 'dispatching' || a.state === 'unknown')) {
const runs = listDispatchRuns(a.workflow, exec, slug, 100);
const hit = runs ? runForNonce(runs, a.nonce) : null;
if (runs) {
a.state = hit ? 'dispatched' : 'absent';
a.run = hit ? asRun(hit) : null;
if (t.state === 'dispatch-unknown') t.state = hit ? (a.purpose === 'bisect' ? 'red' : 'gate-pending') : 'dispatch-absent';
effect(t, `status reconciled ${a.id} (nonce ${a.nonce}): ${hit ? `run ${hit.databaseId}` : 'absent'}`);
save(env, t, a);
}
}
const view = slug && a.run && !a.outcome ? runView(ctx, slug, a.run.id) : null;
lines.push(`ATTEMPT: ${a.id} ${a.purpose} state=${a.state} nonce=${a.nonce} run=${a.run?.id ?? '-'}${view ? ` ${view.status}/${view.conclusion ?? '-'}` : ''} outcome=${a.outcome ?? '-'}`);
}
if (slug && (t.state === 'green' || t.state === 'merging')) {
const c = checkRun(ctx, t.head, env.train.full_gate_check);
lines.push(`CHECK: ${env.train.full_gate_check} on ${t.head.slice(0, 12)}: ${c ? `${c.status}/${c.conclusion ?? '-'}` : 'missing'}`);
}
lines.push(`NEXT(${t.nonce}): ${nextFor(t)}`);
}
if (!trains.length) lines.push('TRAIN: none live');
const freeze = activeFreeze(env.repo.key);
if (freeze) lines.push(...frozenLines(freeze));
const single = trains.length === 1 ? trains[0]! : null;
const next = freeze && !freeze.repair ? `${Q} train build --repair --prs <repair-pr>@<sha>` : single ? nextFor(single) : trains.length ? 'see each NEXT(<nonce>) line' : 'none';
return outOk(lines, next, single ? trainFile(single.nonce) : 'none');
}
/** `train prune`: delete train/<nonce> on the remote for every finished (merged, superseded, dropped) train. */
export function pruneTrains(ctx: QueueContext, o: { dryRun: boolean; train?: string }): TrainOut {
const env = trainEnv(ctx);
if ('code' in env) return outRefused(env, [], 'none', 'none');
rebuildFromNotes(ctx.repoRoot, env.remote);
const lines: string[] = [];
const rgit = (args: string[]) => git(ctx.repoRoot, args);
for (const t of listTrains(env.repo.key).filter(x => !o.train || x.nonce === o.train)) {
if (!TERMINAL_STATES.includes(t.state)) { lines.push(`KEEP: ${t.branch} (${t.state})`); continue; }
if (!remoteBranchSha(rgit, env.remote, t.branch)) { lines.push(`GONE: ${t.branch} (${t.state})`); continue; }
if (o.dryRun) { lines.push(`WOULD-DELETE: ${t.branch} (${t.state})`); continue; }
const d = git(ctx.repoRoot, ['push', '-q', env.remote, '--delete', `refs/heads/${t.branch}`]);
lines.push(d.ok ? `DELETED: ${t.branch} (${t.state})` : `FAILED: ${t.branch}: ${d.err.slice(0, 120)}`);
if (d.ok) { effect(t, `branch deleted: ${t.branch}`); save(env, t); }
}
if (!lines.length) lines.push('PRUNE: no trains');
return outOk(lines, o.dryRun ? `${Q} train prune` : 'none', 'none');
}