Joye Dev

Back

Voyager 业务线学习:Agent Durable Object 生命周期与 transient failure 入口治理

这条业务线处理的不是某一个具体聊天功能,而是 Agent 对话在 Durable Object(DO)出现生命周期或平台暂态故障时,如何保持“数据不被误删、冷对象能被读、可重试失败不会直接变成不可解释的 500”。用户可见的入口有两条


source_automation: voyager-merged-pr run_date: 2026-08-01 anchor_pr_number: 5496 pr_number: 5496 pr_title: “Handle transient Durable Object errors with 503 responses” pr_url: https://github.com/adastralab-ai/voyager/pull/5496 author: “Jason Tan / banchichen” merged_at: “2026-08-01T03:56:04Z” modules:

  • backend/workers/agent/src/index.ts
  • backend/workers/agent/src/transientDoError.ts
  • backend/workers/agent/src/routes/chatAdminTranscript.ts
  • backend/workers/agent/src/routes/index.ts
  • backend/workers/agent/src/agent.ts files_changed: 3 changed_files:
  • backend/workers/agent/src/index.ts
  • backend/workers/agent/src/transientDoError.ts
  • backend/workers/agent/src/transientDoError.test.ts learning_tags:
  • agent-runtime
  • durable-object-lifecycle
  • cold-do-initialization
  • transient-failure
  • websocket-reconnect
  • http-503-contract
  • sentry-grouping
  • error-classification
  • bounded-retry
  • test-strategy business_line: “Agent Durable Object 生命周期、冷启动与 transient failure 入口治理” related_prs: [5199, 5201, 5302] related_prior_prs: [5199] line_stage: “连接关闭不再误删持久状态 -> 冷 DO 读取走 SDK 初始化 seam -> admin transcript 有界重试 -> public WS route 503 与 transient 观测分组” open_questions:
  • “index.ts 的 catch 对所有逃逸异常都返回 503;classifier 只改变 Sentry level/fingerprint,不改变响应,应用 bug 可能被客户端当成可重试故障。”
  • “routeAgentRequest 的 503 contract 没有 workerd/真实 DO reset 的集成测试;当前单测覆盖 classifier,不覆盖 pending WebSocket upgrade 的实际响应。”
  • “PartySocket 的重连放弃条件、按 chat + 时间窗去重和 Retry-After 的实际消费仍未落地;#5496 明确只做止血,不解决重连放大。”
  • “classifier 依赖 SDK/platform error message 与 retryable/overloaded 字段;跨 DO stub 后 flags 是否保留、平台文案漂移如何告警,仍待验证。”
  • “#5302 对所有 admin transcript 错误重试,且可能叠加 PartyServer 的内部 lookup retry;一次故障的实际 dispatch 上限和延迟需要观测。”
  • “wake-path memory-limit reset 被刻意保持 error-level,但外层仍回 503;大 transcript 是否会因此进入持续重连,需要单独的 retention、压缩或客户端终止策略。” feishu_doc_url: null github_repository: joyehuang/ai-agent-field-notes github_path: outputs/voyager-daily-pr-study/2026-08-01-pr-5496-agent-do-transient-503-recovery.md voyager_merge_commit: 68202ffb12ff1cc4e7f0dc2183d43206cdaf33ba voyager_origin_main_snapshot: 68202ffb12ff1cc4e7f0dc2183d43206cdaf33ba

业务线概览#

这条业务线处理的不是某一个具体聊天功能,而是 Agent 对话在 Durable Object(DO)出现生命周期或平台暂态故障时,如何保持“数据不被误删、冷对象能被读、可重试失败不会直接变成不可解释的 500”。用户可见的入口有两条:

  • 浏览器通过 Agent WebSocket 建立或恢复会话。DO 在连接已经建立后被平台重置时,原先的异常会从 routeAgentRequest 逃到 Worker 边界,浏览器侧看到 500,并可能进入反复重连。
  • Admin transcript 读取通过 Agent Worker 的 /api/internal/admin/agent_chats/:chatId/transcript 访问同一个 DO。冷 DO 初始化或存储短暂失败时,管理员不应因为一次可恢复的唤醒失败而得到错误。

