#!/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 { setTimeout as delay } from 'node:timers/promises'; import { isMainModule } from './spawn-portable.mjs'; import { ACTIVE_RUN_STATES, artifactNames, issueEvents, latestReviewRequest, loadSealedArtifact, pendingArtifactName, pendingEvidenceError, processRuns, relabel, reviewRunsForRequest, } 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; } /** * Чистое решение: будить раунд или нет. * * @param {object} p * @param {string[]} p.labels метки issue * @param {object} p.validateRun завершённый прогон Validate: { id, event, status, headSha } * @param {number} p.issue номер issue * @param {object} p.request последняя постановка S7 из timeline * @param {string} p.branch ветка материала * @param {string} p.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, recheck?:boolean}} */ export function decideResume({ labels = [], issue, request, branch, headSha, validateRun, sha, runs = [], pendingOf = () => null }) { if (validateRun?.event !== 'workflow_dispatch') return { action: 'noop', reason: 'not a dispatch run — only the awaited dispatch wakes the round' }; 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' }; if (labels.includes('S4-spec-review')) return { action: 'noop', reason: 'ambiguous review status labels' }; const stop = STOP_LABELS.find((label) => labels.includes(label)); if (stop) return { action: 'noop', reason: `owner stopped the review (${stop})` }; if (request?.label !== REVIEW_LABEL || !Number.isFinite(Date.parse(request.at))) { return { action: 'noop', reason: 'current review label has no timeline request' }; } if (!sha || headSha !== sha) return { action: 'noop', reason: 'branch head differs from the completed Validate material' }; const mine = reviewRunsForRequest(runs, issue, request); if (mine.some((run) => ACTIVE_RUN_STATES.has(run.status))) return { action: 'noop', recheck: true, reason: 'a process run for this issue is already active' }; const latest = mine[0]; if (!latest) return { action: 'noop', recheck: true, 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', recheck: true, reason: 'latest process run left no pending marker — it did not wait for Validate' }; const error = pendingEvidenceError(pending, { issue, run: latest }); if (error) return { action: 'noop', reason: error }; if (pending.branch !== branch || String(pending.validate_run_id) !== String(validateRun.id)) { return { action: 'noop', reason: 'pending marker waits for another branch or Validate run' }; } if (String(pending.material_sha) !== String(sha)) { return { action: 'noop', recheck: true, 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, sleep = delay }) { const issue = issueNumberFromBranch(branch); if (!issue) return { action: 'noop', reason: `branch ${branch} is not an issue branch`, issue: null }; const io = ops || { state: () => { const current = JSON.parse(gh(['issue', 'view', String(issue), '--repo', repo, '--json', 'labels,state'])); const labels = current.state === 'OPEN' ? current.labels.map((label) => label.name) : []; // A deleted branch after merge/closure is normal, not a failed recovery. if (!labels.includes(REVIEW_LABEL) || labels.includes('S4-spec-review') || STOP_LABELS.some((label) => labels.includes(label))) { return { labels, request: null, headSha: null }; } return { labels, request: latestReviewRequest(issueEvents(repo, issue), REVIEW_LABEL), headSha: JSON.parse(gh(['api', `repos/${repo}/git/ref/heads/${branch}`])).object.sha }; }, runs: (request) => request ? processRuns(repo, [], { since: request.at }) : [], pendingOf: (run) => { const name = pendingArtifactName(issue, run); const artifacts = artifactNames(repo, run); if (artifacts.some((item) => item.name === `review-result-${issue}-${run.id}-${run.attempt}` && !item.expired)) { throw new Error('sealed model result exists — refusing to wake a completed review'); } const matches = artifacts.filter((item) => item.name === name && !item.expired); if (matches.length > 1) throw new Error('duplicate pending artifacts'); return matches.length ? loadSealedArtifact(repo, run, matches[0], 'pending.json') : null; }, relabel: () => relabel(repo, { number: issue }, REVIEW_LABEL), }; const inspect = () => { const state = io.state(); const decision = decideResume({ ...state, issue, branch, validateRun, sha, runs: io.runs(state.request), pendingOf: io.pendingOf }); return { state, decision }; }; let first = inspect(); // A completed Validate can arrive before the process list/artifact is visible. // Three fresh snapshots at most; never search behind a completed review barrier. // After this short delivery grace, the scheduled reconciler remains the fallback. for (let attempt = 1; first.decision.recheck && attempt < 3; attempt++) { await sleep(5_000); first = inspect(); } const decision = first.decision; if (decision.action === 'resume' && apply) { const second = inspect(); if (second.decision.action !== 'resume' || first.state.request.id !== second.state.request?.id || decision.run.id !== second.decision.run?.id || decision.run.attempt !== second.decision.run?.attempt) { return { action: 'noop', reason: `state changed before write: ${second.decision.reason}`, issue, applied: false }; } 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 || !arg('run-id')) { console.error('usage: process-resume.mjs --repo= --branch= --sha= --run-id= --event= --status= [--apply=false]'); process.exit(2); } const outcome = await resume({ repo, branch, sha, validateRun: { id: arg('run-id'), 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`); }