修 platform_session_id 无差别下发导致抄送方邮件静默消失 + homeagent 补投漏去重

## platform_session_id 只发给归属方(Gateway)

`notify.Recipients` 原来对所有参与方推同一个 `platform_session_id`,
而那是**会话级**的一个值。生产实测:会话 16845133 接管了 pi 的平台会话
`01a05a5e-…`,那封邮件抄送了 dsh@/home/program/agentmail.new。DSH 收到
同一个 id,在 ~/.dsh/sessions/ 里查不到(那是 /root/.pi/agent/sessions/
下的文件),于是走进「平台侧会话已删」那道防线抛错。

那道防线本身是对的(N-8:不能退回新建,否则人在界面上看不到这封邮件带来
的对话),它拦下的却是「别人的会话」。异常被 ctx.logger.error 吞掉,而
DSH 的 logger 不进 journalctl —— 邮件静默消失,日志里一个字都没有。

- 新增 `repo.PlatformSessionFor` 一并返回归属 Agent:以镜像
  `agent_platform_sessions.agent_name` 为准,镜像整表替换后退回
  `sessions.from_agent`(AdoptPlatformSession 写在那里)
- `PlatformIDOf` 变薄封装,保留原签名
- `notify.Recipients` 加 `platformFor(forName)`:归属方以外一律空串;
  归属抽不到时(owner 空)也不下发 —— 宁可退回当普通会话处理,
  也不让一个抽不到归属的 id 把邮件弄丢
- 归属与收件角色无关:归属方在抄送位上同样拿到

## homeagent catchUp 漏 deliveredMails 去重

`go p.catchUp(…)` 与 `go p.sseLoop()` 是两个并发 goroutine,重启时窗口
重叠:SSE 推一次 + 补投拉一次 = 同一封邮件注入两遍。homeagent 的回信正文
印证了这一点(「之前的对话时序中已经收到并确认过多次了」)。另三个插件的
catchUp 都有这层双查,只有这里漏了。

去重放在循环内逐封查而不是拉完一批再筛:InjectInputSync 一封要跑几十秒,
那期间 SSE 完全可能已经投过后面那几封。

## DSH 接管失败改用 console.error

DSH 的 ctx.logger 不进 journalctl,投递失败是「发件人等不到回信」的唯一
线索。接管失败点与 SSE 分发的 catch 都改走 console.error,并带上 mail_id
与发件人。

## 前端 ccAddress 移除(收尾上一轮未提交的改动)

cc_list 里的 `.new` 是**原始意图**,不该被替换成主收件人的别名:每个抄送
方的 `.new` 是独立的 —— pi@/x.new 给 pi 开一条、dsh@/x.new 给 dsh 开另一
条,各有自己的别名。数据库存的就是原文。删掉 ccAddress,MailView /
ThreadView 直接显示 c.raw。

## 测试

- `internal/notify/notify_test.go` +3 例:挂真实 SSE 客户端读帧,验
  归属方拿到 / 抄送方为空 / 归属方在抄送位也拿到 / 普通会话全空。
  负向对照跑过:platformFor 无条件返回时两条用例失败
- `internal/repo/platform_owner_test.go` +3 例:镜像取归属、普通会话、
  镜像被清后退回 from_agent
- 修好 web/test/components/replyTarget.test.tsx(上一轮遗留的语法损坏),
  三条 .new 用例改成断言原样保留
- gateway 7 包全绿;web 176 例 + 主题 26;dsh 219 / pi 250 / opencode 201

## 生产验证

- 抄送验证:jianf → pi(接管会话)cc dsh。DSH 正常建会话并回信「收到」,
  pi 走接管续谈 —— 两封回信都落在同一条线索上(此前 DSH 那封不存在)
- homeagent 去重:连发两轮,其中一轮在邮件未处理完时重启 homeagent 造出
  SSE/catchUp 并发窗口,两轮都只产生一封 Re:
- homeagent SSE:换新 plugin.bin 后连续 89 分钟零断连(此前 2 小时 102 次
  deadline exceeded 自激振荡)
This commit is contained in:
2026-09-04 19:06:36 +08:00
parent 30b78ef2cd
commit c297468819
10 changed files with 389 additions and 107 deletions

View File

@ -630,9 +630,18 @@ export function apply(ctx: any, config: PluginConfig): void {
if (!existing && adoptedID) {
const onDisk = await persistedCwd(adoptedID);
if (onDisk === undefined) {
// 镜像是快照,可以过期:平台侧那条会话可能已经被人删了
// 平台侧那条会话已不在磁盘上
//
// 不能落到「新开会话」那条路 —— 那会用 `mail-<uuid>` 另开一条,
// 人在 DSH 界面上看不到这封邮件带来的对话,而那正是接管的目的。
// 人在 DSH 界面上看不到这封邮件带来的对话,而那正是接管的目的N-8
//
// 用 console.error 而不是仅靠抛异常:调用方那层的 catch 走
// `ctx.logger.error`,而 DSH 的 logger **不进 journalctl**。邮件因此会
// 静默消失:发件人以为送到了,而日志里一个字都没有(实测过)。
console.error(`[dsh-mail-bridge] 接管失败:会话 ${adoptedID} 不在本机磁盘上`
+ `mail ${data.mail_id},发件人 ${data.from_name || '?'})。`
+ `若这个 id 属于另一个平台,说明 Gateway 把别人的 platform_session_id `
+ `推给了本插件。`);
throw new Error(adoptMissingMessage(adoptedID, '磁盘上已无这条会话的日志'));
}
return locked(adoptedID, async () => {
@ -1462,7 +1471,9 @@ export function apply(ctx: any, config: PluginConfig): void {
console.error(`[dsh-mail-bridge] ${type} -> ${reused ? '续谈' : '新会话'} ${sessionID}`);
})
.catch((e: any) => {
ctx.logger.error(`[dsh-mail-bridge] ${type} 处理失败: ${e?.message || e}`);
// console.error 而不是 ctx.loggerDSH 的 logger 不进 journalctl
// 而投递失败是「发件人等不到回信」的唯一线索。
console.error(`[dsh-mail-bridge] ${type} 处理失败mail ${data?.mail_id || '?'}: ${e?.message || e}`);
});
break;
case 'permission_decision':