24 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 — 组件级测试
现状:有结构性回归(client/electron/test/narrow-layout.test.mjs,28 条断言,
读源码验形态)与一套 playwright 手工脚本,但没有渲染组件跑断言的测试。
范围:关键组件(AddressInput 补全、PermissionPanel 决策、WorkCard 预算渲染)。
已有的浏览器实测:client/electron/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 伪元素在宽屏不生效、无溢出)。
client/electron/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 判成走通(实测踩过)。
日历与待办 ✅ 后端 + 前端
参照 Outlook 的逻辑与 UI:事件/提醒/重复规则、iCal 导入导出、 提醒经邮件通知指定 Agent(正文可编辑、支持变量预填充)、提醒可带附件。
三层分离
事件是日历实体,提醒是触发器,邮件是投递通道。三者刻意不合并:
- 事件(
calendar_events)有时间、重复规则、收件方 - 提醒由调度器在
event_time − remind_before触发 - 邮件由
SendCalendarMail投递,from_name = "calendar"
from_name 刻意既不是人类用户名也不是 Agent 名。用创建者的名字会让 Agent
以为人在实时找它,而人此刻可能在睡觉 —— 模型据此判断「要不要马上追问」,
来源写错会让它问一个不在线的人。
日历提醒不扣会话预算:预算的语义是「这件事值得模型自主发多少封信」, 提醒是人预先设定的定时任务,不是模型的自主行为。让它扣预算会出现 「每天 9 点的日报提醒把当天预算吃掉一格」这种反直觉结果。
已完成
后端
- schema 两份(
calendar_events+calendar_attachments,SQLite 与 PG) repo/calendar.go:CRUD、DueEvents、MarkEventFired、AdvanceRecurrence、 附件增删列、AttachCalendarFilesToMailhandler/calendar.go:9 端点 + iCal 导入导出scheduler/calendar.go:30 秒 ticker、RenderReminder、fireEvent
前端
lib/calendar.ts:日期边界、月/周格子、分桶、模板渲染、datetime-local往返CalendarView.tsx:月/周/日三粒度 + 工具条 + iCal 导入导出按钮CalendarEventEditor.tsx:整页编辑器 + 变量按钮 + 实时预览 + 附件区- 导航项(Sidebar + NarrowNav)+
App.tsx路由
修掉的六个真问题
1. 默认提醒模板把时间烤成字面值
原来 Sprintf 出一份含字面时间的正文存进 reminder_text。对重复事件是错的:
AdvanceRecurrence 只推进 event_time,reminder_text 保持不动 ——
「每天 9 点」的提醒从第二天起永远写着第一天的日期,且不报任何错。
改为存变量形式({title}/{time}/{description}),触发时才替换。
后端 defaultReminderTemplate 与前端 DEFAULT_TEMPLATE 逐字一致,有测试钉住。
2. {time} 渲染成 UTC
DSN 带 _timezone=UTC,从库里读回的 EventTime 是 UTC。直接 Format
会把人在 +0800 输入的 14:30 写成 06:30,而前端预览用的是本地时间 ——
两边差 8 小时且两边都不报错。修法:e.EventTime.Local().Format(...)。
3. 同一提醒每个 tick 重发一次(生产实测 4 封)
DueEvents 有 60 秒 lookahead(调度周期 30 秒,不提前看会让提醒迟到)。
去重判据原本是 last_fired_at < event_time —— 触发时刻(now)本来就早于
落在窗口内的 event_time,条件恒真。实测一条 12:53:17 的事件在
12:52:30 / 12:53:00 / 12:53:06 / 12:53:36 各发了一封。
修法:新增 fired_for 列记录已触发的 occurrence(值 = 当时的 event_time),
判据改为 fired_for <> event_time。AdvanceRecurrence 改了 event_time
就重新到期,没改就永不重发。
4. 附件从未落盘
data := make([]byte, header.Size); file.Read(data) 两处错:单次 Read
不保证填满缓冲(大文件必然短读,sha256 因此算的是半截内容),而且
文件内容压根没写进 blob 存储,只往库里写了一条元数据。
结果是附件「上传成功」、清单里看得见、发提醒时取不到任何字节。
改为走 Blobs.Put(与邮件附件同一套双层大小限制)。
5. 事件附件不会随提醒发出
事件附件与邮件附件是两张表。缺了复制这一步,附件只存在于日历侧 ——
UI 里看得见、提醒按时发出、而 Agent 收到的那封信附件清单是空的。
新增 AttachCalendarFilesToMail:内容寻址下只增元数据不拷磁盘文件。
失败不阻断投递 —— 提醒正文比附件重要,少一个附件比整条提醒发不出去好。
6. iCal TRIGGER 往返是断的
导出写 -P15M、导入找 -PT%dM,自己导出的文件自己都读不回来。
更糟的是 -P15M 在任何合规客户端里都是「提前 15 个月」——
iCal duration 的 M 在 T 之前是月、之后才是分钟。
而且原来用 maxInt(RemindBefore, 15) 兜底,把用户明确设的「到点提醒」(0)
悄悄改成提前 15 分钟;导出不该修改语义。
修法:导出写 -PT<n>M,导入换成正经的 duration 解析
(parseTriggerMinutes 支持 -PT30M/-PT1H30M/-P1D/-P1W,
T 前的 M(月)与正号 trigger 一律忽略而不是乱换算)。
其他修正
- PG schema 整块缺失日历两张表 ——
DATABASE_URL一旦非空,所有/calendar/*在relation does not exist上 500,而 SQLite 下一切正常, 问题只在切外部库时才暴露 DeleteCalendarAttachment曾返回 501,让人「删整个事件来清附件」—— 撤一个错传的文件不该要求把整条日程连提醒配置一起重建- 导出忽略
from/to—— 写死 ±1 年会让人点导出后得到一堆与屏幕上不符的事件 - 导入只接受 multipart —— 命令行调用者收到含糊的「Missing file field」。
改为同时接受 raw
text/calendar(curl --data-binary @x.ics) - 上传附件不校验事件存在 —— 会攒下孤儿附件记录,而
ON DELETE CASCADE永远清不掉它们(没有父行可删)
前端的三个易错点(都有测试钉住)
周首必须是周一。getDay() 把周日算作 0,直接减它会让周日归到上一周
末尾,月视图第一行整体错位。
分桶用本地日期串而不是 toISOString().slice(0,10)。后者给 UTC 日期,
东八区晚上 8 点后的事件会被归到第二天的格子里。
月视图固定 42 格。按需 4~6 行会让网格高度随月份跳动,翻月时页面弹动。
另有一个 TS 陷阱:replaceAll 在 tsconfig 的 target: ES2020 下不存在
(TS2550)。改用 split/join;不能退回 replace,那只换第一个,
同一变量写两次时第二个会原样漏进邮件。
验证
internal/repo/calendar_test.go:CRUD、到期判定、幂等、重复推进、区间查询、 附件增删列、lookahead 窗口内不重发、推进后重新到期、 事件附件复制成邮件附件(含空 sha256 脏数据跳过)internal/handler/ics_test.go14 例:解析基本形态/多事件/无 DTSTART 丢弃/ 重复规则/三种日期格式/带 TZID 参数/转义换行/LF 换行/垃圾输入/ TRIGGER duration 全形态/导出导入往返/默认模板必须是变量形式internal/scheduler/calendar_test.go:模板渲染 7 例(含本地时区与 同一时刻不同 Location 渲染一致)client/electron/test/components/calendar.test.tsx35 例- 生产端到端:建事件 → 30 秒内触发 → 邮件入库、正文变量已替换成本地时间 →
重复事件推进到次日 → 连续两个 tick 零重发(修复前每 tick 一封)→
iCal 导入
-PT45M/FREQ=WEEKLY正确落库 → 导出再导入闭环一致 → 清理验证数据
未做
- 附件下载端点(日历侧):目前只能通过提醒邮件里的附件下载
parseICS不处理折叠续行(RFC 5545 的 75 字节折行):长 DESCRIPTION 会被截断。常见客户端导出的短字段不受影响RRULE只认FREQ=,忽略INTERVAL/BYDAY/COUNT/UNTIL- 周/日视图不按小时定位色块高度(事件都是等高行,不体现时长)
B-8(权限询问转邮件)在 HomeAgent 上不适用
四平台能力矩阵里 homeagent 的 B-8 一直标着「❌ 要先摸清 homed approval API」。 本轮查清了:那个 API 不存在,而且不该存在。
判据:在 SDK(third_party/homeagent-sdk/sdk/*.go)与核心
(internal/**/*.go)两处 grep approval|consent|permission|confirm,
命中数均为 0。
原因是设计取向不同。另三个平台各有一个现成的审批环节,桥做的只是把它从 本地 TUI 改道到邮件通道:
| 平台 | 审批钩子 |
|---|---|
| opencode | permission.ask |
| DSH | approval/request |
| pi | tool_call |
HomeAgent 的核心是一个纯思维核 —— 本身没有任何对外交互能力, 全部能力来自插件,也没有会话这一层(单事件循环)。它不问人: 工具调用直接执行。
它确实有一个可以拦下调用的位置:StageBeforeToolcall
(internal/agent/core/process.go:273,插件给 ctx.Response 赋值即视为拒绝,
核心会把「工具 X 已被插件拒绝」当作 tool 结果喂回模型并发 status: "denied"
事件)。但那是「插件可以否决」而不是「平台在征求同意」:没有待批准的请求、
没有选项、也没有等人的语义。
因此 B-8 在这个平台上是 N/A,不是待实现项。 硬要补等于给平台加一层
它本来没有的能力:要自己划高风险工具白名单、自己定义超时与 fail closed
语义、自己决定人不在时怎么办 —— 那些都是产品决策而不是契约合规。
(技术上可行:RegisterTool 的 handler 是我们的代码,能在执行前发权限邮件
并阻塞等待;InjectInputSync 证明这套 SDK 里「同步等外部答复」是既有形态。
但没有需求驱动就不做。)
随之而来的一个事实,需要知道
homeagent 的工具全部无条件执行,其中包括 cmd_run 这类能力。
它接入 AgentMail 之后,任何能给 homeagent@… 发信的人或 Agent 都能间接
触发这些工具,中间没有人类确认环节。
另三条链上至少有一道兜底:桥收到 409(Gateway 判定整条会话树上没有人类 可路由)时当场表态拒绝。homeagent 这条链没有这一环 —— 因为它根本不发起询问。
这不是缺陷,是那个平台的信任模型:homed 及其插件都跑在用户自己的机器上, 默认完整权限。记在这里是为了让「谁能给 homeagent 发信」这个问题被当作 访问控制来对待,而不是当作邮件权限。
契约文档的改动
B-8标题从「若平台支持,MUST」改为「若平台有审批环节,MUST;没有则 N/A」, 并补一段说明前置条件与StageBeforeToolcall的语义差异- 平台差异对照表补齐 HomeAgent 一列(14 行)。它是四个平台里
唯一不需要为每封邮件开平台侧会话的 —— 没有会话概念,所有邮件注入同一个
事件循环,靠
output_send__agentmail输出通道送回复 - 检查清单里「未决权限询问 fail closed」标注适用条件