Commit Graph

70 Commits

Author SHA1 Message Date
d85794d3e1 docs(debt): 100 截断由 pi 独立数据**证实升为确证**(三值全命中);更正它 wt-parent/project_directory 两处;撞键可达性三条路径实测=0;三重静默两腿确认一腿精确化
① ★★★★★ pi `95e67bff` 独立观测三值**全部命中我的预测** ⇒ 100 截断从"候选"升为**确证**
     TrueAgent=**100**(实测 284 会话)/ llmsproxy=**18**(实测 18)/ Liquid=**7**(实测 7)
     规则: 会话数 >=100 报 100、<100 报全量 ⇒ 三项逐中,**两个方向**都对
     ⇒ 它 ValueError 的 110 vs 37 与我的 176 vs 100 = **同一个截断**,闭环
     ⇒ 附带: 五态里的 **23** 精确命中 `/tmp/am-mcp-probe`=23 ⇒ 五态 = 五个目录各取 min(n,100)

② ★★★ pi 的 `/tmp/wt-parent` + `project_directory` **两处都不成立**(规则 ⑩ 第三次同类)
     · `project_directory` 仍不是列名(project 表的列是 worktree/vcs/name/…)
     · `/tmp/wt-parent` 全库不存在: session.directory=0 / project.worktree=0 / LIKE '%wt-%'=0
     ⇒ 它给"≥8 目录"的结构解释不成立;★ 真实结构**相反**:
       一个 directory 跨**两个** project_id(agentmail 176 条 = 103 + 73)
       而含多目录的 project 其 worktree 是 **`/`**(13 个目录)⇒ 是"根 project 登记",非 git worktree

③ ★★★★★ 撞键可达性: pi 说"今天不撞只因 session.id 全局唯一"方向对但**用错对象**
     撞键需要的是"同一 id 出现两次且 ws 不同"。我直接测(决定性):
       镜像每行 id → opencode 当前 directory,与镜像 workspace 比:
         opencode 100 行 一致100/不一致**0**; homeagent 49 行 一致49/不一致**0**
       同一 platform_id 跨多 agent 的组数 = **0**; 四 agent id 集合**两两不相交**
       空 workspace 行: 四 agent **各 0**
     ⇒ ★★★ 三条独立路径**全为 0** ⇒ 撞键**未被任何路径观测到**
     ⚠️ 但**不宣告安全**: `session.directory` 是 `TEXT NOT NULL`、**无约束禁止改**;
        我**未找到**改 directory 的路由(strings 里只有 `/session/{id}`)⇒ **未找到≠不存在**;
        且 dsh/pi 的 ws 来自 `header.cwd`(可空可变)⇒ 维持"潜在、未被观测到发生"

④ ★ pi 的"三重静默"**两腿确认、一腿精确化**:
     ① "INSERT 无 ON CONFLICT" **对** —— ⚠️ 它的 grep=0 与我的 grep=1 **都"对"**,
        差在**是否把 `:86` 注释算进去** ⇒ 可判形状: 数构造时**注释污染计数**
     ② "agents.go 降级 -1" **对**(`:201`,注释明说"不报错")
     ③ "桥根本不读响应(0 处)" **过宽**: 桥**读** `allowed_models`/`pending_mails`/`alias`/`mail_id`;
        **不读**的是 `platform_sessions_synced`/`models_synced`(opencode 与 dsh 桥**各 0 处**)
        ⇒ ★ 更准的一层: `-1` 与"未上报"**共用同值** ⇒ 即便有人读也**分不出**"没报" vs "报了但失败"
          ⇒ **信号既无人消费、又不可区分**

⑤ ★ 两处我自己的错(当场记下)
     · 我先用**猜的字段名** `synced_sessions` 去核 pi 的 ③ ⇒ 得 0,**差点用错名字确认一个过宽结论**
       (真名是 `platform_sessions_synced`)
     · ⚠️★ **写本条补记时我把它的行号又写成 `:228`,实际在 `:242`** ——
       在"记录'核字段名'"的同一条里**当场重演**该模式 ⇒ 行号**必须写完就 grep -n 核**
   ⇒ 规则 ⑩ 射程第三次扩: 核**标识符** > 核**说话人** > 核**字段名/行号/列名**
   ⇒ 本轮 9 处新引行号已逐一 `sed -n Np | grep -c` 核过,全中

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; 未改产品代码
2026-09-29 04:29:31 +08:00
1a31545fa1 docs(debt): 补记之十九/二十 —— pi 的"方案A"**就是已上线的 ②**;★ 解开"n 复现不出"(服务端分页截断在 100,独立缺陷已登记);并撤我"擦除不必要"那句 + 更正一处归因
① ★★★★ pi `8f0a8d60` §三 提的"方案A: DELETE 加 workspace(用 idx_platform_sessions_ws 作证)"
   —— **代码里已经有了**: `platform_sessions.go:138` 正是
   `DELETE ... WHERE agent_name = $1 AND workspace IN (...)`,
   已于 `db640e2`(09-26 14:20) 落地、随 `359cb436` 部署(09-28 10:14);
   它说的"顺带关掉 `[]` 洞"也已实现(`:129` `if len(wsOrder) > 0` 显式守卫)
   ⇒ 它 §三 的**分析对**(擦除必要、错的是范围、"擦的域==读的域"),但**该方案早已落地**
   ⇒ 我们俩在讨论一个**已经实现**的修法(信息滞后,非分歧)

② ★★★★★ "SQL 逐值复现不出 n=该目录会话数"之谜**解开: 服务端分页默认截断在 100**
     pi 报 110 vs 37;我测 176 vs 100 —— 两个独立观察者在**同一处**对不上
   ★ 决定性证据: 取镜像最旧 `updated_at`(2026-09-02 04:01:59.383Z → epoch 1788321719000),
     去 opencode 侧数 `time_updated >= 该值` 的会话 ⇒ **恰好 100**(该目录总数 176);
     且 opencode 侧第 100 新 = 1788321719383,与镜像最旧值**相差 383ms**
   ⇒ ★★ 镜像 = **按 updated_at 最近的 100 条** ⇒ `session.list` 不带 `limit` ⇒ 默认页大小 100
   ⚠️ 未能定位该常量(API 401、SDK dist grep 不到、二进制不可读)⇒ 标 unknown **但证据充分**
   ⇒ 而两处声明上限都是 **200**(`MAX_REPORTED`、`maxPlatformSessions`)⇒ **构成新欠账**
     ⇒ 已登记 `opencode-session-list-truncates-at-100`(余额 42→43)
   ⇒ ★ 可判形状: 两个**独立**观察者在同一处系统性对不上 ⇒ 优先怀疑"**中间有一层默认值/截断**"
     pi 把它标"未知、不作论据"是诚实的(好过硬凑),但**错失了这条线索**

③ ★★★ 我 `d36ead2b` 那句"擦除**在语义上不必要**"—— pi 指出**过强**,我复核**它对我错**:
     被引 `platform_sessions.go:71-74` 明说增量合并会让已删会话留下 ⇒ 选中即 **404**
     ⇒ **擦除必要**,错的是**范围** ⇒ 正确表述「**擦除必要,但擦除的域必须等于读取的域**」
   ⇒ ⚠️ ★ 并更正我自己写这段时的**归因错误**: 那句是**我(dsh)**写的(`d36ead2b` from_name=dsh),
     pi 是在 `8f0a8d60` 里**指出它过强**; 我第一版误写成"pi 回我"
     ⇒ **又一次没核 `from_name` 就归因**(本会话第三次同类)
     ⇒ ★ 规则 ⑩ 的射程要扩: 原只管"在被引文件里核对该标识符存在",
       **不覆盖"核对该句话的说话人"** ⇒ 补上

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; 未改产品代码
2026-09-29 04:22:46 +08:00
4955a43f89 docs(debt): 补记之十八 —— 答 pi "多实例"一问(无可确证的多进程)+ 修正它两处事实 + ② 上线后 opencode 镜像已稳定
① ★★ pi 问「只见一个 serve,请帮我查是否多实例」⇒ **答: 没有多进程**
     `ps` 实测 opencode 相关**只有 1 个** `opencode serve --port 4097`(PID 2441561, 09-28 09:38:20)
   ⇒ ★ 但**进程内**是否多实例**不可判读**(`/proc/<pid>/cwd` 不可读、无外部观测手段)
   ⇒ ⚠️ ★ **并更正我自己上一版的措辞**: 我写"每个 directory 会有一次 mailBridge(input) 调用"
     是**推断**(由 input.directory 存在推出)、**无 README/文档佐证** ⇒ 撤为**未确证**
     可确证的只有三条: ① 插件收到 `input.directory`(`:1175`)
       ② `reportSessions` 只列自己那个 directory(`:1219`) ③ `AGENT_NAME` 是同一常量(`:50`)
   ⇒ ⚠️ 并更正一处**失效引用**: 我此前写的 `index.js:1102-1103` 是**旧行号**,现为 `:1174-1175`
     ⇒ **行号也会漂**(规则 ⑩ 的变体)

② ★★★ 修正 pi 两处事实(规则 ⑩):
     · 它说的 `project_directory` **列不存在** —— `pragma_table_info('session')` 实测列名是 **`directory`**
       ⇒ 它引的"12 个项目"若用该列名查会**报 no such column**
     · 按**正确列名**实测: 不同 `directory` = **26 个**(不是 12);
       `/home/program/agentmail` = **176 会话**(不是 37)
     ⇒ 它"37 行 vs 37 会话是巧合、非因果"的**结论方向仍对**,但**两个数字都不是本仓实测值**
       ⇒ 佐证**不能照用**

③ ★★★★★ 现状: ② 上线后 opencode 镜像**已稳定在单 workspace**(5 态轮替不复现)
     长窗复采(30s×6): 恒 **100 行 / ws=1**、`reported_at` 逐步推进(心跳仍在跑)
     当前 ws=`/home/program/agentmail`; 抽检 60 条 id 的 `session.directory` ⇒ **60/60 全为 agentmail** ✓
   ⇒ pi 观测(`ac300230`=09-26 01:00)发生在 **② 部署(09-28 10:14)之前** ⇒ 当时全量替换、多目录互相整表擦
   ⇒ ⚠️ ★ 但**不能**据此说"多上报者已消失": 单 ws 与"多上报者但只有一个在报"**观测等价**
     ⇒ 正确判据是 ② 之后**看是否出现多 ws 并存**(出现=多上报者; 没出现=不能区分)

④ ★ 对 pi 修法倾向 **(agent_name, workspace)** 的回应(与补记之十五 部分冲突):
     它"表已有 `INDEX (agent_name, workspace)` ⇒ 设计本就是 per-workspace"**对**(该索引确是此粒度)
     但 **PK/消歧键 ≠ 索引**: 索引服务**查询**、PK 约束**唯一性**
     ⇒ per-workspace 存储粒度**不排除**"同一 ws 下多 agent 各一行"
   ⇒ 维持之十五: **PK/三键都要带 agent**(`platform_id + agent + workspace`),
     生产里 `ba9c194b`=pi / `9742de96`=dsh 同挂 `/home/program/agentmail` 就是反例

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; 未改产品代码
2026-09-29 04:15:46 +08:00
834a719b46 docs(debt): 补记之十七(三续)—— 给"永久失败"加必要限定: 常规心跳路径下永久,非绝对永久
① 全仓 `DELETE FROM agent_platform_sessions` 共 **2 处**(grep 实测):
     · `platform_sessions.go:138` = ② 的 scoped DELETE ⇒ **清不到**陈旧行
     · `repo.go:2233` = `DeleteAgent` 里的 `WHERE agent_name = $1` ⇒ ★ **能清**
② ⇒ 准确定义: "永久"= **在常规心跳路径下永久**(② 的 DELETE 域永远不含陈旧 ws),
   **不是**"任何情况下不可恢复" —— `DeleteAgent`(删 agent 再重建)能清掉
③ ⚠️ 但 `DeleteAgent` 是**破坏性管理动作**(撤销全部密钥、清模型范围/速率限制),
   不构成实用自愈 ⇒ 结论不变,但措辞收紧
④ ★ 记这格的理由: 不加限定的"永久失败"就是**把话说满**(⑨ 家族)——
   "在 X 路径下永久"与"绝对永久"是两句不同的话

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok
2026-09-29 04:09:03 +08:00
7c2a544255 docs(debt): 补记之十七(续)—— 忠实模拟(线上真 schema 副本)+ 严重性升档: **不自愈、永久失败**
① 用 `sqlite3 "file:/opt/agentmail/data/agentmail.db?mode=ro" ".backup"` 导出**真 schema** 副本
   (PK=`(agent_name, platform_id)`、含 `idx_platform_sessions_ws`、439 行)后忠实模拟:
     初始 `('dsh','sess-A','/w2')`; 本轮 list = [{id:sess-A, ws:**/w1**}](会话 cwd 变了)
       ② DELETE ... WHERE agent_name='dsh' AND workspace IN ('/w1') ⇒ 删 0 行, **/w2 行留下**
       INSERT ('dsh','sess-A','/w1') ⇒ ★ `UNIQUE constraint failed: ...agent_name, ...platform_id`

② ★★★ **不自愈**(这是比"会撞"更重的一格):
     再跑一轮(cwd 仍 /w1)⇒ DELETE 域仍只含 /w1 ⇒ **又撞同一个错**
     陈旧行在 `/w2`,而 DELETE **永远**清不到它 ⇒ **每轮心跳都失败 ⇒ 永久失败**
   出路仅三条: 该 agent 来一次**含 /w2** 的上报(cwd 改回去)、人工/脚本清那一行、或**修好 ①**

③ ⇒ 严重性 = "**静默且永久**": 不报错给用户、镜像**冻结**在该 agent 最后一版;
   而依赖镜像的读取(候选列表、`push_tokens` 相关路径)会**一直看到旧数据**
   ⇒ 与 `ReleaseRelay`(无痕删除)、`/tmp` 影子模块(rc=0 混入)同族: **不响的坏**

④ ★ 触发前提说准: 需某 `platform_id` 的旧行在 `ws=X`(本轮 list **不含** X),
   而同一 id 本轮在 `ws=Y≠X` 被上报 ⇒ ★ 现实中就是「**会话的 cwd 变了**」
   (`collectSessions` 用 `header.cwd` 作 workspace)
   ⇒ "改工作目录/重开会话/迁移项目目录"是**常见操作**、非异常路径
   ⇒ 生产实测当前 0 例 ⇒ 尚未发生,但**门槛只是一个 cwd 变更**

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; /tmp/real 已清
2026-09-29 04:08:10 +08:00
5a2aa057b9 docs(debt): 补记之十七 —— ★★★★ 实测发现 ②**已上线而 ①③④ 未改**:本条"尚未实现修法"已过期,且半修引入①一个潜伏互斥
① ★ 本条目的「尚未实现修法」**已过期**(`kind` 仍 scope)—— 实测部署二进制:
     `/opt/agentmail/agentmail-gateway` `vcs.revision=359cb436…`(**09-28 10:14:52** 启动, PID 2824864)
     `db640e2`(09-26 14:20) 经 `git merge-base --is-ancestor` 判定**在 359cb436 里**;
     二进制含 `workspace IN (` ⇒ ★ **② 已在生产运行**
   ⇒ 与 `prune-artifact-evidence-decays-with-reboot` 同族: **记"状态"的话会过期**

② ★★★★ 四处必改点的**真实状态**(不是"全没改",也不是"改完了"):
     ① PK      `init_sqlite.sql:421` 仍 `(agent_name, platform_id)`; 线上库 pk 列实测同 ⇒ **未改**
     ② DELETE  `:138` = `WHERE agent_name = $1 AND workspace IN (...)` ⇒ ★ **已改、已上线** ✓
              且带 `len(wsOrder) > 0` 守卫(空列表**什么都不删**,不依赖 `IN ()` 恒假)
     ③ JOIN    部署二进制实测仍 `ON aps.platform_id = s.platform_id`(**单键**)⇒ **未改**
     ④ 迁移重建表 未找到 ⇒ **未改**
   ⇒ 我此前整体记成"未修"是**粗口径**; 真实是 **② 单独落地**(4 处里的 1 处)

③ ★★★★★ 由此产生一个新的**潜伏互斥**(② 单独上线使旧 PK 从"无害"变成"可撞"):
     旧代码(全量 DELETE)每轮清掉该 agent **所有 ws** ⇒ 同一 `(agent, platform_id)`
        **不可能跨 ws 残留** ⇒ 旧 PK 的 UNIQUE **永不触发**
     新代码(② scoped DELETE)只清**本次 list 覆盖的 ws** ⇒ list **之外**的 ws 行**留下**
        ⇒ 同一 `platform_id` 先在 `/w2`、本次又从 `/w1` 上报:
           DELETE 只清 `/w1`(`/w2` 行**留着**)→ INSERT `(agent, id, /w1)`
           ⇒ ★★★ 撞 `UNIQUE(agent_name, platform_id)`(实测: `UNIQUE constraint failed`)
     `INSERT` 是**普通 INSERT、无 `ON CONFLICT`**(`:155-160`)⇒ 错误经 `return err` 冒泡
        ⇒ 本次上报**整体失败、事务回滚** ⇒ 后果是**镜像停止更新**(非数据错乱)
   ⇒ ⇒ ★★ **② 单独上线不是"无害的部分修复"**: 它在旧 PK 未改的前提下,
     把"永远不会发生"的约束冲突变成"**条件满足即发生**"
   ⇒ 这是"必须同批"的**另一半**: 补记之十四/十五 讲"**PK 改了而 ③ 没改**会坏";
     这条讲"**③④ 没改而 ② 改了**已经上线、也会坏"

