Joye Dev

Back

Voyager 业务线学习:Agent 对话连接生命周期与客户端重连熔断

这条业务线处理的是 Agent 聊天在 Durable Object(DO)或其唤醒路径发生暂态故障时,如何同时保护三件事:持久对话不能因断线被误删;服务端故障要以可恢复的协议返回;客户端不能把一个已经 wedged 的 chat 变成持续唤醒 DO、重复打 Sentry 的重连风暴


source_automation: voyager-merged-pr run_date: 2026-08-03 anchor_pr_number: 5503 pr_number: 5503 pr_title: “Stop reconnecting wedged chats after repeated failures” pr_url: https://github.com/adastralab-ai/voyager/pull/5503 author: “Jason Tan / banchichen” merged_at: “2026-08-03T02:09:55Z” modules:

  • packages/site/src/app/(main)/_agent/chat/_components/AgentChat.stories.tsx
  • packages/site/src/app/(main)/_agent/chat/_components/AgentChat.tsx
  • packages/site/src/app/(main)/_agent/chat/_components/AgentChatContext.test.tsx
  • packages/site/src/app/(main)/_agent/chat/_components/AgentChatContext.tsx files_changed: 4 changed_files:
  • packages/site/src/app/(main)/_agent/chat/_components/AgentChat.stories.tsx
  • packages/site/src/app/(main)/_agent/chat/_components/AgentChat.tsx
  • packages/site/src/app/(main)/_agent/chat/_components/AgentChatContext.test.tsx
  • packages/site/src/app/(main)/_agent/chat/_components/AgentChatContext.tsx learning_tags:
  • agent-runtime
  • durable-object-lifecycle
  • websocket-reconnect
  • bounded-retry
  • client-circuit-breaker
  • manual-recovery
  • queue-preservation
  • transient-failure
  • observability
  • test-strategy business_line: “Agent 对话连接生命周期、DO 暂态故障与客户端重连治理” related_prs: [5199, 5496, 5500] related_prior_prs: [5199, 5496] line_stage: “连接关闭不销毁持久状态 -> public route 将 DO transient 映射为 503 -> 唤醒/连接阶段可观测 -> 客户端有限重连与人工恢复” open_questions:
  • “#5503 的失败计数没有时间衰减,只在一次连接存活至少 30 秒时清零;跨很长时间发生的短连接失败是否应算同一故障窗口仍待决定。”
  • “#5496 的 route catch 对所有逃逸异常返回 503,#5503 会把未分类应用 bug 也带入可重连路径;需要按错误类型收窄 contract 或设置客户端重试预算。”
  • “Retry-After: 5 是 HTTP 响应提示,但 #5503 的客户端策略主要依赖 PartySocket/backoff;尚未证明该 header 被真实连接客户端消费。”
  • “#5503 单测 mock 了 shouldReconnectOnClose/onClose 顺序,没有 workerd、真实 PartySocket、DO reset 与 pending upgrade 的集成覆盖。”
  • “wakePhase 在失败启动后的重试上只保留一次 connect instrumentation;日志是否足以定位所有重试链,以及 console JSON 行的采集完整性仍需生产验证。”
  • “停掉无限重连降低 DO 唤醒放大,但没有服务端按 chat 限流/去重;多 tab、多个客户端和手动 Reconnect 竞态仍需验证。” feishu_doc_url: null github_repository: joyehuang/ai-agent-field-notes github_path: outputs/voyager-daily-pr-study/2026-08-03-pr-5503-agent-chat-reconnect-circuit-breaker.md voyager_merge_commit: 911c99ff424039d81527deeb8420191abe911ae4 voyager_origin_main_snapshot: c244ab6bba263d9293e031643666e0c5bf6271e1

业务线概览#

这条业务线处理的是 Agent 聊天在 Durable Object(DO)或其唤醒路径发生暂态故障时,如何同时保护三件事:持久对话不能因断线被误删;服务端故障要以可恢复的协议返回;客户端不能把一个已经 wedged 的 chat 变成持续唤醒 DO、重复打 Sentry 的重连风暴。