演进的核心是把不同责任分开:持久状态的权威性由 DO storage/session 决定,连接关闭不是删除信号;冷对象访问先走正确的 SDK 初始化 seam;无副作用的读取在窄边界内做有界重试;公开连接路由在平台错误逃逸处返回可识别的 503,并把“如何分组告警”与“是否返回 503”分离。

本次锚点是 PR #5496。它直接响应开放 issue #5473 描述的 DO storage reset:该 issue 记录了异常从 PartyServer/DO stub 逃出后形成未处理 500,以及少数 chat 被重连循环放大为大量 Sentry 事件。#5496 只负责入口止血和分类,不声称已经定位 DO 内部具体哪一个 storage 操作超时。

今日锚点#

字段内容
PR#5496 Handle transient Durable Object errors with 503 responses
作者Jason Tan / banchichen
merge 时间2026-08-01 13:56:04 AEST / 2026-08-01T03:56:04Z
Voyager merge commit68202ffb12ff1cc4e7f0dc2183d43206cdaf33ba
锚点变更3 个文件:Worker entry、transient classifier、classifier unit test
选择理由Melbourne 当天只有 #5496 和偏文档编辑器的 #5399;#5496 是未学习且能自然连起 DO 生命周期、冷启动读取、admin 重试和 public WS 错误边界的工程性锚点,因此没有扩大到昨天。

锚点的关键设计不是“识别到 transient 才返回 503”:index.ts 的 catch 对所有从 routeAgentRequest 逃出的异常统一返回 503;transientDoErrorMessage() 只决定 Sentry 事件是否按 warning 和统一 fingerprint 记录。这种解耦让平台文案漂移不会重新回到未捕获 500,但也留下了把应用 bug 暂时化的风险。

演进时间线#

阶段PR 与真实代码变化改变的层新增的边界
权威状态与连接生命周期#5199,2026-07-25,merge e45c67d59e188fe5b2d032d239424ca6e9c86ad8backend/workers/agent/src/agent.ts删除 onClose 中依据 bounded this.messages 判断并调用 this.destroy() 的逻辑;连接关闭不再等价于 DO 数据为空,显式 destroyChat() 保留为删除协调入口。
冷 DO 可读#5201,2026-07-25,merge 28f9382c798d0f3804735d93c88ae1b3eec4ced4admin transcript route / Agent SDK seamenv.Agent.get(env.Agent.idFromName(chatId)) 换成 await getAgentByName(env.Agent, chatId),再调用 getAdminTranscript();代码事实是改用 SDK helper,目标是让冷对象访问经过能初始化 DO 的路径。
admin 读取有界恢复#5302,2026-07-28,merge 0cb8844face898bd54895ee16c7d296491cb8211admin transcript route / tests为无副作用的 transcript 读取增加最多 3 次尝试,固定 200ms 间隔;同时 await lookup 和实际 RPC,确保“lookup 成功但 read 失败”也进入 retry。
今日锚点:公开 route 止血#5496,2026-08-01,merge 68202ffb12ff1cc4e7f0dc2183d43206cdaf33baWorker entry / Sentry / HTTP response包住 routeAgentRequest 的逃逸异常,返回 503 Agent temporarily unavailableRetry-After: 5;识别 DO 平台 transient、合并 wrapper/cause 的报告分组,并打上 chatId

这条线的层次变化可以压缩成一句话:先修“不能把内存缓存当成持久状态”,再修“冷对象访问不能绕过初始化”,接着修“读取失败可以在窄边界重试”,最后修“公开连接入口不能把 DO reset 作为未处理 500 泄露”。#5199 已在最近学习记录中作为锚点出现,本次把它作为前置背景,新增视角是从 DO 内部生命周期推进到外部入口契约和观测语义。

当前架构与数据流#

