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

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 из цены модели) — без какого-либо параллельного механизма стоимости. При прогоне против платного provider DEMO_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 и пустой датой, и это честно: нет записи о том, когда это произошло.