原 PLUGIN-GUIDE 是叙事式的「怎么做 + 踩过的坑」,读者要自己从散文里推断 「我到底必须做什么」。接第三个平台时这不够用 —— 尤其当照着实现的是一个代理。 改为规格式,编号可引用、强度明确标注、每条尽量给出可机械核对的判据。 旧文档的内容全部保留(迁进第八、九节),另补上原先没有的四类: ## 一、能力矩阵(新增) 回答「这个平台能不能接」。七项必需能力(C-1..C-7)加七项可选(C-8..C-14), 每项给出判据。附一个七问自检 —— 任何一问答不出来就先别写代码。 其中 C-4「轮次结束信号必须能区分成功与出错」在两次适配里都被漏掉过, 两次都造成「无效模型被判成成功」,所以单独标了出来。 ## 二、行为约定(重写) 原先散在各节的要求收拢成一个状态机,按事件逐条规定:B-1 启动 / B-2 心跳 / B-3 new_mail / B-4 permission_decision / B-5 轮次结束 / B-6 无法处理时回信 / B-7 启动补拉 / B-8 权限询问 / B-9 关停。 ## 四、降级语义(新增) 平台缺某项能力时的确切退化路径(D-1..D-7)。原文档只说了「可选」, 没说缺了之后该怎么办 —— 于是「不支持权限钩子」很容易被实现成 「提供 request_permission 工具补偿」,而那正是 I-1 反对的模式。 ## 六、不变量与禁止事项(新增) 12 条 MUST NOT,每条附「违反会怎样」。这些是测试全绿、跑起来也不报错, 但行为就是错的那类问题 —— 例如拉取失败时传 [] 而非省略字段会清空服务端目录。 ## 七、验收清单(新增) 八组可勾选项,每条给出具体命令:grep 自查禁止事项、sqlite3 查在线状态与 配额未被消耗、停插件发信再启动看补投日志。 ## 核对过的事实 写完逐项核对了代码,不是凭记忆: - 12 个端点全部在 main.go 里存在且方法一致 - 发信 9 个字段名与 sendMailRequest 的 json tag 一致 - 心跳响应 12 个字段名与 handler 一致 - 九个数字(30s 心跳 / 25MB 附件 / 20 次每小时 / 补投 5 封 / 快照 200 条 / 模型上限 10 / 目录上限 300 / 降级超时 60s / inbox 默认 5)都能在代码里找到出处 - 验收清单里的六条 sqlite 查询都在生产库上跑通 - 38 个编号无重复,18 处交叉引用全部有定义 引用同步:PLAN.md、PHASE7-REMAINING.md、API.md、README.md、 install.sh、check-shared-libs.sh。
13 KiB
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 + 固定文案的调用点全部换过去。
现在同样的请求回:
{"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(管理员的选择) 两张表,两份 schemarepo/models_scope.go:ReplaceModelCatalog/ListModelCatalog/ListAllowedModels/SetAllowedModels
为什么分两张表:模型会从平台目录里消失(换了 provider 配置、上游临时下线), 整行删掉会连带把管理员的选择也删了,模型回来还得重配一遍。分开存之后 「选了什么」是持久的,目录只决定「这一项现在是否可用」。
已完成(全部):
- handler + 路由:
GET/PUT /admin/agents/{name}/models、GET /agent/models/allowed - 目录上报走心跳而不是另设端点:模型清单会在运行中变, 心跳本来就是 30 秒一次的现成通道;另设一个 POST 等于给「目录是谁写的」 留两个答案
- 心跳响应回传
allowed_models:管理员改了范围后最多一个周期生效,不必重启 lib/model-scope.js:目录整理(两平台)、modelAttemptOrder、renderFailureReport- 插件按 rank 逐个尝试,全部失败发一封说明原因的邮件(走免配额通道)
- 前端
ModelScopePanel:勾选 + 上下移调序 + stale 标记
最难的一点(两个平台都踩了):模型失败不是同步抛出的。
promptAsync() 立即返回、ctx.agents.create() 不校验模型,只包 try/catch
第二个模型永远不会被试到。要等异步结论:
- opencode →
session.error事件 - DSH →
turn/end的reason.kind === 'error'
DSH 还有个陷阱:assistant/chunk 的 finish 子类型也带错误,
把任意 chunk 当成功会让无效 provider 判成走通(实测踩过)。