会话挂载运行时规范(Session Mount Runtime)

September 19, 2026 · View on GitHub

本文是 issue #879「Refactor:规范终端进程挂载」 的运行时契约。 它描述的不是某个界面的做法,而是整个 TUI 里"会话"与"进程"关系的唯一一套规则。 界面实现见 src/screens/SessionSupervisor.tsx,占用协议见 src/sessionMounts.ts

1. 两个概念,先分清

概念含义
会话(session)一份正在运行的 agent 会话,落到磁盘上是一条 append-only 的事件日志
进程(process)一个 TUI 终端进程

三端的本质差别(这是所有设计的起点):

  • webui:后台服务进程托管会话;关掉那个后台窗口,所有会话立刻停止;一台机器只能同时跑一个
  • gui:整个程序托管会话;关掉程序(或从托盘杀掉后台),所有会话立刻停止;一台机器只能同时开一个
  • tui自己就是一个终端进程;关掉它,它托管的会话立刻停止;一台机器可以同时开多个 tui。

TUI 这一行是本规范全部复杂度的来源:它既是"进程",又是"能够托管多个会话的容器", 而且同机可以并存多个这样的容器。webui/gui 靠"全局唯一"回避了的所有问题,TUI 必须正面解决。

2. 唯一模型:终端托管多个会话

一个 TUI 进程 = 一个终端 + 一个挂载集(mount set):当前附着的那个会话,加上所有被 停放(park) 的会话。停放不是"暂停",也不是"保存后关闭"——被停放的会话仍然活着、仍在 这个进程里继续跑(正在生成的回合会继续生成),只是终端当前不显示它的转录。

由此推出三条规则:

  1. 切换会话 = 换看哪个,不是停掉哪个。 离开一个会话时它被停放;它保留在挂载集里, 会话管理界面上始终可见、可切回。
  2. 回合运行中不是拒绝切换的理由。 用户切的是"我在看什么",不是"让模型停"。正在跑的 回合继续在后台跑,它那一行继续报告进度。
  3. 终端进程退出 = 挂载集清零。 这是 TUI 的本质,不做进程托管、不假装会话能活过终端 (gui 的托盘式后台不属于 TUI 的语义)。会话日志本身是持久的,下次 /resume 能回来。

2.1 为什么曾经有两套

/resume 原本是 tui 式:切换会话后当前会话立刻停止keepCurrent=false,旧句柄被 dispose),并且在回合运行中直接拒绝。/agentviewgui/webui 式:切换后当前会话 不停止,而是被挂到本进程的后台句柄账本上;关掉进程才全部关闭。

两者都不是错的设计——它们是两个命令各自长大的历史巧合,于是在同一份 resumeTo 上互相 打补丁,用户也就必须理解两套心智模型。现在保留的是 /agentview 那一套,因为它是唯一与 "TUI 是一个托管多会话的终端进程"这个事实相符的模型,也已经是通道层已有的能力 (backgroundHandles 账本 + agent-view 投影)。

/resume/home/agentview/bg 和输入框左侧的 🏠 现在全部落到同一个界面、同一 套动作上;它们是肌肉记忆的入口,不是四个功能。

3. 跨进程占用协议

3.1 要解决的问题

两个 TUI 进程可以各自 /resume 同一个会话 id。DSH 的会话存储不知道"谁正在驱动这份日志", 于是两个进程会往同一条 append-only 事件日志里交错写入,转录被破坏。这不是体验问题, 是数据损坏。

3.2 谁拥有:~/.dsh-tui/session-mounts.json

每个 TUI 进程发布一条自己的记录,声明它当前挂载了哪些会话:

{
  "version": 1,
  "owners": [
    { "pid": 12345, "startedAt": 1789361300000,
      "sessionIds": ["<sessionId>", "..."] }
  ]
}

归属判定的真源就是 pid:那个进程还在,记录就还算活着。刻意不引入 host / instance——home 目录可以被网络共享,但真正的跨机判定需要传输层协议, 一个写在本机磁盘上的字段做不到它;而 pid 复用只会把「其实空闲」的会话误判为占用 (用户重启一次即可),反方向的错误才是两份日志交错写入。

写入纪律(照搬 src/sessionPins.ts 已验证的做法):

  • 跨进程锁session-mounts.lockwx 独占创建)。锁文件里写的是 <pid>-<随机 nonce>:nonce 决定"锁是不是我的",pid 决定"这把锁还能不能抢";
  • 原子替换session-mounts.json.<pid>.<ts>.<seq>.tmp + rename,读者永远看不到半份文件;
  • 锁被抢走就不许再写:持锁者若被回收,必须能在写入前发现"锁已经不是自己的", 放弃本次提交;释放时也只能删自己那把锁,否则会把新持有者的锁删掉;
  • 只回收确定已经死掉的持有者:token 里的 pid 还活着就永不回收,文件多旧都不行; 只有 pid 已死(或 token 读不出来、且超过 STALE_LOCK_MS 的创建-写入窗口)才回收。 按 mtime 单条件回收会把锁从"只是被暂停"的活持有者手里抢走,而它恢复后可能提交一份 在抢锁之前就已经推导好的快照。代价是 pid 复用:一个无关进程复用了死者的 pid, 这把锁就再也不能自动回收,需要在所有共享该数据目录的进程都停止后手工删除 session-mounts.lock
  • 权限:目录 0700、文件 0600
  • 展示可以尽力而为,授权不行:界面读账本用容错读(坏文档当空表,屏幕上少几行总比 崩掉强),但决定能不能挂载的路径用严格读(见 3.4)。任何一步失败都不会退化成 "那就当没人占用"——那是唯一不可恢复的错误。