用户看到的只是“聊天断了”。实际链路横跨浏览器 Agent transport、routeAgentRequest、Agent DO 的 constructor/onStart/onConnect、DO storage/session,以及消息队列和输入框状态。今天的锚点 #5503 补的是最后一层:当连接连续短暂建立又掉线时,客户端不再默认无限重连,而是停在 disconnected,保留输入和待发送队列,并提供一次明确的 Reconnect

本次的核心结论是:重连不是一个布尔开关,而是跨层的恢复预算。 #5199 先切开“连接关闭”和“删除持久状态”;#5496 在 Worker 入口把 DO reset 的逃逸异常止血成 503;#5500 让 DO 唤醒过程在被 reset 时仍留下可定位的阶段证据;#5503 最终在客户端把无限 retry 收敛为“有条件自动恢复 + 人工恢复入口”。

今日锚点#

项目内容
PR#5503 Stop reconnecting wedged chats after repeated failures
作者Jason Tan / banchichen
合并时间2026-08-03 12:09:55(Australia/Melbourne,UTC 02:09:55Z)
Voyager merge commit911c99ff424039d81527deeb8420191abe911ae4
改动范围4 个 site 文件:AgentChat 状态上下文、断线提示、context 单测和 stories
选择理由#5503 是 Melbourne 当天尚未作为锚点学习的 Agent 可靠性 PR,且直接承接已学习的 #5496“503 但不解决重连放大”开放问题;它可以通过真实共享的 Agent chat/DO 连接边界自然连接 #5199 与 #5500,不需要跨业务线凑相关 PR。

代码事实与推断分开看:PR body 把十次失败描述为大约两分钟的预算,但实现本身只保存“短连接失败次数”,没有独立的 wall-clock 滑动窗口。自动重连是否真的停得足够早、是否会误伤偶发网络抖动,需要结合运行时连接时长和实际 backoff 观测,而不能只从常量名称推断。

演进时间线#

阶段PR 与真实代码变化改变的层新边界
持久状态与连接生命周期分离#5199,2026-07-25,merge e45c67d59e188fe5b2d032d239424ca6e9c86ad8Agent DO onClose / storage删除最后连接关闭时根据 this.messagesthis.destroy() 的逻辑;连接断开不再代表对象为空,显式 destroyChat() 才是删除入口。
public route 入口止血#5496,2026-08-01,merge 68202ffb12ff1cc4e7f0dc2183d43206cdaf33baWorker entry / HTTP / Sentry捕获 routeAgentRequest 的逃逸异常,返回 503Retry-After: 5;transient 分类只改变 Sentry level/fingerprint,不决定响应码。
唤醒路径诊断#5500,2026-08-02 00:05,merge 81dd38fb318ad574bf2bbb5e2a29e06ad9f3cd91Agent constructor / onStart / onConnect / Workers Logs用未经过 Sentry 批处理的 JSON console.log 记录 constructing、startup、connect 的开始/结束和耗时,使 storage deadline reset 之后仍能知道最后进入了哪一阶段。
今日锚点:客户端重连熔断#5503,2026-08-03,merge 911c99ff424039d81527deeb8420191abe911ae4Agent chat context / UI / queue30 秒内未存活的连接计数;连续 10 次后让 SDK 停止重连,进入 disconnected,仅暴露人工 Reconnect;终端 auth close 仍要求刷新。

这不是四个相似标题的拼接,而是同一故障的责任边界逐层外移:DO 不再被连接事件误删,入口把 reset 变成客户端可处理的 503,DO 自己留下故障位置,最后由浏览器决定何时停止继续唤醒服务端。

当前架构与数据流#

