fix: 事故全链路修复 — 调度器合流 + 接管保护 + 补全去重 + 权限显示 + homeagent SSE 振荡

## 事故现场

用户选中补全里的「项目定位」→ 邮件投进另一条会话,界面显示的名字也不是
自己选的那个。授权页只显示 Agent 名,看不出哪个目录哪条线索。

## 四处因果链

**① 调度器自己的 new_mail payload(起点)。** `notifyRecipients`(handler)
与 `SendCalendarMail`(scheduler)是两份代码。加 `platform_session_id` 时只改了
handler 那份 → 日历提醒投进接管会话时插件不知道是接管 → 另开一条新会话 →
命名同步冲掉接管会话的别名。

修法:抽出 `internal/notify` 包,唯一入口 `notify.Recipients`。
handler / scheduler / permission.go 都走它。新增字段时不存在「另一处忘了改」。

**② SyncSessionAlias 覆盖接管别名。** 别名是人从补全里选中的平台 slug,
任何平台命名同步都不该动它。加守卫 `platform_id <> ''` → 有绑定就返回当前值。

**③ SuggestSessionCandidates 按别名字符串去重。** 别名一被冲掉,同一条会话
出现两次(一次被冲的名字、一次镜像 slug),而另一条真实会话被吃掉。
改按 `platform_id` 去重。 mail 侧查 `s.platform_id`,镜像侧查 `platform_id`。

**④ FindOrCreateDefaultSession 不排除接管会话。** 日历提醒省略 session 位 →
FindOrCreateDefaultSession 挑中人显式指定的接管会话。加 `platform_id = ''` 条件。

## 权限页

**CreatePermissionMail 不写 from_workspace。** `from_workspace` 存空串 →
前端 `g.path && ...` 不渲染 → 人只看到光秃的 Agent 名,不知道哪个目录
哪条线索在请求权限。修法:INSERT 时从 sessions.workspace 取。

**SSE payload 缺 session_alias。** permission.go 的 SSE 不走 notify 包(决策人
不是地址解析出的参与方),但 payload 也要带 `session_alias` → 前端拼出
`pi@/home/program/agentmail.别名`,而不是光秃的 `pi`。

**mailGroups.ts:path ← session_workspace。** `from_workspace` 对 Agent 存的是
Agent 名(历史遗留),不能当路径用。PermissionList 显示完整三段地址
`agent@path.alias`。

## NarrowStack z-index

窄屏日历的星期表头(`sticky top-0 z-10`)穿透到二级页面之上。覆盖层
auto z-index 输给 z-10 → 底层组件的层叠穿透到覆盖层。

修法:底层容器加 `isolate`(isolation: isolate),自成层叠上下文;
覆盖层加 `z-10`。只给覆盖层加 z-index 只能治当前一处,底层再写更大的
z-index 又会复现。

## homeagent SSE 自激振荡

根因:五处缺陷叠加,SSE 每 60 秒断一次 → Gateway 全量重放 → 再断 → 再重放。

1. `p.client`(60s Timeout)跑 SSE 长连接 → 新增 `sseClient`(无超时)
2. `InjectInputSync` 在读循环里同步调用 → 改为 `go p.handleNewMail(evt)`
3. `lastEventID` 无条件赋值,Gateway 重放时发旧 ID → 单调递增 `sseMaxID`
4. 无邮件级去重 → 补 `deliveredMails map[string]bool`
5. 手动 `[]byte` 管理:每次 `buf[lineStart:]` 缩小 cap → 最终 len==cap
   → Read 零长切片 → 满速空转。改 `bufio.Reader`。

## 清库

保留 jianf + 4 个 Agent 密钥 + 模型范围配置。清掉 mails/sessions/
calendar_events/attachments/agent_platform_sessions/relayed_mails/
permission_requests/rate_limits。测试数据已全部清零。

## 测试

- gateway 7 包全过;repo + 7 例(adopt_alias_test.go)
- web 182 例(mailGroups 新增 session_workspace 断言)
- 前端构建通过
This commit is contained in:
2026-09-04 15:35:48 +08:00
parent 55b3f9bc4e
commit e8583ecd41
12 changed files with 651 additions and 193 deletions

