feat: L0 线协议冻结 + 附件链路修复 + 人/Agent 区分
L0 核心: - 严格解码 Decode(DisallowUnknownFields) 全覆盖 29 个 DecodeBody 调用点 - DecodeLenient 心跳专用:容忍新字段但回报 unknown_fields - 400 消息列出本端点接受的全部字段(jsonFieldNames 反射 tag) - 日历 status 校验(create 补字段 + update 拦非法值) - 新增 strictdecode_test.go 10 例 + blob/list_test.go 6 例 A-4 附件挂载回滚:checkAttachable 在 CreateMail 前校验,失败按 解挂→释放 relay→删邮件→退预算回滚,幽灵邮件这条路堵住了 A-5 反向 GC:blob.Store.List() 枚举磁盘(跳 .upload-*), SweepUnreferencedBlobs 按 attachments + calendar_attachments 反查, 48h 年龄下限兜上传窗口。已接进每小时 sweep 循环 C 人/Agent 区分:四个读路径 + threadCols 补 from_human / to_human (EXISTS users 判定),models.Mail 加 ToHuman。前端判据从 workspace 启发式改成显式布尔,mailCounterpart/sessionCounterpart 从 session_workspace 取 path(修 dsh@dsh 拼接 bug) 契约文档:SSE new_mail 补 4 字段(in_reply_to/from_human/ permission_mode/permission_enforcement),B-5 加 B-5.6 (Agent→Agent 不转发),B-3.4 MUST 改条件式,心跳补 mode_enforcement + unknown_fields,demo 死链修复 + from_human 检查 验收清单加 Agent→Agent 负向对照项
This commit is contained in:
@ -333,32 +333,53 @@ func bootstrapAdmin(ctx context.Context, cfg *config.Config) {
|
||||
}
|
||||
}
|
||||
|
||||
// sweepOrphanAttachments 周期清理「已上传但从未随邮件发出」的附件。
|
||||
// sweepOrphanAttachments 周期清理无人引用的附件。
|
||||
//
|
||||
// 上传与发信是两步,中间放弃(用户取消写信、Agent 崩溃)就会留下孤立记录与文件。
|
||||
// 保留 24 小时再清:足以覆盖一次正常的写信过程,也不至于让废弃文件长期占盘。
|
||||
// 两个方向,缺一不可:
|
||||
//
|
||||
// 1. **正向**(repo.SweepOrphanAttachments):库里还有记录但从未挂到邮件上。
|
||||
// 上传与发信是两步,中间放弃(用户取消写信、Agent 崩溃)就会留下这类记录。
|
||||
// 保留 24 小时再清:足以覆盖一次正常的写信过程。
|
||||
//
|
||||
// 2. **反向**(repo.SweepUnreferencedBlobs):磁盘上有文件但库里连记录都没有。
|
||||
// 一旦记录本身消失(清库、手工 DELETE、迁移),正向那条 SQL 就永远看不见它——
|
||||
// 本机实测磁盘 8 个 blob 里 7 个属于这种,全部来自一次清库,之后一直占着盘。
|
||||
//
|
||||
// 反向清理的年龄下限取得比正向更宽(48 小时):它删的是「库里查无此物」的文件,
|
||||
// 判据比正向弱,多留一天换取更小的误删面。上传窗口(落盘与入库之间)也靠它兜住。
|
||||
func sweepOrphanAttachments(blobs *blob.Store) {
|
||||
const (
|
||||
interval = 1 * time.Hour
|
||||
keepFor = 24 * time.Hour
|
||||
interval = 1 * time.Hour
|
||||
keepFor = 24 * time.Hour
|
||||
keepUnlinked = 48 * time.Hour
|
||||
)
|
||||
|
||||
sweep := func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 正向失败不能 return:反向那一步与它相互独立,
|
||||
// 一层坏掉不该让另一层也停工。
|
||||
sums, err := repo.SweepOrphanAttachments(ctx, keepFor)
|
||||
if err != nil {
|
||||
log.Printf("附件 GC 失败: %v", err)
|
||||
return
|
||||
}
|
||||
for _, sum := range sums {
|
||||
if err := blobs.Remove(sum); err != nil {
|
||||
log.Printf("附件 GC 删除 %s 失败: %v", sum[:8], err)
|
||||
} else {
|
||||
for _, sum := range sums {
|
||||
if rErr := blobs.Remove(sum); rErr != nil {
|
||||
log.Printf("附件 GC 删除 %s 失败: %v", sum[:8], rErr)
|
||||
}
|
||||
}
|
||||
if len(sums) > 0 {
|
||||
log.Printf("附件 GC 清理了 %d 个孤立记录", len(sums))
|
||||
}
|
||||
}
|
||||
if len(sums) > 0 {
|
||||
log.Printf("附件 GC 清理了 %d 个孤立文件", len(sums))
|
||||
|
||||
// 反向:库里查无此物的磁盘文件。与正向分开报数——
|
||||
// 两个数字的含义不同,合成一个会让「哪一层在漏」看不出来。
|
||||
if n, uErr := repo.SweepUnreferencedBlobs(ctx, blobs, keepUnlinked); uErr != nil {
|
||||
log.Printf("附件反向 GC 失败: %v", uErr)
|
||||
} else if n > 0 {
|
||||
log.Printf("附件反向 GC 清理了 %d 个无引用文件", n)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user