Joye Dev

Back

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 不会被重复或错序,管理员还能读取冷会话的完整记录。这里同时存在三类状态:

  1. Durable Object 中的持久化 transcript 和 Think 的 turn/recovery 状态;
  2. WebSocket 客户端已经收到的 assistant 内容、replay 队列和尚未被服务端快照承认的 user 消息;
  3. 由 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 AESTagents SDK patch / WebSocket transportagents@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 AESTThink 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 AESTAgent Durable Object lifecycle / deletion boundary删除 Agent.onClose 中基于最后连接和内存消息窗口的 destroy;保留 destroyChat 作为 Go 删除协调成功后的显式删除入口。代价是接受空闲 DO 继续存在,把清理责任从连接事件移回明确的删除流程。
新 SDK 版本的重连合并#5042,2026-07-25 14:15:43 AESTagents@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 AESTadmin route / framework initializationchatAdminTranscriptRoute 先保留 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 作为已学习过但必要的前置背景。

当前架构与数据流#

  1. 用户入口和客户端状态。 Home 或编辑器内的 AgentChatProvider 以 chatId 建立 Agent identity,EditorChatSidebar 会把首轮 handoff 作为 optimisticFirstMessage 交给同一套 provider。AgentChatContext 调用 useAgentChat 时开启 resume,并把 SDK 的 isRecovering、isStreaming、status 转成 UI 可消费的 visibleStatus。
  2. WebSocket 到 Durable Object。 浏览器连接 Agent DO;agent.ts 的 onConnect 解析身份、把 connection cookie 只保存在内存并确保 delegation token 可用,然后进入 Think 的连接/turn 生命周期。beforeTurn 既能在有 live connection 时刷新 token,也能在 recovery/continuation 路径使用存储中的 token。
  3. 持久化 transcript 与运行时窗口。 Agent 的业务方法通过 session.getHistory() 读取完整历史;this.messages 只是运行时 bounded window。这个区别直接决定了 #5199 为什么不能用 this.messages.length 判断“没有持久化状态”,也决定了 admin transcript 必须在 session 初始化后调用。
  4. 恢复流和快照合并。 服务端重放 replay=true 的 chunk,agents patch 在 transport 层按同一 part 压实,避免每个 chunk 都触发一次 UI state 更新;收到 replayComplete、done、live chunk 或 error 时才 flush。重连 snapshot 仍是主要权威来源,但 #5042 允许保留前一状态末尾尚未进入 snapshot 的 user 消息,解决 socket 断开窗口内的 optimistic send 丢失。
  5. 用户可见恢复态。 #5190 不把 SDK 在没有 token 时返回的 ready 直接当成终态,而是用 isRecovering 把 turn 标记为 in flight。AgentMessageList 显示 Resuming response…,同时继续把首轮 optimistic user 放在 partial assistant 之前。
  6. 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 推断。

我会怎么吸收#

  1. 遇到清理逻辑时,先列出内存缓存、持久化状态、业务元数据三种 source of truth,再决定是否允许破坏性操作;连接事件通常只能表达连接状态。
  2. 把恢复、重连、排队、终态分别建模,不用一个 streaming/ready boolean 覆盖所有生命周期阶段;尤其要把“没有新 token”与“turn 已完成”区分开。
  3. 在 snapshot 与 optimistic local state 相撞时,定义最小的保留规则和 authority 规则;保留用户意图,但不让旧的 assistant 或已被服务端替换的消息复活。
  4. 对第三方 SDK patch 同时记录精确版本、lock hash、上游修复状态和边界行为;版本升级时先验证 patch 是否仍应用,再验证 replayComplete、done、live boundary 和 error flush。
  5. 为冷启动路径写真实生命周期测试:不仅 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 文档。

🗂️ 这是知识库中的🔬 研究。

内容可能仍在补充或修订中。

← Back