删了又活过来——一个会自己写回数据库的对象
Shellby 复盘:给 AI 会话加逐条删除,删完却又冒出来。凶手是会话自带的自动持久化回调——abort 触发最后一次状态变化、把已删记录 upsert 回去,还经 CloudKit 复活到所有设备。修复是一行,但位置是关键。
Shellby 的 AI Agent 会话历史一直只能积累、不能删。今天加上逐条删除——右键 / 长按菜单、确认弹窗、删运行中会话时提示任务一并中止,删完选择回落到最近的剩余会话。功能本身不复杂,复用现成的 AIConversationStore.delete(_:) 就行。真正值得写的是删完之后的诡异现象:会话删掉了,过一会儿又回来了。
会话是个会自己存盘的活对象
要讲清楚这个 bug,先得说 AI 会话是怎么持久化的。它不是一条静态记录,而是一个活的、会自己写回数据库的对象。
每个 AIAgentSession 挂着一个持久化回调,状态一变(流式吐字、工具调用、消息追加)就触发它,把当前快照写进 SwiftData:
private func attachPersist(_ session: AIAgentSession) {
session.onPersist = { [weak self, weak session] in
guard let self, let session else { return }
self.persist(session)
}
}
而 persist 的关键性质是它是个 upsert——按 id 查,存在就更新、不存在就插入一条新的:
private func persist(_ session: AIAgentSession) {
let id = snap.id
let existing = try? context.fetch(/* id == id */).first
if let rec = existing {
rec.updatedAt = snap.updatedAt
rec.messagesData = data // 更新
} else {
context.insert(AIConversation(id: snap.id, /* ... */)) // 插入
}
try? context.save()
}
开着 iCloud 时,这次 save() 由现成的 CloudKit 私有库镜像自动传播到其它设备——删除功能没新增任何模型、字段或手写 CloudKit 代码,全走既有同步通道。这本是优点,等下会变成放大器。
复活的过程
第一版的 delete 很直觉:中止会话、从内存移除、删数据库记录。
func delete(_ session: AIAgentSession) {
session.abort() // ①
conversations.removeAll { $0.id == session.id }
// ...重选...
deleteRecord(id: session.id) // ②
}
看着没问题,但漏了一环:abort() 中止一个正在运行的会话时,本身会造成一次状态变化(收尾、追加一条中止标记之类)——而状态一变,就触发那个 onPersist 回调。于是时间线变成:
abort()→ 触发onPersist→ 排队一次persist;deleteRecord()把数据库记录删了;- 那次
persist真正执行——upsert 去查 id,记录已经没了,走进 else 分支,context.insert一条同 id 的新记录,save()。
删除和插入前后脚发生,净效果是:会话带着原来的 id 复活了。下次启动一读,它就在列表里;开着 iCloud 的话,CloudKit 还忠实地把这个「幽灵」同步到你所有设备——一个本地的顺序问题,被同步机制放大成了多端幽灵。
这里有个值得停一下的点:upsert 的 insert 分支,把「删除没生效」升级成了「删除后复活」。如果 persist 只会 update、不会 insert,最坏也就是删除失败(记录还在);正因为它会在找不到时新建,一次迟到的持久化才能把一条已删记录重新造出来。
修复是一行,但位置是关键
func delete(_ session: AIAgentSession) {
session.onPersist = nil // ← 先把写回通道切断
session.abort()
conversations.removeAll { $0.id == session.id }
// ...重选...
deleteRecord(id: session.id)
}
session.onPersist = nil 放在第一行,先于 abort()。含义是:在停止这个对象、触发它任何收尾副作用之前,先把它通往数据库的写回路径掐断。之后 abort() 再怎么变状态,也没有回调会 fire,persist 不会排队,那条 else 分支的 insert 永远不会发生。
顺序是这个修复的全部。把 onPersist = nil 放到 abort() 后面,等于没修——中止的副作用已经在那一瞬间把 persist 排出去了。你要先拆掉它的手,再让它倒下。
一个容易误判的点:回调里明明写了 [weak self, weak session],为什么没救?因为删除的整个过程里 session 还活着(内存列表刚移除、但你手上的 session 参数还持有它),弱引用照样解得开。弱引用防的是「对象已经没了还乱调」,而这里对象好好的,问题是它不该再写了。真正的切断是把闭包置 nil,不是指望它被释放。
顺带:把「删哪个、删完选哪个」抽成纯函数
删除还有一块和数据库无关、但值得单独测的逻辑:确认弹窗的待删状态,和「删掉一个之后该选中哪个」。这些抽成了一个纯值类型 ConversationDeletionIntent:
public struct ConversationDeletionIntent {
public private(set) var pendingID: UUID?
public mutating func request(_ id: UUID) { pendingID = id }
public mutating func cancel() { pendingID = nil }
public mutating func confirm() -> UUID? { defer { pendingID = nil }; return pendingID }
// 删掉当前选中的 → 回落到最近的剩余会话;删的不是选中的 → 选择不变
public static func selection(afterDeleting deletedID: UUID,
selectedID: UUID?, remainingIDs: [UUID]) -> UUID? {
selectedID == deletedID ? remainingIDs.first : selectedID
}
}
这层不碰 SwiftData、不碰 UI,纯粹是「请求删除 → 取消 / 确认」的状态机加一个重选规则。抽出来的好处是:删掉选中项要回落、删掉非选中项选择不动、目标已不存在视为无操作——这几条分支不用起 App、不用连数据库,一组单元测试就锁死了。真正难缠的持久化竞态归持久化层,可以被纯逻辑覆盖的部分不该混在里面一起靠手点验证。
复盘
- 删一个「活的、会自我持久化的对象」,不等于删一条记录。静态记录不会反抗;一个挂着自动存盘回调的可观察对象,会在你「移除」和「删库」之间把自己写回来。删它之前,先切断它的写回路径;
- upsert 的 insert 分支会把「没删掉」升级成「复活」。同一个 id 的迟到写入,能把已删记录重新造出来。凡是「找不到就新建」的持久化,都要想清楚它会不会在删除后被一次尾随的调用触发;
- 顺序即修复。
onPersist = nil必须先于会触发它的abort();换个位置这行代码就白写。涉及「关闭 + 收尾副作用」的清理,先拆监听、再触发关闭; - 同步会放大本地的顺序 bug。一个纯本地的删除竞态,经 CloudKit 变成同步到所有设备的幽灵。跨设备镜像会忠实传播任何本地胜出的状态,包括错的;
- 能抽成纯函数的逻辑就别泡在副作用里。确认状态和删后重选是纯逻辑,抽成值类型用单测锁死,把手工验证的预算留给真正绕不开的持久化竞态。
留言