From 00130e08aa247cd8796a99125deb7f9b5131bfa1 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 18:33:24 +0300 Subject: [PATCH] ci: a cancelled Validate dispatch proves nothing to the review gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-` 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 --- ...s-on-candidate-and-review-waits-validate.md | 2 +- scripts/merge-candidate.mjs | 4 +++- scripts/mutation-gate.mjs | 11 +++++++++++ scripts/validate-gate.mjs | 10 +++++++++- test/validate-gate.test.mjs | 18 ++++++++++++++++++ 5 files changed, 42 insertions(+), 3 deletions(-) diff --git a/docs/specs/510-mutants-on-candidate-and-review-waits-validate.md b/docs/specs/510-mutants-on-candidate-and-review-waits-validate.md index df99bed3..d68ed781 100755 --- a/docs/specs/510-mutants-on-candidate-and-review-waits-validate.md +++ b/docs/specs/510-mutants-on-candidate-and-review-waits-validate.md @@ -58,7 +58,7 @@ CLI: `node scripts/validate-gate.mjs --repo= --ref=<ветка> --sh Алгоритм (`ops` инъекция как в `merge-candidate.mjs`): 1. `gh run list --workflow validate.yml --commit --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 -f full=false -f mutants=true`; затем ждать появления dispatch-прогона на `` до `VALIDATE_APPEAR_MS` (3 мин; используются константы `merge-candidate.mjs`). Если голова ветки за это время сменилась (появился dispatch-прогон на другом SHA) — `missing` с пояснением «материал сменился». 4. Ждать завершения до `VALIDATE_TOTAL_MS` (45 мин), опрос каждые 20 с; таймаут → `red` («не завершился за 45 минут»). diff --git a/scripts/merge-candidate.mjs b/scripts/merge-candidate.mjs index 84344867..87ae3373 100755 --- a/scripts/merge-candidate.mjs +++ b/scripts/merge-candidate.mjs @@ -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; diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index d104b4fc..53acc115 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -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', diff --git a/scripts/validate-gate.mjs b/scripts/validate-gate.mjs index 34a58c55..7382b835 100755 --- a/scripts/validate-gate.mjs +++ b/scripts/validate-gate.mjs @@ -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) — не доказательство diff --git a/test/validate-gate.test.mjs b/test/validate-gate.test.mjs index 3789b58c..5538709f 100755 --- a/test/validate-gate.test.mjs +++ b/test/validate-gate.test.mjs @@ -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']); +});