ADR 0183 — A sessão encerrada pode ser reaberta: uma saída própria dos terminais, para active, com evento novo
Status
Accepted. 2026-09-30 (AT-071, história HS-048, épico EP-027, rodada 30).
Revê parte da RN-001 — "terminal states have
no exit" — e convive com o ADR 0061 (a sessão
tem kind, imutável, e o estado de execução é o evento execution.activated).
Não edita nenhum dos dois: acrescenta uma transição à máquina de estados e
declara o que ela não muda.
Três decisões deste ADR são PADRÃO PROVISÓRIO, CONSERVADOR, À ESPERA DO DONO — o papel mínimo, a ausência de prazo e a recusa em sessão com execução. Estão marcadas assim abaixo, e mudar qualquer uma delas não exige ADR novo: é trocar a linha indicada e a RN que a cita.
Context
Medido em origin/dev (79e6b1209f), a partir do levantamento da AT-071
(2026-09-13, b61bc49cd):
session-state-machine.tsdeclaravaclosed: []eclosed_abnormally: [], com o comentário "Estados terminais: nenhuma transição sai deles", e o teste fixava isso. Não haviareopen/reabrir/retomarde sessão em api, engine, web, ADRs nem RNs.- O caso real (
exp001, sessãofe57b393…):closedporheartbeat_timeout, 92 eventos, onzeartifact.business_rule, cinco pares de pergunta estruturada. A conversa inteira sobrevive no event log (RN-002) e os artefatos são eventos, mas a sessão não tinha como voltar a aceitar conversa — desde a RN-581 a api recusa, com 409sessao_encerrada, todo evento de conversa numa sessão terminal. - "Abrir uma sessão nova" não substitui: a ativação de um agente exige handoff
acceptedna própria sessão (agent-activation.ts) e o kickoff do PO lê brief e regras da sessão. Copiar para uma sessão nova duplicaria artefato e quebraria a cadeia Criativo → PO. - As duas dependências estão fechadas: a AT-072 (RN-581 — o heartbeat
reconhece conversa em curso, com teto de 8h) e a AT-073 (RN-580 — a
reidratação lê a conversa inteira). Um agente conversacional parado no
fechamento volta pelo
start_agentidempotente na primeira mensagem e reconstrói o histórico do log. session.closednão é linha desession_events: é outbox. O único registro durável de QUANDO e POR QUE a sessão fechou são as colunasclosed_atetermination_reasonda linha desessions.
Decision
-
Uma transição nova, só a partir dos terminais, só para
active.canReopen/assertReopenemsession-state-machine.ts, SEPARADAS decanTransition:ALLOWED_TRANSITIONScontinua comclosed: []eclosed_abnormally: [], então a rota genéricaPOST .../sessions/:id/transitioncontinua respondendo 409 aclosed → active.closing → activesegue proibido por qualquer caminho — a adoção no drain de shutdown depende disso (shutdown.ex). E sessãocreated/activenão "reabre". -
Rota própria,
POST projects/:projectId/sessions/:sessionId/reopen, 200, caso de usoReopenSessionUseCase. Mesmo desenho deTransitionSessionUseCase.activate: o engine é chamado (startSession) ANTES da transação, e falha dele deixa a sessão encerrada como estava; sob lock tudo é revalidado. -
O que muda é estado, e o fechamento anterior vira evento NOVO. A linha de
sessionspassa aactivecomclosed_atetermination_reasonlimpos. No MESMO contadorseq(RN-002), pelo funil de sempre (AppendSessionEventUseCase), entrasession.reopened, atoruser(quem reabriu), payload{from, to, closedAt, terminationReason}— a cópia do que a coluna perde. Nada é editado, apagado nem renumerado; okindnão é tocado (RN-097). -
PADRÃO PROVISÓRIO — papel mínimo
maintainer. Um degrau acima da transição genérica (developer): reabrir religa gasto de token numa sessão que alguém deu por terminada. Baixar paradeveloperé trocar o@RequireRoleda rota e oroleAtLeastda tela. -
PADRÃO PROVISÓRIO — sem prazo. Toda sessão encerrada é reabrível para sempre. Um prazo (ex.: 30 dias) é uma comparação com o
closed_atno caso de uso. -
PADRÃO PROVISÓRIO — sessão com
execution.activatedNÃO reabre. 409sessao_com_execucao.findActiveExecutionSessionescolhe aactivemais recente que carregaexecution.activated(RN-139): reabrir uma sessão de execução antiga a devolveria a essa busca, e a próxima reativação poderia desviar os dev agents para ela. O caminho declarado para "voltar a executar" continua sendo abrir sessão nova e ativar a execução nela (o card de "nova execução"). -
Engine: o fechamento que a reabertura já desfez não para nada.
SessionLifecycleWorkerlê ostatusda sessão (ProjectSession.status/1, leitura direta da tabela da api, como a Anamnese já fazia) e, se ele não é mais terminal, ignora o job com log — um job atrasado ou em retentativa derrubaria o processo e os conversacionais da sessão reaberta. Sem linha, o fechamento segue como sempre. -
Web. Na faixa da sessão encerrada, "Reabrir sessão" com uma frase do que volta; inerte abaixo de
maintainer(roleAtLeastsobre o papel de WORKSPACE, a lacuna da RN-471 já declarada na tela), com o motivo em TEXTO. A recusa da api (execução, 403) vira toast com a frase dela.
Consequences
- A RN-001 deixa de ser verdade na letra ("terminal states have no exit"): há UMA saída, por rota própria. A RN-649 é o endereço da regra nova; a RN-001 não é editada, e fica apontada por ela.
- Segunda análise do Psicólogo: BLOQUEADA até o dono decidir, sem código
novo. Um segundo fechamento publica
session.closedde novo, e oPsychologistWorkercurto-circuita no caminho automático quando já existe análisecurrentda sessão (alreadyAnalyzed). A conversa pós-reabertura fica SEM análise automática; o reprocessamento manual (triggeredBy: "manual") continua disponível e supersede a anterior. Se o dono decidir que a segunda análise é legítima (a conversa cresceu), a mudança é no worker. Hoje o Psicólogo está pausado (PSYCHOLOGIST_ENABLED=false). - Segunda consolidação do grafo é legítima e idempotente: a
Interacaoé chaveada porsessionIde a janela deseqse estende de forma monótona (RecordInteractionUseCase). - Orçamento e medição por sessão passam a ter mais de um intervalo ativo.
Métrica de duração que faça
closed_at - created_atmente para sessão reaberta; o intervalo anterior está no payload desession.reopened. Não medido aqui; nenhum script atual foi mudado. - Heartbeat depois de reabrir: a tela que reabre está aberta e conecta o
heartbeat; sem aba, o heartbeat fecharia de novo (RN-064/RN-581, conversa em
curso segura até 8h). É o comportamento de sempre de uma sessão
active. - Janela de corrida conhecida: se a transação falhar DEPOIS de o engine
subir o
SessionServer(reabertura concorrente, que a revalidação sob lock recusa), fica um processo para uma sessão ainda encerrada — o mesmo preço queTransitionSessionUseCase.activatejá paga. O heartbeat sem cliente o encerra, e o report cai numa sessão terminal. - Handoffs
offeredpresos na sessão encerrada voltam a ser aceitáveis ao reabrir (RN-581 recusava o aceite só pelo estado). O ciclo de vida do ADR 0182 continua valendo: se o destino foi ativado noutra sessão enquanto esta estava fechada, a oferta já estásuperseded. - Não tocado, de propósito: o botão "Encerrar" em
closed_abnormally(AT-155).