ci(process): раунд ревью ждёт Validate событием, а не сном раннера (#636)

Стадия prepare спала ≈ 28 минут на раунд, пока шёл Validate с мутантами на
материале (модель работает 10–12); за неделю ≈ 420–500 job-минут простоя и
потолок бюджета стадии 55 минут.

- validate-gate.mjs: `--no-wait` — гейт диспатчит прогон, убеждается, что тот
  встал на материал (#539 сохранён), и возвращает `pending` (код 2) вместо
  ожидания; завершённый зелёный/красный отдаёт сразу, как прежде.
- process.yml prepare: третий исход `proceed=pending`: запечатанный маркер
  `review-pending-<issue>-<run>-<attempt>` (issue, stage, branch, material_sha,
  validate run) и выход; модель и интеграция не запускаются; возврат автору —
  только на явном `false`.
- process-resume.yml + scripts/process-resume.mjs: на `workflow_run: completed`
  Validate по ветке issue/* — если метка S7 стоит, активного прогона нет и
  последний прогон оставил маркер на этот SHA, переставить S7 (HP_PROCESS_TOKEN);
  новый прогон находит завершённый dispatch сразу. Без маркера не будит.
- process-reconcile.mjs: читает маркер и состояние Validate на материале;
  идёт — wait, завершился/пропал без продолжения — retry; без маркера — прежний
  escalate. Общий loadSealedArtifact, экспорт processRuns/artifactNames.
- preflight сверяет process-resume.yml между main и dev наравне с process.yml.
- Тесты: validate-gate (4), process-resume (8, включая контракт трёх workflow),
  process-reconcile (2); мутанты gate-no-wait-still-sleeps,
  resume-wakes-round-without-marker, resume-ignores-active-run,
  reconcile-wakes-pending-while-validate-active. PROCESS.md §10.4, AGENTS.md.

Issue: #636
User-Visible: no
This commit is contained in:
Claude
2026-09-23 08:51:17 +03:00
parent e29dfdeb72
commit 351fef43d6
14 changed files with 580 additions and 32 deletions
+45
View File
@@ -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',
+55 -9
View File
@@ -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) };
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env node
// #636: продолжение раунда ревью по событию завершения Validate.
//
// До #636 стадия `prepare` конвейера ждала Validate с мутантами на материале
// внутри job: раннер спал ≈ 28 минут на раунд при 10–12 минутах работы модели
// и упирался в бюджет стадии. Теперь `prepare` диспатчит прогон, кладёт
// запечатанный маркер `review-pending-<issue>-<run>-<attempt>` и выходит.
// Этот скрипт запускает `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=<owner/repo> --branch=<issue/NN-slug> --sha=<sha> --event=<event> --status=<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`);
}
+27 -8
View File
@@ -7,10 +7,15 @@
* доказательство для ревью — dispatch-прогон на точном SHA материала. Push-
* прогон на том же SHA зелёный не считается: в нём мутантов нет.
*
* node scripts/validate-gate.mjs --repo=<owner/repo> --ref=<ветка> --sha=<sha> [--workflow=validate.yml]
* node scripts/validate-gate.mjs --repo=<owner/repo> --ref=<ветка> --sha=<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=<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}`];
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);
}