跳转到内容

8. 为什么会话与消息必须持久化

阶段 08 · 让事实活过一次进程

上一章的结论:权威状态和展示投影分开了,审计必需时 fail closed。

本章要动的那一块:上一章说「权威状态必须可查询」——但它现在全在内存里。kill -9 一下,全部消失。

设想 Mind 正处在这个瞬间:用户刚批准了一次远程删除,RPC 已经发给书房 Hand,Hand 已经开始执行,结果还没回来。

现在 Mind 崩溃重启。重启后它应该知道什么?

崩溃前的状态重启后能知道吗如果不知道会怎样
之前的对话说了什么必须知道用户面对一个失忆的 Agent
用户批准过那次删除必须知道无法证明那次操作凭什么执行
有一个 run 发给了 study-pc必须知道结果回来时不知道该给谁
那次删除到底执行成功了吗不知道,而且无法知道见下文

前三行靠写数据库解决。第四行是本章真正的难点——它不是持久化能解决的问题,因为那个事实此刻在另一台机器上。

Half-Pi 用 SQLite 保存 Mind 侧的权威状态:

表保存什么
session_groups / sessions工作区边界与会话
messages追加式对话历史,带稳定序号
approval_audits审批绑定、裁决者身份、决定与时间
remote_runs / remote_run_events远程执行的状态与每次迁移
remote_tasks后台任务的脱敏快照(真实状态在 Hand)
context_summaries上下文摘要(第 20 章)
security_decisions / lifecycle_outbox安全裁决与可靠事件外发
hand_credentials / face_tokens凭据与 scope
management_audits管理平面操作审计

两个设计点值得单独说。

remote_run_events 为什么单独存一张表? 如果只在 remote_runs 里存「当前状态」,你会丢掉迁移过程。排查时最需要的信息恰恰是「它是怎么从 running 走到 failed 的、中间隔了多久」。只存末态等于把病历换成一张死亡证明。

remote_tasks 为什么叫「快照」? 因为 Mind 不是这个状态的权威——真正在跑任务的是 Hand。Mind 存的是「我最后一次听说它是什么样」。这个区分是 第 13 章 的核心。

表之间的归属关系决定了级联清理的方向:删掉一个 session group,属于它的会话、消息和审批记录一起清理。

flowchart TD
    G["session_groups<br/>工作区边界"] --> S["sessions"]
    S --> M["messages<br/>append-only"]
    S --> A["approval_audits<br/>只存摘要与身份"]
    S --> R["remote_runs + remote_run_events<br/>状态与每次迁移"]
    S --> T["remote_tasks<br/>脱敏快照,权威在 Hand"]
    S --> C["context_summaries"]
    DB[("SQLite = Mind 侧权威")] --- G

一个直觉上很有道理的想法:审计表应该记录完整的工具参数,这样出事时能查清到底做了什么。

Half-Pi 不这么做。审计里存的是:工具名、参数的规范摘要、裁决者身份、决定、原因和时间。原始参数不进去。

审计存完整参数

排查最方便。但工具参数包含文件正文、密钥、个人信息——审计表会变成系统里最全的秘密仓库,而且它按设计要长期保留、广泛可读。

Half-Pi 不采用

审计存摘要 + 身份

能证明「谁在何时批准了参数为 abc123 的这次调用」,能验证事后声称的参数是否一致,但不自己保存秘密。

Half-Pi 的选择

摘要保留了审计的核心用途:验证。有人声称「我批准的是删除 ./build」,拿那份参数算一次摘要就能对上或对不上。要做到这一点并不需要保存原文。

恢复:宁可标记失败,也不自动重跑

Section titled “恢复:宁可标记失败,也不自动重跑”

回到开头那个问题:重启后,那个「已发出但结果未知」的 run 怎么办?

三个选项:

假设成功

把它标成 succeeded 继续往下走。可能是对的,但如果实际失败了,后续所有推理都建立在假事实上。

制造假事实

自动重跑

重新发一次 RPC。对读文件没问题,对删除、写入、转账就是重复副作用——而系统无法判断哪次调用是幂等的。

最危险

标记状态未知

标成 lost 或 stale,如实告诉用户「这次操作的结果我不知道」,让人根据实际情况决定。

Half-Pi 的选择

恢复的目标不是让系统看起来正常,而是让系统对自己知道什么保持诚实。「我不确定」是一个合法且有用的状态;假装确定不是。

不同领域的恢复规则各自定义:

领域重启后的处理
消息历史完整恢复,append-only 保证顺序可靠
pending 审批标记 cancelled——原来的等待者和连接都已不在
前台 run标记 lost——结果无法再路由回已消失的调用方
后台 task标记 stale,等 Hand 重连后对账真实状态

pending 审批那一行值得解释:为什么不保留它继续等?因为一个审批绑定的是「某个具体的执行正在等待这个裁决」。重启后那个 PreparedExecution 已经不存在了,即使用户现在点允许,也没有东西在等这个答案。保留它只会制造一个点了之后什么都不会发生的按钮。

事务边界:两件事必须一起成功

Section titled “事务边界:两件事必须一起成功”

第 6 章 说过必需审计失败要 fail closed。持久化层面的对应做法是:业务状态和它必需的审计在同一个事务里提交。

security_decisions 和 lifecycle_outbox 就是这样一对。如果分两次写,就存在一个窗口:裁决已生效但外发记录没写成(或反过来)。放进同一事务,两者要么都成立要么都不成立。

顺序也有讲究。审批的处理是先持久化,再唤醒等待者。反过来的话,一旦数据库写入失败,工具已经开始执行了,而记录没留下——正是第 6 章要避免的情况。

检查点:Mind 重启后,未完成的审批能不能继续等原来那个用户?

不能。审批绑定的是一个正在等待裁决的具体执行对象,重启后它已经不存在了。Half-Pi 把 pending 审批恢复为 cancelled,让调用方重新建立明确的等待关系。留一个点了没反应的按钮比直接告诉用户「这次请求已取消」更糟。

检查点:为了排查问题,审计表存完整工具参数,不是更有用吗?

不建议。审计表按设计要长期保留、被较多人读取,把原始参数放进去意味着它成为系统里最集中的敏感数据副本。摘要已经能支撑审计的核心用途——验证某份参数是否就是当时被批准的那份。真正需要看内容的场景(审批时)走单独的、有权限检查的投影路径。

检查点:既然有了摘要,是不是可以在压缩历史时把旧消息删掉省空间?

不能。原始消息是审计、恢复和后续重新压缩的事实来源。第 20 章 的做法是保持消息 append-only,另建一个摘要来改变发给模型的视图,而不是改写历史本身。删掉原文就再也无法验证摘要是否忠实。

持久化带来了 schema 迁移、事务边界和恢复语义三类成本,而且恢复语义必须逐个领域想清楚——没有通用答案。

现在 Agent 能行动、能被观察、能在重启后继续。下一章换个方向:不改核心循环,怎么扩展它的知识和工作方式。那一章还会展示一个被明确否决的方案,以及否决它的完整理由。

证据证明什么
store/message.go消息的追加写入与读取入口
store/message_append_test.go历史顺序、分页与请求绑定
store/remote_run_test.gorun 恢复语义与不含原始参数的审计