Перейти к основному содержимому

ADR 0046 — Продвижение истории с полномочиями пользователя

Контекст​

Находка #13 dogfooding, дословно из docs/missions/dogfooding-mission.md:669:

Не существует «продвинуть в ready»: продвижение автоматическое при создании. TransitionStoryUseCase проверяет и испускает backlog.story_transitioned, но не подключён ни к одному маршруту — это мёртвый код. Вкладка Backlog только для чтения.

Она родилась с классификацией P2 и стала одной из трёх P1, которые убивает Фаза 12, по причине, ставшей ясной только после прогона: из трёх находок эта — единственная не об удобстве. Усыновление и перепланирование были трением — человек вручную делал то, что должна была делать машина. Здесь наоборот: машина сама делала то, что должен был решать человек, а CLAUDE.md говорит, в первой строке о том, что такое продукт, что окончательные полномочия принадлежат пользователю. История, становящаяся ready при создании, означает, что PO — агент LLM — сам решает, что входит в очередь работы dev agents.

Три факта, проверенные в коде ДО проектирования, сформировали решение:

  1. story.status — шлюз claim. TaskRepository.claimNext фильтрует по s.status = 'ready' AND s.module_ids ? module AND t.status = 'todo' AND t.blocked = false. Ничто, кроме статуса истории, не отделяет «предложенную работу» от «работы, доступной для взятия».
  2. Проверка была продублирована и асимметрична. Создание вызывало canBecomeReady; переход вызывал assertReady + assertModulesResolved. Две двери в одно и то же состояние, с разными замками.
  3. TransitionStoryUseCase уже делал всё — проверял, испускал событие и, с Фазы 12b, записывал строку outbox task.became_claimable на каждую освобождённую задачу. Не хватало вызывающего.

Решение​

proposed_ready — это boolean, а не новое значение в enum статуса. Это была первая отвергнутая попытка. story_status = 'proposed' был бы выразительнее, но enum — это буквально шлюз claim (факт 1): добавление значения обязало бы пересмотреть каждый запрос, сравнивающий статус, а забытое место не дало бы ошибки — оно дало бы историю, доступную для взятия слишком рано, молча. Boolean говорит, чем это является: ПРЕДЛОЖЕНИЕМ об истории, которая остаётся draft. Состояние машины не меняется, потому что фактически ничего не изменилось; существует ожидающее решение, адресованное пользователю.

Продвижение повторно использует TransitionStoryUseCase; нового перехода не написано. Мёртвый код находки #13 снова вызывается, и вместе с ним бесплатно приходит перепланирование 12b: продвижение записывает строки outbox, которые будят простаивающих dev agents модуля. «Собственное» продвижение пришлось бы перереализовывать — и первая версия, забывшая бы это, оставила бы пакет задач доступным для взятия без оповещения кого-либо, а агент обнаружил бы это случайно при следующем несвязанном событии.

execute получил опциональный параметр actor. Событие backlog.story_transitioned неизменяемо и является тем, что читает аудит; записать agent/po при продвижении, которое было решением пользователя, стёрло бы именно тот человеческий шаг, который возвращает эта фаза.

Проверка была объединена в assertPromotable ДО того, как сделать триггер настраиваемым, и этот порядок — не деталь. Пока у двух путей были разные замки, «продвинуть через UI» и «продвинуть при создании» были бы разными правилами с одним именем, и режим manual был бы строже или мягче, чем auto, по случайности реализации. Тест симметрии в story-promotion.spec.ts — то, что сохраняет свойство: режим меняет, КТО запускает, никогда — ЧТО проверяется.

Деталь, которая чуть не сломала режим auto молча: пустой moduleIds ПРОХОДИТ проверку и должен проходить. При создании у истории ещё нет модулей — их назначает Arquiteto, позже. Подключение assertPromotable к пути создания без сохранения этого заставило бы режим auto больше никогда ничего не продвигать, без единой ошибки.

Backfill направленный, а не слепой. Столбец рождается manual (новое значение по умолчанию), и та же миграция переводит все существующие проекты в auto. Это противоположность backfill RN-046, который мог быть слепым, потому что усыновления до него не существовало. Здесь поведение существовало и использовалось: идущий проект не может перестать производить из-за деплоя. Новое значение по умолчанию действует для тех, кто придёт позже.

Отказ повторяет возврат gate к dev и инвертирует порядок rearm. Причина становится ЗАКРЕПЛЁННЫМ сообщением в сессии PO — первым pinned вне system prompt у разговорного агента, — с той же фразой приоритета, которую correction_message/1 dev agent несёт со времён ADR 0020. Закреплённым, потому что отказ — это ожидающее решение, а не реплика: сжатый ContextManager, PO заново предложил бы ту же историю с тем же дефектом.

Запись идёт ДО вызова engine, в отличие от RearmDevAgentUseCase. Там событие (dev.rearmed) утверждает что-то ОБ ENGINE, и записать его раньше было бы ложью в log, если бы engine отказал. Здесь событие утверждает что-то о ПОЛЬЗОВАТЕЛЕ — он отказал, и это правда, есть ли работающий PO, чтобы услышать, или нет. Потерять решение, потому что процесс агента умер при перезапуске, вернуло бы пользователя к началу без причины. Поэтому engine — best-effort, а внутренний маршрут отвечает 404 при мёртвом PO вместо того, чтобы взорваться :noproc.

Сообщение возврата говорит, что PO МОЖЕТ сделать, и это принятое ограничение. Инструмента редактирования истории нет — только create_story. Сообщение велит создать исправленную версию или спросить пользователя, если причина неясна. Велеть «исправить историю» значило бы просить невозможного, а модель перед невозможной инструкцией выдумывает инструмент или повторяет вызов, пока не исчерпает цикл, — так dev agent сжёг три исправления подряд при приёмке ADR 0020.

Пакетное продвижение — не всё или ничего. Каждая история — своя транзакция; та, что провалится, возвращается в failed с причиной, в 201. Реальный случай конкретен: между предложением PO и решением пользователя модуль мог уйти из module_map. Отмена всего пакета из-за этого перечеркнула бы ревью, которое пользователь только что сделал по остальным.

Последствия​

Значение по умолчанию изменилось, и это нарушение поведения для того, кто создаёт новый проект: backlog не движется сам, пока кто-то не продвинет. Это отмечено в CHANGELOG как несовместимое. Существующий проект ничего не почувствует.

UI Backlog перестал быть только для чтения. Это две единственные записи backlog, принадлежащие пользователю, а не агенту, — всё остальное по-прежнему входит через маршруты /internal/*.

POST /internal/sessions/:id/agent/revise — первый путь api→engine, который возвращает работу РАЗГОВОРНОМУ агенту. До сих пор возвраты (gate → dev) были внутренними для engine, в процессе. Маршрут унаследовал от rearm формат и проверку существования перед вызовом.

Остаётся на потом, как backlog, а не как скрытый долг:

  • Инструмент редактирования истории. Пока его нет, цикл отказа закрывается пересозданием, а отклонённая история остаётся в draft с записанной причиной. Это проверяемо, но оставляет мусор в backlog.
  • Пакетное продвижение с ревью бок о бок. Сегодня пакет — множественный выбор с развёрнутыми историями в самой очереди; выделенный экран ревью имеет смысл, когда объём вырастет.
  • Понижение истории, продвинутой по ошибке. assertTransition допускает ready → draft (так работает story_demoted RN-012), но у пользователя нет маршрута для этого. Путь сегодня — отказ ДО продвижения.