mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* 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> * feat(evals): merge-train workflow side, inert behind the train policy block (GSTA-49 T1b) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore(evals): T1b CHANGELOG under [Unreleased]; classify evals-main-backstop.yml as free-lane only Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(evals): plan-design-with-ui focus detector accepts Opus 5.5's 'Review focus' title with the step's rating and an all-7 option; replay run 39298269902 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.162.0 feat(evals): merge-train workflow side, inert behind the train policy block (GSTA-49 T1b) 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>
330 lines
18 KiB
TypeScript
330 lines
18 KiB
TypeScript
/**
|
|
* Cancellation recovery for the PR lane's receipt store (W1c: CEO-14/25/34,
|
|
* ENG-4/5/7/8).
|
|
*
|
|
* A PR run cancelled by the next push never reaches its report, so its
|
|
* finished receipts never enter the store the report saves. The
|
|
* `recover-receipts` job in evals.yml runs this script from the base ref
|
|
* (never PR code) with `actions: read`, before `plan-slices`:
|
|
*
|
|
* collect restores nothing itself: it reads the store the job restored,
|
|
* lists this PR's evals.yml runs newer than that store (plus runs
|
|
* the checkpoint left unresolved), downloads the receipts of
|
|
* completed cancelled runs (at most 5, merged oldest first,
|
|
* negatives first), reconstructs completed behavior panels from
|
|
* their manifest and slice results to publish panel negatives
|
|
* (partial panels get no credit), and writes the checkpoint
|
|
* `recovery.json` into the store.
|
|
* decide (plan-slices) reads the checkpoint and turns reuse off when
|
|
* recovery degraded or a cancelled run is still unresolved, so a
|
|
* FAIL that may exist is never bypassed by an older PASS.
|
|
*
|
|
* Recovery never fails the run: a gh error, rate limit, missing artifact or
|
|
* an exhausted download budget degrades to "no recovered receipts" with a
|
|
* named reason, and the run executes unreduced.
|
|
*/
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { mergeReceiptDirs, writeNegativeReceipt } from './e2e-shard-reuse';
|
|
import { loadSliceArtifacts, panelReports, parseRunManifest, shardFile } from './test-paid-shards';
|
|
|
|
export const RECOVERY_FILE = 'recovery.json';
|
|
export const MAX_RECOVERED_RUNS = 5;
|
|
const PROCESSED_KEEP = 200;
|
|
|
|
export interface RecoveryCheckpoint {
|
|
schema: 1;
|
|
updatedAt: number;
|
|
mode: 'pr' | 'dispatch';
|
|
/** Run ids whose evidence is merged (or that needed none). */
|
|
processed: string[];
|
|
/** Cancelled runs whose evidence could not be read yet; reuse stays off until a later run reconciles them. */
|
|
unresolved: Array<{ runId: string; reason: string }>;
|
|
/** Why recovery could not run at all; reuse stays off. */
|
|
degraded?: string;
|
|
recovered: number;
|
|
}
|
|
|
|
export interface WorkflowRun {
|
|
id: number;
|
|
status: string;
|
|
conclusion: string | null;
|
|
event: string;
|
|
head_sha: string;
|
|
run_attempt?: number;
|
|
updated_at?: string;
|
|
head_repository?: { full_name?: string } | null;
|
|
pull_requests?: Array<{ number: number }>;
|
|
display_title?: string;
|
|
}
|
|
|
|
/** One `gh` invocation; injectable so free tests drive recovery from recorded responses. */
|
|
export type GhRunner = (args: string[], timeoutMs: number) => { status: number | null; stdout: string; stderr: string };
|
|
|
|
export const runGh: GhRunner = (args, timeoutMs) => {
|
|
const result = spawnSync('gh', args, { encoding: 'utf8', timeout: Math.max(1_000, timeoutMs), maxBuffer: 64 * 1024 * 1024 });
|
|
return { status: result.status, stdout: result.stdout ?? '', stderr: result.error ? String(result.error) : result.stderr ?? '' };
|
|
};
|
|
|
|
/** A gh failure as a named reason with the next step (DX-6). */
|
|
export function ghFailureReason(stderr: string): string {
|
|
if (/rate limit|secondary rate|HTTP 429/i.test(stderr)) {
|
|
const reset = /reset[^0-9]*(\d{9,})/i.exec(stderr)?.[1];
|
|
return `GitHub API rate limit${reset ? ` (resets at ${new Date(Number(reset) * 1000).toISOString()})` : ''}; rerun the job after the reset`;
|
|
}
|
|
if (/HTTP 40[13]|authentication|auth login/i.test(stderr)) return 'gh is not authorized for actions: read; check the job permissions and `gh auth status`';
|
|
if (/ETIMEDOUT|timed out/i.test(stderr)) return 'gh timed out inside the 60 s recovery budget; rerun the job';
|
|
return `gh failed: ${stderr.trim().split('\n')[0]?.slice(0, 160) || 'no output'}`;
|
|
}
|
|
|
|
export function readCheckpoint(store: string): RecoveryCheckpoint | null {
|
|
try {
|
|
const parsed = JSON.parse(fs.readFileSync(path.join(store, RECOVERY_FILE), 'utf8'));
|
|
if (parsed?.schema !== 1 || !Array.isArray(parsed.processed) || !Array.isArray(parsed.unresolved)) return null;
|
|
return parsed as RecoveryCheckpoint;
|
|
} catch { return null; }
|
|
}
|
|
|
|
function writeCheckpoint(store: string, checkpoint: RecoveryCheckpoint): void {
|
|
fs.mkdirSync(store, { recursive: true });
|
|
fs.writeFileSync(path.join(store, RECOVERY_FILE), `${JSON.stringify(checkpoint, null, 2)}\n`, { mode: 0o600 });
|
|
}
|
|
|
|
/** The run id that saved a restored store, from its cache key (`…-pr-<n>-<run id>-<attempt>-merged`). */
|
|
export function storeRunId(cacheKey: string): number {
|
|
const match = /-pr-\d+-(\d+)-\d+-(?:merged|plan)$/.exec(cacheKey);
|
|
return match ? Number(match[1]) : 0;
|
|
}
|
|
|
|
/** CEO-34 provenance: same workflow event, PR number and head repository, never this run. */
|
|
export function acceptedRun(run: WorkflowRun, scope: { pr: number; headRepo: string; selfRunId: number }): boolean {
|
|
return run.id !== scope.selfRunId && run.event === 'pull_request'
|
|
&& run.head_repository?.full_name === scope.headRepo
|
|
&& (run.pull_requests ?? []).some(pr => pr.number === scope.pr);
|
|
}
|
|
|
|
/** The PR a dispatched screen names in its run name (evals.yml: `E2E Evals [eval-bg <nonce>] screen #<n>`); null for any other run. */
|
|
export function screenRunPr(run: Pick<WorkflowRun, 'event' | 'display_title'>): number | null {
|
|
const match = run.event === 'workflow_dispatch' ? /^E2E Evals \[eval-bg [A-Za-z0-9._-]+\] screen #(\d+)$/.exec(run.display_title ?? '') : null;
|
|
return match ? Number(match[1]) : null;
|
|
}
|
|
|
|
/** A dispatched screen of this PR from the base repository (same-repository PRs only), never this run. */
|
|
export function acceptedScreenRun(run: WorkflowRun, scope: { pr: number; repo: string; selfRunId: number }): boolean {
|
|
return run.id !== scope.selfRunId && screenRunPr(run) === scope.pr && run.head_repository?.full_name === scope.repo;
|
|
}
|
|
|
|
function listRuns(gh: GhRunner, repo: string, filter: string[], timeoutMs: number): WorkflowRun[] | { error: string } {
|
|
const listed = gh(['api', '-X', 'GET', `repos/${repo}/actions/workflows/evals.yml/runs`, ...filter.flatMap(f => ['-f', f]), '-f', 'per_page=50'], timeoutMs);
|
|
if (listed.status !== 0) return { error: ghFailureReason(listed.stderr) };
|
|
try { return (JSON.parse(listed.stdout).workflow_runs ?? []) as WorkflowRun[]; } catch { return { error: 'gh returned an unreadable run list' }; }
|
|
}
|
|
|
|
/**
|
|
* The PR's evals.yml runs whose results may carry over: its pull_request runs
|
|
* and, with `screens`, its dispatched screens (both events, filtered by the PR).
|
|
*/
|
|
export function listGateRuns(gh: GhRunner, o: { repo: string; pr: number; headRepo: string; branch: string; selfRunId: number; screens: boolean },
|
|
left: () => number): { runs: WorkflowRun[] } | { error: string } {
|
|
const prRuns = listRuns(gh, o.repo, ['event=pull_request', `branch=${o.branch}`], left());
|
|
if ('error' in prRuns) return prRuns;
|
|
const runs = prRuns.filter(run => acceptedRun(run, { pr: o.pr, headRepo: o.headRepo, selfRunId: o.selfRunId }));
|
|
if (!o.screens) return { runs };
|
|
const screens = listRuns(gh, o.repo, ['event=workflow_dispatch'], left());
|
|
if ('error' in screens) return screens;
|
|
return { runs: [...runs, ...screens.filter(run => acceptedScreenRun(run, { pr: o.pr, repo: o.repo, selfRunId: o.selfRunId }))] };
|
|
}
|
|
|
|
/**
|
|
* Negative receipts a cancelled run's completed work proves (ENG-5): every
|
|
* completed FAIL panel whose trials share one input identity, and every
|
|
* completed non-passing single-trial shard. Partial panels publish nothing.
|
|
*/
|
|
export function negativesFromRun(runDir: string, run: Pick<WorkflowRun, 'id' | 'head_sha' | 'updated_at'>, out: string): number {
|
|
let manifest;
|
|
try { manifest = parseRunManifest(fs.readFileSync(path.join(runDir, 'paid-plan', 'manifest.json'), 'utf8')); } catch { return 0; }
|
|
const artifacts = loadSliceArtifacts(runDir);
|
|
let written = 0;
|
|
for (const attempt of [...new Set(artifacts.map(a => a.result.attempt ?? 1))]) {
|
|
const results = artifacts.map(a => a.result).filter(r => (r.attempt ?? 1) === attempt);
|
|
const completedAt = Math.max(0, ...results.map(r => r.finishedAt ?? 0)) || Date.parse(run.updated_at ?? '') || Date.now();
|
|
const source = { runId: `${run.id}/${attempt}`, revision: run.head_sha, completedAt };
|
|
if (!/^[a-f0-9]{40}$/.test(source.revision)) continue;
|
|
const outcomes = results.flatMap(r => r.outcomes);
|
|
for (const panel of panelReports(manifest, results, attempt).filter(p => p.status === 'FAIL')) {
|
|
const keys = outcomes.filter(o => o.trial?.case === panel.case && shardFile(o.files[0] ?? '') === panel.file).map(o => o.reused ? null : o.inputKey ?? null);
|
|
if (keys.length !== panel.panel.n || keys.some(k => k === null) || new Set(keys).size !== 1) continue;
|
|
writeNegativeReceipt(out, { schema: 1, key: keys[0]!, source });
|
|
written++;
|
|
}
|
|
for (const outcome of outcomes.filter(o => !o.trial && o.inputKey && !o.reused && o.status !== 'passed' && o.status !== 'skipped-by-diff')) {
|
|
writeNegativeReceipt(out, { schema: 1, key: outcome.inputKey!, source });
|
|
written++;
|
|
}
|
|
}
|
|
return written;
|
|
}
|
|
|
|
export interface CollectOptions {
|
|
repo: string;
|
|
pr: number;
|
|
headRepo: string;
|
|
branch: string;
|
|
selfRunId: number;
|
|
store: string;
|
|
/** The restored cache key; recovery reads only runs newer than the run that saved it. */
|
|
storeKey?: string;
|
|
/** `dispatch`: read-only receipts of the PR's completed runs for a validation dispatch (ENG-8). */
|
|
mode?: 'pr' | 'dispatch';
|
|
budgetMs?: number;
|
|
pollMs?: number;
|
|
gh?: GhRunner;
|
|
now?: () => number;
|
|
sleep?: (ms: number) => void;
|
|
}
|
|
|
|
export function collectRecovery(options: CollectOptions): { checkpoint: RecoveryCheckpoint; lines: string[] } {
|
|
const gh = options.gh ?? runGh;
|
|
const now = options.now ?? Date.now;
|
|
const sleep = options.sleep ?? ((ms: number) => Bun.sleepSync(ms));
|
|
const mode = options.mode ?? 'pr';
|
|
const deadline = now() + (options.budgetMs ?? 60_000);
|
|
const left = () => deadline - now();
|
|
const previous = readCheckpoint(options.store);
|
|
const processed = new Set(previous?.processed ?? []);
|
|
const pending = new Set((previous?.unresolved ?? []).map(entry => entry.runId));
|
|
const lines: string[] = [];
|
|
const finish = (checkpoint: Omit<RecoveryCheckpoint, 'schema' | 'updatedAt' | 'mode'>) => {
|
|
const full: RecoveryCheckpoint = { schema: 1, updatedAt: now(), mode, ...checkpoint, processed: checkpoint.processed.slice(-PROCESSED_KEEP) };
|
|
writeCheckpoint(options.store, full);
|
|
return { checkpoint: full, lines };
|
|
};
|
|
const degrade = (reason: string) => {
|
|
lines.push(`receipt recovery degraded: ${reason}; no recovered receipts, this run executes unreduced`);
|
|
return finish({ processed: [...processed], unresolved: previous?.unresolved ?? [], degraded: reason, recovered: 0 });
|
|
};
|
|
|
|
const listed = gh(['api', '-X', 'GET', `repos/${options.repo}/actions/workflows/evals.yml/runs`,
|
|
'-f', 'event=pull_request', '-f', `branch=${options.branch}`, '-f', 'per_page=50'], left());
|
|
if (listed.status !== 0) return degrade(ghFailureReason(listed.stderr));
|
|
let runs: WorkflowRun[];
|
|
try { runs = (JSON.parse(listed.stdout).workflow_runs ?? []) as WorkflowRun[]; } catch { return degrade('gh returned an unreadable run list'); }
|
|
|
|
const floor = mode === 'pr' ? storeRunId(options.storeKey ?? '') : 0;
|
|
const scope = { pr: options.pr, headRepo: options.headRepo, selfRunId: options.selfRunId };
|
|
for (const rejected of runs.filter(run => run.id !== options.selfRunId && run.event === 'pull_request' && !acceptedRun(run, scope)
|
|
&& (run.pull_requests ?? []).some(pr => pr.number === options.pr))) {
|
|
lines.push(`rejected run ${rejected.id}: head repository ${rejected.head_repository?.full_name ?? 'unknown'} is not ${options.headRepo}`);
|
|
}
|
|
const older = runs.filter(run => acceptedRun(run, scope) && run.id < options.selfRunId);
|
|
// A completed run that was not cancelled reached its report, which saved its receipts.
|
|
const reported = (run: WorkflowRun) => run.status === 'completed' && run.conclusion !== 'cancelled';
|
|
const fresh = (run: WorkflowRun) => pending.has(String(run.id)) || (run.id > floor && !processed.has(String(run.id)));
|
|
const candidates = (mode === 'dispatch' ? older.filter(run => run.status === 'completed')
|
|
: older.filter(run => fresh(run) && !reported(run))).sort((a, b) => b.id - a.id).slice(0, MAX_RECOVERED_RUNS);
|
|
if (mode === 'pr') for (const run of older.filter(run => fresh(run) && reported(run))) processed.add(String(run.id));
|
|
|
|
const unresolved: RecoveryCheckpoint['unresolved'] = [];
|
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'recover-receipts-'));
|
|
let recovered = 0;
|
|
try {
|
|
// Oldest first, so a newer FAIL always lands after the PASS it blocks.
|
|
for (const listedRun of [...candidates].sort((a, b) => a.id - b.id)) {
|
|
const id = String(listedRun.id);
|
|
let run = listedRun;
|
|
while (run.status !== 'completed' && left() > (options.pollMs ?? 5_000)) {
|
|
sleep(options.pollMs ?? 5_000);
|
|
const polled = gh(['api', `repos/${options.repo}/actions/runs/${id}`], left());
|
|
if (polled.status !== 0) break;
|
|
try { run = JSON.parse(polled.stdout) as WorkflowRun; } catch { break; }
|
|
}
|
|
if (run.status !== 'completed') {
|
|
unresolved.push({ runId: id, reason: 'still in progress; its completed FAILs cannot be read yet' });
|
|
lines.push(`run ${id} is still ${run.status}; reuse stays off until a later run reconciles it`);
|
|
continue;
|
|
}
|
|
if (mode === 'pr' && run.conclusion !== 'cancelled') { processed.add(id); continue; }
|
|
const dir = path.join(work, id);
|
|
const names = gh(['api', '-X', 'GET', `repos/${options.repo}/actions/runs/${id}/artifacts`, '-f', 'per_page=100'], left());
|
|
let artifacts: string[] = [];
|
|
try {
|
|
artifacts = names.status === 0 ? (JSON.parse(names.stdout).artifacts ?? [])
|
|
.filter((a: { name: string; expired?: boolean }) => !a.expired && /^(?:paid-plan|paid-slice-\d+-a\d+|report-verdict-a\d+)$/.test(a.name))
|
|
.map((a: { name: string }) => a.name) : [];
|
|
} catch { artifacts = []; }
|
|
if (names.status !== 0) {
|
|
unresolved.push({ runId: id, reason: ghFailureReason(names.stderr) });
|
|
lines.push(`run ${id}: cannot list artifacts (${ghFailureReason(names.stderr)})`);
|
|
continue;
|
|
}
|
|
let failed = '';
|
|
if (artifacts.length && left() <= 0) failed = 'the 60 s download budget ran out';
|
|
else if (artifacts.length) {
|
|
// gh extracts one artifact into -D itself and several into one directory per artifact.
|
|
const target = artifacts.length === 1 ? path.join(dir, artifacts[0]!) : dir;
|
|
const got = gh(['run', 'download', id, '-R', options.repo, ...artifacts.flatMap(name => ['-n', name]), '-D', target], left());
|
|
if (got.status !== 0) failed = ghFailureReason(got.stderr);
|
|
}
|
|
if (failed) {
|
|
unresolved.push({ runId: id, reason: failed });
|
|
lines.push(`run ${id}: ${failed}; its evidence stays unresolved`);
|
|
continue;
|
|
}
|
|
const negatives = path.join(dir, 'recovered-negatives');
|
|
const panelNegatives = negativesFromRun(dir, run, negatives);
|
|
const receiptDirs = [negatives, ...artifacts.filter(name => name !== 'paid-plan').flatMap(name =>
|
|
[path.join(dir, name, 'receipts'), path.join(dir, name, 'paid-report', 'report-receipts')]),
|
|
...(mode === 'dispatch' ? [path.join(dir, 'paid-plan', 'store'), path.join(dir, 'paid-plan', 'receipts')] : [])];
|
|
const merged = mergeReceiptDirs(options.store, receiptDirs);
|
|
recovered += merged;
|
|
processed.add(id);
|
|
lines.push(`run ${id} (${run.conclusion}): merged ${merged} receipt(s), ${panelNegatives} reconstructed negative(s) from completed panels and shards`);
|
|
}
|
|
} finally {
|
|
fs.rmSync(work, { recursive: true, force: true });
|
|
}
|
|
if (!candidates.length) lines.push('no cancelled runs to recover');
|
|
return finish({ processed: [...processed], unresolved, recovered });
|
|
}
|
|
|
|
/** plan-slices: reuse is on only when recovery ran, did not degrade and left nothing unresolved. */
|
|
export function recoveryDecision(store: string): { reuse: boolean; reason: string } {
|
|
const checkpoint = readCheckpoint(store);
|
|
if (!checkpoint) return { reuse: false, reason: 'receipt recovery unavailable (no checkpoint from the recover-receipts job); cases execute fresh' };
|
|
if (checkpoint.degraded) return { reuse: false, reason: `receipt recovery degraded: ${checkpoint.degraded}; cases execute fresh` };
|
|
if (checkpoint.unresolved.length) {
|
|
return { reuse: false, reason: `cancelled run(s) ${checkpoint.unresolved.map(u => u.runId).join(', ')} not reconciled (${checkpoint.unresolved[0]!.reason}); cases execute fresh and the next push reconciles them` };
|
|
}
|
|
return { reuse: true, reason: `receipt reuse on; ${checkpoint.recovered} receipt(s) recovered from cancelled runs` };
|
|
}
|
|
|
|
const USAGE = `usage:
|
|
bun run scripts/recover-receipts.ts collect --repo <owner/name> --pr <n> --head-repo <owner/name> --branch <head branch>
|
|
--run-id <this run> --store <dir> [--store-key <restored cache key>] [--mode pr|dispatch] [--budget-seconds 60]
|
|
bun run scripts/recover-receipts.ts decide <store> [--github-output <file>]
|
|
Recovery never fails: on any gh error it records why and the run executes unreduced.`;
|
|
|
|
if (import.meta.main) {
|
|
const [command, ...rest] = process.argv.slice(2);
|
|
const flag = (name: string) => { const i = rest.indexOf(`--${name}`); return i >= 0 ? rest[i + 1] : undefined; };
|
|
if (!command || command === '--help' || command === '-h') { console.log(USAGE); process.exit(command ? 0 : 2); }
|
|
if (command === 'decide' && rest[0]) {
|
|
const decision = recoveryDecision(rest[0]);
|
|
const out = flag('github-output');
|
|
if (out) fs.appendFileSync(out, `reuse=${decision.reuse ? 'on' : 'off'}\n`);
|
|
console.log(`- Receipt reuse: ${decision.reason}`);
|
|
} else if (command === 'collect') {
|
|
const [store, repo, headRepo, branch] = [flag('store'), flag('repo'), flag('head-repo'), flag('branch')];
|
|
const [pr, selfRunId] = [Number(flag('pr')), Number(flag('run-id'))];
|
|
if (!store || !repo || !headRepo || !branch || !Number.isSafeInteger(pr) || !Number.isSafeInteger(selfRunId)) {
|
|
console.error(USAGE); process.exit(2);
|
|
}
|
|
const { lines } = collectRecovery({ repo, pr, headRepo, branch, selfRunId, store, storeKey: flag('store-key'),
|
|
mode: flag('mode') === 'dispatch' ? 'dispatch' : 'pr', budgetMs: Number(flag('budget-seconds') ?? '60') * 1000 });
|
|
console.log('### Receipt recovery');
|
|
for (const line of lines) console.log(`- ${line}`);
|
|
} else { console.error(USAGE); process.exit(2); }
|
|
}
|