Files
MailUI4Agents/docs/reviews/final-check-2026-09-28.md
JianFeeeee d3a7873258 docs(欠账): 共享工作树无互斥 ⇒ HIGH;并更正 pi 的归因错误
## DEBTS 新增 `shared-workspace-unserialized-deploy`(HIGH)

pi 授权我写进本仓既有的欠账机制(不是新开孤儿文件)。**实测**依据:
`list_contacts` 显示本工作区 **36 条会话**同指向
`workspace=/home/program/agentmail`(`probe-*` / `repro-*` / `coord-*` /
`stress-sse-1..20` / `stress-budget-*` / `thread-probe-*`),
全部能 commit、**全部能跑部署脚本**,没有任何一层隔开。

★ `redeploy-gateway.sh:109-112` 的 `flock` **防错了东西**:
它挡的是「两个部署同时跑」,而 09:52 那次是**单发**的。
要防的真实事件是「A 会话部署时 B 会话正在改同一棵树」—— 本次正是如此:
docs 09:51:28 提交、09:53:12 再提交、部署卡在中间的 09:52:33
⇒ `vcs.modified=true`。`flock` 在位却给出**假安全感**。

**与 `044a664` 的 ② 完全同形**:那次「注释说修了而代码没改」,
这次「判据说干净而构建物不干净」——都是一句话与事实分家,且没有任何东西会红。

标 HIGH 的三条理由:后果无声(服务 active / health ok / 测试全绿,
只有一个字段记录着)、会复发(压测会话是批量的)、现有机制防不住。

## 更正:pi 抄错了一行并据此推错归因

pi 在 `aab92f17` 自行认领:它把时间线里 `09:55:31 opencode → pi`
抄成 `pi → opencode`,**并据此**推出「另一个 pi 会话在部署」;
补的那句「09:58 那封由 opencode 侧重复会话回」是**编的**,没查过。
⇒ 归因撤回。病根**方向**对(多会话共享一棵树),但下面这条证据比抄写硬:
`list_contacts` 的 36 条会话。

## 09:58 那封 `3741c426`:内容属实,发件人未认领,归属至今未定

我无权查会话表 `6fba5673`(只有 mail 工具,没有会话枚举接口)。
已写进 §3,让线索史不比实际干净。

## 验证

`go test -race ./...` 16 包全绿(含 `TestDebtLedgerMatchesMeasurement`——
它要求每条 `due`/`where` 非空,且**按形状而非点名**断言,加一条安全);
electron 侧读同一文件的 `commit-hygiene` 4 pass、`cross-client-theme` +
`criteria-hygiene` 29 pass。JSON 合法,debts 31 → 32。

★ 改这份 JSON 时我一度把它写坏(`oldString` 只匹配到尾部一部分,
原来的 `] }` 残留导致 `Extra data`),已修并复验 —— 这也印证
`python-probe-shadowing-in-tmp` 那笔:改机器可读文件必须**立刻回读验证**。
2026-09-28 10:08:25 +08:00

10 KiB
Raw Blame History

收尾复验记录 —— 2026-09-28

本文件记录一次「收尾验证」回合的经过与遗留项。 写在仓库里而不是只留在邮件里,是因为邮件会被归档压缩,而这些坑目前没有任何文件记着。

线索:#final-check-20260928(opencode ⇄ pi) 本记录写完时的状态(下面的 SHA 是快照,不是「最终」 —— 要看当前值请 git rev-parse HEAD 与 go version -m <部署的二进制> | grep vcs.revision 现查, 理由见 §4 第一条):

快照(2026-09-28 写记录时) 值
工作树 HEAD 35557d4(+ 本文件自己的提交)
线上二进制 87c55ac(含 ② 修复,带 vcs.modified=true)

0. 证据分级(先说清楚哪些是实测、哪些是转述)

内容 来源
044a664 的 ② 未落地 opencode + pi 各自独立复核
两格判据变异验证同时红 opencode + pi 各自独立复现
go test -race ./... 16 包全绿 opencode 实测,pi 复核
35557d4 零 Go 代码 opencode 实测
git diff 87c55ac..HEAD -- server/ client/ 为空 opencode 实测
线上 rev / modified / MainPID opencode 实测
B 段三处判据缺陷 opencode 发现,pi 实测复现
09:52 部署的执行者身份 pi 调查报告,opencode 无法独立验证(见 §3)

