会话挂载运行时规范(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) 的会话。停放不是"暂停",也不是"保存后关闭"——被停放的会话仍然活着、仍在 这个进程里继续跑(正在生成的回合会继续生成),只是终端当前不显示它的转录。
由此推出三条规则:
- 切换会话 = 换看哪个,不是停掉哪个。 离开一个会话时它被停放;它保留在挂载集里, 会话管理界面上始终可见、可切回。
- 回合运行中不是拒绝切换的理由。 用户切的是"我在看什么",不是"让模型停"。正在跑的 回合继续在后台跑,它那一行继续报告进度。
- 终端进程退出 = 挂载集清零。 这是 TUI 的本质,不做进程托管、不假装会话能活过终端
(
gui的托盘式后台不属于 TUI 的语义)。会话日志本身是持久的,下次/resume能回来。
2.1 为什么曾经有两套
/resume 原本是 tui 式:切换会话后当前会话立刻停止(keepCurrent=false,旧句柄被
dispose),并且在回合运行中直接拒绝。/agentview 是 gui/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.lock(wx独占创建)。锁文件里写的是<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 检查与占用必须是同一步
挂载一个非本进程存活的会话时,顺序必须是:
- 读账本(
readSessionOwners)做一次预检,只用于给用户一句带 pid 的提示; - 未被占用则调用
reserveMount(sessionId):它在跨进程锁内严格重读文件、重新判定冲突, 冲突则一个字节都不写,成功则把自己这条记录(含新会话)拼进当前文件,并给这次操作 留下一枚进程内 pin; - 然后才
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. 不变量(改这块代码时必须保持)
- 一份日志在同一时刻只被一个进程驱动。 准入判定必须在跨进程锁内完成
(
claimMount:锁内重读 + 冲突重查 + 写占用是同一步),而不是"先在锁外检查、再发布"。 同一会话的再次进入必须幂等短路,绝不走会dispose句柄的收养事务。 - 切换会话不销毁会话。 停放,不
dispose;正在跑的回合不因切换而中断。 - 占用不得永久锁死。 任何一条记录都必须能被「pid 消失」回收;禁止引入 "必须手工解锁"的状态。
- 同机语义必须显式。 归属只按 pid;不要用一个写在本机文件里的 host/instance 字段假装能做跨机判定——共享 home 的两台机器需要的是传输层协议,不是本层。
- 锁只能由持有者写入与释放,且只有死掉的持有者会失去它。 每次获取写入
<pid>-<nonce>;失去锁的一方必须放弃提交,释放时只能删自己的锁。pid 还活着的锁 永不回收,文件多旧都不行——接受的代价是 pid 被复用时需要手工删除(见 3.2)。 - 展示读容错,授权读严格。 文件缺失、JSON 损坏、版本不符、字段类型不对—— 这些在界面上一律读作空,清理必须持锁重读后再写,不得提交锁前快照; 而授予挂载的路径必须反过来:只有 ENOENT 算空,其余一律拒绝,且拒绝时不写一个字节。
- 定时器不得阻止退出。
.unref()+ 退出漏斗清理,缺一不可。 - 界面不得与运行时分歧。 能进入与否由运行时说了算;界面只是提前一步把原因讲清楚。
两条路径共用
src/sessions/resumeFailure.ts的同一套措辞。 - 界面不得假设注册表完整。 空注册表 / 未登记目录的会话必须仍可见、仍可恢复; 注册表读取失败也不得吞掉已经成功读到的会话列表;占用状态必须每个 tick 重读, 不得复用宿主的渲染期快照。
- 焦点事实唯一。 会话列表的焦点以 sessionId 为唯一真源,索引由它派生; 渲染、键盘移动与 Enter 必须共用同一份派生结果。