diff --git a/.github/workflows/process-resume.yml b/.github/workflows/process-resume.yml new file mode 100644 index 00000000..e26cf38a --- /dev/null +++ b/.github/workflows/process-resume.yml @@ -0,0 +1,60 @@ +name: Продолжение ревью после Validate +run-name: "resume · ${{ github.event.workflow_run.head_branch }} · ${{ github.event.workflow_run.conclusion }}" + +# #636. Стадия `prepare` конвейера (process.yml) больше не ждёт Validate с +# мутантами на материале внутри job — раннер спал ≈ 28 минут на раунд при +# 10–12 минутах работы модели. Она диспатчит прогон, кладёт запечатанный +# маркер `review-pending-…` и выходит. Этот workflow просыпается на завершение +# любого Validate и, если раунд ждал именно этот прогон (маркер на материале, +# метка S7 стоит, активного прогона конвейера нет), переставляет метку S7 — +# новый прогон `prepare` находит завершённый dispatch и продолжает раунд. +# Ничего не оценивает: зелёный/красный разбирает сам конвейер. Страховка на +# потерянное событие — process-reconcile.yml с тем же маркером. +# +# Как и process.yml, файл исполняется из ветки по умолчанию (main): для +# события `workflow_run` GitHub берёт workflow только оттуда. Сверка копий — +# в preflight validate.yml. + +on: + workflow_run: + workflows: ["Проверка (CI)"] + types: [completed] + +permissions: + contents: read + actions: read + +jobs: + resume: + name: "Разбудить раунд, ждавший этот Validate" + if: github.event.workflow_run.event == 'workflow_dispatch' && startsWith(github.event.workflow_run.head_branch, 'issue/') + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: process-resume-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + steps: + # Код берётся из dev, как у reconcile: после штатного слияния действует + # версия, которую проверил CI, а не копия из main. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: dev + fetch-depth: 1 + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + # Метка переставляется HP_PROCESS_TOKEN: событие от GITHUB_TOKEN не + # запустило бы process.yml (см. шапку process.yml, п. 1). + - name: Решить по маркеру ожидания и переставить S7 + env: + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.event.workflow_run.head_branch }} + SHA: ${{ github.event.workflow_run.head_sha }} + EVENT: ${{ github.event.workflow_run.event }} + STATUS: ${{ github.event.workflow_run.status }} + run: | + node scripts/process-resume.mjs \ + --repo="$REPO" --branch="$BRANCH" --sha="$SHA" \ + --event="$EVENT" --status="$STATUS" --apply=true | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index d1655b00..1813ba7c 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -582,14 +582,64 @@ jobs: { echo 'proceed=true'; echo 'result=skipped'; } >> "$GITHUB_OUTPUT" exit 0 fi - if node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA"; then - echo 'proceed=true' >> "$GITHUB_OUTPUT" - else - echo 'proceed=false' >> "$GITHUB_OUTPUT" - fi + # #636: раннер не спит, пока идёт Validate (28 минут на раунд при + # 10–12 минутах работы модели). Гейт диспатчит прогон, убеждается, что + # тот встал на материал, и выходит с кодом 2 — «идёт». Раунд продолжит + # событие завершения Validate (process-resume.yml переставит метку + # S7), страховка — process-reconcile. Зелёный или красный завершённый + # прогон гейт и без ожидания возвращает сразу. + set +e + node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait + code=$? + set -e + case "$code" in + 0) echo 'proceed=true' >> "$GITHUB_OUTPUT" ;; + 2) echo 'proceed=pending' >> "$GITHUB_OUTPUT" ;; + *) echo 'proceed=false' >> "$GITHUB_OUTPUT" ;; + esac + + # #636: прогон на материале идёт — записать маркер ожидания и освободить + # раннер. Метка S7 остаётся; событие `workflow_run` по завершении Validate + # переставит её, и новый прогон конвейера найдёт завершённый dispatch + # сразу. Маркер читают process-resume.mjs и process-reconcile.mjs: без + # него ни один из них не имеет права будить раунд — иначе «успешный + # прогон без вердикта» неотличим от потерянного запроса. + - name: Validate идёт — раунд продолжит событие + id: pending + if: steps.rebase.outputs.conflict != 'true' && steps.gate.outputs.proceed == 'pending' + env: + NUM: ${{ github.event.issue.number }} + STAGE: ${{ needs.guard.outputs.stage }} + BRANCH: ${{ steps.branch.outputs.name }} + SHA: ${{ steps.material.outputs.sha }} + VALIDATE_RUN_ID: ${{ steps.gate.outputs.run_id }} + VALIDATE_URL: ${{ steps.gate.outputs.url }} + run: | + dir="$RUNNER_TEMP/review-pending" + mkdir -p "$dir" + jq -n -S \ + --arg run_id "$GITHUB_RUN_ID" --arg run_attempt "$GITHUB_RUN_ATTEMPT" \ + --arg issue "$NUM" --arg stage "$STAGE" --arg branch "$BRANCH" \ + --arg material_sha "$SHA" --arg validate_run_id "$VALIDATE_RUN_ID" \ + --arg validate_url "$VALIDATE_URL" \ + '{schema:1,run_id:$run_id,run_attempt:$run_attempt,issue:$issue,stage:$stage,branch:$branch,material_sha:$material_sha,validate_run_id:$validate_run_id,validate_url:$validate_url}' \ + > "$dir/pending.json" + (cd "$dir" && sha256sum pending.json > manifest.sha256) + echo "artifact=review-pending-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" + short=$(git rev-parse --short "$SHA") + echo "Validate с мутантами на \`$short\` идёт — раннер освобождён, раунд продолжится по завершении прогона${VALIDATE_URL:+ ($VALIDATE_URL)}." >> "$GITHUB_STEP_SUMMARY" + + - name: Сохранить маркер ожидания + if: steps.gate.outputs.proceed == 'pending' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ steps.pending.outputs.artifact }} + path: ${{ runner.temp }}/review-pending + if-no-files-found: error + retention-days: 1 - name: Validate красный — вернуть автору без ревью - if: steps.rebase.outputs.conflict != 'true' && steps.gate.outputs.proceed != 'true' + if: steps.rebase.outputs.conflict != 'true' && steps.gate.outputs.proceed == 'false' env: GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} NUM: ${{ github.event.issue.number }} @@ -1181,6 +1231,11 @@ jobs: echo "::error::стадия deterministic prerequisites завершилась: $PREPARE_RESULT" exit 1 fi + if [ "$PROCEED" = "pending" ]; then + echo "Validate на материале ещё идёт — раунд продолжит событие завершения (#636); интегрировать нечего" + echo "proceed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi if [ "$PROCEED" != "true" ]; then echo "подготовка уже вернула задачу автору; интегрировать нечего" echo "proceed=false" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index b8d3f214..a5b80ccb 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -101,9 +101,10 @@ jobs: run: | git fetch --quiet origin main dev # #472: расписание mutation-gate.yml тоже исполняется из ветки по - # умолчанию — та же ловушка, что у process.yml. Сверяются оба. + # умолчанию — та же ловушка, что у process.yml; #636 добавил + # process-resume.yml (событие workflow_run). Сверяются все три. status=0 - for file in process.yml mutation-gate.yml; do + for file in process.yml mutation-gate.yml process-resume.yml; do if diff <(git show "origin/main:.github/workflows/$file") \ <(git show "origin/dev:.github/workflows/$file"); then echo "$file: main и dev идентичны" diff --git a/AGENTS.md b/AGENTS.md index c06349d1..d1f7503a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -425,7 +425,12 @@ until the verdict or the return arrives: a push on top of a running review cance it (10–20 runner minutes) and, after the material is fixed, also the merge (#312). Set `S7` once per round, not after every CI fix: the pipeline now runs Validate with the diff mutants on the material itself and returns a red one to `S6` without -spending a review cycle. Mutants by diff run only where they are explicitly +spending a review cycle; since #636 it does not sleep while Validate runs — the +prepare stage leaves a sealed `review-pending` marker and exits, and the +completion of Validate (`process-resume.yml`, `workflow_run`) re-applies `S7` +so a fresh run finds the finished dispatch; `process-reconcile.yml` is the +fallback for a lost event. A second `S7` from the pipeline itself is therefore +normal and is not a new round. Mutants by diff run only where they are explicitly requested — the review candidate, the merge candidate (both dispatch Validate with `mutants=true`) and PRs (#510, #601). Ordinary pushes, the beta candidate (`Release:` trailer) and `full=true` do not request them: a routine push costs diff --git a/PROCESS.md b/PROCESS.md index 7ce47ca4..74e92623 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -959,7 +959,14 @@ Medium-находки вне скоупа задачи (#202), кладёт до **Ревью не начинается на красном коде** (#510). После фиксации материала конвейер запускает Validate с мутантами по диффу на этом SHA (`scripts/validate-gate.mjs`: -`workflow_dispatch validate.yml -f mutants=true`) и ждёт его до 45 минут. Красный или +`workflow_dispatch validate.yml -f mutants=true`). **Ждёт его не раннер, а событие** +(#636): подготовка убеждается, что dispatch встал на материал, кладёт запечатанный +маркер ожидания `review-pending-…` и завершается; по завершении Validate +`process-resume.yml` (`workflow_run`) переставляет метку `S7-code-review`, и новый +прогон конвейера находит завершённый dispatch сразу. Страховка на потерянное +событие — `process-reconcile.yml`: успешный прогон подготовки с маркером и уже +завершённым Validate он будит повторной меткой, без маркера — как прежде, только +диагностика. Будить без маркера нельзя: это второй вызов модели. Красный или пропавший прогон возвращает задачу в `S6-in-progress` с комментарием и ссылкой — код никто не читал, цикл ревью не израсходован. Мутанты по диффу вообще бегут только по явному запросу: на кандидате ревью, кандидате слияния (#492) — оба @@ -976,8 +983,9 @@ Medium-находки вне скоупа задачи (#202), кладёт до Ожидание gates, работа модели и публикация/интеграция — три независимых jobs (#551) с отдельными бюджетами 55, 45 и 55 минут. Поэтому долгий Validate не -съедает время модели, а ожидание кандидата после зелёного вердикта не обрывает -готовый review. Между jobs передаётся запечатанный artifact: run/attempt, issue, +съедает время модели (с #636 — и не занимает раннер: до этого подготовка спала +≈ 28 минут на раунд при 10–12 минутах работы модели), а ожидание кандидата после +зелёного вердикта не обрывает готовый review. Между jobs передаётся запечатанный artifact: run/attempt, issue, этап, раунд, branch, SHA/tree материала, якоря ТЗ и результат Validate. Получатель сверяет полный набор файлов, SHA-256 и все поля с outputs предыдущей стадии; неполный, чужой или устаревший результат fail-closed не публикуется и не разрешает diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 3f154637..eefa95e2 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -8449,6 +8449,51 @@ const MUTANT_DEFINITIONS = [ + ' никогда — именно оно в этом процессе заменяет тестирование.', }], }, + // #636: раунд продолжается по событию завершения Validate, а не сном раннера. + // Каждая защита — от второго вызова модели или от вечного ожидания. + { + id: 'gate-no-wait-still-sleeps', + guard: 'node --test --test-name-pattern="#636" test/validate-gate.test.mjs', + because: 'with --no-wait the gate must return pending for a running dispatch instead of polling ' + + 'it inside the pipeline job — otherwise the runner sleeps 28 minutes per round again (#636)', + patches: [{ + file: 'scripts/validate-gate.mjs', + find: " if (!wait) {\n // #636: прогон найден и идёт — ждать его будет событие, не раннер.", + replace: " if (false && !wait) {\n // #636: прогон найден и идёт — ждать его будет событие, не раннер.", + }], + }, + { + id: 'resume-wakes-round-without-marker', + guard: 'node --test --test-name-pattern="#636" test/process-resume.test.mjs', + because: 'a successful process run without a pending marker did not wait for Validate; relabelling ' + + 'it would spend the model a second time (#636)', + patches: [{ + file: 'scripts/process-resume.mjs', + find: " if (!pending) return { action: 'noop', reason: 'latest process run left no pending marker — it did not wait for Validate' };", + replace: " if (false && !pending) return { action: 'noop', reason: 'latest process run left no pending marker — it did not wait for Validate' };", + }], + }, + { + id: 'resume-ignores-active-run', + guard: 'node --test --test-name-pattern="#636" test/process-resume.test.mjs', + because: 'relabelling while a process run is active queues a second round for the same request (#636)', + patches: [{ + file: 'scripts/process-resume.mjs', + find: " if (mine.some((run) => ACTIVE_RUN_STATES.has(run.status))) return { action: 'noop', reason: 'a process run for this issue is already active' };", + replace: " if (false && mine.some((run) => ACTIVE_RUN_STATES.has(run.status))) return { action: 'noop', reason: 'a process run for this issue is already active' };", + }], + }, + { + id: 'reconcile-wakes-pending-while-validate-active', + guard: 'node --test --test-name-pattern="#636" test/process-reconcile.test.mjs', + because: 'reconcile must wait while the Validate the round is pending on still runs; relabelling ' + + 'early dispatches a second Validate and a second round (#636)', + patches: [{ + file: 'scripts/process-reconcile.mjs', + find: " if (run.pendingValidate === 'active') {", + replace: " if (false && run.pendingValidate === 'active') {", + }], + }, { id: 'process-reconcile-restarts-healthy-run', guard: 'node --test test/process-reconcile.test.mjs', diff --git a/scripts/process-reconcile.mjs b/scripts/process-reconcile.mjs index c6bfe225..088338b3 100644 --- a/scripts/process-reconcile.mjs +++ b/scripts/process-reconcile.mjs @@ -50,6 +50,10 @@ export function parseProcessRun(run = {}) { prepared: run.prepared || null, preparedArtifact: Boolean(run.preparedArtifact), resultArtifact: Boolean(run.resultArtifact), + // #636: маркер «Validate на материале идёт, раунд продолжит событие» и + // состояние этого Validate (`active` | `completed` | `missing`). + pending: run.pending || null, + pendingValidate: run.pendingValidate || null, evidenceError: run.evidenceError || null, }; } @@ -143,6 +147,16 @@ export function decideReconciliation({ if (Number.isFinite(settledAt) && now - settledAt < graceMs) { return result('wait', 'completed run is still within label-application grace', { label, stage, run }); } + if (run.conclusion === 'success' && run.pending) { + // #636: успешный прогон без вердикта — это не потеря, а осознанный выход + // подготовки: Validate с мутантами на материале ещё шёл. Пока он идёт — + // ждать; завершился или пропал, а событие раунд не разбудило — разбудить + // повторной меткой: новый прогон найдёт завершённый dispatch сразу. + if (run.pendingValidate === 'active') { + return result('wait', 'Validate on the material is still running; the round resumes on its completion', { label, stage, run }); + } + return result('retry', `Validate on the material is ${run.pendingValidate || 'unknown'} but the round was not resumed`, { label, stage, run }); + } if (run.conclusion === 'success') { return result('escalate', 'successful run did not move the review label', { label, stage, run }); } @@ -202,7 +216,7 @@ function openReviewIssues(repo) { .sort((a, b) => a.number - b.number); } -function processRuns(repo, issues = []) { +export function processRuns(repo, issues = []) { const pages = [1, 2].flatMap((page) => { const response = ghJson(['api', `repos/${repo}/actions/workflows/process.yml/runs?event=issues&per_page=100&page=${page}`]); return response.workflow_runs || []; @@ -227,30 +241,54 @@ function processRuns(repo, issues = []) { }).filter(Boolean); } -function artifactNames(repo, run) { +export function artifactNames(repo, run) { const response = ghJson(['api', `repos/${repo}/actions/runs/${run.id}/artifacts?per_page=100`]); return response.artifacts || []; } -function loadPreparedArtifact(repo, run, artifact) { +/** Sealed JSON artifact of the pipeline: one file plus its sha256 manifest. */ +export function loadSealedArtifact(repo, run, artifact, file) { const dir = mkdtempSync(join(tmpdir(), 'houseplan-process-reconcile-')); try { const downloaded = gh(['run', 'download', String(run.id), '--repo', repo, '--name', artifact.name, '--dir', dir], { allowFailure: true }); if (downloaded.status !== 0) throw new Error(`artifact download failed: ${(downloaded.stderr || '').trim()}`); - const file = join(dir, 'prepared.json'); + const path = join(dir, file); const manifest = join(dir, 'manifest.sha256'); - if (!existsSync(file) || !existsSync(manifest)) throw new Error('prepared artifact is incomplete'); - const body = readFileSync(file); + if (!existsSync(path) || !existsSync(manifest)) throw new Error(`${file} artifact is incomplete`); + const body = readFileSync(path); const expected = readFileSync(manifest, 'utf8').trim().split(/\s+/)[0]; const actual = createHash('sha256').update(body).digest('hex'); - if (expected !== actual) throw new Error('prepared artifact checksum mismatch'); + if (expected !== actual) throw new Error(`${file} artifact checksum mismatch`); return JSON.parse(body.toString('utf8')); } finally { rmSync(dir, { recursive: true, force: true }); } } +function loadPreparedArtifact(repo, run, artifact) { + return loadSealedArtifact(repo, run, artifact, 'prepared.json'); +} + +export const pendingArtifactName = (issue, run) => `review-pending-${issue.number ?? issue}-${run.id}-${run.attempt}`; + +/** + * #636: состояние Validate с мутантами на материале — по dispatch-прогонам на + * SHA. `active` пока хоть один не завершён; `completed`, если завершённый есть; + * иначе `missing` (диспатч не появился — новый прогон конвейера повторит его). + */ +export function validateStateOnMaterial(runs = []) { + const dispatches = (runs || []).filter((run) => (run.event || run.workflowEvent) === 'workflow_dispatch'); + if (dispatches.some((run) => ACTIVE_RUN_STATES.has(String(run.status || '')))) return 'active'; + if (dispatches.some((run) => String(run.status || '') === 'completed')) return 'completed'; + return 'missing'; +} + +function validateRunsOnSha(repo, sha) { + const response = ghJson(['api', `repos/${repo}/actions/workflows/validate.yml/runs?head_sha=${sha}&per_page=20`]); + return response.workflow_runs || []; +} + function hydrateRunEvidence(repo, issue, run) { if (!run || run.status !== 'completed') return run; try { @@ -259,8 +297,14 @@ function hydrateRunEvidence(repo, issue, run) { const resultName = `review-result-${issue.number}-${run.id}-${run.attempt}`; const preparedArtifacts = artifacts.filter((artifact) => artifact.name === preparedName && !artifact.expired); const resultArtifacts = artifacts.filter((artifact) => artifact.name === resultName && !artifact.expired); - if (preparedArtifacts.length > 1 || resultArtifacts.length > 1) { - return { ...run, evidenceError: 'duplicate prepared/result artifacts' }; + const pendingName = pendingArtifactName(issue, run); + const pendingArtifacts = artifacts.filter((artifact) => artifact.name === pendingName && !artifact.expired); + if (preparedArtifacts.length > 1 || resultArtifacts.length > 1 || pendingArtifacts.length > 1) { + return { ...run, evidenceError: 'duplicate prepared/result/pending artifacts' }; + } + const pending = pendingArtifacts.length === 1 ? loadSealedArtifact(repo, run, pendingArtifacts[0], 'pending.json') : null; + if (pending && (String(pending.issue) !== String(issue.number) || String(pending.run_id) !== String(run.id))) { + return { ...run, evidenceError: 'pending marker belongs to another issue/run' }; } return { ...run, @@ -268,6 +312,8 @@ function hydrateRunEvidence(repo, issue, run) { resultArtifact: resultArtifacts.length === 1, prepared: preparedArtifacts.length === 1 ? loadPreparedArtifact(repo, run, preparedArtifacts[0]) : null, + pending, + pendingValidate: pending ? validateStateOnMaterial(validateRunsOnSha(repo, pending.material_sha)) : null, }; } catch (error) { return { ...run, evidenceError: error instanceof Error ? error.message : String(error) }; diff --git a/scripts/process-resume.mjs b/scripts/process-resume.mjs new file mode 100644 index 00000000..4ac7cc2a --- /dev/null +++ b/scripts/process-resume.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node +// #636: продолжение раунда ревью по событию завершения Validate. +// +// До #636 стадия `prepare` конвейера ждала Validate с мутантами на материале +// внутри job: раннер спал ≈ 28 минут на раунд при 10–12 минутах работы модели +// и упирался в бюджет стадии. Теперь `prepare` диспатчит прогон, кладёт +// запечатанный маркер `review-pending---` и выходит. +// Этот скрипт запускает `process-resume.yml` на `workflow_run: completed` +// Validate и делает ровно одно: если раунд действительно ждёт этот прогон — +// переставляет метку S7, и обычный контроллер продолжает с завершённым +// dispatch на руках. Ничего не оценивает и не публикует: вердикт Validate +// (зелёный или красный) разбирает новый прогон `prepare`. +// +// Без маркера ожидания будить раунд нельзя: «успешный прогон без вердикта» +// иначе неотличим от потерянного запроса, а лишняя метка — это второй вызов +// модели. Страховка на потерянное событие — process-reconcile (тот же маркер). +import { execFileSync } from 'node:child_process'; +import { appendFileSync } from 'node:fs'; +import { isMainModule } from './spawn-portable.mjs'; +import { + ACTIVE_RUN_STATES, artifactNames, loadSealedArtifact, parseProcessRun, pendingArtifactName, + processRuns, relabel, +} from './process-reconcile.mjs'; + +export const REVIEW_LABEL = 'S7-code-review'; +const STOP_LABELS = ['blocked', 'review-4']; + +/** `issue/612-view-conflict` → 612; иначе null. */ +export function issueNumberFromBranch(branch) { + const match = /^issue\/(\d+)-/.exec(String(branch || '')); + return match ? Number(match[1]) : null; +} + +const at = (value) => { + const parsed = Date.parse(String(value || '')); + return Number.isFinite(parsed) ? parsed : NaN; +}; + +/** + * Чистое решение: будить раунд или нет. + * + * @param {object} p + * @param {string[]} p.labels метки issue + * @param {object} p.validateRun завершённый прогон Validate: { event, status, headSha } + * @param {string} p.sha SHA материала, на котором завершился Validate + * @param {object[]} p.runs прогоны конвейера этой issue (parseProcessRun-совместимые) + * @param {(run) => object|null} p.pendingOf маркер ожидания прогона либо null + * @returns {{action:'resume'|'noop', reason:string, run?:object}} + */ +export function decideResume({ labels = [], validateRun, sha, runs = [], pendingOf = () => null }) { + if (validateRun?.event !== 'workflow_dispatch') return { action: 'noop', reason: 'not a dispatch run — push runs carry no mutants' }; + if (validateRun?.status !== 'completed') return { action: 'noop', reason: 'validate run is not completed' }; + if (validateRun?.headSha && sha && validateRun.headSha !== sha) return { action: 'noop', reason: 'validate run head differs from the material' }; + if (!labels.includes(REVIEW_LABEL)) return { action: 'noop', reason: 'issue is not awaiting code review' }; + const stop = STOP_LABELS.find((label) => labels.includes(label)); + if (stop) return { action: 'noop', reason: `owner stopped the review (${stop})` }; + const mine = runs.map((run) => run.issue ? run : parseProcessRun(run)).filter(Boolean) + .filter((run) => run.label === REVIEW_LABEL) + .sort((a, b) => at(b.createdAt) - at(a.createdAt) || Number(b.id) - Number(a.id)); + if (mine.some((run) => ACTIVE_RUN_STATES.has(run.status))) return { action: 'noop', reason: 'a process run for this issue is already active' }; + const latest = mine[0]; + if (!latest) return { action: 'noop', reason: 'no process run for this issue — reconcile owns lost requests' }; + if (latest.status !== 'completed' || latest.conclusion !== 'success') { + return { action: 'noop', reason: `latest process run is ${latest.status}/${latest.conclusion || 'none'} — nothing was left pending` }; + } + const pending = pendingOf(latest); + if (!pending) return { action: 'noop', reason: 'latest process run left no pending marker — it did not wait for Validate' }; + if (String(pending.material_sha) !== String(sha)) { + return { action: 'noop', reason: `pending marker waits for ${String(pending.material_sha).slice(0, 8)}, not ${String(sha).slice(0, 8)}` }; + } + return { action: 'resume', reason: 'the round was waiting for exactly this Validate run', run: latest }; +} + +function gh(args) { + return execFileSync('gh', args, { encoding: 'utf8' }); +} + +export async function resume({ repo, branch, sha, validateRun, apply = true, ops = null }) { + const issue = issueNumberFromBranch(branch); + if (!issue) return { action: 'noop', reason: `branch ${branch} is not an issue branch`, issue: null }; + const io = ops || { + labels: () => JSON.parse(gh(['issue', 'view', String(issue), '--repo', repo, '--json', 'labels'])).labels.map((label) => label.name), + runs: () => processRuns(repo, []), + pendingOf: (run) => { + const name = pendingArtifactName(issue, run); + const artifact = artifactNames(repo, run).find((item) => item.name === name && !item.expired); + if (!artifact) return null; + const pending = loadSealedArtifact(repo, run, artifact, 'pending.json'); + return String(pending.issue) === String(issue) && String(pending.run_id) === String(run.id) ? pending : null; + }, + relabel: () => relabel(repo, { number: issue }, REVIEW_LABEL), + }; + const labels = io.labels(); + const runs = io.runs().filter((run) => run.issue === issue); + const decision = decideResume({ labels, validateRun, sha, runs, pendingOf: io.pendingOf }); + if (decision.action === 'resume' && apply) io.relabel(); + return { ...decision, issue, applied: decision.action === 'resume' && apply }; +} + +if (isMainModule(import.meta.url)) { + const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3); + const repo = arg('repo') || process.env.GITHUB_REPOSITORY; + const branch = arg('branch'); + const sha = arg('sha'); + if (!repo || !branch || !sha) { + console.error('usage: process-resume.mjs --repo= --branch= --sha= --event= --status= [--apply=false]'); + process.exit(2); + } + const outcome = await resume({ + repo, branch, sha, + validateRun: { event: arg('event'), status: arg('status') || 'completed', headSha: sha }, + apply: arg('apply') !== 'false', + }); + const lines = [`action=${outcome.action}`, `issue=${outcome.issue ?? ''}`, `reason=${outcome.reason}`, `applied=${outcome.applied ? 'true' : 'false'}`]; + for (const line of lines) console.log(line); + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`); +} diff --git a/scripts/validate-gate.mjs b/scripts/validate-gate.mjs index a4c619b4..dcf8c9fd 100755 --- a/scripts/validate-gate.mjs +++ b/scripts/validate-gate.mjs @@ -7,10 +7,15 @@ * доказательство для ревью — dispatch-прогон на точном SHA материала. Push- * прогон на том же SHA зелёный не считается: в нём мутантов нет. * - * node scripts/validate-gate.mjs --repo= --ref=<ветка> --sha= [--workflow=validate.yml] + * node scripts/validate-gate.mjs --repo= --ref=<ветка> --sha= [--workflow=validate.yml] [--no-wait] * - * Печатает `result=green|failed|missing` и `url=…` (и в $GITHUB_OUTPUT, если он - * задан); код выхода 0 только при green. Логика — чистая функция `validateGate` + * Печатает `result=green|failed|missing|pending`, `url=…`, `run_id=…` (и в + * $GITHUB_OUTPUT, если он задан); код выхода 0 только при green, 2 — pending. + * `--no-wait` (#636): гейт диспатчит прогон и убеждается, что тот появился на + * материале, но не ждёт его завершения — раннер конвейера освобождается, а + * раунд продолжает `process-resume.yml` по событию `workflow_run` (страховка — + * reconcile). Зелёный или красный завершённый прогон и с `--no-wait` + * возвращается сразу. Логика — чистая функция `validateGate` * поверх инъектируемых `ops`, чтобы тесты и мутанты гоняли её без gh. */ import { spawnSync } from 'node:child_process'; @@ -58,9 +63,14 @@ export function provesMutants(jobs) { * @param {string} p.ref ветка, на которой запускать * @param {string} p.sha SHA материала * @param {object} p.ops GitHub run/proof operations plus dispatch, sleep and clock. - * @returns {Promise<{result:'green'|'failed'|'missing', url:string|null, note:string}>} + * @param {boolean} [p.wait] `false` — не ждать идущий прогон, а вернуть `pending` (#636): + * раннер конвейера не спит 28 минут; продолжение разбудит событие + * `workflow_run` (process-resume.yml) либо reconcile. + * @returns {Promise<{result:'green'|'failed'|'missing'|'pending', url:string|null, note:string, runId?:number}>} */ -export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS }) { +export async function validateGate({ + ref, sha, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS, wait = true, +}) { const started = ops.now(); const candidateTree = await ops.candidateTree(sha); const ignored = new Set(); // завершённые dispatch без применимого proof @@ -84,6 +94,13 @@ export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_M tracked = null; continue; } + if (!wait) { + // #636: прогон найден и идёт — ждать его будет событие, не раннер. + return { + result: 'pending', url: run.url || null, runId: run.databaseId, + note: `Validate с мутантами идёт (${run.status}); продолжение — по завершении прогона`, + }; + } } else if (dispatchedAt === null) { await ops.dispatch(ref); dispatchedAt = ops.now(); @@ -151,9 +168,11 @@ if (invokedDirectly) { console.error('usage: validate-gate.mjs --repo= --ref= --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}`]; + const wait = !process.argv.includes('--no-wait'); + const outcome = await validateGate({ ref, sha, wait, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) }); + const lines = [`result=${outcome.result}`, `url=${outcome.url || ''}`, `run_id=${outcome.runId || ''}`, `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); + // 0 — зелёный, 2 — идёт (только с --no-wait), 1 — красный/пропавший. + process.exit(outcome.result === 'green' ? 0 : outcome.result === 'pending' ? 2 : 1); } diff --git a/test/mutation-gate.test.mjs b/test/mutation-gate.test.mjs index 336664d6..c602517e 100644 --- a/test/mutation-gate.test.mjs +++ b/test/mutation-gate.test.mjs @@ -381,7 +381,7 @@ test('#472 AC7: отсутствие Telegram-секретов не роняет }); test('#472 AC8: Validate сверяет mutation-gate.yml между main и dev наравне с process.yml', () => { - assert.match(validateWorkflowText, /for file in process\.yml mutation-gate\.yml; do/); + assert.match(validateWorkflowText, /for file in process\.yml mutation-gate\.yml process-resume\.yml; do/); }); // #475. Свидетель гниёт двумя способами: изменился файл, который он патчит, diff --git a/test/process-reconcile.test.mjs b/test/process-reconcile.test.mjs index 62c33c4c..121810cf 100644 --- a/test/process-reconcile.test.mjs +++ b/test/process-reconcile.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + validateStateOnMaterial, alreadyReported, applyReconciliationDecision, decideReconciliation, latestReviewRequest, markerFor, parseProcessRun, preparedEvidenceError, reconcileAll, reconciliationKey, relabel, } from '../scripts/process-reconcile.mjs'; @@ -35,7 +36,7 @@ test('#555 maps a stable process run-name to issue and stage', () => { id: 70, attempt: 2, issue: 555, label: 'S7-code-review', stage: 'code', status: 'in_progress', conclusion: '', createdAt: '2026-09-13T10:00:01Z', updatedAt: null, url: null, prepared: null, preparedArtifact: false, - resultArtifact: false, evidenceError: null, + resultArtifact: false, pending: null, pendingValidate: null, evidenceError: null, }); assert.equal(parseProcessRun({ display_title: 'unrelated label event' }), null); }); @@ -215,3 +216,41 @@ test('#555 a fresh label/run completion stays inside grace instead of duplicatin runs: [run({ conclusion: 'cancelled', updatedAt: '2026-09-13T11:58:00Z' })], now: NOW, }).action, 'wait'); }); + +// #636: подготовка вышла успешно, оставив маркер ожидания — Validate на +// материале ещё шёл. Это не «успешный прогон без вердикта»: пока Validate идёт, +// reconcile ждёт; завершился или пропал, а событие раунд не разбудило — +// повторная метка. Маркер обязателен: без него правило прежнее (escalate), +// иначе любой успешный прогон без вердикта будил бы модель второй раз. +test('#636 pending marker: running Validate waits, finished Validate retries, no marker still escalates', () => { + const pending = { schema: 1, issue: 636, material_sha: 'a'.repeat(40) }; + const waiting = decideReconciliation({ + issue: issue(), request, runs: [run({ conclusion: 'success', pending, pendingValidate: 'active' })], now: NOW, + }); + assert.equal(waiting.action, 'wait'); + assert.match(waiting.reason, /still running/); + + for (const state of ['completed', 'missing']) { + const done = decideReconciliation({ + issue: issue(), request, runs: [run({ conclusion: 'success', pending, pendingValidate: state })], now: NOW, + }); + assert.equal(done.action, 'retry', state); + assert.match(done.reason, /was not resumed/); + } + + const noMarker = decideReconciliation({ issue: issue(), request, runs: [run({ conclusion: 'success' })], now: NOW }); + assert.equal(noMarker.action, 'escalate'); + const parsed = parseProcessRun({ display_title: 'process #555 · S7-code-review · x', id: 1, pending, pendingValidate: 'active' }); + assert.equal(parsed.pending, pending); + assert.equal(parsed.pendingValidate, 'active'); +}); + +test('#636 validateStateOnMaterial reads only dispatch runs and prefers active over completed', () => { + assert.equal(validateStateOnMaterial([]), 'missing'); + assert.equal(validateStateOnMaterial([{ event: 'push', status: 'completed' }]), 'missing'); + assert.equal(validateStateOnMaterial([{ event: 'workflow_dispatch', status: 'completed' }]), 'completed'); + assert.equal(validateStateOnMaterial([ + { event: 'workflow_dispatch', status: 'completed' }, { event: 'workflow_dispatch', status: 'in_progress' }, + ]), 'active'); + assert.equal(validateStateOnMaterial([{ event: 'workflow_dispatch', status: 'queued' }]), 'active'); +}); diff --git a/test/process-resume.test.mjs b/test/process-resume.test.mjs new file mode 100644 index 00000000..3bf97150 --- /dev/null +++ b/test/process-resume.test.mjs @@ -0,0 +1,111 @@ +// #636: раунд ревью продолжается по событию завершения Validate, а не сном +// раннера. Решение чистое: будить можно только раунд, который сам оставил +// маркер ожидания на этот материал, при стоящей метке S7 и без активного +// прогона конвейера. Всё остальное — noop: лишняя метка = второй вызов модели. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { decideResume, issueNumberFromBranch, resume, REVIEW_LABEL } from '../scripts/process-resume.mjs'; + +const SHA = 'a'.repeat(40); +const OTHER = 'b'.repeat(40); +const validateRun = (over = {}) => ({ event: 'workflow_dispatch', status: 'completed', headSha: SHA, ...over }); +const processRun = (over = {}) => ({ + id: 70, attempt: 1, issue: 636, label: REVIEW_LABEL, stage: 'code', status: 'completed', conclusion: 'success', + createdAt: '2026-09-23T10:00:00Z', ...over, +}); +const pending = { schema: 1, issue: 636, run_id: 70, material_sha: SHA }; +const pendingOf = (run) => (run.id === 70 ? pending : null); + +test('#636 branch → issue number', () => { + assert.equal(issueNumberFromBranch('issue/636-review-wait-event'), 636); + assert.equal(issueNumberFromBranch('issue/7-x'), 7); + assert.equal(issueNumberFromBranch('dev'), null); + assert.equal(issueNumberFromBranch('issue/abc-x'), null); + assert.equal(issueNumberFromBranch(''), null); +}); + +test('#636 the round that waited for exactly this Validate is resumed', () => { + const decision = decideResume({ labels: ['bug', REVIEW_LABEL], validateRun: validateRun(), sha: SHA, runs: [processRun()], pendingOf }); + assert.equal(decision.action, 'resume'); + assert.equal(decision.run.id, 70); +}); + +test('#636 push runs, unfinished runs and foreign SHAs never wake a round', () => { + const base = { labels: [REVIEW_LABEL], sha: SHA, runs: [processRun()], pendingOf }; + assert.equal(decideResume({ ...base, validateRun: validateRun({ event: 'push' }) }).action, 'noop'); + assert.equal(decideResume({ ...base, validateRun: validateRun({ status: 'in_progress' }) }).action, 'noop'); + assert.equal(decideResume({ ...base, validateRun: validateRun({ headSha: OTHER }) }).action, 'noop'); +}); + +test('#636 label state gates the wake-up: no S7, blocked or review-4 → noop', () => { + const base = { validateRun: validateRun(), sha: SHA, runs: [processRun()], pendingOf }; + assert.equal(decideResume({ ...base, labels: ['S6-in-progress'] }).action, 'noop'); + assert.equal(decideResume({ ...base, labels: [REVIEW_LABEL, 'blocked'] }).action, 'noop'); + assert.equal(decideResume({ ...base, labels: [REVIEW_LABEL, 'review-4'] }).action, 'noop'); +}); + +test('#636 an active process run, a run without a pending marker or a marker for another SHA → noop', () => { + const base = { labels: [REVIEW_LABEL], validateRun: validateRun(), sha: SHA }; + const active = decideResume({ ...base, runs: [processRun({ id: 71, status: 'in_progress', conclusion: '', createdAt: '2026-09-23T10:05:00Z' }), processRun()], pendingOf }); + assert.equal(active.action, 'noop'); + assert.match(active.reason, /already active/); + const noMarker = decideResume({ ...base, runs: [processRun()], pendingOf: () => null }); + assert.equal(noMarker.action, 'noop'); + assert.match(noMarker.reason, /no pending marker/); + const otherSha = decideResume({ ...base, runs: [processRun()], pendingOf: () => ({ ...pending, material_sha: OTHER }) }); + assert.equal(otherSha.action, 'noop'); + assert.match(otherSha.reason, /waits for bbbbbbbb/); + const failed = decideResume({ ...base, runs: [processRun({ conclusion: 'failure' })], pendingOf }); + assert.equal(failed.action, 'noop'); + const none = decideResume({ ...base, runs: [], pendingOf }); + assert.match(none.reason, /reconcile owns lost requests/); +}); + +test('#636 the newest process run decides, not an older pending one', () => { + const older = processRun({ id: 60, createdAt: '2026-09-23T09:00:00Z' }); + const newerReturned = processRun({ id: 71, conclusion: 'failure', createdAt: '2026-09-23T11:00:00Z' }); + const decision = decideResume({ + labels: [REVIEW_LABEL], validateRun: validateRun(), sha: SHA, runs: [older, newerReturned], + pendingOf: (run) => (run.id === 60 ? pending : null), + }); + assert.equal(decision.action, 'noop'); +}); + +test('#636 resume() relabels only on a resume decision and only with apply', async () => { + const relabels = []; + const ops = { + labels: () => [REVIEW_LABEL], + runs: () => [processRun()], + pendingOf, + relabel: () => relabels.push(REVIEW_LABEL), + }; + const applied = await resume({ repo: 'o/r', branch: 'issue/636-x', sha: SHA, validateRun: validateRun(), ops }); + assert.equal(applied.action, 'resume'); + assert.equal(applied.applied, true); + assert.deepEqual(relabels, [REVIEW_LABEL]); + const dry = await resume({ repo: 'o/r', branch: 'issue/636-x', sha: SHA, validateRun: validateRun(), ops, apply: false }); + assert.equal(dry.applied, false); + assert.deepEqual(relabels, [REVIEW_LABEL], 'dry run does not relabel'); + const foreign = await resume({ repo: 'o/r', branch: 'dev', sha: SHA, validateRun: validateRun(), ops }); + assert.equal(foreign.action, 'noop'); + assert.equal(foreign.issue, null); +}); + +test('#636 workflows: prepare exits pending with a sealed marker, resume relabels by the marker, preflight mirrors the new file', () => { + const process = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); + const resumeWf = readFileSync(new URL('../.github/workflows/process-resume.yml', import.meta.url), 'utf8'); + const validate = readFileSync(new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8'); + assert.match(process, /validate-gate\.mjs --repo="\$\{\{ github\.repository \}\}" --ref="\$BRANCH" --sha="\$SHA" --no-wait/); + assert.match(process, /2\) echo 'proceed=pending' >> "\$GITHUB_OUTPUT"/); + assert.match(process, /review-pending-\$\{NUM\}-\$\{GITHUB_RUN_ID\}-\$\{GITHUB_RUN_ATTEMPT\}/); + assert.match(process, /sha256sum pending\.json > manifest\.sha256/); + assert.match(process, /Validate красный — вернуть автору без ревью\n\s+if: steps\.rebase\.outputs\.conflict != 'true' && steps\.gate\.outputs\.proceed == 'false'/); + assert.match(resumeWf, /workflow_run:\n\s+workflows: \["Проверка \(CI\)"\]\n\s+types: \[completed\]/); + assert.match(resumeWf, /github\.event\.workflow_run\.event == 'workflow_dispatch' && startsWith\(github\.event\.workflow_run\.head_branch, 'issue\/'\)/); + assert.match(resumeWf, /GH_TOKEN: \$\{\{ secrets\.HP_PROCESS_TOKEN \}\}/); + assert.match(resumeWf, /node scripts\/process-resume\.mjs/); + assert.ok(!/issues: write/.test(resumeWf), 'resume relabels with HP_PROCESS_TOKEN only'); + assert.match(validate, /for file in process\.yml mutation-gate\.yml process-resume\.yml; do/); + assert.equal(readFileSync(new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8').includes('name: Проверка (CI)'), true, 'workflow_run listens to the Validate workflow name'); +}); diff --git a/test/review-doc-guard.test.mjs b/test/review-doc-guard.test.mjs index 3ff38506..a1004a98 100644 --- a/test/review-doc-guard.test.mjs +++ b/test/review-doc-guard.test.mjs @@ -628,7 +628,8 @@ test('#510 AC2: конвейер запускает Validate с мутантам assert.match(gateStep, /\{ echo 'proceed=true'; echo 'result=skipped'; \}/, 'skipped = proceed'); assert.doesNotMatch(workflow.slice(modelJob), /steps\.gate\.outputs/, 'следующие jobs не читают локальные outputs prepare'); const backStep = workflow.slice(back, deps); - assert.match(backStep, /if: steps\.rebase\.outputs\.conflict != 'true' && steps\.gate\.outputs\.proceed != 'true'/); + // #636: третий исход гейта — pending (Validate идёт); возврат автору только на явном false + assert.match(backStep, /if: steps\.rebase\.outputs\.conflict != 'true' && steps\.gate\.outputs\.proceed == 'false'/); assert.match(backStep, /--add-label S6-in-progress --remove-label S7-code-review/); assert.match(backStep, /цикл ревью не израсходован/); assert.match(workflow.slice(modelJob, deps), /if: needs\.prepare\.outputs\.proceed == 'true'/, diff --git a/test/validate-gate.test.mjs b/test/validate-gate.test.mjs index 1ce3553e..0dbf7e3a 100755 --- a/test/validate-gate.test.mjs +++ b/test/validate-gate.test.mjs @@ -170,3 +170,44 @@ test('#510 r1 M1: a cancelled dispatch with no replacement gets one dispatch, no assert.equal(outcome.result, 'green'); assert.deepEqual(fake.dispatched, ['issue/1']); }); + +// #636: раннер конвейера не ждёт Validate внутри job. С `wait: false` гейт +// возвращает завершённый прогон как раньше, а идущий — `pending`, не поллит его; +// прогон, который ещё не появился, гейт всё же диспатчит и дожидается его +// появления на материале (#539), потому что иначе событию завершения нечего +// будить. +test('#636: без ожидания завершённый зелёный dispatch принимается сразу, как и красный', async () => { + const green = fakeOps({ snapshots: [[run()]] }); + assert.equal((await validateGate({ ref: 'issue/1', sha: SHA, ops: green.ops, wait: false })).result, 'green'); + const red = fakeOps({ snapshots: [[run({ conclusion: 'failure', url: 'https://run/red' })]] }); + assert.equal((await validateGate({ ref: 'issue/1', sha: SHA, ops: red.ops, wait: false })).result, 'failed'); + assert.deepEqual(green.dispatched, []); +}); + +test('#636: идущий dispatch на материале — pending с его id и url, без единого sleep', async () => { + const fake = fakeOps({ snapshots: [[run({ status: 'in_progress', conclusion: null, url: 'https://run/live', databaseId: 42 })]] }); + const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, wait: false, pollMs: 1000 }); + assert.equal(outcome.result, 'pending'); + assert.equal(outcome.runId, 42); + assert.equal(outcome.url, 'https://run/live'); + assert.equal(fake.ops.now(), 0, 'гейт не спал'); + assert.deepEqual(fake.dispatched, []); +}); + +test('#636: без прогона гейт диспатчит, ждёт появления и возвращает pending, не завершение', async () => { + const pushOnly = [run({ event: 'push', databaseId: 7 })]; + const live = [...pushOnly, run({ status: 'queued', conclusion: null, databaseId: 9 })]; + const fake = fakeOps({ snapshots: [pushOnly, pushOnly, live, [...pushOnly, run({ databaseId: 9 })]] }); + const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, wait: false, pollMs: 1000 }); + assert.equal(outcome.result, 'pending'); + assert.equal(outcome.runId, 9); + assert.deepEqual(fake.dispatched, ['issue/1']); + assert.equal(fake.calls(), 3, 'остановился на первом снимке с прогоном, до его завершения не дошёл'); +}); + +test('#636: с ожиданием (умолчание) поведение прежнее — идущий прогон дожидается', async () => { + const fake = fakeOps({ snapshots: [[run({ status: 'in_progress', conclusion: null })], [run()]] }); + const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, pollMs: 1000 }); + assert.equal(outcome.result, 'green'); + assert.ok(fake.ops.now() > 0, 'один poll прошёл'); +});