flowchart LR
  B["浏览器 PartySocket / Agent client"] --> W["agent-worker index.ts fetch"]
  A["Admin console"] --> H["/api/* Hono app"]
  W --> R["routeAgentRequest"]
  R --> D["Agent Durable Object"]
  D --> S["session / durable history"]
  R -. "DO reset rejects in-flight promise" .-> C["entry catch"]
  C --> T["transientDoErrorMessage"]
  C --> E["Sentry warning/error + fingerprint + chatId"]
  C --> P["503 + Retry-After: 5"]
  H --> G["Go transcript-access authorization"]
  G --> Q["readTranscript"]
  Q --> N["getAgentByName + getAdminTranscript"]
  N --> D
  Q -. "200ms, max 2 retries" .-> N
plaintext
  1. 入口分流backend/workers/agent/src/index.ts:70-75 先把 /api/* 交给 createApiApp();只有非 /api 请求继续进入 routeAgentRequest。因此 #5302 的 admin transcript retry 不会被 #5496 的 public route catch 代替,二者是两个不同的故障边界。
  2. 公开连接路径index.ts:78-97 继续使用 onBeforeConnect 做用户/session 与 chat ownership 校验,onBeforeRequest 处理普通 Agent 请求。已经被明确转化为 401/503 Response 的认证结果不会抛到外层;#5496 处理的是 DO stub、构造或 PartyServer 路由内部仍然逃逸的异常。
  3. DO 状态路径:#5199 移除连接最后关闭时的自动 destroy();当前 agent.ts:1987-2001 只在显式 destroyChat() 经过删除协调后清除消息、认证和 DO storage。这样断线/reconnect 与删除动作不再共用一个触发器。
  4. Admin transcript 路径routes/index.ts:99-111 以 admin cookie 调 Go 的 transcript authorization,然后 chatAdminTranscript.ts:105-120getAgentByNamegetAdminTranscript 的完整组合做最多 3 次尝试。读取是无副作用的,所以 retry 不会重复写入;耗尽后异常仍会向上抛出,由 API app 的错误边界处理。
  5. 观测路径:#5496 的 classifier 不承担产品重试决策。匹配到已知平台 transient 时,Sentry 只按平台 message fragment 做 fingerprint,并把 level 降为 warning;未匹配、memory-limit 或 blockConcurrencyWhile cancel 仍保持 error-level。响应体统一为不泄露内部错误的短文本。

关键代码#

1. 连接关闭不再依据内存窗口销毁整个 DO#

来源:PR #5199 diffbackend/workers/agent/src/agent.tsonClose 删除 hunk(原约 875-903);当前显式删除 seam 为 agent.ts:1987-2001

- override async onClose(connection, code, reason, wasClean) {
-   await super.onClose(connection, code, reason, wasClean);
-   const isStillOpen = [...this.getConnections()].some(
-     (c) => c.id !== connection.id,
-   );
-   if (!isStillOpen && !this.messages.length) await this.destroy();
- }
plaintext

this.messages 是有界/运行时可见的消息窗口,不是 storage/session 的完整事实。删除这段逻辑后,连接断开只影响连接状态;真正的删除仍由 destroyChat() 在 Go 元数据删除成功后协调。这个前置修复是后续所有“断线后重连仍能读到 transcript”行为成立的前提。

2. 冷对象读取从原始 namespace stub 切到 SDK helper#

来源:PR #5201 diffbackend/workers/agent/src/routes/chatAdminTranscript.ts1-3,55-59 diff hunk;当前实现为 chatAdminTranscript.ts:111-115

- const agent = env.Agent.get(env.Agent.idFromName(chatId));
+ const agent = await getAgentByName(env.Agent, chatId);
  const transcript = await agent.getAdminTranscript();
plaintext

代码可验证的变化是 SDK helper 的引入和异步 lookup;它把 admin route 与 Agent SDK 的对象获取/唤醒 seam 对齐,而不是直接构造 namespace stub。PR 的测试计划声称冷 DO 请求能返回空 transcript,但本报告没有把该本地 wrangler dev 结果当作当前生产集成证明;真正的冷 DO、schema 初始化和 RPC 链路仍需要 workerd 覆盖。

3. retry 必须包住“实际失败的 await”#

来源:PR #5302backend/workers/agent/src/routes/chatAdminTranscript.ts:103-120

async function readTranscript(env: Env, chatId: string, attemptsLeft = 2) {
  try {
    const agent = await getAgentByName(env.Agent, chatId);
    const transcript = await agent.getAdminTranscript();
    return transcript;
  } catch (error) {
    if (attemptsLeft === 0) throw error;
    await new Promise((resolve) => setTimeout(resolve, 200));
    return readTranscript(env, chatId, attemptsLeft - 1);
  }
}
plaintext

这里有一个容易被测试遗漏的边界:如果 agent.getAdminTranscript() 只是从 tryreturn 一个未 await 的 Promise,读取本身的 rejection 会落到当前 catch 之外,只有 getAgentByName 失败会 retry。#5302 新增的测试专门让 read 本身第一次失败,证明 retry 覆盖了完整 read path;第二个测试把总尝试次数锁定为 3。

4. 在 routeAgentRequest 逃逸点建立 HTTP 止血边界#

来源:PR #5496backend/workers/agent/src/index.ts:77-117

try {
  const routed = await routeAgentRequest(request, env, { /* auth hooks */ });
  if (routed) return routed;
} catch (error) {
  const transientMessage = transientDoErrorMessage(error);
  const chatId = extractChatId(url.pathname);
  captureException(error, {
    level: transientMessage ? "warning" : "error",
    ...(transientMessage && {
      fingerprint: ["agent-route-transient-do-error", transientMessage],
    }),
    ...(chatId ? { tags: { chatId } } : {}),
  });
  return new Response("Agent temporarily unavailable", {
    status: 503,
    headers: { "Retry-After": "5" },
  });
}
plaintext

