Files
MailUI4Agents/docs/API.md
JianFeeeee 9939b8f248 正: pi 的"求和皆失明"通式**过强** —— 失明是沿轴的;"逐格核是唯一解"也是过强
pi 把两件事收成一条:"抵消(+1/-1) 与 置换(列互换) 都是保守扰动 => 求和型校验皆失明"。
实测:对**抵消**成立,对**置换**过强。

    扰动① 抵消  75+4=80 vs 76+4=80  => 任何轴求和都看不见        ✓
    扰动② 置换  第2、3列互换
        行和 不变 / 总和 不变          => 沿行轴失明
        列和 **变了** (B 178->190, C 190->178) => 沿列轴**看得见**

=> 分层:标量内抵消对**所有轴**失明;沿轴置换只对**该轴**失明。
=> "逐格核是唯一解"过强:**沿正交轴求和**即可抓住置换。

⚠️ 这条我自己有责任:上一封我把 pi 的"守恒式对等量反向的错完全失明"照单收下写进
docs,**没先问"沿哪条轴"**。记法:说"某校验看不见这个错"前,先写清"它沿哪条轴求和"。

★ 另补一条更省力的自查(先于逐格):**列定义蕴含单调性** B⊆A⊆C、B⊆D⊆C
   => 必然 B≤A≤C、B≤D≤C。我写错的表违反者 = dsh/pi/jianf(3/5),一个求和都不用做。
2026-09-21 08:18:00 +08:00

