Files
MailUI4Agents/docs/DEBTS-REVIEW.md
JianFeeeee 7634be8966 fix(inbox): 收件箱按**工作区**收窄(三维地址的 path 位此前从未被使用)
用户 12 天前就提过(`552fbc7` 只修了 session_id 那一维),这轮才真修。
用户原话:「难道让一个不在项目工作区的 agentsession 去修工程吗?」

# 缺陷(生产实测,2026-09-26)

在 `mc` 工作区干活的 pi 读收件箱拿到 **200 封,其中 191 封属于
`/home/program/agentmail`** —— 它照着那些信里的断言去改 agentmail 的代码,
把手上的 mc 活丢在一边。用户当场问它「你怎么干着干着修 agentmail 去了?」
(这条对话就在 mc 会话的 jsonl 里)

根因:`ListInboxScoped` 的 WHERE 只有 `m.to_name = $1`(+ 可选 session_id),
**没有任何 workspace 条件**。三维地址 `name@path.session` 的 path 位
在收件箱侧从未生效 —— 那不是"另一种语义",是没兑现契约。

# 三条守卫全部只覆盖自动转发,防不住这个

| 守卫 | 只覆盖 | 为何无效 |
| --- | --- | --- |
| 会话预算 | `relay != ""` 才扣 | 这批信 relay=0(模型主动发)⇒ 不扣 |
| maxRelayHops=5 | 同上,只数 relay | 同上 ⇒ 不进那个分支 |
| 插件自动转发守卫 | 插件代劳时 | 日志明说"本轮不自动转发" ⇒ 模型自己发的不受管 |

# 服务端

· `ListInboxScoped` / `CountUnreadScoped` / `MarkAllInboxReadForSession`
  三处统一加 `s.workspace = $N`(用会话的 workspace,不用 mails.to_workspace:
  后者是信封字段、可能是抄送或历史遗留;"线索属于哪个工作区"是会话属性)。
  ★ 三处必须是**同一个谓词** —— 列表看不到的信却被"全部标掉"标掉就是静默丢信
  (session_scope_test.go 记过这个形状)。
· **workspace 在 Agent 侧必需,缺了 400**(用户裁定:「不带 workspace 是错误
  发件格式,直接退回!」)。旧语义(不带=全部)正是缺陷本身,不留兼容回退。
· 人类侧**不过滤**(一个人跨工作区,WebUI 按 session_workspace 分组显示)——
  所以"必需"这条约束放在 Handler 而不是 repo 层:它是接口契约,不是数据层不变量。
· 新增 `UnreadWorkspaces`:心跳是**进程级**(一个桥服务所有工作区),没有
  "我的工作区"可言;但只有总数桥不知道去哪个工作区补投 ⇒ 心跳回
  `pending_workspaces` 清单,桥逐个消费。
· 决策载荷补 `workspace`(服务端知道 session→workspace,插件重启后推不出来)。
· `TouchAgentLastSeen` 从 HeartbeatAgent 拆出:middleware 在每个认证请求上都调它,
  而那时工作区还没解析(请求体没读),原来在白算一次 CountUnread。

# 三个插件(pi / opencode / dsh)

· 读类工具带 `workspace`;补投从"读一次全局收件箱"改为**逐工作区**读。
· pi:worker 信封的 `to_workspace` 经闭包递进工具(不是会话文件 header 的 cwd ——
  后者是"会话上次落在哪",前者是"这封信寄到哪个工作区")。
· opencode/dsh:插件常驻、信封在 deliverMail 那刻就消费掉了 ⇒ 新增
  `sessionWorkspace` 映射(键与既有 reverseMap 同一把)。
· 修一处真 bug:`UnreadWorkspaces` 原先会返回相对路径工作区(历史库里有
  `workspace='root'`),桥侧实测撞 400(`补投工作区 root 失败`)⇒ 只报可寻址的。

# 实测凭据

· 改前:`pi` 的收件箱 200 封混 3 个工作区(agentmail 191 / TrueAgent 7 / huawei 2)
· 改后:agentmail=100(total 228)、mc=16、TrueAgent=7 —— 各工作区独立
· 不带 workspace ⇒ **HTTP 400**,话术给出可执行步骤
· 桥日志:`rw=/home/newqqagent/plugindev/editdoc-upgrade` —— 终于是别的工作区了
  (改前 78 次 worker 启动**全部**是 `/home/program/agentmail`)

# 判据

· `server/internal/repo/workspace_scope_test.go`(3 条):
  两向收窄 + **反向对照**(不带时两条都看得到 ⇒ 证明是收窄不是清空)+
  未读数同口径 + 相对路径必须报错
· `plugins/pi-mail-bridge/test/inbox-workspace-scope.test.mjs`(4 条):接线 +
  取信封而非 cwd + 补投逐工作区 + 判据自检
· dsh 那条 `取不到会话时退回整体收件箱` **改了**:它钉的"退回整体"正是缺陷,
  现在钉"两维各自缺席时各自不带、服务端 400 让错误可见"
· 变异验证:服务端 2 处 + 插件 3 处,全部判红后恢复回绿

