跳到主要内容

数据截至 (上游 commit 3667151744e3)

活得久:落盘恢复、换二进制不断线、跨机器接管

30 秒导读: herdr 对用户的承诺是「关盖、断网、重启、升级都不丢 agent」。这句话其实由三套完全不同的机制兑现,强度和代价各不相同:一份 JSON 落盘(便宜,但进程死了内容就没了)、一次 agent --resume(把会话还给 agent 自己)、一次 SCM_RIGHTS 传 fd 的服务端接力(最贵,但真的一个字节都不丢)。本章讲清这三套各自保什么、不保什么。


1. 先分清「三种死法」

「不丢 agent」听着是一件事,实现上要按丢的是什么分开处理。herdr 面对的是三种性质不同的中断:

中断场景服务端进程子进程(agent)herdr 用哪套机制
关机、重启、herdr server stop冷持久化 + 重开时 restore
herdr update --handoff(换二进制)换一个新的活着,无感热交接(live handoff)
关掉笔记本盖子 / 换一台机器坐下活着(在远端)活着SSH 桥接重新 attach

三者不是递进关系,是并列的三条命。理解本章的关键是记住:热交接不保子进程会话内容以外的东西,冷持久化不保任何活的进程。

一句直觉:

  • 冷持久化 ≈ 拍照片。 房间烧了,你只剩照片,得照着照片重新布置。
  • agent resume ≈ 让房客自己带钥匙回来。 herdr 不复原对话,它只负责喊一声 claude --resume <id>
  • 热交接 ≈ 房子不动,只换房东。 门锁(PTY 主端 fd)当面交接,住户(agent 进程)全程不知道换了人。

2. 顶层全景

这张图从上往下看:上半是「进程都死了」的路径,下半是「进程还活着」的路径。两条路径的交汇点是同一份 SessionSnapshot

┌──────────────────────────────┐
│ 运行中的服务端 (headless) │
│ AppState + PTY runtimes │
└───────┬──────────────┬───────┘
│ │
每 5s 防抖落盘 │ │ 升级时一次性打包
v v
┌───────────────────────┐ ┌──────────────────────┐
│ ~/.config/herdr/ │ │ HandoffManifest │
│ session.json │ │ = 同一份 snapshot │
│ session-history.json │ │ + 每 pane 运行时态 │
│ plugins.json │ │ + N 个 PTY 主端 fd │
└───────────┬───────────┘ └──────────┬───────────┘
│ │
进程重开 │ │ SCM_RIGHTS 过 unix socket
v v
┌───────────────────────┐ ┌──────────────────────┐
│ restore() │ │ restore_handoff() │
│ → 每 pane 起新 shell │ │ → 每 pane 复用旧 fd │
│ → 回灌历史 ANSI │ │ → 子进程完全无感 │
│ → 或跑 agent resume │ │ │
└───────────────────────┘ └──────────────────────┘

各部件一句话职责:

部件干什么在哪
persist 模块定义三个落盘文件与快照格式src/persist.rs
SessionSnapshot工作区 / 标签 / 布局 / pane cwd 的可序列化形态src/persist/snapshot.rs:16
capture / capture_history把活的 AppState 拍成快照src/persist/snapshot.rs:252:385
restore按快照重开一整套 pane(新 PTY)src/persist/restore.rs:65
AgentResumePlan把「哪个 agent + 哪个会话 id」翻成一条 argvsrc/agent_resume.rs:22
HandoffManifest热交接时新老服务端之间的唯一契约src/server/handoff.rs:34
HandoffRuntimeState每个 pane 在交接中要带走的运行时事实src/handoff_runtime.rs:13
run_remote起 SSH 桥、把本地 client 接到远端服务端src/remote/attach.rs:162

3. 冷持久化:三个 JSON 文件

3.1 存哪、存什么

模块顶部的注释就是权威说明(src/persist.rs:1-5):session 存 ~/.config/herdr/session.json,可选的 pane 屏幕历史单独存 session-history.json,已安装插件存 plugins.json

