跳转至

09 · 持久化、恢复与 Live Handoff

关闭 UI 后 Agent 继续工作,是 server/client 架构解决的第一步;服务器自身重启或升级时,怎样不丢工作区、最好连 PTY 进程也不丢,才是更难的第二步。Herdr 为此提供两级连续性:磁盘快照负责结构恢复,Unix Live Handoff 负责活跃运行时转移。

9.1 两种连续性不要混淆

机制 能保留什么 不能保证什么
session snapshot restore workspace/tab/layout、cwd、Agent/launch/resume 描述、可选历史 原 child pid、原 PTY 内核状态、内存中的 VT 精确状态
Live Handoff 以上结构 + 活跃 PTY master/runtime handoff state 不支持的平台、进程崩溃后已丢失的 FD

普通 restore 是“根据说明重新搭建”;handoff 是“把正在运行的机器交给新控制者”。二者共享 snapshot 形状,但失败语义完全不同。

9.2 为什么使用专用 Snapshot DTO

持久化入口在 src/persist.rs 与 src/persist/。磁盘格式不直接序列化 AppState,而是定义版本化 DTO:

这样能把磁盘契约从内部重构中隔离,并明确排除 socket、task、mutex、缓存和瞬时 modal。

9.3 Snapshot v3 的层级

固定快照格式版本为 3,常量见 SNAPSHOT_VERSION。逻辑结构如下:

flowchart TB
    S["SessionSnapshot v3"] --> W["WorkspaceSnapshot[]"]
    S --> Active["active/selected/sidebar state"]
    W --> WI["public id, name, cwd, worktree membership"]
    W --> Counters["next public tab/pane ids"]
    W --> T["TabSnapshot[]"]
    T --> TI["name, public id, focus, zoom"]
    T --> L["LayoutSnapshot BSP"]
    T --> P["PaneSnapshot[]"]
    P --> PI["cwd, label, agent, session, launch/resume"]

Session 层

保存 workspace 集合、active/selected workspace、sidebar 宽度/可见性等跨 workspace 选择。

Workspace 层

保存稳定 public id、名称、identity cwd、worktree 来源/checkout 元数据、公开编号 counter 和 tab。

Tab 层

保存布局树、pane snapshot、focused/root/zoomed pane 与 tab 名称/编号。

Pane 层

保存恢复终端所需的 cwd、label、识别的 Agent/managed kind、Agent session、launch argv/resume plan;不会保存 PaneRuntime。

9.4 布局快照为什么镜像 BSP

如果只保存每个 pane 的绝对 rect,恢复到不同窗口大小时无法知道原始分割意图。LayoutSnapshot 镜像 Pane | Split(direction, ratio, children),因此在新 area 中可重新计算。

恢复时必须交叉验证:

  • leaf 引用存在的 pane;
  • pane 不被多个 leaf 非法引用;
  • focus/zoom 指向有效 pane;
  • ratio 合法;
  • public id counter 不倒退;
  • 空/损坏 tab 有可预期降级。

9.5 History 为什么分文件

结构快照与终端 history 的更新频率和体积完全不同。Herdr 把可选 ANSI/history 放进独立的 session-history.json:

  • 结构变化小而关键,可频繁原子写;
  • history 大且易变化,可配置/裁剪;
  • history 损坏不应让整个 workspace 结构无法恢复;
  • 关闭 history 可以降低磁盘 I/O 和隐私暴露;
  • 恢复时先建结构,再尽力注入历史。

这也是持久化设计中的通用原则:把“必须恢复的小元数据”和“可丢的大缓存”分开 durability class。

9.6 Dirty 标记与写入节流

每个细小动作都同步写 JSON 会阻塞主循环。领域动作先 mark_session_dirty,协调循环在合适 deadline/debounce 后 capture snapshot 并写入。

但节流不能无限推迟:有序 shutdown、server update 和 handoff 前要 flush 关键状态。理想语义是:

state mutation -> dirty=true
多个紧邻 mutation -> 合并
deadline/shutdown -> capture consistent snapshot
write temp -> flush/close -> rename target
success -> dirty=false(仅当期间没有更新的 revision)