这段代码的责任边界很窄但很重要:它不尝试重建 DO,也不吞掉异常;它把异常送入 Sentry 后给客户端一个可区分的 HTTP 暂态响应。extractChatId() 只从 /agents/agent/<chatId> 路径解析 chat id,认证逻辑仍在既有 hook 中,不因错误观测而改变授权。

5. classifier 统一 wrapper/cause,同时把 OOM 留在高可见路径#

来源:PR #5496backend/workers/agent/src/transientDoError.ts:33-90

for (const candidate of selfAndCauses(error)) {
  if (MEMORY_LIMIT_RESET.test(messageOf(candidate))) return null;
}
for (const candidate of selfAndCauses(error)) {
  const message = messageOf(candidate);
  for (const pattern of TRANSIENT_MESSAGES) {
    const matched = message.match(pattern);
    if (matched) return matched[0];
  }
  if (typeof candidate === "object" && candidate != null) {
    const retryable =
      "retryable" in candidate && candidate.retryable === true;
    const overloaded =
      "overloaded" in candidate && candidate.overloaded === true;
    if (retryable && !overloaded) {
      return message || "retryable";
    }
  }
}
return null;
plaintext

上面保留了实际实现的 unknown 类型保护和两次 cause 链遍历。实现先遍历最多 8 层 cause,确保 memory-limit reset 即使带 retryable 也返回 null;再用窄正则匹配 storage timeout、storage internal reset、code update 和 network loss;最后才接受 retryable && !overloaded。匹配返回 fragment 而不是完整 wrapper message,使 bare error、SqlError 拼接消息和 cause 形式落到同一 Sentry fingerprint。

transientDoError.test.ts:10-88 覆盖了平台文案、retryable/overloaded 排除、memory-limit、flat/cause wrapper、普通错误、blockConcurrencyWhile cancel 和非 Error 输入。它证明的是分类函数的纯逻辑,不证明 routeAgentRequest 在真实 workerd 中能把 pending WebSocket upgrade 统一转为 503。

工程取舍#

边界与复用#

  • 权威状态与连接状态分开:#5199 不再用连接关闭推断持久状态为空;destroyChat() 是显式删除 seam。这个选择避免把 bounded cache 的偶然为空解释成数据删除。
  • 读取重试放在最窄的无副作用边界:#5302 只重试 admin transcript 读取,不把整个 admin handler、授权请求或 PostHog replay 查询一起重放。这样不会重复授权副作用,也不改变成功响应结构。
  • 入口 catch 与 classifier 解耦:#5496 让所有逃逸异常都有可控响应,已知 transient 只影响告警分组。分类规则错误时仍能止住未捕获 500,代价是 error-level 事件或过宽的 503。
  • 优先复用已有 SDK/协议 seam:#5201 使用 getAgentByName,#5496 使用现有 routeAgentRequest、Sentry captureException 与 auth 的 extractChatId;没有新增第二套 DO 访问或重连协议。

