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

ADR 0183 — Закрытую сессию можно открыть снова: собственный выход из терминальных состояний, в active, с новым событием

Статус​

Accepted. 2026-09-30 (AT-071, история HS-048, эпик EP-027, раунд 30). Пересматривает часть RN-001 — «terminal states have no exit» — и сосуществует с ADR 0061 (у сессии есть kind, неизменяемый, а состояние выполнения — это событие execution.activated). Не редактирует ни один из них: добавляет переход в машину состояний и объявляет, чего он не меняет.

Три решения этого ADR — ПРЕДВАРИТЕЛЬНОЕ, КОНСЕРВАТИВНОЕ ЗНАЧЕНИЕ ПО УМОЛЧАНИЮ, ОЖИДАЮЩЕЕ ВЛАДЕЛЬЦА: минимальная роль, отсутствие срока и отказ для сессии с выполнением. Ниже они помечены так, и изменение любого из них не требует нового ADR: это замена указанной строки и RN, которая её цитирует.

Контекст​

Измерено в origin/dev (79e6b1209f), на основе исследования AT-071 (2026-09-13, b61bc49cd):

  • session-state-machine.ts объявлял closed: [] и closed_abnormally: [], с комментарием «Estados terminais: nenhuma transição sai deles», и тест это закреплял. Не было reopen/reabrir/retomar сессии ни в api, ни в engine, ни в web, ни в ADR, ни в RN.
  • Реальный случай (exp001, сессия fe57b393…): closed по heartbeat_timeout, 92 события, одиннадцать artifact.business_rule, пять пар структурированных вопросов. Вся беседа сохраняется в event log (RN-002), и артефакты — это события, но у сессии не было способа снова принимать беседу — со времён RN-581 api отклоняет с 409 sessao_encerrada каждое событие беседы в терминальной сессии.
  • «Открыть новую сессию» не заменяет этого: включение агента требует accepted handoff в самой сессии (agent-activation.ts), а kickoff PO читает brief и правила сессии. Копирование в новую сессию дублировало бы артефакт и сломало бы цепочку Criativo → PO.
  • Обе зависимости закрыты: AT-072 (RN-581 — heartbeat распознаёт идущую беседу, с потолком 8 ч) и AT-073 (RN-580 — регидратация читает всю беседу). Разговорный агент, остановленный при закрытии, возвращается через идемпотентный start_agent при первом сообщении и восстанавливает историю из log.
  • session.closed — не строка session_events: это outbox. Единственная устойчивая запись о том, КОГДА и ПОЧЕМУ сессия закрылась, — столбцы closed_at и termination_reason строки sessions.

