Repository navigation
fix(quota-retry): 事件归属按 session 反查, 修 10 次耗尽后不续命 - #18
Conversation
## 问题/错误 原生 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
left a comment
There was a problem hiding this comment.
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),带信封。
需要处理
-
r${round}兜底和注释自相矛盾。 注释说"不用纯轮次号,实例重建后会撞 id",但failureKey拿不到 assistantMessageID 时用的正是r${round}。step.failed保证有该字段,所以这是边角,但一旦走到就是"撞 id → 判为已续 → 不唤醒",静默失效。建议:拿不到就带进程内唯一后缀(宁可重复一次,也不要静默丢),或直接不注入并dbg报错。 -
lastFailureMessage没有在execution.started清理。 现在只在succeeded清。如果某个 turn 没经过step.failed就失败,下一轮会拿到上一轮的旧 id 作为幂等键,可能误判"已续过"。建议在execution.started/step.started一并清。 -
每个无信封事件都会
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 之后我认为可以合。
关于幂等的补充意见(汇总)幂等到底需不需要归属判定修好之后,正常情况不需要:一个会话只属于一个 location,只有一个实例会处理
如果没有这两种场景,幂等可以整个删掉,代码更简单,也不会有"误判已续"的风险。 重复注入可以接受如果两个实例各注入一条,后果是:多一条标记、多一个空 turn(前一个成功后,排队的标记再跑一次), 对比两种失败:
后者正是本 PR 要修的故障形态。所以:宁可重复,也不能静默不续命。
|
## 问题/错误
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 回归溯源。
|
Review 三条"需要处理"已在 ba600e4 闭环:
非阻塞两条: 冲突判定改为 溯源补充: 这个过滤是 #8 (b63b9c9, 修多 location 重复续命)引入的回归——当时假设所有会话事件都带 location 信封, 2.0.32 的 |
yangyaofei
left a comment
There was a problem hiding this comment.
第二轮 review(针对 ba600e4d)
三处已按上一轮意见改好,我逐条核过:
- 幂等兜底键:
r${round}-${Date.now().toString(36)}—— 拿不到assistantMessageID时不再用可能撞旧标记的纯轮次号,改为"允许重复、但不静默丢",方向正确。 - 冲突判定:
tag + message一起匹配,能识别Session.SyntheticConflictError。 - 残留清理:
execution.started里清lastFailure/lastFailureMessage。 - 归属反查缓存:
sid -> dir60s 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 可以合。
关于"失败结果被缓存"的说明(补上一轮 review)这不是可读性问题,是功能问题,只是概率低。把机制写清楚:
其中 什么时候会失败
要区分两种情况:
建议
这样既不会放大抖动,也不会在多 location 部署下反复打失败查询。 |
查询失败(undefined)若被缓存 60s, 期间到达的 execution.failed(一次性事件)会被 误判非本 location 而静默丢续命。改为: 失败不写缓存(下一条无信封事件自然重试), 成功结果带 4096 条粗上限防无限增长; 卸载时随插件实例闭包一起回收。
|
成立, 已修(7db9bf8):
|
yangyaofei
left a comment
There was a problem hiding this comment.
第三轮 review(针对 7db9bf86)
dirCache 的问题已按建议修好:
- 只缓存成功结果 ——
sessionDir === undefined时不再写缓存,下一条无信封事件会重新查,等于免费重试; - 加了 4096 的粗上限,超过时整体清空,避免按会话数无限增长(插入序无法逐条淘汰,这个取舍可以接受);
- 失败仍会
dbg,不静默。
一个可选建议(非阻塞)
execution.failed 是一次性事件:如果它到达时那一次 session.get 恰好失败,仍然会丢掉这一次续命(虽然不再被放大 60 秒)。如果想彻底闭合,可以在失败时于同一事件内立即重试一次(第二个请求通常就成功了)。不做也不影响本次合并——只是把窗口从"60 秒"缩到"一次查询"。
除此之外,我认为 #18 可以合。
* 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 事件的长流 (慢流场景)被误杀。
问题
原生 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_ea9e725d5ffeno0tUqw9JbYGmE429 后 90 秒 failed。修复
core.resolveEventOwner, 纯函数+单测): 有信封(高频 location 级事件) → 信封是权威, 快路径不查库; 无信封(execution.*) → 按 sessionID 反查ctx.session.get()的info.location.directory(注意:Session.Info无顶层directory)再比; 反查失败必须 dbg 记录, 不允许静默 return。assistantMessageID(拿不到退回轮次号)替代时间戳 —— 同一次失败多实例并发注入时第二个得到 synthetic 冲突, 视为已续过, 幂等跳过; 不用纯轮次号(实例重建后 rounds 归零会与旧标记撞 id, 导致"以为续过"而不再唤醒)。验证(隔离环境: 假 HOME + always-429 mock)
bun test46 pass(含 4 个归属回归用例)。备注
保活(location TTL sweeper)是另一件事, 在后续 PR 单独发; 本 PR 只修续命。