fix(process): bind verdict wait to current review round (#546)

Issue: #546
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-13 10:26:12 +03:00
parent a388243129
commit 5fc596c748
5 changed files with 118 additions and 6 deletions
+4 -1
View File
@@ -245,7 +245,10 @@ the command timeout. Do the polling with `node scripts/wait-verdict.mjs --issue
cancelled merge, failed run) and optionally Validate on the SHA, prints only when
the state changes and exits 0 on a new label, 3 on an event that needs a hand,
4 on timeout — the same 90 s × 30 without a model turn per tick. It writes
nothing. Watch the **label**, not the comment: the label is the state,
nothing. Pipeline comments older than the latest application of `S4`/`S7` are
the baseline, not an outcome of the new round; an outcome from the current round
which already exists when the waiter starts is still delivered immediately (#546).
Watch the **label**, not the comment: the label is the state,
the comment only explains it. Do not wait at all while `blocked` is set — the task
is waiting on the owner, not on the reviewer. On exhausting the attempts, stop and
tell the owner: a failed run leaves the label where it was, forever.
+4 -2
View File
@@ -962,8 +962,10 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
он существует только в момент своего хода, поэтому ожидание это опрос: раз в 90
секунд, не более 30 попыток — `node scripts/wait-verdict.mjs --issue NN` делает его
детерминированно и говорит только при смене состояния (#496). Смотреть на метку, а
не на комментарий: метка и есть состояние. При `blocked` не ждать — задача ждёт
владельца.
не на комментарий: метка и есть состояние. Комментарии конвейера до последнего
применения `S4`/`S7` считаются историческим baseline, а уже опубликованный исход
текущего раунда доставляется сразу при первом опросе (#546). При `blocked` не
ждать — задача ждёт владельца.
**После прогона ревью метка меняется всегда.** Инвариант появился не сразу: первая
редакция при конфликте слияния оставляла метку на месте, и это оказалось тупиком —
+11
View File
@@ -8653,6 +8653,17 @@ const MUTANT_DEFINITIONS = [
+ ' # The store is the durable authority (#335): a drop that\n',
}],
},
{
id: 'wait-verdict-reuses-historical-failure',
guard: 'node --test --test-name-pattern="#546" test/wait-verdict.test.mjs',
because: 'the first snapshot must baseline pipeline comments older than the latest S4/S7 request; '
+ 'otherwise every restarted review immediately exits on the previous round failure (#546 AC1/AC2)',
patches: [{
file: 'scripts/wait-verdict.mjs',
find: ' .filter((c) => c.event && eventBelongsToReview(c, snapshot.reviewRequest));',
replace: ' .filter((c) => c.event); // mutant: every historical event belongs to the current round',
}],
},
{
id: 'release-gate-counts-cancelled-runs',
guard: 'node --test test/release-gate.test.mjs',
+30 -2
View File
@@ -8,7 +8,10 @@
// состояние не будит никого. Что доставляется: смена статусной метки (вердикт),
// отказ конвейера (комментарий «Ревью не запускалось» / «Слияние отменено» /
// «Автоматическое ревью не отработало»), `blocked`, `review-4`, а при `--sha` —
// исход Validate на этом SHA.
// исход Validate на этом SHA. Исторические комментарии до последнего запроса
// S4/S7 образуют baseline: иначе новый раунд немедленно завершался по старому
// failure. Событие текущего раунда, даже опубликованное до запуска waiter,
// доставляется сразу (#546).
//
// Скрипт НИЧЕГО не пишет: ни меток, ни комментариев, ни запусков. Новое ревью
// или релиз начинаются только по текущей авторизации человека.
@@ -36,13 +39,34 @@ export const PIPELINE_EVENTS = [
{ re: /^Конвейер ревью не запущен:/m, kind: 'refused', text: 'конвейер отказал (blocked/review-4) — читать комментарий' },
];
/** Последнее применение S4/S7 — устойчивый якорь текущего раунда ревью. */
export function reviewRequestFromEvents(events = []) {
const requests = events
.filter((event) => event?.event === 'labeled' && REVIEW_LABELS.includes(event?.label?.name))
.map((event) => ({
id: String(event.id || event.node_id || event.createdAt || event.created_at || ''),
at: event.createdAt || event.created_at || null,
label: event.label.name,
}))
.filter((request) => Number.isFinite(Date.parse(String(request.at || ''))));
requests.sort((a, b) => Date.parse(a.at) - Date.parse(b.at) || a.id.localeCompare(b.id));
return requests.at(-1) || null;
}
function eventBelongsToReview(comment, request) {
if (!request) return true; // Совместимость с чистыми/старыми snapshot без timeline.
const eventAt = Date.parse(String(comment.at || ''));
const requestAt = Date.parse(String(request.at || ''));
return Number.isFinite(eventAt) && Number.isFinite(requestAt) && eventAt >= requestAt;
}
/** Снимок → нормализованное состояние. */
export function stateOf(snapshot) {
const labels = snapshot.labels || [];
const status = STATUS.find((l) => labels.includes(l)) || null;
const events = (snapshot.comments || [])
.map((c) => ({ id: c.id, at: c.createdAt, event: PIPELINE_EVENTS.find((e) => e.re.test(String(c.body || ''))) }))
.filter((c) => c.event);
.filter((c) => c.event && eventBelongsToReview(c, snapshot.reviewRequest));
const last = events.at(-1) || null;
return {
status,
@@ -50,6 +74,7 @@ export function stateOf(snapshot) {
exhausted: labels.includes('review-4'),
lastEventId: last ? String(last.id) : null,
lastEvent: last ? last.event : null,
reviewRequest: snapshot.reviewRequest || null,
validate: snapshot.validate || null,
};
}
@@ -115,9 +140,12 @@ function gh(args) {
export function ghSnapshotReader({ number, repo, sha }) {
return async () => {
const view = gh(['issue', 'view', String(number), '--repo', repo, '--json', 'labels,comments']);
const pages = gh(['api', '--paginate', '--slurp', `repos/${repo}/issues/${number}/events?per_page=100`]);
const timelineEvents = Array.isArray(pages?.[0]) ? pages.flat() : (Array.isArray(pages) ? pages : []);
const snapshot = {
labels: (view.labels || []).map((l) => l.name),
comments: (view.comments || []).map((c) => ({ id: c.id || c.url || c.createdAt, createdAt: c.createdAt, body: c.body })),
reviewRequest: reviewRequestFromEvents(timelineEvents),
};
if (sha) {
try {
+69 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { decide, stateOf, waitForVerdict } from '../scripts/wait-verdict.mjs';
import { decide, reviewRequestFromEvents, stateOf, waitForVerdict } from '../scripts/wait-verdict.mjs';
// #496: ожидание детерминировано — одинаковое состояние молчит, смена метки и
// события конвейера доставляются один раз, ничего не пишется.
@@ -56,3 +56,71 @@ test('лимит ожидания — код 4 и одна строка (#496)',
assert.equal(lines.length, 2);
assert.match(lines[1], /лимит ожидания \(3 × 0 с\)/);
});
test('старый failure до нового запроса ревью — baseline, ожидание продолжается (#546)', async () => {
const oldFailure = {
id: 'old', createdAt: '2026-09-08T10:00:00Z',
body: 'Автоматическое ревью не отработало: [прогон](old).',
};
const currentRequest = { id: 'request-2', at: '2026-09-12T10:00:00Z', label: 'S7-code-review' };
const snapshot = { ...snap(['S7-code-review'], [oldFailure]), reviewRequest: currentRequest };
const lines = []; let slept = 0;
const code = await waitForVerdict({
readSnapshot: async () => snapshot,
intervalMs: 1, maxTicks: 2, sleep: async () => { slept++; }, log: (line) => lines.push(line),
});
assert.equal(code, 4);
assert.equal(slept, 1);
assert.equal(lines.some((line) => line.includes('прогон ревью упал')), false);
});
test('failure текущего раунда, опубликованный до запуска waiter, виден на первом poll (#546)', async () => {
const currentFailure = {
id: 'current', createdAt: '2026-09-12T10:05:00Z',
body: 'Автоматическое ревью не отработало: [прогон](current).',
};
const snapshot = {
...snap(['S7-code-review'], [currentFailure]),
reviewRequest: { id: 'request-2', at: '2026-09-12T10:00:00Z', label: 'S7-code-review' },
};
const lines = []; let slept = 0;
const code = await waitForVerdict({
readSnapshot: async () => snapshot,
intervalMs: 1, maxTicks: 2, sleep: async () => { slept++; }, log: (line) => lines.push(line),
});
assert.equal(code, 3);
assert.equal(slept, 0);
assert.ok(lines.some((line) => line.includes('прогон ревью упал')));
});
test('якорь раунда — последнее применение любой review-метки (#546)', () => {
const request = reviewRequestFromEvents([
{ id: 1, event: 'labeled', created_at: '2026-09-10T09:00:00Z', label: { name: 'S4-spec-review' } },
{ id: 2, event: 'labeled', created_at: '2026-09-10T10:00:00Z', label: { name: 'P1' } },
{ id: 3, event: 'unlabeled', created_at: '2026-09-10T11:00:00Z', label: { name: 'S4-spec-review' } },
{ id: 4, event: 'labeled', created_at: '2026-09-12T09:00:00Z', label: { name: 'S7-code-review' } },
]);
assert.deepEqual(request, { id: '4', at: '2026-09-12T09:00:00Z', label: 'S7-code-review' });
});
test('новые outcome и owner blocker текущего раунда не скрываются baseline-фильтром (#546)', () => {
const request = { id: 'request', at: '2026-09-12T10:00:00Z', label: 'S7-code-review' };
const stale = {
id: 'stale', createdAt: '2026-09-12T10:05:00Z',
body: '**Слияние отменено: ветка изменилась после проверенного материала (#312).**',
};
const event = decide(null, stateOf({ ...snap(['S7-code-review'], [stale]), reviewRequest: request }));
assert.equal(event.code, 3);
assert.ok(event.lines.some((line) => line.includes('слияние отменено')));
const blocker = decide(null, stateOf({ ...snap(['S7-code-review', 'blocked']), reviewRequest: request }));
assert.equal(blocker.code, 3);
assert.ok(blocker.lines.some((line) => line.includes('blocked')));
const verdict = decide(
stateOf({ ...snap(['S7-code-review']), reviewRequest: request }),
stateOf({ ...snap(['S8-merged']), reviewRequest: request }),
);
assert.equal(verdict.code, 0);
assert.ok(verdict.lines.some((line) => line.includes('S7-code-review → S8-merged')));
});