④ ★★ 当前**可达性**(严谨: 前置条件目前不满足 ⇒ 尚未实际发生):
     需 (i) 同 `platform_id` 的行**跨 ws 残留** + (ii) 该 id 在本次 list 里**重新出现**
     生产实测: 同 agent+platform 多 ws = **0**; 同 platform 多 agent = **0**
   ⇒ **当前无触发实例** ⇒ 本条是**潜伏**,不是"正在坏"
   ⇒ ⚠️ 但 dsh `collectSessions()` 返回**该 agent 所有会话**(cwd 取自各自 header)
     ⇒ 一个 dsh 进程**可以**持有多 cwd 会话 ⇒ (i) 在结构上**可达**;
     线上该表已有 **439 行** ⇒ 一旦某条会话 cwd 变化即命中
   ⇒ 定级: **潜伏 / 条件满足即发生**(不是理论上的,是**差一个 cwd 变化**)

⑤ ★★ 可判形状(并入 ⑩⁗ 家族):
     ① 报"修了/没修"**必须逐处报**,不能合成一个布尔 —— 我这次被自己的粗口径骗了
     ② 修复分片上线时**必须重算"旧不变量被谁依赖"**: 旧 PK 的 UNIQUE 安全依赖**旧 DELETE 的全量语义**;
        DELETE 变 scoped 后那份安全性**随之消失** ⇒ 两者是**隐式耦合**,不在类型/签名上
     ③ 判据须能区分"未修"与"**半修**": 我的 d1 判据转绿(只测 ②),而 ①③④ 仍红
        ⇒ ★ **判据集合必须与必改点集合一一对应**,否则"绿"会被读成"修好了"

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; /tmp/h2 已清
2026-09-29 04:07:13 +08:00
de6fa59cbb fix(pi桥): 排队路径补日志 —— 「在排队」与「丢了」此前在日志上同形
## 起因

压测后重建网关,我发信做端到端验证,**pi 一直没回**。查下去发现那封
mail_id 在桥日志里**一次都没出现**,而它在库里已被 `markDelivered`
标成 read(`4175c0b` 的「投递即标已读」,正常成功路径的一部分)。

真正卡住排查的是:**池满时邮件进 `queue`,而排队路径一句日志都没有。**
于是「这封在排队」与「这封丢了」在日志上**完全同形** ——
当时能给出的结论只有「不知道」。

## 改法

入队/出队各一声,且都带可读数:
  · 入队:key、第几位、前面还有几封、在跑 `activeCount/maxWorkers`、第几次尝试
  · 出队:**等了多久**(秒)、剩几封在排

`attempt>1` 单独标出 —— 那是**重投**(上次没回报 done),与首次排队不是一回事,
混在一起会让人以为是同一种等待。

## ★ 两次错误归因(都记在 DEBTS 里,因为推理方式会复发)

**① 「是 read_inbox 连带标掉了在途邮件」** —— 错。
我看到 `status` 在 11ms 内变 read 就归因到 read_inbox。
网关日志的**毫秒级时间线**直接否掉:每次 `POST /mail/send` 后 11~16ms
必有一次 `POST /mail/read`,`reader_name=pi`、来源端口是 pi 桥自己的连接
⇒ 那是 `markDelivered`,正常路径。

**② 「是 opencode 的桥串用了 pi 的密钥」** —— 错。
我一度以为 `reader_name=pi` 与「连接来自 opencode 进程」矛盾。
实测两个 CONFIG_DIR 不同、各自的 key 在库里分别属于 pi / opencode。**没有串用。**

★ 共同点:**我先有了候选解释,再去找支持它的证据**。
正确顺序是「先取一条能一次说清的独立时间线,再解释」。

## 顺带纠正我自己上轮的一个测量假象

我曾说「实测同一时刻 4 个 worker 在跑,MAX_WORKERS=3 被绕过」——
**错的**。`pgrep -f` **把执行查询的那条命令自己算进去了**(它含同样的字符串)。
用 `ps -eo pid,args | grep -E "node .*/worker\.mjs$" | grep -v grep` 实测是 **3 个**,
与上限一致。探针把自己算进来 —— 与 `baseline-residue` / `python-probe-shadowing` 同族。

## 判据

新增 `test/queue-observability.test.mjs`(4 格,钉**形状**不钉读数 ——
读数要真把池压满才有):
  入队/出队各有一声、出队那声必须含等待时长、入队那声必须带占用比、
  以及一条自检(删掉入队日志后源码里确实没有它 ⇒ 判据恒绿的话会先红)

变异验证:删掉入队日志 ⇒ **4 格全红**。
全套:pi 530 / opencode 351 / dsh 426,全绿;共用 lib 一致性 ✅。
2026-09-28 10:40:01 +08:00
fffe6bf63a docs(欠账): 登记 read_inbox 吞掉在途邮件(压测后实测复现 3/3)
## 现象(实测,不是推演)

给 pi 发一封邮件 ⇒ 库里 `status` 变 `read`(投递后 **10~13ms**),
而**桥的日志里那封 mail_id 一次都没出现** ⇒ 没起会话、没人回信。

  读数:`mail_reads.read_at - mails.created_at = 0.013s`
        桥日志提及次数 = 0/3(连发三封,三封全中)
  发件人视角 = 「信发出去了,然后没声了」

## 机制:两件事各自都对,合起来丢信

① `4175c0b` 把「投递即标已读」做成一个动作(`markDelivered`),
   治的是「桥重启 → 重投 → 回声」;`catchUp` 按 `status=unread` 捞。
② `read_inbox` **读完自动标已读**(README 明写),而它按 inbox 取信,
   **不区分「这封是不是正在等派发」**。

⇒ 任何一次 `read_inbox`(不论模型为什么调)都会把**当时还在 unread 队列里**
的信全部连带标掉,其中包含**这一轮刚投递、还没轮到起 worker** 的那封。
它随后既不在 unread 里(捞不到)、也不在 `deliveredMails` 里(还没投递)
⇒ **静默消失**。

## ★ 与 4175c0b 修的不是同一件事

  那条治的是「**投过之后**没标已读 ⇒ 重投回声」
  这条是「**投递之前**就被别的路径标已读 ⇒ 投不出去」
两条方向相反,却落到同一条 SQL 上。

## 放大条件

worker 池越小越容易撞(`AGENTMAIL_MAX_WORKERS` 默认 3,
实测同一时刻 4 个 worker 在跑)。池满时信在队列里等,
**等待窗口正是被 read_inbox 扫掉的窗口**。

## 修法方向(未实施,等人定)

`GetInbox` 侧只标「本会话已投递」的信;或桥侧投递时先落 `deliveredMails`
再让模型读得到 —— **后者与 4175c0b 的「投递即标已读」直接冲突,不能两边都要**。
2026-09-28 10:28:21 +08:00
c25ee1e97e docs(欠账): build-stamp 因产物陈旧而长期红 —— 并修掉两处 JSON 里的坏字节
## 新增 `build-stamp-stale-artifact-blocks-verification`(debts 33 → 34)

`build-stamp` 断言产物自报的 `gitRev` 精确等于 HEAD。实测产物记 `87c55ac`,
而 HEAD 随本轮工作走到 `f1c74fc` ⇒ **无论谁提交什么,这条判据都不会自己
变绿**,只能被一次重构建救。

★ `BUILD_INFO.json` 经 `git ls-files` 确认**未被跟踪** ⇒ 缺的那次构建
  从来没人提交过,不是被谁回滚。
★ 已在**干净 HEAD** 上复现 ⇒ 不是本轮引入(`f1c74fc` 之前就红)。

## 为什么值得单独记一笔:它会**训练人忽略红色**

套件汇总里 `build-stamp` 与真缺陷并列显示。真实的危害不是这条判据本身,
是人一旦习惯「哦又是 build-stamp」,就会把**同一行里的真缺陷一起放过**。
这与本仓反复消的「看不到 ⇒ 绿」是同一族,方向相反:
**看到了 ⇒ 当没看见**。而 `d3a7873` 那笔 HIGH 记的「判据说干净而构建物
不干净」正是它的成因 —— 本条是那笔债的**日常形态**:不危险,但会钝化。

到期动作只认一个:**重跑构建**。判据自己的报错文案已警告不要去改
`gitRev`/`srcHash` 了事(那是把它废掉),本条认同并把正确修法写进 `due`。
**未做**:没触发构建、没改 `BUILD_INFO.json`、没加进任何跳过名单 ——
构建是部署动作,而本工作区正被多个会话并发提交(见下)。

## 顺带修掉两处 JSON 里的坏字节(U+FFFD)

写新条目时自查发现 `docs/DEBTS.json` 里有 6 个替换字符:
① **我这次笔误**:「无论谁提交什么,这条判据」被写成 3 个 `\ufffd`;
② **前人笔误**(HEAD 里就有,已用 `git show HEAD:` 确认):`harmony-system-back-key`
   的「正确的那一个钩子」同样烂了 3 个字节。
两处都已修,`replacement chars: 6 → 0`,JSON 复验合法。

★ 这与 `d3a7873` 里记的 `python-probe-shadowing-in-tmp` 同源:
  改机器可读文件必须**立刻回读验证**。这次是回读时**顺带**发现的,
  说明那条判据的价值不在"校验格式",在"逼你去看一眼内容"。

## 验证

· `node test/run-all.mjs`:files=35 ran=35 checks=582 pass=571 fail=2
  skip=9(**不是通过**)red=2。红的仍是那两条:本次故意红的
  `cross-bridge-prompt` 与本条新登记的 `build-stamp`。
  `RESULT … debts=37` 已把新条目计入(static-only=5==登记 ✓)。
· `debt-visibility` 1 pass / `criteria-hygiene` 10 pass / `commit-hygiene` 4 pass。
· go 侧:除故意红的 notify 外 16 包全绿(须 `GOCACHE=.tmp/gocache`)。

## 共享工作树实况(`shared-workspace-unserialized-deploy` 持续发生)

`server/internal/repo/` 下现有**四个不属于本会话**的未跟踪探针
(`zz_toctou_` / `zz_proposedfix_` / `zz_correctedshape_` / `zz_naivevscorrected_`),
本轮又多了两个 ⇒ **同一包内并发写入正在进行**。本 commit 只 stage
`docs/DEBTS.json`,四个探针原样留在工作树未动。
2026-09-28 10:19:51 +08:00
f1c74fc4ce test(网关): 载荷必须带父邮件发件人 —— 并更正我说它"要新增查询"是错的
## 新增 server/internal/notify/parent_direction_test.go(当前**故意红**)

`docs/DEBTS.json` 的 `in-reply-to-ignores-direction` 的**数据层**判据,
与插件侧 `cross-bridge-prompt.test.mjs` 第 5 条配对(一条钉服务端、
一条钉四个桥的读法,两头都红才算这条债被完整挡住)。

## ★ 更正:上一条 commit(018d5b3)里我说错了一处

我在那里面写「修法:服务端补 `parent_from` 字段**更便宜**,不用多一次
往返」—— 方向对,但**没查证就下了结论**,而且把成本说满了。
现已回读确认,实际比那更便宜:

`resolveTarget` 的 `reply_to` 分支(`internal/handler/mail.go:80-85`)
**已经把父邮件整行 `repo.GetMailByID` 读进内存**(`mail` 变量),
只用了它的 `SessionID` 就把它丢掉;而 `models.Mail` 上就有 `FromName`
(`internal/models/models.go:142`)。

⇒ 判方向所需的**全部数据已经在函数里**,不需要新查询、不需要新 join、
不需要改表。**这不是"补一个字段",是"别把已经在手的数据扔掉"。**
已在 DEBTS 的 note 里留下更正,不静默改口(与 aab92f17 同一个教训:
说过的话要能在记录里看到被改掉)。

## 为什么这条判据是「读源码」而不是「跑行为」

缺陷形状是**载荷少一个字段**。直接跑行为可以断言"payload 里有
parent_from",但那要求先在 repo 里造出「父邮件由别人发出」的数据 ——
而造那串数据的前提正是这个字段已经存在 ⇒ **写不出一个不预设修法的红灯**。
故改为按形状断言源码(AST):判据钉 `Recipients`(载荷是它内部的闭包),
找有没有从父邮件取发件人的取值。

★ 这条判据自己踩了一次同类坑并已修:初版锚的是 `mailToEvent`,
那是我**臆测的函数名**,真机上直接报"找不到"。现已改锚 `Recipients`,
且 `t.Fatal` 的文案明确要求"同步更新判据而不是删掉它" ——
不能因为重构改了函数名就让判据悄悄失去锚点(那正是 `044a664` 的形状:
注释说判据在,而它其实没钉住任何东西)。

## 验证:判据确实有牙(不是空判)

未修 → 红(报"载荷里没有父邮件发件人");
模拟加一行 `"parent_from"` 到载荷 → **转绿**;随即完整还原,
`git diff` 对 `notify/mail.go` 为空(已复验)。
★ 第一次模拟时我写成 `m.ParentFrom`(结构体没这个字段)⇒ 编译失败,
  那是模拟没写对、不是判据的问题;改成字面量再验,绿。

## 全量

`GOCACHE=.tmp/gocache go test ./...`:除本条**故意红**的 notify 外全绿
(repo 1.3s / handler 12s / sse / sse 等 16 包)。
⚠ 默认 `GOCACHE=/root/.cache/go-build` 权限被拒,须显式指定。

## 共享工作树实况(`shared-workspace-unserialized-deploy` 正在发生)

本次 `git status` 看到 `server/internal/repo/zz_toctou_probe_test.go`
与 `zz_proposedfix_probe_test.go` 两个**不属于我**的未跟踪文件
(opencode 的 throwaway probe,同一包内 `go test ./internal/repo/` 仍绿)。
⇒ 本 commit **只 stage 我这两个文件**,那两个探针原样留在工作树里未动。
2026-09-28 10:18:43 +08:00
e4cbffd542 docs(欠账): 更正我自己那笔 HIGH 的两处失准 —— 披露存在,只是会蒸发
自查上一条提交时逐行核了 `redeploy-gateway.sh`,发现我把 `shared-workspace-unserialized-deploy`
写重了。两处更正:

## 更正一:不是「无声」,是**披露会蒸发**

初稿写「没有任何东西会红」。**不准确** —— 披露机制存在,而且是 pi 2026-09-25 专门加的:

· `redeploy-gateway.sh:261-265`:脏树时 `warn` 并**逐个列出未提交文件名**
  (注释里明说「清单有名字,bool 没有」);
· `check-deploy-drift.mjs:1462-1467`:把 `vcs.modified=true` 作为 **WARN 披露**,
  且**刻意不判红**。

⇒ 真正缺的不是披露,是**披露的持久性**。实测 09:52 那次的清单**已不可复原**:
脚本无 `tee`、journal 0 行、`/tmp` 只剩无关产物。**事后没人能说出那次构建
带了谁的哪些文件** —— 而那正是当初加这条披露的全部理由(2026-09-14
`pool.mjs` 那一行未提交的 `let missingSessionCount = 0;` 就是这么进生产的)。

## 更正二:假安全感有**两处**,不只 `flock`

`check-deploy-drift` 的 `modified` **WARN 不参与退出码** ⇒
「反正有判据在报」,而 WARN 不进退出码,**没有东西会因此停下**。

## `due` 同步改写

原文建议「部署时若有未提交改动就拒绝」—— 那会与既有设计**直接冲突**:
`check-deploy-drift.mjs:1462-1467` 刻意不判红,理由写在代码里
(脏树在本仓是常态,判红=总在亮)。改到真正缺的那格:
**让部署把「构建时的未提交文件清单」持久化**(写进构建物旁边 / 归档日志)。
并加一句 ⚠ 提醒别顺手把 WARN 改成判红。

## 验证

`go test -race -run TestDebtLedger ./internal/repo/` PASS(含 due/where 非空与按形状断言);
electron `commit-hygiene` 4 pass。JSON 合法,debts 32。
用 `json.dumps(indent=2)` 改写以保证**其余 31 条逐字节不动** ——
diff 只有 2 行命中,前 31 条 id 顺序与内容均未变(已逐条比对)。

★ 改机器可读文件前先验过 round-trip 逐字节一致才动手,否则一次
`json.dump` 就会把整份文件重排、淹没真正的改动。
2026-09-28 10:13:15 +08:00
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
4556886e04 feat(部署判据): 已退场宿主可显式豁免(zcode)+ 记我自己踩的两个坑
## 背景