为什么屏幕历史要单独一个文件? 因为它是唯一可能含敏感输出的部分。src/persist/io.rs 的测试直接把这条钉成契约:结构快照里连 history 字样都不许出现(src/persist/io.rs:231-247)。想关掉历史时,save_to_paths 会顺手把旧的历史文件删掉,而不是留着变陈旧(src/persist/io.rs:63-76)。

路径由 crate::session::data_dir() 给出。默认就是配置目录,带 --session <name> 时会落到 sessions/<name>/ 子目录(src/session.rs:157-167)——所以多会话之间的持久化是天然隔离的

3.2 快照里有什么、没有什么

SessionSnapshot 是一棵树,层级和 UI 一一对应:

SessionSnapshot 版本号 / 工作区列表 / 侧栏宽度
└─ WorkspaceSnapshot 身份 cwd / worktree 归属 / 公开编号 / 标签列表
└─ TabSnapshot 布局树 / pane 字典 / zoom / 焦点 / 根 pane
├─ LayoutSnapshot Pane(id) | Split{方向, 比例, 左, 右}
└─ PaneSnapshot cwd / 标签名 / agent 名 / agent 会话引用 / 启动 argv

对应符号:SessionSnapshot(src/persist/snapshot.rs:16)、WorkspaceSnapshot(:50)、TabSnapshot(:85)、LayoutSnapshot(:128)、PaneSnapshot(:98)。

注意 PaneSnapshot 里没有的东西:没有终端内容、没有子进程 pid、没有 fd。 它只记「这个格子在哪个目录、跑的是谁」。屏幕内容走另一条线(PaneHistorySnapshot,src/persist/snapshot.rs:121),它只有两个字段:一整段 ANSI 和行数。

3.3 版本号 = 一道单向闸门

/// Current snapshot format version.
pub(super) const SNAPSHOT_VERSION: u32 = 3;

src/persist/snapshot.rs:12。这个常量在 load() 里的用法很值得学(src/persist/io.rs:126-141):解析失败时先看文件自称的版本号,如果比自己新,就明确记一条「来自更新版本的 herdr,忽略」的日志,而不是当成损坏文件。老版本降级运行时不会误删新版本的 session。

反方向则是宽容的:migrate_snapshot 把旧格式的 workspace 逐个升级(src/persist/snapshot.rs:189),LegacyWorkspaceSnapshot 里那种「一个 workspace 只有一个布局、没有 tab 概念」的老结构,会被包成单个 tab(src/persist/snapshot.rs:144-169)。

3.4 什么时候写:5 秒防抖 + 后台线程

const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_secs(5);

src/app/mod.rs:46。任何改动 session 的动作只是把 deadline 往后推 5 秒(src/app/session.rs:14-18),真正落盘发生在一个独立命名线程 herdr-session-save 上(src/app/session.rs:74-79)。上一次还没写完就又到期了?那就再顺延 250ms,绝不并发写(src/app/session.rs:67-70)。

写文件本身是标准的原子替换:先写 .json.tmp,再 rename 覆盖目标;rename 失败就把临时文件删掉(src/persist/io.rs:53-59)。

还有一个容易被忽略的细节:写之前会手动跟随符号链接,最多 16 层(src/persist/io.rs:21-42)。注释说明了原因——用 stow 管理 dotfiles 的人第一次保存时,session.json 可能是个指向还不存在的目标的悬空软链,而 fs::canonicalize 处理不了这种情况。


4. 进程重开之后:restore 与 agent resume

4.1 restore 做的事

restore 的文档注释一句话说完了它的本质(src/persist/restore.rs:64-65):

Restore workspaces from a snapshot. Each pane gets a fresh shell in its saved cwd.

每个 pane 拿到的是一个全新的 shell。 旧进程早就没了,herdr 能做的只有三件事:把格子按原布局摆回去、把工作目录设对、然后在这个新终端里想办法把「看起来像还在」补出来。

三个辅助结构承担了这里的复杂度:

结构职责位置
RestoreRuntimeContext打包一堆恢复期间不变的东西(scrollback 上限、shell 配置、事件通道)src/persist/restore.rs:37
AgentRestoreState跟踪「本轮已经 resume 过哪些 agent 会话」,防重复src/persist/restore.rs:25
PaneRestoreStartup单个 pane 的启动决策结果:回灌历史,还是跑 resumesrc/persist/restore.rs:30

