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

@ -306,12 +306,23 @@ func CreateMail(ctx context.Context, sessionID uuid.UUID, parentMailID *uuid.UUI
}
// CreatePermissionMail 创建权限请求邮件,toUser 为目标人类用户名
// CreatePermissionMail 创建一封权限询问邮件(Agent → 人类决策人)。
//
// **from_workspace 必须写入发起方的工作目录。**
//
// 不写的后果在授权页上很具体:那一列存空串,而前端拿 `from_workspace`
// 当「发起方在哪个目录干活」渲染 —— 于是那一行永远不显示,
// 人只看到一个光秃的 Agent 名,不知道是哪个目录里的哪条线索在请求权限。
// 同名 Agent 在不同目录是不同的活,那正是决策时最需要的信息。
//
// 取会话的 workspace 而不是传参:会话的工作目录在它建立时就定下了,
// 而询问发起于那条会话里。
func CreatePermissionMail(ctx context.Context, sessionID uuid.UUID, fromName, toUser, question, body string, options []string) (uuid.UUID, error) {
optsJSON, _ := json.Marshal(options)
var id uuid.UUID
err := db.DB.QueryRowContext(ctx,
`INSERT INTO mails (session_id, from_name, to_name, subject, body, mail_type, permission_options, created_at)
VALUES ($1, $2, $3, $4, $5, 'permission_request', $6, NOW()) RETURNING mail_id`,
`INSERT INTO mails (session_id, from_name, from_workspace, to_name, subject, body, mail_type, permission_options, created_at)
VALUES ($1, $2, COALESCE((SELECT workspace FROM sessions WHERE session_id = $1), ''), $3, $4, $5, 'permission_request', $6, NOW()) RETURNING mail_id`,
sessionID, fromName, toUser, "权限请求: "+question, body, optsJSON,
).Scan(&id)
return id, err
@ -647,6 +658,10 @@ func FindOrCreateDefaultSession(ctx context.Context, name, path, fromAgent, subj
SELECT s.session_id
FROM sessions s
WHERE s.status <> 'archived'
-- 接管会话是人显式指定的线索,不该被省略 session 位的邮件当「默认会话」吃掉。
-- 日历提醒省略 session 位后落进了人选的那条会话 → 那条会话的别名被另开的
-- 会话的命名同步冲掉(项目定位 → 日程提醒:…)的链条,起点就在这里。
AND (s.platform_id IS NULL OR s.platform_id = '')
AND EXISTS (
SELECT 1 FROM mails m
WHERE m.session_id = s.session_id
@ -719,14 +734,25 @@ func SyncSessionAlias(ctx context.Context, id uuid.UUID, want string) (string, e
var cur *string
var source string
var platformID string
if err := db.DB.QueryRowContext(ctx,
`SELECT session_alias, COALESCE(alias_source, 'platform') FROM sessions WHERE session_id = $1`,
id).Scan(&cur, &source); err != nil {
`SELECT session_alias, COALESCE(alias_source, 'platform'), COALESCE(platform_id, '')
FROM sessions WHERE session_id = $1`,
id).Scan(&cur, &source, &platformID); err != nil {
return "", err
}
if source == "manual" && cur != nil && *cur != "" {
return *cur, nil
}
// 接管会话的别名是人从补全里选中的平台 slug,任何平台命名同步
// 都不该动它。不守这道门的话,事件日历经由桥另开会话 → 命名同步
// 回写 → 把接管会话的别名冲掉 → 人在补全里选的名字凭空消失。
// 生产上已经兑现过一次(项目定位 → 日程提醒:…)。
if platformID != "" {
if cur != nil && *cur != "" {
return *cur, nil
}
}
for i := 0; i < maxAttempts; i++ {
candidate := want