zcode 宿主已在本机删除(进程无、`/etc/systemd/system/zcode*.service` 无、
`/opt/agentmail/plugins/` 下无该目录),而 `plugins/zcode-mail-bridge/`
(62 个文件)与 `deploy/systemd/zcode.*`(3 个单元)**故意留在仓库里**以便恢复。

`HOSTS` 是硬编码四家,于是两条判据永久红,且结论区建议的
`bash deploy/redeploy-plugin.sh zcode` 是**错的动作** ——
那会部署一个用户已决定不再运行的宿主。

## 改法:显式豁免表 `RETIRED_HOSTS`

**豁免只换表述、不压红**(这是关键,理由见下):
  · `checkHost` 仍逐条列出「已退场」+ 理由,只是不计入 `stale`;
  · `checkLayout` 的 note 里明写「已退场宿主、故意不装:<单元>」。

为什么不能静默跳过:本文件反复强调「**没检查到** ≠ **不存在**」。
静默跳过 = 让人以为"检查了、没问题",而"这台机器上跑什么"
本身就是需要有人知道的**事实**。豁免必须**可见**。

## ★★ 我自己连踩两个坑,都让豁免**静默生效**(不是"豁免得太宽"那种噪声)

① 第一版 `isRetiredHost` 只查 `units[0]`(zcode.service)。
   实测"只装 zcode-mail-bridge.service、不装快照"时
   **仍被判成已退场**,且那个新装上的单元在 note 里被写成
   「故意不装」—— **自相矛盾且完全静默**。
   ⇒ 改为 `units.every` 逐个查。根因:只抽查一个单元就宣称"全都没装"。

② 第二版用 `existsSync` 判软链存在,而 `current` 是**相对**软链
   (`→ 20260926-085613`)。快照目录被删、软链残留成**断链**时
   `existsSync` 返回 false ⇒ 「装过又删剩」与「从没装过」读数
   **完全同形**。实测断链场景仍被判「已退场」。
   ⇒ 改用 `lstatSync`(看得见软链本身,含断链)。

## 验证:四态都实测过

  A 全都没装        ⇒ 豁免("已退场"仍出现在输出里)
  B 有效软链        ⇒ 报漂移
  C **断链**软链    ⇒ 报漂移
  D 只装一个单元    ⇒ 报漂移

`--self-check` 47 格仍全过。结论区回到
"四个宿主都在跑当前代码"。

## 欠账

豁免逻辑本身**没有自检样本**进 `--self-check`(现靠人测四态)。
按本仓纪律"能自证就别靠人测",应补:注入假 fs 让四态各跑一遍,
且必须有一格是**断链**样本 —— 第一/二版都恰好漏在断链上。
已登记 `DEBTS.json` 的 `retired-host-exempt-never-tested`。
2026-09-28 08:46:02 +08:00
f460ccf7e4 docs(debt): 补记之十六 —— 复核 pi 99320f45(两处早已在案)+ 记录两条**独立测量互证**的表
① ★ 那封信(09-26 02:06:55)的两条更正**我 12 分钟后就已全收**(`81b61fde`, 02:18:27)并自撤了对应论据
   ⇒ 本轮只做**核对、未改结论**。规则 ⑩ 逐条在被引文件里复核,仍成立:
     `deploy/prune-test-sessions.sh:117` `DELETE … WHERE mail_id IN (SELECT …)` ⇒ **不要求 NULL** ✓ 能删已绑定行
     `deploy/reset-demo.sh:81`       `DELETE FROM relayed_mails;`                    ⇒ **全清** ✓
   ⇒ 「查不到痕迹 ⇒ 没删过」不成立,已在定稿多处 ✓
   ⇒ 结论不变: **「422 未能确证」**、`bound(T1) ∈ [556,559]`、占位释放是**非唯一**可行解释

② ★★★ 本轮唯一**新**的一格 —— pi 的 44 次采样与我 200 次**互证**(此前未并列记录):
     | 量              | 我(200×0.3s+90×1s) | pi(44 次)    | 判定 |
     |-----------------|--------------------|--------------|------|
     | "→0" 零行占比   | **25%**             | **25%**      | ✓ 一致 |
     | 换 ws 事件      | 27 次变化 / 90s     | 22 次 / 44 次| ✓ 同量级 |
     | 最长零窗        | ≈ **5.1s**          | 未测         | 我独有 |
     | "→0 之后回升"   | ★ **测不到**        | **11 次**    | **它独有** |

③ ⇒ ★ 重点不是"谁对"而是**两条测量粒度不同、因而互补**:
     我的 0.3s×200(=60s) 与 1s×90(=90s) **无法分辨**"归零后回升"这个**子形态**
     (回升快于采样间隔时,我只看到"又一次变化")
   ⇒ "25% 相同"不是同义反复: 两个**独立执行**在**同一量**上撞出一致 ⇒ 零窗口是真实的
   ⇒ "11 回升"是**我采样设计漏问**的一格(不是它多测了真相)
   ⇒ ★ 方法论同族: 采样率决定**能看见哪些形态**; 报"没测到"必须写"**我的粒度下测不到**",
     而非"不存在"

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; 未改产品代码
2026-09-28 04:11:11 +08:00
201644749a docs(debt): 补记之十五 —— 修法形状**定稿**:必改点三处→**四处**,③ 的键必须是**三键**(我补了 pi 没列的场景,结论反转)
① ★★ pi 场景C 复现成立: 同 agent + 同 platform_id + 两个 workspace(改完 PK 的未来态)
     ① 只按 **agent** ⇒ **2 行** ⇒ QueryRow 仍取第一行 ⇒ 照"按 agent"改会**踩新歧义** ✓

② ★★★★ 我构造了 pi 没列的**场景D**(**两个 agent 共用同一 workspace**),结论反转:
     ② 只按 **workspace** ⇒ ★ **2 行** ⇒ **也不够**
     ③ agent + workspace 两把 ⇒ **1 行** ✓
   ⇒ ★★ 场景D **不是假想**: 生产 `sessions` 里现成就有 ——
        `ba9c194b` from_agent=[pi]  ws=/home/program/agentmail
        `9742de96` from_agent=[dsh] ws=/home/program/agentmail   ★ 同一 workspace、两个 agent
   ⇒ ⇒ **三键 (platform_id, agent_name, workspace) 是唯一在 A/B/C/D 全场景恒为 1 行的键** ✓
     pi 的结论(两把一起)**成立且是必需的**; 我补的是"为什么不能只留一把"

③ ★★ `sessions.workspace` 口径要写全: pi 的「9/9 非空」✓ 成立,但那是
     **有 platform_id 的 9 行**这个子集; 全表是 **51/79**(那 28 条空的**全都没有 platform_id**)
   ⇒ 对**要改的那条 JOIN** 而言 workspace **总是可用** ✓,且**不需新加数据/迁移** ✓
   ⇒ ★ 教训: 同一句"非空率"不写**分母**时 9/9 与 51/79 都能自称"非空"
     ⇒ 报比例**必须带分母定义**(与 ⑫′ 同源)

④ ★★ 补验上一版标的 unknown: 有 platform_id 的 9 行 `from_agent` **9/9 非空** ✓
   (与 workspace 同为"有 platform_id ⇒ 必非空")⇒ ③ 的两个键**都有数据支撑**,不需新增列

⑤ ⇒ ★★★★★ **必改点定稿(四处,必须同批)**:
     ① PK → `(agent_name, workspace, platform_id)`
     ② DELETE(`:63`) 按 workspace 限定
     ③ JOIN(`:473-474`) **三键**: `ON aps.platform_id = s.platform_id
        AND aps.agent_name = s.from_agent AND aps.workspace = s.workspace`
     ④ 迁移重建表(`BeginTx` 内 DROP+RENAME+**RENAME 后**重建 `idx_platform_sessions_ws`)
   ⇒ ⚠️ ③ **必须**与 ① 同批: 单独改 ① 会把 ③ 的歧义**放大**(场景C 实测)
   ⇒ 顺带记: `pid=mail-xxxx` 那类 platform_id 与 workspace 1:1 的行看着够用,但**不能依赖**(上两行即反例)

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; /tmp/sc 已清; 未改产品代码
2026-09-28 04:09:09 +08:00
af4190d145 docs(debt): 补记 python-probe-shadowing —— 采纳 pi 两因子刻画,并把我两处措辞**收紧**(我自己先审自己)
① ★★ 它推翻的"混入+rc=0 不可达"**我早已自己推翻过**(`docs/API.md:6755` 就写着"被 pi 反例推翻")
   ⇒ 我自查本轮新登记的原文: 那里写的是「**可能** rc=0」(不绝对)⇒ **没有**重新断言
   ⇒ ★ 准确定性: 不是"复犯", 是**措辞没守住已结案的边界** —— 而这本身是新的一类错

② ★★ 两因子刻画,我实测**成立**且比它的表述更准:
     反例复现: `try: import json / except: json=None` ⇒ **rc=0 + SHADOW_OUTPUT 混入 + marker 仍在** ✓
     对照(不 catch、异常逃逸)⇒ 混入仍在但 **rc=1** ✓ ⇒ 两者**独立** ✓
   ⇒ 采纳: 「**混入**由"遮蔽文件被执行"决定;「**rc**」由"异常是否逃逸"决定(取决于调用方 catch)」
     ⇒ 两个**正交**因子,必须**分别**断言,合起来才是"完全静默"
   ⇒ ★ 并把它的"必然"**收紧一格**(我实测): "混入必然"成立于「**脚本与影子同目录**」这个前提下
     (此时脚本目录在 sys.path[0]、影子总是先被找到,两种导入顺序实测都命中);
     但影子文件**自身不写 stdout** 时 ⇒ **执行了却不混入**
     ⇒ 准确说法: 「**被执行**必然、**混入**取决于影子是否写 stdout」

③ ★★ 我自己那句"真危险形态"(`2>/dev/null` 拿到别人的文本)标**不完整**:
     完整的完全静默 = **混入** × **rc 由 catch 决定** × **stderr 被丢弃** —— 三者同时成立时
     ⇒ 输出被替换、退出码正常、连报错通道都被关掉 ⇒ **无任何可观测征兆**
   ⇒ 与 `recount-relay-counts.sh` 那条同族: **"没报错"≠"没出错"** ⇒ 判据必须**独立于 rc**

④ ★ 采纳它的可判自检: 报"**条数**"前先问「**这个量有几个来源**」,多来源须**列出各自贡献**
   ⇒ 与 ⑫′(报数带查询)、④′(按机制分类)同族 ⇒ 并入本条

⑤ 顺手核了一遍"我在已结案错误上又踩了几次": 全文仅 "同强" 一处残留,且**正是我已订正的那句**
   ⇒ 无未修残留

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; /tmp/tf* 已清; 未改产品代码
2026-09-28 04:07:00 +08:00
2b9bf656f7 docs(debt): 补记之十四 —— pi 249fe29d §三 第三处成立且**比它说的更重**:改 PK **不解决**它,反而让它变成常态路径
① 核 pi 指的代码属实: `platform_sessions.go:467` `PlatformSessionFor` 用 **QueryRowContext**,
   `:473-474` 的 `LEFT JOIN ... ON aps.platform_id = s.platform_id` —— ON 子句**只按 platform_id**、
   **不含 agent/workspace** ⇒ 多行时 **QueryRow 静默取第一行**

② ★★ 实测复现 (/tmp/p3): 同一 `ses_ABC` 挂 pi 与 dsh 两条 ⇒ 返回 **agent_name = dsh**,
   而调用方是 **pi 的会话** ⇒ **归属方取错**、无报错无告警

③ ★★★★ 比 pi 说的更重的一层 —— 用**改后**的 PK `(agent_name, workspace, platform_id)` 建表实测:
     同一 `(pi, ses_ABC)` 插**两个不同 workspace** ⇒ 都插得进(**这正是改 PK 的目的**)
     `:473` 的 JOIN 只按 platform_id ⇒ 匹配 **3 行** ⇒ QueryRow 仍取第一行
   ⇒ ★★★ 改 PK **之前**旧 PK `(agent_name, platform_id)` 把"一 agent 一 platform 只能有一个 ws"
     压住了 ⇒ 这条歧义**几乎触发不到**;
     改 PK **之后**同一 agent 的同一 platform **可以**有多个 ws ⇒ 歧义**变成常态路径**
   ⇒ ★★ ⇒ 第三处**必须与 PK 同批改**; 不改的话本次修复会把一条"今天几乎触发不到"的取错归属
     **升级成天天可能触发**

④ ★★ 承重(我核了调用链,故认同它"比候选列表更重"):
     `notify/mail.go:94` `platformID, platformOwner := repo.PlatformSessionFor(ctx, m.SessionID)`
     → owner **直接决定投给谁**(`:105-109`: platform_id 只发给归属方; owner 为空则**一律不下发**)
     → `:90-93` 注释记着**生产实测过的真实故障**(pi 的会话被推给 dsh ⇒ 邮件静默消失)
   ⇒ 失败形状是**静默**的(与 `ReleaseRelay`、`/tmp` 影子模块同族)
   ⇒ pi 说 `:205/:292` 已按 workspace 限定、不受影响 —— 我核了,**属实** ✓

⑤ ★★ ★ 自查并**当场撤回**我上一版写的"**循环依赖**"(那是我加的,pi 没提):
     复核 `:94` 在 `Recipients` **函数顶部、只算一次**(CC 循环在 `:239`)⇒ **无循环** ⇒ 撤
   ⇒ 但复核时暴露一个**更基础**的问题(改标**未解**、不假装已知修法):
     `owner` 是**全局一个**的值,而一封邮件**可有多个参与方**(`m.CC`)
     ⇒ 一次 `PlatformSessionFor` 回答的是"这条**会话**归属谁",
       而分发需要的是"这封信对**每个参与方**各自是什么" —— **不是同一个问题**
     ⇒ 修好 JOIN 消歧只让"会话归属"变**确定**; "多参与方各自该不该收 platform_id"仍**未设计**

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; /tmp/p3 已清; 未改产品代码
2026-09-28 04:04:28 +08:00
928d2714e9 docs(debt): 新登记 python-probe-shadowing-in-tmp —— ★ 触发条件是**脚本所在目录**(不是 cwd),且**静默 rc=0**
① 复核 pi `b1bd61ef` §四: **它对,我此前的"换目录跑"规避无效**。实测 (/tmp/shadow):
     脚本在 /tmp/shadow/t1.py、cwd=`/` ⇒ **仍被污染** ⇒ `sys.path[0] == '/tmp/shadow'`
   ⇒ 正确说法: `sys.path[0]` = **脚本自身所在目录**; 换 cwd 完全无效
   ⇒ `python3 -I` **有效**(隔离模式不把脚本目录放进 sys.path)✓

② ★★ 真链路比"某个脚本 import 了 json"宽得多:
     `json/__init__.py` 内部 `import re` ⇒ `re/__init__.py:124` `import enum`
   ⇒ **任何 `import json` 的脚本**都会执行同目录下的 `re.py` / `enum.py`

③ ★★ 严重性实测: 伪造 `json.py` 让 `json.load()` 返回 `110` ⇒ 被污染脚本
   **正常跑完、rc=0、stdout 混进别人的输出**
   ⇒ 形状 = "混入别人的输出且可能 rc=0" —— 与 `ReleaseRelay` 那条同族: **不报错、不失败、只是答案换了**
   ⇒ 本轮已因它撤过一次结论("11/12 候选=0"),故必须留档

④ ⇒ ★★ **撤销**我此前给的规避"换目录跑"(对"脚本在 /tmp"**不成立**)

⑤ ⇒ 自查我本会话**结论是否受影响**(非辩解,是必查项):
     我所有 python 探针都是 **heredoc(不落盘)** ⇒ 走 stdin、`sys.path[0]` 是 `''`(cwd),
     而我的 cwd **从不是 /tmp** ⇒ **免疫**(已实测对照: 落盘脚本被污染、heredoc 干净)
     我落过盘的探针目录(idxtest/h3/v1/pktest)**只含 sqlite 命令、无 .py** ⇒ 不触发
   ⇒ 结论: 已提交结论**未被污染**; 但只要有人改成"落盘 .py 再跑"就开始不可信,且**无任何报错**

⑥ ⇒ 可执行规则(替代"小心一点"): ① 落 .py **不放 /tmp**(或任何多人共用目录)
   ② 必须放则 `python3 -I`,或先 ls 确认同目录无 json.py/re.py/enum.py/os.py/sys.py
   ③ 首选 **heredoc 不落盘**(天然免疫)
   ④ 症状识别: 输出出现**没写过的行**、或 rc=0 却结果离谱 ⇒ 先查同目录影子文件

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; /tmp/shadow* 已清; 未改产品代码
2026-09-28 04:02:00 +08:00
2b8eeae3a6 docs(debt): 新登记 prune-artifact-evidence-decays-with-reboot —— ★ 物证①的**前提已失效**(/tmp 是 tmpfs,重启即全失)
① 复核 pi `c6dbc8d0` §三: 他的加强物证**当时是对的且测法扎实** ——
   prune 的 `.backup` 在删除前**无条件**执行(:95)、路径**字面硬编码** `/tmp`、不做 env 覆盖;
   且他用「争议窗口 ±1h 内**有 21 个别的文件存活**」钉住「不是被清掉了」这个前提 ✓