4.2 回灌历史:initial_history_ansi

新 PTY 一起来是空白的。herdr 把上次保存的那段 ANSI 直接喂进新终端的解析器,让它看起来像刚才那样——参数就叫 initial_history_ansi,一路传到 TerminalRuntime::spawn_with_initial_history(src/persist/restore.rs:591-605)。

要理解的是它只是画面,不是状态。滚回去能看见上次的输出,但那个进程真的没了。

4.3 关键取舍:历史和 resume 二选一

pane_restore_startup 里有一段注释把这条规则写死了(src/persist/restore.rs:744-747):

Native agent resume owns the conversation history. If a pane has a resumable agent session and resume is enabled, do not replay saved pane presentation history into that terminal.

翻译:只要这个 pane 能用 agent 自己的 resume 命令接回来,herdr 就不再回灌自己那份历史画面。 因为 agent 恢复后会重画一遍对话,两份叠在一起就是重影。代码上就是 initial_history_ansi 直接置 None(src/persist/restore.rs:774-778)。

判定流程:

pane 快照里有 agent_session?
│ 否 ──────────────► 回灌 initial_history_ansi,起普通 shell
│ 是
v
config 允许 resume_agents_on_restore?
│ 否 ──────────────► 同上,回灌历史
│ 是
v
agent_resume::plan() 认识这个 source/agent 吗?
│ 否 ──────────────► 同上,回灌历史
│ 是
v
dedupe_key 本轮已经用过了?
│ 是 ──────────────► 判为重复,两边都不做(不 resume 也不回灌)
│ 否
v
记录 AgentResumePlan,启动时执行 `claude --resume <id>` 这类命令

去重那一步是必要的:同一个 agent 会话被两个 pane 记着时,resume 两次会打架。dedupe_key 由 source、agent、引用类型、引用值拼成(src/agent_resume.rs:220-225),在真正 spawn 之前就先占位,spawn 失败再回滚(src/persist/restore.rs:751-753:668-670)。

4.4 resume 计划长什么样

AgentResumePlan 只有三个字段——agent 名、要执行的 argv、去重键(src/agent_resume.rs:22-26)。plan() 本质是一张查表(src/agent_resume.rs:118),每个 agent 的 resume 语法不一样:

agent生成的命令形态依据
claudeclaude --resume <id>src/agent_resume.rs:124-130
codexcodex resume <id>src/agent_resume.rs:131-133
copilotcopilot --resume=<id>src/agent_resume.rs:134-136
ompomp --resume=<值>(注释特别说明它没有 --session,和 pi 不同)src/agent_resume.rs:156-160
cursorcursor-agent --resume <id>(Windows 上是 cursor-agent.cmd)src/agent_resume.rs:188-199

AgentSessionRef 区分两种引用方式:IdPath(src/agent_resume.rs:9-19)。目前只有 piomp 走路径形态,其余都是 id(src/agent_resume.rs:106-110)。

4.5 一道安全闸:is_official_agent_source

plan()session_ref_from_snapshot()session_ref_from_report() 三个入口第一句都是同一个检查(src/agent_resume.rs:119:103:59):

pub(crate) fn is_official_agent_source(source: &str, agent: &str) -> bool {
matches!(
(source, agent),
("herdr:claude", "claude")
| ("herdr:codex", "codex")
| ...
)
}

src/agent_resume.rs:227-241。source 必须是 herdr: 前缀的内置钩子,并且要和 agent 名配对匹配。这意味着第三方插件报上来的会话引用无法让 herdr 在恢复时自动执行命令——resume 会真的 spawn 一个进程,这个白名单就是那道闸门。


5. 热交接:换二进制而不断线(本章重点)

5.1 问题是什么

herdr update 装了新版本。旧服务端进程里挂着 12 个 pane,每个 pane 底下是一个跑到一半的 agent。要用上新二进制,常规做法是「停掉旧服务端 → 子进程全死 → 按 session.json 重开」——这正是要避免的。