flowchart LR
  U["浏览器 AgentChat"] --> T["useAgent / PartySocket"]
  T -->|WebSocket / retry| W["agent worker index.ts"]
  W --> R["routeAgentRequest"]
  R --> D["Agent Durable Object"]
  D --> H["Think / agents hydration\nonStart + onConnect"]
  H --> S["DO storage / session / transcript"]
  R -. "DO reset rejects route promise" .-> E["#5496 catch"]
  E --> P["503 + Retry-After: 5"]
  E --> O["Sentry level + fingerprint + chatId"]
  P --> T
  D -. "wake diagnostics" .-> L["#5500 console JSON markers"]
  T --> C["#5503 failure counter"]
  C -->|< 10 short failures| T
  C -->|10th short failure| G["disconnected + Reconnect"]
  G -->|agent.reconnect()| T
  G -. "preserve input / queued messages" .-> Q["composer + FIFO queue"]
plaintext
  1. 浏览器入口。 AgentActiveChatuseAgent 建立以 chatId 为 name 的连接。#5503 通过 shouldReconnectOnClose 拿到 SDK 的 close event;这一步发生在 onClose 前,所以返回 false 才能真正阻止下一次 socket/DO 唤醒。onClose 只负责把 UI phase 设成 reconnectingdisconnected
  2. Worker 与 DO 边界。 backend/workers/agent/src/index.ts:77-117routeAgentRequest 外层捕获逃逸异常。已知 platform transient 会被 transientDoErrorMessage 归一为 warning/fingerprint;未知异常、memory-limit reset 仍以 error 记录,但响应仍统一是 503。这是观测分类与恢复协议的解耦。
  3. DO 唤醒/连接。 backend/workers/agent/src/agent.ts:631-727 在 constructor、onStartonConnect 周围加 marker。#5500 记录的是“故障发生在哪”,不是修复 storage deadline;#5199 保证断开后 transcript/state 仍由 storage/session 保留,显式 destroyChat 才清除。
  4. 客户端停止条件。 AgentChatContext.tsx:1060-1073 在每次 close 前累加短连接失败,30 秒以上的健康连接会清零。达到十次后设置 hasGivenUponClose 将 phase 置为 disconnectedAgentChat.tsx:264-282retry 是否存在决定显示 Reconnect 还是“刷新页面”。
  5. 消息安全。 连接停止时 submitUserMessageAgentChatContext.tsx:1262-1266 返回 false,不让 composer 清空输入;队列 flush 在 :1323-1331 对 disconnected 直接暂停,避免把消息标记成已发但永远没有 socket 消费。

关键代码#

1. #5199:删除错误的 DO 自动销毁触发器#

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

- 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 transcript。把最后一个连接关闭解释成“空 DO”会让后续 reconnect 面对被清掉的 session;#5503 的人工恢复只有在这个前提成立时才有意义。当前 destroyChat() 仍在 agent.ts:2130-2137 关闭连接并显式 destroy()

2. #5496:在 routeAgentRequest 逃逸处建立 503 边界#

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

} 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

事实是这个 catch 对所有从 route 逃出的异常返回 503;classifier 只影响 Sentry 的 level、fingerprint 和 chat tag。这样已知 DO reset 不再变成未捕获 500,也没有把 platform 文案暴露给浏览器;代价是应用 bug 也会暂时看起来像可重试失败,#5503 可能因此继续给它有限的自动 retry。

3. #5500:用直出 JSON marker 留住 reset 前的最后证据#

来源:PR #5500backend/workers/agent/src/wakePhase.ts:15-21agent.ts:653-675

export function markWakePhase(
  chatId: string,
  phase: string,
  fields?: Record<string, unknown>,
): void {
  // eslint-disable-next-line no-console
  console.log(JSON.stringify({ msg: "agent-wake", phase, chatId, ...fields }));
}
plaintext

constructor 先记 constructingsuper() 返回后记 constructed,包装的 onStart 在 finally 记 startup:doneonConnect 则记 connect:begin/authed/done。直接走 console.log 是因为 storage deadline reset 可能连 Sentry 的批量事件、span 和 logger buffer 一起丢掉。这个 PR 不减少唤醒成本,只把“在哪一阶段死掉”从不可见变成可查询。

4. #5503:计数放在 SDK 的 reconnect predicate,而不是 onClose#

来源:PR #5503packages/site/src/app/(main)/_agent/chat/_components/AgentChatContext.tsx:167-185,1060-1074