3.3 活性只有一个证人:pid

一条记录活着,当且仅当 process.kill(pid, 0) 成立(EPERM 也算活着)。 没有心跳时间戳:时间戳只有在定时器持续刷新时才可信,而「刷不动」的进程恰恰就是 那条该过期的记录。一个证人只有一个答案,也不会出现两个证人互相矛盾。

工况结果
正常退出(含 Ctrl+C拆解漏斗调用 clearOwnMounts(),记录立刻删除,会话立即可被别的 tui 进入
kill -9 / 强关终端 / 断电pid 消失 → 下一次读取即忽略该记录
pid 被复用记录被误判为占用,直到那个无关进程退出

已知代价写在模块头:本账本是同机可见性层,不是写权限权威——真正的写互斥由宿主 自己的会话写锁保证。

不在读取路径写回。 读取只负责过滤,清理只发生在写入路径:锁外「读快照 → 过滤 → 写回」会丢掉并发发布方在这段时间里写入的活记录(rename 的原子性只能防半份文件, 防不了丢更新),于是「读取时自愈」会退化成「读取时删掉别人的占用」。

3.4 检查与占用必须是同一步

挂载一个非本进程存活的会话时,顺序必须是:

  1. 读账本(readSessionOwners)做一次预检,只用于给用户一句带 pid 的提示;
  2. 未被占用则调用 reserveMount(sessionId):它在跨进程锁内严格重读文件、重新判定冲突, 冲突则一个字节都不写,成功则把自己这条记录(含新会话)拼进当前文件,并给这次操作 留下一枚进程内 pin;
  3. 然后才 await 恢复流程;结束时分两种收尾——提交用 settle()(此后由 agent 注册表 负责),没提交用 abandon()(把会话交还出去)。

"先检查、再发布"跨进程不是原子的:两个进程可以各自在第 1 步读到 free,然后依次发布, 账本里就出现同一会话的两个 live 持有者。第 2 步把权威判定放进锁内,才是真正的准入语义。

拒绝有三种,必须分得开MountFailure):

结果含义处理
occupied + holders有具名的活体持有者提示"被进程 N 占用",拒绝
busy对端正持着那把短锁,没查成有界退避重试(25–400ms),预算用尽后拒绝并提示稍后重试
unavailable + detail账本读不了 / 写不进,没查成明确拒绝并给出原因(文件损坏时提示在无其他 TUI 运行时删除)

旧实现把后两者压成 holders: [],调用方只能猜;启动恢复据此把"没查成"当成了"没人占用", 于是照常挂载——这正是本账本存在的意义所在的那个错误。已存在的会话遇到这三种都拒绝, 不得 fail-open:只读 HOME 的代价是一次响亮的拒绝,猜错的代价是不可恢复的日志交错。

唯一的例外是刚生成的新会话 id/new/bg、fork、rewind、模型切换、启动新建): 它不可能是别人持有的,此时拒绝只是"没能公告",不是冲突,所以记一条警告继续创建—— 挡掉用户开新会话换取一个根本不可能存在的冲突,不是这笔账该算的。

严格读与容错读分工。 readMountLedgerStrict() 只把 ENOENT 当初始空账本,读不了 / JSON 损坏 / 版本不认识 / 记录形状不对一律 unavailable,而且拒绝时不改动文件—— 坏记录可能正是另一个写入者唯一的声明,跳过它再整体重写等于替别人放弃占用。

预约要活过等待期。 占用声明在 agent 进入注册表之前就已提交(恢复要先读 preset、 路由、workspace),而发布器只从注册表推导集合。因此发布时取"注册表 ∪ 在途操作"的并集, 否则本进程自己的心跳就能把刚提交的预约冲掉,让对端读到 free

会话切换被否决 / 绑定已失效 / 恢复抛错(包括 binding.adopt() 抛错)时,本次取得的 占用会被撤销(abandon()releaseMount),不会留下"看起来被占用、其实没人用"的预约。 释放前要先等句柄真的关完(binding.abandon() 现在会 await 底层 dispose),否则占用 会在写入还没停的时候就消失。

本进程已存活的会话不走占用检查,也不走活体收养:它就是当前挂着的会话,再次进入是 幂等成功(保持同一个 Agent 句柄)。这一点必须显式短路——把 undefined 句柄交给 "收养"事务会走默认处置 dispose,把正在运行的会话停掉还返回成功。