热交接的目标:换掉服务端进程,但一个子进程都不重启。

5.2 核心把戏:传 fd,不传数据

一个 pane 的本质是一个 PTY 主端文件描述符。子进程写在从端,服务端读写主端。只要新进程也能拿到同一个主端,子进程根本察觉不到对面换人了——它连的是内核里的那个 pty 对象,不是某个进程。

Unix 域套接字的 SCM_RIGHTS 辅助消息正好能干这件事:把 fd 编号写进控制消息发出去,内核在接收进程里新建一个指向同一打开文件的描述符。发的不是数字,是能力本身。

herdr 手写了这一段(没有引第三方库):发送侧用 sendmsgSCM_RIGHTS 控制消息(src/server/handoff.rs:384-415),接收侧用 recvmsg 取回,并且显式检查 MSG_CTRUNC——控制消息被截断就直接报错,以及数量对不上时把已收到的 fd 全部 close 掉再报错(src/server/handoff.rs:439-467)。这两处是防 fd 泄漏的关键。

这也是整章唯一一处硬性平台边界:src/server/handoff.rs 从第 1 行起整个文件都在 #[cfg(unix)] 下,HandoffRuntimeState 同样(src/handoff_runtime.rs:11)。Windows 上没有 fd 传递这回事,perform_live_handoff 直接返回 "live handoff is only supported on Unix"(src/server/headless.rs:1450-1456),能力位也照实上报:live_handoff: cfg!(unix)(src/platform/mod.rs:66)。

5.3 契约:manifest 里带什么

HandoffManifest(src/server/handoff.rs:34-47)是新老服务端之间唯一的书面契约:

字段作用
version协议自身版本,HANDOFF_VERSION = 1(src/server/handoff.rs:20)
source_version / source_protocol旧服务端的自报家门
expected_version / expected_protocol旧服务端要求新进程必须是什么版本
snapshot完整的 SessionSnapshot,复用冷持久化那套结构
panes每个 pane 一条 HandoffRuntimeState
api_window_title通过 API 设过的窗口标题(注释写明:它比设置它的那个服务端活得久)

expected_* 这对字段是防串台的:新进程收到 manifest 后立刻自检,协议或版本对不上就拒绝接手(src/server/handoff.rs:247-267)。升级时这两个值由 herdr update 从 release 信息里填入(src/update.rs:1586-1590)。

每个 pane 带走的运行时事实(src/handoff_runtime.rs:13-30)刻意做得很薄:pid、行列、单元格像素尺寸、kitty 键盘协议标志、输入状态、终端标题,以及一段可选的 initial_history_ansi

5.4 保什么、不保什么——文档注释直接给了答案

HandoffRuntimeState 的 doc 注释(src/handoff_runtime.rs:4-10)是本章最该记住的一段:

Handoff preserves server-owned session state such as PTYs, processes, agent identity, and durable plugin/session metadata. It intentionally does not preserve transient coordination such as in-flight requests, waits, subscriptions, client sockets, or pane-to-pane messages; clients reconnect and retry those operations after replacement.

整理成表:

不保(客户端重连后自己重试)
PTY 主端 fd处理到一半的 API 请求
子进程 / agent 身份正在 wait 的等待器
插件与 session 元数据事件订阅
布局、标签、工作区客户端连接本身
API 设的窗口标题pane 到 pane 的消息

所以热交接是「运行时接力」,不是「事务迁移」。交接一开始,所有客户端就被主动踢掉,并附一条明确理由(src/server/headless.rs:2781-2797):"live update in progress; reconnect after handoff completes"

5.5 完整时序

这张图从上往下读,左边是旧服务端,右边是新进程,中间是那条私有 unix socket。

