Files
houseplan-card/scripts/process-resume.mjs
T
Claude 351fef43d6 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
2026-09-23 08:51:17 +03:00

118 lines
7.1 KiB
JavaScript

#!/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`);
}