# Phase 7 剩余项与已知生产缺陷追踪 7.7 DSH 插件已完成(见 `docs/PLUGIN-CONTRACT.md` 与 PLAN.md §7.7)。 ## 7.8 跨主机 Agent —— 协议层面已支持 **原计划**(Gateway + Registry 拆分、etcd/Consul 服务注册、跨主机路由)**不做**。 它解决的是「Gateway 怎么找到 Agent」,而这个方向从一开始就不成立: **连接方向是单向的 —— Agent 主动连 Gateway,Gateway 从不外呼。** 因此「发现」不是 Gateway 的问题,是 Agent 的配置问题:它只需要知道一个 公网 URL 加一把密钥。注册中心要解决的「被叫方在哪」在这个架构里不存在 —— 被叫方自己会打进来。 同一个理由让平台会话同步走插件上报(见 PLUGIN-CONTRACT 的 `W-3`): Gateway 不外呼,就不需要知道任何 Agent 的地址。 ### 已验证可用(2026-09-02,从 192.168.2.106 打到公网) 完整一轮往返跑通了:`admin` 在本机发信给 `remotebot@/tmp/remotebot-ws`, `.106` 上的脚本收到并回信入库。 | 能力 | 结果 | |---|---| | 注册(`POST /agent/register`) | 通,`workspaces` 落库为 `[{"name":"demo","path":"/tmp/remotebot-ws"}]` | | 心跳(`POST /agent/heartbeat`) | 通,`models_synced: 1`、回传 `allowed_models` 与 `models_unrestricted` | | SSE 长连(`GET /events/stream` + Bearer) | 通,收到 `connected` | | 收件箱 + 标记已读 | 通 | | 发信(`POST /mail/send` 带 `reply_to`) | 通,`from_workspace` 正确 | | Gateway 侧在线状态 | `status=online`,`last_seen` 随心跳推进 | 验证用的是一个**只依赖 python 标准库的脚本**(`deploy/remote-agent-demo.py`), 它没装 AgentMail 的任何代码。这就是「协议层面已支持」的含义: 跨主机不需要新组件,只需要三个环境变量。 ### 写这个脚本时踩的两个坑(新平台接入会重复踩) **`workspaces` 是对象数组 `[{name, path}]`,不是字符串数组。** 传字符串原先只得到一句固定的 400 `Invalid JSON` —— 完全没指向是哪个字段, 只能靠翻服务端结构体才能发现。两个正式插件都传 `workspaces: []`, 所以这个坑一直没暴露过;第三方客户端没有「翻服务端源码」这个条件。 **已修**:新增 `handler.DecodeBody`,22 处 `Decode` + 固定文案的调用点全部换过去。 现在同样的请求回: ```json {"error": "字段 \"workspaces\" 类型不对:期望 object,收到 string"} ``` 刻意不回显 `encoding/json` 的原文——它带 Go 类型名(`models.Workspace`), 那是本侧的实现细节,不该出现在公开 API 的响应里。 `internal/handler/decode_test.go` 钉住这一点(含「不得泄漏 Go 类型名」的断言)。 **SSE 只推连上之后的事件,离线期间的邮件要靠心跳的 `pending_mails` 补拉。** 第一版脚本只挂了 SSE,于是启动前发的那封邮件永远不会被处理 —— 日志里 `pending=1` 明明写着有一封未读,却没人去拉。 **这个坑两个正式插件也有**(原以为它们做了补拉,查了才发现没有)。已修: 新增共用模块 `lib/catchup.js`,两插件在首个成功心跳后补投一次。 - 只在**首个**心跳后补,不是每轮:每轮都补会把「模型正在处理中、尚未标已读」 的邮件重复投递 - 串行投递、一次最多 5 封:每封都要起一轮模型,并发放出去等于对上游打 N 个 并发请求,且最后那几封要等前面全部跑完 - 与 SSE 共用 `deliveredMails` 去重:心跳与 SSE 建连之间有个窗口, 那期间到的邮件既在 `pending_mails` 里也会被 SSE 推一次 - 按时间**正序**补投(收件箱是倒序返回的):同一会话里的多封邮件倒着塞进去, 上下文顺序是乱的 - `permission` 类邮件不补投:原来的工具调用早随进程一起没了, 投过去模型没有可恢复的上下文 端到端验证(两平台各一次):停插件 → 发邮件 → 启插件 → 日志出现 「补投 1 封离线期间的邮件」→ 回信入库。随后在线状态再发一封确认只回一次。 ### 剩下的确实是运维便利,不是能力缺失 - [ ] 一条命令为远端主机建密钥并打印那三个环境变量(现在要手工调 admin API) - [ ] 密钥轮换(现在换密钥要重启远端 agent) - [ ] Agent 列表显示来源主机(`agents.host_url` 列已存在但没人写, 要写的话应当由心跳带上自报的地址 —— 仍然不是 Gateway 去探测) 这三项都不阻塞跨主机使用,因此不再归入 Phase 7。 ## 可以立即推进的生产缺陷 ### P0 — SSE Last-Event-ID 补投 **根因**:EventSource 断线重连时自带 Last-Event-ID 头,但服务端直接忽略了—— 所有断线期间的邮件通知都丢失。用户刷新页面也会错过已推的事件。 **影响**:重连后永远看不到断线期间收到的邮件(除非手动刷新)。 **修法**:服务端维护一个有界循环缓冲区(ring buffer),每次 Broadcast 同时写入, SSE 连接的 handler 在首次连接时从缓冲区头部开始(客户端传了 Last-Event-ID 就从那里), 没有则从头(只带最近 N 条)。缓冲区大小设 500,内存 < 2MB。 ### P0 — 连接状态指示器 **根因**:SSE 断线后前端无任何可见反馈——用户以为系统正常,实际通知已停。 **影响**:实时性是 Agent 协作的核心体验,断线无提示会让人以为「Agent 没在动」。 **修法**:header 旁加一个连接状态点(绿/黄/红),SSE 的 onopen/onerror 事件驱动。 ### P1 — 登录限速跨进程问题 ✅ **根因**:LoginLimiter 是进程内内存计数器,多实例部署时每个实例独立计数。 **修法**:改为 DB 事务(rate_limits 表 + IMMEDIATE 事务),多实例共享同一份计数。 ### P1 — 新建会话限速同理 ✅ **根因**:sessionRateLimiter 也是进程内计数器。 **修法**:同上,sessionrate.go 重写为调用 RateLimitCheckAndRecord。 ### P2 — 组件级测试 **现状**:有结构性回归(`web/test/narrow-layout.test.mjs`,28 条断言, 读源码验形态)与一套 playwright 手工脚本,但**没有渲染组件跑断言的测试**。 **范围**:关键组件(AddressInput 补全、PermissionPanel 决策、WorkCard 预算渲染)。 **已有的浏览器实测**:`web/test/manual/`(`npm run test:narrow` / `npm run test:wide`)—— 连本机共享 Chromium 量真实盒子与命中区。 不在 `npm test` 里,因为要一个跑着的浏览器加一个活的 Gateway。 剩下的是把它接进 CI(需要一个 headless 环境与一个测试用 Gateway 实例)。 ### 窄屏实测修复(2026-09-02)✅ 用 playwright 连本机共享 Chromium,在 390px(iPhone 14 Pro)与 320px(iPhone SE) 两档实测。**一个功能性 bug 加五处可用性问题**: **抽屉式侧栏遮挡底部导航(真 bug,已删抽屉)** 抽屉是 `fixed left-0 top-0 bottom-0 z-50`,铺满整个视口高度;底部导航没有 z-index。抽屉打开时点最左那一项「收件」,`elementFromPoint` 命中的是抽屉里的 SVG,不是导航按钮。 修法是**删掉抽屉**而不是给导航加 z-index:抽屉里六项(收件/发件/联系/用户/ 新建/管理)与底部导航完全重复,唯一独有的是退出登录。为一个按钮维护一套 fixed 层级 + 遮罩不划算,何况它还引入了遮挡、Esc 关不掉、底层未锁滚三个问题。 退出登录移到「我的」页 —— 它与密码、密钥同属「账号自身」,而那里此前根本 没有退出入口。 **触摸命中区(新增 `.tap`)** 详情页那排工具按钮视觉高度只有 15-16px(实测「标记已读」48x16、「对话树」 54x16、「转发」42x16、「抄送」20x15),移动端下限是 44x44。 直接加 padding 会把本来就挤的头部撑散、在 320px 上换行,因此改用居中的透明 伪元素扩大命中区:**视觉一像素不动**。只在 `max-width: 767px` 生效 —— 桌面用鼠标精度足够,而扩大后的命中区在密排工具栏里会互相重叠。 覆盖 MailView / ContactPanel / WorkCard / ModelScopePanel / AdminUsersPage / ComposePage / ThreadView / KeyPanel / QuotaPanel / Attachments / BackButton。 **悬停才显形的按钮在触摸设备上永远透明却按得动(新增 `.reveal`)** `opacity-0 group-hover:opacity-100` 在没有 hover 的设备上永远是 `opacity: 0`, 但仍然接收点击 —— 实测联系人列表里 `elementFromPoint` 命中的就是那个看不见的 「归档」。一个看不见却按得动的破坏性按钮比没有按钮更糟:人以为点的是卡片, 实际归档了一条会话。 改为默认可见,只在 `(hover: hover) and (pointer: fine)` 时隐藏 —— 单看 `hover` 会把带触摸板的平板算进去。 **对话树缩进在 320px 下把卡片压成竖条** 固定「每级 20px、上限 8 级」= 最多 160px;320px 屏还要去掉 `px-4` 的 32px 与 连接线 18px,卡片只剩 110px,发件人一行直接被 truncate 吃掉。 窄屏改成每级 10px、上限 5 级。 **对话树窄屏没有返回出口** 只有「关闭」。两者语义不同:返回退出整个详情栏回到列表,关闭只收起树、 留在这封邮件上。补了 `BackButton`。 **「我的」页根本没有滚动容器(用户反馈)** 窄屏外壳是 `h-full flex flex-col overflow-hidden`、页面是 `flex-1 flex flex-col`, 中间缺一层 `overflow-y-auto` —— 内容超出的部分**直接被裁**,滚不到也点不到。 实测 390px 下内容需 860px、容器 795px,「退出登录」连同下面 65px 一起消失; 1280x800 的桌面上同样看不到。其余六个页面级组件都有这一层,只有它漏了。 **登录页 / 初始化页在矮屏滚不到底** 卡片高约 371px,而 `h-full flex items-center` 在内容超高时让它上下**同时**溢出, 溢出到顶部那段滚不到(`scrollTop` 最小是 0)—— 实测 568x280(横屏手机、 或软键盘弹出后的可视高度)下「登录」按钮完全在视口外,光加 `overflow-y-auto` 也够不着。改用卡片自己的 `my-auto`:auto margin 在空间不足时退化为 0, 矮屏变成顶对齐可滚布局,高屏仍然垂直居中。 #### 验证 playwright 端到端 18 项全通过(含「底部导航六项都命中自己」、 「工具按钮命中区 >= 44px」、五个页面各有纵向滚动容器、无横向溢出、 320px 无横向溢出);宽屏回归 5 项全通过(三栏并排、常驻侧栏仍有退出登录、 无返回按钮、`.tap` 伪元素在宽屏不生效、无溢出)。 `web/test/narrow-layout.test.mjs` 从 20 条扩到 37 条,把上述每一条都钉住。 滚动检查的判据是「**有**滚动容器」而不是「当前正在滚动」: 内容暂时不够高时后者为假,但页面是健康的 —— 真正的 bug 是根本没有那一层。 ### P2 — 深色主题 **现状**:只有浅色主题,深夜使用刺眼。 **范围**:tailwind dark: 前缀覆盖主要组件。 ### P1 — 每平台可用模型范围 ✅ **需求**:配置页面为每个 Agent 平台划定「邮箱调用场景下可用的模型范围」, 端侧插件按范围**逐个降级尝试**,全部失败时把失败原因封装成邮件回复。 选择而非手打模型名 —— 平台上报目录,管理员勾选。 **已完成**: - `agent_model_catalog`(平台上报的目录)+ `agent_allowed_models`(管理员的选择) 两张表,两份 schema - `repo/models_scope.go`:`ReplaceModelCatalog` / `ListModelCatalog` / `ListAllowedModels` / `SetAllowedModels` **为什么分两张表**:模型会从平台目录里消失(换了 provider 配置、上游临时下线), 整行删掉会连带把管理员的选择也删了,模型回来还得重配一遍。分开存之后 「选了什么」是持久的,目录只决定「这一项现在是否可用」。 **已完成(全部)**: - [x] handler + 路由:`GET/PUT /admin/agents/{name}/models`、`GET /agent/models/allowed` - [x] **目录上报走心跳**而不是另设端点:模型清单会在运行中变, 心跳本来就是 30 秒一次的现成通道;另设一个 POST 等于给「目录是谁写的」 留两个答案 - [x] 心跳响应回传 `allowed_models`:管理员改了范围后最多一个周期生效,不必重启 - [x] `lib/model-scope.js`:目录整理(两平台)、`modelAttemptOrder`、`renderFailureReport` - [x] 插件按 rank 逐个尝试,全部失败发一封说明原因的邮件(走免配额通道) - [x] 前端 `ModelScopePanel`:勾选 + 上下移调序 + stale 标记 **最难的一点**(两个平台都踩了):**模型失败不是同步抛出的**。 `promptAsync()` 立即返回、`ctx.agents.create()` 不校验模型,只包 try/catch 第二个模型永远不会被试到。要等异步结论: - opencode → `session.error` 事件 - DSH → `turn/end` 的 `reason.kind === 'error'` DSH 还有个陷阱:`assistant/chunk` 的 `finish` 子类型也带错误, 把任意 chunk 当成功会让无效 provider 判成走通(实测踩过)。