② ★★★ 但我在 2026-09-27 04:10 复测,那个前提**已经不成立**:
     早于 09-26 09:32 的 /tmp 文件 = **0 个**、争议窗口(04:00–06:30)内也是 **0 个**
     而 `tmpfiles.d/tmp.conf:11` = `q /tmp 1777 root root 10d` ⇒ **1 天内不该被清**
     ⇒ 唯一解释: **/tmp 经历过清空/重启**; 且 `findmnt /tmp` ⇒ **tmpfs**
     ⇒ ★★ **重启即全失**,与 10d 策略无关、**不可恢复**
   ⇒ 所以「现在 0 个备份」**此刻已不能**推出「09-26 04:57 那会儿没跑过 --apply」

③ ★ 定稿已就地降级(`docs/API.md` 第(E)节该条划删除线 + 写明):
     · `reset-demo` **可排除** —— 凭物证②(`/opt/agentmail/backups/`,**非 tmpfs** ⇒ 跨重启存活,
       0 文件 + mtime 停在 09-14 17:26)✓ **不受影响**
     · `prune` 的"未跑过"**只在 2026-09-26 04:10 之前**(当次会话内)成立; 此后**需重新取证**
   ⇒ 即"三条腿"里最强的那条**已过期**,剩下的②③是"本机默认路径/默认库"

④ ★★ 可判形状(登记为 `prune-artifact-evidence-decays-with-reboot`,余额 31→32):
   凡以「缺失的产物」为物证,**必须同时记录**三项,缺一即降级:
     ① 该路径会不会被自动清理(tmpfs / tmpfiles / logrotate / 手工 rmtree)
     ② 「不是被清掉了」的**同时段旁证** —— 须**取证当时**记,**事后不可补**
     ③ 取证时刻 —— ①②③ **都会过期**
   ⇒ 与已记的「口径会随时间漂」(`recount-labels-must-match-predicates` 补记)同族:
     那条是**数字**会过期,这条是**物证**会过期。
   ⇒ ★ 由此得一条**该做而没做**的: 物证会过期 ⇒ **结论就该带时刻**。
     我们此前把「prune 没跑过」写成**无时刻的现在时** ⇒ 本次纠正这个写法。

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; 围栏 1692 配平; 未改产品代码
2026-09-27 04:11:38 +08:00
3f1abbf0c4 docs(debt): 补记之十二/十三 —— pi 陷阱①复现成立且**修法已实测跑通**;★★ 陷阱②它的**理由方向反了**(会导出错误动作)
① ★★★★ 陷阱①(逐条 Exec ⇒ 各自 auto-commit ⇒ DROP 后崩)—— **我独立复现,完全成立**:
     播下 aps 行数=1 → 建 _new → 拷贝 → DROP ⇒ `aps` 存在=**0**、`aps_new` 行数=1
     下次启动: `CREATE TABLE IF NOT EXISTS aps(新PK)` 建出**空表** ⇒ 实测行数=**0**(线上即 37 行)
     守卫只看 PK ⇒ PK **已正确** ⇒ **跳过重建** ⇒ **永不自愈**; 而判据 (a)(b) 此时**全绿**
   ⇒ 绿着丢数据
② ★★ 修法**实测跑通**(非纸面): 同一 `BEGIN…COMMIT` 内 建 _new→拷贝→DROP→RENAME→**重建索引**
     ⇒ 行数=1(保住)✓ 索引=1(补回)✓ `_new` 残留=0 ✓ PK=(a,b,w) ✓
   ⇒ 索引那条**必须写在 RENAME 之后且在事务内**(写在 RENAME 之前会被 init DDL 那句空转掩盖)
   ⇒ 采纳 pi 的"④ 从『保证顺序』改成『**事务内显式重建索引**』"—— 顺序依赖被事务消掉
   ⇒ ⚠️ 但**仅靠事务不够**: 防不了"上一版已崩"留下的孤儿 `_new` ⇒ 判据 (c) 与孤儿可恢复是**必需**第二道

③ ★★★ 陷阱②(守卫不能用 LIKE)—— 陷阱为真,但**理由方向反了**:
     pi 说: "'…PRIMARY KEY (…' **带空格**,**实际无空格**"
     ★ 我实测: `sqlite_master.sql` **逐字保留**建表语句、不规范化空白
        建 `PRIMARY KEY (a, b)` ⇒ 库里带空格 ⇒ **带空格的 LIKE 命中**
        建 `PRIMARY KEY(a, b)`  ⇒ 库里无空格 ⇒ 带空格的 LIKE **不命中**
        线上实测: `agent_platform_sessions` 的 `PRIMARY KEY (agent_name, platform_id)` **带空格**、
                  `LIKE '%PRIMARY KEY (agent_name, platform_id)%'` **命中=1** ✓
        `init_sqlite.sql:421`(及 :328/:380/:389)用的**就是**带空格写法
   ⇒ ★★ 真正机制: **LIKE 命中与否取决于当初 DDL 的书写风格**,而书写风格不是不变量
   ⇒ 结论与 pi **一致**(用 `pragma_table_info` 的 pk 列,别用 LIKE)但**理由不同**
   ⇒ ⚠️★ 照 pi 的理由去改(例如为"匹配无空格"把 DDL 改成紧凑写法)**恰好制造它描述的故障**:
      改完 DDL 后老库 `sqlite_master.sql` 仍是旧样式(IF NOT EXISTS 不重写)⇒ LIKE 反而不命中
   ⇒ ★ 本轮最值得记的: **正确结论 + 错误理由 ⇒ 导出错误动作**(我差点照错误理由去改)

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; /tmp/v1 已清; 未改产品代码
2026-09-27 04:09:42 +08:00
8b2c9a8fb8 docs(debt): 补记之十一 —— PK 断言要"走迁移路径"才有判别力;★ 并给出可执行形状 + 线上现状实测
① ✅ pi §四 确认,并量化"为什么"(/tmp/pktest 实测):
   全新库: PK 来自 DDL 本身 ⇒ 断言**恒绿**、零判别力
   旧  库: 重跑同一份 DDL(CREATE TABLE IF NOT EXISTS 命中已存在表 ⇒ 原样跳过)
           ⇒ PK 仍是 `PRIMARY KEY (agent_name, platform_id)`
   ⇒ 两种情形**同一份断言**,差别只在"库怎么来的"
   ⇒ ★ 判据必须**自己造一个旧库**(老 DDL 建表 → 灌行 → 跑迁移 → 断言),
     否则它测的是"DDL 文本对不对",而那件事**永远成立**

② ★★ 线上现状(2026-09-27 04:0x 实测)—— 正是"改 DDL 对生产静默无效"的现场:
   PK 列 = `agent_name , platform_id`(**仍是旧的**)
   索引  = `idx_platform_sessions_ws`(在,今天还没被任何重建吞掉)
   行数  = 278
   ⇒ 文件里 PK 写什么,库里都不是那个;而全部测试绿

③ ★ 判据/自检该用哪种读法(两个都实测):
   ✅ `pragma_table_info` 的 **pk>0 列按 pk 序号排序**后逐项比对
      旧库 ⇒ agent_name, platform_id;  全新库 ⇒ agent_name, workspace, platform_id
   ✗ `LIKE '%(agent_name, workspace, platform_id)%'` 匹配 PK 文本:
      在**旧库(PK 明知是错的)上不命中** ⇒ 判据会"**绿着一个错的库**"
   ⚠️ pragma 的 pk 顺序**可能与声明书写顺序不同**
      (PRIMARY KEY (agent_name, platform_id, workspace) 在 pragma 里就是这个顺序)
      ⇒ **必须按 pk 序号排序后逐项比**,只比集合会漏掉次序差异

④ ★ 由此给出"迁移后自检"的最小形状(今天可写、现在**红**、修好即绿):
   在 `Migrate` 末尾对 agent_platform_sessions 断言三样:
     ① pk 列(按序号)== (agent_name, workspace, platform_id)
     ② idx_platform_sessions_ws 存在
     ③ 无 agent_platform_sessions_new 残留
   ★ 与 (a)(b)(c) 的区别: 那些是**测试**断言(要自己造旧库才有判别力),
     这一条是**启动路径上的自检** ⇒ **生产也会响** —— 否则缺陷在生产上永远不暴露

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok; 临时目录已清
2026-09-27 04:05:33 +08:00
ac09c496e2 docs(debt): 补记之十 —— 重建表会**静默吞掉** idx_platform_sessions_ws(pi 指出,我实测并**加重**)
① 我先复现 pi 的断言(/tmp/idxtest,裸 12 步: 建 _new → 拷贝 → DROP → RENAME):
     重建前索引 = idx_platform_sessions_ws ⇒ 重建后 = **(无)** ✓
   并复现它的"依赖顺序": 重建在前+CREATE INDEX 在后 ⇒ 能补回; 反之 ⇒ 补不回 ✓

② ★★★ 但在**本仓真实顺序**下,那句 `CREATE INDEX IF NOT EXISTS` 是**空转、补不回**:
     `migrate.go:33-37` 逐条 Exec 走完 init DDL 全文(含 :424 的 CREATE INDEX),
     **之后**才是自定义迁移(addMissingColumns 在 :40 也在其后)
   ⇒ 真实时序: CREATE INDEX(空转,索引此刻还在) → **DROP** 带走 → RENAME 不带回
   ⇒ 我按真实时序实测: 最终索引 = (无)
   ⇒ ★★ 补记之七 ④ 里的「b: 索引存在」判据**会红** —— 且它红得**对**

③ ★ 为什么这个索引是论证基石而非可选优化:
     `platform_sessions.go:473-474` 的 LEFT JOIN 正是修复③要消歧的那一条
     ⇒ 丢索引**不出错**,只让每轮心跳的 DELETE/JOIN 退化成**全表扫描**
     ⇒ ★★ 最坏的一类后果: 不报错、除"索引存在"外全绿、线上只是变慢(静默劣化)

④ ★★ 修复清单加两项:
     ④b **在重建之后**(不是之前)显式 `CREATE INDEX IF NOT EXISTS idx_platform_sessions_ws`
         ⇒ init DDL 那句在 DROP 前已跑过、只是空转,**指望它兜底是错的**
     ④c 判据 (b) 保留,且**必须走迁移路径**(全新建库必绿、不具判别力)
     ⇒ 正确形状不是"记得补这一个",而是"**迁移后逐项核对 schema 对象集合**"

⑤ 我已**实测验证 ④b 可行**(/tmp/idxtest/d.db):
     重建后索引=(无) → 补建后=idx_platform_sessions_ws
     PK=PRIMARY KEY (agent_name, workspace, platform_id) ✓ 行数=1 ✓ _new 残留=0 ✓
     ⇒ 行数与 PK 都对,**只有索引需要显式补** ⇒ 证实"RENAME 只搬表,不搬索引/触发器/外键"

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok
2026-09-27 04:04:56 +08:00
7c9d1cedc9 docs(debt): 记一条高频教训 —— "当下测出的结论"不适用于"被评动作发生的时刻"(两次都由我踩中)
★★ pi `e440953b` 指出我 `1de1c4c7` **在其帧内逐条为真**,我复核**全对**:
  · 决定性时间序: 脚本首版 `60d59f9` 提交 = **09-25 06:08:59**;我 `1de1c4c7` = **09-25 05:52:04**
    ⇒ ★ 晚 **16m55s** ⇒ 写那封时脚本**尚不存在** ⇒ "拿脚本的 459 套讨论的 459"**时间上不可能**
  · 帧重建(`created_at <= '2026-09-24 21:52:04'`,该列 0 NULL): bound∧P=**458** / loose∧P=**459**
  · `1de1c4c7` 的四条断言在**它自己帧**里逐条为真:
      [a] 459(loose) 里未绑定那 1 行 = 1 ✓  [b] 458(bound) 里残留那 1 行 = 1 ✓
      [c] 残留总数 = 2 ✓                  [d] 459(loose) − 2 = 457 ✓
⇒ 我在 `03adbf14`/`2a9be0e` 里"我 1de1c4c7 也错"的判断**作废**; 真错只有 pi `44dccaee` 那句(他已自认)

★★★ 由此得一条教训(**两次都由我踩中** ⇒ 值得进清单):
  ① `e77154d1` 那轮: 探针把"取 T"写在 `ReplacePlatformSessions` **之后** ⇒ 量到删除后的表
     ⇒ 得出"我的探测器漏报"的**相反**结论
  ② 本轮: 我验证"459−1(未绑定) 不成立"**在今日帧成立**,就据此判 `1de1c4c7`(**讨论帧**)也错
⇒ ★ 同形: **观测/判定的时刻必须与被观测/被评的动作发生在同一时刻**。
  这是"判据要锚定到它防的那个动作"的**时间轴版本** —— 原那条管"锚到哪个动作",这条管"在哪个时刻测"
⇒ ★ 可执行动作: 凡结论涉及"某历史时刻的库状态",必须用 `created_at` 这类**带时刻的列**把状态
  **重建**出来再判,并把该时刻与被评动作的时刻**一起打印**(⑫ 的第四样)

另: `recount-labels-must-match-predicates` 补记本条的**第二个面** ——
  不只是"口径写得不完整",还有"**口径会随时间漂**"(loose/bound 各 +1 后撞上同一个 459)
⇒ 故只把标签写全**不够**,须**同时打印两个口径 + 取数时刻**(`b39359d` 已如此)
2026-09-26 09:20:09 +08:00
9311612358 test(repo): 建 (d1) 判据 —— 上报非空 list 时不得删除其它 workspace 的行(**今天可写、现在红、修好即绿**)
pi `e77154d1` §三 指出我"把 (d) 整体归入 due"是**反方向的错**: 判据的**可得性**本身要复核 ——
把今天就能给的判据当成"要等未来才能给",余额里就挂着一个今天就能变绿的缺口。
我先写仓内探针逐条跑(跑完即删),四条读数:

  [修前] 播下 /A=2 → B 上报 /B=1        ⇒ (d1) FAIL: /A = 0,期望 2   ★本缺陷
  [修前] T\L @DELETE前 = [/A]            ⇒ 响 ✓(我那形状确实抓得到真缺陷)
  [修好] 共存 /A=2 /B=2 → /A 仍 = 2      ⇒ (d1) PASS ⇒ 修好即绿 ✓
  [修好] T\L @DELETE前 = [/A]            ⇒ ★ 假阳:修好后每次心跳都常鸣
  [修好] 与"实际删除域"比 destroyed = [] ⇒ 不响 ✓ 无假阳

⇒ ★★ 同时**撤回我 §三 提的 `T\L ⇒ WARN` 形状**(记入 DEBTS 补记之九):
   根因: "即将被销毁"由 **DELETE 的谓词**决定,不是由 T\L 决定。
   修前 DELETE 域 = 整个 agent ⊋ L ⇒ T\L 恰等于被销毁集合(碰巧对)
   修后 DELETE 域 = 按 ws 删 = L       ⇒ 被销毁 = ∅,而 T\L 仍非空 ⇒ **恒假阳**
   而修好后表里天然共存多 ws(那正是修复目标)⇒ **每次心跳常鸣**
   ⇒ 落进本仓「**还清了反而红**」那个坑 —— 我为 (d) 拒绝超前断言的理由,
     在我自己提的形状里以假阳形式复现了。忠实形状: `destroyed = 被本次 DELETE 移除
     且未被本次 list 重插的 ws`(与实际删除域同源 ⇒ 修前响/修后不响,且 [] 时仍覆盖)
⇒ ★★ (d) 拆两半: **d1 = 本条**(每项自带 Workspace ⇒ 不需要请求级字段 ⇒ 今日可判);
   **d2 = 上报 [] 时只清自己那个 ws**(需要"这次上报属于谁")⇒ 与 scope 字段同 due

自查: 探针第一版把"取 T"写在 Replace **之后** ⇒ 量到删除后的表 ⇒ 结论会全反
      (据此差点得出"漏报"的相反结论);已把取 T 排到 B 上报**之前**重测。
      教训: **观测点必须与被观测的判据在同一时刻** —— 与"判据要锚定到它防的那个动作"同一条。
