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
+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) };