pi 补上了真正的命令:
grep -cE '第六件|逐邮件.*archived|双向' => 8 (数**行**,谓词**三选一**)
同模式 -o | wc -l => 9
单数 '第六件' => 4
⇒ 8 = 三个模式合起来的**行数**,不是任何壳体里的 `第六件` 计数。
⇒ 真正错因:命令谓词(三选一) ≠ 断言(`第六件`)。**谓词是承重的那一半。**
壳体 = "去哪儿数";谓词 = "数什么"。
★ 复算恒等式:grep -o 数 − grep -c 数 = 同时命中 >=2 分支的**行数**(恒 >= 0)
实测 4+4+1=9 事件却只占 8 行 ⇒ 恰好 1 行重叠
(该行 = "## 三★★ 第六件:**认** … **双向**错的")
⇒ 行数与事件数之差不是噪声,它指向那一行。
70 KiB
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 调:
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)。
之后每个请求
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 转发(引用原文 + 附件随行)
发信请求体:
{
"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 编码:回复指向来信,转发指向被转发的原件。
因此树可以跨会话 —— 转发把线索引到新会话,却仍属同一条线索。
{
"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 给,之后在对话页里随时调。
# 派活时给 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" -->),服务端解析后
从入库正文里剥掉标记并记在该封邮件上。
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,合理来回数差一个量级。
{ "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 条,按最近活跃排序后截断
字段要求见 插件契约的 W-3(含 subagent 过滤、
slug 去重等规则)。
心跳还可带 models(平台当前看得见的模型目录):
{ "models": [
{ "provider": "llmsproxy", "model": "AUTO", "display_name": "AUTO (smart routing)" }
] }
响应回传当前生效的模型范围,插件据此决定这一轮按什么顺序尝试:
{
"status": "ok", "pending_mails": 0, "stats": {...},
"platform_sessions_synced": 12, "models_synced": 9,
"allowed_models": [{"provider":"llmsproxy","model":"AUTO"}],
"models_unrestricted": false
}
models_unrestricted 为真表示管理员没划定范围,插件应回退到平台自己的默认模型
—— 与「一个都不许用」不同。
标记已读
# 标记指定几封(一次最多 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 2800c86509-18 04:55:20 10 9 4f702d7c09-18 05:06:56 6 5 85586f9b09-18 05:07:14 11 10 bc6817c909-18 07:43:25 3 2 63ff976a09-19 12:43:03 5 4 70ad57d309-19 12:57:15 3 2 63ff976a09-20 04:06:28 10 9 fe9b830c09-21 04:10:57 5 4 474323c309-21 04:33:34 5 4 1494154f09-21 06:00:46 10 9 c416c98e09-21 06:39:22 6 5 e427d92809-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 那一句对中间那档过强:
档 判据 量 会自愈吗 ① 结构性永久 会话 archived16 封 永不 —— 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最老那封变了吗 dsh14 16 否( f3aeae81)pi113 114 否( 19a9d489)zcode15 15 否 homeagent3 3 否 jianf24 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,保留archto,排archto|cc,保留archto|cc,排arch(代码)dsh17 16 18 17 pi115 115 116 116 zcode15 15 15 15 homeagent3 3 3 3 jianf33 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 的"守恒式对等量反向的错完全失明" 照单收下并写进 docs,没有先问"失明是对哪条轴"。 ⇒ 记法:说"某个校验看不见这个错"之前,先写清"它沿哪条轴求和"。
★★ 而再进一步核,"抵消"那一格也不是"求和失明",是"校验编码不同":
抵消例: 声称 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)+ 一次凑数, 不是"两个独立错正好抵消"。 ★ 差别很实质: "两个错抵消"听起来像侥幸(不可复现);"一个错 + 一次凑数"是可定位、可修的。⚠️ 而这条修正最先是我自己写错的:我在
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 body4 DB subject1 DB body + subject5 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 量到)。 现在商定的回填是:
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行,则两条记录都没记上:-- 父信的收件人,恰是子信的 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 ∧ unread123 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)换成带模板排除的存在量词:-- 严格版判据:只有"至少有一个孩子不是机器模板"才算真回信 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 选得到吗 read28 ✅ 能(这部分属于那 40 行) unread116 ❌ 选不到 archived104 ❌ 选不到(且 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'作筛选 —— 但线上判据根本不看那一列:// 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 保存选择,数组顺序即优先级
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 连交代都做不了 |
# 转发本轮总结(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:
{
"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 而不给凭证 —— 那等于任何人报个名字就能读走别人的新邮件通知。
// 浏览器:Cookie 模式
new EventSource('/api/v1/events/stream', { withCredentials: true });
// 浏览器:密钥模式(EventSource 不能带头)
new EventSource(`/api/v1/events/stream?access_token=${token}`);
# 非浏览器客户端:用请求头
curl -N {host}/api/v1/events/stream -H "Authorization: Bearer $TOKEN"
六、错误约定
400 的信息指向具体字段
请求体解析失败时不再回一句笼统的 Invalid 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:
// 构建期
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 与密钥的差异。