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

@ -1,6 +1,7 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
@ -73,6 +74,22 @@ type Plugin struct {
// B-1.6 补拉状态:首个成功心跳后只补一次
catchupDone bool
// ─── SSE 专用 ───
// SSE 需要一个不设 Timeout 的 HTTP client原来 p.client60s Timeout
// 跑 SSE 长连接,每 60 秒自己掐断自己。之后 lastEventID 回退 → 重放 →
// 又阻塞 → 又超时 —— 自激振荡。这个 client 只给 readSSE 用。
sseClient *http.Client
// B-7.3 邮件级去重SSE 重放会重发同一批事件,没有这层去重
// 每封邮件会被注入 agent 两遍。契约 B-7.3 要求:每封只注入一次。
deliveredMails map[string]bool
// 单调递增的 last-seen-ID被重放的旧事件不会让它回退。
// 原来直接赋值p.lastEventID = eidGateway 重放时发旧 ID
// 于是 lastEventID 从 123 退回 116 → 下次重连又报 116 → 又重放。
sseMaxID int64
}
// ─── B-1.1 密钥解析与本地生成 ───
@ -146,14 +163,16 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
}
return &Plugin{
name: name,
agentName: agentName,
gwURL: strings.TrimRight(gw, "/"),
key: "", // Start() 里解析
keyFile: "",
client: &http.Client{Timeout: 60 * time.Second},
stopCh: make(chan struct{}),
explicitSends: make(map[string]time.Time),
name: name,
agentName: agentName,
gwURL: strings.TrimRight(gw, "/"),
key: "", // Start() 里解析
keyFile: "",
client: &http.Client{Timeout: 60 * time.Second},
sseClient: &http.Client{}, // 无超时SSE 是长连接
stopCh: make(chan struct{}),
deliveredMails: make(map[string]bool),
explicitSends: make(map[string]time.Time),
}, nil
}
@ -550,14 +569,19 @@ func (p *Plugin) readSSE() error {
}
req.Header.Set("Authorization", "Bearer "+p.key)
// W-4断线期间的事件会丢带上 Last-Event-ID 可以让 Gateway 从断点补发
// W-4断线期间的事件会丢带上 Last-Event-ID 可以让 Gateway 从断点补发
// 只上报比当前记录的更大的 IDGateway 重放时发的是事件原本的 ID
// 如果无条件赋值lastEventID 会从 123 退回 116 → 下次重连又报 116 →
// 又重放 —— 自激振荡的放大器。
p.sseMu.Lock()
if p.lastEventID != "" {
req.Header.Set("Last-Event-ID", p.lastEventID)
}
p.sseMu.Unlock()
resp, err := p.client.Do(req)
// sseClient 无 Timeoutp.client 有 60s TimeoutSSE 是长连接,
// 每 60 秒自己掐断自己 → 重放 → 阻塞 → 超时 → 重放。
resp, err := p.sseClient.Do(req)
if err != nil {
return err
}
@ -569,8 +593,11 @@ func (p *Plugin) readSSE() error {
log.Printf("[homeagent-mail-bridge] SSE 已连接")
buf := make([]byte, 0, 4096)
lineStart := 0
// bufio.Reader 解决原来手动管理 []byte 的两个问题:
// 1. 每次 buf = buf[lineStart:] 让 cap 缩小,几轮之后 len==cap
// Read 拿到零长切片 → (0, nil) → 满速空转
// 2. 手写的 line 分割逻辑有边界条件(跨次 Read 的半行处理)
br := bufio.NewReader(resp.Body)
for {
select {
case <-p.stopCh:
@ -578,39 +605,43 @@ func (p *Plugin) readSSE() error {
default:
}
n, err := resp.Body.Read(buf[len(buf):cap(buf)])
if n > 0 {
buf = buf[:len(buf)+n]
for {
i := bytes.IndexByte(buf[lineStart:], '\n')
if i < 0 {
break
}
line := string(buf[lineStart : lineStart+i])
lineStart += i + 1
p.parseSSELine(line)
}
if lineStart > 0 {
buf = buf[lineStart:]
lineStart = 0
}
buf = buf[:len(buf)]
line, err := br.ReadString('\n')
if line != "" {
p.parseSSELine(strings.TrimRight(line, "\n"))
}
if err != nil {
if err != io.EOF {
return err
if err == io.EOF {
return nil
}
return nil
return err
}
}
}
// parseSSEID 把 "id: 123" 格式的事件 ID 解析成整数。
// 解析失败返回 0大于 0 的 ID 才会被接受),保证不会误清状态。
func parseSSEID(raw string) int64 {
var id int64
for _, c := range raw {
if c >= '0' && c <= '9' {
id = id*10 + int64(c-'0')
}
}
return id
}
func (p *Plugin) parseSSELine(line string) {
// W-4记录 Last-Event-ID
// W-4记录 Last-Event-ID。只向前推进,不回退。
// Gateway 重放旧事件时发的是旧 ID无条件赋值会让 lastEventID
// 从 123 退回到 116 → 下次重连报 116 → 又重放 → 振荡。
if strings.HasPrefix(line, "id: ") {
eid := strings.TrimPrefix(line, "id: ")
id := parseSSEID(eid)
p.sseMu.Lock()
p.lastEventID = eid
if id > p.sseMaxID {
p.sseMaxID = id
p.lastEventID = eid
}
p.sseMu.Unlock()
return
}
@ -647,7 +678,22 @@ func (p *Plugin) parseSSELine(line string) {
}
if evt.MailType == "normal" {
p.handleNewMail(evt)
// B-7.3去重。SSE 重放时同一封邮件会再出现,没有这层
// 每封邮件会被注入 agent 两遍(实测 21 次超时 → 21 次重放)。
p.sseMu.Lock()
if p.deliveredMails[evt.MailID] {
p.sseMu.Unlock()
return
}
p.deliveredMails[evt.MailID] = true
p.sseMu.Unlock()
// InjectInputSync 会阻塞几十秒(查日志、调工具、转发 QQ
// 而它跑在 readSSE 的读循环里 —— 循环卡住期间 SSE 事件积压在
// TCP 缓冲区,卡到超时断线重连后 Gateway 全部重放一遍。
// 把处理丢到独立 goroutineparseSSELine 立刻返回,读循环继续。
// homeagent 是单事件循环InjectInputSync 自己会排队。
go p.handleNewMail(evt)
}
}