三层分离:事件是日历实体,提醒是触发器,邮件是投递通道。
`from_name = "calendar"` 刻意既非人类名也非 Agent 名 —— 用创建者的名字
会让 Agent 以为人在实时找它,而人此刻可能在睡觉,模型据此判断
「要不要马上追问」,来源写错会让它问一个不在线的人。
日历提醒不扣会话预算:预算的语义是「这件事值得模型自主发多少封信」,
而提醒是人预先设定的定时任务,不是模型的自主行为。
**多收件人两种投递模式,都要**:
- separate(默认)= 各发一封、落各自会话、互相看不到
- together = 首个为主收件人、其余进 cc_list、共享一条线索
「让三个 Agent 各自独立汇报」与「让 pi 主办、dsh 知情」是完全不同的任务
形态。默认 separate 因为失败模式更轻:together 用错会让本该独立判断的
Agent 互相看到回复而趋同,那种上下文污染事后无法分离。
抄送方也推 SSE。漏了这步的后果很隐蔽:cc_list 里有他们、查收件箱看得见,
但没有任何事件推给他们 —— 插件不会唤起会话,Agent 到下次补拉才发现。
recipients 存**原始地址串**而非结构化:session 位的 new/别名三态该在
触发那一刻解析,存结构化会让「.new」这种一次性语义在建事件时就被固化,
而重复事件每次触发都该重新决定落到哪条会话。
---
修掉的七个真问题:
**1. 默认提醒模板把时间烤成字面值。**
原来 Sprintf 出含字面时间的正文存进 reminder_text。对重复事件是错的:
AdvanceRecurrence 只推进 event_time,模板不动 —— 「每天 9 点」的提醒
从第二天起永远写着第一天的日期,且不报任何错。改为存变量形式。
**2. `{time}` 渲染成 UTC。**
DSN 带 _timezone=UTC,读回的 EventTime 是 UTC。直接 Format 会把人在
+0800 输入的 14:30 写成 06:30,而前端预览用本地时间 —— 两边差 8 小时
且都不报错。
**3. 同一提醒每 tick 重发一次(生产实测 4 封)。**
DueEvents 有 60 秒 lookahead(周期 30 秒,不提前看会迟到)。去重判据
原本是 `last_fired_at < event_time` —— 触发时刻本来就早于落在窗口内的
event_time,条件恒真。实测一条 12:53:17 的事件在 12:52:30 / 12:53:00 /
12:53:06 / 12:53:36 各发一封。新增 fired_for 列记录**已触发的
occurrence**,判据改为 `fired_for <> event_time`。
**4. 过期重复事件刷屏。**
AdvanceRecurrence 只推一步:一条 100 天前设的每日事件每轮都判定过期 →
发一封 → 只前进一天 → 下轮又过期。实测 30 轮触发 30 次,而周期是
30 秒。改成 advanceToFuture 一路推到越过当前时刻;跳过的 occurrence
不补发(三个月前那次站会提醒现在发出去毫无意义,只会淹掉该看的那封)。
带 maxAdvanceSteps=4000 上限:农历路径依赖外部库,没有上限就是个死循环
goroutine,而它跑在调度器里 —— 整个提醒系统会一起卡住。
**5. 越过 recurrence_end 不置 cancelled。**
留在 active 会变僵尸事件:DueEvents 每轮都捞到它(event_time 在过去),
但 fired_for 已等于 event_time 所以又不触发。
**6. 附件从未落盘。**
`data := make([]byte, header.Size); file.Read(data)` 两处错:单次 Read
不保证填满缓冲(大文件必然短读,sha256 算的是半截内容),而且文件内容
压根没写进 blob 存储。结果是附件「上传成功」、清单里看得见、
发提醒时取不到任何字节。改走 Blobs.Put。
**7. iCal TRIGGER 往返是断的。**
导出写 `-P15M`、导入找 `-PT%dM`,自己导出的文件自己都读不回来。更糟的是
`-P15M` 在任何合规客户端里都是「提前 **15 个月**」—— iCal duration 的 M
在 T 之前是月、之后才是分钟。而且用 maxInt(RemindBefore,15) 兜底,把用户
明确设的「到点提醒」(0) 悄悄改成提前 15 分钟。
---
其他修正:
- **PG schema 整块缺失日历两张表** —— DATABASE_URL 非空时所有 /calendar/*
在 relation does not exist 上 500,而 SQLite 下一切正常,问题只在切外部库
时才暴露
- DeleteCalendarAttachment 曾返回 501,让人「删整个事件来清附件」
- 导出忽略 from/to;导入只接受 multipart(命令行调用者收到含糊的
「Missing file field」)
- 上传附件不校验事件存在 —— 会攒孤儿记录,而 ON DELETE CASCADE 清不掉
它们(SQLite 的 foreign_keys 默认关)
- 农历规则 RRULE 表达不了,走 X-AGENTMAIL-RECURRENCE 扩展属性 + 公历近似
兜底。**RRULE 分支不能覆盖已读到的农历值** —— X- 出现在 RRULE 之前时
无条件赋值会把 lunar_monthly 打回 monthly,往返一圈农历规则悄悄退化
- 抽出 calendarCols 常量:原先四处手抄同一串列名,加一列漏改任何一处
不会编译报错,只会运行时列错位(ListSessionsFor 上真的发生过)
测试:repo 20+ 例(到期判定/幂等/lookahead 不重发/过期不刷屏/农历推进/
多收件人/兜底链)、handler 20 例 iCal、scheduler 7 例模板渲染。
生产端到端验证并清理了数据。
112 lines
3.7 KiB
Go
112 lines
3.7 KiB
Go
package scheduler
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/agentmail/gateway/internal/models"
|
||
)
|
||
|
||
func TestRenderReminder(t *testing.T) {
|
||
at := time.Date(2026, 9, 4, 9, 30, 0, 0, time.Local)
|
||
e := models.CalendarEvent{
|
||
Title: "每日站会",
|
||
Description: "同步昨天进展与今天计划",
|
||
EventTime: at,
|
||
}
|
||
|
||
t.Run("三个变量都替换", func(t *testing.T) {
|
||
got := RenderReminder("日程提醒:{title}\n时间:{time}\n{description}", e)
|
||
for _, want := range []string{"每日站会", "2026-09-04 09:30", "同步昨天进展与今天计划"} {
|
||
if !strings.Contains(got, want) {
|
||
t.Errorf("渲染结果缺 %q:\n%s", want, got)
|
||
}
|
||
}
|
||
if strings.Contains(got, "{") {
|
||
t.Errorf("仍有未替换的占位符:\n%s", got)
|
||
}
|
||
})
|
||
|
||
t.Run("同一变量出现多次全部替换", func(t *testing.T) {
|
||
// ReplaceAll 而非 Replace:模板里写两遍 {title} 时
|
||
// 只替换第一处会让 Agent 收到一封半成品邮件。
|
||
got := RenderReminder("{title} —— 请开始 {title}", e)
|
||
if strings.Contains(got, "{title}") {
|
||
t.Errorf("第二处 {title} 未替换:%s", got)
|
||
}
|
||
})
|
||
|
||
t.Run("空模板不产生占位符残留", func(t *testing.T) {
|
||
if got := RenderReminder("", e); got != "" {
|
||
t.Errorf("空模板应渲染成空串,得到 %q", got)
|
||
}
|
||
})
|
||
|
||
t.Run("没有变量的模板原样返回", func(t *testing.T) {
|
||
const plain = "该跑测试了"
|
||
if got := RenderReminder(plain, e); got != plain {
|
||
t.Errorf("纯文本模板应原样返回,得到 %q", got)
|
||
}
|
||
})
|
||
|
||
t.Run("描述为空时不留下空行以外的痕迹", func(t *testing.T) {
|
||
e2 := e
|
||
e2.Description = ""
|
||
got := RenderReminder("{title}|{description}|", e2)
|
||
if got != "每日站会||" {
|
||
t.Errorf("空描述应替换成空串,得到 %q", got)
|
||
}
|
||
})
|
||
|
||
// {time} 必须是本地时间。DSN 带 _timezone=UTC,从库里读回的 EventTime
|
||
// 是 UTC;不转本地就会把人在 +0800 输入的 14:30 写成 06:30,
|
||
// 而前端预览用的是本地时间 —— 两边差 8 小时且都不报错。
|
||
t.Run("time 用本地时区而非 UTC", func(t *testing.T) {
|
||
// 刻意构造一个 UTC 时刻(模拟从库里 Scan 出来的样子)
|
||
utcEvent := models.CalendarEvent{
|
||
Title: "跨时区检查",
|
||
EventTime: time.Date(2026, 9, 3, 6, 30, 0, 0, time.UTC),
|
||
}
|
||
got := RenderReminder("{time}", utcEvent)
|
||
want := time.Date(2026, 9, 3, 6, 30, 0, 0, time.UTC).Local().Format("2006-01-02 15:04")
|
||
if got != want {
|
||
t.Errorf("{time} = %q,期望本地时间 %q", got, want)
|
||
}
|
||
})
|
||
|
||
// 同一时刻无论以哪个时区的 Location 传进来,渲染结果必须一致 ——
|
||
// 它代表的是「墙上时钟的那一刻」,与 Location 的表示方式无关。
|
||
t.Run("同一时刻不同 Location 渲染一致", func(t *testing.T) {
|
||
base := time.Date(2026, 9, 3, 6, 30, 0, 0, time.UTC)
|
||
a := RenderReminder("{time}", models.CalendarEvent{EventTime: base})
|
||
b := RenderReminder("{time}", models.CalendarEvent{EventTime: base.Local()})
|
||
if a != b {
|
||
t.Errorf("UTC 与 Local 表示同一时刻却渲染出不同结果:%q vs %q", a, b)
|
||
}
|
||
})
|
||
}
|
||
|
||
func TestShort(t *testing.T) {
|
||
// 日志里截前 8 位;短 id(测试里可能出现)不能 panic
|
||
if got := short("0123456789abcdef"); got != "01234567" {
|
||
t.Errorf("长 id 应截断成 8 位,得到 %q", got)
|
||
}
|
||
if got := short("abc"); got != "abc" {
|
||
t.Errorf("短 id 应原样返回,得到 %q", got)
|
||
}
|
||
if got := short(""); got != "" {
|
||
t.Errorf("空串应原样返回,得到 %q", got)
|
||
}
|
||
}
|
||
|
||
func TestStartStopIdempotent(t *testing.T) {
|
||
// Stop 在没启动时被调(defer 里必然发生)不该 panic;
|
||
// Start 两次也不该泄漏 goroutine(第二次先停旧的)。
|
||
Stop()
|
||
Start()
|
||
Start()
|
||
Stop()
|
||
Stop()
|
||
}
|