Skip to content

fix(quota-retry): 事件归属按 session 反查, 修 10 次耗尽后不续命 - #18

Merged
yangyaofei merged 5 commits into
masterfrom
fix/quota-retry-ownership
Oct 20, 2026
Merged

yangyaofei merged 5 commits into
masterfrom
fix/quota-retry-ownership

Conversation

@yangyaofei

Copy link
Copy Markdown
Owner

问题

原生 10 次重试耗尽 → session.execution.failed → 应注入 synthetic 标记开新一轮, 但从未发生(生产 DB 0 条 msg_quota-retry-round-*)。

根因

宿主 2.0.32 的 execution.* / usage.updated 由服务器级执行队列发布, 不带 location 信封(隔离环境实测: step.*/inbox.*/retry.scheduled 带, execution.* 为空)。原过滤 evLoc !== selfDir 把它们全部静默丢弃 → 续命/轮次清零全部变死代码, 无任何报错。

生产事故: 2026-10-20 ses_ea9e725d5ffeno0tUqw9JbYGmE 429 后 90 秒 failed。

修复

  1. 归属双路(core.resolveEventOwner, 纯函数+单测): 有信封(高频 location 级事件) → 信封是权威, 快路径不查库; 无信封(execution.*) → 按 sessionID 反查 ctx.session.get() 的 info.location.directory(注意: Session.Info 无顶层 directory)再比; 反查失败必须 dbg 记录, 不允许静默 return。
  2. 续命标记幂等键: 失败 step 的 assistantMessageID(拿不到退回轮次号)替代时间戳 —— 同一次失败多实例并发注入时第二个得到 synthetic 冲突, 视为已续过, 幂等跳过; 不用纯轮次号(实例重建后 rounds 归零会与旧标记撞 id, 导致"以为续过"而不再唤醒)。

验证(隔离环境: 假 HOME + always-429 mock)

  • standalone CLI 拓扑: round 1 失败 → 28ms 后标记落库 → round 2 assistant 创建(CLI 随后自带退出属伪象)。
  • serve 拓扑(生产同构): 循环续命 3+ 轮, 每轮 10 次原生重试, 3 个标记 id 各不相同(每轮失败消息不同), 第 4 轮自动启动。
  • bun test 46 pass(含 4 个归属回归用例)。

备注

保活(location TTL sweeper)是另一件事, 在后续 PR 单独发; 本 PR 只修续命。

## 问题/错误
原生 10 次重试耗尽 → session.execution.failed → 续命应注入 synthetic 开新一轮,
但从未发生(生产 DB 0 条 msg_quota-retry-round-*, 失败会话只有 user/assistant/idle 三条)。

## 发现过程
隔离环境(fake HOME + mock 429)复现, 给插件加事件日志后抓到:
  session.step.failed       loc=/mnt/.../note      ← 带 location
  session.execution.failed  loc=                   ← 空
宿主 2.0.32 的 execution.*/usage.updated 由服务器级执行队列发布, 发布点在 location
作用域之外, 信封为空。原过滤 `evLoc !== selfDir` 把它们全部静默丢弃 →
续命/轮次清零/心跳 disarm 全部变死代码, 且无任何报错。
(生产事故: 2026-10-20 ses_ea9e725d5ffeno0tUqw9JbYGmE 429 后 90 秒 failed)

## 实现方案
1. 归属判据改为双路(core.resolveEventOwner, 纯函数+单测):
   - 有信封(location 级事件, 高频) → 信封是权威, 快路径不查库;
   - 无信封(execution.*) → 按 sessionID 反查 ctx.session.get().directory 再比;
   - 反查失败/不匹配必须 dbg 记录, 不允许静默 return。
2. 续命标记幂等键: assistantMessageID(拿不到退回轮次号)替代时间戳 ——
   同一次失败多实例并发注入时, 第二个得到 synthetic 冲突, 视为已续过, 幂等跳过。
   不用纯轮次号: 实例重建后 rounds 归零会与旧标记撞 id, 导致"以为续过"而不再唤醒。

## 实现过程
- core.ts: +resolveEventOwner(带信封规则注释); core.test.ts: +4 用例(含回归)。
- server.ts: handleEventInner 归属双路 + [owner] 日志; step.failed 记
  lastFailureMessage; resetSession 清理; redoRound 用确定性 id + 冲突幂等跳过。