测试: ./internal/repo/ 245 通过、唯一红项即本条(它断言的正是尚未修复的缺陷)
登记: platform-mirror-d1-cross-workspace(余额 28→29);-run Debt ⇒ ok
2026-09-26 09:15:30 +08:00
a6ee355089 docs(debt): 补记之八 —— 活库当场量到该缺陷(26 目录轮流上场/25% 读到 0),并收窄 pi 的探测器
① ★★★ 首次**当场量到**(此前都是复现/推断):
   opencode 镜像任一瞬间只持有 1 个目录(瞬时含多 ws 的样本 = 0/200),
   而它自己库里有 **26** 个 directory ⇒ 轮流出场、每次心跳整批擦掉上一个
   90×1s: 0 行 24/90;非空样本出现过 9 个不同目录;变化 27 次(→0 共 7 次、非空→非空 13 次)
   200×0.3s: 0 行 25%;最长连续 0 段 17 样本 ≈ 5.1s
   各目录占比 agentmail 36% / am-mcp-probe 22% / 其余 1~4% ⇒ 除最常出场者外大概率读到 0
   对照: pi ws=63 rows=150、dsh ws=25 rows=77(单上报者 ⇒ 上报域==替换域 ⇒ 无损)

② 范围定稿(pi 结构层,我复核成立): 充要形状 = **上报域 ⊊ 替换域**
   opencode 按 directory 实例化(index.js:1102-1103 / :1145-1147)而 AGENT_NAME 是单一常量(:49)
   pi(worker.mjs:384-387)/dsh(index.ts:413)单上报者 ⇒ 上报域==替换域 ⇒ 无此病
   ⇒ (d) 适用范围写明"一个 agent 多个上报者";记已知风险"pi 若改成按目录实例化立刻得同一 bug"

③ ★★ 收窄 pi §六 的探测器: `len(list)==0 && before>0` 只覆盖两种形态之一
   实测 27 次变化里 →0 仅 7 次、**非空→非空 13 次**(报 23 行的心跳也会擦掉 37 行那个目录,而它 len≠0)
   ⇒ 忠实于机制(域不匹配)的探测器应当**比域**: L=list 里的 ws 集合、T=表中该 agent 现有 ws 集合,
     **T 中不在 L 里的 ws ⇒ 即将被销毁 ⇒ WARN(附行数)**
     ⇒ 同时覆盖 ① `[]`(L=∅)与 ② 换成另一个目录(L={B} ⇒ T 中 A 命中)
   ★ 差别: pi 版量"这次报了多少",比域版量"**谁即将没有**" —— 后者才是读侧真正感到的事

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok
2026-09-26 03:53:01 +08:00
a90f664962 docs(debt): 登记 recount-labels-must-match-predicates —— 本轮的标签漏限定符是"已写下的口径自身不完整"
为什么单独立一条(而不是并进刚修的那个提交):
· 我刚修的只是**那两个标签**(实例),没有任何判据保证"标签与它数的谓词一致"(形状)
· 这个脚本存在的全部理由就是"把口径写下来"(文件头「口径声明」节),
  而它最显眼的两行标签没写全限定符 ⇒ 读的人拿去对账必然对不上,且已实际发生一轮
  (09-25 我报的 loose 459 与 09-26 脚本打的 bound 459 是同一个数字、不同集合)

可判形状(到期时建):
① 每个读数变量在**定义处**与**标签处**的谓词一致 —— 把"标签字面"当查询跑一遍比对
   (本轮就是这么抓到的,比读代码可靠)
② 两个口径并存时**两个数都要打** —— 只打一个时读者判不出手里那个是哪个口径

due = 下一次往 recount 扩充读数/口径时(防复发,不是修当下 bug —— 当下两处已修)
余额 27→28(26 条);go test ./internal/repo/ -run Debt ⇒ ok
2026-09-26 03:44:35 +08:00
92cee22271 docs(debt): ④ 补齐第四块 —— "正常态"丢数据 ⇒ 加判据 (d) 与治本项(请求级 scope 字段)
pi 48c2c4c9 报的缺口我独立复核成立,并把它查到**结构层**:

① 缺口: 空列表上报时 `$2`(workspace) 无定义
   `ReplacePlatformSessions(ctx, agentName, list)` 签名里**没有** workspace 参数
   workspace 逐行来自 ps.Workspace(:81 INSERT 的 $3)⇒ `list=[]` 时**无从反推**
   而 `heartbeatRequest`(agents.go:13-46) 实测**无请求级 workspace 字段**
   ⇒ "这次上报替哪个目录报的"在**协议上不存在** ⇒ 不是"加个参数"能修的,是协议缺字段
   三种取法全不成立: '' ⇒ 清不掉(残留); 全部 ⇒ 退化成今天的 agent 级全擦; 上一次的值 ⇒ 引入状态且多实例互相覆盖

② 深度: `[]` 三语义里 ②"这个 agent 没会话"今天无人有权说;缺的是服务端**第二次区分**
   桥已区分(index.js:1144-1156 成功空⇒[] / 异常⇒省略字段);服务端拿到 [] 不知属于哪个 workspace
   ⇒ 语义① 落地时缺**主语** ⇒ 治本项 = 请求级 scope 字段(DELETE 域 == 上报域)

③ 复核 pi 支撑数据: opencode project 13 行 / 11 不重复 worktree
   逐行 0 会话 = 3 行;按 worktree 汇总 0 会话 = **2 个** ⇒ pi "2 个合法空目录报 []" 成立 ✓
   镜像表 90 个不同 (agent,workspace) ⇒ 多目录上报是常态 ⇒ 空目录报 [] 今天就发生
   教训: 聚合口径变了答案就变(行=3/worktree=2)⇒ 报数必须带**聚合键**

④ 判据从三条加到四条,且 (d) 按"与治本项同一前提到期"登记(避免造出永久红判据)
   (a) 迁移路径 PK 断言 (b) 索引存在 (c) 无 _new 残留
   (d) 空列表上报不得丢数据: 同时断言「该 workspace 被清空」**且**「其他 workspace 行数不变」
       —— 只断一半必漏(只断前者漏误擦=本 bug;只断后者漏残留)

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok
2026-09-26 03:26:45 +08:00
b93cc82f93 docs(debt): 把"补记之六"从 platform-mirror 迁到 failure-suppression(我上一步放错了条目)
上一步 46f3c38 把"失败报告产生者 6 个/4 语言、失败前缀 5 种形态"记进了
platform-mirror-replace-domain-too-wide —— 内容与该缺陷无关(那条讲平台镜像的替换域),
正确归属是 failure-suppression-must-not-merge-parallel(失败报告的分类/抑制)。

迁移后: mirror note 回到 9735 字节(与迁移前一致),failure note = 7419 字节
校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok
2026-09-26 03:01:46 +08:00
46f3c38470 docs(debt): 补记之六 —— 失败报告的产生者是 6 个/4 语言,失败前缀有 5 种形态(含 homeagent 标记在第 2 段)
回答 pi 5b0bbc33 的"桥侧成本"之问(悬置未答):

① 产生者不是 11 处 —— 另有**两个不在 plugins/ 清单里**的:
   ① deploy/service-failure-notify.mjs:155(独立 systemd 脚本,非插件)⇒ 库里 service-failure × 25(全已绑定)
   ② plugins/homeagent-mail-bridge/plugin.go:900(**Go 桥**)⇒ 库里 homeagent: × 18
   语言分布 Go/TS/MJS/JS 四种

② ★ 失败前缀**五种形态**并存(不是一种):
   model-failure ×36 / service-failure ×25 / zcode-failure ×21(第 1 段即标记)
   homeagent ×16 = `homeagent:failure:<uuid>` ← ★ **标记在第 2 段**
   empty-reply ×0(代码 1 处、库 0 行 ⇒ 首次触发即静默漏判)
   且 homeagent: 共 18 行 ⇒ 同前缀两种语义(另 2 行无 failure)
   ⇒ parseLegacyPrefix 今天就得认五种形态,不是"为第 6 家预留"

③ 结论: relay_meta 方向对(且"不改 TestRelayKindsIsExactlyTwo"的理由强),
   但成本被抬高(6 产生者/4 语言/deploy 脚本与 Go 桥不在常规心智模型里)
   ⇒ 建议**先纯服务端**由显式 5 形态白名单推出 is_failure,并同时记一条计数
     (既非 5 形态又含 failure 字样 ⇒ 新形态会显形而非静默漏判)⇒ 零桥改动拿到可信度+可观测
     relay_meta 留作第二步
2026-09-26 03:01:20 +08:00
30079bf003 docs(debt): ⑤′ 重建的实现约束 —— 静默丢 37 行的可达形态 + 订正 pi 两条理由
① ★★★ 陷阱1/3 成立且可达(我用真表名实测复现):
   重建若写成 DDL 里 4 条**无 BEGIN** 语句(migrate 逐条 Exec ⇒ 各自 auto-commit),
   DROP 后崩 ⇒ 次日启动 CREATE TABLE IF NOT EXISTS 建出空的新 PK 表、守卫见 PK 已新 ⇒ 跳过重建
   ⇒ 实测: agent_platform_sessions 行=0、_new 行=3 ⇒ **判据 (a)(b) 全绿而镜像为 0**
   ⇒ 判据必须加 (c) 断言无 <表>_new 残留(必需,非可选)

② ★★ pi 两条理由需订正(结论对、机制错):
   ① "SQL BEGIN 不生效" ⇒ 我实测 **BEGIN 生效**(BEGIN→DROP→ROLLBACK 表回来了;崩溃后亦完好)
      真因: 本仓 SQLite 走 SetMaxOpenConns(1)(db.go:91)⇒ 池里只有一条连接 ⇒ BEGIN 恰在同连接
      ⇒ 这是巧合不是保证(PG 分支就是 20)⇒ 用 BeginTx 对,但理由应写"正确性依赖 MaxOpenConns(1)"
   ② "实际文本无空格" ⇒ 本仓实际是 PRIMARY KEY (agent_name, platform_id) **有**空格
      失配真因是**它模式里 , 后少了空格** ⇒ 用 pragma 对,但理由应写"LIKE 依赖空白/换行/引号形态"

③ ✅ pi 自撤的"第二次 Migrate 会撞 PK"我也复核为真: 4 语句版天然幂等(run1/2/3 rc=0、行数不变)

④ ✅ 附带事实两条均成立: main.go 两次 Migrate ⇒ 重建必须幂等;
   全仓 REFERENCES agent_platform_sessions = 0 ⇒ 无需处理 FK

⑤ ④ 最终措辞: Go 层事务重建(BeginTx→新表→INSERT SELECT→DROP→RENAME→**同事务内重建索引**)
   ⇒ 与 DDL 批次前后无关 ⇒ 顺序依赖消失(收 pi 这点)
   判据: (a) 迁移路径断言 PK + (b) 断言索引存在 + (c) 断言无 <表>_new 残留

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok
2026-09-26 02:55:04 +08:00
e175fc697d docs(debt): 订正危害机制为"抵达次序/last-writer-holds"(否证"心跳频率不同")+ 补三条语义约束
① ★ 订正流传的因果: "每个 project 心跳频率不同"**不成立**(三种独立观测)
   ① 九种状态的**复现间隔全部 = 30.01s**(= setInterval(beat,30000))
   ② 相位固定: 三轮 30s 窗口的抵达次序与相对偏移逐轮重合
   ③ 驻留时长相差 30 倍(agentmail 10.86s vs facemodule 0.30s)而复现间隔全等
   ⇒ 真机制 = 各上报者周期相同、相位错开、~15s 内挤成一串抵达,之后 ~6-15s 静默
     ⇒ 最后抵达者独占静默间隙(last-writer-holds)
     ⇒ 占比由**抵达次序**决定,不由频率
   ⇒ ★ 危害更重而非更轻: 次序固定 ⇒ **稳定偏置**(长采样候选为 0 = 63.9%),
     不像随机间歇会被平均掉
   附: /tmp/am-mcp-probe 非本次调试产物 — opencode.db 里 23 条 09-19 会话(他人探针遗留)

② 修法 A 必须同时定住三条语义([] 今天把 ① 与 ② 混在一起):
   ① 本目录无会话 ⇒ 允许,[] 只清自己那个 workspace
   ② 本 agent 无会话 ⇒ 今天无任何上报者该有权说
   ③ 看不到(list 失败)⇒ 必须继续"省略该字段",**不得**降级成 []
   ★ ③ 不可省: 一旦改成 [],"一次 list 失败"会把该 workspace 的 37 行清成 0,
     而下游只看"候选少了",看不出那是读取失败

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok
2026-09-26 02:48:56 +08:00
6a8e5dd16d docs(debt): ④ 补两个伴随项 —— 重建表会丢具名索引 + 判据必须长在"迁移路径"上
pi(`b9c7308c`)提出,我逐条独立复现成立:

(i) 重建表丢掉具名索引:
    重建序列 新表→INSERT SELECT→DROP→RENAME ⇒ PK 真换了(功能对)
    但重建前索引 = sqlite_autoindex_aps_1 + idx_platform_sessions_ws
       重建后索引 = sqlite_autoindex_aps_1(**具名的没了**,RENAME 不带回)
    而该索引正是本条基石之一("设计本来就 per-workspace"):
      init_sqlite.sql:424 / init.sql:383  CREATE INDEX IF NOT EXISTS idx_platform_sessions_ws
   补救的现成条件: migrate 每次启动逐条重跑整份 init DDL,该 CREATE INDEX 与建表同批
     ⇒ 重建在这批 DDL **之前** ⇒ 索引当场补回(无窗口)
       重建在这批 DDL **之后** ⇒ 要等**下次启动**(窗口 = 本进程余生)
   我实测两个顺序确认 ⇒ 又一个顺序依赖(与 PK-先于-DELETE 同族)

(ii) 判据必须写成"迁移路径"测试:
    常规测试走 setupTestDB → t.TempDir() + Migrate = **全新建库** ⇒ 断言 PK 必然绿 ⇒ 抓不到生产
    只有「旧库 → Migrate → 断言实际 PK」才抓得到(pi 实测: 旧 PK 库 ⇒ 断言红)
    更便宜的补充: 启动自检(生产启动查 sqlite_master,PK 缺 workspace 即拒启/告警)
      —— 不需要测试基础设施,且生产上会响(测试永远不覆盖已部署库)
   ⇒ ④ 的判据两条并列: (a) 迁移路径断言实际 PK;(b) 断言 idx_platform_sessions_ws 存在

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ ok(余额 27)
2026-09-26 02:21:41 +08:00
2912be45a9 docs(debt): ③ 的消歧键订正为 agent+workspace(收 pi 场景C);新增 ④ 迁移静默失效
① ③ 订正(pi `43d2c9dd` 造场景C 反驳我"只按 agent",实测成立):
     场景A 跨 agent + 不同 ws                            ⇒ 三键皆 1 行 ✓
     场景B 跨 agent + 同  ws                             ⇒ 按 ws 2 行 ✗ / 按 agent 1 行 ✓ / 两把 1 行 ✓
     场景C 同 agent + 同 id + 两 ws(未来态,PK 加 ws 后合法)⇒ 按 agent **2 行** ✗ / 两把 1 行 ✓
   ⇒ 正确键 = aps.agent_name = s.from_agent AND aps.workspace = s.workspace
   且 sessions.workspace 列已存在(sqliteAddColumns 补的); 生产: 有 platform_id 的 9 条
   ⇒ workspace 非空 9/9、与 aps 一致 6/6(另 3 条镜像无此 id)⇒ 不需新加数据
   ★ 我一度想用 ORDER BY (aps.agent_name=s.from_agent) DESC LIMIT 1 替代"谓词入 ON",
     场景E(本侧由 e2 接管、镜像同名 id 只剩 e1 那行)实测取到 e1(错),
     而谓词入 ON 得 NULL ⇒ 退回 from_agent=e2(对,合文档 :388-390)⇒ 我的排序键想法撤回

② ④ 新增(本回合最重的发现): 改 PK 在**已部署库上静默不生效**
   ① init_sqlite.sql:407 / init.sql:371 都是 CREATE TABLE IF NOT EXISTS ⇒ 对已存在的表整条跳过
   ② migrate.go:33-40 每次启动逐条重跑 init DDL(cmd/server/main.go:39/48);
      addMissingColumns(:365) 只补列、不碰约束 ⇒ 补不了 PK
   ③ 实测: 旧 PK 库重跑含新 PK 的 DDL ⇒ rc=0 无报错,sqlite_master 里 PK 仍是旧的,
      再插「同 id 不同 ws」第二行 ⇒ 仍报 1555
   ④ 测试库走 t.TempDir()+Migrate ⇒ 每次全新建表 ⇒ 新 PK 生效 ⇒ 测试全绿;
      且全仓无任何 schema/PK 断言(sqlite_master/table_info grep=0)
   ⇒ 「改完 DDL、测试全绿、生产没变」完全静默:
     DELETE/JOIN 都改对了,但 PK 没变 ⇒ 同 id 跨 ws INSERT 撞 1555 + 无 ON CONFLICT
     + defer tx.Rollback() ⇒ 整个事务回滚 ⇒ 心跳持续成功而镜像永不再更新
     —— 比现在的间歇擦除更糟
   ⇒ 治法: 显式重建表(建新表含新 PK→INSERT SELECT→DROP→RENAME)+ 一条断言实际 PK 的判据