旧服务端 socket 新进程(--handoff-import)
│ │
① bind + 生成 token ────────► herdr-handoff-<pid>.sock (0600) │
② 踢掉所有客户端 │
③ 逐 pane 暂停 PTY 读取(超时 2s) │
④ capture() 出 SessionSnapshot │
⑤ spawn 新进程 ────────────────────────────────────────────────────► 启动
⑥ ◄──── token ───────────────────────────── 连接并报 token
⑦ 校验 token,发 manifest ────────────────────────────────────────────► 校验版本/协议
⑧ ◄──── "validated" ────────────────────────
⑨ dup 出 N 个 fd,SCM_RIGHTS 发送 ─────────────────────────────────────► restore_handoff()
⑩ ◄──── "restored" ─────────────────────────
⑪ 删掉自己的公开 socket 文件 等旧 socket 关闭
⑫ ◄──── "ready" ──────────────────────────── 绑好新 socket
⑬ 发 "committed" ────────────────────────────────────────────────────►
⑭ preserve_for_handoff():放弃子进程所有权 assume_handoff_ownership()
⑮ ◄──── "owned" ──────────────────────────── 接管所有权 + 恢复读取
⑯ 退出 开始服务

怎么读这张图:任何一步失败,恢复动作都不一样——⑬ 之前可回滚,⑬ 之后不可回滚。 这条分界线是整个设计的重心。

一次交接里的握手字符串就五个:validatedrestoredreadycommittedowned(分别对应 src/server/handoff.rs:268:279:285:206:301)。

5.6 所有权是怎么「不重叠」的

fd 复制之后,有一段时间新老两个进程都持有指向同一个 PTY 的描述符。herdr 用一个布尔量把「谁负责杀子进程」表达得毫不含糊:PaneRuntime::preserve_processes_on_drop

Drop 的实现是:关掉 PTY actor,然后只有 preserve_processes_on_drop == false 时才去终结子进程(src/pane.rs:1225-1241)。

于是交接两侧各做一次单向翻转:

时刻调什么效果
commit 之后旧服务端preserve_for_handoff()true——我退出时杀子进程(src/pane.rs:1613-1625)
收到 committed 之后新进程assume_handoff_ownership()false——从现在起我负责(src/pane.rs:1628-1630)

导入侧构造出来的 runtime 默认就是 preserve_processes_on_drop: true(src/pane.rs:2010)——也就是说,在还没收到 committed 之前新进程若崩掉,它不会顺手把别人的子进程带走。先保守、后接管,这个默认值挑得很讲究。

5.7 静止:pause 与 replay

复制 fd 之前必须先让 PTY 停下来,否则「旧服务端已读走但还没交出去」的字节就丢了。

pause_handoff_reader(2s) 沿着 TerminalRuntimePaneRuntime → PTY actor 一路下探到 PtyIoControlCommand::BeginHandoff(src/terminal/runtime.rs:45src/pane.rs:1645src/pty/actor/unix.rs:228-263)。它做两件事:立刻停止接受用户写入(user_writes.accepting = false),然后等 actor 回报已经静止;超时或失败会自动 rollback_handoff() 把接受标志恢复回去。

pause 期间的画面则靠一小段 replay 补:handoff_history_ansi() 抓当前历史,截断到上限(src/pane.rs:1675-1682):

pub(crate) const MAX_REPLAY_BYTES_PER_PANE: usize = 8 * 1024;

src/server/handoff.rs:28。截断不是粗暴切字节——truncate_handoff_history 先对齐 UTF-8 边界,再往后找到第一个换行才下刀,避免留下半行乱码;找不到换行就干脆返回空串(src/pane.rs:1333-1346)。

这里有一条和第 4 章读取口径呼应的硬边界:pane 处在 alternate screen 时,handoff_history_ansi() 直接返回 None(src/pane.rs:1675-1678)。全屏 TUI(vim、大多数 agent 的交互界面)的内容根本不进 host scrollback,自然也无从回捞——这和 控制面读取那边「alt screen 要专门探测、不能靠翻滚动缓冲」是同一个物理事实的两种表现。

另外,已经有 agent 会话的 pane 不带 replay(src/server/headless.rs:1309-1317):理由和 §4.3 完全一致——重画交给 agent 自己,herdr 不叠第二层。

5.8 限额与失败处理

pub(crate) const MAX_FDS_PER_HANDOFF: usize = 64;

