# CODE-REVIEW #555 · заход r1 Материал: `e3bf893e3d76b3276a548ee9182f073c901a12e9` (HEAD, рабочая копия на нём). Диапазон: `git diff origin/dev...HEAD` (один коммит, `ci: восстанавливать потерянные запросы ревью (#555)`, `Issue: #555`, `User-Visible: no`). ## Скоуп Инфраструктурная задача (`infra`, `process`, `tech-debt`, без продуктового S* на входе — по AGENTS.md это ожидаемо). Из аудита 12.09 (§7.2, остаток I3): конвейер `process.yml` (#499) убрал нерелевантные label-события, но не гарантирует, что S4/S7 после `cancelled`/`timed_out`/отказа guard приходят в понятное конечное состояние. Задача добавляет bounded reconciler — снимок раз в 30 минут, не polling. Изменённые файлы: `.github/workflows/process-reconcile.yml` (новый workflow), `.github/workflows/process.yml` (+1 строка `run-name` — стабильная идентичность прогона по issue+label), `scripts/process-reconcile.mjs` (новый, 399 строк), `scripts/mutation-gate.mjs` (+5 мутантов), `test/process-reconcile.test.mjs` (новый, 8 тестов), `test/review-doc-guard.test.mjs` (+1 тест на согласованность документации/workflow), `AGENTS.md` + `PROCESS.md` (описание механизма). `src/**` не тронут — продукт и его контракты не затрагиваются. Проверка входа (Rule #1 неприменимо буквально — это infra-задача, вход по AGENTS.md — явное решение владельца применить `S7-code-review`, что и стоит на issue). Трейлеры корректны: `Issue: #555`, `User-Visible: no` — согласуется с тем, что changelog не менялся и менять было незачем. ## Как проверялось - Прочитан `scripts/process-reconcile.mjs` целиком построчно: `parseProcessRun`, `latestReviewRequest`, `preparedEvidenceError`, `decideReconciliation`, `reconciliationKey`/`markerFor`/`alreadyReported`, и — отдельно, так как это единственный слой без юнит-тестов — императивная обвязка `gh()`, `relabel()`, `addComment()`, `snapshot()`, `reconcileAll()`. - Прочитан `.github/workflows/process-reconcile.yml` и изменение `run-name` в `process.yml` целиком; сверено, что `RUN_TITLE`-регэксп в скрипте соответствует формату `run-name` буквально (issue.number → label.name → issue.title через ` · `). - Прочитаны оба новых теста и добавленный тест в `review-doc-guard.test.mjs` построчно, сопоставлены с кодом и текстом документов. - Прогнан целевой `node --test test/process-reconcile.test.mjs` — 8/8 green (см. «Гейты»). - Сверены все 5 новых `MUTANT_DEFINITIONS` в `scripts/mutation-gate.mjs`: каждый `find` дословно встречается ровно один раз в `scripts/process-reconcile.mjs`, и для каждого патча прослежено по коду, что соответствующий тест в `test/process-reconcile.test.mjs` обязан покраснеть (см. разбор по AC ниже) — без пересборки мутационного гейта (дорогой, ночной, §513, не гейт code review). - Прочитаны тело issue #555 и единственный комментарий автора (перечень проверок и SHA). ## Находки ### [High] `relabel()` может уронить весь прогон и никогда не эскалировать именно тот случай, ради которого задача заведена `scripts/process-reconcile.mjs:502-511`: ```js 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()}`); } } ``` Второй вызов `gh(...)` (заявленный «best-effort rollback») результата не проверяет: `throw` происходит безусловно на основании `added` — результата ПЕРВОЙ попытки — даже если вторая попытка восстановила метку успешно. Это само по себе неверно (лишний шум), но хуже цепочка последствий: - `relabel()` вызывается внутри `reconcileAll()` (`process-reconcile.mjs:354`) без единого `try/catch` вокруг записи (проверено: `grep -n "try\|catch"` — единственные `try` в файле относятся к `preparedEvidenceError`/ `hydrateRunEvidence`, не к этому месту). - Исключение всплывает из `reconcileAll()` в `.then()/.catch()` на `process-reconcile.mjs:391-398`: `.then()` (который пишет `--output=summary.json`) не выполняется, отрабатывает только `.catch()`, `process.exitCode = 2`. - В workflow `process-reconcile.yml` шаг `upload-artifact` идёт следующим шагом без `if: always()` — при ненулевом коде шаг `run:` фейлится, `upload-artifact` не выполняется вовсе. Итог прохода: ни machine-readable summary (AC5), ни обработки остальных issue в очереди этого тика. - Критично для AC "намеренно остановленная работа не возобновляется" и общей цели issue: `addComment()` (запись дедуплицированного диагностического комментария) стоит в коде ПОСЛЕ `relabel()` и в этой ветке не выполняется никогда. Значит `alreadyReported()` не увидит маркер, и при следующем тике (через 30 минут) `decideReconciliation` примет то же решение `retry` для того же события — реконсилер наступит на тот же `relabel()` заново. Если причина отказа `--add-label` не самоустранилась (метка удалена, право токена, устойчивый rate-limit), это бесконечный цикл: workflow красный каждые 30 минут, ни одного комментария в issue не появляется. - Это ровно тот класс отказа, который задача обязана закрыть: «Failure guard, … получают один дедуплицированный диагностический комментарий и эскалацию человеку» (PROCESS.md, добавлено этим же коммитом) — для отказа самого восстановления такой эскалации нет вообще. Тестами не покрыто: `relabel`, `addComment`, `snapshot`, `reconcileAll` не экспортированы и не встречаются в `test/process-reconcile.test.mjs` (проверено `grep -n "export function\|export async function" scripts/process-reconcile.mjs` против списка импортов теста) — вся императивная запись проверена только чтением кода, как и требует протокол этого этапа. Ручной прогон автора тоже не покрывает эту ветку: «read-only CLI на живой очереди — green, records=0, writes=0» — это прогон с `apply=false` (или без reconciliation-действий), путь `relabel()` не исполнялся ни разу ни в тесте, ни вручную. Воспроизведение (по коду, не по запуску): вызвать `relabel(repo, issue, label)` в ситуации, когда первый `gh issue edit --add-label` вернёт `status !== 0` (транзиентная ошибка GitHub API/rate limit сразу после `--remove-label`, что в CI не экзотика) — независимо от исхода второй попытки, функция бросает исключение и завершает `reconcileAll()` без записи артефакта и без диагностического комментария. **Это находка в скоупе задачи**: файл и функция — часть этого же diff, чинится там же. Из-за High это блокирующий вердикт данного захода. Минимальное исправление (не мой мандат его вносить, только диагноз): проверять результат именно повторной попытки, а не переиспользовать `added`; и/или оборачивать применение решения на уровне `reconcileAll()` в `try/catch` на issue, чтобы отказ одного issue не ронял снимок остальных и не блокировал запись `summary.json`. ## Проверено и корректно - **Матрица решений `decideReconciliation`** (AC1) — для всех перечисленных в issue фикстур (cancellation, guard failure/`failure`, timeout, `startup_failure`, потерянное событие, уже применённый verdict, ещё идущий run) выбирается правильное действие; прочитано построчно и подтверждено зелёным целевым прогоном (см. «Гейты»). - **Дедупликация и запрет повторного расхода цикла** (AC2, частично) — чистая часть: `reconciliationKey`/`markerFor`/`alreadyReported`, `retryAlreadyIssued` (не более одного авто-relabel на потерянное событие) — проверены тестом `#555 repeat reconciliation has a stable dedupe marker` и чтением; логика корректна. Применение чужого stage/material отклоняется `preparedEvidenceError` (`badIdentity`) — проверено тестом и подтверждено соответствующим mutation-witness (`process-reconcile-accepts-foreign- prepared-evidence`), прослежено по коду, что мутация переводит исход `retry` вместо ожидаемого в тесте `escalate` — тест обязан покраснеть. - **Гонка между чтением и записью** (AC3, частично) — `reconcileAll` перед мутацией перечитывает issue/events/runs (`snapshot(repo, freshRuns, first.issue)`) и сверяет `confirmed.action`/`confirmedKey` с исходным решением; при расхождении действие не применяется (`record.action = 'noop'`). Логика прочитана и корректна как таковая — сама операция записи (см. High) ненадёжна, но защита от протухшего решения — да. - **Здоровый running run не перезапускается** (AC4) — `age <= activeLimitMs` → `wait`; подтверждено тестом и mutation-witness (`process-reconcile-restarts-healthy-run`: мутация `wait`→`retry` красит тест «running waits»). - **`blocked`/`review-4` сильнее старого события** (AC3/AC4) — проверено тестом и mutation-witness (`process-reconcile-ignores-owner-stop`): при `if (false && …)` тест `issue(['S7-code-review','blocked'])` перестаёт быть `noop`. - **Sealed model result не переигрывается** — `run.resultArtifact` → `escalate`, тест + witness (`process-reconcile-reruns-sealed-model-result`) подтверждают, что без проверки конклюзия ушла бы в `retry`. - **Один авто-relabel, не бесконечный** — `retryAlreadyIssued` + witness (`process-reconcile-retries-lost-event-forever`) подтверждают переход `retry`→`escalate` после первого отмеченного повтора — сама детекция корректна (описанная в High проблема — это отсутствие записи маркера в одном конкретном отказном пути, а не дефект самой проверки). - **Стабильная идентичность прогона** — `RUN_TITLE` (`/^process #(\d+) · (S4-spec-review|S7-code-review)(?: ·|$)/`) сверена буквально с новым `run-name` в `process.yml`; `on: issues: types: [labeled]` — единственный триггер этого workflow (прочитан файл целиком), значит `github.event.label.name` всегда заполнен и regex всегда применим к свежим прогонам. Легаси-путь по совпадению `issue.title` (для прогонов до #555) безопасно вырождается в `null` при неоднозначности (`matches.length !== 1`). - **Workflow `process-reconcile.yml`** — `cron: '7,37 * * * *'` (раз в 30 минут, не polling одинакового состояния на каждый тик, а один снимок и выход), `concurrency: group: process-reconcile, cancel-in-progress: false` не даёt двум прогонам работать параллельно, `timeout-minutes: 10` ограничивает зависший прогон, `permissions:` минимальны для `GITHUB_TOKEN` (сам скрипт везде использует `GH_TOKEN: secrets.HP_PROCESS_TOKEN`, как и остальной `process.yml` — сверено по существующему паттерну). - **Трейлеры и класс изменений** — `Issue: #555`, `User-Visible: no` корректны; `src/**` не тронут, changelog не требовался. - **Тест `test/review-doc-guard.test.mjs` (#555)** — построчно сверен с текущим содержимым `PROCESS.md`/`AGENTS.md`/обоих workflow-файлов; все проверяемые фрагменты присутствуют дословно. ## Чего не проверял и почему - **`npx tsc --noEmit`, `npm test` (полностью), `npm run build`+сверка трёх копий бандла** — не гонял повторно: Validate на этом же SHA `e3bf893e` зелёный (https://github.com/Matysh/houseplan-card/actions/runs/34757318234), код с тех пор не менялся (материал ревью = вершина без исполненного ребейза). Прогнал только точечно `node --test test/process-reconcile.test.mjs` (8/8 green) — дешёвая точечная проверка нового теста, не замена полного набора. - **`node scripts/check-docs.mjs`** — не гонял: диапазон не трогает `src/**`, отпечаток скриншотов документации не мог устареть. - **`npm run invariants -- --config …`** — не гонял: диапазон не трогает геометрию, толщину стен, `layout`, `marker.space`, `open_spans` — модель вообще не затронута. - **Браузерные смоки `demo/smoke_*.mjs`, `npm run golden:verify`, `python -m pytest tests_backend`, performance-профили** — не гонял и не выбирал по `scripts/smoke-select.mjs`: diff не трогает `src/**`, `demo/**` (кроме несуществующего — фактически не тронут), `custom_components/**/*.py` не менялся, AC задачи ни один из этих гейтов не называет. Это гейты визуального/HA-контракта продукта, а изменение — чисто процессный скрипт и workflow. - **Полный ночной `scripts/mutation-gate.mjs` прогон (пересборка бандла на каждый мутант)** — не гонял: это дорогой, отдельно расписанный (§513) гейт вне цикла ревью. Вместо него по каждому из 5 новых мутантов проверено по коду точное совпадение `find`-паттерна и прослежена логика, почему соответствующий тест обязан покраснеть — см. «Проверено и корректно». - **Живой прогон `process-reconcile.mjs --apply=true` на реальной очереди issues** — не выполнял (ручного тестирования в этом цикле нет, а «доказательство» такого прогона потребовало бы мутировать реальные метки/ комментарии чужих issue, что вне полномочий ревью). Именно отсутствие любого исполнения (тестового или ручного) ветки `relabel()`/`addComment()`/ `reconcileAll()` — основание High-находки выше. ## Итог Один High, найден чтением непокрытого тестами кода записи (`relabel()` безусловно бросает исключение независимо от исхода собственного «best-effort rollback», что роняет весь прогон реконсилера и — что хуже — пропускает именно тот единственный диагностический комментарий, который должен предотвратить бесконечный тихий повтор одного и того же отказа). Находка в скоупе задачи и не требует отдельного issue — правится в этой же ветке. Остальная (доминирующая по объёму) логика — чистая функция `decideReconciliation` и её вспомогательные функции — вычитана, протестирована точечным прогоном и подтверждена всеми пятью новыми mutation witnesses. --- ## Материал раунда - Ветка: `issue/555-process-reconciler`, коммит `e3bf893e3d76` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала: `9ddf11012bb0a1d9657e3801bb856e32a6cca73f` ``` git log --all --format='%H %T' | grep 9ddf11012bb0 ``` - Тело issue: `61feb887bc5d5cae5bac106b48e7f07161e697938ac473cc87c5e47a8ca8d4e6` - Вердикт конвейера: `red` · High 1