доменные ошибки сравниваются через errors.As, отказ Close не теряется
- признаки «работы нет» и «задача не найдена» узнаются по смыслу, а не приведением типа: обёртка `%w` на пути больше не превращает пустой прогон воркера в отказ раз в секунду - отказ закрытия соединения с распознавателем доходит до вызывающего (`errors.Join`) либо до журнала; у `errcheck` включён `check-blank`, иначе критерий принимал реализацию, выбрасывающую отказ в пустоту - заведены первые тесты пакета worker и capability `pipeline`; долг из четырёх замечаний линтера закрыт, гейт зелёный целиком
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-11
|
||||
@@ -0,0 +1,218 @@
|
||||
## Context
|
||||
|
||||
Два признака конвейера — «работы в этом состоянии нет» и «подходящей задачи не
|
||||
нашлось» — сегодня узнаются приведением значения ошибки к точному типу:
|
||||
`err.(*contract.NoopJobError)` в `internal/controller/worker/worker.go:51` и
|
||||
`err.(*contract.JobNotFoundError)` в `internal/service/transcribe.go:392`.
|
||||
Приведение видит только само значение и слепо к пояснениям, добавленным
|
||||
обёрткой `fmt.Errorf("…: %w", err)`.
|
||||
|
||||
Путь сегодня короткий и обёрток на нём нет: `FindAndAcquire`
|
||||
(`internal/adapter/repo/sqlite/transcript_job_repo.go:177`) рождает
|
||||
`JobNotFoundError`, `findJob` переводит его в `NoopJobError`, воркер этот признак
|
||||
ловит. Поэтому дефект пока не проявился — он ждёт первой обёртки, а обёртка
|
||||
`%w` объявлена конвенцией `docs/conventions/errors.md` умолчанием проекта. То
|
||||
есть код написан против собственного умолчания, и цена срабатывания —
|
||||
три записи отказа в секунду и столько же засчитанных сбоев, которых не было.
|
||||
|
||||
Отдельно и мельче: отказ закрытия соединения теряется в двух местах —
|
||||
`sttConn.Close()` на пути отказа конструктора распознавателя
|
||||
(`internal/adapter/recognizer/yandex/speechkit.go:55`) и `defer
|
||||
recognizer.Close()` при остановке процесса (`main.go:124`).
|
||||
|
||||
Оба сюжета держат гейт проекта красным четырьмя замечаниями линтера; долг
|
||||
объявлен в `CLAUDE.md`, разделе «Гейт».
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Признак пустого прогона переживает пояснение, добавленное на любом
|
||||
промежуточном шаге.
|
||||
- Отказ закрытия соединения не теряется молча.
|
||||
- Гейт зелёный целиком: замечаний `errorlint` и `errcheck` нет.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Внешнее поведение не меняется: ни ответы пользователю, ни набор состояний
|
||||
задачи, ни формат метрик.
|
||||
- Единая точка перевода доменной ошибки в HTTP-статус — другое расхождение той
|
||||
же конвенции, записанное там же; этим изменением не трогается.
|
||||
- Обёртки `%w` по всему пути не расставляются: изменение делает проверку
|
||||
устойчивой к ним, а не вводит их.
|
||||
- `tg.EmptyBotTokenError` — третья типизированная ошибка без полей — не трогается:
|
||||
линтер на ней молчит, приведения типа у неё нет.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Решение 1: признак узнаётся `errors.As`, типы остаются
|
||||
|
||||
Обе проверки переходят на `errors.As` с сохранением сегодняшних типов
|
||||
`contract.NoopJobError` и `contract.JobNotFoundError`.
|
||||
|
||||
Что человек увидит иначе: ничего — ровно в этом ценность. Владелец сервиса
|
||||
увидит разницу лишь в тот день, когда кто-то добавит пояснение к ошибке на этом
|
||||
пути: журнал останется тихим, вместо того чтобы наполниться отказами, которых
|
||||
не было.
|
||||
|
||||
**Рассмотрено и отвергнуто — sentinel вместо типов.** Конвенция
|
||||
(`docs/conventions/errors.md`, раздел «Sentinel и типизированные») говорит:
|
||||
типизированная ошибка нужна, когда вызывающему нужны **данные**, а данные обоих
|
||||
типов сегодня не читает никто — обе проверяются на факт. По этому доводу типы
|
||||
следовало бы заменить на `errors.New` и проверять `errors.Is`, а состояние
|
||||
задачи вносить обёрткой `fmt.Errorf("%s: %w", state, contract.ErrNoJob)`.
|
||||
|
||||
Отвергнуто по цене против цели: обе формы **одинаково** устойчивы к обёртке, то
|
||||
есть по цели изменения они неразличимы, а sentinel правит пять мест вместо двух
|
||||
и переписывает рождение ошибки в репозитории и в сервисе. Задача — снятие долга
|
||||
линтера, а не пересмотр номенклатуры ошибок. Довод конвенции при этом не
|
||||
исчезает: он остаётся верным и становится поводом отдельной работы в тот день,
|
||||
когда типы так и не начнут нести читаемых данных.
|
||||
|
||||
**Рассмотрено и отвергнуто — оставить приведение, заглушив линтер.** Правило
|
||||
`errorlint` выключается директивой на строке. Отвергнуто: дефект от этого не
|
||||
исчезает, а перестаёт быть видимым, и следующий читатель кода примет молчание
|
||||
линтера за проверенность.
|
||||
|
||||
### Решение 2: отказ закрытия — по месту, а не единым правилом
|
||||
|
||||
Два места разные по природе, и одинаково они не чинятся.
|
||||
|
||||
- **Путь отказа конструктора** (`speechkit.go:55`): клиент распознавания создан,
|
||||
а клиент операций создать не удалось. Оба отказа независимы, и конвенция для
|
||||
такого случая называет `errors.Join`. Ошибка конструктора получает вторую
|
||||
строку, если закрытие тоже отказало.
|
||||
- **Остановка процесса** (`main.go:124`): отдавать отказ некому — процесс
|
||||
заканчивается. Значит, он идёт в журнал владельца, а `defer` получает тело с
|
||||
проверкой. В запись идёт **значение ошибки как есть**: оно несёт состояние
|
||||
клиента и адрес узла, но не тело запроса и не ключ — ключ живёт в метаданных
|
||||
вызова, а не в соединении. Разворачивать первопричину на границе клиента, как
|
||||
требует `docs/conventions/logging.md` от ошибок транспорта, здесь нечего:
|
||||
обёрток на этом пути нет.
|
||||
|
||||
**Чем этот отказ является на самом деле — сказано прямо, чтобы обоснование не
|
||||
поехало дальше в неверном виде.** `grpc.NewClient` ленив и соединения не
|
||||
открывает, а `Close` у клиента возвращает отказ единственным образом — при
|
||||
повторном закрытии. Значит, на пути отказа конструктора второй операнд почти
|
||||
наверняка `nil`, а запись при остановке процесса говорит о **нашей** ошибке
|
||||
(закрыли дважды), а не о недоступности Yandex. Обработка обоих мест остаётся:
|
||||
она стоит одну строку и переживёт смену клиента, а её отсутствие каждый раз
|
||||
приходится заново обосновывать читателю.
|
||||
|
||||
**Оракул этого решения чинится машиной, а не обещанием.** Правило `errcheck`
|
||||
сегодня молчит на `_ = conn.Close()`: настройка `check-blank` не выставлена, а её
|
||||
умолчание — «пропускать». То есть реализация, выбрасывающая отказ в пустоту,
|
||||
удовлетворяет критерию приёмки «линтер не даёт замечаний `errcheck`», не
|
||||
удовлетворяя самому критерию — «отказ возвращается либо попадает в журнал».
|
||||
Поэтому изменение включает `errcheck.check-blank: true` в `.golangci.yml`.
|
||||
Проверено прогоном: на сегодняшнем коде правило замечаний не добавляет, то есть
|
||||
включается чисто и отдельного коммита приведения не требует.
|
||||
|
||||
Это правка политики линтера, и её цена названа: `_ =` перестаёт быть способом
|
||||
сказать «отказ здесь не важен» молча — теперь такое место придётся либо
|
||||
обрабатывать, либо вносить в `exclude-functions` поимённо, то есть заметно.
|
||||
|
||||
**Рассмотрено и отвергнуто — дописать оба типа в `exclude-functions`
|
||||
`.golangci.yml`.** Соблазн сильный: список исключений там уже есть, и в нём
|
||||
записана ровно эта политика — «закрытие через `defer` и лучшая-попытка уборки
|
||||
файла — осознанно без проверки». То есть замечание снимается одной строкой
|
||||
конфига, и она даже выглядит согласованной с прежним решением.
|
||||
|
||||
Отвергнуто: политика в конфиге относится к закрытию, у которого **отказ ничего
|
||||
не значит** — файл, читатель, соединение с базой на выходе. Здесь не так: в
|
||||
первом случае отказ закрытия сопровождает уже случившийся отказ и полезен для
|
||||
разбора, во втором — это единственный признак того, что соединение с оплачиваемым
|
||||
внешним сервисом закрылось неправильно. Записав их в исключения, мы бы
|
||||
расширили политику молча, самим фактом добавления строки, и потеряли бы оба
|
||||
сигнала навсегда. Критерий приёмки задачи требует именно «возвращается либо
|
||||
попадает в журнал», а не «линтер замолчал».
|
||||
|
||||
### Решение 3: узнавание по смыслу шире приведения, и граница ставится нормой
|
||||
|
||||
Приведение типа видело **только вершину** цепочки. `errors.As` видит признак на
|
||||
любой её глубине — в этом и цель, но у расширения есть встречная сторона: отказ,
|
||||
к которому признак пустого прогона примешался по дороге (обёрткой или
|
||||
`errors.Join`), воркер зачтёт пустым прогоном. Тогда задача останется в своём
|
||||
состоянии и будет переопрашиваться раз в секунду без единой записи — тот самый
|
||||
класс, от которого защищает инвариант «Принятая запись не теряется молча», только
|
||||
с обратным знаком относительно чинимого дефекта.
|
||||
|
||||
Прямого места, где это случается, сегодня нет: признак рождается ровно в двух
|
||||
местах и никем не оборачивается. Но `%w` объявлен умолчанием проекта, а
|
||||
`errors.Join` вводит в кодовую базу это же изменение — значит, дыра появится
|
||||
тихо и не сегодня.
|
||||
|
||||
Граница ставится **нормой, а не кодом**: спека требует, чтобы признак рождался
|
||||
только ответом хранилища на опрос этим же шагом, и запрещает слою сохранять чужой
|
||||
признак в цепочке своей ошибки.
|
||||
|
||||
**Рассмотрено и отвергнуто — научить воркер различать «признак на вершине» от
|
||||
«признака в глубине».** Отвергнуто по цене: `errors.As` такого различения не
|
||||
даёт вовсе, пришлось бы либо проверять вершину вручную (то есть вернуть
|
||||
приведение типа, которое чинится), либо заводить свой обход цепочки. Код
|
||||
усложняется ради случая, которого сегодня нет ни одного, а защита от него нужна
|
||||
на входе — при написании нового слоя, — где норма работает, а проверка в рантайме
|
||||
опоздала бы.
|
||||
|
||||
### Решение 4: спека заводится только на пустой прогон
|
||||
|
||||
Дельта заводит capability `pipeline` с одним требованием — о пустом прогоне
|
||||
воркера. Имя предвосхищено маркерами долга в `docs/architecture.md`.
|
||||
|
||||
Закрытие соединения требования **не получает**: домена оно не трогает, наружу не
|
||||
видно. Заводить под него норму значило бы нормировать внутреннюю гигиену —
|
||||
граница спек проекта проходит не здесь. Судьёй остаётся критерий приёмки, а его
|
||||
оракул сделан различающим включением `check-blank` (Решение 2), а не оставлен на
|
||||
слово.
|
||||
|
||||
Требование при этом **не закрепляет нормой известный долг журнала.**
|
||||
`docs/conventions/logging.md` держит расхождение: сбой фонового цикла
|
||||
записывается дважды — шагом конвейера и следом воркером, — и уровнем `ERROR`
|
||||
там, где конвенция просит `WARN` для повторяющегося сбоя. Спека говорит «ровно
|
||||
один раз единственной логирующей точкой» и уровня не называет: иначе следующий,
|
||||
кто возьмётся закрывать этот долг, обнаружил бы, что убрать вторую запись нельзя
|
||||
без правки спеки, — и долг стал бы контрактом молча.
|
||||
Остальное поведение конвейера (переходы состояний, захват, срок протухания)
|
||||
спекой тоже не описывается: оно этим изменением не трогается, а требование,
|
||||
написанное без проверки, — предположение, а не норма.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **`errors.As` требует указателя на указатель, и ошибка формы не ловится
|
||||
компилятором, а даёт панику в рантайме** → цель объявляется переменной нужного
|
||||
типа (`var noop *contract.NoopJobError`), а ветка покрывается тестом, который
|
||||
и есть оракул критерия 2.
|
||||
- **`defer` не сработает, если процесс уйдёт через `os.Exit`** → проверить, что
|
||||
между постановкой `defer` и штатным выходом `os.Exit` не вызывается; иначе
|
||||
запись о закрытии не появится, и это будет тихой потерей того же рода, что
|
||||
чинится.
|
||||
- **Типы остаются, и довод конвенции о sentinel остаётся неотработанным** →
|
||||
назван открытым вопросом, а не забыт; работа отдельная.
|
||||
- **`check-blank: true` меняет политику для всего проекта, а не для двух мест** →
|
||||
проверено прогоном: на сегодняшнем коде замечаний не добавляется. Цена в
|
||||
будущем — осознанное игнорирование отказа придётся объявлять в
|
||||
`exclude-functions`, а не писать `_ =` по месту.
|
||||
- **Молчание на пустом прогоне остаётся единственным наблюдаемым состоянием
|
||||
цикла**: воркер, остановившийся по отмене или не стартовавший вовсе, выглядит
|
||||
так же, как воркер без работы → изменением не чинится и в требование не
|
||||
закладывается запрет на будущий признак живости: спека запрещает лишь запись
|
||||
**на уровне владельца**, оставляя место и `DEBUG`, и отдельному счётчику
|
||||
прогонов. Работа отдельная, идёт в урожай.
|
||||
- **У пакета `internal/controller/worker` сегодня нет ни одного теста** → тест
|
||||
на пустой прогон заводится этим изменением и приносит с собой первую тестовую
|
||||
оснастку пакета: подменный журнал и чтение счётчика из реестра метрик. Оснастка
|
||||
рискует разойтись с той, что уже есть в `internal/service` и
|
||||
`internal/controller/http`; сверяется по ним, а не пишется с нуля.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Миграции нет: схема базы не трогается, данные не переносятся, формат файлов на
|
||||
диске не меняется. Откат — обратный коммит.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Переводить ли обе ошибки в sentinel** и убирать типы, которых никто не
|
||||
читает. Довод конвенции остаётся в силе; цена — правка пяти мест против двух.
|
||||
Решается отдельной работой, не этой.
|
||||
- **Единая точка перевода доменной ошибки в HTTP-статус** — расхождение записано
|
||||
в `docs/conventions/errors.md` и этим изменением не закрывается.
|
||||
@@ -0,0 +1,48 @@
|
||||
## Why
|
||||
|
||||
Воркер отличает «работы сейчас нет» от настоящего отказа хрупким способом: он
|
||||
смотрит на точный тип значения ошибки. Пока никто по дороге не добавил к ошибке
|
||||
пояснения, это работает. Первое же пояснение, добавленное в любом месте пути,
|
||||
сделает пустой прогон неотличимым от поломки — молча, без единого признака в
|
||||
коде. Сервис начнёт раз в секунду на каждый из трёх воркеров писать в журнал
|
||||
отказ, которого не было, и засчитывать несуществующие сбои в счётчик работы.
|
||||
Инвариант проекта «пустой прогон — не ошибка» стоит ровно на этой проверке.
|
||||
|
||||
Сегодня же на этих местах красен линтер, и вместе с двумя потерянными отказами
|
||||
закрытия соединения он держит гейт проекта красным целиком.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Признак «работы нет» и признак «подходящей задачи не нашлось» перестают
|
||||
зависеть от точной формы значения ошибки: они узнаются по смыслу и переживают
|
||||
любые пояснения, добавленные по дороге.
|
||||
- Отказ при закрытии соединения с распознавателем перестаёт теряться: он либо
|
||||
доходит до вызывающего, либо попадает в журнал владельца.
|
||||
- Поведение снаружи не меняется: пользователь, внешняя программа и набор
|
||||
состояний задачи остаются прежними.
|
||||
- Гейт проекта становится зелёным целиком — снимается объявленный долг из
|
||||
четырёх замечаний линтера.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `pipeline`: конвейер расшифровки — как задача переходит между состояниями, что
|
||||
делает воркер, когда работы нет, и что считается отказом шага. Имя предвосхищено
|
||||
маркерами долга в `docs/architecture.md`; этим изменением заводится **только**
|
||||
требование о пустом прогоне, остальное поведение конвейера дописывает задача,
|
||||
которая его тронет.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
Нет. Требования `intake` изменение не трогает.
|
||||
|
||||
## Impact
|
||||
|
||||
- `internal/contract` — форма признаков «задачи нет» и «задача не найдена»;
|
||||
- `internal/controller/worker` — проверка пустого прогона, журнал и счётчик
|
||||
работы; у пакета сегодня нет ни одного теста, изменение заводит первый;
|
||||
- `internal/service` — перевод «задача не найдена» в «работы нет»;
|
||||
- `internal/adapter/repo/sqlite` — рождение признака «задача не найдена»;
|
||||
- `internal/adapter/recognizer/yandex` и `main.go` — отказ закрытия соединения;
|
||||
- гейт проекта и запись долга в `docs/conventions/errors.md` и `CLAUDE.md`.
|
||||
@@ -0,0 +1,477 @@
|
||||
# Триаж ревью: errors-as-instead-of-typecast
|
||||
|
||||
## Сводка
|
||||
|
||||
- **Размер / сложность / метка:** среднее × знакомое → **medium**. Режим — по графу.
|
||||
База диффа `origin/master` непригодна (удалённая ветка отстала на десятки
|
||||
коммитов и даёт 9136 строк шума); реальный вход — рабочее дерево: 5 файлов кода
|
||||
и конфига, 3 документа, 2 новых теста, каталог `openspec/changes/`.
|
||||
- **Состояние гейта:** зелёный. Проверено проходом `autotests` дважды (exit 0) и
|
||||
переспрошено триажем поимённо: `golangci-lint run` → `0 issues`,
|
||||
`go test ./...` → все пакеты `ok`. Объявленных долгов у гейта после этого
|
||||
изменения нет — заявление `CLAUDE.md` подтверждено выводом инструментов, а не
|
||||
декларацией.
|
||||
- **Находок на входе:** 12 сырых от четырёх проходов → **9 различных причин**
|
||||
после дедупликации (`Close`/`err2` пришёл трижды, MUST «ровно один раз» —
|
||||
дважды) → **5 в первых двух секциях**, 3 в гипотезах, 1 отсеяна.
|
||||
- **Потолки не срабатывали:** 2 + 3 = 5 при допустимых 3 + 4. Ничего не срезано,
|
||||
ничего не выброшено молча.
|
||||
|
||||
### Сигнал о заниженной метке
|
||||
|
||||
**Пришёл, от одного прохода — `review-code`.** Основания: четыре узла правки,
|
||||
изменение политики линтера на весь проект (`errcheck.check-blank`), переписанный
|
||||
раздел «Гейт» в `CLAUDE.md`, введение новой capability `pipeline`. С меткой
|
||||
`large` запускались бы отдельные проходы `architecture` и `operations` вместо
|
||||
разбора этих тем внутри `basics`.
|
||||
|
||||
`review-basics` запускался и сигнала о метке не подал. Согласие/несогласие
|
||||
проходов приоритет меняет, `confidence` — нет: метку выбирал `review-scope`.
|
||||
|
||||
**Ретроспективно сигнал подтверждается исходом:** три из пяти оставшихся находок
|
||||
— про документы и норму (`architecture.md`, `conventions/README.md`,
|
||||
`docs/review.md`, delta-спека), то есть ровно про тот слой, который на метке
|
||||
`medium` разбирается наименее глубоко.
|
||||
|
||||
### План разметки с исходом по каждой теме
|
||||
|
||||
| Тема | Дом | Глубина | Кто закрывает | Исход |
|
||||
|---|---|---|---|---|
|
||||
| requirements | дельта `specs/pipeline/spec.md` | разбор | `specs` | **закрыта**, 2 находки (1 дошла до блокирующей) |
|
||||
| autotests | `CLAUDE.md` «Гейт» | — | `autotests` | **закрыта**, 1 находка (понижена в гипотезы) |
|
||||
| conventions | `docs/conventions/` (errors.md, logging.md, README.md) | разбор | `code` | **закрыта**, 4 находки (1 блокирующая, 1 отсеяна) |
|
||||
| architecture | `docs/architecture.md` + `passport.md` | разбор | `basics` | **закрыта**, 1 находка |
|
||||
| security | `docs/security.md` | разбор | `basics` | **закрыта**, 0 находок (все три вопроса неприменимы) |
|
||||
| operations | `docs/architecture.md` «Эксплуатация» + `database.md` | разбор | `basics` | **закрыта**, 1 находка (понижена: изменением не создана) |
|
||||
|
||||
Своих тем проекта нет. **Тем без отчёта нет** — все шесть вернули вывод.
|
||||
|
||||
---
|
||||
|
||||
## Блокирует мердж
|
||||
|
||||
### 1. Дельта-спека закрепляет контрактом поведение, которого у системы нет, и после архивации станет ложной посылкой для следующей задачи
|
||||
|
||||
- Файл: `openspec/changes/errors-as-instead-of-typecast/specs/pipeline/spec.md:36-40`
|
||||
- Severity: major (`critical` не ставится: построенного пути к отказу или потере
|
||||
данных нет — вред отложенный и через следующего автора)
|
||||
- Confidence: high
|
||||
- Найдено проходами: `specs` (находка 1), `basics` («дешевле переделать», п. 1) —
|
||||
две независимые формулировки одной причины; оракула у них не было, оракул добыт
|
||||
триажем.
|
||||
- **Оракул (мой прогон, не на слово):**
|
||||
|
||||
```
|
||||
go test -overlay=<scratchpad>/overlay.json -run TestTriageOneFailureOneRecord -v ./internal/service/
|
||||
```
|
||||
|
||||
Тест поднимает `findJob` с репозиторием, отдающим `errors.New("database is gone")`,
|
||||
и логирует возвращённое так, как это делает `worker.go:59`. Вывод:
|
||||
|
||||
```
|
||||
level=ERROR msg="Failed to find and acquire job" state=created error="database is gone"
|
||||
level=ERROR msg="Worker error" worker=probe error="failed find and acquire job: created, database is gone"
|
||||
--- FAIL: один отказ дал 2 записей уровня ERROR, требование спеки — ровно одна
|
||||
```
|
||||
|
||||
Второй оракул — дословный критерий приёмки этого же изменения
|
||||
(`tasks.md`, «Добавлено разбором дизайна»): «**Норма не закрепляет контрактом
|
||||
то, что конвенции помечают строкой «Расхождение»: место записи и уровень
|
||||
журнала остаются долгом `docs/conventions/logging.md`**». Спека нормирует
|
||||
именно место записи. Долг записан в `docs/conventions/logging.md:159-162`.
|
||||
|
||||
- Последствие: требование `MUST быть записан ровно один раз единственной
|
||||
логирующей точкой` не имеет ни сценария (проверить его нечем — покраснеть оно
|
||||
не может), ни срока, ни владельца, ни маркера долга. Оговорка «сегодняшнее
|
||||
расхождение этим требованием нормой не объявляется» снимает обратное прочтение,
|
||||
но не снимает сам MUST. После `archive` абзац переезжает в
|
||||
`openspec/specs/pipeline/spec.md` и читается следующим автором как
|
||||
действующая норма: он либо «починит» вторую точку записи, не приняв
|
||||
сознательно решение об уровне (`ERROR` против `WARN` — дизайн его намеренно
|
||||
не принимал, `logging.md` требует `WARN` для повторяющегося сбоя фонового
|
||||
цикла), либо норма сгниёт. По `CLAUDE.md` («что такое сделана»: гейт зелёный
|
||||
**и критерии приёмки проверены поимённо») изменение сейчас не выполнено по
|
||||
своему собственному критерию.
|
||||
- Предложение: см. варианты в развилке.
|
||||
- **Действие: развилка.**
|
||||
|
||||
> В дельта-спеке `pipeline` абзац «Отказ шага MUST быть записан ровно один раз
|
||||
> единственной логирующей точкой» нормирует поведение, которого у системы нет
|
||||
> (оракул: один отказ хранилища даёт две записи ERROR), и нарушает
|
||||
> собственный критерий приёмки задачи. Как поступаем?
|
||||
>
|
||||
> **(а) Ослабить норму до проверяемого сегодня.** Оставить в требовании только
|
||||
> «отказ шага MUST быть виден владельцу записью в журнале и засчитан в счётчик
|
||||
> с пометкой отказа» — это покрыто сценарием «Шаг отказал» и проверкой
|
||||
> `TestFailureIsLoggedAndCounted`. Единственность логирующей точки вынести
|
||||
> отдельной строкой долга со ссылкой на `docs/conventions/logging.md:159-162` и
|
||||
> завести задачу в `tasks/items/`. Цена: правка дельта-спеки → **одобрение
|
||||
> дизайна отменяется, нужен возврат на чекпоинт**; кода не касается.
|
||||
>
|
||||
> **(б) Убрать вторую точку записи в этом же изменении.** Снять
|
||||
> `s.logger.Error("Failed to find and acquire job", …)` из
|
||||
> `internal/service/transcribe.go:398`, оставив запись воркеру. Цена: это чужой
|
||||
> долг и рост scope; требует сознательного решения об уровне записи, которое
|
||||
> дизайн не принимал; нужен новый сценарий и новая проверка на «ровно один
|
||||
> раз». Дельта-спека при этом всё равно правится (добавляется сценарий).
|
||||
>
|
||||
> **(в) Оставить как есть.** Цена: в актуальную спеку уезжает MUST, который
|
||||
> система нарушает с момента `apply` и который никогда не покраснеет.
|
||||
|
||||
**Прямой ответ на заданный вопрос: да, принятие этой находки меняет
|
||||
дельта-спеку — в любом из вариантов (а) и (б).** По правилу скилла `resolve`
|
||||
это отменяет одобрение дизайна и требует возврата на чекпоинт к человеку.
|
||||
Вариант (в) чекпоинта не требует, но оставляет дефект.
|
||||
|
||||
### 2. Закрытый долг остался записан в трёх местах, и одно из них — раздел, которым триаж отсеивает ложноположительные: следующая настоящая находка того же класса будет молча выброшена
|
||||
|
||||
- Файлы: `docs/conventions/README.md:21`, `docs/conventions/README.md:54`,
|
||||
`docs/review.md:69-74`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Найдено проходом: `code` (находка B); третье место (`docs/review.md`) добавлено
|
||||
триажем — это тот же дефект в третьем доме.
|
||||
- **Оракул — дословные строки, живые на момент триажа:**
|
||||
- `docs/conventions/README.md:21`: «…лог пишется на каждом шаге и дублируется
|
||||
воркером, **доменные ошибки проверяются приведением типа**.» — расхождения
|
||||
больше нет, изменение его закрыло.
|
||||
- Правило самого же README (строки 27-29): «Каждое такое место названо в своей
|
||||
записи строкой «*Расхождение:*». … **Проходу ревью строка «Расхождение»
|
||||
говорит, что находка на этом месте уже известна и новой не считается.**»
|
||||
- `docs/review.md:69-74`, раздел «Типовые ложноположительные»: «Настоящий
|
||||
дефект рядом другой — **проверка идёт приведением типа и сломается при первой
|
||||
же обёртке; он уже записан в
|
||||
[conventions/errors.md](conventions/errors.md)**» — ссылка ведёт в файл, из
|
||||
которого этот текст изменением удалён (`git diff docs/conventions/errors.md`,
|
||||
строки 51-57 сняты).
|
||||
- `docs/conventions/README.md:54`: «Непроверенное возвращаемое значение ошибки |
|
||||
`.golangci.yml` → `errcheck` (кроме `defer Close` и `send`)» — таблица не
|
||||
знает о включённом `check-blank`, то есть о том, что `_ = x.Close()` теперь
|
||||
краснеет.
|
||||
- Задача сама называет ровно две правки (`tasks.md`, 4.2): «В
|
||||
`docs/conventions/errors.md` снять …; **прочие расхождения того файла** не
|
||||
трогать». Про соседний `README.md` и про `docs/review.md` там нет ничего —
|
||||
места просто пропущены, а не оставлены сознательно.
|
||||
- Последствие: класс «молчание». `docs/review.md`, «Типовые ложноположительные»
|
||||
— единственный проектный вход в шаг отсева триажа. Пока строка жива, следующий
|
||||
прогон ревью, увидев приведение типа в новом коде, обязан отнести находку к
|
||||
известным и выбросить её — то есть регрессия ровно того дефекта, который
|
||||
чинило это изменение, пройдёт молча. `docs.py check` этого не ловит: ссылки не
|
||||
битые, битым стало утверждение внутри документа.
|
||||
- Предложение: снять фразу «доменные ошибки проверяются приведением типа» из
|
||||
`README.md:21`; снять или переписать первый пункт «Типовых ложноположительных»
|
||||
в `docs/review.md` — оговорка про настоящий дефект рядом больше неверна, сам
|
||||
же пункт про `NoopJobError` остаётся верным; в строке таблицы «Механизировано»
|
||||
заменить «(кроме `defer Close` и `send`)» на актуальный перечень
|
||||
`exclude-functions` и упомянуть `check-blank: true`.
|
||||
- **Действие: инлайн.** Три текстовые правки, решение однозначно, инвариантов не
|
||||
трогает.
|
||||
|
||||
---
|
||||
|
||||
## Стоит исправить сейчас
|
||||
|
||||
### 3. `Close()` адаптера SpeechKit теряет отказ закрытия второго соединения — ровно тот дефект, который изменение объявило закрытым своим критерием приёмки
|
||||
|
||||
- Файл: `internal/adapter/recognizer/yandex/speechkit.go:79-91`
|
||||
- Severity: minor (ущерб мал — см. находку 4: на этом пути оба `Close` в
|
||||
сегодняшней реализации возвращают `nil`; вес держится критерием приёмки, а не
|
||||
последствием)
|
||||
- Confidence: high
|
||||
- Найдено проходами: `specs` (находка 2), `code` (находка D), `basics` (находка
|
||||
E) — **три независимых попадания, оракула ни у одного нет**. Совпадение
|
||||
повышает приоритет (значит, бросается в глаза), но не `confidence`: под всеми
|
||||
проходами одна модель.
|
||||
- Оракул — дословный критерий приёмки этого же изменения (`tasks.md`, раздел
|
||||
«Критерии приёмки»): «**Отказ `Close` не теряется молча: он либо возвращается
|
||||
вызывающему, либо попадает в лог.**» Код:
|
||||
|
||||
```go
|
||||
if err1 != nil {
|
||||
return err1
|
||||
}
|
||||
return err2 // err2 при ненулевом err1 теряется молча
|
||||
```
|
||||
|
||||
`errcheck` этого не видит: значение присвоено переменной. То есть механизация,
|
||||
ради которой изменение включило `check-blank`, здесь мимо.
|
||||
- Последствие: при остановке процесса, если оба gRPC-соединения отказали в
|
||||
закрытии, владелец увидит один отказ из двух и будет разбирать половину
|
||||
картины. Вероятность низкая, стоимость правки — одна строка.
|
||||
- Предложение: `return errors.Join(err1, err2)` — тот же приём, который изменение
|
||||
уже применило в конструкторе восемью строками выше, и `nil` из него отбрасывается.
|
||||
- **Действие: инлайн.**
|
||||
|
||||
### 4. Комментарии и `design.md` описывают поведение gRPC, которого нет: журнал отправит владельца разбирать недоступность Yandex по ошибке, которая может возникнуть только от нашего двойного закрытия
|
||||
|
||||
- Файлы: `internal/adapter/recognizer/yandex/speechkit.go:56-59`, `main.go:124-129`,
|
||||
`openspec/changes/errors-as-instead-of-typecast/design.md:81-92`
|
||||
- Severity: minor
|
||||
- Confidence: high
|
||||
- Найдено проходом: `code` (находка A). Оракул проверен триажем поимённо.
|
||||
- **Оракул — исходники `google.golang.org/grpc@v1.74.2`:**
|
||||
- `clientconn.go:145` — `func NewClient(...)`: конструктор ленив, соединения не
|
||||
открывает (устанавливает его первый RPC либо явный `Connect()`);
|
||||
- `clientconn.go:1142-1156` — `Close()` возвращает **только** `nil` либо
|
||||
`ErrClientConnClosing`, и только при повторном закрытии (`if cc.conns == nil`);
|
||||
- `clientconn.go:67` — `ErrClientConnClosing = status.Error(codes.Canceled,
|
||||
"grpc: the client connection is closing")`: статическая константа.
|
||||
- Последствие, по местам:
|
||||
- `speechkit.go:56-59` — комментарий обещает «**уже открытое** соединение могло
|
||||
не закрыться»; на деле второй операнд `errors.Join` на этом пути **всегда
|
||||
`nil`**. Конструкция безвредна и защищает от смены реализации, но читатель
|
||||
выведет из комментария неверную модель API;
|
||||
- `main.go:124-129` — комментарий «недоступность внешнего сервиса разбирает он»
|
||||
и уровень `ERROR` (по `logging.md` — класс «сбой БД, диска, недоступность
|
||||
внешнего сервиса»). Запись достижима только двойным закрытием, то есть нашим
|
||||
дефектом. Владелец, увидев её, пойдёт разбирать Yandex вместо своего кода;
|
||||
- `design.md:81-92` — тот же неверный образ записан двумя утверждениями
|
||||
(«соединение с распознаванием уже открыто»; ошибка «несёт состояние
|
||||
соединения и адрес узла» — `ErrClientConnClosing` не несёт ни того, ни
|
||||
другого) и после архивации поедет дальше как обоснование.
|
||||
- Предложение: код не трогать. Привести к действительности три текста: в
|
||||
`speechkit.go` — «`grpc.NewClient` ленив, закрытие здесь почти всегда `nil`;
|
||||
`errors.Join` стоит на случай смены реализации»; в `main.go` — «единственный
|
||||
достижимый отказ здесь — повторное закрытие, то есть дефект наш, а не
|
||||
внешнего сервиса»; в `design.md` — снять оба неверных утверждения.
|
||||
- **Действие: инлайн.** Правка `design.md` фактическая, а не решенческая:
|
||||
решение («отказ закрытия — по месту, а не единым правилом») остаётся тем же,
|
||||
меняется только неверное описание чужого API. Дельта-спеку не трогает,
|
||||
чекпоинта не требует.
|
||||
|
||||
### 5. Преамбула `docs/architecture.md` описывает состояние после архивации: сегодня она утверждает существование спеки, до которой нет пути
|
||||
|
||||
- Файл: `docs/architecture.md:11-23`
|
||||
- Severity: minor
|
||||
- Confidence: high
|
||||
- Найдено проходом: `basics` («дешевле переделать», п. 2)
|
||||
- Оракул — состояние дерева на момент триажа: `ls openspec/specs/` даёт **только**
|
||||
`intake`; в новой преамбуле у пункта `intake` ссылка есть
|
||||
(`../openspec/specs/intake/spec.md`), у пункта `pipeline` ссылки **нет** — она
|
||||
снята, потому что `docs.py check` краснел битой ссылкой. Задача предписала эту
|
||||
правку сама (`tasks.md`, 4.4).
|
||||
- Последствие: пока изменение не заархивировано, документ утверждает «Заведены
|
||||
две capability», а найти вторую читателю негде — единственная форма, в которой
|
||||
она существует, лежит в `openspec/changes/`. Если изменение уедет без
|
||||
`archive` (а `archive` — отдельный шаг и отдельная команда), расхождение
|
||||
останется постоянным, и поймать его нечем: гейт зелёный именно потому, что
|
||||
ссылку сняли.
|
||||
- Предложение: у пункта `pipeline` дописать оговорку о том, где спека лежит
|
||||
сейчас и когда переедет — «дельта в
|
||||
`openspec/changes/errors-as-instead-of-typecast/specs/pipeline/spec.md`,
|
||||
переезжает в `openspec/specs/` при архивации задачи». Одно предложение, ссылка
|
||||
на существующий файл, гейт остаётся зелёным.
|
||||
- **Действие: инлайн.**
|
||||
|
||||
---
|
||||
|
||||
## Гипотезы без доказательства
|
||||
|
||||
### Новая ветка `errors.Join` в `newSpeechKitService` не покрыта ни одним тестом
|
||||
|
||||
Понижено с **major/high** до гипотезы и, по существу, до строки в границах
|
||||
покрытия. Пришло от прохода `autotests` с настоящим оракулом
|
||||
(`go tool cover -func` → `newSpeechKitService 0.0%`; триаж перепроверил:
|
||||
`go test -coverprofile` даёт `internal/adapter/recognizer/yandex — coverage:
|
||||
0.0% of statements`, тестовых файлов в пакете нет вовсе).
|
||||
|
||||
Почему понижено: находка 4 показывает, что ветка практически недостижима.
|
||||
`grpc.NewClient` с константным корректным адресом (`operation.api.cloud.yandex.net:443`)
|
||||
и валидными TLS-credentials отказывает только на разборе target'а, а второй
|
||||
операнд `errors.Join` на этом пути всегда `nil`. То есть непокрыт код, который
|
||||
и выполняться-то не будет. **Предложенное самим проходом «вынести создание
|
||||
клиента за шов» триаж не рекомендует**: это разросшаяся абстракция в адаптере
|
||||
ради единственной недостижимой ветки, и заказывать её на основании процента
|
||||
покрытия — ровно та правка, от которой защищает потолок. Принят второй вариант
|
||||
самого же прохода: занести в границы покрытия (сделано ниже).
|
||||
|
||||
### Признака живости воркера нет: сутки тишины одинаково означают «записей не слали» и «все три воркера висят»
|
||||
|
||||
Пришло от `basics` (находка F), понижено: **изменением не создано**. Спека
|
||||
нормирует молчание пустого прогона, но само молчание стоит на инварианте
|
||||
`CLAUDE.md` «`NoopJobError` — не ошибка», который старше этой задачи.
|
||||
Оракула на «воркер висит» нет — поднять SpeechKit в тесте нечем (см. «Недоступно
|
||||
проверке»). Более того, свойство **уже заведено задачей**:
|
||||
`tasks/items/service-observability.md`, критерий завершения 1 — «По метрикам
|
||||
видно, что конвейер встал: задача висит в состоянии дольше обычного, и это
|
||||
отличимо от «работы нет»». Новой находкой не считается; чинить в этом изменении
|
||||
нечего, иначе это рост scope на целую тему наблюдаемости.
|
||||
|
||||
### Норма спеки пересказана прозой в `docs/conventions/errors.md` — второй дом факта
|
||||
|
||||
Пришло от `basics` («дешевле переделать», п. 3). Оракула нет: `openspec/config.yaml:36`
|
||||
действительно предупреждает, что «второй дом факта расходится с первым молча»,
|
||||
но новый абзац `errors.md:51-57` заканчивается словами «**Норма записана
|
||||
требованием capability `pipeline`**», то есть первый дом назван явно. Разделение
|
||||
здесь защитимо: спека говорит, что делает система, конвенция — как это пишут в
|
||||
коде. Доказательства предстоящего расхождения у меня нет, а превентивная правка
|
||||
свелась бы к спору о вкусе. Оставлено гипотезой; если расхождение когда-нибудь
|
||||
случится, эта запись — след.
|
||||
|
||||
---
|
||||
|
||||
## Promote candidates
|
||||
|
||||
- **Форма `msg` в журнале.** `logger.Error("failed to close audio recognizer", …)`
|
||||
(`main.go:126`) — предложение, а не короткая категория, вопреки
|
||||
`docs/conventions/logging.md:32-37`. Пришло от `code` (находка C) и **отсеяно
|
||||
как находка**: `logging.md:44` уже несёт строку «*Расхождение:* сегодня `msg` —
|
||||
предложение вида `Starting conversion job`», и весь корпус журнала написан в
|
||||
этой форме; правка одной строки сделает журнал неоднороднее, а не однороднее.
|
||||
Это претензия на правило, а не на этот код: либо сканер формы `msg`, либо
|
||||
отдельная задача на разовую миграцию всего корпуса.
|
||||
- **Устаревание утверждения внутри документа не механизировано.** `docs.py check`
|
||||
ловит битые ссылки и раскладку, но не ловит ситуацию находки 2: файл на месте,
|
||||
ссылка цела, неверным стало утверждение о его содержимом. Кандидат:
|
||||
проверка «строка «*Расхождение:*» и упоминание расхождения в чужом файле
|
||||
живут парой» либо явные якоря вместо ссылок на файл целиком.
|
||||
- **Покрытие изменённых строк не считается ничем** (`CLAUDE.md`, «Гейт», сказано
|
||||
прямо). Именно поэтому проход `autotests` вынужден был звать `go tool cover`
|
||||
руками, а решение «покрывать или занести в границы» принималось на глаз.
|
||||
Кандидат в шаг гейта, а не в находку ревью.
|
||||
|
||||
---
|
||||
|
||||
## Границы покрытия
|
||||
|
||||
### План: темы, их дома и глубины
|
||||
|
||||
Все шесть тем плана (`requirements`, `autotests`, `conventions`, `architecture`,
|
||||
`security`, `operations`) имели дом и вернули отчёт — см. таблицу в сводке. Тем
|
||||
без дома в плане нет. Своих тем проекта нет.
|
||||
|
||||
### Какие проходы запускались и в каком режиме
|
||||
|
||||
Метка `medium`, режим «по графу». Запущены: `review-autotests`, `review-specs`,
|
||||
`review-code`, `review-basics`, `review-triage`. Состав соответствует плану
|
||||
`review-scope`.
|
||||
|
||||
### Какие проходы не запускались и почему
|
||||
|
||||
- Отдельные проходы **`architecture` и `operations`** — по метке: на `medium`
|
||||
эти темы разбираются внутри `review-basics` меньшей глубиной. `review-code`
|
||||
подал сигнал, что метка, вероятно, занижена (см. сводку); при `large` эти два
|
||||
прохода шли бы отдельно и глубже.
|
||||
- Прохода **идиоматичности** в конвейере нет — упразднён.
|
||||
- Прохода **независимой реализации** в конвейере нет — снят по стоимости.
|
||||
|
||||
### Что каждый запущенный проход не мог проверить в принципе
|
||||
|
||||
- `autotests` — судит оракулы и их способность краснеть, но не судит, верна ли
|
||||
сама норма, которую они проверяют; поведение под реальным потоком не
|
||||
воспроизводит.
|
||||
- `specs` — судит соответствие нормы и кода, но не судит, нужна ли норма и не
|
||||
дорога ли она; альтернативной формулировки требования не строит.
|
||||
- `code` — читает дифф; поведения системы целиком, в сборе и под нагрузкой, не
|
||||
наблюдает.
|
||||
- `basics` — тремя темами на малой глубине; ни одну из них до дна не доводит по
|
||||
построению.
|
||||
- `triage` (я) — **ничего нового не нахожу по определению**: я не читаю код в
|
||||
поисках дефектов, я работаю с чужими выводами. Пропуск любого прохода — мой
|
||||
пропуск тоже, и единственное, что я могу с этим сделать, — назвать его
|
||||
поимённо, что и сделано выше.
|
||||
|
||||
### Что осталось целиком на человеке
|
||||
|
||||
**Не проверит ни один проход** (`docs/review.md:159-166`):
|
||||
|
||||
- `operations`: поведение внешних сервисов под нагрузкой и на границах —
|
||||
SpeechKit и Object Storage поднять в тесте нечем;
|
||||
- `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
|
||||
день, и утверждения о росте остаются условиями, а не замерами;
|
||||
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
|
||||
отдан внешней программе, и она вне нашей границы.
|
||||
|
||||
**Перестали проверять сознательно** (`docs/review.md:168-175`):
|
||||
|
||||
- `autotests`: разбор вывода настоящего `ffprobe`. Проверки приёма звали его до
|
||||
2026-08-11 — правда, звали так, что он всегда отказывал, — а теперь получают
|
||||
длительность от подставного источника. Своего теста у
|
||||
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в
|
||||
`docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md`.
|
||||
|
||||
**Плюс этим прогоном:**
|
||||
|
||||
- `internal/adapter/recognizer/yandex` — покрытие **0.0 %**, тестовых файлов в
|
||||
пакете нет вовсе; новая ветка `errors.Join` в `newSpeechKitService:53-63` не
|
||||
исполняется ни одной проверкой. Заносится сюда сознательно вместо заведения
|
||||
шва (см. гипотезы);
|
||||
- `main.go` — пакет `main` в проекте никогда не тестировался, шва нет; новый
|
||||
`defer` с логированием отказа закрытия проверен только чтением (`tasks.md`,
|
||||
2.4: между постановкой `defer` и завершением `main` нет `os.Exit`);
|
||||
- реальный путь `NoopJobError` от SQLite-репозитория (не от подставного) —
|
||||
проверки обоих звеньев работают на заглушках.
|
||||
|
||||
**Общее, что не проверяет никто:** история инцидентов; поведение под реальным
|
||||
потоком; поведение внешних систем в их версиях (утверждения о gRPC в находке 4
|
||||
сняты с исходников `v1.74.2` — на другой версии их надо перепроверять);
|
||||
завязка потребителей на текущее поведение; вопрос «а нужна ли эта
|
||||
функциональность вообще».
|
||||
|
||||
### Каких документов проекта не хватило
|
||||
|
||||
- **`docs/adr/` по теме этого изменения — записи нет.** Решение «`NoopJobError`
|
||||
и `JobNotFoundError` остаются типизированными, а не становятся sentinel»
|
||||
принято в `design.md` (Решение 1) и живёт только там, в документе изменения,
|
||||
который после архивации уедет в `openspec/changes/archive/`. Через полгода
|
||||
обоснование придётся выводить заново.
|
||||
- **Строка «*Расхождение:*» не имеет владельца и срока по построению**
|
||||
(`docs/conventions/README.md:27-29`). Из-за этого долг «одна запись отказа —
|
||||
две строки журнала» нельзя ни просрочить, ни закрыть — что и породило находку 1.
|
||||
- Прочих пробелов проходы не заявили. `docs/security.md`, `docs/database.md`,
|
||||
`docs/passport.md`, `docs/conventions/*` на месте и периметр называют;
|
||||
инварианты в `CLAUDE.md` есть и снабжены severity — деградации по этому
|
||||
разряду на этом прогоне не было.
|
||||
|
||||
### Сработавшие потолки — по строке на проход
|
||||
|
||||
- `review-basics` — потолок **2/4**, показано 2 находки + 3 пункта «дешевле
|
||||
переделать до мерджа». Потолок не срабатывал, за срезом ничего не осталось.
|
||||
Проход сообщил это сам.
|
||||
- `review-autotests` — **о своём потолке не сообщил**; показана 1 находка и 1
|
||||
отклонённая гипотеза. Это находка о прогоне: сколько осталось за срезом,
|
||||
установить нечем.
|
||||
- `review-specs` — **о своём потолке не сообщил**; показано 2 находки. То же.
|
||||
- `review-code` — **о своём потолке не сообщил**; показано 4 находки при
|
||||
раздельных потолках половин `conventions` и `техника`. То же.
|
||||
- `review-triage` (я) — потолки 3 и 4, занято 2 и 3. **Потолок не срабатывал,
|
||||
ничего не срезано, ничего не выброшено молча.** Единственная отсеянная находка
|
||||
(форма `msg`) названа поимённо в `Promote candidates` с причиной отсева.
|
||||
|
||||
### Четыре строки триажа: чего в конвейере нет вовсе
|
||||
|
||||
1. **Решения проекта не сверялись.** `docs/adr/` — процессный документ, прогон
|
||||
его не открывает. Изменение вводит новую capability `pipeline` и меняет
|
||||
политику линтера на весь проект; расходится ли это с записанными ADR, ни один
|
||||
проход не проверял. Ловит такое сверка документации — скилл
|
||||
`av-dev-docs:healthcheck`, и звать его надо руками (`CLAUDE.md`, «Гейт»:
|
||||
«согласованность документов между собой и с кодом … звать его надо руками»).
|
||||
2. **Записанные наблюдения проекта не использовались.** `docs/research/` — тоже
|
||||
процессный. Каждое число в этом отчёте снято на этом прогоне приложенной
|
||||
командой: `0 issues` от `golangci-lint run`, `0.0 % of statements` от
|
||||
`go test -coverprofile`, «2 записи ERROR» от прогона с `-overlay`. Чисел без
|
||||
команды замера в отчёте нет.
|
||||
3. **Поимённая сверка с руководствами по стилю Go не задавалась ни одним
|
||||
проходом.** `errors.Join` в конструкторе, `errors.As` с указателем на
|
||||
указатель, `defer` с телом вместо голого вызова — все три конструкции судились
|
||||
по внутренним конвенциям проекта и по линтеру. Различение «идиоматично против
|
||||
просто распространено» не спрашивал никто с тех пор, как упразднён проход про
|
||||
идиоматичность.
|
||||
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
|
||||
нет.** Проход независимой реализации снят по стоимости, а не по замеру. Вопрос
|
||||
«а не решается ли задача «признак не ломается обёрткой» иначе — например,
|
||||
sentinel-значениями, как сам `design.md` рассматривает в Решении 1» никем
|
||||
независимо не проверялся: рассмотрел и отверг его автор дизайна, и ревью
|
||||
сверялось с его же рассуждением.
|
||||
|
||||
Метка `medium`, поэтому пятая строка про `small` не применяется: темы `security`,
|
||||
`operations` и `architecture` разбирались проходом `basics` по своим домам, а не
|
||||
только по инвариантам `CLAUDE.md`.
|
||||
|
||||
---
|
||||
|
||||
**Формулировка «критичных проблем не обнаружено» в этом отчёте не употребляется
|
||||
и не подразумевается.** `critical` не выставлен ни одной находке по конкретной
|
||||
причине: построенного пути к потере данных, порче или утечке секрета ни один
|
||||
проход не предъявил, а `critical` без оракула или построенного пути не
|
||||
существует. Что именно осталось непроверенным — перечислено выше поимённо.
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
## Purpose
|
||||
|
||||
Конвейер расшифровки: как задача движется по состояниям, что делает воркер,
|
||||
когда работы нет, и что считается отказом шага.
|
||||
|
||||
Описан пока **только пустой прогон воркера** — тот, что нормируют проверки
|
||||
пакета `internal/controller/worker` и перевод признака в `internal/service`.
|
||||
Сознательно не описаны переходы состояний и цепочка `created → converted →
|
||||
transcribe → done | failed`, захват задачи и срок его протухания, отмена
|
||||
контекста посреди шага, освобождение ресурсов внешних клиентов. Это не значит,
|
||||
что такого поведения нет: оно живёт в коде, а требования на него не написаны,
|
||||
потому что требование без проверки — предположение, а не норма. Первая задача,
|
||||
которая трогает любое из перечисленного, дописывает его сюда.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Пустой прогон воркера — не отказ
|
||||
|
||||
Воркер SHALL отличать «работы в этом состоянии сейчас нет» от отказа шага. На
|
||||
пустом прогоне он MUST не считать прогон отказом: не увеличивать счётчик работы
|
||||
и не писать о нём на уровне владельца сервиса. Признак пустого прогона MUST
|
||||
узнаваться по смыслу значения, а не по его точной форме, и MUST переживать
|
||||
пояснения, добавленные к этому значению на любом промежуточном шаге пути.
|
||||
|
||||
Требование стоит на инварианте проекта «`NoopJobError` — не ошибка»: три воркера
|
||||
опрашивают базу раз в секунду, и пустой прогон, принятый за отказ, даёт три
|
||||
записи отказа в секунду и столько же засчитанных сбоев, которых не было.
|
||||
|
||||
Признак пустого прогона MUST рождаться только ответом хранилища на опрос этим же
|
||||
шагом. Слой, придающий отказу собственный смысл, MUST не сохранять чужой признак
|
||||
в цепочке своей ошибки. Узнавание по смыслу видит признак на любой глубине, и
|
||||
отказ, к которому признак примешался, воркер зачёл бы пустым прогоном: задача
|
||||
осталась бы в своём состоянии и переопрашивалась раз в секунду без единой записи
|
||||
— ровно то, что запрещает инвариант «Принятая запись не теряется молча».
|
||||
|
||||
Отказ шага, наоборот, MUST быть виден владельцу сервиса записью в журнале и MUST
|
||||
быть засчитан в счётчик работы с пометкой отказа.
|
||||
|
||||
**Сколько раз он записывается и каким уровнем — это требование не нормирует, и
|
||||
умолчанием тут считать нечего.** Сегодня один отказ даёт две записи: пишет шаг
|
||||
конвейера и следом воркер, — а уровень стоит `ERROR` там, где конвенция просит
|
||||
`WARN` для повторяющегося сбоя фонового цикла. И то и другое записано долгом в
|
||||
`docs/conventions/logging.md`, раздел «Ошибки», строкой «Расхождение, и оно
|
||||
системное». Долгом оно и остаётся: требование, объявившее одиночную запись
|
||||
нормой, сделало бы недостижимое обязательным, а требование, объявившее нормой
|
||||
двойную, — закрыло бы долг контрактом. Задача, которая возьмётся за этот долг,
|
||||
дописывает норму сюда.
|
||||
|
||||
#### Scenario: Работы в состоянии нет
|
||||
|
||||
- **GIVEN** ни одной задачи в опрашиваемом состоянии нет
|
||||
- **WHEN** воркер делает свой прогон
|
||||
- **THEN** на уровне владельца сервиса об этом прогоне не пишется ничего
|
||||
- **AND** счётчик работы воркера не растёт
|
||||
|
||||
#### Scenario: Признак пустого прогона дошёл с пояснением
|
||||
|
||||
- **GIVEN** работы в опрашиваемом состоянии нет
|
||||
- **AND** промежуточный шаг добавил к этому признаку своё пояснение
|
||||
- **WHEN** воркер делает свой прогон
|
||||
- **THEN** прогон по-прежнему считается пустым: счётчик не растёт, записи на
|
||||
уровне владельца нет
|
||||
|
||||
#### Scenario: Шаг отказал
|
||||
|
||||
- **GIVEN** шаг конвейера вернул отказ
|
||||
- **WHEN** воркер завершает прогон
|
||||
- **THEN** отказ виден владельцу сервиса записью в журнале
|
||||
- **AND** счётчик работы воркера растёт с пометкой отказа
|
||||
|
||||
#### Scenario: Шаг сделал работу
|
||||
|
||||
- **GIVEN** шаг конвейера отработал задачу без отказа
|
||||
- **WHEN** воркер завершает прогон
|
||||
- **THEN** счётчик работы воркера растёт с пометкой успеха
|
||||
- **AND** записи об отказе в журнале нет
|
||||
@@ -0,0 +1,100 @@
|
||||
## 1. Признак узнаётся по смыслу
|
||||
|
||||
- [x] 1.1 В `internal/controller/worker/worker.go:51` заменить приведение
|
||||
`err.(*contract.NoopJobError)` на `errors.As` с целью типа
|
||||
`*contract.NoopJobError`; порядок ветвей (счётчик, затем журнал) сохранить.
|
||||
- [x] 1.2 В `internal/service/transcribe.go:392` заменить приведение
|
||||
`err.(*contract.JobNotFoundError)` на `errors.As`.
|
||||
- [x] 1.3 `golangci-lint run` не даёт замечаний `errorlint` — проверить прогоном.
|
||||
|
||||
## 2. Отказ закрытия не теряется
|
||||
|
||||
- [x] 2.1 В `.golangci.yml` включить `errcheck.check-blank: true`. Без этого
|
||||
`_ = conn.Close()` снимает замечание, и оракул раздела 4 не различает годную
|
||||
реализацию от негодной. Прогон обязан остаться на прежних 4 замечаниях — новых
|
||||
мест правило не открывает (проверено при разборе дизайна).
|
||||
- [x] 2.2 В `internal/adapter/recognizer/yandex/speechkit.go:55` собрать отказ
|
||||
закрытия `sttConn` с отказом соединения через `errors.Join`; `nil` от закрытия
|
||||
форму ошибки не меняет.
|
||||
- [x] 2.3 В `main.go:124` заменить `defer recognizer.Close()` на `defer` с телом,
|
||||
пишущим отказ закрытия в журнал.
|
||||
- [x] 2.4 Проверить, что между постановкой этого `defer` и штатным завершением
|
||||
`main` нет вызова `os.Exit`: иначе запись не появится (риск из `design.md`).
|
||||
- [x] 2.5 `golangci-lint run` не даёт замечаний `errcheck` — проверить прогоном.
|
||||
- [x] 2.6 Мутация оракула: временно заменить оба места на `_ = …Close()` —
|
||||
`golangci-lint run` обязан покраснеть обоими. Не покраснел — оракул критерия 4
|
||||
не работает, и шаг 2.1 сделан неверно. Восстановить код после проверки.
|
||||
|
||||
## 3. Оракул на пустой прогон — оба звена пути
|
||||
|
||||
- [x] 3.1 Завести первый тест пакета `internal/controller/worker`; оснастку
|
||||
(подменный журнал, чтение счётчика из реестра метрик) взять по образцу тестов
|
||||
`internal/service` и `internal/controller/http`, а не писать заново.
|
||||
- [x] 3.2 Тест: воркер, чья работа вернула `*contract.NoopJobError`, **обёрнутый**
|
||||
`fmt.Errorf("…: %w", err)`, не пишет в журнал ни одной записи.
|
||||
- [x] 3.3 Тот же тест: счётчик `transcriber_worker_job_count` для этого воркера
|
||||
не изменился — значение читается до и после прогона.
|
||||
- [x] 3.4 Тест отказа: обычный отказ шага даёт запись в журнал и рост счётчика с
|
||||
пометкой отказа — иначе оракул зелен на коде, который не считает отказом ничего.
|
||||
- [x] 3.5 Тест успеха: прогон без отказа растит счётчик с пометкой успеха и не
|
||||
пишет об отказе. Без него реализация, снявшая счёт успешных прогонов, проходит
|
||||
все проверки, а доля отказов перестаёт считаться.
|
||||
- [x] 3.6 **Второе звено пути**: тест в `internal/service` на `findJob` с
|
||||
подставным репозиторием, возвращающим `fmt.Errorf("…: %w",
|
||||
&contract.JobNotFoundError{…})` — ожидание `*contract.NoopJobError`. Без него
|
||||
правка 1.2 принимается только линтером, а линтер проверяет форму, не смысл.
|
||||
- [x] 3.7 Мутация, поимённо по обеим строкам: вернуть приведение типа в
|
||||
`worker.go` — краснеют 3.2 и 3.3; вернуть приведение (или подставить
|
||||
несовпадающую цель `errors.As`) в `transcribe.go` — краснеет 3.6. Обе мутации
|
||||
обязаны покраснеть по отдельности. Восстановить код после проверки.
|
||||
|
||||
## 4. Учёт
|
||||
|
||||
- [x] 4.1 `task gate` зелёный целиком; в `CLAUDE.md`, разделе «Гейт», снять
|
||||
запись об известном отказе `golangci-lint` и о задаче
|
||||
`errors-as-instead-of-typecast`.
|
||||
- [x] 4.2 В `docs/conventions/errors.md` снять пометку «*Расхождение, и оно
|
||||
опасно:*» о приведении типа и строку преамбулы «доменные ошибки проверяются
|
||||
приведением типа»; прочие расхождения того файла не трогать.
|
||||
- [x] 4.3 Дописать `## Purpose` в спеку `pipeline` — что это за capability и что
|
||||
сознательно **не** описано (переходы состояний, захват, срок протухания,
|
||||
отмена контекста, освобождение ресурсов). Без него следующий автор не отличит
|
||||
«остальное не нормировано» от «остального не бывает»; валидатор этого не ловит.
|
||||
- [x] 4.4 Поправить преамбулу `docs/architecture.md`: заведены две capability,
|
||||
`intake` и `pipeline`, и в `pipeline` описан только пустой прогон воркера.
|
||||
Маркеры `<!-- канон: поведение → openspec/specs/pipeline -->` на строках про
|
||||
идемпотентность и цепочку состояний оставить долгом — но так, чтобы их нельзя
|
||||
было прочесть как «уже переехало».
|
||||
- [x] 4.5 `openspec validate --strict errors-as-instead-of-typecast` проходит.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
Дословно из записи задачи `tasks/items/errors-as-instead-of-typecast.md`:
|
||||
|
||||
- Обе проверки идут через `errors.As` либо через `errors.Is` по sentinel.
|
||||
Оракул — `golangci-lint run` не даёт замечаний `errorlint`.
|
||||
- Обёртка `fmt.Errorf("…: %w", err)` в середине пути не ломает распознавание.
|
||||
Оракул — тест: обёрнутый `NoopJobError` воркер по-прежнему считает пустым
|
||||
прогоном и не пишет ни лога, ни метрики.
|
||||
- Метрика `transcriber_worker_job_count` на пустом прогоне не растёт. Оракул —
|
||||
тот же тест, проверка значения счётчика до и после.
|
||||
- Отказ `Close` не теряется молча: он либо возвращается вызывающему, либо
|
||||
попадает в лог. Оракул — `golangci-lint run` не даёт замечаний `errcheck`, и
|
||||
`task gate` зелёный целиком.
|
||||
|
||||
Добавлено разбором дизайна (рубрика прохода `rubric`, потолок пунктов не
|
||||
применялся):
|
||||
|
||||
- Каждый оракул способен покраснеть на негодной реализации. Проверяется
|
||||
мутацией — шаги 2.6 и 3.7; критерий, чей единственный оракул молчание линтера,
|
||||
годится только когда линтер краснеет на **всех** негодных реализациях.
|
||||
- Признак пустого прогона распознаётся на **обоих** звеньях пути, и каждое звено
|
||||
имеет свою падающую проверку.
|
||||
- Классификация отказа не зависит от текста ошибки: ни подстроки `Error()`, ни
|
||||
точной формы значения.
|
||||
- Норма не закрепляет контрактом то, что конвенции помечают строкой
|
||||
«Расхождение», и не объявляет обязательным недостижимое: число записей об
|
||||
отказе и уровень журнала остаются долгом `docs/conventions/logging.md`, а
|
||||
требование их не нормирует ни в ту, ни в другую сторону. Проверено ревью кода:
|
||||
первая редакция требовала «ровно один раз», чему код не соответствовал с
|
||||
первого дня.
|
||||
@@ -0,0 +1,78 @@
|
||||
# pipeline Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Конвейер расшифровки: как задача движется по состояниям, что делает воркер,
|
||||
когда работы нет, и что считается отказом шага.
|
||||
|
||||
Описан пока **только пустой прогон воркера** — тот, что нормируют проверки
|
||||
пакета `internal/controller/worker` и перевод признака в `internal/service`.
|
||||
Сознательно не описаны переходы состояний и цепочка `created → converted →
|
||||
transcribe → done | failed`, захват задачи и срок его протухания, отмена
|
||||
контекста посреди шага, освобождение ресурсов внешних клиентов. Это не значит,
|
||||
что такого поведения нет: оно живёт в коде, а требования на него не написаны,
|
||||
потому что требование без проверки — предположение, а не норма. Первая задача,
|
||||
которая трогает любое из перечисленного, дописывает его сюда.
|
||||
|
||||
## Requirements
|
||||
### Requirement: Пустой прогон воркера — не отказ
|
||||
|
||||
Воркер SHALL отличать «работы в этом состоянии сейчас нет» от отказа шага. На
|
||||
пустом прогоне он MUST не считать прогон отказом: не увеличивать счётчик работы
|
||||
и не писать о нём на уровне владельца сервиса. Признак пустого прогона MUST
|
||||
узнаваться по смыслу значения, а не по его точной форме, и MUST переживать
|
||||
пояснения, добавленные к этому значению на любом промежуточном шаге пути.
|
||||
|
||||
Требование стоит на инварианте проекта «`NoopJobError` — не ошибка»: три воркера
|
||||
опрашивают базу раз в секунду, и пустой прогон, принятый за отказ, даёт три
|
||||
записи отказа в секунду и столько же засчитанных сбоев, которых не было.
|
||||
|
||||
Признак пустого прогона MUST рождаться только ответом хранилища на опрос этим же
|
||||
шагом. Слой, придающий отказу собственный смысл, MUST не сохранять чужой признак
|
||||
в цепочке своей ошибки. Воркер узнаёт признак по смыслу на любой глубине, поэтому
|
||||
отказ, к которому признак примешался, тоже зачёл бы пустым прогоном: задача
|
||||
осталась бы в своём состоянии и переопрашивалась раз в секунду без единой записи
|
||||
— ровно то, что запрещает инвариант «Принятая запись не теряется молча».
|
||||
|
||||
Отказ шага, наоборот, MUST быть виден владельцу сервиса записью в журнале и MUST
|
||||
быть засчитан в счётчик работы с пометкой отказа.
|
||||
|
||||
**Сколько раз он записывается и каким уровнем — это требование не нормирует, и
|
||||
умолчанием тут считать нечего.** Сегодня один отказ даёт две записи: пишет шаг
|
||||
конвейера и следом воркер, — а уровень стоит `ERROR` там, где конвенция просит
|
||||
`WARN` для повторяющегося сбоя фонового цикла. И то и другое записано долгом в
|
||||
`docs/conventions/logging.md`, раздел «Ошибки», строкой «Расхождение, и оно
|
||||
системное». Долгом оно и остаётся: требование, объявившее одиночную запись
|
||||
нормой, сделало бы недостижимое обязательным, а требование, объявившее нормой
|
||||
двойную, — закрыло бы долг контрактом. Задача, которая возьмётся за этот долг,
|
||||
дописывает норму сюда.
|
||||
|
||||
#### Scenario: Работы в состоянии нет
|
||||
|
||||
- **GIVEN** ни одной задачи в опрашиваемом состоянии нет
|
||||
- **WHEN** воркер делает свой прогон
|
||||
- **THEN** на уровне владельца сервиса об этом прогоне не пишется ничего
|
||||
- **AND** счётчик работы воркера не растёт
|
||||
|
||||
#### Scenario: Признак пустого прогона дошёл с пояснением
|
||||
|
||||
- **GIVEN** работы в опрашиваемом состоянии нет
|
||||
- **AND** промежуточный шаг добавил к этому признаку своё пояснение
|
||||
- **WHEN** воркер делает свой прогон
|
||||
- **THEN** прогон по-прежнему считается пустым: счётчик не растёт, записи на
|
||||
уровне владельца нет
|
||||
|
||||
#### Scenario: Шаг отказал
|
||||
|
||||
- **GIVEN** шаг конвейера вернул отказ
|
||||
- **WHEN** воркер завершает прогон
|
||||
- **THEN** отказ виден владельцу сервиса записью в журнале
|
||||
- **AND** счётчик работы воркера растёт с пометкой отказа
|
||||
|
||||
#### Scenario: Шаг сделал работу
|
||||
|
||||
- **GIVEN** шаг конвейера отработал задачу без отказа
|
||||
- **WHEN** воркер завершает прогон
|
||||
- **THEN** счётчик работы воркера растёт с пометкой успеха
|
||||
- **AND** записи об отказе в журнале нет
|
||||
|
||||
Reference in New Issue
Block a user