pregate: a carried run clears the remote gate through remote_gate_runs, so a paid-only remainder is not a dead end

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 01:51:22 +00:00
1 parent fbb3a5d946
commit dd877a9ca4
4 files changed
+112

No files matched your search

+1
View File
@@ -4,3 +4,4 @@ bump: minor
- CI is now the one full test run. When a verified CI run already passed at the exact, clean tree, `gstack-pregate`'s tests stage and /ship's test lanes use it instead of running the suite again (GSTA-258, R65 item 1). The tests stage looks up the newest verified run at HEAD under the base branch's `run_profiles` by itself (`CI_CARRY: accepted …`; `--no-ci-carry` turns it off). A /ship lane carries only when the base branch's `.gstack/pregate.json` maps its exact command in the new `ship_lanes_from` key, and only when the run's `tree.json` covers the lane's inventory; gstack maps `bun run test` to its Free Tests run. A carried lane prints `LANE: <label> carried run=<id>/<attempt>` and is recorded as an explicit CI-sourced evidence row (`gstack-evidence record-ci`), never as a local run. Dirty trees, fork runs, stale runs and a newer red or running run never carry.
- A lane that already passed at the same tree, command and runtime is reused instead of rerun (`gstack-evidence run --reuse`, the default from `gstack-ship-preflight` and `finish`): a newer failure vetoes an older pass, and the reused row keeps its original timestamp, so the 24-hour age limit still measures the real run.
- /ship runs beside CI: on a repo that maps its lanes, Phase 2 pushes early (`gstack-ship-release push --early` runs the secrets scan and the sealed guard first), `finish` defers a mapped lane it cannot carry yet (`EVIDENCE: DEFERRED`), and the new `gstack-ship-release accept` carries it from the green run at the final pushed head and reruns the pregate there. `gstack-ci-carry find|wait` checks or waits for that run, and the ship receipt records `ci_carried`. See docs/pregate.md#ci-carry.
- On a small machine, the remote gate obligation clears from the same carried run (through `remote_gate_runs` and its inventory check) when the files left over are only ones the gate skips, such as gstack's paid evals. Before, that remainder made the gate report `tests=0` and `clear --run` refuse it as `paid_only`, so nothing could clear it.
+11
View File
@@ -933,6 +933,17 @@ run. On a small machine a selection the run covers records no remote gate
obligation. It prints `CI_CARRY: accepted <receipt>` or `CI_CARRY: none
(<reason>)` on stderr; `--no-ci-carry` skips the lookup.
When files are left over, the remote gate obligation still clears from the
same run when the base declares `remote_gate_runs` naming that run's profile:
the run must pass every `clear --run` check, with the carried files counted
as selected files. So a remainder of only files the gate's `skipped` command
names (gstack's paid evals, which the gate itself reports as
`PREGATE_REMOTE_PAID_ONLY` with `tests=0`) clears with `skipped=<n>` in the
receipt instead of becoming an obligation nothing can clear. The line is
`CI_CARRY: cleared pregate.json/remote_gate <receipt>`, or `CI_CARRY:
pregate.json/remote_gate stays uncleared (<reason>)` when a check refuses, for
example a gate inventory file missing from the run's `tree.json`.
**/ship lanes: `ship_lanes_from`.** A lane carries only when the base branch's
`.gstack/pregate.json` maps its exact command (the evidence ledger binds the
command's sha256; a label alone never carries) to the profiles whose runs carry
+21
View File
@@ -103,9 +103,30 @@ export async function runPregate(ctx: PregateContext, o: RunOptions): Promise<Pr
requires_remote: remote,
};
file.verdict = verdictOf(file);
const gateLine = carryGate(ctx, file);
if (gateLine) ctx.progress(gateLine);
return file;
}
/**
* After a verified run carried part of the tests stage, that run clears the remote gate obligation under `clear --run`'s
* own checks (remote_gate_runs profiles, inventory coverage, skipped files). The obligation is checked as the carried
* files plus the rest: without that, a remainder of only gate-skipped files (gstack's paid evals) is refused paid_only,
* and the gate itself reports tests=0 for it, so nothing could ever clear it.
*/
function carryGate(ctx: PregateContext, file: PregateFile): string | null {
const gate = file.requires_remote?.find(r => r.lane === REMOTE_GATE_LANE && !r.receipt);
const carried = file.checks.find(c => c.id === 'lanes')?.from_run?.files;
if (!gate || !carried?.length || !ctx.fromRun || !('inventory' in ctx.fromRun.check)) return null;
const check = ctx.fromRun.check as RunCheck;
if (!check.gateRuns) return null;
const view: PregateFile = { ...file, requires_remote: file.requires_remote!.map(r => (r === gate ? { ...r, tests: [...new Set([...carried, ...r.tests])] } : r)) };
const d = clearByRuns(view, REMOTE_GATE_LANE, [ctx.fromRun.ref], check, ctx.repoRoot);
if (!('receipt' in d)) return `CI_CARRY: ${REMOTE_GATE_LANE} stays uncleared (${d.message})`;
clearObligation(file, REMOTE_GATE_LANE, d.receipt, 'ci-carry', d.substitution);
return `CI_CARRY: cleared ${REMOTE_GATE_LANE} ${d.receipt}`;
}
/** `pass`, `warn`, `requires-remote`, `incomplete` or `fail`: the worst status across checks, with uncleared obligations counted. */
export function verdictOf(file: PregateFile): string {
const statuses = new Set(file.checks.map(c => c.status));
+79
View File
@@ -0,0 +1,79 @@
/**
* CI carry and the remote gate (GSTA-258 A): after a verified CI run carries part of the tests stage on a small
* machine, the files left over can all be ones the remote gate never runs (gstack's paid evals). The gate itself then
* reports tests=0 (PREGATE_REMOTE_PAID_ONLY) and `clear --run` refuses paid_only, so the obligation could never clear.
* The carried run clears it instead, through remote_gate_runs and its inventory check. The run API is a fake.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import type { PregateFile } from '../lib/headless-artifacts';
import { buildContext } from '../lib/pregate/context';
import { clearDecision, runPregate, type RunCheck } from '../lib/pregate/index';
import type { ApiRun, RunProfile } from '../lib/run-evidence';
import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, type FixtureRepo } from './helpers/pregate-fixture';
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const LANE = 'pregate.json/remote_gate';
const NOW = new Date('2026-11-02T12:00:00Z');
const LOCAL_OS = process.platform === 'darwin' ? 'macOS' : 'Linux';
const PROFILE: RunProfile = { workflow: 'free-tests.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['unit'], artifact: 'tested-tree', os: LOCAL_OS };
const PAID = 'test/nightly/retry-heavy.test.js';
const FREE = ['test/retry.test.js', 'test/util.test.js'];
function fixture(): FixtureRepo {
const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, remote_gate: 'node scripts/remote-gate.js' } });
repos.push(repo);
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n', 'lib/retry.js': 'exports.MAX_RETRIES = 3;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\nexports.touched = true;\n' }, 'touch two subjects');
return repo;
}
/** Run 100 of free-tests.yml at HEAD, green, its tree.json listing `files`; the gate's inventory is FREE and its skipped command names PAID. */
function runCheck(repo: FixtureRepo, files: string[]): RunCheck {
const head = repo.git('rev-parse', 'HEAD');
const api: ApiRun = { id: 100, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', head_sha: head, path: '.github/workflows/free-tests.yml', created_at: '2026-11-02T10:00:00Z', updated_at: '2026-11-02T10:30:00Z', repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' } };
return {
repo: 'fx/fx', profiles: { 'ci-reuse/free-tests': PROFILE }, now: NOW,
gateRuns: { profiles: ['ci-reuse/free-tests'], inventory: 'INVENTORY', skipped: 'SKIPPED' },
inventory: (command, _tree, selected) => (command === 'SKIPPED' ? (selected ?? []).filter(f => f === PAID) : FREE),
deps: {
run: () => api, runsForHead: (_wf, sha) => (sha === head ? [api] : []), jobs: () => [{ name: 'unit', conclusion: 'success' }],
artifact: () => ({ run_id: 100, run_attempt: 1, event: 'workflow_dispatch', sha: head, head_sha: head, tree: repo.git('rev-parse', 'HEAD^{tree}'), os: [LOCAL_OS], files, tests: files.length * 4 }),
commitTree: sha => repo.git('rev-parse', `${sha}^{tree}`),
},
} as RunCheck;
}
async function carried(repo: FixtureRepo, check: RunCheck): Promise<{ file: PregateFile; progress: string[] }> {
const progress: string[] = [];
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: l => progress.push(l), cores: 4, testBackend: 'local' });
if (!('ctx' in loaded)) throw new Error('fixture context refused');
loaded.ctx.fromRun = { ref: '100', check };
return { file: await runPregate(loaded.ctx, { stage: 'tests' }), progress };
}
const gate = (file: PregateFile) => file.requires_remote?.find(r => r.lane === LANE);
describe.skipIf(process.platform === 'win32')('CI carry clears the remote gate on a small machine', () => {
// Value: protects=a carried run whose remainder is only gate-skipped (paid) files leaves no obligation nothing can clear;
// fails_when=the remainder stays a remote_gate obligation that the gate reports tests=0 for and clear --run refuses paid_only;
// why_new=GSTA-258 A, the PREGATE_REMOTE_PAID_ONLY dead end seen at 0fba5533 (112 paid files after run 41554380563); seam=runPregate with a fake run API
test('the carried run clears the gate when only skipped files remain, and the receipt counts them', async () => {
const repo = fixture();
const check = runCheck(repo, FREE);
const { file, progress } = await carried(repo, check);
const obligation = gate(file)!;
expect(obligation.tests).toEqual([PAID]);
expect(clearDecision({ ...file, requires_remote: [{ ...obligation, receipt: null }] }, LANE, { runs: ['100'], evidence: check }, repo.work)).toMatchObject({ refused: 'PREGATE_RECEIPT_INVALID', message: expect.stringContaining('reason=paid_only') });
expect([obligation.receipt, obligation.cleared_by]).toEqual([expect.stringMatching(new RegExp(`^remote-gate tree=${file.tree.slice(0, 12)} tests=8 skipped=1 runs=100/1 `)), 'ci-carry']);
expect(progress).toContain(`CI_CARRY: cleared ${LANE} ${obligation.receipt}`);
expect(file.verdict).toBe('requires-remote cleared');
}, 120_000);
// Value: protects=the carry clears the gate only under clear --run's own checks; fails_when=a run that misses an inventory file clears it
test('a run that misses a gate inventory file leaves the gate uncleared and says why', async () => {
const repo = fixture();
const { file, progress } = await carried(repo, runCheck(repo, ['test/util.test.js']));
expect(gate(file)?.receipt).toBeNull();
expect(progress).toContainEqual(expect.stringMatching(new RegExp(`^CI_CARRY: ${LANE} stays uncleared \\(REUSE: refused reason=inventory_uncovered`)));
}, 120_000);
});