Решение​

  1. Один новый переход, только из терминальных состояний, только в active. canReopen/assertReopen в session-state-machine.ts, ОТДЕЛЬНЫЕ от canTransition: ALLOWED_TRANSITIONS по-прежнему содержит closed: [] и closed_abnormally: [], поэтому общий маршрут POST .../sessions/:id/transition по-прежнему отвечает 409 на closed → active. closing → active по-прежнему запрещён любым путём — от этого зависит усыновление в drain при остановке (shutdown.ex). А сессия created/active не «открывается снова».

  2. Собственный маршрут, POST projects/:projectId/sessions/:sessionId/reopen, 200, сценарий использования ReopenSessionUseCase. Тот же дизайн, что у TransitionSessionUseCase.activate: engine вызывается (startSession) ДО транзакции, и его сбой оставляет сессию закрытой, как была; под блокировкой всё перепроверяется.

  3. Меняется состояние, а предыдущее закрытие становится НОВЫМ событием. Строка sessions переходит в active с очищенными closed_at и termination_reason. В ТОМ ЖЕ счётчике seq (RN-002), через привычную воронку (AppendSessionEventUseCase), входит session.reopened, актор user (тот, кто открыл снова), payload {from, to, closedAt, terminationReason} — копия того, что теряет столбец. Ничто не редактируется, не удаляется и не перенумеровывается; kind не трогается (RN-097).

  4. ПРЕДВАРИТЕЛЬНОЕ ЗНАЧЕНИЕ — минимальная роль maintainer. На ступень выше общего перехода (developer): повторное открытие снова включает расход токенов в сессии, которую кто-то счёл законченной. Понизить до developer — значит заменить @RequireRole маршрута и roleAtLeast экрана.

  5. ПРЕДВАРИТЕЛЬНОЕ ЗНАЧЕНИЕ — без срока. Каждую закрытую сессию можно открыть снова всегда. Срок (напр.: 30 дней) — это сравнение с closed_at в сценарии использования.

  6. ПРЕДВАРИТЕЛЬНОЕ ЗНАЧЕНИЕ — сессия с execution.activated НЕ открывается снова. 409 sessao_com_execucao. findActiveExecutionSession выбирает самую свежую active, несущую execution.activated (RN-139): повторное открытие старой сессии выполнения вернуло бы её в этот поиск, и следующее повторное включение могло бы увести dev agents в неё. Объявленный путь «снова выполнять» остаётся прежним — открыть новую сессию и включить выполнение в ней (карточка «новое выполнение»).

  7. Engine: закрытие, которое повторное открытие уже отменило, ничего не останавливает. SessionLifecycleWorker читает status сессии (ProjectSession.status/1, прямое чтение таблицы api, как Anamnese уже делала) и, если он больше не терминальный, игнорирует job с логом — запоздалый job или job в повторной попытке обрушил бы процесс и разговорных агентов открытой снова сессии. Без строки закрытие идёт как всегда.

  8. Web. На полосе закрытой сессии — «Reabrir sessão» с фразой о том, что возвращается; инертна ниже maintainer (roleAtLeast по роли WORKSPACE — пробел RN-471, уже объявленный на экране), с причиной ТЕКСТОМ. Отказ api (выполнение, 403) становится toast с её фразой.

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

  • RN-001 перестаёт быть верной буквально («terminal states have no exit»): есть ОДИН выход, через собственный маршрут. RN-649 — адрес нового правила; RN-001 не редактируется, и она на него указывает.
  • Второй анализ Psicólogo: ЗАБЛОКИРОВАН, пока владелец не решит, без нового кода. Второе закрытие снова публикует session.closed, и PsychologistWorker делает короткое замыкание на автоматическом пути, когда уже существует current анализ сессии (alreadyAnalyzed). Беседа после повторного открытия остаётся БЕЗ автоматического анализа; ручная повторная обработка (triggeredBy: "manual") остаётся доступной и заменяет предыдущий. Если владелец решит, что второй анализ законен (беседа выросла), изменение — в worker. Сегодня Psicólogo приостановлен (PSYCHOLOGIST_ENABLED=false).
  • Вторая консолидация графа законна и идемпотентна: Interacao ключуется по sessionId, а окно seq расширяется монотонно (RecordInteractionUseCase).
  • Бюджет и измерение по сессии начинают иметь более одного активного интервала. Метрика длительности, делающая closed_at - created_at, лжёт для повторно открытой сессии; предыдущий интервал находится в payload session.reopened. Здесь не измерено; ни один текущий скрипт не изменён.
  • Heartbeat после повторного открытия: экран, который открывает снова, открыт и подключает heartbeat; без вкладки heartbeat снова закрыл бы (RN-064/RN-581, идущая беседа удерживает до 8 ч). Это привычное поведение active сессии.
  • Известное окно гонки: если транзакция провалится ПОСЛЕ того, как engine поднимет SessionServer (конкурентное повторное открытие, которое перепроверка под блокировкой отклоняет), остаётся процесс для ещё закрытой сессии — та же цена, которую TransitionSessionUseCase.activate уже платит. Heartbeat без клиента его завершает, а report попадает в терминальную сессию.
  • Handoffs offered, застрявшие в закрытой сессии, снова становятся принимаемыми при повторном открытии (RN-581 отклоняла принятие только по состоянию). Жизненный цикл ADR 0182 продолжает действовать: если цель была включена в другой сессии, пока эта была закрыта, предложение уже superseded.
  • Намеренно не затронуто: кнопка «Encerrar» в closed_abnormally (AT-155).