校验: go test ./internal/repo/ -run Debt -count=1 ⇒ PASS(余额 27,新条目渲染正常)
2026-09-26 02:09:21 +08:00
a0f5fab9bd docs(debt): 补记 platform-mirror 那条的第三处必改点 —— :395 的 JOIN 歧义(既存,非 PK 副作用)
pi(`249fe29d`)指出改 PK 的下游影响面,我实测复核并**修正其归因**:

① `platform_sessions.go:393-397` 的
     LEFT JOIN agent_platform_sessions aps ON aps.platform_id = s.platform_id
   + QueryRowContext(...).Scan(...) —— JOIN **不带 agent_name/workspace**,
   而镜像表 PK 是 (agent_name, platform_id) ⇒ 同一 platform_id 挂两个 agent 就有两行。
   实测: opencode 与 homeagent 上报同一 platform_id ⇒ JOIN 出 **2 行**,
   PlatformSessionFor 返回 owner=homeagent 而该会话是 opencode 接管的 ⇒ **取错归属**。

★ 归因修正: 这是**既存缺陷**,**不是**"改 PK 的副作用" ——
   它与 workspace 无关(PK 今天已允许跨 agent 同名),且生产数据里跨 agent 的
   platform_id 交集 = 0 所以未显形(又一个"当前干净是数据性质、非约束")。

② pi 建议的修法「JOIN 加 workspace」**不完整**(实测两场景):
     跨 agent + 不同 workspace ⇒ 1 行 ✓
     跨 agent + **相同** workspace ⇒ **2 行** ✗ 仍歧义

③ 正确消歧键是 **agent 身份**: JOIN ... AND aps.agent_name = s.from_agent ⇒ 1 行 ✓
   而 sessions.from_agent 由 AdoptPlatformSession → CreateSession(ctx, nil, agentName, …)
   写入(repo.go:260 第 2 个形参)⇒ 接管路径结构性非空(生产库: 无空值)。

⇒ 伴随项从「PK + DELETE」扩为三处**必须同批**改;
   漏掉第三处 ⇒ 取错归属 ⇒ platform_session_id 发给非归属方 ⇒ 邮件静默消失
   (比候选少一条更重,消费者 notify/mail.go:94)。

校验: go test ./internal/repo/ -run Debt ⇒ ok
2026-09-26 01:49:25 +08:00
682bcf3d45 docs(debt): 登记「平台镜像的替换域过宽」—— agent 级整表替换 vs per-project 上报者
现象(实测,可复现):
  agent_platform_sessions 在 10 个状态间轮换,每次差的恒为一个 project 的会话数
  (/tmp=49 /root=38 am-mcp-probe=23 agentmail=37 TrueAgent=100 llmsproxy=18
    facemodule=7 Liquid=7 NextAgent=2 空),同状态内 reported_at span = 0.0ms
  ⇒ 每次都是**整表替换**,而每个上报者只知道一个 directory
  ⇒ 后一个把前一个的清单整体擦掉

危害(口径已修正):
  候选 = 来源1(本侧 sessions, 实测 6) + 来源2(该镜像, 实测 37)
  镜像被擦时该 workspace 的候选 ~43 → ~6(掉 37 条),**不是归零**
  (先前写 37→0 是漏了来源1)

★ 为什么记的是「到期前提」而不是「修法」:
  它是**带顺序约束**的: 若只给 DELETE 加 workspace 而 PK 不动,则
  「同一 platform_id 出现在两个 workspace」⇒ UNIQUE constraint failed (1555)
  ⇒ INSERT 无 ON CONFLICT(grep=0) + defer Rollback ⇒ **整个 DELETE 回滚**
  ⇒ agents.go:186 降级 -1、桥不读该字段(grep=0) ⇒ 三重静默
  ⇒ 从「间歇擦除」变成「永不自愈的静默停滞」(更难查)
  故到期前提写死为: **先 PK 加 workspace,再 DELETE 加 workspace**

三个已被推翻的根因(留作反面材料,见 note):
  ① 「读域 vs 擦除域,且 [] 是 truthy」——非主因(80% 是非空替换)
  ② 「擦除在语义上不必要」——错,整表替换有意且有 TestReplacePlatformSessionsIsFullReplace
  ③ 「今天不撞靠 session.id 全局唯一」——因给错了,实测同 id 换 workspace 也不撞,
     真正原因是「全量 DELETE + seen 去重」两处代码结构

校验: go test ./internal/repo/ -run Debt ⇒ PASS(余额 27,新条已计入)
2026-09-26 01:44:26 +08:00
e63a5ba81d docs(debt): 给那条被我误判的"不该写进判据"加**醒目自我取代标记**
`88f4b8a` 已用"三个带"更正了这个判定,但**更正写在后面**,而误判那句留在前面 ——
一个读到那里就停的人(或一个 grep 抓单句的人)只会看到 "**不该写进判据**",
而那正是我这条工作线上反复在防的"**半截更新**"。

原句划掉(`~~…~~`)+ 就地写明"我判错了、已被本节末取代、结论是**顺序约束**"。
⇒ 规矩: **更正一条已写下的判断时,要在被更正的那句旁边留标记** ——
   只在末尾补一段,等于让"错的那句"继续独立生效。

(本节里那句的上下文是 pi `8f6f6e6e` §三;实测见同一节末"三个带"。)

验证: JSON 合法;`TestDebtLedgerMatchesMeasurement`/`TestDebtSummaryReadsAuthoritativeLedger` 通过;
`debt-visibility.test.mjs` 1/1。
2026-09-25 06:57:24 +08:00
88f4b8ab5e fix(debt): 我上一条把 pi 的 hop 建议判成"假问题"——**判过头了**;实测有**三个带**,最坏那个会清零 hop 守卫
★ 更正我自己上一条 `a05509c`(同一天、我写的)。我写:
  "`CountTrailingRelayHops` 是重算 ⇒ '计不计'由有没有落库唯一决定 ⇒ **不该写进判据**"
**那半对,但我漏了两个形**,而漏掉的那个正是最坏的。实测(`/tmp/hop.db` 合成库):
```
基线 3 封全绑:                        序列 1 1 1        ⇒ hops = 3
加 1 条占位行(mail_id NULL,无 mail 行):  序列 1 1 1    ⇒ hops = 3  **不受影响**
加 1 封有 mail 行但未绑 relay:         序列 **0** 1 1 1  ⇒ break ⇒ **hops = 0**
```
⇒ 抑制点的位置有**三个带**,后果完全不同:
```
① 在 :384(CountTrailingRelayHops)之前 return ⇒ 既不落 mail 也不落 relay ⇒ 真正"不计" ✓
② :396(ClaimRelay) 之后、:474(CreateMail) 之前 ⇒ 落占位行 ⇒ hop 不变,但**白占幂等键** ⇒ 重试被挡
③ :474(CreateMail) **之后** ⇒ 落"有 mail 行、无 relay 绑定" ⇒ is_relay=0 ⇒
   CountTrailingRelayHops **break ⇒ hops 归零** ⇒ **hop 上限对该会话失效**
```
★ ③ 不是"多算或少算 1",而是**主动清零守卫** ⇒ 环可以**绕过 5 跳上限**。
   而 ③ 恰是"抑制逻辑写在 CreateMail 之后"这种最自然的写法会落进去的带(那里才拿到 mailID)。

⇒ 所以 pi 的直觉**是对的**,我错在把它当成"可配的口径"而整体否掉。
   正确的结论(取代上一条): 这不是口径,是**必须满足的顺序约束**,而顺序约束**更要**进判据:
```
「抑制必须在 CountTrailingRelayHops 之前 return」——理由: 落在其后会造出"有 mail 行无 relay 绑定"
的邮件,把 hop 守卫清零
判据形状: 构造一封因抑制而不发的报告,断言同会话 CountTrailingRelayHops 不降为 0
         (**能失败** —— 把抑制挪到 CreateMail 之后就红)
```
★ 教训与这条线同形: 我用"重算"这个**机制事实**否掉了一整条建议,而没有把机制在**所有落点**上跑一遍。
   pi 说的是"位置影响读数",我说的是"机制是重算" —— **两句都真,但我的那句不能推出"所以不必写"**。

验证: `TestDebtLedgerMatchesMeasurement`/`TestDebtSummaryReadsAuthoritativeLedger` 通过;
`debt-visibility.test.mjs` 1/1。
2026-09-25 06:56:29 +08:00
a05509c51c docs(debt): 补记判据⑥(permission 分叉点)+ 判据清单落定 + 否掉一条落点细节
pi `8f6f6e6e` 提的三件事,我 `476d22ad` 已逐条答过;本条把**判据清单落定**进登记
(此前只活在邮件里 —— 与上一条同样的问题)。

## ⑥ permission 分叉点,必须钉住

```
pi 的判据: 「本封来信 id ∈ relayed_mails」  ⇒ 抑制
我的判据: 「parent 本身是 failure-relay」   ⇒ 抑制
构造: 一封 permission 询问(kind=permission,也是 relay)发出 → 对方处理失败 → 发失败报告
  pi  : 来信(permission) ∈ relayed_mails 为真 ⇒ **抑制**(错: 首报该发出去)
  我  : parent 不是 failure 类             ⇒ **放行** ✓
```
**可达性我验了**(这是它必须钉住的原因):
```
permission-relay 的子邮件 = **137**(mail_type: normal 58 / permission_decision 79)
  ⇒ 子邮件确实会被产生 ⇒ 那一侧处理失败就会产出失败报告 ⇒ 分叉点可达
当前: failure 报告的 parent 是 permission-relay 的 = **0** ⇒ 今天还没分叉
  ⇒ 而这正是"两条判据现在等价(都 10 / 差集 0)"的来源
```
★ 所以"等价"是**当前数据的性质,不是机制的保证**。选判据的理由不能是"它们现在等价",
而是**我的判据把"是不是失败类"读了出来**,pi 的没有 —— 信息量更大的一点更耐久。

(判据清单 ①–⑥ 一并落定;⑥ 需要失败才能构造。)

## ⚠ 同时否掉一条落点细节(免得下一个人重走)

pi 建议"插在 `:384` 前 ⇒ 被抑制者不消耗 hop,这个口径要写进判据"。
我读实现后判它为**假问题**: `CountTrailingRelayHops`(`relayhops.go:54-80`)是**重算**
(每次 `mails LEFT JOIN relayed_mails` 现扫),不是自增计数器 ⇒ "计不计"由"有没有落库"
唯一决定,**不是口径选项** ⇒ **不该写进判据**(写进去会让人以为可配)。
唯一相关的事实(与 hop 无关): 抑制点必须在 `ClaimRelay`(`mail.go:397`)**之前** return,
否则白占一个幂等键、把后来的重试也挡掉。

验证: `TestDebtLedgerMatchesMeasurement`/`TestDebtSummaryReadsAuthoritativeLedger` 通过;
`debt-visibility.test.mjs` 1/1。
2026-09-25 06:55:13 +08:00
efa07126c4 fix(debt): 更正我上一条自己写错的计数 —— **8 条语句 / 5 个文件**,不是"7 处 / 5 个包"
★ 这是我上一条 `3f394c8`(同一天、我写的)里的一处**未经核对的数**,而我刚刚还在
另一封里要求 pi 的规则"数必须有精确定义"。自证一下:

```
我写的           : 7 处 / 5 个包 / 3 种语言
按"生成 relay_key 的语句"逐条数:
  plugins/dsh-mail-bridge/src/index.ts:1240   model-failure:
  plugins/dsh-mail-bridge/src/index.ts:1749   empty-reply:
  plugins/pi-mail-bridge/src/worker.mjs:625   model-failure:
  plugins/pi-mail-bridge/src/worker.mjs:715   model-failure:
  plugins/zcode-mail-bridge/src/index.mjs:304 zcode-failure:
  plugins/homeagent-mail-bridge/plugin.go:929 homeagent:failure:
  deploy/service-failure-notify.mjs:92        service-failure:(有 INVOCATION_ID)
  deploy/service-failure-notify.mjs:94        service-failure:(退化为 sha256)
  ⇒ **8 条**(不是 7)
按文件去重: dsh / pi / zcode / homeagent / deploy = **5 个**("包"→"文件"更准)
```

**错在哪**: 我先写了"7",然后把它当成已知去查证 —— 而**没有回头数一遍**。
`service-failure-notify.mjs` 有**两处**(`INVOCATION_ID` 分支 + sha256 分支),
我数成了 1。⇒ 这正是"**报数必须给数据源/口径**"那条:口径写了("产生点"),
但**没真按口径数**。

★ 讽刺的是这条修正**不改变任何结论**(8 vs 7 都是"散在多个包、无载体"),
但它必须改 —— 否则下一个引用它的人会带着一个错的数往下走,而**错数比错结论更难发现**
(结论有争论,数看着就像核过的)。这正是本会话反复吃的那族形状。

验证: `TestDebtLedgerMatchesMeasurement`/`TestDebtSummaryReadsAuthoritativeLedger` 通过。
2026-09-25 06:53:35 +08:00
3f394c83c7 docs(debt): 追加「失败报告抑制」的**前置**问题 —— 谓词"是不是失败报告"没有载体
pi `334710a1` 的 A/B 落点分析(网关侧抑制 vs 桥侧省调用)**都**要先回答
「parent 是不是失败报告」。我按实产查了它的全部产生点 —— 它没有载体。

## 实测:7 个产生点 / 5 个包 / 3 种语言,写法互不相同

```
dsh-mail-bridge/src/index.ts:1240   model-failure:${data.mail_id}
dsh-mail-bridge/src/index.ts:1749   **empty-reply**:${mailSessionID}
pi-mail-bridge/src/worker.mjs:625   model-failure:${...}
pi-mail-bridge/src/worker.mjs:715   model-failure:${...}
zcode-mail-bridge/src/index.mjs:304 zcode-failure:${...}
homeagent-mail-bridge/plugin.go:929 "homeagent:failure:"+replyTo          ← Go
deploy/service-failure-notify.mjs   service-failure:${INVOCATION_ID|sha256} ← systemd 脚本
```
网关侧一无所知:这几家前缀 + empty-reply 在 `server/**/*.go` 里 **0** 处引用。

★ "用现成的 `RelayKeyForMail` 判一下"只对一半 —— 它返回 `(relay_key, kind)`,而
**`kind` 区分不了失败**:`kind='summary'` 里 failure 98 / 非 failure **321** ⇒
用 kind 判会连 321 行普通搬运一起豁免,**正是已否掉的修法①**。

★★ 活反例:`empty-reply:` 是失败类但**不含 `failure` 字样** ⇒ `LIKE '%failure%'`
永远抓不到它(代码 1 处、当前 **0 行** ⇒ **将来第一次触发就是静默漏判**)。

⇒ 推论: 在网关硬编码这四家前缀 ⇒ 第 6 家桥出现时**静默漏判** ⇒ 报告不再被抑制 ⇒
环回来(假绿)。所以修法应**先把分类变成网关拥有的东西**(枚举第三类 / 或 relayed_mails
加一列),再由各桥**声明**而非拼串。

⚠ 加枚举会撞上一条**故意**的锁: `TestRelayKindsIsExactlyTwo`(`relay_test.go:60-64`)
"免配额类型是白名单…新增前请确认它确实是 harness 代劳" ⇒ 必须同步改它 ——
而那正是它本来就该问的问题。

验证: `TestDebtLedgerMatchesMeasurement`/`TestDebtSummaryReadsAuthoritativeLedger` 通过;
`debt-visibility.test.mjs` 1/1。
2026-09-25 06:52:53 +08:00
5b06f7cbec docs(debt): 登记「并列失败不得被合并」—— pi a948cdbb 的判据⑤(此前只活在邮件里)
pi 提了一条判据⑤:**同一父信下的两封并列失败报告不得被合并**。我上封(`a792717a`)
已认它该进判据,但**仓库里没有任何地方记着它** —— 只留在邮件里。

## 为什么它必须进登记

```
B 口径(agent, failure-父链根)今天: 98 封 → 92 组、抑制 6
  组内「两个成员共享同一父」的组数 = **0**(我逐组实测;5 个多成员组全是真链式)
⇒ 今天确实不会误合并 —— 但这是**当前数据的性质,不是设计的保证**
```
只要出现「同一封来信被两个不同 agent 各回一封失败报告」,root-keying 就会合并它们,
而那些是**内容各异的并列失败**。★ 这形状**本系统真实发生过**(我复算确认):
```
d042cc4c: 22 封失败报告、distinct parent = **22**、其中 parent 本身是失败报告的 = **1**
          ⇒ 21 封并列;(agent, 线程根) 口径下塌成 4 组(9/7/5/1)⇒ 一次丢 **18**
```
⇒ 「同级并列」不是边角情况,所以判据要钉的是**机制**、不是"当下恰好成立"。

## 为什么现在建不了

抑制机制**尚未实现**(`grep -c 'suppress|抑制' server/**/*.go` = **0**)⇒ 这条判据此刻
**无对象可测**。所以登记为欠账,到期条件写成 **"失败报告抑制机制落地时"**,
并列出已否掉的修法(①跳过 kind=summary 会豁免它要拦的那类;②仅根 root-keying 正是本条要防的),
免得接手的人重走。