## 总结
修复"10 次耗尽后不续命"; 事件形状再变化时最坏是日志里出现 [owner] 跳过记录,
不会再静默失效。心跳/保活不在本 PR(上游 anomalyco/opencode#57416 已合并等 release)。

@yangyaofei yangyaofei left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

方向和判定都对,我按 2.0.32 的源码逐条核过:

  • session.execution.failed 的 schema 只有 {...Base, error},确实没有 assistantMessageID(packages/schema/src/session-event.ts:247);session.step.failed 有(:373-378),所以把幂等键记在 step.failed 分支是对的。
  • ctx.session.get({ sessionID }) 的入参形状正确(packages/client/src/effect/generated/client.ts:455)。
  • Session.Info 确实没有顶层 directory,目录在 info.location.directory(packages/schema/src/session.ts:53)。
  • Execution.Failed 的发布点只传了 releaseOnCommit(sessionID),没有 location 上下文(packages/core/src/session/execution.ts:139),与"execution.* 无信封"一致;Step.Failed 从 runner 里发(publish-llm-event.ts:428),带信封。

需要处理

  1. r${round} 兜底和注释自相矛盾。 注释说"不用纯轮次号,实例重建后会撞 id",但 failureKey 拿不到 assistantMessageID 时用的正是 r${round}。step.failed 保证有该字段,所以这是边角,但一旦走到就是"撞 id → 判为已续 → 不唤醒",静默失效。建议:拿不到就带进程内唯一后缀(宁可重复一次,也不要静默丢),或直接不注入并 dbg 报错。

  2. lastFailureMessage 没有在 execution.started 清理。 现在只在 succeeded 清。如果某个 turn 没经过 step.failed 就失败,下一轮会拿到上一轮的旧 id 作为幂等键,可能误判"已续过"。建议在 execution.started / step.started 一并清。

  3. 每个无信封事件都会 session.get 一次(含 usage.updated)。多 location 部署下这是热路径。建议加一个 sid -> dir 的短 TTL 缓存(或至少只在 execution.* / usage.updated 上查)。

非阻塞

  • synthetic 冲突靠 /conflict|already exists/i 匹配错误文案,比较脆;能拿到 Session.SyntheticConflictError 的 tag 就按 tag 判断,拿不到再退回正则。
  • [owner] skip by session dir 在多 location 下每个事件一行,建议只在 debug 开关下打。
  • PR 描述里可以补一句:这个过滤是 #8 引入的回归(不是宿主变更),方便以后追溯。

修完 1、2 之后我认为可以合。

@yangyaofei

Copy link
Copy Markdown
Owner Author

关于幂等的补充意见(汇总)

幂等到底需不需要

归属判定修好之后,正常情况不需要:一个会话只属于一个 location,只有一个实例会处理 execution.failed。幂等只兜两种边角:

  • 同一目录被两个 serve 同时驱动(之前踩过:测试 serve + app 托管 serve);
  • 事件流重连后重放同一条 execution.failed。

如果没有这两种场景,幂等可以整个删掉,代码更简单,也不会有"误判已续"的风险。

重复注入可以接受

如果两个实例各注入一条,后果是:多一条标记、多一个空 turn(前一个成功后,排队的标记再跑一次),rounds 跳号。功能不坏。

对比两种失败:

失败方式 后果
重复注入 多一条标记 + 一次多余模型调用,会话还能继续
误判"已续过" 什么都不发生,会话直接 failed,且无任何报错

后者正是本 PR 要修的故障形态。所以:宁可重复,也不能静默不续命。

r${round} 兜底建议删掉

注释说"不用纯轮次号,实例重建后会撞 id",但兜底用的正是 r${round}:

  • 插件/进程重启后内存里的 rounds 归零;
  • 下一轮失败算出 ...-r1,和上次留下的 r1 标记撞 id;
  • 被当成"已经续过" → 跳过 → 会话 failed。

不是无限重启,重启一次就可能撞上。

建议:

  1. 有 assistantMessageID → 照常拿它去重(同一次失败才会算出同一个 id,正是要挡的);
  2. 没有 → 不要猜轮次号,生成唯一 id 注入(允许重复)并 dbg 记录;
  3. 在 execution.started 清 lastFailureMessage(现在只在 succeeded 清;如果某个 turn 没经过 step.failed 就失败,下一轮会拿到旧 id,同样误判"已续")。

原则:幂等可以留,但不能把"无法确定"当成"已经做过"。

非阻塞

  • 每个无信封事件都会 session.get 一次(含 usage.updated),多 location 下是热路径,建议加 sid -> dir 的短 TTL 缓存;
  • synthetic 冲突靠正则匹配错误文案较脆,能拿 Session.SyntheticConflictError 的 tag 就按 tag 判断;
  • [owner] skip by session dir 在多 location 下每事件一行,建议只在 debug 开关下打;
  • PR 描述可以补一句:这个过滤是 fix(quota-retry): 事件处理入口统一 per-location 过滤(修多 location 重复续命) #8 引入的回归,不是宿主行为变化。

## 问题/错误
review 指出三处: ① 幂等兜底键 r{round} 与注释自相矛盾(纯轮次号撞旧标记→静默丢续命);
② lastFailureMessage 只在 succeeded 清, 未经 step.failed 的失败会复用上一 turn 旧 id;
③ 无信封事件逐条 session.get, 全 location 广播下是热路径。

## 发现过程
外部 review 逐条核过 2.0.32 源码(execution.failed 无 assistantMessageID /
Session.Info 无顶层 directory / Execution.Failed 发布点无 location), 判定成立。

## 实现方案
① 兜底键改 轮次号+时间戳: 宁可多续一轮, 不可能撞旧标记;
② execution.started 时清 lastFailure + lastFailureMessage(新执行不该继承旧键);
③ sid→dir 缓存 60s TTL(顺带兜住 session.move 陈旧性); 冲突判定 tag 优先、正则兜底。

## 实现过程
server.ts: handleEventInner 加 dirCache; 新增 execution.started 清理分支;
redoRound 兜底键与注释改写; synthetic 冲突匹配加 _tag。

## 总结
三处均闭环; [owner] 日志保持 logFile 开关门控(默认关); PR 描述补 #8 回归溯源。
@yangyaofei

Copy link
Copy Markdown
Owner Author

Review 三条"需要处理"已在 ba600e4 闭环:

  1. 兜底键自相矛盾 → 改为 r{round}-{ts}: 拿不到 assistantMessageID 时宁可多续一轮(重复标记的代价 = 多跑一轮), 不用可能撞旧标记的纯轮次号(代价 = 静默丢续命)。注释同步改写, 不再自相矛盾。
  2. 残留键 → execution.started 时清 lastFailure + lastFailureMessage(新执行不继承旧 turn 的键)。
  3. 热路径 → sid→dir 缓存, 60s TTL(顺带兜住 session.move 的目录陈旧性)。

非阻塞两条: 冲突判定改为 _tag 优先、正则兜底; [owner] 日志维持现状——dbg 本身由 logFile 配置门控, 默认关闭, 已满足"只在 debug 开关下打"。

溯源补充: 这个过滤是 #8 (b63b9c9, 修多 location 重复续命)引入的回归——当时假设所有会话事件都带 location 信封, 2.0.32 的 execution.* 发布点在 location 作用域之外不满足该假设。

@yangyaofei yangyaofei left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

第二轮 review(针对 ba600e4d)

三处已按上一轮意见改好,我逐条核过:

  • 幂等兜底键:r${round}-${Date.now().toString(36)} —— 拿不到 assistantMessageID 时不再用可能撞旧标记的纯轮次号,改为"允许重复、但不静默丢",方向正确。
  • 冲突判定:tag + message 一起匹配,能识别 Session.SyntheticConflictError。
  • 残留清理:execution.started 里清 lastFailure / lastFailureMessage。
  • 归属反查缓存:sid -> dir 60s TTL,避免每个无信封事件查一次。

还剩一处需要改(会重演"静默不续命")

dirCache 现在把失败结果也缓存了:

sessionDir = rawDir === undefined || rawDir === null ? undefined : String(rawDir)
dirCache.set(sid, { dir: sessionDir, at: Date.now() })

如果某次 session.get 失败(超时、实例刚启动),sessionDir 是 undefined,却会以 60s TTL 缓存下来。这 60 秒内到达的 execution.failed 会被判成"非本 location"直接 skip —— 又是一次静默丢续命,而且没有任何重试机会(execution.failed 只发一次)。

建议:

  • 只缓存成功结果;失败不写缓存(或 TTL 1–2s),并 dbg 记录;
  • 顺手给 dirCache 一个上限/卸载时清理,避免按会话数无限增长。

非阻塞

  • resolveEventOwner 的"信封优先"语义现在是正确的(有信封就不查库),测试也覆盖了冲突场景。

除上面这一条外,我认为 #18 可以合。

@yangyaofei

Copy link
Copy Markdown
Owner Author

关于"失败结果被缓存"的说明(补上一轮 review)

这不是可读性问题,是功能问题,只是概率低。把机制写清楚:

session.get 失败 → sessionDir = undefined → 以 60s TTL 缓存。这 60 秒内,所有没有 location 信封的事件都会被判为"不属于本实例"并 return:

被误跳过的事件 后果
session.execution.failed 不续命,会话直接 failed(正是本 PR 要修的故障)
session.execution.succeeded 轮次状态不清零,下一 turn 轮次号继续增长;inRescue 保持为真
session.execution.started 不进 activeExecutions(#19 的保活会漏掉这次执行)

其中 execution.failed 只发一次,被吞掉没有任何补救机会。

什么时候会失败

session.get 是同进程的 DB 读,平时几乎不失败。可能失败的时机:DB 忙/锁、服务正在关停、会话刚被删除。

要区分两种情况:

  • 会话确实不存在 / 不属于本 location:失败是预期内的,缓存反而避免重复的失败查询,是合理的;
  • 瞬时故障:才是有害的——60 秒缓存会把"一次抖动"放大成"一分钟内所有终态事件全部丢失"。

建议

  1. 失败时在同一个事件内立即重试一次,仍失败才跳过;
  2. 区分错误类型:not found 可以缓存(它确实不属于本实例),瞬时错误不缓存,或 TTL 降到 1–2 秒;
  3. 任何跳过都 dbg 记录,不要静默。

这样既不会放大抖动,也不会在多 location 部署下反复打失败查询。

查询失败(undefined)若被缓存 60s, 期间到达的 execution.failed(一次性事件)会被
误判非本 location 而静默丢续命。改为: 失败不写缓存(下一条无信封事件自然重试),
成功结果带 4096 条粗上限防无限增长; 卸载时随插件实例闭包一起回收。
@yangyaofei

Copy link
Copy Markdown
Owner Author

成立, 已修(7db9bf8):

  • 只缓存成功结果。查询失败(undefined)不写缓存 —— 下一条无信封事件(usage.updated 等周期性到达)会自然重查, 等于免费重试; execution.failed 一次性事件不再可能被 60s 的坏缓存判死。
  • 4096 条粗上限。到达即整表清空(插入序 Map 没有廉价的逐条 LRU, 清空一次代价可忽略, 且 60s TTL 本来就让它是个"近期热点"缓存而非状态)。插件卸载时随实例闭包整体回收, 无需显式清理。

yangyaofei added a commit that referenced this pull request Oct 20, 2026

@yangyaofei yangyaofei left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

第三轮 review(针对 7db9bf86)

dirCache 的问题已按建议修好:

  • 只缓存成功结果 —— sessionDir === undefined 时不再写缓存,下一条无信封事件会重新查,等于免费重试;
  • 加了 4096 的粗上限,超过时整体清空,避免按会话数无限增长(插入序无法逐条淘汰,这个取舍可以接受);
  • 失败仍会 dbg,不静默。

一个可选建议(非阻塞)

execution.failed 是一次性事件:如果它到达时那一次 session.get 恰好失败,仍然会丢掉这一次续命(虽然不再被放大 60 秒)。如果想彻底闭合,可以在失败时于同一事件内立即重试一次(第二个请求通常就成功了)。不做也不影响本次合并——只是把窗口从"60 秒"缩到"一次查询"。

除此之外,我认为 #18 可以合。

@yangyaofei
yangyaofei merged commit 1511d14 into master Oct 20, 2026
yangyaofei added a commit that referenced this pull request Oct 20, 2026
两处冲突均取本侧(keepalive 分支): resetSession 不再 disarmHeartbeat(per-sid 心跳
已被 location 级 sweeper 取代); execution.started = #18 review 的清理 + 保活集合
录入(超集)。#17 的 onHttpResponse 注释与 #18 的归属修复已在 master 侧, 自动合并保留。
bun test 50 pass。
yangyaofei added a commit that referenced this pull request Oct 20, 2026
* fix(quota-retry): 事件归属按 session 反查 + 续命标记幂等键, 修 10 次耗尽后不续命

## 问题/错误
原生 10 次重试耗尽 → session.execution.failed → 续命应注入 synthetic 开新一轮,
但从未发生(生产 DB 0 条 msg_quota-retry-round-*, 失败会话只有 user/assistant/idle 三条)。

## 发现过程
隔离环境(fake HOME + mock 429)复现, 给插件加事件日志后抓到:
  session.step.failed       loc=/mnt/.../note      ← 带 location
  session.execution.failed  loc=                   ← 空
宿主 2.0.32 的 execution.*/usage.updated 由服务器级执行队列发布, 发布点在 location
作用域之外, 信封为空。原过滤 `evLoc !== selfDir` 把它们全部静默丢弃 →
续命/轮次清零/心跳 disarm 全部变死代码, 且无任何报错。
(生产事故: 2026-10-20 ses_ea9e725d5ffeno0tUqw9JbYGmE 429 后 90 秒 failed)

## 实现方案
1. 归属判据改为双路(core.resolveEventOwner, 纯函数+单测):
   - 有信封(location 级事件, 高频) → 信封是权威, 快路径不查库;
   - 无信封(execution.*) → 按 sessionID 反查 ctx.session.get().directory 再比;
   - 反查失败/不匹配必须 dbg 记录, 不允许静默 return。
2. 续命标记幂等键: assistantMessageID(拿不到退回轮次号)替代时间戳 ——
   同一次失败多实例并发注入时, 第二个得到 synthetic 冲突, 视为已续过, 幂等跳过。
   不用纯轮次号: 实例重建后 rounds 归零会与旧标记撞 id, 导致"以为续过"而不再唤醒。

## 实现过程
- core.ts: +resolveEventOwner(带信封规则注释); core.test.ts: +4 用例(含回归)。
- server.ts: handleEventInner 归属双路 + [owner] 日志; step.failed 记
  lastFailureMessage; resetSession 清理; redoRound 用确定性 id + 冲突幂等跳过。

## 总结
修复"10 次耗尽后不续命"; 事件形状再变化时最坏是日志里出现 [owner] 跳过记录,
不会再静默失效。心跳/保活不在本 PR(上游 anomalyco/opencode#57416 已合并等 release)。

* debug: 归属反查失败时记录具体错误

* fix: Session.Info 无顶层 directory, 目录在 info.location.directory

* feat(quota-retry): location 保活 sweeper 对齐上游 #57416 保护集(keepalive 开关)

* fix(quota-retry): 按 PR #18 review 修三处: 幂等兜底键/残留键清理/归属反查缓存

## 问题/错误
review 指出三处: ① 幂等兜底键 r{round} 与注释自相矛盾(纯轮次号撞旧标记→静默丢续命);
② lastFailureMessage 只在 succeeded 清, 未经 step.failed 的失败会复用上一 turn 旧 id;
③ 无信封事件逐条 session.get, 全 location 广播下是热路径。

## 发现过程
外部 review 逐条核过 2.0.32 源码(execution.failed 无 assistantMessageID /
Session.Info 无顶层 directory / Execution.Failed 发布点无 location), 判定成立。

## 实现方案
① 兜底键改 轮次号+时间戳: 宁可多续一轮, 不可能撞旧标记;
② execution.started 时清 lastFailure + lastFailureMessage(新执行不该继承旧键);
③ sid→dir 缓存 60s TTL(顺带兜住 session.move 陈旧性); 冲突判定 tag 优先、正则兜底。

## 实现过程
server.ts: handleEventInner 加 dirCache; 新增 execution.started 清理分支;
redoRound 兜底键与注释改写; synthetic 冲突匹配加 _tag。

## 总结
三处均闭环; [owner] 日志保持 logFile 开关门控(默认关); PR 描述补 #8 回归溯源。

* fix: 合并重复的 execution.started handler(merge 后第二个永远不可达)

* fix(quota-retry): dirCache 只缓存成功结果 + 4096 上限(#18 review r2)

查询失败(undefined)若被缓存 60s, 期间到达的 execution.failed(一次性事件)会被
误判非本 location 而静默丢续命。改为: 失败不写缓存(下一条无信封事件自然重试),
成功结果带 4096 条粗上限防无限增长; 卸载时随插件实例闭包一起回收。

* feat(quota-retry): pending 条目超龄剔除 + 目标存活校验(#19 review)

## 问题/错误
review 指出: activeExecutions/runningShells 只在终态事件删除, 终态事件一旦丢失
(断连/会话被删/插件重载错位)条目永久残留 → 永久保活 + 每 45 分钟一条标记持续膨胀。
事件流推导没有宿主内存注册表的天然自清理, 必须自己兜底。

## 实现方案
采纳建议 1 + 目标校验组合: ① 条目带时间戳, 超 MAX_PENDING_AGE_MS(12h)在 sweep 时
剔除(续命轮每轮新 execution.started 自然刷新, 不受影响); ② 每 sweep 对选中目标做
一次 session.get, 会话已删 → 剔除全部相关条目并跳过(不给死会话发标记)。

## 实现过程
core.ts: +MAX_PENDING_AGE_MS(带取舍注释: 12h+ 真实后台 shell 失去保护, dbg 可见,
两害取其轻)。server.ts: 两个集合改带时间戳的 Map; +pruneStale/dropSession;
sweep 循环先剔除超龄, 再校验目标存活。

## 总结
陈旧条目最坏存活 12h 后自动清出; 噪音抑制建议(仅静默时发)被否: 插件侧无法区分
durable/ephemeral 事件, 用"任意事件"抑制心跳会让 60min+ 无 durable 事件的长流
(慢流场景)被误杀。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant