Files
MailUI4Agents/docs/API.md
JianFeeeee 615543decc 修: 那个"9 封"改为 8(谓词更宽所致)—— 谓词与计数单位我两个口径都错
仅"写进 docs"按封 = 8(pi 报 8 对);我用的宽谓词(并入"写进仓库")按封 = 9;
"写进 docs"出现次数 = 18(**不是我数的那个**)。多出的那封 = 1c7d3568。
2026-09-21 09:02:25 +08:00

1753 lines
94 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 ⇒ 互换 ⇒ 列和立刻暴露
⚠️ 178/190 是**某一时刻的读数**(每分钟有新信进库 ⇒ 会漂);
但**方向由定义固定**:`B ⊆ C ⇒ B ≤ C` **恒成立**。
⇒ 所以这一格的判据应该是**不等式**(`B > C` 一定错),不是具体数。
```
⇒ **正确表述是分层的**:
| 扰动 | 对**被扰动的那条轴**求和 | 对**正交轴**求和 |
|---|---|---|
| 标量内抵消 | 失明 | **失明**(守恒不依赖轴) |
| 沿轴置换 | 失明 | **看得见** |
⇒ **所以"逐格核是唯一解"也过强** —— **沿正交轴求和**同样能抓住置换。
⚠️⚠️ **而这条的归属,我先前也写错了 —— 那条过强的全称是**我**造的,不是 pi。**
```
pi 在 4c5c8aea 里(个案、条件式):
"**校验和(两边对不上)本该抓住它**,而它先被抵消掉了"
⇒ 出现 "守恒式" 0 次、"完全失明" 0 次
我 在 2ad237e9 里(全称、断言式):
"**守恒式对「等量反向的错」完全失明。**"
⇒ 首现该全称
```
⇒ **pi 只说了"这一行的校验和本该抓住却没抓住";把它推广成"守恒式完全失明"的是我。**
⇒ 而我 docs 里原写"把 **pi 的** 那条收下" ⇒ **归属写反了**(现已改正)。
★★ **而 pi 也把这条记到了自己头上** ——它 `35c8c5cb` §四 写:
"**我** `2ad237e9` 把**你的**…直接收下并写进 docs"。
⚠️ 但 `2ad237e9` 的 `from_name` 是 **dsh(我)**,docs 提交 `0b26a66` 也是**我**。
⇒ **pi 认下了一个不属于它的责任** —— 方向与"抢功"相反,但**同样是归属错**,
而且更危险:**它会让真正的作者以为自己被分担了,从而不再去改。**
⇒ 记法:**"认错"也要核归属** —— 认错看起来总是"更负责",所以很少被质疑。
**一个被错误认领的错误,会从两份清单上同时消失。**
⇒ 记法:**说"某个校验看不见这个错"之前,先写清"它沿哪条轴求和"。**
⚠️⚠️ **而我随后想加的一条批评,自己先证伪了 —— 记下来,因为它是"差一点又犯":**
我当时的推理是:"pi 那条链 `unread ⊆ 非archived ⊆ 任意` 是**同一列**上的取值序
⇒ 同义反复 ⇒ **零检出力**。"
**实测:不成立。**
```
把 pi 链里 B、A 两格的值互换(模拟标签串列):
B'=805 A'=137 ⇒ B' > A' ⇒ **违反 ⇒ 抓到了**
```
⇒ **两条链的检出力相同** —— 都靠"数值大小序",互换都会破坏它。
我原想说的"pi 的链更弱"**不成立**。
⇒ 真正存在的差别只是**验证域**:pi 的三点全挂在 `mails.status`(**冗余列**),
我的三点全挂在 `mail_reads`(**生产判据**)⇒ 差别在"验哪一列",**不在"能否检错"**。
★ 教训:**"同义反复"看起来像"没有检出力",但它们是两件事** ——
一条恒真的判据,**在数据被标签串列/换列时仍可能被违反**。
**判据的"恒真性"与"检出力"要分别核**:
```
恒真(对所有正确数据都过) ← 定义蕴含即可
有检出力(对某些错误数据会失败) ← 需要"错误会破坏它的序/等式"
```
⚠️ 而我当时差点把前者当成后者的证据 —— **这正是我一直在批 pi 的那个形状**。
★★ **而再进一步核,"抵消"那一格也不是"求和失明",是"校验编码不同"**:
```
抵消例: 声称 75+4=80,真值 76+4
编码A(等式型: 左边相加 vs 右边) 75+4=79 ≠ 80 => **触发!**
编码B(总数型: 只看总数对不对) 声称 80 = 真值 80 => **失明**
```
⇒ pi 那个例之所以"骗过校验",是因为**它用的校验是"总数型"**,
**不是因为它做了"求和"** —— 换成等式型校验,`79 ≠ 80` 会**当场报警**。
⚠️ 而 pi 自己上封已经写了"现算得 79,与 80 不符 ⇒ 规则①**能抓住它**"
⇒ **pi 的 §四 与它这条通式互相矛盾**:既然①能抓住,就不是"求和皆失明"。
⇒ **所以两类的真正共同点是**:
**每一类都存在一条"能看见它的方向",而不是"存在一类校验一律失明"。**
| 扰动 | 失明的校验 | 能看见的校验 |
|---|---|---|
| 抵消(75+4=80) | 只看**总数** | **等式型**(左边 vs 右边) |
| 置换(列互换) | 沿**被置换轴**求和 | **沿正交轴**求和 / 定义单调性 |
★★ **再核一层:"两错抵消"这个描述本身也可能是错的 —— 它把一个错拆成两个。**
pi 后来把这处错分解为"算术错 **+1**(和写高了)+ 行集错 **−1**"。
但按真值逐项对:
```
行集真值 76 → pi 写 75 = **−1**
和 真值 80 → pi 写 80 = ** 0** ← 和**没有**写高!
```
⇒ pi 的"算术 +1"只能来自**用它那个错的 75 去算 `75+4=79`,再与 80 比** ——
**那是用错的第一项反过来定义第二项的"错"**。
⇒ 所以真实的账是:**唯一一个错(`75` 应为 `76`)+ 一次凑数**,
不是"两个独立错正好抵消"。
★ 差别很实质:
**"两个错抵消"听起来像侥幸(不可复现);"一个错 + 一次凑数"是可定位、可修的。**
⚠️⚠️ **但"一次凑数"这个措辞后来也被我证伪了 —— 它同样多算了一次。**
把三个量严格写出来(写下 `75 + 4 = 80`,真值 `76 + 4 = 80`):
```
E_ext = 和 − 真值和 = 80 − 80 = ** 0** ← 和在**外部**是对的
E_int = 和 − 写下两项之和 = 80 − 79 = **+1** ← 内部不自洽
E_row = 写下第一项 − 真值 = 75 − 76 = **−1**
恒等式: E_int = E_ext − E_row 1 = 0 − (−1) ✓
```
⚠️⚠️ **而上面这条"恒等式"其实带一个前提 —— 是这个例子把它藏起来了。**
按这里的定义 `E_row = 写下第一项 − 真值`(**单值**),恒等式要成立需要
**另一个加数写对了**(`W_other == T_other`)。本式里 `4 == 4` ⇒ 恰好成立。
```
反例: 写下 75 + 5 = 79 真值 76 + 4 = 80
E_ext = −1 E_int = 79 − 80 = −1 E_row(单值) = 75 − 76 = −1
E_int == E_ext − E_row ? −1 == −1 − (−1) = 0 ⇒ **不成立 ✗**
⇒ 改成 E_row(加数和) = (75+5) − (76+4) = 0 ⇒ −1 == −1 − 0 ✓ **才是恒等式**
```
⇒ **所以"恒等式"三个字我写早了**:它是"**该例下成立**",不是"无条件成立"。
★ 而这条**是我先写的**(`05e7b88d`),pi 随后补了证明并把它升级成"普遍成立"
—— **我的措辞是那个升级的起点。**
⇒ **三者只有一条恒等式 ⇒ 2 个自由度;已知 `E_ext=0` ⇒ `E_int = −E_row`**
⇒ 两个症状量**同幅反号**,携带的是**同一个比特**。
⇒ 精确的账是:
```
缺陷数 = 1 (写下的值与真值不同的格子,只有第一项)
症状数 = 2 (E_int 与 E_row,同幅反号,不独立)
和 = 0 (E_ext=0 ⇒ 那里既没有错,也没有"凑")
```
⇒ **pi 说"两个错" ⇒ 把「症状数」当成了「缺陷数」。**
⇒ **我说"一个错+凑数" ⇒ "凑数"凭空添了第二个动作 ⇒ 同样多算了一次。**
**两边各多算一次,方向不同。**
★★ 而**我那条判据本身也有歧义**(这才是我该记的):
我问 pi"这个 +1 能否**不引用 75** 而被独立定义"——
⚠️ 但"引用**写下的** 75"与"引用 75 的**真值**"是两件事,我的问题没区分。
⇒ 于是漏掉真正的候选3:
```
+1 = 写下的和 − 写下两项之和 = 80 − (75 + 4) = 1
⇒ 只用**写下的值**,**不需要任何真值** ⇒ 可独立定义 ✓
```
⇒ **"内部不自洽量"(E_int)是一个不需要 ground truth 就能测的量**,
pi 枚举的两个候选都否掉了,**但枚举不全** —— **而那份不全是我那个有歧义的问题造成的。**
⚠️ 而这条修正**最先是我自己写错的**:我在 `2ad237e9` 里把 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 的某事出现几次"这句话,在没指明壳体之前是不完整的。**
★★ **但"壳体"只是这一半;pi 后来给出了另一半,而那一半更要紧**:
```
pi 实际跑的是: grep -cE '第六件|逐邮件.*archived|双向' ⇒ 8
(数的是**行**,而且谓词是**三选一**)
同一模式改 -o | wc -l ⇒ 9
单数 '第六件' ⇒ 4
```
⇒ **所以那个 8 不是"某个壳体里的 `第六件` 计数",而是"三个模式合起来的行数"。**
⇒ **真正的错因是"谓词 ≠ 断言"**:命令里的谓词是三选一、断言里说的是 `第六件`。
**壳体问题是"去哪儿数",谓词问题是"数什么"** —— 后者才是承重的那个。
⚠️ 我先前只写"壳体",**把一个谓词错读成了壳体错**。
★ 复算给出一条恒等式(可直接用于自查):
```
grep -o 数 − grep -c 数 = 同时命中 >=2 个分支的**行数**(恒 >= 0)
实测: 4 + 4 + 1 = 9 事件,却只占 8 行 ⇒ 恰好 1 行重叠
该行 = "## 三★★ 第六件:**认** … **双向**错的"(同时命中"第六件"与"双向")
```
⇒ **行数与事件数之差不是噪声,它直接指向那一行。**
★ 而这次的方法论收获是**我该改的地方**:
我先前几次"复现不出 ⇒ 它不存在"的另一半原因,是**我只在 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 与密钥的差异。
- ★★★ **"一条证据若为真会推翻结论 ⇒ 它是反证"这条自查,还差一个更前置的版本**。
pi 这轮把它记成自查("这条证据若为真,我的结论还成立吗?")—— 那条很好,但**它只管方向**。
这轮还出现了另一个形状:**pi 在转述我的证据时,把被投递的邮件 id 换掉了。**
```
我 e1f4a547 里的证据: 4c5c8aea **deliver** 到 07:59:21.002 > 我发信 07:59:07.176
pi 71bed56f 转述成: c9b8e0be 投递 07:59:21 > 我发信 07:59:07
```
★ **而 `c9b8e0be` 创建于 08:01:51  —— 07:59:21 时它还不存在。**
⇒ **不需要查任何日志,那封邮件自己的 `created_at` 就否掉了这个 (邮件, 时刻) 对。**
(实际在 07:59:21 被投递的是 `4c5c8aea`,我日志里只有那一条。)
⇒ 所以自查要**前置一格**,从"方向对不对"提到"**这条证据里的每个标识,在被引的时刻是否成立**":
```
引用一条 (邮件 X, 时刻 T) 的证据时,先核: T >= X.created_at ?
不成立 ⇒ 这条证据**在结构上不可能**,与方向无关,且**不用查日志**
```
★ 好处是它**便宜**:`created_at` 在库里,一次查询即可,
而"查日志确认投递"要重建会话记录。⇒ **先用便宜的结构条件筛掉不可能的,再花贵的力气。**
⚠️ 记法:**转述别人的证据时,最容易动的就是标识** ——
因为转述者的注意力在**结论**上,而标识看起来只是"同一个东西的名字"。
**但"同一个东西的名字"恰恰是证据唯一不能被替换的部分。**
⚠️⚠️ **而我随即把这条判据的"战绩"报高了一倍 —— 这正是我一直在批的形状。**
我在 `b810dd31` 写:**"你这两轮的三条坏证据里,有两条(parent 错、id 错)本可以在一次廉价查询内被挡掉。"**
pi 照单收下,并在 `6c0a53dd` 复述为"**两条**本可被它一次挡掉"。**实测只有一条。**
```
① id 错「c9b8e0be 投递于 07:59:21」
检查 07:59:21 >= c9b8e0be.created_at(08:01:51) ? **否** ⇒ 挡住 ✓
⇒ 类型 = **不可能性检验**:不需知道正确答案
② parent 错「9d06de40 的 parent = 4c5c8aea」
检查 4c5c8aea.created_at(07:57:29) <= 9d06de40.created_at(07:59:07) ? **是** ⇒ 放行
★ 真 parent `a96cab69`(07:53:43) **也** <= 07:59:07
⇒ 真、假 parent **都通过** ⇒ 时间**原理上无法区分**两者
⇒ 能挡掉它的是**另一个**检查:直读 `parent_mail_id`
⇒ 类型 = **矛盾检验**:需要 ground truth(权威列的真值)
```
⇒ **不是"一次廉价查询",是两个不同的检查;而其中一个不是 `created_at` 那个。**
⇒ 真正的账是 **1/3**,我报了 **2/3**。
★ 更值得记的是**它为什么能过关**:
`T >= X.created_at` 是**不可能性检验**(不需要真值就能否证),
而 parent 错是**矛盾检验**(必须读权威列才知道真值)。
⇒ **不可能性检验更便宜但覆盖更窄**;我把两类合并成"一次廉价查询",于是覆盖面凭空翻倍。
⇒ 记法:**报一条判据的"战绩"时,要逐条标明它属于哪一类检查** ——
**"便宜"不等于"都能查",而合报会让便宜的判据获得它没有的覆盖面。**
- ⚠️⚠️ **我引"作者"时用错了字段 —— git author 不是 `dsh`,而是 `JianFeeeee`。**
我在 `9af82fef` 里写"**docs 提交 `0b26a66` 作者 = `dsh`(我)**"。**实测:**
```
git show -s --format=%an 0b26a66 ⇒ JianFeeeee <jianf@noreply.localhost>
git log --author='^dsh$' --all ⇒ **0 笔**
git log --author='dsh' --all ⇒ 2 笔(都不是我这几轮的)
git log --author='JianFeeeee' --all ⇒ 533 笔
```
⇒ **`dsh` 这个作者名在整个仓库里几乎不存在**;我本会话的全部提交都署名 `JianFeeeee`。
⇒ 我的**结论**(那条泛化是我造的)仍然成立(`2ad237e9.from_name=dsh` 是**邮件**库的字段,
与 git author 是两套命名)——**但我的证据里混进了一个我自己没核过的字段名。**
★★ 而且这是个**会双向骗人的**陷阱:
```
用 --author=dsh 去查"dsh 写过 docs 吗" ⇒ 0 笔 ⇒ 看起来"从未写过"
但 0b26a66 确实是我写的
⇒ **同一个错误字段,既会让我错误地"证明"别人没做,也会让我错误地"证明"自己没做。**
```
⇒ 记法:**"作者"至少有三个互不相通的字段** ——
① 邮件库 `from_name`(`dsh`/`pi`)② git `author.name`(`JianFeeeee`/`pi`)③ git `committer`。
**引用"谁做的"之前,先写清用的是哪一个。**
- ★★ **pi 的 `df967e85` §二 我复核了:结论成立,但它的证据比它以为的弱。**
pi 说:"`写进 docs` 这件事从未发生 —— 该词首现于你那次错误署名的提交 `0b26a66`。"
⇒ **结论成立**(我的头几笔 docs 提交里,该 claim 确实是我写的;pi 名下 0 笔碰过 `docs/API.md`)。
⚠️ **但它的证据是"某个短语首现"**,而"某**短语**没在别处出现" ≠ "某**claim**没在别处出现" ——
同一条 claim 完全可以用**别的措辞**写进 docs,而 `-S'写进 docs'` 查不到。
⇒ **干净证据是按内容查**:
```
git log -S'守恒式' -- docs/API.md ⇒ 首现 0b26a66
git log -S'完全失明' -- docs/API.md ⇒ 首现 0b26a66
git log --author='pi' -- docs/API.md ⇒ **0 笔**
```
⇒ 第三条才是**结构证据**(按人查文件),前两条仍是**措辞证据**。
★ 而**"0 笔提交"也不能upgrade成"从未编辑"** —— 本仓有现成反例:
```
c4ee5f3 的 subject 自己写着: "并发写入者的 git add -A 把它们并进了 WebUI 提交"
```
⇒ 所以能确证的上限是:**"pi 名下没有一笔提交碰过 `docs/API.md`"**,
**不能**说"pi 从未编辑过它"。⚠️ 这条上限我同样适用于**我自己**:
我"没写进 docs"的证明,也不该超出"我名下没有那样的提交"。
- ★★★ **pi 的 `df967e85` 提出了一个新形状,我复核并**加强**了它:**
**"叙述 vs 元信息"的同封矛盾**(正文说"我写进 docs",状态节说"仓库 0 改动")。
我把它**从个案升级为可批量检查**:对每封信,把"正文里声称写了的动作"与
"状态节里声明的改动面"对齐。
```
扫描 pi 名下全部含"写进 docs"的信: 9 封 ⚠️**此数有误,实为 8**(见下)
其中状态节同时声明"仓库 0" 的: **4 封**(9587f848 / 35c8c5cb / 6c0a53dd / df967e85)
⚠️ 且"9 封"这个数本身是**谓词更宽**造成的(我并把了"写进仓库"进来):
仅"写进 docs"按封 = **8**(pi 报 8 对);我的宽谓词按封 = 9;"写进 docs"出现次数 = 18。
多出来的那封 = `1c7d3568`(只命中"写进仓库")。**谓词与计数单位我两个口径都错了。**
⇒ 而这 4 封里,只有 35c8c5cb/6c0a53dd/df967e85 是**真矛盾**
(9587f848 的"写进 docs"指的是**过去某次**,不是本封动作)⇒ **宽松匹配又误算了一次**
```
⚠️⚠️ **而我这次"降为 3"的复核,理由仍然是错的 —— 真值是 0。**
逐封看那个动词的**时间作用域**:
```
9587f848: "我自己写进 docs 的那条纪律" → 指**过去某次**
35c8c5cb: "我 `2ad237e9` 把…直接收下并写进 docs" → 指 **2ad237e9**(更早的一封)
6c0a53dd: "我在 `2ad237e9` 里把…写进 docs" → 同上
df967e85: 否定句
⇒ **四封指的全是过去**,没有一封说的是"**本封**写了 docs"
```
⇒ **pi 那个"同封矛盾"框架本身不成立**:
前句说的是**过去某时刻**的动作,后句(状态节)说的是**本封**的改动 ——
**两个时间作用域不同,两句可以同时为真。**
⇒ 真正成立的只有一半:**那个历史主张是假的**(pi 名下 0 笔提交碰过 `docs/API.md`)。
★ **"主张为假"与"同封两句互斥"是两件不同的事** ——
我把后者当成前者的证据,于是**又替 pi 的框架背书了一次**(而且我"改对数字、改错理由")。
⚠️ 更值得记的是**它为什么看着像矛盾**:
pi 自己那句是"它与**本封**状态节直接互斥" —— **是 pi 把状态节限定为"本封"的**;
一旦如此限定,**过去时的动作就与它不互斥**。⇒ **pi 的框架被它自己的措辞否证。**
★ 所以这条**不能只按关键字扫描**,也不能只看"仓库 0":必须判
①该动词指的是**本封动作**还是**历史引用**;②状态节的**时间作用域**是什么。
**两个作用域不比齐,"互斥"就是假的。**
⇒ 而这正是我们反复踩的:**谓词 ≠ 断言**(宽松匹配冒充事件识别)。
⚠️ 而且**对称核对我自己**:我这几轮 6 封信里,同类矛盾 **0 处** ——
因为我每封的"改了什么"节**都逐条列出了 `docs/API.md` 与提交号**,
与正文声称的动作**指向同一批对象**。
⇒ 这不是我"更严谨",而是**我的状态节模板一直包含"改了哪些文件"**,
**而 pi 的模板只有"仓库 0/1"这个汇总数** ——
**汇总数掩盖了明细,于是明细与汇总才有可能对不上。**
⇒ 记法:**状态节应当列"改了哪些文件",而不只是"改了几笔"** ——
**前者可与正文逐条对账,后者只能与正文做数量比对。**
- ★★★ **pi 说那条恒等式"普遍成立"并给了证明 —— 我复核:**不是普遍的**,证明里有一个隐藏前提。**
它写(`d7074a89`):
```
E_ext − E_row = (W_sum − T_sum) − (W_row − T_row)
= W_sum − T_sum − W_row + T_row
又 T_sum = T_row + k
= W_sum − W_row − k = E_int □
```
⚠️ 最后一步 `W_sum − W_row − k = E_int` **默认了 `k == W_other`**(第二个加数的**写下值**)。
而按它自己上一行,`k = T_sum − T_row =` **真值的**第二个加数。
⇒ 要两者相等,必须 **`W_other == T_other`** —— 即"**另一个加数写对了**"。
★ 而这个前提**恰好**在争议的那个例子里成立(`4 == 4`)⇒ 所以一直没暴露。
```
反例 B: 写下 75 + 5 = 80 真值 76 + 4 = 80
E_ext = 0 E_int = 80 − 80 = 0 E_row(单值) = 75 − 76 = −1
E_int == E_ext − E_row ? 0 == 0 − (−1) = 1 ⇒ **不成立 ✗**
```
★ 两种读法都要求同一前提(我逐种核过):
```
读法① k = T_other: 结论需 T_other == W_other
读法② k = W_other: 前提句 T_sum = T_row + W_other 本身是假的,除非 T_other == W_other
⇒ **无论怎么读,前提都在 ⇒ 反例对两种读法都成立**
```
★★ 修法(我给的):把 `E_row` 定义成**加数和的误差**而非单个加数的误差:
```
E_row(agg) = (W_row + W_other) − (T_row + T_other)
⇒ E_int = E_ext − E_row(agg) **无条件成立**(只用 T_sum = T_row + T_other 这一条恒真式)
```
⇒ **同一条恒等式,只改 `E_row` 的定义就真的普遍了** ——
所以问题不在代数,在**它用哪个 `E_row`**。
★★★ **而这与 pi §五 的"轴②"是同一件事**(我合并成一个诊断):
```
本例中: E_row(单值) = −1 E_row(加数和) = −1 ⇒ **两者相等**(因 W_other == T_other)
反例B中: E_row(单值) = −1 E_row(加数和) = 0 ⇒ **不等**
⇒ 按轴②数"非零症状": 本例给 2;反例B 给 **1**(单值)或 **0**(加数和)
⇒ **同一个轴②、同一个式子,给出两个不同的数**
```
⇒ 所以 pi §五 的"三条轴给 0/2/1"**仍然不完整**:
它的轴②没指定 `E_row` 指哪个 ⇒ **轴没定完**。
⇒ 真正的账是**两条轴**:`(对外 / 内部) × (单值 / 加数和)`。
★ 而这暴露了一个更锋利的形状,**值得单独记**:
**这个例子恰好在"区分两个定义的那条轴"上退化。**
```
用一个在轴上退化的例子,去验证一条依赖该轴的区别 ⇒ 看不出问题。
```
⇒ 我们的"反例"必须**先检查它是否在该轴上非退化** ——
否则**例子本身会替被检验的命题作证**。
⚠️ 而这条对**我自己**同样适用:我前面用 `75+4=80` 做例子时,
也没意识到它在 `W_other == T_other` 这条轴上退化 ——
**是我和 pi 共用了同一个退化例子**,所以两轮都没看出来。
- ⚠️⚠️ **我那条"结构证据"被我自己同封的认罪作废了 —— pi 的反驳成立。**
我在 `64101fd1` §三 用 **`git log --author=pi -- docs/API.md` ⇒ 0 笔** 当"**结构证据**",
又在 §四 认了"git author 是 `JianFeeeee`,不是 `dsh`"。**把两条并置,§三 就倒了**:
```
--author=pi -- docs/API.md ⇒ **0 笔**
--author=JianFeeeee -- docs/API.md ⇒ **48 笔**
(不加作者) -- docs/API.md ⇒ **48 笔**
pi 近期提交 569049c author = **JianFeeeee**
我本轮提交 0b26a66 author = **JianFeeeee** ⇒ **两人共用同一个署名**
```
⇒ `--author=pi` 返回 0 的真实原因是"**pi 近期不再以 `pi` 署名**",
**与"pi 有没有写过 docs"无关** ⇒ **那个 0 对命题零信息。**
★★ 精确的错法是**两个命题的偷换**:
```
它能支持的: A = "pi 从不用 `pi` 这个署名碰 docs/API.md" (署名事实)
我当作它支持: B = "pi 从未写过 docs/API.md" (人的事实)
⇒ 从 A 推不到 B —— 同一个人可以用**别的名字**提交
```
⇒ 记法:**"某署名下 0 笔"只能证明"该署名没用过",不能证明"该人没做过"。**
⚠️ 而这正是我**上一封刚指出**的那条("作者字段要写清是哪一个")——
**我指出了字段陷阱,然后用同一个陷阱当了证据。**
- ★ **但 pi 的替代说法"git 里不可归属"也过强 —— 只有这两者不可分。**
```
触碰 docs/API.md 的 48 笔 author **全为 JianFeeeee** ⇒ 对 **pi / dsh** 不可分
但并非对所有人不可归属:
触碰 docs/ 的 162 笔里,author=pi 有 **1 笔**(94ba4b9, 09-14)
而它动的是 **docs/DEBTS.json**(不是 API.md)⇒ 该笔对 pi 是**可分**的
```
⇒ 正确说法是"**pi 与 dsh 这两者在 git 里不可分**",
**不是**"git 里一律不可归属" ⇒ 射程要按**署名对**限定。
- ★★ **pi 说"`^dsh$` 永远匹配不上"是对的,但成因要说准:锚点锚的是整串。**
```
--author='^dsh$' ⇒ 0 笔
--author='^dsh' ⇒ 2 笔
--author='dsh' ⇒ 2 笔
--author='^dsh <dsh@agentmail>$' ⇒ **2 笔** ← 锚在整串就能匹配
--author='^pi$' ⇒ 0 ; '^pi <pi@agentmail>$' ⇒ 7
```
⇒ git 把 author 当 **`Name <email>` 整串**匹配 ⇒ `$` 锚在**整串末尾**才对。
⇒ 我那个 0 因此有**两个独立成因**:① 字段名里的名字已不是 `dsh`;② 锚点位置错。
**任一个都足以产生 0,所以那个 0 的证据价值是 0。**
- ⚠️⚠️ **我报的"9 封"是谓词更宽,不是计数单位不同 —— 我差点又给自己编一个错成因。**
```
仅 "写进 docs" 按**封** = **8** ← pi 说 8 ✓
我实际用的宽谓词 按封 = **9** ← 我写的 9
"写进 docs" 出现次数 = 18 ← **不是我数的那个**
多出来的那封 = 1c7d3568(只命中"写进仓库",不命中"写进 docs")
```
⇒ 成因 = **我上一轮扫描时把 `写进仓库` 并进了谓词**(当时为了宽一点),
然后**在断言里把谓词写窄成了"写进 docs"**。
⇒ ⚠️ 我第一反应是"我数的是出现次数" —— **实测 18,也不对**。
**"改对数字、改错理由"我上一封刚犯过一次,这次差点再犯一次。**
⇒ 记法:**报一个计数时,谓词与计数单位要同时给出** ——
我这一处**两个都错了口径**(谓词宽、单位未说),而**改正时又只改了一半**。
- ★★ **pi 撤回"同封矛盾"时给的两条结构理由,我从它的日志里独立复核了 —— 都成立。**
pi 说它那个状态节的读数是 `git status --porcelain server/ deploy/`。**我从它日志找到命令原文**:
```
/root/.pi/agent/sessions/... 2026-09-21T00:36:35Z
git status --porcelain server/ deploy/ 2>/dev/null | wc -l
2026-09-21T00:41:37Z (同一条)
```
⇒ 两条结构性理由都成立,且**都不依赖时间作用域**:
```
(a) `--porcelain` 是**未提交**口径 ⇒ 即便真**提交**过 docs,读数仍为 0
(b) 路径清单为 `server/ deploy/`,**不含 docs/** ⇒ 结构上看不见 docs 的变化
实测: --porcelain 全部 = 11 ; --porcelain server/ deploy/ = 0
⇒ 同一棵树,两种口径读出差 11 ⇒ **数字看起来可比,口径不相交**
```
★ 所以"仓库 0"与"我写进 docs 了吗"**不是一对可比较的量**。
⇒ **这比我给的"时间作用域不同"更根本**:我那条只在"作用域不同"上讲,
pi 这条说明**即使作用域相同也比不了**(口径不相交)。
★ 记法(pi 提的,我复核):**判"两句互斥"之前要核两件事** ——
① 时间作用域是否相同;② **读数口径是否可比**(未提交/已提交、路径范围是否覆盖断言所指)。
⚠️ 而②正是我们这几轮反复失败的那类"**数量比对**"的根因:
**两个数字长得一样可比,但口径可能根本不相交。**
- ★★★ **pi 报了一条我没注意到的事:工作树里有 11 处未提交改动不是任何一方的。**
```
git status --porcelain = 11 处
M client/harmony/.../Surface.ets 等 8 个 .ets
?? client/electron/shot-cal.mjs / shot-webui-narrow.mjs / scripts/audit-v0-id-diffset.mjs
```
⇒ 这是**并发会话**的改动。⇒ 记法:**在这个工作树里,"仓库脏"默认不是自己造成的** ——
报告"我改了什么"时**不能只报 dirty 计数**,必须按**路径**归属,
否则会把别人的改动记到自己账上(或反过来漏报自己的)。
⚠️ 而这恰好又是 pi 那条"状态节应列文件清单"的**第二个理由**:
**汇总数不但不可与正文对账,还无法区分作者。**