shouldReconnectOnClose: (event: CloseEvent) => {
  if (isTerminalCloseCode(event.code)) return false;
  const uptimeMs = openedAtRef.current
    ? Date.now() - openedAtRef.current
    : 0;
  openedAtRef.current = 0;
  failureCountRef.current =
    uptimeMs >= HEALTHY_CONNECTION_MS ? 0 : failureCountRef.current + 1;
  const shouldRetry = failureCountRef.current < CONNECT_FAILURE_LIMIT;
  if (!shouldRetry) setHasGivenUp(true);
  return shouldRetry;
},
plaintext

CONNECT_FAILURE_LIMIT = 10HEALTHY_CONNECTION_MS = 30_000 是客户端状态机的两个阈值。终端 1008/4xxx close 不参与计数,因为重新连接不能改变 SDK 的 terminal verdict;健康连接会清除旧失败,避免一个很久以前的短暂故障永久污染当前会话。把判断放在 predicate 而非 onClose 是关键:测试 helper 也按 partysocket 的真实顺序先调用 predicate,再调用 onClose

人工恢复会同时清理本地计数和 SDK 的 backoff:

const retryConnection = () => {
  failureCountRef.current = 0;
  openedAtRef.current = 0;
  setHasGivenUp(false);
  setConnectionPhase(
    hasEverConnectedRef.current ? "reconnecting" : "connecting",
  );
  agent.reconnect();
};
plaintext

实际代码在 AgentChatContext.tsx:1100-1109,这里的取舍是点击后立即发起一次尝试,而不是等之前累积的 backoff 自然结束。

5. #5503:断线时保留用户意图,并把恢复入口显式化#

来源:PR #5503 diffAgentChat.tsx:256-282AgentChatContext.tsx:1262-1266,1323-1331

if (connectionPhase === "disconnected") return false;
plaintext

这行看似简单,但它阻止 SDK 把消息塞进一个不会再 flush 的内部队列,同时让 composer 保留输入;单独的 queue effect 在 disconnected 时 return,未发送项继续可见。UI 只在 retry 非空时显示 Can’t connect to this chat / Reconnect;如果是 terminal auth close,retry 为 null,仍显示要求刷新页面的旧路径,避免给用户一个注定无效的按钮。

测试证据来自 AgentChatContext.test.tsx:1365-1421:前 9 次短失败 predicate 返回 true,第 10 次返回 false 并进入 disconnected,点击 Retry 调用 agent.reconnect();另一用例把连接推进 120 秒后确认计数清零。它验证的是连接 phase machine 和消息入口的行为,不是 Cloudflare DO 真 reset 或 PartySocket 真实 backoff 的生产回归。

工程取舍#

边界与复用#

  • 持久状态、连接状态、恢复预算分层。 #5199 让 storage/session 成为 transcript 的权威;#5503 只在 site context 内维护短期 failure count,不把客户端重连计数写回 DO,避免故障本身再制造持久状态。
  • 复用已有协议 seam。 #5496 不另造错误 API,沿用 routeAgentRequest 的 503 入口;#5503 复用 agents SDK 的 shouldReconnectOnCloseagent.reconnect(),没有绕过 SDK 自己的 terminal close 判断。
  • 自动化与人工恢复并存。 9 次以内的短失败仍然自动恢复,30 秒健康连接会恢复预算;达到上限后停住,用户可以在证据和输入都保留的情况下主动重试。
  • 终端错误不混入 transient。 1008/4xxx 仍走刷新/重新鉴权;memory-limit reset 在 #5496 classifier 中保持 error-level,避免把可重复的 OOM 当成普通网络波动。

