跳到主要内容

数据截至 (上游 commit 4ec6bbf5884e)

领域模型与状态骨架 — Project / Worktree / Tab 怎么被表达和存住

30 秒导读: Orca 是给"一群并行跑的编码 agent"用的 IDE。要让十几个 agent 各占一个工作区同时干活,第一件事是把"工作区是什么、怎么认出它、状态存在哪"定死。这一章只讲数据结构和身份规则:五个核心名词、三套 id、git worktree 与普通文件夹的双形态、tab 的分屏树,以及"主进程管落盘、渲染进程管内存"的分工。

这章不讲工作区怎么被创建和销毁(见 一条 worktree 的一生),也不讲终端和 PTY(见 终端底座)、RPC 与远程执行(见 runtime 与 RPC)。


1. 这章要解决的问题(零基础也能懂)

场景

你在一个仓库里同时让 5 个 agent 干 5 件事。最朴素的做法是开 5 个终端,但它们共享同一份工作副本——A 改的文件 B 看得见,git 状态互相打架。

Git 自带的解法叫 worktree(工作树:同一个 .git 仓库,签出到多个互不干扰的目录)。Orca 把这个原语包装成产品级概念:一个 worktree = 一个工作区 = 侧边栏里的一行 = 一组终端标签页 = 一个 agent 的地盘。

于是要回答三个问题

问题这章的答案
怎么唯一地"指着"一个工作区?${repoId}::${worktreePath},路径就是身份
不是 git 仓库的普通文件夹怎么办?造一个"假 worktree"投影进同一套类型
状态存哪?谁说了算?磁盘上的 orca-data.json 是权威,渲染进程持一份可重建的镜像

一句话直觉

把 Orca 的领域模型想成"文件系统 + 便签":git 和磁盘提供硬事实(这个目录存在、在哪个分支),Orca 只在旁边贴便签(显示名、关联的 issue、置顶、未读)。便签用路径当图钉钉在硬事实上——这就是本章所有取舍的源头。


2. 五个名词,先认人