src/server/handoff.rs:26。单次 sendmsg 能带的 fd 数受控制消息缓冲区限制,herdr 不做分批,而是在交接开始前就检查并给出可操作的错误话术(src/server/headless.rs:1266-1275):"close panes or restart herdr normally"。发送侧还有第二道同样的检查(src/server/handoff.rs:177-182)。

失败处理按 §5.5 的分界线分成两档:

失败发生在处理依据
commit 之前(spawn / 校验 / 传 fd 失败)关掉已复制的 fd、杀掉新进程、rollback_handoff_before_commit 恢复所有 pane 的读取,旧服务端继续服务src/server/headless.rs:1505-1517
已删公开 socket、但新进程没 ready等旧 socket 确认关闭后把 API/client socket 重新绑回来,再回滚src/server/headless.rs:1471-1502
commit 之后不回滚。旧服务端等 API 响应写完就退出src/server/headless.rs:1443-1448:3647-3649

herdr update 侧还有一层兜底:交接报错时它会再去查一次服务端到底是谁——如果起来的其实是新版本,就当成功;如果还是旧的,就问用户要不要停掉(src/update.rs:1359-1372:1424-1450)。

5.9 socket 交棒:两边都不抢

新进程绑公开 socket 之前必须确认旧的已经放手。wait_for_old_public_sockets_to_close 不看文件存在与否,而是真的去 connect 一次——能连上就说明还有人在听,每 50ms 重试,最多 5 秒(src/server/headless.rs:5259-5276,调用点 :5209)。

导入进程的完整生命周期在 run_handoff_import_server(src/server/headless.rs:5178-5256),顺序是:收 manifest → App::new_from_handoff 重建状态 → 报 restored → 等旧 socket 关闭 → 起 API server → 报 ready → 等 committed → 接管所有权 + 解除读取暂停 → 报 owned

最后一个小细节:交接完成后第一个客户端接上来时,会给所有 pane 发一次「假 resize」把子进程逼着重画一帧(nudge_child_redraw_after_handoff,src/pty/actor/unix.rs:206-226;触发点 src/server/headless.rs:1520-1528)。全屏 agent 的画面就是这样回来的。

5.10 pane id 会漂,所以有别名表

导入侧重建布局时,pane id 是重新分配的。handoff_pane_aliases 按布局树的遍历顺序把「旧 id → 新 id」配成一张表,存进 state.pane_id_aliases(src/persist/restore.rs:121-138src/app/mod.rs:834:850)。这样用户脚本里写的旧 pane 编号在交接后仍然能解析。


6. 跨机器:SSH 桥接

6.1 模型:服务端留在远端,只把客户端接过去

herdr 的进程模型(见 第 1 章)是「一个常驻服务端 + 一群瘦客户端」,跨机器几乎是白送的:服务端一直留在远端机器上,你在本地起一个客户端

本地机器 远端机器
┌───────────────┐ ┌────────────────┐
│ herdr client │ │ herdr server │
└───────┬───────┘ └────────┬───────┘
│ 连本地 unix socket │ 本地 client socket
v ^
┌───────────────┐ ssh(stdio) ┌──────────────┴──────────┐
│ SshStdioBridge├────────────────►│ herdr remote-client- │
│ (本地监听) │ │ bridge (远端 stdio 转发) │
└───────────────┘ └─────────────────────────┘

run_remote 把这条链拉起来(src/remote/attach.rs:162-199):探测远端平台 → 必要时装/换远端二进制 → 确保远端服务端在跑 → 起本地桥 → 把自己 re-exec 成一个普通 client 进程,只是把 socket 路径指向桥(src/remote/attach.rs:2114-2143)。

远端那一侧极其简单:连上本地 client socket,然后 stdin/stdout 双向对拷(src/remote/host_unix.rs:8-33)。它自己也会按需拉起服务端,并检查协议版本对不对得上(src/remote/host_unix.rs:51-69)。

6.2 参数怎么拆

extract_remote_args 在正式解析 CLI 之前先把 remote 相关参数摘出去(src/remote/attach.rs:68-150),三条规则值得注意:

  • 遇到 -- 就停,后面原样透传给子命令(:83-86)。
  • --remote-keybindings 没配 --remote 是错误(:142-144)。
  • --handoff没有 --remote 时会被放回参数列表——因为那时它是给本地 herdr update 用的(:145-147)。

