Files
houseplan-card/docs/reviews/CODE-REVIEW-555-r1.md
T
2026-09-13 12:43:51 +00:00

243 lines
20 KiB
Markdown
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.
# 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.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/555-process-reconciler`, коммит `e3bf893e3d76` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `9ddf11012bb0a1d9657e3801bb856e32a6cca73f`
```
git log --all --format='%H %T' | grep 9ddf11012bb0
```
- Тело issue: `61feb887bc5d5cae5bac106b48e7f07161e697938ac473cc87c5e47a8ca8d4e6`
- Вердикт конвейера: `red` · High 1