feat(pregate): a guard that already passed at this tree carries from CI or the ledger (GST-1186)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-03 05:55:57 +00:00
1 parent 5f705f367a
commit 8ce5f29e6f
5 files changed
+140 -8

No files matched your search

+34 -5
View File
@@ -5,6 +5,7 @@
*
* gstack-evidence run [--reuse [--max-age <hours>]] --label <L> -- <cmd...>
* gstack-evidence record-ci --label <L> --base <base> -- <cmd>
* gstack-evidence passed -- <cmd>
* gstack-evidence carried [--json]
* gstack-evidence check [--label <L> [--expect-cmd <exact string>]]... | --all
* [--max-age <hours>] [--allow-paths <csv>]
@@ -598,6 +599,35 @@ function identityDigest(): string {
return sha256(JSON.stringify({ bun: version("bun"), node: version("node"), os: process.platform, arch: process.arch, image: process.env.GSTACK_RUNNER_IMAGE || null }));
}
/** A row that passed with a counted run within the max age: a CI row (record-ci verified its run) or a local run under this machine's identity. */
function countedPass(r: EvidenceRecord, maxAgeHours: number): boolean {
if (r.exit !== 0 || typeof r.tests_ran !== "number" || r.tests_ran <= 0) return false;
const age = Date.now() - Date.parse(r.ts);
if (!(age >= 0 && age <= maxAgeHours * 3600_000)) return false;
return r.source === "ci" || r.identity === identityDigest();
}
/**
* `passed -- <cmd>` (GST-1186, read-only): did this exact command pass at the current working tree under any label? The
* newest row for the command hash and tree decides, so a newer failure vetoes; it must be a counted pass (countedPass). Prints
* PASSED: label=<L> source=<ci|local> ts=<ts> and exits 0, else NOT_PASSED: <reason> and exits 1. gstack-pregate's guards read it.
*/
function cmdPassed(argv: string[]): number {
const sep = argv.indexOf("--");
const command = sep < 0 ? "" : argv.slice(sep + 1).join(" ");
if (!command || sep !== 0) { console.error("usage: gstack-evidence passed -- <command>"); return 2; }
const wtree = currentWtree();
if (!wtree) { console.log("NOT_PASSED: no working-tree fingerprint"); return 1; }
let records: EvidenceRecord[] = [];
try { records = readJsonl<EvidenceRecord>(ledgerPath().file); } catch { records = []; }
const cmdSha = sha256(command);
const newest = records.findLast((r) => r.cmd_sha256 === cmdSha && r.wtree === wtree);
if (!newest) { console.log(`NOT_PASSED: no ledger row for this command at tree ${wtree.slice(0, 12)}`); return 1; }
if (!countedPass(newest, 24)) { console.log(`NOT_PASSED: the newest row (label=${newest.label} ts=${newest.ts}) is not a counted pass within 24 h under this runtime`); return 1; }
console.log(`PASSED: label=${newest.label} source=${newest.source ?? "local"} ts=${newest.reused_from ?? newest.ts}`);
return 0;
}
/**
* `run --reuse`: the newest local row for this label, command and working tree decides. A counted pass under the same
* identity within the max age is copied (original ts kept) and printed as a run would be; anything else runs the command.
@@ -612,10 +642,7 @@ function reused(file: string, label: string, cmdSha: string, maxAgeHours: number
return false;
}
const newest = records.findLast((r) => r.label === label && r.cmd_sha256 === cmdSha && r.wtree === wtree && r.source === undefined);
if (!newest || newest.exit !== 0 || typeof newest.tests_ran !== "number" || newest.tests_ran <= 0) return false;
const age = Date.now() - Date.parse(newest.ts);
if (!(age >= 0 && age <= maxAgeHours * 3600_000)) return false;
if (newest.identity !== identityDigest()) return false;
if (!newest || !countedPass(newest, maxAgeHours)) return false;
try {
appendJsonl(file, { ...newest, reused_from: newest.reused_from ?? newest.ts }, { mode: 0o600 });
} catch (e: any) {
@@ -912,6 +939,8 @@ try {
process.exit(await cmdRun(rest));
} else if (sub === "record-ci") {
process.exit(cmdRecordCi(rest));
} else if (sub === "passed") {
process.exit(cmdPassed(rest));
} else if (sub === "carried") {
process.exit(cmdCarried(rest));
} else if (sub === "check") {
@@ -923,7 +952,7 @@ try {
} else if (sub === "verify") {
process.exit(cmdVerify(rest));
} else {
console.error("usage: gstack-evidence run|record-ci|carried|check|bundle|ancestor|verify ...");
console.error("usage: gstack-evidence run|record-ci|passed|carried|check|bundle|ancestor|verify ...");
process.exit(2);
}
} catch (e: any) {
+5
View File
@@ -0,0 +1,5 @@
- A pregate guard whose exact command already passed at this tree no longer runs again (GST-1186). When the base branch's `ship_lanes_from` maps the command, it is carried from the verified CI run through the same `gstack-evidence record-ci` path /ship lanes use (`GUARD: <id> carried runs=…`); otherwise a counted ledger pass of that command at the same working tree stands (`GUARD: <id> reused …`, and the new read-only `gstack-evidence passed -- <command>`). Repos whose suites are guards, like founderstar, stop rerunning about six minutes of suites per /ship. See docs/pregate.md#guard-carry.
- Known limit: `ship_lanes_from` is still read from `origin/<base>` only, so the PR that adds a mapping cannot carry from it.
### Tests
- `test/ship-ci-carry.test.ts`: in founderstar's shape (guards only, no lanes), `check-quick` carries from CI, `play-through` reuses a same-tree ledger pass, `assets` runs, and a dirty tree carries nothing. It fails without the guard carry.
+28
View File
@@ -1080,6 +1080,34 @@ guard is a *command* with its own fix line. Older gstack refuses unknown keys
that reads them ships. The worked gbrain configuration is in
[gbrain worked configs](#gbrain-worked-configs).
<a id="guard-carry"></a>
### A guard that already passed at this tree does not run again
A guard is skipped as passed when its exact command already passed at this tree
(GST-1186). Two sources count, checked in this order:
1. **CI.** When the base branch's `ship_lanes_from` maps the guard's exact
command, the guard is carried from the verified CI run at HEAD's clean tree,
through the same `gstack-evidence record-ci` and `findCarry` path a /ship
lane uses ([ci-carry](#ci-carry)). It prints `GUARD: <id> carried
runs=<id>/<attempt>`. When no run carries, it prints `CI_CARRY: none
guard=<id> (<reason>)` and goes on to the ledger.
2. **The evidence ledger.** When the newest ledger row for the exact command at
this working tree, under any label, is a counted pass (a CI row, or a local
run with `tests_ran` above zero under this runtime, within 24 hours), the
guard prints `GUARD: <id> reused label=<L> source=<ci|local> ts=<ts>`.
`gstack-evidence passed -- <command>` is the same lookup by hand. A newer
failure of the command at that tree vetoes an older pass.
Anything else runs the guard as before, with the same timeout, stall rule and
fix line. This matters for repos whose suites are guards rather than lanes
(founderstar declares `check-quick`, `play-through` and `assets` as guards),
which otherwise reran every suite /ship had just carried from CI.
Known limit: `ship_lanes_from` is read from `origin/<base>` only, so the pull
request that adds a mapping cannot carry from it. Prove it with the dry run in
[proving a new mapping](#prove-a-mapping) until it lands.
<a id="allowlist"></a>
## Allowlist: benign findings the secrets check accepts
+34 -3
View File
@@ -11,6 +11,13 @@
* incident: a gbrain serial test landed without its manifest row and
* passed both the isolation check and pregate; `test/cli-goldens.test.ts`
* spawns the CLI as a subprocess, so no import graph selects it.
*
* GST-1186: a guard whose exact command already passed at this tree is not run
* again. #3901's carry (gstack-evidence record-ci) carries it from a verified
* CI run when the base's ship_lanes_from maps the command, else a counted
* ledger pass of the same command at the same working tree stands
* (gstack-evidence passed). ship_lanes_from is read from origin/<base> only, so
* the PR that adds a mapping cannot carry from it yet.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
@@ -23,8 +30,26 @@ import { checkScratch } from '../run-scratch';
import { readRange, STALL_IDLE, supervise } from '../supervise';
import type { EnvironmentCause } from '../types';
import type { CheckDef, CheckResult, PregateContext } from '../types';
import { runScriptSync } from '../../script-invocation';
interface GuardRun { guard: Guard; exit: number | null; timedOut: boolean; stalled: boolean; tail: string[]; log: string; environment: EnvironmentCause | null }
interface GuardRun { guard: Guard; exit: number | null; timedOut: boolean; stalled: boolean; tail: string[]; log: string; environment: EnvironmentCause | null; prior?: string; carryNone?: string }
const EVIDENCE = path.resolve(import.meta.dir, '..', '..', '..', 'bin', 'gstack-evidence');
/** The pass that already covers this guard's exact command at this tree (GST-1186): a CI carry, else a same-tree ledger pass. */
function priorPass(ctx: PregateContext, guard: Guard): { prior?: string; carryNone?: string } {
const evidence = (args: string[]) => runScriptSync(EVIDENCE, args, { cwd: ctx.repoRoot, env: ctx.env, encoding: 'utf8', timeout: 600_000 });
let carryNone: string | undefined;
if (Object.prototype.hasOwnProperty.call(ctx.config.ship_lanes_from, guard.command)) {
const ci = evidence(['record-ci', '--label', `guard-${guard.id}`, '--base', ctx.base, '--', guard.command]);
const runs = / source=ci runs=(\S+)/.exec(String(ci.stderr ?? ''))?.[1];
if (ci.status === 0 && runs) return { prior: `carried runs=${runs}` };
carryNone = /^CARRY_NONE: (.+)$/m.exec(String(ci.stderr ?? ''))?.[1] ?? `record-ci exit ${ci.status}`;
}
const local = evidence(['passed', '--', guard.command]);
const hit = /^PASSED: (.+)$/m.exec(String(local.stdout ?? ''))?.[1];
return local.status === 0 && hit ? { prior: `reused ${hit}`, carryNone } : { carryNone };
}
async function runGuard(ctx: PregateContext, guard: Guard, env: NodeJS.ProcessEnv, dir: string): Promise<GuardRun> {
const timeoutMs = guard.timeout_s ? guard.timeout_s * 1000 : ctx.timeoutMs;
@@ -53,9 +78,14 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
if (runtime.refusal) return { ...base, ...runtime.refusal, cause: 'environment', environment: launchCause('runtime', runtime.refusal.detail), lines: [`runtime: ${runtime.label}`, environmentLine(launchCause('runtime', runtime.refusal.detail))] };
const dir = checkScratch(ctx.scratch, 'guards');
const runs: GuardRun[] = [];
for (const g of selected) runs.push(await runGuard(ctx, g, runtime.env, dir));
for (const g of selected) {
const p = priorPass(ctx, g);
runs.push(p.prior ? { guard: g, exit: 0, timedOut: false, stalled: false, tail: [], log: '-', environment: null, ...p } : { ...(await runGuard(ctx, g, runtime.env, dir)), carryNone: p.carryNone });
}
const lines = [`runtime: ${runtime.label}`];
for (const r of runs) {
if (r.carryNone) lines.push(`CI_CARRY: none guard=${r.guard.id} (${r.carryNone})`);
if (r.prior) { lines.push(`GUARD: ${r.guard.id} ${r.prior}`); continue; }
lines.push(r.stalled ? `GUARD: ${r.guard.id} stalled: no output for ${ctx.stallTimeoutMs / 1000} s${STALL_IDLE}; process group killed (log ${r.log})` : r.timedOut ? `GUARD: ${r.guard.id} timed out after ${r.guard.timeout_s ?? ctx.timeoutMs / 1000} s (log ${r.log})` : `GUARD: ${r.guard.id} exit=${r.exit} (log ${r.log})`);
if (r.exit !== 0) lines.push(...r.tail.map(l => ` ${l}`));
if (r.environment) lines.push(` ${r.guard.id} ${environmentLine(r.environment)}`);
@@ -68,7 +98,8 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
if (failed.length) return { ...withRuns, status: 'fail', cause: 'diff', code: 'PREGATE_GUARD_FAILED', detail: `${failed.length} guard(s) failed: ${failed.map(r => r.guard.id).join(', ')}${timedOut.length ? `; timed out: ${timedOut.map(r => r.guard.id).join(', ')}` : ''}${blocked.length ? `; blocked by the environment: ${blocked.map(r => r.guard.id).join(', ')}` : ''}`, fix: fixOf(failed) };
if (blocked.length) return environmentResult(withRuns, blocked[0]!.environment!, `${blocked.length} guard(s) could not run on this machine: ${blocked.map(r => `${r.guard.id} (${r.environment!.kind})`).join(', ')}${timedOut.length ? `; timed out: ${timedOut.map(r => r.guard.id).join(', ')}` : ''}`);
if (timedOut.length) return { ...withRuns, status: 'incomplete', code: timedOut.some(r => r.stalled) ? 'PREGATE_STALLED' : 'PREGATE_INCOMPLETE', detail: `${timedOut.length} guard(s) did not finish: ${timedOut.map(r => `${r.guard.id} (${r.stalled ? 'stalled' : 'timed out'})`).join(', ')}`, fix: 'fix the hang, or raise the guard\'s timeout_s, --timeout or --stall-timeout; an unfinished guard never passes' };
return { ...withRuns, detail: `${runs.length} guard(s) passed: ${runs.map(r => r.guard.id).join(', ')}` };
const prior = runs.filter(r => r.prior).length;
return { ...withRuns, detail: `${runs.length} guard(s) passed: ${runs.map(r => r.guard.id).join(', ')}${prior ? ` (${prior} from an earlier pass at this tree)` : ''}` };
}
export const guardsCheck: CheckDef = { id: 'guards', stage: 'preflight', tier: 2, downgradable: false, needsRepoCommands: true, run };
+39
View File
@@ -192,3 +192,42 @@ describe('gstack-pregate tests stage and gstack-ci-carry', () => {
expect(cli(['bogus']).status).toBe(2);
});
});
describe('GST-1186: pregate guards carry like lanes (founderstar shape: guards only, no lanes)', () => {
// Value: protects=a guard whose exact command the base's ship_lanes_from maps is carried from the verified CI run at this tree, and a guard
// whose exact command already passed at this tree in the ledger is reused, so guard-only repos stop rerunning suites CI or /ship already ran;
// fails_when=check-quick reruns beside a green CI run, play-through reruns after a same-tree ledger pass, or a dirty tree still carries;
// why_new=FOUN-74 (founderstar GST-1184): about 6 minutes of guard suites rerun per /ship; seam=gstack-evidence record-ci and passed; no_claim=CI
const script = (name: string, tests: number) => `printf x >> ../${name}-runs\nprintf ' ${tests} pass\\n 0 fail\\nRan ${tests} tests across 1 file. [1.00ms]\\n' >&2\n`;
const CHECK = 'bash tests/check.sh --quick';
const PLAY = 'bash tests/play-through.sh';
const guard = (id: string, command: string) => ({ id, when: ['src/**'], command, fix: `rerun ${id}` });
const FOUNDERSTAR = {
...BASE,
'.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [CHECK]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\ntest/b.test.ts\\n'" } },
guards: [guard('check-quick', CHECK), guard('play-through', PLAY), guard('assets', 'bash tests/assets.sh')] }, null, 2) + '\n',
'tests/check.sh': script('check', 4), 'tests/play-through.sh': script('play', 2), 'tests/assets.sh': script('assets', 1),
};
const guards = (f: ShipFx) => f.run(path.join(BIN, 'gstack-pregate'), ['--only', 'guards', '--allow-repo-commands', '--base', 'main']);
const counts = (f: ShipFx) => [runs(f, 'check-runs'), runs(f, 'play-runs'), runs(f, 'assets-runs')];
test('check-quick carries from CI, play-through reuses a same-tree ledger pass, assets runs; a dirty tree carries nothing', () => {
const f = shipFixture(FOUNDERSTAR); fixtures.push(f);
world(f);
const first = guards(f);
expect(first.status, first.stdout + first.stderr).toBe(0);
expect(first.stdout).toContain('GUARD: check-quick carried runs=7/1');
expect(first.stdout).toContain('3 guard(s) passed: check-quick, play-through, assets (1 from an earlier pass at this tree)');
expect(counts(f)).toEqual([0, 1, 1]);
expect(f.run(path.join(BIN, 'gstack-evidence'), ['run', '--label', 'play', '--', PLAY]).status).toBe(0);
const second = guards(f);
expect(second.status).toBe(0);
expect(second.stdout).toMatch(/GUARD: play-through reused label=play source=local ts=\S+/);
expect(counts(f)).toEqual([0, 2, 2]);
f.write({ 'src/b.ts': 'export const b = 3;\n' });
const dirty = guards(f);
expect(dirty.stdout).toMatch(/CI_CARRY: none guard=check-quick \(dirty_tree/);
expect(dirty.stdout).not.toContain('reused');
expect(counts(f)).toEqual([1, 3, 3]);
});
});