Files
houseplan-card/test/validate-gate.test.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

214 lines
13 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #510 §5: the review pipeline proves mutants on the material before spending a review cycle.
import assert from 'node:assert/strict';
import test from 'node:test';
import { validateGate, isMutantRun, provesMutants } from '../scripts/validate-gate.mjs';
import { buildCiProof } from '../scripts/ci-proof.mjs';
const SHA = 'a'.repeat(40);
const TREE = 'b'.repeat(40);
/** Fake gh: a scripted list of run snapshots per call, a virtual clock. */
const MUTANT_JOBS = [1, 2, 3, 4, 5, 6].map((n) => ({ name: `Мутанты по диффу (${n}/6): затронутые свидетели краснеют`, conclusion: 'success' }));
const OTHER_JOBS = [{ name: 'Фронтенд: типы, юниты, мутанты, синхрон бандла', conclusion: 'success' }];
const BASE_JOBS = [
{ name: 'Предполёт: документация, провенанс, процесс', conclusion: 'success' },
{ name: 'Классификация изменённых файлов', conclusion: 'success' },
{ name: 'Переиспользование: это дерево уже проверено', conclusion: 'success' },
];
function fakeOps({ snapshots, onRef = [], jobsById = {} }) {
let clock = 0;
let calls = 0;
const dispatched = [];
return {
ops: {
listRuns: async () => { const s = snapshots[Math.min(calls, snapshots.length - 1)]; calls += 1; return s; },
listRunsOnRef: async () => onRef,
jobs: async (id) => jobsById[id] ?? [...OTHER_JOBS, ...MUTANT_JOBS],
candidateTree: async () => TREE,
proof: async (row) => {
const selected = jobsById[row.databaseId] ?? [...OTHER_JOBS, ...MUTANT_JOBS];
const mutants = provesMutants(selected);
const proof = buildCiProof({
candidateSha: SHA, candidateTree: TREE, runId: row.databaseId,
attempt: row.attempt ?? 1, event: row.event,
needs: {
preflight: { result: 'success' },
changes: { result: 'success', outputs: {
heavy: 'false', mutants_requested: String(mutants),
frontend: 'true', backend: 'false', integration: 'false',
} },
reuse: { result: 'success', outputs: {} },
frontend: { result: 'success' },
changed_mutants: { result: mutants ? 'success' : 'skipped' },
},
});
return { proof, jobs: [...BASE_JOBS, ...selected], reuseRuns: new Map() };
},
dispatch: async (ref) => { dispatched.push(ref); },
sleep: async (ms) => { clock += ms; },
now: () => clock,
},
dispatched,
calls: () => calls,
};
}
const run = (over) => ({ databaseId: 1, status: 'completed', conclusion: 'success', url: 'https://run/1', event: 'workflow_dispatch', headSha: SHA, ...over });
test('#510: only a dispatch run proves mutants; a push run on the same SHA does not', () => {
assert.equal(isMutantRun(run()), true);
assert.equal(isMutantRun(run({ event: 'push' })), false);
});
test('#510 (ревью ТЗ r1): proof needs the mutant jobs executed and green, not just a green run', () => {
assert.equal(provesMutants([...OTHER_JOBS, ...MUTANT_JOBS]), true);
assert.equal(provesMutants(OTHER_JOBS), false, 'no mutant job at all — mutants were not requested');
assert.equal(provesMutants([...OTHER_JOBS, ...MUTANT_JOBS.map((job) => ({ ...job, conclusion: 'skipped' }))]), false, 'skipped is not executed');
assert.equal(provesMutants([...MUTANT_JOBS.slice(0, 2), { ...MUTANT_JOBS[2], conclusion: 'failure' }]), false);
assert.equal(provesMutants([]), false);
});
test('#510 (ревью ТЗ r1): a green foreign dispatch whose mutant jobs were skipped is ignored — the gate dispatches its own', async () => {
const foreign = run({ databaseId: 5, url: 'https://run/foreign' });
const own = run({ databaseId: 6, url: 'https://run/own' });
const fake = fakeOps({
snapshots: [[foreign], [foreign], [own, foreign], [own, foreign]],
jobsById: { 5: [...OTHER_JOBS, ...MUTANT_JOBS.map((job) => ({ ...job, conclusion: 'skipped' }))] },
});
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, pollMs: 1000 });
assert.equal(outcome.result, 'green');
assert.equal(outcome.url, 'https://run/own');
assert.deepEqual(fake.dispatched, ['issue/1']);
});
test('#510 AC2: a completed green dispatch run on the material is accepted without a new dispatch', async () => {
const fake = fakeOps({ snapshots: [[run({ event: 'push', databaseId: 7 }), run()]] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops });
assert.equal(outcome.result, 'green');
assert.equal(outcome.url, 'https://run/1');
assert.deepEqual(fake.dispatched, []);
});
test('#510 AC2: a completed red dispatch run returns the task without review', async () => {
const fake = fakeOps({ snapshots: [[run({ conclusion: 'failure', url: 'https://run/red' })]] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops });
assert.equal(outcome.result, 'failed');
assert.equal(outcome.url, 'https://run/red');
});
test('#510 AC2: a green push run alone is not proof — the gate dispatches and waits', async () => {
const pushOnly = [run({ event: 'push', databaseId: 7 })];
const fake = fakeOps({ snapshots: [pushOnly, pushOnly, [...pushOnly, run({ status: 'in_progress', conclusion: null })], [...pushOnly, run()]] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, pollMs: 1000 });
assert.equal(outcome.result, 'green');
assert.deepEqual(fake.dispatched, ['issue/1'], 'exactly one dispatch on the branch');
});
test('#510 AC2: the dispatch that never appears is reported as missing, naming a moved material', async () => {
const fake = fakeOps({ snapshots: [[]], onRef: [run({ headSha: 'b'.repeat(40), url: 'https://run/other' })] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, appearMs: 5000, pollMs: 1000 });
assert.equal(outcome.result, 'missing');
assert.equal(outcome.url, 'https://run/other');
assert.match(outcome.note, /ссылка ветки не указывает на материал/);
assert.match(outcome.note, /bbbbbbbb/);
// #539: прежде чем обвинять автора, гейт пробует ещё раз — своя гонка
// закрывается, чужой коммит переживает и вторую попытку.
assert.deepEqual(fake.dispatched, ['issue/1', 'issue/1']);
});
// #539: `workflow_dispatch` принимает только ref, а имя ветки резолвится на
// стороне GitHub. Конвейер сам переписывает ветку ребейзом, и 12.09 на #536
// диспатч встал на ДОпушевый SHA — задача вернулась автору, которому чинить
// было нечего. Вторая попытка обязана попасть на материал.
test('#539: диспатч, промахнувшийся по устаревшей ссылке, повторяется и принимается', async () => {
const landed = run({ databaseId: 7, url: 'https://run/7' });
const fake = fakeOps({
// пусто → пусто (промах, повтор) → прогон на материале
snapshots: [[], [], [landed]],
onRef: [run({ headSha: 'b'.repeat(40), url: 'https://run/other' })],
});
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, appearMs: 5000, pollMs: 6000 });
assert.equal(outcome.result, 'green', 'повторный диспатч попал на материал');
assert.equal(outcome.url, 'https://run/7');
assert.deepEqual(fake.dispatched, ['issue/1', 'issue/1'], 'ровно две попытки, не бесконечно');
});
test('#510 AC2: a dispatch that never finishes is red after the total window', async () => {
const running = [run({ status: 'in_progress', conclusion: null })];
const fake = fakeOps({ snapshots: [running] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, totalMs: 10_000, pollMs: 4000 });
assert.equal(outcome.result, 'failed');
assert.match(outcome.note, /не завершился/);
assert.deepEqual(fake.dispatched, []);
});
test('#510: the tracked dispatch run is followed even when a newer dispatch appears first in the list', async () => {
const first = run({ databaseId: 1, status: 'in_progress', conclusion: null });
const newer = run({ databaseId: 2, status: 'in_progress', conclusion: null, url: 'https://run/2' });
const fake = fakeOps({ snapshots: [[first], [newer, first], [newer, run({ databaseId: 1, url: 'https://run/1' })]] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, pollMs: 1000 });
assert.equal(outcome.result, 'green');
assert.equal(outcome.url, 'https://run/1');
});
test('#510 r1 M1: a cancelled dispatch proves nothing — the gate waits for its replacement instead of returning the task', async () => {
const cancelled = run({ databaseId: 1, conclusion: 'cancelled', url: 'https://run/cancelled' });
const replacement = run({ databaseId: 2, url: 'https://run/2' });
const fake = fakeOps({ snapshots: [[cancelled], [replacement, cancelled]] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, pollMs: 1000 });
assert.equal(outcome.result, 'green');
assert.equal(outcome.url, 'https://run/2');
assert.deepEqual(fake.dispatched, [], 'the replacement was already there — no extra dispatch');
});
test('#510 r1 M1: a cancelled dispatch with no replacement gets one dispatch, not a red verdict', async () => {
const cancelled = run({ databaseId: 1, conclusion: 'cancelled' });
const fake = fakeOps({ snapshots: [[cancelled], [cancelled], [run({ databaseId: 3, url: 'https://run/3' }), cancelled]] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, pollMs: 1000 });
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 прошёл');
});