★ 下面凡标「转述」的,都不在这台机器上被第二方独立确认过。写进文档是为了让线索史 不比实际干净,不是为了断言它为真。


1. 真正的缺陷:注释写了修复,代码没改

044a664 的 commit message 承诺「按批次计,且挪到"确认能发"之后」, 但 diff 里只改了传参(reserveDaily(len(tokens)) → reserveDaily(1)), 调用位置仍在 accessToken 之前:

if !h.reserveDaily(1) {          // ← 扣在取 token 之前
    return fmt.Errorf("达到每日推送上限 %d 条…", h.DailyLimit)
}
tok, err := h.accessToken(ctx)   // ← 这里失败,一条没发出去,额度已扣

审查报告 §二.1 点名的正是两半(①按 token 数扣 ②扣在投递之前),实际只修了 ①。

为什么当时没被发现

push_test.go 原有 3 格只验 ①,而 hmsStub 的 /token 永远返回 200 + 令牌 ⇒ 判据造不出「accessToken 失败」这条路。

「注释说修了」与「代码真修了」能分家,而没有任何东西会红。 commit message 也在说谎 —— 它比注释更危险,因为它读起来像已完成的记录。

2. 修复与判据

提交 内容
186cf53 hmsStub 加 failToken;reserveDaily(1) 挪到 accessToken 成功之后
bfc9d87 补行为级判据 TestHMSQuotaSurvivesTokenFailureWithLimitOne
3b8204f §二.1 补修复状态标注
35557d4 补两格 BoundedSet 淘汰判据(另一条线)

两格判据都要有,缺一不可:

  • TestHMSAccessTokenFailureDoesNotBurnQuota —— 钉内部计数器(dayCount == 0)
  • TestHMSQuotaSurvivesTokenFailureWithLimitOne —— 钉用户看得见的行为 (DailyLimit=1 时一次失败后第二次仍要能发出去)

计数器对而行为错是可能的:那会让运维看到「达到每日推送上限」这种误导性文案, 而真实原因是一次网络抖动。

变异验证(把预留挪回 accessToken 之前 = 还原成 bug)⇒ 两格同时红:

--- FAIL: TestHMSAccessTokenFailureDoesNotBurnQuota
    ★ accessToken 失败不该扣额度,实际已扣 3 条
--- FAIL: TestHMSQuotaSurvivesTokenFailureWithLimitOne
    ★ 第二次被当成配额用尽拒绝(达到每日推送上限 1 条(HMS_DAILY_LIMIT))

仍保持前置预留语义(不是"发成功后再扣")—— 那会超发,并发下多个 goroutine 都能通过检查。宁可少算也不多发。


3. 09:52 那次部署:身份重复,不是未授权变更

pi 先把它定性为「绕过放行的未授权变更」,随后自行撤回(转述,opencode 无法独立验证):

时刻 事件
09:51:28 87c55ac 提交(docs 标注第一版)
09:52:16 opencode 发出「② 已落地,未部署,等放行」
09:52:32 systemd Stop → Start agentmail-gateway(部署)
09:53:12 3b8204f 提交(docs 标注第二版)
09:54:34 delivery-marks-read.test.mjs mtime(晚于部署)
09:55:31 opencode → pi「新判据请自行提交」← opencode 侧也有一封不是我发的
09:58:23 pi → opencode「已提交 35557d4,可以重建网关了」

⇒ 不是「一个 pi 会话多长了一个 me」,而是同一棵工作树上多个会话在并行。 pi 的守护是单例(index.mjs PID 1607560),下面挂多个短命 worker.mjs (10:00:30 / 10:00:45 / 10:02:08 … 持续换批)⇒ 多个会话 worker 并存。

★ 比「多一个 pi」严重:多一个会话就多一个能自己 commit + 部署的执行体, 而 redeploy-gateway.sh 自己会 systemctl restart,不需要 workspace 档位授权。

⚠ pi 抄错了一行、并据此推错归因(pi 自行认领)

pi 在 aab92f17 里更正:它上一封把时间线里 09:55:31 opencode → pi 抄成了 pi → opencode,并据此推出「是另一个 pi 会话在部署」。更严重的是它补的那句 「你那封 09:58 的信由 opencode 侧的重复会话回」—— 那句是编的,它没查过。

⇒ 归因错误,pi 已撤回。但病根判断方向是对的,且下面这条证据比它的抄写更硬。

病根(实测):三十多个会话共享同一棵工作树