最后一条很重要:若写盘期间又发生新状态,不能因为旧 snapshot 成功而清掉新 dirty。

9.7 原子替换与损坏处理

src/persist/io.rs 负责读取/写入边界。常见安全模式是同目录临时文件写完后 rename:

  1. 序列化到临时路径;
  2. 完整写入并关闭;
  3. rename 替换正式文件;
  4. 错误时保留旧正式文件;
  5. 读取损坏文件时给出可诊断错误/备份,而不是覆盖它。

同目录避免跨文件系统 rename 失去原子性。严格程度也分场景:session 主快照损坏要显式警告;插件 registry 启动读取可更宽容,但用户显式 mutation 前要严格确认,避免用空 registry 覆盖坏文件——第 11 章详解。

9.8 版本与迁移

读取逻辑先检查 version:

  • 当前/旧版本:解析并迁移;
  • 比当前 SNAPSHOT_VERSION 更大的未来版本:拒绝,而不是猜测;
  • legacy pre-tabs v2:把旧 workspace pane/layout 包装成 tab;
  • 缺省字段:只在有安全、明确的默认值时补齐。

拒绝未来版本保护了双向切换:用户用新版写出 v4,再临时运行旧版 v3 时,旧版不会静默丢掉不认识的数据并覆盖文件。

测试 fixture 包括 tests/fixtures/session/legacy-pre-tabs-v2.json,把迁移契约固定为可复现样本。

9.9 恢复不是反序列化结束

恢复入口在 src/persist/restore.rs。大致阶段:

flowchart TD
    Read["读 + 迁移 snapshot"] --> Validate["验证 IDs / layout / cwd"]
    Validate --> State["先构造 workspace/tab/pane/TerminalState"]
    State --> Plan["为每个 pane 选择恢复计划"]
    Plan --> Native{"Agent 支持原生 resume?"}
    Native -->|是| Resume["构造 agent resume argv/session"]
    Native -->|否| Shell["重启 shell/launch argv"]
    Resume --> Spawn["逐个创建 runtime"]
    Shell --> Spawn
    Spawn --> Attach["注册 TerminalId 并附着 pane"]
    Attach --> History["尽力恢复 history"]
    History --> Focus["恢复 active/focus/zoom"]

先身份,后资源

先恢复所有稳定 ID 和树,才能让 runtime spawn 失败被局部记录,而不是造成后续引用错位。

cwd 需要验证和降级

目录可能被删除。恢复应选择存在的祖先、workspace/repo root 或安全默认 cwd,并通知用户;不能因为一个 pane 的 cwd 失效而放弃整个 session。

Agent 原生 resume 是能力

若 snapshot 保存了 Agent session 且该 Agent 有已知 resume 命令,恢复计划可以启动原生会话;否则只重启 shell/原始 argv。不能把所有 Agent 的 session id 当通用命令行参数。

9.10 命名 Session 是完整隔离单元

src/session.rs 为不同 session 计算各自数据目录。每个 namespace 都有自己的:

  • snapshot/history;
  • client socket;
  • API socket;
  • handoff coordination;
  • daemon ownership。

这允许同一用户同时运行多个独立 Herdr 控制平面。隔离不能只改 snapshot 文件名,否则 API client 可能连到错误 daemon。

9.11 Live Handoff 要解决什么

普通更新若 stop old server 再 start new server,所有 PTY child 通常随 owner 退出,或者即使 child 存活,新 server 也没有 master FD。Unix handoff 利用可复制 FD 和私有协调 socket,让新 server 接管。

涉及代码:

9.12 Handoff 协议:准备、切换、提交

sequenceDiagram
    participant U as Updater/API
    participant Old as Old server
    participant P as PTY readers
    participant H as Private handoff socket
    participant New as New server binary
    participant Pub as Public API/client sockets

    U->>Old: request live handoff(new binary)
    Old->>P: pause all readers
    Old->>Old: capture snapshot + runtime states
    Old->>New: spawn replacement with private socket
    Old->>H: send manifest + duplicated PTY FDs
    New->>New: validate/import state and runtimes
    New-->>Old: imported / ready
    Old->>Pub: unbind/remove public endpoints
    New->>Pub: bind public endpoints
    New-->>Old: commit ready
    Old->>Old: release ownership without killing children
    Old-->>U: exit old server
    New->>P: resume readers under new ownership

