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。
Context
在 origin/dev(79e6b1209f)上测量,基于 AT-071 的调研
(2026-09-13,b61bc49cd):
session-state-machine.ts声明了closed: []和closed_abnormally: [],并带有注释 "Estados terminais: nenhuma transição sai deles",测试固定了这一点。在 api、engine、web、ADR 和 RN 中都没有会话的reopen/reabrir/retomar。- 真实案例(
exp001,会话fe57b393…):因heartbeat_timeout而closed,92 个事件,十一个artifact.business_rule,五对结构化问题。整个对话都保存在 event log 中(RN-002),工件也是事件,但会话没有办法重新接受对话——自 RN-581 起,api 会以 409sessao_encerrada拒绝终止会话中的所有对话事件。 - “开一个新会话”并不能替代:启用一个 agent 需要本会话中有
accepted的 handoff(agent-activation.ts),PO 的 kickoff 读取本会话的 brief 和规则。复制到新会话会重复工件并打断 Criativo → PO 的链条。 - 两个依赖都已关闭:AT-072(RN-581——heartbeat 识别进行中的对话,上限 8h)和 AT-073(RN-580——重建读取整个对话)。在关闭时停止的对话型 agent 会在第一条消息时通过幂等的
start_agent回来,并从日志重建历史。 session.closed不是session_events的一行:它是 outbox。会话何时以及为何关闭的唯一持久记录,是sessions行中的closed_at和termination_reason列。
Decision
-
一个新转换,只从终止状态出发,只通向
active。session-state-machine.ts中的canReopen/assertReopen,与canTransition分开:ALLOWED_TRANSITIONS继续保持closed: []和closed_abnormally: [],因此通用路由POST .../sessions/:id/transition对closed → active继续返回 409。closing → active无论经由任何路径仍被禁止——shutdown 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)高一级:重新打开会在一个有人认定已结束的会话中重新开启 token 花费。降为developer只需替换路由的@RequireRole和界面的roleAtLeast。 -
临时默认值——没有期限。 每个已结束的会话永远都可以重新打开。期限(例如 30 天)就是在用例中与
closed_at做一次比较。 -
临时默认值——有
execution.activated的会话不能重新打开。 409sessao_com_execucao。findActiveExecutionSession选择带有execution.activated的最新active会话(RN-139):重新打开一个旧的执行会话会把它送回这个查找,下一次重新启用可能会把 dev agent 引向它。“重新执行”的声明路径仍然是开一个新会话并在其中启用执行(“nova execução”卡片)。 -
Engine:已被重新打开撤销的关闭不会停止任何东西。
SessionLifecycleWorker读取会话的status(ProjectSession.status/1,直接读取 api 的表,就像 Anamnese 早已做的那样),如果它不再是终止状态,就记录日志并忽略该任务——一个延迟或重试中的任务会推倒重新打开的会话的进程和对话型 agent。没有该行时,关闭照常进行。 -
Web。 在已结束会话的横幅中,“Reabrir sessão”附带一句说明会恢复什么;低于
maintainer时不可用(roleAtLeast基于 workspace 的角色,即界面上已声明的 RN-471 缺口),原因以文字说明。api 的拒绝(执行、403)变成带有其原话的 toast。
Consequences
- RN-001 在字面上不再成立("terminal states have no exit"):通过专属路由,有一个出口。RN-649 是新规则的地址;RN-001 不被编辑,由新规则指向它。
- Psicólogo 的第二次分析:在负责人决定之前被阻塞**,无需新代码。第二次关闭会再次发布
session.closed,而当已存在该会话的current分析时,PsychologistWorker会在自动路径上短路(alreadyAnalyzed)。重新打开后的对话没有**自动分析;手动重新处理(triggeredBy: "manual")仍然可用并取代之前的分析。如果负责人决定第二次分析是正当的(对话增长了),改动在 worker 中。今天 Psicólogo 处于暂停状态(PSYCHOLOGIST_ENABLED=false)。 - 图谱的第二次汇总是正当且幂等的:
Interacao以sessionId为键,seq窗口单调扩展 (RecordInteractionUseCase)。 - 按会话的预算和测量开始有不止一个活跃区间。 用
closed_at - created_at计算的时长指标对重新打开的会话是在撒谎;之前的区间在session.reopened的 payload 中。此处未测量;现有脚本均未修改。 - 重新打开后的 heartbeat:执行重新打开的界面是打开的,并连接 heartbeat;没有标签页时,heartbeat 会再次关闭它(RN-064/RN-581,进行中的对话最多保持 8h)。这是
active会话一贯的行为。 - 已知的竞争窗口:如果事务在 engine 启动
SessionServer之后失败(并发重新打开,被锁下的重新验证拒绝),就会留下一个属于仍然结束的会话的进程——这与TransitionSessionUseCase.activate已经付出的代价相同。没有客户端的 heartbeat 会结束它,报告落到一个终止会话中。 - 卡在已结束会话中的
offeredhandoff 在重新打开时重新可以被接受(RN-581 只按状态拒绝接受)。ADR 0182 的生命周期继续有效:如果目标在本会话关闭期间于另一会话中被启用,该提议已经是superseded。 - 刻意未触碰:
closed_abnormally中的“Encerrar”按钮(AT-155)。