Agent Durable Object:从断线可见性到生命周期安全
结论先行:这条线真正收敛的不是一个“重连 UI bug”,而是 Agent 对话的状态权威和生命周期边界。服务端 Durable Object/Think 持久化完整 transcript,客户端把 replay、恢复态和断线期间的本地发送分别建模;连接关闭不再被当作“对象为空、可以删除”的证据,admin 读取…
source_automation: “voyager-merged-pr” run_date: “2026-07-25” anchor_pr_number: 5199 pr_number: 5199 pr_title: “fix(agent): stop auto-destroying an agent DO when the last connection closes” pr_url: “https://github.com/adastralab-ai/voyager/pull/5199 ↗” author: “Horcrux / magicismight” merged_at: “2026-07-25T03:59:51Z” merge_commit: “e45c67d59e188fe5b2d032d239424ca6e9c86ad8” main_sha_at_read: “28f9382c798d0f3804735d93c88ae1b3eec4ced4” modules:
- “backend/workers/agent/src/agent.ts”
- “patches/agents@0.17.3.patch”
- “patches/agents@0.18.0.patch”
- “packages/site/src/app/(main)/_agent/chat/_components/AgentChatContext.tsx”
- “packages/site/src/app/(main)/_agent/chat/_components/AgentMessageList.tsx”
- “packages/site/src/app/(main)/_agent/editor-chat/_components/EditorChatSidebar.tsx”
- “backend/workers/agent/src/routes/chatAdminTranscript.ts”
- “backend/workers/agent/src/routes/index.test.ts” files_changed: 1 learning_tags:
- “agent-runtime”
- “durable-object-lifecycle”
- “chat-recovery”
- “websocket-reconnect”
- “resume”
- “replay-buffer”
- “optimistic-ui”
- “transcript-persistence”
- “cold-do”
- “admin-route”
- “dependency-patch”
- “test-strategy”
- “state-authority” business_line: “Agent Durable Object 生命周期、重连与可恢复对话” related_prs: [4895, 5042, 5190, 5201] line_stage: “replay burst 客户端收敛 (#4895) -> 首轮恢复态与乐观消息 (#5190) -> 停止连接关闭时误删 DO (#5199) -> agents@0.18.0 重连快照合并 (#5042) -> 冷 DO admin transcript 初始化 (#5201)” open_questions:
- “#5199 没有新增回归测试;是否应覆盖最后一个连接关闭后持久化 transcript、进行中 turn 和下一次 reconnect 都不被 destroy 的 Durable Object 测试。”
- “移除自动清理后,空闲 DO 的存储保留和显式 destroyChat 删除协调由谁负责;是否需要 alarm/retention 级别的孤儿数据治理。”
- “#5042 只保留断线期间 trailing local-only user message,快照仍覆盖其他内容;需要用 regenerate、多个排队消息、重复 id 和跨 tab 场景验证这个窄合并策略。”
- “replayBuffer 没有显式内存上限或背压策略;长 turn、providerMetadata 高频 chunk 与 error flush 需要真实 WebSocket 压测。”
- “#5201 删除了独立的 chatAdminTranscript route 测试;当前入口测试只 mock getAgentByName,没有验证冷 DO 初始化后 session.getHistory 的真实调用链。”
- “connectionCookie 只存内存,DO 休眠后 token 过期会要求客户端重新连接;恢复窗口、重新鉴权和 admin 只读路径之间的边界仍需端到端验证。” feishu_doc_url: null github_commit: “e45c67d59e188fe5b2d032d239424ca6e9c86ad8”
结论先行:这条线真正收敛的不是一个“重连 UI bug”,而是 Agent 对话的状态权威和生命周期边界。服务端 Durable Object/Think 持久化完整 transcript,客户端把 replay、恢复态和断线期间的本地发送分别建模;连接关闭不再被当作“对象为空、可以删除”的证据,admin 读取也必须通过能初始化冷 DO 的框架入口。#5199 是这条线的架构锚点,#4895、#5042、#5190 和 #5201 分别补齐客户端流量、快照合并、用户可见状态和冷启动读取。
业务线概览#
用户对 Agent 的连续预期是:发送的消息不会在断线时消失,正在生成的回答不会因为 worker 重启或 DO 休眠而假装完成,恢复后的 transcript 不会被重复或错序,管理员还能读取冷会话的完整记录。这里同时存在三类状态:
- Durable Object 中的持久化 transcript 和 Think 的 turn/recovery 状态;
- WebSocket 客户端已经收到的 assistant 内容、replay 队列和尚未被服务端快照承认的 user 消息;
- 由 admin 路由或删除协调器触发的非聊天连接生命周期操作。
本次学习范围集中在上述边界,不延伸到模型计费、图片资产 URL 或 soft-interrupt 的具体产品交互。它与前几日的 #4895 直接相连,但新增重点是“谁有权决定状态存在”和“冷 DO 如何安全进入业务方法”。
今日锚点#
- PR: #5199 fix(agent): stop auto-destroying an agent DO when the last connection closes ↗
- 作者: Horcrux / magicismight
- Merge: 2026-07-25T03:59:51Z,即 Australia/Melbourne 2026-07-25 13:59:51 AEST
- Merge commit: e45c67d59e188fe5b2d032d239424ca6e9c86ad8
- 改动规模: 1 个文件,0 additions / 29 deletions;改动只落在 backend/workers/agent/src/agent.ts。
- 主线状态: 锚点及四个相关 PR 的 merge commit 都已验证可达本次读取的 origin/main:28f9382c798d0f3804735d93c88ae1b3eec4ced4。
选择 #5199 是因为它删除的是跨层错误假设,而不是局部文案或重连参数:onClose 看到最后一条连接关闭且 this.messages 为空,就调用 destroy;但 this.messages 是内存窗口,不等价于完整持久化 transcript。与此同时,今天窗口内的 #5042 和 #5201 可以分别证明重连快照与冷 DO 入口正在同一条链上继续修补,#5190 则把恢复信号传到用户界面,业务线并不孤立。
演进时间线#
| 阶段 | PR | 改变的层 | 代码证据与新增能力 |
|---|---|---|---|
| replay burst 收敛 | #4895 ↗,2026-07-17 01:07:26 AEST | agents SDK patch / WebSocket transport | 在 agents@0.17.3 的 dist/chat/react.js 增加 ReplayChunkBuffer:相邻同 part 的 text、reasoning、tool-input delta 合并,在 replayComplete、done、首个 live chunk 或 error 边界 flush。已有内容的 error replay 先 flush,再发 error UI chunk,避免把已恢复内容丢给客户端。 |
| 首轮恢复可见化 | #5190 ↗,2026-07-25 02:38:15 AEST | Think client hook / chat context / message list | 从 useAgentChat 读取 isRecovering,把 isStreaming 或 isRecovering 都视为 turn in flight;把 Think 只在 turn 结束回显的首条 user message 前置到 assistant 流之前。新增测试覆盖“恢复时 status 仍 ready 但 UI 必须显示 streaming”和“乐观 user 在 assistant 之前”。 |
| 当前锚点:生命周期安全 | #5199 ↗,2026-07-25 13:59:51 AEST | Agent Durable Object lifecycle / deletion boundary | 删除 Agent.onClose 中基于最后连接和内存消息窗口的 destroy;保留 destroyChat 作为 Go 删除协调成功后的显式删除入口。代价是接受空闲 DO 继续存在,把清理责任从连接事件移回明确的删除流程。 |
| 新 SDK 版本的重连合并 | #5042 ↗,2026-07-25 14:15:43 AEST | agents@0.18.0 patch / lockfile | 把 #4895 的 replayBuffer 迁到 agents@0.18.0,同时把 reconnect snapshot 的 setMessages(next) 改为基于前一状态的窄合并:只追加 snapshot 中没有的 trailing local-only user messages,避免用户在 socket 断开期间发送的消息被快照覆盖。 |
| 冷 DO admin 读取 | #5201 ↗,2026-07-25 18:26:04 AEST | admin route / framework initialization | chatAdminTranscriptRoute 先保留 Go admin cookie 鉴权,再从 env.Agent.get(idFromName(chatId)) 切换到 await getAgentByName(env.Agent, chatId),让冷 DO 在调用 getAdminTranscript 前走框架初始化路径;getAdminTranscript 读取完整 session history,而不是 bounded this.messages。 |
候选说明: 7 月 25 日 Melbourne 窗口已有合适的 Agent/DO 候选,因此没有扩大到更早日期作为锚点。#5143 已在前一轮作为锚点学习,未重复选择;#5188 虽在日期窗口内合入,但 base 是非 main 的 zh-platform-task-tier,merge commit 不在 origin/main,不计入本次主线。#5199 比同日的模型切换、账单和脚本类 PR 更能串起前置 replay、客户端恢复和后续冷 DO 修复,所以选为锚点;#5042、#5201 作为同日相关后续,#4895 作为已学习过但必要的前置背景。
当前架构与数据流#
- 用户入口和客户端状态。 Home 或编辑器内的 AgentChatProvider 以 chatId 建立 Agent identity,EditorChatSidebar 会把首轮 handoff 作为 optimisticFirstMessage 交给同一套 provider。AgentChatContext 调用 useAgentChat 时开启 resume,并把 SDK 的 isRecovering、isStreaming、status 转成 UI 可消费的 visibleStatus。
- WebSocket 到 Durable Object。 浏览器连接 Agent DO;agent.ts 的 onConnect 解析身份、把 connection cookie 只保存在内存并确保 delegation token 可用,然后进入 Think 的连接/turn 生命周期。beforeTurn 既能在有 live connection 时刷新 token,也能在 recovery/continuation 路径使用存储中的 token。
- 持久化 transcript 与运行时窗口。 Agent 的业务方法通过 session.getHistory() 读取完整历史;this.messages 只是运行时 bounded window。这个区别直接决定了 #5199 为什么不能用 this.messages.length 判断“没有持久化状态”,也决定了 admin transcript 必须在 session 初始化后调用。
- 恢复流和快照合并。 服务端重放 replay=true 的 chunk,agents patch 在 transport 层按同一 part 压实,避免每个 chunk 都触发一次 UI state 更新;收到 replayComplete、done、live chunk 或 error 时才 flush。重连 snapshot 仍是主要权威来源,但 #5042 允许保留前一状态末尾尚未进入 snapshot 的 user 消息,解决 socket 断开窗口内的 optimistic send 丢失。
- 用户可见恢复态。 #5190 不把 SDK 在没有 token 时返回的 ready 直接当成终态,而是用 isRecovering 把 turn 标记为 in flight。AgentMessageList 显示 Resuming response…,同时继续把首轮 optimistic user 放在 partial assistant 之前。
- Admin 只读与显式删除。 admin transcript route 先向 Go 的 transcript-access 端点验证 admin cookie,再通过 getAgentByName 取得已初始化的 DO 并读取完整历史。删除由 Go 元数据删除成功后的 destroyChat 触发;连接关闭本身不再触发删除。
关键代码#
1. 不再把内存窗口当成删除条件#
证据:PR #5199,backend/workers/agent/src/agent.ts,旧版本 diff 约 878-905 行。
const isStillOpen = [...this.getConnections()].some(
(c) => c.id !== connection.id,
);
if (!isStillOpen && !this.messages.length) {
await this.destroy();
}plaintext这段代码的危险点不在 onClose 本身,而在判定依据:this.messages 是 bounded in-memory cache,不能证明 session storage 中没有完整历史。#5199 删除整个 override,把“连接断开”与“数据删除”分开。当前显式删除仍在 backend/workers/agent/src/agent.ts:1745-1759:
// Called after the Go metadata DELETE has succeeded.
async destroyChat(): Promise<void> {
for (const conn of this.getConnections()) {
conn.close(1000, "chat deleted");
}
await this.destroy();
}plaintext这里的安全性来自调用顺序和职责边界:Go 先完成归属校验与元数据删除,DO 再清理自己的消息、认证信息和连接。
2. 把恢复态纳入 turn 状态机#
证据:PR #5190,packages/site/src/app/(main)/_agent/chat/_components/AgentChatContext.tsx:1026-1037;packages/site/src/app/(main)/_agent/chat/_components/AgentMessageList.tsx:157-167。
const isTurnInFlight = isStreaming || isRecovering;
const isTurnActive = isTurnInFlight || status === "submitted";
const visibleStatus =
status === "error"
? "error"
: isTurnInFlight
? "streaming"
: status;plaintext这不是简单把文案改成“恢复中”:visibleStatus 还参与 send-gating、queued message flush 和 streamingChatId,因而恢复期不会被当作可插入下一轮的空闲窗口。AgentMessageList 再用 isRecovering 选择 Resuming response…,把服务端的恢复事实传到用户可见层。
3. 重连快照的窄合并#
证据:PR #5042,patches/agents@0.18.0.patch:176-198(嵌套 dist/chat/react.js diff)。
setMessages((prev) => {
const nextIds = new Set(next.map((m) => m.id));
let tailStart = prev.length;
while (tailStart > 0) {
const message = prev[tailStart - 1];
if (message.role !== "user" || nextIds.has(message.id)) break;
tailStart--;
}
return tailStart < prev.length ? [...next, ...prev.slice(tailStart)] : next;
});plaintext这里没有把 prev 和 next 做无条件 union:snapshot 对已知消息仍是 authoritative,只保存末尾尚未被 snapshot 承认的 user 消息。这个设计避免恢复时“用户消息不丢”和 regenerate 后“旧 assistant 被复活”同时发生。
同一个 patch 文件前部的 ReplayChunkBuffer(约 9-40 行)还把相邻同 part delta 合并、在 replay 边界 flush;#5042 因此是 #4895 在 agents@0.18.0 上的版本迁移和语义扩展,而不是一条孤立的 lockfile 更新。
4. 冷 DO 读取必须经过初始化入口#
证据:PR #5201,backend/workers/agent/src/routes/chatAdminTranscript.ts:1、37-62。
const authFailure = await authorizeTranscriptAccess({
env,
chatId,
cookieHeader,
});
if (authFailure) return authFailure;
const agent = await getAgentByName(env.Agent, chatId);
const transcript = await agent.getAdminTranscript();
return Response.json(transcript);plaintext权限和生命周期是两层:Go 的 admin cookie 仍负责授权;getAgentByName 负责让冷 DO 的框架状态先建立,再进入 getAdminTranscript。最终 merge commit 的代码使用的是这个 helper,虽然 PR 标题和早期描述使用了 fetch path 的说法,学习结论以实际 diff 为准。
5. 测试证据暴露了覆盖边界#
证据:PR #5190 的 AgentChatContext.test.tsx:737-817 增加两类行为断言;PR #5201 的最终 diff 删除 chatAdminTranscript.test.ts,backend/workers/agent/src/routes/index.test.ts:5-7 只增加 agents 模块 mock。
it("keeps the turn in-flight while the server is recovering it", async () => {
// SDK reports "ready" during recovery; UI must read in-flight.
expect(screen.getByTestId("status").textContent).toBe("streaming");
expect(screen.getByTestId("recovering").textContent).toBe("true");
});plaintext因此客户端恢复态有明确的组件级证据;冷 DO admin 链路在 PR 描述中有 wrangler dev 的手工验证,但合入后的仓库 diff 没有保留一条从 route 到 getAgentByName 再到 session.getHistory 的自动化测试。这是当前最明显的验证缺口。
工程取舍#
- 生命周期安全优先于连接级即时清理。 #5199 接受空闲 DO 继续存在,因为连接关闭既不能证明数据为空,也不是删除语义。真正的清理走显式 destroyChat,减少误删持久化 transcript 的风险;但这把存储保留和孤儿清理责任推给删除协调器或未来的 retention/alarm 机制。
- 快照权威 + 极窄本地补偿。 #5042 不信任本地所有 state,也不粗暴丢弃本地 state。它只保留 trailing local-only user messages,避免用户在重连窗口发送的内容丢失,同时避免恢复旧 assistant 或 regenerate 结果。
- 恢复态不是连接态。 isRecovering 代表服务端 turn 仍在推进,即使当前没有 token;connectionPhase 仍单独表示 connecting、reconnecting 或 disconnected。把两者合成一个 boolean 会让重连 UX 和发送门控互相污染。
- 依赖 patch 绑定精确版本。 #4895 针对 agents@0.17.3,#5042 迁到 agents@0.18.0 并更新 pnpm lock patch hash;这种方式能快速修复上游缺陷,但要求每次版本升级复核 patch hunk 和 replay/error 语义。#5042 的最终文件列表只有 patch 和 lockfile,PR body 所述的专门 patch-marker 测试没有出现在合入 diff 中,需按代码事实看待验证强度。
- 框架初始化是业务 API 的前置条件。 #5201 不绕过 Think/PartyServer 的初始化而直接拿 DO stub;它保持 admin 授权不变,把冷启动修复放在 helper 选择上。代价是测试更难在普通 Node 环境中直接加载 cloudflare scheme 依赖,更需要一条真实 worker/冷 DO 回归。
和最近学习记录的关系#
- #4895 已是 7 月 17 日的学习锚点,本报告不重复它的“如何批处理 replay burst”结论,而把它放到更大的生命周期链中:客户端能正确消费 replay,前提仍是 server DO 不被误删、snapshot 不覆盖用户本地尾部。
- #4844 的 soft-interrupt 已把流式 turn 和后续 user intent 建模成有序队列;本次 #5190/#5042 说明同样的“不要在中间态宣告终态”原则还需要覆盖恢复和重连 snapshot。
- 7 月 24 日的 #5143 关注 Agent 画布写入后的 inverse delta 和本地 history;本次关注聊天 transcript 的 durable source 和连接生命周期。两条线共享一个有价值的边界:服务端提交/持久化是事实来源,客户端只做受约束的呈现、恢复和 history 补偿。
- 7 月 23 日的 #5107 关注生产 turn 与 eval 的状态/输入一致性;本次的对应问题是生产 turn 与客户端恢复状态的一致性。两者都说明“看起来完成”不能只由某个局部 signal 推断。
我会怎么吸收#
- 遇到清理逻辑时,先列出内存缓存、持久化状态、业务元数据三种 source of truth,再决定是否允许破坏性操作;连接事件通常只能表达连接状态。
- 把恢复、重连、排队、终态分别建模,不用一个 streaming/ready boolean 覆盖所有生命周期阶段;尤其要把“没有新 token”与“turn 已完成”区分开。
- 在 snapshot 与 optimistic local state 相撞时,定义最小的保留规则和 authority 规则;保留用户意图,但不让旧的 assistant 或已被服务端替换的消息复活。
- 对第三方 SDK patch 同时记录精确版本、lock hash、上游修复状态和边界行为;版本升级时先验证 patch 是否仍应用,再验证 replayComplete、done、live boundary 和 error flush。
- 为冷启动路径写真实生命周期测试:不仅 mock 一个 stub 能返回数据,还要证明初始化 hook、完整 session history、授权和业务方法调用按正确顺序发生。
边界、风险与未解问题#
- #5199 本身只有删除,没有新增测试;最关键的“关闭最后连接后仍保留 transcript、下一次 reconnect 能继续”目前是推理,不是仓库中的自动化证据。
- 空闲 DO 不再因最后连接关闭而 destroy,安全性提高,但数据保留策略未在这组 PR 中闭环。若 Go 删除协调器漏掉调用或历史数据迁移异常,可能出现长期保留的 chat storage。
- #5042 的窄合并假定未承认消息只会出现在前一状态尾部、且角色是 user;跨 tab、重复提交、regenerate、多个 queue item 和快照延迟会挑战这个假定。
- ReplayChunkBuffer 只按相邻 delta 合并,providerMetadata 会阻止合并;没有显式 buffer 上限、背压或大 replay telemetry,长 turn 仍有内存和恢复延迟风险。
- #5190 的 isRecovering 只修正客户端显示和发送门控,不能证明服务端恢复一定会完成;若恢复最终进入 error,仍需要终态 error、部分内容 flush 和可继续操作保持一致。
- #5201 的最终测试覆盖弱于修复本身:原 route 单测被删除,入口测试 mock 了 agents 包而没有验证 cold DO。手工 wrangler dev 结果不能替代跨 worker、休眠和真实 session 初始化回归。
- connection cookie 只存内存是刻意的凭证边界,但 hibernation 后的 token refresh、断线恢复、admin 只读和 platform task 无连接执行路径仍需用真实生命周期验证。
GitHub 文档#
- 本报告: outputs/voyager-daily-pr-study/2026-07-25-pr-5199-agent-do-reconnect-lifecycle.md
- 锚点 PR: #5199 ↗
- 相关 PR: #4895 ↗、#5042 ↗、#5190 ↗、#5201 ↗
- Voyager 读取基线: origin/main at 28f9382c798d0f3804735d93c88ae1b3eec4ced4;锚点 merge commit 为 e45c67d59e188fe5b2d032d239424ca6e9c86ad8。
- 持久化说明: 本报告直接写入 joyehuang/ai-agent-field-notes;feishu_doc_url 固定为 null,不创建或更新任何 Feishu 文档。