View File

@ -11,8 +11,8 @@ import (
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/notify"
"github.com/agentmail/gateway/internal/repo"
"github.com/agentmail/gateway/internal/sse"
"github.com/google/uuid"
)
@ -279,62 +279,33 @@ func SendCalendarMail(ctx context.Context, eventID, toAddr, subject, body, creat
}
}
alias := repo.SessionAliasOf(ctx, sessionID)
sse.Default.SendToRecipient(addr.Name, "new_mail", map[string]interface{}{
"mail_id": mailID.String(),
"session_id": sessionID.String(),
"from_name": calendarSender,
"subject": subject,
"mail_type": "normal",
"role": "to",
"to_workspace": addr.Path,
"session_alias": alias,
// 回信地址给 calendar 是发不出去的(它不是收件方),
// 给会话自己的地址才让模型能把结果回报到同一条线索上。
"reply_address": models.FormatAddress(addr.Name, addr.Path, alias),
"self_address": models.FormatAddress(addr.Name, addr.Path, alias),
// 让插件与 UI 能区分「这封是定时提醒」而不是有人在找它
"origin": "calendar",
})
sse.Default.SendToRecipient(addr.Name, "session_update", map[string]interface{}{
"session_id": sessionID.String(),
"status": "active",
})
// 抄送方也要收到 SSE。
// ─── 推送 SSE ───
//
// 漏了这一步的后果很隐蔽:邮件的 cc_list 里有他们、他们**查**收件箱
// 能看到这封信,但没有任何事件推给他们 —— 于是插件不会唤起会话,
// Agent 直到下一次补拉(重启时)才发现。对「知情方」而言等于没通知。
for _, c := range ccList {
alias := repo.SessionAliasOf(ctx, sessionID)
sse.Default.SendToRecipient(c.Name, "new_mail", map[string]interface{}{
"mail_id": mailID.String(),
"session_id": sessionID.String(),
"from_name": calendarSender,
"subject": subject,
"mail_type": "normal",
"role": "cc",
"to_workspace": c.Path,
"session_alias": alias,
"reply_address": models.FormatAddress(addr.Name, addr.Path, alias),
"self_address": models.FormatAddress(c.Name, c.Path, alias),
"origin": "calendar",
})
sse.Default.SendToRecipient(c.Name, "session_update", map[string]interface{}{
"session_id": sessionID.String(),
"status": "active",
})
}
// 这是整个系统里**唯一**的推送实现(handler 那条路径也是 notify.Recipients)。
// 原来这里自己拼了一整份 payload(handler 里是另一份),
// 加 `platform_session_id` 时只改了那边 → 日历提醒投进接管会话时
// 插件不知道是接管、另开了一条新 pi 会话 → 命名同步把接管会话的别名冲掉
// → 人在补全里选的「项目定位」变成了「日程提醒:…」→ 选哪条都落进同一条。
// 根因只是「同一件事写了两遍」。
//
// ReplyToName = addr.Name:日历提醒的回信要落回那条线索,不是回给 calendar
// Origin = "calendar":让插件与 UI 能区分「这封是定时提醒」
notify.Recipients(ctx, notify.Mail{
SessionID: sessionID,
MailID: mailID,
From: calendarSender,
To: addr,
CC: ccList,
Subject: subject,
MailType: "normal",
Origin: "calendar",
ReplyToName: addr.Name,
})
// 创建者也该看到提醒发出去了 —— 否则「我设的提醒到底触发了没有」
// 只能去翻 journalctl。
if createdBy != "" && createdBy != addr.Name {
sse.Default.SendToRecipient(createdBy, "session_update", map[string]interface{}{
"session_id": sessionID.String(),
"status": "active",
})
}
// 创建者不是收件方,不在 Recipients 的参与方去重里 —— 他不会收到
// new_mail(他不该被「有人在找你」打扰),但他应该看到会话活跃起来:
// 「我设的提醒到底触发了没有」不应该只能去翻 journalctl。
notify.SessionActive(createdBy, sessionID)
return nil
}