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 commit | 911c99ff424039d81527deeb8420191abe911ae4 |
| 改动范围 | 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 e45c67d59e188fe5b2d032d239424ca6e9c86ad8 | Agent DO onClose / storage | 删除最后连接关闭时根据 this.messages 调 this.destroy() 的逻辑;连接断开不再代表对象为空,显式 destroyChat() 才是删除入口。 |
| public route 入口止血 | #5496 ↗,2026-08-01,merge 68202ffb12ff1cc4e7f0dc2183d43206cdaf33ba | Worker entry / HTTP / Sentry | 捕获 routeAgentRequest 的逃逸异常,返回 503 和 Retry-After: 5;transient 分类只改变 Sentry level/fingerprint,不决定响应码。 |
| 唤醒路径诊断 | #5500 ↗,2026-08-02 00:05,merge 81dd38fb318ad574bf2bbb5e2a29e06ad9f3cd91 | Agent constructor / onStart / onConnect / Workers Logs | 用未经过 Sentry 批处理的 JSON console.log 记录 constructing、startup、connect 的开始/结束和耗时,使 storage deadline reset 之后仍能知道最后进入了哪一阶段。 |
| 今日锚点:客户端重连熔断 | #5503 ↗,2026-08-03,merge 911c99ff424039d81527deeb8420191abe911ae4 | Agent chat context / UI / queue | 30 秒内未存活的连接计数;连续 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- 浏览器入口。
AgentActiveChat用useAgent建立以chatId为 name 的连接。#5503 通过shouldReconnectOnClose拿到 SDK 的 close event;这一步发生在onClose前,所以返回false才能真正阻止下一次 socket/DO 唤醒。onClose只负责把 UI phase 设成reconnecting或disconnected。 - Worker 与 DO 边界。
backend/workers/agent/src/index.ts:77-117在routeAgentRequest外层捕获逃逸异常。已知 platform transient 会被transientDoErrorMessage归一为 warning/fingerprint;未知异常、memory-limit reset 仍以 error 记录,但响应仍统一是 503。这是观测分类与恢复协议的解耦。 - DO 唤醒/连接。
backend/workers/agent/src/agent.ts:631-727在 constructor、onStart和onConnect周围加 marker。#5500 记录的是“故障发生在哪”,不是修复 storage deadline;#5199 保证断开后 transcript/state 仍由 storage/session 保留,显式destroyChat才清除。 - 客户端停止条件。
AgentChatContext.tsx:1060-1073在每次 close 前累加短连接失败,30 秒以上的健康连接会清零。达到十次后设置hasGivenUp,onClose将 phase 置为disconnected;AgentChat.tsx:264-282由retry是否存在决定显示Reconnect还是“刷新页面”。 - 消息安全。 连接停止时
submitUserMessage在AgentChatContext.tsx:1262-1266返回false,不让 composer 清空输入;队列 flush 在:1323-1331对 disconnected 直接暂停,避免把消息标记成已发但永远没有 socket 消费。
关键代码#
1. #5199:删除错误的 DO 自动销毁触发器#
来源:PR #5199 diff ↗,backend/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 #5496 ↗,backend/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 #5500 ↗,backend/workers/agent/src/wakePhase.ts:15-21 与 agent.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 }));
}plaintextconstructor 先记 constructing,super() 返回后记 constructed,包装的 onStart 在 finally 记 startup:done,onConnect 则记 connect:begin/authed/done。直接走 console.log 是因为 storage deadline reset 可能连 Sentry 的批量事件、span 和 logger buffer 一起丢掉。这个 PR 不减少唤醒成本,只把“在哪一阶段死掉”从不可见变成可查询。
4. #5503:计数放在 SDK 的 reconnect predicate,而不是 onClose#
来源:PR #5503 ↗,packages/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;
},plaintextCONNECT_FAILURE_LIMIT = 10、HEALTHY_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 diff ↗,AgentChat.tsx:256-282、AgentChatContext.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 的shouldReconnectOnClose和agent.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。 |
| #5496 | transientDoError.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 本身声明不需要测试。 |
| #5503 | AgentChatContext.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。
本次新增视角有三点:
- “无限重试”是资源治理问题,不只是用户体验问题;客户端 retry predicate 是 DO 唤醒成本的一个控制阀。
- 自动重连的状态与用户消息队列必须同时设计;停止 socket 后再允许 composer 清空,会把恢复能力变成数据丢失。
- 诊断、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 写成已完成的跨层限流协议。
仍待确认#
- 在 workerd 中制造真实 DO storage reset,确认 pending WebSocket upgrade 的 503、PartySocket close event 和
shouldReconnectOnClose的顺序与 mock 一致。 - 采集同一 chat 的连接持续时间、失败次数、手动 Reconnect 成功率、DO wake 次数和 Sentry fingerprint,验证十次阈值是否过松或过严。
- 决定是否把失败预算改为“时间窗口 + 次数”或按错误类型分别计数,以避免长时间偶发抖动与持续 reset 共用同一计数器。
- 覆盖多 tab、组件卸载重挂载、页面刷新、队列中多个 follow-up、Reconnect 与自然恢复同时发生的竞态。
- 评估 route catch 对未分类应用错误统一返回 503 的长期风险,必要时把 transient、auth、应用故障和资源过载拆成更窄的响应 contract。
- 评估
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/mainsnapshot:c244ab6bba263d9293e031643666e0c5bf6271e1 - Voyager anchor merge commit:
911c99ff424039d81527deeb8420191abe911ae4 - 目标仓库本次报告/index 的最终 commit 在推送后写入本地
study-log.jsonl的githubCommit字段,并在自动化交接中给出。 feishu_doc_url: null;本报告仅以 GitHub Markdown 为持久化来源。