HandoffManifest 保存结构 snapshot、runtime state 列表和协调元数据;FD 通过 Unix 机制与描述一一对应。这里必须同时对齐 TerminalId,不能只依赖传输顺序。

9.13 为什么先暂停 reader

复制 PTY master FD 后,两个 fd 指向同一底层字节流。若旧新 reader 同时 read:

  • 每批输出只会被其中一个消费;
  • 两边 VT 各缺一半内容;
  • Agent 检测 sequence 分叉;
  • 新 server 首帧不一致。

所以旧 reader 先进入可确认的 paused 状态,完成 handoff 后只有新 reader 恢复。pause 不是 sleep;它需要 actor 协议确认已经停止触碰 FD。

9.14 Public socket 切换是危险窗口

新 server 在完全恢复前不能抢 public socket,旧 server 在新 server 未 ready 前也不能放弃它。顺序近似两阶段提交:

prepare: 新 server 私下导入,旧 server 仍对外服务/持有端点
switch : 旧 server 暂时撤下 public endpoints
commit : 新 server bind 成功并确认
finish : 旧 server 放弃 runtime ownership 并退出

严格意义上这不是分布式共识,但“旧 owner 在新 owner证明可接管前不能销毁资源”的原则相同。

9.15 每个失败点如何回滚

失败点 回滚
replacement 无法 spawn 恢复旧 readers,保持 public sockets
private socket/manifest 传输失败 关闭临时连接与 dup FDs,恢复旧 readers
新 server snapshot/runtime 导入失败 新进程退出,旧 server 恢复
新 server ready 超时 视作失败,不释放旧 ownership
old 已撤 socket,new bind 失败 旧 server等待清理后重新 bind public sockets
commit 前 client 断线 client 可在 public endpoint 恢复后重连
commit 后 old cleanup 失败 child ownership已明确,不能双方都 kill

源码中特别有 restore_public_sockets_after_failed_handoff 和等待后恢复路径,说明维护者把最危险的“服务端点已撤但接管失败”作为显式状态,而非 panic 后听天由命。

9.16 Ownership release 不等于普通 drop

旧 server 成功交接后,不能沿正常 shutdown 路径关闭 PTY/kill child。runtime 需要“释放本进程 wrapper,但保留底层已转移资源”的语义。反过来,失败回滚时旧 server 仍是 owner,dup 到新进程的资源必须关闭。

这类系统最好用类型/状态显式区分:

Owned -> PausedForHandoff -> Transferred
                 \-> RolledBack(Owned)

即使当前实现分散在 flags 和步骤中,阅读时也应按这个状态机验证每条 drop 路径。

9.17 Handoff 的测试价值

tests/live_handoff.rs 不只检查 server pid 改变,还验证:

  • 原 PTY child 继续存活;
  • 交接后仍能读写;
  • keyboard mode/terminal state 保持;
  • API/client socket 重建;
  • named session 隔离;
  • plugin/HTTP 等伴随资源按契约恢复;
  • 失败注入后旧 server 回滚并继续服务。

可靠性交接必须以“业务资源连续”作为断言,不能只断言新进程启动成功。

9.18 本章结论

  1. 专用 versioned snapshot 是磁盘契约,不应直接暴露内部 state;
  2. 小而关键的结构与大而可选的 history 应分开保存;
  3. restore 是验证、迁移、重建身份、选择恢复计划、再创建 runtime 的过程;
  4. Live Handoff 的核心是 reader 排他、FD/身份对齐、public endpoint 两阶段切换;
  5. 在新 owner 提交前,旧 owner必须保有完整回滚能力。

下一章转向外部自动化:92 个 JSON API 方法怎样进入同一个单写者循环,长等待为什么放在循环外,以及 EventHub sequence 如何消灭经典的 check-then-subscribe 丢事件窗口。