ADR 0023 — Закрытие Фазы 4b (сессия 2): работающий каталог, дедупликация, различающая, кто отказал, и очередь, переживающая раунд
- Статус: принят
- Дата: 2026-07-26
- Фаза: 4b (закрытие сессии 2 — сама Anamnese — это ADR 0016)
Контекст
ADR 0016 поставил Anamnese: планировщик по проекту, proficiency_profiles
с доказательствами, каталог, выведенный из module_map, instruction_patch как
proposed_action с diff LCS, append-only версионирование с откатом
вперёд, очередь, замыкающая цикл, и два раздела UI. Ничего из этого не отменено.
Не хватало шага, который сессия 1 получила в ADR 0022: прогнать критерий приёмки
и закрыть то, что проявляется только при прогоне. Аудит трёх приложений нашёл ~28
отклонений — семь ломали критерий, один пункт формулировки так и не был
реализован, а критерий ни разу не выполнялся (не было
demo:anamnese и способа запустить раунд по требованию).
Решения
1. Каталог должен токенизировать составной стек — именно это всё блокировало
deriveCatalog помещал stack из module_map целиком как ОДНУ компетенцию.
Но ModuleMapModule.stack — это ОДНА строка свободного текста, написанная
Arquiteto (через LLM), и на практике она перечисляет несколько технологий. При
stack: "NestJS + Drizzle + Postgres" каталог получал компетенцию
"nestjs + drizzle + postgres", а естественная выдача ("nestjs") выпадала —
и поскольку validateProficiencyBatch отклоняет ВЕСЬ ПАКЕТ, раунд не
записывал ни одного профиля.
Последствие было хуже, чем «на один профиль меньше»: только emit_proficiency делает halt
в ToolLoop, поэтому раунд никогда не завершался, никогда не записывал anamnese_runs, а
окно обрабатывалось повторно бесконечно. Anamnese не работала ни в одном
реалистичном проекте. Существующий тест проверял только идеализированный случай единственного
токена (deriveCatalog(['NestJS'])), а это как раз то, что написал бы человек,
а не LLM.
deriveCatalog стал разбивать по +, ,, / и &, добавляя каждый токен
И фразу целиком (тот, кто написал "Node.js" — токен с точкой, — не может
перестать учитываться). Токен из 1 символа отбрасывается: расширять каталог
шумом — ровно то, чего защитное ограждение делать не может, и есть явный тест на то,
что токенизация не ослабляет отклонение чувствительных атрибутов.
Два соседних дефекта на том же пути записи:
- компетенция записывалась СЫРОЙ, а unique — это
(project, user, competency)—"NestJS"и"nestjs "создавали две строки для одного и того же. Теперь записывается нормализованной. - две записи для одного
(userId, competency)в пакете натыкались наON CONFLICT DO UPDATE command cannot affect row a second time(upsert — одна команда), превращаясь в непрозрачный 500 вместо исправимого tool-result. Теперь это отклонение с сообщением, говорящим, что делать.
2. Отказ ПОЛИТИКИ — не отказ пользователя
«Отказ регистрируется, чтобы не предлагать то же самое снова» говорит о решении пользователя. Но
дедупликация фильтровала только status === 'denied', а ProposeActionUseCase записывает тот же
статус без решающего, когда decide отказывает по роли ниже
maintainer или по permissions.json. Результат: patch, заблокированный
политикой, был обречён навсегда — человек так и не видел diff, и даже
исправив роль, нельзя было предложить его снова.
Теперь требуется decidedBy !== null. Это одна строка, и это разница между «пользователь
сказал нет» и «система не позволила даже показать».
3. Потребление очереди — следствие существования patch
emit_proficiency безусловно отправлял consumedQueueIds: ctx.queued_ids
вместе с профилями. Раунд, прочитавший принятую гипотезу и не предложивший patch,
сжигал запись очереди, и ничто не ставило её в очередь заново: критерий «принимаю
гипотезу и вижу, что следующий patch на неё ссылается» молча падал и никогда не
восстанавливался.
Мы перевернули ответственность: engine больше не трогает id очереди, а
ProposeInstructionPatchUseCase помечает запись потреблённой, когда ссылающийся на неё patch
РОЖДАЕТСЯ, в той же транзакции. Прочитанная и неиспользованная гипотеза остаётся
ожидающей до следующего раунда.
Почему потреблять при ПРЕДЛОЖЕНИИ, а не при одобрении: гипотеза выполнила свою роль, когда стала patch, который человек может оценить. Если пользователь отклонит patch, повторение предотвращает дедупликация (решение 2) — повторная постановка в очередь привела бы к вечному предложению одного и того же patch.
Последствие для контракта: consumedQueueIds ушёл из внутреннего DTO, а
markConsumed(ids) стал markConsumedByHypothesis(projectId, hypothesisId).
projectId в фильтре попутно закрывает межтенантную дыру: старая версия
была inArray(id, ids) без области, поэтому вызов мог пометить потреблённой
очередь другого проекта.
4. hypothesisId проверяется на обоих концах
Ни engine (по гипотезам раунда), ни api (по проекту) не
проверяли id. Галлюцинированный id доходил до
agent_instruction_versions.source_hypothesis_id, и трассируемость
гипотеза→patch→версия указывала в никуда.
Engine отклоняет hypothesisId вне ctx.queued_hypothesis_ids, с
сообщением, возвращающимся модели для исправления на следующем ходу (та же идиома, что и у
отклонения доказательства Psicólogo); api перепроверяет, что гипотеза существует и принадлежит
проекту. Обратите внимание, что ctx теперь несёт id ГИПОТЕЗ, а не строк
очереди, — то, что prompt предлагает модели, и то, что должен нести patch, — это
одно и то же.
5. «Команды, которые одобряет/отклоняет» входят в окно — отменяя отсрочку из 0016
Пункт 1 формулировки перечисляет четыре сигнала: язык, исправления агентам,
команды, которые одобряет/отклоняет, и уровень вопросов. Первые три находятся в
event log; четвёртый живёт в proposed_actions.decided_at, и ADR 0016:170-174
отложил его чтение («естественное развитие контекста»). Один из четырёх сигналов вне,
по замыслу.
Новый listDecidedInWindow(projectId, from, to) — только решения с человеком
в качестве решающего, в том же окне, что engine использует для лога, — наполняет поле
decisions[] в контексте, а prompt получил собственный раздел с
rejectionReason. Причина отказа — самый богатый сигнал окна:
она говорит, что человек счёл неправильным, его собственными словами («nunca use push
--force, gere migration» стоит больше десяти сообщений чата).
Triage.should_run? стал считать решения вместе с событиями: окно, в
котором пользователь только одобрял и отклонял действия, ЯВЛЯЕТСЯ материалом, а его отбрасывали как пустое.
6. Доказательство профиля имеет область ПРОЕКТА — UI нужно было разрешать сессию
Чип доказательства всегда переходил в useLatestSession(projectId). Но
окно Anamnese пересекает несколько сессий (ADR 0016, решение 10), поэтому
любое доказательство из старой сессии попадало в «evento não encontrado nesta
sessão». Это ТОТ ЖЕ дефект, который решение 1 ADR 0022 закрыло для Psicólogo,
повторённый здесь, — а у ProficiencyProfile нет sessionId, чтобы исправить на
клиенте.
Новый GET /projects/:projectId/events/:eventId поверх GetProjectEventUseCase:
в отличие от сессионного endpoint сессии 1, здесь сессия — это ответ,
а не проверка. Чип разрешает и затем переходит. Метка закреплённого события в
SessionPage перестала говорить «citado pela hipótese» — теперь оно приходит из двух
источников.
7. Приватность: каждый видит своё; кто администрирует — видит команду
GET /proficiency был viewer и возвращал профиль ВСЕХ участников
(компетенция, уровень, обоснования, доказательства). Хуже: удаление требовало
developer, поэтому viewer профилировался и получал 403 при попытке удалить
собственный профиль — пробивая «весь профиль видим и удаляем пользователем».
Профиль компетенций — данные о человеке, поэтому по умолчанию человек видит свой:
owner/maintainer получают агрегированный вид (полезно для распределения работы),
любая другая роль получает только собственный, через listByUser, который существовал и был
мёртвым кодом. Удаление и opt-in стали требовать только viewer — быть участником.
Мы отбросили «никто не видит ничьего»: это оставило бы экран пустым для тех, кто
администрирует, без реального выигрыша в приватности внутри проекта, к которому человек
решил присоединиться.
Две меньшие дыры того же ограждения: доказательство не было ограничено
проектом (findById без проверки проекта, тогда как сообщение обещало «deste
projeto»), а delete + opt-out были двумя отдельными await — падение между ними
оставляло профиль удалённым и выводимым заново, то есть удаление было бы косметическим
всё равно. Теперь это одна транзакция.
8. Раунд по требованию, потому что 15 минут не тестируемы
Единственным способом провести раунд был тик планировщика. POST /projects/:projectId/anamnese/run (engine + api, maintainer по той же причине,
что и повторный анализ Psicólogo: запускает ToolLoop и тратит бюджет) закрывает
асимметрию и делает критерий приёмки исполнимым. Проект без сессии
отвечает 409 — нет лога для анализа и места для повествования, как и на
периодическом пути.
9. Паритет с тем, что укрепила сессия 1
У Anamnese были, один за другим, те же дефекты, которые ADR 0022 закрыл в Psicólogo:
performвозвращал:okпри сбое контекста, с комментарием, что это позволяет Oban повторить, —:okпомечаетcompleted, поэтому раунд проекта молча исчезал, аmax_attempts: 3был мёртвым грузом.reason_for({:ok, ctx})игнорировалctx.last_error. Это было единственное из четырёх мест вызова (QA, Dev, Psicólogo, Anamnese) вне шаблона: упавший provider превращался в «encerrou sem emitir perfis».- окно шло в сообщении
:pinned(котороеContextManagerне может сжимать, намеренно — event ids доказательства должны выжить) с 500 событиями иinspect(payload)без усечения, против фиксированногоcontext_window. Получило потолок событий через конфигурацию, усечённый payload и ВИДИМУЮ модели заметку об опущенном (она может цитировать только те id, которые видит). Попутноcreated_at— который собирался и отбрасывался — вошёл в строку события: «уровень вопросов» — это анализ тенденции, а тенденции нужно время. - все потолки были атрибутами модуля. Они переехали в
runtime.exsи в compose с текущими значениями по умолчанию. ActionPipelineбыл зарегистрирован в:pre_tool_useкак постоянный no-op (ни один tool Anamnese не являетсяterminal/write_file).
10. Один diff, один вид
--diff-add/--diff-del специфицированы в design/COMPONENTS.md:127 и
не существовали в design/tokens.css. ApprovalCard использовал
var(--diff-add, #0e2e24) и поэтому всегда падал на зашитый hex;
история версий, написанная позже, придумала цвет ТЕКСТА вместо фона.
Один и тот же patch выглядел по-разному в зависимости от того, где вы смотрели. Токены
определены в обеих темах (пара из спецификации откалибрована для тёмной), и оба
места стали использовать одну идиому.
Также: удаление профиля получило подтверждение через уже существовавший ui/Modal (это
необратимо, и голый клик был слишком), откат и opt-in получили состояние
pending (двойной клик создавал две версии), opt-in стал инвалидировать query,
а карточка patch отрисовывает все файлы — не только files[0], тогда как
соседняя ветвь git_commit всегда перебирала их в цикле.
Выполненная проверка
pnpm --filter api demo:anamnese — скрипт явно разделяет детерминированное
(токенизированный каталог, ограждение, очередь, потребляемая только с patch,
patch→одобрение→версия с sourceHypothesisId→откат, возвращающий содержимое
с созданием новой версии, дедупликация по человеческому решению, opt-out, препятствующий
повторному выводу) и зависящее от модели (раунд записывает профиль с разрешимым
доказательством и предлагает patch). Выходит с кодом 1, перечисляя упавшее.
Наборы тестов: engine 250, api 500, web 105 — все зелёные. Недостававшие тесты
включают первые web-тесты Anamnese (сессия 1 получила свои в ADR 0022;
у этой не было ни одного) и два, охраняющие гарантии без покрытия: потолок
«instruction_patch никогда не одобряется автоматически» в decide.ts — того же класса, что и
запрет merge, у которого четыре теста, — и ExecuteInstructionPatchUseCase,
единственный исполнитель без spec, передававшийся как undefined as never в соседних тестах.
Окно усекалось до секунды — и раунд молча пропускался
Это проявилось только при реальном прогоне demo:anamnese и было НАСТОЯЩЕЙ причиной
«профиль не приходит», которую я приписывал нестабильности локальной 7B. token_usage
для актора anamnese был НОЛЬ: модель никогда не вызывалась.
session_events.created_at был объявлен :utc_datetime в схеме Ecto
(точность до СЕКУНДЫ) против колонки timestamptz(6). Ecto усекает
параметр при сравнении, поэтому created_at < window_to превращалось в
< 15:39:32 вместо < 15:39:32.931 и отбрасывало всё, что произошло в
текущую секунду. Измерено в базе: 0 событий в усечённом окне против 9 без
усечения, при первом событии в 15:39:32.363.
Поскольку скрипт делает всё меньше чем за секунду, окно было пустым ВСЕГДА. В production 15-минутный тик почти всегда проскакивает (события уже из прошлых секунд), и поэтому это прошло аудит — который даже зафиксировал это как «незначительное, с оговорками». Это не было незначительным.
Два изменения:
:utc_datetime_usecв поле, в соответствии с колонкой. Регрессионный тест (event_window_test.exs— у окна по проекту не было ни одного) был проверен ПАДАЮЩИМ без исправления: тест, который прошёл бы в любом случае, ничего бы не доказал.- Пропущенный раунд стал оставлять след (
Logger.infoсо счётчиками и порогом). Намеренно НЕ событие в логе: тик каждые 15 мин на проект стал бы шумом. Но именно полное молчание это и скрыло — раунд, который ничего не делает и ничего не говорит, недиагностируем.
С исправлением demo:anamnese прошёл ЦЕЛИКОМ: critério de aceite da Fase 4b (sessão 2) ATENDIDO.
Визуальный проход нашёл три вещи, которые не поймал бы ни один тест
demo:* доказывает данные; он не доказывает, что экран показывает данные. Открыв UI в
подопытном проекте:
- История инструкций была невидима для существующих агентов.
Раздел делал fan-out по
AGENT_LIST(статический реестр, сdev-backend/dev-frontend), но Фаза 4a создаёт dev agent НА МОДУЛЬ (dev-api,dev-web). При 3 версияхdev-apiв базе экран говорил «Nenhum agente teve a instrução alterada ainda» — то есть пункт 5 формулировки (история с diff и откатом) был мёртв как раз для агентов, которых патчит Anamnese. НовыйGET /projects/:projectId/instruction-versionsвозвращает тех, у кого ЕСТЬ история, и UI перестал угадывать slug. - Повествования о patch и откате теряли версию.
payloadFieldвозвращал толькоstring, аtoVersion/restoredFromприходят как число — поэтому лента говорила «instrução de dev-api atualizada» (без версии) и «revertida para a v?». Теперь принимает число. - «Sempre permitir» появлялось в
instruction_patch. Потолокdecide.tsвсегда навязываетrequire_approval, поэтому запись правила вpermissions.jsonничего не меняет: кнопка обещала несуществующий эффект. Скрыта для этого типа.
Кроме того, проверено на экране: уровень/обоснования/чипы профиля; чип
доказательства, разрешающий ПРАВИЛЬНУЮ сессию (переход в сессию, содержащую событие, а не
в самую свежую) с событием, закреплённым вверху лога; бейдж hipótese <id> в
версии и в карточке одобрения; diff с новыми токенами в обоих местах; и —
закрывая то, что ADR 0022 утверждал без проверки, — полоса анализов
Psicólogo со стоимостью, действительно расходящейся (тяжёлый US$ 0,0068 vs лёгкий US$
0,0001), и чип доказательства, указывающий на agent.response — тип, который
лента скрывает, — доходящий до события.
Объём и допущения
Вне этого закрытия: синхронизация объединения ActionType в web с 12 типами
backend (ранее существовавший долг, ADR 0016); пересборка prompt в живом разговорном
GenServer; индекс в session_events для окна по проекту; ручное
редактирование инструкции через UI; индекс по source_hypothesis_id и обратный
запрос (гипотеза → версии), который пока никому не нужен.
apply patch по-прежнему выполняется вне транзакции, обновляющей действие
(ExecuteInstructionPatchUseCase): падение ровно между ними оставляет
инструкцию пропатченной при действии в approved. Это зафиксировано, потому что реально,
но исправление требует переосмыслить транзакционную границу ВСЕХ исполнителей
(у исполнителей infra и git та же форма), а сделать это только здесь создало бы
несогласованность между родственниками.