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

@ -58,14 +58,23 @@ export default function NarrowStack({
return (
<div className="flex-1 min-h-0 relative overflow-hidden">
{/* 底层:始终挂载。打开覆盖层时用 aria-hidden 把它从无障碍树里摘掉,
否则屏幕阅读器会读到两层内容 */}
<div className="absolute inset-0 flex" aria-hidden={open ? 'true' : undefined}>
否则屏幕阅读器会读到两层内容
`isolate`isolation: isolate是必需的它让底层**自成一个层叠上下文**。
不加的后果在日历上实测到过:月视图的星期表头是 `sticky top-0 z-10`
而覆盖层没有 z-index= auto = 0—— 两者在同一个层叠上下文里比,
`z-10` 赢过 `auto`,于是底层的表头穿透到二级页面之上,把日程内容遮住一条。
为何不只给覆盖层加 z-10 就完事:那只能治当下这一处。底层是任意业务组件,
下一个人在里面写个 `z-20` 就又复现,而这类 bug 只能肉眼看见。
isolate 把边界定在容器上,底层写多少 z-index 都出不来。 */}
<div className="absolute inset-0 flex isolate" aria-hidden={open ? 'true' : undefined}>
{base}
</div>
{mounted && (
<div
className={`absolute inset-0 flex bg-white shadow-2xl transition-transform duration-200 ease-out motion-reduce:transition-none ${
className={`absolute inset-0 z-10 flex bg-white shadow-2xl transition-transform duration-200 ease-out motion-reduce:transition-none ${
entered ? 'translate-x-0' : 'translate-x-full'
}`}
>

View File

@ -132,10 +132,9 @@ function PermissionSessionGroup({
}`}
/>
<BotIcon className="w-3.5 h-3.5 text-slate-600 shrink-0" />
<span className="text-xs font-semibold text-gray-900 truncate">{g.agentName}</span>
{g.path && (
<span className="text-[10px] text-gray-400 font-mono truncate">{g.path}</span>
)}
<span className="text-xs font-mono text-gray-900 truncate">
{g.agentName}{g.path ? `@${g.path}` : ''}{g.alias ? `.${g.alias}` : ''}
</span>
<span className="ml-auto text-[10px] text-gray-400 shrink-0">{time}</span>
</div>
@ -156,9 +155,11 @@ function PermissionSessionGroup({
)}
</div>
<p className="text-[10px] text-blue-500 font-mono truncate mt-0.5 pl-5">
{g.alias ? `.${g.alias}` : '(未命名会话)'}
</p>
{!g.alias && (
<p className="text-[10px] text-gray-400 font-mono truncate mt-0.5 pl-5">
()
</p>
)}
</button>
{open && (

View File

@ -160,7 +160,7 @@ export function groupPermissions(mails: Mail[]): PermissionGroup[] {
sessionId,
alias: latest.session_alias || '',
agentName: latest.from_name,
path: latest.from_workspace || '',
path: latest.session_workspace || '',
pending: sorted.filter(isPendingPermission),
settled: sorted.filter(m => !isPendingPermission(m)),
latest

View File

@ -254,12 +254,22 @@ describe('groupPermissions', () => {
expect(groups.map(g => g.sessionId)).toEqual(['new', 'old']);
});
it('组头带上发起请求的 Agent 与工作目录', () => {
it('组头带上发起请求的 Agent 与会话工作目录', () => {
const [g] = groupPermissions([
perm({ session_id: 's', from_name: 'dsh', from_workspace: '/home/program/llmsproxy' })
perm({
session_id: 's',
from_name: 'dsh',
// from_workspace 对 Agent 存的是 **Agent 名**而不是路径(历史遗留)。
// 拿它当路径用会在授权页拼出 `dsh@dsh`,而权限请求的
// from_workspace 实测是**空串** —— 于是那一行永远不渲染,
// 人根本不知道是哪个目录里的哪条线索在请求权限。
from_workspace: 'dsh',
session_workspace: '/home/program/llmsproxy'
})
]);
// 同名 Agent 在不同目录是不同的活,光有名字判断不了
expect(g.agentName).toBe('dsh');
// path 必须取 session_workspace会话的 workspace权威来源
expect(g.path).toBe('/home/program/llmsproxy');
});