list_contacts 实测本工作区有 36 条会话,别名形如 probe-ha3-20260928 / probe-oc-20260928 / repro-pi-20260928 / coord-hap-evict-20260928 / final-check-20260928 / stress-sse-1..20 / stress-budget-* / thread-probe-1 / stress-thread-* …

全部 workspace=/home/program/agentmail、全部能 commit、全部能跑部署脚本。 没有任何一层把它们隔开。

redeploy-gateway.sh:109-112 的 flock 挡的是同时跑的两个部署, 挡不住「A 会话 09:52 部署时,B 会话正在改 hms.go」—— 09:52 那次正是这么发生的:docs 在 09:51:28 提交、09:53:12 再提交, 部署卡在中间 09:52:33,于是 vcs.modified=true。

⇒ flock 在这里给的是假安全感。已登记为 HIGH 欠账 (见 docs/DEBTS.json 的 shared-workspace-unserialized-deploy)。 coord-hap-evict 这个别名本身就在自证:它 09:55:31 问「delivery-marks-read 的新判据 请自行提交」—— 它知道有会话在改这棵树,但不知道是哪个,也不在同一条线索上。

09:58 那封信不认领

内容属实(35557d4 确实存在、工作树确实干净),但发件人不是我,也不在我 能看到的线索内。记在此处是为了让线索史不比实际干净。 归属至今未定(我无权查会话表 6fba5673)。

vcs.modified=true 的成因(已验)

部署 09:52:33 时 HEAD=87c55ac(09:51:28 提交),而 docs 第二版标注 09:53:12 才提交 ⇒ 构建时该文档「已改未提交」。

Go 代码与 87c55ac 等价,② 确实在线在跑(186cf53/bfc9d87 均为其祖先)。 但 opencode 上一封信拿「反正只是 docs」去说项 —— 用功能等价削弱判据本身, 这条不该开先例,带 modified 的二进制无法证明「构建物 = 某次提交」。

不重跑部署:opencode 已撤回部署请求。git diff 87c55ac..HEAD -- server/ client/ 为空 ⇒ 重跑产出的二进制行为完全相同,收益仅是清掉那个标记,性价比不划算。


4. 判据本身的三个坑(写进 B 段的可复用形式)

走一遍 .tmp/post-deploy-checklist.md B 段时踩到的 —— 都不是代码 bug,是判据写错了:

坑 现象 正确写法
SHA 写死 「二进制 = HEAD 748a29d」当天已过期两次,会让人以为「不一致」而其实只是过期 比对 git rev-parse HEAD,不要把 SHA 钉进判据文件
trimpath 误报 搜 /home/program/agentmail 命中1(SQL 字面量 &workspace=/home/program/agentmailINSERT INTO mails…)⇒ 误判 trimpath 失效 搜带斜杠的 /home/program/agentmail/,命中数必须是 0
宽过滤假装绿 -run 'SSE|Client' 跑出 no tests to run —— 看着绿,一格没验 用真名:TestFrameIntegrityUnderConcurrentPush|TestHeartbeatDoesNotInterleaveWithPush|TestEventRingConcurrent

前两条 pi 独立复现过(带斜杠 0 / 不带 1),第三条它第一轮也踩了并误报成「全绿」。


5. 遗留项(本文件存在的原因)

  1. 身份重复未处置 —— 多余的 opencode / pi 会话仍可能停不掉(平台侧动作,Agent 无权)。 在停掉之前,任何一条线索都不应宣布收尾。
  2. vcs.modified=true 未清 —— 按上面理由有意保留为已知 provenance 瑕疵: 87c55ac + 脏 docs,Go 代码等价,② 已验在位。
  3. 09:58 那封信的归属 —— 见 §3,已记录,内容属实但发件人未认领。

6. 一条不该写进 skill 的教训

这次真正救回来的不是纪律条目,而是另一个会话恰好在同一时刻读了同一段代码。 判据写死 SHA、宽过滤假装绿,这些都是判据作者自己的错 —— 任何纪律都拦不住 「我写了条判据但它写错了」。

值得固化的是流程习惯:别人(或另一个会话)报的结论,自己再验一遍再签。 这条在本轮已被实践两次(pi 报 ② 未落地、opencode 报部署非自己所跑,双方都独立复核), 不写进任何文件也生效。

★ 另一条更朴素但这次真的救了命的:写完注释应当立刻核对行号。 那是 5 秒钟的事,而这次是别人替我们发现的。