ADR 0022 — Закрытие Фазы 4b (сессия 1): доказательство, которое доходит, стоимость, которая видна, анализ, который переживает kill
- Статус: принят
- Дата: 2026-07-25
- Фаза: 4b (закрытие сессии 1 — сам реальный Psicólogo — это ADR 0015)
Контекст
ADR 0015 поставил реального Psicólogo: потребитель session.closed,
собранный контекст, структурированные гипотезы через ToolLoop, доказательство, проверяемое в
домене, идемпотентность через частичный уникальный индекс, сортировка стоимости по tier.
Ничего из этого здесь не отменено.
Не хватало шага, который ADR 0019/0020/0021 сделали для Фазы 4a:
прогнать критерий приёмки и обнаружить то, что проявляется только при прогоне.
Аудит трёх приложений против формулировки нашёл конкретные отклонения — три
из них буквально ломали критерий приёмки, а критерий ни разу не
выполнялся (у всех предыдущих фаз есть demo:*; у Psicólogo
не было).
Решения
1. Навигируемое доказательство требует поиска события по ID
Чип доказательства переходил в /projects/:id/sessions/:sid?highlightEvent=:eid,
и SessionPage прокручивал до #event-<id>. Две вещи делали так, что этого места
назначения не существовало:
ActivityFeedскрывает машинный шум (agent.response,tool.call,tool.result,agent.status,agent.delta,context.compacted) и ещё применяет фильтры агента/типа. Это РОВНО те события, которые Psicólogo цитирует чаще всего, — там находится поведение агентов. Чип переходил, а экран ничего не показывал.useSessionEventsзапрашивает{ limit: 200, latest: true }. Доказательство вне последних 200 событий было недостижимо — а комментарий самогоactivity.tsфиксирует реальный лог со 193 событиями.
Два исправления, намеренно разной природы:
GET /projects/:projectId/sessions/:sessionId/events/:eventId (новый,
viewer) поверх GetSessionEventUseCase, который переиспользует
SessionEventRepository.findById с ТОЙ ЖЕ проверкой принадлежности, что и
ProposeHypothesesUseCase.resolveKnownEventIds (существует И принадлежит этой сессии;
id другой сессии — 404). SessionPage отрисовывает процитированное событие
закреплённым вверху панели лога. Это не зависит от пагинации и от
фильтра — это структурная гарантия того, что доказательство доходит.
ActivityFeed никогда не скрывает выделенное событие, даже если это машинный
шум, даже при активном фильтре. Невидимое выделение — это навигация, которая
никуда не приводит.
Почему оба, а не одно: закреплённое событие решает общий случай; исключение в фильтре сохраняет выделение согласованным, когда событие ТАКЖЕ находится в окне, — без него пользователь увидел бы событие вверху и не увидел бы подсветку в логе.
2. Стоимость анализа существовала и нигде не показывалась
GetPsychologistAnalysisCostUseCase был зарегистрирован в DI-модуле, и
ни один контроллер его не внедрял. Его docstring утверждал, что это «число,
которое UI показывает в карточке Insight», — не было маршрута, не было UI, не
было теста. «Различные стоимости между лёгкой и тяжёлой сортировкой, видимые в
metering» никто не мог проверить.
GET /projects/:projectId/psychologist/analyses (viewer) поверх
ListPsychologistAnalysesUseCase, который объединяет актуальные анализы с
подсчётом гипотез (один сгруппированный запрос, а не N) и суммированной стоимостью из
token_usage. UI получил полосу анализов вверху раздела Insights:
tier, число событий, гипотезы, стоимость и ссылка на сессию.
Стоимость — на анализируемую СЕССИЮ, а не на строку анализа: token_usage
записывает по сессии + актору, без ссылки на анализ. В повторно проанализированной сессии
число — накопленное по проходам, что правильно для «сколько
Psicólogo потратил на эту сессию»; поэтому поле называется costMicros, а не
analysisCostMicros.
3. Без Lifeline «анализ после перезапуска» был невозможен
config :engine, Oban имел plugins: [Oban.Plugins.Pruner]. С
Oban.Engines.Basic job, получивший SIGKILL во время executing, не
возвращается: узел умер, не отметив исход, строка навсегда остаётся осиротевшей в executing,
и max_attempts: 5 у worker никогда не задействуется. Требование
«kill engine порождает анализ после перезапуска» не могло выполняться.
{Oban.Plugins.Lifeline, rescue_after: :timer.minutes(5)} вошёл в
plugins. Действует для ВСЕХ workers намеренно (Psicólogo, Anamnese, drain
outbox) — сиротство свойственно механизму, а не отдельному worker.
Тест, охраняющий это (Engine.ObanDurabilityTest), утверждает конфигурацию, а не
поведение: каждый тест worker вызывает perform/1 напрямую, поэтому
сценарий сиротства существует только при реальном запуске engine. Тест
конфигурации — то, что предотвращает молчаливую регрессию; реальный сценарий остаётся в
runbook проверки.
4. :ok в ветви сбоя убивал повторную попытку
PsychologistWorker.perform/1 возвращал :ok, когда контекст не приходил,
с комментарием «пусть Oban повторит». В Oban :ok помечает
job completed. Если api была недоступна в момент drain, анализ
молча исчезал.
Теперь возвращается {:error, reason}. Без повествования analysis_failed в этой ветви
намеренно: путь повествования — сама api, которая как раз и
недоступна, — исход тем временем фиксирует строка job.
reason_for/1 также стал смотреть на ctx.last_error, который ToolLoop
заполняет при сбое provider. Без этого таймаут provider описывался
как «encerrou sem emitir hipóteses» — та же ловушка, уже закрытая в QA и
в Dev (ADR 0019), которые являются более новым кодом, чем Psicólogo.
5. Причина завершения берётся из ПРИЧИНЫ, а не из статуса
Monitor.classify/1 велит heartbeat_timeout закрываться как "closed"
намеренно (никого на другой стороне — нормальный способ завершения сессии, а не
сбой engine). Но TerminationClassifier.classify(_, "closed")
возвращал :normal, поэтому:
- причина
:timeoutбыла недостижима в production, хотя формулировка называет таймаут рядом с crash и kill; - сессия, умершая по таймауту, появлялась в prompt как «нормальное завершение» и так и не получала раздел анализа завершения;
- тест классификатора фиксировал неверное поведение
(
classify("heartbeat_timeout", "closed") == :normal).
Классификатор стал читать причину первой. Мы отказались менять
Monitor.classify/1, чтобы heartbeat_timeout закрывался как
closed_abnormally: это решило бы у источника, но затронуло бы машину состояний
Фазы 1/4a и её тесты, вне объёма этой сессии, — а решение о том,
что таймаут — это не аномальное с точки зрения engine закрытие, остаётся
правильным. Иное мнение нужно Psicólogo, и именно он его выражает.
Последствие для контракта: engine начинает передавать cause в payload
emit_hypotheses, а api требует terminationAnalysis, когда
cause !== 'normal' (requiresTerminationAnalysis), вместо того чтобы смотреть на
session.status === 'closed_abnormally'. cause необязательна в DTO — без
неё срабатывает прежнее поведение, поэтому более старый engine не ломается
при rolling deploy.
Также появилась честная причина для {"normal", "closed_abnormally"} (процесс
вышел чисто, но api не ожидала остановки): :unknown, с
меткой «parada inesperada, sem causa identificada». Раньше это попадало бы в
:crash, что лгало бы о наличии исключения.
6. Лог в prompt нуждается в потолке — pinned не сжимается
Весь event log шёл в одном сообщении :pinned, а ContextManager
никогда не сжимает pinned. Это ПРАВИЛЬНО (резюме не сохраняет event ids,
которые должно цитировать доказательство), но означает, что обрезка должна происходить
раньше: при Event.list/1 без LIMIT и inspect(payload) на событие длинная
сессия переполняла окно 128k, и анализ умирал с ошибкой
provider.
Три изменения:
Event.count/1(COUNT в базе) иEvent.list_recent/2. Сортировка стала решать по реальному числу, не загружая лог; только потом, уже зная tier, события читаются с потолком этого tier.- Потолок событий на tier (
max_prompt_events) и размера payload на событие (max_payload_chars). Входит ХВОСТ: там находится состояние сессии в момент завершения, а это ровно то, что должен описывать раздел завершения. То же рассуждение, что и уlatest: trueленты. - Обрезка видима модели: prompt говорит, сколько событий было опущено, и велит цитировать только присутствующие ids. Без этого модель заключила бы, что прочитала всю сессию, и цитировала бы ids, которых не видела, — их api отклонила бы, сжигая итерацию.
7. Жизненный цикл гипотезы становится compare-and-swap
У updateStatus не было WHERE status = 'proposed' — защита от
double-accept была лишь проверкой read-then-write в use case, поэтому два
одновременных клика проходили оба, и второй перезаписывал решение
первого. Стало updateStatusIfProposed, который возвращает null, когда не совпадает,
а use case переводит это в 400.
Доменная проверка (assertHypothesisTransition) остаётся перед CAS
намеренно: именно она даёт хорошее сообщение для обычного случая («уже принята»).
CAS — это взаимное исключение, а не объяснение.
AcceptHypothesisUseCase также стал выполняться в ОДНОЙ транзакции. Их было
четыре (update, два события, enqueue Anamnese): падение посередине оставляло
гипотезу accepted, которая так и не попала в очередь, — ломая ровно тот
замкнутый цикл, ради питания которого существует accept.
8. Гонка параллельного анализа — это конфликт, а не 500
findCurrentBySession не берёт lock, поэтому два одновременных прогона auto видят
«нет актуального анализа» и вставляют оба. Частичный уникальный индекс всегда был
правильной страховочной сетью — не хватало перевода нарушения (23505 на
именованном индексе) в ConflictException вместо того, чтобы позволить ему вылететь как 500.
Мы не поставили SELECT ... FOR UPDATE: проигравший гонку не теряет работу
(победивший анализ уже записан), поэтому блокировка была бы стоимостью без выигрыша.
superseded_at (миграция 0020) отвечает, КОГДА анализ был заменён.
Цепочка supersedes уже отвечала, кем, но «заменяет предыдущую версию
с историей» не поддаётся аудиту без даты.
9. Сортировка стала ручкой оператора
Порог, потолки итераций, бюджет на tier и потолки prompt были
атрибутами модуля. Они переехали в config/runtime.exs со значениями ADR
0015 по умолчанию, в соответствии с другими ручками harness, — контроль стоимости
— это то, что подкручивается по окружению, а не перекомпиляцией.
10. Уборка
ActionPipeline был зарегистрирован как :pre_tool_use в hooks
Psicólogo, но в реестре только один tool, emit_hypotheses, который
:direct (Psicólogo только читает, никогда не предлагает действие с внешним эффектом) —
постоянный no-op, удалён. listCurrentByProject/
listNonDismissedByProject получили ORDER BY created_at DESC (без этого
Postgres не обещает порядок, и группировка Insights меняла порядок
между опросами). reanalyzeSession в api-client существовал без единого
вызывающего: получил кнопку в полосе анализов. А комментарий
internal-sessions.controller, всё ещё говоривший о «PsychologistWorker
(placeholder, fase 3+ traz a análise real)», исправлен.
Последствия
-
Исполняемый критерий приёмки:
apps/api/scripts/demo-psicologo.ts(pnpm --filter api demo:psicologo) завершает 3 сессии — нормальную с коротким логом (лёгкая сортировка), kill (closed_abnormally, причинаkill) и ошибку с длинным логом (тяжёлая сортировка) — и проверяет структурно: один актуальный анализ на сессию, ожидаемый tier, хотя бы одна гипотеза, КАЖДОЕ доказательство разрешается через endpoint по id в собственной сессии,terminationAnalysisв двух аномальных, стоимость лёгкой ниже тяжёлой и повторная обработка, которая заменяет без удаления (с датой). Выходит с кодом 1 и перечисляет сбои, если что-то не сходится. ТЕКСТ гипотез не проверяется — он выходит из LLM. -
Обе модели ollama засеяны с НУЛЕВОЙ ценой, а нулевая стоимость в обоих tier ничего бы не доказала о «различных стоимостях». Скрипт назначает им различные номинальные цены (seed явно говорит, что цена редактируема) и связывает каждый tier с одной. Стоимость по-прежнему берётся с реального пути (
RunLlmTurnUseCaseзаписываетtoken_usage.cost_microsиз цены модели) — без какого-либо параллельного механизма стоимости. При прогоне против платного providerDEMO_MODEL_LEVE/DEMO_MODEL_PESADOизбавляют от этого. -
Kill в скрипте — это report от Monitor, а не SIGKILL процесса: то, что потребляет Psicólogo, — это
sessions.termination_reason+ статус, и именно это произвёл бы реальный kill. Убийство контейнера engine посреди анализа — ДРУГОЙ сценарий (спасение осиротевшего job через Lifeline), и он проверяется вручную: с job вexecuting—docker killдля engine, поднять снова, и анализ завершится послеrescue_after. -
Первые web-тесты Фазы 4b:
HypothesisCard(уверенность, навигация доказательства, указывающая на ПРОАНАЛИЗИРОВАННУЮ сессию, раздел завершения, действия только пока предложена),ActivityFeed(выделенное никогда не скрывается, ни будучи машинным шумом, ни при фильтре агента) и ветвиpsychologist.*вactivity.ts— которые существовали без единого теста. -
В engine вошли тесты тяжёлой сортировки от начала до конца (все предыдущие работали с пустым логом,
event_count == 0), сбоя контекста, возвращающего{:error, _}, успешного исправления ПОСЛЕ отклонения api (цикл «до M попыток» никогда не прогонялся до успеха), таймаута, классифицированного изheartbeat_timeoutсо статусом"closed", и обрезки лога. В api —AcceptHypothesisUseCase/DismissHypothesisUseCase(у которых не было ни одного теста), CAS, блокирующий double-accept,ListPsychologistAnalysesUseCase(лёгкая < тяжёлой),GetSessionEventUseCaseи требование завершения, управляемоеcause.
Выполненная проверка
Критерий приёмки ПРОГНАН на этом стеке (локальный Ollama, без платного provider) и
прошёл во всех трёх случаях: актуальный анализ на сессию, ожидаемый tier, гипотеза в
каждой, ВСЕ доказательства разрешаются через endpoint по id в собственной
сессии, terminationAnalysis в двух аномальных, стоимость лёгкой
(US$ 0,000087) ниже тяжёлой (US$ 0,002086) и повторный анализ,
заменяющий с датированным superseded_at.
Две вещи проявились только при прогоне, и обе стали значением по умолчанию/документацией в скрипте:
llama3.1:8bне вызывает tool. Израсходовал итерации tier и так и не выдалemit_hypotheses; исход вышел как «encerrou sem emitir hipóteses», а НЕ как «falha no provider» — что, попутно, есть работающее различение из решения 4: модель ответила, просто не использовала tool. На этом стеке толькоqwen2.5-coder:7bвыдерживает tool call со структурированным аргументом (по той же причине он запускает dev agents), отсюда копия в Ollama, чтобы дать две ЦЕНЫ одной и той же способной модели.- Потолок в 4 итерации лёгкого tier тесен для локальной 7B. В первом
прогоне сессия kill умерла на «лимите итераций» после двух
попыток с пустым
evidenceEventIdsи двух, цитирующих ПРИДУМАННЫЙ event id (01KYE4B4W25GJ8R6H9Z3FJ8DQX), каждая правильно отклонена с сообщением api, возвращающимся модели. То есть ограждение доказательства поймало галлюцинацию id в production, ровно как задумано. Правильный ответ — поднять потолок по окружению (ручки теперь в compose), никогда не ослаблять проверку.
Kill после перезапуска проверен вручную и работает. Job
Engine.Workers.PsychologistWorker в executing, docker kill для
контейнера engine, job остался осиротевшим в executing без исхода, engine
вернулся, и через ~6 минут (rescue_after 5 мин + интервал Lifeline)
анализ ЗАВЕРШИЛСЯ, с supersedes, указывающим на предыдущий. Без Lifeline
этот job никогда не вышел бы из executing.
Объём и допущения
Вне этого закрытия: SELECT ... FOR UPDATE в findCurrentBySession
(см. решение 8); Oban unique: на уровне job (constraint + предварительная проверка
достаточны, а Lifeline решает случай, который мог бы это мотивировать); пакетный повторный анализ;
дашборд стоимости сверх полосы анализов; любые изменения в
Monitor.classify/1 или в машине состояний сессии.
superseded_at аддитивен и nullable — анализы, заменённые ДО этой
миграции, остаются с superseded = true и пустой датой, и это честно: нет
записи о том, когда это произошло.