04 · 状态、运行时与身份模型¶
Herdr 源码里最容易产生“类型重复”错觉的区域,是 state/runtime 分层:App 与 AppState、PaneState 与 PaneRuntime、TerminalState 与 TerminalRuntime 同时存在。理解这些类型不是记字段,而是判断哪些事实能跨重启,哪些资源只能活在当前进程。
4.1 分离的根本原因¶
考虑一个 pane 中运行 Claude 的场景:
- 布局需要知道 pane 在哪棵 BSP 树里;
- 持久化需要知道 cwd、Agent 类型和恢复参数;
- UI 需要知道该 pane 的输出是否已读;
- runtime 需要持有 PTY master、VT parser、reader task 和 child pid;
- Live Handoff 需要把前几类结构传给新进程,同时把 FD 所有权交过去。
把这些放进一个 struct 会立即遇到:
Arc<Mutex<_>>无法自然序列化;- 移动 pane 等于移动/重建 runtime;
- view 的
seen污染 Agent 真实状态; - snapshot clone 可能意外持锁或复制重量缓存;
- handoff 无法清楚区分“发送描述”和“转移资源”。
所以 Herdr 的核心规则是:
状态描述世界;运行时使世界继续运动;稳定 ID 把两者连接起来。
4.2 五个核心容器¶
classDiagram
class App {
AppState state
TerminalRuntimeRegistry runtimes
channels
deadlines
caches
tasks
}
class AppState {
Map~TerminalId, TerminalState~ terminals
Vec~Workspace~ workspaces
Mode mode
ViewState view
Config config
notifications
}
class Workspace {
WorkspacePublicId id
Vec~Tab~ tabs
counters
git metadata cache
}
class PaneState {
TerminalId terminal_id
bool seen
right_click_passthrough
}
class TerminalState {
TerminalId id
cwd
detected agent/state
hook/session metadata
launch/resume plan
revision
}
class TerminalRuntimeRegistry {
Map~TerminalId, TerminalRuntime~ map
}
class TerminalRuntime {
PaneRuntime inner
}
App *-- AppState
App *-- TerminalRuntimeRegistry
AppState *-- Workspace
Workspace *-- PaneState
PaneState --> TerminalState: terminal_id
AppState *-- TerminalState
TerminalRuntimeRegistry *-- TerminalRuntime
TerminalRuntime --> TerminalState: same TerminalId
源码入口:App、AppState、TerminalState。
4.3 App 与 AppState¶
AppState:领域事实中心¶
AppState 组织:
TerminalId -> TerminalState;- workspace 列表、当前/选中 workspace;
- mode、modal request、view state;
- 配置、通知、sidebar/navigator 状态;
- plugin-facing data 和若干可派生状态。
称它“纯状态”不表示每个字段都能直接 serde;更准确地说,它的职责是表达服务器权威事实,而不是拥有 PTY actor、socket 或线程 join handle。
App:协调运行环境¶
App 在 AppState 外再拥有:
- runtime registry;
- event sender/receiver;
- deadline、debounce 与 pending work;
- rendering/input 相关 cache;
- API、persistence、background task 协调资源。
因此 handler 的一个实用判断是:
只改变业务事实 -> 尽量落在 state/action
需要 PTY、channel、timer、I/O -> 由 App/server 协调
只计算展示 -> view/render helper
4.4 TerminalState:终端的持久事实¶
TerminalState 的字段横跨几个子域:
| 子域 | 代表信息 | 为什么属于 state |
|---|---|---|
| 身份 | TerminalId、revision |
跨布局和事件关联 |
| 恢复 | cwd、launch argv、resume plan | 重启后重建 runtime |
| Agent | detected agent、fallback state | UI/API 的权威结果 |
| Hook 会话 | source、session、sequence、generation | 仲裁迟到报告 |
| 呈现元数据 | title、label、progress metadata | 多客户端共享事实 |
| 生命周期 | process/session state | 决定恢复与关闭动作 |
它不持有 PTY 对象。即使服务器暂时没有 runtime,TerminalState 仍可以描述一个待恢复、失败或已退出的终端。
4.5 TerminalRuntime 与迁移接缝¶
TerminalRuntime 当前包裹传统的 PaneRuntime。这个薄包装有两层意义:
- 对新架构暴露“runtime 属于 terminal identity”,而不是属于屏幕位置;
- 保留旧
PaneRuntime中成熟的 PTY/VT 能力,允许渐进迁移。
PaneRuntime 包含典型活跃资源:
Arc包裹的 terminal/VT;- PTY I/O actor handle;
- 当前 rows/cols;
- child/process identity;
- detection content sequence;
- reader/cleanup task 与运行标志。
把包装层视为“多余抽象”会漏掉演进方向:系统正把 runtime 所有权从视图 pane 移到 server-owned terminal。
4.6 Runtime Registry 是重连表¶
它提供的不是普通 collection 便利方法,而是生命周期语义:
- 新 terminal 创建后注册;
- pane 只通过
TerminalId找 runtime; - layout 移动不移动 runtime;
- session restore 先恢复 state,再逐个建 runtime;
- terminal 关闭时统一 shutdown/remove;
- handoff 时按 ID 导出和重新绑定。
registry 也避免“只从当前可见 pane 遍历 runtime”的错误。隐藏 tab、后台 workspace 和暂时未附着的 terminal 仍可能拥有必须管理的进程。
4.7 PaneState 为什么很小¶
PaneState 主要保存:
- 附着的
TerminalId; - 输出是否已看过
seen; - 右键是否直通终端等 pane 交互偏好。
pane 在这里更接近一个视图插槽。它不是终端本身。这个区分带来几个自然结果:
- 同一 terminal 理论上可以从一个布局位置重新附着到另一个位置;
- 删除 layout node 和终止 terminal 是两个应明确排序的动作;
Donebadge 可以由 terminalIdle与 paneseen=false共同投影;- snapshot 可以分别保存布局和 terminal 恢复信息。
4.8 三套身份不要互换¶
Herdr 至少有三类经常出现在同一函数中的 ID。
| 身份 | 典型形式 | 稳定范围 | 用途 |
|---|---|---|---|
PaneId |
进程内 atomic u32 |
当前运行时/布局实例 | BSP 节点索引、内部 map |
| public workspace/tab/pane id | 用户可见递增编号/别名 | session 内稳定,不轻易复用 | CLI/API、脚本、导航 |
TerminalId |
时间戳/计数器构成的 opaque string | terminal 生命周期,跨布局/恢复 | state-runtime 连接、事件关联 |
内部 PaneId 定义在 src/layout.rs,TerminalId 在 src/terminal/id.rs,公开 ID 的解析和别名在 src/app/ids.rs。
一个具体例子¶
用户执行:
系统大致要做:
flowchart LR
Public["公开 workspace/tab/pane id"] --> Resolve["按当前 session 解析"]
Resolve --> Internal["内部 PaneId"]
Internal --> PState["PaneState"]
PState --> Tid["TerminalId"]
Tid --> Registry["TerminalRuntimeRegistry"]
Registry --> Pty["PTY input"]
若直接把公开 pane 编号当 PaneId,布局重建后脚本可能写错终端;若只用 PaneId 发异步事件,节点删除并重建时可能命中复用对象;若用 TerminalId 表示屏幕位置,又无法表达 tab/layout 层级。
4.9 创建终端的事务边界¶
“分割 pane 并启动 shell”跨越 state 和 runtime:
- 分配/准备新的 terminal identity;
- 在布局树插入 pane;
- 创建
TerminalState; - spawn PTY 和构造 runtime;
- 注册 runtime;
- 更新选中/聚焦状态;
- 安排 persistence/render。
PTY spawn 可能失败。因此布局操作不能先永久提交再假装 runtime 一定成功。Herdr 的 failure-aware split 会保留足够旧状态,在创建失败时回滚 layout 和相关 state。
sequenceDiagram
participant A as Action
participant L as TileLayout
participant S as AppState
participant R as RuntimeRegistry
participant OS as PTY spawn
A->>L: prepare split
A->>S: prepare terminal state
A->>OS: spawn runtime
alt success
OS-->>A: runtime
A->>R: insert TerminalId
A->>L: commit focus/layout
else failure
OS-->>A: error
A->>S: remove prepared state
A->>L: restore previous tree
end
这里不一定使用一个显式 Transaction 类型,但语义上就是跨内存与 OS 资源的补偿事务。
4.10 关闭终端的顺序¶
关闭同样不能只是从 HashMap 删除:
- 确认请求指向当前身份;
- 通知/关闭 runtime 的 input 和 reader;
- 处理 child 是否应终止、保留或已退出;
- 从 registry 移除;
- 从 terminal state map 移除或记录结束状态;
- 修复 layout、focus、公开 ID 映射;
- 发事件、持久化、重绘。
尤其在 Live Handoff 中,“drop runtime”可能意味着误杀仍需存活的 child,因此 runtime 的 ownership/release API 必须显式表达是否保留底层进程。
4.11 Revision、sequence 与 generation¶
这些词在源码里解决不同的新鲜度问题:
- revision:某个 terminal state 的可观察版本,帮助下游判断是否变化;
- content sequence:VT 接收新内容的单调进度,用于检测/等待输出;
- hook sequence:同一 integration source 的报告顺序,拒绝迟到状态;
- session generation:Agent 进程会话代际,旧进程退出后的 hook 不能复活新状态;
- render sequence/baseline:某个客户端已经确认的呈现进度。
它们不能合成一个全局 counter。每个 counter 的比较只有在相同身份和作用域内才有意义。
4.12 快照不等于直接序列化 AppState¶
持久化层使用专门的 snapshot DTO,而不是 serde_json::to_string(&app_state)。这样可以:
- 只保存恢复所需字段;
- 排除派生 cache、临时 modal、runtime handle;
- 给格式版本和 migration 明确边界;
- 将公开稳定结构与内部实现解耦;
- 恢复时验证 cwd、Agent resume capability 和 layout 引用。
详细类型将在第 9 章展开。这里要记住:state/runtime 分离是可持久化的必要条件,但还不是充分条件;稳定 snapshot schema 才是磁盘契约。
4.13 设计评价¶
做得好的地方¶
- 以
TerminalId建立 server-owned runtime 的方向清楚; - view attachment 与 terminal lifecycle 已被明确区分;
- 专用 snapshot 避免内部字段成为永久格式;
- sequence/generation 的使用覆盖关键异步边界。
当前代价¶
TerminalRuntime仍包裹大型PaneRuntime,新旧名词同时存在;App与HeadlessServer两个协调体让某些生命周期逻辑重复;- 大型
TerminalState同时承担检测仲裁、恢复和呈现元数据,修改时需要跨域理解。
可能的演进方向¶
这是基于源码的评价而非上游承诺:长期可以继续把 PaneRuntime 按 PTY ownership、VT state、detection runtime 拆成更窄组件,并让单进程模式复用同一个 server core + in-process transport,从而减少双循环语义漂移。
4.14 本章结论¶
Herdr 的领域模型可以用三句话记住:
- workspace/tab/pane 描述用户如何组织和观看终端;
TerminalState描述服务器认为终端是什么、如何恢复、Agent 在做什么;TerminalRuntimeRegistry描述当前进程里哪些终端真的活着,以及活跃资源在哪里。
下一章继续下钻 view 侧:workspace、tab 和 TileLayout 如何用 BSP 树稳定表达分割、缩放、公开编号和失败回滚。