真 bug 合集:向量库探活误报 + edit 降级漏能力位 + 群聊沉默窗口不撤销 + 文案 #98

Merged
Yushu merged 3 commits from fix/93-real-bug-batch into main 2026-07-29 10:05:12 +00:00
Member

Closes #93

五处独立小修。其中 AC 第 4 条的前提经查证不成立、改动已撤回,另有一处 AC 点名的接线位置被换掉(有实测复现支撑),两处都在下面说明。

1. 向量库探活把 property 当函数调用 → /diagnose 恒误报

ping_vector_store 写成 await self._qdrant().get_collections(),而 _qdrant@property。全文件 11 处 self._qdrant 只有这一处多了括号。_probe 宽捕 Exception 只回类名,于是真实部署上向量库永远 down、理由还是个具误导性的 TypeError

没被发现的原因是 PersistentStorage.ping_vector_store 从未被执行过——唯一相关测试用的是自定义假 storage。新增 tests/test_diagnostics_storage.py,两套实现共用同一份契约断言。变异验证:把括号加回去,persistent 参数立刻红。

2. _handle_edit 的降级路径不查 capabilities.self_recall

降级 = 撤回旧消息 + 重发新内容,所以它self_recall_handle_self_recall 有这道 fail-closed 闸(issue #20 修的就是这个),edit 那扇门原样漏了出来。而 edit=False & self_recall=FalseCapabilitySet缺省组合、edit 工具对模型恒可见——不声明能力位的平台上这就是默认行为。

同时修测试替身:fake_egress.recall() 此前不查能力位就记账,所以就算有人写了这条组合的用例也不会红。(规格轴逐条核对了 runtime_loop.py 全部 10 处 self._egress.*,确认 edit 降级是最后一个洞。)

3. 沉默窗口在群聊里不撤销 → 恰好在抢话 (接线位置与 AC 不同)

_silence_window.interrupt() 唯一调用点在 _handle_reactive_message 里,而那个 handler 注册在 on_message(rule=to_me())——群聊第三方发言不撤销待发消息,恰恰是这个机制要治的那件事。私聊因 to_me() 恒真而碰巧正确。

AC 点名 @event_postprocessor,实现用的是 @event_preprocessor,因为 postprocessor 位置有一条实测可复现的竞态:它跑在 _handle_reactive_message 已经 create_task(_debounced_flush(...)) 之后,只要那个 flush 抢先注册好 asyncio.Event,interrupt 就会撤掉本轮自己的回复。

复现:ARISE_DEBOUNCE_WINDOW_SECONDS=0test_gating_e2e.py + test_walking_skeleton.py

版本 结果
@event_postprocessor 5 failed(全是"预期发送没发生")
钩子改成直接 return(对照组) 8 passed
@event_preprocessor 8 passed

生产默认的去抖窗口只是让它需要事件循环卡住才触发,不是让它消失——实际表现就是全量测试在高负载批次里偶发红(我在实现期真撞到过两批)。已固化成回归测试 test_the_interrupt_does_not_cancel_the_send_it_triggered;变异验证:改回 postprocessor 时它立刻红、同文件另两条仍绿。

preprocessor 的额外风险(抛异常时 NoneBot return False 丢弃整条事件,所有插件都收不到)由两轴 review 指出,已就地用 try/except + logger.exception 兜住——本特性失灵最多少撤销一次待发,不该把整个 bot 变哑。

新增 tests/test_silence_window_wiring.py:走真实 handle_event、不装任何 matcher,断言的是真实 wait() 的返回值而不是"interrupt 被调过",并带一条"别的 chat 不受影响"的反向对照。

4. [tool.pytest] 节名——AC 前提不成立,改动已撤回

ticket 说"pytest 只读 [tool.pytest.ini_options],于是整节配置静默失效"。在本仓钉的 pytest 9.1.1 上不成立[tool.pytest] 是 pytest 9 的原生 TOML 形式、也是官方推荐写法ini_options 才是向后兼容的字符串形式,两者不能并存(_pytest/config/findpaths.py 里同时出现直接报错)。

实测原始状态([tool.pytest])下:config.inipath 已指向 pyproject.toml、config.getini("asyncio_mode") 已是 auto--strict-markers 已经在拦没注册的 marker。这一节一直是生效的,没有任何配置在静默失效。

顺带自省:我原来当作"改名生效"的两条证据(头部出现 configfile: 行 + strict-marker 探针变红)都是改名之后才测的,改名之前同样成立——等于没证明任何变化。是两轴 review 逼我回头补测才发现的。

改动已撤回,查证结果写进了注释,免得下一个人再"修"一次。

5. ports.py 未配置时的错误文案

原文案列的"前处理/后处理/persona/llm_client/权限"既不是字段名、也早已漏了后来加的那几项。改成指向 ArisePorts 的字段定义——不再维护一份会过期的清单。

范围外但同源的一处

gating.py 里"真实群聊非 @ 流量目前完全不经过反应式入口"那条注记,随本改动部分过期,一并注明:非 @ 消息现在会经过这个钩子,但它interrupt(),不产生任何门控评估/LLM 调用/记账,所以那条"新增消息接收面/成本面"的顾虑仍未被触及。

验证

uv run pytest -n auto 连跑 4 次(含两次高负载批次,91s / 149s)1444 passedruff check 全过。

Closes #93 五处独立小修。其中 **AC 第 4 条的前提经查证不成立、改动已撤回**,另有一处 AC 点名的接线位置被换掉(有实测复现支撑),两处都在下面说明。 ## 1. 向量库探活把 property 当函数调用 → `/diagnose` 恒误报 ✅ `ping_vector_store` 写成 `await self._qdrant().get_collections()`,而 `_qdrant` 是 `@property`。全文件 11 处 `self._qdrant` 只有这一处多了括号。`_probe` 宽捕 `Exception` 只回类名,于是真实部署上向量库**永远** down、理由还是个具误导性的 `TypeError`。 没被发现的原因是 `PersistentStorage.ping_vector_store` **从未被执行过**——唯一相关测试用的是自定义假 storage。新增 `tests/test_diagnostics_storage.py`,两套实现共用同一份契约断言。变异验证:把括号加回去,persistent 参数立刻红。 ## 2. `_handle_edit` 的降级路径不查 `capabilities.self_recall` ✅ 降级 = 撤回旧消息 + 重发新内容,所以它**也**要 `self_recall`。`_handle_self_recall` 有这道 fail-closed 闸(issue #20 修的就是这个),edit 那扇门原样漏了出来。而 `edit=False & self_recall=False` 是 `CapabilitySet` 的**缺省组合**、edit 工具对模型恒可见——不声明能力位的平台上这就是默认行为。 同时修测试替身:`fake_egress.recall()` 此前不查能力位就记账,所以就算有人写了这条组合的用例也不会红。(规格轴逐条核对了 `runtime_loop.py` 全部 10 处 `self._egress.*`,确认 `edit` 降级是最后一个洞。) ## 3. 沉默窗口在群聊里不撤销 → 恰好在抢话 ✅(接线位置与 AC 不同) `_silence_window.interrupt()` 唯一调用点在 `_handle_reactive_message` 里,而那个 handler 注册在 `on_message(rule=to_me())`——群聊第三方发言**不撤销**待发消息,恰恰是这个机制要治的那件事。私聊因 `to_me()` 恒真而碰巧正确。 **AC 点名 `@event_postprocessor`,实现用的是 `@event_preprocessor`**,因为 postprocessor 位置有一条**实测可复现**的竞态:它跑在 `_handle_reactive_message` 已经 `create_task(_debounced_flush(...))` **之后**,只要那个 flush 抢先注册好 `asyncio.Event`,interrupt 就会撤掉**本轮自己**的回复。 复现:`ARISE_DEBOUNCE_WINDOW_SECONDS=0` 跑 `test_gating_e2e.py` + `test_walking_skeleton.py` → | 版本 | 结果 | |---|---| | `@event_postprocessor` | **5 failed**(全是"预期发送没发生")| | 钩子改成直接 return(对照组)| 8 passed | | `@event_preprocessor` | 8 passed | 生产默认的去抖窗口只是让它需要事件循环卡住才触发,不是让它消失——实际表现就是全量测试在高负载批次里偶发红(我在实现期真撞到过两批)。已固化成回归测试 `test_the_interrupt_does_not_cancel_the_send_it_triggered`;变异验证:改回 postprocessor 时它立刻红、同文件另两条仍绿。 preprocessor 的额外风险(抛异常时 NoneBot `return False` 丢弃**整条事件**,所有插件都收不到)由两轴 review 指出,已就地用 try/except + `logger.exception` 兜住——本特性失灵最多少撤销一次待发,不该把整个 bot 变哑。 新增 `tests/test_silence_window_wiring.py`:走真实 `handle_event`、不装任何 matcher,断言的是真实 `wait()` 的返回值而不是"interrupt 被调过",并带一条"别的 chat 不受影响"的反向对照。 ## 4. `[tool.pytest]` 节名——**AC 前提不成立,改动已撤回** ❌ ticket 说"pytest 只读 `[tool.pytest.ini_options]`,于是整节配置静默失效"。在本仓钉的 **pytest 9.1.1 上不成立**:`[tool.pytest]` 是 pytest 9 的**原生 TOML 形式、也是官方推荐写法**,`ini_options` 才是向后兼容的字符串形式,两者不能并存(`_pytest/config/findpaths.py` 里同时出现直接报错)。 实测原始状态(`[tool.pytest]`)下:`config.inipath` 已指向 pyproject.toml、`config.getini("asyncio_mode")` 已是 `auto`、`--strict-markers` 已经在拦没注册的 marker。**这一节一直是生效的**,没有任何配置在静默失效。 顺带自省:我原来当作"改名生效"的两条证据(头部出现 `configfile:` 行 + strict-marker 探针变红)都是改名**之后**才测的,改名之前同样成立——等于没证明任何变化。是两轴 review 逼我回头补测才发现的。 改动已撤回,查证结果写进了注释,免得下一个人再"修"一次。 ## 5. `ports.py` 未配置时的错误文案 ✅ 原文案列的"前处理/后处理/persona/llm_client/权限"既不是字段名、也早已漏了后来加的那几项。改成指向 `ArisePorts` 的字段定义——不再维护一份会过期的清单。 ## 范围外但同源的一处 `gating.py` 里"真实群聊非 @ 流量目前完全不经过反应式入口"那条注记,随本改动部分过期,一并注明:非 @ 消息现在会经过这个钩子,但它**只**调 `interrupt()`,不产生任何门控评估/LLM 调用/记账,所以那条"新增消息接收面/成本面"的顾虑仍未被触及。 ## 验证 `uv run pytest -n auto` 连跑 4 次(含两次高负载批次,91s / 149s)**1444 passed**。`ruff check` 全过。
## 1. 向量库探活把 property 当函数调用 → `/diagnose` 恒误报

`storage.py` 的 `ping_vector_store` 写成 `await self._qdrant().get_collections()`,
而 `_qdrant` 是 `@property`——加括号即 `TypeError`。全文件 11 处 `self._qdrant`
只有这一处多了括号。`_probe` 宽捕 `Exception` 只回类名,于是真实部署上向量库
**永远** down,理由还是个具误导性的 `TypeError`。

没被发现是因为 `PersistentStorage.ping_vector_store` **从未被执行过**——唯一相关
测试用的是自定义假 storage。新增 `tests/test_diagnostics_storage.py`,两套实现
共用同一份契约断言。

## 2. `_handle_edit` 的降级路径不查 `capabilities.self_recall`

降级 = 撤回旧消息 + 重发新内容,所以它**也**要 `self_recall`。`_handle_self_recall`
有这道 fail-closed 闸(issue #20 修的就是这个),edit 那扇门原样漏了出来。而
`edit=False & self_recall=False` 是 `CapabilitySet` 的**缺省组合**,edit 工具对模型
恒可见——不声明能力位的平台上这就是默认行为。违反 ADR-0004「core 拥降级策略」
+ ADR-0012。

同时修测试替身:`tests/fake_egress.py` 的 `recall()` 此前是**唯一**一个不查能力位
就记账的方法(其余七个都 raise),所以就算有人写了这条组合的用例也不会红。

## 3. 沉默窗口在群聊里不撤销 → 恰好在抢话

`_silence_window.interrupt()` 全仓唯一调用点在 `_handle_reactive_message` 里,而那个
handler 注册在 `on_message(rule=to_me())`——群聊里第三方正常发言**不撤销**待发消息,
恰恰是这个机制要治的那件事。私聊因 `to_me()` 恒真而碰巧正确,这也是它一直没被发现
的原因。CONTEXT.md 与 `silence_window.py` 模块文档都断言"任何人发言即撤销"。

改挂 `@event_postprocessor`(同环境感知/实时状态感知两个先例),**只撤销、不产生
任何 LLM 调用**,所以不构成"新增消息接收面/成本面"的架构决定;`gating.py` 里记着
那条顾虑的地方一并注明它未被触及。新增 `tests/test_silence_window_wiring.py`:走真实
`handle_event`、不装任何 matcher,断言的是真实 `wait()` 的返回值而不是"interrupt 被调
过",并带一条"别的 chat 不受影响"的反向对照。

## 4. `[tool.pytest]` 节名错,整节配置静默失效

pytest 只读 `[tool.pytest.ini_options]`。改名后已实测生效(`configfile: pyproject.toml`
出现在头部),且用一个假 marker 探针确认 `--strict-markers` 真的开始拦了。全量重跑
没有暴露出既有的错拼 marker。

## 5. `ports.py` 未配置时的错误文案

原文案列的"前处理/后处理/persona/llm_client/权限"既不是字段名、也早已漏了后来加的
那几项。改成指向 `ArisePorts` 的字段定义——不再维护一份会过期的清单。

全量 1443 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
时序不变量:撤销信号该在**这条消息自己的 flush 任务被创建之前**发出。
postprocessor 在响应器之后跑,那时 `_handle_reactive_message` 已经
`create_task(_debounced_flush(...))` 了——"interrupt 不会撤掉本轮自己刚安排的
发送"于是依赖上了"去抖窗口比响应器→postprocessor 那段间隔长"这个从没人写下来的
前提。preprocessor 的时序与修复前那版完全一致(都在响应器之前),只是覆盖面从
`to_me` 扩到全部消息,是被证明稳定的那个顺序的严格超集。

**这条依赖是推理出来的,没有实测到**:中途观察到 2 次 e2e 失败曾被我怀疑是它,
反向验证(改回 postprocessor 连跑 3 次全量)**没能复现**,那批次机器负载明显更高,
归因于负载。docstring 里如实写了证据到哪儿为止——不是在修一个观察到的 bug,是在
零成本前提下去掉一条隐含前提。

AC 点名 postprocessor 的理由是"仓里已有两个先例",而那两个先例各自都**需要**后置
(环境信号要看处理结果、实时状态是通知类事件),这条不需要。属于对 AC 的有意偏离。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## AC 第 4 条的前提是错的,改动已撤回

ticket 说"pytest 只读 `[tool.pytest.ini_options]`,`[tool.pytest]` 整节静默失效"。
在本仓钉的 pytest 9.1.1 上**不成立**:`[tool.pytest]` 是 pytest 9 的**原生 TOML
形式、也是官方推荐写法**,`ini_options` 才是向后兼容的字符串形式,两者不能并存
(`_pytest/config/findpaths.py`)。实测原始状态下 `config.inipath` 就指向
pyproject.toml、`asyncio_mode` 已是 `auto`、`--strict-markers` 已经在拦没注册的
marker——这一节一直生效。我原来的"改名生效"证据(头部出现 configfile 行 + strict
探针变红)两条都是改名**之后**才测的,改名之前同样成立,等于没证明任何变化。

所以撤回改名,并把这段查证结果写进注释,免得下一个人再"修"一次。

## 沉默窗口那条竞态:从"推理"升级成"实测可复现 + 有回归测试"

上一个 commit 里我把 preprocessor 的动机写成"推理出来的、反向验证没能复现"。
反向验证当时跑在空闲机器上,所以没撞上。用 `ARISE_DEBOUNCE_WINDOW_SECONDS=0`
就**确定复现**:postprocessor 版本下 `test_gating_e2e.py` + `test_walking_skeleton.py`
5 failed(全是"预期发送没发生"),把本函数改成直接 return 的对照组 8 passed,
换 preprocessor 同样 8 passed。这解释了此前高负载批次里那几次偶发红。

已把它固化成回归测试 `test_the_interrupt_does_not_cancel_the_send_it_triggered`
(压去抖窗口到 0;两个读取点都要压,只压一个会以"根本没触发"这个无关原因变红)。
变异验证:改回 `@event_postprocessor` 时这条立刻红、同文件另两条仍绿。

## preprocessor 的额外风险已就地兜住

规格轴与标准轴都指出:preprocessor 抛异常时 NoneBot `return False` 丢弃**整条事件**
(所有插件的响应器与 postprocessor 都不跑),而 `get_ports()` 在 host 未 configure
时就抛。整段包进 try/except + logger.exception:本特性失灵最多少撤销一次待发,
不该把整个 bot 变哑。

## 其它

- `fake_egress` 那句"**唯一**一个不检查能力位就记账"不准确——`send` 同样不查(它
  没有对应能力位,是正当的),且 `quote_message_id` 没查存在的 `capabilities.quote`
  (既有缺口,不在本票范围)。改成准确表述。
- `test_silence_window_wiring.py` 的反向对照原本每次必然烧满 2 秒 settle 超时,改成
  直接查状态(上一句已结算,循环早转过了);断言失败时两个 task 会带 10 秒定时器
  留在 session 级事件循环上,补 finally 取消。
- 全仓四处 `@event_postprocessor` 字样跟着钩子一起改准。
- `_handle_reactive_message` 里那段 5 行"墓碑注释"压成 1 行。

连跑 4 次全量(含两次高负载批次)1444 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Yushu merged commit 0ea65b78d2 into main 2026-07-29 10:05:12 +00:00
Yushu deleted branch fix/93-real-bug-batch 2026-07-29 10:05:13 +00:00
Sign in to join this conversation.
No description provided.