docs: 日历子系统 + 寻址发现工具组 + 权限死锁的排查记录

PHASE7-REMAINING 新增日历一节:三层分离的理由、修掉的六个真问题
(含每一个的现场取证与判据)、前端三个易错点、验证清单、未做的缺口。

写成「可核对的规格 + 踩坑理由」而不是叙事:这些 bug 的共同点是
**不报错**(模板烤死时间、{time} 渲染成 UTC、每 tick 重发、附件从未落盘、
TRIGGER 往返断开、PG schema 缺表),下一个人只有知道判据才能避开。

README 把插件工具表从「六个」更新到十一个(现在 homeagent 是十五个),
并说明寻址发现那一组解决的是**猜地址** —— 生产上真的发生过一个 Agent
猜了 opencode@/home,投递成功但那不是它的工作目录,那封邮件静默变成了
一条平行会话的开端。

PLUGIN-CONTRACT 补 B-8(权限询问转邮件)的判据表与三平台差异。
This commit is contained in:
2026-09-04 06:30:56 +08:00
parent d74f356f41
commit bca50b80c6
3 changed files with 397 additions and 7 deletions

View File

@ -220,3 +220,138 @@ playwright 端到端 18 项全通过(含「底部导航六项都命中自己
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`、
附件增删列、`AttachCalendarFilesToMail`
- `handler/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.go` 14 例:解析基本形态/多事件/无 DTSTART 丢弃/
重复规则/三种日期格式/带 TZID 参数/转义换行/LF 换行/垃圾输入/
**TRIGGER duration 全形态**/**导出导入往返**/**默认模板必须是变量形式**
- `internal/scheduler/calendar_test.go`:模板渲染 7 例(含**本地时区**与
**同一时刻不同 Location 渲染一致**)
- `web/test/components/calendar.test.tsx` 35 例
- 生产端到端:建事件 → 30 秒内触发 → 邮件入库、正文变量已替换成本地时间 →
重复事件推进到次日 → **连续两个 tick 零重发**(修复前每 tick 一封)→
iCal 导入 `-PT45M`/`FREQ=WEEKLY` 正确落库 → 导出再导入闭环一致 →
清理验证数据
### 未做
- 附件下载端点(日历侧):目前只能通过提醒邮件里的附件下载
- `parseICS` 不处理折叠续行(RFC 5545 的 75 字节折行):长 DESCRIPTION
会被截断。常见客户端导出的短字段不受影响
- `RRULE` 只认 `FREQ=`,忽略 `INTERVAL`/`BYDAY`/`COUNT`/`UNTIL`
- 周/日视图不按小时定位色块高度(事件都是等高行,不体现时长)