diff --git a/AGENTS.md b/AGENTS.md index baf20b83..6b122b70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/PROCESS.md b/PROCESS.md index 9c51dd48..32424803 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -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` не +ждать — задача ждёт владельца. **После прогона ревью метка меняется всегда.** Инвариант появился не сразу: первая редакция при конфликте слияния оставляла метку на месте, и это оказалось тупиком — diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index a04273df..f091a59b 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -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', diff --git a/scripts/wait-verdict.mjs b/scripts/wait-verdict.mjs index 6d436725..1f90638d 100644 --- a/scripts/wait-verdict.mjs +++ b/scripts/wait-verdict.mjs @@ -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 { diff --git a/test/wait-verdict.test.mjs b/test/wait-verdict.test.mjs index b3917361..95bb083e 100644 --- a/test/wait-verdict.test.mjs +++ b/test/wait-verdict.test.mjs @@ -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'))); +});