兼容性与性能#

  • AgentConnectionState 从只有 phase 扩展为 phase + retry,stories 和测试 fixture 同步补 retry: null;这是同一 release 内的内部 context contract,不保留双形状兼容层。
  • 客户端少于十次失败时仍沿用 SDK/PartySocket 的 backoff;达到上限才调用 shouldReconnectOnClose = false。#5503 的目标不是让每次失败更快,而是给持续失败设置资源上限。
  • Retry-After: 5 与客户端 backoff 的实际组合未在代码中显式绑定。当前实现降低了“无限重连”的上限,但没有证明五秒提示会成为所有客户端的共同节流策略。
  • #5500 的 console.log(JSON.stringify(...)) 每次唤醒/连接写多条 Workers Logs;它以诊断可靠性换取日志量,是否需要采样、敏感字段治理和长期保留策略由生产观测数据决定。

可靠性与测试策略#

PR已有证据未被证明的部分
#5199删除误销毁逻辑,保留显式 destroyChat seam;相关 checks 已随 PR 合入没有真实 DO storage/session 生命周期测试证明最后连接关闭后 reconnect 仍保留完整 transcript。
#5496transientDoError.test.ts:10-88 覆盖平台文案、cause、retryable/overloaded、OOM 和普通错误没有 workerd 中 pending WebSocket upgrade 进入 catch 并返回 503 的集成覆盖;所有逃逸异常的响应也未进一步分类。
#5500代码把 marker 放在 constructor、startup、auth/connect 的阶段边界;PR 以日志观察为验证方式没有自动断言 console 行在 reset、失败 startup 重试、多个 tab 交错时完整可见;PR 本身声明不需要测试。
#5503AgentChatContext.test.tsx:1365-1421 覆盖 9/10 次、30 秒清零和手动 reconnect;PR 另有本地 pnpm agent:dev 手测说明没有真实 PartySocket、Cloudflare DO reset、503/Retry-After 消费、多 tab 或 browser reload/rehydration 回归。

因此当前能确认的是“客户端状态机不会无限重连且不丢输入的代码路径成立”;不能直接推出“生产中的 DO 唤醒量、Sentry 事件量和用户恢复成功率已经下降”。后者需要观测与部署环境测试闭环。

和最近学习记录的关系#

最近的 #5496 学习报告 已经指出:public route 返回 503 只是入口止血,PartySocket 的重连放弃条件、Retry-After 实际消费和按 chat 的重连放大仍未落地。本次 #5503 正好补上“客户端何时停止”的一层,但没有改变 #5496 的 server-side contract,也没有把 Retry-After 解析成显式状态机。

更早的 #5199 学习报告 关注 DO 不能因最后连接关闭而被销毁。本次把它作为前置约束:人工 Reconnect 不是重新创建一个无历史的 chat,而是重新连接保留了 durable transcript 的同一个 chat DO。

本次新增视角有三点:

  1. “无限重试”是资源治理问题,不只是用户体验问题;客户端 retry predicate 是 DO 唤醒成本的一个控制阀。
  2. 自动重连的状态与用户消息队列必须同时设计;停止 socket 后再允许 composer 清空,会把恢复能力变成数据丢失。
  3. 诊断、HTTP 响应和客户端预算是三个独立层:#5500 告诉工程师死在哪里,#5496 告诉客户端可以稍后再试,#5503 决定此 tab 继续试多少次;任何一层都不能单独证明恢复成功。

我会怎么吸收#

  • 对 WebSocket/Actor 系统画出“连接事件 → 服务端对象 → 持久状态 → 客户端队列”的恢复表,明确每个 close/error 的责任方和终止条件。
  • 把 retry 设计成有预算的状态机:区分 terminal code、短暂失败、健康连接清零和人工 override,不把 maxRetries = Infinity 当作可靠性策略。
  • 在 SDK callback 顺序可能影响语义时,先用测试 helper 复现真实调用顺序,再把副作用放到 predicate 或生命周期的正确阶段。
  • 任何“停止发送”的 UI 状态都要同时检查输入保留、FIFO 队列、重连后的 flush 和导航副作用;否则网络修复可能制造业务数据丢失。
  • 将错误分类、诊断 marker 和用户恢复协议分别验证;纯 classifier 单测不能代替 workerd/真实 transport 集成测试。

边界、风险与未解问题#

