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 отклоняет с 409sessao_encerradaкаждое событие беседы в терминальной сессии. - «Открыть новую сессию» не заменяет этого: включение агента требует
acceptedhandoff в самой сессии (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.
Решение
-
Один новый переход, только из терминальных состояний, только в
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не «открывается снова». -
Собственный маршрут,
POST projects/:projectId/sessions/:sessionId/reopen, 200, сценарий использованияReopenSessionUseCase. Тот же дизайн, что уTransitionSessionUseCase.activate: engine вызывается (startSession) ДО транзакции, и его сбой оставляет сессию закрытой, как была; под блокировкой всё перепроверяется. -
Меняется состояние, а предыдущее закрытие становится НОВЫМ событием. Строка
sessionsпереходит вactiveс очищеннымиclosed_atиtermination_reason. В ТОМ ЖЕ счётчикеseq(RN-002), через привычную воронку (AppendSessionEventUseCase), входитsession.reopened, акторuser(тот, кто открыл снова), payload{from, to, closedAt, terminationReason}— копия того, что теряет столбец. Ничто не редактируется, не удаляется и не перенумеровывается;kindне трогается (RN-097). -
ПРЕДВАРИТЕЛЬНОЕ ЗНАЧЕНИЕ — минимальная роль
maintainer. На ступень выше общего перехода (developer): повторное открытие снова включает расход токенов в сессии, которую кто-то счёл законченной. Понизить доdeveloper— значит заменить@RequireRoleмаршрута иroleAtLeastэкрана. -
ПРЕДВАРИТЕЛЬНОЕ ЗНАЧЕНИЕ — без срока. Каждую закрытую сессию можно открыть снова всегда. Срок (напр.: 30 дней) — это сравнение с
closed_atв сценарии использования. -
ПРЕДВАРИТЕЛЬНОЕ ЗНАЧЕНИЕ — сессия с
execution.activatedНЕ открывается снова. 409sessao_com_execucao.findActiveExecutionSessionвыбирает самую свежуюactive, несущуюexecution.activated(RN-139): повторное открытие старой сессии выполнения вернуло бы её в этот поиск, и следующее повторное включение могло бы увести dev agents в неё. Объявленный путь «снова выполнять» остаётся прежним — открыть новую сессию и включить выполнение в ней (карточка «новое выполнение»). -
Engine: закрытие, которое повторное открытие уже отменило, ничего не останавливает.
SessionLifecycleWorkerчитаетstatusсессии (ProjectSession.status/1, прямое чтение таблицы api, как Anamnese уже делала) и, если он больше не терминальный, игнорирует job с логом — запоздалый job или job в повторной попытке обрушил бы процесс и разговорных агентов открытой снова сессии. Без строки закрытие идёт как всегда. -
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, лжёт для повторно открытой сессии; предыдущий интервал находится в payloadsession.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).