RemoteKeybindings 只有两个取值:Local(默认,快捷键在本地客户端解释)和 Server(交给远端)(src/remote/attach.rs:38-51)。

6.3 ControlMaster 与断线重来

herdr 托管 SSH 配置时会加上 ControlMaster=auto + ControlPersist=yes 并指定 -S <control_path>(src/remote/attach.rs:606-621)。效果是多条 ssh 调用(探测平台、装二进制、开桥)复用同一条已认证连接,不必反复走认证。

REATTACH_COMMAND_ENV_VAR(HERDR_REATTACH_COMMAND,src/remote/attach.rs:34)是给用户看的:客户端断开时能原样打印出「怎么再连回来」。这条命令由 reattach_command 拼出,会把 --remote-keybindings--handoff--session 都带上(src/remote/attach.rs:1627-1649)。

注意这里的「跨机器」没有任何进程迁移。 agent 从头到尾就在远端那台机器上跑,本地只是个屏幕。所以「合上笔记本换个地方」根本不算中断——这条命是三条里最便宜的。远端 Windows 主机目前不支持(src/remote.rs:9-14)。


7. 自更新与发布通道

热交接的主要消费者就是 herdr update

两个通道,两个 manifest URL(src/update.rs:25-26):

通道manifest说明
stablehttps://herdr.dev/latest.json默认
previewhttps://herdr.dev/preview.jsonherdr channel set preview 后启用

版本字符串由 src/build_info.rs 在编译期拼出来:stable 就是 CARGO_PKG_VERSION,其它通道拼成 <版本>-<通道>.<build_id>(src/build_info.rs:13-21)。这就是 manifest 里 expected_version 要比对的那个串。

parse_self_update_args 只认一个开关(src/update.rs:1096-1108):

usage: herdr update [--handoff]

--handoff 置位后,self_update 的行为分叉(src/update.rs:2164-2177):不带它就是「装完请自行停掉旧服务端」;带上它就对每个在跑的服务端发一次 ServerLiveHandoff API 调用,参数里填上新二进制路径、期望协议号、期望版本(src/update.rs:1586-1590)。

发之前先问对方支不支持:server_supports_live_handoff 读的是服务端上报的能力位(src/update.rs:835-840src/api/schema/server.rs:17-21)。旧到没有这个字段的服务端,自然走「停机更新」路径。

下载的产物必须过 sha256 校验才装(src/update.rs:647,实现在 src/checksum.rs:9-26)。verify_sha256 先验期望值本身是不是 64 位十六进制,再流式算文件摘要,不整个读进内存(src/checksum.rs:28-40)。

另外几条硬规矩:包管理器装的 herdr 一律禁止自更新,并指向对应的升级命令(Homebrew / mise / Nix,src/update.rs:2074-2105);在 herdr 会话内部跑 herdr update 会被拒绝(src/update.rs:2107-2109)。


8. 边界与已知取舍

诚实清单。这些不是 bug,是刻意的取舍:

热交接不保的:

  • in-flight API 请求、waits、订阅、客户端连接、pane 到 pane 的消息——全部由客户端重连后重试(src/handoff_runtime.rs:4-10)。
  • 一次最多 64 个 pane,超了就直接拒绝,不分批(src/server/handoff.rs:26)。
  • 每 pane 的 replay 上限 8 KiB(src/server/handoff.rs:28)。这只是「看起来连续」的补丁,不是完整历史。
  • commit 之后不可回滚。这段窗口里旧服务端已经删了公开 socket 而新进程尚未完全就绪。

平台边界:

  • Windows 没有 fd 传递,热交接整个不可用(src/server/headless.rs:1450-1456src/platform/mod.rs:66)。Windows 的更新路径是走安装器、装完提示重启会话(src/update.rs:2130-2152)。
  • 远端 Windows 主机不支持(src/remote.rs:9-14)。