★ 这条的处境正是本会话那个结论的又一例:**一个没有执行者的结论会一直"在讨论"**——
写进登记 + 到期条件,才会让接手的人**必须**遇到它。

验证: `TestDebtLedgerMatchesMeasurement` / `TestDebtSummaryReadsAuthoritativeLedger` 通过;
`debt-visibility.test.mjs` 1/1 通过。
2026-09-25 06:42:32 +08:00
3c2b7d1385 fix(deploy): 还清欠账 redeploy-script-unguarded-steps —— 四类裸步骤接收退出码
出口是这条欠账**自己写的**到期条件("下一次改 `deploy/` 下任一脚本时"):
当天因修 `--dry-run` 落地写入而动了这个脚本,所以一并还。

## ① 前端同步三步

`rm -rf assets` / `rm -f index.html` / `cp -r dist` 原先调用点不接退出码,
而 `run()` 内部是 `eval`(脚本只有 `set -uo pipefail`,无 `-e`)⇒ 失败既不中断也不上报。
`cp` 那条尤其要紧:前端产物没拷进去 ⇒ go:embed 把**旧界面**打进二进制,而所有单测仍绿
(2026-09-14 踩过,见脚本内那段注释)。三处都改成 `|| { bad …; exit 2; }`。

## ② `systemctl stop`(本条欠账原文点名的"stop 失败而状态没人看")

加它的理由比原文**更强一层**:`systemctl start` 对**已在运行**的服务是 **no-op** ⇒
stop 没成功时后面那句 start 什么也不做,**旧进程继续跑旧代码**,而脚本一路走到
后置验证、报"部署成功"。也就是说,"部署脚本跑过了 ≠ 线上跑的是当前代码"这个第 2 类漂移
会被脚本**自己在内部**造出来 —— 与今天线上那件事(09-19 二进制跑了 6 天)同形,只是成因在脚本内。

## ③④ DRY_RUN 分支

am-sandbox 的 `go build`/`install`、以及 `install -d $PREFIX/bin` 与通知脚本 `install`
一并挪进 DRY_RUN 分支(上一提交 7a65b27 只修了后者的"会落地",这里补齐构建/安装两步)。

## 边界(如实记,避免读成"全脚本已无裸步骤")

只覆盖 `redeploy-gateway.sh`。`redeploy-plugin.sh` / `install.sh` 的同类步骤仍未加守卫 ——
那是另一条欠账 `deploy-interrupt-trap-other-scripts` 的范围。

## 验证

`bash -n` 通过;`--dry-run` rc=0 且不落地(只留 0 字节锁);
Go 侧 `TestDebtLedgerMatchesMeasurement` / `TestDebtSummaryReadsAuthoritativeLedger` 通过;
`node test/debt-visibility.test.mjs` 1/1 通过。
2026-09-25 06:22:09 +08:00
65de1c3884 跨端对齐:授权栏 navigator_only + 组件按页拆分 + 服务器补 permission_options
用户两项裁定落地(均为 ask_user 明确选择):

① 授权栏口径 = navigator_only(照 WebUI 架构)
   · 新建 pages/PermissionPanel.ets —— 详情页的决策面板,
     对应 MailView.tsx:693 的 PermissionPanel(审批型 / 主动提问 / 已处理 三态)
   · 决策入口从授权栏移到 MailDetailPage;MailDetailPage 原来只显示一个
     「权限请求」小标签、根本没有决策入口(比 WebUI 少一整块,且反了:
     栏里能决策、点进详情反而不能)
   · PermissionTab 删掉内联「同意/拒绝」+ 备注框 + decide():
     整卡可点 → onOpenMail(对齐 WebUI PermissionList.tsx:81 的 pick())
   · PermissionRequest 补 source_account_id(客户端侧记来源,跳详情要定位网关)

② 服务器补 permission_options —— 修一条真实的、跨端共有的缺口
   · mails.permission_options 从 INSERT 起就写进去,但**从来没有任何读路径
     选过它** ⇒ 详情端点永远返回空。WebUI 的决策面板读 mail.permission_options,
     所以提问型的预设选项**两端全部落空**(审批型靠 ['同意','拒绝'] 兜底蒙混)
   · GetMailByID 补选该列 + JSON 反序列化(与 cc_list 同款)

③ 组件按页封装(用户要求「以便与 WebUI 一一对应」)
   MainPage.ets 4592 → 3192 行
   · pages/PermissionTab.ets    720 行  ↔ PermissionList.tsx
   · pages/ContactsTab.ets      796 行  ↔ ContactPanel.tsx
   · pages/NavDestinations.ets  181 行  ↔ Navigation 壳
   · pages/NavShared.ets         65 行  ↔ 跨栏共用件

④ 判据跟着组件搬家(否则静默失效,不是红)
   harmony-logic 的 pageCode / harmony-nav 的 navSrc 改为显式文件名单;
   harmony-appearance 的 PANE_SOURCES 补 ContactsTab;harmony-contacts 三个
   test 并入 ContactsTab;harmony-logic 的决策断言改指 PermissionPanel,
   并新增「授权栏不许再有内联决策」两条(navigator_only 的正形状)。

   animation-audit:共享元素转场判据从「同文件共址」改为「按 id 找驱动」。
   旧形状把 in/out 端必须在同一文件当成代理,而两端**天然在两处**;
   抽出写信页(NavDestinations 持有 in 端)后误报。新判据仍要求每个 id
   都有 Motion.morph 驱动 —— 变异实测:把驱动换成裸 animateTo 仍判红。

判据:files=34 checks=556 red=1(仅 build-stamp,产物待重构建)
2026-09-24 10:10:32 +08:00
006f813066 docs: 登记 3 条口头承诺过的未验项(此前只存在于对话里)
用户问「全部完成了是吧」时我核对了一遍,发现上一轮我**只在回复里**说过
「这项没验」,而它们**没有进任何登记文件**。

这正是本仓反复消的形状:**「说过」不等于「记着」** ——
对话一结束/一压缩,那三句就没了,而登记文件才是能活下来的地方。
(`DEBTS.json` 自己的注释就写着这条纪律:理由不能只存在于某个人当时的记忆里。)

补登记 3 条(都是 `kind: env`,即"本机能做但当前环境验不了"):

1. `harmony-morph-unverified-middleframes`
   两处共享元素转场的**中间帧**没看到 —— `snapshot_display` 往返 1.5-3s,
   比 220ms 的动画慢一个数量级。结构/接线有判据钉着(三条变异验过会红),
   但"真的在动"只能真机看。附了本仓那条教训(探针不敏感时量的是噪声)。

2. `harmony-account-errors-banner-unverified`
   聚合失败横幅只验了「不出现」那一半。这条横幅**全部价值就在它出现的那一次**,
   所以"逻辑对齐 + 编译通过"不能算验过。

3. `harmony-permission-history-render-unverified`
   「历史 n 条」的分组是纯函数、有跨端判据,但**渲染那层**没验
   (本机 `permission_request` 0 封)。附了复现路径。

★ 顺带说明为什么这次要写进文件而不是再回一句:
  前两条我自己都**明确说过"不声称已验"**,但两次都只是消息。
  第三条更是只在 commit message 里提过。三者有一个共同点 ——
  **它们都是"我以为说过了"就够了的**,而实际不会有人回头翻聊天记录。
2026-09-21 16:48:44 +08:00
d857352e29 跨端: 闭合 harmony-permission-history —— 授权栏补上「已决策的历史」
这是 `docs/DEBTS.json` 里登记的一条,它的到期条件原文是
「做『授权栏与 WebUI 对齐』时」—— 就是现在这一轮。

## 原缺口

鸿蒙的 `PermissionTab` 只调 `GET /permission/pending`(服务端
`ListPendingPermissionsFor`,SQL 带 `WHERE pr.result IS NULL`)
⇒ **只拿得到待决的**,于是"这条会话批过哪些事"完全看不到;
而 WebUI 有(`PermissionList.tsx:182` 的「历史 {n}」)。

## 关键判断:**不照抄 WebUI 的 inbox 分组**

我先把 `PermissionTab.load` 整个改成读 inbox + `groupPermissions`,
**改到一半发现行不通**(编译报 `question`/`context`/`agent_name` 找不到):

· WebUI 从 inbox 分组,但它的 `PermissionRow` **只渲染**
  `subject`/`created_at`/`permission_result`/`permission_expires_at`
  (逐字段 grep 过,全文件没有 `question`/`options`/`context`);
· 而**待决**那一段我们要显示 `question`/`options`/`context`/`kind`
  —— 那四个字段在 `permission_requests` **表**里,
  inbox 回包(`models.Mail`)**没有它们**(模型逐条核过,只有 `permission_result`
  与 `permission_options`)。

⇒ 两条来源各有各的信息量,不是二选一:
  · **待决**继续走专用端点(信息更全、能直接决策);
  · **历史**走 inbox 补上。
  代价是每账号多一次请求 —— 这是**有意的取舍**,写在代码注释里。

(半成品已 `git checkout` 撤掉,没有把它留在提交里。
 撤掉的原因如实记在注释里,免得下一个人以为"照着 WebUI 改"就行。)

## 落地

· `model/MailGrouping.ts`:加 `groupPermissions` + `PermissionGroup` +
  `isPendingPermission`,逐条对齐 WebUI 的 `groupPermissions`,
  含它那**三步排序**(有待决的先来 → 待决多的更靠前 → 最新一封倒序)。
· `PermissionTab`:从 inbox 取 `mail_type=permission_request &&
  permission_result != ''` 的,分组后渲染「历史 n 条」(只读、不可操作)。
· 空态判据从 `requests.length === 0` 改成**两者都空**才显示 ——
  否则"有待决的历史"会被误报成"没有待决策的请求"。

## 判据自己抓到了我

`cross-client-logic.test.mjs` 的「缺口只减不增」在我补上 `groupPermissions`
之后立刻变红,并给出准确指引:

    减少(harmony 补上了功能)→ 请把 gaps 里对应的名字删掉

⇒ 已清空 `gaps`。**这条判据在这轮里三次发挥作用**:
  第一次报出这个缺口(09-20),第二次在我半成品时红了,
  第三次确认闭合。`pass=7 fail=0`。

`docs/DEBTS.json` 的 `count` 已改 0、`due` 记完成、`note` 写明修法与取舍。

## 设备验证(如实)

✓ 授权页正常渲染,进程存活(23343),无新 jscrash
✓ 空态文案正确(本机确实没有权限邮件)
✗ **"历史 n 条"真的显示出来**这条路径没能实测:
  本机没有已决策的权限请求(服务端实测 `permission_request` 0 封)。
  逻辑逐条对齐 WebUI、编译通过,但我不声称已看到它渲染。
2026-09-21 16:45:23 +08:00
5e4a1b616c 跨端: B 的交付物(跨端纯逻辑一致性判据)+ 照 skill 回扫修掉两处隐形债
用户:「你为什么不加载鸿蒙开发相关skill?」—— 说得对。那份
`arkts-grammar-standards` 写着 "REQUIRED before writing the first .ets file of a
session",而我这轮一直在写 `.ets`。补加载后照它的规则表**逐条回扫**,
当场抓出两处此前没人管的违规。

══ ① 用户要做的 B:`cross-client-logic.test.mjs`(新,6 条判据)

背景:两套纯逻辑各写一份且已分叉(replyTarget 214/170 行、mailGroups 178/459、
appearance 187/324、calendar 208/446)。当天已**踩到**两处分叉
(`participantAddress` 的 `||`、`ThreadPage` 字段全错)。

做法:**同一张用例表喂给两边,逐条比结果**(`--experimental-strip-types`
直接在 node 里跑两侧源码 —— 两边的 model 层都是纯逻辑、无 SDK 依赖)。
不选"生成一份共享源码":harmony 不能 import 工程外文件,
且两边类型系统不同(ArkTS 禁解构/any/对象字面量要具名类型),
生成器要维护"两边都能过"的子集,是另一个大工程。

