From 18f194f924a87ce407e75c83acd00a68625f5274 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 21 Sep 2026 07:00:30 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B9:=20=E5=88=A4=E6=8D=AE=E6=94=B6?= =?UTF-8?q?=E6=B4=9E=EF=BC=88=E6=9C=BA=E5=99=A8=E5=9B=9E=E4=BF=A1=E4=B8=8D?= =?UTF-8?q?=E7=AE=97"=E5=9B=9E=E8=BF=87"=EF=BC=89+=20=E5=86=99=E6=B8=85=20?= =?UTF-8?q?T-12=20=E7=9A=84=E8=AF=AD=E4=B9=89=E7=A9=BA=E7=99=BD=EF=BC=9B?= =?UTF-8?q?=E2=98=85=20=E8=AE=B0=E6=88=91=E8=87=AA=E5=B7=B1=E7=9A=84?= =?UTF-8?q?=E9=87=8F=E9=94=99=E4=B8=A4=E5=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 指出我那条 `收件人回过 ⇒ 必然读过` 有一个洞。**逐条复核成立**,已改。 ## 一★ 洞:**机器回信**也被算成了"回过" "回"只有在**是模型的产物**时才蕴含"读过"。桥有**自动**回信路径 —— 模型一次都没跑起来时,桥代它回一封 `处理失败: <父主题>`: pi src/worker.mjs:619 / :711 zcode src/index.mjs:300 dsh src/index.ts:1215 / :1719 ← dsh 侧也有,pi 只列了 3 处 ⇒ **那封"回信"恰恰是"没读过"的证据。** 精确模板匹配实测(`status='unread'`): 宽松:任意孩子(含机器回信) 114 严格:**至少一个孩子不是**机器模板 105 差 9 ← 与 pi 独立列出的 9 封**完全一致** ## 二★ 我自己在这条上先写错了量词(复核时才抓到) 我第一版写成 `NOT EXISTS(… AND ch.subject NOT LIKE '处理失败:%')` —— **那问的是"一个真回信都没有",是反向量词**,实测只剩 **9 封**(正好是那批机器信)。 **判据从 105 翻成 9,照样返回行、照样不报错 —— 静默答错。** 正确写法是**带模板排除的存在量词**(已写进 docs 的 SQL)。 ★ 又一次"先写结论、后复核",顺序反了。另记"并存"2 封(真回信+机器通知,均 `status=read`) 说明**不能用"父信含机器孩子就排除"的粗写法**。 ## 三、T-12 的语义空白已写进契约(四个桥逐个量过) `read_mail` 是否产生"已读",契约沉默。**四个桥各只有 1 处 `/mail/read`,且四处都只在 `read_inbox` 里**: dsh src/index.ts:1340 read_inbox(:1302) pi src/tools.mjs:188 read_inbox(:159) zcode lib/tools.mjs:155 read_inbox(:120) opencode index.js:291 — 而 `read_mail` 实现体只有 `client.get(...)` ⇒ **"只取正文、不改状态"是各桥一致的设计意图**。 ⇒ 写进契约:**`read_mail` 不产生已读状态 ⇒ 未读计数不等于"没人读过"**; 治法是把语义写进契约,**不是多回填几行**((a) 本来就没有权威记录,补只是猜)。 ## 四★ 记我自己的量错两处(同一形状,连错两次) 做上面那张四桥表时,我**两次**把"grep 返回空"读成了"没有": 1. opencode:grep 了 `opencode-mail-bridge/src/` —— **该目录不存在**,源码在包根 `index.js`。 2. zcode:grep 了 `zcode-mail-bridge/src/`(存在,但标已读那行在 `lib/tools.mjs`)。 **`grep` 对不存在的目录不报错、只返回空** ⇒ "路径写错"与"真的没有"**读数完全相同**。 ⇒ **数一个东西"有几处"之前,先确认搜索路径存在、且覆盖所有落点。** (第二次之所以抓到,是因为我改完表**回去逐处 `sed -n 'p'` 对行号** —— 只按 `grep -c` 收工,这张表就会带着两个"0 处"进仓库。) ★ 另修一处**漂移的绝对数**:严格口径下"会话未归档"是 **74**(我先前写 83,是旧口径的残留)。 已在 docs 注明该数会随我们的往来漂移,**只当量级、不当阈值**。 --- docs/API.md | 54 ++++++++++++++++++++++++++++++++++++++++- docs/PLUGIN-CONTRACT.md | 46 +++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index d69a193..5329a64 100644 --- a/docs/API.md +++ b/docs/API.md @@ -524,7 +524,59 @@ curl -X POST {host}/api/v1/mail/read -H "Authorization: Bearer $AGENT_KEY" WHERE r.mail_id=m.mail_id AND r.reader_name=m.to_name); ``` - 实测 **248 封**,按 `mails.status` 拆: + ⚠️⚠️ **但这个判据有一个洞(pi 2026-09-21 指出,我逐条复核成立):它把"机器回信"也当成了"回过"。** + "回"只有在**是模型的产物**时才蕴含"读过"。桥有**自动**回信路径 —— 模型**一次都没跑起来**时, + 桥代它回一封 `处理失败: <父主题>`: + + ``` + plugins/pi-mail-bridge/src/worker.mjs:619 / :711 subject: `处理失败: ${data.subject …}` + plugins/zcode-mail-bridge/src/index.mjs:300 同上 + plugins/dsh-mail-bridge/src/index.ts:1215 / :1719 同上(dsh 侧也有,pi 只列了 3 处) + ``` + + ⇒ **那封"回信"恰恰是"没读过"的证据**,不是"读过了"的证据。 + 精确模板匹配(`child.subject LIKE '处理失败:%'`,不靠子串)后实测: + + | 口径(均限 `mails.status='unread'`) | 封数 | + |---|---| + | 宽松:任意孩子(含机器回信) | **114** | + | 严格:**至少一个孩子不是**机器模板 | **105** | + | 差 | **9** | + + 那 9 封**逐条核过**(每封只有 1 个孩子,且那个孩子就是机器模板): + `70cef54d`/`aa78b31a`/`889f8eb3`/`e43496ed`/`84900edd`/`614f78e4`/`4a3b8e1b`/`85624acd`/`fa233ece` + —— 与 pi 独立列出的 9 封**完全一致**(它按精确模板匹配 `= '处理失败: ' || 父主题`,我按 `LIKE`)。 + + ★ **改判据时要用"至少一个非机器孩子"** —— 即把上面那条裸的 + `EXISTS (… ch.from_name = m.to_name)` 换成**带模板排除**的存在量词: + + ```sql + -- 严格版判据:只有"至少有一个孩子不是机器模板"才算真回信 + SELECT m.mail_id, m.to_name, m.status FROM mails m + WHERE m.to_name <> '' + AND EXISTS (SELECT 1 FROM mails ch + WHERE ch.parent_mail_id = m.mail_id AND ch.from_name = m.to_name + AND ch.subject NOT LIKE '处理失败:%') -- ★ 关键这一行 + AND NOT EXISTS (SELECT 1 FROM mail_reads r + WHERE r.mail_id=m.mail_id AND r.reader_name=m.to_name); + ``` + + ⚠️ **不要写成** `AND NOT EXISTS (… AND ch.subject NOT LIKE '处理失败:%')` —— + 那问的是"**一个真回信都没有**",是**反向**的量词,实测只剩 **9 封** + (正好是"只有机器孩子"的那批)。**把 `EXISTS` 的否定写进去,判据会从 105 翻成 9** + 且**照样返回行、照样不报错** —— 静默答错,不显红。 + 这一点我**自己先写错了、复核时才抓到**(先写结论后复核,顺序反了)。 + + ⚠️ 另记一条**并存**情形(说明为什么不能用"删掉机器孩子再看剩没剩"的写法): + 实测有 2 封**既有真回信、又有 `处理失败:` 通知**(`b3ce9d0f`、`1f9ff3b4`,都在 dsh 侧)。 + 它们**恰好 `status='read'`** ⇒ 不在上面那个 `unread` 集里,**所以对 114→105 这个差没有影响**; + 但任何"父信含机器孩子就排除"的粗暴写法都会把它们**误删**。 + ⇒ 正确写法是**带模板排除的存在量词**(上面那条 SQL),而不是"先减集合再判空"。 + ⇒ **这又是一次"同一主题前缀 ≠ 同一个角色":`处理失败:` 说明的是*那一轮*没跑起来, + 不说明*这封信*没人读过。** + + 实测 **248 封**,按 `mails.status` 拆(⚠️ 此为**未排除机器回信**的旧口径; + 排除后略降 —— 与上面 `unread` 那一档的 114→105 同理): | `mails.status` | 封数 | 回填 SQL 选得到吗 | |---|---|---| diff --git a/docs/PLUGIN-CONTRACT.md b/docs/PLUGIN-CONTRACT.md index 3afdce9..5388a59 100644 --- a/docs/PLUGIN-CONTRACT.md +++ b/docs/PLUGIN-CONTRACT.md @@ -786,6 +786,52 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一 | **T-12** | `read_mail` | SHOULD | 读一封邮件的完整内容(含参与方地址与 reply_address) | | **T-13** | `propose_alias`(T-2 参数) | MAY | 模型在 `send_mail` 的 `propose_alias` 字段提议改名 | +> ★★ **T-12 的语义空白:`read_mail` 是否产生"已读"状态,契约没写**(2026-09-21 定)。 +> +> 这不是猜测 —— **四个桥里逐个量过**,`/mail/read` 这个唯一的标已读端点: +> +> | 桥 | 源码位置(⚠️ 各不相同) | `/mail/read` | 所属工具 | +> |---|---|---|---| +> | `dsh-mail-bridge` | `src/index.ts` | **1 处**(`:1340`) | `read_inbox`(`:1302`) | +> | `pi-mail-bridge` | `src/tools.mjs` | **1 处**(`:188`) | `read_inbox`(`:159`) | +> | `opencode-mail-bridge` | `index.js`(**不在 `src/`**) | **1 处**(`:291`) | — | +> | `zcode-mail-bridge` | `lib/tools.mjs`(**不在 `src/`**) | **1 处**(`:155`) | `read_inbox`(`:120`) | +> +> ⇒ **四个桥各自的路径都不同**,而四处都**只**在 `read_inbox` 里标已读。 +> +> ⚠️ **我在这张表上连错两次,两次都是同一个形状 —— "路径写错 ⇒ grep 返回空 ⇒ 读成没有"**: +> +> 1. opencode:我 grep 了 `opencode-mail-bridge/src/` —— **该目录不存在**, +> 而它的源码在**包根的 `index.js`** ⇒ 我把"没找到"写成了"0 处"。 +> 2. zcode:我 grep 了 `zcode-mail-bridge/src/`(存在,但只有 `index/prompt/turn-mode/zcode-run`) +> —— **标已读那行在 `lib/tools.mjs`** ⇒ 我又写成"0 处"。 +> +> **`grep` 对不存在的目录不报错、只返回空** —— +> 于是"路径写错"与"真的没有"**读数完全相同**。这正是本仓那条 +> **"判据在,但走不到"**的同族:判据(`grep`)没问题,**它压根没走到该走的地方**。 +> ⇒ **数一个东西"有几处"之前,先确认搜索路径存在、且覆盖了所有可能的落点。** +> (我第二次之所以抓到,是因为我改完表格**回去逐处 `sed -n 'p'` 对行号**; +> 只按第一次的 `grep -c` 收工,这张表就会带着两个"0 处"进仓库。) +> +> 而 `read_mail` 的实现体(dsh `:1523`,pi 同形)**只有 `client.get('/agent/mail/')`**, +> **没有任何标已读的调用**。⇒ **"只取正文、不改状态"是各桥一致的设计意图,不是某个桥的疏忽。** +> +> 由此得到两条必须写进契约的话(pi 提议、我复核同意): +> +> 1. **`read_mail` 不产生已读状态。** +> 2. ⇒ **未读计数不等于"没人读过"** —— 一个 Agent 完全可以读完正文、处理完、 +> 甚至回了信,而系统里它仍是 `unread`。 +> (实测后果见 `docs/API.md`:`status='unread'` 却"收件人回过"的信有 **105** 封, +> 且其中 **74** 封的会话未归档 ⇒ 会出现在 `?status=unread`、被 `catchUp` 反复重投。) +> ⚠️ 这个数**会漂移**(我们自己的往来就在改它,实测同日 113→116→105) ⇒ 只当**量级**用, +> 别当验收阈值。 +> +> ⇒ **治法是把语义写进契约,不是"多回填几行"。** +> 回填只清"冗余列与权威列之间的差"(成因 (b));而 (a) 这一类 +> **在系统里本来就没有权威记录** —— 没有记录可回填,任何"补"都只是在猜。 +> ⚠️ 因此**不要**用"收件人回过"去批量标已读:那条判据是**充分**条件(还有 9 封是机器回信、 +> 不蕴含读过),把它当**必要**条件用就会把没读的标成已读。 + > **为什么 T-8 到 T-12 都是 SHOULD/MAY 而不是 MUST**:它们解决的是静默投递错误 > (`suggest_address`)和信息不足(`list_contacts` / `read_mail` / `read_thread`), > 但都不影响主链路(收信 → 起会话 → 自动回信)。一个最小可行插件只注册 T-1 和 T-2