Orca 的核心类型曾经集中在一个巨型 src/shared/types.ts 里;上游已把它按领域拆分src/shared/*-types.ts 一族(repo-types.tstab-types.tspersisted-state-types.ts……)加一个 src/shared/worktree/ 子目录(types.ts/meta-types.ts/lineage-types.ts 等),主进程和渲染进程都从这里 import。下表给出拆分后的定义处。

名词是什么身份定义处
Repo一个被导入的本地/远程仓库或文件夹randomUUID()src/shared/repo-types.ts:42
Project跨多个 Repo 的持久项目身份(一个项目可能在 Mac 和 SSH 各有一份签出)randomUUID(),持 sourceRepoIds[]src/shared/project-types.ts:16
ProjectGroup侧边栏的分组(纯组织,不影响执行)randomUUID()src/shared/project-group-types.ts:3
Worktree工作区——一次 git worktree 签出,或它的文件夹等价物${repoId}::${path}src/shared/worktree/types.ts:60
Tab工作区里的一个内容页(终端 / 编辑器 / diff / 浏览器…)UUID 或文件路径src/shared/tab-types.ts:40

再加一个横跨所有名词的维度:

  • ExecutionHostId —— 这份东西在哪台机器上执行。三种取值:'local'ssh:${targetId}runtime:${environmentId}src/shared/execution-host.ts:9)。

层级长这样

ProjectGroup ── 侧边栏分组,可嵌套(parentGroupId)

├── Repo ──────────── 一个签出根,id = UUID
│ │ path / connectionId / executionHostId
│ │
│ ├── Worktree ── 工作区,id = `${repoId}::${path}`
│ │ │
│ │ ├── TabGroup ── 一个"标签条+内容区",可被分屏树摆放
│ │ │ └── Tab ── terminal / editor / diff / browser / …
│ │ └── TabGroupLayoutNode ── 分屏树(leaf=groupId, split=first/second)
│ │
│ └── Worktree …(并行的其它 agent 地盘)

└── FolderWorkspace ── 非 git 的工作区,挂在 group 上而不是 repo 上

Project 不在这棵树上——它是横向的持久身份,靠 sourceRepoIds 把散落在不同宿主上的 Repo 串成一个项目(src/shared/project-types.ts:16Worktree.projectId 是它在工作区上的回指)。


3. 顶层全景:状态从磁盘到屏幕

先给"怎么读这张图":从左到右是启动时的水化方向,从右到左是用户操作后的回写方向。

磁盘 主进程 (Electron main) 渲染进程 (React)
┌──────────┐ ┌───────────────────────┐ ┌────────────────────┐
│ orca- │ load │ Store 类 │ IPC │ zustand useAppStore│
│ data.json│ ─────► │ 持 PersistedState │ ───► │ 40 个 slice 组合 │
│ │ ◄───── │ ① 归一化 ② 防抖落盘 │ ◄─── │ 内存镜像 + 纯瞬态 │
└──────────┘ save └───────────────────────┘ set └────────────────────┘
▲ ▲ ▲
唯一权威 ①改 id ②GC ③迁移 水化时丢弃不认识的行

三个部件各自的职责:

部件干什么在哪
orca-data.json唯一的持久权威;PersistedState 的序列化<userData>/orca-data.jsonsrc/main/persistence/loading-store/user-data-path.ts:24
Store读/写/防抖/归一化/GC/身份迁移src/main/persistence/loading-store/store.ts:511
useAppStore渲染进程的单一 zustand store,40 个 slice 拼成 AppStatesrc/renderer/src/store/index.ts:61

主线走一遍(不进代码):

  1. 启动Store 构造函数 load() 读 JSON,跑一遍 pane 身份归一化,缺字段的用默认值补齐(src/main/persistence/loading-store/store.ts:566 构造调 load():807,pane 身份归一化经 normalizeWorkspaceSessionPaneIdentities:2912)。
  2. 列工作区 — 主进程跑 git worktree list,把硬事实(路径/HEAD/分支)和便签WorktreeMeta)合并成完整 Worktree 发给渲染进程。
  3. 水化 UI — 渲染进程用 WorkspaceSessionState 重建标签页和分屏树,凡是引用了不存在工作区的行一律丢弃。
  4. 回写 — 用户开关标签页 → 渲染进程整包 session:set 给主进程 → 主进程归一化后防抖 1 秒落盘。

4. 身份规则:三套 id,一个取舍

这是全章最重要的一节。Orca 里有三层身份,各解决一个问题。

4.1 repoId — 一次性发的 UUID

导入一个仓库时直接 randomUUID(),之后再不变(src/main/ipc/repos.ts:351)。所以仓库整体挪位置不会丢身份

4.2 worktreeId — 路径即身份

export type Worktree = {
id: string // `${repoId}::${path}`
...

—— src/shared/worktree/types.ts:60-61,注释就写在类型上。

分隔符 :: 定义在一个共享常量里,明确说明"曾经因为三处各写一遍 parser 而踩过 bug,所以集中到这里"(WORKTREE_ID_SEPARATORsrc/shared/pty-session-id-format.ts:15)。解析走 splitWorktreeIdsrc/shared/worktree/id.ts:20),主进程侧包一层会抛错的 parseWorktreeIdsrc/main/ipc/worktree-logic.ts:264)。

这么设计换来了什么:

  • 不需要注册表。 git worktree list 吐出路径,直接就能拼出 id、去 worktreeMeta 里查便签。用户在 Orca 之外手动 git worktree add 出来的目录,下次刷新自动被认领。
  • id 自带定位信息。 拿到 id 就知道 cwd 该设成哪、属于哪个 repo,不用回表。
  • 删除天然幂等。 目录没了,id 自然再也不出现。

代价是什么: 目录一改名,身份就断了。

4.3 改名迁移:priorWorktreeIds 和那 35 张表

改名不是罕见操作——Orca 有个功能是"agent 说的第一句话决定工作区叫什么",它会把分支和显示名改掉,然后顺手 git worktree move 把文件夹也对齐(renameWorktreeFolderOnFirstWorksrc/main/agent-hooks/first-work-folder-rename.ts:37)。文件夹一动,${repoId}::${path} 就换了一个。

处理顺序被注释钉死:"先 move(不可回头点),再同步重打身份,不让任何东西插进来"(同文件 :68-71):

await deps.moveWorktree(repo.path, plan.oldPath, plan.newPath)
deps.migrateWorktreeIdentity(worktreeId, plan.newWorktreeId)
deps.notifyWorktreeRenamed(repo.id, worktreeId, plan.newWorktreeId)

主进程侧 Store.migrateWorktreeIdentitysrc/main/persistence/loading-store/store.ts:2592)做三件事:

  1. 搬 key —— worktreeMetaworktreeLineageByIdworkspaceLineageByChildKey、各 host 分区的 session…… 逐个 record[new] = record[old]; delete record[old]
  2. 记旧账 —— 把旧 id 追加进新 meta 的 priorWorktreeIdssrc/main/persistence/tracking-repos/worktree-identity-migration.ts:148-154)。
  3. 修反向引用 —— 别的工作区的 lineage.parentWorktreeId 指着旧 id 的,一并改掉。

第 2 步为什么必须有?因为 PTY 会话是在旧 id 下铸出来的。守护进程的会话 GC 看到一个"归属于不存在的工作区"的会话就会回收它——不留旧账,改个名字等于把用户正在跑的 agent 杀了。类型注释把这句话写得很清楚:

让守护进程的 session GC 和注册表水化认得出用旧 id 铸出来的会话,而不是把它们当孤儿收掉。 —— WorktreeMeta.priorWorktreeIdssrc/shared/worktree/meta-types.ts:81-85

渲染进程有一份对称的实现buildWorktreeRenameStatesrc/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts:55)遍历一张常量表 WORKTREE_ID_KEYED_MAP_KEYS——35 个以 worktreeId 为 key 的 mapsrc/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts:11-53),从 tabsByWorktreegitStatusByWorktreeexpandedDirs,一个都不能漏。漏一个,用户改完名就会发现某个面板空了。

还有更细的坑:终端"重开上次关掉的标签页"快照里存的是绝对 startupCwd,不重映射的话 Cmd+Shift+T 会 spawn 到一个已经不存在的目录去(remapClosedTerminalTabSnapshotCwdsworktree-identity-rename-state.ts:105-112;函数本体在 slices/recently-closed-tabs.ts:78)。

一句话总结这个取舍:

路径即身份独立 UUID + 路径字段
自动发现外部 worktree免费需要注册/对账
改名要在两个进程重打 30+ 张表免费
id 可读性高(自带路径)
Orca 的选择

4.4 instanceId — 不随路径漂的那一半

正因为 id 会变,Orca 又补了一个不变instanceId

每个工作区实例不可变的 ID,用来在路径被复用后拒绝过期的 lineage。 —— WorktreeMeta.instanceIdsrc/shared/worktree/meta-types.ts:18-19

它专门服务于血缘(lineage)。WorktreeLineagesrc/shared/worktree/lineage-types.ts:19)同时记 parentWorktreeId(路径派生,会漂)和 parentWorktreeInstanceId(不漂)。场景:一个协调者 agent 在 A 工作区里派生出 B 工作区,A 后来被删、同一个路径被新建的 C 占用——只比路径的话 B 会误认 C 当父亲,比 instanceId 就不会。

4.5 WorkspaceKey — 给"工作区"这个概念一个统一 key

worktree 之外还有 folder workspace,两者要能塞进同一个 Record。于是有了带前缀的 key:

export type WorkspaceScope =
| { type: 'worktree'; worktreeId: string }
| { type: 'folder'; folderWorkspaceId: string }

export type WorkspaceKey = `worktree:${string}` | `folder:${string}`

—— src/shared/folder-workspace-types.ts:11,构造/解析在 src/shared/workspace-scope.ts:3worktreeWorkspaceKey)、:7folderWorkspaceKey)、:11parseWorkspaceKey)。

新代码用 WorkspaceKey,老代码继续读裸 activeWorktreeIdgetActiveSidebarWorkspaceIdsrc/shared/workspace-scope.ts:29)负责在两套之间兜底。这是一次没做完的迁移WorkspaceSessionState 的注释直说了:"key 可能是老式裸 worktree ID,也可能是规范的 WorkspaceKey"(src/shared/workspace-session-state-types.ts:39-40)。

4.6 ExecutionHostId — 身份还要带宿主

同一个 repoId::path 在两台服务器上可能都存在。所以宿主 id 是身份不可分割的一部分:

'local' 本机(Mac / Windows / Linux)
'ssh:<encodeURIComponent(targetId)>' SSH 目标
'runtime:<encodeURIComponent(envId)>' 远程 Orca runtime(含临时 VM)

—— src/shared/execution-host.ts:9,解析器 parseExecutionHostId:69)连 decodeURIComponent 失败都当无效处理。

宿主的优先级是一段值得抄的代码(getWorktreeExecutionHostIdsrc/shared/execution-host.ts:161):工作区自己的 hostId 优先,其次从 repo 推(executionHostId > connectionId > 'local')。注释解释了为什么统一成一个函数:runtime 和 SSH 快照能给出比 repo 兜底更精确的归属,"侧边栏的每一次宿主判断都必须走同一套优先级"。

Repo 上为什么既有 connectionId 又有 executionHostId?类型注释给了答案:runtime 宿主上的 repo connectionId: null,看起来和本地 repo 一模一样,必须有显式字段区分(src/shared/repo-types.ts:61-66)。


5. 双形态:git worktree 与 folder workspace

5.1 为什么要有第二种形态

不是所有人都想用 git worktree。有人只想让 agent 在一个普通目录里干活(写文档、跑脚本、非 git 项目)。Orca 的处理不是新开一套 UI,而是把文件夹伪装成 worktree,让侧边栏、标签页、终端全部复用同一条代码路径。

代价是"folder workspace"在代码里有两条不同的实现路线,读代码时最容易在这里迷路:

路线记录类型挂在谁下面id 形状投影函数
A. 独立 folder 工作区FolderWorkspaceprojectGroupIdfolder:<uuid>folderWorkspaceToWorktreesrc/shared/folder-workspace-worktree.ts:7
B. folder 型 repo 的会话实例WorktreeMetarepoId${repoId}::${repoPath}::workspace:<uuid>mergeFolderWorkspacesrc/main/ipc/worktrees.ts:953

路线 B 的分隔符是另一个常量 FOLDER_WORKSPACE_INSTANCE_SEPARATOR = '::workspace:'src/shared/worktree/id.ts:10)。它的存在是因为:一个文件夹项目可以开多个并行会话,但它们共享同一个物理目录。所以有了一对"孪生"解析器:

splitWorktreeId(id) // 拿身份 → 保留 ::workspace:<uuid> 后缀
splitWorktreeIdForFilesystem(id) // 拿路径 → 剥掉后缀,得到真实目录

—— src/shared/worktree/id.ts:20:31,后者的注释直说:"文件系统调用方要的是真实文件夹路径当 cwd/root"。

踩坑提示: 用错了 splitter 会导致 cwd 指向一个不存在的 .../foo::workspace:abc-123 目录。gcStaleWorktreeMeta 里也专门跳过带这个分隔符的 key,因为"folder 项目的工作区实例本身就是工作区记录,不是一行签出"(src/main/persistence/tracking-repos/worktree-metadata-normalization.ts:35)。

5.2 三个 merge 函数,一个 Worktree 类型

不管哪条路线,最后都收敛成同一个 Worktree

git worktree list ──┐
(GitWorktreeInfo)│ mergeWorktree(repoId, git, meta) ← 硬事实 + 便签
├──► Worktree ◄── mergeFolderWorkspace(repo, id, meta) ← 路径 B
WorktreeMeta ────┘ └─ folderWorkspaceToWorktree(fw) ← 路径 A

mergeWorktreesrc/main/ipc/worktree-metadata-merge.ts:11)是最典型的一个,显示名的兜底链很能说明设计意图:

displayName: meta?.displayName || branchShort || defaultDisplayName || basename(git.path)

用户改的名 → 分支名 → repo 名 → 目录名。folder 路线因为没有 git 事实,head/branch 直接填空串、isBare: falsesrc/shared/folder-workspace-worktree.ts:46-49)——类型上是 Worktree,语义上有一半字段是假的,这是这个投影方案的固有代价。

Worktree 的类型定义把这层结构写得很直白:它是 {...应用层字段} & GitWorktreeInfosrc/shared/worktree/types.ts:60meta-types.ts:72-78 的创建标记字段),交叉类型的右半边就是 git 那部分硬事实(src/shared/worktree/types.ts:21)。

5.3 归属:这个 worktree 是谁造的

自动发现带来一个新问题:git worktree list 里既有 Orca 造的,也有用户手工造的,还有 agent 自己偷偷造的临时目录。于是有 WorktreeOwnership 四态(src/shared/worktree/types.ts:198):

取值含义判据
orca-managedOrca 亲手造的meta 里有"强证据"字段
agent-scratch子 agent 的临时工作树(如 .claude/worktrees路径匹配
external用户在 Orca 之外造的不在 Orca 的 workspace 根下
unknown-legacy说不准兜底

判定顺序在 classifyWorktreeOwnershipsrc/shared/worktree/ownership.ts:111),"强证据"的定义是一组只有 Orca 会写的字段:orcaCreatedAtorcaCreationWorkspaceLayoutcreatedWithAgentpushTargetsparsePresetId……(hasStrongOrcaMetadatasrc/shared/worktree/ownership.ts:227)。

为什么不能只看路径? 代码里有一句关键注释:普通的 git worktree add 完全可以把目标定在 Orca 的嵌套 workspace 目录里——"只有 metadata 能证明是 Orca 造的"(src/shared/worktree/ownership.ts:152-154)。

带上归属的 WorktreeDetectedWorktreesrc/shared/worktree/types.ts:202),多三个字段:ownershipselectedCheckoutvisible

5.4 路径是怎么算出来的

既然路径就是身份,那算路径的函数就是身份的铸造厂。computeWorktreePathsrc/main/ipc/worktree-logic.ts:101):

workspaceRoot = repo.worktreeBasePath ?? settings.workspaceDir
└─ WSL 特例:镜像到 \\wsl.localhost\<distro>\home\<u>\orca\workspaces

nestWorkspaces = true → <root>/<repoName>/<sanitizedName>
nestWorkspaces = false → <root>/<sanitizedName>

两个安全守卫值得单独看:

  • sanitizeWorktreeNamesrc/main/ipc/worktree-logic.ts:25)保留 Unicode 字母数字(让人能用中文命名工作区),但把连续的点折叠成一个——注释说明原因:git check-ref-format 拒绝含 .. 的 ref,一个像 ../../foo 的提示词 slug 化后会变成 ..-..-foo,git 会直接拒建分支。
  • ensurePathWithinWorkspacesrc/main/ipc/worktree-logic.ts:79)用 path.relative 判断结果是否逃出了 workspace 根,防目录穿越。

6. 分屏树:tab → tabGroup → leaf

6.1 要解决的小问题

一个工作区里用户会同时要:一个跑 agent 的终端、一个看代码的编辑器、一个看 diff 的面板,还想把它们左右分屏。分屏是递归的(左边再上下分),所以只能用树。

6.2 三层结构

worktreeId

├── layoutByWorktree[worktreeId] : TabGroupLayoutNode ← 分屏树
│ split(direction, ratio)
│ ├── leaf(groupId: "g1")
│ └── split
│ ├── leaf(groupId: "g2")
│ └── leaf(groupId: "g3")

├── groupsByWorktree[worktreeId] : TabGroup[] ← 每个 group 一条标签条
│ { id: "g1", activeTabId, tabOrder[], recentTabIds[] }

└── unifiedTabsByWorktree[worktreeId] : Tab[] ← 扁平的标签页清单
{ id, entityId, groupId, contentType, label, ... }

三层各自的分工(src/renderer/src/store/slices/tabs.ts:63-69):

类型存什么定义处
布局TabGroupLayoutNode只有 leaf(groupId)split(first, second, ratio)src/shared/tab-types.ts:7
分组TabGroup标签顺序 tabOrder[] + 活跃页 + MRU 栈 recentTabIds[]src/shared/tab-types.ts:68
Tab内容类型、标签文字、entityIdsrc/shared/tab-types.ts:40

identityId 为什么要分开? Tab 是"UI 上的一格",真正的内容活在别的 slice 里:终端 tab 的 entityId 指向 TerminalTab 记录,编辑器的指向文件路径,浏览器的指向 BrowserWorkspace id(src/shared/tab-types.ts:42)。这样"关闭标签页"和"销毁内容"可以解耦。

contentType 有 7 种:terminal | editor | diff | conflict-review | check-details | browser | simulatorsrc/shared/tab-types.ts:19-27)。其中只有 4 种算"工作区可见标签"(WorkspaceVisibleTabTypesrc/shared/tab-types.ts:28)——diff 之类是瞬态的,重启不恢复。

recentTabIds 是个小而妙的设计:每个 group 独立维护 MRU 栈,关掉当前页时回退到上一个用过的页,而不是跳到视觉上的邻居;作用域限定在 group 内,所以分屏的两半各有各的历史(src/shared/tab-types.ts:72-78)。

6.3 还有第二棵树:终端窗格

一个终端 tab 内部还能再分屏。这是另一棵独立的树 TerminalPaneLayoutNode,叶子不是 groupId 而是 leafIdsrc/shared/terminal-tab-types.ts:60)。

两棵树的关系:

TabGroupLayoutNode (工作区级)leaf = groupId → 一条标签条
└── Tab (terminal)
└── TerminalPaneLayoutNode (tab 级)leaf = leafId → 一个 xterm 窗格

leafId 必须是 UUID,由一个正则守着(UUID_RE + isTerminalLeafIdsrc/shared/stable-pane-id.ts:4/:18)。窗格的全局身份是 paneKey

export function makePaneKey(tabId: string, stableLeafId: string): PaneKey {
if (!tabId || tabId.includes(':')) throw new Error('tabId must be non-empty and must not contain ":"')
if (!isTerminalLeafId(stableLeafId)) throw new Error('stableLeafId must be a UUID')
return `${tabId}:${stableLeafId}` as PaneKey
}

—— src/shared/stable-pane-id.ts:22

文件顶部的注释解释了为什么非要用持久 UUID 而不是渲染进程本地的数字窗格 id:paneKey 要跨渲染进程重载、跨 PTY 环境变量、跨 hook IPC、跨保留的 UI 行src/shared/stable-pane-id.ts:1-3)。agent 状态就是按 paneKey 索引的(agentStatusByPaneKeysrc/renderer/src/store/slices/agent-status.ts:177)——详见 认得出 agent

历史包袱也留着:parseLegacyNumericPaneKeysrc/shared/stable-pane-id.ts:47)还能认出老的 tabId:0 形式,用于建别名。


7. 主进程持久化 vs 渲染进程内存态

7.1 分工原则

主进程 Store渲染进程 useAppStore
权威性唯一权威,落 orca-data.json镜像 + 瞬态,随时可从主进程重建
内容用户意图(便签、设置、会话布局)上面全部 + 实时状态(agent 状态、git status、PTY 绑定)
生命周期跨重启随窗口消失
写入方式防抖 1s / 强制 5s同步 set()

判断某个东西该放哪,有一条清晰的线:能从磁盘/git/进程重新算出来的,不存TerminalLayoutSnapshot.ptyIdsByLeafId 的注释是最好的例子——"用于会话内的重挂载(比如 tab 移组);用于应用重启,因为 PTY 是瞬态进程"(src/shared/terminal-tab-types.ts:78-80)。

7.2 落盘的东西:PersistedState

PersistedStatesrc/shared/persisted-state-types.ts:50)是 orca-data.json 的形状,默认值在 getDefaultPersistedStatesrc/shared/constants.ts:425),SCHEMA_VERSION = 1src/shared/constants.ts:46)。

跟本章相关的字段:

字段内容
repos / projects / projectGroups / folderWorkspaces四张实体表
worktreeMeta: Record<worktreeId, WorktreeMeta>便签表——路径为 key 的那张
worktreeLineageById / workspaceLineageByChildKey血缘(谁派生了谁)
workspaceSession'local' 宿主的会话布局
workspaceSessionsByHostId其它宿主的会话分区
githubCache单独走 sidecar 文件

注意两处刻意的向后兼容设计:

  1. workspaceSession 保留成裸字段而不是并进 workspaceSessionsByHostId['local'],注释写明理由:"遗留的单 blob 会话,作为规范的 'local' 分区保留,这样应用降级后仍能读到自己的工作区"(src/shared/persisted-state-types.ts:88)。
  2. githubCache 拆成 sidecar:它每轮轮询都刷新,写在主文件里会导致每个周期重写整个数 MB 的 JSON(src/main/persistence/loading-store/user-data-path.ts:29)。

WorktreeMetasrc/shared/worktree/meta-types.ts:17)和 Worktree 高度重合但不含 git 事实——没有 head/branch/path。这正是"便签 vs 硬事实"的边界:便签落盘,硬事实每次问 git。

7.3 会话状态:一个巨型可选字段包

WorkspaceSessionStatesrc/shared/workspace-session-state-types.ts:32)是重启后重建 UI 所需的一切:tabsByWorktreeunifiedTabstabGroupstabGroupLayoutsopenFilesByWorktreebrowserTabsByWorktreeterminalLayoutsByTabId……几乎每个字段都是可选的,因为老版本写下的 session 必须能被新版本读。

最典型的双读路径写在类型注释里:

统一 tab 模型 —— 由包含 TabsSlice 的构建版本写入时才存在。读路径先查它,缺失则回退到 legacy 字段。 —— WorkspaceSessionState.unifiedTabssrc/shared/workspace-session-state-types.ts:68-69

对应的实现就是 buildHydratedTabStatesrc/renderer/src/store/slices/tabs-hydration.ts:314):

if (session.unifiedTabs && session.tabGroups) {
return hydrateUnifiedFormat(session, validWorktreeIds)
}
return hydrateLegacyFormat(session, validWorktreeIds)

legacy 路径会把老的 tabsByWorktree + openFilesByWorktree 现场合成一个 group(:189 起)。

水化时的自我保护:所有 hydrate 函数都收一个 validWorktreeIds: Set<string>,key 不在集合里就整条跳过(:56-58)。分屏树则走 pruneTabGroupLayoutForGroups:24)——递归剪掉指向已消失 group 的叶子,只剩一边就把另一边提上来,两边都在且没变就返回原节点(保持引用相等,省一次 React 重渲染)。

7.4 写入节奏

渲染进程改状态

├─ 平时 ────► session:set (invoke) ──► Store.setWorkspaceSession ──► scheduleSave()
│ │
│ 防抖 1s,最长等 5s ◄────────┘
│ │
│ ▼ 原子写(temp → rename)
└─ 关窗口 ──► session:set-sync (sendSync) ──► setWorkspaceSession + flush() ──► orca-data.json
↑ 阻塞渲染进程直到写完

参数和理由都在源码里:

// Why 1s trailing + 5s max-wait (was 300ms unbounded): coalesce mutation bursts; max-wait bounds crash staleness at 5s.
private static SAVE_DEBOUNCE_MS = 1_000
private static SAVE_MAX_WAIT_MS = 5_000

—— src/main/persistence/loading-store/store.ts:1714-1715,调度逻辑在 scheduleSave:1717)。

同步通道的存在理由写在 IPC 处理器上:sendSync 会阻塞渲染进程直到返回,这样不管 before-quit 的顺序如何,数据(包括终端滚屏缓冲)一定先落盘再关窗(src/main/ipc/session.ts:29-38)。

还有两个小机关:lastWrittenStateHash 跳过内容没变的写入(src/main/persistence/loading-store/store.ts:531);quitFlushStarted 之后拒绝新的防抖写入,保证退出时的那次是最后一次(store.ts:528quitFlushStarted:1721 的拒绝写入)。

7.5 主进程不是傻存:三类主动加工

① 归一化 leafId。 渲染进程写来的分屏快照可能带非 UUID 的旧 leafId、甚至同一个 leafId 出现两次。normalizeTerminalLayoutSnapshotForPersistencesrc/main/persistence/restoring-sessions/terminal-layout-normalization.ts:134)负责重铸:

  1. 数每个 leafId 出现几次,出现 >1 次的进 duplicatedInputLeafIds 黑名单(terminal-layout-normalization.ts:170-171)。
  2. 已经是 UUID 的原样保留;不是的按位置向"上一次的布局"对齐取旧 UUID,取不到就 randomUUID()terminal-layout-normalization.ts:177-194)。
  3. 四张随 leaf 走的表一起重映射ptyIdsByLeafIdbuffersByLeafIdscrollbackRefsByLeafIdtitlesByLeafIdremapLeafRecordForPersistenceterminal-layout-normalization.ts:87)。

这里最要命的是 ptyIdsByLeafId——它是"哪个窗格连着哪个活着的 PTY 进程"。重映射漏了,重挂载时窗格就接到别人的进程上去了。setLocalWorkspaceSession 上还挂着一条 issue 编号的注释:

Why (Issue #217): merge existing bindings when the incoming binding is empty, so a stale pre-spawn snapshot can't overwrite the durable PTY binding. —— src/main/persistence/loading-store/store.ts:2911

翻译:渲染进程的防抖写入器可能还攥着 spawn 之前的快照(那时候还没有 PTY 绑定),直接覆盖就会把已经建立的绑定抹掉。所以空绑定要跟旧值合并,而不是覆盖。

改完 leafId 还要连坐修一串东西:已确认的 agent 面板(acknowledgedAgentsByPaneKey)、SSH 远程 PTY 租约(remapSshRemotePtyLeaseLeafIds),因为它们的 key 里嵌着 leafId(store.ts:2912-2930acknowledgedAgentsByPaneKey 重映射于 :2920、SSH PTY 租约于 :2926)。

② GC 便签表。 worktreeMeta 会单向增长——在 Orca 之外被删的 worktree 留下永远不被清理的条目。gcStaleWorktreeMetasrc/main/persistence/tracking-repos/worktree-metadata-normalization.ts:24)的注释给了个真实数字:"一台重度使用的机器上 63% 是死条目"。

它的清理条件收得非常窄,每一条都有理由:

跳过为什么
::workspace: 的 key那是 folder 工作区记录本身,不是签出行
远程/SSH/非 local 宿主本地 existsSync 会误判远程路径不存在
project 拥有的 meta走自己的 project/host 生命周期
非绝对路径 / WSL UNC 路径判不准
闲置未满 30 天WORKTREE_META_GC_GRACE_MSworktree-metadata-normalization.ts:21

③ 按宿主分区会话。 getWorkspaceSession(hostId)src/main/persistence/loading-store/store.ts:2775)把 'local' 路由到裸字段、其它路由到 workspaceSessionsByHostId。IPC 层把 hostId 设计成可选的第二参数,这样老渲染进程不传它,行为与分区前完全一致(src/main/ipc/session.ts:9-11)。分区的必要性一句话说清:"两台服务器上可能存在完全相同的 repo/path id"(src/main/persistence/loading-store/store.ts:2848)。

7.6 渲染进程:40 个 slice 的一个大 store

useAppStore单个 zustand store,由 40 个 slice 工厂拼成(src/renderer/src/store/index.ts:61),类型侧是 40 个 slice 类型的交叉(AppStatesrc/renderer/src/store/types.ts:47,从 RepoSlice 一路交到 RemoteServerUpdatesSlice)。

按"要不要落盘"分成三档:

例子 slice落盘吗
磁盘镜像repossettingsworktrees(元数据部分)、tabs会(经 session/meta IPC)
会话瞬态agent-statusruntime-statuspane-foreground-agent不会
纯 UIuidictationnew-issue-draft部分

瞬态的意图在注释里写得很明确:"实时的,不持久化"(agentStatusByPaneKeysrc/renderer/src/store/slices/agent-status.ts:177)。

WorktreeSlicesrc/renderer/src/store/slices/worktree-helpers.ts:94)里有个很好的边界示范——pendingWorktreeCreations

保持和 worktreesByRepo 分开是刻意的——只有 git worktree add 成功后才存在真正的 worktree 行,在这里伪造一行会一路波及 git-status、tab 模型、持久化和 PTY 拉起。会话级,永不持久化。 —— src/renderer/src/store/slices/worktree-helpers.ts:102-109

"正在创建中"不是一个 Worktree,因为 Worktree 的身份是路径,而路径要等 git 建完才成立。这条约束贯穿 第 2 章

派生数据走 selector(src/renderer/src/store/selectors.ts),并且大量用 WeakMap 按 slice 引用做缓存——因为这些 selector 挂在常驻组件上,一次无关的 store 写入不该触发全量重扫(getCachedHasAnyWorktrees:37-46)。


8. 巧妙之处(可以带走的)

  1. 合成 id 的分隔符只定义一次。 WORKTREE_ID_SEPARATOR 集中在 src/shared/pty-session-id-format.ts:15,注释记着教训:"三处各写一遍 parser,其中一处比别处松,就是 REMOTE 误标 bug 的种子。"

  2. "我造的"用 metadata 证明,不用路径证明。 hasStrongOrcaMetadatasrc/shared/worktree/ownership.ts:227)列出一组只有 Orca 会写的字段。路径可以被别人占,metadata 不会。

  3. 改名迁移用常量表驱动,不是手写 35 行。 WORKTREE_ID_KEYED_MAP_KEYSsrc/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts:11satisfies readonly (keyof AppState)[]——加了新 map 忘了加进表里,TypeScript 拦不住,但至少 review 时有一处集中清单可看。

  4. 同一个 id 配两个解析器,按用途分。 splitWorktreeId(要身份)vs splitWorktreeIdForFilesystem(要真实路径)(src/shared/worktree/id.ts:20/:31)。合成 id 只要带了"虚拟后缀",就必然需要这一对。

  5. 降级路径永远保留裸字段。 workspaceSession 不并进 workspaceSessionsByHostIdhostId 是可选第二参数、unifiedTabs 缺失就走 legacy 水化——三处都是同一个模式:新结构叠在旧结构旁边,而不是替换它

  6. 剪枝时保持引用相等。 pruneTabGroupLayoutForGroupssrc/renderer/src/store/slices/tabs-hydration.ts:25)在两个子节点都没变时返回原节点而不是新对象,省掉 React 的一次重渲染。

  7. 给非 ASCII 命名让路,但给 git 的规则让步。 sanitizeWorktreeNamesrc/main/ipc/worktree-logic.ts:25)保留 CJK 和重音字母,只剥 git 和文件系统真正会拒绝的字符——同时把 .. 折叠掉,因为 git check-ref-format 不认。


9. 边界与已知代价

诚实列一下这套模型的账:

  • 路径即身份的账,永远还不完。 每加一个以 worktreeId 为 key 的 map,就多一处要在两个进程的迁移逻辑里登记的地方。35 张表已经是"靠人记得住"的上限。
  • folder workspace 有一半字段是假的。 投影出来的 Worktreehead/branch 是空串、isBare: falsesrc/shared/folder-workspace-worktree.ts:46-49)。任何直接读这些字段的代码都得先判断形态。
  • 两条 folder 路线并存。 folder:<uuid>${repoId}::${path}::workspace:<uuid> 是两套不同的 id 形状,代码里靠不同的函数处理,没有统一。
  • WorkspaceKey 迁移没做完。 老代码读裸 activeWorktreeId,新代码读 activeWorkspaceKeygetActiveSidebarWorkspaceIdsrc/shared/workspace-scope.ts:29)在中间兜底;tabsByWorktree 的 key 类型注释直说"可能是两者之一"(src/shared/workspace-session-state-types.ts:39-40)。
  • WorkspaceSessionState 是个巨型可选包。 向后兼容的代价是几十个 ? 字段,读的时候到处要 ?? {}
  • WorkspaceStatus = string 工作区状态就是个裸字符串(src/shared/worktree/types.ts:51),类型上不约束,用户可自定义状态定义(WorkspaceStatusDefinitionsrc/shared/worktree/types.ts:53)。
  • 持久化层曾经是一个 7400+ 行的 persistence.ts;上游已按职责拆成 src/main/persistence/ 目录(loading-store/applying-settings/restoring-sessions/tracking-repos/……),persistence.ts 只剩一个 9 行的再导出桶。拆分让"存储契约作为一个整体被 review"的旧理由不再成立,但代价是多跳一层才能找到实现。

10. 代码地图

主题文件关键符号
核心类型(拆分后的定义处)src/shared/project-types.tsrepo-types.tsproject-group-types.tsworktree/types.tsworktree/meta-types.tsfolder-workspace-types.tstab-types.tspersisted-state-types.tsworkspace-session-state-types.tsProjectRepoProjectGroupWorktreeWorktreeMetaFolderWorkspaceTabPersistedStateWorkspaceSessionState
worktree id 解析src/shared/worktree/id.tssplitWorktreeIdsplitWorktreeIdForFilesystemgetRepoIdFromWorktreeIdFOLDER_WORKSPACE_INSTANCE_SEPARATOR
id 分隔符定义src/shared/pty-session-id-format.tsWORKTREE_ID_SEPARATORPTY_SESSION_ID_SEPARATOR
工作区 key(双形态)src/shared/workspace-scope.tsworktreeWorkspaceKeyfolderWorkspaceKeyparseWorkspaceKeygetActiveSidebarWorkspaceId
执行宿主身份src/shared/execution-host.tsExecutionHostIdparseExecutionHostIdgetRepoExecutionHostIdgetWorktreeExecutionHostId
窗格身份src/shared/stable-pane-id.tsmakePaneKeyparsePaneKeyisTerminalLeafIdparseLegacyNumericPaneKey
归属判定src/shared/worktree/ownership.tsclassifyWorktreeOwnershiphasStrongOrcaMetadatatoDetectedWorktree
folder → worktree 投影src/shared/folder-workspace-worktree.tsfolderWorkspaceToWorktree
默认持久状态src/shared/constants.tsSCHEMA_VERSIONgetDefaultPersistedStategetDefaultWorkspaceSession
持久化核心src/main/persistence/loading-store/store.ts + tracking-repos/restoring-sessions/StorescheduleSavemigrateWorktreeIdentitytracking-repos/worktree-identity-migration.ts)、gcStaleWorktreeMetatracking-repos/worktree-metadata-normalization.ts)、normalizeWorkspaceSessionPaneIdentitiesrestoring-sessions/terminal-layout-normalization.ts
会话 IPCsrc/main/ipc/session.tsregisterSessionHandlerssession:get/set/patch/flush/set-sync
路径与命名规则src/main/ipc/worktree-logic.tscomputeWorktreePathcomputeWorkspaceRootsanitizeWorktreeNameensurePathWithinWorkspaceparseWorktreeId
git 事实 + 便签合并src/main/ipc/worktree-metadata-merge.tsmergeWorktree
folder 工作区实例src/main/ipc/worktrees.tsgetFolderWorkspaceRootIdmergeFolderWorkspaceisFolderWorkspaceIdForRepo
改名触发点src/main/agent-hooks/first-work-folder-rename.tsrenameWorktreeFolderOnFirstWork
渲染进程 store 组装src/renderer/src/store/index.ts / types.tsuseAppStoreAppState
工作区切片src/renderer/src/store/slices/worktree-helpers.tsWorktreeSlicefindWorktreeByIdapplyWorktreeUpdates
改名重打 keysrc/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.tsWORKTREE_ID_KEYED_MAP_KEYSbuildWorktreeRenameState
标签页切片src/renderer/src/store/slices/tabs.tsTabsSlicecreateUnifiedTabcreateUnifiedTabInSplit
标签页水化src/renderer/src/store/slices/tabs-hydration.tsbuildHydratedTabStatehydrateUnifiedFormathydrateLegacyFormatpruneTabGroupLayoutForGroups
派生选择器src/renderer/src/store/selectors.tsselectFloatingVisibleTabCountgetCachedHasAnyWorktrees

下一章: 一条 worktree 的一生 — 从选 base 到安全清场 —— 这套身份规则在创建/删除流程里是怎么被真正用起来的。