Repository navigation
fix(quota-retry): 事件处理入口统一 per-location 过滤(修多 location 重复续命) - #8
Merged
Merged
Conversation
## 问题/错误 同一 serve 内每个 location 一个插件实例,事件流是全 location 广播——同一事件每个实例都各处理一遍。 per-location 过滤此前只加在 session.retry.scheduled(心跳)分支上;续命分支(session.execution.failed → 注入轮次标记开新一轮)没有过滤:配额失败时非属主实例会落到 shouldContinue 默认策略,各自注入标记, 造成重复标记 / 重复续命(多 location 机器上每轮翻倍)。 ## 发现过程 用户侧日志 12:34:34 出现三条完全相同的 [ev] step.failed(sessionID / assistantMessageID / error / snapshot 全同)= 一次事件被 3 个实例各处理一遍。核查 master 代码确认只有 session.retry.scheduled 分支带 evLoc 过滤(该过滤是此前为「实测 3 实例 = 3 条重复标记」加上的),其余分支 (execution.failed / succeeded / interrupted、step.failed / started、inbox.enqueued)均无。 bus.publish 的 location 取自发布者上下文的 Location.Service(packages/core/src/bus.ts), 会话事件都带 location 信封,因此入口过滤是正确机制。 ## 实现方案 把 per-location 过滤上提到 handleEventInner 入口,一次覆盖所有事件分支;删除 session.retry.scheduled 分支内重复的过滤。 ## 实现过程 - packages/quota-retry/server.ts:handleEventInner 开头新增 evLoc 比对,不匹配直接 return; - 删除 retry.scheduled 分支内的 evLoc 过滤(保留 sid / at 校验)。 - bun test:quota-retry 44 pass / 0 fail。 ## 总结 入口级过滤避免「只修出现症状的那一个分支」的遗漏;事件带 location 信封是 bus 的统一行为, 后续新增分支无需各自再过滤。
yangyaofei
added a commit
that referenced
this pull request
Oct 20, 2026
## 问题/错误
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
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 * 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(quota-retry): dirCache 只缓存成功结果 + 4096 上限(#18 review r2) 查询失败(undefined)若被缓存 60s, 期间到达的 execution.failed(一次性事件)会被 误判非本 location 而静默丢续命。改为: 失败不写缓存(下一条无信封事件自然重试), 成功结果带 4096 条粗上限防无限增长; 卸载时随插件实例闭包一起回收。
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 事件的长流 (慢流场景)被误杀。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What / Why
同一 serve 内每个 location 一个插件实例,事件流是全 location 广播:同一事件每个实例都会收到一遍。
per-location 过滤此前只加在
session.retry.scheduled(心跳)分支上;续命分支(session.execution.failed→ 注入轮次标记开新一轮)没有过滤。配额失败时,非属主实例会落到shouldContinue默认策略(配额/限流类错误命中默认类型),各自注入一条轮次标记并开新一轮——多 location 机器上造成重复标记 / 重复续命(每轮翻倍)。用户侧日志 12:34:34 的三条完全相同的
[ev] step.failed(sessionID / assistantMessageID / error / snapshot 全同)即一次事件被 3 个实例各处理一遍。What changed
packages/quota-retry/server.tshandleEventInner入口新增 per-location 过滤:evLoc !== ctx.location.directory直接 return;session.retry.scheduled分支内重复的过滤(保留 sid / at 校验)。依据:
bus.publish的 location 取自发布者上下文的Location.Service(packages/core/src/bus.ts),会话事件都带 location 信封;心跳分支此前就靠同一机制工作。Validation
bun test(quota-retry):44 pass / 0 fail。Risks
bus.publish,只有显式global选项的事件没有 location,本插件订阅的session.*事件都由 location 作用域内的服务发布,均带 location。