docs: review document for #555

Issue: #555
User-Visible: no
This commit is contained in:
claude[bot]
2026-09-13 12:43:51 +00:00
parent e3bf893e3d
commit e66e82fc86
+242
View File
@@ -0,0 +1,242 @@
# 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