From e3bf893e3d76b3276a548ee9182f073c901a12e9 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 13 Sep 2026 15:26:13 +0300 Subject: [PATCH] =?UTF-8?q?ci:=20=D0=B2=D0=BE=D1=81=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=D0=B2=D0=BB=D0=B8=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=82=D0=B5=D1=80=D1=8F=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D1=8B=20=D1=80=D0=B5=D0=B2=D1=8C?= =?UTF-8?q?=D1=8E=20(#555)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: #555 User-Visible: no --- .github/workflows/process-reconcile.yml | 57 ++++ .github/workflows/process.yml | 1 + AGENTS.md | 9 + PROCESS.md | 19 ++ scripts/mutation-gate.mjs | 55 ++++ scripts/process-reconcile.mjs | 399 ++++++++++++++++++++++++ test/process-reconcile.test.mjs | 159 ++++++++++ test/review-doc-guard.test.mjs | 22 ++ 8 files changed, 721 insertions(+) create mode 100644 .github/workflows/process-reconcile.yml create mode 100644 scripts/process-reconcile.mjs create mode 100644 test/process-reconcile.test.mjs diff --git a/.github/workflows/process-reconcile.yml b/.github/workflows/process-reconcile.yml new file mode 100644 index 00000000..eace6efe --- /dev/null +++ b/.github/workflows/process-reconcile.yml @@ -0,0 +1,57 @@ +name: Сверка очереди ревью +run-name: "reconcile process queue · ${{ github.event_name }}" + +on: + schedule: + - cron: '7,37 * * * *' + workflow_dispatch: + inputs: + apply: + description: "Повторно будить потерянные запросы и публиковать диагностику" + required: true + type: boolean + default: true + +permissions: + actions: read + contents: read + issues: read + +jobs: + reconcile: + name: "Один снимок S4/S7 без polling модели" + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: process-reconcile + cancel-in-progress: false + steps: + # Расписание читается из main, а исполняемый reconciler — из dev: так + # после штатного merge действует та же версия кода, которую проверил CI. + - uses: actions/checkout@v7 + with: + ref: dev + fetch-depth: 1 + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: 22 + - name: Сопоставить labels, requests, runs и sealed evidence + env: + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + REPO: ${{ github.repository }} + APPLY: ${{ github.event_name == 'schedule' || inputs.apply == true }} + run: | + mkdir -p artifacts/process-reconcile + node scripts/process-reconcile.mjs \ + --repo "$REPO" \ + --apply="$APPLY" \ + --max-actions=5 \ + --output=artifacts/process-reconcile/summary.json + - name: Опубликовать компактный machine-readable итог + uses: actions/upload-artifact@v4 + with: + name: process-reconcile-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/process-reconcile/summary.json + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index ddb522a8..6ca0440d 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -1,4 +1,5 @@ name: Ревью-конвейер +run-name: "process #${{ github.event.issue.number }} · ${{ github.event.label.name }} · ${{ github.event.issue.title }}" # Событийный конвейер процесса (PROCESS.md). Смена статусной метки — это # сообщение: она порождает событие, событие запускает следующий шаг. diff --git a/AGENTS.md b/AGENTS.md index d85b6dc0..a3cdafe2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -268,6 +268,15 @@ What the new label means: **After a review run the label always changes.** If it did not, the run itself failed rather than the work — say so to the owner instead of polling on. +The repository also has a bounded queue reconciler (#555). It takes one S4/S7 +snapshot every thirty minutes and exits. It may re-apply the same review label +only when the matching event was lost or its run ended with a transient +cancellation/timeout before a sealed result existed. It never applies verdicts or +merges. Running work, `blocked`, `review-4`, a foreign material/stage/attempt, a +guard failure, or an unintegrated sealed model result is left untouched and gets +at most one machine-keyed diagnostic. This is a safety net, not permission for an +agent to stop waiting for the result of the review it started. + **A failed pre-release gate does not send the issue back to review.** The implementation loop runs only typecheck, unit and build; golden, browser smokes, performance and the full HA harness run before a beta, which is after the code diff --git a/PROCESS.md b/PROCESS.md index 82a889f6..c57e16f1 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -1069,6 +1069,25 @@ npm ci, Python и Chromium, оставаясь исполненной job: до Если метка не сменилась, значит упал сам прогон, а не работа: смотреть логи и сообщать владельцу, а не продолжать опрос. +**Очередь S4/S7 сверяется отдельным bounded controller (#555).** Workflow +`process-reconcile.yml` раз в полчаса делает один снимок открытых задач и +завершается — активного polling одинакового состояния и вызова модели на каждый +тик нет. Он сопоставляет последнюю постановку `S4`/`S7` с run по номеру задачи и +этапу; если стадия успела подготовить материал, дополнительно проверяет +запечатанные run/attempt, SHA/tree, список блобов ТЗ и номер раунда. Текущий +`blocked`, `review-4` или снятая review-метка всегда сильнее старого события. + +Автоматически и не более одного раза повторно применяется только сама review-метка после доказанно +потерянного события либо transient-исхода до получения запечатанного результата +(`cancelled`, `timed_out`, `stale`, `startup_failure`, `skipped`). Это не применяет +вердикт и не расходует цикл: обычный конвейер заново читает актуальные labels и +материал. Здоровый running run не трогается. Failure guard, неизвестная связь, +чужой material/stage/attempt, success без смены метки и сбой после появления +`review-result`, а также потеря повторного события получают один дедуплицированный диагностический комментарий и +эскалацию человеку — второй вызов модели или S8 по догадке запрещены. Перед любой +записью controller перечитывает состояние; итог каждого прохода публикуется как +`houseplan-process-reconcile/v1` artifact. + **Конвейер — идемпотентный контроллер, а событие лишь будит его** (#499). Guard читает метки issue текущими, а не из снимка события: прогон мог простоять в очереди, пока владелец снял метку — отозванный запрос не исполняется, и комментария об этом diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 03766e8c..ea2785d2 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -7893,6 +7893,61 @@ const MUTANT_DEFINITIONS = [ + ' никогда — именно оно в этом процессе заменяет тестирование.', }], }, + { + id: 'process-reconcile-restarts-healthy-run', + guard: 'node --test test/process-reconcile.test.mjs', + because: '#555: bounded reconciliation must not duplicate a healthy queued/running review ' + + 'or turn metadata polling into another model invocation', + patches: [{ + file: 'scripts/process-reconcile.mjs', + find: " if (age <= activeLimitMs) return result('wait', 'matching process run is healthy and active', { label, stage, run });", + replace: " if (age <= activeLimitMs) return result('retry', 'mutant: restart healthy run', { label, stage, run });", + }], + }, + { + id: 'process-reconcile-ignores-owner-stop', + guard: 'node --test test/process-reconcile.test.mjs', + because: '#555: blocked/review-4 is a current owner decision and always wins over an old ' + + 'lost-event or cancelled-run observation', + patches: [{ + file: 'scripts/process-reconcile.mjs', + find: " if (labels.includes('blocked') || labels.includes('review-4')) {", + replace: " if (false && (labels.includes('blocked') || labels.includes('review-4'))) {", + }], + }, + { + id: 'process-reconcile-accepts-foreign-prepared-evidence', + guard: 'node --test test/process-reconcile.test.mjs', + because: '#555: an artifact from another issue, stage, run or attempt is diagnosis only and ' + + 'must never authorize recovery or verdict application', + patches: [{ + file: 'scripts/process-reconcile.mjs', + find: ' if (badIdentity) return \'prepared artifact belongs to another issue/stage/run attempt\';', + replace: ' if (false && badIdentity) return \'mutant: foreign identity accepted\';', + }], + }, + { + id: 'process-reconcile-reruns-sealed-model-result', + guard: 'node --test test/process-reconcile.test.mjs', + because: '#555: a sealed model artifact must be escalated for deterministic integration, not ' + + 'paid for a second time by an automatic relabel', + patches: [{ + file: 'scripts/process-reconcile.mjs', + find: ' if (run.resultArtifact) {', + replace: ' if (false && run.resultArtifact) {', + }], + }, + { + id: 'process-reconcile-retries-lost-event-forever', + guard: 'node --test test/process-reconcile.test.mjs', + because: '#555: one lost wake-up gets one automatic relabel; if that event is lost too, ' + + 'reconciliation escalates instead of generating a run/comment every schedule tick', + patches: [{ + file: 'scripts/process-reconcile.mjs', + find: ' if (retryAlreadyIssued) {', + replace: ' if (false && retryAlreadyIssued) {', + }], + }, { id: 'review-integration-skips-evidence-checksum', guard: 'node --test --test-name-pattern="#551" test/review-doc-guard.test.mjs', diff --git a/scripts/process-reconcile.mjs b/scripts/process-reconcile.mjs new file mode 100644 index 00000000..47c5aa06 --- /dev/null +++ b/scripts/process-reconcile.mjs @@ -0,0 +1,399 @@ +#!/usr/bin/env node +// Bounded reconciliation for the event-driven S4/S7 controller (#555). +// +// The label remains the state. This script never publishes a verdict and never +// merges anything; it only wakes the ordinary controller after a proven lost or +// transiently terminated request, or leaves one deduplicated diagnostic when +// recovery would require guessing. One invocation reads one snapshot and exits: +// it does not poll and therefore cannot turn a healthy wait into model usage. + +import { createHash } from 'node:crypto'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { isMainModule } from './spawn-portable.mjs'; + +export const REVIEW_LABELS = ['S4-spec-review', 'S7-code-review']; +export const ACTIVE_RUN_STATES = new Set(['queued', 'in_progress', 'pending', 'requested', 'waiting']); +export const RETRYABLE_CONCLUSIONS = new Set(['cancelled', 'timed_out', 'stale', 'startup_failure', 'skipped']); +export const DEFAULT_GRACE_MS = 5 * 60_000; +export const DEFAULT_ACTIVE_LIMIT_MS = 4 * 60 * 60_000; + +const STAGE = { 'S4-spec-review': 'spec', 'S7-code-review': 'code' }; +const RUN_TITLE = /^process #(\d+) · (S4-spec-review|S7-code-review)(?: ·|$)/; +const MARKER_PREFIX = 'houseplan-process-reconcile:v1'; +const RETRY_COMMENT = /houseplan-process-reconcile:v1:[^\s]+[\s\S]*Автосверка процесса повторно/; + +const at = (value) => { + const parsed = Date.parse(String(value || '')); + return Number.isFinite(parsed) ? parsed : NaN; +}; + +const labelsOf = (issue) => (issue?.labels || []).map((label) => + typeof label === 'string' ? label : label?.name).filter(Boolean); + +export function parseProcessRun(run = {}) { + const match = String(run.displayTitle || run.display_title || run.name || '').match(RUN_TITLE); + if (!match) return null; + return { + id: Number(run.databaseId ?? run.id), + attempt: Number(run.attempt ?? run.run_attempt ?? 1), + issue: Number(match[1]), + label: match[2], + stage: STAGE[match[2]], + status: String(run.status || ''), + conclusion: run.conclusion == null ? '' : String(run.conclusion), + createdAt: run.createdAt || run.created_at || run.run_started_at || null, + updatedAt: run.updatedAt || run.updated_at || run.completedAt || null, + url: run.url || run.html_url || null, + prepared: run.prepared || null, + preparedArtifact: Boolean(run.preparedArtifact), + resultArtifact: Boolean(run.resultArtifact), + evidenceError: run.evidenceError || null, + }; +} + +export function latestReviewRequest(events = [], label = null) { + 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, + actor: event.actor?.login || null, + })) + .filter((request) => Number.isFinite(at(request.at)) && (!label || request.label === label)); + requests.sort((a, b) => at(a.at) - at(b.at) || a.id.localeCompare(b.id)); + return requests.at(-1) || null; +} + +export function preparedEvidenceError(prepared, { issue, stage, run }) { + if (!prepared) return null; + const badIdentity = prepared.schema !== 1 + || String(prepared.run_id) !== String(run.id) + || String(prepared.run_attempt) !== String(run.attempt) + || String(prepared.issue) !== String(issue.number) + || prepared.stage !== stage; + if (badIdentity) return 'prepared artifact belongs to another issue/stage/run attempt'; + if (!/^[0-9a-f]{40}$/i.test(String(prepared.material_sha || '')) + || !/^[0-9a-f]{40}$/i.test(String(prepared.material_tree || '')) + || typeof prepared.material_specs !== 'string' + || !/^[1-9][0-9]*$/.test(String(prepared.cycle || ''))) { + return 'prepared artifact has incomplete material SHA/tree/spec hash or round'; + } + return null; +} + +function result(action, reason, context = {}) { + return { action, reason, ...context }; +} + +/** Pure controller decision. It never interprets or applies a model verdict. */ +export function decideReconciliation({ + issue, request = null, runs = [], now = Date.now(), graceMs = DEFAULT_GRACE_MS, + activeLimitMs = DEFAULT_ACTIVE_LIMIT_MS, +}) { + const labels = labelsOf(issue); + const statuses = REVIEW_LABELS.filter((label) => labels.includes(label)); + if (statuses.length === 0) return result('noop', 'verdict already applied or issue is not awaiting review'); + if (statuses.length !== 1) return result('escalate', 'ambiguous review status labels', { label: null }); + const label = statuses[0]; + const stage = STAGE[label]; + if (labels.includes('blocked') || labels.includes('review-4')) { + return result('noop', 'owner intentionally stopped review', { label, stage }); + } + if (!request || request.label !== label || !Number.isFinite(at(request.at))) { + return result('escalate', 'current review label has no unambiguous timeline request', { label, stage }); + } + + const requestAt = at(request.at); + const matching = runs + .map((run) => run.issue ? run : parseProcessRun(run)) + .filter(Boolean) + .filter((run) => run.issue === Number(issue.number) && run.label === label + && Number.isFinite(at(run.createdAt)) && at(run.createdAt) >= requestAt - 120_000) + .sort((a, b) => at(b.createdAt) - at(a.createdAt) || Number(b.id) - Number(a.id)); + const run = matching[0] || null; + + if (!run) { + if (now - requestAt < graceMs) return result('wait', 'label event is still within delivery grace', { label, stage }); + const retryAlreadyIssued = (issue?.comments || []).some((comment) => + RETRY_COMMENT.test(String(comment?.body || '')) && at(comment?.createdAt) >= requestAt); + if (retryAlreadyIssued) { + return result('escalate', 'one automatic retry was already issued but still has no matching run', { label, stage }); + } + return result('retry', 'label event has no matching process run', { label, stage }); + } + if (run.evidenceError) { + return result('escalate', `run evidence is invalid: ${run.evidenceError}`, { label, stage, run }); + } + const evidenceError = preparedEvidenceError(run.prepared, { issue, stage, run }); + if (evidenceError) return result('escalate', evidenceError, { label, stage, run }); + + if (ACTIVE_RUN_STATES.has(run.status)) { + const age = now - at(run.createdAt); + if (age <= activeLimitMs) return result('wait', 'matching process run is healthy and active', { label, stage, run }); + return result('escalate', 'matching process run is active beyond the controller budget', { label, stage, run }); + } + if (run.status !== 'completed') { + return result('escalate', `unknown process run status: ${run.status || 'missing'}`, { label, stage, run }); + } + const settledAt = at(run.updatedAt || run.createdAt); + 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') { + return result('escalate', 'successful run did not move the review label', { label, stage, run }); + } + if (run.resultArtifact) { + return result('escalate', 'sealed model result exists but was not integrated; automatic rerun would spend the model twice', { label, stage, run }); + } + if (RETRYABLE_CONCLUSIONS.has(run.conclusion)) { + return result('retry', `transient process run conclusion: ${run.conclusion}`, { label, stage, run }); + } + return result('escalate', `non-transient process run conclusion: ${run.conclusion || 'missing'}`, { label, stage, run }); +} + +export function reconciliationKey(issue, request, decision) { + const raw = [issue.number, request?.id || 'no-request', decision.label || 'ambiguous', + decision.run?.id || 'no-run', decision.run?.attempt || '0', decision.action, decision.reason].join('|'); + return createHash('sha256').update(raw).digest('hex').slice(0, 20); +} + +export function markerFor(key) { + return ``; +} + +export function alreadyReported(issue, key) { + const marker = markerFor(key); + return (issue?.comments || []).some((comment) => String(comment?.body || '').includes(marker)); +} + +function gh(args, { allowFailure = false } = {}) { + const result = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + if (!allowFailure && (result.error || result.status !== 0)) { + throw new Error(`gh ${args.join(' ')} → ${(result.stderr || result.error?.message || '').trim()}`); + } + return result; +} + +function ghJson(args) { + const output = gh(args).stdout || 'null'; + return JSON.parse(output); +} + +function issueView(repo, number) { + return ghJson(['issue', 'view', String(number), '--repo', repo, '--json', 'number,title,labels,comments,updatedAt']); +} + +function issueEvents(repo, number) { + const pages = ghJson(['api', '--paginate', '--slurp', `repos/${repo}/issues/${number}/events?per_page=100`]); + return Array.isArray(pages?.[0]) ? pages.flat() : (Array.isArray(pages) ? pages : []); +} + +function openReviewIssues(repo) { + const fields = 'number,title,labels,comments,updatedAt'; + const rows = REVIEW_LABELS.flatMap((label) => ghJson([ + 'issue', 'list', '--repo', repo, '--state', 'open', '--label', label, + '--limit', '500', '--json', fields, + ])); + return [...new Map(rows.map((issue) => [issue.number, issue])).values()] + .sort((a, b) => a.number - b.number); +} + +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 || []; + }); + return pages.map((raw) => { + const stable = parseProcessRun(raw); + if (stable) return stable; + // Runs created before #555 used the issue title as display_title. Accept + // that legacy identity only when it names exactly one current S4/S7 issue; + // title ambiguity must never wake a potentially different task. + const title = String(raw.display_title || raw.displayTitle || ''); + const matches = issues.filter((issue) => issue.title === title); + if (matches.length !== 1) return null; + const labels = labelsOf(matches[0]); + const statuses = REVIEW_LABELS.filter((label) => labels.includes(label)); + if (statuses.length !== 1) return null; + const label = statuses[0]; + return parseProcessRun({ + ...raw, + display_title: `process #${matches[0].number} · ${label} · ${title}`, + }); + }).filter(Boolean); +} + +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) { + 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 manifest = join(dir, 'manifest.sha256'); + if (!existsSync(file) || !existsSync(manifest)) throw new Error('prepared artifact is incomplete'); + const body = readFileSync(file); + 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'); + return JSON.parse(body.toString('utf8')); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +function hydrateRunEvidence(repo, issue, run) { + if (!run || run.status !== 'completed') return run; + try { + const artifacts = artifactNames(repo, run); + const preparedName = `review-prepared-${issue.number}-${run.id}-${run.attempt}`; + 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' }; + } + return { + ...run, + preparedArtifact: preparedArtifacts.length === 1, + resultArtifact: resultArtifacts.length === 1, + prepared: preparedArtifacts.length === 1 + ? loadPreparedArtifact(repo, run, preparedArtifacts[0]) : null, + }; + } catch (error) { + return { ...run, evidenceError: error instanceof Error ? error.message : String(error) }; + } +} + +function commentBody(issue, request, decision, key) { + const run = decision.run; + const evidence = run?.prepared + ? ` Материал: \`${String(run.prepared.material_sha).slice(0, 12)}\`, tree \`${String(run.prepared.material_tree).slice(0, 12)}\`, ТЗ \`${run.prepared.material_specs || 'нет файлового ТЗ'}\`, раунд r${run.prepared.cycle}.` + : ''; + const link = run?.url ? ` [Прогон](${run.url}).` : ''; + if (decision.action === 'retry') { + return `${markerFor(key)}\nАвтосверка процесса повторно разбудила \`${decision.label}\`: ${decision.reason}.${link}${evidence}\n\n` + + 'Вердикт не применялся, цикл ревью не расходуется самой сверкой; новый запуск заново проверит актуальные метки и материал.'; + } + return `${markerFor(key)}\n**Автосверка процесса не стала угадывать результат.** ${decision.reason}.${link}${evidence}\n\n` + + `Запрос: \`${request?.id || 'не найден'}\`, текущая метка: \`${decision.label || labelsOf(issue).join(', ') || 'нет'}\`. ` + + 'Метка и вердикт не изменены; требуется разбор человеком.'; +} + +function addComment(repo, issue, body) { + gh(['issue', 'comment', String(issue.number), '--repo', repo, '--body', body]); +} + +function relabel(repo, issue, label) { + gh(['issue', 'edit', String(issue.number), '--repo', repo, '--remove-label', label]); + const added = gh(['issue', 'edit', String(issue.number), '--repo', repo, '--add-label', label], { allowFailure: true }); + if (added.status !== 0) { + // Best-effort rollback: leaving an issue without its review state is worse + // than a loud failed reconciliation. + gh(['issue', 'edit', String(issue.number), '--repo', repo, '--add-label', label], { allowFailure: true }); + throw new Error(`could not restore ${label}: ${(added.stderr || '').trim()}`); + } +} + +async function snapshot(repo, baseRuns, issue) { + const fresh = issueView(repo, issue.number); + const events = issueEvents(repo, issue.number); + const labels = labelsOf(fresh); + const label = REVIEW_LABELS.find((candidate) => labels.includes(candidate)) || null; + const request = latestReviewRequest(events, label); + const candidates = baseRuns.filter((run) => run.issue === issue.number && run.label === label) + .sort((a, b) => at(b.createdAt) - at(a.createdAt)); + const hydrated = candidates.length ? [hydrateRunEvidence(repo, fresh, candidates[0]), ...candidates.slice(1)] : candidates; + return { issue: fresh, request, runs: hydrated }; +} + +export async function reconcileAll({ repo, apply = false, maxActions = 5, now = Date.now() }) { + const issues = openReviewIssues(repo); + const runs = processRuns(repo, issues); + const records = []; + let mutations = 0; + for (const listed of issues) { + const first = await snapshot(repo, runs, listed); + const decision = decideReconciliation({ ...first, now }); + const key = reconciliationKey(first.issue, first.request, decision); + const record = { + issue: first.issue.number, + title: first.issue.title, + request: first.request, + action: decision.action, + reason: decision.reason, + run: decision.run ? { + id: decision.run.id, attempt: decision.run.attempt, status: decision.run.status, + conclusion: decision.run.conclusion, url: decision.run.url, + materialSha: decision.run.prepared?.material_sha || null, + materialTree: decision.run.prepared?.material_tree || null, + materialSpecs: decision.run.prepared?.material_specs || null, + round: decision.run.prepared?.cycle || null, + } : null, + key, + applied: false, + }; + if (apply && ['retry', 'escalate'].includes(decision.action) && mutations < maxActions + && !alreadyReported(first.issue, key)) { + // Re-read immediately before a write. A label/body/owner decision may have + // changed while runs and artifacts were inspected. + const freshRuns = processRuns(repo, [first.issue]); + const second = await snapshot(repo, freshRuns, first.issue); + const confirmed = decideReconciliation({ ...second, now: Date.now() }); + const confirmedKey = reconciliationKey(second.issue, second.request, confirmed); + if (confirmed.action === decision.action && confirmedKey === key && !alreadyReported(second.issue, key)) { + if (decision.action === 'retry') relabel(repo, second.issue, decision.label); + addComment(repo, second.issue, commentBody(second.issue, second.request, confirmed, key)); + record.applied = true; + mutations++; + } else { + record.reason = `state changed before write: ${confirmed.action}/${confirmed.reason}`; + record.action = 'noop'; + } + } + records.push(record); + } + return { + schema: 'houseplan-process-reconcile/v1', + generatedAt: new Date(now).toISOString(), + repo, + apply, + counts: records.reduce((out, row) => ({ ...out, [row.action]: (out[row.action] || 0) + 1 }), {}), + mutations, + records, + }; +} + +if (isMainModule(import.meta.url)) { + const args = process.argv.slice(2); + const value = (name, fallback = '') => { + const eq = args.find((arg) => arg.startsWith(`--${name}=`)); + if (eq) return eq.slice(name.length + 3); + const index = args.indexOf(`--${name}`); + return index >= 0 ? (args[index + 1] || 'true') : fallback; + }; + const repo = value('repo', process.env.GITHUB_REPOSITORY || 'Matysh/houseplan-card'); + const apply = value('apply', 'false') === 'true'; + const maxActions = Number(value('max-actions', '5')); + const output = value('output', ''); + reconcileAll({ repo, apply, maxActions }).then((summary) => { + const body = `${JSON.stringify(summary, null, 2)}\n`; + if (output) { + mkdirSync(dirname(output), { recursive: true }); + writeFileSync(output, body); + } + process.stdout.write(`${JSON.stringify({ schema: summary.schema, counts: summary.counts, mutations: summary.mutations })}\n`); + }).catch((error) => { + console.error(`process-reconcile: ${error instanceof Error ? error.stack || error.message : String(error)}`); + process.exitCode = 2; + }); +} diff --git a/test/process-reconcile.test.mjs b/test/process-reconcile.test.mjs new file mode 100644 index 00000000..d6b93f04 --- /dev/null +++ b/test/process-reconcile.test.mjs @@ -0,0 +1,159 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + alreadyReported, decideReconciliation, latestReviewRequest, markerFor, + parseProcessRun, preparedEvidenceError, reconciliationKey, +} from '../scripts/process-reconcile.mjs'; + +const NOW = Date.parse('2026-09-13T12:00:00Z'); +const request = { id: 'event-7', at: '2026-09-13T10:00:00Z', label: 'S7-code-review' }; +const issue = (labels = ['S7-code-review']) => ({ number: 555, title: 'fixture', labels, comments: [] }); +const run = (overrides = {}) => ({ + id: 70, + attempt: 1, + issue: 555, + label: 'S7-code-review', + stage: 'code', + status: 'completed', + conclusion: 'cancelled', + createdAt: '2026-09-13T10:01:00Z', + updatedAt: '2026-09-13T10:20:00Z', + url: 'https://example.test/runs/70', + prepared: null, + resultArtifact: false, + ...overrides, +}); + +test('#555 maps a stable process run-name to issue and stage', () => { + assert.deepEqual(parseProcessRun({ + id: 70, + run_attempt: 2, + display_title: 'process #555 · S7-code-review · fixture title', + status: 'in_progress', + created_at: '2026-09-13T10:00:01Z', + }), { + id: 70, attempt: 2, issue: 555, label: 'S7-code-review', stage: 'code', + status: 'in_progress', conclusion: '', createdAt: '2026-09-13T10:00:01Z', + updatedAt: null, url: null, prepared: null, preparedArtifact: false, + resultArtifact: false, evidenceError: null, + }); + assert.equal(parseProcessRun({ display_title: 'unrelated label event' }), null); +}); + +test('#555 latest label request is stage-specific and deterministic', () => { + const events = [ + { id: 1, event: 'labeled', created_at: '2026-09-13T08:00:00Z', label: { name: 'S7-code-review' } }, + { id: 2, event: 'labeled', created_at: '2026-09-13T09:00:00Z', label: { name: 'P2' } }, + { id: 3, event: 'labeled', created_at: '2026-09-13T10:00:00Z', label: { name: 'S4-spec-review' } }, + { id: 4, event: 'labeled', created_at: '2026-09-13T11:00:00Z', label: { name: 'S7-code-review' }, actor: { login: 'owner' } }, + ]; + assert.deepEqual(latestReviewRequest(events, 'S7-code-review'), { + id: '4', at: '2026-09-13T11:00:00Z', label: 'S7-code-review', actor: 'owner', + }); +}); + +test('#555 fixture matrix: lost/cancelled/timeout retry, running waits, applied verdict is noop', () => { + const lost = decideReconciliation({ issue: issue(), request, runs: [], now: NOW }); + assert.equal(lost.action, 'retry'); + assert.match(lost.reason, /no matching process run/); + + for (const conclusion of ['cancelled', 'timed_out', 'startup_failure']) { + const decision = decideReconciliation({ issue: issue(), request, runs: [run({ conclusion })], now: NOW }); + assert.equal(decision.action, 'retry', conclusion); + } + + const running = decideReconciliation({ + issue: issue(), request, + runs: [run({ status: 'in_progress', conclusion: '', createdAt: '2026-09-13T11:00:00Z' })], now: NOW, + }); + assert.equal(running.action, 'wait'); + + const applied = decideReconciliation({ issue: issue(['S8-merged']), request, runs: [run()], now: NOW }); + assert.equal(applied.action, 'noop'); +}); + +test('#555 guard failure and ambiguous state escalate without automatic retry', () => { + const failed = decideReconciliation({ issue: issue(), request, runs: [run({ conclusion: 'failure' })], now: NOW }); + assert.equal(failed.action, 'escalate'); + assert.match(failed.reason, /non-transient/); + + const tooLong = decideReconciliation({ + issue: issue(), request: { ...request, at: '2026-09-13T05:00:00Z' }, + runs: [run({ status: 'in_progress', conclusion: '', createdAt: '2026-09-13T06:00:00Z' })], now: NOW, + }); + assert.equal(tooLong.action, 'escalate'); + + assert.equal(decideReconciliation({ + issue: issue(['S7-code-review', 'blocked']), request, runs: [], now: NOW, + }).action, 'noop'); + assert.equal(decideReconciliation({ + issue: issue(['S7-code-review', 'review-4']), request, runs: [], now: NOW, + }).action, 'noop'); + assert.equal(decideReconciliation({ + issue: issue(['S4-spec-review', 'S7-code-review']), request, runs: [], now: NOW, + }).action, 'escalate'); +}); + +test('#555 sealed result and successful-but-unapplied run never trigger a second model', () => { + const sealed = decideReconciliation({ + issue: issue(), request, runs: [run({ conclusion: 'timed_out', resultArtifact: true })], now: NOW, + }); + assert.equal(sealed.action, 'escalate'); + assert.match(sealed.reason, /model result/); + + const unapplied = decideReconciliation({ + issue: issue(), request, runs: [run({ conclusion: 'success' })], now: NOW, + }); + assert.equal(unapplied.action, 'escalate'); + assert.match(unapplied.reason, /did not move/); +}); + +test('#555 prepared proof is bound to issue, stage, attempt, material and round', () => { + const good = { + schema: 1, run_id: '70', run_attempt: '1', issue: '555', stage: 'code', cycle: '2', + material_sha: 'a'.repeat(40), material_tree: 'b'.repeat(40), material_specs: 'c'.repeat(40) + ' docs/specs/555.md;', + }; + assert.equal(preparedEvidenceError(good, { issue: issue(), stage: 'code', run: run() }), null); + for (const patch of [ + { issue: '556' }, { stage: 'spec' }, { run_attempt: '2' }, { material_sha: 'bad' }, { cycle: '0' }, + ]) { + assert.ok(preparedEvidenceError({ ...good, ...patch }, { issue: issue(), stage: 'code', run: run() }), JSON.stringify(patch)); + } + const foreign = decideReconciliation({ + issue: issue(), request, runs: [run({ prepared: { ...good, stage: 'spec' } })], now: NOW, + }); + assert.equal(foreign.action, 'escalate'); + assert.match(foreign.reason, /another issue\/stage/); +}); + +test('#555 repeat reconciliation has a stable dedupe marker', () => { + const decision = decideReconciliation({ issue: issue(), request, runs: [], now: NOW }); + const key = reconciliationKey(issue(), request, decision); + assert.equal(key, reconciliationKey(issue(), request, decision)); + assert.equal(alreadyReported(issue(), key), false); + assert.equal(alreadyReported({ ...issue(), comments: [{ body: `${markerFor(key)}\ndone` }] }, key), true); + + const afterRetry = { + ...issue(), + comments: [{ + createdAt: '2026-09-13T10:05:00Z', + body: `${markerFor(key)}\nАвтосверка процесса повторно разбудила \`S7-code-review\``, + }], + }; + const retriedRequest = { ...request, id: 'event-8', at: '2026-09-13T10:04:59Z' }; + const stopped = decideReconciliation({ issue: afterRetry, request: retriedRequest, runs: [], now: NOW }); + assert.equal(stopped.action, 'escalate'); + assert.match(stopped.reason, /one automatic retry/); +}); + +test('#555 a fresh label/run completion stays inside grace instead of duplicating work', () => { + assert.equal(decideReconciliation({ + issue: issue(), + request: { ...request, at: '2026-09-13T11:58:00Z' }, + runs: [], now: NOW, + }).action, 'wait'); + assert.equal(decideReconciliation({ + issue: issue(), request, + runs: [run({ conclusion: 'cancelled', updatedAt: '2026-09-13T11:58:00Z' })], now: NOW, + }).action, 'wait'); +}); diff --git a/test/review-doc-guard.test.mjs b/test/review-doc-guard.test.mjs index 28fe637d..322f1835 100644 --- a/test/review-doc-guard.test.mjs +++ b/test/review-doc-guard.test.mjs @@ -770,6 +770,28 @@ test('#553: канон разделяет review и исполнение тес 'точные runtime pins читаются из исполняемых источников, не из памятки'); }); +test('#555: bounded reconciler wakes only lost review requests and emits one machine summary', () => { + const read = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8'); + const workflow = read('.github/workflows/process-reconcile.yml'); + const processWorkflow = read('.github/workflows/process.yml'); + const process = read('PROCESS.md'); + const agents = read('AGENTS.md'); + + assert.match(processWorkflow, + /run-name: "process #\$\{\{ github\.event\.issue\.number \}\} · \$\{\{ github\.event\.label\.name \}\}/, + 'run identity includes issue and requested stage'); + assert.match(workflow, /cron: '7,37 \* \* \* \*'/); + assert.match(workflow, /workflow_dispatch:/); + assert.match(workflow, /ref: dev/); + assert.match(workflow, /secrets\.HP_PROCESS_TOKEN/); + assert.match(workflow, /node scripts\/process-reconcile\.mjs[\s\S]*--apply="\$APPLY"/); + assert.match(workflow, /--max-actions=5/); + assert.match(workflow, /process-reconcile-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/); + assert.match(process, /houseplan-process-reconcile\/v1/); + assert.match(process, /второй вызов модели или S8 по догадке запрещены/); + assert.match(agents, /bounded queue reconciler \(#555\)/); +}); + test('#551: gates, модель и интеграция имеют независимые jobs, contracts и бюджеты', () => { const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); const job = (name, next) => {