全量:server `go test ./...` 绿;三插件 513+340+403 全绿。
2026-09-26 07:44:33 +08:00

168 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# `docs/DEBTS.json` 审读(2026-09-26)
审读对象:`docs/DEBTS.json`(25 条、余额 27)。
方法:读文件 + 读**它的三个消费方**(Go 判据 / electron RESULT 行 / `debt-visibility`),
再用仓库现状**反查每条的 `due`/`where` 是否仍与事实一致**。凡是我能实测的都实测了。
结论先说:**结构是好的,两条被机器钉住的条目也是准的**;
但**「已结算」这件事没有任何判据在管** —— 于是「结算了却看起来没结算」就成了一种静默状态,
而且我实测**已经发生过一次**,且它把一个**真·未验的欠账**藏进了不可见的桶里。
---
## 一、总体健康度(这些是好的,先说清楚)
| 项 | 实测 | 判断 |
|---|---|---|
| JSON 合法、`debts` 25 条 | ✓ | 好 |
| 每条 `id/count/due/where` 齐全 | ✓ 0 条缺失 | 好 |
| `static-criteria` 登记 vs `STATIC_ONLY.length` | 5 vs **5** | 好(`commit-hygiene` 钉住) |
| `mails-status-derived` 登记 vs 实测 | 1 vs 1 | 好(`TestDebtLedgerMatchesMeasurement` 钉住) |
| 头部"两端都读同一个文件" | 属实 | 好 |
| 余额打印来自权威源(无手写副本) | 属实 | 好(`TestDebtSummaryReadsAuthoritativeLedger` 钉住) |
★ 特别值得一提:`debt_registry_test.go` 里那段"**该断言的是形状,不是点名**"(删掉硬编码三笔清单)
和 `run-all.mjs` 里"子集关系必须打进字符串本身" —— 这两条**是这份文件里最有价值的资产**:
它们挡的正是"余额看起来对、其实没人判"这一族。下面的问题**不是**它们失职,
而是**它们覆盖的相位之外还有一片空地**。
---
## 二、★ 主要问题:**「已结算」无判据 ⇒ 结算后的条目会"看起来没结算"**
### 2.1 机制
```
余额打印只收 count>0(debtSummary / run-all.mjs 的 reduce,两处一致)
⇒ 一旦把某条 count 置 0,它就从**默认可见的余额**里消失
⇒ 而 due / where 是**自由文本**,没有任何判据校验它们与 note 里"已结算"是否一致
(实测: 读 `note` 的判据 **0 处** —— `run-all.mjs` 里那个 `.note` 属于变异体 `DIAG` 表,
与 DEBTS 条目无关;due/where 则只被断言"非空")
⇒ 结果: 「已结算」只写在 note 深处,而 due/where 可以永远停在结算前 —— 全绿
```
### 2.2 我实测到的**真实案例**(不是假想)
`harmony-maildetail-missing-three`:
```
count = 0 ← 不计入余额 ⇒ 默认路径上看不见
due = 「鸿蒙详情页对齐 WebUI 时(三块功能,服务端都已支持)」 ← 读起来是**未完成**
where = 「MailDetailPage.ets(对照 MailView.tsx)」 ← 读起来是**缺口**
note 末段 = 「2026-09-19 结算:三块**都做完了**」 ← 事实是**已完成**
```
我反查了代码,`note` 是对的、`due`/`where` 是**过期**的:
```
client/harmony/.../MailDetailPage.ets 里三块都在: Forward ×19 / rename ×22 / Budget ×17(max_rounds ×5)
```
⇒ ★ 这条**已还清**,但它**三条字段全都停在结算前**,且因 `count=0` 而不可见。
下一个人来审计,看到的是"鸿蒙详情页缺三块功能"这个**已经不存在的欠账**。
### 2.3 ★★ 而它顺带藏起了一个**真的**未验项
同一条的 note 末尾有一句**实质未验**:
> ★ 未验:预算条的**点击**在设备上没走通 —— 模拟器顶部 155px 是系统手势区,
> 而折叠头部恰在其中(坐标式点击会被系统抢走)。数据链路已实测,观感没验。
我实测:这句话**只存在于这个 `count=0` 的 note 里**,
`MailDetailPage.ets` 里搜不到(grep `155px`/`系统手势区` = 0)。
而本仓对"设备未验"的**既有惯例是单独立条、`count=1`**:
```
harmony-morph-unverified-middleframes count=1
harmony-account-errors-banner-unverified count=1
harmony-permission-history-render-unverified count=1
```
⇒ ★ 于是这里出现了**双重的不可见**:
① 它被写在一个 `count=0` 的条目的 note 里(不进余额);
② 它**没有**按同一族的惯例**单独立条**。
⇒ 按本文件自己的判据("**欠账不显形,就等于没有**"),这条**等于不存在**。
### 2.4 为什么现有判据抓不到(这不是它们写错了)
```
· debt_registry_test.go : 只断言 due/where **非空** + 两笔存在 + 与**实测**一致
⇒ "结算后字段该更新"不在这三条里
· commit-hygiene : 只钉 static-criteria(那笔有权威实测源)
· debt-visibility : 形状是"**判据目录里**声明未覆盖/未验 ⇒ 必须登记"
★ 它**确实**读了 DEBTS.json,但只取 `d.where` 做"这一笔是否被引用"的比对,
**从不读 `d.note`**(实测: 该文件里 `\.note` 命中 0)
⇒ 扫描面 = 判据目录的文件 + 条目的 `where`
⇒ 而这里那句"未验"写在 **`note` 里**,两处都不在 ⇒ **不在它的扫描面**
⇒ 三者的盲区**恰好交汇**在这条上 ⇒ 全绿
```
### 2.5 建议(判据形状,不是改数字)
```
① 结算语义判据(Go 侧,最便宜):
若 note 里出现"结算/已还清/都做完了"等词 ⇒ 断言 due 也带已清标记(或断言该条被移出 debts[])
反向: 若 due 带已清标记 ⇒ 断言 note 里确有结算条目(防止"标了却没说清是什么")
② note 里的"未验/未覆盖"必须**另有登记**:
把 debt-visibility 的 MARKERS 扫描面从"判据目录"**扩到 docs/DEBTS.json 的 note**
(否则 note 就成了新的"注释里写边界"——正是该判据 §16 要挡的那件事)
③ 惯例二选一并写进头部:
"结算即移除"(头部现在这么写)与"置 count=0 留档"(实际做法,5 条)**两套并存**
⇒ 我倾向**保留留档**(有考古价值),但**头部那句话要改**,否则它本身在误导
```
---
## 三、次要问题
### 3.1 `kind` 无判据,且 `run-all.mjs` 拿它当**分类**用
```
16 条 'scope' / 6 条 'env' / 3 条是**整句话**(被当成分类值):
deploy-space-prefix-fs kind = 「判据铺得不满(不是新列)」
pi-bridge-adopt-cwd-mismatch kind = 「沙箱 rw 与 worker 实际 cwd 的第三个来源未对齐(同类已修两处,剩接管路径)」
deploy-interrupt-trap-other-scripts kind = 「只在 redeploy-gateway.sh 做了,另两个部署脚本没做」
```
消费侧 `run-all.mjs:1960` 做的是 `filter(d => d.kind === 'env')` ⇒ 那 3 条**静默落进 other 桶**。
Go 侧 struct **根本不读 `kind`**,所以没有编译期或运行期反馈。
⇒ 不是错误(余额没算丢),但**"分类"这个字段目前是装饰性的**:
读的人会以为 `kind` 是枚举,实际是自由文本。
建议:要么把 `kind` 收成枚举并登记那 3 条,要么承认它是"备注"并**改名**(`kind_note`),
否则下一个写条目的人会照着这 3 条继续写句子。
### 3.2 无"路径腐烂"检查
`where` 里的仓库路径没有任何判据验证仍存在。
我扫了全部 `where`+`due`,**实测只有 1 处疑似**(`platform-mirror-…` 的 `server/main.go`),
复核后**是散文里的裸文件名、不是路径声明**(真路径 `server/cmd/server/main.go`)—— 所以当前没坏。
但这是**运气**:`where` 指的文件被改名/移动时,没有任何东西会红。
建议:`where` 里形如 `client/…|server/…|deploy/…|plugins/…|docs/…` 的 token 断言 `os.Stat` 通过。
### 3.3 `count=0` 条目的"到期前提"措辞不一致
5 条已清条目的 `due` 都带「已…」,但 `harmony-maildetail-missing-three` 不带(见 §2.2)。
这正是 §2.5① 那条判据要抓的形状 —— 单列出来是为了说明**它不是孤例的猜测,是可复现的类别**。
---
## 四、我**没有**发现问题的部分(避免下一个人重复审)
- 两处"同一个事实两份实现"的坑(`var debts` 手写 map、`debtSummary` 不读权威源)**已经修好且判据在位** ——
且 `debt_registry_test.go` 里那个"锚点会自匹配"的教训(`var " + "debts"`)写得很清楚,值得保留。
- 余额数值本身:25 条 / 余额 27 / env 桶 6 / other 21 —— 我逐条加过,**算术无误**。
- `note` 可选:9 条没有 `note` 字段(都在头部或 `due` 里交代了),**不是缺失**。
- `harmony-permission-history`(0) 与 `...-render-unverified`(1) 看似重复,实为两条不同对象
(前者=数据源只读 pending,后者=渲染段未验)—— **不是重复登记**。
---
## 五、一句话
**这份文件最擅长的、也已经做到的是"销账"(条目还清 → count 归零);它最弱的是"销账之后"** ——
`count=0` 让条目离开默认可见的余额,而 `due`/`where` 与 `note` 的一致性、
以及 `note` 里新写下的边界,都**没有判据**。
`harmony-maildetail-missing-three` 同时踩中两处:它**已还清**(却读起来像没还),
并且把一个**真的未验项**(预算条点击)藏进了不可见的 note —— 按本文件自己的话,
**那一条等于没有**。