mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* fix(eval-bg): local lanes export EVALS_RUN_ID so native-capture cases start (GSTA-70) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.219.0 stamp 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>
449 lines
26 KiB
TypeScript
449 lines
26 KiB
TypeScript
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
|
/**
|
|
* eval-bg — the one entry point for background paid evals (`bun run eval:bg:<lane>`).
|
|
*
|
|
* Two backends, one completion contract (W5d, CEO-17, DX-1..3/7, ENG-12/19):
|
|
*
|
|
* dispatch The pushed, clean ref is dispatched to CI (evals.yml and/or
|
|
* evals-periodic.yml on garrytan/gstack) with an `expected_sha` the
|
|
* workflow verifies and a `nonce` its run-name carries; this process
|
|
* resolves the run by that nonce and follows it to its conclusion.
|
|
* local The lane's sharded runner on this machine, capped at
|
|
* ceil(1.5 x planned serial seconds / EVALS_JOBS) + 20 min, at most 4 h.
|
|
*
|
|
* Both run under bin/gstack-detach with the `gstack-evals` lock, a run-scoped
|
|
* log under ~/.gstack-dev/eval-runs/ whose first line names the backend and the
|
|
* tested revision, and the `### gstack-detach EXIT=<code> ###` sentinel. A
|
|
* followed CI run maps its conclusion: success 0, failure 1, cancelled 130,
|
|
* anything else 2. `status <run-id|log>` reconnects later.
|
|
*
|
|
* Dispatch needs every one of: gh installed and authenticated, origin is
|
|
* garrytan/gstack with push rights, a branch whose remote head (read with
|
|
* `gh api`, never git fetch) equals HEAD, and a clean tree (no staged,
|
|
* unstaged or untracked files, the rule test/helpers/test-selection.ts uses).
|
|
* Otherwise it runs locally and prints why; `--dispatch` refuses instead.
|
|
* Dispatched runs are validation runs: fresh, never writing PR receipts.
|
|
*/
|
|
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 { detectBaseBranch } from '../test/helpers/touchfiles';
|
|
import { DEFAULT_JOBS, ROOT, type PaidTier } from './lib/paid-types';
|
|
import { buildRunManifest, loadPaidTestDurations, recordedShardMs } from './lib/paid-plan';
|
|
|
|
export type EvalBgLane = 'pr' | 'gate' | 'periodic' | 'release';
|
|
export type Backend = 'dispatch' | 'local';
|
|
export const EVAL_BG_LANES: readonly EvalBgLane[] = ['pr', 'gate', 'periodic', 'release'];
|
|
export const CANONICAL_REPO = 'garrytan/gstack';
|
|
export const EVAL_LOCK = 'gstack-evals';
|
|
export const LOCAL_CAP_SECONDS = 4 * 60 * 60;
|
|
export const LOCAL_SETUP_SECONDS = 20 * 60;
|
|
export const LOCAL_CAP_FACTOR = 1.5;
|
|
/** Packing weight of a shard with no recorded wall: the PR lane's ~10-minute case target. */
|
|
export const UNRECORDED_SHARD_MS = 600_000;
|
|
/** How long the dispatch follower may hold the lock: the longest CI lane plus queueing. */
|
|
export const DISPATCH_FOLLOW_SECONDS = 4 * 60 * 60;
|
|
|
|
export interface WorkflowDispatch { workflow: string; inputs: Record<string, string> }
|
|
|
|
/**
|
|
* Every workflow_dispatch input name eval-bg and gstack-ship-queue send, in
|
|
* one place. The train inputs (cases, prefix_shas) and the screen inputs
|
|
* (screen_pr, screen_merge_sha, screen_policy) are the interface the
|
|
* workflow side (plan GSTA-49, lane T1b) adds to evals.yml.
|
|
*/
|
|
export const DISPATCH_INPUTS = {
|
|
evalsAll: 'evals_all', expectedSha: 'expected_sha', nonce: 'nonce', baseRef: 'base_ref', baseSha: 'base_sha', prReceipts: 'pr_receipts',
|
|
cases: 'cases', prefixShas: 'prefix_shas', screenPr: 'screen_pr', screenMergeSha: 'screen_merge_sha', screenPolicy: 'screen_policy',
|
|
} as const;
|
|
|
|
/** What a dispatch is for, beside its lane: the screen policy keeps a broad screen from adopting a narrow run. */
|
|
export type DispatchPurpose = 'screen' | 'train' | 'bisect' | 'backstop';
|
|
export type ScreenPolicy = 'narrow' | 'broad';
|
|
export interface NonceScope { purpose: DispatchPurpose; policy?: ScreenPolicy }
|
|
|
|
interface LaneSpec {
|
|
/** The package script the local backend runs. */
|
|
script: string;
|
|
/** Worker count the local command defaults to (EVALS_JOBS overrides). */
|
|
jobs: number;
|
|
/** Tiers the local command plans, in order. */
|
|
tiers: Array<{ tier: PaidTier; profile: 'pr' | 'full' }>;
|
|
}
|
|
|
|
export const LANE_SPECS: Record<EvalBgLane, LaneSpec> = {
|
|
pr: { script: 'test:pr', jobs: 2, tiers: [{ tier: 'gate', profile: 'pr' }] },
|
|
gate: { script: 'test:gate:sharded', jobs: DEFAULT_JOBS, tiers: [{ tier: 'gate', profile: 'full' }] },
|
|
periodic: { script: 'test:periodic:sharded', jobs: DEFAULT_JOBS, tiers: [{ tier: 'periodic', profile: 'full' }] },
|
|
release: { script: 'test:release', jobs: DEFAULT_JOBS, tiers: [{ tier: 'gate', profile: 'full' }, { tier: 'periodic', profile: 'full' }] },
|
|
};
|
|
|
|
/** The workflows a lane dispatches and their inputs (names agreed with evals.yml's workflow_dispatch). */
|
|
export function dispatchPlan(lane: EvalBgLane, ids: { sha: string; baseRef: string; baseSha: string; nonce: string; prNumber?: number | null }): WorkflowDispatch[] {
|
|
const I = DISPATCH_INPUTS;
|
|
const evals = (all: boolean): WorkflowDispatch => ({ workflow: 'evals.yml', inputs: {
|
|
[I.evalsAll]: String(all), [I.expectedSha]: ids.sha, [I.nonce]: ids.nonce,
|
|
...(all ? {} : { [I.baseRef]: ids.baseRef, [I.baseSha]: ids.baseSha }),
|
|
// CEO-29/ENG-8: a PR lane run on a ref with an open same-repo PR reads that PR's receipts read-only.
|
|
...(!all && ids.prNumber ? { [I.prReceipts]: String(ids.prNumber) } : {}),
|
|
} });
|
|
const periodic: WorkflowDispatch = { workflow: 'evals-periodic.yml', inputs: { [I.expectedSha]: ids.sha, [I.nonce]: ids.nonce } };
|
|
if (lane === 'pr') return [evals(false)];
|
|
if (lane === 'gate') return [evals(true)];
|
|
if (lane === 'periodic') return [periodic];
|
|
return [evals(true), periodic];
|
|
}
|
|
|
|
/**
|
|
* Local cap: ceil(1.5 x planned serial seconds / jobs) + 20 min setup, at most
|
|
* 4 h. Serial seconds sum the recorded wall of every planned shard
|
|
* (scripts/paid-test-durations.json), unrecorded shards at UNRECORDED_SHARD_MS.
|
|
*/
|
|
export function localCapSeconds(serialMs: number, jobs: number): { seconds: number; capped: boolean } {
|
|
if (!Number.isSafeInteger(jobs) || jobs < 1) throw new Error('EVALS_JOBS must be a positive integer');
|
|
const computed = Math.ceil(LOCAL_CAP_FACTOR * serialMs / jobs / 1000) + LOCAL_SETUP_SECONDS;
|
|
return { seconds: Math.min(computed, LOCAL_CAP_SECONDS), capped: computed > LOCAL_CAP_SECONDS };
|
|
}
|
|
|
|
/** Planned serial work of a lane on this checkout, from the same planner CI uses. */
|
|
export function plannedSerialMs(lane: EvalBgLane, rootDir = ROOT): number {
|
|
return LANE_SPECS[lane].tiers.reduce((total, { tier, profile }) => {
|
|
const plan = (all: boolean) => buildRunManifest({ tier, profile: all ? 'full' : profile, sliceCount: 1, evalsAll: all,
|
|
env: all ? { EVALS_ALL: '1' } : {}, rootDir });
|
|
let manifest;
|
|
try { manifest = plan(profile === 'full'); }
|
|
catch { manifest = plan(true); } // a PR diff that needs full validation runs the full gate locally
|
|
const recorded = loadPaidTestDurations(rootDir, tier);
|
|
return total + manifest.entries.filter(entry => entry.status === 'planned')
|
|
.reduce((sum, entry) => sum + (recordedShardMs(recorded, entry.file) ?? UNRECORDED_SHARD_MS), 0);
|
|
}, 0);
|
|
}
|
|
|
|
export interface CheckoutFacts {
|
|
sha: string;
|
|
branch: string | null;
|
|
/** `git status --porcelain --untracked-files=all` lines. */
|
|
dirty: string[];
|
|
/** owner/name of origin, null when unknown. */
|
|
repo: string | null;
|
|
ghReady: boolean;
|
|
canPush: boolean;
|
|
/** Remote head of the branch on garrytan/gstack (gh api), null when absent. */
|
|
remoteSha: string | null;
|
|
/** Commits HEAD is ahead of remoteSha, null when unknown. */
|
|
ahead: number | null;
|
|
}
|
|
|
|
export type BackendChoice =
|
|
| { backend: Backend; reason: string }
|
|
| { backend: 'refuse'; reason: string };
|
|
|
|
/** DX-1 checkout contract, one row per reason dispatch is not possible. */
|
|
export function dispatchBlockers(facts: CheckoutFacts): string[] {
|
|
const sha = facts.sha.slice(0, 12);
|
|
const blockers: string[] = [];
|
|
if (!facts.ghReady) blockers.push('gh is not installed or not authenticated (run `gh auth status`)');
|
|
if (facts.repo !== CANONICAL_REPO) blockers.push(`origin is ${facts.repo ?? 'unknown'}, not ${CANONICAL_REPO} (a fork cannot dispatch its workflows)`);
|
|
else if (facts.ghReady && !facts.canPush) blockers.push(`this gh account cannot dispatch workflows on ${CANONICAL_REPO}`);
|
|
if (!facts.branch) blockers.push(`HEAD ${sha} is detached; dispatch needs a pushed branch`);
|
|
else if (facts.ghReady && facts.repo === CANONICAL_REPO && facts.remoteSha === null) blockers.push(`branch ${facts.branch} is not on ${CANONICAL_REPO}; push it`);
|
|
else if (facts.remoteSha !== null && facts.remoteSha !== facts.sha) {
|
|
blockers.push(facts.ahead !== null
|
|
? `HEAD ${sha} is ${facts.ahead} commits ahead of origin/${facts.branch}`
|
|
: `HEAD ${sha} differs from origin/${facts.branch} (${facts.remoteSha.slice(0, 12)})`);
|
|
}
|
|
if (facts.dirty.length) blockers.push(`${facts.dirty.length} files are modified or untracked`);
|
|
return blockers;
|
|
}
|
|
|
|
/** Choose the backend: an explicit mode wins; otherwise dispatch when nothing blocks it. */
|
|
export function chooseBackend(facts: CheckoutFacts, mode: Backend | 'auto'): BackendChoice {
|
|
if (mode === 'local') return { backend: 'local', reason: 'local requested (--local or GSTACK_EVAL_BG_MODE=local)' };
|
|
const blockers = dispatchBlockers(facts);
|
|
if (!blockers.length) return { backend: 'dispatch', reason: `HEAD ${facts.sha.slice(0, 12)} is clean and pushed to ${CANONICAL_REPO}/${facts.branch}` };
|
|
if (mode === 'dispatch') return { backend: 'refuse', reason: `${blockers.join(' and ')}; commit and push, or rerun with --local` };
|
|
return { backend: 'local', reason: `cannot dispatch: ${blockers.join('; ')}` };
|
|
}
|
|
|
|
/** CI conclusion -> the detach exit code (DX-2). */
|
|
export function conclusionExitCode(conclusion: string | null | undefined): number {
|
|
if (conclusion === 'success') return 0;
|
|
if (conclusion === 'failure') return 1;
|
|
if (conclusion === 'cancelled') return 130;
|
|
return 2;
|
|
}
|
|
|
|
/** Status words `status` prints, from a detach EXIT code or a CI run. */
|
|
export function exitStatusWord(code: string): 'passed' | 'failed' | 'cancelled' {
|
|
if (code === '0') return 'passed';
|
|
return code === '130' ? 'cancelled' : 'failed';
|
|
}
|
|
|
|
/**
|
|
* Nonce: lane, tested revision and base, plus a random suffix; the dedupe key
|
|
* is everything but the suffix. A scoped dispatch (a screen, a train, a
|
|
* bisect, a backstop) adds its purpose and screen policy to the lane field
|
|
* (`pr.screen.broad-…`), so runs of another purpose or policy never match
|
|
* its key and the plain lane keys are unchanged.
|
|
*/
|
|
export function makeNonce(lane: EvalBgLane, sha: string, baseSha: string, random = Math.random().toString(36).slice(2, 8), scope?: NonceScope): string {
|
|
return `${dedupeKey(lane, sha, baseSha, scope)}${random}`;
|
|
}
|
|
export function dedupeKey(lane: EvalBgLane, sha: string, baseSha: string, scope?: NonceScope): string {
|
|
const field = scope ? [lane, scope.purpose, ...(scope.policy ? [scope.policy] : [])].join('.') : lane;
|
|
return `${field}-${sha.slice(0, 12)}-${(baseSha || 'none').slice(0, 12)}-`;
|
|
}
|
|
|
|
/** The scope a nonce was minted with (null for a plain lane nonce). */
|
|
export function nonceScope(nonce: string): NonceScope | null {
|
|
const m = /^(?:pr|gate|periodic|release)\.(screen|train|bisect|backstop)(?:\.(narrow|broad))?-[0-9a-f]{1,12}-/.exec(nonce);
|
|
return m ? { purpose: m[1] as DispatchPurpose, ...(m[2] ? { policy: m[2] as ScreenPolicy } : {}) } : null;
|
|
}
|
|
|
|
export interface RunSummary { databaseId: number; displayTitle: string; status: string; conclusion: string | null; url: string; event: string }
|
|
|
|
/**
|
|
* DX-3 / ENG-12: an existing dispatch of the same lane, revision and base that
|
|
* is queued, running or completed successfully is followed instead of
|
|
* dispatching a duplicate. Only dispatches match (by the nonce in run-name); a
|
|
* failed or cancelled run is never reused.
|
|
*/
|
|
export function findDuplicateRun(runs: readonly RunSummary[], key: string): RunSummary | null {
|
|
return runs.find(run => run.event === 'workflow_dispatch' && run.displayTitle.includes(`eval-bg ${key}`)
|
|
&& (run.status !== 'completed' || run.conclusion === 'success')) ?? null;
|
|
}
|
|
|
|
// ─── Process plumbing (git / gh / detach) ────────────────────────────────────
|
|
|
|
export type Exec = (command: string, args: string[]) => { status: number | null; stdout: string };
|
|
|
|
const RUN_FIELDS = 'databaseId,displayTitle,status,conclusion,url,event';
|
|
|
|
/** Recent workflow_dispatch runs of one workflow; null when gh could not list them (unknown, never "none"). */
|
|
export function listDispatchRuns(workflow: string, run: Exec, repo = CANONICAL_REPO, limit = 30): RunSummary[] | null {
|
|
const out = run('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--event', 'workflow_dispatch', '--limit', String(limit), '--json', RUN_FIELDS]);
|
|
if (out.status !== 0) return null;
|
|
try { const parsed = JSON.parse(out.stdout); return Array.isArray(parsed) ? parsed as RunSummary[] : null; } catch { return null; }
|
|
}
|
|
|
|
/** The dispatch run whose run-name carries exactly this nonce. */
|
|
export function runForNonce(runs: readonly RunSummary[], nonce: string): RunSummary | null {
|
|
return runs.find(item => item.event === 'workflow_dispatch' && item.displayTitle.includes(`eval-bg ${nonce}]`)) ?? null;
|
|
}
|
|
|
|
/** `gh workflow run` with the plan's inputs on `ref`; false when gh refused it. */
|
|
export function dispatchWorkflow(dispatch: WorkflowDispatch, ref: string, run: Exec, repo = CANONICAL_REPO): boolean {
|
|
const args = ['workflow', 'run', dispatch.workflow, '--repo', repo, '--ref', ref,
|
|
...Object.entries(dispatch.inputs).flatMap(([name, value]) => ['-f', `${name}=${value}`])];
|
|
return run('gh', args).status === 0;
|
|
}
|
|
|
|
/** Poll for the run a dispatch started, by its nonce, for up to ten poll intervals; null when none appeared. */
|
|
export async function awaitNonceRun(workflow: string, nonce: string, run: Exec, repo = CANONICAL_REPO): Promise<RunSummary | null> {
|
|
const deadline = Date.now() + 10 * pollMs();
|
|
while (Date.now() < deadline) {
|
|
await sleep(Math.min(pollMs(), 10_000));
|
|
const found = runForNonce(listDispatchRuns(workflow, run, repo) ?? [], nonce);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const exec: Exec = (command, args) => {
|
|
const result = spawnSync(command, args, { cwd: ROOT, encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024 });
|
|
return { status: result.error ? null : result.status, stdout: result.stdout ?? '' };
|
|
};
|
|
|
|
function originRepo(run: Exec): string | null {
|
|
const url = run('git', ['config', '--get', 'remote.origin.url']).stdout.trim();
|
|
return /github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?\/?$/.exec(url)?.[1] ?? null;
|
|
}
|
|
|
|
/** Gather the DX-1 facts. Remote state comes from `gh api` only (ENG-19). */
|
|
export function gatherCheckoutFacts(run: Exec = exec): CheckoutFacts {
|
|
const sha = run('git', ['rev-parse', 'HEAD']).stdout.trim();
|
|
const branch = run('git', ['branch', '--show-current']).stdout.trim() || null;
|
|
const dirty = run('git', ['status', '--porcelain', '--untracked-files=all']).stdout.split('\n').filter(Boolean);
|
|
const repo = originRepo(run);
|
|
const ghReady = run('gh', ['auth', 'status']).status === 0;
|
|
const canPush = ghReady && repo === CANONICAL_REPO
|
|
&& run('gh', ['api', `repos/${CANONICAL_REPO}`, '--jq', '.permissions.push']).stdout.trim() === 'true';
|
|
const remote = ghReady && repo === CANONICAL_REPO && branch
|
|
? run('gh', ['api', `repos/${CANONICAL_REPO}/branches/${encodeURIComponent(branch)}`, '--jq', '.commit.sha']) : null;
|
|
const remoteSha = remote && remote.status === 0 && /^[0-9a-f]{40}$/.test(remote.stdout.trim()) ? remote.stdout.trim() : null;
|
|
const count = remoteSha && remoteSha !== sha ? run('git', ['rev-list', '--count', `${remoteSha}..HEAD`]) : null;
|
|
return { sha, branch, dirty, repo, ghReady, canPush, remoteSha,
|
|
ahead: count && count.status === 0 ? Number(count.stdout.trim()) : null };
|
|
}
|
|
|
|
/** Run-scoped log path, the same layout gstack-detach uses for its own default. */
|
|
export function runScopedLog(label: string, branch: string | null, now = new Date()): string {
|
|
const stamp = now.toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15);
|
|
return path.join(os.homedir(), '.gstack-dev', 'eval-runs',
|
|
`${label}-${path.basename(ROOT)}-${(branch ?? 'nobranch').replace(/\//g, '-')}-${stamp}-${process.pid}.log`);
|
|
}
|
|
|
|
/** Start `command` under gstack-detach with the eval lock; the header becomes the log's first line. */
|
|
export function launchDetached(opts: { log: string; label: string; timeoutSeconds: number; header: string; command: string[]; env?: NodeJS.ProcessEnv }): string {
|
|
fs.mkdirSync(path.dirname(opts.log), { recursive: true });
|
|
fs.writeFileSync(opts.log, `${opts.header}\n`, { flag: 'a' });
|
|
const result = spawnSync(path.join(ROOT, 'bin', 'gstack-detach'), ['--log', opts.log, '--lock', EVAL_LOCK,
|
|
'--timeout', String(opts.timeoutSeconds), '--label', opts.label, '--', ...opts.command],
|
|
{ cwd: ROOT, encoding: 'utf8', timeout: 30_000, env: opts.env ?? process.env });
|
|
if (result.status !== 0) throw new Error(`gstack-detach failed to start: ${result.stderr || result.error?.message}`);
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
/** The run id a local lane exports: the run-scoped log's name, reduced to [A-Za-z0-9_.-] as the native-capture cases require. */
|
|
export function localRunId(log: string): string {
|
|
return path.basename(log, '.log').replace(/[^\w.-]/g, '-');
|
|
}
|
|
|
|
/**
|
|
* The local backend: CI sets EVALS_RUN_ID per slice, and the native-capture
|
|
* cases refuse to start without it, so a local lane exports one too (an
|
|
* exported value is kept). The id is logged under the header.
|
|
*/
|
|
export function launchLocal(opts: { log: string; label: string; timeoutSeconds: number; header: string; command: string[]; env?: NodeJS.ProcessEnv }): string {
|
|
const env = opts.env ?? process.env;
|
|
const runId = env.EVALS_RUN_ID || localRunId(opts.log);
|
|
return launchDetached({ ...opts, header: `${opts.header}\n[eval-bg] EVALS_RUN_ID=${runId}`, env: { ...env, EVALS_RUN_ID: runId } });
|
|
}
|
|
|
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
const pollMs = () => Number(process.env.GSTACK_EVAL_BG_POLL_MS) || 30_000;
|
|
|
|
/** Dispatch one workflow (or adopt a duplicate) and follow it; returns the mapped exit code. */
|
|
async function followWorkflow(dispatch: WorkflowDispatch, ref: string, key: string, run: Exec): Promise<number> {
|
|
let target = findDuplicateRun(listDispatchRuns(dispatch.workflow, run) ?? [], key);
|
|
if (target) console.log(`[eval-bg] ${dispatch.workflow}: following existing run ${target.url} (same lane, revision and base; no duplicate dispatch)`);
|
|
else {
|
|
if (!dispatchWorkflow(dispatch, ref, run)) { console.log(`[eval-bg] ${dispatch.workflow}: gh workflow run failed`); return 2; }
|
|
target = await awaitNonceRun(dispatch.workflow, dispatch.inputs[DISPATCH_INPUTS.nonce]!, run);
|
|
if (!target) { console.log(`[eval-bg] ${dispatch.workflow}: dispatched, but no run named ${dispatch.inputs.nonce} appeared`); return 2; }
|
|
}
|
|
console.log(`[eval-bg] ${dispatch.workflow}: run ${target.databaseId} ${target.url}`);
|
|
let last = '';
|
|
while (true) {
|
|
const view = run('gh', ['run', 'view', String(target.databaseId), '--repo', CANONICAL_REPO, '--json', 'status,conclusion']);
|
|
let state: { status?: string; conclusion?: string | null } = {};
|
|
try { state = JSON.parse(view.stdout); } catch { /* transient gh failure: poll again */ }
|
|
if (state.status && state.status !== last) console.log(`[eval-bg] ${dispatch.workflow}: ${last = state.status}`);
|
|
if (state.status === 'completed') {
|
|
const code = conclusionExitCode(state.conclusion);
|
|
console.log(`[eval-bg] ${dispatch.workflow}: conclusion ${state.conclusion ?? 'unknown'} -> exit ${code}`);
|
|
return code;
|
|
}
|
|
await sleep(pollMs());
|
|
}
|
|
}
|
|
|
|
/** The detached follower: every planned workflow, then the first non-zero code. */
|
|
export async function follow(plan: WorkflowDispatch[], ref: string, key: string, run: Exec = exec): Promise<number> {
|
|
const codes = await Promise.all(plan.map(dispatch => followWorkflow(dispatch, ref, key, run)));
|
|
return codes.find(code => code !== 0) ?? 0;
|
|
}
|
|
|
|
/** `status <run-id|log>`: running / passed / failed / cancelled / incomplete. */
|
|
export function statusOf(target: string, run: Exec = exec, alive: (pgid: number) => boolean = pgidAlive): string {
|
|
if (/^\d+$/.test(target)) {
|
|
const view = run('gh', ['run', 'view', target, '--repo', CANONICAL_REPO, '--json', 'status,conclusion']);
|
|
const state = JSON.parse(view.stdout || '{}') as { status?: string; conclusion?: string };
|
|
return state.status === 'completed' ? exitStatusWord(String(conclusionExitCode(state.conclusion))) : 'running';
|
|
}
|
|
const log = fs.readFileSync(target, 'utf8');
|
|
const exit = /### gstack-detach EXIT=(\S+) ###/.exec(log)?.[1];
|
|
if (exit) return exitStatusWord(exit);
|
|
const pgid = Number(/### gstack-detach START label=\S+ pgid=(\d+) ###/.exec(log)?.[1]);
|
|
return pgid && alive(pgid) ? 'running' : 'incomplete';
|
|
}
|
|
|
|
function pgidAlive(pgid: number): boolean {
|
|
try { process.kill(-pgid, 0); return true; } catch { return false; }
|
|
}
|
|
|
|
const HELP = `Usage:
|
|
bun run scripts/eval-bg.ts <pr|gate|periodic|release> [--local | --dispatch] [--timeout SECS] [--base REF]
|
|
bun run scripts/eval-bg.ts status <run-id|log>
|
|
|
|
Runs a paid eval lane in the background and returns immediately. Poll the printed
|
|
log; it ends with "### gstack-detach EXIT=<code> ###" (0 passed, 1 failed,
|
|
130 cancelled, 2 other).
|
|
|
|
pr diff-selected PR gate (CI: evals.yml, evals_all=false)
|
|
gate full gate tier (CI: evals.yml, evals_all=true)
|
|
periodic full periodic tier (CI: evals-periodic.yml, periodic lane only)
|
|
release gate + periodic (CI: both workflows)
|
|
|
|
Backend: a clean HEAD pushed to ${CANONICAL_REPO} dispatches the CI workflow and
|
|
follows it; anything else runs locally under bin/gstack-detach, capped at
|
|
ceil(1.5 x planned serial seconds / EVALS_JOBS) + 20 min (at most 4 h), with
|
|
EVALS_RUN_ID set to the log's name unless one is exported.
|
|
--local / --dispatch force a backend (also GSTACK_EVAL_BG_MODE=local|dispatch);
|
|
--dispatch refuses instead of falling back
|
|
--timeout SECS override the computed local cap
|
|
--base REF base branch for the pr lane (default: detected base)
|
|
Needs: gh with repo access for dispatch; ANTHROPIC_API_KEY for local runs.`;
|
|
|
|
async function main(argv: string[]): Promise<number> {
|
|
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) { console.log(HELP); return argv.length ? 0 : 2; }
|
|
const [command, ...rest] = argv;
|
|
if (command === 'status') {
|
|
if (!rest[0]) throw new Error('status needs a run id or a log path');
|
|
console.log(statusOf(rest[0]));
|
|
return 0;
|
|
}
|
|
if (command === '__follow') {
|
|
const [planJson, ref, key] = rest;
|
|
return follow(JSON.parse(planJson!), ref!, key!);
|
|
}
|
|
if (!EVAL_BG_LANES.includes(command as EvalBgLane)) throw new Error(`Unknown lane ${command}; expected ${EVAL_BG_LANES.join(', ')} or status (see --help)`);
|
|
const lane = command as EvalBgLane;
|
|
let mode: Backend | 'auto' = process.env.GSTACK_EVAL_BG_MODE === 'local' || process.env.GSTACK_EVAL_BG_MODE === 'dispatch' ? process.env.GSTACK_EVAL_BG_MODE : 'auto';
|
|
let timeoutOverride: number | null = null;
|
|
let baseRef = detectBaseBranch(ROOT) || 'main';
|
|
for (let index = 0; index < rest.length; index++) {
|
|
const flag = rest[index];
|
|
if (flag === '--local' || flag === '--dispatch') mode = flag.slice(2) as Backend;
|
|
else if (flag === '--timeout') {
|
|
timeoutOverride = Number(rest[++index]);
|
|
if (!Number.isSafeInteger(timeoutOverride) || timeoutOverride <= 0) throw new Error('--timeout needs positive seconds');
|
|
} else if (flag === '--base') baseRef = rest[++index] ?? baseRef;
|
|
else throw new Error(`Unknown argument: ${flag} (see --help)`);
|
|
}
|
|
const facts = gatherCheckoutFacts();
|
|
const choice = chooseBackend(facts, mode);
|
|
if (choice.backend === 'refuse') { console.error(`[eval-bg] refusing to dispatch: ${choice.reason}`); return 1; }
|
|
const label = `evals-${lane}`;
|
|
const log = runScopedLog(label, facts.branch);
|
|
const tested = `${facts.sha}${facts.dirty.length ? ` + ${facts.dirty.length} uncommitted file(s)` : ''}`;
|
|
if (choice.backend === 'dispatch') {
|
|
const baseSha = exec('git', ['merge-base', `origin/${baseRef}`, 'HEAD']).stdout.trim();
|
|
const pr = lane === 'pr' ? exec('gh', ['api', `repos/${CANONICAL_REPO}/pulls?head=${CANONICAL_REPO.split('/')[0]}:${facts.branch}&state=open`, '--jq', '.[0].number']).stdout.trim() : '';
|
|
const nonce = makeNonce(lane, facts.sha, baseSha);
|
|
const plan = dispatchPlan(lane, { sha: facts.sha, baseRef, baseSha, nonce, prNumber: Number(pr) || null });
|
|
const header = `[eval-bg] backend=dispatch lane=${lane} tested=${facts.sha} ref=${facts.branch} workflows=${plan.map(item => item.workflow).join(',')} nonce=${nonce} (${choice.reason})`;
|
|
console.log(header);
|
|
console.log(launchDetached({ log, label, timeoutSeconds: DISPATCH_FOLLOW_SECONDS, header,
|
|
command: [process.execPath, 'run', path.join(ROOT, 'scripts', 'eval-bg.ts'), '__follow', JSON.stringify(plan), facts.branch!, dedupeKey(lane, facts.sha, baseSha)] }));
|
|
return 0;
|
|
}
|
|
const jobs = Number(process.env.EVALS_JOBS) || LANE_SPECS[lane].jobs;
|
|
const cap = timeoutOverride !== null ? { seconds: timeoutOverride, capped: false } : localCapSeconds(plannedSerialMs(lane), jobs);
|
|
const header = `[eval-bg] backend=local lane=${lane} tested=${tested} cap=${cap.seconds}s jobs=${jobs}${timeoutOverride !== null ? ' (--timeout)' : ''} (${choice.reason})`;
|
|
console.log(header);
|
|
if (cap.capped) console.log(`[eval-bg] WARNING: planned work exceeds the ${LOCAL_CAP_SECONDS}s local cap; the tail may not run. Dispatch to CI or raise EVALS_JOBS.`);
|
|
console.log(launchLocal({ log, label, timeoutSeconds: cap.seconds, header, command: ['bun', 'run', LANE_SPECS[lane].script] }));
|
|
return 0;
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
try {
|
|
process.exitCode = await main(process.argv.slice(2));
|
|
} catch (error) {
|
|
console.error(`[eval-bg] ${error instanceof Error ? error.message : String(error)}`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|