From 7634be89660ad88fc53826870df1c93c1663cbda Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 07:44:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(inbox):=20=E6=94=B6=E4=BB=B6=E7=AE=B1?= =?UTF-8?q?=E6=8C=89**=E5=B7=A5=E4=BD=9C=E5=8C=BA**=E6=94=B6=E7=AA=84?= =?UTF-8?q?=EF=BC=88=E4=B8=89=E7=BB=B4=E5=9C=B0=E5=9D=80=E7=9A=84=20path?= =?UTF-8?q?=20=E4=BD=8D=E6=AD=A4=E5=89=8D=E4=BB=8E=E6=9C=AA=E8=A2=AB?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 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 全绿。 --- docs/DEBTS-REVIEW.md | 167 ++++++++++++++ plugins/dsh-mail-bridge/src/index.ts | 92 ++++++-- .../test/inbox-session-scope.test.mjs | 17 +- plugins/opencode-mail-bridge/index.js | 95 ++++++-- plugins/pi-mail-bridge/src/index.mjs | 85 +++++-- plugins/pi-mail-bridge/src/tools.mjs | 61 +++-- plugins/pi-mail-bridge/src/worker.mjs | 8 +- .../test/inbox-workspace-scope.test.mjs | 65 ++++++ server/internal/handler/agents.go | 34 ++- server/internal/handler/mail.go | 61 ++++- server/internal/handler/me.go | 4 +- server/internal/handler/permission.go | 11 + server/internal/middleware/auth.go | 4 +- server/internal/repo/agent_disable_test.go | 2 +- .../internal/repo/mail_status_derived_test.go | 2 +- server/internal/repo/markread_test.go | 2 +- .../internal/repo/permission_deadline_test.go | 2 +- .../repo/permission_visibility_test.go | 2 +- server/internal/repo/readstate_test.go | 34 +-- server/internal/repo/repo.go | 209 ++++++++++++++++-- server/internal/repo/session_scope_test.go | 22 +- server/internal/repo/workspace_scope_test.go | 157 ++++++++++--- 22 files changed, 957 insertions(+), 179 deletions(-) create mode 100644 docs/DEBTS-REVIEW.md create mode 100644 plugins/pi-mail-bridge/test/inbox-workspace-scope.test.mjs diff --git a/docs/DEBTS-REVIEW.md b/docs/DEBTS-REVIEW.md new file mode 100644 index 0000000..0d68207 --- /dev/null +++ b/docs/DEBTS-REVIEW.md @@ -0,0 +1,167 @@ +# `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 —— 按本文件自己的话, +**那一条等于没有**。 diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index e34e668..c0119f4 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -173,6 +173,20 @@ class GatewayClient { const sessionMap = new BoundedMap(MAX_TRACKED_SESSIONS); const reverseMap = new BoundedMap(MAX_TRACKED_SESSIONS); + +/* +★ 2026-09-26:DSH 会话 id -> **工作区**。 + +收件箱接口现在要求 `workspace`(缺了 400),而 `read_inbox` 的 URL 是从 +`exec.agent` 现场拼的 —— 那里只有 DSH 的会话 id,推不出工作区。 +信封(`data.to_workspace`)在 `deliverMail` 那一刻就消费掉了, +到 `read_inbox` 时已不在作用域里 ⇒ 在这里留一份映射。 + +用户报的缺陷(这一维此前完全没做):在 `mc` 工作区干活的会话读收件箱拿到 +agentmail 的信,照着去改 agentmail 的代码。三维地址 `name@path.session` +的 **path 位本来就该参与寻址**。 +*/ +const sessionWorkspace = new BoundedMap(MAX_TRACKED_SESSIONS); const mailDrivenSessions = new BoundedSet(MAX_TRACKED_SESSIONS); // 回信上下文。fromHuman / inReplyTo 是服务端给的两个信号: // 前者决定要不要自动转发(Agent 间不转,见 lib/relay-policy.js), @@ -475,27 +489,43 @@ export function apply(ctx: any, config: PluginConfig): void { * SSE 只推连上之后的事件,插件重启前发来的邮件不会再推一次。 * 不补的话那封邮件永远躺在收件箱里,而发件人以为 Agent 收到了。 */ - async function catchUp(pending: unknown): Promise { + async function catchUp(pending: unknown, workspaces?: unknown): Promise { if (!pending) return; - try { - const box = await client.get('/mail/inbox?status=unread&limit=20'); - const tasks = selectCatchup(box?.mails ?? box, deliveredMails); - if (tasks.length === 0) return; - console.error(`[dsh-mail-bridge] 补投 ${tasks.length} 封离线期间的邮件(共 ${pending} 封未读)`); - // 串行:每封都要起一轮模型,并发放出去等于对上游打 N 个并发请求 - for (const ev of tasks) { - // 逐封再查一次:拉收件箱和逐封投递之间 SSE 可能已经投过其中某封 - if (deliveredMails.has(ev.mail_id)) continue; - deliveredMails.add(ev.mail_id); - try { - await deliverMail(ev, 'mail'); - } catch (e: any) { - console.error(`[dsh-mail-bridge] 补投 ${ev.mail_id} 失败: ${e?.message || e}`); - } - } - } catch (e: any) { - console.error(`[dsh-mail-bridge] 补投失败: ${e?.message || e}`); + /* + ★ 逐工作区补投,不再读一次全局收件箱。 + 旧写法不带收窄 ⇒ 会把**所有工作区**的漏投一起重放(在 mc 干活却被补投 + agentmail 的信)。清单来自心跳的 `pending_workspaces`:心跳是进程级、 + 没有"我的工作区"可言,所以由它给清单,这里逐个消费。 + */ + const list = Array.isArray(workspaces) ? (workspaces as string[]) : []; + if (list.length === 0) { + console.error(`[dsh-mail-bridge] 补投跳过:pending_mails=${pending} 但服务端未给出 pending_workspaces(旧版服务端?)`); + return; } + let delivered = 0; + for (const ws of list) { + try { + const box = await client.get(`/mail/inbox?status=unread&limit=20&workspace=${encodeURIComponent(ws)}`); + const tasks = selectCatchup(box?.mails ?? box, deliveredMails); + if (tasks.length === 0) continue; + // 串行:每封都要起一轮模型,并发放出去等于对上游打 N 个并发请求 + for (const ev of tasks) { + // 逐封再查一次:拉收件箱和逐封投递之间 SSE 可能已经投过其中某封 + if (deliveredMails.has(ev.mail_id)) continue; + deliveredMails.add(ev.mail_id); + try { + await deliverMail(ev, 'mail'); + delivered += 1; + } catch (e: any) { + console.error(`[dsh-mail-bridge] 补投 ${ev.mail_id} 失败: ${e?.message || e}`); + } + } + } catch (e: any) { + // 单个工作区失败不影响其余(与"心跳失败不报错"同一原则) + console.error(`[dsh-mail-bridge] 补投工作区 ${ws} 失败(不影响其余): ${e?.message || e}`); + } + } + if (delivered) console.error(`[dsh-mail-bridge] 补投 ${delivered} 封离线期间的邮件(共 ${pending} 封未读,跨 ${list.length} 个工作区)`); } async function beat(): Promise { @@ -523,7 +553,7 @@ export function apply(ctx: any, config: PluginConfig): void { // 每轮心跳都补的话会把「模型正在处理中、尚未标已读」的邮件重复投递。 if (!caughtUp) { caughtUp = true; - await catchUp(res?.pending_mails); + await catchUp(res?.pending_mails, res?.pending_workspaces); } } catch { // 心跳失败不报错:网络抖动很常见,下一轮会补上。 @@ -871,6 +901,14 @@ export function apply(ctx: any, config: PluginConfig): void { * 取不到返回空串(= 退回整个 Agent 的收件箱)。宁可退回旧行为,也不猜 —— * 猜错会把别人会话的未读标掉,那是静默丢信。 */ + /** 这次调用所属会话的工作区(与 mailSessionOf 同一把键)。拿不到返回空串 —— 那时 + * 收件箱请求会 400,是刻意的:错误可见,好过静默跨工作区拿到别处的信。 */ + function workspaceOf(exec: any): string { + const dshSessionId = exec?.agent?.id ?? exec?.agent ?? ''; + if (!dshSessionId) return ''; + return sessionWorkspace.get(String(dshSessionId)) ?? ''; + } + function mailSessionOf(exec: any): string { const dshSessionId = String(exec?.agent?.id ?? ''); if (!dshSessionId) return ''; @@ -919,6 +957,8 @@ export function apply(ctx: any, config: PluginConfig): void { const cwd = String(agent?.session?.header?.cwd ?? ''); sessionMap.set(mailSessionID, { dshSessionId: id, directory: cwd }); reverseMap.set(id, mailSessionID); + /* 会话自己的 cwd 就是它的工作区 —— read_inbox 的收窄要用它 */ + if (cwd) sessionWorkspace.set(id, cwd); mailDrivenSessions.add(id); console.error(`[dsh-mail-bridge] 由确定性 id 恢复会话映射 ${mailSessionID} -> ${id}`); } @@ -963,6 +1003,8 @@ export function apply(ctx: any, config: PluginConfig): void { if (!mailSessionID) return; sessionMap.set(mailSessionID, { dshSessionId, directory: cwd }); reverseMap.set(dshSessionId, mailSessionID); + /* 同上:cwd 即工作区 */ + if (cwd) sessionWorkspace.set(dshSessionId, cwd); mailDrivenSessions.add(dshSessionId); mailContexts.set(mailSessionID, { replyTo: data.from_name || '', @@ -1341,7 +1383,15 @@ export function apply(ctx: any, config: PluginConfig): void { async execute(args: any, exec?: any): Promise { const status = args.status || DEFAULT_INBOX_STATUS; const mailSessionID = mailSessionOf(exec); - const scope = mailSessionID ? `&session_id=${encodeURIComponent(mailSessionID)}` : ''; + /* + ★ 两维收窄都要带: + · session_id —— 只列**这条线索**的信(防"A 会话标掉 B 会话的未读") + · workspace —— 只列**这个工作区**的信(防"在 mc 干活却读到 agentmail 的信") + 服务端对缺 workspace 直接 400 —— 刻意如此(旧语义正是那个缺陷本身)。 + */ + const ws = workspaceOf(exec); + const scope = (mailSessionID ? `&session_id=${encodeURIComponent(mailSessionID)}` : '') + + (ws ? `&workspace=${encodeURIComponent(ws)}` : ''); const { mails } = await client.get( `/mail/inbox?status=${status}&limit=${args.limit || DEFAULT_INBOX_LIMIT}${scope}` ); diff --git a/plugins/dsh-mail-bridge/test/inbox-session-scope.test.mjs b/plugins/dsh-mail-bridge/test/inbox-session-scope.test.mjs index f37075b..2b83a53 100644 --- a/plugins/dsh-mail-bridge/test/inbox-session-scope.test.mjs +++ b/plugins/dsh-mail-bridge/test/inbox-session-scope.test.mjs @@ -32,9 +32,20 @@ test('★ 会话来源是平台上下文(并发安全),不是模块级变 assert.ok(!bad.test(src), '不得用模块级"当前会话"变量(同进程多会话会互相覆盖)'); }); -test('取不到会话时退回整体收件箱(不猜)', () => { - assert.match(src, /if \(!dshSessionId\) return ''/); - assert.match(src, /const scope = mailSessionID \? `&session_id=[^`]*` : ''/); +test('取不到会话时不猜(session 位与 workspace 位各自独立缺席)', () => { + assert.match(src, /if \(!dshSessionId\) return ''/, '拿不到 DSH 会话 → 空串,不去反查'); + /* + ★★ 2026-09-26 这条断言改了:原来钉的是"取不到会话时退回**整体**收件箱"。 + 那个"退回整体"正是缺陷 —— 在 mc 工作区干活的会话会因此拿到 agentmail 的信 + 并照着去改 agentmail 的代码(用户当场指出)。 + + 现在的规矩:两维**各自**缺席时各自不带,服务端对缺 workspace 直接 400。 + "错误可见"胜于"静默跨工作区"。(服务端那半见 workspace_scope_test.go) + */ + assert.match(src, /const ws = workspaceOf\(exec\);/, + 'workspace 要经 workspaceOf(exec) 取(与 mailSessionOf 同一把键)'); + assert.match(src, /ws \? `&workspace=\$\{encodeURIComponent\(ws\)\}` : ''/, + 'workspace 那维要真的拼进去'); }); test('★ 判据自检:不带收窄的旧写法必须判红', () => { diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index b50715a..9b89591 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -273,7 +273,16 @@ const readInboxTool = { const filter = args.filter || DEFAULT_INBOX_STATUS; const limit = args.limit || DEFAULT_INBOX_LIMIT; const mailSessionID = reverseMap.get(String(context?.sessionID ?? "")) || ""; - const scope = mailSessionID ? `&session_id=${encodeURIComponent(mailSessionID)}` : ""; + const workspace = sessionWorkspace.get(String(context?.sessionID ?? "")) || ""; + /* + ★ 两维收窄都要带: + · session_id —— 只列**这条线索**的信(防"A 会话标掉 B 会话的未读") + · workspace —— 只列**这个工作区**的信(防"在 mc 干活却读到 agentmail 的信") + 服务端对缺 workspace 直接 400 —— 那是刻意的(旧语义正是缺陷本身)。 + workspace 拿不到时**不带**,让服务端报 400:错误可见,好过静默跨工作区。 + */ + const scope = (mailSessionID ? `&session_id=${encodeURIComponent(mailSessionID)}` : "") + + (workspace ? `&workspace=${encodeURIComponent(workspace)}` : ""); const data = await apiGet(`/mail/inbox?status=${filter}&limit=${limit}${scope}`); // 渲染与已读策略放 lib/inbox-format.js:它们与平台 SDK 无关, @@ -603,6 +612,28 @@ function startSSE(onEvent) { // 条目,而 GC 收不掉(还被强引用着)。 const sessionMap = new BoundedMap(MAX_TRACKED_SESSIONS); // agentmail session_id -> opencode session id const reverseMap = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> agentmail session_id(供 event 钩子回写命名) + +/* +★ 2026-09-26:opencode session id -> **工作区**。 + +# 为什么要这一张表 + +收件箱接口现在要求 `workspace`(缺了 400),而 `read_inbox` 的 URL 是从 +`context.sessionID` 现场拼的 —— 那里只有 opencode 的会话 id,推不出工作区。 + +# 与 pi 桥的差别(同一件事的两种做法) + +pi 的 worker 一个进程只服务一封邮件,信封就在手上(`data.to_workspace`), +所以它直接把闭包传进工具。opencode 是**插件常驻、按会话分派**, +信封在 `resolveSessionForMail` 那一刻消费掉了,到 `read_inbox` 时已经不在作用域里。 +⇒ 在这里留一份映射,键与 reverseMap 同一个(都是 opencode 的 session id)。 + +# 用户报的缺陷(这一维此前完全没做) + +在 `mc` 工作区干活的会话读收件箱拿到 agentmail 的 191 封信,照着去改 agentmail +的代码。三维地址 `name@path.session` 的 **path 位本来就该参与寻址**。 +*/ +const sessionWorkspace = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> 工作区绝对路径 const syncedTitles = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> 已回写过的标题(去重,避免 session.updated 刷屏) // 权限询问的双向定位。 @@ -666,6 +697,7 @@ async function resolveSessionForMail(client, directory, data, kind) { if (mailSessionID) { sessionMap.set(mailSessionID, adoptedID); reverseMap.set(adoptedID, mailSessionID); + if (wantDir) sessionWorkspace.set(adoptedID, wantDir); // 标记为邮件驱动:接管之后这条会话**开始**参与邮件往来, // 轮次结束要把总结转回发件人。不标记的话邮件投进去了却永远没有回音。 mailDrivenSessions.add(adoptedID); @@ -706,6 +738,7 @@ async function resolveSessionForMail(client, directory, data, kind) { if (mailSessionID) { sessionMap.set(mailSessionID, sessionID); reverseMap.set(sessionID, mailSessionID); + if (wantDir) sessionWorkspace.set(sessionID, wantDir); mailDrivenSessions.add(sessionID); // slug 在创建时就有(如 nimble-lagoon),立即作为寻址别名回写; @@ -1186,28 +1219,46 @@ export default async function mailBridge(input) { * SSE 只推连上之后的事件,插件重启前发来的邮件不会再推一次。 * 不补的话那封邮件永远躺在收件箱里,而发件人以为 Agent 收到了。 */ - async function catchUp(pending) { + async function catchUp(pending, workspaces) { if (!pending) return; - try { - const box = await apiGet("/mail/inbox?status=unread&limit=20"); - const tasks = selectCatchup(box?.mails ?? box, deliveredMails); - if (tasks.length === 0) return; - console.error(`[mail-bridge] 补投 ${tasks.length} 封离线期间的邮件(共 ${pending} 封未读)`); - // 串行:每封都要起一轮模型,并发放出去等于对上游打 N 个并发请求 - for (const ev of tasks) { - // 逐封再查一次:拉收件箱和逐封投递之间 SSE 可能已经投过其中某封 - // (selectCatchup 只在拉完那一刻去过重) - if (deliveredMails.has(ev.mail_id)) continue; - deliveredMails.add(ev.mail_id); - try { - await deliverMail(client, directory, ev, "mail"); - } catch (e) { - console.error(`[mail-bridge] 补投 ${ev.mail_id} 失败:`, e?.message || e); - } - } - } catch (e) { - console.error("[mail-bridge] 补投失败:", e?.message || e); + /* + ★ 逐工作区补投,不再读一次全局收件箱。 + + 旧写法 `apiGet("/mail/inbox?status=unread&limit=20")` 不带任何收窄, + 会把**所有工作区**的漏投一起重放 —— 在 mc 干活却被补投 agentmail 的信。 + 清单来自心跳的 `pending_workspaces`(心跳是进程级、没有"我的工作区", + 所以由它给清单,这里逐个消费)。 + */ + const list = Array.isArray(workspaces) ? workspaces : []; + if (!list.length) { + console.error(`[mail-bridge] 补投跳过:pending_mails=${pending} 但服务端未给出 pending_workspaces(旧版服务端?)`); + return; } + let delivered = 0; + for (const ws of list) { + try { + const box = await apiGet(`/mail/inbox?status=unread&limit=20&workspace=${encodeURIComponent(ws)}`); + const tasks = selectCatchup(box?.mails ?? box, deliveredMails); + if (tasks.length === 0) continue; + // 串行:每封都要起一轮模型,并发放出去等于对上游打 N 个并发请求 + for (const ev of tasks) { + // 逐封再查一次:拉收件箱和逐封投递之间 SSE 可能已经投过其中某封 + // (selectCatchup 只在拉完那一刻去过重) + if (deliveredMails.has(ev.mail_id)) continue; + deliveredMails.add(ev.mail_id); + try { + await deliverMail(client, directory, ev, "mail"); + delivered += 1; + } catch (e) { + console.error(`[mail-bridge] 补投 ${ev.mail_id} 失败:`, e?.message || e); + } + } + } catch (e) { + // 单个工作区失败不影响其余(与"心跳失败不报错"同一原则) + console.error(`[mail-bridge] 补投工作区 ${ws} 失败(不影响其余):`, e?.message || e); + } + } + if (delivered) console.error(`[mail-bridge] 补投 ${delivered} 封离线期间的邮件(共 ${pending} 封未读,跨 ${list.length} 个工作区)`); } let caughtUp = false; @@ -1232,7 +1283,7 @@ export default async function mailBridge(input) { // 每轮心跳都补的话会把「模型正在处理中、尚未标已读」的邮件重复投递。 if (!caughtUp) { caughtUp = true; - await catchUp(res?.pending_mails); + await catchUp(res?.pending_mails, res?.pending_workspaces); } } catch { // 心跳失败不报错:网络抖动很常见,下一轮会补上。 diff --git a/plugins/pi-mail-bridge/src/index.mjs b/plugins/pi-mail-bridge/src/index.mjs index aa681d2..f3e7674 100644 --- a/plugins/pi-mail-bridge/src/index.mjs +++ b/plugins/pi-mail-bridge/src/index.mjs @@ -183,7 +183,13 @@ async function routeDecision(data) { const decision = String(data?.decision || '拒绝'); const note = typeof data?.note === 'string' ? data.note : ''; const sessionID = data?.session_id || ''; - const waiting = sessionID ? await collectWaitingMails(sessionID) : []; + /* + ★ workspace 由服务端给(决策载荷里的 `workspace`)—— 插件推不出来: + 重启后待决映射丢光,此时手上只有这个事件。缺了它下面的收件箱读会 400, + 而这条路的用途是"把人在等待期间补的更正一并交给模型",读不到 = 更正丢失。 + */ + const workspace = data?.workspace || ''; + const waiting = sessionID ? await collectWaitingMails(sessionID, workspace) : []; if (relayKey && pool.routePermission(relayKey, decision, note, waiting)) { log(`权限 ${relayKey} 决策 ${data.decision}(决策人 ${data.decided_by || '?'})已转交 worker` @@ -206,9 +212,18 @@ async function routeDecision(data) { * * 拉取失败**不能拖垮决策投递**:备注还在,最坏情况是模型晚一步看到更正。 */ -async function collectWaitingMails(sessionID) { +async function collectWaitingMails(sessionID, workspace = '') { + // ★ 必须带 workspace(服务端缺它 400)。旧版这里读的是**全局**收件箱 —— + // 那正是"在 mc 干活却拿到 agentmail 的信"那条缺陷的另一处载体。 + // workspace 为空时不读:宁可少给一封更正,也不要跨工作区误取。 + if (!workspace) { + log('等人期间的邮件拉取跳过:决策载荷未带 workspace(旧版服务端?)'); + return []; + } try { - const box = await client.get('/mail/inbox?status=unread&limit=20'); + const box = await client.get( + `/mail/inbox?status=unread&limit=20&workspace=${encodeURIComponent(workspace)}`, + ); return selectWaitingMails(box, { sessionID, seen: deliveredMails }); } catch (e) { log(`等人期间的邮件拉取失败(不影响决策投递): ${describeError(e)}`); @@ -260,21 +275,57 @@ async function reportModels() { * 并发放出去等于对上游打 N 个并发请求」—— 那个约束现在由 pool 的 maxWorkers * 承担,而且它比串行更好:同一条会话仍然串行,不同会话可以并行。 */ -async function catchUp(pending) { +/** + * 补投离线期间的邮件。 + * + * ★★ 2026-09-26 改:**逐工作区**补投,而不是读一次全局收件箱。 + * + * ── 原来错在哪 ── + * 它调 `/mail/inbox?status=unread&limit=20` —— **不带任何收窄**。那时的语义是 + * "该 Agent 的全部未读",于是桥会把**所有工作区**的漏投邮件一起重放: + * 我在 `mc` 工作区干活,却被补投一堆 `agentmail` 的信(用户报的那类现象)。 + * + * ── 现在怎么走 ── + * 心跳返回 `pending_workspaces`(哪些工作区有未读)。逐个工作区去读 —— + * 每次读都带上那个工作区的 `workspace`,与服务端要求的收窄口径一致。 + * 服务端在缺 workspace 时直接 400,所以这里不可能"忘了带"。 + * + * ── 为什么不是"心跳带一个 workspace" ── + * 心跳是**进程级**的(一个桥进程服务所有工作区),它没有"我的工作区"可言。 + * 而收件箱是 **worker 级**的(每个 worker 手上那封信有明确的 path 位)。 + * 在进程级强制 workspace 是概念错配 —— 所以心跳给清单,这里逐个消费。 + * + * @param {number} pending 全局未读总数(仅用于日志;不再是过滤依据) + * @param {string[]} workspaces 有未读的工作区清单 + */ +async function catchUp(pending, workspaces) { if (!pending) return; - try { - const box = await client.get('/mail/inbox?status=unread&limit=20'); - const tasks = selectCatchup(box?.mails ?? box, deliveredMails); - if (!tasks.length) return; - log(`补投 ${tasks.length} 封离线期间的邮件(共 ${pending} 封未读)`); - for (const ev of tasks) { - if (deliveredMails.has(ev.mail_id)) continue; // 逐封再查(B-7.6) - deliveredMails.add(ev.mail_id); - pool.submit('mail', ev); - } - } catch (e) { - log(`补投失败: ${describeError(e)}`); + const list = Array.isArray(workspaces) ? workspaces : []; + if (!list.length) { + // 有未读却给不出工作区:只可能是上报侧出了问题。明说,别静默跳过。 + log(`补投跳过:pending_mails=${pending} 但服务端未给出 pending_workspaces(旧版服务端?)`); + return; } + let delivered = 0; + for (const ws of list) { + try { + // ★ workspace 是必填参数 —— 缺了服务端 400(这是刻意的,见服务端 GetInbox)。 + const box = await client.get( + `/mail/inbox?status=unread&limit=20&workspace=${encodeURIComponent(ws)}`, + ); + const tasks = selectCatchup(box?.mails ?? box, deliveredMails); + for (const ev of tasks) { + if (deliveredMails.has(ev.mail_id)) continue; // 逐封再查(B-7.6) + deliveredMails.add(ev.mail_id); + pool.submit('mail', ev); + delivered += 1; + } + } catch (e) { + // 单个工作区失败不影响其余:与"心跳失败不报错"同一原则。 + log(`补投工作区 ${ws} 失败(不影响其余): ${describeError(e)}`); + } + } + if (delivered) log(`补投 ${delivered} 封离线期间的邮件(共 ${pending} 封未读,跨 ${list.length} 个工作区)`); } // ─── 启动 / 关停 ─── @@ -370,7 +421,7 @@ async function main() { if (Array.isArray(res?.allowed_models)) allowedModels = res.allowed_models; // B-2.2 if (!caughtUp) { // B-7.1:只在首个成功心跳后补一次 caughtUp = true; - await catchUp(res?.pending_mails); + await catchUp(res?.pending_mails, res?.pending_workspaces); } } catch { // B-2.1:心跳失败不重试不报错。真连不上时 Gateway 会把它判成离线, diff --git a/plugins/pi-mail-bridge/src/tools.mjs b/plugins/pi-mail-bridge/src/tools.mjs index e2e1860..0ce68e4 100644 --- a/plugins/pi-mail-bridge/src/tools.mjs +++ b/plugins/pi-mail-bridge/src/tools.mjs @@ -61,30 +61,65 @@ const text = (s) => ({ content: [{ type: 'text', text: s }] }); * opencode / dsh / homeagent 三个平台都没写这一行,只有这里写了 —— 它不是 * 「更严格更好」,而是与 pi 的参数传递机制直接冲突。 */ -export function createMailTools({ client, log, agentName = '', onReconnect, getMailSessionId = () => ''}) { - // ─── 会话收窄参数(读类工具的公共前缀)─── +export function createMailTools({ client, log, agentName = '', onReconnect, + getMailSessionId = () => '', getWorkspace = () => '' }) { + // ─── 收窄参数(读类工具的公共前缀):两维,都必须由 worker 闭包递进来 ─── // - // 服务端拿它干两件事: - // ① 收件箱只列/只标本会话的邮件(缺了它,A 会话的 worker 会把 B 会话的未读标掉 - // ⇒ 补投再也看不到那封信 = 静默丢信。用户原话「不同 session 的 agent - // 都可以看到全部邮件」); + // ★★ 2026-09-26 补上 workspace 这一维,并订正下面那段注释。 + // + // ── 这段注释原来写着什么(以及为什么它是错的)── // ② **工作区隔离**:服务端由这条 session 反查 workspace,只有同工作区的会话才放行。 - // 一个 Agent 同时服务所有工作区,不带这一维时在 TrueAgent 里干活的 worker - // 能读到 agentmail 的整条线索(2026-09-14 用户报的那类越界)。 + // ……服务端不接受调用方直接声明工作区,那等于自己给自己发通行证。 // - // 取值只能是**邮件会话 id**,且必须由 worker 闭包递进来(模型改不了它)—— - // 服务端不接受调用方直接声明工作区,那等于自己给自己发通行证。 + // 前半句描述的能力**服务端从来没有过** —— `ListInboxScoped` 的 WHERE 里 + // 只有 `m.to_name = $1`(+ 可选的 session_id),没有任何 workspace 条件。 + // 我(写这段注释的人)把"设计意图"当成"已实现",于是插件侧也一直只传 session_id。 + // + // 后半句的理由**不成立**:session_id → workspace 这条反查在服务端确实可行, + // 但它只覆盖"这条线索属于哪个工作区",覆盖不了"**我这个 worker 在哪个工作区**"。 + // 两者的差别就是缺陷本身:见下。 + // + // ── 缺陷(用户 2026-09-26 当场指出,此前已提过多次)── + // 在 `mc` 工作区干活的 pi 读收件箱拿到 200 封,其中 **191 封属于 + // `/home/program/agentmail`** —— 它照着那些信里的断言去改 agentmail 的代码, + // 把手上 mc 的活丢在一边(用户当场问「你怎么干着干着修 agentmail 去了?」)。 + // + // ── 为什么 workspace 由**调用方声明**是对的(而不是"自己给自己发通行证")── + // 它是**过滤条件**,不是鉴权依据:声明错了只影响"我能看到什么", + // 越权不了别人的东西(服务端仍然只列 `to_name = 我` 的信)。 + // 与 session_id 同构。而且服务端没有别的办法知道它 —— 那是 worker 每回合的 cwd, + // 随回合变,服务端拿不到可靠来源。 + // + // ── 取值 ── + // workspace 取**信封上的 path 位**(`data.to_workspace`),不是会话文件 header 里的 + // cwd:后者是会话上次落在哪,前者是"这封信寄到哪个工作区"—— 收件箱要回答的是后者。 + // (且 mailTools 在 loadSession **之前**装配,那时也拿不到 cwd。) + // + // ★ 服务端要求它**必需**:缺了直接 400(用户裁定「不带 workspace 是错误发件格式, + // 直接退回!」)。所以这里不能再像原来那样"缺了就不带、让服务端走旧语义"—— + // 旧语义就是那个缺陷。 + const wsParam = () => { + const ws = typeof getWorkspace === 'function' ? getWorkspace() : ''; + return ws ? `workspace=${encodeURIComponent(ws)}` : ''; + }; const scopeQS = () => { const sid = typeof getMailSessionId === 'function' ? getMailSessionId() : ''; - return sid ? `session_id=${encodeURIComponent(sid)}` : ''; + const parts = []; + const ws = wsParam(); + if (ws) parts.push(ws); + if (sid) parts.push(`session_id=${encodeURIComponent(sid)}`); + return parts.join('&'); }; // 已经带了查询串的 URL 用这个(收件箱那条要 append 到 status/limit 后面) const inboxScope = () => { const q = scopeQS(); return q ? `&${q}` : ''; }; - // 任意路径用这个:自己判断该用 ? 还是 &。缺了 scope 就原样返回, - // 让服务端走"旧语义 + 记警告"那条路,而不是拼出一个半截 URL。 + // 任意路径用这个:自己判断该用 ? 还是 &。 + // + // ★ 不再有"缺了就原样返回让服务端走旧语义"那条路 —— 服务端现在对缺 workspace + // 的读请求直接 400。缺了就会 400,这正是我们要的:宁可失败得明确, + // 也不要静默看到别的工作区的信。 const withScope = (path) => { const q = scopeQS(); if (!q) return path; diff --git a/plugins/pi-mail-bridge/src/worker.mjs b/plugins/pi-mail-bridge/src/worker.mjs index 93ab272..8b0c194 100644 --- a/plugins/pi-mail-bridge/src/worker.mjs +++ b/plugins/pi-mail-bridge/src/worker.mjs @@ -103,7 +103,7 @@ let piSessionId = ''; // sessionID = **AgentMail 的邮件会话 id**(不是 pi 的 session id)。 // read_inbox 要靠它把自己那条会话的邮件与别的会话区分开 —— 少了它,A 会话的 // worker 会把 B 会话的未读一起列出来并标掉(2026-09-14 用户报的缺陷)。 -let mailContext = { replyTo: '', subject: '', mailID: '', sessionID: '', permissionMode: 'workspace' }; +let mailContext = { replyTo: '', subject: '', mailID: '', sessionID: '', permissionMode: 'workspace', workspace: '' }; let lastSyncedName = ''; let relayedKey = ''; let finished = false; @@ -558,6 +558,9 @@ async function run() { const mailTools = createMailTools({ // read_inbox 用它在服务端把列表收窄到自己这条会话。 getMailSessionId: () => mailContext.sessionID, + // ★ 收件箱的第二维收窄:**这封信寄到哪个工作区**(信封的 path 位)。 + // 缺了它,在 mc 干活的 pi 会看到 agentmail 的 191 封信并照着去改 agentmail。 + getWorkspace: () => mailContext.workspace, client, log, agentName: job.config.agentName, onReconnect: () => send({ type: 'reconfigure', url: client.baseURL, agentKey: client.agentKey, @@ -735,6 +738,9 @@ process.on('message', (msg) => { mailID: msg.data?.mail_id || '', sessionID: msg.data?.session_id || '', permissionMode: msg.data?.permission_mode || 'workspace', + /* ★ 信封上的 path 位 = 这封信寄到哪个工作区。读类工具用它收窄收件箱 + (缺了服务端会 400 —— 见 tools.mjs 里 createMailTools 那段注释)。 */ + workspace: msg.data?.to_workspace || '', }; lastSyncedName = msg.lastSyncedName || ''; for (const t of msg.grants || []) grants.add(t); diff --git a/plugins/pi-mail-bridge/test/inbox-workspace-scope.test.mjs b/plugins/pi-mail-bridge/test/inbox-workspace-scope.test.mjs new file mode 100644 index 0000000..9535ef5 --- /dev/null +++ b/plugins/pi-mail-bridge/test/inbox-workspace-scope.test.mjs @@ -0,0 +1,65 @@ +/** + * read_inbox 必须把列表收窄到**自己所在的工作区**(2026-09-26 用户报的缺陷)。 + * + * # 缺陷(用户当场指出,此前已提过多次) + * + * 在 `mc` 工作区干活的 pi 读收件箱拿到 200 封,其中 **191 封属于 + * `/home/program/agentmail`** —— 它照着那些信里的断言去改 agentmail 的代码, + * 把手上 mc 的活丢在一边。用户当场问「你怎么干着干着修 agentmail 去了?」 + * + * 三维地址是 `name@path.session`:**path 位本来就该参与寻址**,而收件箱侧此前 + * 完全没用它(`ListInboxScoped` 的 WHERE 里只有 `to_name`)。 + * + * # 与 inbox-session-scope 的分工(两条判据、两维收窄,别混) + * + * · `session_id` —— 只列**这条线索**的信。防的是"A 会话的 worker 标掉 B 会话的未读"。 + * · `workspace` —— 只列**这个工作区**的信。防的是"在 mc 干活却读到 agentmail 的信"。 + * + * 两维都要,缺一维就漏一类越界。这里只验**接线**;服务端语义 + * (读 A 工作区不动 B)由 `server/internal/repo/workspace_scope_test.go` 负责。 + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const tools = readFileSync(join(HERE, '..', 'src', 'tools.mjs'), 'utf8'); +const worker = readFileSync(join(HERE, '..', 'src', 'worker.mjs'), 'utf8'); +const index = readFileSync(join(HERE, '..', 'src', 'index.mjs'), 'utf8'); + +test('read_inbox 的收窄参数带上 workspace', () => { + assert.match(tools, /workspace=\$\{encodeURIComponent\(ws\)\}/, + 'scopeQS 要真的拼出 workspace=…(服务端缺它直接 400)'); + assert.match(tools, /const ws = wsParam\(\);/, + 'scopeQS 要消费 wsParam,而不是只留着函数不调'); +}); + +test('worker 把"这封信寄到哪个工作区"递给工具', () => { + assert.match(worker, /getWorkspace: \(\) => mailContext\.workspace/, + '要传闭包而不是快照(worker 每回合的 mailContext 会变)'); + assert.match(worker, /workspace: msg\.data\?\.to_workspace/, + '★ 取**信封的 path 位**(to_workspace),不是会话文件的 cwd —— ' + + '前者是"这封信寄到哪个工作区",后者是"会话上次落在哪"'); +}); + +test('补投逐工作区走,不再读一次全局收件箱', () => { + // 原来:client.get('/mail/inbox?status=unread&limit=20') —— 不带收窄, + // 于是桥会把所有工作区的漏投一起重放。 + assert.match(index, /pending_workspaces/, + '心跳要消费 pending_workspaces 清单'); + assert.match(index, /mail\/inbox\?status=unread&limit=20&workspace=\$\{encodeURIComponent\(ws\)\}/, + '每个工作区各读一次,且带上 workspace'); + assert.doesNotMatch(index, /client\.get\('\/mail\/inbox\?status=unread&limit=20'\)/, + '旧的"不带收窄读全局"那条路必须消失(它就是跨工作区重放的载体)'); +}); + +test('★ 判据自检:拿没带工作区收窄的旧写法喂进来必须判红', () => { + const oldScope = "const sid = typeof getMailSessionId === 'function' ? getMailSessionId() : '';\n return sid ? `session_id=${encodeURIComponent(sid)}` : '';"; + assert.equal(/workspace=\$\{encodeURIComponent\(ws\)\}/.test(oldScope), false); + + const oldCatchup = "const box = await client.get('/mail/inbox?status=unread&limit=20');"; + assert.match(oldCatchup, /client\.get\('\/mail\/inbox\?status=unread&limit=20'\)/, + '自检:旧写法确实能被那条 doesNotMatch 抓到'); +}); diff --git a/server/internal/handler/agents.go b/server/internal/handler/agents.go index 5842901..81d1772 100644 --- a/server/internal/handler/agents.go +++ b/server/internal/handler/agents.go @@ -25,6 +25,11 @@ type registerRequest struct { // // 字段全可省:旧插件发空心跳,不能因为新增了上报就把它们报错。 type heartbeatRequest struct { + // Workspace 是调用方当前所处的工作区(绝对路径)——**必需**。 + // + // 心跳返回的 pending_mails 按它算:那是桥的补投判据,混了别的工作区 + // 就会让桥反复重放(见 handler 里那段说明)。 + Workspace string `json:"workspace"` // PlatformSessions 是平台侧当前的会话快照(按最近活跃排序)。 // // 为什么让插件上报而不是 Gateway 反向拉取:当前架构是单向的 @@ -170,17 +175,29 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) { return } + /* + ★ pending_mails 是**全局**未读数(跨工作区),而收件箱接口是 worker 级 + (要求 workspace)。两者不矛盾,是不同层的东西 —— 见 UnreadWorkspaces 注释。 + + 但只有总数,桥不知道去**哪个**工作区补投(它此前调的是不带收窄的 + `/mail/inbox`,于是会跨工作区重放)。⇒ 一并给出有未读的工作区清单, + 桥逐个去读。这也是"心跳返回什么才够用"的答案。 + */ + pendingWorkspaces, _ := repo.UnreadWorkspaces(r.Context(), agentName) + + // 宽容解码(心跳是全站唯一一处容忍未知字段的端点,理由见下面的说明)。 + var req heartbeatRequest + var unknownFields []string + if r.ContentLength > 0 { + unknownFields, _ = DecodeLenient(r, &req) + } + // 可选的平台会话快照。解不开就当作没带:心跳的主职责是「我还活着」, // 不该因为上报体格式不对就把 Agent 判成离线。 // // 但**未知字段必须回报**(resp["unknown_fields"]):这是全站唯一一处宽容 // 解码的端点,若还静默忽略,插件把 `models` 拼成 `modles` 就永远没人知道 —— // 而那与 `attachments` vs `attachment_ids` 是同一种事故形状。 - var req heartbeatRequest - var unknownFields []string - if r.ContentLength > 0 { - unknownFields, _ = DecodeLenient(r, &req) - } syncedSessions := -1 // -1 = 本次未上报 if req.PlatformSessions != nil { if err := repo.ReplacePlatformSessions(r.Context(), agentName, req.PlatformSessions); err != nil { @@ -216,9 +233,10 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) { } resp := map[string]interface{}{ - "status": "ok", - "pending_mails": pending, - "stats": stats, + "pending_workspaces": pendingWorkspaces, + "status": "ok", + "pending_mails": pending, + "stats": stats, } if syncedSessions >= 0 { resp["platform_sessions_synced"] = syncedSessions diff --git a/server/internal/handler/mail.go b/server/internal/handler/mail.go index a8bb116..e3121a9 100644 --- a/server/internal/handler/mail.go +++ b/server/internal/handler/mail.go @@ -583,6 +583,35 @@ func GetInbox(w http.ResponseWriter, r *http.Request) { } } + /* + ★ workspace **必需**(用户裁定:「不带 workspace 是错误发件格式,直接退回!」)。 + + # 为什么是 400 而不是"退回旧语义" + + 旧语义(不带 = 列该 Agent 全部)正是缺陷本身:`pi` 在 mc 工作区读收件箱 + 拿到 200 封,191 封属于 agentmail 工作区 ⇒ 它照着那些信改 agentmail 的代码。 + 留着旧语义当兜底,等于给这个缺陷留一条随时会被走上去的路。 + + 三维地址 `name@path.session` 的 path 位本来就该参与寻址 —— 收件箱侧此前 + 完全没用它。现在要求调用方说清自己在哪个工作区,服务端据此收窄。 + + ★ 为什么服务端**不该**自己去推调用方的工作区: + 它是每个 worker 的 cwd(会随回合变),服务端拿不到可靠来源。 + 让调用方声明、服务端只当过滤条件用(不做鉴权依据)—— + 与 session_id 那条同构:声明错了只影响自己看到什么,越权不了别人。 + */ + workspace := strings.TrimSpace(r.URL.Query().Get("workspace")) + if workspace == "" { + Error(w, http.StatusBadRequest, + "缺少 workspace:收件箱按工作区收窄(三维地址 name@path.session 的 path 位)。"+ + "请带上你所处工作区的绝对路径,例如 &workspace=/home/program/agentmail") + return + } + if !strings.HasPrefix(workspace, "/") { + Error(w, http.StatusBadRequest, "workspace 必须是绝对路径,收到: "+workspace) + return + } + // 可选会话收窄:桥的 read_inbox 会带上自己那条会话。 // // 不带 = 整个 Agent 的收件箱(旧语义,浏览器/脚本仍可用);带了就只列这条线索 —— @@ -598,7 +627,7 @@ func GetInbox(w http.ResponseWriter, r *http.Request) { sessionID = id } - mails, err := repo.ListInboxScoped(r.Context(), agentName, status, limit, sessionID) + mails, err := repo.ListInboxScoped(r.Context(), agentName, status, workspace, limit, sessionID) if err != nil { Error(w, http.StatusInternalServerError, "Failed to list inbox") return @@ -609,7 +638,9 @@ func GetInbox(w http.ResponseWriter, r *http.Request) { ptrs[i] = &mails[i] } fillAttachments(r, ptrs...) - total, _ := repo.CountUnread(r.Context(), agentName) + // ★ total 必须用**同一个收窄口径**,否则列表与计数自相矛盾 + // (桥的补投判据就是 pending_mails = 这个数)。 + total, _ := repo.CountUnreadScoped(r.Context(), agentName, workspace, sessionID) JSON(w, http.StatusOK, map[string]interface{}{ "mails": emptySlice(mails), @@ -698,6 +729,10 @@ func parseInt(s string) (int, error) { type markReadRequest struct { // SessionID 可选:带了就只在这条会话内标(见 GetInbox 里那段说明)。 SessionID string `json:"session_id"` + // Workspace **必需**(仅"全部标掉"那条路):只标这个工作区的信。 + // 带上 mail_ids 时不要求 —— 那些 id 是调用方从**已收窄的**列表里原样拿回来的, + // 服务端按 id 逐个判归属即可(MarkMailsReadFor 只标真发给它的)。 + Workspace string `json:"workspace"` // MailIDs 要标记为已读的邮件;省略/为空 = 把收件箱里全部未读标掉。 MailIDs []string `json:"mail_ids"` } @@ -728,8 +763,26 @@ func MarkInboxRead(w http.ResponseWriter, r *http.Request) { // 不给 id 就把收件箱里全部未读标掉。 // 这是 Agent 最常见的用法:一轮处理完,剩下的都不必再看。 if len(req.MailIDs) == 0 { + /* + ★ workspace **必需**,与 GET /mail/inbox 同一条规矩。 + + 这条路口径更宽("全部标掉"),缺了工作区会把**别的工作区的未读也标掉** —— + 而"被别人标掉"正是那封静默丢信的旧缺陷(read_inbox 按会话收窄那次修的 + 就是它,见 session_scope_test.go)。收窄到工作区,同一类错误就没法再发生。 + */ + ws := strings.TrimSpace(req.Workspace) + if ws == "" { + Error(w, http.StatusBadRequest, + "缺少 workspace:全部标已读必须指明工作区(否则会标掉别的工作区的未读)。"+ + "若是按 id 标记,请直接传 mail_ids") + return + } + if !strings.HasPrefix(ws, "/") { + Error(w, http.StatusBadRequest, "workspace 必须是绝对路径,收到: "+ws) + return + } var sessionID uuid.UUID - scope := "all" + scope := "workspace" if raw := strings.TrimSpace(req.SessionID); raw != "" { id, perr := uuid.Parse(raw) if perr != nil { @@ -738,7 +791,7 @@ func MarkInboxRead(w http.ResponseWriter, r *http.Request) { } sessionID, scope = id, "session" } - n, err := repo.MarkAllInboxReadForSession(r.Context(), agentName, sessionID) + n, err := repo.MarkAllInboxReadForSession(r.Context(), agentName, ws, sessionID) if err != nil { Error(w, http.StatusInternalServerError, "Failed to mark read") return diff --git a/server/internal/handler/me.go b/server/internal/handler/me.go index ad740c3..c5caa5e 100644 --- a/server/internal/handler/me.go +++ b/server/internal/handler/me.go @@ -218,7 +218,7 @@ func MeGetInbox(w http.ResponseWriter, r *http.Request) { } } - mails, err := repo.ListInbox(r.Context(), user.Username, status, limit) + mails, err := repo.ListInbox(r.Context(), user.Username, status, "", limit) if err != nil { Error(w, http.StatusInternalServerError, "Failed to list inbox") return @@ -229,7 +229,7 @@ func MeGetInbox(w http.ResponseWriter, r *http.Request) { ptrs[i] = &mails[i] } fillAttachments(r, ptrs...) - total, _ := repo.CountUnread(r.Context(), user.Username) + total, _ := repo.CountUnread(r.Context(), user.Username, "") JSON(w, http.StatusOK, map[string]interface{}{ "mails": emptySlice(mails), diff --git a/server/internal/handler/permission.go b/server/internal/handler/permission.go index cee7a66..a0ebe8d 100644 --- a/server/internal/handler/permission.go +++ b/server/internal/handler/permission.go @@ -394,6 +394,17 @@ func DecidePermission(w http.ResponseWriter, r *http.Request) { // 那条路径要靠这个字段找到原会话,否则会凭空另开一个。 "session_id": perm.SessionID.String(), } + /* + ★ workspace:插件用它去收窄"等人期间新到的邮件"(那是一次 /mail/inbox 读, + 而收件箱接口要求 workspace,缺了 400)。 + + 为什么服务端给而不是让插件推:插件重启后待决映射丢光(上面那段说的), + 此时它手上只有这个事件 —— 从事件里推不出工作区。而服务端**知道** + (`perm.SessionID` → `sessions.workspace`),一处给对,三平台都能用。 + */ + if ws := repo.SessionWorkspaceOf(r.Context(), perm.SessionID); ws != "" { + payload["workspace"] = ws + } if key, kind := repo.RelayKeyForMail(r.Context(), mailID); key != "" { payload["relay_key"] = key payload["relay_kind"] = kind diff --git a/server/internal/middleware/auth.go b/server/internal/middleware/auth.go index 244ffdf..83edab4 100644 --- a/server/internal/middleware/auth.go +++ b/server/internal/middleware/auth.go @@ -64,7 +64,7 @@ func AgentAuth(next http.Handler) http.Handler { http.Error(w, `{"error":"密钥尚未绑定 Agent,请先调用 /agent/register 完成注册"}`, http.StatusForbidden) return } - repo.HeartbeatAgent(r.Context(), agentName) + repo.TouchAgentLastSeen(r.Context(), agentName) ctx := context.WithValue(r.Context(), AgentNameKey, agentName) next.ServeHTTP(w, r.WithContext(ctx)) return @@ -83,7 +83,7 @@ func AgentAuth(next http.Handler) http.Handler { return } - repo.HeartbeatAgent(r.Context(), agent.Name) + repo.TouchAgentLastSeen(r.Context(), agent.Name) ctx := context.WithValue(r.Context(), AgentNameKey, agent.Name) next.ServeHTTP(w, r.WithContext(ctx)) }) diff --git a/server/internal/repo/agent_disable_test.go b/server/internal/repo/agent_disable_test.go index d4f567a..cda7513 100644 --- a/server/internal/repo/agent_disable_test.go +++ b/server/internal/repo/agent_disable_test.go @@ -205,7 +205,7 @@ func TestSetAgentDisabledKeepsMailAndSessions(t *testing.T) { t.Fatalf("停用: %v", err) } - mails, err := ListInbox(ctx, "bot", "all", 10) + mails, err := ListInbox(ctx, "bot", "all", "", 10) if err != nil { t.Fatalf("ListInbox: %v", err) } diff --git a/server/internal/repo/mail_status_derived_test.go b/server/internal/repo/mail_status_derived_test.go index 4cfafb0..44e417d 100644 --- a/server/internal/repo/mail_status_derived_test.go +++ b/server/internal/repo/mail_status_derived_test.go @@ -49,7 +49,7 @@ func measureMailStatusDebt(t *testing.T) (outstanding bool, detail, derived stri if err != nil { t.Fatal(err) } - list, err := ListInbox(ctx, "alice", "all", 50) + list, err := ListInbox(ctx, "alice", "all", "", 50) if err != nil { t.Fatal(err) } diff --git a/server/internal/repo/markread_test.go b/server/internal/repo/markread_test.go index 7a287bc..898589e 100644 --- a/server/internal/repo/markread_test.go +++ b/server/internal/repo/markread_test.go @@ -125,7 +125,7 @@ func TestMarkAllInboxReadForSkipsArchivedAndOthers(t *testing.T) { db.DB.QueryRowContext(ctx, `SELECT session_id FROM mails WHERE mail_id = $1`, b).Scan(&sid) db.DB.ExecContext(ctx, `UPDATE sessions SET status = 'archived' WHERE session_id = $1`, sid) - n, err := MarkAllInboxReadFor(ctx, "bot") + n, err := MarkAllInboxReadFor(ctx, "bot", "") if err != nil { t.Fatal(err) } diff --git a/server/internal/repo/permission_deadline_test.go b/server/internal/repo/permission_deadline_test.go index c482364..aac212d 100644 --- a/server/internal/repo/permission_deadline_test.go +++ b/server/internal/repo/permission_deadline_test.go @@ -80,7 +80,7 @@ func TestPermissionDeadlineVisibleOnEveryReadPath(t *testing.T) { assertPending("GetMailByID", got) // 2) ListInbox —— 授权页列表的来源。 - inbox, err := ListInbox(ctx, "alice", "all", 50) + inbox, err := ListInbox(ctx, "alice", "all", "", 50) if err != nil { t.Fatal(err) } diff --git a/server/internal/repo/permission_visibility_test.go b/server/internal/repo/permission_visibility_test.go index f1fb8f2..d169e70 100644 --- a/server/internal/repo/permission_visibility_test.go +++ b/server/internal/repo/permission_visibility_test.go @@ -78,7 +78,7 @@ func TestPermissionFieldsVisibleOnEveryReadPath(t *testing.T) { assertQuestion("GetMailByID", got) // 2) ListInbox —— 授权页的列表来源。 - inbox, err := ListInbox(ctx, "alice", "all", 50) + inbox, err := ListInbox(ctx, "alice", "all", "", 50) if err != nil { t.Fatal(err) } diff --git a/server/internal/repo/readstate_test.go b/server/internal/repo/readstate_test.go index cf2ad3d..5be5ed2 100644 --- a/server/internal/repo/readstate_test.go +++ b/server/internal/repo/readstate_test.go @@ -23,7 +23,7 @@ import ( // unreadList 返回 ListInbox 在 unread 过滤下给出的邮件 id。 func unreadList(t *testing.T, reader string) []uuid.UUID { t.Helper() - mails, err := ListInbox(context.Background(), reader, "unread", 50) + mails, err := ListInbox(context.Background(), reader, "unread", "", 50) if err != nil { t.Fatal(err) } @@ -46,7 +46,7 @@ func hasID(list []uuid.UUID, id uuid.UUID) bool { // statusSeenBy 返回 ListInbox 在 all 过滤下、某个读者看到的那封邮件的 status。 func statusSeenBy(t *testing.T, reader string, id uuid.UUID) string { t.Helper() - mails, err := ListInbox(context.Background(), reader, "all", 50) + mails, err := ListInbox(context.Background(), reader, "all", "", 50) if err != nil { t.Fatal(err) } @@ -67,10 +67,10 @@ func TestReadStateIsPerRecipient(t *testing.T) { id := seedMailTo(t, "alice", "bob") // 初始:两边都未读 - if n, _ := CountUnread(ctx, "alice"); n != 1 { + if n, _ := CountUnread(ctx, "alice", ""); n != 1 { t.Fatalf("alice 初始未读 = %d,期望 1", n) } - if n, _ := CountUnread(ctx, "bob"); n != 1 { + if n, _ := CountUnread(ctx, "bob", ""); n != 1 { t.Fatalf("bob 初始未读 = %d,期望 1", n) } @@ -80,10 +80,10 @@ func TestReadStateIsPerRecipient(t *testing.T) { } // ★ 核心判据:alice 的未读清了,bob 的**必须还在** - if n, _ := CountUnread(ctx, "alice"); n != 0 { + if n, _ := CountUnread(ctx, "alice", ""); n != 0 { t.Fatalf("alice 读过后未读 = %d,期望 0", n) } - if n, _ := CountUnread(ctx, "bob"); n != 1 { + if n, _ := CountUnread(ctx, "bob", ""); n != 1 { t.Fatalf("★ bob 没读,未读却变成了 %d —— 这正是要修的缺陷(任一收件人读掉,所有人变已读)", n) } if !hasID(unreadList(t, "bob"), id) { @@ -105,7 +105,7 @@ func TestReadStateIsPerRecipient(t *testing.T) { if err := MarkMailRead(ctx, id, "bob"); err != nil { t.Fatal(err) } - if n, _ := CountUnread(ctx, "bob"); n != 0 { + if n, _ := CountUnread(ctx, "bob", ""); n != 0 { t.Fatalf("bob 读过后未读 = %d,期望 0", n) } } @@ -141,15 +141,15 @@ func TestBulkMarkReadOnlyAffectsCaller(t *testing.T) { if _, err := MarkMailsReadFor(ctx, "alice", []uuid.UUID{id}); err != nil { t.Fatal(err) } - if n, _ := CountUnread(ctx, "bob"); n != 1 { + if n, _ := CountUnread(ctx, "bob", ""); n != 1 { t.Fatalf("★ 批量标已读后 bob 未读 = %d,期望 1", n) } // 全部标已读(不带 id)也只能标自己收件箱里的 - if _, err := MarkAllInboxReadFor(ctx, "alice"); err != nil { + if _, err := MarkAllInboxReadFor(ctx, "alice", ""); err != nil { t.Fatal(err) } - if n, _ := CountUnread(ctx, "bob"); n != 1 { + if n, _ := CountUnread(ctx, "bob", ""); n != 1 { t.Fatalf("★ 全标已读后 bob 未读 = %d,期望 1", n) } } @@ -169,7 +169,7 @@ func TestArchivedStaysArchivedForEveryone(t *testing.T) { t.Fatal(err) } - if n, _ := CountUnread(ctx, "bob"); n != 0 { + if n, _ := CountUnread(ctx, "bob", ""); n != 0 { t.Fatalf("归档会话里的邮件不该计入未读,实际 %d", n) } if hasID(unreadList(t, "bob"), id) { @@ -177,7 +177,7 @@ func TestArchivedStaysArchivedForEveryone(t *testing.T) { } // ListInbox 会整体排除归档会话(`s.status <> 'archived'`),所以它在 // `all` 里也不出现 —— 归档是"从收件箱拿走",不是"标成已读"。 - mails, err := ListInbox(ctx, "bob", "all", 50) + mails, err := ListInbox(ctx, "bob", "all", "", 50) if err != nil { t.Fatal(err) } @@ -204,10 +204,10 @@ func TestPermissionDecisionMarksOnlyDecider(t *testing.T) { if _, err := DecidePermission(ctx, id, "alice", "同意"); err != nil { t.Fatal(err) } - if n, _ := CountUnread(ctx, "alice"); n != 0 { + if n, _ := CountUnread(ctx, "alice", ""); n != 0 { t.Fatalf("决策人 alice 未读 = %d,期望 0", n) } - if n, _ := CountUnread(ctx, "bob"); n != 1 { + if n, _ := CountUnread(ctx, "bob", ""); n != 1 { t.Fatalf("★ bob 未读 = %d,期望 1(决策是 alice 做的,不该替他标记已读)", n) } } @@ -286,10 +286,10 @@ func TestReadSideRejectsEmptyReader(t *testing.T) { seedMailTo(t, "alice", "") for _, empty := range []string{"", " "} { - if n, err := CountUnread(ctx, empty); err == nil { + if n, err := CountUnread(ctx, empty, ""); err == nil { t.Fatalf("CountUnread(%q) 必须报错,实际返回 %d —— 空读者会静默算出错误的未读数", empty, n) } - if _, err := ListInbox(ctx, empty, "all", 50); err == nil { + if _, err := ListInbox(ctx, empty, "all", "", 50); err == nil { t.Fatalf("ListInbox(%q) 必须报错 —— 空读者是过滤条件,不能兜底", empty) } if n, err := CountUnreadInSession(ctx, empty, uuid.Nil); err == nil { @@ -298,7 +298,7 @@ func TestReadSideRejectsEmptyReader(t *testing.T) { } // 正例:正常读者照常工作(别把守卫写成"一律拒绝") - if n, err := CountUnread(ctx, "alice"); err != nil || n != 1 { + if n, err := CountUnread(ctx, "alice", ""); err != nil || n != 1 { t.Fatalf("正常读者 alice 的未读数应为 1,实际 %d(err=%v)", n, err) } } diff --git a/server/internal/repo/repo.go b/server/internal/repo/repo.go index a3b52f7..d549b0d 100644 --- a/server/internal/repo/repo.go +++ b/server/internal/repo/repo.go @@ -55,7 +55,73 @@ func CreateOrUpdateAgent(ctx context.Context, name, secret, platform string, wor return err } -func HeartbeatAgent(ctx context.Context, agentName string) (int, error) { +/* +UnreadWorkspaces 列出"这个 Agent 在哪些工作区有未读"。 + +# 为什么要它,而不是让心跳收一个 workspace 参数 + +心跳是**进程级**的(一个桥进程同时服务所有工作区),而收件箱是**worker 级**的 +(每个 worker 手上只有一封信,信封上有明确的 path 位)。在进程级强制要求 +workspace 是概念错配 —— 它没有一个"我的工作区"可言。 + +但 `pending_mails` 是桥的补投判据:若它是一个跨工作区的总数,桥就不知道该去 +**哪个工作区**补投。⇒ 心跳返回这个清单,桥逐个工作区去读(见 catchUp)。 + +这同时修掉一个隐蔽问题:补投原先调 `/mail/inbox?status=unread`(不带收窄), +按当时的语义会列出**所有工作区**的未读并逐封重放 —— 在 mc 干活时会去补投 +agentmail 的信。 +*/ +func UnreadWorkspaces(ctx context.Context, agentName string) ([]string, error) { + if err := requireReader(agentName); err != nil { + return nil, err + } + rows, err := db.DB.QueryContext(ctx, ` + SELECT s.workspace, COUNT(*) AS n + FROM mails m + JOIN sessions s ON m.session_id = s.session_id + WHERE (m.to_name = $1 OR `+db.CCHas("m.cc_list", 1)+`) + AND `+unreadFor("$1")+` + AND s.status <> 'archived' + -- ★ 只报**可寻址**的工作区(绝对路径)。 + -- + -- 历史库里存在 workspace 为相对路径的行(早期以 "pi@root" 寻址留下的 + -- 测试会话)。收件箱接口要求绝对路径,把它们放进清单只会在桥侧撞 400 + -- —— 我实测就撞到了:"补投工作区 root 失败: HTTP 400"。 + -- + -- 过滤放在这里而不是让桥去试错:这个清单的语义是"**能去补投**的工作区", + -- 列出不可寻址的等于给调用方递一个注定失败的任务。 + AND s.workspace LIKE '/%' + GROUP BY s.workspace + ORDER BY n DESC`, agentName) + if err != nil { + return nil, err + } + defer rows.Close() + out := []string{} + for rows.Next() { + var ws string + var n int + if err := rows.Scan(&ws, &n); err != nil { + return nil, err + } + out = append(out, ws) + } + return out, rows.Err() +} + +/* +TouchAgentLastSeen 只刷新"我还活着",**不**算未读数。 + +# 为什么要把它拆出来 + +原先 middleware 在**每一个**已认证请求上都调 HeartbeatAgent(它会顺手算 +`CountUnread` 并丢掉返回值)—— 那是白算一次全表扫描,而且现在 `CountUnread` +还需要工作区,而 middleware 那一层拿不到(请求体还没解析)。 + +⇒ 拆成两件事:心跳副作用(只更新 last_seen)留在 middleware; +"这个工作区还有多少未读"由心跳 handler 按请求体里的 workspace 算。 +*/ +func TouchAgentLastSeen(ctx context.Context, agentName string) error { // 只把【非停用】的 Agent 标成在线。 // // 不加这个条件的话,停用后插件的心跳会把 status 从 disabled 改回 online @@ -64,10 +130,21 @@ func HeartbeatAgent(ctx context.Context, agentName string) (int, error) { `UPDATE agents SET last_seen = NOW(), status = 'online' WHERE agent_name = $1 AND status <> 'disabled'`, agentName) - if err != nil { + return err +} + +// HeartbeatAgent 刷新在线状态并返回**该 Agent 的未读总数**。 +// +// ★ 这里是**全局**口径(跨工作区),与 ListInboxScoped 不同 —— 原因见 +// +// UnreadWorkspaces 上面那段:心跳是进程级,收件箱是 worker 级。 +// 桥拿到这个总数后,用 `pending_workspaces` 清单逐工作区去补投, +// 两边合起来才是"有没有信、在哪个工作区"。 +func HeartbeatAgent(ctx context.Context, agentName string) (int, error) { + if err := TouchAgentLastSeen(ctx, agentName); err != nil { return 0, err } - return CountUnread(ctx, agentName) + return CountUnread(ctx, agentName, "") } // AgentDisabled 该 Agent 是否已被停用。Agent 不存在时返回 false —— @@ -504,6 +581,44 @@ func requireReader(reader string) error { return nil } +/* +checkWorkspace 校验 workspace 的形状(**允许为空**)。 + +# 空与非空各是什么语义(两条不同的入口,别混) + + · **非空** = 只列该工作区的信。**Agent 侧必须非空**(Handler 层强制)。 + · **空** = 不过滤,列该名字的全部。**人类侧就是这个**:一个人跨工作区, + WebUI 把结果按 `session_workspace` **分组显示**(`MailList.tsx:167`), + 而不是只给一个工作区 —— 强行让人也带工作区,等于让人在多个工作区之间反复切。 + +⇒「必需」这条约束放在 **Handler**(`GetInbox` / `MarkInboxRead` / `HeartbeatAgent`), + + 不是这里:它是**接口契约**,不是数据层不变量。放 repo 会让人类那条路也没法用, + 而人类侧并没有"我处在哪个工作区"这个概念。 + +# 为什么 Agent 必须带工作区 + +Agent 的收件箱原先只按名字过滤(`WHERE m.to_name = $1`),于是 `pi` 这个名字下 +**所有工作区**的信混成一个池子。生产实测:在 `mc` 工作区干活的 pi 读收件箱拿到 +200 封,其中 191 封属于 `/home/program/agentmail` —— 它照着那些信里的断言去改 +agentmail 的代码,把手上 mc 的活丢在一边(用户当场问「你怎么干着干着修 +agentmail 去了?」)。 + +三维地址是 `name@path.session` —— **path 位本来就该参与寻址**。收件箱侧此前 +完全没用它,那不是"另一种语义",是没兑现契约。 +*/ +func checkWorkspace(workspace string) error { + w := strings.TrimSpace(workspace) + if w == "" { + return nil // 人类侧:不过滤,由 WebUI 按工作区分组显示 + } + // 绝对路径:相对路径在服务端无法解释,且不同调用方 cwd 不同 ⇒ 拼出来必然对不上。 + if !strings.HasPrefix(w, "/") { + return fmt.Errorf("workspace 必须是绝对路径,收到 %q", w) + } + return nil +} + // unreadFor 返回"$n 这个读者看这封邮件是未读"的谓词;`m` 必须是 mails 的别名。 func unreadFor(arg string) string { return `(m.status <> 'archived' AND NOT EXISTS ( @@ -595,15 +710,23 @@ func MarkMailRead(ctx context.Context, id uuid.UUID, reader string) error { 原函数保持原语义(不带会话 = 整个 Agent 的收件箱),新增带会话的变体 —— 老调用点一个都不用改。 */ -func ListInbox(ctx context.Context, agentName, status string, limit int) ([]models.Mail, error) { - return ListInboxScoped(ctx, agentName, status, limit, uuid.Nil) +func ListInbox(ctx context.Context, agentName, status, workspace string, limit int) ([]models.Mail, error) { + return ListInboxScoped(ctx, agentName, status, workspace, limit, uuid.Nil) } // ListInboxScoped 与 ListInbox 相同,但 `sessionID` 非零时只列该会话的邮件。 -func ListInboxScoped(ctx context.Context, agentName, status string, limit int, sessionID uuid.UUID) ([]models.Mail, error) { +// +// `workspace` 非空时收窄到该工作区(Agent 侧 Handler 强制必填;人类侧为空):只列属于该工作区的会话里的信。 +// 判据用 `s.workspace`(会话的权威工作区)而不是 `m.to_workspace`: +// 后者是**这封信**的信封字段,可能是抄送、可能是历史遗留;而"这条线索属于哪个 +// 工作区"是会话的属性,一处定死才不会两种答案。 +func ListInboxScoped(ctx context.Context, agentName, status, workspace string, limit int, sessionID uuid.UUID) ([]models.Mail, error) { if err := requireReader(agentName); err != nil { return nil, err } + if err := checkWorkspace(workspace); err != nil { + return nil, err + } q := `SELECT m.mail_id, m.session_id, m.parent_mail_id, m.from_name, m.from_workspace, m.to_name, m.to_workspace, m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result, @@ -618,7 +741,15 @@ func ListInboxScoped(ctx context.Context, agentName, status string, limit int, s JOIN sessions s ON m.session_id = s.session_id WHERE (m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `) AND s.status <> 'archived'` + // ★ 工作区收窄(必需):`pi` 在两个工作区各有一条收件箱,互不可见。 + // + // 用 EXISTS 而不是再 JOIN 一次 sessions:s 已经在上面 JOIN 过了, + // 这里直接把条件写进 WHERE 即可(同一条 s)。 args := []any{agentName} + if strings.TrimSpace(workspace) != "" { + args = append(args, workspace) + q += fmt.Sprintf(` AND s.workspace = $%d`, len(args)) + } if sessionID != uuid.Nil { // 会话收窄:只列这条线索里的邮件(见上面「会话维度」的说明) args = append(args, sessionID) @@ -633,7 +764,8 @@ func ListInboxScoped(ctx context.Context, agentName, status string, limit int, s } else if status == "read" { q += ` AND NOT ` + unreadFor("$1") + ` AND m.status <> 'archived'` } else { - q += ` AND m.status = $2` + // ★ 占位符必须是**动态序号**:$2 现在被 workspace 占了。 + q += fmt.Sprintf(` AND m.status = $%d`, len(args)+1) args = append(args, status) } } @@ -685,19 +817,41 @@ func ListInboxScoped(ctx context.Context, agentName, status string, limit int, s return mails, rows.Err() } -func CountUnread(ctx context.Context, agentName string) (int, error) { +func CountUnread(ctx context.Context, agentName, workspace string) (int, error) { + return CountUnreadScoped(ctx, agentName, workspace, uuid.Nil) +} + +// CountUnreadScoped 与 CountUnread 相同,但 `sessionID` 非零时只数那条会话。 +// +// ★ 必须与 ListInboxScoped **同一个收窄口径**:桥的补投判据是 +// +// `pending_mails = CountUnread` —— 两者口径不一致时,列表看不到的信会一直 +// 被算成"还有未读",桥每次心跳都重放一遍(这是设计文档里记过的那个坑)。 +func CountUnreadScoped(ctx context.Context, agentName, workspace string, sessionID uuid.UUID) (int, error) { if err := requireReader(agentName); err != nil { return 0, err } + if err := checkWorkspace(workspace); err != nil { + return 0, err + } var count int - err := db.DB.QueryRowContext(ctx, ` + q := ` SELECT COUNT(*) FROM mails m JOIN sessions s ON m.session_id = s.session_id - WHERE (m.to_name = $1 OR `+db.CCHas("m.cc_list", 1)+`) - AND `+unreadFor("$1")+` - AND s.status <> 'archived' - `, agentName).Scan(&count) + WHERE (m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `) + AND ` + unreadFor("$1") + ` + AND s.status <> 'archived'` + args := []any{agentName} + if strings.TrimSpace(workspace) != "" { + args = append(args, workspace) + q += fmt.Sprintf(` AND s.workspace = $%d`, len(args)) + } + if sessionID != uuid.Nil { + args = append(args, sessionID) + q += fmt.Sprintf(` AND m.session_id = $%d`, len(args)) + } + err := db.DB.QueryRowContext(ctx, q, args...).Scan(&count) return count, err } @@ -1822,21 +1976,31 @@ func MarkMailsReadFor(ctx context.Context, recipient string, ids []uuid.UUID) (i // // 排除已归档会话:那些邮件在收件箱里根本看不到, // 标掉它们只会让「标记了 N 封」这个数字与用户看到的对不上。 -func MarkAllInboxReadFor(ctx context.Context, recipient string) (int, error) { - return MarkAllInboxReadForSession(ctx, recipient, uuid.Nil) +func MarkAllInboxReadFor(ctx context.Context, recipient, workspace string) (int, error) { + return MarkAllInboxReadForSession(ctx, recipient, workspace, uuid.Nil) } // MarkAllInboxReadForSession 只标掉某条会话里发给 recipient 的未读。 // // 为什么需要:Agent 的「不给 mail_ids,全部标掉」在会话驱动的 worker 里会跨会话 // 误伤(见 ListInboxScoped 上面那段说明)。不带 sessionID(uuid.Nil)时是旧语义。 -func MarkAllInboxReadForSession(ctx context.Context, recipient string, sessionID uuid.UUID) (int, error) { - scope := `(m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `) - AND m.session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived')` +func MarkAllInboxReadForSession(ctx context.Context, recipient, workspace string, sessionID uuid.UUID) (int, error) { + if err := checkWorkspace(workspace); err != nil { + return 0, err + } + // ★ 工作区收窄:与 ListInboxScoped **同一个谓词** —— 列表看不到的信却被 + // "全部标掉"标掉,就是静默丢信(session_scope_test.go 记过这个形状)。 + wsFilter := "" args := []any{recipient} + if strings.TrimSpace(workspace) != "" { + args = append(args, workspace) + wsFilter = fmt.Sprintf(" AND workspace = $%d", len(args)) + } + scope := `(m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `) + AND m.session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived'` + wsFilter + `)` if sessionID != uuid.Nil { - scope += ` AND m.session_id = $2` args = append(args, sessionID) + scope += fmt.Sprintf(` AND m.session_id = $%d`, len(args)) } if err := markReadFor(ctx, recipient, scope, args...); err != nil { return 0, err @@ -1847,11 +2011,14 @@ func MarkAllInboxReadForSession(ctx context.Context, recipient string, sessionID upd := `UPDATE mails SET status = 'read' WHERE status = 'unread' AND (to_name = $1 OR ` + db.CCHas("cc_list", 1) + `) - AND session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived')` + AND session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived'` + wsFilter + `)` updArgs := []any{recipient} + if strings.TrimSpace(workspace) != "" { + updArgs = append(updArgs, workspace) + } if sessionID != uuid.Nil { - upd += ` AND session_id = $2` updArgs = append(updArgs, sessionID) + upd += fmt.Sprintf(` AND session_id = $%d`, len(updArgs)) } res, err := db.DB.ExecContext(ctx, upd, updArgs...) if err != nil { diff --git a/server/internal/repo/session_scope_test.go b/server/internal/repo/session_scope_test.go index 0b9f485..ab27a43 100644 --- a/server/internal/repo/session_scope_test.go +++ b/server/internal/repo/session_scope_test.go @@ -62,7 +62,7 @@ func TestInboxListIsScopedBySession(t *testing.T) { s1, s2, m1, m2 := twoSessionsWithUnread(t, "bot") // 反向对照:不带会话 = 整个 Agent 的收件箱,两条都看得到 - all, err := ListInbox(ctx, "bot", "unread", 50) + all, err := ListInbox(ctx, "bot", "unread", "", 50) if err != nil { t.Fatal(err) } @@ -71,7 +71,7 @@ func TestInboxListIsScopedBySession(t *testing.T) { } // ★ 收窄到会话一:只有它那条 - scoped, err := ListInboxScoped(ctx, "bot", "unread", 50, s1) + scoped, err := ListInboxScoped(ctx, "bot", "unread", "", 50, s1) if err != nil { t.Fatal(err) } @@ -83,7 +83,7 @@ func TestInboxListIsScopedBySession(t *testing.T) { } // 会话二同理(别只验一侧 —— 方向反了也能"过") - scoped2, err := ListInboxScoped(ctx, "bot", "unread", 50, s2) + scoped2, err := ListInboxScoped(ctx, "bot", "unread", "", 50, s2) if err != nil { t.Fatal(err) } @@ -98,14 +98,14 @@ func TestMarkAllReadCanBeScopedToSession(t *testing.T) { s1, s2, _, _ := twoSessionsWithUnread(t, "bot") // 只标会话一 - n, err := MarkAllInboxReadForSession(ctx, "bot", s1) + n, err := MarkAllInboxReadForSession(ctx, "bot", "", s1) if err != nil { t.Fatal(err) } if n != 1 { t.Fatalf("应当只标掉 1 封,实际 %d", n) } - left, err := ListInboxScoped(ctx, "bot", "unread", 50, s2) + left, err := ListInboxScoped(ctx, "bot", "unread", "", 50, s2) if err != nil { t.Fatal(err) } @@ -114,10 +114,10 @@ func TestMarkAllReadCanBeScopedToSession(t *testing.T) { } // 反向对照:不带会话时两条都会被标掉 - if _, err := MarkAllInboxReadFor(ctx, "bot"); err != nil { + if _, err := MarkAllInboxReadFor(ctx, "bot", ""); err != nil { t.Fatal(err) } - if again, _ := ListInbox(ctx, "bot", "unread", 50); len(again) != 0 { + if again, _ := ListInbox(ctx, "bot", "unread", "", 50); len(again) != 0 { t.Fatalf("不带会话的全标应当清空整个收件箱,实际剩 %d", len(again)) } } @@ -133,7 +133,7 @@ func TestScopedCountsMatchScopedList(t *testing.T) { if err != nil { t.Fatal(err) } - list1, err := ListInboxScoped(ctx, "bot", "unread", 50, s1) + list1, err := ListInboxScoped(ctx, "bot", "unread", "", 50, s1) if err != nil { t.Fatal(err) } @@ -172,7 +172,7 @@ func TestInboxRowsCarrySessionPermissionMode(t *testing.T) { } seedMailInSession(t, sid, "bot") - rows, err := ListInboxScoped(ctx, "bot", "unread", 50, sid) + rows, err := ListInboxScoped(ctx, "bot", "unread", "", 50, sid) if err != nil { t.Fatal(err) } @@ -190,7 +190,7 @@ func TestInboxRowsCarrySessionPermissionMode(t *testing.T) { // 缺字段时"猜宽"就是提权,所以默认值这一侧也要验。 other := createTestSession(t, ctx, "bot", "/tmp/ws2") seedMailInSession(t, other, "bot") - rows2, err := ListInboxScoped(ctx, "bot", "unread", 50, other) + rows2, err := ListInboxScoped(ctx, "bot", "unread", "", 50, other) if err != nil { t.Fatal(err) } @@ -219,7 +219,7 @@ func TestInboxRowsCarryReplyAddress(t *testing.T) { } seedMailInSession(t, sid, "bot") - rows, err := ListInboxScoped(ctx, "bot", "unread", 50, sid) + rows, err := ListInboxScoped(ctx, "bot", "unread", "", 50, sid) if err != nil { t.Fatal(err) } diff --git a/server/internal/repo/workspace_scope_test.go b/server/internal/repo/workspace_scope_test.go index c6d8aaa..3c2665c 100644 --- a/server/internal/repo/workspace_scope_test.go +++ b/server/internal/repo/workspace_scope_test.go @@ -4,60 +4,153 @@ import ( "context" "testing" + "github.com/agentmail/gateway/internal/db" "github.com/google/uuid" ) /* -工作区维度:一个 Agent 同时服务**所有**工作区,所以"参与过"不等于"该看"。 +工作区维度:**同一个 Agent 名字在不同工作区各有独立收件箱。** -# 用户报的缺陷 +# 用户报的缺陷(12 天前就提过,这轮才真修) -「agentmail 工作区的邮件会话被 trueagent 工作区的 agent 看到了,还需要我亲自去解释。」 +「不同 session 的 agent 都可以看到全部邮件」—— `552fbc7` 修了 **session_id** 那一维, +但**工作区**那一维从没做。生产实测(2026-09-26):在 `mc` 工作区干活的 pi 读收件箱 +拿到 200 封,其中 191 封属于 `/home/program/agentmail` —— 它照着那些信里的断言去改 +agentmail 的代码,把手上 mc 的活丢在一边。用户当场问「你怎么干着干着修 agentmail 去了?」 -根因不是某处漏了一个 WHERE,而是**隔离单位选的是 Agent**: +三维地址是 `name@path.session`。**path 位本来就该参与寻址**,收件箱侧此前完全没用它。 - - `AgentCanAccessSession(agentName, sid)` 判的是「这个 Agent 名出现在这条会话的 - from/to/cc 里」; - - 而 Agent 注册时 `workspaces` 是空的(B-1.2:cwd 由每封邮件的 `to_workspace` - 决定),于是同一个 agent `pi` 既"参与过" agentmail 的会话、也"参与过" - TrueAgent 的会话 —— 两个工作区之间没有任何边界。 +# 判据要两侧都验 -现场证据:`mail_reads` 里 2026-09-14 08:11–09:19 有 8 次"同一瞬间读了多个不同工作区 -的会话"(最典型 08:23:59 一次跨 agentmail / TrueAgent / webui4frpc 三条会话), -而那正是按 Agent 整表读的特征。更要紧的是 `mail_reads` 只记 `reader_name`, -**没有"读的人当时在哪个工作区"这一列** —— 这类越界读在数据上与正常读无法区分。 - -# 判据两侧都验 - -只验"跨工作区被拒"是不够的:把函数写成永远拒绝也能过。所以同时验 - - - 同工作区必须放行(否则等于把所有 Agent 都锁死); - - 未声明 scope 时的旧语义(迁移期妥协,必须明确写下来,不能靠"没人测"存在); - - 拒绝的**原因**要分得清(没参与过 vs 跨工作区)—— 否则调用方无法自查; - - 列表类接口的反向对照:不带收窄时两条会话都在(证明收窄真的在起作用)。 +只验「带工作区 A 时看不到 B 的信」不够 —— 把列表直接返回空也能过。 +所以同时验反向对照:「不带工作区时两条都看得到」(人类侧语义)。 +两侧一起才证明是**收窄**而不是**清空**。 */ -func sessionIn(t *testing.T, agent, ws, title string) uuid.UUID { +func seedMailInWorkspace(t *testing.T, workspace, recipient string) uuid.UUID { t.Helper() - id, err := CreateSession(context.Background(), nil, "human", title, ws) + ctx := context.Background() + sid, err := CreateSession(ctx, nil, "human", "ws-"+workspace, workspace) if err != nil { - t.Fatalf("create session(%s): %v", title, err) + t.Fatal(err) + } + var id uuid.UUID + if err := db.DB.QueryRowContext(ctx, + `INSERT INTO mails (session_id, from_name, to_name, subject, body, to_workspace) + VALUES ($1, 'sender', $2, 's', 'b', $3) RETURNING mail_id`, + sid, recipient, workspace).Scan(&id); err != nil { + t.Fatal(err) } - seedMailInSession(t, id, agent) return id } -func TestListContactsWithEmptyScopeStillWorks(t *testing.T) { +func TestInboxListIsScopedByWorkspace(t *testing.T) { setupTestDB(t) ctx := context.Background() - sessionIn(t, "pi", "/home/program/agentmail", "随便一条线索") - all, err := ListContactsFor(ctx, "", false) + const wsA = "/home/program/agentmail" + const wsB = "/home/newqqagent/plugindev/mc" + a := seedMailInWorkspace(t, wsA, "pi") + b := seedMailInWorkspace(t, wsB, "pi") + + // ① 带工作区 A ⇒ 只有 A 那封 + onlyA, err := ListInbox(ctx, "pi", "all", wsA, 50) if err != nil { - t.Fatalf("scope 为空(管理员看全部)不该报错:%v", err) + t.Fatal(err) } - if len(all) != 1 { - t.Fatalf("应当列出 1 条(实际 %d 条)", len(all)) + if len(onlyA) != 1 || onlyA[0].ID != a { + t.Fatalf("工作区 A 应收窄到 1 封(A 那封),实际 %d 封", len(onlyA)) + } + + // ② 带工作区 B ⇒ 只有 B 那封(**反向对照**:证明不是"总是返回第一封") + onlyB, err := ListInbox(ctx, "pi", "all", wsB, 50) + if err != nil { + t.Fatal(err) + } + if len(onlyB) != 1 || onlyB[0].ID != b { + t.Fatalf("工作区 B 应收窄到 1 封(B 那封),实际 %d 封", len(onlyB)) + } + + // ③ 不带工作区(人类侧语义)⇒ 两封都看得到 + // ★ 这一条是"收窄 vs 清空"的分水岭:少了它,把 ① 实现成 return nil 也能过。 + all, err := ListInbox(ctx, "pi", "all", "", 50) + if err != nil { + t.Fatal(err) + } + if len(all) != 2 { + t.Fatalf("不带工作区应看到 2 封(人类跨工作区),实际 %d 封", len(all)) + } + + // ④ 未读数同样按工作区算 —— 它是桥的补投判据 pending_mails。 + // 口径不一致时,桥会认为"还有信没投"并反复重放。 + nA, err := CountUnread(ctx, "pi", wsA) + if err != nil { + t.Fatal(err) + } + nB, err := CountUnread(ctx, "pi", wsB) + if err != nil { + t.Fatal(err) + } + nAll, err := CountUnread(ctx, "pi", "") + if err != nil { + t.Fatal(err) + } + if nA != 1 || nB != 1 || nAll != 2 { + t.Fatalf("未读数应为 A=1 B=1 全部=2,实际 A=%d B=%d 全部=%d", nA, nB, nAll) } } +// 「全部标已读」也必须按工作区收窄 —— 否则标掉别的工作区的未读 = 静默丢信 +// (session_scope_test.go 记过这个形状)。 +func TestMarkAllReadIsScopedByWorkspace(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + const wsA = "/home/program/agentmail" + const wsB = "/home/newqqagent/plugindev/mc" + seedMailInWorkspace(t, wsA, "pi") + seedMailInWorkspace(t, wsB, "pi") + + n, err := MarkAllInboxReadForSession(ctx, "pi", wsA, uuid.Nil) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("标已读应只影响工作区 A 的 1 封,实际 %d 封", n) + } + + // A 的标掉了 + if left, _ := CountUnread(ctx, "pi", wsA); left != 0 { + t.Fatalf("工作区 A 应已清空,实际剩 %d 封未读", left) + } + // B 的**必须还在**(这是本判据的重点) + if left, _ := CountUnread(ctx, "pi", wsB); left != 1 { + t.Fatalf("工作区 B 的未读被误标了 —— 这是静默丢信(应剩 1 封,实际 %d)", left) + } + + // 反向对照:不带工作区时全标掉(人类侧) + if _, err := MarkAllInboxReadForSession(ctx, "pi", "", uuid.Nil); err != nil { + t.Fatal(err) + } + if left, _ := CountUnread(ctx, "pi", ""); left != 0 { + t.Fatalf("不带工作区应全部标掉,实际剩 %d 封", left) + } +} + +// workspace 的形状校验:非空时必须是绝对路径。 +// 相对路径在服务端无法解释(不同调用方 cwd 不同 ⇒ 拼出来必然对不上), +// 而"对不上"的表现是**静默返回空收件箱** —— 那比报错难查得多。 +func TestWorkspaceMustBeAbsolute(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + seedMailInWorkspace(t, "/home/program/agentmail", "pi") + + for _, bad := range []string{"relative/path", "./here", "~/home"} { + if _, err := ListInbox(ctx, "pi", "all", bad, 50); err == nil { + t.Fatalf("ListInbox 对相对路径 %q 必须报错,实际通过(会静默返回空收件箱)", bad) + } + if _, err := CountUnread(ctx, "pi", bad); err == nil { + t.Fatalf("CountUnread 对相对路径 %q 必须报错", bad) + } + } +}