跳转至

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。这个薄包装有两层意义:

  1. 对新架构暴露“runtime 属于 terminal identity”,而不是属于屏幕位置;
  2. 保留旧 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 是重连表

TerminalRuntimeRegistry 维护:

TerminalId -> TerminalRuntime

它提供的不是普通 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 是两个应明确排序的动作;
  • Done badge 可以由 terminal Idle 与 pane seen=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。

一个具体例子

用户执行:

herdr pane send --workspace 2 --tab 4 --pane 7 "cargo test"

系统大致要做:

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:

  1. 分配/准备新的 terminal identity;
  2. 在布局树插入 pane;
  3. 创建 TerminalState;
  4. spawn PTY 和构造 runtime;
  5. 注册 runtime;
  6. 更新选中/聚焦状态;
  7. 安排 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 删除:

  1. 确认请求指向当前身份;
  2. 通知/关闭 runtime 的 input 和 reader;
  3. 处理 child 是否应终止、保留或已退出;
  4. 从 registry 移除;
  5. 从 terminal state map 移除或记录结束状态;
  6. 修复 layout、focus、公开 ID 映射;
  7. 发事件、持久化、重绘。

尤其在 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 的领域模型可以用三句话记住:

  1. workspace/tab/pane 描述用户如何组织和观看终端;
  2. TerminalState 描述服务器认为终端是什么、如何恢复、Agent 在做什么;
  3. TerminalRuntimeRegistry 描述当前进程里哪些终端真的活着,以及活跃资源在哪里。

下一章继续下钻 view 侧:workspace、tab 和 TileLayout 如何用 BSP 树稳定表达分割、缩放、公开编号和失败回滚。