ci: a cancelled Validate dispatch proves nothing to the review gate

Code review r1 (M1): validate-gate.mjs and merge-candidate's waitValidate
read a `cancelled` dispatch run on the material as red, so a dispatch
replaced by the next one in the `validate-dispatch-<ref>` concurrency
group would have returned the task S7→S6 for nothing — the same class
#511 fixed in release-gate.mjs. Cancelled runs are now ignored: the gate
follows the replacement dispatch, or starts its own when there is none.

Mutant: review-returns-task-on-cancelled-dispatch.

Issue: #510
User-Visible: no
This commit is contained in:
Codex
2026-09-09 18:33:24 +03:00
parent f6e64ba985
commit 00130e08aa
5 changed files with 42 additions and 3 deletions
@@ -58,7 +58,7 @@ CLI: `node scripts/validate-gate.mjs --repo=<owner/repo> --ref=<ветка> --sh
Алгоритм (`ops` инъекция как в `merge-candidate.mjs`):
1. `gh run list --workflow validate.yml --commit <sha> --json databaseId,status,conclusion,url,event,createdAt --limit 20`.
2. Кандидат в доказательства — `event == 'workflow_dispatch'` (только там мутанты могли быть запрошены). Завершённый не-success → `red`. Завершённый success — доказательство **только если** job «Мутанты по диффу» в нём исполнены и зелёные (`gh run view --json jobs`: есть ≥1 job с таким префиксом и все `success`); зелёный dispatch со `skipped` мутантами (чужой запуск с `mutants=false`) не доказательство — он игнорируется, и гейт запускает свой (ревью ТЗ r1, Medium 2). Незавершённый → ждать его.
2. Кандидат в доказательства — `event == 'workflow_dispatch'` (только там мутанты могли быть запрошены). Завершённый не-success → `red`. Завершённый success — доказательство **только если** job «Мутанты по диффу» в нём исполнены и зелёные (`gh run view --json jobs`: есть ≥1 job с таким префиксом и все `success`); зелёный dispatch со `skipped` мутантами (чужой запуск с `mutants=false`) не доказательство — он игнорируется, и гейт запускает свой (ревью ТЗ r1, Medium 2). Незавершённый → ждать его. Отменённый (`cancelled`) не доказывает ничего (#511, ревью кода r1 M1): его заменил следующий dispatch в той же concurrency-группе — гейт ждёт замену, а без неё запускает свой; то же в `waitValidate` слияния.
3. Нет подходящего → `gh workflow run validate.yml --ref <ref> -f full=false -f mutants=true`; затем ждать появления dispatch-прогона на `<sha>` до `VALIDATE_APPEAR_MS` (3 мин; используются константы `merge-candidate.mjs`). Если голова ветки за это время сменилась (появился dispatch-прогон на другом SHA) — `missing` с пояснением «материал сменился».
4. Ждать завершения до `VALIDATE_TOTAL_MS` (45 мин), опрос каждые 20 с; таймаут → `red` («не завершился за 45 минут»).
+3 -1
View File
@@ -140,7 +140,9 @@ export function realOps({ repo, token, workflow = 'validate.yml', sleep = (ms) =
while (now() - started < VALIDATE_TOTAL_MS) {
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);
// Отменённый прогон ничего не доказывает (#511): его заменил следующий
// dispatch на той же ветке — ждём его, а не красим кандидата.
const runs = all.filter((x) => (!event || x.event === event) && x.conclusion !== 'cancelled');
const run = runs.find((x) => x.databaseId === runId) || runs[0];
if (run) {
runId = run.databaseId;
+11
View File
@@ -8147,6 +8147,17 @@ const MUTANT_DEFINITIONS = [
replace: " // mutant: completed means green — a red dispatch falls through to the job check",
}],
},
{
id: 'review-returns-task-on-cancelled-dispatch',
guard: 'node --test test/validate-gate.test.mjs',
because: 'a dispatch cancelled by its replacement in the same concurrency group proves nothing; '
+ 'reading it as red sends the task back to S6 for no reason (#510 review r1 M1, #511)',
patches: [{
file: 'scripts/validate-gate.mjs',
find: " if (run.conclusion === 'cancelled') {",
replace: " if (false) { // mutant: cancelled counts as red",
}],
},
{
id: 'review-trusts-push-run-without-mutants',
guard: 'node --test test/validate-gate.test.mjs',
+9 -1
View File
@@ -48,7 +48,7 @@ export function provesMutants(jobs) {
*/
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 без исполненных мутантов
const ignored = new Set(); // завершённые dispatch, которые ничего не доказывают: отменённые и зелёные без мутантов
let tracked = null;
let dispatchedAt = null;
while (ops.now() - started < totalMs) {
@@ -57,6 +57,14 @@ export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_M
if (run) {
tracked = run.databaseId;
if (run.status === 'completed') {
if (run.conclusion === 'cancelled') {
// Отменённый прогон ничего не доказывает (#511, ревью r1 M1): его
// заменил другой dispatch в той же concurrency-группе — ждём его,
// а если замены нет, запускаем свой.
ignored.add(run.databaseId);
tracked = null;
continue;
}
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) — не доказательство
+18
View File
@@ -105,3 +105,21 @@ test('#510: the tracked dispatch run is followed even when a newer dispatch appe
assert.equal(outcome.result, 'green');
assert.equal(outcome.url, 'https://run/1');
});
test('#510 r1 M1: a cancelled dispatch proves nothing — the gate waits for its replacement instead of returning the task', async () => {
const cancelled = run({ databaseId: 1, conclusion: 'cancelled', url: 'https://run/cancelled' });
const replacement = run({ databaseId: 2, url: 'https://run/2' });
const fake = fakeOps({ snapshots: [[cancelled], [replacement, cancelled]] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, pollMs: 1000 });
assert.equal(outcome.result, 'green');
assert.equal(outcome.url, 'https://run/2');
assert.deepEqual(fake.dispatched, [], 'the replacement was already there — no extra dispatch');
});
test('#510 r1 M1: a cancelled dispatch with no replacement gets one dispatch, not a red verdict', async () => {
const cancelled = run({ databaseId: 1, conclusion: 'cancelled' });
const fake = fakeOps({ snapshots: [[cancelled], [cancelled], [run({ databaseId: 3, url: 'https://run/3' }), cancelled]] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, pollMs: 1000 });
assert.equal(outcome.result, 'green');
assert.deepEqual(fake.dispatched, ['issue/1']);
});