兼容性与性能#

  • 503 + Retry-After: 5 是新增的 public route 行为,错误正文固定为 Agent temporarily unavailable,避免把 storage、SQL 或 DO 内部信息暴露给浏览器。认证 hook 自己返回的 401/503 仍保留原有 terminal/transient 区分。
  • classifier 的 cause 链最多走 8 层,正则只用于观测分组,不参与模型、存储或请求重放,运行时成本很小。平台文案变化的失败模式是“更响亮的 error 分组”,不是重新出现未捕获 500;这是 PR 选择的低耦合降级。
  • admin retry 最多额外等待约 400ms,但它可能叠加 PartyServer 对 getAgentByName 的内部 retry。PR 说明估算 lookup 最多出现 9 次 dispatch;代码没有对该嵌套上限做显式抑制,这应由运行时指标确认。
  • #5496 没有在 Worker 内部再次 retry 一个已被 DO reset 的请求,而是把重连责任留给客户端。这样避免在一个已经等待 11 秒左右才失败的连接上继续堆叠 server-side retry,但客户端的无限重连/放弃策略仍是未完成的产品边界。

可靠性与测试策略#

PR已验证的测试层没有被证明的部分
#5199删除生命周期逻辑,GitHub checks 通过没有用真实 DO storage 验证最后连接关闭、持久 transcript、后续 reconnect 的完整生命周期。
#5201route 单测覆盖鉴权失败、不可用、成功转发;PR 计划包含本地冷 DO 手测没有 workerd/部署环境的冷 DO schema 初始化与 RPC 集成回归。
#5302fake timers 覆盖 lookup 失败、read 失败、3 次上限;GitHub checks 通过没有错误分类、结构化诊断、嵌套 SDK retry ceiling 或真实 storage reset 验证。
#5496纯 classifier 单测覆盖正向、负向、wrapper/cause;GitHub checks 中前端 unit/browser/quality 通过PR 明确没有 route 503 contract 测试,因为仓库没有 workerd test environment;pending WebSocket upgrade、Retry-After 消费、Sentry grouping 仍待集成测试。

这里要区分 CI 事实与运行时推断:GitHub checks 的通过说明提交满足当时配置的检查,不能证明 Cloudflare DO reset 已在生产被恢复;#5496 自己也把 route contract 标为未覆盖。

和最近学习记录的关系#

最近的 #5199 学习报告 关注的是“DO 是否应因最后一个连接关闭而被销毁”,结论是连接生命周期不能替代持久状态权威。本次没有重复把 #5199 当锚点,而是把它作为这条线的前置事实:如果连接关闭仍会删除 DO,今天的 503 和后续重连都没有稳定的历史可读。

本次新增的观察角度有三点:

  1. getAgentByNamereadTranscript 把冷启动/暂态恢复前移到调用边界,说明“DO 不被误删”还不够,唤醒路径也要可重试。
  2. admin route 的 retry 和 public WS route 的 503 是两种不同的恢复协议:前者服务端可以安全重放无副作用读,后者只能返回可解释的暂态响应并交给连接客户端。
  3. 观测分组是独立的控制面:wrapper/cause 归一、chat tag 和 memory-limit 的 error-level 保留,解决的是 Sentry 信号质量,不应被误读成根因定位或重试成功证明。

我会怎么吸收#

  • 在分布式对象/Actor 代码中,先列出 durable state、bounded in-memory cache、connection state 和显式 delete command,再决定哪个事件能触发清理;不要用“当前内存为空”代表“持久对象为空”。
  • 对可重放操作,把 retry 放在最小的 side-effect-free 函数里,并且 await 到真正可能失败的 Promise;单测必须分别让 lookup 和 read 本身失败。
  • 在异步路由中把“响应契约”“重试策略”“告警分类”拆成三个决策:响应可以统一 503,分类可以 conservative,重试不应因为分类 helper 顺便改变。
  • 对跨 RPC/SDK 的平台错误,测试 bare、wrapper、cause、flag 缺失和负面高风险错误;分类只改善可观测性时,要确保分类漂移的 fallback 是更响亮,而不是静默。
  • 把“能在 Node 单测中验证”与“真实 workerd/DO 生命周期已验证”写成两条独立证据链;没有 runtime seam 时,优先补最小 integration harness,而不是把纯函数覆盖率当作 route contract。

