ci: diff mutants only on request; the review pipeline proves them on the material before reviewing

Validate ran the three "Мутанты по диффу" shards on every push of every
branch: 48 of 56 job-hours on 08–09.09, most of them cancelled by the
next push. Mutants now run when asked — pull requests, the nightly
schedule, a push carrying a `Release:` trailer, or a dispatch with
`mutants=true` (classify-changes.mjs → `mutants_requested`); an ordinary
push runs the light checks only.

The proof moves to where it is consumed. process.yml gets a gate after
the #499 reuse step: on the code stage it looks for a dispatch Validate
run on the exact material SHA whose mutant jobs executed and passed
(scripts/validate-gate.mjs); none → it dispatches one and waits; red or
missing → the task goes back S7→S6 with the run link and the review
cycle is not spent. Spec stage and the reuse fast-path skip the gate
(`proceed=true`); all later steps branch on `proceed` in place of the
old conflict conjunct only. merge-candidate.mjs dispatches Validate on
the pushed candidate and waits for that dispatch run.

PROCESS.md/AGENTS.md: review does not start on red code; one handoff —
one push.

Mutants: mutants-run-on-every-push, review-starts-on-red-validate,
review-trusts-push-run-without-mutants, merge-waits-push-run-without-mutants.

Issue: #510
User-Visible: no
This commit is contained in:
Codex
2026-09-09 15:14:20 +00:00
committed by claude[bot]
parent 34c5c2d134
commit 97dfa457a4
14 changed files with 504 additions and 34 deletions
+21
View File
@@ -94,6 +94,20 @@ export function heavyGatesRequested({ eventName, headMessage, fullInput } = {})
return hasReleaseTrailer(headMessage);
}
/**
* Нужны ли мутанты по диффу (#510). За 08–09.09 они съели 86 % job-минут
* Validate, потому что бежали на каждом промежуточном пуше и отменялись
* следующим. Место мутантов — кандидат: ревью-конвейер и слияние кандидата
* запускают Validate по кнопке с `mutants=true`, ночной прогон и PR берут
* полный набор, кандидат беты несёт трейлер `Release:`. Обычный push — нет.
*/
export function mutantsRequested({ eventName, headMessage, fullInput, mutantsInput } = {}) {
if (eventName === 'pull_request') return true;
if (eventName === 'schedule') return true;
if (eventName === 'workflow_dispatch') return String(fullInput) === 'true' || String(mutantsInput) === 'true';
return hasReleaseTrailer(headMessage);
}
/** Трейлер `Release: vX.Y.Z` в конце сообщения коммита — признак кандидата. */
export function hasReleaseTrailer(message) {
return /^Release:\s*v?\d+\.\d+\.\d+\S*\s*$/m.test(String(message || ''));
@@ -119,6 +133,13 @@ if (invokedDirectly) {
fullInput: process.env.FULL_INPUT,
});
process.stdout.write(`heavy=${heavy ? 'true' : 'false'}\n`);
const mutants = mutantsRequested({
eventName: process.env.EVENT_NAME,
headMessage: process.env.HEAD_MESSAGE,
fullInput: process.env.FULL_INPUT,
mutantsInput: process.env.MUTANTS_INPUT,
});
process.stdout.write(`mutants_requested=${mutants ? 'true' : 'false'}\n`);
} else {
const all = process.argv.includes('--all');
const outputs = all ? classifyAll() : classifyChanges(readFileSync(0, 'utf8'));
+15 -5
View File
@@ -127,12 +127,20 @@ export function realOps({ repo, token, workflow = 'validate.yml', sleep = (ms) =
if (/stale info|rejected|fetch first|lease/i.test(r.stderr)) return false;
throw new Error(`git push ${ref}: ${r.stderr}`);
},
waitValidate: async (sha) => {
// Мутанты по диффу бегут только по запросу (#510): кандидат после ребейза —
// новое дерево, поэтому слияние запускает Validate с мутантами само и ждёт
// именно этот dispatch-прогон; push-прогон на том же SHA их не содержит.
dispatchValidate: (ref) => {
const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', 'mutants=true']);
if (r.status !== 0) throw new Error(`gh workflow run ${workflow}: ${r.stderr || r.stdout}`);
},
waitValidate: async (sha, { event = 'workflow_dispatch' } = {}) => {
const started = now();
let runId = null;
while (now() - started < VALIDATE_TOTAL_MS) {
const r = sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', 'databaseId,status,conclusion,url', '--limit', '5']);
const runs = r.status === 0 && r.stdout ? JSON.parse(r.stdout) : [];
const r = sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', 'databaseId,status,conclusion,url,event', '--limit', '10']);
const all = r.status === 0 && r.stdout ? JSON.parse(r.stdout) : [];
const runs = all.filter((x) => !event || x.event === event);
const run = runs.find((x) => x.databaseId === runId) || runs[0];
if (run) {
runId = run.databaseId;
@@ -200,8 +208,10 @@ export async function mergeCandidate({ branch, material, issue, ops, maxAttempts
tip = candidate;
if (!patchIdEqual) return finish(decideMerge({ fresh: true, devMoved: true, patchIdEqual: false }), { candidate, devNow });
ops.log(`Validate на кандидате ${candidate.slice(0, 8)} — ждём`);
const { result, url } = await ops.waitValidate(candidate);
// мутанты по диффу — по запросу (#510): dispatch на ветке, где теперь стоит кандидат
ops.dispatchValidate(branch);
ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);
const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });
let decision = decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: result, attempt, maxAttempts });
if (decision.action !== 'push') return finish(decision, { candidate, devNow, runUrl: url });
+45 -1
View File
@@ -3694,7 +3694,7 @@ const MUTANT_DEFINITIONS = [
+ 'it without a green Validate on that SHA is the false-green the audit reproduced (#492 §4)',
patches: [{
file: 'scripts/merge-candidate.mjs',
find: ' const { result, url } = await ops.waitValidate(candidate);',
find: " const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });",
replace: " const { result, url } = { result: 'green', url: 'skipped' }; // mutant: no validation",
}],
},
@@ -8125,6 +8125,50 @@ const MUTANT_DEFINITIONS = [
replace: ' // mutant: the warm factory is deferred like a cold import\n',
}],
},
{
id: 'mutants-run-on-every-push',
guard: 'node --test --test-name-pattern="#510" test/classify-changes.test.mjs',
because: 'mutants by diff belong to the review candidate, the PR, the nightly run and the beta '
+ 'candidate — an ordinary push must not spend 3×8 runner minutes on them (#510 AC1)',
patches: [{
file: 'scripts/classify-changes.mjs',
find: " if (eventName === 'workflow_dispatch') return String(fullInput) === 'true' || String(mutantsInput) === 'true';\n return hasReleaseTrailer(headMessage);\n}",
replace: " if (eventName === 'workflow_dispatch') return String(fullInput) === 'true' || String(mutantsInput) === 'true';\n return true; // mutant: every push\n}",
}],
},
{
id: 'review-starts-on-red-validate',
guard: 'node --test test/validate-gate.test.mjs',
because: 'a red dispatch run on the material must return the task without a review; treating '
+ 'any completed run as green spends the review cycle on code CI already rejected (#510 AC2)',
patches: [{
file: 'scripts/validate-gate.mjs',
find: " result: run.conclusion === 'success' ? 'green' : 'red',",
replace: " result: 'green', // mutant: completed means green",
}],
},
{
id: 'review-trusts-push-run-without-mutants',
guard: 'node --test test/validate-gate.test.mjs',
because: 'a green push run on the same SHA holds no mutants and is not proof; the gate must '
+ 'dispatch the mutant run instead of accepting it (#510 AC2)',
patches: [{
file: 'scripts/validate-gate.mjs',
find: " return run?.event === 'workflow_dispatch';",
replace: " return !!run; // mutant: any run counts",
}],
},
{
id: 'merge-waits-push-run-without-mutants',
guard: 'node --test test/merge-candidate.test.mjs',
because: 'the merged candidate is a new tree; the merge must dispatch the mutant run on it and '
+ 'wait for that run, not for the push run that carries no mutants (#510 AC3)',
patches: [{
file: 'scripts/merge-candidate.mjs',
find: " ops.dispatchValidate(branch);\n ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);\n const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });",
replace: " ops.log(`Validate на кандидате ${candidate.slice(0, 8)} — ждём`);\n const { result, url } = await ops.waitValidate(candidate); // mutant: push run, no dispatch",
}],
},
{
id: 'quota-counts-the-staged-upload-twice',
guard: 'node scripts/backend-test-guard.mjs '
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env node
/**
* Validate с мутантами на материале ревью — до того, как ревьюер потратит
* цикл (#510 §5).
*
* Мутанты по диффу бегут только по запросу (`validate.yml`, `mutants=true`), и
* доказательство для ревью — dispatch-прогон на точном SHA материала. Push-
* прогон на том же SHA зелёный не считается: в нём мутантов нет.
*
* node scripts/validate-gate.mjs --repo=<owner/repo> --ref=<ветка> --sha=<sha> [--workflow=validate.yml]
*
* Печатает `result=green|red|missing` и `url=…` (и в $GITHUB_OUTPUT, если он
* задан); код выхода 0 только при green. Логика — чистая функция `validateGate`
* поверх инъектируемых `ops`, чтобы тесты и мутанты гоняли её без gh.
*/
import { spawnSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { VALIDATE_APPEAR_MS, VALIDATE_TOTAL_MS } from './merge-candidate.mjs';
export const POLL_MS = 20_000;
/** Кандидат в доказательства: dispatch — только там мутанты могут быть запрошены. */
export function isMutantRun(run) {
return run?.event === 'workflow_dispatch';
}
export const MUTANT_JOB_PREFIX = 'Мутанты по диффу';
/**
* Зелёный dispatch доказывает мутанты, только если их job реально исполнены
* (ревью ТЗ r1): чужой dispatch с `mutants=false` на том же SHA тоже зелёный,
* но с `changed_mutants: skipped`. `validate.yml` при запросе исполняет job
* даже на пустом отборе, поэтому skipped однозначно значит «не запрашивали».
*/
export function provesMutants(jobs) {
const mutantJobs = (Array.isArray(jobs) ? jobs : []).filter((job) => String(job?.name || '').startsWith(MUTANT_JOB_PREFIX));
return mutantJobs.length > 0 && mutantJobs.every((job) => job.conclusion === 'success');
}
/**
* @param {object} p
* @param {string} p.ref ветка, на которой запускать
* @param {string} p.sha SHA материала
* @param {object} p.ops { listRuns(sha) → [{databaseId,status,conclusion,url,event,headSha}], listRunsOnRef(ref) → те же, jobs(runId) → [{name,conclusion}], dispatch(ref), sleep(ms), now() }
* @returns {Promise<{result:'green'|'red'|'missing', url:string|null, note:string}>}
*/
export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS }) {
const started = ops.now();
const ignored = new Set(); // завершённые зелёные dispatch без исполненных мутантов
let tracked = null;
let dispatchedAt = null;
while (ops.now() - started < totalMs) {
const runs = (await ops.listRuns(sha)).filter((x) => isMutantRun(x) && !ignored.has(x.databaseId));
const run = runs.find((x) => tracked && x.databaseId === tracked) || runs[0];
if (run) {
tracked = run.databaseId;
if (run.status === 'completed') {
if (run.conclusion !== 'success') return { result: 'red', url: run.url, note: `dispatch-прогон завершился: ${run.conclusion}` };
if (provesMutants(await ops.jobs(run.databaseId))) return { result: 'green', url: run.url, note: 'dispatch-прогон с исполненными мутантами зелёный' };
// зелёный, но мутанты не исполнялись (чужой dispatch без mutants=true) — не доказательство
ignored.add(run.databaseId);
tracked = null;
continue;
}
} else if (dispatchedAt === null) {
await ops.dispatch(ref);
dispatchedAt = ops.now();
} else if (ops.now() - dispatchedAt > appearMs) {
// Прогон должен был появиться. Если на ветке появился dispatch на другом
// SHA — материал сменился под ногами; иначе запуск просто не прошёл.
const elsewhere = (await ops.listRunsOnRef(ref)).filter(isMutantRun).find((x) => x.headSha && x.headSha !== sha);
return {
result: 'missing', url: elsewhere?.url || null,
note: elsewhere ? `материал сменился: dispatch-прогон стоит на ${String(elsewhere.headSha).slice(0, 8)}` : 'dispatch-прогон не появился за 3 минуты',
};
}
await ops.sleep(pollMs);
}
return { result: 'red', url: null, note: 'Validate с мутантами не завершился за 45 минут' };
}
const sh = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' });
export function realOps({ repo, workflow = 'validate.yml' }) {
const fields = 'databaseId,status,conclusion,url,event,headSha';
const parse = (r) => (r.status === 0 && r.stdout ? JSON.parse(r.stdout) : []);
return {
listRuns: async (sha) => parse(sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', fields, '--limit', '20'])),
jobs: async (runId) => {
const r = sh('gh', ['run', 'view', String(runId), '--repo', repo, '--json', 'jobs']);
return r.status === 0 && r.stdout ? (JSON.parse(r.stdout).jobs || []).map((job) => ({ name: job.name, conclusion: job.conclusion })) : [];
},
listRunsOnRef: async (ref) => parse(sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--branch', ref, '--event', 'workflow_dispatch', '--json', fields, '--limit', '5'])),
dispatch: async (ref) => {
const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', 'mutants=true']);
if (r.status !== 0) throw new Error(`gh workflow run: ${r.stderr || r.stdout}`);
},
sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
now: () => Date.now(),
};
}
const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
const repo = arg('repo') || process.env.GITHUB_REPOSITORY;
const ref = arg('ref');
const sha = arg('sha');
if (!repo || !ref || !sha) {
console.error('usage: validate-gate.mjs --repo=<owner/repo> --ref=<branch> --sha=<sha> [--workflow=validate.yml]');
process.exit(2);
}
const outcome = await validateGate({ ref, sha, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) });
const lines = [`result=${outcome.result}`, `url=${outcome.url || ''}`, `note=${outcome.note}`];
for (const line of lines) console.log(line);
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
process.exit(outcome.result === 'green' ? 0 : 1);
}