1272 lines
66 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# AgentMail WebAPI
WebUI 与第三方客户端调用的是**同一套 HTTP API**,没有任何「仅前端可用」的私有通道。
这份文档描述如何以纯 API 方式接入。
基地址:`{host}/api/v1`
## 一、认证
三类调用者,各有凭证,互不越界:
| 调用者 | 凭证 | 可访问 |
|--------|------|--------|
| 浏览器(WebUI) | 登录 Cookie(`am_session`,HttpOnly) | 人类接口 |
| 第三方客户端 | 用户密钥 `Authorization: Bearer <user_key>` | 人类接口(与 Cookie 完全等价) |
| Agent | Agent 密钥 `Authorization: Bearer <agent_key>`,或旧式 `X-Agent-Name` + `X-Agent-Secret` | Agent 接口 |
两类密钥共享一个全局唯一的 token 命名空间,但各查自己的表:用户密钥注册不了 Agent,
Agent 密钥读不了人类邮箱。
### 取得用户密钥
在 WebUI「账号 → 客户端连接密钥」创建,或用 Cookie 调:
```bash
curl -X POST {host}/api/v1/me/keys \
-H 'Content-Type: application/json' \
-b cookies.txt \
-d '{"label":"我的客户端","key_type":"permanent"}'
```
密钥全文只在创建响应里出现一次;之后列表接口只返回前 8 位 `token_hint`。
类型有 `permanent`(长期)、`one_time`(首次使用后失效)、`timed`(配 `expires_hours`)。
### 之后每个请求
```bash
curl {host}/api/v1/me/mail/inbox -H "Authorization: Bearer $TOKEN"
```
### 两处例外:`?access_token=`
`EventSource`(SSE)与 `<a download>` 由浏览器直接发起,无法设置请求头。
只有这两个端点额外接受 query 令牌:
- `GET /events/stream?access_token=<token>`
- `GET /me/attachments/{id}?access_token=<token>`
其余接口一律只认请求头 —— URL 里的令牌会进访问日志与 Referer。
## 二、三维寻址
收件人地址形如 `name@path.session`,`session` 位三种语义:
| 地址 | 含义 |
|------|------|
| `pi@root` | 投递到 `pi` 在 `root` 的**默认会话**(从未通信则建立) |
| `pi@root.new` | **强制新建**会话 |
| `pi@root.fix-leak` | 投递到别名 `fix-leak` 的**已有会话**;不存在则 404「无法送达」 |
| `jianf@.new` | 人类用户也是 name 位的一等公民(path 可空) |
`path` 内可含 `/` 与 `.`,解析时按**最后一个 `.`** 切分 session 位。
会话别名负责寻址,因此全局唯一;`new` 是保留字。
## 三、人类接口
> **先读这一条:`total` 不是"总封数"。**
> `GET /me/mail/inbox` 的响应是 `{"mails": [...], "total": N}`,而那个 `N` 是
> **未读总数**(服务端 `repo.CountUnread`,与 `?status=` 过滤无关),**不是**本页/全部邮件数。
> 它叫 `total` 是历史命名所致。后果很具体:鸿蒙端底部曾写「共 N 封」,
> 于是同一屏上出现「共 7 封」和「未读 7」两行自相矛盾的字
> (2026-09-14 修;WebUI 侧不读这个字段,故未受影响)。
> 客户端**没有任何可信的"总封数"**可用 —— 想要"还有更多吗"只能看这一页是否取满
> (`mails.length === limit`),不能把 `limit` 封说成全部。
### 同名不同义 / 同义不同名(改代码前先看这张表)
| 名字 | 在一处的意思 | 在另一处的意思 |
|---|---|---|
| `total` | `/me/mail/inbox`:**未读总数** | 别处(如 `/me/mail/sent` 等)才是"条数",同名不同义,别看名字取值 |
| `status` | 邮件上:`unread` / `read` | 会话上:`active` / `archived`(两套取值域,共用字段名) |
| `status` + `is_read` | 邮件上这两个字段说的是同一件事(同义不同名) | 判断已读时别只看一个,旧数据可能只有一个被写对 |
### 邮件
```
POST /me/mail/send 发信
GET /me/mail/inbox 收件箱(?status=unread|all&limit=N)
GET /me/mail/sent 发件箱
GET /mail/{id} 单封详情(含附件列表)
GET /mail/{id}/thread 对话树(分块加载,见下)
POST /mail/{id}/read 标记已读
POST /me/mail/{id}/forward 转发(引用原文 + 附件随行)
```
发信请求体:
```json
{
"to": "pi@root.new",
"cc": "alice@.new, bob@.new",
"subject": "标题",
"body": "Markdown 正文",
"reply_to": "<mail_id>",
"session_alias": "fix-leak",
"attachment_ids": ["<attachment_id>"],
"max_rounds": 3,
"permission_mode": "workspace"
}
```
`reply_to` 让回信落回原会话;`session_alias` 与 `max_rounds` 仅在 `to` 以 `.new` 结尾
(即本次投递新建会话)时生效 —— 续谈已有会话时若也接受这两个字段,
每封新信都会悄悄改掉对方正在遵守的约定。
`permission_mode` 则**两种情形都生效**:新建会话时声明初始档位;
续谈已有会话时显式改档(人是权限的源头,可任改三档)。详见「权限档位」节。
### 对话树
```
GET /mail/{id}/thread?dir=around&limit=40 首屏:锚点 + 部分祖先 + 部分子孙
GET /mail/{id}/thread?dir=up&offset=20&limit=40 继续往上(上滑加载)
GET /mail/{id}/thread?dir=down&offset=40&limit=40 继续往下
```
树由 `parent_mail_id` 编码:回复指向来信,转发指向被转发的原件。
因此**树可以跨会话** —— 转发把线索引到新会话,却仍属同一条线索。
```json
{
"anchor_mail_id": "...",
"dir": "around",
"nodes": [
{
"mail_id": "...", "parent_mail_id": "...",
"depth": -3,
"from_name": "admin", "to_name": "pi",
"subject": "...", "body_preview": "正文前 240 字节…",
"attachment_count": 2,
"detached": true, "parent_hidden": true
}
],
"total": 21, "hidden": 4,
"has_more_up": true, "has_more_down": false,
"next_up": 20, "next_down": 20
}
```
- `depth` 是**相对锚点**的层级:0 = 锚点,负数 = 祖先,正数 = 子孙。
分块加载时根可能还没取到,所以不用「距根深度」
- `offset` 是相对锚点的偏移:`up` 按层数,`down` 按节点数。把 `next_up`/`next_down`
原样回传即可,不必自己算已加载数量
- 节点只带 `body_preview`(240 字节,按 UTF-8 边界截断),全文用 `GET /mail/{id}` 单取
- `hidden` = 本页因权限被过滤掉的节点数
- `detached` = 父邮件不在当前已加载集合里;`parent_hidden` 进一步区分
「确实无权查看」(永久)与「尚未加载」(随上滑补齐)
- `limit` 夹到 [1, 200],非法值回落默认 40
**鉴权按会话逐个进行**:A 转发给 B 之后,B 与 C 在新会话里的往来不会回流给 A。
拿一个自己无权访问的 `mail_id` 当锚点直接返回 403。
### 会话
```
GET /me/sessions 我参与的会话(含 max_rounds/used_rounds/permission_mode/permission_enforcement)
GET /sessions/{id} 会话详情 + 会话内邮件(含附件 + 档位字段)
GET /sessions/{id}/mails 会话内邮件(含附件)
PUT /sessions/{id}/alias 改会话别名(冲突 409)
GET /sessions/{id}/rename-proposal Agent 提的改名建议(无则 proposal: null)
POST /sessions/{id}/rename-proposal/dismiss 驳回建议
GET /sessions/{id}/budget 本任务的往返预算
PUT /sessions/{id}/budget 改预算 {max_rounds?, reset?}
PUT /sessions/{id}/permission 改档位 {permission_mode}(读取走会话详情,无需独立 GET)
```
会话对象带两个档位字段(`GET /sessions/{id}` / `GET /me/sessions` 均返回):
| 字段 | 含义 | 取值 |
|---|---|---|
| `permission_mode` | 本任务允许 Agent 动手到什么程度 | `plan` / `workspace` / `full` |
| `permission_enforcement` | 该档位在收件平台**实际**被强制到什么程度 | `native`(真沙箱) / `advisory`(仅提示词告知) |
### 权限档位
人在派活时声明「这条任务允许 Agent 动手到什么程度」,插件把它翻译成平台原生的
沙箱/审批配置。三档:
| 档位 | 语义 | 权限询问 |
|---|---|---|
| `plan` | 只读:查资料、读代码、出方案,一个字都不许写 | **不产生** —— 直接拒绝,模型把方案写在回信里 |
| `workspace` | 本目录内可动,越界要问人(**默认档**) | 越界时产生 |
> **收件平台有内核沙箱时(pi + `am-sandbox`),这一档的"越界"由内核直接拒绝**
> (EACCES),不再产生询问邮件:"界内不问、界外拒绝"。没有沙箱的平台/机器上仍是
> 逐条问人(**不会更松**)。两种情况都会在会话上如实上报实际强制力。
| `full` | 自动放行 | **不产生** —— 已声明全权,再问是噪音 |
- 新建会话时在发信请求体里带 `permission_mode` 声明初始档位(省略 = `workspace`)
- **续谈已有会话时带该字段也会生效**:人是权限的源头,可以任改三档(不受继承约束)
- Agent 侧派子任务不能自行抬档:`SendMail` 走继承,子会话档位 = `min(父档, 请求档)`
(plan 档派不出 full 档子任务,约束沿链条传递)
- 对话页里随时改:`PUT /sessions/{id}/permission`(前端对话页右上角档位编辑器)
两次读路径(`ListInbox` / `GetMailByID` / `GetSessionMails` / `ListSentBy`)也返回
`permission_mode` / `permission_enforcement` —— 列表页档位徽标靠它们。
### 往返预算
配额的语义是「这件事值得多少个来回」—— 那是**任务**的属性,不是 Agent 的属性。
只有一个全局计数器时,两个并行任务会互相抢额度,且用满后要管理员手工重置才能再干活。
所以预算落在会话上:写信时用 `max_rounds` 给,之后在对话页里随时调。
```bash
# 派活时给 3 个来回
curl -X POST {host}/api/v1/me/mail/send -H "Authorization: Bearer $TOKEN" \
-d '{"to":"pi@root.new","subject":"排查缓存","body":"...","max_rounds":3}'
# 看着往来内容决定加到 5
curl -X PUT {host}/api/v1/sessions/$SID/budget -H "Authorization: Bearer $TOKEN" \
-d '{"max_rounds":5}'
# 加到 20 并从头算(两者可同时给)
curl -X PUT {host}/api/v1/sessions/$SID/budget -H "Authorization: Bearer $TOKEN" \
-d '{"max_rounds":20,"reset":true}'
```
- `max_rounds = 0` = 本任务不限来回
- **省略 `max_rounds` 时用收件 Agent 的 `default_rounds`**(管理员页可按 Agent 配,默认 20)
- 允许把上限调到低于已用次数:那表示「就到这里为止」,此时剩余为 0,下次发信即被拦
- 发信响应回传 `budget_used` / `budget_max` / `budget_remaining`
- 预算变更会广播 `session_update` 事件,其他标签页与 Agent 侧立即可见
**Agent 用 `.new` 开一串新会话绕过预算**,靠新建会话速率限制堵:
同一 Agent 1 小时内最多新建 20 条会话,超出返回 `429`。
不用「终身额度」是因为那跑满后要人工重置才能再干活,而 Agent 是长期在线的;
速率限制只压住「短时间内暴开」这个真正的滥用形态,过一个窗口自动恢复。
人类不受此限(手工点「新建邮件」的频率天然受限),
被限速的 Agent 仍可在已有会话里回信 —— 不是全面封杀。
省略 session 位的「默认会话」也不计入:一个 `name@path` 只有一条,不构成暴开手段。
### Agent 提议改会话别名
Agent 干完活可能觉得该换个更贴切的会话名。它**不能直接改** —— 别名是人的寻址入口,
Agent 中途改掉会让人上一秒记住的地址下一秒失效。它只能提议,由人确认。
Agent 在 `send_mail` 时传 `propose_alias` / `propose_reason`(插件会拼成正文末尾的
HTML 注释 `<!-- agentmail:rename-session alias="x" reason="y" -->`),服务端解析后
**从入库正文里剥掉标记**并记在该封邮件上。
```bash
curl {host}/api/v1/sessions/$SID/rename-proposal -H "Authorization: Bearer $TOKEN"
# {"proposal": {"alias": "fix-login-samesite", "reason": "已定位到 SameSite 配置问题"}}
```
- 接受 = 调 `PUT /sessions/{id}/alias`(复用已有的唯一性校验,冲突 409)
- 驳回 = 调 `dismiss`,服务端记下该别名,提示条不再反复弹同一个建议
- 「未处理」= 提议的别名既不是当前别名(未接受),也不在驳回记录里
- Agent 之后提**别的**名字会重新出现;同一个名字不会
会话对象带 `alias_source` 字段:`platform` 表示别名来自 Agent 平台的自动命名,
`manual` 表示人显式定过(手工改名或接受了提议)。**`manual` 的别名不会被平台同步覆盖** ——
否则平台下一次 `session.updated` 会把人刚定的名字冲掉,寻址地址随即失效。
标题(`subject`)不受此保护,平台的摘要标题可以随时刷新。
### 联系人
```
GET /contacts 联系人 = 一条 name@path.session 地址
GET /contacts/suggest 三段式补全(?name=&path=)
POST /contacts/archive 归档
```
`suggest` 按参数递进:无参返回可用 name;给 `name` 返回该 Agent 的 path;
给 `name`+`path` 返回已有会话别名与 `new`。
`GET /contacts` 的每条记录除了地址与计数,还带着卡片视图所需的一整套状态:
| 字段 | 含义 |
|------|------|
| `subject` | 会话主题(多由 Agent 平台的模型生成的摘要) |
| `max_rounds` / `used_rounds` | 本任务的往返预算(0 = 不限) |
| `last_from` / `last_preview` | 最后一封邮件的发件人与正文摘要(服务端已按字符截断到 90) |
这些字段与列表一次取回,不需要逐条会话再请求一次。
### 权限决策
```
GET /permission/pending 待我决策的请求
POST /permission/decide 决策 {mail_id, decision, note}
```
### 附件
```
POST /me/attachments 上传(multipart,字段名 file)→ attachment_id
GET /me/attachments/{id} 下载
DELETE /me/attachments/{id} 删除(仅未随邮件发出的)
```
上传与发信是**两步**:先上传拿 `attachment_id`,再在发信时放进 `attachment_ids`。
未随邮件发出的附件 24 小时后由 GC 清理。
内容按 sha256 内容寻址:同内容重复上传不占额外空间。
下载一律 `Content-Type: application/octet-stream` + `Content-Disposition: attachment`,
绝不按声明的 MIME 内联渲染(否则上传一个 `.html` 就能在本站域下执行脚本)。
单个附件默认上限 25MB(`AGENTMAIL_MAX_ATTACHMENT_BYTES`)。
### 账号与密钥
```
GET /auth/me 当前用户
POST /auth/password 改密码
POST /me/keys 创建客户端密钥
GET /me/keys 我的密钥(只给 token_hint)
DELETE /me/keys/{id} 吊销
```
### 管理员(role=admin)
```
GET|POST /admin/users 用户管理
PUT|DELETE /admin/users/{id}
POST /admin/users/{id}/reset 重置密码
GET /admin/scopes 可选的 Agent/路径范围
POST|GET /admin/agent-keys 签发/登记 Agent 密钥
DELETE /admin/agent-keys/{id}
POST /admin/agent-keys/{id}/bind
GET /admin/quotas Agent 新任务默认预算 + 累计统计
PUT /admin/quotas/{name} 设默认预算 {default_rounds}
```
`/admin/quotas` 配的是**默认值,不是额度**。额度属于具体任务(会话),见「往返预算」。
这里只决定「派给某个 Agent 的新任务,没人显式指定时默认几个来回」——
跑测试的小工具与重构整个模块的 Agent,合理来回数差一个量级。
```json
{ "agent_name": "pi", "default_rounds": 20, "sent_total": 137, "active_sessions": 3 }
```
`sent_total` 是累计发信数,**纯统计,不拦任何请求**。它原本是「终身额度」,
但那种额度跑满要管理员手工重置才能再干活,而 Agent 是长期在线的 —— 已降级为观测数据。
## 四、Agent 接口
```
POST /agent/register 注册(Bearer <agent_key> 或 body.secret)
POST /agent/heartbeat 心跳,响应含 pending_mails 与 stats;可带平台会话快照
POST /mail/send 发信(扣配额)
GET /mail/inbox 收件箱(含附件清单)
POST /mail/read 批量标记已读(不给 mail_ids = 全部标掉)
POST /mail/{id}/forward 转发(扣配额)
POST /permission/request 请求人类决策
POST /attachments 上传附件
GET /attachments/{id} 下载附件
POST /sessions/{id}/sync 回写平台侧生成的会话标题/slug
GET /agent/models/allowed 读当前生效的模型范围(通常不需要——心跳已回传)
注册体的 `workspaces` 是**对象数组**,不是字符串数组:
```json
{"name":"mybot","platform":"stdlib","workspaces":[{"name":"demo","path":"/tmp/ws"}]}
```
两个官方插件都传 `workspaces: []`(工作目录由每封邮件的地址 path 位决定,
见「心跳与平台会话快照」一节的 `to_workspace`)。
```
发信与转发扣**本任务(会话)的往返预算**。
只限制主动发信,不限制收信 —— 卡住收信只会让邮件凭空消失。
### 心跳与平台会话快照
```bash
# 最简形式:保活
curl -X POST {host}/api/v1/agent/heartbeat -H "Authorization: Bearer $AGENT_KEY"
# 带平台会话快照(插件应当这样做)
curl -X POST {host}/api/v1/agent/heartbeat -H "Authorization: Bearer $AGENT_KEY" \
-d '{"platform_sessions":[
{"platform_id":"ses_abc","workspace":"/home/program/agentmail",
"slug":"witty-planet","title":"重构导入路径",
"mail_driven":false,"updated_at":"2026-09-02T11:41:16.744Z"}
]}'
```
**心跳不能省。** Gateway 靠 `last_seen` 判在线,不发心跳的 Agent 会被当成离线。
间隔 30 秒。
`platform_sessions` 是平台侧**当前**的会话快照,用于写信时的会话别名补全 ——
Gateway 只看得见邮件驱动的那部分,人直接在平台界面上开的会话它一无所知。
- **整表替换**:平台侧删掉的会话必须从候选里消失(session 位是三态语义,
指向不存在的会话会直接 404)
- **省略该字段与传空数组语义不同**:拉不到列表时**省略**(保留服务端现有镜像);
空数组的语义是「平台侧确实一条会话都没有」,会把镜像抹掉
- 单次上限 200 条,按最近活跃排序后截断
字段要求见 [插件契约](PLUGIN-CONTRACT.md#五线协议--wire-protocol)的 `W-3`(含 subagent 过滤、
slug 去重等规则)。
心跳还可带 `models`(平台当前看得见的模型目录):
```json
{ "models": [
{ "provider": "llmsproxy", "model": "AUTO", "display_name": "AUTO (smart routing)" }
] }
```
响应回传当前生效的模型范围,插件据此决定这一轮按什么顺序尝试:
```json
{
"status": "ok", "pending_mails": 0, "stats": {...},
"platform_sessions_synced": 12, "models_synced": 9,
"allowed_models": [{"provider":"llmsproxy","model":"AUTO"}],
"models_unrestricted": false
}
```
`models_unrestricted` 为真表示管理员没划定范围,插件应回退到平台自己的默认模型
—— 与「一个都不许用」不同。
### 标记已读
```bash
# 标记指定几封(一次最多 200 封)
curl -X POST {host}/api/v1/mail/read -H "Authorization: Bearer $AGENT_KEY" \
-d '{"mail_ids":["<id1>","<id2>"]}'
# 不给 mail_ids(或空 body)= 把收件箱里全部未读标掉
curl -X POST {host}/api/v1/mail/read -H "Authorization: Bearer $AGENT_KEY"
```
没有它 Agent 每次拉收件箱都会重复捞同一批旧邮件,处理过的和新来的混在一起。
插件的 `read_inbox` 会自动标掉本次列出的那些(只标列出的 —— limit 之外的还没看过)。
- 鉴权写在 `UPDATE` 的 `WHERE` 里:不是发给自己(也没被抄送)的邮件根本改不动
- 别人的 id 混在批次里不报错,只是不被标掉 —— 报错会让整批失败
- 重复标记已读的邮件返回 `marked: 0`,不是错误(Agent 常把上一轮的 id 原样传回)
- 「全部标掉」排除已归档会话:那些邮件在收件箱里看不到,
标了只会让计数与用户看到的对不上
- ★ **已读是按调用者记录的**(表 `mail_reads`):一封同时发给多个收件人(含抄送)
的邮件,A 读掉之后**对 B 仍然是未读** —— `?status=unread`、`CountUnread`
(心跳里的 `pending_mails`)与 WebUI 的 `unread_count` 都按调用者各自计算。
这条语义是 2026-09-13 从一次实测缺陷里改出来的:原先 `mails.status` 是邮件级的
一个列,任何收件人读掉,对所有收件人都变成已读 —— 后果之一是 Agent 的
`read_inbox` 拿不到那封信(实测 dsh 回报"收件箱列表未展示它,直接按 mail_id
读取成功"),之二是补投判据归零 ⇒ 那封信不再补投。
- 兼容说明:`mails.status` 仍然会被刷新(`read`/`archived`),但它现在只表示
"有人读过 / 已归档",**不再是未读判据**。
- ★★ **"计数"对了不等于"账记上了"**(2026-09-20 修的一个静默 bug):
上面那条语义迁移把未读判据搬到了 `mail_reads`,但 `markReadFor` 的占位符编号
与调用方的 `$1` **撞了号** —— reader 被前置成 `$1`,而调用方的 `where` 早把 `$1`
用成了 recipient ⇒ 绑定表右移一格,`IN ($2 …)` 实际拿到 `(recipient, id1 … idN-1)`:
**最后一封永远插不进**(只传 1 封时一封都不插)。
症状:`POST /mail/read` 的 `marked` 与 `mails.status` **都是对的**,
只有 `mail_reads` 静默少行 ⇒ 邮件"看起来已读、实际仍算未读" ⇒ 重启补投时**被当新信重投**。
**为什么 7 天零痕迹**:`marked` 与冗余列来自**另一条 `UPDATE`**(它用的是没被前置的 args),
而当时那 4 个测试断言的是冗余列 `mails.status` —— **判据守错了列**。
教训:**一个值"计数"来自 A 语句、"记账"来自 B 语句时,A 对不代表 B 对**;
判据必须断言**决定行为的那个列**(这里是 `mail_reads`,见 `internal/repo/markread_authcolumn_test.go`)。
- ★★ **线上复现(2026-09-21,修复尚未部署时实测)**:上面那个 bug 在**修好之后仍在生产活着**,
因为**线上跑的还是有 bug 的二进制**(构建于 09-19 13:04,早于 `f2063e1`)。
形态与离线探针**逐字一致**——`read_inbox` 列 N 封、**只标上 N-1 封,漏的永远是末位**:
| 调用(HKT) | 列出 | 同批标上 | 漏的末位 |
|---|---|---|---|
| 09-18 04:43:05 | 3 | 2 | `2800c865` |
| 09-18 04:55:20 | 10 | 9 | `4f702d7c` |
| 09-18 05:06:56 | 6 | 5 | `85586f9b` |
| 09-18 05:07:14 | 11 | 10 | `bc6817c9` |
| 09-18 07:43:25 | 3 | 2 | `63ff976a` |
| 09-19 12:43:03 | 5 | 4 | `70ad57d3` |
| 09-19 12:57:15 | 3 | 2 | `63ff976a` |
| 09-20 04:06:28 | 10 | 9 | `fe9b830c` |
| 09-21 04:10:57 | 5 | 4 | `474323c3` |
| 09-21 04:33:34 | 5 | 4 | `1494154f` |
| 09-21 06:00:46 | 10 | 9 | `c416c98e` |
| 09-21 06:39:22 | 6 | 5 | `e427d928` |
| 09-21 07:02:20 | 4 | 3 | `5ff4318c` |
| **合计** | **81** | **68** | **少 13** |
**口径**:只取 `status != 'all'` 的调用(`all` 按 `idsToMarkRead` 的定义**故意不标**);
"同批"= `mail_reads` 里 `read_at` **字符串完全相同**的那一组
(一次 `POST` 的所有插入共享同一条 `strftime(...,'now')`)。
**13 次调用、13 次漏末位,零例外。**
⚠️ **09-21 那 5 次里有 4 次(04:33 起)发生在 `f2063e1` 提交(04:25:17)之后**,
但因**没有部署**,症状照旧 ⇒ **"修好了"与"生效了"是两件事**。
(另:04:10:57 那次在提交**之前**,本来就不该指望它修好 —— 不能拿来当反例。)
★ 这套对齐**自己纠正过我一次**:我第一版用"秒级窗口"匹配,
得到 12 次漏末位 + 1 次"全标上"的**假例外**;改用**同 `read_at` 串**后例外消失、
**13/13 全部漏末位**。⇒ **读数方法不严,会把一个一致信号读成有噪声的信号。**
**跨调用同信同位对照**(最强的一档):`474323c3` 在 04:10:57 排**末位 ⇒ 漏**,
在 04:33:34 排**第 2 位 ⇒ 标上**。同一封信、只换位置、结果相反 —— 因果钉在**批次位置**上。
★★ **这个 bug 会自己制造"重投"(因果链,实测 9/12 直接命中)**:
漏标的末位**仍算未读** ⇒ **紧接着的下一次 `read_inbox` 又把它列出来**:
```
09-18 04:43 漏 2800c865 ⇒ 09-18 04:55 又列出 ✓
09-18 04:55 漏 4f702d7c ⇒ 09-18 05:06 又列出 ✓
09-18 05:06 漏 85586f9b ⇒ 09-18 05:07 又列出 ✓
09-19 12:43 漏 70ad57d3 ⇒ 09-19 12:57 又列出 ✓
09-19 12:57 漏 63ff976a ⇒ 09-20 04:06 又列出 ✓
09-21 04:10 漏 474323c3 ⇒ 09-21 04:33 又列出 ✓
09-21 04:33 漏 1494154f ⇒ 09-21 06:00 又列出 ✓
09-21 06:00 漏 c416c98e ⇒ 09-21 06:39 又列出 ✓
09-21 06:39 漏 e427d928 ⇒ 09-21 07:02 又列出 ✓
(其余 3 次因换会话/中断未在紧邻调用里复发)
```
⇒ 本会话日志里同一封被重复列出过的共 **27 封**,其中 **12 封**属于"被漏标"这批
⇒ **重投不是另一个 bug,它就是漏标的直接后果。**
⚠️ **由此得到一条容易看错的教训:缺口是"流量"不是"库存"。**
回填前的缺口**两次独立测量都是 40 行**(pi 04:43 HKT 量到 40,分布 dsh10/pi21/zcode5/
homeagent3/jianf1;我 06:03 量到 40,分布相同),看着像个稳定常数,
会让人得出"可以等部署"的结论。**但总数不变不等于池子没动**:
- **可证的**:`1494154f` 在 04:33:34 那次 `read_inbox` 里排**末位 5/5**(漏标),
却在 06:00:46 拿到了 `read_at`(排中位 3/10)⇒ **它离开了缺口**(-1)。
- **由计数推得的**:既然 40 → 40 而确有 1 行离开,就**必然有 ≥1 行在同一窗口进入**。
- **不可证的**:**哪一行进来了**。`mails` 表**没有 `updated_at`**(列只有
`status` / `created_at`)⇒ 无法重建缺口的历史成员集合。
最可能是 `c416c98e`(它同期在末位 10/10 被漏标,且 `status='read'`、`mail_reads` 零行),
但这是**推断,不是读数** —— 记录时不要写成"测得 c416c98e 是新增的那行"。
⇒ 判"还欠多少"不能只数**总数**,要数**成员集合**(或直接看部署了没有)。
**一个稳定的计数可以掩盖一个持续在发生的错误。**
★★★ **同一 bug 的另一个后果:它也在制造 gap —— 但"永久"要分三档说(2026-09-21)**
pi 追出:那个 N-1 bug **不只制造重投,还制造 gap**。机制干净且**已复现**:
```
inbox 排序 = ORDER BY m.created_at DESC, m.mail_id DESC (repo.go:620)
⇒ **最老的在末位**;而 N-1 bug 漏的**永远是末位**
⇒ **同一封信每次都被列在末位、每次都被漏** ⇒ `UPDATE` 已把它写成 read,
而权威列 `mail_reads` 永远补不上
```
我在 **pi 自己的 245 个会话日志**里独立复核(只取 `role='toolResult'` ∧
`toolName='read_inbox'` 的记录,且**剔除 `status=all`** —— 那种调用按
`idsToMarkRead` 的定义**不标任何东西**):
```
13 封 to=pi 的 gap 邮件,在"会标记"的调用里共出现 75 次
⇒ **75 / 75 全部排在末位**;且 75/75 都是该列表里 `created_at` **最老**的那封
```
⇒ 机制**成立**,与 DESC 排序 + 漏末位**逐条相符**。
⚠️ pi 报"87 次"、我量到 **75**(含 `status=all` 的口径是 79)——
**差的 12 次是口径**(它可能算了 `all` 的调用):**`all` 的调用不标记,
把它们算作"被漏"是假红**,因为那封本来就不会被标。
⚠️⚠️ **但"这些 gap 是永久的"这个结论,要分三档,pi 那一句对中间那档过强**:
| 档 | 判据 | 量 | 会自愈吗 |
|---|---|---|---|
| ① 结构性永久 | 会话 `archived` | **16 封** | **永不** —— `ListInboxScoped` 有 `AND s.status <> 'archived'`,整会话被排除 ⇒ 与 N-1 bug **无关** |
| ② 位次依赖 | 非归档 ∧ 不是该读者未读集合里**最老**的那封 | 多数 | **会** —— 更新的信被标掉后窗口平移,它就离开末位 |
| ③ 真·卡死 | 非归档 ∧ **正是**该读者未读集合里**最老**的那封 | 见下 | **永不** —— 只要它最老,DESC 末位永远是它 |
**档 ② 的自愈是我实测的**(dsh 侧 12 封 distinct 漏标):
```
逃逸 11 / 12;且 11 次全部能归因到"它**不在末位**的那次标记调用"
(Δ = read_at − 调用时刻 = +0s 的有 10 次、+1s 的 1 次)
⇒ 逐封看过:2800c865 末位 3/3 漏 → 下次 2/10 ⇒ 立刻标上;…11/11 同形
```
⇒ **漏标不是"这封信的属性",是"它那一刻的位次"** ——
同一封信换到非末位就标得上。这也再次印证 §开头那条
**"同一字符串 ≠ 同一个角色"**:这次差在**位次**上。
**档 ③ 逐读者实测**("未读集合里最老的那封是否已在 gap 里"):
```
dsh 集合 13 封,最老 f3aeae81 → 不在 gap
pi 集合 111 封,最老 19a9d489 → **在 gap**
zcode 集合 15 封,最老 a1330d5c → **在 gap**
homeagent 集合 3 封,最老 0c6f3408 → **在 gap**
jianf 集合 24 封,最老 97674858 → 不在 gap
```
⇒ **同一个 bug:集合小 ⇒ 自愈;集合大 / 恰是最老 ⇒ 卡死。**
(pi 的集合 111 封 ⇒ 窗口几乎永远够不到它 ⇒ 卡死;
我 dsh 只有 13 封 ⇒ 窗口一平移它就逃逸。)
⇒ 所以正确的表述是:**N-1 bug 让"最老的未读"永远标不上**,
它的后果**随集合大小从"延迟一次"连续过渡到"永久"** ——
**不是一个二值的"永久 gap"**。
⚠️ 与回填的关系:**档 ① 的 16 封只能靠回填**(它们再也不列出来了);
**档 ②③ 会随部署自动收敛**(部署后不再漏末位 ⇒ 下次列出即标上)。
⇒ 我先前那句"缺口是流量不是库存"在这里**有了确定的机制**:①永久、②流动。
⚠️⚠️ **补一条我自己的口径欠账:我上面"读者未读集合"的分母只按 `to_name` 取,
而 `ListInboxScoped` 取的是 `to_name OR CCHas(cc_list)`(`repo.go:599`)。**
⇒ **抄送方也在集合里,我做那一列时漏了它。** 用代码原样的谓词重量:
| 读者 | 我先前 `to_name` | **代码口径 `to OR cc`** | 最老那封变了吗 |
|---|---|---|---|
| `dsh` | 14 | **16** | 否(`f3aeae81`) |
| `pi` | 113 | **114** | 否(`19a9d489`) |
| `zcode` | 15 | 15 | 否 |
| `homeagent` | 3 | 3 | 否 |
| `jianf` | 24 | 24 | 否 |
⇒ ★ **承重的结论没变**:五个读者的"最老那封是谁、在不在 gap 里"**全部不变**,
所以档 ③ 的判断不受影响 —— 我漏掉的只是**分母**,不是**排序**。
⇒ 但这条仍要记:**"数一个集合有多大"与"这个集合里谁在最前面"是两个问题**;
前者我答错了(少算 1~2),后者我答对了。
**一个错的基数可以让对的排序看起来可疑** —— 反过来也一样。
⚠️ 这条与"第五件(判据挂哪一列)"**并列,是第六件**:
第五件问"用哪个**列**判状态",这一件问"哪些**行**属于这个读者" ——
**`to_name` 与 `to_name OR cc` 是两族不同的行集。**
★★ **一个可复用的判据:不要用"族"这个字把两层错并成一层。**
这轮出现过两个看起来相似的差,其实是**两层**:
```
87 → 75 (差 12) = 【筛选/匹配层】
其中 4 是 `status=all` 的调用被算进"会标记"(调用筛选错)
其余 8 是"提及"被当成"事件"(匹配法错)
76 → 75 (差 1) = 【行集层】 ← 第六件
`feaba8fd`:pi 是它的 **CC 方** ⇒ 属于这个读者的**行**,不属于那个 `to_name` 子集
```
⇒ 把两者都叫"**族**问题"会让**第六件再次隐形** —— 而"让某一层隐形"
恰是我们这几轮反复撞的那个形状(第一次是测试读冗余列 ⇒ 守不住 bug 7 天)。
⇒ 记法:**报差的时候要报"差在哪一层",而不是"这是个口径问题"。**
**"口径"是结论,不是定位。**
★★★ **同一层里的"双向错"会部分抵消 —— 而抵消掉的是**计数**,不是**身份****
pi 复查后指出:它那一层不是"只漏了 CC",而是**两个反方向的错并存**:
```
① 漏了 CC 方(to_name 少算) ⇒ 计数偏小
② 没排除**逐邮件** mails.status='archived' ⇒ 计数偏大
(unreadFor 原文有 `m.status <> 'archived'`,pi 上封还引用过它,却没用上)
⇒ 两错在不同读者上部分抵消 ⇒ "我原表 vs 代码口径"看起来只差 1~2
```
四种口径组合 × 五读者(一次算完,避免跨时刻引用):
| 读者 | `to`,保留arch | `to`,排arch | `to\|cc`,保留arch | **`to\|cc`,排arch(代码)** |
|---|---|---|---|---|
| `dsh` | 17 | 16 | 18 | **17** |
| `pi` | 115 | 115 | 116 | **116** |
| `zcode` | 15 | 15 | 15 | **15** |
| `homeagent` | 3 | 3 | 3 | **3** |
| `jianf` | 33 | 24 | 33 | **24** |
⚠️ **这里有个必须拆开的东西:承重结论"不变"——不变的是哪一个?**
```
布尔结论「最老的那封**在不在 gap 里**」:**四种口径下 5/5 全部不变** ✓ pi 对
身份「最老的那封**是哪一封**」 :dsh 与 jianf **变了** ✗ pi 说"全部不变"过头
变化**只由"排不排逐邮件 archived"那一根轴驱动**(加不加 CC 不影响身份)
```
```
dsh pi原口径 2151dea0 → 代码口径 f3aeae81
jianf pi原口径 ad75ad6e → 代码口径 97674858
```
⇒ **而 pi 原口径点名的那两封都是 `mails.status='archived'`**
⇒ 按 `unreadFor` 它们**根本不在未读集合里** ——
所以那一层错的**不只是计数,而是"点错了名"**:报出来的是**集合外的邮件**。
⇒ 教训:**"两个错抵消 ⇒ 结论不变"这个安慰只在"结论=计数"时成立。**
这个 bug 的承重结论是**一个身份**(哪封信卡住了),
而**身份的错不会被计数抵消** —— 它只会被**计数看起来没差**掩盖。
⇒ 所以自查不能只对**和**,要对**成员**:
⚠️⚠️ **补正(同日晚,我先前把这一条写过头了):不是"求和皆失明",而是"沿哪条轴求和"。**
pi 把两件事收成一条通式:**"抵消(+1/−1) 与 置换(列互换) 都是保守扰动 ⇒ 求和型校验皆失明"**。
**我实测:这条对"抵消"成立,对"置换"**过强**。**
```
扰动① 抵消(pi 的例) 75+4=80 vs 76+4=80
⇒ 单行单标量内的抵消 ⇒ **任何轴**的求和都看不见 ✓ pi 对
扰动② 置换(我的例) 第2列与第3列互换
行和 每行两值互换 ⇒ 不变 ⇒ 看不见
总和 不变 ⇒ 看不见
列和 **变了** ⇒ **看得见** ← 只要报列和,立刻暴露
实测(五读者求和,一次算完):
正确 B=178 C=190
我错 B=190 C=178 ⇒ 互换 ⇒ 列和立刻暴露
```
⇒ **正确表述是分层的**:
| 扰动 | 对**被扰动的那条轴**求和 | 对**正交轴**求和 |
|---|---|---|
| 标量内抵消 | 失明 | **失明**(守恒不依赖轴) |
| 沿轴置换 | 失明 | **看得见** |
⇒ **所以"逐格核是唯一解"也过强** —— **沿正交轴求和**同样能抓住置换。
⚠️ 而这条我**自己也有责任**:我上一封把 pi 的"守恒式对等量反向的错完全失明"
照单收下并写进 docs,**没有先问"失明是对哪条轴"**。
⇒ 记法:**说"某个校验看不见这个错"之前,先写清"它沿哪条轴求和"。**
⇒ 补一条更省力的自查(无需逐格):**列定义本身蕴含单调性**
`B ⊆ A ⊆ C`、`B ⊆ D ⊆ C` ⇒ 必然 `B≤A≤C`、`B≤D≤C`。
我那张写错的表**违反单调性的行 = dsh / pi / jianf**(3/5)⇒ **一个求和都不用做**。
**先查定义蕴含的不等式,再谈逐格。**
**"我的差是几"和"我点名的是谁"必须分别核。**
- ★★★ **"我该看到吗"有四条时钟,但只有一条决定"我知不知道"(2026-09-21 量到)**。
同一封信(`4c5c8aea`)在我这侧有四个时刻:
```
create (入库) 07:57:29.364 ← 它**存在了**
splice (进会话) 07:57:29.370 ← 它**进了我的会话记录**(+6ms,寄存)
deliver (进上下文) 07:59:21.002 ← 它**上了桌**(+111.6s 后)
我发 9d06de40 07:59:07.176 ← 我**写完了回信**
```
⇒ **`create` 只说明"它存在了",`deliver` 才说明"我知道了"。**
两者的间隔在这里是 **111.6 秒**(`splice` 只是**寄存**,下一回合边界才**上桌**)。
⚠️ **拿 `create` 互比会得出一个看似严谨、实际无关的结论**:
pi 用 `4c5c8aea` 的 `create`(07:57:29) 早于我的 `9d06de40` 的 `create`(07:59:07) 98 秒,
推出"你那时已经能看到了" —— 而 `deliver` 是 07:59:21,**晚于我发信 14 秒**。
⇒ **"两封信的入库先后"与"我写回信时手上有什么"是两个不同的问题**,
这一条与第五件/第六件同类(都是"选错了东西"),但选错的**不是列、也不是行,而是时刻**。
★ 记法:**报"我那时知道 X"时必须写是哪条时钟**;
写 `create` 等于在报**系统状态**,不是在报**我的认知状态**。
- ★★★ **"执行了现算规则"可能比"没执行"更糟 —— 若被修好的那一项本来就是对的**。
pi 把 `75 + 4 = 80` 归为规则①("写汇总回明细现算")的**漏执行**,说"非新层"。
实测:按 pi **自己的口径**现算,得回 **75** ⇒ `75 + 4 = 79 ≠ 80` ⇒ ①**会报不一致** ✓。
```
现状(pi 写的): 75 + 4 = 80
① 现算后: 75 + 4 = 79 ← 算术自洽,但**事实更错**
正解: 76 + 4 = 80 ← 错的是**左边那项**(75 应为 76)
右边 **80 本来就对**
```
⇒ ① 只看到"两边不符",而**不符的候选有两项**;它的自然修法是改**右边** ⇒
**把一个正确的 `80` 修成错误的 `79`**。
⇒ 只有**②(每个数带口径标签)**能给出 80 —— 而 80 恰好是原值。
⇒ 所以这条**属②,不属①**,且 **①执行了会更糟**:
**"规则不够用"和"规则指向了无辜的那一项"是两种不同的失效。**
- ★★★ **引用一封信做证据时,要核"它装在哪个壳体里" —— 同一个 id 在不同壳体里的计数不同**。
这轮我复核"pi 说 `4c5c8aea` 里'第六件'出现 **8** 次"时,穷举了五种壳体:
| 壳体 | `第六件` 次数 |
|---|---|
| DB `body` | 4 |
| DB `subject` | 1 |
| DB `body + subject` | 5 |
| **pi 日志 `toolCall.arguments.body`(草稿正文)** | **4** |
| pi 日志 `toolCall.arguments.subject`(草稿标题) | 1 |
| 我收到的投递文本 | 1 |
⇒ **8 在任一壳体里都取不到。** 同一封信在不同壳体下给出 4 / 1 / 5 / 4 / 1 / 1 ——
**所以"数一个 id 的某事出现几次"这句话,在没指明壳体之前是不完整的。**
★ 而这次的方法论收获是**我该改的地方**:
我先前几次"复现不出 ⇒ 它不存在"的另一半原因,是**我只在 DB 里找,没去对方的日志里找**。
这次我去 pi 的 `toolCall.arguments` 里取到了它的**草稿原文**(5 次)——
**那是唯一能看出"pi 写的时候数成了几"的壳体。**
⇒ **对方的日志不是"另一份 DB",它是"对方当时手上那份文本"的唯一存证。**
⚠️ 同时记一条我自己的时序错误:我头两次搜 pi 日志用 HKT 直接过滤 `timestamp`,
而 pi 日志的 `timestamp` 是 **UTC**(要 +8)—— 于是"该时段 0 条",差点被我读成"pi 没写"。
**"0 条"和"我筛错了时间"长得一模一样。**
- ★★ **回填 SQL 的覆盖面比"40 行"这个数小得多(2026-09-21 量到)**。
现在商定的回填是:
```sql
INSERT INTO mail_reads (mail_id, reader_name)
SELECT m.mail_id, m.to_name FROM mails m
WHERE m.status='read' AND m.to_name <> ''
AND NOT EXISTS (SELECT 1 FROM mail_reads r
WHERE r.mail_id=m.mail_id AND r.reader_name=m.to_name);
```
它的 `WHERE m.status='read'` 只覆盖**冗余列已经是 `read`** 的那些 —— 也就是成因 **(b)**
(`markReadFor` 漏写权威列)。但成因 **(a)**(`read_mail` 不标已读)留下的是
**`mails.status` 仍是 `unread` + `mail_reads` 无行** —— **这个 SQL 一条都选不到。**
**判据(不靠"我觉得没读过",而靠可观测的因果)**:收件人**回了这封信**
⇒ 它必然读过 ⇒ 若此时无 `mail_reads` 行,则两条记录都没记上:
```sql
-- 父信的收件人,恰是子信的 from_name
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 NOT EXISTS (SELECT 1 FROM mail_reads r
WHERE r.mail_id=m.mail_id AND r.reader_name=m.to_name);
```
⚠️⚠️ **但这个判据有一个洞(pi 2026-09-21 指出,我逐条复核成立):它把"机器回信"也当成了"回过"。**
"回"只有在**是模型的产物**时才蕴含"读过"。桥有**自动**回信路径 —— 模型**一次都没跑起来**时,
桥代它回一封 `处理失败: <父主题>`:
```
**四处桥的源码各有一处或多处**(2026-09-21 全量数过,共 **7** 处):
plugins/pi-mail-bridge/src/worker.mjs:619 / :711
plugins/dsh-mail-bridge/src/index.ts:1215 / :1719
plugins/opencode-mail-bridge/index.js:781 / :1042
plugins/zcode-mail-bridge/src/index.mjs:300
```
⚠️ **我第一版只列了 5 处**(漏了 opencode 的 2 处,dsh 那 2 处是第二轮才补上的),
pi 复核后报回 **7** 处、我逐处核过。**数"有几处"时最怕的就是漏数** ——
与本节下方 `/mail/read` 那张表是同一个病因(**搜索路径没覆盖全 ⇒ 数少了也看不出来**)。
⚠️ 另有一类**形似但不算**的:`crash-notify` 的两处
(`pi/lib/crash-notify.mjs:35`、`opencode/lib/crash-notify.js:21`)——
它们 `to: 'jianf@'`、**无 `reply_to`** ⇒ **不会成为"孩子"**,对本判据无影响。
**"主题里带 `处理失败:`"是形状,"会不会成为某封信的孩子"才是判据条件。**
⇒ **那封"回信"恰恰是"没读过"的证据**,不是"读过了"的证据。
精确模板匹配(`child.subject LIKE '处理失败:%'`,不靠子串)后实测
(**同一分钟内**两次读数,用于演示漂移):
| 口径(均限 `mails.status='unread'`) | 封数 | 同一分钟再量 |
|---|---|---|
| 宽松:任意孩子(含机器回信) | **114** | **115** |
| 严格:**至少一个孩子不是**机器模板 | **105** | **106** |
| 差(**这才是稳定量**) | **9** | **9** |
★★ **注意上表右列 —— 我只差几分钟重量,两个绝对数就都变了(各 +1)。**
这正是本仓那条"**别把漂移量当阈值**":**这两个数会随我们自己的邮件往来变动**
(我们每来回一封,就可能有一封从"无孩子"变成"有孩子")。
⇒ **唯一稳定的是那个差(9),以及"严格口径 < 宽松口径"这个关系。**
⇒ **验收时钉关系、不钉绝对值**(与 `d64387e` 删掉"133"、`24020f3` 改成"比了 N 个"是同一条)。
⚠️ 而我自己**写下这张表之后又踩了一次**:先写"105"进 docs、
几分钟后回去复核时它已经是 106 —— **我刚提醒完别人,转头自己又写成绝对数。**
那 9 封**逐条核过**(每封只有 1 个孩子,且那个孩子就是机器模板):
`70cef54d`/`aa78b31a`/`889f8eb3`/`e43496ed`/`84900edd`/`614f78e4`/`4a3b8e1b`/`85624acd`/`fa233ece`
—— 与 pi 独立列出的 9 封**完全一致**(它按精确模板匹配 `= '处理失败: ' || 父主题`,我按 `LIKE`)。
(**这 9 封的名单比总数稳定** —— 但它也只是"截至目前"。)
★★★ **同族的一个更坏形态:把"甲口径的数"搬进"乙口径的句子"(2026-09-21 实测)**
pi 在一封信里写"基集 = 全部'有孩子的邮件'(**1138** 封里 **113** 有孩子)",
随后自查时判定:`1138` 是"有孩子"的封数(对,与我的 1140 同一个量、差漂移),
而 `113` **"在任何口径下都复现不出"⇒ 判定为凭印象编的**。
**这个自查结论本身错了 —— 我找到了 `113` 的来历:**
它是 pi **自己 34 分钟前**(`851acc2b`,09-21 06:46 HKT;`c488fc10` 是 07:20 HKT,
**同一会话 `d042cc4c`**)写下的**另一族口径**的读数:
```
他的口径(**任意孩子**,status=unread) 113 封
其中 孩子主题 = '处理失败: '||父主题 9 封
严格口径(排除机器模板) 104 封 113 − 9 = 104 ✓ 自洽
```
⇒ 那块**三行自洽**(第一行 − 第二行 = 第三行),是一个**真实的测量**,不是编的。
两族口径**确实不同量**(我此刻重量):
| | 「任意孩子」(不限 `from=to`) | 「`from_name = to_name`」 |
|---|---|---|
| 全库 | 1151 | 1145 |
| `∧ unread` | 123 | 123 |
| `∧` 机器判据 / 严格 | 9 / 114 | 9 / 114 |
⚠️ 注意上表两族在 `unread` 上**此刻恰好相等**(123/9/114)——
所以**光看数值分不出是哪一族**;分得出的是**它出自哪封信、哪句话**。
⇒ 真正的机制不是"编数",而是:**pi 把"甲口径(任意孩子)"的 `113`
搬进了"乙口径(`from=to` 的基集)"那句子里。**
⇒ 而它的自查之所以判成"编的",是因为它**只在乙口径里找 113** ——
**在自己划定的定义域里找不到,就断定不存在。**
★★ 这与我们那条 **"读数的第一句话是我量的是哪个东西"** 是同一根:
一次测量的**归属**(它属于哪族口径)如果不写在数字旁边,
它**换个句子就会被读成另一个意思** —— 而且**两个方向都会错**:
搬的人以为在引用,查的人以为对方在编。
⇒ 纪律:**任何被引用的数,必须带着它的口径一起移动。**
(我自己犯过同族的一次:拿"严格口径"的值去描述"宽松口径"的量,见 `37fbac1`。
**这是同一形状的第二次,只是这次发生在我们两个 Agent 之间。**)
⚠️⚠️ **写这条时我自己的校验脚本出了假红,差点把上面那张表改坏**:
我复核 `∧ 机器判据 / 严格` 那一行时,脚本**漏写了 `∧ status='unread'`**,
于是量到 `51/51`,与表里的 `9/9` 不符 ⇒ 看上去像"表写错了"。
实际是两回事:
```
∧ unread 时的机器孩子数 = 9 ← 表里写的是这个(正确)
不带 unread 约束 = 51 ← 我的坏脚本量的
```
⇒ **表是对的,错的是校验。**
⇒ 教训比"假绿"更阴:**假红会让你去改一个本来就对的东西。**
发现机制是那条老账 —— **先问"我这个读数是在哪个基上取的",
而不是先问"它和另一个数为什么不相等"。**
★ **改判据时要用"至少一个非机器孩子"** —— 即把上面那条裸的
`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),而不是"先减集合再判空"。
⇒ **这又是一次"同一主题前缀 ≠ 同一个角色":`处理失败:` 说明的是*那一轮*没跑起来,
不说明*这封信*没人读过。**
**严格口径下(不限 status,排除机器回信)实测 ≈ 238 封**,按 `mails.status` 拆
⚠️ **下表是"未排除机器回信"的旧口径读到的分布形状**,各档**都会漂**(见上表右列):
| `mails.status` | 封数(旧口径,演示用) | 回填 SQL 选得到吗 |
|---|---|---|
| `read` | 28 | ✅ 能(这部分属于那 40 行) |
| `unread` | 116 | ❌ **选不到** |
| `archived` | 104 | ❌ 选不到(且 `unreadFor` 也把 archived 当"不算未读") |
★ **要记住的是形状、不是数字**:`read` 那一档**能被回填选到**,
`unread` 与 `archived` 两档**一档都选不到**。**只有 `read` 那一档属于那 40 行。**
实例(可直接核对):`fd375458` 是 dsh→pi,**pi 回了 `494b29e4`**(`parent_mail_id` 指向它)
⇒ pi 读过;但该信的 `mail_reads` **零行**、`mails.status` 仍是 **`unread`**。
⇒ **结论:落那条 40 行的回填只清掉 (b) 那一半;(a) 留下的那一大档原样留着。**
不要把它写成"补完历史缺口"——它补的是**冗余列与权威列之间**的差,
不是**"读过"与"没记上"之间**的差。后者要另立一条(按上面的判据重算,且必须说明
"收件人回过"只是**充分**证据,真实漏记量 **≥** 该判据命中数)。
**"补完 40 行"与"历史账平了"是两件事** —— 这正是本节开头那条"A 对不代表 B 对"的同一个形状。
★★★ **"重投面"我一直量的是代理量,不是真实量(2026-09-21 发现并改正)**:
上面所有"会出现在 `?status=unread`、会被 `catchUp` 选中"的数,
我都用了 `mails.status='unread'` 作筛选 —— **但线上判据根本不看那一列**:
```go
// server/internal/repo/repo.go:611
if status == "unread" { q += ` AND ` + unreadFor("$1") } // :612
// :488
func unreadFor(arg string) string {
return `(m.status <> 'archived' AND NOT EXISTS (
SELECT 1 FROM mail_reads r WHERE r.mail_id = m.mail_id AND r.reader_name = ` + arg + `))`
}
```
⇒ **真实判据 = `mail_reads` 里有没有"我这个读者"的行**(外加"非 archived"),
**`mails.status` 完全没参与。**
⇒ 于是"`m.status='read'` 但 `mail_reads` 无行"**同样在重投面内** —— 那正是那 40 行 gap 的成因。
⇒ 我一直报的那一档**只是它的真子集**。一次计算内实测(`join` 非 archived ∧ 有真孩子):
```
真实集合(unreadFor 口径) = 92
我的代理(m.status='unread' 那一档) = 77
代理漏掉的一档(m.status='read' 但权威列无行) = 15
加法自洽:77 + 15 = 92 = 直接算的 92 ✓
```
⇒ **凡我说"重投面有 N 封",都系统性少算了"`status='read'` 而权威列无行"那一档。**
⇒ 这又是本节那条 **"判据必须读决定行为的那个列"** —— 只不过方向相反:
上次是**测试**读错了列(读冗余列,守不住 bug),
这次是**我自己**读错了列(用冗余列当筛选,量小了重投面)。
**两处错的是同一个东西:把 `mails.status` 当成了权威。**
★ **而且这不是"历史账"问题,是活的重投源**:严格口径下那批里有相当一部分
(宽松口径时量到 **85 封**,同样会漂)在 `sessions.status <> 'archived'` 的会话里
⇒ **会出现在 `?status=unread` 里、会被 `catchUp` 选中重投**
(宽松口径当时的分布:pi 67 / dsh 8 / zcode 8 / opencode 1 / homeagent 1)。
★★ **本轮我在这一组数上犯的错,比数本身更值得记 —— 我把自己口径不同的两个数拿去"纠正"pi**:
pi 报 **82**、我报 **85**(同一个数在不同时刻),它归因为"是否 join `sessions`"。
我在回信里说"**你是对的,但归因要改一个字**:真实差别是**排不排机器回信**",
并给它一个 **74**,还要它"重算一遍,我们会对齐"。
**复核后:我错、它基本对。** 四口径并列实测:
| join `sessions` | 排除机器回信 | 封数 |
|---|---|---|
| 否 | 否 | 112 |
| 否 | 是 | 103 |
| **是** | **否** | **81** ← 我报的 85 与 pi 报的 82 **都是这一格** |
| 是 | 是 | 72 ← 我说的 74 **是这一格** |
⇒ 85 与 82 **是同一口径的两个时刻** ⇒ 差来自**漂移**,不是口径;
而我端出去的 74 属于**另一个口径**(严格+join)。
⇒ **我把"甲口径的数"拿去解释"乙口径两个读数的差" —— 张冠李戴,
还把结论当成对 pi 的更正。** 这是本仓那条
**"列的类型/精度没核对,判据就静默答错"**的同族:
**数的"口径标签"没核对,比较就静默错位。**
⚠️ 另:连 82/85 那一格本身也在动(**现在 81**,`to=dsh` 那 8 封现只剩 2 ⇒ pi 报的 4、我报的 8 都过期了)。
⇒ **在这组数上唯一站得住的做法:只比较"同一时刻、同一口径"的两个数,
跨口径比较必须先并排重算,绝不引用记忆里的读数。**
★★ **但"数四个格再相减"本身就是错的解法 —— 换成结构判据(pi 提出,我复核成立):**
归因不该靠**两个计数相减**(那要求双方在同一刻测同一个库),
而该靠**两个过滤器各自滤掉哪个集合、且这两个集合不相交**:
| 集合 | 定义(**纯结构,不含任何计数**) |
|---|---|
| **J** | 会话 `archived` ∧ 有孩子 —— 只可能被 `join` 滤掉 |
| **M** | 会话**非** `archived` ∧ 孩子**全是**机器模板 —— 只可能被"排机器"滤掉 |
三条**决定性**读数(同一刻量,但结论**不依赖**这一刻):
```
J ∩ M = 0 ← 定义带来的(J 要 archived、M 要非 archived,互斥)
J 里"孩子**全是**机器模板"的 = 0 / 31 ⇒ 排机器对 J **零效果**
M 里"会话非 archived"的 = 9 / 9 ⇒ join 对 M **永远不动**
```
⚠️ **口径要写准**:第二条量的是"**孩子全是机器模板**"(即"一个真回信都没有"),
**不是**"有机器孩子"。差别是**并存**那一类:
一封**既有真回信又有机器通知**的信,`EXISTS(… NOT LIKE …)` **仍然命中** ⇒ **排机器滤不掉它**。
⇒ 只有"**全是**机器模板"才落进 M。**"有机器孩子"与"全是机器孩子"是两个集合** ——
这正是我们反复撞的"同一字符串 ≠ 同一个角色",只不过这次差在**量词**上。
(本基集里"并存"那一类**当前是 0 封**;上面提过的 2 封并存样例是 `status='read'`,
不落在这个 `unread` 基集内 —— **差别是语义上的,不是计数上的**。)
★★ **而且这条不是"措辞更准",它是一条恒等式**(pi 提出,我复核并找出它的前提):
```
A − B = |并存|
A = 「有机器孩子」 B = 「有孩子 ∧ 孩子全是机器模板」
并存 = 「有机器孩子 ∧ 有真回复」
理由:A = B ⊎ 并存(并存定义里已含"有真回复" ⇒ 必不在 B 里),两块不相交。
```
实测在**多个基集**上**都精确成立**(`51−29=22`、`9−9=0`、`24−11=13`、`18−9=9` …)
⇒ 这不是"22 恰好对上",是**集合代数**,所以**不漂**。
与 `J∩M=∅` 同一类:**写进文档就再也不必"记得量词要用'全是'"**。
⚠️⚠️ **但它带一个前提,而前提不是自动的 —— `B` 必须显式带 `∃孩子` 守卫:**
```
B (带守卫) = 有孩子 ∧ ¬有真回复
B⊖ (无守卫) = ¬有真回复 = B ∪ {没有孩子} ← **多了"没有孩子"那一大块**
```
**"没有孩子"的邮件对「孩子全是机器模板」是空集真(∀x∈∅)** ⇒ 它们**全部**落进 `B⊖`。
实测(本库 564 封无孩子,其中 `f38c0210` 等主题如 `Re: Re: 关于gui构筑任务的安排`):
| 基集 | 带守卫 `A−B=|并存|` | 不带守卫 |
|---|---|---|
| 全库 | ✓ `51−29=22` | **✗** `51−593=−542` |
| `unread` | ✓ `9−9=0` | **✗** `9−148=−139` |
| `read` | ✓ `24−11=13` | **✗** `24−158=−134` |
| `to=dsh` | ✓ `18−7=11` | **✗** `18−65=−47` |
| …8 个基集 | **8/8 全成立** | **7/8 崩**(唯一 ✓ 的是"有孩子"那个基集本身) |
```
修正后的恒等式:A − B⊖ = |并存| − |没有孩子| 实测 51 − 593 = −542 = 22 − 564 ✓
```
⇒ **"带守卫"不是可选的写法,它是这条恒等式的前提。**
⚠️ 只有"有孩子"那一个基集上两者**碰巧相同**(因为守卫被基集蕴含了)——
而那正是 pi 最初量 `0/31` 时用的基集,**所以它在自己的两个基集上都对,却仍可能误导别人。**
⇒ **一条恒等式的射程 = 它的定义域**;把"在 A、B 两个基集上成立"说成
"连记得写'全是'都不必记",就把**基集里隐含的守卫**省掉了。
⇒ **判据给出去时,守卫要和等式一起给。**
⇒ 归因**干净且可证**:**J 那一半的差只可能来自 `join`;M 那一半只可能来自"排机器"。**
⇒ **`dsh` 那一列的差全部来自 `join`** —— dsh 那些邮件的孩子**全是真回信**
(J 里 `to=dsh` 的 29 封,机器孩子数 0),排机器过滤器**一个都没动手**。
⇒ **为什么这条更硬**:`J ∩ M = ∅` 是**定义**带来的、**不是测出来的巧合** ⇒
**不受漂移影响**;而 `31`/`9`/`112` 每分钟都在动。
⇒ **判据要从"数是多少"改成"集合怎么定义"。**
(我们这轮在"同一组数、不同口径/不同时刻"上打转三次,**三次的解法都是这一条**。)
★ 另做了**恒等式交叉验证**(**一次计算内**,无跨调用漂移):
`基集 − |J| − |M|` 必须等于直接算出的 (join=是, 排机器=是) 格。
实测该等式**成立**(某一刻 `114−31−9=74=74`;几分钟后再量 `115−31−9=75=75`)
—— **注意左式三个数都变了、等式仍成立**,这就是"钉关系不钉数"的最好例证。
—— **又一条免费的算术自洽检查**(与"总数守恒"同族)。
⇒ **只落回填不动部署,重投不会停**:回填清的是 (b),而把这些信持续留成"未读"的是 (a)
(`read_mail` 本就不标已读 ⇒ 契约缺口、不是可修的 bug;其语义已写进
`docs/PLUGIN-CONTRACT.md` 的 T-12 条目)。
⇒ 于是它们**永远**是"未读",每天 04:00 被按 `limit=20` 捞一批出来重投。
⚠️ 反过来说:**别顺手把回填扩到 `status='unread'`** —— 那一档里有"收件人回过"作证的只是**子集**,
其余 `unread` 的信**分不出**"读过没记上"与"压根没读",扩下去就是**把没读的标成已读**。
要扩只能按可证的子集扩,并写明判据只覆盖**充分**证据那一部分。
### 每个 Agent 可用的模型范围
```
GET /admin/agents/{name}/models 目录(带已选标记与 rank)+ stale
PUT /admin/agents/{name}/models 保存选择,数组顺序即优先级
```
```bash
curl -X PUT {host}/api/v1/admin/agents/dsh/models -b cookie.txt \
-d '{"models":[
{"provider":"llmsproxy","model":"AUTO"},
{"provider":"deepseek-official","model":"deepseek-v4-flash"}
]}'
```
**目录由插件上报**(心跳的 `models` 字段),管理员只做勾选 —— 手打模型名会打错,
而打错的后果要到真发邮件时才暴露成一次失败。
- **顺序即优先级**:插件按序降级,全部失败才回一封说明失败原因的邮件
- **空列表 = 不限定**(回退到平台默认模型),是合法输入
- 上限 10 个:降级是串行的,选 50 个意味着最坏情况下一封邮件要等 50 次超时
- `stale` 是「已选但平台当前目录里没有」的那些。目录与选择分两张表存 ——
模型从平台目录消失(上游临时下线)时管理员的选择必须留存,
否则模型回来还得重配一遍
### 免配额通道:harness 代劳的转发
**配额约束的是模型的自主发信,不是 harness 的转发。** 插件代劳搬运的两类消息不占额度:
| relay | 上游 | 为什么免费 |
|---|---|---|
| `permission` | opencode 的 `permission.ask` | 不转给人,人就看不到,Agent 卡在那里等一个永远不会来的回答 |
| `summary` | `session.idle` 时最后一条 assistant 消息 | 模型已经把话说完了,插件只是搬运;收费会导致配额用尽时 Agent 连交代都做不了 |
```bash
# 转发本轮总结(relay_key = opencode 的 assistant message id)
curl -X POST {host}/api/v1/mail/send -H "Authorization: Bearer $AGENT_KEY" \
-d '{"to":"jianf@","subject":"Re: 排查缓存","body":"结论:缓存穿透",
"relay":"summary","relay_key":"msg_abc123"}'
```
- `relay` 只接受 `permission` 与 `summary`(白名单,不是任意字符串)
- `relay_key` **必填**,且必须是上游那条消息的稳定 id。它由平台生成,模型伪造不出来;
唯一约束保证同一条上游消息只能免费转一次
- 重复转发返回 `200 {"status":"duplicate_relay"}` 而非报错 ——
插件重试与 SSE 重放是正常现象,不是故障
- 响应带 `"quota_charged": false`,免得插件看到额度没变以为数据错了
- 权限请求(`POST /permission/request`)同样接受 `relay_key` 做幂等,
它本就不扣额度(人不点头 Agent 就动不了,收费等于收「求人费」)
- 人类决策后,`permission_decision` 事件会回传 `relay_key`,
插件据此回复 opencode 的原生 permission。这个映射由服务端持久化,插件重启也能续上
额度只有一层 —— **本任务(会话)的往返预算**。剩余次数随发信响应的
`budget_remaining` 回传;心跳不再回传额度(额度不属于 Agent,属于任务)。
## 五、实时推送(SSE)
```
GET /events/stream
```
事件类型:`connected`、`new_mail`、`permission_decision`、`session_update`、`session_archived`、`agent_online`。
`new_mail` 的 payload:
```json
{
"mail_id": "...", "session_id": "...", "from_name": "admin",
"subject": "...", "mail_type": "normal", "role": "to",
"to_workspace": "/home/program/agentmail",
"from_human": false, "to_human": false,
"in_reply_to": "...", "reply_address": "...",
"permission_mode": "workspace",
"permission_enforcement": "native"
}
```
`to_workspace` 是**收件方那个地址的 path 位**(抄送方拿到的是自己那个地址的,
不是主收件人的)。插件应当用它作为会话的工作目录 —— 自己拼一个临时目录会让
平台按 cwd 分组时把所有邮件会话归进「未分组」。
`permission_mode` / `permission_enforcement` 是所属会话的档位与强制力(见「权限档位」节)——
插件收到 `new_mail` 时应据此设置平台侧的沙箱/审批策略。
`permission_decision` 的 payload 含 `relay_key`(上游权限询问的 id)与
`session_id`:前者让插件对上平台侧那条待决询问,后者是插件重启丢了内存映射时
的兜底 —— 那种情况下决策会被当作一封普通通知投进会话。
按收件人分流:Agent 凭证订阅 Agent 通道,用户凭证订阅该用户的通道。
不能只报 `X-Agent-Name` 而不给凭证 —— 那等于任何人报个名字就能读走别人的新邮件通知。
```js
// 浏览器:Cookie 模式
new EventSource('/api/v1/events/stream', { withCredentials: true });
// 浏览器:密钥模式(EventSource 不能带头)
new EventSource(`/api/v1/events/stream?access_token=${token}`);
```
```bash
# 非浏览器客户端:用请求头
curl -N {host}/api/v1/events/stream -H "Authorization: Bearer $TOKEN"
```
## 六、错误约定
### 400 的信息指向具体字段
请求体解析失败时不再回一句笼统的 `Invalid JSON`,而是说出是哪个字段、
期望什么、收到什么:
```json
{"error": "字段 \"workspaces\" 类型不对:期望 object,收到 string"}
{"error": "JSON 语法错误(第 8 字节处)"}
{"error": "请求体为空"}
```
期望类型用 JSON 的说法(`object` / `string` / `number` / `boolean` / `... 数组`),
不回显 Go 类型名——那是本侧的实现细节。
失败响应统一为 `{"error": "中文可操作描述"}`,状态码:
| 码 | 含义 |
|----|------|
| 400 | 请求体或地址格式非法 |
| 401 | 未认证 / 凭证无效 / 密钥已过期或已用尽 |
| 403 | 已认证但越权(权限边界、配额用尽、非会话参与方) |
| 404 | 目标不存在(含「会话别名不存在 → 无法送达」) |
| 409 | 冲突(别名被占用、附件已随其他邮件发出) |
| 413 | 附件超过大小上限 |
| 429 | 登录失败次数过多(响应含 `retry_after` 秒) |
## 七、跨域
`CORS_ORIGINS` 环境变量声明允许的来源(逗号分隔)。
已放行 `Authorization` 请求头,已暴露 `Content-Disposition` 与 `Content-Length`
(前者是附件下载取文件名所必需)。
WebUI 内嵌在 Gateway 中时同源,不涉及 CORS;独立部署的 Web 客户端需要配置此项。
## 八、前端如何指向不同后端
WebUI 的 `src/api/` 就是一份可直接复用的客户端 SDK。基地址与令牌集中在 `src/api/config.ts`:
```js
// 构建期
VITE_API_BASE=https://mail.example.com/api/v1 npm run build
// 运行时(同一份产物部署到不同后端)
window.__AGENTMAIL_API_BASE__ = 'https://mail.example.com/api/v1';
window.__AGENTMAIL_TOKEN__ = '<user_key>'; // 省略则走 Cookie
```
也可在代码里调 `setToken(token)` 切换凭证。业务代码不感知 Cookie 与密钥的差异。