三层分离:事件是日历实体,提醒是触发器,邮件是投递通道。
`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 例模板渲染。
生产端到端验证并清理了数据。
135 lines
5.8 KiB
Go
135 lines
5.8 KiB
Go
package db
|
||
|
||
import (
|
||
"context"
|
||
_ "embed"
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
//go:embed migrations/init.sql
|
||
var initSQLPostgres string
|
||
|
||
//go:embed migrations/init_sqlite.sql
|
||
var initSQLSQLite string
|
||
|
||
// Migrate 建表建索引。两种方言各有一份 schema,语义保持一致。
|
||
func Migrate(ctx context.Context) error {
|
||
switch D {
|
||
case Postgres:
|
||
// PG 侧含 DO $$ … $$ 迁移块,必须整体提交
|
||
if _, err := DB.ExecContext(ctx, initSQLPostgres); err != nil {
|
||
return fmt.Errorf("migrate postgres: %w", err)
|
||
}
|
||
case SQLite:
|
||
// modernc.org/sqlite 的 Exec 不接受多语句,逐条执行
|
||
for i, stmt := range splitStatements(initSQLSQLite) {
|
||
if _, err := DB.ExecContext(ctx, stmt); err != nil {
|
||
return fmt.Errorf("migrate sqlite (语句 #%d: %.60s): %w", i+1, stmt, err)
|
||
}
|
||
}
|
||
// CREATE TABLE IF NOT EXISTS 不会给**已存在**的表补列,而 SQLite 又没有
|
||
// ADD COLUMN IF NOT EXISTS。已部署的库靠这一步补齐新列。
|
||
if err := addMissingColumns(ctx); err != nil {
|
||
return err
|
||
}
|
||
default:
|
||
return fmt.Errorf("migrate: 未初始化的方言")
|
||
}
|
||
|
||
fmt.Printf("数据库迁移完成(%s)\n", D)
|
||
return nil
|
||
}
|
||
|
||
// splitStatements 按分号切分 SQL 脚本并剔除注释行。
|
||
// 本项目的 SQLite schema 只有 CREATE 语句,不含字符串字面量里的分号,
|
||
// 因此按分号朴素切分是安全的;若将来加入含分号的字面量需改用真正的词法切分。
|
||
func splitStatements(script string) []string {
|
||
var out []string
|
||
for _, raw := range strings.Split(script, ";") {
|
||
var lines []string
|
||
for _, line := range strings.Split(raw, "\n") {
|
||
if t := strings.TrimSpace(line); t == "" || strings.HasPrefix(t, "--") {
|
||
continue
|
||
}
|
||
lines = append(lines, line)
|
||
}
|
||
if stmt := strings.TrimSpace(strings.Join(lines, "\n")); stmt != "" {
|
||
out = append(out, stmt)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// sqliteAddColumns 声明 SQLite 侧需要在已存在的表上补齐的列。
|
||
//
|
||
// 新库由 init_sqlite.sql 的 CREATE TABLE 一次建全,这里只服务**已部署的库**。
|
||
// PG 侧用 ALTER TABLE ... ADD COLUMN IF NOT EXISTS 就够,SQLite 没有这个语法,
|
||
// 只能先查 pragma 再决定加不加。
|
||
//
|
||
// 新增列时同时改两处:init_sqlite.sql 的 CREATE TABLE(给新库)与这张表(给老库)。
|
||
var sqliteAddColumns = []struct{ table, column, ddl string }{
|
||
{"mails", "rename_alias", "ALTER TABLE mails ADD COLUMN rename_alias TEXT"},
|
||
{"mails", "rename_reason", "ALTER TABLE mails ADD COLUMN rename_reason TEXT"},
|
||
{"sessions", "rename_dismissed", "ALTER TABLE sessions ADD COLUMN rename_dismissed TEXT"},
|
||
{"sessions", "alias_source", "ALTER TABLE sessions ADD COLUMN alias_source TEXT NOT NULL DEFAULT 'platform'"},
|
||
// 会话级往返预算(0 = 不限)。旧库默认 0:引入预算不应该把已在进行的会话卡死。
|
||
{"sessions", "max_rounds", "ALTER TABLE sessions ADD COLUMN max_rounds INTEGER NOT NULL DEFAULT 0"},
|
||
{"sessions", "used_rounds", "ALTER TABLE sessions ADD COLUMN used_rounds INTEGER NOT NULL DEFAULT 0"},
|
||
// 会话所属的工作目录。旧库默认空串:历史会话的 workspace 无法可靠反推
|
||
// (Agent 回信的 from_workspace 存的是 Agent 名而不是路径),强行回填只会
|
||
// 造出一批看起来有值实际是错的数据。
|
||
{"sessions", "workspace", "ALTER TABLE sessions ADD COLUMN workspace TEXT NOT NULL DEFAULT ''"},
|
||
// 日历多收件人。旧库默认 '[]':读的时候由 EffectiveRecipients() 退回
|
||
// to_address / agent_name,历史事件因此继续工作,不需要数据迁移。
|
||
{"calendar_events", "recipients", "ALTER TABLE calendar_events ADD COLUMN recipients TEXT NOT NULL DEFAULT '[]'"},
|
||
{"calendar_events", "delivery_mode", "ALTER TABLE calendar_events ADD COLUMN delivery_mode TEXT NOT NULL DEFAULT 'separate'"},
|
||
// 日历事件已触发的 occurrence。旧库为 NULL:等价于「从未触发」,
|
||
// 于是已过期的一次性事件会补发一次提醒 —— 这是可接受的,
|
||
// 而反过来(默认成 event_time)会让正在等的提醒永远发不出去。
|
||
{"calendar_events", "fired_for", "ALTER TABLE calendar_events ADD COLUMN fired_for DATETIME"},
|
||
// 派给该 Agent 的新任务默认多少个来回。
|
||
// 旧库也给 20:之前的 max_rounds 默认是 10 但那是终身额度,语义不同,
|
||
// 不能直接搬过来当单任务预算。
|
||
{"agents", "default_rounds", "ALTER TABLE agents ADD COLUMN default_rounds INTEGER NOT NULL DEFAULT 20"},
|
||
}
|
||
|
||
// sqliteAddIndexes 是建表后才能建的索引(依赖上面补的列)。
|
||
// CREATE INDEX IF NOT EXISTS 天然幂等,直接执行即可。
|
||
var sqliteAddIndexes = []string{
|
||
// 人类决策后要按 mail_id 反查上游 permission id
|
||
"CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id)",
|
||
}
|
||
|
||
func addMissingColumns(ctx context.Context) error {
|
||
for _, c := range sqliteAddColumns {
|
||
has, err := columnExists(ctx, c.table, c.column)
|
||
if err != nil {
|
||
return fmt.Errorf("migrate sqlite: 检查 %s.%s: %w", c.table, c.column, err)
|
||
}
|
||
if has {
|
||
continue
|
||
}
|
||
if _, err := DB.ExecContext(ctx, c.ddl); err != nil {
|
||
return fmt.Errorf("migrate sqlite: 补列 %s.%s: %w", c.table, c.column, err)
|
||
}
|
||
fmt.Printf("补列 %s.%s\n", c.table, c.column)
|
||
}
|
||
for _, ddl := range sqliteAddIndexes {
|
||
if _, err := DB.ExecContext(ctx, ddl); err != nil {
|
||
return fmt.Errorf("migrate sqlite: 建索引 %.60s: %w", ddl, err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func columnExists(ctx context.Context, table, column string) (bool, error) {
|
||
// pragma_table_info 是表函数形式的 PRAGMA,可以直接当表查(比解析 PRAGMA 输出干净)。
|
||
// table 与 column 都来自上面的硬编码常量表,不存在注入面。
|
||
var n int
|
||
err := DB.QueryRowContext(ctx,
|
||
`SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`,
|
||
table, column).Scan(&n)
|
||
return n > 0, err
|
||
}
|