边界、风险与未解问题#

代码事实#

  • #5496 的 catch 无条件返回 503;transientMessage 只影响 Sentry level 和 fingerprint。普通应用异常、未分类 DO 错误、memory-limit reset 都不会返回各自的 4xx/5xx 细分。
  • classifier 只匹配有限平台文案或 retryable flag,并对 overloaded 和 memory-limit 做排除;blockConcurrencyWhile cancel 也刻意保持 error-level。
  • #5302 的 retry 对所有错误生效,固定 200ms,最多 3 次;没有把平台错误分类或 Sentry tag 带入 admin route。
  • 关联的 #5199、#5201、#5302 merge commit,以及锚点 68202ff... 均已验证为 origin/main 祖先。#5199 已经有历史学习记录,#5201/#5302 是本次代码阅读中补齐的相邻阶段。

合理推断#

  • #5496 能显著改善 Sentry 中“未处理 500 + wrapper 分裂”的信号,但不能阻止一个已成功连接后持续失败的客户端不断重连;如果客户端的连接成功状态每次都重置退避,5 秒 header 也不会自动形成最终放弃。
  • #5199 和 #5201 组合后,DO 的数据保留与冷启动访问边界更合理;但如果根因是 wake 时 schema/FTS/storage 操作太慢,#5302/#5496 都只是把失败变得可重试/可观测,没有减少单次唤醒成本。
  • 对全部逃逸异常返回 503 可能让应用 bug 被客户端重试,短期降低用户看到的错误细节,长期却增加故障噪声;需要基于异常类型或 route contract 进一步收窄。

仍待确认#

  • 在 workerd 中制造真实 DO storage reset,验证 pending WebSocket upgrade 是否确实从 routeAgentRequest 进入 catch,以及 Retry-After 是否被实际连接客户端消费。
  • 用大 transcript、cold DO、最后连接关闭、DO hibernation 和部署更新组合场景确认 schema bootstrap、session.getHistory()getAdminTranscript() 的尾延迟及失败位置。
  • 为同一 chat 的 repeated reset 增加限流/去重或客户端 give-up 规则,同时保留至少一个可定位的 Sentry 事件,不要用降采样掩盖仍在重连的用户。
  • retryableoverloaded 和底层 platform reason 的跨 stub 传播做成结构化字段或诊断日志,减少对错误文案正则的长期依赖。
  • 决定 admin transcript 是否应只重试 SDK 认定的 transient;若继续全量 retry,至少要记录 attempts、总延迟和最终错误类型,确认嵌套 retry 不会放大存储故障。

候选说明#

候选处理依据
#5496 transient DO errors -> 503选为锚点Melbourne 2026-08-01 合并、日志中未学习、直接对应开放的 DO reset 入口问题,并能通过共享 Agent Worker/DO runtime 边界连接至少 3 个已合入 PR。
#5399 fixed document paper size未选同日合并但属于 Docs 页面尺寸/文档编辑器能力,不能自然串起本次 Agent DO 故障线;若以它为锚点需跨业务线凑相关 PR。
#5497 continuation tools未扩大昨日 Agent runtime 候选,主线本身值得学习,但今天已有足够强且更贴合基础设施可靠性边界的锚点,按窗口规则不再转向昨天。
origin/claude/issue-5473-suggestion-2-dmrpgj 的 phase-marker commits不作为 related PR该分支包含 #5496 之后的 wake-phase 标记,但没有作为 PR 合入 origin/main,因此只作为未完成的诊断方向,不计入正式演进时间线。

没有把相似的 Agent 标题机械拼接进来;正式相关 PR 都通过同一 DO storage/session、admin transcript 或 public Agent route 的真实代码边界连接,并已核对主线祖先关系。

GitHub 文档#

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

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

← Back