alt screen 的内容捞不回来:

  • pane 在 alternate screen 时,内容根本不进 host scrollback,handoff_history_ansi() 返回 None(src/pane.rs:1675-1678),snapshot_history() 拿到的也只是主 scrollback(src/pane.rs:2788-2791)。交接后靠一次假 resize 逼子进程重画,而不是靠回放。同样的物理限制在控制面读取那边表现为「读 alt screen 需要专门的探测流程」。

冷持久化的边界:

  • 恢复出来的是新 shell,不是旧进程。历史只是画面。
  • 只有白名单内的官方 agent source 才会被自动 resume(src/agent_resume.rs:227)。
  • pane 屏幕历史默认不存,要开 experimental.pane_history(src/app/mod.rs:416-420)。
  • 落盘有 5 秒防抖(src/app/mod.rs:46),硬断电最多丢 5 秒内的结构变更。

9. 代码地图

主题文件关键符号
落盘文件布局src/persist.rs模块 doc、saveloadrestore 再导出
快照数据结构src/persist/snapshot.rsSNAPSHOT_VERSIONSessionSnapshotWorkspaceSnapshotTabSnapshotLayoutSnapshotPaneSnapshot
拍快照src/persist/snapshot.rscapturecapture_historycapture_pane_historycapture_node
原子写与版本闸门src/persist/io.rssave_json_to_pathresolve_write_targetloadload_history
插件注册表src/persist/plugin_registry.rsregistry_pathsave_to_pathload
落盘时机src/app/mod.rssrc/app/session.rsSESSION_SAVE_DEBOUNCEschedule_session_savestart_background_session_savesave_session_now
冷恢复src/persist/restore.rsrestoreRestoreRuntimeContextAgentRestoreStatePaneRestoreStartuppane_restore_startup
交接恢复src/persist/restore.rsrestore_handoffrestore_with_imports_stricthandoff_pane_aliases
agent 会话续接src/agent_resume.rsAgentResumePlanAgentSessionRefplandedupe_keyis_official_agent_source
交接协议src/server/handoff.rsHANDOFF_VERSIONHandoffManifestReceivedHandoffMAX_FDS_PER_HANDOFFMAX_REPLAY_BYTES_PER_PANEhandoff_socket_pathspawn_handoff_importsend_fdsrecv_fds
交接携带的运行时态src/handoff_runtime.rsHandoffRuntimeStateImportedHandoffRuntime
交接编排(导出侧)src/server/headless.rsperform_live_handoffrollback_handoff_before_commitrestore_public_sockets_after_failed_handofffinish_live_handoff_shutdowndisconnect_all_clients_for_handoff
交接编排(导入侧)src/server/headless.rsrun_handoff_import_serverwait_for_old_public_sockets_to_close
PTY 所有权与静止src/pane.rsduplicate_handoff_fdpreserve_for_handoffassume_handoff_ownershippause_handoff_readerhandoff_history_ansifrom_handoff_fdtruncate_handoff_historypreserve_processes_on_drop
PTY actor 交接命令src/pty/actor/unix.rsbegin_handoffduplicate_for_handoffrollback_handoffrelease_after_commitnudge_child_redraw_after_handoff
fd 原语src/pty/fd.rsduplicate_fdduplicate_cloexec_fdset_cloexecresize_pty_fd
终端层封装src/terminal/runtime.rsTerminalRuntime::from_handoff_fdhandoff_runtime_statehandoff_history_ansi
跨机器src/remote/attach.rsextract_remote_argsrun_remoteRemoteKeybindingsREATTACH_COMMAND_ENV_VARapply_managed_ssh_optionsSshStdioBridgereattach_command
远端桥src/remote.rssrc/remote/host_unix.rsrun_remote_client_bridgeensure_remote_server_running
自更新src/update.rsself_updateparse_self_update_argslive_handoff_running_server_for_updateserver_supports_live_handoff
构建身份与校验src/build_info.rssrc/checksum.rsBASE_VERSIONchannelversionis_previewverify_sha256
API 契约src/api/schema/server.rsServerLiveHandoffParamsServerCapabilities

相邻章节: 进程模型见 第 1 章;PTY 与终端内核见 第 2 章;agent 状态判定见 第 3 章;控制面与读取口径见 第 4 章;帧生成与下发见 第 5 章