3.5 界面契约

  • 其他 tui 占用的会话:照常列出,但整行标红、状态格显示 、行尾写 占用 pid <pid>,进入时提示 该会话正被其他 TUI 终端占用(进程 pid),无法进入
  • 自己占用的会话(停放中的)不算占用:它必须能从本界面切回。
  • 空闲、且不在本进程的会话:正常可进入。
  • 占用状态随界面自有的 2s tick 重新读账本,宿主不能在渲染时把快照捕获进闭包—— 否则别的终端退出后,这一行会一直红着且点不动,直到某个无关的通道事件碰巧重绘。
  • 界面只有注册表不够用:注册表可以为空(bare composition 不挂 workspace 服务, 或登记被移除而日志仍在)。此时界面必须提供一个基于会话 cwd 的兜底分组, 把"未登记目录下的会话"照常列出并可恢复——"没有登记"不等于"没有历史"。

4. 刷新开销

占用与活跃状态是两个内存/小文件读,与"重新列会话"(每个会话一次 stat + 按 revision 失效的摘要缓存)是两件事,刻意分开:

数据来源频率
挂载集发布(写)publishMounts,单次小文件带锁读写PUBLISH_INTERVAL_MS = 15s
画面上的活跃状态 / 占用(读)通道的 agent-view 投影快照 + 账本界面自有 2s tick,仅 setState
会话列表listSessions()打开界面时一次、Ctrl+L 手动一次

三者都遵守仓库既有的资源纪律:

  • 定时器一律 .unref()绝不成为进程退不出去的原因;
  • 一律经 ctx.effect / owner.own 单一退出漏斗清理;
  • 读取路径不碰文件:过滤死记录只发生在内存里,真正的清理由下一次写入顺手完成。

内存侧没有增长点:账本规模是「进程数 × 各进程已挂载的会话」,且每次发布整体重写; 活跃状态读的是通道已有的投影快照,不新建订阅。

5. 与 dpx 隔离环境的关系

dpx 创建的 tui 环境彼此完全隔离,隔离运行时状态写到各自的 home 路径

  • 会话账本路径 = join(homedir(), '.dsh-tui')src/utils/paths.ts); 而 dpx 隔离会同时改写 HOME / USERPROFILE,所以每个环境的 .dsh-tui 天然落在自己的 环境根内,不会看到别的环境的挂载记录。
  • 会话日志根 = $DSH_HOME/sessions(dpx 各环境各自的 DSH_HOME)。

因此:同一环境内的多个 tui 互相可见、互相保护;不同环境之间互不干扰—— 这正是"隔离环境的 tui 不被对方运行时干扰"的落地方式。跨环境看到同一个会话 id 的情况 不会发生,因为连会话存储根都不同。

6. 不变量(改这块代码时必须保持)

  1. 一份日志在同一时刻只被一个进程驱动。 准入判定必须在跨进程锁内完成 (claimMount:锁内重读 + 冲突重查 + 写占用是同一步),而不是"先在锁外检查、再发布"。 同一会话的再次进入必须幂等短路,绝不走会 dispose 句柄的收养事务。
  2. 切换会话不销毁会话。 停放,不 dispose;正在跑的回合不因切换而中断。
  3. 占用不得永久锁死。 任何一条记录都必须能被「pid 消失」回收;禁止引入 "必须手工解锁"的状态。
  4. 同机语义必须显式。 归属只按 pid;不要用一个写在本机文件里的 host/instance 字段假装能做跨机判定——共享 home 的两台机器需要的是传输层协议,不是本层。
  5. 锁只能由持有者写入与释放,且只有死掉的持有者会失去它。 每次获取写入 <pid>-<nonce>;失去锁的一方必须放弃提交,释放时只能删自己的锁。pid 还活着的锁 永不回收,文件多旧都不行——接受的代价是 pid 被复用时需要手工删除(见 3.2)。
  6. 展示读容错,授权读严格。 文件缺失、JSON 损坏、版本不符、字段类型不对—— 这些在界面上一律读作空,清理必须持锁重读后再写,不得提交锁前快照; 而授予挂载的路径必须反过来:只有 ENOENT 算空,其余一律拒绝,且拒绝时不写一个字节。
  7. 定时器不得阻止退出。 .unref() + 退出漏斗清理,缺一不可。
  8. 界面不得与运行时分歧。 能进入与否由运行时说了算;界面只是提前一步把原因讲清楚。 两条路径共用 src/sessions/resumeFailure.ts 的同一套措辞。
  9. 界面不得假设注册表完整。 空注册表 / 未登记目录的会话必须仍可见、仍可恢复; 注册表读取失败也不得吞掉已经成功读到的会话列表;占用状态必须每个 tick 重读, 不得复用宿主的渲染期快照。
  10. 焦点事实唯一。 会话列表的焦点以 sessionId 为唯一真源,索引由它派生; 渲染、键盘移动与 Enter 必须共用同一份派生结果。