mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 14:34:42 +01:00
* feat(ship): pause /ship on a red base instead of failing (gstack-base-red, conditional receipts) (GSTA-157 PR 1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: base-red shell runner launches bash through runBashSync Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: base-red coverage additions (unreproduced head, unresolved head, readPause, wait unconfigured) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: base-red pause in unattended endings; BASE_RED_UNCONFIGURED covers an invalid base policy Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: base-red coverage pass 2 (write --base-red, summary line, classify usage before fetch, unresolved-head detail) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship-queue): merge trains for any repo — train.gates (workflows/command), every prefix gated, generalized train land (direct/update, merge method), base-red trains, train init/doctor, watch --land (GSTA-157 PR 2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (train gates: dispatch bound, lease, rebase, post-merge checks, output tail, caps) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: train land refuses the next slot until the previous slot's post-merge checks are green Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: ship-policy, troubleshooting and captain for train gates review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: changelog fragment for merge trains for any repo Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: keep ship-queue-train-generic out of the Windows subset (gh shim resolves gh.exe there) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
129 lines
8.1 KiB
TypeScript
129 lines
8.1 KiB
TypeScript
/**
|
|
* `train watch --train <nonce> [--timeout <s>]`: the controller's one
|
|
* resumable follow command. Machines restart and kill
|
|
* background `gh run watch` chains, so watch keeps no process state: it
|
|
* reads the train record, runs the step the state asks for (train build
|
|
* --train to push, run the free tests and dispatch the gate; train status to
|
|
* settle an unconfirmed dispatch), waits for the pending run to finish by
|
|
* polling gh, then reads the verdict, and prints the last step's NEXT. Run it
|
|
* again after an interruption and it continues from the record. It never
|
|
* dispatches anything the record would not dispatch on its own.
|
|
*
|
|
* A generic train (gates in the policy; GSTA-157 PR 2) loops build, wait, verdict until the verdict settles, and with
|
|
* --land lands each landable slot in order through train land, stopping at the first refusal with its code and NEXT.
|
|
* It never merges without --land and never merges main into a PR.
|
|
*/
|
|
import type { QueueContext } from './ship-queue';
|
|
import { buildTrain, findTrain, nextFor, outOk, outPending, outRefused, trainEnv, trainStatus, type TrainOut } from './ship-queue-train';
|
|
import { repoSlug, runView } from './ship-queue-train-github';
|
|
import { attemptsOf, trainFile, type TrainRecord } from './ship-queue-train-record';
|
|
import type { TrainEnv } from './ship-queue-train';
|
|
import { trainVerdict } from './ship-queue-train-verdict';
|
|
import { verifyPostMerge } from './ship-queue-post-merge';
|
|
|
|
const Q = 'gstack-ship-queue';
|
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
|
export interface WatchOptions { train: string; timeoutSec: number; pollMs: number; freeTimeoutSec: number; land?: boolean }
|
|
|
|
/** Wait for the train's current gate run to complete; null when it did, else why the wait stopped. */
|
|
async function awaitGate(ctx: QueueContext, t: TrainRecord, deadline: number, pollMs: number): Promise<string | null> {
|
|
const a = attemptsOf(t).filter(x => x.purpose !== 'bisect').at(-1);
|
|
if (!a?.run) return `no gate run is recorded for ${t.nonce}`;
|
|
const slug = repoSlug(ctx);
|
|
if (!slug) return 'gh repo view could not name the repository';
|
|
for (;;) {
|
|
const view = runView(ctx, slug, a.run.id);
|
|
if (view?.status === 'completed') return null;
|
|
if (Date.now() >= deadline) return `run ${a.run.id} is still ${view?.status ?? 'unreadable'} at the watch timeout`;
|
|
await sleep(pollMs);
|
|
}
|
|
}
|
|
|
|
/** Wait until every dispatched prefix run of a generic train completes; null when they did, else why the wait stopped. */
|
|
async function awaitPrefixRuns(ctx: QueueContext, t: TrainRecord, deadline: number, pollMs: number): Promise<string | null> {
|
|
const slug = repoSlug(ctx);
|
|
if (!slug) return 'gh repo view could not name the repository';
|
|
for (;;) {
|
|
const open = attemptsOf(t).filter(a => a.state === 'dispatched' && a.run && a.outcome !== 'GREEN' && a.outcome !== 'RED' && a.outcome !== 'INCOMPLETE');
|
|
const running = open.filter(a => runView(ctx, slug, a.run!.id)?.status !== 'completed');
|
|
if (!running.length) return null;
|
|
if (Date.now() >= deadline) return `${running.length} prefix run(s) still running at the watch timeout (${running.map(a => a.run!.id).join(', ')})`;
|
|
await sleep(pollMs);
|
|
}
|
|
}
|
|
|
|
async function watchGeneric(ctx: QueueContext, env: TrainEnv, start: TrainRecord, o: WatchOptions, lines: string[], again: string, deadline: number): Promise<TrainOut> {
|
|
let t = start;
|
|
const run = trainFile(t.nonce);
|
|
const reload = () => { t = findTrain(env, o.train) ?? t; };
|
|
let verdict: TrainOut | null = null;
|
|
while (!t.verdict) {
|
|
if (t.state === 'built' || t.state === 'pushed' || verdict?.next.includes('train build --train')) {
|
|
const b = await buildTrain(ctx, { resume: t.nonce, dryRun: false, urgent: false, repair: false, freeTimeoutSec: o.freeTimeoutSec, pollMs: o.pollMs });
|
|
reload();
|
|
lines.push(`WATCH: train build --train ${t.nonce} (${b.failure?.code ?? b.status})`);
|
|
if (b.status !== 'complete') return { ...b, lines: [...lines, ...b.lines] };
|
|
}
|
|
const why = await awaitPrefixRuns(ctx, t, deadline, o.pollMs);
|
|
if (why) return outPending({ code: 'QUEUE_TRAIN_PENDING', message: why }, lines, again, run);
|
|
verdict = trainVerdict(ctx, { train: t.nonce });
|
|
reload();
|
|
if (!t.verdict && Date.now() >= deadline) return { ...verdict, lines: [...lines, ...verdict.lines], next: again };
|
|
if (!t.verdict) await sleep(o.pollMs);
|
|
}
|
|
verdict ??= trainVerdict(ctx, { train: t.nonce });
|
|
lines.push(...verdict.lines);
|
|
if (!o.land || !t.verdict.landable) return { ...verdict, lines };
|
|
const { postMergeChecks, trainLand } = await import('./ship-queue-train-land');
|
|
const post = postMergeChecks(env);
|
|
for (const s of t.slots.slice(0, t.verdict.landable)) {
|
|
if (t.merged.some(m => m.pr === s.pr)) continue;
|
|
const remaining = Math.max(1, Math.floor((deadline - Date.now()) / 60_000));
|
|
const landed = await trainLand(ctx, { train: t.nonce, pr: s.pr, timeoutMin: remaining });
|
|
lines.push(`WATCH: train land --train ${t.nonce} --pr ${s.pr} (${landed.failure?.code ?? landed.status})`, ...landed.lines);
|
|
if (landed.status !== 'complete') return { ...landed, lines };
|
|
reload();
|
|
const sha = t.merged.find(m => m.pr === s.pr)?.sha;
|
|
if (!post.length || !sha) continue;
|
|
const checked = await verifyPostMerge(ctx, { sha, checks: post, timeoutSec: Math.max(1, Math.floor((deadline - Date.now()) / 1000)), pollMs: o.pollMs });
|
|
lines.push(`WATCH: verify --post-merge ${sha} (${checked.failure?.code ?? checked.status})`, ...checked.lines);
|
|
if (checked.status !== 'complete') return { ...checked, lines, next: checked.failure?.code === 'QUEUE_TRAIN_PENDING' ? `${checked.next} && ${Q} train watch --train ${t.nonce} --land` : checked.next };
|
|
}
|
|
return outOk(lines, nextFor(t), run);
|
|
}
|
|
|
|
export async function trainWatch(ctx: QueueContext, o: WatchOptions): Promise<TrainOut> {
|
|
const env = trainEnv(ctx);
|
|
if ('code' in env) return outRefused(env, [], 'none', 'none');
|
|
let 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 deadline = Date.now() + o.timeoutSec * 1000;
|
|
const lines: string[] = [`WATCH: train ${t.nonce} state=${t.state} (resumable; rerun this command after an interruption)`];
|
|
const again = `${Q} train watch --train ${t.nonce} --timeout ${o.timeoutSec}`;
|
|
const step = (label: string, out: TrainOut): TrainOut => ({ ...out, lines: [...lines, `WATCH: ${label}`, ...out.lines] });
|
|
const reload = () => { t = findTrain(env, o.train) ?? t; };
|
|
if (t.gated) return watchGeneric(ctx, env, t, o, lines, again, deadline);
|
|
if (t.state === 'dispatch-unknown') {
|
|
const s = trainStatus(ctx, { train: t.nonce });
|
|
lines.push(`WATCH: train status settled the unconfirmed dispatch`, ...s.lines.filter(l => l.startsWith('ATTEMPT:')));
|
|
reload();
|
|
}
|
|
if (['built', 'pushed', 'free-pending', 'free-green', 'dispatch-absent'].includes(t.state)) {
|
|
const remaining = Math.max(1, Math.floor((deadline - Date.now()) / 1000));
|
|
const b = await buildTrain(ctx, { resume: t.nonce, dryRun: false, urgent: false, repair: false, freeTimeoutSec: Math.min(o.freeTimeoutSec, remaining), pollMs: o.pollMs });
|
|
reload();
|
|
if (b.status !== 'complete') return step(`train build --train ${t.nonce} stopped (${b.failure?.code ?? b.status})`, b.failure?.code === 'QUEUE_TRAIN_PENDING' ? { ...b, next: again } : b);
|
|
lines.push(`WATCH: train build --train ${t.nonce}: free tests green, gate dispatched`);
|
|
}
|
|
if (t.state === 'gate-pending') {
|
|
const why = await awaitGate(ctx, t, deadline, o.pollMs);
|
|
if (why) return outPending({ code: 'QUEUE_TRAIN_PENDING', message: why }, lines, again, run);
|
|
lines.push('WATCH: gate run completed; reading the verdict');
|
|
return step(`train verdict --train ${t.nonce}`, trainVerdict(ctx, { train: t.nonce }));
|
|
}
|
|
if (t.state === 'dispatch-unknown') return outPending({ code: 'QUEUE_TRAIN_DISPATCH_UNKNOWN', message: `the gate dispatch for ${t.nonce} is still unconfirmed` }, lines, again, run);
|
|
return outOk([...lines, `WATCH: nothing to wait for in state ${t.state}`], nextFor(t), run);
|
|
}
|