Merge remote-tracking branch 'origin/capy/next-wave-fix-c' into capy/next-wave

This commit is contained in:
garrytan committed 2026-10-15 01:40:49 +00:00
commit d940633623
9 files changed
+180 -63

No files matched your search

+1
View File
@@ -162,6 +162,7 @@ gstack_result_anchor() {
PREGATE_LANE_FAILED) printf '%s' 'pregate-lane-failed' ;;
PREGATE_INCOMPLETE) printf '%s' 'pregate-incomplete' ;;
PREGATE_STALLED) printf '%s' 'pregate-stalled' ;;
PREGATE_RECEIPT_INVALID) printf '%s' 'pregate-receipt-invalid' ;;
PREGATE_STALE) printf '%s' 'pregate-stale' ;;
PREGATE_REMOTE_UNCLEARED) printf '%s' 'pregate-remote-uncleared' ;;
ISSUE_UNREADABLE) printf '%s' 'issue-unreadable' ;;
+19 -12
View File
@@ -6,7 +6,7 @@
* [--allow-repo-commands] [--policy-from base|head] [--out <pregate.json>]
* [--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--explain] [--json]
* gstack-pregate verify [--out <pregate.json>] [--json] publication check: current tree, no fail/incomplete, obligations cleared
* gstack-pregate clear --remote <workflow.yml/job> --run-url <url> | --bundle-lane <label> [--out <pregate.json>]
* gstack-pregate clear --remote <workflow.yml/job> --run-url <url> | --bundle-lane <label> | --receipt <file> [--out <pregate.json>]
* gstack-pregate lanes [--explain] the lane list and platform tiers read from .github/workflows
* gstack-pregate validate | init [--explain] [--force] the working-tree .gstack/pregate.json
* gstack-pregate sealed [--range <a..b>] [--remote <name>] [--base <branch>] [--json]
@@ -29,10 +29,11 @@ import * as path from 'node:path';
import { EXIT, exitTableHelp, formatResult, type PregateFile } from '../lib/headless-artifacts';
import { PREGATE_CONFIG_PATH, exampleConfigJson, explainConfig, loadConfigFromHead } from '../lib/pregate/config';
import { buildContext } from '../lib/pregate/context';
import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index';
import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index';
import { describeLane, discoverLanes } from '../lib/pregate/workflows';
import { REMOTE_GATE_LANE } from '../lib/pregate/checks/lanes';
import { ensureTmpExcluded } from '../lib/restamp';
import { resultCodeSuffix, resultLine, type ResultCodeName } from '../lib/result-codes';
import { RESULT_CODES, resultCodeSuffix, resultLine, type ResultCodeName } from '../lib/result-codes';
import { DEFAULT_STALL_TIMEOUT_S } from '../lib/pregate/context';
import { cancelActive } from '../lib/pregate/supervise';
import { defaultGh, evaluateRegate, renderRegate } from '../lib/pregate/regate';
@@ -45,7 +46,9 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
run (default) stage 1 mechanical preflight (${CHECKS.filter(c => c.stage === 'preflight').map(c => c.id).join(', ')}), then stage 2 test execution (${CHECKS.filter(c => c.stage === 'tests').map(c => c.id).join(', ')})
verify publication check against ${PREGATE_OUT_REL}: current tree, no fail or incomplete, every requires-remote obligation cleared
clear record the receipt that clears one requires-remote obligation
clear record the receipt that clears one requires-remote obligation: pregate.json/remote_gate needs
--receipt <file> (the gate's PREGATE_REMOTE: line, checked against the tested tree); a
test_backend/<backend> run URL is recorded as attested, not validated
lanes print every CI lane with its platform tier (from .github/workflows/*.yml)
validate | init the working-tree ${PREGATE_CONFIG_PATH}
sealed [--range <a..b>] [--remote <name>] [--base <branch>]
@@ -61,7 +64,8 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
--allow-repo-commands run pin-file commands and the test runner (what /ship passes); without it those checks are incomplete
--policy-from base|head base (default, reviewed) or head (unreviewed; needs --allow-repo-commands)
--out <file> pregate.json path (default ${PREGATE_OUT_REL})
--timeout <s> per-check timeout (default 600): it bounds the whole check, every runner process included
--timeout <s> timeout for each runner process (default 600): the unit-file group, each serial file, each guard
without timeout_s; the stall timeout catches a silent one sooner
--stall-timeout <s> kill a test command's process group after this long with no output and record the check
incomplete (PREGATE_STALLED); default stall_timeout_s from the base .gstack/pregate.json,
else ${DEFAULT_STALL_TIMEOUT_S}; 0 disables
@@ -87,7 +91,7 @@ function flag(args: string[], name: string): string | undefined { const i = args
const VERB_FLAGS: Record<string, [string[], string[]]> = {
run: [['--stage', '--only', '--base', '--policy-from', '--out', '--timeout', '--stall-timeout', '--spend-cap', '--run'], ['--allow-repo-commands', '--explain', '--json', '--remote']],
verify: [['--out'], ['--json']],
clear: [['--remote', '--run-url', '--bundle-lane', '--out'], []],
clear: [['--remote', '--run-url', '--bundle-lane', '--receipt', '--out'], []],
lanes: [[], ['--explain', '--json']],
validate: [[], ['--json']],
init: [[], ['--explain', '--force']],
@@ -191,16 +195,19 @@ function cmdVerify(root: string, rest: string[], json: boolean): number {
function cmdClear(root: string, rest: string[]): number {
const lane = flag(rest, '--remote');
const receipt = flag(rest, '--run-url') ?? (flag(rest, '--bundle-lane') ? `bundle-lane:${flag(rest, '--bundle-lane')}` : undefined);
if (!lane || !receipt) { process.stderr.write(`gstack-pregate clear: --remote <workflow.yml/job> and --run-url <url> (or --bundle-lane <label>) are required\n`); return EXIT.usage; }
if (!lane) { process.stderr.write(`gstack-pregate clear: --remote <workflow.yml/job> with --run-url <url>, --bundle-lane <label> or (for ${REMOTE_GATE_LANE}) --receipt <file> is required\n`); return EXIT.usage; }
const out = outPath(root, rest);
const read = fs.existsSync(out) ? readPregateFile(out) : { file: null, errors: [] };
if (!read.file) { process.stderr.write(resultLine('gstack-pregate clear', 'PREGATE_STALE', `${out} is missing or invalid; run gstack-pregate first`) + '\n'); return EXIT.fail; }
let file: PregateFile;
try { file = clearObligation(read.file, lane, receipt, `gstack-pregate clear ${new Date().toISOString()}`); }
catch (e: any) { process.stderr.write(`gstack-pregate clear: ${e.message}\n`); return EXIT.usage; }
const decision = clearDecision(read.file, lane, { runUrl: flag(rest, '--run-url'), bundleLane: flag(rest, '--bundle-lane'), receiptPath: flag(rest, '--receipt') });
if ('refused' in decision) {
if (decision.refused === 'usage') { process.stderr.write(`gstack-pregate clear: ${decision.message}\n`); return EXIT.usage; }
process.stderr.write(`gstack-pregate clear: receipt refused — ${decision.message}; fix: ${RESULT_CODES.PREGATE_RECEIPT_INVALID.fix} ${resultCodeSuffix('PREGATE_RECEIPT_INVALID')}\n`);
return EXIT.fail;
}
const file = clearObligation(read.file, lane, decision.receipt, `gstack-pregate clear ${new Date().toISOString()}`);
writePregateFile(root, out, file);
process.stdout.write(`PREGATE_CLEARED: ${lane} receipt=${receipt} verdict=${file.verdict}\n`);
process.stdout.write(`PREGATE_CLEARED: ${lane} receipt=${decision.receipt} verdict=${file.verdict}${decision.attested ? ` (attested, not validated: ${lane} has no machine-checkable receipt)` : ''}\n`);
return EXIT.ok;
}
+16 -8
View File
@@ -127,9 +127,10 @@ Two stages with separate clocks, both printed:
the project's `engines.bun` (the row prints `runtime: bun <v> from <dir>`),
so a Capy host's older image Bun cannot flip the verdict.
- Every test-tier command (the lanes runner, each guard, the `--remote` gate)
runs supervised: see [supervision](#supervision). `--timeout` bounds the
whole check (all runner processes together); a serial file that would start
after the deadline is not started and the check is `incomplete`.
runs supervised: see [supervision](#supervision). `--timeout` bounds each
runner process (the unit-file group, each serial file, each guard without
its own `timeout_s`), not their total; the stall timeout catches a hung or
silent one sooner.
- Under 8 cores, a base-branch `remote_gate` or a remote `test_backend`
(`gstack-config set test_backend ubicloud`) records the local test tier as a
`requires-remote` obligation instead of running it: see [remote gate](#remote-gate).
@@ -157,9 +158,10 @@ gstack lacks never gets a green it did not ask for). Tier 2 checks still to come
## Supervision: progress, stalls, deadlines
GBRA-95 (gbrain, 4 cores): a core edit selected 3,683 local files, 358 of them
serial; the test stage printed nothing for 90 minutes because each runner
process had its own `--timeout` and its output was buffered until it returned.
Now each test-tier command:
serial; the test stage printed nothing for 90 minutes: each runner process
ran up to its own `--timeout` (one per serial file) with its output buffered
until it returned, and nothing noticed a process that had gone quiet. Now each
test-tier command:
- runs in its own process group (`setsid`), with stdout and stderr appended to
a log file, never a pipe, so a backgrounded grandchild that keeps the output
@@ -182,7 +184,9 @@ Now each test-tier command:
(`--stall-timeout <s>`, else `stall_timeout_s`, else 300; `0` disables): the
check is `incomplete` with `PREGATE_STALLED` and names the last file
started; the next serial file still runs;
- is killed as **timed out** at the check's `--timeout` deadline.
- is killed as **timed out** when that runner process reaches `--timeout`
(per runner process, so many serial files that each fit still pass); the
check is `incomplete` and the next serial file still runs.
A kill is SIGTERM to the group, then SIGKILL after 5 s (a group that ignores
SIGTERM still dies). SIGINT or SIGTERM to `gstack-pregate` itself SIGKILLs every
@@ -202,7 +206,11 @@ run, `pregate.json/remote_gate` (the declared command) or
<repo> -- '<runner> <files>'`). Without either, the tier runs locally under
the supervision above. Close the obligation one of two ways:
- `gstack-pregate clear --remote <lane> --run-url <url>` after running it; or
- after running it, `gstack-pregate clear --remote pregate.json/remote_gate
--receipt <file>` with the gate's output, validated as `--remote` validates it
(a run URL is refused, `PREGATE_RECEIPT_INVALID` for a bad receipt), or
`gstack-pregate clear --remote test_backend/<backend> --run-url <url>`, which
has no machine-checkable receipt and is recorded as `attested:<url>`; or
- `gstack-pregate --stage tests --remote --spend-cap <usd> --allow-repo-commands`,
which runs the declared `remote_gate` now (never under `--policy-from head`;
a `test_backend` without `remote_gate` cannot be run this way). The gate runs
+14
View File
@@ -2445,6 +2445,20 @@ child, a server that never exits, a synchronous wait), fix it and rerun
needs a larger `--stall-timeout`; `0` disables the stall kill and leaves only
`--timeout`.
<a id="pregate-receipt-invalid"></a>
### `PREGATE_RECEIPT_INVALID`
**Meaning.** `gstack-pregate clear --remote pregate.json/remote_gate --receipt
<file>` read the remote gate's output and its `PREGATE_REMOTE:` line is missing,
names another tree than the one `pregate.json` tested, reports 0 tests, or
reports failures. The obligation stays uncleared. A run URL alone never clears
this obligation; a `test_backend/<backend>` obligation takes a run URL, recorded
as attested rather than validated.
**Fix.** Run the remote gate on the tree pregate tested and pass its output
file with `--receipt`, or let `gstack-pregate --stage tests --remote --spend-cap
<usd> --allow-repo-commands` run and validate it.
<a id="pregate-stale"></a>
### `PREGATE_STALE`
+38 -26
View File
@@ -49,13 +49,13 @@ export function parseSummary(output: string): RunnerSummary {
return { ran: null, failed: null };
}
interface LocalRun { exit: number | null; outcome: Supervised['outcome'] | 'not-started'; ran: number | null; failed: number | null; tail: string[]; command: string; files: string[]; lastFile: string | null; ms: number }
interface LocalRun { exit: number | null; outcome: Supervised['outcome']; ran: number | null; failed: number | null; tail: string[]; command: string; files: string[]; lastFile: string | null; ms: number }
const quote = (f: string) => `'${f.replace(/'/g, `'\\''`)}'`;
async function runOnce(ctx: PregateContext, env: NodeJS.ProcessEnv, runner: string, files: string[], log: string, deadline: number, counter: Counter): Promise<LocalRun> {
async function runOnce(ctx: PregateContext, env: NodeJS.ProcessEnv, runner: string, files: string[], log: string, counter: Counter): Promise<LocalRun> {
const command = runner.replace('{files}', files.map(quote).join(' '));
const r = await supervise({ command, cwd: ctx.repoRoot, env: { ...env, CI: env.CI ?? '1' }, log, deadline, stallMs: ctx.stallTimeoutMs, label: `lanes (${files.length === 1 ? files[0] : `${files.length} files`})`, files, adapter: adapterFor(runner), counter, progress: ctx.progress });
const r = await supervise({ command, cwd: ctx.repoRoot, env: { ...env, CI: env.CI ?? '1' }, log, deadline: Date.now() + ctx.timeoutMs, stallMs: ctx.stallTimeoutMs, label: `lanes (${files.length === 1 ? files[0] : `${files.length} files`})`, files, adapter: adapterFor(runner), counter, progress: ctx.progress });
const output = readRange(log, r.start, r.end);
const summary = parseSummary(output);
return { exit: r.exit, outcome: r.outcome, ...summary, tail: output.trim().split('\n').slice(-25), command, files, lastFile: r.lastFile, ms: r.ms };
@@ -64,8 +64,8 @@ async function runOnce(ctx: PregateContext, env: NodeJS.ProcessEnv, runner: stri
/**
* Unit files in one runner process, then each serial-pattern file in its
* own (a serial file shares process-wide state: a module mock, an env
* mutation). `--timeout` bounds the whole check: a group that would start
* after the deadline is not started. A stalled group is killed and the next
* mutation). `--timeout` bounds each runner process; the stall timeout
* catches a silent one. A stalled or timed-out group is killed and the next
* one runs. The first unfinished, then the first non-zero run decides the
* exit; counts add up only when every run reported a summary.
*/
@@ -75,29 +75,26 @@ async function runLocal(ctx: PregateContext, env: NodeJS.ProcessEnv, runner: str
fs.writeFileSync(log, '');
const serial = files.filter(f => matchesAny(ctx.config.serial_patterns, f));
const unit = files.filter(f => !serial.includes(f));
const deadline = Date.now() + ctx.timeoutMs;
const counter: Counter = { done: 0, total: files.length };
const runs: LocalRun[] = [];
for (const group of [...(unit.length ? [unit] : []), ...serial.map(f => [f])]) {
if (Date.now() >= deadline) { runs.push({ exit: null, outcome: 'not-started', ran: null, failed: null, tail: [], command: '', files: group, lastFile: null, ms: 0 }); continue; }
runs.push(await runOnce(ctx, env, runner, group, log, deadline, counter));
runs.push(await runOnce(ctx, env, runner, group, log, counter));
}
const bad = runs.find(r => r.exit === null) ?? runs.find(r => r.exit !== 0);
const sum = (k: 'ran' | 'failed') => (runs.some(r => r[k] === null) ? null : runs.reduce((n, r) => n + (r[k] as number), 0));
const started = runs.filter(r => r.outcome !== 'not-started');
return { runs, bad, exit: bad ? bad.exit : 0, ran: sum('ran'), failed: sum('failed'), log, tail: (bad?.tail.length ? bad : started[started.length - 1] ?? runs[0]!).tail, command: started.map(r => r.command).join(' && '), serial: serial.length };
return { runs, bad, exit: bad ? bad.exit : 0, ran: sum('ran'), failed: sum('failed'), log, tail: (bad ?? runs[runs.length - 1]!).tail, command: runs.map(r => r.command).join(' && '), serial: serial.length };
}
/** Why the local lane did not finish: the stall, the deadline or a launch failure, with the last file started. */
/** Why the local lane did not finish: a stall, a runner process past --timeout or a launch failure, with the last file started. */
function unfinished(ctx: PregateContext, r: Awaited<ReturnType<typeof runLocal>>, total: number): Pick<CheckResult, 'code' | 'detail' | 'fix'> {
const notStarted = r.runs.filter(x => x.outcome === 'not-started').flatMap(x => x.files).length;
const stalled = r.runs.filter(x => x.outcome === 'stalled');
const rest = `${notStarted ? `; ${notStarted} of ${total} file(s) not started` : ''}${r.failed ? `; ${r.failed} failed before that` : ''}`;
const timedOut = r.runs.filter(x => x.outcome === 'timeout');
const rest = r.failed ? `; ${r.failed} failed` : '';
if (stalled.length) {
return { code: 'PREGATE_STALLED', detail: `no test output for ${ctx.stallTimeoutMs / 1000} s; process group killed; last file started: ${stalled.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest}`, fix: `read ${r.log} for the stalled file; fix the hang, or raise --stall-timeout (stall_timeout_s in .gstack/pregate.json; 0 disables) for a test that is quiet for longer` };
}
const why = r.runs.find(x => x.outcome === 'spawn-failed') ? 'the runner could not be launched' : `local lane timed out after ${ctx.timeoutMs} ms`;
return { code: 'PREGATE_INCOMPLETE', detail: `${why} (${total} files${rest})`, fix: 'raise --timeout or split the selection; a timed-out lane never passes' };
if (!timedOut.length) return { code: 'PREGATE_INCOMPLETE', detail: `the runner could not be launched (${total} files${rest})`, fix: 'read the lane log, fix the runner command, then rerun gstack-pregate --stage tests' };
return { code: 'PREGATE_INCOMPLETE', detail: `a runner process timed out after ${ctx.timeoutMs} ms (--timeout applies to each runner process; ${total} files; last file started: ${timedOut.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest})`, fix: 'raise --timeout or split the selection; a timed-out lane never passes' };
}
function describe(sel: Selection): string[] {
@@ -120,10 +117,29 @@ function remoteObligation(ctx: PregateContext, sel: Selection): RemoteObligation
const declared = ctx.config.remote_gate;
const runner = detectRunner(ctx, sel.localTests);
const command = declared ?? `${GSTACK_ROOT}/scripts/ubicloud/ubi-runner.sh run --src ${quote(ctx.repoRoot)} -- ${quote(runner ? runner.replace('{files}', sel.localTests.map(quote).join(' ')) : '<test runner> <selected files>')}`;
const [workflow, job] = declared ? ['pregate.json', 'remote_gate'] : ['test_backend', ctx.testBackend];
const [workflow, job] = declared ? REMOTE_GATE_LANE.split('/') as [string, string] : ['test_backend', ctx.testBackend];
return { lane: `${workflow}/${job}`, platform: 'remote', workflow, job, tests: sel.localTests, receipt: null, command };
}
export const REMOTE_GATE_LANE = 'pregate.json/remote_gate';
/** The `clear` command for an obligation: the remote gate's own output for remote_gate, a run URL otherwise. */
export const clearHint = (lane: string) => `gstack-pregate clear --remote ${lane} ${lane === REMOTE_GATE_LANE ? '--receipt <gate output file>' : '--run-url <url>'}`;
export interface RemoteReport { tree: string; tests: number; failed: number; spent?: string }
/** The last `PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n> [spent=<usd>]` line in a remote gate's output. */
export function parseRemoteReport(output: string): RemoteReport | null {
const m = [...output.matchAll(/^PREGATE_REMOTE: tree=(\S+) tests=(\d+) failed=(\d+)(?: spent=(\d+(?:\.\d+)?))?\s*$/gm)].pop();
return m ? { tree: m[1]!, tests: Number(m[2]), failed: Number(m[3]), spent: m[4] } : null;
}
/** Why a remote gate report does not clear the obligation for `tree` (`--remote` and `clear --receipt`), or null when it does. */
export function remoteReportProblem(report: RemoteReport, tree: string): string | null {
if (report.tree !== tree) return `the remote gate tested tree ${report.tree.slice(0, 12)}, not ${tree.slice(0, 12)}`;
if (report.tests === 0) return 'the remote gate reported 0 tests';
if (report.failed > 0) return `the remote gate reported ${report.failed} failed of ${report.tests}`;
return null;
}
/**
* `--remote`: run the declared remote gate now, under the spend cap. The
* gate gets PREGATE_TREE, PREGATE_FILES_FILE and PREGATE_SPEND_CAP_USD and
@@ -144,22 +160,18 @@ async function runRemote(ctx: PregateContext, sel: Selection, base: CheckResult)
fs.writeFileSync(filesFile, sel.localTests.join('\n') + '\n');
const r = await supervise({ command: obligation.command!, cwd: ctx.repoRoot, env: { ...ctx.env, PREGATE_TREE: ctx.wtree, PREGATE_FILES_FILE: filesFile, PREGATE_SPEND_CAP_USD: String(cap) }, log, deadline: Date.now() + ctx.timeoutMs, stallMs: ctx.stallTimeoutMs, label: `remote gate ${obligation.lane}`, progress: ctx.progress });
const output = readRange(log, r.start, r.end);
const reported = [...output.matchAll(/^PREGATE_REMOTE: tree=(\S+) tests=(\d+) failed=(\d+)(?: spent=(\d+(?:\.\d+)?))?\s*$/gm)].pop();
const spent = reported?.[4];
const reported = parseRemoteReport(output);
const spent = reported?.spent;
const spend = `spend: spent ${spent ? `$${spent}` : '$0'}, reserved $0, unknown ${spent ? '$0' : `$${cap} (no spent= reported; counted at the cap)`} (cap $${cap})`;
ctx.progress(`PREGATE_REMOTE_SPEND: ${spend.slice('spend: '.length)}`);
const runLines = [...lines, `remote gate: ${obligation.command}`, `log: ${log}`, spend, ...output.trim().split('\n').slice(-15).map(l => ` ${l}`)];
const inputs = { ...base.inputs, command: sha256(obligation.command!) };
const why = r.outcome !== 'exited' ? `the remote gate did not finish (${r.outcome === 'stalled' ? `no output for ${ctx.stallTimeoutMs / 1000} s` : r.outcome})`
: !reported ? `the remote gate exited ${r.exit} without a PREGATE_REMOTE: line`
: reported[1] !== ctx.wtree ? `the remote gate tested tree ${reported[1]!.slice(0, 12)}, not ${ctx.wtree.slice(0, 12)}`
: Number(reported[2]) === 0 ? 'the remote gate reported 0 tests'
: Number(reported[3]) > 0 ? `the remote gate reported ${reported[3]} failed of ${reported[2]}`
: r.exit !== 0 ? `the remote gate exited ${r.exit}`
: null;
: remoteReportProblem(reported, ctx.wtree) ?? (r.exit !== 0 ? `the remote gate exited ${r.exit}` : null);
if (why) return { ...base, inputs, lines: runLines, status: 'incomplete', remote: [...sel.remote, obligation], code: r.outcome === 'stalled' ? 'PREGATE_STALLED' : 'PREGATE_INCOMPLETE', detail: `${why}; requires-remote ${obligation.lane} stays uncleared`, fix: `read ${log}; the obligation clears only on PREGATE_REMOTE: tree=${ctx.wtree.slice(0, 12)}… tests>0 failed=0 with exit 0` };
const cleared = { ...obligation, receipt: `remote-gate tree=${ctx.wtree.slice(0, 12)} tests=${reported![2]}`, cleared_by: `gstack-pregate --remote ${new Date().toISOString()}` };
return { ...base, inputs, lines: runLines, status: 'requires-remote', remote: [...sel.remote, cleared], detail: `remote gate ${obligation.lane}: ${reported![2]} tests, 0 failed on tree ${ctx.wtree.slice(0, 12)}; ${spend}` };
const cleared = { ...obligation, receipt: `remote-gate tree=${ctx.wtree.slice(0, 12)} tests=${reported!.tests}`, cleared_by: `gstack-pregate --remote ${new Date().toISOString()}` };
return { ...base, inputs, lines: runLines, status: 'requires-remote', remote: [...sel.remote, cleared], detail: `remote gate ${obligation.lane}: ${reported!.tests} tests, 0 failed on tree ${ctx.wtree.slice(0, 12)}; ${spend}` };
}
async function run(ctx: PregateContext): Promise<CheckResult> {
@@ -177,7 +189,7 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
const smallMachine = ctx.cores < SMALL_MACHINE_CORES && sel.localTests.length > 0 && (ctx.config.remote_gate || ctx.testBackend !== 'local');
if (smallMachine) {
const obligation = remoteObligation(ctx, sel);
return { ...base, status: 'requires-remote', remote: [...sel.remote, obligation], lines: [...lines, `requires-remote ${obligation.lane}: ${ctx.cores} cores (under ${SMALL_MACHINE_CORES}); ${sel.localTests.length} local test file(s) recorded for the remote gate, not run here`, `run: ${obligation.command}`], detail: `${sel.localTests.length} local test file(s) recorded as requires-remote ${obligation.lane} (${ctx.cores} cores, under ${SMALL_MACHINE_CORES}); run it, then gstack-pregate clear --remote ${obligation.lane} --run-url <url>, or gstack-pregate --remote --spend-cap <usd> --allow-repo-commands` };
return { ...base, status: 'requires-remote', remote: [...sel.remote, obligation], lines: [...lines, `requires-remote ${obligation.lane}: ${ctx.cores} cores (under ${SMALL_MACHINE_CORES}); ${sel.localTests.length} local test file(s) recorded for the remote gate, not run here`, `run: ${obligation.command}`], detail: `${sel.localTests.length} local test file(s) recorded as requires-remote ${obligation.lane} (${ctx.cores} cores, under ${SMALL_MACHINE_CORES}); run it, then ${clearHint(obligation.lane)}, or gstack-pregate --remote --spend-cap <usd> --allow-repo-commands` };
}
if (!ctx.allowRepoCommands) return { ...base, status: 'incomplete', code: 'REPO_COMMANDS_NOT_ALLOWED', detail: `${sel.localTests.length} local test file(s) selected but not run (pass --allow-repo-commands)`, fix: 'rerun with --allow-repo-commands (what /ship passes)' };
if (sel.localTests.length === 0) return { ...base, status: sel.remote.length ? 'requires-remote' : 'pass', detail: `no local lane; ${sel.remote.length} requires-remote obligation(s)` };
+38 -2
View File
@@ -10,12 +10,13 @@
* land: `guards` (field wave CD1); ratchets, lint, links, hermetic, patches
* and integrator later.
*/
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { ARTIFACT_SCHEMAS, checkSchema, type PregateFile, type PregateRemote, type ValidationError } from '../headless-artifacts';
import { wtreeFingerprint } from '../regen';
import { guardsCheck } from './checks/guards';
import { lanesCheck } from './checks/lanes';
import { clearHint, lanesCheck, parseRemoteReport, REMOTE_GATE_LANE, remoteReportProblem } from './checks/lanes';
import { literalsCheck } from './checks/literals';
import { regenCheck } from './checks/regen';
import { secretsCheck } from './checks/secrets';
@@ -86,7 +87,7 @@ export function renderTable(file: PregateFile, opts: { lines?: boolean } = { lin
].filter(Boolean);
const remote = (file.requires_remote ?? []).map(r => {
const run = r.command ? `run ${r.command.length > 200 ? `${r.command.slice(0, 200)}… (full command in pregate.json)` : r.command}, then ` : '';
return `requires-remote: ${r.lane} (${r.platform}) ${r.tests.length} test(s) — ${r.receipt ? `cleared by ${r.receipt}` : `uncleared; ${run}clear with gstack-pregate clear --remote ${r.lane} --run-url <url>`}`;
return `requires-remote: ${r.lane} (${r.platform}) ${r.tests.length} test(s) — ${r.receipt ? `cleared by ${r.receipt}` : `uncleared; ${run}clear with ${clearHint(r.lane)}`}`;
});
return [
`stage check status time detail`,
@@ -134,6 +135,41 @@ export function verifyPregate(repoRoot: string, file: PregateFile, treeNow = wtr
return { ok: true, message: `pregate ${file.verdict} is current for tree ${file.tree.slice(0, 12)}`, current, uncleared };
}
export interface ClearInput { runUrl?: string; bundleLane?: string; receiptPath?: string }
export type ClearDecision = { receipt: string; attested: boolean } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string };
/**
* What `clear` may record for one obligation. A `pregate.json/remote_gate`
* obligation needs the gate's own report (`--receipt <file>` holding its
* PREGATE_REMOTE: line), validated as `--remote` validates it against the
* tested tree; a run URL is not checkable there. A `test_backend/<backend>`
* obligation has no machine-checkable receipt, so its run URL is recorded as
* attested. Platform lanes take a run URL or an evidence-bundle lane label.
*/
export function clearDecision(file: PregateFile, lane: string, input: ClearInput): ClearDecision {
const list = file.requires_remote ?? [];
const target = list.find(r => r.lane === lane);
if (!target) return { refused: 'usage', message: `no requires-remote obligation named ${lane}; obligations: ${list.map(r => r.lane).join(', ') || '(none)'}` };
if (target.lane === REMOTE_GATE_LANE) {
if (!input.receiptPath) return { refused: 'usage', message: `${lane} clears only with --receipt <file> holding the remote gate's PREGATE_REMOTE: tree=… tests=… failed=… line (a run URL is not checkable); or rerun gstack-pregate --remote` };
let text: string;
try { text = fs.readFileSync(input.receiptPath, 'utf8'); }
catch (e: any) { return { refused: 'PREGATE_RECEIPT_INVALID', message: `cannot read ${input.receiptPath}: ${e.message}` }; }
const report = parseRemoteReport(text);
const problem = report ? remoteReportProblem(report, file.tree) : `${input.receiptPath} has no PREGATE_REMOTE: line`;
if (problem) return { refused: 'PREGATE_RECEIPT_INVALID', message: `${problem}; ${lane} stays uncleared` };
return { receipt: `remote-gate tree=${file.tree.slice(0, 12)} tests=${report!.tests} receipt=${path.resolve(input.receiptPath)} sha256=${createHash('sha256').update(text).digest('hex').slice(0, 12)}`, attested: false };
}
if (input.receiptPath) return { refused: 'usage', message: `--receipt applies only to ${REMOTE_GATE_LANE}; clear ${lane} with --run-url <url>${target.workflow === 'test_backend' ? '' : ' or --bundle-lane <label>'}` };
if (target.workflow === 'test_backend') {
if (!input.runUrl) return { refused: 'usage', message: `${lane} clears with --run-url <url>` };
return { receipt: `attested:${input.runUrl}`, attested: true };
}
const receipt = input.runUrl ?? (input.bundleLane ? `bundle-lane:${input.bundleLane}` : undefined);
if (!receipt) return { refused: 'usage', message: `${lane} clears with --run-url <url> or --bundle-lane <label>` };
return { receipt, attested: false };
}
/** Record the receipt that clears one obligation (a run URL or an evidence-bundle lane label). */
export function clearObligation(file: PregateFile, lane: string, receipt: string, by: string): PregateFile {
const list = file.requires_remote ?? [];
+5
View File
@@ -394,6 +394,11 @@ export const RESULT_CODES = {
summary: 'a test command printed nothing for the stall timeout, so its process group was killed; the check is incomplete, never pass',
fix: 'read the lane log for the last file started, fix the hang, or raise `--stall-timeout` (`stall_timeout_s`; 0 disables) for a test that is quiet for longer, then rerun `gstack-pregate --stage tests`',
},
PREGATE_RECEIPT_INVALID: {
anchor: 'pregate-receipt-invalid',
summary: 'the receipt offered to `gstack-pregate clear` for the remote gate obligation does not show the tested tree passing',
fix: 'rerun the remote gate on this tree and pass its output with `gstack-pregate clear --remote pregate.json/remote_gate --receipt <file>`, or run `gstack-pregate --stage tests --remote --spend-cap <usd> --allow-repo-commands`',
},
PREGATE_STALE: {
anchor: 'pregate-stale',
summary: 'pregate.json was recorded against a different tree than the one being published',
+39 -8
View File
@@ -71,16 +71,47 @@ describe('small machines record the test tier for the remote gate (C4)', () => {
expect((await lanesOn(gated(), { cores: 16, testBackend: 'local' })).lanes.status).toBe('pass');
});
test('`clear` discharges the recorded obligation like any requires-remote lane', () => {
test('`clear` on the remote_gate obligation needs the gate\'s PREGATE_REMOTE receipt for the tested tree; a run URL or a bad receipt leaves it uncleared', async () => {
const repo = gated();
const fileAt = path.join(repo.work, '.gstack/tmp/pregate.json');
return lanesOn(repo, { cores: 4, testBackend: 'local' }).then(({ file }) => {
fs.mkdirSync(path.dirname(fileAt), { recursive: true });
fs.writeFileSync(fileAt, JSON.stringify(file));
const cleared = runBin(repo, 'gstack-pregate', ['clear', '--remote', 'pregate.json/remote_gate', '--run-url', 'https://ci.example/run/1']);
expect(cleared.status).toBe(EXIT.ok);
expect(cleared.out).toContain('PREGATE_CLEARED: pregate.json/remote_gate receipt=https://ci.example/run/1 verdict=requires-remote cleared');
});
const { file } = await lanesOn(repo, { cores: 4, testBackend: 'local' });
fs.mkdirSync(path.dirname(fileAt), { recursive: true });
fs.writeFileSync(fileAt, JSON.stringify(file));
const clear = (args: string[]) => runBin(repo, 'gstack-pregate', ['clear', '--remote', 'pregate.json/remote_gate', ...args]);
const receiptOf = (line: string) => { const f = path.join(repo.root, `receipt-${Math.random().toString(36).slice(2)}.log`); fs.writeFileSync(f, `gate log\n${line}\n`); return f; };
const url = clear(['--run-url', 'https://ci.example/run/1']);
expect(url.status).toBe(EXIT.usage);
expect(url.err).toContain('clears only with --receipt <file>');
for (const [line, why] of [
[`PREGATE_REMOTE: tree=${'deadbeef'.repeat(8)} tests=3 failed=0`, 'the remote gate tested tree deadbeefdead'],
[`PREGATE_REMOTE: tree=${file.tree} tests=0 failed=0`, 'the remote gate reported 0 tests'],
[`PREGATE_REMOTE: tree=${file.tree} tests=3 failed=1`, 'the remote gate reported 1 failed of 3'],
['all green, trust me', 'has no PREGATE_REMOTE: line'],
] as const) {
const r = clear(['--receipt', receiptOf(line)]);
expect(r.status).toBe(EXIT.fail);
expect(r.err).toContain(why);
expect(r.err).toContain('(PREGATE_RECEIPT_INVALID; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#pregate-receipt-invalid)');
expect(JSON.parse(fs.readFileSync(fileAt, 'utf8')).requires_remote[0].receipt).toBeNull();
}
const ok = clear(['--receipt', receiptOf(`PREGATE_REMOTE: tree=${file.tree} tests=3 failed=0`)]);
expect(ok.status).toBe(EXIT.ok);
expect(ok.out).toMatch(new RegExp(`^PREGATE_CLEARED: pregate\\.json/remote_gate receipt=remote-gate tree=${file.tree.slice(0, 12)} tests=3 receipt=\\S+ sha256=[0-9a-f]{12} verdict=requires-remote cleared$`, 'm'));
expect(ok.out).not.toContain('attested');
});
test('`clear` on a test_backend obligation records the run URL as attested, not validated, and says so', async () => {
const repo = gated(BASE_PREGATE);
const fileAt = path.join(repo.work, '.gstack/tmp/pregate.json');
const { file, lanes } = await lanesOn(repo, { cores: 4, testBackend: 'ubicloud' });
expect(lanes.detail).toContain('gstack-pregate clear --remote test_backend/ubicloud --run-url <url>');
fs.mkdirSync(path.dirname(fileAt), { recursive: true });
fs.writeFileSync(fileAt, JSON.stringify(file));
expect(runBin(repo, 'gstack-pregate', ['clear', '--remote', 'test_backend/ubicloud', '--receipt', fileAt]).status).toBe(EXIT.usage);
const r = runBin(repo, 'gstack-pregate', ['clear', '--remote', 'test_backend/ubicloud', '--run-url', 'https://ci.example/run/7']);
expect(r.status).toBe(EXIT.ok);
expect(r.out).toContain('PREGATE_CLEARED: test_backend/ubicloud receipt=attested:https://ci.example/run/7 verdict=requires-remote cleared (attested, not validated: test_backend/ubicloud has no machine-checkable receipt)');
expect(JSON.parse(fs.readFileSync(fileAt, 'utf8')).requires_remote[0].receipt).toBe('attested:https://ci.example/run/7');
});
test('config: stall_timeout_s and remote_gate are typed', async () => {
+10 -7
View File
@@ -182,15 +182,18 @@ describe('gstack-pregate: the test stage never hangs silently', () => {
expect(alive(Number(fs.readFileSync(path.join(repo.root, 'bg.pid'), 'utf8')))).toBe(false);
}, 90_000);
test('--timeout bounds the whole check: serial files past the deadline are not started and the lane is incomplete', () => {
test('--timeout bounds each runner process: serial files that each fit run to a pass even when their total exceeds it; one that overruns is incomplete', () => {
const repo = makeFixtureRepo(); repos.push(repo);
const slow = "const test = require('node:test');\ntest('slow', async () => { await new Promise(r => setTimeout(r, 2500)); });\n";
const slow = "const test = require('node:test');\ntest('slow', async () => { await new Promise(r => setTimeout(r, 1500)); });\n";
commit(repo, { 'test/a.serial.test.js': slow, 'test/b.serial.test.js': slow, 'test/c.serial.test.js': slow });
const r = pregate(repo, ['--timeout', '4']);
expect(r.status).toBe(EXIT.fail);
const row = lanes(repo);
expect(row).toMatchObject({ status: 'incomplete', code: 'PREGATE_INCOMPLETE' });
expect(row.detail).toMatch(/local lane timed out after 4000 ms \(3 files; 1 of 3 file\(s\) not started\)/);
const r = pregate(repo, ['--timeout', '3']);
expect(r.status).toBe(EXIT.ok);
expect(lanes(repo)).toMatchObject({ status: 'pass' });
expect(lanes(repo).detail).toContain('3 tests ran in 3 file(s) (3 serial), exit 0');
commit(repo, { 'test/c.serial.test.js': "const test = require('node:test');\ntest('slower', async () => { await new Promise(r => setTimeout(r, 5000)); });\n" });
expect(pregate(repo, ['--timeout', '3']).status).toBe(EXIT.fail);
expect(lanes(repo)).toMatchObject({ status: 'incomplete', code: 'PREGATE_INCOMPLETE' });
expect(lanes(repo).detail).toContain('a runner process timed out after 3000 ms (--timeout applies to each runner process; 3 files; last file started: test/c.serial.test.js)');
}, 90_000);
test('a late tree mutation by a test\'s child is caught while the check runs, and a leftover child cannot mutate the tree after it', async () => {