20 KiB
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:
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 на этом же SHAe3bf893eзелёный (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— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
9ddf11012bb0a1d9657e3801bb856e32a6cca73fgit log --all --format='%H %T' | grep 9ddf11012bb0 - Тело issue:
61feb887bc5d5cae5bac106b48e7f07161e697938ac473cc87c5e47a8ca8d4e6 - Вердикт конвейера:
red· High 1