代码事实#

  • #5503 只在当前 AgentActiveChat 挂载实例内保存 failure count;计数不是跨 tab、跨 reload 或跨设备共享的。
  • HEALTHY_CONNECTION_MS 只有在收到 onOpen 后、下一次 close 时才生效;它不是一个定时器,也不会随时间自动衰减 failure count。
  • #5496 的 catch 无条件返回 503;transientDoErrorMessage 的匹配结果只改变 Sentry warning/error 和 fingerprint,不能阻止应用异常进入重连。
  • #5500 记录阶段和耗时,但没有修复 storage deadline、减小 hydration 或改变 DO 的重启行为。

合理推断#

  • 对持续 reset 的 chat,#5503 会把浏览器侧的无限唤醒变成最多十次自动尝试,应该降低单个 tab 的重连放大;但多 tab 仍可同时各自消耗十次预算。
  • 健康连接清零能够避免早期网络事故永久污染会话,但“30 秒足够健康”的阈值是针对 #5473 观察到的约 11 秒 reset 经验,不是平台保证。
  • Retry-After 若未被 agents transport 消费,#5503 的实际节流仍由 SDK backoff 决定;因此报告不能把 HTTP header 写成已完成的跨层限流协议。

仍待确认#

  1. 在 workerd 中制造真实 DO storage reset,确认 pending WebSocket upgrade 的 503、PartySocket close event 和 shouldReconnectOnClose 的顺序与 mock 一致。
  2. 采集同一 chat 的连接持续时间、失败次数、手动 Reconnect 成功率、DO wake 次数和 Sentry fingerprint,验证十次阈值是否过松或过严。
  3. 决定是否把失败预算改为“时间窗口 + 次数”或按错误类型分别计数,以避免长时间偶发抖动与持续 reset 共用同一计数器。
  4. 覆盖多 tab、组件卸载重挂载、页面刷新、队列中多个 follow-up、Reconnect 与自然恢复同时发生的竞态。
  5. 评估 route catch 对未分类应用错误统一返回 503 的长期风险,必要时把 transient、auth、应用故障和资源过载拆成更窄的响应 contract。
  6. 评估 wakePhase 的日志量、chatId 访问控制和失败启动重试时 marker 覆盖范围,避免诊断数据本身成为新的成本或隐私问题。

候选说明#

本次候选窗口严格按 Australia/Melbourne:先查 2026-08-03,当天有 #5503、#5482、#5507、#5372、#5511、#5328 等未学习 PR。#5507 已在本轮之前的自动化记忆与 GitHub 日志中作为当天锚点完成,不能重复;#5482 属于 billing,#5372 属于 Google 登录安全,#5511 是相对孤立的 CODE HTML parse 缓存优化,#5328 是 update_design 画布尺寸契约,均不能比 #5503 更自然地承接上一轮 DO transient 入口问题。

#5503 能通过真实代码边界连接 #5496 的 routeAgentRequest 503、#5500 的 Agent wake marker,以及 #5199 的 DO 持久生命周期;四个 PR 的 merge commit 都已验证为当前 origin/main 祖先。#5500 位于 Melbourne 2026-08-02 00:05 的“昨天”窗口,但它是 #5503 直接依赖的诊断前置,因此没有扩大到更早日期。没有把 #5507、语言提示、billing 或文档编辑器改动拼进这条线。

GitHub 文档#

  • 报告文件:outputs/voyager-daily-pr-study/2026-08-03-pr-5503-agent-chat-reconnect-circuit-breaker.md
  • 索引文件:outputs/voyager-daily-pr-study/index.md
  • 目标仓库:joyehuang/ai-agent-field-notes
  • Voyager PR: #5503#5199#5496#5500
  • Voyager origin/main snapshot:c244ab6bba263d9293e031643666e0c5bf6271e1
  • Voyager anchor merge commit:911c99ff424039d81527deeb8420191abe911ae4
  • 目标仓库本次报告/index 的最终 commit 在推送后写入本地 study-log.jsonlgithubCommit 字段,并在自动化交接中给出。
  • feishu_doc_url: null;本报告仅以 GitHub Markdown 为持久化来源。

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

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

← Back