# 17. Session:会话管理、任务执行、工具调用、状态持久化
# 17.1 领域模型与生命周期
推荐把状态拆成以下层级,而不是把所有内容塞进一个 messages 数组:
Session / Thread // 表示可跨连接恢复的顶层会话实体,是所有运行状态的顶层容器
├─ immutable: session_id, principal, created_at, lineage // 保存不可变身份、创建时间和父子来源关系,作为审计与幂等基础
├─ mutable config: model, cwd, permission profile, tools, environment // 保存可按 Turn 更新的模型、工作目录、权限和执行环境配置
├─ Turn[N] // 一个会话包含多个用户目标处理周期,每个 Turn 有独立生命周期
│ ├─ user inputs / queued steering // 记录本轮初始输入以及运行中排队等待安全点消费的用户指导
│ ├─ Step[N]: context snapshot + model response // 每个 Step 固化一次模型调用所用上下文快照和对应响应
│ ├─ ToolCall[N]: proposal → approval → execution → result // 工具调用显式经历提议、审批、执行和结果持久化状态
│ └─ terminal outcome: complete / aborted / failed // Turn 最终必须落入完成、主动中止或失败之一的终态
├─ Task graph / plan versions // 关联可调度任务图以及每次审批或重规划产生的计划版本
└─ Event log / rollout / artifacts // 用追加事件保存发生顺序,并通过引用关联大体积执行产物
其中 Thread 更接近可恢复的长期对话实体,Session 可理解为一次活跃连接或运行实例,Turn 是一次用户目标处理,Step 是一次模型调用。不同产品命名可能不同,但必须明确 ID、生命周期和持久化边界。
# 17.2 状态机与一致性
会话状态至少要处理:并发输入、取消、审批等待、断线恢复、进程崩溃和重复事件。
建议采用“内存状态 + 追加事件日志 + 可重建投影”:
- 内存状态服务当前低延迟执行。
- 追加日志保存事实顺序,是恢复和审计的来源。
- SQLite/索引/列表页是投影,可以从事件修复,不应成为唯一真相。
- 大文件、图片和完整命令输出存入执行产物存储区(Artifact Store),事件只保存引用、哈希和摘要。
写入顺序通常是:先给事件分配稳定 ID 和递增事件序号,再持久化关键状态,最后向客户端确认。界面上的临时进度可以允许偶尔漏显,但 ToolResult、审批结果和文件修改必须能够去重和重放。
# 17.3 Claude Code 源码映射
Claude Code 的本地 Transcript 是按项目目录存放的 JSONL,每条主链消息通过 uuid/parentUuid 形成可分叉链。工具进度被明确视为临时 UI 状态,不写入 JSONL、也不参与父子链,否则恢复时会产生孤儿分支:sessionStorage.ts:132、sessionStorage.ts:148、sessionStorage.ts:202。
写入侧不是每条消息都立即写入持久化存储:Project 为每个文件维护有序队列,默认每 100ms 合并一批 JSONL,单块上限为 100MB;flush() 会取消定时器、等待正在处理的写入任务,再清空剩余队列:sessionStorage.ts:549、sessionStorage.ts:606、sessionStorage.ts:645、sessionStorage.ts:841。这是写入吞吐量与崩溃时可能丢失的数据范围之间的明确取舍。
恢复侧从多个叶节点中选择最新的非 sidechain 节点,再沿 parentUuid 回溯主链;随后恢复计划、文件历史和 Skill 状态,清理未完成 ToolUse,并运行 resume hook:conversationRecovery.ts:409、conversationRecovery.ts:459、conversationRecovery.ts:542、conversationRecovery.ts:559。
# 17.4 Codex 源码映射
Codex Session 同时持有 active_turn、等待进入主循环的用户消息、InputQueue,以及 MCP、Agent 和 Environment 等服务;SessionConfiguration 保存模型、指令、审批策略、权限配置、环境选择、Session 来源和 Thread 的父子来源关系:session.rs:45、session.rs:68。
新建、Fork 和 Resume 使用不同的 InitialHistory 分支。初始化时解析 thread_id/session_id/forked_from_id/parent_thread_id;非临时会话创建或恢复 LiveThread 持久化,临时会话跳过:session.rs:548、session.rs:569、session.rs:626、session.rs:685。
执行侧 RegularTask 发出 TurnStarted 后反复调用 run_turn;如果运行期间又有用户输入,就用空的初始输入继续下一次循环,让队列在安全边界被消费:regular.rs:38、regular.rs:49、regular.rs:73。
每条 Conversation Item 先更新内存 state.history,再追加到持久化 Rollout,并向客户端发送 raw item:session/mod.rs:2953。协议事件同样先按持久化策略写 Rollout,再进入 Trace 和事件通道:session/mod.rs:2043。
# 17.5 可恢复会话伪代码
function appendEvent(session, event): // 定义会话事件的持久化、状态更新和推送统一入口
event.id = deterministicOrUUID() // 为可去重事件生成确定性 ID,否则生成全局唯一 ID
event.ordinal = session.nextOrdinal() // 给 ordinal 字段分配递增事件序号,用于排序、补发和断点续传
event.hash = hashCanonical(event.payload) // 对规范化载荷计算哈希,以检测篡改和重复但内容不一致的事件
durableLog.append(event) // 先追加到持久化日志,使其成为恢复与审计的权威记录
inMemoryReducer.apply(event) // 再根据事件更新内存状态,为当前执行提供低延迟查询结果
clientStream.emit(event) // 最后推送给客户端,确保已显示事件在断线后可以从日志补发
function resume(threadId): // 定义从持久化 Rollout 恢复指定 Thread 的运行过程
meta, events = rollout.loadAndValidate(threadId) // 加载元数据和事件流,并验证顺序、校验和及版本兼容性
state = reduce(events, emptyState(meta)) // 从空状态依序重放事件,重建崩溃前的会话状态
state = repairDanglingToolCalls(state) // 找出只有调用没有结果的工具,把它们标记为待确认或中断
state = restoreArtifactsAndSkills(state) // 按引用恢复附件、计划、文件快照和已加载 Skill 状态
if state.lastTurn == RUNNING: // 检查进程退出时是否仍有未正常结束的活跃 Turn
state.lastTurn = INTERRUPTED // 将其显式改为中断,避免把旧运行状态误认为仍在执行
return startRuntime(state, nextOrdinal=max(events.ordinal)+1) // 从最大已有事件序号的下一位启动 Runtime,避免序号冲突
function executeToolIdempotently(call): // 定义按稳定调用 ID 去重的工具执行包装器
if resultStore.contains(call.id): // 恢复或重放时先检查该调用是否已有持久化结果
return resultStore[call.id] // 已执行过则复用原结果,避免重复产生外部副作用
mark(call.id, RUNNING) // 在执行前记录运行状态,便于崩溃恢复识别只有开始记录、没有结果的调用
result = toolRuntime.execute(call) // 通过受控 Runtime 真正执行已授权的工具调用
atomicallyPersist(call.id, result) // 将调用 ID 与结果原子绑定持久化,防止部分写入造成重复执行
return result // 把已持久化的结构化结果返回给 Agent Loop 继续推理
注意:对写文件、支付、发消息等非幂等动作,仅靠重复 call_id 不够;需要下游支持 idempotency key,或在恢复时进入“结果未知,需人工确认”状态,不能盲目重试。
# 17.6 高频面试题
问:断线重连后如何避免重复显示和重复执行?
客户端重连时携带最后确认的事件游标;服务端按递增事件序号补发,前端状态更新器按 event id 去重。工具执行使用稳定的 call id 和结果表去重;对于无法确认结果的外部副作用,不自动重放。
问:为什么 JSONL/Rollout 比只存最终 messages 更适合 Agent?
Agent 的诊断对象包括采样、审批、工具、压缩和状态迁移。只存最终 messages 会丢失中间事实,无法解释失败、恢复半完成工具或做轨迹评测。
# 17.7 原理深化:Session 正确性取决于写入顺序与恢复语义
Session 的关键不是“能保存聊天”,而是明确哪些记录必须先写入持久化存储再对外可见。用户输入、审批结果、ToolCall 的开始意图和最终状态、计划版本及文件执行产物,都属于恢复所需的关键事件:应先获得稳定的递增事件序号并写入原始事件记录(Rollout),再更新可以重新生成的状态视图,最后通知客户端。工具进度可以只保存在内存并允许丢失,因为它不改变副作用是否已经发生。Claude Code 明确不把进度写入 JSONL,而对普通消息使用有序写队列和显式 flush(),体现的正是这两种持久化级别。
Codex Resume/Fork 初始化先恢复历史和有效配置,再持久化继承前缀并 flush;源码注释强调要把 copied prefix 与 child settings 放在同一追加序列中,避免进程重启恢复时只看到一半继承状态:session/mod.rs:1317、session/mod.rs:1389、session/mod.rs:1399。这说明恢复不是反序列化一个对象,而是按事件版本重建并修复中断边界。
恢复时 ToolCall 只能落入三类:已有持久化最终状态则复用;明确未执行则可按策略重试;执行结果未知则进入 interrupted/unknown 并查询下游幂等键或请求人工确认。把所有旧 running 直接标成 failed 会诱发重复副作用。Session 关闭也要先停止新提交、传播取消、等待/终止子任务、flush Rollout,再释放锁与临时资源,否则“进程退出成功”仍可能留下无法恢复的半条事件链。