SwiftSwiftDataShellby

删了又活过来——一个会自己写回数据库的对象

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 回调。于是时间线变成:

  1. abort() → 触发 onPersist → 排队一次 persist
  2. deleteRecord() 把数据库记录删了;
  3. 那次 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 变成同步到所有设备的幽灵。跨设备镜像会忠实传播任何本地胜出的状态,包括错的;
  • 能抽成纯函数的逻辑就别泡在副作用里。确认状态和删后重选是纯逻辑,抽成值类型用单测锁死,把手工验证的预算留给真正绕不开的持久化竞态。

留言

  • 加载中…

留言先审后发,通过后公开显示;邮箱只有站主可见。