★ **首轮运行就报出两处真分叉,都不是我踩到才发现**:
  ① `formatAddress('dsh', undefined, undefined)`:electron 返回 `"dsh"`,
     harmony **抛** `Cannot read properties of undefined`。
     —— 又是 `omitempty` 那个坑(**第三次**),这次是判据先报的。
  ② `monthGrid`:electron **固定 6 行**(`grid-rows-6`),harmony **4~6 行**
     ⇒ 翻月时网格高度跳动。WebUI 的注释明写要避免这个("行数变化会让整个
     网格高度跳动,翻月时页面内容上下弹")。
  ③ 顺着 ② 又发现:WebUI 邻月格子**填真实日期并置灰、可点**
     (`CalendarView.tsx:545-556`),harmony 留**空白格**。

★ 判据自身的两次错,都留了档(判据的 bug 与代码的 bug 一样危险):
  · 第一版把 `args[0]` 当单个参数传,字符串被当可迭代对象展开 ⇒
    `formatAddress('d','s','h')` —— **判据自己造出假分叉**。
  · 第一版 `weekStart` 传 0(周日),而两端实际都是 1(周一)⇒ 又一处假分叉。
    差一点就去"修"一个不存在的问题。
  · `monthGrid` 的投影第一版按 `inMonth ? [y,m,day] : null`,
    把"邻月填不填真日期"这个**真分叉**抹平了 —— 投影只该换表示,不该替我看不看。

★ 三类"不同"要分清(写进文件头):**命名不同**(投影归一,不是分叉)、
  **签名不同**(ArkTS 没 Date 重载习惯;语义必须一样)、**行为不同**(是分叉,以 electron 为准)。

══ ② skill 回扫抓出的两处隐形债(编译器只告警、判据也不管)

· **正则字面量**(`arkts-no-regexp-literals`):`MailDetailPage.ets:615` 的
  `/^\d+$/`(从 2026-09-19 活到今天)。
· **废弃的全局 `router`**:`api/Logout.ets:76` 的 `router.replaceUrl(...)`。
  它是个独立函数(没有 `this`)⇒ 拿不到 `UIContext`,改成由调用方传
  (两个调用点都持有 `getUIContext()`,零成本)。

★ 这两条为什么能活这么久:**编译器对它们只告警、不挡构建**,
  全仓也**没有判据**管 ⇒ 规则事实上不存在。已补两条判据,都做了变异验证。

══ ③ 顺带修正一条**恒真的同义反复**断言

`harmony-calendar` 里 "today 不在本月:不许标在别的月" 那条:
它是在"邻月格子是空 `DayCell`(`iso` 为空串)"时写的 ⇒ `c.iso === today`
**永远不可能**匹配 ⇒ `count === 0` 恒真,**看起来守着一条规则,其实什么都没守**。
改成真不变量:**"被标为今天的那一格,iso 必须就是 today;至多一格"**,
并反向核对"2026-10-01 确实出现在 9 月网格里"(否则那段是空转)。
实测 WebUI `CalendarView.tsx:547` 是逐格 `isSameDay` ⇒ **它会标**,
所以原来那条"不许标"本身就窄了一半。

══ ④ 登记两处盘点发现(**没有**顺手改,因为需要人决定)

· `harmony-dead-pages`:`InboxPage.ets`(238 行) 不可达(不在页面表、无人导航),
  `SessionsPage.ets`(170 行) 唯一引用来自 InboxPage ⇒ 一起不可达。
  没删是因为 `HARMONY-ALIGN-PLAN.md:214` 把它当变异测试靶子用过 ——
  删掉会永久丢代码,是否只是"早期留存"我判断不了。
· `harmony-permission-history`:WebUI 授权栏显示**待决 + 已决策历史**两段
  (拿 inbox 自己分组,`PermissionList.tsx:27/174/182`);鸿蒙调专用端点
  `/permission/pending`(SQL `WHERE pr.result IS NULL`)⇒ **只拿得到待决的**。
  已在 `cross-client-logic` 的 gaps 里如实登记,判据会盯着"不要再少"。

══ 判据状态

`files=33 ran=33 checks=530 pass=530 fail=0 skip=0 red=0 broken=0 unreported=0`;
`baseline=7/7✓`(底本第 9 次重算,已按规矩先 `git diff --quiet HEAD` 取证 + 记录理由)。
`verdict=red` 残余仍是 5 条静态判据的**设备到期提示**(既有机制)。

══ 环境

模拟器昨天起卡死(hdc 能连、shell 超时、CPU 150%、跑了 34 小时),
导致设备判据各跑 836 秒后失败 —— 看起来像"套件卡死"。用户批准后杀掉重启
(`Emulator -start HATriple -noWindow`,`devecocli` 那套因 x11 起不来),
现在**75~150 秒**跑完整套。
2026-09-20 22:35:35 +08:00
25e7d8f3bf 跨端: 补附件区(两端一直都有这个功能,我上次误判成"死代码")+ 修两个真 bug
══ ① 更正我 2026-09-19 的一个**错误结论**(已写进 docs/DEBTS.json 留档)

那天我审计后写下:`GET /me/mail/inbox` 的回包**既没有 `attachments` 也没有
`has_attachments`** ⇒ `MailList.tsx:261` 的 `mail.attachments?.length ?? 0`
恒为 0、WebUI 那个 📎 是**死代码**。并据此在鸿蒙侧**有意不抄**这个标记。

**这个结论是错的**,错在取证方法:我**只看了一封没有附件的邮件**,
看到 key 不在,就断言服务端从不返回它。事实:
· 服务端 `GetInbox`(`mail.go:586`)**明确调了 `fillAttachments`**,注释还写着
  理由:「Agent 靠收件箱列表得知有哪些附件可下载,否则它不知道该调 attachment_id」
· `Mail.Attachments` 的 tag 带 **`omitempty`** ⇒ **没有附件的邮件根本不输出这个 key**

实测 `limit=200`(96 封):带 `attachments` 的 **2 封**,正是真有附件那两封。

★ 教训:**`omitempty` 字段的"缺失"不等于"服务端不返回"**。
  判「某字段有没有」必须拿**确实有值的那条**去验,而不是拿一条恰好为空的数据。
  这与同一天那个白屏崩溃(`session_workspace` 缺失 → `undefined` → 抛)
  是**同一个坑的两面** —— 那天是"缺失 → 客户端崩",今天是"缺失 → 我误判成不返回"。

══ ② 补附件区(鸿蒙原来完全没有)

· `model/Attachment.ts`(新)—— `formatSize` / `attachmentLabel`,纯逻辑无 SDK 依赖,
  逐字对齐 WebUI `api/client.ts:390`(三档 + 保留一位小数)。
· `MailApi.downloadAttachment` —— 走 `getBytes`(不是 `get<T>`:后者假定 JSON,
  取二进制会炸;壁纸当初踩过)。
· `IcsFile.saveBinaryFile` —— 与既有 `saveIcsText` 同一套流程,只是写 `ArrayBuffer`。
· `MailDetailPage` 正文之后渲染附件清单(回形针 + 文件名 + 大小 + 下载),
  位置/形态对齐 WebUI `Attachments.tsx`(无附件时**整块不渲染**)。
· 列表行的 📎 + 数字(`attach_count`,与 `cc_count` 同形状派生)。

══ ③ 顺带撞出并修掉两个**真 bug**

**bug A(差一点就是 94/96 必崩)**:我第一版写 `mail.attachments.length` ——
而 `Attachments` 带 `omitempty`,96 封里只有 2 封有这个 key ⇒ 其余 94 封是
`undefined` ⇒ `.length` 抛。**与当天早些时候那个白屏崩溃是同一个坑,
我刚修过、还在 `Models.ets` 里写了一大段注释,然后加新字段时照踩。**
⇒ 说明"记住别这么写"不管用,要在每个真正读的地方把 `?? []` 写出来。

**bug B(潜在白屏)**:`PermissionTab` 读 `req.session_alias` ——
而服务端 `PermissionRequest` struct **根本没有这个字段**(`models.go:239-255`),
`ListPendingPermissionsFor` 的 SELECT 也没查它,WebUI 的类型里同样没有。
它是我照"授权卡总得显示会话名"的直觉加出来的。⇒ 恒 `undefined`,
一旦有待办就抛。**一直没暴露只因为当前待办数一直是 0**(实测 `{"requests":[]}`)。
⇒ 改成服务端确实有的 `agent_name`,并**删掉那个字段声明**:
让误用变成**编译错**,而不是运行时白屏。

同样是 `body_preview`(`omitempty`,值是 `Body` 的截断)——
空正文 ⇒ 空串 ⇒ 服务端省略 key ⇒ `undefined.length` 抛。那批 96 封恰好都有正文,
所以"看起来没问题"——那正是这个坑的形态。已加 `?? ''`。

══ ④ 新判据:`omitempty` 字段的读法(形状,不是实例)

从 `server/internal/models` **算出**"只以 omitempty 形式出现过"的字段名
(不在判据里手抄名单),再扫鸿蒙侧对它们的裸成员调用。
★ 关键:**不能按字段名一刀切** —— 我第一版就是这么写的,报了 6 处、4 处误报:
  `session_alias` 在服务端有**两个**声明(`Mail` 上带 omitempty、`repo.Contact` 上不带),
  鸿蒙那 4 处读的全是 `Contact` ⇒ 恒有值、不是 bug。
  ⇒ 只扫"从未不带 omitempty 出现过"的名字,那 4 处自动排除。
已逐个核实 4 处豁免(每条都写了取证理由,不是"看着像就放过")。
变异验证:把 `?? []` 去掉 → 判据转红,且**正是**报 `MailStore.ets: mail.attach_count = mail.attachments.length`。

══ ⑤ 数据路径已实测(模拟器)

临时把 `INBOX_PAGE_SIZE` 提到 200(因为有附件那两封在下标 51/52,
默认 limit=50 **根本取不到** —— 这也解释了为什么之前一直没发现),
加临时 hilog 后拿到:
    AttProbe: mail=531a1629-… attach=1
    AttProbe: mail=b68cbbe8-… attach=1
正好是那两封。验完已撤掉探针、`INBOX_PAGE_SIZE` 恢复 50。

══ ⚠️ 本轮**未能**完成设备端视觉验收

模拟器已卡死(`hdc` 能连上但 `shell` 超时;进程 152% CPU、已跑 32 小时),
导致套件里的设备判据各跑 836 秒后失败("要能拉起应用")。
主机可用内存只剩 ~3.7GB。附件区的**渲染**(清单外观、下载落盘)
尚未在设备上看过 —— 待模拟器恢复后补。
2026-09-20 21:14:20 +08:00
21132647bc 跨端: 12 处「深色下字看不见」的真 bug + 判据基建补上「看像素」这一层
## 一、判据基建:本目录终于能**看像素**了

此前只能靠 `dumpLayout` —— 那是**结构化描述**,报的是"组件声明了什么",
不是"屏幕上画成什么样"。两者会分叉,而观感类结论只能在像素上得出来。

新增 `lib/harmony-device.mjs`:`screenshot()` / `pixelAt()` /
`hexToRgb()` / `closeColor()`(用 ffmpeg 转 1×1 原始 RGB,不引依赖)。

**它当场证明了它的价值**:`cross-client-theme` 新增的设备判据
用真实像素抓到下面这个 bug —— 静态判据全绿时它藏得好好的。

## 二、真 bug:**12 处**把 `Theme.surface` 当前景色用

`Theme.surface` 是 `sys.color.ohos_id_color_list_card_bg` ——
一个**跟随系统主题翻转**的 Resource:浅色近白、**深色近黑**。

- 浅色下当白字用**碰巧对**(白字压蓝底)
- **深色下字变成黑的**,压在品牌蓝 / danger 红 / warn 琥珀上**几乎看不见**

设备现场:写邮件悬浮球是品牌蓝 `#2563EB`,截图里那个铅笔图标**几乎是隐形的**;
读圆心像素得到 `rgb(32,34,36)`。往左偏 50px 读到底色才见 `rgb(36,99,235)`。

`Theme.accentFg`(`#FFFFFF`)的注释原话就是「品牌底上的文字」—— 为这个场景存在,
却**一处都没用**。

修:12 处 `fontColor/iconColor(Theme.surface)` → `Theme.accentFg`
(`MainPage` 10 + `InboxPage` 1 + `SessionsPage` 1)。改完全仓 0 处残留。
另在 `Theme.ets` 给 `surface` / `accentFg` 都补上"能当什么、不能当什么"的注释。

## 三、判据(两条,都做了变异验证)

1. **设备条**(`cross-client-theme`):读悬浮球像素 ——
   ① 品牌色**真的画成** `#2563EB`(声明 ≠ 渲染);
   ② 球上图标与底色 **WCAG 对比度 ≥3:1**(压在上面的东西得看得见)。
   把 `.accentFg` 改回 `.surface` ⇒ **判红**;还原 ⇒ 绿。
2. **静态防线**(同文件):全局 grep「`fontColor/iconColor(Theme.surface)`」一处不许有。
   设备条只能看一处,而这个错法有 12 处 —— 静态防线管住整类。

## 四、判据自身踩的三个坑(都写进注释了)

- **采样点撞上图标**:第一版取球心,读到 `rgb(32,34,36)`,差点当成"品牌色没渲染"。
  截图一看球是蓝的,深色那点是**铅笔图标**。⇒ 往中心左偏 30% 球宽。
- **假设错了 FAB 的位置**:按"屏幕右下角"找(`x1 > 屏宽*0.6`),
  实测 `[942,1997]`(`x1=942` vs 阈值 1910)⇒ 永远找不到、**静默跳过**。
  原因是列表窗格是**左栏**,球在"左栏的右下角"。⇒ 形状只用站得住的那部分(下半部)。
- **设备判据要自己搭现场**:不加自导航时它**永远跳过**(前面的判据把前台留在管理页),
  而那看起来像"功能没了"。加自导航后立刻开始工作并抓到 bug。

## 五、欠账

- `harmony-maildetail-missing-three` → **count 0(结算)**:三块都做完了
  (转发 `b7c5d8b` / 改名建议 `c2f35d1`+`e79a86a` / 往返预算 `ac62daf`)。
  如实记着**未验**的那点:预算条的**点击**没在设备上走通
  (模拟器顶部 155px 是系统手势区,折叠头部恰在其中)。
- `static-criteria` 5:`cross-client-theme` **升级了一半**,仍留在名单里 ——
  `.ets` 那半只有悬浮球这一处上了设备,其余令牌仍是静态对齐。
- `debt-visibility` 登记 `cross-client-theme` 1 处边界声明(带出处)。

`run-all.mjs` → `checks=513 pass=513 fail=0 skip=0 red=0 broken=0 unreported=0`;
Go 侧 `./internal/repo/...` 通过。
2026-09-19 19:29:49 +08:00
1bf687f506 判据: 图片上传链的设备判据(用真实素材)+ 静态欠账从 6 减到 5
继续升级到期的静态判据。这一批做 `harmony-imageprep`(上传链),
并顺手把已完成的 `harmony-admin` 移出欠账名单。

## 一、图片上传链:用**真实素材**验压缩决策的输入

`run-all.mjs` 的 `STATIC_ONLY` 里那条登记写着
「上传链的设备侧:`@ohos.multimedia.image` + 相册要设备才能真跑」。
上面 30 条判的都是 `model/ImagePrep.ts` 的纯逻辑(阈值、单调性、边界…),
它们全绿时有一件事从未验过:**那些数字与设备上真实的图片对得上吗**。

新判据用**用户真上传过的那张壁纸**(`GET /me/appearance/image` 取回,
1402×1122 / 152570 字节 —— 不是合成图),断三个跨端事实:
① 设备上读到的像素尺寸 = 决策时用的尺寸;
② 真实体积在客户端上限之内;③ 该尺寸走 `planCompress` 首档**不缩小**
(长边 1402 < 2560)+ `judgePick` 放行。

★ **为什么不合成图**:纯色能压到几 KB、噪声几乎压不动,用它们验阈值
会得到"怎么都对"的假绿。真实照片的行为才是要验的那个。

★ **诚实标注了没做的那一半**(写在判据注释与 `DEBTS.json` 里):
本判据用的是**设备上的 `file`/`ls`** 这一独立来源读素材属性,
**没有**跑 `image.createImagePacker()`。跑它需要一个**用户选图**入口
(`DocumentViewPicker`,要人操作系统选择器),自动化里没有稳定路径;
而编一个"绕过选择器直接调 `packJpeg`"的测试专用入口,会是**只有测试在用的代码**
——那种代码不会被真实场景触到,验它等于验一个不存在的东西。

## 二、静态欠账 6 → 5(还完就划掉)

`harmony-admin` 那条**已升级为设备判据**(上一批做的),
所以它**不该再留在 `STATIC_ONLY` 里** —— 那个名单是给"还欠着的"记账的。
留着会让余额虚高,而这正是这个机制要防的(欠账不显形就等于没有)。

## 三、途中被两条"登记一致性"判据拦了两次(都按它们给的方向修)

1. `debt-visibility`:我在 `harmony-imageprep` 里新增了两处边界声明
   ("未覆盖/未验"这类词),而余额里没登记 ⇒ 红。
   **按它要求的顺序做**:先补 `docs/DEBTS.json`(`harmony-p4c-boundary-decls`
   那一笔的 note 里写明"只做了一半"),再把登记次数 4 → 6。
2. `commit-hygiene`:`DEBTS.json` 说 `static-criteria=6`,实测 5 ⇒ 红。
   —— 这条正是"可见的那个数字是副本,漂移了必须两边一起改"。
   改数字之外还在那一笔里写了**为什么减**(admin 升级并移出名单)。

★ 第三处被拦很有意思:`debt-visibility` 是**按词表数自己**的判据,
我第一次修时在那个文件里写了一句话里含"仍未覆盖",于是它把自己数多了 1 处
(12 → 13)。那一刻是**判据在正确地工作**("多一处即红")——
我写的其实是**引用**另一笔账,不是新的边界声明,所以改成了不带判定词的措辞。

## 四、判据

`run-all.mjs` → `files=32 ran=32 checks=507 pass=507 fail=0 skip=0
red=0 broken=0 unreported=0`。`harmony-imageprep` 30 → 31;
`harmony-admin` 移出静态名单(仍在套件里,28 条)。
`hvigorw assembleHap` 成功;前端重建。

**剩余到期未升级**:`harmony-appearance`(已有 2 条设备判据)、
`harmony-logic`、`cross-client-theme`、`appearance-defaults` —— 逐条来。
2026-09-19 16:18:10 +08:00
81621974b9 跨端: 日历「新建」按钮补文字(原先只有一个加号)+ 详情页三处缺失登记
## 一、日历的「新建」按钮(对齐 WebUI 的主动作)

WebUI `CalendarView.tsx:362-369` 那个按钮是工具栏里**唯一的实心按钮**:
`px-2.5 py-1 bg-blue-600 text-white` + `<PlusIcon/>` + **「新建」两个字**。

鸿蒙原先只有一个光秃秃的 `Button('+')` —— 丢掉的不只是文字,是**主动作这层语义**:
用户得猜"这个加号加什么"(加日程?加订阅?加日历?)。

改成蓝底 + 图标 + 「新建」,与 WebUI 同形。**设备实测截图确认**。

★ 与这条对照的是它左边那两条「导入/导出」:鸿蒙**有意**用文字而非图标,
理由已写在代码注释里(WebUI 有 `title` 可悬停,手指没有悬停)。
同一个道理在主动作上更成立 —— 主动作不该是个谜语。

## 二、邮件详情页缺三块功能(审计发现,服务端都已支持)

对照 `MailView.tsx` 逐段核对,鸿蒙 `MailDetailPage.ets` 缺:

| 功能 | WebUI | 服务端 |
|---|---|---|
| `RenameProposalBar` | `MailView.tsx:324` | ✅ `rename_proposal.go` 已在解析 `propose_alias` |
| `ForwardBar`(转发) | `MailView.tsx:378` | ✅ `forward.go`(含 `forwardSubject` 的 Fwd: 叠加处理) |
| `BudgetEditor`(预算) | `MailView.tsx:235` | ✅ `max_rounds` 字段 |

改名建议那条尤其重要(WebUI 注释:「Agent 干到一半自己改掉,人上一秒记住的
地址下一秒就失效」)—— 是否决制而不是 Agent 单方面改,这是**寻址稳定性**的设计。

三条都**没有**在这批里硬做:各含交互 + 接口 + 状态,不是顺手能补的量级。
登记进 `docs/DEBTS.json`(`harmony-maildetail-missing-three`)——
硬塞的结果是每条都半成品,那比缺着更糟(缺着是可见的,半成品是不可见的)。

## 三、审计中确认**已对齐**的部分(不是漏做)

- 通信:工具条三页签分组与顺序、列表行的字段集(发件人/主题/摘要/时间/未读点/
  账号徽标/档位徽标)、会话折叠与组头(别名/主题/未读/封数)、空态文案、
  发件箱的三处差异(数据源/`to_name`/不筛权限)、归档确认框。
- 日历:工具条分组与顺序、宽屏两栏(左网格 + 右 400vp 常驻)、右栏三态、
  月/周/日三档网格与农历小字、非本月淡出、今天高亮、事件圆点、
  点某天的行为(宽屏换右栏 / 窄屏滑入)、导入导出的三条结果分支。
- 联系人:卡片/列表两视图、字段集、归档确认框(含破坏性操作的二次确认)。
- 「我的」:八个分段的字段与顺序、壁纸选择器、主题三态、多账号入口。
- 管理页:列与操作、管理员门禁。
- 外壳:侧栏三项 + 底簇、底栏四项、徽标三档色调与位置、玻璃材质、让位。

## 判据

`run-all.mjs` → `checks=504 pass=503 fail=1`(唯一那条是 build-stamp 的
产物过期,重建后 7/7)。`hvigorw assembleHap` 成功。
`docs/DEBTS.json` 新增三条登记(断点差异 / 列表附件数 / 详情页三功能)。
2026-09-19 15:19:57 +08:00